@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.
- package/CHANGELOG.md +91 -0
- package/CLAUDE.md +135 -0
- package/COLLABORATION_REPORT.md +209 -0
- package/PROJECT_GUIDE.md +355 -0
- package/README.md +158 -66
- package/docs/dev-guide.md +63 -50
- package/docs/qa-feature-list.md +452 -0
- package/docs/webhook-guide.md +178 -0
- package/index.ts +28 -2
- package/openclaw.plugin.json +131 -21
- package/package.json +20 -3
- package/scripts/deploy.sh +66 -7
- package/scripts/postinstall.cjs +80 -0
- package/skills/infoflow-dev/SKILL.md +2 -2
- package/skills/infoflow-dev/references/api.md +1 -1
- package/src/adapter/inbound/webhook-parser.ts +27 -5
- package/src/adapter/inbound/ws-receiver.ts +304 -43
- package/src/adapter/outbound/markdown-local-images.ts +80 -0
- package/src/adapter/outbound/reply-dispatcher.ts +146 -65
- package/src/adapter/outbound/target-resolver.ts +4 -3
- package/src/channel/accounts.ts +97 -22
- package/src/channel/channel.ts +456 -12
- package/src/channel/media.ts +20 -6
- package/src/channel/monitor.ts +8 -3
- package/src/channel/outbound.ts +358 -21
- package/src/channel/streaming.ts +740 -0
- package/src/commands/changelog.ts +80 -0
- package/src/commands/doctor.ts +545 -0
- package/src/commands/logs.ts +449 -0
- package/src/commands/version.ts +20 -0
- package/src/compat/openclaw-sdk.ts +218 -0
- package/src/handler/message-handler.ts +673 -166
- package/src/logging.ts +1 -1
- package/src/runtime.ts +1 -1
- package/src/security/dm-policy.ts +1 -4
- package/src/security/group-policy.ts +174 -51
- package/src/tools/actions/index.ts +15 -13
- package/src/tools/cron/relay.ts +1154 -0
- package/src/tools/hooks/index.ts +13 -1
- package/src/tools/index.ts +714 -32
- package/src/types.ts +144 -25
- package/src/utils/audio/g722/dct_tables.ts +381 -0
- package/src/utils/audio/g722/decoder.ts +919 -0
- package/src/utils/audio/g722/defs.ts +105 -0
- package/src/utils/audio/g722/hd-parser.ts +247 -0
- package/src/utils/audio/g722/huff_tables.ts +240 -0
- package/src/utils/audio/g722/index.ts +78 -0
- package/src/utils/audio/g722/output_decoded.pcm +0 -0
- package/src/utils/audio/g722/output_decoded.wav +0 -0
- package/src/utils/audio/g722/tables.ts +173 -0
- package/src/utils/audio/g722/test_api.ts +31 -0
- package/src/utils/audio/g722/test_voice.hd +0 -0
- package/src/utils/bos/im-bos-client.ts +219 -0
- package/src/utils/group-agent-cache.ts +142 -0
- 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 (@
|
|
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
|
|
16
|
-
*
|
|
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 {
|
|
31
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
135
|
-
*
|
|
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 =
|
|
146
|
-
const
|
|
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:
|
|
149
|
-
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:
|
|
153
|
-
toid:
|
|
358
|
+
fromuserid: header.fromuserid ?? payload.fromUserId ?? data.fromUserId ?? "",
|
|
359
|
+
toid: payload.groupid ?? payload.groupId ?? data.groupId,
|
|
154
360
|
totype: "GROUP",
|
|
155
|
-
msgtype:
|
|
156
|
-
messageid:
|
|
157
|
-
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:
|
|
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 =
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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:
|
|
206
|
-
|
|
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(
|
|
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: 
|
|
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
|
+
}
|