@core-workspace/infoflow-openclaw-plugin 2026.3.9 → 2026.3.27-beta.0

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 (55) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/CLAUDE.md +135 -0
  3. package/COLLABORATION_REPORT.md +209 -0
  4. package/PROJECT_GUIDE.md +355 -0
  5. package/README.md +158 -66
  6. package/docs/dev-guide.md +63 -50
  7. package/docs/qa-feature-list.md +452 -0
  8. package/docs/webhook-guide.md +178 -0
  9. package/index.ts +28 -2
  10. package/openclaw.plugin.json +131 -21
  11. package/package.json +16 -3
  12. package/scripts/deploy.sh +66 -7
  13. package/scripts/postinstall.cjs +80 -0
  14. package/skills/infoflow-dev/SKILL.md +2 -2
  15. package/skills/infoflow-dev/references/api.md +1 -1
  16. package/src/adapter/inbound/webhook-parser.ts +27 -5
  17. package/src/adapter/inbound/ws-receiver.ts +304 -43
  18. package/src/adapter/outbound/markdown-local-images.ts +80 -0
  19. package/src/adapter/outbound/reply-dispatcher.ts +146 -65
  20. package/src/adapter/outbound/target-resolver.ts +4 -3
  21. package/src/channel/accounts.ts +97 -22
  22. package/src/channel/channel.ts +456 -12
  23. package/src/channel/media.ts +20 -6
  24. package/src/channel/monitor.ts +8 -3
  25. package/src/channel/outbound.ts +358 -21
  26. package/src/channel/streaming.ts +740 -0
  27. package/src/commands/changelog.ts +80 -0
  28. package/src/commands/doctor.ts +545 -0
  29. package/src/commands/logs.ts +449 -0
  30. package/src/commands/version.ts +20 -0
  31. package/src/compat/openclaw-sdk.ts +218 -0
  32. package/src/handler/message-handler.ts +673 -166
  33. package/src/logging.ts +1 -1
  34. package/src/runtime.ts +1 -1
  35. package/src/security/dm-policy.ts +1 -4
  36. package/src/security/group-policy.ts +174 -51
  37. package/src/tools/actions/index.ts +15 -13
  38. package/src/tools/cron/relay.ts +1154 -0
  39. package/src/tools/hooks/index.ts +13 -1
  40. package/src/tools/index.ts +714 -32
  41. package/src/types.ts +144 -25
  42. package/src/utils/audio/g722/dct_tables.ts +381 -0
  43. package/src/utils/audio/g722/decoder.ts +919 -0
  44. package/src/utils/audio/g722/defs.ts +105 -0
  45. package/src/utils/audio/g722/hd-parser.ts +247 -0
  46. package/src/utils/audio/g722/huff_tables.ts +240 -0
  47. package/src/utils/audio/g722/index.ts +78 -0
  48. package/src/utils/audio/g722/output_decoded.pcm +0 -0
  49. package/src/utils/audio/g722/output_decoded.wav +0 -0
  50. package/src/utils/audio/g722/tables.ts +173 -0
  51. package/src/utils/audio/g722/test_api.ts +31 -0
  52. package/src/utils/audio/g722/test_voice.hd +0 -0
  53. package/src/utils/bos/im-bos-client.ts +219 -0
  54. package/src/utils/group-agent-cache.ts +142 -0
  55. package/src/utils/token-adapter.ts +120 -51
@@ -1,21 +1,40 @@
1
1
  /**
2
2
  * Infoflow Agent Tools(Agent 工具注册)
3
3
  *
4
- * 向 OpenClaw 注册两个供 LLM function calling 使用的工具:
4
+ * 向 OpenClaw 注册供 LLM function calling 使用的工具:
5
5
  * - infoflow_send: 主动向用户或群发送消息
6
6
  * - infoflow_recall: 撤回近期发送的消息
7
+ * - infoflow_cron: 创建强绑定当前如流会话目标的定时消息
7
8
  *
8
9
  * 这两个工具封装了 outbound/send.ts 中的底层发送/撤回逻辑,
9
10
  * 让 Agent 能够主动发消息,而不仅限于被动回复。
10
11
  */
11
12
 
13
+ import { spawn } from "node:child_process";
14
+ import { randomUUID } from "node:crypto";
15
+ import fs from "node:fs/promises";
16
+ import path from "node:path";
12
17
  import { Type, type Static } from "@sinclair/typebox";
13
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
18
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
19
+ import { normalizeInfoflowTarget } from "../adapter/outbound/target-resolver.js";
14
20
  import { resolveInfoflowAccount } from "../channel/accounts.js";
21
+ import { prepareInfoflowImageBase64, sendInfoflowImageMessage } from "../channel/media.js";
22
+ import {
23
+ sendInfoflowMessage,
24
+ recallInfoflowGroupMessage,
25
+ recallInfoflowPrivateMessage,
26
+ isLikelyLocalPath,
27
+ } from "../channel/outbound.js";
15
28
  import { logVerbose } from "../logging.js";
16
- import { sendInfoflowMessage, recallInfoflowGroupMessage, recallInfoflowPrivateMessage } from "../channel/outbound.js";
29
+ import type { InfoflowMessageContentItem } from "../types.js";
30
+ import { imBosUpload, imBosGetUrl } from "../utils/bos/im-bos-client.js";
17
31
  import { querySentMessages, removeRecalledMessages } from "../utils/store/message-store.js";
18
- import { normalizeInfoflowTarget } from "../adapter/outbound/target-resolver.js";
32
+ import {
33
+ encodeInfoflowCronJobDescription,
34
+ resolveInfoflowTargetFromSessionEntry,
35
+ resolveInfoflowTargetFromSessionKey,
36
+ type InfoflowCronJobMetadata,
37
+ } from "./cron/relay.js";
19
38
 
20
39
  // ---------------------------------------------------------------------------
21
40
  // infoflow_send 参数 Schema
@@ -45,6 +64,12 @@ const InfoflowSendSchema = Type.Object({
45
64
  description: "多账号模式下指定账号 ID,不填则使用默认账号。",
46
65
  }),
47
66
  ),
67
+ imageUrl: Type.Optional(
68
+ Type.String({
69
+ description:
70
+ "要发送的图片 URL 或本地文件路径。提供此参数时会发送图片消息;可与 message 同时使用(先发文字再发图片)。",
71
+ }),
72
+ ),
48
73
  });
49
74
 
50
75
  type InfoflowSendParams = Static<typeof InfoflowSendSchema>;
@@ -78,6 +103,217 @@ const InfoflowRecallSchema = Type.Object({
78
103
 
79
104
  type InfoflowRecallParams = Static<typeof InfoflowRecallSchema>;
80
105
 
106
+ // ---------------------------------------------------------------------------
107
+ // infoflow_cron 参数 Schema
108
+ // ---------------------------------------------------------------------------
109
+
110
+ const InfoflowCronSchema = Type.Object({
111
+ name: Type.String({
112
+ description: "定时任务名称,建议简短明确,例如“日报提醒”。",
113
+ }),
114
+ message: Type.String({
115
+ description: "定时发送的消息内容,支持 Markdown。",
116
+ }),
117
+ atAll: Type.Optional(
118
+ Type.Boolean({
119
+ description: "群聊定时消息是否 @全体成员。仅群聊生效,单聊忽略。",
120
+ }),
121
+ ),
122
+ mentionUserIds: Type.Optional(
123
+ Type.String({
124
+ description:
125
+ '群聊定时消息需要 @ 的成员列表,逗号分隔的 uuapName(例如 "zhangsan,lisi")。仅群聊生效,atAll 为 true 时忽略。',
126
+ }),
127
+ ),
128
+ at: Type.Optional(
129
+ Type.String({
130
+ description: '一次性任务的触发时间。支持 ISO 时间,例如 "2026-03-17T18:00:00+08:00"。',
131
+ }),
132
+ ),
133
+ every: Type.Optional(
134
+ Type.String({
135
+ description: '按固定间隔重复执行,例如 "30m"、"1h"。',
136
+ }),
137
+ ),
138
+ cron: Type.Optional(
139
+ Type.String({
140
+ description: 'Cron 表达式,例如 "0 9 * * 1-5"。',
141
+ }),
142
+ ),
143
+ tz: Type.Optional(
144
+ Type.String({
145
+ description: 'Cron 表达式对应的 IANA 时区,例如 "Asia/Shanghai"。',
146
+ }),
147
+ ),
148
+ description: Type.Optional(
149
+ Type.String({
150
+ description: "可选的任务备注。",
151
+ }),
152
+ ),
153
+ keepAfterRun: Type.Optional(
154
+ Type.Boolean({
155
+ description: "一次性任务执行后是否保留。默认执行后删除。",
156
+ }),
157
+ ),
158
+ });
159
+
160
+ type InfoflowCronParams = Static<typeof InfoflowCronSchema>;
161
+ type InfoflowToolExecutionContext = {
162
+ messageChannel?: string;
163
+ requesterSenderId?: string;
164
+ sessionKey?: string;
165
+ agentDir?: string;
166
+ agentId?: string;
167
+ workspaceDir?: string;
168
+ };
169
+
170
+ function json(payload: unknown) {
171
+ return {
172
+ content: [{ type: "text" as const, text: JSON.stringify(payload) }],
173
+ details: payload,
174
+ };
175
+ }
176
+
177
+ function expandHomePrefix(filePath: string): string {
178
+ if (filePath === "~") {
179
+ return process.env.HOME ?? "~";
180
+ }
181
+ if (filePath.startsWith("~/")) {
182
+ return path.join(process.env.HOME ?? "~", filePath.slice(2));
183
+ }
184
+ return filePath;
185
+ }
186
+
187
+ function toObject(value: unknown): Record<string, unknown> | null {
188
+ if (!value || typeof value !== "object") {
189
+ return null;
190
+ }
191
+ return value as Record<string, unknown>;
192
+ }
193
+
194
+ function toTrimmedString(value: unknown): string | undefined {
195
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
196
+ }
197
+
198
+ function buildInfoflowCronSessionKey(): string {
199
+ return `agent:main:cron:infoflow:${randomUUID()}`;
200
+ }
201
+
202
+ function resolveToolSessionStorePath(
203
+ api: OpenClawPluginApi,
204
+ ctx: InfoflowToolExecutionContext,
205
+ ): string | null {
206
+ const configured = toObject(api.config.session)?.store;
207
+ if (typeof configured === "string" && configured.trim()) {
208
+ const agentId = ctx.agentId?.trim() || "main";
209
+ return path.resolve(expandHomePrefix(configured.trim().replaceAll("{agentId}", agentId)));
210
+ }
211
+ if (ctx.agentDir?.trim()) {
212
+ return path.join(ctx.agentDir, "sessions", "sessions.json");
213
+ }
214
+ return null;
215
+ }
216
+
217
+ async function resolveInfoflowCronTargetForTool(
218
+ api: OpenClawPluginApi,
219
+ ctx: InfoflowToolExecutionContext,
220
+ ): Promise<{ target: string; creatorSenderId?: string } | null> {
221
+ if (ctx.messageChannel !== "infoflow") {
222
+ return null;
223
+ }
224
+
225
+ const requesterSenderId = ctx.requesterSenderId?.trim() || undefined;
226
+ const storePath = resolveToolSessionStorePath(api, ctx);
227
+ if (storePath && ctx.sessionKey?.trim()) {
228
+ try {
229
+ const raw = await fs.readFile(storePath, "utf8");
230
+ const parsed = toObject(JSON.parse(raw));
231
+ const sessionTarget = resolveInfoflowTargetFromSessionEntry({
232
+ entry: parsed?.[ctx.sessionKey],
233
+ sessionKey: ctx.sessionKey,
234
+ });
235
+ if (sessionTarget) {
236
+ return {
237
+ target: sessionTarget,
238
+ creatorSenderId: requesterSenderId,
239
+ };
240
+ }
241
+ } catch {
242
+ // Ignore session store read errors and fall through to sessionKey / FromUserId resolution.
243
+ }
244
+ }
245
+
246
+ const sessionTarget = resolveInfoflowTargetFromSessionKey(ctx.sessionKey);
247
+ if (sessionTarget) {
248
+ return {
249
+ target: sessionTarget,
250
+ creatorSenderId: requesterSenderId,
251
+ };
252
+ }
253
+
254
+ if (requesterSenderId && !ctx.sessionKey?.toLowerCase().includes(":group:")) {
255
+ return {
256
+ target: `infoflow:${requesterSenderId}`,
257
+ creatorSenderId: requesterSenderId,
258
+ };
259
+ }
260
+
261
+ return null;
262
+ }
263
+
264
+ async function runOpenClaw(
265
+ args: string[],
266
+ cwd: string | undefined,
267
+ ): Promise<{
268
+ exitCode: number;
269
+ stdout: string;
270
+ stderr: string;
271
+ }> {
272
+ return await new Promise((resolve, reject) => {
273
+ const child = spawn("openclaw", args, {
274
+ cwd,
275
+ env: process.env,
276
+ stdio: ["ignore", "pipe", "pipe"],
277
+ });
278
+ let stdout = "";
279
+ let stderr = "";
280
+ child.stdout.on("data", (chunk) => {
281
+ stdout += String(chunk);
282
+ });
283
+ child.stderr.on("data", (chunk) => {
284
+ stderr += String(chunk);
285
+ });
286
+ child.on("error", reject);
287
+ child.on("close", (exitCode) => {
288
+ resolve({
289
+ exitCode: exitCode ?? 1,
290
+ stdout,
291
+ stderr,
292
+ });
293
+ });
294
+ });
295
+ }
296
+
297
+ function parseJsonFromMixedOutput(raw: string): unknown {
298
+ const trimmed = raw.trim();
299
+ if (!trimmed) {
300
+ return null;
301
+ }
302
+ try {
303
+ return JSON.parse(trimmed);
304
+ } catch {
305
+ const objectStart = trimmed.indexOf("{");
306
+ if (objectStart >= 0) {
307
+ try {
308
+ return JSON.parse(trimmed.slice(objectStart));
309
+ } catch {
310
+ return null;
311
+ }
312
+ }
313
+ return null;
314
+ }
315
+ }
316
+
81
317
  // ---------------------------------------------------------------------------
82
318
  // 注册函数
83
319
  // ---------------------------------------------------------------------------
@@ -96,22 +332,23 @@ export function registerInfoflowTools(api: OpenClawPluginApi): void {
96
332
  api.registerTool(
97
333
  {
98
334
  name: "infoflow_send",
335
+ label: "Infoflow Send",
99
336
  description:
100
- "向如流(Infoflow)的用户或群发送消息。支持纯文本和 Markdown 格式。" +
337
+ "向如流(Infoflow)的用户或群发送消息。支持纯文本、Markdown 格式和图片(URL 或本地路径)。" +
101
338
  "群消息可以 @指定成员 或 @全体成员。" +
102
- "当需要主动发送通知、提醒或消息时使用此工具。",
339
+ "当需要主动发送通知、提醒、消息或图片时使用此工具。",
103
340
  parameters: InfoflowSendSchema,
104
341
  async execute(_toolCallId, rawParams) {
105
342
  const params = rawParams as InfoflowSendParams;
106
- const { to: rawTo, message, atAll, mentionUserIds, accountId } = params;
343
+ const { to: rawTo, message, atAll, mentionUserIds, accountId, imageUrl } = params;
107
344
 
108
345
  // 标准化目标格式(去掉可能多余的前缀)
109
346
  const to = normalizeInfoflowTarget(rawTo) ?? rawTo;
110
347
  const isGroup = /^group:\d+$/i.test(to);
111
348
 
112
- logVerbose(`[infoflow_send] to=${to}, isGroup=${isGroup}`);
349
+ logVerbose(`[infoflow_send] to=${to}, isGroup=${isGroup}, imageUrl=${imageUrl ?? "none"}`);
113
350
 
114
- const contents: Array<{ type: string; content: string }> = [];
351
+ const contents: InfoflowMessageContentItem[] = [];
115
352
 
116
353
  // 群消息:先拼 @mention 块,再拼正文
117
354
  if (isGroup) {
@@ -128,13 +365,80 @@ export function registerInfoflowTools(api: OpenClawPluginApi): void {
128
365
  }
129
366
  }
130
367
 
131
- if (message.trim()) {
368
+ if (message?.trim()) {
132
369
  contents.push({ type: "markdown", content: message });
133
370
  }
134
371
 
372
+ // 有图片时:先发文字(如有),再发图片
373
+ if (imageUrl?.trim()) {
374
+ // 先发文字+@
375
+ if (contents.length > 0) {
376
+ await sendInfoflowMessage({
377
+ cfg: api.config!,
378
+ to,
379
+ contents,
380
+ accountId: accountId ?? undefined,
381
+ });
382
+ }
383
+
384
+ // 尝试原生图片发送,失败则降级为链接
385
+ try {
386
+ const localRoots = isLikelyLocalPath(imageUrl) ? [imageUrl] : undefined;
387
+ const prepared = await prepareInfoflowImageBase64({
388
+ mediaUrl: imageUrl,
389
+ mediaLocalRoots: localRoots,
390
+ });
391
+ if (prepared.isImage) {
392
+ const imgResult = await sendInfoflowImageMessage({
393
+ cfg: api.config!,
394
+ to,
395
+ base64Image: prepared.base64,
396
+ accountId: accountId ?? undefined,
397
+ });
398
+ const payload = {
399
+ ok: imgResult.ok,
400
+ channel: "infoflow",
401
+ to,
402
+ ...(imgResult.messageId ? { messageId: imgResult.messageId } : {}),
403
+ ...(imgResult.error ? { error: imgResult.error } : {}),
404
+ };
405
+ return {
406
+ content: [{ type: "text" as const, text: JSON.stringify(payload) }],
407
+ details: payload,
408
+ };
409
+ }
410
+ } catch (e) {
411
+ logVerbose(`[infoflow_send] prepareInfoflowImageBase64 error: ${e}`);
412
+ }
413
+
414
+ // 降级:发链接
415
+ const linkResult = await sendInfoflowMessage({
416
+ cfg: api.config!,
417
+ to,
418
+ contents: [{ type: "link", content: imageUrl }],
419
+ accountId: accountId ?? undefined,
420
+ });
421
+ const payload = {
422
+ ok: linkResult.ok,
423
+ channel: "infoflow",
424
+ to,
425
+ ...(linkResult.messageId ? { messageId: linkResult.messageId } : {}),
426
+ ...(linkResult.error ? { error: linkResult.error } : {}),
427
+ };
428
+ return {
429
+ content: [{ type: "text" as const, text: JSON.stringify(payload) }],
430
+ details: payload,
431
+ };
432
+ }
433
+
135
434
  if (contents.length === 0) {
136
435
  return {
137
- content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "message is empty" }) }],
436
+ content: [
437
+ {
438
+ type: "text" as const,
439
+ text: JSON.stringify({ ok: false, error: "message is empty" }),
440
+ },
441
+ ],
138
442
  details: { ok: false, error: "message is empty" },
139
443
  };
140
444
  }
@@ -167,6 +471,7 @@ export function registerInfoflowTools(api: OpenClawPluginApi): void {
167
471
  api.registerTool(
168
472
  {
169
473
  name: "infoflow_recall",
474
+ label: "Infoflow Recall",
170
475
  description:
171
476
  "撤回机器人在如流(Infoflow)发送的消息。" +
172
477
  "可按消息 ID 撤回指定消息,也可按数量撤回最近 N 条(默认 1 条)。" +
@@ -180,7 +485,10 @@ export function registerInfoflowTools(api: OpenClawPluginApi): void {
180
485
  const target = to.replace(/^infoflow:/i, "");
181
486
 
182
487
  // 校验账号配置
183
- const account = resolveInfoflowAccount({ cfg: api.config!, accountId: accountId ?? undefined });
488
+ const account = resolveInfoflowAccount({
489
+ cfg: api.config!,
490
+ accountId: accountId ?? undefined,
491
+ });
184
492
  if (!account.config.appKey || !account.config.appSecret) {
185
493
  const err = { ok: false, error: "Infoflow appKey/appSecret not configured." };
186
494
  return {
@@ -203,35 +511,67 @@ export function registerInfoflowTools(api: OpenClawPluginApi): void {
203
511
  // 模式 A:按 messageId 撤回单条
204
512
  if (messageId) {
205
513
  // 群消息撤回需要同时提供 messageid + msgseqid,从本地消息记录中查找 msgseqid
206
- const stored = querySentMessages(account.accountId, { target: `group:${groupId}`, count: 1 })
207
- .find((r) => r.messageid === messageId);
514
+ const stored = querySentMessages(account.accountId, {
515
+ target: `group:${groupId}`,
516
+ count: 1,
517
+ }).find((r) => r.messageid === messageId);
208
518
  const msgseqid = stored?.msgseqid ?? "";
209
519
  if (!msgseqid) {
210
- return json({ ok: false, error: "msgseqid not found for this messageId. It may have already been recalled or not found in the message store." });
520
+ return json({
521
+ ok: false,
522
+ error:
523
+ "msgseqid not found for this messageId. It may have already been recalled or not found in the message store.",
524
+ });
211
525
  }
212
- const result = await recallInfoflowGroupMessage({ account, groupId, messageid: messageId, msgseqid });
526
+ const result = await recallInfoflowGroupMessage({
527
+ account,
528
+ groupId,
529
+ messageid: messageId,
530
+ msgseqid,
531
+ });
213
532
  if (result.ok) {
214
- try { removeRecalledMessages(account.accountId, [messageId]); } catch { /* ignore */ }
533
+ try {
534
+ removeRecalledMessages(account.accountId, [messageId]);
535
+ } catch {
536
+ /* ignore */
537
+ }
215
538
  }
216
539
  return json({ ok: result.ok, to, ...(result.error ? { error: result.error } : {}) });
217
540
  }
218
541
 
219
542
  // 模式 B:按 count 批量撤回最近 N 条
220
- const records = querySentMessages(account.accountId, { target: `group:${groupId}`, count })
221
- .filter((r) => r.msgseqid); // 只撤回有 msgseqid 的记录
543
+ const records = querySentMessages(account.accountId, {
544
+ target: `group:${groupId}`,
545
+ count,
546
+ }).filter((r) => r.msgseqid); // 只撤回有 msgseqid 的记录
222
547
 
223
548
  if (records.length === 0) {
224
549
  return json({ ok: true, to, recalled: 0, message: "No recallable messages found." });
225
550
  }
226
551
 
227
- let succeeded = 0, failed = 0;
552
+ let succeeded = 0,
553
+ failed = 0;
228
554
  const recalledIds: string[] = [];
229
555
  for (const r of records) {
230
- const res = await recallInfoflowGroupMessage({ account, groupId, messageid: r.messageid, msgseqid: r.msgseqid });
231
- if (res.ok) { succeeded++; recalledIds.push(r.messageid); } else { failed++; }
556
+ const res = await recallInfoflowGroupMessage({
557
+ account,
558
+ groupId,
559
+ messageid: r.messageid,
560
+ msgseqid: r.msgseqid,
561
+ });
562
+ if (res.ok) {
563
+ succeeded++;
564
+ recalledIds.push(r.messageid);
565
+ } else {
566
+ failed++;
567
+ }
232
568
  }
233
569
  if (recalledIds.length > 0) {
234
- try { removeRecalledMessages(account.accountId, recalledIds); } catch { /* ignore */ }
570
+ try {
571
+ removeRecalledMessages(account.accountId, recalledIds);
572
+ } catch {
573
+ /* ignore */
574
+ }
235
575
  }
236
576
  return json({ ok: failed === 0, to, recalled: succeeded, failed, total: records.length });
237
577
  }
@@ -240,32 +580,59 @@ export function registerInfoflowTools(api: OpenClawPluginApi): void {
240
580
  // 私聊撤回需要 appAgentId(企业后台的应用 ID)
241
581
  const appAgentId = account.config.appAgentId;
242
582
  if (!appAgentId) {
243
- return json({ ok: false, error: "Private message recall requires appAgentId configuration." });
583
+ return json({
584
+ ok: false,
585
+ error: "Private message recall requires appAgentId configuration.",
586
+ });
244
587
  }
245
588
 
246
589
  // 模式 A:按 messageId(即 msgkey)撤回单条
247
590
  if (messageId) {
248
- const result = await recallInfoflowPrivateMessage({ account, msgkey: messageId, appAgentId });
591
+ const result = await recallInfoflowPrivateMessage({
592
+ account,
593
+ msgkey: messageId,
594
+ appAgentId,
595
+ });
249
596
  if (result.ok) {
250
- try { removeRecalledMessages(account.accountId, [messageId]); } catch { /* ignore */ }
597
+ try {
598
+ removeRecalledMessages(account.accountId, [messageId]);
599
+ } catch {
600
+ /* ignore */
601
+ }
251
602
  }
252
603
  return json({ ok: result.ok, to, ...(result.error ? { error: result.error } : {}) });
253
604
  }
254
605
 
255
606
  // 模式 B:批量撤回最近 N 条
256
- const records = querySentMessages(account.accountId, { target, count }).filter((r) => r.messageid);
607
+ const records = querySentMessages(account.accountId, { target, count }).filter(
608
+ (r) => r.messageid,
609
+ );
257
610
  if (records.length === 0) {
258
611
  return json({ ok: true, to, recalled: 0, message: "No recallable messages found." });
259
612
  }
260
613
 
261
- let succeeded = 0, failed = 0;
614
+ let succeeded = 0,
615
+ failed = 0;
262
616
  const recalledIds: string[] = [];
263
617
  for (const r of records) {
264
- const res = await recallInfoflowPrivateMessage({ account, msgkey: r.messageid, appAgentId });
265
- if (res.ok) { succeeded++; recalledIds.push(r.messageid); } else { failed++; }
618
+ const res = await recallInfoflowPrivateMessage({
619
+ account,
620
+ msgkey: r.messageid,
621
+ appAgentId,
622
+ });
623
+ if (res.ok) {
624
+ succeeded++;
625
+ recalledIds.push(r.messageid);
626
+ } else {
627
+ failed++;
628
+ }
266
629
  }
267
630
  if (recalledIds.length > 0) {
268
- try { removeRecalledMessages(account.accountId, recalledIds); } catch { /* ignore */ }
631
+ try {
632
+ removeRecalledMessages(account.accountId, recalledIds);
633
+ } catch {
634
+ /* ignore */
635
+ }
269
636
  }
270
637
  return json({ ok: failed === 0, to, recalled: succeeded, failed, total: records.length });
271
638
  },
@@ -273,5 +640,320 @@ export function registerInfoflowTools(api: OpenClawPluginApi): void {
273
640
  { name: "infoflow_recall" },
274
641
  );
275
642
 
276
- api.logger.info?.("infoflow_tools: Registered infoflow_send and infoflow_recall tools");
643
+ api.registerTool(
644
+ (ctx) => {
645
+ if (ctx.messageChannel !== "infoflow") {
646
+ return null;
647
+ }
648
+
649
+ return {
650
+ name: "infoflow_cron",
651
+ label: "Infoflow Cron",
652
+ description:
653
+ "在当前如流会话里创建定时消息。目标会从可信的 FromUserId / groupId 上下文自动绑定,不允许模型自行猜测收件人。",
654
+ parameters: InfoflowCronSchema,
655
+ async execute(_toolCallId, rawParams) {
656
+ const params = rawParams as InfoflowCronParams;
657
+ const scheduleCount = [params.at, params.every, params.cron].filter(
658
+ (value) => typeof value === "string" && value.trim(),
659
+ ).length;
660
+ if (scheduleCount !== 1) {
661
+ return json({
662
+ ok: false,
663
+ error: "Exactly one of at / every / cron must be provided.",
664
+ });
665
+ }
666
+
667
+ const message = params.message.trim();
668
+ const name = params.name.trim();
669
+ if (!name || !message) {
670
+ return json({
671
+ ok: false,
672
+ error: "name and message are required.",
673
+ });
674
+ }
675
+
676
+ const targetContext = await resolveInfoflowCronTargetForTool(api, ctx);
677
+ if (!targetContext) {
678
+ return json({
679
+ ok: false,
680
+ error:
681
+ "Unable to resolve the current Infoflow target from trusted session context. In private chat this requires FromUserId; in group chat this requires current groupId/session routing.",
682
+ });
683
+ }
684
+ const isGroupTarget = /^group:\d+$/i.test(targetContext.target);
685
+ const mentionUserIds = isGroupTarget
686
+ ? (params.mentionUserIds ?? "")
687
+ .split(",")
688
+ .map((value) => value.trim())
689
+ .filter(Boolean)
690
+ : [];
691
+
692
+ const metadata: InfoflowCronJobMetadata = {
693
+ version: 1,
694
+ mode: "direct",
695
+ target: targetContext.target,
696
+ creatorSenderId: targetContext.creatorSenderId,
697
+ channel: "infoflow",
698
+ source: "infoflow_cron",
699
+ atAll: isGroupTarget ? params.atAll === true : undefined,
700
+ mentionUserIds:
701
+ isGroupTarget && params.atAll !== true && mentionUserIds.length > 0
702
+ ? mentionUserIds
703
+ : undefined,
704
+ };
705
+ const description = encodeInfoflowCronJobDescription({
706
+ metadata,
707
+ userDescription: params.description,
708
+ });
709
+ const sessionKey = buildInfoflowCronSessionKey();
710
+
711
+ const args = [
712
+ "cron",
713
+ "add",
714
+ "--json",
715
+ "--name",
716
+ name,
717
+ "--session",
718
+ "main",
719
+ "--session-key",
720
+ sessionKey,
721
+ "--wake",
722
+ "now",
723
+ "--system-event",
724
+ message,
725
+ "--description",
726
+ description,
727
+ ];
728
+
729
+ if (params.at?.trim()) {
730
+ args.push("--at", params.at.trim());
731
+ if (params.keepAfterRun) {
732
+ args.push("--keep-after-run");
733
+ } else {
734
+ args.push("--delete-after-run");
735
+ }
736
+ }
737
+ if (params.every?.trim()) {
738
+ args.push("--every", params.every.trim());
739
+ }
740
+ if (params.cron?.trim()) {
741
+ args.push("--cron", params.cron.trim());
742
+ }
743
+ if (params.tz?.trim()) {
744
+ args.push("--tz", params.tz.trim());
745
+ }
746
+
747
+ const result = await runOpenClaw(args, ctx.workspaceDir);
748
+ const parsed = parseJsonFromMixedOutput(`${result.stdout}\n${result.stderr}`);
749
+ const parsedObject = toObject(parsed);
750
+ const jobObject = toObject(parsedObject?.job);
751
+ const jobId = toTrimmedString(parsedObject?.id) ?? toTrimmedString(jobObject?.id);
752
+ const account = resolveInfoflowAccount({ cfg: api.config! });
753
+ if (result.exitCode !== 0) {
754
+ return json({
755
+ ok: false,
756
+ error: result.stderr.trim() || result.stdout.trim() || "openclaw cron add failed",
757
+ target: targetContext.target,
758
+ });
759
+ }
760
+
761
+ return json({
762
+ ok: true,
763
+ target: targetContext.target,
764
+ creatorSenderId: targetContext.creatorSenderId,
765
+ observe: {
766
+ channel: "infoflow",
767
+ jobId,
768
+ sessionKey,
769
+ jobsPath: "~/.openclaw/cron/jobs.json",
770
+ runsPath: jobId
771
+ ? `~/.openclaw/cron/runs/${jobId}.jsonl`
772
+ : "~/.openclaw/cron/runs/<jobId>.jsonl",
773
+ relayStatusPath: `<OpenClaw stateDir>/${account.config.privateDataDir}/cron-relay-status.md`,
774
+ relayStatePath: `<OpenClaw stateDir>/${account.config.privateDataDir}/cron-relay-state.json`,
775
+ },
776
+ job: parsed ?? result.stdout.trim(),
777
+ });
778
+ },
779
+ };
780
+ },
781
+ { name: "infoflow_cron" },
782
+ );
783
+
784
+ // ---- infoflow_bos_upload:上传文件到 BOS 并返回下载链接 ----
785
+
786
+ const InfoflowBosUploadSchema = Type.Object({
787
+ content: Type.String({
788
+ description: "要上传的文件内容(文本)。对于纯文本文件直接传入内容;对于代码文件传入源代码。",
789
+ }),
790
+ fileName: Type.String({
791
+ description: '文件名(含扩展名),例如 "report.md"、"data.csv"、"script.py"。',
792
+ }),
793
+ objectKey: Type.Optional(
794
+ Type.String({
795
+ description:
796
+ '可选的完整存储路径(目录 + 文件名 + 扩展名),例如 "reports/2026/Q1.md"、"exports/data.csv",系统会自动添加 openclaw/ 前缀。不填则自动生成唯一路径(格式:openclaw/uploads/{yyyyMMdd}/{uuid}-{fileName}),避免不同用户上传同名文件时互相覆盖。仅在需要固定路径或主动覆盖更新时才填此字段。',
797
+ }),
798
+ ),
799
+ accountId: Type.Optional(
800
+ Type.String({
801
+ description: "多账号模式下指定账号 ID,不填则使用默认账号。",
802
+ }),
803
+ ),
804
+ });
805
+
806
+ type InfoflowBosUploadParams = Static<typeof InfoflowBosUploadSchema>;
807
+
808
+ api.registerTool(
809
+ {
810
+ name: "infoflow_bos_upload",
811
+ label: "Infoflow BOS Upload",
812
+ description:
813
+ "将生成的文件内容(文本、代码、CSV、Markdown 等)上传到 BOS 云存储,并自动返回带有效期的预签名下载链接。" +
814
+ "当用户请求生成文件、导出数据、保存代码等场景时使用此工具。" +
815
+ "上传成功后会同时返回 objectKey 和可直接点击的下载 URL。",
816
+ parameters: InfoflowBosUploadSchema,
817
+ async execute(_toolCallId, rawParams) {
818
+ const params = rawParams as InfoflowBosUploadParams;
819
+ const { content, fileName, objectKey, accountId } = params;
820
+
821
+ if (!content?.trim()) {
822
+ return json({ ok: false, error: "content is empty" });
823
+ }
824
+ if (!fileName?.trim()) {
825
+ return json({ ok: false, error: "fileName is required" });
826
+ }
827
+
828
+ const account = resolveInfoflowAccount({
829
+ cfg: api.config!,
830
+ accountId: accountId ?? undefined,
831
+ });
832
+ const { apiHost, appKey, appSecret } = account.config;
833
+ if (!appKey || !appSecret) {
834
+ return json({ ok: false, error: "Infoflow appKey/appSecret not configured." });
835
+ }
836
+
837
+ // 若调用方未指定 objectKey,则自动生成唯一路径,防止不同用户/会话上传同名文件时互相覆盖。
838
+ // 格式:openclaw/uploads/{yyyyMMdd}/{uuid}-{fileName}
839
+ const resolvedObjectKey = objectKey?.trim()
840
+ ? `openclaw/${objectKey.trim()}`
841
+ : (() => {
842
+ const today = new Date().toISOString().slice(0, 10).replace(/-/g, "");
843
+ return `openclaw/uploads/${today}/${randomUUID()}-${fileName.trim()}`;
844
+ })();
845
+
846
+ logVerbose(
847
+ `[infoflow_bos_upload] fileName=${fileName}, objectKey=${resolvedObjectKey}, contentLen=${content.length}`,
848
+ );
849
+
850
+ // 上传文件
851
+ const fileBuffer = Buffer.from(content, "utf-8");
852
+ const uploadResult = await imBosUpload({
853
+ apiHost,
854
+ appKey,
855
+ appSecret,
856
+ fileContent: fileBuffer,
857
+ fileName: fileName.trim(),
858
+ objectKey: resolvedObjectKey,
859
+ });
860
+
861
+ if (!uploadResult.ok) {
862
+ return json({ ok: false, error: uploadResult.error ?? "upload failed" });
863
+ }
864
+
865
+ // 上传成功后自动获取预签名下载 URL
866
+ const resultObjectKey = uploadResult.objectKey ?? resolvedObjectKey;
867
+ const urlResult = await imBosGetUrl({
868
+ apiHost,
869
+ appKey,
870
+ appSecret,
871
+ objectKey: resultObjectKey,
872
+ });
873
+
874
+ return json({
875
+ ok: true,
876
+ objectKey: resultObjectKey,
877
+ eTag: uploadResult.eTag,
878
+ downloadUrl: urlResult.ok ? urlResult.url : undefined,
879
+ expirationSeconds: urlResult.ok ? urlResult.expirationSeconds : undefined,
880
+ ...(urlResult.ok ? {} : { urlError: urlResult.error }),
881
+ });
882
+ },
883
+ },
884
+ { name: "infoflow_bos_upload" },
885
+ );
886
+
887
+ // ---- infoflow_bos_get_url:获取/刷新 BOS 文件预签名下载 URL ----
888
+
889
+ const InfoflowBosGetUrlSchema = Type.Object({
890
+ objectKey: Type.String({
891
+ description: "BOS 对象的存储路径(object_key),由上传时返回。",
892
+ }),
893
+ expirationSeconds: Type.Optional(
894
+ Type.Number({
895
+ description: "预签名 URL 的有效期(秒),默认 3600(1 小时),最大 86400(24 小时)。",
896
+ }),
897
+ ),
898
+ accountId: Type.Optional(
899
+ Type.String({
900
+ description: "多账号模式下指定账号 ID,不填则使用默认账号。",
901
+ }),
902
+ ),
903
+ });
904
+
905
+ type InfoflowBosGetUrlParams = Static<typeof InfoflowBosGetUrlSchema>;
906
+
907
+ api.registerTool(
908
+ {
909
+ name: "infoflow_bos_get_url",
910
+ label: "Infoflow BOS Get URL",
911
+ description:
912
+ "为已存在的 BOS 文件获取或刷新预签名下载 URL。" +
913
+ "当用户需要重新获取之前上传文件的下载链接(例如链接已过期)时使用此工具。" +
914
+ "需要提供上传时返回的 objectKey。",
915
+ parameters: InfoflowBosGetUrlSchema,
916
+ async execute(_toolCallId, rawParams) {
917
+ const params = rawParams as InfoflowBosGetUrlParams;
918
+ const { objectKey, expirationSeconds, accountId } = params;
919
+
920
+ if (!objectKey?.trim()) {
921
+ return json({ ok: false, error: "objectKey is required" });
922
+ }
923
+
924
+ const account = resolveInfoflowAccount({
925
+ cfg: api.config!,
926
+ accountId: accountId ?? undefined,
927
+ });
928
+ const { apiHost, appKey, appSecret } = account.config;
929
+ if (!appKey || !appSecret) {
930
+ return json({ ok: false, error: "Infoflow appKey/appSecret not configured." });
931
+ }
932
+
933
+ logVerbose(
934
+ `[infoflow_bos_get_url] objectKey=${objectKey}, expirationSeconds=${expirationSeconds ?? 3600}`,
935
+ );
936
+
937
+ const urlResult = await imBosGetUrl({
938
+ apiHost,
939
+ appKey,
940
+ appSecret,
941
+ objectKey: objectKey.trim(),
942
+ expirationSeconds: expirationSeconds ?? undefined,
943
+ });
944
+
945
+ return json({
946
+ ok: urlResult.ok,
947
+ url: urlResult.url,
948
+ expirationSeconds: urlResult.expirationSeconds,
949
+ ...(urlResult.error ? { error: urlResult.error } : {}),
950
+ });
951
+ },
952
+ },
953
+ { name: "infoflow_bos_get_url" },
954
+ );
955
+
956
+ api.logger.info?.(
957
+ "infoflow_tools: Registered infoflow_send, infoflow_recall, infoflow_cron, infoflow_bos_upload and infoflow_bos_get_url tools",
958
+ );
277
959
  }