@mhfire/dsh-im-bridge 0.3.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
@@ -31,6 +31,36 @@ import {
31
31
  WecomSessionReject,
32
32
  type WecomSessionRef,
33
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'
34
64
  import {
35
65
  DEFAULT_THINKING,
36
66
  footerOf,
@@ -81,6 +111,25 @@ const ThinkingSchema = z.object({
81
111
  outputSpin: z.array(String).default(DEFAULT_THINKING.outputSpin),
82
112
  })
83
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
+
84
133
  /** Plugin config: secrets come from the profile patch or Settings. */
85
134
  export interface Config {
86
135
  botId: string
@@ -99,6 +148,7 @@ export interface Config {
99
148
  thinking: ThinkingConfig
100
149
  deniedMessage: string
101
150
  welcomeMessage: string
151
+ wecomCli: WecomCliConfig
102
152
  }
103
153
 
104
154
  /** Schemastery schema for the composition entry and settings namespace. */
@@ -119,6 +169,7 @@ export const Config: z<Config> = z.object({
119
169
  thinking: ThinkingSchema.default(DEFAULT_THINKING),
120
170
  deniedMessage: z.string().default('无权访问本服务'),
121
171
  welcomeMessage: z.string().default('👋 办公助手已就绪。直接发消息即可,例如查文件、整理文档、查资料或处理日常事务。'),
172
+ wecomCli: WecomCliSchema.default({ enabled: false, skillsDir: '', configDir: '', allowFrom: [] }),
122
173
  })
123
174
 
124
175
  interface LoggedEvent {
@@ -153,6 +204,7 @@ interface ChunkData {
153
204
  interface LiveAgent {
154
205
  whenIdle(): Promise<void>
155
206
  followup(message: unknown): void
207
+ ctx: Context
156
208
  session: {
157
209
  seq: number
158
210
  events: readonly LoggedEvent[]
@@ -164,6 +216,14 @@ interface ChatState {
164
216
  agent?: LiveAgent
165
217
  sessionId?: string
166
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
167
227
  queue: Promise<unknown>
168
228
  lastActivity: string
169
229
  activityClearAt: number
@@ -175,6 +235,10 @@ interface ChatState {
175
235
  /** Placeholder Map value so later messages on the same key share one queue. */
176
236
  function emptyChatState(): ChatState {
177
237
  return {
238
+ office: false,
239
+ wecomPromptInstalled: false,
240
+ officeSkillsRegistered: false,
241
+ officeToolRegistered: false,
178
242
  queue: Promise.resolve(),
179
243
  lastActivity: '',
180
244
  activityClearAt: 0,
@@ -184,6 +248,20 @@ function emptyChatState(): ChatState {
184
248
  }
185
249
  }
186
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
+
187
265
  interface DefaultModel {
188
266
  currentSelection(): { provider: string; model: string; reasoningEffort?: string }
189
267
  }
@@ -252,6 +330,16 @@ interface AgentPresets {
252
330
  mount(agentCtx: Context, id: string): Promise<unknown>
253
331
  }
254
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
+
255
343
  interface SettingsReader {
256
344
  get(ns: ReturnType<typeof settingsNamespace>): unknown
257
345
  }
@@ -412,6 +500,79 @@ export function apply(ctx: Context, config: Config): void {
412
500
  })
413
501
  const cfg = (): Config => source()
414
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
+
415
576
  void (async () => {
416
577
  const loader = ctx.get('loader') as LoaderTree | undefined
417
578
  await loader?.await()
@@ -423,7 +584,57 @@ export function apply(ctx: Context, config: Config): void {
423
584
  return
424
585
  }
425
586
 
426
- const chats = new Map<string, ChatState>()
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
+ }
427
638
 
428
639
  /**
429
640
  * Add the channel prefix to a title the Host generated. `session/event`
@@ -494,6 +705,52 @@ export function apply(ctx: Context, config: Config): void {
494
705
  }
495
706
  }
496
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
+
497
754
  async function ensureAgent(ref: WecomSessionRef): Promise<ChatState> {
498
755
  let st = chats.get(ref.key)
499
756
  if (st === undefined) {
@@ -502,10 +759,18 @@ export function apply(ctx: Context, config: Config): void {
502
759
  }
503
760
  st.kind = ref.kind
504
761
  if (st.agent !== undefined) {
505
- if (st.sessionId === undefined || !archivedSessions().has(st.sessionId)) return st
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
+ }
506
768
  console.log(`[im-bridge] 会话 ${st.sessionId} 已归档,改开新会话`)
507
769
  st.agent = undefined
508
770
  st.sessionId = undefined
771
+ st.wecomPromptInstalled = false
772
+ st.officeSkillsRegistered = false
773
+ st.officeToolRegistered = false
509
774
  }
510
775
 
511
776
  const persistence = ctx.get('sessionPersistence') as SessionPersistence | undefined
@@ -522,6 +787,12 @@ export function apply(ctx: Context, config: Config): void {
522
787
  st.agent = agent
523
788
  st.sessionId = sessionId
524
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
+ }
525
796
  void unpinLegacyWecomTitle(agent)
526
797
  const cwd = agent.session.header?.cwd ?? stored?.cwd
527
798
  if (cwd !== undefined && cwd !== cfg().workspace) {
@@ -552,13 +823,7 @@ export function apply(ctx: Context, config: Config): void {
552
823
  const selected = { current: selection, assembled: undefined }
553
824
  installModelSelection(agentCtx, selected)
554
825
  if (presets !== undefined) await presets.mount(agentCtx, resolvedId)
555
- agentCtx.inject(['systemPrompt'], (promptCtx) => {
556
- promptCtx.systemPrompt.section({
557
- name: 'deployment:persona',
558
- order: 0,
559
- text: () => resolvePersona(cfg(), settings),
560
- })
561
- })
826
+ installWecomChannel(agentCtx, st)
562
827
  }
563
828
  const agentOptions = { provider: selection.provider, model: selection.model }
564
829
 
@@ -637,7 +902,13 @@ export function apply(ctx: Context, config: Config): void {
637
902
  }
638
903
 
639
904
  async function handle(frame: WecomFrame, ref: WecomSessionRef, content: string): Promise<void> {
640
- const st = await ensureAgent(ref)
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)
641
912
  const startedAt = Date.now()
642
913
  const streamId = generateReqId('stream')
643
914
  let stopThinking: (() => void) | null = null
@@ -163,6 +163,7 @@ export function planWecomBind(key: string, state: WecomBindState): WecomBindPlan
163
163
  if (state.stored.has(sessionId)) return { sessionId, bind: 'resume', epoch }
164
164
  return { sessionId, bind: 'create', epoch }
165
165
  }
166
+
166
167
  throw new Error(`im-bridge: no free session epoch for ${key} within ${String(limit)} candidates`)
167
168
  }
168
169