bingocode 1.0.40 → 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 +23 -9
- 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
package/adapters/feishu/index.ts
DELETED
|
@@ -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 `` 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
|
-
// (``) 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
|
-
})
|