@core-workspace/infoflow-openclaw-plugin 2026.3.8

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.
@@ -0,0 +1,934 @@
1
+ /**
2
+ * Outbound send API: POST messages to the Infoflow service.
3
+ * Supports both private (DM) and group chat messages.
4
+ */
5
+
6
+ import { randomUUID } from "node:crypto";
7
+ import type { OpenClawConfig } from "openclaw/plugin-sdk";
8
+ import { resolveInfoflowAccount } from "./accounts.js";
9
+ import { recordSentMessageId } from "../adapter/inbound/webhook-parser.js";
10
+ import { getInfoflowSendLog, formatInfoflowError, logVerbose } from "../logging.js";
11
+ import { getOrCreateAdapter, _resetAdapters } from "../utils/token-adapter.js";
12
+ import { coreEvents } from "../events.js";
13
+ import type {
14
+ InfoflowGroupMessageBodyItem,
15
+ InfoflowMessageContentItem,
16
+ InfoflowOutboundReply,
17
+ ResolvedInfoflowAccount,
18
+ } from "../types.js";
19
+
20
+ export const DEFAULT_TIMEOUT_MS = 30_000; // 30 seconds
21
+
22
+ /**
23
+ * Ensures apiHost uses HTTPS for security (secrets in transit).
24
+ * Allows HTTP only for localhost/127.0.0.1 (local development).
25
+ */
26
+ export function ensureHttps(apiHost: string): string {
27
+ if (apiHost.startsWith("http://")) {
28
+ const url = new URL(apiHost);
29
+ const isLocal = url.hostname === "localhost" || url.hostname === "127.0.0.1";
30
+ if (!isLocal) {
31
+ return apiHost.replace(/^http:/, "https:");
32
+ }
33
+ }
34
+ return apiHost;
35
+ }
36
+
37
+ // Infoflow API paths (host is configured via apiHost in config)
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";
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Helper Functions
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Parses link content format: "href" or "[label]href"
49
+ * Returns both href and label (label defaults to href if not specified)
50
+ */
51
+ function parseLinkContent(content: string): { href: string; label: string } {
52
+ if (content.startsWith("[")) {
53
+ const closeBracket = content.indexOf("]");
54
+ if (closeBracket > 1) {
55
+ return {
56
+ label: content.slice(1, closeBracket),
57
+ href: content.slice(closeBracket + 1),
58
+ };
59
+ }
60
+ }
61
+ return { href: content, label: content };
62
+ }
63
+
64
+ /**
65
+ * Checks if a string looks like a local file path rather than a URL.
66
+ * Mirrors the pattern from src/media/parse.ts; security validation is
67
+ * deferred to the load layer (loadWebMedia).
68
+ */
69
+ export function isLikelyLocalPath(content: string): boolean {
70
+ const trimmed = content.trim();
71
+ return (
72
+ trimmed.startsWith("/") ||
73
+ trimmed.startsWith("./") ||
74
+ trimmed.startsWith("../") ||
75
+ trimmed.startsWith("~")
76
+ );
77
+ }
78
+
79
+ /**
80
+ * Extracts a numeric or string value for the given key from raw JSON text.
81
+ * This bypasses JSON.parse precision loss for large integers (>2^53).
82
+ * Matches both bare integers ("key": 123) and quoted strings ("key": "abc").
83
+ */
84
+ export function extractIdFromRawJson(rawJson: string, key: string): string | undefined {
85
+ // Match bare integer: "key": 12345
86
+ const reNum = new RegExp(`"${key}"\\s*:\\s*(\\d+)`);
87
+ const mNum = rawJson.match(reNum);
88
+ if (mNum) return mNum[1];
89
+ // Match quoted string: "key": "value"
90
+ const reStr = new RegExp(`"${key}"\\s*:\\s*"([^"]+)"`);
91
+ const mStr = rawJson.match(reStr);
92
+ return mStr?.[1];
93
+ }
94
+
95
+ /**
96
+ * Extracts message ID from Infoflow API response data.
97
+ * Handles different response formats:
98
+ * - Private: data.msgkey
99
+ * - Group: data.data.messageid or data.data.msgid (nested)
100
+ * - Fallback: data.messageid or data.msgid (flat)
101
+ */
102
+ export function extractMessageId(data: Record<string, unknown>): string | undefined {
103
+ // Try data.msgkey (private message format)
104
+ if (data.msgkey != null) {
105
+ return String(data.msgkey);
106
+ }
107
+
108
+ // Try nested data.data structure (group message format)
109
+ const innerData = data.data as Record<string, unknown> | undefined;
110
+ if (innerData && typeof innerData === "object") {
111
+ // Try data.data.messageid
112
+ if (innerData.messageid != null) {
113
+ return String(innerData.messageid);
114
+ }
115
+ // Try data.data.msgid
116
+ if (innerData.msgid != null) {
117
+ return String(innerData.msgid);
118
+ }
119
+ }
120
+
121
+ // Fallback: try flat structure
122
+ if (data.messageid != null) {
123
+ return String(data.messageid);
124
+ }
125
+ if (data.msgid != null) {
126
+ return String(data.msgid);
127
+ }
128
+
129
+ return undefined;
130
+ }
131
+
132
+ /**
133
+ * Extracts msgseqid from Infoflow group send API response data.
134
+ * The recall API requires this alongside messageid.
135
+ */
136
+ export function extractMsgSeqId(data: Record<string, unknown>): string | undefined {
137
+ // Try nested data.data structure (group message format)
138
+ const innerData = data.data as Record<string, unknown> | undefined;
139
+ if (innerData && typeof innerData === "object" && innerData.msgseqid != null) {
140
+ return String(innerData.msgseqid);
141
+ }
142
+
143
+ // Fallback: flat structure
144
+ if (data.msgseqid != null) {
145
+ return String(data.msgseqid);
146
+ }
147
+
148
+ return undefined;
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Token Management
153
+ // ---------------------------------------------------------------------------
154
+
155
+ /**
156
+ * Gets the app access token via SDK TokenManager.
157
+ * Token caching, MD5 signing, concurrency safety, and early refresh
158
+ * are handled internally by the SDK.
159
+ */
160
+ export async function getAppAccessToken(params: {
161
+ apiHost: string;
162
+ appKey: string;
163
+ appSecret: string;
164
+ timeoutMs?: number;
165
+ }): Promise<{ ok: boolean; token?: string; error?: string }> {
166
+ try {
167
+ const adapter = getOrCreateAdapter({
168
+ apiHost: params.apiHost,
169
+ appKey: params.appKey,
170
+ appSecret: params.appSecret,
171
+ });
172
+ const token = await adapter.getToken();
173
+ return { ok: true, token };
174
+ } catch (err) {
175
+ return { ok: false, error: formatInfoflowError(err) };
176
+ }
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // Private Chat (DM) Message Sending
181
+ // ---------------------------------------------------------------------------
182
+
183
+ /**
184
+ * Sends a private (DM) message to a user.
185
+ * @param account - Resolved Infoflow account with config
186
+ * @param toUser - Recipient's uuapName (email prefix), multiple users separated by |
187
+ * @param contents - Array of content items (text/markdown; "at" is ignored for private messages)
188
+ */
189
+ export async function sendInfoflowPrivateMessage(params: {
190
+ account: ResolvedInfoflowAccount;
191
+ toUser: string;
192
+ contents: InfoflowMessageContentItem[];
193
+ timeoutMs?: number;
194
+ }): Promise<{ ok: boolean; error?: string; invaliduser?: string; msgkey?: string }> {
195
+ const { account, toUser, contents, timeoutMs = DEFAULT_TIMEOUT_MS } = params;
196
+ const { apiHost, appKey, appSecret } = account.config;
197
+
198
+ // Validate account config
199
+ if (!appKey || !appSecret) {
200
+ return { ok: false, error: "Infoflow appKey/appSecret not configured." };
201
+ }
202
+
203
+ // Check if contents contain link type
204
+ const hasLink = contents.some((item) => item.type.toLowerCase() === "link");
205
+
206
+ // Get token first
207
+ const tokenResult = await getAppAccessToken({ apiHost, appKey, appSecret, timeoutMs });
208
+ if (!tokenResult.ok || !tokenResult.token) {
209
+ getInfoflowSendLog().error(`[infoflow:sendPrivate] token error: ${tokenResult.error}`);
210
+ return { ok: false, error: tokenResult.error ?? "failed to get token" };
211
+ }
212
+
213
+ let timeout: ReturnType<typeof setTimeout> | undefined;
214
+ try {
215
+ const controller = new AbortController();
216
+ timeout = setTimeout(() => controller.abort(), timeoutMs);
217
+
218
+ let payload: Record<string, unknown>;
219
+
220
+ if (hasLink) {
221
+ // Build richtext format payload when link is present
222
+ const richtextContent: Array<{ type: string; text?: string; href?: string; label?: string }> =
223
+ [];
224
+
225
+ for (const item of contents) {
226
+ const type = item.type.toLowerCase();
227
+ if (type === "text") {
228
+ richtextContent.push({ type: "text", text: item.content });
229
+ } else if (type === "md" || type === "markdown") {
230
+ richtextContent.push({ type: "text", text: item.content });
231
+ } else if (type === "link") {
232
+ if (item.content) {
233
+ const { href, label } = parseLinkContent(item.content);
234
+ richtextContent.push({ type: "a", href, label });
235
+ }
236
+ }
237
+ }
238
+
239
+ if (richtextContent.length === 0) {
240
+ return { ok: false, error: "no valid content for private message" };
241
+ }
242
+
243
+ payload = {
244
+ touser: toUser,
245
+ msgtype: "richtext",
246
+ richtext: { content: richtextContent },
247
+ };
248
+ } else {
249
+ // Original logic: filter text/markdown contents and merge with '\n'
250
+ const textParts: string[] = [];
251
+ let hasMarkdown = false;
252
+
253
+ for (const item of contents) {
254
+ const type = item.type.toLowerCase();
255
+ if (type === "text") {
256
+ textParts.push(item.content);
257
+ } else if (type === "md" || type === "markdown") {
258
+ textParts.push(item.content);
259
+ hasMarkdown = true;
260
+ }
261
+ }
262
+
263
+ if (textParts.length === 0) {
264
+ return { ok: false, error: "no valid content for private message" };
265
+ }
266
+
267
+ const mergedContent = textParts.join("\n");
268
+ const msgtype: string = hasMarkdown ? "md" : "text";
269
+
270
+ payload = { touser: toUser, msgtype };
271
+ if (msgtype === "text") {
272
+ payload.text = { content: mergedContent };
273
+ } else {
274
+ payload.md = { content: mergedContent };
275
+ }
276
+ }
277
+
278
+ const headers = {
279
+ Authorization: `Bearer-${tokenResult.token}`,
280
+ "Content-Type": "application/json; charset=utf-8",
281
+ LOGID: randomUUID(),
282
+ };
283
+
284
+ const bodyStr = JSON.stringify(payload);
285
+
286
+ // Log request URL and body when verbose logging is enabled
287
+ logVerbose(`[infoflow:sendPrivate] POST body: ${bodyStr}`);
288
+
289
+ const res = await fetch(`${ensureHttps(apiHost)}${INFOFLOW_PRIVATE_SEND_PATH}`, {
290
+ method: "POST",
291
+ headers,
292
+ body: bodyStr,
293
+ signal: controller.signal,
294
+ });
295
+
296
+ const responseText = await res.text();
297
+ const data = JSON.parse(responseText) as Record<string, unknown>;
298
+ logVerbose(`[infoflow:sendPrivate] response: status=${res.status}, data=${responseText}`);
299
+
300
+ // Check outer code first
301
+ const code = typeof data.code === "string" ? data.code : "";
302
+ if (code !== "ok") {
303
+ const errMsg = String(data.message ?? data.errmsg ?? `code=${code || "unknown"}`);
304
+ getInfoflowSendLog().error(`[infoflow:sendPrivate] failed: ${errMsg}`);
305
+ return { ok: false, error: errMsg };
306
+ }
307
+
308
+ // Check inner data.errcode
309
+ const innerData = data.data as Record<string, unknown> | undefined;
310
+ const errcode = innerData?.errcode;
311
+ if (errcode != null && errcode !== 0) {
312
+ const errMsg = String(innerData?.errmsg ?? `errcode ${errcode}`);
313
+ getInfoflowSendLog().error(`[infoflow:sendPrivate] failed: ${errMsg}`);
314
+ return {
315
+ ok: false,
316
+ error: errMsg,
317
+ invaliduser: innerData?.invaliduser as string | undefined,
318
+ };
319
+ }
320
+
321
+ // Extract message ID from raw text to preserve large integer precision
322
+ const msgkey =
323
+ extractIdFromRawJson(responseText, "msgkey") ??
324
+ extractIdFromRawJson(responseText, "messageid") ??
325
+ extractMessageId(innerData ?? {});
326
+ if (msgkey) {
327
+ recordSentMessageId(msgkey);
328
+ coreEvents.emit("message:sent", {
329
+ accountId: account.accountId,
330
+ target: toUser,
331
+ from: account.config.appAgentId != null ? `agent:${account.config.appAgentId}` : "agent:unknown",
332
+ messageid: msgkey,
333
+ msgseqid: "",
334
+ contents,
335
+ sentAt: Date.now(),
336
+ });
337
+ }
338
+
339
+ return { ok: true, invaliduser: innerData?.invaliduser as string | undefined, msgkey };
340
+ } catch (err) {
341
+ const errMsg = formatInfoflowError(err);
342
+ getInfoflowSendLog().error(`[infoflow:sendPrivate] exception: ${errMsg}`);
343
+ return { ok: false, error: errMsg };
344
+ } finally {
345
+ clearTimeout(timeout);
346
+ }
347
+ }
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // Group Chat Message Sending
351
+ // ---------------------------------------------------------------------------
352
+
353
+ /**
354
+ * Sends a group chat message.
355
+ * @param account - Resolved Infoflow account with config
356
+ * @param groupId - Target group ID (numeric)
357
+ * @param contents - Array of content items (text/markdown/at)
358
+ */
359
+ export async function sendInfoflowGroupMessage(params: {
360
+ account: ResolvedInfoflowAccount;
361
+ groupId: number;
362
+ contents: InfoflowMessageContentItem[];
363
+ replyTo?: InfoflowOutboundReply;
364
+ timeoutMs?: number;
365
+ }): Promise<{ ok: boolean; error?: string; messageid?: string; msgseqid?: string }> {
366
+ const { account, groupId, contents, replyTo, timeoutMs = DEFAULT_TIMEOUT_MS } = params;
367
+ const { apiHost, appKey, appSecret } = account.config;
368
+
369
+ // Validate account config
370
+ if (!appKey || !appSecret) {
371
+ return { ok: false, error: "Infoflow appKey/appSecret not configured." };
372
+ }
373
+
374
+ // Validate contents
375
+ if (contents.length === 0) {
376
+ return { ok: false, error: "contents array is empty" };
377
+ }
378
+
379
+ // Build group message body from contents
380
+ let hasMarkdown = false;
381
+ const body: InfoflowGroupMessageBodyItem[] = [];
382
+ for (const item of contents) {
383
+ const type = item.type.toLowerCase();
384
+ if (type === "text") {
385
+ body.push({ type: "TEXT", content: item.content });
386
+ } else if (type === "md" || type === "markdown") {
387
+ body.push({ type: "MD", content: item.content });
388
+ hasMarkdown = true;
389
+ } else if (type === "at") {
390
+ // Parse AT content: "all" means atall, otherwise comma-separated user IDs
391
+ if (item.content === "all") {
392
+ body.push({ type: "AT", atall: true, atuserids: [] });
393
+ } else {
394
+ const userIds = item.content
395
+ .split(",")
396
+ .map((s) => s.trim())
397
+ .filter(Boolean);
398
+ if (userIds.length > 0) {
399
+ body.push({ type: "AT", atuserids: userIds });
400
+ }
401
+ }
402
+ } else if (type === "link") {
403
+ // Group messages only use href (label is ignored)
404
+ if (item.content) {
405
+ const { href } = parseLinkContent(item.content);
406
+ body.push({ type: "LINK", href });
407
+ }
408
+ } else if (type === "at-agent") {
409
+ // Robot AT: parse comma-separated numeric IDs into atagentids
410
+ const agentIds = item.content
411
+ .split(",")
412
+ .map((s) => Number(s.trim()))
413
+ .filter(Number.isFinite);
414
+ if (agentIds.length > 0) {
415
+ body.push({ type: "AT", atuserids: [], atagentids: agentIds });
416
+ }
417
+ } else if (type === "image") {
418
+ body.push({ type: "IMAGE", content: item.content });
419
+ }
420
+ }
421
+
422
+ // Split body: LINK and IMAGE must be sent as individual messages
423
+ const linkItems = body.filter((b) => b.type === "LINK");
424
+ const imageItems = body.filter((b) => b.type === "IMAGE");
425
+ const textItems = body.filter((b) => b.type !== "LINK" && b.type !== "IMAGE");
426
+
427
+ // Get token first (shared by all sends)
428
+ const tokenResult = await getAppAccessToken({ apiHost, appKey, appSecret, timeoutMs });
429
+ if (!tokenResult.ok || !tokenResult.token) {
430
+ getInfoflowSendLog().error(`[infoflow:sendGroup] token error: ${tokenResult.error}`);
431
+ return { ok: false, error: tokenResult.error ?? "failed to get token" };
432
+ }
433
+
434
+ // NOTE: Infoflow API requires "Bearer-<token>" format (with hyphen, not space).
435
+ // This is a non-standard format specific to Infoflow service. Do not modify
436
+ // unless the Infoflow API specification changes.
437
+ const headers = {
438
+ Authorization: `Bearer-${tokenResult.token}`,
439
+ "Content-Type": "application/json",
440
+ };
441
+
442
+ let msgIndex = 0;
443
+
444
+ // Helper: post a single group message payload
445
+ const postGroupMessage = async (
446
+ msgBody: InfoflowGroupMessageBodyItem[],
447
+ msgtype: string,
448
+ replyTo?: InfoflowOutboundReply,
449
+ ): Promise<{ ok: boolean; error?: string; messageid?: string; msgseqid?: string }> => {
450
+ let timeout: ReturnType<typeof setTimeout> | undefined;
451
+ try {
452
+ const controller = new AbortController();
453
+ timeout = setTimeout(() => controller.abort(), timeoutMs);
454
+
455
+ const payload = {
456
+ message: {
457
+ header: {
458
+ toid: groupId,
459
+ totype: "GROUP",
460
+ msgtype,
461
+ clientmsgid: Date.now() + msgIndex++,
462
+ role: "robot",
463
+ },
464
+ body: msgBody,
465
+ ...(replyTo
466
+ ? {
467
+ reply: {
468
+ messageid: String(replyTo.messageid), // messageid应该是字符串
469
+ preview: replyTo.preview ?? "",
470
+ ...(replyTo.imid ? { imid: replyTo.imid } : {}), // 如果有 imid 则添加
471
+ replyType: replyTo.replytype ?? "1", // 注意是replyType,不是replytype
472
+ },
473
+ }
474
+ : {}),
475
+ },
476
+ };
477
+
478
+ // Build request body
479
+ const bodyStr = JSON.stringify(payload);
480
+
481
+ const res = await fetch(`${ensureHttps(apiHost)}${INFOFLOW_GROUP_SEND_PATH}`, {
482
+ method: "POST",
483
+ headers,
484
+ body: bodyStr,
485
+ signal: controller.signal,
486
+ });
487
+
488
+ const responseText = await res.text();
489
+ const data = JSON.parse(responseText) as Record<string, unknown>;
490
+ logVerbose(`[infoflow:sendGroup] response: status=${res.status}, data=${responseText}`);
491
+
492
+ const code = typeof data.code === "string" ? data.code : "";
493
+ if (code !== "ok") {
494
+ const errMsg = String(data.message ?? data.errmsg ?? `code=${code || "unknown"}`);
495
+ getInfoflowSendLog().error(`[infoflow:sendGroup] failed: ${errMsg}`);
496
+ return { ok: false, error: errMsg };
497
+ }
498
+
499
+ const innerData = data.data as Record<string, unknown> | undefined;
500
+ const errcode = innerData?.errcode;
501
+ if (errcode != null && errcode !== 0) {
502
+ const errMsg = String(innerData?.errmsg ?? `errcode ${errcode}`);
503
+ getInfoflowSendLog().error(`[infoflow:sendGroup] failed: ${errMsg}`);
504
+ return { ok: false, error: errMsg };
505
+ }
506
+
507
+ // Extract IDs from raw text to preserve large integer precision
508
+ const messageid =
509
+ extractIdFromRawJson(responseText, "messageid") ??
510
+ extractIdFromRawJson(responseText, "msgid");
511
+ const msgseqid = extractIdFromRawJson(responseText, "msgseqid");
512
+ if (messageid) {
513
+ recordSentMessageId(messageid);
514
+ }
515
+
516
+ return { ok: true, messageid, msgseqid };
517
+ } catch (err) {
518
+ const errMsg = formatInfoflowError(err);
519
+ getInfoflowSendLog().error(`[infoflow:sendGroup] exception: ${errMsg}`);
520
+ return { ok: false, error: errMsg };
521
+ } finally {
522
+ clearTimeout(timeout);
523
+ }
524
+ };
525
+
526
+ // Helper: emit sent event for a successful sub-message
527
+ const emitSent = (
528
+ result: { messageid?: string; msgseqid?: string },
529
+ digestContents: InfoflowMessageContentItem[],
530
+ ) => {
531
+ if (!result.messageid) return;
532
+ coreEvents.emit("message:sent", {
533
+ accountId: account.accountId,
534
+ target: `group:${groupId}`,
535
+ from: account.config.appAgentId != null ? `agent:${account.config.appAgentId}` : "agent:unknown",
536
+ messageid: result.messageid,
537
+ msgseqid: result.msgseqid ?? "",
538
+ contents: digestContents,
539
+ sentAt: Date.now(),
540
+ });
541
+ };
542
+
543
+ let lastMessageId: string | undefined;
544
+ let lastMsgSeqId: string | undefined;
545
+ let firstError: string | undefined;
546
+ let replyApplied = false;
547
+
548
+ // 1) Send text/AT/MD items together (if any)
549
+ if (textItems.length > 0) {
550
+ const msgtype = hasMarkdown ? "MD" : "TEXT";
551
+ const result = await postGroupMessage(
552
+ textItems,
553
+ msgtype,
554
+ !replyApplied ? params.replyTo : undefined,
555
+ );
556
+ replyApplied = true;
557
+ if (result.ok) {
558
+ lastMessageId = result.messageid;
559
+ lastMsgSeqId = result.msgseqid;
560
+ const digestItems = contents.filter((c) => !["link", "image"].includes(c.type.toLowerCase()));
561
+ emitSent(result, digestItems);
562
+ } else if (!firstError) {
563
+ firstError = result.error;
564
+ }
565
+ }
566
+
567
+ // 2) Send each LINK as a separate message
568
+ for (const linkItem of linkItems) {
569
+ const result = await postGroupMessage(
570
+ [linkItem],
571
+ "TEXT",
572
+ !replyApplied ? params.replyTo : undefined,
573
+ );
574
+ replyApplied = true;
575
+ if (result.ok) {
576
+ lastMessageId = result.messageid;
577
+ lastMsgSeqId = result.msgseqid;
578
+ emitSent(result, [{ type: "link", content: linkItem.href }]);
579
+ } else if (!firstError) {
580
+ firstError = result.error;
581
+ }
582
+ }
583
+
584
+ // 3) Send each IMAGE as a separate message
585
+ for (const imageItem of imageItems) {
586
+ const result = await postGroupMessage(
587
+ [imageItem],
588
+ "IMAGE",
589
+ !replyApplied ? params.replyTo : undefined,
590
+ );
591
+ replyApplied = true;
592
+ if (result.ok) {
593
+ lastMessageId = result.messageid;
594
+ lastMsgSeqId = result.msgseqid;
595
+ emitSent(result, [{ type: "image", content: "" }]);
596
+ } else if (!firstError) {
597
+ firstError = result.error;
598
+ }
599
+ }
600
+
601
+ if (firstError) {
602
+ return { ok: false, error: firstError, messageid: lastMessageId, msgseqid: lastMsgSeqId };
603
+ }
604
+ return { ok: true, messageid: lastMessageId, msgseqid: lastMsgSeqId };
605
+ }
606
+
607
+ // ---------------------------------------------------------------------------
608
+ // Group Message Recall (撤回)
609
+ // ---------------------------------------------------------------------------
610
+
611
+ /**
612
+ * Recalls (撤回) a group message previously sent by the robot.
613
+ * Only group messages can be recalled via this API.
614
+ */
615
+ export async function recallInfoflowGroupMessage(params: {
616
+ account: ResolvedInfoflowAccount;
617
+ groupId: number;
618
+ messageid: string;
619
+ msgseqid: string;
620
+ timeoutMs?: number;
621
+ }): Promise<{ ok: boolean; error?: string }> {
622
+ const { account, groupId, messageid, msgseqid, timeoutMs = DEFAULT_TIMEOUT_MS } = params;
623
+ const { apiHost, appKey, appSecret } = account.config;
624
+
625
+ // 验证必要的认证配置
626
+ if (!appKey || !appSecret) {
627
+ return { ok: false, error: "Infoflow appKey/appSecret not configured." };
628
+ }
629
+
630
+ // 获取应用访问令牌
631
+ const tokenResult = await getAppAccessToken({ apiHost, appKey, appSecret, timeoutMs });
632
+ if (!tokenResult.ok || !tokenResult.token) {
633
+ getInfoflowSendLog().error(`[infoflow:recallGroup] token error: ${tokenResult.error}`);
634
+ return { ok: false, error: tokenResult.error ?? "failed to get token" };
635
+ }
636
+
637
+ let timeout: ReturnType<typeof setTimeout> | undefined;
638
+ try {
639
+ const controller = new AbortController();
640
+ timeout = setTimeout(() => controller.abort(), timeoutMs);
641
+
642
+ // 手动构建 JSON 以保持 messageid/msgseqid 为原始整数,避免 JavaScript Number 精度丢失
643
+ const bodyStr = `{"groupId":${groupId},"messageid":${messageid},"msgseqid":${msgseqid}}`;
644
+
645
+ logVerbose(`[infoflow:recallGroup] POST token: ${tokenResult.token} body: ${bodyStr}`);
646
+
647
+ // 发送撤回请求
648
+ const res = await fetch(`${ensureHttps(apiHost)}${INFOFLOW_GROUP_RECALL_PATH}`, {
649
+ method: "POST",
650
+ headers: {
651
+ Authorization: `Bearer-${tokenResult.token}`,
652
+ "Content-Type": "application/json; charset=utf-8",
653
+ },
654
+ body: bodyStr,
655
+ signal: controller.signal,
656
+ });
657
+
658
+ const data = JSON.parse(await res.text()) as Record<string, unknown>;
659
+ logVerbose(
660
+ `[infoflow:recallGroup] response: status=${res.status}, data=${JSON.stringify(data)}`,
661
+ );
662
+
663
+ // 检查外层响应码
664
+ const code = typeof data.code === "string" ? data.code : "";
665
+ if (code !== "ok") {
666
+ const errMsg = String(data.message ?? data.errmsg ?? `code=${code || "unknown"}`);
667
+ getInfoflowSendLog().error(`[infoflow:recallGroup] failed: ${errMsg}`);
668
+ return { ok: false, error: errMsg };
669
+ }
670
+
671
+ // 检查内层错误码
672
+ const innerData = data.data as Record<string, unknown> | undefined;
673
+ const errcode = innerData?.errcode;
674
+ if (errcode != null && errcode !== 0) {
675
+ const errMsg = String(innerData?.errmsg ?? `errcode ${errcode}`);
676
+ getInfoflowSendLog().error(`[infoflow:recallGroup] failed: ${errMsg}`);
677
+ return { ok: false, error: errMsg };
678
+ }
679
+
680
+ return { ok: true };
681
+ } catch (err) {
682
+ const errMsg = formatInfoflowError(err);
683
+ getInfoflowSendLog().error(`[infoflow:recallGroup] exception: ${errMsg}`);
684
+ return { ok: false, error: errMsg };
685
+ } finally {
686
+ clearTimeout(timeout);
687
+ }
688
+ }
689
+
690
+ // ---------------------------------------------------------------------------
691
+ // Private Message Recall (撤回)
692
+ // ---------------------------------------------------------------------------
693
+
694
+ /**
695
+ * Recalls (撤回) a private message previously sent by the app.
696
+ * Uses the /api/v1/app/message/revoke endpoint.
697
+ */
698
+ export async function recallInfoflowPrivateMessage(params: {
699
+ account: ResolvedInfoflowAccount;
700
+ /** 发送消息时返回的 msgkey(存储于 sent-message-store 的 messageid 字段) */
701
+ msgkey: string;
702
+ /** 如流企业后台"应用ID" */
703
+ appAgentId: number;
704
+ timeoutMs?: number;
705
+ }): Promise<{ ok: boolean; error?: string }> {
706
+ const { account, msgkey, appAgentId, timeoutMs = DEFAULT_TIMEOUT_MS } = params;
707
+ const { apiHost, appKey, appSecret } = account.config;
708
+
709
+ if (!appKey || !appSecret) {
710
+ return { ok: false, error: "Infoflow appKey/appSecret not configured." };
711
+ }
712
+
713
+ const tokenResult = await getAppAccessToken({ apiHost, appKey, appSecret, timeoutMs });
714
+ if (!tokenResult.ok || !tokenResult.token) {
715
+ getInfoflowSendLog().error(`[infoflow:recallPrivate] token error: ${tokenResult.error}`);
716
+ return { ok: false, error: tokenResult.error ?? "failed to get token" };
717
+ }
718
+
719
+ let timeout: ReturnType<typeof setTimeout> | undefined;
720
+ try {
721
+ const controller = new AbortController();
722
+ timeout = setTimeout(() => controller.abort(), timeoutMs);
723
+
724
+ const bodyStr = JSON.stringify({ msgkey, agentid: appAgentId });
725
+
726
+ logVerbose(`[infoflow:recallPrivate] POST auth: ${tokenResult.token} body: ${bodyStr}`);
727
+
728
+ const res = await fetch(`${ensureHttps(apiHost)}${INFOFLOW_PRIVATE_RECALL_PATH}`, {
729
+ method: "POST",
730
+ headers: {
731
+ Authorization: `Bearer-${tokenResult.token}`,
732
+ "Content-Type": "application/json; charset=utf-8",
733
+ LOGID: String(Date.now()),
734
+ },
735
+ body: bodyStr,
736
+ signal: controller.signal,
737
+ });
738
+
739
+ const data = JSON.parse(await res.text()) as Record<string, unknown>;
740
+ logVerbose(
741
+ `[infoflow:recallPrivate] response: status=${res.status}, data=${JSON.stringify(data)}`,
742
+ );
743
+
744
+ // 检查外层响应码
745
+ const code = typeof data.code === "string" ? data.code : "";
746
+ if (code !== "ok") {
747
+ const errMsg = String(data.message ?? data.errmsg ?? `code=${code || "unknown"}`);
748
+ getInfoflowSendLog().error(`[infoflow:recallPrivate] failed: ${errMsg}`);
749
+ return { ok: false, error: errMsg };
750
+ }
751
+
752
+ // 检查内层错误码
753
+ const innerData = data.data as Record<string, unknown> | undefined;
754
+ const errcode = innerData?.errcode;
755
+ if (errcode != null && errcode !== 0) {
756
+ const errMsg = String(innerData?.errmsg ?? `errcode ${errcode}`);
757
+ getInfoflowSendLog().error(`[infoflow:recallPrivate] failed: ${errMsg}`);
758
+ return { ok: false, error: errMsg };
759
+ }
760
+
761
+ return { ok: true };
762
+ } catch (err) {
763
+ const errMsg = formatInfoflowError(err);
764
+ getInfoflowSendLog().error(`[infoflow:recallPrivate] exception: ${errMsg}`);
765
+ return { ok: false, error: errMsg };
766
+ } finally {
767
+ clearTimeout(timeout);
768
+ }
769
+ }
770
+
771
+ // ---------------------------------------------------------------------------
772
+ // Local Image Link Resolution
773
+ // ---------------------------------------------------------------------------
774
+
775
+ /**
776
+ * Pre-processes content items: for "link" items pointing to local file paths,
777
+ * checks if the file is an image and converts to "image" type with base64 content.
778
+ * Falls back to original "link" type if not an image or on error.
779
+ */
780
+ async function resolveLocalImageLinks(
781
+ contents: InfoflowMessageContentItem[],
782
+ ): Promise<InfoflowMessageContentItem[]> {
783
+ const hasLocalLinks = contents.some(
784
+ (item) => item.type === "link" && isLikelyLocalPath(parseLinkContent(item.content).href),
785
+ );
786
+ if (!hasLocalLinks) {
787
+ return contents;
788
+ }
789
+
790
+ // Dynamic import to avoid circular dependency (media.ts imports from send.ts)
791
+ const { prepareInfoflowImageBase64 } = await import("./media.js");
792
+
793
+ const resolved: InfoflowMessageContentItem[] = [];
794
+ for (const item of contents) {
795
+ if (item.type !== "link") {
796
+ resolved.push(item);
797
+ continue;
798
+ }
799
+
800
+ const { href } = parseLinkContent(item.content);
801
+ if (!isLikelyLocalPath(href)) {
802
+ resolved.push(item);
803
+ continue;
804
+ }
805
+
806
+ // Attempt image detection for local path
807
+ try {
808
+ const prepared = await prepareInfoflowImageBase64({ mediaUrl: href, mediaLocalRoots:[href] });
809
+ if (prepared.isImage) {
810
+ resolved.push({ type: "image", content: prepared.base64 });
811
+ continue;
812
+ }
813
+ } catch {
814
+ logVerbose(`[infoflow:send] local image detection failed for ${href}, sending as link`);
815
+ }
816
+ resolved.push(item);
817
+ }
818
+
819
+ return resolved;
820
+ }
821
+
822
+ // ---------------------------------------------------------------------------
823
+ // Unified Message Sending
824
+ // ---------------------------------------------------------------------------
825
+
826
+ /**
827
+ * Unified message sending entry point.
828
+ * Parses the `to` target and dispatches to group or private message sending.
829
+ * Local file path links that are images are automatically sent as native images.
830
+ * @param cfg - OpenClaw config
831
+ * @param to - Target: "username" for private, "group:123" for group
832
+ * @param contents - Array of content items (text/markdown/at)
833
+ * @param accountId - Optional account ID for multi-account support
834
+ * @param replyTo - Optional reply context for group messages (ignored for private)
835
+ */
836
+ export async function sendInfoflowMessage(params: {
837
+ cfg: OpenClawConfig;
838
+ to: string;
839
+ contents: InfoflowMessageContentItem[];
840
+ accountId?: string;
841
+ replyTo?: InfoflowOutboundReply;
842
+ }): Promise<{ ok: boolean; error?: string; messageId?: string; msgseqid?: string }> {
843
+ const { cfg, to, contents, accountId } = params;
844
+
845
+ // Resolve account config
846
+ const account = resolveInfoflowAccount({ cfg, accountId });
847
+ const { appKey, appSecret } = account.config;
848
+
849
+ if (!appKey || !appSecret) {
850
+ return { ok: false, error: "Infoflow appKey/appSecret not configured." };
851
+ }
852
+
853
+ // Validate contents
854
+ if (contents.length === 0) {
855
+ return { ok: false, error: "contents array is empty" };
856
+ }
857
+
858
+ // Pre-process: convert local-path link items to native image items if they're images
859
+ const resolvedContents = await resolveLocalImageLinks(contents);
860
+
861
+ // Parse target: remove "infoflow:" prefix if present
862
+ const target = to.replace(/^infoflow:/i, "");
863
+
864
+ // Check if target is a group (format: group:123)
865
+ const groupMatch = target.match(/^group:(\d+)/i);
866
+ if (groupMatch) {
867
+ // Group path: sendInfoflowGroupMessage already handles IMAGE items
868
+ const groupId = Number(groupMatch[1]);
869
+ const result = await sendInfoflowGroupMessage({
870
+ account,
871
+ groupId,
872
+ contents: resolvedContents,
873
+ replyTo: params.replyTo,
874
+ });
875
+ return {
876
+ ok: result.ok,
877
+ error: result.error,
878
+ messageId: result.messageid,
879
+ msgseqid: result.msgseqid,
880
+ };
881
+ }
882
+
883
+ // Private path: split image items (sendInfoflowPrivateMessage doesn't handle image type)
884
+ const imageItems = resolvedContents.filter((c) => c.type === "image");
885
+ const nonImageContents = resolvedContents.filter((c) => c.type !== "image");
886
+
887
+ let lastMessageId: string | undefined;
888
+ let firstError: string | undefined;
889
+
890
+ // Send non-image contents via private message API
891
+ if (nonImageContents.length > 0) {
892
+ const result = await sendInfoflowPrivateMessage({
893
+ account,
894
+ toUser: target,
895
+ contents: nonImageContents,
896
+ });
897
+ if (result.ok) {
898
+ lastMessageId = result.msgkey;
899
+ } else {
900
+ firstError = result.error;
901
+ }
902
+ }
903
+
904
+ // Send image items as native private images
905
+ if (imageItems.length > 0) {
906
+ const { sendInfoflowPrivateImage } = await import("./media.js");
907
+ for (const imgItem of imageItems) {
908
+ const result = await sendInfoflowPrivateImage({
909
+ account,
910
+ toUser: target,
911
+ base64Image: imgItem.content,
912
+ });
913
+ if (result.ok) {
914
+ lastMessageId = result.msgkey;
915
+ } else if (!firstError) {
916
+ firstError = result.error;
917
+ }
918
+ }
919
+ }
920
+
921
+ if (firstError && !lastMessageId) {
922
+ return { ok: false, error: firstError };
923
+ }
924
+ return { ok: true, messageId: lastMessageId };
925
+ }
926
+
927
+ // ---------------------------------------------------------------------------
928
+ // Test-only exports (@internal — not part of the public API)
929
+ // ---------------------------------------------------------------------------
930
+
931
+ /** @internal — Clears the SDK adapter cache. Only use in tests. */
932
+ export function _resetTokenCache(): void {
933
+ _resetAdapters();
934
+ }