Registre des canaux
Responsabilités
Le registre des canaux (channel registry) est la couche d'OpenClaw qui unifie « 22 canaux de chat + canaux tiers arbitraires de plugins » en un dictionnaire interrogeable. Il n'expose que quatre choses: lister les ids de canaux enregistrés, normaliser (normalize) les alias saisis par l'utilisateur en id canonique, rechercher une entrée de plugin de canal (channel plugin) par id ou alias, et produire une courte ligne de présentation pour les interfaces setup / status.
Le registre lui-même ne charge pas l'implémentation runtime du canal — il ne détient que des métadonnées (metadata) et des index de recherche. Les vraies actions comme appeler startAccount ou sendMessage sont le travail de l'adaptateur de canal; le registre ne fait que « dire quel plugin correspond à l'id telegram, et quels sont ses alias ».
Motivation de conception
Pourquoi dégager une couche registre plutôt que d'importer directement import { telegramPlugin } from "..."? Trois raisons concrètes.
Premièrement, le nombre de canaux est fixe mais il y a des alias. telegram dans la config utilisateur peut s'écrire tg, Telegram, TELEGRAM; le registre doit tous les ramener à un id canonique, sinon toutes les maps indexées par id en aval devraient refaire la gestion de la casse et des alias.
Deuxièmement, les canaux ont deux couches bundled et loaded. Bundled correspond aux métadonnées embarquées dans le code généré, loaded correspond à ce qui est chargé au runtime depuis un paquet npm de plugin. Loaded prend precedence sur bundled, de sorte qu'installer un paquet de plugin telegram permette de pinner ou d'override la version builtin, mais en l'absence d'installation le bundled sert de repli. Le registre doit fusionner ces deux couches en une vue de recherche unique.
Troisièmement, les plugins s'enregistrent à chaud: un canal enregistré après le snapshot de démarrage doit aussi pouvoir être trouvé (correctif #94127). Le registre ne peut pas être build une seule fois au démarrage, mais chaque recherche ne doit pas tout recalculer non plus — d'où un numéro de version de snapshot (version) pour invalider le cache.
Fichiers clés
channels/registry.ts:22-77— Facade publique,normalizeChannelId/normalizeAnyChannelId/listRegisteredChannelPluginIds/formatChannelPrimerLine.registry-lookup.ts:42-100— Vue de recherche avec cache par version,buildRegisteredChannelPluginLookupest le hot path.channels/ids.ts:21-93— Source des 22 ids bundled et de leurs alias, dérivé des generated metadata.channels/plugins/registry.ts:20-65— Facade runtime, superpose le repli bundled.registry-loaded.ts— État réel des plugins de canaux chargés, appelé parregistry.ts.registry-loader.ts:17-43— Chargeur paresseux générique, résout n'importe quelle surface de plugin depuis un channel id.plugins/bundled.ts:796-900— Chargeur de canaux bundled, n'émet qu'un warn en cas d'échec.plugins/catalog.ts:463-532— Builder de catalog UI, fusionne bundled / officiel / externe.bundled-channel-config-metadata.generated.ts— Métadonnées générées pour les 22 canaux, incluant schema, alias, order.
Flux de données
Les 22 ids de canaux bundled viennent des generated metadata. ids.ts les filtre et les trie au démarrage (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" }),
);
}Les 22 ids effectifs sont: clickclack / discord / feishu / googlechat / imessage / irc / line / matrix / mattermost / msteams / nostr / qqbot / raft / signal / slack / sms / telegram / tlon / twitch / whatsapp / zalo / zalouser.
La normalisation suit une repli à deux niveaux « table d'alias → catalog runtime » (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);
}On cherche d'abord dans les CHAT_CHANNEL_ALIASES générés à la compilation pour les alias courants; en cas d'échec on passe à listRuntimeBundledChatChannelEntries — ce dernier lit bundled-channel-catalog-read.ts et couvre les métadonnées bundled non présentes à la compilation mais enregistrées dynamiquement au runtime.
La recherche runtime est gérée par registry-lookup.ts avec un cache snapshot + version (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;
}
// ... rebuild des index byKey / byId
}Notez que la condition de rebuild utilise quatre égalités de référence plus un version. version est un entier incrémenté par le plugin registry à chaque enregistrement à chaud d'un nouveau plugin; le cache s'invalide alors automatiquement — évite de recalculer la Map byKey à chaque requête registry.
Le remplissage de l'index suit la règle premier arrivé gagne (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);
}
}Cette règle garantit que l'id canonique pointe toujours vers la première entrée enregistrée; les alias enregistrés plus tardivement ne peuvent pas capturer la place — critique pour les scénarios de hot-plug, pour empêcher qu'un plugin installé plus tard n'écrase accidentellement un canal bundled.
La recherche du corps du plugin de canal passe par getChannelPlugin, qui consulte d'abord loaded puis repli vers 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);
}En cas d'échec de chargement bundled, on n'émet qu'un warn sans throw (plugins/bundled.ts:679): log.warn(\[channels] failed to load bundled channel ${id}: ${detail}`)puis on cachenull. La requête suivante du même id renvoie directement undefined, sans redéclencher le chargement — c'est la tolérance conçue pour « dépendance runtime manquante »; describeBundledChannelLoadErrorinvite en plus l'utilisateur à lanceropenclaw doctor --fix`.
Toute la chaîne de recherche:
Limites et modes d'échec
- Invalidation du cache snapshot par version:
buildRegisteredChannelPluginLookuprécupèreversionviagetActivePluginChannelRegistrySnapshotFromState. Si le plugin registry incrémente la version mais oublie d'en informer le channel state, les canaux enregistrés à chaud deviendront introuvables. - First writer wins: alias premier arrivé premier servi. Si deux plugins enregistrent le même id, seul le premier prend effet; le second est ignoré silencieusement — donc une collision de noms de canaux ne lève pas, elle donne juste « semble installé mais ne prend pas effet ».
- Échec de chargement bundled n'émet qu'un warn: une erreur de module manquant invite à lancer
openclaw doctor --fix(bundled.ts:318), sans bloquer le démarrage. C'est pour qu'un canal en panne n'impacte pas les 21 autres. - Repli bundled limité aux 22 canaux builtin: les canaux de plugins tiers n'entrent pas dans les generated metadata; si loaded n'est pas installé,
getBundledChannelPluginrenvoie directement undefined — pas de repli. - Repli runtime catalog:
listRuntimeBundledChatChannelEntriesutilise un cache??=unique (ids.ts:61); la première invocation lit le fichier catalog, ensuite seule la mémoire est utilisée. Si le fichier catalog est remplacé en cours de run, il faut redémarrer le processus pour le voir. - Timing de génération:
GENERATED_BUNDLED_CHANNEL_CONFIG_METADATAest produit parscripts/generate-bundled-channel-config-metadata.tsau build; ajouter un canal exige de relancer le script de génération, sinonCHAT_CHANNEL_ID_SETne contiendra pas le nouvel id.
Résumé
Le registre ne fait que « lister, normaliser, rechercher, présenter » — il ne touche pas au runtime du canal. Il couvre les 22 canaux bundled plus les canaux tiers via generated metadata + repli runtime catalog; il évite de recalculer l'index à chaque requête via un cache snapshot + version; il garantit via first-writer-wins que l'enregistrement à chaud ne peut pas écraser un id canonique existant.
L'endroit où les capacités du canal sont réellement appelées est l'adaptateur de canal; la façon dont l'utilisateur configure ces canaux dans openclaw.json est dans configuration des canaux; le mécanisme général du système de plugins est dans plugins de capacités. Comment un canal pousse les messages vers le cœur de la passerelle pour nourrir la boucle principale de l'agent, puis renvoie via l'adaptateur, est le sujet de la page suivante.