Files
MailUI4Agents/plugins/homeagent-mail-bridge/schedule.go
JianFeeeee 5b76ac5c57 feat(homeagent): 四个日程工具 + 工具计数不再硬编码
create_schedule / list_schedules / update_schedule / delete_schedule,
插件从 11 个工具变 15 个。

时间格式是这组工具最容易出错的地方,parseEventTime 接受三种形态:
完整 RFC3339(推荐)、无时区(按本机时区解释)、只有日期(当天 9 点,
比零点有用 —— 零点的提醒没人看)。

**刻意不接受自然语言**(「明天九点」):解析它需要知道模型以为今天是哪天,
而它上下文里那个日期常常是错的 —— 一个静默偏移一天的提醒比报错糟得多。
解析失败的报文里带上当前本机时间,让它能自己算。

其他细节:
- recipients 用逗号分隔的 string 而非数组:几个平台对数组参数的 schema
  支持不一致,而逗号分隔在所有平台上都是普通 string。中英文逗号都收 ——
  中文输入法下打出「,」是常态。
- argInt 同时收 float64 与字符串:"30" 带引号会让「提前 30 分钟」静默失效。
- update 的工具描述里写明「只传要改的字段」:模型的默认倾向是回传它记得的
  全部字段,记错一个就覆盖掉原有的提醒正文或收件方。
- list 在接近上限(>=75%)时主动提示清理,而不是等撞墙 —— 撞墙那一刻
  模型往往正在做别的事,没有余裕去清理。
- scheduleEvent.effectiveRecipients() 与服务端同一条兜底链:只读
  recipients 会让旧数据显示成「发给:」后面空白。

**工具计数从 RegisterTool 的调用数派生,不硬编码。**
之前写死 13 而实际注册 11 —— 排查「工具没生效」时日志说 13、平台说 11,
两个数字都不可信,白花了一轮时间。加工具时忘改常量是必然的,
所以让它没有机会写错。

plugin.go 另补 put/delete 两个 HTTP 辅助(原来只有 get/post)。

已部署验证:homed 日志「注册完成(15 个工具 + 1 个输出通道)」,
模型实际调用 list_schedules 成功。
2026-09-04 06:28:55 +08:00

473 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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"
"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}},
}
}