feat(channels): add shared slash command system

- Add /help, /clear (aliases: /reset, /new), /status commands to ChannelBase
- Commands are handled locally without agent round-trip
- TelegramAdapter skips "Working..." indicator for local commands
- Update docs to reflect new command structure

This provides a consistent command interface across all channel types
(Telegram, WeChat, etc.) with platform-specific extensibility.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
tanzhenxin 2026-03-26 03:24:44 +00:00
parent 697898a9fb
commit 9c001ba61e
4 changed files with 130 additions and 53 deletions

View file

@ -217,14 +217,16 @@ Files work with any model — no multimodal support required.
## Slash Commands
Channels support slash commands. Some are handled locally by the adapter:
Channels support slash commands. These are handled locally (no agent round-trip):
- `/start` — Welcome message
- `/help` — List available commands
- `/reset` — Reset your session and start fresh
- `/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, etc.).
## Running
```bash

View file

@ -88,7 +88,7 @@ You can send photos and documents to the bot, not just text.
## 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 `/reset` to start fresh.
- **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

View file

@ -9,6 +9,9 @@ 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<boolean>;
export abstract class ChannelBase {
protected config: ChannelConfig;
protected bridge: AcpBridge;
@ -17,6 +20,7 @@ export abstract class ChannelBase {
protected router: SessionRouter;
protected name: string;
private instructedSessions: Set<string> = new Set();
private commands: Map<string, CommandHandler> = new Map();
constructor(
name: string,
@ -41,6 +45,8 @@ export abstract class ChannelBase {
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) {
@ -64,6 +70,98 @@ export abstract class ChannelBase {
onToolCall(_chatId: string, _event: ToolCallEvent): void {}
/**
* 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 removed = this.router.removeSession(this.name, envelope.senderId);
if (removed) {
this.instructedSessions.clear();
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);
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<void> {
// 1. Group gate: policy + allowlist + mention gating
const groupResult = this.groupGate.check(envelope);
@ -80,6 +178,17 @@ export abstract class ChannelBase {
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,

View file

@ -14,8 +14,8 @@ import type {
AcpBridge,
} from '@qwen-code/channel-base';
// Commands handled locally by the Telegram adapter (not forwarded to ACP)
const LOCAL_COMMANDS = new Set(['start', 'help', 'reset']);
// Commands handled by Telegraf directly (before handleInbound)
const TELEGRAF_COMMANDS = new Set<string>();
export class TelegramChannel extends ChannelBase {
private bot: Telegraf;
@ -36,54 +36,16 @@ export class TelegramChannel extends ChannelBase {
const botInfo = await this.bot.telegram.getMe();
this.botId = botInfo.id;
this.botUsername = botInfo.username ?? '';
// Register local-only commands
this.bot.command('start', async (ctx) => {
await ctx.reply(
`Hi ${ctx.from.first_name}! I'm a Qwen Code agent.\n\nSend any message to chat, or use slash commands like /compress, /summary.\n\nType /help for more info.`,
);
});
this.bot.command('help', async (ctx) => {
const lines = [
'Local commands:',
'/start — Welcome message',
'/help — Show this help',
'/reset — Reset your session (start fresh)',
];
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 ctx.reply(lines.join('\n'));
});
this.bot.command('reset', async (ctx) => {
const senderId = String(ctx.from.id);
const removed = this.router.removeSession(this.name, senderId);
if (removed) {
await ctx.reply(
'Session reset. Your next message will start a fresh conversation.',
);
} else {
await ctx.reply('No active session to reset.');
}
});
// All other messages (including non-local slash commands) go through handleInbound
// All messages (including slash commands) go through handleInbound
// where ChannelBase dispatches shared commands (/help, /clear, /status, etc.)
this.bot.on('text', async (ctx) => {
const msg = ctx.message;
const text = msg.text;
// Skip if it's a local command (already handled above)
// Skip Telegraf-handled commands
if (text.startsWith('/')) {
const command = text.slice(1).split(/[\s@]/)[0]?.toLowerCase();
if (command && LOCAL_COMMANDS.has(command)) {
if (command && TELEGRAF_COMMANDS.has(command)) {
return;
}
}
@ -200,15 +162,19 @@ export class TelegramChannel extends ChannelBase {
return;
}
// Send "Working..." immediately for instant feedback
const workingMsg = await this.bot.telegram
.sendMessage(envelope.chatId, 'Working...')
.catch(() => null);
// Skip "Working..." for local slash commands — they respond instantly
const isLocalCommand =
envelope.text.startsWith('/') && this.isLocalCommand(envelope.text);
const workingMsg = isLocalCommand
? null
: await this.bot.telegram
.sendMessage(envelope.chatId, 'Working...')
.catch(() => null);
try {
await super.handleInbound(envelope);
} finally {
// Always delete "Working..." — even on error/timeout
if (workingMsg) {
this.bot.telegram
.deleteMessage(envelope.chatId, workingMsg.message_id)