@xgjktech/xg_cwork_im 1.11.5 → 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 +77 -538
  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 +34 -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,495 +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
- });
43
- const XgImConfigSchema = z.object({
44
- robotKey: z.string().optional(),
45
- appKey: z.string().optional(),
46
- agentId: z.string().optional().default("main"),
47
- baseUrl: z.string().url("baseUrl must be a valid URL"),
48
- wsBaseUrl: z.string().url("wsBaseUrl must be a valid URL").optional(),
49
- enabled: z.boolean().optional().default(true),
50
- name: z.string().optional(),
51
- groupPolicy: z.enum(["open", "mention"]).optional().default("mention"),
52
- allowFrom: z.array(z.string()).optional().default([]),
53
- debug: z.boolean().optional().default(false),
54
- maxConnectionAttempts: z.number().int().positive().optional().default(10),
55
- initialReconnectDelay: z.number().int().positive().optional().default(1_000),
56
- maxReconnectDelay: z.number().int().positive().optional().default(60_000),
57
- reconnectJitter: z.number().min(0).max(1).optional().default(0.3),
58
- fileUploadFormField: z.string().min(1).optional(),
59
- maxAttachmentBytes: z.number().int().positive().optional(),
60
- /**
61
- * 非空时:将入站附件下载到 `resolveAgentWorkspaceDir(cfg)` 下该相对子目录,
62
- * 再向 OpenClaw 传 workspace 相对路径或 file://(见 inboundMediaOpenClawPath、inbound-media-local)。
63
- */
64
- inboundMediaWorkspaceSubdir: z.string().optional(),
65
- inboundMediaOpenClawPath: z.enum(["workspaceRelative", "absoluteFileUrl"]).optional(),
66
- /** 沙箱内 workspace 挂载根,如 `/workspace`;与 workspaceRelative 组合生成绝对引用 */
67
- inboundMediaSandboxRootPrefix: z.string().optional(),
68
- /** 群聊入站时写入 OpenClaw `GroupSystemPrompt`(与 accounts 内字段合并规则见 getXgImConfig) */
69
- systemPrompt: z.string().optional(),
70
- // 多账户:对象 map(key 为 accountId)
71
- accounts: z.record(z.string(), XgImAccountConfigSchema).optional(),
72
- }).superRefine((val, ctx) => {
73
- const badSubdir = (s) => typeof s === "string" && s.trim().length > 0 && normalizeInboundWorkspaceSubdir(s) == null;
74
- if (badSubdir(val.inboundMediaWorkspaceSubdir)) {
75
- ctx.addIssue({
76
- code: z.ZodIssueCode.custom,
77
- path: ["inboundMediaWorkspaceSubdir"],
78
- message: "inboundMediaWorkspaceSubdir must be a relative path under workspace (no .., no absolute path)",
79
- });
80
- }
81
- const badSandboxPrefix = (s) => typeof s === "string" && s.trim().length > 0 && !s.trim().startsWith("/");
82
- if (badSandboxPrefix(val.inboundMediaSandboxRootPrefix)) {
83
- ctx.addIssue({
84
- code: z.ZodIssueCode.custom,
85
- path: ["inboundMediaSandboxRootPrefix"],
86
- message: "inboundMediaSandboxRootPrefix must be empty or an absolute path such as /workspace",
87
- });
88
- }
89
- const accounts = val.accounts;
90
- if (!accounts)
91
- return;
92
- for (const [key, acc] of Object.entries(accounts)) {
93
- if (key === "default")
94
- continue;
95
- if (!acc || typeof acc !== "object")
96
- continue;
97
- if (!acc.robotKey && !acc.appKey) {
98
- ctx.addIssue({
99
- code: z.ZodIssueCode.custom,
100
- path: ["accounts", key, "robotKey"],
101
- message: "robotKey (or appKey) is required for account entries (except accounts.default)",
102
- });
103
- }
104
- if (badSubdir(acc.inboundMediaWorkspaceSubdir)) {
105
- ctx.addIssue({
106
- code: z.ZodIssueCode.custom,
107
- path: ["accounts", key, "inboundMediaWorkspaceSubdir"],
108
- message: "inboundMediaWorkspaceSubdir must be a relative path under workspace (no .., no absolute path)",
109
- });
110
- }
111
- if (badSandboxPrefix(acc.inboundMediaSandboxRootPrefix)) {
112
- ctx.addIssue({
113
- code: z.ZodIssueCode.custom,
114
- path: ["accounts", key, "inboundMediaSandboxRootPrefix"],
115
- message: "inboundMediaSandboxRootPrefix must be empty or an absolute path such as /workspace",
116
- });
117
- }
118
- }
119
- });
120
- // ─── 辅助函数 ─────────────────────────────────────────────────────────────────
121
- /**
122
- * 规范化 key 字段:优先取 robotKey,没有则取 appKey,统一写入 appKey。
123
- * 这样下游代码(auth.ts 等)只需读 config.appKey,无需感知 robotKey 的存在。
124
- */
125
- function normalizeRobotKey(config) {
126
- const resolved = config.robotKey ?? config.appKey;
127
- if (resolved === config.appKey)
128
- return config;
129
- return { ...config, appKey: resolved };
130
- }
131
- /** 从顶层 cfg 中取出 XgImConfig,支持多账户(README:channels.xg_cwork_im) */
132
- function getXgImConfig(cfg, accountId) {
133
- const raw = cfg?.channels?.xg_cwork_im;
134
- if (!raw)
135
- throw new Error("[cwork_im] channels.xg_cwork_im config not found");
136
- const accounts = raw.accounts;
137
- const accountMap = accounts && typeof accounts === "object" && !Array.isArray(accounts)
138
- ? accounts
139
- : undefined;
140
- const defaults = accountMap?.default ? { ...accountMap.default } : undefined;
141
- // 指定了具体账户 ID 时,优先合并对应账户配置
142
- if (accountId && accountId !== "default" && accountMap) {
143
- const sub = accountMap[accountId];
144
- if (sub)
145
- return normalizeRobotKey({ ...raw, ...(defaults ?? {}), ...sub });
146
- }
147
- // 没有指定 accountId(如 Cron outbound 场景),自动 fallback 到「第一个账户」
148
- // 避免顶层 raw 没有 appKey/robotKey 时 getToken 失败
149
- if (accountMap && !raw.appKey && !raw.robotKey) {
150
- const firstKey = Object.keys(accountMap).find((k) => k !== "default");
151
- const first = firstKey ? accountMap[firstKey] : undefined;
152
- if (first)
153
- return normalizeRobotKey({ ...raw, ...(defaults ?? {}), ...first });
154
- }
155
- return normalizeRobotKey(raw);
156
- }
157
- /**
158
- * 合并 `channels.xg_cwork_im` 顶层、`accounts.default`、以及 `accounts.<accountId>` 后的 `systemPrompt`,
159
- * 写入 OpenClaw 入站 `GroupSystemPrompt`(与 getXgImConfig 的非 default 账户合并方式一致;对 default 账户会合并 default 片段)。
160
- */
161
- function resolveXgImGroupSystemPrompt(cfg, accountId) {
162
- try {
163
- const raw = cfg?.channels?.xg_cwork_im;
164
- if (!raw)
165
- return undefined;
166
- const accountMap = raw.accounts && typeof raw.accounts === "object" && !Array.isArray(raw.accounts)
167
- ? raw.accounts
168
- : undefined;
169
- const defaults = accountMap?.default ? { ...accountMap.default } : {};
170
- const base = { ...raw, ...defaults };
171
- let merged = base;
172
- if (accountId && accountId !== "default" && accountMap?.[accountId]) {
173
- merged = { ...base, ...accountMap[accountId] };
174
- }
175
- const t = merged.systemPrompt?.trim();
176
- return t || undefined;
177
- }
178
- catch {
179
- return undefined;
180
- }
181
- }
182
- function isConfigured(cfg) {
183
- try {
184
- const c = getXgImConfig(cfg);
185
- return Boolean(c?.appKey && c?.baseUrl);
186
- }
187
- catch {
188
- return false;
189
- }
190
- }
191
- /**
192
- * OpenClaw 对入站命令:`CommandAuthorized === undefined` 会按 false 处理,文本 /command 会被静默忽略(仍走普通 AI)。
193
- * 见 openclaw LINE #26996 等修复。此处与 channels.xg_cwork_im.allowFrom 对齐;allowFrom 为空表示不限制发送者。
194
- */
195
- function isSenderInXgImAllowFrom(config, senderId) {
196
- const allow = config.allowFrom ?? [];
197
- if (allow.length === 0)
198
- return true;
199
- const id = senderId?.trim();
200
- if (!id)
201
- return false;
202
- return allow.includes(id);
203
- }
204
- function resolveInboundCommandAuthorized(rt, cfg, p) {
205
- const r = rt;
206
- const fn = r?.channel?.commands?.resolveControlCommandGate;
207
- if (typeof fn === "function") {
208
- try {
209
- const out = fn({
210
- cfg,
211
- channel: "xg_cwork_im",
212
- accountId: p.accountId,
213
- senderId: p.senderId ?? "",
214
- chatType: "group",
215
- });
216
- if (typeof out === "boolean")
217
- return out;
218
- if (out && typeof out === "object" && "commandAuthorized" in out) {
219
- return Boolean(out.commandAuthorized);
220
- }
221
- }
222
- catch {
223
- /* 回退到 allowFrom */
224
- }
225
- }
226
- return isSenderInXgImAllowFrom(p.config, p.senderId);
227
- }
228
- function toLogger(sink) {
229
- const prefix = "[xg_cwork_im]";
230
- const s = sink;
231
- const emitInfo = (msg) => {
232
- if (typeof s?.info === "function") {
233
- s.info(msg);
234
- return;
235
- }
236
- if (typeof s?.log === "function") {
237
- s.log(msg);
238
- return;
239
- }
240
- console.log(`${prefix} ${msg}`);
241
- };
242
- const emitWarn = (msg) => {
243
- if (typeof sink?.warn === "function") {
244
- sink.warn(msg);
245
- return;
246
- }
247
- console.warn(`${prefix} ${msg}`);
248
- };
249
- const emitError = (msg) => {
250
- if (typeof sink?.error === "function") {
251
- sink.error(msg);
252
- return;
253
- }
254
- console.error(`${prefix} ${msg}`);
255
- };
256
- return {
257
- info: emitInfo,
258
- warn: emitWarn,
259
- error: emitError,
260
- debug: (msg) => {
261
- if (typeof sink?.debug === "function")
262
- sink.debug(msg);
263
- else
264
- emitInfo(msg);
265
- },
266
- };
267
- }
268
- // ─── WebSocket 入站:纯函数与 @ 后派发 ───────────────────────────────────────
269
- function collectFileItems(msgContent) {
270
- return (msgContent?.files ?? []).filter((f) => f.url?.trim());
271
- }
272
- function buildInboundDisplayText(msgContent, fileItems) {
273
- let rawText = msgContent?.text ?? "";
274
- if (fileItems.length > 0) {
275
- const lines = fileItems.map((f) => {
276
- const name = f.name?.trim() || f.fileId || "未命名文件";
277
- return `- \`${name}\``;
278
- });
279
- const block = `**附件(${fileItems.length})**\n${lines.join("\n")}`;
280
- rawText = rawText.trim() ? `${rawText.trim()}\n\n${block}` : block;
281
- const parsedParts = fileItems
282
- .map((f) => {
283
- const c = f.content?.trim();
284
- if (!c)
285
- return null;
286
- const name = f.name?.trim() || f.fileId || "未命名文件";
287
- return `### ${name}\n\n${c}`;
288
- })
289
- .filter((x) => x != null);
290
- if (parsedParts.length > 0) {
291
- const parsedBlock = `**附件解析内容**\n\n${parsedParts.join("\n\n")}`;
292
- rawText = `${rawText.trim()}\n\n${parsedBlock}`;
293
- }
294
- const urlGuard = "**附件 URL 使用约束**\n" +
295
- "处理附件时必须逐字原样使用消息中提供的完整 URL;禁止修改、删除或重排任何 query 参数。";
296
- rawText = `${rawText.trim()}\n\n${urlGuard}`;
297
- }
298
- return rawText;
299
- }
300
- function isActuallyMentioned(msg, params, bot, rawTextForLegacy) {
301
- const mentions = params.mentions;
302
- const isMentioned = Array.isArray(mentions) && (mentions.includes(bot.userId) || mentions.includes("all"));
303
- const isLegacyMentioned = msg.cmd === "robotMention" &&
304
- (!params.mentions || params.mentions.length === 0) &&
305
- new RegExp(`@${bot.name}\\b`).test(rawTextForLegacy);
306
- return isMentioned || isLegacyMentioned;
307
- }
308
- function mediaFieldsFromFileItems(fileItems) {
309
- if (fileItems.length === 0)
310
- return {};
311
- // 只传 MediaPath(s),不传 MediaType(s)。
312
- // 预签名 URL 的 query 中常含 response-content-type;若额外给 MediaType,模型/工具更容易“改写 URL”导致签名失效。
313
- if (fileItems.length === 1) {
314
- return {
315
- MediaPath: fileItems[0].url,
316
- };
317
- }
318
- return {
319
- MediaPaths: fileItems.map((f) => f.url),
320
- };
321
- }
322
- /** ext / 用户 background / 附件元数据(不含下载 URL,避免预签名链过长;URL 已在入站 MediaPath(s)) */
323
- function buildUntrustedContext(msgExt, senderBackground, fileItems) {
324
- const parts = [];
325
- if (msgExt)
326
- parts.push(JSON.stringify(msgExt));
327
- if (senderBackground)
328
- parts.push(String(senderBackground));
329
- if (fileItems.length > 0) {
330
- const summary = fileItems.map((f) => ({
331
- name: f.name ?? null,
332
- format: f.format ?? null,
333
- fileId: f.fileId ?? null,
334
- size: f.size ?? null,
335
- contentChars: f.content?.trim() ? f.content.trim().length : null,
336
- }));
337
- parts.push(`xg_cwork_im.attachments: ${JSON.stringify(summary)}`);
338
- }
339
- return parts.length > 0 ? parts : undefined;
340
- }
341
- function buildTargetReplyMeta(params) {
342
- return {
343
- targetMsgId: params.msgId,
344
- targetUserId: params.userInfo?.id ?? "",
345
- targetUserName: params.userInfo?.name ?? "未知用户",
346
- previewText: params.msgContent?.text ?? "",
347
- };
348
- }
349
- /** 流式 START → dispatch → 必发 END;首包超时走 HTTP 覆盖占位 */
350
- async function dispatchMentionedReply(args) {
351
- const { rt, cfg, config, log, logPrefix, route, inboundCtx, params, currentIdentity, streamClient } = args;
352
- log.info(`${logPrefix} [dispatch] Dispatching to OpenClaw AI, sessionKey=${route.sessionKey}`);
353
- let isFirstReply = true;
354
- const dispatchStart = Date.now();
355
- const { msgId: ackPlaceholderMsgId } = await streamClient.start({
356
- groupId: params.groupId,
357
- });
358
- const ackRawStr = ackPlaceholderMsgId == null ? "" : typeof ackPlaceholderMsgId === "string" ? ackPlaceholderMsgId : String(ackPlaceholderMsgId);
359
- const streamMsgId = ackRawStr.trim();
360
- log.info(`${logPrefix} [stream] START acknowledged placeholder rawLen=${ackRawStr.length} trimmedLen=${streamMsgId.length} ` +
361
- `summary=${streamMsgId.length === 0 ? "(empty)" : streamMsgId.length <= 28 ? streamMsgId : `${streamMsgId.slice(0, 14)}…${streamMsgId.slice(-8)}(len=${streamMsgId.length})`}`);
362
- if (!streamMsgId) {
363
- 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`);
364
- }
365
- let hasFirstReply = false;
366
- const firstReplyTimeoutMs = config.firstReplyTimeoutMs ?? 30 * 60_000;
367
- const timeoutLabel = `${logPrefix} [stream] First reply timeout after ${firstReplyTimeoutMs}ms, updating thinking message as error`;
368
- const firstReplyTimeout = setTimeout(async () => {
369
- if (hasFirstReply)
370
- return;
371
- log.warn(timeoutLabel);
372
- try {
373
- const reply = buildTargetReplyMeta(params);
374
- const timeoutText = "当前请求处理超时,请稍后重试。";
375
- await sendTextMessage(config, currentIdentity.token, params.groupId, timeoutText, [reply.targetUserId], log, streamMsgId, reply);
376
- // 标记已发送,让 finally 以 reason=stop 结束,避免后台再显示"AI未响应"
377
- hasFirstReply = true;
378
- log.info(`${logPrefix} [send] Timeout reply sent via HTTP: groupId=${params.groupId} msgId=${streamMsgId} text="${timeoutText}"`);
379
- }
380
- catch (err) {
381
- log.error(`${logPrefix} [timeout] Failed to send timeout reply: ${String(err)}`);
382
- }
383
- }, firstReplyTimeoutMs);
384
- let deliverInvokeCount = 0;
385
- let deliverCallCount = 0;
386
- let deliverSkippedCount = 0;
387
- let dispatchError = undefined;
388
- // 捕获 deliver 抛出后被 dispatcher 静默吞掉的错误:postImMessage 已带 5 次重试,
389
- // 进到这里说明所有重试均失败(IM 后台不可用 / 4xx / resultCode 业务错误等),需要让 finally 走兜底分支
390
- let lastDeliverError = undefined;
391
- try {
392
- await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
393
- ctx: inboundCtx,
394
- cfg,
395
- /**
396
- * OpenClaw 2026.x:群聊默认 `messages.groupChat.visibleReplies` / `messages.visibleReplies` 未为 `automatic` 时,
397
- * 会启用 `message_tool_only`,从而 suppressDelivery=true,不向 channel ReplyDispatcher(本插件 deliver)
398
- * 投递 block/final;模型仍会运行且网页端可读会话 transcript,表现为 IM invoke=0、占位 END no_reply。
399
- * 工作说说必须由本插件回 HTTP,此处强制 automatic。
400
- */
401
- replyOptions: {
402
- sourceReplyDeliveryMode: "automatic",
403
- },
404
- dispatcherOptions: {
405
- responsePrefix: "",
406
- // Logged when normalizeReplyPayload decides to skip a payload (e.g. heartbeat, silent token, empty).
407
- onSkip: (payload, meta) => {
408
- deliverSkippedCount++;
409
- log.info(`${logPrefix} [deliver] Payload skipped by normalizer kind=${meta.kind} reason=${meta.reason}`);
410
- },
411
- // Logged when deliver throws and the dispatcher catches it (error would otherwise be silently dropped).
412
- onError: (err, meta) => {
413
- lastDeliverError = err;
414
- log.error(`${logPrefix} [deliver] Dispatcher caught unhandled error kind=${meta.kind}: ${String(err)}`);
415
- },
416
- deliver: async (payload) => {
417
- try {
418
- deliverInvokeCount++;
419
- const textPart = (payload.markdown || payload.text || "").trim();
420
- const hasMedia = Boolean(payload.mediaUrl?.trim()) ||
421
- Boolean(payload.mediaUrls?.some((u) => typeof u === "string" && u.trim()));
422
- const textPreview = textPart.length > 120 ? `${textPart.slice(0, 120)}…(len=${textPart.length})` : textPart;
423
- log.info(`${logPrefix} [deliver] invoke #${deliverInvokeCount} isThinking=${payload.isThinking ?? false} ` +
424
- `textLen=${textPart.length} hasMedia=${hasMedia} mediaUrl=${Boolean(payload.mediaUrl?.trim())} ` +
425
- `preview=${JSON.stringify(textPreview || "(empty)")}`);
426
- if (!textPart && !(hasMedia && !payload.isThinking)) {
427
- log.info(`${logPrefix} [deliver] Payload has no sendable content, skipping` +
428
- ` (isThinking=${payload.isThinking ?? false} hasMedia=${hasMedia})`);
429
- return;
430
- }
431
- deliverCallCount++;
432
- if (isFirstReply) {
433
- const ttfr = Date.now() - dispatchStart;
434
- log.info(`${logPrefix} [deliver] First response block received from AI (TTFB: ${ttfr}ms)`);
435
- isFirstReply = false;
436
- }
437
- const reply = buildTargetReplyMeta(params);
438
- const atIds = [reply.targetUserId];
439
- if (!hasFirstReply) {
440
- clearTimeout(firstReplyTimeout);
441
- log.info(`${logPrefix} [deliver] First HTTP will merge streaming placeholder msgId=${streamMsgId.length > 0 ? `yes(len=${streamMsgId.length})` : "NO(empty)"}`);
442
- await sendReplyDeliverBlock(config, currentIdentity.token, params.groupId, payload, atIds, log, streamMsgId, reply);
443
- hasFirstReply = true; // set after successful send
444
- const preview = textPart.length > 80 ? `${textPart.slice(0, 80)}...` : textPart || "[media]";
445
- log.info(`${logPrefix} [send] First reply sent via HTTP: groupId=${params.groupId} msgId=${streamMsgId} preview="${preview}"`);
446
- return;
447
- }
448
- await sendReplyDeliverBlock(config, currentIdentity.token, params.groupId, payload, atIds, log, undefined, reply);
449
- const preview = textPart.length > 80 ? `${textPart.slice(0, 80)}...` : textPart || "[media]";
450
- log.info(`${logPrefix} [send] Additional reply sent via HTTP: groupId=${params.groupId} preview="${preview}"`);
451
- }
452
- catch (err) {
453
- log.error(`${logPrefix} [deliver] Reply deliver failed: ${String(err)}`);
454
- throw err;
455
- }
456
- },
457
- },
458
- });
459
- log.info(`${logPrefix} [dispatch] Dispatch completed for sessionKey=${route.sessionKey}` +
460
- ` (invoke=${deliverInvokeCount} delivered=${deliverCallCount} skipped=${deliverSkippedCount})`);
461
- if (deliverInvokeCount === 0) {
462
- log.warn(`${logPrefix} [dispatch] OpenClaw never invoked channel deliver() for this turn — ` +
463
- `this plugin will not POST /im/message/send; IM「收到消息」若存在,多半来自占位/END 或其它通道,而非本插件 HTTP 合并`);
464
- }
465
- else if (deliverCallCount === 0) {
466
- log.warn(`${logPrefix} [dispatch] deliver() invoked ${deliverInvokeCount} time(s) but none had sendable text/media — ` +
467
- `check model output (thinking-only / empty blocks) or OpenClaw normalizer`);
468
- }
469
- }
470
- catch (dispatchErr) {
471
- dispatchError = dispatchErr;
472
- log.error(`${logPrefix} [dispatch] Dispatch failed (invoke=${deliverInvokeCount} delivered=${deliverCallCount} skipped=${deliverSkippedCount}): ${String(dispatchErr)}`);
473
- }
474
- finally {
475
- clearTimeout(firstReplyTimeout);
476
- // 兜底原则:
477
- // - 有"实际错误"(dispatch 自身异常 / deliver 全部重试后仍失败)且尚未发过任何消息
478
- // → 主动告知用户,以 reason=stop 结束(后台信任已发消息)。
479
- // - 无错误也无回复(AI 输出为空 / 全部被 normalizer skip / /reset 类静默命令)
480
- // → 以 reason=no_reply 结束,由后台展示"AI未响应"。
481
- if (!hasFirstReply) {
482
- const reportableError = dispatchError ?? lastDeliverError;
483
- if (reportableError !== undefined) {
484
- const errSource = dispatchError !== undefined ? "dispatch" : "deliver";
485
- log.warn(`${logPrefix} [dispatch] ${errSource} error with no prior reply` +
486
- ` (invoke=${deliverInvokeCount} delivered=${deliverCallCount} skipped=${deliverSkippedCount}), sending error notice`);
487
- const replyMeta = buildTargetReplyMeta(params);
488
- try {
489
- await sendTextMessage(config, currentIdentity.token, params.groupId, `当前请求处理时出现异常,请稍后重试。\n错误信息:${String(reportableError)}`, [replyMeta.targetUserId], log, streamMsgId, replyMeta);
490
- hasFirstReply = true;
491
- }
492
- catch (fallbackErr) {
493
- log.error(`${logPrefix} [dispatch] Failed to send error notice (${errSource} error): ${String(fallbackErr)}`);
494
- }
495
- }
496
- else {
497
- log.warn(`${logPrefix} [dispatch] No reply was sent to user` +
498
- ` (invoke=${deliverInvokeCount} delivered=${deliverCallCount} skipped=${deliverSkippedCount}), ending with reason=no_reply` +
499
- (deliverInvokeCount === 0
500
- ? " — plugin did not run POST /im/message/send this turn"
501
- : ""));
502
- }
503
- }
504
- // reason=stop:已通过 HTTP 发送了至少一条回复(含兜底错误消息),后台信任已有消息。
505
- // reason=no_reply:本次无任何回复且无异常,后台凭此展示"AI未响应"。
506
- const endReason = hasFirstReply ? "stop" : "no_reply";
507
- try {
508
- await streamClient.end(streamMsgId, endReason);
509
- log.info(`${logPrefix} [stream] END sent: msgId=${streamMsgId} reason=${endReason}`);
510
- }
511
- catch (endErr) {
512
- log.error(`${logPrefix} [stream] END failed (msgId=${streamMsgId}): ${String(endErr)}`);
513
- }
514
- }
515
- }
516
31
  // ─── Channel Plugin 定义 ─────────────────────────────────────────────────────
517
32
  export const xgCworkImChannelPlugin = {
518
33
  id: "xg_cwork_im",
@@ -524,8 +39,6 @@ export const xgCworkImChannelPlugin = {
524
39
  blurb: "工作说说 IM 机器人,通过 WebSocket 长连接接收 @ 消息。",
525
40
  aliases: ["xg_cwork_im", "im"],
526
41
  },
527
- // 双重类型转换绕过 zod v3/v4 的 TS 类型不兼容
528
- // buildChannelConfigSchema 会将 schema 包装成可序列化的形式,避免 DataCloneError
529
42
  configSchema: buildChannelConfigSchema(XgImConfigSchema),
530
43
  capabilities: {
531
44
  chatTypes: ["group"],
@@ -536,7 +49,6 @@ export const xgCworkImChannelPlugin = {
536
49
  blockStreaming: false,
537
50
  },
538
51
  reload: { configPrefixes: ["channels.xg_cwork_im"] },
539
- // ── 账户配置 ────────────────────────────────────────────────────────────────
540
52
  config: {
541
53
  listAccountIds: (cfg) => {
542
54
  try {
@@ -544,7 +56,6 @@ export const xgCworkImChannelPlugin = {
544
56
  const accounts = config.accounts;
545
57
  if (accounts) {
546
58
  if (!Array.isArray(accounts) && typeof accounts === "object") {
547
- // 推荐写法:对象 key 作为 accountId
548
59
  const keys = Object.keys(accounts).filter((k) => k !== "default");
549
60
  return keys;
550
61
  }
@@ -563,7 +74,7 @@ export const xgCworkImChannelPlugin = {
563
74
  accountId: id,
564
75
  config,
565
76
  enabled: config.enabled !== false,
566
- configured: Boolean(config.appKey && config.baseUrl),
77
+ configured: Boolean((config.robotKey ?? config.appKey) && config.baseUrl),
567
78
  name: config.name ?? null,
568
79
  };
569
80
  }
@@ -577,8 +88,6 @@ export const xgCworkImChannelPlugin = {
577
88
  };
578
89
  }
579
90
  },
580
- // 注意:OpenClaw 可能在缺省路由时使用 defaultAccountId。
581
- // 我们使用 "default" 作为别名:getXgImConfig 会自动 fallback 到第一个非 default 的真实账号。
582
91
  defaultAccountId: () => "default",
583
92
  isConfigured: (account) => account.configured,
584
93
  describeAccount: (account) => ({
@@ -588,7 +97,6 @@ export const xgCworkImChannelPlugin = {
588
97
  configured: account.configured,
589
98
  }),
590
99
  },
591
- // ── 群聊设置 ─────────────────────────────────────────────────────────────────
592
100
  groups: {
593
101
  resolveRequireMention: (params) => {
594
102
  try {
@@ -596,11 +104,10 @@ export const xgCworkImChannelPlugin = {
596
104
  return config?.groupPolicy !== "open";
597
105
  }
598
106
  catch {
599
- return true; // 默认需要 @
107
+ return true;
600
108
  }
601
109
  },
602
110
  },
603
- // ── 出站消息(openclaw 主动发送时调用)────────────────────────────────────────
604
111
  outbound: {
605
112
  deliveryMode: "direct",
606
113
  resolveTarget: (params) => {
@@ -624,7 +131,6 @@ export const xgCworkImChannelPlugin = {
624
131
  messageId: randomUUID(),
625
132
  };
626
133
  },
627
- /** 出站带媒体:上传资源后发 FILE,与网关 deliver 逻辑一致(OpenClaw 要求与 sendText 同时实现) */
628
134
  sendMedia: async (ctx) => {
629
135
  const { cfg, to, text, mediaUrl, accountId, log: ctxLog } = ctx;
630
136
  const log = toLogger(ctxLog);
@@ -641,27 +147,22 @@ export const xgCworkImChannelPlugin = {
641
147
  };
642
148
  },
643
149
  },
644
- // ── 网关(WebSocket 长连接)─────────────────────────────────────────────────
645
150
  gateway: {
646
151
  startAccount: async (ctx) => {
647
152
  const account = ctx.account;
648
153
  const config = account.config;
649
154
  const log = toLogger(ctx.log);
650
155
  const logPrefix = `[${account.accountId}:${config.agentId || "main"}]`;
651
- if (!config.appKey || !config.baseUrl) {
652
- 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`);
653
158
  }
654
159
  log.info(`${logPrefix} Starting xg-cwork-im channel...`);
655
- // 1. 获取机器人 token(有效期一年,缓存后无需重复请求)
656
160
  const identity = await getToken(config, log);
657
- // 2. 获取 PluginRuntime(用于路由消息给 OpenClaw)
658
161
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
659
162
  const rt = getXgImRuntime();
660
- // 3. 若 abort 信号已触发则不启动
661
163
  if (ctx.abortSignal?.aborted) {
662
164
  throw new Error(`${logPrefix} Connection aborted before start`);
663
165
  }
664
- // 4. 消息去重(按账户隔离)
665
166
  const processedMsgIds = new Set();
666
167
  const MSG_DEDUP_MAX = 1_000;
667
168
  const isDuplicate = (msgId) => {
@@ -678,7 +179,13 @@ export const xgCworkImChannelPlugin = {
678
179
  processedMsgIds.add(msgId);
679
180
  return false;
680
181
  };
681
- // 5. 收到 WebSocket 消息时的处理逻辑
182
+ const runWithSessionQueue = createRunWithSessionQueue({
183
+ config,
184
+ log,
185
+ logPrefix,
186
+ rt,
187
+ cfg: ctx.cfg,
188
+ });
682
189
  const handleMessage = async (msg) => {
683
190
  const params = msg.params;
684
191
  try {
@@ -713,7 +220,6 @@ export const xgCworkImChannelPlugin = {
713
220
  log.info(`${logPrefix} Message from ${senderName}(${senderId ?? "unknown"}) in group=${params.groupId}: ${text}`);
714
221
  const currentIdentity = await getToken(config, log);
715
222
  const actuallyMentioned = isActuallyMentioned(msg, params, currentIdentity, text);
716
- // 通过 PluginRuntime 路由消息到 OpenClaw
717
223
  const route = rt.channel.routing.resolveAgentRoute({
718
224
  cfg: ctx.cfg,
719
225
  channel: "xg_cwork_im",
@@ -747,12 +253,40 @@ export const xgCworkImChannelPlugin = {
747
253
  senderId,
748
254
  config,
749
255
  });
750
- const groupSystemPromptResolved = resolveXgImGroupSystemPrompt(ctx.cfg, account.accountId);
751
- if (config.debug) {
752
- log.info(`${logPrefix} [GroupSystemPrompt] ` +
753
- (groupSystemPromptResolved
754
- ? `active len=${groupSystemPromptResolved.length} preview=${JSON.stringify(groupSystemPromptResolved.slice(0, 96))}`
755
- : "(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))}`);
756
290
  }
757
291
  const inboundCtx = rt.channel.reply.finalizeInboundContext({
758
292
  Body: body,
@@ -778,19 +312,16 @@ export const xgCworkImChannelPlugin = {
778
312
  GroupChannel: route.sessionKey,
779
313
  ...mediaFieldsFromFileItems(fileItems),
780
314
  UntrustedContext: buildUntrustedContext(msgExt, senderBackground, fileItems),
781
- // 同时保留原始 ext 供可能的后续逻辑使用
782
315
  XgImExt: msgExt,
783
316
  });
784
- // 若消息内容为 /reset,则在将其转交给 OpenClaw 之前完整打印一次入站上下文,便于排查 reset 行为
785
317
  if (text.trim() === "/reset") {
786
318
  try {
787
319
  log.info?.(`${logPrefix} [reset] inboundCtx payload before dispatch: ${JSON.stringify(inboundCtx)}`);
788
320
  }
789
321
  catch {
790
- // 忽略 JSON 序列化异常,避免影响正常流程
322
+ /* ignore */
791
323
  }
792
324
  }
793
- // 【所有消息都必须做】记录到数据库!让 AI 产生“记忆”
794
325
  log.info(`${logPrefix} [session] Recording inbound session sessionKey=${inboundCtx.SessionKey || route.sessionKey}`);
795
326
  await rt.channel.session.recordInboundSession({
796
327
  storePath,
@@ -807,21 +338,23 @@ export const xgCworkImChannelPlugin = {
807
338
  },
808
339
  });
809
340
  if (actuallyMentioned) {
810
- await dispatchMentionedReply({
811
- rt,
812
- cfg: ctx.cfg,
813
- config,
814
- log,
815
- logPrefix,
341
+ let agentWorkspaceDir;
342
+ try {
343
+ agentWorkspaceDir = resolveAgentWorkspaceDir(ctx.cfg, route.agentId);
344
+ }
345
+ catch {
346
+ /* 解析失败时静默降级 */
347
+ }
348
+ await runWithSessionQueue(route.sessionKey, {
816
349
  route,
817
350
  inboundCtx,
818
351
  params,
819
352
  currentIdentity,
820
- streamClient: wsHandle.streamClient,
821
- });
353
+ agentWorkspaceDir,
354
+ skipSessionQueue: isBypassQueueStopMessage(text),
355
+ }, wsHandle.streamClient);
822
356
  }
823
357
  else {
824
- // 没 @ 我,仅作为旁观者缓存记忆,不打扰群里聊天
825
358
  log.debug?.(`${logPrefix} Not mentioned in group, quietly memorized the message context.`);
826
359
  }
827
360
  }
@@ -829,9 +362,15 @@ export const xgCworkImChannelPlugin = {
829
362
  log.error(`${logPrefix} handleMessage error: ${String(err)}`);
830
363
  }
831
364
  };
832
- // 5. 启动 WebSocket 连接
833
- const wsHandle = startWebSocket(config, identity.token, handleMessage, log);
834
- // 6. 阻塞到 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
+ });
835
374
  if (ctx.abortSignal) {
836
375
  await new Promise((resolve) => {
837
376
  const abortHandler = () => {