@xgjktech/xg_cwork_im 1.0.3 → 1.0.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 CHANGED
@@ -11,7 +11,11 @@ IM 系统 → WebSocket → 插件(本项目) → OpenClaw AI → IM
11
11
  1. 插件启动时用 `appKey` 换取 `access_token` 和 `userId`
12
12
  2. 建立 WebSocket 长连接,接收 `robotMention` 事件
13
13
  3. 将收到的消息转发给 OpenClaw 处理
14
- 4. OpenClaw AI 回复后,插件调用 IM 接口发送消息并 `@` 原始发送者
14
+ 4. OpenClaw AI 回复后,插件:
15
+ - 先通过 WebSocket `START` 让 IM 端显示一条「思考中」占位消息;
16
+ - 当 AI 产出第一条完整回复时,用 HTTP `/im/message/send` 覆盖这条占位消息;
17
+ - 若有多条回复,则后续每条都通过 HTTP 作为**独立消息**发送;
18
+ - 首条回复若在配置的超时时间内一直未产生,会自动将占位消息更新为「当前请求处理超时,请稍后重试」。
15
19
 
16
20
  ---
17
21
 
@@ -166,6 +170,9 @@ openclaw gateway restart
166
170
  "wsBaseUrl": "wss://cwork-web-test.xgjktech.com.cn",
167
171
  "groupPolicy": "mention",
168
172
  "debug": false,
173
+ // 可选:首条 AI 回复超时时间(毫秒),默认 5 分钟
174
+ // 未配置时:5 * 60_000 = 300000
175
+ "firstReplyTimeoutMs": 300000,
169
176
  "accounts": [
170
177
  { "appKey": "appKey_机器人A", "agentId": "main", "name": "个人助手" },
171
178
  { "appKey": "appKey_机器人B", "agentId": "sales", "name": "销售助手" }
@@ -187,6 +194,8 @@ openclaw gateway restart
187
194
  "debug": false,
188
195
  "maxConnectionAttempts": 20,
189
196
  "maxReconnectDelay": 120000,
197
+ // 生产环境可根据需要调整首回复超时时间(毫秒)
198
+ "firstReplyTimeoutMs": 300000,
190
199
  "accounts": [
191
200
  { "appKey": "你的生产 appKey", "agentId": "main", "name": "个人助手" }
192
201
  ]
@@ -212,6 +221,7 @@ openclaw gateway restart
212
221
  | `initialReconnectDelay` | number | `1000` | 初始重连延迟(ms) |
213
222
  | `maxReconnectDelay` | number | `60000` | 最大重连延迟(ms) |
214
223
  | `reconnectJitter` | number | `0.3` | 重连抖动因子(0-1) |
224
+ | `firstReplyTimeoutMs` | number | `300000` | **首条 AI 回复超时时间(毫秒)**。用于保护「思考中」占位消息:若在该时间窗口内 AI 没有任何回复,则自动将占位消息更新为「当前请求处理超时,请稍后重试」。|
215
225
 
216
226
  ### 账户配置(`accounts[n]`)
217
227
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xgjktech/xg_cwork_im",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "XG CWork IM channel plugin for OpenClaw",
5
5
  "keywords": [
6
6
  "bot",
@@ -24,6 +24,7 @@
24
24
  "install:prod": "npm install --omit=dev"
25
25
  },
26
26
  "dependencies": {
27
+ "@sinclair/typebox": "^0.32.0",
27
28
  "axios": "^1.6.0",
28
29
  "ws": "^8.17.0",
29
30
  "zod": "^3.22.0"
package/src/channel.ts CHANGED
@@ -432,11 +432,48 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
432
432
  });
433
433
  log.info(`${logPrefix} [stream] START acknowledged: msgId=${msgId}`);
434
434
 
435
- // 2. 分发消息给 AI,deliver 回调负责把增量文本通过 CHUNK 推给 IM
435
+ // 1.1 为“思考中”占位增加首回复超时保护(默认 5 分钟,可通过 firstReplyTimeoutMs 配置)
436
+ let hasFirstReply = false;
437
+ let firstReplyTimedOut = false;
438
+ const firstReplyTimeoutMs = config.firstReplyTimeoutMs ?? 5 * 60_000;
439
+ const timeoutLabel = `${logPrefix} [stream] First reply timeout after ${firstReplyTimeoutMs}ms, updating thinking message as error`;
440
+ const firstReplyTimeout = setTimeout(async () => {
441
+ if (hasFirstReply) {
442
+ return;
443
+ }
444
+ firstReplyTimedOut = true;
445
+ log.warn(timeoutLabel);
446
+ try {
447
+ const senderId = params.userInfo?.id;
448
+ const senderName = params.userInfo?.name ?? "未知用户";
449
+ const text = params.msgContent?.text ?? "";
450
+ const reply = {
451
+ targetMsgId: params.msgId,
452
+ targetUserId: senderId ?? "",
453
+ targetUserName: senderName,
454
+ previewText: text,
455
+ };
456
+ const timeoutText = "当前请求处理超时,请稍后重试。";
457
+ await sendTextMessage(
458
+ config,
459
+ currentIdentity.token,
460
+ params.groupId,
461
+ timeoutText,
462
+ [senderId ?? ""] as string[],
463
+ log,
464
+ msgId,
465
+ reply,
466
+ );
467
+ log.info(
468
+ `${logPrefix} [send] Timeout reply sent via HTTP: groupId=${params.groupId} msgId=${msgId} text="${timeoutText}"`,
469
+ );
470
+ } catch (err: unknown) {
471
+ log.error(`${logPrefix} [timeout] Failed to send timeout reply: ${String(err)}`);
472
+ }
473
+ }, firstReplyTimeoutMs);
474
+
475
+ // 2. 分发消息给 AI,deliver 回调负责发送首条和后续回复
436
476
  let fullText = "";
437
- // think 与 answer 阶段各自维护 seq,便于集群/跨服务按序重排
438
- let thinkSeq = 0;
439
- let answerSeq = 0;
440
477
 
441
478
  await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
442
479
  ctx: inboundCtx,
@@ -448,9 +485,6 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
448
485
  const textToSend = payload.markdown || payload.text;
449
486
  if (!textToSend) return;
450
487
 
451
- const isThinking = payload.isThinking === true;
452
- const seq = isThinking ? thinkSeq++ : answerSeq++;
453
-
454
488
  if (isFirstReply) {
455
489
  const ttfr = Date.now() - dispatchStart;
456
490
  log.info(`${logPrefix} [deliver] First response block received from AI (TTFB: ${ttfr}ms)`);
@@ -459,14 +493,51 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
459
493
 
460
494
  fullText += textToSend;
461
495
 
462
- // 将增量 block 以 CHUNK 形式推给 IM,带 seq 便于服务端按序
463
- await wsHandle.streamClient.chunk(msgId, {
464
- isThinking,
465
- content: textToSend,
466
- seq,
467
- });
496
+ const senderId = params.userInfo?.id;
497
+ const senderName = params.userInfo?.name ?? "未知用户";
498
+ const text = params.msgContent?.text ?? "";
499
+ const reply = {
500
+ targetMsgId: params.msgId,
501
+ targetUserId: senderId ?? "",
502
+ targetUserName: senderName,
503
+ previewText: text,
504
+ };
505
+
506
+ // 第一次有效回复:覆盖“思考中”占位消息(带 msgId)
507
+ if (!hasFirstReply) {
508
+ hasFirstReply = true;
509
+ clearTimeout(firstReplyTimeout);
510
+ await sendTextMessage(
511
+ config,
512
+ currentIdentity.token,
513
+ params.groupId,
514
+ textToSend,
515
+ [senderId ?? ""] as string[],
516
+ log,
517
+ msgId,
518
+ reply,
519
+ );
520
+ const preview = textToSend.length > 80 ? `${textToSend.slice(0, 80)}...` : textToSend;
521
+ log.info(
522
+ `${logPrefix} [send] First reply sent via HTTP: groupId=${params.groupId} msgId=${msgId} text="${preview}"`,
523
+ );
524
+ return;
525
+ }
526
+
527
+ // 后续回复:作为独立消息发送(不再复用 msgId)
528
+ await sendTextMessage(
529
+ config,
530
+ currentIdentity.token,
531
+ params.groupId,
532
+ textToSend,
533
+ [senderId ?? ""] as string[],
534
+ log,
535
+ undefined,
536
+ reply,
537
+ );
538
+ const preview = textToSend.length > 80 ? `${textToSend.slice(0, 80)}...` : textToSend;
468
539
  log.info(
469
- `${logPrefix} [stream] CHUNK sent: msgId=${msgId} isThinking=${isThinking} seq=${seq} length=${textToSend.length}`,
540
+ `${logPrefix} [send] Additional reply sent via HTTP: groupId=${params.groupId} text="${preview}"`,
470
541
  );
471
542
  } catch (err: unknown) {
472
543
  log.error(`${logPrefix} Reply deliver failed: ${String(err)}`);
@@ -476,35 +547,12 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
476
547
  },
477
548
  });
478
549
 
479
- // 3. AI 推理完成,发送 END,并用 msgId 覆盖最终消息内容
550
+ // 3. AI 推理完成,发送 END,结束本次流式占位语义
551
+ // 一旦发送 END,就不再补发「请求超时」消息,因此这里无条件清理定时器。
552
+ clearTimeout(firstReplyTimeout);
480
553
  await wsHandle.streamClient.end(msgId, "stop");
481
554
  log.info(`${logPrefix} [stream] END sent: msgId=${msgId}`);
482
555
 
483
- if (fullText) {
484
- log.info(
485
- `${logPrefix} [send] Updating final message via HTTP: groupId=${params.groupId} msgId=${msgId}`,
486
- );
487
-
488
- // 构造 reply 信息:回复用户刚发的这条消息
489
- const reply = {
490
- targetMsgId: params.msgId,
491
- targetUserId: senderId ?? "",
492
- targetUserName: senderName,
493
- previewText: text,
494
- };
495
-
496
- await sendTextMessage(
497
- config,
498
- currentIdentity.token,
499
- params.groupId,
500
- fullText,
501
- [senderId ?? ""] as string[],
502
- log,
503
- msgId,
504
- reply,
505
- );
506
- }
507
-
508
556
  log.info(`${logPrefix} [dispatch] Dispatch completed for sessionKey=${route.sessionKey}`);
509
557
  } else {
510
558
  // 没 @ 我,仅作为旁观者缓存记忆,不打扰群里聊天
@@ -33,7 +33,7 @@ export async function sendTextMessage(
33
33
  const url = `${config.baseUrl}/im/message/send`;
34
34
 
35
35
  const body: SendMessageBody = {
36
- type: "TEXT",
36
+ type: "RICH_TEXT",
37
37
  groupId,
38
38
  text: content,
39
39
  ...(atUserIds.length > 0 ? { atUserIds } : {}),
package/src/types.ts CHANGED
@@ -69,6 +69,13 @@ export interface XgImConfig extends OpenClawConfig {
69
69
  maxReconnectDelay?: number;
70
70
  /** 重连延迟抖动因子 0-1(默认 0.3) */
71
71
  reconnectJitter?: number;
72
+ /**
73
+ * 首次 AI 回复超时时间(毫秒)。
74
+ *
75
+ * - 未配置时默认 5 分钟(300_000ms)。
76
+ * - 仅用于保护「思考中」占位消息,避免长时间不被更新。
77
+ */
78
+ firstReplyTimeoutMs?: number;
72
79
  }
73
80
 
74
81
  // ─── IM 接口 Request / Response ─────────────────────────────────────────────