diff --git a/docs/users/features/channels/overview.md b/docs/users/features/channels/overview.md index a70c254a7..6ba103845 100644 --- a/docs/users/features/channels/overview.md +++ b/docs/users/features/channels/overview.md @@ -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 diff --git a/docs/users/features/channels/telegram.md b/docs/users/features/channels/telegram.md index 5c5f1bddd..3e62ebbce 100644 --- a/docs/users/features/channels/telegram.md +++ b/docs/users/features/channels/telegram.md @@ -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 diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index c8e4e7f33..0ff0d36f3 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -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; + 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 = new Set(); + private commands: Map = 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 { // 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, diff --git a/packages/channels/telegram/src/TelegramAdapter.ts b/packages/channels/telegram/src/TelegramAdapter.ts index 14533f304..af75be9de 100644 --- a/packages/channels/telegram/src/TelegramAdapter.ts +++ b/packages/channels/telegram/src/TelegramAdapter.ts @@ -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(); 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)