@chbo297/infoflow 2026.3.2 → 2026.3.6
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 +426 -15
- package/index.ts +6 -1
- package/openclaw.plugin.json +14 -2
- package/package.json +2 -2
- package/src/accounts.ts +3 -0
- package/src/actions.ts +350 -4
- package/src/bot.ts +357 -14
- package/src/channel.ts +64 -23
- package/src/infoflow-req-parse.ts +0 -1
- package/src/media.ts +367 -0
- package/src/monitor.ts +1 -1
- package/src/reply-dispatcher.ts +157 -67
- package/src/send.ts +497 -57
- package/src/sent-message-store.ts +238 -0
- package/src/types.ts +27 -2
package/src/bot.ts
CHANGED
|
@@ -1,11 +1,27 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildPendingHistoryContextFromMap,
|
|
3
|
+
clearHistoryEntriesIfEnabled,
|
|
4
|
+
DEFAULT_GROUP_HISTORY_LIMIT,
|
|
5
|
+
type HistoryEntry,
|
|
6
|
+
recordPendingHistoryEntryIfEnabled,
|
|
7
|
+
buildAgentMediaPayload,
|
|
8
|
+
type OpenClawConfig,
|
|
9
|
+
type ReplyPayload,
|
|
10
|
+
} from "openclaw/plugin-sdk";
|
|
1
11
|
import { resolveInfoflowAccount } from "./accounts.js";
|
|
2
12
|
import { getInfoflowBotLog, formatInfoflowError, logVerbose } from "./logging.js";
|
|
3
13
|
import { createInfoflowReplyDispatcher } from "./reply-dispatcher.js";
|
|
4
14
|
import { getInfoflowRuntime } from "./runtime.js";
|
|
15
|
+
import {
|
|
16
|
+
sendInfoflowMessage,
|
|
17
|
+
recallInfoflowGroupMessage,
|
|
18
|
+
recallInfoflowPrivateMessage,
|
|
19
|
+
} from "./send.js";
|
|
5
20
|
import type {
|
|
6
21
|
InfoflowChatType,
|
|
7
22
|
InfoflowMessageEvent,
|
|
8
23
|
InfoflowMentionIds,
|
|
24
|
+
InfoflowOutboundReply,
|
|
9
25
|
InfoflowReplyMode,
|
|
10
26
|
InfoflowGroupConfig,
|
|
11
27
|
HandleInfoflowMessageParams,
|
|
@@ -36,6 +52,8 @@ type InfoflowBodyItem = {
|
|
|
36
52
|
name?: string;
|
|
37
53
|
/** 人类用户 AT 时有此字段(uuap name),与 robotid 互斥 */
|
|
38
54
|
userid?: string;
|
|
55
|
+
/** IMAGE 类型 body item 的图片下载地址 */
|
|
56
|
+
downloadurl?: string;
|
|
39
57
|
};
|
|
40
58
|
|
|
41
59
|
/**
|
|
@@ -219,6 +237,9 @@ function buildProactivePrompt(): string {
|
|
|
219
237
|
/** In-memory map tracking bot's last reply timestamp per group */
|
|
220
238
|
const groupLastReplyMap = new Map<string, number>();
|
|
221
239
|
|
|
240
|
+
/** In-memory map accumulating recent group messages for context injection when bot is @mentioned */
|
|
241
|
+
const chatHistories = new Map<string, HistoryEntry[]>();
|
|
242
|
+
|
|
222
243
|
/** Record that the bot replied to a group (called after successful send) */
|
|
223
244
|
export function recordGroupReply(groupId: string): void {
|
|
224
245
|
groupLastReplyMap.set(groupId, Date.now());
|
|
@@ -241,6 +262,7 @@ type ResolvedGroupConfig = {
|
|
|
241
262
|
followUpWindow: number;
|
|
242
263
|
watchMentions: string[];
|
|
243
264
|
systemPrompt?: string;
|
|
265
|
+
thinkingIndicator: boolean;
|
|
244
266
|
};
|
|
245
267
|
|
|
246
268
|
/** Infer replyMode from legacy requireMention + watchMentions fields */
|
|
@@ -265,9 +287,104 @@ function resolveGroupConfig(
|
|
|
265
287
|
followUpWindow: groupCfg?.followUpWindow ?? account.config.followUpWindow ?? 300,
|
|
266
288
|
watchMentions: groupCfg?.watchMentions ?? account.config.watchMentions ?? [],
|
|
267
289
|
systemPrompt: groupCfg?.systemPrompt,
|
|
290
|
+
thinkingIndicator: groupCfg?.thinkingIndicator ?? account.config.thinkingIndicator ?? true,
|
|
268
291
|
};
|
|
269
292
|
}
|
|
270
293
|
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
// Thinking indicator (收到🤔...)
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
type ThinkingIndicatorHandle = {
|
|
299
|
+
messageid: string;
|
|
300
|
+
msgseqid: string;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Sends a "收到🤔..." thinking indicator message.
|
|
305
|
+
* Returns message IDs needed for recall, or undefined on failure.
|
|
306
|
+
*/
|
|
307
|
+
async function sendThinkingIndicator(params: {
|
|
308
|
+
cfg: OpenClawConfig;
|
|
309
|
+
to: string;
|
|
310
|
+
accountId: string;
|
|
311
|
+
replyTo?: InfoflowOutboundReply;
|
|
312
|
+
}): Promise<ThinkingIndicatorHandle | undefined> {
|
|
313
|
+
const { cfg, to, accountId, replyTo } = params;
|
|
314
|
+
try {
|
|
315
|
+
const result = await sendInfoflowMessage({
|
|
316
|
+
cfg,
|
|
317
|
+
to,
|
|
318
|
+
contents: [{ type: "text", content: "收到🤔..." }],
|
|
319
|
+
accountId,
|
|
320
|
+
replyTo,
|
|
321
|
+
});
|
|
322
|
+
if (result.ok && result.messageId) {
|
|
323
|
+
logVerbose(
|
|
324
|
+
`[infoflow] thinking indicator sent: to=${to}, messageId=${result.messageId}, msgseqid=${result.msgseqid ?? "n/a"}`,
|
|
325
|
+
);
|
|
326
|
+
return { messageid: result.messageId, msgseqid: result.msgseqid ?? "" };
|
|
327
|
+
}
|
|
328
|
+
if (!result.ok) {
|
|
329
|
+
logVerbose(`[infoflow] thinking indicator send failed: ${result.error}`);
|
|
330
|
+
}
|
|
331
|
+
return undefined;
|
|
332
|
+
} catch (err) {
|
|
333
|
+
logVerbose(`[infoflow] thinking indicator exception: ${formatInfoflowError(err)}`);
|
|
334
|
+
return undefined;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Recalls a previously sent thinking indicator (group or private).
|
|
340
|
+
* Silently swallows errors to avoid disrupting the reply flow.
|
|
341
|
+
*/
|
|
342
|
+
async function recallThinkingIndicator(params: {
|
|
343
|
+
cfg: OpenClawConfig;
|
|
344
|
+
accountId: string;
|
|
345
|
+
handle: ThinkingIndicatorHandle;
|
|
346
|
+
groupId?: number;
|
|
347
|
+
isPrivate?: boolean;
|
|
348
|
+
}): Promise<void> {
|
|
349
|
+
const { cfg, accountId, handle, groupId, isPrivate } = params;
|
|
350
|
+
try {
|
|
351
|
+
const account = resolveInfoflowAccount({ cfg, accountId });
|
|
352
|
+
if (isPrivate) {
|
|
353
|
+
const appAgentId = account.config.appAgentId;
|
|
354
|
+
if (!appAgentId) {
|
|
355
|
+
logVerbose(
|
|
356
|
+
`[infoflow] thinking indicator private recall skipped: appAgentId not configured`,
|
|
357
|
+
);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
const result = await recallInfoflowPrivateMessage({
|
|
361
|
+
account,
|
|
362
|
+
msgkey: handle.messageid,
|
|
363
|
+
appAgentId,
|
|
364
|
+
});
|
|
365
|
+
if (result.ok) {
|
|
366
|
+
logVerbose(`[infoflow] thinking indicator recalled (private)`);
|
|
367
|
+
} else {
|
|
368
|
+
logVerbose(`[infoflow] thinking indicator private recall failed: ${result.error}`);
|
|
369
|
+
}
|
|
370
|
+
} else if (groupId !== undefined) {
|
|
371
|
+
const result = await recallInfoflowGroupMessage({
|
|
372
|
+
account,
|
|
373
|
+
groupId,
|
|
374
|
+
messageid: handle.messageid,
|
|
375
|
+
msgseqid: handle.msgseqid,
|
|
376
|
+
});
|
|
377
|
+
if (result.ok) {
|
|
378
|
+
logVerbose(`[infoflow] thinking indicator recalled: groupId=${groupId}`);
|
|
379
|
+
} else {
|
|
380
|
+
logVerbose(`[infoflow] thinking indicator recall failed: ${result.error}`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
} catch (err) {
|
|
384
|
+
logVerbose(`[infoflow] thinking indicator recall exception: ${formatInfoflowError(err)}`);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
271
388
|
/**
|
|
272
389
|
* Handles an incoming private chat message from Infoflow.
|
|
273
390
|
* Receives the raw decrypted message data and dispatches to the agent.
|
|
@@ -290,24 +407,39 @@ export async function handlePrivateChatMessage(params: HandlePrivateChatParams):
|
|
|
290
407
|
const createTime = msgData.CreateTime ?? msgData.createtime;
|
|
291
408
|
const timestamp = createTime != null ? Number(createTime) * 1000 : Date.now();
|
|
292
409
|
|
|
410
|
+
// Detect image messages: MsgType=image with PicUrl
|
|
411
|
+
const msgType = String(msgData.MsgType ?? msgData.msgtype ?? "");
|
|
412
|
+
const picUrl = String(msgData.PicUrl ?? msgData.picurl ?? "");
|
|
413
|
+
const imageUrls: string[] = [];
|
|
414
|
+
if (msgType === "image" && picUrl.trim()) {
|
|
415
|
+
imageUrls.push(picUrl.trim());
|
|
416
|
+
}
|
|
417
|
+
|
|
293
418
|
logVerbose(
|
|
294
|
-
`[infoflow] private chat: fromuser=${fromuser}, senderName=${senderName}, raw msgData: ${JSON.stringify(msgData)}`,
|
|
419
|
+
`[infoflow] private chat: fromuser=${fromuser}, senderName=${senderName}, mes=${mes}, msgType=${msgType}, raw msgData: ${JSON.stringify(msgData)}`,
|
|
295
420
|
);
|
|
296
421
|
|
|
297
|
-
if (!fromuser || !mes.trim()) {
|
|
422
|
+
if (!fromuser || (!mes.trim() && imageUrls.length === 0)) {
|
|
298
423
|
return;
|
|
299
424
|
}
|
|
300
425
|
|
|
426
|
+
// For image-only messages (no text), use placeholder
|
|
427
|
+
let effectiveMes = mes.trim();
|
|
428
|
+
if (!effectiveMes && imageUrls.length > 0) {
|
|
429
|
+
effectiveMes = "<media:image>";
|
|
430
|
+
}
|
|
431
|
+
|
|
301
432
|
// Delegate to the common message handler (private chat)
|
|
302
433
|
await handleInfoflowMessage({
|
|
303
434
|
cfg,
|
|
304
435
|
event: {
|
|
305
436
|
fromuser,
|
|
306
|
-
mes,
|
|
437
|
+
mes: effectiveMes,
|
|
307
438
|
chatType: "direct",
|
|
308
439
|
senderName,
|
|
309
440
|
messageId: messageIdStr,
|
|
310
441
|
timestamp,
|
|
442
|
+
imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
|
|
311
443
|
},
|
|
312
444
|
accountId,
|
|
313
445
|
statusSink,
|
|
@@ -365,6 +497,7 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
|
|
|
365
497
|
let textContent = "";
|
|
366
498
|
let rawTextContent = "";
|
|
367
499
|
const replyContextItems: string[] = [];
|
|
500
|
+
const imageUrls: string[] = [];
|
|
368
501
|
if (Array.isArray(bodyItems)) {
|
|
369
502
|
for (const item of bodyItems) {
|
|
370
503
|
if (item.type === "replyData") {
|
|
@@ -388,6 +521,12 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
|
|
|
388
521
|
if (name) {
|
|
389
522
|
rawTextContent += `@${name} `;
|
|
390
523
|
}
|
|
524
|
+
} else if (item.type === "IMAGE") {
|
|
525
|
+
// 提取图片下载地址
|
|
526
|
+
const url = item.downloadurl;
|
|
527
|
+
if (typeof url === "string" && url.trim()) {
|
|
528
|
+
imageUrls.push(url.trim());
|
|
529
|
+
}
|
|
391
530
|
}
|
|
392
531
|
}
|
|
393
532
|
}
|
|
@@ -397,9 +536,13 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
|
|
|
397
536
|
|
|
398
537
|
const replyContext = replyContextItems.length > 0 ? replyContextItems : undefined;
|
|
399
538
|
|
|
400
|
-
if (!mes && !replyContext) {
|
|
539
|
+
if (!mes && !replyContext && imageUrls.length === 0) {
|
|
401
540
|
return;
|
|
402
541
|
}
|
|
542
|
+
// 纯图片消息:设置占位符
|
|
543
|
+
if (!mes && imageUrls.length > 0) {
|
|
544
|
+
mes = `<media:image>${imageUrls.length > 1 ? ` (${imageUrls.length} images)` : ""}`;
|
|
545
|
+
}
|
|
403
546
|
// If mes is empty but replyContext exists, use a placeholder so the message is not dropped
|
|
404
547
|
if (!mes && replyContext) {
|
|
405
548
|
mes = "(引用回复)";
|
|
@@ -425,6 +568,7 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
|
|
|
425
568
|
mentionIds:
|
|
426
569
|
mentionIds.userIds.length > 0 || mentionIds.agentIds.length > 0 ? mentionIds : undefined,
|
|
427
570
|
replyContext,
|
|
571
|
+
imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
|
|
428
572
|
},
|
|
429
573
|
accountId,
|
|
430
574
|
statusSink,
|
|
@@ -477,7 +621,7 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
|
|
|
477
621
|
// Build conversation label and from address based on chat type
|
|
478
622
|
const fromLabel = isGroup ? `group:${groupId}` : senderName || fromuser;
|
|
479
623
|
const fromAddress = isGroup ? `infoflow:group:${groupId}` : `infoflow:${fromuser}`;
|
|
480
|
-
const toAddress = isGroup ? `infoflow:${groupId}` : `infoflow:${
|
|
624
|
+
const toAddress = isGroup ? `infoflow:group:${groupId}` : `infoflow:${fromuser}`;
|
|
481
625
|
|
|
482
626
|
const body = core.channel.reply.formatAgentEnvelope({
|
|
483
627
|
channel: "Infoflow",
|
|
@@ -488,8 +632,116 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
|
|
|
488
632
|
body: mes,
|
|
489
633
|
});
|
|
490
634
|
|
|
635
|
+
// Inject accumulated group chat history into the body for context
|
|
636
|
+
const historyKey = isGroup && groupId !== undefined ? String(groupId) : undefined;
|
|
637
|
+
let combinedBody = body;
|
|
638
|
+
if (isGroup && historyKey) {
|
|
639
|
+
combinedBody = buildPendingHistoryContextFromMap({
|
|
640
|
+
historyMap: chatHistories,
|
|
641
|
+
historyKey,
|
|
642
|
+
limit: DEFAULT_GROUP_HISTORY_LIMIT,
|
|
643
|
+
currentMessage: body,
|
|
644
|
+
formatEntry: (entry) =>
|
|
645
|
+
core.channel.reply.formatAgentEnvelope({
|
|
646
|
+
channel: "Infoflow",
|
|
647
|
+
from: entry.sender,
|
|
648
|
+
timestamp: entry.timestamp ?? Date.now(),
|
|
649
|
+
body: entry.body,
|
|
650
|
+
}),
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const inboundHistory =
|
|
655
|
+
isGroup && historyKey
|
|
656
|
+
? (chatHistories.get(historyKey) ?? []).map((e) => ({
|
|
657
|
+
sender: e.sender,
|
|
658
|
+
body: e.body,
|
|
659
|
+
timestamp: e.timestamp,
|
|
660
|
+
}))
|
|
661
|
+
: undefined;
|
|
662
|
+
|
|
663
|
+
// --- Resolve inbound media (images) ---
|
|
664
|
+
const INFOFLOW_MAX_IMAGES = 20;
|
|
665
|
+
const mediaMaxBytes = 30 * 1024 * 1024; // 30MB default, matching Feishu
|
|
666
|
+
const mediaList: Array<{ path: string; contentType?: string }> = [];
|
|
667
|
+
const failReasons: string[] = [];
|
|
668
|
+
|
|
669
|
+
if (event.imageUrls && event.imageUrls.length > 0) {
|
|
670
|
+
// Collect unique hostnames from image URLs for SSRF allowlist.
|
|
671
|
+
// Infoflow image servers (e.g. xp2.im.baidu.com, e4hi.im.baidu.com) resolve to
|
|
672
|
+
// internal IPs on Baidu's network, so they need to be explicitly allowed.
|
|
673
|
+
const allowedHostnames: string[] = [];
|
|
674
|
+
for (const imageUrl of event.imageUrls) {
|
|
675
|
+
try {
|
|
676
|
+
const hostname = new URL(imageUrl).hostname;
|
|
677
|
+
if (hostname && !allowedHostnames.includes(hostname)) {
|
|
678
|
+
allowedHostnames.push(hostname);
|
|
679
|
+
}
|
|
680
|
+
} catch {
|
|
681
|
+
// invalid URL, will fail at fetch time
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
const ssrfPolicy = allowedHostnames.length > 0 ? { allowedHostnames } : undefined;
|
|
685
|
+
|
|
686
|
+
const urls = event.imageUrls.slice(0, INFOFLOW_MAX_IMAGES);
|
|
687
|
+
const results = await Promise.allSettled(
|
|
688
|
+
urls.map(async (imageUrl) => {
|
|
689
|
+
const fetched = await core.channel.media.fetchRemoteMedia({
|
|
690
|
+
url: imageUrl,
|
|
691
|
+
maxBytes: mediaMaxBytes,
|
|
692
|
+
ssrfPolicy,
|
|
693
|
+
});
|
|
694
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
695
|
+
fetched.buffer,
|
|
696
|
+
fetched.contentType ?? undefined,
|
|
697
|
+
"inbound",
|
|
698
|
+
mediaMaxBytes,
|
|
699
|
+
);
|
|
700
|
+
logVerbose(`[infoflow] downloaded image from ${imageUrl}, saved to ${saved.path}`);
|
|
701
|
+
return { path: saved.path, contentType: saved.contentType ?? fetched.contentType };
|
|
702
|
+
}),
|
|
703
|
+
);
|
|
704
|
+
for (const result of results) {
|
|
705
|
+
if (result.status === "fulfilled") {
|
|
706
|
+
mediaList.push(result.value);
|
|
707
|
+
} else {
|
|
708
|
+
const reason = String(result.reason);
|
|
709
|
+
logVerbose(`[infoflow] failed to download image: ${reason}`);
|
|
710
|
+
failReasons.push(reason);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const mediaPayload = buildAgentMediaPayload(mediaList);
|
|
716
|
+
|
|
717
|
+
// If user sent images but some/all downloads failed, adjust the body to inform the LLM.
|
|
718
|
+
const requestedImageCount = event.imageUrls?.length ?? 0;
|
|
719
|
+
const downloadedImageCount = mediaList.length;
|
|
720
|
+
const failedImageCount = requestedImageCount - downloadedImageCount;
|
|
721
|
+
if (requestedImageCount > 0 && failedImageCount > 0) {
|
|
722
|
+
// Deduplicate error reasons and truncate for readability
|
|
723
|
+
const uniqueReasons = [...new Set(failReasons)];
|
|
724
|
+
const reasonSummary = uniqueReasons.map((r) => r.slice(0, 200)).join("; ");
|
|
725
|
+
|
|
726
|
+
if (downloadedImageCount === 0) {
|
|
727
|
+
// All failed
|
|
728
|
+
const failNote =
|
|
729
|
+
`[The user sent ${requestedImageCount > 1 ? `${requestedImageCount} images` : "an image"}, ` +
|
|
730
|
+
`but failed to load: ${reasonSummary}]`;
|
|
731
|
+
if (combinedBody.includes("<media:image>")) {
|
|
732
|
+
combinedBody = combinedBody.replace(/<media:image>(\s*\(\d+ images\))?/, failNote);
|
|
733
|
+
} else {
|
|
734
|
+
combinedBody += `\n\n${failNote}`;
|
|
735
|
+
}
|
|
736
|
+
} else {
|
|
737
|
+
// Partial failure: some images loaded, some didn't
|
|
738
|
+
const failNote = `[${failedImageCount} of ${requestedImageCount} images failed to load: ${reasonSummary}]`;
|
|
739
|
+
combinedBody += `\n\n${failNote}`;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
491
743
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
492
|
-
Body:
|
|
744
|
+
Body: combinedBody,
|
|
493
745
|
RawBody: event.rawMes ?? mes,
|
|
494
746
|
CommandBody: mes,
|
|
495
747
|
From: fromAddress,
|
|
@@ -509,7 +761,9 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
|
|
|
509
761
|
OriginatingTo: toAddress,
|
|
510
762
|
WasMentioned: isGroup ? event.wasMentioned : undefined,
|
|
511
763
|
ReplyToBody: event.replyContext ? event.replyContext.join("\n---\n") : undefined,
|
|
764
|
+
InboundHistory: inboundHistory,
|
|
512
765
|
CommandAuthorized: true,
|
|
766
|
+
...mediaPayload,
|
|
513
767
|
});
|
|
514
768
|
|
|
515
769
|
// Record session using recordInboundSession for proper session tracking
|
|
@@ -532,6 +786,14 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
|
|
|
532
786
|
|
|
533
787
|
// "record" mode: save to session only, no think, no reply
|
|
534
788
|
if (replyMode === "record") {
|
|
789
|
+
if (groupIdStr) {
|
|
790
|
+
recordPendingHistoryEntryIfEnabled({
|
|
791
|
+
historyMap: chatHistories,
|
|
792
|
+
historyKey: groupIdStr,
|
|
793
|
+
entry: { sender: senderName || fromuser, body: mes, timestamp: Date.now() },
|
|
794
|
+
limit: DEFAULT_GROUP_HISTORY_LIMIT,
|
|
795
|
+
});
|
|
796
|
+
}
|
|
535
797
|
return;
|
|
536
798
|
}
|
|
537
799
|
|
|
@@ -550,6 +812,14 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
|
|
|
550
812
|
) {
|
|
551
813
|
ctxPayload.GroupSystemPrompt = buildFollowUpPrompt();
|
|
552
814
|
} else {
|
|
815
|
+
if (groupIdStr) {
|
|
816
|
+
recordPendingHistoryEntryIfEnabled({
|
|
817
|
+
historyMap: chatHistories,
|
|
818
|
+
historyKey: groupIdStr,
|
|
819
|
+
entry: { sender: senderName || fromuser, body: mes, timestamp: Date.now() },
|
|
820
|
+
limit: DEFAULT_GROUP_HISTORY_LIMIT,
|
|
821
|
+
});
|
|
822
|
+
}
|
|
553
823
|
return;
|
|
554
824
|
}
|
|
555
825
|
}
|
|
@@ -575,6 +845,14 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
|
|
|
575
845
|
// Follow-up window: let LLM decide if this is a follow-up
|
|
576
846
|
ctxPayload.GroupSystemPrompt = buildFollowUpPrompt();
|
|
577
847
|
} else {
|
|
848
|
+
if (groupIdStr) {
|
|
849
|
+
recordPendingHistoryEntryIfEnabled({
|
|
850
|
+
historyMap: chatHistories,
|
|
851
|
+
historyKey: groupIdStr,
|
|
852
|
+
entry: { sender: senderName || fromuser, body: mes, timestamp: Date.now() },
|
|
853
|
+
limit: DEFAULT_GROUP_HISTORY_LIMIT,
|
|
854
|
+
});
|
|
855
|
+
}
|
|
578
856
|
return;
|
|
579
857
|
}
|
|
580
858
|
}
|
|
@@ -608,6 +886,21 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
|
|
|
608
886
|
// Build unified target: "group:<id>" for group chat, username for private chat
|
|
609
887
|
const to = isGroup && groupId !== undefined ? `group:${groupId}` : fromuser;
|
|
610
888
|
|
|
889
|
+
// --- Thinking indicator ("收到🤔...") ---
|
|
890
|
+
const thinkingEnabled = groupCfg?.thinkingIndicator ?? account.config.thinkingIndicator ?? true;
|
|
891
|
+
let thinkingHandle: ThinkingIndicatorHandle | undefined;
|
|
892
|
+
if (thinkingEnabled) {
|
|
893
|
+
thinkingHandle = await sendThinkingIndicator({
|
|
894
|
+
cfg,
|
|
895
|
+
to,
|
|
896
|
+
accountId: account.accountId,
|
|
897
|
+
replyTo:
|
|
898
|
+
isGroup && event.messageId
|
|
899
|
+
? { messageid: event.messageId, preview: mes.slice(0, 100) }
|
|
900
|
+
: undefined,
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
|
|
611
904
|
// Provide mention context to the LLM so it can decide who to @mention
|
|
612
905
|
if (isGroup && event.mentionIds) {
|
|
613
906
|
const parts: string[] = [];
|
|
@@ -632,22 +925,72 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
|
|
|
632
925
|
atOptions: isGroup && event.wasMentioned ? { atUserIds: [fromuser] } : undefined,
|
|
633
926
|
// Pass mention IDs for LLM-driven @mention resolution in outbound text
|
|
634
927
|
mentionIds: isGroup ? event.mentionIds : undefined,
|
|
928
|
+
// Pass inbound messageId for outbound reply-to (group only)
|
|
929
|
+
replyToMessageId: isGroup ? event.messageId : undefined,
|
|
930
|
+
replyToPreview: isGroup ? mes : undefined,
|
|
635
931
|
});
|
|
636
932
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
933
|
+
// Wrap dispatcher to recall thinking indicator before first delivery
|
|
934
|
+
const canRecallThinking = Boolean(thinkingHandle);
|
|
935
|
+
let thinkingRecalled = false;
|
|
936
|
+
const doRecallThinking = async () => {
|
|
937
|
+
if (thinkingRecalled || !canRecallThinking) return;
|
|
938
|
+
thinkingRecalled = true;
|
|
939
|
+
await recallThinkingIndicator({
|
|
940
|
+
cfg,
|
|
941
|
+
accountId: account.accountId,
|
|
942
|
+
handle: thinkingHandle!,
|
|
943
|
+
groupId: isGroup ? groupId : undefined,
|
|
944
|
+
isPrivate: !isGroup,
|
|
945
|
+
});
|
|
946
|
+
};
|
|
947
|
+
|
|
948
|
+
const originalDeliver = dispatcherOptions.deliver;
|
|
949
|
+
const wrappedDispatcherOptions = {
|
|
950
|
+
...dispatcherOptions,
|
|
951
|
+
deliver: async (payload: ReplyPayload) => {
|
|
952
|
+
await doRecallThinking();
|
|
953
|
+
return originalDeliver(payload);
|
|
954
|
+
},
|
|
955
|
+
onCleanup: () => {
|
|
956
|
+
void doRecallThinking();
|
|
957
|
+
},
|
|
958
|
+
};
|
|
959
|
+
|
|
960
|
+
// Wrap dispatch in try/finally to guarantee the thinking indicator bound to
|
|
961
|
+
// this message is always recalled — even when queue policy drops/enqueues the
|
|
962
|
+
// message before typing activates (typing.cleanup skips onCleanup when inactive).
|
|
963
|
+
// doRecallThinking is idempotent (thinkingRecalled flag), so duplicate calls are no-ops.
|
|
964
|
+
let dispatchResult;
|
|
965
|
+
try {
|
|
966
|
+
dispatchResult = await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
967
|
+
ctx: ctxPayload,
|
|
968
|
+
cfg,
|
|
969
|
+
dispatcherOptions: wrappedDispatcherOptions,
|
|
970
|
+
replyOptions,
|
|
971
|
+
});
|
|
972
|
+
} finally {
|
|
973
|
+
await doRecallThinking();
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
const didReply = dispatchResult?.queuedFinal ?? false;
|
|
977
|
+
|
|
978
|
+
// Clear accumulated history after dispatch (it's now in the session transcript)
|
|
979
|
+
if (isGroup && historyKey) {
|
|
980
|
+
clearHistoryEntriesIfEnabled({
|
|
981
|
+
historyMap: chatHistories,
|
|
982
|
+
historyKey,
|
|
983
|
+
limit: DEFAULT_GROUP_HISTORY_LIMIT,
|
|
984
|
+
});
|
|
985
|
+
}
|
|
643
986
|
|
|
644
987
|
// Record bot reply timestamp for follow-up window tracking
|
|
645
|
-
if (isGroup && groupId !== undefined) {
|
|
988
|
+
if (didReply && isGroup && groupId !== undefined) {
|
|
646
989
|
recordGroupReply(String(groupId));
|
|
647
990
|
}
|
|
648
991
|
|
|
649
992
|
logVerbose(
|
|
650
|
-
`[infoflow] dispatch complete: ${chatType} from ${fromuser}, hasGroupSystemPrompt=${Boolean(ctxPayload.GroupSystemPrompt)}`,
|
|
993
|
+
`[infoflow] dispatch complete: ${chatType} from ${fromuser}, replied=${didReply}, finalCount=${dispatchResult?.counts.final ?? 0}, hasGroupSystemPrompt=${Boolean(ctxPayload.GroupSystemPrompt)}`,
|
|
651
994
|
);
|
|
652
995
|
}
|
|
653
996
|
|
package/src/channel.ts
CHANGED
|
@@ -17,11 +17,12 @@ import {
|
|
|
17
17
|
} from "./accounts.js";
|
|
18
18
|
import { infoflowMessageActions } from "./actions.js";
|
|
19
19
|
import { logVerbose } from "./logging.js";
|
|
20
|
+
import { prepareInfoflowImageBase64, sendInfoflowImageMessage } from "./media.js";
|
|
20
21
|
import { startInfoflowMonitor } from "./monitor.js";
|
|
21
22
|
import { getInfoflowRuntime } from "./runtime.js";
|
|
22
23
|
import { sendInfoflowMessage } from "./send.js";
|
|
23
24
|
import { normalizeInfoflowTarget, looksLikeInfoflowId } from "./targets.js";
|
|
24
|
-
import type {
|
|
25
|
+
import type { ResolvedInfoflowAccount } from "./types.js";
|
|
25
26
|
|
|
26
27
|
// Re-export types and account functions for external consumers
|
|
27
28
|
export type { InfoflowAccountConfig, ResolvedInfoflowAccount } from "./types.js";
|
|
@@ -44,6 +45,7 @@ export const infoflowPlugin: ChannelPlugin<ResolvedInfoflowAccount> = {
|
|
|
44
45
|
capabilities: {
|
|
45
46
|
chatTypes: ["direct", "group"],
|
|
46
47
|
nativeCommands: true,
|
|
48
|
+
unsend: true,
|
|
47
49
|
},
|
|
48
50
|
reload: { configPrefixes: ["channels.infoflow"] },
|
|
49
51
|
actions: infoflowMessageActions,
|
|
@@ -221,39 +223,78 @@ export const infoflowPlugin: ChannelPlugin<ResolvedInfoflowAccount> = {
|
|
|
221
223
|
messageId: result.ok ? (result.messageId ?? "sent") : "failed",
|
|
222
224
|
};
|
|
223
225
|
},
|
|
224
|
-
sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => {
|
|
226
|
+
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, mediaLocalRoots }) => {
|
|
225
227
|
logVerbose(`[infoflow:sendMedia] to=${to}, accountId=${accountId}, mediaUrl=${mediaUrl}`);
|
|
226
228
|
|
|
227
|
-
// Build contents array: text (if provided) + link for media URL
|
|
228
|
-
const contents: InfoflowMessageContentItem[] = [];
|
|
229
229
|
const trimmedText = text?.trim();
|
|
230
|
+
|
|
231
|
+
// Helper: send text as markdown
|
|
232
|
+
const sendText = () =>
|
|
233
|
+
sendInfoflowMessage({
|
|
234
|
+
cfg,
|
|
235
|
+
to,
|
|
236
|
+
contents: [{ type: "markdown", content: trimmedText! }],
|
|
237
|
+
accountId: accountId ?? undefined,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
// Helper: attempt native image send, fall back to link
|
|
241
|
+
const sendImage = async (): Promise<{ ok: boolean; messageId?: string }> => {
|
|
242
|
+
if (!mediaUrl) return { ok: false };
|
|
243
|
+
try {
|
|
244
|
+
const prepared = await prepareInfoflowImageBase64({
|
|
245
|
+
mediaUrl,
|
|
246
|
+
mediaLocalRoots: mediaLocalRoots ?? undefined,
|
|
247
|
+
});
|
|
248
|
+
if (prepared.isImage) {
|
|
249
|
+
const result = await sendInfoflowImageMessage({
|
|
250
|
+
cfg,
|
|
251
|
+
to,
|
|
252
|
+
base64Image: prepared.base64,
|
|
253
|
+
accountId: accountId ?? undefined,
|
|
254
|
+
});
|
|
255
|
+
if (result.ok) return { ok: true, messageId: result.messageId };
|
|
256
|
+
// Native send failed, fall back to link
|
|
257
|
+
logVerbose(
|
|
258
|
+
`[infoflow:sendMedia] native image failed: ${result.error}, falling back to link`,
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
} catch (err) {
|
|
262
|
+
logVerbose(`[infoflow:sendMedia] image prep failed, falling back to link: ${err}`);
|
|
263
|
+
}
|
|
264
|
+
// Fallback: send as link
|
|
265
|
+
const linkResult = await sendInfoflowMessage({
|
|
266
|
+
cfg,
|
|
267
|
+
to,
|
|
268
|
+
contents: [{ type: "link", content: mediaUrl }],
|
|
269
|
+
accountId: accountId ?? undefined,
|
|
270
|
+
});
|
|
271
|
+
return { ok: linkResult.ok, messageId: linkResult.messageId };
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
// Dispatch: concurrent text + image, or text-only, or image-only
|
|
275
|
+
if (trimmedText && mediaUrl) {
|
|
276
|
+
const [, imageResult] = await Promise.all([sendText(), sendImage()]);
|
|
277
|
+
return {
|
|
278
|
+
channel: "infoflow",
|
|
279
|
+
messageId: imageResult.ok ? (imageResult.messageId ?? "sent") : "failed",
|
|
280
|
+
};
|
|
281
|
+
}
|
|
230
282
|
if (trimmedText) {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
283
|
+
const result = await sendText();
|
|
284
|
+
return {
|
|
285
|
+
channel: "infoflow",
|
|
286
|
+
messageId: result.ok ? (result.messageId ?? "sent") : "failed",
|
|
287
|
+
};
|
|
234
288
|
}
|
|
235
289
|
if (mediaUrl) {
|
|
236
|
-
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Fallback: if no valid content, return early
|
|
240
|
-
if (contents.length === 0) {
|
|
290
|
+
const result = await sendImage();
|
|
241
291
|
return {
|
|
242
292
|
channel: "infoflow",
|
|
243
|
-
messageId: "failed",
|
|
293
|
+
messageId: result.ok ? (result.messageId ?? "sent") : "failed",
|
|
244
294
|
};
|
|
245
295
|
}
|
|
246
296
|
|
|
247
|
-
|
|
248
|
-
cfg,
|
|
249
|
-
to,
|
|
250
|
-
contents,
|
|
251
|
-
accountId: accountId ?? undefined,
|
|
252
|
-
});
|
|
253
|
-
return {
|
|
254
|
-
channel: "infoflow",
|
|
255
|
-
messageId: result.ok ? (result.messageId ?? "sent") : "failed",
|
|
256
|
-
};
|
|
297
|
+
return { channel: "infoflow", messageId: "failed" };
|
|
257
298
|
},
|
|
258
299
|
},
|
|
259
300
|
status: {
|