diff --git a/adapters/common/__tests__/http-client.test.ts b/adapters/common/__tests__/http-client.test.ts index 4135aa6e..65549f12 100644 --- a/adapters/common/__tests__/http-client.test.ts +++ b/adapters/common/__tests__/http-client.test.ts @@ -158,4 +158,122 @@ describe('AdapterHttpClient', () => { 'http://127.0.0.1:3456/api/tasks/lists/session-123', ) }) + + it('listSessions calls GET /api/sessions with project and pagination query', async () => { + globalThis.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify({ + sessions: [ + { + id: 'session-1', + title: 'Fix Telegram menu', + createdAt: '2026-06-09T00:00:00.000Z', + modifiedAt: '2026-06-09T01:00:00.000Z', + messageCount: 3, + projectPath: '-repo', + projectRoot: '/repo', + workDir: '/repo', + workDirExists: true, + }, + ], + total: 1, + }), { + headers: { 'Content-Type': 'application/json' }, + })) + ) as any + + const result = await client.listSessions({ project: '/repo', limit: 10, offset: 5 }) + + expect(result.sessions[0]?.id).toBe('session-1') + expect((globalThis.fetch as any).mock.calls[0][0]).toBe( + 'http://127.0.0.1:3456/api/sessions?project=%2Frepo&limit=10&offset=5', + ) + }) + + it('lists and activates providers through the server provider API', async () => { + globalThis.fetch = mock((url: string, init?: RequestInit) => { + if (url.endsWith('/api/providers') && !init?.method) { + return Promise.resolve(new Response(JSON.stringify({ + providers: [{ id: 'provider-1', name: 'Provider One', models: { main: 'model-main' } }], + activeId: null, + }), { + headers: { 'Content-Type': 'application/json' }, + })) + } + return Promise.resolve(new Response(JSON.stringify({ ok: true }), { + headers: { 'Content-Type': 'application/json' }, + })) + }) as any + + const providers = await client.listProviders() + await client.activateProvider('provider-1') + await client.activateOfficialProvider() + + expect(providers.providers[0]?.name).toBe('Provider One') + expect((globalThis.fetch as any).mock.calls[1][0]).toBe( + 'http://127.0.0.1:3456/api/providers/provider-1/activate', + ) + expect((globalThis.fetch as any).mock.calls[1][1].method).toBe('POST') + expect((globalThis.fetch as any).mock.calls[2][0]).toBe( + 'http://127.0.0.1:3456/api/providers/official', + ) + }) + + it('lists and sets models through the server models API', async () => { + globalThis.fetch = mock((url: string, init?: RequestInit) => { + if (url.endsWith('/api/models') && !init?.method) { + return Promise.resolve(new Response(JSON.stringify({ + provider: null, + models: [{ id: 'claude-opus-4-7', name: 'Opus 4.7', description: 'Most capable', context: '1m' }], + }), { + headers: { 'Content-Type': 'application/json' }, + })) + } + if (url.endsWith('/api/models/current') && !init?.method) { + return Promise.resolve(new Response(JSON.stringify({ + model: { id: 'claude-opus-4-7', name: 'Opus 4.7', description: 'Most capable', context: '1m' }, + }), { + headers: { 'Content-Type': 'application/json' }, + })) + } + return Promise.resolve(new Response(JSON.stringify({ ok: true, model: 'claude-sonnet-4-6' }), { + headers: { 'Content-Type': 'application/json' }, + })) + }) as any + + const models = await client.listModels() + const current = await client.getCurrentModel() + await client.setCurrentModel('claude-sonnet-4-6') + + expect(models.models[0]?.id).toBe('claude-opus-4-7') + expect(current.model.id).toBe('claude-opus-4-7') + expect(JSON.parse((globalThis.fetch as any).mock.calls[2][1].body)).toEqual({ + modelId: 'claude-sonnet-4-6', + }) + }) + + it('lists skills for a cwd through the server skills API', async () => { + globalThis.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify({ + skills: [ + { + name: 'reviewer', + description: 'Review code', + source: 'user', + userInvocable: true, + contentLength: 120, + hasDirectory: true, + }, + ], + }), { + headers: { 'Content-Type': 'application/json' }, + })) + ) as any + + const result = await client.listSkills('/repo') + + expect(result.skills[0]?.name).toBe('reviewer') + expect((globalThis.fetch as any).mock.calls[0][0]).toBe( + 'http://127.0.0.1:3456/api/skills?cwd=%2Frepo', + ) + }) }) diff --git a/adapters/common/__tests__/ws-bridge.test.ts b/adapters/common/__tests__/ws-bridge.test.ts index 787be014..5cbb8886 100644 --- a/adapters/common/__tests__/ws-bridge.test.ts +++ b/adapters/common/__tests__/ws-bridge.test.ts @@ -203,9 +203,43 @@ describe('WsBridge: handler serialization', () => { bridge.onServerMessage('chat-reset', () => {}) bridge.connectSession('chat-reset', 'sess-reset') await bridge.waitForOpen('chat-reset') + const staleSession = (bridge as any).sessions.get('chat-reset') + expect(staleSession.ws.listenerCount('message')).toBeGreaterThan(0) bridge.resetSession('chat-reset') expect(bridge.hasSession('chat-reset')).toBe(false) + expect(staleSession.ws.listenerCount('message')).toBe(0) + expect(staleSession.ws.listenerCount('close')).toBe(0) + expect(staleSession.ws.listenerCount('error')).toBe(0) + + bridge.destroy() + }) + + it('does not dispatch stale messages from a socket reset before reconnect', async () => { + const bridge = new WsBridge(serverUrl, 'test') + const events: string[] = [] + + bridge.onServerMessage('chat-resume', (msg: any) => { + events.push(String(msg.tag)) + }) + bridge.connectSession('chat-resume', 'sess-old') + expect(await bridge.waitForOpen('chat-resume')).toBe(true) + const oldServerWs = await waitForServerConnection() + + bridge.resetSession('chat-resume') + bridge.onServerMessage('chat-resume', (msg: any) => { + events.push(String(msg.tag)) + }) + bridge.connectSession('chat-resume', 'sess-new') + expect(await bridge.waitForOpen('chat-resume')).toBe(true) + const newServerWs = connections[1]! + + oldServerWs.send(JSON.stringify({ tag: 'stale-old' })) + newServerWs.send(JSON.stringify({ tag: 'fresh-new' })) + + await new Promise((resolve) => setTimeout(resolve, 80)) + + expect(events).toEqual(['fresh-new']) bridge.destroy() }) diff --git a/adapters/common/http-client.ts b/adapters/common/http-client.ts index 4c006d96..18c9da18 100644 --- a/adapters/common/http-client.ts +++ b/adapters/common/http-client.ts @@ -26,6 +26,50 @@ export type SessionTask = { status: 'pending' | 'in_progress' | 'completed' } +export type SessionListItem = { + id: string + title: string + createdAt: string + modifiedAt: string + messageCount: number + projectPath: string + projectRoot?: string | null + workDir: string | null + workDirExists: boolean + permissionMode?: string +} + +export type ProviderSummary = { + id: string + name: string + presetId?: string + models?: { + main?: string + haiku?: string + sonnet?: string + opus?: string + } +} + +export type ModelSummary = { + id: string + name: string + description: string + context: string +} + +export type SkillSummary = { + name: string + displayName?: string + description: string + source: 'user' | 'project' | 'plugin' + userInvocable: boolean + version?: string + contentLength: number + hasDirectory: boolean + pluginName?: string +} + export class AdapterHttpClient { readonly httpBaseUrl: string private readonly allowedProjectRoots: string[] @@ -188,6 +232,143 @@ export class AdapterHttpClient { clearTimeout(timer) } } + + async listSessions(options?: { + project?: string + limit?: number + offset?: number + }): Promise<{ sessions: SessionListItem[]; total: number }> { + const params = new URLSearchParams() + if (options?.project) params.set('project', options.project) + if (options?.limit !== undefined) params.set('limit', String(options.limit)) + if (options?.offset !== undefined) params.set('offset', String(options.offset)) + const suffix = params.toString() ? `?${params.toString()}` : '' + + const { controller, timer } = this.createTimeoutController() + try { + const res = await fetch(`${this.httpBaseUrl}/api/sessions${suffix}`, { + signal: controller.signal, + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ message: res.statusText })) + throw new Error(`Failed to list sessions: ${(err as any).message}`) + } + return (await res.json()) as { sessions: SessionListItem[]; total: number } + } finally { + clearTimeout(timer) + } + } + + async listProviders(): Promise<{ providers: ProviderSummary[]; activeId: string | null }> { + const { controller, timer } = this.createTimeoutController() + try { + const res = await fetch(`${this.httpBaseUrl}/api/providers`, { + signal: controller.signal, + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ message: res.statusText })) + throw new Error(`Failed to list providers: ${(err as any).message}`) + } + return (await res.json()) as { providers: ProviderSummary[]; activeId: string | null } + } finally { + clearTimeout(timer) + } + } + + async activateProvider(providerId: string): Promise { + await this.postJson(`/api/providers/${encodeURIComponent(providerId)}/activate`) + } + + async activateOfficialProvider(): Promise { + await this.postJson('/api/providers/official') + } + + async listModels(): Promise<{ models: ModelSummary[]; provider: { id: string; name: string } | null }> { + const { controller, timer } = this.createTimeoutController() + try { + const res = await fetch(`${this.httpBaseUrl}/api/models`, { + signal: controller.signal, + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ message: res.statusText })) + throw new Error(`Failed to list models: ${(err as any).message}`) + } + return (await res.json()) as { models: ModelSummary[]; provider: { id: string; name: string } | null } + } finally { + clearTimeout(timer) + } + } + + async getCurrentModel(): Promise<{ model: ModelSummary }> { + const { controller, timer } = this.createTimeoutController() + try { + const res = await fetch(`${this.httpBaseUrl}/api/models/current`, { + signal: controller.signal, + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ message: res.statusText })) + throw new Error(`Failed to get current model: ${(err as any).message}`) + } + return (await res.json()) as { model: ModelSummary } + } finally { + clearTimeout(timer) + } + } + + async setCurrentModel(modelId: string): Promise { + await this.putJson('/api/models/current', { modelId }) + } + + async listSkills(cwd: string): Promise<{ skills: SkillSummary[] }> { + const params = new URLSearchParams({ cwd }) + const { controller, timer } = this.createTimeoutController() + try { + const res = await fetch(`${this.httpBaseUrl}/api/skills?${params.toString()}`, { + signal: controller.signal, + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ message: res.statusText })) + throw new Error(`Failed to list skills: ${(err as any).message}`) + } + return (await res.json()) as { skills: SkillSummary[] } + } finally { + clearTimeout(timer) + } + } + + private async postJson(pathname: string): Promise { + const { controller, timer } = this.createTimeoutController() + try { + const res = await fetch(`${this.httpBaseUrl}${pathname}`, { + method: 'POST', + signal: controller.signal, + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ message: res.statusText })) + throw new Error(`Request failed: ${(err as any).message}`) + } + } finally { + clearTimeout(timer) + } + } + + private async putJson(pathname: string, body: Record): Promise { + const { controller, timer } = this.createTimeoutController() + try { + const res = await fetch(`${this.httpBaseUrl}${pathname}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: controller.signal, + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ message: res.statusText })) + throw new Error(`Request failed: ${(err as any).message}`) + } + } finally { + clearTimeout(timer) + } + } } function isPathWithinAllowedRoots(target: string, roots: string[]): boolean { diff --git a/adapters/common/ws-bridge.ts b/adapters/common/ws-bridge.ts index 21e633db..413e2d2c 100644 --- a/adapters/common/ws-bridge.ts +++ b/adapters/common/ws-bridge.ts @@ -129,6 +129,7 @@ export class WsBridge { const session = this.sessions.get(chatId) if (session) { if (session.reconnectTimer) clearTimeout(session.reconnectTimer) + session.ws.removeAllListeners() session.ws.close(1000, 'session reset') this.sessions.delete(chatId) } @@ -150,6 +151,7 @@ export class WsBridge { } for (const [, session] of this.sessions) { if (session.reconnectTimer) clearTimeout(session.reconnectTimer) + session.ws.removeAllListeners() session.ws.close(1000, 'bridge destroyed') } this.sessions.clear() @@ -192,6 +194,7 @@ export class WsBridge { return } if (msg.type === 'pong') return + if (this.sessions.get(chatId) !== session) return const handler = this.handlers.get(chatId) if (!handler) return diff --git a/adapters/telegram/__tests__/commands.test.ts b/adapters/telegram/__tests__/commands.test.ts new file mode 100644 index 00000000..f6d76d55 --- /dev/null +++ b/adapters/telegram/__tests__/commands.test.ts @@ -0,0 +1,611 @@ +import { describe, expect, it, mock } from 'bun:test' +import { + OPENAI_OFFICIAL_DEFAULT_MODEL_ID, + buildModelSelectionItems, + buildProviderSelectionItems, + createTelegramCommandController, + createTelegramRuntimeCommandController, + registerTelegramExtendedCommands, + renderSelectionView, + sessionToSelectionItem, + skillToSelectionItem, + tryHandleTelegramSelectionCallback, +} from '../commands.js' + +function createCommandContext(options?: { + chatId?: number + userId?: number + match?: string + text?: string +}) { + const replies: string[] = [] + const edits: string[] = [] + const answers: Array = [] + const ctx = { + chat: { id: options?.chatId ?? 42, type: 'private' }, + from: { id: options?.userId ?? 7 }, + match: options?.match, + callbackQuery: { + message: { + chat: { id: options?.chatId ?? 42 }, + text: options?.text ?? 'choose', + }, + }, + reply: mock(async (text: string) => { + replies.push(text) + }), + editMessageText: mock(async (text: string) => { + edits.push(text) + }), + answerCallbackQuery: mock(async (text?: string) => { + answers.push(text) + }), + } + return { ctx, replies, edits, answers } +} + +function createController(overrides?: Record) { + const sent: Array<{ chatId: number; text: string; options?: unknown }> = [] + const runtimeModels: string[] = [] + const bridgeEvents: string[] = [] + const deps = { + api: { + sendMessage: mock(async (chatId: number, text: string, options?: unknown) => { + sent.push({ chatId, text, options }) + }), + }, + httpClient: { + listProviders: mock(async () => ({ + activeId: 'anthropic', + providers: [ + { + id: 'anthropic', + name: 'Anthropic', + models: { main: 'claude-sonnet-4-5' }, + }, + ], + })), + activateOfficialProvider: mock(async () => {}), + activateProvider: mock(async () => {}), + listModels: mock(async () => ({ + provider: { id: 'anthropic', name: 'Anthropic' }, + models: [ + { id: 'claude-sonnet-4-5', name: 'Sonnet', context: '200K' }, + { id: 'claude-opus-4-5', name: 'Opus' }, + ], + })), + getCurrentModel: mock(async () => ({ model: { id: 'claude-sonnet-4-5' } })), + setCurrentModel: mock(async () => {}), + listSkills: mock(async () => ({ + skills: [ + { + name: 'skill-a', + displayName: 'Skill A', + description: 'does a', + source: 'plugin', + userInvocable: true, + contentLength: 123, + hasDirectory: true, + }, + { + name: 'hidden', + displayName: 'Hidden', + description: 'no', + source: 'plugin', + userInvocable: false, + contentLength: 10, + hasDirectory: false, + }, + ], + })), + listRecentProjects: mock(async () => [ + { + projectName: 'repo', + realPath: '/work/repo', + branch: 'main', + sessionCount: 2, + }, + ]), + listSessions: mock(async () => ({ + sessions: [ + { + id: 'session-123456789', + title: 'Fix IM', + createdAt: '2026-06-09T07:00:00.000Z', + workDir: '/work/repo', + projectPath: '/work/repo', + workDirExists: true, + modifiedAt: '2026-06-09T08:00:00.000Z', + messageCount: 5, + }, + ], + total: 1, + })), + }, + defaultWorkDir: '/work/repo', + isAllowedUser: mock(() => true), + ensureExistingSession: mock(async () => ({ sessionId: 'active', workDir: '/work/repo' })), + clearTransientChatState: mock((chatId: string) => bridgeEvents.push(`clear:${chatId}`)), + setStoredSession: mock((chatId: string, sessionId: string, workDir: string) => { + bridgeEvents.push(`store:${chatId}:${sessionId}:${workDir}`) + }), + deleteStoredSession: mock((chatId: string) => bridgeEvents.push(`delete:${chatId}`)), + resetBridgeSession: mock((chatId: string) => bridgeEvents.push(`reset:${chatId}`)), + connectBridgeSession: mock((chatId: string, sessionId: string) => { + bridgeEvents.push(`connect:${chatId}:${sessionId}`) + }), + onBridgeServerMessage: mock((chatId: string) => bridgeEvents.push(`listen:${chatId}`)), + waitForBridgeOpen: mock(async () => true), + setRuntimeModel: mock((_chatId: string, modelId: string) => { + runtimeModels.push(modelId) + }), + ...overrides, + } as any + + return { + controller: createTelegramCommandController(deps), + deps, + sent, + runtimeModels, + bridgeEvents, + } +} + +describe('Telegram command controller helpers', () => { + it('builds provider, model, skill, session, and selection view data', () => { + expect(buildProviderSelectionItems([ + { + id: 'p1', + name: 'Provider One', + models: { main: 'model-main' }, + }, + ], 'p1').map((item) => item.value)).toEqual(['official', 'openai-official', 'p1']) + + expect(buildModelSelectionItems([ + { id: 'm1', name: 'Model One', context: '8K', description: 'fast' }, + ], 'm1')[0]).toEqual({ + label: '✓ Model One', + value: 'm1', + description: 'm1 · 8K · fast', + }) + + expect(skillToSelectionItem({ + name: 'x', + displayName: 'Skill X', + description: 'desc', + source: 'plugin', + pluginName: 'pkg', + userInvocable: true, + contentLength: 20, + hasDirectory: true, + })).toEqual({ + label: 'Skill X', + value: 'x', + description: 'plugin:pkg · desc', + }) + + expect(sessionToSelectionItem({ + id: 'abcdef123456789', + title: '', + createdAt: '2026-06-09T07:00:00.000Z', + workDir: '/repo', + projectPath: '/repo', + workDirExists: true, + modifiedAt: 'not-a-date', + messageCount: 3, + })).toEqual({ + label: '会话 abcdef12', + value: 'abcdef123456789', + description: 'not-a-date · 3 条消息 · /repo', + meta: { workDir: '/repo' }, + }) + + const view = renderSelectionView({ + kind: 'model', + title: 'Pick', + items: [{ label: 'Model', value: 'm' }], + page: 0, + expiresAt: Date.now() + 1000, + }) + expect(view.text).toContain('1. Model') + expect(view.replyMarkup.inline_keyboard[0][0]).toEqual({ + text: 'Model', + callback_data: 'tgsel:model:pick:0', + }) + }) + + it('registers extended commands and routes selection callbacks', async () => { + const commands: string[] = [] + const bot = { + command: mock((command: string) => commands.push(command)), + } + const controller = { + sendHelp: mock(async () => {}), + handleResumeCommand: mock(async () => {}), + handleProviderCommand: mock(async () => {}), + handleModelCommand: mock(async () => {}), + handleSkillsCommand: mock(async () => {}), + handleSelectionCallback: mock(async () => {}), + } as any + + registerTelegramExtendedCommands(bot, controller) + expect(commands).toEqual(['start', 'help', 'resume', 'provider', 'model', 'skills']) + + const handled = await tryHandleTelegramSelectionCallback( + 'tgsel:model:pick:2', + createCommandContext().ctx, + controller, + ) + expect(handled).toBe(true) + expect(controller.handleSelectionCallback).toHaveBeenCalledWith( + expect.anything(), + { kind: 'model', action: 'pick', index: 2 }, + ) + expect(await tryHandleTelegramSelectionCallback('permit:req:yes', createCommandContext().ctx, controller)) + .toBe(false) + }) + + it('syncs official provider command and rejects unauthorized private chats', async () => { + const { controller, deps, runtimeModels } = createController() + const allowed = createCommandContext({ match: 'claude' }) + + await controller.handleProviderCommand(allowed.ctx) + + expect(deps.httpClient.activateOfficialProvider).toHaveBeenCalled() + expect(deps.httpClient.setCurrentModel).toHaveBeenCalledWith('claude-opus-4-7') + expect(runtimeModels).toEqual(['claude-opus-4-7']) + expect(allowed.replies[0]).toContain('Claude 官方') + + const denied = createCommandContext({ userId: 999 }) + deps.isAllowedUser.mockImplementation(() => false) + await controller.handleProviderCommand(denied.ctx) + expect(denied.replies[0]).toContain('未授权') + }) + + it('supports direct custom provider and model commands', async () => { + const { controller, deps, runtimeModels } = createController() + + await controller.handleProviderCommand(createCommandContext({ match: 'anthropic' }).ctx) + expect(deps.httpClient.activateProvider).toHaveBeenCalledWith('anthropic') + expect(deps.httpClient.setCurrentModel).toHaveBeenCalledWith('claude-sonnet-4-5') + expect(runtimeModels).toContain('claude-sonnet-4-5') + + await controller.handleModelCommand(createCommandContext({ match: 'manual-model' }).ctx) + expect(deps.httpClient.setCurrentModel).toHaveBeenCalledWith('manual-model') + expect(runtimeModels).toContain('manual-model') + }) + + it('reports empty lists and command failures without throwing', async () => { + const { controller, sent } = createController({ + defaultWorkDir: '', + ensureExistingSession: mock(async () => null), + httpClient: { + listProviders: mock(async () => { throw new Error('providers down') }), + activateOfficialProvider: mock(async () => {}), + activateProvider: mock(async () => { throw new Error('bad provider') }), + listModels: mock(async () => ({ provider: null, models: [] })), + getCurrentModel: mock(async () => ({ model: { id: 'none' } })), + setCurrentModel: mock(async () => { throw new Error('bad model') }), + listSkills: mock(async () => ({ skills: [] })), + listRecentProjects: mock(async () => []), + listSessions: mock(async () => ({ sessions: [], total: 0 })), + }, + }) + + await controller.handleProviderCommand(createCommandContext().ctx) + expect(sent.at(-1)?.text).toContain('无法获取 Provider 列表') + + const providerCtx = createCommandContext({ match: 'broken' }) + await controller.handleProviderCommand(providerCtx.ctx) + expect(providerCtx.replies[0]).toContain('Provider 切换失败') + + await controller.handleModelCommand(createCommandContext().ctx) + expect(sent.at(-1)?.text).toContain('没有可用模型') + + await controller.handleModelCommand(createCommandContext({ match: 'broken-model' }).ctx) + expect(sent.at(-1)?.text).toContain('模型切换失败') + + await controller.handleSkillsCommand(createCommandContext().ctx) + expect(sent.at(-1)?.text).toContain('请先发送 /new') + + await controller.handleResumeCommand(createCommandContext().ctx) + expect(sent.at(-1)?.text).toContain('没有找到最近项目') + }) + + it('covers command fallback branches for help, paging, and list errors', async () => { + const { controller, sent } = createController() + const help = createCommandContext() + await controller.sendHelp(help.ctx) + expect(help.replies[0]).toContain('/resume') + + await controller.handleModelCommand(createCommandContext().ctx) + const page = createCommandContext() + page.ctx.editMessageText = mock(async () => { throw new Error('edit failed') }) + await controller.handleSelectionCallback(page.ctx, { + kind: 'model', + action: 'page', + index: 0, + }) + expect(sent.at(-1)?.text).toContain('选择模型') + + const noInvocable = createController({ + httpClient: { + ...createController().deps.httpClient, + listSkills: mock(async () => ({ + skills: [{ + name: 'hidden', + displayName: 'Hidden', + description: 'hidden', + source: 'plugin', + userInvocable: false, + contentLength: 1, + hasDirectory: false, + }], + })), + }, + }) + await noInvocable.controller.handleSkillsCommand(createCommandContext().ctx) + expect(noInvocable.sent.at(-1)?.text).toContain('没有可用 Skills') + + const listFailures = createController({ + httpClient: { + ...createController().deps.httpClient, + listModels: mock(async () => { throw new Error('models down') }), + listSkills: mock(async () => { throw new Error('skills down') }), + listRecentProjects: mock(async () => { throw new Error('projects down') }), + }, + }) + await listFailures.controller.handleModelCommand(createCommandContext().ctx) + await listFailures.controller.handleSkillsCommand(createCommandContext().ctx) + await listFailures.controller.handleResumeCommand(createCommandContext().ctx) + expect(listFailures.sent.map((message) => message.text).join('\n')).toContain('无法获取模型列表') + expect(listFailures.sent.map((message) => message.text).join('\n')).toContain('无法获取 Skills') + expect(listFailures.sent.map((message) => message.text).join('\n')).toContain('无法获取项目列表') + }) + + it('uses paginated callback state for provider selection', async () => { + const { controller, deps, runtimeModels, sent } = createController() + const command = createCommandContext() + await controller.handleProviderCommand(command.ctx) + + expect(sent[0].text).toContain('选择 Provider') + expect((sent[0].options as any).reply_markup.inline_keyboard[1][0].callback_data) + .toBe('tgsel:provider:pick:1') + + const callback = createCommandContext() + await controller.handleSelectionCallback(callback.ctx, { + kind: 'provider', + action: 'pick', + index: 1, + }) + + expect(deps.httpClient.activateProvider).toHaveBeenCalledWith('openai-official') + expect(deps.httpClient.setCurrentModel).toHaveBeenCalledWith(OPENAI_OFFICIAL_DEFAULT_MODEL_ID) + expect(runtimeModels).toContain(OPENAI_OFFICIAL_DEFAULT_MODEL_ID) + expect(callback.edits[0]).toContain('ChatGPT Official') + }) + + it('lists models and switches model through callback', async () => { + const { controller, deps, sent, runtimeModels } = createController() + await controller.handleModelCommand(createCommandContext().ctx) + + expect(sent[0].text).toContain('选择模型(Anthropic)') + const callback = createCommandContext() + await controller.handleSelectionCallback(callback.ctx, { + kind: 'model', + action: 'pick', + index: 0, + }) + + expect(deps.httpClient.setCurrentModel).toHaveBeenCalledWith('claude-sonnet-4-5') + expect(runtimeModels).toEqual(['claude-sonnet-4-5']) + expect(callback.edits[0]).toContain('已切换模型') + }) + + it('lists invocable skills and shows selected skill details', async () => { + const { controller, deps, sent } = createController() + await controller.handleSkillsCommand(createCommandContext().ctx) + + expect(deps.httpClient.listSkills).toHaveBeenCalledWith('/work/repo') + expect(sent[0].text).toContain('当前项目可用 Skills') + + const callback = createCommandContext() + await controller.handleSelectionCallback(callback.ctx, { + kind: 'skill', + action: 'pick', + index: 0, + }) + expect(callback.edits[0]).toContain('Skill:Skill A') + }) + + it('resumes a historical project session through two callbacks', async () => { + const { controller, deps, sent, bridgeEvents } = createController() + await controller.handleResumeCommand(createCommandContext().ctx) + + expect(sent[0].text).toContain('选择要恢复的项目') + const projectCallback = createCommandContext() + await controller.handleSelectionCallback(projectCallback.ctx, { + kind: 'resume_project', + action: 'pick', + index: 0, + }) + + expect(deps.httpClient.listSessions).toHaveBeenCalledWith({ + project: '/work/repo', + limit: 50, + offset: 0, + }) + expect(projectCallback.edits[0]).toContain('选择要恢复的会话') + + const sessionCallback = createCommandContext() + await controller.handleSelectionCallback(sessionCallback.ctx, { + kind: 'resume_session', + action: 'pick', + index: 0, + }) + + expect(bridgeEvents).toEqual([ + 'reset:42', + 'clear:42', + 'store:42:session-123456789:/work/repo', + 'connect:42:session-123456789', + 'listen:42', + ]) + expect(sessionCallback.edits[0]).toContain('已恢复会话') + }) + + it('handles selection callback edge cases and resume timeout cleanup', async () => { + const unauthorized = createController({ isAllowedUser: mock(() => false) }) + const denied = createCommandContext() + await unauthorized.controller.handleSelectionCallback(denied.ctx, { + kind: 'model', + action: 'pick', + index: 0, + }) + expect(denied.answers[0]).toBe('未授权') + + const { controller, deps, bridgeEvents } = createController({ + waitForBridgeOpen: mock(async () => false), + }) + const stale = createCommandContext() + await controller.handleSelectionCallback(stale.ctx, { + kind: 'model', + action: 'pick', + index: 0, + }) + expect(stale.answers[0]).toContain('选择已过期') + + await controller.handleModelCommand(createCommandContext().ctx) + const noop = createCommandContext() + await controller.handleSelectionCallback(noop.ctx, { + kind: 'model', + action: 'noop', + index: 0, + }) + expect(noop.answers).toContain(undefined) + + const missing = createCommandContext() + await controller.handleSelectionCallback(missing.ctx, { + kind: 'model', + action: 'pick', + index: 99, + }) + expect(missing.answers[0]).toContain('选项不存在') + + await controller.handleResumeCommand(createCommandContext().ctx) + await controller.handleSelectionCallback(createCommandContext().ctx, { + kind: 'resume_project', + action: 'pick', + index: 0, + }) + const timeout = createCommandContext() + await controller.handleSelectionCallback(timeout.ctx, { + kind: 'resume_session', + action: 'pick', + index: 0, + }) + + expect(deps.deleteStoredSession).toHaveBeenCalledWith('42') + expect(bridgeEvents).toContain('delete:42') + expect(timeout.edits[0]).toContain('连接服务器超时') + }) + + it('handles selection callback failures for provider, model, and session lists', async () => { + const provider = createController() + await provider.controller.handleProviderCommand(createCommandContext().ctx) + provider.deps.httpClient.activateProvider.mockImplementationOnce(async () => { throw new Error('provider failed') }) + const providerPick = createCommandContext() + await provider.controller.handleSelectionCallback(providerPick.ctx, { + kind: 'provider', + action: 'pick', + index: 1, + }) + expect(providerPick.edits[0]).toContain('Provider 切换失败') + + const model = createController() + await model.controller.handleModelCommand(createCommandContext().ctx) + model.deps.httpClient.setCurrentModel.mockImplementationOnce(async () => { throw new Error('model failed') }) + const modelPick = createCommandContext() + await model.controller.handleSelectionCallback(modelPick.ctx, { + kind: 'model', + action: 'pick', + index: 0, + }) + expect(modelPick.edits[0]).toContain('模型切换失败') + + const sessions = createController({ + httpClient: { + ...createController().deps.httpClient, + listSessions: mock(async () => ({ sessions: [], total: 0 })), + }, + }) + await sessions.controller.handleResumeCommand(createCommandContext().ctx) + const projectPick = createCommandContext() + await sessions.controller.handleSelectionCallback(projectPick.ctx, { + kind: 'resume_project', + action: 'pick', + index: 0, + }) + expect(projectPick.edits[0]).toContain('没有可恢复会话') + + const sessionFailure = createController({ + httpClient: { + ...createController().deps.httpClient, + listSessions: mock(async () => { throw new Error('sessions down') }), + }, + }) + await sessionFailure.controller.handleResumeCommand(createCommandContext().ctx) + const failedProjectPick = createCommandContext() + await sessionFailure.controller.handleSelectionCallback(failedProjectPick.ctx, { + kind: 'resume_project', + action: 'pick', + index: 0, + }) + expect(failedProjectPick.edits[0]).toContain('无法获取会话列表') + }) + + it('creates a controller from runtime dependencies', async () => { + const events: string[] = [] + const controller = createTelegramRuntimeCommandController({ + botApi: { sendMessage: mock(async () => {}) }, + httpClient: createController().deps.httpClient, + defaultWorkDir: '/work/repo', + bridge: { + resetSession: (chatId) => events.push(`reset:${chatId}`), + connectSession: (chatId, sessionId) => events.push(`connect:${chatId}:${sessionId}`), + onServerMessage: (chatId, handler) => { + events.push(`listen:${chatId}`) + void handler({ type: 'connected' }) + }, + waitForOpen: mock(async () => true), + }, + sessionStore: { + set: (chatId, sessionId, workDir) => events.push(`store:${chatId}:${sessionId}:${workDir}`), + delete: (chatId) => events.push(`delete:${chatId}`), + }, + isAllowedUser: () => true, + ensureExistingSession: mock(async () => ({ sessionId: 'active', workDir: '/work/repo' })), + clearTransientChatState: (chatId) => events.push(`clear:${chatId}`), + handleServerMessage: (chatId, msg) => { + events.push(`message:${chatId}:${(msg as any).type}`) + }, + setRuntimeModel: (chatId, modelId) => events.push(`model:${chatId}:${modelId}`), + }) + + await controller.setModelFromCommand('42', 'model-x') + await controller.handleResumeCommand(createCommandContext().ctx) + await controller.handleSelectionCallback(createCommandContext().ctx, { + kind: 'resume_project', + action: 'pick', + index: 0, + }) + await controller.handleSelectionCallback(createCommandContext().ctx, { + kind: 'resume_session', + action: 'pick', + index: 0, + }) + + expect(events).toContain('model:42:model-x') + expect(events).toContain('message:42:connected') + }) +}) diff --git a/adapters/telegram/__tests__/menu.test.ts b/adapters/telegram/__tests__/menu.test.ts new file mode 100644 index 00000000..8960e0ca --- /dev/null +++ b/adapters/telegram/__tests__/menu.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, mock } from 'bun:test' +import { + TELEGRAM_BOT_COMMANDS, + buildTelegramSelectionPage, + parseTelegramSelectionCallback, + syncTelegramBotCommands, +} from '../menu.js' + +describe('Telegram menu helpers', () => { + it('keeps bot commands valid for Telegram setMyCommands', () => { + expect(TELEGRAM_BOT_COMMANDS.length).toBeGreaterThan(0) + for (const command of TELEGRAM_BOT_COMMANDS) { + expect(command.command).toMatch(/^[a-z0-9_]{1,32}$/) + expect(command.description.trim().length).toBeGreaterThan(0) + expect(command.description.length).toBeLessThanOrEqual(256) + } + }) + + it('deletes stale bot commands before setting the current menu', async () => { + const calls: string[] = [] + const api = { + deleteMyCommands: mock(async () => { + calls.push('delete') + }), + setMyCommands: mock(async () => { + calls.push('set') + }), + } + + await syncTelegramBotCommands(api) + + expect(calls).toEqual(['delete', 'set']) + expect(api.setMyCommands).toHaveBeenCalledWith(TELEGRAM_BOT_COMMANDS) + }) + + it('builds paginated selection callbacks after eight options', () => { + const page = buildTelegramSelectionPage({ + kind: 'model', + items: Array.from({ length: 9 }, (_, index) => ({ + label: `Model ${index + 1}`, + value: `model-${index + 1}`, + })), + page: 0, + }) + + expect(page.totalPages).toBe(2) + expect(page.visibleItems).toHaveLength(8) + expect(page.rows.at(-1)).toEqual([ + { text: '1/2', callbackData: 'tgsel:model:noop:0' }, + { text: 'Next', callbackData: 'tgsel:model:page:1' }, + ]) + }) + + it('parses selection callbacks and rejects unrelated callback data', () => { + expect(parseTelegramSelectionCallback('tgsel:resume_session:pick:7')).toEqual({ + kind: 'resume_session', + action: 'pick', + index: 7, + }) + expect(parseTelegramSelectionCallback('permit:req:yes')).toBeNull() + expect(parseTelegramSelectionCallback('tgsel:model:page:-1')).toBeNull() + }) +}) diff --git a/adapters/telegram/commands.ts b/adapters/telegram/commands.ts new file mode 100644 index 00000000..0c98ce5c --- /dev/null +++ b/adapters/telegram/commands.ts @@ -0,0 +1,719 @@ +import { formatImHelp } from '../common/format.js' +import type { + AdapterHttpClient, + ProviderSummary, + SessionListItem, + SkillSummary, +} from '../common/http-client.js' +import { + buildTelegramSelectionPage, + parseTelegramSelectionCallback, + type TelegramSelectionCallback, + type TelegramSelectionItem, + type TelegramSelectionKind, +} from './menu.js' + +export const TELEGRAM_SELECTION_TTL_MS = 15 * 60 * 1000 +export const OFFICIAL_PROVIDER_VALUE = 'official' +export const OPENAI_OFFICIAL_PROVIDER_ID = 'openai-official' +export const OFFICIAL_DEFAULT_MODEL_ID = 'claude-opus-4-7' +export const OPENAI_OFFICIAL_DEFAULT_MODEL_ID = 'gpt-5.3-codex' + +type TelegramSendApi = { + sendMessage: (chatId: number, text: string, options?: TelegramSendOptions) => Promise +} + +type TelegramSendOptions = { + reply_markup?: TelegramInlineKeyboardMarkup +} + +type TelegramInlineKeyboardMarkup = { + inline_keyboard: Array> +} + +type TelegramCommandContext = { + chat?: { id: string | number; type?: string } + from?: { id: number } + match?: string | RegExpMatchArray + callbackQuery?: { + message?: { + chat: { id: string | number } + text?: string + } + } + reply: (text: string) => Promise + editMessageText: (text: string, options?: TelegramSendOptions) => Promise + answerCallbackQuery: (text?: string) => Promise +} + +type PendingTelegramSelection = { + kind: TelegramSelectionKind + title: string + items: TelegramSelectionItem[] + page: number + expiresAt: number +} + +type NewSelection = Omit + +type RuntimeModelSetter = (chatId: string, modelId: string) => void + +export type TelegramCommandControllerDeps = { + api: TelegramSendApi + httpClient: AdapterHttpClient + defaultWorkDir: string + isAllowedUser: (userId: number) => boolean + ensureExistingSession: (chatId: string) => Promise<{ sessionId: string; workDir: string } | null> + clearTransientChatState: (chatId: string) => void + setStoredSession: (chatId: string, sessionId: string, workDir: string) => void + deleteStoredSession: (chatId: string) => void + resetBridgeSession: (chatId: string) => void + connectBridgeSession: (chatId: string, sessionId: string) => void + onBridgeServerMessage: (chatId: string) => void + waitForBridgeOpen: (chatId: string) => Promise + setRuntimeModel: RuntimeModelSetter +} + +export type TelegramCommandController = ReturnType + +export type TelegramCommandRegistrar = { + command: (command: string, handler: (ctx: TelegramCommandContext) => unknown) => unknown +} + +export type TelegramRuntimeCommandControllerDeps = { + botApi: TelegramSendApi + httpClient: AdapterHttpClient + defaultWorkDir: string + bridge: { + resetSession: (chatId: string) => void + connectSession: (chatId: string, sessionId: string) => void + onServerMessage: (chatId: string, handler: (msg: unknown) => void | Promise) => void + waitForOpen: (chatId: string) => Promise + } + sessionStore: { + set: (chatId: string, sessionId: string, workDir: string) => void + delete: (chatId: string) => void + } + isAllowedUser: (userId: number) => boolean + ensureExistingSession: (chatId: string) => Promise<{ sessionId: string; workDir: string } | null> + clearTransientChatState: (chatId: string) => void + handleServerMessage: (chatId: string, msg: unknown) => void | Promise + setRuntimeModel: (chatId: string, modelId: string) => void +} + +export function createTelegramRuntimeCommandController( + deps: TelegramRuntimeCommandControllerDeps, +): TelegramCommandController { + return createTelegramCommandController({ + api: deps.botApi, + httpClient: deps.httpClient, + defaultWorkDir: deps.defaultWorkDir, + isAllowedUser: deps.isAllowedUser, + ensureExistingSession: deps.ensureExistingSession, + clearTransientChatState: deps.clearTransientChatState, + setStoredSession: (chatId, sessionId, workDir) => deps.sessionStore.set(chatId, sessionId, workDir), + deleteStoredSession: (chatId) => deps.sessionStore.delete(chatId), + resetBridgeSession: (chatId) => deps.bridge.resetSession(chatId), + connectBridgeSession: (chatId, sessionId) => deps.bridge.connectSession(chatId, sessionId), + onBridgeServerMessage: (chatId) => deps.bridge.onServerMessage( + chatId, + (msg) => deps.handleServerMessage(chatId, msg), + ), + waitForBridgeOpen: (chatId) => deps.bridge.waitForOpen(chatId), + setRuntimeModel: deps.setRuntimeModel, + }) +} + +export function registerTelegramExtendedCommands( + bot: TelegramCommandRegistrar, + controller: TelegramCommandController, +): void { + bot.command('start', (ctx) => void controller.sendHelp(ctx)) + bot.command('help', (ctx) => void controller.sendHelp(ctx)) + bot.command('resume', (ctx) => void controller.handleResumeCommand(ctx)) + bot.command('provider', (ctx) => void controller.handleProviderCommand(ctx)) + bot.command('model', (ctx) => void controller.handleModelCommand(ctx)) + bot.command('skills', (ctx) => void controller.handleSkillsCommand(ctx)) +} + +export async function tryHandleTelegramSelectionCallback( + data: string, + ctx: TelegramCommandContext, + controller: TelegramCommandController, +): Promise { + const callback = parseTelegramSelectionCallback(data) + if (!callback) return false + await controller.handleSelectionCallback(ctx, callback) + return true +} + +export function createTelegramCommandController(deps: TelegramCommandControllerDeps) { + const pendingSelections = new Map() + + const sendSelection = async (chatId: string, selection: NewSelection): Promise => { + const next = setPendingSelection(pendingSelections, chatId, selection) + const view = renderSelectionView(next) + await deps.api.sendMessage(Number(chatId), view.text, { reply_markup: view.replyMarkup }) + } + + const editSelection = async (ctx: TelegramCommandContext, selection: NewSelection): Promise => { + const chatId = getCallbackChatId(ctx) + if (!chatId) return + const next = setPendingSelection(pendingSelections, chatId, selection) + const view = renderSelectionView(next) + try { + await ctx.editMessageText(view.text, { reply_markup: view.replyMarkup }) + } catch { + await deps.api.sendMessage(Number(chatId), view.text, { reply_markup: view.replyMarkup }) + } + } + + const ensureAuthorizedPrivateChat = async (ctx: TelegramCommandContext): Promise => { + if (!ctx.from || ctx.chat?.type !== 'private') return false + if (deps.isAllowedUser(ctx.from.id)) return true + await ctx.reply('🔒 未授权。请在 Claude Code 桌面端生成配对码后发送给我。') + return false + } + + const showProviderPicker = async (chatId: string): Promise => { + try { + const { providers, activeId } = await deps.httpClient.listProviders() + await sendSelection(chatId, { + kind: 'provider', + title: '选择 Provider:', + items: buildProviderSelectionItems(providers, activeId), + page: 0, + }) + } catch (err) { + await sendError(deps.api, chatId, '无法获取 Provider 列表', err) + } + } + + const applyProviderByValue = async ( + chatId: string, + providerValue: string, + label?: string, + ): Promise<{ label: string; defaultModel?: string }> => { + if (providerValue === OFFICIAL_PROVIDER_VALUE || providerValue === 'claude') { + await deps.httpClient.activateOfficialProvider() + await deps.httpClient.setCurrentModel(OFFICIAL_DEFAULT_MODEL_ID) + deps.setRuntimeModel(chatId, OFFICIAL_DEFAULT_MODEL_ID) + return { label: 'Claude 官方', defaultModel: OFFICIAL_DEFAULT_MODEL_ID } + } + + const isOpenAiOfficial = providerValue === OPENAI_OFFICIAL_PROVIDER_ID || providerValue === 'openai' + const providerId = isOpenAiOfficial ? OPENAI_OFFICIAL_PROVIDER_ID : providerValue + await deps.httpClient.activateProvider(providerId) + + let defaultModel = isOpenAiOfficial ? OPENAI_OFFICIAL_DEFAULT_MODEL_ID : undefined + if (!defaultModel) { + const { providers } = await deps.httpClient.listProviders() + defaultModel = providers.find((provider) => provider.id === providerId)?.models?.main?.trim() || undefined + } + if (defaultModel) { + await deps.httpClient.setCurrentModel(defaultModel) + deps.setRuntimeModel(chatId, defaultModel) + } + + return { + label: label ? stripSelectedPrefix(label) : isOpenAiOfficial ? 'ChatGPT Official' : providerId, + defaultModel, + } + } + + const handleProviderCommand = async (ctx: TelegramCommandContext): Promise => { + if (!await ensureAuthorizedPrivateChat(ctx)) return + const chatId = String(ctx.chat!.id) + const query = getCommandMatchText(ctx) + if (!query) { + await showProviderPicker(chatId) + return + } + + try { + const result = await applyProviderByValue(chatId, query) + await ctx.reply(formatProviderChangedMessage(result.label, result.defaultModel, '发送 /new 后新配置会用于新会话。')) + } catch (err) { + await ctx.reply(`❌ Provider 切换失败:${toErrorMessage(err)}`) + } + } + + const applyProviderSelection = async ( + ctx: TelegramCommandContext, + item: TelegramSelectionItem, + ): Promise => { + const chatId = getCallbackChatId(ctx) + if (!chatId) return + try { + const result = await applyProviderByValue(chatId, item.value, item.label) + pendingSelections.delete(chatId) + await ctx.editMessageText(formatProviderChangedMessage( + result.label, + result.defaultModel, + '当前已运行的会话可能仍使用旧 runtime;发送 /new 后会按新配置启动。', + )) + } catch (err) { + await ctx.editMessageText(`❌ Provider 切换失败:${toErrorMessage(err)}`) + } + } + + const showModelPicker = async (chatId: string): Promise => { + try { + const [modelsResult, currentResult] = await Promise.all([ + deps.httpClient.listModels(), + deps.httpClient.getCurrentModel().catch(() => null), + ]) + if (modelsResult.models.length === 0) { + await deps.api.sendMessage(Number(chatId), '没有可用模型。请先在桌面端配置 Provider。') + return + } + + await sendSelection(chatId, { + kind: 'model', + title: `选择模型(${modelsResult.provider?.name ?? 'Claude 官方'}):`, + items: buildModelSelectionItems(modelsResult.models, currentResult?.model.id), + page: 0, + }) + } catch (err) { + await sendError(deps.api, chatId, '无法获取模型列表', err) + } + } + + const setModelFromCommand = async (chatId: string, modelId: string): Promise => { + try { + await deps.httpClient.setCurrentModel(modelId) + deps.setRuntimeModel(chatId, modelId) + await deps.api.sendMessage(Number(chatId), formatModelChangedMessage(modelId)) + } catch (err) { + await deps.api.sendMessage(Number(chatId), `❌ 模型切换失败:${toErrorMessage(err)}`) + } + } + + const handleModelCommand = async (ctx: TelegramCommandContext): Promise => { + if (!await ensureAuthorizedPrivateChat(ctx)) return + const chatId = String(ctx.chat!.id) + const modelId = getCommandMatchText(ctx) + if (modelId) { + await setModelFromCommand(chatId, modelId) + return + } + await showModelPicker(chatId) + } + + const applyModelSelection = async ( + ctx: TelegramCommandContext, + item: TelegramSelectionItem, + ): Promise => { + const chatId = getCallbackChatId(ctx) + if (!chatId) return + try { + await deps.httpClient.setCurrentModel(item.value) + deps.setRuntimeModel(chatId, item.value) + pendingSelections.delete(chatId) + await ctx.editMessageText([ + `✅ 已切换模型:${stripSelectedPrefix(item.label)}`, + item.value, + '', + '当前已运行的会话可能仍使用旧 runtime;发送 /new 后会按新模型启动。', + ].join('\n')) + } catch (err) { + await ctx.editMessageText(`❌ 模型切换失败:${toErrorMessage(err)}`) + } + } + + const showSkills = async (chatId: string): Promise => { + const stored = await deps.ensureExistingSession(chatId) + const cwd = stored?.workDir || deps.defaultWorkDir + if (!cwd) { + await deps.api.sendMessage(Number(chatId), '请先发送 /new 选择项目,再查看 Skills。') + return + } + + try { + const { skills } = await deps.httpClient.listSkills(cwd) + const visibleSkills = skills.filter((skill) => skill.userInvocable) + if (visibleSkills.length === 0) { + await deps.api.sendMessage(Number(chatId), `当前项目没有可用 Skills:${cwd}`) + return + } + + await sendSelection(chatId, { + kind: 'skill', + title: `当前项目可用 Skills:\n${cwd}`, + items: visibleSkills.map(skillToSelectionItem), + page: 0, + }) + } catch (err) { + await sendError(deps.api, chatId, '无法获取 Skills', err) + } + } + + const handleSkillsCommand = async (ctx: TelegramCommandContext): Promise => { + if (!await ensureAuthorizedPrivateChat(ctx)) return + await showSkills(String(ctx.chat!.id)) + } + + const applySkillSelection = async ( + ctx: TelegramCommandContext, + item: TelegramSelectionItem, + ): Promise => { + const chatId = getCallbackChatId(ctx) + if (!chatId) return + pendingSelections.delete(chatId) + await ctx.editMessageText([ + `Skill:${item.label}`, + item.description, + '', + '可以直接描述任务让 Agent 自动选择,也可以在桌面端 /skills 查看详情。', + ].filter(Boolean).join('\n')) + } + + const showResumeProjectPicker = async (chatId: string): Promise => { + try { + const projects = await deps.httpClient.listRecentProjects() + if (projects.length === 0) { + await deps.api.sendMessage(Number(chatId), '没有找到最近项目。请先发送 /new 创建会话。') + return + } + + await sendSelection(chatId, { + kind: 'resume_project', + title: '选择要恢复的项目:', + items: projects.map((project) => ({ + label: `${project.projectName}${project.branch ? ` (${project.branch})` : ''}`, + value: project.realPath, + description: `${project.realPath} · ${project.sessionCount} 个会话`, + })), + page: 0, + }) + } catch (err) { + await sendError(deps.api, chatId, '无法获取项目列表', err) + } + } + + const handleResumeCommand = async (ctx: TelegramCommandContext): Promise => { + if (!await ensureAuthorizedPrivateChat(ctx)) return + await showResumeProjectPicker(String(ctx.chat!.id)) + } + + const showResumeSessionPicker = async ( + ctx: TelegramCommandContext, + project: TelegramSelectionItem, + ): Promise => { + const chatId = getCallbackChatId(ctx) + if (!chatId) return + try { + const { sessions } = await deps.httpClient.listSessions({ + project: project.value, + limit: 50, + offset: 0, + }) + const resumableSessions = sessions.filter((session) => session.workDir) + if (resumableSessions.length === 0) { + pendingSelections.delete(chatId) + await ctx.editMessageText(`没有可恢复会话:${project.label}`) + return + } + + await editSelection(ctx, { + kind: 'resume_session', + title: `选择要恢复的会话:\n${project.label}`, + items: resumableSessions.map(sessionToSelectionItem), + page: 0, + }) + } catch (err) { + await ctx.editMessageText(`❌ 无法获取会话列表:${toErrorMessage(err)}`) + } + } + + const resumeSessionForChat = async ( + ctx: TelegramCommandContext, + item: TelegramSelectionItem, + ): Promise => { + const chatId = getCallbackChatId(ctx) + if (!chatId) return + const workDir = item.meta?.workDir + if (!workDir) { + await ctx.editMessageText('❌ 这个会话缺少工作目录,无法恢复。') + return + } + + deps.resetBridgeSession(chatId) + deps.clearTransientChatState(chatId) + deps.setStoredSession(chatId, item.value, workDir) + deps.connectBridgeSession(chatId, item.value) + deps.onBridgeServerMessage(chatId) + const opened = await deps.waitForBridgeOpen(chatId) + if (!opened) { + deps.deleteStoredSession(chatId) + await ctx.editMessageText('⚠️ 恢复会话时连接服务器超时,请重试。') + return + } + + pendingSelections.delete(chatId) + await ctx.editMessageText([ + `✅ 已恢复会话:${item.label}`, + workDir, + '', + '可以继续发送消息。', + ].join('\n')) + } + + const handleSelectionCallback = async ( + ctx: TelegramCommandContext, + callback: TelegramSelectionCallback, + ): Promise => { + const chatId = getCallbackChatId(ctx) + if (!chatId || !ctx.from || !deps.isAllowedUser(ctx.from.id)) { + await ctx.answerCallbackQuery('未授权').catch(() => {}) + return true + } + + const selection = getPendingSelection(pendingSelections, chatId, callback.kind) + if (!selection) { + await ctx.answerCallbackQuery('选择已过期,请重新发送命令').catch(() => {}) + return true + } + + if (callback.action === 'noop') { + await ctx.answerCallbackQuery().catch(() => {}) + return true + } + + if (callback.action === 'page') { + await ctx.answerCallbackQuery().catch(() => {}) + await editSelection(ctx, { + ...selection, + page: callback.index, + }) + return true + } + + const item = selection.items[callback.index] + if (!item) { + await ctx.answerCallbackQuery('选项不存在,请重新发送命令').catch(() => {}) + return true + } + + await ctx.answerCallbackQuery('处理中...').catch(() => {}) + switch (callback.kind) { + case 'provider': + await applyProviderSelection(ctx, item) + break + case 'model': + await applyModelSelection(ctx, item) + break + case 'resume_project': + await showResumeSessionPicker(ctx, item) + break + case 'resume_session': + await resumeSessionForChat(ctx, item) + break + case 'skill': + await applySkillSelection(ctx, item) + break + } + return true + } + + return { + sendHelp: (ctx: TelegramCommandContext) => ctx.reply(buildTelegramHelpText()), + handleProviderCommand, + handleModelCommand, + handleSkillsCommand, + handleResumeCommand, + handleSelectionCallback, + clearPendingSelections: (chatId: string) => pendingSelections.delete(chatId), + showProviderPicker, + showModelPicker, + showSkills, + showResumeProjectPicker, + setModelFromCommand, + } +} + +export function buildTelegramHelpText(): string { + return [ + '👋 Claude Code Bot 已就绪。', + '', + formatImHelp(), + '', + 'Telegram 扩展命令:', + '/resume — 恢复历史会话', + '/provider — 切换 Provider', + '/model [model] — 查看或切换模型', + '/skills — 查看当前项目可用 Skills', + ].join('\n') +} + +export function buildProviderSelectionItems( + providers: ProviderSummary[], + activeId: string | null, +): TelegramSelectionItem[] { + return [ + { + label: `${activeId === null ? '✓ ' : ''}Claude 官方`, + value: OFFICIAL_PROVIDER_VALUE, + description: '使用 Claude 官方或环境变量配置', + meta: { defaultModel: OFFICIAL_DEFAULT_MODEL_ID }, + }, + { + label: `${activeId === OPENAI_OFFICIAL_PROVIDER_ID ? '✓ ' : ''}ChatGPT Official`, + value: OPENAI_OFFICIAL_PROVIDER_ID, + description: '使用 ChatGPT 登录的 Codex 模型', + meta: { defaultModel: OPENAI_OFFICIAL_DEFAULT_MODEL_ID }, + }, + ...providers.map((provider) => providerToSelectionItem(provider, activeId)), + ] +} + +export function providerToSelectionItem( + provider: ProviderSummary, + activeId: string | null, +): TelegramSelectionItem { + const mainModel = provider.models?.main?.trim() + return { + label: `${activeId === provider.id ? '✓ ' : ''}${provider.name}`, + value: provider.id, + description: mainModel ? `默认模型:${mainModel}` : provider.id, + ...(mainModel ? { meta: { defaultModel: mainModel } } : {}), + } +} + +export function buildModelSelectionItems( + models: Array<{ id: string; name?: string; context?: string; description?: string }>, + currentModelId?: string, +): TelegramSelectionItem[] { + return models.map((model) => ({ + label: `${model.id === currentModelId ? '✓ ' : ''}${model.name || model.id}`, + value: model.id, + description: [model.id, model.context, model.description].filter(Boolean).join(' · '), + })) +} + +export function skillToSelectionItem(skill: SkillSummary): TelegramSelectionItem { + const source = skill.pluginName ? `${skill.source}:${skill.pluginName}` : skill.source + return { + label: skill.displayName || skill.name, + value: skill.name, + description: `${source} · ${skill.description}`, + } +} + +export function sessionToSelectionItem(session: SessionListItem): TelegramSelectionItem { + const title = session.title || `会话 ${compactId(session.id)}` + return { + label: title, + value: session.id, + description: `${formatDateTime(session.modifiedAt)} · ${session.messageCount} 条消息 · ${session.workDir}`, + ...(session.workDir ? { meta: { workDir: session.workDir } } : {}), + } +} + +export function renderSelectionView(selection: PendingTelegramSelection): { + text: string + replyMarkup: TelegramInlineKeyboardMarkup +} { + const page = buildTelegramSelectionPage({ + kind: selection.kind, + items: selection.items, + page: selection.page, + }) + const lines = page.visibleItems.map((item, offset) => { + const number = offset + 1 + return item.description + ? `${number}. ${item.label}\n ${item.description}` + : `${number}. ${item.label}` + }) + const pageSuffix = page.totalPages > 1 ? `\n\n第 ${page.page + 1}/${page.totalPages} 页` : '' + return { + text: `${selection.title}\n\n${lines.join('\n\n')}${pageSuffix}`, + replyMarkup: { + inline_keyboard: page.rows.map((row) => row.map((button) => ({ + text: button.text, + callback_data: button.callbackData, + }))), + }, + } +} + +export function formatDateTime(value: string): string { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + return date.toLocaleString('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} + +export function compactId(id: string): string { + return id.length <= 12 ? id : id.slice(0, 8) +} + +function setPendingSelection( + pendingSelections: Map, + chatId: string, + selection: NewSelection, +): PendingTelegramSelection { + const next = { + ...selection, + expiresAt: Date.now() + TELEGRAM_SELECTION_TTL_MS, + } + pendingSelections.set(chatId, next) + return next +} + +function getPendingSelection( + pendingSelections: Map, + chatId: string, + kind: TelegramSelectionKind, +): PendingTelegramSelection | null { + const selection = pendingSelections.get(chatId) + if (!selection || selection.kind !== kind) return null + if (selection.expiresAt < Date.now()) { + pendingSelections.delete(chatId) + return null + } + return selection +} + +function getCallbackChatId(ctx: TelegramCommandContext): string | null { + const chatId = ctx.callbackQuery?.message?.chat.id + return chatId === undefined || chatId === null ? null : String(chatId) +} + +function getCommandMatchText(ctx: TelegramCommandContext): string | undefined { + if (typeof ctx.match !== 'string') return undefined + return ctx.match.trim() || undefined +} + +function formatProviderChangedMessage(label: string, defaultModel: string | undefined, suffix: string): string { + return [ + `✅ 已切换 Provider:${stripSelectedPrefix(label)}`, + defaultModel ? `默认模型:${defaultModel}` : undefined, + '', + suffix, + ].filter(Boolean).join('\n') +} + +function formatModelChangedMessage(modelId: string): string { + return [ + `✅ 已切换模型:${modelId}`, + '', + '当前已运行的会话可能仍使用旧 runtime;发送 /new 后会按新模型启动。', + ].join('\n') +} + +function stripSelectedPrefix(value: string): string { + return value.replace(/^✓\s*/, '') +} + +async function sendError(api: TelegramSendApi, chatId: string, label: string, err: unknown): Promise { + await api.sendMessage(Number(chatId), `❌ ${label}:${toErrorMessage(err)}`) +} + +function toErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} diff --git a/adapters/telegram/index.ts b/adapters/telegram/index.ts index 3da671e2..cf9d6147 100644 --- a/adapters/telegram/index.ts +++ b/adapters/telegram/index.ts @@ -13,7 +13,6 @@ import { MessageDedup } from '../common/message-dedup.js' import { enqueue } from '../common/chat-queue.js' import { getConfiguredWorkDir, loadConfig } from '../common/config.js' import { - formatImHelp, formatImStatus, formatPermissionRequest, splitMessage, @@ -42,6 +41,8 @@ import type { AttachmentRef } from '../common/ws-bridge.js' import { ImageBlockWatcher } from '../common/attachment/image-block-watcher.js' import type { PendingUpload } from '../common/attachment/attachment-types.js' import * as fs from 'node:fs/promises' +import { syncTelegramBotCommands } from './menu.js' +import { createTelegramRuntimeCommandController, registerTelegramExtendedCommands, tryHandleTelegramSelectionCallback } from './commands.js' const TELEGRAM_TEXT_LIMIT = 4000 // leave margin below 4096 const TELEGRAM_STREAMING_TEXT_LIMIT = TELEGRAM_TEXT_LIMIT - 2 // reserve room for cursor @@ -96,6 +97,8 @@ type ChatRuntimeState = { pendingPermissionCount: number } +const commandController = createTelegramRuntimeCommandController({ botApi: bot.api, httpClient, defaultWorkDir, bridge, sessionStore, ensureExistingSession, clearTransientChatState, isAllowedUser: (userId) => isAllowedUser('telegram', userId), handleServerMessage: (chatId, msg) => handleServerMessage(chatId, msg as ServerMessage), setRuntimeModel: (chatId, modelId) => { getRuntimeState(chatId).model = modelId } }) + // ---------- helpers ---------- function getBuffer(chatId: string): MessageBuffer { @@ -556,12 +559,7 @@ async function handleServerMessage(chatId: string, msg: ServerMessage): Promise< // ---------- bot handlers ---------- -async function sendHelp(ctx: Context): Promise { - await ctx.reply(`👋 Claude Code Bot 已就绪。\n\n${formatImHelp()}`) -} - -bot.command('start', (ctx) => void sendHelp(ctx)) -bot.command('help', (ctx) => void sendHelp(ctx)) +registerTelegramExtendedCommands(bot, commandController) /** Reset session state and start a new session for chatId. * If `query` is provided, match a project by index or name; @@ -576,6 +574,7 @@ async function startNewSession(chatId: string, query?: string): Promise { buffers.get(chatId)?.reset() buffers.delete(chatId) pendingProjectSelection.delete(chatId) + commandController.clearPendingSelections(chatId) pendingPermissions.delete(chatId) runtimeStates.delete(chatId) tgImageWatchers.delete(chatId) @@ -805,6 +804,8 @@ bot.on( bot.on('callback_query:data', async (ctx) => { const data = ctx.callbackQuery.data + if (await tryHandleTelegramSelectionCallback(data, ctx, commandController)) return + if (!data.startsWith('permit:')) return const decision = parsePermitCallbackData(data) @@ -832,6 +833,8 @@ console.log('[Telegram] Starting bot...') console.log(`[Telegram] Server: ${config.serverUrl}`) console.log(`[Telegram] Allowed users: ${config.telegram.allowedUsers.length === 0 ? 'all' : config.telegram.allowedUsers.join(', ')}`) +void syncTelegramBotCommands(bot.api).then(() => console.log('[Telegram] Command menu synced')).catch((err) => console.warn('[Telegram] Command menu sync failed:', err instanceof Error ? err.message : err)) + bot.start({ onStart: () => console.log('[Telegram] Bot is running!'), }) diff --git a/adapters/telegram/menu.ts b/adapters/telegram/menu.ts new file mode 100644 index 00000000..03a714e4 --- /dev/null +++ b/adapters/telegram/menu.ts @@ -0,0 +1,154 @@ +export type TelegramBotCommand = { + command: string + description: string +} + +export type TelegramSelectionKind = + | 'provider' + | 'model' + | 'resume_project' + | 'resume_session' + | 'skill' + +export type TelegramSelectionItem = { + label: string + value: string + description?: string + meta?: Record +} + +export type TelegramSelectionButton = { + text: string + callbackData: string +} + +export type TelegramSelectionPage = { + page: number + totalPages: number + visibleItems: Array + rows: TelegramSelectionButton[][] +} + +export type TelegramSelectionCallback = + | { kind: TelegramSelectionKind; action: 'pick'; index: number } + | { kind: TelegramSelectionKind; action: 'page'; index: number } + | { kind: TelegramSelectionKind; action: 'noop'; index: number } + +type TelegramCommandApi = { + deleteMyCommands?: () => Promise + setMyCommands: (commands: TelegramBotCommand[]) => Promise +} + +const TELEGRAM_SELECTION_PAGE_SIZE = 8 +const TELEGRAM_BUTTON_LABEL_LIMIT = 32 + +export const TELEGRAM_BOT_COMMANDS: TelegramBotCommand[] = [ + { command: 'start', description: '开始使用' }, + { command: 'help', description: '查看帮助' }, + { command: 'new', description: '新建会话或切换项目' }, + { command: 'projects', description: '查看最近项目' }, + { command: 'resume', description: '恢复历史会话' }, + { command: 'status', description: '查看当前状态' }, + { command: 'clear', description: '清空当前上下文' }, + { command: 'stop', description: '停止当前生成' }, + { command: 'provider', description: '切换 Provider' }, + { command: 'model', description: '切换模型' }, + { command: 'skills', description: '查看 Skills' }, + { command: 'allow', description: '允许权限请求' }, + { command: 'always', description: '永久允许权限请求' }, + { command: 'deny', description: '拒绝权限请求' }, +] + +export async function syncTelegramBotCommands( + api: TelegramCommandApi, + commands: TelegramBotCommand[] = TELEGRAM_BOT_COMMANDS, +): Promise { + if (api.deleteMyCommands) { + await api.deleteMyCommands() + } + await api.setMyCommands(commands) +} + +export function buildTelegramSelectionPage(params: { + kind: TelegramSelectionKind + items: TelegramSelectionItem[] + page: number + pageSize?: number +}): TelegramSelectionPage { + const pageSize = params.pageSize ?? TELEGRAM_SELECTION_PAGE_SIZE + const totalPages = Math.max(1, Math.ceil(params.items.length / pageSize)) + const page = clampPage(params.page, totalPages) + const start = page * pageSize + const visibleItems = params.items + .slice(start, start + pageSize) + .map((item, offset) => ({ ...item, index: start + offset })) + + const rows = visibleItems.map((item) => [ + { + text: truncateButtonLabel(item.label), + callbackData: `tgsel:${params.kind}:pick:${item.index}`, + }, + ]) + + if (totalPages > 1) { + const nav: TelegramSelectionButton[] = [] + if (page > 0) { + nav.push({ + text: 'Prev', + callbackData: `tgsel:${params.kind}:page:${page - 1}`, + }) + } + nav.push({ + text: `${page + 1}/${totalPages}`, + callbackData: `tgsel:${params.kind}:noop:${page}`, + }) + if (page < totalPages - 1) { + nav.push({ + text: 'Next', + callbackData: `tgsel:${params.kind}:page:${page + 1}`, + }) + } + rows.push(nav) + } + + return { + page, + totalPages, + visibleItems, + rows, + } +} + +export function parseTelegramSelectionCallback(data: string): TelegramSelectionCallback | null { + const match = data.match(/^tgsel:([a-z_]+):(pick|page|noop):(\d+)$/) + if (!match) return null + const kind = match[1] as TelegramSelectionKind + if (!isTelegramSelectionKind(kind)) return null + const index = Number(match[3]) + if (!Number.isSafeInteger(index) || index < 0) return null + return { + kind, + action: match[2] as TelegramSelectionCallback['action'], + index, + } as TelegramSelectionCallback +} + +function isTelegramSelectionKind(value: string): value is TelegramSelectionKind { + return value === 'provider' || + value === 'model' || + value === 'resume_project' || + value === 'resume_session' || + value === 'skill' +} + +function clampPage(page: number, totalPages: number): number { + if (!Number.isFinite(page) || page < 0) return 0 + if (page >= totalPages) return totalPages - 1 + return Math.floor(page) +} + +function truncateButtonLabel(label: string): string { + const chars = Array.from(label.trim() || '选择') + if (chars.length <= TELEGRAM_BUTTON_LABEL_LIMIT) return chars.join('') + return `${chars.slice(0, TELEGRAM_BUTTON_LABEL_LIMIT - 1).join('')}…` +}