From 2ae4df0e983f27369cc976f85166eba695f7631d Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 4 Sep 2026 06:28:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent-calendar):=20Agent=20=E4=BE=A7?= =?UTF-8?q?=E6=97=A5=E5=8E=86=E7=AB=AF=E7=82=B9=EF=BC=88=E5=8F=AF=E5=86=99?= =?UTF-8?q?=EF=BC=8C=E5=8F=AA=E8=83=BD=E5=8A=A8=E8=87=AA=E5=B7=B1=E5=BB=BA?= =?UTF-8?q?=E7=9A=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在这之前 /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 各条边界)。 --- gateway/cmd/server/main.go | 33 ++ gateway/internal/handler/agent_calendar.go | 454 +++++++++++++++++++ gateway/internal/handler/models_scope.go | 18 +- gateway/internal/repo/agent_calendar_test.go | 215 +++++++++ gateway/internal/repo/sessionrate.go | 46 ++ 5 files changed, 750 insertions(+), 16 deletions(-) create mode 100644 gateway/internal/handler/agent_calendar.go create mode 100644 gateway/internal/repo/agent_calendar_test.go diff --git a/gateway/cmd/server/main.go b/gateway/cmd/server/main.go index 6ef2333..bf6b3b3 100644 --- a/gateway/cmd/server/main.go +++ b/gateway/cmd/server/main.go @@ -16,6 +16,7 @@ import ( "github.com/agentmail/gateway/internal/handler" "github.com/agentmail/gateway/internal/middleware" "github.com/agentmail/gateway/internal/repo" + "github.com/agentmail/gateway/internal/scheduler" "github.com/agentmail/gateway/internal/static" "github.com/go-chi/chi/v5" chimw "github.com/go-chi/chi/v5/middleware" @@ -60,6 +61,11 @@ func main() { // 否则取消发信与 Agent 崩溃留下的文件会让磁盘单调增长。 go sweepOrphanAttachments(blobs) + // 日历调度器:把到期的提醒变成邮件。 + // 必须在建表(migrate)之后启动 —— 它启动时立即扫一次表。 + scheduler.Start() + defer scheduler.Stop() + r := chi.NewRouter() r.Use(chimw.Logger) @@ -127,6 +133,20 @@ func main() { r.Get("/agent/mail/{id}", handler.AgentGetMail) r.Get("/agent/mail/{id}/thread", handler.AgentGetMailThread) r.Get("/agent/sessions/{id}/participants", handler.AgentSessionParticipants) + + // ---- 日历 / 待办(可写,但只能动自己建的)---- + // + // 在这之前「明天九点提醒我看 CI」只能靠插件进程里的 setTimeout —— + // 进程一重启定时器就消失,提醒静默不见且无处留痕。放进 Gateway 之后 + // 由数据库与调度器保证:插件重启、Agent 换机器都不影响。 + // + // 三处收紧(见 handler/agent_calendar.go):只看/只改自己建的、 + // 不能设给人类、速率 20 次每小时 + 总量 50 条双闸。 + r.Post("/agent/calendar/events", handler.AgentCreateCalendarEvent) + r.Get("/agent/calendar/events", handler.AgentListCalendarEvents) + r.Get("/agent/calendar/events/{id}", handler.AgentGetCalendarEvent) + r.Put("/agent/calendar/events/{id}", handler.AgentUpdateCalendarEvent) + r.Delete("/agent/calendar/events/{id}", handler.AgentDeleteCalendarEvent) }) // ---- 人类登录态 ---- @@ -178,6 +198,19 @@ func main() { // 在线 Agent 列表(补全用) r.Get("/agents", handler.ListAgents) + // 日历事件 + r.Post("/calendar/events", handler.CreateCalendarEvent) + r.Get("/calendar/events", handler.ListCalendarEvents) + r.Get("/calendar/events/{id}", handler.GetCalendarEvent) + r.Put("/calendar/events/{id}", handler.UpdateCalendarEvent) + r.Delete("/calendar/events/{id}", handler.DeleteCalendarEvent) + r.Post("/calendar/events/{id}/attachments", handler.UploadCalendarAttachment) + r.Get("/calendar/events/{id}/attachments", handler.ListCalendarAttachments) + // 单条删除:撤一个错传的文件不该要求把整条日程重建 + r.Delete("/calendar/attachments/{attachmentID}", handler.DeleteCalendarAttachment) + r.Get("/calendar/export.ics", handler.ExportCalendarICS) + r.Post("/calendar/import.ics", handler.ImportCalendarICS) + // 管理员 r.Group(func(r chi.Router) { r.Use(middleware.AdminOnly) diff --git a/gateway/internal/handler/agent_calendar.go b/gateway/internal/handler/agent_calendar.go new file mode 100644 index 0000000..ab1c2c5 --- /dev/null +++ b/gateway/internal/handler/agent_calendar.go @@ -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_time(RFC3339,例如 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}) +} diff --git a/gateway/internal/handler/models_scope.go b/gateway/internal/handler/models_scope.go index c1a1263..19328d5 100644 --- a/gateway/internal/handler/models_scope.go +++ b/gateway/internal/handler/models_scope.go @@ -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:]) -} diff --git a/gateway/internal/repo/agent_calendar_test.go b/gateway/internal/repo/agent_calendar_test.go new file mode 100644 index 0000000..9e35671 --- /dev/null +++ b/gateway/internal/repo/agent_calendar_test.go @@ -0,0 +1,215 @@ +package repo + +import ( + "context" + "testing" + "time" + + "github.com/agentmail/gateway/internal/models" +) + +// Agent 只能看到自己建的日程。别人的日程里可能有它无权知道的会议与地址。 +func TestListCalendarEventsCreatedBy(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + mine := seedEvent(t, &models.CalendarEvent{ + Title: "pi 自己建的", EventTime: time.Now().Add(time.Hour), CreatedBy: "pi", + }) + seedEvent(t, &models.CalendarEvent{ + Title: "dsh 建的", EventTime: time.Now().Add(time.Hour), CreatedBy: "dsh", + }) + seedEvent(t, &models.CalendarEvent{ + Title: "人建的", EventTime: time.Now().Add(time.Hour), CreatedBy: "jianf", + }) + + from := time.Now().Add(-time.Hour) + to := time.Now().AddDate(0, 1, 0) + + got, err := ListCalendarEventsCreatedBy(ctx, "pi", from, to, "active") + if err != nil { + t.Fatalf("列出: %v", err) + } + if len(got) != 1 { + t.Fatalf("pi 应只看到 1 条,得到 %d", len(got)) + } + if got[0].EventID != mine.EventID { + t.Errorf("看到了别人的事件:%s", got[0].Title) + } + + // 没建过任何事件的 Agent 得到空数组而不是 nil(nil 序列化成 null 前端会崩) + empty, err := ListCalendarEventsCreatedBy(ctx, "opencode", from, to, "active") + if err != nil { + t.Fatalf("列出: %v", err) + } + if empty == nil { + t.Error("应返回空数组而不是 nil") + } + if len(empty) != 0 { + t.Errorf("应为空,得到 %d 条", len(empty)) + } +} + +// 「发给我但不是我建的」同样不返回:那些事件的编辑权不属于我, +// 列出来只会让模型试图改它然后拿到 404。 +func TestListCalendarEventsCreatedByIgnoresRecipient(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + seedEvent(t, &models.CalendarEvent{ + Title: "dsh 建的、发给 pi", + EventTime: time.Now().Add(time.Hour), + CreatedBy: "dsh", + Recipients: []string{"pi"}, + }) + + got, err := ListCalendarEventsCreatedBy(ctx, "pi", + time.Now().Add(-time.Hour), time.Now().AddDate(0, 1, 0), "active") + if err != nil { + t.Fatalf("列出: %v", err) + } + if len(got) != 0 { + t.Errorf("收件人不等于创建者,不该出现在列表里(得到 %d 条)", len(got)) + } +} + +func TestListCalendarEventsCreatedByStatusFilter(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + seedEvent(t, &models.CalendarEvent{ + Title: "生效中", EventTime: time.Now().Add(time.Hour), CreatedBy: "pi", Status: "active", + }) + seedEvent(t, &models.CalendarEvent{ + Title: "已暂停", EventTime: time.Now().Add(time.Hour), CreatedBy: "pi", Status: "paused", + }) + + from := time.Now().Add(-time.Hour) + to := time.Now().AddDate(0, 1, 0) + + if got, _ := ListCalendarEventsCreatedBy(ctx, "pi", from, to, "active"); len(got) != 1 { + t.Errorf("active 过滤应给 1 条,得到 %d", len(got)) + } + // 空串 = 不过滤(handler 里 status=all 映射成空串) + if got, _ := ListCalendarEventsCreatedBy(ctx, "pi", from, to, ""); len(got) != 2 { + t.Errorf("不过滤应给 2 条,得到 %d", len(got)) + } +} + +// 总量上限的依据。速率限制压不住「每小时建 19 条连建一周」, +// 而日历事件是长效的 —— 攒下来的每条都持续产生提醒。 +func TestCountActiveEventsBy(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + for i := 0; i < 3; i++ { + seedEvent(t, &models.CalendarEvent{ + Title: "生效", EventTime: time.Now().Add(time.Hour), CreatedBy: "pi", Status: "active", + }) + } + // cancelled 与 paused 不该计入「生效中」 + seedEvent(t, &models.CalendarEvent{ + Title: "取消了", EventTime: time.Now().Add(time.Hour), CreatedBy: "pi", Status: "cancelled", + }) + seedEvent(t, &models.CalendarEvent{ + Title: "暂停了", EventTime: time.Now().Add(time.Hour), CreatedBy: "pi", Status: "paused", + }) + seedEvent(t, &models.CalendarEvent{ + Title: "别人的", EventTime: time.Now().Add(time.Hour), CreatedBy: "dsh", Status: "active", + }) + + n, err := CountActiveEventsBy(ctx, "pi") + if err != nil { + t.Fatalf("计数: %v", err) + } + if n != 3 { + t.Errorf("pi 的生效事件应为 3,得到 %d", n) + } + + if n, _ := CountActiveEventsBy(ctx, "从来没建过"); n != 0 { + t.Errorf("没建过应为 0,得到 %d", n) + } +} + +// ─── 速率限制 ─── + +func TestAllowAgentCalendarEvent(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + limit := CalendarRateLimit() + for i := 0; i < limit; i++ { + ok, _ := AllowAgentCalendarEvent(ctx, "pi") + if !ok { + t.Fatalf("第 %d 次(上限 %d)就被拒了", i+1, limit) + } + } + ok, retry := AllowAgentCalendarEvent(ctx, "pi") + if ok { + t.Error("超过上限应被拒") + } + if retry <= 0 { + t.Errorf("被拒时应给出 retryAfter,得到 %d", retry) + } +} + +// 日历桶与新建会话桶必须独立:建满 20 条日程不该连带堵住新建会话。 +func TestCalendarRateBucketIsSeparateFromSession(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + for i := 0; i < CalendarRateLimit(); i++ { + AllowAgentCalendarEvent(ctx, "pi") + } + if ok, _ := AllowAgentCalendarEvent(ctx, "pi"); ok { + t.Fatal("准备阶段:日历桶应已满") + } + // 新建会话桶应完全不受影响 + if ok, _ := AllowNewSession(ctx, "pi"); !ok { + t.Error("日历桶满不该堵住新建会话 —— 两个桶必须独立") + } +} + +// 不同 Agent 的桶互不干扰。 +func TestCalendarRateBucketPerAgent(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + for i := 0; i < CalendarRateLimit(); i++ { + AllowAgentCalendarEvent(ctx, "pi") + } + if ok, _ := AllowAgentCalendarEvent(ctx, "dsh"); !ok { + t.Error("pi 建满不该影响 dsh") + } +} + +// 创建失败要归还名额:那次创建实际没有发生。 +func TestReleaseAgentCalendarEvent(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + for i := 0; i < CalendarRateLimit(); i++ { + AllowAgentCalendarEvent(ctx, "pi") + } + if ok, _ := AllowAgentCalendarEvent(ctx, "pi"); ok { + t.Fatal("准备阶段:应已满") + } + // 归还一个(模拟刚才那次被拒之前的失败创建) + ReleaseAgentCalendarEvent(ctx, "pi") + if ok, _ := AllowAgentCalendarEvent(ctx, "pi"); !ok { + t.Error("归还名额后应能再建一条") + } +} + +// 空 Agent 名放行且不记账:这条路径只在鉴权已经失败时才可能走到, +// 记账会污染桶(bucket 名变成 "calendar:")。 +func TestCalendarRateEmptyAgentPassesThrough(t *testing.T) { + setupTestDB(t) + ctx := context.Background() + + for i := 0; i < CalendarRateLimit()+5; i++ { + if ok, _ := AllowAgentCalendarEvent(ctx, ""); !ok { + t.Fatal("空 Agent 名应一律放行") + } + } +} diff --git a/gateway/internal/repo/sessionrate.go b/gateway/internal/repo/sessionrate.go index dac5d45..8476a4a 100644 --- a/gateway/internal/repo/sessionrate.go +++ b/gateway/internal/repo/sessionrate.go @@ -46,3 +46,49 @@ func ReleaseNewSession(ctx context.Context, agentName string) { // SessionRateLimit 暴露窗口内的新建上限,供错误文案使用。 func SessionRateLimit() int { return sessionRateLimit } + +// ---------- Agent 建日历事件的速率限制 ---------- +// +// 与新建会话同一套机制、独立的桶。为什么必须限: +// +// 日历事件是**长效**的 —— 一条每日重复的提醒会一直发下去,直到有人去删。 +// 模型在循环里每轮建一个「10 分钟后提醒我检查」,攒出几十条定时任务后, +// 即使那条会话早已归档,提醒仍会按时发出。这比 `.new` 洪泛更难收拾: +// 后者只是多几条空会话,前者是持续产生新邮件的源头。 +// +// 上限与新建会话一致(20 次/小时):正常用法下 Agent 一次任务里建 +// 一两条日程,20 条足够宽松;而循环失控时一小时内就会撞上限。 +const ( + calendarRateWindow = time.Hour + calendarRateLimit = 20 +) + +// AllowAgentCalendarEvent 供 handler 调用:Agent 建日历事件前先过速率限制。 +// +// 人类不走这条路径(在界面上手工填表的频率天然受限), +// 因此桶名带 agent: 前缀,与人类操作完全隔离。 +// 返回 (allowed, retryAfter)。DB 不可用时放行 —— 限速不该成为可用性的单点。 +func AllowAgentCalendarEvent(ctx context.Context, agentName string) (bool, int) { + if agentName == "" { + return true, 0 + } + return RateLimitCheckAndRecord(ctx, "calendar:"+agentName, calendarRateWindow, calendarRateLimit) +} + +// ReleaseAgentCalendarEvent 建事件失败后归还名额。 +// +// 与 ReleaseNewSession 同理:记账发生在检查那一刻, +// 后续的写库失败意味着「那次创建实际没有发生」,不该占名额。 +func ReleaseAgentCalendarEvent(ctx context.Context, agentName string) { + if agentName == "" { + return + } + bucket := "calendar:" + agentName + _, _ = db.DB.ExecContext(ctx, + `DELETE FROM rate_limits WHERE bucket = $1 AND ts = ( + SELECT MAX(ts) FROM rate_limits WHERE bucket = $1 + )`, bucket) +} + +// CalendarRateLimit 暴露窗口内的上限,供错误文案使用。 +func CalendarRateLimit() int { return calendarRateLimit }