主题
OpenClaw 源码解读(十三)消息路由与会话管理
本篇深入分析 OpenClaw 的消息路由引擎——从一条入站消息到达 Gateway,到它被路由到正确的 Agent 会话,再到配对验证和会话持久化的完整链路。路由系统是整个平台的"神经中枢",决定了"谁的消息,交给哪个 Agent,在哪个会话中处理"。
目录
- 一、路由系统全景
- 二、Account ID 体系
- 三、Session Key 会话键
- 四、Binding 绑定与路由决策
- 五、DM Scope 与身份链接
- 六、配对系统 (Pairing)
- 七、渠道注册表与 Dock
- 八、会话记录与 Last Route
- 九、线程路由 (Thread Routing)
- 十、缓存架构与性能优化
- 十一、模块关系与设计总结
一、路由系统全景
1.1 核心问题
当一条消息从 WhatsApp/Telegram/Discord 等渠道到达 Gateway 时,系统需要回答三个关键问题:
1. 这条消息来自哪个渠道的哪个账户? → Account ID
2. 这条消息应该交给哪个 Agent 处理? → Agent Routing (Bindings)
3. 这条消息属于哪个会话上下文? → Session Key1.2 路由链路总览
入站消息 (WhatsApp DM / Telegram 群组 / Discord 频道 ...)
│
├─ Step 1: 渠道识别 → normalizeChannelId("telegram") → "telegram"
│
├─ Step 2: 账户归一化 → normalizeAccountId("MyBot_2") → "mybot_2"
│
├─ Step 3: 消息类型分类 → normalizeChatType("group") → "group"
│
├─ Step 4: 路由决策 → resolveAgentRoute({cfg, channel, accountId, peer})
│ ├─ 遍历 Bindings(按优先级分层匹配)
│ ├─ 确定目标 agentId
│ └─ 生成 sessionKey
│
├─ Step 5: 配对验证 → DM 策略检查(pairing / allowlist / open)
│
└─ Step 6: 会话记录 → recordInboundSession() + updateLastRoute()1.3 核心文件清单
| 文件 | 职责 |
|---|---|
src/routing/account-id.ts | Account ID 归一化与缓存 |
src/routing/session-key.ts | Session Key 生成与解析 |
src/routing/resolve-route.ts | 路由决策引擎(Binding 匹配) |
src/routing/bindings.ts | Binding 配置读取与归一化 |
src/routing/account-lookup.ts | 多账户配置查找 |
src/channels/chat-type.ts | 消息类型(direct/group/channel) |
src/channels/registry.ts | 渠道注册表与元数据 |
src/channels/dock.ts | 渠道 Dock(轻量级渠道能力描述) |
src/channels/session.ts | 会话记录与 Last Route 更新 |
src/pairing/pairing-store.ts | 配对请求存储与 AllowFrom 管理 |
src/pairing/pairing-challenge.ts | 配对挑战发起 |
src/pairing/setup-code.ts | 设备配对码生成 |
消息路由与会话管理/01-infographic-routing-overview-1775150748154.png)
二、Account ID 体系
2.1 为什么需要 Account ID?
同一个渠道可以有多个 Bot 账户。例如你在 Telegram 上运行两个 Bot(一个工作用,一个个人用),它们需要通过 Account ID 区分:
jsonc
{
"channels": {
"telegram": {
"accounts": {
"work-bot": { "token": "...", "allowFrom": [...] },
"personal-bot": { "token": "...", "allowFrom": [...] }
}
}
}
}2.2 归一化规则 (src/routing/account-id.ts)
Account ID 的归一化确保不同的输入格式都指向同一个账户:
typescript
const VALID_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
function canonicalizeAccountId(value: string): string {
if (VALID_ID_RE.test(value)) {
return value.toLowerCase();
}
return value
.toLowerCase() // 全部小写
.replace(INVALID_CHARS_RE, "-") // 非法字符替换为 "-"
.replace(LEADING_DASH_RE, "") // 去掉前导 "-"
.replace(TRAILING_DASH_RE, "") // 去掉尾部 "-"
.slice(0, 64); // 最大 64 字符
}归一化示例:
| 输入 | 归一化结果 | 说明 |
|---|---|---|
"MyBot" | "mybot" | 大写转小写 |
"my bot #2" | "my-bot--2" | 空格和特殊字符变 - |
"" / null / undefined | "default" | 空值回退到默认 |
"__proto__" | undefined → "default" | 阻止原型污染! |
2.3 原型污染防护
注意第 28 行的 isBlockedObjectKey(canonical) 检查——这是一个精巧的安全防护:
typescript
function normalizeCanonicalAccountId(value: string): string | undefined {
const canonical = canonicalizeAccountId(value);
if (!canonical || isBlockedObjectKey(canonical)) {
return undefined; // 阻止 "__proto__", "constructor", "toString" 等
}
return canonical;
}如果攻击者设法让 Account ID 为 "__proto__",在 JavaScript 中使用 accounts[accountId] 访问时会触发原型链操作,可能导致安全漏洞。
2.4 LRU 缓存
归一化结果被缓存在一个手动实现的 LRU Map 中(最大 512 条):
typescript
const ACCOUNT_ID_CACHE_MAX = 512;
const normalizeAccountIdCache = new Map<string, string>();
function setNormalizeCache<T>(cache: Map<string, T>, key: string, value: T): void {
cache.set(key, value);
if (cache.size <= ACCOUNT_ID_CACHE_MAX) {
return;
}
// 超出容量时,删除最早插入的条目(Map 保持插入顺序)
const oldest = cache.keys().next();
if (!oldest.done) {
cache.delete(oldest.value);
}
}这利用了 ES6 Map 的插入顺序保证——keys().next() 总是返回最早插入的键,模拟了 LRU 淘汰。
消息路由与会话管理/02-infographic-account-id-1775150749169.png)
2.5 多账户查找 (src/routing/account-lookup.ts)
当需要从配置的 accounts 对象中查找某个 Account ID 对应的配置时:
typescript
export function resolveAccountEntry<T>(
accounts: Record<string, T> | undefined,
accountId: string,
): T | undefined {
if (Object.hasOwn(accounts, accountId)) {
return accounts[accountId]; // 精确匹配优先
}
// 回退到大小写不敏感匹配
const matchKey = Object.keys(accounts).find((key) => key.toLowerCase() === normalized);
return matchKey ? accounts[matchKey] : undefined;
}这确保了用户在配置中写 "MyBot" 而路由传入 "mybot" 时仍能匹配。
三、Session Key 会话键
3.1 Session Key 的作用
Session Key 是会话持久化和并发隔离的核心标识符。每个 Session Key 对应一个独立的 Agent 会话上下文(对话历史、工具状态等)。
3.2 Session Key 的格式
agent:<agentId>:<rest>具体格式取决于消息类型和 DM Scope:
| 场景 | Session Key 格式 | 示例 |
|---|---|---|
| DM (dmScope=main) | agent:<agentId>:main | agent:main:main |
| DM (dmScope=per-peer) | agent:<agentId>:direct:<peerId> | agent:main:direct:12345 |
| DM (per-channel-peer) | agent:<agentId>:<channel>:direct:<peerId> | agent:main:telegram:direct:12345 |
| DM (per-account-channel-peer) | agent:<agentId>:<channel>:<accountId>:direct:<peerId> | agent:main:telegram:work-bot:direct:12345 |
| 群组消息 | agent:<agentId>:<channel>:group:<groupId> | agent:main:telegram:group:chatid123 |
| 频道消息 | agent:<agentId>:<channel>:channel:<channelId> | agent:main:discord:channel:456 |
| 线程消息 | <baseKey>:thread:<threadId> | agent:main:slack:channel:789:thread:ts123 |
3.3 DM 会话收敛
dmScope 配置决定了 DM 消息如何映射到会话,这是 OpenClaw 最巧妙的设计之一:
dmScope: "main" (默认)
┌──────────────────────────────────────────────┐
│ WhatsApp 用户 A 的 DM ──┐ │
│ Telegram 用户 B 的 DM ──┼──→ agent:main:main │ ← 全部汇聚到同一个 main 会话
│ Discord 用户 C 的 DM ──┘ │
└──────────────────────────────────────────────┘
dmScope: "per-peer"
┌──────────────────────────────────────────────────────┐
│ WhatsApp 用户 A ──→ agent:main:direct:user_a │
│ Telegram 用户 B ──→ agent:main:direct:user_b │ ← 每人一个会话
│ Discord 用户 C ──→ agent:main:direct:user_c │
└──────────────────────────────────────────────────────┘
dmScope: "per-channel-peer"
┌────────────────────────────────────────────────────────────┐
│ WhatsApp 用户 A ──→ agent:main:whatsapp:direct:user_a │
│ Telegram 用户 A ──→ agent:main:telegram:direct:user_a │ ← 同一人不同渠道也隔离
└────────────────────────────────────────────────────────────┘为什么默认 main? 因为 OpenClaw 是个人 AI 助手——操作者就是唯一的用户,所有 DM 本质上都是"跟自己对话",汇聚到同一个 main 会话最符合直觉。
消息路由与会话管理/03-infographic-dm-scope-1775150750061.png)
3.4 Session Key 生成核心 (buildAgentPeerSessionKey)
这是整个路由系统最核心的函数之一(src/routing/session-key.ts):
typescript
export function buildAgentPeerSessionKey(params: {
agentId: string;
mainKey?: string;
channel: string;
accountId?: string | null;
peerKind?: ChatType | null; // "direct" | "group" | "channel"
peerId?: string | null;
identityLinks?: Record<string, string[]>;
dmScope?: "main" | "per-peer" | "per-channel-peer" | "per-account-channel-peer";
}): string {
const peerKind = params.peerKind ?? "direct";
if (peerKind === "direct") {
const dmScope = params.dmScope ?? "main";
let peerId = (params.peerId ?? "").trim();
// 身份链接:将不同渠道的同一用户映射到统一 ID
const linkedPeerId = resolveLinkedPeerId({...});
if (linkedPeerId) peerId = linkedPeerId;
// 根据 dmScope 生成不同格式的 key
if (dmScope === "per-account-channel-peer" && peerId) {
return `agent:${agentId}:${channel}:${accountId}:direct:${peerId}`;
}
if (dmScope === "per-channel-peer" && peerId) {
return `agent:${agentId}:${channel}:direct:${peerId}`;
}
if (dmScope === "per-peer" && peerId) {
return `agent:${agentId}:direct:${peerId}`;
}
// dmScope === "main" → 回退到 main session
return buildAgentMainSessionKey({agentId, mainKey});
}
// 群组/频道消息:总是按 channel + peerKind + peerId 隔离
return `agent:${agentId}:${channel}:${peerKind}:${peerId}`;
}3.5 Agent ID 归一化
Agent ID 的归一化规则与 Account ID 相同:
typescript
export const DEFAULT_AGENT_ID = "main";
export function normalizeAgentId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim();
if (!trimmed) return DEFAULT_AGENT_ID;
if (VALID_ID_RE.test(trimmed)) return trimmed.toLowerCase();
// 最佳努力回退
return trimmed.toLowerCase()
.replace(INVALID_CHARS_RE, "-")
.replace(LEADING_DASH_RE, "")
.replace(TRAILING_DASH_RE, "")
.slice(0, 64) || DEFAULT_AGENT_ID;
}3.6 Session Key 形状分类
系统需要识别旧版本的 Session Key 格式以实现向后兼容:
typescript
export type SessionKeyShape = "missing" | "agent" | "legacy_or_alias" | "malformed_agent";
export function classifySessionKeyShape(sessionKey: string | undefined): SessionKeyShape {
if (!raw) return "missing";
if (parseAgentSessionKey(raw)) return "agent"; // "agent:xxx:yyy" 格式
return raw.startsWith("agent:") ? "malformed_agent" : "legacy_or_alias";
}四、Binding 绑定与路由决策
4.1 Binding 的概念
Binding 是一种声明式的路由规则,告诉 OpenClaw:"当消息匹配这些条件时,路由到这个 Agent"。
jsonc
{
"bindings": [
{
"agentId": "work-assistant",
"match": {
"channel": "slack",
"accountId": "work-bot",
"peer": { "kind": "group", "id": "C01234567" }
}
},
{
"agentId": "personal-assistant",
"match": {
"channel": "telegram",
"accountId": "*" // 通配符:匹配所有账户
}
}
]
}4.2 七层优先级匹配
路由引擎按严格的优先级顺序遍历 7 个匹配层级(src/routing/resolve-route.ts):
优先级 1: binding.peer ← 精确匹配对话对象(群组/用户 ID)
优先级 2: binding.peer.parent ← 线程父级匹配(thread 继承 parent binding)
优先级 3: binding.guild+roles ← Discord 服务器 + 角色匹配
优先级 4: binding.guild ← Discord 服务器匹配(无角色)
优先级 5: binding.team ← Slack 团队匹配
优先级 6: binding.account ← 账户级匹配(非通配符)
优先级 7: binding.channel ← 渠道级匹配(通配符 accountId="*")为什么这样排序? 越具体的规则优先级越高。一个指定了精确群组 ID 的 binding 应该覆盖一个只指定了渠道的 binding。
消息路由与会话管理/04-infographic-binding-tiers-1775150750821.png)
4.3 路由决策核心代码
typescript
export function resolveAgentRoute(input: ResolveAgentRouteInput): ResolvedAgentRoute {
// 1. 归一化所有输入
const channel = normalizeToken(input.channel);
const accountId = normalizeAccountId(input.accountId);
const peer = /* 归一化 peer */;
const dmScope = input.cfg.session?.dmScope ?? "main";
// 2. 检查缓存
const routeCache = resolveRouteCacheForConfig(input.cfg);
const cachedRoute = routeCache.get(routeCacheKey);
if (cachedRoute) return { ...cachedRoute };
// 3. 获取当前 channel+account 的所有 binding
const bindingsIndex = getEvaluatedBindingIndexForChannelAccount(cfg, channel, accountId);
// 4. 按 7 层优先级遍历
const tiers = [
{ matchedBy: "binding.peer", candidates: collectPeerIndexedBindings(index, peer) },
{ matchedBy: "binding.peer.parent", candidates: collectPeerIndexedBindings(index, parentPeer) },
{ matchedBy: "binding.guild+roles", candidates: index.byGuildWithRoles.get(guildId) },
{ matchedBy: "binding.guild", candidates: index.byGuild.get(guildId) },
{ matchedBy: "binding.team", candidates: index.byTeam.get(teamId) },
{ matchedBy: "binding.account", candidates: index.byAccount },
{ matchedBy: "binding.channel", candidates: index.byChannel },
];
for (const tier of tiers) {
const matched = tier.candidates.find(c => matchesBindingScope(c.match, scope));
if (matched) {
return choose(matched.binding.agentId, tier.matchedBy);
}
}
// 5. 无匹配 → 使用默认 Agent
return choose(resolveDefaultAgentId(cfg), "default");
}4.4 返回值结构
typescript
type ResolvedAgentRoute = {
agentId: string; // 目标 Agent ID
channel: string; // 归一化后的渠道名
accountId: string; // 归一化后的账户 ID
sessionKey: string; // 生成的会话键(用于持久化和并发)
mainSessionKey: string; // 对应的 main 会话键(DM 收敛用)
matchedBy: "binding.peer" | "binding.guild" | ... | "default"; // 匹配方式(调试用)
};4.5 Binding 索引结构
为了高效匹配,binding 被预处理为一个索引结构:
typescript
type EvaluatedBindingsIndex = {
byPeer: Map<string, EvaluatedBinding[]>; // "group:123" → [binding1, ...]
byGuildWithRoles: Map<string, EvaluatedBinding[]>; // guildId → [bindings with roles]
byGuild: Map<string, EvaluatedBinding[]>; // guildId → [bindings without roles]
byTeam: Map<string, EvaluatedBinding[]>; // teamId → [...]
byAccount: EvaluatedBinding[]; // account-level bindings
byChannel: EvaluatedBinding[]; // channel-level bindings (wildcard)
};这种索引结构确保匹配操作是 O(1) 或 O(n_matches),而非 O(n_all_bindings)。
4.6 Peer Kind 兼容匹配
Discord 和 Slack 中,"group" 和 "channel" 的语义有重叠。路由引擎做了兼容处理:
typescript
function peerKindMatches(bindingKind: ChatType, scopeKind: ChatType): boolean {
if (bindingKind === scopeKind) return true;
// "group" 和 "channel" 互相兼容
const both = new Set([bindingKind, scopeKind]);
return both.has("group") && both.has("channel");
}4.7 Agent 存在性验证
路由决策确定 agentId 后,还会验证这个 Agent 是否真的存在于配置中:
typescript
function pickFirstExistingAgentId(cfg: OpenClawConfig, agentId: string): string {
const lookup = resolveAgentLookupCache(cfg);
const resolved = lookup.byNormalizedId.get(normalized);
if (resolved) return resolved;
// Agent 不存在 → 回退到默认 Agent
return lookup.fallbackDefaultAgentId;
}五、DM Scope 与身份链接
5.1 Identity Links(身份链接)
当同一个用户在多个渠道发消息时,默认情况下 per-peer 模式会为每个渠道创建独立会话。Identity Links 允许将它们合并:
jsonc
{
"session": {
"dmScope": "per-peer",
"identityLinks": {
"alice": ["telegram:12345", "whatsapp:+1234567890", "discord:98765"],
"bob": ["telegram:67890", "slack:U01234"]
}
}
}5.2 身份解析算法
typescript
function resolveLinkedPeerId(params: {
identityLinks?: Record<string, string[]>;
channel: string;
peerId: string;
}): string | null {
// 构建候选 ID 集合
const candidates = new Set<string>();
candidates.add(normalize(peerId)); // "12345"
candidates.add(normalize(`${channel}:${peerId}`)); // "telegram:12345"
// 遍历身份映射表
for (const [canonical, ids] of Object.entries(identityLinks)) {
for (const id of ids) {
if (candidates.has(normalize(id))) {
return canonical; // 返回统一的 canonical 名称,如 "alice"
}
}
}
return null; // 未找到链接
}效果: 当 dmScope="per-peer" 时,Telegram 用户 12345 和 WhatsApp 用户 +1234567890 的消息都会路由到 agent:main:direct:alice,共享同一个会话上下文。
消息路由与会话管理/05-infographic-identity-links-1775150751603.png)
六、配对系统 (Pairing)
6.1 配对的作用
配对(Pairing)是 OpenClaw 的 DM 安全门卫。当一个陌生人通过消息渠道给 Bot 发 DM 时,系统不会直接回复,而是发出一个配对码,要求 Bot 所有者手动审批。
6.2 配对流程
陌生用户发送 DM
│
├─ 1. 生成 8 位配对码(无歧义字母表)
│ "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" ← 去掉了 0/O/1/I 避免视觉混淆
│
├─ 2. 存储配对请求到 JSON 文件
│ ~/.openclaw/credentials/<channel>-pairing.json
│
├─ 3. 回复陌生用户:
│ "OpenClaw: access not configured.
│ userId: 12345
│ Pairing code: HJK3N9PW
│ Ask the bot owner to approve with:
│ openclaw pairing approve telegram HJK3N9PW"
│
├─ 4. Bot 所有者执行审批命令
│ $ openclaw pairing approve telegram HJK3N9PW
│
└─ 5. 系统将用户 ID 加入 AllowFrom 白名单
~/.openclaw/credentials/<channel>-<accountId>-allowFrom.json消息路由与会话管理/06-infographic-pairing-flow-1775150752440.png)
6.3 配对码生成
typescript
const PAIRING_CODE_LENGTH = 8;
const PAIRING_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // 32 个字符
function randomCode(): string {
let out = "";
for (let i = 0; i < PAIRING_CODE_LENGTH; i++) {
const idx = crypto.randomInt(0, PAIRING_CODE_ALPHABET.length); // 密码学安全随机
out += PAIRING_CODE_ALPHABET[idx];
}
return out;
}安全性: 32^8 = 约 1.1 万亿种可能,加上 1 小时过期和最多 3 个待处理请求的限制,暴力破解不可行。
6.4 配对请求管理
配对存储有严格的资源限制:
| 参数 | 值 | 说明 |
|---|---|---|
PAIRING_PENDING_TTL_MS | 1 小时 | 未审批的请求自动过期 |
PAIRING_PENDING_MAX | 3 | 同一渠道最多 3 个待处理请求 |
| 文件锁 | withFileLock | 防止并发写入竞态 |
| 锁超时 | 30 秒 | 防止死锁 |
| 锁重试 | 10 次,指数退避 | 高并发下的韧性 |
过期清理: 每次读取配对请求时都会自动清理过期条目(惰性清理):
typescript
function isExpired(entry: PairingRequest, nowMs: number): boolean {
const createdAt = parseTimestamp(entry.createdAt);
return nowMs - createdAt > PAIRING_PENDING_TTL_MS;
}
// 超出上限时,按 lastSeenAt 排序,保留最新的
function pruneExcessRequests(reqs: PairingRequest[], maxPending: number) {
const sorted = reqs.toSorted((a, b) => resolveLastSeenAt(a) - resolveLastSeenAt(b));
return { requests: sorted.slice(-maxPending), removed: true };
}6.5 AllowFrom 白名单存储
审批通过后,用户 ID 被写入 AllowFrom 白名单文件。这个存储有精心的缓存机制:
读取 AllowFrom 文件
│
├─ stat(filePath) 获取 mtimeMs 和 size
│
├─ 缓存命中?(mtimeMs + size 相同 → 文件未变)
│ ├─ 是 → 返回缓存内容
│ └─ 否 → 读取文件,解析,更新缓存
│
└─ 文件不存在?→ 缓存空结果,避免重复 stat向后兼容: 旧版本的 AllowFrom 文件是渠道级别的(不区分账户),新版本是账户级别的。对于 default 账户,系统会合并两种文件的内容:
typescript
export async function readChannelAllowFromStore(channel, env, accountId) {
if (isDefaultAccount) {
const scopedEntries = await readPath(channel, env, accountId);
const legacyEntries = await readPath(channel, env); // 旧版文件
return dedupePreserveOrder([...scopedEntries, ...legacyEntries]);
}
// 非默认账户严格隔离,不读取旧版文件
return await readNonDefaultAccountAllowFrom({channel, env, accountId});
}6.6 配对挑战发起 (src/pairing/pairing-challenge.ts)
所有渠道的配对流程都通过一个统一的共享函数发起,确保一致性:
typescript
export async function issuePairingChallenge(params: PairingChallengeParams) {
// 1. 创建或更新配对请求(幂等:同一用户重复发消息只生成一个码)
const { code, created } = await params.upsertPairingRequest({
id: params.senderId,
meta: params.meta,
});
// 2. 只在首次创建时发送配对回复(防止刷屏)
if (!created) return { created: false };
// 3. 构建回复文本
const replyText = buildPairingReply({ channel, idLine, code });
// 4. 发送
await params.sendPairingReply(replyText);
return { created: true, code };
}6.7 设备配对码 (src/pairing/setup-code.ts)
除了消息渠道的用户配对,OpenClaw 还支持设备配对(iOS/Android 节点连接 Gateway)。设备配对码是一个 Base64URL 编码的 JSON 负载:
typescript
export function encodePairingSetupCode(payload: PairingSetupPayload): string {
const json = JSON.stringify(payload);
// { "url": "wss://my-gateway:18789", "token": "abc123" }
const base64 = Buffer.from(json, "utf8").toString("base64");
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}URL 解析优先级:
1. 显式配置的 publicUrl
2. gateway.remote.url(如果 preferRemoteUrl)
3. Tailscale Serve/Funnel → wss://<tailnet-host>
4. gateway.remote.url(普通回退)
5. LAN IP(私有 IPv4)
6. Tailnet IP(100.x.x.x)
→ 都失败则报错:"Gateway is only bound to loopback"七、渠道注册表与 Dock
7.1 渠道注册表 (src/channels/registry.ts)
所有内置渠道以固定顺序注册:
typescript
export const CHAT_CHANNEL_ORDER = [
"telegram", "whatsapp", "discord", "irc",
"googlechat", "slack", "signal", "imessage",
] as const;每个渠道都有元数据(ChannelMeta):
typescript
const CHAT_CHANNEL_META = {
telegram: {
id: "telegram",
label: "Telegram",
selectionLabel: "Telegram (Bot API)",
docsPath: "/channels/telegram",
blurb: "simplest way to get started...",
systemImage: "paperplane", // SF Symbol(macOS/iOS 图标)
},
// ...
};7.2 渠道别名
系统支持渠道别名,方便用户输入:
typescript
export const CHAT_CHANNEL_ALIASES: Record<string, ChatChannelId> = {
imsg: "imessage",
"internet-relay-chat": "irc",
"google-chat": "googlechat",
gchat: "googlechat",
};7.3 Channel Dock (src/channels/dock.ts)
Dock 是渠道的轻量级能力描述,不加载任何重型依赖(如 Web 登录、puppeteer 等):
typescript
type ChannelDock = {
id: ChannelId;
capabilities: ChannelCapabilities; // 支持的功能
commands?: ChannelCommandAdapter; // 斜杠命令
outbound?: { textChunkLimit?: number }; // 出站消息分片限制
streaming?: { blockStreamingCoalesceDefaults?: {...} }; // 流式响应配置
config?: { resolveAllowFrom, formatAllowFrom, resolveDefaultTo }; // 配置读取
groups?: ChannelGroupAdapter; // 群组行为
mentions?: ChannelMentionAdapter; // @提及处理
threading?: ChannelThreadingAdapter; // 线程行为
// ...
};渠道能力声明示例:
typescript
telegram: {
capabilities: {
chatTypes: ["direct", "group", "channel", "thread"],
nativeCommands: true, // 支持原生斜杠命令
blockStreaming: true, // 支持块流式传输
},
}7.4 为什么分离 Registry 和 Dock?
| 模块 | 职责 | 加载成本 |
|---|---|---|
registry.ts | 渠道 ID、元数据、别名归一化 | 极低(纯数据) |
dock.ts | 渠道能力、配置读取、允许列表处理 | 低(轻量逻辑) |
plugins/*.ts | 完整渠道实现(登录、消息收发) | 高(SDK、网络) |
共享代码应从 registry.ts 和 dock.ts 导入,而不是从 plugins 目录——这避免了在只需要读取渠道元数据时意外加载整个 WhatsApp SDK。
消息路由与会话管理/07-infographic-channel-layers-1775150753190.png)
八、会话记录与 Last Route
8.1 入站会话记录 (src/channels/session.ts)
每条入站消息都会触发会话元数据的记录:
typescript
export async function recordInboundSession(params: {
storePath: string; // 会话存储路径
sessionKey: string; // 路由生成的会话键
ctx: MsgContext; // 消息上下文
groupResolution?: GroupKeyResolution;
updateLastRoute?: InboundLastRouteUpdate;
}) {
// 1. 记录会话元数据(异步,fire-and-forget)
void recordSessionMetaFromInbound({...}).catch(onRecordError);
// 2. 更新 Last Route(用于出站消息的反向路由)
if (update && !shouldSkipPinnedMainDmRouteUpdate(update.mainDmOwnerPin)) {
await updateLastRoute({
storePath,
sessionKey: targetSessionKey,
deliveryContext: { channel, to, accountId, threadId },
});
}
}8.2 Last Route 的作用
Last Route 记录了"最后一次从哪个渠道/哪个目标收到消息",用于反向路由出站消息:
场景:Agent 需要主动发送一条消息(如定时提醒)
│
├─ Agent 只知道 sessionKey = "agent:main:main"
│
├─ 查询 Last Route → { channel: "telegram", to: "12345", accountId: "default" }
│
└─ 通过 Telegram 渠道发送消息给用户 123458.3 Main DM Owner Pin
当 DM Scope 为 main 时(所有 DM 汇聚到同一个会话),存在一个微妙的问题:
场景:
1. Bot Owner (Alice) 通过 WhatsApp 和 Bot 对话 → lastRoute = WhatsApp:Alice
2. 配对用户 (Bob) 通过 Telegram 发消息 → lastRoute 被改为 Telegram:Bob
3. Agent 主动回复时,消息发到了 Bob 那里!Alice 收不到了mainDmOwnerPin 机制解决了这个问题:
typescript
function shouldSkipPinnedMainDmRouteUpdate(pin) {
if (!pin) return false;
const owner = pin.ownerRecipient.trim().toLowerCase();
const sender = pin.senderRecipient.trim().toLowerCase();
// 如果发送者不是 Owner,跳过 lastRoute 更新
if (owner !== sender) {
pin.onSkip?.({ ownerRecipient, senderRecipient });
return true; // 不更新 lastRoute,保持 Owner 的路由不变
}
return false;
}消息路由与会话管理/08-infographic-last-route-1775150754144.png)
九、线程路由 (Thread Routing)
9.1 线程会话键生成
对于支持线程的渠道(Slack、Discord、Telegram),线程消息在基础会话键后追加线程 ID:
typescript
export function resolveThreadSessionKeys(params: {
baseSessionKey: string;
threadId?: string | null;
parentSessionKey?: string;
useSuffix?: boolean;
}) {
if (!threadId) return { sessionKey: baseSessionKey };
const normalizedThreadId = normalize(threadId);
const sessionKey = useSuffix
? `${baseSessionKey}:thread:${normalizedThreadId}`
: baseSessionKey;
return { sessionKey, parentSessionKey };
}示例:
Slack 频道 #general 的主消息 → agent:main:slack:channel:C01234
└─ 线程 ts=1234.5678 → agent:main:slack:channel:C01234:thread:1234.56789.2 线程 Binding 继承
线程消息的 Binding 匹配有继承机制——如果线程 peer 没有直接匹配的 binding,会尝试匹配父级 peer:
typescript
// 优先级 2: binding.peer.parent
{
matchedBy: "binding.peer.parent",
enabled: Boolean(parentPeer && parentPeer.id),
scopePeer: parentPeer,
candidates: collectPeerIndexedBindings(bindingsIndex, parentPeer),
}消息路由与会话管理/09-infographic-thread-routing-1775150754918.png)
十、缓存架构与性能优化
10.1 三级缓存
路由系统有精心设计的多级缓存:
Level 1: Account ID 归一化缓存
├── normalizeAccountIdCache (Map, max 512)
└── normalizeOptionalAccountIdCache (Map, max 512)
Level 2: Binding 评估缓存
├── evaluatedBindingsCacheByCfg (WeakMap<Config, ...>)
│ ├── byChannelAccount (Map<"channel\taccount", EvaluatedBinding[]>, max 2000)
│ └── byChannelAccountIndex (Map<"channel\taccount", Index>)
└── 配置变更时自动失效(通过 bindingsRef 引用比较)
Level 3: 路由结果缓存
├── resolvedRouteCacheByCfg (WeakMap<Config, ...>)
│ └── byKey (Map<cacheKey, ResolvedAgentRoute>, max 4000)
└── 配置变更时自动失效(bindings + agents + session 引用比较)
Level 4: Agent 查找缓存
└── agentLookupCacheByCfg (WeakMap<Config, AgentLookupCache>)
Level 5: AllowFrom 读取缓存
└── allowFromReadCache (Map<filePath, { exists, mtimeMs, size, entries }>)消息路由与会话管理/10-infographic-cache-architecture-1775150755748.png)
10.2 WeakMap 缓存失效
所有与配置相关的缓存都使用 WeakMap<OpenClawConfig, ...> + 引用比较:
typescript
const existing = resolvedRouteCacheByCfg.get(cfg);
if (
existing &&
existing.bindingsRef === cfg.bindings && // 引用比较
existing.agentsRef === cfg.agents && // 引用比较
existing.sessionRef === cfg.session // 引用比较
) {
return existing.byKey; // 配置未变,缓存有效
}
// 配置变了,清空重建为什么用 WeakMap? 当 OpenClawConfig 对象被垃圾回收时(如配置热重载后旧配置被释放),对应的缓存自动清除,无需手动管理。
10.3 缓存溢出策略
所有 Map 缓存都有上限,溢出时采用全清策略:
typescript
if (routeCache.size > MAX_RESOLVED_ROUTE_CACHE_KEYS) {
routeCache.clear();
routeCache.set(routeCacheKey, route);
}为什么不用 LRU? 路由缓存的场景是:大量不同的 (channel, account, peer) 组合产生不同的缓存键。当键空间超过上限时,说明可能有异常流量(如大量不同群组),此时全清再重建比维护 LRU 更简单高效。
十一、模块关系与设计总结
11.1 路由数据流
┌─────────────────────┐
│ OpenClawConfig │
│ (bindings, agents, │
│ session, channels) │
└──────────┬──────────┘
│
┌────────────────────▼─────────────────────┐
│ resolveAgentRoute() │
│ (src/routing/resolve-route.ts) │
│ │
│ 输入: channel + accountId + peer │
│ 输出: agentId + sessionKey + matchedBy │
└──┬───────────┬───────────┬───────────────┘
│ │ │
┌────────────▼┐ ┌─────▼─────┐ ┌──▼──────────────┐
│ account-id │ │ bindings │ │ session-key │
│ 归一化+缓存 │ │ 索引+匹配 │ │ 生成+DM scope │
└─────────────┘ └───────────┘ └──┬──────────────┘
│
┌─────────▼──────────────────┐
│ identity links │
│ (跨渠道用户身份合并) │
└────────────────────────────┘
┌────────────────────────────────────────────────────────┐
│ 配对系统 (Pairing) │
│ │
│ pairing-store.ts: 请求存储 + AllowFrom 白名单 │
│ pairing-challenge.ts: 统一的配对挑战发起 │
│ setup-code.ts: 设备配对码(Base64URL JSON) │
└────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────┐
│ 渠道基础设施 │
│ │
│ registry.ts: 渠道 ID + 元数据 + 别名 │
│ dock.ts: 轻量级渠道能力描述 │
│ session.ts: 入站会话记录 + Last Route 更新 │
│ chat-type.ts: direct / group / channel 分类 │
└────────────────────────────────────────────────────────┘11.2 关键设计模式
| 模式 | 应用 | 说明 |
|---|---|---|
| 声明式路由 | Bindings | 用户不写路由代码,只声明匹配规则 |
| 分层优先级 | 7 层匹配 | 最具体的规则优先,避免歧义 |
| WeakMap 缓存 | 配置缓存 | 配置对象被 GC 时缓存自动清除 |
| 引用比较失效 | 缓存一致性 | 配置热重载后缓存自动重建 |
| 惰性清理 | 配对过期 | 每次读取时清理过期条目 |
| 原子写入 | AllowFrom | 通过文件锁 + writeJsonFileAtomically |
| 向后兼容 | Legacy AllowFrom | 合并旧版和新版白名单文件 |
| Fail-safe 默认 | 路由回退 | 无匹配时回退到默认 Agent |
消息路由与会话管理/11-infographic-design-patterns-1775150756886.png)
11.3 安全考量
| 风险 | 防护 | 位置 |
|---|---|---|
| 原型污染 | isBlockedObjectKey | account-id.ts |
| 路径遍历 | safeChannelKey 过滤 \/:*?"<>|.. | pairing-store.ts |
| 并发写入 | withFileLock 文件锁 | pairing-store.ts |
| DM 刷屏 | 配对请求上限 3 个 + 1 小时过期 | pairing-store.ts |
| 密码学安全 | crypto.randomInt 生成配对码 | pairing-store.ts |
| 跨账户隔离 | 非默认账户不继承 legacy AllowFrom | pairing-store.ts |
11.4 推荐阅读顺序
| 优先级 | 文件 | 行数 | 难度 |
|---|---|---|---|
| 🔴 必读 | routing/session-key.ts | ~246 | ★★★☆☆ |
| 🔴 必读 | routing/resolve-route.ts | ~719 | ★★★★☆ |
| 🔴 必读 | routing/account-id.ts | ~70 | ★★☆☆☆ |
| 🟡 推荐 | pairing/pairing-store.ts | ~844 | ★★★★☆ |
| 🟡 推荐 | channels/dock.ts | ~500+ | ★★★☆☆ |
| 🟡 推荐 | channels/registry.ts | ~189 | ★★☆☆☆ |
| 🟢 选读 | routing/bindings.ts | ~113 | ★★☆☆☆ |
| 🟢 选读 | pairing/pairing-challenge.ts | ~48 | ★☆☆☆☆ |
| 🟢 选读 | pairing/setup-code.ts | ~341 | ★★★☆☆ |
| 🟢 选读 | channels/session.ts | ~81 | ★★☆☆☆ |
11.5 思考题
为什么
dmScope默认值是"main"而不是"per-peer"?如果一个多用户团队使用 OpenClaw,应该选哪个?提示:考虑 OpenClaw 的核心定位——个人 AI 助手 vs 团队协作工具。
路由缓存溢出时为什么选择全清(
cache.clear())而不是 LRU 淘汰?提示:考虑路由缓存键的分布特征——正常使用时键空间是有限的。
配对系统为什么限制同时只能有 3 个待处理请求?如果超过了会怎样?
提示:看
pruneExcessRequests的实现——不是拒绝新请求,而是淘汰最老的。Identity Links 为什么用渠道前缀格式(
"telegram:12345")而不只用用户 ID?提示:考虑不同渠道的用户 ID 可能碰撞(如 Telegram 和 Discord 都用纯数字 ID)。
📌 小结: 消息路由与会话管理是 OpenClaw 的"交通枢纽"——它通过声明式的 Binding 规则和分层优先级匹配,将来自 22+ 种渠道的消息精准路由到正确的 Agent 会话。DM Scope 和 Identity Links 的设计体现了"个人 AI 助手"的定位——所有 DM 默认汇聚到一个 main 会话,但也为多用户场景提供了灵活的隔离选项。配对系统则是安全的最后一道防线,通过密码学安全的 8 位配对码确保只有经过 Owner 审批的用户才能与 Bot 交互。