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.
- package/bin/bingo-win.cjs +2 -1
- package/bin/bingocode-win.cjs +2 -1
- package/bin/claude-win.cjs +2 -1
- package/bun.lock +1716 -0
- package/package.json +14 -2
- package/src/server/config/providers.yaml +1 -1
- package/src/server/proxy/transform/anthropicToOpenaiChat.ts +11 -4
- package/adapters/README.md +0 -87
- package/adapters/common/__tests__/chat-queue.test.ts +0 -61
- package/adapters/common/__tests__/format.test.ts +0 -148
- package/adapters/common/__tests__/http-client.test.ts +0 -105
- package/adapters/common/__tests__/message-buffer.test.ts +0 -84
- package/adapters/common/__tests__/message-dedup.test.ts +0 -57
- package/adapters/common/__tests__/session-store.test.ts +0 -62
- package/adapters/common/__tests__/ws-bridge.test.ts +0 -177
- package/adapters/common/attachment/__tests__/attachment-limits.test.ts +0 -52
- package/adapters/common/attachment/__tests__/attachment-store.test.ts +0 -108
- package/adapters/common/attachment/__tests__/image-block-watcher.test.ts +0 -115
- package/adapters/common/attachment/attachment-limits.ts +0 -58
- package/adapters/common/attachment/attachment-store.ts +0 -121
- package/adapters/common/attachment/attachment-types.ts +0 -29
- package/adapters/common/attachment/image-block-watcher.ts +0 -94
- package/adapters/common/chat-queue.ts +0 -24
- package/adapters/common/config.ts +0 -96
- package/adapters/common/format.ts +0 -229
- package/adapters/common/http-client.ts +0 -107
- package/adapters/common/message-buffer.ts +0 -91
- package/adapters/common/message-dedup.ts +0 -57
- package/adapters/common/pairing.ts +0 -149
- package/adapters/common/session-store.ts +0 -60
- package/adapters/common/ws-bridge.ts +0 -282
- package/adapters/feishu/__tests__/card-errors.test.ts +0 -194
- package/adapters/feishu/__tests__/cardkit.test.ts +0 -295
- package/adapters/feishu/__tests__/extract-payload.test.ts +0 -77
- package/adapters/feishu/__tests__/feishu.test.ts +0 -907
- package/adapters/feishu/__tests__/flush-controller.test.ts +0 -290
- package/adapters/feishu/__tests__/markdown-style.test.ts +0 -353
- package/adapters/feishu/__tests__/media.test.ts +0 -120
- package/adapters/feishu/__tests__/streaming-card.test.ts +0 -914
- package/adapters/feishu/card-errors.ts +0 -151
- package/adapters/feishu/cardkit.ts +0 -294
- package/adapters/feishu/extract-payload.ts +0 -95
- package/adapters/feishu/flush-controller.ts +0 -149
- package/adapters/feishu/index.ts +0 -1275
- package/adapters/feishu/markdown-style.ts +0 -212
- package/adapters/feishu/media.ts +0 -176
- package/adapters/feishu/streaming-card.ts +0 -612
- package/adapters/package.json +0 -23
- package/adapters/telegram/__tests__/media.test.ts +0 -86
- package/adapters/telegram/__tests__/telegram.test.ts +0 -115
- package/adapters/telegram/index.ts +0 -754
- package/adapters/telegram/media.ts +0 -89
- package/adapters/tsconfig.json +0 -18
- package/runtime/mac_helper.py +0 -775
- package/runtime/requirements-win.txt +0 -7
- package/runtime/requirements.txt +0 -6
- package/runtime/test_helpers.py +0 -322
- package/runtime/win_helper.py +0 -723
- package/scripts/count-app-loc.ts +0 -256
- package/scripts/release.ts +0 -130
- package/start-cli.bat +0 -7
- package/stubs/ant-claude-for-chrome-mcp.ts +0 -24
- 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
|
-
})
|