bingocode 1.0.41 → 1.1.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/bin/bingo-win.cjs +2 -1
  2. package/bin/bingocode-win.cjs +2 -1
  3. package/bin/claude-win.cjs +2 -1
  4. package/bun.lock +1716 -0
  5. package/package.json +14 -2
  6. package/src/server/config/providers.yaml +1 -1
  7. package/src/server/proxy/transform/anthropicToOpenaiChat.ts +11 -4
  8. package/adapters/README.md +0 -87
  9. package/adapters/common/__tests__/chat-queue.test.ts +0 -61
  10. package/adapters/common/__tests__/format.test.ts +0 -148
  11. package/adapters/common/__tests__/http-client.test.ts +0 -105
  12. package/adapters/common/__tests__/message-buffer.test.ts +0 -84
  13. package/adapters/common/__tests__/message-dedup.test.ts +0 -57
  14. package/adapters/common/__tests__/session-store.test.ts +0 -62
  15. package/adapters/common/__tests__/ws-bridge.test.ts +0 -177
  16. package/adapters/common/attachment/__tests__/attachment-limits.test.ts +0 -52
  17. package/adapters/common/attachment/__tests__/attachment-store.test.ts +0 -108
  18. package/adapters/common/attachment/__tests__/image-block-watcher.test.ts +0 -115
  19. package/adapters/common/attachment/attachment-limits.ts +0 -58
  20. package/adapters/common/attachment/attachment-store.ts +0 -121
  21. package/adapters/common/attachment/attachment-types.ts +0 -29
  22. package/adapters/common/attachment/image-block-watcher.ts +0 -94
  23. package/adapters/common/chat-queue.ts +0 -24
  24. package/adapters/common/config.ts +0 -96
  25. package/adapters/common/format.ts +0 -229
  26. package/adapters/common/http-client.ts +0 -107
  27. package/adapters/common/message-buffer.ts +0 -91
  28. package/adapters/common/message-dedup.ts +0 -57
  29. package/adapters/common/pairing.ts +0 -149
  30. package/adapters/common/session-store.ts +0 -60
  31. package/adapters/common/ws-bridge.ts +0 -282
  32. package/adapters/feishu/__tests__/card-errors.test.ts +0 -194
  33. package/adapters/feishu/__tests__/cardkit.test.ts +0 -295
  34. package/adapters/feishu/__tests__/extract-payload.test.ts +0 -77
  35. package/adapters/feishu/__tests__/feishu.test.ts +0 -907
  36. package/adapters/feishu/__tests__/flush-controller.test.ts +0 -290
  37. package/adapters/feishu/__tests__/markdown-style.test.ts +0 -353
  38. package/adapters/feishu/__tests__/media.test.ts +0 -120
  39. package/adapters/feishu/__tests__/streaming-card.test.ts +0 -914
  40. package/adapters/feishu/card-errors.ts +0 -151
  41. package/adapters/feishu/cardkit.ts +0 -294
  42. package/adapters/feishu/extract-payload.ts +0 -95
  43. package/adapters/feishu/flush-controller.ts +0 -149
  44. package/adapters/feishu/index.ts +0 -1275
  45. package/adapters/feishu/markdown-style.ts +0 -212
  46. package/adapters/feishu/media.ts +0 -176
  47. package/adapters/feishu/streaming-card.ts +0 -612
  48. package/adapters/package.json +0 -23
  49. package/adapters/telegram/__tests__/media.test.ts +0 -86
  50. package/adapters/telegram/__tests__/telegram.test.ts +0 -115
  51. package/adapters/telegram/index.ts +0 -754
  52. package/adapters/telegram/media.ts +0 -89
  53. package/adapters/tsconfig.json +0 -18
  54. package/runtime/mac_helper.py +0 -775
  55. package/runtime/requirements-win.txt +0 -7
  56. package/runtime/requirements.txt +0 -6
  57. package/runtime/test_helpers.py +0 -322
  58. package/runtime/win_helper.py +0 -723
  59. package/scripts/count-app-loc.ts +0 -256
  60. package/scripts/release.ts +0 -130
  61. package/start-cli.bat +0 -7
  62. package/stubs/ant-claude-for-chrome-mcp.ts +0 -24
  63. package/stubs/color-diff-napi.ts +0 -45
@@ -1,754 +0,0 @@
1
- /**
2
- * Telegram Adapter for Claude Code Desktop
3
- *
4
- * 基于 grammY 的轻量 Telegram Bot,直连服务端 /ws/:sessionId。
5
- * 启动:TELEGRAM_BOT_TOKEN=xxx bun run telegram/index.ts
6
- */
7
-
8
- import { Bot, InlineKeyboard, type Context } from 'grammy'
9
- import * as path from 'node:path'
10
- import { WsBridge, type ServerMessage } from '../common/ws-bridge.js'
11
- import { MessageBuffer } from '../common/message-buffer.js'
12
- import { MessageDedup } from '../common/message-dedup.js'
13
- import { enqueue } from '../common/chat-queue.js'
14
- import { loadConfig } from '../common/config.js'
15
- import {
16
- formatImHelp,
17
- formatImStatus,
18
- formatPermissionRequest,
19
- splitMessage,
20
- } from '../common/format.js'
21
- import { SessionStore } from '../common/session-store.js'
22
- import { AdapterHttpClient } from '../common/http-client.js'
23
- import { isAllowedUser, tryPair } from '../common/pairing.js'
24
- import { TelegramMediaService } from './media.js'
25
- import { AttachmentStore } from '../common/attachment/attachment-store.js'
26
- import { checkAttachmentLimit } from '../common/attachment/attachment-limits.js'
27
- import type { AttachmentRef } from '../common/ws-bridge.js'
28
- import { ImageBlockWatcher } from '../common/attachment/image-block-watcher.js'
29
- import type { PendingUpload } from '../common/attachment/attachment-types.js'
30
- import * as fs from 'node:fs/promises'
31
-
32
- const TELEGRAM_TEXT_LIMIT = 4000 // leave margin below 4096
33
-
34
- // ---------- init ----------
35
-
36
- const config = loadConfig()
37
- if (!config.telegram.botToken) {
38
- console.error('[Telegram] Missing TELEGRAM_BOT_TOKEN. Set env or ~/.claude/adapters.json')
39
- process.exit(1)
40
- }
41
-
42
- const bot = new Bot(config.telegram.botToken)
43
- const bridge = new WsBridge(config.serverUrl, 'tg')
44
- const dedup = new MessageDedup()
45
- const sessionStore = new SessionStore()
46
- const httpClient = new AdapterHttpClient(config.serverUrl)
47
- const attachmentStore = new AttachmentStore()
48
- const media = new TelegramMediaService(bot, attachmentStore)
49
- attachmentStore.gc().catch((err) => {
50
- console.warn('[Telegram] AttachmentStore.gc failed:', err instanceof Error ? err.message : err)
51
- })
52
-
53
- // Track placeholder messages for streaming updates
54
- const placeholders = new Map<string, { chatId: string; messageId: number }>()
55
- // Track accumulated text per chat for streaming
56
- const accumulatedText = new Map<string, string>()
57
- // Message buffers per chat
58
- const buffers = new Map<string, MessageBuffer>()
59
- // Track chats waiting for project selection
60
- const pendingProjectSelection = new Map<string, boolean>()
61
- const runtimeStates = new Map<string, ChatRuntimeState>()
62
- /** Per-chat outbound image watcher for Agent-produced markdown images. */
63
- const tgImageWatchers = new Map<string, ImageBlockWatcher>()
64
-
65
- function getTgWatcher(chatId: string): ImageBlockWatcher {
66
- let w = tgImageWatchers.get(chatId)
67
- if (!w) {
68
- w = new ImageBlockWatcher()
69
- tgImageWatchers.set(chatId, w)
70
- }
71
- return w
72
- }
73
-
74
- type ChatRuntimeState = {
75
- state: 'idle' | 'thinking' | 'streaming' | 'tool_executing' | 'permission_pending'
76
- verb?: string
77
- model?: string
78
- pendingPermissionCount: number
79
- }
80
-
81
- // ---------- helpers ----------
82
-
83
- function getBuffer(chatId: string): MessageBuffer {
84
- let buf = buffers.get(chatId)
85
- if (!buf) {
86
- buf = new MessageBuffer(async (text, isComplete) => {
87
- await flushToTelegram(chatId, text, isComplete)
88
- })
89
- buffers.set(chatId, buf)
90
- }
91
- return buf
92
- }
93
-
94
- function getRuntimeState(chatId: string): ChatRuntimeState {
95
- let state = runtimeStates.get(chatId)
96
- if (!state) {
97
- state = { state: 'idle', pendingPermissionCount: 0 }
98
- runtimeStates.set(chatId, state)
99
- }
100
- return state
101
- }
102
-
103
- function clearTransientChatState(chatId: string): void {
104
- placeholders.delete(chatId)
105
- accumulatedText.delete(chatId)
106
- buffers.get(chatId)?.reset()
107
- const runtime = getRuntimeState(chatId)
108
- runtime.state = 'idle'
109
- runtime.verb = undefined
110
- runtime.pendingPermissionCount = 0
111
- tgImageWatchers.delete(chatId)
112
- }
113
-
114
- async function ensureExistingSession(chatId: string): Promise<{ sessionId: string; workDir: string } | null> {
115
- const stored = sessionStore.get(chatId)
116
- if (!stored) return null
117
-
118
- if (!bridge.hasSession(chatId)) {
119
- bridge.connectSession(chatId, stored.sessionId)
120
- bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
121
- const opened = await bridge.waitForOpen(chatId)
122
- if (!opened) return null
123
- }
124
-
125
- return stored
126
- }
127
-
128
- async function buildStatusText(chatId: string): Promise<string> {
129
- const stored = await ensureExistingSession(chatId)
130
- if (!stored) return formatImStatus(null)
131
-
132
- const runtime = getRuntimeState(chatId)
133
- let projectName = path.basename(stored.workDir) || stored.workDir
134
- let branch: string | null = null
135
-
136
- try {
137
- const gitInfo = await httpClient.getGitInfo(stored.sessionId)
138
- projectName = gitInfo.repoName || path.basename(gitInfo.workDir) || projectName
139
- branch = gitInfo.branch
140
- } catch {
141
- // Ignore git lookup failures and fall back to stored workDir
142
- }
143
-
144
- let taskCounts:
145
- | {
146
- total: number
147
- pending: number
148
- inProgress: number
149
- completed: number
150
- }
151
- | undefined
152
-
153
- try {
154
- const tasks = await httpClient.getTasksForSession(stored.sessionId)
155
- if (tasks.length > 0) {
156
- taskCounts = {
157
- total: tasks.length,
158
- pending: tasks.filter((task) => task.status === 'pending').length,
159
- inProgress: tasks.filter((task) => task.status === 'in_progress').length,
160
- completed: tasks.filter((task) => task.status === 'completed').length,
161
- }
162
- }
163
- } catch {
164
- // Ignore task lookup failures in IM status summary
165
- }
166
-
167
- return formatImStatus({
168
- sessionId: stored.sessionId,
169
- projectName,
170
- branch,
171
- model: runtime.model,
172
- state: runtime.state,
173
- verb: runtime.verb,
174
- pendingPermissionCount: runtime.pendingPermissionCount,
175
- taskCounts,
176
- })
177
- }
178
-
179
- async function flushToTelegram(chatId: string, newText: string, isComplete: boolean): Promise<void> {
180
- const numericChatId = Number(chatId)
181
- const prev = accumulatedText.get(chatId) ?? ''
182
- const fullText = prev + newText
183
- accumulatedText.set(chatId, fullText)
184
-
185
- const placeholder = placeholders.get(chatId)
186
-
187
- if (placeholder) {
188
- if (isComplete) {
189
- const chunks = splitMessage(fullText, TELEGRAM_TEXT_LIMIT)
190
- try {
191
- await bot.api.editMessageText(numericChatId, placeholder.messageId, chunks[0]!)
192
- } catch { /* ignore */ }
193
- for (let i = 1; i < chunks.length; i++) {
194
- await bot.api.sendMessage(numericChatId, chunks[i]!)
195
- }
196
- } else {
197
- const displayText = fullText.slice(0, TELEGRAM_TEXT_LIMIT - 2) + ' ▍'
198
- try {
199
- await bot.api.editMessageText(numericChatId, placeholder.messageId, displayText)
200
- } catch { /* ignore */ }
201
- }
202
- } else if (isComplete && fullText.trim()) {
203
- const chunks = splitMessage(fullText, TELEGRAM_TEXT_LIMIT)
204
- for (const chunk of chunks) {
205
- await bot.api.sendMessage(numericChatId, chunk)
206
- }
207
- }
208
-
209
- if (isComplete) {
210
- placeholders.delete(chatId)
211
- accumulatedText.delete(chatId)
212
- buffers.get(chatId)?.reset()
213
- }
214
- }
215
-
216
- // ---------- session management ----------
217
-
218
- async function ensureSession(chatId: string): Promise<boolean> {
219
- if (bridge.hasSession(chatId)) return true
220
-
221
- const stored = sessionStore.get(chatId)
222
- if (stored) {
223
- bridge.connectSession(chatId, stored.sessionId)
224
- bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
225
- return await bridge.waitForOpen(chatId)
226
- }
227
-
228
- const workDir = config.defaultProjectDir
229
- if (workDir) {
230
- return await createSessionForChat(chatId, workDir)
231
- }
232
-
233
- await showProjectPicker(chatId)
234
- return false
235
- }
236
-
237
- async function createSessionForChat(chatId: string, workDir: string): Promise<boolean> {
238
- const numericChatId = Number(chatId)
239
- try {
240
- // Always tear down any stale WS connection before creating a new session.
241
- // Without this, bridge.connectSession() below would short-circuit when an
242
- // old OPEN connection still exists, leaving messages routed to the old session.
243
- bridge.resetSession(chatId)
244
-
245
- const sessionId = await httpClient.createSession(workDir)
246
- sessionStore.set(chatId, sessionId, workDir)
247
- bridge.connectSession(chatId, sessionId)
248
- bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
249
- const opened = await bridge.waitForOpen(chatId)
250
- if (!opened) {
251
- await bot.api.sendMessage(numericChatId, '⚠️ 连接服务器超时,请重试。')
252
- return false
253
- }
254
- return true
255
- } catch (err) {
256
- await bot.api.sendMessage(numericChatId,
257
- `❌ 无法创建会话: ${err instanceof Error ? err.message : String(err)}`)
258
- return false
259
- }
260
- }
261
-
262
- async function showProjectPicker(chatId: string): Promise<void> {
263
- const numericChatId = Number(chatId)
264
- try {
265
- const projects = await httpClient.listRecentProjects()
266
- if (projects.length === 0) {
267
- await bot.api.sendMessage(numericChatId,
268
- '没有找到最近的项目。请先在 Desktop App 中打开一个项目,或在 Settings → IM 接入中配置默认项目。')
269
- return
270
- }
271
-
272
- const lines = projects.slice(0, 10).map((p, i) =>
273
- `${i + 1}. ${p.projectName}${p.branch ? ` (${p.branch})` : ''}\n ${p.realPath}`
274
- )
275
- pendingProjectSelection.set(chatId, true)
276
- await bot.api.sendMessage(numericChatId,
277
- `选择项目(回复编号):\n\n${lines.join('\n\n')}\n\n💡 下次可直接 /new <编号或名称> 快速新建会话`)
278
- } catch (err) {
279
- await bot.api.sendMessage(numericChatId,
280
- `❌ 无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`)
281
- }
282
- }
283
-
284
- // ---------- outbound media dispatch ----------
285
-
286
- /** Upload a PendingUpload found in streaming output and send it via
287
- * bot.api.sendPhoto as an independent message. Runs fire-and-forget
288
- * from the stream handler so streaming text isn't blocked. */
289
- async function dispatchOutboundMedia(chatId: string, pending: PendingUpload): Promise<void> {
290
- const numericChatId = Number(chatId)
291
- try {
292
- let buffer: Buffer
293
- let mime = 'image/png'
294
- switch (pending.source.kind) {
295
- case 'base64': {
296
- buffer = Buffer.from(pending.source.data, 'base64')
297
- mime = pending.source.mime
298
- break
299
- }
300
- case 'path': {
301
- buffer = await fs.readFile(pending.source.path)
302
- mime = pending.source.mime ?? 'image/png'
303
- break
304
- }
305
- case 'url': {
306
- const resp = await fetch(pending.source.url)
307
- if (!resp.ok) {
308
- throw new Error(`fetch ${pending.source.url} -> ${resp.status}`)
309
- }
310
- buffer = Buffer.from(await resp.arrayBuffer())
311
- mime = pending.source.mime ?? resp.headers.get('content-type') ?? 'image/png'
312
- break
313
- }
314
- }
315
- const check = checkAttachmentLimit('image', buffer.length, mime)
316
- if (!check.ok) {
317
- console.warn('[Telegram] Outbound image rejected:', check.hint)
318
- return
319
- }
320
- await media.sendPhoto(numericChatId, buffer, pending.alt)
321
- } catch (err) {
322
- console.error(
323
- '[Telegram] dispatchOutboundMedia failed:',
324
- err instanceof Error ? err.message : err,
325
- )
326
- }
327
- }
328
-
329
- // ---------- server message handler ----------
330
-
331
- async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<void> {
332
- const numericChatId = Number(chatId)
333
- const buf = getBuffer(chatId)
334
- const runtime = getRuntimeState(chatId)
335
-
336
- switch (msg.type) {
337
- case 'connected':
338
- break
339
-
340
- case 'status':
341
- runtime.state = msg.state
342
- runtime.verb = typeof msg.verb === 'string' ? msg.verb : undefined
343
- if (msg.state === 'thinking' && !placeholders.has(chatId)) {
344
- const sent = await bot.api.sendMessage(numericChatId, '💭 思考中...')
345
- placeholders.set(chatId, { chatId, messageId: sent.message_id })
346
- accumulatedText.set(chatId, '')
347
- }
348
- break
349
-
350
- case 'content_start':
351
- if (msg.blockType === 'text') {
352
- if (!placeholders.has(chatId)) {
353
- const sent = await bot.api.sendMessage(numericChatId, '▍')
354
- placeholders.set(chatId, { chatId, messageId: sent.message_id })
355
- accumulatedText.set(chatId, '')
356
- }
357
- } else if (msg.blockType === 'tool_use') {
358
- // Finalize current text placeholder before tool calls,
359
- // so text after tools gets a fresh message
360
- await buf.complete()
361
- // If placeholder still exists (buffer was already empty), clean up directly
362
- if (placeholders.has(chatId)) {
363
- const text = accumulatedText.get(chatId)
364
- if (text?.trim()) {
365
- try {
366
- await bot.api.editMessageText(numericChatId, placeholders.get(chatId)!.messageId, text)
367
- } catch { /* ignore */ }
368
- }
369
- placeholders.delete(chatId)
370
- accumulatedText.delete(chatId)
371
- buffers.get(chatId)?.reset()
372
- }
373
- }
374
- break
375
-
376
- case 'content_delta':
377
- if (msg.text) {
378
- buf.append(msg.text)
379
- const newUploads = getTgWatcher(chatId).feed(msg.text)
380
- for (const pending of newUploads) {
381
- void dispatchOutboundMedia(chatId, pending)
382
- }
383
- }
384
- break
385
-
386
- case 'thinking':
387
- if (placeholders.has(chatId)) {
388
- try {
389
- await bot.api.editMessageText(
390
- numericChatId,
391
- placeholders.get(chatId)!.messageId,
392
- `💭 ${msg.text.slice(0, 200)}...`,
393
- )
394
- } catch { /* ignore */ }
395
- }
396
- break
397
-
398
- case 'tool_use_complete':
399
- // Tool details are noise for IM users; visible in Desktop if needed.
400
- break
401
-
402
- case 'tool_result':
403
- // Tool errors are handled internally by the AI (retries etc.)
404
- // No need to notify the user for every failed attempt.
405
- break
406
-
407
- case 'permission_request': {
408
- runtime.pendingPermissionCount += 1
409
- runtime.state = 'permission_pending'
410
- const text = formatPermissionRequest(msg.toolName, msg.input, msg.requestId)
411
- const keyboard = new InlineKeyboard()
412
- .text('✅ 允许', `permit:${msg.requestId}:yes`)
413
- .text('❌ 拒绝', `permit:${msg.requestId}:no`)
414
- await bot.api.sendMessage(numericChatId, text, { reply_markup: keyboard })
415
- break
416
- }
417
-
418
- case 'message_complete':
419
- runtime.state = 'idle'
420
- runtime.verb = undefined
421
- await buf.complete()
422
- // Ensure placeholder is always cleaned up even if buffer was already empty
423
- if (placeholders.has(chatId)) {
424
- const text = accumulatedText.get(chatId)
425
- if (text?.trim()) {
426
- try {
427
- const chunks = splitMessage(text, TELEGRAM_TEXT_LIMIT)
428
- await bot.api.editMessageText(numericChatId, placeholders.get(chatId)!.messageId, chunks[0]!)
429
- for (let i = 1; i < chunks.length; i++) {
430
- await bot.api.sendMessage(numericChatId, chunks[i]!)
431
- }
432
- } catch { /* ignore */ }
433
- }
434
- placeholders.delete(chatId)
435
- accumulatedText.delete(chatId)
436
- buffers.get(chatId)?.reset()
437
- }
438
- break
439
-
440
- case 'error':
441
- runtime.state = 'idle'
442
- runtime.verb = undefined
443
- // Auto-recover from stale thinking block signatures by creating a fresh session.
444
- // This happens when the API key or provider changed since the session was created.
445
- if (msg.message && /Invalid.*signature.*thinking/i.test(msg.message)) {
446
- const stored = sessionStore.get(chatId)
447
- const workDir = stored?.workDir || config.defaultProjectDir
448
- if (workDir) {
449
- await bot.api.sendMessage(numericChatId, '⚠️ 会话上下文已失效,正在自动重建...')
450
- clearTransientChatState(chatId)
451
- bridge.resetSession(chatId)
452
- sessionStore.delete(chatId)
453
- const ok = await createSessionForChat(chatId, workDir)
454
- if (ok) {
455
- await bot.api.sendMessage(numericChatId, '✅ 已重建会话,请重新发送消息。')
456
- } else {
457
- await bot.api.sendMessage(numericChatId, '❌ 重建会话失败,请发送 /new 手动新建。')
458
- }
459
- } else {
460
- await bot.api.sendMessage(numericChatId, '⚠️ 会话上下文已失效,请发送 /new 新建会话。')
461
- }
462
- } else {
463
- await bot.api.sendMessage(numericChatId, `❌ ${msg.message}`)
464
- }
465
- break
466
-
467
- case 'system_notification':
468
- if (msg.subtype === 'init' && msg.data && typeof msg.data === 'object') {
469
- const model = (msg.data as Record<string, unknown>).model
470
- if (typeof model === 'string' && model.trim()) {
471
- runtime.model = model
472
- }
473
- }
474
- break
475
- }
476
- }
477
-
478
- // ---------- bot handlers ----------
479
-
480
- async function sendHelp(ctx: Context): Promise<void> {
481
- await ctx.reply(`👋 Claude Code Bot 已就绪。\n\n${formatImHelp()}`)
482
- }
483
-
484
- bot.command('start', (ctx) => void sendHelp(ctx))
485
- bot.command('help', (ctx) => void sendHelp(ctx))
486
-
487
- /** Reset session state and start a new session for chatId.
488
- * If `query` is provided, match a project by index or name;
489
- * otherwise use defaultProjectDir or show the picker. */
490
- async function startNewSession(chatId: string, query?: string): Promise<void> {
491
- const numericChatId = Number(chatId)
492
-
493
- bridge.resetSession(chatId)
494
- sessionStore.delete(chatId)
495
- placeholders.delete(chatId)
496
- accumulatedText.delete(chatId)
497
- buffers.get(chatId)?.reset()
498
- buffers.delete(chatId)
499
- pendingProjectSelection.delete(chatId)
500
- runtimeStates.delete(chatId)
501
- tgImageWatchers.delete(chatId)
502
-
503
- if (query) {
504
- try {
505
- const { project, ambiguous } = await httpClient.matchProject(query)
506
- if (project) {
507
- const ok = await createSessionForChat(chatId, project.realPath)
508
- if (ok) {
509
- await bot.api.sendMessage(numericChatId,
510
- `✅ 已新建会话:${project.projectName}${project.branch ? ` (${project.branch})` : ''}`)
511
- }
512
- return
513
- }
514
- if (ambiguous) {
515
- const list = ambiguous.map((p, i) => `${i + 1}. ${p.projectName} — ${p.realPath}`).join('\n')
516
- await bot.api.sendMessage(numericChatId, `匹配到多个项目,请更精确:\n\n${list}`)
517
- return
518
- }
519
- await bot.api.sendMessage(numericChatId, `未找到匹配 "${query}" 的项目。发送 /projects 查看完整列表。`)
520
- } catch (err) {
521
- await bot.api.sendMessage(numericChatId,
522
- `❌ ${err instanceof Error ? err.message : String(err)}`)
523
- }
524
- } else {
525
- const workDir = config.defaultProjectDir
526
- if (workDir) {
527
- const ok = await createSessionForChat(chatId, workDir)
528
- if (ok) {
529
- await bot.api.sendMessage(numericChatId, '✅ 已新建会话,可以开始对话了。')
530
- }
531
- } else {
532
- await showProjectPicker(chatId)
533
- }
534
- }
535
- }
536
-
537
- bot.command('new', async (ctx) => {
538
- const chatId = String(ctx.chat.id)
539
- await startNewSession(chatId, ctx.match?.trim() || undefined)
540
- })
541
-
542
- bot.command('projects', async (ctx) => {
543
- const chatId = String(ctx.chat.id)
544
- await showProjectPicker(chatId)
545
- })
546
-
547
- bot.command('stop', (ctx) => {
548
- const chatId = String(ctx.chat.id)
549
- void (async () => {
550
- const stored = await ensureExistingSession(chatId)
551
- if (!stored) {
552
- await ctx.reply(formatImStatus(null))
553
- return
554
- }
555
- bridge.sendStopGeneration(chatId)
556
- await ctx.reply('⏹ 已发送停止信号。')
557
- })()
558
- })
559
-
560
- bot.command('status', async (ctx) => {
561
- const chatId = String(ctx.chat.id)
562
- await ctx.reply(await buildStatusText(chatId))
563
- })
564
-
565
- bot.command('clear', (ctx) => {
566
- const chatId = String(ctx.chat.id)
567
- void (async () => {
568
- const stored = await ensureExistingSession(chatId)
569
- if (!stored) {
570
- await ctx.reply(formatImStatus(null))
571
- return
572
- }
573
- clearTransientChatState(chatId)
574
- const sent = bridge.sendUserMessage(chatId, '/clear')
575
- if (!sent) {
576
- await ctx.reply('⚠️ 无法发送 /clear,请先发送 /new 重新连接会话。')
577
- return
578
- }
579
- await ctx.reply('🧹 已清空当前会话上下文。')
580
- })()
581
- })
582
-
583
- /** Shared per-user-message pipeline: dedup, pairing check, project-pick
584
- * routing, enqueue, ensureSession, sendUserMessage with attachments.
585
- * Caller has already extracted text and attachments from the context. */
586
- async function routeUserMessage(
587
- ctx: Context,
588
- text: string,
589
- attachments: AttachmentRef[],
590
- ): Promise<void> {
591
- if (!ctx.from || ctx.chat?.type !== 'private') return
592
- if (!dedup.tryRecord(String(ctx.message?.message_id))) return
593
-
594
- const chatId = String(ctx.chat.id)
595
- const userId = ctx.from.id
596
-
597
- if (!isAllowedUser('telegram', userId)) {
598
- const displayName = [ctx.from.first_name, ctx.from.last_name].filter(Boolean).join(' ')
599
- const success = tryPair(text.trim(), { userId, displayName }, 'telegram')
600
- if (success) {
601
- await ctx.reply('✅ 配对成功!现在可以开始聊天了。\n\n发送消息即可与 Claude 对话。')
602
- } else {
603
- await ctx.reply('🔒 未授权。请在 Claude Code 桌面端生成配对码后发送给我。')
604
- }
605
- return
606
- }
607
-
608
- enqueue(chatId, async () => {
609
- if (pendingProjectSelection.has(chatId)) {
610
- if (text.trim()) await startNewSession(chatId, text.trim())
611
- return
612
- }
613
- const ready = await ensureSession(chatId)
614
- if (!ready) return
615
- const effective =
616
- text || (attachments.length > 0 ? '(用户发送了附件)' : '')
617
- if (!effective && attachments.length === 0) return
618
- const sent = bridge.sendUserMessage(chatId, effective, attachments.length ? attachments : undefined)
619
- if (!sent) {
620
- await bot.api.sendMessage(Number(chatId), '⚠️ 消息发送失败,连接可能已断开。请发送 /new 重新开始。')
621
- }
622
- })
623
- }
624
-
625
- /** Scan ctx.message for photo/document/video/audio/voice, download
626
- * each via TelegramMediaService, apply size/mime limits, and produce
627
- * a ready-to-send AttachmentRef[] plus any rejection hints. */
628
- async function collectAttachmentsFromCtx(
629
- ctx: Context,
630
- ): Promise<{ attachments: AttachmentRef[]; rejections: string[] }> {
631
- const msg = ctx.message
632
- if (!msg || !ctx.chat) return { attachments: [], rejections: [] }
633
- const sessionId = sessionStore.get(String(ctx.chat.id))?.sessionId ?? String(ctx.chat.id)
634
- const attachments: AttachmentRef[] = []
635
- const rejections: string[] = []
636
-
637
- const runOne = async (
638
- fileId: string,
639
- fileName?: string,
640
- mimeType?: string,
641
- ): Promise<void> => {
642
- try {
643
- const local = await media.downloadFile(fileId, sessionId, { fileName, mimeType })
644
- const check = checkAttachmentLimit(local.kind, local.size, local.mimeType)
645
- if (!check.ok) {
646
- rejections.push(check.hint)
647
- return
648
- }
649
- if (local.kind === 'image') {
650
- attachments.push({
651
- type: 'image',
652
- name: local.name,
653
- data: local.buffer.toString('base64'),
654
- mimeType: local.mimeType,
655
- })
656
- } else {
657
- attachments.push({
658
- type: 'file',
659
- name: local.name,
660
- path: local.path,
661
- mimeType: local.mimeType,
662
- })
663
- }
664
- } catch (err) {
665
- console.error('[Telegram] downloadFile failed:', err)
666
- rejections.push('📎 附件下载失败,请稍后重试')
667
- }
668
- }
669
-
670
- // Photos: grammY exposes an array of sizes, largest last.
671
- if (msg.photo && msg.photo.length > 0) {
672
- const largest = msg.photo[msg.photo.length - 1]!
673
- await runOne(largest.file_id, `photo-${largest.file_unique_id}.jpg`, 'image/jpeg')
674
- }
675
- if (msg.document) {
676
- await runOne(msg.document.file_id, msg.document.file_name, msg.document.mime_type)
677
- }
678
- if (msg.video) {
679
- await runOne(msg.video.file_id, msg.video.file_name, msg.video.mime_type)
680
- }
681
- if (msg.audio) {
682
- await runOne(msg.audio.file_id, msg.audio.file_name, msg.audio.mime_type)
683
- }
684
- if (msg.voice) {
685
- await runOne(
686
- msg.voice.file_id,
687
- `voice-${msg.voice.file_unique_id}.ogg`,
688
- msg.voice.mime_type ?? 'audio/ogg',
689
- )
690
- }
691
-
692
- return { attachments, rejections }
693
- }
694
-
695
- bot.on('message:text', async (ctx) => {
696
- await routeUserMessage(ctx, ctx.message.text, [])
697
- })
698
-
699
- bot.on(
700
- ['message:photo', 'message:document', 'message:video', 'message:audio', 'message:voice'],
701
- async (ctx) => {
702
- const caption = ctx.message.caption ?? ''
703
- const { attachments, rejections } = await collectAttachmentsFromCtx(ctx)
704
- for (const r of rejections) {
705
- await ctx.reply(r).catch(() => {})
706
- }
707
- if (attachments.length === 0 && !caption.trim()) return
708
- await routeUserMessage(ctx, caption, attachments)
709
- },
710
- )
711
-
712
- bot.on('callback_query:data', async (ctx) => {
713
- const data = ctx.callbackQuery.data
714
- if (!data.startsWith('permit:')) return
715
-
716
- const parts = data.split(':')
717
- if (parts.length !== 3) return
718
-
719
- const requestId = parts[1]!
720
- const allowed = parts[2] === 'yes'
721
- const chatId = String(ctx.callbackQuery.message?.chat.id)
722
-
723
- bridge.sendPermissionResponse(chatId, requestId, allowed)
724
- const runtime = getRuntimeState(chatId)
725
- runtime.pendingPermissionCount = Math.max(0, runtime.pendingPermissionCount - 1)
726
-
727
- const statusText = allowed ? '✅ 已允许' : '❌ 已拒绝'
728
- try {
729
- await ctx.editMessageText(
730
- ctx.callbackQuery.message?.text + `\n\n${statusText}`,
731
- )
732
- } catch { /* ignore */ }
733
-
734
- await ctx.answerCallbackQuery(statusText)
735
- })
736
-
737
- // ---------- start ----------
738
-
739
- console.log('[Telegram] Starting bot...')
740
- console.log(`[Telegram] Server: ${config.serverUrl}`)
741
- console.log(`[Telegram] Allowed users: ${config.telegram.allowedUsers.length === 0 ? 'all' : config.telegram.allowedUsers.join(', ')}`)
742
-
743
- bot.start({
744
- onStart: () => console.log('[Telegram] Bot is running!'),
745
- })
746
-
747
- // Graceful shutdown
748
- process.on('SIGINT', () => {
749
- console.log('[Telegram] Shutting down...')
750
- bot.stop()
751
- bridge.destroy()
752
- dedup.destroy()
753
- process.exit(0)
754
- })