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,1275 +0,0 @@
1
- /**
2
- * 飞书 (Feishu/Lark) Adapter for Claude Code Desktop
3
- *
4
- * 基于 @larksuiteoapi/node-sdk 的轻量飞书 Bot,直连服务端 /ws/:sessionId。
5
- * 使用 WebSocket 长连接接收事件,无需公网地址。
6
- *
7
- * 启动:FEISHU_APP_ID=xxx FEISHU_APP_SECRET=xxx bun run feishu/index.ts
8
- */
9
-
10
- import * as Lark from '@larksuiteoapi/node-sdk'
11
- import * as path from 'node:path'
12
- import * as fs from 'node:fs/promises'
13
- import { WsBridge, type ServerMessage, type AttachmentRef } from '../common/ws-bridge.js'
14
- import { MessageDedup } from '../common/message-dedup.js'
15
- import { StreamingCard } from './streaming-card.js'
16
- import { enqueue } from '../common/chat-queue.js'
17
- import { loadConfig } from '../common/config.js'
18
- import {
19
- formatImHelp,
20
- formatImStatus,
21
- splitMessage,
22
- } from '../common/format.js'
23
- import { SessionStore } from '../common/session-store.js'
24
- import { AdapterHttpClient, type RecentProject } from '../common/http-client.js'
25
- import { isAllowedUser, tryPair } from '../common/pairing.js'
26
- import { optimizeMarkdownForFeishu } from './markdown-style.js'
27
- import { extractInboundPayload } from './extract-payload.js'
28
- import { FeishuMediaService } from './media.js'
29
- import { AttachmentStore } from '../common/attachment/attachment-store.js'
30
- import { checkAttachmentLimit } from '../common/attachment/attachment-limits.js'
31
- import { ImageBlockWatcher } from '../common/attachment/image-block-watcher.js'
32
- import type { PendingUpload } from '../common/attachment/attachment-types.js'
33
-
34
- // ---------- init ----------
35
-
36
- const config = loadConfig()
37
- if (!config.feishu.appId || !config.feishu.appSecret) {
38
- console.error('[Feishu] Missing FEISHU_APP_ID / FEISHU_APP_SECRET. Set env or ~/.claude/adapters.json')
39
- process.exit(1)
40
- }
41
-
42
- const larkClient = new Lark.Client({
43
- appId: config.feishu.appId,
44
- appSecret: config.feishu.appSecret,
45
- appType: Lark.AppType.SelfBuild,
46
- domain: Lark.Domain.Feishu,
47
- })
48
-
49
- const bridge = new WsBridge(config.serverUrl, 'feishu')
50
- const dedup = new MessageDedup()
51
- const sessionStore = new SessionStore()
52
- const httpClient = new AdapterHttpClient(config.serverUrl)
53
-
54
- // Attachment plumbing — shared by inbound (download) and outbound (upload) paths.
55
- const attachmentStore = new AttachmentStore()
56
- const media = new FeishuMediaService(larkClient, attachmentStore)
57
- attachmentStore.gc().catch((err) => {
58
- console.warn('[Feishu] AttachmentStore.gc failed:', err instanceof Error ? err.message : err)
59
- })
60
-
61
- // One streaming card lifecycle per chatId (CardKit main + patch fallback).
62
- const streamingCards = new Map<string, StreamingCard>()
63
- const pendingProjectSelection = new Map<string, boolean>()
64
- const runtimeStates = new Map<string, ChatRuntimeState>()
65
-
66
- // Per-chat outbound watchers for Agent-produced markdown image references.
67
- // `imageWatchers` extracts `![alt](src)` from streaming text;
68
- // `uploadedImageKeys` caches fingerprint → image_key so the same image
69
- // referenced multiple times in one turn isn't re-uploaded.
70
- const imageWatchers = new Map<string, ImageBlockWatcher>()
71
- const uploadedImageKeys = new Map<string, Map<string, string>>()
72
-
73
- // Bot's own open_id (resolved on first message)
74
- let botOpenId: string | null = null
75
- // WSClient reference for graceful shutdown
76
- let wsClient: InstanceType<typeof Lark.WSClient> | null = null
77
-
78
- type ChatRuntimeState = {
79
- state: 'idle' | 'thinking' | 'streaming' | 'tool_executing' | 'permission_pending'
80
- verb?: string
81
- model?: string
82
- pendingPermissionCount: number
83
- }
84
-
85
- // ---------- helpers ----------
86
-
87
- function getRuntimeState(chatId: string): ChatRuntimeState {
88
- let state = runtimeStates.get(chatId)
89
- if (!state) {
90
- state = { state: 'idle', pendingPermissionCount: 0 }
91
- runtimeStates.set(chatId, state)
92
- }
93
- return state
94
- }
95
-
96
- /** Get the existing StreamingCard for this chat, or create one in 'idle' state. */
97
- function getOrCreateStreamingCard(chatId: string): StreamingCard {
98
- let card = streamingCards.get(chatId)
99
- if (!card) {
100
- card = new StreamingCard({ larkClient, chatId })
101
- streamingCards.set(chatId, card)
102
- }
103
- return card
104
- }
105
-
106
- function getImageWatcher(chatId: string): ImageBlockWatcher {
107
- let w = imageWatchers.get(chatId)
108
- if (!w) {
109
- w = new ImageBlockWatcher()
110
- imageWatchers.set(chatId, w)
111
- }
112
- return w
113
- }
114
-
115
- function getUploadedKeys(chatId: string): Map<string, string> {
116
- let m = uploadedImageKeys.get(chatId)
117
- if (!m) {
118
- m = new Map()
119
- uploadedImageKeys.set(chatId, m)
120
- }
121
- return m
122
- }
123
-
124
- /** Upload a PendingUpload found in streaming output and send it as an
125
- * independent im.message.create({msg_type:'image'}) message — runs
126
- * fire-and-forget so the streaming card is never blocked. All failure
127
- * modes are non-fatal: log and skip. */
128
- async function dispatchOutboundImage(chatId: string, pending: PendingUpload): Promise<void> {
129
- const cache = getUploadedKeys(chatId)
130
- if (cache.has(pending.id)) return // already uploaded within this chat
131
-
132
- try {
133
- let buffer: Buffer
134
- let mime = 'image/png'
135
- switch (pending.source.kind) {
136
- case 'base64': {
137
- buffer = Buffer.from(pending.source.data, 'base64')
138
- mime = pending.source.mime
139
- break
140
- }
141
- case 'path': {
142
- buffer = await fs.readFile(pending.source.path)
143
- mime = pending.source.mime ?? 'image/png'
144
- break
145
- }
146
- case 'url': {
147
- const resp = await fetch(pending.source.url)
148
- if (!resp.ok) throw new Error(`fetch ${pending.source.url} -> ${resp.status}`)
149
- buffer = Buffer.from(await resp.arrayBuffer())
150
- mime = pending.source.mime ?? resp.headers.get('content-type') ?? 'image/png'
151
- break
152
- }
153
- }
154
-
155
- const check = checkAttachmentLimit('image', buffer.length, mime)
156
- if (!check.ok) {
157
- console.warn('[Feishu] Outbound image rejected:', check.hint)
158
- return
159
- }
160
-
161
- const imageKey = await media.uploadImage(buffer, mime)
162
- cache.set(pending.id, imageKey)
163
- await media.sendImageMessage(chatId, imageKey)
164
- } catch (err) {
165
- console.error(
166
- '[Feishu] dispatchOutboundImage failed:',
167
- err instanceof Error ? err.message : err,
168
- )
169
- }
170
- }
171
-
172
- /** Finalize and remove the streaming card (normal completion). */
173
- async function finalizeStreamingCard(chatId: string): Promise<void> {
174
- const card = streamingCards.get(chatId)
175
- if (!card) return
176
- streamingCards.delete(chatId)
177
- await card.finalize()
178
- }
179
-
180
- /** Abort and remove the streaming card (error path). Non-throwing. */
181
- async function abortStreamingCard(chatId: string, err: Error): Promise<void> {
182
- const card = streamingCards.get(chatId)
183
- if (!card) return
184
- streamingCards.delete(chatId)
185
- await card.abort(err).catch(() => {})
186
- }
187
-
188
- function clearTransientChatState(chatId: string): void {
189
- // Abort any in-flight streaming card (best effort, don't block)
190
- const card = streamingCards.get(chatId)
191
- if (card) {
192
- streamingCards.delete(chatId)
193
- void card.abort(new Error('session cleared')).catch(() => {})
194
- }
195
- imageWatchers.delete(chatId)
196
- uploadedImageKeys.delete(chatId)
197
- const runtime = getRuntimeState(chatId)
198
- runtime.state = 'idle'
199
- runtime.verb = undefined
200
- runtime.pendingPermissionCount = 0
201
- }
202
-
203
- async function ensureExistingSession(chatId: string): Promise<{ sessionId: string; workDir: string } | null> {
204
- const stored = sessionStore.get(chatId)
205
- if (!stored) return null
206
-
207
- if (!bridge.hasSession(chatId)) {
208
- bridge.connectSession(chatId, stored.sessionId)
209
- bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
210
- const opened = await bridge.waitForOpen(chatId)
211
- if (!opened) return null
212
- }
213
-
214
- return stored
215
- }
216
-
217
- async function buildStatusText(chatId: string): Promise<string> {
218
- const stored = await ensureExistingSession(chatId)
219
- if (!stored) return formatImStatus(null)
220
-
221
- const runtime = getRuntimeState(chatId)
222
- let projectName = path.basename(stored.workDir) || stored.workDir
223
- let branch: string | null = null
224
-
225
- try {
226
- const gitInfo = await httpClient.getGitInfo(stored.sessionId)
227
- projectName = gitInfo.repoName || path.basename(gitInfo.workDir) || projectName
228
- branch = gitInfo.branch
229
- } catch {
230
- // Ignore git lookup failures and fall back to stored workDir
231
- }
232
-
233
- let taskCounts:
234
- | {
235
- total: number
236
- pending: number
237
- inProgress: number
238
- completed: number
239
- }
240
- | undefined
241
-
242
- try {
243
- const tasks = await httpClient.getTasksForSession(stored.sessionId)
244
- if (tasks.length > 0) {
245
- taskCounts = {
246
- total: tasks.length,
247
- pending: tasks.filter((task) => task.status === 'pending').length,
248
- inProgress: tasks.filter((task) => task.status === 'in_progress').length,
249
- completed: tasks.filter((task) => task.status === 'completed').length,
250
- }
251
- }
252
- } catch {
253
- // Ignore task lookup failures in IM status summary
254
- }
255
-
256
- return formatImStatus({
257
- sessionId: stored.sessionId,
258
- projectName,
259
- branch,
260
- model: runtime.model,
261
- state: runtime.state,
262
- verb: runtime.verb,
263
- pendingPermissionCount: runtime.pendingPermissionCount,
264
- taskCounts,
265
- })
266
- }
267
-
268
- /** Send a text message (post format). */
269
- async function sendText(chatId: string, text: string, replyToMessageId?: string): Promise<string | undefined> {
270
- const content = JSON.stringify({
271
- zh_cn: { content: [[{ tag: 'md', text }]] },
272
- })
273
-
274
- try {
275
- if (replyToMessageId) {
276
- const resp = await larkClient.im.message.reply({
277
- path: { message_id: replyToMessageId },
278
- data: { content, msg_type: 'post' },
279
- })
280
- return resp.data?.message_id
281
- }
282
- const resp = await larkClient.im.message.create({
283
- params: { receive_id_type: 'chat_id' },
284
- data: {
285
- receive_id: chatId,
286
- msg_type: 'post' as const,
287
- content,
288
- },
289
- })
290
- return resp.data?.message_id
291
- } catch (err) {
292
- console.error('[Feishu] Send text error:', err)
293
- return undefined
294
- }
295
- }
296
-
297
- /** Send an interactive card (for permission requests). */
298
- async function sendCard(chatId: string, card: Record<string, unknown>): Promise<string | undefined> {
299
- try {
300
- const resp = await larkClient.im.message.create({
301
- params: { receive_id_type: 'chat_id' },
302
- data: {
303
- receive_id: chatId,
304
- msg_type: 'interactive',
305
- content: JSON.stringify(card),
306
- },
307
- })
308
- return resp.data?.message_id
309
- } catch (err) {
310
- console.error('[Feishu] Send card error:', err)
311
- return undefined
312
- }
313
- }
314
-
315
- /** Pretty-print an absolute path for IM display.
316
- * - Replace $HOME with `~`
317
- * - Middle-truncate if it's still very long, keeping the project tail visible */
318
- function prettyPath(realPath: string, maxLen = 64): string {
319
- const home = process.env.HOME
320
- let p = realPath
321
- if (home) {
322
- if (p === home) return '~'
323
- if (p.startsWith(`${home}/`)) p = `~${p.slice(home.length)}`
324
- }
325
- if (p.length <= maxLen) return p
326
- // Project name lives at the tail — keep more of the tail than the head.
327
- const tailLen = Math.floor(maxLen * 0.65)
328
- const headLen = maxLen - tailLen - 1
329
- return `${p.slice(0, headLen)}…${p.slice(-tailLen)}`
330
- }
331
-
332
- /** Build an interactive project picker card — mobile-first layout.
333
- *
334
- * Design: one column_set per project with exactly 2 columns:
335
- * - Col 1 (weighted): project info (title markdown + small grey path)
336
- * - Col 2 (auto): "选择" button, vertically centered
337
- *
338
- * Only 2 columns with one weighted + one auto means the weight distribution
339
- * is trivial (auto takes its natural width, weighted takes the rest). This
340
- * avoids the layout issues seen in 3-column attempts. */
341
- function buildProjectPickerCard(projects: RecentProject[]): Record<string, unknown> {
342
- const items = projects.slice(0, 10)
343
- const total = projects.length
344
- const subtitleText =
345
- total > items.length
346
- ? `共 ${total} 个最近项目,显示前 ${items.length}`
347
- : `共 ${total} 个最近项目`
348
-
349
- const rows = items.map((p, i) => {
350
- const branch = p.branch ? ` · *${p.branch}*` : ''
351
- return {
352
- tag: 'column_set',
353
- flex_mode: 'stretch',
354
- horizontal_spacing: '8px',
355
- margin: i === 0 ? '0px 0 0 0' : '10px 0 0 0',
356
- columns: [
357
- // Col 1 — project info (title + notation path, stacked)
358
- {
359
- tag: 'column',
360
- width: 'weighted',
361
- weight: 1,
362
- vertical_align: 'center',
363
- elements: [
364
- {
365
- tag: 'markdown',
366
- content: `**${p.projectName}**${branch}`,
367
- },
368
- {
369
- tag: 'markdown',
370
- content: prettyPath(p.realPath, 56),
371
- text_size: 'notation',
372
- margin: '2px 0 0 0',
373
- },
374
- ],
375
- },
376
- // Col 2 — action button (auto width, vertically centered)
377
- {
378
- tag: 'column',
379
- width: 'auto',
380
- vertical_align: 'center',
381
- elements: [
382
- {
383
- tag: 'button',
384
- text: { tag: 'plain_text', content: '选择' },
385
- type: i === 0 ? 'primary' : 'default',
386
- size: 'small',
387
- value: {
388
- action: 'pick_project',
389
- realPath: p.realPath,
390
- projectName: p.projectName,
391
- },
392
- },
393
- ],
394
- },
395
- ],
396
- }
397
- })
398
-
399
- return {
400
- schema: '2.0',
401
- config: {
402
- wide_screen_mode: true,
403
- update_multi: true,
404
- },
405
- header: {
406
- title: { tag: 'plain_text', content: '📁 选择项目' },
407
- subtitle: { tag: 'plain_text', content: subtitleText },
408
- template: 'blue',
409
- },
410
- body: {
411
- elements: [
412
- ...rows,
413
- { tag: 'hr', margin: '14px 0 0 0' },
414
- {
415
- tag: 'markdown',
416
- content: '💡 点击右侧 **选择** 按钮,或发送 `/new <项目名>`',
417
- text_size: 'notation',
418
- margin: '6px 0 0 0',
419
- },
420
- ],
421
- },
422
- }
423
- }
424
-
425
- /** Human-readable summary of a tool call for display in the permission card. */
426
- type ToolCallSummary = {
427
- icon: string
428
- label: string
429
- /** Display string for the operation target (file path or command preview) */
430
- target?: string
431
- /** Absolute file path for cross-directory detection, when applicable */
432
- filePath?: string
433
- }
434
-
435
- /** Map a Claude Code tool call to an icon + human-readable Chinese label.
436
- * Unknown tools fall back to the raw tool name with a generic icon. */
437
- function summarizeToolCall(toolName: string, input: unknown): ToolCallSummary {
438
- const rec: Record<string, unknown> =
439
- input && typeof input === 'object' ? (input as Record<string, unknown>) : {}
440
- const str = (key: string): string | undefined =>
441
- typeof rec[key] === 'string' ? (rec[key] as string) : undefined
442
-
443
- switch (toolName) {
444
- case 'Write': {
445
- const fp = str('file_path')
446
- return { icon: '✏️', label: '写入文件', target: fp, filePath: fp }
447
- }
448
- case 'Edit':
449
- case 'MultiEdit':
450
- case 'NotebookEdit': {
451
- const fp = str('file_path') ?? str('notebook_path')
452
- return { icon: '✏️', label: '修改文件', target: fp, filePath: fp }
453
- }
454
- case 'Read': {
455
- const fp = str('file_path')
456
- return { icon: '📖', label: '读取文件', target: fp, filePath: fp }
457
- }
458
- case 'Bash':
459
- case 'BashOutput': {
460
- return { icon: '🖥️', label: '执行命令', target: str('command') }
461
- }
462
- case 'Grep': {
463
- const pattern = str('pattern')
464
- return {
465
- icon: '🔍',
466
- label: '搜索内容',
467
- target: pattern ? `pattern: ${pattern}` : undefined,
468
- filePath: str('path'),
469
- }
470
- }
471
- case 'Glob': {
472
- const pattern = str('pattern')
473
- return {
474
- icon: '📁',
475
- label: '查找文件',
476
- target: pattern ? `pattern: ${pattern}` : undefined,
477
- filePath: str('path'),
478
- }
479
- }
480
- case 'WebFetch':
481
- return { icon: '🌐', label: '访问网页', target: str('url') }
482
- case 'WebSearch':
483
- return { icon: '🌐', label: '搜索网页', target: str('query') }
484
- default:
485
- return { icon: '🔧', label: toolName }
486
- }
487
- }
488
-
489
- /** True if `filePath` resolves to a location outside of `workDir`.
490
- * Relative paths are resolved against workDir first. */
491
- function isOutsideWorkDir(filePath: string, workDir: string): boolean {
492
- const abs = path.isAbsolute(filePath)
493
- ? path.normalize(filePath)
494
- : path.resolve(workDir, filePath)
495
- const normWork = path.normalize(workDir).replace(/\/+$/, '')
496
- return abs !== normWork && !abs.startsWith(normWork + path.sep)
497
- }
498
-
499
- /** Truncate a single-line target preview (e.g. shell command) to maxLen. */
500
- function truncateTarget(s: string, maxLen = 160): string {
501
- if (s.length <= maxLen) return s
502
- return s.slice(0, maxLen - 1) + '…'
503
- }
504
-
505
- /** Build a permission request card (Schema 2.0, mobile-friendly).
506
- *
507
- * Layout:
508
- * header → 🔐 需要权限确认 (orange / red if cross-dir)
509
- * body → <icon> **<label>** `<toolName>`
510
- * ```
511
- * <target> (path or command, if present)
512
- * ```
513
- * ⚠️ 跨目录警告 (only when filePath escapes workDir)
514
- * ────
515
- * [ ✅ 允许 | ♾️ 永久允许 | ❌ 拒绝 ]
516
- *
517
- * The 永久允许 button carries `rule: 'always'` in its value — the server
518
- * turns that into `updatedPermissions` using the CLI's permission_suggestions,
519
- * so the same tool call won't prompt again in this session. */
520
- function buildPermissionCard(
521
- toolName: string,
522
- input: unknown,
523
- requestId: string,
524
- workDir?: string,
525
- ): Record<string, unknown> {
526
- const summary = summarizeToolCall(toolName, input)
527
- const crossDir = Boolean(
528
- workDir && summary.filePath && isOutsideWorkDir(summary.filePath, workDir),
529
- )
530
-
531
- const elements: Record<string, unknown>[] = [
532
- // Header line: icon + human label + raw tool tag
533
- {
534
- tag: 'markdown',
535
- content: `${summary.icon} **${summary.label}** \`${toolName}\``,
536
- },
537
- ]
538
-
539
- // Target preview (file path / command / url …)
540
- if (summary.target) {
541
- const shown = summary.filePath
542
- ? prettyPath(summary.target, 80)
543
- : truncateTarget(summary.target, 160)
544
- elements.push({
545
- tag: 'markdown',
546
- content: '```\n' + shown + '\n```',
547
- margin: '4px 0 0 0',
548
- })
549
- }
550
-
551
- // Cross-directory warning (only when the file escapes the session's workDir)
552
- if (crossDir) {
553
- elements.push({
554
- tag: 'markdown',
555
- content: '⚠️ **该操作位于当前项目目录之外**',
556
- margin: '8px 0 0 0',
557
- text_size: 'notation',
558
- })
559
- }
560
-
561
- // Divider
562
- elements.push({ tag: 'hr', margin: '12px 0 0 0' })
563
-
564
- // Action row — three equal columns: 允许 / 永久允许 / 拒绝
565
- elements.push({
566
- tag: 'column_set',
567
- flex_mode: 'stretch',
568
- horizontal_spacing: '8px',
569
- margin: '8px 0 0 0',
570
- columns: [
571
- {
572
- tag: 'column',
573
- width: 'weighted',
574
- weight: 1,
575
- vertical_align: 'center',
576
- elements: [
577
- {
578
- tag: 'button',
579
- text: { tag: 'plain_text', content: '✅ 允许' },
580
- type: 'primary',
581
- size: 'medium',
582
- value: { action: 'permit', requestId, allowed: true },
583
- },
584
- ],
585
- },
586
- {
587
- tag: 'column',
588
- width: 'weighted',
589
- weight: 1,
590
- vertical_align: 'center',
591
- elements: [
592
- {
593
- tag: 'button',
594
- text: { tag: 'plain_text', content: '♾️ 永久允许' },
595
- type: 'default',
596
- size: 'medium',
597
- value: { action: 'permit', requestId, allowed: true, rule: 'always' },
598
- },
599
- ],
600
- },
601
- {
602
- tag: 'column',
603
- width: 'weighted',
604
- weight: 1,
605
- vertical_align: 'center',
606
- elements: [
607
- {
608
- tag: 'button',
609
- text: { tag: 'plain_text', content: '❌ 拒绝' },
610
- type: 'danger',
611
- size: 'medium',
612
- value: { action: 'permit', requestId, allowed: false },
613
- },
614
- ],
615
- },
616
- ],
617
- })
618
-
619
- return {
620
- schema: '2.0',
621
- config: {
622
- wide_screen_mode: false,
623
- update_multi: true,
624
- },
625
- header: {
626
- title: { tag: 'plain_text', content: '🔐 需要权限确认' },
627
- subtitle: {
628
- tag: 'plain_text',
629
- content: crossDir ? '⚠️ 跨目录操作' : toolName,
630
- },
631
- template: crossDir ? 'red' : 'orange',
632
- padding: '12px 12px 12px 12px',
633
- icon: { tag: 'standard_icon', token: 'lock-chat_filled' },
634
- },
635
- body: { elements },
636
- }
637
- }
638
-
639
- // ---------- session management ----------
640
-
641
- async function ensureSession(chatId: string): Promise<boolean> {
642
- if (bridge.hasSession(chatId)) return true
643
-
644
- const stored = sessionStore.get(chatId)
645
- if (stored) {
646
- bridge.connectSession(chatId, stored.sessionId)
647
- bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
648
- return await bridge.waitForOpen(chatId)
649
- }
650
-
651
- const workDir = config.defaultProjectDir
652
- if (workDir) {
653
- return await createSessionForChat(chatId, workDir)
654
- }
655
-
656
- await showProjectPicker(chatId)
657
- return false
658
- }
659
-
660
- async function createSessionForChat(chatId: string, workDir: string): Promise<boolean> {
661
- try {
662
- // Always tear down any stale WS connection before creating a new session.
663
- // Without this, bridge.connectSession() below would short-circuit when an
664
- // old OPEN connection still exists (e.g. /projects → pick_project path),
665
- // leaving user messages routed to the previous session's workDir.
666
- bridge.resetSession(chatId)
667
- // Also abort any in-flight streaming card tied to the old session.
668
- const inflightCard = streamingCards.get(chatId)
669
- if (inflightCard) {
670
- streamingCards.delete(chatId)
671
- void inflightCard.abort(new Error('session reset')).catch(() => {})
672
- }
673
-
674
- const sessionId = await httpClient.createSession(workDir)
675
- sessionStore.set(chatId, sessionId, workDir)
676
- bridge.connectSession(chatId, sessionId)
677
- bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
678
- const opened = await bridge.waitForOpen(chatId)
679
- if (!opened) {
680
- await sendText(chatId, '⚠️ 连接服务器超时,请重试。')
681
- return false
682
- }
683
- return true
684
- } catch (err) {
685
- await sendText(chatId, `❌ 无法创建会话: ${err instanceof Error ? err.message : String(err)}`)
686
- return false
687
- }
688
- }
689
-
690
- async function showProjectPicker(chatId: string): Promise<void> {
691
- try {
692
- const projects = await httpClient.listRecentProjects()
693
- if (projects.length === 0) {
694
- await sendText(chatId,
695
- '没有找到最近的项目。请先在 Desktop App 中打开一个项目,或在设置中配置默认项目。')
696
- return
697
- }
698
- pendingProjectSelection.set(chatId, true)
699
- const cardId = await sendCard(chatId, buildProjectPickerCard(projects))
700
- if (!cardId) {
701
- // Fallback to text picker if card delivery failed (permissions, etc.)
702
- const lines = projects.slice(0, 10).map((p, i) =>
703
- `${i + 1}. **${p.projectName}**${p.branch ? ` (${p.branch})` : ''}\n ${p.realPath}`
704
- )
705
- await sendText(chatId, `选择项目(回复编号):\n\n${lines.join('\n\n')}\n\n💡 下次可直接 /new <编号或名称> 快速新建会话`)
706
- }
707
- } catch (err) {
708
- await sendText(chatId, `❌ 无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`)
709
- }
710
- }
711
-
712
- async function startNewSession(chatId: string, query?: string): Promise<void> {
713
- bridge.resetSession(chatId)
714
- sessionStore.delete(chatId)
715
- // Abort any in-flight streaming card for the previous session
716
- const inflightCard = streamingCards.get(chatId)
717
- if (inflightCard) {
718
- streamingCards.delete(chatId)
719
- void inflightCard.abort(new Error('session reset')).catch(() => {})
720
- }
721
- imageWatchers.delete(chatId)
722
- uploadedImageKeys.delete(chatId)
723
- pendingProjectSelection.delete(chatId)
724
- runtimeStates.delete(chatId)
725
-
726
- if (query) {
727
- try {
728
- const { project, ambiguous } = await httpClient.matchProject(query)
729
- if (project) {
730
- const ok = await createSessionForChat(chatId, project.realPath)
731
- if (ok) {
732
- await sendText(chatId,
733
- `✅ 已新建会话:**${project.projectName}**${project.branch ? ` (${project.branch})` : ''}`)
734
- }
735
- return
736
- }
737
- if (ambiguous) {
738
- const list = ambiguous.map((p, i) => `${i + 1}. **${p.projectName}** — ${p.realPath}`).join('\n')
739
- await sendText(chatId, `匹配到多个项目,请更精确:\n\n${list}`)
740
- return
741
- }
742
- await sendText(chatId, `未找到匹配 "${query}" 的项目。发送 /projects 查看完整列表。`)
743
- } catch (err) {
744
- await sendText(chatId, `❌ ${err instanceof Error ? err.message : String(err)}`)
745
- }
746
- } else {
747
- const workDir = config.defaultProjectDir
748
- if (workDir) {
749
- const ok = await createSessionForChat(chatId, workDir)
750
- if (ok) {
751
- await sendText(chatId, '✅ 已新建会话,可以开始对话了。')
752
- }
753
- } else {
754
- await showProjectPicker(chatId)
755
- }
756
- }
757
- }
758
-
759
- // ---------- server message handler ----------
760
-
761
- async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<void> {
762
- const runtime = getRuntimeState(chatId)
763
-
764
- switch (msg.type) {
765
- case 'connected':
766
- break
767
-
768
- case 'status': {
769
- runtime.state = msg.state
770
- runtime.verb = typeof msg.verb === 'string' ? msg.verb : undefined
771
- // 注意: 故意不在 thinking 时创建卡片。/clear、/compact 这类命令
772
- // 不产生文本输出,但 CLI 仍会发 thinking → message_complete 事件。
773
- // 如果在 thinking 就建卡,这些命令会留下一张空卡片。
774
- // 真正的创建时机是 content_start{text} 或第一次 content_delta。
775
- break
776
- }
777
-
778
- case 'content_start': {
779
- if (msg.blockType === 'text') {
780
- // 幂等: 预建卡或上一次 content_delta 已经创建了卡片则复用,否则现在创建
781
- const card = getOrCreateStreamingCard(chatId)
782
- await card.ensureCreated().catch((err) => {
783
- console.error('[Feishu] ensureCreated on content_start failed:', err)
784
- })
785
- } else if (msg.blockType === 'tool_use') {
786
- // 把工具调用起点登记到已存在的卡 —— 让用户看到 "⚙️ 运行中..." 指示。
787
- // 只读 map,不 getOrCreate: /clear 这类无回复命令不应该因为上游发了
788
- // 孤立的 tool_use 事件而被迫建一张空卡。
789
- const card = streamingCards.get(chatId)
790
- if (card) {
791
- card.startTool(msg.toolUseId, msg.toolName)
792
- }
793
- }
794
- // 注意: tool_use 不 finalize 当前卡。让整个 turn 的所有文本输出
795
- // 合并到同一张卡里 —— 更接近 Desktop UI 的一体化答复体验,也避免
796
- // "预建空卡 + tool_use finalize → 留下空白卡" 的视觉 bug。
797
- break
798
- }
799
-
800
- case 'content_delta': {
801
- if (typeof msg.text === 'string' && msg.text) {
802
- // 正常情况 content_start{text} 已经创建了卡片,这里直接 appendText。
803
- // 极端情况(上游跳过了 content_start)也要能容错 —— getOrCreate + async ensureCreated。
804
- const card = getOrCreateStreamingCard(chatId)
805
- // ensureCreated 幂等,已 streaming 时是 no-op
806
- void card.ensureCreated().catch((err) => {
807
- console.error('[Feishu] ensureCreated on delta failed:', err)
808
- })
809
- card.appendText(msg.text)
810
-
811
- // Watch the streaming text for outbound markdown image references
812
- // (`![alt](src)`) and dispatch each new one as a standalone
813
- // im.message.create({msg_type:'image'}) — fire-and-forget so the
814
- // streaming card never waits on upload RTT. The image arrives in
815
- // chat as a separate message alongside the streaming card text.
816
- const newUploads = getImageWatcher(chatId).feed(msg.text)
817
- for (const pending of newUploads) {
818
- void dispatchOutboundImage(chatId, pending)
819
- }
820
- }
821
- break
822
- }
823
-
824
- case 'thinking': {
825
- // 推理文本(reasoning)—— 作为卡片顶部的 blockquote 预览持续更新,
826
- // 让用户在工具执行期间也能看到模型的思考过程(对齐 Telegram 的行为)。
827
- // 同样不 auto-create: 没有预建卡的命令路径不应该被 thinking 事件撑出一张空卡。
828
- const card = streamingCards.get(chatId)
829
- if (card && typeof msg.text === 'string' && msg.text) {
830
- card.appendReasoning(msg.text)
831
- }
832
- break
833
- }
834
-
835
- case 'tool_use_complete': {
836
- // 把对应 tool step 从 "⚙️ running" 切到 "✅ done",让用户看到进度推进。
837
- const card = streamingCards.get(chatId)
838
- if (card) {
839
- card.completeTool(msg.toolUseId, msg.toolName)
840
- }
841
- break
842
- }
843
-
844
- case 'tool_result':
845
- // Tool errors are handled internally by the AI (retries etc.)
846
- break
847
-
848
- case 'permission_request': {
849
- runtime.pendingPermissionCount += 1
850
- runtime.state = 'permission_pending'
851
- const stored = sessionStore.get(chatId)
852
- const card = buildPermissionCard(
853
- msg.toolName,
854
- msg.input,
855
- msg.requestId,
856
- stored?.workDir,
857
- )
858
- await sendCard(chatId, card)
859
- break
860
- }
861
-
862
- case 'message_complete':
863
- runtime.state = 'idle'
864
- runtime.verb = undefined
865
- await finalizeStreamingCard(chatId)
866
- break
867
-
868
- case 'error':
869
- runtime.state = 'idle'
870
- runtime.verb = undefined
871
- // Auto-recover from stale thinking block signatures by creating a fresh session.
872
- if (msg.message && /Invalid.*signature.*thinking/i.test(msg.message)) {
873
- // Abort any in-flight streaming card first
874
- if (streamingCards.has(chatId)) {
875
- const card = streamingCards.get(chatId)!
876
- streamingCards.delete(chatId)
877
- void card.abort(new Error('session reset')).catch(() => {})
878
- }
879
- const stored = sessionStore.get(chatId)
880
- const workDir = stored?.workDir || config.defaultProjectDir
881
- if (workDir) {
882
- await sendText(chatId, '⚠️ 会话上下文已失效,正在自动重建...')
883
- bridge.resetSession(chatId)
884
- sessionStore.delete(chatId)
885
- imageWatchers.delete(chatId)
886
- uploadedImageKeys.delete(chatId)
887
- runtimeStates.delete(chatId)
888
- const ok = await createSessionForChat(chatId, workDir)
889
- if (ok) {
890
- await sendText(chatId, '✅ 已重建会话,请重新发送消息。')
891
- } else {
892
- await sendText(chatId, '❌ 重建会话失败,请发送 /new 手动新建。')
893
- }
894
- } else {
895
- await sendText(chatId, '⚠️ 会话上下文已失效,请发送 /new 新建会话。')
896
- }
897
- } else if (streamingCards.has(chatId)) {
898
- await abortStreamingCard(chatId, new Error(msg.message ?? 'unknown error'))
899
- } else {
900
- await sendText(chatId, `❌ ${msg.message}`)
901
- }
902
- break
903
-
904
- case 'system_notification':
905
- if (msg.subtype === 'init' && msg.data && typeof msg.data === 'object') {
906
- const model = (msg.data as Record<string, unknown>).model
907
- if (typeof model === 'string' && model.trim()) {
908
- runtime.model = model
909
- }
910
- }
911
- break
912
- }
913
- }
914
-
915
- // ---------- message helpers ----------
916
-
917
- function isBotMentioned(mentions?: Array<{ id?: { open_id?: string } }>): boolean {
918
- if (!mentions || !botOpenId) return false
919
- return mentions.some((m) => m.id?.open_id === botOpenId)
920
- }
921
-
922
- function stripMentions(text: string): string {
923
- return text.replace(/@_user_\d+/g, '').trim()
924
- }
925
-
926
- // ---------- event handlers ----------
927
-
928
- async function handleMessage(data: any): Promise<void> {
929
- const event = data as {
930
- sender?: { sender_id?: { open_id?: string } }
931
- message?: {
932
- message_id?: string
933
- chat_id?: string
934
- chat_type?: string
935
- content?: string
936
- message_type?: string
937
- mentions?: Array<{ id?: { open_id?: string }; name?: string }>
938
- }
939
- }
940
-
941
- const messageId = event.message?.message_id
942
- const chatId = event.message?.chat_id
943
- const senderOpenId = event.sender?.sender_id?.open_id
944
- const chatType = event.message?.chat_type
945
- const content = event.message?.content
946
- const msgType = event.message?.message_type
947
-
948
- if (!messageId || !chatId || !senderOpenId || !content || !msgType) return
949
-
950
- if (!dedup.tryRecord(messageId)) return
951
-
952
- // 只处理私聊
953
- if (chatType === 'p2p') {
954
- if (!isAllowedUser('feishu', senderOpenId)) {
955
- // 尝试配对
956
- const pairText = extractInboundPayload(content, msgType).text.trim() || null
957
- if (pairText) {
958
- const success = tryPair(pairText.trim(), { userId: senderOpenId, displayName: 'Feishu User' }, 'feishu')
959
- if (success) {
960
- await sendText(chatId, '✅ 配对成功!现在可以开始聊天了。\n\n发送消息即可与 Claude 对话。')
961
- } else {
962
- await sendText(chatId, '🔒 未授权。请在 Claude Code 桌面端生成配对码后发送给我。')
963
- }
964
- }
965
- return
966
- }
967
- } else {
968
- // 群聊不处理
969
- return
970
- }
971
-
972
- const payload = extractInboundPayload(content, msgType)
973
- const msgText = stripMentions(payload.text || '')
974
- const pendingDownloads = payload.pendingDownloads
975
- const hasAttachments = pendingDownloads.length > 0
976
-
977
- // Allow empty text only when attachments are present
978
- // (image-only / file-only message)
979
- if (!msgText && !hasAttachments) return
980
-
981
- // Capture messageId in a non-nullable const before entering the enqueue
982
- // closure so the downloadResource call below doesn't need a `!` assertion.
983
- // The early-return guard at the top of handleMessage already proved it
984
- // non-undefined, but TS doesn't track that across the async closure.
985
- const safeMessageId = messageId
986
-
987
- // All user input (commands + normal chat) goes through a single per-chat
988
- // serial queue. Without this, rapidly-fired commands could have their
989
- // async bodies interleave at `await` points, causing reply messages
990
- // (e.g. "🧹 已清空..." after "✅ 已新建...") to appear in the wrong order.
991
- enqueue(chatId, async () => {
992
- // ----- Commands (only when there are no attachments — `command + image`
993
- // isn't a meaningful combo, so attachments always take precedence) -----
994
-
995
- if (!hasAttachments && (msgText === '/new' || msgText === '新会话' || msgText.startsWith('/new '))) {
996
- const arg = msgText.startsWith('/new ') ? msgText.slice(5).trim() : ''
997
- await startNewSession(chatId, arg || undefined)
998
- return
999
- }
1000
- if (!hasAttachments && (msgText === '/help' || msgText === '帮助')) {
1001
- await sendText(chatId, formatImHelp())
1002
- return
1003
- }
1004
- if (!hasAttachments && (msgText === '/status' || msgText === '状态')) {
1005
- await sendText(chatId, await buildStatusText(chatId))
1006
- return
1007
- }
1008
- if (!hasAttachments && (msgText === '/clear' || msgText === '清空')) {
1009
- const stored = await ensureExistingSession(chatId)
1010
- if (!stored) {
1011
- await sendText(chatId, formatImStatus(null))
1012
- return
1013
- }
1014
- clearTransientChatState(chatId)
1015
- const sent = bridge.sendUserMessage(chatId, '/clear')
1016
- if (!sent) {
1017
- await sendText(chatId, '⚠️ 无法发送 /clear,请先发送 /new 重新连接会话。')
1018
- return
1019
- }
1020
- await sendText(chatId, '🧹 已清空当前会话上下文。')
1021
- return
1022
- }
1023
- if (!hasAttachments && (msgText === '/stop' || msgText === '停止')) {
1024
- const stored = await ensureExistingSession(chatId)
1025
- if (!stored) {
1026
- await sendText(chatId, formatImStatus(null))
1027
- return
1028
- }
1029
- bridge.sendStopGeneration(chatId)
1030
- await sendText(chatId, '⏹ 已发送停止信号。')
1031
- return
1032
- }
1033
- if (!hasAttachments && (msgText === '/projects' || msgText === '项目列表')) {
1034
- await showProjectPicker(chatId)
1035
- return
1036
- }
1037
-
1038
- // User is replying to a project picker prompt
1039
- if (!hasAttachments && pendingProjectSelection.has(chatId)) {
1040
- await startNewSession(chatId, msgText.trim())
1041
- return
1042
- }
1043
-
1044
- // ----- Normal message flow (with optional inbound attachments) -----
1045
-
1046
- const ready = await ensureSession(chatId)
1047
- if (!ready) return
1048
-
1049
- // Download attachments (if any). Each download is independent —
1050
- // a single failure must not poison the rest, so we use allSettled.
1051
- let attachments: AttachmentRef[] | undefined
1052
- if (hasAttachments) {
1053
- try {
1054
- const stored = sessionStore.get(chatId)
1055
- const sessionId = stored?.sessionId ?? chatId
1056
- const settled = await Promise.allSettled(
1057
- pendingDownloads.map((p) =>
1058
- media.downloadResource({
1059
- messageId: safeMessageId,
1060
- fileKey: p.fileKey,
1061
- kind: p.kind,
1062
- fileName: p.fileName,
1063
- sessionId,
1064
- }),
1065
- ),
1066
- )
1067
- const accepted: AttachmentRef[] = []
1068
- let downloadFailures = 0
1069
- for (const result of settled) {
1070
- if (result.status === 'rejected') {
1071
- downloadFailures += 1
1072
- console.error('[Feishu] downloadResource failed:', result.reason)
1073
- continue
1074
- }
1075
- const local = result.value
1076
- const check = checkAttachmentLimit(local.kind, local.size, local.mimeType)
1077
- if (!check.ok) {
1078
- await sendText(chatId, check.hint)
1079
- continue
1080
- }
1081
- if (local.kind === 'image') {
1082
- accepted.push({
1083
- type: 'image',
1084
- name: local.name,
1085
- data: local.buffer.toString('base64'),
1086
- mimeType: local.mimeType,
1087
- })
1088
- } else {
1089
- accepted.push({
1090
- type: 'file',
1091
- name: local.name,
1092
- path: local.path,
1093
- mimeType: local.mimeType,
1094
- })
1095
- }
1096
- }
1097
- if (downloadFailures > 0) {
1098
- await sendText(
1099
- chatId,
1100
- downloadFailures === pendingDownloads.length
1101
- ? '📎 附件下载失败,请稍后重试'
1102
- : `📎 ${downloadFailures} 个附件下载失败,已跳过`,
1103
- )
1104
- }
1105
- if (accepted.length > 0) attachments = accepted
1106
- } catch (err) {
1107
- console.error('[Feishu] Unexpected attachment pipeline error:', err)
1108
- await sendText(chatId, '📎 附件处理异常,请稍后重试')
1109
- return
1110
- }
1111
- }
1112
-
1113
- const effectiveText =
1114
- msgText || (attachments && attachments.length > 0 ? '(用户发送了附件)' : '')
1115
-
1116
- // If all attachments were rejected (limit / download fail) AND user had
1117
- // no text, silently abort — the rejection hints have already been sent
1118
- // via sendText, and Claude shouldn't be invoked with empty content.
1119
- if (!effectiveText && !(attachments && attachments.length > 0)) return
1120
-
1121
- // Pre-create the streaming card immediately so the user sees a
1122
- // "☁️ 正在思考中..." indicator while the backend is still thinking
1123
- // (before the first content_delta arrives). We intentionally do NOT
1124
- // create a card for /clear-style commands (which go through the
1125
- // earlier branches), so they won't leave an empty card behind.
1126
- const card = getOrCreateStreamingCard(chatId)
1127
- void card.ensureCreated().catch((err) => {
1128
- console.error('[Feishu] pre-create streaming card failed:', err)
1129
- })
1130
-
1131
- const sent = bridge.sendUserMessage(chatId, effectiveText, attachments)
1132
- if (!sent) {
1133
- await sendText(chatId, '⚠️ 消息发送失败,连接可能已断开。请发送 /new 重新开始。')
1134
- }
1135
- })
1136
- }
1137
-
1138
- async function handleCardAction(data: any): Promise<any> {
1139
- const event = data as {
1140
- operator?: { open_id?: string }
1141
- action?: {
1142
- value?: {
1143
- action?: string
1144
- requestId?: string
1145
- allowed?: boolean
1146
- rule?: string
1147
- realPath?: string
1148
- projectName?: string
1149
- }
1150
- }
1151
- context?: { open_chat_id?: string }
1152
- }
1153
-
1154
- const action = event.action?.value?.action
1155
- const chatId = event.context?.open_chat_id
1156
- if (!chatId) return
1157
-
1158
- if (action === 'permit') {
1159
- const requestId = event.action?.value?.requestId
1160
- const allowed = event.action?.value?.allowed ?? false
1161
- const rule = event.action?.value?.rule
1162
- if (!requestId) return
1163
-
1164
- bridge.sendPermissionResponse(chatId, requestId, allowed, rule)
1165
- const runtime = getRuntimeState(chatId)
1166
- runtime.pendingPermissionCount = Math.max(0, runtime.pendingPermissionCount - 1)
1167
-
1168
- const statusText = allowed
1169
- ? rule === 'always'
1170
- ? '♾️ 已永久允许(本次会话内不再询问相同操作)'
1171
- : '✅ 已允许'
1172
- : '❌ 已拒绝'
1173
- await sendText(chatId, statusText)
1174
- return { toast: { type: 'info', content: allowed ? (rule === 'always' ? '♾️ 永久允许' : '✅ 已允许') : '❌ 已拒绝' } }
1175
- }
1176
-
1177
- if (action === 'pick_project') {
1178
- const realPath = event.action?.value?.realPath
1179
- const projectName = event.action?.value?.projectName ?? realPath ?? '(unknown)'
1180
- if (!realPath) return
1181
-
1182
- pendingProjectSelection.delete(chatId)
1183
- // createSessionForChat handles its own error messaging on failure
1184
- const ok = await createSessionForChat(chatId, realPath)
1185
- if (ok) {
1186
- await sendText(chatId, `✅ 已新建会话:**${projectName}**`)
1187
- }
1188
- return { toast: { type: 'info', content: `📁 ${projectName}` } }
1189
- }
1190
- }
1191
-
1192
- // ---------- resolve bot identity ----------
1193
-
1194
- async function resolveBotOpenId(retries = 3): Promise<void> {
1195
- // Feishu has no "me" user_id literal — use /open-apis/bot/v3/info to fetch
1196
- // the bot's identity via tenant_access_token. Response shape:
1197
- // { code: 0, msg: 'ok', bot: { open_id: 'ou_xxx', ... } }
1198
- for (let i = 0; i < retries; i++) {
1199
- try {
1200
- const resp = await (larkClient as any).request({
1201
- method: 'GET',
1202
- url: '/open-apis/bot/v3/info',
1203
- })
1204
- const openId = resp?.bot?.open_id ?? resp?.data?.bot?.open_id ?? null
1205
- if (openId) {
1206
- botOpenId = openId
1207
- console.log(`[Feishu] Bot open_id: ${botOpenId}`)
1208
- return
1209
- }
1210
- } catch (err) {
1211
- if (i < retries - 1) {
1212
- console.warn(
1213
- `[Feishu] Could not resolve bot open_id, retrying (${i + 1}/${retries})...`,
1214
- err instanceof Error ? err.message : err,
1215
- )
1216
- await new Promise((r) => setTimeout(r, 2000 * (i + 1)))
1217
- }
1218
- }
1219
- }
1220
- console.warn('[Feishu] Could not resolve bot open_id (group @mention check may not work)')
1221
- }
1222
-
1223
- // ---------- start ----------
1224
-
1225
- async function start(): Promise<void> {
1226
- console.log('[Feishu] Starting bot...')
1227
- console.log(`[Feishu] Server: ${config.serverUrl}`)
1228
- console.log(`[Feishu] App ID: ${config.feishu.appId}`)
1229
-
1230
- await resolveBotOpenId()
1231
-
1232
- const dispatcher = new Lark.EventDispatcher({
1233
- encryptKey: config.feishu.encryptKey,
1234
- verificationToken: config.feishu.verificationToken,
1235
- })
1236
-
1237
- dispatcher.register({
1238
- 'im.message.receive_v1': async (data: any) => {
1239
- try {
1240
- await handleMessage(data)
1241
- } catch (err) {
1242
- console.error('[Feishu] Message handler error:', err)
1243
- }
1244
- },
1245
- 'card.action.trigger': async (data: any) => {
1246
- try {
1247
- return await handleCardAction(data)
1248
- } catch (err) {
1249
- console.error('[Feishu] Card action error:', err)
1250
- }
1251
- },
1252
- } as any)
1253
-
1254
- wsClient = new Lark.WSClient({
1255
- appId: config.feishu.appId,
1256
- appSecret: config.feishu.appSecret,
1257
- domain: Lark.Domain.Feishu,
1258
- loggerLevel: Lark.LoggerLevel.info,
1259
- })
1260
-
1261
- await wsClient.start({ eventDispatcher: dispatcher })
1262
- console.log('[Feishu] Bot is running! (WebSocket connected)')
1263
- }
1264
-
1265
- start().catch((err) => {
1266
- console.error('[Feishu] Failed to start:', err)
1267
- process.exit(1)
1268
- })
1269
-
1270
- process.on('SIGINT', () => {
1271
- console.log('[Feishu] Shutting down...')
1272
- bridge.destroy()
1273
- dedup.destroy()
1274
- process.exit(0)
1275
- })