@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/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.9",
4
4
  "description": "XG CWork IM channel plugin for OpenClaw",
5
5
  "keywords": [
6
6
  "bot",
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 { 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 { 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: false,
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
- const msgType = msgContent?.type ?? "text";
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
- let rawText = msgContent?.text ?? "";
326
- // 如果是语音消息且没有文本内容,设为占位符
327
- if (msgType === "voice" && !rawText) {
328
- rawText = "[语音消息]";
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
- if (!text && !msgUrl) {
333
- log.debug?.(`${logPrefix} Empty message (no text and no url), skipping`);
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
- // 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 视角的“单条纯净消息”
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
- 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
- })(),
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
- 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,
792
+ await dispatchMentionedReply({
793
+ rt,
509
794
  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
- },
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.`);