@xgjktech/xg_cwork_im 1.0.6 → 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/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 中注入)────────────────────────────
@@ -41,10 +45,14 @@ function getXgImRuntime(): PluginRuntime {
41
45
  // ─── 配置 Schema ──────────────────────────────────────────────────────────────
42
46
 
43
47
  const XgImAccountConfigSchema = z.object({
44
- appKey: z.string().min(1, "appKey is required"),
48
+ // 注意:为了兼容 OpenClaw doctor 生成的 accounts.default(仅包含默认项,不含 appKey),这里先放宽为 optional,
49
+ // 然后在 XgImConfigSchema.superRefine 中强制校验:除 default 之外的账号必须提供 appKey。
50
+ appKey: z.string().min(1, "appKey is required").optional(),
45
51
  agentId: z.string().optional().default("main"),
46
52
  name: z.string().optional(),
47
53
  groupPolicy: z.enum(["open", "mention"]).optional().default("mention"),
54
+ fileUploadFormField: z.string().min(1).optional(),
55
+ maxAttachmentBytes: z.number().int().positive().optional(),
48
56
  });
49
57
 
50
58
  const XgImConfigSchema = z.object({
@@ -61,7 +69,24 @@ const XgImConfigSchema = z.object({
61
69
  initialReconnectDelay: z.number().int().positive().optional().default(1_000),
62
70
  maxReconnectDelay: z.number().int().positive().optional().default(60_000),
63
71
  reconnectJitter: z.number().min(0).max(1).optional().default(0.3),
64
- accounts: z.array(XgImAccountConfigSchema).optional(),
72
+ fileUploadFormField: z.string().min(1).optional(),
73
+ maxAttachmentBytes: z.number().int().positive().optional(),
74
+ // 多账户:对象 map(key 为 accountId)
75
+ accounts: z.record(z.string(), XgImAccountConfigSchema).optional(),
76
+ }).superRefine((val, ctx) => {
77
+ const accounts = val.accounts;
78
+ if (!accounts) return;
79
+ for (const [key, acc] of Object.entries(accounts)) {
80
+ if (key === "default") continue;
81
+ if (!acc || typeof acc !== "object") continue;
82
+ if (!("appKey" in acc) || !acc.appKey) {
83
+ ctx.addIssue({
84
+ code: z.ZodIssueCode.custom,
85
+ path: ["accounts", key, "appKey"],
86
+ message: "appKey is required for account entries (except accounts.default)",
87
+ });
88
+ }
89
+ }
65
90
  });
66
91
 
67
92
  // ─── 辅助函数 ─────────────────────────────────────────────────────────────────
@@ -71,19 +96,25 @@ function getXgImConfig(cfg: OpenClawConfig, accountId?: string | null): XgImConf
71
96
  const raw = (cfg as Record<string, Record<string, unknown>>)?.channels?.xg_cwork_im as XgImConfig | undefined;
72
97
  if (!raw) throw new Error("[cwork_im] channels.xg_cwork_im config not found");
73
98
 
74
- // 指定了具体账户 ID(数组下标)时,合并对应账户配置
75
- if (accountId && accountId !== "default" && raw.accounts) {
76
- const accIdx = parseInt(accountId, 10);
77
- const sub = raw.accounts[accIdx];
78
- if (sub) {
79
- return { ...raw, ...sub };
80
- }
99
+ const accounts = raw.accounts as unknown;
100
+ const accountMap =
101
+ accounts && typeof accounts === "object" && !Array.isArray(accounts)
102
+ ? (accounts as Record<string, Partial<XgImConfig> | undefined>)
103
+ : undefined;
104
+ const defaults = accountMap?.default ? { ...accountMap.default } : undefined;
105
+
106
+ // 指定了具体账户 ID 时,优先合并对应账户配置
107
+ if (accountId && accountId !== "default" && accountMap) {
108
+ const sub = accountMap[accountId];
109
+ if (sub) return { ...raw, ...(defaults ?? {}), ...sub };
81
110
  }
82
111
 
83
- // 没有指定 accountId(如 Cron outbound 场景),自动 fallback 到 accounts[0]
112
+ // 没有指定 accountId(如 Cron outbound 场景),自动 fallback 到「第一个账户」
84
113
  // 避免顶层 raw 没有 appKey 时 getToken 失败
85
- if (raw.accounts && raw.accounts.length > 0 && !raw.appKey) {
86
- return { ...raw, ...raw.accounts[0] };
114
+ if (accountMap && !raw.appKey) {
115
+ const firstKey = Object.keys(accountMap).find((k) => k !== "default");
116
+ const first = firstKey ? accountMap[firstKey] : undefined;
117
+ if (first) return { ...raw, ...(defaults ?? {}), ...first };
87
118
  }
88
119
 
89
120
  return raw;
@@ -117,6 +148,210 @@ function toLogger(sink: { info?: (msg: string) => void; warn?: (msg: string) =>
117
148
  };
118
149
  }
119
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
+
120
355
  // ─── Channel Plugin 定义 ─────────────────────────────────────────────────────
121
356
 
122
357
  export const xgCworkImChannelPlugin: XgImChannelPlugin = {
@@ -136,7 +371,7 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
136
371
  chatTypes: ["group"] as Array<"direct" | "group">,
137
372
  reactions: false,
138
373
  threads: false,
139
- media: false,
374
+ media: true,
140
375
  nativeCommands: false,
141
376
  blockStreaming: false,
142
377
  },
@@ -147,9 +382,13 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
147
382
  listAccountIds: (cfg: OpenClawConfig): string[] => {
148
383
  try {
149
384
  const config = getXgImConfig(cfg);
150
- if (config.accounts && config.accounts.length > 0) {
151
- // 使用数组下标作为 ID
152
- return config.accounts.map((_, i) => i.toString());
385
+ const accounts = config.accounts as unknown;
386
+ if (accounts) {
387
+ if (!Array.isArray(accounts) && typeof accounts === "object") {
388
+ // 推荐写法:对象 key 作为 accountId
389
+ const keys = Object.keys(accounts as Record<string, unknown>).filter((k) => k !== "default");
390
+ return keys;
391
+ }
153
392
  }
154
393
  return isConfigured(cfg) ? ["default"] : [];
155
394
  } catch {
@@ -179,6 +418,8 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
179
418
  }
180
419
  },
181
420
 
421
+ // 注意:OpenClaw 可能在缺省路由时使用 defaultAccountId。
422
+ // 我们使用 "default" 作为别名:getXgImConfig 会自动 fallback 到第一个非 default 的真实账号。
182
423
  defaultAccountId: (): string => "default",
183
424
 
184
425
  isConfigured: (account: ResolvedAccount): boolean => account.configured,
@@ -231,6 +472,35 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
231
472
  messageId: randomUUID(),
232
473
  };
233
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
+ },
234
504
  },
235
505
 
236
506
  // ── 网关(WebSocket 长连接)─────────────────────────────────────────────────
@@ -286,22 +556,16 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
286
556
  }
287
557
 
288
558
  const msgContent = params.msgContent;
289
- const msgType = msgContent?.type ?? "text";
290
- const msgUrl = msgContent?.url;
559
+ const fileItems = collectFileItems(msgContent);
291
560
  const msgExt = msgContent?.ext;
292
561
  const senderId = params.userInfo?.id;
293
562
  const senderName = params.userInfo?.name || senderId || "";
294
563
  const senderBackground = params.userInfo?.background;
295
564
 
296
- let rawText = msgContent?.text ?? "";
297
- // 如果是语音消息且没有文本内容,设为占位符
298
- if (msgType === "voice" && !rawText) {
299
- rawText = "[语音消息]";
300
- }
301
- const text = rawText;
565
+ const text = buildInboundDisplayText(msgContent, fileItems);
302
566
 
303
- if (!text && !msgUrl) {
304
- 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`);
305
569
  return;
306
570
  }
307
571
 
@@ -309,21 +573,8 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
309
573
  `${logPrefix} Message from ${senderName}(${senderId ?? "unknown"}) in group=${params.groupId}: ${text}`,
310
574
  );
311
575
 
312
- // 1. 获取当前机器人身份
313
576
  const currentIdentity = await getToken(config, log);
314
- // 2. 判定是否真正 @ 了当前机器人
315
- const mentions = params.mentions;
316
- const isMentioned = Array.isArray(mentions) &&
317
- (mentions.includes(currentIdentity.userId) || mentions.includes("all"));
318
-
319
- // (补充逻辑) 如果是 robotMention 指令,但没有 mentions 列表,则降级为老逻辑:文本正则匹配
320
- const isLegacyMentioned = msg.cmd === "robotMention" &&
321
- (!params.mentions || params.mentions.length === 0) &&
322
- new RegExp(`@${currentIdentity.name}\\b`).test(rawText);
323
-
324
- const actuallyMentioned = isMentioned || isLegacyMentioned;
325
-
326
- // 3. 构建 OpenClaw 视角的“单条纯净消息”
577
+ const actuallyMentioned = isActuallyMentioned(msg, params, currentIdentity, text);
327
578
 
328
579
  // 通过 PluginRuntime 路由消息到 OpenClaw
329
580
  const route = rt.channel.routing.resolveAgentRoute({
@@ -378,16 +629,8 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
378
629
  OriginatingChannel: "xg_cwork_im",
379
630
  OriginatingTo: params.groupId,
380
631
  GroupChannel: route.sessionKey,
381
- // 透传媒体信息
382
- MediaUrl: msgUrl,
383
- MediaType: msgType === "voice" ? "voice" : undefined,
384
- // 透传扩展字段给 AI (作为 UntrustedContext)
385
- UntrustedContext: (() => {
386
- const parts: string[] = [];
387
- if (msgExt) parts.push(JSON.stringify(msgExt));
388
- if (senderBackground) parts.push(String(senderBackground));
389
- return parts.length > 0 ? parts : undefined;
390
- })(),
632
+ ...mediaFieldsFromFileItems(fileItems),
633
+ UntrustedContext: untrustedContextFromExtAndBackground(msgExt, senderBackground),
391
634
  // 同时保留原始 ext 供可能的后续逻辑使用
392
635
  XgImExt: msgExt,
393
636
  });
@@ -420,140 +663,19 @@ export const xgCworkImChannelPlugin: XgImChannelPlugin = {
420
663
  },
421
664
  });
422
665
 
423
- // 【只有真 @ 我的消息才做】呼叫 AI 激活推理
424
666
  if (actuallyMentioned) {
425
- log.info(`${logPrefix} [dispatch] Dispatching to OpenClaw AI, sessionKey=${route.sessionKey}`);
426
- let isFirstReply = true;
427
- const dispatchStart = Date.now();
428
-
429
- // 1. 先向 IM 声明“开始流式消息”,拿到 msgId
430
- const { msgId } = await wsHandle.streamClient.start({
431
- groupId: params.groupId,
432
- });
433
- log.info(`${logPrefix} [stream] START acknowledged: msgId=${msgId}`);
434
-
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 回调负责发送首条和后续回复
476
- let fullText = "";
477
-
478
- await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
479
- ctx: inboundCtx,
667
+ await dispatchMentionedReply({
668
+ rt,
480
669
  cfg: ctx.cfg,
481
- dispatcherOptions: {
482
- responsePrefix: "",
483
- deliver: async (payload: { markdown?: string; text?: string; isThinking?: boolean }) => {
484
- try {
485
- const textToSend = payload.markdown || payload.text;
486
- if (!textToSend) return;
487
-
488
- if (isFirstReply) {
489
- const ttfr = Date.now() - dispatchStart;
490
- log.info(`${logPrefix} [deliver] First response block received from AI (TTFB: ${ttfr}ms)`);
491
- isFirstReply = false;
492
- }
493
-
494
- fullText += textToSend;
495
-
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;
539
- log.info(
540
- `${logPrefix} [send] Additional reply sent via HTTP: groupId=${params.groupId} text="${preview}"`,
541
- );
542
- } catch (err: unknown) {
543
- log.error(`${logPrefix} Reply deliver failed: ${String(err)}`);
544
- throw err;
545
- }
546
- },
547
- },
670
+ config,
671
+ log,
672
+ logPrefix,
673
+ route,
674
+ inboundCtx,
675
+ params,
676
+ currentIdentity,
677
+ streamClient: wsHandle.streamClient,
548
678
  });
549
-
550
- // 3. AI 推理完成,发送 END,结束本次流式占位语义
551
- // 一旦发送 END,就不再补发「请求超时」消息,因此这里无条件清理定时器。
552
- clearTimeout(firstReplyTimeout);
553
- await wsHandle.streamClient.end(msgId, "stop");
554
- log.info(`${logPrefix} [stream] END sent: msgId=${msgId}`);
555
-
556
- log.info(`${logPrefix} [dispatch] Dispatch completed for sessionKey=${route.sessionKey}`);
557
679
  } else {
558
680
  // 没 @ 我,仅作为旁观者缓存记忆,不打扰群里聊天
559
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) => {