Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
3eedc43
feat(channels): add Telegram channel integration with ACP bridge
tanzhenxin Mar 24, 2026
be838ee
feat(channels/telegram): format agent markdown as Telegram HTML
tanzhenxin Mar 24, 2026
2985201
feat(channels/telegram): add slash command support
tanzhenxin Mar 24, 2026
615ccd0
feat(channels): add config validation, instructions, and sessionScope…
tanzhenxin Mar 24, 2026
a5d2faf
fix(channels): fix TypeScript build errors
tanzhenxin Mar 24, 2026
2867e77
refactor(channels): simplify Telegram status tracking and improve eve…
tanzhenxin Mar 24, 2026
59ee49e
docs(channels): add Channels feature documentation
tanzhenxin Mar 24, 2026
8753245
feat(channels): add DM pairing flow for sender approval
tanzhenxin Mar 24, 2026
9023646
feat(channels): add group chat support for Telegram
tanzhenxin Mar 25, 2026
24c9b0f
feat(channels): add WeChat/Weixin channel support
tanzhenxin Mar 25, 2026
4f2b9e9
feat(channels): add multimodal support with image handling
tanzhenxin Mar 25, 2026
b37e211
feat(channels): add file and photo support for Telegram and WeChat
tanzhenxin Mar 25, 2026
f6ae769
docs(channels): document media support and add WeChat guide
tanzhenxin Mar 25, 2026
1a605ec
feat(channels): add crash recovery and gateway mode support
tanzhenxin Mar 26, 2026
697898a
feat(channel): add status and stop commands for service management
tanzhenxin Mar 26, 2026
9c001ba
feat(channels): add shared slash command system
tanzhenxin Mar 26, 2026
1a272a1
feat(channels): add reply context support for referenced messages
tanzhenxin Mar 26, 2026
92c54ff
feat(channels): add DingTalk channel adapter
tanzhenxin Mar 26, 2026
217964b
feat(channels): add reaction feedback and webhook caching for DingTalk
tanzhenxin Mar 26, 2026
6c6057c
feat(channels): add DingTalk markdown normalization
tanzhenxin Mar 26, 2026
a61189b
feat(channels): add DingTalk media download support
tanzhenxin Mar 26, 2026
9f4dd53
feat(channels): add DingTalk reply/quote message context support
tanzhenxin Mar 26, 2026
33901fb
docs(channels): add DingTalk channel documentation
tanzhenxin Mar 26, 2026
f3a03d0
fix(channels): isolate sessions per chat and serialize prompts per se…
tanzhenxin Mar 26, 2026
8a6ed12
feat(channels): add ChannelPlugin interface and registry-based factory
tanzhenxin Mar 26, 2026
06ccc80
feat(channels): allow extensions to register channel plugins
tanzhenxin Mar 26, 2026
2b10a2d
test(channels): add loopback channel integration test
tanzhenxin Mar 26, 2026
0f9e440
feat(channels): add mock channel package for E2E testing
tanzhenxin Mar 26, 2026
01c2e5a
docs(channels): add custom channel plugins documentation
tanzhenxin Mar 26, 2026
987eebd
docs(channels): add plugin developer guide and rename mock to plugin-…
tanzhenxin Mar 27, 2026
c97c548
feat(channels): make plugin-example package publishable
tanzhenxin Mar 27, 2026
a700ce8
chore(release): add channel packages to release workflow
tanzhenxin Mar 27, 2026
5dfcfd6
feat(build): add channel-base package to build order
tanzhenxin Mar 27, 2026
811ccdd
Merge remote-tracking branch 'origin/main' into feat/channels-telegram
tanzhenxin Mar 27, 2026
fc0bb3c
chore(cli): add TypeScript project references for channels packages
tanzhenxin Mar 27, 2026
dea1449
feat(channels): configure channel adapters for compiled distribution
tanzhenxin Mar 27, 2026
af345a3
chore(channels): bump package versions and improve clean script
tanzhenxin Mar 27, 2026
a806c8a
chore(docker): ignore tsconfig.tsbuildinfo files
tanzhenxin Mar 27, 2026
cceac60
fix(cli): skip stdin read for ACP mode
tanzhenxin Mar 27, 2026
0ca8cf8
docs(channels): add README for channel-base package
tanzhenxin Mar 27, 2026
f7979aa
feat(channels): add streaming response hooks to ChannelBase
tanzhenxin Mar 27, 2026
3d24a9c
feat(channels): add BlockStreamer for progressive message delivery
tanzhenxin Mar 27, 2026
3e0f213
feat(channels): add structured attachment support for file handling
tanzhenxin Mar 27, 2026
39103ee
docs(channels): document attachments and block streaming features
tanzhenxin Mar 27, 2026
d84675e
test(channels): add comprehensive test suites for channel adapters
tanzhenxin Mar 27, 2026
9fc2abb
style(test): use bracket notation for process.env access
tanzhenxin Mar 28, 2026
7251da0
feat(channels): add dispatch modes and prompt lifecycle hooks
tanzhenxin Mar 28, 2026
7962d4f
Merge remote-tracking branch 'origin/main' into feat/channels-telegram
tanzhenxin Mar 30, 2026
bac0ba0
docs(channels): add design documentation for channels feature
tanzhenxin Mar 30, 2026
2ca45b7
docs(channels): remove personal info from design docs
tanzhenxin Mar 30, 2026
7bbd5e6
fix(channels): address PR review — security, bugs, and reliability
tanzhenxin Mar 31, 2026
f61517c
chore(channels): add plugin-example to build pipeline and prepublish …
tanzhenxin Apr 1, 2026
46bd05e
fix(channels/telegram): migrate from telegraf to grammy
tanzhenxin Apr 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ node_modules
# Build artifacts (rebuilt from scratch inside the container)
dist
**/dist
**/tsconfig.tsbuildinfo

# Version control
.git
16 changes: 15 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ jobs:
IS_DRY_RUN: '${{ steps.vars.outputs.is_dry_run }}'
RELEASE_TAG: '${{ steps.version.outputs.RELEASE_TAG }}'
run: |-
git add package.json package-lock.json packages/*/package.json
git add package.json package-lock.json packages/*/package.json packages/channels/*/package.json
if git diff --staged --quiet; then
echo "No version changes to commit"
else
Expand Down Expand Up @@ -198,6 +198,20 @@ jobs:
env:
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'

- name: 'Publish @qwen-code/channel-base'
working-directory: 'packages/channels/base'
run: |-
npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }}
env:
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'

- name: 'Publish @qwen-code/channel-plugin-example'
working-directory: 'packages/channels/plugin-example'
run: |-
npm publish --access public --tag=${{ steps.version.outputs.NPM_TAG }} ${{ steps.vars.outputs.is_dry_run == 'true' && '--dry-run' || '' }}
env:
NODE_AUTH_TOKEN: '${{ secrets.NPM_TOKEN }}'

- name: 'Create GitHub Release and Tag'
if: |-
${{ steps.vars.outputs.is_dry_run == 'false' }}
Expand Down
190 changes: 190 additions & 0 deletions docs/design/channels/channels-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
# Channels Design

> External messaging integrations for Qwen Code — interact with an agent from Telegram, WeChat, and more.
>
> Channel-implementation status: `channels-implementation.md`. Testing: `channels-testing-guide.md`.

## Overview

A **channel** connects an external messaging platform to a Qwen Code agent. Configured in `settings.json`, managed via `qwen channel` subcommands, multi-user (each user gets an isolated ACP session).

## Architecture

```
┌──────────┐ ┌─────────────────────────────────────┐
│ Telegram │ Platform API │ Channel Service │
│ User A │◄──────────────────────►│ │
├──────────┤ (WebSocket/polling) │ ┌───────────┐ ┌──────────────┐ │
│ WeChat │◄──────────────────────►│ │ Platform │ │ ACP Bridge │ │
│ User B │ │ │ Adapter │ │ (shared) │ │
└──────────┘ │ │ │ │ │ │
│ │ - connect │ │ - spawns │ │
│ │ - receive │ │ qwen-code │ │
│ │ - send │ │ - manages │ │
│ │ │ │ sessions │ │
│ └─────┬──────┘ └──────┬───────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────────────────────┐ │
│ │ SenderGate · GroupGate │ │
│ │ SessionRouter · ChannelBase │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────┘
│ stdio (ACP ndjson)
┌─────────────────────────────────────┐
│ qwen-code --acp │
│ Session A (user alice, id: "abc") │
│ Session B (user bob, id: "def") │
└─────────────────────────────────────┘
```

**Platform Adapter** — connects to external API, translates messages to/from Envelopes. **ACP Bridge** — spawns `qwen-code --acp`, manages sessions, emits `textChunk`/`toolCall`/`disconnected` events. **Session Router** — maps senders to ACP sessions via namespaced keys (`<channel>:<sender>`). **Sender Gate** / **Group Gate** — access control (allowlist / pairing / open) and mention gating. **Channel Base** — abstract base with Template Method pattern: plugins override `connect`, `sendMessage`, `disconnect`. **Channel Registry** — `Map<string, ChannelPlugin>` with collision detection.

### Envelope

Normalized message format all platforms convert to:

- **Identity**: `senderId`, `senderName`, `chatId`, `channelName`
- **Content**: `text`, optional `imageBase64`/`imageMimeType`, optional `referencedText`
- **Context**: `isGroup`, `isMentioned`, `isReplyToBot`, optional `threadId`

Plugin responsibilities: `senderId` must be stable/unique; `chatId` must distinguish DMs from groups; boolean flags must be accurate for gate logic; @mentions stripped from `text`.

### Message Flow

```
Inbound: User message → Adapter → GroupGate → SenderGate → Slash commands → SessionRouter → AcpBridge → Agent
Outbound: Agent response → AcpBridge → SessionRouter → Adapter → User
```

Slash commands (`/clear`, `/help`, `/status`) are handled in ChannelBase before reaching the agent.

### Sessions

One `qwen-code --acp` process with multiple ACP sessions. Scope per channel: **`user`** (default), **`thread`**, or **`single`**. Routing keys namespaced as `<channelName>:<key>`.

### Error Handling

- **Connection failures** — logged; service continues if at least one channel connects
- **Bridge crashes** — exponential backoff (max 3 retries), `setBridge()` on all channels, session restore
- **Session serialization** — per-session promise chains prevent concurrent prompt collisions

## Plugin System

The architecture is extensible — new adapters (including third-party) can be added without modifying core. Built-in channels use the same plugin interface (dogfooding).

### Plugin Contract

A `ChannelPlugin` declares `channelType`, `displayName`, `requiredConfigFields`, and a `createChannel()` factory. Plugins implement three methods:

| Method | Responsibility |
| --------------------------- | ------------------------------------------------- |
| `connect()` | Connect to platform and register message handlers |
| `sendMessage(chatId, text)` | Format and deliver agent response |
| `disconnect()` | Clean up on shutdown |

On inbound messages, plugins build an `Envelope` and call `this.handleInbound(envelope)` — the base class handles the rest: access control, group gating, pairing, session routing, prompt serialization, slash commands, instructions injection, reply context, and crash recovery.

### Extension Points

- Custom slash commands via `registerCommand()`
- Working indicators by wrapping `handleInbound()` with typing/reaction display
- Tool call hooks via `onToolCall()`
- Media handling by attaching to Envelope before `handleInbound()`

### Discovery & Loading

External plugins are **extensions** managed by `ExtensionManager`, declared in `qwen-extension.json`:

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

Loading sequence at `qwen channel start`: load settings → register built-ins → scan extensions → dynamic import + validate → register (reject collisions) → validate config → `createChannel()` → `connect()`.

Plugins run in-process (no sandbox), same trust model as npm dependencies.

## Configuration

```jsonc
{
"channels": {
"my-telegram": {
"type": "telegram",
"token": "$TELEGRAM_BOT_TOKEN", // env var reference
"senderPolicy": "allowlist", // allowlist | pairing | open
"allowedUsers": ["123456"],
"sessionScope": "user", // user | thread | single
"cwd": "/path/to/project",
"model": "qwen3.5-plus",
"instructions": "Keep responses short.",
"groupPolicy": "disabled", // disabled | allowlist | open
"groups": { "*": { "requireMention": true } },
},
},
}
```

Auth is plugin-specific: static token (Telegram), app credentials (DingTalk), QR code login (WeChat), proxy token (TMCP).

## CLI Commands

```bash
# Channels
qwen channel start [name] # start all or one channel
qwen channel stop # stop running service
qwen channel status # show channels, sessions, uptime
qwen channel pairing list <ch> # pending pairing requests
qwen channel pairing approve <ch> <code> # approve a request

# Extensions
qwen extensions install <path-or-package> # install
qwen extensions link <local-path> # symlink for dev
qwen extensions list # show installed
qwen extensions remove <name> # uninstall
```

## Package Structure

```
packages/channels/
├── base/ # @qwen-code/channel-base
│ └── src/
│ ├── AcpBridge.ts # ACP process lifecycle, session management
│ ├── SessionRouter.ts # sender ↔ session mapping, persistence
│ ├── SenderGate.ts # allowlist / pairing / open
│ ├── GroupGate.ts # group chat policy + mention gating
│ ├── PairingStore.ts # pairing code generation + approval
│ ├── ChannelBase.ts # abstract base: routing, slash commands
│ └── types.ts # Envelope, ChannelConfig, etc.
├── telegram/ # @qwen-code/channel-telegram
├── weixin/ # @qwen-code/channel-weixin
└── dingtalk/ # @qwen-code/channel-dingtalk
```

## What's Next

- **DingTalk: quoted bot responses** — persist outbound text keyed by `processQueryKey` (see `channels-dingtalk.md`)
- **Streaming responses** — edit messages in-place as chunks arrive
- **Structured logging** — pino; JSON by default, human-readable on TTY
- **E2E tests** — mock servers for platform APIs + mock ACP agent
- **Daemon mode** — background operation, systemd/launchd unit generation

## Known Limitations

- **Shared workspace conflicts** — multiple users editing the same `cwd` may cause file conflicts
- **Crash-recovery sessions only** — sessions persist for bridge restarts but cleared on clean shutdown
- **Sequential prompts per session** — messages queue within a session; different sessions run independently
- **Single instance** — PID file prevents duplicates; `qwen channel stop` first
- **Shared bridge model** — all channels share one ACP bridge process; if channels configure different models, only the first is used (warning shown)
107 changes: 107 additions & 0 deletions docs/design/channels/channels-implementation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Channels

Qwen Code supports three messaging channels — Telegram, WeChat, and DingTalk. All adapters extend the shared channel architecture (`ChannelBase`, `AcpBridge`, `SessionRouter`) in `packages/channels/base/src/`. Each channel can be started individually or all together with `node dist/cli.js channel start`.

---

## Telegram

Source: `packages/channels/telegram/src/TelegramAdapter.ts`, built on the Telegraf library.

The adapter supports plain text messaging, slash commands, a working indicator ("typing" chat action), DM pairing, and group chat (supergroups with @mention gating). Image receiving works via `bot.on('photo')` → `getFileLink` → download → base64, with captions passed as envelope text. File/document receiving saves downloaded files to `/tmp/channel-files/` and includes the path in the envelope so the agent can read them via `read-file` (works with any model, no multimodal required). Referenced messages include the quoted text as context in the prompt. Output is formatted as Telegram HTML (converted from markdown). Authentication uses a static bot token. Session persistence and pairing state are stored under `~/.qwen/channels/`.

```jsonc
// ~/.qwen/settings.json
{
"channels": {
"my-telegram": {
"type": "telegram",
"token": "$TELEGRAM_BOT_TOKEN",
"senderPolicy": "pairing",
"allowedUsers": [],
"sessionScope": "user",
"instructions": "Keep responses concise.",
},
},
}
```

```bash
source /path/to/telegram/.env
npm run bundle && node dist/cli.js channel start my-telegram
```

**Future work:** Streaming responses via in-place `editMessageText` (throttled at ~2s to respect rate limits, best-effort fallback to single message). Slash command polish — register with BotFather via `setMyCommands()`, fix `/help` timing, add `/status` command.

---

## WeChat (Weixin)

Source: `packages/channels/weixin/src/`, ported from the cc-weixin project. Uses the iLink Bot API at `ilinkai.weixin.qq.com`.

The adapter supports plain text messaging via a custom long-poll loop (`/ilink/bot/getupdates`, cursor-based), with `context_token` caching per user for reply context. Authentication uses QR code login (`qwen channel configure-weixin`), producing a bearer token stored in `~/.qwen/channels/weixin/account.json`. A typing indicator fires before each ACP prompt using the `sendTyping` API (ticket obtained from `getConfig`). Image and file/PDF receiving works through CDN download with AES-128-ECB decryption — images are forwarded as base64 content blocks, files are saved to `/tmp/channel-files/` and referenced by path. Referenced messages (user replies) include quoted text as context in the prompt. Formatting is plain text only (all markdown is stripped). The adapter handles session expiry (`errcode -14`) with automatic reconnection, uses backoff after consecutive errors, and persists the polling cursor to `~/.qwen/channels/weixin/cursor.txt` for crash recovery.

```jsonc
// ~/.qwen/settings.json
{
"channels": {
"my-weixin": {
"type": "weixin",
"senderPolicy": "pairing",
"allowedUsers": [],
"sessionScope": "user",
"instructions": "Keep responses concise, plain text only.",
"baseUrl": "https://ilinkai.weixin.qq.com", // optional override
},
},
}
```

Credentials are stored separately in `~/.qwen/channels/weixin/account.json`, created by `qwen channel configure-weixin`.

```bash
# First time: login via QR code
node dist/cli.js channel configure-weixin

# Start
npm run bundle && node dist/cli.js channel start my-weixin
```

**Future work:** Media send (upload to WeChat CDN with AES encryption). Voice/video receive. Streaming responses via `message_state: GENERATING` → `FINISH` (pending client-side investigation). Multi-account support. Message chunking for long responses.

---

## DingTalk (钉钉)

Source: `packages/channels/dingtalk/src/`, using Stream mode (WebSocket, no public IP required). Referenced from openclaw-channel-dingtalk.

The adapter connects via the `dingtalk-stream` SDK, which handles WebSocket connection, reconnection, heartbeats, and callback ACKs (DingTalk retries unACKed messages). Authentication reuses the SDK's built-in token (`client.getConfig().access_token`) from AppKey + AppSecret. Responses are sent back through a per-message `sessionWebhook` URL — a temporary, conversation-scoped endpoint that supports text, markdown, images, and files. Both DM and group chat are supported, with group messages gated by `@mention` detection (`isInAtList`). A 👀 emoji reaction serves as a working indicator while the agent processes (posted via the emotion API and recalled on completion). Output is formatted as DingTalk markdown, with tables converted to plain text, messages split at ~3800 characters, and code fences maintained across chunks. Image, file, audio, and video receiving works through a two-step download flow (`downloadCode` → `downloadUrl` → buffer); images are forwarded as base64, files saved to `/tmp/channel-files/`. Quoted message context is extracted from `text.repliedMsg` and `quoteMessage`, with bot-reply detection via `chatbotUserId`.

```jsonc
// ~/.qwen/settings.json
{
"channels": {
"my-dingtalk": {
"type": "dingtalk",
"clientId": "$DINGTALK_CLIENT_ID",
"clientSecret": "$DINGTALK_CLIENT_SECRET",
"senderPolicy": "open",
"sessionScope": "user",
"cwd": "/path/to/project",
"instructions": "Keep responses concise. Use DingTalk markdown.",
"groupPolicy": "open",
"groups": {
"*": { "requireMention": true },
},
},
},
}
```

```bash
export DINGTALK_CLIENT_ID=<your-app-key>
export DINGTALK_CLIENT_SECRET=<your-app-secret>
npm run bundle && node dist/cli.js channel start my-dingtalk
```

**Future work:** Quoted bot responses (persisting outbound messages keyed by `processQueryKey` for lookup on reply). AI Card streaming via `/v1.0/card/instances` and `/v1.0/card/streaming` with graceful markdown fallback.
Loading
Loading