@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/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 } 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,24 @@ 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
+ messageid: msgkey,
379
+ msgseqid: "",
380
+ digest: buildMessageDigest(contents),
381
+ sentAt: Date.now(),
382
+ });
383
+ } catch {
384
+ // Do not block sending
385
+ }
316
386
  }
317
387
 
318
388
  return { ok: true, invaliduser: innerData?.invaliduser as string | undefined, msgkey };
@@ -339,8 +409,9 @@ export async function sendInfoflowGroupMessage(params: {
339
409
  account: ResolvedInfoflowAccount;
340
410
  groupId: number;
341
411
  contents: InfoflowMessageContentItem[];
412
+ replyTo?: InfoflowOutboundReply;
342
413
  timeoutMs?: number;
343
- }): Promise<{ ok: boolean; error?: string; messageid?: string }> {
414
+ }): Promise<{ ok: boolean; error?: string; messageid?: string; msgseqid?: string }> {
344
415
  const { account, groupId, contents, timeoutMs = DEFAULT_TIMEOUT_MS } = params;
345
416
  const { apiHost, appKey, appSecret } = account.config;
346
417
 
@@ -392,92 +463,413 @@ export async function sendInfoflowGroupMessage(params: {
392
463
  if (agentIds.length > 0) {
393
464
  body.push({ type: "AT", atuserids: [], atagentids: agentIds });
394
465
  }
466
+ } else if (type === "image") {
467
+ body.push({ type: "IMAGE", content: item.content });
395
468
  }
396
469
  }
397
470
 
398
- const headerMsgType = hasMarkdown ? "MD" : "TEXT";
471
+ // Split body: LINK and IMAGE must be sent as individual messages
472
+ const linkItems = body.filter((b) => b.type === "LINK");
473
+ const imageItems = body.filter((b) => b.type === "IMAGE");
474
+ const textItems = body.filter((b) => b.type !== "LINK" && b.type !== "IMAGE");
399
475
 
400
- // Get token first
476
+ // Get token first (shared by all sends)
401
477
  const tokenResult = await getAppAccessToken({ apiHost, appKey, appSecret, timeoutMs });
402
478
  if (!tokenResult.ok || !tokenResult.token) {
403
479
  getInfoflowSendLog().error(`[infoflow:sendGroup] token error: ${tokenResult.error}`);
404
480
  return { ok: false, error: tokenResult.error ?? "failed to get token" };
405
481
  }
406
482
 
483
+ // NOTE: Infoflow API requires "Bearer-<token>" format (with hyphen, not space).
484
+ // This is a non-standard format specific to Infoflow service. Do not modify
485
+ // unless the Infoflow API specification changes.
486
+ const headers = {
487
+ Authorization: `Bearer-${tokenResult.token}`,
488
+ "Content-Type": "application/json",
489
+ };
490
+
491
+ let msgIndex = 0;
492
+
493
+ // Helper: post a single group message payload
494
+ const postGroupMessage = async (
495
+ msgBody: InfoflowGroupMessageBodyItem[],
496
+ msgtype: string,
497
+ replyTo?: InfoflowOutboundReply,
498
+ ): Promise<{ ok: boolean; error?: string; messageid?: string; msgseqid?: string }> => {
499
+ let timeout: ReturnType<typeof setTimeout> | undefined;
500
+ try {
501
+ const controller = new AbortController();
502
+ timeout = setTimeout(() => controller.abort(), timeoutMs);
503
+
504
+ const payload = {
505
+ message: {
506
+ header: {
507
+ toid: groupId,
508
+ totype: "GROUP",
509
+ msgtype,
510
+ clientmsgid: Date.now() + msgIndex++,
511
+ role: "robot",
512
+ },
513
+ body: msgBody,
514
+ ...(replyTo
515
+ ? {
516
+ reply: {
517
+ messageid: replyTo.messageid,
518
+ preview: replyTo.preview ?? "",
519
+ replytype: replyTo.replytype ?? "1",
520
+ },
521
+ }
522
+ : {}),
523
+ },
524
+ };
525
+
526
+ const bodyStr = JSON.stringify(payload);
527
+ logVerbose(`[infoflow:sendGroup] POST body: ${bodyStr}`);
528
+
529
+ const res = await fetch(`${ensureHttps(apiHost)}${INFOFLOW_GROUP_SEND_PATH}`, {
530
+ method: "POST",
531
+ headers,
532
+ body: bodyStr,
533
+ signal: controller.signal,
534
+ });
535
+
536
+ const responseText = await res.text();
537
+ const data = JSON.parse(responseText) as Record<string, unknown>;
538
+ logVerbose(`[infoflow:sendGroup] response: status=${res.status}, data=${responseText}`);
539
+
540
+ const code = typeof data.code === "string" ? data.code : "";
541
+ if (code !== "ok") {
542
+ const errMsg = String(data.message ?? data.errmsg ?? `code=${code || "unknown"}`);
543
+ getInfoflowSendLog().error(`[infoflow:sendGroup] failed: ${errMsg}`);
544
+ return { ok: false, error: errMsg };
545
+ }
546
+
547
+ const innerData = data.data as Record<string, unknown> | undefined;
548
+ const errcode = innerData?.errcode;
549
+ if (errcode != null && errcode !== 0) {
550
+ const errMsg = String(innerData?.errmsg ?? `errcode ${errcode}`);
551
+ getInfoflowSendLog().error(`[infoflow:sendGroup] failed: ${errMsg}`);
552
+ return { ok: false, error: errMsg };
553
+ }
554
+
555
+ // Extract IDs from raw text to preserve large integer precision
556
+ const messageid =
557
+ extractIdFromRawJson(responseText, "messageid") ??
558
+ extractIdFromRawJson(responseText, "msgid");
559
+ const msgseqid = extractIdFromRawJson(responseText, "msgseqid");
560
+ if (messageid) {
561
+ recordSentMessageId(messageid);
562
+ }
563
+
564
+ return { ok: true, messageid, msgseqid };
565
+ } catch (err) {
566
+ const errMsg = formatInfoflowError(err);
567
+ getInfoflowSendLog().error(`[infoflow:sendGroup] exception: ${errMsg}`);
568
+ return { ok: false, error: errMsg };
569
+ } finally {
570
+ clearTimeout(timeout);
571
+ }
572
+ };
573
+
574
+ // Helper: record a successful sub-message to the persistent store
575
+ const recordToStore = (
576
+ result: { messageid?: string; msgseqid?: string },
577
+ digestContents: InfoflowMessageContentItem[],
578
+ ) => {
579
+ if (result.messageid) {
580
+ try {
581
+ recordSentMessage(account.accountId, {
582
+ target: `group:${groupId}`,
583
+ messageid: result.messageid,
584
+ msgseqid: result.msgseqid ?? "",
585
+ digest: buildMessageDigest(digestContents),
586
+ sentAt: Date.now(),
587
+ });
588
+ } catch {
589
+ // Do not block sending
590
+ }
591
+ }
592
+ };
593
+
594
+ let lastMessageId: string | undefined;
595
+ let lastMsgSeqId: string | undefined;
596
+ let firstError: string | undefined;
597
+ let replyApplied = false;
598
+
599
+ // 1) Send text/AT/MD items together (if any)
600
+ if (textItems.length > 0) {
601
+ const msgtype = hasMarkdown ? "MD" : "TEXT";
602
+ const result = await postGroupMessage(
603
+ textItems,
604
+ msgtype,
605
+ !replyApplied ? params.replyTo : undefined,
606
+ );
607
+ replyApplied = true;
608
+ if (result.ok) {
609
+ lastMessageId = result.messageid;
610
+ lastMsgSeqId = result.msgseqid;
611
+ const digestItems = contents.filter((c) => !["link", "image"].includes(c.type.toLowerCase()));
612
+ recordToStore(result, digestItems);
613
+ } else if (!firstError) {
614
+ firstError = result.error;
615
+ }
616
+ }
617
+
618
+ // 2) Send each LINK as a separate message
619
+ for (const linkItem of linkItems) {
620
+ const result = await postGroupMessage(
621
+ [linkItem],
622
+ "TEXT",
623
+ !replyApplied ? params.replyTo : undefined,
624
+ );
625
+ replyApplied = true;
626
+ if (result.ok) {
627
+ lastMessageId = result.messageid;
628
+ lastMsgSeqId = result.msgseqid;
629
+ recordToStore(result, [{ type: "link", content: linkItem.href }]);
630
+ } else if (!firstError) {
631
+ firstError = result.error;
632
+ }
633
+ }
634
+
635
+ // 3) Send each IMAGE as a separate message
636
+ for (const imageItem of imageItems) {
637
+ const result = await postGroupMessage(
638
+ [imageItem],
639
+ "IMAGE",
640
+ !replyApplied ? params.replyTo : undefined,
641
+ );
642
+ replyApplied = true;
643
+ if (result.ok) {
644
+ lastMessageId = result.messageid;
645
+ lastMsgSeqId = result.msgseqid;
646
+ recordToStore(result, [{ type: "image", content: "" }]);
647
+ } else if (!firstError) {
648
+ firstError = result.error;
649
+ }
650
+ }
651
+
652
+ if (firstError) {
653
+ return { ok: false, error: firstError, messageid: lastMessageId, msgseqid: lastMsgSeqId };
654
+ }
655
+ return { ok: true, messageid: lastMessageId, msgseqid: lastMsgSeqId };
656
+ }
657
+
658
+ // ---------------------------------------------------------------------------
659
+ // Group Message Recall (撤回)
660
+ // ---------------------------------------------------------------------------
661
+
662
+ /**
663
+ * Recalls (撤回) a group message previously sent by the robot.
664
+ * Only group messages can be recalled via this API.
665
+ */
666
+ export async function recallInfoflowGroupMessage(params: {
667
+ account: ResolvedInfoflowAccount;
668
+ groupId: number;
669
+ messageid: string;
670
+ msgseqid: string;
671
+ timeoutMs?: number;
672
+ }): Promise<{ ok: boolean; error?: string }> {
673
+ const { account, groupId, messageid, msgseqid, timeoutMs = DEFAULT_TIMEOUT_MS } = params;
674
+ const { apiHost, appKey, appSecret } = account.config;
675
+
676
+ // 验证必要的认证配置
677
+ if (!appKey || !appSecret) {
678
+ return { ok: false, error: "Infoflow appKey/appSecret not configured." };
679
+ }
680
+
681
+ // 获取应用访问令牌
682
+ const tokenResult = await getAppAccessToken({ apiHost, appKey, appSecret, timeoutMs });
683
+ if (!tokenResult.ok || !tokenResult.token) {
684
+ getInfoflowSendLog().error(`[infoflow:recallGroup] token error: ${tokenResult.error}`);
685
+ return { ok: false, error: tokenResult.error ?? "failed to get token" };
686
+ }
687
+
407
688
  let timeout: ReturnType<typeof setTimeout> | undefined;
408
689
  try {
409
690
  const controller = new AbortController();
410
691
  timeout = setTimeout(() => controller.abort(), timeoutMs);
411
692
 
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,
693
+ // 手动构建 JSON 以保持 messageid/msgseqid 为原始整数,避免 JavaScript Number 精度丢失
694
+ const bodyStr = `{"groupId":${groupId},"messageid":${messageid},"msgseqid":${msgseqid}}`;
695
+
696
+ logVerbose(`[infoflow:recallGroup] POST token: ${tokenResult.token} body: ${bodyStr}`);
697
+
698
+ // 发送撤回请求
699
+ const res = await fetch(`${ensureHttps(apiHost)}${INFOFLOW_GROUP_RECALL_PATH}`, {
700
+ method: "POST",
701
+ headers: {
702
+ Authorization: `Bearer-${tokenResult.token}`,
703
+ "Content-Type": "application/json; charset=utf-8",
422
704
  },
423
- };
705
+ body: bodyStr,
706
+ signal: controller.signal,
707
+ });
424
708
 
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
- };
709
+ const data = JSON.parse(await res.text()) as Record<string, unknown>;
710
+ logVerbose(
711
+ `[infoflow:recallGroup] response: status=${res.status}, data=${JSON.stringify(data)}`,
712
+ );
432
713
 
433
- const bodyStr = JSON.stringify(payload);
714
+ // 检查外层响应码
715
+ const code = typeof data.code === "string" ? data.code : "";
716
+ if (code !== "ok") {
717
+ const errMsg = String(data.message ?? data.errmsg ?? `code=${code || "unknown"}`);
718
+ getInfoflowSendLog().error(`[infoflow:recallGroup] failed: ${errMsg}`);
719
+ return { ok: false, error: errMsg };
720
+ }
721
+
722
+ // 检查内层错误码
723
+ const innerData = data.data as Record<string, unknown> | undefined;
724
+ const errcode = innerData?.errcode;
725
+ if (errcode != null && errcode !== 0) {
726
+ const errMsg = String(innerData?.errmsg ?? `errcode ${errcode}`);
727
+ getInfoflowSendLog().error(`[infoflow:recallGroup] failed: ${errMsg}`);
728
+ return { ok: false, error: errMsg };
729
+ }
730
+
731
+ return { ok: true };
732
+ } catch (err) {
733
+ const errMsg = formatInfoflowError(err);
734
+ getInfoflowSendLog().error(`[infoflow:recallGroup] exception: ${errMsg}`);
735
+ return { ok: false, error: errMsg };
736
+ } finally {
737
+ clearTimeout(timeout);
738
+ }
739
+ }
740
+
741
+ // ---------------------------------------------------------------------------
742
+ // Private Message Recall (撤回)
743
+ // ---------------------------------------------------------------------------
744
+
745
+ /**
746
+ * Recalls (撤回) a private message previously sent by the app.
747
+ * Uses the /api/v1/app/message/revoke endpoint.
748
+ */
749
+ export async function recallInfoflowPrivateMessage(params: {
750
+ account: ResolvedInfoflowAccount;
751
+ /** 发送消息时返回的 msgkey(存储于 sent-message-store 的 messageid 字段) */
752
+ msgkey: string;
753
+ /** 如流企业后台"应用ID" */
754
+ appAgentId: number;
755
+ timeoutMs?: number;
756
+ }): Promise<{ ok: boolean; error?: string }> {
757
+ const { account, msgkey, appAgentId, timeoutMs = DEFAULT_TIMEOUT_MS } = params;
758
+ const { apiHost, appKey, appSecret } = account.config;
759
+
760
+ if (!appKey || !appSecret) {
761
+ return { ok: false, error: "Infoflow appKey/appSecret not configured." };
762
+ }
763
+
764
+ const tokenResult = await getAppAccessToken({ apiHost, appKey, appSecret, timeoutMs });
765
+ if (!tokenResult.ok || !tokenResult.token) {
766
+ getInfoflowSendLog().error(`[infoflow:recallPrivate] token error: ${tokenResult.error}`);
767
+ return { ok: false, error: tokenResult.error ?? "failed to get token" };
768
+ }
769
+
770
+ let timeout: ReturnType<typeof setTimeout> | undefined;
771
+ try {
772
+ const controller = new AbortController();
773
+ timeout = setTimeout(() => controller.abort(), timeoutMs);
774
+
775
+ const bodyStr = JSON.stringify({ msgkey, agentid: appAgentId });
434
776
 
435
- // Log request body when verbose logging is enabled
436
- logVerbose(`[infoflow:sendGroup] POST body: ${bodyStr}`);
777
+ logVerbose(`[infoflow:recallPrivate] POST body: ${bodyStr}`);
437
778
 
438
- const res = await fetch(`${ensureHttps(apiHost)}${INFOFLOW_GROUP_SEND_PATH}`, {
779
+ const res = await fetch(`${ensureHttps(apiHost)}${INFOFLOW_PRIVATE_RECALL_PATH}`, {
439
780
  method: "POST",
440
- headers,
781
+ headers: {
782
+ Authorization: `Bearer-${tokenResult.token}`,
783
+ "Content-Type": "application/json; charset=utf-8",
784
+ LOGID: String(Date.now()),
785
+ },
441
786
  body: bodyStr,
442
787
  signal: controller.signal,
443
788
  });
444
789
 
445
790
  const data = JSON.parse(await res.text()) as Record<string, unknown>;
791
+ logVerbose(
792
+ `[infoflow:recallPrivate] response: status=${res.status}, data=${JSON.stringify(data)}`,
793
+ );
446
794
 
447
- // Check outer code first
795
+ // 检查外层响应码
448
796
  const code = typeof data.code === "string" ? data.code : "";
449
797
  if (code !== "ok") {
450
798
  const errMsg = String(data.message ?? data.errmsg ?? `code=${code || "unknown"}`);
451
- getInfoflowSendLog().error(`[infoflow:sendGroup] failed: ${errMsg}`);
799
+ getInfoflowSendLog().error(`[infoflow:recallPrivate] failed: ${errMsg}`);
452
800
  return { ok: false, error: errMsg };
453
801
  }
454
802
 
455
- // Check inner data.errcode
803
+ // 检查内层错误码
456
804
  const innerData = data.data as Record<string, unknown> | undefined;
457
805
  const errcode = innerData?.errcode;
458
806
  if (errcode != null && errcode !== 0) {
459
807
  const errMsg = String(innerData?.errmsg ?? `errcode ${errcode}`);
460
- getInfoflowSendLog().error(`[infoflow:sendGroup] failed: ${errMsg}`);
808
+ getInfoflowSendLog().error(`[infoflow:recallPrivate] failed: ${errMsg}`);
461
809
  return { ok: false, error: errMsg };
462
810
  }
463
811
 
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 };
812
+ return { ok: true };
472
813
  } catch (err) {
473
814
  const errMsg = formatInfoflowError(err);
474
- getInfoflowSendLog().error(`[infoflow:sendGroup] exception: ${errMsg}`);
815
+ getInfoflowSendLog().error(`[infoflow:recallPrivate] exception: ${errMsg}`);
475
816
  return { ok: false, error: errMsg };
476
817
  } finally {
477
818
  clearTimeout(timeout);
478
819
  }
479
820
  }
480
821
 
822
+ // ---------------------------------------------------------------------------
823
+ // Local Image Link Resolution
824
+ // ---------------------------------------------------------------------------
825
+
826
+ /**
827
+ * Pre-processes content items: for "link" items pointing to local file paths,
828
+ * checks if the file is an image and converts to "image" type with base64 content.
829
+ * Falls back to original "link" type if not an image or on error.
830
+ */
831
+ async function resolveLocalImageLinks(
832
+ contents: InfoflowMessageContentItem[],
833
+ ): Promise<InfoflowMessageContentItem[]> {
834
+ const hasLocalLinks = contents.some(
835
+ (item) => item.type === "link" && isLikelyLocalPath(parseLinkContent(item.content).href),
836
+ );
837
+ if (!hasLocalLinks) {
838
+ return contents;
839
+ }
840
+
841
+ // Dynamic import to avoid circular dependency (media.ts imports from send.ts)
842
+ const { prepareInfoflowImageBase64 } = await import("./media.js");
843
+
844
+ const resolved: InfoflowMessageContentItem[] = [];
845
+ for (const item of contents) {
846
+ if (item.type !== "link") {
847
+ resolved.push(item);
848
+ continue;
849
+ }
850
+
851
+ const { href } = parseLinkContent(item.content);
852
+ if (!isLikelyLocalPath(href)) {
853
+ resolved.push(item);
854
+ continue;
855
+ }
856
+
857
+ // Attempt image detection for local path
858
+ try {
859
+ const prepared = await prepareInfoflowImageBase64({ mediaUrl: href });
860
+ if (prepared.isImage) {
861
+ resolved.push({ type: "image", content: prepared.base64 });
862
+ continue;
863
+ }
864
+ } catch {
865
+ logVerbose(`[infoflow:send] local image detection failed for ${href}, sending as link`);
866
+ }
867
+ resolved.push(item);
868
+ }
869
+
870
+ return resolved;
871
+ }
872
+
481
873
  // ---------------------------------------------------------------------------
482
874
  // Unified Message Sending
483
875
  // ---------------------------------------------------------------------------
@@ -485,17 +877,20 @@ export async function sendInfoflowGroupMessage(params: {
485
877
  /**
486
878
  * Unified message sending entry point.
487
879
  * Parses the `to` target and dispatches to group or private message sending.
880
+ * Local file path links that are images are automatically sent as native images.
488
881
  * @param cfg - OpenClaw config
489
882
  * @param to - Target: "username" for private, "group:123" for group
490
883
  * @param contents - Array of content items (text/markdown/at)
491
884
  * @param accountId - Optional account ID for multi-account support
885
+ * @param replyTo - Optional reply context for group messages (ignored for private)
492
886
  */
493
887
  export async function sendInfoflowMessage(params: {
494
888
  cfg: OpenClawConfig;
495
889
  to: string;
496
890
  contents: InfoflowMessageContentItem[];
497
891
  accountId?: string;
498
- }): Promise<{ ok: boolean; error?: string; messageId?: string }> {
892
+ replyTo?: InfoflowOutboundReply;
893
+ }): Promise<{ ok: boolean; error?: string; messageId?: string; msgseqid?: string }> {
499
894
  const { cfg, to, contents, accountId } = params;
500
895
 
501
896
  // Resolve account config
@@ -511,28 +906,73 @@ export async function sendInfoflowMessage(params: {
511
906
  return { ok: false, error: "contents array is empty" };
512
907
  }
513
908
 
909
+ // Pre-process: convert local-path link items to native image items if they're images
910
+ const resolvedContents = await resolveLocalImageLinks(contents);
911
+
514
912
  // Parse target: remove "infoflow:" prefix if present
515
913
  const target = to.replace(/^infoflow:/i, "");
516
914
 
517
915
  // Check if target is a group (format: group:123)
518
916
  const groupMatch = target.match(/^group:(\d+)/i);
519
917
  if (groupMatch) {
918
+ // Group path: sendInfoflowGroupMessage already handles IMAGE items
520
919
  const groupId = Number(groupMatch[1]);
521
- const result = await sendInfoflowGroupMessage({ account, groupId, contents });
920
+ const result = await sendInfoflowGroupMessage({
921
+ account,
922
+ groupId,
923
+ contents: resolvedContents,
924
+ replyTo: params.replyTo,
925
+ });
522
926
  return {
523
927
  ok: result.ok,
524
928
  error: result.error,
525
929
  messageId: result.messageid,
930
+ msgseqid: result.msgseqid,
526
931
  };
527
932
  }
528
933
 
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
- };
934
+ // Private path: split image items (sendInfoflowPrivateMessage doesn't handle image type)
935
+ const imageItems = resolvedContents.filter((c) => c.type === "image");
936
+ const nonImageContents = resolvedContents.filter((c) => c.type !== "image");
937
+
938
+ let lastMessageId: string | undefined;
939
+ let firstError: string | undefined;
940
+
941
+ // Send non-image contents via private message API
942
+ if (nonImageContents.length > 0) {
943
+ const result = await sendInfoflowPrivateMessage({
944
+ account,
945
+ toUser: target,
946
+ contents: nonImageContents,
947
+ });
948
+ if (result.ok) {
949
+ lastMessageId = result.msgkey;
950
+ } else {
951
+ firstError = result.error;
952
+ }
953
+ }
954
+
955
+ // Send image items as native private images
956
+ if (imageItems.length > 0) {
957
+ const { sendInfoflowPrivateImage } = await import("./media.js");
958
+ for (const imgItem of imageItems) {
959
+ const result = await sendInfoflowPrivateImage({
960
+ account,
961
+ toUser: target,
962
+ base64Image: imgItem.content,
963
+ });
964
+ if (result.ok) {
965
+ lastMessageId = result.msgkey;
966
+ } else if (!firstError) {
967
+ firstError = result.error;
968
+ }
969
+ }
970
+ }
971
+
972
+ if (firstError && !lastMessageId) {
973
+ return { ok: false, error: firstError };
974
+ }
975
+ return { ok: true, messageId: lastMessageId };
536
976
  }
537
977
 
538
978
  // ---------------------------------------------------------------------------