@xgjktech/xg_cwork_im 1.0.7 → 1.0.9
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 +367 -360
- package/package.json +1 -1
- package/src/channel.ts +393 -175
- package/src/connection.ts +362 -351
- package/src/group-history-tool.ts +21 -6
- package/src/inbound-media-local.ts +159 -0
- package/src/resource-file.ts +253 -0
- package/src/send-group-message-tool.ts +98 -98
- package/src/send-service.ts +215 -68
- package/src/tool-json-result.ts +24 -0
- package/src/types.ts +122 -27
package/package.json
CHANGED
package/src/channel.ts
CHANGED
|
@@ -13,16 +13,21 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
|
|
13
13
|
import { buildChannelConfigSchema } from "openclaw/plugin-sdk";
|
|
14
14
|
import { z } from "zod";
|
|
15
15
|
import { clearTokenCache, getToken } from "./auth.js";
|
|
16
|
-
import { startWebSocket } from "./connection.js";
|
|
17
|
-
import {
|
|
18
|
-
import type
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
16
|
+
import { startWebSocket, type ImStreamClient } from "./connection.js";
|
|
17
|
+
import { normalizeInboundWorkspaceSubdir, saveInboundFilesToWorkspace } from "./inbound-media-local.js";
|
|
18
|
+
import { sendReplyDeliverBlock, sendTextMessage, type ReplyDeliverPayload } from "./send-service.js";
|
|
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,
|
|
26
31
|
} from "./types.js";
|
|
27
32
|
|
|
28
33
|
// ─── 全局 Runtime(在 index.ts 的 register 中注入)────────────────────────────
|
|
@@ -47,6 +52,9 @@ const XgImAccountConfigSchema = z.object({
|
|
|
47
52
|
agentId: z.string().optional().default("main"),
|
|
48
53
|
name: z.string().optional(),
|
|
49
54
|
groupPolicy: z.enum(["open", "mention"]).optional().default("mention"),
|
|
55
|
+
fileUploadFormField: z.string().min(1).optional(),
|
|
56
|
+
maxAttachmentBytes: z.number().int().positive().optional(),
|
|
57
|
+
inboundMediaWorkspaceSubdir: z.string().optional(),
|
|
50
58
|
});
|
|
51
59
|
|
|
52
60
|
const XgImConfigSchema = z.object({
|
|
@@ -63,9 +71,28 @@ const XgImConfigSchema = z.object({
|
|
|
63
71
|
initialReconnectDelay: z.number().int().positive().optional().default(1_000),
|
|
64
72
|
maxReconnectDelay: z.number().int().positive().optional().default(60_000),
|
|
65
73
|
reconnectJitter: z.number().min(0).max(1).optional().default(0.3),
|
|
74
|
+
fileUploadFormField: z.string().min(1).optional(),
|
|
75
|
+
maxAttachmentBytes: z.number().int().positive().optional(),
|
|
76
|
+
/**
|
|
77
|
+
* 非空时:将入站附件下载到 `resolveAgentWorkspaceDir(cfg)` 下该相对子目录,
|
|
78
|
+
* 再向 OpenClaw 传本地 file:// 路径(见 inbound-media-local)。
|
|
79
|
+
*/
|
|
80
|
+
inboundMediaWorkspaceSubdir: z.string().optional(),
|
|
66
81
|
// 多账户:对象 map(key 为 accountId)
|
|
67
82
|
accounts: z.record(z.string(), XgImAccountConfigSchema).optional(),
|
|
68
83
|
}).superRefine((val, ctx) => {
|
|
84
|
+
const badSubdir = (s: string | undefined): boolean =>
|
|
85
|
+
typeof s === "string" && s.trim().length > 0 && normalizeInboundWorkspaceSubdir(s) == null;
|
|
86
|
+
|
|
87
|
+
if (badSubdir(val.inboundMediaWorkspaceSubdir)) {
|
|
88
|
+
ctx.addIssue({
|
|
89
|
+
code: z.ZodIssueCode.custom,
|
|
90
|
+
path: ["inboundMediaWorkspaceSubdir"],
|
|
91
|
+
message:
|
|
92
|
+
"inboundMediaWorkspaceSubdir must be a relative path under workspace (no .., no absolute path)",
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
69
96
|
const accounts = val.accounts;
|
|
70
97
|
if (!accounts) return;
|
|
71
98
|
for (const [key, acc] of Object.entries(accounts)) {
|
|
@@ -78,6 +105,14 @@ const XgImConfigSchema = z.object({
|
|
|
78
105
|
message: "appKey is required for account entries (except accounts.default)",
|
|
79
106
|
});
|
|
80
107
|
}
|
|
108
|
+
if (badSubdir(acc.inboundMediaWorkspaceSubdir)) {
|
|
109
|
+
ctx.addIssue({
|
|
110
|
+
code: z.ZodIssueCode.custom,
|
|
111
|
+
path: ["accounts", key, "inboundMediaWorkspaceSubdir"],
|
|
112
|
+
message:
|
|
113
|
+
"inboundMediaWorkspaceSubdir must be a relative path under workspace (no .., no absolute path)",
|
|
114
|
+
});
|
|
115
|
+
}
|
|
81
116
|
}
|
|
82
117
|
});
|
|
83
118
|
|
|
@@ -121,6 +156,53 @@ function isConfigured(cfg: OpenClawConfig): boolean {
|
|
|
121
156
|
}
|
|
122
157
|
}
|
|
123
158
|
|
|
159
|
+
/**
|
|
160
|
+
* OpenClaw 对入站命令:`CommandAuthorized === undefined` 会按 false 处理,文本 /command 会被静默忽略(仍走普通 AI)。
|
|
161
|
+
* 见 openclaw LINE #26996 等修复。此处与 channels.xg_cwork_im.allowFrom 对齐;allowFrom 为空表示不限制发送者。
|
|
162
|
+
*/
|
|
163
|
+
function isSenderInXgImAllowFrom(config: XgImConfig, senderId: string | undefined): boolean {
|
|
164
|
+
const allow = config.allowFrom ?? [];
|
|
165
|
+
if (allow.length === 0) return true;
|
|
166
|
+
const id = senderId?.trim();
|
|
167
|
+
if (!id) return false;
|
|
168
|
+
return allow.includes(id);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function resolveInboundCommandAuthorized(
|
|
172
|
+
rt: unknown,
|
|
173
|
+
cfg: OpenClawConfig,
|
|
174
|
+
p: { accountId: string; senderId: string | undefined; config: XgImConfig },
|
|
175
|
+
): boolean {
|
|
176
|
+
const r = rt as {
|
|
177
|
+
channel?: {
|
|
178
|
+
commands?: {
|
|
179
|
+
resolveControlCommandGate?: (args: Record<string, unknown>) =>
|
|
180
|
+
| boolean
|
|
181
|
+
| { commandAuthorized?: boolean };
|
|
182
|
+
};
|
|
183
|
+
};
|
|
184
|
+
};
|
|
185
|
+
const fn = r?.channel?.commands?.resolveControlCommandGate;
|
|
186
|
+
if (typeof fn === "function") {
|
|
187
|
+
try {
|
|
188
|
+
const out = fn({
|
|
189
|
+
cfg,
|
|
190
|
+
channel: "xg_cwork_im",
|
|
191
|
+
accountId: p.accountId,
|
|
192
|
+
senderId: p.senderId ?? "",
|
|
193
|
+
chatType: "group",
|
|
194
|
+
});
|
|
195
|
+
if (typeof out === "boolean") return out;
|
|
196
|
+
if (out && typeof out === "object" && "commandAuthorized" in out) {
|
|
197
|
+
return Boolean((out as { commandAuthorized?: boolean }).commandAuthorized);
|
|
198
|
+
}
|
|
199
|
+
} catch {
|
|
200
|
+
/* 回退到 allowFrom */
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return isSenderInXgImAllowFrom(p.config, p.senderId);
|
|
204
|
+
}
|
|
205
|
+
|
|
124
206
|
// ─── 日志适配器 ───────────────────────────────────────────────────────────────
|
|
125
207
|
// ChannelLogSink 的 info/warn/error 方法接受单个 string
|
|
126
208
|
|
|
@@ -140,6 +222,241 @@ function toLogger(sink: { info?: (msg: string) => void; warn?: (msg: string) =>
|
|
|
140
222
|
};
|
|
141
223
|
}
|
|
142
224
|
|
|
225
|
+
// ─── WebSocket 入站:纯函数与 @ 后派发 ───────────────────────────────────────
|
|
226
|
+
|
|
227
|
+
function collectFileItems(msgContent: WsMessageContent | undefined): MsgFileVO[] {
|
|
228
|
+
return (msgContent?.files ?? []).filter((f) => f.url?.trim());
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function buildInboundDisplayText(msgContent: WsMessageContent | undefined, fileItems: MsgFileVO[]): string {
|
|
232
|
+
let rawText = msgContent?.text ?? "";
|
|
233
|
+
if (fileItems.length > 0) {
|
|
234
|
+
const lines = fileItems.map((f) => {
|
|
235
|
+
const name = f.name?.trim() || f.fileId || "未命名文件";
|
|
236
|
+
const mime = imFormatToMimeType(f.format);
|
|
237
|
+
return mime ? `- \`${name}\` (${mime})` : `- \`${name}\``;
|
|
238
|
+
});
|
|
239
|
+
const block = `**附件(${fileItems.length})**\n${lines.join("\n")}`;
|
|
240
|
+
rawText = rawText.trim() ? `${rawText.trim()}\n\n${block}` : block;
|
|
241
|
+
|
|
242
|
+
const parsedParts = fileItems
|
|
243
|
+
.map((f) => {
|
|
244
|
+
const c = f.content?.trim();
|
|
245
|
+
if (!c) return null;
|
|
246
|
+
const name = f.name?.trim() || f.fileId || "未命名文件";
|
|
247
|
+
return `### ${name}\n\n${c}`;
|
|
248
|
+
})
|
|
249
|
+
.filter((x): x is string => x != null);
|
|
250
|
+
if (parsedParts.length > 0) {
|
|
251
|
+
const parsedBlock = `**附件解析内容**\n\n${parsedParts.join("\n\n")}`;
|
|
252
|
+
rawText = `${rawText.trim()}\n\n${parsedBlock}`;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return rawText;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function isActuallyMentioned(
|
|
259
|
+
msg: WsMessage,
|
|
260
|
+
params: WsMessageParams,
|
|
261
|
+
bot: { userId: string; name: string },
|
|
262
|
+
rawTextForLegacy: string,
|
|
263
|
+
): boolean {
|
|
264
|
+
const mentions = params.mentions;
|
|
265
|
+
const isMentioned =
|
|
266
|
+
Array.isArray(mentions) && (mentions.includes(bot.userId) || mentions.includes("all"));
|
|
267
|
+
const isLegacyMentioned =
|
|
268
|
+
msg.cmd === "robotMention" &&
|
|
269
|
+
(!params.mentions || params.mentions.length === 0) &&
|
|
270
|
+
new RegExp(`@${bot.name}\\b`).test(rawTextForLegacy);
|
|
271
|
+
return isMentioned || isLegacyMentioned;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function mediaFieldsFromFileItems(fileItems: MsgFileVO[]): Record<string, unknown> {
|
|
275
|
+
if (fileItems.length === 0) return {};
|
|
276
|
+
const mimes = fileItems.map(
|
|
277
|
+
(f) => imFormatToMimeType(f.format) ?? "application/octet-stream",
|
|
278
|
+
);
|
|
279
|
+
// OpenClaw 入站展示与媒体拉取以 MediaPath(s) 为准;[media attached: …] 依赖 Path,仅写 MediaUrl 时该段不出现。
|
|
280
|
+
// 不要同时写 Path 与 Url(同链接会被拼成两段,出现 `url | url`)。
|
|
281
|
+
if (fileItems.length === 1) {
|
|
282
|
+
return {
|
|
283
|
+
MediaPath: fileItems[0]!.url,
|
|
284
|
+
MediaType: mimes[0],
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
MediaPaths: fileItems.map((f) => f.url),
|
|
289
|
+
MediaTypes: mimes,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** ext / 用户 background / 附件元数据(不含下载 URL,避免预签名链过长;URL 已在入站 MediaPath(s)) */
|
|
294
|
+
function buildUntrustedContext(
|
|
295
|
+
msgExt: WsMessageContent["ext"],
|
|
296
|
+
senderBackground: string | undefined,
|
|
297
|
+
fileItems: MsgFileVO[],
|
|
298
|
+
): string[] | undefined {
|
|
299
|
+
const parts: string[] = [];
|
|
300
|
+
if (msgExt) parts.push(JSON.stringify(msgExt));
|
|
301
|
+
if (senderBackground) parts.push(String(senderBackground));
|
|
302
|
+
if (fileItems.length > 0) {
|
|
303
|
+
const summary = fileItems.map((f) => ({
|
|
304
|
+
name: f.name ?? null,
|
|
305
|
+
format: f.format ?? null,
|
|
306
|
+
fileId: f.fileId ?? null,
|
|
307
|
+
size: f.size ?? null,
|
|
308
|
+
contentChars: f.content?.trim() ? f.content.trim().length : null,
|
|
309
|
+
}));
|
|
310
|
+
parts.push(`xg_cwork_im.attachments: ${JSON.stringify(summary)}`);
|
|
311
|
+
}
|
|
312
|
+
return parts.length > 0 ? parts : undefined;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function buildTargetReplyMeta(params: WsMessageParams): {
|
|
316
|
+
targetMsgId: string;
|
|
317
|
+
targetUserId: string;
|
|
318
|
+
targetUserName: string;
|
|
319
|
+
previewText: string;
|
|
320
|
+
} {
|
|
321
|
+
return {
|
|
322
|
+
targetMsgId: params.msgId,
|
|
323
|
+
targetUserId: params.userInfo?.id ?? "",
|
|
324
|
+
targetUserName: params.userInfo?.name ?? "未知用户",
|
|
325
|
+
previewText: params.msgContent?.text ?? "",
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** 流式 START → dispatch → 必发 END;首包超时走 HTTP 覆盖占位 */
|
|
330
|
+
async function dispatchMentionedReply(args: {
|
|
331
|
+
rt: any;
|
|
332
|
+
cfg: OpenClawConfig;
|
|
333
|
+
config: XgImConfig;
|
|
334
|
+
log: Logger;
|
|
335
|
+
logPrefix: string;
|
|
336
|
+
route: { sessionKey: string };
|
|
337
|
+
inboundCtx: unknown;
|
|
338
|
+
params: WsMessageParams;
|
|
339
|
+
currentIdentity: BotIdentity;
|
|
340
|
+
streamClient: ImStreamClient;
|
|
341
|
+
}): Promise<void> {
|
|
342
|
+
const { rt, cfg, config, log, logPrefix, route, inboundCtx, params, currentIdentity, streamClient } = args;
|
|
343
|
+
|
|
344
|
+
log.info(`${logPrefix} [dispatch] Dispatching to OpenClaw AI, sessionKey=${route.sessionKey}`);
|
|
345
|
+
let isFirstReply = true;
|
|
346
|
+
const dispatchStart = Date.now();
|
|
347
|
+
|
|
348
|
+
const { msgId: streamMsgId } = await streamClient.start({
|
|
349
|
+
groupId: params.groupId,
|
|
350
|
+
});
|
|
351
|
+
log.info(`${logPrefix} [stream] START acknowledged: msgId=${streamMsgId}`);
|
|
352
|
+
|
|
353
|
+
let hasFirstReply = false;
|
|
354
|
+
const firstReplyTimeoutMs = config.firstReplyTimeoutMs ?? 30 * 60_000;
|
|
355
|
+
const timeoutLabel = `${logPrefix} [stream] First reply timeout after ${firstReplyTimeoutMs}ms, updating thinking message as error`;
|
|
356
|
+
const firstReplyTimeout = setTimeout(async () => {
|
|
357
|
+
if (hasFirstReply) return;
|
|
358
|
+
log.warn(timeoutLabel);
|
|
359
|
+
try {
|
|
360
|
+
const reply = buildTargetReplyMeta(params);
|
|
361
|
+
const timeoutText = "当前请求处理超时,请稍后重试。";
|
|
362
|
+
await sendTextMessage(
|
|
363
|
+
config,
|
|
364
|
+
currentIdentity.token,
|
|
365
|
+
params.groupId,
|
|
366
|
+
timeoutText,
|
|
367
|
+
[reply.targetUserId] as string[],
|
|
368
|
+
log,
|
|
369
|
+
streamMsgId,
|
|
370
|
+
reply,
|
|
371
|
+
);
|
|
372
|
+
log.info(
|
|
373
|
+
`${logPrefix} [send] Timeout reply sent via HTTP: groupId=${params.groupId} msgId=${streamMsgId} text="${timeoutText}"`,
|
|
374
|
+
);
|
|
375
|
+
} catch (err: unknown) {
|
|
376
|
+
log.error(`${logPrefix} [timeout] Failed to send timeout reply: ${String(err)}`);
|
|
377
|
+
}
|
|
378
|
+
}, firstReplyTimeoutMs);
|
|
379
|
+
|
|
380
|
+
try {
|
|
381
|
+
await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
382
|
+
ctx: inboundCtx,
|
|
383
|
+
cfg,
|
|
384
|
+
dispatcherOptions: {
|
|
385
|
+
responsePrefix: "",
|
|
386
|
+
deliver: async (payload: ReplyDeliverPayload) => {
|
|
387
|
+
try {
|
|
388
|
+
const textPart = (payload.markdown || payload.text || "").trim();
|
|
389
|
+
const hasMedia =
|
|
390
|
+
Boolean(payload.mediaUrl?.trim()) ||
|
|
391
|
+
Boolean(payload.mediaUrls?.some((u) => typeof u === "string" && u.trim()));
|
|
392
|
+
if (!textPart && !(hasMedia && !payload.isThinking)) return;
|
|
393
|
+
|
|
394
|
+
if (isFirstReply) {
|
|
395
|
+
const ttfr = Date.now() - dispatchStart;
|
|
396
|
+
log.info(
|
|
397
|
+
`${logPrefix} [deliver] First response block received from AI (TTFB: ${ttfr}ms)`,
|
|
398
|
+
);
|
|
399
|
+
isFirstReply = false;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const reply = buildTargetReplyMeta(params);
|
|
403
|
+
const atIds = [reply.targetUserId] as string[];
|
|
404
|
+
|
|
405
|
+
if (!hasFirstReply) {
|
|
406
|
+
hasFirstReply = true;
|
|
407
|
+
clearTimeout(firstReplyTimeout);
|
|
408
|
+
await sendReplyDeliverBlock(
|
|
409
|
+
config,
|
|
410
|
+
currentIdentity.token,
|
|
411
|
+
params.groupId,
|
|
412
|
+
payload,
|
|
413
|
+
atIds,
|
|
414
|
+
log,
|
|
415
|
+
streamMsgId,
|
|
416
|
+
reply,
|
|
417
|
+
);
|
|
418
|
+
const preview =
|
|
419
|
+
textPart.length > 80 ? `${textPart.slice(0, 80)}...` : textPart || "[media]";
|
|
420
|
+
log.info(
|
|
421
|
+
`${logPrefix} [send] First reply sent via HTTP: groupId=${params.groupId} msgId=${streamMsgId} preview="${preview}"`,
|
|
422
|
+
);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
await sendReplyDeliverBlock(
|
|
427
|
+
config,
|
|
428
|
+
currentIdentity.token,
|
|
429
|
+
params.groupId,
|
|
430
|
+
payload,
|
|
431
|
+
atIds,
|
|
432
|
+
log,
|
|
433
|
+
undefined,
|
|
434
|
+
reply,
|
|
435
|
+
);
|
|
436
|
+
const preview =
|
|
437
|
+
textPart.length > 80 ? `${textPart.slice(0, 80)}...` : textPart || "[media]";
|
|
438
|
+
log.info(
|
|
439
|
+
`${logPrefix} [send] Additional reply sent via HTTP: groupId=${params.groupId} preview="${preview}"`,
|
|
440
|
+
);
|
|
441
|
+
} catch (err: unknown) {
|
|
442
|
+
log.error(`${logPrefix} Reply deliver failed: ${String(err)}`);
|
|
443
|
+
throw err;
|
|
444
|
+
}
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
log.info(`${logPrefix} [dispatch] Dispatch completed for sessionKey=${route.sessionKey}`);
|
|
449
|
+
} finally {
|
|
450
|
+
clearTimeout(firstReplyTimeout);
|
|
451
|
+
try {
|
|
452
|
+
await streamClient.end(streamMsgId, "stop");
|
|
453
|
+
log.info(`${logPrefix} [stream] END sent: msgId=${streamMsgId}`);
|
|
454
|
+
} catch (endErr: unknown) {
|
|
455
|
+
log.error(`${logPrefix} [stream] END failed (msgId=${streamMsgId}): ${String(endErr)}`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
143
460
|
// ─── Channel Plugin 定义 ─────────────────────────────────────────────────────
|
|
144
461
|
|
|
145
462
|
export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
@@ -159,7 +476,7 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
159
476
|
chatTypes: ["group"] as Array<"direct" | "group">,
|
|
160
477
|
reactions: false,
|
|
161
478
|
threads: false,
|
|
162
|
-
media:
|
|
479
|
+
media: true,
|
|
163
480
|
nativeCommands: false,
|
|
164
481
|
blockStreaming: false,
|
|
165
482
|
},
|
|
@@ -260,6 +577,35 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
260
577
|
messageId: randomUUID(),
|
|
261
578
|
};
|
|
262
579
|
},
|
|
580
|
+
|
|
581
|
+
/** 出站带媒体:上传资源后发 FILE,与网关 deliver 逻辑一致(OpenClaw 要求与 sendText 同时实现) */
|
|
582
|
+
sendMedia: async (ctx) => {
|
|
583
|
+
const { cfg, to, text, mediaUrl, accountId, log: ctxLog } = ctx as typeof ctx & { log?: Logger };
|
|
584
|
+
const log = toLogger(ctxLog);
|
|
585
|
+
const config = getXgImConfig(cfg, accountId);
|
|
586
|
+
const identity = await getToken(config, log);
|
|
587
|
+
|
|
588
|
+
if (!mediaUrl?.trim()) {
|
|
589
|
+
await sendTextMessage(config, identity.token, to, text || "", [], log);
|
|
590
|
+
return { channel: "xg_cwork_im", messageId: randomUUID() };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
await sendReplyDeliverBlock(
|
|
594
|
+
config,
|
|
595
|
+
identity.token,
|
|
596
|
+
to,
|
|
597
|
+
{ text: text || "", mediaUrl: mediaUrl.trim() },
|
|
598
|
+
[],
|
|
599
|
+
log,
|
|
600
|
+
undefined,
|
|
601
|
+
undefined,
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
return {
|
|
605
|
+
channel: "xg_cwork_im",
|
|
606
|
+
messageId: randomUUID(),
|
|
607
|
+
};
|
|
608
|
+
},
|
|
263
609
|
},
|
|
264
610
|
|
|
265
611
|
// ── 网关(WebSocket 长连接)─────────────────────────────────────────────────
|
|
@@ -315,22 +661,29 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
315
661
|
}
|
|
316
662
|
|
|
317
663
|
const msgContent = params.msgContent;
|
|
318
|
-
|
|
319
|
-
const msgUrl = msgContent?.url;
|
|
664
|
+
let fileItems = collectFileItems(msgContent);
|
|
320
665
|
const msgExt = msgContent?.ext;
|
|
321
666
|
const senderId = params.userInfo?.id;
|
|
322
667
|
const senderName = params.userInfo?.name || senderId || "";
|
|
323
668
|
const senderBackground = params.userInfo?.background;
|
|
324
669
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
670
|
+
const inboundSub = config.inboundMediaWorkspaceSubdir?.trim();
|
|
671
|
+
if (inboundSub && fileItems.length > 0 && normalizeInboundWorkspaceSubdir(inboundSub)) {
|
|
672
|
+
fileItems = await saveInboundFilesToWorkspace({
|
|
673
|
+
rt,
|
|
674
|
+
cfg: ctx.cfg,
|
|
675
|
+
config,
|
|
676
|
+
workspaceSubdir: inboundSub,
|
|
677
|
+
senderUserId: senderId,
|
|
678
|
+
fileItems,
|
|
679
|
+
log,
|
|
680
|
+
});
|
|
329
681
|
}
|
|
330
|
-
const text = rawText;
|
|
331
682
|
|
|
332
|
-
|
|
333
|
-
|
|
683
|
+
const text = buildInboundDisplayText(msgContent, fileItems);
|
|
684
|
+
|
|
685
|
+
if (!text.trim() && fileItems.length === 0) {
|
|
686
|
+
log.debug?.(`${logPrefix} Empty message (no text and no files), skipping`);
|
|
334
687
|
return;
|
|
335
688
|
}
|
|
336
689
|
|
|
@@ -338,21 +691,8 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
338
691
|
`${logPrefix} Message from ${senderName}(${senderId ?? "unknown"}) in group=${params.groupId}: ${text}`,
|
|
339
692
|
);
|
|
340
693
|
|
|
341
|
-
// 1. 获取当前机器人身份
|
|
342
694
|
const currentIdentity = await getToken(config, log);
|
|
343
|
-
|
|
344
|
-
const mentions = params.mentions;
|
|
345
|
-
const isMentioned = Array.isArray(mentions) &&
|
|
346
|
-
(mentions.includes(currentIdentity.userId) || mentions.includes("all"));
|
|
347
|
-
|
|
348
|
-
// (补充逻辑) 如果是 robotMention 指令,但没有 mentions 列表,则降级为老逻辑:文本正则匹配
|
|
349
|
-
const isLegacyMentioned = msg.cmd === "robotMention" &&
|
|
350
|
-
(!params.mentions || params.mentions.length === 0) &&
|
|
351
|
-
new RegExp(`@${currentIdentity.name}\\b`).test(rawText);
|
|
352
|
-
|
|
353
|
-
const actuallyMentioned = isMentioned || isLegacyMentioned;
|
|
354
|
-
|
|
355
|
-
// 3. 构建 OpenClaw 视角的“单条纯净消息”
|
|
695
|
+
const actuallyMentioned = isActuallyMentioned(msg, params, currentIdentity, text);
|
|
356
696
|
|
|
357
697
|
// 通过 PluginRuntime 路由消息到 OpenClaw
|
|
358
698
|
const route = rt.channel.routing.resolveAgentRoute({
|
|
@@ -387,10 +727,17 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
387
727
|
envelope: envelopeOptions,
|
|
388
728
|
});
|
|
389
729
|
|
|
730
|
+
const commandAuthorized = resolveInboundCommandAuthorized(rt, ctx.cfg, {
|
|
731
|
+
accountId: account.accountId,
|
|
732
|
+
senderId,
|
|
733
|
+
config,
|
|
734
|
+
});
|
|
735
|
+
|
|
390
736
|
const inboundCtx = rt.channel.reply.finalizeInboundContext({
|
|
391
737
|
Body: body,
|
|
392
738
|
RawBody: text,
|
|
393
739
|
CommandBody: text,
|
|
740
|
+
CommandAuthorized: commandAuthorized,
|
|
394
741
|
From: params.groupId,
|
|
395
742
|
To: params.groupId,
|
|
396
743
|
SessionKey: route.sessionKey,
|
|
@@ -407,16 +754,8 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
407
754
|
OriginatingChannel: "xg_cwork_im",
|
|
408
755
|
OriginatingTo: params.groupId,
|
|
409
756
|
GroupChannel: route.sessionKey,
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
MediaType: msgType === "voice" ? "voice" : undefined,
|
|
413
|
-
// 透传扩展字段给 AI (作为 UntrustedContext)
|
|
414
|
-
UntrustedContext: (() => {
|
|
415
|
-
const parts: string[] = [];
|
|
416
|
-
if (msgExt) parts.push(JSON.stringify(msgExt));
|
|
417
|
-
if (senderBackground) parts.push(String(senderBackground));
|
|
418
|
-
return parts.length > 0 ? parts : undefined;
|
|
419
|
-
})(),
|
|
757
|
+
...mediaFieldsFromFileItems(fileItems),
|
|
758
|
+
UntrustedContext: buildUntrustedContext(msgExt, senderBackground, fileItems),
|
|
420
759
|
// 同时保留原始 ext 供可能的后续逻辑使用
|
|
421
760
|
XgImExt: msgExt,
|
|
422
761
|
});
|
|
@@ -449,140 +788,19 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
449
788
|
},
|
|
450
789
|
});
|
|
451
790
|
|
|
452
|
-
// 【只有真 @ 我的消息才做】呼叫 AI 激活推理
|
|
453
791
|
if (actuallyMentioned) {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
const dispatchStart = Date.now();
|
|
457
|
-
|
|
458
|
-
// 1. 先向 IM 声明“开始流式消息”,拿到 msgId
|
|
459
|
-
const { msgId } = await wsHandle.streamClient.start({
|
|
460
|
-
groupId: params.groupId,
|
|
461
|
-
});
|
|
462
|
-
log.info(`${logPrefix} [stream] START acknowledged: msgId=${msgId}`);
|
|
463
|
-
|
|
464
|
-
// 1.1 为“思考中”占位增加首回复超时保护(默认 30分钟,可通过 firstReplyTimeoutMs 配置)
|
|
465
|
-
let hasFirstReply = false;
|
|
466
|
-
let firstReplyTimedOut = false;
|
|
467
|
-
const firstReplyTimeoutMs = config.firstReplyTimeoutMs ?? 30 * 60_000;
|
|
468
|
-
const timeoutLabel = `${logPrefix} [stream] First reply timeout after ${firstReplyTimeoutMs}ms, updating thinking message as error`;
|
|
469
|
-
const firstReplyTimeout = setTimeout(async () => {
|
|
470
|
-
if (hasFirstReply) {
|
|
471
|
-
return;
|
|
472
|
-
}
|
|
473
|
-
firstReplyTimedOut = true;
|
|
474
|
-
log.warn(timeoutLabel);
|
|
475
|
-
try {
|
|
476
|
-
const senderId = params.userInfo?.id;
|
|
477
|
-
const senderName = params.userInfo?.name ?? "未知用户";
|
|
478
|
-
const text = params.msgContent?.text ?? "";
|
|
479
|
-
const reply = {
|
|
480
|
-
targetMsgId: params.msgId,
|
|
481
|
-
targetUserId: senderId ?? "",
|
|
482
|
-
targetUserName: senderName,
|
|
483
|
-
previewText: text,
|
|
484
|
-
};
|
|
485
|
-
const timeoutText = "当前请求处理超时,请稍后重试。";
|
|
486
|
-
await sendTextMessage(
|
|
487
|
-
config,
|
|
488
|
-
currentIdentity.token,
|
|
489
|
-
params.groupId,
|
|
490
|
-
timeoutText,
|
|
491
|
-
[senderId ?? ""] as string[],
|
|
492
|
-
log,
|
|
493
|
-
msgId,
|
|
494
|
-
reply,
|
|
495
|
-
);
|
|
496
|
-
log.info(
|
|
497
|
-
`${logPrefix} [send] Timeout reply sent via HTTP: groupId=${params.groupId} msgId=${msgId} text="${timeoutText}"`,
|
|
498
|
-
);
|
|
499
|
-
} catch (err: unknown) {
|
|
500
|
-
log.error(`${logPrefix} [timeout] Failed to send timeout reply: ${String(err)}`);
|
|
501
|
-
}
|
|
502
|
-
}, firstReplyTimeoutMs);
|
|
503
|
-
|
|
504
|
-
// 2. 分发消息给 AI,deliver 回调负责发送首条和后续回复
|
|
505
|
-
let fullText = "";
|
|
506
|
-
|
|
507
|
-
await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
508
|
-
ctx: inboundCtx,
|
|
792
|
+
await dispatchMentionedReply({
|
|
793
|
+
rt,
|
|
509
794
|
cfg: ctx.cfg,
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
const ttfr = Date.now() - dispatchStart;
|
|
519
|
-
log.info(`${logPrefix} [deliver] First response block received from AI (TTFB: ${ttfr}ms)`);
|
|
520
|
-
isFirstReply = false;
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
fullText += textToSend;
|
|
524
|
-
|
|
525
|
-
const senderId = params.userInfo?.id;
|
|
526
|
-
const senderName = params.userInfo?.name ?? "未知用户";
|
|
527
|
-
const text = params.msgContent?.text ?? "";
|
|
528
|
-
const reply = {
|
|
529
|
-
targetMsgId: params.msgId,
|
|
530
|
-
targetUserId: senderId ?? "",
|
|
531
|
-
targetUserName: senderName,
|
|
532
|
-
previewText: text,
|
|
533
|
-
};
|
|
534
|
-
|
|
535
|
-
// 第一次有效回复:覆盖“思考中”占位消息(带 msgId)
|
|
536
|
-
if (!hasFirstReply) {
|
|
537
|
-
hasFirstReply = true;
|
|
538
|
-
clearTimeout(firstReplyTimeout);
|
|
539
|
-
await sendTextMessage(
|
|
540
|
-
config,
|
|
541
|
-
currentIdentity.token,
|
|
542
|
-
params.groupId,
|
|
543
|
-
textToSend,
|
|
544
|
-
[senderId ?? ""] as string[],
|
|
545
|
-
log,
|
|
546
|
-
msgId,
|
|
547
|
-
reply,
|
|
548
|
-
);
|
|
549
|
-
const preview = textToSend.length > 80 ? `${textToSend.slice(0, 80)}...` : textToSend;
|
|
550
|
-
log.info(
|
|
551
|
-
`${logPrefix} [send] First reply sent via HTTP: groupId=${params.groupId} msgId=${msgId} text="${preview}"`,
|
|
552
|
-
);
|
|
553
|
-
return;
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
// 后续回复:作为独立消息发送(不再复用 msgId)
|
|
557
|
-
await sendTextMessage(
|
|
558
|
-
config,
|
|
559
|
-
currentIdentity.token,
|
|
560
|
-
params.groupId,
|
|
561
|
-
textToSend,
|
|
562
|
-
[senderId ?? ""] as string[],
|
|
563
|
-
log,
|
|
564
|
-
undefined,
|
|
565
|
-
reply,
|
|
566
|
-
);
|
|
567
|
-
const preview = textToSend.length > 80 ? `${textToSend.slice(0, 80)}...` : textToSend;
|
|
568
|
-
log.info(
|
|
569
|
-
`${logPrefix} [send] Additional reply sent via HTTP: groupId=${params.groupId} text="${preview}"`,
|
|
570
|
-
);
|
|
571
|
-
} catch (err: unknown) {
|
|
572
|
-
log.error(`${logPrefix} Reply deliver failed: ${String(err)}`);
|
|
573
|
-
throw err;
|
|
574
|
-
}
|
|
575
|
-
},
|
|
576
|
-
},
|
|
795
|
+
config,
|
|
796
|
+
log,
|
|
797
|
+
logPrefix,
|
|
798
|
+
route,
|
|
799
|
+
inboundCtx,
|
|
800
|
+
params,
|
|
801
|
+
currentIdentity,
|
|
802
|
+
streamClient: wsHandle.streamClient,
|
|
577
803
|
});
|
|
578
|
-
|
|
579
|
-
// 3. AI 推理完成,发送 END,结束本次流式占位语义
|
|
580
|
-
// 一旦发送 END,就不再补发「请求超时」消息,因此这里无条件清理定时器。
|
|
581
|
-
clearTimeout(firstReplyTimeout);
|
|
582
|
-
await wsHandle.streamClient.end(msgId, "stop");
|
|
583
|
-
log.info(`${logPrefix} [stream] END sent: msgId=${msgId}`);
|
|
584
|
-
|
|
585
|
-
log.info(`${logPrefix} [dispatch] Dispatch completed for sessionKey=${route.sessionKey}`);
|
|
586
804
|
} else {
|
|
587
805
|
// 没 @ 我,仅作为旁观者缓存记忆,不打扰群里聊天
|
|
588
806
|
log.debug?.(`${logPrefix} Not mentioned in group, quietly memorized the message context.`);
|