diff --git a/.dockerignore b/.dockerignore index 88c9891937..f8700bbc72 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,6 +5,7 @@ node_modules # Build artifacts (rebuilt from scratch inside the container) dist **/dist +**/tsconfig.tsbuildinfo # Version control .git diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 617cf9553b..5d323cdd92 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 @@ -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' }} diff --git a/docs/design/channels/channels-design.md b/docs/design/channels/channels-design.md new file mode 100644 index 0000000000..7acbdf5edc --- /dev/null +++ b/docs/design/channels/channels-design.md @@ -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 (`:`). **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` 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 `:`. + +### 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 # pending pairing requests +qwen channel pairing approve # approve a request + +# Extensions +qwen extensions install # install +qwen extensions link # symlink for dev +qwen extensions list # show installed +qwen extensions remove # 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) diff --git a/docs/design/channels/channels-implementation.md b/docs/design/channels/channels-implementation.md new file mode 100644 index 0000000000..35e936b4fb --- /dev/null +++ b/docs/design/channels/channels-implementation.md @@ -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= +export DINGTALK_CLIENT_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. diff --git a/docs/design/channels/channels-roadmap.md b/docs/design/channels/channels-roadmap.md new file mode 100644 index 0000000000..8f0583744c --- /dev/null +++ b/docs/design/channels/channels-roadmap.md @@ -0,0 +1,51 @@ +# Channels Roadmap + +## Implemented (MVP) + +- **3 built-in channels** — Telegram, WeChat, DingTalk +- **Plugin system** — `ChannelBase` SDK with `connect`/`sendMessage`/`disconnect`, extension manifest, compiled JS + `.d.ts` +- **Access control** — `allowlist`, `pairing` (8-char codes, CLI approval), `open` policies +- **Group chat** — `open`/`disabled`/`allowlist` group policy, `requireMention` per group, reply-as-mention +- **Session routing** — `user`, `thread`, `single` scopes with per-channel `cwd`, `model`, `instructions` +- **Dispatch modes** — `steer` (default: cancel + re-prompt), `collect` (buffer + coalesce), `followup` (sequential queue). Per-channel and per-group config. +- **Working indicators** — centralized `onPromptStart`/`onPromptEnd` hooks. Telegram: typing bar. WeChat: typing API. DingTalk: 👀 emoji reaction. +- **Block streaming** — progressive multi-message delivery with paragraph-aware chunking +- **Streaming hooks** — `onResponseChunk`/`onResponseComplete` for plugins to implement progressive display +- **Media support** — images (vision input), files/audio/video (saved to temp, path in prompt), `Attachment` interface on `Envelope` +- **Slash commands** — `/help`, `/clear` (`/reset`, `/new`), `/status`, custom via `registerCommand()` +- **Service management** — `qwen channel start/stop/status`, PID tracking, crash recovery (auto-restart, session persistence) +- **Token security** — `$ENV_VAR` syntax in config + +## Future Work + +### Safety & Group Chat + +- **Per-group tool restrictions** — `tools`/`toolsBySender` deny/allow lists per group +- **Group context history** — ring buffer of recent skipped messages, prepended on @mention +- **Regex mention patterns** — fallback `mentionPatterns` for unreliable @mention metadata +- **Per-group instructions** — `instructions` field on `GroupConfig` for per-group personas +- **`/activation` command** — runtime toggle for `requireMention`, persisted to disk + +### Operational Tooling + +- **`qwen channel doctor`** — config validation, env vars, bot tokens, network checks +- **`qwen channel status --probe`** — real connectivity checks per channel + +### Platform Expansion + +- **Discord** — Bot API + Gateway, servers/channels/DMs/threads +- **Slack** — Bolt SDK, Socket Mode, workspaces/channels/DMs/threads + +### Multi-Agent + +- **Multi-agent routing** — multiple agents with bindings per channel/group/user +- **Broadcast groups** — multiple agents respond to the same message + +### Plugin Ecosystem + +- **Community plugin template** — `create-qwen-channel` scaffolding tool +- **Plugin registry/discovery** — `qwen extensions search`, version compatibility + +## Reference: OpenClaw Comparison + +See [channels-comparison.md](channels-comparison.md) for the detailed feature comparison between OpenClaw and Qwen-Code channels. diff --git a/docs/design/channels/channels-testing-guide.md b/docs/design/channels/channels-testing-guide.md new file mode 100644 index 0000000000..cbb4bcdcdb --- /dev/null +++ b/docs/design/channels/channels-testing-guide.md @@ -0,0 +1,156 @@ +# Channels Testing Guide + +How to test channel integrations end-to-end. + +## Credentials + +- Telegram bot: `@qwencod_test_1_bot` (远弟) +- Bot token env var: `TELEGRAM_BOT_TOKEN` +- Bot token file: `/path/to/telegram/.env` +- Telegram user ID: `` +- WeChat credentials: `~/.qwen/channels/weixin/account.json` + +## Before testing + +**Important:** Stop any running service first. Duplicate instances cause duplicate responses. + +```bash +# Stop the service if running +qwen channel stop + +# Or check status first +qwen channel status + +# If processes are stuck (e.g. from manual kill -9), clean up manually +pkill -9 -f "cli.js --acp" +pkill -9 -f "channel start" +rm -f ~/.qwen/channels/service.pid ~/.qwen/channels/sessions.json +``` + +## Sending messages via Bot API (no bot process needed) + +```bash +# Source the token +export TELEGRAM_BOT_TOKEN=$(grep TELEGRAM_BOT_TOKEN /path/to/telegram/.env | cut -d= -f2) + +# Send a message (replace YOUR_CHAT_ID with your Telegram user ID) +curl -s -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \ + -H "Content-Type: application/json" \ + -d '{"chat_id": "YOUR_CHAT_ID", "text": "Hello from the bot!"}' +``` + +## Starting channels + +```bash +export TELEGRAM_BOT_TOKEN=$(grep TELEGRAM_BOT_TOKEN /path/to/telegram/.env | cut -d= -f2) +cd /path/to/qwen-code +npm run bundle + +# Single channel +node dist/cli.js channel start my-telegram + +# All channels (shared bridge) +node dist/cli.js channel start +``` + +Settings config: `~/.qwen/settings.json` under `channels.*`. + +## Checking registered commands + +```bash +curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMyCommands" | python3 -m json.tool +``` + +## Test scenarios + +### 1. Slash commands (shared across all channels) + +Start the service, then send on Telegram or WeChat: + +| Command | Expected | +| --------- | --------------------------------------------------------------- | +| `/help` | List of all commands | +| `/status` | "Session: none, Access: ..." | +| `/clear` | "No active session to clear." (or "Session cleared." if active) | +| `/reset` | Same as `/clear` (alias) | +| `/new` | Same as `/clear` (alias) | + +### 2. Basic text round-trip + +1. Start the bot +2. Send any text (e.g. "hello") +3. Bot should respond via the agent +4. `/status` should now show "Session: active" + +### 3. Multi-turn conversation + +1. Send "my name is Alice" +2. Send "what is my name?" +3. Agent should remember "Alice" from same session + +### 4. Session clear + +1. Have an active session (send a message first) +2. Send `/clear` (or `/reset` or `/new`) +3. Send "what is my name?" +4. Agent should NOT remember — fresh session + +### 5. Tool calls (internal) + +1. Send "list the files in /path/to/project" +2. Agent should use shell/ls internally and return file listing +3. Verify response contains actual file names + +### 6. Markdown formatting + +1. Send "write me a hello world in python with explanation" +2. Response should render with proper Telegram HTML formatting (bold, code blocks, etc.) + +### 7. Multi-channel mode + +1. Ensure both `my-telegram` and `my-weixin` are configured in `~/.qwen/settings.json` +2. For WeChat: run `node dist/cli.js channel configure-weixin` if token expired +3. Start all: `node dist/cli.js channel start` +4. Should show: `Starting 2 channel(s): my-weixin, my-telegram` +5. Send messages on both platforms — each should get exactly one response +6. Check `~/.qwen/channels/sessions.json` — each channel should have its own cwd + +### 8. Crash recovery + +1. Start multi-channel mode and send a message to create sessions +2. Find the ACP bridge PID: `ps --ppid -o pid,args | grep acp` +3. Kill it: `kill -9 ` +4. Log should show: `Bridge crashed (1/3). Restarting in 3s...` then `Sessions restored: 2, failed: 0` +5. Send a message — should work, and session context (e.g. "what is my name?") should be preserved + +### 9. Clean shutdown + +1. Start channels, send a message to create sessions +2. Press Ctrl+C (or `qwen channel stop` from another terminal) +3. `~/.qwen/channels/sessions.json` should be deleted +4. `~/.qwen/channels/service.pid` should be deleted + +### 10. Service management + +1. Start service: `qwen channel start` +2. Check status from another terminal: `qwen channel status` — should show running, uptime, channels +3. Try starting again: `qwen channel start` — should fail with "already running" error +4. Stop from another terminal: `qwen channel stop` — should stop gracefully +5. Confirm stopped: `qwen channel status` — should show "No channel service is running." + +### 11. Referenced messages (quoted replies) + +1. Send a message and get a bot response +2. Reply to (quote) the bot's response with a follow-up question (e.g. "summarize that") +3. Agent should see the quoted text as context and respond accordingly +4. Test on both Telegram and WeChat + +## Useful debug commands + +```bash +# Check recent updates the bot received +curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getUpdates?limit=5" | python3 -m json.tool + +# Get bot info +curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getMe" | python3 -m json.tool +``` diff --git a/docs/developers/_meta.ts b/docs/developers/_meta.ts index a7a316f77d..42c938c9f5 100644 --- a/docs/developers/_meta.ts +++ b/docs/developers/_meta.ts @@ -17,6 +17,7 @@ export default { type: 'separator', }, + 'channel-plugins': 'Channel Plugin Guide', tools: 'Tools', examples: { diff --git a/docs/developers/channel-plugins.md b/docs/developers/channel-plugins.md new file mode 100644 index 0000000000..7dffa78dcc --- /dev/null +++ b/docs/developers/channel-plugins.md @@ -0,0 +1,179 @@ +# 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 { + // 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 { + // 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 (legacy — prefer `attachments`) | +| `imageMimeType` | string | No | e.g., `image/jpeg` (legacy — prefer `attachments`) | +| `attachments` | Attachment[] | No | Structured media attachments (see below) | + +### Attachments + +Use the `attachments` array for images, files, audio, and video. `handleInbound()` resolves them automatically: images with base64 `data` are sent to the model as vision input, files with a `filePath` get their path appended to the prompt so the agent can read them. + +```typescript +interface Attachment { + type: 'image' | 'file' | 'audio' | 'video'; + data?: string; // base64-encoded data (images, small files) + filePath?: string; // absolute path to local file (large files saved to disk) + mimeType: string; // e.g. 'application/pdf', 'image/jpeg' + fileName?: string; // original file name from the platform +} +``` + +Example — handling a file upload in your adapter: + +```typescript +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, + }, +]; +``` + +The legacy `imageBase64`/`imageMimeType` fields still work for backwards compatibility but `attachments` is preferred for new code. + +## 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 `onPromptStart()` and `onPromptEnd()` to show platform-specific typing indicators. These hooks fire only when a prompt actually begins processing — not for buffered messages (collect mode) or gated/blocked messages: + +```typescript +protected override onPromptStart(chatId: string, sessionId: string, messageId?: string): void { + this.platformClient.sendTyping(chatId); // your platform API +} + +protected override onPromptEnd(chatId: string, sessionId: string, messageId?: string): void { + this.platformClient.stopTyping(chatId); +} +``` + +**Tool call hooks** — override `onToolCall()` to display agent activity (e.g., "Running shell command..."). + +**Streaming hooks** — override `onResponseChunk(chatId, chunk, sessionId)` for per-chunk progressive display (e.g., editing a message in-place). Override `onResponseComplete(chatId, fullText, sessionId)` to customize final delivery. + +**Block streaming** — set `blockStreaming: "on"` in the channel config. The base class automatically splits responses into multiple messages at paragraph boundaries. No plugin code needed — it works alongside `onResponseChunk`. + +**Media** — populate `envelope.attachments` with images/files. See [Attachments](#attachments) above. + +## 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 diff --git a/docs/users/extension/introduction.md b/docs/users/extension/introduction.md index 0efb25b7cf..f8d5ce4c84 100644 --- a/docs/users/extension/introduction.md +++ b/docs/users/extension/introduction.md @@ -156,6 +156,12 @@ The `qwen-extension.json` file contains the configuration for the extension. The "command": "node my-server.js" } }, + "channels": { + "my-platform": { + "entry": "dist/index.js", + "displayName": "My Platform Channel" + } + }, "contextFileName": "QWEN.md", "commands": "commands", "skills": "skills", @@ -175,6 +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 [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. diff --git a/docs/users/features/_meta.ts b/docs/users/features/_meta.ts index cb083c35aa..89b135bcd6 100644 --- a/docs/users/features/_meta.ts +++ b/docs/users/features/_meta.ts @@ -13,5 +13,6 @@ export default { 'token-caching': 'Token Caching', sandbox: 'Sandboxing', language: 'i18n', + channels: 'Channels', hooks: 'Hooks', }; diff --git a/docs/users/features/channels/_meta.ts b/docs/users/features/channels/_meta.ts new file mode 100644 index 0000000000..6ee9966566 --- /dev/null +++ b/docs/users/features/channels/_meta.ts @@ -0,0 +1,7 @@ +export default { + overview: 'Overview', + telegram: 'Telegram', + weixin: 'WeChat', + dingtalk: 'DingTalk', + plugins: 'Plugins', +}; diff --git a/docs/users/features/channels/dingtalk.md b/docs/users/features/channels/dingtalk.md new file mode 100644 index 0000000000..ed88caa4f1 --- /dev/null +++ b/docs/users/features/channels/dingtalk.md @@ -0,0 +1,134 @@ +# DingTalk (Dingtalk) + +This guide covers setting up a Qwen Code channel on DingTalk (钉钉). + +## Prerequisites + +- A DingTalk organization account +- A DingTalk bot application with AppKey and AppSecret (see below) + +## Creating a Bot + +1. Go to the [DingTalk Developer Portal](https://open-dev.dingtalk.com) +2. Create a new application (or use an existing one) +3. Under the application, enable the **Robot** capability +4. In Robot settings, enable **Stream Mode** (机器人协议 → Stream 模式) +5. Note the **AppKey** (Client ID) and **AppSecret** (Client Secret) from the application credentials page + +### Stream Mode + +DingTalk Stream mode uses an outbound WebSocket connection — no public URL or server is needed. The bot connects to DingTalk's servers, which push messages through the WebSocket. This is the simplest deployment model. + +## Configuration + +Add the channel to `~/.qwen/settings.json`: + +```json +{ + "channels": { + "my-dingtalk": { + "type": "dingtalk", + "clientId": "$DINGTALK_CLIENT_ID", + "clientSecret": "$DINGTALK_CLIENT_SECRET", + "senderPolicy": "open", + "sessionScope": "user", + "cwd": "/path/to/your/project", + "instructions": "You are a concise coding assistant responding via DingTalk.", + "groupPolicy": "open", + "groups": { + "*": { "requireMention": true } + } + } + } +} +``` + +Set the credentials as environment variables: + +```bash +export DINGTALK_CLIENT_ID= +export DINGTALK_CLIENT_SECRET= +``` + +Or define them in the `env` section of `settings.json`: + +```json +{ + "env": { + "DINGTALK_CLIENT_ID": "your-app-key", + "DINGTALK_CLIENT_SECRET": "your-app-secret" + } +} +``` + +## Running + +```bash +# Start only the DingTalk channel +qwen channel start my-dingtalk + +# Or start all configured channels together +qwen channel start +``` + +Open DingTalk and send a message to the bot. You should see a 👀 emoji reaction appear while the agent processes, followed by the response. + +## Group Chats + +DingTalk bots work in both DM and group conversations. To enable group support: + +1. Set `groupPolicy` to `"allowlist"` or `"open"` in your channel config +2. Add the bot to a DingTalk group +3. @mention the bot in the group to trigger a response + +By default, the bot requires an @mention in group chats (`requireMention: true`). Set `"requireMention": false` for a specific group to make it respond to all messages. See [Group Chats](./overview#group-chats) for full details. + +### Finding a Group's Conversation ID + +DingTalk uses `conversationId` to identify groups. You can find it in the channel service logs when someone sends a message in the group — look for the `conversationId` field in the log output. + +## Images and Files + +You can send photos and documents to the bot, not just text. + +**Photos:** Send an image (screenshot, diagram, etc.) and the agent will analyze it using its vision capabilities. This requires a multimodal model — add `"model": "qwen3.5-plus"` (or another vision-capable model) to your channel config. DingTalk supports sending images directly or as part of rich text messages (mixed text + images). + +**Files:** Send a PDF, code file, or any document. The bot downloads it from DingTalk's servers and saves it locally so the agent can read it with its file tools. Audio and video files are also supported. This works with any model. + +## Key Differences from Telegram + +- **Authentication:** AppKey + AppSecret instead of a static bot token. The SDK manages access token refresh automatically. +- **Connection:** WebSocket stream instead of polling — no public IP or webhook URL needed. +- **Formatting:** Responses use DingTalk's markdown dialect (a limited subset). Tables are automatically converted to plain text since DingTalk doesn't render them. Long messages are split into chunks at ~3800 characters. +- **Working indicator:** A 👀 emoji reaction is added to the user's message while processing, then removed when the response is sent. +- **Media download:** Two-step process — a `downloadCode` from the message is exchanged for a temporary download URL via DingTalk's API. +- **Groups:** DingTalk uses `isInAtList` for @mention detection instead of parsing message entities. + +## Tips + +- **Use DingTalk markdown-aware instructions** — DingTalk supports a limited markdown subset (headers, bold, links, code blocks, but not tables). Adding instructions like "Use DingTalk markdown. Avoid tables." helps the agent format responses correctly. +- **Restrict access** — In an organization context, `senderPolicy: "open"` may be acceptable. For tighter control, use `"allowlist"` or `"pairing"`. See [DM Pairing](./overview#dm-pairing) for details. +- **Referenced messages** — Quoting (replying to) a user message includes the quoted text as context for the agent. Quoting bot responses is not yet supported. + +## Troubleshooting + +### Bot doesn't connect + +- Verify your AppKey and AppSecret are correct +- Check that the environment variables are set before running `qwen channel start` +- Make sure **Stream Mode** is enabled in the bot's settings on the DingTalk Developer Portal +- Check the terminal output for connection errors + +### Bot doesn't respond in groups + +- Check that `groupPolicy` is set to `"allowlist"` or `"open"` (default is `"disabled"`) +- Make sure you @mention the bot in the group message +- Verify the bot has been added to the group + +### "No sessionWebhook in message" + +This means DingTalk didn't include a reply endpoint in the message callback. This can happen if the bot's permissions are misconfigured. Check the bot's settings in the Developer Portal. + +### "Sorry, something went wrong processing your message" + +This usually means the agent encountered an error. Check the terminal output for details. diff --git a/docs/users/features/channels/overview.md b/docs/users/features/channels/overview.md new file mode 100644 index 0000000000..3b6e74e9b1 --- /dev/null +++ b/docs/users/features/channels/overview.md @@ -0,0 +1,336 @@ +# Channels + +Channels let you interact with a Qwen Code agent from messaging platforms like Telegram, WeChat, or DingTalk, instead of the terminal. You send messages from your phone or desktop chat app, and the agent responds just like it would in the CLI. + +## How It Works + +When you run `qwen channel start`, Qwen Code: + +1. Reads channel configurations from your `settings.json` +2. Spawns a single agent process using the [Agent Client Protocol (ACP)](../../developers/architecture) +3. Connects to each messaging platform and starts listening for messages +4. Routes incoming messages to the agent and sends responses back to the correct chat + +All channels share one agent process with isolated sessions per user. Each channel can have its own working directory, model, and instructions. + +## Quick Start + +1. Set up a bot on your messaging platform (see channel-specific guides: [Telegram](./telegram), [WeChat](./weixin), [DingTalk](./dingtalk)) +2. Add the channel configuration to `~/.qwen/settings.json` +3. Run `qwen channel start` to start all channels, or `qwen channel start ` for a single channel + +Want to connect a platform that isn't built in? See [Plugins](./plugins) to add a custom adapter as an extension. + +## Configuration + +Channels are configured under the `channels` key in `settings.json`. Each channel has a name and a set of options: + +```json +{ + "channels": { + "my-channel": { + "type": "telegram", + "token": "$MY_BOT_TOKEN", + "senderPolicy": "allowlist", + "allowedUsers": ["123456789"], + "sessionScope": "user", + "cwd": "/path/to/working/directory", + "instructions": "Optional system instructions for the agent.", + "groupPolicy": "disabled", + "groups": { + "*": { "requireMention": true } + } + } + } +} +``` + +### Options + +| Option | Required | Description | +| ------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `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 | +| `model` | No | Model to use for this channel (e.g., `qwen3.5-plus`). Overrides the default model. Useful for multimodal models that support image input | +| `senderPolicy` | No | Who can talk to the bot: `allowlist` (default), `open`, or `pairing` | +| `allowedUsers` | No | List of user IDs allowed to use the bot (used by `allowlist` and `pairing` policies) | +| `sessionScope` | No | How sessions are scoped: `user` (default), `thread`, or `single` | +| `cwd` | No | Working directory for the agent. Defaults to the current directory | +| `instructions` | No | Custom instructions prepended to the first message of each session | +| `groupPolicy` | No | Group chat access: `disabled` (default), `allowlist`, or `open`. See [Group Chats](#group-chats) | +| `groups` | No | Per-group settings. Keys are group chat IDs or `"*"` for defaults. See [Group Chats](#group-chats) | +| `dispatchMode` | No | What happens when you send a message while the bot is busy: `steer` (default), `collect`, or `followup`. See [Dispatch Modes](#dispatch-modes) | +| `blockStreaming` | No | Progressive response delivery: `on` or `off` (default). See [Block Streaming](#block-streaming) | +| `blockStreamingChunk` | No | Chunk size bounds: `{ "minChars": 400, "maxChars": 1000 }`. See [Block Streaming](#block-streaming) | +| `blockStreamingCoalesce` | No | Idle flush: `{ "idleMs": 1500 }`. See [Block Streaming](#block-streaming) | + +### Sender Policy + +Controls who can interact with the bot: + +- **`allowlist`** (default) — Only users listed in `allowedUsers` can send messages. Others are silently ignored. +- **`pairing`** — Unknown senders receive a pairing code. The bot operator approves them via CLI, and they're added to a persistent allowlist. Users in `allowedUsers` skip pairing entirely. See [DM Pairing](#dm-pairing) below. +- **`open`** — Anyone can send messages. Use with caution. + +### Session Scope + +Controls how conversation sessions are managed: + +- **`user`** (default) — One session per user. All messages from the same user share a conversation. +- **`thread`** — One session per thread/topic. Useful for group chats with threads. +- **`single`** — One shared session for all users. Everyone shares the same conversation. + +### Token Security + +Bot tokens should not be stored directly in `settings.json`. Instead, use environment variable references: + +```json +{ + "token": "$TELEGRAM_BOT_TOKEN" +} +``` + +Set the actual token in your shell environment or in a `.env` file that gets loaded before running the channel. + +## DM Pairing + +When `senderPolicy` is set to `"pairing"`, unknown senders go through an approval flow: + +1. An unknown user sends a message to the bot +2. The bot replies with an 8-character pairing code (e.g., `VEQDDWXJ`) +3. The user shares the code with you (the bot operator) +4. You approve them via CLI: + +```bash +qwen channel pairing approve my-channel VEQDDWXJ +``` + +Once approved, the user's ID is saved to `~/.qwen/channels/-allowlist.json` and all future messages go through normally. + +### Pairing CLI Commands + +```bash +# List pending pairing requests +qwen channel pairing list my-channel + +# Approve a request by code +qwen channel pairing approve my-channel +``` + +### Pairing Rules + +- Codes are 8 characters, uppercase, using an unambiguous alphabet (no `0`/`O`/`1`/`I`) +- Codes expire after 1 hour +- Maximum 3 pending requests per channel at a time — additional requests are ignored until one expires or is approved +- Users listed in `allowedUsers` in `settings.json` always skip pairing +- Approved users are stored in `~/.qwen/channels/-allowlist.json` — treat this file as sensitive + +## Group Chats + +By default, the bot only works in direct messages. To enable group chat support, set `groupPolicy` to `"allowlist"` or `"open"`. + +### Group Policy + +Controls whether the bot participates in group chats at all: + +- **`disabled`** (default) — The bot ignores all group messages. Safest option. +- **`allowlist`** — The bot only responds in groups explicitly listed in `groups` by chat ID. The `"*"` key provides default settings but does **not** act as a wildcard allow. +- **`open`** — The bot responds in all groups it's added to. Use with caution. + +### Mention Gating + +In groups, the bot requires an `@mention` or a reply to one of its messages by default. This prevents the bot from responding to every message in a group chat. + +Configure per-group with the `groups` setting: + +```json +{ + "groups": { + "*": { "requireMention": true }, + "-100123456": { "requireMention": false } + } +} +``` + +- **`"*"`** — Default settings for all groups. Only sets config defaults, not an allowlist entry. +- **Group chat ID** — Override settings for a specific group. Overrides `"*"` defaults. +- **`requireMention`** (default: `true`) — When `true`, the bot only responds to messages that @mention it or reply to one of its messages. When `false`, the bot responds to all messages (useful for dedicated task groups). + +### How group messages are evaluated + +``` +1. groupPolicy — is this group allowed? (no → ignore) +2. requireMention — was the bot mentioned/replied to? (no → ignore) +3. senderPolicy — is this sender approved? (no → pairing flow) +4. Route to session +``` + +### Telegram Setup for Groups + +1. Add the bot to a group +2. **Disable privacy mode** in BotFather (`/mybots` → Bot Settings → Group Privacy → Turn Off) — otherwise the bot won't see non-command messages +3. **Remove and re-add the bot** to the group after changing privacy mode (Telegram caches this setting) + +### Finding a Group Chat ID + +To find a group's chat ID for the `groups` allowlist: + +1. Stop the bot if it's running +2. Send a message mentioning the bot in the group +3. Use the Telegram Bot API to check queued updates: + +```bash +curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/getUpdates" | python3 -m json.tool +``` + +Look for `message.chat.id` in the response — group IDs are negative numbers (e.g., `-5170296765`). + +## Media Support + +Channels support sending images and files to the agent, not just text. + +### Images + +Send a photo to the bot and the agent will see it — useful for sharing screenshots, error messages, or diagrams. The image is sent directly to the model as a vision input. + +To use image support, configure a multimodal model for the channel: + +```json +{ + "channels": { + "my-channel": { + "type": "telegram", + "model": "qwen3.5-plus", + ... + } + } +} +``` + +### Files + +Send a document (PDF, code file, text file, etc.) to the bot. The file is downloaded and saved to a temporary directory, and the agent is told the file path so it can read the contents using its file-reading tools. + +Files work with any model — no multimodal support required. + +### Platform differences + +| Feature | Telegram | WeChat | DingTalk | +| -------- | -------------------------------------------- | -------------------------------- | --------------------------------------------- | +| Images | Direct download via Bot API | CDN download with AES decryption | downloadCode API (two-step) | +| Files | Direct download via Bot API (20MB limit) | CDN download with AES decryption | downloadCode API (two-step) | +| Captions | Photo/file captions included as message text | Not applicable | Rich text: mixed text + images in one message | + +## Dispatch Modes + +Controls what happens when you send a new message while the bot is still processing a previous one. + +- **`steer`** (default) — The bot cancels the current request and starts working on your new message. Best for normal chat, where a follow-up usually means you want to correct or redirect the bot. +- **`collect`** — Your new messages are buffered. When the current request finishes, all buffered messages are combined into a single follow-up prompt. Good for async workflows where you want to queue up thoughts. +- **`followup`** — Each message is queued and processed as its own separate turn, in order. Useful for batch workflows where each message is independent. + +```json +{ + "channels": { + "my-channel": { + "type": "telegram", + "dispatchMode": "steer", + ... + } + } +} +``` + +You can also set dispatch mode per group, overriding the channel default: + +```json +{ + "groups": { + "*": { "requireMention": true, "dispatchMode": "steer" }, + "-100123456": { "dispatchMode": "collect" } + } +} +``` + +## Block Streaming + +By default, the agent works for a while and then sends one large response. With block streaming enabled, the response arrives as multiple shorter messages while the agent is still working — similar to how ChatGPT or Claude show progressive output. + +```json +{ + "channels": { + "my-channel": { + "type": "telegram", + "blockStreaming": "on", + "blockStreamingChunk": { "minChars": 400, "maxChars": 1000 }, + "blockStreamingCoalesce": { "idleMs": 1500 }, + ... + } + } +} +``` + +### How it works + +- The agent's response is split into blocks at paragraph boundaries and sent as separate messages +- `minChars` (default 400) — don't send a block until it's at least this long, to avoid spamming tiny messages +- `maxChars` (default 1000) — if a block gets this long without a natural break, send it anyway +- `idleMs` (default 1500) — if the agent pauses (e.g., running a tool), send what's buffered so far +- When the agent finishes, any remaining text is sent immediately + +Only `blockStreaming` is required. The chunk and coalesce settings are optional and have sensible defaults. + +## Slash Commands + +Channels support slash commands. These are handled locally (no agent round-trip): + +- `/help` — List available commands +- `/clear` — Clear your session and start fresh (aliases: `/reset`, `/new`) +- `/status` — Show session info and access policy + +All other slash commands (e.g., `/compress`, `/summary`) are forwarded to the agent. + +These commands work on all channel types (Telegram, WeChat, DingTalk). + +## Running + +```bash +# Start all configured channels (shared agent process) +qwen channel start + +# Start a single channel +qwen channel start my-channel + +# Check if the service is running +qwen channel status + +# Stop the running service +qwen channel stop +``` + +The bot runs in the foreground. Press `Ctrl+C` to stop, or use `qwen channel stop` from another terminal. + +### Multi-Channel Mode + +When you run `qwen channel start` without a name, all channels defined in `settings.json` start together sharing a single agent process. Each channel maintains its own sessions — a Telegram user and a WeChat user get separate conversations, even though they share the same agent. + +Each channel uses its own `cwd` from its config, so different channels can work on different projects simultaneously. + +### Service Management + +The channel service uses a PID file (`~/.qwen/channels/service.pid`) to track the running instance: + +- **Duplicate prevention**: Running `qwen channel start` while a service is already running will show an error instead of starting a second instance +- **`qwen channel stop`**: Gracefully stops the running service from another terminal +- **`qwen channel status`**: Shows whether the service is running, its uptime, and session counts per channel + +### Crash Recovery + +If the agent process crashes unexpectedly, the channel service automatically restarts it and attempts to restore all active sessions. Users can continue their conversations without starting over. + +- Sessions are persisted to `~/.qwen/channels/sessions.json` while the service is running +- On crash: the agent restarts within 3 seconds and reloads saved sessions +- After 3 consecutive crashes, the service exits with an error +- On clean shutdown (Ctrl+C or `qwen channel stop`): session data is cleared — the next start is always fresh diff --git a/docs/users/features/channels/plugins.md b/docs/users/features/channels/plugins.md new file mode 100644 index 0000000000..0c9108913f --- /dev/null +++ b/docs/users/features/channels/plugins.md @@ -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. diff --git a/docs/users/features/channels/telegram.md b/docs/users/features/channels/telegram.md new file mode 100644 index 0000000000..3e62ebbce8 --- /dev/null +++ b/docs/users/features/channels/telegram.md @@ -0,0 +1,120 @@ +# Telegram + +This guide covers setting up a Qwen Code channel on Telegram. + +## Prerequisites + +- A Telegram account +- A Telegram bot token (see below) + +## Creating a Bot + +1. Open Telegram and search for [@BotFather](https://t.me/BotFather) +2. Send `/newbot` and follow the prompts to choose a name and username +3. BotFather will give you a bot token — save it securely + +## Finding Your User ID + +To use `senderPolicy: "allowlist"` or `"pairing"`, you need your Telegram user ID (a numeric ID, not your username). + +The easiest way to find it: + +1. Search for [@userinfobot](https://t.me/userinfobot) on Telegram +2. Send it any message — it will reply with your user ID + +## Configuration + +Add the channel to `~/.qwen/settings.json`: + +```json +{ + "channels": { + "my-telegram": { + "type": "telegram", + "token": "$TELEGRAM_BOT_TOKEN", + "senderPolicy": "allowlist", + "allowedUsers": ["YOUR_USER_ID"], + "sessionScope": "user", + "cwd": "/path/to/your/project", + "instructions": "You are a concise coding assistant responding via Telegram. Keep responses short.", + "groupPolicy": "disabled", + "groups": { + "*": { "requireMention": true } + } + } + } +} +``` + +Set the bot token as an environment variable: + +```bash +export TELEGRAM_BOT_TOKEN= +``` + +Or add it to a `.env` file that gets sourced before running. + +## Running + +```bash +# Start only the Telegram channel +qwen channel start my-telegram + +# Or start all configured channels together +qwen channel start +``` + +Then open your bot in Telegram and send a message. You should see "Working..." appear immediately, followed by the agent's response. + +## Group Chats + +To use the bot in Telegram groups: + +1. Set `groupPolicy` to `"allowlist"` or `"open"` in your channel config +2. **Disable privacy mode** in BotFather: `/mybots` → select your bot → Bot Settings → Group Privacy → Turn Off +3. Add the bot to a group. If it was already in the group, **remove and re-add it** (Telegram caches privacy settings from when the bot joined) +4. If using `groupPolicy: "allowlist"`, add the group's chat ID to `groups` in your config + +By default, the bot requires an @mention or a reply to respond in groups. Set `"requireMention": false` for a specific group to make it respond to all messages (useful for dedicated task groups). See [Group Chats](./overview#group-chats) for full details. + +## Images and Files + +You can send photos and documents to the bot, not just text. + +**Photos:** Send a photo and the agent will analyze it using its vision capabilities. This requires a multimodal model — add `"model": "qwen3.5-plus"` (or another vision-capable model) to your channel config. Photo captions are passed as the message text. + +**Documents:** Send a PDF, code file, or any document. The bot downloads it and saves it locally so the agent can read it with its file tools. This works with any model. Telegram's file size limit is 20MB. + +## Tips + +- **Keep instructions concise-focused** — Telegram has a 4096-character message limit. Adding instructions like "keep responses short" helps the agent stay within bounds. +- **Use `sessionScope: "user"`** — This gives each user their own conversation. Use `/clear` to start fresh. +- **Restrict access** — Use `senderPolicy: "allowlist"` for a fixed set of users, or `"pairing"` to let new users request access with a code you approve via CLI. See [DM Pairing](./overview#dm-pairing) for details. + +## Message Formatting + +The agent's markdown responses are automatically converted to Telegram-compatible HTML. Code blocks, bold, italic, links, and lists are all supported. + +## Troubleshooting + +### Bot doesn't respond + +- Check that the bot token is correct and the environment variable is set +- Verify your user ID is in `allowedUsers` if using `senderPolicy: "allowlist"`, or that you've been approved if using `"pairing"` +- Check the terminal output for errors + +### Bot doesn't respond in groups + +- Check that `groupPolicy` is set to `"allowlist"` or `"open"` (default is `"disabled"`) +- If using `"allowlist"`, verify the group's chat ID is in the `groups` config +- Make sure **Group Privacy is turned off** in BotFather — without this, the bot can't see non-command messages in groups +- If you changed privacy mode after adding the bot to a group, **remove and re-add the bot** to the group +- By default, the bot requires an @mention or a reply. Send `@yourbotname hello` to test + +### "Sorry, something went wrong processing your message" + +This usually means the agent encountered an error. Check the terminal output for details. + +### Bot takes a long time to respond + +The agent may be running multiple tool calls (reading files, searching, etc.). The "Working..." indicator shows while the agent is processing. Complex tasks can take a minute or more. diff --git a/docs/users/features/channels/weixin.md b/docs/users/features/channels/weixin.md new file mode 100644 index 0000000000..5bcc18a133 --- /dev/null +++ b/docs/users/features/channels/weixin.md @@ -0,0 +1,106 @@ +# WeChat (Weixin) + +This guide covers setting up a Qwen Code channel on WeChat via the official iLink Bot API. + +## Prerequisites + +- A WeChat account that can scan QR codes (mobile app) +- Access to the iLink Bot platform (WeChat's official bot API) + +## Setup + +### 1. Log in via QR code + +WeChat uses QR code authentication instead of a static bot token. Run the login command: + +```bash +qwen channel configure-weixin +``` + +This will display a QR code URL. Scan it with your WeChat mobile app to authenticate. Your credentials are saved to `~/.qwen/channels/weixin/account.json`. + +### 2. Configure the channel + +Add the channel to `~/.qwen/settings.json`: + +```json +{ + "channels": { + "my-weixin": { + "type": "weixin", + "senderPolicy": "pairing", + "allowedUsers": [], + "sessionScope": "user", + "cwd": "/path/to/your/project", + "model": "qwen3.5-plus", + "instructions": "You are a concise coding assistant responding via WeChat. Keep responses under 500 characters. Use plain text only." + } + } +} +``` + +Note: WeChat channels do not use a `token` field — credentials come from the QR login step. + +### 3. Start the channel + +```bash +# Start only the WeChat channel +qwen channel start my-weixin + +# Or start all configured channels together +qwen channel start +``` + +Open WeChat and send a message to the bot. You should see a typing indicator ("...") while the agent processes, followed by the response. + +## Images and Files + +You can send photos and documents to the bot, not just text. + +**Photos:** Send an image (screenshot, photo, etc.) and the agent will analyze it using its vision capabilities. This requires a multimodal model — add `"model": "qwen3.5-plus"` (or another vision-capable model) to your channel config. A typing indicator shows while the image is being downloaded and processed. + +**Files:** Send a PDF, code file, or any document. The bot downloads and decrypts it from WeChat's CDN, saves it locally, and the agent reads it with its file tools. This works with any model. + +## Configuration Options + +WeChat channels support all the standard channel options (see [Channel Overview](./overview#options)), plus: + +| Option | Description | +| --------- | ------------------------------------------------------------------------------ | +| `baseUrl` | Override the iLink Bot API base URL (default: `https://ilinkai.weixin.qq.com`) | + +## Key Differences from Telegram + +- **Authentication:** QR code login instead of a static bot token. Sessions can expire — the channel will pause and log a message if this happens. +- **Formatting:** WeChat only supports plain text. Markdown in agent responses is automatically stripped. +- **Typing indicator:** WeChat has a native "..." typing indicator instead of a "Working..." text message. +- **Groups:** WeChat iLink Bot is DM-only — group chats are not supported. +- **Media encryption:** Images and files are encrypted on WeChat's CDN with AES-128-ECB. The channel handles decryption transparently. + +## Tips + +- **Use plain text instructions** — Since WeChat strips all markdown, add instructions like "Use plain text only" to avoid the agent producing formatted responses that look messy. +- **Keep responses short** — WeChat message bubbles work best with concise text. Adding a character limit to your instructions helps (e.g., "Keep responses under 500 characters"). +- **Session expiry** — If you see "Session expired (errcode -14)" in the logs, your WeChat login has expired. Stop the channel and re-run `qwen channel configure-weixin` to log in again. +- **Restrict access** — Use `senderPolicy: "pairing"` or `"allowlist"` to control who can talk to the bot. See [DM Pairing](./overview#dm-pairing) for details. + +## Troubleshooting + +### "WeChat account not configured" + +Run `qwen channel configure-weixin` to log in via QR code first. + +### "Session expired (errcode -14)" + +Your WeChat login session has expired. Stop the channel and run `qwen channel configure-weixin` again. + +### Bot doesn't respond + +- Check the terminal output for errors +- Verify the channel is running (`qwen channel start my-weixin`) +- If using `senderPolicy: "allowlist"`, make sure your WeChat user ID is in `allowedUsers` + +### Images not working + +- Make sure your channel config has a `model` that supports vision (e.g., `qwen3.5-plus`) +- Check the terminal for CDN download errors — these may indicate a network issue diff --git a/esbuild.config.js b/esbuild.config.js index 2b532b44ef..c49eba358f 100644 --- a/esbuild.config.js +++ b/esbuild.config.js @@ -62,6 +62,10 @@ esbuild __dirname, 'packages/cli/src/patches/is-in-ci.ts', ), + '@qwen-code/web-templates': path.resolve( + __dirname, + 'packages/web-templates/src/index.ts', + ), }, define: { 'process.env.CLI_VERSION': JSON.stringify(pkg.version), diff --git a/eslint.config.js b/eslint.config.js index 7b54f58a83..c52b6b5c57 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -64,7 +64,7 @@ export default tseslint.config( }, { // General overrides and rules for the project (TS/TSX files) - files: ['packages/*/src/**/*.{ts,tsx}'], // Target only TS/TSX in the cli package + files: ['packages/**/src/**/*.{ts,tsx}'], // Target TS/TSX in all packages (including nested) plugins: { import: importPlugin, }, diff --git a/integration-tests/channel-plugin.test.ts b/integration-tests/channel-plugin.test.ts new file mode 100644 index 0000000000..10478c7864 --- /dev/null +++ b/integration-tests/channel-plugin.test.ts @@ -0,0 +1,174 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Channel Plugin Integration Test — Real E2E with WebSocket + * + * 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?") + * → WebSocket push to MockPluginChannel + * → ChannelBase.handleInbound(envelope) + * → SenderGate (open policy) + * → SessionRouter (creates/reuses session) + * → AcpBridge.prompt(sessionId, text) + * → qwen-code --acp (REAL model request) + * → MockPluginChannel.sendMessage(chatId, response) + * → WebSocket response to mock server + * → server resolves promise with agent text + * + * This exercises the real WebSocket protocol, real message serialization, + * real ChannelPlugin interface, and real model backend — all in one test process. + */ + +import { describe, it, expect, afterAll } from 'vitest'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mkdirSync } from 'node:fs'; + +// Import from the monorepo channel packages +import { + AcpBridge, + SessionRouter, +} from '../packages/channels/base/dist/index.js'; +import type { ChannelConfig } from '../packages/channels/base/dist/index.js'; +import { + MockPluginChannel, + createMockServer, +} 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'); +const RESPONSE_TIMEOUT_MS = 120_000; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Channel Plugin (Mock WebSocket E2E)', () => { + let bridge: InstanceType; + let channel: MockPluginChannel; + let server: MockServerHandle; + let testDir: string; + + const setup = async () => { + const baseDir = + process.env['INTEGRATION_TEST_FILE_DIR'] || + join(__dirname, '..', '.integration-tests', `channel-${Date.now()}`); + testDir = join(baseDir, 'channel-plugin-example-e2e'); + mkdirSync(testDir, { recursive: true }); + + // 1. Start mock server on random ports (no port conflicts) + server = await createMockServer({ httpPort: 0, wsPort: 0 }); + + // 2. Start AcpBridge (spawns real qwen-code --acp) + bridge = new AcpBridge({ + cliEntryPath: CLI_PATH, + cwd: testDir, + }); + await bridge.start(); + + // 3. Create and connect MockPluginChannel via WebSocket + const config: ChannelConfig & Record = { + type: 'plugin-example', + token: '', + senderPolicy: 'open', + allowedUsers: [], + sessionScope: 'user', + cwd: testDir, + groupPolicy: 'disabled', + groups: {}, + serverWsUrl: server.wsUrl, + }; + + const router = new SessionRouter(bridge, testDir, 'user'); + channel = new MockPluginChannel('test-mock', config, bridge, { router }); + await channel.connect(); + + // 4. Wait for the channel's WebSocket to be registered by the server + await server.waitForConnection(5_000); + }; + + afterAll(async () => { + try { + channel?.disconnect(); + } catch { + // ignore + } + try { + bridge?.stop(); + } catch { + // ignore + } + try { + await server?.close(); + } catch { + // ignore + } + }); + + it( + 'should send a message through WebSocket and receive a real agent response', + async () => { + await setup(); + + // This goes: server → WS → MockPluginChannel → ChannelBase → AcpBridge → agent → back + const response = await server.sendMessage( + 'What is 2+2? Reply with ONLY the number, nothing else.', + ); + + expect(response).toBeTruthy(); + expect(response).toContain('4'); + console.log(`[mock-e2e] Single turn response: "${response}"`); + }, + RESPONSE_TIMEOUT_MS, + ); + + it( + 'should maintain session state across multiple WebSocket messages', + async () => { + const chatId = 'ws-session-test'; + const opts = { chatId }; + + const r1 = await server.sendMessage( + 'My secret word is "pineapple". Remember it.', + opts, + ); + expect(r1).toBeTruthy(); + console.log(`[mock-e2e] Memory set response: "${r1}"`); + + const r2 = await server.sendMessage( + 'What is my secret word? Reply with ONLY the word, nothing else.', + opts, + ); + expect(r2).toBeTruthy(); + expect(r2.toLowerCase()).toContain('pineapple'); + console.log(`[mock-e2e] Memory recall response: "${r2}"`); + }, + RESPONSE_TIMEOUT_MS * 2, + ); + + it( + 'should handle a different sender through the same WebSocket pipeline', + async () => { + const response = await server.sendMessage( + 'What is 10 * 5? Reply with ONLY the number, nothing else.', + { + senderId: 'another-user', + senderName: 'Another User', + chatId: 'dm-another-user', + }, + ); + + expect(response).toBeTruthy(); + expect(response).toContain('50'); + console.log(`[mock-e2e] Different sender response: "${response}"`); + }, + RESPONSE_TIMEOUT_MS, + ); +}); diff --git a/package-lock.json b/package-lock.json index bfa901db34..30de664760 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,12 @@ "name": "@qwen-code/qwen-code", "version": "0.13.2", "workspaces": [ - "packages/*" + "packages/*", + "packages/channels/base", + "packages/channels/telegram", + "packages/channels/weixin", + "packages/channels/dingtalk", + "packages/channels/plugin-example" ], "dependencies": { "@testing-library/dom": "^10.4.1", @@ -1540,6 +1545,12 @@ "resolved": "packages/test-utils", "link": true }, + "node_modules/@grammyjs/types": { + "version": "3.25.0", + "resolved": "https://registry.npmjs.org/@grammyjs/types/-/types-3.25.0.tgz", + "integrity": "sha512-iN9i5p+8ZOu9OMxWNcguojQfz4K/PDyMPOnL7PPCON+SoA/F8OKMH3uR7CVUkYfdNe0GCz8QOzAWrnqusQYFOg==", + "license": "MIT" + }, "node_modules/@grpc/grpc-js": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", @@ -2990,6 +3001,26 @@ "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", "license": "BSD-3-Clause" }, + "node_modules/@qwen-code/channel-base": { + "resolved": "packages/channels/base", + "link": true + }, + "node_modules/@qwen-code/channel-dingtalk": { + "resolved": "packages/channels/dingtalk", + "link": true + }, + "node_modules/@qwen-code/channel-plugin-example": { + "resolved": "packages/channels/plugin-example", + "link": true + }, + "node_modules/@qwen-code/channel-telegram": { + "resolved": "packages/channels/telegram", + "link": true + }, + "node_modules/@qwen-code/channel-weixin": { + "resolved": "packages/channels/weixin", + "link": true + }, "node_modules/@qwen-code/qwen-code": { "resolved": "packages/cli", "link": true @@ -6421,6 +6452,17 @@ "node": ">=4" } }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/azure-devops-node-api": { "version": "12.5.0", "resolved": "https://registry.npmjs.org/azure-devops-node-api/-/azure-devops-node-api-12.5.0.tgz", @@ -8059,6 +8101,17 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/dingtalk-stream-sdk-nodejs": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/dingtalk-stream-sdk-nodejs/-/dingtalk-stream-sdk-nodejs-2.0.4.tgz", + "integrity": "sha512-aVHQ72zAZ6upfuwQXhLvorDZY47uyOp8cvMFVrvLOws8tVCiM1YwFcKvcPthOt9c2gaGdv3BXHtnLeLeWFAv8Q==", + "license": "MIT", + "dependencies": { + "axios": "^1.4.0", + "debug": "^4.3.4", + "ws": "^8.13.0" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -9734,6 +9787,26 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -9767,9 +9840,9 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -10323,6 +10396,21 @@ "node": ">=10" } }, + "node_modules/grammy": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/grammy/-/grammy-1.41.1.tgz", + "integrity": "sha512-wcHAQ1e7svL3fJMpDchcQVcWUmywhuepOOjHUHmMmWAwUJEIyK5ea5sbSjZd+Gy1aMpZeP8VYJa+4tP+j1YptQ==", + "license": "MIT", + "dependencies": { + "@grammyjs/types": "3.25.0", + "abort-controller": "^3.0.0", + "debug": "^4.4.3", + "node-fetch": "^2.7.0" + }, + "engines": { + "node": "^12.20.0 || >=14.13.1" + } + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", @@ -14646,6 +14734,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -16933,6 +17027,18 @@ "node": ">=6" } }, + "node_modules/telegram-markdown-formatter": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/telegram-markdown-formatter/-/telegram-markdown-formatter-0.1.2.tgz", + "integrity": "sha512-GGkgawMLBhaO2epjx7YSncpCzoXciuB+zlmI1od7EqSCufWFls0qBKWZfjnON6RIENp1dQFsaoQdbP3tOCsJ5g==", + "license": "MIT", + "bin": { + "tg-md": "dist/cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/terminal-link": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-4.0.0.tgz", @@ -18798,6 +18904,63 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "packages/channels/base": { + "name": "@qwen-code/channel-base", + "version": "0.13.0", + "dependencies": { + "@agentclientprotocol/sdk": "^0.14.1" + }, + "devDependencies": { + "typescript": "^5.0.0" + } + }, + "packages/channels/dingtalk": { + "name": "@qwen-code/channel-dingtalk", + "version": "0.13.0", + "dependencies": { + "@qwen-code/channel-base": "file:../base", + "dingtalk-stream-sdk-nodejs": "^2.0.4" + }, + "devDependencies": { + "typescript": "^5.0.0" + } + }, + "packages/channels/plugin-example": { + "name": "@qwen-code/channel-plugin-example", + "version": "0.13.0", + "dependencies": { + "@qwen-code/channel-base": "file:../base", + "ws": "^8.18.0" + }, + "bin": { + "qwen-channel-plugin-example-server": "dist/start-server.js" + }, + "devDependencies": { + "@types/ws": "^8.5.0" + } + }, + "packages/channels/telegram": { + "name": "@qwen-code/channel-telegram", + "version": "0.13.0", + "dependencies": { + "@qwen-code/channel-base": "file:../base", + "grammy": "^1.41.1", + "telegram-markdown-formatter": "^0.1.2" + }, + "devDependencies": { + "typescript": "^5.0.0" + } + }, + "packages/channels/weixin": { + "name": "@qwen-code/channel-weixin", + "version": "0.13.0", + "dependencies": { + "@qwen-code/channel-base": "file:../base" + }, + "devDependencies": { + "typescript": "^5.0.0" + } + }, "packages/cli": { "name": "@qwen-code/qwen-code", "version": "0.13.2", @@ -18806,6 +18969,10 @@ "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.25.1", + "@qwen-code/channel-base": "file:../channels/base", + "@qwen-code/channel-dingtalk": "file:../channels/dingtalk", + "@qwen-code/channel-telegram": "file:../channels/telegram", + "@qwen-code/channel-weixin": "file:../channels/weixin", "@qwen-code/qwen-code-core": "file:../core", "@qwen-code/web-templates": "file:../web-templates", "@types/update-notifier": "^6.0.8", diff --git a/package.json b/package.json index 85576afdf3..e5c49cae2d 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,12 @@ }, "type": "module", "workspaces": [ - "packages/*" + "packages/*", + "packages/channels/base", + "packages/channels/telegram", + "packages/channels/weixin", + "packages/channels/dingtalk", + "packages/channels/plugin-example" ], "repository": { "type": "git", diff --git a/packages/channels/base/README.md b/packages/channels/base/README.md new file mode 100644 index 0000000000..ef89ca1f46 --- /dev/null +++ b/packages/channels/base/README.md @@ -0,0 +1,295 @@ +# @qwen-code/channel-base + +Base infrastructure for building Qwen Code channel adapters. Provides the abstract base class, access control, session routing, and the ACP bridge that communicates with the agent. + +If you're building a channel plugin, this is your only dependency. + +## Install + +```bash +npm install @qwen-code/channel-base +``` + +## Quick start + +Subclass `ChannelBase` and implement three methods: + +```typescript +import { ChannelBase } from '@qwen-code/channel-base'; +import type { + ChannelConfig, + Envelope, + AcpBridge, +} from '@qwen-code/channel-base'; + +class MyChannel extends ChannelBase { + async connect(): Promise { + // Connect to platform API, register message handlers. + // When a message arrives, build an Envelope and call: + // this.handleInbound(envelope) + } + + async sendMessage(chatId: string, text: string): Promise { + // Deliver the agent's response to the platform. + } + + disconnect(): void { + // Clean up connections on shutdown. + } +} +``` + +Export a `ChannelPlugin` object so the extension loader can discover it: + +```typescript +import type { ChannelPlugin } from '@qwen-code/channel-base'; + +export const plugin: ChannelPlugin = { + channelType: 'my-platform', + displayName: 'My Platform', + requiredConfigFields: ['apiKey'], + createChannel: (name, config, bridge, options) => + new MyChannel(name, config, bridge, options), +}; +``` + +For a complete working example, see [`@qwen-code/channel-plugin-example`](../plugin-example/). + +## Architecture + +``` +Inbound: Platform message + → Envelope (with attachments) + → GroupGate (group policy + mention gating) + → SenderGate (allowlist / pairing / open) + → Slash commands (/clear, /help, /status) + → SessionRouter (resolve or create ACP session) + → Resolve attachments (images → bridge, files → prompt text) + → AcpBridge.prompt() → agent + +Outbound: Agent response + → BlockStreamer (if enabled: split into blocks at paragraph boundaries) + → sendMessage() → platform +``` + +Everything between `handleInbound()` and `sendMessage()` is handled by the base class — your adapter only deals with platform I/O. + +## Exports + +### Classes + +| Class | Purpose | +| --------------- | ---------------------------------------------------------------- | +| `ChannelBase` | Abstract base class — extend this to build a channel adapter | +| `AcpBridge` | Spawns and communicates with the `qwen-code --acp` agent process | +| `BlockStreamer` | Progressive multi-message delivery for block streaming | +| `SessionRouter` | Maps senders to ACP sessions with configurable scoping | +| `SenderGate` | DM access control (allowlist / pairing / open) | +| `GroupGate` | Group chat policy and @mention gating | +| `PairingStore` | Pairing code generation, approval, and allowlist persistence | + +### Types + +| Type | Description | +| --------------- | ---------------------------------------------- | +| `Attachment` | Structured file/image/audio/video attachment | +| `ChannelConfig` | Channel configuration from `settings.json` | +| `ChannelPlugin` | Plugin factory interface (what you export) | +| `Envelope` | Normalized inbound message format | +| `SenderPolicy` | `'allowlist' \| 'pairing' \| 'open'` | +| `GroupPolicy` | `'disabled' \| 'allowlist' \| 'open'` | +| `SessionScope` | `'user' \| 'thread' \| 'single'` | +| `GroupConfig` | Per-group settings (e.g. `requireMention`) | +| `SessionTarget` | Maps a session back to its channel/sender/chat | + +## API reference + +### ChannelBase + +```typescript +constructor(name: string, config: ChannelConfig, bridge: AcpBridge, options?: ChannelBaseOptions) +``` + +**Abstract methods** (you must implement): + +| Method | Signature | +| --------------- | ---------------------------------------------------------------------------- | +| `connect()` | `() => Promise` — Connect to the platform and start receiving messages | +| `sendMessage()` | `(chatId: string, text: string) => Promise` — Deliver agent response | +| `disconnect()` | `() => void` — Clean up on shutdown | + +**Provided methods:** + +| Method | Description | +| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | +| `handleInbound(envelope)` | Route an inbound message through the full pipeline (gate checks, commands, session, prompt). Call this from your message handler. | +| `setBridge(bridge)` | Replace the ACP bridge after crash recovery | +| `registerCommand(name, handler)` | Register a custom slash command (e.g. `/mycommand`) | +| `onToolCall(chatId, event)` | Hook called on agent tool invocations — override to show indicators | +| `onResponseChunk(chatId, chunk, sessionId)` | Hook called per streaming text chunk — override for progressive display (default: no-op) | +| `onResponseComplete(chatId, fullText, sessionId)` | Hook called when full response is ready — override to customize delivery (default: `sendMessage()`) | + +**Block streaming:** When `blockStreaming: "on"` is set in the channel config, the base class automatically splits the agent's streaming response into multiple messages at paragraph boundaries. See [Block Streaming](#block-streaming) below. + +**Built-in slash commands:** `/clear` (`/reset`, `/new`), `/help`, `/status` + +### AcpBridge + +Manages the `qwen-code --acp` child process and ACP sessions. + +```typescript +constructor(options: { cliEntryPath: string; cwd: string; model?: string }) +``` + +| Method | Description | +| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `start()` | Spawn the agent process | +| `stop()` | Kill the agent process | +| `newSession(cwd)` | Create a new ACP session, returns `sessionId` | +| `loadSession(sessionId, cwd)` | Restore an existing session | +| `prompt(sessionId, text, options?)` | Send a message to the agent, returns the full response text. Supports optional `imageBase64` and `imageMimeType`. | +| `isConnected` | Whether the agent process is alive | + +**Events** (EventEmitter): + +| Event | Payload | Description | +| -------------- | ------------------------ | ------------------------ | +| `textChunk` | `(sessionId, chunk)` | Streaming response chunk | +| `toolCall` | `(event: ToolCallEvent)` | Agent invoked a tool | +| `disconnected` | `(code, signal)` | Agent process exited | + +### SessionRouter + +Maps senders to ACP sessions based on the configured scope. + +```typescript +constructor(bridge: AcpBridge, defaultCwd: string, scope?: SessionScope, persistPath?: string) +``` + +**Routing keys by scope:** + +| Scope | Key format | Effect | +| ---------------- | ------------------------- | ----------------------------------------- | +| `user` (default) | `channel:senderId:chatId` | Each user gets their own session per chat | +| `thread` | `channel:threadId` | One session per thread | +| `single` | `channel:__single__` | One shared session for the entire channel | + +| Method | Description | +| --------------------------------------------------------- | ----------------------------------------------------------- | +| `resolve(channelName, senderId, chatId, threadId?, cwd?)` | Get or create a session for the given sender | +| `removeSession(channelName, senderId, chatId?)` | Remove session(s) — used by `/clear` | +| `restoreSessions()` | Reload sessions from disk after bridge restart | +| `clearAll()` | Clear all sessions and delete persist file (clean shutdown) | + +### SenderGate + +```typescript +constructor(policy: SenderPolicy, allowedUsers?: string[], pairingStore?: PairingStore) +``` + +| Method | Description | +| ------------------------------ | ------------------------------------------------------------ | +| `check(senderId, senderName?)` | Returns `{ allowed: boolean, pairingCode?: string \| null }` | + +**Policy behavior:** + +| Policy | Behavior | +| ----------- | --------------------------------------------------------------------------------------------------------- | +| `open` | Everyone allowed | +| `allowlist` | Only `allowedUsers` allowed | +| `pairing` | Check allowlist, then approved pairings, then generate a pairing code (8-char, 1hr expiry, max 3 pending) | + +### GroupGate + +```typescript +constructor(policy?: GroupPolicy, groups?: Record) +``` + +| Method | Description | +| ----------------- | ---------------------------------------------------------------------------------------------- | +| `check(envelope)` | Returns `{ allowed: boolean, reason?: 'disabled' \| 'not_allowlisted' \| 'mention_required' }` | + +**Policy behavior:** + +| Policy | Behavior | +| ----------- | ---------------------------------------- | +| `disabled` | All group messages rejected | +| `allowlist` | Only groups listed in config are allowed | +| `open` | All groups allowed | + +When `requireMention` is `true` (default), group messages are only processed if the bot is @mentioned or the message is a reply to the bot. + +### PairingStore + +```typescript +constructor(channelName: string) +``` + +Persists pairing state to `~/.qwen/channels/{channelName}-pairing.json` and `{channelName}-allowlist.json`. + +| Method | Description | +| ------------------------------------- | --------------------------------------------------------------------------------------------------------- | +| `createRequest(senderId, senderName)` | Generate an 8-char pairing code (or return existing). Returns `null` if 3 pending requests already exist. | +| `approve(code)` | Approve a pairing request, adds sender to allowlist. Returns the request or `null`. | +| `isApproved(senderId)` | Check if sender is in the approved allowlist | +| `listPending()` | Get active (non-expired) pending requests | + +## Envelope + +The normalized message format your adapter must construct: + +```typescript +interface Envelope { + channelName: string; // your channel instance name + senderId: string; // stable, unique sender ID + senderName: string; // display name + chatId: string; // distinguishes DMs from groups + text: string; // message text (@mentions stripped) + messageId?: string; // platform message ID + threadId?: string; // for thread-scoped sessions + isGroup: boolean; // true for group chats + isMentioned: boolean; // true if bot was @mentioned + isReplyToBot: boolean; // true if replying to bot's message + referencedText?: string; // quoted message text + imageBase64?: string; // base64-encoded image (legacy — prefer attachments) + imageMimeType?: string; // e.g. 'image/jpeg' (legacy — prefer attachments) + attachments?: Attachment[]; // structured file/image/audio/video attachments +} + +interface Attachment { + type: 'image' | 'file' | 'audio' | 'video'; + data?: string; // base64-encoded data (images, small files) + filePath?: string; // absolute path to local file (large files) + mimeType: string; // e.g. 'application/pdf', 'image/jpeg' + fileName?: string; // original file name from the platform +} +``` + +`handleInbound()` automatically resolves attachments: images with `data` are sent to the model as vision input, files with `filePath` get their path appended to the prompt text so the agent can read them with its tools. + +## Block Streaming + +When `blockStreaming: "on"` is set in a channel's config, the agent's response is delivered as multiple separate messages instead of one large wall of text. The `BlockStreamer` accumulates streaming chunks and emits completed blocks based on paragraph boundaries and size heuristics. + +**Config fields** (on `ChannelConfig`): + +| Field | Type | Default | Description | +| ------------------------ | ------------------------ | --------------- | --------------------------------------------------------------------------- | +| `blockStreaming` | `'on' \| 'off'` | `'off'` | Enable/disable block streaming | +| `blockStreamingChunk` | `{ minChars, maxChars }` | `{ 400, 1000 }` | `minChars`: don't emit until this size. `maxChars`: force-emit at this size | +| `blockStreamingCoalesce` | `{ idleMs }` | `{ 1500 }` | Emit buffered text after this many ms of silence from the agent | + +**How it works:** + +1. Text accumulates as the agent streams its response +2. When the buffer reaches `minChars` and hits a paragraph break (`\n\n`), that block is sent as a separate message +3. If the buffer reaches `maxChars` without a paragraph break, it force-splits at the best break point (newline > space) +4. If the agent goes quiet for `idleMs`, the buffer is flushed (as long as it's past `minChars`) +5. When the agent finishes, any remaining text is sent immediately regardless of `minChars` + +Block streaming and `onResponseChunk` work independently — plugins can override `onResponseChunk` for their own purposes while block streaming handles delivery. + +## Further reading + +- [Channel Plugin Developer Guide](../../docs/developers/channel-plugins.md) +- [`@qwen-code/channel-plugin-example`](../plugin-example/) — working reference implementation diff --git a/packages/channels/base/package.json b/packages/channels/base/package.json new file mode 100644 index 0000000000..6d66675dea --- /dev/null +++ b/packages/channels/base/package.json @@ -0,0 +1,26 @@ +{ + "name": "@qwen-code/channel-base", + "version": "0.13.0", + "description": "Base channel infrastructure for Qwen Code", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc --build" + }, + "dependencies": { + "@agentclientprotocol/sdk": "^0.14.1" + }, + "devDependencies": { + "typescript": "^5.0.0" + } +} diff --git a/packages/channels/base/src/AcpBridge.ts b/packages/channels/base/src/AcpBridge.ts new file mode 100644 index 0000000000..346a1617af --- /dev/null +++ b/packages/channels/base/src/AcpBridge.ts @@ -0,0 +1,249 @@ +import { spawn } from 'node:child_process'; +import type { ChildProcess } from 'node:child_process'; +import { Readable, Writable } from 'node:stream'; +import { EventEmitter } from 'node:events'; +import { + ClientSideConnection, + ndJsonStream, + PROTOCOL_VERSION, +} from '@agentclientprotocol/sdk'; +import type { + Client, + SessionNotification, + RequestPermissionRequest, + RequestPermissionResponse, +} from '@agentclientprotocol/sdk'; + +export interface AcpBridgeOptions { + cliEntryPath: string; + cwd: string; + model?: string; +} + +export interface AvailableCommand { + name: string; + description: string; +} + +export interface ToolCallEvent { + sessionId: string; + toolCallId: string; + kind: string; + title: string; + status: string; + rawInput?: Record; +} + +export class AcpBridge extends EventEmitter { + private child: ChildProcess | null = null; + private connection: ClientSideConnection | null = null; + private options: AcpBridgeOptions; + private _availableCommands: AvailableCommand[] = []; + + constructor(options: AcpBridgeOptions) { + super(); + this.options = options; + } + + get availableCommands(): AvailableCommand[] { + return this._availableCommands; + } + + async start(): Promise { + const { cliEntryPath, cwd } = this.options; + + const args = [cliEntryPath, '--acp']; + if (this.options.model) { + args.push('--model', this.options.model); + } + + this.child = spawn(process.execPath, args, { + cwd, + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env }, + shell: false, + }); + + this.child.stderr?.on('data', (data: Buffer) => { + const msg = data.toString().trim(); + if (msg) { + process.stderr.write(`[AcpBridge] ${msg}\n`); + } + }); + + this.child.on('exit', (code, signal) => { + process.stderr.write( + `[AcpBridge] Process exited (code=${code}, signal=${signal})\n`, + ); + this.connection = null; + this.child = null; + this.emit('disconnected', code, signal); + }); + + // Give the process a moment to start + await new Promise((resolve) => setTimeout(resolve, 1000)); + + if (!this.child || this.child.killed) { + throw new Error('ACP process failed to start'); + } + + const stdout = Readable.toWeb( + this.child.stdout!, + ) as ReadableStream; + const stdin = Writable.toWeb(this.child.stdin!) as WritableStream; + const stream = ndJsonStream(stdin, stdout); + + this.connection = new ClientSideConnection( + (): Client => ({ + sessionUpdate: (params: SessionNotification): Promise => { + this.handleSessionUpdate(params); + return Promise.resolve(); + }, + + requestPermission: async ( + params: RequestPermissionRequest, + ): Promise => { + // Auto-approve for now; Phase 5 will add interactive approval + const options = Array.isArray(params.options) ? params.options : []; + const optionId = + options.find((o) => o.optionId === 'proceed_once')?.optionId || + options[0]?.optionId || + 'proceed_once'; + return { outcome: { outcome: 'selected', optionId } }; + }, + + extNotification: async (): Promise => {}, + }), + stream, + ); + + await this.connection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + }); + } + + async newSession(cwd: string): Promise { + const conn = this.ensureConnection(); + const response = await conn.newSession({ cwd, mcpServers: [] }); + return response.sessionId; + } + + async loadSession(sessionId: string, cwd: string): Promise { + const conn = this.ensureConnection(); + const response = await conn.loadSession({ + sessionId, + cwd, + mcpServers: [], + }); + return response.sessionId; + } + + async prompt( + sessionId: string, + text: string, + options?: { imageBase64?: string; imageMimeType?: string }, + ): Promise { + const conn = this.ensureConnection(); + + const chunks: string[] = []; + const onChunk = (sid: string, chunk: string) => { + if (sid === sessionId) chunks.push(chunk); + }; + this.on('textChunk', onChunk); + + const prompt: Array> = []; + if (options?.imageBase64 && options.imageMimeType) { + prompt.push({ + type: 'image', + data: options.imageBase64, + mimeType: options.imageMimeType, + }); + } + prompt.push({ type: 'text', text }); + + try { + await conn.prompt({ + sessionId, + prompt: prompt as Array<{ type: 'text'; text: string }>, + }); + } finally { + this.off('textChunk', onChunk); + } + + return chunks.join(''); + } + + async cancelSession(sessionId: string): Promise { + const conn = this.ensureConnection(); + await conn.cancel({ sessionId }); + } + + stop(): void { + if (this.child) { + this.child.kill(); + this.child = null; + } + this.connection = null; + } + + get isConnected(): boolean { + return ( + this.child !== null && !this.child.killed && this.child.exitCode === null + ); + } + + private handleSessionUpdate(params: SessionNotification): void { + const { sessionId } = params; + const update = (params as unknown as Record)['update'] as + | Record + | undefined; + if (!update) return; + + const type = update['sessionUpdate'] as string; + + switch (type) { + case 'agent_message_chunk': { + const content = update['content'] as + | { type?: string; text?: string } + | undefined; + if (content?.type === 'text' && content.text) { + this.emit('textChunk', sessionId, content.text); + } + break; + } + case 'tool_call': { + const event: ToolCallEvent = { + sessionId, + toolCallId: update['toolCallId'] as string, + kind: (update['kind'] as string) || '', + title: (update['title'] as string) || '', + status: (update['status'] as string) || 'pending', + rawInput: update['rawInput'] as Record | undefined, + }; + this.emit('toolCall', event); + break; + } + case 'available_commands_update': { + if (Array.isArray(update['availableCommands'])) { + this._availableCommands = update[ + 'availableCommands' + ] as AvailableCommand[]; + } + break; + } + default: + // Ignore other session update types + break; + } + + this.emit('sessionUpdate', params); + } + + private ensureConnection(): ClientSideConnection { + if (!this.connection || !this.isConnected) { + throw new Error('Not connected to ACP agent'); + } + return this.connection; + } +} diff --git a/packages/channels/base/src/BlockStreamer.test.ts b/packages/channels/base/src/BlockStreamer.test.ts new file mode 100644 index 0000000000..598a9b2df1 --- /dev/null +++ b/packages/channels/base/src/BlockStreamer.test.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { BlockStreamer } from './BlockStreamer.js'; + +describe('BlockStreamer', () => { + let sent: string[]; + let send: (text: string) => Promise; + + beforeEach(() => { + vi.useFakeTimers(); + sent = []; + send = async (text: string) => { + sent.push(text); + }; + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + function createStreamer( + overrides: Partial<{ + minChars: number; + maxChars: number; + idleMs: number; + }> = {}, + ) { + return new BlockStreamer({ + minChars: overrides.minChars ?? 20, + maxChars: overrides.maxChars ?? 60, + idleMs: overrides.idleMs ?? 500, + send, + }); + } + + it('does not emit below minChars', () => { + const s = createStreamer(); + s.push('short'); + expect(sent).toEqual([]); + expect(s.blockCount).toBe(0); + }); + + it('emits at paragraph boundary when buffer >= minChars', async () => { + const s = createStreamer({ minChars: 10 }); + s.push('Hello world, this is a paragraph.\n\nSecond part'); + // Should have emitted the first paragraph + await s.flush(); + expect(sent).toEqual(['Hello world, this is a paragraph.', 'Second part']); + expect(s.blockCount).toBe(2); + }); + + it('does not split at paragraph boundary when text before it < minChars', async () => { + const s = createStreamer({ minChars: 100 }); + s.push('Short.\n\nAlso short.'); + // Neither section exceeds minChars, and total < maxChars + expect(sent).toEqual([]); + await s.flush(); + expect(sent).toEqual(['Short.\n\nAlso short.']); + }); + + it('force-splits at maxChars', async () => { + const s = createStreamer({ minChars: 10, maxChars: 30 }); + // 40 chars, no newlines — should force-split at space near 30 + s.push('aaaa bbbb cccc dddd eeee ffff gggg hhhh'); + await s.flush(); + // First block splits around 30 chars at a space boundary + expect(sent.length).toBe(2); + expect(sent[0]!.length).toBeLessThanOrEqual(30); + expect(sent[0]! + ' ' + sent[1]!).toBe( + 'aaaa bbbb cccc dddd eeee ffff gggg hhhh', + ); + }); + + it('force-splits at maxChars with no break points', async () => { + const s = createStreamer({ minChars: 5, maxChars: 10 }); + s.push('abcdefghijklmnop'); // 16 chars, no spaces + await s.flush(); + expect(sent).toEqual(['abcdefghij', 'klmnop']); + }); + + it('prefers paragraph break over newline when force-splitting', async () => { + const s = createStreamer({ minChars: 5, maxChars: 30 }); + s.push('line one\n\nline two\nline three xx'); + await s.flush(); + // Should split at \n\n (pos 10) since it's within maxChars + expect(sent[0]).toBe('line one'); + expect(sent.length).toBe(2); + }); + + it('emits on idle timer when buffer >= minChars', async () => { + const s = createStreamer({ minChars: 5, idleMs: 500 }); + s.push('Hello world'); // 11 chars, no boundary + expect(sent).toEqual([]); + + vi.advanceTimersByTime(500); + // idle timer should have fired + await s.flush(); + expect(sent).toEqual(['Hello world']); + }); + + it('does not emit on idle timer when buffer < minChars', async () => { + const s = createStreamer({ minChars: 100, idleMs: 500 }); + s.push('tiny'); + vi.advanceTimersByTime(500); + expect(sent).toEqual([]); + // flush still sends remaining + await s.flush(); + expect(sent).toEqual(['tiny']); + }); + + it('resets idle timer on each push', async () => { + const s = createStreamer({ minChars: 20, idleMs: 500 }); + s.push('Hello '); + vi.advanceTimersByTime(400); + s.push('world, how are you?'); // total 25 chars + vi.advanceTimersByTime(400); + // Only 400ms since last push, shouldn't fire yet + expect(sent).toEqual([]); + vi.advanceTimersByTime(100); + // Now 500ms since last push + await s.flush(); + expect(sent).toEqual(['Hello world, how are you?']); + }); + + it('flush sends everything remaining', async () => { + const s = createStreamer({ minChars: 1000 }); // very high min + s.push('some text that will never hit minChars'); + await s.flush(); + expect(sent).toEqual(['some text that will never hit minChars']); + }); + + it('flush with empty buffer is a no-op', async () => { + const s = createStreamer(); + await s.flush(); + expect(sent).toEqual([]); + expect(s.blockCount).toBe(0); + }); + + it('trims whitespace from emitted blocks', async () => { + const s = createStreamer({ minChars: 5 }); + s.push(' \n Hello world \n\n Next '); + await s.flush(); + // The first block includes leading whitespace up to \n\n, trimmed + expect(sent.every((t) => t === t.trim())).toBe(true); + }); + + it('does not emit empty blocks after trimming', async () => { + const s = createStreamer({ minChars: 1 }); + s.push('\n\n\n\n'); + await s.flush(); + // All whitespace — nothing to emit after trim + expect(sent).toEqual([]); + expect(s.blockCount).toBe(0); + }); + + it('serializes sends', async () => { + vi.useRealTimers(); + const order: string[] = []; + let callIndex = 0; + const slowSend = async (text: string) => { + const idx = callIndex++; + // Simulate async delay + await new Promise((r) => setTimeout(r, 10)); + order.push(`${idx}:${text}`); + }; + + const s = new BlockStreamer({ + minChars: 5, + maxChars: 20, + idleMs: 0, + send: slowSend, + }); + + s.push('aaaa bbbb cccc dddd eeee ffff'); + await s.flush(); + // All sends completed in order + expect(order.length).toBeGreaterThanOrEqual(2); + // Verify sequential ordering + for (let i = 0; i < order.length; i++) { + expect(order[i]).toMatch(new RegExp(`^${i}:`)); + } + }); + + it('handles multiple paragraph boundaries', async () => { + const s = createStreamer({ minChars: 5, maxChars: 200 }); + s.push('Para one.\n\nPara two.\n\nPara three.'); + await s.flush(); + // Should emit paras 1+2 as one block (last \n\n boundary), then para 3 + expect(sent).toEqual(['Para one.\n\nPara two.', 'Para three.']); + }); + + it('works with idleMs=0 (idle timer disabled)', async () => { + const s = createStreamer({ minChars: 10, idleMs: 0 }); + s.push('Hello world, no timer'); + vi.advanceTimersByTime(10000); + expect(sent).toEqual([]); + await s.flush(); + expect(sent).toEqual(['Hello world, no timer']); + }); +}); diff --git a/packages/channels/base/src/BlockStreamer.ts b/packages/channels/base/src/BlockStreamer.ts new file mode 100644 index 0000000000..6938605d9d --- /dev/null +++ b/packages/channels/base/src/BlockStreamer.ts @@ -0,0 +1,134 @@ +/** + * BlockStreamer — progressive multi-message delivery for channels. + * + * Accumulates text chunks from the agent's streaming response and emits + * completed "blocks" (paragraphs / sections) as separate channel messages + * while the agent is still working. This gives users a natural conversation + * flow instead of waiting 30–120 seconds for a single wall of text. + * + * Emission triggers: + * 1. Buffer ≥ maxChars → force-split at best break point + * 2. Buffer ≥ minChars AND a paragraph boundary (\n\n) exists → emit up to boundary + * 3. Idle timer fires (no chunk for idleMs) AND buffer ≥ minChars → emit buffer + * 4. flush() called (response complete) → emit everything remaining + * + * All sends are serialized — the next block waits for the previous send to complete. + */ + +export interface BlockStreamerOptions { + /** Minimum characters before emitting a block. Default: 400. */ + minChars: number; + /** Force-emit when buffer exceeds this size. Default: 1000. */ + maxChars: number; + /** Emit buffered text after this many ms of inactivity. Default: 1500. */ + idleMs: number; + /** Callback to deliver a completed block. Called with trimmed text. */ + send: (text: string) => Promise; +} + +export class BlockStreamer { + private buffer = ''; + private idleTimer: ReturnType | null = null; + private sending: Promise = Promise.resolve(); + private opts: BlockStreamerOptions; + + /** Number of blocks emitted so far. */ + blockCount = 0; + + constructor(opts: BlockStreamerOptions) { + this.opts = opts; + } + + /** Feed a new text chunk from the agent stream. */ + push(chunk: string): void { + this.buffer += chunk; + this.clearIdleTimer(); + this.checkEmit(); + + if (this.buffer.length > 0 && this.opts.idleMs > 0) { + this.idleTimer = setTimeout(() => this.onIdle(), this.opts.idleMs); + } + } + + /** Flush all remaining buffered text. Awaits all pending sends. */ + async flush(): Promise { + this.clearIdleTimer(); + if (this.buffer.length > 0) { + this.emitBlock(this.buffer); + this.buffer = ''; + } + await this.sending; + } + + // --------------------------------------------------------------------------- + // Internal + // --------------------------------------------------------------------------- + + private checkEmit(): void { + // 1. Force-split if buffer exceeds maxChars + while (this.buffer.length >= this.opts.maxChars) { + const bp = this.findBreakPoint(this.buffer, this.opts.maxChars); + this.emitBlock(this.buffer.slice(0, bp)); + this.buffer = this.buffer.slice(bp); + } + + // 2. Emit at paragraph boundary if we have enough text + if (this.buffer.length >= this.opts.minChars) { + const bp = this.findBlockBoundary(this.buffer); + if (bp > 0) { + this.emitBlock(this.buffer.slice(0, bp)); + this.buffer = this.buffer.slice(bp); + } + } + } + + private onIdle(): void { + this.idleTimer = null; + if (this.buffer.length >= this.opts.minChars) { + this.emitBlock(this.buffer); + this.buffer = ''; + } + } + + private emitBlock(text: string): void { + const trimmed = text.trim(); + if (!trimmed) return; + this.blockCount++; + this.sending = this.sending + .then(() => this.opts.send(trimmed)) + .catch(() => {}); + } + + /** + * Find the last paragraph boundary (\n\n) in the buffer. + * Returns the position after the boundary, or -1 if no suitable boundary + * exists at or after minChars. + */ + private findBlockBoundary(text: string): number { + const last = text.lastIndexOf('\n\n'); + if (last < 0 || last < this.opts.minChars) return -1; + return last + 2; + } + + /** + * Find the best break point at or before maxPos. + * Prefers paragraph break > newline > space > maxPos. + */ + private findBreakPoint(text: string, maxPos: number): number { + const sub = text.slice(0, maxPos); + const para = sub.lastIndexOf('\n\n'); + if (para > 0) return para + 2; + const nl = sub.lastIndexOf('\n'); + if (nl > 0) return nl + 1; + const sp = sub.lastIndexOf(' '); + if (sp > 0) return sp + 1; + return maxPos; + } + + private clearIdleTimer(): void { + if (this.idleTimer !== null) { + clearTimeout(this.idleTimer); + this.idleTimer = null; + } + } +} diff --git a/packages/channels/base/src/ChannelBase.test.ts b/packages/channels/base/src/ChannelBase.test.ts new file mode 100644 index 0000000000..34628a0310 --- /dev/null +++ b/packages/channels/base/src/ChannelBase.test.ts @@ -0,0 +1,756 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EventEmitter } from 'node:events'; +import type { ChannelConfig, Envelope } from './types.js'; +import type { AcpBridge } from './AcpBridge.js'; +import { ChannelBase } from './ChannelBase.js'; +import type { ChannelBaseOptions } from './ChannelBase.js'; + +// Concrete test implementation +class TestChannel extends ChannelBase { + sent: Array<{ chatId: string; text: string }> = []; + connected = false; + promptStarts: Array<{ + chatId: string; + sessionId: string; + messageId?: string; + }> = []; + promptEnds: Array<{ chatId: string; sessionId: string; messageId?: string }> = + []; + + async connect() { + this.connected = true; + } + async sendMessage(chatId: string, text: string) { + this.sent.push({ chatId, text }); + } + disconnect() { + this.connected = false; + } + + protected override onPromptStart( + chatId: string, + sessionId: string, + messageId?: string, + ): void { + this.promptStarts.push({ chatId, sessionId, messageId }); + } + + protected override onPromptEnd( + chatId: string, + sessionId: string, + messageId?: string, + ): void { + this.promptEnds.push({ chatId, sessionId, messageId }); + } +} + +function createBridge(): AcpBridge { + const emitter = new EventEmitter(); + let sessionCounter = 0; + const bridge = Object.assign(emitter, { + newSession: vi.fn().mockImplementation(() => `s-${++sessionCounter}`), + loadSession: vi.fn(), + prompt: vi.fn().mockResolvedValue('agent response'), + stop: vi.fn(), + start: vi.fn(), + isConnected: true, + availableCommands: [], + setBridge: vi.fn(), + }); + return bridge as unknown as AcpBridge; +} + +function defaultConfig(overrides: Partial = {}): ChannelConfig { + return { + type: 'test', + token: 'tok', + senderPolicy: 'open', + allowedUsers: [], + sessionScope: 'user', + cwd: '/tmp', + groupPolicy: 'disabled', + groups: {}, + ...overrides, + }; +} + +function envelope(overrides: Partial = {}): Envelope { + return { + channelName: 'test-chan', + senderId: 'user1', + senderName: 'User 1', + chatId: 'chat1', + text: 'hello', + isGroup: false, + isMentioned: false, + isReplyToBot: false, + ...overrides, + }; +} + +describe('ChannelBase', () => { + let bridge: AcpBridge; + + beforeEach(() => { + bridge = createBridge(); + }); + + function createChannel( + configOverrides: Partial = {}, + options?: ChannelBaseOptions, + ): TestChannel { + return new TestChannel( + 'test-chan', + defaultConfig(configOverrides), + bridge, + options, + ); + } + + describe('gate integration', () => { + it('silently drops group messages when groupPolicy=disabled', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope({ isGroup: true })); + expect(ch.sent).toEqual([]); + expect(bridge.prompt).not.toHaveBeenCalled(); + }); + + it('allows DM messages through', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope()); + expect(bridge.prompt).toHaveBeenCalled(); + }); + + it('rejects sender with allowlist policy', async () => { + const ch = createChannel({ + senderPolicy: 'allowlist', + allowedUsers: ['admin'], + }); + await ch.handleInbound(envelope({ senderId: 'stranger' })); + expect(bridge.prompt).not.toHaveBeenCalled(); + }); + + it('allows sender on allowlist', async () => { + const ch = createChannel({ + senderPolicy: 'allowlist', + allowedUsers: ['user1'], + }); + await ch.handleInbound(envelope()); + expect(bridge.prompt).toHaveBeenCalled(); + }); + }); + + describe('slash commands', () => { + it('/help sends command list', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope({ text: '/help' })); + expect(ch.sent).toHaveLength(1); + expect(ch.sent[0]!.text).toContain('/help'); + expect(ch.sent[0]!.text).toContain('/clear'); + expect(bridge.prompt).not.toHaveBeenCalled(); + }); + + it('/clear removes session and confirms', async () => { + const ch = createChannel(); + // Create a session first + await ch.handleInbound(envelope()); + ch.sent = []; + // Now clear + await ch.handleInbound(envelope({ text: '/clear' })); + expect(ch.sent).toHaveLength(1); + expect(ch.sent[0]!.text).toContain('Session cleared'); + }); + + it('/clear reports when no session exists', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope({ text: '/clear' })); + expect(ch.sent).toHaveLength(1); + expect(ch.sent[0]!.text).toContain('No active session'); + }); + + it('/reset and /new are aliases for /clear', async () => { + for (const cmd of ['/reset', '/new']) { + const ch = createChannel(); + await ch.handleInbound(envelope()); + ch.sent = []; + await ch.handleInbound(envelope({ text: cmd })); + expect(ch.sent[0]!.text).toContain('Session cleared'); + } + }); + + it('/status shows session info', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope({ text: '/status' })); + expect(ch.sent).toHaveLength(1); + expect(ch.sent[0]!.text).toContain('Session: none'); + expect(ch.sent[0]!.text).toContain('Access: open'); + expect(ch.sent[0]!.text).toContain('Channel: test-chan'); + }); + + it('/status shows active session', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope({ text: 'hi' })); + ch.sent = []; + await ch.handleInbound(envelope({ text: '/status' })); + expect(ch.sent[0]!.text).toContain('Session: active'); + }); + + it('handles /command@botname format', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope({ text: '/help@mybot' })); + expect(ch.sent).toHaveLength(1); + expect(ch.sent[0]!.text).toContain('/help'); + }); + + it('forwards unrecognized commands to agent', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope({ text: '/unknown' })); + expect(bridge.prompt).toHaveBeenCalled(); + }); + }); + + describe('custom commands', () => { + it('subclass can register custom commands', async () => { + const ch = createChannel(); + // Access protected method via the test subclass + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (ch as any).registerCommand('ping', async () => { + await ch.sendMessage('chat1', 'pong'); + return true; + }); + await ch.handleInbound(envelope({ text: '/ping' })); + expect(ch.sent).toHaveLength(1); + expect(ch.sent[0]!.text).toBe('pong'); + expect(bridge.prompt).not.toHaveBeenCalled(); + }); + + it('/help shows platform-specific commands', async () => { + const ch = createChannel(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (ch as any).registerCommand('start', async () => true); + await ch.handleInbound(envelope({ text: '/help' })); + expect(ch.sent[0]!.text).toContain('/start'); + }); + }); + + describe('message enrichment', () => { + it('prepends referenced text', async () => { + const ch = createChannel(); + await ch.handleInbound( + envelope({ text: 'my reply', referencedText: 'original message' }), + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const promptText = (bridge.prompt as any).mock.calls[0][1] as string; + expect(promptText).toContain('[Replying to: "original message"]'); + expect(promptText).toContain('my reply'); + }); + + it('appends file paths from attachments', async () => { + const ch = createChannel(); + await ch.handleInbound( + envelope({ + text: 'check this', + attachments: [ + { + type: 'file', + filePath: '/tmp/test.pdf', + mimeType: 'application/pdf', + fileName: 'test.pdf', + }, + ], + }), + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const promptText = (bridge.prompt as any).mock.calls[0][1] as string; + expect(promptText).toContain('/tmp/test.pdf'); + expect(promptText).toContain('"test.pdf"'); + }); + + it('extracts image from attachments', async () => { + const ch = createChannel(); + await ch.handleInbound( + envelope({ + text: 'see image', + attachments: [ + { + type: 'image', + data: 'base64data', + mimeType: 'image/png', + }, + ], + }), + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const options = (bridge.prompt as any).mock.calls[0][2]; + expect(options.imageBase64).toBe('base64data'); + expect(options.imageMimeType).toBe('image/png'); + }); + + it('uses legacy imageBase64 when no attachment image', async () => { + const ch = createChannel(); + await ch.handleInbound( + envelope({ + text: 'see image', + imageBase64: 'legacydata', + imageMimeType: 'image/jpeg', + }), + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const options = (bridge.prompt as any).mock.calls[0][2]; + expect(options.imageBase64).toBe('legacydata'); + }); + + it('prepends instructions on first message only', async () => { + const ch = createChannel({ instructions: 'Be concise.' }); + await ch.handleInbound(envelope({ text: 'first' })); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const firstPrompt = (bridge.prompt as any).mock.calls[0][1] as string; + expect(firstPrompt).toContain('Be concise.'); + + await ch.handleInbound(envelope({ text: 'second' })); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const secondPrompt = (bridge.prompt as any).mock.calls[1][1] as string; + expect(secondPrompt).not.toContain('Be concise.'); + }); + }); + + describe('session routing', () => { + it('creates new session on first message', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope()); + expect(bridge.newSession).toHaveBeenCalledTimes(1); + }); + + it('reuses session for same sender', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope()); + await ch.handleInbound(envelope()); + expect(bridge.newSession).toHaveBeenCalledTimes(1); + }); + + it('creates separate sessions for different senders', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope({ senderId: 'alice' })); + await ch.handleInbound(envelope({ senderId: 'bob' })); + expect(bridge.newSession).toHaveBeenCalledTimes(2); + }); + }); + + describe('response delivery', () => { + it('sends agent response via sendMessage', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope()); + expect(ch.sent).toHaveLength(1); + expect(ch.sent[0]!.text).toBe('agent response'); + }); + + it('does not send when agent returns empty response', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (bridge.prompt as any).mockResolvedValue(''); + const ch = createChannel(); + await ch.handleInbound(envelope()); + expect(ch.sent).toEqual([]); + }); + }); + + describe('block streaming', () => { + it('uses block streamer when blockStreaming=on', async () => { + // The streamer sends blocks; onResponseComplete is NOT called + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (bridge.prompt as any).mockImplementation( + (sid: string, _text: string) => { + // Simulate streaming chunks + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (bridge as any).emit('textChunk', sid, 'Hello world! '); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (bridge as any).emit('textChunk', sid, 'This is a test.'); + return Promise.resolve('Hello world! This is a test.'); + }, + ); + + const ch = createChannel({ + blockStreaming: 'on', + blockStreamingChunk: { minChars: 5, maxChars: 100 }, + blockStreamingCoalesce: { idleMs: 0 }, + }); + await ch.handleInbound(envelope()); + // BlockStreamer flush should have sent the accumulated text + expect(ch.sent.length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('pairing flow', () => { + it('sends pairing code message when required', async () => { + const ch = createChannel({ senderPolicy: 'pairing', allowedUsers: [] }); + await ch.handleInbound(envelope({ senderId: 'stranger' })); + expect(ch.sent).toHaveLength(1); + expect(ch.sent[0]!.text).toContain('pairing code'); + expect(bridge.prompt).not.toHaveBeenCalled(); + }); + }); + + describe('setBridge', () => { + it('replaces the bridge instance', async () => { + const ch = createChannel(); + const newBridge = createBridge(); + ch.setBridge(newBridge); + // The channel should use the new bridge for future messages + // (this mainly ensures no crash) + expect(() => ch.setBridge(newBridge)).not.toThrow(); + }); + }); + + describe('dispatch modes', () => { + it('collect: buffers messages and coalesces into one followup prompt', async () => { + // Make the first prompt "slow" — we control when it resolves + let resolveFirst!: (v: string) => void; + const firstPrompt = new Promise((r) => { + resolveFirst = r; + }); + let callCount = 0; + (bridge.prompt as ReturnType).mockImplementation(() => { + callCount++; + if (callCount === 1) return firstPrompt; + return Promise.resolve('coalesced response'); + }); + + const ch = createChannel({ dispatchMode: 'collect' }); + + // Send first message — starts processing + const p1 = ch.handleInbound(envelope({ text: 'first' })); + + // Wait a tick for the prompt to be registered as active + await new Promise((r) => setTimeout(r, 10)); + + // Send two more messages while first is busy — these should buffer + const p2 = ch.handleInbound(envelope({ text: 'second' })); + const p3 = ch.handleInbound(envelope({ text: 'third' })); + + // p2 and p3 should resolve immediately (buffered, not queued) + await p2; + await p3; + + // First prompt is still running, bridge.prompt called only once + expect(callCount).toBe(1); + + // Resolve the first prompt + resolveFirst('first response'); + await p1; + + // Wait for the coalesced followup to process + await new Promise((r) => setTimeout(r, 50)); + + // bridge.prompt should have been called twice: original + coalesced + expect(callCount).toBe(2); + + // The second call should contain both buffered messages coalesced + const secondCallText = (bridge.prompt as ReturnType).mock + .calls[1][1] as string; + expect(secondCallText).toContain('second'); + expect(secondCallText).toContain('third'); + + // Both responses should have been sent + expect(ch.sent).toEqual( + expect.arrayContaining([ + expect.objectContaining({ text: 'first response' }), + expect.objectContaining({ text: 'coalesced response' }), + ]), + ); + }); + + it('collect: no followup if no messages buffered', async () => { + const ch = createChannel({ dispatchMode: 'collect' }); + await ch.handleInbound(envelope({ text: 'only message' })); + expect(bridge.prompt).toHaveBeenCalledTimes(1); + expect(ch.sent).toHaveLength(1); + }); + + it('steer: cancels running prompt and re-prompts with cancellation note', async () => { + let resolveFirst!: (v: string) => void; + const firstPrompt = new Promise((r) => { + resolveFirst = r; + }); + let callCount = 0; + (bridge.prompt as ReturnType).mockImplementation(() => { + callCount++; + if (callCount === 1) return firstPrompt; + return Promise.resolve('steered response'); + }); + + // Add cancelSession mock + (bridge as unknown as Record).cancelSession = vi + .fn() + .mockImplementation(() => { + // Simulate cancellation — resolve the first prompt + resolveFirst('cancelled partial'); + return Promise.resolve(); + }); + + const ch = createChannel({ dispatchMode: 'steer' }); + + // Send first message — starts processing + const p1 = ch.handleInbound(envelope({ text: 'refactor auth' })); + + // Wait for prompt to register as active + await new Promise((r) => setTimeout(r, 10)); + + // Send correction while first is busy + const p2 = ch.handleInbound( + envelope({ text: 'actually refactor billing' }), + ); + + // Both should resolve + await p1; + await p2; + + // cancelSession should have been called + expect( + (bridge as unknown as Record unknown>).cancelSession, + ).toHaveBeenCalledTimes(1); + + // First prompt's response should NOT have been sent (it was cancelled) + expect(ch.sent).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ text: 'cancelled partial' }), + ]), + ); + + // Second prompt should include the cancellation note + const secondCallText = (bridge.prompt as ReturnType).mock + .calls[1][1] as string; + expect(secondCallText).toContain('previous request has been cancelled'); + expect(secondCallText).toContain('actually refactor billing'); + + // Steered response should have been sent + expect(ch.sent).toEqual( + expect.arrayContaining([ + expect.objectContaining({ text: 'steered response' }), + ]), + ); + }); + + it('followup: queues messages sequentially', async () => { + let resolveFirst!: (v: string) => void; + const firstPrompt = new Promise((r) => { + resolveFirst = r; + }); + let callCount = 0; + (bridge.prompt as ReturnType).mockImplementation(() => { + callCount++; + if (callCount === 1) return firstPrompt; + return Promise.resolve(`response-${callCount}`); + }); + + const ch = createChannel({ dispatchMode: 'followup' }); + + // Send first message + const p1 = ch.handleInbound(envelope({ text: 'task one' })); + + // Wait for prompt to start + await new Promise((r) => setTimeout(r, 10)); + + // Send second message — should queue (not buffer) + const p2 = ch.handleInbound(envelope({ text: 'task two' })); + + // Only first prompt should be running + expect(callCount).toBe(1); + + // Resolve first + resolveFirst('response-1'); + await p1; + await p2; + + // Both prompts ran sequentially + expect(callCount).toBe(2); + + // Both got their own response + expect(ch.sent).toEqual([ + expect.objectContaining({ text: 'response-1' }), + expect.objectContaining({ text: 'response-2' }), + ]); + }); + + it('steer is the default mode when dispatchMode not set', async () => { + let resolveFirst!: (v: string) => void; + const firstPrompt = new Promise((r) => { + resolveFirst = r; + }); + let callCount = 0; + (bridge.prompt as ReturnType).mockImplementation(() => { + callCount++; + if (callCount === 1) return firstPrompt; + return Promise.resolve('steered response'); + }); + + // Add cancelSession mock + (bridge as unknown as Record).cancelSession = vi + .fn() + .mockImplementation(() => { + resolveFirst('cancelled'); + return Promise.resolve(); + }); + + // No dispatchMode set — should default to steer + const ch = createChannel(); + + const p1 = ch.handleInbound(envelope({ text: 'first' })); + await new Promise((r) => setTimeout(r, 10)); + + // Second message should cancel the first (steer behavior) + const p2 = ch.handleInbound(envelope({ text: 'second' })); + + await p1; + await p2; + + // cancelSession should have been called (steer behavior) + expect( + (bridge as unknown as Record unknown>).cancelSession, + ).toHaveBeenCalledTimes(1); + + // Both prompts ran + expect(callCount).toBe(2); + }); + + it('per-group dispatchMode overrides channel-level', async () => { + let resolveFirst!: (v: string) => void; + const firstPrompt = new Promise((r) => { + resolveFirst = r; + }); + let callCount = 0; + (bridge.prompt as ReturnType).mockImplementation(() => { + callCount++; + if (callCount === 1) return firstPrompt; + return Promise.resolve(`response-${callCount}`); + }); + + // Channel default is collect, but group overrides to followup + const ch = createChannel({ + dispatchMode: 'collect', + groupPolicy: 'open', + groups: { 'group-1': { dispatchMode: 'followup' } }, + }); + + const groupEnv = envelope({ + isGroup: true, + isMentioned: true, + chatId: 'group-1', + }); + + const p1 = ch.handleInbound({ ...groupEnv, text: 'first' }); + await new Promise((r) => setTimeout(r, 10)); + + // In followup mode, second message queues (doesn't buffer and return) + const p2Promise = ch.handleInbound({ ...groupEnv, text: 'second' }); + + expect(callCount).toBe(1); + + resolveFirst('response-1'); + await p1; + await p2Promise; + + // Both ran sequentially — followup behavior + expect(callCount).toBe(2); + expect(ch.sent).toEqual([ + expect.objectContaining({ text: 'response-1' }), + expect.objectContaining({ text: 'response-2' }), + ]); + }); + }); + + describe('prompt lifecycle hooks', () => { + it('calls onPromptStart and onPromptEnd for each prompt', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope({ text: 'hello' })); + + expect(ch.promptStarts).toHaveLength(1); + expect(ch.promptStarts[0]!.chatId).toBe('chat1'); + expect(ch.promptEnds).toHaveLength(1); + expect(ch.promptEnds[0]!.chatId).toBe('chat1'); + }); + + it('passes messageId to hooks', async () => { + const ch = createChannel(); + await ch.handleInbound(envelope({ text: 'hello', messageId: 'msg-42' })); + + expect(ch.promptStarts[0]!.messageId).toBe('msg-42'); + expect(ch.promptEnds[0]!.messageId).toBe('msg-42'); + }); + + it('does not call hooks for gated messages', async () => { + const ch = createChannel({ + senderPolicy: 'allowlist', + allowedUsers: ['admin'], + }); + await ch.handleInbound(envelope({ senderId: 'stranger' })); + + expect(ch.promptStarts).toHaveLength(0); + expect(ch.promptEnds).toHaveLength(0); + }); + + it('does not call hooks for buffered messages in collect mode', async () => { + let resolveFirst!: (v: string) => void; + const firstPrompt = new Promise((r) => { + resolveFirst = r; + }); + let callCount = 0; + (bridge.prompt as ReturnType).mockImplementation(() => { + callCount++; + if (callCount === 1) return firstPrompt; + return Promise.resolve('ok'); + }); + + const ch = createChannel({ dispatchMode: 'collect' }); + + const p1 = ch.handleInbound( + envelope({ text: 'first', messageId: 'msg-1' }), + ); + await new Promise((r) => setTimeout(r, 10)); + + // This message gets buffered — should NOT trigger hooks + await ch.handleInbound(envelope({ text: 'second', messageId: 'msg-2' })); + + // Only one prompt start so far (for the first message) + expect(ch.promptStarts).toHaveLength(1); + expect(ch.promptStarts[0]!.messageId).toBe('msg-1'); + + resolveFirst('done'); + await p1; + await new Promise((r) => setTimeout(r, 50)); + + // After coalesced prompt runs, we should have 2 start/end pairs + expect(ch.promptStarts).toHaveLength(2); + expect(ch.promptEnds).toHaveLength(2); + }); + + it('calls onPromptEnd even when prompt throws', async () => { + (bridge.prompt as ReturnType).mockRejectedValue( + new Error('agent error'), + ); + + const ch = createChannel(); + // handleInbound catches the error internally + await ch.handleInbound(envelope({ text: 'hello' })).catch(() => {}); + + expect(ch.promptStarts).toHaveLength(1); + expect(ch.promptEnds).toHaveLength(1); + }); + }); + + describe('isLocalCommand', () => { + it('returns true for registered commands', () => { + const ch = createChannel(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((ch as any).isLocalCommand('/help')).toBe(true); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((ch as any).isLocalCommand('/clear')).toBe(true); + }); + + it('returns false for non-commands', () => { + const ch = createChannel(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((ch as any).isLocalCommand('hello')).toBe(false); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((ch as any).isLocalCommand('/unknown')).toBe(false); + }); + }); +}); diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts new file mode 100644 index 0000000000..cc118e15ec --- /dev/null +++ b/packages/channels/base/src/ChannelBase.ts @@ -0,0 +1,443 @@ +import type { ChannelConfig, DispatchMode, Envelope } from './types.js'; +import { BlockStreamer } from './BlockStreamer.js'; +import { GroupGate } from './GroupGate.js'; +import { SenderGate } from './SenderGate.js'; +import { PairingStore } from './PairingStore.js'; +import { SessionRouter } from './SessionRouter.js'; +import type { AcpBridge, ToolCallEvent } from './AcpBridge.js'; + +export interface ChannelBaseOptions { + router?: SessionRouter; +} + +/** Handler for a slash command. Return true if handled, false to forward to agent. */ +type CommandHandler = (envelope: Envelope, args: string) => Promise; + +export abstract class ChannelBase { + protected config: ChannelConfig; + protected bridge: AcpBridge; + protected groupGate: GroupGate; + protected gate: SenderGate; + protected router: SessionRouter; + protected name: string; + private instructedSessions: Set = new Set(); + private commands: Map = new Map(); + /** Per-session promise chain to serialize prompt + send (followup mode). */ + private sessionQueues: Map> = new Map(); + + /** Per-session active prompt tracking for dispatch modes. */ + private activePrompts: Map< + string, + { cancelled: boolean; done: Promise; resolve: () => void } + > = new Map(); + /** Per-session message buffer for collect mode. */ + private collectBuffers: Map< + string, + Array<{ text: string; envelope: Envelope }> + > = new Map(); + + constructor( + name: string, + config: ChannelConfig, + bridge: AcpBridge, + options?: ChannelBaseOptions, + ) { + this.name = name; + this.config = config; + this.bridge = bridge; + + this.groupGate = new GroupGate(config.groupPolicy, config.groups); + + const pairingStore = + config.senderPolicy === 'pairing' ? new PairingStore(name) : undefined; + this.gate = new SenderGate( + config.senderPolicy, + config.allowedUsers, + pairingStore, + ); + this.router = + options?.router || + new SessionRouter(bridge, config.cwd, config.sessionScope); + + this.registerSharedCommands(); + + // When running standalone (no gateway), register toolCall listener directly. + // In gateway mode, the ChannelManager dispatches events instead. + if (!options?.router) { + bridge.on('toolCall', (event: ToolCallEvent) => { + const target = this.router.getTarget(event.sessionId); + if (target) { + this.onToolCall(target.chatId, event); + } + }); + } + } + + abstract connect(): Promise; + abstract sendMessage(chatId: string, text: string): Promise; + abstract disconnect(): void; + + /** Replace the bridge instance (used after crash recovery restart). */ + setBridge(bridge: AcpBridge): void { + this.bridge = bridge; + } + + onToolCall(_chatId: string, _event: ToolCallEvent): void {} + + /** + * Called when a prompt actually begins processing (inside the session queue). + * Override to show a platform-specific working indicator (e.g., typing, reaction). + * Not called for buffered messages (collect mode) or gated/blocked messages. + */ + protected onPromptStart( + _chatId: string, + _sessionId: string, + _messageId?: string, + ): void {} + + /** + * Called when a prompt finishes (response sent or cancelled). + * Override to hide the working indicator. + */ + protected onPromptEnd( + _chatId: string, + _sessionId: string, + _messageId?: string, + ): void {} + + /** + * Called for each text chunk as the agent streams its response. + * Override to implement progressive display (e.g., updating an AI card in-place). + * Default: no-op (chunks are collected internally and delivered via onResponseComplete). + */ + protected onResponseChunk( + _chatId: string, + _chunk: string, + _sessionId: string, + ): void {} + + /** + * Called when the agent's full response is ready. + * Override to customize delivery (e.g., finalize an AI card). + * Default: calls sendMessage() with the full response text. + */ + protected async onResponseComplete( + chatId: string, + fullText: string, + _sessionId: string, + ): Promise { + await this.sendMessage(chatId, fullText); + } + + /** + * Register a slash command handler. Subclasses can call this to add + * platform-specific commands (e.g., /start for Telegram). + * Overrides shared commands if the same name is registered. + */ + protected registerCommand(name: string, handler: CommandHandler): void { + this.commands.set(name.toLowerCase(), handler); + } + + /** Register shared slash commands. Called from constructor. */ + private registerSharedCommands(): void { + const clearHandler: CommandHandler = async (envelope) => { + const removedIds = this.router.removeSession( + this.name, + envelope.senderId, + envelope.chatId, + ); + if (removedIds.length > 0) { + for (const id of removedIds) { + this.instructedSessions.delete(id); + } + await this.sendMessage( + envelope.chatId, + 'Session cleared. Your next message will start a fresh conversation.', + ); + } else { + await this.sendMessage(envelope.chatId, 'No active session to clear.'); + } + return true; + }; + + this.registerCommand('clear', clearHandler); + this.registerCommand('reset', clearHandler); + this.registerCommand('new', clearHandler); + + this.registerCommand('help', async (envelope) => { + const lines = [ + 'Commands:', + '/help — Show this help', + '/clear — Clear your session (aliases: /reset, /new)', + '/status — Show session info', + ]; + + // Platform-specific commands (registered by adapters, not shared ones) + const sharedCmds = new Set(['help', 'clear', 'reset', 'new', 'status']); + const platformCmds = [...this.commands.keys()].filter( + (c) => !sharedCmds.has(c), + ); + if (platformCmds.length > 0) { + for (const cmd of platformCmds) { + lines.push(`/${cmd}`); + } + } + + const agentCommands = this.bridge.availableCommands; + if (agentCommands.length > 0) { + lines.push('', 'Agent commands (forwarded to Qwen Code):'); + for (const cmd of agentCommands) { + lines.push(`/${cmd.name} — ${cmd.description}`); + } + } + + lines.push('', 'Send any text to chat with the agent.'); + await this.sendMessage(envelope.chatId, lines.join('\n')); + return true; + }); + + this.registerCommand('status', async (envelope) => { + const hasSession = this.router.hasSession( + this.name, + envelope.senderId, + envelope.chatId, + ); + const policy = this.config.senderPolicy; + const lines = [ + `Session: ${hasSession ? 'active' : 'none'}`, + `Access: ${policy}`, + `Channel: ${this.name}`, + ]; + await this.sendMessage(envelope.chatId, lines.join('\n')); + return true; + }); + } + + /** Check if a message text matches a registered local command. */ + protected isLocalCommand(text: string): boolean { + const parsed = this.parseCommand(text); + return parsed !== null && this.commands.has(parsed.command); + } + + /** + * Parse a slash command from message text. + * Returns { command, args } or null if not a slash command. + */ + private parseCommand(text: string): { command: string; args: string } | null { + if (!text.startsWith('/')) return null; + // Handle /command@botname format (Telegram groups) + const match = text.match(/^\/([a-zA-Z0-9_]+)(?:@\S+)?\s*(.*)/s); + if (!match) return null; + return { command: match[1].toLowerCase(), args: match[2].trim() }; + } + + async handleInbound(envelope: Envelope): Promise { + // 1. Group gate: policy + allowlist + mention gating + const groupResult = this.groupGate.check(envelope); + if (!groupResult.allowed) { + return; // silently drop — no pairing, no reply + } + + // 2. Sender gate: allowlist / pairing / open + const result = this.gate.check(envelope.senderId, envelope.senderName); + if (!result.allowed) { + if (result.pairingCode !== undefined) { + await this.onPairingRequired(envelope.chatId, result.pairingCode); + } + return; + } + + // 3. Slash command handling — before session/agent routing + const parsed = this.parseCommand(envelope.text); + if (parsed) { + const handler = this.commands.get(parsed.command); + if (handler) { + const handled = await handler(envelope, parsed.args); + if (handled) return; + } + // Unrecognized commands fall through to the agent + } + + const sessionId = await this.router.resolve( + this.name, + envelope.senderId, + envelope.chatId, + envelope.threadId, + this.config.cwd, + ); + + // Prepend referenced (quoted) message text for reply context + let promptText = envelope.text; + if (envelope.referencedText) { + promptText = `[Replying to: "${envelope.referencedText}"]\n\n${promptText}`; + } + + // Resolve attachments: extract image for bridge, append file paths to text + let imageBase64 = envelope.imageBase64; + let imageMimeType = envelope.imageMimeType; + if (envelope.attachments?.length) { + const filePaths: string[] = []; + for (const att of envelope.attachments) { + if (att.type === 'image' && att.data && !imageBase64) { + imageBase64 = att.data; + imageMimeType = att.mimeType; + } else if (att.filePath) { + const label = att.type === 'file' ? 'file' : att.type; + const name = att.fileName ? ` "${att.fileName}"` : ''; + filePaths.push( + `User sent a ${label}${name}. It has been saved to: ${att.filePath}`, + ); + } + } + if (filePaths.length > 0) { + promptText = promptText + '\n\n' + filePaths.join('\n'); + } + } + + // Prepend channel instructions on first message of a session + if (this.config.instructions && !this.instructedSessions.has(sessionId)) { + promptText = `${this.config.instructions}\n\n${promptText}`; + this.instructedSessions.add(sessionId); + } + + // Resolve dispatch mode: per-group override → channel config → default + const groupCfg = envelope.isGroup + ? this.config.groups[envelope.chatId] || this.config.groups['*'] + : undefined; + const mode: DispatchMode = + groupCfg?.dispatchMode || this.config.dispatchMode || 'steer'; + + const active = this.activePrompts.get(sessionId); + + if (active) { + // A prompt is already running for this session + switch (mode) { + case 'collect': { + // Buffer the message; it will be coalesced when the active prompt finishes + let buffer = this.collectBuffers.get(sessionId); + if (!buffer) { + buffer = []; + this.collectBuffers.set(sessionId, buffer); + } + buffer.push({ text: promptText, envelope }); + return; + } + case 'steer': { + // Cancel the running prompt, then fall through to send a new one + active.cancelled = true; + await this.bridge.cancelSession(sessionId).catch(() => {}); + // Wait for the active prompt to finish winding down + await active.done; + // Prepend a cancellation note so the agent understands context + promptText = `[The user sent a new message while you were working. Their previous request has been cancelled.]\n\n${promptText}`; + break; + } + case 'followup': { + // Chain onto the session queue (existing sequential behavior) + break; + } + default: { + // Exhaustive check — should never happen + const _exhaustive: never = mode; + throw new Error(`Unknown dispatch mode: ${_exhaustive}`); + } + } + } + + // Run the prompt (with followup-mode serialization for safety) + const prev = this.sessionQueues.get(sessionId) ?? Promise.resolve(); + const useBlockStreaming = this.config.blockStreaming === 'on'; + const current = prev.then(async () => { + // Register this prompt as active + let doneResolve: () => void = () => {}; + const done = new Promise((r) => { + doneResolve = r; + }); + const promptState = { cancelled: false, done, resolve: doneResolve }; + this.activePrompts.set(sessionId, promptState); + + this.onPromptStart(envelope.chatId, sessionId, envelope.messageId); + + const streamer = useBlockStreaming + ? new BlockStreamer({ + minChars: this.config.blockStreamingChunk?.minChars ?? 400, + maxChars: this.config.blockStreamingChunk?.maxChars ?? 1000, + idleMs: this.config.blockStreamingCoalesce?.idleMs ?? 1500, + send: (text) => this.sendMessage(envelope.chatId, text), + }) + : null; + + const onChunk = (sid: string, chunk: string) => { + if (sid === sessionId) { + this.onResponseChunk(envelope.chatId, chunk, sessionId); + streamer?.push(chunk); + } + }; + this.bridge.on('textChunk', onChunk); + + try { + const response = await this.bridge.prompt(sessionId, promptText, { + imageBase64, + imageMimeType, + }); + + // If cancelled (steer mode), skip sending the response + if (!promptState.cancelled && response) { + if (streamer) { + await streamer.flush(); + } else { + await this.onResponseComplete(envelope.chatId, response, sessionId); + } + } + } finally { + this.bridge.off('textChunk', onChunk); + this.onPromptEnd(envelope.chatId, sessionId, envelope.messageId); + this.activePrompts.delete(sessionId); + // Signal any steer waiter that we're done + promptState.resolve(); + + // Drain collect buffer if any messages accumulated + const buffer = this.collectBuffers.get(sessionId); + if (buffer && buffer.length > 0) { + this.collectBuffers.delete(sessionId); + const coalesced = buffer.map((b) => b.text).join('\n\n'); + const lastEnvelope = buffer[buffer.length - 1]!.envelope; + // Re-enter handleInbound with the coalesced message + const syntheticEnvelope: Envelope = { + ...lastEnvelope, + text: coalesced, + // Clear attachments/references — already resolved in original text + referencedText: undefined, + attachments: undefined, + imageBase64: undefined, + imageMimeType: undefined, + }; + // Queue the coalesced prompt (don't await to avoid deadlock on the queue) + this.handleInbound(syntheticEnvelope).catch(() => {}); + } + } + }); + this.sessionQueues.set( + sessionId, + current.catch(() => {}), + ); + await current; + } + + protected async onPairingRequired( + chatId: string, + code: string | null, + ): Promise { + if (code) { + await this.sendMessage( + chatId, + `Your pairing code is: ${code}\n\nAsk the bot operator to approve you with:\n qwen channel pairing approve ${this.name} ${code}`, + ); + } else { + await this.sendMessage( + chatId, + 'Too many pending pairing requests. Please try again later.', + ); + } + } +} diff --git a/packages/channels/base/src/GroupGate.test.ts b/packages/channels/base/src/GroupGate.test.ts new file mode 100644 index 0000000000..4f54195230 --- /dev/null +++ b/packages/channels/base/src/GroupGate.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from 'vitest'; +import { GroupGate } from './GroupGate.js'; +import type { Envelope } from './types.js'; + +function envelope(overrides: Partial = {}): Envelope { + return { + channelName: 'test', + senderId: 'user1', + senderName: 'User', + chatId: 'chat1', + text: 'hello', + isGroup: false, + isMentioned: false, + isReplyToBot: false, + ...overrides, + }; +} + +describe('GroupGate', () => { + describe('non-group messages', () => { + it('always allows DM messages regardless of policy', () => { + for (const policy of ['disabled', 'allowlist', 'open'] as const) { + const gate = new GroupGate(policy); + expect(gate.check(envelope()).allowed).toBe(true); + } + }); + }); + + describe('disabled policy', () => { + it('rejects all group messages', () => { + const gate = new GroupGate('disabled'); + const result = gate.check(envelope({ isGroup: true })); + expect(result).toEqual({ allowed: false, reason: 'disabled' }); + }); + }); + + describe('allowlist policy', () => { + it('rejects groups not in allowlist', () => { + const gate = new GroupGate('allowlist', { other: {} }); + const result = gate.check(envelope({ isGroup: true })); + expect(result).toEqual({ allowed: false, reason: 'not_allowlisted' }); + }); + + it('does not treat "*" as wildcard allow', () => { + const gate = new GroupGate('allowlist', { '*': {} }); + const result = gate.check(envelope({ isGroup: true })); + expect(result).toEqual({ allowed: false, reason: 'not_allowlisted' }); + }); + + it('allows explicitly listed group with mention', () => { + const gate = new GroupGate('allowlist', { chat1: {} }); + const result = gate.check(envelope({ isGroup: true, isMentioned: true })); + expect(result.allowed).toBe(true); + }); + + it('requires mention by default for allowlisted group', () => { + const gate = new GroupGate('allowlist', { chat1: {} }); + const result = gate.check(envelope({ isGroup: true })); + expect(result).toEqual({ allowed: false, reason: 'mention_required' }); + }); + + it('allows reply-to-bot as alternative to mention', () => { + const gate = new GroupGate('allowlist', { chat1: {} }); + const result = gate.check( + envelope({ isGroup: true, isReplyToBot: true }), + ); + expect(result.allowed).toBe(true); + }); + + it('respects requireMention=false override', () => { + const gate = new GroupGate('allowlist', { + chat1: { requireMention: false }, + }); + const result = gate.check(envelope({ isGroup: true })); + expect(result.allowed).toBe(true); + }); + }); + + describe('open policy', () => { + it('allows any group with mention', () => { + const gate = new GroupGate('open'); + const result = gate.check(envelope({ isGroup: true, isMentioned: true })); + expect(result.allowed).toBe(true); + }); + + it('requires mention by default', () => { + const gate = new GroupGate('open'); + const result = gate.check(envelope({ isGroup: true })); + expect(result).toEqual({ allowed: false, reason: 'mention_required' }); + }); + + it('uses "*" as default config fallback', () => { + const gate = new GroupGate('open', { '*': { requireMention: false } }); + const result = gate.check(envelope({ isGroup: true })); + expect(result.allowed).toBe(true); + }); + + it('per-group config overrides "*" default', () => { + const gate = new GroupGate('open', { + '*': { requireMention: false }, + chat1: { requireMention: true }, + }); + const result = gate.check(envelope({ isGroup: true })); + expect(result).toEqual({ allowed: false, reason: 'mention_required' }); + }); + }); + + describe('defaults', () => { + it('defaults to disabled policy', () => { + const gate = new GroupGate(); + const result = gate.check(envelope({ isGroup: true })); + expect(result).toEqual({ allowed: false, reason: 'disabled' }); + }); + }); +}); diff --git a/packages/channels/base/src/GroupGate.ts b/packages/channels/base/src/GroupGate.ts new file mode 100644 index 0000000000..82b9754e6d --- /dev/null +++ b/packages/channels/base/src/GroupGate.ts @@ -0,0 +1,57 @@ +import type { GroupPolicy, GroupConfig, Envelope } from './types.js'; + +export interface GroupCheckResult { + allowed: boolean; + reason?: 'disabled' | 'not_allowlisted' | 'mention_required'; +} + +export class GroupGate { + private policy: GroupPolicy; + private groups: Record; + + constructor( + policy: GroupPolicy = 'disabled', + groups: Record = {}, + ) { + this.policy = policy; + this.groups = groups; + } + + /** + * Full group check: policy + allowlist + mention gating. + * Evaluation order: + * 1. groupPolicy (disabled → drop) + * 2. group allowlist (allowlist mode, no match → drop) + * 3. mention gating (requireMention + not mentioned → drop silently) + * + * Mention gating runs before sender gate so that unmentioned messages + * in groups don't trigger pairing flows. + */ + check(envelope: Envelope): GroupCheckResult { + if (!envelope.isGroup) { + return { allowed: true }; + } + + if (this.policy === 'disabled') { + return { allowed: false, reason: 'disabled' }; + } + + if (this.policy === 'allowlist') { + // In allowlist mode, "*" is only a default config — not a wildcard allow. + // The group must be explicitly listed by ID. + if (!this.groups[envelope.chatId]) { + return { allowed: false, reason: 'not_allowlisted' }; + } + } + + // Per-group config, falling back to "*" defaults, then built-in defaults + const groupConfig = this.groups[envelope.chatId] || this.groups['*'] || {}; + const requireMention = groupConfig.requireMention ?? true; + + if (requireMention && !envelope.isMentioned && !envelope.isReplyToBot) { + return { allowed: false, reason: 'mention_required' }; + } + + return { allowed: true }; + } +} diff --git a/packages/channels/base/src/PairingStore.ts b/packages/channels/base/src/PairingStore.ts new file mode 100644 index 0000000000..ebe23f1d5f --- /dev/null +++ b/packages/channels/base/src/PairingStore.ts @@ -0,0 +1,141 @@ +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +// Alphabet without ambiguous chars: 0/O, 1/I +const SAFE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; +const CODE_LENGTH = 8; +const EXPIRY_MS = 60 * 60 * 1000; // 1 hour +const MAX_PENDING = 3; + +export interface PairingRequest { + senderId: string; + senderName: string; + code: string; + createdAt: number; // epoch ms +} + +export class PairingStore { + private dir: string; + private pendingPath: string; + private allowlistPath: string; + + constructor(channelName: string) { + this.dir = path.join(os.homedir(), '.qwen', 'channels'); + this.pendingPath = path.join(this.dir, `${channelName}-pairing.json`); + this.allowlistPath = path.join(this.dir, `${channelName}-allowlist.json`); + } + + isApproved(senderId: string): boolean { + const list = this.readAllowlist(); + return list.includes(senderId); + } + + /** + * Create a pairing request for an unknown sender. + * Returns the code if created, or null if the pending cap is reached. + * If the sender already has a non-expired pending request, returns that code. + */ + createRequest(senderId: string, senderName: string): string | null { + const pending = this.readPending(); + + // Purge expired + const now = Date.now(); + const active = pending.filter((r) => now - r.createdAt < EXPIRY_MS); + + // Check if sender already has a pending request + const existing = active.find((r) => r.senderId === senderId); + if (existing) { + return existing.code; + } + + // Cap check + if (active.length >= MAX_PENDING) { + return null; + } + + const code = generateCode(); + active.push({ senderId, senderName, code, createdAt: now }); + this.writePending(active); + return code; + } + + /** + * Approve a pairing request by code. + * Returns the sender ID if found, or null if not found / expired. + */ + approve(code: string): PairingRequest | null { + const pending = this.readPending(); + const now = Date.now(); + const idx = pending.findIndex( + (r) => r.code === code.toUpperCase() && now - r.createdAt < EXPIRY_MS, + ); + if (idx === -1) return null; + + const request = pending[idx]!; + pending.splice(idx, 1); + this.writePending(pending); + + // Add to allowlist + const list = this.readAllowlist(); + if (!list.includes(request.senderId)) { + list.push(request.senderId); + this.writeAllowlist(list); + } + + return request; + } + + listPending(): PairingRequest[] { + const pending = this.readPending(); + const now = Date.now(); + return pending.filter((r) => now - r.createdAt < EXPIRY_MS); + } + + getAllowlist(): string[] { + return this.readAllowlist(); + } + + private ensureDir(): void { + if (!fs.existsSync(this.dir)) { + fs.mkdirSync(this.dir, { recursive: true }); + } + } + + private readPending(): PairingRequest[] { + try { + const data = fs.readFileSync(this.pendingPath, 'utf-8'); + return JSON.parse(data) as PairingRequest[]; + } catch { + return []; + } + } + + private writePending(requests: PairingRequest[]): void { + this.ensureDir(); + fs.writeFileSync(this.pendingPath, JSON.stringify(requests, null, 2)); + } + + private readAllowlist(): string[] { + try { + const data = fs.readFileSync(this.allowlistPath, 'utf-8'); + return JSON.parse(data) as string[]; + } catch { + return []; + } + } + + private writeAllowlist(list: string[]): void { + this.ensureDir(); + fs.writeFileSync(this.allowlistPath, JSON.stringify(list, null, 2)); + } +} + +function generateCode(): string { + let code = ''; + for (let i = 0; i < CODE_LENGTH; i++) { + code += SAFE_ALPHABET[crypto.randomInt(SAFE_ALPHABET.length)]; + } + return code; +} diff --git a/packages/channels/base/src/SenderGate.test.ts b/packages/channels/base/src/SenderGate.test.ts new file mode 100644 index 0000000000..05f74a83cd --- /dev/null +++ b/packages/channels/base/src/SenderGate.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi } from 'vitest'; +import { SenderGate } from './SenderGate.js'; +import type { PairingStore } from './PairingStore.js'; + +function mockPairingStore(overrides: Partial = {}): PairingStore { + return { + isApproved: vi.fn().mockReturnValue(false), + createRequest: vi.fn().mockReturnValue('ABCD1234'), + approve: vi.fn(), + listPending: vi.fn().mockReturnValue([]), + getAllowlist: vi.fn().mockReturnValue([]), + ...overrides, + } as unknown as PairingStore; +} + +describe('SenderGate', () => { + describe('open policy', () => { + it('allows any sender', () => { + const gate = new SenderGate('open'); + expect(gate.check('anyone').allowed).toBe(true); + }); + }); + + describe('allowlist policy', () => { + it('allows listed users', () => { + const gate = new SenderGate('allowlist', ['alice', 'bob']); + expect(gate.check('alice').allowed).toBe(true); + }); + + it('rejects unlisted users', () => { + const gate = new SenderGate('allowlist', ['alice']); + const result = gate.check('eve'); + expect(result.allowed).toBe(false); + expect(result.pairingCode).toBeUndefined(); + }); + + it('works with empty allowlist', () => { + const gate = new SenderGate('allowlist'); + expect(gate.check('anyone').allowed).toBe(false); + }); + }); + + describe('pairing policy', () => { + it('allows static allowlisted users without checking store', () => { + const store = mockPairingStore(); + const gate = new SenderGate('pairing', ['admin'], store); + const result = gate.check('admin'); + expect(result.allowed).toBe(true); + expect(store.isApproved).not.toHaveBeenCalled(); + }); + + it('allows dynamically approved users', () => { + const store = mockPairingStore({ + isApproved: vi.fn().mockReturnValue(true), + }); + const gate = new SenderGate('pairing', [], store); + expect(gate.check('user1').allowed).toBe(true); + }); + + it('generates pairing code for unknown sender', () => { + const store = mockPairingStore({ + createRequest: vi.fn().mockReturnValue('XYZW5678'), + }); + const gate = new SenderGate('pairing', [], store); + const result = gate.check('stranger', 'Stranger Name'); + expect(result.allowed).toBe(false); + expect(result.pairingCode).toBe('XYZW5678'); + expect(store.createRequest).toHaveBeenCalledWith( + 'stranger', + 'Stranger Name', + ); + }); + + it('returns null pairingCode when cap reached', () => { + const store = mockPairingStore({ + createRequest: vi.fn().mockReturnValue(null), + }); + const gate = new SenderGate('pairing', [], store); + const result = gate.check('stranger'); + expect(result.allowed).toBe(false); + expect(result.pairingCode).toBeNull(); + }); + + it('uses senderId as senderName fallback', () => { + const store = mockPairingStore(); + const gate = new SenderGate('pairing', [], store); + gate.check('user42'); + expect(store.createRequest).toHaveBeenCalledWith('user42', 'user42'); + }); + + it('works without pairing store (no store provided)', () => { + const gate = new SenderGate('pairing'); + const result = gate.check('anyone'); + expect(result.allowed).toBe(false); + expect(result.pairingCode).toBeNull(); + }); + }); + + describe('unknown policy', () => { + it('throws on unknown policy', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const gate = new SenderGate('unknown' as any); + expect(() => gate.check('user')).toThrow('Unknown sender policy'); + }); + }); +}); diff --git a/packages/channels/base/src/SenderGate.ts b/packages/channels/base/src/SenderGate.ts new file mode 100644 index 0000000000..ec9e4e5865 --- /dev/null +++ b/packages/channels/base/src/SenderGate.ts @@ -0,0 +1,50 @@ +import type { SenderPolicy } from './types.js'; +import type { PairingStore } from './PairingStore.js'; + +export interface SenderCheckResult { + allowed: boolean; + pairingCode?: string | null; // set when pairing policy returns a code (null = cap reached) +} + +export class SenderGate { + private policy: SenderPolicy; + private allowedUsers: Set; + private pairingStore: PairingStore | null; + + constructor( + policy: SenderPolicy, + allowedUsers: string[] = [], + pairingStore?: PairingStore, + ) { + this.policy = policy; + this.allowedUsers = new Set(allowedUsers); + this.pairingStore = pairingStore || null; + } + + check(senderId: string, senderName?: string): SenderCheckResult { + switch (this.policy) { + case 'open': + return { allowed: true }; + case 'allowlist': + return { allowed: this.allowedUsers.has(senderId) }; + case 'pairing': { + // Check static allowlist first + if (this.allowedUsers.has(senderId)) { + return { allowed: true }; + } + // Check dynamic approved list + if (this.pairingStore?.isApproved(senderId)) { + return { allowed: true }; + } + // Generate pairing code + const code = this.pairingStore?.createRequest( + senderId, + senderName || senderId, + ); + return { allowed: false, pairingCode: code ?? null }; + } + default: + throw new Error(`Unknown sender policy: ${this.policy}`); + } + } +} diff --git a/packages/channels/base/src/SessionRouter.test.ts b/packages/channels/base/src/SessionRouter.test.ts new file mode 100644 index 0000000000..d33f67fd77 --- /dev/null +++ b/packages/channels/base/src/SessionRouter.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { SessionRouter } from './SessionRouter.js'; +import type { AcpBridge } from './AcpBridge.js'; + +let sessionCounter = 0; + +function mockBridge(): AcpBridge { + return { + newSession: vi.fn().mockImplementation(() => `session-${++sessionCounter}`), + loadSession: vi.fn().mockImplementation((id: string) => id), + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + availableCommands: [], + } as unknown as AcpBridge; +} + +describe('SessionRouter', () => { + let bridge: AcpBridge; + + beforeEach(() => { + sessionCounter = 0; + bridge = mockBridge(); + }); + + describe('routing key scopes', () => { + it('user scope: routes by channel + sender + chat', async () => { + const router = new SessionRouter(bridge, '/tmp'); + const s1 = await router.resolve('ch', 'alice', 'chat1'); + const s2 = await router.resolve('ch', 'alice', 'chat2'); + const s3 = await router.resolve('ch', 'bob', 'chat1'); + expect(new Set([s1, s2, s3]).size).toBe(3); + }); + + it('user scope: same sender+chat reuses session', async () => { + const router = new SessionRouter(bridge, '/tmp'); + const s1 = await router.resolve('ch', 'alice', 'chat1'); + const s2 = await router.resolve('ch', 'alice', 'chat1'); + expect(s1).toBe(s2); + expect(bridge.newSession).toHaveBeenCalledTimes(1); + }); + + it('thread scope: routes by channel + threadId', async () => { + const router = new SessionRouter(bridge, '/tmp', 'thread'); + const s1 = await router.resolve('ch', 'alice', 'chat1', 'thread1'); + const s2 = await router.resolve('ch', 'bob', 'chat1', 'thread1'); + expect(s1).toBe(s2); // same thread = same session + }); + + it('thread scope: falls back to chatId when no threadId', async () => { + const router = new SessionRouter(bridge, '/tmp', 'thread'); + const s1 = await router.resolve('ch', 'alice', 'chat1'); + const s2 = await router.resolve('ch', 'bob', 'chat1'); + expect(s1).toBe(s2); + }); + + it('single scope: all messages share one session per channel', async () => { + const router = new SessionRouter(bridge, '/tmp', 'single'); + const s1 = await router.resolve('ch', 'alice', 'chat1'); + const s2 = await router.resolve('ch', 'bob', 'chat2'); + expect(s1).toBe(s2); + }); + + it('single scope: different channels get different sessions', async () => { + const router = new SessionRouter(bridge, '/tmp', 'single'); + const s1 = await router.resolve('ch1', 'alice', 'chat1'); + const s2 = await router.resolve('ch2', 'alice', 'chat1'); + expect(s1).not.toBe(s2); + }); + + it('per-channel scope overrides default scope', async () => { + const router = new SessionRouter(bridge, '/tmp', 'user'); + router.setChannelScope('telegram', 'single'); + + // 'telegram' uses single scope: same session for different users + const t1 = await router.resolve('telegram', 'alice', 'chat1'); + const t2 = await router.resolve('telegram', 'bob', 'chat2'); + expect(t1).toBe(t2); + + // other channel still uses default 'user' scope + const d1 = await router.resolve('dingtalk', 'alice', 'chat1'); + const d2 = await router.resolve('dingtalk', 'bob', 'chat1'); + expect(d1).not.toBe(d2); + }); + + it('mixed per-channel scopes work independently', async () => { + const router = new SessionRouter(bridge, '/tmp'); + router.setChannelScope('ch-thread', 'thread'); + router.setChannelScope('ch-single', 'single'); + router.setChannelScope('ch-user', 'user'); + + // thread scope: same thread = same session + const t1 = await router.resolve('ch-thread', 'alice', 'c1', 'thread1'); + const t2 = await router.resolve('ch-thread', 'bob', 'c1', 'thread1'); + expect(t1).toBe(t2); + + // single scope: one session for all + const s1 = await router.resolve('ch-single', 'alice', 'c1'); + const s2 = await router.resolve('ch-single', 'bob', 'c2'); + expect(s1).toBe(s2); + + // user scope: per-sender-per-chat + const u1 = await router.resolve('ch-user', 'alice', 'c1'); + const u2 = await router.resolve('ch-user', 'alice', 'c2'); + expect(u1).not.toBe(u2); + }); + }); + + describe('resolve', () => { + it('passes cwd to bridge.newSession', async () => { + const router = new SessionRouter(bridge, '/default'); + await router.resolve('ch', 'alice', 'chat1', undefined, '/custom'); + expect(bridge.newSession).toHaveBeenCalledWith('/custom'); + }); + + it('uses defaultCwd when no cwd provided', async () => { + const router = new SessionRouter(bridge, '/default'); + await router.resolve('ch', 'alice', 'chat1'); + expect(bridge.newSession).toHaveBeenCalledWith('/default'); + }); + }); + + describe('getTarget', () => { + it('returns target for existing session', async () => { + const router = new SessionRouter(bridge, '/tmp'); + const sid = await router.resolve('ch', 'alice', 'chat1', 'thread1'); + const target = router.getTarget(sid); + expect(target).toEqual({ + channelName: 'ch', + senderId: 'alice', + chatId: 'chat1', + threadId: 'thread1', + }); + }); + + it('returns undefined for unknown session', () => { + const router = new SessionRouter(bridge, '/tmp'); + expect(router.getTarget('nonexistent')).toBeUndefined(); + }); + }); + + describe('hasSession', () => { + it('returns true for existing session with chatId', async () => { + const router = new SessionRouter(bridge, '/tmp'); + await router.resolve('ch', 'alice', 'chat1'); + expect(router.hasSession('ch', 'alice', 'chat1')).toBe(true); + }); + + it('returns false for non-existing session', () => { + const router = new SessionRouter(bridge, '/tmp'); + expect(router.hasSession('ch', 'alice', 'chat1')).toBe(false); + }); + + it('prefix-scans when chatId omitted', async () => { + const router = new SessionRouter(bridge, '/tmp'); + await router.resolve('ch', 'alice', 'chat1'); + expect(router.hasSession('ch', 'alice')).toBe(true); + expect(router.hasSession('ch', 'bob')).toBe(false); + }); + }); + + describe('removeSession', () => { + it('removes session by key and returns session IDs', async () => { + const router = new SessionRouter(bridge, '/tmp'); + const sid = await router.resolve('ch', 'alice', 'chat1'); + const removed = router.removeSession('ch', 'alice', 'chat1'); + expect(removed).toEqual([sid]); + expect(router.hasSession('ch', 'alice', 'chat1')).toBe(false); + }); + + it('returns empty array when nothing to remove', () => { + const router = new SessionRouter(bridge, '/tmp'); + expect(router.removeSession('ch', 'alice', 'chat1')).toEqual([]); + }); + + it('removes all sender sessions when chatId omitted', async () => { + const router = new SessionRouter(bridge, '/tmp'); + await router.resolve('ch', 'alice', 'chat1'); + await router.resolve('ch', 'alice', 'chat2'); + const removed = router.removeSession('ch', 'alice'); + expect(removed).toHaveLength(2); + expect(router.hasSession('ch', 'alice')).toBe(false); + }); + + it('cleans up target mapping after removal', async () => { + const router = new SessionRouter(bridge, '/tmp'); + const sid = await router.resolve('ch', 'alice', 'chat1'); + router.removeSession('ch', 'alice', 'chat1'); + expect(router.getTarget(sid)).toBeUndefined(); + }); + }); + + describe('getAll', () => { + it('returns all session entries', async () => { + const router = new SessionRouter(bridge, '/tmp'); + await router.resolve('ch', 'alice', 'chat1'); + await router.resolve('ch', 'bob', 'chat2'); + const all = router.getAll(); + expect(all).toHaveLength(2); + expect(all.map((e) => e.target.senderId).sort()).toEqual([ + 'alice', + 'bob', + ]); + }); + + it('returns empty array when no sessions', () => { + const router = new SessionRouter(bridge, '/tmp'); + expect(router.getAll()).toEqual([]); + }); + }); + + describe('clearAll', () => { + it('clears all in-memory state', async () => { + const router = new SessionRouter(bridge, '/tmp'); + await router.resolve('ch', 'alice', 'chat1'); + router.clearAll(); + expect(router.hasSession('ch', 'alice', 'chat1')).toBe(false); + expect(router.getAll()).toEqual([]); + }); + }); + + describe('setBridge', () => { + it('replaces the bridge instance', async () => { + const router = new SessionRouter(bridge, '/tmp'); + const newBridge = mockBridge(); + router.setBridge(newBridge); + await router.resolve('ch', 'alice', 'chat1'); + expect(newBridge.newSession).toHaveBeenCalled(); + expect(bridge.newSession).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/channels/base/src/SessionRouter.ts b/packages/channels/base/src/SessionRouter.ts new file mode 100644 index 0000000000..bf08301bc6 --- /dev/null +++ b/packages/channels/base/src/SessionRouter.ts @@ -0,0 +1,234 @@ +import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'; +import type { SessionScope, SessionTarget } from './types.js'; +import type { AcpBridge } from './AcpBridge.js'; + +interface PersistedEntry { + sessionId: string; + target: SessionTarget; + cwd: string; +} + +export class SessionRouter { + private toSession: Map = new Map(); // routing key → session ID + private toTarget: Map = new Map(); // session ID → target + private toCwd: Map = new Map(); // session ID → cwd + + private bridge: AcpBridge; + private defaultCwd: string; + private defaultScope: SessionScope; + private channelScopes: Map = new Map(); + private persistPath: string | undefined; + + constructor( + bridge: AcpBridge, + defaultCwd: string, + scope: SessionScope = 'user', + persistPath?: string, + ) { + this.bridge = bridge; + this.defaultCwd = defaultCwd; + this.defaultScope = scope; + this.persistPath = persistPath; + } + + /** Replace the bridge instance (used after crash recovery restart). */ + setBridge(bridge: AcpBridge): void { + this.bridge = bridge; + } + + /** Set scope override for a specific channel. */ + setChannelScope(channelName: string, scope: SessionScope): void { + this.channelScopes.set(channelName, scope); + } + + private routingKey( + channelName: string, + senderId: string, + chatId: string, + threadId?: string, + ): string { + const scope = this.channelScopes.get(channelName) || this.defaultScope; + switch (scope) { + case 'thread': + return `${channelName}:${threadId || chatId}`; + case 'single': + return `${channelName}:__single__`; + case 'user': + default: + return `${channelName}:${senderId}:${chatId}`; + } + } + + async resolve( + channelName: string, + senderId: string, + chatId: string, + threadId?: string, + cwd?: string, + ): Promise { + const key = this.routingKey(channelName, senderId, chatId, threadId); + const existing = this.toSession.get(key); + if (existing) { + return existing; + } + + const sessionCwd = cwd || this.defaultCwd; + const sessionId = await this.bridge.newSession(sessionCwd); + this.toSession.set(key, sessionId); + this.toTarget.set(sessionId, { channelName, senderId, chatId, threadId }); + this.toCwd.set(sessionId, sessionCwd); + this.persist(); + return sessionId; + } + + getTarget(sessionId: string): SessionTarget | undefined { + return this.toTarget.get(sessionId); + } + + hasSession(channelName: string, senderId: string, chatId?: string): boolean { + const key = chatId + ? this.routingKey(channelName, senderId, chatId) + : `${channelName}:${senderId}`; + // If chatId is provided, do exact lookup; otherwise prefix-scan for any match + if (chatId) return this.toSession.has(key); + for (const k of this.toSession.keys()) { + if (k.startsWith(`${channelName}:${senderId}`)) return true; + } + return false; + } + + /** + * Remove session(s) for the given sender. Returns the removed session IDs. + */ + removeSession( + channelName: string, + senderId: string, + chatId?: string, + ): string[] { + const removedIds: string[] = []; + if (chatId) { + const key = this.routingKey(channelName, senderId, chatId); + const sessionId = this.deleteByKey(key); + if (sessionId) removedIds.push(sessionId); + } else { + // No chatId: remove all sessions for this sender on this channel + const prefix = `${channelName}:${senderId}`; + for (const k of [...this.toSession.keys()]) { + if (k.startsWith(prefix)) { + const sessionId = this.deleteByKey(k); + if (sessionId) removedIds.push(sessionId); + } + } + } + if (removedIds.length > 0) this.persist(); + return removedIds; + } + + private deleteByKey(key: string): string | null { + const sessionId = this.toSession.get(key); + if (!sessionId) return null; + this.toSession.delete(key); + this.toTarget.delete(sessionId); + this.toCwd.delete(sessionId); + return sessionId; + } + + /** Get all session entries for crash recovery. */ + getAll(): Array<{ key: string; sessionId: string; target: SessionTarget }> { + const entries: Array<{ + key: string; + sessionId: string; + target: SessionTarget; + }> = []; + for (const [key, sessionId] of this.toSession) { + const target = this.toTarget.get(sessionId); + if (target) { + entries.push({ key, sessionId, target }); + } + } + return entries; + } + + /** + * Restore session mappings from a previous bridge. + * Called after bridge restart — attempts loadSession for each saved mapping. + * Failed loads are silently dropped (new session on next message). + */ + async restoreSessions(): Promise<{ + restored: number; + failed: number; + }> { + if (!this.persistPath || !existsSync(this.persistPath)) { + return { restored: 0, failed: 0 }; + } + + let entries: Record; + try { + entries = JSON.parse(readFileSync(this.persistPath, 'utf-8')); + } catch { + return { restored: 0, failed: 0 }; + } + + let restored = 0; + let failed = 0; + + for (const [key, entry] of Object.entries(entries)) { + try { + const sessionId = await this.bridge.loadSession( + entry.sessionId, + entry.cwd, + ); + this.toSession.set(key, sessionId); + this.toTarget.set(sessionId, entry.target); + this.toCwd.set(sessionId, entry.cwd); + restored++; + } catch { + // Session can't be loaded — will create fresh on next message + failed++; + } + } + + // Update persist file to only include successfully restored sessions + if (failed > 0) { + this.persist(); + } + + return { restored, failed }; + } + + /** Clear in-memory state and delete persist file. Used on clean shutdown. */ + clearAll(): void { + this.toSession.clear(); + this.toTarget.clear(); + this.toCwd.clear(); + if (this.persistPath && existsSync(this.persistPath)) { + try { + unlinkSync(this.persistPath); + } catch { + // best-effort + } + } + } + + private persist(): void { + if (!this.persistPath) return; + + const data: Record = {}; + for (const [key, sessionId] of this.toSession) { + const target = this.toTarget.get(sessionId); + if (target) { + data[key] = { + sessionId, + target, + cwd: this.toCwd.get(sessionId) || this.defaultCwd, + }; + } + } + + try { + writeFileSync(this.persistPath, JSON.stringify(data, null, 2), 'utf-8'); + } catch { + // best-effort — don't break message flow for persistence failure + } + } +} diff --git a/packages/channels/base/src/index.ts b/packages/channels/base/src/index.ts new file mode 100644 index 0000000000..9361644a20 --- /dev/null +++ b/packages/channels/base/src/index.ts @@ -0,0 +1,32 @@ +export { AcpBridge } from './AcpBridge.js'; +export type { + AcpBridgeOptions, + AvailableCommand, + ToolCallEvent, +} from './AcpBridge.js'; +export { BlockStreamer } from './BlockStreamer.js'; +export type { BlockStreamerOptions } from './BlockStreamer.js'; +export { ChannelBase } from './ChannelBase.js'; +export type { ChannelBaseOptions } from './ChannelBase.js'; +export { PairingStore } from './PairingStore.js'; +export type { PairingRequest } from './PairingStore.js'; +export { GroupGate } from './GroupGate.js'; +export type { GroupCheckResult } from './GroupGate.js'; +export { SenderGate } from './SenderGate.js'; +export type { SenderCheckResult } from './SenderGate.js'; +export { SessionRouter } from './SessionRouter.js'; +export type { + Attachment, + BlockStreamingChunkConfig, + BlockStreamingCoalesceConfig, + ChannelConfig, + ChannelPlugin, + ChannelType, + DispatchMode, + Envelope, + GroupConfig, + GroupPolicy, + SenderPolicy, + SessionScope, + SessionTarget, +} from './types.js'; diff --git a/packages/channels/base/src/types.ts b/packages/channels/base/src/types.ts new file mode 100644 index 0000000000..4ff2828f3d --- /dev/null +++ b/packages/channels/base/src/types.ts @@ -0,0 +1,120 @@ +import type { AcpBridge } from './AcpBridge.js'; +import type { ChannelBase, ChannelBaseOptions } from './ChannelBase.js'; + +export type SenderPolicy = 'allowlist' | 'pairing' | 'open'; +export type SessionScope = 'user' | 'thread' | 'single'; +export type ChannelType = string; +export type GroupPolicy = 'disabled' | 'allowlist' | 'open'; +export type DispatchMode = 'collect' | 'steer' | 'followup'; + +export interface GroupConfig { + requireMention?: boolean; // default: true + dispatchMode?: DispatchMode; +} + +export interface BlockStreamingChunkConfig { + /** Minimum characters before emitting a block. Default: 400. */ + minChars?: number; + /** Force-emit when buffer exceeds this size. Default: 1000. */ + maxChars?: number; +} + +export interface BlockStreamingCoalesceConfig { + /** Emit buffered text after this many ms of inactivity. Default: 1500. */ + idleMs?: number; +} + +export interface ChannelConfig { + type: ChannelType; + token: string; + clientId?: string; + clientSecret?: string; + senderPolicy: SenderPolicy; + allowedUsers: string[]; + sessionScope: SessionScope; + cwd: string; + approvalMode?: string; + instructions?: string; + model?: string; + groupPolicy: GroupPolicy; // default: "disabled" + groups: Record; // "*" for defaults, group IDs for overrides + + /** Dispatch mode for concurrent messages. Default: 'collect'. */ + dispatchMode?: DispatchMode; + + /** Enable block streaming — emit completed blocks as separate messages. */ + blockStreaming?: 'on' | 'off'; + /** Chunk size bounds for block streaming. */ + blockStreamingChunk?: BlockStreamingChunkConfig; + /** Idle coalescing for block streaming. */ + blockStreamingCoalesce?: BlockStreamingCoalesceConfig; +} + +export interface Attachment { + /** Content category. */ + type: 'image' | 'file' | 'audio' | 'video'; + /** Base64-encoded data (for images or small files). */ + data?: string; + /** Absolute path to a local file (for large files saved to disk). */ + filePath?: string; + /** MIME type (e.g. "image/jpeg", "application/pdf"). */ + mimeType: string; + /** Original file name from the platform. */ + fileName?: string; +} + +export interface Envelope { + channelName: string; + senderId: string; + senderName: string; + chatId: string; + text: string; + threadId?: string; + /** Platform-specific message ID for response correlation. */ + messageId?: string; + isGroup: boolean; + isMentioned: boolean; + isReplyToBot: boolean; + /** Text of the message being replied to (quoted/referenced message). */ + referencedText?: string; + /** Base64-encoded image data (e.g. from WeChat CDN download). */ + imageBase64?: string; + /** MIME type for the image (e.g. "image/jpeg", "image/png"). */ + imageMimeType?: string; + /** Structured attachments (images, files, audio, video). */ + attachments?: Attachment[]; +} + +export interface SessionTarget { + channelName: string; + senderId: string; + chatId: string; + threadId?: string; +} + +/** + * A channel plugin registers a channel type and provides a factory + * to create adapter instances. Both built-in adapters and external + * plugins conform to this interface. + */ +export interface ChannelPlugin { + /** Unique channel type ID (e.g., "telegram", "tmcp-dingtalk"). */ + channelType: string; + + /** Human-readable name for CLI output. */ + displayName: string; + + /** + * Config fields required by this channel type, beyond the shared + * ChannelConfig fields. Validated at startup. + */ + requiredConfigFields?: string[]; + + /** Create a channel adapter instance. */ + createChannel( + name: string, + config: ChannelConfig & Record, + bridge: AcpBridge, + options?: ChannelBaseOptions, + ): ChannelBase; +} diff --git a/packages/channels/base/tsconfig.json b/packages/channels/base/tsconfig.json new file mode 100644 index 0000000000..d2afb1929f --- /dev/null +++ b/packages/channels/base/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"] +} diff --git a/packages/channels/base/vitest.config.ts b/packages/channels/base/vitest.config.ts new file mode 100644 index 0000000000..bfaebe3ce6 --- /dev/null +++ b/packages/channels/base/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + globals: true, + }, +}); diff --git a/packages/channels/dingtalk/package.json b/packages/channels/dingtalk/package.json new file mode 100644 index 0000000000..73c915eab0 --- /dev/null +++ b/packages/channels/dingtalk/package.json @@ -0,0 +1,27 @@ +{ + "name": "@qwen-code/channel-dingtalk", + "version": "0.13.0", + "description": "DingTalk channel adapter for Qwen Code", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc --build" + }, + "dependencies": { + "@qwen-code/channel-base": "file:../base", + "dingtalk-stream-sdk-nodejs": "^2.0.4" + }, + "devDependencies": { + "typescript": "^5.0.0" + } +} diff --git a/packages/channels/dingtalk/src/DingtalkAdapter.ts b/packages/channels/dingtalk/src/DingtalkAdapter.ts new file mode 100644 index 0000000000..d927c45839 --- /dev/null +++ b/packages/channels/dingtalk/src/DingtalkAdapter.ts @@ -0,0 +1,585 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { basename, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { DWClient, TOPIC_ROBOT, EventAck } from 'dingtalk-stream-sdk-nodejs'; +import type { DWClientDownStream } from 'dingtalk-stream-sdk-nodejs'; +import { ChannelBase } from '@qwen-code/channel-base'; +import { normalizeDingTalkMarkdown, extractTitle } from './markdown.js'; +import { downloadMedia } from './media.js'; +import type { + ChannelConfig, + ChannelBaseOptions, + Envelope, + AcpBridge, +} from '@qwen-code/channel-base'; + +/** + * Raw DingTalk message data — the SDK's RobotMessage type only covers text, + * but DingTalk sends richer payloads for richText, picture, file, etc. + */ + +interface DingTalkRichTextPart { + type?: string; + text?: string; + downloadCode?: string; + atName?: string; +} + +interface DingTalkRepliedMsg { + msgId?: string; + msgType?: string; + senderId?: string; + content?: { + text?: string; + richText?: DingTalkRichTextPart[]; + downloadCode?: string; + fileName?: string; + }; +} + +interface DingTalkMessageData { + msgId?: string; + msgtype?: string; + conversationType?: string; + conversationId?: string; + sessionWebhook?: string; + senderId?: string; + senderStaffId?: string; + senderNick?: string; + chatbotUserId?: string; + isInAtList?: boolean; + text?: { + content?: string; + isReplyMsg?: boolean; + repliedMsg?: DingTalkRepliedMsg; + }; + quoteMessage?: { + msgId?: string; + senderId?: string; + text?: { content?: string }; + msgtype?: string; + }; + content?: { + richText?: DingTalkRichTextPart[]; + downloadCode?: string; + fileName?: string; + recognition?: string; + }; +} + +/** Track seen msgIds to deduplicate retried callbacks. */ +const DEDUP_TTL_MS = 5 * 60 * 1000; // 5 minutes + +const ACK_REACTION_NAME = '👀'; +const ACK_EMOTION_ID = '2659900'; +const ACK_EMOTION_BG_ID = 'im_bg_1'; +const EMOTION_API = 'https://api.dingtalk.com/v1.0/robot/emotion'; + +export class DingtalkChannel extends ChannelBase { + private client: DWClient; + private seenMessages: Map = new Map(); + private dedupTimer?: ReturnType; + /** Map conversationId → latest sessionWebhook URL for sending replies. */ + private webhooks: Map = new Map(); + /** Map messageId → conversationId for reaction attach/recall in hooks. */ + private reactionContext: Map = new Map(); + + constructor( + name: string, + config: ChannelConfig, + bridge: AcpBridge, + options?: ChannelBaseOptions, + ) { + super(name, config, bridge, options); + + if (!config.clientId || !config.clientSecret) { + throw new Error( + `Channel "${name}" requires clientId and clientSecret for DingTalk.`, + ); + } + + this.client = new DWClient({ + clientId: config.clientId, + clientSecret: config.clientSecret, + }); + } + + async connect(): Promise { + this.client.registerCallbackListener( + TOPIC_ROBOT, + (msg: DWClientDownStream) => { + // ACK immediately so DingTalk doesn't retry + this.client.send(msg.headers.messageId, { + status: EventAck.SUCCESS, + message: 'ok', + }); + this.onMessage(msg); + }, + ); + + await this.client.connect(); + + // Periodically clean up dedup map + this.dedupTimer = setInterval(() => { + const now = Date.now(); + for (const [id, ts] of this.seenMessages) { + if (now - ts > DEDUP_TTL_MS) { + this.seenMessages.delete(id); + } + } + }, 60_000); + + process.stderr.write(`[DingTalk:${this.name}] Connected via stream.\n`); + } + + async sendMessage(chatId: string, text: string): Promise { + // chatId is a conversationId — resolve to the latest sessionWebhook + const webhook = this.webhooks.get(chatId); + if (!webhook) { + process.stderr.write( + `[DingTalk:${this.name}] No webhook for chatId ${chatId}, cannot send.\n`, + ); + return; + } + + const chunks = normalizeDingTalkMarkdown(text); + const title = extractTitle(text); + + for (const chunk of chunks) { + const body = { + msgtype: 'markdown', + markdown: { + title: chunks.length > 1 ? `${title} (cont.)` : title, + text: chunk, + }, + }; + + const resp = await fetch(webhook, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!resp.ok) { + const detail = await resp.text().catch(() => ''); + process.stderr.write( + `[DingTalk:${this.name}] sendMessage failed: HTTP ${resp.status} ${detail}\n`, + ); + } + } + } + + private getAccessToken(): string | undefined { + return this.client.getConfig().access_token; + } + + private async emotionApi( + endpoint: 'reply' | 'recall', + msgId: string, + conversationId: string, + ): Promise { + const token = this.getAccessToken(); + if (!token) return; + + const robotCode = this.config.clientId; + if (!robotCode || !msgId || !conversationId) return; + + try { + const resp = await fetch(`${EMOTION_API}/${endpoint}`, { + method: 'POST', + headers: { + 'x-acs-dingtalk-access-token': token, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + robotCode, + openMsgId: msgId, + openConversationId: conversationId, + emotionType: 2, + emotionName: ACK_REACTION_NAME, + textEmotion: { + emotionId: ACK_EMOTION_ID, + emotionName: ACK_REACTION_NAME, + text: ACK_REACTION_NAME, + backgroundId: ACK_EMOTION_BG_ID, + }, + }), + }); + if (!resp.ok) { + const detail = await resp.text().catch(() => ''); + process.stderr.write( + `[DingTalk:${this.name}] emotion/${endpoint} failed: ${resp.status} ${detail}\n`, + ); + } + } catch { + // best-effort, don't break message flow + } + } + + private async attachReaction( + msgId: string, + conversationId: string, + ): Promise { + await this.emotionApi('reply', msgId, conversationId); + } + + private async recallReaction( + msgId: string, + conversationId: string, + ): Promise { + await this.emotionApi('recall', msgId, conversationId); + } + + disconnect(): void { + if (this.dedupTimer) { + clearInterval(this.dedupTimer); + } + this.client.disconnect(); + process.stderr.write(`[DingTalk:${this.name}] Disconnected.\n`); + } + + protected override onPromptStart( + _chatId: string, + _sessionId: string, + messageId?: string, + ): void { + if (!messageId) return; + const convId = this.reactionContext.get(messageId); + if (convId) { + this.attachReaction(messageId, convId).catch(() => {}); + } + } + + protected override onPromptEnd( + _chatId: string, + _sessionId: string, + messageId?: string, + ): void { + if (!messageId) return; + const convId = this.reactionContext.get(messageId); + if (convId) { + this.recallReaction(messageId, convId).catch(() => {}); + this.reactionContext.delete(messageId); + } + } + + /** + * Extract quoted/referenced message context from a reply. + * DingTalk provides this via text.repliedMsg (newer) or quoteMessage (legacy). + */ + private extractQuotedContext(data: DingTalkMessageData): { + referencedText?: string; + isReplyToBot: boolean; + } { + // Newer format: text.repliedMsg + if (data.text?.isReplyMsg && data.text.repliedMsg) { + const replied = data.text.repliedMsg; + const isReplyToBot = + !!data.chatbotUserId && replied.senderId === data.chatbotUserId; + + // Note: DingTalk doesn't include content for interactiveCard replies + // (bot responses sent via webhook). Only user message quotes have text. + const text = this.summarizeRepliedContent(replied); + return { referencedText: text || undefined, isReplyToBot }; + } + + // Legacy format: quoteMessage + if (data.quoteMessage) { + const quote = data.quoteMessage; + const isReplyToBot = + !!data.chatbotUserId && quote.senderId === data.chatbotUserId; + const text = quote.text?.content?.trim(); + return { referencedText: text || undefined, isReplyToBot }; + } + + return { isReplyToBot: false }; + } + + /** + * Build a text summary from a repliedMsg, handling text, richText, and + * media message types with placeholders. + */ + private summarizeRepliedContent(replied: DingTalkRepliedMsg): string { + const msgType = replied.msgType; + const content = replied.content; + + // Direct text content + if (content?.text?.trim()) { + return content.text.trim(); + } + + // RichText: concatenate text parts, placeholder for images + if (content?.richText && Array.isArray(content.richText)) { + const parts: string[] = []; + for (const part of content.richText) { + const partType = part.type || 'text'; + if (partType === 'text' && part.text) { + parts.push(part.text); + } else if (partType === 'picture') { + parts.push('[image]'); + } else if (partType === 'at' && part.atName) { + parts.push(`@${part.atName}`); + } + } + const summary = parts.join('').trim(); + if (summary) return summary; + } + + // Media type placeholders + switch (msgType) { + case 'picture': + return '[image]'; + case 'file': + return `[file: ${content?.fileName || 'file'}]`; + case 'audio': + return '[audio]'; + case 'video': + return '[video]'; + default: + break; + } + + return ''; + } + + /** + * Extract text and media download codes from an incoming DingTalk message. + * Handles text, richText, picture, file, audio, and video message types. + */ + private extractContent(data: DingTalkMessageData): { + text: string; + downloadCodes: string[]; + mediaType?: 'image' | 'file' | 'audio' | 'video'; + fileName?: string; + } { + const msgtype = data.msgtype || 'text'; + + if (msgtype === 'richText') { + const richText = data.content?.richText; + if (!Array.isArray(richText)) { + return { text: '', downloadCodes: [] }; + } + let text = ''; + const codes: string[] = []; + for (const part of richText) { + const partType = part.type || 'text'; + if (partType === 'text' && part.text) { + text += part.text; + } else if (partType === 'picture' && part.downloadCode) { + codes.push(part.downloadCode); + } + } + return { + text: text.trim() || (codes.length > 0 ? '(image)' : ''), + downloadCodes: codes, + mediaType: codes.length > 0 ? 'image' : undefined, + }; + } + + if (msgtype === 'picture') { + const code = data.content?.downloadCode; + return { + text: '(image)', + downloadCodes: code ? [code] : [], + mediaType: 'image', + }; + } + + if (msgtype === 'file') { + const code = data.content?.downloadCode; + const fileName = data.content?.fileName || undefined; + return { + text: `(file: ${fileName || 'file'})`, + downloadCodes: code ? [code] : [], + mediaType: 'file', + fileName, + }; + } + + if (msgtype === 'audio') { + const code = data.content?.downloadCode; + const recognition = data.content?.recognition; + return { + text: recognition || '(audio)', + downloadCodes: code ? [code] : [], + mediaType: 'audio', + }; + } + + if (msgtype === 'video') { + const code = data.content?.downloadCode; + return { + text: '(video)', + downloadCodes: code ? [code] : [], + mediaType: 'video', + }; + } + + // Default: text message + return { text: data.text?.content?.trim() || '', downloadCodes: [] }; + } + + /** + * Download a media file and attach it to the envelope. + * Images → base64 in envelope; files → saved to temp dir with path in text. + */ + private async attachMedia( + envelope: Envelope, + downloadCode: string, + mediaType: 'image' | 'file' | 'audio' | 'video', + fileName?: string, + ): Promise { + const token = this.getAccessToken(); + const robotCode = this.config.clientId; + if (!token || !robotCode) { + process.stderr.write( + `[DingTalk:${this.name}] Cannot download media: missing token or robotCode.\n`, + ); + return; + } + + const media = await downloadMedia(downloadCode, robotCode, token); + if (!media) return; + + if (mediaType === 'image') { + const mimeType = media.mimeType.startsWith('image/') + ? media.mimeType + : 'image/jpeg'; + envelope.attachments = [ + ...(envelope.attachments || []), + { + type: 'image', + data: media.buffer.toString('base64'), + mimeType, + }, + ]; + } else { + // Save non-image files to temp dir so the agent can read them + const dir = join(tmpdir(), 'channel-files', randomUUID()); + mkdirSync(dir, { recursive: true }); + const safeName = + basename(fileName || '') || `dingtalk_${mediaType}_${Date.now()}`; + const filePath = join(dir, safeName); + writeFileSync(filePath, media.buffer); + + // Clean up placeholder text like "(audio)", "(video)", "(file: name)" + if ( + envelope.text === `(file: ${fileName || 'file'})` || + envelope.text === '(audio)' || + envelope.text === '(video)' + ) { + envelope.text = ''; + } + + envelope.attachments = [ + ...(envelope.attachments || []), + { + type: mediaType, + filePath, + mimeType: media.mimeType, + fileName: safeName, + }, + ]; + } + } + + private onMessage(downstream: DWClientDownStream): void { + try { + const data: DingTalkMessageData = + typeof downstream.data === 'string' + ? JSON.parse(downstream.data) + : (downstream.data as DingTalkMessageData); + const msgId = data.msgId || downstream.headers.messageId; + + // Dedup: DingTalk retries unACKed messages + if (msgId && this.seenMessages.has(msgId)) { + return; + } + if (msgId) { + this.seenMessages.set(msgId, Date.now()); + } + + const isGroup = data.conversationType === '2'; + const sessionWebhook = data.sessionWebhook; + const conversationId = data.conversationId; + + if (!sessionWebhook) { + process.stderr.write( + `[DingTalk:${this.name}] No sessionWebhook in message, skipping.\n`, + ); + return; + } + + // Cache webhook by conversationId so sendMessage can look it up + if (conversationId) { + this.webhooks.set(conversationId, sessionWebhook); + } + + const isMentioned = Boolean(data.isInAtList); + + // Extract text and media info from message + const content = this.extractContent(data); + let cleanText = content.text; + + // Strip first @mention (the bot) from text, keep other @mentions intact + if (isMentioned) { + cleanText = cleanText.replace(/@\S+/, '').trim(); + } + + // Extract quoted message context + const quoted = this.extractQuotedContext(data); + + const chatId = conversationId || sessionWebhook; + + const envelope: Envelope = { + channelName: this.name, + senderId: data.senderId || data.senderStaffId || '', + senderName: data.senderNick || 'Unknown', + chatId, + text: cleanText || content.text, + isGroup, + isMentioned, + isReplyToBot: quoted.isReplyToBot, + referencedText: quoted.referencedText, + }; + + // Store messageId + conversationId for reaction hooks + envelope.messageId = msgId; + if (msgId && conversationId) { + this.reactionContext.set(msgId, conversationId); + } + + const processMessage = async () => { + // Download media if present (first downloadCode only for images) + if (content.downloadCodes.length > 0 && content.mediaType) { + await this.attachMedia( + envelope, + content.downloadCodes[0]!, + content.mediaType, + content.fileName, + ); + } + // reactionContext cleanup is handled by onPromptEnd (not here), + // because in collect mode handleInbound returns immediately after + // buffering — the context must survive until the prompt actually runs. + await this.handleInbound(envelope); + }; + + // Don't await — stream callback should return quickly + processMessage().catch((err) => { + process.stderr.write( + `[DingTalk:${this.name}] Error handling message: ${err}\n`, + ); + this.sendMessage( + chatId, + 'Sorry, something went wrong processing your message.', + ).catch(() => {}); + }); + } catch (err) { + process.stderr.write( + `[DingTalk:${this.name}] Failed to parse message: ${err}\n`, + ); + } + } +} diff --git a/packages/channels/dingtalk/src/index.ts b/packages/channels/dingtalk/src/index.ts new file mode 100644 index 0000000000..4ea26fd7d2 --- /dev/null +++ b/packages/channels/dingtalk/src/index.ts @@ -0,0 +1,13 @@ +export { DingtalkChannel } from './DingtalkAdapter.js'; +export { downloadMedia } from './media.js'; + +import { DingtalkChannel } from './DingtalkAdapter.js'; +import type { ChannelPlugin } from '@qwen-code/channel-base'; + +export const plugin: ChannelPlugin = { + channelType: 'dingtalk', + displayName: 'DingTalk', + requiredConfigFields: ['clientId', 'clientSecret'], + createChannel: (name, config, bridge, options) => + new DingtalkChannel(name, config, bridge, options), +}; diff --git a/packages/channels/dingtalk/src/markdown.test.ts b/packages/channels/dingtalk/src/markdown.test.ts new file mode 100644 index 0000000000..b779a1951f --- /dev/null +++ b/packages/channels/dingtalk/src/markdown.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from 'vitest'; +import { + convertTables, + splitChunks, + extractTitle, + normalizeDingTalkMarkdown, +} from './markdown.js'; + +describe('DingTalk markdown utilities', () => { + describe('convertTables', () => { + it('converts a simple markdown table to pipe-separated text', () => { + const input = [ + '| Name | Age |', + '| --- | --- |', + '| Alice | 30 |', + '| Bob | 25 |', + ].join('\n'); + const result = convertTables(input); + expect(result).toContain('Name | Age'); + expect(result).toContain('Alice | 30'); + expect(result).not.toContain('---'); + }); + + it('preserves non-table content', () => { + const input = 'Hello world\n\nSome text'; + expect(convertTables(input)).toBe(input); + }); + + it('does not convert tables inside code fences', () => { + const input = [ + '```', + '| Name | Age |', + '| --- | --- |', + '| Alice | 30 |', + '```', + ].join('\n'); + const result = convertTables(input); + expect(result).toBe(input); + }); + + it('handles table with surrounding text', () => { + const input = [ + 'Before', + '| A | B |', + '| --- | --- |', + '| 1 | 2 |', + 'After', + ].join('\n'); + const result = convertTables(input); + expect(result).toContain('Before'); + expect(result).toContain('After'); + expect(result).toContain('A | B'); + }); + + it('handles table with alignment colons in separator', () => { + const input = [ + '| Left | Center | Right |', + '| :--- | :---: | ---: |', + '| a | b | c |', + ].join('\n'); + const result = convertTables(input); + expect(result).not.toContain(':---'); + }); + }); + + describe('splitChunks', () => { + it('returns single chunk for short text', () => { + expect(splitChunks('short text')).toEqual(['short text']); + }); + + it('returns single chunk for empty text', () => { + expect(splitChunks('')).toEqual(['']); + }); + + it('splits long text into chunks', () => { + const line = 'a'.repeat(100) + '\n'; + const text = line.repeat(50); // 5050 chars > 3800 + const chunks = splitChunks(text); + expect(chunks.length).toBeGreaterThan(1); + chunks.forEach((chunk) => { + expect(chunk.length).toBeLessThanOrEqual(3900); // allow small overhead + }); + }); + + it('closes and reopens code fences across boundaries', () => { + const longCode = '```\n' + 'x\n'.repeat(2000) + '```'; + const chunks = splitChunks(longCode); + expect(chunks.length).toBeGreaterThan(1); + // First chunk should end with closing fence + expect(chunks[0]).toContain('```'); + // Second chunk should start with opening fence + if (chunks.length > 1) { + expect(chunks[1]!.trimStart().startsWith('```')).toBe(true); + } + }); + }); + + describe('extractTitle', () => { + it('extracts title from first line', () => { + expect(extractTitle('Hello World\nmore text')).toBe('Hello World'); + }); + + it('strips markdown heading markers', () => { + expect(extractTitle('## My Title\ncontent')).toBe('My Title'); + }); + + it('strips bold/list markers', () => { + expect(extractTitle('* Item one')).toBe('Item one'); + expect(extractTitle('> Quote text')).toBe('Quote text'); + }); + + it('truncates to 20 chars', () => { + expect( + extractTitle('This is a very long title that should be truncated') + .length, + ).toBeLessThanOrEqual(20); + }); + + it('returns Reply for empty text', () => { + expect(extractTitle('')).toBe('Reply'); + expect(extractTitle('###')).toBe('Reply'); + }); + }); + + describe('normalizeDingTalkMarkdown', () => { + it('converts tables and splits into chunks', () => { + const input = ['| A | B |', '| --- | --- |', '| 1 | 2 |'].join('\n'); + const result = normalizeDingTalkMarkdown(input); + expect(result.length).toBeGreaterThanOrEqual(1); + expect(result[0]).not.toContain('---'); + }); + + it('passes through plain text', () => { + const result = normalizeDingTalkMarkdown('simple text'); + expect(result).toEqual(['simple text']); + }); + }); +}); diff --git a/packages/channels/dingtalk/src/markdown.ts b/packages/channels/dingtalk/src/markdown.ts new file mode 100644 index 0000000000..751f5d2b88 --- /dev/null +++ b/packages/channels/dingtalk/src/markdown.ts @@ -0,0 +1,130 @@ +/** + * DingTalk markdown normalization. + * + * DingTalk's markdown renderer is a limited subset with quirks: + * - Tables don't render — convert to pipe-separated plain text + * - Max message length ~3800 chars — split into chunks + * - Code fences must be closed/reopened across chunk boundaries + */ + +const CHUNK_LIMIT = 3800; + +// --- Table conversion --- + +function isTableSeparator(line: string): boolean { + const trimmed = line.trim(); + if (!trimmed.includes('-')) return false; + const cells = trimmed + .replace(/^\|/, '') + .replace(/\|$/, '') + .split('|') + .map((c) => c.trim()); + return cells.length > 0 && cells.every((c) => /^:?-{3,}:?$/.test(c)); +} + +function isTableRow(line: string): boolean { + const trimmed = line.trim(); + return trimmed.includes('|') && !trimmed.startsWith('```'); +} + +function parseTableRow(line: string): string[] { + return line + .trim() + .replace(/^\|/, '') + .replace(/\|$/, '') + .split('|') + .map((c) => c.trim()); +} + +function renderTable(lines: string[]): string { + const rows = lines.map(parseTableRow).filter((cells) => cells.length > 0); + return rows.map((cells) => cells.join(' | ')).join(' \n'); +} + +export function convertTables(text: string): string { + const lines = text.split('\n'); + const output: string[] = []; + let i = 0; + let inCode = false; + + while (i < lines.length) { + const line = lines[i] || ''; + if (line.trim().startsWith('```')) { + inCode = !inCode; + output.push(line); + i++; + continue; + } + + if ( + !inCode && + i + 1 < lines.length && + isTableRow(line) && + isTableSeparator(lines[i + 1] || '') + ) { + const tableLines = [line]; + i += 2; // skip header + separator + while (i < lines.length && isTableRow(lines[i] || '')) { + tableLines.push(lines[i] || ''); + i++; + } + output.push(renderTable(tableLines)); + continue; + } + + output.push(line); + i++; + } + + return output.join('\n'); +} + +// --- Chunk splitting --- + +export function splitChunks(text: string): string[] { + if (!text || text.length <= CHUNK_LIMIT) { + return [text]; + } + + const chunks: string[] = []; + let buf = ''; + const lines = text.split('\n'); + let inCode = false; + + for (const line of lines) { + const fenceCount = (line.match(/```/g) || []).length; + + if (buf.length + line.length + 1 > CHUNK_LIMIT && buf.length > 0) { + if (inCode) { + buf += '\n```'; + } + chunks.push(buf); + buf = inCode ? '```\n' : ''; + } + + buf += (buf ? '\n' : '') + line; + + if (fenceCount % 2 === 1) { + inCode = !inCode; + } + } + + if (buf) { + chunks.push(buf); + } + + return chunks; +} + +/** Extract a short title from the first line of markdown for the webhook payload. */ +export function extractTitle(text: string): string { + const firstLine = text.split('\n')[0] || ''; + const cleaned = firstLine.replace(/^[#*\s\->]+/, '').slice(0, 20); + return cleaned || 'Reply'; +} + +/** Full normalization pipeline: tables → chunks. */ +export function normalizeDingTalkMarkdown(text: string): string[] { + const converted = convertTables(text); + return splitChunks(converted); +} diff --git a/packages/channels/dingtalk/src/media.ts b/packages/channels/dingtalk/src/media.ts new file mode 100644 index 0000000000..19396a9492 --- /dev/null +++ b/packages/channels/dingtalk/src/media.ts @@ -0,0 +1,85 @@ +/** + * DingTalk media download helpers. + * + * Two-step flow: + * 1. POST downloadCode to DingTalk API → get a temporary downloadUrl + * 2. GET the downloadUrl → arraybuffer + */ + +const DOWNLOAD_API = + 'https://api.dingtalk.com/v1.0/robot/messageFiles/download'; + +export interface MediaFile { + buffer: Buffer; + mimeType: string; +} + +/** + * Download a media file from DingTalk using a downloadCode. + * + * @param downloadCode - The code from incoming message richText/content + * @param robotCode - The bot's clientId (appKey) + * @param accessToken - A valid DingTalk access token + * @returns MediaFile with buffer and mimeType, or null on failure + */ +export async function downloadMedia( + downloadCode: string, + robotCode: string, + accessToken: string, +): Promise { + if (!downloadCode || !robotCode || !accessToken) { + return null; + } + + try { + // Step 1: Get downloadUrl from DingTalk API + const apiResp = await fetch(DOWNLOAD_API, { + method: 'POST', + headers: { + 'x-acs-dingtalk-access-token': accessToken, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ downloadCode, robotCode }), + }); + + if (!apiResp.ok) { + const detail = await apiResp.text().catch(() => ''); + process.stderr.write( + `[DingTalk] downloadMedia API failed: HTTP ${apiResp.status} ${detail}\n`, + ); + return null; + } + + const payload = (await apiResp.json()) as Record; + const downloadUrl = + (payload['downloadUrl'] as string) ?? + ((payload['data'] as Record)?.['downloadUrl'] as string); + + if (!downloadUrl) { + process.stderr.write( + `[DingTalk] downloadMedia: no downloadUrl in response\n`, + ); + return null; + } + + // Step 2: Download the actual file + const fileResp = await fetch(downloadUrl); + if (!fileResp.ok) { + process.stderr.write( + `[DingTalk] downloadMedia file fetch failed: HTTP ${fileResp.status}\n`, + ); + return null; + } + + const mimeType = + fileResp.headers.get('content-type') || 'application/octet-stream'; + const buffer = Buffer.from(await fileResp.arrayBuffer()); + + return { buffer, mimeType }; + } catch (err) { + process.stderr.write( + `[DingTalk] downloadMedia error: ${err instanceof Error ? err.message : err}\n`, + ); + return null; + } +} diff --git a/packages/channels/dingtalk/tsconfig.json b/packages/channels/dingtalk/tsconfig.json new file mode 100644 index 0000000000..30e3324c83 --- /dev/null +++ b/packages/channels/dingtalk/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"], + "references": [{ "path": "../base" }] +} diff --git a/packages/channels/dingtalk/vitest.config.ts b/packages/channels/dingtalk/vitest.config.ts new file mode 100644 index 0000000000..bfaebe3ce6 --- /dev/null +++ b/packages/channels/dingtalk/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + globals: true, + }, +}); diff --git a/packages/channels/plugin-example/README.md b/packages/channels/plugin-example/README.md new file mode 100644 index 0000000000..a174610800 --- /dev/null +++ b/packages/channels/plugin-example/README.md @@ -0,0 +1,104 @@ +# @qwen-code/channel-plugin-example + +A reference channel plugin for Qwen Code. It connects to a WebSocket server and routes messages through the full channel pipeline (access control, session routing, agent bridge). + +Use this package to: + +- **Try out the channel plugin system** — install it as an extension and run it with the built-in mock server +- **Use it as a starting point** — fork the source to build your own channel adapter (see the [Channel Plugin Developer Guide](../../docs/developers/channel-plugins.md)) + +## Quick start + +### 1. Install the package + +```bash +npm install @qwen-code/channel-plugin-example +``` + +### 2. Link it as a Qwen Code extension + +The package ships a `qwen-extension.json` manifest, so it works as an extension out of the box: + +```bash +qwen extensions link ./node_modules/@qwen-code/channel-plugin-example +``` + +### 3. Configure the channel + +Add a channel entry to `~/.qwen/settings.json`: + +```json +{ + "channels": { + "my-plugin-test": { + "type": "plugin-example", + "serverWsUrl": "ws://localhost:9201", + "senderPolicy": "open", + "sessionScope": "user", + "cwd": "/path/to/your/project" + } + } +} +``` + +### 4. Start the mock server + +```bash +npx qwen-channel-plugin-example-server +``` + +The server prints the HTTP and WebSocket URLs. You can customize ports with environment variables: + +```bash +HTTP_PORT=8080 WS_PORT=8081 npx qwen-channel-plugin-example-server +``` + +### 5. Start the channel + +In a separate terminal: + +```bash +qwen channel start my-plugin-test +``` + +### 6. Send a message + +```bash +curl -sX POST http://localhost:9200/message \ + -H 'Content-Type: application/json' \ + -d '{"senderId":"user1","senderName":"Tester","text":"What is 2+2?"}' +``` + +You should get a JSON response with the agent's reply. + +## How it works + +``` +Mock Server (HTTP + WS) + ↕ WebSocket +MockPluginChannel (this package) + → Envelope → ChannelBase.handleInbound() + → SenderGate → SessionRouter → AcpBridge.prompt() + → qwen-code agent → model API + ← response + ← sendMessage() → WebSocket → Mock Server + ← HTTP response +``` + +## Building your own channel + +See `src/MockPluginChannel.ts` for a working example. The key points: + +1. Extend `ChannelBase` and implement `connect()`, `sendMessage()`, `disconnect()` +2. Build an `Envelope` from incoming platform messages and call `this.handleInbound(envelope)` +3. Export a `plugin` object conforming to `ChannelPlugin` +4. Add a `qwen-extension.json` manifest + +### Features you get for free + +- **Block streaming** — enable `blockStreaming: "on"` in config and the agent's response is automatically split into multiple messages at paragraph boundaries +- **Attachments** — populate `envelope.attachments` with images/files and `handleInbound()` routes them to the agent (images as vision input, files as paths in the prompt) +- **Streaming hooks** — override `onResponseChunk()` for progressive display (e.g., editing a message in-place) +- Access control (allowlist, pairing, open), session routing, slash commands, crash recovery + +Full guide: [Channel Plugin Developer Guide](../../docs/developers/channel-plugins.md) diff --git a/packages/channels/plugin-example/package.json b/packages/channels/plugin-example/package.json new file mode 100644 index 0000000000..c973892e54 --- /dev/null +++ b/packages/channels/plugin-example/package.json @@ -0,0 +1,32 @@ +{ + "name": "@qwen-code/channel-plugin-example", + "version": "0.13.0", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "src", + "qwen-extension.json" + ], + "bin": { + "qwen-channel-plugin-example-server": "dist/start-server.js" + }, + "scripts": { + "build": "tsc --build", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "@qwen-code/channel-base": "file:../base", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/ws": "^8.5.0" + } +} diff --git a/packages/channels/plugin-example/qwen-extension.json b/packages/channels/plugin-example/qwen-extension.json new file mode 100644 index 0000000000..fdfff08ff5 --- /dev/null +++ b/packages/channels/plugin-example/qwen-extension.json @@ -0,0 +1,10 @@ +{ + "name": "qwen-channel-plugin-example", + "version": "0.1.0", + "channels": { + "plugin-example": { + "entry": "dist/index.js", + "displayName": "Plugin Example Channel" + } + } +} diff --git a/packages/channels/plugin-example/src/MockPluginChannel.ts b/packages/channels/plugin-example/src/MockPluginChannel.ts new file mode 100644 index 0000000000..a4f856350d --- /dev/null +++ b/packages/channels/plugin-example/src/MockPluginChannel.ts @@ -0,0 +1,137 @@ +import { ChannelBase } from '@qwen-code/channel-base'; +import type { + ChannelConfig, + ChannelBaseOptions, + Envelope, + AcpBridge, +} from '@qwen-code/channel-base'; +import WebSocket from 'ws'; +import type { + InboundMessage, + OutboundMessage, + ChunkMessage, +} from './protocol.js'; + +export interface MockPluginConfig extends ChannelConfig { + serverWsUrl: string; +} + +export class MockPluginChannel extends ChannelBase { + private ws: WebSocket | null = null; + private serverWsUrl: string; + private pendingMessageId: string | undefined; + + constructor( + name: string, + config: MockPluginConfig & Record, + bridge: AcpBridge, + options?: ChannelBaseOptions, + ) { + super(name, config, bridge, options); + this.serverWsUrl = config.serverWsUrl; + } + + async connect(): Promise { + return new Promise((resolve, reject) => { + this.ws = new WebSocket(this.serverWsUrl); + + this.ws.on('open', () => { + resolve(); + }); + + this.ws.on('message', (data: Buffer) => { + try { + const msg = JSON.parse(data.toString()) as InboundMessage; + if (msg.type === 'inbound') { + this.onInboundMessage(msg); + } + } catch { + // ignore parse errors + } + }); + + this.ws.on('close', () => { + this.ws = null; + }); + + this.ws.on('error', (err: Error) => { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + reject(err); + } + }); + }); + } + + private onInboundMessage(msg: InboundMessage): void { + const envelope: Envelope = { + channelName: this.name, + senderId: msg.senderId, + senderName: msg.senderName, + chatId: msg.chatId, + text: msg.text, + messageId: msg.messageId, + isGroup: false, + isMentioned: false, + isReplyToBot: false, + }; + + this.handleInbound(envelope).catch(() => { + // errors handled internally by ChannelBase + }); + } + + protected override onResponseChunk( + chatId: string, + chunk: string, + _sessionId: string, + ): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return; + + const msg: ChunkMessage = { + type: 'chunk', + messageId: this.pendingMessageId || 'unknown', + chatId, + text: chunk, + }; + this.ws.send(JSON.stringify(msg)); + } + + protected override async onResponseComplete( + chatId: string, + fullText: string, + _sessionId: string, + ): Promise { + await this.sendMessage(chatId, fullText); + } + + async sendMessage(chatId: string, text: string): Promise { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + return; + } + + const outbound: OutboundMessage = { + type: 'outbound', + messageId: this.pendingMessageId || 'unknown', + chatId, + text, + }; + + this.ws.send(JSON.stringify(outbound)); + } + + disconnect(): void { + if (this.ws) { + this.ws.close(); + this.ws = null; + } + } + + override async handleInbound(envelope: Envelope): Promise { + this.pendingMessageId = envelope.messageId; + try { + await super.handleInbound(envelope); + } finally { + this.pendingMessageId = undefined; + } + } +} diff --git a/packages/channels/plugin-example/src/index.ts b/packages/channels/plugin-example/src/index.ts new file mode 100644 index 0000000000..d5734298e5 --- /dev/null +++ b/packages/channels/plugin-example/src/index.ts @@ -0,0 +1,21 @@ +import type { ChannelPlugin } from '@qwen-code/channel-base'; +import { MockPluginChannel } from './MockPluginChannel.js'; + +export { MockPluginChannel } from './MockPluginChannel.js'; +export type { MockPluginConfig } from './MockPluginChannel.js'; +export { createMockServer } from './mock-server.js'; +export type { MockServerHandle, MockServerOptions } from './mock-server.js'; +export type { InboundMessage, OutboundMessage, WsMessage } from './protocol.js'; + +export const plugin: ChannelPlugin = { + channelType: 'plugin-example', + displayName: 'Plugin Example', + requiredConfigFields: ['serverWsUrl'], + createChannel: (name, config, bridge, options) => + new MockPluginChannel( + name, + config as typeof config & { serverWsUrl: string }, + bridge, + options, + ), +}; diff --git a/packages/channels/plugin-example/src/mock-server.ts b/packages/channels/plugin-example/src/mock-server.ts new file mode 100644 index 0000000000..450ded322a --- /dev/null +++ b/packages/channels/plugin-example/src/mock-server.ts @@ -0,0 +1,282 @@ +/** + * Mock Platform Server — programmatic API for integration tests. + * + * Provides a createMockServer() function that starts HTTP + WebSocket servers + * and returns a handle for sending messages and cleaning up. + * + * Architecture: + * Test code calls server.sendMessage("Hello") + * → HTTP handler creates messageId, pushes via WebSocket to connected channel + * → Channel processes → responds via WebSocket + * → Server resolves the pending promise with agent response text + */ + +import http from 'node:http'; +import crypto from 'node:crypto'; +import { WebSocketServer, WebSocket } from 'ws'; + +export interface MockServerHandle { + /** Port the HTTP server is listening on */ + httpPort: number; + /** Port the WebSocket server is listening on */ + wsPort: number; + /** WebSocket URL for channels to connect to */ + wsUrl: string; + /** Send a message through the full pipeline and wait for the agent response */ + sendMessage( + text: string, + options?: { senderId?: string; senderName?: string; chatId?: string }, + ): Promise; + /** Wait for a plugin channel to connect */ + waitForConnection(timeoutMs?: number): Promise; + /** Shut down both servers and reject pending requests */ + close(): Promise; +} + +export interface MockServerOptions { + /** HTTP port (0 = random available port) */ + httpPort?: number; + /** WebSocket port (0 = random available port) */ + wsPort?: number; + /** Timeout for agent responses in ms (default: 120000) */ + responseTimeoutMs?: number; +} + +export function createMockServer( + options?: MockServerOptions, +): Promise { + const responseTimeoutMs = options?.responseTimeoutMs ?? 120_000; + + let pluginWs: WebSocket | null = null; + let connectionResolver: (() => void) | null = null; + + const pendingRequests = new Map< + string, + { + resolve: (result: { text: string; chunks: string[] }) => void; + reject: (err: Error) => void; + timer: ReturnType; + chunks: string[]; + } + >(); + + // --- WebSocket server --- + const wss = new WebSocketServer({ port: options?.wsPort ?? 0 }); + + wss.on('connection', (ws) => { + pluginWs = ws; + + if (connectionResolver) { + connectionResolver(); + connectionResolver = null; + } + + ws.on('message', (data) => { + try { + const msg = JSON.parse(data.toString()); + if (msg.type === 'chunk' && msg.messageId) { + const pending = pendingRequests.get(msg.messageId); + if (pending) { + pending.chunks.push(msg.text); + } + } else if (msg.type === 'outbound' && msg.messageId) { + const pending = pendingRequests.get(msg.messageId); + if (pending) { + clearTimeout(pending.timer); + pendingRequests.delete(msg.messageId); + pending.resolve({ text: msg.text, chunks: pending.chunks }); + } + } + } catch { + // ignore + } + }); + + ws.on('close', () => { + if (pluginWs === ws) pluginWs = null; + }); + }); + + // --- HTTP server --- + const httpServer = http.createServer((req, res) => { + if (req.method === 'GET' && req.url === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + status: 'ok', + pluginConnected: + pluginWs !== null && pluginWs.readyState === WebSocket.OPEN, + }), + ); + return; + } + + if (req.method === 'POST' && req.url === '/message') { + let body = ''; + req.on('data', (chunk: Buffer) => { + body += chunk.toString(); + }); + req.on('end', () => { + try { + const { senderId, senderName, chatId, text } = JSON.parse(body); + if (!senderId || !text) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ error: 'senderId and text are required' }), + ); + return; + } + if (!pluginWs || pluginWs.readyState !== WebSocket.OPEN) { + res.writeHead(503, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Plugin channel not connected' })); + return; + } + + const messageId = crypto.randomUUID(); + pluginWs.send( + JSON.stringify({ + type: 'inbound', + messageId, + senderId, + senderName: senderName || senderId, + chatId: chatId || `dm-${senderId}`, + text, + }), + ); + + const responsePromise = new Promise<{ + text: string; + chunks: string[]; + }>((resolve, reject) => { + const timer = setTimeout(() => { + pendingRequests.delete(messageId); + reject(new Error('Timeout waiting for agent response')); + }, responseTimeoutMs); + pendingRequests.set(messageId, { + resolve, + reject, + timer, + chunks: [], + }); + }); + + responsePromise + .then((result) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + messageId, + text: result.text, + streaming: { + chunks: result.chunks.length, + bytes: result.chunks.reduce((n, c) => n + c.length, 0), + }, + }), + ); + }) + .catch((err: Error) => { + res.writeHead(504, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: err.message })); + }); + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid JSON body' })); + } + }); + return; + } + + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not found' })); + }); + + // Start both servers and return the handle + return new Promise((resolve, reject) => { + const wsAddress = wss.address(); + if (!wsAddress || typeof wsAddress === 'string') { + reject(new Error('WebSocket server failed to bind')); + return; + } + const wsPort = wsAddress.port; + + httpServer.listen(options?.httpPort ?? 0, () => { + const httpAddress = httpServer.address(); + if (!httpAddress || typeof httpAddress === 'string') { + reject(new Error('HTTP server failed to bind')); + return; + } + const httpPort = httpAddress.port; + + const handle: MockServerHandle = { + httpPort, + wsPort, + wsUrl: `ws://localhost:${wsPort}`, + + async sendMessage(text, opts) { + const senderId = opts?.senderId || 'test-user'; + const senderName = opts?.senderName || 'Test User'; + const chatId = opts?.chatId || `dm-${senderId}`; + + if (!pluginWs || pluginWs.readyState !== WebSocket.OPEN) { + throw new Error('Plugin channel not connected'); + } + + const messageId = crypto.randomUUID(); + pluginWs.send( + JSON.stringify({ + type: 'inbound', + messageId, + senderId, + senderName, + chatId, + text, + }), + ); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pendingRequests.delete(messageId); + reject(new Error('Timeout waiting for agent response')); + }, responseTimeoutMs); + pendingRequests.set(messageId, { + resolve: (result) => resolve(result.text), + reject, + timer, + chunks: [], + }); + }); + }, + + async waitForConnection(timeoutMs = 10_000) { + if (pluginWs && pluginWs.readyState === WebSocket.OPEN) return; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error('Timeout waiting for channel connection')); + }, timeoutMs); + connectionResolver = () => { + clearTimeout(timer); + resolve(); + }; + }); + }, + + async close() { + for (const [, pending] of pendingRequests) { + clearTimeout(pending.timer); + pending.reject(new Error('Server shutting down')); + } + pendingRequests.clear(); + + await new Promise((r) => { + wss.close(() => r()); + }); + await new Promise((r) => { + httpServer.close(() => r()); + }); + }, + }; + + resolve(handle); + }); + }); +} diff --git a/packages/channels/plugin-example/src/protocol.ts b/packages/channels/plugin-example/src/protocol.ts new file mode 100644 index 0000000000..25eee90343 --- /dev/null +++ b/packages/channels/plugin-example/src/protocol.ts @@ -0,0 +1,31 @@ +/** + * Shared protocol types for mock channel WebSocket communication. + */ + +/** Server → Plugin Channel (WebSocket) */ +export interface InboundMessage { + type: 'inbound'; + messageId: string; + senderId: string; + senderName: string; + chatId: string; + text: string; +} + +/** Plugin Channel → Server (WebSocket) — streaming chunk */ +export interface ChunkMessage { + type: 'chunk'; + messageId: string; + chatId: string; + text: string; +} + +/** Plugin Channel → Server (WebSocket) — final response */ +export interface OutboundMessage { + type: 'outbound'; + messageId: string; + chatId: string; + text: string; +} + +export type WsMessage = InboundMessage | ChunkMessage | OutboundMessage; diff --git a/packages/channels/plugin-example/src/start-server.ts b/packages/channels/plugin-example/src/start-server.ts new file mode 100644 index 0000000000..c033a13438 --- /dev/null +++ b/packages/channels/plugin-example/src/start-server.ts @@ -0,0 +1,36 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ +/** + * Start the mock WebSocket server for testing the plugin-example channel. + * + * Usage: + * npx qwen-channel-plugin-example-server + * # or + * node node_modules/@qwen-code/channel-plugin-example/dist/start-server.js + * + * Environment variables: + * HTTP_PORT (default: 9200) + * WS_PORT (default: 9201) + */ +import { createMockServer } from './mock-server.js'; + +const httpPort = parseInt(process.env['HTTP_PORT'] || '9200', 10); +const wsPort = parseInt(process.env['WS_PORT'] || '9201', 10); + +const server = await createMockServer({ httpPort, wsPort }); + +console.log(`Mock server running:`); +console.log(` HTTP: http://localhost:${server.httpPort}`); +console.log(` WS: ws://localhost:${server.wsPort}`); +console.log(); +console.log(`Send a test message:`); +console.log(` curl -sX POST http://localhost:${server.httpPort}/message \\`); +console.log(` -H 'Content-Type: application/json' \\`); +console.log( + ` -d '{"senderId":"user1","senderName":"Tester","text":"Hello"}'`, +); + +process.on('SIGINT', async () => { + await server.close(); + process.exit(0); +}); diff --git a/packages/channels/plugin-example/tsconfig.json b/packages/channels/plugin-example/tsconfig.json new file mode 100644 index 0000000000..8daf594089 --- /dev/null +++ b/packages/channels/plugin-example/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"], + "references": [{ "path": "../base" }] +} diff --git a/packages/channels/telegram/package.json b/packages/channels/telegram/package.json new file mode 100644 index 0000000000..1d18623fae --- /dev/null +++ b/packages/channels/telegram/package.json @@ -0,0 +1,28 @@ +{ + "name": "@qwen-code/channel-telegram", + "version": "0.13.0", + "description": "Telegram channel adapter for Qwen Code", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc --build" + }, + "dependencies": { + "@qwen-code/channel-base": "file:../base", + "grammy": "^1.41.1", + "telegram-markdown-formatter": "^0.1.2" + }, + "devDependencies": { + "typescript": "^5.0.0" + } +} diff --git a/packages/channels/telegram/src/TelegramAdapter.ts b/packages/channels/telegram/src/TelegramAdapter.ts new file mode 100644 index 0000000000..2de28b08ca --- /dev/null +++ b/packages/channels/telegram/src/TelegramAdapter.ts @@ -0,0 +1,248 @@ +import { mkdirSync, writeFileSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { basename, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { Bot } from 'grammy'; +import { + telegramFormat, + splitHtmlForTelegram, +} from 'telegram-markdown-formatter'; +import { ChannelBase } from '@qwen-code/channel-base'; +import type { + ChannelConfig, + ChannelBaseOptions, + Envelope, + AcpBridge, +} from '@qwen-code/channel-base'; + +export class TelegramChannel extends ChannelBase { + private bot: Bot; + private botId: number = 0; + private botUsername: string = ''; + + constructor( + name: string, + config: ChannelConfig, + bridge: AcpBridge, + options?: ChannelBaseOptions, + ) { + super(name, config, bridge, options); + this.bot = new Bot(config.token); + } + + private getFileUrl(filePath: string): string { + return `https://api.telegram.org/file/bot${this.bot.token}/${filePath}`; + } + + async connect(): Promise { + const botInfo = await this.bot.api.getMe(); + this.botId = botInfo.id; + this.botUsername = botInfo.username ?? ''; + // All messages (including slash commands) go through handleInbound + // where ChannelBase dispatches shared commands (/help, /clear, /status, etc.) + this.bot.on('message:text', async (ctx) => { + const msg = ctx.message; + const text = msg.text; + + const envelope = this.buildEnvelope(msg, text, msg.entities); + + // Don't await — long prompts would block the update loop + this.handleInbound(envelope).catch((err) => { + process.stderr.write( + `[Telegram:${this.name}] Error handling message: ${err}\n`, + ); + ctx + .reply('Sorry, something went wrong processing your message.') + .catch(() => {}); + }); + }); + + // Photo messages + this.bot.on('message:photo', async (ctx) => { + const msg = ctx.message; + const envelope = this.buildEnvelope( + msg, + msg.caption || '(image)', + msg.caption_entities, + ); + + // Pick the largest photo size (last in array) + const photo = msg.photo[msg.photo.length - 1]; + if (!photo) return; + + try { + const file = await ctx.api.getFile(photo.file_id); + const fileUrl = this.getFileUrl(file.file_path!); + const resp = await fetch(fileUrl); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const buf = Buffer.from(await resp.arrayBuffer()); + envelope.imageBase64 = buf.toString('base64'); + envelope.imageMimeType = 'image/jpeg'; // Telegram always converts photos to JPEG + } catch (err) { + process.stderr.write( + `[Telegram:${this.name}] Failed to download photo: ${err instanceof Error ? err.message : err}\n`, + ); + } + + this.handleInbound(envelope).catch((err) => { + process.stderr.write( + `[Telegram:${this.name}] Error handling message: ${err}\n`, + ); + ctx + .reply('Sorry, something went wrong processing your message.') + .catch(() => {}); + }); + }); + + // Document/file messages + this.bot.on('message:document', async (ctx) => { + const msg = ctx.message; + const doc = msg.document; + const fileName = doc.file_name || `file_${Date.now()}`; + + const envelope = this.buildEnvelope( + msg, + msg.caption || `(file: ${fileName})`, + msg.caption_entities, + ); + + try { + const file = await ctx.api.getFile(doc.file_id); + const fileUrl = this.getFileUrl(file.file_path!); + const resp = await fetch(fileUrl); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const buf = Buffer.from(await resp.arrayBuffer()); + + // Save to temp dir so the agent can read it via read-file tool + const dir = join(tmpdir(), 'channel-files', randomUUID()); + mkdirSync(dir, { recursive: true }); + const filePath = join(dir, basename(fileName) || `file_${Date.now()}`); + writeFileSync(filePath, buf); + + envelope.text = msg.caption || ''; + envelope.attachments = [ + { + type: 'file', + filePath, + mimeType: doc.mime_type || 'application/octet-stream', + fileName, + }, + ]; + } catch (err) { + process.stderr.write( + `[Telegram:${this.name}] Failed to download document: ${err instanceof Error ? err.message : err}\n`, + ); + envelope.text = + (msg.caption || '') + + `\n\n(User sent a file "${fileName}" but download failed)`; + } + + this.handleInbound(envelope).catch((err) => { + process.stderr.write( + `[Telegram:${this.name}] Error handling message: ${err}\n`, + ); + ctx + .reply('Sorry, something went wrong processing your message.') + .catch(() => {}); + }); + }); + + this.bot.start({ drop_pending_updates: true }).catch((err) => { + process.stderr.write( + `[Telegram:${this.name}] Bot launch error: ${err}\n`, + ); + }); + + process.once('SIGINT', () => this.bot.stop()); + process.once('SIGTERM', () => this.bot.stop()); + } + + /** Per-chat typing interval — repeats every 4s since Telegram expires it after 5s. */ + private typingIntervals = new Map>(); + + protected override onPromptStart(chatId: string): void { + // Clear any stale interval (shouldn't happen, but safe) + const existing = this.typingIntervals.get(chatId); + if (existing) clearInterval(existing); + + const sendTyping = () => + this.bot.api.sendChatAction(chatId, 'typing').catch(() => {}); + sendTyping(); + this.typingIntervals.set(chatId, setInterval(sendTyping, 4000)); + } + + protected override onPromptEnd(chatId: string): void { + const interval = this.typingIntervals.get(chatId); + if (interval) { + clearInterval(interval); + this.typingIntervals.delete(chatId); + } + } + + async sendMessage(chatId: string, text: string): Promise { + const html = telegramFormat(text); + const chunks = splitHtmlForTelegram(html); + for (const chunk of chunks) { + try { + await this.bot.api.sendMessage(chatId, chunk, { + parse_mode: 'HTML', + }); + } catch { + // Fallback to plain text if HTML parsing fails + await this.bot.api.sendMessage(chatId, text); + return; + } + } + } + + disconnect(): void { + this.bot.stop(); + } + + private buildEnvelope( + msg: { + from: { id: number; first_name: string; last_name?: string }; + chat: { id: number; type: string }; + reply_to_message?: { from?: { id: number }; text?: string }; + }, + text: string, + entities?: Array<{ type: string; offset: number; length: number }>, + ): Envelope { + const isGroup = msg.chat.type === 'group' || msg.chat.type === 'supergroup'; + + const isMentioned = + entities?.some( + (e) => + e.type === 'mention' && + this.botUsername && + text.slice(e.offset, e.offset + e.length).toLowerCase() === + `@${this.botUsername.toLowerCase()}`, + ) ?? false; + + const isReplyToBot = msg.reply_to_message?.from?.id === this.botId; + + let cleanText = text; + if (isMentioned && this.botUsername) { + cleanText = text + .replace(new RegExp(`@${this.botUsername}`, 'gi'), '') + .trim(); + } + + // Extract referenced message text (when user replies to a message) + const referencedText = msg.reply_to_message?.text || undefined; + + return { + channelName: this.name, + senderId: String(msg.from.id), + senderName: + msg.from.first_name + + (msg.from.last_name ? ` ${msg.from.last_name}` : ''), + chatId: String(msg.chat.id), + text: cleanText, + isGroup, + isMentioned, + isReplyToBot, + referencedText, + }; + } +} diff --git a/packages/channels/telegram/src/index.ts b/packages/channels/telegram/src/index.ts new file mode 100644 index 0000000000..97426548d6 --- /dev/null +++ b/packages/channels/telegram/src/index.ts @@ -0,0 +1,12 @@ +export { TelegramChannel } from './TelegramAdapter.js'; + +import { TelegramChannel } from './TelegramAdapter.js'; +import type { ChannelPlugin } from '@qwen-code/channel-base'; + +export const plugin: ChannelPlugin = { + channelType: 'telegram', + displayName: 'Telegram', + requiredConfigFields: ['token'], + createChannel: (name, config, bridge, options) => + new TelegramChannel(name, config, bridge, options), +}; diff --git a/packages/channels/telegram/tsconfig.json b/packages/channels/telegram/tsconfig.json new file mode 100644 index 0000000000..30e3324c83 --- /dev/null +++ b/packages/channels/telegram/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"], + "references": [{ "path": "../base" }] +} diff --git a/packages/channels/telegram/vitest.config.ts b/packages/channels/telegram/vitest.config.ts new file mode 100644 index 0000000000..bfaebe3ce6 --- /dev/null +++ b/packages/channels/telegram/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + globals: true, + }, +}); diff --git a/packages/channels/weixin/package.json b/packages/channels/weixin/package.json new file mode 100644 index 0000000000..f0a35a8533 --- /dev/null +++ b/packages/channels/weixin/package.json @@ -0,0 +1,34 @@ +{ + "name": "@qwen-code/channel-weixin", + "version": "0.13.0", + "description": "WeChat (Weixin) channel adapter for Qwen Code", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./accounts": { + "types": "./dist/accounts.d.ts", + "default": "./dist/accounts.js" + }, + "./login": { + "types": "./dist/login.d.ts", + "default": "./dist/login.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc --build" + }, + "dependencies": { + "@qwen-code/channel-base": "file:../base" + }, + "devDependencies": { + "typescript": "^5.0.0" + } +} diff --git a/packages/channels/weixin/src/WeixinAdapter.ts b/packages/channels/weixin/src/WeixinAdapter.ts new file mode 100644 index 0000000000..7a5b36b974 --- /dev/null +++ b/packages/channels/weixin/src/WeixinAdapter.ts @@ -0,0 +1,223 @@ +/** + * WeChat channel adapter for Qwen Code. + * Extends ChannelBase with WeChat iLink Bot API integration. + */ + +import { mkdirSync, writeFileSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { basename, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { ChannelBase } from '@qwen-code/channel-base'; +import type { + ChannelConfig, + ChannelBaseOptions, + Envelope, + AcpBridge, +} from '@qwen-code/channel-base'; +import { loadAccount, DEFAULT_BASE_URL } from './accounts.js'; +import { startPollLoop, getContextToken } from './monitor.js'; +import type { CdnRef, FileCdnRef } from './monitor.js'; +import { sendText } from './send.js'; +import { downloadAndDecrypt } from './media.js'; +import { getConfig, sendTyping } from './api.js'; +import { TypingStatus } from './types.js'; + +/** In-memory typing ticket cache: userId -> typingTicket */ +const typingTickets = new Map(); + +export class WeixinChannel extends ChannelBase { + private abortController: AbortController | null = null; + private baseUrl: string; + private token: string = ''; + + constructor( + name: string, + config: ChannelConfig, + bridge: AcpBridge, + options?: ChannelBaseOptions, + ) { + super(name, config, bridge, options); + this.baseUrl = + (config as ChannelConfig & { baseUrl?: string }).baseUrl || + DEFAULT_BASE_URL; + } + + async connect(): Promise { + const account = loadAccount(); + if (!account) { + throw new Error( + 'WeChat account not configured. Run "qwen channel configure-weixin" first.', + ); + } + this.token = account.token; + if (account.baseUrl) { + this.baseUrl = account.baseUrl; + } + + this.abortController = new AbortController(); + + startPollLoop({ + baseUrl: this.baseUrl, + token: this.token, + onMessage: async (msg) => { + const envelope: Envelope = { + channelName: this.name, + senderId: msg.fromUserId, + senderName: msg.fromUserId, + chatId: msg.fromUserId, + text: msg.text, + isGroup: false, + isMentioned: false, + isReplyToBot: false, + referencedText: msg.refText, + }; + + this.handleInboundWithMedia(envelope, msg.image, msg.file).catch( + (err) => { + const errMsg = + err instanceof Error ? err.message : JSON.stringify(err, null, 2); + process.stderr.write( + `[Weixin:${this.name}] Error handling message: ${errMsg}\n`, + ); + }, + ); + }, + abortSignal: this.abortController.signal, + }).catch((err) => { + if (!this.abortController?.signal.aborted) { + process.stderr.write(`[Weixin:${this.name}] Poll loop error: ${err}\n`); + } + }); + + process.stderr.write( + `[Weixin:${this.name}] Connected to WeChat (${this.baseUrl})\n`, + ); + } + + protected override onPromptStart(chatId: string): void { + this.setTyping(chatId, true).catch(() => {}); + } + + protected override onPromptEnd(chatId: string): void { + this.setTyping(chatId, false).catch(() => {}); + } + + private async handleInboundWithMedia( + envelope: Envelope, + image?: CdnRef, + file?: FileCdnRef, + ): Promise { + // Download image from CDN + if (image) { + try { + const imageData = await downloadAndDecrypt( + image.encryptQueryParam, + image.aesKey, + ); + envelope.imageBase64 = imageData.toString('base64'); + envelope.imageMimeType = detectImageMime(imageData); + } catch (err) { + process.stderr.write( + `[Weixin:${this.name}] Failed to download image: ${err instanceof Error ? err.message : err}\n`, + ); + } + } + + // Download file from CDN, save to temp dir + if (file) { + try { + const fileData = await downloadAndDecrypt( + file.encryptQueryParam, + file.aesKey, + ); + const dir = join(tmpdir(), 'channel-files', randomUUID()); + mkdirSync(dir, { recursive: true }); + const filePath = join( + dir, + basename(file.fileName) || `file_${Date.now()}`, + ); + writeFileSync(filePath, fileData); + envelope.attachments = [ + { + type: 'file', + filePath, + mimeType: 'application/octet-stream', + fileName: file.fileName, + }, + ]; + } catch (err) { + process.stderr.write( + `[Weixin:${this.name}] Failed to download file: ${err instanceof Error ? err.message : err}\n`, + ); + envelope.text = `(User sent a file "${file.fileName}" but download failed)`; + } + } + + await super.handleInbound(envelope); + } + + async sendMessage(chatId: string, text: string): Promise { + const contextToken = getContextToken(chatId) || ''; + await sendText({ + to: chatId, + text, + baseUrl: this.baseUrl, + token: this.token, + contextToken, + }); + } + + disconnect(): void { + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + } + + private async setTyping(userId: string, typing: boolean): Promise { + try { + let ticket = typingTickets.get(userId); + if (!ticket) { + const contextToken = getContextToken(userId); + const config = await getConfig( + this.baseUrl, + this.token, + userId, + contextToken, + ); + if (config.typing_ticket) { + ticket = config.typing_ticket; + typingTickets.set(userId, ticket); + } + } + if (!ticket) return; + + await sendTyping(this.baseUrl, this.token, { + ilink_user_id: userId, + typing_ticket: ticket, + status: typing ? TypingStatus.TYPING : TypingStatus.CANCEL, + }); + } catch { + // Typing is best-effort — don't fail the message flow + } + } +} + +/** Detect image MIME type from magic bytes. */ +function detectImageMime(data: Buffer): string { + if (data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e) { + return 'image/png'; + } + if (data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46) { + return 'image/gif'; + } + if ( + data[0] === 0x52 && + data[1] === 0x49 && + data[2] === 0x46 && + data[3] === 0x46 + ) { + return 'image/webp'; + } + return 'image/jpeg'; +} diff --git a/packages/channels/weixin/src/accounts.ts b/packages/channels/weixin/src/accounts.ts new file mode 100644 index 0000000000..c505b06ce1 --- /dev/null +++ b/packages/channels/weixin/src/accounts.ts @@ -0,0 +1,61 @@ +/** + * Credential storage for WeChat account. + * Stores account data in ~/.qwen/channels/weixin/ + */ + +import { + existsSync, + mkdirSync, + readFileSync, + writeFileSync, + unlinkSync, + chmodSync, +} from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +export const DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com'; + +export interface AccountData { + token: string; + baseUrl: string; + userId?: string; + savedAt: string; +} + +export function getStateDir(): string { + const dir = + process.env['WEIXIN_STATE_DIR'] || + join(homedir(), '.qwen', 'channels', 'weixin'); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + return dir; +} + +function accountPath(): string { + return join(getStateDir(), 'account.json'); +} + +export function loadAccount(): AccountData | null { + const p = accountPath(); + if (!existsSync(p)) return null; + try { + return JSON.parse(readFileSync(p, 'utf-8')) as AccountData; + } catch { + return null; + } +} + +export function saveAccount(data: AccountData): void { + const p = accountPath(); + writeFileSync(p, JSON.stringify(data, null, 2), 'utf-8'); + chmodSync(p, 0o600); +} + +export function clearAccount(): void { + const p = accountPath(); + if (existsSync(p)) { + unlinkSync(p); + } +} diff --git a/packages/channels/weixin/src/api.ts b/packages/channels/weixin/src/api.ts new file mode 100644 index 0000000000..f45ccfb303 --- /dev/null +++ b/packages/channels/weixin/src/api.ts @@ -0,0 +1,128 @@ +/** + * HTTP API wrapper for WeChat iLink Bot API. + */ + +import type { + GetUpdatesReq, + GetUpdatesResp, + SendMessageReq, + GetConfigResp, + SendTypingReq, + SendTypingResp, + BaseInfo, +} from './types.js'; + +const CHANNEL_VERSION = '0.1.0'; + +function baseInfo(): BaseInfo { + return { channel_version: CHANNEL_VERSION }; +} + +function randomUin(): string { + const buf = new Uint8Array(4); + crypto.getRandomValues(buf); + return btoa(String.fromCharCode(...buf)); +} + +function buildHeaders(token?: string): Record { + const headers: Record = { + 'Content-Type': 'application/json', + 'X-WECHAT-UIN': randomUin(), + }; + if (token) { + headers['AuthorizationType'] = 'ilink_bot_token'; + headers['Authorization'] = `Bearer ${token}`; + } + return headers; +} + +async function post( + baseUrl: string, + path: string, + body: unknown, + token?: string, + timeoutMs = 40000, + signal?: AbortSignal, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + if (signal) { + signal.addEventListener('abort', () => controller.abort(), { once: true }); + } + + try { + const resp = await fetch(`${baseUrl}${path}`, { + method: 'POST', + headers: buildHeaders(token), + body: JSON.stringify(body), + signal: controller.signal, + }); + if (!resp.ok) { + throw new Error(`HTTP ${resp.status}: ${resp.statusText}`); + } + return (await resp.json()) as T; + } finally { + clearTimeout(timeout); + } +} + +export async function getUpdates( + baseUrl: string, + token: string, + getUpdatesBuf: string, + timeoutMs = 40000, + signal?: AbortSignal, +): Promise { + const body: GetUpdatesReq = { + get_updates_buf: getUpdatesBuf, + base_info: baseInfo(), + }; + try { + return await post( + baseUrl, + '/ilink/bot/getupdates', + body, + token, + timeoutMs, + signal, + ); + } catch (err: unknown) { + if (err instanceof Error && err.name === 'AbortError') { + return { ret: 0, msgs: [], get_updates_buf: getUpdatesBuf }; + } + throw err; + } +} + +export async function sendMessage( + baseUrl: string, + token: string, + msg: SendMessageReq['msg'], +): Promise { + const body: SendMessageReq = { msg, base_info: baseInfo() }; + await post(baseUrl, '/ilink/bot/sendmessage', body, token); +} + +export async function getConfig( + baseUrl: string, + token: string, + userId: string, + contextToken?: string, +): Promise { + const body = { + ilink_user_id: userId, + context_token: contextToken, + base_info: baseInfo(), + }; + return post(baseUrl, '/ilink/bot/getconfig', body, token); +} + +export async function sendTyping( + baseUrl: string, + token: string, + req: Omit, +): Promise { + const body: SendTypingReq = { ...req, base_info: baseInfo() }; + return post(baseUrl, '/ilink/bot/sendtyping', body, token); +} diff --git a/packages/channels/weixin/src/index.ts b/packages/channels/weixin/src/index.ts new file mode 100644 index 0000000000..440c5c0a65 --- /dev/null +++ b/packages/channels/weixin/src/index.ts @@ -0,0 +1,11 @@ +export { WeixinChannel } from './WeixinAdapter.js'; + +import { WeixinChannel } from './WeixinAdapter.js'; +import type { ChannelPlugin } from '@qwen-code/channel-base'; + +export const plugin: ChannelPlugin = { + channelType: 'weixin', + displayName: 'WeChat', + createChannel: (name, config, bridge, options) => + new WeixinChannel(name, config, bridge, options), +}; diff --git a/packages/channels/weixin/src/login.ts b/packages/channels/weixin/src/login.ts new file mode 100644 index 0000000000..8771b23737 --- /dev/null +++ b/packages/channels/weixin/src/login.ts @@ -0,0 +1,112 @@ +/** + * QR code login flow for WeChat iLink Bot. + */ + +export interface LoginResult { + connected: boolean; + token?: string; + baseUrl?: string; + userId?: string; + message: string; +} + +/** Step 1: Get QR code from server and display in terminal */ +export async function startLogin(apiBaseUrl: string): Promise { + const resp = await fetch(`${apiBaseUrl}/ilink/bot/get_bot_qrcode?bot_type=3`); + if (!resp.ok) { + throw new Error(`Failed to get QR code: HTTP ${resp.status}`); + } + const data = (await resp.json()) as { + qrcode?: string; + qrcode_img_content?: string; + }; + + if (!data.qrcode) { + throw new Error('No qrcode in response'); + } + + if (data.qrcode_img_content) { + process.stderr.write( + `QR code URL: ${data.qrcode_img_content}\nScan this URL with WeChat.\n`, + ); + } + + process.stderr.write('Scan the QR code with WeChat to connect.\n'); + return data.qrcode; +} + +/** Step 2: Poll for scan result */ +export async function waitForLogin(params: { + qrcodeId: string; + apiBaseUrl: string; + timeoutMs?: number; +}): Promise { + const { apiBaseUrl, timeoutMs = 480000 } = params; + let currentQrcodeId = params.qrcodeId; + const deadline = Date.now() + timeoutMs; + let retryCount = 0; + + while (Date.now() < deadline) { + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 60000); + + const resp = await fetch( + `${apiBaseUrl}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(currentQrcodeId)}`, + { + headers: { 'iLink-App-ClientVersion': '1' }, + signal: controller.signal, + }, + ); + clearTimeout(timeout); + + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + + const data = (await resp.json()) as { + status?: string; + bot_token?: string; + ilink_bot_id?: string; + baseurl?: string; + ilink_user_id?: string; + }; + + switch (data.status) { + case 'confirmed': + return { + connected: true, + token: data.bot_token, + baseUrl: data.baseurl, + userId: data.ilink_user_id, + message: 'Connected to WeChat successfully!', + }; + case 'scaned': + process.stderr.write( + 'QR code scanned, waiting for confirmation...\n', + ); + break; + case 'expired': + retryCount++; + if (retryCount >= 3) { + return { + connected: false, + message: 'QR code expired after maximum retries.', + }; + } + process.stderr.write('QR code expired, refreshing...\n'); + currentQrcodeId = await startLogin(apiBaseUrl); + break; + default: + break; + } + } catch (err: unknown) { + if (err instanceof Error && err.name === 'AbortError') { + continue; + } + throw err; + } + + await new Promise((r) => setTimeout(r, 1000)); + } + + return { connected: false, message: 'Login timed out.' }; +} diff --git a/packages/channels/weixin/src/media.test.ts b/packages/channels/weixin/src/media.test.ts new file mode 100644 index 0000000000..745c015549 --- /dev/null +++ b/packages/channels/weixin/src/media.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest'; +import { createDecipheriv, createCipheriv } from 'node:crypto'; + +/** + * Test the AES key parsing and decryption logic used in media.ts. + * We test the pure crypto functions by reimplementing them here + * since they're not exported, but the behavior must match. + */ + +function parseAesKey(aesKeyBase64: string): Buffer { + const decoded = Buffer.from(aesKeyBase64, 'base64'); + if (decoded.length === 16) { + return decoded; + } + if ( + decoded.length === 32 && + /^[0-9a-fA-F]{32}$/.test(decoded.toString('ascii')) + ) { + return Buffer.from(decoded.toString('ascii'), 'hex'); + } + throw new Error( + `Invalid aes_key: expected 16 raw bytes or 32 hex chars, got ${decoded.length} bytes`, + ); +} + +function decryptAesEcb(ciphertext: Buffer, key: Buffer): Buffer { + const decipher = createDecipheriv('aes-128-ecb', key, null); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]); +} + +describe('Weixin media crypto', () => { + describe('parseAesKey', () => { + it('accepts 16-byte raw key encoded in base64', () => { + const raw = Buffer.alloc(16, 0xab); + const b64 = raw.toString('base64'); + const result = parseAesKey(b64); + expect(result).toEqual(raw); + expect(result.length).toBe(16); + }); + + it('accepts 32-char hex string encoded in base64', () => { + // 32 hex chars → 16 bytes when parsed as hex + const hexStr = 'aabbccdd11223344aabbccdd11223344'; + const b64 = Buffer.from(hexStr, 'ascii').toString('base64'); + const result = parseAesKey(b64); + expect(result.length).toBe(16); + expect(result.toString('hex')).toBe(hexStr); + }); + + it('throws for invalid key length', () => { + const bad = Buffer.alloc(20, 0x00).toString('base64'); + expect(() => parseAesKey(bad)).toThrow('Invalid aes_key'); + }); + + it('throws for 32-byte non-hex content', () => { + // 32 bytes but not valid hex characters + const nonHex = Buffer.from('zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz', 'ascii'); + const b64 = nonHex.toString('base64'); + expect(() => parseAesKey(b64)).toThrow('Invalid aes_key'); + }); + }); + + describe('decryptAesEcb', () => { + it('encrypts then decrypts round-trip', () => { + const key = Buffer.alloc(16, 0x42); + const plaintext = Buffer.from('Hello, WeChat media decryption!'); + + // Encrypt + const cipher = createCipheriv('aes-128-ecb', key, null); + const ciphertext = Buffer.concat([ + cipher.update(plaintext), + cipher.final(), + ]); + + // Decrypt + const decrypted = decryptAesEcb(ciphertext, key); + expect(decrypted.toString()).toBe(plaintext.toString()); + }); + + it('handles empty plaintext', () => { + const key = Buffer.alloc(16, 0x01); + const cipher = createCipheriv('aes-128-ecb', key, null); + const ciphertext = Buffer.concat([ + cipher.update(Buffer.alloc(0)), + cipher.final(), + ]); + const decrypted = decryptAesEcb(ciphertext, key); + expect(decrypted.length).toBe(0); + }); + }); +}); diff --git a/packages/channels/weixin/src/media.ts b/packages/channels/weixin/src/media.ts new file mode 100644 index 0000000000..8cd7fa9ebd --- /dev/null +++ b/packages/channels/weixin/src/media.ts @@ -0,0 +1,56 @@ +/** + * CDN download with AES-128-ECB decryption. + * Ported from cc-weixin/plugins/weixin/src/media.ts (download path only). + */ + +import { createDecipheriv } from 'node:crypto'; + +const CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c'; + +function buildCdnDownloadUrl(encryptedQueryParam: string): string { + return `${CDN_BASE_URL}/download?encrypted_query_param=${encodeURIComponent(encryptedQueryParam)}`; +} + +function decryptAesEcb(ciphertext: Buffer, key: Buffer): Buffer { + const decipher = createDecipheriv('aes-128-ecb', key, null); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]); +} + +/** + * Parse aes_key from CDNMedia into a raw 16-byte Buffer. + * Two encodings exist: + * - base64(raw 16 bytes) → images + * - base64(hex string of 16 bytes) → file/voice/video + */ +function parseAesKey(aesKeyBase64: string): Buffer { + const decoded = Buffer.from(aesKeyBase64, 'base64'); + if (decoded.length === 16) { + return decoded; + } + if ( + decoded.length === 32 && + /^[0-9a-fA-F]{32}$/.test(decoded.toString('ascii')) + ) { + return Buffer.from(decoded.toString('ascii'), 'hex'); + } + throw new Error( + `Invalid aes_key: expected 16 raw bytes or 32 hex chars, got ${decoded.length} bytes`, + ); +} + +/** Download encrypted media from CDN and decrypt it. */ +export async function downloadAndDecrypt( + encryptQueryParam: string, + aesKey: string, +): Promise { + const url = buildCdnDownloadUrl(encryptQueryParam); + + const resp = await fetch(url); + if (!resp.ok) { + throw new Error(`CDN download failed: HTTP ${resp.status}`); + } + + const ciphertext = Buffer.from(await resp.arrayBuffer()); + const keyBuf = parseAesKey(aesKey); + return decryptAesEcb(ciphertext, keyBuf); +} diff --git a/packages/channels/weixin/src/monitor.ts b/packages/channels/weixin/src/monitor.ts new file mode 100644 index 0000000000..73ac6a32e2 --- /dev/null +++ b/packages/channels/weixin/src/monitor.ts @@ -0,0 +1,204 @@ +/** + * Long-polling loop: getUpdates -> callback. + * Platform-agnostic: the onMessage callback handles delivery. + */ + +import { getUpdates } from './api.js'; +import { MessageType, MessageItemType } from './types.js'; +import type { WeixinMessage } from './types.js'; +import { getStateDir } from './accounts.js'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** In-memory context token cache: userId -> contextToken */ +const contextTokens = new Map(); + +export function getContextToken(userId: string): string | undefined { + return contextTokens.get(userId); +} + +function cursorPath(): string { + return join(getStateDir(), 'cursor.txt'); +} + +function loadCursor(): string { + const p = cursorPath(); + if (existsSync(p)) return readFileSync(p, 'utf-8').trim(); + return ''; +} + +function saveCursor(cursor: string): void { + writeFileSync(cursorPath(), cursor, 'utf-8'); +} + +export interface CdnRef { + encryptQueryParam: string; + aesKey: string; +} + +export interface FileCdnRef extends CdnRef { + fileName: string; +} + +export interface ParsedMessage { + fromUserId: string; + messageId: string; + text: string; + /** CDN reference for deferred image download. */ + image?: CdnRef; + /** CDN reference for deferred file download. */ + file?: FileCdnRef; + /** Text of the referenced (replied-to) message. */ + refText?: string; +} + +export type OnMessageCallback = (msg: ParsedMessage) => Promise; + +export async function startPollLoop(params: { + baseUrl: string; + token: string; + onMessage: OnMessageCallback; + abortSignal: AbortSignal; +}): Promise { + const { baseUrl, token, onMessage, abortSignal } = params; + + let cursor = loadCursor(); + let consecutiveErrors = 0; + let pollTimeoutMs = 40000; + + process.stderr.write('[weixin] Starting message poll loop...\n'); + + while (!abortSignal.aborted) { + try { + const resp = await getUpdates( + baseUrl, + token, + cursor, + pollTimeoutMs, + abortSignal, + ); + + if (resp.errcode === -14) { + process.stderr.write( + '[weixin] Session expired (errcode -14). Pausing 30s...\n', + ); + await new Promise((r) => setTimeout(r, 30000)); + continue; + } + + if (resp.ret !== 0 && resp.ret !== undefined) { + throw new Error( + `getUpdates error: ret=${resp.ret} errcode=${resp.errcode} ${resp.errmsg}`, + ); + } + + consecutiveErrors = 0; + + // Respect server-suggested poll timeout + if (resp.longpolling_timeout_ms && resp.longpolling_timeout_ms > 0) { + pollTimeoutMs = resp.longpolling_timeout_ms + 5000; // add buffer for network + } + + if (resp.msgs && resp.msgs.length > 0) { + for (const msg of resp.msgs) { + await processMessage(msg, onMessage); + } + } + + // Persist cursor after messages are processed to avoid losing messages on crash + if (resp.get_updates_buf) { + cursor = resp.get_updates_buf; + saveCursor(cursor); + } + } catch (err: unknown) { + if (abortSignal.aborted) break; + + consecutiveErrors++; + process.stderr.write( + `[weixin] Poll error (${consecutiveErrors}): ${err instanceof Error ? err.message : err}\n`, + ); + + if (consecutiveErrors >= 3) { + process.stderr.write( + '[weixin] Too many consecutive errors, backing off 30s...\n', + ); + await new Promise((r) => setTimeout(r, 30000)); + consecutiveErrors = 0; + } else { + await new Promise((r) => setTimeout(r, 2000)); + } + } + } + + process.stderr.write('[weixin] Poll loop stopped.\n'); +} + +async function processMessage( + msg: WeixinMessage, + onMessage: OnMessageCallback, +): Promise { + if (msg.message_type !== MessageType.USER) return; + + const fromUserId = msg.from_user_id; + if (!fromUserId) return; + + // Cache context token (required for replies) + if (msg.context_token) { + contextTokens.set(fromUserId, msg.context_token); + } + + // Extract text, image, file CDN references, and referenced message + let textContent = ''; + let image: CdnRef | undefined; + let file: FileCdnRef | undefined; + let refText: string | undefined; + + if (msg.item_list) { + for (const item of msg.item_list) { + if (item.type === MessageItemType.TEXT && item.text_item?.text) { + textContent += (textContent ? '\n' : '') + item.text_item.text; + } + + // Extract referenced message text + if (item.ref_msg) { + const refItem = item.ref_msg.message_item; + if (refItem?.text_item?.text) { + refText = refItem.text_item.text; + } else if (item.ref_msg.title) { + refText = item.ref_msg.title; + } + } + + if (item.type === MessageItemType.IMAGE && item.image_item) { + const media = item.image_item.media; + if (media?.encrypt_query_param && media.aes_key) { + image = { + encryptQueryParam: media.encrypt_query_param, + aesKey: media.aes_key, + }; + } + } else if (item.type === MessageItemType.FILE && item.file_item) { + const media = item.file_item.media; + if (media?.encrypt_query_param && media.aes_key) { + file = { + encryptQueryParam: media.encrypt_query_param, + aesKey: media.aes_key, + fileName: item.file_item.file_name || `file_${Date.now()}`, + }; + } + } + } + } + + // Need either text, image, or file to proceed + if (!textContent && !image && !file) return; + + await onMessage({ + fromUserId, + messageId: String(msg.message_id || ''), + text: textContent || (file ? `(file: ${file.fileName})` : '(image)'), + image, + file, + refText, + }); +} diff --git a/packages/channels/weixin/src/send.test.ts b/packages/channels/weixin/src/send.test.ts new file mode 100644 index 0000000000..95152672cf --- /dev/null +++ b/packages/channels/weixin/src/send.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest'; +import { markdownToPlainText } from './send.js'; + +describe('markdownToPlainText', () => { + it('strips code blocks', () => { + const input = '```js\nconst x = 1;\n```'; + expect(markdownToPlainText(input)).toBe('const x = 1;'); + }); + + it('strips inline code', () => { + expect(markdownToPlainText('use `npm install`')).toBe('use npm install'); + }); + + it('strips bold', () => { + expect(markdownToPlainText('**bold text**')).toBe('bold text'); + }); + + it('strips italic', () => { + expect(markdownToPlainText('*italic text*')).toBe('italic text'); + expect(markdownToPlainText('_italic text_')).toBe('italic text'); + }); + + it('strips bold+italic', () => { + expect(markdownToPlainText('***bold italic***')).toBe('bold italic'); + }); + + it('strips strikethrough', () => { + expect(markdownToPlainText('~~deleted~~')).toBe('deleted'); + }); + + it('strips headings', () => { + expect(markdownToPlainText('# Title\n## Subtitle')).toBe('Title\nSubtitle'); + }); + + it('converts links to text (url)', () => { + expect(markdownToPlainText('[click here](https://example.com)')).toBe( + 'click here (https://example.com)', + ); + }); + + it('converts image syntax (link regex fires before image regex)', () => { + // In the current implementation, the link regex fires before the image regex, + // so `![alt](url)` becomes `!alt (url)` rather than `[alt]` + const result = markdownToPlainText('![alt](https://img.png)'); + expect(result).toBe('!alt (https://img.png)'); + }); + + it('strips blockquote markers', () => { + expect(markdownToPlainText('> quoted text')).toBe('quoted text'); + }); + + it('normalizes list markers', () => { + expect(markdownToPlainText('* item 1\n- item 2')).toBe( + '- item 1\n- item 2', + ); + }); + + it('collapses triple+ newlines', () => { + expect(markdownToPlainText('a\n\n\n\nb')).toBe('a\n\nb'); + }); + + it('trims result', () => { + expect(markdownToPlainText(' \n hello \n ')).toBe('hello'); + }); + + it('handles double underscore bold', () => { + expect(markdownToPlainText('__bold__')).toBe('bold'); + }); + + it('handles complex markdown', () => { + const input = '# Title\n\n**Bold** and *italic* with `code`\n\n> quote'; + const result = markdownToPlainText(input); + expect(result).toContain('Title'); + expect(result).toContain('Bold'); + expect(result).toContain('italic'); + expect(result).toContain('code'); + expect(result).toContain('quote'); + expect(result).not.toContain('#'); + expect(result).not.toContain('**'); + expect(result).not.toContain('`'); + }); +}); diff --git a/packages/channels/weixin/src/send.ts b/packages/channels/weixin/src/send.ts new file mode 100644 index 0000000000..54ca8fa52c --- /dev/null +++ b/packages/channels/weixin/src/send.ts @@ -0,0 +1,52 @@ +/** + * Send messages to WeChat users. + */ + +import { randomUUID } from 'node:crypto'; +import { sendMessage } from './api.js'; +import { MessageType, MessageState, MessageItemType } from './types.js'; + +/** Convert markdown to plain text (WeChat doesn't support markdown) */ +export function markdownToPlainText(text: string): string { + return text + .replace(/```[\s\S]*?\n([\s\S]*?)```/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/\*\*\*(.+?)\*\*\*/g, '$1') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + .replace(/___(.+?)___/g, '$1') + .replace(/__(.+?)__/g, '$1') + .replace(/_(.+?)_/g, '$1') + .replace(/~~(.+?)~~/g, '$1') + .replace(/^#{1,6}\s+/gm, '') + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 ($2)') + .replace(/!\[([^\]]*)\]\([^)]+\)/g, '[$1]') + .replace(/^>\s+/gm, '') + .replace(/^[-*_]{3,}$/gm, '---') + .replace(/^[\s]*[-*+]\s+/gm, '- ') + .replace(/^[\s]*(\d+)\.\s+/gm, '$1. ') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +/** Send a text message */ +export async function sendText(params: { + to: string; + text: string; + baseUrl: string; + token: string; + contextToken: string; +}): Promise { + const { to, text, baseUrl, token, contextToken } = params; + const plainText = markdownToPlainText(text); + + await sendMessage(baseUrl, token, { + to_user_id: to, + from_user_id: '', + client_id: randomUUID(), + message_type: MessageType.BOT, + message_state: MessageState.FINISH, + context_token: contextToken, + item_list: [{ type: MessageItemType.TEXT, text_item: { text: plainText } }], + }); +} diff --git a/packages/channels/weixin/src/types.ts b/packages/channels/weixin/src/types.ts new file mode 100644 index 0000000000..702ec7be89 --- /dev/null +++ b/packages/channels/weixin/src/types.ts @@ -0,0 +1,134 @@ +/** + * WeChat iLink Bot API protocol types. + */ + +export const MessageType = { + NONE: 0, + USER: 1, + BOT: 2, +} as const; + +export const MessageItemType = { + NONE: 0, + TEXT: 1, + IMAGE: 2, + VOICE: 3, + FILE: 4, + VIDEO: 5, +} as const; + +export const MessageState = { + NEW: 0, + GENERATING: 1, + FINISH: 2, +} as const; + +export interface BaseInfo { + channel_version?: string; +} + +export interface CDNMedia { + encrypt_query_param?: string; + aes_key?: string; + encrypt_type?: number; +} + +export interface TextItem { + text?: string; +} + +export interface ImageItem { + media?: CDNMedia; + thumb_media?: CDNMedia; + aeskey?: string; + url?: string; + mid_size?: number; +} + +export interface VoiceItem { + media?: CDNMedia; + text?: string; +} + +export interface FileItem { + media?: CDNMedia; + file_name?: string; + md5?: string; + len?: string; +} + +export interface VideoItem { + media?: CDNMedia; + video_size?: number; +} + +export interface RefMessage { + message_item?: MessageItem; + title?: string; +} + +export interface MessageItem { + type?: number; + text_item?: TextItem; + image_item?: ImageItem; + voice_item?: VoiceItem; + file_item?: FileItem; + video_item?: VideoItem; + ref_msg?: RefMessage; +} + +export interface WeixinMessage { + seq?: number; + message_id?: number; + from_user_id?: string; + to_user_id?: string; + client_id?: string; + create_time_ms?: number; + session_id?: string; + message_type?: number; + message_state?: number; + item_list?: MessageItem[]; + context_token?: string; +} + +export interface GetUpdatesReq { + get_updates_buf?: string; + base_info?: BaseInfo; +} + +export interface GetUpdatesResp { + ret?: number; + errcode?: number; + errmsg?: string; + msgs?: WeixinMessage[]; + get_updates_buf?: string; + longpolling_timeout_ms?: number; +} + +export interface SendMessageReq { + msg?: WeixinMessage; + base_info?: BaseInfo; +} + +export const TypingStatus = { + TYPING: 1, + CANCEL: 2, +} as const; + +export interface GetConfigResp { + ret?: number; + errmsg?: string; + typing_ticket?: string; +} + +export interface SendTypingReq { + ilink_user_id?: string; + typing_ticket?: string; + status?: number; + base_info?: BaseInfo; +} + +export interface SendTypingResp { + ret?: number; + errmsg?: string; +} diff --git a/packages/channels/weixin/tsconfig.json b/packages/channels/weixin/tsconfig.json new file mode 100644 index 0000000000..30e3324c83 --- /dev/null +++ b/packages/channels/weixin/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "src/**/*.test.ts"], + "references": [{ "path": "../base" }] +} diff --git a/packages/channels/weixin/vitest.config.ts b/packages/channels/weixin/vitest.config.ts new file mode 100644 index 0000000000..bfaebe3ce6 --- /dev/null +++ b/packages/channels/weixin/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + globals: true, + }, +}); diff --git a/packages/cli/package.json b/packages/cli/package.json index 9d1b1a0d35..221d092bf3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -40,6 +40,10 @@ "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", "@modelcontextprotocol/sdk": "^1.25.1", + "@qwen-code/channel-base": "file:../channels/base", + "@qwen-code/channel-telegram": "file:../channels/telegram", + "@qwen-code/channel-weixin": "file:../channels/weixin", + "@qwen-code/channel-dingtalk": "file:../channels/dingtalk", "@qwen-code/qwen-code-core": "file:../core", "@qwen-code/web-templates": "file:../web-templates", "@types/update-notifier": "^6.0.8", diff --git a/packages/cli/src/commands/channel.ts b/packages/cli/src/commands/channel.ts new file mode 100644 index 0000000000..7b75da308b --- /dev/null +++ b/packages/cli/src/commands/channel.ts @@ -0,0 +1,36 @@ +import type { CommandModule, Argv } from 'yargs'; +import { startCommand } from './channel/start.js'; +import { stopCommand } from './channel/stop.js'; +import { statusCommand } from './channel/status.js'; +import { + pairingListCommand, + pairingApproveCommand, +} from './channel/pairing.js'; +import { configureWeixinCommand } from './channel/configure.js'; + +const pairingCommand: CommandModule = { + command: 'pairing', + describe: 'Manage DM pairing requests', + builder: (yargs: Argv) => + yargs + .command(pairingListCommand) + .command(pairingApproveCommand) + .demandCommand(1, 'You need at least one command before continuing.') + .version(false), + handler: () => {}, +}; + +export const channelCommand: CommandModule = { + command: 'channel', + describe: 'Manage messaging channels (Telegram, Discord, etc.)', + builder: (yargs: Argv) => + yargs + .command(startCommand) + .command(stopCommand) + .command(statusCommand) + .command(pairingCommand) + .command(configureWeixinCommand) + .demandCommand(1, 'You need at least one command before continuing.') + .version(false), + handler: () => {}, +}; diff --git a/packages/cli/src/commands/channel/channel-registry.ts b/packages/cli/src/commands/channel/channel-registry.ts new file mode 100644 index 0000000000..bc2f2997ca --- /dev/null +++ b/packages/cli/src/commands/channel/channel-registry.ts @@ -0,0 +1,28 @@ +import type { ChannelPlugin } from '@qwen-code/channel-base'; +import { plugin as telegramPlugin } from '@qwen-code/channel-telegram'; +import { plugin as weixinPlugin } from '@qwen-code/channel-weixin'; +import { plugin as dingtalkPlugin } from '@qwen-code/channel-dingtalk'; + +const registry = new Map(); + +// Register built-in channel types +for (const p of [telegramPlugin, weixinPlugin, dingtalkPlugin]) { + registry.set(p.channelType, p); +} + +export function registerPlugin(plugin: ChannelPlugin): void { + if (registry.has(plugin.channelType)) { + throw new Error( + `Channel type "${plugin.channelType}" is already registered.`, + ); + } + registry.set(plugin.channelType, plugin); +} + +export function getPlugin(channelType: string): ChannelPlugin | undefined { + return registry.get(channelType); +} + +export function supportedTypes(): string[] { + return [...registry.keys()]; +} diff --git a/packages/cli/src/commands/channel/config-utils.test.ts b/packages/cli/src/commands/channel/config-utils.test.ts new file mode 100644 index 0000000000..6835f8880b --- /dev/null +++ b/packages/cli/src/commands/channel/config-utils.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { resolveEnvVars, parseChannelConfig } from './config-utils.js'; + +// Mock the channel-registry so we don't pull in real plugins +vi.mock('./channel-registry.js', () => ({ + getPlugin: (type: string) => { + const plugins: Record< + string, + { channelType: string; requiredConfigFields?: string[] } + > = { + telegram: { channelType: 'telegram', requiredConfigFields: ['token'] }, + dingtalk: { + channelType: 'dingtalk', + requiredConfigFields: ['clientId', 'clientSecret'], + }, + bare: { channelType: 'bare' }, // no requiredConfigFields + }; + return plugins[type]; + }, + supportedTypes: () => ['telegram', 'dingtalk', 'bare'], +})); + +describe('resolveEnvVars', () => { + const ENV_KEY = 'TEST_RESOLVE_VAR_123'; + + afterEach(() => { + delete process.env[ENV_KEY]; + }); + + it('returns literal values unchanged', () => { + expect(resolveEnvVars('my-token')).toBe('my-token'); + }); + + it('resolves $ENV_VAR to its value', () => { + process.env[ENV_KEY] = 'secret'; + expect(resolveEnvVars(`$${ENV_KEY}`)).toBe('secret'); + }); + + it('throws when referenced env var is not set', () => { + expect(() => resolveEnvVars(`$${ENV_KEY}`)).toThrow( + `Environment variable ${ENV_KEY} is not set`, + ); + }); + + it('does not resolve vars that do not start with $', () => { + process.env[ENV_KEY] = 'val'; + expect(resolveEnvVars(`prefix$${ENV_KEY}`)).toBe(`prefix$${ENV_KEY}`); + }); +}); + +describe('parseChannelConfig', () => { + it('throws when type is missing', () => { + expect(() => parseChannelConfig('bot', {})).toThrow( + 'missing required field "type"', + ); + }); + + it('throws for unsupported channel type', () => { + expect(() => parseChannelConfig('bot', { type: 'slack' })).toThrow( + '"slack" is not supported', + ); + }); + + it('throws when plugin-required fields are missing', () => { + expect(() => parseChannelConfig('bot', { type: 'telegram' })).toThrow( + 'requires "token"', + ); + }); + + it('parses minimal valid config with defaults', () => { + const result = parseChannelConfig('bot', { + type: 'bare', + }); + + expect(result.type).toBe('bare'); + expect(result.token).toBe(''); + expect(result.senderPolicy).toBe('allowlist'); + expect(result.allowedUsers).toEqual([]); + expect(result.sessionScope).toBe('user'); + expect(result.cwd).toBe(process.cwd()); + expect(result.groupPolicy).toBe('disabled'); + expect(result.groups).toEqual({}); + }); + + it('resolves env vars in token, clientId, clientSecret', () => { + process.env['TEST_TOKEN'] = 'tok123'; + process.env['TEST_CID'] = 'cid456'; + process.env['TEST_SEC'] = 'sec789'; + + const result = parseChannelConfig('bot', { + type: 'bare', + token: '$TEST_TOKEN', + clientId: '$TEST_CID', + clientSecret: '$TEST_SEC', + }); + + expect(result.token).toBe('tok123'); + expect(result.clientId).toBe('cid456'); + expect(result.clientSecret).toBe('sec789'); + + delete process.env['TEST_TOKEN']; + delete process.env['TEST_CID']; + delete process.env['TEST_SEC']; + }); + + it('preserves explicit config values over defaults', () => { + const result = parseChannelConfig('bot', { + type: 'bare', + token: 'literal-tok', + senderPolicy: 'open', + allowedUsers: ['alice'], + sessionScope: 'thread', + cwd: '/custom', + approvalMode: 'auto', + instructions: 'Be helpful', + model: 'qwen-coder', + groupPolicy: 'open', + groups: { g1: { mentionKeywords: ['@bot'] } }, + }); + + expect(result.token).toBe('literal-tok'); + expect(result.senderPolicy).toBe('open'); + expect(result.allowedUsers).toEqual(['alice']); + expect(result.sessionScope).toBe('thread'); + expect(result.cwd).toBe('/custom'); + expect(result.approvalMode).toBe('auto'); + expect(result.instructions).toBe('Be helpful'); + expect(result.model).toBe('qwen-coder'); + expect(result.groupPolicy).toBe('open'); + expect(result.groups).toEqual({ g1: { mentionKeywords: ['@bot'] } }); + }); + + it('spreads extra fields from raw config', () => { + const result = parseChannelConfig('bot', { + type: 'bare', + customField: 42, + }); + expect((result as Record)['customField']).toBe(42); + }); +}); diff --git a/packages/cli/src/commands/channel/config-utils.ts b/packages/cli/src/commands/channel/config-utils.ts new file mode 100644 index 0000000000..5e7401b1d7 --- /dev/null +++ b/packages/cli/src/commands/channel/config-utils.ts @@ -0,0 +1,83 @@ +import type { ChannelConfig } from '@qwen-code/channel-base'; +import * as path from 'node:path'; +import { getPlugin, supportedTypes } from './channel-registry.js'; + +export function resolveEnvVars(value: string): string { + if (value.startsWith('$')) { + const envName = value.substring(1); + const envValue = process.env[envName]; + if (!envValue) { + throw new Error( + `Environment variable ${envName} is not set (referenced as ${value})`, + ); + } + return envValue; + } + return value; +} + +export function findCliEntryPath(): string { + const mainModule = process.argv[1]; + if (mainModule) { + return path.resolve(mainModule); + } + throw new Error('Cannot determine CLI entry path'); +} + +export function parseChannelConfig( + name: string, + rawConfig: Record, +): ChannelConfig & Record { + if (!rawConfig['type']) { + throw new Error(`Channel "${name}" is missing required field "type".`); + } + + const channelType = rawConfig['type'] as string; + const plugin = getPlugin(channelType); + if (!plugin) { + throw new Error( + `Channel type "${channelType}" is not supported. Available: ${supportedTypes().join(', ')}`, + ); + } + + // Validate plugin-required fields + for (const field of plugin.requiredConfigFields ?? []) { + if (!rawConfig[field]) { + throw new Error( + `Channel "${name}" (${channelType}) requires "${field}".`, + ); + } + } + + // Resolve env vars for known credential fields + const token = rawConfig['token'] + ? resolveEnvVars(rawConfig['token'] as string) + : ''; + const clientId = rawConfig['clientId'] + ? resolveEnvVars(rawConfig['clientId'] as string) + : undefined; + const clientSecret = rawConfig['clientSecret'] + ? resolveEnvVars(rawConfig['clientSecret'] as string) + : undefined; + + return { + ...rawConfig, + type: channelType, + token, + clientId, + clientSecret, + senderPolicy: + (rawConfig['senderPolicy'] as ChannelConfig['senderPolicy']) || + 'allowlist', + allowedUsers: (rawConfig['allowedUsers'] as string[]) || [], + sessionScope: + (rawConfig['sessionScope'] as ChannelConfig['sessionScope']) || 'user', + cwd: (rawConfig['cwd'] as string) || process.cwd(), + approvalMode: rawConfig['approvalMode'] as string | undefined, + instructions: rawConfig['instructions'] as string | undefined, + model: rawConfig['model'] as string | undefined, + groupPolicy: + (rawConfig['groupPolicy'] as ChannelConfig['groupPolicy']) || 'disabled', + groups: (rawConfig['groups'] as ChannelConfig['groups']) || {}, + }; +} diff --git a/packages/cli/src/commands/channel/configure.ts b/packages/cli/src/commands/channel/configure.ts new file mode 100644 index 0000000000..0da23152a0 --- /dev/null +++ b/packages/cli/src/commands/channel/configure.ts @@ -0,0 +1,85 @@ +import type { CommandModule } from 'yargs'; +import { writeStderrLine, writeStdoutLine } from '../../utils/stdioHelpers.js'; +import { + loadAccount, + saveAccount, + clearAccount, + DEFAULT_BASE_URL, +} from '@qwen-code/channel-weixin/accounts'; +import { startLogin, waitForLogin } from '@qwen-code/channel-weixin/login'; + +export const configureWeixinCommand: CommandModule< + object, + { action: string | undefined } +> = { + command: 'configure-weixin [action]', + describe: 'Configure WeChat channel (login via QR code)', + builder: (yargs) => + yargs.positional('action', { + type: 'string', + describe: '"clear" to remove stored credentials, omit to login', + }), + handler: async (argv) => { + const { action } = argv; + + if (action === 'clear') { + clearAccount(); + writeStdoutLine('WeChat credentials cleared.'); + return; + } + + if (action === 'status') { + const account = loadAccount(); + if (account) { + writeStdoutLine(`WeChat account configured (saved ${account.savedAt})`); + writeStdoutLine(` Base URL: ${account.baseUrl}`); + if (account.userId) { + writeStdoutLine(` User ID: ${account.userId}`); + } + } else { + writeStdoutLine('WeChat account not configured.'); + } + return; + } + + // Default action: login + const existing = loadAccount(); + if (existing) { + writeStdoutLine( + `Existing WeChat credentials found (saved ${existing.savedAt}).`, + ); + writeStdoutLine('Re-running login will overwrite them.\n'); + } + + const baseUrl = DEFAULT_BASE_URL; + + writeStdoutLine('Starting WeChat QR code login...\n'); + + try { + const qrcodeId = await startLogin(baseUrl); + const result = await waitForLogin({ qrcodeId, apiBaseUrl: baseUrl }); + + if (result.connected && result.token) { + saveAccount({ + token: result.token, + baseUrl: result.baseUrl || baseUrl, + userId: result.userId, + savedAt: new Date().toISOString(), + }); + writeStdoutLine('\n' + result.message); + writeStdoutLine( + 'Credentials saved. You can now start a weixin channel with:', + ); + writeStdoutLine(' qwen channel start '); + } else { + writeStderrLine('\n' + result.message); + process.exit(1); + } + } catch (err) { + writeStderrLine( + `Login failed: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + }, +}; diff --git a/packages/cli/src/commands/channel/pairing.ts b/packages/cli/src/commands/channel/pairing.ts new file mode 100644 index 0000000000..b61b0622e4 --- /dev/null +++ b/packages/cli/src/commands/channel/pairing.ts @@ -0,0 +1,66 @@ +import type { CommandModule } from 'yargs'; +import { PairingStore } from '@qwen-code/channel-base'; +import { writeStderrLine, writeStdoutLine } from '../../utils/stdioHelpers.js'; + +export const pairingListCommand: CommandModule = { + command: 'list ', + describe: 'List pending pairing requests for a channel', + builder: (yargs) => + yargs.positional('name', { + type: 'string', + describe: 'Channel name', + demandOption: true, + }), + handler: (argv) => { + const store = new PairingStore(argv.name); + const pending = store.listPending(); + + if (pending.length === 0) { + writeStdoutLine('No pending pairing requests.'); + return; + } + + writeStdoutLine(`Pending pairing requests for "${argv.name}":\n`); + for (const req of pending) { + const ago = Math.round((Date.now() - req.createdAt) / 60000); + writeStdoutLine( + ` Code: ${req.code} Sender: ${req.senderName} (${req.senderId}) ${ago}m ago`, + ); + } + }, +}; + +export const pairingApproveCommand: CommandModule< + object, + { name: string; code: string } +> = { + command: 'approve ', + describe: 'Approve a pending pairing request', + builder: (yargs) => + yargs + .positional('name', { + type: 'string', + describe: 'Channel name', + demandOption: true, + }) + .positional('code', { + type: 'string', + describe: 'Pairing code', + demandOption: true, + }), + handler: (argv) => { + const store = new PairingStore(argv.name); + const request = store.approve(argv.code); + + if (!request) { + writeStderrLine( + `No pending request found for code "${argv.code.toUpperCase()}". It may have expired.`, + ); + process.exit(1); + } + + writeStdoutLine( + `Approved: ${request.senderName} (${request.senderId}) can now use channel "${argv.name}".`, + ); + }, +}; diff --git a/packages/cli/src/commands/channel/pidfile.test.ts b/packages/cli/src/commands/channel/pidfile.test.ts new file mode 100644 index 0000000000..6e0d0398ee --- /dev/null +++ b/packages/cli/src/commands/channel/pidfile.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +// vi.hoisted runs before vi.mock hoisting, so fsStore is available in the factory +const fsStore = vi.hoisted(() => { + const store: Record = {}; + return store; +}); + +vi.mock('node:fs', () => { + const mock = { + existsSync: (p: string) => p in fsStore, + readFileSync: (p: string) => { + if (!(p in fsStore)) throw new Error('ENOENT'); + return fsStore[p]; + }, + writeFileSync: (p: string, data: string) => { + fsStore[p] = data; + }, + mkdirSync: () => {}, + unlinkSync: (p: string) => { + delete fsStore[p]; + }, + }; + return { ...mock, default: mock }; +}); + +import { + readServiceInfo, + writeServiceInfo, + removeServiceInfo, + signalService, + waitForExit, +} from './pidfile.js'; + +// We need to mock process.kill for isProcessAlive / signalService +const originalKill = process.kill; + +function getPidFilePath() { + return join(homedir(), '.qwen', 'channels', 'service.pid'); +} + +beforeEach(() => { + for (const k of Object.keys(fsStore)) delete fsStore[k]; +}); + +afterEach(() => { + process.kill = originalKill; +}); + +describe('writeServiceInfo + readServiceInfo', () => { + it('writes and reads back service info for a live process', () => { + // Mock process.kill(pid, 0) to indicate alive + // eslint-disable-next-line @typescript-eslint/no-explicit-any + process.kill = vi.fn(() => true) as any; + + writeServiceInfo(['telegram', 'dingtalk']); + const info = readServiceInfo(); + + expect(info).not.toBeNull(); + expect(info!.pid).toBe(process.pid); + expect(info!.channels).toEqual(['telegram', 'dingtalk']); + expect(info!.startedAt).toBeTruthy(); + }); + + it('returns null when no PID file exists', () => { + const info = readServiceInfo(); + expect(info).toBeNull(); + }); + + it('cleans up and returns null for corrupt PID file', () => { + const filePath = getPidFilePath(); + fsStore[filePath] = 'not-json!!!'; + + const info = readServiceInfo(); + expect(info).toBeNull(); + // File should be cleaned up + expect(filePath in fsStore).toBe(false); + }); + + it('cleans up and returns null for stale PID (dead process)', () => { + // First write with alive process + // eslint-disable-next-line @typescript-eslint/no-explicit-any + process.kill = vi.fn(() => true) as any; + writeServiceInfo(['telegram']); + + // Now simulate dead process + + process.kill = vi.fn(() => { + throw new Error('ESRCH'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any; + + const info = readServiceInfo(); + expect(info).toBeNull(); + }); +}); + +describe('removeServiceInfo', () => { + it('removes existing PID file', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + process.kill = vi.fn(() => true) as any; + writeServiceInfo(['test']); + removeServiceInfo(); + + const info = readServiceInfo(); + expect(info).toBeNull(); + }); + + it('is a no-op when no PID file exists', () => { + expect(() => removeServiceInfo()).not.toThrow(); + }); +}); + +describe('signalService', () => { + it('returns true when signal is delivered', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + process.kill = vi.fn(() => true) as any; + expect(signalService(1234, 'SIGTERM')).toBe(true); + expect(process.kill).toHaveBeenCalledWith(1234, 'SIGTERM'); + }); + + it('returns false when process is not found', () => { + + process.kill = vi.fn(() => { + throw new Error('ESRCH'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any; + expect(signalService(9999)).toBe(false); + }); + + it('defaults to SIGTERM', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + process.kill = vi.fn(() => true) as any; + signalService(1234); + expect(process.kill).toHaveBeenCalledWith(1234, 'SIGTERM'); + }); +}); + +describe('waitForExit', () => { + it('returns true immediately if process is already dead', async () => { + + process.kill = vi.fn(() => { + throw new Error('ESRCH'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any; + + const result = await waitForExit(9999, 1000, 50); + expect(result).toBe(true); + }); + + it('returns true when process dies within timeout', async () => { + let alive = true; + + process.kill = vi.fn(() => { + if (!alive) throw new Error('ESRCH'); + return true; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any; + + // Kill after 100ms + setTimeout(() => { + alive = false; + }, 100); + + const result = await waitForExit(1234, 2000, 50); + expect(result).toBe(true); + }); + + it('returns false on timeout when process stays alive', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + process.kill = vi.fn(() => true) as any; + + const result = await waitForExit(1234, 150, 50); + expect(result).toBe(false); + }); +}); diff --git a/packages/cli/src/commands/channel/pidfile.ts b/packages/cli/src/commands/channel/pidfile.ts new file mode 100644 index 0000000000..b1f04f7301 --- /dev/null +++ b/packages/cli/src/commands/channel/pidfile.ts @@ -0,0 +1,126 @@ +import { + existsSync, + readFileSync, + writeFileSync, + mkdirSync, + unlinkSync, +} from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +export interface ServiceInfo { + pid: number; + startedAt: string; + channels: string[]; +} + +function pidFilePath(): string { + return path.join(os.homedir(), '.qwen', 'channels', 'service.pid'); +} + +/** Check if a process is alive. */ +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +/** + * Read the PID file and return service info if the process is still alive. + * Returns null if no file, invalid file, or stale (dead process). + * Automatically cleans up stale PID files. + */ +export function readServiceInfo(): ServiceInfo | null { + const filePath = pidFilePath(); + if (!existsSync(filePath)) return null; + + let info: ServiceInfo; + try { + info = JSON.parse(readFileSync(filePath, 'utf-8')); + } catch { + // Corrupt file — clean up + try { + unlinkSync(filePath); + } catch { + // best-effort + } + return null; + } + + if (!isProcessAlive(info.pid)) { + // Stale PID — process is dead, clean up + try { + unlinkSync(filePath); + } catch { + // best-effort + } + return null; + } + + return info; +} + +/** Write PID file with current process info. */ +export function writeServiceInfo(channels: string[]): void { + const filePath = pidFilePath(); + const dir = path.dirname(filePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + + const info: ServiceInfo = { + pid: process.pid, + startedAt: new Date().toISOString(), + channels, + }; + + writeFileSync(filePath, JSON.stringify(info, null, 2), 'utf-8'); +} + +/** Delete the PID file. */ +export function removeServiceInfo(): void { + const filePath = pidFilePath(); + if (existsSync(filePath)) { + try { + unlinkSync(filePath); + } catch { + // best-effort + } + } +} + +/** + * Send a signal to the running service. + * Returns true if signal was sent, false if process not found. + */ +export function signalService( + pid: number, + signal: NodeJS.Signals = 'SIGTERM', +): boolean { + try { + process.kill(pid, signal); + return true; + } catch { + return false; + } +} + +/** + * Wait for a process to exit, polling at intervals. + * Returns true if process exited, false if timeout. + */ +export async function waitForExit( + pid: number, + timeoutMs: number = 5000, + pollMs: number = 200, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (!isProcessAlive(pid)) return true; + await new Promise((r) => setTimeout(r, pollMs)); + } + return !isProcessAlive(pid); +} diff --git a/packages/cli/src/commands/channel/start.ts b/packages/cli/src/commands/channel/start.ts new file mode 100644 index 0000000000..2b70226227 --- /dev/null +++ b/packages/cli/src/commands/channel/start.ts @@ -0,0 +1,433 @@ +import * as path from 'node:path'; +import * as os from 'node:os'; +import type { CommandModule } from 'yargs'; +import { loadSettings } from '../../config/settings.js'; +import { writeStderrLine, writeStdoutLine } from '../../utils/stdioHelpers.js'; +import { AcpBridge, SessionRouter } from '@qwen-code/channel-base'; +import type { + ChannelBase, + ChannelPlugin, + ToolCallEvent, +} from '@qwen-code/channel-base'; +import { getPlugin, registerPlugin } from './channel-registry.js'; +import { findCliEntryPath, parseChannelConfig } from './config-utils.js'; +import { + readServiceInfo, + writeServiceInfo, + removeServiceInfo, +} from './pidfile.js'; +import { getExtensionManager } from '../extensions/utils.js'; + +const MAX_CRASH_RESTARTS = 3; +const CRASH_WINDOW_MS = 5 * 60 * 1000; // 5-minute window for counting crashes +const RESTART_DELAY_MS = 3000; + +function sessionsPath(): string { + return path.join(os.homedir(), '.qwen', 'channels', 'sessions.json'); +} + +function loadChannelsConfig(): Record { + const settings = loadSettings(process.cwd()); + const channels = ( + settings.merged as unknown as { channels?: Record } + ).channels; + return channels || {}; +} + +/** + * Load channel plugins from active extensions. + * Extensions declare channels in their qwen-extension.json manifest. + */ +async function loadChannelsFromExtensions(): Promise { + let loaded = 0; + try { + const extensionManager = await getExtensionManager(); + const extensions = extensionManager + .getLoadedExtensions() + .filter((e) => e.isActive && e.channels); + + for (const ext of extensions) { + for (const [channelType, channelDef] of Object.entries(ext.channels!)) { + if (getPlugin(channelType)) { + writeStderrLine( + `[Extensions] Skipping channel "${channelType}" from "${ext.name}": type already registered`, + ); + continue; + } + + const entryPath = path.join(ext.path, channelDef.entry); + try { + const module = (await import(entryPath)) as { + plugin?: ChannelPlugin; + }; + const plugin = module.plugin; + + if (!plugin || typeof plugin.createChannel !== 'function') { + writeStderrLine( + `[Extensions] "${ext.name}": channel entry point does not export a valid plugin object`, + ); + continue; + } + + if (plugin.channelType !== channelType) { + writeStderrLine( + `[Extensions] "${ext.name}": channelType mismatch — manifest says "${channelType}", plugin says "${plugin.channelType}"`, + ); + continue; + } + + registerPlugin(plugin); + loaded++; + writeStdoutLine( + `[Extensions] Loaded channel "${channelType}" from "${ext.name}"`, + ); + } catch (err) { + writeStderrLine( + `[Extensions] Failed to load channel "${channelType}" from "${ext.name}": ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + } + } catch (err) { + writeStderrLine( + `[Extensions] Failed to load extensions: ${err instanceof Error ? err.message : String(err)}`, + ); + } + return loaded; +} + +function createChannel( + name: string, + config: ReturnType, + bridge: AcpBridge, + options?: { router?: SessionRouter }, +): ChannelBase { + const channelPlugin = getPlugin(config.type); + if (!channelPlugin) { + throw new Error(`Unknown channel type: "${config.type}".`); + } + return channelPlugin.createChannel(name, config, bridge, options); +} + +function registerToolCallDispatch( + bridge: AcpBridge, + router: SessionRouter, + channels: Map, +): void { + bridge.on('toolCall', (event: ToolCallEvent) => { + const target = router.getTarget(event.sessionId); + if (target) { + const channel = channels.get(target.channelName); + if (channel) { + channel.onToolCall(target.chatId, event); + } + } + }); +} + +/** Check for duplicate instance and abort if one is already running. */ +function checkDuplicateInstance(): void { + const existing = readServiceInfo(); + if (existing) { + writeStderrLine( + `Error: Channel service is already running (PID ${existing.pid}, started ${existing.startedAt}).`, + ); + writeStderrLine('Use "qwen channel stop" to stop it first.'); + process.exit(1); + } +} + +/** Start a single channel with its own bridge + crash recovery. */ +async function startSingle(name: string): Promise { + checkDuplicateInstance(); + const channelsConfig = loadChannelsConfig(); + + await loadChannelsFromExtensions(); + + if (!channelsConfig[name]) { + writeStderrLine( + `Error: Channel "${name}" not found in settings. Add it to channels.${name} in settings.json.`, + ); + process.exit(1); + } + + let config; + try { + config = parseChannelConfig( + name, + channelsConfig[name] as Record, + ); + } catch (err) { + writeStderrLine( + `Error: ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + + const cliEntryPath = findCliEntryPath(); + let shuttingDown = false; + const crashTimestamps: number[] = []; + + const bridgeOpts = { cliEntryPath, cwd: config.cwd, model: config.model }; + let bridge = new AcpBridge(bridgeOpts); + await bridge.start(); + + const router = new SessionRouter( + bridge, + config.cwd, + config.sessionScope, + sessionsPath(), + ); + const channels: Map = new Map(); + + const channel = createChannel(name, config, bridge, { router }); + channels.set(name, channel); + registerToolCallDispatch(bridge, router, channels); + await channel.connect(); + + writeServiceInfo([name]); + writeStdoutLine(`[Channel] "${name}" is running. Press Ctrl+C to stop.`); + + bridge.on('disconnected', async () => { + if (shuttingDown) return; + + const now = Date.now(); + crashTimestamps.push(now); + // Only count crashes within the recent window + const recentCrashes = crashTimestamps.filter( + (ts) => now - ts < CRASH_WINDOW_MS, + ); + + if (recentCrashes.length > MAX_CRASH_RESTARTS) { + writeStderrLine( + `[Channel] Bridge crashed ${recentCrashes.length} times in ${CRASH_WINDOW_MS / 1000}s. Giving up.`, + ); + channel.disconnect(); + router.clearAll(); + removeServiceInfo(); + process.exit(1); + } + + writeStderrLine( + `[Channel] Bridge crashed (${recentCrashes.length}/${MAX_CRASH_RESTARTS} in window). Restarting in ${RESTART_DELAY_MS / 1000}s...`, + ); + await new Promise((r) => setTimeout(r, RESTART_DELAY_MS)); + + try { + bridge = new AcpBridge(bridgeOpts); + await bridge.start(); + router.setBridge(bridge); + channel.setBridge(bridge); + registerToolCallDispatch(bridge, router, channels); + + const result = await router.restoreSessions(); + writeStdoutLine( + `[Channel] Bridge restarted. Sessions restored: ${result.restored}, failed: ${result.failed}`, + ); + } catch (err) { + writeStderrLine( + `[Channel] Failed to restart bridge: ${err instanceof Error ? err.message : String(err)}`, + ); + } + }); + + const shutdown = () => { + shuttingDown = true; + writeStdoutLine('\n[Channel] Shutting down...'); + channel.disconnect(); + bridge.stop(); + router.clearAll(); + removeServiceInfo(); + process.exit(0); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + + await new Promise(() => {}); +} + +/** Start all configured channels with a shared bridge + crash recovery. */ +async function startAll(): Promise { + checkDuplicateInstance(); + const channelsConfig = loadChannelsConfig(); + + await loadChannelsFromExtensions(); + + if (Object.keys(channelsConfig).length === 0) { + writeStderrLine( + 'Error: No channels configured in settings.json. Add entries under "channels".', + ); + process.exit(1); + } + + // Parse all configs upfront — fail fast on bad config + const parsed: Array<{ + name: string; + config: ReturnType; + }> = []; + for (const [name, raw] of Object.entries(channelsConfig)) { + try { + parsed.push({ + name, + config: parseChannelConfig(name, raw as Record), + }); + } catch (err) { + writeStderrLine( + `Error in channel "${name}": ${err instanceof Error ? err.message : String(err)}`, + ); + process.exit(1); + } + } + + const cliEntryPath = findCliEntryPath(); + const defaultCwd = process.cwd(); + let shuttingDown = false; + const crashTimestamps: number[] = []; + + // All channels share one bridge process. Use the first channel's model. + const models = [ + ...new Set(parsed.map((p) => p.config.model).filter(Boolean)), + ]; + if (models.length > 1) { + writeStderrLine( + `[Channel] Warning: Multiple models configured (${models.join(', ')}). ` + + `Shared bridge will use "${models[0]}".`, + ); + } + const bridgeOpts = { + cliEntryPath, + cwd: defaultCwd, + model: models[0], + }; + let bridge = new AcpBridge(bridgeOpts); + await bridge.start(); + + const router = new SessionRouter(bridge, defaultCwd, 'user', sessionsPath()); + // Register per-channel scope overrides so each channel uses its own sessionScope + for (const { name, config } of parsed) { + router.setChannelScope(name, config.sessionScope); + } + const channels: Map = new Map(); + + writeStdoutLine( + `[Channel] Starting ${parsed.length} channel(s): ${parsed.map((p) => p.name).join(', ')}`, + ); + + for (const { name, config } of parsed) { + channels.set(name, createChannel(name, config, bridge, { router })); + } + registerToolCallDispatch(bridge, router, channels); + + // Connect all channels + let connectedCount = 0; + for (const [name, channel] of channels) { + try { + await channel.connect(); + connectedCount++; + writeStdoutLine(`[Channel] "${name}" connected.`); + } catch (err) { + writeStderrLine( + `[Channel] Failed to connect "${name}": ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + if (connectedCount === 0) { + writeStderrLine('[Channel] No channels connected. Exiting.'); + bridge.stop(); + process.exit(1); + } + + writeServiceInfo(parsed.map((p) => p.name)); + writeStdoutLine( + `[Channel] Running ${connectedCount} channel(s). Press Ctrl+C to stop.`, + ); + + bridge.on('disconnected', async () => { + if (shuttingDown) return; + + const now = Date.now(); + crashTimestamps.push(now); + const recentCrashes = crashTimestamps.filter( + (ts) => now - ts < CRASH_WINDOW_MS, + ); + + if (recentCrashes.length > MAX_CRASH_RESTARTS) { + writeStderrLine( + `[Channel] Bridge crashed ${recentCrashes.length} times in ${CRASH_WINDOW_MS / 1000}s. Giving up.`, + ); + for (const channel of channels.values()) { + try { + channel.disconnect(); + } catch { + // best-effort + } + } + router.clearAll(); + removeServiceInfo(); + process.exit(1); + } + + writeStderrLine( + `[Channel] Bridge crashed (${recentCrashes.length}/${MAX_CRASH_RESTARTS} in window). Restarting in ${RESTART_DELAY_MS / 1000}s...`, + ); + await new Promise((r) => setTimeout(r, RESTART_DELAY_MS)); + + try { + bridge = new AcpBridge(bridgeOpts); + await bridge.start(); + router.setBridge(bridge); + for (const channel of channels.values()) { + channel.setBridge(bridge); + } + registerToolCallDispatch(bridge, router, channels); + + const result = await router.restoreSessions(); + writeStdoutLine( + `[Channel] Bridge restarted. Sessions restored: ${result.restored}, failed: ${result.failed}`, + ); + } catch (err) { + writeStderrLine( + `[Channel] Failed to restart bridge: ${err instanceof Error ? err.message : String(err)}`, + ); + } + }); + + const shutdown = () => { + shuttingDown = true; + writeStdoutLine('\n[Channel] Shutting down...'); + for (const [name, channel] of channels) { + try { + channel.disconnect(); + writeStdoutLine(`[Channel] "${name}" disconnected.`); + } catch { + // best-effort + } + } + bridge.stop(); + router.clearAll(); + removeServiceInfo(); + process.exit(0); + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + + await new Promise(() => {}); +} + +export const startCommand: CommandModule = { + command: 'start [name]', + describe: 'Start channels (all if no name given, or a single named channel)', + builder: (yargs) => + yargs.positional('name', { + type: 'string', + describe: 'Channel name (omit to start all configured channels)', + }), + handler: async (argv) => { + if (argv.name) { + await startSingle(argv.name); + } else { + await startAll(); + } + }, +}; diff --git a/packages/cli/src/commands/channel/status.ts b/packages/cli/src/commands/channel/status.ts new file mode 100644 index 0000000000..bbd5e4f356 --- /dev/null +++ b/packages/cli/src/commands/channel/status.ts @@ -0,0 +1,78 @@ +import { existsSync, readFileSync } from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import type { CommandModule } from 'yargs'; +import { writeStdoutLine } from '../../utils/stdioHelpers.js'; +import { readServiceInfo } from './pidfile.js'; +import type { SessionTarget } from '@qwen-code/channel-base'; + +interface PersistedEntry { + sessionId: string; + target: SessionTarget; + cwd: string; +} + +function formatUptime(startedAt: string): string { + const ms = Date.now() - new Date(startedAt).getTime(); + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) return `${days}d ${hours % 24}h ${minutes % 60}m`; + if (hours > 0) return `${hours}h ${minutes % 60}m`; + if (minutes > 0) return `${minutes}m ${seconds % 60}s`; + return `${seconds}s`; +} + +export const statusCommand: CommandModule = { + command: 'status', + describe: 'Show channel service status', + handler: async () => { + const info = readServiceInfo(); + + if (!info) { + writeStdoutLine('No channel service is running.'); + process.exit(0); + } + + writeStdoutLine(`Channel service: running (PID ${info.pid})`); + writeStdoutLine(`Uptime: ${formatUptime(info.startedAt)}`); + writeStdoutLine(''); + + // Read session data for per-channel counts + const sessionsPath = path.join( + os.homedir(), + '.qwen', + 'channels', + 'sessions.json', + ); + + const sessionCounts = new Map(); + if (existsSync(sessionsPath)) { + try { + const entries: Record = JSON.parse( + readFileSync(sessionsPath, 'utf-8'), + ); + for (const entry of Object.values(entries)) { + const name = entry.target.channelName; + sessionCounts.set(name, (sessionCounts.get(name) || 0) + 1); + } + } catch { + // best-effort + } + } + + // Table header + const nameWidth = Math.max(15, ...info.channels.map((c) => c.length + 2)); + writeStdoutLine(`${'Channel'.padEnd(nameWidth)}Sessions`); + writeStdoutLine(`${'-'.repeat(nameWidth)}--------`); + + for (const name of info.channels) { + const count = sessionCounts.get(name) || 0; + writeStdoutLine(`${name.padEnd(nameWidth)}${count}`); + } + + process.exit(0); + }, +}; diff --git a/packages/cli/src/commands/channel/stop.ts b/packages/cli/src/commands/channel/stop.ts new file mode 100644 index 0000000000..e783581ec4 --- /dev/null +++ b/packages/cli/src/commands/channel/stop.ts @@ -0,0 +1,49 @@ +import type { CommandModule } from 'yargs'; +import { writeStderrLine, writeStdoutLine } from '../../utils/stdioHelpers.js'; +import { + readServiceInfo, + signalService, + waitForExit, + removeServiceInfo, +} from './pidfile.js'; + +export const stopCommand: CommandModule = { + command: 'stop', + describe: 'Stop the running channel service', + handler: async () => { + const info = readServiceInfo(); + + if (!info) { + writeStdoutLine('No channel service is running.'); + process.exit(0); + } + + writeStdoutLine(`Stopping channel service (PID ${info.pid})...`); + + if (!signalService(info.pid, 'SIGTERM')) { + writeStderrLine( + 'Failed to send signal — process may have already exited.', + ); + removeServiceInfo(); + process.exit(0); + } + + const exited = await waitForExit(info.pid, 5000); + + if (exited) { + // Clean up in case the process didn't delete its own PID file + removeServiceInfo(); + writeStdoutLine('Service stopped.'); + } else { + writeStderrLine( + 'Service did not exit within 5 seconds. Sending SIGKILL...', + ); + signalService(info.pid, 'SIGKILL'); + await waitForExit(info.pid, 2000); + removeServiceInfo(); + writeStdoutLine('Service killed.'); + } + + process.exit(0); + }, +}; diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index fcc33f76ad..32131e47a8 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -51,6 +51,7 @@ import { getCliVersion } from '../utils/version.js'; import { loadSandboxConfig } from './sandboxConfig.js'; import { appEvents } from '../utils/events.js'; import { mcpCommand } from '../commands/mcp.js'; +import { channelCommand } from '../commands/channel.js'; // UUID v4 regex pattern for validation const SESSION_ID_REGEX = @@ -590,7 +591,9 @@ export async function parseArguments(): Promise { // Register Auth subcommands .command(authCommand) // Register Hooks subcommands - .command(hooksCommand); + .command(hooksCommand) + // Register Channel subcommands + .command(channelCommand); yargsInstance .version(await getCliVersion()) // This will enable the --version flag based on package.json @@ -611,7 +614,8 @@ export async function parseArguments(): Promise { result._.length > 0 && (result._[0] === 'mcp' || result._[0] === 'extensions' || - result._[0] === 'hooks') + result._[0] === 'hooks' || + result._[0] === 'channel') ) { // MCP/Extensions/Hooks commands handle their own execution and process exit process.exit(0); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index d2cf5081c7..ea7d2a6c32 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -189,6 +189,18 @@ const SETTINGS_SCHEMA = { mergeStrategy: MergeStrategy.SHALLOW_MERGE, }, + // Channels configuration (Telegram, Discord, etc.) + channels: { + type: 'object', + label: 'Channels', + category: 'Advanced', + requiresRestart: true, + default: {} as Record>, + description: 'Configuration for messaging channels.', + showInDialog: false, + mergeStrategy: MergeStrategy.SHALLOW_MERGE, + }, + // Model providers configuration grouped by authType modelProviders: { type: 'object', diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index aebb67993b..a302e6caf0 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -282,11 +282,13 @@ export async function main() { process.exit(1); } } - // For stream-json mode, don't read stdin here - it should be forwarded to the sandbox - // and consumed by StreamJsonInputReader inside the container + // For stream-json and ACP modes, don't read stdin here — stdin carries + // protocol data (not a user prompt) and should be forwarded to the sandbox + // intact via stdio: 'inherit'. const inputFormat = argv.inputFormat as string | undefined; + const isAcpMode = argv.acp || argv.experimentalAcp; let stdinData = ''; - if (!process.stdin.isTTY && inputFormat !== 'stream-json') { + if (!process.stdin.isTTY && inputFormat !== 'stream-json' && !isAcpMode) { stdinData = await readStdin(); } diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index cd546eeda9..a62cd38e6a 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -84,5 +84,11 @@ "src/services/prompt-processors/shellProcessor.test.ts", "src/commands/extensions/examples/**" ], - "references": [{ "path": "../core" }] + "references": [ + { "path": "../core" }, + { "path": "../channels/base" }, + { "path": "../channels/telegram" }, + { "path": "../channels/weixin" }, + { "path": "../channels/dingtalk" } + ] } diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index e64527ced7..dd1ae81d0c 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -87,6 +87,15 @@ export enum SettingScope { SystemDefaults = 'SystemDefaults', } +export interface ExtensionChannelConfig { + /** Relative path to JS entry point (must export `plugin: ChannelPlugin`) */ + entry: string; + /** Human-readable name for CLI output */ + displayName?: string; + /** Extra config fields required beyond the shared ChannelConfig fields */ + requiredConfigFields?: string[]; +} + export interface Extension { id: string; name: string; @@ -104,6 +113,7 @@ export interface Extension { skills?: SkillConfig[]; agents?: SubagentConfig[]; hooks?: { [K in HookEventName]?: HookDefinition[] }; + channels?: Record; } export interface ExtensionConfig { @@ -117,6 +127,7 @@ export interface ExtensionConfig { agents?: string | string[]; settings?: ExtensionSetting[]; hooks?: { [K in HookEventName]?: HookDefinition[] }; + channels?: Record; } export interface ExtensionUpdateInfo { @@ -650,6 +661,10 @@ export class ExtensionManager { ); } + if (config.channels) { + extension.channels = config.channels; + } + extension.commands = await loadCommandsFromDir( `${effectiveExtensionPath}/commands`, ); diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index c7f53048e1..8bf578d8c5 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -8,6 +8,11 @@ "type": "object", "additionalProperties": true }, + "channels": { + "description": "Configuration for messaging channels.", + "type": "object", + "additionalProperties": true + }, "modelProviders": { "description": "Model providers configuration grouped by authType. Each authType contains an array of model configurations.", "type": "object", diff --git a/scripts/build.js b/scripts/build.js index 0ce010b3b6..9864a80e4c 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -37,14 +37,21 @@ execSync('npm run generate', { stdio: 'inherit', cwd: root }); // 1. test-utils (no internal dependencies) // 2. core (foundation package) // 3. web-templates (embeddable web templates - used by cli) -// 4. cli (depends on core, test-utils, web-templates) -// 5. webui (shared UI components - used by vscode companion) -// 6. sdk (no internal dependencies) -// 7. vscode-ide-companion (depends on webui) +// 4. channel-base (base channel infrastructure - used by channel adapters and cli) +// 5. channel adapters (depend on channel-base) +// 6. cli (depends on core, test-utils, web-templates, channel packages) +// 6. webui (shared UI components - used by vscode companion) +// 7. sdk (no internal dependencies) +// 8. vscode-ide-companion (depends on webui) const buildOrder = [ 'packages/test-utils', 'packages/core', 'packages/web-templates', + 'packages/channels/base', + 'packages/channels/telegram', + 'packages/channels/weixin', + 'packages/channels/dingtalk', + 'packages/channels/plugin-example', 'packages/cli', 'packages/webui', 'packages/sdk-typescript', diff --git a/scripts/clean.js b/scripts/clean.js index 864a2bec7b..7de9ca0c33 100644 --- a/scripts/clean.js +++ b/scripts/clean.js @@ -43,6 +43,7 @@ for (const workspace of rootPackageJson.workspaces) { for (const pkgPath of packages) { const pkgDir = dirname(join(root, pkgPath)); rmSync(join(pkgDir, 'dist'), RMRF_OPTIONS); + rmSync(join(pkgDir, 'tsconfig.tsbuildinfo'), { force: true }); } } diff --git a/scripts/version.js b/scripts/version.js index d67ff71f67..cf74a8bd96 100644 --- a/scripts/version.js +++ b/scripts/version.js @@ -104,9 +104,28 @@ if (cliPackageJson.config?.sandboxImageUri) { writeJson(cliPackageJsonPath, cliPackageJson); } -// 7. Run `npm install` to update package-lock.json. +// 7. Rewrite file: references to semver for packages that will be published to npm. +// During development, channel packages use file: for monorepo linking. +// At release time, published packages need real semver references so they resolve from npm. +const publishedCrossRefs = [ + { + packagePath: 'packages/channels/plugin-example/package.json', + dep: '@qwen-code/channel-base', + }, +]; +for (const { packagePath, dep } of publishedCrossRefs) { + const pkgPath = resolve(process.cwd(), packagePath); + const pkgJson = readJson(pkgPath); + if (pkgJson.dependencies?.[dep]?.startsWith('file:')) { + pkgJson.dependencies[dep] = `^${newVersion}`; + console.log(`Updated ${dep} in ${packagePath} to ^${newVersion}`); + writeJson(pkgPath, pkgJson); + } +} + +// 8. Run `npm install` to update package-lock.json. run( - 'npm install --workspace packages/cli --workspace packages/core --package-lock-only', + 'npm install --workspace packages/cli --workspace packages/core --workspace packages/channels/base --workspace packages/channels/plugin-example --package-lock-only', ); console.log(`Successfully bumped versions to v${newVersion}.`); diff --git a/vitest.config.ts b/vitest.config.ts index 88cded8b8a..339420a562 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,10 @@ export default defineConfig({ 'packages/core', 'packages/vscode-ide-companion', 'packages/sdk-typescript', + 'packages/channels/base', + 'packages/channels/dingtalk', + 'packages/channels/telegram', + 'packages/channels/weixin', 'integration-tests', 'scripts', ],