Skip to content

Kanal-Registry

源码版本v2026.6.11

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

Datenfluss

Die 22 bundled Kanal-IDs stammen aus generated metadata. ids.ts filtert und sortiert sie beim Start (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" }),
    );
}

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):

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);
}

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):

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 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):

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);
  }
}

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):

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);
}

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: buildRegisteredChannelPluginLookup holt sich über getActivePluginChannelRegistrySnapshotFromState die version. 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 getBundledChannelPlugin direkt undefined — kein Fallback.
  • Runtime-Catalog-Fallback: listRuntimeBundledChatChannelEntries cached 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_METADATA wird von scripts/generate-bundled-channel-config-metadata.ts zur Build-Zeit erzeugt; ein neuer Kanal erfordert ein erneutes Generierungslauf, sonst fehlt die ID in CHAT_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.