@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/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:${account.accountId}`;
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: 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
- await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
638
- ctx: ctxPayload,
639
- cfg,
640
- dispatcherOptions,
641
- replyOptions,
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 { InfoflowMessageContentItem, ResolvedInfoflowAccount } from "./types.js";
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
- // Use "markdown" type even though param is named `text`: LLM outputs are often markdown,
232
- // and Infoflow's markdown type handles both plain text and markdown seamlessly.
233
- contents.push({ type: "markdown", content: trimmedText });
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
- contents.push({ type: "link", content: mediaUrl });
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
- const result = await sendInfoflowMessage({
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: {
@@ -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
  }