diff --git a/docs/users/features/channels/overview.md b/docs/users/features/channels/overview.md index 71813981c..a4ea55d3d 100644 --- a/docs/users/features/channels/overview.md +++ b/docs/users/features/channels/overview.md @@ -41,23 +41,23 @@ Channels are configured under the `channels` key in `settings.json`. Each channe ### Options -| Option | Required | Description | -| -------------- | -------- | ---------------------------------------------------------------------------- | -| `type` | Yes | Channel type: `telegram` (more coming soon) | -| `token` | Yes | Bot token. Supports `$ENV_VAR` syntax to read from environment variables | -| `senderPolicy` | No | Who can talk to the bot: `allowlist` (default), `open`, or `pairing` | -| `allowedUsers` | No | List of user IDs allowed to use the bot (when `senderPolicy` is `allowlist`) | -| `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 | +| Option | Required | Description | +| -------------- | -------- | ------------------------------------------------------------------------------------ | +| `type` | Yes | Channel type: `telegram` (more coming soon) | +| `token` | Yes | Bot token. Supports `$ENV_VAR` syntax to read from environment variables | +| `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 | ### 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. -- **`pairing`** — (Coming soon) New users go through a pairing flow before they can chat. ### Session Scope @@ -79,6 +79,39 @@ Bot tokens should not be stored directly in `settings.json`. Instead, use enviro 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 + ## Slash Commands Channels support slash commands. Some are handled locally by the adapter: diff --git a/docs/users/features/channels/telegram.md b/docs/users/features/channels/telegram.md index 236a597ff..197fcdb5d 100644 --- a/docs/users/features/channels/telegram.md +++ b/docs/users/features/channels/telegram.md @@ -15,7 +15,7 @@ This guide covers setting up a Qwen Code channel on Telegram. ## Finding Your User ID -To use `senderPolicy: "allowlist"`, you need your Telegram user ID (a numeric ID, not your username). +To use `senderPolicy: "allowlist"` or `"pairing"`, you need your Telegram user ID (a numeric ID, not your username). The easiest way to find it: @@ -62,7 +62,7 @@ Then open your bot in Telegram and send a message. You should see "Working..." a - **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 `/reset` to start fresh. -- **Restrict access** — Use `senderPolicy: "allowlist"` with your user ID to prevent unauthorized access. The bot silently ignores messages from users not on the list. +- **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 @@ -73,7 +73,7 @@ The agent's markdown responses are automatically converted to Telegram-compatibl ### 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"` +- 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 ### "Sorry, something went wrong processing your message" diff --git a/eslint.config.js b/eslint.config.js index 7b54f58a8..c52b6b5c5 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/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index be1bf45fc..a48e96992 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -1,5 +1,6 @@ import type { ChannelConfig, Envelope } from './types.js'; import { SenderGate } from './SenderGate.js'; +import { PairingStore } from './PairingStore.js'; import { SessionRouter } from './SessionRouter.js'; import type { AcpBridge, ToolCallEvent } from './AcpBridge.js'; @@ -15,7 +16,14 @@ export abstract class ChannelBase { this.name = name; this.config = config; this.bridge = bridge; - this.gate = new SenderGate(config.senderPolicy, config.allowedUsers); + + const pairingStore = + config.senderPolicy === 'pairing' ? new PairingStore(name) : undefined; + this.gate = new SenderGate( + config.senderPolicy, + config.allowedUsers, + pairingStore, + ); this.router = new SessionRouter(bridge, config.cwd, config.sessionScope); bridge.on('toolCall', (event: ToolCallEvent) => { @@ -33,7 +41,11 @@ export abstract class ChannelBase { onToolCall(_chatId: string, _event: ToolCallEvent): void {} async handleInbound(envelope: Envelope): Promise { - if (!this.gate.check(envelope.senderId)) { + const result = this.gate.check(envelope.senderId, envelope.senderName); + if (!result.allowed) { + if (result.pairingCode !== undefined) { + await this.onPairingRequired(envelope.chatId, result.pairingCode); + } return; } @@ -57,4 +69,21 @@ export abstract class ChannelBase { await this.sendMessage(envelope.chatId, response); } } + + 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/PairingStore.ts b/packages/channels/base/src/PairingStore.ts new file mode 100644 index 000000000..c49eeb4c9 --- /dev/null +++ b/packages/channels/base/src/PairingStore.ts @@ -0,0 +1,140 @@ +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[Math.floor(Math.random() * SAFE_ALPHABET.length)]; + } + return code; +} diff --git a/packages/channels/base/src/SenderGate.ts b/packages/channels/base/src/SenderGate.ts index b6a338c86..ec9e4e586 100644 --- a/packages/channels/base/src/SenderGate.ts +++ b/packages/channels/base/src/SenderGate.ts @@ -1,23 +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[] = []) { + constructor( + policy: SenderPolicy, + allowedUsers: string[] = [], + pairingStore?: PairingStore, + ) { this.policy = policy; this.allowedUsers = new Set(allowedUsers); + this.pairingStore = pairingStore || null; } - check(senderId: string): boolean { + check(senderId: string, senderName?: string): SenderCheckResult { switch (this.policy) { case 'open': - return true; + return { allowed: true }; case 'allowlist': - return this.allowedUsers.has(senderId); - case 'pairing': - // Pairing will be implemented later; for now, treat as allowlist - return this.allowedUsers.has(senderId); + 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/index.ts b/packages/channels/base/src/index.ts index 71dfaefa6..d33a06880 100644 --- a/packages/channels/base/src/index.ts +++ b/packages/channels/base/src/index.ts @@ -5,7 +5,10 @@ export type { ToolCallEvent, } from './AcpBridge.js'; export { ChannelBase } from './ChannelBase.js'; +export { PairingStore } from './PairingStore.js'; +export type { PairingRequest } from './PairingStore.js'; export { SenderGate } from './SenderGate.js'; +export type { SenderCheckResult } from './SenderGate.js'; export { SessionRouter } from './SessionRouter.js'; export type { ChannelConfig, diff --git a/packages/cli/src/commands/channel.ts b/packages/cli/src/commands/channel.ts index 190923d20..2d259a78e 100644 --- a/packages/cli/src/commands/channel.ts +++ b/packages/cli/src/commands/channel.ts @@ -1,5 +1,21 @@ import type { CommandModule, Argv } from 'yargs'; import { startCommand } from './channel/start.js'; +import { + pairingListCommand, + pairingApproveCommand, +} from './channel/pairing.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', @@ -7,6 +23,7 @@ export const channelCommand: CommandModule = { builder: (yargs: Argv) => yargs .command(startCommand) + .command(pairingCommand) .demandCommand(1, 'You need at least one command before continuing.') .version(false), handler: () => {}, diff --git a/packages/cli/src/commands/channel/pairing.ts b/packages/cli/src/commands/channel/pairing.ts new file mode 100644 index 000000000..b61b0622e --- /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}".`, + ); + }, +};