Files
MailUI4Agents/plugins/homeagent-mail-bridge/schedule.go
JianFeeeee 55b3f9bc4e fix(web): 地址显示按「人 / Agent」分维度 —— 别名跟 Agent 走,人只显示名字
## 症状

单封邮件的元信息三行都不对(生产实测那封 12:12:12):

    发件  jianf.邮件驱动·多智能体协作平台-完整设计文档-一、项目概述-11-项目定位
    收件  pi@/home/program/agentmail
    抄送  pi@/home/program/agentmail.new

人指定的是「投进 pi 的那条会话」,而界面把会话别名拼给了**发件人**。

## 三处错

**1. 别名拼错了一方。** `name@path.session` 三段才唯一确定「哪个 Agent、
在哪个目录、哪条线索」—— 别名必须跟 Agent 走。拼给发件人之后收件人变成
`pi@/home/program/agentmail`,那指向**默认会话**而不是人指定的那条。

**2. 人不该有目录和会话位。** 人没有工作目录,发给人就是进收件箱。
`jianf.某会话` 是把 Agent 的三维语义硬套在人身上,而且因为 from_workspace
为空,拼出来的形态连 ParseAddress 都还原不了 —— 没有 `@` 时整串被当成
**名字**(实测 name="jianf.某会话别名"),投递必然 404。

**3. 抄送残留 `.new`。** 它是一次性动作,建完会话就失效;留着会让人以为
再发一次还能投进同一条会话,实际会开出第三条。

## 修法

`identityAddress` → `participantAddress(name, workspace, alias)`,
判据是有没有 workspace:

    Agent → pi@/home/program/agentmail.日程提醒:…    三段齐全
    人    → jianf                                     裸名字

`ccAddress` 按同一判据分流;`.new` 换成当前会话别名。
六处手工拼接(MailView / MailList ×2 / ThreadView)统一走这两个函数。

## 顺带修掉 `dsh@dsh`

改的时候实测发现:**`mails.from_workspace` 对 Agent 存的是 Agent 名而不是
路径**(历史遗留,见 db/migrate.go 里 sessions.workspace 的注释)。
拿它当路径拼,Agent 发来的信显示成 `dsh@dsh`。

会话的 workspace 才是权威来源 → `models.Mail` 新增 `SessionWorkspace`,
六处查询补 `s.workspace`:GetMailByID / ListInbox / GetSessionMails /
GetSessionMailByID / ListSentBy / threadCols。

## formatAddress 与后端对齐

第一版我改成「path 为空时舍弃 session 返回裸名字」,对着后端 ParseAddress
跑了一遍才发现搞反了 —— **正确形态是保留 `@`**:

    jianf@.任务  → name=jianf path="" session=任务   ✓
    jianf.任务   → name="jianf.任务"                  ✗

现在两端六个 case 逐例一致(这个分支只在内部逻辑上用得到;
展示一律走 participantAddress,人根本不带会话位)。

## 取舍

列表行与对话树节点**不带会话位**:列表的分组头已单独显示别名,
树的每个节点都在同一条线索上 —— 重复无信息量,而 92 字节的别名会把那行挤没。

## homeagent 日程工具的两个修复(同批)

**查询串手拼吃掉了时区。** RFC3339 的 `+08:00` 里那个 `+` 在查询串里正是
空格的转义形式,服务端 ParseQuery 还原成空格 → time.Parse 失败 →
AgentListCalendarEvents **静默退回默认区间**(不报错)。表现为「明明有日程
却说一条都没有」。改走 url.Values.Encode()。

**默认窗口 3 个月太窄。** yearly / lunar_yearly 的下一次触发随时落在窗口外,
模型问「我建过什么」得到空结果,然后照着空结果再建一条重复的。改成 14 个月。
空结果的话术也从「你还没有建过日程」改成说出实际查询区间 —— 前者在窗口外
有事件时是假话。

## 验收

- web 182 例(replyTarget 24 → 46);tsc 无错;Gateway 7 包全过
- 新增 test/manual/addr-verify.mjs:真渲染两个方向都验过
    人 → Agent:jianf / pi@/home/program/agentmail.日程提醒:…
    Agent → 人:dsh@/home/program/agentmail.查看工程与插件适配指南 / jianf
  判据含「Agent 的 path 必须是真路径而不是 Agent 名」(锁 dsh@dsh 那个 bug)
2026-09-04 13:49:16 +08:00

503 lines
18 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"
"net/url"
"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 列出自己建的日程。
//
// 查询串必须走 url.Values 编码,**不能手拼**。
//
// RFC3339 的时区偏移带一个 `+`2026-12-11T09:00:00+08:00而 `+` 在查询串里
// 正是空格的转义形式:手拼进 URL 后服务端 ParseQuery 把它还原成空格,
// time.Parse(RFC3339) 随即失败 —— 而 AgentListCalendarEvents 解析失败时是
// **静默退回默认区间**now-24h ~ now+3月不报错。
// 于是这里传的 from/to 全部无效,表现为「明明有日程却说一条都没有」。
// 真实踩过一次:先怀疑了数据库、密钥和权限,最后才发现是这个加号。
func (p *Plugin) handleListSchedules(args map[string]interface{}) (interface{}, error) {
q := url.Values{}
q.Set("status", argStringOr(args, "status", "active"))
if v := strings.TrimSpace(argString(args, "from")); v != "" {
if t, err := parseEventTime(v); err == nil {
q.Set("from", t.Format(time.RFC3339))
}
}
if v := strings.TrimSpace(argString(args, "to")); v != "" {
if t, err := parseEventTime(v); err == nil {
q.Set("to", t.Format(time.RFC3339))
}
} else {
// 默认往后 14 个月,而不是沿用服务端的 3 个月。
//
// 生日、纪念日这类 yearly / lunar_yearly 的下一次触发随时会落在
// 3 个月之外。窗口太窄时模型问「我建过什么」得到的是空 ——
// 然后它会照着空结果再建一条重复的。14 个月保证任何年度重复
// 都至少露一次脸。
q.Set("to", time.Now().AddDate(1, 2, 0).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.Encode(), &res); err != nil {
return nil, err
}
if len(res.Events) == 0 {
// 不能说「你还没有建过日程」——窗口外或别的 status 下还有事件时
// 这是假话,而模型会照着这句话去建一条重复的。
// 把实际查询区间说出来,让它知道该往哪放宽。
from := q.Get("from")
if from == "" {
from = "默认起点(昨天)"
}
return textResult(fmt.Sprintf(
"这个区间内没有日程:%s ~ %sstatus=%s。\n"+
"想找更早/更远的请放宽 from/to或用 status=all 看已暂停与已取消的。\n"+
"确认真的没有再用 create_schedule 建 —— 别照着空结果建重复的。",
from, q.Get("to"), q.Get("status"))), 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}},
}
}