@core-workspace/infoflow-openclaw-plugin 2026.3.9 → 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 +16 -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
|
@@ -1,21 +1,32 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync, readFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
1
4
|
import {
|
|
2
5
|
buildPendingHistoryContextFromMap,
|
|
3
6
|
clearHistoryEntriesIfEnabled,
|
|
4
7
|
DEFAULT_GROUP_HISTORY_LIMIT,
|
|
5
|
-
type HistoryEntry,
|
|
6
8
|
recordPendingHistoryEntryIfEnabled,
|
|
7
|
-
|
|
8
|
-
} from "openclaw/plugin-sdk";
|
|
9
|
+
} from "../compat/openclaw-sdk.js";
|
|
10
|
+
import type { HistoryEntry } from "openclaw/plugin-sdk/reply-history";
|
|
11
|
+
import { buildAgentMediaPayload } from "../compat/openclaw-sdk.js";
|
|
12
|
+
import { createInfoflowReplyDispatcher } from "../adapter/outbound/reply-dispatcher.js";
|
|
9
13
|
import { resolveInfoflowAccount } from "../channel/accounts.js";
|
|
14
|
+
import { sendInfoflowMessage, queryASRResult, fetchGroupMemberList } from "../channel/outbound.js";
|
|
15
|
+
import {
|
|
16
|
+
InfoflowStreamingCardSession,
|
|
17
|
+
normalizeStreamingFallbackFormat,
|
|
18
|
+
} from "../channel/streaming.js";
|
|
10
19
|
import { getInfoflowBotLog, formatInfoflowError, logVerbose } from "../logging.js";
|
|
11
|
-
import { createInfoflowReplyDispatcher } from "../adapter/outbound/reply-dispatcher.js";
|
|
12
|
-
import { sendInfoflowMessage } from "../channel/outbound.js";
|
|
13
20
|
import { getInfoflowRuntime } from "../runtime.js";
|
|
21
|
+
import { checkDmPolicy, checkGroupPolicy } from "../security/dm-policy.js";
|
|
14
22
|
import {
|
|
15
23
|
checkBotMentioned,
|
|
16
24
|
checkWatchMentioned,
|
|
17
25
|
checkWatchRegex,
|
|
18
26
|
extractMentionIds,
|
|
27
|
+
getBotRobotidFromBody,
|
|
28
|
+
checkReplyToBot,
|
|
29
|
+
hasOtherMentions,
|
|
19
30
|
recordGroupReply,
|
|
20
31
|
isWithinFollowUpWindow,
|
|
21
32
|
resolveGroupConfig,
|
|
@@ -23,23 +34,56 @@ import {
|
|
|
23
34
|
buildWatchRegexPrompt,
|
|
24
35
|
buildFollowUpPrompt,
|
|
25
36
|
buildProactivePrompt,
|
|
26
|
-
type InfoflowBodyItem,
|
|
27
37
|
} from "../security/group-policy.js";
|
|
28
|
-
import { checkDmPolicy, checkGroupPolicy } from "../security/dm-policy.js";
|
|
29
38
|
import type {
|
|
30
39
|
InfoflowChatType,
|
|
40
|
+
InfoflowInboundBodyItem,
|
|
31
41
|
InfoflowMessageEvent,
|
|
32
42
|
InfoflowMentionIds,
|
|
33
43
|
InfoflowReplyMode,
|
|
44
|
+
InfoflowGroupSessionMode,
|
|
34
45
|
InfoflowGroupConfig,
|
|
46
|
+
InfoflowSender,
|
|
35
47
|
HandleInfoflowMessageParams,
|
|
36
48
|
HandlePrivateChatParams,
|
|
37
49
|
HandleGroupChatParams,
|
|
38
|
-
ResolvedInfoflowAccount,
|
|
39
50
|
} from "../types.js";
|
|
51
|
+
import { descSender, type InfoflowMessageFormat } from "../types.js";
|
|
52
|
+
import { decodeHdToWav } from "../utils/audio/g722/index.js";
|
|
53
|
+
import { isCacheValid, updateGroupAgentCache } from "../utils/group-agent-cache.js";
|
|
40
54
|
|
|
41
55
|
// Re-export types for external consumers
|
|
42
|
-
export type { InfoflowChatType, InfoflowMessageEvent } from "../types.js";
|
|
56
|
+
export type { InfoflowChatType, InfoflowMessageEvent, InfoflowSender } from "../types.js";
|
|
57
|
+
|
|
58
|
+
function buildInfoflowCronTargetPrompt(params: { chatType: InfoflowChatType; to: string }): string {
|
|
59
|
+
const target = `infoflow:${params.to}`;
|
|
60
|
+
if (params.chatType === "group") {
|
|
61
|
+
return [
|
|
62
|
+
`当前会话来自如流群,当前群发送目标固定为 ${target}。`,
|
|
63
|
+
"如果你要创建定时消息、cron、system-event,必须优先使用 infoflow_cron 工具。",
|
|
64
|
+
`infoflow_cron 会自动绑定当前群 ${target},并在有 FromUserId 时附带创建人信息。`,
|
|
65
|
+
`只有在你明确无法使用 infoflow_cron 时,才允许显式写 target=${target}。`,
|
|
66
|
+
].join("");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return [
|
|
70
|
+
`当前会话来自如流单聊,发送目标为 ${target}。`,
|
|
71
|
+
"如果你要创建定时消息、cron、system-event,必须优先使用 infoflow_cron 工具。",
|
|
72
|
+
`infoflow_cron 会直接使用当前会话可信的 FromUserId,并绑定为 ${target}。`,
|
|
73
|
+
].join("");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function appendInfoflowSystemPrompt(
|
|
77
|
+
existing: string | undefined,
|
|
78
|
+
extra: string | undefined,
|
|
79
|
+
): string | undefined {
|
|
80
|
+
const next = extra?.trim();
|
|
81
|
+
if (!next) {
|
|
82
|
+
return existing;
|
|
83
|
+
}
|
|
84
|
+
const prev = existing?.trim();
|
|
85
|
+
return prev ? `${prev}\n\n---\n\n${next}` : next;
|
|
86
|
+
}
|
|
43
87
|
|
|
44
88
|
// ---------------------------------------------------------------------------
|
|
45
89
|
// Group reply tracking (in-memory) for follow-up window
|
|
@@ -55,52 +99,86 @@ const chatHistories = new Map<string, HistoryEntry[]>();
|
|
|
55
99
|
export async function handlePrivateChatMessage(params: HandlePrivateChatParams): Promise<void> {
|
|
56
100
|
const { cfg, msgData, accountId, statusSink } = params;
|
|
57
101
|
|
|
58
|
-
|
|
59
|
-
const fromuser = String(msgData.FromUserId ?? msgData.fromuserid ?? msgData.from ?? "");
|
|
60
|
-
const mes = String(msgData.Content ?? msgData.content ?? msgData.text ?? msgData.mes ?? "");
|
|
102
|
+
logVerbose(`[infoflow:dm] raw private message:\n${JSON.stringify(msgData, null, 2)}`);
|
|
61
103
|
|
|
62
|
-
//
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
// 1. Extract fields from msgData
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
const fromuser = String(msgData.FromUserId ?? msgData.fromuserid ?? msgData.from ?? "");
|
|
108
|
+
let mes = String(msgData.Content ?? msgData.content ?? msgData.text ?? msgData.mes ?? "");
|
|
63
109
|
const senderName = String(msgData.FromUserName ?? msgData.username ?? fromuser);
|
|
110
|
+
const rawImid = msgData.FromId ?? msgData.fromid ?? msgData.imid ?? msgData.fromimid;
|
|
111
|
+
const imid = rawImid != null ? String(rawImid) : undefined;
|
|
112
|
+
// 构建私聊发送者:目前仅支持普通账户
|
|
113
|
+
const buildSender = (): InfoflowSender | undefined => {
|
|
114
|
+
if (!fromuser) return undefined;
|
|
115
|
+
return {
|
|
116
|
+
kind: "regular",
|
|
117
|
+
userid: fromuser,
|
|
118
|
+
name: senderName || undefined,
|
|
119
|
+
imid: imid,
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const sender = buildSender();
|
|
124
|
+
if (!sender) {
|
|
125
|
+
getInfoflowBotLog().warn(`[inbound:dm] dropped: fromuser is empty`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
64
128
|
|
|
65
129
|
// Extract message ID for dedup tracking
|
|
66
130
|
const messageId = msgData.MsgId ?? msgData.msgid ?? msgData.messageid;
|
|
67
131
|
const messageIdStr = messageId != null ? String(messageId) : undefined;
|
|
68
132
|
|
|
133
|
+
// Extract secondary message ID for private reply (msgid2)
|
|
134
|
+
const rawMsgid2 = msgData.MsgId2 ?? msgData.msgid2;
|
|
135
|
+
const msgid2Str = rawMsgid2 != null ? String(rawMsgid2) : undefined;
|
|
136
|
+
|
|
69
137
|
// Extract timestamp (CreateTime is in seconds, convert to milliseconds)
|
|
70
138
|
const createTime = msgData.CreateTime ?? msgData.createtime;
|
|
71
139
|
const timestamp = createTime != null ? Number(createTime) * 1000 : Date.now();
|
|
72
140
|
|
|
73
|
-
// Detect image messages: MsgType=image with PicUrl
|
|
74
141
|
const msgType = String(msgData.MsgType ?? msgData.msgtype ?? "");
|
|
75
142
|
const picUrl = String(msgData.PicUrl ?? msgData.picurl ?? "");
|
|
143
|
+
const voiceUrl = String(msgData.VoiceUrl ?? msgData.voiceUrl ?? "");
|
|
144
|
+
const fromPlatform = String(msgData.FromPlatform ?? msgData.fromPlatform ?? "");
|
|
145
|
+
const agentId = String(msgData.agentId ?? "");
|
|
146
|
+
const openCode = String(msgData.OpenCode ?? msgData.openCode ?? "");
|
|
147
|
+
|
|
148
|
+
// Image handling
|
|
76
149
|
const imageUrls: string[] = [];
|
|
77
150
|
if (msgType === "image" && picUrl.trim()) {
|
|
78
151
|
imageUrls.push(picUrl.trim());
|
|
79
152
|
}
|
|
80
153
|
|
|
81
|
-
|
|
82
|
-
`[
|
|
154
|
+
getInfoflowBotLog().info(
|
|
155
|
+
`[inbound:dm] from=${fromuser}, name=${senderName}, msgType=${msgType}, msgId=${messageIdStr ?? "?"}, text=${mes.slice(0, 80)}${mes.length > 80 ? "..." : ""}, images=${imageUrls.length}${msgType === "voice" ? `, voice=true` : ""}`,
|
|
83
156
|
);
|
|
84
157
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if (!fromuser || (!mes.trim() && imageUrls.length === 0)) {
|
|
158
|
+
if (!fromuser || (!mes.trim() && imageUrls.length === 0 && msgType !== "voice")) {
|
|
159
|
+
getInfoflowBotLog().warn(
|
|
160
|
+
`[inbound:dm] dropped: fromuser=${fromuser || "(empty)"}, mes_empty=${!mes.trim()}, images=${imageUrls.length}`,
|
|
161
|
+
);
|
|
90
162
|
return;
|
|
91
163
|
}
|
|
92
164
|
|
|
93
|
-
//
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// 3. dmPolicy check (before any network calls)
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
94
168
|
const account = resolveInfoflowAccount({ cfg, accountId });
|
|
95
169
|
const dmResult = checkDmPolicy(account, fromuser);
|
|
96
170
|
if (!dmResult.allowed) {
|
|
97
|
-
|
|
171
|
+
getInfoflowBotLog().warn(
|
|
172
|
+
`[inbound:dm] rejected: dmPolicy=${account.config.dmPolicy ?? "allowlist"}, fromuser=${fromuser}`,
|
|
173
|
+
);
|
|
98
174
|
sendInfoflowMessage({
|
|
99
175
|
cfg,
|
|
100
176
|
to: fromuser,
|
|
101
177
|
contents: [{ type: "text", content: "🚫 抱歉,您暂无使用权限,请联系龙虾主开通~" }],
|
|
102
178
|
accountId: account.accountId,
|
|
103
|
-
}).catch(() => {
|
|
179
|
+
}).catch(() => {
|
|
180
|
+
/* ignore send errors */
|
|
181
|
+
});
|
|
104
182
|
return;
|
|
105
183
|
}
|
|
106
184
|
if ("note" in dmResult && dmResult.note === "pairing") {
|
|
@@ -108,7 +186,89 @@ export async function handlePrivateChatMessage(params: HandlePrivateChatParams):
|
|
|
108
186
|
logVerbose(`[infoflow] private message: dmPolicy=pairing, fromuser=${fromuser}`);
|
|
109
187
|
}
|
|
110
188
|
|
|
111
|
-
//
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// 4. Voice handling: Content empty → .mp3 download or .hd ASR
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
const isVoiceMp3 = msgType === "voice" && voiceUrl.includes(".mp3");
|
|
193
|
+
let localWavPath: string | undefined;
|
|
194
|
+
if (msgType === "voice" && !mes.trim() && voiceUrl) {
|
|
195
|
+
if (isVoiceMp3) {
|
|
196
|
+
// mp3 语音文件:由 handleInfoflowMessage 下载到本地,路径传给 openclaw
|
|
197
|
+
mes = "[语音消息]";
|
|
198
|
+
} else {
|
|
199
|
+
// .hd 等格式:通过 VoiceUrl MD5 调用 ASR 获取识别文本
|
|
200
|
+
const md5 = extractMd5FromVoiceUrl(voiceUrl);
|
|
201
|
+
if (md5) {
|
|
202
|
+
const asrResult = await queryASRResult({ account, md5 });
|
|
203
|
+
if (asrResult.ok && asrResult.content) {
|
|
204
|
+
mes = asrResult.content;
|
|
205
|
+
getInfoflowBotLog().info(
|
|
206
|
+
`[inbound:dm] voice ASR success: md5=${md5}, text=${mes.slice(0, 80)}`,
|
|
207
|
+
);
|
|
208
|
+
} else {
|
|
209
|
+
getInfoflowBotLog().info(
|
|
210
|
+
`[inbound:dm] voice ASR failed: md5=${md5}, error=${asrResult.error ?? "empty"}`,
|
|
211
|
+
);
|
|
212
|
+
if (asrResult.error?.includes("plat.clientError, 非法的企业机器人")) {
|
|
213
|
+
sendInfoflowMessage({
|
|
214
|
+
cfg,
|
|
215
|
+
to: fromuser,
|
|
216
|
+
contents: [
|
|
217
|
+
{
|
|
218
|
+
type: "text",
|
|
219
|
+
content:
|
|
220
|
+
"当前机器人暂不支持语音转文本,为了更好的体验,可联系管理员在企业管理后台,应用中心-设置-开放API开启[单聊语音消息转文本]权限",
|
|
221
|
+
},
|
|
222
|
+
],
|
|
223
|
+
accountId: account.accountId,
|
|
224
|
+
}).catch(() => {});
|
|
225
|
+
}
|
|
226
|
+
// ASR 失败回退:下载 .hd → decodeHdToWav → .wav 交给 openclaw 处理
|
|
227
|
+
try {
|
|
228
|
+
const voiceTmpDir = join(homedir(), ".openclaw", "tmp", "voice");
|
|
229
|
+
mkdirSync(voiceTmpDir, { recursive: true });
|
|
230
|
+
const hdPath = join(voiceTmpDir, `${md5}.hd`);
|
|
231
|
+
const wavPath = join(voiceTmpDir, `${md5}.wav`);
|
|
232
|
+
const hdRes = await fetch(voiceUrl);
|
|
233
|
+
if (!hdRes.ok) throw new Error(`download hd failed: status=${hdRes.status}`);
|
|
234
|
+
writeFileSync(hdPath, Buffer.from(await hdRes.arrayBuffer()));
|
|
235
|
+
const decodeResult = decodeHdToWav(hdPath, wavPath);
|
|
236
|
+
try {
|
|
237
|
+
unlinkSync(hdPath);
|
|
238
|
+
} catch {
|
|
239
|
+
/* ignore */
|
|
240
|
+
}
|
|
241
|
+
if (decodeResult) {
|
|
242
|
+
mes = "[语音消息]";
|
|
243
|
+
localWavPath = wavPath;
|
|
244
|
+
getInfoflowBotLog().info(
|
|
245
|
+
`[inbound:dm] voice hd→wav ok: md5=${md5}, duration=${decodeResult.duration.toFixed(1)}s, path=${wavPath}`,
|
|
246
|
+
);
|
|
247
|
+
} else {
|
|
248
|
+
getInfoflowBotLog().info(`[inbound:dm] voice hd→wav decode failed: md5=${md5}`);
|
|
249
|
+
}
|
|
250
|
+
} catch (err) {
|
|
251
|
+
getInfoflowBotLog().info(
|
|
252
|
+
`[inbound:dm] voice hd download/decode error: md5=${md5}, ${formatInfoflowError(err)}`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
} else {
|
|
257
|
+
getInfoflowBotLog().info(
|
|
258
|
+
`[inbound:dm] voice: cannot extract MD5 from VoiceUrl=${voiceUrl.slice(0, 80)}`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// After ASR, if still no content (and not mp3 voice), drop the message
|
|
265
|
+
if (!mes.trim() && imageUrls.length === 0) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// 5. Build effective message and dispatch
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
112
272
|
let effectiveMes = mes.trim();
|
|
113
273
|
if (!effectiveMes && imageUrls.length > 0) {
|
|
114
274
|
effectiveMes = "<media:image>";
|
|
@@ -118,13 +278,18 @@ export async function handlePrivateChatMessage(params: HandlePrivateChatParams):
|
|
|
118
278
|
await handleInfoflowMessage({
|
|
119
279
|
cfg,
|
|
120
280
|
event: {
|
|
121
|
-
|
|
281
|
+
sender,
|
|
122
282
|
mes: effectiveMes,
|
|
123
283
|
chatType: "direct",
|
|
124
|
-
senderName,
|
|
125
284
|
messageId: messageIdStr,
|
|
285
|
+
msgid2: msgid2Str,
|
|
126
286
|
timestamp,
|
|
127
287
|
imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
|
|
288
|
+
voiceUrl: msgType === "voice" && voiceUrl ? voiceUrl : undefined,
|
|
289
|
+
localVoicePath: localWavPath,
|
|
290
|
+
fromPlatform: fromPlatform || undefined,
|
|
291
|
+
agentId: agentId || undefined,
|
|
292
|
+
openCode: openCode || undefined,
|
|
128
293
|
},
|
|
129
294
|
accountId,
|
|
130
295
|
statusSink,
|
|
@@ -142,12 +307,17 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
|
|
|
142
307
|
const header = (msgData.message as Record<string, unknown>)?.header as
|
|
143
308
|
| Record<string, unknown>
|
|
144
309
|
| undefined;
|
|
145
|
-
const
|
|
310
|
+
const rawFromuser = String(header?.fromuserid ?? msgData.fromuserid ?? msgData.from ?? "");
|
|
146
311
|
|
|
147
312
|
// Extract sender's imid (数字ID) - the numeric user ID is in msgData.fromid
|
|
148
|
-
const senderImid =
|
|
313
|
+
const senderImid =
|
|
314
|
+
msgData.fromid ?? header?.imid ?? header?.fromimid ?? msgData.imid ?? msgData.fromimid;
|
|
149
315
|
const senderImidStr = senderImid != null ? String(senderImid) : undefined;
|
|
150
316
|
|
|
317
|
+
// 机器人发送者在 ALL_MESSAGE_FORWARD 中通常没有 fromuserid,
|
|
318
|
+
// 用 senderImidStr(数字 robot ID)作为兜底,加 "bot:" 前缀避免与人类用户 ID 碰撞
|
|
319
|
+
const senderIsBot = !rawFromuser && !!senderImidStr;
|
|
320
|
+
|
|
151
321
|
// Extract message ID (priority: header.messageid > header.msgid > MsgId)
|
|
152
322
|
const messageId = header?.messageid ?? header?.msgid ?? msgData.MsgId;
|
|
153
323
|
const messageIdStr = messageId != null ? String(messageId) : undefined;
|
|
@@ -161,42 +331,63 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
|
|
|
161
331
|
const timestamp = rawTime != null ? Number(rawTime) : Date.now();
|
|
162
332
|
|
|
163
333
|
// Debug: 打印完整的原始消息数据
|
|
164
|
-
logVerbose(`[
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (!
|
|
334
|
+
logVerbose(`[inbound:group:raw] msgData: ${JSON.stringify(msgData, null, 2)}`);
|
|
335
|
+
|
|
336
|
+
// 构建群聊发送者(含 name):机器人 agentid 暂无处取值(不填),imid 保持不变;普通用户用 rawFromuser 作为 userid
|
|
337
|
+
const buildSender = (): InfoflowSender | undefined => {
|
|
338
|
+
const name = String(
|
|
339
|
+
header?.username ?? header?.nickname ?? msgData.username ?? (rawFromuser || senderImidStr),
|
|
340
|
+
);
|
|
341
|
+
if (senderIsBot) {
|
|
342
|
+
return senderImidStr ? { kind: "robot", imid: senderImidStr, name } : undefined;
|
|
343
|
+
}
|
|
344
|
+
return rawFromuser
|
|
345
|
+
? { kind: "regular", userid: rawFromuser, imid: senderImidStr, name }
|
|
346
|
+
: undefined;
|
|
347
|
+
};
|
|
348
|
+
|
|
349
|
+
const sender = buildSender();
|
|
350
|
+
if (!sender) {
|
|
351
|
+
getInfoflowBotLog().warn(
|
|
352
|
+
`[inbound:group] dropped: fromuser and senderImidStr both empty, groupId=${groupid}`,
|
|
353
|
+
);
|
|
181
354
|
return;
|
|
182
355
|
}
|
|
183
356
|
|
|
184
357
|
// Extract message content from body array or flat content field
|
|
185
358
|
const message = msgData.message as Record<string, unknown> | undefined;
|
|
186
|
-
const bodyItems = (message?.body ?? msgData.body ?? []) as
|
|
359
|
+
const bodyItems = (message?.body ?? msgData.body ?? []) as InfoflowInboundBodyItem[];
|
|
187
360
|
|
|
188
361
|
// Resolve account to get robotName for mention detection
|
|
189
362
|
const account = resolveInfoflowAccount({ cfg, accountId });
|
|
190
363
|
const robotName = account.config.robotName;
|
|
191
364
|
|
|
192
365
|
// Check groupPolicy allowlist
|
|
193
|
-
|
|
366
|
+
// wasMentioned 判断优先级:
|
|
367
|
+
// 1. ws-receiver 已注入 msgData.wasMentioned 时优先使用
|
|
368
|
+
// 2. webhook 模式下:
|
|
369
|
+
// - MESSAGE_RECEIVE: 明确 @机器人 事件
|
|
370
|
+
// - ALL_MESSAGE_FORWARD: 全量消息,需要回退到 body AT 检测
|
|
371
|
+
// 3. 兜底:body 里 AT 元素(robotid 或 robotName)判定
|
|
372
|
+
const rawEventType = String(msgData.eventtype ?? "");
|
|
373
|
+
const wasMentionedEarly =
|
|
374
|
+
msgData.wasMentioned === true
|
|
375
|
+
? true
|
|
376
|
+
: rawEventType === "ALL_MESSAGE_FORWARD"
|
|
377
|
+
? checkBotMentioned(bodyItems, account.config)
|
|
378
|
+
: rawEventType === "MESSAGE_RECEIVE"
|
|
379
|
+
? true // 明确的 @机器人 事件
|
|
380
|
+
: checkBotMentioned(bodyItems, account.config);
|
|
194
381
|
const groupPolicyResult = checkGroupPolicy(account, groupid, wasMentionedEarly);
|
|
195
382
|
if (!groupPolicyResult.allowed) {
|
|
196
383
|
if (groupPolicyResult.reason === "disabled") {
|
|
197
|
-
|
|
384
|
+
getInfoflowBotLog().warn(
|
|
385
|
+
`[inbound:group] rejected: groupPolicy=disabled, groupId=${groupid}`,
|
|
386
|
+
);
|
|
198
387
|
} else {
|
|
199
|
-
|
|
388
|
+
getInfoflowBotLog().warn(
|
|
389
|
+
`[inbound:group] rejected: groupPolicy=allowlist, group=${groupPolicyResult.groupIdStr} not in groupAllowFrom`,
|
|
390
|
+
);
|
|
200
391
|
// 发送无权限提示,仅当消息是 @机器人 时才回复,避免在无关群里刷屏
|
|
201
392
|
if (groupPolicyResult.wasMentioned && groupPolicyResult.groupIdStr) {
|
|
202
393
|
sendInfoflowMessage({
|
|
@@ -204,7 +395,9 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
|
|
|
204
395
|
to: `group:${groupPolicyResult.groupIdStr}`,
|
|
205
396
|
contents: [{ type: "text", content: "🚫 抱歉,该群暂无使用权限,请联系龙虾主开通~" }],
|
|
206
397
|
accountId: account.accountId,
|
|
207
|
-
}).catch(() => {
|
|
398
|
+
}).catch(() => {
|
|
399
|
+
/* ignore send errors */
|
|
400
|
+
});
|
|
208
401
|
}
|
|
209
402
|
}
|
|
210
403
|
return;
|
|
@@ -213,12 +406,67 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
|
|
|
213
406
|
// Check if bot was @mentioned (by robotName)
|
|
214
407
|
const wasMentioned = wasMentionedEarly;
|
|
215
408
|
|
|
409
|
+
// --- robotId auto-discovery + self-message ignore ---
|
|
410
|
+
let effectiveRobotId = account.config.robotId;
|
|
411
|
+
if (wasMentioned) {
|
|
412
|
+
const discoveredRobotId = getBotRobotidFromBody(bodyItems, robotName, effectiveRobotId);
|
|
413
|
+
if (discoveredRobotId && discoveredRobotId !== effectiveRobotId) {
|
|
414
|
+
effectiveRobotId = discoveredRobotId;
|
|
415
|
+
// Persist discovered robotId to config (fire-and-forget)
|
|
416
|
+
try {
|
|
417
|
+
const runtime = getInfoflowRuntime();
|
|
418
|
+
const currentCfg = runtime.config.loadConfig();
|
|
419
|
+
const section = (currentCfg.channels?.["infoflow"] ?? {}) as Record<string, unknown>;
|
|
420
|
+
const accounts = (section.accounts ?? {}) as Record<string, Record<string, unknown>>;
|
|
421
|
+
// When `accounts` block exists (multi-account mode), only write to the matching account entry;
|
|
422
|
+
// when no `accounts` block exists (single-account / legacy mode), write to the channel root.
|
|
423
|
+
const acctCfg: Record<string, unknown> | undefined =
|
|
424
|
+
accounts[accountId] ?? (!section.accounts ? section : undefined);
|
|
425
|
+
if (!acctCfg) {
|
|
426
|
+
logVerbose(
|
|
427
|
+
`[infoflow:bot] cannot persist robotId: accountId=${accountId} not found in config accounts`,
|
|
428
|
+
);
|
|
429
|
+
} else {
|
|
430
|
+
acctCfg.robotId = discoveredRobotId;
|
|
431
|
+
runtime.config.writeConfigFile(currentCfg);
|
|
432
|
+
logVerbose(
|
|
433
|
+
`[infoflow:bot] robotId auto-discovered: ${discoveredRobotId} for account=${accountId}`,
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
} catch (err) {
|
|
437
|
+
logVerbose(`[infoflow:bot] failed to persist robotId: ${formatInfoflowError(err)}`);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// Ignore own messages: if fromid matches the bot's robotId
|
|
442
|
+
const fromid = msgData.fromid ?? header?.fromid;
|
|
443
|
+
if (effectiveRobotId && fromid != null && String(fromid) === effectiveRobotId) {
|
|
444
|
+
logVerbose(
|
|
445
|
+
`[infoflow:bot] ignoring own message: fromid=${fromid}, robotId=${effectiveRobotId}`,
|
|
446
|
+
);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
216
450
|
// Extract non-bot mention IDs (userIds + agentIds) for LLM-driven @mentions
|
|
217
|
-
const mentionIds = extractMentionIds(bodyItems, robotName);
|
|
451
|
+
const mentionIds = extractMentionIds(bodyItems, robotName, effectiveRobotId);
|
|
452
|
+
|
|
453
|
+
// Preload group agent name→agentId cache (skip if cache is still valid)
|
|
454
|
+
if (groupid !== undefined && !isCacheValid(groupid)) {
|
|
455
|
+
try {
|
|
456
|
+
const memberResult = await fetchGroupMemberList({ account, groupId: groupid });
|
|
457
|
+
if (memberResult.ok && memberResult.agents) {
|
|
458
|
+
updateGroupAgentCache(groupid, memberResult.agents);
|
|
459
|
+
}
|
|
460
|
+
} catch (err) {
|
|
461
|
+
logVerbose(`[inbound:group] group agent cache preload failed: ${formatInfoflowError(err)}`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
218
464
|
|
|
219
|
-
// Build
|
|
465
|
+
// Build three versions: mes (CommandBody, no @xxx), rawMes (RawBody, with @xxx),
|
|
466
|
+
// agentVisibleText (for LLM, with @name (robotid:N))
|
|
220
467
|
let textContent = "";
|
|
221
468
|
let rawTextContent = "";
|
|
469
|
+
let agentVisibleText = "";
|
|
222
470
|
const replyContextItems: string[] = [];
|
|
223
471
|
const imageUrls: string[] = [];
|
|
224
472
|
if (Array.isArray(bodyItems)) {
|
|
@@ -232,43 +480,57 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
|
|
|
232
480
|
} else if (item.type === "TEXT") {
|
|
233
481
|
textContent += item.content ?? "";
|
|
234
482
|
rawTextContent += item.content ?? "";
|
|
483
|
+
agentVisibleText += item.content ?? "";
|
|
235
484
|
} else if (item.type === "LINK") {
|
|
236
485
|
const label = item.label ?? "";
|
|
237
486
|
if (label) {
|
|
238
487
|
textContent += ` ${label} `;
|
|
239
488
|
rawTextContent += ` ${label} `;
|
|
489
|
+
agentVisibleText += ` ${label} `;
|
|
240
490
|
}
|
|
241
491
|
} else if (item.type === "AT") {
|
|
242
|
-
// AT elements only go into rawTextContent, not textContent
|
|
243
492
|
const name = item.name ?? "";
|
|
244
493
|
if (name) {
|
|
245
|
-
|
|
494
|
+
const atLabel =
|
|
495
|
+
item.robotid != null
|
|
496
|
+
? `@${name}(robotid:${item.robotid}) `
|
|
497
|
+
: `@${name}(userid:${item.userid}) `;
|
|
498
|
+
textContent += atLabel;
|
|
499
|
+
rawTextContent += atLabel;
|
|
500
|
+
agentVisibleText += atLabel;
|
|
246
501
|
}
|
|
247
502
|
} else if (item.type === "IMAGE") {
|
|
248
503
|
// 提取图片下载地址
|
|
249
|
-
logVerbose(`[DEBUG bot.groupchat] IMAGE item: ${JSON.stringify(item, null, 2)}`);
|
|
250
504
|
const url = item.downloadurl;
|
|
251
505
|
if (typeof url === "string" && url.trim()) {
|
|
252
|
-
logVerbose(`[DEBUG bot.groupchat] 提取到图片URL: ${url}`);
|
|
253
506
|
imageUrls.push(url.trim());
|
|
254
|
-
} else {
|
|
255
|
-
logVerbose(`[DEBUG bot.groupchat] WARNING: IMAGE item 缺少有效的 downloadurl 字段`);
|
|
256
507
|
}
|
|
508
|
+
} else if (item.type === "MD") {
|
|
509
|
+
// Markdown 消息(机器人等场景),提取文本内容
|
|
510
|
+
textContent += item.content ?? "";
|
|
511
|
+
rawTextContent += item.content ?? "";
|
|
512
|
+
agentVisibleText += item.content ?? "";
|
|
257
513
|
}
|
|
258
514
|
}
|
|
259
515
|
}
|
|
260
516
|
|
|
261
517
|
let mes = textContent.trim() || String(msgData.content ?? msgData.text ?? "");
|
|
262
518
|
const rawMes = rawTextContent.trim() || mes;
|
|
519
|
+
const bodyForAgent = agentVisibleText.trim() || rawMes || mes;
|
|
263
520
|
|
|
264
521
|
const replyContext = replyContextItems.length > 0 ? replyContextItems : undefined;
|
|
265
522
|
|
|
523
|
+
// Check if this message is a quoted reply to one of the bot's own previously sent messages
|
|
524
|
+
const isReplyToBot = replyContext ? checkReplyToBot(bodyItems, accountId) : false;
|
|
525
|
+
|
|
266
526
|
if (!mes && !replyContext && imageUrls.length === 0) {
|
|
527
|
+
getInfoflowBotLog().warn(
|
|
528
|
+
`[inbound:group] dropped: empty body, sender=${descSender(sender)}, groupId=${groupid}`,
|
|
529
|
+
);
|
|
267
530
|
return;
|
|
268
531
|
}
|
|
269
532
|
// 纯图片消息:设置占位符
|
|
270
533
|
if (!mes && imageUrls.length > 0) {
|
|
271
|
-
logVerbose(`[DEBUG bot.groupchat] 纯图片消息: ${imageUrls.length} 张图片`);
|
|
272
534
|
mes = `<media:image>${imageUrls.length > 1 ? ` (${imageUrls.length} images)` : ""}`;
|
|
273
535
|
}
|
|
274
536
|
// If mes is empty but replyContext exists, use a placeholder so the message is not dropped
|
|
@@ -276,19 +538,19 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
|
|
|
276
538
|
mes = "(引用回复)";
|
|
277
539
|
}
|
|
278
540
|
|
|
279
|
-
|
|
280
|
-
|
|
541
|
+
logVerbose(
|
|
542
|
+
`[inbound:group] ${descSender(sender)}, group=${groupid}, msgId=${messageIdStr ?? "?"}, mentioned=${wasMentioned}, text=${mes.slice(0, 80)}${mes.length > 80 ? "..." : ""}, images=${imageUrls.length}, reply=${replyContextItems.length > 0}`,
|
|
543
|
+
);
|
|
281
544
|
|
|
282
545
|
// Delegate to the common message handler (group chat)
|
|
283
546
|
await handleInfoflowMessage({
|
|
284
547
|
cfg,
|
|
285
548
|
event: {
|
|
286
|
-
|
|
549
|
+
sender,
|
|
287
550
|
mes,
|
|
288
551
|
rawMes,
|
|
289
552
|
chatType: "group",
|
|
290
553
|
groupId: groupid,
|
|
291
|
-
senderName,
|
|
292
554
|
wasMentioned,
|
|
293
555
|
messageId: messageIdStr,
|
|
294
556
|
timestamp,
|
|
@@ -297,7 +559,8 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
|
|
|
297
559
|
mentionIds.userIds.length > 0 || mentionIds.agentIds.length > 0 ? mentionIds : undefined,
|
|
298
560
|
replyContext,
|
|
299
561
|
imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
|
|
300
|
-
|
|
562
|
+
bodyForAgent,
|
|
563
|
+
isReplyToBot: isReplyToBot || undefined,
|
|
301
564
|
},
|
|
302
565
|
accountId,
|
|
303
566
|
statusSink,
|
|
@@ -310,23 +573,43 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
|
|
|
310
573
|
*/
|
|
311
574
|
export async function handleInfoflowMessage(params: HandleInfoflowMessageParams): Promise<void> {
|
|
312
575
|
const { cfg, event, accountId, statusSink } = params;
|
|
313
|
-
const {
|
|
576
|
+
const { sender, mes, chatType, groupId } = event;
|
|
577
|
+
const senderDesc = descSender(sender);
|
|
578
|
+
const senderImid = sender.imid;
|
|
579
|
+
// 带类型前缀的发送者标识,用于历史记录条目
|
|
580
|
+
const senderTag = sender.kind === "regular" ? `reg:${sender.userid}` : `bot:${sender.imid}`;
|
|
581
|
+
const bodyForAgent = event.bodyForAgent ?? mes;
|
|
314
582
|
|
|
315
583
|
const account = resolveInfoflowAccount({ cfg, accountId });
|
|
316
584
|
const core = getInfoflowRuntime();
|
|
317
585
|
|
|
318
586
|
const isGroup = chatType === "group";
|
|
319
|
-
// Convert groupId (number) to string for peerId since routing expects string
|
|
320
|
-
const peerId = isGroup ? (groupId !== undefined ? String(groupId) : fromuser) : fromuser;
|
|
321
587
|
|
|
322
|
-
// Resolve per-group config for replyMode gating
|
|
588
|
+
// Resolve per-group config for replyMode gating (needed for groupSessionMode)
|
|
323
589
|
const groupCfg = isGroup ? resolveGroupConfig(account, groupId) : undefined;
|
|
324
590
|
|
|
325
591
|
// "ignore" mode: discard immediately, no save, no think, no reply
|
|
326
592
|
if (isGroup && groupCfg?.replyMode === "ignore") {
|
|
593
|
+
logVerbose(`[inbound:group] dropped: replyMode=ignore, ${senderDesc}, groupId=${groupId}`);
|
|
327
594
|
return;
|
|
328
595
|
}
|
|
329
596
|
|
|
597
|
+
// Determine group session mode for peerId generation (group config overrides account config)
|
|
598
|
+
const groupSessionMode = isGroup ? groupCfg!.groupSessionMode : "group";
|
|
599
|
+
// 会话路由 peerId:
|
|
600
|
+
// 群聊 user 模式:groupId:{groupId};{userid || imid},按用户分会话
|
|
601
|
+
// 群聊 group 模式:groupId
|
|
602
|
+
// 群聊无 groupId(兜底):userid || imid
|
|
603
|
+
// 私聊:userid || imid
|
|
604
|
+
const senderId = sender.kind === "regular" ? sender.userid : (sender.imid ?? "");
|
|
605
|
+
const peerId = isGroup
|
|
606
|
+
? groupId !== undefined
|
|
607
|
+
? groupSessionMode === "user"
|
|
608
|
+
? `groupId:${groupId};${senderId}`
|
|
609
|
+
: String(groupId)
|
|
610
|
+
: senderId
|
|
611
|
+
: senderId;
|
|
612
|
+
|
|
330
613
|
// Resolve route based on chat type
|
|
331
614
|
const route = core.channel.routing.resolveAgentRoute({
|
|
332
615
|
cfg,
|
|
@@ -347,10 +630,20 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
|
|
|
347
630
|
sessionKey: route.sessionKey,
|
|
348
631
|
});
|
|
349
632
|
|
|
350
|
-
//
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
633
|
+
// 构建会话标签:
|
|
634
|
+
// 私聊 regular:优先 userid,fallback name,兜底"未知"
|
|
635
|
+
// 私聊 robot:优先 name,fallback imid,兜底"未知"
|
|
636
|
+
// 群聊 regular:group:{groupId}:{userid || imid}
|
|
637
|
+
// 群聊 robot:group:{groupId}:{name || imid}
|
|
638
|
+
const fromLabel = isGroup
|
|
639
|
+
? sender.kind === "regular"
|
|
640
|
+
? `group:${groupId}:${sender.userid || sender.imid}`
|
|
641
|
+
: `group:${groupId}:${sender.name || sender.imid}`
|
|
642
|
+
: sender.kind === "regular"
|
|
643
|
+
? sender.userid || sender.name || "未知"
|
|
644
|
+
: sender.name || sender.imid || "未知";
|
|
645
|
+
const fromAddress = isGroup ? `infoflow:group:${groupId}` : `infoflow:${senderId}`;
|
|
646
|
+
const toAddress = isGroup ? `infoflow:group:${groupId}` : `infoflow:${senderId}`;
|
|
354
647
|
|
|
355
648
|
const body = core.channel.reply.formatAgentEnvelope({
|
|
356
649
|
channel: "Infoflow",
|
|
@@ -358,7 +651,7 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
|
|
|
358
651
|
timestamp: Date.now(),
|
|
359
652
|
previousTimestamp,
|
|
360
653
|
envelope: envelopeOptions,
|
|
361
|
-
body:
|
|
654
|
+
body: bodyForAgent,
|
|
362
655
|
});
|
|
363
656
|
|
|
364
657
|
// Inject accumulated group chat history into the body for context
|
|
@@ -395,9 +688,8 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
|
|
|
395
688
|
const mediaList: Array<{ path: string; contentType?: string }> = [];
|
|
396
689
|
const failReasons: string[] = [];
|
|
397
690
|
|
|
398
|
-
logVerbose(`[
|
|
691
|
+
logVerbose(`[inbound] 图片处理: urls=${event.imageUrls?.length ?? 0}`);
|
|
399
692
|
if (event.imageUrls && event.imageUrls.length > 0) {
|
|
400
|
-
logVerbose(`[DEBUG bot] 待下载图片URLs: ${JSON.stringify(event.imageUrls)}`);
|
|
401
693
|
// Collect unique hostnames from image URLs for SSRF allowlist.
|
|
402
694
|
// Infoflow image servers (e.g. xp2.im.baidu.com, e4hi.im.baidu.com) resolve to
|
|
403
695
|
// internal IPs on Baidu's network, so they need to be explicitly allowed.
|
|
@@ -417,38 +709,88 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
|
|
|
417
709
|
const urls = event.imageUrls.slice(0, INFOFLOW_MAX_IMAGES);
|
|
418
710
|
const results = await Promise.allSettled(
|
|
419
711
|
urls.map(async (imageUrl) => {
|
|
420
|
-
logVerbose(`[DEBUG bot] 开始下载图片: ${imageUrl}`);
|
|
421
712
|
const fetched = await core.channel.media.fetchRemoteMedia({
|
|
422
713
|
url: imageUrl,
|
|
423
714
|
maxBytes: mediaMaxBytes,
|
|
424
715
|
ssrfPolicy,
|
|
425
716
|
});
|
|
426
|
-
logVerbose(`[DEBUG bot] 图片下载成功: size=${fetched.buffer.length}, contentType=${fetched.contentType}`);
|
|
427
717
|
const saved = await core.channel.media.saveMediaBuffer(
|
|
428
718
|
fetched.buffer,
|
|
429
719
|
fetched.contentType ?? undefined,
|
|
430
720
|
"inbound",
|
|
431
721
|
mediaMaxBytes,
|
|
432
722
|
);
|
|
433
|
-
logVerbose(
|
|
434
|
-
|
|
723
|
+
logVerbose(
|
|
724
|
+
`[inbound] image downloaded: url=${imageUrl}, size=${fetched.buffer.length}, saved=${saved.path}`,
|
|
725
|
+
);
|
|
435
726
|
return { path: saved.path, contentType: saved.contentType ?? fetched.contentType };
|
|
436
727
|
}),
|
|
437
728
|
);
|
|
438
729
|
for (const result of results) {
|
|
439
730
|
if (result.status === "fulfilled") {
|
|
440
731
|
mediaList.push(result.value);
|
|
441
|
-
logVerbose(`[DEBUG bot] 图片处理成功: ${result.value.path}`);
|
|
442
732
|
} else {
|
|
443
733
|
const reason = String(result.reason);
|
|
444
|
-
logVerbose(`[
|
|
445
|
-
logVerbose(`[DEBUG bot] 图片下载失败: ${reason}`);
|
|
734
|
+
logVerbose(`[inbound] image download failed: ${reason}`);
|
|
446
735
|
failReasons.push(reason);
|
|
447
736
|
}
|
|
448
737
|
}
|
|
449
738
|
}
|
|
450
739
|
|
|
451
|
-
logVerbose(`[
|
|
740
|
+
logVerbose(`[inbound] 图片处理完成: ok=${mediaList.length}, fail=${failReasons.length}`);
|
|
741
|
+
|
|
742
|
+
// --- Resolve inbound media (voice mp3) ---
|
|
743
|
+
if (event.voiceUrl && event.voiceUrl.includes(".mp3")) {
|
|
744
|
+
try {
|
|
745
|
+
const voiceHostname = new URL(event.voiceUrl).hostname;
|
|
746
|
+
const ssrfPolicy = voiceHostname ? { allowedHostnames: [voiceHostname] } : undefined;
|
|
747
|
+
const fetched = await core.channel.media.fetchRemoteMedia({
|
|
748
|
+
url: event.voiceUrl,
|
|
749
|
+
maxBytes: mediaMaxBytes,
|
|
750
|
+
ssrfPolicy,
|
|
751
|
+
});
|
|
752
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
753
|
+
fetched.buffer,
|
|
754
|
+
fetched.contentType ?? "audio/mpeg",
|
|
755
|
+
"inbound",
|
|
756
|
+
mediaMaxBytes,
|
|
757
|
+
);
|
|
758
|
+
mediaList.push({ path: saved.path, contentType: saved.contentType ?? "audio/mpeg" });
|
|
759
|
+
logVerbose(
|
|
760
|
+
`[inbound] voice mp3 downloaded: url=${event.voiceUrl}, size=${fetched.buffer.length}, saved=${saved.path}`,
|
|
761
|
+
);
|
|
762
|
+
} catch (err) {
|
|
763
|
+
const reason = formatInfoflowError(err);
|
|
764
|
+
logVerbose(`[inbound] voice mp3 download failed: ${reason}`);
|
|
765
|
+
failReasons.push(reason);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// --- Resolve inbound media (voice wav from hd→wav conversion) ---
|
|
770
|
+
if (event.localVoicePath) {
|
|
771
|
+
try {
|
|
772
|
+
const wavBuffer = readFileSync(event.localVoicePath);
|
|
773
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
774
|
+
wavBuffer,
|
|
775
|
+
"audio/wav",
|
|
776
|
+
"inbound",
|
|
777
|
+
mediaMaxBytes,
|
|
778
|
+
);
|
|
779
|
+
mediaList.push({ path: saved.path, contentType: saved.contentType ?? "audio/wav" });
|
|
780
|
+
logVerbose(
|
|
781
|
+
`[inbound] voice wav loaded: path=${event.localVoicePath}, size=${wavBuffer.length}, saved=${saved.path}`,
|
|
782
|
+
);
|
|
783
|
+
try {
|
|
784
|
+
unlinkSync(event.localVoicePath);
|
|
785
|
+
} catch {
|
|
786
|
+
/* ignore */
|
|
787
|
+
}
|
|
788
|
+
} catch (err) {
|
|
789
|
+
const reason = formatInfoflowError(err);
|
|
790
|
+
logVerbose(`[inbound] voice wav load failed: ${reason}`);
|
|
791
|
+
failReasons.push(reason);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
452
794
|
|
|
453
795
|
const mediaPayload = buildAgentMediaPayload(mediaList);
|
|
454
796
|
|
|
@@ -489,8 +831,8 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
|
|
|
489
831
|
ChatType: chatType,
|
|
490
832
|
ConversationLabel: fromLabel,
|
|
491
833
|
GroupSubject: isGroup ? `group:${groupId}` : undefined,
|
|
492
|
-
SenderName:
|
|
493
|
-
SenderId:
|
|
834
|
+
SenderName: sender.name,
|
|
835
|
+
SenderId: sender.kind === "regular" ? sender.userid : sender.imid,
|
|
494
836
|
Provider: "infoflow",
|
|
495
837
|
Surface: "infoflow",
|
|
496
838
|
MessageSid: event.messageId ?? `${Date.now()}`,
|
|
@@ -521,50 +863,84 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
|
|
|
521
863
|
let triggerReason = "direct-message";
|
|
522
864
|
if (isGroup && groupCfg) {
|
|
523
865
|
const { replyMode } = groupCfg;
|
|
524
|
-
|
|
866
|
+
// Generate key for history tracking and follow-up window based on groupSessionMode
|
|
867
|
+
const historyReplyKey = groupId !== undefined ? String(groupId) : undefined;
|
|
525
868
|
|
|
526
869
|
// "record" mode: save to session only, no think, no reply
|
|
527
870
|
if (replyMode === "record") {
|
|
528
|
-
if (
|
|
871
|
+
if (historyReplyKey) {
|
|
529
872
|
logVerbose(
|
|
530
|
-
`[infoflow:bot] pending: from=${
|
|
873
|
+
`[infoflow:bot] pending: from=${senderDesc}, group=${groupId}, reason=record-mode`,
|
|
531
874
|
);
|
|
532
875
|
recordPendingHistoryEntryIfEnabled({
|
|
533
876
|
historyMap: chatHistories,
|
|
534
|
-
historyKey:
|
|
535
|
-
entry: {
|
|
877
|
+
historyKey: historyReplyKey,
|
|
878
|
+
entry: {
|
|
879
|
+
sender: senderTag,
|
|
880
|
+
body: bodyForAgent,
|
|
881
|
+
timestamp: Date.now(),
|
|
882
|
+
},
|
|
536
883
|
limit: DEFAULT_GROUP_HISTORY_LIMIT,
|
|
537
884
|
});
|
|
538
885
|
}
|
|
539
886
|
return;
|
|
540
887
|
}
|
|
541
888
|
|
|
542
|
-
|
|
889
|
+
// eventtype 区分保证了 wasMentioned 的可靠性,不再依赖 robotName 配置
|
|
543
890
|
const wasMentioned = event.wasMentioned === true;
|
|
544
891
|
|
|
892
|
+
// 消息里 @了其他机器人(有 robotid 的 AT 元素但不是当前机器人)时,
|
|
893
|
+
// 不应触发 followUp,避免"@别人说话"被误认为是跟进问题
|
|
894
|
+
const mentionsOtherBot =
|
|
895
|
+
!wasMentioned &&
|
|
896
|
+
(event.bodyItems ?? []).some((item) => item.type === "AT" && item.robotid != null);
|
|
897
|
+
|
|
545
898
|
if (replyMode === "mention-only") {
|
|
546
|
-
// Only reply if bot was @mentioned
|
|
547
|
-
const shouldReply =
|
|
899
|
+
// Only reply if bot was @mentioned.
|
|
900
|
+
const shouldReply = wasMentioned;
|
|
548
901
|
if (shouldReply) {
|
|
549
902
|
triggerReason = "bot-mentioned";
|
|
550
903
|
} else {
|
|
551
|
-
// Check follow-up window: if bot recently replied, allow LLM to decide
|
|
904
|
+
// Check follow-up window: if bot recently replied, allow LLM to decide.
|
|
905
|
+
// Skip followUp if the message is @mentioning another bot.
|
|
552
906
|
if (
|
|
907
|
+
!mentionsOtherBot &&
|
|
553
908
|
groupCfg.followUp &&
|
|
554
|
-
|
|
555
|
-
isWithinFollowUpWindow(
|
|
909
|
+
historyReplyKey &&
|
|
910
|
+
isWithinFollowUpWindow(historyReplyKey, groupCfg.followUpWindow)
|
|
556
911
|
) {
|
|
912
|
+
// Follow-up window: if message @mentions others, record only without dispatching
|
|
913
|
+
if (hasOtherMentions(event.mentionIds)) {
|
|
914
|
+
logVerbose(
|
|
915
|
+
`[infoflow:bot] followUp record-only: from=${senderDesc}, group=${groupId}, reason=followup-but-other-mentioned`,
|
|
916
|
+
);
|
|
917
|
+
recordPendingHistoryEntryIfEnabled({
|
|
918
|
+
historyMap: chatHistories,
|
|
919
|
+
historyKey: historyReplyKey,
|
|
920
|
+
entry: {
|
|
921
|
+
sender: senderTag,
|
|
922
|
+
body: bodyForAgent,
|
|
923
|
+
timestamp: Date.now(),
|
|
924
|
+
},
|
|
925
|
+
limit: DEFAULT_GROUP_HISTORY_LIMIT,
|
|
926
|
+
});
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
557
929
|
triggerReason = "followUp";
|
|
558
|
-
ctxPayload.GroupSystemPrompt = buildFollowUpPrompt();
|
|
930
|
+
ctxPayload.GroupSystemPrompt = buildFollowUpPrompt(event.isReplyToBot);
|
|
559
931
|
} else {
|
|
560
|
-
if (
|
|
932
|
+
if (historyReplyKey) {
|
|
561
933
|
logVerbose(
|
|
562
|
-
`[infoflow:bot] pending: from=${
|
|
934
|
+
`[infoflow:bot] pending: from=${senderDesc}, group=${groupId}, reason=mention-only-not-mentioned`,
|
|
563
935
|
);
|
|
564
936
|
recordPendingHistoryEntryIfEnabled({
|
|
565
937
|
historyMap: chatHistories,
|
|
566
|
-
historyKey:
|
|
567
|
-
entry: {
|
|
938
|
+
historyKey: historyReplyKey,
|
|
939
|
+
entry: {
|
|
940
|
+
sender: senderTag,
|
|
941
|
+
body: bodyForAgent,
|
|
942
|
+
timestamp: Date.now(),
|
|
943
|
+
},
|
|
568
944
|
limit: DEFAULT_GROUP_HISTORY_LIMIT,
|
|
569
945
|
});
|
|
570
946
|
}
|
|
@@ -572,8 +948,8 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
|
|
|
572
948
|
}
|
|
573
949
|
}
|
|
574
950
|
} else if (replyMode === "mention-and-watch") {
|
|
575
|
-
// Reply if bot @mentioned, or if watched person @mentioned, or follow-up
|
|
576
|
-
const botMentioned =
|
|
951
|
+
// Reply if bot @mentioned, or if watched person @mentioned, or follow-up.
|
|
952
|
+
const botMentioned = wasMentioned;
|
|
577
953
|
if (botMentioned) {
|
|
578
954
|
triggerReason = "bot-mentioned";
|
|
579
955
|
} else {
|
|
@@ -584,31 +960,56 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
|
|
|
584
960
|
? checkWatchMentioned(event.bodyItems, watchMentions)
|
|
585
961
|
: undefined;
|
|
586
962
|
|
|
963
|
+
const matchedRegexPattern = groupCfg.watchRegex
|
|
964
|
+
? checkWatchRegex(mes, groupCfg.watchRegex)
|
|
965
|
+
: undefined;
|
|
966
|
+
|
|
587
967
|
if (matchedWatchId) {
|
|
588
968
|
triggerReason = `watchMentions(${matchedWatchId})`;
|
|
589
969
|
// Watch-mention triggered: instruct agent to reply only if confident
|
|
590
970
|
ctxPayload.GroupSystemPrompt = buildWatchMentionPrompt(matchedWatchId);
|
|
591
|
-
} else if (
|
|
592
|
-
triggerReason = `watchRegex(${
|
|
971
|
+
} else if (matchedRegexPattern) {
|
|
972
|
+
triggerReason = `watchRegex(${matchedRegexPattern})`;
|
|
593
973
|
// Watch-content triggered: message matched configured regex pattern
|
|
594
|
-
ctxPayload.GroupSystemPrompt = buildWatchRegexPrompt(
|
|
974
|
+
ctxPayload.GroupSystemPrompt = buildWatchRegexPrompt(matchedRegexPattern);
|
|
595
975
|
} else if (
|
|
976
|
+
!mentionsOtherBot &&
|
|
596
977
|
groupCfg.followUp &&
|
|
597
|
-
|
|
598
|
-
isWithinFollowUpWindow(
|
|
978
|
+
historyReplyKey &&
|
|
979
|
+
isWithinFollowUpWindow(historyReplyKey, groupCfg.followUpWindow)
|
|
599
980
|
) {
|
|
981
|
+
// Follow-up window: if message @mentions others, record only without dispatching
|
|
982
|
+
if (hasOtherMentions(event.mentionIds)) {
|
|
983
|
+
logVerbose(
|
|
984
|
+
`[infoflow:bot] followUp record-only: from=${senderDesc}, group=${groupId}, reason=followup-but-other-mentioned`,
|
|
985
|
+
);
|
|
986
|
+
recordPendingHistoryEntryIfEnabled({
|
|
987
|
+
historyMap: chatHistories,
|
|
988
|
+
historyKey: historyReplyKey,
|
|
989
|
+
entry: {
|
|
990
|
+
sender: senderTag,
|
|
991
|
+
body: bodyForAgent,
|
|
992
|
+
timestamp: Date.now(),
|
|
993
|
+
},
|
|
994
|
+
limit: DEFAULT_GROUP_HISTORY_LIMIT,
|
|
995
|
+
});
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
600
998
|
triggerReason = "followUp";
|
|
601
|
-
|
|
602
|
-
ctxPayload.GroupSystemPrompt = buildFollowUpPrompt();
|
|
999
|
+
ctxPayload.GroupSystemPrompt = buildFollowUpPrompt(event.isReplyToBot);
|
|
603
1000
|
} else {
|
|
604
|
-
if (
|
|
1001
|
+
if (historyReplyKey) {
|
|
605
1002
|
logVerbose(
|
|
606
|
-
`[infoflow:bot] pending: from=${
|
|
1003
|
+
`[infoflow:bot] pending: from=${senderDesc}, group=${groupId}, reason=mention-and-watch-no-trigger`,
|
|
607
1004
|
);
|
|
608
1005
|
recordPendingHistoryEntryIfEnabled({
|
|
609
1006
|
historyMap: chatHistories,
|
|
610
|
-
historyKey:
|
|
611
|
-
entry: {
|
|
1007
|
+
historyKey: historyReplyKey,
|
|
1008
|
+
entry: {
|
|
1009
|
+
sender: senderTag,
|
|
1010
|
+
body: bodyForAgent,
|
|
1011
|
+
timestamp: Date.now(),
|
|
1012
|
+
},
|
|
612
1013
|
limit: DEFAULT_GROUP_HISTORY_LIMIT,
|
|
613
1014
|
});
|
|
614
1015
|
}
|
|
@@ -617,7 +1018,7 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
|
|
|
617
1018
|
}
|
|
618
1019
|
} else if (replyMode === "proactive") {
|
|
619
1020
|
// Always think and potentially reply
|
|
620
|
-
const botMentioned =
|
|
1021
|
+
const botMentioned = wasMentioned;
|
|
621
1022
|
if (botMentioned) {
|
|
622
1023
|
triggerReason = "bot-mentioned";
|
|
623
1024
|
} else {
|
|
@@ -646,8 +1047,45 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
|
|
|
646
1047
|
}
|
|
647
1048
|
}
|
|
648
1049
|
|
|
649
|
-
//
|
|
650
|
-
const to =
|
|
1050
|
+
// 回复目标:群聊用 group:{groupId},私聊 regular 用 userid,robot 用 imid
|
|
1051
|
+
const to =
|
|
1052
|
+
isGroup && groupId !== undefined
|
|
1053
|
+
? `group:${groupId}`
|
|
1054
|
+
: sender.kind === "regular"
|
|
1055
|
+
? sender.userid
|
|
1056
|
+
: (sender.imid ?? "");
|
|
1057
|
+
|
|
1058
|
+
ctxPayload.GroupSystemPrompt = appendInfoflowSystemPrompt(
|
|
1059
|
+
ctxPayload.GroupSystemPrompt,
|
|
1060
|
+
buildInfoflowCronTargetPrompt({
|
|
1061
|
+
chatType,
|
|
1062
|
+
to,
|
|
1063
|
+
}),
|
|
1064
|
+
);
|
|
1065
|
+
|
|
1066
|
+
const configuredMessageFormat: InfoflowMessageFormat = isGroup
|
|
1067
|
+
? (account.config.groupMessageFormat ?? "markdown")
|
|
1068
|
+
: (account.config.dmMessageFormat ?? "markdown");
|
|
1069
|
+
let effectiveMessageFormat = configuredMessageFormat;
|
|
1070
|
+
let streamingCard: InfoflowStreamingCardSession | undefined;
|
|
1071
|
+
|
|
1072
|
+
if (configuredMessageFormat === "streaming-card") {
|
|
1073
|
+
streamingCard = new InfoflowStreamingCardSession({
|
|
1074
|
+
cfg,
|
|
1075
|
+
accountId: account.accountId,
|
|
1076
|
+
to,
|
|
1077
|
+
answerFormat: "markdown",
|
|
1078
|
+
fallbackFormat: normalizeStreamingFallbackFormat(configuredMessageFormat),
|
|
1079
|
+
});
|
|
1080
|
+
const started = await streamingCard.start();
|
|
1081
|
+
if (!started) {
|
|
1082
|
+
logVerbose(
|
|
1083
|
+
`[streaming] failed to start streaming card for to=${to}, falling back to standard reply`,
|
|
1084
|
+
);
|
|
1085
|
+
streamingCard = undefined;
|
|
1086
|
+
effectiveMessageFormat = normalizeStreamingFallbackFormat(configuredMessageFormat);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
651
1089
|
|
|
652
1090
|
// Provide mention context to the LLM so it can decide who to @mention
|
|
653
1091
|
if (isGroup && event.mentionIds) {
|
|
@@ -663,48 +1101,44 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
|
|
|
663
1101
|
}
|
|
664
1102
|
}
|
|
665
1103
|
|
|
666
|
-
|
|
667
|
-
`[
|
|
1104
|
+
getInfoflowBotLog().info(
|
|
1105
|
+
`[dispatch] from=${senderDesc}, to=${to}, chatType=${chatType}, trigger=${triggerReason}, replyMode=${groupCfg?.replyMode ?? "N/A"}, session=${route.sessionKey}`,
|
|
668
1106
|
);
|
|
669
1107
|
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
logVerbose(`[DEBUG bot] WARNING: event.messageId is undefined/null!`);
|
|
674
|
-
}
|
|
675
|
-
if (!event.senderImid) {
|
|
676
|
-
logVerbose(`[DEBUG bot] WARNING: event.senderImid is undefined/null!`);
|
|
677
|
-
}
|
|
678
|
-
if (!isGroup) {
|
|
679
|
-
logVerbose(`[DEBUG bot] Not a group message, skipping reply-to`);
|
|
680
|
-
}
|
|
1108
|
+
logVerbose(
|
|
1109
|
+
`[dispatch:detail] trigger=${triggerReason}, messageId=${event.messageId}, senderImid=${senderImid}, images=${mediaList.length}`,
|
|
1110
|
+
);
|
|
681
1111
|
|
|
682
1112
|
// Send "processing" hint if LLM takes longer than processingHintDelay seconds
|
|
683
1113
|
// (default: 5s). Gives users feedback without spamming fast responses.
|
|
684
|
-
const processingHintEnabled =
|
|
1114
|
+
const processingHintEnabled =
|
|
1115
|
+
effectiveMessageFormat !== "streaming-card" && account.config.processingHint !== false;
|
|
685
1116
|
let cancelProcessingHint: (() => void) | undefined;
|
|
686
1117
|
let hintWasSent = false;
|
|
687
1118
|
const dispatchStartTime = Date.now();
|
|
688
1119
|
if (processingHintEnabled) {
|
|
689
1120
|
const delayMs = (account.config.processingHintDelay ?? 5) * 1000;
|
|
690
1121
|
const processingReplyTo =
|
|
691
|
-
|
|
1122
|
+
event.messageId
|
|
692
1123
|
? {
|
|
693
1124
|
messageid: event.messageId,
|
|
694
1125
|
preview: mes ? (mes.length > 100 ? mes.slice(0, 100) + "..." : mes) : "",
|
|
695
|
-
...(
|
|
1126
|
+
...(senderImid ? { imid: senderImid } : {}),
|
|
696
1127
|
replytype: "2" as const,
|
|
1128
|
+
...(event.msgid2 ? { msgid2: event.msgid2 } : {}),
|
|
697
1129
|
}
|
|
698
1130
|
: undefined;
|
|
699
1131
|
let cancelled = false;
|
|
700
|
-
cancelProcessingHint = () => {
|
|
1132
|
+
cancelProcessingHint = () => {
|
|
1133
|
+
cancelled = true;
|
|
1134
|
+
};
|
|
701
1135
|
setTimeout(() => {
|
|
702
1136
|
if (cancelled) return;
|
|
703
1137
|
hintWasSent = true;
|
|
704
1138
|
sendInfoflowMessage({
|
|
705
1139
|
cfg,
|
|
706
1140
|
to,
|
|
707
|
-
contents: [{ type: "text", content: "
|
|
1141
|
+
contents: [{ type: "text", content: "👌收到啦" }],
|
|
708
1142
|
accountId: account.accountId,
|
|
709
1143
|
replyTo: processingReplyTo,
|
|
710
1144
|
}).catch(() => {});
|
|
@@ -716,19 +1150,24 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
|
|
|
716
1150
|
agentId: route.agentId,
|
|
717
1151
|
accountId: account.accountId,
|
|
718
1152
|
to,
|
|
1153
|
+
streamingCard,
|
|
719
1154
|
statusSink,
|
|
720
|
-
// @mention the sender back when bot was directly @mentioned in a group
|
|
721
|
-
atOptions:
|
|
1155
|
+
// @mention the sender back when bot was directly @mentioned in a group (skip for bot senders)
|
|
1156
|
+
atOptions:
|
|
1157
|
+
isGroup && event.wasMentioned && event.sender.kind === "regular"
|
|
1158
|
+
? { atUserIds: [event.sender.userid] }
|
|
1159
|
+
: undefined,
|
|
722
1160
|
// Pass mention IDs for LLM-driven @mention resolution in outbound text
|
|
723
1161
|
mentionIds: isGroup ? event.mentionIds : undefined,
|
|
724
|
-
// Pass inbound messageId for outbound reply-to
|
|
725
|
-
replyToMessageId:
|
|
726
|
-
replyToPreview:
|
|
727
|
-
replyToImid:
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
1162
|
+
// Pass inbound messageId for outbound reply-to
|
|
1163
|
+
replyToMessageId: event.messageId,
|
|
1164
|
+
replyToPreview: mes,
|
|
1165
|
+
replyToImid: senderImid,
|
|
1166
|
+
replyToMsgid2: event.msgid2,
|
|
1167
|
+
// Message format: per-chat-type config, falling back to "markdown"
|
|
1168
|
+
messageFormat: effectiveMessageFormat,
|
|
1169
|
+
// Chunk size: per-account config, default 1800
|
|
1170
|
+
textChunkLimit: account.config.textChunkLimit ?? 1800,
|
|
732
1171
|
});
|
|
733
1172
|
|
|
734
1173
|
// Cancel processing hint the moment the first real message starts being delivered,
|
|
@@ -736,38 +1175,89 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
|
|
|
736
1175
|
if (cancelProcessingHint) {
|
|
737
1176
|
const originalDeliver = dispatcherOptions.deliver;
|
|
738
1177
|
let hintCancelledOnDeliver = false;
|
|
739
|
-
dispatcherOptions.deliver = async (
|
|
1178
|
+
dispatcherOptions.deliver = async (
|
|
1179
|
+
payload: Parameters<typeof originalDeliver>[0],
|
|
1180
|
+
info?: Parameters<typeof originalDeliver>[1],
|
|
1181
|
+
) => {
|
|
740
1182
|
if (!hintCancelledOnDeliver) {
|
|
741
1183
|
hintCancelledOnDeliver = true;
|
|
742
1184
|
cancelProcessingHint!();
|
|
743
1185
|
}
|
|
744
|
-
return originalDeliver(payload);
|
|
1186
|
+
return originalDeliver(payload, info);
|
|
745
1187
|
};
|
|
746
1188
|
}
|
|
747
1189
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
1190
|
+
let hintCancelledOnStream = false;
|
|
1191
|
+
const cancelHintOnStream = () => {
|
|
1192
|
+
if (cancelProcessingHint && !hintCancelledOnStream) {
|
|
1193
|
+
hintCancelledOnStream = true;
|
|
1194
|
+
cancelProcessingHint();
|
|
1195
|
+
}
|
|
1196
|
+
};
|
|
1197
|
+
|
|
1198
|
+
const mergedReplyOptions = {
|
|
1199
|
+
...replyOptions,
|
|
1200
|
+
onPartialReply: async (payload: { text?: string; mediaUrls?: string[] }) => {
|
|
1201
|
+
cancelHintOnStream();
|
|
1202
|
+
streamingCard?.noteAssistantText(payload.text);
|
|
1203
|
+
},
|
|
1204
|
+
onReasoningStream: async (payload: { text?: string; mediaUrls?: string[] }) => {
|
|
1205
|
+
cancelHintOnStream();
|
|
1206
|
+
streamingCard?.noteReasoning(payload.text);
|
|
1207
|
+
},
|
|
1208
|
+
onToolStart: async (payload: { name?: string; phase?: string }) => {
|
|
1209
|
+
cancelHintOnStream();
|
|
1210
|
+
streamingCard?.noteToolStart(payload);
|
|
1211
|
+
},
|
|
1212
|
+
onToolResult: async (payload: { text?: string; mediaUrls?: string[] }) => {
|
|
1213
|
+
cancelHintOnStream();
|
|
1214
|
+
streamingCard?.noteToolResult(payload.text);
|
|
1215
|
+
if (payload.mediaUrls?.length) {
|
|
1216
|
+
for (const mediaUrl of payload.mediaUrls) {
|
|
1217
|
+
streamingCard?.noteToolResult(`- 附件: ${mediaUrl}`);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
},
|
|
1221
|
+
onAssistantMessageStart: async () => {
|
|
1222
|
+
cancelHintOnStream();
|
|
1223
|
+
},
|
|
1224
|
+
};
|
|
1225
|
+
|
|
1226
|
+
let dispatchResult:
|
|
1227
|
+
| Awaited<ReturnType<typeof core.channel.reply.dispatchReplyWithBufferedBlockDispatcher>>
|
|
1228
|
+
| undefined;
|
|
1229
|
+
try {
|
|
1230
|
+
dispatchResult = await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
1231
|
+
ctx: ctxPayload,
|
|
1232
|
+
cfg,
|
|
1233
|
+
dispatcherOptions,
|
|
1234
|
+
replyOptions: mergedReplyOptions,
|
|
1235
|
+
});
|
|
1236
|
+
} catch (err) {
|
|
1237
|
+
await streamingCard?.failWithMessage("处理出错,请稍后重试");
|
|
1238
|
+
throw err;
|
|
1239
|
+
}
|
|
754
1240
|
|
|
755
1241
|
// Fallback cancel: in case deliver was never called (e.g. empty response)
|
|
756
1242
|
cancelProcessingHint?.();
|
|
757
1243
|
|
|
758
1244
|
// If hint was shown to the user, send "搞定" so they know the task is done
|
|
759
|
-
if (hintWasSent) {
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
}
|
|
1245
|
+
// if (hintWasSent) {
|
|
1246
|
+
// const elapsedSec = Math.round((Date.now() - dispatchStartTime) / 1000);
|
|
1247
|
+
// sendInfoflowMessage({
|
|
1248
|
+
// cfg,
|
|
1249
|
+
// to,
|
|
1250
|
+
// contents: [{ type: "text", content: `任务完成 ✨ (${elapsedSec}s)` }],
|
|
1251
|
+
// accountId: account.accountId,
|
|
1252
|
+
// }).catch(() => {});
|
|
1253
|
+
// }
|
|
768
1254
|
|
|
769
1255
|
const didReply = dispatchResult?.queuedFinal ?? false;
|
|
770
1256
|
|
|
1257
|
+
if (!didReply && streamingCard?.isUsable()) {
|
|
1258
|
+
await streamingCard.complete();
|
|
1259
|
+
}
|
|
1260
|
+
|
|
771
1261
|
// Clear accumulated history after dispatch (it's now in the session transcript)
|
|
772
1262
|
if (isGroup && historyKey) {
|
|
773
1263
|
clearHistoryEntriesIfEnabled({
|
|
@@ -779,14 +1269,31 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
|
|
|
779
1269
|
|
|
780
1270
|
// Record bot reply timestamp for follow-up window tracking
|
|
781
1271
|
if (didReply && isGroup && groupId !== undefined) {
|
|
782
|
-
|
|
1272
|
+
const replyKey = String(groupId);
|
|
1273
|
+
recordGroupReply(replyKey);
|
|
783
1274
|
}
|
|
784
1275
|
|
|
785
|
-
|
|
786
|
-
`[
|
|
1276
|
+
getInfoflowBotLog().info(
|
|
1277
|
+
`[dispatch:done] from=${senderDesc}, to=${to}, replied=${didReply}, blocks=${dispatchResult?.counts.final ?? 0}, elapsed=${Date.now() - dispatchStartTime}ms`,
|
|
787
1278
|
);
|
|
788
1279
|
}
|
|
789
1280
|
|
|
1281
|
+
/**
|
|
1282
|
+
* Extracts the MD5 hash from a VoiceUrl's fileid parameter.
|
|
1283
|
+
* Example: "http://...?fileid=f161a9fd88ca2635ca1334c006c4ffb7.hd" → "F161A9FD88CA2635CA1334C006C4FFB7"
|
|
1284
|
+
*/
|
|
1285
|
+
export function extractMd5FromVoiceUrl(voiceUrl: string): string | undefined {
|
|
1286
|
+
try {
|
|
1287
|
+
const url = new URL(voiceUrl);
|
|
1288
|
+
const fileid = url.searchParams.get("fileid") ?? "";
|
|
1289
|
+
// Remove file extension (e.g. ".hd") and return uppercase MD5
|
|
1290
|
+
const md5 = fileid.replace(/\.[^.]+$/, "");
|
|
1291
|
+
return md5 && /^[0-9a-f]{32}$/i.test(md5) ? md5.toUpperCase() : undefined;
|
|
1292
|
+
} catch {
|
|
1293
|
+
return undefined;
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
|
|
790
1297
|
// ---------------------------------------------------------------------------
|
|
791
1298
|
// Test-only exports (@internal)
|
|
792
1299
|
// ---------------------------------------------------------------------------
|