@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/README.md +426 -15
- package/index.ts +6 -1
- package/openclaw.plugin.json +14 -2
- package/package.json +2 -2
- package/src/accounts.ts +3 -0
- package/src/actions.ts +350 -4
- package/src/bot.ts +357 -14
- package/src/channel.ts +64 -23
- package/src/infoflow-req-parse.ts +0 -1
- package/src/media.ts +367 -0
- package/src/monitor.ts +1 -1
- package/src/reply-dispatcher.ts +157 -67
- package/src/send.ts +497 -57
- package/src/sent-message-store.ts +238 -0
- package/src/types.ts +27 -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 } 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,24 @@ 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
|
+
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
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
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
|
-
|
|
436
|
-
logVerbose(`[infoflow:sendGroup] POST body: ${bodyStr}`);
|
|
777
|
+
logVerbose(`[infoflow:recallPrivate] POST body: ${bodyStr}`);
|
|
437
778
|
|
|
438
|
-
const res = await fetch(`${ensureHttps(apiHost)}${
|
|
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
|
-
//
|
|
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:
|
|
799
|
+
getInfoflowSendLog().error(`[infoflow:recallPrivate] failed: ${errMsg}`);
|
|
452
800
|
return { ok: false, error: errMsg };
|
|
453
801
|
}
|
|
454
802
|
|
|
455
|
-
//
|
|
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:
|
|
808
|
+
getInfoflowSendLog().error(`[infoflow:recallPrivate] failed: ${errMsg}`);
|
|
461
809
|
return { ok: false, error: errMsg };
|
|
462
810
|
}
|
|
463
811
|
|
|
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 };
|
|
812
|
+
return { ok: true };
|
|
472
813
|
} catch (err) {
|
|
473
814
|
const errMsg = formatInfoflowError(err);
|
|
474
|
-
getInfoflowSendLog().error(`[infoflow:
|
|
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
|
-
|
|
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({
|
|
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
|
|
530
|
-
const
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
// ---------------------------------------------------------------------------
|