feat(adapter): add Telegram command menu (#596)

Add Telegram command-menu sync plus /resume, /provider, /model, and /skills command handling with paginated inline selections.
Expose the adapter HTTP calls needed by those commands and harden stale WebSocket session cleanup during Telegram resume/reset flows.

Tested:
- cd adapters && bun test telegram/__tests__/commands.test.ts telegram/__tests__/menu.test.ts
- cd adapters && bunx tsc --noEmit -p tsconfig.json
- bun run check:adapters
- bun run check:persistence-upgrade
- bun run check:native
- bun run verify (coverage lane failed on existing root WebSocket Chat Integration timeout)

Not-tested:
- Live Telegram bot smoke, no bot token/session available.

Confidence: medium
Scope-risk: moderate
This commit is contained in:
程序员阿江(Relakkes) 2026-06-09 21:01:30 +08:00
parent d11748d61a
commit 3ff6a79e62
9 changed files with 1893 additions and 7 deletions

View File

@ -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',
)
})
})

View File

@ -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()
})

View File

@ -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<void> {
await this.postJson(`/api/providers/${encodeURIComponent(providerId)}/activate`)
}
async activateOfficialProvider(): Promise<void> {
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<void> {
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<void> {
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<string, unknown>): Promise<void> {
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 {

View File

@ -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

View File

@ -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<string | undefined> = []
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<string, unknown>) {
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('SkillSkill 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')
})
})

View File

@ -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()
})
})

View File

@ -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<unknown>
}
type TelegramSendOptions = {
reply_markup?: TelegramInlineKeyboardMarkup
}
type TelegramInlineKeyboardMarkup = {
inline_keyboard: Array<Array<{ text: string; callback_data: string }>>
}
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<unknown>
editMessageText: (text: string, options?: TelegramSendOptions) => Promise<unknown>
answerCallbackQuery: (text?: string) => Promise<unknown>
}
type PendingTelegramSelection = {
kind: TelegramSelectionKind
title: string
items: TelegramSelectionItem[]
page: number
expiresAt: number
}
type NewSelection = Omit<PendingTelegramSelection, 'expiresAt'>
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<boolean>
setRuntimeModel: RuntimeModelSetter
}
export type TelegramCommandController = ReturnType<typeof createTelegramCommandController>
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>) => void
waitForOpen: (chatId: string) => Promise<boolean>
}
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<void>
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<boolean> {
const callback = parseTelegramSelectionCallback(data)
if (!callback) return false
await controller.handleSelectionCallback(ctx, callback)
return true
}
export function createTelegramCommandController(deps: TelegramCommandControllerDeps) {
const pendingSelections = new Map<string, PendingTelegramSelection>()
const sendSelection = async (chatId: string, selection: NewSelection): Promise<void> => {
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<void> => {
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<boolean> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
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<void> => {
if (!await ensureAuthorizedPrivateChat(ctx)) return
await showSkills(String(ctx.chat!.id))
}
const applySkillSelection = async (
ctx: TelegramCommandContext,
item: TelegramSelectionItem,
): Promise<void> => {
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<void> => {
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<void> => {
if (!await ensureAuthorizedPrivateChat(ctx)) return
await showResumeProjectPicker(String(ctx.chat!.id))
}
const showResumeSessionPicker = async (
ctx: TelegramCommandContext,
project: TelegramSelectionItem,
): Promise<void> => {
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<void> => {
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<boolean> => {
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<string, PendingTelegramSelection>,
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<string, PendingTelegramSelection>,
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<void> {
await api.sendMessage(Number(chatId), `${label}${toErrorMessage(err)}`)
}
function toErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}

View File

@ -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<void> {
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<void> {
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!'),
})

154
adapters/telegram/menu.ts Normal file
View File

@ -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<string, string>
}
export type TelegramSelectionButton = {
text: string
callbackData: string
}
export type TelegramSelectionPage = {
page: number
totalPages: number
visibleItems: Array<TelegramSelectionItem & { index: number }>
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<unknown>
setMyCommands: (commands: TelegramBotCommand[]) => Promise<unknown>
}
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<void> {
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('')}`
}