程序员阿江(Relakkes) 3ff6a79e62 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
2026-06-09 21:01:30 +08:00

155 lines
4.6 KiB
TypeScript

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('')}`
}