@mhfire/dsh-im-bridge 0.4.2 → 0.4.3

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
@@ -1,1058 +1,1099 @@
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
- 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'
64
- import {
65
- DEFAULT_THINKING,
66
- footerOf,
67
- fmtDuration,
68
- labelTool,
69
- pickStatusLine,
70
- sendFinal,
71
- startThinking,
72
- streamPhaseFromChunk,
73
- truncate,
74
- type ThinkingConfig,
75
- } from './wecom.ts'
76
-
77
- /** Package root (persona files live beside package.json). */
78
- const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
79
- /** Built-in Chinese persona. */
80
- const DEFAULT_PERSONA_ZH = join(PACKAGE_ROOT, 'persona.default.md')
81
- /** Built-in English persona. */
82
- const DEFAULT_PERSONA_EN = join(PACKAGE_ROOT, 'persona.default.en.md')
83
- /** Host locale settings namespace (`dsh-client-locale`). */
84
- const LOCALE_SETTINGS_NS = settingsNamespace('locale')
85
-
86
- /** Settings namespace paired with the browser card. */
87
- export const IM_BRIDGE_NS = settingsNamespace('im-bridge')
88
-
89
- /** Cordis diagnostic name. */
90
- export const name = 'im-bridge'
91
-
92
- /** Required host services. */
93
- export const inject = ['agents', 'sessions', 'agentDefaultModel']
94
-
95
- const ThinkingPhase = z.object({
96
- atSec: z.number(),
97
- text: z.string(),
98
- })
99
-
100
- const ThinkingSchema = z.object({
101
- phases: z.array(ThinkingPhase).default(DEFAULT_THINKING.phases),
102
- spin: z.array(String).default(DEFAULT_THINKING.spin),
103
- eggs: z.array(String).default(DEFAULT_THINKING.eggs),
104
- eggAfterSec: z.number().default(DEFAULT_THINKING.eggAfterSec),
105
- intervalMs: z.number().default(DEFAULT_THINKING.intervalMs),
106
- activityPrefix: z.string().default(DEFAULT_THINKING.activityPrefix),
107
- toolLabels: z.dict(String).default(DEFAULT_THINKING.toolLabels),
108
- reasoningStatus: z.array(String).default(DEFAULT_THINKING.reasoningStatus),
109
- outputStatus: z.array(String).default(DEFAULT_THINKING.outputStatus),
110
- reasoningSpin: z.array(String).default(DEFAULT_THINKING.reasoningSpin),
111
- outputSpin: z.array(String).default(DEFAULT_THINKING.outputSpin),
112
- })
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
-
133
- /** Plugin config: secrets come from the profile patch or Settings. */
134
- export interface Config {
135
- botId: string
136
- secret: string
137
- workspace: string
138
- allowFrom: string[]
139
- startHint: string
140
- agentTimeoutSec: number
141
- agentPreset: string
142
- provider: string
143
- model: string
144
- reasoningEffort: string
145
- persona: string
146
- personaFile: string
147
- maxReplyBytes: number
148
- thinking: ThinkingConfig
149
- deniedMessage: string
150
- welcomeMessage: string
151
- wecomCli: WecomCliConfig
152
- }
153
-
154
- /** Schemastery schema for the composition entry and settings namespace. */
155
- export const Config: z<Config> = z.object({
156
- botId: z.string().default('').role('secret'),
157
- secret: z.string().default('').role('secret'),
158
- workspace: z.string().default(process.cwd()),
159
- allowFrom: z.array(String).default([]),
160
- startHint: z.string().default('🧠 正在思考...'),
161
- agentTimeoutSec: z.number().default(600),
162
- agentPreset: z.string().default('standard'),
163
- provider: z.string().default(''),
164
- model: z.string().default(''),
165
- reasoningEffort: z.string().default(''),
166
- persona: z.string().default(''),
167
- personaFile: z.string().default(''),
168
- maxReplyBytes: z.number().default(20000),
169
- thinking: ThinkingSchema.default(DEFAULT_THINKING),
170
- deniedMessage: z.string().default('无权访问本服务'),
171
- welcomeMessage: z.string().default('👋 办公助手已就绪。直接发消息即可,例如查文件、整理文档、查资料或处理日常事务。'),
172
- wecomCli: WecomCliSchema.default({ enabled: false, skillsDir: '', configDir: '', allowFrom: [] }),
173
- })
174
-
175
- interface LoggedEvent {
176
- seq: number
177
- type: string
178
- data: Record<string, unknown>
179
- }
180
-
181
- interface TextBlock {
182
- type: string
183
- text?: string
184
- }
185
-
186
- interface AssistantMessageData {
187
- message?: { content?: TextBlock[] }
188
- }
189
-
190
- interface ToolCallData {
191
- name?: string
192
- callId?: string
193
- }
194
-
195
- interface ToolResultData {
196
- error?: unknown
197
- message?: { source?: { callId?: string } }
198
- }
199
-
200
- interface ChunkData {
201
- chunk?: { type?: string; blockType?: string }
202
- }
203
-
204
- interface LiveAgent {
205
- whenIdle(): Promise<void>
206
- followup(message: unknown): void
207
- ctx: Context
208
- session: {
209
- seq: number
210
- events: readonly LoggedEvent[]
211
- header?: { cwd?: string; agentPreset?: string }
212
- }
213
- }
214
-
215
- interface ChatState {
216
- agent?: LiveAgent
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
227
- queue: Promise<unknown>
228
- lastActivity: string
229
- activityClearAt: number
230
- lastToolByCallId: Map<string, string>
231
- modelStreamPhase: 'idle' | 'reasoning' | 'outputting'
232
- streamStatusTick: number
233
- }
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
-
265
- interface DefaultModel {
266
- currentSelection(): { provider: string; model: string; reasoningEffort?: string }
267
- }
268
-
269
- interface AgentRegistry {
270
- get(id: ReturnType<typeof SessionId>): LiveAgent | undefined
271
- create(options: {
272
- sessionId: ReturnType<typeof SessionId>
273
- meta?: { cwd?: string; agentPreset?: string }
274
- agentOptions?: { provider: string; model: string }
275
- setup?: (agentCtx: Context) => void | Promise<void>
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[]
296
- }
297
-
298
- interface SessionStore {
299
- flush(session: LiveAgent['session']): Promise<void>
300
- }
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
-
328
- interface AgentPresets {
329
- resolve(id: string): Promise<{ id: string }>
330
- mount(agentCtx: Context, id: string): Promise<unknown>
331
- }
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
-
343
- interface SettingsReader {
344
- get(ns: ReturnType<typeof settingsNamespace>): unknown
345
- }
346
-
347
- interface LoaderTree {
348
- await(): Promise<void>
349
- }
350
-
351
- interface WecomFrame {
352
- body?: {
353
- text?: { content?: string }
354
- sender?: { userid?: string }
355
- from?: { userid?: string }
356
- userid?: string
357
- chatid?: string
358
- chattype?: string | number
359
- }
360
- }
361
-
362
- interface WecomClient {
363
- replyStream(frame: unknown, streamId: string, content: string, finish: boolean): Promise<unknown>
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>
370
- connect(): void
371
- close?(): void
372
- on(event: string, handler: (...args: never[]) => void): void
373
- }
374
-
375
- /** Join assistant text from one turn starting at `firstSeq`. */
376
- function summarize(events: readonly LoggedEvent[], firstSeq: number): { text: string; reason: unknown } {
377
- let started = false
378
- let text = ''
379
- let reason: unknown
380
- for (const event of events) {
381
- if (event.seq < firstSeq) continue
382
- if (event.type === 'turn/start') { started = true; continue }
383
- if (!started) continue
384
- if (event.type === 'assistant/message') {
385
- const message = (event.data as AssistantMessageData).message
386
- const joined = (message?.content ?? [])
387
- .filter((block) => block.type === 'text')
388
- .map((block) => block.text ?? '')
389
- .join('')
390
- if (joined !== '') text = joined
391
- }
392
- if (event.type === 'turn/end') reason = event.data.reason
393
- }
394
- return { text, reason }
395
- }
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
-
411
- /** Read Host `locale.preference`; missing or unknown falls back to `zh`. */
412
- function readLocalePreference(settings: SettingsReader | undefined): 'zh' | 'en' {
413
- if (settings === undefined) return 'zh'
414
- try {
415
- const section = settings.get(LOCALE_SETTINGS_NS)
416
- const pref = section && typeof section === 'object' && 'preference' in section
417
- ? (section as { preference?: unknown }).preference
418
- : undefined
419
- return pref === 'en' ? 'en' : 'zh'
420
- } catch {
421
- return 'zh'
422
- }
423
- }
424
-
425
- /** Strip leading `#` comment lines from a built-in persona file. */
426
- function stripLeadingHashComments(text: string): string {
427
- const lines = text.split(/\r?\n/)
428
- let i = 0
429
- while (i < lines.length && /^\s*#/.test(lines[i] ?? '')) i++
430
- while (i < lines.length && (lines[i] ?? '').trim() === '') i++
431
- return lines.slice(i).join('\n')
432
- }
433
-
434
- /** Resolve persona: personaFile persona string built-in locale file. */
435
- function resolvePersona(config: Config, settings: SettingsReader | undefined): string {
436
- if (config.personaFile) {
437
- try {
438
- return readFileSync(config.personaFile, 'utf8')
439
- } catch (error) {
440
- const message = error instanceof Error ? error.message : String(error)
441
- console.error(`[im-bridge] 读取 personaFile 失败: ${message}`)
442
- }
443
- }
444
- if (config.persona !== '') return config.persona
445
- const file = readLocalePreference(settings) === 'en' ? DEFAULT_PERSONA_EN : DEFAULT_PERSONA_ZH
446
- try {
447
- return stripLeadingHashComments(readFileSync(file, 'utf8'))
448
- } catch (error) {
449
- const message = error instanceof Error ? error.message : String(error)
450
- console.error(`[im-bridge] 读取默认人设失败: ${message}`)
451
- return ''
452
- }
453
- }
454
-
455
- /**
456
- * Resolve the model for a new WeCom chat session. Both provider and model must be
457
- * non-empty to override; otherwise fall back to agent-default-model.
458
- */
459
- function resolveSelection(
460
- config: Config,
461
- defaultModel: DefaultModel,
462
- ): { provider: string; model: string; reasoningEffort?: string } {
463
- const provider = config.provider.trim()
464
- const model = config.model.trim()
465
- if (provider !== '' && model !== '') {
466
- const effort = config.reasoningEffort.trim()
467
- return effort === '' ? { provider, model } : { provider, model, reasoningEffort: effort }
468
- }
469
- if (provider !== '' || model !== '') {
470
- console.warn('[im-bridge] provider/model 需同时填写才覆盖企微模型, 已回退 agent-default-model。')
471
- }
472
- return defaultModel.currentSelection()
473
- }
474
-
475
- /**
476
- * Mount the WeCom bridge: settings namespace, then a deferred WebSocket after Loader settle.
477
- * @param ctx - host plugin context.
478
- * @param config - composition entry used as the settings `base` layer.
479
- */
480
- export function apply(ctx: Context, config: Config): void {
481
- const agents = ctx.get('agents') as AgentRegistry | undefined
482
- const sessions = ctx.get('sessions') as SessionStore | undefined
483
- const defaultModel = ctx.get('agentDefaultModel') as DefaultModel | undefined
484
- if (agents === undefined || sessions === undefined || defaultModel === undefined) {
485
- throw new Error('im-bridge: 需要 agents/sessions/agentDefaultModel 服务')
486
- }
487
-
488
- let source = (): Config => config
489
- let settings: SettingsReader | undefined
490
- installSettingsSection(ctx, IM_BRIDGE_NS, Config, config, {
491
- setSource: (current) => { source = current },
492
- onChange: () => {
493
- // Live fields are read through source() on the next handle/ensureAgent.
494
- // botId/secret still require a process restart to open the WebSocket.
495
- },
496
- })
497
- ctx.inject(['settings'], (settingsCtx) => {
498
- settings = settingsCtx.settings as SettingsReader
499
- settingsCtx.effect(() => () => { settings = undefined }, 'im-bridge: settings reader')
500
- })
501
- const cfg = (): Config => source()
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
-
576
- void (async () => {
577
- const loader = ctx.get('loader') as LoaderTree | undefined
578
- await loader?.await()
579
- const { botId, secret } = cfg()
580
- if (!botId || !secret) {
581
- console.warn(
582
- '[im-bridge] 跳过启动: 缺少 botId/secret。请在 profile cordis.patch.yml Settings → 插件配置中填写后重启。',
583
- )
584
- return
585
- }
586
-
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
- }
814
-
815
- const selection = resolveSelection(cfg(), defaultModel)
816
- const presets = ctx.get('agentPresets') as AgentPresets | undefined
817
- const presetId = (plan.bind === 'resume' && stored?.agentPreset) ? stored.agentPreset : cfg().agentPreset
818
- let resolvedId = presetId
819
- if (presets !== undefined) {
820
- resolvedId = (await presets.resolve(presetId)).id
821
- }
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,
836
- })
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
855
- }
856
- }
857
-
858
- ctx.on('session/event', (session: TitledSession, event: LoggedEvent) => {
859
- const thinking = cfg().thinking
860
- const prefix = thinking?.activityPrefix ?? DEFAULT_THINKING.activityPrefix
861
- const flashMs = Number.isFinite(thinking?.intervalMs) && thinking.intervalMs > 0
862
- ? thinking.intervalMs
863
- : DEFAULT_THINKING.intervalMs
864
- for (const st of chats.values()) {
865
- if (st.sessionId !== session.id) continue
866
- if (event.type === 'session/title') {
867
- prefixWecomTitle(session, st)
868
- continue
869
- }
870
- if (event.type === 'assistant/chunk') {
871
- const next = streamPhaseFromChunk((event.data as ChunkData).chunk)
872
- if (next !== null) st.modelStreamPhase = next
873
- continue
874
- }
875
- if (event.type === 'tool/call') {
876
- const toolName = (event.data as ToolCallData).name ?? ''
877
- const callId = (event.data as ToolCallData).callId
878
- if (callId !== undefined) st.lastToolByCallId.set(callId, toolName)
879
- st.activityClearAt = 0
880
- st.lastActivity = `${prefix}${labelTool(toolName, thinking)}`
881
- return
882
- }
883
- if (event.type === 'tool/result') {
884
- const data = event.data as ToolResultData
885
- const callId = data.message?.source?.callId
886
- const rawName = (callId !== undefined && st.lastToolByCallId.get(callId))
887
- || [...st.lastToolByCallId.values()].at(-1)
888
- || ''
889
- if (callId !== undefined) st.lastToolByCallId.delete(callId)
890
- const label = labelTool(rawName || '工具', thinking)
891
- const failed = data.error !== undefined
892
- st.lastActivity = failed ? `❌ ${label} 失败` : `✅ ${label} 完成`
893
- st.activityClearAt = Date.now() + flashMs
894
- st.modelStreamPhase = 'idle'
895
- }
896
- }
897
- })
898
-
899
- const { default: AiBot, generateReqId } = await import('@wecom/aibot-node-sdk') as {
900
- default: { WSClient: new (options: { botId: string; secret: string }) => WecomClient }
901
- generateReqId: (kind: string) => string
902
- }
903
-
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)
912
- const startedAt = Date.now()
913
- const streamId = generateReqId('stream')
914
- let stopThinking: (() => void) | null = null
915
- st.lastActivity = ''
916
- st.activityClearAt = 0
917
- st.lastToolByCallId.clear()
918
- st.modelStreamPhase = 'idle'
919
- st.streamStatusTick = 0
920
- try {
921
- await ws.replyStream(frame, streamId, cfg().startHint, false)
922
- stopThinking = startThinking(
923
- ws, frame, streamId, startedAt, cfg().agentTimeoutSec,
924
- () => {
925
- if (st.activityClearAt > 0 && Date.now() >= st.activityClearAt) {
926
- st.lastActivity = ''
927
- st.activityClearAt = 0
928
- }
929
- if (st.lastActivity) return st.lastActivity
930
- const thinking = cfg().thinking
931
- const tick = st.streamStatusTick++
932
- if (st.modelStreamPhase === 'reasoning') {
933
- return pickStatusLine(
934
- thinking?.reasoningStatus,
935
- DEFAULT_THINKING.reasoningStatus,
936
- tick,
937
- )
938
- }
939
- if (st.modelStreamPhase === 'outputting') {
940
- return pickStatusLine(
941
- thinking?.outputStatus,
942
- DEFAULT_THINKING.outputStatus,
943
- tick,
944
- )
945
- }
946
- return ''
947
- },
948
- cfg().thinking,
949
- () => (st.lastActivity ? 'idle' : st.modelStreamPhase),
950
- )
951
- } catch (error) {
952
- const message = error instanceof Error ? error.message : String(error)
953
- console.error(`[im-bridge] 占位回复失败: ${message}`)
954
- }
955
- try {
956
- if (st.agent === undefined) throw new Error('im-bridge: chat agent missing')
957
- await st.agent.whenIdle()
958
- const firstSeq = st.agent.session.seq
959
- st.agent.followup(createUserMessage({
960
- content: [{ type: 'text', text: content }],
961
- source: { kind: 'user' },
962
- }))
963
- await st.agent.whenIdle()
964
- await sessions.flush(st.agent.session)
965
- const outcome = summarize(st.agent.session.events, firstSeq)
966
- if (stopThinking) stopThinking()
967
- const ms = Date.now() - startedAt
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)})`)
981
- await sendFinal(ws, frame, streamId, reply)
982
- if (collected.images.length > 0) {
983
- await sendCollectedPngs(ws, resolveChatId(frame, ref.sender), collected.images)
984
- }
985
- } catch (error) {
986
- if (stopThinking) stopThinking()
987
- const ms = Date.now() - startedAt
988
- const message = error instanceof Error ? error.message : String(error)
989
- console.error(`[im-bridge] agent 失败: ${message}`)
990
- try {
991
- await sendFinal(ws, frame, streamId, `处理失败: ${truncate(message, 400)}\n\n---\n❌ 耗时 ${fmtDuration(ms)}`)
992
- } catch (retryError) {
993
- const retryMessage = retryError instanceof Error ? retryError.message : String(retryError)
994
- console.error(`[im-bridge] 错误回复也失败: ${retryMessage}`)
995
- }
996
- }
997
- }
998
-
999
- const ws = new AiBot.WSClient({ botId, secret })
1000
-
1001
- ws.on('connected', (() => console.log('[im-bridge] WebSocket 已连接')) as (...args: never[]) => void)
1002
- ws.on('authenticated', (() => console.log('[im-bridge] 认证成功, 等待消息...')) as (...args: never[]) => void)
1003
- ws.on('disconnected', ((reason: string) => console.log(`[im-bridge] 断开: ${reason}`)) as (...args: never[]) => void)
1004
- ws.on('reconnecting', ((n: number) => console.log(`[im-bridge] 第 ${n} 次重连...`)) as (...args: never[]) => void)
1005
- ws.on('error', ((error: Error) => console.error(`[im-bridge] 错误: ${error.message}`)) as (...args: never[]) => void)
1006
-
1007
- ws.on('message.text', ((frame: WecomFrame) => {
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)) {
1023
- void ws.replyStream(frame, generateReqId('stream'), cfg().deniedMessage, true).catch(() => {})
1024
- return
1025
- }
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)
1032
- st.queue = st.queue
1033
- .then(() => handle(frame, ref, content))
1034
- .catch((error: unknown) => {
1035
- const message = error instanceof Error ? error.message : String(error)
1036
- console.error(`[im-bridge] 任务异常: ${message}`)
1037
- })
1038
- }) as (...args: never[]) => void)
1039
-
1040
- ws.on('event.enter_chat', ((frame: WecomFrame) => {
1041
- const sender = frame.body?.from?.userid || 'unknown'
1042
- console.log(`[im-bridge] 用户 ${sender} 进入会话`)
1043
- void ws.replyWelcome(frame, {
1044
- msgtype: 'text',
1045
- text: { content: cfg().welcomeMessage },
1046
- }).catch((error: unknown) => {
1047
- const message = error instanceof Error ? error.message : String(error)
1048
- console.error(`[im-bridge] 欢迎语失败: ${message}`)
1049
- })
1050
- }) as (...args: never[]) => void)
1051
-
1052
- ws.connect()
1053
-
1054
- ctx.on('dispose', () => {
1055
- try { ws.close?.() } catch { /* already closed */ }
1056
- })
1057
- })()
1058
- }
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
+ * `ctx.settings.installSection`; 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, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
17
+ import { SessionId } from '@deepseek-ai/dsh-session'
18
+ import { installModelSelection, type ModelSelection } from '@deepseek-ai/dsh-agent'
19
+ // Type-only: the ctx.settings Context merge; the section installs through the service.
20
+ import type {} from '@deepseek-ai/dsh-settings'
21
+ // Type-only: the 'session/title' SessionEventMap merge this bridge reads and appends.
22
+ import type {} from '@deepseek-ai/dsh-session-title'
23
+ import {
24
+ collectReplyPngs,
25
+ resolveChatId,
26
+ sendCollectedPngs,
27
+ } from './reply-images.ts'
28
+ import {
29
+ isLegacyPinnedWecomTitle,
30
+ planWecomBind,
31
+ resolveWecomSession,
32
+ stripBotMention,
33
+ wecomDisplayTitle,
34
+ WecomSessionReject,
35
+ type WecomSessionRef,
36
+ } from './session-key.ts'
37
+ import {
38
+ ALLOW_FROM_REQUIRED_MESSAGE,
39
+ IM_BRIDGE_RPC_CHANNEL,
40
+ INSTALL_SKILLS_ENDPOINT,
41
+ SKILLS_RPC_UNAVAILABLE_MESSAGE,
42
+ WECOM_CLI_NO_OFFICE_PROMPT,
43
+ WECOM_CLI_PROMPT,
44
+ WECOM_CLI_TOOL_NAME,
45
+ WORKSPACE_WECOMCLI_LEAK_MESSAGE,
46
+ authInitHint,
47
+ countWecomcliSkills,
48
+ countWorkspaceWecomcliLeaks,
49
+ ensureConfigDir,
50
+ ensureOnPath,
51
+ installOfficialWecomSkills,
52
+ loadWecomSkills,
53
+ probeAuth,
54
+ registerWecomCliTool,
55
+ registerWecomOfficeSkills,
56
+ resolveConfigDir,
57
+ resolveSkillsDir,
58
+ resolveWecomBin,
59
+ senderHasOfficeAccess,
60
+ shouldEnableWecomCli,
61
+ shouldInjectWecomOfficeSkills,
62
+ skillsInstallHint,
63
+ trySeedAuth,
64
+ type InstallWecomSkillsResult,
65
+ type WecomSkill,
66
+ } from './wecom-cli.ts'
67
+ import {
68
+ DEFAULT_THINKING,
69
+ footerOf,
70
+ fmtDuration,
71
+ labelTool,
72
+ pickStatusLine,
73
+ sendFinal,
74
+ startThinking,
75
+ streamPhaseFromChunk,
76
+ truncate,
77
+ type ThinkingConfig,
78
+ } from './wecom.ts'
79
+
80
+ /** Package root (persona files live beside package.json). */
81
+ const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
82
+ /** Built-in Chinese persona. */
83
+ const DEFAULT_PERSONA_ZH = join(PACKAGE_ROOT, 'persona.default.md')
84
+ /** Built-in English persona. */
85
+ const DEFAULT_PERSONA_EN = join(PACKAGE_ROOT, 'persona.default.en.md')
86
+ /** Host locale settings namespace (`dsh-client-locale`). */
87
+ const LOCALE_SETTINGS_NS = 'locale'
88
+
89
+ /** Settings namespace paired with the browser card. */
90
+ export const IM_BRIDGE_NS = 'im-bridge'
91
+
92
+ /** Cordis diagnostic name. */
93
+ export const name = 'im-bridge'
94
+
95
+ /** Required host services. */
96
+ export const inject = ['agents', 'sessions', 'agentDefaultModel']
97
+
98
+ const ThinkingPhase = z.object({
99
+ atSec: z.number(),
100
+ text: z.string(),
101
+ })
102
+
103
+ const ThinkingSchema = z.object({
104
+ phases: z.array(ThinkingPhase).default(DEFAULT_THINKING.phases),
105
+ spin: z.array(String).default(DEFAULT_THINKING.spin),
106
+ eggs: z.array(String).default(DEFAULT_THINKING.eggs),
107
+ eggAfterSec: z.number().default(DEFAULT_THINKING.eggAfterSec),
108
+ intervalMs: z.number().default(DEFAULT_THINKING.intervalMs),
109
+ activityPrefix: z.string().default(DEFAULT_THINKING.activityPrefix),
110
+ toolLabels: z.dict(String).default(DEFAULT_THINKING.toolLabels),
111
+ reasoningStatus: z.array(String).default(DEFAULT_THINKING.reasoningStatus),
112
+ outputStatus: z.array(String).default(DEFAULT_THINKING.outputStatus),
113
+ reasoningSpin: z.array(String).default(DEFAULT_THINKING.reasoningSpin),
114
+ outputSpin: z.array(String).default(DEFAULT_THINKING.outputSpin),
115
+ })
116
+
117
+ const WecomCliSchema = z.object({
118
+ enabled: z.boolean().default(false),
119
+ skillsDir: z.string().default(''),
120
+ configDir: z.string().default(''),
121
+ allowFrom: z.array(String).default([]),
122
+ })
123
+
124
+ /** Optional wecom-cli office skills (mail, calendar, docs, …). */
125
+ export interface WecomCliConfig {
126
+ /** Explicit opt-in; off by default because it shares the authorized identity. */
127
+ enabled: boolean
128
+ /** Skills root; empty = `$DSH_HOME/wecom-cli-skills`. */
129
+ skillsDir: string
130
+ /** Credential directory; empty = `<workspace>/.dsh/wecom-cli`. */
131
+ configDir: string
132
+ /** Office-command userid list; empty skips PATH / auth. Independent of chat `allowFrom`. */
133
+ allowFrom: string[]
134
+ }
135
+
136
+ /** Plugin config: secrets come from the profile patch or Settings. */
137
+ export interface Config {
138
+ botId: string
139
+ secret: string
140
+ workspace: string
141
+ allowFrom: string[]
142
+ startHint: string
143
+ agentTimeoutSec: number
144
+ agentPreset: string
145
+ provider: string
146
+ model: string
147
+ reasoningEffort: string
148
+ persona: string
149
+ personaFile: string
150
+ maxReplyBytes: number
151
+ thinking: ThinkingConfig
152
+ deniedMessage: string
153
+ welcomeMessage: string
154
+ wecomCli: WecomCliConfig
155
+ }
156
+
157
+ /** Schemastery schema for the composition entry and settings namespace. */
158
+ export const Config: z<Config> = z.object({
159
+ botId: z.string().default('').role('secret'),
160
+ secret: z.string().default('').role('secret'),
161
+ workspace: z.string().default(process.cwd()),
162
+ allowFrom: z.array(String).default([]),
163
+ startHint: z.string().default('🧠 正在思考...'),
164
+ agentTimeoutSec: z.number().default(600),
165
+ agentPreset: z.string().default('standard'),
166
+ provider: z.string().default(''),
167
+ model: z.string().default(''),
168
+ reasoningEffort: z.string().default(''),
169
+ persona: z.string().default(''),
170
+ personaFile: z.string().default(''),
171
+ maxReplyBytes: z.number().default(20000),
172
+ thinking: ThinkingSchema.default(DEFAULT_THINKING),
173
+ deniedMessage: z.string().default('无权访问本服务'),
174
+ welcomeMessage: z.string().default('👋 办公助手已就绪。直接发消息即可,例如查文件、整理文档、查资料或处理日常事务。'),
175
+ wecomCli: WecomCliSchema.default({ enabled: false, skillsDir: '', configDir: '', allowFrom: [] }),
176
+ })
177
+
178
+ interface LoggedEvent {
179
+ seq: number
180
+ type: string
181
+ /** Per-type payload; each reader narrows it to the event it handles. */
182
+ data: unknown
183
+ }
184
+
185
+ interface TextBlock {
186
+ type: string
187
+ text?: string
188
+ }
189
+
190
+ interface AssistantMessageData {
191
+ message?: { content?: TextBlock[] }
192
+ }
193
+
194
+ interface ToolCallData {
195
+ name?: string
196
+ callId?: string
197
+ }
198
+
199
+ interface ToolResultData {
200
+ error?: unknown
201
+ message?: { source?: { callId?: string } }
202
+ }
203
+
204
+ interface ChunkData {
205
+ chunk?: { type?: string; blockType?: string }
206
+ }
207
+
208
+ interface LiveAgent {
209
+ whenIdle(): Promise<void>
210
+ followup(message: unknown): void
211
+ ctx: Context
212
+ session: {
213
+ seq: number
214
+ snapshotEvents(): readonly LoggedEvent[]
215
+ header?: { cwd?: string; agentPreset?: string }
216
+ }
217
+ }
218
+
219
+ interface ChatState {
220
+ agent?: LiveAgent
221
+ sessionId?: string
222
+ kind?: 'single' | 'group'
223
+ /** True when the current inbound sender is on `wecomCli.allowFrom`. */
224
+ office: boolean
225
+ /** Prompt sections already registered on this Agent. */
226
+ wecomPromptInstalled: boolean
227
+ /** wecomcli-* already registered on this Agent. */
228
+ officeSkillsRegistered: boolean
229
+ /** The gated `wecom_cli` tool already registered on this Agent. */
230
+ officeToolRegistered: boolean
231
+ queue: Promise<unknown>
232
+ lastActivity: string
233
+ activityClearAt: number
234
+ lastToolByCallId: Map<string, string>
235
+ modelStreamPhase: 'idle' | 'reasoning' | 'outputting'
236
+ streamStatusTick: number
237
+ }
238
+
239
+ /** Placeholder Map value so later messages on the same key share one queue. */
240
+ function emptyChatState(): ChatState {
241
+ return {
242
+ office: false,
243
+ wecomPromptInstalled: false,
244
+ officeSkillsRegistered: false,
245
+ officeToolRegistered: false,
246
+ queue: Promise.resolve(),
247
+ lastActivity: '',
248
+ activityClearAt: 0,
249
+ lastToolByCallId: new Map(),
250
+ modelStreamPhase: 'idle',
251
+ streamStatusTick: 0,
252
+ }
253
+ }
254
+
255
+ /** Duck-typed Connection RPC result (no apiproxy import). */
256
+ type SkillsRpcResult =
257
+ | { ok: true; value: InstallWecomSkillsResult }
258
+ | { ok: false; error: { code: 'internal' | 'cancelled'; message: string; details: Record<string, never> } }
259
+
260
+ /** Optional Host Connection used by the Settings install button. */
261
+ interface HostConnectionRpc {
262
+ handle(
263
+ channel: string,
264
+ handler: (endpoint: string, payload: unknown, signal: AbortSignal) => Promise<SkillsRpcResult>,
265
+ options: { authority: 'loopback' },
266
+ ): () => Promise<void>
267
+ }
268
+
269
+ interface DefaultModel {
270
+ currentSelection(): ModelSelection
271
+ }
272
+
273
+ interface AgentRegistry {
274
+ get(id: ReturnType<typeof SessionId>): LiveAgent | undefined
275
+ create(options: {
276
+ sessionId: ReturnType<typeof SessionId>
277
+ meta?: { cwd?: string; agentPreset?: string }
278
+ agentOptions?: { provider: string; model: string }
279
+ setup?: (agentCtx: Context) => void | Promise<void>
280
+ }): Promise<{ agent: LiveAgent }>
281
+ resume(options: {
282
+ resumeSessionId: ReturnType<typeof SessionId>
283
+ agentOptions?: { provider: string; model: string }
284
+ setup?: (agentCtx: Context) => void | Promise<void>
285
+ }): Promise<{ agent: LiveAgent }>
286
+ }
287
+
288
+ interface SessionPersistenceHeader {
289
+ id: string
290
+ cwd?: string
291
+ agentPreset?: string
292
+ }
293
+
294
+ interface SessionPersistence {
295
+ list(): Promise<SessionPersistenceHeader[]>
296
+ }
297
+
298
+ interface WorkspaceRegistry {
299
+ readonly archivedSessionIds: readonly string[]
300
+ }
301
+
302
+ interface SessionStore {
303
+ flush(session: LiveAgent['session']): Promise<void>
304
+ }
305
+
306
+ interface SessionTitleSnapshot {
307
+ title: string
308
+ source: { kind: string }
309
+ }
310
+
311
+ interface SessionTitleService {
312
+ get(session: LiveAgent['session']): SessionTitleSnapshot | undefined
313
+ refresh(session: LiveAgent['session']): Promise<unknown>
314
+ }
315
+
316
+ interface TitledSession {
317
+ id: string
318
+ snapshotEvents(): readonly LoggedEvent[]
319
+ append(type: 'session/title', data: {
320
+ title: string
321
+ messageSeqs: number[]
322
+ source: unknown
323
+ }): void
324
+ }
325
+
326
+ interface SessionTitleEventData {
327
+ title?: string
328
+ messageSeqs?: number[]
329
+ source?: { kind?: string }
330
+ }
331
+
332
+ interface AgentPresets {
333
+ resolve(id: string): Promise<{ id: string }>
334
+ mount(agentCtx: Context, id: string): Promise<unknown>
335
+ }
336
+
337
+ interface SystemPromptService {
338
+ section(entry: { name: string; order: number; text: () => string }): unknown
339
+ }
340
+
341
+ /** Caller-bound prompt registry on an Agent context (not a raw `get()` result). */
342
+ interface PromptHost {
343
+ get(name: string): unknown
344
+ systemPrompt?: SystemPromptService
345
+ }
346
+
347
+ /** The read face persona / locale resolution needs. */
348
+ interface SettingsReader {
349
+ get(ns: string): unknown
350
+ }
351
+
352
+ /** Hooks `installSection` drives at attach, at each committed change, and at detach. */
353
+ interface SettingsSectionHooks<T> {
354
+ setSource(current: () => T): void
355
+ onChange(): void
356
+ validate?(value: T): void
357
+ }
358
+
359
+ /** `ctx.settings` members this plugin uses, duck-typed like the other host services. */
360
+ interface SettingsService extends SettingsReader {
361
+ installSection<T>(
362
+ owner: Context,
363
+ ns: string,
364
+ schema: z<T>,
365
+ entry: T,
366
+ hooks: SettingsSectionHooks<T>,
367
+ ): void
368
+ }
369
+
370
+ interface LoaderTree {
371
+ await(): Promise<void>
372
+ }
373
+
374
+ interface WecomFrame {
375
+ body?: {
376
+ text?: { content?: string }
377
+ sender?: { userid?: string }
378
+ from?: { userid?: string }
379
+ userid?: string
380
+ chatid?: string
381
+ chattype?: string | number
382
+ }
383
+ }
384
+
385
+ interface WecomClient {
386
+ replyStream(frame: unknown, streamId: string, content: string, finish: boolean): Promise<unknown>
387
+ replyWelcome(frame: unknown, payload: { msgtype: string; text: { content: string } }): Promise<unknown>
388
+ uploadMedia(
389
+ fileBuffer: Buffer,
390
+ options: { type: string; filename: string },
391
+ ): Promise<{ media_id?: string; mediaId?: string }>
392
+ sendMediaMessage(chatid: string, mediaType: string, mediaId: string): Promise<unknown>
393
+ connect(): void
394
+ close?(): void
395
+ on(event: string, handler: (...args: never[]) => void): void
396
+ }
397
+
398
+ /** Join assistant text from one turn starting at `firstSeq`. */
399
+ function summarize(events: readonly LoggedEvent[], firstSeq: number): { text: string; reason: unknown } {
400
+ let started = false
401
+ let text = ''
402
+ let reason: unknown
403
+ for (const event of events) {
404
+ if (event.seq < firstSeq) continue
405
+ if (event.type === 'turn/start') { started = true; continue }
406
+ if (!started) continue
407
+ if (event.type === 'assistant/message') {
408
+ const message = (event.data as AssistantMessageData).message
409
+ const joined = (message?.content ?? [])
410
+ .filter((block) => block.type === 'text')
411
+ .map((block) => block.text ?? '')
412
+ .join('')
413
+ if (joined !== '') text = joined
414
+ }
415
+ if (event.type === 'turn/end') reason = (event.data as { reason?: unknown }).reason
416
+ }
417
+ return { text, reason }
418
+ }
419
+
420
+ /**
421
+ * Payload of the log's last `session/title` event — the title in force now.
422
+ * @param session - live session whose log to fold.
423
+ * @returns the payload, or undefined when the session has no title event.
424
+ */
425
+ function latestTitleData(session: TitledSession): SessionTitleEventData | undefined {
426
+ const events = session.snapshotEvents()
427
+ for (let i = events.length - 1; i >= 0; i -= 1) {
428
+ const event = events[i]
429
+ if (event.type === 'session/title') return event.data as SessionTitleEventData
430
+ }
431
+ return undefined
432
+ }
433
+
434
+ /** Read Host `locale.preference`; missing or unknown falls back to `zh`. */
435
+ function readLocalePreference(settings: SettingsReader | undefined): 'zh' | 'en' {
436
+ if (settings === undefined) return 'zh'
437
+ try {
438
+ const section = settings.get(LOCALE_SETTINGS_NS)
439
+ const pref = section && typeof section === 'object' && 'preference' in section
440
+ ? (section as { preference?: unknown }).preference
441
+ : undefined
442
+ return pref === 'en' ? 'en' : 'zh'
443
+ } catch {
444
+ return 'zh'
445
+ }
446
+ }
447
+
448
+ /** Strip leading `#` comment lines from a built-in persona file. */
449
+ function stripLeadingHashComments(text: string): string {
450
+ const lines = text.split(/\r?\n/)
451
+ let i = 0
452
+ while (i < lines.length && /^\s*#/.test(lines[i] ?? '')) i++
453
+ while (i < lines.length && (lines[i] ?? '').trim() === '') i++
454
+ return lines.slice(i).join('\n')
455
+ }
456
+
457
+ /** Resolve persona: personaFile persona string built-in locale file. */
458
+ function resolvePersona(config: Config, settings: SettingsReader | undefined): string {
459
+ if (config.personaFile) {
460
+ try {
461
+ return readFileSync(config.personaFile, 'utf8')
462
+ } catch (error) {
463
+ const message = error instanceof Error ? error.message : String(error)
464
+ console.error(`[im-bridge] 读取 personaFile 失败: ${message}`)
465
+ }
466
+ }
467
+ if (config.persona !== '') return config.persona
468
+ const file = readLocalePreference(settings) === 'en' ? DEFAULT_PERSONA_EN : DEFAULT_PERSONA_ZH
469
+ try {
470
+ return stripLeadingHashComments(readFileSync(file, 'utf8'))
471
+ } catch (error) {
472
+ const message = error instanceof Error ? error.message : String(error)
473
+ console.error(`[im-bridge] 读取默认人设失败: ${message}`)
474
+ return ''
475
+ }
476
+ }
477
+
478
+ /**
479
+ * Resolve the model for a new WeCom chat session. Both provider and model must be
480
+ * non-empty to override; otherwise fall back to agent-default-model.
481
+ */
482
+ function resolveSelection(
483
+ config: Config,
484
+ defaultModel: DefaultModel,
485
+ ): ModelSelection {
486
+ const provider = config.provider.trim()
487
+ const model = config.model.trim()
488
+ if (provider !== '' && model !== '') {
489
+ const effort = config.reasoningEffort.trim()
490
+ return effort === ''
491
+ ? { provider, model }
492
+ : { provider, model, reasoningEffort: ReasoningEffortId(effort) }
493
+ }
494
+ if (provider !== '' || model !== '') {
495
+ console.warn('[im-bridge] provider/model 需同时填写才覆盖企微模型, 已回退 agent-default-model。')
496
+ }
497
+ return defaultModel.currentSelection()
498
+ }
499
+
500
+ /**
501
+ * Read a Host service this bridge cannot run without.
502
+ * @param ctx - host plugin context.
503
+ * @param name - service name in the global service store.
504
+ * @returns the service value.
505
+ * @throws when the composition does not provide the service.
506
+ */
507
+ function requireService<T>(ctx: Context, name: string): T {
508
+ const service = ctx.get(name) as T | undefined
509
+ if (service === undefined) throw new Error(`im-bridge: 需要 ${name} 服务`)
510
+ return service
511
+ }
512
+
513
+ /**
514
+ * Mount the WeCom bridge: settings namespace, then a deferred WebSocket after Loader settle.
515
+ * @param ctx - host plugin context.
516
+ * @param config - composition entry used as the settings `base` layer.
517
+ */
518
+ export function apply(ctx: Context, config: Config): void {
519
+ const agents = requireService<AgentRegistry>(ctx, 'agents')
520
+ const sessions = requireService<SessionStore>(ctx, 'sessions')
521
+ const defaultModel = requireService<DefaultModel>(ctx, 'agentDefaultModel')
522
+
523
+ let source = (): Config => config
524
+ let settings: SettingsReader | undefined
525
+ ctx.inject(['settings'], (settingsCtx) => {
526
+ const provider = settingsCtx.settings as SettingsService
527
+ // The owner is this plugin's own ctx, not the injected one: the provider
528
+ // reads it to tell its own detach from this plugin unloading.
529
+ provider.installSection(ctx, IM_BRIDGE_NS, Config, config, {
530
+ setSource: (current) => { source = current },
531
+ onChange: () => {
532
+ // Live fields are read through source() on the next handle/ensureAgent.
533
+ // botId/secret still require a process restart to open the WebSocket.
534
+ },
535
+ })
536
+ settings = provider
537
+ settingsCtx.effect(() => () => { settings = undefined }, 'im-bridge: settings reader')
538
+ })
539
+ const cfg = (): Config => source()
540
+
541
+ const chats = new Map<string, ChatState>()
542
+ let wecomCliReady = false
543
+ let officeSkills: WecomSkill[] = []
544
+ /** Launcher and credential directory the gated tool spawns with; set once wecom-cli is usable. */
545
+ let officeCli: { binJs: string; configDir: string } | undefined
546
+
547
+ /**
548
+ * Install the office layer on one Agent: wecomcli-* skills and the gated
549
+ * `wecom_cli` tool. Both register through the Agent's own context, so a group
550
+ * chat or the GUI never sees them. Idempotent per Agent, and retried on each
551
+ * inbound message because skills can arrive from the Settings button later.
552
+ */
553
+ function installOfficeLayer(agentCtx: Context, st: ChatState): void {
554
+ if (!wecomCliReady) return
555
+ if (!shouldInjectWecomOfficeSkills(st.kind, st.office)) return
556
+ if (!st.officeSkillsRegistered) {
557
+ const count = registerWecomOfficeSkills(agentCtx, officeSkills)
558
+ if (count > 0) {
559
+ st.officeSkillsRegistered = true
560
+ console.log(`[im-bridge] 已在该 Agent 注册 ${String(count)} wecomcli-*`)
561
+ }
562
+ }
563
+ if (st.officeToolRegistered || officeCli === undefined) return
564
+ try {
565
+ st.officeToolRegistered = registerWecomCliTool(agentCtx, officeCli.binJs, officeCli.configDir)
566
+ if (st.officeToolRegistered) {
567
+ console.log(`[im-bridge] 已在该 Agent 注册 ${WECOM_CLI_TOOL_NAME} 工具`)
568
+ }
569
+ } catch (error) {
570
+ const message = error instanceof Error ? error.message : String(error)
571
+ console.error(`[im-bridge] 注册 ${WECOM_CLI_TOOL_NAME} 失败: ${message}`)
572
+ }
573
+ }
574
+
575
+ ctx.inject(['connection'], (bound) => {
576
+ const rpc = (bound.get('connection') as { rpc?: HostConnectionRpc } | undefined)?.rpc
577
+ if (rpc === undefined) {
578
+ console.warn(`[im-bridge] ${SKILLS_RPC_UNAVAILABLE_MESSAGE}`)
579
+ return
580
+ }
581
+ bound.effect(
582
+ () => rpc.handle(IM_BRIDGE_RPC_CHANNEL, async (endpoint, _payload, signal) => {
583
+ if (endpoint !== INSTALL_SKILLS_ENDPOINT) {
584
+ return {
585
+ ok: false,
586
+ error: { code: 'internal', message: `unknown endpoint ${endpoint}`, details: {} },
587
+ }
588
+ }
589
+ if (signal.aborted) {
590
+ return {
591
+ ok: false,
592
+ error: { code: 'cancelled', message: '安装已取消', details: {} },
593
+ }
594
+ }
595
+ try {
596
+ const dest = resolveSkillsDir(cfg().wecomCli.skillsDir, cfg().workspace)
597
+ const result = await installOfficialWecomSkills(dest, { signal })
598
+ officeSkills = loadWecomSkills(result.dest).filter(skill => skill.name.startsWith('wecomcli-'))
599
+ for (const st of chats.values()) {
600
+ if (st.agent === undefined) continue
601
+ installOfficeLayer(st.agent.ctx, st)
602
+ }
603
+ console.log(`[im-bridge] 已安装 ${String(result.count)} 个 wecomcli-* 到 ${result.dest}`)
604
+ return { ok: true, value: result }
605
+ } catch (error) {
606
+ const message = error instanceof Error ? error.message : String(error)
607
+ return { ok: false, error: { code: 'internal', message, details: {} } }
608
+ }
609
+ }, { authority: 'loopback' }),
610
+ 'im-bridge: wecomcli.installSkills',
611
+ )
612
+ })
613
+
614
+ void (async () => {
615
+ const loader = ctx.get('loader') as LoaderTree | undefined
616
+ await loader?.await()
617
+ const { botId, secret } = cfg()
618
+ if (!botId || !secret) {
619
+ console.warn(
620
+ '[im-bridge] 跳过启动: 缺少 botId/secret。请在 profile cordis.patch.yml 或 Settings → 插件配置中填写后重启。',
621
+ )
622
+ return
623
+ }
624
+
625
+ const wecomCli = cfg().wecomCli
626
+ if (wecomCli.enabled) {
627
+ const skillsDir = resolveSkillsDir(wecomCli.skillsDir, cfg().workspace)
628
+ officeSkills = loadWecomSkills(skillsDir).filter(skill => skill.name.startsWith('wecomcli-'))
629
+ const wecomcliCount = countWecomcliSkills(officeSkills)
630
+ if (wecomcliCount === 0) {
631
+ console.warn(
632
+ `[im-bridge] 未找到 wecomcli-* skills(${skillsDir})。${skillsInstallHint(skillsDir)}`,
633
+ )
634
+ } else {
635
+ console.log(
636
+ `[im-bridge] 已从 ${skillsDir} 加载 ${String(wecomcliCount)} 个 wecomcli-*,将在办公单聊 Agent 上注册`,
637
+ )
638
+ }
639
+ const leakCount = countWorkspaceWecomcliLeaks(cfg().workspace)
640
+ if (leakCount > 0) {
641
+ console.warn(`[im-bridge] ${WORKSPACE_WECOMCLI_LEAK_MESSAGE}(${String(leakCount)})`)
642
+ }
643
+ if (!shouldEnableWecomCli(true, wecomCli.allowFrom)) {
644
+ console.warn(`[im-bridge] ${ALLOW_FROM_REQUIRED_MESSAGE}`)
645
+ } else {
646
+ wecomCliReady = true
647
+ const binJs = resolveWecomBin()
648
+ if (binJs === undefined) {
649
+ console.warn('[im-bridge] 未找到 @wecom/cli 二进制,办公命令不可用。请确认插件依赖已安装。')
650
+ } else {
651
+ const configDir = ensureConfigDir(resolveConfigDir(wecomCli.configDir, cfg().workspace))
652
+ officeCli = { binJs, configDir }
653
+ console.log(`[im-bridge] wecom-cli 凭证目录: ${configDir}`)
654
+ const pathResult = ensureOnPath()
655
+ console.log(
656
+ `[im-bridge] PATH 上的 wecom-cli 已改为拒绝执行: ${pathResult.shimDir}${pathResult.shadowed ? '(已遮蔽另一个 wecom-cli)' : ''}`,
657
+ )
658
+ let status = await probeAuth(binJs, configDir)
659
+ if (status === 'unauthorized') {
660
+ if ((await trySeedAuth(binJs, cfg().botId, cfg().secret, configDir)) === undefined) {
661
+ status = await probeAuth(binJs, configDir)
662
+ }
663
+ }
664
+ if (status === 'authorized') {
665
+ console.log('[im-bridge] wecom-cli 已授权')
666
+ } else if (status === 'unauthorized') {
667
+ console.warn(
668
+ `[im-bridge] wecom-cli 未能用 botId/secret 完成授权。请在 host 上执行 ${authInitHint(configDir)}(输入同一套密钥,不要全局安装)。`,
669
+ )
670
+ } else {
671
+ console.warn('[im-bridge] wecom-cli auth show 失败。')
672
+ }
673
+ }
674
+ }
675
+ }
676
+
677
+ /**
678
+ * Add the channel prefix to a title the Host generated. `session/event`
679
+ * runs inside the append publication window, which refuses a reentrant
680
+ * append, so the prefixed title goes out in a microtask and re-reads the
681
+ * log first: an already prefixed tail (including the one this appends)
682
+ * stops the chain.
683
+ */
684
+ function prefixWecomTitle(session: TitledSession, st: ChatState): void {
685
+ const kind = st.kind
686
+ if (kind === undefined) return
687
+ queueMicrotask(() => {
688
+ const data = latestTitleData(session)
689
+ if (data === undefined) return
690
+ // An explicit GUI rename is pinned on purpose; only automatic titles get labelled.
691
+ if (data.source?.kind === 'user') return
692
+ const raw = typeof data.title === 'string' ? data.title : ''
693
+ const next = wecomDisplayTitle(kind, raw)
694
+ if (next === raw) return
695
+ const messageSeqs = Array.isArray(data.messageSeqs)
696
+ ? data.messageSeqs.filter((seq) => typeof seq === 'number')
697
+ : []
698
+ // A non-user title must cite at least one user/message seq, or the
699
+ // session-title invariant rejects the append.
700
+ if (messageSeqs.length === 0) return
701
+ try {
702
+ session.append('session/title', {
703
+ title: next,
704
+ messageSeqs,
705
+ source: data.source ?? { kind: 'fallback' },
706
+ })
707
+ } catch (error) {
708
+ const message = error instanceof Error ? error.message : String(error)
709
+ console.error(`[im-bridge] 加标题前缀失败: ${message}`)
710
+ }
711
+ })
712
+ }
713
+
714
+ /**
715
+ * Sessions the GUI archived. Archiving is the workspace registry's global
716
+ * set, not session state, and it has no inverse: an archived session is
717
+ * invisible in every list, so this plugin must stop writing to it.
718
+ */
719
+ function archivedSessions(): ReadonlySet<string> {
720
+ const registry = ctx.get('workspaceRegistry') as WorkspaceRegistry | undefined
721
+ if (registry === undefined) return new Set()
722
+ try {
723
+ return new Set(registry.archivedSessionIds)
724
+ } catch (error) {
725
+ // The getter throws until the registry finishes its own startup.
726
+ const message = error instanceof Error ? error.message : String(error)
727
+ console.warn(`[im-bridge] 读归档会话失败: ${message}`)
728
+ return new Set()
729
+ }
730
+ }
731
+
732
+ async function unpinLegacyWecomTitle(agent: LiveAgent): Promise<void> {
733
+ const titles = ctx.get('sessionTitle') as SessionTitleService | undefined
734
+ if (titles === undefined) return
735
+ try {
736
+ const snapshot = titles.get(agent.session)
737
+ if (snapshot?.source?.kind !== 'user') return
738
+ if (!isLegacyPinnedWecomTitle(snapshot.title)) return
739
+ await titles.refresh(agent.session)
740
+ } catch (error) {
741
+ const message = error instanceof Error ? error.message : String(error)
742
+ console.error(`[im-bridge] 解开旧标题失败: ${message}`)
743
+ }
744
+ }
745
+
746
+ /**
747
+ * Register persona + wecom prompt on this Agent's layer, synchronously.
748
+ * Must use `agentCtx.systemPrompt` (caller-bound). `inject()` without await
749
+ * yields a microtask and can publish the Agent before the section exists;
750
+ * `adopt` never re-runs setup.
751
+ */
752
+ function installWecomChannel(agentCtx: Context, st: ChatState): void {
753
+ if (st.wecomPromptInstalled) return
754
+ const host = agentCtx as Context & PromptHost
755
+ if (host.get('systemPrompt') === undefined || host.systemPrompt === undefined) {
756
+ console.warn('[im-bridge] 当前 Agent 没有 systemPrompt,企微提示词未注入')
757
+ return
758
+ }
759
+ const prompt = host.systemPrompt
760
+ try {
761
+ prompt.section({
762
+ name: 'deployment:persona',
763
+ order: 0,
764
+ text: () => resolvePersona(cfg(), settings),
765
+ })
766
+ } catch (error) {
767
+ const message = error instanceof Error ? error.message : String(error)
768
+ console.warn(`[im-bridge] 人设段未覆盖(可能已由 preset 注册): ${message}`)
769
+ }
770
+ try {
771
+ prompt.section({
772
+ name: 'channel:wecom-cli',
773
+ order: 1,
774
+ // Re-read per request: a group Agent is shared, and its sender —
775
+ // hence `st.office` — changes message to message. Only an office
776
+ // 1:1 gets the tool, so only it may get the office prompt.
777
+ // Always inject: WeCom has no GUI even when wecomCli is off.
778
+ text: () => wecomCliReady && shouldInjectWecomOfficeSkills(st.kind, st.office)
779
+ ? WECOM_CLI_PROMPT
780
+ : WECOM_CLI_NO_OFFICE_PROMPT,
781
+ })
782
+ console.log(
783
+ `[im-bridge] 已注入企微提示词 office=${String(st.office)} kind=${st.kind ?? '?'}`,
784
+ )
785
+ } catch (error) {
786
+ const message = error instanceof Error ? error.message : String(error)
787
+ console.error(`[im-bridge] 注入企微提示词失败: ${message}`)
788
+ }
789
+ st.wecomPromptInstalled = true
790
+ }
791
+
792
+ async function ensureAgent(ref: WecomSessionRef): Promise<ChatState> {
793
+ let st = chats.get(ref.key)
794
+ if (st === undefined) {
795
+ st = emptyChatState()
796
+ chats.set(ref.key, st)
797
+ }
798
+ st.kind = ref.kind
799
+ if (st.agent !== undefined) {
800
+ if (st.sessionId === undefined || !archivedSessions().has(st.sessionId)) {
801
+ // Office access is decided per inbound sender, and skills can arrive
802
+ // from the Settings button after this Agent was created.
803
+ installOfficeLayer(st.agent.ctx, st)
804
+ return st
805
+ }
806
+ console.log(`[im-bridge] 会话 ${st.sessionId} 已归档,改开新会话`)
807
+ st.agent = undefined
808
+ st.sessionId = undefined
809
+ st.wecomPromptInstalled = false
810
+ st.officeSkillsRegistered = false
811
+ st.officeToolRegistered = false
812
+ }
813
+
814
+ const persistence = ctx.get('sessionPersistence') as SessionPersistence | undefined
815
+ const headers = persistence === undefined ? [] : await persistence.list()
816
+ const plan = planWecomBind(ref.key, {
817
+ live: (id) => agents.get(SessionId(id)) !== undefined,
818
+ stored: new Set(headers.map((header) => header.id)),
819
+ archived: archivedSessions(),
820
+ })
821
+ const sessionId = SessionId(plan.sessionId)
822
+ const stored = headers.find((header) => header.id === sessionId)
823
+
824
+ const attach = (agent: LiveAgent, how: 'adopt' | 'resume' | 'create'): void => {
825
+ st.agent = agent
826
+ st.sessionId = sessionId
827
+ st.kind = ref.kind
828
+ if (agent.ctx === undefined) {
829
+ console.warn('[im-bridge] Agent 没有 ctx,无法注入企微提示词')
830
+ } else {
831
+ installWecomChannel(agent.ctx, st)
832
+ installOfficeLayer(agent.ctx, st)
833
+ }
834
+ void unpinLegacyWecomTitle(agent)
835
+ const cwd = agent.session.header?.cwd ?? stored?.cwd
836
+ if (cwd !== undefined && cwd !== cfg().workspace) {
837
+ console.warn(
838
+ `[im-bridge] 会话 ${sessionId} 仍使用存档目录 ${cwd},当前 workspace=${cfg().workspace}`,
839
+ )
840
+ }
841
+ const epoch = plan.epoch > 1 ? ` 第${String(plan.epoch)}段` : ''
842
+ console.log(
843
+ `[im-bridge] 为 ${ref.key} ${how}会话 ${sessionId}${epoch} userid=${ref.sender} chattype=${ref.kind} chatid=${ref.chatid ?? ''}`,
844
+ )
845
+ }
846
+
847
+ const live = agents.get(sessionId)
848
+ if (plan.bind === 'adopt' && live !== undefined) {
849
+ attach(live, 'adopt')
850
+ return st
851
+ }
852
+
853
+ const selection = resolveSelection(cfg(), defaultModel)
854
+ const presets = ctx.get('agentPresets') as AgentPresets | undefined
855
+ const presetId = (plan.bind === 'resume' && stored?.agentPreset) ? stored.agentPreset : cfg().agentPreset
856
+ let resolvedId = presetId
857
+ if (presets !== undefined) {
858
+ resolvedId = (await presets.resolve(presetId)).id
859
+ }
860
+ const setup = async (agentCtx: Context): Promise<void> => {
861
+ const selected = { current: selection, assembled: undefined }
862
+ installModelSelection(agentCtx, selected)
863
+ if (presets !== undefined) await presets.mount(agentCtx, resolvedId)
864
+ installWecomChannel(agentCtx, st)
865
+ }
866
+ const agentOptions = { provider: selection.provider, model: selection.model }
867
+
868
+ try {
869
+ if (plan.bind === 'resume') {
870
+ const { agent } = await agents.resume({
871
+ resumeSessionId: sessionId,
872
+ agentOptions,
873
+ setup,
874
+ })
875
+ attach(agent, 'resume')
876
+ return st
877
+ }
878
+ const { agent } = await agents.create({
879
+ sessionId,
880
+ meta: { cwd: cfg().workspace, agentPreset: resolvedId },
881
+ agentOptions,
882
+ setup,
883
+ })
884
+ attach(agent, 'create')
885
+ return st
886
+ } catch (error) {
887
+ const raced = agents.get(sessionId)
888
+ if (raced !== undefined) {
889
+ attach(raced, 'adopt')
890
+ return st
891
+ }
892
+ throw error
893
+ }
894
+ }
895
+
896
+ ctx.on('session/event', (session: TitledSession, event: LoggedEvent) => {
897
+ const thinking = cfg().thinking
898
+ const prefix = thinking?.activityPrefix ?? DEFAULT_THINKING.activityPrefix
899
+ const flashMs = Number.isFinite(thinking?.intervalMs) && thinking.intervalMs > 0
900
+ ? thinking.intervalMs
901
+ : DEFAULT_THINKING.intervalMs
902
+ for (const st of chats.values()) {
903
+ if (st.sessionId !== session.id) continue
904
+ if (event.type === 'session/title') {
905
+ prefixWecomTitle(session, st)
906
+ continue
907
+ }
908
+ if (event.type === 'assistant/chunk') {
909
+ const next = streamPhaseFromChunk((event.data as ChunkData).chunk)
910
+ if (next !== null) st.modelStreamPhase = next
911
+ continue
912
+ }
913
+ if (event.type === 'tool/call') {
914
+ const toolName = (event.data as ToolCallData).name ?? ''
915
+ const callId = (event.data as ToolCallData).callId
916
+ if (callId !== undefined) st.lastToolByCallId.set(callId, toolName)
917
+ st.activityClearAt = 0
918
+ st.lastActivity = `${prefix}${labelTool(toolName, thinking)}`
919
+ return
920
+ }
921
+ if (event.type === 'tool/result') {
922
+ const data = event.data as ToolResultData
923
+ const callId = data.message?.source?.callId
924
+ const rawName = (callId !== undefined && st.lastToolByCallId.get(callId))
925
+ || [...st.lastToolByCallId.values()].at(-1)
926
+ || ''
927
+ if (callId !== undefined) st.lastToolByCallId.delete(callId)
928
+ const label = labelTool(rawName || '工具', thinking)
929
+ const failed = data.error !== undefined
930
+ st.lastActivity = failed ? `❌ ${label} 失败` : `✅ ${label} 完成`
931
+ st.activityClearAt = Date.now() + flashMs
932
+ st.modelStreamPhase = 'idle'
933
+ }
934
+ }
935
+ })
936
+
937
+ // The SDK's own WSClient types its listeners per event name; this bridge
938
+ // reads frames as JSON and needs one permissive `on`, so the narrowing goes
939
+ // through unknown rather than widening every handler to the SDK's maps.
940
+ const { default: AiBot, generateReqId } = await import('@wecom/aibot-node-sdk') as unknown as {
941
+ default: { WSClient: new (options: { botId: string; secret: string }) => WecomClient }
942
+ generateReqId: (kind: string) => string
943
+ }
944
+
945
+ async function handle(frame: WecomFrame, ref: WecomSessionRef, content: string): Promise<void> {
946
+ let st = chats.get(ref.key)
947
+ if (st === undefined) {
948
+ st = emptyChatState()
949
+ chats.set(ref.key, st)
950
+ }
951
+ st.office = senderHasOfficeAccess(cfg().wecomCli.allowFrom, ref.sender)
952
+ st = await ensureAgent(ref)
953
+ const startedAt = Date.now()
954
+ const streamId = generateReqId('stream')
955
+ let stopThinking: (() => void) | null = null
956
+ st.lastActivity = ''
957
+ st.activityClearAt = 0
958
+ st.lastToolByCallId.clear()
959
+ st.modelStreamPhase = 'idle'
960
+ st.streamStatusTick = 0
961
+ try {
962
+ await ws.replyStream(frame, streamId, cfg().startHint, false)
963
+ stopThinking = startThinking(
964
+ ws, frame, streamId, startedAt, cfg().agentTimeoutSec,
965
+ () => {
966
+ if (st.activityClearAt > 0 && Date.now() >= st.activityClearAt) {
967
+ st.lastActivity = ''
968
+ st.activityClearAt = 0
969
+ }
970
+ if (st.lastActivity) return st.lastActivity
971
+ const thinking = cfg().thinking
972
+ const tick = st.streamStatusTick++
973
+ if (st.modelStreamPhase === 'reasoning') {
974
+ return pickStatusLine(
975
+ thinking?.reasoningStatus,
976
+ DEFAULT_THINKING.reasoningStatus,
977
+ tick,
978
+ )
979
+ }
980
+ if (st.modelStreamPhase === 'outputting') {
981
+ return pickStatusLine(
982
+ thinking?.outputStatus,
983
+ DEFAULT_THINKING.outputStatus,
984
+ tick,
985
+ )
986
+ }
987
+ return ''
988
+ },
989
+ cfg().thinking,
990
+ () => (st.lastActivity ? 'idle' : st.modelStreamPhase),
991
+ )
992
+ } catch (error) {
993
+ const message = error instanceof Error ? error.message : String(error)
994
+ console.error(`[im-bridge] 占位回复失败: ${message}`)
995
+ }
996
+ try {
997
+ if (st.agent === undefined) throw new Error('im-bridge: chat agent missing')
998
+ await st.agent.whenIdle()
999
+ const firstSeq = st.agent.session.seq
1000
+ st.agent.followup(createUserMessage({
1001
+ content: [{ type: 'text', text: content }],
1002
+ source: { kind: 'user' },
1003
+ }))
1004
+ await st.agent.whenIdle()
1005
+ await sessions.flush(st.agent.session)
1006
+ const outcome = summarize(st.agent.session.snapshotEvents(), firstSeq)
1007
+ if (stopThinking) stopThinking()
1008
+ const ms = Date.now() - startedAt
1009
+ const body = outcome.text || '(agent 无输出)'
1010
+ const collected = collectReplyPngs(body, cfg().workspace)
1011
+ for (const reason of collected.skipped) {
1012
+ console.warn(`[im-bridge] 跳过图片: ${reason}`)
1013
+ }
1014
+ let reply = truncate(body, (cfg().maxReplyBytes || 20000) - 200) + footerOf(ms)
1015
+ if (collected.skipped.length > 0) {
1016
+ reply = truncate(
1017
+ `${reply}\n⚠️ ${collected.skipped.length} 张图片未发送(过大、越权或不存在)`,
1018
+ cfg().maxReplyBytes || 20000,
1019
+ )
1020
+ }
1021
+ console.log(`[im-bridge] ${ref.key} 完成 (${Buffer.byteLength(reply, 'utf8')}B, ${fmtDuration(ms)})`)
1022
+ await sendFinal(ws, frame, streamId, reply)
1023
+ if (collected.images.length > 0) {
1024
+ await sendCollectedPngs(ws, resolveChatId(frame, ref.sender), collected.images)
1025
+ }
1026
+ } catch (error) {
1027
+ if (stopThinking) stopThinking()
1028
+ const ms = Date.now() - startedAt
1029
+ const message = error instanceof Error ? error.message : String(error)
1030
+ console.error(`[im-bridge] agent 失败: ${message}`)
1031
+ try {
1032
+ await sendFinal(ws, frame, streamId, `处理失败: ${truncate(message, 400)}\n\n---\n❌ 耗时 ${fmtDuration(ms)}`)
1033
+ } catch (retryError) {
1034
+ const retryMessage = retryError instanceof Error ? retryError.message : String(retryError)
1035
+ console.error(`[im-bridge] 错误回复也失败: ${retryMessage}`)
1036
+ }
1037
+ }
1038
+ }
1039
+
1040
+ const ws = new AiBot.WSClient({ botId, secret })
1041
+
1042
+ ws.on('connected', (() => console.log('[im-bridge] WebSocket 已连接')) as (...args: never[]) => void)
1043
+ ws.on('authenticated', (() => console.log('[im-bridge] 认证成功, 等待消息...')) as (...args: never[]) => void)
1044
+ ws.on('disconnected', ((reason: string) => console.log(`[im-bridge] 断开: ${reason}`)) as (...args: never[]) => void)
1045
+ ws.on('reconnecting', ((n: number) => console.log(`[im-bridge] 第 ${n} 次重连...`)) as (...args: never[]) => void)
1046
+ ws.on('error', ((error: Error) => console.error(`[im-bridge] 错误: ${error.message}`)) as (...args: never[]) => void)
1047
+
1048
+ ws.on('message.text', ((frame: WecomFrame) => {
1049
+ const inbound = (frame.body?.text?.content || '').trim()
1050
+ if (!inbound) return
1051
+ const content = stripBotMention(inbound)
1052
+ let ref: WecomSessionRef
1053
+ try {
1054
+ ref = resolveWecomSession(frame)
1055
+ } catch (error) {
1056
+ if (error instanceof WecomSessionReject) {
1057
+ console.error(`[im-bridge] ${error.reply}`)
1058
+ void ws.replyStream(frame, generateReqId('stream'), error.reply, true).catch(() => {})
1059
+ return
1060
+ }
1061
+ throw error
1062
+ }
1063
+ if (cfg().allowFrom.length > 0 && !cfg().allowFrom.includes(ref.sender)) {
1064
+ void ws.replyStream(frame, generateReqId('stream'), cfg().deniedMessage, true).catch(() => {})
1065
+ return
1066
+ }
1067
+ console.log(
1068
+ `[im-bridge] 收到 key=${ref.key} userid=${ref.sender} chattype=${String(frame.body?.chattype ?? '')} chatid=${ref.chatid ?? ''}: ${content.slice(0, 100)}`,
1069
+ )
1070
+ const st = chats.get(ref.key) ?? emptyChatState()
1071
+ st.kind = ref.kind
1072
+ chats.set(ref.key, st)
1073
+ st.queue = st.queue
1074
+ .then(() => handle(frame, ref, content))
1075
+ .catch((error: unknown) => {
1076
+ const message = error instanceof Error ? error.message : String(error)
1077
+ console.error(`[im-bridge] 任务异常: ${message}`)
1078
+ })
1079
+ }) as (...args: never[]) => void)
1080
+
1081
+ ws.on('event.enter_chat', ((frame: WecomFrame) => {
1082
+ const sender = frame.body?.from?.userid || 'unknown'
1083
+ console.log(`[im-bridge] 用户 ${sender} 进入会话`)
1084
+ void ws.replyWelcome(frame, {
1085
+ msgtype: 'text',
1086
+ text: { content: cfg().welcomeMessage },
1087
+ }).catch((error: unknown) => {
1088
+ const message = error instanceof Error ? error.message : String(error)
1089
+ console.error(`[im-bridge] 欢迎语失败: ${message}`)
1090
+ })
1091
+ }) as (...args: never[]) => void)
1092
+
1093
+ ws.connect()
1094
+
1095
+ ctx.effect(() => () => {
1096
+ try { ws.close?.() } catch { /* already closed */ }
1097
+ }, 'im-bridge: wecom websocket')
1098
+ })()
1099
+ }