mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-01 21:20:44 +00:00
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:
parent
697898a9fb
commit
9c001ba61e
4 changed files with 130 additions and 53 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue