渠道注册表
职责
渠道注册表 (channel registry) 是 OpenClaw 把"22 个 chat 渠道 + 任意第三方插件渠道"统一成一个可查询字典的层。它对外只暴露四件事:列出已注册的渠道 id、把用户输入的别名 (alias) 规范化 (normalize) 成正规 id、按 id 或 alias 查找一个渠道插件 (channel plugin) 条目、给 setup / status 界面拼一行简短的介绍文字。
注册表本身不加载渠道的运行时实现——它只持有元数据 (metadata) 与查找索引。真正调用 startAccount、sendMessage 这些动作是 渠道适配器 干的活,注册表只负责"告诉我 telegram 这个 id 对应哪个 plugin 条目,以及它的别名有哪些"。
设计动机
为什么单拎一层注册表面不是直接 import { telegramPlugin } from "..."?三个现实原因:
第一,渠道数固定但有别名。telegram 在用户配置里可能写成 tg、Telegram、TELEGRAM,注册表得把这些都归一到同一个正规 id,否则下游所有按 id 索引的 map 都要重复做大小写和别名处理。
第二,渠道有 bundled 与 loaded 两层。bundled 是生成代码内自带的元数据,loaded 是运行时从插件 npm 包加载的。loaded 优先于 bundled,这样装了 telegram 插件包后能 pin 或 override 自带版本,但没装时也能用 bundled 兜底。注册表得把这两层合并成同一个查询视图。
第三,插件热注册:启动快照之后注册的渠道也要能被查到 (修过 #94127 这类问题)。注册表不能只在启动时 build 一次,但每次查询又不能都重算——所以加了快照版本号 (version) 做缓存失效。
关键文件
channels/registry.ts:22-77— 公共 facade,normalizeChannelId/normalizeAnyChannelId/listRegisteredChannelPluginIds/formatChannelPrimerLine。registry-lookup.ts:42-100— 带版本号缓存的查找视图,buildRegisteredChannelPluginLookup是热路径。channels/ids.ts:21-93— 22 个 bundled 渠道 id 与别名的源头,从 generated metadata 派生。channels/plugins/registry.ts:20-65— 运行时 facade,叠加 bundled fallback。registry-loaded.ts— 已加载渠道插件的真实状态,被registry.ts调用。registry-loader.ts:17-43— 通用惰性值加载器,从一个 channel id 解析出任意的 plugin surface。plugins/bundled.ts:796-900— bundled 渠道加载器,出错只 warn 不抛。plugins/catalog.ts:463-532— UI catalog 构建器,合并 bundled / 官方 / 外部 catalog。bundled-channel-config-metadata.generated.ts— 自动生成的 22 渠道元数据,含 schema、别名、order。
数据流
22 个 bundled 渠道 id 来自 generated metadata。ids.ts 启动时把它过滤并排序(ids.ts:21):
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):
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):
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):
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):
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通过getActivePluginChannelRegistrySnapshotFromState拿version。如果 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_METADATA由scripts/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 主循环,再经适配器发回去,是下一页的主题。