mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 03:30:40 +00:00
docs(channels): add plugin developer guide and rename mock to plugin-example
- Add comprehensive developer guide for building channel plugins - Add user-facing docs for installing/configuring custom channel plugins - Replace custom-channels.md with new plugins.md - Rename @qwen-code/channel-mock to @qwen-code/channel-plugin-example - Add messageId field to Envelope type for response correlation This provides clear documentation for developers building custom channel adapters and renames the mock package to better reflect its purpose as a reference implementation example. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
01c2e5a373
commit
987eebd1c4
17 changed files with 246 additions and 270 deletions
|
|
@ -17,6 +17,7 @@ export default {
|
|||
type: 'separator',
|
||||
},
|
||||
|
||||
'channel-plugins': 'Channel Plugin Guide',
|
||||
tools: 'Tools',
|
||||
|
||||
examples: {
|
||||
|
|
|
|||
135
docs/developers/channel-plugins.md
Normal file
135
docs/developers/channel-plugins.md
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
# Channel Plugin Developer Guide
|
||||
|
||||
A channel plugin connects Qwen Code to a messaging platform. It's packaged as an [extension](../users/extension/introduction) and loaded at startup. For user-facing docs on installing and configuring plugins, see [Plugins](../users/features/channels/plugins).
|
||||
|
||||
## How It Fits Together
|
||||
|
||||
Your plugin sits in the Platform Adapter layer. You handle platform-specific concerns (connecting, receiving messages, sending responses). `ChannelBase` handles everything else (access control, session routing, prompt queuing, slash commands, crash recovery).
|
||||
|
||||
```
|
||||
Your Plugin → builds Envelope → handleInbound()
|
||||
ChannelBase → gates → commands → routing → AcpBridge.prompt()
|
||||
ChannelBase → calls your sendMessage() with the agent's response
|
||||
```
|
||||
|
||||
## The Plugin Object
|
||||
|
||||
Your extension entry point exports a `plugin` conforming to `ChannelPlugin`:
|
||||
|
||||
```typescript
|
||||
import type { ChannelPlugin } from '@qwen-code/channel-base';
|
||||
import { MyChannel } from './MyChannel.js';
|
||||
|
||||
export const plugin: ChannelPlugin = {
|
||||
channelType: 'my-platform', // Unique ID, used in settings.json "type" field
|
||||
displayName: 'My Platform', // Shown in CLI output
|
||||
requiredConfigFields: ['apiKey'], // Validated at startup (beyond standard ChannelConfig)
|
||||
createChannel: (name, config, bridge, options) =>
|
||||
new MyChannel(name, config, bridge, options),
|
||||
};
|
||||
```
|
||||
|
||||
## The Channel Adapter
|
||||
|
||||
Extend `ChannelBase` and implement three methods:
|
||||
|
||||
```typescript
|
||||
import { ChannelBase } from '@qwen-code/channel-base';
|
||||
import type { Envelope } from '@qwen-code/channel-base';
|
||||
|
||||
export class MyChannel extends ChannelBase {
|
||||
async connect(): Promise<void> {
|
||||
// Connect to your platform, register message handlers
|
||||
// When a message arrives:
|
||||
const envelope: Envelope = {
|
||||
channelName: this.name,
|
||||
senderId: '...', // Stable, unique platform user ID
|
||||
senderName: '...', // Display name
|
||||
chatId: '...', // Chat/conversation ID (distinct for DMs vs groups)
|
||||
text: '...', // Message text (strip @mentions)
|
||||
isGroup: false, // Accurate — used by GroupGate
|
||||
isMentioned: false, // Accurate — used by GroupGate
|
||||
isReplyToBot: false, // Accurate — used by GroupGate
|
||||
};
|
||||
this.handleInbound(envelope);
|
||||
}
|
||||
|
||||
async sendMessage(chatId: string, text: string): Promise<void> {
|
||||
// Format markdown → platform format, chunk if needed, deliver
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
// Clean up connections
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## The Envelope
|
||||
|
||||
The normalized message object you build from platform data. The boolean flags drive gate logic, so they must be accurate.
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
| ---------------- | ------- | -------- | -------------------------------------------------------------------------- |
|
||||
| `channelName` | string | Yes | Use `this.name` |
|
||||
| `senderId` | string | Yes | Must be stable across messages (used for session routing + access control) |
|
||||
| `senderName` | string | Yes | Display name |
|
||||
| `chatId` | string | Yes | Must distinguish DMs from groups |
|
||||
| `text` | string | Yes | Strip bot @mentions |
|
||||
| `threadId` | string | No | For `sessionScope: "thread"` |
|
||||
| `messageId` | string | No | Platform message ID — useful for response correlation |
|
||||
| `isGroup` | boolean | Yes | GroupGate relies on this |
|
||||
| `isMentioned` | boolean | Yes | GroupGate relies on this |
|
||||
| `isReplyToBot` | boolean | Yes | GroupGate relies on this |
|
||||
| `referencedText` | string | No | Quoted message — prepended as context |
|
||||
| `imageBase64` | string | No | Base64-encoded image for multimodal models |
|
||||
| `imageMimeType` | string | No | e.g., `image/jpeg` |
|
||||
|
||||
For **files**: download from your platform, save to a temp directory, include the file path in `text`.
|
||||
|
||||
## Extension Manifest
|
||||
|
||||
Your `qwen-extension.json` declares the channel type. The key must match `channelType` in your plugin object:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-channel-extension",
|
||||
"version": "1.0.0",
|
||||
"channels": {
|
||||
"my-platform": {
|
||||
"entry": "dist/index.js",
|
||||
"displayName": "My Platform Channel"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Optional Extension Points
|
||||
|
||||
**Custom slash commands** — register in your constructor:
|
||||
|
||||
```typescript
|
||||
this.registerCommand('mycommand', async (envelope, args) => {
|
||||
await this.sendMessage(envelope.chatId, 'Response');
|
||||
return true; // handled, don't forward to agent
|
||||
});
|
||||
```
|
||||
|
||||
**Working indicators** — override `handleInbound()` to show platform-specific typing indicators:
|
||||
|
||||
```typescript
|
||||
override async handleInbound(envelope: Envelope): Promise<void> {
|
||||
await this.platformClient.sendTyping(envelope.chatId); // your platform API
|
||||
try { await super.handleInbound(envelope); }
|
||||
finally { await this.platformClient.stopTyping(envelope.chatId); }
|
||||
}
|
||||
```
|
||||
|
||||
**Tool call hooks** — override `onToolCall()` to display agent activity (e.g., "Running shell command...").
|
||||
|
||||
**Media** — download from your platform, set `imageBase64`/`imageMimeType` on the Envelope before calling `handleInbound()`.
|
||||
|
||||
## Reference Implementations
|
||||
|
||||
- **Plugin example** (`packages/channels/plugin-example/`) — minimal WebSocket-based adapter, good starting point
|
||||
- **Telegram** (`packages/channels/telegram/`) — full-featured: images, files, formatting, typing indicators
|
||||
- **DingTalk** (`packages/channels/dingtalk/`) — stream-based with rich text handling
|
||||
|
|
@ -181,7 +181,7 @@ The `qwen-extension.json` file contains the configuration for the extension. The
|
|||
- `version`: The version of the extension.
|
||||
- `mcpServers`: A map of MCP servers to configure. The key is the name of the server, and the value is the server configuration. These servers will be loaded on startup just like MCP servers configured in a [`settings.json` file](./cli/configuration.md). If both an extension and a `settings.json` file configure an MCP server with the same name, the server defined in the `settings.json` file takes precedence.
|
||||
- Note that all MCP server configuration options are supported except for `trust`.
|
||||
- `channels`: A map of custom channel adapters. The key is the channel type name, and the value has an `entry` (path to compiled JS entry point) and optional `displayName`. The entry point must export a `plugin` object conforming to the `ChannelPlugin` interface. See [Custom Channel Plugins](../features/channels/custom-channels) for a full guide.
|
||||
- `channels`: A map of custom channel adapters. The key is the channel type name, and the value has an `entry` (path to compiled JS entry point) and optional `displayName`. The entry point must export a `plugin` object conforming to the `ChannelPlugin` interface. See [Channel Plugins](../features/channels/plugins) for a full guide.
|
||||
- `contextFileName`: The name of the file that contains the context for the extension. This will be used to load the context from the extension directory. If this property is not used but a `QWEN.md` file is present in your extension directory, then that file will be loaded.
|
||||
- `commands`: The directory containing custom commands (default: `commands`). Commands are `.md` files that define prompts.
|
||||
- `skills`: The directory containing custom skills (default: `skills`). Skills are discovered automatically and become available via the `/skills` command.
|
||||
|
|
|
|||
|
|
@ -3,5 +3,5 @@ export default {
|
|||
telegram: 'Telegram',
|
||||
weixin: 'WeChat',
|
||||
dingtalk: 'DingTalk',
|
||||
'custom-channels': 'Custom Channel Plugins',
|
||||
plugins: 'Plugins',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,242 +0,0 @@
|
|||
# Custom Channel Plugins
|
||||
|
||||
You can extend the channel system with custom platform adapters packaged as [extensions](../../extension/introduction). This lets you connect Qwen Code to any messaging platform, webhook, or custom transport.
|
||||
|
||||
## How It Works
|
||||
|
||||
Channel plugins are loaded at startup from active extensions. When `qwen channel start` runs, it:
|
||||
|
||||
1. Scans all enabled extensions for `channels` entries in their `qwen-extension.json`
|
||||
2. Dynamically imports each channel's entry point
|
||||
3. Registers the channel type so it can be referenced in `settings.json`
|
||||
4. Creates channel instances using the plugin's factory function
|
||||
|
||||
The plugin provides a `ChannelPlugin` object that tells the channel system how to create your adapter. Your adapter extends `ChannelBase`, which gives you the full pipeline for free: sender gating, group policies, session routing, and the ACP bridge to the agent.
|
||||
|
||||
## Creating a Channel Plugin
|
||||
|
||||
### 1. Set up the project
|
||||
|
||||
Create a new directory for your extension:
|
||||
|
||||
```bash
|
||||
mkdir my-channel-extension
|
||||
cd my-channel-extension
|
||||
npm init -y
|
||||
```
|
||||
|
||||
Install the channel base package (adjust the path to your qwen-code checkout):
|
||||
|
||||
```bash
|
||||
npm install @qwen-code/channel-base
|
||||
```
|
||||
|
||||
### 2. Write the channel adapter
|
||||
|
||||
Create a class that extends `ChannelBase`. You need to implement three methods:
|
||||
|
||||
- **`connect()`** — Connect to your platform (WebSocket, polling, webhook, etc.)
|
||||
- **`sendMessage(chatId, text)`** — Send a response back to a specific chat
|
||||
- **`disconnect()`** — Clean up connections on shutdown
|
||||
|
||||
When your platform delivers an incoming message, build an `Envelope` and call `this.handleInbound(envelope)`. The base class handles everything else: access control, session routing, prompting the agent, and calling your `sendMessage()` with the response.
|
||||
|
||||
```typescript
|
||||
import { ChannelBase } from '@qwen-code/channel-base';
|
||||
import type {
|
||||
ChannelConfig,
|
||||
ChannelBaseOptions,
|
||||
Envelope,
|
||||
AcpBridge,
|
||||
} from '@qwen-code/channel-base';
|
||||
|
||||
interface MyPlatformConfig extends ChannelConfig {
|
||||
apiKey: string;
|
||||
webhookUrl: string;
|
||||
}
|
||||
|
||||
export class MyPlatformChannel extends ChannelBase {
|
||||
private client: any;
|
||||
|
||||
constructor(
|
||||
name: string,
|
||||
config: MyPlatformConfig & Record<string, unknown>,
|
||||
bridge: AcpBridge,
|
||||
options?: ChannelBaseOptions,
|
||||
) {
|
||||
super(name, config, bridge, options);
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
// Connect to your platform
|
||||
this.client = await createPlatformClient(this.config);
|
||||
|
||||
// When a message arrives, push it through the pipeline
|
||||
this.client.on('message', (msg) => {
|
||||
const envelope: Envelope = {
|
||||
channelName: this.name,
|
||||
senderId: msg.userId,
|
||||
senderName: msg.userName,
|
||||
chatId: msg.chatId,
|
||||
text: msg.text,
|
||||
isGroup: msg.isGroup ?? false,
|
||||
isMentioned: msg.isMentioned ?? false,
|
||||
isReplyToBot: msg.isReplyToBot ?? false,
|
||||
};
|
||||
this.handleInbound(envelope);
|
||||
});
|
||||
}
|
||||
|
||||
async sendMessage(chatId: string, text: string): Promise<void> {
|
||||
await this.client.send(chatId, text);
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.client?.close();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Export the plugin
|
||||
|
||||
Create an `index.ts` (or `index.js`) that exports a `plugin` object conforming to the `ChannelPlugin` interface:
|
||||
|
||||
```typescript
|
||||
import type { ChannelPlugin } from '@qwen-code/channel-base';
|
||||
import { MyPlatformChannel } from './MyPlatformChannel.js';
|
||||
|
||||
export const plugin: ChannelPlugin = {
|
||||
channelType: 'my-platform',
|
||||
displayName: 'My Platform',
|
||||
requiredConfigFields: ['apiKey'],
|
||||
createChannel: (name, config, bridge, options) =>
|
||||
new MyPlatformChannel(name, config as any, bridge, options),
|
||||
};
|
||||
```
|
||||
|
||||
The fields are:
|
||||
|
||||
| Field | Required | Description |
|
||||
| ---------------------- | -------- | -------------------------------------------------------------------------- |
|
||||
| `channelType` | Yes | Unique type identifier. Must match the key in `qwen-extension.json` |
|
||||
| `displayName` | Yes | Human-readable name shown in CLI output |
|
||||
| `requiredConfigFields` | No | Extra config fields your channel needs beyond the standard `ChannelConfig` |
|
||||
| `createChannel` | Yes | Factory function that creates your channel adapter instance |
|
||||
|
||||
### 4. Create the extension manifest
|
||||
|
||||
Create `qwen-extension.json` in your project root:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-channel-extension",
|
||||
"version": "1.0.0",
|
||||
"channels": {
|
||||
"my-platform": {
|
||||
"entry": "dist/index.js",
|
||||
"displayName": "My Platform Channel"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `channels` field maps channel type names to their configuration:
|
||||
|
||||
| Field | Required | Description |
|
||||
| ---------------------- | -------- | ------------------------------------------------------------------- |
|
||||
| `entry` | Yes | Relative path to the compiled JS entry point (must export `plugin`) |
|
||||
| `displayName` | No | Human-readable name for CLI output |
|
||||
| `requiredConfigFields` | No | Extra config fields the channel requires |
|
||||
|
||||
> **Note:** The channel type key (e.g., `my-platform`) must match the `channelType` value in your exported plugin object. The system validates this at load time.
|
||||
|
||||
### 5. Build the extension
|
||||
|
||||
Compile your TypeScript to JavaScript. The entry point in `qwen-extension.json` must point to compiled JS, not TypeScript source:
|
||||
|
||||
```bash
|
||||
npx tsc
|
||||
```
|
||||
|
||||
### 6. Install the extension
|
||||
|
||||
You can install from a local path during development:
|
||||
|
||||
```bash
|
||||
qwen extensions install /path/to/my-channel-extension
|
||||
```
|
||||
|
||||
Or link it for development (changes are reflected immediately):
|
||||
|
||||
```bash
|
||||
qwen extensions link /path/to/my-channel-extension
|
||||
```
|
||||
|
||||
### 7. Configure the channel
|
||||
|
||||
Add a channel entry to `~/.qwen/settings.json` using your custom type:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"my-bot": {
|
||||
"type": "my-platform",
|
||||
"apiKey": "$MY_PLATFORM_API_KEY",
|
||||
"senderPolicy": "open",
|
||||
"cwd": "/path/to/project"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All standard channel options (`senderPolicy`, `allowedUsers`, `sessionScope`, `cwd`, `instructions`, `groupPolicy`, `groups`, `model`) work with custom channels.
|
||||
|
||||
### 8. Start the channel
|
||||
|
||||
```bash
|
||||
qwen channel start my-bot
|
||||
```
|
||||
|
||||
## The Envelope
|
||||
|
||||
The `Envelope` is the message object you build from your platform's incoming data and pass to `handleInbound()`:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ---------------- | ------- | -------- | ------------------------------------------------- |
|
||||
| `channelName` | string | Yes | The channel name (use `this.name`) |
|
||||
| `senderId` | string | Yes | Platform-specific user identifier |
|
||||
| `senderName` | string | Yes | Display name of the sender |
|
||||
| `chatId` | string | Yes | Platform-specific chat/conversation identifier |
|
||||
| `text` | string | Yes | The message text |
|
||||
| `threadId` | string | No | Thread identifier (for `sessionScope: "thread"`) |
|
||||
| `isGroup` | boolean | Yes | Whether the message is from a group chat |
|
||||
| `isMentioned` | boolean | Yes | Whether the bot was @mentioned |
|
||||
| `isReplyToBot` | boolean | Yes | Whether the message is a reply to the bot |
|
||||
| `referencedText` | string | No | Text of a quoted/replied-to message (for context) |
|
||||
| `imageBase64` | string | No | Base64-encoded image data (for multimodal models) |
|
||||
| `imageMimeType` | string | No | MIME type of the image (e.g., `image/jpeg`) |
|
||||
|
||||
## What You Get for Free
|
||||
|
||||
By extending `ChannelBase`, your channel automatically supports:
|
||||
|
||||
- **Sender policies** — `allowlist`, `pairing`, and `open` access control
|
||||
- **Group policies** — Per-group settings with optional @mention gating
|
||||
- **Session routing** — Per-user, per-thread, or single shared sessions
|
||||
- **DM pairing** — Full pairing code flow for unknown users
|
||||
- **Slash commands** — `/help`, `/clear`, `/status` work out of the box
|
||||
- **Custom instructions** — Prepended to the first message in each session
|
||||
- **Crash recovery** — Automatic restart with session preservation
|
||||
- **Per-session serialization** — Messages are queued to prevent race conditions
|
||||
|
||||
## Example: Mock Channel Plugin
|
||||
|
||||
The `@qwen-code/channel-mock` package (in `packages/channels/mock/`) is a complete reference implementation. It connects to a WebSocket server and routes messages through the full pipeline:
|
||||
|
||||
```
|
||||
Mock Client → HTTP → Mock Server → WebSocket → MockPluginChannel
|
||||
→ ChannelBase → AcpBridge → qwen-code agent
|
||||
→ response flows back the same path
|
||||
```
|
||||
|
||||
See `packages/channels/mock/src/MockPluginChannel.ts` for a working example of a WebSocket-based channel adapter.
|
||||
|
|
@ -19,7 +19,7 @@ All channels share one agent process with isolated sessions per user. Each chann
|
|||
2. Add the channel configuration to `~/.qwen/settings.json`
|
||||
3. Run `qwen channel start` to start all channels, or `qwen channel start <name>` for a single channel
|
||||
|
||||
Want to connect a platform that isn't built in? See [Custom Channel Plugins](./custom-channels) to build your own adapter as an extension.
|
||||
Want to connect a platform that isn't built in? See [Plugins](./plugins) to add a custom adapter as an extension.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
|
@ -49,7 +49,7 @@ Channels are configured under the `channels` key in `settings.json`. Each channe
|
|||
|
||||
| Option | Required | Description |
|
||||
| -------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `type` | Yes | Channel type: `telegram`, `weixin`, `dingtalk`, or a custom type from an extension (see [Custom Channel Plugins](./custom-channels)) |
|
||||
| `type` | Yes | Channel type: `telegram`, `weixin`, `dingtalk`, or a custom type from an extension (see [Plugins](./plugins)) |
|
||||
| `token` | Telegram | Bot token. Supports `$ENV_VAR` syntax to read from environment variables. Not needed for WeChat or DingTalk |
|
||||
| `clientId` | DingTalk | DingTalk AppKey. Supports `$ENV_VAR` syntax |
|
||||
| `clientSecret` | DingTalk | DingTalk AppSecret. Supports `$ENV_VAR` syntax |
|
||||
|
|
|
|||
87
docs/users/features/channels/plugins.md
Normal file
87
docs/users/features/channels/plugins.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# Custom Channel Plugins
|
||||
|
||||
You can extend the channel system with custom platform adapters packaged as [extensions](../../extension/introduction). This lets you connect Qwen Code to any messaging platform, webhook, or custom transport.
|
||||
|
||||
## How It Works
|
||||
|
||||
Channel plugins are loaded at startup from active extensions. When `qwen channel start` runs, it:
|
||||
|
||||
1. Scans all enabled extensions for `channels` entries in their `qwen-extension.json`
|
||||
2. Dynamically imports each channel's entry point
|
||||
3. Registers the channel type so it can be referenced in `settings.json`
|
||||
4. Creates channel instances using the plugin's factory function
|
||||
|
||||
Your custom channel gets the full shared pipeline for free: sender gating, group policies, session routing, slash commands, crash recovery, and the ACP bridge to the agent.
|
||||
|
||||
## Installing a Custom Channel
|
||||
|
||||
Install an extension that provides a channel plugin:
|
||||
|
||||
```bash
|
||||
# From a local path (for development or private plugins)
|
||||
qwen extensions install /path/to/my-channel-extension
|
||||
|
||||
# Or link it for development (changes are reflected immediately)
|
||||
qwen extensions link /path/to/my-channel-extension
|
||||
```
|
||||
|
||||
## Configuring a Custom Channel
|
||||
|
||||
Add a channel entry to `~/.qwen/settings.json` using the custom type provided by the extension:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"my-bot": {
|
||||
"type": "my-platform",
|
||||
"apiKey": "$MY_PLATFORM_API_KEY",
|
||||
"senderPolicy": "open",
|
||||
"cwd": "/path/to/project"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `type` must match a channel type registered by an installed extension. Check the extension's documentation for which plugin-specific fields are required (e.g., `apiKey`, `webhookUrl`).
|
||||
|
||||
All standard channel options work with custom channels:
|
||||
|
||||
| Option | Description |
|
||||
| -------------- | ---------------------------------------------- |
|
||||
| `senderPolicy` | `allowlist`, `pairing`, or `open` |
|
||||
| `allowedUsers` | Static allowlist of sender IDs |
|
||||
| `sessionScope` | `user`, `thread`, or `single` |
|
||||
| `cwd` | Working directory for the agent |
|
||||
| `instructions` | Prepended to the first message of each session |
|
||||
| `model` | Model override for the channel |
|
||||
| `groupPolicy` | `disabled`, `allowlist`, or `open` |
|
||||
| `groups` | Per-group settings |
|
||||
|
||||
See [Overview](./overview) for details on each option.
|
||||
|
||||
## Starting the Channel
|
||||
|
||||
```bash
|
||||
# Start all channels including custom ones
|
||||
qwen channel start
|
||||
|
||||
# Start just your custom channel
|
||||
qwen channel start my-bot
|
||||
```
|
||||
|
||||
## What You Get for Free
|
||||
|
||||
Custom channels automatically support everything built-in channels do:
|
||||
|
||||
- **Sender policies** — `allowlist`, `pairing`, and `open` access control
|
||||
- **Group policies** — Per-group settings with optional @mention gating
|
||||
- **Session routing** — Per-user, per-thread, or single shared sessions
|
||||
- **DM pairing** — Full pairing code flow for unknown users
|
||||
- **Slash commands** — `/help`, `/clear`, `/status` work out of the box
|
||||
- **Custom instructions** — Prepended to the first message in each session
|
||||
- **Crash recovery** — Automatic restart with session preservation
|
||||
- **Per-session serialization** — Messages are queued to prevent race conditions
|
||||
|
||||
## Building Your Own Channel Plugin
|
||||
|
||||
Want to build a channel plugin for a new platform? See the [Channel Plugin Developer Guide](/developers/channel-plugins) for the `ChannelPlugin` interface, the `Envelope` format, and extension points.
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
/**
|
||||
* Channel Plugin Integration Test — Real E2E with WebSocket
|
||||
*
|
||||
* Tests the actual MockPluginChannel (from @qwen-code/channel-mock) connected
|
||||
* Tests the actual MockPluginChannel (from @qwen-code/channel-plugin-example) connected
|
||||
* to an in-process mock server via WebSocket. The full message flow is:
|
||||
*
|
||||
* server.sendMessage("What is 2+2?")
|
||||
|
|
@ -39,8 +39,8 @@ import type { ChannelConfig } from '../packages/channels/base/dist/index.js';
|
|||
import {
|
||||
MockPluginChannel,
|
||||
createMockServer,
|
||||
} from '../packages/channels/mock/src/index.js';
|
||||
import type { MockServerHandle } from '../packages/channels/mock/src/index.js';
|
||||
} from '../packages/channels/plugin-example/src/index.js';
|
||||
import type { MockServerHandle } from '../packages/channels/plugin-example/src/index.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const CLI_PATH = join(__dirname, '..', 'dist', 'cli.js');
|
||||
|
|
@ -60,7 +60,7 @@ describe('Channel Plugin (Mock WebSocket E2E)', () => {
|
|||
const baseDir =
|
||||
process.env['INTEGRATION_TEST_FILE_DIR'] ||
|
||||
join(__dirname, '..', '.integration-tests', `channel-${Date.now()}`);
|
||||
testDir = join(baseDir, 'channel-mock-e2e');
|
||||
testDir = join(baseDir, 'channel-plugin-example-e2e');
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
|
||||
// 1. Start mock server on random ports (no port conflicts)
|
||||
|
|
@ -75,7 +75,7 @@ describe('Channel Plugin (Mock WebSocket E2E)', () => {
|
|||
|
||||
// 3. Create and connect MockPluginChannel via WebSocket
|
||||
const config: ChannelConfig & Record<string, unknown> = {
|
||||
type: 'mock-plugin',
|
||||
type: 'plugin-example',
|
||||
token: '',
|
||||
senderPolicy: 'open',
|
||||
allowedUsers: [],
|
||||
|
|
|
|||
10
package-lock.json
generated
10
package-lock.json
generated
|
|
@ -13,7 +13,7 @@
|
|||
"packages/channels/telegram",
|
||||
"packages/channels/weixin",
|
||||
"packages/channels/dingtalk",
|
||||
"packages/channels/mock"
|
||||
"packages/channels/plugin-example"
|
||||
],
|
||||
"dependencies": {
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
|
|
@ -3003,8 +3003,8 @@
|
|||
"resolved": "packages/channels/dingtalk",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@qwen-code/channel-mock": {
|
||||
"resolved": "packages/channels/mock",
|
||||
"node_modules/@qwen-code/channel-plugin-example": {
|
||||
"resolved": "packages/channels/plugin-example",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@qwen-code/channel-telegram": {
|
||||
|
|
@ -18990,8 +18990,8 @@
|
|||
"typescript": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"packages/channels/mock": {
|
||||
"name": "@qwen-code/channel-mock",
|
||||
"packages/channels/plugin-example": {
|
||||
"name": "@qwen-code/channel-plugin-example",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@qwen-code/channel-base": "file:../base",
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
"packages/channels/telegram",
|
||||
"packages/channels/weixin",
|
||||
"packages/channels/dingtalk",
|
||||
"packages/channels/mock"
|
||||
"packages/channels/plugin-example"
|
||||
],
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ export interface Envelope {
|
|||
chatId: string;
|
||||
text: string;
|
||||
threadId?: string;
|
||||
/** Platform-specific message ID for response correlation. */
|
||||
messageId?: string;
|
||||
isGroup: boolean;
|
||||
isMentioned: boolean;
|
||||
isReplyToBot: boolean;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "@qwen-code/channel-mock",
|
||||
"name": "@qwen-code/channel-plugin-example",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
|
|
@ -65,15 +65,12 @@ export class MockPluginChannel extends ChannelBase {
|
|||
senderName: msg.senderName,
|
||||
chatId: msg.chatId,
|
||||
text: msg.text,
|
||||
messageId: msg.messageId,
|
||||
isGroup: false,
|
||||
isMentioned: false,
|
||||
isReplyToBot: false,
|
||||
};
|
||||
|
||||
// Store messageId for response correlation
|
||||
(envelope as unknown as Record<string, unknown>)['_messageId'] =
|
||||
msg.messageId;
|
||||
|
||||
this.handleInbound(envelope).catch(() => {
|
||||
// errors handled internally by ChannelBase
|
||||
});
|
||||
|
|
@ -84,11 +81,9 @@ export class MockPluginChannel extends ChannelBase {
|
|||
return;
|
||||
}
|
||||
|
||||
const messageId = this.pendingMessageId || 'unknown';
|
||||
|
||||
const outbound: OutboundMessage = {
|
||||
type: 'outbound',
|
||||
messageId,
|
||||
messageId: this.pendingMessageId || 'unknown',
|
||||
chatId,
|
||||
text,
|
||||
};
|
||||
|
|
@ -104,9 +99,7 @@ export class MockPluginChannel extends ChannelBase {
|
|||
}
|
||||
|
||||
override async handleInbound(envelope: Envelope): Promise<void> {
|
||||
this.pendingMessageId = (envelope as unknown as Record<string, unknown>)[
|
||||
'_messageId'
|
||||
] as string | undefined;
|
||||
this.pendingMessageId = envelope.messageId;
|
||||
try {
|
||||
await super.handleInbound(envelope);
|
||||
} finally {
|
||||
|
|
@ -8,8 +8,8 @@ export type { MockServerHandle, MockServerOptions } from './mock-server.js';
|
|||
export type { InboundMessage, OutboundMessage, WsMessage } from './protocol.js';
|
||||
|
||||
export const plugin: ChannelPlugin = {
|
||||
channelType: 'mock-plugin',
|
||||
displayName: 'Mock Plugin',
|
||||
channelType: 'plugin-example',
|
||||
displayName: 'Plugin Example',
|
||||
requiredConfigFields: ['serverWsUrl'],
|
||||
createChannel: (name, config, bridge, options) =>
|
||||
new MockPluginChannel(name, config as MockPluginConfig, bridge, options),
|
||||
Loading…
Add table
Add a link
Reference in a new issue