@chbo297/infoflow 2026.3.4 → 2026.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +426 -15
- package/openclaw.plugin.json +14 -2
- package/package.json +2 -2
- package/src/accounts.ts +3 -0
- package/src/actions.ts +346 -4
- package/src/bot.ts +197 -9
- package/src/channel.ts +65 -23
- package/src/infoflow-req-parse.ts +2 -3
- package/src/media.ts +369 -0
- package/src/reply-dispatcher.ts +157 -67
- package/src/send.ts +498 -57
- package/src/sent-message-store.ts +267 -0
- package/src/types.ts +21 -2
package/src/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
|
|
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
|
|
313
|
-
const msgkey =
|
|
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
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)}${
|
|
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
|
-
//
|
|
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:
|
|
800
|
+
getInfoflowSendLog().error(`[infoflow:recallPrivate] failed: ${errMsg}`);
|
|
452
801
|
return { ok: false, error: errMsg };
|
|
453
802
|
}
|
|
454
803
|
|
|
455
|
-
//
|
|
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:
|
|
809
|
+
getInfoflowSendLog().error(`[infoflow:recallPrivate] failed: ${errMsg}`);
|
|
461
810
|
return { ok: false, error: errMsg };
|
|
462
811
|
}
|
|
463
812
|
|
|
464
|
-
|
|
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:
|
|
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
|
-
|
|
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({
|
|
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
|
|
530
|
-
const
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|