diff --git a/plugins/homeagent-mail-bridge/plugin.go b/plugins/homeagent-mail-bridge/plugin.go index 974f4d1..28365c4 100644 --- a/plugins/homeagent-mail-bridge/plugin.go +++ b/plugins/homeagent-mail-bridge/plugin.go @@ -168,7 +168,14 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { // ─── 注册工具 ─── - s.RegisterTool("read_inbox", sdk.ToolDef{ + // registerTool 包一层只为计数:日志里的工具数必须与实际注册数一致。 + registeredToolCount := 0 + registerTool := func(name string, def sdk.ToolDef, h func(map[string]interface{}) (interface{}, error)) { + registeredToolCount++ + s.RegisterTool(name, def, h) + } + + registerTool("read_inbox", sdk.ToolDef{ Name: "read_inbox", Description: "查阅收件箱中的邮件。收到新邮件通知后应立即调用此工具。每封含 mail_id、发件人、主题、正文与附件清单。", Parameters: map[string]interface{}{ @@ -180,13 +187,13 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { }, }, p.handleReadInbox) - s.RegisterTool("read_mail", sdk.ToolDef{ + registerTool("read_mail", sdk.ToolDef{ Name: "read_mail", Description: "读一封邮件的完整内容,含收件人、抄送清单、附件与每个参与方的可投递地址。", Parameters: oneStringParam("mail_id", "邮件 ID", true), }, p.handleReadMail) - s.RegisterTool("send_mail", sdk.ToolDef{ + registerTool("send_mail", sdk.ToolDef{ Name: "send_mail", Description: "发送邮件。三维地址 name@path.session。回复来信请传 reply_to。", Parameters: map[string]interface{}{ @@ -202,7 +209,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { }, }, p.handleSendMail) - s.RegisterTool("forward_mail", sdk.ToolDef{ + registerTool("forward_mail", sdk.ToolDef{ Name: "forward_mail", Description: "转发一封邮件给新的收件人(自动引用原文与附件)。与回复不同:回复落回原会话,转发按目标地址另行定位会话。", Parameters: map[string]interface{}{ @@ -219,7 +226,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { }, }, p.handleForwardMail) - s.RegisterTool("upload_attachment", sdk.ToolDef{ + registerTool("upload_attachment", sdk.ToolDef{ Name: "upload_attachment", Description: "上传本地文件作为邮件附件。返回 attachment_id,填入 send_mail 的 attachments 字段。", Parameters: map[string]interface{}{ @@ -231,7 +238,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { }, }, p.handleUploadAttachment) - s.RegisterTool("download_attachment", sdk.ToolDef{ + registerTool("download_attachment", sdk.ToolDef{ Name: "download_attachment", Description: "下载附件到本地。", Parameters: map[string]interface{}{ @@ -244,7 +251,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { }, }, p.handleDownloadAttachment) - s.RegisterTool("suggest_address", sdk.ToolDef{ + registerTool("suggest_address", sdk.ToolDef{ Name: "suggest_address", Description: "查询可用收件人地址。不带参数给候选收件人;带 name 给工作目录;name+path 都带则给会话别名。发信前应先用它确认地址。", Parameters: map[string]interface{}{ @@ -256,19 +263,19 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { }, }, p.handleSuggestAddress) - s.RegisterTool("list_contacts", sdk.ToolDef{ + registerTool("list_contacts", sdk.ToolDef{ Name: "list_contacts", Description: "列出自己参与过的全部会话及各自的可投递地址、未读数、剩余往返预算。", Parameters: oneStringParam("limit", "最多列出多少条,默认 20", false), }, p.handleListContacts) - s.RegisterTool("session_participants", sdk.ToolDef{ + registerTool("session_participants", sdk.ToolDef{ Name: "session_participants", Description: "列出某条会话的全部参与方与各自的可投递地址,并标出谁还没回应。", Parameters: oneStringParam("session_id", "会话 ID", true), }, p.handleSessionParticipants) - s.RegisterTool("read_thread", sdk.ToolDef{ + registerTool("read_thread", sdk.ToolDef{ Name: "read_thread", Description: "查看一封邮件所在线索的完整往来(谁回了谁、谁还没回)。", Parameters: map[string]interface{}{ @@ -281,7 +288,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { }, }, p.handleReadThread) - s.RegisterTool("connect_to_server", sdk.ToolDef{ + registerTool("connect_to_server", sdk.ToolDef{ Name: "connect_to_server", Description: "连接到 AgentMail Gateway:登记本机密钥并完成注册。首次安装或换了 Gateway 地址时调用。", Parameters: map[string]interface{}{ @@ -293,12 +300,91 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error { }, }, p.handleConnectToServer) + // ─── 日程 / 待办 ─── + // + // 这一组的价值不在「记事」而在**跨进程的时间**:模型自己没法让一个进程 + // 在未来某刻醒来,插件里的定时器也随 homed 重启一起消失。交给 Gateway + // 之后由数据库与调度器保证,到点发一封邮件把收件方唤起来。 + + registerTool("create_schedule", sdk.ToolDef{ + Name: "create_schedule", + Description: "创建一条日程提醒。到点时 Gateway 会发一封邮件给收件方(默认是你自己)," + + "因此它能跨进程重启生效 —— 比你自己记着时间可靠。" + + "典型用法:稍后检查某件事、提醒另一个 Agent 交东西、周期性巡检。" + + scheduleFieldsHint, + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "title": map[string]interface{}{"type": "string", "description": "日程标题,会成为提醒邮件的主题"}, + "event_time": map[string]interface{}{"type": "string", "description": "事件时间,RFC3339 带时区(2026-09-10T09:00:00+08:00)。不支持「明天」这类相对表述"}, + "description": map[string]interface{}{"type": "string", "description": "补充说明,可作为 {description} 变量填入提醒正文"}, + "reminder_text": map[string]interface{}{"type": "string", "description": "提醒邮件正文模板,支持 {title} {time} {description} 三个变量。留空用默认模板"}, + "remind_before": map[string]interface{}{"type": "number", "description": "提前多少分钟提醒,默认 0(到点才提醒)"}, + "recurrence": map[string]interface{}{"type": "string", "description": "重复规则,默认 none。农历用 lunar_monthly / lunar_yearly"}, + "recurrence_end": map[string]interface{}{"type": "string", "description": "重复到什么时候为止,留空 = 一直重复"}, + "recipients": map[string]interface{}{"type": "string", "description": "收件方,逗号分隔的三维地址(如 dsh,pi@/home/x)。省略 = 发给自己。不能设给人类用户 —— 要通知人请直接 send_mail"}, + "delivery_mode": map[string]interface{}{"type": "string", "description": "多收件人时:separate(默认,各自独立会话互不可见)或 together(首个为主收件人其余抄送,共享一条线索)"}, + }, + "required": []string{"title", "event_time"}, + }, + }, p.handleCreateSchedule) + + registerTool("list_schedules", sdk.ToolDef{ + Name: "list_schedules", + Description: "列出你建的日程(只能看到自己建的)。返回每条的 ID、时间、重复规则与收件方。" + + "改时间或删除前先用它查 ID。", + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "status": map[string]interface{}{"type": "string", "description": "过滤 active(默认)/paused/cancelled/all"}, + "from": map[string]interface{}{"type": "string", "description": "起始时间,默认昨天"}, + "to": map[string]interface{}{"type": "string", "description": "结束时间,默认三个月后"}, + }, + }, + }, p.handleListSchedules) + + registerTool("update_schedule", sdk.ToolDef{ + Name: "update_schedule", + Description: "改一条日程。**只传要改的字段**,省略的保持原值 —— 不要回传全部字段," + + "记错一个就会覆盖掉原有的提醒正文或收件方。" + + "暂停提醒传 status=paused。" + scheduleFieldsHint, + Parameters: map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "event_id": map[string]interface{}{"type": "string", "description": "要改哪条(用 list_schedules 查)"}, + "title": map[string]interface{}{"type": "string", "description": "新标题"}, + "event_time": map[string]interface{}{"type": "string", "description": "新时间,RFC3339 带时区"}, + "description": map[string]interface{}{"type": "string", "description": "新说明"}, + "reminder_text": map[string]interface{}{"type": "string", "description": "新的提醒正文模板"}, + "remind_before": map[string]interface{}{"type": "number", "description": "新的提前分钟数"}, + "recurrence": map[string]interface{}{"type": "string", "description": "新的重复规则"}, + "recurrence_end": map[string]interface{}{"type": "string", "description": "新的重复终止时间"}, + "recipients": map[string]interface{}{"type": "string", "description": "新收件方,逗号分隔。不能改成空"}, + "delivery_mode": map[string]interface{}{"type": "string", "description": "separate 或 together"}, + "status": map[string]interface{}{"type": "string", "description": "active / paused(暂停提醒)/ cancelled"}, + }, + "required": []string{"event_id"}, + }, + }, p.handleUpdateSchedule) + + registerTool("delete_schedule", sdk.ToolDef{ + Name: "delete_schedule", + Description: "删掉一条日程,之后不再提醒。只想临时停掉请用 update_schedule 传 status=paused。", + Parameters: oneStringParam("event_id", "要删哪条(用 list_schedules 查)", true), + }, p.handleDeleteSchedule) + // 注册输出通道 s.RegisterOutputChannel("homeagent", sdk.CapText|sdk.CapFile, "发送邮件。meta JSON 格式:{to, subject, reply_to},type: text", sdk.ChannelDef{}, p.handleOutputChannel) - log.Printf("[homeagent-mail-bridge] 注册完成(%d 个工具),等待 Gateway SSE", 13) + // 数量从 RegisterTool 的调用数派生,不硬编码。 + // + // 之前这里写死 13,而实际注册的是 11 —— 排查「工具没生效」时日志说 13、 + // 平台说 11,两个数字都不可信,白花了一轮时间。加工具时忘改常量是必然的, + // 所以让它没有机会写错。 + log.Printf("[homeagent-mail-bridge] 注册完成(%d 个工具 + 1 个输出通道),等待 Gateway SSE", + registeredToolCount) // 启动心跳 + SSE(后台 goroutine) go p.heartbeatLoop() @@ -1048,6 +1134,58 @@ func (p *Plugin) post(path string, payload interface{}, out interface{}) error { return nil } +func (p *Plugin) put(path string, payload interface{}, out interface{}) error { + data, err := json.Marshal(payload) + if err != nil { + return err + } + url := path + if !strings.HasPrefix(path, "http") { + url = p.gwURL + "/api/v1" + path + } + req, err := http.NewRequest("PUT", url, bytes.NewReader(data)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+p.key) + resp, err := p.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("PUT %s HTTP %d: %s", path, resp.StatusCode, string(body)) + } + if out != nil { + return json.NewDecoder(resp.Body).Decode(out) + } + return nil +} + +func (p *Plugin) delete(path string) error { + url := path + if !strings.HasPrefix(path, "http") { + url = p.gwURL + "/api/v1" + path + } + req, err := http.NewRequest("DELETE", url, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+p.key) + resp, err := p.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("DELETE %s HTTP %d: %s", path, resp.StatusCode, string(body)) + } + return nil +} + func (p *Plugin) markRead(ids []string) { data, _ := json.Marshal(map[string]interface{}{"mail_ids": ids}) req, err := http.NewRequest("POST", p.gwURL+"/api/v1/mail/read", bytes.NewReader(data)) diff --git a/plugins/homeagent-mail-bridge/schedule.go b/plugins/homeagent-mail-bridge/schedule.go new file mode 100644 index 0000000..40886ac --- /dev/null +++ b/plugins/homeagent-mail-bridge/schedule.go @@ -0,0 +1,472 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +// 日程与待办工具。 +// +// # 为什么要有这一组 +// +// 在这之前「明天九点提醒我看 CI 结果」只能靠插件进程里的定时器 —— +// homed 一重启定时器就消失,那条提醒静默不见且无处留痕。 +// 交给 Gateway 之后由数据库与调度器保证:插件重启、换机器都不影响。 +// +// 更重要的是它让**跨 Agent 的任务交接**成立:模型可以给 dsh 设一条 +// 「明天交周报」的提醒,到点由 Gateway 发一封邮件把 dsh 唤起来。 +// 这件事模型自己做不到 —— 它没法让另一个进程在未来某刻醒来。 +// +// # 时间格式是这组工具最容易出错的地方 +// +// 模型写时间有三种典型错法:只给「明天九点」这样的自然语言、给了 +// RFC3339 但忘了时区、年份写成过去。前两种在这里被 parseEventTime 兜住, +// 第三种由服务端拦(一次性日程设在过去会立刻触发,几乎总是笔误)。 +// +// 刻意**不**接受自然语言:解析「下周三」需要知道模型以为的今天是哪天, +// 而它的上下文里那个日期可能是错的。要求显式时间戳会让它先去查当前时间, +// 那一步本身就消除了大部分歧义。 + +// scheduleFieldsHint 是几个工具共用的参数说明。 +// +// 抽出来是因为这段话必须逐字一致:模型看到 create 与 update 的描述不同时, +// 会以为两者语义有别,于是在 update 里重复传所有字段(那正是我们想避免的)。 +const scheduleFieldsHint = "时间用 RFC3339(如 2026-09-10T09:00:00+08:00,必须带时区偏移)。" + + "recurrence 可选 none/daily/weekly/monthly/yearly/lunar_monthly/lunar_yearly," + + "其中 lunar_* 按农历推进(每农历月同一日 / 每农历年同月同日,适合农历生日与祭日)。" + +// handleCreateSchedule 建一条日程提醒。 +func (p *Plugin) handleCreateSchedule(args map[string]interface{}) (interface{}, error) { + title := strings.TrimSpace(argString(args, "title")) + if title == "" { + return nil, fmt.Errorf("必须给 title") + } + when, err := parseEventTime(argString(args, "event_time")) + if err != nil { + return nil, err + } + + payload := map[string]interface{}{ + "title": title, + "event_time": when.Format(time.RFC3339), + } + if v := strings.TrimSpace(argString(args, "description")); v != "" { + payload["description"] = v + } + if v := strings.TrimSpace(argString(args, "reminder_text")); v != "" { + payload["reminder_text"] = v + } + if v := strings.TrimSpace(argString(args, "recurrence")); v != "" { + payload["recurrence"] = v + } + if n, ok := argInt(args, "remind_before"); ok { + payload["remind_before"] = n + } + // 收件人:逗号分隔的地址列表。省略 = 发给自己(最常见的用法, + // 每次都要求写出自己的名字只会让模型忘记然后拿到 400)。 + recipients := parseRecipients(argString(args, "recipients")) + if len(recipients) > 0 { + payload["recipients"] = recipients + } + if v := strings.TrimSpace(argString(args, "delivery_mode")); v != "" { + payload["delivery_mode"] = v + } + if v := strings.TrimSpace(argString(args, "recurrence_end")); v != "" { + end, eErr := parseEventTime(v) + if eErr != nil { + return nil, fmt.Errorf("recurrence_end 解析失败:%w", eErr) + } + payload["recurrence_end"] = end.Format(time.RFC3339) + } + + var ev scheduleEvent + if err := p.post("/agent/calendar/events", payload, &ev); err != nil { + return nil, err + } + + lines := []string{ + fmt.Sprintf("日程已创建:%s", ev.Title), + fmt.Sprintf(" 时间:%s", formatEventWhen(ev)), + fmt.Sprintf(" 提醒发给:%s", strings.Join(ev.effectiveRecipients(), ", ")), + } + if ev.Recurrence != "" && ev.Recurrence != "none" { + lines = append(lines, fmt.Sprintf(" 重复:%s", describeRecurrence(ev.Recurrence))) + } + if len(ev.effectiveRecipients()) > 1 { + lines = append(lines, fmt.Sprintf(" 投递方式:%s", describeDelivery(ev.DeliveryMode))) + } + // 回传 id:改时间或删掉都要用它,不给的话模型只能再 list 一次 + lines = append(lines, fmt.Sprintf(" 日程 ID:%s", ev.EventID)) + return textResult(strings.Join(lines, "\n")), nil +} + +// handleListSchedules 列出自己建的日程。 +func (p *Plugin) handleListSchedules(args map[string]interface{}) (interface{}, error) { + q := "?status=" + argStringOr(args, "status", "active") + if v := strings.TrimSpace(argString(args, "from")); v != "" { + if t, err := parseEventTime(v); err == nil { + q += "&from=" + t.Format(time.RFC3339) + } + } + if v := strings.TrimSpace(argString(args, "to")); v != "" { + if t, err := parseEventTime(v); err == nil { + q += "&to=" + t.Format(time.RFC3339) + } + } + + var res struct { + Events []scheduleEvent `json:"events"` + ActiveLimit int `json:"active_limit"` + } + if err := p.get(p.gwURL+"/api/v1/agent/calendar/events"+q, &res); err != nil { + return nil, err + } + + if len(res.Events) == 0 { + return textResult("你还没有建过日程。用 create_schedule 建一条。"), nil + } + + var sb strings.Builder + fmt.Fprintf(&sb, "你的日程(%d 条", len(res.Events)) + // 接近上限时主动说出来。只在撞墙时才报错等于让模型一直蒙在鼓里, + // 而它撞墙那一刻往往正在做别的事,没有余裕去清理。 + if res.ActiveLimit > 0 { + fmt.Fprintf(&sb, ",生效上限 %d", res.ActiveLimit) + if len(res.Events)*4 >= res.ActiveLimit*3 { + sb.WriteString(" —— 已接近上限,建议删掉不需要的") + } + } + sb.WriteString("):\n") + + for _, ev := range res.Events { + fmt.Fprintf(&sb, "\n· %s\n", ev.Title) + fmt.Fprintf(&sb, " 时间:%s\n", formatEventWhen(ev)) + if ev.Recurrence != "" && ev.Recurrence != "none" { + fmt.Fprintf(&sb, " 重复:%s\n", describeRecurrence(ev.Recurrence)) + } + if r := ev.effectiveRecipients(); len(r) > 0 { + fmt.Fprintf(&sb, " 发给:%s", strings.Join(r, ", ")) + if len(r) > 1 { + fmt.Fprintf(&sb, "(%s)", describeDelivery(ev.DeliveryMode)) + } + sb.WriteString("\n") + } + if ev.Status != "" && ev.Status != "active" { + fmt.Fprintf(&sb, " 状态:%s\n", describeStatus(ev.Status)) + } + fmt.Fprintf(&sb, " ID:%s\n", ev.EventID) + } + return textResult(sb.String()), nil +} + +// handleUpdateSchedule 改一条日程。 +// +// **只传要改的字段**:服务端按字段是否出现决定改不改,省略的保持原值。 +// 这一点必须在工具描述里写清楚 —— 模型的默认倾向是回传它记得的全部字段, +// 而它记错一个就会把提醒正文或收件人覆盖掉,且没有任何报错。 +func (p *Plugin) handleUpdateSchedule(args map[string]interface{}) (interface{}, error) { + id := strings.TrimSpace(argString(args, "event_id")) + if id == "" { + return nil, fmt.Errorf("必须给 event_id(用 list_schedules 查)") + } + + payload := map[string]interface{}{} + if v, ok := args["title"].(string); ok { + payload["title"] = strings.TrimSpace(v) + } + if v, ok := args["description"].(string); ok { + payload["description"] = v + } + if v, ok := args["reminder_text"].(string); ok { + payload["reminder_text"] = v + } + if v, ok := args["recurrence"].(string); ok && strings.TrimSpace(v) != "" { + payload["recurrence"] = strings.TrimSpace(v) + } + if v, ok := args["status"].(string); ok && strings.TrimSpace(v) != "" { + payload["status"] = strings.TrimSpace(v) + } + if v, ok := args["delivery_mode"].(string); ok && strings.TrimSpace(v) != "" { + payload["delivery_mode"] = strings.TrimSpace(v) + } + if n, ok := argInt(args, "remind_before"); ok { + payload["remind_before"] = n + } + if v, ok := args["event_time"].(string); ok && strings.TrimSpace(v) != "" { + t, err := parseEventTime(v) + if err != nil { + return nil, err + } + payload["event_time"] = t.Format(time.RFC3339) + } + if v, ok := args["recurrence_end"].(string); ok && strings.TrimSpace(v) != "" { + t, err := parseEventTime(v) + if err != nil { + return nil, fmt.Errorf("recurrence_end 解析失败:%w", err) + } + payload["recurrence_end"] = t.Format(time.RFC3339) + } + if v, ok := args["recipients"].(string); ok && strings.TrimSpace(v) != "" { + payload["recipients"] = parseRecipients(v) + } + + if len(payload) == 0 { + return nil, fmt.Errorf("没有要改的字段。只传需要修改的那几个即可(其余保持原值)") + } + + var ev scheduleEvent + if err := p.put("/agent/calendar/events/"+id, payload, &ev); err != nil { + return nil, err + } + return textResult(strings.Join([]string{ + fmt.Sprintf("日程已更新:%s", ev.Title), + fmt.Sprintf(" 时间:%s", formatEventWhen(ev)), + fmt.Sprintf(" 重复:%s", describeRecurrence(ev.Recurrence)), + fmt.Sprintf(" 发给:%s", strings.Join(ev.effectiveRecipients(), ", ")), + fmt.Sprintf(" 状态:%s", describeStatus(ev.Status)), + }, "\n")), nil +} + +// handleDeleteSchedule 删一条日程。 +func (p *Plugin) handleDeleteSchedule(args map[string]interface{}) (interface{}, error) { + id := strings.TrimSpace(argString(args, "event_id")) + if id == "" { + return nil, fmt.Errorf("必须给 event_id(用 list_schedules 查)") + } + if err := p.delete("/agent/calendar/events/" + id); err != nil { + return nil, err + } + return textResult(fmt.Sprintf("日程 %s 已删除,不再提醒。", id)), nil +} + +// ─── 数据结构与格式化 ─── + +// scheduleEvent 是 Gateway 返回的事件。 +// +// 只声明用到的字段:多声明的字段一旦服务端改名会静默变成零值, +// 而少声明的字段 json 解码本来就忽略。 +type scheduleEvent struct { + EventID string `json:"event_id"` + Title string `json:"title"` + Description string `json:"description"` + ReminderText string `json:"reminder_text"` + AgentName string `json:"agent_name"` + ToAddress string `json:"to_address"` + Recipients []string `json:"recipients"` + DeliveryMode string `json:"delivery_mode"` + EventTime string `json:"event_time"` + RemindBefore int `json:"remind_before"` + Recurrence string `json:"recurrence"` + Status string `json:"status"` +} + +// effectiveRecipients 与服务端 EffectiveRecipients() 同一条兜底链。 +// +// 必须有:服务端对旧数据会退回 to_address/agent_name, +// 这里若只读 recipients,那些事件会显示成「发给:」后面空白。 +func (e scheduleEvent) effectiveRecipients() []string { + out := make([]string, 0, len(e.Recipients)) + for _, r := range e.Recipients { + if r = strings.TrimSpace(r); r != "" { + out = append(out, r) + } + } + if len(out) > 0 { + return out + } + if v := strings.TrimSpace(e.ToAddress); v != "" { + return []string{v} + } + if v := strings.TrimSpace(e.AgentName); v != "" { + return []string{v} + } + return nil +} + +// formatEventWhen 把事件时间渲染成本地时间,并写出实际提醒时刻。 +// +// 提醒时刻必须单独写出来:模型设了「提前 30 分钟」之后, +// 只看事件时间会以为那才是收到通知的时候。 +func formatEventWhen(e scheduleEvent) string { + t, err := time.Parse(time.RFC3339, e.EventTime) + if err != nil { + return e.EventTime + } + local := t.Local() + out := local.Format("2006-01-02 15:04 (Mon)") + if e.RemindBefore > 0 { + remind := local.Add(-time.Duration(e.RemindBefore) * time.Minute) + out += fmt.Sprintf(",提前 %s 提醒(%s)", + describeMinutes(e.RemindBefore), remind.Format("01-02 15:04")) + } + return out +} + +func describeMinutes(min int) string { + switch { + case min <= 0: + return "0 分钟" + case min < 60: + return strconv.Itoa(min) + " 分钟" + case min%60 == 0: + return strconv.Itoa(min/60) + " 小时" + default: + return fmt.Sprintf("%d 小时 %d 分钟", min/60, min%60) + } +} + +func describeRecurrence(r string) string { + switch r { + case "", "none": + return "不重复" + case "daily": + return "每天" + case "weekly": + return "每周" + case "monthly": + return "每月(公历同一日)" + case "yearly": + return "每年(公历同月日)" + case "lunar_monthly": + return "每农历月(同一日)" + case "lunar_yearly": + return "每农历年(同月同日)" + default: + return r + } +} + +func describeDelivery(m string) string { + if m == "together" { + return "一起发送:首个是主收件人,其余抄送,共享同一条线索" + } + return "分别发送:各自独立会话,互相看不到" +} + +func describeStatus(s string) string { + switch s { + case "paused": + return "已暂停(不再触发提醒)" + case "cancelled": + return "已取消" + default: + return "生效中" + } +} + +// ─── 参数解析 ─── + +// parseEventTime 解析时间字符串。 +// +// 接受三种形态,按严格程度递减: +// +// 1. 完整 RFC3339 带时区(`2026-09-10T09:00:00+08:00`)—— 推荐 +// 2. 不带时区(`2026-09-10T09:00:00` / `2026-09-10 09:00`)—— 按**本机时区**解释 +// 3. 只有日期(`2026-09-10`)—— 当天 09:00 +// +// 第 2 种要兜住是因为模型经常忘时区,而拒绝它只会让它重试同样的写法。 +// 按本机时区解释是唯一合理的猜测:Agent 与 Gateway 跑在同一台机器上, +// 人说「九点」指的就是这台机器上的九点。 +// +// **刻意不接受自然语言**(「明天九点」):解析它需要知道模型以为今天是哪天, +// 而它上下文里那个日期常常是错的 —— 一个静默偏移一天的提醒比报错糟得多。 +func parseEventTime(raw string) (time.Time, error) { + s := strings.TrimSpace(raw) + if s == "" { + return time.Time{}, fmt.Errorf("必须给 event_time,例如 2026-09-10T09:00:00+08:00") + } + + // 带时区的标准形态 + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t, nil + } + + // 无时区:按本机时区解释 + for _, layout := range []string{ + "2006-01-02T15:04:05", + "2006-01-02T15:04", + "2006-01-02 15:04:05", + "2006-01-02 15:04", + } { + if t, err := time.ParseInLocation(layout, s, time.Local); err == nil { + return t, nil + } + } + + // 只有日期:默认上午九点(比零点有用 —— 零点的提醒没人看) + if t, err := time.ParseInLocation("2006-01-02", s, time.Local); err == nil { + return t.Add(9 * time.Hour), nil + } + + return time.Time{}, fmt.Errorf( + "时间 %q 无法解析。请用 RFC3339 带时区,例如 2026-09-10T09:00:00+08:00;"+ + "当前本机时间是 %s。不支持「明天」「下周三」这类相对表述 —— "+ + "请先确认当前日期再算出具体时间戳。", + s, time.Now().Format(time.RFC3339)) +} + +// parseRecipients 把逗号分隔的地址串切成列表。 +// +// 用字符串而不是数组参数:几个平台的工具参数 schema 对数组的支持不一致, +// 而逗号分隔在所有平台上都是一个普通 string。中英文逗号都接受 —— +// 中文输入法下打出「,」是常态,因为这个报错去改提示词不值得。 +func parseRecipients(raw string) []string { + s := strings.TrimSpace(raw) + if s == "" { + return nil + } + s = strings.ReplaceAll(s, ",", ",") + parts := strings.Split(s, ",") + out := make([]string, 0, len(parts)) + seen := map[string]bool{} + for _, x := range parts { + x = strings.TrimSpace(x) + if x == "" || seen[x] { + continue + } + seen[x] = true + out = append(out, x) + } + return out +} + +func argString(args map[string]interface{}, key string) string { + if v, ok := args[key].(string); ok { + return v + } + return "" +} + +func argStringOr(args map[string]interface{}, key, fallback string) string { + if v := strings.TrimSpace(argString(args, key)); v != "" { + return v + } + return fallback +} + +// argInt 读一个整数参数。 +// +// JSON 数字解码成 float64,但模型也常传字符串("30")—— +// 两种都收,否则「提前 30 分钟」会因为引号而静默失效。 +func argInt(args map[string]interface{}, key string) (int, bool) { + switch v := args[key].(type) { + case float64: + return int(v), true + case int: + return v, true + case string: + if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil { + return n, true + } + } + return 0, false +} + +// textResult 包一个纯文本工具结果。 +func textResult(text string) map[string]interface{} { + return map[string]interface{}{ + "content": []map[string]interface{}{{"type": "text", "text": text}}, + } +}