@mhfire/dsh-im-bridge 0.2.0 → 0.4.2

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 CHANGED
@@ -2,13 +2,12 @@
2
2
  * dsh-im-bridge — WeCom AI bot ⇄ DSH Agent host plugin.
3
3
  *
4
4
  * Function-plugin shape (`name` / `inject` / `Config` / `apply`, no default
5
- * export). Messages create in-process Agents so per-sender sessions stay on
5
+ * export). Messages create in-process Agents so per-chat-window sessions stay on
6
6
  * the same Loader tree as the Web GUI. Settings register through
7
7
  * `installSettingsSection`; live fields read `source()`, credentials still
8
8
  * require a process restart to open the WebSocket.
9
9
  */
10
10
 
11
- import { randomUUID } from 'node:crypto'
12
11
  import { readFileSync } from 'node:fs'
13
12
  import { dirname, join } from 'node:path'
14
13
  import { fileURLToPath } from 'node:url'
@@ -18,6 +17,50 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm'
18
17
  import { SessionId } from '@deepseek-ai/dsh-session'
19
18
  import { installModelSelection } from '@deepseek-ai/dsh-agent'
20
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
+ ALLOW_FROM_REQUIRED_MESSAGE,
36
+ IM_BRIDGE_RPC_CHANNEL,
37
+ INSTALL_SKILLS_ENDPOINT,
38
+ SKILLS_RPC_UNAVAILABLE_MESSAGE,
39
+ WECOM_CLI_NO_OFFICE_PROMPT,
40
+ WECOM_CLI_PROMPT,
41
+ WECOM_CLI_TOOL_NAME,
42
+ WORKSPACE_WECOMCLI_LEAK_MESSAGE,
43
+ authInitHint,
44
+ countWecomcliSkills,
45
+ countWorkspaceWecomcliLeaks,
46
+ ensureConfigDir,
47
+ ensureOnPath,
48
+ installOfficialWecomSkills,
49
+ loadWecomSkills,
50
+ probeAuth,
51
+ registerWecomCliTool,
52
+ registerWecomOfficeSkills,
53
+ resolveConfigDir,
54
+ resolveSkillsDir,
55
+ resolveWecomBin,
56
+ senderHasOfficeAccess,
57
+ shouldEnableWecomCli,
58
+ shouldInjectWecomOfficeSkills,
59
+ skillsInstallHint,
60
+ trySeedAuth,
61
+ type InstallWecomSkillsResult,
62
+ type WecomSkill,
63
+ } from './wecom-cli.ts'
21
64
  import {
22
65
  DEFAULT_THINKING,
23
66
  footerOf,
@@ -68,6 +111,25 @@ const ThinkingSchema = z.object({
68
111
  outputSpin: z.array(String).default(DEFAULT_THINKING.outputSpin),
69
112
  })
70
113
 
114
+ const WecomCliSchema = z.object({
115
+ enabled: z.boolean().default(false),
116
+ skillsDir: z.string().default(''),
117
+ configDir: z.string().default(''),
118
+ allowFrom: z.array(String).default([]),
119
+ })
120
+
121
+ /** Optional wecom-cli office skills (mail, calendar, docs, …). */
122
+ export interface WecomCliConfig {
123
+ /** Explicit opt-in; off by default because it shares the authorized identity. */
124
+ enabled: boolean
125
+ /** Skills root; empty = `$DSH_HOME/wecom-cli-skills`. */
126
+ skillsDir: string
127
+ /** Credential directory; empty = `<workspace>/.dsh/wecom-cli`. */
128
+ configDir: string
129
+ /** Office-command userid list; empty skips PATH / auth. Independent of chat `allowFrom`. */
130
+ allowFrom: string[]
131
+ }
132
+
71
133
  /** Plugin config: secrets come from the profile patch or Settings. */
72
134
  export interface Config {
73
135
  botId: string
@@ -86,6 +148,7 @@ export interface Config {
86
148
  thinking: ThinkingConfig
87
149
  deniedMessage: string
88
150
  welcomeMessage: string
151
+ wecomCli: WecomCliConfig
89
152
  }
90
153
 
91
154
  /** Schemastery schema for the composition entry and settings namespace. */
@@ -106,6 +169,7 @@ export const Config: z<Config> = z.object({
106
169
  thinking: ThinkingSchema.default(DEFAULT_THINKING),
107
170
  deniedMessage: z.string().default('无权访问本服务'),
108
171
  welcomeMessage: z.string().default('👋 办公助手已就绪。直接发消息即可,例如查文件、整理文档、查资料或处理日常事务。'),
172
+ wecomCli: WecomCliSchema.default({ enabled: false, skillsDir: '', configDir: '', allowFrom: [] }),
109
173
  })
110
174
 
111
175
  interface LoggedEvent {
@@ -140,12 +204,26 @@ interface ChunkData {
140
204
  interface LiveAgent {
141
205
  whenIdle(): Promise<void>
142
206
  followup(message: unknown): void
143
- session: { seq: number; events: readonly LoggedEvent[] }
207
+ ctx: Context
208
+ session: {
209
+ seq: number
210
+ events: readonly LoggedEvent[]
211
+ header?: { cwd?: string; agentPreset?: string }
212
+ }
144
213
  }
145
214
 
146
- interface SenderState {
215
+ interface ChatState {
147
216
  agent?: LiveAgent
148
217
  sessionId?: string
218
+ kind?: 'single' | 'group'
219
+ /** True when the current inbound sender is on `wecomCli.allowFrom`. */
220
+ office: boolean
221
+ /** Prompt sections already registered on this Agent. */
222
+ wecomPromptInstalled: boolean
223
+ /** wecomcli-* already registered on this Agent. */
224
+ officeSkillsRegistered: boolean
225
+ /** The gated `wecom_cli` tool already registered on this Agent. */
226
+ officeToolRegistered: boolean
149
227
  queue: Promise<unknown>
150
228
  lastActivity: string
151
229
  activityClearAt: number
@@ -154,28 +232,114 @@ interface SenderState {
154
232
  streamStatusTick: number
155
233
  }
156
234
 
235
+ /** Placeholder Map value so later messages on the same key share one queue. */
236
+ function emptyChatState(): ChatState {
237
+ return {
238
+ office: false,
239
+ wecomPromptInstalled: false,
240
+ officeSkillsRegistered: false,
241
+ officeToolRegistered: false,
242
+ queue: Promise.resolve(),
243
+ lastActivity: '',
244
+ activityClearAt: 0,
245
+ lastToolByCallId: new Map(),
246
+ modelStreamPhase: 'idle',
247
+ streamStatusTick: 0,
248
+ }
249
+ }
250
+
251
+ /** Duck-typed Connection RPC result (no apiproxy import). */
252
+ type SkillsRpcResult =
253
+ | { ok: true; value: InstallWecomSkillsResult }
254
+ | { ok: false; error: { code: 'internal' | 'cancelled'; message: string; details: Record<string, never> } }
255
+
256
+ /** Optional Host Connection used by the Settings install button. */
257
+ interface HostConnectionRpc {
258
+ handle(
259
+ channel: string,
260
+ handler: (endpoint: string, payload: unknown, signal: AbortSignal) => Promise<SkillsRpcResult>,
261
+ options: { authority: 'loopback' },
262
+ ): () => Promise<void>
263
+ }
264
+
157
265
  interface DefaultModel {
158
266
  currentSelection(): { provider: string; model: string; reasoningEffort?: string }
159
267
  }
160
268
 
161
269
  interface AgentRegistry {
270
+ get(id: ReturnType<typeof SessionId>): LiveAgent | undefined
162
271
  create(options: {
163
272
  sessionId: ReturnType<typeof SessionId>
164
273
  meta?: { cwd?: string; agentPreset?: string }
165
274
  agentOptions?: { provider: string; model: string }
166
275
  setup?: (agentCtx: Context) => void | Promise<void>
167
276
  }): Promise<{ agent: LiveAgent }>
277
+ resume(options: {
278
+ resumeSessionId: ReturnType<typeof SessionId>
279
+ agentOptions?: { provider: string; model: string }
280
+ setup?: (agentCtx: Context) => void | Promise<void>
281
+ }): Promise<{ agent: LiveAgent }>
282
+ }
283
+
284
+ interface SessionPersistenceHeader {
285
+ id: string
286
+ cwd?: string
287
+ agentPreset?: string
288
+ }
289
+
290
+ interface SessionPersistence {
291
+ list(): Promise<SessionPersistenceHeader[]>
292
+ }
293
+
294
+ interface WorkspaceRegistry {
295
+ readonly archivedSessionIds: readonly string[]
168
296
  }
169
297
 
170
298
  interface SessionStore {
171
299
  flush(session: LiveAgent['session']): Promise<void>
172
300
  }
173
301
 
302
+ interface SessionTitleSnapshot {
303
+ title: string
304
+ source: { kind: string }
305
+ }
306
+
307
+ interface SessionTitleService {
308
+ get(session: LiveAgent['session']): SessionTitleSnapshot | undefined
309
+ refresh(session: LiveAgent['session']): Promise<unknown>
310
+ }
311
+
312
+ interface TitledSession {
313
+ id: string
314
+ events: readonly LoggedEvent[]
315
+ append(type: 'session/title', data: {
316
+ title: string
317
+ messageSeqs: number[]
318
+ source: unknown
319
+ }): void
320
+ }
321
+
322
+ interface SessionTitleEventData {
323
+ title?: string
324
+ messageSeqs?: number[]
325
+ source?: { kind?: string }
326
+ }
327
+
174
328
  interface AgentPresets {
175
329
  resolve(id: string): Promise<{ id: string }>
176
330
  mount(agentCtx: Context, id: string): Promise<unknown>
177
331
  }
178
332
 
333
+ interface SystemPromptService {
334
+ section(entry: { name: string; order: number; text: () => string }): unknown
335
+ }
336
+
337
+ /** Caller-bound prompt registry on an Agent context (not a raw `get()` result). */
338
+ interface PromptHost {
339
+ get(name: string): unknown
340
+ systemPrompt?: SystemPromptService
341
+ }
342
+
179
343
  interface SettingsReader {
180
344
  get(ns: ReturnType<typeof settingsNamespace>): unknown
181
345
  }
@@ -190,12 +354,19 @@ interface WecomFrame {
190
354
  sender?: { userid?: string }
191
355
  from?: { userid?: string }
192
356
  userid?: string
357
+ chatid?: string
358
+ chattype?: string | number
193
359
  }
194
360
  }
195
361
 
196
362
  interface WecomClient {
197
363
  replyStream(frame: unknown, streamId: string, content: string, finish: boolean): Promise<unknown>
198
364
  replyWelcome(frame: unknown, payload: { msgtype: string; text: { content: string } }): Promise<unknown>
365
+ uploadMedia(
366
+ fileBuffer: Buffer,
367
+ options: { type: string; filename: string },
368
+ ): Promise<{ media_id?: string; mediaId?: string }>
369
+ sendMediaMessage(chatid: string, mediaType: string, mediaId: string): Promise<unknown>
199
370
  connect(): void
200
371
  close?(): void
201
372
  on(event: string, handler: (...args: never[]) => void): void
@@ -223,6 +394,20 @@ function summarize(events: readonly LoggedEvent[], firstSeq: number): { text: st
223
394
  return { text, reason }
224
395
  }
225
396
 
397
+ /**
398
+ * Payload of the log's last `session/title` event — the title in force now.
399
+ * @param session - live session whose log to fold.
400
+ * @returns the payload, or undefined when the session has no title event.
401
+ */
402
+ function latestTitleData(session: TitledSession): SessionTitleEventData | undefined {
403
+ const events = session.events
404
+ for (let i = events.length - 1; i >= 0; i -= 1) {
405
+ const event = events[i]
406
+ if (event.type === 'session/title') return event.data as SessionTitleEventData
407
+ }
408
+ return undefined
409
+ }
410
+
226
411
  /** Read Host `locale.preference`; missing or unknown falls back to `zh`. */
227
412
  function readLocalePreference(settings: SettingsReader | undefined): 'zh' | 'en' {
228
413
  if (settings === undefined) return 'zh'
@@ -268,7 +453,7 @@ function resolvePersona(config: Config, settings: SettingsReader | undefined): s
268
453
  }
269
454
 
270
455
  /**
271
- * Resolve the model for a new sender session. Both provider and model must be
456
+ * Resolve the model for a new WeCom chat session. Both provider and model must be
272
457
  * non-empty to override; otherwise fall back to agent-default-model.
273
458
  */
274
459
  function resolveSelection(
@@ -315,6 +500,79 @@ export function apply(ctx: Context, config: Config): void {
315
500
  })
316
501
  const cfg = (): Config => source()
317
502
 
503
+ const chats = new Map<string, ChatState>()
504
+ let wecomCliReady = false
505
+ let officeSkills: WecomSkill[] = []
506
+ /** Launcher and credential directory the gated tool spawns with; set once wecom-cli is usable. */
507
+ let officeCli: { binJs: string; configDir: string } | undefined
508
+
509
+ /**
510
+ * Install the office layer on one Agent: wecomcli-* skills and the gated
511
+ * `wecom_cli` tool. Both register through the Agent's own context, so a group
512
+ * chat or the GUI never sees them. Idempotent per Agent, and retried on each
513
+ * inbound message because skills can arrive from the Settings button later.
514
+ */
515
+ function installOfficeLayer(agentCtx: Context, st: ChatState): void {
516
+ if (!wecomCliReady) return
517
+ if (!shouldInjectWecomOfficeSkills(st.kind, st.office)) return
518
+ if (!st.officeSkillsRegistered) {
519
+ const count = registerWecomOfficeSkills(agentCtx, officeSkills)
520
+ if (count > 0) {
521
+ st.officeSkillsRegistered = true
522
+ console.log(`[im-bridge] 已在该 Agent 注册 ${String(count)} 个 wecomcli-*`)
523
+ }
524
+ }
525
+ if (st.officeToolRegistered || officeCli === undefined) return
526
+ try {
527
+ st.officeToolRegistered = registerWecomCliTool(agentCtx, officeCli.binJs, officeCli.configDir)
528
+ if (st.officeToolRegistered) {
529
+ console.log(`[im-bridge] 已在该 Agent 注册 ${WECOM_CLI_TOOL_NAME} 工具`)
530
+ }
531
+ } catch (error) {
532
+ const message = error instanceof Error ? error.message : String(error)
533
+ console.error(`[im-bridge] 注册 ${WECOM_CLI_TOOL_NAME} 失败: ${message}`)
534
+ }
535
+ }
536
+
537
+ ctx.inject(['connection'], (bound) => {
538
+ const rpc = (bound.get('connection') as { rpc?: HostConnectionRpc } | undefined)?.rpc
539
+ if (rpc === undefined) {
540
+ console.warn(`[im-bridge] ${SKILLS_RPC_UNAVAILABLE_MESSAGE}`)
541
+ return
542
+ }
543
+ bound.effect(
544
+ () => rpc.handle(IM_BRIDGE_RPC_CHANNEL, async (endpoint, _payload, signal) => {
545
+ if (endpoint !== INSTALL_SKILLS_ENDPOINT) {
546
+ return {
547
+ ok: false,
548
+ error: { code: 'internal', message: `unknown endpoint ${endpoint}`, details: {} },
549
+ }
550
+ }
551
+ if (signal.aborted) {
552
+ return {
553
+ ok: false,
554
+ error: { code: 'cancelled', message: '安装已取消', details: {} },
555
+ }
556
+ }
557
+ try {
558
+ const dest = resolveSkillsDir(cfg().wecomCli.skillsDir, cfg().workspace)
559
+ const result = await installOfficialWecomSkills(dest, { signal })
560
+ officeSkills = loadWecomSkills(result.dest).filter(skill => skill.name.startsWith('wecomcli-'))
561
+ for (const st of chats.values()) {
562
+ if (st.agent === undefined) continue
563
+ installOfficeLayer(st.agent.ctx, st)
564
+ }
565
+ console.log(`[im-bridge] 已安装 ${String(result.count)} 个 wecomcli-* 到 ${result.dest}`)
566
+ return { ok: true, value: result }
567
+ } catch (error) {
568
+ const message = error instanceof Error ? error.message : String(error)
569
+ return { ok: false, error: { code: 'internal', message, details: {} } }
570
+ }
571
+ }, { authority: 'loopback' }),
572
+ 'im-bridge: wecomcli.installSkills',
573
+ )
574
+ })
575
+
318
576
  void (async () => {
319
577
  const loader = ctx.get('loader') as LoaderTree | undefined
320
578
  await loader?.await()
@@ -326,58 +584,289 @@ export function apply(ctx: Context, config: Config): void {
326
584
  return
327
585
  }
328
586
 
329
- const senders = new Map<string, SenderState>()
587
+ const wecomCli = cfg().wecomCli
588
+ if (wecomCli.enabled) {
589
+ const skillsDir = resolveSkillsDir(wecomCli.skillsDir, cfg().workspace)
590
+ officeSkills = loadWecomSkills(skillsDir).filter(skill => skill.name.startsWith('wecomcli-'))
591
+ const wecomcliCount = countWecomcliSkills(officeSkills)
592
+ if (wecomcliCount === 0) {
593
+ console.warn(
594
+ `[im-bridge] 未找到 wecomcli-* skills(${skillsDir})。${skillsInstallHint(skillsDir)}`,
595
+ )
596
+ } else {
597
+ console.log(
598
+ `[im-bridge] 已从 ${skillsDir} 加载 ${String(wecomcliCount)} 个 wecomcli-*,将在办公单聊 Agent 上注册`,
599
+ )
600
+ }
601
+ const leakCount = countWorkspaceWecomcliLeaks(cfg().workspace)
602
+ if (leakCount > 0) {
603
+ console.warn(`[im-bridge] ${WORKSPACE_WECOMCLI_LEAK_MESSAGE}(${String(leakCount)})`)
604
+ }
605
+ if (!shouldEnableWecomCli(true, wecomCli.allowFrom)) {
606
+ console.warn(`[im-bridge] ${ALLOW_FROM_REQUIRED_MESSAGE}`)
607
+ } else {
608
+ wecomCliReady = true
609
+ const binJs = resolveWecomBin()
610
+ if (binJs === undefined) {
611
+ console.warn('[im-bridge] 未找到 @wecom/cli 二进制,办公命令不可用。请确认插件依赖已安装。')
612
+ } else {
613
+ const configDir = ensureConfigDir(resolveConfigDir(wecomCli.configDir, cfg().workspace))
614
+ officeCli = { binJs, configDir }
615
+ console.log(`[im-bridge] wecom-cli 凭证目录: ${configDir}`)
616
+ const pathResult = ensureOnPath()
617
+ console.log(
618
+ `[im-bridge] PATH 上的 wecom-cli 已改为拒绝执行: ${pathResult.shimDir}${pathResult.shadowed ? '(已遮蔽另一个 wecom-cli)' : ''}`,
619
+ )
620
+ let status = await probeAuth(binJs, configDir)
621
+ if (status === 'unauthorized') {
622
+ if ((await trySeedAuth(binJs, cfg().botId, cfg().secret, configDir)) === undefined) {
623
+ status = await probeAuth(binJs, configDir)
624
+ }
625
+ }
626
+ if (status === 'authorized') {
627
+ console.log('[im-bridge] wecom-cli 已授权')
628
+ } else if (status === 'unauthorized') {
629
+ console.warn(
630
+ `[im-bridge] wecom-cli 未能用 botId/secret 完成授权。请在 host 上执行 ${authInitHint(configDir)}(输入同一套密钥,不要全局安装)。`,
631
+ )
632
+ } else {
633
+ console.warn('[im-bridge] wecom-cli auth show 失败。')
634
+ }
635
+ }
636
+ }
637
+ }
638
+
639
+ /**
640
+ * Add the channel prefix to a title the Host generated. `session/event`
641
+ * runs inside the append publication window, which refuses a reentrant
642
+ * append, so the prefixed title goes out in a microtask and re-reads the
643
+ * log first: an already prefixed tail (including the one this appends)
644
+ * stops the chain.
645
+ */
646
+ function prefixWecomTitle(session: TitledSession, st: ChatState): void {
647
+ const kind = st.kind
648
+ if (kind === undefined) return
649
+ queueMicrotask(() => {
650
+ const data = latestTitleData(session)
651
+ if (data === undefined) return
652
+ // An explicit GUI rename is pinned on purpose; only automatic titles get labelled.
653
+ if (data.source?.kind === 'user') return
654
+ const raw = typeof data.title === 'string' ? data.title : ''
655
+ const next = wecomDisplayTitle(kind, raw)
656
+ if (next === raw) return
657
+ const messageSeqs = Array.isArray(data.messageSeqs)
658
+ ? data.messageSeqs.filter((seq) => typeof seq === 'number')
659
+ : []
660
+ // A non-user title must cite at least one user/message seq, or the
661
+ // session-title invariant rejects the append.
662
+ if (messageSeqs.length === 0) return
663
+ try {
664
+ session.append('session/title', {
665
+ title: next,
666
+ messageSeqs,
667
+ source: data.source ?? { kind: 'fallback' },
668
+ })
669
+ } catch (error) {
670
+ const message = error instanceof Error ? error.message : String(error)
671
+ console.error(`[im-bridge] 加标题前缀失败: ${message}`)
672
+ }
673
+ })
674
+ }
675
+
676
+ /**
677
+ * Sessions the GUI archived. Archiving is the workspace registry's global
678
+ * set, not session state, and it has no inverse: an archived session is
679
+ * invisible in every list, so this plugin must stop writing to it.
680
+ */
681
+ function archivedSessions(): ReadonlySet<string> {
682
+ const registry = ctx.get('workspaceRegistry') as WorkspaceRegistry | undefined
683
+ if (registry === undefined) return new Set()
684
+ try {
685
+ return new Set(registry.archivedSessionIds)
686
+ } catch (error) {
687
+ // The getter throws until the registry finishes its own startup.
688
+ const message = error instanceof Error ? error.message : String(error)
689
+ console.warn(`[im-bridge] 读归档会话失败: ${message}`)
690
+ return new Set()
691
+ }
692
+ }
693
+
694
+ async function unpinLegacyWecomTitle(agent: LiveAgent): Promise<void> {
695
+ const titles = ctx.get('sessionTitle') as SessionTitleService | undefined
696
+ if (titles === undefined) return
697
+ try {
698
+ const snapshot = titles.get(agent.session)
699
+ if (snapshot?.source?.kind !== 'user') return
700
+ if (!isLegacyPinnedWecomTitle(snapshot.title)) return
701
+ await titles.refresh(agent.session)
702
+ } catch (error) {
703
+ const message = error instanceof Error ? error.message : String(error)
704
+ console.error(`[im-bridge] 解开旧标题失败: ${message}`)
705
+ }
706
+ }
707
+
708
+ /**
709
+ * Register persona + wecom prompt on this Agent's layer, synchronously.
710
+ * Must use `agentCtx.systemPrompt` (caller-bound). `inject()` without await
711
+ * yields a microtask and can publish the Agent before the section exists;
712
+ * `adopt` never re-runs setup.
713
+ */
714
+ function installWecomChannel(agentCtx: Context, st: ChatState): void {
715
+ if (st.wecomPromptInstalled) return
716
+ const host = agentCtx as Context & PromptHost
717
+ if (host.get('systemPrompt') === undefined || host.systemPrompt === undefined) {
718
+ console.warn('[im-bridge] 当前 Agent 没有 systemPrompt,企微提示词未注入')
719
+ return
720
+ }
721
+ const prompt = host.systemPrompt
722
+ try {
723
+ prompt.section({
724
+ name: 'deployment:persona',
725
+ order: 0,
726
+ text: () => resolvePersona(cfg(), settings),
727
+ })
728
+ } catch (error) {
729
+ const message = error instanceof Error ? error.message : String(error)
730
+ console.warn(`[im-bridge] 人设段未覆盖(可能已由 preset 注册): ${message}`)
731
+ }
732
+ try {
733
+ prompt.section({
734
+ name: 'channel:wecom-cli',
735
+ order: 1,
736
+ // Re-read per request: a group Agent is shared, and its sender —
737
+ // hence `st.office` — changes message to message. Only an office
738
+ // 1:1 gets the tool, so only it may get the office prompt.
739
+ // Always inject: WeCom has no GUI even when wecomCli is off.
740
+ text: () => wecomCliReady && shouldInjectWecomOfficeSkills(st.kind, st.office)
741
+ ? WECOM_CLI_PROMPT
742
+ : WECOM_CLI_NO_OFFICE_PROMPT,
743
+ })
744
+ console.log(
745
+ `[im-bridge] 已注入企微提示词 office=${String(st.office)} kind=${st.kind ?? '?'}`,
746
+ )
747
+ } catch (error) {
748
+ const message = error instanceof Error ? error.message : String(error)
749
+ console.error(`[im-bridge] 注入企微提示词失败: ${message}`)
750
+ }
751
+ st.wecomPromptInstalled = true
752
+ }
753
+
754
+ async function ensureAgent(ref: WecomSessionRef): Promise<ChatState> {
755
+ let st = chats.get(ref.key)
756
+ if (st === undefined) {
757
+ st = emptyChatState()
758
+ chats.set(ref.key, st)
759
+ }
760
+ st.kind = ref.kind
761
+ if (st.agent !== undefined) {
762
+ if (st.sessionId === undefined || !archivedSessions().has(st.sessionId)) {
763
+ // Office access is decided per inbound sender, and skills can arrive
764
+ // from the Settings button after this Agent was created.
765
+ installOfficeLayer(st.agent.ctx, st)
766
+ return st
767
+ }
768
+ console.log(`[im-bridge] 会话 ${st.sessionId} 已归档,改开新会话`)
769
+ st.agent = undefined
770
+ st.sessionId = undefined
771
+ st.wecomPromptInstalled = false
772
+ st.officeSkillsRegistered = false
773
+ st.officeToolRegistered = false
774
+ }
775
+
776
+ const persistence = ctx.get('sessionPersistence') as SessionPersistence | undefined
777
+ const headers = persistence === undefined ? [] : await persistence.list()
778
+ const plan = planWecomBind(ref.key, {
779
+ live: (id) => agents.get(SessionId(id)) !== undefined,
780
+ stored: new Set(headers.map((header) => header.id)),
781
+ archived: archivedSessions(),
782
+ })
783
+ const sessionId = SessionId(plan.sessionId)
784
+ const stored = headers.find((header) => header.id === sessionId)
785
+
786
+ const attach = (agent: LiveAgent, how: 'adopt' | 'resume' | 'create'): void => {
787
+ st.agent = agent
788
+ st.sessionId = sessionId
789
+ st.kind = ref.kind
790
+ if (agent.ctx === undefined) {
791
+ console.warn('[im-bridge] Agent 没有 ctx,无法注入企微提示词')
792
+ } else {
793
+ installWecomChannel(agent.ctx, st)
794
+ installOfficeLayer(agent.ctx, st)
795
+ }
796
+ void unpinLegacyWecomTitle(agent)
797
+ const cwd = agent.session.header?.cwd ?? stored?.cwd
798
+ if (cwd !== undefined && cwd !== cfg().workspace) {
799
+ console.warn(
800
+ `[im-bridge] 会话 ${sessionId} 仍使用存档目录 ${cwd},当前 workspace=${cfg().workspace}`,
801
+ )
802
+ }
803
+ const epoch = plan.epoch > 1 ? ` 第${String(plan.epoch)}段` : ''
804
+ console.log(
805
+ `[im-bridge] 为 ${ref.key} ${how}会话 ${sessionId}${epoch} userid=${ref.sender} chattype=${ref.kind} chatid=${ref.chatid ?? ''}`,
806
+ )
807
+ }
808
+
809
+ const live = agents.get(sessionId)
810
+ if (plan.bind === 'adopt' && live !== undefined) {
811
+ attach(live, 'adopt')
812
+ return st
813
+ }
330
814
 
331
- async function ensureAgent(sender: string): Promise<SenderState> {
332
- let st = senders.get(sender)
333
- if (st !== undefined && st.agent !== undefined) return st
334
- const sessionId = SessionId(`session-${randomUUID()}`)
335
815
  const selection = resolveSelection(cfg(), defaultModel)
336
816
  const presets = ctx.get('agentPresets') as AgentPresets | undefined
337
- let resolvedId = cfg().agentPreset
817
+ const presetId = (plan.bind === 'resume' && stored?.agentPreset) ? stored.agentPreset : cfg().agentPreset
818
+ let resolvedId = presetId
338
819
  if (presets !== undefined) {
339
- resolvedId = (await presets.resolve(cfg().agentPreset)).id
820
+ resolvedId = (await presets.resolve(presetId)).id
340
821
  }
341
- const { agent } = await agents.create({
342
- sessionId,
343
- meta: { cwd: cfg().workspace, agentPreset: resolvedId },
344
- agentOptions: { provider: selection.provider, model: selection.model },
345
- setup: async (agentCtx) => {
346
- const selected = { current: selection, assembled: undefined }
347
- installModelSelection(agentCtx, selected)
348
- if (presets !== undefined) await presets.mount(agentCtx, resolvedId)
349
- agentCtx.inject(['systemPrompt'], (promptCtx) => {
350
- promptCtx.systemPrompt.section({
351
- name: 'deployment:persona',
352
- order: 0,
353
- text: () => resolvePersona(cfg(), settings),
354
- })
822
+ const setup = async (agentCtx: Context): Promise<void> => {
823
+ const selected = { current: selection, assembled: undefined }
824
+ installModelSelection(agentCtx, selected)
825
+ if (presets !== undefined) await presets.mount(agentCtx, resolvedId)
826
+ installWecomChannel(agentCtx, st)
827
+ }
828
+ const agentOptions = { provider: selection.provider, model: selection.model }
829
+
830
+ try {
831
+ if (plan.bind === 'resume') {
832
+ const { agent } = await agents.resume({
833
+ resumeSessionId: sessionId,
834
+ agentOptions,
835
+ setup,
355
836
  })
356
- },
357
- })
358
- st = {
359
- agent,
360
- sessionId,
361
- queue: Promise.resolve(),
362
- lastActivity: '',
363
- activityClearAt: 0,
364
- lastToolByCallId: new Map(),
365
- modelStreamPhase: 'idle',
366
- streamStatusTick: 0,
837
+ attach(agent, 'resume')
838
+ return st
839
+ }
840
+ const { agent } = await agents.create({
841
+ sessionId,
842
+ meta: { cwd: cfg().workspace, agentPreset: resolvedId },
843
+ agentOptions,
844
+ setup,
845
+ })
846
+ attach(agent, 'create')
847
+ return st
848
+ } catch (error) {
849
+ const raced = agents.get(sessionId)
850
+ if (raced !== undefined) {
851
+ attach(raced, 'adopt')
852
+ return st
853
+ }
854
+ throw error
367
855
  }
368
- senders.set(sender, st)
369
- console.log(`[im-bridge] 为 ${sender} 创建会话 ${sessionId}`)
370
- return st
371
856
  }
372
857
 
373
- ctx.on('session/event', (session: { id: string }, event: LoggedEvent) => {
858
+ ctx.on('session/event', (session: TitledSession, event: LoggedEvent) => {
374
859
  const thinking = cfg().thinking
375
860
  const prefix = thinking?.activityPrefix ?? DEFAULT_THINKING.activityPrefix
376
861
  const flashMs = Number.isFinite(thinking?.intervalMs) && thinking.intervalMs > 0
377
862
  ? thinking.intervalMs
378
863
  : DEFAULT_THINKING.intervalMs
379
- for (const st of senders.values()) {
864
+ for (const st of chats.values()) {
380
865
  if (st.sessionId !== session.id) continue
866
+ if (event.type === 'session/title') {
867
+ prefixWecomTitle(session, st)
868
+ continue
869
+ }
381
870
  if (event.type === 'assistant/chunk') {
382
871
  const next = streamPhaseFromChunk((event.data as ChunkData).chunk)
383
872
  if (next !== null) st.modelStreamPhase = next
@@ -412,8 +901,14 @@ export function apply(ctx: Context, config: Config): void {
412
901
  generateReqId: (kind: string) => string
413
902
  }
414
903
 
415
- async function handle(frame: WecomFrame, sender: string, content: string): Promise<void> {
416
- const st = await ensureAgent(sender)
904
+ async function handle(frame: WecomFrame, ref: WecomSessionRef, content: string): Promise<void> {
905
+ let st = chats.get(ref.key)
906
+ if (st === undefined) {
907
+ st = emptyChatState()
908
+ chats.set(ref.key, st)
909
+ }
910
+ st.office = senderHasOfficeAccess(cfg().wecomCli.allowFrom, ref.sender)
911
+ st = await ensureAgent(ref)
417
912
  const startedAt = Date.now()
418
913
  const streamId = generateReqId('stream')
419
914
  let stopThinking: (() => void) | null = null
@@ -458,7 +953,7 @@ export function apply(ctx: Context, config: Config): void {
458
953
  console.error(`[im-bridge] 占位回复失败: ${message}`)
459
954
  }
460
955
  try {
461
- if (st.agent === undefined) throw new Error('im-bridge: sender agent missing')
956
+ if (st.agent === undefined) throw new Error('im-bridge: chat agent missing')
462
957
  await st.agent.whenIdle()
463
958
  const firstSeq = st.agent.session.seq
464
959
  st.agent.followup(createUserMessage({
@@ -470,9 +965,23 @@ export function apply(ctx: Context, config: Config): void {
470
965
  const outcome = summarize(st.agent.session.events, firstSeq)
471
966
  if (stopThinking) stopThinking()
472
967
  const ms = Date.now() - startedAt
473
- const reply = truncate(outcome.text || '(agent 无输出)', (cfg().maxReplyBytes || 20000) - 200) + footerOf(ms)
474
- console.log(`[im-bridge] ${sender} 完成 (${Buffer.byteLength(reply, 'utf8')}B, ${fmtDuration(ms)})`)
968
+ const body = outcome.text || '(agent 无输出)'
969
+ const collected = collectReplyPngs(body, cfg().workspace)
970
+ for (const reason of collected.skipped) {
971
+ console.warn(`[im-bridge] 跳过图片: ${reason}`)
972
+ }
973
+ let reply = truncate(body, (cfg().maxReplyBytes || 20000) - 200) + footerOf(ms)
974
+ if (collected.skipped.length > 0) {
975
+ reply = truncate(
976
+ `${reply}\n⚠️ ${collected.skipped.length} 张图片未发送(过大、越权或不存在)`,
977
+ cfg().maxReplyBytes || 20000,
978
+ )
979
+ }
980
+ console.log(`[im-bridge] ${ref.key} 完成 (${Buffer.byteLength(reply, 'utf8')}B, ${fmtDuration(ms)})`)
475
981
  await sendFinal(ws, frame, streamId, reply)
982
+ if (collected.images.length > 0) {
983
+ await sendCollectedPngs(ws, resolveChatId(frame, ref.sender), collected.images)
984
+ }
476
985
  } catch (error) {
477
986
  if (stopThinking) stopThinking()
478
987
  const ms = Date.now() - startedAt
@@ -496,25 +1005,32 @@ export function apply(ctx: Context, config: Config): void {
496
1005
  ws.on('error', ((error: Error) => console.error(`[im-bridge] 错误: ${error.message}`)) as (...args: never[]) => void)
497
1006
 
498
1007
  ws.on('message.text', ((frame: WecomFrame) => {
499
- const content = (frame.body?.text?.content || '').trim()
500
- if (!content) return
501
- const sender = frame.body?.sender?.userid || frame.body?.from?.userid || frame.body?.userid || 'unknown'
502
- if (cfg().allowFrom.length > 0 && !cfg().allowFrom.includes(sender)) {
1008
+ const inbound = (frame.body?.text?.content || '').trim()
1009
+ if (!inbound) return
1010
+ const content = stripBotMention(inbound)
1011
+ let ref: WecomSessionRef
1012
+ try {
1013
+ ref = resolveWecomSession(frame)
1014
+ } catch (error) {
1015
+ if (error instanceof WecomSessionReject) {
1016
+ console.error(`[im-bridge] ${error.reply}`)
1017
+ void ws.replyStream(frame, generateReqId('stream'), error.reply, true).catch(() => {})
1018
+ return
1019
+ }
1020
+ throw error
1021
+ }
1022
+ if (cfg().allowFrom.length > 0 && !cfg().allowFrom.includes(ref.sender)) {
503
1023
  void ws.replyStream(frame, generateReqId('stream'), cfg().deniedMessage, true).catch(() => {})
504
1024
  return
505
1025
  }
506
- console.log(`[im-bridge] 收到 from=${sender}: ${content.slice(0, 100)}`)
507
- const st = senders.get(sender) ?? {
508
- queue: Promise.resolve(),
509
- lastActivity: '',
510
- activityClearAt: 0,
511
- lastToolByCallId: new Map(),
512
- modelStreamPhase: 'idle' as const,
513
- streamStatusTick: 0,
514
- }
515
- senders.set(sender, st)
1026
+ console.log(
1027
+ `[im-bridge] 收到 key=${ref.key} userid=${ref.sender} chattype=${String(frame.body?.chattype ?? '')} chatid=${ref.chatid ?? ''}: ${content.slice(0, 100)}`,
1028
+ )
1029
+ const st = chats.get(ref.key) ?? emptyChatState()
1030
+ st.kind = ref.kind
1031
+ chats.set(ref.key, st)
516
1032
  st.queue = st.queue
517
- .then(() => handle(frame, sender, content))
1033
+ .then(() => handle(frame, ref, content))
518
1034
  .catch((error: unknown) => {
519
1035
  const message = error instanceof Error ? error.message : String(error)
520
1036
  console.error(`[im-bridge] 任务异常: ${message}`)