@xgjktech/xg_cwork_im 1.11.3 → 1.11.5
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 +20 -10
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/{index.ts → dist/index.js} +5 -9
- package/dist/index.js.map +1 -0
- package/dist/src/auth.d.ts +25 -0
- package/dist/src/auth.d.ts.map +1 -0
- package/{src/auth.ts → dist/src/auth.js} +55 -79
- package/dist/src/auth.js.map +1 -0
- package/dist/src/channel.d.ts +13 -0
- package/dist/src/channel.d.ts.map +1 -0
- package/{src/channel.ts → dist/src/channel.js} +252 -392
- package/dist/src/channel.js.map +1 -0
- package/dist/src/connection.d.ts +51 -0
- package/dist/src/connection.d.ts.map +1 -0
- package/{src/connection.ts → dist/src/connection.js} +63 -124
- package/dist/src/connection.js.map +1 -0
- package/dist/src/group-history-tool.d.ts +23 -0
- package/dist/src/group-history-tool.d.ts.map +1 -0
- package/{src/group-history-tool.ts → dist/src/group-history-tool.js} +168 -204
- package/dist/src/group-history-tool.js.map +1 -0
- package/dist/src/inbound-media-local.d.ts +40 -0
- package/dist/src/inbound-media-local.d.ts.map +1 -0
- package/{src/inbound-media-local.ts → dist/src/inbound-media-local.js} +52 -90
- package/dist/src/inbound-media-local.js.map +1 -0
- package/dist/src/recommended-system-prompt.d.ts +3 -0
- package/dist/src/recommended-system-prompt.d.ts.map +1 -0
- package/dist/src/recommended-system-prompt.js +3 -0
- package/dist/src/recommended-system-prompt.js.map +1 -0
- package/dist/src/resource-file.d.ts +65 -0
- package/dist/src/resource-file.d.ts.map +1 -0
- package/{src/resource-file.ts → dist/src/resource-file.js} +80 -103
- package/dist/src/resource-file.js.map +1 -0
- package/dist/src/send-group-message-tool.d.ts +16 -0
- package/dist/src/send-group-message-tool.d.ts.map +1 -0
- package/{src/send-group-message-tool.ts → dist/src/send-group-message-tool.js} +17 -34
- package/dist/src/send-group-message-tool.js.map +1 -0
- package/dist/src/send-service.d.ts +34 -0
- package/dist/src/send-service.d.ts.map +1 -0
- package/dist/src/send-service.js +263 -0
- package/dist/src/send-service.js.map +1 -0
- package/{src/tool-json-result.ts → dist/src/tool-json-result.d.ts} +14 -24
- package/dist/src/tool-json-result.d.ts.map +1 -0
- package/dist/src/tool-json-result.js +20 -0
- package/dist/src/tool-json-result.js.map +1 -0
- package/{src/types.ts → dist/src/types.d.ts} +32 -95
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +32 -0
- package/dist/src/types.js.map +1 -0
- package/openclaw.plugin.json +6 -0
- package/package.json +7 -6
- package/src/recommended-system-prompt.ts +0 -3
- package/src/send-service.ts +0 -283
|
@@ -7,44 +7,24 @@
|
|
|
7
7
|
* - gateway : 启动 WebSocket 长连接,接收 robotMention 消息后通过
|
|
8
8
|
* PluginRuntime 路由给 OpenClaw 处理
|
|
9
9
|
*/
|
|
10
|
-
|
|
11
10
|
import { randomUUID } from "node:crypto";
|
|
12
|
-
import
|
|
13
|
-
import { buildChannelConfigSchema } from "openclaw/plugin-sdk";
|
|
11
|
+
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/core";
|
|
14
12
|
import { z } from "zod";
|
|
15
13
|
import { clearTokenCache, getToken } from "./auth.js";
|
|
16
|
-
import { startWebSocket
|
|
14
|
+
import { startWebSocket } from "./connection.js";
|
|
17
15
|
import { normalizeInboundWorkspaceSubdir, saveInboundFilesToWorkspace } from "./inbound-media-local.js";
|
|
18
|
-
import { sendReplyDeliverBlock, sendTextMessage
|
|
19
|
-
import {
|
|
20
|
-
imFormatToMimeType,
|
|
21
|
-
type BotIdentity,
|
|
22
|
-
type GatewayStartContext,
|
|
23
|
-
type MsgFileVO,
|
|
24
|
-
type PluginRuntime,
|
|
25
|
-
type ResolvedAccount,
|
|
26
|
-
type WsMessage,
|
|
27
|
-
type WsMessageContent,
|
|
28
|
-
type WsMessageParams,
|
|
29
|
-
type XgImChannelPlugin,
|
|
30
|
-
type XgImConfig,
|
|
31
|
-
} from "./types.js";
|
|
32
|
-
|
|
16
|
+
import { sendReplyDeliverBlock, sendTextMessage } from "./send-service.js";
|
|
33
17
|
// ─── 全局 Runtime(在 index.ts 的 register 中注入)────────────────────────────
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
export function setXgImRuntime(rt: PluginRuntime): void {
|
|
18
|
+
let xgImRuntime = null;
|
|
19
|
+
export function setXgImRuntime(rt) {
|
|
38
20
|
xgImRuntime = rt;
|
|
39
21
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
22
|
+
function getXgImRuntime() {
|
|
23
|
+
if (!xgImRuntime)
|
|
24
|
+
throw new Error("[cwork_im] Plugin runtime not initialized");
|
|
43
25
|
return xgImRuntime;
|
|
44
26
|
}
|
|
45
|
-
|
|
46
27
|
// ─── 配置 Schema ──────────────────────────────────────────────────────────────
|
|
47
|
-
|
|
48
28
|
const XgImAccountConfigSchema = z.object({
|
|
49
29
|
// 注意:为了兼容 OpenClaw doctor 生成的 accounts.default(仅包含默认项,不含 appKey/robotKey),这里先放宽为 optional,
|
|
50
30
|
// 然后在 XgImConfigSchema.superRefine 中强制校验:除 default 之外的账号必须提供 robotKey 或 appKey。
|
|
@@ -60,7 +40,6 @@ const XgImAccountConfigSchema = z.object({
|
|
|
60
40
|
inboundMediaSandboxRootPrefix: z.string().optional(),
|
|
61
41
|
systemPrompt: z.string().optional(),
|
|
62
42
|
});
|
|
63
|
-
|
|
64
43
|
const XgImConfigSchema = z.object({
|
|
65
44
|
robotKey: z.string().optional(),
|
|
66
45
|
appKey: z.string().optional(),
|
|
@@ -91,21 +70,15 @@ const XgImConfigSchema = z.object({
|
|
|
91
70
|
// 多账户:对象 map(key 为 accountId)
|
|
92
71
|
accounts: z.record(z.string(), XgImAccountConfigSchema).optional(),
|
|
93
72
|
}).superRefine((val, ctx) => {
|
|
94
|
-
const badSubdir = (s
|
|
95
|
-
typeof s === "string" && s.trim().length > 0 && normalizeInboundWorkspaceSubdir(s) == null;
|
|
96
|
-
|
|
73
|
+
const badSubdir = (s) => typeof s === "string" && s.trim().length > 0 && normalizeInboundWorkspaceSubdir(s) == null;
|
|
97
74
|
if (badSubdir(val.inboundMediaWorkspaceSubdir)) {
|
|
98
75
|
ctx.addIssue({
|
|
99
76
|
code: z.ZodIssueCode.custom,
|
|
100
77
|
path: ["inboundMediaWorkspaceSubdir"],
|
|
101
|
-
message:
|
|
102
|
-
"inboundMediaWorkspaceSubdir must be a relative path under workspace (no .., no absolute path)",
|
|
78
|
+
message: "inboundMediaWorkspaceSubdir must be a relative path under workspace (no .., no absolute path)",
|
|
103
79
|
});
|
|
104
80
|
}
|
|
105
|
-
|
|
106
|
-
const badSandboxPrefix = (s: string | undefined): boolean =>
|
|
107
|
-
typeof s === "string" && s.trim().length > 0 && !s.trim().startsWith("/");
|
|
108
|
-
|
|
81
|
+
const badSandboxPrefix = (s) => typeof s === "string" && s.trim().length > 0 && !s.trim().startsWith("/");
|
|
109
82
|
if (badSandboxPrefix(val.inboundMediaSandboxRootPrefix)) {
|
|
110
83
|
ctx.addIssue({
|
|
111
84
|
code: z.ZodIssueCode.custom,
|
|
@@ -113,12 +86,14 @@ const XgImConfigSchema = z.object({
|
|
|
113
86
|
message: "inboundMediaSandboxRootPrefix must be empty or an absolute path such as /workspace",
|
|
114
87
|
});
|
|
115
88
|
}
|
|
116
|
-
|
|
117
89
|
const accounts = val.accounts;
|
|
118
|
-
if (!accounts)
|
|
90
|
+
if (!accounts)
|
|
91
|
+
return;
|
|
119
92
|
for (const [key, acc] of Object.entries(accounts)) {
|
|
120
|
-
if (key === "default")
|
|
121
|
-
|
|
93
|
+
if (key === "default")
|
|
94
|
+
continue;
|
|
95
|
+
if (!acc || typeof acc !== "object")
|
|
96
|
+
continue;
|
|
122
97
|
if (!acc.robotKey && !acc.appKey) {
|
|
123
98
|
ctx.addIssue({
|
|
124
99
|
code: z.ZodIssueCode.custom,
|
|
@@ -130,122 +105,104 @@ const XgImConfigSchema = z.object({
|
|
|
130
105
|
ctx.addIssue({
|
|
131
106
|
code: z.ZodIssueCode.custom,
|
|
132
107
|
path: ["accounts", key, "inboundMediaWorkspaceSubdir"],
|
|
133
|
-
message:
|
|
134
|
-
"inboundMediaWorkspaceSubdir must be a relative path under workspace (no .., no absolute path)",
|
|
108
|
+
message: "inboundMediaWorkspaceSubdir must be a relative path under workspace (no .., no absolute path)",
|
|
135
109
|
});
|
|
136
110
|
}
|
|
137
111
|
if (badSandboxPrefix(acc.inboundMediaSandboxRootPrefix)) {
|
|
138
112
|
ctx.addIssue({
|
|
139
113
|
code: z.ZodIssueCode.custom,
|
|
140
114
|
path: ["accounts", key, "inboundMediaSandboxRootPrefix"],
|
|
141
|
-
message:
|
|
142
|
-
"inboundMediaSandboxRootPrefix must be empty or an absolute path such as /workspace",
|
|
115
|
+
message: "inboundMediaSandboxRootPrefix must be empty or an absolute path such as /workspace",
|
|
143
116
|
});
|
|
144
117
|
}
|
|
145
118
|
}
|
|
146
119
|
});
|
|
147
|
-
|
|
148
120
|
// ─── 辅助函数 ─────────────────────────────────────────────────────────────────
|
|
149
|
-
|
|
150
121
|
/**
|
|
151
122
|
* 规范化 key 字段:优先取 robotKey,没有则取 appKey,统一写入 appKey。
|
|
152
123
|
* 这样下游代码(auth.ts 等)只需读 config.appKey,无需感知 robotKey 的存在。
|
|
153
124
|
*/
|
|
154
|
-
function normalizeRobotKey
|
|
125
|
+
function normalizeRobotKey(config) {
|
|
155
126
|
const resolved = config.robotKey ?? config.appKey;
|
|
156
|
-
if (resolved === config.appKey)
|
|
127
|
+
if (resolved === config.appKey)
|
|
128
|
+
return config;
|
|
157
129
|
return { ...config, appKey: resolved };
|
|
158
130
|
}
|
|
159
|
-
|
|
160
131
|
/** 从顶层 cfg 中取出 XgImConfig,支持多账户(README:channels.xg_cwork_im) */
|
|
161
|
-
function getXgImConfig(cfg
|
|
162
|
-
const raw =
|
|
163
|
-
if (!raw)
|
|
164
|
-
|
|
165
|
-
const accounts = raw.accounts
|
|
166
|
-
const accountMap =
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
: undefined;
|
|
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;
|
|
170
140
|
const defaults = accountMap?.default ? { ...accountMap.default } : undefined;
|
|
171
|
-
|
|
172
141
|
// 指定了具体账户 ID 时,优先合并对应账户配置
|
|
173
142
|
if (accountId && accountId !== "default" && accountMap) {
|
|
174
143
|
const sub = accountMap[accountId];
|
|
175
|
-
if (sub)
|
|
144
|
+
if (sub)
|
|
145
|
+
return normalizeRobotKey({ ...raw, ...(defaults ?? {}), ...sub });
|
|
176
146
|
}
|
|
177
|
-
|
|
178
147
|
// 没有指定 accountId(如 Cron outbound 场景),自动 fallback 到「第一个账户」
|
|
179
148
|
// 避免顶层 raw 没有 appKey/robotKey 时 getToken 失败
|
|
180
149
|
if (accountMap && !raw.appKey && !raw.robotKey) {
|
|
181
150
|
const firstKey = Object.keys(accountMap).find((k) => k !== "default");
|
|
182
151
|
const first = firstKey ? accountMap[firstKey] : undefined;
|
|
183
|
-
if (first)
|
|
152
|
+
if (first)
|
|
153
|
+
return normalizeRobotKey({ ...raw, ...(defaults ?? {}), ...first });
|
|
184
154
|
}
|
|
185
|
-
|
|
186
155
|
return normalizeRobotKey(raw);
|
|
187
156
|
}
|
|
188
|
-
|
|
189
157
|
/**
|
|
190
158
|
* 合并 `channels.xg_cwork_im` 顶层、`accounts.default`、以及 `accounts.<accountId>` 后的 `systemPrompt`,
|
|
191
159
|
* 写入 OpenClaw 入站 `GroupSystemPrompt`(与 getXgImConfig 的非 default 账户合并方式一致;对 default 账户会合并 default 片段)。
|
|
192
160
|
*/
|
|
193
|
-
function resolveXgImGroupSystemPrompt(cfg
|
|
161
|
+
function resolveXgImGroupSystemPrompt(cfg, accountId) {
|
|
194
162
|
try {
|
|
195
|
-
const raw =
|
|
196
|
-
if (!raw)
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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;
|
|
201
169
|
const defaults = accountMap?.default ? { ...accountMap.default } : {};
|
|
202
|
-
const base
|
|
203
|
-
let merged
|
|
170
|
+
const base = { ...raw, ...defaults };
|
|
171
|
+
let merged = base;
|
|
204
172
|
if (accountId && accountId !== "default" && accountMap?.[accountId]) {
|
|
205
173
|
merged = { ...base, ...accountMap[accountId] };
|
|
206
174
|
}
|
|
207
175
|
const t = merged.systemPrompt?.trim();
|
|
208
176
|
return t || undefined;
|
|
209
|
-
}
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
210
179
|
return undefined;
|
|
211
180
|
}
|
|
212
181
|
}
|
|
213
|
-
|
|
214
|
-
function isConfigured(cfg: OpenClawConfig): boolean {
|
|
182
|
+
function isConfigured(cfg) {
|
|
215
183
|
try {
|
|
216
184
|
const c = getXgImConfig(cfg);
|
|
217
185
|
return Boolean(c?.appKey && c?.baseUrl);
|
|
218
|
-
}
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
219
188
|
return false;
|
|
220
189
|
}
|
|
221
190
|
}
|
|
222
|
-
|
|
223
191
|
/**
|
|
224
192
|
* OpenClaw 对入站命令:`CommandAuthorized === undefined` 会按 false 处理,文本 /command 会被静默忽略(仍走普通 AI)。
|
|
225
193
|
* 见 openclaw LINE #26996 等修复。此处与 channels.xg_cwork_im.allowFrom 对齐;allowFrom 为空表示不限制发送者。
|
|
226
194
|
*/
|
|
227
|
-
function isSenderInXgImAllowFrom(config
|
|
195
|
+
function isSenderInXgImAllowFrom(config, senderId) {
|
|
228
196
|
const allow = config.allowFrom ?? [];
|
|
229
|
-
if (allow.length === 0)
|
|
197
|
+
if (allow.length === 0)
|
|
198
|
+
return true;
|
|
230
199
|
const id = senderId?.trim();
|
|
231
|
-
if (!id)
|
|
200
|
+
if (!id)
|
|
201
|
+
return false;
|
|
232
202
|
return allow.includes(id);
|
|
233
203
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
rt: unknown,
|
|
237
|
-
cfg: OpenClawConfig,
|
|
238
|
-
p: { accountId: string; senderId: string | undefined; config: XgImConfig },
|
|
239
|
-
): boolean {
|
|
240
|
-
const r = rt as {
|
|
241
|
-
channel?: {
|
|
242
|
-
commands?: {
|
|
243
|
-
resolveControlCommandGate?: (args: Record<string, unknown>) =>
|
|
244
|
-
| boolean
|
|
245
|
-
| { commandAuthorized?: boolean };
|
|
246
|
-
};
|
|
247
|
-
};
|
|
248
|
-
};
|
|
204
|
+
function resolveInboundCommandAuthorized(rt, cfg, p) {
|
|
205
|
+
const r = rt;
|
|
249
206
|
const fn = r?.channel?.commands?.resolveControlCommandGate;
|
|
250
207
|
if (typeof fn === "function") {
|
|
251
208
|
try {
|
|
@@ -256,43 +213,63 @@ function resolveInboundCommandAuthorized(
|
|
|
256
213
|
senderId: p.senderId ?? "",
|
|
257
214
|
chatType: "group",
|
|
258
215
|
});
|
|
259
|
-
if (typeof out === "boolean")
|
|
216
|
+
if (typeof out === "boolean")
|
|
217
|
+
return out;
|
|
260
218
|
if (out && typeof out === "object" && "commandAuthorized" in out) {
|
|
261
|
-
return Boolean(
|
|
219
|
+
return Boolean(out.commandAuthorized);
|
|
262
220
|
}
|
|
263
|
-
}
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
264
223
|
/* 回退到 allowFrom */
|
|
265
224
|
}
|
|
266
225
|
}
|
|
267
226
|
return isSenderInXgImAllowFrom(p.config, p.senderId);
|
|
268
227
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
+
};
|
|
281
256
|
return {
|
|
282
|
-
info:
|
|
283
|
-
warn:
|
|
284
|
-
error:
|
|
285
|
-
debug: (msg) =>
|
|
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
|
+
},
|
|
286
266
|
};
|
|
287
267
|
}
|
|
288
|
-
|
|
289
268
|
// ─── WebSocket 入站:纯函数与 @ 后派发 ───────────────────────────────────────
|
|
290
|
-
|
|
291
|
-
function collectFileItems(msgContent: WsMessageContent | undefined): MsgFileVO[] {
|
|
269
|
+
function collectFileItems(msgContent) {
|
|
292
270
|
return (msgContent?.files ?? []).filter((f) => f.url?.trim());
|
|
293
271
|
}
|
|
294
|
-
|
|
295
|
-
function buildInboundDisplayText(msgContent: WsMessageContent | undefined, fileItems: MsgFileVO[]): string {
|
|
272
|
+
function buildInboundDisplayText(msgContent, fileItems) {
|
|
296
273
|
let rawText = msgContent?.text ?? "";
|
|
297
274
|
if (fileItems.length > 0) {
|
|
298
275
|
const lines = fileItems.map((f) => {
|
|
@@ -301,67 +278,54 @@ function buildInboundDisplayText(msgContent: WsMessageContent | undefined, fileI
|
|
|
301
278
|
});
|
|
302
279
|
const block = `**附件(${fileItems.length})**\n${lines.join("\n")}`;
|
|
303
280
|
rawText = rawText.trim() ? `${rawText.trim()}\n\n${block}` : block;
|
|
304
|
-
|
|
305
281
|
const parsedParts = fileItems
|
|
306
282
|
.map((f) => {
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
}
|
|
312
|
-
|
|
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);
|
|
313
290
|
if (parsedParts.length > 0) {
|
|
314
291
|
const parsedBlock = `**附件解析内容**\n\n${parsedParts.join("\n\n")}`;
|
|
315
292
|
rawText = `${rawText.trim()}\n\n${parsedBlock}`;
|
|
316
293
|
}
|
|
317
|
-
|
|
318
|
-
const urlGuard =
|
|
319
|
-
"**附件 URL 使用约束**\n" +
|
|
294
|
+
const urlGuard = "**附件 URL 使用约束**\n" +
|
|
320
295
|
"处理附件时必须逐字原样使用消息中提供的完整 URL;禁止修改、删除或重排任何 query 参数。";
|
|
321
296
|
rawText = `${rawText.trim()}\n\n${urlGuard}`;
|
|
322
297
|
}
|
|
323
298
|
return rawText;
|
|
324
299
|
}
|
|
325
|
-
|
|
326
|
-
function isActuallyMentioned(
|
|
327
|
-
msg: WsMessage,
|
|
328
|
-
params: WsMessageParams,
|
|
329
|
-
bot: { userId: string; name: string },
|
|
330
|
-
rawTextForLegacy: string,
|
|
331
|
-
): boolean {
|
|
300
|
+
function isActuallyMentioned(msg, params, bot, rawTextForLegacy) {
|
|
332
301
|
const mentions = params.mentions;
|
|
333
|
-
const isMentioned =
|
|
334
|
-
|
|
335
|
-
const isLegacyMentioned =
|
|
336
|
-
msg.cmd === "robotMention" &&
|
|
302
|
+
const isMentioned = Array.isArray(mentions) && (mentions.includes(bot.userId) || mentions.includes("all"));
|
|
303
|
+
const isLegacyMentioned = msg.cmd === "robotMention" &&
|
|
337
304
|
(!params.mentions || params.mentions.length === 0) &&
|
|
338
305
|
new RegExp(`@${bot.name}\\b`).test(rawTextForLegacy);
|
|
339
306
|
return isMentioned || isLegacyMentioned;
|
|
340
307
|
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
308
|
+
function mediaFieldsFromFileItems(fileItems) {
|
|
309
|
+
if (fileItems.length === 0)
|
|
310
|
+
return {};
|
|
344
311
|
// 只传 MediaPath(s),不传 MediaType(s)。
|
|
345
312
|
// 预签名 URL 的 query 中常含 response-content-type;若额外给 MediaType,模型/工具更容易“改写 URL”导致签名失效。
|
|
346
313
|
if (fileItems.length === 1) {
|
|
347
314
|
return {
|
|
348
|
-
MediaPath: fileItems[0]
|
|
315
|
+
MediaPath: fileItems[0].url,
|
|
349
316
|
};
|
|
350
317
|
}
|
|
351
318
|
return {
|
|
352
319
|
MediaPaths: fileItems.map((f) => f.url),
|
|
353
320
|
};
|
|
354
321
|
}
|
|
355
|
-
|
|
356
322
|
/** ext / 用户 background / 附件元数据(不含下载 URL,避免预签名链过长;URL 已在入站 MediaPath(s)) */
|
|
357
|
-
function buildUntrustedContext(
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
)
|
|
362
|
-
|
|
363
|
-
if (msgExt) parts.push(JSON.stringify(msgExt));
|
|
364
|
-
if (senderBackground) parts.push(String(senderBackground));
|
|
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));
|
|
365
329
|
if (fileItems.length > 0) {
|
|
366
330
|
const summary = fileItems.map((f) => ({
|
|
367
331
|
name: f.name ?? null,
|
|
@@ -374,13 +338,7 @@ function buildUntrustedContext(
|
|
|
374
338
|
}
|
|
375
339
|
return parts.length > 0 ? parts : undefined;
|
|
376
340
|
}
|
|
377
|
-
|
|
378
|
-
function buildTargetReplyMeta(params: WsMessageParams): {
|
|
379
|
-
targetMsgId: string;
|
|
380
|
-
targetUserId: string;
|
|
381
|
-
targetUserName: string;
|
|
382
|
-
previewText: string;
|
|
383
|
-
} {
|
|
341
|
+
function buildTargetReplyMeta(params) {
|
|
384
342
|
return {
|
|
385
343
|
targetMsgId: params.msgId,
|
|
386
344
|
targetUserId: params.userInfo?.id ?? "",
|
|
@@ -388,168 +346,133 @@ function buildTargetReplyMeta(params: WsMessageParams): {
|
|
|
388
346
|
previewText: params.msgContent?.text ?? "",
|
|
389
347
|
};
|
|
390
348
|
}
|
|
391
|
-
|
|
392
349
|
/** 流式 START → dispatch → 必发 END;首包超时走 HTTP 覆盖占位 */
|
|
393
|
-
async function dispatchMentionedReply(args
|
|
394
|
-
rt: any;
|
|
395
|
-
cfg: OpenClawConfig;
|
|
396
|
-
config: XgImConfig;
|
|
397
|
-
log: Logger;
|
|
398
|
-
logPrefix: string;
|
|
399
|
-
route: { sessionKey: string };
|
|
400
|
-
inboundCtx: unknown;
|
|
401
|
-
params: WsMessageParams;
|
|
402
|
-
currentIdentity: BotIdentity;
|
|
403
|
-
streamClient: ImStreamClient;
|
|
404
|
-
}): Promise<void> {
|
|
350
|
+
async function dispatchMentionedReply(args) {
|
|
405
351
|
const { rt, cfg, config, log, logPrefix, route, inboundCtx, params, currentIdentity, streamClient } = args;
|
|
406
|
-
|
|
407
352
|
log.info(`${logPrefix} [dispatch] Dispatching to OpenClaw AI, sessionKey=${route.sessionKey}`);
|
|
408
353
|
let isFirstReply = true;
|
|
409
354
|
const dispatchStart = Date.now();
|
|
410
|
-
|
|
411
|
-
const { msgId: streamMsgId } = await streamClient.start({
|
|
355
|
+
const { msgId: ackPlaceholderMsgId } = await streamClient.start({
|
|
412
356
|
groupId: params.groupId,
|
|
413
357
|
});
|
|
414
|
-
|
|
415
|
-
|
|
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
|
+
}
|
|
416
365
|
let hasFirstReply = false;
|
|
417
366
|
const firstReplyTimeoutMs = config.firstReplyTimeoutMs ?? 30 * 60_000;
|
|
418
367
|
const timeoutLabel = `${logPrefix} [stream] First reply timeout after ${firstReplyTimeoutMs}ms, updating thinking message as error`;
|
|
419
368
|
const firstReplyTimeout = setTimeout(async () => {
|
|
420
|
-
if (hasFirstReply)
|
|
369
|
+
if (hasFirstReply)
|
|
370
|
+
return;
|
|
421
371
|
log.warn(timeoutLabel);
|
|
422
372
|
try {
|
|
423
373
|
const reply = buildTargetReplyMeta(params);
|
|
424
374
|
const timeoutText = "当前请求处理超时,请稍后重试。";
|
|
425
|
-
await sendTextMessage(
|
|
426
|
-
config,
|
|
427
|
-
currentIdentity.token,
|
|
428
|
-
params.groupId,
|
|
429
|
-
timeoutText,
|
|
430
|
-
[reply.targetUserId] as string[],
|
|
431
|
-
log,
|
|
432
|
-
streamMsgId,
|
|
433
|
-
reply,
|
|
434
|
-
);
|
|
375
|
+
await sendTextMessage(config, currentIdentity.token, params.groupId, timeoutText, [reply.targetUserId], log, streamMsgId, reply);
|
|
435
376
|
// 标记已发送,让 finally 以 reason=stop 结束,避免后台再显示"AI未响应"
|
|
436
377
|
hasFirstReply = true;
|
|
437
|
-
log.info(
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
} catch (err: unknown) {
|
|
378
|
+
log.info(`${logPrefix} [send] Timeout reply sent via HTTP: groupId=${params.groupId} msgId=${streamMsgId} text="${timeoutText}"`);
|
|
379
|
+
}
|
|
380
|
+
catch (err) {
|
|
441
381
|
log.error(`${logPrefix} [timeout] Failed to send timeout reply: ${String(err)}`);
|
|
442
382
|
}
|
|
443
383
|
}, firstReplyTimeoutMs);
|
|
444
|
-
|
|
384
|
+
let deliverInvokeCount = 0;
|
|
445
385
|
let deliverCallCount = 0;
|
|
446
386
|
let deliverSkippedCount = 0;
|
|
447
|
-
let dispatchError
|
|
387
|
+
let dispatchError = undefined;
|
|
448
388
|
// 捕获 deliver 抛出后被 dispatcher 静默吞掉的错误:postImMessage 已带 5 次重试,
|
|
449
389
|
// 进到这里说明所有重试均失败(IM 后台不可用 / 4xx / resultCode 业务错误等),需要让 finally 走兜底分支
|
|
450
|
-
let lastDeliverError
|
|
451
|
-
|
|
390
|
+
let lastDeliverError = undefined;
|
|
452
391
|
try {
|
|
453
392
|
await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
454
393
|
ctx: inboundCtx,
|
|
455
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
|
+
},
|
|
456
404
|
dispatcherOptions: {
|
|
457
405
|
responsePrefix: "",
|
|
458
406
|
// Logged when normalizeReplyPayload decides to skip a payload (e.g. heartbeat, silent token, empty).
|
|
459
|
-
onSkip: (payload
|
|
407
|
+
onSkip: (payload, meta) => {
|
|
460
408
|
deliverSkippedCount++;
|
|
461
|
-
log.info(
|
|
462
|
-
`${logPrefix} [deliver] Payload skipped by normalizer kind=${meta.kind} reason=${meta.reason}`,
|
|
463
|
-
);
|
|
409
|
+
log.info(`${logPrefix} [deliver] Payload skipped by normalizer kind=${meta.kind} reason=${meta.reason}`);
|
|
464
410
|
},
|
|
465
411
|
// Logged when deliver throws and the dispatcher catches it (error would otherwise be silently dropped).
|
|
466
|
-
onError: (err
|
|
412
|
+
onError: (err, meta) => {
|
|
467
413
|
lastDeliverError = err;
|
|
468
|
-
log.error(
|
|
469
|
-
`${logPrefix} [deliver] Dispatcher caught unhandled error kind=${meta.kind}: ${String(err)}`,
|
|
470
|
-
);
|
|
414
|
+
log.error(`${logPrefix} [deliver] Dispatcher caught unhandled error kind=${meta.kind}: ${String(err)}`);
|
|
471
415
|
},
|
|
472
|
-
deliver: async (payload
|
|
416
|
+
deliver: async (payload) => {
|
|
473
417
|
try {
|
|
418
|
+
deliverInvokeCount++;
|
|
474
419
|
const textPart = (payload.markdown || payload.text || "").trim();
|
|
475
|
-
const hasMedia =
|
|
476
|
-
Boolean(payload.mediaUrl?.trim()) ||
|
|
420
|
+
const hasMedia = Boolean(payload.mediaUrl?.trim()) ||
|
|
477
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)")}`);
|
|
478
426
|
if (!textPart && !(hasMedia && !payload.isThinking)) {
|
|
479
|
-
log.info(
|
|
480
|
-
|
|
481
|
-
` (isThinking=${payload.isThinking ?? false} hasMedia=${hasMedia})`,
|
|
482
|
-
);
|
|
427
|
+
log.info(`${logPrefix} [deliver] Payload has no sendable content, skipping` +
|
|
428
|
+
` (isThinking=${payload.isThinking ?? false} hasMedia=${hasMedia})`);
|
|
483
429
|
return;
|
|
484
430
|
}
|
|
485
|
-
|
|
486
431
|
deliverCallCount++;
|
|
487
432
|
if (isFirstReply) {
|
|
488
433
|
const ttfr = Date.now() - dispatchStart;
|
|
489
|
-
log.info(
|
|
490
|
-
`${logPrefix} [deliver] First response block received from AI (TTFB: ${ttfr}ms)`,
|
|
491
|
-
);
|
|
434
|
+
log.info(`${logPrefix} [deliver] First response block received from AI (TTFB: ${ttfr}ms)`);
|
|
492
435
|
isFirstReply = false;
|
|
493
436
|
}
|
|
494
|
-
|
|
495
437
|
const reply = buildTargetReplyMeta(params);
|
|
496
|
-
const atIds = [reply.targetUserId]
|
|
497
|
-
|
|
438
|
+
const atIds = [reply.targetUserId];
|
|
498
439
|
if (!hasFirstReply) {
|
|
499
440
|
clearTimeout(firstReplyTimeout);
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
currentIdentity.token,
|
|
503
|
-
params.groupId,
|
|
504
|
-
payload,
|
|
505
|
-
atIds,
|
|
506
|
-
log,
|
|
507
|
-
streamMsgId,
|
|
508
|
-
reply,
|
|
509
|
-
);
|
|
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);
|
|
510
443
|
hasFirstReply = true; // set after successful send
|
|
511
|
-
const preview =
|
|
512
|
-
|
|
513
|
-
log.info(
|
|
514
|
-
`${logPrefix} [send] First reply sent via HTTP: groupId=${params.groupId} msgId=${streamMsgId} preview="${preview}"`,
|
|
515
|
-
);
|
|
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}"`);
|
|
516
446
|
return;
|
|
517
447
|
}
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
payload,
|
|
524
|
-
atIds,
|
|
525
|
-
log,
|
|
526
|
-
undefined,
|
|
527
|
-
reply,
|
|
528
|
-
);
|
|
529
|
-
const preview =
|
|
530
|
-
textPart.length > 80 ? `${textPart.slice(0, 80)}...` : textPart || "[media]";
|
|
531
|
-
log.info(
|
|
532
|
-
`${logPrefix} [send] Additional reply sent via HTTP: groupId=${params.groupId} preview="${preview}"`,
|
|
533
|
-
);
|
|
534
|
-
} catch (err: unknown) {
|
|
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) {
|
|
535
453
|
log.error(`${logPrefix} [deliver] Reply deliver failed: ${String(err)}`);
|
|
536
454
|
throw err;
|
|
537
455
|
}
|
|
538
456
|
},
|
|
539
457
|
},
|
|
540
458
|
});
|
|
541
|
-
log.info(
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
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) {
|
|
546
471
|
dispatchError = dispatchErr;
|
|
547
|
-
log.error(
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
} finally {
|
|
472
|
+
log.error(`${logPrefix} [dispatch] Dispatch failed (invoke=${deliverInvokeCount} delivered=${deliverCallCount} skipped=${deliverSkippedCount}): ${String(dispatchErr)}`);
|
|
473
|
+
}
|
|
474
|
+
finally {
|
|
551
475
|
clearTimeout(firstReplyTimeout);
|
|
552
|
-
|
|
553
476
|
// 兜底原则:
|
|
554
477
|
// - 有"实际错误"(dispatch 自身异常 / deliver 全部重试后仍失败)且尚未发过任何消息
|
|
555
478
|
// → 主动告知用户,以 reason=stop 结束(后台信任已发消息)。
|
|
@@ -559,51 +482,39 @@ async function dispatchMentionedReply(args: {
|
|
|
559
482
|
const reportableError = dispatchError ?? lastDeliverError;
|
|
560
483
|
if (reportableError !== undefined) {
|
|
561
484
|
const errSource = dispatchError !== undefined ? "dispatch" : "deliver";
|
|
562
|
-
log.warn(
|
|
563
|
-
|
|
564
|
-
` (delivered=${deliverCallCount} skipped=${deliverSkippedCount}), sending error notice`,
|
|
565
|
-
);
|
|
485
|
+
log.warn(`${logPrefix} [dispatch] ${errSource} error with no prior reply` +
|
|
486
|
+
` (invoke=${deliverInvokeCount} delivered=${deliverCallCount} skipped=${deliverSkippedCount}), sending error notice`);
|
|
566
487
|
const replyMeta = buildTargetReplyMeta(params);
|
|
567
488
|
try {
|
|
568
|
-
await sendTextMessage(
|
|
569
|
-
config,
|
|
570
|
-
currentIdentity.token,
|
|
571
|
-
params.groupId,
|
|
572
|
-
`当前请求处理时出现异常,请稍后重试。\n错误信息:${String(reportableError)}`,
|
|
573
|
-
[replyMeta.targetUserId] as string[],
|
|
574
|
-
log,
|
|
575
|
-
streamMsgId,
|
|
576
|
-
replyMeta,
|
|
577
|
-
);
|
|
489
|
+
await sendTextMessage(config, currentIdentity.token, params.groupId, `当前请求处理时出现异常,请稍后重试。\n错误信息:${String(reportableError)}`, [replyMeta.targetUserId], log, streamMsgId, replyMeta);
|
|
578
490
|
hasFirstReply = true;
|
|
579
|
-
} catch (fallbackErr: unknown) {
|
|
580
|
-
log.error(
|
|
581
|
-
`${logPrefix} [dispatch] Failed to send error notice (${errSource} error): ${String(fallbackErr)}`,
|
|
582
|
-
);
|
|
583
491
|
}
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
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
|
+
: ""));
|
|
589
502
|
}
|
|
590
503
|
}
|
|
591
|
-
|
|
592
504
|
// reason=stop:已通过 HTTP 发送了至少一条回复(含兜底错误消息),后台信任已有消息。
|
|
593
505
|
// reason=no_reply:本次无任何回复且无异常,后台凭此展示"AI未响应"。
|
|
594
506
|
const endReason = hasFirstReply ? "stop" : "no_reply";
|
|
595
507
|
try {
|
|
596
508
|
await streamClient.end(streamMsgId, endReason);
|
|
597
509
|
log.info(`${logPrefix} [stream] END sent: msgId=${streamMsgId} reason=${endReason}`);
|
|
598
|
-
}
|
|
510
|
+
}
|
|
511
|
+
catch (endErr) {
|
|
599
512
|
log.error(`${logPrefix} [stream] END failed (msgId=${streamMsgId}): ${String(endErr)}`);
|
|
600
513
|
}
|
|
601
514
|
}
|
|
602
515
|
}
|
|
603
|
-
|
|
604
516
|
// ─── Channel Plugin 定义 ─────────────────────────────────────────────────────
|
|
605
|
-
|
|
606
|
-
export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
517
|
+
export const xgCworkImChannelPlugin = {
|
|
607
518
|
id: "xg_cwork_im",
|
|
608
519
|
meta: {
|
|
609
520
|
id: "xg_cwork_im",
|
|
@@ -615,9 +526,9 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
615
526
|
},
|
|
616
527
|
// 双重类型转换绕过 zod v3/v4 的 TS 类型不兼容
|
|
617
528
|
// buildChannelConfigSchema 会将 schema 包装成可序列化的形式,避免 DataCloneError
|
|
618
|
-
configSchema: buildChannelConfigSchema(XgImConfigSchema
|
|
529
|
+
configSchema: buildChannelConfigSchema(XgImConfigSchema),
|
|
619
530
|
capabilities: {
|
|
620
|
-
chatTypes: ["group"]
|
|
531
|
+
chatTypes: ["group"],
|
|
621
532
|
reactions: false,
|
|
622
533
|
threads: false,
|
|
623
534
|
media: true,
|
|
@@ -625,27 +536,26 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
625
536
|
blockStreaming: false,
|
|
626
537
|
},
|
|
627
538
|
reload: { configPrefixes: ["channels.xg_cwork_im"] },
|
|
628
|
-
|
|
629
539
|
// ── 账户配置 ────────────────────────────────────────────────────────────────
|
|
630
540
|
config: {
|
|
631
|
-
listAccountIds: (cfg
|
|
541
|
+
listAccountIds: (cfg) => {
|
|
632
542
|
try {
|
|
633
543
|
const config = getXgImConfig(cfg);
|
|
634
|
-
const accounts = config.accounts
|
|
544
|
+
const accounts = config.accounts;
|
|
635
545
|
if (accounts) {
|
|
636
546
|
if (!Array.isArray(accounts) && typeof accounts === "object") {
|
|
637
547
|
// 推荐写法:对象 key 作为 accountId
|
|
638
|
-
const keys = Object.keys(accounts
|
|
548
|
+
const keys = Object.keys(accounts).filter((k) => k !== "default");
|
|
639
549
|
return keys;
|
|
640
550
|
}
|
|
641
551
|
}
|
|
642
552
|
return isConfigured(cfg) ? ["default"] : [];
|
|
643
|
-
}
|
|
553
|
+
}
|
|
554
|
+
catch {
|
|
644
555
|
return [];
|
|
645
556
|
}
|
|
646
557
|
},
|
|
647
|
-
|
|
648
|
-
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null): ResolvedAccount => {
|
|
558
|
+
resolveAccount: (cfg, accountId) => {
|
|
649
559
|
const id = accountId || "default";
|
|
650
560
|
try {
|
|
651
561
|
const config = getXgImConfig(cfg, id);
|
|
@@ -656,166 +566,135 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
656
566
|
configured: Boolean(config.appKey && config.baseUrl),
|
|
657
567
|
name: config.name ?? null,
|
|
658
568
|
};
|
|
659
|
-
}
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
660
571
|
return {
|
|
661
572
|
accountId: id,
|
|
662
|
-
config: {}
|
|
573
|
+
config: {},
|
|
663
574
|
enabled: false,
|
|
664
575
|
configured: false,
|
|
665
576
|
name: null,
|
|
666
577
|
};
|
|
667
578
|
}
|
|
668
579
|
},
|
|
669
|
-
|
|
670
580
|
// 注意:OpenClaw 可能在缺省路由时使用 defaultAccountId。
|
|
671
581
|
// 我们使用 "default" 作为别名:getXgImConfig 会自动 fallback 到第一个非 default 的真实账号。
|
|
672
|
-
defaultAccountId: ()
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
describeAccount: (account: ResolvedAccount) => ({
|
|
582
|
+
defaultAccountId: () => "default",
|
|
583
|
+
isConfigured: (account) => account.configured,
|
|
584
|
+
describeAccount: (account) => ({
|
|
677
585
|
accountId: account.accountId,
|
|
678
586
|
name: account.config?.name ?? "xg_cwork_im",
|
|
679
587
|
enabled: account.enabled,
|
|
680
588
|
configured: account.configured,
|
|
681
589
|
}),
|
|
682
590
|
},
|
|
683
|
-
|
|
684
591
|
// ── 群聊设置 ─────────────────────────────────────────────────────────────────
|
|
685
592
|
groups: {
|
|
686
|
-
resolveRequireMention: (params)
|
|
593
|
+
resolveRequireMention: (params) => {
|
|
687
594
|
try {
|
|
688
595
|
const config = getXgImConfig(params.cfg, params.accountId);
|
|
689
596
|
return config?.groupPolicy !== "open";
|
|
690
|
-
}
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
691
599
|
return true; // 默认需要 @
|
|
692
600
|
}
|
|
693
601
|
},
|
|
694
602
|
},
|
|
695
|
-
|
|
696
603
|
// ── 出站消息(openclaw 主动发送时调用)────────────────────────────────────────
|
|
697
604
|
outbound: {
|
|
698
|
-
deliveryMode: "direct"
|
|
699
|
-
|
|
605
|
+
deliveryMode: "direct",
|
|
700
606
|
resolveTarget: (params) => {
|
|
701
607
|
const trimmed = params.to?.trim();
|
|
702
608
|
if (!trimmed) {
|
|
703
609
|
return {
|
|
704
|
-
ok: false
|
|
610
|
+
ok: false,
|
|
705
611
|
error: new Error("XG-IM message requires --to <groupId>"),
|
|
706
612
|
};
|
|
707
613
|
}
|
|
708
|
-
return { ok: true
|
|
614
|
+
return { ok: true, to: trimmed };
|
|
709
615
|
},
|
|
710
|
-
|
|
711
616
|
sendText: async (ctx) => {
|
|
712
|
-
const { cfg, to, text, accountId, log: ctxLog } = ctx
|
|
617
|
+
const { cfg, to, text, accountId, log: ctxLog } = ctx;
|
|
713
618
|
const log = toLogger(ctxLog);
|
|
714
619
|
const config = getXgImConfig(cfg, accountId);
|
|
715
620
|
const identity = await getToken(config, log);
|
|
716
|
-
|
|
717
621
|
await sendTextMessage(config, identity.token, to, text, [], log);
|
|
718
|
-
|
|
719
622
|
return {
|
|
720
623
|
channel: "xg_cwork_im",
|
|
721
624
|
messageId: randomUUID(),
|
|
722
625
|
};
|
|
723
626
|
},
|
|
724
|
-
|
|
725
627
|
/** 出站带媒体:上传资源后发 FILE,与网关 deliver 逻辑一致(OpenClaw 要求与 sendText 同时实现) */
|
|
726
628
|
sendMedia: async (ctx) => {
|
|
727
|
-
const { cfg, to, text, mediaUrl, accountId, log: ctxLog } = ctx
|
|
629
|
+
const { cfg, to, text, mediaUrl, accountId, log: ctxLog } = ctx;
|
|
728
630
|
const log = toLogger(ctxLog);
|
|
729
631
|
const config = getXgImConfig(cfg, accountId);
|
|
730
632
|
const identity = await getToken(config, log);
|
|
731
|
-
|
|
732
633
|
if (!mediaUrl?.trim()) {
|
|
733
634
|
await sendTextMessage(config, identity.token, to, text || "", [], log);
|
|
734
635
|
return { channel: "xg_cwork_im", messageId: randomUUID() };
|
|
735
636
|
}
|
|
736
|
-
|
|
737
|
-
await sendReplyDeliverBlock(
|
|
738
|
-
config,
|
|
739
|
-
identity.token,
|
|
740
|
-
to,
|
|
741
|
-
{ text: text || "", mediaUrl: mediaUrl.trim() },
|
|
742
|
-
[],
|
|
743
|
-
log,
|
|
744
|
-
undefined,
|
|
745
|
-
undefined,
|
|
746
|
-
);
|
|
747
|
-
|
|
637
|
+
await sendReplyDeliverBlock(config, identity.token, to, { text: text || "", mediaUrl: mediaUrl.trim() }, [], log, undefined, undefined);
|
|
748
638
|
return {
|
|
749
639
|
channel: "xg_cwork_im",
|
|
750
640
|
messageId: randomUUID(),
|
|
751
641
|
};
|
|
752
642
|
},
|
|
753
643
|
},
|
|
754
|
-
|
|
755
644
|
// ── 网关(WebSocket 长连接)─────────────────────────────────────────────────
|
|
756
645
|
gateway: {
|
|
757
|
-
startAccount: async (ctx
|
|
646
|
+
startAccount: async (ctx) => {
|
|
758
647
|
const account = ctx.account;
|
|
759
648
|
const config = account.config;
|
|
760
649
|
const log = toLogger(ctx.log);
|
|
761
650
|
const logPrefix = `[${account.accountId}:${config.agentId || "main"}]`;
|
|
762
|
-
|
|
763
651
|
if (!config.appKey || !config.baseUrl) {
|
|
764
652
|
throw new Error(`${logPrefix} appKey and baseUrl are required in config`);
|
|
765
653
|
}
|
|
766
|
-
|
|
767
654
|
log.info(`${logPrefix} Starting xg-cwork-im channel...`);
|
|
768
|
-
|
|
769
655
|
// 1. 获取机器人 token(有效期一年,缓存后无需重复请求)
|
|
770
656
|
const identity = await getToken(config, log);
|
|
771
|
-
|
|
772
657
|
// 2. 获取 PluginRuntime(用于路由消息给 OpenClaw)
|
|
773
658
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
774
|
-
const rt = getXgImRuntime()
|
|
775
|
-
|
|
659
|
+
const rt = getXgImRuntime();
|
|
776
660
|
// 3. 若 abort 信号已触发则不启动
|
|
777
661
|
if (ctx.abortSignal?.aborted) {
|
|
778
662
|
throw new Error(`${logPrefix} Connection aborted before start`);
|
|
779
663
|
}
|
|
780
|
-
|
|
781
664
|
// 4. 消息去重(按账户隔离)
|
|
782
|
-
const processedMsgIds = new Set
|
|
665
|
+
const processedMsgIds = new Set();
|
|
783
666
|
const MSG_DEDUP_MAX = 1_000;
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
667
|
+
const isDuplicate = (msgId) => {
|
|
668
|
+
if (processedMsgIds.has(msgId))
|
|
669
|
+
return true;
|
|
787
670
|
if (processedMsgIds.size >= MSG_DEDUP_MAX) {
|
|
788
671
|
const iter = processedMsgIds.values();
|
|
789
672
|
for (let i = 0; i < MSG_DEDUP_MAX / 2; i++) {
|
|
790
673
|
const val = iter.next().value;
|
|
791
|
-
if (val !== undefined)
|
|
674
|
+
if (val !== undefined)
|
|
675
|
+
processedMsgIds.delete(val);
|
|
792
676
|
}
|
|
793
677
|
}
|
|
794
678
|
processedMsgIds.add(msgId);
|
|
795
679
|
return false;
|
|
796
680
|
};
|
|
797
|
-
|
|
798
681
|
// 5. 收到 WebSocket 消息时的处理逻辑
|
|
799
|
-
const handleMessage = async (msg
|
|
682
|
+
const handleMessage = async (msg) => {
|
|
800
683
|
const params = msg.params;
|
|
801
684
|
try {
|
|
802
685
|
if (isDuplicate(params.msgId)) {
|
|
803
686
|
log.debug?.(`${logPrefix} Duplicate msgId=${params.msgId}, skipping`);
|
|
804
687
|
return;
|
|
805
688
|
}
|
|
806
|
-
|
|
807
689
|
const msgContent = params.msgContent;
|
|
808
690
|
let fileItems = collectFileItems(msgContent);
|
|
809
691
|
const msgExt = msgContent?.ext;
|
|
810
692
|
const senderId = params.userInfo?.id;
|
|
811
693
|
const senderName = params.userInfo?.name || senderId || "";
|
|
812
694
|
const senderBackground = params.userInfo?.background;
|
|
813
|
-
|
|
814
695
|
const inboundSub = config.inboundMediaWorkspaceSubdir?.trim();
|
|
815
696
|
if (inboundSub && fileItems.length > 0 && normalizeInboundWorkspaceSubdir(inboundSub)) {
|
|
816
|
-
log.info(
|
|
817
|
-
`${logPrefix} [inbound-local] enabled subdir=${inboundSub} sender=${senderId ?? "unknown"} files=${fileItems.length} pathStyle=${config.inboundMediaOpenClawPath ?? "workspaceRelative"} sandboxPrefix=${config.inboundMediaSandboxRootPrefix ?? "(none)"}`,
|
|
818
|
-
);
|
|
697
|
+
log.info(`${logPrefix} [inbound-local] enabled subdir=${inboundSub} sender=${senderId ?? "unknown"} files=${fileItems.length} pathStyle=${config.inboundMediaOpenClawPath ?? "workspaceRelative"} sandboxPrefix=${config.inboundMediaSandboxRootPrefix ?? "(none)"}`);
|
|
819
698
|
fileItems = await saveInboundFilesToWorkspace({
|
|
820
699
|
rt,
|
|
821
700
|
cfg: ctx.cfg,
|
|
@@ -826,21 +705,14 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
826
705
|
log,
|
|
827
706
|
});
|
|
828
707
|
}
|
|
829
|
-
|
|
830
708
|
const text = buildInboundDisplayText(msgContent, fileItems);
|
|
831
|
-
|
|
832
709
|
if (!text.trim() && fileItems.length === 0) {
|
|
833
710
|
log.debug?.(`${logPrefix} Empty message (no text and no files), skipping`);
|
|
834
711
|
return;
|
|
835
712
|
}
|
|
836
|
-
|
|
837
|
-
log.info(
|
|
838
|
-
`${logPrefix} Message from ${senderName}(${senderId ?? "unknown"}) in group=${params.groupId}: ${text}`,
|
|
839
|
-
);
|
|
840
|
-
|
|
713
|
+
log.info(`${logPrefix} Message from ${senderName}(${senderId ?? "unknown"}) in group=${params.groupId}: ${text}`);
|
|
841
714
|
const currentIdentity = await getToken(config, log);
|
|
842
715
|
const actuallyMentioned = isActuallyMentioned(msg, params, currentIdentity, text);
|
|
843
|
-
|
|
844
716
|
// 通过 PluginRuntime 路由消息到 OpenClaw
|
|
845
717
|
const route = rt.channel.routing.resolveAgentRoute({
|
|
846
718
|
cfg: ctx.cfg,
|
|
@@ -850,17 +722,14 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
850
722
|
peer: { kind: "group", id: params.groupId },
|
|
851
723
|
});
|
|
852
724
|
log.info(`${logPrefix} [route] agentId=${route.agentId} sessionKey=${route.sessionKey}`);
|
|
853
|
-
|
|
854
725
|
const storePath = rt.channel.session.resolveStorePath(ctx.cfg.session?.store, {
|
|
855
726
|
agentId: route.agentId,
|
|
856
727
|
});
|
|
857
|
-
|
|
858
728
|
const envelopeOptions = rt.channel.reply.resolveEnvelopeFormatOptions(ctx.cfg);
|
|
859
729
|
const previousTimestamp = rt.channel.session.readSessionUpdatedAt({
|
|
860
730
|
storePath,
|
|
861
731
|
sessionKey: route.sessionKey,
|
|
862
732
|
});
|
|
863
|
-
|
|
864
733
|
const msgTime = params.timestamp ?? params.msgSendTime ?? 0;
|
|
865
734
|
const fromLabel = `${params.groupId} - ${senderName}`;
|
|
866
735
|
const body = rt.channel.reply.formatInboundEnvelope({
|
|
@@ -873,23 +742,18 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
873
742
|
previousTimestamp,
|
|
874
743
|
envelope: envelopeOptions,
|
|
875
744
|
});
|
|
876
|
-
|
|
877
745
|
const commandAuthorized = resolveInboundCommandAuthorized(rt, ctx.cfg, {
|
|
878
746
|
accountId: account.accountId,
|
|
879
747
|
senderId,
|
|
880
748
|
config,
|
|
881
749
|
});
|
|
882
|
-
|
|
883
750
|
const groupSystemPromptResolved = resolveXgImGroupSystemPrompt(ctx.cfg, account.accountId);
|
|
884
751
|
if (config.debug) {
|
|
885
|
-
log.info(
|
|
886
|
-
|
|
887
|
-
(groupSystemPromptResolved
|
|
888
|
-
|
|
889
|
-
: "(empty — 若已配置 systemPrompt:检查网关是否加载了含本字段的插件构建,或把 systemPrompt 写到 accounts.<id> 再试)"),
|
|
890
|
-
);
|
|
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> 再试)"));
|
|
891
756
|
}
|
|
892
|
-
|
|
893
757
|
const inboundCtx = rt.channel.reply.finalizeInboundContext({
|
|
894
758
|
Body: body,
|
|
895
759
|
RawBody: text,
|
|
@@ -917,18 +781,15 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
917
781
|
// 同时保留原始 ext 供可能的后续逻辑使用
|
|
918
782
|
XgImExt: msgExt,
|
|
919
783
|
});
|
|
920
|
-
|
|
921
784
|
// 若消息内容为 /reset,则在将其转交给 OpenClaw 之前完整打印一次入站上下文,便于排查 reset 行为
|
|
922
785
|
if (text.trim() === "/reset") {
|
|
923
786
|
try {
|
|
924
|
-
log.info?.(
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
} catch {
|
|
787
|
+
log.info?.(`${logPrefix} [reset] inboundCtx payload before dispatch: ${JSON.stringify(inboundCtx)}`);
|
|
788
|
+
}
|
|
789
|
+
catch {
|
|
928
790
|
// 忽略 JSON 序列化异常,避免影响正常流程
|
|
929
791
|
}
|
|
930
792
|
}
|
|
931
|
-
|
|
932
793
|
// 【所有消息都必须做】记录到数据库!让 AI 产生“记忆”
|
|
933
794
|
log.info(`${logPrefix} [session] Recording inbound session sessionKey=${inboundCtx.SessionKey || route.sessionKey}`);
|
|
934
795
|
await rt.channel.session.recordInboundSession({
|
|
@@ -941,11 +802,10 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
941
802
|
to: params.groupId,
|
|
942
803
|
accountId: account.accountId,
|
|
943
804
|
},
|
|
944
|
-
onRecordError: (err
|
|
805
|
+
onRecordError: (err) => {
|
|
945
806
|
log.error(`${logPrefix} Failed to record session: ${String(err)}`);
|
|
946
807
|
},
|
|
947
808
|
});
|
|
948
|
-
|
|
949
809
|
if (actuallyMentioned) {
|
|
950
810
|
await dispatchMentionedReply({
|
|
951
811
|
rt,
|
|
@@ -959,31 +819,31 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
959
819
|
currentIdentity,
|
|
960
820
|
streamClient: wsHandle.streamClient,
|
|
961
821
|
});
|
|
962
|
-
}
|
|
822
|
+
}
|
|
823
|
+
else {
|
|
963
824
|
// 没 @ 我,仅作为旁观者缓存记忆,不打扰群里聊天
|
|
964
825
|
log.debug?.(`${logPrefix} Not mentioned in group, quietly memorized the message context.`);
|
|
965
826
|
}
|
|
966
|
-
}
|
|
827
|
+
}
|
|
828
|
+
catch (err) {
|
|
967
829
|
log.error(`${logPrefix} handleMessage error: ${String(err)}`);
|
|
968
830
|
}
|
|
969
831
|
};
|
|
970
|
-
|
|
971
832
|
// 5. 启动 WebSocket 连接
|
|
972
833
|
const wsHandle = startWebSocket(config, identity.token, handleMessage, log);
|
|
973
|
-
|
|
974
834
|
// 6. 阻塞到 abortSignal 触发(若存在);若无 abortSignal,则交由进程生命周期管理
|
|
975
835
|
if (ctx.abortSignal) {
|
|
976
|
-
await new Promise
|
|
977
|
-
const abortHandler = ()
|
|
836
|
+
await new Promise((resolve) => {
|
|
837
|
+
const abortHandler = () => {
|
|
978
838
|
log.info(`${logPrefix} Abort signal received, stopping XG-IM channel...`);
|
|
979
839
|
wsHandle.stop();
|
|
980
840
|
clearTokenCache(config);
|
|
981
841
|
resolve();
|
|
982
842
|
};
|
|
983
|
-
|
|
984
843
|
ctx.abortSignal?.addEventListener("abort", abortHandler, { once: true });
|
|
985
844
|
});
|
|
986
845
|
}
|
|
987
846
|
},
|
|
988
847
|
},
|
|
989
848
|
};
|
|
849
|
+
//# sourceMappingURL=channel.js.map
|