feat(agent-calendar): Agent 侧日历端点(可写,只能动自己建的)

在这之前 /calendar/* 全挂在 UserAuth 后面,Agent 密钥一律 401。
于是「明天九点提醒我看 CI」只能靠插件进程里的 setTimeout —— 进程一重启
定时器就消失,那条提醒静默不见且无处留痕。放进 Gateway 后由数据库与
调度器保证:插件重启、Agent 换机器、甚至换平台都不影响。

更重要的是它让**跨 Agent 的任务交接**成立:模型可以给 dsh 设一条
「明天交周报」的提醒。这件事模型自己做不到 —— 它没法让另一个进程在未来
某刻醒来。

三处收紧:

**1. 只看/只改自己建的。**
别人的日程里可能有它无权知道的会议与地址。不存在与不属于我都回 **404**
而非 403 —— 后者会泄漏「这个 id 存在」,让 Agent 能枚举出别人有多少条日程。

**2. 不能设给人类。**
理由是投递通道不对等。Agent 之间的提醒是任务信号:收到就干活、干完回信。
发给人的提醒是打扰 —— 进收件箱、触发未读徽标,而人**无法回信让它停下**
(提醒是日历实体不是对话),只能去 WebUI 里找出那条事件删掉。
一个 Agent 建条「每 10 分钟提醒 jianf 检查进度」的代价远大于收益
(它其实可以直接 send_mail)。

**3. 速率 + 总量双闸。**
速率(20 次/小时,独立桶)压住「短时间暴建」,压不住「每小时建 19 条、
连建一周」—— 而日历事件是**长效**的,一条每日重复提醒会一直发下去。
攒下 300 条之后即使停止建新的,每天仍有 300 封提醒涌出来。
所以加 maxActiveEventsPerAgent=50,并在列表响应里回传 active_limit:
模型看到 42/50 就知道该清理,只在撞墙时才报错等于让它一直蒙在鼓里。

其他设计点:
- **PUT 是部分更新**(人类端点是整体替换)。调用方是模型 —— 要求它每次
  回传全部字段,漏一个就把提醒正文或收件人清空,而那种破坏没有任何报错。
  全部字段用 *T,nil = 没传 = 保持原值。
- 一次性事件设在过去拦掉(会立刻触发,几乎总是时区或年份写错);
  重复事件不拦 —— 「每天 9 点」从昨天开始是合理写法。
- 收件人省略时默认给自己:最常见的用法,每次要求写出自己的名字只会让
  模型忘记然后拿到 400。

顺带:两个 handler 文件各写了一份手写 itoa(models_scope 那份还漏了负数),
统一成 strconv.Itoa。

测试 repo 9 例:创建者过滤、收件人≠创建者不返回、总量计数、
速率桶与会话桶独立、按 Agent 隔离、失败归还名额。
生产实测 11 项全过(含 403/400/404/429 各条边界)。
This commit is contained in:
2026-09-04 06:28:33 +08:00
parent 069bf03ae2
commit 2ae4df0e98
5 changed files with 750 additions and 16 deletions

View File

@ -0,0 +1,454 @@
package handler
import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/agentmail/gateway/internal/middleware"
"github.com/agentmail/gateway/internal/models"
"github.com/agentmail/gateway/internal/repo"
)
// Agent 侧的日历能力。
//
// # 为什么 Agent 需要建日程
//
// 在这之前日历是纯人类功能:`/calendar/*` 全挂在 `middleware.UserAuth` 后面,
// Agent 密钥一律 401。于是「明天九点提醒我看 CI 结果」这件事Agent 只能
// 在自己进程里 setTimeout —— 而它的进程随时会重启,定时器一并消失,
// 那条提醒静默不见,没有任何地方留下痕迹。
//
// 把日程放进 Gateway 之后它由数据库与调度器保证插件重启、Agent 换机器、
// 甚至换平台,提醒照样按时到达。
//
// # 与人类端点的三处差异
//
// 1. **只能看自己建的**`ListCalendarEventsCreatedBy`)。别人的日程里
// 可能有它无权知道的会议与地址。
// 2. **只能改自己建的**。人建的提醒不该被 Agent 悄悄改时间或删掉 ——
// 那等于让它绕过人的安排。
// 3. **有速率与总量双重上限**。见下面 `guardAgentQuota` 的说明。
//
// # 为什么允许发给别人
//
// 「让 pi 提醒 dsh 明天交周报」是真实需求:跨 Agent 的任务交接本来就是
// 这个平台的主题。收件人一律走完整三维寻址,与 send_mail 同一套解析,
// 因此 Agent 能设的目标不会超出它本来就能发信的范围。
//
// **但不能设给人类**:见 `rejectHumanRecipients`。
// ─── 配额守卫 ───
// maxActiveEventsPerAgent 是单个 Agent 同时生效的事件总量上限。
//
// 为什么速率限制不够:`calendar:` 桶压住的是「一小时内建几条」,
// 压不住「每小时建 19 条、连建一周」。而日历事件是**长效**的 ——
// 一条每日重复提醒会一直发下去直到有人删它。攒下 300 条之后,
// 即使 Agent 早已停止建新的,每天仍有 300 封提醒邮件涌出来。
//
// 50 条:正常用法下一个 Agent 手上的长期日程是个位数;
// 撞到 50 说明它在无意义地攒任务,此时报错比继续接受更有用。
const maxActiveEventsPerAgent = 50
// guardAgentQuota 检查速率与总量两道闸。
//
// 返回 false 时已经写好响应,调用方直接 return。
// 第二个返回值是「本次已记账」,创建失败时调用方要 Release 归还。
func guardAgentQuota(w http.ResponseWriter, r *http.Request, agentName string) (ok bool, charged bool) {
// 总量先查:它不消耗速率名额,撞上限时不该顺手扣一次
active, err := repo.CountActiveEventsBy(r.Context(), agentName)
if err == nil && active >= maxActiveEventsPerAgent {
Error(w, http.StatusTooManyRequests,
"你当前已有 "+strconv.Itoa(active)+" 条生效中的日程(上限 "+strconv.Itoa(maxActiveEventsPerAgent)+
")。请先删掉不需要的,或把多条合并成一条重复日程。")
return false, false
}
if allowed, retry := repo.AllowAgentCalendarEvent(r.Context(), agentName); !allowed {
Error(w, http.StatusTooManyRequests,
"建日程过于频繁1 小时内已建 "+strconv.Itoa(repo.CalendarRateLimit())+" 条)。"+
strconv.Itoa(retry)+" 秒后再试;如果只是想改时间,请用 PUT 改已有那条而不是新建。")
return false, false
}
return true, true
}
// rejectHumanRecipients 拒绝把人类放进收件人列表。
//
// 理由是**投递通道不对等**。Agent 之间的提醒是任务信号:收到就干活,
// 干完回信,人不在环里也能推进。而发给人的提醒是打扰 —— 它会进人的收件箱、
// 触发未读徽标,而人无法「回信让它停下」(提醒是日历实体,不是对话)。
//
// 一个 Agent 建一条「每 10 分钟提醒 jianf 检查进度」的日程,人就只能去
// WebUI 里找出那条事件删掉。给 Agent 这个能力,收益(它其实可以直接发邮件)
// 远小于代价。
//
// 人自己在界面上给自己设提醒不受此限 —— 那是人类端点的事。
func rejectHumanRecipients(w http.ResponseWriter, r *http.Request, recipients []string) bool {
for _, raw := range recipients {
addr, err := models.ParseAddress(raw)
if err != nil {
continue // 地址合法性由 normalizeRecipients 负责报错
}
human, hErr := repo.IsHumanUser(r.Context(), addr.Name)
if hErr != nil {
// 查不动就放行:这道闸是防滥用,不该因为 DB 抖动而挡住正常请求
continue
}
if human {
Error(w, http.StatusForbidden,
"不能把日程提醒设给人类用户("+addr.Name+")。"+
"要通知人请直接 send_mail —— 那样他能回信,而定时提醒他只能去界面上删。")
return false
}
}
return true
}
// requireOwnEvent 读出事件并确认它是本 Agent 建的。
//
// 返回 nil 时已写好响应。刻意对「不存在」与「不属于我」都回 404 ——
// 回 403 会泄漏「这个 id 存在」,让 Agent 能枚举出别人有多少条日程。
func requireOwnEvent(w http.ResponseWriter, r *http.Request, agentName string) *models.CalendarEvent {
id := strings.TrimSpace(chi.URLParam(r, "id"))
if id == "" {
Error(w, http.StatusBadRequest, "Missing event id")
return nil
}
e, err := repo.GetCalendarEvent(r.Context(), id)
if errors.Is(err, repo.ErrEventNotFound) {
Error(w, http.StatusNotFound, "日程不存在")
return nil
}
if err != nil {
Error(w, http.StatusInternalServerError, "读取日程失败")
return nil
}
if e.CreatedBy != agentName {
Error(w, http.StatusNotFound, "日程不存在")
return nil
}
return e
}
// ─── 端点 ───
// POST /api/v1/agent/calendar/events
func AgentCreateCalendarEvent(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
var req struct {
Title string `json:"title"`
Description string `json:"description"`
ReminderText string `json:"reminder_text"`
Recipients []string `json:"recipients"`
DeliveryMode string `json:"delivery_mode"`
EventTime time.Time `json:"event_time"`
RemindBefore int `json:"remind_before"`
Recurrence string `json:"recurrence"`
RecurrenceEnd *time.Time `json:"recurrence_end"`
}
if !DecodeBody(w, r, &req) {
return
}
if strings.TrimSpace(req.Title) == "" {
Error(w, http.StatusBadRequest, "Missing title")
return
}
if req.EventTime.IsZero() {
Error(w, http.StatusBadRequest, "Missing event_timeRFC3339例如 2026-09-10T09:00:00+08:00")
return
}
if req.Recurrence == "" {
req.Recurrence = models.RecurNone
}
if !validRecurrence(req.Recurrence) {
Error(w, http.StatusBadRequest,
"recurrence 必须是 none/daily/weekly/monthly/yearly/lunar_monthly/lunar_yearly 之一")
return
}
// 收件人默认是自己:「提醒我明天看 CI」是最常见的用法
// 每次都要求写出自己的名字只会让模型忘记然后拿到 400。
recipients, badAddr := normalizeRecipients(req.Recipients)
if badAddr != "" {
Error(w, http.StatusBadRequest, "收件地址无法解析:"+badAddr)
return
}
if len(recipients) == 0 {
recipients = []string{agentName}
}
if !rejectHumanRecipients(w, r, recipients) {
return
}
// 一次性事件设在过去毫无意义:调度器下一轮就会立刻发出去,
// 而模型的意图显然是「未来某时」。这几乎总是时区或年份写错。
// 重复事件不拦:一条「每天 9 点」的规则从昨天开始是合理的写法,
// AdvanceRecurrence 会把它推到下一个未来时刻。
if req.Recurrence == models.RecurNone && req.EventTime.Before(time.Now()) {
Error(w, http.StatusBadRequest,
"event_time 在过去("+req.EventTime.Format(time.RFC3339)+
")。一次性日程会立刻触发 —— 请检查时区与年份是否写对。")
return
}
okQuota, charged := guardAgentQuota(w, r, agentName)
if !okQuota {
return
}
if strings.TrimSpace(req.ReminderText) == "" {
req.ReminderText = defaultReminderTemplate
}
if req.DeliveryMode == "" {
req.DeliveryMode = models.DeliverSeparate
}
e := &models.CalendarEvent{
Title: strings.TrimSpace(req.Title),
Description: req.Description,
ReminderText: req.ReminderText,
Recipients: recipients,
DeliveryMode: req.DeliveryMode,
ToAddress: recipients[0],
EventTime: req.EventTime,
RemindBefore: req.RemindBefore,
Recurrence: req.Recurrence,
RecurrenceEnd: req.RecurrenceEnd,
Status: "active",
// created_by 记 Agent 名。它与 users.username 共用命名空间,
// 因此不会与人类创建者混淆。
CreatedBy: agentName,
}
out, err := repo.CreateCalendarEvent(r.Context(), e)
if err != nil {
if charged {
// 那次创建实际没有发生,名额还回去
repo.ReleaseAgentCalendarEvent(r.Context(), agentName)
}
Error(w, http.StatusInternalServerError, "创建日程失败")
return
}
JSON(w, http.StatusCreated, out)
}
// GET /api/v1/agent/calendar/events
//
// 只返回本 Agent 建的事件。默认区间是「现在往后 90 天」——
// Agent 关心的是「接下来要发生什么」,不是翻历史。
func AgentListCalendarEvents(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
from := time.Now().Add(-24 * time.Hour)
to := time.Now().AddDate(0, 3, 0)
if v := r.URL.Query().Get("from"); v != "" {
if t, err := time.Parse(time.RFC3339, v); err == nil {
from = t
}
}
if v := r.URL.Query().Get("to"); v != "" {
if t, err := time.Parse(time.RFC3339, v); err == nil {
to = t
}
}
status := r.URL.Query().Get("status")
if status == "" {
status = "active"
}
if status == "all" {
status = "" // repo 里空串 = 不过滤
}
events, err := repo.ListCalendarEventsCreatedBy(r.Context(), agentName, from, to, status)
if err != nil {
Error(w, http.StatusInternalServerError, "读取日程失败")
return
}
JSON(w, http.StatusOK, map[string]any{
"events": events,
// 把上限一并回传:模型看到 42/50 才知道该清理了,
// 只在撞墙时才用报文告知等于让它一直蒙在鼓里。
"active_limit": maxActiveEventsPerAgent,
})
}
// GET /api/v1/agent/calendar/events/{id}
func AgentGetCalendarEvent(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
e := requireOwnEvent(w, r, agentName)
if e == nil {
return
}
JSON(w, http.StatusOK, e)
}
// PUT /api/v1/agent/calendar/events/{id}
//
// 部分更新:省略的字段保持原值。
//
// 与人类端点(整体替换)不同,因为调用方是模型 —— 要求它每次都回传全部
// 字段,漏一个就会把提醒正文或收件人清空,而那种破坏没有任何报错。
func AgentUpdateCalendarEvent(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
cur := requireOwnEvent(w, r, agentName)
if cur == nil {
return
}
// 全部用指针nil = 没传 = 保持原值。
// 用值类型的话「传了空字符串想清空说明」与「没传」无法区分。
var req struct {
Title *string `json:"title"`
Description *string `json:"description"`
ReminderText *string `json:"reminder_text"`
Recipients *[]string `json:"recipients"`
DeliveryMode *string `json:"delivery_mode"`
EventTime *time.Time `json:"event_time"`
RemindBefore *int `json:"remind_before"`
Recurrence *string `json:"recurrence"`
RecurrenceEnd *time.Time `json:"recurrence_end"`
Status *string `json:"status"`
}
if !DecodeBody(w, r, &req) {
return
}
next := *cur
if req.Title != nil {
if strings.TrimSpace(*req.Title) == "" {
Error(w, http.StatusBadRequest, "title 不能为空")
return
}
next.Title = strings.TrimSpace(*req.Title)
}
if req.Description != nil {
next.Description = *req.Description
}
if req.ReminderText != nil {
next.ReminderText = *req.ReminderText
if strings.TrimSpace(next.ReminderText) == "" {
next.ReminderText = defaultReminderTemplate
}
}
if req.Recipients != nil {
recipients, badAddr := normalizeRecipients(*req.Recipients)
if badAddr != "" {
Error(w, http.StatusBadRequest, "收件地址无法解析:"+badAddr)
return
}
if len(recipients) == 0 {
Error(w, http.StatusBadRequest, "recipients 不能改成空 —— 那样日程永远不会提醒任何人")
return
}
if !rejectHumanRecipients(w, r, recipients) {
return
}
next.Recipients = recipients
next.ToAddress = recipients[0]
}
if req.DeliveryMode != nil {
next.DeliveryMode = *req.DeliveryMode
}
if req.EventTime != nil {
if req.EventTime.IsZero() {
Error(w, http.StatusBadRequest, "event_time 无效")
return
}
next.EventTime = *req.EventTime
}
if req.RemindBefore != nil {
if *req.RemindBefore < 0 {
Error(w, http.StatusBadRequest, "remind_before 不能为负")
return
}
next.RemindBefore = *req.RemindBefore
}
if req.Recurrence != nil {
if !validRecurrence(*req.Recurrence) {
Error(w, http.StatusBadRequest,
"recurrence 必须是 none/daily/weekly/monthly/yearly/lunar_monthly/lunar_yearly 之一")
return
}
next.Recurrence = *req.Recurrence
}
if req.RecurrenceEnd != nil {
next.RecurrenceEnd = req.RecurrenceEnd
}
if req.Status != nil {
switch *req.Status {
case "active", "paused", "cancelled":
next.Status = *req.Status
default:
Error(w, http.StatusBadRequest, "status 必须是 active/paused/cancelled")
return
}
}
// 改了时间就允许重新触发。
//
// 不清 fired_for 的后果:把一条已触发的事件时间往后挪,
// DueEvents 的判据 `fired_for <> event_time` 恰好又成立了 —— 这是对的;
// 但把时间挪成**原值**(比如只改标题时前端回传了同一个时间)不该重发。
// 因此这里不主动清,靠 occurrence 相等自然判断即可。
if err := repo.UpdateCalendarEvent(r.Context(), next.EventID, &next); err != nil {
if errors.Is(err, repo.ErrEventNotFound) {
Error(w, http.StatusNotFound, "日程不存在")
return
}
Error(w, http.StatusInternalServerError, "更新日程失败")
return
}
out, err := repo.GetCalendarEvent(r.Context(), next.EventID)
if err != nil {
Error(w, http.StatusInternalServerError, "更新成功但读回失败")
return
}
JSON(w, http.StatusOK, out)
}
// DELETE /api/v1/agent/calendar/events/{id}
func AgentDeleteCalendarEvent(w http.ResponseWriter, r *http.Request) {
agentName := middleware.GetAgentName(r)
if agentName == "" {
Error(w, http.StatusUnauthorized, "Unauthorized")
return
}
e := requireOwnEvent(w, r, agentName)
if e == nil {
return
}
if err := repo.DeleteCalendarEvent(r.Context(), e.EventID); err != nil {
if errors.Is(err, repo.ErrEventNotFound) {
Error(w, http.StatusNotFound, "日程不存在")
return
}
Error(w, http.StatusInternalServerError, "删除日程失败")
return
}
// 附件随事件一起清ON DELETE CASCADE 在 SQLite 下需要 foreign_keys=ON
// 而那个 pragma 默认是关的,不显式删会留下孤儿记录。
_ = repo.DeleteCalendarAttachments(r.Context(), e.EventID)
JSON(w, http.StatusOK, map[string]any{"status": "deleted", "event_id": e.EventID})
}

View File

@ -2,6 +2,7 @@ package handler
import (
"net/http"
"strconv"
"strings"
"github.com/agentmail/gateway/internal/middleware"
@ -88,7 +89,7 @@ func AdminSetAgentModels(w http.ResponseWriter, r *http.Request) {
}
if len(req.Models) > maxAllowedModels {
Error(w, http.StatusBadRequest,
"选定的模型过多(上限 "+itoa(maxAllowedModels)+" 个)")
"选定的模型过多(上限 "+strconv.Itoa(maxAllowedModels)+" 个)")
return
}
@ -113,18 +114,3 @@ func AdminSetAgentModels(w http.ResponseWriter, r *http.Request) {
// 降级尝试是串行的:选 50 个意味着最坏情况下一封邮件要等 50 次模型调用超时。
// 十个已经足够表达「主力 + 几个备选」。
const maxAllowedModels = 10
// itoa 避免为一个数字引入 strconv 导入(本文件只此一处用到)。
func itoa(n int) string {
if n == 0 {
return "0"
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}