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