Files
MailUI4Agents/plugins/homeagent-mail-bridge/tools.go
JianFeeeee a44fd6949b feat: 权限档位体系(三档 plan/workspace/full + 四桥 from_session_id)
L2 核心改动:sessions 表补 permission_mode / permission_enforcement 两列
(sqlite + pg 同步),三桥 lib/permission-mode.js 翻译档位到平台原生配置,
homeagent advisory 模式提示词告知模型实际强制力。四桥全部携带 from_session_id
供 relay 去重与会话回溯。

FromHuman / ToHuman 判据已加入心跳 payload 与 notify/mail.go。
2026-09-06 15:16:49 +08:00

455 lines
14 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 (%s, id=%s)\n", a.Filename, formatSize(int64(a.SizeBytes)), a.AttachmentID)
}
sb.WriteString(" 用 download_attachment 取回(传 attachment_id 与 save_path\n")
}
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
}