Skip to content

渠道注册表

源码版本v2026.6.11

职责

渠道注册表 (channel registry) 是 OpenClaw 把"22 个 chat 渠道 + 任意第三方插件渠道"统一成一个可查询字典的层。它对外只暴露四件事:列出已注册的渠道 id、把用户输入的别名 (alias) 规范化 (normalize) 成正规 id、按 id 或 alias 查找一个渠道插件 (channel plugin) 条目、给 setup / status 界面拼一行简短的介绍文字。

注册表本身不加载渠道的运行时实现——它只持有元数据 (metadata) 与查找索引。真正调用 startAccountsendMessage 这些动作是 渠道适配器 干的活,注册表只负责"告诉我 telegram 这个 id 对应哪个 plugin 条目,以及它的别名有哪些"。

设计动机

为什么单拎一层注册表面不是直接 import { telegramPlugin } from "..."?三个现实原因:

第一,渠道数固定但有别名telegram 在用户配置里可能写成 tgTelegramTELEGRAM,注册表得把这些都归一到同一个正规 id,否则下游所有按 id 索引的 map 都要重复做大小写和别名处理。

第二,渠道有 bundled 与 loaded 两层。bundled 是生成代码内自带的元数据,loaded 是运行时从插件 npm 包加载的。loaded 优先于 bundled,这样装了 telegram 插件包后能 pin 或 override 自带版本,但没装时也能用 bundled 兜底。注册表得把这两层合并成同一个查询视图。

第三,插件热注册:启动快照之后注册的渠道也要能被查到 (修过 #94127 这类问题)。注册表不能只在启动时 build 一次,但每次查询又不能都重算——所以加了快照版本号 (version) 做缓存失效。

关键文件

数据流

22 个 bundled 渠道 id 来自 generated metadata。ids.ts 启动时把它过滤并排序(ids.ts:21):

typescript
function listBundledChatChannelEntries(): BundledChatChannelEntry[] {
  return GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA.filter((entry) => entry.configurable !== false)
    .map((entry) => ({
      id: normalizeOptionalLowercaseString(entry.channelId) ?? entry.channelId,
      aliases: entry.aliases ?? [],
      order: entry.order ?? Number.MAX_SAFE_INTEGER,
    }))
    .toSorted(
      (left, right) =>
        left.order - right.order || left.id.localeCompare(right.id, "en", { sensitivity: "base" }),
    );
}

实际 22 个 id 是:clickclack / discord / feishu / googlechat / imessage / irc / line / matrix / mattermost / msteams / nostr / qqbot / raft / signal / slack / sms / telegram / tlon / twitch / whatsapp / zalo / zalouser。

规范化 (normalization) 走"alias 表 → runtime catalog"两级回退(ids.ts:84):

typescript
export function normalizeChatChannelId(raw?: string | null): ChatChannelId | null {
  const normalized = normalizeOptionalLowercaseString(raw);
  if (!normalized) {
    return null;
  }
  const resolved = CHAT_CHANNEL_ALIASES[normalized] ?? normalized;
  return CHAT_CHANNEL_ID_SET.has(resolved)
    ? resolved
    : normalizeRuntimeBundledChatChannelId(normalized);
}

这里先用编译期生成的 CHAT_CHANNEL_ALIASES 命中常见别名,没命中再走 listRuntimeBundledChatChannelEntries 兜底——后者会读 bundled-channel-catalog-read.ts,覆盖那些没在编译期生成、但运行时动态注册了的 bundled metadata。

运行时查找由 registry-lookup.ts 用快照+版本号缓存(registry-lookup.ts:42):

typescript
function buildRegisteredChannelPluginLookup(): RegisteredChannelPluginLookup {
  const { registry, version } = getActivePluginChannelRegistrySnapshotFromState();
  const channels = Array.isArray(registry?.channels) ? registry.channels : undefined;
  const channelCount = channels?.length ?? 0;
  const cached = registeredChannelPluginLookup;
  if (
    cached &&
    cached.registry === registry &&
    cached.channels === channels &&
    cached.channelCount === channelCount &&
    cached.version === version
  ) {
    return cached;
  }
  // ... 重建 byKey / byId 索引
}

注意重建条件用了四个引用相等加一个 version。version 是 plugin registry 推进的整数,每次热注册一个新 plugin 时 bump,缓存就自动失效——避免每次查 registry 都重算 byKey Map。

索引填充时先到先得(registry-lookup.ts:31):

typescript
function setLookupEntry(
  map: Map<string, RegisteredChannelPluginEntry>,
  key: string | undefined,
  entry: RegisteredChannelPluginEntry,
): void {
  // First writer wins so canonical ids keep priority over later aliases.
  if (key && !map.has(key)) {
    map.set(key, entry);
  }
}

这条规则保证正规 id 永远指向最早注册的那一条,后注册的同名 alias 不能抢占——这对热插拔场景很关键,防止后装的插件意外覆盖 bundled 渠道。

查找渠道插件本体走 getChannelPlugin,先查 loaded 再回退到 bundled(plugins/registry.ts:49):

typescript
export function getChannelPlugin(id: ChannelId): ChannelPlugin | undefined {
  const resolvedId = normalizeOptionalString(id) ?? "";
  if (!resolvedId) {
    return undefined;
  }
  // Loaded plugins win over bundled fallbacks so installed plugin state can pin
  // or override a bundled channel during runtime.
  return getLoadedChannelPlugin(resolvedId) ?? getBundledChannelPlugin(resolvedId);
}

bundled 加载失败时只 warn 不抛(plugins/bundled.ts:679):log.warn(\[channels] failed to load bundled channel ${id}: ${detail}`)然后缓存null。下一次查同 id 直接返回 undefined,不会反复触发加载——这是给"运行时依赖缺失"留的容错空间,describeBundledChannelLoadError还会提示用户跑openclaw doctor --fix`。

整个查找链路:

边界与失败

  • 快照缓存失效靠 version:buildRegisteredChannelPluginLookup 通过 getActivePluginChannelRegistrySnapshotFromStateversion。如果 plugin registry 推进 version 但忘了通知 channel state,热注册的渠道会查不到。
  • First writer wins:别名先到先得。如果两个 plugin 注册了同 id,只有第一个生效;后来者会被静默忽略——所以渠道 id 命名冲突不会抛错,只会"看起来装了但其实没生效"。
  • bundled 加载失败只 warn:missing module 错误会提示跑 openclaw doctor --fix(bundled.ts:318),但不阻断启动。这是为了让一个渠道挂了不影响其余 21 个。
  • bundled fallback 只对内置 22 渠道生效:第三方插件渠道不进 generated metadata,如果 loaded 没装,getBundledChannelPlugin 直接返回 undefined——没有兜底。
  • runtime catalog 兜底:listRuntimeBundledChatChannelEntries??= 缓存一次(ids.ts:61),首次调用读 catalog 文件,之后只用内存。如果 catalog 文件运行时被替换,要重启进程才能感知。
  • 生成时机:GENERATED_BUNDLED_CHANNEL_CONFIG_METADATAscripts/generate-bundled-channel-config-metadata.ts 在构建期生成,加新渠道必须重跑生成脚本,否则 CHAT_CHANNEL_ID_SET 不含新 id。

小结

注册表只做"列、归一、查、介绍"四件事,不碰渠道运行时。它通过 generated metadata + runtime catalog 两级兜底覆盖 22 个 bundled 渠道加任意第三方插件渠道,通过快照 version 缓存避免每次查询重算索引,通过 first-writer-wins 保证热注册不会覆盖已有正规 id。

真正调用渠道能力的地方看 渠道适配器,用户在 openclaw.json 里怎么配这些渠道看 渠道配置,插件系统的整体机制看 能力插件。渠道怎么把消息送进 网关核心 走 agent 主循环,再经适配器发回去,是下一页的主题。