@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.
- package/README.md +17 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -7
- package/dist/index.js.map +1 -1
- package/dist/src/agent-context-types.d.ts +33 -0
- package/dist/src/agent-context-types.d.ts.map +1 -0
- package/dist/src/agent-context-types.js +26 -0
- package/dist/src/agent-context-types.js.map +1 -0
- package/dist/src/apply-sync-mapping.d.ts +17 -0
- package/dist/src/apply-sync-mapping.d.ts.map +1 -0
- package/dist/src/apply-sync-mapping.js +86 -0
- package/dist/src/apply-sync-mapping.js.map +1 -0
- package/dist/src/auth.d.ts +2 -2
- package/dist/src/auth.d.ts.map +1 -1
- package/dist/src/auth.js +18 -12
- package/dist/src/auth.js.map +1 -1
- package/dist/src/build-project-prompt.d.ts +28 -0
- package/dist/src/build-project-prompt.d.ts.map +1 -0
- package/dist/src/build-project-prompt.js +86 -0
- package/dist/src/build-project-prompt.js.map +1 -0
- package/dist/src/channel-config.d.ts +256 -0
- package/dist/src/channel-config.d.ts.map +1 -0
- package/dist/src/channel-config.js +194 -0
- package/dist/src/channel-config.js.map +1 -0
- package/dist/src/channel-log.d.ts +17 -0
- package/dist/src/channel-log.d.ts.map +1 -0
- package/dist/src/channel-log.js +45 -0
- package/dist/src/channel-log.js.map +1 -0
- package/dist/src/channel.d.ts +4 -7
- package/dist/src/channel.d.ts.map +1 -1
- package/dist/src/channel.js +81 -603
- package/dist/src/channel.js.map +1 -1
- package/dist/src/connection.d.ts +45 -9
- package/dist/src/connection.d.ts.map +1 -1
- package/dist/src/connection.js +117 -5
- package/dist/src/connection.js.map +1 -1
- package/dist/src/dispatch-mentioned-reply.d.ts +25 -0
- package/dist/src/dispatch-mentioned-reply.d.ts.map +1 -0
- package/dist/src/dispatch-mentioned-reply.js +346 -0
- package/dist/src/dispatch-mentioned-reply.js.map +1 -0
- package/dist/src/gateway-session-queue.d.ts +26 -0
- package/dist/src/gateway-session-queue.d.ts.map +1 -0
- package/dist/src/gateway-session-queue.js +82 -0
- package/dist/src/gateway-session-queue.js.map +1 -0
- package/dist/src/group-history-tool.d.ts +11 -3
- package/dist/src/group-history-tool.d.ts.map +1 -1
- package/dist/src/group-history-tool.js +14 -10
- package/dist/src/group-history-tool.js.map +1 -1
- package/dist/src/inbound-display.d.ts +25 -0
- package/dist/src/inbound-display.d.ts.map +1 -0
- package/dist/src/inbound-display.js +100 -0
- package/dist/src/inbound-display.js.map +1 -0
- package/dist/src/project-path-normalize.d.ts +16 -0
- package/dist/src/project-path-normalize.d.ts.map +1 -0
- package/dist/src/project-path-normalize.js +75 -0
- package/dist/src/project-path-normalize.js.map +1 -0
- package/dist/src/resolve-inbound-group-prompt.d.ts +18 -0
- package/dist/src/resolve-inbound-group-prompt.d.ts.map +1 -0
- package/dist/src/resolve-inbound-group-prompt.js +38 -0
- package/dist/src/resolve-inbound-group-prompt.js.map +1 -0
- package/dist/src/resource-file.d.ts +17 -8
- package/dist/src/resource-file.d.ts.map +1 -1
- package/dist/src/resource-file.js +60 -21
- package/dist/src/resource-file.js.map +1 -1
- package/dist/src/send-group-message-tool.d.ts +3 -2
- package/dist/src/send-group-message-tool.d.ts.map +1 -1
- package/dist/src/send-group-message-tool.js +5 -4
- package/dist/src/send-group-message-tool.js.map +1 -1
- package/dist/src/send-service.d.ts +5 -1
- package/dist/src/send-service.d.ts.map +1 -1
- package/dist/src/send-service.js +6 -2
- package/dist/src/send-service.js.map +1 -1
- package/dist/src/sync-mapping-types.d.ts +170 -0
- package/dist/src/sync-mapping-types.d.ts.map +1 -0
- package/dist/src/sync-mapping-types.js +41 -0
- package/dist/src/sync-mapping-types.js.map +1 -0
- package/dist/src/sync-service-client.d.ts +27 -0
- package/dist/src/sync-service-client.d.ts.map +1 -0
- package/dist/src/sync-service-client.js +72 -0
- package/dist/src/sync-service-client.js.map +1 -0
- package/dist/src/types.d.ts +22 -0
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js.map +1 -1
- package/package.json +1 -1
package/dist/src/channel.js
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* XG-IM Channel Plugin —
|
|
2
|
+
* XG-IM Channel Plugin — 组装层
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* -
|
|
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}
|
|
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
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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
|
|
823
|
-
if (
|
|
824
|
-
log.
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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 = () => {
|