@chbo297/infoflow 2026.3.18 → 2026.5.4

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