Skip to Content
EntwicklerhandbuchLeitfaden für Channel-Plugin-Entwickler

Leitfaden für Channel-Plugin-Entwickler

Ein Channel-Plugin verbindet Qwen Code mit einer Messaging-Plattform. Es wird als Extension paketiert und beim Start geladen. Benutzerdokumentation zur Installation und Konfiguration von Plugins findest du unter Plugins.

Wie alles zusammenhängt

Dein Plugin befindet sich in der Platform-Adapter-Schicht. Du kümmerst dich um plattformspezifische Belange (Verbinden, Nachrichten empfangen, Antworten senden). ChannelBase übernimmt alles andere (Zugriffskontrolle, Session-Routing, Prompt-Queuing, Slash-Befehle, Crash-Recovery).

Dein Plugin → erstellt Envelope → handleInbound() ChannelBase → gates → commands → routing → ChannelAgentBridge.prompt() ChannelBase → ruft dein sendMessage() mit der Antwort des Agenten auf

ChannelAgentBridge ist der Adapter-seitige Bridge-Vertrag. Der aktuelle eigenständige qwen channel start-Pfad stellt einen AcpBridge bereit, aber Plugin-Code sollte Konstruktorparameter als ChannelAgentBridge typisieren, damit derselbe Adapter später auch hinter anderen Bridge-Implementierungen laufen kann.

Migrationshinweis für bestehende TypeScript-Plugins: Wenn dein Adapter-Konstruktor oder deine Factory bridge explizit als AcpBridge typisiert, ändere diese Annotation in ChannelAgentBridge und verwende weiterhin nur die Methoden, die von diesem Vertrag offengelegt werden. JavaScript-Plugins sind zur Laufzeit nicht betroffen, und der eigenständige qwen channel start übergibt weiterhin die aktuelle AcpBridge-Implementierung.

Laufzeitmodi

Derselbe Plugin-Adapter kann von beiden Channel-Laufzeiten gehostet werden:

  • qwen channel start [name] ist der eigenständige, ACP-gestützte Dienst. Er verwendet weiterhin AcpBridge und bleibt der stabile Befehl zum Ausführen von Channels außerhalb eines Daemons.
  • qwen serve --channel <name> und wiederholbare --channel-Flags starten einen experimentellen, vom Daemon verwalteten Channel-Worker. Benannte Channels werden nach besitzendem Workspace gruppiert, mit einem Worker pro besitzender Runtime. --channel all startet absichtlich nur die konfigurierten Channels des primären Workspaces. Worker werden von qwen serve verwaltet, verbinden sich über das SDK mit diesem Daemon und übergeben den Adaptern eine ChannelAgentBridge-Fassade, die von DaemonChannelBridge unterstützt wird.

Vom Daemon verwaltete Channels erben den Lebenszyklus und die Statusberichterstattung des Daemons. Sie sind absichtlich als Out-of-Process ausgelegt, damit Adapter- oder Plattform-SDK-Fehler den Daemon nicht zum Absturz bringen. Jeder benannte Channel muss in genau einen registrierten, vertrauenswürdigen Workspace aufgelöst werden; sein Worker erhält das kanonische cwd und das Umgebungs-Overlay dieser Runtime. Ein User/System-Channel ohne cwd ist mehrdeutig, wenn mehrere Workspaces registriert sind, während ein Channel in einer Workspace-lokalen Einstellungsdatei standardmäßig zu diesem Workspace gehört. --channel all bleibt auf den primären Workspace beschränkt und kann nicht mit benannten Auswahlen kombiniert werden.

Das Plugin-Objekt

Der Einstiegspunkt deiner Extension exportiert ein plugin, das ChannelPlugin entspricht:

import type { ChannelPlugin } from '@qwen-code/channel-base'; import { MyChannel } from './MyChannel.js'; export const plugin: ChannelPlugin = { channelType: 'my-platform', // Eindeutige ID, wird im "type"-Feld in settings.json verwendet displayName: 'My Platform', // Wird in der CLI-Ausgabe angezeigt requiredConfigFields: ['apiKey'], // Wird beim Start validiert (über die Standard-ChannelConfig hinaus) createChannel: (name, config, bridge, options) => new MyChannel(name, config, bridge, options), };

Der Channel-Adapter

Erweitere ChannelBase und implementiere drei Methoden:

import { ChannelBase } from '@qwen-code/channel-base'; import type { ChannelBaseOptions, ChannelAgentBridge, ChannelConfig, Envelope, SessionTarget, } from '@qwen-code/channel-base'; export class MyChannel extends ChannelBase { constructor( name: string, config: ChannelConfig, bridge: ChannelAgentBridge, options?: ChannelBaseOptions, ) { super(name, config, bridge, options); } async connect(): Promise<void> { // Verbinde dich mit deiner Plattform, registriere Nachrichten-Handler // Wenn eine Nachricht eintrifft: const envelope: Envelope = { channelName: this.name, senderId: '...', // Stabile, eindeutige Plattform-Benutzer-ID senderName: '...', // Anzeigename chatId: '...', // Chat-/Konversations-ID (unterscheidet zwischen DMs und Gruppen) text: '...', // Nachrichtentext (@mentions entfernen) isGroup: false, // Muss korrekt sein – wird von GroupGate verwendet isMentioned: false, // Muss korrekt sein – wird von GroupGate verwendet isReplyToBot: false, // Muss korrekt sein – wird von GroupGate verwendet }; this.handleInbound(envelope); } async sendMessage(chatId: string, text: string): Promise<void> { // Markdown in Plattformformat umwandeln, bei Bedarf in Chunks aufteilen, zustellen } disconnect(): void { // Verbindungen bereinigen } }

Die meisten Adapter sollten options unverändert durchreichen. Wenn ein Adapter seinen eigenen SessionRouter erstellt und diesen Router an super() übergibt, setze registerBridgeEvents: true in ChannelBaseOptions, damit ChannelBase die Ereignisse toolCall und sessionDied weiterhin direkt empfängt. Lasse es für Router, die vom Channel-Gateway bereitgestellt werden, nicht gesetzt.

Wenn dein Adapter Shell-Befehlsverhalten verfügbar macht, prüfe, ob bridge.shellCommand existiert, bevor du es aktivierst. Vom Daemon verwaltete Worker lassen diese optionale Methode weg, es sei denn, der Daemon bewirbt die session_shell_command-Fähigkeit.

Das Envelope

Das normalisierte Nachrichtenobjekt, das du aus Plattformdaten erstellst. Die booleschen Flags steuern die Gate-Logik, sie müssen also korrekt sein.

FeldTypErforderlichHinweise
channelNamestringJaVerwende this.name
senderIdstringJaMuss über Nachrichten hinweg stabil sein (wird für Session-Routing + Zugriffskontrolle verwendet)
senderNamestringJaAnzeigename
chatIdstringJaMuss zwischen DMs und Gruppen unterscheiden
chatNamestringNeinGruppen-/Konversationsname, wenn von der Plattform bereitgestellt
textstringJaBot-@mentions entfernen
threadIdstringNeinFür sessionScope: "thread"
messageIdstringNeinPlattform-Nachrichten-ID – nützlich für die Antwortkorrelation
isGroupbooleanJaGroupGate verlässt sich darauf
isMentionedbooleanJaGroupGate verlässt sich darauf
isReplyToBotbooleanJaGroupGate verlässt sich darauf
referencedTextstringNeinZitierte Nachricht – wird als Kontext vorangestellt
imageBase64stringNeinBase64-kodiertes Bild (Legacy – bevorzuge attachments)
imageMimeTypestringNeinz. B. image/jpeg (Legacy – bevorzuge attachments)
attachmentsAttachment[]NeinStrukturierte Medien-Attachments (siehe unten)

Attachments

Verwende das attachments-Array für Bilder, Dateien, Audio und Video. handleInbound() löst diese automatisch auf: Bilder mit Base64-data werden als Vision-Input an das Modell gesendet, Dateien mit einem filePath bekommen ihren Pfad an den Prompt angehängt, damit der Agent sie lesen kann.

interface Attachment { type: 'image' | 'file' | 'audio' | 'video'; data?: string; // Base64-kodierte Daten (Bilder, kleine Dateien) filePath?: string; // Absoluter Pfad zur lokalen Datei (große Dateien auf Festplatte gespeichert) mimeType: string; // z. B. 'application/pdf', 'image/jpeg' fileName?: string; // Originaler Dateiname von der Plattform }

Beispiel – Verarbeiten eines Datei-Uploads in deinem Adapter:

import { writeFileSync, mkdirSync, existsSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; const buf = await downloadFromPlatform(fileId); const dir = join(tmpdir(), 'channel-files'); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); const filePath = join(dir, fileName); writeFileSync(filePath, buf); envelope.attachments = [ { type: 'file', filePath, mimeType: 'application/pdf', fileName, }, ];

Die Legacy-Felder imageBase64/imageMimeType funktionieren weiterhin für die Abwärtskompatibilität, aber attachments wird für neuen Code bevorzugt.

Extension-Manifest

Deine qwen-extension.json deklariert den Channel-Typ. Der Schlüssel muss mit channelType in deinem Plugin-Objekt übereinstimmen:

{ "name": "my-channel-extension", "version": "1.0.0", "channels": { "my-platform": { "entry": "dist/index.js", "displayName": "My Platform Channel" } } }

Optionale Erweiterungspunkte

Benutzerdefinierte Slash-Befehle – registriere sie in deinem Konstruktor:

this.registerCommand('mycommand', async (envelope, args) => { await this.sendMessage(envelope.chatId, 'Response'); return true; // verarbeitet, nicht an den Agenten weiterleiten });

Verarbeitungsindikatoren – überschreibe onPromptStart() und onPromptEnd(), um plattformspezifische Tipp-Indikatoren anzuzeigen. Diese Hooks feuern nur, wenn ein Prompt tatsächlich mit der Verarbeitung beginnt – nicht für gepufferte Nachrichten (Collect-Modus) oder geblockte Nachrichten:

protected override onPromptStart(chatId: string, sessionId: string, messageId?: string): void { this.platformClient.sendTyping(chatId); // deine Plattform-API } protected override onPromptEnd(chatId: string, sessionId: string, messageId?: string): void { this.platformClient.stopTyping(chatId); }

Tool-Call-Hooks – überschreibe onToolCall(), um Agentenaktivität anzuzeigen (z. B. “Shell-Befehl wird ausgeführt…”).

Streaming-Hooks – überschreibe onResponseChunk(chatId, chunk, sessionId) für die progressive Anzeige pro Chunk (z. B. direktes Bearbeiten einer Nachricht). Überschreibe onResponseComplete(chatId, fullText, sessionId), um die endgültige Zustellung anzupassen.

Block-Streaming – setze blockStreaming: "on" in der Channel-Konfiguration. Die Basisklasse teilt Antworten automatisch an Absatzgrenzen in mehrere Nachrichten auf. Kein Plugin-Code erforderlich – es funktioniert parallel zu onResponseChunk.

Proaktive Zustellung – überschreibe supportsProactiveSend(), um true zurückzugeben, wenn der Adapter ohne eine aktive eingehende Anfrage senden kann. ChannelBase nutzt diese Fähigkeit für persistente Channel-Loops, Webhook-Tasks, Hintergrund-Agent-Ergebnisse und Daemon-Zustellung. Die Standard-Target-Policy lehnt Thread-Targets ab; überschreibe die geschützten Target-Prüfungen nur für Target-Formen, die deine Plattform sicher zustellen kann:

override supportsProactiveSend(): boolean { return true; } protected override supportsProactiveTarget(target: SessionTarget): boolean { return target.threadId === undefined; } protected override async pushProactive( target: SessionTarget, text: string, ): Promise<void> { await this.platformClient.send(target.chatId, text); }

Verwende supportsProactiveDeliveryTarget(), wenn die generische Daemon-Zustellung eine andere Target-Form akzeptiert, und supportsProactiveWebhookTarget(), wenn die Webhook-Zustellung von Loops und Hintergrund-Ergebnissen abweicht. Halte nicht unterstützte Targets abgelehnt, statt auf eine andere Konversation auszuweichen.

Medien – fülle envelope.attachments mit Bildern/Dateien. Siehe Attachments weiter oben.

Referenzimplementierungen

  • Plugin-Beispiel (packages/channels/plugin-example/) – minimaler WebSocket-basierter Adapter, guter Startpunkt
  • Telegram (packages/channels/telegram/) – vollumfänglich: Bilder, Dateien, Formatierung, Tipp-Indikatoren
  • DingTalk (packages/channels/dingtalk/) – Stream-basiert mit Rich-Text-Verarbeitung
Last updated on