@core-workspace/infoflow-openclaw-plugin 2026.3.36 → 2026.4.10

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.
Files changed (41) hide show
  1. package/README.md +56 -22
  2. package/dist/index.cjs +45821 -0
  3. package/openclaw.plugin.json +647 -140
  4. package/package.json +23 -10
  5. package/CHANGELOG.md +0 -21
  6. package/docs/architecture-data-flow.md +0 -429
  7. package/docs/architecture.md +0 -423
  8. package/docs/dev-guide.md +0 -611
  9. package/docs/qa-feature-list.md +0 -413
  10. package/index.ts +0 -53
  11. package/publish.sh +0 -221
  12. package/scripts/deploy.sh +0 -34
  13. package/scripts/npm-tools/README.md +0 -70
  14. package/scripts/npm-tools/cli.js +0 -262
  15. package/scripts/npm-tools/package.json +0 -21
  16. package/skills/infoflow-dev/SKILL.md +0 -88
  17. package/skills/infoflow-dev/references/api.md +0 -413
  18. package/src/adapter/inbound/webhook-parser.ts +0 -433
  19. package/src/adapter/inbound/ws-receiver.ts +0 -268
  20. package/src/adapter/outbound/reply-dispatcher.ts +0 -274
  21. package/src/adapter/outbound/target-resolver.ts +0 -109
  22. package/src/channel/accounts.ts +0 -184
  23. package/src/channel/channel.ts +0 -365
  24. package/src/channel/media.ts +0 -373
  25. package/src/channel/monitor.ts +0 -184
  26. package/src/channel/outbound.ts +0 -942
  27. package/src/commands/changelog.ts +0 -53
  28. package/src/commands/doctor.ts +0 -391
  29. package/src/commands/logs.ts +0 -212
  30. package/src/events.ts +0 -62
  31. package/src/handler/message-handler.ts +0 -796
  32. package/src/logging.ts +0 -123
  33. package/src/runtime.ts +0 -14
  34. package/src/security/dm-policy.ts +0 -80
  35. package/src/security/group-policy.ts +0 -273
  36. package/src/tools/actions/index.ts +0 -456
  37. package/src/tools/hooks/index.ts +0 -82
  38. package/src/tools/index.ts +0 -277
  39. package/src/types.ts +0 -293
  40. package/src/utils/store/message-store.ts +0 -295
  41. package/src/utils/token-adapter.ts +0 -90
@@ -1,942 +0,0 @@
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
287
- getInfoflowSendLog().info(`[outbound:dm] to=${toUser}, msgtype=${payload.msgtype}, bodyLen=${bodyStr.length}`);
288
- logVerbose(`[outbound:dm] POST body: ${bodyStr}`);
289
-
290
- const res = await fetch(`${ensureHttps(apiHost)}${INFOFLOW_PRIVATE_SEND_PATH}`, {
291
- method: "POST",
292
- headers,
293
- body: bodyStr,
294
- signal: controller.signal,
295
- });
296
-
297
- const responseText = await res.text();
298
- const data = JSON.parse(responseText) as Record<string, unknown>;
299
- getInfoflowSendLog().info(`[outbound:dm] response: status=${res.status}, code=${data.code ?? "?"}, msgkey=${(data.data as any)?.msgkey ?? "?"}`);
300
- logVerbose(`[outbound:dm] response body: ${responseText}`);
301
-
302
- // Check outer code first
303
- const code = typeof data.code === "string" ? data.code : "";
304
- if (code !== "ok") {
305
- const errMsg = String(data.message ?? data.errmsg ?? `code=${code || "unknown"}`);
306
- getInfoflowSendLog().error(`[infoflow:sendPrivate] failed: ${errMsg}`);
307
- return { ok: false, error: errMsg };
308
- }
309
-
310
- // Check inner data.errcode
311
- const innerData = data.data as Record<string, unknown> | undefined;
312
- const errcode = innerData?.errcode;
313
- if (errcode != null && errcode !== 0) {
314
- const errMsg = String(innerData?.errmsg ?? `errcode ${errcode}`);
315
- getInfoflowSendLog().error(`[infoflow:sendPrivate] failed: ${errMsg}`);
316
- return {
317
- ok: false,
318
- error: errMsg,
319
- invaliduser: innerData?.invaliduser as string | undefined,
320
- };
321
- }
322
-
323
- // Extract message ID from raw text to preserve large integer precision
324
- const msgkey =
325
- extractIdFromRawJson(responseText, "msgkey") ??
326
- extractIdFromRawJson(responseText, "messageid") ??
327
- extractMessageId(innerData ?? {});
328
- if (msgkey) {
329
- recordSentMessageId(msgkey);
330
- coreEvents.emit("message:sent", {
331
- accountId: account.accountId,
332
- target: toUser,
333
- from: account.config.appAgentId != null ? `agent:${account.config.appAgentId}` : "agent:unknown",
334
- messageid: msgkey,
335
- msgseqid: "",
336
- contents,
337
- sentAt: Date.now(),
338
- });
339
- }
340
-
341
- return { ok: true, invaliduser: innerData?.invaliduser as string | undefined, msgkey };
342
- } catch (err) {
343
- const errMsg = formatInfoflowError(err);
344
- getInfoflowSendLog().error(`[infoflow:sendPrivate] exception: ${errMsg}`);
345
- return { ok: false, error: errMsg };
346
- } finally {
347
- clearTimeout(timeout);
348
- }
349
- }
350
-
351
- // ---------------------------------------------------------------------------
352
- // Group Chat Message Sending
353
- // ---------------------------------------------------------------------------
354
-
355
- /**
356
- * Sends a group chat message.
357
- * @param account - Resolved Infoflow account with config
358
- * @param groupId - Target group ID (numeric)
359
- * @param contents - Array of content items (text/markdown/at)
360
- */
361
- export async function sendInfoflowGroupMessage(params: {
362
- account: ResolvedInfoflowAccount;
363
- groupId: number;
364
- contents: InfoflowMessageContentItem[];
365
- replyTo?: InfoflowOutboundReply;
366
- timeoutMs?: number;
367
- }): Promise<{ ok: boolean; error?: string; messageid?: string; msgseqid?: string }> {
368
- const { account, groupId, contents, replyTo, timeoutMs = DEFAULT_TIMEOUT_MS } = params;
369
- const { apiHost, appKey, appSecret } = account.config;
370
-
371
- // Validate account config
372
- if (!appKey || !appSecret) {
373
- return { ok: false, error: "Infoflow appKey/appSecret not configured." };
374
- }
375
-
376
- // Validate contents
377
- if (contents.length === 0) {
378
- return { ok: false, error: "contents array is empty" };
379
- }
380
-
381
- // Build group message body from contents
382
- let hasMarkdown = false;
383
- const body: InfoflowGroupMessageBodyItem[] = [];
384
- for (const item of contents) {
385
- const type = item.type.toLowerCase();
386
- if (type === "text") {
387
- body.push({ type: "TEXT", content: item.content });
388
- } else if (type === "md" || type === "markdown") {
389
- body.push({ type: "MD", content: item.content });
390
- hasMarkdown = true;
391
- } else if (type === "at") {
392
- // Parse AT content: "all" means atall, otherwise comma-separated user IDs
393
- if (item.content === "all") {
394
- body.push({ type: "AT", atall: true, atuserids: [] });
395
- } else {
396
- const userIds = item.content
397
- .split(",")
398
- .map((s) => s.trim())
399
- .filter(Boolean);
400
- if (userIds.length > 0) {
401
- body.push({ type: "AT", atuserids: userIds });
402
- }
403
- }
404
- } else if (type === "link") {
405
- // Group messages only use href (label is ignored)
406
- if (item.content) {
407
- const { href } = parseLinkContent(item.content);
408
- body.push({ type: "LINK", href });
409
- }
410
- } else if (type === "at-agent") {
411
- // Robot AT: parse comma-separated numeric IDs into atagentids
412
- const agentIds = item.content
413
- .split(",")
414
- .map((s) => Number(s.trim()))
415
- .filter(Number.isFinite);
416
- if (agentIds.length > 0) {
417
- body.push({ type: "AT", atuserids: [], atagentids: agentIds });
418
- }
419
- } else if (type === "image") {
420
- body.push({ type: "IMAGE", content: item.content });
421
- }
422
- }
423
-
424
- // Split body: LINK and IMAGE must be sent as individual messages
425
- const linkItems = body.filter((b) => b.type === "LINK");
426
- const imageItems = body.filter((b) => b.type === "IMAGE");
427
- const textItems = body.filter((b) => b.type !== "LINK" && b.type !== "IMAGE");
428
-
429
- // Get token first (shared by all sends)
430
- const tokenResult = await getAppAccessToken({ apiHost, appKey, appSecret, timeoutMs });
431
- if (!tokenResult.ok || !tokenResult.token) {
432
- getInfoflowSendLog().error(`[infoflow:sendGroup] token error: ${tokenResult.error}`);
433
- return { ok: false, error: tokenResult.error ?? "failed to get token" };
434
- }
435
-
436
- // NOTE: Infoflow API requires "Bearer-<token>" format (with hyphen, not space).
437
- // This is a non-standard format specific to Infoflow service. Do not modify
438
- // unless the Infoflow API specification changes.
439
- const headers = {
440
- Authorization: `Bearer-${tokenResult.token}`,
441
- "Content-Type": "application/json",
442
- };
443
-
444
- let msgIndex = 0;
445
-
446
- // Helper: post a single group message payload
447
- const postGroupMessage = async (
448
- msgBody: InfoflowGroupMessageBodyItem[],
449
- msgtype: string,
450
- replyTo?: InfoflowOutboundReply,
451
- ): Promise<{ ok: boolean; error?: string; messageid?: string; msgseqid?: string }> => {
452
- let timeout: ReturnType<typeof setTimeout> | undefined;
453
- try {
454
- const controller = new AbortController();
455
- timeout = setTimeout(() => controller.abort(), timeoutMs);
456
-
457
- const payload = {
458
- message: {
459
- header: {
460
- toid: groupId,
461
- totype: "GROUP",
462
- msgtype,
463
- clientmsgid: Date.now() + msgIndex++,
464
- role: "robot",
465
- },
466
- body: msgBody,
467
- ...(replyTo
468
- ? {
469
- reply: {
470
- messageid: String(replyTo.messageid),
471
- preview: replyTo.preview ?? "",
472
- ...(replyTo.imid ? { imid: replyTo.imid } : {}),
473
- replytype: replyTo.replytype ?? "1",
474
- },
475
- }
476
- : {}),
477
- },
478
- };
479
-
480
- // Build request body
481
- const bodyStr = JSON.stringify(payload);
482
-
483
- getInfoflowSendLog().info(`[outbound:group] groupId=${groupId}, msgtype=${msgtype}, bodyLen=${bodyStr.length}, hasReply=${!!replyTo}`);
484
- logVerbose(`[outbound:group] POST body: ${bodyStr}`);
485
-
486
- const res = await fetch(`${ensureHttps(apiHost)}${INFOFLOW_GROUP_SEND_PATH}`, {
487
- method: "POST",
488
- headers,
489
- body: bodyStr,
490
- signal: controller.signal,
491
- });
492
-
493
- const responseText = await res.text();
494
- const data = JSON.parse(responseText) as Record<string, unknown>;
495
- getInfoflowSendLog().info(`[outbound:group] response: status=${res.status}, code=${data.code ?? "?"}, messageid=${extractIdFromRawJson(responseText, "messageid") ?? "?"}`);
496
- logVerbose(`[outbound:group] response body: ${responseText}`);
497
-
498
- const code = typeof data.code === "string" ? data.code : "";
499
- if (code !== "ok") {
500
- const errMsg = String(data.message ?? data.errmsg ?? `code=${code || "unknown"}`);
501
- getInfoflowSendLog().error(`[infoflow:sendGroup] failed: ${errMsg}`);
502
- return { ok: false, error: errMsg };
503
- }
504
-
505
- const innerData = data.data as Record<string, unknown> | undefined;
506
- const errcode = innerData?.errcode;
507
- if (errcode != null && errcode !== 0) {
508
- const errMsg = String(innerData?.errmsg ?? `errcode ${errcode}`);
509
- getInfoflowSendLog().error(`[infoflow:sendGroup] failed: ${errMsg}`);
510
- return { ok: false, error: errMsg };
511
- }
512
-
513
- // Extract IDs from raw text to preserve large integer precision
514
- const messageid =
515
- extractIdFromRawJson(responseText, "messageid") ??
516
- extractIdFromRawJson(responseText, "msgid");
517
- const msgseqid = extractIdFromRawJson(responseText, "msgseqid");
518
- if (messageid) {
519
- recordSentMessageId(messageid);
520
- }
521
-
522
- return { ok: true, messageid, msgseqid };
523
- } catch (err) {
524
- const errMsg = formatInfoflowError(err);
525
- getInfoflowSendLog().error(`[infoflow:sendGroup] exception: ${errMsg}`);
526
- return { ok: false, error: errMsg };
527
- } finally {
528
- clearTimeout(timeout);
529
- }
530
- };
531
-
532
- // Helper: emit sent event for a successful sub-message
533
- const emitSent = (
534
- result: { messageid?: string; msgseqid?: string },
535
- digestContents: InfoflowMessageContentItem[],
536
- ) => {
537
- if (!result.messageid) return;
538
- coreEvents.emit("message:sent", {
539
- accountId: account.accountId,
540
- target: `group:${groupId}`,
541
- from: account.config.appAgentId != null ? `agent:${account.config.appAgentId}` : "agent:unknown",
542
- messageid: result.messageid,
543
- msgseqid: result.msgseqid ?? "",
544
- contents: digestContents,
545
- sentAt: Date.now(),
546
- });
547
- };
548
-
549
- let lastMessageId: string | undefined;
550
- let lastMsgSeqId: string | undefined;
551
- let firstError: string | undefined;
552
- let replyApplied = false;
553
-
554
- // 1) Send text/AT/MD items together (if any)
555
- if (textItems.length > 0) {
556
- const msgtype = hasMarkdown ? "MD" : "TEXT";
557
- const result = await postGroupMessage(
558
- textItems,
559
- msgtype,
560
- !replyApplied ? params.replyTo : undefined,
561
- );
562
- replyApplied = true;
563
- if (result.ok) {
564
- lastMessageId = result.messageid;
565
- lastMsgSeqId = result.msgseqid;
566
- const digestItems = contents.filter((c) => !["link", "image"].includes(c.type.toLowerCase()));
567
- emitSent(result, digestItems);
568
- } else if (!firstError) {
569
- firstError = result.error;
570
- }
571
- }
572
-
573
- // 2) Send each LINK as a separate message
574
- for (const linkItem of linkItems) {
575
- const result = await postGroupMessage(
576
- [linkItem],
577
- "TEXT",
578
- !replyApplied ? params.replyTo : undefined,
579
- );
580
- replyApplied = true;
581
- if (result.ok) {
582
- lastMessageId = result.messageid;
583
- lastMsgSeqId = result.msgseqid;
584
- emitSent(result, [{ type: "link", content: linkItem.href }]);
585
- } else if (!firstError) {
586
- firstError = result.error;
587
- }
588
- }
589
-
590
- // 3) Send each IMAGE as a separate message
591
- for (const imageItem of imageItems) {
592
- const result = await postGroupMessage(
593
- [imageItem],
594
- "IMAGE",
595
- !replyApplied ? params.replyTo : undefined,
596
- );
597
- replyApplied = true;
598
- if (result.ok) {
599
- lastMessageId = result.messageid;
600
- lastMsgSeqId = result.msgseqid;
601
- emitSent(result, [{ type: "image", content: "" }]);
602
- } else if (!firstError) {
603
- firstError = result.error;
604
- }
605
- }
606
-
607
- if (firstError) {
608
- return { ok: false, error: firstError, messageid: lastMessageId, msgseqid: lastMsgSeqId };
609
- }
610
- return { ok: true, messageid: lastMessageId, msgseqid: lastMsgSeqId };
611
- }
612
-
613
- // ---------------------------------------------------------------------------
614
- // Group Message Recall (撤回)
615
- // ---------------------------------------------------------------------------
616
-
617
- /**
618
- * Recalls (撤回) a group message previously sent by the robot.
619
- * Only group messages can be recalled via this API.
620
- */
621
- export async function recallInfoflowGroupMessage(params: {
622
- account: ResolvedInfoflowAccount;
623
- groupId: number;
624
- messageid: string;
625
- msgseqid: string;
626
- timeoutMs?: number;
627
- }): Promise<{ ok: boolean; error?: string }> {
628
- const { account, groupId, messageid, msgseqid, timeoutMs = DEFAULT_TIMEOUT_MS } = params;
629
- const { apiHost, appKey, appSecret } = account.config;
630
-
631
- // 验证必要的认证配置
632
- if (!appKey || !appSecret) {
633
- return { ok: false, error: "Infoflow appKey/appSecret not configured." };
634
- }
635
-
636
- // 获取应用访问令牌
637
- const tokenResult = await getAppAccessToken({ apiHost, appKey, appSecret, timeoutMs });
638
- if (!tokenResult.ok || !tokenResult.token) {
639
- getInfoflowSendLog().error(`[infoflow:recallGroup] token error: ${tokenResult.error}`);
640
- return { ok: false, error: tokenResult.error ?? "failed to get token" };
641
- }
642
-
643
- let timeout: ReturnType<typeof setTimeout> | undefined;
644
- try {
645
- const controller = new AbortController();
646
- timeout = setTimeout(() => controller.abort(), timeoutMs);
647
-
648
- // 手动构建 JSON 以保持 messageid/msgseqid 为原始整数,避免 JavaScript Number 精度丢失
649
- const bodyStr = `{"groupId":${groupId},"messageid":${messageid},"msgseqid":${msgseqid}}`;
650
-
651
- logVerbose(`[infoflow:recallGroup] POST token: ${tokenResult.token} body: ${bodyStr}`);
652
-
653
- // 发送撤回请求
654
- const res = await fetch(`${ensureHttps(apiHost)}${INFOFLOW_GROUP_RECALL_PATH}`, {
655
- method: "POST",
656
- headers: {
657
- Authorization: `Bearer-${tokenResult.token}`,
658
- "Content-Type": "application/json; charset=utf-8",
659
- },
660
- body: bodyStr,
661
- signal: controller.signal,
662
- });
663
-
664
- const data = JSON.parse(await res.text()) as Record<string, unknown>;
665
- logVerbose(
666
- `[infoflow:recallGroup] response: status=${res.status}, data=${JSON.stringify(data)}`,
667
- );
668
-
669
- // 检查外层响应码
670
- const code = typeof data.code === "string" ? data.code : "";
671
- if (code !== "ok") {
672
- const errMsg = String(data.message ?? data.errmsg ?? `code=${code || "unknown"}`);
673
- getInfoflowSendLog().error(`[infoflow:recallGroup] failed: ${errMsg}`);
674
- return { ok: false, error: errMsg };
675
- }
676
-
677
- // 检查内层错误码
678
- const innerData = data.data as Record<string, unknown> | undefined;
679
- const errcode = innerData?.errcode;
680
- if (errcode != null && errcode !== 0) {
681
- const errMsg = String(innerData?.errmsg ?? `errcode ${errcode}`);
682
- getInfoflowSendLog().error(`[infoflow:recallGroup] failed: ${errMsg}`);
683
- return { ok: false, error: errMsg };
684
- }
685
-
686
- return { ok: true };
687
- } catch (err) {
688
- const errMsg = formatInfoflowError(err);
689
- getInfoflowSendLog().error(`[infoflow:recallGroup] exception: ${errMsg}`);
690
- return { ok: false, error: errMsg };
691
- } finally {
692
- clearTimeout(timeout);
693
- }
694
- }
695
-
696
- // ---------------------------------------------------------------------------
697
- // Private Message Recall (撤回)
698
- // ---------------------------------------------------------------------------
699
-
700
- /**
701
- * Recalls (撤回) a private message previously sent by the app.
702
- * Uses the /api/v1/app/message/revoke endpoint.
703
- */
704
- export async function recallInfoflowPrivateMessage(params: {
705
- account: ResolvedInfoflowAccount;
706
- /** 发送消息时返回的 msgkey(存储于 sent-message-store 的 messageid 字段) */
707
- msgkey: string;
708
- /** 如流企业后台"应用ID" */
709
- appAgentId: number;
710
- timeoutMs?: number;
711
- }): Promise<{ ok: boolean; error?: string }> {
712
- const { account, msgkey, appAgentId, timeoutMs = DEFAULT_TIMEOUT_MS } = params;
713
- const { apiHost, appKey, appSecret } = account.config;
714
-
715
- if (!appKey || !appSecret) {
716
- return { ok: false, error: "Infoflow appKey/appSecret not configured." };
717
- }
718
-
719
- const tokenResult = await getAppAccessToken({ apiHost, appKey, appSecret, timeoutMs });
720
- if (!tokenResult.ok || !tokenResult.token) {
721
- getInfoflowSendLog().error(`[infoflow:recallPrivate] token error: ${tokenResult.error}`);
722
- return { ok: false, error: tokenResult.error ?? "failed to get token" };
723
- }
724
-
725
- let timeout: ReturnType<typeof setTimeout> | undefined;
726
- try {
727
- const controller = new AbortController();
728
- timeout = setTimeout(() => controller.abort(), timeoutMs);
729
-
730
- const bodyStr = JSON.stringify({ msgkey, agentid: appAgentId });
731
-
732
- logVerbose(`[infoflow:recallPrivate] POST auth: ${tokenResult.token} body: ${bodyStr}`);
733
-
734
- const res = await fetch(`${ensureHttps(apiHost)}${INFOFLOW_PRIVATE_RECALL_PATH}`, {
735
- method: "POST",
736
- headers: {
737
- Authorization: `Bearer-${tokenResult.token}`,
738
- "Content-Type": "application/json; charset=utf-8",
739
- LOGID: String(Date.now()),
740
- },
741
- body: bodyStr,
742
- signal: controller.signal,
743
- });
744
-
745
- const data = JSON.parse(await res.text()) as Record<string, unknown>;
746
- logVerbose(
747
- `[infoflow:recallPrivate] response: status=${res.status}, data=${JSON.stringify(data)}`,
748
- );
749
-
750
- // 检查外层响应码
751
- const code = typeof data.code === "string" ? data.code : "";
752
- if (code !== "ok") {
753
- const errMsg = String(data.message ?? data.errmsg ?? `code=${code || "unknown"}`);
754
- getInfoflowSendLog().error(`[infoflow:recallPrivate] failed: ${errMsg}`);
755
- return { ok: false, error: errMsg };
756
- }
757
-
758
- // 检查内层错误码
759
- const innerData = data.data as Record<string, unknown> | undefined;
760
- const errcode = innerData?.errcode;
761
- if (errcode != null && errcode !== 0) {
762
- const errMsg = String(innerData?.errmsg ?? `errcode ${errcode}`);
763
- getInfoflowSendLog().error(`[infoflow:recallPrivate] failed: ${errMsg}`);
764
- return { ok: false, error: errMsg };
765
- }
766
-
767
- return { ok: true };
768
- } catch (err) {
769
- const errMsg = formatInfoflowError(err);
770
- getInfoflowSendLog().error(`[infoflow:recallPrivate] exception: ${errMsg}`);
771
- return { ok: false, error: errMsg };
772
- } finally {
773
- clearTimeout(timeout);
774
- }
775
- }
776
-
777
- // ---------------------------------------------------------------------------
778
- // Local Image Link Resolution
779
- // ---------------------------------------------------------------------------
780
-
781
- /**
782
- * Pre-processes content items: for "link" items pointing to local file paths,
783
- * checks if the file is an image and converts to "image" type with base64 content.
784
- * Falls back to original "link" type if not an image or on error.
785
- */
786
- async function resolveLocalImageLinks(
787
- contents: InfoflowMessageContentItem[],
788
- ): Promise<InfoflowMessageContentItem[]> {
789
- const hasLocalLinks = contents.some(
790
- (item) => item.type === "link" && isLikelyLocalPath(parseLinkContent(item.content).href),
791
- );
792
- if (!hasLocalLinks) {
793
- return contents;
794
- }
795
-
796
- // Dynamic import to avoid circular dependency (media.ts imports from send.ts)
797
- const { prepareInfoflowImageBase64 } = await import("./media.js");
798
-
799
- const resolved: InfoflowMessageContentItem[] = [];
800
- for (const item of contents) {
801
- if (item.type !== "link") {
802
- resolved.push(item);
803
- continue;
804
- }
805
-
806
- const { href } = parseLinkContent(item.content);
807
- if (!isLikelyLocalPath(href)) {
808
- resolved.push(item);
809
- continue;
810
- }
811
-
812
- // Attempt image detection for local path
813
- try {
814
- const prepared = await prepareInfoflowImageBase64({ mediaUrl: href, mediaLocalRoots:[href] });
815
- if (prepared.isImage) {
816
- resolved.push({ type: "image", content: prepared.base64 });
817
- continue;
818
- }
819
- } catch {
820
- logVerbose(`[infoflow:send] local image detection failed for ${href}, sending as link`);
821
- }
822
- resolved.push(item);
823
- }
824
-
825
- return resolved;
826
- }
827
-
828
- // ---------------------------------------------------------------------------
829
- // Unified Message Sending
830
- // ---------------------------------------------------------------------------
831
-
832
- /**
833
- * Unified message sending entry point.
834
- * Parses the `to` target and dispatches to group or private message sending.
835
- * Local file path links that are images are automatically sent as native images.
836
- * @param cfg - OpenClaw config
837
- * @param to - Target: "username" for private, "group:123" for group
838
- * @param contents - Array of content items (text/markdown/at)
839
- * @param accountId - Optional account ID for multi-account support
840
- * @param replyTo - Optional reply context for group messages (ignored for private)
841
- */
842
- export async function sendInfoflowMessage(params: {
843
- cfg: OpenClawConfig;
844
- to: string;
845
- contents: InfoflowMessageContentItem[];
846
- accountId?: string;
847
- replyTo?: InfoflowOutboundReply;
848
- }): Promise<{ ok: boolean; error?: string; messageId?: string; msgseqid?: string }> {
849
- const { cfg, to, contents, accountId } = params;
850
-
851
- // Resolve account config
852
- const account = resolveInfoflowAccount({ cfg, accountId });
853
- const { appKey, appSecret } = account.config;
854
-
855
- if (!appKey || !appSecret) {
856
- return { ok: false, error: "Infoflow appKey/appSecret not configured." };
857
- }
858
-
859
- // Validate contents
860
- if (contents.length === 0) {
861
- return { ok: false, error: "contents array is empty" };
862
- }
863
-
864
- // Pre-process: convert local-path link items to native image items if they're images
865
- const resolvedContents = await resolveLocalImageLinks(contents);
866
-
867
- // Parse target: remove "infoflow:" prefix if present
868
- const target = to.replace(/^infoflow:/i, "");
869
-
870
- getInfoflowSendLog().info(`[outbound] sendMessage: to=${target}, items=${resolvedContents.length}, types=[${resolvedContents.map(c => c.type).join(",")}]`);
871
-
872
- // Check if target is a group (format: group:123)
873
- const groupMatch = target.match(/^group:(\d+)/i);
874
- if (groupMatch) {
875
- // Group path: sendInfoflowGroupMessage already handles IMAGE items
876
- const groupId = Number(groupMatch[1]);
877
- const result = await sendInfoflowGroupMessage({
878
- account,
879
- groupId,
880
- contents: resolvedContents,
881
- replyTo: params.replyTo,
882
- });
883
- return {
884
- ok: result.ok,
885
- error: result.error,
886
- messageId: result.messageid,
887
- msgseqid: result.msgseqid,
888
- };
889
- }
890
-
891
- // Private path: split image items (sendInfoflowPrivateMessage doesn't handle image type)
892
- const imageItems = resolvedContents.filter((c) => c.type === "image");
893
- const nonImageContents = resolvedContents.filter((c) => c.type !== "image");
894
-
895
- let lastMessageId: string | undefined;
896
- let firstError: string | undefined;
897
-
898
- // Send non-image contents via private message API
899
- if (nonImageContents.length > 0) {
900
- const result = await sendInfoflowPrivateMessage({
901
- account,
902
- toUser: target,
903
- contents: nonImageContents,
904
- });
905
- if (result.ok) {
906
- lastMessageId = result.msgkey;
907
- } else {
908
- firstError = result.error;
909
- }
910
- }
911
-
912
- // Send image items as native private images
913
- if (imageItems.length > 0) {
914
- const { sendInfoflowPrivateImage } = await import("./media.js");
915
- for (const imgItem of imageItems) {
916
- const result = await sendInfoflowPrivateImage({
917
- account,
918
- toUser: target,
919
- base64Image: imgItem.content,
920
- });
921
- if (result.ok) {
922
- lastMessageId = result.msgkey;
923
- } else if (!firstError) {
924
- firstError = result.error;
925
- }
926
- }
927
- }
928
-
929
- if (firstError && !lastMessageId) {
930
- return { ok: false, error: firstError };
931
- }
932
- return { ok: true, messageId: lastMessageId };
933
- }
934
-
935
- // ---------------------------------------------------------------------------
936
- // Test-only exports (@internal — not part of the public API)
937
- // ---------------------------------------------------------------------------
938
-
939
- /** @internal — Clears the SDK adapter cache. Only use in tests. */
940
- export function _resetTokenCache(): void {
941
- _resetAdapters();
942
- }