# 定时提醒 > 「明早 8 点提醒我交作业」「每周一 9:25 提醒张三开周会」「每天早上 9 点在群里说句早安」—— > 到点由插件自己发消息,**不依赖模型醒着**。任务存在 `data/reminders.json`,重启不丢。 这是整个插件里唯一一个「机器人在未来某个时刻主动开口」的功能。它和 [主动说话](proactive.md#偶尔主动说话proactive)的区别是:主动说话是「它想不想说」, 定时提醒是**用户和它之间的约定** —— 到点必须发,不能因为模型抽风、额度用完、 写了一个 `SKIP` 就消失。 ## 它长什么样 ```text 用户:明早 8 点提醒我交作业 bot :好,明天 08:00 @ 你。 (次日 08:00,群里) bot :@李四 交作业 ``` ```text 用户:每周一 9:25 提醒张三开周会 bot :收到,下周一 09:25 开始,每周一次。 (需要部署打开 reminderMentionOthers,或把这个人加进 reminderUsers) ``` ```text 用户:每天早上 9 点在群里说句早安 bot :好,从明天 09:00 开始。 (无 @,固定文案;模式是 agent 时它会用自己的语气说) ``` 模型怎么知道「现在几点」:渠道提示里每轮都会写一行 `现在是 2026-08-20 14:32(周四,本地时间)` —— 「明天下午 3 点」「下周一」都由模型 换算成绝对时间,插件只做严格校验(见下)。 换算的时间由模型给,所以渠道提示还要求它**在回复里写出绝对日期** (如「10 月 19 日 周一 12:00」),别只说「明天中午」:只有写出日期,对方才能一眼 看出「明天」有没有被理解成另一天。凌晨(大致 00:00–06:00)的「明天 / 今天几点」 有歧义 —— 日历上的明天,和「睡醒后的那个白天」,两种都说得通 —— 提示词要求它先问 一句是哪天,不问就按最近的那个未来时刻排。这是真实事故换来的规则:2026-10-18 02:12 一句「明天中午提醒我拿快递」,模型按日历排到了 10/19 12:00,而对方想的是 睡醒后的 10/18 12:00;回复里又只写「明天中午」,偏差一直到点才发现。 ## 三种模式 | 模式 | 到点做什么 | 精度 | 成本 | 谁可以用 | |---|---|---|---|---| | `text` | 原样发 `text` | 一字不差 | 0 | 所有人(或 `reminderUsers` 白名单) | | `agent` | 跑一轮模型,把 `text` 用它的语气说出来 | 内容可能被改写 | 1 次模型调用 | 所有人(受 `reminderModes` 与每日额度约束) | | `prompt` | 跑一轮模型,按 `prompt` 做事(可调工具),把结果发出来 | 不保证 | N 次调用 | 受信者(`reminderUsers` 里的人或 Web 操作员) | `reminderModes` 是**上限**:默认 `agent`(text + agent 可用,prompt 拒绝); 设成 `text` 就只剩固定文案。prompt 模式还要额外满足「受信者」这一条。 **text 是永远兜底**:agent/prompt 模式在模型失败、超时、写 `SKIP`、进程重启、 `/reset` 打断时,都会退回固定文案(prompt 模式没有兜底文案且允许跳过时才会真的丢弃)。 提醒是承诺,不能静默消失。 ## 时间怎么算 - `at` 用**本地时间** `YYYY-MM-DDTHH:MM`(也接受空格分隔);`in_minutes` 是相对分钟数。 两者二选一。 - 一次性任务至少要提前 30 秒;最远 365 天。 - 周期任务(`daily` / `weekdays` / `weekly` / `monthly`)如果第一次时刻已经过去, 会**滚到下一个未来时刻**(「每天 9 点」在 10 点说也成立),不会报错。 - `monthly` 的日号在小月**夹到月末**:`31` 号在 2 月落到 28/29 号,4 月落到 30 号。 - 时间推算从**日历分量**重新构造,不是 `+24 小时`:跨夏令时、跨月、跨年都按本地 日历算。部署机器的时区就是提醒的时区 —— 想固定东八区,给服务设 `TZ=Asia/Shanghai`。 ## 谁能建、能 @ 谁 | 动作 | 默认 | 由谁控制 | |---|---|---| | 创建提醒 | 所有人 | `reminderUsers`(留空 = 不限制) | | @ 创建者自己 | 允许 | — | | @ 别人 | **禁止** | `reminderMentionOthers`;受信者不受限 | | @全体成员 | **禁止** | `reminderAllowAll` + 受信者 | | 指定**别的**会话 | 仅受信者 / Web 操作员 | 代码内判定 | | 取消 | 创建者本人 / Web 操作员 | — | | 每人每天新建 | 5 条 | `reminderDailyMax` | | 每会话待发 | 20 条 | `reminderMaxActive` | | 文本长度 | 200 字(prompt 500 字) | 常量 | | 单条 @ 人数 | 5 人 | 常量 | 「受信者」= `reminderUsers` 名单里的人,或者从 Web UI 主会话里调用(操作员)。 QQ 会话里不接受裸 QQ 号作为 @ 目标:名字只在**这个会话**的 `participants` (最近说过话的人)里解析,认不出来就报错并列出可用的昵称 —— 否则群友能让机器人 @ 一个根本不在群里的人。 ## 提醒正文不会被解析 `text` 原样发出:`[[图片: …]]`、`[[@某人]]` 在提醒里**就是六个字符的原文**,不会变成图 或 at 段。@ 只由创建时解析好的 QQ 号生成。 这是硬边界:提醒文本来自群聊,是不可信输入。如果它到点还能被当成标记解析,群友就能 借一条定时任务让机器人到点发图、或者 @ 任意的人。 ## 重启、断线、`/reset` - 任务写在 `data/reminders.json`(`reminderFile`),**重启不丢**。 - 到点那一刻进程没起来 / 断线:在 `reminderGraceMs`(默认 10 分钟)内补发; 超过就标成 `missed`,`qq_remind_list` 里能看到,不再打扰。 - 周期任务在重启后**不补历史**:错过的那几次直接跳过,排到下一个未来时刻。 - agent/prompt 那一轮跑到一半进程被杀:重启后在 grace 内补一条**固定文案** (不知道模型说没说出口,宁可发一条文本也不赌)。 - `/reset` 不影响提醒:任务属于会话而不是某一轮 Session,照常到点发送。 - 发送是**先记账再发**(at-most-once):进程死在「已记账、没发出去」之间时,重启后 补一条文本;反过来(发完再记账)会在崩溃后重复打扰。重复 @ 全体比漏一条更糟。 - 断线重试每 15 秒一次,仍受 grace 约束;恢复连接后调度器会立刻重排。 ## 配置 | 字段 | 默认 | 说明 | |---|---|---| | `reminder` | `true` | 总开关 | | `reminderFile` | `data/reminders.json` | 任务文件,相对 `cwd`,必须落在 `cwd` 内 | | `reminderUsers` | `[]` | 允许创建的人;这些人也是受信者 | | `reminderMentionOthers` | `false` | 普通群友能不能 @ 别人 | | `reminderAllowAll` | `false` | 能不能 @全体成员 | | `reminderDailyMax` | `5` | 每人每天新建上限;`0` = 不限 | | `reminderMaxActive` | `20` | 每会话待发上限 | | `reminderGraceMs` | `600000` | 补发窗口 | | `reminderModes` | `agent` | 允许的最高模式 | | `reminderAgentTimeoutMs` | `120000` | agent/prompt 硬超时 | | `reminderAgentDailyMax` | `20` | 每会话每天最多跑几次 Agent 提醒;`0` = 不限 | 设置页在**「提醒」tab**(不是「群聊」—— 提醒同样服务私聊);改完保存会触发插件重启, 任务不丢。 ### 在设置页看已记录的任务 这一页的最上面就是一块**只读**的「已记录的定时任务」:待发的排最前(按到期时间), 历史任务按最近一次收尾排在后面;每条带状态、模式、本地时间、目标会话、@ 名单、 创建者、已发次数与上次错误。页面开着时每 15 秒自动重读一次(任务是在 QQ 那一侧 建的,没有事件能推到浏览器),也可以点「刷新」立刻读。 数据来自 `reminders.overview()` 的一条只读 Host 路由 `/api/qq-bridge/reminders`(`REMINDER_ROUTE_PATH`):它挂在 DSH Connection 的 `/api` 认证栅栏后面,只有已登录的操作员浏览器读得到;返回的字段是**展示投影**,不含 `retryText` / `agentCount` / `scheduledAt` 这些内部账。浏览器半侧在 `client.js` 的 `ReminderTasks` 里,页面那一块只负责画,建 / 取消任务仍然走 QQ 或 `qq_remind_*` 工具。 `reminder` 关掉或任务文件不可用时,这一块会明说「定时提醒已关闭」,而不是显示空列表。 ## 实现要点(改代码前先读) - **任务与调度在 `reminders.js`**:纯函数(时间推算 / 校验 / 模式决策)与 `createReminders`(持久化、单定时器、重试)分开,前者可以不起 Harness 直接单测。 - **发送在 `index.js`**:`deliverReminderText` 走「原样、不分气泡、不加打字延迟」; `deliverReminderModel` 走正常拟人发送链路,但 @ 由任务注入(`sendParts` 的 `at`)。 - **agent/prompt 那一轮**:`runReminderTurn` 伪造一条 user message,`submitted` 上写 `reminderId`(**不写 `proactive`**),`turn/end` 里 reminder 分支必须排在 error / SKIP / aborted **之前** —— 那些分支在普通轮次里是静默丢弃,提醒不能静默。 - **守卫按任务存**(`pendingReminderTurns`),不按会话存:`/reset` 会清掉会话的 `turnOwners`,那一轮的 `turn/end` 永远不会来,只有守卫能把兜底文案发出去。 - **一个会话同一时刻只跑一条 Agent 提醒**:`busyWithTurn` 为真时这一条直接发固定文案; 同一分钟到点的第二条也走固定文案(可预期、不抢队列)。 - **定时器只有一个**:排到最早的活跃任务,最长睡 1 小时(时钟被改 / 进程挂起后能自己 校正),并夹在 `MAX_TIMER_MS` 之内(`setTimeout` 的 24.8 天上限)。 - **每日额度从任务文件现算**:`countCreatedToday` 数「今天创建的任务」,重启不会清零; 历史任务保留 7 天后裁剪。 - **去重**:同会话 + 同内容 + 同 @ 人 + 同一分钟内的重复创建返回已有任务 (`duplicate: true`),防止模型重试建两条。 - **文件损坏不拖垮功能**:读回时逐条校验,坏条目跳过并记一条 warn;整个 JSON 坏掉 就按空表启动(任务会丢,但插件照常运行)。 - **设置页那块只是投影**:`reminderOverview`(纯函数)挑展示字段并在 Host 侧算好本地 时间;`reminders.overview()` 先 `await ready` 再给快照,页面在启动读盘未完成时打开 也不会看到假的空列表。页面绝不能拿 `snapshot()` 直接序列化出去。 - **路由要等 Connection 服务出现**:用 `ctx.inject(['connection'], …)`,别在 apply 里 `ctx.get('connection')` 探一次就完 —— 本插件的 `apply()` 跑在 `dsh-client-connection` 之前(实测网页 URL 比插件的启动日志晚约 2 秒),那一刻拿到的是 `undefined`,路由 于是**永远**挂不上,设置页那一块只有 404。headless 组合里这个服务永远不会出现, `inject` 的回调因此不跑,也不影响插件激活。 排查「它怎么不提醒」: 1. 启动日志里有没有 `reminders on — …`;没有就看 `reminder` 与 `reminderFile`; 2. `qq_remind_list` 看任务状态(`active` / `done` / `missed` / `failed`)与 `error`; 3. 失败原因在 `report('warn', …)` 里(没有连上、被踢、被禁言、超过补发窗口); 4. `reminderModes` 是不是把 `agent` / `prompt` 挡住了;`reminderUsers` 是不是把创建者 挡在外面。