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, "会话: #%s(session_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: %s,Session: %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 }