@core-workspace/infoflow-openclaw-plugin 2026.3.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +989 -0
- package/docs/architecture-data-flow.md +429 -0
- package/docs/architecture.md +423 -0
- package/docs/dev-guide.md +611 -0
- package/index.ts +29 -0
- package/openclaw.plugin.json +138 -0
- package/package.json +40 -0
- package/scripts/deploy.sh +34 -0
- package/skills/infoflow-dev/SKILL.md +88 -0
- package/skills/infoflow-dev/references/api.md +413 -0
- package/src/adapter/inbound/webhook-parser.ts +433 -0
- package/src/adapter/inbound/ws-receiver.ts +226 -0
- package/src/adapter/outbound/reply-dispatcher.ts +281 -0
- package/src/adapter/outbound/target-resolver.ts +109 -0
- package/src/channel/accounts.ts +164 -0
- package/src/channel/channel.ts +364 -0
- package/src/channel/media.ts +365 -0
- package/src/channel/monitor.ts +184 -0
- package/src/channel/outbound.ts +934 -0
- package/src/events.ts +62 -0
- package/src/handler/message-handler.ts +801 -0
- package/src/logging.ts +123 -0
- package/src/runtime.ts +14 -0
- package/src/security/dm-policy.ts +80 -0
- package/src/security/group-policy.ts +271 -0
- package/src/tools/actions/index.ts +456 -0
- package/src/tools/hooks/index.ts +82 -0
- package/src/tools/index.ts +277 -0
- package/src/types.ts +277 -0
- package/src/utils/store/message-store.ts +295 -0
- package/src/utils/token-adapter.ts +90 -0
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildPendingHistoryContextFromMap,
|
|
3
|
+
clearHistoryEntriesIfEnabled,
|
|
4
|
+
DEFAULT_GROUP_HISTORY_LIMIT,
|
|
5
|
+
type HistoryEntry,
|
|
6
|
+
recordPendingHistoryEntryIfEnabled,
|
|
7
|
+
buildAgentMediaPayload,
|
|
8
|
+
} from "openclaw/plugin-sdk";
|
|
9
|
+
import { resolveInfoflowAccount } from "../channel/accounts.js";
|
|
10
|
+
import { getInfoflowBotLog, formatInfoflowError, logVerbose } from "../logging.js";
|
|
11
|
+
import { createInfoflowReplyDispatcher } from "../adapter/outbound/reply-dispatcher.js";
|
|
12
|
+
import { sendInfoflowMessage } from "../channel/outbound.js";
|
|
13
|
+
import { getInfoflowRuntime } from "../runtime.js";
|
|
14
|
+
import {
|
|
15
|
+
checkBotMentioned,
|
|
16
|
+
checkWatchMentioned,
|
|
17
|
+
checkWatchRegex,
|
|
18
|
+
extractMentionIds,
|
|
19
|
+
recordGroupReply,
|
|
20
|
+
isWithinFollowUpWindow,
|
|
21
|
+
resolveGroupConfig,
|
|
22
|
+
buildWatchMentionPrompt,
|
|
23
|
+
buildWatchRegexPrompt,
|
|
24
|
+
buildFollowUpPrompt,
|
|
25
|
+
buildProactivePrompt,
|
|
26
|
+
type InfoflowBodyItem,
|
|
27
|
+
} from "../security/group-policy.js";
|
|
28
|
+
import { checkDmPolicy, checkGroupPolicy } from "../security/dm-policy.js";
|
|
29
|
+
import type {
|
|
30
|
+
InfoflowChatType,
|
|
31
|
+
InfoflowMessageEvent,
|
|
32
|
+
InfoflowMentionIds,
|
|
33
|
+
InfoflowReplyMode,
|
|
34
|
+
InfoflowGroupConfig,
|
|
35
|
+
HandleInfoflowMessageParams,
|
|
36
|
+
HandlePrivateChatParams,
|
|
37
|
+
HandleGroupChatParams,
|
|
38
|
+
ResolvedInfoflowAccount,
|
|
39
|
+
} from "../types.js";
|
|
40
|
+
|
|
41
|
+
// Re-export types for external consumers
|
|
42
|
+
export type { InfoflowChatType, InfoflowMessageEvent } from "../types.js";
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Group reply tracking (in-memory) for follow-up window
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/** In-memory map accumulating recent group messages for context injection when bot is @mentioned */
|
|
49
|
+
const chatHistories = new Map<string, HistoryEntry[]>();
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Handles an incoming private chat message from Infoflow.
|
|
53
|
+
* Receives the raw decrypted message data and dispatches to the agent.
|
|
54
|
+
*/
|
|
55
|
+
export async function handlePrivateChatMessage(params: HandlePrivateChatParams): Promise<void> {
|
|
56
|
+
const { cfg, msgData, accountId, statusSink } = params;
|
|
57
|
+
|
|
58
|
+
// Extract sender and content from msgData (flexible field names)
|
|
59
|
+
const fromuser = String(msgData.FromUserId ?? msgData.fromuserid ?? msgData.from ?? "");
|
|
60
|
+
const mes = String(msgData.Content ?? msgData.content ?? msgData.text ?? msgData.mes ?? "");
|
|
61
|
+
|
|
62
|
+
// Extract sender name (FromUserName is more human-readable than FromUserId)
|
|
63
|
+
const senderName = String(msgData.FromUserName ?? msgData.username ?? fromuser);
|
|
64
|
+
|
|
65
|
+
// Extract message ID for dedup tracking
|
|
66
|
+
const messageId = msgData.MsgId ?? msgData.msgid ?? msgData.messageid;
|
|
67
|
+
const messageIdStr = messageId != null ? String(messageId) : undefined;
|
|
68
|
+
|
|
69
|
+
// Extract timestamp (CreateTime is in seconds, convert to milliseconds)
|
|
70
|
+
const createTime = msgData.CreateTime ?? msgData.createtime;
|
|
71
|
+
const timestamp = createTime != null ? Number(createTime) * 1000 : Date.now();
|
|
72
|
+
|
|
73
|
+
// Detect image messages: MsgType=image with PicUrl
|
|
74
|
+
const msgType = String(msgData.MsgType ?? msgData.msgtype ?? "");
|
|
75
|
+
const picUrl = String(msgData.PicUrl ?? msgData.picurl ?? "");
|
|
76
|
+
const imageUrls: string[] = [];
|
|
77
|
+
if (msgType === "image" && picUrl.trim()) {
|
|
78
|
+
imageUrls.push(picUrl.trim());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
logVerbose(
|
|
82
|
+
`[infoflow] private chat: fromuser=${fromuser}, senderName=${senderName}, mes=${mes}, msgType=${msgType}, raw msgData: ${JSON.stringify(msgData)}`,
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
logVerbose(
|
|
86
|
+
`[DEBUG private] content字段诊断: Content=${JSON.stringify(msgData.Content)}, content=${JSON.stringify(msgData.content)}, mes=${JSON.stringify(msgData.mes)}, MsgType=${JSON.stringify(msgData.MsgType)}, msgtype=${JSON.stringify(msgData.msgtype)}, PicUrl=${JSON.stringify(msgData.PicUrl)}, picurl=${JSON.stringify(msgData.picurl)}, imageUrls=${JSON.stringify(imageUrls)}`,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
if (!fromuser || (!mes.trim() && imageUrls.length === 0)) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Check dmPolicy: send a hint when user is not authorized
|
|
94
|
+
const account = resolveInfoflowAccount({ cfg, accountId });
|
|
95
|
+
const dmResult = checkDmPolicy(account, fromuser);
|
|
96
|
+
if (!dmResult.allowed) {
|
|
97
|
+
logVerbose(`[infoflow] private message rejected: dmPolicy=allowlist, fromuser=${fromuser}`);
|
|
98
|
+
sendInfoflowMessage({
|
|
99
|
+
cfg,
|
|
100
|
+
to: fromuser,
|
|
101
|
+
contents: [{ type: "text", content: "🚫 抱歉,您暂无使用权限,请联系龙虾主开通~" }],
|
|
102
|
+
accountId: account.accountId,
|
|
103
|
+
}).catch(() => { /* ignore send errors */ });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if ("note" in dmResult && dmResult.note === "pairing") {
|
|
107
|
+
// pairing 由框架处理配对逻辑,此处仅做日志,不发提示以免干扰配对流程
|
|
108
|
+
logVerbose(`[infoflow] private message: dmPolicy=pairing, fromuser=${fromuser}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// For image-only messages (no text), use placeholder
|
|
112
|
+
let effectiveMes = mes.trim();
|
|
113
|
+
if (!effectiveMes && imageUrls.length > 0) {
|
|
114
|
+
effectiveMes = "<media:image>";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Delegate to the common message handler (private chat)
|
|
118
|
+
await handleInfoflowMessage({
|
|
119
|
+
cfg,
|
|
120
|
+
event: {
|
|
121
|
+
fromuser,
|
|
122
|
+
mes: effectiveMes,
|
|
123
|
+
chatType: "direct",
|
|
124
|
+
senderName,
|
|
125
|
+
messageId: messageIdStr,
|
|
126
|
+
timestamp,
|
|
127
|
+
imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
|
|
128
|
+
},
|
|
129
|
+
accountId,
|
|
130
|
+
statusSink,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Handles an incoming group chat message from Infoflow.
|
|
136
|
+
* Receives the raw decrypted message data and dispatches to the agent.
|
|
137
|
+
*/
|
|
138
|
+
export async function handleGroupChatMessage(params: HandleGroupChatParams): Promise<void> {
|
|
139
|
+
const { cfg, msgData, accountId, statusSink } = params;
|
|
140
|
+
|
|
141
|
+
// Extract sender from nested structure or flat fields
|
|
142
|
+
const header = (msgData.message as Record<string, unknown>)?.header as
|
|
143
|
+
| Record<string, unknown>
|
|
144
|
+
| undefined;
|
|
145
|
+
const fromuser = String(header?.fromuserid ?? msgData.fromuserid ?? msgData.from ?? "");
|
|
146
|
+
|
|
147
|
+
// Extract sender's imid (数字ID) - the numeric user ID is in msgData.fromid
|
|
148
|
+
const senderImid = msgData.fromid ?? header?.imid ?? header?.fromimid ?? msgData.imid ?? msgData.fromimid;
|
|
149
|
+
const senderImidStr = senderImid != null ? String(senderImid) : undefined;
|
|
150
|
+
|
|
151
|
+
// Extract message ID (priority: header.messageid > header.msgid > MsgId)
|
|
152
|
+
const messageId = header?.messageid ?? header?.msgid ?? msgData.MsgId;
|
|
153
|
+
const messageIdStr = messageId != null ? String(messageId) : undefined;
|
|
154
|
+
|
|
155
|
+
const rawGroupId = msgData.groupid ?? header?.groupid;
|
|
156
|
+
const groupid =
|
|
157
|
+
typeof rawGroupId === "number" ? rawGroupId : rawGroupId ? Number(rawGroupId) : undefined;
|
|
158
|
+
|
|
159
|
+
// Extract timestamp (time is in milliseconds)
|
|
160
|
+
const rawTime = msgData.time ?? header?.servertime;
|
|
161
|
+
const timestamp = rawTime != null ? Number(rawTime) : Date.now();
|
|
162
|
+
|
|
163
|
+
// Debug: 打印完整的原始消息数据
|
|
164
|
+
logVerbose(`[DEBUG bot.groupchat] 完整 msgData: ${JSON.stringify(msgData, null, 2)}`);
|
|
165
|
+
logVerbose(`[DEBUG bot.groupchat] 完整 header: ${JSON.stringify(header, null, 2)}`);
|
|
166
|
+
|
|
167
|
+
// Debug: 输出所有可能的 ID 字段
|
|
168
|
+
logVerbose(`[DEBUG bot.groupchat] 查找 imid (期望值: 102752365):`);
|
|
169
|
+
logVerbose(` - header.imid: ${header?.imid}`);
|
|
170
|
+
logVerbose(` - header.fromimid: ${header?.fromimid}`);
|
|
171
|
+
logVerbose(` - header.fromuserid: ${header?.fromuserid}`);
|
|
172
|
+
logVerbose(` - msgData.imid: ${msgData.imid}`);
|
|
173
|
+
logVerbose(` - msgData.fromimid: ${msgData.fromimid}`);
|
|
174
|
+
logVerbose(` - msgData.fromuserid: ${msgData.fromuserid}`);
|
|
175
|
+
logVerbose(` - msgData.from: ${msgData.from}`);
|
|
176
|
+
logVerbose(` - msgData.userid: ${msgData.userid}`);
|
|
177
|
+
logVerbose(` - fromuser: ${fromuser}`);
|
|
178
|
+
logVerbose(` - senderImidStr: ${senderImidStr}`);
|
|
179
|
+
|
|
180
|
+
if (!fromuser) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Extract message content from body array or flat content field
|
|
185
|
+
const message = msgData.message as Record<string, unknown> | undefined;
|
|
186
|
+
const bodyItems = (message?.body ?? msgData.body ?? []) as InfoflowBodyItem[];
|
|
187
|
+
|
|
188
|
+
// Resolve account to get robotName for mention detection
|
|
189
|
+
const account = resolveInfoflowAccount({ cfg, accountId });
|
|
190
|
+
const robotName = account.config.robotName;
|
|
191
|
+
|
|
192
|
+
// Check groupPolicy allowlist
|
|
193
|
+
const wasMentionedEarly = checkBotMentioned(bodyItems, robotName);
|
|
194
|
+
const groupPolicyResult = checkGroupPolicy(account, groupid, wasMentionedEarly);
|
|
195
|
+
if (!groupPolicyResult.allowed) {
|
|
196
|
+
if (groupPolicyResult.reason === "disabled") {
|
|
197
|
+
logVerbose(`[infoflow] group message rejected: groupPolicy=disabled`);
|
|
198
|
+
} else {
|
|
199
|
+
logVerbose(`[infoflow] group message rejected: group=${groupPolicyResult.groupIdStr} not in groupAllowFrom`);
|
|
200
|
+
// 发送无权限提示,仅当消息是 @机器人 时才回复,避免在无关群里刷屏
|
|
201
|
+
if (groupPolicyResult.wasMentioned && groupPolicyResult.groupIdStr) {
|
|
202
|
+
sendInfoflowMessage({
|
|
203
|
+
cfg,
|
|
204
|
+
to: `group:${groupPolicyResult.groupIdStr}`,
|
|
205
|
+
contents: [{ type: "text", content: "🚫 抱歉,该群暂无使用权限,请联系龙虾主开通~" }],
|
|
206
|
+
accountId: account.accountId,
|
|
207
|
+
}).catch(() => { /* ignore send errors */ });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Check if bot was @mentioned (by robotName)
|
|
214
|
+
const wasMentioned = wasMentionedEarly;
|
|
215
|
+
|
|
216
|
+
// Extract non-bot mention IDs (userIds + agentIds) for LLM-driven @mentions
|
|
217
|
+
const mentionIds = extractMentionIds(bodyItems, robotName);
|
|
218
|
+
|
|
219
|
+
// Build two versions: mes (for CommandBody, no @xxx) and rawMes (for RawBody, with @xxx)
|
|
220
|
+
let textContent = "";
|
|
221
|
+
let rawTextContent = "";
|
|
222
|
+
const replyContextItems: string[] = [];
|
|
223
|
+
const imageUrls: string[] = [];
|
|
224
|
+
if (Array.isArray(bodyItems)) {
|
|
225
|
+
for (const item of bodyItems) {
|
|
226
|
+
if (item.type === "replyData") {
|
|
227
|
+
// 引用回复:提取被引用消息的内容(可能有多条引用)
|
|
228
|
+
const replyBody = (item.content ?? "").trim();
|
|
229
|
+
if (replyBody) {
|
|
230
|
+
replyContextItems.push(replyBody);
|
|
231
|
+
}
|
|
232
|
+
} else if (item.type === "TEXT") {
|
|
233
|
+
textContent += item.content ?? "";
|
|
234
|
+
rawTextContent += item.content ?? "";
|
|
235
|
+
} else if (item.type === "LINK") {
|
|
236
|
+
const label = item.label ?? "";
|
|
237
|
+
if (label) {
|
|
238
|
+
textContent += ` ${label} `;
|
|
239
|
+
rawTextContent += ` ${label} `;
|
|
240
|
+
}
|
|
241
|
+
} else if (item.type === "AT") {
|
|
242
|
+
// AT elements only go into rawTextContent, not textContent
|
|
243
|
+
const name = item.name ?? "";
|
|
244
|
+
if (name) {
|
|
245
|
+
rawTextContent += `@${name} `;
|
|
246
|
+
}
|
|
247
|
+
} else if (item.type === "IMAGE") {
|
|
248
|
+
// 提取图片下载地址
|
|
249
|
+
logVerbose(`[DEBUG bot.groupchat] IMAGE item: ${JSON.stringify(item, null, 2)}`);
|
|
250
|
+
const url = item.downloadurl;
|
|
251
|
+
if (typeof url === "string" && url.trim()) {
|
|
252
|
+
logVerbose(`[DEBUG bot.groupchat] 提取到图片URL: ${url}`);
|
|
253
|
+
imageUrls.push(url.trim());
|
|
254
|
+
} else {
|
|
255
|
+
logVerbose(`[DEBUG bot.groupchat] WARNING: IMAGE item 缺少有效的 downloadurl 字段`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
let mes = textContent.trim() || String(msgData.content ?? msgData.text ?? "");
|
|
262
|
+
const rawMes = rawTextContent.trim() || mes;
|
|
263
|
+
|
|
264
|
+
const replyContext = replyContextItems.length > 0 ? replyContextItems : undefined;
|
|
265
|
+
|
|
266
|
+
if (!mes && !replyContext && imageUrls.length === 0) {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
// 纯图片消息:设置占位符
|
|
270
|
+
if (!mes && imageUrls.length > 0) {
|
|
271
|
+
logVerbose(`[DEBUG bot.groupchat] 纯图片消息: ${imageUrls.length} 张图片`);
|
|
272
|
+
mes = `<media:image>${imageUrls.length > 1 ? ` (${imageUrls.length} images)` : ""}`;
|
|
273
|
+
}
|
|
274
|
+
// If mes is empty but replyContext exists, use a placeholder so the message is not dropped
|
|
275
|
+
if (!mes && replyContext) {
|
|
276
|
+
mes = "(引用回复)";
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Extract sender name from header or fallback to fromuser
|
|
280
|
+
const senderName = String(header?.username ?? header?.nickname ?? msgData.username ?? fromuser);
|
|
281
|
+
|
|
282
|
+
// Delegate to the common message handler (group chat)
|
|
283
|
+
await handleInfoflowMessage({
|
|
284
|
+
cfg,
|
|
285
|
+
event: {
|
|
286
|
+
fromuser,
|
|
287
|
+
mes,
|
|
288
|
+
rawMes,
|
|
289
|
+
chatType: "group",
|
|
290
|
+
groupId: groupid,
|
|
291
|
+
senderName,
|
|
292
|
+
wasMentioned,
|
|
293
|
+
messageId: messageIdStr,
|
|
294
|
+
timestamp,
|
|
295
|
+
bodyItems,
|
|
296
|
+
mentionIds:
|
|
297
|
+
mentionIds.userIds.length > 0 || mentionIds.agentIds.length > 0 ? mentionIds : undefined,
|
|
298
|
+
replyContext,
|
|
299
|
+
imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
|
|
300
|
+
senderImid: senderImidStr, // 传递发送者的 imid
|
|
301
|
+
},
|
|
302
|
+
accountId,
|
|
303
|
+
statusSink,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Resolves route, builds envelope, records session meta, and dispatches reply for one incoming Infoflow message.
|
|
309
|
+
* Called from monitor after webhook request is validated.
|
|
310
|
+
*/
|
|
311
|
+
export async function handleInfoflowMessage(params: HandleInfoflowMessageParams): Promise<void> {
|
|
312
|
+
const { cfg, event, accountId, statusSink } = params;
|
|
313
|
+
const { fromuser, mes, chatType, groupId, senderName } = event;
|
|
314
|
+
|
|
315
|
+
const account = resolveInfoflowAccount({ cfg, accountId });
|
|
316
|
+
const core = getInfoflowRuntime();
|
|
317
|
+
|
|
318
|
+
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
|
+
|
|
322
|
+
// Resolve per-group config for replyMode gating
|
|
323
|
+
const groupCfg = isGroup ? resolveGroupConfig(account, groupId) : undefined;
|
|
324
|
+
|
|
325
|
+
// "ignore" mode: discard immediately, no save, no think, no reply
|
|
326
|
+
if (isGroup && groupCfg?.replyMode === "ignore") {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Resolve route based on chat type
|
|
331
|
+
const route = core.channel.routing.resolveAgentRoute({
|
|
332
|
+
cfg,
|
|
333
|
+
channel: "infoflow",
|
|
334
|
+
accountId: account.accountId,
|
|
335
|
+
peer: {
|
|
336
|
+
kind: isGroup ? "group" : "direct",
|
|
337
|
+
id: peerId,
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
|
|
342
|
+
agentId: route.agentId,
|
|
343
|
+
});
|
|
344
|
+
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
|
|
345
|
+
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
|
346
|
+
storePath,
|
|
347
|
+
sessionKey: route.sessionKey,
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// Build conversation label and from address based on chat type
|
|
351
|
+
const fromLabel = isGroup ? `group:${groupId}` : senderName || fromuser;
|
|
352
|
+
const fromAddress = isGroup ? `infoflow:group:${groupId}` : `infoflow:${fromuser}`;
|
|
353
|
+
const toAddress = isGroup ? `infoflow:group:${groupId}` : `infoflow:${fromuser}`;
|
|
354
|
+
|
|
355
|
+
const body = core.channel.reply.formatAgentEnvelope({
|
|
356
|
+
channel: "Infoflow",
|
|
357
|
+
from: fromLabel,
|
|
358
|
+
timestamp: Date.now(),
|
|
359
|
+
previousTimestamp,
|
|
360
|
+
envelope: envelopeOptions,
|
|
361
|
+
body: mes,
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// Inject accumulated group chat history into the body for context
|
|
365
|
+
const historyKey = isGroup && groupId !== undefined ? String(groupId) : undefined;
|
|
366
|
+
let combinedBody = body;
|
|
367
|
+
if (isGroup && historyKey) {
|
|
368
|
+
combinedBody = buildPendingHistoryContextFromMap({
|
|
369
|
+
historyMap: chatHistories,
|
|
370
|
+
historyKey,
|
|
371
|
+
limit: DEFAULT_GROUP_HISTORY_LIMIT,
|
|
372
|
+
currentMessage: body,
|
|
373
|
+
formatEntry: (entry) =>
|
|
374
|
+
core.channel.reply.formatAgentEnvelope({
|
|
375
|
+
channel: "Infoflow",
|
|
376
|
+
from: entry.sender,
|
|
377
|
+
timestamp: entry.timestamp ?? Date.now(),
|
|
378
|
+
body: entry.body,
|
|
379
|
+
}),
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const inboundHistory =
|
|
384
|
+
isGroup && historyKey
|
|
385
|
+
? (chatHistories.get(historyKey) ?? []).map((e) => ({
|
|
386
|
+
sender: e.sender,
|
|
387
|
+
body: e.body,
|
|
388
|
+
timestamp: e.timestamp,
|
|
389
|
+
}))
|
|
390
|
+
: undefined;
|
|
391
|
+
|
|
392
|
+
// --- Resolve inbound media (images) ---
|
|
393
|
+
const INFOFLOW_MAX_IMAGES = 20;
|
|
394
|
+
const mediaMaxBytes = 30 * 1024 * 1024; // 30MB default, matching Feishu
|
|
395
|
+
const mediaList: Array<{ path: string; contentType?: string }> = [];
|
|
396
|
+
const failReasons: string[] = [];
|
|
397
|
+
|
|
398
|
+
logVerbose(`[DEBUG bot] 图片处理开始: imageUrls数量=${event.imageUrls?.length ?? 0}`);
|
|
399
|
+
if (event.imageUrls && event.imageUrls.length > 0) {
|
|
400
|
+
logVerbose(`[DEBUG bot] 待下载图片URLs: ${JSON.stringify(event.imageUrls)}`);
|
|
401
|
+
// Collect unique hostnames from image URLs for SSRF allowlist.
|
|
402
|
+
// Infoflow image servers (e.g. xp2.im.baidu.com, e4hi.im.baidu.com) resolve to
|
|
403
|
+
// internal IPs on Baidu's network, so they need to be explicitly allowed.
|
|
404
|
+
const allowedHostnames: string[] = [];
|
|
405
|
+
for (const imageUrl of event.imageUrls) {
|
|
406
|
+
try {
|
|
407
|
+
const hostname = new URL(imageUrl).hostname;
|
|
408
|
+
if (hostname && !allowedHostnames.includes(hostname)) {
|
|
409
|
+
allowedHostnames.push(hostname);
|
|
410
|
+
}
|
|
411
|
+
} catch {
|
|
412
|
+
// invalid URL, will fail at fetch time
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const ssrfPolicy = allowedHostnames.length > 0 ? { allowedHostnames } : undefined;
|
|
416
|
+
|
|
417
|
+
const urls = event.imageUrls.slice(0, INFOFLOW_MAX_IMAGES);
|
|
418
|
+
const results = await Promise.allSettled(
|
|
419
|
+
urls.map(async (imageUrl) => {
|
|
420
|
+
logVerbose(`[DEBUG bot] 开始下载图片: ${imageUrl}`);
|
|
421
|
+
const fetched = await core.channel.media.fetchRemoteMedia({
|
|
422
|
+
url: imageUrl,
|
|
423
|
+
maxBytes: mediaMaxBytes,
|
|
424
|
+
ssrfPolicy,
|
|
425
|
+
});
|
|
426
|
+
logVerbose(`[DEBUG bot] 图片下载成功: size=${fetched.buffer.length}, contentType=${fetched.contentType}`);
|
|
427
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
428
|
+
fetched.buffer,
|
|
429
|
+
fetched.contentType ?? undefined,
|
|
430
|
+
"inbound",
|
|
431
|
+
mediaMaxBytes,
|
|
432
|
+
);
|
|
433
|
+
logVerbose(`[infoflow] downloaded image from ${imageUrl}, saved to ${saved.path}`);
|
|
434
|
+
logVerbose(`[DEBUG bot] 图片保存成功: path=${saved.path}`);
|
|
435
|
+
return { path: saved.path, contentType: saved.contentType ?? fetched.contentType };
|
|
436
|
+
}),
|
|
437
|
+
);
|
|
438
|
+
for (const result of results) {
|
|
439
|
+
if (result.status === "fulfilled") {
|
|
440
|
+
mediaList.push(result.value);
|
|
441
|
+
logVerbose(`[DEBUG bot] 图片处理成功: ${result.value.path}`);
|
|
442
|
+
} else {
|
|
443
|
+
const reason = String(result.reason);
|
|
444
|
+
logVerbose(`[infoflow] failed to download image: ${reason}`);
|
|
445
|
+
logVerbose(`[DEBUG bot] 图片下载失败: ${reason}`);
|
|
446
|
+
failReasons.push(reason);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
logVerbose(`[DEBUG bot] 图片处理完成: 成功=${mediaList.length}, 失败=${failReasons.length}`);
|
|
452
|
+
|
|
453
|
+
const mediaPayload = buildAgentMediaPayload(mediaList);
|
|
454
|
+
|
|
455
|
+
// If user sent images but some/all downloads failed, adjust the body to inform the LLM.
|
|
456
|
+
const requestedImageCount = event.imageUrls?.length ?? 0;
|
|
457
|
+
const downloadedImageCount = mediaList.length;
|
|
458
|
+
const failedImageCount = requestedImageCount - downloadedImageCount;
|
|
459
|
+
if (requestedImageCount > 0 && failedImageCount > 0) {
|
|
460
|
+
// Deduplicate error reasons and truncate for readability
|
|
461
|
+
const uniqueReasons = [...new Set(failReasons)];
|
|
462
|
+
const reasonSummary = uniqueReasons.map((r) => r.slice(0, 200)).join("; ");
|
|
463
|
+
|
|
464
|
+
if (downloadedImageCount === 0) {
|
|
465
|
+
// All failed
|
|
466
|
+
const failNote =
|
|
467
|
+
`[The user sent ${requestedImageCount > 1 ? `${requestedImageCount} images` : "an image"}, ` +
|
|
468
|
+
`but failed to load: ${reasonSummary}]`;
|
|
469
|
+
if (combinedBody.includes("<media:image>")) {
|
|
470
|
+
combinedBody = combinedBody.replace(/<media:image>(\s*\(\d+ images\))?/, failNote);
|
|
471
|
+
} else {
|
|
472
|
+
combinedBody += `\n\n${failNote}`;
|
|
473
|
+
}
|
|
474
|
+
} else {
|
|
475
|
+
// Partial failure: some images loaded, some didn't
|
|
476
|
+
const failNote = `[${failedImageCount} of ${requestedImageCount} images failed to load: ${reasonSummary}]`;
|
|
477
|
+
combinedBody += `\n\n${failNote}`;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
482
|
+
Body: combinedBody,
|
|
483
|
+
RawBody: event.rawMes ?? mes,
|
|
484
|
+
CommandBody: mes,
|
|
485
|
+
From: fromAddress,
|
|
486
|
+
To: toAddress,
|
|
487
|
+
SessionKey: route.sessionKey,
|
|
488
|
+
AccountId: route.accountId,
|
|
489
|
+
ChatType: chatType,
|
|
490
|
+
ConversationLabel: fromLabel,
|
|
491
|
+
GroupSubject: isGroup ? `group:${groupId}` : undefined,
|
|
492
|
+
SenderName: senderName || fromuser,
|
|
493
|
+
SenderId: fromuser,
|
|
494
|
+
Provider: "infoflow",
|
|
495
|
+
Surface: "infoflow",
|
|
496
|
+
MessageSid: event.messageId ?? `${Date.now()}`,
|
|
497
|
+
Timestamp: event.timestamp ?? Date.now(),
|
|
498
|
+
OriginatingChannel: "infoflow",
|
|
499
|
+
OriginatingTo: toAddress,
|
|
500
|
+
WasMentioned: isGroup ? event.wasMentioned : undefined,
|
|
501
|
+
ReplyToBody: event.replyContext ? event.replyContext.join("\n---\n") : undefined,
|
|
502
|
+
InboundHistory: inboundHistory,
|
|
503
|
+
CommandAuthorized: true,
|
|
504
|
+
...mediaPayload,
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
// Record session using recordInboundSession for proper session tracking
|
|
508
|
+
await core.channel.session.recordInboundSession({
|
|
509
|
+
storePath,
|
|
510
|
+
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
|
511
|
+
ctx: ctxPayload,
|
|
512
|
+
onRecordError: (err) => {
|
|
513
|
+
getInfoflowBotLog().error(
|
|
514
|
+
`[infoflow] failed updating session meta (sessionKey=${route.sessionKey}, accountId=${accountId}): ${formatInfoflowError(err)}`,
|
|
515
|
+
);
|
|
516
|
+
},
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
// Reply mode gating for group messages
|
|
520
|
+
// Session is already recorded above for context history
|
|
521
|
+
let triggerReason = "direct-message";
|
|
522
|
+
if (isGroup && groupCfg) {
|
|
523
|
+
const { replyMode } = groupCfg;
|
|
524
|
+
const groupIdStr = groupId !== undefined ? String(groupId) : undefined;
|
|
525
|
+
|
|
526
|
+
// "record" mode: save to session only, no think, no reply
|
|
527
|
+
if (replyMode === "record") {
|
|
528
|
+
if (groupIdStr) {
|
|
529
|
+
logVerbose(
|
|
530
|
+
`[infoflow:bot] pending: from=${fromuser}, group=${groupId}, reason=record-mode`,
|
|
531
|
+
);
|
|
532
|
+
recordPendingHistoryEntryIfEnabled({
|
|
533
|
+
historyMap: chatHistories,
|
|
534
|
+
historyKey: groupIdStr,
|
|
535
|
+
entry: { sender: senderName || fromuser, body: mes, timestamp: Date.now() },
|
|
536
|
+
limit: DEFAULT_GROUP_HISTORY_LIMIT,
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const canDetectMention = Boolean(account.config.robotName);
|
|
543
|
+
const wasMentioned = event.wasMentioned === true;
|
|
544
|
+
|
|
545
|
+
if (replyMode === "mention-only") {
|
|
546
|
+
// Only reply if bot was @mentioned
|
|
547
|
+
const shouldReply = canDetectMention && wasMentioned;
|
|
548
|
+
if (shouldReply) {
|
|
549
|
+
triggerReason = "bot-mentioned";
|
|
550
|
+
} else {
|
|
551
|
+
// Check follow-up window: if bot recently replied, allow LLM to decide
|
|
552
|
+
if (
|
|
553
|
+
groupCfg.followUp &&
|
|
554
|
+
groupIdStr &&
|
|
555
|
+
isWithinFollowUpWindow(groupIdStr, groupCfg.followUpWindow)
|
|
556
|
+
) {
|
|
557
|
+
triggerReason = "followUp";
|
|
558
|
+
ctxPayload.GroupSystemPrompt = buildFollowUpPrompt();
|
|
559
|
+
} else {
|
|
560
|
+
if (groupIdStr) {
|
|
561
|
+
logVerbose(
|
|
562
|
+
`[infoflow:bot] pending: from=${fromuser}, group=${groupId}, reason=mention-only-not-mentioned`,
|
|
563
|
+
);
|
|
564
|
+
recordPendingHistoryEntryIfEnabled({
|
|
565
|
+
historyMap: chatHistories,
|
|
566
|
+
historyKey: groupIdStr,
|
|
567
|
+
entry: { sender: senderName || fromuser, body: mes, timestamp: Date.now() },
|
|
568
|
+
limit: DEFAULT_GROUP_HISTORY_LIMIT,
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
} else if (replyMode === "mention-and-watch") {
|
|
575
|
+
// Reply if bot @mentioned, or if watched person @mentioned, or follow-up
|
|
576
|
+
const botMentioned = canDetectMention && wasMentioned;
|
|
577
|
+
if (botMentioned) {
|
|
578
|
+
triggerReason = "bot-mentioned";
|
|
579
|
+
} else {
|
|
580
|
+
// Check watch-mention
|
|
581
|
+
const watchMentions = groupCfg.watchMentions;
|
|
582
|
+
const matchedWatchId =
|
|
583
|
+
watchMentions.length > 0 && event.bodyItems
|
|
584
|
+
? checkWatchMentioned(event.bodyItems, watchMentions)
|
|
585
|
+
: undefined;
|
|
586
|
+
|
|
587
|
+
if (matchedWatchId) {
|
|
588
|
+
triggerReason = `watchMentions(${matchedWatchId})`;
|
|
589
|
+
// Watch-mention triggered: instruct agent to reply only if confident
|
|
590
|
+
ctxPayload.GroupSystemPrompt = buildWatchMentionPrompt(matchedWatchId);
|
|
591
|
+
} else if (groupCfg.watchRegex && checkWatchRegex(mes, groupCfg.watchRegex)) {
|
|
592
|
+
triggerReason = `watchRegex(${groupCfg.watchRegex})`;
|
|
593
|
+
// Watch-content triggered: message matched configured regex pattern
|
|
594
|
+
ctxPayload.GroupSystemPrompt = buildWatchRegexPrompt(groupCfg.watchRegex);
|
|
595
|
+
} else if (
|
|
596
|
+
groupCfg.followUp &&
|
|
597
|
+
groupIdStr &&
|
|
598
|
+
isWithinFollowUpWindow(groupIdStr, groupCfg.followUpWindow)
|
|
599
|
+
) {
|
|
600
|
+
triggerReason = "followUp";
|
|
601
|
+
// Follow-up window: let LLM decide if this is a follow-up
|
|
602
|
+
ctxPayload.GroupSystemPrompt = buildFollowUpPrompt();
|
|
603
|
+
} else {
|
|
604
|
+
if (groupIdStr) {
|
|
605
|
+
logVerbose(
|
|
606
|
+
`[infoflow:bot] pending: from=${fromuser}, group=${groupId}, reason=mention-and-watch-no-trigger`,
|
|
607
|
+
);
|
|
608
|
+
recordPendingHistoryEntryIfEnabled({
|
|
609
|
+
historyMap: chatHistories,
|
|
610
|
+
historyKey: groupIdStr,
|
|
611
|
+
entry: { sender: senderName || fromuser, body: mes, timestamp: Date.now() },
|
|
612
|
+
limit: DEFAULT_GROUP_HISTORY_LIMIT,
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
} else if (replyMode === "proactive") {
|
|
619
|
+
// Always think and potentially reply
|
|
620
|
+
const botMentioned = canDetectMention && wasMentioned;
|
|
621
|
+
if (botMentioned) {
|
|
622
|
+
triggerReason = "bot-mentioned";
|
|
623
|
+
} else {
|
|
624
|
+
// Check watch-mention first (higher priority prompt)
|
|
625
|
+
const watchMentions = groupCfg.watchMentions;
|
|
626
|
+
const matchedWatchId =
|
|
627
|
+
watchMentions.length > 0 && event.bodyItems
|
|
628
|
+
? checkWatchMentioned(event.bodyItems, watchMentions)
|
|
629
|
+
: undefined;
|
|
630
|
+
if (matchedWatchId) {
|
|
631
|
+
triggerReason = `watchMentions(${matchedWatchId})`;
|
|
632
|
+
ctxPayload.GroupSystemPrompt = buildWatchMentionPrompt(matchedWatchId);
|
|
633
|
+
} else {
|
|
634
|
+
triggerReason = "proactive";
|
|
635
|
+
ctxPayload.GroupSystemPrompt = buildProactivePrompt();
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Inject per-group systemPrompt (append, don't replace)
|
|
641
|
+
if (groupCfg.systemPrompt) {
|
|
642
|
+
const existing = ctxPayload.GroupSystemPrompt ?? "";
|
|
643
|
+
ctxPayload.GroupSystemPrompt = existing
|
|
644
|
+
? `${existing}\n\n---\n\n${groupCfg.systemPrompt}`
|
|
645
|
+
: groupCfg.systemPrompt;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Build unified target: "group:<id>" for group chat, username for private chat
|
|
650
|
+
const to = isGroup && groupId !== undefined ? `group:${groupId}` : fromuser;
|
|
651
|
+
|
|
652
|
+
// Provide mention context to the LLM so it can decide who to @mention
|
|
653
|
+
if (isGroup && event.mentionIds) {
|
|
654
|
+
const parts: string[] = [];
|
|
655
|
+
if (event.mentionIds.userIds.length > 0) {
|
|
656
|
+
parts.push(`User IDs: ${event.mentionIds.userIds.join(", ")}`);
|
|
657
|
+
}
|
|
658
|
+
if (event.mentionIds.agentIds.length > 0) {
|
|
659
|
+
parts.push(`Bot IDs: ${event.mentionIds.agentIds.join(", ")}`);
|
|
660
|
+
}
|
|
661
|
+
if (parts.length > 0) {
|
|
662
|
+
ctxPayload.Body += `\n\n[System: @mentioned in group: ${parts.join("; ")}. To @mention someone in your reply, use the @id format]`;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
logVerbose(
|
|
667
|
+
`[infoflow:bot] dispatching to LLM: from=${fromuser}, group=${groupId ?? "N/A"}, trigger=${triggerReason}, replyMode=${groupCfg?.replyMode ?? "N/A"}`,
|
|
668
|
+
);
|
|
669
|
+
|
|
670
|
+
// Debug: Log reply-to context
|
|
671
|
+
logVerbose(`[DEBUG bot] event.messageId=${event.messageId}, event.senderImid=${event.senderImid}, isGroup=${isGroup}, mes=${mes.slice(0, 50)}`);
|
|
672
|
+
if (!event.messageId) {
|
|
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
|
+
}
|
|
681
|
+
|
|
682
|
+
// Send "processing" hint if LLM takes longer than processingHintDelay seconds
|
|
683
|
+
// (default: 5s). Gives users feedback without spamming fast responses.
|
|
684
|
+
const processingHintEnabled = account.config.processingHint !== false;
|
|
685
|
+
let cancelProcessingHint: (() => void) | undefined;
|
|
686
|
+
let hintWasSent = false;
|
|
687
|
+
const dispatchStartTime = Date.now();
|
|
688
|
+
if (processingHintEnabled) {
|
|
689
|
+
const delayMs = (account.config.processingHintDelay ?? 5) * 1000;
|
|
690
|
+
const processingReplyTo =
|
|
691
|
+
isGroup && event.messageId
|
|
692
|
+
? {
|
|
693
|
+
messageid: event.messageId,
|
|
694
|
+
preview: mes ? (mes.length > 100 ? mes.slice(0, 100) + "..." : mes) : "",
|
|
695
|
+
...(event.senderImid ? { imid: event.senderImid } : {}),
|
|
696
|
+
replytype: "2" as const,
|
|
697
|
+
}
|
|
698
|
+
: undefined;
|
|
699
|
+
let cancelled = false;
|
|
700
|
+
cancelProcessingHint = () => { cancelled = true; };
|
|
701
|
+
setTimeout(() => {
|
|
702
|
+
if (cancelled) return;
|
|
703
|
+
hintWasSent = true;
|
|
704
|
+
sendInfoflowMessage({
|
|
705
|
+
cfg,
|
|
706
|
+
to,
|
|
707
|
+
contents: [{ type: "text", content: "⏳ 处理中..." }],
|
|
708
|
+
accountId: account.accountId,
|
|
709
|
+
replyTo: processingReplyTo,
|
|
710
|
+
}).catch(() => {});
|
|
711
|
+
}, delayMs);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const { dispatcherOptions, replyOptions } = createInfoflowReplyDispatcher({
|
|
715
|
+
cfg,
|
|
716
|
+
agentId: route.agentId,
|
|
717
|
+
accountId: account.accountId,
|
|
718
|
+
to,
|
|
719
|
+
statusSink,
|
|
720
|
+
// @mention the sender back when bot was directly @mentioned in a group
|
|
721
|
+
atOptions: isGroup && event.wasMentioned ? { atUserIds: [fromuser] } : undefined,
|
|
722
|
+
// Pass mention IDs for LLM-driven @mention resolution in outbound text
|
|
723
|
+
mentionIds: isGroup ? event.mentionIds : undefined,
|
|
724
|
+
// Pass inbound messageId for outbound reply-to (group only)
|
|
725
|
+
replyToMessageId: isGroup ? event.messageId : undefined,
|
|
726
|
+
replyToPreview: isGroup ? mes : undefined,
|
|
727
|
+
replyToImid: isGroup ? event.senderImid : undefined,
|
|
728
|
+
// Message format: per-chat-type config, falling back to "text"
|
|
729
|
+
messageFormat: isGroup
|
|
730
|
+
? (account.config.groupMessageFormat ?? "text")
|
|
731
|
+
: (account.config.dmMessageFormat ?? "text"),
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
// Cancel processing hint the moment the first real message starts being delivered,
|
|
735
|
+
// so the hint never appears after the bot's actual reply.
|
|
736
|
+
if (cancelProcessingHint) {
|
|
737
|
+
const originalDeliver = dispatcherOptions.deliver;
|
|
738
|
+
let hintCancelledOnDeliver = false;
|
|
739
|
+
dispatcherOptions.deliver = async (payload: Parameters<typeof originalDeliver>[0]) => {
|
|
740
|
+
if (!hintCancelledOnDeliver) {
|
|
741
|
+
hintCancelledOnDeliver = true;
|
|
742
|
+
cancelProcessingHint!();
|
|
743
|
+
}
|
|
744
|
+
return originalDeliver(payload);
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
const dispatchResult = await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
749
|
+
ctx: ctxPayload,
|
|
750
|
+
cfg,
|
|
751
|
+
dispatcherOptions,
|
|
752
|
+
replyOptions,
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
// Fallback cancel: in case deliver was never called (e.g. empty response)
|
|
756
|
+
cancelProcessingHint?.();
|
|
757
|
+
|
|
758
|
+
// If hint was shown to the user, send "搞定" so they know the task is done
|
|
759
|
+
if (hintWasSent) {
|
|
760
|
+
const elapsedSec = Math.round((Date.now() - dispatchStartTime) / 1000);
|
|
761
|
+
sendInfoflowMessage({
|
|
762
|
+
cfg,
|
|
763
|
+
to,
|
|
764
|
+
contents: [{ type: "text", content: `任务完成 ✨ (${elapsedSec}s)` }],
|
|
765
|
+
accountId: account.accountId,
|
|
766
|
+
}).catch(() => {});
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const didReply = dispatchResult?.queuedFinal ?? false;
|
|
770
|
+
|
|
771
|
+
// Clear accumulated history after dispatch (it's now in the session transcript)
|
|
772
|
+
if (isGroup && historyKey) {
|
|
773
|
+
clearHistoryEntriesIfEnabled({
|
|
774
|
+
historyMap: chatHistories,
|
|
775
|
+
historyKey,
|
|
776
|
+
limit: DEFAULT_GROUP_HISTORY_LIMIT,
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Record bot reply timestamp for follow-up window tracking
|
|
781
|
+
if (didReply && isGroup && groupId !== undefined) {
|
|
782
|
+
recordGroupReply(String(groupId));
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
logVerbose(
|
|
786
|
+
`[infoflow] dispatch complete: ${chatType} from ${fromuser}, replied=${didReply}, finalCount=${dispatchResult?.counts.final ?? 0}, hasGroupSystemPrompt=${Boolean(ctxPayload.GroupSystemPrompt)}`,
|
|
787
|
+
);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// ---------------------------------------------------------------------------
|
|
791
|
+
// Test-only exports (@internal)
|
|
792
|
+
// ---------------------------------------------------------------------------
|
|
793
|
+
|
|
794
|
+
/** @internal — Check if bot was mentioned in message body. Only exported for tests. */
|
|
795
|
+
export { checkBotMentioned as _checkBotMentioned } from "../security/group-policy.js";
|
|
796
|
+
|
|
797
|
+
/** @internal — Check if any watch-list name was @mentioned. Only exported for tests. */
|
|
798
|
+
export { checkWatchMentioned as _checkWatchMentioned } from "../security/group-policy.js";
|
|
799
|
+
|
|
800
|
+
/** @internal — Extract non-bot mention IDs. Only exported for tests. */
|
|
801
|
+
export { extractMentionIds as _extractMentionIds } from "../security/group-policy.js";
|