@mhfire/dsh-im-bridge 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +64 -4
- package/README.md +64 -4
- package/lib/index.js +528 -68
- package/package.json +4 -3
- package/persona.default.en.md +1 -0
- package/persona.default.md +2 -1
- package/persona.example.md +2 -1
- package/src/index.ts +307 -62
- package/src/reply-images.ts +228 -0
- package/src/session-key.ts +197 -0
- package/tsconfig.json +1 -1
package/lib/index.js
CHANGED
|
@@ -1,12 +1,332 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
1
|
+
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
4
3
|
import { fileURLToPath } from "node:url";
|
|
5
4
|
import z from "@deepseek-ai/schemastery";
|
|
6
5
|
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
7
6
|
import { SessionId } from "@deepseek-ai/dsh-session";
|
|
8
7
|
import { installModelSelection } from "@deepseek-ai/dsh-agent";
|
|
9
8
|
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
9
|
+
import { createHash } from "node:crypto";
|
|
10
|
+
/** PNG signature (first 8 bytes). */
|
|
11
|
+
const PNG_MAGIC = Buffer.from([
|
|
12
|
+
137,
|
|
13
|
+
80,
|
|
14
|
+
78,
|
|
15
|
+
71,
|
|
16
|
+
13,
|
|
17
|
+
10,
|
|
18
|
+
26,
|
|
19
|
+
10
|
|
20
|
+
]);
|
|
21
|
+
/** `` or `[text](url)`, capturing the destination. */
|
|
22
|
+
const MARKDOWN_LINK = /!?\[(?:[^\]]*?)\]\(\s*(<[^>]+>|[^\s)]+)(?:\s+(?:"[^"]*"|'[^']*'))?\s*\)/g;
|
|
23
|
+
/**
|
|
24
|
+
* Pull destination URLs from Markdown images and links, in order.
|
|
25
|
+
* @param text - assistant reply body.
|
|
26
|
+
* @returns raw destinations (angle brackets already stripped).
|
|
27
|
+
*/
|
|
28
|
+
function extractMarkdownUrls(text) {
|
|
29
|
+
const urls = [];
|
|
30
|
+
for (const match of text.matchAll(MARKDOWN_LINK)) {
|
|
31
|
+
const raw = match[1];
|
|
32
|
+
if (raw === void 0) continue;
|
|
33
|
+
const dest = raw.startsWith("<") && raw.endsWith(">") ? raw.slice(1, -1) : raw;
|
|
34
|
+
if (dest !== "") urls.push(dest);
|
|
35
|
+
}
|
|
36
|
+
return urls;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Chat id for `sendMediaMessage`: group `chatid`, otherwise the sender userid.
|
|
40
|
+
* @param frame - inbound WeCom frame.
|
|
41
|
+
* @param sender - userid used for 1:1 chats.
|
|
42
|
+
*/
|
|
43
|
+
function resolveChatId(frame, sender) {
|
|
44
|
+
const chattype = frame.body?.chattype;
|
|
45
|
+
const chatid = frame.body?.chatid;
|
|
46
|
+
if ((chattype === "group" || chattype === 2 || chattype === "2") && chatid) return chatid;
|
|
47
|
+
if (!(chattype === "single" || chattype === 1 || chattype === "1") && chatid) return chatid;
|
|
48
|
+
return sender;
|
|
49
|
+
}
|
|
50
|
+
function stripQueryHash(url) {
|
|
51
|
+
const noHash = url.split("#")[0] ?? url;
|
|
52
|
+
return noHash.split("?")[0] ?? noHash;
|
|
53
|
+
}
|
|
54
|
+
function isRemote(url) {
|
|
55
|
+
return /^(?:https?:|data:|mailto:|file:)/i.test(url);
|
|
56
|
+
}
|
|
57
|
+
function isPngPath(url) {
|
|
58
|
+
return /\.png$/i.test(url);
|
|
59
|
+
}
|
|
60
|
+
function containedIn(root, target) {
|
|
61
|
+
const rel = relative(root, target);
|
|
62
|
+
if (rel === "") return false;
|
|
63
|
+
if (isAbsolute(rel)) return false;
|
|
64
|
+
if (rel === ".." || rel.startsWith(`..${sep}`)) return false;
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
function tryRealpath(path) {
|
|
68
|
+
try {
|
|
69
|
+
return realpathSync(path);
|
|
70
|
+
} catch {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Resolve Markdown destinations to workspace PNG files.
|
|
76
|
+
* @param text - untruncated assistant reply.
|
|
77
|
+
* @param workspace - Agent cwd / plugin `workspace`.
|
|
78
|
+
* @param options - optional size/count caps.
|
|
79
|
+
*/
|
|
80
|
+
function collectReplyPngs(text, workspace, options) {
|
|
81
|
+
const maxBytes = options?.maxBytes ?? 10485760;
|
|
82
|
+
const maxCount = options?.maxCount ?? 10;
|
|
83
|
+
const images = [];
|
|
84
|
+
const skipped = [];
|
|
85
|
+
const seen = /* @__PURE__ */ new Set();
|
|
86
|
+
const root = tryRealpath(workspace);
|
|
87
|
+
if (root === void 0) {
|
|
88
|
+
skipped.push(`工作区不可读: ${workspace}`);
|
|
89
|
+
return {
|
|
90
|
+
images,
|
|
91
|
+
skipped
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
for (const raw of extractMarkdownUrls(text)) {
|
|
95
|
+
const url = stripQueryHash(raw.trim());
|
|
96
|
+
if (url === "" || isRemote(url)) continue;
|
|
97
|
+
if (!isPngPath(url)) continue;
|
|
98
|
+
if (images.length >= maxCount) {
|
|
99
|
+
skipped.push(`超过 ${maxCount} 张上限,忽略后续图片`);
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
const real = tryRealpath(resolve(root, url));
|
|
103
|
+
if (real === void 0) {
|
|
104
|
+
skipped.push(`文件不存在: ${url}`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (!containedIn(root, real)) {
|
|
108
|
+
skipped.push(`越出工作区: ${url}`);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (seen.has(real)) continue;
|
|
112
|
+
let size;
|
|
113
|
+
try {
|
|
114
|
+
size = statSync(real).size;
|
|
115
|
+
} catch {
|
|
116
|
+
skipped.push(`无法读取: ${url}`);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (size > maxBytes) {
|
|
120
|
+
skipped.push(`超过 ${maxBytes} 字节: ${url}`);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
let buffer;
|
|
124
|
+
try {
|
|
125
|
+
buffer = readFileSync(real);
|
|
126
|
+
} catch {
|
|
127
|
+
skipped.push(`无法读取: ${url}`);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (buffer.subarray(0, PNG_MAGIC.length).compare(PNG_MAGIC) !== 0) {
|
|
131
|
+
skipped.push(`不是 PNG: ${url}`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
seen.add(real);
|
|
135
|
+
images.push({
|
|
136
|
+
absPath: real,
|
|
137
|
+
filename: basename(real),
|
|
138
|
+
buffer
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
images,
|
|
143
|
+
skipped
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function mediaIdOf(result) {
|
|
147
|
+
const id = result.media_id ?? result.mediaId;
|
|
148
|
+
return id !== void 0 && id !== "" ? id : void 0;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Upload each PNG then push it as a WeCom image message.
|
|
152
|
+
* Failures are logged and do not abort the remaining files.
|
|
153
|
+
* @param ws - WeCom client.
|
|
154
|
+
* @param chatid - 1:1 userid or group chatid.
|
|
155
|
+
* @param images - files from {@link collectReplyPngs}.
|
|
156
|
+
* @returns counts of sent vs failed filenames.
|
|
157
|
+
*/
|
|
158
|
+
async function sendCollectedPngs(ws, chatid, images) {
|
|
159
|
+
const failed = [];
|
|
160
|
+
let sent = 0;
|
|
161
|
+
for (const image of images) try {
|
|
162
|
+
const mediaId = mediaIdOf(await ws.uploadMedia(image.buffer, {
|
|
163
|
+
type: "image",
|
|
164
|
+
filename: image.filename
|
|
165
|
+
}));
|
|
166
|
+
if (mediaId === void 0) {
|
|
167
|
+
failed.push(image.filename);
|
|
168
|
+
console.error(`[im-bridge] 上传成功但无 media_id: ${image.filename}`);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
await ws.sendMediaMessage(chatid, "image", mediaId);
|
|
172
|
+
sent++;
|
|
173
|
+
console.log(`[im-bridge] 已发送图片 ${image.filename} (${image.buffer.length}B)`);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
failed.push(image.filename);
|
|
176
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
177
|
+
console.error(`[im-bridge] 发送图片失败 ${image.filename}: ${message}`);
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
sent,
|
|
181
|
+
failed
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
//#endregion
|
|
185
|
+
//#region src/session-key.ts
|
|
186
|
+
/**
|
|
187
|
+
* Route WeCom inbound frames onto one DSH session per chat window:
|
|
188
|
+
* 1:1 by userid, groups by chatid.
|
|
189
|
+
*/
|
|
190
|
+
/** Single-chat frame with no userid — caller must refuse, not merge. */
|
|
191
|
+
var WecomSessionReject = class extends Error {
|
|
192
|
+
/** Short WeCom reply when the frame cannot be routed. */
|
|
193
|
+
reply;
|
|
194
|
+
/**
|
|
195
|
+
* @param reply - text sent back on the inbound frame.
|
|
196
|
+
*/
|
|
197
|
+
constructor(reply) {
|
|
198
|
+
super(reply);
|
|
199
|
+
this.name = "WecomSessionReject";
|
|
200
|
+
this.reply = reply;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
/**
|
|
204
|
+
* Sender userid from official `from.userid`, then `body.userid`.
|
|
205
|
+
* Does not read `sender` (not on the SDK message type).
|
|
206
|
+
*/
|
|
207
|
+
function senderUserid(frame) {
|
|
208
|
+
const from = frame.body?.from?.userid?.trim();
|
|
209
|
+
if (from) return from;
|
|
210
|
+
const body = frame.body?.userid?.trim();
|
|
211
|
+
if (body) return body;
|
|
212
|
+
return "";
|
|
213
|
+
}
|
|
214
|
+
function isGroupChat(chattype, chatid) {
|
|
215
|
+
if (chattype === "group" || chattype === 2 || chattype === "2") return true;
|
|
216
|
+
if (chatid !== "" && chattype !== "single" && chattype !== 1 && chattype !== "1") return true;
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
/** GUI channel prefix by window kind (no userid / chatid). */
|
|
220
|
+
const WECOM_TITLE_PREFIX = {
|
|
221
|
+
single: "企微·私聊",
|
|
222
|
+
group: "企微·群"
|
|
223
|
+
};
|
|
224
|
+
/** Leading `企微·` / previously used `企业微信·` channel labels. */
|
|
225
|
+
const CHANNEL_PREFIX = /^(?:企业微信|企微)·(?:私聊|群)\s*/u;
|
|
226
|
+
/**
|
|
227
|
+
* Sidebar title: channel kind plus first-prompt text, never an id.
|
|
228
|
+
* @param kind - {@link WecomSessionRef.kind}.
|
|
229
|
+
* @param raw - automatic or previously prefixed title.
|
|
230
|
+
*/
|
|
231
|
+
function wecomDisplayTitle(kind, raw) {
|
|
232
|
+
const prefix = WECOM_TITLE_PREFIX[kind];
|
|
233
|
+
const stripped = raw.replace(CHANNEL_PREFIX, "").trim();
|
|
234
|
+
return stripped === "" ? prefix : `${prefix} ${stripped}`;
|
|
235
|
+
}
|
|
236
|
+
/** One leading `@nickname` and its separator; JS `\s` covers WeCom's U+00A0 and U+2005. */
|
|
237
|
+
const LEADING_MENTION = /^@[^\s@]+(?:\s+|$)/u;
|
|
238
|
+
/**
|
|
239
|
+
* Drop the `@bot` mentions WeCom prepends to a group message, so the model
|
|
240
|
+
* input and the generated title both start at the actual request. Mentions
|
|
241
|
+
* later in the text stay; a message that is nothing but mentions is returned
|
|
242
|
+
* unchanged rather than emptied.
|
|
243
|
+
* @param text - trimmed inbound message text.
|
|
244
|
+
*/
|
|
245
|
+
function stripBotMention(text) {
|
|
246
|
+
let rest = text;
|
|
247
|
+
for (let match = LEADING_MENTION.exec(rest); match !== null; match = LEADING_MENTION.exec(rest)) rest = rest.slice(match[0].length);
|
|
248
|
+
const stripped = rest.trim();
|
|
249
|
+
return stripped === "" ? text : stripped;
|
|
250
|
+
}
|
|
251
|
+
/** Previously pinned `企微·私聊/群 <id>` titles from the id-based rename. */
|
|
252
|
+
function isLegacyPinnedWecomTitle(title) {
|
|
253
|
+
return /^(?:企微·(?:私聊|群))\s+[A-Za-z0-9][A-Za-z0-9_-]*$/.test(title.trim());
|
|
254
|
+
}
|
|
255
|
+
function singleRef(sender) {
|
|
256
|
+
return {
|
|
257
|
+
key: `single:${sender}`,
|
|
258
|
+
kind: "single",
|
|
259
|
+
sender
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Stable DSH session id for one epoch of a WeCom window (survives process
|
|
264
|
+
* restart). Epoch 1 carries no suffix, so ids minted before archiving support
|
|
265
|
+
* keep resolving to the same session.
|
|
266
|
+
* @param key - {@link WecomSessionRef.key}.
|
|
267
|
+
* @param epoch - 1-based session generation for this window.
|
|
268
|
+
*/
|
|
269
|
+
function wecomSessionId(key, epoch = 1) {
|
|
270
|
+
const hex = createHash("sha256").update(key).digest("hex").slice(0, 16);
|
|
271
|
+
return epoch <= 1 ? `wecom-${hex}` : `wecom-${hex}-${epoch}`;
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Bind a WeCom window to its first non-archived epoch. Archiving a session in
|
|
275
|
+
* the GUI hides it everywhere with no way back, so its epoch is skipped and
|
|
276
|
+
* the window continues in the next one; the chosen id adopts a live Agent,
|
|
277
|
+
* resumes a persisted session, or starts a new one.
|
|
278
|
+
* @param key - {@link WecomSessionRef.key}.
|
|
279
|
+
* @param state - live / persisted / archived knowledge per candidate id.
|
|
280
|
+
*/
|
|
281
|
+
function planWecomBind(key, state) {
|
|
282
|
+
const limit = state.archived.size + 1;
|
|
283
|
+
for (let epoch = 1; epoch <= limit; epoch += 1) {
|
|
284
|
+
const sessionId = wecomSessionId(key, epoch);
|
|
285
|
+
if (state.archived.has(sessionId)) continue;
|
|
286
|
+
if (state.live(sessionId)) return {
|
|
287
|
+
sessionId,
|
|
288
|
+
bind: "adopt",
|
|
289
|
+
epoch
|
|
290
|
+
};
|
|
291
|
+
if (state.stored.has(sessionId)) return {
|
|
292
|
+
sessionId,
|
|
293
|
+
bind: "resume",
|
|
294
|
+
epoch
|
|
295
|
+
};
|
|
296
|
+
return {
|
|
297
|
+
sessionId,
|
|
298
|
+
bind: "create",
|
|
299
|
+
epoch
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
throw new Error(`im-bridge: no free session epoch for ${key} within ${String(limit)} candidates`);
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Map one inbound frame to a chat-window session.
|
|
306
|
+
* Group without `chatid` falls back to 1:1 when userid is present.
|
|
307
|
+
* 1:1 without userid throws {@link WecomSessionReject}.
|
|
308
|
+
*/
|
|
309
|
+
function resolveWecomSession(frame) {
|
|
310
|
+
const sender = senderUserid(frame);
|
|
311
|
+
const chattype = frame.body?.chattype;
|
|
312
|
+
const chatid = frame.body?.chatid?.trim() ?? "";
|
|
313
|
+
if (isGroupChat(chattype, chatid)) {
|
|
314
|
+
if (chatid === "") {
|
|
315
|
+
if (sender === "") throw new WecomSessionReject("无法识别会话,已忽略");
|
|
316
|
+
console.error(`[im-bridge] 群消息缺少 chatid, 退回单聊 key from=${sender}`);
|
|
317
|
+
return singleRef(sender);
|
|
318
|
+
}
|
|
319
|
+
return {
|
|
320
|
+
key: `group:${chatid}`,
|
|
321
|
+
kind: "group",
|
|
322
|
+
sender,
|
|
323
|
+
chatid
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
if (sender === "") throw new WecomSessionReject("无法识别发送者,已忽略");
|
|
327
|
+
return singleRef(sender);
|
|
328
|
+
}
|
|
329
|
+
//#endregion
|
|
10
330
|
//#region src/wecom.ts
|
|
11
331
|
/** Default stream-animation copy; Config / cordis patch may override. */
|
|
12
332
|
const DEFAULT_THINKING = {
|
|
@@ -204,7 +524,7 @@ async function sendFinal(ws, frame, streamId, content) {
|
|
|
204
524
|
* dsh-im-bridge — WeCom AI bot ⇄ DSH Agent host plugin.
|
|
205
525
|
*
|
|
206
526
|
* Function-plugin shape (`name` / `inject` / `Config` / `apply`, no default
|
|
207
|
-
* export). Messages create in-process Agents so per-
|
|
527
|
+
* export). Messages create in-process Agents so per-chat-window sessions stay on
|
|
208
528
|
* the same Loader tree as the Web GUI. Settings register through
|
|
209
529
|
* `installSettingsSection`; live fields read `source()`, credentials still
|
|
210
530
|
* require a process restart to open the WebSocket.
|
|
@@ -263,6 +583,17 @@ const Config = z.object({
|
|
|
263
583
|
deniedMessage: z.string().default("无权访问本服务"),
|
|
264
584
|
welcomeMessage: z.string().default("👋 办公助手已就绪。直接发消息即可,例如查文件、整理文档、查资料或处理日常事务。")
|
|
265
585
|
});
|
|
586
|
+
/** Placeholder Map value so later messages on the same key share one queue. */
|
|
587
|
+
function emptyChatState() {
|
|
588
|
+
return {
|
|
589
|
+
queue: Promise.resolve(),
|
|
590
|
+
lastActivity: "",
|
|
591
|
+
activityClearAt: 0,
|
|
592
|
+
lastToolByCallId: /* @__PURE__ */ new Map(),
|
|
593
|
+
modelStreamPhase: "idle",
|
|
594
|
+
streamStatusTick: 0
|
|
595
|
+
};
|
|
596
|
+
}
|
|
266
597
|
/** Join assistant text from one turn starting at `firstSeq`. */
|
|
267
598
|
function summarize(events, firstSeq) {
|
|
268
599
|
let started = false;
|
|
@@ -286,6 +617,18 @@ function summarize(events, firstSeq) {
|
|
|
286
617
|
reason
|
|
287
618
|
};
|
|
288
619
|
}
|
|
620
|
+
/**
|
|
621
|
+
* Payload of the log's last `session/title` event — the title in force now.
|
|
622
|
+
* @param session - live session whose log to fold.
|
|
623
|
+
* @returns the payload, or undefined when the session has no title event.
|
|
624
|
+
*/
|
|
625
|
+
function latestTitleData(session) {
|
|
626
|
+
const events = session.events;
|
|
627
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
628
|
+
const event = events[i];
|
|
629
|
+
if (event.type === "session/title") return event.data;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
289
632
|
/** Read Host `locale.preference`; missing or unknown falls back to `zh`. */
|
|
290
633
|
function readLocalePreference(settings) {
|
|
291
634
|
if (settings === void 0) return "zh";
|
|
@@ -323,7 +666,7 @@ function resolvePersona(config, settings) {
|
|
|
323
666
|
}
|
|
324
667
|
}
|
|
325
668
|
/**
|
|
326
|
-
* Resolve the model for a new
|
|
669
|
+
* Resolve the model for a new WeCom chat session. Both provider and model must be
|
|
327
670
|
* non-empty to override; otherwise fall back to agent-default-model.
|
|
328
671
|
*/
|
|
329
672
|
function resolveSelection(config, defaultModel) {
|
|
@@ -375,60 +718,167 @@ function apply(ctx, config) {
|
|
|
375
718
|
console.warn("[im-bridge] 跳过启动: 缺少 botId/secret。请在 profile cordis.patch.yml 或 Settings → 插件配置中填写后重启。");
|
|
376
719
|
return;
|
|
377
720
|
}
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
721
|
+
const chats = /* @__PURE__ */ new Map();
|
|
722
|
+
/**
|
|
723
|
+
* Add the channel prefix to a title the Host generated. `session/event`
|
|
724
|
+
* runs inside the append publication window, which refuses a reentrant
|
|
725
|
+
* append, so the prefixed title goes out in a microtask and re-reads the
|
|
726
|
+
* log first: an already prefixed tail (including the one this appends)
|
|
727
|
+
* stops the chain.
|
|
728
|
+
*/
|
|
729
|
+
function prefixWecomTitle(session, st) {
|
|
730
|
+
const kind = st.kind;
|
|
731
|
+
if (kind === void 0) return;
|
|
732
|
+
queueMicrotask(() => {
|
|
733
|
+
const data = latestTitleData(session);
|
|
734
|
+
if (data === void 0) return;
|
|
735
|
+
if (data.source?.kind === "user") return;
|
|
736
|
+
const raw = typeof data.title === "string" ? data.title : "";
|
|
737
|
+
const next = wecomDisplayTitle(kind, raw);
|
|
738
|
+
if (next === raw) return;
|
|
739
|
+
const messageSeqs = Array.isArray(data.messageSeqs) ? data.messageSeqs.filter((seq) => typeof seq === "number") : [];
|
|
740
|
+
if (messageSeqs.length === 0) return;
|
|
741
|
+
try {
|
|
742
|
+
session.append("session/title", {
|
|
743
|
+
title: next,
|
|
744
|
+
messageSeqs,
|
|
745
|
+
source: data.source ?? { kind: "fallback" }
|
|
746
|
+
});
|
|
747
|
+
} catch (error) {
|
|
748
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
749
|
+
console.error(`[im-bridge] 加标题前缀失败: ${message}`);
|
|
750
|
+
}
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Sessions the GUI archived. Archiving is the workspace registry's global
|
|
755
|
+
* set, not session state, and it has no inverse: an archived session is
|
|
756
|
+
* invisible in every list, so this plugin must stop writing to it.
|
|
757
|
+
*/
|
|
758
|
+
function archivedSessions() {
|
|
759
|
+
const registry = ctx.get("workspaceRegistry");
|
|
760
|
+
if (registry === void 0) return /* @__PURE__ */ new Set();
|
|
761
|
+
try {
|
|
762
|
+
return new Set(registry.archivedSessionIds);
|
|
763
|
+
} catch (error) {
|
|
764
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
765
|
+
console.warn(`[im-bridge] 读归档会话失败: ${message}`);
|
|
766
|
+
return /* @__PURE__ */ new Set();
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
async function unpinLegacyWecomTitle(agent) {
|
|
770
|
+
const titles = ctx.get("sessionTitle");
|
|
771
|
+
if (titles === void 0) return;
|
|
772
|
+
try {
|
|
773
|
+
const snapshot = titles.get(agent.session);
|
|
774
|
+
if (snapshot?.source?.kind !== "user") return;
|
|
775
|
+
if (!isLegacyPinnedWecomTitle(snapshot.title)) return;
|
|
776
|
+
await titles.refresh(agent.session);
|
|
777
|
+
} catch (error) {
|
|
778
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
779
|
+
console.error(`[im-bridge] 解开旧标题失败: ${message}`);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
async function ensureAgent(ref) {
|
|
783
|
+
let st = chats.get(ref.key);
|
|
784
|
+
if (st === void 0) {
|
|
785
|
+
st = emptyChatState();
|
|
786
|
+
chats.set(ref.key, st);
|
|
787
|
+
}
|
|
788
|
+
st.kind = ref.kind;
|
|
789
|
+
if (st.agent !== void 0) {
|
|
790
|
+
if (st.sessionId === void 0 || !archivedSessions().has(st.sessionId)) return st;
|
|
791
|
+
console.log(`[im-bridge] 会话 ${st.sessionId} 已归档,改开新会话`);
|
|
792
|
+
st.agent = void 0;
|
|
793
|
+
st.sessionId = void 0;
|
|
794
|
+
}
|
|
795
|
+
const persistence = ctx.get("sessionPersistence");
|
|
796
|
+
const headers = persistence === void 0 ? [] : await persistence.list();
|
|
797
|
+
const plan = planWecomBind(ref.key, {
|
|
798
|
+
live: (id) => agents.get(SessionId(id)) !== void 0,
|
|
799
|
+
stored: new Set(headers.map((header) => header.id)),
|
|
800
|
+
archived: archivedSessions()
|
|
801
|
+
});
|
|
802
|
+
const sessionId = SessionId(plan.sessionId);
|
|
803
|
+
const stored = headers.find((header) => header.id === sessionId);
|
|
804
|
+
const attach = (agent, how) => {
|
|
805
|
+
st.agent = agent;
|
|
806
|
+
st.sessionId = sessionId;
|
|
807
|
+
st.kind = ref.kind;
|
|
808
|
+
unpinLegacyWecomTitle(agent);
|
|
809
|
+
const cwd = agent.session.header?.cwd ?? stored?.cwd;
|
|
810
|
+
if (cwd !== void 0 && cwd !== cfg().workspace) console.warn(`[im-bridge] 会话 ${sessionId} 仍使用存档目录 ${cwd},当前 workspace=${cfg().workspace}`);
|
|
811
|
+
const epoch = plan.epoch > 1 ? ` 第${String(plan.epoch)}段` : "";
|
|
812
|
+
console.log(`[im-bridge] 为 ${ref.key} ${how}会话 ${sessionId}${epoch} userid=${ref.sender} chattype=${ref.kind} chatid=${ref.chatid ?? ""}`);
|
|
813
|
+
};
|
|
814
|
+
const live = agents.get(sessionId);
|
|
815
|
+
if (plan.bind === "adopt" && live !== void 0) {
|
|
816
|
+
attach(live, "adopt");
|
|
817
|
+
return st;
|
|
818
|
+
}
|
|
383
819
|
const selection = resolveSelection(cfg(), defaultModel);
|
|
384
820
|
const presets = ctx.get("agentPresets");
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
current: selection,
|
|
400
|
-
assembled: void 0
|
|
821
|
+
const presetId = plan.bind === "resume" && stored?.agentPreset ? stored.agentPreset : cfg().agentPreset;
|
|
822
|
+
let resolvedId = presetId;
|
|
823
|
+
if (presets !== void 0) resolvedId = (await presets.resolve(presetId)).id;
|
|
824
|
+
const setup = async (agentCtx) => {
|
|
825
|
+
installModelSelection(agentCtx, {
|
|
826
|
+
current: selection,
|
|
827
|
+
assembled: void 0
|
|
828
|
+
});
|
|
829
|
+
if (presets !== void 0) await presets.mount(agentCtx, resolvedId);
|
|
830
|
+
agentCtx.inject(["systemPrompt"], (promptCtx) => {
|
|
831
|
+
promptCtx.systemPrompt.section({
|
|
832
|
+
name: "deployment:persona",
|
|
833
|
+
order: 0,
|
|
834
|
+
text: () => resolvePersona(cfg(), settings)
|
|
401
835
|
});
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
836
|
+
});
|
|
837
|
+
};
|
|
838
|
+
const agentOptions = {
|
|
839
|
+
provider: selection.provider,
|
|
840
|
+
model: selection.model
|
|
841
|
+
};
|
|
842
|
+
try {
|
|
843
|
+
if (plan.bind === "resume") {
|
|
844
|
+
const { agent } = await agents.resume({
|
|
845
|
+
resumeSessionId: sessionId,
|
|
846
|
+
agentOptions,
|
|
847
|
+
setup
|
|
409
848
|
});
|
|
849
|
+
attach(agent, "resume");
|
|
850
|
+
return st;
|
|
410
851
|
}
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
852
|
+
const { agent } = await agents.create({
|
|
853
|
+
sessionId,
|
|
854
|
+
meta: {
|
|
855
|
+
cwd: cfg().workspace,
|
|
856
|
+
agentPreset: resolvedId
|
|
857
|
+
},
|
|
858
|
+
agentOptions,
|
|
859
|
+
setup
|
|
860
|
+
});
|
|
861
|
+
attach(agent, "create");
|
|
862
|
+
return st;
|
|
863
|
+
} catch (error) {
|
|
864
|
+
const raced = agents.get(sessionId);
|
|
865
|
+
if (raced !== void 0) {
|
|
866
|
+
attach(raced, "adopt");
|
|
867
|
+
return st;
|
|
868
|
+
}
|
|
869
|
+
throw error;
|
|
870
|
+
}
|
|
425
871
|
}
|
|
426
872
|
ctx.on("session/event", (session, event) => {
|
|
427
873
|
const thinking = cfg().thinking;
|
|
428
874
|
const prefix = thinking?.activityPrefix ?? DEFAULT_THINKING.activityPrefix;
|
|
429
875
|
const flashMs = Number.isFinite(thinking?.intervalMs) && thinking.intervalMs > 0 ? thinking.intervalMs : DEFAULT_THINKING.intervalMs;
|
|
430
|
-
for (const st of
|
|
876
|
+
for (const st of chats.values()) {
|
|
431
877
|
if (st.sessionId !== session.id) continue;
|
|
878
|
+
if (event.type === "session/title") {
|
|
879
|
+
prefixWecomTitle(session, st);
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
432
882
|
if (event.type === "assistant/chunk") {
|
|
433
883
|
const next = streamPhaseFromChunk(event.data.chunk);
|
|
434
884
|
if (next !== null) st.modelStreamPhase = next;
|
|
@@ -455,8 +905,8 @@ function apply(ctx, config) {
|
|
|
455
905
|
}
|
|
456
906
|
});
|
|
457
907
|
const { default: AiBot, generateReqId } = await import("@wecom/aibot-node-sdk");
|
|
458
|
-
async function handle(frame,
|
|
459
|
-
const st = await ensureAgent(
|
|
908
|
+
async function handle(frame, ref, content) {
|
|
909
|
+
const st = await ensureAgent(ref);
|
|
460
910
|
const startedAt = Date.now();
|
|
461
911
|
const streamId = generateReqId("stream");
|
|
462
912
|
let stopThinking = null;
|
|
@@ -484,7 +934,7 @@ function apply(ctx, config) {
|
|
|
484
934
|
console.error(`[im-bridge] 占位回复失败: ${message}`);
|
|
485
935
|
}
|
|
486
936
|
try {
|
|
487
|
-
if (st.agent === void 0) throw new Error("im-bridge:
|
|
937
|
+
if (st.agent === void 0) throw new Error("im-bridge: chat agent missing");
|
|
488
938
|
await st.agent.whenIdle();
|
|
489
939
|
const firstSeq = st.agent.session.seq;
|
|
490
940
|
st.agent.followup(createUserMessage({
|
|
@@ -499,9 +949,14 @@ function apply(ctx, config) {
|
|
|
499
949
|
const outcome = summarize(st.agent.session.events, firstSeq);
|
|
500
950
|
if (stopThinking) stopThinking();
|
|
501
951
|
const ms = Date.now() - startedAt;
|
|
502
|
-
const
|
|
503
|
-
|
|
952
|
+
const body = outcome.text || "(agent 无输出)";
|
|
953
|
+
const collected = collectReplyPngs(body, cfg().workspace);
|
|
954
|
+
for (const reason of collected.skipped) console.warn(`[im-bridge] 跳过图片: ${reason}`);
|
|
955
|
+
let reply = truncate(body, (cfg().maxReplyBytes || 2e4) - 200) + footerOf(ms);
|
|
956
|
+
if (collected.skipped.length > 0) reply = truncate(`${reply}\n⚠️ ${collected.skipped.length} 张图片未发送(过大、越权或不存在)`, cfg().maxReplyBytes || 2e4);
|
|
957
|
+
console.log(`[im-bridge] ${ref.key} 完成 (${Buffer.byteLength(reply, "utf8")}B, ${fmtDuration(ms)})`);
|
|
504
958
|
await sendFinal(ws, frame, streamId, reply);
|
|
959
|
+
if (collected.images.length > 0) await sendCollectedPngs(ws, resolveChatId(frame, ref.sender), collected.images);
|
|
505
960
|
} catch (error) {
|
|
506
961
|
if (stopThinking) stopThinking();
|
|
507
962
|
const ms = Date.now() - startedAt;
|
|
@@ -525,24 +980,29 @@ function apply(ctx, config) {
|
|
|
525
980
|
ws.on("reconnecting", ((n) => console.log(`[im-bridge] 第 ${n} 次重连...`)));
|
|
526
981
|
ws.on("error", ((error) => console.error(`[im-bridge] 错误: ${error.message}`)));
|
|
527
982
|
ws.on("message.text", ((frame) => {
|
|
528
|
-
const
|
|
529
|
-
if (!
|
|
530
|
-
const
|
|
531
|
-
|
|
983
|
+
const inbound = (frame.body?.text?.content || "").trim();
|
|
984
|
+
if (!inbound) return;
|
|
985
|
+
const content = stripBotMention(inbound);
|
|
986
|
+
let ref;
|
|
987
|
+
try {
|
|
988
|
+
ref = resolveWecomSession(frame);
|
|
989
|
+
} catch (error) {
|
|
990
|
+
if (error instanceof WecomSessionReject) {
|
|
991
|
+
console.error(`[im-bridge] ${error.reply}`);
|
|
992
|
+
ws.replyStream(frame, generateReqId("stream"), error.reply, true).catch(() => {});
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
throw error;
|
|
996
|
+
}
|
|
997
|
+
if (cfg().allowFrom.length > 0 && !cfg().allowFrom.includes(ref.sender)) {
|
|
532
998
|
ws.replyStream(frame, generateReqId("stream"), cfg().deniedMessage, true).catch(() => {});
|
|
533
999
|
return;
|
|
534
1000
|
}
|
|
535
|
-
console.log(`[im-bridge] 收到
|
|
536
|
-
const st =
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
lastToolByCallId: /* @__PURE__ */ new Map(),
|
|
541
|
-
modelStreamPhase: "idle",
|
|
542
|
-
streamStatusTick: 0
|
|
543
|
-
};
|
|
544
|
-
senders.set(sender, st);
|
|
545
|
-
st.queue = st.queue.then(() => handle(frame, sender, content)).catch((error) => {
|
|
1001
|
+
console.log(`[im-bridge] 收到 key=${ref.key} userid=${ref.sender} chattype=${String(frame.body?.chattype ?? "")} chatid=${ref.chatid ?? ""}: ${content.slice(0, 100)}`);
|
|
1002
|
+
const st = chats.get(ref.key) ?? emptyChatState();
|
|
1003
|
+
st.kind = ref.kind;
|
|
1004
|
+
chats.set(ref.key, st);
|
|
1005
|
+
st.queue = st.queue.then(() => handle(frame, ref, content)).catch((error) => {
|
|
546
1006
|
const message = error instanceof Error ? error.message : String(error);
|
|
547
1007
|
console.error(`[im-bridge] 任务异常: ${message}`);
|
|
548
1008
|
});
|