Kanal-Registry
Verantwortung
Die Kanal-Registry (channel registry) ist die Schicht, die OpenClaw nutzt, um „22 Chat-Kanäle + beliebige Drittanbieter-Plugin-Kanäle" in ein einheitlich abfragbares Wörterbuch zu vereinen. Sie exponiert nur vier Dinge: registrierte Kanal-IDs auflisten, einen vom Nutzer eingegebenen Alias normalisieren (normalize) zur kanonischen ID, nach ID oder Alias einen Kanal-Plugin-Eintrag (channel plugin) finden, und für Setup-/Status-UI eine kurze Vorstellungszeile zusammenbauen.
Die Registry selbst lädt nicht die Laufzeitimplementierung des Kanals — sie hält nur Metadaten (metadata) und Lookup-Indizes. Die echten Aktionen wie startAccount, sendMessage macht der Kanal-Adapter; die Registry ist nur zuständig für „sag mir, welcher Plugin-Eintrag zu telegram gehört und welche Aliase es hat".
Designmotivation
Warum eine eigene Registry-Schicht statt direkt import { telegramPlugin } from "..."? Drei praktische Gründe:
Erstens: Kanalanzahl ist fix, aber es gibt Aliase. telegram kann in der Nutzerkonfiguration als tg, Telegram, TELEGRAM auftauchen; die Registry muss das auf dieselbe kanonische ID normalisieren, sonst müsste jeder Downstream-Map, der nach ID indiziert, Groß-/Kleinschreibung und Aliase wiederholt behandeln.
Zweitens: Kanäle haben eine bundled- und eine loaded-Schicht. bundled sind Metadaten, die im generierten Code mitkommen; loaded sind Laufzeit-Daten aus einem geladenen npm-Plugin-Paket. loaded hat Vorrang vor bundled, sodass das Installieren des Telegram-Plugin-Pakets die bundled-Version pinnen oder überschreiben kann; ohne installiertes Paket greift das bundled-Fallback. Die Registry muss diese zwei Schichten zu einer einheitlichen Lookup-Sicht verschmelzen.
Drittens: Heiß-Registrierung von Plugins — Kanäle, die nach dem Start-Snapshot registriert werden, müssen abfragbar bleiben (Reparatur von Tickets wie #94127). Die Registry darf nicht zur Startup-Zeit einmal aufbauen, aber jede Abfrage darf auch nicht neu rechnen — deshalb treibt eine Snapshot-Versionsnummer (version) den Cache-Verfall.
Schlüsseldateien
channels/registry.ts:22-77— Öffentliche Fassade,normalizeChannelId/normalizeAnyChannelId/listRegisteredChannelPluginIds/formatChannelPrimerLine.registry-lookup.ts:42-100— Lookup-Sicht mit Versions-Cache;buildRegisteredChannelPluginLookupist der Hot Path.channels/ids.ts:21-93— Quelle der 22 bundled Kanal-IDs und Aliase, aus generated metadata abgeleitet.channels/plugins/registry.ts:20-65— Laufzeit-Fassade, legt bundled-Fallback darüber.registry-loaded.ts— Echter Zustand geladener Kanal-Plugins, vonregistry.tsaufgerufen.registry-loader.ts:17-43— Generischer Lazy-Loader, der aus einer Channel-ID beliebige Plugin-Surfaces auflöst.plugins/bundled.ts:796-900— bundled-Kanal-Lader; bei Fehler nur warn, kein Throw.plugins/catalog.ts:463-532— UI-Catalog-Builder, verschmilzt bundled / offizielle / externe Catalogs.bundled-channel-config-metadata.generated.ts— Automatisch generierte 22-Kanal-Metadaten mit Schema, Aliasen, Reihenfolge.
Datenfluss
Die 22 bundled Kanal-IDs stammen aus generated metadata. ids.ts filtert und sortiert sie beim Start (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" }),
);
}Die 22 IDs sind: clickclack / discord / feishu / googlechat / imessage / irc / line / matrix / mattermost / msteams / nostr / qqbot / raft / signal / slack / sms / telegram / tlon / twitch / whatsapp / zalo / zalouser.
Die Normalisierung läuft zweistufig über „Alias-Tabelle → Runtime-Catalog" als Fallback (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);
}Hier wird zuerst die kompilierte CHAT_CHANNEL_ALIASES für häufige Aliase bemüht; ohne Treffer geht es an listRuntimeBundledChatChannelEntries als Fallback — das bundled-channel-catalog-read.ts liest und bundled-Metadaten abdeckt, die nicht zur Kompilierzeit generiert, aber zur Laufzeit dynamisch registriert wurden.
Die Laufzeit-Abfrage nutzt in registry-lookup.ts Snapshot + Versions-Cache (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 Indizes neu aufbauen
}Beachten Sie, dass die Neubedingung vier Referenzgleichheiten plus eine version nutzt. version ist ein Integer, den die Plugin-Registry bei jeder Heiß-Registrierung eines neuen Plugins bump't; damit verfällt der Cache automatisch — das verhindert, dass jede Registry-Abfrage die byKey-Map neu berechnen muss.
Beim Füllen der Indizes gilt First-Writer-Wins (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);
}
}Diese Regel garantiert, dass eine kanonische ID immer auf den zuerst registrierten Eintrag zeigt; ein später registrierter gleichnamiger Alias kann nicht verdrängen — wichtig für Heiß-Steck-Szenarien, damit ein nachträglich installiertes Plugin keinen bundled-Kanal versehentlich überschreibt.
Die Suche nach dem Kanal-Plugin-Körper geht über getChannelPlugin: zuerst loaded, dann Fallback auf 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);
}Schlägt bundled-Laden fehl, wird nur gewarnt, nicht geworfen (plugins/bundled.ts:679): log.warn(\[channels] failed to load bundled channel ${id}: ${detail}`)und danachnullgecacht. Die nächste Abfrage derselben ID liefert direkt undefined, ohne wiederholt zu laden — das ist eine Fehlertoleranz für „Laufzeitabhängigkeit fehlt";describeBundledChannelLoadErrorempfiehltopenclaw doctor --fix`.
Die gesamte Lookup-Kette:
Grenzen und Fehler
- Snapshot-Cache-Verfall über version:
buildRegisteredChannelPluginLookupholt sich übergetActivePluginChannelRegistrySnapshotFromStatedieversion. Bumpt die Plugin-Registry die version, ohne den Kanal-Zustand zu benachrichtigen, bleiben heiß-registrierte Kanäle unsichtbar. - First-Writer-Wins: Aliase nach First-Come. Registrieren zwei Plugins dieselbe ID, gewinnt nur das erste; der Nachzügler wird stillschweigend ignoriert — Kanal-ID-Namenskonflikte werfen also nicht, sondern „sieht aus installiert, ist aber wirkungslos".
- bundled-Laden fehlgeschlagen nur warn: missing-module-Fehler empfehlen
openclaw doctor --fix(bundled.ts:318), blockieren aber nicht den Start. So nimmt ein Kanal, der stirbt, die anderen 21 nicht mit. - bundled-Fallback nur für 22 interne Kanäle: Drittanbieter-Plugin-Kanäle sind nicht in generated metadata; ohne loaded-Installtion liefert
getBundledChannelPlugindirekt undefined — kein Fallback. - Runtime-Catalog-Fallback:
listRuntimeBundledChatChannelEntriescached einmalig über??=(ids.ts:61); der erste Aufruf liest die Catalog-Datei, danach nur Speicher. Wird die Catalog-Datei zur Laufzeit ausgetauscht, hilft nur ein Prozessneustart. - Generierungszeitpunkt:
GENERATED_BUNDLED_CHANNEL_CONFIG_METADATAwird vonscripts/generate-bundled-channel-config-metadata.tszur Build-Zeit erzeugt; ein neuer Kanal erfordert ein erneutes Generierungslauf, sonst fehlt die ID inCHAT_CHANNEL_ID_SET.
Zusammenfassung
Die Registry macht nur die vier Dinge „auflisten, normalisieren, suchen, vorstellen" und berührt die Kanal-Laufzeit nicht. Über generated metadata + runtime catalog zweistufiges Fallback deckt sie 22 bundled-Kanäle plus beliebige Drittanbieter-Plugin-Kanäle ab; Snapshot-Version-Cache verhindert Neu-Berechnung der Indizes bei jeder Abfrage; First-Writer-Wins schützt vor Heiß-Registrierung, die bestehende kanonische IDs überschreibt.
Wo Kanalfähigkeiten tatsächlich gerufen werden, siehe Kanal-Adapter; wie Nutzer diese Kanäle in openclaw.json konfigurieren, siehe Kanal-Konfiguration; die Mechanik des Plugin-Systems siehe Plugins. Wie ein Kanal Nachrichten in die Gateway-Kern zur Agent-Hauptschleife einspeist und der Adapter sie zurücksickt, ist das Thema der nächsten Seite.