@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/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:${account.accountId}`;
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: 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 { 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,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
- // 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 });
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
- contents.push({ type: "link", content: mediaUrl });
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
- 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
- };
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 | number): void {
64
+ export function recordSentMessageId(messageId: string | null): void {
65
65
  if (messageId == null) return;
66
- messageCache.check(String(messageId)); // Will record if not duplicate
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
  }