@xgjktech/xg_cwork_im 1.11.6 → 1.11.7

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.
Files changed (84) hide show
  1. package/README.md +17 -6
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +12 -7
  4. package/dist/index.js.map +1 -1
  5. package/dist/src/agent-context-types.d.ts +33 -0
  6. package/dist/src/agent-context-types.d.ts.map +1 -0
  7. package/dist/src/agent-context-types.js +26 -0
  8. package/dist/src/agent-context-types.js.map +1 -0
  9. package/dist/src/apply-sync-mapping.d.ts +17 -0
  10. package/dist/src/apply-sync-mapping.d.ts.map +1 -0
  11. package/dist/src/apply-sync-mapping.js +86 -0
  12. package/dist/src/apply-sync-mapping.js.map +1 -0
  13. package/dist/src/auth.d.ts +2 -2
  14. package/dist/src/auth.d.ts.map +1 -1
  15. package/dist/src/auth.js +18 -12
  16. package/dist/src/auth.js.map +1 -1
  17. package/dist/src/build-project-prompt.d.ts +28 -0
  18. package/dist/src/build-project-prompt.d.ts.map +1 -0
  19. package/dist/src/build-project-prompt.js +86 -0
  20. package/dist/src/build-project-prompt.js.map +1 -0
  21. package/dist/src/channel-config.d.ts +256 -0
  22. package/dist/src/channel-config.d.ts.map +1 -0
  23. package/dist/src/channel-config.js +194 -0
  24. package/dist/src/channel-config.js.map +1 -0
  25. package/dist/src/channel-log.d.ts +17 -0
  26. package/dist/src/channel-log.d.ts.map +1 -0
  27. package/dist/src/channel-log.js +45 -0
  28. package/dist/src/channel-log.js.map +1 -0
  29. package/dist/src/channel.d.ts +4 -7
  30. package/dist/src/channel.d.ts.map +1 -1
  31. package/dist/src/channel.js +81 -603
  32. package/dist/src/channel.js.map +1 -1
  33. package/dist/src/connection.d.ts +45 -9
  34. package/dist/src/connection.d.ts.map +1 -1
  35. package/dist/src/connection.js +117 -5
  36. package/dist/src/connection.js.map +1 -1
  37. package/dist/src/dispatch-mentioned-reply.d.ts +25 -0
  38. package/dist/src/dispatch-mentioned-reply.d.ts.map +1 -0
  39. package/dist/src/dispatch-mentioned-reply.js +346 -0
  40. package/dist/src/dispatch-mentioned-reply.js.map +1 -0
  41. package/dist/src/gateway-session-queue.d.ts +26 -0
  42. package/dist/src/gateway-session-queue.d.ts.map +1 -0
  43. package/dist/src/gateway-session-queue.js +82 -0
  44. package/dist/src/gateway-session-queue.js.map +1 -0
  45. package/dist/src/group-history-tool.d.ts +11 -3
  46. package/dist/src/group-history-tool.d.ts.map +1 -1
  47. package/dist/src/group-history-tool.js +14 -10
  48. package/dist/src/group-history-tool.js.map +1 -1
  49. package/dist/src/inbound-display.d.ts +25 -0
  50. package/dist/src/inbound-display.d.ts.map +1 -0
  51. package/dist/src/inbound-display.js +100 -0
  52. package/dist/src/inbound-display.js.map +1 -0
  53. package/dist/src/project-path-normalize.d.ts +16 -0
  54. package/dist/src/project-path-normalize.d.ts.map +1 -0
  55. package/dist/src/project-path-normalize.js +75 -0
  56. package/dist/src/project-path-normalize.js.map +1 -0
  57. package/dist/src/resolve-inbound-group-prompt.d.ts +18 -0
  58. package/dist/src/resolve-inbound-group-prompt.d.ts.map +1 -0
  59. package/dist/src/resolve-inbound-group-prompt.js +38 -0
  60. package/dist/src/resolve-inbound-group-prompt.js.map +1 -0
  61. package/dist/src/resource-file.d.ts +17 -8
  62. package/dist/src/resource-file.d.ts.map +1 -1
  63. package/dist/src/resource-file.js +60 -21
  64. package/dist/src/resource-file.js.map +1 -1
  65. package/dist/src/send-group-message-tool.d.ts +3 -2
  66. package/dist/src/send-group-message-tool.d.ts.map +1 -1
  67. package/dist/src/send-group-message-tool.js +5 -4
  68. package/dist/src/send-group-message-tool.js.map +1 -1
  69. package/dist/src/send-service.d.ts +5 -1
  70. package/dist/src/send-service.d.ts.map +1 -1
  71. package/dist/src/send-service.js +6 -2
  72. package/dist/src/send-service.js.map +1 -1
  73. package/dist/src/sync-mapping-types.d.ts +170 -0
  74. package/dist/src/sync-mapping-types.d.ts.map +1 -0
  75. package/dist/src/sync-mapping-types.js +41 -0
  76. package/dist/src/sync-mapping-types.js.map +1 -0
  77. package/dist/src/sync-service-client.d.ts +27 -0
  78. package/dist/src/sync-service-client.d.ts.map +1 -0
  79. package/dist/src/sync-service-client.js +72 -0
  80. package/dist/src/sync-service-client.js.map +1 -0
  81. package/dist/src/types.d.ts +22 -0
  82. package/dist/src/types.d.ts.map +1 -1
  83. package/dist/src/types.js.map +1 -1
  84. package/package.json +1 -1
@@ -1,17 +1,21 @@
1
1
  /**
2
- * XG-IM Channel Plugin — 核心定义
2
+ * XG-IM Channel Plugin — 组装层
3
3
  *
4
- * 遵循 OpenClaw Channel Plugin 架构(参考 openclaw-channel-dingtalk):
5
- * - config : 账户解析
6
- * - outbound : AI 回复发送
7
- * - gateway : 启动 WebSocket 长连接,接收 robotMention 消息后通过
8
- * PluginRuntime 路由给 OpenClaw 处理
4
+ * 配置与解析见 channel-config.ts;入站正文见 inbound-display.ts;
5
+ * 排队调度见 gateway-session-queue.ts;@ 后派发见 dispatch-mentioned-reply.ts。
9
6
  */
10
7
  import { randomUUID } from "node:crypto";
8
+ import { resolveAgentWorkspaceDir } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
11
9
  import { buildChannelConfigSchema } from "openclaw/plugin-sdk/core";
12
- import { z } from "zod";
13
10
  import { clearTokenCache, getToken } from "./auth.js";
11
+ import { getXgImConfig, isConfigured, resolveInboundCommandAuthorized, XgImConfigSchema, } from "./channel-config.js";
12
+ import { parseRobotMentionAgentContext } from "./agent-context-types.js";
13
+ import { resolveInboundGroupSystemPrompt } from "./resolve-inbound-group-prompt.js";
14
+ import { toLogger } from "./channel-log.js";
15
+ import { applySyncMappingFromWsParams } from "./apply-sync-mapping.js";
14
16
  import { startWebSocket } from "./connection.js";
17
+ import { createRunWithSessionQueue } from "./gateway-session-queue.js";
18
+ import { buildInboundDisplayText, buildUntrustedContext, collectFileItems, isActuallyMentioned, isBypassQueueStopMessage, mediaFieldsFromFileItems, } from "./inbound-display.js";
15
19
  import { normalizeInboundWorkspaceSubdir, saveInboundFilesToWorkspace } from "./inbound-media-local.js";
16
20
  import { sendReplyDeliverBlock, sendTextMessage } from "./send-service.js";
17
21
  // ─── 全局 Runtime(在 index.ts 的 register 中注入)────────────────────────────
@@ -24,500 +28,6 @@ function getXgImRuntime() {
24
28
  throw new Error("[cwork_im] Plugin runtime not initialized");
25
29
  return xgImRuntime;
26
30
  }
27
- // ─── 配置 Schema ──────────────────────────────────────────────────────────────
28
- const XgImAccountConfigSchema = z.object({
29
- // 注意:为了兼容 OpenClaw doctor 生成的 accounts.default(仅包含默认项,不含 appKey/robotKey),这里先放宽为 optional,
30
- // 然后在 XgImConfigSchema.superRefine 中强制校验:除 default 之外的账号必须提供 robotKey 或 appKey。
31
- robotKey: z.string().min(1).optional(),
32
- appKey: z.string().min(1).optional(),
33
- agentId: z.string().optional().default("main"),
34
- name: z.string().optional(),
35
- groupPolicy: z.enum(["open", "mention"]).optional().default("mention"),
36
- fileUploadFormField: z.string().min(1).optional(),
37
- maxAttachmentBytes: z.number().int().positive().optional(),
38
- inboundMediaWorkspaceSubdir: z.string().optional(),
39
- inboundMediaOpenClawPath: z.enum(["workspaceRelative", "absoluteFileUrl"]).optional(),
40
- inboundMediaSandboxRootPrefix: z.string().optional(),
41
- systemPrompt: z.string().optional(),
42
- maxPendingMessages: z.number().int().min(0).optional(),
43
- });
44
- const XgImConfigSchema = z.object({
45
- robotKey: z.string().optional(),
46
- appKey: z.string().optional(),
47
- agentId: z.string().optional().default("main"),
48
- baseUrl: z.string().url("baseUrl must be a valid URL"),
49
- wsBaseUrl: z.string().url("wsBaseUrl must be a valid URL").optional(),
50
- enabled: z.boolean().optional().default(true),
51
- name: z.string().optional(),
52
- groupPolicy: z.enum(["open", "mention"]).optional().default("mention"),
53
- allowFrom: z.array(z.string()).optional().default([]),
54
- debug: z.boolean().optional().default(false),
55
- maxConnectionAttempts: z.number().int().positive().optional().default(10),
56
- initialReconnectDelay: z.number().int().positive().optional().default(1_000),
57
- maxReconnectDelay: z.number().int().positive().optional().default(60_000),
58
- reconnectJitter: z.number().min(0).max(1).optional().default(0.3),
59
- fileUploadFormField: z.string().min(1).optional(),
60
- maxAttachmentBytes: z.number().int().positive().optional(),
61
- /**
62
- * 非空时:将入站附件下载到 `resolveAgentWorkspaceDir(cfg)` 下该相对子目录,
63
- * 再向 OpenClaw 传 workspace 相对路径或 file://(见 inboundMediaOpenClawPath、inbound-media-local)。
64
- */
65
- inboundMediaWorkspaceSubdir: z.string().optional(),
66
- inboundMediaOpenClawPath: z.enum(["workspaceRelative", "absoluteFileUrl"]).optional(),
67
- /** 沙箱内 workspace 挂载根,如 `/workspace`;与 workspaceRelative 组合生成绝对引用 */
68
- inboundMediaSandboxRootPrefix: z.string().optional(),
69
- /** 群聊入站时写入 OpenClaw `GroupSystemPrompt`(与 accounts 内字段合并规则见 getXgImConfig) */
70
- systemPrompt: z.string().optional(),
71
- /** 每个会话(群组)最多可排队等待处理的消息数量(默认 5,设 0 表示不排队直接通知丢弃) */
72
- maxPendingMessages: z.number().int().min(0).optional().default(5),
73
- // 多账户:对象 map(key 为 accountId)
74
- accounts: z.record(z.string(), XgImAccountConfigSchema).optional(),
75
- }).superRefine((val, ctx) => {
76
- const badSubdir = (s) => typeof s === "string" && s.trim().length > 0 && normalizeInboundWorkspaceSubdir(s) == null;
77
- if (badSubdir(val.inboundMediaWorkspaceSubdir)) {
78
- ctx.addIssue({
79
- code: z.ZodIssueCode.custom,
80
- path: ["inboundMediaWorkspaceSubdir"],
81
- message: "inboundMediaWorkspaceSubdir must be a relative path under workspace (no .., no absolute path)",
82
- });
83
- }
84
- const badSandboxPrefix = (s) => typeof s === "string" && s.trim().length > 0 && !s.trim().startsWith("/");
85
- if (badSandboxPrefix(val.inboundMediaSandboxRootPrefix)) {
86
- ctx.addIssue({
87
- code: z.ZodIssueCode.custom,
88
- path: ["inboundMediaSandboxRootPrefix"],
89
- message: "inboundMediaSandboxRootPrefix must be empty or an absolute path such as /workspace",
90
- });
91
- }
92
- const accounts = val.accounts;
93
- if (!accounts)
94
- return;
95
- for (const [key, acc] of Object.entries(accounts)) {
96
- if (key === "default")
97
- continue;
98
- if (!acc || typeof acc !== "object")
99
- continue;
100
- if (!acc.robotKey && !acc.appKey) {
101
- ctx.addIssue({
102
- code: z.ZodIssueCode.custom,
103
- path: ["accounts", key, "robotKey"],
104
- message: "robotKey (or appKey) is required for account entries (except accounts.default)",
105
- });
106
- }
107
- if (badSubdir(acc.inboundMediaWorkspaceSubdir)) {
108
- ctx.addIssue({
109
- code: z.ZodIssueCode.custom,
110
- path: ["accounts", key, "inboundMediaWorkspaceSubdir"],
111
- message: "inboundMediaWorkspaceSubdir must be a relative path under workspace (no .., no absolute path)",
112
- });
113
- }
114
- if (badSandboxPrefix(acc.inboundMediaSandboxRootPrefix)) {
115
- ctx.addIssue({
116
- code: z.ZodIssueCode.custom,
117
- path: ["accounts", key, "inboundMediaSandboxRootPrefix"],
118
- message: "inboundMediaSandboxRootPrefix must be empty or an absolute path such as /workspace",
119
- });
120
- }
121
- }
122
- });
123
- // ─── 辅助函数 ─────────────────────────────────────────────────────────────────
124
- /**
125
- * 规范化 key 字段:优先取 robotKey,没有则取 appKey,统一写入 appKey。
126
- * 这样下游代码(auth.ts 等)只需读 config.appKey,无需感知 robotKey 的存在。
127
- */
128
- function normalizeRobotKey(config) {
129
- const resolved = config.robotKey ?? config.appKey;
130
- if (resolved === config.appKey)
131
- return config;
132
- return { ...config, appKey: resolved };
133
- }
134
- /** 从顶层 cfg 中取出 XgImConfig,支持多账户(README:channels.xg_cwork_im) */
135
- function getXgImConfig(cfg, accountId) {
136
- const raw = cfg?.channels?.xg_cwork_im;
137
- if (!raw)
138
- throw new Error("[cwork_im] channels.xg_cwork_im config not found");
139
- const accounts = raw.accounts;
140
- const accountMap = accounts && typeof accounts === "object" && !Array.isArray(accounts)
141
- ? accounts
142
- : undefined;
143
- const defaults = accountMap?.default ? { ...accountMap.default } : undefined;
144
- // 指定了具体账户 ID 时,优先合并对应账户配置
145
- if (accountId && accountId !== "default" && accountMap) {
146
- const sub = accountMap[accountId];
147
- if (sub)
148
- return normalizeRobotKey({ ...raw, ...(defaults ?? {}), ...sub });
149
- }
150
- // 没有指定 accountId(如 Cron outbound 场景),自动 fallback 到「第一个账户」
151
- // 避免顶层 raw 没有 appKey/robotKey 时 getToken 失败
152
- if (accountMap && !raw.appKey && !raw.robotKey) {
153
- const firstKey = Object.keys(accountMap).find((k) => k !== "default");
154
- const first = firstKey ? accountMap[firstKey] : undefined;
155
- if (first)
156
- return normalizeRobotKey({ ...raw, ...(defaults ?? {}), ...first });
157
- }
158
- return normalizeRobotKey(raw);
159
- }
160
- /**
161
- * 合并 `channels.xg_cwork_im` 顶层、`accounts.default`、以及 `accounts.<accountId>` 后的 `systemPrompt`,
162
- * 写入 OpenClaw 入站 `GroupSystemPrompt`(与 getXgImConfig 的非 default 账户合并方式一致;对 default 账户会合并 default 片段)。
163
- */
164
- function resolveXgImGroupSystemPrompt(cfg, accountId) {
165
- try {
166
- const raw = cfg?.channels?.xg_cwork_im;
167
- if (!raw)
168
- return undefined;
169
- const accountMap = raw.accounts && typeof raw.accounts === "object" && !Array.isArray(raw.accounts)
170
- ? raw.accounts
171
- : undefined;
172
- const defaults = accountMap?.default ? { ...accountMap.default } : {};
173
- const base = { ...raw, ...defaults };
174
- let merged = base;
175
- if (accountId && accountId !== "default" && accountMap?.[accountId]) {
176
- merged = { ...base, ...accountMap[accountId] };
177
- }
178
- const t = merged.systemPrompt?.trim();
179
- return t || undefined;
180
- }
181
- catch {
182
- return undefined;
183
- }
184
- }
185
- function isConfigured(cfg) {
186
- try {
187
- const c = getXgImConfig(cfg);
188
- return Boolean(c?.appKey && c?.baseUrl);
189
- }
190
- catch {
191
- return false;
192
- }
193
- }
194
- /**
195
- * OpenClaw 对入站命令:`CommandAuthorized === undefined` 会按 false 处理,文本 /command 会被静默忽略(仍走普通 AI)。
196
- * 见 openclaw LINE #26996 等修复。此处与 channels.xg_cwork_im.allowFrom 对齐;allowFrom 为空表示不限制发送者。
197
- */
198
- function isSenderInXgImAllowFrom(config, senderId) {
199
- const allow = config.allowFrom ?? [];
200
- if (allow.length === 0)
201
- return true;
202
- const id = senderId?.trim();
203
- if (!id)
204
- return false;
205
- return allow.includes(id);
206
- }
207
- function resolveInboundCommandAuthorized(rt, cfg, p) {
208
- const r = rt;
209
- const fn = r?.channel?.commands?.resolveControlCommandGate;
210
- if (typeof fn === "function") {
211
- try {
212
- const out = fn({
213
- cfg,
214
- channel: "xg_cwork_im",
215
- accountId: p.accountId,
216
- senderId: p.senderId ?? "",
217
- chatType: "group",
218
- });
219
- if (typeof out === "boolean")
220
- return out;
221
- if (out && typeof out === "object" && "commandAuthorized" in out) {
222
- return Boolean(out.commandAuthorized);
223
- }
224
- }
225
- catch {
226
- /* 回退到 allowFrom */
227
- }
228
- }
229
- return isSenderInXgImAllowFrom(p.config, p.senderId);
230
- }
231
- function toLogger(sink) {
232
- const prefix = "[xg_cwork_im]";
233
- const s = sink;
234
- const emitInfo = (msg) => {
235
- if (typeof s?.info === "function") {
236
- s.info(msg);
237
- return;
238
- }
239
- if (typeof s?.log === "function") {
240
- s.log(msg);
241
- return;
242
- }
243
- console.log(`${prefix} ${msg}`);
244
- };
245
- const emitWarn = (msg) => {
246
- if (typeof sink?.warn === "function") {
247
- sink.warn(msg);
248
- return;
249
- }
250
- console.warn(`${prefix} ${msg}`);
251
- };
252
- const emitError = (msg) => {
253
- if (typeof sink?.error === "function") {
254
- sink.error(msg);
255
- return;
256
- }
257
- console.error(`${prefix} ${msg}`);
258
- };
259
- return {
260
- info: emitInfo,
261
- warn: emitWarn,
262
- error: emitError,
263
- debug: (msg) => {
264
- if (typeof sink?.debug === "function")
265
- sink.debug(msg);
266
- else
267
- emitInfo(msg);
268
- },
269
- };
270
- }
271
- // ─── WebSocket 入站:纯函数与 @ 后派发 ───────────────────────────────────────
272
- function collectFileItems(msgContent) {
273
- // 保留有 url(需下载/引用的媒体)或有 content(IM 侧已内嵌解析文本)的项;两者均为空的占位项丢弃。
274
- return (msgContent?.files ?? []).filter((f) => f.url?.trim() || f.content?.trim());
275
- }
276
- function buildInboundDisplayText(msgContent, fileItems) {
277
- let rawText = msgContent?.text ?? "";
278
- if (fileItems.length > 0) {
279
- const lines = fileItems.map((f) => {
280
- const name = f.name?.trim() || f.fileId || "未命名文件";
281
- return `- \`${name}\``;
282
- });
283
- const block = `**附件(${fileItems.length})**\n${lines.join("\n")}`;
284
- rawText = rawText.trim() ? `${rawText.trim()}\n\n${block}` : block;
285
- const parsedParts = fileItems
286
- .map((f) => {
287
- const c = f.content?.trim();
288
- if (!c)
289
- return null;
290
- const name = f.name?.trim() || f.fileId || "未命名文件";
291
- return `### ${name}\n\n${c}`;
292
- })
293
- .filter((x) => x != null);
294
- if (parsedParts.length > 0) {
295
- const parsedBlock = `**附件解析内容**\n\n${parsedParts.join("\n\n")}`;
296
- rawText = `${rawText.trim()}\n\n${parsedBlock}`;
297
- }
298
- const urlGuard = "**附件 URL 使用约束**\n" +
299
- "处理附件时必须逐字原样使用消息中提供的完整 URL;禁止修改、删除或重排任何 query 参数。";
300
- rawText = `${rawText.trim()}\n\n${urlGuard}`;
301
- }
302
- return rawText;
303
- }
304
- function isActuallyMentioned(msg, params, bot, rawTextForLegacy) {
305
- const mentions = params.mentions;
306
- const isMentioned = Array.isArray(mentions) && (mentions.includes(bot.userId) || mentions.includes("all"));
307
- const isLegacyMentioned = msg.cmd === "robotMention" &&
308
- (!params.mentions || params.mentions.length === 0) &&
309
- new RegExp(`@${bot.name}\\b`).test(rawTextForLegacy);
310
- return isMentioned || isLegacyMentioned;
311
- }
312
- function mediaFieldsFromFileItems(fileItems) {
313
- // 只传有实际 url 的项;content-only 项无需 MediaPath(内容已通过 buildInboundDisplayText 内联传入)。
314
- // 不传 MediaType(s):预签名 URL 的 query 中常含 response-content-type,额外给 MediaType 容易导致模型改写 URL 使签名失效。
315
- const urlItems = fileItems.filter((f) => f.url?.trim());
316
- if (urlItems.length === 0)
317
- return {};
318
- if (urlItems.length === 1) {
319
- return {
320
- MediaPath: urlItems[0].url,
321
- };
322
- }
323
- return {
324
- MediaPaths: urlItems.map((f) => f.url),
325
- };
326
- }
327
- /** ext / 用户 background / 附件元数据(不含下载 URL,避免预签名链过长;URL 已在入站 MediaPath(s)) */
328
- function buildUntrustedContext(msgExt, senderBackground, fileItems) {
329
- const parts = [];
330
- if (msgExt)
331
- parts.push(JSON.stringify(msgExt));
332
- if (senderBackground)
333
- parts.push(String(senderBackground));
334
- if (fileItems.length > 0) {
335
- const summary = fileItems.map((f) => ({
336
- name: f.name ?? null,
337
- format: f.format ?? null,
338
- fileId: f.fileId ?? null,
339
- size: f.size ?? null,
340
- contentChars: f.content?.trim() ? f.content.trim().length : null,
341
- }));
342
- parts.push(`xg_cwork_im.attachments: ${JSON.stringify(summary)}`);
343
- }
344
- return parts.length > 0 ? parts : undefined;
345
- }
346
- function buildTargetReplyMeta(params) {
347
- return {
348
- targetMsgId: params.msgId,
349
- targetUserId: params.userInfo?.id ?? "",
350
- targetUserName: params.userInfo?.name ?? "未知用户",
351
- previewText: params.msgContent?.text ?? "",
352
- };
353
- }
354
- /** 流式 START → dispatch → 必发 END;首包超时走 HTTP 覆盖占位 */
355
- async function dispatchMentionedReply(args) {
356
- const { rt, cfg, config, log, logPrefix, route, inboundCtx, params, currentIdentity, streamClient } = args;
357
- log.info(`${logPrefix} [dispatch] Dispatching to OpenClaw AI, sessionKey=${route.sessionKey}`);
358
- let isFirstReply = true;
359
- const dispatchStart = Date.now();
360
- const { msgId: ackPlaceholderMsgId } = await streamClient.start({
361
- groupId: params.groupId,
362
- });
363
- const ackRawStr = ackPlaceholderMsgId == null ? "" : typeof ackPlaceholderMsgId === "string" ? ackPlaceholderMsgId : String(ackPlaceholderMsgId);
364
- const streamMsgId = ackRawStr.trim();
365
- log.info(`${logPrefix} [stream] START acknowledged placeholder rawLen=${ackRawStr.length} trimmedLen=${streamMsgId.length} ` +
366
- `summary=${streamMsgId.length === 0 ? "(empty)" : streamMsgId.length <= 28 ? streamMsgId : `${streamMsgId.slice(0, 14)}…${streamMsgId.slice(-8)}(len=${streamMsgId.length})`}`);
367
- if (!streamMsgId) {
368
- log.warn(`${logPrefix} [stream] WARNING: START ack msgId empty after trim — HTTP cannot merge streaming placeholder; IM likely shows「AI未响应」plus a separate reply bubble`);
369
- }
370
- let hasFirstReply = false;
371
- const firstReplyTimeoutMs = config.firstReplyTimeoutMs ?? 30 * 60_000;
372
- const timeoutLabel = `${logPrefix} [stream] First reply timeout after ${firstReplyTimeoutMs}ms, updating thinking message as error`;
373
- const firstReplyTimeout = setTimeout(async () => {
374
- if (hasFirstReply)
375
- return;
376
- log.warn(timeoutLabel);
377
- try {
378
- const reply = buildTargetReplyMeta(params);
379
- const timeoutText = "当前请求处理超时,请稍后重试。";
380
- await sendTextMessage(config, currentIdentity.token, params.groupId, timeoutText, [reply.targetUserId], log, streamMsgId, reply);
381
- // 标记已发送,让 finally 以 reason=stop 结束,避免后台再显示"AI未响应"
382
- hasFirstReply = true;
383
- log.info(`${logPrefix} [send] Timeout reply sent via HTTP: groupId=${params.groupId} msgId=${streamMsgId} text="${timeoutText}"`);
384
- }
385
- catch (err) {
386
- log.error(`${logPrefix} [timeout] Failed to send timeout reply: ${String(err)}`);
387
- }
388
- }, firstReplyTimeoutMs);
389
- let deliverInvokeCount = 0;
390
- let deliverCallCount = 0;
391
- let deliverSkippedCount = 0;
392
- let dispatchError = undefined;
393
- // 捕获 deliver 抛出后被 dispatcher 静默吞掉的错误:postImMessage 已带 5 次重试,
394
- // 进到这里说明所有重试均失败(IM 后台不可用 / 4xx / resultCode 业务错误等),需要让 finally 走兜底分支
395
- let lastDeliverError = undefined;
396
- try {
397
- await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
398
- ctx: inboundCtx,
399
- cfg,
400
- /**
401
- * OpenClaw 2026.x:群聊默认 `messages.groupChat.visibleReplies` / `messages.visibleReplies` 未为 `automatic` 时,
402
- * 会启用 `message_tool_only`,从而 suppressDelivery=true,不向 channel ReplyDispatcher(本插件 deliver)
403
- * 投递 block/final;模型仍会运行且网页端可读会话 transcript,表现为 IM invoke=0、占位 END no_reply。
404
- * 工作说说必须由本插件回 HTTP,此处强制 automatic。
405
- */
406
- replyOptions: {
407
- sourceReplyDeliveryMode: "automatic",
408
- },
409
- dispatcherOptions: {
410
- responsePrefix: "",
411
- // Logged when normalizeReplyPayload decides to skip a payload (e.g. heartbeat, silent token, empty).
412
- onSkip: (payload, meta) => {
413
- deliverSkippedCount++;
414
- log.info(`${logPrefix} [deliver] Payload skipped by normalizer kind=${meta.kind} reason=${meta.reason}`);
415
- },
416
- // Logged when deliver throws and the dispatcher catches it (error would otherwise be silently dropped).
417
- onError: (err, meta) => {
418
- lastDeliverError = err;
419
- log.error(`${logPrefix} [deliver] Dispatcher caught unhandled error kind=${meta.kind}: ${String(err)}`);
420
- },
421
- deliver: async (payload) => {
422
- try {
423
- deliverInvokeCount++;
424
- const textPart = (payload.markdown || payload.text || "").trim();
425
- const hasMedia = Boolean(payload.mediaUrl?.trim()) ||
426
- Boolean(payload.mediaUrls?.some((u) => typeof u === "string" && u.trim()));
427
- const textPreview = textPart.length > 120 ? `${textPart.slice(0, 120)}…(len=${textPart.length})` : textPart;
428
- log.info(`${logPrefix} [deliver] invoke #${deliverInvokeCount} isThinking=${payload.isThinking ?? false} ` +
429
- `textLen=${textPart.length} hasMedia=${hasMedia} mediaUrl=${Boolean(payload.mediaUrl?.trim())} ` +
430
- `preview=${JSON.stringify(textPreview || "(empty)")}`);
431
- if (!textPart && !(hasMedia && !payload.isThinking)) {
432
- log.info(`${logPrefix} [deliver] Payload has no sendable content, skipping` +
433
- ` (isThinking=${payload.isThinking ?? false} hasMedia=${hasMedia})`);
434
- return;
435
- }
436
- deliverCallCount++;
437
- if (isFirstReply) {
438
- const ttfr = Date.now() - dispatchStart;
439
- log.info(`${logPrefix} [deliver] First response block received from AI (TTFB: ${ttfr}ms)`);
440
- isFirstReply = false;
441
- }
442
- const reply = buildTargetReplyMeta(params);
443
- const atIds = [reply.targetUserId];
444
- if (!hasFirstReply) {
445
- clearTimeout(firstReplyTimeout);
446
- log.info(`${logPrefix} [deliver] First HTTP will merge streaming placeholder msgId=${streamMsgId.length > 0 ? `yes(len=${streamMsgId.length})` : "NO(empty)"}`);
447
- await sendReplyDeliverBlock(config, currentIdentity.token, params.groupId, payload, atIds, log, streamMsgId, reply);
448
- hasFirstReply = true; // set after successful send
449
- const preview = textPart.length > 80 ? `${textPart.slice(0, 80)}...` : textPart || "[media]";
450
- log.info(`${logPrefix} [send] First reply sent via HTTP: groupId=${params.groupId} msgId=${streamMsgId} preview="${preview}"`);
451
- return;
452
- }
453
- await sendReplyDeliverBlock(config, currentIdentity.token, params.groupId, payload, atIds, log, undefined, reply);
454
- const preview = textPart.length > 80 ? `${textPart.slice(0, 80)}...` : textPart || "[media]";
455
- log.info(`${logPrefix} [send] Additional reply sent via HTTP: groupId=${params.groupId} preview="${preview}"`);
456
- }
457
- catch (err) {
458
- log.error(`${logPrefix} [deliver] Reply deliver failed: ${String(err)}`);
459
- throw err;
460
- }
461
- },
462
- },
463
- });
464
- log.info(`${logPrefix} [dispatch] Dispatch completed for sessionKey=${route.sessionKey}` +
465
- ` (invoke=${deliverInvokeCount} delivered=${deliverCallCount} skipped=${deliverSkippedCount})`);
466
- if (deliverInvokeCount === 0) {
467
- log.warn(`${logPrefix} [dispatch] OpenClaw never invoked channel deliver() for this turn — ` +
468
- `this plugin will not POST /im/message/send; IM「收到消息」若存在,多半来自占位/END 或其它通道,而非本插件 HTTP 合并`);
469
- }
470
- else if (deliverCallCount === 0) {
471
- log.warn(`${logPrefix} [dispatch] deliver() invoked ${deliverInvokeCount} time(s) but none had sendable text/media — ` +
472
- `check model output (thinking-only / empty blocks) or OpenClaw normalizer`);
473
- }
474
- }
475
- catch (dispatchErr) {
476
- dispatchError = dispatchErr;
477
- log.error(`${logPrefix} [dispatch] Dispatch failed (invoke=${deliverInvokeCount} delivered=${deliverCallCount} skipped=${deliverSkippedCount}): ${String(dispatchErr)}`);
478
- }
479
- finally {
480
- clearTimeout(firstReplyTimeout);
481
- // 兜底原则:
482
- // - 有"实际错误"(dispatch 自身异常 / deliver 全部重试后仍失败)且尚未发过任何消息
483
- // → 主动告知用户,以 reason=stop 结束(后台信任已发消息)。
484
- // - 无错误也无回复(AI 输出为空 / 全部被 normalizer skip / /reset 类静默命令)
485
- // → 以 reason=no_reply 结束,由后台展示"AI未响应"。
486
- if (!hasFirstReply) {
487
- const reportableError = dispatchError ?? lastDeliverError;
488
- if (reportableError !== undefined) {
489
- const errSource = dispatchError !== undefined ? "dispatch" : "deliver";
490
- log.warn(`${logPrefix} [dispatch] ${errSource} error with no prior reply` +
491
- ` (invoke=${deliverInvokeCount} delivered=${deliverCallCount} skipped=${deliverSkippedCount}), sending error notice`);
492
- const replyMeta = buildTargetReplyMeta(params);
493
- try {
494
- await sendTextMessage(config, currentIdentity.token, params.groupId, `当前请求处理时出现异常,请稍后重试。\n错误信息:${String(reportableError)}`, [replyMeta.targetUserId], log, streamMsgId, replyMeta);
495
- hasFirstReply = true;
496
- }
497
- catch (fallbackErr) {
498
- log.error(`${logPrefix} [dispatch] Failed to send error notice (${errSource} error): ${String(fallbackErr)}`);
499
- }
500
- }
501
- else {
502
- log.warn(`${logPrefix} [dispatch] No reply was sent to user` +
503
- ` (invoke=${deliverInvokeCount} delivered=${deliverCallCount} skipped=${deliverSkippedCount}), ending with reason=no_reply` +
504
- (deliverInvokeCount === 0
505
- ? " — plugin did not run POST /im/message/send this turn"
506
- : ""));
507
- }
508
- }
509
- // reason=stop:已通过 HTTP 发送了至少一条回复(含兜底错误消息),后台信任已有消息。
510
- // reason=no_reply:本次无任何回复且无异常,后台凭此展示"AI未响应"。
511
- const endReason = hasFirstReply ? "stop" : "no_reply";
512
- try {
513
- await streamClient.end(streamMsgId, endReason);
514
- log.info(`${logPrefix} [stream] END sent: msgId=${streamMsgId} reason=${endReason}`);
515
- }
516
- catch (endErr) {
517
- log.error(`${logPrefix} [stream] END failed (msgId=${streamMsgId}): ${String(endErr)}`);
518
- }
519
- }
520
- }
521
31
  // ─── Channel Plugin 定义 ─────────────────────────────────────────────────────
522
32
  export const xgCworkImChannelPlugin = {
523
33
  id: "xg_cwork_im",
@@ -529,8 +39,6 @@ export const xgCworkImChannelPlugin = {
529
39
  blurb: "工作说说 IM 机器人,通过 WebSocket 长连接接收 @ 消息。",
530
40
  aliases: ["xg_cwork_im", "im"],
531
41
  },
532
- // 双重类型转换绕过 zod v3/v4 的 TS 类型不兼容
533
- // buildChannelConfigSchema 会将 schema 包装成可序列化的形式,避免 DataCloneError
534
42
  configSchema: buildChannelConfigSchema(XgImConfigSchema),
535
43
  capabilities: {
536
44
  chatTypes: ["group"],
@@ -541,7 +49,6 @@ export const xgCworkImChannelPlugin = {
541
49
  blockStreaming: false,
542
50
  },
543
51
  reload: { configPrefixes: ["channels.xg_cwork_im"] },
544
- // ── 账户配置 ────────────────────────────────────────────────────────────────
545
52
  config: {
546
53
  listAccountIds: (cfg) => {
547
54
  try {
@@ -549,7 +56,6 @@ export const xgCworkImChannelPlugin = {
549
56
  const accounts = config.accounts;
550
57
  if (accounts) {
551
58
  if (!Array.isArray(accounts) && typeof accounts === "object") {
552
- // 推荐写法:对象 key 作为 accountId
553
59
  const keys = Object.keys(accounts).filter((k) => k !== "default");
554
60
  return keys;
555
61
  }
@@ -568,7 +74,7 @@ export const xgCworkImChannelPlugin = {
568
74
  accountId: id,
569
75
  config,
570
76
  enabled: config.enabled !== false,
571
- configured: Boolean(config.appKey && config.baseUrl),
77
+ configured: Boolean((config.robotKey ?? config.appKey) && config.baseUrl),
572
78
  name: config.name ?? null,
573
79
  };
574
80
  }
@@ -582,8 +88,6 @@ export const xgCworkImChannelPlugin = {
582
88
  };
583
89
  }
584
90
  },
585
- // 注意:OpenClaw 可能在缺省路由时使用 defaultAccountId。
586
- // 我们使用 "default" 作为别名:getXgImConfig 会自动 fallback 到第一个非 default 的真实账号。
587
91
  defaultAccountId: () => "default",
588
92
  isConfigured: (account) => account.configured,
589
93
  describeAccount: (account) => ({
@@ -593,7 +97,6 @@ export const xgCworkImChannelPlugin = {
593
97
  configured: account.configured,
594
98
  }),
595
99
  },
596
- // ── 群聊设置 ─────────────────────────────────────────────────────────────────
597
100
  groups: {
598
101
  resolveRequireMention: (params) => {
599
102
  try {
@@ -601,11 +104,10 @@ export const xgCworkImChannelPlugin = {
601
104
  return config?.groupPolicy !== "open";
602
105
  }
603
106
  catch {
604
- return true; // 默认需要 @
107
+ return true;
605
108
  }
606
109
  },
607
110
  },
608
- // ── 出站消息(openclaw 主动发送时调用)────────────────────────────────────────
609
111
  outbound: {
610
112
  deliveryMode: "direct",
611
113
  resolveTarget: (params) => {
@@ -629,7 +131,6 @@ export const xgCworkImChannelPlugin = {
629
131
  messageId: randomUUID(),
630
132
  };
631
133
  },
632
- /** 出站带媒体:上传资源后发 FILE,与网关 deliver 逻辑一致(OpenClaw 要求与 sendText 同时实现) */
633
134
  sendMedia: async (ctx) => {
634
135
  const { cfg, to, text, mediaUrl, accountId, log: ctxLog } = ctx;
635
136
  const log = toLogger(ctxLog);
@@ -646,27 +147,22 @@ export const xgCworkImChannelPlugin = {
646
147
  };
647
148
  },
648
149
  },
649
- // ── 网关(WebSocket 长连接)─────────────────────────────────────────────────
650
150
  gateway: {
651
151
  startAccount: async (ctx) => {
652
152
  const account = ctx.account;
653
153
  const config = account.config;
654
154
  const log = toLogger(ctx.log);
655
155
  const logPrefix = `[${account.accountId}:${config.agentId || "main"}]`;
656
- if (!config.appKey || !config.baseUrl) {
657
- throw new Error(`${logPrefix} appKey and baseUrl are required in config`);
156
+ if (!(config.robotKey ?? config.appKey) || !config.baseUrl) {
157
+ throw new Error(`${logPrefix} robotKey and baseUrl are required in config`);
658
158
  }
659
159
  log.info(`${logPrefix} Starting xg-cwork-im channel...`);
660
- // 1. 获取机器人 token(有效期一年,缓存后无需重复请求)
661
160
  const identity = await getToken(config, log);
662
- // 2. 获取 PluginRuntime(用于路由消息给 OpenClaw)
663
161
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
664
162
  const rt = getXgImRuntime();
665
- // 3. 若 abort 信号已触发则不启动
666
163
  if (ctx.abortSignal?.aborted) {
667
164
  throw new Error(`${logPrefix} Connection aborted before start`);
668
165
  }
669
- // 4. 消息去重(按账户隔离)
670
166
  const processedMsgIds = new Set();
671
167
  const MSG_DEDUP_MAX = 1_000;
672
168
  const isDuplicate = (msgId) => {
@@ -683,74 +179,13 @@ export const xgCworkImChannelPlugin = {
683
179
  processedMsgIds.add(msgId);
684
180
  return false;
685
181
  };
686
- const busySessions = new Set();
687
- const sessionQueues = new Map();
688
- const maxPendingMsgs = config.maxPendingMessages ?? 5;
689
- /**
690
- * 将一次"提到我"的派发请求提交给队列调度器。
691
- * - 若当前会话空闲:直接调用 dispatchMentionedReply。
692
- * - 若当前会话忙碌:通知用户已排队,然后入队;上一条处理完成后自动出队执行。
693
- * - 若队列已满(maxPendingMsgs > 0):通知用户并丢弃。
694
- * - maxPendingMsgs = 0:禁用排队,直接通知用户稍后再试。
695
- */
696
- const runWithSessionQueue = async (sessionKey, pending, streamClient) => {
697
- if (busySessions.has(sessionKey)) {
698
- // 注意:JS 单线程,此处到第一个 await 之间不会被打断,所以队列长度判断和 push 均是原子的。
699
- // 必须在任何 await 之前完成 push,否则并发进入的消息会读到相同的 queue.length,
700
- // 导致多条消息计算出相同的队列位置(重复通知"第 1 位"等竞态问题)。
701
- const queue = sessionQueues.get(sessionKey) ?? [];
702
- if (maxPendingMsgs === 0 || queue.length >= maxPendingMsgs) {
703
- const reason = maxPendingMsgs === 0
704
- ? "当前不支持任务排队,请等待处理完成后再发送。"
705
- : `当前任务队列已满(最多 ${maxPendingMsgs} 条),请等待处理完成后再重新发送。`;
706
- log.warn(`${logPrefix} [queue] Session ${sessionKey} busy & queue full/disabled (max=${maxPendingMsgs}), dropping`);
707
- // 通知是 fire-and-forget:不 await,避免阻塞当前调用栈
708
- sendTextMessage(config, pending.currentIdentity.token, pending.params.groupId, reason, [pending.params.userInfo?.id ?? ""], log).catch((notifyErr) => {
709
- log.error(`${logPrefix} [queue] Failed to send queue-full notice: ${String(notifyErr)}`);
710
- });
711
- return;
712
- }
713
- // ★ 先同步 push,再异步发通知:确保并发调用看到的 queue.length 各不相同
714
- const queuePos = queue.length + 1;
715
- queue.push(pending);
716
- sessionQueues.set(sessionKey, queue);
717
- log.info(`${logPrefix} [queue] Session ${sessionKey} busy, queued message (pos=${queuePos})`);
718
- // 通知是 fire-and-forget:不 await
719
- sendTextMessage(config, pending.currentIdentity.token, pending.params.groupId, `收到,当前正在处理其他任务,您的消息已排队(队列第 ${queuePos} 位),处理完成后将自动回复。`, [pending.params.userInfo?.id ?? ""], log).catch((notifyErr) => {
720
- log.warn(`${logPrefix} [queue] Failed to send queue notification: ${String(notifyErr)}`);
721
- });
722
- return;
723
- }
724
- busySessions.add(sessionKey);
725
- try {
726
- await dispatchMentionedReply({
727
- rt,
728
- cfg: ctx.cfg,
729
- config,
730
- log,
731
- logPrefix,
732
- route: pending.route,
733
- inboundCtx: pending.inboundCtx,
734
- params: pending.params,
735
- currentIdentity: pending.currentIdentity,
736
- streamClient,
737
- });
738
- }
739
- finally {
740
- busySessions.delete(sessionKey);
741
- const queue = sessionQueues.get(sessionKey);
742
- if (queue && queue.length > 0) {
743
- const next = queue.shift();
744
- if (queue.length === 0)
745
- sessionQueues.delete(sessionKey);
746
- log.info(`${logPrefix} [queue] Dispatching next queued message for session ${sessionKey} (remaining=${queue.length})`);
747
- runWithSessionQueue(sessionKey, next, streamClient).catch((err) => {
748
- log.error(`${logPrefix} [queue] Error processing queued dispatch: ${String(err)}`);
749
- });
750
- }
751
- }
752
- };
753
- // 6. 收到 WebSocket 消息时的处理逻辑
182
+ const runWithSessionQueue = createRunWithSessionQueue({
183
+ config,
184
+ log,
185
+ logPrefix,
186
+ rt,
187
+ cfg: ctx.cfg,
188
+ });
754
189
  const handleMessage = async (msg) => {
755
190
  const params = msg.params;
756
191
  try {
@@ -785,7 +220,6 @@ export const xgCworkImChannelPlugin = {
785
220
  log.info(`${logPrefix} Message from ${senderName}(${senderId ?? "unknown"}) in group=${params.groupId}: ${text}`);
786
221
  const currentIdentity = await getToken(config, log);
787
222
  const actuallyMentioned = isActuallyMentioned(msg, params, currentIdentity, text);
788
- // 通过 PluginRuntime 路由消息到 OpenClaw
789
223
  const route = rt.channel.routing.resolveAgentRoute({
790
224
  cfg: ctx.cfg,
791
225
  channel: "xg_cwork_im",
@@ -819,12 +253,40 @@ export const xgCworkImChannelPlugin = {
819
253
  senderId,
820
254
  config,
821
255
  });
822
- const groupSystemPromptResolved = resolveXgImGroupSystemPrompt(ctx.cfg, account.accountId);
823
- if (config.debug) {
824
- log.info(`${logPrefix} [GroupSystemPrompt] ` +
825
- (groupSystemPromptResolved
826
- ? `active len=${groupSystemPromptResolved.length} preview=${JSON.stringify(groupSystemPromptResolved.slice(0, 96))}`
827
- : "(empty 若已配置 systemPrompt:检查网关是否加载了含本字段的插件构建,或把 systemPrompt 写到 accounts.<id> 再试)"));
256
+ const agentContext = parseRobotMentionAgentContext(params);
257
+ if (params.agentContext != null && !agentContext) {
258
+ log.error(`${logPrefix} [agent-context] invalid params.agentContext (schema validation failed)`);
259
+ }
260
+ let workspaceHostRoot;
261
+ if (agentContext?.localRoot.kind === "workspace_relative") {
262
+ try {
263
+ workspaceHostRoot = resolveAgentWorkspaceDir(ctx.cfg, route.agentId);
264
+ }
265
+ catch {
266
+ log.warn?.(`${logPrefix} [agent-context] cannot resolve workspace host root for workspace_relative localRoot`);
267
+ }
268
+ }
269
+ const groupSystemPromptResolved = resolveInboundGroupSystemPrompt({
270
+ cfg: ctx.cfg,
271
+ accountId: account.accountId,
272
+ config,
273
+ agentContext,
274
+ workspaceHostRoot,
275
+ log,
276
+ logPrefix,
277
+ });
278
+ if (agentContext) {
279
+ const gsp = groupSystemPromptResolved ?? "";
280
+ const projectMarker = "## 项目工作区";
281
+ const projectIdx = gsp.indexOf(projectMarker);
282
+ log.info(`${logPrefix} [GroupSystemPrompt] dispatch ` +
283
+ `len=${gsp.length} hasProjectBlock=${projectIdx >= 0} ` +
284
+ `head=${JSON.stringify(gsp.slice(0, 80))} ` +
285
+ `tail=${JSON.stringify(gsp.slice(-120))}`);
286
+ }
287
+ else if (config.debug && groupSystemPromptResolved) {
288
+ log.info(`${logPrefix} [GroupSystemPrompt] static-only len=${groupSystemPromptResolved.length} ` +
289
+ `preview=${JSON.stringify(groupSystemPromptResolved.slice(0, 96))}`);
828
290
  }
829
291
  const inboundCtx = rt.channel.reply.finalizeInboundContext({
830
292
  Body: body,
@@ -850,19 +312,16 @@ export const xgCworkImChannelPlugin = {
850
312
  GroupChannel: route.sessionKey,
851
313
  ...mediaFieldsFromFileItems(fileItems),
852
314
  UntrustedContext: buildUntrustedContext(msgExt, senderBackground, fileItems),
853
- // 同时保留原始 ext 供可能的后续逻辑使用
854
315
  XgImExt: msgExt,
855
316
  });
856
- // 若消息内容为 /reset,则在将其转交给 OpenClaw 之前完整打印一次入站上下文,便于排查 reset 行为
857
317
  if (text.trim() === "/reset") {
858
318
  try {
859
319
  log.info?.(`${logPrefix} [reset] inboundCtx payload before dispatch: ${JSON.stringify(inboundCtx)}`);
860
320
  }
861
321
  catch {
862
- // 忽略 JSON 序列化异常,避免影响正常流程
322
+ /* ignore */
863
323
  }
864
324
  }
865
- // 【所有消息都必须做】记录到数据库!让 AI 产生“记忆”
866
325
  log.info(`${logPrefix} [session] Recording inbound session sessionKey=${inboundCtx.SessionKey || route.sessionKey}`);
867
326
  await rt.channel.session.recordInboundSession({
868
327
  storePath,
@@ -879,10 +338,23 @@ export const xgCworkImChannelPlugin = {
879
338
  },
880
339
  });
881
340
  if (actuallyMentioned) {
882
- await runWithSessionQueue(route.sessionKey, { route, inboundCtx, params, currentIdentity }, wsHandle.streamClient);
341
+ let agentWorkspaceDir;
342
+ try {
343
+ agentWorkspaceDir = resolveAgentWorkspaceDir(ctx.cfg, route.agentId);
344
+ }
345
+ catch {
346
+ /* 解析失败时静默降级 */
347
+ }
348
+ await runWithSessionQueue(route.sessionKey, {
349
+ route,
350
+ inboundCtx,
351
+ params,
352
+ currentIdentity,
353
+ agentWorkspaceDir,
354
+ skipSessionQueue: isBypassQueueStopMessage(text),
355
+ }, wsHandle.streamClient);
883
356
  }
884
357
  else {
885
- // 没 @ 我,仅作为旁观者缓存记忆,不打扰群里聊天
886
358
  log.debug?.(`${logPrefix} Not mentioned in group, quietly memorized the message context.`);
887
359
  }
888
360
  }
@@ -890,9 +362,15 @@ export const xgCworkImChannelPlugin = {
890
362
  log.error(`${logPrefix} handleMessage error: ${String(err)}`);
891
363
  }
892
364
  };
893
- // 7. 启动 WebSocket 连接
894
- const wsHandle = startWebSocket(config, identity.token, handleMessage, log);
895
- // 8. 阻塞到 abortSignal 触发(若存在);若无 abortSignal,则交由进程生命周期管理
365
+ const wsHandle = startWebSocket(config, identity.token, handleMessage, log, {
366
+ onSyncMapping: (params) => applySyncMappingFromWsParams({
367
+ config,
368
+ cfg: ctx.cfg,
369
+ rt,
370
+ log,
371
+ logPrefix,
372
+ }, params),
373
+ });
896
374
  if (ctx.abortSignal) {
897
375
  await new Promise((resolve) => {
898
376
  const abortHandler = () => {