跳到主要内容

无人值守与多渠道交付:WhatsApp 网关与 Cron 心跳

30 秒导读: 前几章的 Dexter 都是"你在终端里问一句、它答一句"。这一章讲它怎么走出 CLI: 一头接上 WhatsApp,让你在手机上聊天就能驱动同一个 agent;另一头挂上 Cron 调度器,让它自己定时醒来、 查行情、只在"真有事"时才主动给你发消息。贯穿两头的一条安全暗线是:任何出站消息都要先过白名单校验, 定时任务和它起的子 agent 都是隔离会话

本章属于 Dexter 系列。它只讲"外壳与自主"这一层;agent 主循环怎么跑、上下文怎么压缩,见 01-agent-loop03-context-engineering;记忆检索见 04-memory;工具路由与权限闸门见 02-tools-and-permissions。 全景先读 index


1. 这是什么(零基础也能懂)

一句话定义: 这是 Dexter 的交付层 + 自主层——把"命令行里的一次问答",变成"随时能从 WhatsApp 找它、它也能自己定时来找你"的常驻服务。

解决什么问题 / 给谁用: 假设你想要一个盯盘助手。你不想每次都打开终端敲命令——你想像跟朋友发微信一样, 在 WhatsApp 里问它"AAPL 现在多少";你更想让它替你守着:"NVDA 到 150 就告诉我""每天开盘查一眼大盘"。 这一层就是把 agent 接到聊天软件上,再给它装一个闹钟。

它能做什么:

  • 收发 WhatsApp 消息:私聊直接答;群聊里只有被 @ 到才答,但一直在旁边"听"着攒上下文。
  • 多会话并行、同会话串行:不同人/不同群互不干扰,同一个会话里你连发几条不会打架。
  • 忙时中途注入:agent 正在干活时你又补一句,它会在下一轮把你的新话接进去,而不是排队干等。
  • 定时自主(心跳 / cron):按 every/cron/at 三种日程自己醒来跑任务,只在有结论时才发消息。
  • 安全出站:每一条要发出去的消息,收件人都必须在白名单里,否则直接拦掉。

用起来什么样: 你在终端起一个常驻进程,它就同时挂上了 WhatsApp 和 cron:

$ dexter gateway run
[whatsapp] Connected
[cron] runner started (1 enabled job)

# —— 你在手机 WhatsApp 私聊里发 ——
你: 帮我盯着 NVDA,到 150 就叫我
Dexter: 好的,已经设了一个价格提醒(fulfillment=once),命中后会自动停。

# —— 几小时后,cron 自己醒来、发现命中,主动推给你 ——
Dexter: NVDA 刚突破 $150.3(+2.1%),已达到你设的目标。

一句话直觉 / 类比: 把这层想成 agent 的**"神经末梢 + 生物钟"**。CLI 是它清醒时跟你面对面说话; 网关是它的耳朵和嘴(听 WhatsApp、回 WhatsApp);cron 心跳是它的生物钟——到点自己醒,没事就继续睡 (这是全章最关键的一个设计:定时任务默认沉默,只有真有结论才吵醒你)。

本节不出现代码细节。记住一句话就够了:同一个 agent 循环,前面接了个聊天入口,旁边挂了个闹钟。


2. 顶层全景(它大概怎么转)

整个交付层围绕一个常驻服务 startGateway(src/gateway/gateway.ts:215)。它启动时做两件事: 拉起 WhatsApp 通道管理器,再拉起 cron 调度器。之后有两条独立的"触发链"会汇流到同一个 agent 运行器。

怎么读下面这张图: 左边两条链(人发消息 / 闹钟到点)各自把请求整理好,都走进中间的 runAgentForMessage;右边是它们共享的一条安全底线——出站必过 assertOutboundAllowed

┌─────────────────────────── 触发链 A:人主动发消息 ───────────────────────────┐
│ │
│ WhatsApp 用户 │
│ │ Baileys 收到 │
│ ▼ │
│ inbound.ts ── dedupe 去重 ── access-control 入站门 ──► handleInbound │
│ │ 群?→ @提及门控 │
│ │ 路由 resolveRoute│
│ ▼ │
└──────────────────────────────────────────────┐ ┌───────────────┐ │
├──►│ runAgentFor​ │ │
┌─────────────── 触发链 B:闹钟到点 ────────────┤ │ Message │──► 清洗 │
│ │ │ (会话串行/队列)│ Markdown│
│ cron/runner.ts 每 tick 轮询 jobs.json │ └───────────────┘ │ │
│ │ 找到期任务 │ ▼ │
│ ▼ │ ┌──────────────┐
│ cron/executor.ts executeCronJob │ │ assertOutbound│
│ active-hours 门 → 找投递目标 → │ │ Allowed(白名单)│
│ 起【隔离】agent + HEARTBEAT_OK 指令 ──────┘ └──────┬───────┘
│ suppression 去重 → fulfillment 模式 ▼
└───────────────────────────────────────────────────────────► 发回 WhatsApp

部件一句话职责:

部件干什么在哪个文件
startGateway常驻服务入口:起 WhatsApp 通道 + cronsrc/gateway/gateway.ts:215
handleInbound单条入站消息的主流程(门控→路由→跑 agent→回发)src/gateway/gateway.ts:44
monitorWebInboxBaileys 收消息、去重、入站访问控制src/gateway/channels/whatsapp/inbound.ts:106
assertOutboundAllowed出站白名单闸门(收发都过它)src/gateway/channels/whatsapp/outbound.ts:57
resolveRoute把 channel/account/peer 映射到 agentId + sessionKeysrc/gateway/routing/resolve-route.ts:40
runAgentForMessage会话级串行/队列,真正驱动 agent 循环src/gateway/agent-runner.ts:74
群聊三件套提及检测 / 历史缓冲 / 成员追踪src/gateway/group/*
startCronRunner调度轮询:醒来→跑到期任务→重新定闹钟src/cron/runner.ts:24
executeCronJob一次定时任务的完整执行src/cron/executor.ts:94
evaluateSuppression心跳去重:"无事就沉默"src/gateway/heartbeat/suppression.ts:58
cronTool / heartbeatTool暴露给 agent 自己增删定时任务的工具src/tools/cron/cron-tool.ts:87src/tools/heartbeat/heartbeat-tool.ts:83

主线走一遍(高层): 一条 WhatsApp 消息进来,先在 inbound.ts 被去重和入站访问控制过滤;进 handleInbound 后,群聊要先过 @提及门控,再由 resolveRoute 算出该用哪个会话;runAgentForMessage 在该会话上串行地跑一遍 agent 循环;拿到答案后清洗成 WhatsApp 能显示的纯文本,过一遍出站白名单再发回。 cron 那条链几乎一样,只是触发者是闹钟、跑的是隔离会话、且答案要先过"沉默"判定。


3. 核心原理:入站与网关主流程

这一节顺着一条真实入站消息,把 handleInbound(src/gateway/gateway.ts:44)拆开讲。

3.1 群聊 @提及门控:被点名才开口,但一直在听

要解决的小问题: 私聊很简单——每句话都是对你说的。群聊里如果每条消息都回,机器人会变成刷屏噪音。 但如果不看的消息就完全丢掉,等它被 @ 到时又缺了上下文。

思路: 分成两条路——没被 @ 就"只记不答",被 @ 了就把攒下来的历史一次性喂进去。

handleInbound 一进来就先处理群聊分支(src/gateway/gateway.ts:51):

// 示意,非源码 —— 群聊门控的骨架
if (isGroup) {
noteGroupMember(chatId, senderId, senderName); // 记下谁在群里
if (!isBotMentioned({ ... })) {
recordGroupMessage(chatId, { ... }); // 攒进历史缓冲,不回复
return; // 提前返回,省掉整个 agent 开销
}
}

真实实现里,提及检测 isBotMentioned(src/gateway/group/mention-detection.ts:4)有两层:优先比对 Baileys 给的 mentionedJids(把机器人自己的电话 JID 和 LID 都归一成 base 标识来匹配),匹配不上再兜底 ——检查消息正文里有没有出现机器人的电话数字(selfE164)。

没被 @ 的消息不是丢掉,而是进 recordGroupMessage(src/gateway/group/history-buffer.ts:21)的内存缓冲。 这个缓冲有两道容量闸:每个群最多留 MAX_ENTRIES_PER_GROUP = 50 条(超了从头截),群的总数最多 MAX_GROUPS = 200(超了 LRU 淘汰最老的群)。

等真被 @ 到,getAndClearGroupHistory(history-buffer.ts:44)一次性取出并清空缓冲, formatGroupHistoryContext(history-buffer.ts:54)把它拼成一段带标记的上下文塞进 query:

[Group messages since your last reply - for context]
Alice (+1555...): 有人看 NVDA 财报了吗
Bob (+1555...): 还没,等 @Dexter

[Current message - respond to this]
Carol (+1555...): @Dexter 帮我们看下 NVDA 这季怎么样

关键细节: 成员追踪 noteGroupMember(src/gateway/group/member-tracker.ts:12)维护一张 groupId → {电话 → 显示名} 的花名册;回复时 formatGroupMembersList(member-tracker.ts:27)把 观察到的显示名群元数据里的参与者 合并,让 agent 知道"能按名字称呼谁"。这份名单最终进入 GroupContext(见 3.4)。

3.2 路由:一条消息该落到哪个会话

要解决的小问题: 同一个进程可能服务多个"agent 人格"、多个账号、多个群和私聊。得有个规则决定 "这条消息用哪套配置、写进哪段对话历史"。

resolveRoute(src/gateway/routing/resolve-route.ts:40)按从具体到宽泛的优先级匹配 bindings:

peer 精确匹配 (binding.peer) ──最优先
↓ 没中
account 匹配 (binding.account)
↓ 没中
channel 匹配 (binding.channel)
↓ 没中
default(内置默认 agent)

匹配结果里最重要的是 sessionKey,由 buildSessionKey(resolve-route.ts:24)拼成一个稳定字符串:

agent:<agentId>:<channel>:<accountId>:<peerKind>:<peerId>

比如 agent:default:whatsapp:default:group:1203...@g.us注意:群用 chatId(群 JID)当 peer, 私聊用 senderId(gateway.ts:77),所以同一个群里所有人共享一段对话历史,而私聊按人分开。

拿到路由后,upsertSessionMeta(src/gateway/sessions/store.ts:41)把会话元数据(最后发往谁、哪个账号、 哪个 agent、更新时间)落到磁盘 sessions/<agentId>/sessions.json。这份元数据后面被 cron 复用—— 定时任务靠它找"该把结果发给谁"(见 4.2)。

3.3 会话级串行 + 忙时中途注入

要解决的小问题: 你在 agent 还在思考时又补发了一句。三种糟糕的处理方式:丢掉、并发跑两个循环 (会撞车)、或者严格排队(你干等)。Dexter 要的是第四种:当前这轮把你的新话接进去

网关侧的决策在 handleInbound(src/gateway/gateway.ts:164):

// 示意,非源码 —— 忙时改为入队,而不是新起一轮
if (isSessionRunning(route.sessionKey)) {
enqueueForSession(route.sessionKey, model, query); // 交给正在跑的那轮去消化
return;
}
const answer = await runAgentForMessage({ sessionKey: route.sessionKey, query, ... });

runAgentForMessage(src/gateway/agent-runner.ts:74)用一条 Promise 尾巴实现同会话串行、跨会话并发:

// 示意,非源码 —— 每个会话一条串行链
session.tail = session.tail.then(run, run); // 无论上一轮成功失败都接着排
await session.tail;

真源码在 src/gateway/agent-runner.ts:144.then(run, run) 两个参数都传 run,保证即使上一轮抛错, 下一轮照样执行,不会因为一次失败卡死整条链。

那"中途注入"发生在哪?agent 主循环每轮工具回合之间会去排空队列:drainQueue (src/agent/agent.ts:438)把队里的消息合并成一条 HumanMessage 插进对话(agent.ts:270-273), 发一个 queue_drain 事件。队列本身是优先级队列(src/utils/message-queue.ts:42),next(用户输入) 排在 later(系统/心跳)前面。

还有一个收尾兜底:agent 那轮结束后,runAgentForMessage 再检查一次队列——如果在它最后一次 check 之后又来了消息,就 dequeueAll 合并、直接再起一轮 follow-up(agent-runner.ts:104-126), 不让任何一条掉地上。

3.4 打字指示、GroupContext 与回发清洗

打字指示循环: agent 跑得久,用户会以为死了。所以 handleInbound 起一个每 5 秒刷新一次的 composing 指示(gateway.ts:96-110),回复发出前 stopTypingLoop() 停掉。群聊用 inbound.sendComposing()(绕过出站严格校验),私聊走 sendComposing

GroupContext: 群聊分支把群名、成员名单、激活模式打包成 GroupContext(gateway.ts:152), 传进 agent。buildGroupSection(src/agent/prompts.ts:180)据此在系统提示里加一段"你在群里、被 @ 唤醒、 回复要简短、别名字都列给你了"的行为约束。类型定义见 src/agent/prompts.ts:171

回发清洗: agent 的输出常是 Markdown,但 WhatsApp 只认自己那套弱格式。回发前统一过 cleanMarkdownForWhatsApp(src/gateway/utils.ts:47)转换。私聊走 sendMessageWhatsApp, 群聊走 inbound.reply()(gateway.ts:189-200)。空答案不发(gateway.ts:203)。

3.5 通道底座:Baileys 收发、去重、断线重连

网关和 WhatsApp 之间隔着 Baileys 库(@whiskeysockets/baileys)。这层有三个值得记的防御:

入站去重。 WhatsApp 会重复投递同一条消息。isRecentInboundMessage (src/gateway/channels/whatsapp/dedupe.ts:42)用 accountId:remoteJid:messageId 当键, 命中过就跳过(inbound.ts:172)。缓存有 20 分钟 TTL、上限 5000 条(dedupe.ts:1-2)。

入站访问控制。 收到消息先过 checkInboundAccessControl(inbound.ts:199),按 dmPolicy/groupPolicy/allowFrom 决定放不放行。append 类型(离线补投的历史)只标已读、 不自动回复(inbound.ts:286)。

断线重连。 连接断了走指数退避重连。computeBackoff (src/gateway/channels/whatsapp/reconnect.ts:23)按 initialMs * factor^attempt 加抖动、封顶 maxMs;重连循环在 runtime.ts:93-102,超过 maxAttempts 就放弃。若 WhatsApp 报"已登出"(401), 清掉凭据、要求重新登录(runtime.ts:82-87)。


4. 核心原理:定时自主(Cron 与心跳)

这是本章的"自主"一半——让 Dexter 不靠人触发也能干活。先建立直觉,再看执行。

4.1 调度轮询:醒来、跑到期任务、重新定闹钟

思路: 不给每个任务各挂一个定时器(进程重启就丢),而是把任务状态落盘jobs.json, 用一个单定时器反复"醒来看看有没有到期的"。

startCronRunner(src/cron/runner.ts:24)的循环是经典三步:

startup: 给所有 enabled 但没算过下次时间的任务补上 nextRunAtMs


tick: load jobs.json → 挑出 nextRunAtMs <= now 的 → 串行执行 → 回到 scheduleNext


scheduleNext: 找所有任务里最早的 nextRunAtMs → setTimeout 到那时(但最多睡 60s)

两个关键设计:

  • 每个 tick 都重新 loadCronStore()(runner.ts:59),所以 agent 用工具刚加的任务下一拍就生效, 不用重启进程。
  • 睡眠上限 MAX_TIMER_DELAY_MS = 60_000(runner.ts:13)。哪怕最近的任务在 3 小时后,它也最多睡 60 秒就醒一次——为的是及时发现"刚被加进来的新任务"。定时器还 unref()(runner.ts:107),不阻止进程退出。

4.2 一次任务的执行:executeCronJob

executeCronJob(src/cron/executor.ts:94)是整条自主链的心脏。它按顺序过一串闸门,任何一关不过就 "跳过并重新定下次",绝不硬发:

① active-hours 门 ── 不在活跃时段?→ 跳过,重排
② 找投递目标 ── 没有历史会话可发?→ 跳过,重排
③ 出站校验 ── assertOutboundAllowed 不过?→ 跳过,重排
④ 起【隔离】agent + 注入 HEARTBEAT_OK 指令
⑤ suppression ── "无事/重复"?→ 记为 suppressed,不发
⑥ 投递 + fulfillment(once 自动禁用 / ask 追问 / keep 继续)

① active-hours 门。 isWithinActiveHours(executor.ts:49)按任务的时区(默认 America/New_York)判断当前是否落在 startenddaysOfWeek(默认周一到周五)内。盯盘任务不该在 凌晨或周末打扰你。

② 找投递目标。 cron 任务没有"当前对话",它靠 findTargetSession(executor.ts:81)去 sessions.json 里挑最近更新过、且有 lastTo会话——也就是"你最近在跟它聊的那个窗口"。这正是 3.2 里 upsertSessionMeta 落盘的元数据在被复用。

④ 起隔离 agent + "无事就沉默"指令。 这是精华。任务用 isolatedSession: true 跑 (executor.ts:141-149),不带持久历史和记忆(省 token、互不污染)。query 里硬编一段强指令 (executor.ts:132-136):

[CRON JOB: NVDA price watch]
...你的监控 prompt...

## Instructions
- 如果条件【没】满足,你必须只回复:HEARTBEAT_OK
- 不要发状态更新或进度汇报——用户只想在条件满足时才收到消息
- 不要说"暂无需操作""仍低于目标""还没到"——就回 HEARTBEAT_OK
- 只有真有可执行的结论时才发真正的消息

⑤ suppression 去重。 就算这么叮嘱,模型有时还是会话痨。evaluateSuppression (src/gateway/heartbeat/suppression.ts:58)再兜一层,把这几类判为"该沉默":

情况判定 reason怎么识别
空回复emptytrim 后为空
就是那个令牌ok-token匹配 HEARTBEAT_OK(容忍 ** 粗体和句末标点)
"没啥可报"的话痨no-action命中 no action needed/nothing to report/everything is fine 等模式
24 小时内重复duplicate清洗后文本与上次完全相同且在 DUPLICATE_WINDOW_MS

⑥ 投递与 fulfillment。 不该沉默时,清洗 Markdown、sendMessageWhatsApp 发出,并更新去重状态。 接着看 fulfillment 模式(executor.ts:186):

  • once:发完自动禁用这个任务(价格提醒该一次性,别发完还刷屏)。
  • ask:query 里额外让 agent 问"要继续盯吗"(executor.ts:133-135)。
  • keep(默认):继续按日程跑。

无论走到哪个分支,最后都 scheduleNextRun(executor.ts:199)用 computeNextRunAtMs 算好下次。

4.3 算下一次:computeNextRunAtMs

computeNextRunAtMs(src/cron/schedule.ts:10)按三种日程类型分别算下次触发的毫秒时间戳:

类型语义关键行为
at一次性,某个 ISO 时刻时刻已过 → 返回 undefined(过期)
every固定间隔(毫秒)anchorMs 起按周期推,正好落在整点会推到下一周期
croncron 表达式 + 时区交给 croner 库算,并处理它"年份回退"的边界,保证 MIN_REFIRE_GAP_MS 最小间隔防自旋

返回 undefined 是一个约定信号:代表"这个日程没有下次了"(一次性任务已过期或表达式非法), 上层据此把任务 enabled = false(executor.ts:204-208)。

4.4 错误退避与自我禁用

任务跑挂了怎么办?handleJobError(src/cron/executor.ts:225)按类型分开处理:

  • 一次性 at 任务:重试到 MAX_AT_RETRIES = 3 次,还挂就禁用(executor.ts:239-245)。
  • 重复任务:指数退避——下次时间取"正常下次"和"退避时间"里更晚的那个(executor.ts:246-251), 错误连续几次就越等越久。退避梯度 BACKOFF_SCHEDULE_MS(executor.ts:26)是 30s → 1min → 5min → 15min → 60min。

日程本身反复算不出来(比如 cron 表达式坏了),scheduleErrorCount 累计到 SCHEDULE_ERROR_DISABLE_THRESHOLD = 3 也会禁用任务(executor.ts:213-218),避免一个坏任务永远卡在 "每 tick 都炸"的死循环里。

4.5 心跳:cron 的一个特例

"心跳"不是独立机制,而是一个名叫 Heartbeat 的 cron 任务。网关启动时 ensureHeartbeatCronJob(src/cron/heartbeat-migration.ts:17)读 gateway.json 里的心跳配置, 把 .dexter/HEARTBEAT.md 的清单(buildHeartbeatQuery)编成一个 every 任务写进 jobs.json; 配置变了就更新那条任务。默认清单是"大盘异动 + 重大财经新闻"(src/gateway/heartbeat/prompt.ts:7)。

所以心跳和任何 cron 任务共享 4.1–4.4 的全部逻辑——包括那句灵魂指令"无事就沉默"。这也是为什么 HEARTBEAT_OK_TOKEN(suppression.ts:1)这个名字里带 heartbeat,却被通用 cron 执行器复用。

4.6 暴露给 agent 的两把工具

关键闭环:agent 自己就能增删定时任务,不用人去改配置文件。

  • cronTool(src/tools/cron/cron-tool.ts:87):list/add/update/remove/run 五个动作, 直接读写 jobs.json。你说一句"盯着 NVDA 到 150",agent 就调它 add 一个 fulfillment: once 的任务。
  • heartbeatTool(src/tools/heartbeat/heartbeat-tool.ts:83):view/update 心跳清单 .dexter/HEARTBEAT.md;更新时自动在 gateway 配置里启用心跳、并同步那条 Heartbeat cron 任务 (heartbeat-tool.ts:110-115)。

因为 runner 每 tick 都重读 jobs.json(4.1),工具写完下一拍就被调度,无需重启。


5. 巧妙之处(可借鉴的技术)

① 出站白名单是收发双方的公共底线。 无论是人触发的回复(gateway.ts:124)、还是 cron 主动投递 (executor.ts:120)、还是底层 sendMessageWhatsApp(outbound.ts:101),都要先过 assertOutboundAllowed(src/gateway/channels/whatsapp/outbound.ts:57)。它在严格模式下忽略通配符 *、要求显式列出收件人 E.164(outbound.ts:79-88),群目标则看 groupPolicy。哪怕上游 prompt 被 注入、诱导它给陌生号码发消息,这道闸也拦得住。这就是全章反复出现的那条安全暗线。

② "无事就沉默"是双层保险。 先用 prompt 指令让模型自觉回 HEARTBEAT_OK (executor.ts:132-136),再用 evaluateSuppression(suppression.ts:58)在代码侧兜底拦截空/令牌/ 话痨/重复。既信任模型、又不依赖模型——自主 agent 最怕的"定时刷屏"被治住了。

③ cron 和子 agent 全是隔离会话。 定时任务用 isolatedSession: true(executor.ts:147)跑, 不碰主会话历史与记忆。省 token 之外,更重要的是互不污染:一个后台盯盘任务不该把噪音塞进你正在聊的对话。

④ 状态落盘 + 单定时器轮询 = 抗重启的调度。 不给每个任务挂内存定时器,而是把 nextRunAtMs 写进 jobs.json、用一个封顶 60s 的循环反复捞到期任务(runner.ts)。进程重启后 startup 重新补算下次时间 (runner.ts:30),任务不丢。saveCronStore 还用"写临时文件再 rename"做原子写(src/cron/store.ts:31-48), 避免写一半崩了留下坏 JSON。

⑤ 群聊"只记不答"省掉全部 agent 开销。 没被 @ 的群消息在 handleInbound 顶部就 recordGroupMessagereturn(gateway.ts:63-73),连路由和 agent 都不启动——旁听不花钱,被点名才干活。


6. 边界与局限(诚实地说)

  • 单进程、内存态多。 群历史缓冲、成员花名册、会话运行态、suppression 状态都在内存 (history-buffer.ts:16member-tracker.ts:7agent-runner.ts:15executor.ts:24)。 进程重启即丢——重启后群上下文和"上次发过什么"的去重记忆都归零(cron 任务本身因落盘而幸存)。
  • 投递目标是"最近那个会话"。 cron 靠 findTargetSession(executor.ts:81)挑最近更新的会话, 而且写死用 default agent 的 store。多用户/多 agent 场景下,定时结果只会发给最近活跃的那个人, 代码里看不到"这个任务归属哪个用户"的绑定。
  • 提及检测偏宽。 兜底逻辑是"正文里出现机器人电话数字就算被 @"(mention-detection.ts:34-38), 有人贴了含该串数字的内容可能误触发。
  • cron 任务串行执行。 一个 tick 里到期的任务是一个个跑的(runner.ts:68),某个任务的 agent 跑得慢 会拖后面的。适合轻量盯盘,不适合大批量并发任务。
  • 本章不覆盖: agent 循环内部与上下文压缩(见 01/03)、 记忆检索(见 04)、工具权限闸门(见 02)。

7. 代码地图(导航索引)

按符号名 grep 比按行号更抗漂移。以下是本章关键落点:

主题文件路径符号名
网关常驻入口src/gateway/gateway.tsstartGateway
入站主流程src/gateway/gateway.tshandleInbound
群 @提及检测src/gateway/group/mention-detection.tsisBotMentioned
群历史缓冲src/gateway/group/history-buffer.tsrecordGroupMessage / getAndClearGroupHistory / formatGroupHistoryContext
群成员追踪src/gateway/group/member-tracker.tsnoteGroupMember / formatGroupMembersList
路由解析src/gateway/routing/resolve-route.tsresolveRoute / buildSessionKey
会话元数据落盘src/gateway/sessions/store.tsupsertSessionMeta / resolveSessionStorePath
会话串行 + 队列src/gateway/agent-runner.tsrunAgentForMessage / isSessionRunning / enqueueForSession
队列(优先级 + 中途注入)src/utils/message-queue.tscreateMessageQueue / dequeueAll
GroupContext 系统提示段src/agent/prompts.tsGroupContext / buildGroupSection
回发 Markdown 清洗src/gateway/utils.tscleanMarkdownForWhatsApp
Baileys 收消息src/gateway/channels/whatsapp/inbound.tsmonitorWebInbox
出站白名单闸门src/gateway/channels/whatsapp/outbound.tsassertOutboundAllowed / sendMessageWhatsApp
入站去重src/gateway/channels/whatsapp/dedupe.tsisRecentInboundMessage
断线重连退避src/gateway/channels/whatsapp/reconnect.tscomputeBackoff / resolveReconnectPolicy
cron 调度轮询src/cron/runner.tsstartCronRunner
cron 任务执行src/cron/executor.tsexecuteCronJob / isWithinActiveHours / findTargetSession / handleJobError
算下一次触发src/cron/schedule.tscomputeNextRunAtMs
cron 状态落盘(原子写)src/cron/store.tsloadCronStore / saveCronStore
心跳去重"无事沉默"src/gateway/heartbeat/suppression.tsevaluateSuppression / HEARTBEAT_OK_TOKEN
心跳任务迁移src/cron/heartbeat-migration.tsensureHeartbeatCronJob
心跳 query 构建src/gateway/heartbeat/prompt.tsbuildHeartbeatQuery
cron 工具(给 agent)src/tools/cron/cron-tool.tscronTool
heartbeat 工具(给 agent)src/tools/heartbeat/heartbeat-tool.tsheartbeatTool