Files
MailUI4Agents/plugins/homeagent-mail-bridge/tools.go
JianFeeeee 473c46659a fix(homeagent): 删除 plugin.go 里与 tools.go 重复的 6 个方法
plugin.go 与 tools.go 各自声明了同名工具处理器。逐个 diff 后保留
tools.go 的版本 —— 它严格更丰富:read_thread 多 ParentHid → parent_hidden
并区分「父邮件无权查看」与「父邮件尚未加载」,suggest_address 多解析
candidates,connect_to_server 多一条注释。

plugin.go 删除 handleConnectToServer / handleForwardMail /
handleSuggestAddress / handleListContacts / handleSessionParticipants /
handleReadThread + minInt。

tools.go 删除本地 min():go.mod 是 go 1.25.0,内置 min 可用,
本地定义只是遮蔽。

部署验证:11 项工具注册(日志里硬编码的「13 个工具」是错的数字)。
2026-09-03 21:11:15 +08:00

454 lines
13 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"fmt"
"strings"
)
// ─── 工具定义(与 opencode/dsh/pi 同源逻辑Go 版本)───
//
// 所有工具都是对 Gateway REST API 的薄封装HTTP → 渲染 → 模型可读文本。
// 与 JS 插件的区别仅在 HTTP 辅助函数p.get / p.post行为完全一致。
func (p *Plugin) handleReadMail(args map[string]interface{}) (interface{}, error) {
mid, _ := args["mail_id"].(string)
if mid == "" {
return nil, fmt.Errorf("缺少 mail_id")
}
var data struct {
Mail struct {
FromName string `json:"from_name"`
ToName string `json:"to_name"`
ToWorkspace string `json:"to_workspace"`
Subject string `json:"subject"`
Body string `json:"body"`
CCList []struct {
Name string `json:"name"`
Path string `json:"path"`
Raw string `json:"raw"`
} `json:"cc_list"`
Attachments []struct {
Filename string `json:"filename"`
SizeBytes int `json:"size_bytes"`
AttachmentID string `json:"attachment_id"`
} `json:"attachments"`
} `json:"mail"`
SessionAlias string `json:"session_alias"`
ReplyAddress string `json:"reply_address"`
SelfAddress string `json:"self_address"`
Participants []struct {
Name string `json:"name"`
Path string `json:"path"`
Roles []string `json:"roles"`
Address string `json:"address"`
IsSelf bool `json:"is_self"`
} `json:"participants"`
}
if err := p.get(p.gwURL+"/api/v1/agent/mail/"+mid, &data); err != nil {
return nil, err
}
var sb strings.Builder
fmt.Fprintf(&sb, "发件人: %s\n", data.Mail.FromName)
if data.Mail.ToWorkspace != "" {
fmt.Fprintf(&sb, "收件人: %s@%s\n", data.Mail.ToName, data.Mail.ToWorkspace)
} else {
fmt.Fprintf(&sb, "收件人: %s\n", data.Mail.ToName)
}
fmt.Fprintf(&sb, "主题: %s\n", data.Mail.Subject)
fmt.Fprintf(&sb, "会话: #%ssession_id: %s\n", data.SessionAlias, data.Mail.FromName)
if len(data.Mail.CCList) > 0 {
names := make([]string, 0, len(data.Mail.CCList))
for _, c := range data.Mail.CCList {
names = append(names, c.Raw)
}
fmt.Fprintf(&sb, "抄送: %s\n", strings.Join(names, "、"))
}
if len(data.Mail.Attachments) > 0 {
fmt.Fprintf(&sb, "附件:\n")
for _, a := range data.Mail.Attachments {
fmt.Fprintf(&sb, " - %s (%.1fKB, id=%s)\n", a.Filename, float64(a.SizeBytes)/1024, a.AttachmentID)
}
}
fmt.Fprintf(&sb, "\n%s\n", data.Mail.Body)
if len(data.Participants) > 0 {
fmt.Fprintf(&sb, "\n可投递地址:\n")
for _, pt := range data.Participants {
if pt.Address != "" && !pt.IsSelf {
fmt.Fprintf(&sb, " - %s (%s)\n", pt.Address, strings.Join(pt.Roles, "/"))
}
}
}
if data.ReplyAddress != "" {
fmt.Fprintf(&sb, "回信给发件人用 %s或传 reply_to=%s\n", data.ReplyAddress, data.Mail.FromName)
}
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": sb.String()}},
}, nil
}
func (p *Plugin) handleForwardMail(args map[string]interface{}) (interface{}, error) {
mid, _ := args["mail_id"].(string)
to, _ := args["to"].(string)
comment, _ := args["comment"].(string)
cc, _ := args["cc"].(string)
subj, _ := args["subject"].(string)
sa, _ := args["session_alias"].(string)
if mid == "" || to == "" {
return nil, fmt.Errorf("缺少 mail_id 和 to")
}
payload := map[string]interface{}{
"to": to,
"comment": comment,
"cc": cc,
"subject": subj,
"session_alias": sa,
}
var result map[string]interface{}
if err := p.post("/mail/"+mid+"/forward", payload, &result); err != nil {
return nil, err
}
text := fmt.Sprintf("已转发。新 Mail ID: %sSession: %s", result["mail_id"], result["session_id"])
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": text}},
}, nil
}
func (p *Plugin) handleSuggestAddress(args map[string]interface{}) (interface{}, error) {
name, _ := args["name"].(string)
path, _ := args["path"].(string)
name = strings.TrimSpace(name)
path = strings.TrimSpace(path)
qs := ""
if name != "" {
qs += "name=" + name
}
if path != "" {
if qs != "" {
qs += "&"
}
qs += "path=" + path
}
var data struct {
Kind string `json:"kind"`
Suggestions []string `json:"suggestions"`
Addresses []string `json:"addresses"`
Candidates []struct {
Alias string `json:"alias"`
Title string `json:"title"`
Unread int `json:"unread"`
Source string `json:"source"`
} `json:"candidates"`
}
if err := p.get(p.gwURL+"/api/v1/agent/contacts/suggest?"+qs, &data); err != nil {
return nil, err
}
var sb strings.Builder
switch data.Kind {
case "name":
sb.WriteString(fmt.Sprintf("可投递的收件人(%d 个):\n", len(data.Suggestions)))
for _, n := range data.Suggestions {
fmt.Fprintf(&sb, "- %s\n", n)
}
sb.WriteString("\n下一步用 suggest_address 带上 name 查它可用的工作目录path 位)。")
case "path":
if len(data.Suggestions) == 0 {
fmt.Fprintf(&sb, "%s 没有记录在案的工作目录。\npath 位可以留空。", name)
} else {
fmt.Fprintf(&sb, "%s 用过的工作目录(按最近使用排序):\n", name)
for _, p := range data.Suggestions {
fmt.Fprintf(&sb, "- %s\n", p)
}
}
default:
// session
existing := 0
for _, a := range data.Suggestions {
if a != "new" {
existing++
}
}
if existing == 0 {
fmt.Fprintf(&sb, "%s@%s 下还没有可续谈的会话。", name, path)
} else {
fmt.Fprintf(&sb, "%s@%s 下可续谈的会话:\n", name, path)
for i, alias := range data.Suggestions {
if alias == "new" {
continue
}
addr := ""
if i < len(data.Addresses) {
addr = data.Addresses[i]
}
fmt.Fprintf(&sb, "- %s\n", addr)
}
}
}
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": sb.String()}},
}, nil
}
func (p *Plugin) handleListContacts(args map[string]interface{}) (interface{}, error) {
limit := 20
if v, ok := args["limit"].(float64); ok && v > 0 {
limit = int(v)
}
var data struct {
Contacts []struct {
Address string `json:"address"`
Subject string `json:"subject"`
Unread int `json:"unread_count"`
MaxRounds int `json:"max_rounds"`
UsedRounds int `json:"used_rounds"`
Alias string `json:"session_alias"`
} `json:"contacts"`
}
if err := p.get(p.gwURL+"/api/v1/agent/contacts", &data); err != nil {
return nil, err
}
if len(data.Contacts) == 0 {
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": "还没有任何往来会话。"}},
}, nil
}
// 按未读优先排序
for i := 0; i < len(data.Contacts)-1; i++ {
for j := i + 1; j < len(data.Contacts); j++ {
if data.Contacts[j].Unread > data.Contacts[i].Unread {
data.Contacts[i], data.Contacts[j] = data.Contacts[j], data.Contacts[i]
}
}
}
var sb strings.Builder
n := limit
if n > len(data.Contacts) {
n = len(data.Contacts)
}
fmt.Fprintf(&sb, "往来会话(共 %d 条):\n", len(data.Contacts))
for _, c := range data.Contacts[:n] {
bits := []string{}
if c.Unread > 0 {
bits = append(bits, fmt.Sprintf("%d 封未读", c.Unread))
}
if c.Subject != "" {
bits = append(bits, c.Subject)
}
if c.MaxRounds > 0 {
left := c.MaxRounds - c.UsedRounds
if left < 0 {
left = 0
}
bits = append(bits, fmt.Sprintf("剩 %d/%d 个来回", left, c.MaxRounds))
}
extra := ""
if len(bits) > 0 {
extra = fmt.Sprintf(" %s", strings.Join(bits, ""))
}
fmt.Fprintf(&sb, "- %s%s\n", c.Address, extra)
}
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": sb.String()}},
}, nil
}
func (p *Plugin) handleSessionParticipants(args map[string]interface{}) (interface{}, error) {
sid, _ := args["session_id"].(string)
if sid == "" {
return nil, fmt.Errorf("缺少 session_id")
}
var data struct {
SessionAlias string `json:"session_alias"`
Participants []struct {
Name string `json:"name"`
Path string `json:"path"`
Roles []string `json:"roles"`
IsSelf bool `json:"is_self"`
MailCount int `json:"mail_count"`
Address string `json:"address"`
} `json:"participants"`
}
if err := p.get(p.gwURL+"/api/v1/agent/sessions/"+sid+"/participants", &data); err != nil {
return nil, err
}
if len(data.Participants) == 0 {
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": "该会话还没有参与方。"}},
}, nil
}
var sb strings.Builder
fmt.Fprintf(&sb, "会话 #%s 的参与方:\n", data.SessionAlias)
for _, pt := range data.Participants {
tags := []string{}
if pt.IsSelf {
tags = append(tags, "就是你")
}
if len(pt.Roles) > 0 {
tags = append(tags, strings.Join(pt.Roles, "/"))
}
if pt.MailCount == 0 && !pt.IsSelf {
tags = append(tags, "尚未回应")
}
extra := ""
if len(tags) > 0 {
extra = fmt.Sprintf(" [%s]", strings.Join(tags, ""))
}
fmt.Fprintf(&sb, "- %s %s%s\n", pt.Name, pt.Address, extra)
}
sb.WriteString("\n要联系其中某一方把它的地址原样填进 send_mail 的 to。")
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": sb.String()}},
}, nil
}
func (p *Plugin) handleReadThread(args map[string]interface{}) (interface{}, error) {
mid, _ := args["mail_id"].(string)
if mid == "" {
return nil, fmt.Errorf("缺少 mail_id")
}
offset := ""
if v, ok := args["offset"].(float64); ok && v > 0 {
offset = fmt.Sprintf("?offset=%d", int(v))
}
var data struct {
Total int `json:"total"`
Hidden int `json:"hidden"`
HasMore bool `json:"has_more"`
NextOff int `json:"next_offset"`
AnchorID string `json:"anchor_mail_id"`
Nodes []struct {
MailID string `json:"mail_id"`
FromName string `json:"from_name"`
ToName string `json:"to_name"`
Subject string `json:"subject"`
Depth int `json:"depth"`
Detached bool `json:"detached"`
ParentHid bool `json:"parent_hidden"`
} `json:"nodes"`
}
if err := p.get(p.gwURL+"/api/v1/agent/mail/"+mid+"/thread"+offset, &data); err != nil {
return nil, err
}
if len(data.Nodes) == 0 {
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": "这条线索上没有可见的邮件。"}},
}, nil
}
var sb strings.Builder
fmt.Fprintf(&sb, "线索共 %d 封", data.Total)
if data.Hidden > 0 {
fmt.Fprintf(&sb, "(另有 %d 封无权查看)", data.Hidden)
}
sb.WriteString("\n")
for _, n := range data.Nodes {
indent := ""
if n.Depth > 0 {
indent = strings.Repeat(" ", min(n.Depth, 8))
}
marks := []string{}
if n.MailID == data.AnchorID {
marks = append(marks, "当前这封")
}
if n.Detached {
if n.ParentHid {
marks = append(marks, "父邮件无权查看")
} else {
marks = append(marks, "父邮件尚未加载")
}
}
extra := ""
if len(marks) > 0 {
extra = fmt.Sprintf(" (%s)", strings.Join(marks, ""))
}
fmt.Fprintf(&sb, "%s- %s → %s: %s [%s]%s\n",
indent, n.FromName, n.ToName, n.Subject, n.MailID, extra)
}
if data.HasMore {
fmt.Fprintf(&sb, "\n还有更多用 offset=%d 继续取。\n", data.NextOff)
}
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": sb.String()}},
}, nil
}
// oneStringParam 给只有一个参数的工具生成 schema。
//
// 单独提出来不是为了省字数,而是因为手写 JSON Schema 字面量很容易漏掉
// `"type": "object"` 或把 required 写成字符串而不是数组 —— 那类错误不会
// 在编译期暴露,而是让模型收到一个它无法调用的工具。
func oneStringParam(name, desc string, required bool) map[string]interface{} {
schema := map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
name: map[string]interface{}{"type": "string", "description": desc},
},
}
if required {
schema["required"] = []string{name}
}
return schema
}
// handleConnectToServer 重新登记密钥并注册。
//
// 成功后把新坐标写回 p当场生效 —— 不用等重启。这是这个工具存在的全部意义:
// 若还要重启才生效,人直接改环境变量就行了,不需要给模型一个工具。
func (p *Plugin) handleConnectToServer(args map[string]interface{}) (interface{}, error) {
url := p.gwURL
if v, ok := args["gateway_url"].(string); ok && strings.TrimSpace(v) != "" {
url = strings.TrimRight(strings.TrimSpace(v), "/")
}
key := p.key
if v, ok := args["key_token"].(string); ok && strings.TrimSpace(v) != "" {
key = strings.TrimSpace(v)
}
if key == "" {
return nil, fmt.Errorf("没有可用的密钥:请传 key_token或在 AGENTMAIL_AGENT_KEY 环境变量里配置")
}
// 用候选坐标试注册,成功了才写回 —— 失败时不该把原本能用的配置改坏
probe := &Plugin{agentName: p.agentName, gwURL: url, key: key, client: p.client}
if err := probe.register(); err != nil {
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": strings.Join([]string{
fmt.Sprintf("连接失败:%v", err),
"",
"若提示密钥无效,请让管理员在 AgentMail 后台「Agent 密钥」中登记:",
key,
}, "\n")}},
}, nil
}
p.gwURL = url
p.key = key
return map[string]interface{}{
"content": []map[string]interface{}{{"type": "text", "text": fmt.Sprintf("已连接 %s注册为 %s。", url, p.agentName)}},
}, nil
}