feat(channels): add DM pairing flow for sender approval

- Add PairingStore for managing pending requests and approved users
- Update SenderGate to support pairing policy with code generation
- Add CLI commands: `qwen channel pairing list/approve`
- Document pairing flow with rules and usage examples

This allows unknown senders to request access via a pairing code
that the bot operator approves through the CLI.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
tanzhenxin 2026-03-24 11:37:16 +00:00
parent 59ee49e0ab
commit 8753245b5f
9 changed files with 338 additions and 23 deletions

View file

@ -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/<name>-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 <CODE>
```
### 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/<name>-allowlist.json` — treat this file as sensitive
## Slash Commands
Channels support slash commands. Some are handled locally by the adapter:

View file

@ -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"

View file

@ -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,
},

View file

@ -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<void> {
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<void> {
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.',
);
}
}
}

View file

@ -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;
}

View file

@ -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<string>;
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}`);
}
}
}

View file

@ -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,

View file

@ -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: () => {},

View file

@ -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<object, { name: string }> = {
command: 'list <name>',
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 <name> <code>',
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}".`,
);
},
};