Skip to content

Kanal-Adapter

源码版本v2026.6.11

Verantwortung

Ein Kanal-Adapter (channel adapter) ist die Klebstoffschicht, die „22 Chat-Plattformen unterschiedlicher Protokolle" in den einheitlichen OpenClaw-internen Ereignisstrom übersetzt. Jeder Kanal implementiert eine Reihe von Adapter-Schnittstellen; das Gateway verhandelt nur mit diesen Schnittstellen und interessiert sich nicht, ob darunter Telegram-Polling, Slack Socket Mode oder Discord Gateway steckt.

Die Adapter fallen in drei Gruppen:

  1. GatewayAdapterstartAccount / stopAccount, verantwortlich, eine lange Verbindung eines Kontos hochzuziehen (Polling / Webhook / WebSocket) und Plattform-Rohereignisse in die Agent-Hauptschleife einzuspeisen; loginWithQrStart / loginWithQrWait / logoutAccount behandeln Kanäle wie WhatsApp, die QR-Scan-Login brauchen.
  2. MessageAdapter — vier Outbound-Methoden send.text / send.media / send.payload / send.poll, plus receive-Ack-Strategie-Deklaration, live-Streaming-Vorschau-Fähigkeitsdeklaration. Das ist die einheitliche Sende-Schnittstelle in Outbound-Richtung.
  3. Feinkörnige Fähigkeitsadapterconfig / setup / pairing / security / groups / mentions / outbound / status / gateway / auth / approval / elevated / commands / lifecycle / secrets / allowlist / doctor / bindings / conversationBindings / streaming / threading / message / messaging / agentPrompt / directory / resolver / actions / heartbeat, jeder optional — der Kanal implementiert nach Fähigkeit, was fehlt, bleibt undefined.

Designmotivation

Warum gateway / message / eine Reihe kleiner Adapter aufteilen? Weil die Fähigkeiten zwischen Kanälen stark differieren:

Telegram unterstützt Webhook und Long-Polling, WhatsApp muss QR-Scan, Slack geht über Socket Mode (WebSocket), Discord über Gateway (WebSocket), iMessage über eine lokale AppleScript-Brücke. Eine vereinheitlichte Channel-Schnittstelle müsste entweder viele optionale Felder stopfen oder jeden Kanal viele Leer-Methoden implementieren lassen.

In Outbound-Richtung unterstützt mancher Kanal replyToId, mancher threadId, mancher nativeQuote, mancher Streaming-Vorschau (draft preview). Diese Fähigkeiten sind deklarativ — der Adapter deklariert in capabilities z. B. nativeStreaming: true; der Kerncode verwendet sie nur, wenn er sie sieht, sonst geht er in den Degradationspfad.

startAccount erhält im ChannelGatewayContext abortSignal, channelRuntime, setStatus, getStatus — das sind Fähigkeiten, die das Gateway dem Kanal injiziert, nicht solche, die sich der Kanal selbst baut. channelRuntime.reply.dispatchReplyWithBufferedBlockDispatcher ist der Eintritt, über den das Gateway den Kanal Inbound-Ereignisse in die Agent-Hauptschleife einspeisen lässt.

Das Design der Adapterschicht lautet also: feinkörnige Schnittstellen + alles optional + Fähigkeiten deklarativ. Der Kanal implementiert nach Bedarf, der Kerncode wählt die Strategie nach deklarierter Fähigkeit.

Schlüsseldateien

Datenfluss

Der Kanalstart tritt in gateway.startAccount(ctx) ein (telegram/channel.ts:993):

typescript
gateway: {
  startAccount: async (ctx) => {
    const account = ctx.account;
    // ... Token anpingen, Bot-Info auflösen
    return startTelegramMonitor({
      token,
      accountId: account.accountId,
      cfg: ctx.cfg,
      runtime: ctx.runtime,
      channelRuntime: ctx.channelRuntime,
      abortSignal: ctx.abortSignal,
      useWebhook: Boolean(account.config.webhookUrl),
      webhookUrl: account.config.webhookUrl,
      webhookSecret: account.config.webhookSecret,
      // ...
    });
  },
  stopAccount: async ({ account, accountId, log }) => {
    const released = await releaseStoppedTelegramPollingLease({ token, accountId });
    if (released) {
      log?.info?.(`[${accountId}] released stopped Telegram polling lease`);
    }
  },
}

Beachten Sie: ctx.account ist die strukturierte Account aus der Kanal-Konfiguration; ctx.channelRuntime ist die vom Gateway injizierte Fähigkeits-Tasche mit acht Submodulen reply / routing / text / session / media / commands / groups / pairing; abortSignal ist das Lebenszyklus-Signal des Gateways — beim Stop des Gateways muss die lange Verbindung sofort reißen.

Slacks startAccount (slack/channel.ts:757) ist dünner und delegiert direkt an monitorSlackProvider: botToken + appToken werden nach Trim übergeben (appToken für Socket Mode, botToken für normale Bot-API); die restlichen Felder ähneln Telegram — channelRuntime / abortSignal / setStatus / getStatus. Slack Socket Mode ist eine WebSocket-Langverbindung, implementiert in monitorSlackProvider; setStatus / getStatus lassen den Monitor den Kontostand in den Snapshot der Kanal-Registry zurückspeisen.

Discord startet mit einem zusätzlichen Rate-Limit-Schutz (discord/channel.ts:690):

typescript
const startupDelayMs = resolveDiscordStartupDelayMs(ctx.cfg, account.accountId);
if (startupDelayMs > 0) {
  ctx.log?.info(
    `[${account.accountId}] delaying provider startup ${Math.round(startupDelayMs / 1000)}s to reduce Discord startup rate limits`,
  );
  try {
    await sleepWithAbort(startupDelayMs, ctx.abortSignal);
  } catch {
    return;
  }
}

sleepWithAbort lässt das Warten auch auf abortSignal reagieren — stoppt das Gateway, bricht der Start sofort ab, statt doof abzuwarten. Discords Token-Status wird vorab geprüft; configured_unavailable wirft (discord/channel.ts:685), um nicht mit unaufgelöstem SecretRef zu starten.

WhatsApps QR-Login ist zweiphasig (whatsapp/channel.ts:345): loginWithQrStart startet den Web-Login und liefert den QR-Code zurück; loginWithQrWait pollt, bis der Nutzer den Scan abschließt; logoutAccount ruft logoutWeb und räumt authDir auf. Die zwei Methoden erlauben dem Setup-UI, den QR-Code zu rendern und auf das Ergebnis zu warten, statt den gesamten Login in einem überlangen Promise zu vergraben.

In Outbound-Richtung läuft der MessageAdapter (message/types.ts:306):

typescript
export type ChannelMessageSendAdapter<
  TConfig = OpenClawConfig,
  TSendResult extends ChannelMessageSendResult = ChannelMessageSendResult,
> = {
  text?: (ctx: ChannelMessageSendTextContext<TConfig>) => Promise<TSendResult>;
  media?: (ctx: ChannelMessageSendMediaContext<TConfig>) => Promise<TSendResult>;
  payload?: (ctx: ChannelMessageSendPayloadContext<TConfig>) => Promise<TSendResult>;
  poll?: (ctx: ChannelMessageSendPollContext<TConfig>) => Promise<TSendResult>;
  lifecycle?: ChannelMessageSendLifecycleAdapter<TConfig, TSendResult>;
};

Die vier Methoden entsprechen vier Outbound-Payloads: text Plain-Text, media Medien, payload Rich-Karte, poll Umfrage. lifecycle ist ein Hook: beforeSend / afterSendSuccess / afterSendFailure / afterCommit, damit der Kanal rund ums Senden eigene Logik einhakt (z. B. Slack aktualisiert den Reply-Zähler der Parent-Message).

Inbound-Richtung läuft über Ack-Strategie (message/types.ts:374):

typescript
export type ChannelMessageReceiveAckPolicy =
  | "after_receive_record"
  | "after_agent_dispatch"
  | "after_durable_send"
  | "manual";

Vier Strategien steuern „wann der Plattform ackt wird, dass diese Nachricht empfangen wurde" — after_receive_record ist der früheste, direkt nach Persistenz; after_agent_dispatch nach Erhalt des Agenten; after_durable_send nach erfolgreicher zuverlässiger Zustellung der Antwort; manual der Kanal steuert selbst. defineChannelMessageAdapter defaultet auf manual (message/adapter.ts:12), weil die meisten Kanäle ihren Ack-Zeitpunkt selbst bestimmen müssen.

Die gesamte Inbound → Outbound-Kette:

Grenzen und Fehler

  • SecretRef unaufgelöst starten wirft: Discord prüft in startAccount auf tokenStatus === "configured_unavailable" (discord/channel.ts:685), wirft statt mit leerem Token zu verbinden; verhindert, dass in Logs der Randzustand „Token ist leerer String" sickert.
  • Polling-Lease-Freigabe: Telegrams stopAccount muss releaseStoppedTelegramPollingLease (telegram/channel.ts:1094) rufen, sonst hält die vorherige Polling-Lease noch und es knallt am Token-Konflikt.
  • Rate-Limit aktiv verzögern: Discord startet vor startupDelayMs Warten, das durch abortSignal abbrechbar ist (discord/channel.ts:696). Das macht „Discord-Concurrent-Start-Limit vermeiden" zu explizitem Sleep statt Fehler-Wiederholung.
  • Bundled-Laden fehlschlägt nur warn: describeBundledChannelLoadError (bundled.ts:318) empfiehlt openclaw doctor --fix; ein Kanal, der stirbt, beeinträchtigt die anderen nicht.
  • channelRuntime optional: ChannelGatewayContext.channelRuntime ist optional; externe Plugins sollten auf undefined prüfen (types.adapters.ts:313), sonst droht bei SDK-Versionsabweichung NPE.
  • Ack defaultet manual: defineChannelMessageAdapter setzt receive defaultet auf manual (message/adapter.ts:12); deklariert der Kanal keine Ack-Strategie, ackt der Kerncode nicht automatisch — verhindert „Nachricht gilt als bearbeitet, ist aber verloren".
  • capabilities deklarativ degradieren: durableFinalDeliveryCapabilities (message/types.ts:17) listet 12 Fähigkeiten; nur was der Kanal in capabilities auf true setzt, lässt den Kerncode den jeweiligen zuverlässigen Zustellungspfad gehen, der Rest degradiert ohne Throw.

Zusammenfassung

Die Adapterschicht kapselt die Unterschiede von 22 Kanälen hinter einer feinkörnigen, alles optionalen, deklarativen Fähigkeits-Schnittstelle. GatewayAdapter verwaltet Start/Stopp und Login, MessageAdapter verwaltet Outbound-Senden und Inbound-Ack, die feinkörnigen Adapter je einen Fähigkeitsaspekt. Das Gateway injiziert Fähigkeiten über channelRuntime, steuert den Lebenszyklus über abortSignal, wählt Strategien deklarativ über capabilities.

Wie aus der Kanal-Registry der Plugin-Eintrag geholt und sein startAccount gerufen wird und wie Agent-Ereignisse zum MessageAdapter zurück zur Plattform fließen, siehe Chat-Broadcast. Wie jeder Kanal in openclaw.json Token, allowFrom, accounts konfiguriert, siehe Kanal-Konfiguration.