@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/send.ts CHANGED
@@ -8,19 +8,21 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk";
8
8
  import { resolveInfoflowAccount } from "./accounts.js";
9
9
  import { recordSentMessageId } from "./infoflow-req-parse.js";
10
10
  import { getInfoflowSendLog, formatInfoflowError, logVerbose } from "./logging.js";
11
+ import { recordSentMessage, buildMessageDigest, buildAgentFrom } from "./sent-message-store.js";
11
12
  import type {
12
13
  InfoflowGroupMessageBodyItem,
13
14
  InfoflowMessageContentItem,
15
+ InfoflowOutboundReply,
14
16
  ResolvedInfoflowAccount,
15
17
  } from "./types.js";
16
18
 
17
- const DEFAULT_TIMEOUT_MS = 30_000; // 30 seconds
19
+ export const DEFAULT_TIMEOUT_MS = 30_000; // 30 seconds
18
20
 
19
21
  /**
20
22
  * Ensures apiHost uses HTTPS for security (secrets in transit).
21
23
  * Allows HTTP only for localhost/127.0.0.1 (local development).
22
24
  */
23
- function ensureHttps(apiHost: string): string {
25
+ export function ensureHttps(apiHost: string): string {
24
26
  if (apiHost.startsWith("http://")) {
25
27
  const url = new URL(apiHost);
26
28
  const isLocal = url.hostname === "localhost" || url.hostname === "127.0.0.1";
@@ -33,8 +35,10 @@ function ensureHttps(apiHost: string): string {
33
35
 
34
36
  // Infoflow API paths (host is configured via apiHost in config)
35
37
  const INFOFLOW_AUTH_PATH = "/api/v1/auth/app_access_token";
36
- const INFOFLOW_PRIVATE_SEND_PATH = "/api/v1/app/message/send";
37
- const INFOFLOW_GROUP_SEND_PATH = "/api/v1/robot/msg/groupmsgsend";
38
+ export const INFOFLOW_PRIVATE_SEND_PATH = "/api/v1/app/message/send";
39
+ export const INFOFLOW_GROUP_SEND_PATH = "/api/v1/robot/msg/groupmsgsend";
40
+ export const INFOFLOW_GROUP_RECALL_PATH = "/api/v1/robot/group/msgRecall";
41
+ export const INFOFLOW_PRIVATE_RECALL_PATH = "/api/v1/app/message/revoke";
38
42
 
39
43
  // Token cache to avoid fetching token for every message
40
44
  // Use Map keyed by appKey to support multi-account isolation
@@ -61,6 +65,37 @@ function parseLinkContent(content: string): { href: string; label: string } {
61
65
  return { href: content, label: content };
62
66
  }
63
67
 
68
+ /**
69
+ * Checks if a string looks like a local file path rather than a URL.
70
+ * Mirrors the pattern from src/media/parse.ts; security validation is
71
+ * deferred to the load layer (loadWebMedia).
72
+ */
73
+ function isLikelyLocalPath(content: string): boolean {
74
+ const trimmed = content.trim();
75
+ return (
76
+ trimmed.startsWith("/") ||
77
+ trimmed.startsWith("./") ||
78
+ trimmed.startsWith("../") ||
79
+ trimmed.startsWith("~")
80
+ );
81
+ }
82
+
83
+ /**
84
+ * Extracts a numeric or string value for the given key from raw JSON text.
85
+ * This bypasses JSON.parse precision loss for large integers (>2^53).
86
+ * Matches both bare integers ("key": 123) and quoted strings ("key": "abc").
87
+ */
88
+ export function extractIdFromRawJson(rawJson: string, key: string): string | undefined {
89
+ // Match bare integer: "key": 12345
90
+ const reNum = new RegExp(`"${key}"\\s*:\\s*(\\d+)`);
91
+ const mNum = rawJson.match(reNum);
92
+ if (mNum) return mNum[1];
93
+ // Match quoted string: "key": "value"
94
+ const reStr = new RegExp(`"${key}"\\s*:\\s*"([^"]+)"`);
95
+ const mStr = rawJson.match(reStr);
96
+ return mStr?.[1];
97
+ }
98
+
64
99
  /**
65
100
  * Extracts message ID from Infoflow API response data.
66
101
  * Handles different response formats:
@@ -68,7 +103,7 @@ function parseLinkContent(content: string): { href: string; label: string } {
68
103
  * - Group: data.data.messageid or data.data.msgid (nested)
69
104
  * - Fallback: data.messageid or data.msgid (flat)
70
105
  */
71
- function extractMessageId(data: Record<string, unknown>): string | undefined {
106
+ export function extractMessageId(data: Record<string, unknown>): string | undefined {
72
107
  // Try data.msgkey (private message format)
73
108
  if (data.msgkey != null) {
74
109
  return String(data.msgkey);
@@ -98,6 +133,25 @@ function extractMessageId(data: Record<string, unknown>): string | undefined {
98
133
  return undefined;
99
134
  }
100
135
 
136
+ /**
137
+ * Extracts msgseqid from Infoflow group send API response data.
138
+ * The recall API requires this alongside messageid.
139
+ */
140
+ export function extractMsgSeqId(data: Record<string, unknown>): string | undefined {
141
+ // Try nested data.data structure (group message format)
142
+ const innerData = data.data as Record<string, unknown> | undefined;
143
+ if (innerData && typeof innerData === "object" && innerData.msgseqid != null) {
144
+ return String(innerData.msgseqid);
145
+ }
146
+
147
+ // Fallback: flat structure
148
+ if (data.msgseqid != null) {
149
+ return String(data.msgseqid);
150
+ }
151
+
152
+ return undefined;
153
+ }
154
+
101
155
  // ---------------------------------------------------------------------------
102
156
  // Token Management
103
157
  // ---------------------------------------------------------------------------
@@ -276,7 +330,7 @@ export async function sendInfoflowPrivateMessage(params: {
276
330
 
277
331
  const bodyStr = JSON.stringify(payload);
278
332
 
279
- // Log request body when verbose logging is enabled
333
+ // Log request URL and body when verbose logging is enabled
280
334
  logVerbose(`[infoflow:sendPrivate] POST body: ${bodyStr}`);
281
335
 
282
336
  const res = await fetch(`${ensureHttps(apiHost)}${INFOFLOW_PRIVATE_SEND_PATH}`, {
@@ -286,7 +340,9 @@ export async function sendInfoflowPrivateMessage(params: {
286
340
  signal: controller.signal,
287
341
  });
288
342
 
289
- const data = JSON.parse(await res.text()) as Record<string, unknown>;
343
+ const responseText = await res.text();
344
+ const data = JSON.parse(responseText) as Record<string, unknown>;
345
+ logVerbose(`[infoflow:sendPrivate] response: status=${res.status}, data=${responseText}`);
290
346
 
291
347
  // Check outer code first
292
348
  const code = typeof data.code === "string" ? data.code : "";
@@ -309,10 +365,25 @@ export async function sendInfoflowPrivateMessage(params: {
309
365
  };
310
366
  }
311
367
 
312
- // Extract message ID and record for dedup
313
- const msgkey = extractMessageId(innerData ?? {});
368
+ // Extract message ID from raw text to preserve large integer precision
369
+ const msgkey =
370
+ extractIdFromRawJson(responseText, "msgkey") ??
371
+ extractIdFromRawJson(responseText, "messageid") ??
372
+ extractMessageId(innerData ?? {});
314
373
  if (msgkey) {
315
374
  recordSentMessageId(msgkey);
375
+ try {
376
+ recordSentMessage(account.accountId, {
377
+ target: toUser,
378
+ from: buildAgentFrom(account.config.appAgentId),
379
+ messageid: msgkey,
380
+ msgseqid: "",
381
+ digest: buildMessageDigest(contents),
382
+ sentAt: Date.now(),
383
+ });
384
+ } catch {
385
+ // Do not block sending
386
+ }
316
387
  }
317
388
 
318
389
  return { ok: true, invaliduser: innerData?.invaliduser as string | undefined, msgkey };
@@ -339,8 +410,9 @@ export async function sendInfoflowGroupMessage(params: {
339
410
  account: ResolvedInfoflowAccount;
340
411
  groupId: number;
341
412
  contents: InfoflowMessageContentItem[];
413
+ replyTo?: InfoflowOutboundReply;
342
414
  timeoutMs?: number;
343
- }): Promise<{ ok: boolean; error?: string; messageid?: string }> {
415
+ }): Promise<{ ok: boolean; error?: string; messageid?: string; msgseqid?: string }> {
344
416
  const { account, groupId, contents, timeoutMs = DEFAULT_TIMEOUT_MS } = params;
345
417
  const { apiHost, appKey, appSecret } = account.config;
346
418
 
@@ -392,92 +464,413 @@ export async function sendInfoflowGroupMessage(params: {
392
464
  if (agentIds.length > 0) {
393
465
  body.push({ type: "AT", atuserids: [], atagentids: agentIds });
394
466
  }
467
+ } else if (type === "image") {
468
+ body.push({ type: "IMAGE", content: item.content });
395
469
  }
396
470
  }
397
471
 
398
- const headerMsgType = hasMarkdown ? "MD" : "TEXT";
472
+ // Split body: LINK and IMAGE must be sent as individual messages
473
+ const linkItems = body.filter((b) => b.type === "LINK");
474
+ const imageItems = body.filter((b) => b.type === "IMAGE");
475
+ const textItems = body.filter((b) => b.type !== "LINK" && b.type !== "IMAGE");
399
476
 
400
- // Get token first
477
+ // Get token first (shared by all sends)
401
478
  const tokenResult = await getAppAccessToken({ apiHost, appKey, appSecret, timeoutMs });
402
479
  if (!tokenResult.ok || !tokenResult.token) {
403
480
  getInfoflowSendLog().error(`[infoflow:sendGroup] token error: ${tokenResult.error}`);
404
481
  return { ok: false, error: tokenResult.error ?? "failed to get token" };
405
482
  }
406
483
 
484
+ // NOTE: Infoflow API requires "Bearer-<token>" format (with hyphen, not space).
485
+ // This is a non-standard format specific to Infoflow service. Do not modify
486
+ // unless the Infoflow API specification changes.
487
+ const headers = {
488
+ Authorization: `Bearer-${tokenResult.token}`,
489
+ "Content-Type": "application/json",
490
+ };
491
+
492
+ let msgIndex = 0;
493
+
494
+ // Helper: post a single group message payload
495
+ const postGroupMessage = async (
496
+ msgBody: InfoflowGroupMessageBodyItem[],
497
+ msgtype: string,
498
+ replyTo?: InfoflowOutboundReply,
499
+ ): Promise<{ ok: boolean; error?: string; messageid?: string; msgseqid?: string }> => {
500
+ let timeout: ReturnType<typeof setTimeout> | undefined;
501
+ try {
502
+ const controller = new AbortController();
503
+ timeout = setTimeout(() => controller.abort(), timeoutMs);
504
+
505
+ const payload = {
506
+ message: {
507
+ header: {
508
+ toid: groupId,
509
+ totype: "GROUP",
510
+ msgtype,
511
+ clientmsgid: Date.now() + msgIndex++,
512
+ role: "robot",
513
+ },
514
+ body: msgBody,
515
+ ...(replyTo
516
+ ? {
517
+ reply: {
518
+ messageid: replyTo.messageid,
519
+ preview: replyTo.preview ?? "",
520
+ replytype: replyTo.replytype ?? "1",
521
+ },
522
+ }
523
+ : {}),
524
+ },
525
+ };
526
+
527
+ const bodyStr = JSON.stringify(payload);
528
+ logVerbose(`[infoflow:sendGroup] POST body: ${bodyStr}`);
529
+
530
+ const res = await fetch(`${ensureHttps(apiHost)}${INFOFLOW_GROUP_SEND_PATH}`, {
531
+ method: "POST",
532
+ headers,
533
+ body: bodyStr,
534
+ signal: controller.signal,
535
+ });
536
+
537
+ const responseText = await res.text();
538
+ const data = JSON.parse(responseText) as Record<string, unknown>;
539
+ logVerbose(`[infoflow:sendGroup] response: status=${res.status}, data=${responseText}`);
540
+
541
+ const code = typeof data.code === "string" ? data.code : "";
542
+ if (code !== "ok") {
543
+ const errMsg = String(data.message ?? data.errmsg ?? `code=${code || "unknown"}`);
544
+ getInfoflowSendLog().error(`[infoflow:sendGroup] failed: ${errMsg}`);
545
+ return { ok: false, error: errMsg };
546
+ }
547
+
548
+ const innerData = data.data as Record<string, unknown> | undefined;
549
+ const errcode = innerData?.errcode;
550
+ if (errcode != null && errcode !== 0) {
551
+ const errMsg = String(innerData?.errmsg ?? `errcode ${errcode}`);
552
+ getInfoflowSendLog().error(`[infoflow:sendGroup] failed: ${errMsg}`);
553
+ return { ok: false, error: errMsg };
554
+ }
555
+
556
+ // Extract IDs from raw text to preserve large integer precision
557
+ const messageid =
558
+ extractIdFromRawJson(responseText, "messageid") ??
559
+ extractIdFromRawJson(responseText, "msgid");
560
+ const msgseqid = extractIdFromRawJson(responseText, "msgseqid");
561
+ if (messageid) {
562
+ recordSentMessageId(messageid);
563
+ }
564
+
565
+ return { ok: true, messageid, msgseqid };
566
+ } catch (err) {
567
+ const errMsg = formatInfoflowError(err);
568
+ getInfoflowSendLog().error(`[infoflow:sendGroup] exception: ${errMsg}`);
569
+ return { ok: false, error: errMsg };
570
+ } finally {
571
+ clearTimeout(timeout);
572
+ }
573
+ };
574
+
575
+ // Helper: record a successful sub-message to the persistent store
576
+ const recordToStore = (
577
+ result: { messageid?: string; msgseqid?: string },
578
+ digestContents: InfoflowMessageContentItem[],
579
+ ) => {
580
+ if (!result.messageid) return;
581
+ try {
582
+ recordSentMessage(account.accountId, {
583
+ target: `group:${groupId}`,
584
+ from: buildAgentFrom(account.config.appAgentId),
585
+ messageid: result.messageid,
586
+ msgseqid: result.msgseqid ?? "",
587
+ digest: buildMessageDigest(digestContents),
588
+ sentAt: Date.now(),
589
+ });
590
+ } catch {
591
+ // Do not block sending
592
+ }
593
+ };
594
+
595
+ let lastMessageId: string | undefined;
596
+ let lastMsgSeqId: string | undefined;
597
+ let firstError: string | undefined;
598
+ let replyApplied = false;
599
+
600
+ // 1) Send text/AT/MD items together (if any)
601
+ if (textItems.length > 0) {
602
+ const msgtype = hasMarkdown ? "MD" : "TEXT";
603
+ const result = await postGroupMessage(
604
+ textItems,
605
+ msgtype,
606
+ !replyApplied ? params.replyTo : undefined,
607
+ );
608
+ replyApplied = true;
609
+ if (result.ok) {
610
+ lastMessageId = result.messageid;
611
+ lastMsgSeqId = result.msgseqid;
612
+ const digestItems = contents.filter((c) => !["link", "image"].includes(c.type.toLowerCase()));
613
+ recordToStore(result, digestItems);
614
+ } else if (!firstError) {
615
+ firstError = result.error;
616
+ }
617
+ }
618
+
619
+ // 2) Send each LINK as a separate message
620
+ for (const linkItem of linkItems) {
621
+ const result = await postGroupMessage(
622
+ [linkItem],
623
+ "TEXT",
624
+ !replyApplied ? params.replyTo : undefined,
625
+ );
626
+ replyApplied = true;
627
+ if (result.ok) {
628
+ lastMessageId = result.messageid;
629
+ lastMsgSeqId = result.msgseqid;
630
+ recordToStore(result, [{ type: "link", content: linkItem.href }]);
631
+ } else if (!firstError) {
632
+ firstError = result.error;
633
+ }
634
+ }
635
+
636
+ // 3) Send each IMAGE as a separate message
637
+ for (const imageItem of imageItems) {
638
+ const result = await postGroupMessage(
639
+ [imageItem],
640
+ "IMAGE",
641
+ !replyApplied ? params.replyTo : undefined,
642
+ );
643
+ replyApplied = true;
644
+ if (result.ok) {
645
+ lastMessageId = result.messageid;
646
+ lastMsgSeqId = result.msgseqid;
647
+ recordToStore(result, [{ type: "image", content: "" }]);
648
+ } else if (!firstError) {
649
+ firstError = result.error;
650
+ }
651
+ }
652
+
653
+ if (firstError) {
654
+ return { ok: false, error: firstError, messageid: lastMessageId, msgseqid: lastMsgSeqId };
655
+ }
656
+ return { ok: true, messageid: lastMessageId, msgseqid: lastMsgSeqId };
657
+ }
658
+
659
+ // ---------------------------------------------------------------------------
660
+ // Group Message Recall (撤回)
661
+ // ---------------------------------------------------------------------------
662
+
663
+ /**
664
+ * Recalls (撤回) a group message previously sent by the robot.
665
+ * Only group messages can be recalled via this API.
666
+ */
667
+ export async function recallInfoflowGroupMessage(params: {
668
+ account: ResolvedInfoflowAccount;
669
+ groupId: number;
670
+ messageid: string;
671
+ msgseqid: string;
672
+ timeoutMs?: number;
673
+ }): Promise<{ ok: boolean; error?: string }> {
674
+ const { account, groupId, messageid, msgseqid, timeoutMs = DEFAULT_TIMEOUT_MS } = params;
675
+ const { apiHost, appKey, appSecret } = account.config;
676
+
677
+ // 验证必要的认证配置
678
+ if (!appKey || !appSecret) {
679
+ return { ok: false, error: "Infoflow appKey/appSecret not configured." };
680
+ }
681
+
682
+ // 获取应用访问令牌
683
+ const tokenResult = await getAppAccessToken({ apiHost, appKey, appSecret, timeoutMs });
684
+ if (!tokenResult.ok || !tokenResult.token) {
685
+ getInfoflowSendLog().error(`[infoflow:recallGroup] token error: ${tokenResult.error}`);
686
+ return { ok: false, error: tokenResult.error ?? "failed to get token" };
687
+ }
688
+
407
689
  let timeout: ReturnType<typeof setTimeout> | undefined;
408
690
  try {
409
691
  const controller = new AbortController();
410
692
  timeout = setTimeout(() => controller.abort(), timeoutMs);
411
693
 
412
- const payload = {
413
- message: {
414
- header: {
415
- toid: groupId,
416
- totype: "GROUP",
417
- msgtype: headerMsgType,
418
- clientmsgid: Date.now(),
419
- role: "robot",
420
- },
421
- body,
694
+ // 手动构建 JSON 以保持 messageid/msgseqid 为原始整数,避免 JavaScript Number 精度丢失
695
+ const bodyStr = `{"groupId":${groupId},"messageid":${messageid},"msgseqid":${msgseqid}}`;
696
+
697
+ logVerbose(`[infoflow:recallGroup] POST token: ${tokenResult.token} body: ${bodyStr}`);
698
+
699
+ // 发送撤回请求
700
+ const res = await fetch(`${ensureHttps(apiHost)}${INFOFLOW_GROUP_RECALL_PATH}`, {
701
+ method: "POST",
702
+ headers: {
703
+ Authorization: `Bearer-${tokenResult.token}`,
704
+ "Content-Type": "application/json; charset=utf-8",
422
705
  },
423
- };
706
+ body: bodyStr,
707
+ signal: controller.signal,
708
+ });
424
709
 
425
- // NOTE: Infoflow API requires "Bearer-<token>" format (with hyphen, not space).
426
- // This is a non-standard format specific to Infoflow service. Do not modify
427
- // unless the Infoflow API specification changes.
428
- const headers = {
429
- Authorization: `Bearer-${tokenResult.token}`,
430
- "Content-Type": "application/json",
431
- };
710
+ const data = JSON.parse(await res.text()) as Record<string, unknown>;
711
+ logVerbose(
712
+ `[infoflow:recallGroup] response: status=${res.status}, data=${JSON.stringify(data)}`,
713
+ );
432
714
 
433
- const bodyStr = JSON.stringify(payload);
715
+ // 检查外层响应码
716
+ const code = typeof data.code === "string" ? data.code : "";
717
+ if (code !== "ok") {
718
+ const errMsg = String(data.message ?? data.errmsg ?? `code=${code || "unknown"}`);
719
+ getInfoflowSendLog().error(`[infoflow:recallGroup] failed: ${errMsg}`);
720
+ return { ok: false, error: errMsg };
721
+ }
722
+
723
+ // 检查内层错误码
724
+ const innerData = data.data as Record<string, unknown> | undefined;
725
+ const errcode = innerData?.errcode;
726
+ if (errcode != null && errcode !== 0) {
727
+ const errMsg = String(innerData?.errmsg ?? `errcode ${errcode}`);
728
+ getInfoflowSendLog().error(`[infoflow:recallGroup] failed: ${errMsg}`);
729
+ return { ok: false, error: errMsg };
730
+ }
731
+
732
+ return { ok: true };
733
+ } catch (err) {
734
+ const errMsg = formatInfoflowError(err);
735
+ getInfoflowSendLog().error(`[infoflow:recallGroup] exception: ${errMsg}`);
736
+ return { ok: false, error: errMsg };
737
+ } finally {
738
+ clearTimeout(timeout);
739
+ }
740
+ }
741
+
742
+ // ---------------------------------------------------------------------------
743
+ // Private Message Recall (撤回)
744
+ // ---------------------------------------------------------------------------
745
+
746
+ /**
747
+ * Recalls (撤回) a private message previously sent by the app.
748
+ * Uses the /api/v1/app/message/revoke endpoint.
749
+ */
750
+ export async function recallInfoflowPrivateMessage(params: {
751
+ account: ResolvedInfoflowAccount;
752
+ /** 发送消息时返回的 msgkey(存储于 sent-message-store 的 messageid 字段) */
753
+ msgkey: string;
754
+ /** 如流企业后台"应用ID" */
755
+ appAgentId: number;
756
+ timeoutMs?: number;
757
+ }): Promise<{ ok: boolean; error?: string }> {
758
+ const { account, msgkey, appAgentId, timeoutMs = DEFAULT_TIMEOUT_MS } = params;
759
+ const { apiHost, appKey, appSecret } = account.config;
760
+
761
+ if (!appKey || !appSecret) {
762
+ return { ok: false, error: "Infoflow appKey/appSecret not configured." };
763
+ }
764
+
765
+ const tokenResult = await getAppAccessToken({ apiHost, appKey, appSecret, timeoutMs });
766
+ if (!tokenResult.ok || !tokenResult.token) {
767
+ getInfoflowSendLog().error(`[infoflow:recallPrivate] token error: ${tokenResult.error}`);
768
+ return { ok: false, error: tokenResult.error ?? "failed to get token" };
769
+ }
770
+
771
+ let timeout: ReturnType<typeof setTimeout> | undefined;
772
+ try {
773
+ const controller = new AbortController();
774
+ timeout = setTimeout(() => controller.abort(), timeoutMs);
775
+
776
+ const bodyStr = JSON.stringify({ msgkey, agentid: appAgentId });
434
777
 
435
- // Log request body when verbose logging is enabled
436
- logVerbose(`[infoflow:sendGroup] POST body: ${bodyStr}`);
778
+ logVerbose(`[infoflow:recallPrivate] POST auth: ${tokenResult.token} body: ${bodyStr}`);
437
779
 
438
- const res = await fetch(`${ensureHttps(apiHost)}${INFOFLOW_GROUP_SEND_PATH}`, {
780
+ const res = await fetch(`${ensureHttps(apiHost)}${INFOFLOW_PRIVATE_RECALL_PATH}`, {
439
781
  method: "POST",
440
- headers,
782
+ headers: {
783
+ Authorization: `Bearer-${tokenResult.token}`,
784
+ "Content-Type": "application/json; charset=utf-8",
785
+ LOGID: String(Date.now()),
786
+ },
441
787
  body: bodyStr,
442
788
  signal: controller.signal,
443
789
  });
444
790
 
445
791
  const data = JSON.parse(await res.text()) as Record<string, unknown>;
792
+ logVerbose(
793
+ `[infoflow:recallPrivate] response: status=${res.status}, data=${JSON.stringify(data)}`,
794
+ );
446
795
 
447
- // Check outer code first
796
+ // 检查外层响应码
448
797
  const code = typeof data.code === "string" ? data.code : "";
449
798
  if (code !== "ok") {
450
799
  const errMsg = String(data.message ?? data.errmsg ?? `code=${code || "unknown"}`);
451
- getInfoflowSendLog().error(`[infoflow:sendGroup] failed: ${errMsg}`);
800
+ getInfoflowSendLog().error(`[infoflow:recallPrivate] failed: ${errMsg}`);
452
801
  return { ok: false, error: errMsg };
453
802
  }
454
803
 
455
- // Check inner data.errcode
804
+ // 检查内层错误码
456
805
  const innerData = data.data as Record<string, unknown> | undefined;
457
806
  const errcode = innerData?.errcode;
458
807
  if (errcode != null && errcode !== 0) {
459
808
  const errMsg = String(innerData?.errmsg ?? `errcode ${errcode}`);
460
- getInfoflowSendLog().error(`[infoflow:sendGroup] failed: ${errMsg}`);
809
+ getInfoflowSendLog().error(`[infoflow:recallPrivate] failed: ${errMsg}`);
461
810
  return { ok: false, error: errMsg };
462
811
  }
463
812
 
464
- // Extract message ID from nested data.data structure and record for dedup
465
- const nestedData = innerData?.data as Record<string, unknown> | undefined;
466
- const messageid = extractMessageId(nestedData ?? innerData ?? {});
467
- if (messageid) {
468
- recordSentMessageId(messageid);
469
- }
470
-
471
- return { ok: true, messageid };
813
+ return { ok: true };
472
814
  } catch (err) {
473
815
  const errMsg = formatInfoflowError(err);
474
- getInfoflowSendLog().error(`[infoflow:sendGroup] exception: ${errMsg}`);
816
+ getInfoflowSendLog().error(`[infoflow:recallPrivate] exception: ${errMsg}`);
475
817
  return { ok: false, error: errMsg };
476
818
  } finally {
477
819
  clearTimeout(timeout);
478
820
  }
479
821
  }
480
822
 
823
+ // ---------------------------------------------------------------------------
824
+ // Local Image Link Resolution
825
+ // ---------------------------------------------------------------------------
826
+
827
+ /**
828
+ * Pre-processes content items: for "link" items pointing to local file paths,
829
+ * checks if the file is an image and converts to "image" type with base64 content.
830
+ * Falls back to original "link" type if not an image or on error.
831
+ */
832
+ async function resolveLocalImageLinks(
833
+ contents: InfoflowMessageContentItem[],
834
+ ): Promise<InfoflowMessageContentItem[]> {
835
+ const hasLocalLinks = contents.some(
836
+ (item) => item.type === "link" && isLikelyLocalPath(parseLinkContent(item.content).href),
837
+ );
838
+ if (!hasLocalLinks) {
839
+ return contents;
840
+ }
841
+
842
+ // Dynamic import to avoid circular dependency (media.ts imports from send.ts)
843
+ const { prepareInfoflowImageBase64 } = await import("./media.js");
844
+
845
+ const resolved: InfoflowMessageContentItem[] = [];
846
+ for (const item of contents) {
847
+ if (item.type !== "link") {
848
+ resolved.push(item);
849
+ continue;
850
+ }
851
+
852
+ const { href } = parseLinkContent(item.content);
853
+ if (!isLikelyLocalPath(href)) {
854
+ resolved.push(item);
855
+ continue;
856
+ }
857
+
858
+ // Attempt image detection for local path
859
+ try {
860
+ const prepared = await prepareInfoflowImageBase64({ mediaUrl: href });
861
+ if (prepared.isImage) {
862
+ resolved.push({ type: "image", content: prepared.base64 });
863
+ continue;
864
+ }
865
+ } catch {
866
+ logVerbose(`[infoflow:send] local image detection failed for ${href}, sending as link`);
867
+ }
868
+ resolved.push(item);
869
+ }
870
+
871
+ return resolved;
872
+ }
873
+
481
874
  // ---------------------------------------------------------------------------
482
875
  // Unified Message Sending
483
876
  // ---------------------------------------------------------------------------
@@ -485,17 +878,20 @@ export async function sendInfoflowGroupMessage(params: {
485
878
  /**
486
879
  * Unified message sending entry point.
487
880
  * Parses the `to` target and dispatches to group or private message sending.
881
+ * Local file path links that are images are automatically sent as native images.
488
882
  * @param cfg - OpenClaw config
489
883
  * @param to - Target: "username" for private, "group:123" for group
490
884
  * @param contents - Array of content items (text/markdown/at)
491
885
  * @param accountId - Optional account ID for multi-account support
886
+ * @param replyTo - Optional reply context for group messages (ignored for private)
492
887
  */
493
888
  export async function sendInfoflowMessage(params: {
494
889
  cfg: OpenClawConfig;
495
890
  to: string;
496
891
  contents: InfoflowMessageContentItem[];
497
892
  accountId?: string;
498
- }): Promise<{ ok: boolean; error?: string; messageId?: string }> {
893
+ replyTo?: InfoflowOutboundReply;
894
+ }): Promise<{ ok: boolean; error?: string; messageId?: string; msgseqid?: string }> {
499
895
  const { cfg, to, contents, accountId } = params;
500
896
 
501
897
  // Resolve account config
@@ -511,28 +907,73 @@ export async function sendInfoflowMessage(params: {
511
907
  return { ok: false, error: "contents array is empty" };
512
908
  }
513
909
 
910
+ // Pre-process: convert local-path link items to native image items if they're images
911
+ const resolvedContents = await resolveLocalImageLinks(contents);
912
+
514
913
  // Parse target: remove "infoflow:" prefix if present
515
914
  const target = to.replace(/^infoflow:/i, "");
516
915
 
517
916
  // Check if target is a group (format: group:123)
518
917
  const groupMatch = target.match(/^group:(\d+)/i);
519
918
  if (groupMatch) {
919
+ // Group path: sendInfoflowGroupMessage already handles IMAGE items
520
920
  const groupId = Number(groupMatch[1]);
521
- const result = await sendInfoflowGroupMessage({ account, groupId, contents });
921
+ const result = await sendInfoflowGroupMessage({
922
+ account,
923
+ groupId,
924
+ contents: resolvedContents,
925
+ replyTo: params.replyTo,
926
+ });
522
927
  return {
523
928
  ok: result.ok,
524
929
  error: result.error,
525
930
  messageId: result.messageid,
931
+ msgseqid: result.msgseqid,
526
932
  };
527
933
  }
528
934
 
529
- // Private message (DM)
530
- const result = await sendInfoflowPrivateMessage({ account, toUser: target, contents });
531
- return {
532
- ok: result.ok,
533
- error: result.error,
534
- messageId: result.msgkey,
535
- };
935
+ // Private path: split image items (sendInfoflowPrivateMessage doesn't handle image type)
936
+ const imageItems = resolvedContents.filter((c) => c.type === "image");
937
+ const nonImageContents = resolvedContents.filter((c) => c.type !== "image");
938
+
939
+ let lastMessageId: string | undefined;
940
+ let firstError: string | undefined;
941
+
942
+ // Send non-image contents via private message API
943
+ if (nonImageContents.length > 0) {
944
+ const result = await sendInfoflowPrivateMessage({
945
+ account,
946
+ toUser: target,
947
+ contents: nonImageContents,
948
+ });
949
+ if (result.ok) {
950
+ lastMessageId = result.msgkey;
951
+ } else {
952
+ firstError = result.error;
953
+ }
954
+ }
955
+
956
+ // Send image items as native private images
957
+ if (imageItems.length > 0) {
958
+ const { sendInfoflowPrivateImage } = await import("./media.js");
959
+ for (const imgItem of imageItems) {
960
+ const result = await sendInfoflowPrivateImage({
961
+ account,
962
+ toUser: target,
963
+ base64Image: imgItem.content,
964
+ });
965
+ if (result.ok) {
966
+ lastMessageId = result.msgkey;
967
+ } else if (!firstError) {
968
+ firstError = result.error;
969
+ }
970
+ }
971
+ }
972
+
973
+ if (firstError && !lastMessageId) {
974
+ return { ok: false, error: firstError };
975
+ }
976
+ return { ok: true, messageId: lastMessageId };
536
977
  }
537
978
 
538
979
  // ---------------------------------------------------------------------------