mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 03:30:40 +00:00
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:
parent
59ee49e0ab
commit
8753245b5f
9 changed files with 338 additions and 23 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
140
packages/channels/base/src/PairingStore.ts
Normal file
140
packages/channels/base/src/PairingStore.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: () => {},
|
||||
|
|
|
|||
66
packages/cli/src/commands/channel/pairing.ts
Normal file
66
packages/cli/src/commands/channel/pairing.ts
Normal 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}".`,
|
||||
);
|
||||
},
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue