Skip to content

OpenClaw 源码解读(十三)消息路由与会话管理

本篇深入分析 OpenClaw 的消息路由引擎——从一条入站消息到达 Gateway,到它被路由到正确的 Agent 会话,再到配对验证和会话持久化的完整链路。路由系统是整个平台的"神经中枢",决定了"谁的消息,交给哪个 Agent,在哪个会话中处理"。


目录


一、路由系统全景

1.1 核心问题

当一条消息从 WhatsApp/Telegram/Discord 等渠道到达 Gateway 时,系统需要回答三个关键问题:

1. 这条消息来自哪个渠道的哪个账户? → Account ID
2. 这条消息应该交给哪个 Agent 处理? → Agent Routing (Bindings)
3. 这条消息属于哪个会话上下文?    → Session Key

1.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.tsAccount ID 归一化与缓存
src/routing/session-key.tsSession Key 生成与解析
src/routing/resolve-route.ts路由决策引擎(Binding 匹配)
src/routing/bindings.tsBinding 配置读取与归一化
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设备配对码生成

![消息路由6步处理管线全景](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(十三)消息路由与会话管理/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 淘汰。

![Account ID 归一化管线与安全机制](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(十三)消息路由与会话管理/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>:mainagent: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 会话最符合直觉。

![DM Scope 四种会话收敛模式](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(十三)消息路由与会话管理/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。

![Binding 七层优先级匹配机制](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(十三)消息路由与会话管理/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 与身份链接

当同一个用户在多个渠道发消息时,默认情况下 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,共享同一个会话上下文。

![Identity Links 跨渠道身份链接机制](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(十三)消息路由与会话管理/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

![配对系统安全流程:从陌生用户到白名单](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(十三)消息路由与会话管理/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_MS1 小时未审批的请求自动过期
PAIRING_PENDING_MAX3同一渠道最多 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.tsdock.ts 导入,而不是从 plugins 目录——这避免了在只需要读取渠道元数据时意外加载整个 WhatsApp SDK。

![渠道基础设施三层架构:Registry → Dock → Plugin](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(十三)消息路由与会话管理/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 渠道发送消息给用户 12345

8.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;
}

![Last Route 反向路由与 Owner Pin 保护机制](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(十三)消息路由与会话管理/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.5678

9.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),
}

![线程路由的会话键生成与 Binding 继承](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(十三)消息路由与会话管理/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 }>)

![五级缓存架构与失效机制](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(十三)消息路由与会话管理/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

![路由系统核心设计模式与模块依赖关系](https://qn.huat.xyz/blog/article-Illustration/OpenClaw 源码解读(十三)消息路由与会话管理/11-infographic-design-patterns-1775150756886.png)

11.3 安全考量

风险防护位置
原型污染isBlockedObjectKeyaccount-id.ts
路径遍历safeChannelKey 过滤 \/:*?"<>|..pairing-store.ts
并发写入withFileLock 文件锁pairing-store.ts
DM 刷屏配对请求上限 3 个 + 1 小时过期pairing-store.ts
密码学安全crypto.randomInt 生成配对码pairing-store.ts
跨账户隔离非默认账户不继承 legacy AllowFrompairing-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 思考题

  1. 为什么 dmScope 默认值是 "main" 而不是 "per-peer"?如果一个多用户团队使用 OpenClaw,应该选哪个?

    提示:考虑 OpenClaw 的核心定位——个人 AI 助手 vs 团队协作工具。

  2. 路由缓存溢出时为什么选择全清(cache.clear())而不是 LRU 淘汰?

    提示:考虑路由缓存键的分布特征——正常使用时键空间是有限的。

  3. 配对系统为什么限制同时只能有 3 个待处理请求?如果超过了会怎样?

    提示:看 pruneExcessRequests 的实现——不是拒绝新请求,而是淘汰最老的。

  4. Identity Links 为什么用渠道前缀格式("telegram:12345")而不只用用户 ID?

    提示:考虑不同渠道的用户 ID 可能碰撞(如 Telegram 和 Discord 都用纯数字 ID)。


📌 小结: 消息路由与会话管理是 OpenClaw 的"交通枢纽"——它通过声明式的 Binding 规则和分层优先级匹配,将来自 22+ 种渠道的消息精准路由到正确的 Agent 会话。DM Scope 和 Identity Links 的设计体现了"个人 AI 助手"的定位——所有 DM 默认汇聚到一个 main 会话,但也为多用户场景提供了灵活的隔离选项。配对系统则是安全的最后一道防线,通过密码学安全的 8 位配对码确保只有经过 Owner 审批的用户才能与 Bot 交互。

读文档、看源码、写代码,理解 AI Agent 本质 🤖