@chbo297/infoflow 2026.3.4 → 2026.3.7
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/openclaw.plugin.json +14 -2
- package/package.json +2 -2
- package/src/accounts.ts +3 -0
- package/src/actions.ts +346 -4
- package/src/bot.ts +197 -9
- package/src/channel.ts +65 -23
- package/src/infoflow-req-parse.ts +2 -3
- package/src/media.ts +369 -0
- package/src/reply-dispatcher.ts +157 -67
- package/src/send.ts +498 -57
- package/src/sent-message-store.ts +267 -0
- package/src/types.ts +21 -2
package/src/bot.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildPendingHistoryContextFromMap,
|
|
3
|
+
clearHistoryEntriesIfEnabled,
|
|
4
|
+
DEFAULT_GROUP_HISTORY_LIMIT,
|
|
5
|
+
type HistoryEntry,
|
|
6
|
+
recordPendingHistoryEntryIfEnabled,
|
|
7
|
+
buildAgentMediaPayload,
|
|
8
|
+
} from "openclaw/plugin-sdk";
|
|
1
9
|
import { resolveInfoflowAccount } from "./accounts.js";
|
|
2
10
|
import { getInfoflowBotLog, formatInfoflowError, logVerbose } from "./logging.js";
|
|
3
11
|
import { createInfoflowReplyDispatcher } from "./reply-dispatcher.js";
|
|
@@ -36,6 +44,8 @@ type InfoflowBodyItem = {
|
|
|
36
44
|
name?: string;
|
|
37
45
|
/** 人类用户 AT 时有此字段(uuap name),与 robotid 互斥 */
|
|
38
46
|
userid?: string;
|
|
47
|
+
/** IMAGE 类型 body item 的图片下载地址 */
|
|
48
|
+
downloadurl?: string;
|
|
39
49
|
};
|
|
40
50
|
|
|
41
51
|
/**
|
|
@@ -219,6 +229,9 @@ function buildProactivePrompt(): string {
|
|
|
219
229
|
/** In-memory map tracking bot's last reply timestamp per group */
|
|
220
230
|
const groupLastReplyMap = new Map<string, number>();
|
|
221
231
|
|
|
232
|
+
/** In-memory map accumulating recent group messages for context injection when bot is @mentioned */
|
|
233
|
+
const chatHistories = new Map<string, HistoryEntry[]>();
|
|
234
|
+
|
|
222
235
|
/** Record that the bot replied to a group (called after successful send) */
|
|
223
236
|
export function recordGroupReply(groupId: string): void {
|
|
224
237
|
groupLastReplyMap.set(groupId, Date.now());
|
|
@@ -290,24 +303,39 @@ export async function handlePrivateChatMessage(params: HandlePrivateChatParams):
|
|
|
290
303
|
const createTime = msgData.CreateTime ?? msgData.createtime;
|
|
291
304
|
const timestamp = createTime != null ? Number(createTime) * 1000 : Date.now();
|
|
292
305
|
|
|
306
|
+
// Detect image messages: MsgType=image with PicUrl
|
|
307
|
+
const msgType = String(msgData.MsgType ?? msgData.msgtype ?? "");
|
|
308
|
+
const picUrl = String(msgData.PicUrl ?? msgData.picurl ?? "");
|
|
309
|
+
const imageUrls: string[] = [];
|
|
310
|
+
if (msgType === "image" && picUrl.trim()) {
|
|
311
|
+
imageUrls.push(picUrl.trim());
|
|
312
|
+
}
|
|
313
|
+
|
|
293
314
|
logVerbose(
|
|
294
|
-
`[infoflow] private chat: fromuser=${fromuser}, senderName=${senderName}, raw msgData: ${JSON.stringify(msgData)}`,
|
|
315
|
+
`[infoflow] private chat: fromuser=${fromuser}, senderName=${senderName}, mes=${mes}, msgType=${msgType}, raw msgData: ${JSON.stringify(msgData)}`,
|
|
295
316
|
);
|
|
296
317
|
|
|
297
|
-
if (!fromuser || !mes.trim()) {
|
|
318
|
+
if (!fromuser || (!mes.trim() && imageUrls.length === 0)) {
|
|
298
319
|
return;
|
|
299
320
|
}
|
|
300
321
|
|
|
322
|
+
// For image-only messages (no text), use placeholder
|
|
323
|
+
let effectiveMes = mes.trim();
|
|
324
|
+
if (!effectiveMes && imageUrls.length > 0) {
|
|
325
|
+
effectiveMes = "<media:image>";
|
|
326
|
+
}
|
|
327
|
+
|
|
301
328
|
// Delegate to the common message handler (private chat)
|
|
302
329
|
await handleInfoflowMessage({
|
|
303
330
|
cfg,
|
|
304
331
|
event: {
|
|
305
332
|
fromuser,
|
|
306
|
-
mes,
|
|
333
|
+
mes: effectiveMes,
|
|
307
334
|
chatType: "direct",
|
|
308
335
|
senderName,
|
|
309
336
|
messageId: messageIdStr,
|
|
310
337
|
timestamp,
|
|
338
|
+
imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
|
|
311
339
|
},
|
|
312
340
|
accountId,
|
|
313
341
|
statusSink,
|
|
@@ -365,6 +393,7 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
|
|
|
365
393
|
let textContent = "";
|
|
366
394
|
let rawTextContent = "";
|
|
367
395
|
const replyContextItems: string[] = [];
|
|
396
|
+
const imageUrls: string[] = [];
|
|
368
397
|
if (Array.isArray(bodyItems)) {
|
|
369
398
|
for (const item of bodyItems) {
|
|
370
399
|
if (item.type === "replyData") {
|
|
@@ -388,6 +417,12 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
|
|
|
388
417
|
if (name) {
|
|
389
418
|
rawTextContent += `@${name} `;
|
|
390
419
|
}
|
|
420
|
+
} else if (item.type === "IMAGE") {
|
|
421
|
+
// 提取图片下载地址
|
|
422
|
+
const url = item.downloadurl;
|
|
423
|
+
if (typeof url === "string" && url.trim()) {
|
|
424
|
+
imageUrls.push(url.trim());
|
|
425
|
+
}
|
|
391
426
|
}
|
|
392
427
|
}
|
|
393
428
|
}
|
|
@@ -397,9 +432,13 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
|
|
|
397
432
|
|
|
398
433
|
const replyContext = replyContextItems.length > 0 ? replyContextItems : undefined;
|
|
399
434
|
|
|
400
|
-
if (!mes && !replyContext) {
|
|
435
|
+
if (!mes && !replyContext && imageUrls.length === 0) {
|
|
401
436
|
return;
|
|
402
437
|
}
|
|
438
|
+
// 纯图片消息:设置占位符
|
|
439
|
+
if (!mes && imageUrls.length > 0) {
|
|
440
|
+
mes = `<media:image>${imageUrls.length > 1 ? ` (${imageUrls.length} images)` : ""}`;
|
|
441
|
+
}
|
|
403
442
|
// If mes is empty but replyContext exists, use a placeholder so the message is not dropped
|
|
404
443
|
if (!mes && replyContext) {
|
|
405
444
|
mes = "(引用回复)";
|
|
@@ -425,6 +464,7 @@ export async function handleGroupChatMessage(params: HandleGroupChatParams): Pro
|
|
|
425
464
|
mentionIds:
|
|
426
465
|
mentionIds.userIds.length > 0 || mentionIds.agentIds.length > 0 ? mentionIds : undefined,
|
|
427
466
|
replyContext,
|
|
467
|
+
imageUrls: imageUrls.length > 0 ? imageUrls : undefined,
|
|
428
468
|
},
|
|
429
469
|
accountId,
|
|
430
470
|
statusSink,
|
|
@@ -477,7 +517,7 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
|
|
|
477
517
|
// Build conversation label and from address based on chat type
|
|
478
518
|
const fromLabel = isGroup ? `group:${groupId}` : senderName || fromuser;
|
|
479
519
|
const fromAddress = isGroup ? `infoflow:group:${groupId}` : `infoflow:${fromuser}`;
|
|
480
|
-
const toAddress = isGroup ? `infoflow:${groupId}` : `infoflow:${
|
|
520
|
+
const toAddress = isGroup ? `infoflow:group:${groupId}` : `infoflow:${fromuser}`;
|
|
481
521
|
|
|
482
522
|
const body = core.channel.reply.formatAgentEnvelope({
|
|
483
523
|
channel: "Infoflow",
|
|
@@ -488,8 +528,116 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
|
|
|
488
528
|
body: mes,
|
|
489
529
|
});
|
|
490
530
|
|
|
531
|
+
// Inject accumulated group chat history into the body for context
|
|
532
|
+
const historyKey = isGroup && groupId !== undefined ? String(groupId) : undefined;
|
|
533
|
+
let combinedBody = body;
|
|
534
|
+
if (isGroup && historyKey) {
|
|
535
|
+
combinedBody = buildPendingHistoryContextFromMap({
|
|
536
|
+
historyMap: chatHistories,
|
|
537
|
+
historyKey,
|
|
538
|
+
limit: DEFAULT_GROUP_HISTORY_LIMIT,
|
|
539
|
+
currentMessage: body,
|
|
540
|
+
formatEntry: (entry) =>
|
|
541
|
+
core.channel.reply.formatAgentEnvelope({
|
|
542
|
+
channel: "Infoflow",
|
|
543
|
+
from: entry.sender,
|
|
544
|
+
timestamp: entry.timestamp ?? Date.now(),
|
|
545
|
+
body: entry.body,
|
|
546
|
+
}),
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const inboundHistory =
|
|
551
|
+
isGroup && historyKey
|
|
552
|
+
? (chatHistories.get(historyKey) ?? []).map((e) => ({
|
|
553
|
+
sender: e.sender,
|
|
554
|
+
body: e.body,
|
|
555
|
+
timestamp: e.timestamp,
|
|
556
|
+
}))
|
|
557
|
+
: undefined;
|
|
558
|
+
|
|
559
|
+
// --- Resolve inbound media (images) ---
|
|
560
|
+
const INFOFLOW_MAX_IMAGES = 20;
|
|
561
|
+
const mediaMaxBytes = 30 * 1024 * 1024; // 30MB default, matching Feishu
|
|
562
|
+
const mediaList: Array<{ path: string; contentType?: string }> = [];
|
|
563
|
+
const failReasons: string[] = [];
|
|
564
|
+
|
|
565
|
+
if (event.imageUrls && event.imageUrls.length > 0) {
|
|
566
|
+
// Collect unique hostnames from image URLs for SSRF allowlist.
|
|
567
|
+
// Infoflow image servers (e.g. xp2.im.baidu.com, e4hi.im.baidu.com) resolve to
|
|
568
|
+
// internal IPs on Baidu's network, so they need to be explicitly allowed.
|
|
569
|
+
const allowedHostnames: string[] = [];
|
|
570
|
+
for (const imageUrl of event.imageUrls) {
|
|
571
|
+
try {
|
|
572
|
+
const hostname = new URL(imageUrl).hostname;
|
|
573
|
+
if (hostname && !allowedHostnames.includes(hostname)) {
|
|
574
|
+
allowedHostnames.push(hostname);
|
|
575
|
+
}
|
|
576
|
+
} catch {
|
|
577
|
+
// invalid URL, will fail at fetch time
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
const ssrfPolicy = allowedHostnames.length > 0 ? { allowedHostnames } : undefined;
|
|
581
|
+
|
|
582
|
+
const urls = event.imageUrls.slice(0, INFOFLOW_MAX_IMAGES);
|
|
583
|
+
const results = await Promise.allSettled(
|
|
584
|
+
urls.map(async (imageUrl) => {
|
|
585
|
+
const fetched = await core.channel.media.fetchRemoteMedia({
|
|
586
|
+
url: imageUrl,
|
|
587
|
+
maxBytes: mediaMaxBytes,
|
|
588
|
+
ssrfPolicy,
|
|
589
|
+
});
|
|
590
|
+
const saved = await core.channel.media.saveMediaBuffer(
|
|
591
|
+
fetched.buffer,
|
|
592
|
+
fetched.contentType ?? undefined,
|
|
593
|
+
"inbound",
|
|
594
|
+
mediaMaxBytes,
|
|
595
|
+
);
|
|
596
|
+
logVerbose(`[infoflow] downloaded image from ${imageUrl}, saved to ${saved.path}`);
|
|
597
|
+
return { path: saved.path, contentType: saved.contentType ?? fetched.contentType };
|
|
598
|
+
}),
|
|
599
|
+
);
|
|
600
|
+
for (const result of results) {
|
|
601
|
+
if (result.status === "fulfilled") {
|
|
602
|
+
mediaList.push(result.value);
|
|
603
|
+
} else {
|
|
604
|
+
const reason = String(result.reason);
|
|
605
|
+
logVerbose(`[infoflow] failed to download image: ${reason}`);
|
|
606
|
+
failReasons.push(reason);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const mediaPayload = buildAgentMediaPayload(mediaList);
|
|
612
|
+
|
|
613
|
+
// If user sent images but some/all downloads failed, adjust the body to inform the LLM.
|
|
614
|
+
const requestedImageCount = event.imageUrls?.length ?? 0;
|
|
615
|
+
const downloadedImageCount = mediaList.length;
|
|
616
|
+
const failedImageCount = requestedImageCount - downloadedImageCount;
|
|
617
|
+
if (requestedImageCount > 0 && failedImageCount > 0) {
|
|
618
|
+
// Deduplicate error reasons and truncate for readability
|
|
619
|
+
const uniqueReasons = [...new Set(failReasons)];
|
|
620
|
+
const reasonSummary = uniqueReasons.map((r) => r.slice(0, 200)).join("; ");
|
|
621
|
+
|
|
622
|
+
if (downloadedImageCount === 0) {
|
|
623
|
+
// All failed
|
|
624
|
+
const failNote =
|
|
625
|
+
`[The user sent ${requestedImageCount > 1 ? `${requestedImageCount} images` : "an image"}, ` +
|
|
626
|
+
`but failed to load: ${reasonSummary}]`;
|
|
627
|
+
if (combinedBody.includes("<media:image>")) {
|
|
628
|
+
combinedBody = combinedBody.replace(/<media:image>(\s*\(\d+ images\))?/, failNote);
|
|
629
|
+
} else {
|
|
630
|
+
combinedBody += `\n\n${failNote}`;
|
|
631
|
+
}
|
|
632
|
+
} else {
|
|
633
|
+
// Partial failure: some images loaded, some didn't
|
|
634
|
+
const failNote = `[${failedImageCount} of ${requestedImageCount} images failed to load: ${reasonSummary}]`;
|
|
635
|
+
combinedBody += `\n\n${failNote}`;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
491
639
|
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
|
492
|
-
Body:
|
|
640
|
+
Body: combinedBody,
|
|
493
641
|
RawBody: event.rawMes ?? mes,
|
|
494
642
|
CommandBody: mes,
|
|
495
643
|
From: fromAddress,
|
|
@@ -509,7 +657,9 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
|
|
|
509
657
|
OriginatingTo: toAddress,
|
|
510
658
|
WasMentioned: isGroup ? event.wasMentioned : undefined,
|
|
511
659
|
ReplyToBody: event.replyContext ? event.replyContext.join("\n---\n") : undefined,
|
|
660
|
+
InboundHistory: inboundHistory,
|
|
512
661
|
CommandAuthorized: true,
|
|
662
|
+
...mediaPayload,
|
|
513
663
|
});
|
|
514
664
|
|
|
515
665
|
// Record session using recordInboundSession for proper session tracking
|
|
@@ -532,6 +682,14 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
|
|
|
532
682
|
|
|
533
683
|
// "record" mode: save to session only, no think, no reply
|
|
534
684
|
if (replyMode === "record") {
|
|
685
|
+
if (groupIdStr) {
|
|
686
|
+
recordPendingHistoryEntryIfEnabled({
|
|
687
|
+
historyMap: chatHistories,
|
|
688
|
+
historyKey: groupIdStr,
|
|
689
|
+
entry: { sender: senderName || fromuser, body: mes, timestamp: Date.now() },
|
|
690
|
+
limit: DEFAULT_GROUP_HISTORY_LIMIT,
|
|
691
|
+
});
|
|
692
|
+
}
|
|
535
693
|
return;
|
|
536
694
|
}
|
|
537
695
|
|
|
@@ -550,6 +708,14 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
|
|
|
550
708
|
) {
|
|
551
709
|
ctxPayload.GroupSystemPrompt = buildFollowUpPrompt();
|
|
552
710
|
} else {
|
|
711
|
+
if (groupIdStr) {
|
|
712
|
+
recordPendingHistoryEntryIfEnabled({
|
|
713
|
+
historyMap: chatHistories,
|
|
714
|
+
historyKey: groupIdStr,
|
|
715
|
+
entry: { sender: senderName || fromuser, body: mes, timestamp: Date.now() },
|
|
716
|
+
limit: DEFAULT_GROUP_HISTORY_LIMIT,
|
|
717
|
+
});
|
|
718
|
+
}
|
|
553
719
|
return;
|
|
554
720
|
}
|
|
555
721
|
}
|
|
@@ -575,6 +741,14 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
|
|
|
575
741
|
// Follow-up window: let LLM decide if this is a follow-up
|
|
576
742
|
ctxPayload.GroupSystemPrompt = buildFollowUpPrompt();
|
|
577
743
|
} else {
|
|
744
|
+
if (groupIdStr) {
|
|
745
|
+
recordPendingHistoryEntryIfEnabled({
|
|
746
|
+
historyMap: chatHistories,
|
|
747
|
+
historyKey: groupIdStr,
|
|
748
|
+
entry: { sender: senderName || fromuser, body: mes, timestamp: Date.now() },
|
|
749
|
+
limit: DEFAULT_GROUP_HISTORY_LIMIT,
|
|
750
|
+
});
|
|
751
|
+
}
|
|
578
752
|
return;
|
|
579
753
|
}
|
|
580
754
|
}
|
|
@@ -632,22 +806,36 @@ export async function handleInfoflowMessage(params: HandleInfoflowMessageParams)
|
|
|
632
806
|
atOptions: isGroup && event.wasMentioned ? { atUserIds: [fromuser] } : undefined,
|
|
633
807
|
// Pass mention IDs for LLM-driven @mention resolution in outbound text
|
|
634
808
|
mentionIds: isGroup ? event.mentionIds : undefined,
|
|
809
|
+
// Pass inbound messageId for outbound reply-to (group only)
|
|
810
|
+
replyToMessageId: isGroup ? event.messageId : undefined,
|
|
811
|
+
replyToPreview: isGroup ? mes : undefined,
|
|
635
812
|
});
|
|
636
813
|
|
|
637
|
-
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
814
|
+
const dispatchResult = await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
638
815
|
ctx: ctxPayload,
|
|
639
816
|
cfg,
|
|
640
817
|
dispatcherOptions,
|
|
641
818
|
replyOptions,
|
|
642
819
|
});
|
|
643
820
|
|
|
821
|
+
const didReply = dispatchResult?.queuedFinal ?? false;
|
|
822
|
+
|
|
823
|
+
// Clear accumulated history after dispatch (it's now in the session transcript)
|
|
824
|
+
if (isGroup && historyKey) {
|
|
825
|
+
clearHistoryEntriesIfEnabled({
|
|
826
|
+
historyMap: chatHistories,
|
|
827
|
+
historyKey,
|
|
828
|
+
limit: DEFAULT_GROUP_HISTORY_LIMIT,
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
|
|
644
832
|
// Record bot reply timestamp for follow-up window tracking
|
|
645
|
-
if (isGroup && groupId !== undefined) {
|
|
833
|
+
if (didReply && isGroup && groupId !== undefined) {
|
|
646
834
|
recordGroupReply(String(groupId));
|
|
647
835
|
}
|
|
648
836
|
|
|
649
837
|
logVerbose(
|
|
650
|
-
`[infoflow] dispatch complete: ${chatType} from ${fromuser}, hasGroupSystemPrompt=${Boolean(ctxPayload.GroupSystemPrompt)}`,
|
|
838
|
+
`[infoflow] dispatch complete: ${chatType} from ${fromuser}, replied=${didReply}, finalCount=${dispatchResult?.counts.final ?? 0}, hasGroupSystemPrompt=${Boolean(ctxPayload.GroupSystemPrompt)}`,
|
|
651
839
|
);
|
|
652
840
|
}
|
|
653
841
|
|
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,12 +45,14 @@ 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,
|
|
50
52
|
agentPrompt: {
|
|
51
53
|
messageToolHints: () => [
|
|
52
54
|
'Infoflow group @mentions: set atAll=true to @all members, or mentionUserIds="user1,user2" (comma-separated uuapName) to @mention specific users. Only effective for group targets (group:<id>).',
|
|
55
|
+
'Infoflow supports message recall (撤回): use action="delete" to recall the most recent message, or specify messageId to recall a specific message. Works for both private and group messages.',
|
|
53
56
|
],
|
|
54
57
|
},
|
|
55
58
|
config: {
|
|
@@ -221,39 +224,78 @@ export const infoflowPlugin: ChannelPlugin<ResolvedInfoflowAccount> = {
|
|
|
221
224
|
messageId: result.ok ? (result.messageId ?? "sent") : "failed",
|
|
222
225
|
};
|
|
223
226
|
},
|
|
224
|
-
sendMedia: async ({ cfg, to, text, mediaUrl, accountId }) => {
|
|
227
|
+
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, mediaLocalRoots }) => {
|
|
225
228
|
logVerbose(`[infoflow:sendMedia] to=${to}, accountId=${accountId}, mediaUrl=${mediaUrl}`);
|
|
226
229
|
|
|
227
|
-
// Build contents array: text (if provided) + link for media URL
|
|
228
|
-
const contents: InfoflowMessageContentItem[] = [];
|
|
229
230
|
const trimmedText = text?.trim();
|
|
231
|
+
|
|
232
|
+
// Helper: send text as markdown
|
|
233
|
+
const sendText = () =>
|
|
234
|
+
sendInfoflowMessage({
|
|
235
|
+
cfg,
|
|
236
|
+
to,
|
|
237
|
+
contents: [{ type: "markdown", content: trimmedText! }],
|
|
238
|
+
accountId: accountId ?? undefined,
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// Helper: attempt native image send, fall back to link
|
|
242
|
+
const sendImage = async (): Promise<{ ok: boolean; messageId?: string }> => {
|
|
243
|
+
if (!mediaUrl) return { ok: false };
|
|
244
|
+
try {
|
|
245
|
+
const prepared = await prepareInfoflowImageBase64({
|
|
246
|
+
mediaUrl,
|
|
247
|
+
mediaLocalRoots: mediaLocalRoots ?? undefined,
|
|
248
|
+
});
|
|
249
|
+
if (prepared.isImage) {
|
|
250
|
+
const result = await sendInfoflowImageMessage({
|
|
251
|
+
cfg,
|
|
252
|
+
to,
|
|
253
|
+
base64Image: prepared.base64,
|
|
254
|
+
accountId: accountId ?? undefined,
|
|
255
|
+
});
|
|
256
|
+
if (result.ok) return { ok: true, messageId: result.messageId };
|
|
257
|
+
// Native send failed, fall back to link
|
|
258
|
+
logVerbose(
|
|
259
|
+
`[infoflow:sendMedia] native image failed: ${result.error}, falling back to link`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
} catch (err) {
|
|
263
|
+
logVerbose(`[infoflow:sendMedia] image prep failed, falling back to link: ${err}`);
|
|
264
|
+
}
|
|
265
|
+
// Fallback: send as link
|
|
266
|
+
const linkResult = await sendInfoflowMessage({
|
|
267
|
+
cfg,
|
|
268
|
+
to,
|
|
269
|
+
contents: [{ type: "link", content: mediaUrl }],
|
|
270
|
+
accountId: accountId ?? undefined,
|
|
271
|
+
});
|
|
272
|
+
return { ok: linkResult.ok, messageId: linkResult.messageId };
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
// Dispatch: concurrent text + image, or text-only, or image-only
|
|
276
|
+
if (trimmedText && mediaUrl) {
|
|
277
|
+
const [, imageResult] = await Promise.all([sendText(), sendImage()]);
|
|
278
|
+
return {
|
|
279
|
+
channel: "infoflow",
|
|
280
|
+
messageId: imageResult.ok ? (imageResult.messageId ?? "sent") : "failed",
|
|
281
|
+
};
|
|
282
|
+
}
|
|
230
283
|
if (trimmedText) {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
284
|
+
const result = await sendText();
|
|
285
|
+
return {
|
|
286
|
+
channel: "infoflow",
|
|
287
|
+
messageId: result.ok ? (result.messageId ?? "sent") : "failed",
|
|
288
|
+
};
|
|
234
289
|
}
|
|
235
290
|
if (mediaUrl) {
|
|
236
|
-
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Fallback: if no valid content, return early
|
|
240
|
-
if (contents.length === 0) {
|
|
291
|
+
const result = await sendImage();
|
|
241
292
|
return {
|
|
242
293
|
channel: "infoflow",
|
|
243
|
-
messageId: "failed",
|
|
294
|
+
messageId: result.ok ? (result.messageId ?? "sent") : "failed",
|
|
244
295
|
};
|
|
245
296
|
}
|
|
246
297
|
|
|
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
|
-
};
|
|
298
|
+
return { channel: "infoflow", messageId: "failed" };
|
|
257
299
|
},
|
|
258
300
|
},
|
|
259
301
|
status: {
|
|
@@ -61,9 +61,9 @@ function isDuplicateMessage(msgData: Record<string, unknown>): boolean {
|
|
|
61
61
|
* Called after successfully sending a message to prevent
|
|
62
62
|
* the bot from processing its own outbound messages as inbound.
|
|
63
63
|
*/
|
|
64
|
-
export function recordSentMessageId(messageId: string |
|
|
64
|
+
export function recordSentMessageId(messageId: string | null): void {
|
|
65
65
|
if (messageId == null) return;
|
|
66
|
-
messageCache.check(
|
|
66
|
+
messageCache.check(messageId);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
// ---------------------------------------------------------------------------
|
|
@@ -334,7 +334,6 @@ function tryDecryptAndDispatch(params: DecryptDispatchParams): ParseResult {
|
|
|
334
334
|
);
|
|
335
335
|
});
|
|
336
336
|
|
|
337
|
-
logVerbose(`[infoflow] ${chatType}: message dispatched successfully`);
|
|
338
337
|
return { handled: true, statusCode: 200, body: "success" };
|
|
339
338
|
}
|
|
340
339
|
}
|