@xgjktech/xg_cwork_im 1.0.7 → 1.0.8
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/package.json +1 -1
- package/src/channel.ts +269 -176
- package/src/connection.ts +11 -3
- package/src/group-history-tool.ts +21 -6
- 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 +287 -206
package/package.json
CHANGED
package/src/channel.ts
CHANGED
|
@@ -13,16 +13,20 @@ 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 { sendTextMessage } from "./send-service.js";
|
|
18
|
-
import
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
16
|
+
import { startWebSocket, type ImStreamClient } from "./connection.js";
|
|
17
|
+
import { sendReplyDeliverBlock, sendTextMessage, type ReplyDeliverPayload } from "./send-service.js";
|
|
18
|
+
import {
|
|
19
|
+
imFormatToMimeType,
|
|
20
|
+
type BotIdentity,
|
|
21
|
+
type GatewayStartContext,
|
|
22
|
+
type MsgFileVO,
|
|
23
|
+
type PluginRuntime,
|
|
24
|
+
type ResolvedAccount,
|
|
25
|
+
type WsMessage,
|
|
26
|
+
type WsMessageContent,
|
|
27
|
+
type WsMessageParams,
|
|
28
|
+
type XgImChannelPlugin,
|
|
29
|
+
type XgImConfig,
|
|
26
30
|
} from "./types.js";
|
|
27
31
|
|
|
28
32
|
// ─── 全局 Runtime(在 index.ts 的 register 中注入)────────────────────────────
|
|
@@ -47,6 +51,8 @@ const XgImAccountConfigSchema = z.object({
|
|
|
47
51
|
agentId: z.string().optional().default("main"),
|
|
48
52
|
name: z.string().optional(),
|
|
49
53
|
groupPolicy: z.enum(["open", "mention"]).optional().default("mention"),
|
|
54
|
+
fileUploadFormField: z.string().min(1).optional(),
|
|
55
|
+
maxAttachmentBytes: z.number().int().positive().optional(),
|
|
50
56
|
});
|
|
51
57
|
|
|
52
58
|
const XgImConfigSchema = z.object({
|
|
@@ -63,6 +69,8 @@ const XgImConfigSchema = z.object({
|
|
|
63
69
|
initialReconnectDelay: z.number().int().positive().optional().default(1_000),
|
|
64
70
|
maxReconnectDelay: z.number().int().positive().optional().default(60_000),
|
|
65
71
|
reconnectJitter: z.number().min(0).max(1).optional().default(0.3),
|
|
72
|
+
fileUploadFormField: z.string().min(1).optional(),
|
|
73
|
+
maxAttachmentBytes: z.number().int().positive().optional(),
|
|
66
74
|
// 多账户:对象 map(key 为 accountId)
|
|
67
75
|
accounts: z.record(z.string(), XgImAccountConfigSchema).optional(),
|
|
68
76
|
}).superRefine((val, ctx) => {
|
|
@@ -140,6 +148,210 @@ function toLogger(sink: { info?: (msg: string) => void; warn?: (msg: string) =>
|
|
|
140
148
|
};
|
|
141
149
|
}
|
|
142
150
|
|
|
151
|
+
// ─── WebSocket 入站:纯函数与 @ 后派发 ───────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
function collectFileItems(msgContent: WsMessageContent | undefined): MsgFileVO[] {
|
|
154
|
+
return (msgContent?.files ?? []).filter((f) => f.url?.trim());
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function buildInboundDisplayText(msgContent: WsMessageContent | undefined, fileItems: MsgFileVO[]): string {
|
|
158
|
+
let rawText = msgContent?.text ?? "";
|
|
159
|
+
if (fileItems.length > 0) {
|
|
160
|
+
const names = fileItems
|
|
161
|
+
.map((f) => f.name?.trim() || f.fileId || "未命名文件")
|
|
162
|
+
.join("、");
|
|
163
|
+
const fileNote = `[附件 ${fileItems.length} 个: ${names}]`;
|
|
164
|
+
rawText = rawText.trim() ? `${rawText.trim()}\n\n${fileNote}` : fileNote;
|
|
165
|
+
}
|
|
166
|
+
return rawText;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isActuallyMentioned(
|
|
170
|
+
msg: WsMessage,
|
|
171
|
+
params: WsMessageParams,
|
|
172
|
+
bot: { userId: string; name: string },
|
|
173
|
+
rawTextForLegacy: string,
|
|
174
|
+
): boolean {
|
|
175
|
+
const mentions = params.mentions;
|
|
176
|
+
const isMentioned =
|
|
177
|
+
Array.isArray(mentions) && (mentions.includes(bot.userId) || mentions.includes("all"));
|
|
178
|
+
const isLegacyMentioned =
|
|
179
|
+
msg.cmd === "robotMention" &&
|
|
180
|
+
(!params.mentions || params.mentions.length === 0) &&
|
|
181
|
+
new RegExp(`@${bot.name}\\b`).test(rawTextForLegacy);
|
|
182
|
+
return isMentioned || isLegacyMentioned;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function mediaFieldsFromFileItems(fileItems: MsgFileVO[]): Record<string, unknown> {
|
|
186
|
+
if (fileItems.length === 0) return {};
|
|
187
|
+
const mimes = fileItems.map(
|
|
188
|
+
(f) => imFormatToMimeType(f.format) ?? "application/octet-stream",
|
|
189
|
+
);
|
|
190
|
+
return {
|
|
191
|
+
MediaPath: fileItems[0]!.url,
|
|
192
|
+
MediaUrl: fileItems[0]!.url,
|
|
193
|
+
MediaPaths: fileItems.map((f) => f.url),
|
|
194
|
+
MediaUrls: fileItems.map((f) => f.url),
|
|
195
|
+
MediaType: mimes[0],
|
|
196
|
+
MediaTypes: mimes,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function untrustedContextFromExtAndBackground(
|
|
201
|
+
msgExt: WsMessageContent["ext"],
|
|
202
|
+
senderBackground: string | undefined,
|
|
203
|
+
): string[] | undefined {
|
|
204
|
+
const parts: string[] = [];
|
|
205
|
+
if (msgExt) parts.push(JSON.stringify(msgExt));
|
|
206
|
+
if (senderBackground) parts.push(String(senderBackground));
|
|
207
|
+
return parts.length > 0 ? parts : undefined;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function buildTargetReplyMeta(params: WsMessageParams): {
|
|
211
|
+
targetMsgId: string;
|
|
212
|
+
targetUserId: string;
|
|
213
|
+
targetUserName: string;
|
|
214
|
+
previewText: string;
|
|
215
|
+
} {
|
|
216
|
+
return {
|
|
217
|
+
targetMsgId: params.msgId,
|
|
218
|
+
targetUserId: params.userInfo?.id ?? "",
|
|
219
|
+
targetUserName: params.userInfo?.name ?? "未知用户",
|
|
220
|
+
previewText: params.msgContent?.text ?? "",
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** 流式 START → dispatch → 必发 END;首包超时走 HTTP 覆盖占位 */
|
|
225
|
+
async function dispatchMentionedReply(args: {
|
|
226
|
+
rt: any;
|
|
227
|
+
cfg: OpenClawConfig;
|
|
228
|
+
config: XgImConfig;
|
|
229
|
+
log: Logger;
|
|
230
|
+
logPrefix: string;
|
|
231
|
+
route: { sessionKey: string };
|
|
232
|
+
inboundCtx: unknown;
|
|
233
|
+
params: WsMessageParams;
|
|
234
|
+
currentIdentity: BotIdentity;
|
|
235
|
+
streamClient: ImStreamClient;
|
|
236
|
+
}): Promise<void> {
|
|
237
|
+
const { rt, cfg, config, log, logPrefix, route, inboundCtx, params, currentIdentity, streamClient } = args;
|
|
238
|
+
|
|
239
|
+
log.info(`${logPrefix} [dispatch] Dispatching to OpenClaw AI, sessionKey=${route.sessionKey}`);
|
|
240
|
+
let isFirstReply = true;
|
|
241
|
+
const dispatchStart = Date.now();
|
|
242
|
+
|
|
243
|
+
const { msgId: streamMsgId } = await streamClient.start({
|
|
244
|
+
groupId: params.groupId,
|
|
245
|
+
});
|
|
246
|
+
log.info(`${logPrefix} [stream] START acknowledged: msgId=${streamMsgId}`);
|
|
247
|
+
|
|
248
|
+
let hasFirstReply = false;
|
|
249
|
+
const firstReplyTimeoutMs = config.firstReplyTimeoutMs ?? 30 * 60_000;
|
|
250
|
+
const timeoutLabel = `${logPrefix} [stream] First reply timeout after ${firstReplyTimeoutMs}ms, updating thinking message as error`;
|
|
251
|
+
const firstReplyTimeout = setTimeout(async () => {
|
|
252
|
+
if (hasFirstReply) return;
|
|
253
|
+
log.warn(timeoutLabel);
|
|
254
|
+
try {
|
|
255
|
+
const reply = buildTargetReplyMeta(params);
|
|
256
|
+
const timeoutText = "当前请求处理超时,请稍后重试。";
|
|
257
|
+
await sendTextMessage(
|
|
258
|
+
config,
|
|
259
|
+
currentIdentity.token,
|
|
260
|
+
params.groupId,
|
|
261
|
+
timeoutText,
|
|
262
|
+
[reply.targetUserId] as string[],
|
|
263
|
+
log,
|
|
264
|
+
streamMsgId,
|
|
265
|
+
reply,
|
|
266
|
+
);
|
|
267
|
+
log.info(
|
|
268
|
+
`${logPrefix} [send] Timeout reply sent via HTTP: groupId=${params.groupId} msgId=${streamMsgId} text="${timeoutText}"`,
|
|
269
|
+
);
|
|
270
|
+
} catch (err: unknown) {
|
|
271
|
+
log.error(`${logPrefix} [timeout] Failed to send timeout reply: ${String(err)}`);
|
|
272
|
+
}
|
|
273
|
+
}, firstReplyTimeoutMs);
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
277
|
+
ctx: inboundCtx,
|
|
278
|
+
cfg,
|
|
279
|
+
dispatcherOptions: {
|
|
280
|
+
responsePrefix: "",
|
|
281
|
+
deliver: async (payload: ReplyDeliverPayload) => {
|
|
282
|
+
try {
|
|
283
|
+
const textPart = (payload.markdown || payload.text || "").trim();
|
|
284
|
+
const hasMedia =
|
|
285
|
+
Boolean(payload.mediaUrl?.trim()) ||
|
|
286
|
+
Boolean(payload.mediaUrls?.some((u) => typeof u === "string" && u.trim()));
|
|
287
|
+
if (!textPart && !(hasMedia && !payload.isThinking)) return;
|
|
288
|
+
|
|
289
|
+
if (isFirstReply) {
|
|
290
|
+
const ttfr = Date.now() - dispatchStart;
|
|
291
|
+
log.info(
|
|
292
|
+
`${logPrefix} [deliver] First response block received from AI (TTFB: ${ttfr}ms)`,
|
|
293
|
+
);
|
|
294
|
+
isFirstReply = false;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const reply = buildTargetReplyMeta(params);
|
|
298
|
+
const atIds = [reply.targetUserId] as string[];
|
|
299
|
+
|
|
300
|
+
if (!hasFirstReply) {
|
|
301
|
+
hasFirstReply = true;
|
|
302
|
+
clearTimeout(firstReplyTimeout);
|
|
303
|
+
await sendReplyDeliverBlock(
|
|
304
|
+
config,
|
|
305
|
+
currentIdentity.token,
|
|
306
|
+
params.groupId,
|
|
307
|
+
payload,
|
|
308
|
+
atIds,
|
|
309
|
+
log,
|
|
310
|
+
streamMsgId,
|
|
311
|
+
reply,
|
|
312
|
+
);
|
|
313
|
+
const preview =
|
|
314
|
+
textPart.length > 80 ? `${textPart.slice(0, 80)}...` : textPart || "[media]";
|
|
315
|
+
log.info(
|
|
316
|
+
`${logPrefix} [send] First reply sent via HTTP: groupId=${params.groupId} msgId=${streamMsgId} preview="${preview}"`,
|
|
317
|
+
);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
await sendReplyDeliverBlock(
|
|
322
|
+
config,
|
|
323
|
+
currentIdentity.token,
|
|
324
|
+
params.groupId,
|
|
325
|
+
payload,
|
|
326
|
+
atIds,
|
|
327
|
+
log,
|
|
328
|
+
undefined,
|
|
329
|
+
reply,
|
|
330
|
+
);
|
|
331
|
+
const preview =
|
|
332
|
+
textPart.length > 80 ? `${textPart.slice(0, 80)}...` : textPart || "[media]";
|
|
333
|
+
log.info(
|
|
334
|
+
`${logPrefix} [send] Additional reply sent via HTTP: groupId=${params.groupId} preview="${preview}"`,
|
|
335
|
+
);
|
|
336
|
+
} catch (err: unknown) {
|
|
337
|
+
log.error(`${logPrefix} Reply deliver failed: ${String(err)}`);
|
|
338
|
+
throw err;
|
|
339
|
+
}
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
log.info(`${logPrefix} [dispatch] Dispatch completed for sessionKey=${route.sessionKey}`);
|
|
344
|
+
} finally {
|
|
345
|
+
clearTimeout(firstReplyTimeout);
|
|
346
|
+
try {
|
|
347
|
+
await streamClient.end(streamMsgId, "stop");
|
|
348
|
+
log.info(`${logPrefix} [stream] END sent: msgId=${streamMsgId}`);
|
|
349
|
+
} catch (endErr: unknown) {
|
|
350
|
+
log.error(`${logPrefix} [stream] END failed (msgId=${streamMsgId}): ${String(endErr)}`);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
143
355
|
// ─── Channel Plugin 定义 ─────────────────────────────────────────────────────
|
|
144
356
|
|
|
145
357
|
export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
@@ -159,7 +371,7 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
159
371
|
chatTypes: ["group"] as Array<"direct" | "group">,
|
|
160
372
|
reactions: false,
|
|
161
373
|
threads: false,
|
|
162
|
-
media:
|
|
374
|
+
media: true,
|
|
163
375
|
nativeCommands: false,
|
|
164
376
|
blockStreaming: false,
|
|
165
377
|
},
|
|
@@ -260,6 +472,35 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
260
472
|
messageId: randomUUID(),
|
|
261
473
|
};
|
|
262
474
|
},
|
|
475
|
+
|
|
476
|
+
/** 出站带媒体:上传资源后发 FILE,与网关 deliver 逻辑一致(OpenClaw 要求与 sendText 同时实现) */
|
|
477
|
+
sendMedia: async (ctx) => {
|
|
478
|
+
const { cfg, to, text, mediaUrl, accountId, log: ctxLog } = ctx as typeof ctx & { log?: Logger };
|
|
479
|
+
const log = toLogger(ctxLog);
|
|
480
|
+
const config = getXgImConfig(cfg, accountId);
|
|
481
|
+
const identity = await getToken(config, log);
|
|
482
|
+
|
|
483
|
+
if (!mediaUrl?.trim()) {
|
|
484
|
+
await sendTextMessage(config, identity.token, to, text || "", [], log);
|
|
485
|
+
return { channel: "xg_cwork_im", messageId: randomUUID() };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
await sendReplyDeliverBlock(
|
|
489
|
+
config,
|
|
490
|
+
identity.token,
|
|
491
|
+
to,
|
|
492
|
+
{ text: text || "", mediaUrl: mediaUrl.trim() },
|
|
493
|
+
[],
|
|
494
|
+
log,
|
|
495
|
+
undefined,
|
|
496
|
+
undefined,
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
return {
|
|
500
|
+
channel: "xg_cwork_im",
|
|
501
|
+
messageId: randomUUID(),
|
|
502
|
+
};
|
|
503
|
+
},
|
|
263
504
|
},
|
|
264
505
|
|
|
265
506
|
// ── 网关(WebSocket 长连接)─────────────────────────────────────────────────
|
|
@@ -315,22 +556,16 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
315
556
|
}
|
|
316
557
|
|
|
317
558
|
const msgContent = params.msgContent;
|
|
318
|
-
const
|
|
319
|
-
const msgUrl = msgContent?.url;
|
|
559
|
+
const fileItems = collectFileItems(msgContent);
|
|
320
560
|
const msgExt = msgContent?.ext;
|
|
321
561
|
const senderId = params.userInfo?.id;
|
|
322
562
|
const senderName = params.userInfo?.name || senderId || "";
|
|
323
563
|
const senderBackground = params.userInfo?.background;
|
|
324
564
|
|
|
325
|
-
|
|
326
|
-
// 如果是语音消息且没有文本内容,设为占位符
|
|
327
|
-
if (msgType === "voice" && !rawText) {
|
|
328
|
-
rawText = "[语音消息]";
|
|
329
|
-
}
|
|
330
|
-
const text = rawText;
|
|
565
|
+
const text = buildInboundDisplayText(msgContent, fileItems);
|
|
331
566
|
|
|
332
|
-
if (!text &&
|
|
333
|
-
log.debug?.(`${logPrefix} Empty message (no text and no
|
|
567
|
+
if (!text.trim() && fileItems.length === 0) {
|
|
568
|
+
log.debug?.(`${logPrefix} Empty message (no text and no files), skipping`);
|
|
334
569
|
return;
|
|
335
570
|
}
|
|
336
571
|
|
|
@@ -338,21 +573,8 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
338
573
|
`${logPrefix} Message from ${senderName}(${senderId ?? "unknown"}) in group=${params.groupId}: ${text}`,
|
|
339
574
|
);
|
|
340
575
|
|
|
341
|
-
// 1. 获取当前机器人身份
|
|
342
576
|
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 视角的“单条纯净消息”
|
|
577
|
+
const actuallyMentioned = isActuallyMentioned(msg, params, currentIdentity, text);
|
|
356
578
|
|
|
357
579
|
// 通过 PluginRuntime 路由消息到 OpenClaw
|
|
358
580
|
const route = rt.channel.routing.resolveAgentRoute({
|
|
@@ -407,16 +629,8 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
407
629
|
OriginatingChannel: "xg_cwork_im",
|
|
408
630
|
OriginatingTo: params.groupId,
|
|
409
631
|
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
|
-
})(),
|
|
632
|
+
...mediaFieldsFromFileItems(fileItems),
|
|
633
|
+
UntrustedContext: untrustedContextFromExtAndBackground(msgExt, senderBackground),
|
|
420
634
|
// 同时保留原始 ext 供可能的后续逻辑使用
|
|
421
635
|
XgImExt: msgExt,
|
|
422
636
|
});
|
|
@@ -449,140 +663,19 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
|
|
|
449
663
|
},
|
|
450
664
|
});
|
|
451
665
|
|
|
452
|
-
// 【只有真 @ 我的消息才做】呼叫 AI 激活推理
|
|
453
666
|
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,
|
|
667
|
+
await dispatchMentionedReply({
|
|
668
|
+
rt,
|
|
509
669
|
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
|
-
},
|
|
670
|
+
config,
|
|
671
|
+
log,
|
|
672
|
+
logPrefix,
|
|
673
|
+
route,
|
|
674
|
+
inboundCtx,
|
|
675
|
+
params,
|
|
676
|
+
currentIdentity,
|
|
677
|
+
streamClient: wsHandle.streamClient,
|
|
577
678
|
});
|
|
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
679
|
} else {
|
|
587
680
|
// 没 @ 我,仅作为旁观者缓存记忆,不打扰群里聊天
|
|
588
681
|
log.debug?.(`${logPrefix} Not mentioned in group, quietly memorized the message context.`);
|
package/src/connection.ts
CHANGED
|
@@ -44,6 +44,9 @@ export interface ImStreamClient {
|
|
|
44
44
|
/** 心跳间隔(ms):每 20 秒发一次 ping,防止连接被后台剔除 */
|
|
45
45
|
const PING_INTERVAL_MS = 20_000;
|
|
46
46
|
|
|
47
|
+
/** 心跳日志间隔(ms):每 10 分钟输出一次 info 日志,避免刷屏 */
|
|
48
|
+
const PING_LOG_INTERVAL_MS = 600_000;
|
|
49
|
+
|
|
47
50
|
/** 计算指数退避延迟(带随机抖动) */
|
|
48
51
|
function calcDelay(
|
|
49
52
|
attempt: number,
|
|
@@ -107,9 +110,10 @@ export function startWebSocket(
|
|
|
107
110
|
}
|
|
108
111
|
}
|
|
109
112
|
|
|
110
|
-
/** 启动心跳:每
|
|
113
|
+
/** 启动心跳:每 20s 发一次 ping,info 日志每 10 分钟输出一次 */
|
|
111
114
|
function startHeartbeat(socket: WebSocket): void {
|
|
112
115
|
clearHeartbeat();
|
|
116
|
+
let lastLogTime = 0;
|
|
113
117
|
|
|
114
118
|
pingTimer = setInterval(() => {
|
|
115
119
|
if (socket.readyState !== WebSocket.OPEN) {
|
|
@@ -117,8 +121,12 @@ export function startWebSocket(
|
|
|
117
121
|
return;
|
|
118
122
|
}
|
|
119
123
|
|
|
120
|
-
const now =
|
|
121
|
-
|
|
124
|
+
const now = Date.now();
|
|
125
|
+
const shouldLog = now - lastLogTime >= PING_LOG_INTERVAL_MS;
|
|
126
|
+
if (shouldLog) {
|
|
127
|
+
lastLogTime = now;
|
|
128
|
+
log.info(`${logPrefix} ♥ Ping sent at ${new Date().toISOString()}`);
|
|
129
|
+
}
|
|
122
130
|
|
|
123
131
|
// 发送标准 WebSocket ping 帧
|
|
124
132
|
socket.ping((err: Error | null) => {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import axios from "axios";
|
|
9
9
|
import { Type, type Static } from "@sinclair/typebox";
|
|
10
10
|
import type { AnyAgentTool } from "openclaw/plugin-sdk";
|
|
11
|
-
import {
|
|
11
|
+
import { toolJsonResult } from "./tool-json-result.js";
|
|
12
12
|
import { getToken } from "./auth.js";
|
|
13
13
|
import type { BotIdentity, WsMessageParams, XgImConfig } from "./types.js";
|
|
14
14
|
|
|
@@ -143,7 +143,7 @@ export function buildGroupHistoryTool(config: XgImConfig): AnyAgentTool {
|
|
|
143
143
|
identity = await getToken(accountConfig);
|
|
144
144
|
console.log(`[cwork_im][tool] token acquired for userId=${identity.userId}`);
|
|
145
145
|
} catch (err: unknown) {
|
|
146
|
-
return
|
|
146
|
+
return toolJsonResult({
|
|
147
147
|
ok: false,
|
|
148
148
|
error: `Failed to obtain access token: ${String(err)}`,
|
|
149
149
|
messages: [],
|
|
@@ -158,7 +158,7 @@ export function buildGroupHistoryTool(config: XgImConfig): AnyAgentTool {
|
|
|
158
158
|
`[cwork_im][tool] fetched ${messages.length} messages from group ${groupId} using accountId=${accountId ?? "0"} userId=${userId ?? "N/A"}`,
|
|
159
159
|
);
|
|
160
160
|
} catch (err: unknown) {
|
|
161
|
-
return
|
|
161
|
+
return toolJsonResult({
|
|
162
162
|
ok: false,
|
|
163
163
|
error: `Failed to fetch group history: ${String(err)}`,
|
|
164
164
|
messages: [],
|
|
@@ -167,7 +167,10 @@ export function buildGroupHistoryTool(config: XgImConfig): AnyAgentTool {
|
|
|
167
167
|
|
|
168
168
|
// 格式化成对话摘要,方便 AI 理解
|
|
169
169
|
const formatted = messages
|
|
170
|
-
.filter((m) =>
|
|
170
|
+
.filter((m) => {
|
|
171
|
+
const t = m.msgContent?.type;
|
|
172
|
+
return t === "text" || t === "file";
|
|
173
|
+
})
|
|
171
174
|
.map((m) => {
|
|
172
175
|
const senderId = m.userInfo?.id ?? "";
|
|
173
176
|
const senderName = m.userInfo?.name || senderId || "未知用户";
|
|
@@ -175,10 +178,22 @@ export function buildGroupHistoryTool(config: XgImConfig): AnyAgentTool {
|
|
|
175
178
|
const sender = isBot ? "[AI]" : senderName;
|
|
176
179
|
const ts = m.timestamp ?? m.msgSendTime ?? 0;
|
|
177
180
|
const time = new Date(ts).toLocaleTimeString("zh-CN", { hour12: false });
|
|
178
|
-
|
|
181
|
+
const c = m.msgContent;
|
|
182
|
+
const body =
|
|
183
|
+
c?.type === "file" && c.files?.length
|
|
184
|
+
? [
|
|
185
|
+
c.text?.trim(),
|
|
186
|
+
`[附件 ${c.files.length} 个: ${c.files
|
|
187
|
+
.map((f) => f.name?.trim() || f.fileId || "文件")
|
|
188
|
+
.join("、")}]`,
|
|
189
|
+
]
|
|
190
|
+
.filter(Boolean)
|
|
191
|
+
.join(" ")
|
|
192
|
+
: (c?.text ?? "");
|
|
193
|
+
return `[${time}] ${sender}: ${body}`;
|
|
179
194
|
});
|
|
180
195
|
|
|
181
|
-
return
|
|
196
|
+
return toolJsonResult({
|
|
182
197
|
ok: true,
|
|
183
198
|
groupId,
|
|
184
199
|
count: formatted.length,
|