@core-workspace/infoflow-openclaw-plugin 2026.3.8 → 2026.3.27-beta.0

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.
Files changed (55) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/CLAUDE.md +135 -0
  3. package/COLLABORATION_REPORT.md +209 -0
  4. package/PROJECT_GUIDE.md +355 -0
  5. package/README.md +158 -66
  6. package/docs/dev-guide.md +63 -50
  7. package/docs/qa-feature-list.md +452 -0
  8. package/docs/webhook-guide.md +178 -0
  9. package/index.ts +28 -2
  10. package/openclaw.plugin.json +131 -21
  11. package/package.json +20 -3
  12. package/scripts/deploy.sh +66 -7
  13. package/scripts/postinstall.cjs +80 -0
  14. package/skills/infoflow-dev/SKILL.md +2 -2
  15. package/skills/infoflow-dev/references/api.md +1 -1
  16. package/src/adapter/inbound/webhook-parser.ts +27 -5
  17. package/src/adapter/inbound/ws-receiver.ts +304 -43
  18. package/src/adapter/outbound/markdown-local-images.ts +80 -0
  19. package/src/adapter/outbound/reply-dispatcher.ts +146 -65
  20. package/src/adapter/outbound/target-resolver.ts +4 -3
  21. package/src/channel/accounts.ts +97 -22
  22. package/src/channel/channel.ts +456 -12
  23. package/src/channel/media.ts +20 -6
  24. package/src/channel/monitor.ts +8 -3
  25. package/src/channel/outbound.ts +358 -21
  26. package/src/channel/streaming.ts +740 -0
  27. package/src/commands/changelog.ts +80 -0
  28. package/src/commands/doctor.ts +545 -0
  29. package/src/commands/logs.ts +449 -0
  30. package/src/commands/version.ts +20 -0
  31. package/src/compat/openclaw-sdk.ts +218 -0
  32. package/src/handler/message-handler.ts +673 -166
  33. package/src/logging.ts +1 -1
  34. package/src/runtime.ts +1 -1
  35. package/src/security/dm-policy.ts +1 -4
  36. package/src/security/group-policy.ts +174 -51
  37. package/src/tools/actions/index.ts +15 -13
  38. package/src/tools/cron/relay.ts +1154 -0
  39. package/src/tools/hooks/index.ts +13 -1
  40. package/src/tools/index.ts +714 -32
  41. package/src/types.ts +144 -25
  42. package/src/utils/audio/g722/dct_tables.ts +381 -0
  43. package/src/utils/audio/g722/decoder.ts +919 -0
  44. package/src/utils/audio/g722/defs.ts +105 -0
  45. package/src/utils/audio/g722/hd-parser.ts +247 -0
  46. package/src/utils/audio/g722/huff_tables.ts +240 -0
  47. package/src/utils/audio/g722/index.ts +78 -0
  48. package/src/utils/audio/g722/output_decoded.pcm +0 -0
  49. package/src/utils/audio/g722/output_decoded.wav +0 -0
  50. package/src/utils/audio/g722/tables.ts +173 -0
  51. package/src/utils/audio/g722/test_api.ts +31 -0
  52. package/src/utils/audio/g722/test_voice.hd +0 -0
  53. package/src/utils/bos/im-bos-client.ts +219 -0
  54. package/src/utils/group-agent-cache.ts +142 -0
  55. package/src/utils/token-adapter.ts +120 -51
@@ -9,11 +9,11 @@
9
9
  * (the connection itself is authenticated), so we skip AES-ECB decryption
10
10
  * and feed the payload directly to bot handlers.
11
11
  *
12
- * SDK event system (@core-workspace/infoflow-sdk-nodejs):
12
+ * SDK event system (@baidu/infoflow-sdk-nodejs):
13
13
  * - Group messages → "group.*" (covers group.text, group.mixed, group.image, etc.)
14
14
  * - Private messages → "private.*" (covers private.text, private.image, etc.)
15
- * - event.data is normalized by SDK: { chatType, msgType, fromUserId, groupId, body,
16
- * content, originalMessage, ... }
15
+ * - SDK 0.1.1: event.data is normalized with fromUserId, groupId, body, originalMessage, ...
16
+ * - SDK 0.1.5+: event.data is { chatType, msgType, raw } — raw is the full original payload
17
17
  *
18
18
  * SDK Bug Workaround:
19
19
  * SDK's normalizePrivateMessage() drops PicUrl/MsgId fields from private messages.
@@ -25,11 +25,13 @@
25
25
  */
26
26
 
27
27
  import { WSClient } from "@core-workspace/infoflow-sdk-nodejs";
28
- import type { OpenClawConfig } from "openclaw/plugin-sdk";
28
+ import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry";
29
+ import { extractIdFromRawJson } from "../../channel/outbound.js";
29
30
  import { handleGroupChatMessage, handlePrivateChatMessage } from "../../handler/message-handler.js";
30
- import { isDuplicateMessage } from "./webhook-parser.js";
31
- import { formatInfoflowError, logVerbose } from "../../logging.js";
31
+ import { formatInfoflowError, logVerbose, getInfoflowWebhookLog } from "../../logging.js";
32
+ import { checkBotMentioned } from "../../security/group-policy.js";
32
33
  import type { ResolvedInfoflowAccount } from "../../types.js";
34
+ import { isDuplicateMessage } from "./webhook-parser.js";
33
35
 
34
36
  // ---------------------------------------------------------------------------
35
37
  // Types
@@ -42,6 +44,51 @@ export type WSReceiverOptions = {
42
44
  statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
43
45
  };
44
46
 
47
+ type GroupInboundSeenKind = "forward" | "mention";
48
+
49
+ const GROUP_INBOUND_TTL_MS = 5 * 60 * 1000;
50
+ const GROUP_INBOUND_MAX_SIZE = 2048;
51
+ const groupInboundSeen = new Map<string, { kind: GroupInboundSeenKind; seenAt: number }>();
52
+
53
+ function pruneGroupInboundSeen(now: number): void {
54
+ for (const [key, value] of groupInboundSeen) {
55
+ if (now - value.seenAt > GROUP_INBOUND_TTL_MS) {
56
+ groupInboundSeen.delete(key);
57
+ }
58
+ }
59
+ if (groupInboundSeen.size <= GROUP_INBOUND_MAX_SIZE) return;
60
+ const overflow = groupInboundSeen.size - GROUP_INBOUND_MAX_SIZE;
61
+ const oldest = [...groupInboundSeen.entries()]
62
+ .sort((a, b) => a[1].seenAt - b[1].seenAt)
63
+ .slice(0, overflow);
64
+ for (const [key] of oldest) {
65
+ groupInboundSeen.delete(key);
66
+ }
67
+ }
68
+
69
+ function shouldSkipDuplicateGroupEvent(
70
+ dedupKey: string,
71
+ incomingKind: GroupInboundSeenKind,
72
+ ): boolean {
73
+ const now = Date.now();
74
+ pruneGroupInboundSeen(now);
75
+ const existing = groupInboundSeen.get(dedupKey);
76
+ if (!existing) {
77
+ groupInboundSeen.set(dedupKey, { kind: incomingKind, seenAt: now });
78
+ return false;
79
+ }
80
+ if (incomingKind === "forward") {
81
+ existing.seenAt = now;
82
+ return true;
83
+ }
84
+ if (existing.kind === "mention") {
85
+ existing.seenAt = now;
86
+ return true;
87
+ }
88
+ groupInboundSeen.set(dedupKey, { kind: "mention", seenAt: now });
89
+ return false;
90
+ }
91
+
45
92
  // ---------------------------------------------------------------------------
46
93
  // WSReceiver
47
94
  // ---------------------------------------------------------------------------
@@ -54,17 +101,21 @@ export class InfoflowWSReceiver {
54
101
  private wsClient: WSClient;
55
102
  private options: WSReceiverOptions;
56
103
  private stopped = false;
104
+ private handleGroupEventRef: ((event: any) => Promise<void>) | null = null;
105
+ private handlePrivateEventRef: ((event: any) => Promise<void>) | null = null;
57
106
 
58
107
  constructor(options: WSReceiverOptions) {
59
108
  this.options = options;
60
109
  const { appKey, appSecret } = options.account.config;
61
- const wsGateway =
62
- options.account.config.wsGateway ?? "infoflow-open-gateway.weiyun.baidu.com";
110
+ const wsGateway = options.account.config.wsGateway ?? "infoflow-open-gateway.baidu.com";
111
+ const wsConnectDomain = options.account.config.wsConnectDomain;
63
112
 
64
113
  this.wsClient = new WSClient({
65
114
  appId: appKey,
66
115
  appSecret: appSecret,
67
116
  wsGateway,
117
+ ...(wsConnectDomain ? { wsConnectDomain } : {}),
118
+ endpointTimeout: 15_000, // SDK 0.1.7: configurable timeout for Phase 1 endpoint fetch
68
119
  });
69
120
 
70
121
  // Patch SDK bug: normalizePrivateMessage() drops PicUrl/MsgId.
@@ -79,34 +130,115 @@ export class InfoflowWSReceiver {
79
130
  return normalized;
80
131
  };
81
132
  }
133
+
134
+ // Patch frameCodec.parsePayload to attach _rawJson to parsed objects.
135
+ // This preserves large integer precision (e.g. messageid) that JSON.parse loses.
136
+ const frameCodec = (this.wsClient as any).frameCodec;
137
+ if (frameCodec && typeof frameCodec.parsePayload === "function") {
138
+ const originalParse = frameCodec.parsePayload.bind(frameCodec);
139
+ frameCodec.parsePayload = function (frame: any) {
140
+ const result = originalParse(frame);
141
+ if (result && typeof result === "object" && frame.payload) {
142
+ try {
143
+ result._rawJson = frame.payload.toString("utf-8");
144
+ } catch {
145
+ /* ignore */
146
+ }
147
+ }
148
+ // Log every frame that passes through (method=DATA means a message frame)
149
+ const method = frame.method ?? "?";
150
+ getInfoflowWebhookLog().info(
151
+ `[ws:frame] method=${method}, payloadLen=${frame.payload?.length ?? 0}`,
152
+ );
153
+ return result;
154
+ };
155
+ }
156
+
157
+ // Patch SDK reconnect behavior: when this receiver is stopped, intercept
158
+ // handleDisconnect to prevent the SDK from attempting to reconnect.
159
+ // Without this patch, a stopped receiver may reconnect after channel hot-reload
160
+ // and silently consume inbound messages that should go to the new receiver.
161
+ const rawHandleDisconnect = (this.wsClient as any).handleDisconnect;
162
+ if (typeof rawHandleDisconnect === "function") {
163
+ const originalHandleDisconnect = rawHandleDisconnect.bind(this.wsClient);
164
+ (this.wsClient as any).handleDisconnect = (...args: unknown[]) => {
165
+ if (this.stopped) {
166
+ try {
167
+ (this.wsClient as any).stopHeartbeat?.();
168
+ } catch {
169
+ // ignore shutdown cleanup errors
170
+ }
171
+ (this.wsClient as any).state = "disconnected";
172
+ return;
173
+ }
174
+ return originalHandleDisconnect(...args);
175
+ };
176
+ }
82
177
  }
83
178
 
84
179
  /** Connect and start receiving messages. */
85
180
  async start(): Promise<void> {
86
181
  // Listen for all group messages (group.text, group.mixed, group.image, etc.)
87
182
  const handleGroupEvent = async (event: any) => {
88
- if (this.stopped) return;
183
+ getInfoflowWebhookLog().info(
184
+ `[ws:inbound] group event received, type=${event?.type ?? event?.data?.msgType ?? "?"}`,
185
+ );
186
+ if (this.stopped) {
187
+ getInfoflowWebhookLog().warn(`[ws:inbound] group event dropped (receiver stopped)`);
188
+ return;
189
+ }
89
190
  try {
90
191
  await this.handleGroupEvent(event.data ?? event);
91
192
  } catch (err) {
92
- console.error(`[infoflow:ws] group handler error: ${formatInfoflowError(err)}`);
193
+ getInfoflowWebhookLog().error(
194
+ `[ws:inbound] group handler error: ${formatInfoflowError(err)}`,
195
+ );
93
196
  }
94
197
  };
95
198
  this.wsClient.on("group.*", handleGroupEvent);
199
+ this.handleGroupEventRef = handleGroupEvent;
96
200
 
97
201
  // Listen for all private messages (private.text, private.image, etc.)
98
202
  const handlePrivateEvent = async (event: any) => {
99
- if (this.stopped) return;
203
+ getInfoflowWebhookLog().info(
204
+ `[ws:inbound] private event received, type=${event?.type ?? event?.data?.msgType ?? "?"}`,
205
+ );
206
+ if (this.stopped) {
207
+ getInfoflowWebhookLog().warn(`[ws:inbound] private event dropped (receiver stopped)`);
208
+ return;
209
+ }
100
210
  try {
101
211
  await this.handlePrivateEvent(event.data ?? event);
102
212
  } catch (err) {
103
- console.error(`[infoflow:ws] private handler error: ${formatInfoflowError(err)}`);
213
+ getInfoflowWebhookLog().error(
214
+ `[ws:inbound] private handler error: ${formatInfoflowError(err)}`,
215
+ );
104
216
  }
105
217
  };
106
218
  this.wsClient.on("private.*", handlePrivateEvent);
219
+ this.handlePrivateEventRef = handlePrivateEvent;
220
+
221
+ // Listen for SDK-level connect/disconnect/error events for diagnostics
222
+ this.wsClient.on("connected" as any, (event: any) => {
223
+ getInfoflowWebhookLog().info(
224
+ `[ws:connect] websocket connected, connection_id=${event?.connectionId ?? "?"}`,
225
+ );
226
+ });
227
+ this.wsClient.on("disconnected" as any, (event: any) => {
228
+ getInfoflowWebhookLog().warn(
229
+ `[ws:disconnect] websocket disconnected, connection_id=${event?.connectionId ?? "?"}`,
230
+ );
231
+ });
232
+ this.wsClient.on("error" as any, (event: any) => {
233
+ const msg = event?.error?.message ?? String(event?.error ?? event ?? "unknown");
234
+ getInfoflowWebhookLog().error(`[ws:error] websocket error: ${msg}`);
235
+ });
107
236
 
108
237
  // Connect (two-phase: endpoint allocation → WS handshake)
238
+ const wsGateway = this.options.account.config.wsGateway ?? "infoflow-open-gateway.baidu.com";
239
+ getInfoflowWebhookLog().info(`[ws:connect] connecting to ${wsGateway}`);
109
240
  await this.wsClient.connect();
241
+ getInfoflowWebhookLog().info(`[ws:connect] initial connection established`);
110
242
 
111
243
  // Listen for abortSignal to gracefully disconnect
112
244
  this.options.abortSignal.addEventListener(
@@ -122,6 +254,38 @@ export class InfoflowWSReceiver {
122
254
  stop(): void {
123
255
  if (this.stopped) return;
124
256
  this.stopped = true;
257
+ // 移除事件监听器,防止 SDK 自动重连后旧 handler 继续接收消息
258
+ if (this.handleGroupEventRef) {
259
+ try {
260
+ (this.wsClient as any).off("group.*", this.handleGroupEventRef);
261
+ } catch {
262
+ /* ignore */
263
+ }
264
+ this.handleGroupEventRef = null;
265
+ }
266
+ if (this.handlePrivateEventRef) {
267
+ try {
268
+ (this.wsClient as any).off("private.*", this.handlePrivateEventRef);
269
+ } catch {
270
+ /* ignore */
271
+ }
272
+ this.handlePrivateEventRef = null;
273
+ }
274
+ getInfoflowWebhookLog().info(`[ws:disconnect] stopping ws receiver`);
275
+ try {
276
+ const client = this.wsClient as any;
277
+ client.stopHeartbeat?.();
278
+ if (client.serverConfig && typeof client.serverConfig === "object") {
279
+ client.serverConfig = {
280
+ ...client.serverConfig,
281
+ reconnect_count: 0,
282
+ };
283
+ }
284
+ client.maxReconnectAttempts = 0;
285
+ client.reconnectAttempts = 0;
286
+ } catch {
287
+ // ignore defensive shutdown patch failures
288
+ }
125
289
  try {
126
290
  this.wsClient.disconnect();
127
291
  } catch {
@@ -131,8 +295,10 @@ export class InfoflowWSReceiver {
131
295
 
132
296
  /**
133
297
  * Handle a group message event from the new SDK.
134
- * SDK normalizes to: { chatType, msgType, fromUserId, groupId, body, content,
135
- * originalMessage, createTime, ... }
298
+ *
299
+ * SDK 0.1.1: normalizes to { chatType, msgType, fromUserId, groupId, body, originalMessage, ... }
300
+ * SDK 0.1.5: normalizes to { chatType, msgType, raw } — raw is the original payload
301
+ *
136
302
  * We reconstruct the raw msgData shape expected by handleGroupChatMessage.
137
303
  */
138
304
  private async handleGroupEvent(data: any): Promise<void> {
@@ -140,37 +306,86 @@ export class InfoflowWSReceiver {
140
306
 
141
307
  this.options.statusSink?.({ lastInboundAt: Date.now() });
142
308
 
309
+ // SDK 0.1.5 puts everything in data.raw; 0.1.1 inlines fields directly.
310
+ const payload = data.raw ?? data;
311
+
143
312
  // Reconstruct raw msgData compatible with handleGroupChatMessage expectations:
144
313
  // { message: { header: { fromuserid, servertime, messageid, ... }, body }, groupid, ... }
145
- const originalMessage = data.originalMessage ?? {};
146
- const header = originalMessage.header ?? {};
314
+ const originalMessage = payload.originalMessage ?? payload;
315
+ const message = payload.message ?? originalMessage.message ?? {};
316
+ const header = message.header ?? originalMessage.header ?? {};
317
+
318
+ // Extract messageid with full precision from raw JSON string (avoids JS Number precision loss
319
+ // for large int64 IDs like 1859821262633816373 which JSON.parse rounds to ...300).
320
+ const rawJson: string | undefined = payload._rawJson;
321
+ const preciseMessageId =
322
+ (rawJson && extractIdFromRawJson(rawJson, "messageid")) ??
323
+ (header.messageid != null ? String(header.messageid) : undefined);
324
+ const preciseClientMsgId =
325
+ (rawJson && extractIdFromRawJson(rawJson, "clientmsgid")) ??
326
+ (header.clientmsgid != null ? String(header.clientmsgid) : undefined);
327
+
328
+ // SDK 用 eventtype 区分消息类型:
329
+ // MESSAGE_RECEIVE = @机器人 的消息(wasMentioned = true)
330
+ // ALL_MESSAGE_FORWARD = 群内全量消息(需开启全量订阅,可能包含 @机器人,需 body 兜底)
331
+ const rawEventType: string =
332
+ payload.eventtype ??
333
+ payload.eventType ??
334
+ originalMessage.eventtype ??
335
+ originalMessage.eventType ??
336
+ "MESSAGE_RECEIVE";
337
+ const bodyItems = (message.body ?? payload.body ?? data.body ?? []) as Array<{
338
+ type?: string;
339
+ name?: string;
340
+ robotid?: number;
341
+ }>;
342
+ const isMentionEvent =
343
+ rawEventType === "MESSAGE_RECEIVE"
344
+ ? true
345
+ : checkBotMentioned(bodyItems, this.options.account.config);
346
+
347
+ getInfoflowWebhookLog().info(
348
+ `[ws:inbound] group eventtype=${rawEventType}, wasMentioned=${isMentionEvent}`,
349
+ );
350
+
147
351
  const msgData: Record<string, unknown> = {
148
- eventtype: "MESSAGE_RECEIVE",
149
- groupid: data.groupId ?? data.groupid,
352
+ eventtype: rawEventType,
353
+ groupid: payload.groupid ?? payload.groupId ?? data.groupId,
354
+ fromid: payload.fromid,
355
+ wasMentioned: isMentionEvent,
150
356
  message: {
151
357
  header: {
152
- fromuserid: data.fromUserId ?? header.fromuserid ?? "",
153
- toid: data.groupId ?? data.groupid,
358
+ fromuserid: header.fromuserid ?? payload.fromUserId ?? data.fromUserId ?? "",
359
+ toid: payload.groupid ?? payload.groupId ?? data.groupId,
154
360
  totype: "GROUP",
155
- msgtype: data.msgType ?? header.msgtype ?? "text",
156
- messageid: header.messageid ?? header.clientmsgid,
157
- clientmsgid: header.clientmsgid,
361
+ msgtype: header.msgtype ?? data.msgType ?? "text",
362
+ messageid: preciseMessageId,
363
+ clientmsgid: preciseClientMsgId,
158
364
  servertime: header.servertime,
159
365
  clienttime: header.clienttime,
160
366
  at: header.at ?? { atrobotids: [] },
161
367
  },
162
- body: data.body ?? (data.content ? [{ type: "TEXT", content: data.content }] : []),
368
+ body:
369
+ bodyItems.length > 0
370
+ ? bodyItems
371
+ : data.content
372
+ ? [{ type: "TEXT", content: data.content }]
373
+ : [],
163
374
  },
164
375
  };
165
376
 
166
- // Dedup check using clientmsgid or messageid as key
167
- const dedupKey = header.clientmsgid ?? header.messageid;
168
- if (dedupKey && isDuplicateMessage({ CreateTime: String(dedupKey) })) {
169
- logVerbose("[infoflow:ws] duplicate group message, skipping");
377
+ // Dedup check using clientmsgid or messageid as key (use precise string IDs)
378
+ const dedupKey = preciseClientMsgId ?? preciseMessageId;
379
+ const dedupKind: GroupInboundSeenKind = isMentionEvent ? "mention" : "forward";
380
+ if (dedupKey && shouldSkipDuplicateGroupEvent(dedupKey, dedupKind)) {
381
+ logVerbose(
382
+ `[infoflow:ws] duplicate group message skipped: key=${dedupKey}, kind=${dedupKind}`,
383
+ );
170
384
  return;
171
385
  }
172
-
173
- logVerbose(`[infoflow:ws] group message: from=${data.fromUserId}, msgType=${data.msgType}, groupId=${data.groupId}`);
386
+ logVerbose(
387
+ `[infoflow:ws] group message: from=${header.fromuserid}, msgType=${header.msgtype}, groupId=${payload.groupid ?? payload.groupId}`,
388
+ );
174
389
 
175
390
  await handleGroupChatMessage({
176
391
  cfg: this.options.config,
@@ -182,7 +397,10 @@ export class InfoflowWSReceiver {
182
397
 
183
398
  /**
184
399
  * Handle a private message event from the new SDK.
185
- * SDK normalizes to: { chatType, msgType, fromUserId, content, createTime, ... }
400
+ *
401
+ * SDK 0.1.1: normalizes to { chatType, msgType, fromUserId, content, createTime, originalMessage, ... }
402
+ * SDK 0.1.5: normalizes to { chatType, msgType, raw } — raw is the original payload
403
+ *
186
404
  * We reconstruct the raw msgData shape expected by handlePrivateChatMessage.
187
405
  */
188
406
  private async handlePrivateEvent(data: any): Promise<void> {
@@ -190,20 +408,63 @@ export class InfoflowWSReceiver {
190
408
 
191
409
  this.options.statusSink?.({ lastInboundAt: Date.now() });
192
410
 
193
- logVerbose(`[DEBUG ws.private] SDK event data 完整结构: ${JSON.stringify(data, null, 2)}`);
411
+ // SDK 0.1.5 puts everything in data.raw; 0.1.1 inlines fields directly.
412
+ const payload = data.raw ?? data;
194
413
 
195
414
  // Reconstruct raw msgData compatible with handlePrivateChatMessage expectations:
196
415
  // { FromUserId, Content, MsgType, CreateTime, PicUrl, MsgId, ... }
197
- const originalMessage = data.originalMessage ?? {};
416
+ const originalMessage = payload.originalMessage ?? payload;
417
+
418
+ // Extract MsgId with full precision from raw JSON string (avoids JS Number precision loss
419
+ // for large int64 IDs > 2^53, consistent with group message handling above).
420
+ const rawJson: string | undefined = payload._rawJson ?? originalMessage._rawJson;
421
+ const preciseMsgId =
422
+ (rawJson &&
423
+ (extractIdFromRawJson(rawJson, "MsgId") ?? extractIdFromRawJson(rawJson, "msgId"))) ??
424
+ (() => {
425
+ const raw = payload.MsgId ?? payload.msgId ?? data.msgId ?? originalMessage.MsgId;
426
+ return raw != null ? String(raw) : undefined;
427
+ })();
428
+
198
429
  const msgData: Record<string, unknown> = {
199
- FromUserId: data.fromUserId ?? data.FromUserId ?? originalMessage.FromUserId ?? "",
200
- FromUserName: data.fromUserName ?? data.FromUserName ?? originalMessage.FromUserName,
201
- Content: data.content ?? data.Content ?? originalMessage.Content ?? "",
202
- MsgType: data.msgType ?? data.MsgType ?? originalMessage.MsgType ?? "text",
203
- CreateTime: data.createTime ?? data.CreateTime ?? originalMessage.CreateTime ?? String(Date.now()),
430
+ FromUserId:
431
+ payload.FromUserId ??
432
+ payload.fromUserId ??
433
+ data.fromUserId ??
434
+ originalMessage.FromUserId ??
435
+ "",
436
+ FromUserName:
437
+ payload.FromUserName ??
438
+ payload.fromUserName ??
439
+ data.fromUserName ??
440
+ originalMessage.FromUserName,
441
+ Content: payload.Content ?? payload.content ?? data.content ?? originalMessage.Content ?? "",
442
+ MsgType:
443
+ payload.MsgType ?? payload.msgType ?? data.msgType ?? originalMessage.MsgType ?? "text",
444
+ CreateTime:
445
+ payload.CreateTime ??
446
+ payload.createTime ??
447
+ data.createTime ??
448
+ originalMessage.CreateTime ??
449
+ String(Date.now()),
204
450
  // 图片消息字段
205
- PicUrl: data.picUrl ?? data.PicUrl ?? originalMessage.PicUrl ?? "",
206
- MsgId: data.msgId ?? data.MsgId ?? originalMessage.MsgId,
451
+ PicUrl: payload.PicUrl ?? payload.picUrl ?? data.picUrl ?? originalMessage.PicUrl ?? "",
452
+ // 语音消息字段
453
+ VoiceUrl:
454
+ payload.VoiceUrl ?? payload.voiceUrl ?? data.voiceUrl ?? originalMessage.VoiceUrl ?? "",
455
+ // 平台与应用字段
456
+ FromPlatform:
457
+ payload.FromPlatform ??
458
+ payload.fromPlatform ??
459
+ data.fromPlatform ??
460
+ originalMessage.FromPlatform ??
461
+ "",
462
+ agentId: payload.agentId ?? data.agentId ?? originalMessage.agentId ?? "",
463
+ OpenCode:
464
+ payload.OpenCode ?? payload.openCode ?? data.openCode ?? originalMessage.OpenCode ?? "",
465
+ MsgId: preciseMsgId,
466
+ // 发送者数字ID(用于私聊引用回复的 uid 字段)
467
+ FromId: payload.FromId ?? payload.fromid ?? data.FromId ?? data.fromid ?? originalMessage.FromId,
207
468
  };
208
469
 
209
470
  // Dedup check
@@ -212,7 +473,9 @@ export class InfoflowWSReceiver {
212
473
  return;
213
474
  }
214
475
 
215
- logVerbose(`[infoflow:ws] private message: from=${msgData.FromUserId}, msgType=${msgData.MsgType}`);
476
+ logVerbose(
477
+ `[infoflow:ws] private message: from=${msgData.FromUserId}, msgType=${msgData.MsgType}`,
478
+ );
216
479
 
217
480
  await handlePrivateChatMessage({
218
481
  cfg: this.options.config,
@@ -222,5 +485,3 @@ export class InfoflowWSReceiver {
222
485
  });
223
486
  }
224
487
  }
225
-
226
-
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Parse LLM output text for local image paths embedded as Markdown images.
3
+ * Splits text into alternating text and image segments so the reply dispatcher
4
+ * can send text as text messages and local images as native image messages.
5
+ */
6
+
7
+ import { isLikelyLocalPath } from "../../channel/outbound.js";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Types
11
+ // ---------------------------------------------------------------------------
12
+
13
+ export type TextSegment = { kind: "text"; content: string };
14
+ export type ImageSegment = { kind: "image"; path: string };
15
+ export type MarkdownSegment = TextSegment | ImageSegment;
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Markdown image regex
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /**
22
+ * Matches Markdown image syntax: ![alt](path)
23
+ * Captures the path inside the parentheses.
24
+ */
25
+ const MD_IMAGE_RE = /!\[[^\]]*\]\(((?:[^()]+|\([^()]*\))+)\)/g;
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Public API
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Parse markdown text for local image references.
33
+ * Returns an ordered list of text and image segments.
34
+ *
35
+ * - Only local paths (absolute, relative, or ~) are extracted as image segments.
36
+ * - HTTP(S) URLs remain inline as part of text segments.
37
+ * - Empty text segments are omitted.
38
+ */
39
+ export function parseMarkdownForLocalImages(text: string): MarkdownSegment[] {
40
+ const segments: MarkdownSegment[] = [];
41
+ let lastIndex = 0;
42
+
43
+ for (const match of text.matchAll(MD_IMAGE_RE)) {
44
+ const fullMatch = match[0];
45
+ const path = match[1].trim();
46
+ const matchStart = match.index;
47
+
48
+ if (!isLikelyLocalPath(path)) {
49
+ // Not a local path — keep as part of the text
50
+ continue;
51
+ }
52
+
53
+ // Push preceding text (if any)
54
+ if (matchStart > lastIndex) {
55
+ const preceding = text.slice(lastIndex, matchStart);
56
+ if (preceding.trim()) {
57
+ segments.push({ kind: "text", content: preceding });
58
+ }
59
+ }
60
+
61
+ // Push the image segment
62
+ segments.push({ kind: "image", path });
63
+ lastIndex = matchStart + fullMatch.length;
64
+ }
65
+
66
+ // Push remaining text (if any)
67
+ if (lastIndex < text.length) {
68
+ const remaining = text.slice(lastIndex);
69
+ if (remaining.trim()) {
70
+ segments.push({ kind: "text", content: remaining });
71
+ }
72
+ }
73
+
74
+ // If no local images found, return a single text segment
75
+ if (segments.length === 0 && text.trim()) {
76
+ return [{ kind: "text", content: text }];
77
+ }
78
+
79
+ return segments;
80
+ }