@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xgjktech/xg_cwork_im",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "XG CWork IM channel plugin for OpenClaw",
5
5
  "keywords": [
6
6
  "bot",
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 type {
19
- GatewayStartContext,
20
- PluginRuntime,
21
- ResolvedAccount,
22
- WsMessage,
23
- WsMessageParams,
24
- XgImChannelPlugin,
25
- XgImConfig,
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: false,
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 msgType = msgContent?.type ?? "text";
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
- let rawText = msgContent?.text ?? "";
326
- // 如果是语音消息且没有文本内容,设为占位符
327
- if (msgType === "voice" && !rawText) {
328
- rawText = "[语音消息]";
329
- }
330
- const text = rawText;
565
+ const text = buildInboundDisplayText(msgContent, fileItems);
331
566
 
332
- if (!text && !msgUrl) {
333
- log.debug?.(`${logPrefix} Empty message (no text and no url), skipping`);
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
- // 2. 判定是否真正 @ 了当前机器人
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
- MediaUrl: msgUrl,
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
- log.info(`${logPrefix} [dispatch] Dispatching to OpenClaw AI, sessionKey=${route.sessionKey}`);
455
- let isFirstReply = true;
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
- dispatcherOptions: {
511
- responsePrefix: "",
512
- deliver: async (payload: { markdown?: string; text?: string; isThinking?: boolean }) => {
513
- try {
514
- const textToSend = payload.markdown || payload.text;
515
- if (!textToSend) return;
516
-
517
- if (isFirstReply) {
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
- /** 启动心跳:每 30s 发一次 ping */
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 = new Date().toISOString();
121
- log.info(`${logPrefix} Ping sent at ${now}`);
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 { jsonResult } from "openclaw/plugin-sdk";
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 jsonResult({
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 jsonResult({
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) => m.msgContent?.type === "text")
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
- return `[${time}] ${sender}: ${m.msgContent.text}`;
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 jsonResult({
196
+ return toolJsonResult({
182
197
  ok: true,
183
198
  groupId,
184
199
  count: formatted.length,