@mhfire/dsh-im-bridge 0.1.7 → 0.3.0

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/src/index.ts ADDED
@@ -0,0 +1,787 @@
1
+ /**
2
+ * dsh-im-bridge — WeCom AI bot ⇄ DSH Agent host plugin.
3
+ *
4
+ * Function-plugin shape (`name` / `inject` / `Config` / `apply`, no default
5
+ * export). Messages create in-process Agents so per-chat-window sessions stay on
6
+ * the same Loader tree as the Web GUI. Settings register through
7
+ * `installSettingsSection`; live fields read `source()`, credentials still
8
+ * require a process restart to open the WebSocket.
9
+ */
10
+
11
+ import { readFileSync } from 'node:fs'
12
+ import { dirname, join } from 'node:path'
13
+ import { fileURLToPath } from 'node:url'
14
+ import type { Context } from '@deepseek-ai/cordis'
15
+ import z from '@deepseek-ai/schemastery'
16
+ import { createUserMessage } from '@deepseek-ai/dsh-llm'
17
+ import { SessionId } from '@deepseek-ai/dsh-session'
18
+ import { installModelSelection } from '@deepseek-ai/dsh-agent'
19
+ import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
20
+ import {
21
+ collectReplyPngs,
22
+ resolveChatId,
23
+ sendCollectedPngs,
24
+ } from './reply-images.ts'
25
+ import {
26
+ isLegacyPinnedWecomTitle,
27
+ planWecomBind,
28
+ resolveWecomSession,
29
+ stripBotMention,
30
+ wecomDisplayTitle,
31
+ WecomSessionReject,
32
+ type WecomSessionRef,
33
+ } from './session-key.ts'
34
+ import {
35
+ DEFAULT_THINKING,
36
+ footerOf,
37
+ fmtDuration,
38
+ labelTool,
39
+ pickStatusLine,
40
+ sendFinal,
41
+ startThinking,
42
+ streamPhaseFromChunk,
43
+ truncate,
44
+ type ThinkingConfig,
45
+ } from './wecom.ts'
46
+
47
+ /** Package root (persona files live beside package.json). */
48
+ const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
49
+ /** Built-in Chinese persona. */
50
+ const DEFAULT_PERSONA_ZH = join(PACKAGE_ROOT, 'persona.default.md')
51
+ /** Built-in English persona. */
52
+ const DEFAULT_PERSONA_EN = join(PACKAGE_ROOT, 'persona.default.en.md')
53
+ /** Host locale settings namespace (`dsh-client-locale`). */
54
+ const LOCALE_SETTINGS_NS = settingsNamespace('locale')
55
+
56
+ /** Settings namespace paired with the browser card. */
57
+ export const IM_BRIDGE_NS = settingsNamespace('im-bridge')
58
+
59
+ /** Cordis diagnostic name. */
60
+ export const name = 'im-bridge'
61
+
62
+ /** Required host services. */
63
+ export const inject = ['agents', 'sessions', 'agentDefaultModel']
64
+
65
+ const ThinkingPhase = z.object({
66
+ atSec: z.number(),
67
+ text: z.string(),
68
+ })
69
+
70
+ const ThinkingSchema = z.object({
71
+ phases: z.array(ThinkingPhase).default(DEFAULT_THINKING.phases),
72
+ spin: z.array(String).default(DEFAULT_THINKING.spin),
73
+ eggs: z.array(String).default(DEFAULT_THINKING.eggs),
74
+ eggAfterSec: z.number().default(DEFAULT_THINKING.eggAfterSec),
75
+ intervalMs: z.number().default(DEFAULT_THINKING.intervalMs),
76
+ activityPrefix: z.string().default(DEFAULT_THINKING.activityPrefix),
77
+ toolLabels: z.dict(String).default(DEFAULT_THINKING.toolLabels),
78
+ reasoningStatus: z.array(String).default(DEFAULT_THINKING.reasoningStatus),
79
+ outputStatus: z.array(String).default(DEFAULT_THINKING.outputStatus),
80
+ reasoningSpin: z.array(String).default(DEFAULT_THINKING.reasoningSpin),
81
+ outputSpin: z.array(String).default(DEFAULT_THINKING.outputSpin),
82
+ })
83
+
84
+ /** Plugin config: secrets come from the profile patch or Settings. */
85
+ export interface Config {
86
+ botId: string
87
+ secret: string
88
+ workspace: string
89
+ allowFrom: string[]
90
+ startHint: string
91
+ agentTimeoutSec: number
92
+ agentPreset: string
93
+ provider: string
94
+ model: string
95
+ reasoningEffort: string
96
+ persona: string
97
+ personaFile: string
98
+ maxReplyBytes: number
99
+ thinking: ThinkingConfig
100
+ deniedMessage: string
101
+ welcomeMessage: string
102
+ }
103
+
104
+ /** Schemastery schema for the composition entry and settings namespace. */
105
+ export const Config: z<Config> = z.object({
106
+ botId: z.string().default('').role('secret'),
107
+ secret: z.string().default('').role('secret'),
108
+ workspace: z.string().default(process.cwd()),
109
+ allowFrom: z.array(String).default([]),
110
+ startHint: z.string().default('🧠 正在思考...'),
111
+ agentTimeoutSec: z.number().default(600),
112
+ agentPreset: z.string().default('standard'),
113
+ provider: z.string().default(''),
114
+ model: z.string().default(''),
115
+ reasoningEffort: z.string().default(''),
116
+ persona: z.string().default(''),
117
+ personaFile: z.string().default(''),
118
+ maxReplyBytes: z.number().default(20000),
119
+ thinking: ThinkingSchema.default(DEFAULT_THINKING),
120
+ deniedMessage: z.string().default('无权访问本服务'),
121
+ welcomeMessage: z.string().default('👋 办公助手已就绪。直接发消息即可,例如查文件、整理文档、查资料或处理日常事务。'),
122
+ })
123
+
124
+ interface LoggedEvent {
125
+ seq: number
126
+ type: string
127
+ data: Record<string, unknown>
128
+ }
129
+
130
+ interface TextBlock {
131
+ type: string
132
+ text?: string
133
+ }
134
+
135
+ interface AssistantMessageData {
136
+ message?: { content?: TextBlock[] }
137
+ }
138
+
139
+ interface ToolCallData {
140
+ name?: string
141
+ callId?: string
142
+ }
143
+
144
+ interface ToolResultData {
145
+ error?: unknown
146
+ message?: { source?: { callId?: string } }
147
+ }
148
+
149
+ interface ChunkData {
150
+ chunk?: { type?: string; blockType?: string }
151
+ }
152
+
153
+ interface LiveAgent {
154
+ whenIdle(): Promise<void>
155
+ followup(message: unknown): void
156
+ session: {
157
+ seq: number
158
+ events: readonly LoggedEvent[]
159
+ header?: { cwd?: string; agentPreset?: string }
160
+ }
161
+ }
162
+
163
+ interface ChatState {
164
+ agent?: LiveAgent
165
+ sessionId?: string
166
+ kind?: 'single' | 'group'
167
+ queue: Promise<unknown>
168
+ lastActivity: string
169
+ activityClearAt: number
170
+ lastToolByCallId: Map<string, string>
171
+ modelStreamPhase: 'idle' | 'reasoning' | 'outputting'
172
+ streamStatusTick: number
173
+ }
174
+
175
+ /** Placeholder Map value so later messages on the same key share one queue. */
176
+ function emptyChatState(): ChatState {
177
+ return {
178
+ queue: Promise.resolve(),
179
+ lastActivity: '',
180
+ activityClearAt: 0,
181
+ lastToolByCallId: new Map(),
182
+ modelStreamPhase: 'idle',
183
+ streamStatusTick: 0,
184
+ }
185
+ }
186
+
187
+ interface DefaultModel {
188
+ currentSelection(): { provider: string; model: string; reasoningEffort?: string }
189
+ }
190
+
191
+ interface AgentRegistry {
192
+ get(id: ReturnType<typeof SessionId>): LiveAgent | undefined
193
+ create(options: {
194
+ sessionId: ReturnType<typeof SessionId>
195
+ meta?: { cwd?: string; agentPreset?: string }
196
+ agentOptions?: { provider: string; model: string }
197
+ setup?: (agentCtx: Context) => void | Promise<void>
198
+ }): Promise<{ agent: LiveAgent }>
199
+ resume(options: {
200
+ resumeSessionId: ReturnType<typeof SessionId>
201
+ agentOptions?: { provider: string; model: string }
202
+ setup?: (agentCtx: Context) => void | Promise<void>
203
+ }): Promise<{ agent: LiveAgent }>
204
+ }
205
+
206
+ interface SessionPersistenceHeader {
207
+ id: string
208
+ cwd?: string
209
+ agentPreset?: string
210
+ }
211
+
212
+ interface SessionPersistence {
213
+ list(): Promise<SessionPersistenceHeader[]>
214
+ }
215
+
216
+ interface WorkspaceRegistry {
217
+ readonly archivedSessionIds: readonly string[]
218
+ }
219
+
220
+ interface SessionStore {
221
+ flush(session: LiveAgent['session']): Promise<void>
222
+ }
223
+
224
+ interface SessionTitleSnapshot {
225
+ title: string
226
+ source: { kind: string }
227
+ }
228
+
229
+ interface SessionTitleService {
230
+ get(session: LiveAgent['session']): SessionTitleSnapshot | undefined
231
+ refresh(session: LiveAgent['session']): Promise<unknown>
232
+ }
233
+
234
+ interface TitledSession {
235
+ id: string
236
+ events: readonly LoggedEvent[]
237
+ append(type: 'session/title', data: {
238
+ title: string
239
+ messageSeqs: number[]
240
+ source: unknown
241
+ }): void
242
+ }
243
+
244
+ interface SessionTitleEventData {
245
+ title?: string
246
+ messageSeqs?: number[]
247
+ source?: { kind?: string }
248
+ }
249
+
250
+ interface AgentPresets {
251
+ resolve(id: string): Promise<{ id: string }>
252
+ mount(agentCtx: Context, id: string): Promise<unknown>
253
+ }
254
+
255
+ interface SettingsReader {
256
+ get(ns: ReturnType<typeof settingsNamespace>): unknown
257
+ }
258
+
259
+ interface LoaderTree {
260
+ await(): Promise<void>
261
+ }
262
+
263
+ interface WecomFrame {
264
+ body?: {
265
+ text?: { content?: string }
266
+ sender?: { userid?: string }
267
+ from?: { userid?: string }
268
+ userid?: string
269
+ chatid?: string
270
+ chattype?: string | number
271
+ }
272
+ }
273
+
274
+ interface WecomClient {
275
+ replyStream(frame: unknown, streamId: string, content: string, finish: boolean): Promise<unknown>
276
+ replyWelcome(frame: unknown, payload: { msgtype: string; text: { content: string } }): Promise<unknown>
277
+ uploadMedia(
278
+ fileBuffer: Buffer,
279
+ options: { type: string; filename: string },
280
+ ): Promise<{ media_id?: string; mediaId?: string }>
281
+ sendMediaMessage(chatid: string, mediaType: string, mediaId: string): Promise<unknown>
282
+ connect(): void
283
+ close?(): void
284
+ on(event: string, handler: (...args: never[]) => void): void
285
+ }
286
+
287
+ /** Join assistant text from one turn starting at `firstSeq`. */
288
+ function summarize(events: readonly LoggedEvent[], firstSeq: number): { text: string; reason: unknown } {
289
+ let started = false
290
+ let text = ''
291
+ let reason: unknown
292
+ for (const event of events) {
293
+ if (event.seq < firstSeq) continue
294
+ if (event.type === 'turn/start') { started = true; continue }
295
+ if (!started) continue
296
+ if (event.type === 'assistant/message') {
297
+ const message = (event.data as AssistantMessageData).message
298
+ const joined = (message?.content ?? [])
299
+ .filter((block) => block.type === 'text')
300
+ .map((block) => block.text ?? '')
301
+ .join('')
302
+ if (joined !== '') text = joined
303
+ }
304
+ if (event.type === 'turn/end') reason = event.data.reason
305
+ }
306
+ return { text, reason }
307
+ }
308
+
309
+ /**
310
+ * Payload of the log's last `session/title` event — the title in force now.
311
+ * @param session - live session whose log to fold.
312
+ * @returns the payload, or undefined when the session has no title event.
313
+ */
314
+ function latestTitleData(session: TitledSession): SessionTitleEventData | undefined {
315
+ const events = session.events
316
+ for (let i = events.length - 1; i >= 0; i -= 1) {
317
+ const event = events[i]
318
+ if (event.type === 'session/title') return event.data as SessionTitleEventData
319
+ }
320
+ return undefined
321
+ }
322
+
323
+ /** Read Host `locale.preference`; missing or unknown falls back to `zh`. */
324
+ function readLocalePreference(settings: SettingsReader | undefined): 'zh' | 'en' {
325
+ if (settings === undefined) return 'zh'
326
+ try {
327
+ const section = settings.get(LOCALE_SETTINGS_NS)
328
+ const pref = section && typeof section === 'object' && 'preference' in section
329
+ ? (section as { preference?: unknown }).preference
330
+ : undefined
331
+ return pref === 'en' ? 'en' : 'zh'
332
+ } catch {
333
+ return 'zh'
334
+ }
335
+ }
336
+
337
+ /** Strip leading `#` comment lines from a built-in persona file. */
338
+ function stripLeadingHashComments(text: string): string {
339
+ const lines = text.split(/\r?\n/)
340
+ let i = 0
341
+ while (i < lines.length && /^\s*#/.test(lines[i] ?? '')) i++
342
+ while (i < lines.length && (lines[i] ?? '').trim() === '') i++
343
+ return lines.slice(i).join('\n')
344
+ }
345
+
346
+ /** Resolve persona: personaFile → persona string → built-in locale file. */
347
+ function resolvePersona(config: Config, settings: SettingsReader | undefined): string {
348
+ if (config.personaFile) {
349
+ try {
350
+ return readFileSync(config.personaFile, 'utf8')
351
+ } catch (error) {
352
+ const message = error instanceof Error ? error.message : String(error)
353
+ console.error(`[im-bridge] 读取 personaFile 失败: ${message}`)
354
+ }
355
+ }
356
+ if (config.persona !== '') return config.persona
357
+ const file = readLocalePreference(settings) === 'en' ? DEFAULT_PERSONA_EN : DEFAULT_PERSONA_ZH
358
+ try {
359
+ return stripLeadingHashComments(readFileSync(file, 'utf8'))
360
+ } catch (error) {
361
+ const message = error instanceof Error ? error.message : String(error)
362
+ console.error(`[im-bridge] 读取默认人设失败: ${message}`)
363
+ return ''
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Resolve the model for a new WeCom chat session. Both provider and model must be
369
+ * non-empty to override; otherwise fall back to agent-default-model.
370
+ */
371
+ function resolveSelection(
372
+ config: Config,
373
+ defaultModel: DefaultModel,
374
+ ): { provider: string; model: string; reasoningEffort?: string } {
375
+ const provider = config.provider.trim()
376
+ const model = config.model.trim()
377
+ if (provider !== '' && model !== '') {
378
+ const effort = config.reasoningEffort.trim()
379
+ return effort === '' ? { provider, model } : { provider, model, reasoningEffort: effort }
380
+ }
381
+ if (provider !== '' || model !== '') {
382
+ console.warn('[im-bridge] provider/model 需同时填写才覆盖企微模型, 已回退 agent-default-model。')
383
+ }
384
+ return defaultModel.currentSelection()
385
+ }
386
+
387
+ /**
388
+ * Mount the WeCom bridge: settings namespace, then a deferred WebSocket after Loader settle.
389
+ * @param ctx - host plugin context.
390
+ * @param config - composition entry used as the settings `base` layer.
391
+ */
392
+ export function apply(ctx: Context, config: Config): void {
393
+ const agents = ctx.get('agents') as AgentRegistry | undefined
394
+ const sessions = ctx.get('sessions') as SessionStore | undefined
395
+ const defaultModel = ctx.get('agentDefaultModel') as DefaultModel | undefined
396
+ if (agents === undefined || sessions === undefined || defaultModel === undefined) {
397
+ throw new Error('im-bridge: 需要 agents/sessions/agentDefaultModel 服务')
398
+ }
399
+
400
+ let source = (): Config => config
401
+ let settings: SettingsReader | undefined
402
+ installSettingsSection(ctx, IM_BRIDGE_NS, Config, config, {
403
+ setSource: (current) => { source = current },
404
+ onChange: () => {
405
+ // Live fields are read through source() on the next handle/ensureAgent.
406
+ // botId/secret still require a process restart to open the WebSocket.
407
+ },
408
+ })
409
+ ctx.inject(['settings'], (settingsCtx) => {
410
+ settings = settingsCtx.settings as SettingsReader
411
+ settingsCtx.effect(() => () => { settings = undefined }, 'im-bridge: settings reader')
412
+ })
413
+ const cfg = (): Config => source()
414
+
415
+ void (async () => {
416
+ const loader = ctx.get('loader') as LoaderTree | undefined
417
+ await loader?.await()
418
+ const { botId, secret } = cfg()
419
+ if (!botId || !secret) {
420
+ console.warn(
421
+ '[im-bridge] 跳过启动: 缺少 botId/secret。请在 profile cordis.patch.yml 或 Settings → 插件配置中填写后重启。',
422
+ )
423
+ return
424
+ }
425
+
426
+ const chats = new Map<string, ChatState>()
427
+
428
+ /**
429
+ * Add the channel prefix to a title the Host generated. `session/event`
430
+ * runs inside the append publication window, which refuses a reentrant
431
+ * append, so the prefixed title goes out in a microtask and re-reads the
432
+ * log first: an already prefixed tail (including the one this appends)
433
+ * stops the chain.
434
+ */
435
+ function prefixWecomTitle(session: TitledSession, st: ChatState): void {
436
+ const kind = st.kind
437
+ if (kind === undefined) return
438
+ queueMicrotask(() => {
439
+ const data = latestTitleData(session)
440
+ if (data === undefined) return
441
+ // An explicit GUI rename is pinned on purpose; only automatic titles get labelled.
442
+ if (data.source?.kind === 'user') return
443
+ const raw = typeof data.title === 'string' ? data.title : ''
444
+ const next = wecomDisplayTitle(kind, raw)
445
+ if (next === raw) return
446
+ const messageSeqs = Array.isArray(data.messageSeqs)
447
+ ? data.messageSeqs.filter((seq) => typeof seq === 'number')
448
+ : []
449
+ // A non-user title must cite at least one user/message seq, or the
450
+ // session-title invariant rejects the append.
451
+ if (messageSeqs.length === 0) return
452
+ try {
453
+ session.append('session/title', {
454
+ title: next,
455
+ messageSeqs,
456
+ source: data.source ?? { kind: 'fallback' },
457
+ })
458
+ } catch (error) {
459
+ const message = error instanceof Error ? error.message : String(error)
460
+ console.error(`[im-bridge] 加标题前缀失败: ${message}`)
461
+ }
462
+ })
463
+ }
464
+
465
+ /**
466
+ * Sessions the GUI archived. Archiving is the workspace registry's global
467
+ * set, not session state, and it has no inverse: an archived session is
468
+ * invisible in every list, so this plugin must stop writing to it.
469
+ */
470
+ function archivedSessions(): ReadonlySet<string> {
471
+ const registry = ctx.get('workspaceRegistry') as WorkspaceRegistry | undefined
472
+ if (registry === undefined) return new Set()
473
+ try {
474
+ return new Set(registry.archivedSessionIds)
475
+ } catch (error) {
476
+ // The getter throws until the registry finishes its own startup.
477
+ const message = error instanceof Error ? error.message : String(error)
478
+ console.warn(`[im-bridge] 读归档会话失败: ${message}`)
479
+ return new Set()
480
+ }
481
+ }
482
+
483
+ async function unpinLegacyWecomTitle(agent: LiveAgent): Promise<void> {
484
+ const titles = ctx.get('sessionTitle') as SessionTitleService | undefined
485
+ if (titles === undefined) return
486
+ try {
487
+ const snapshot = titles.get(agent.session)
488
+ if (snapshot?.source?.kind !== 'user') return
489
+ if (!isLegacyPinnedWecomTitle(snapshot.title)) return
490
+ await titles.refresh(agent.session)
491
+ } catch (error) {
492
+ const message = error instanceof Error ? error.message : String(error)
493
+ console.error(`[im-bridge] 解开旧标题失败: ${message}`)
494
+ }
495
+ }
496
+
497
+ async function ensureAgent(ref: WecomSessionRef): Promise<ChatState> {
498
+ let st = chats.get(ref.key)
499
+ if (st === undefined) {
500
+ st = emptyChatState()
501
+ chats.set(ref.key, st)
502
+ }
503
+ st.kind = ref.kind
504
+ if (st.agent !== undefined) {
505
+ if (st.sessionId === undefined || !archivedSessions().has(st.sessionId)) return st
506
+ console.log(`[im-bridge] 会话 ${st.sessionId} 已归档,改开新会话`)
507
+ st.agent = undefined
508
+ st.sessionId = undefined
509
+ }
510
+
511
+ const persistence = ctx.get('sessionPersistence') as SessionPersistence | undefined
512
+ const headers = persistence === undefined ? [] : await persistence.list()
513
+ const plan = planWecomBind(ref.key, {
514
+ live: (id) => agents.get(SessionId(id)) !== undefined,
515
+ stored: new Set(headers.map((header) => header.id)),
516
+ archived: archivedSessions(),
517
+ })
518
+ const sessionId = SessionId(plan.sessionId)
519
+ const stored = headers.find((header) => header.id === sessionId)
520
+
521
+ const attach = (agent: LiveAgent, how: 'adopt' | 'resume' | 'create'): void => {
522
+ st.agent = agent
523
+ st.sessionId = sessionId
524
+ st.kind = ref.kind
525
+ void unpinLegacyWecomTitle(agent)
526
+ const cwd = agent.session.header?.cwd ?? stored?.cwd
527
+ if (cwd !== undefined && cwd !== cfg().workspace) {
528
+ console.warn(
529
+ `[im-bridge] 会话 ${sessionId} 仍使用存档目录 ${cwd},当前 workspace=${cfg().workspace}`,
530
+ )
531
+ }
532
+ const epoch = plan.epoch > 1 ? ` 第${String(plan.epoch)}段` : ''
533
+ console.log(
534
+ `[im-bridge] 为 ${ref.key} ${how}会话 ${sessionId}${epoch} userid=${ref.sender} chattype=${ref.kind} chatid=${ref.chatid ?? ''}`,
535
+ )
536
+ }
537
+
538
+ const live = agents.get(sessionId)
539
+ if (plan.bind === 'adopt' && live !== undefined) {
540
+ attach(live, 'adopt')
541
+ return st
542
+ }
543
+
544
+ const selection = resolveSelection(cfg(), defaultModel)
545
+ const presets = ctx.get('agentPresets') as AgentPresets | undefined
546
+ const presetId = (plan.bind === 'resume' && stored?.agentPreset) ? stored.agentPreset : cfg().agentPreset
547
+ let resolvedId = presetId
548
+ if (presets !== undefined) {
549
+ resolvedId = (await presets.resolve(presetId)).id
550
+ }
551
+ const setup = async (agentCtx: Context): Promise<void> => {
552
+ const selected = { current: selection, assembled: undefined }
553
+ installModelSelection(agentCtx, selected)
554
+ if (presets !== undefined) await presets.mount(agentCtx, resolvedId)
555
+ agentCtx.inject(['systemPrompt'], (promptCtx) => {
556
+ promptCtx.systemPrompt.section({
557
+ name: 'deployment:persona',
558
+ order: 0,
559
+ text: () => resolvePersona(cfg(), settings),
560
+ })
561
+ })
562
+ }
563
+ const agentOptions = { provider: selection.provider, model: selection.model }
564
+
565
+ try {
566
+ if (plan.bind === 'resume') {
567
+ const { agent } = await agents.resume({
568
+ resumeSessionId: sessionId,
569
+ agentOptions,
570
+ setup,
571
+ })
572
+ attach(agent, 'resume')
573
+ return st
574
+ }
575
+ const { agent } = await agents.create({
576
+ sessionId,
577
+ meta: { cwd: cfg().workspace, agentPreset: resolvedId },
578
+ agentOptions,
579
+ setup,
580
+ })
581
+ attach(agent, 'create')
582
+ return st
583
+ } catch (error) {
584
+ const raced = agents.get(sessionId)
585
+ if (raced !== undefined) {
586
+ attach(raced, 'adopt')
587
+ return st
588
+ }
589
+ throw error
590
+ }
591
+ }
592
+
593
+ ctx.on('session/event', (session: TitledSession, event: LoggedEvent) => {
594
+ const thinking = cfg().thinking
595
+ const prefix = thinking?.activityPrefix ?? DEFAULT_THINKING.activityPrefix
596
+ const flashMs = Number.isFinite(thinking?.intervalMs) && thinking.intervalMs > 0
597
+ ? thinking.intervalMs
598
+ : DEFAULT_THINKING.intervalMs
599
+ for (const st of chats.values()) {
600
+ if (st.sessionId !== session.id) continue
601
+ if (event.type === 'session/title') {
602
+ prefixWecomTitle(session, st)
603
+ continue
604
+ }
605
+ if (event.type === 'assistant/chunk') {
606
+ const next = streamPhaseFromChunk((event.data as ChunkData).chunk)
607
+ if (next !== null) st.modelStreamPhase = next
608
+ continue
609
+ }
610
+ if (event.type === 'tool/call') {
611
+ const toolName = (event.data as ToolCallData).name ?? ''
612
+ const callId = (event.data as ToolCallData).callId
613
+ if (callId !== undefined) st.lastToolByCallId.set(callId, toolName)
614
+ st.activityClearAt = 0
615
+ st.lastActivity = `${prefix}${labelTool(toolName, thinking)}`
616
+ return
617
+ }
618
+ if (event.type === 'tool/result') {
619
+ const data = event.data as ToolResultData
620
+ const callId = data.message?.source?.callId
621
+ const rawName = (callId !== undefined && st.lastToolByCallId.get(callId))
622
+ || [...st.lastToolByCallId.values()].at(-1)
623
+ || ''
624
+ if (callId !== undefined) st.lastToolByCallId.delete(callId)
625
+ const label = labelTool(rawName || '工具', thinking)
626
+ const failed = data.error !== undefined
627
+ st.lastActivity = failed ? `❌ ${label} 失败` : `✅ ${label} 完成`
628
+ st.activityClearAt = Date.now() + flashMs
629
+ st.modelStreamPhase = 'idle'
630
+ }
631
+ }
632
+ })
633
+
634
+ const { default: AiBot, generateReqId } = await import('@wecom/aibot-node-sdk') as {
635
+ default: { WSClient: new (options: { botId: string; secret: string }) => WecomClient }
636
+ generateReqId: (kind: string) => string
637
+ }
638
+
639
+ async function handle(frame: WecomFrame, ref: WecomSessionRef, content: string): Promise<void> {
640
+ const st = await ensureAgent(ref)
641
+ const startedAt = Date.now()
642
+ const streamId = generateReqId('stream')
643
+ let stopThinking: (() => void) | null = null
644
+ st.lastActivity = ''
645
+ st.activityClearAt = 0
646
+ st.lastToolByCallId.clear()
647
+ st.modelStreamPhase = 'idle'
648
+ st.streamStatusTick = 0
649
+ try {
650
+ await ws.replyStream(frame, streamId, cfg().startHint, false)
651
+ stopThinking = startThinking(
652
+ ws, frame, streamId, startedAt, cfg().agentTimeoutSec,
653
+ () => {
654
+ if (st.activityClearAt > 0 && Date.now() >= st.activityClearAt) {
655
+ st.lastActivity = ''
656
+ st.activityClearAt = 0
657
+ }
658
+ if (st.lastActivity) return st.lastActivity
659
+ const thinking = cfg().thinking
660
+ const tick = st.streamStatusTick++
661
+ if (st.modelStreamPhase === 'reasoning') {
662
+ return pickStatusLine(
663
+ thinking?.reasoningStatus,
664
+ DEFAULT_THINKING.reasoningStatus,
665
+ tick,
666
+ )
667
+ }
668
+ if (st.modelStreamPhase === 'outputting') {
669
+ return pickStatusLine(
670
+ thinking?.outputStatus,
671
+ DEFAULT_THINKING.outputStatus,
672
+ tick,
673
+ )
674
+ }
675
+ return ''
676
+ },
677
+ cfg().thinking,
678
+ () => (st.lastActivity ? 'idle' : st.modelStreamPhase),
679
+ )
680
+ } catch (error) {
681
+ const message = error instanceof Error ? error.message : String(error)
682
+ console.error(`[im-bridge] 占位回复失败: ${message}`)
683
+ }
684
+ try {
685
+ if (st.agent === undefined) throw new Error('im-bridge: chat agent missing')
686
+ await st.agent.whenIdle()
687
+ const firstSeq = st.agent.session.seq
688
+ st.agent.followup(createUserMessage({
689
+ content: [{ type: 'text', text: content }],
690
+ source: { kind: 'user' },
691
+ }))
692
+ await st.agent.whenIdle()
693
+ await sessions.flush(st.agent.session)
694
+ const outcome = summarize(st.agent.session.events, firstSeq)
695
+ if (stopThinking) stopThinking()
696
+ const ms = Date.now() - startedAt
697
+ const body = outcome.text || '(agent 无输出)'
698
+ const collected = collectReplyPngs(body, cfg().workspace)
699
+ for (const reason of collected.skipped) {
700
+ console.warn(`[im-bridge] 跳过图片: ${reason}`)
701
+ }
702
+ let reply = truncate(body, (cfg().maxReplyBytes || 20000) - 200) + footerOf(ms)
703
+ if (collected.skipped.length > 0) {
704
+ reply = truncate(
705
+ `${reply}\n⚠️ ${collected.skipped.length} 张图片未发送(过大、越权或不存在)`,
706
+ cfg().maxReplyBytes || 20000,
707
+ )
708
+ }
709
+ console.log(`[im-bridge] ${ref.key} 完成 (${Buffer.byteLength(reply, 'utf8')}B, ${fmtDuration(ms)})`)
710
+ await sendFinal(ws, frame, streamId, reply)
711
+ if (collected.images.length > 0) {
712
+ await sendCollectedPngs(ws, resolveChatId(frame, ref.sender), collected.images)
713
+ }
714
+ } catch (error) {
715
+ if (stopThinking) stopThinking()
716
+ const ms = Date.now() - startedAt
717
+ const message = error instanceof Error ? error.message : String(error)
718
+ console.error(`[im-bridge] agent 失败: ${message}`)
719
+ try {
720
+ await sendFinal(ws, frame, streamId, `处理失败: ${truncate(message, 400)}\n\n---\n❌ 耗时 ${fmtDuration(ms)}`)
721
+ } catch (retryError) {
722
+ const retryMessage = retryError instanceof Error ? retryError.message : String(retryError)
723
+ console.error(`[im-bridge] 错误回复也失败: ${retryMessage}`)
724
+ }
725
+ }
726
+ }
727
+
728
+ const ws = new AiBot.WSClient({ botId, secret })
729
+
730
+ ws.on('connected', (() => console.log('[im-bridge] WebSocket 已连接')) as (...args: never[]) => void)
731
+ ws.on('authenticated', (() => console.log('[im-bridge] 认证成功, 等待消息...')) as (...args: never[]) => void)
732
+ ws.on('disconnected', ((reason: string) => console.log(`[im-bridge] 断开: ${reason}`)) as (...args: never[]) => void)
733
+ ws.on('reconnecting', ((n: number) => console.log(`[im-bridge] 第 ${n} 次重连...`)) as (...args: never[]) => void)
734
+ ws.on('error', ((error: Error) => console.error(`[im-bridge] 错误: ${error.message}`)) as (...args: never[]) => void)
735
+
736
+ ws.on('message.text', ((frame: WecomFrame) => {
737
+ const inbound = (frame.body?.text?.content || '').trim()
738
+ if (!inbound) return
739
+ const content = stripBotMention(inbound)
740
+ let ref: WecomSessionRef
741
+ try {
742
+ ref = resolveWecomSession(frame)
743
+ } catch (error) {
744
+ if (error instanceof WecomSessionReject) {
745
+ console.error(`[im-bridge] ${error.reply}`)
746
+ void ws.replyStream(frame, generateReqId('stream'), error.reply, true).catch(() => {})
747
+ return
748
+ }
749
+ throw error
750
+ }
751
+ if (cfg().allowFrom.length > 0 && !cfg().allowFrom.includes(ref.sender)) {
752
+ void ws.replyStream(frame, generateReqId('stream'), cfg().deniedMessage, true).catch(() => {})
753
+ return
754
+ }
755
+ console.log(
756
+ `[im-bridge] 收到 key=${ref.key} userid=${ref.sender} chattype=${String(frame.body?.chattype ?? '')} chatid=${ref.chatid ?? ''}: ${content.slice(0, 100)}`,
757
+ )
758
+ const st = chats.get(ref.key) ?? emptyChatState()
759
+ st.kind = ref.kind
760
+ chats.set(ref.key, st)
761
+ st.queue = st.queue
762
+ .then(() => handle(frame, ref, content))
763
+ .catch((error: unknown) => {
764
+ const message = error instanceof Error ? error.message : String(error)
765
+ console.error(`[im-bridge] 任务异常: ${message}`)
766
+ })
767
+ }) as (...args: never[]) => void)
768
+
769
+ ws.on('event.enter_chat', ((frame: WecomFrame) => {
770
+ const sender = frame.body?.from?.userid || 'unknown'
771
+ console.log(`[im-bridge] 用户 ${sender} 进入会话`)
772
+ void ws.replyWelcome(frame, {
773
+ msgtype: 'text',
774
+ text: { content: cfg().welcomeMessage },
775
+ }).catch((error: unknown) => {
776
+ const message = error instanceof Error ? error.message : String(error)
777
+ console.error(`[im-bridge] 欢迎语失败: ${message}`)
778
+ })
779
+ }) as (...args: never[]) => void)
780
+
781
+ ws.connect()
782
+
783
+ ctx.on('dispose', () => {
784
+ try { ws.close?.() } catch { /* already closed */ }
785
+ })
786
+ })()
787
+ }