mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
d11748d61a
commit
3ff6a79e62
@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
611
adapters/telegram/__tests__/commands.test.ts
Normal file
611
adapters/telegram/__tests__/commands.test.ts
Normal 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('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')
|
||||
})
|
||||
})
|
||||
63
adapters/telegram/__tests__/menu.test.ts
Normal file
63
adapters/telegram/__tests__/menu.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
719
adapters/telegram/commands.ts
Normal file
719
adapters/telegram/commands.ts
Normal 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)
|
||||
}
|
||||
@ -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
154
adapters/telegram/menu.ts
Normal 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('')}…`
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user