diff --git a/packages/channels/base/src/AcpBridge.ts b/packages/channels/base/src/AcpBridge.ts index 2860f1a7f..2a9143493 100644 --- a/packages/channels/base/src/AcpBridge.ts +++ b/packages/channels/base/src/AcpBridge.ts @@ -19,16 +19,26 @@ export interface AcpBridgeOptions { cwd: string; } +export interface AvailableCommand { + name: string; + description: string; +} + export class AcpBridge extends EventEmitter { private child: ChildProcess | null = null; private connection: ClientSideConnection | null = null; private options: AcpBridgeOptions; + private _availableCommands: AvailableCommand[] = []; constructor(options: AcpBridgeOptions) { super(); this.options = options; } + get availableCommands(): AvailableCommand[] { + return this._availableCommands; + } + async start(): Promise { const { cliEntryPath, cwd } = this.options; @@ -80,6 +90,16 @@ export class AcpBridge extends EventEmitter { ? JSON.stringify(update.content).substring(0, 200) : '', ); + + // Capture available commands from ACP + if ( + update?.sessionUpdate === 'available_commands_update' && + Array.isArray(update.availableCommands) + ) { + this._availableCommands = + update.availableCommands as AvailableCommand[]; + } + this.emit('sessionUpdate', params); return Promise.resolve(); }, diff --git a/packages/channels/base/src/SessionRouter.ts b/packages/channels/base/src/SessionRouter.ts index 31e55ed24..4097a1870 100644 --- a/packages/channels/base/src/SessionRouter.ts +++ b/packages/channels/base/src/SessionRouter.ts @@ -34,4 +34,17 @@ export class SessionRouter { getTarget(sessionId: string): SessionTarget | undefined { return this.toTarget.get(sessionId); } + + hasSession(channelName: string, senderId: string): boolean { + return this.toSession.has(`${channelName}:${senderId}`); + } + + removeSession(channelName: string, senderId: string): boolean { + const key = `${channelName}:${senderId}`; + const sessionId = this.toSession.get(key); + if (!sessionId) return false; + this.toSession.delete(key); + this.toTarget.delete(sessionId); + return true; + } } diff --git a/packages/channels/base/src/index.ts b/packages/channels/base/src/index.ts index 92f278d0d..9308d7f55 100644 --- a/packages/channels/base/src/index.ts +++ b/packages/channels/base/src/index.ts @@ -1,5 +1,5 @@ export { AcpBridge } from './AcpBridge.js'; -export type { AcpBridgeOptions } from './AcpBridge.js'; +export type { AcpBridgeOptions, AvailableCommand } from './AcpBridge.js'; export { ChannelBase } from './ChannelBase.js'; export { SenderGate } from './SenderGate.js'; export { SessionRouter } from './SessionRouter.js'; diff --git a/packages/channels/telegram/src/TelegramAdapter.ts b/packages/channels/telegram/src/TelegramAdapter.ts index e7209ae0c..a32579c79 100644 --- a/packages/channels/telegram/src/TelegramAdapter.ts +++ b/packages/channels/telegram/src/TelegramAdapter.ts @@ -7,6 +7,9 @@ import { ChannelBase } from '@qwen-code/channel-base'; import type { ChannelConfig, Envelope } from '@qwen-code/channel-base'; 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']); + export class TelegramChannel extends ChannelBase { private bot: Telegraf; @@ -16,8 +19,58 @@ export class TelegramChannel extends ChannelBase { } async connect(): Promise { + // 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 this.bot.on('text', async (ctx) => { const msg = ctx.message; + const text = msg.text; + + // Skip if it's a local command (already handled above) + if (text.startsWith('/')) { + const command = text.slice(1).split(/[\s@]/)[0]?.toLowerCase(); + if (command && LOCAL_COMMANDS.has(command)) { + return; + } + } + const envelope: Envelope = { channelName: this.name, senderId: String(msg.from.id), @@ -25,7 +78,7 @@ export class TelegramChannel extends ChannelBase { msg.from.first_name + (msg.from.last_name ? ` ${msg.from.last_name}` : ''), chatId: String(msg.chat.id), - text: msg.text, + text, }; try {