Kanal-Adapter
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:
- GatewayAdapter —
startAccount/stopAccount, verantwortlich, eine lange Verbindung eines Kontos hochzuziehen (Polling / Webhook / WebSocket) und Plattform-Rohereignisse in die Agent-Hauptschleife einzuspeisen;loginWithQrStart/loginWithQrWait/logoutAccountbehandeln Kanäle wie WhatsApp, die QR-Scan-Login brauchen. - MessageAdapter — vier Outbound-Methoden
send.text/send.media/send.payload/send.poll, plusreceive-Ack-Strategie-Deklaration,live-Streaming-Vorschau-Fähigkeitsdeklaration. Das ist die einheitliche Sende-Schnittstelle in Outbound-Richtung. - Feinkörnige Fähigkeitsadapter —
config/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
types.plugin.ts:66-111—ChannelPlugin-Wurzeltyp, kombiniert alle Adapter-Surfaces.types.adapters.ts:244-359—ChannelGatewayContextundChannelGatewayAdapter, definiertstartAccount/stopAccount/loginWithQrStart.message/types.ts:306-410—ChannelMessageAdapterShapeaus send / durableFinal / live / receive vier Facetten.message/adapter.ts:11-32—defineChannelMessageAdapterHelper, Receive defaultet aufmanual-Ack.telegram/channel.ts:992-1106— Telegramsgateway-Adapter,startAccountbehandelt Webhook und Polling.slack/channel.ts:756-776— Slacksgateway.startAccountdelegiert anmonitorSlackProvider.discord/channel.ts:682-721— Discord startet mitstartupDelayMsgegen Rate-Limit.whatsapp/channel.ts:345-368— WhatsAppsloginWithQrStart/loginWithQrWait/logoutAccount.
Datenfluss
Der Kanalstart tritt in gateway.startAccount(ctx) ein (telegram/channel.ts:993):
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):
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):
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):
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
startAccountauftokenStatus === "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
stopAccountmussreleaseStoppedTelegramPollingLease(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
startupDelayMsWarten, das durchabortSignalabbrechbar 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) empfiehltopenclaw doctor --fix; ein Kanal, der stirbt, beeinträchtigt die anderen nicht. - channelRuntime optional:
ChannelGatewayContext.channelRuntimeist optional; externe Plugins sollten aufundefinedprüfen (types.adapters.ts:313), sonst droht bei SDK-Versionsabweichung NPE. - Ack defaultet manual:
defineChannelMessageAdaptersetzt receive defaultet aufmanual(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 incapabilitiesauf 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.