@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.
- package/CHANGELOG.md +91 -0
- package/CLAUDE.md +135 -0
- package/COLLABORATION_REPORT.md +209 -0
- package/PROJECT_GUIDE.md +355 -0
- package/README.md +158 -66
- package/docs/dev-guide.md +63 -50
- package/docs/qa-feature-list.md +452 -0
- package/docs/webhook-guide.md +178 -0
- package/index.ts +28 -2
- package/openclaw.plugin.json +131 -21
- package/package.json +16 -3
- package/scripts/deploy.sh +66 -7
- package/scripts/postinstall.cjs +80 -0
- package/skills/infoflow-dev/SKILL.md +2 -2
- package/skills/infoflow-dev/references/api.md +1 -1
- package/src/adapter/inbound/webhook-parser.ts +27 -5
- package/src/adapter/inbound/ws-receiver.ts +304 -43
- package/src/adapter/outbound/markdown-local-images.ts +80 -0
- package/src/adapter/outbound/reply-dispatcher.ts +146 -65
- package/src/adapter/outbound/target-resolver.ts +4 -3
- package/src/channel/accounts.ts +97 -22
- package/src/channel/channel.ts +456 -12
- package/src/channel/media.ts +20 -6
- package/src/channel/monitor.ts +8 -3
- package/src/channel/outbound.ts +358 -21
- package/src/channel/streaming.ts +740 -0
- package/src/commands/changelog.ts +80 -0
- package/src/commands/doctor.ts +545 -0
- package/src/commands/logs.ts +449 -0
- package/src/commands/version.ts +20 -0
- package/src/compat/openclaw-sdk.ts +218 -0
- package/src/handler/message-handler.ts +673 -166
- package/src/logging.ts +1 -1
- package/src/runtime.ts +1 -1
- package/src/security/dm-policy.ts +1 -4
- package/src/security/group-policy.ts +174 -51
- package/src/tools/actions/index.ts +15 -13
- package/src/tools/cron/relay.ts +1154 -0
- package/src/tools/hooks/index.ts +13 -1
- package/src/tools/index.ts +714 -32
- package/src/types.ts +144 -25
- package/src/utils/audio/g722/dct_tables.ts +381 -0
- package/src/utils/audio/g722/decoder.ts +919 -0
- package/src/utils/audio/g722/defs.ts +105 -0
- package/src/utils/audio/g722/hd-parser.ts +247 -0
- package/src/utils/audio/g722/huff_tables.ts +240 -0
- package/src/utils/audio/g722/index.ts +78 -0
- package/src/utils/audio/g722/output_decoded.pcm +0 -0
- package/src/utils/audio/g722/output_decoded.wav +0 -0
- package/src/utils/audio/g722/tables.ts +173 -0
- package/src/utils/audio/g722/test_api.ts +31 -0
- package/src/utils/audio/g722/test_voice.hd +0 -0
- package/src/utils/bos/im-bos-client.ts +219 -0
- package/src/utils/group-agent-cache.ts +142 -0
- package/src/utils/token-adapter.ts +120 -51
|
@@ -0,0 +1,1154 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import type { OpenClawConfig, OpenClawPluginService } from "openclaw/plugin-sdk/plugin-entry";
|
|
5
|
+
import { normalizeInfoflowTarget } from "../../adapter/outbound/target-resolver.js";
|
|
6
|
+
import { resolveDefaultInfoflowAccountId, resolveInfoflowAccount } from "../../channel/accounts.js";
|
|
7
|
+
import { sendInfoflowMessage } from "../../channel/outbound.js";
|
|
8
|
+
import type { InfoflowMessageContentItem } from "../../types.js";
|
|
9
|
+
|
|
10
|
+
type CronRunStatus = "ok" | "error" | "skipped";
|
|
11
|
+
|
|
12
|
+
type CronRunLogEntry = {
|
|
13
|
+
ts: number;
|
|
14
|
+
jobId: string;
|
|
15
|
+
action: "finished";
|
|
16
|
+
status?: CronRunStatus;
|
|
17
|
+
error?: string;
|
|
18
|
+
summary?: string;
|
|
19
|
+
sessionId?: string;
|
|
20
|
+
sessionKey?: string;
|
|
21
|
+
runAtMs?: number;
|
|
22
|
+
durationMs?: number;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type CronJobLike = {
|
|
26
|
+
id: string;
|
|
27
|
+
agentId?: string;
|
|
28
|
+
name?: string;
|
|
29
|
+
description?: string;
|
|
30
|
+
sessionKey?: string;
|
|
31
|
+
payload?: {
|
|
32
|
+
kind?: string;
|
|
33
|
+
deliver?: boolean;
|
|
34
|
+
channel?: string;
|
|
35
|
+
to?: string;
|
|
36
|
+
};
|
|
37
|
+
delivery?: {
|
|
38
|
+
mode?: string;
|
|
39
|
+
channel?: string;
|
|
40
|
+
to?: string;
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type RelayStateFile = {
|
|
45
|
+
version: 1;
|
|
46
|
+
files: Record<string, number>;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type RelayTargetSource = "metadata" | "payload" | "delivery" | "session" | "none";
|
|
50
|
+
|
|
51
|
+
type RelayStatus = "idle" | "pending" | "relayed" | "skipped";
|
|
52
|
+
|
|
53
|
+
type RelayStatusJobEntry = {
|
|
54
|
+
jobId: string;
|
|
55
|
+
jobName: string;
|
|
56
|
+
channel: "infoflow";
|
|
57
|
+
target?: string;
|
|
58
|
+
targetSource: RelayTargetSource;
|
|
59
|
+
creatorSenderId?: string;
|
|
60
|
+
lastRun?: {
|
|
61
|
+
fileName: string;
|
|
62
|
+
line: number;
|
|
63
|
+
status?: CronRunStatus;
|
|
64
|
+
relayStatus: RelayStatus;
|
|
65
|
+
relayReason?: string;
|
|
66
|
+
time: string;
|
|
67
|
+
durationMs?: number;
|
|
68
|
+
summary?: string;
|
|
69
|
+
error?: string;
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
type RelayStatusRecentRun = {
|
|
74
|
+
jobId: string;
|
|
75
|
+
jobName: string;
|
|
76
|
+
target?: string;
|
|
77
|
+
targetSource: RelayTargetSource;
|
|
78
|
+
status?: CronRunStatus;
|
|
79
|
+
relayStatus: RelayStatus;
|
|
80
|
+
relayReason?: string;
|
|
81
|
+
time: string;
|
|
82
|
+
durationMs?: number;
|
|
83
|
+
summary?: string;
|
|
84
|
+
error?: string;
|
|
85
|
+
fileName: string;
|
|
86
|
+
line: number;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
type RelayStatusFile = {
|
|
90
|
+
version: 1;
|
|
91
|
+
updatedAt: string;
|
|
92
|
+
runsDir: string;
|
|
93
|
+
jobsPath: string;
|
|
94
|
+
statePath: string;
|
|
95
|
+
jobs: RelayStatusJobEntry[];
|
|
96
|
+
recentRuns: RelayStatusRecentRun[];
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export type InfoflowCronJobMetadata = {
|
|
100
|
+
version: 1;
|
|
101
|
+
mode: "direct";
|
|
102
|
+
target: string;
|
|
103
|
+
creatorSenderId?: string;
|
|
104
|
+
channel?: "infoflow";
|
|
105
|
+
source?: "infoflow_cron";
|
|
106
|
+
atAll?: boolean;
|
|
107
|
+
mentionUserIds?: string[];
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const STATE_VERSION = 1;
|
|
111
|
+
const RELAY_STATUS_VERSION = 1;
|
|
112
|
+
const MIN_POLL_INTERVAL_MS = 500;
|
|
113
|
+
const DEFAULT_POLL_INTERVAL_MS = 2000;
|
|
114
|
+
const MAX_RECENT_RUNS = 50;
|
|
115
|
+
const INFOFLOW_CHANNEL_ALIASES = new Set(["infoflow", "ruliu", "bdim"]);
|
|
116
|
+
const INFOFLOW_CRON_METADATA_PREFIX = "infoflow-cron-meta:";
|
|
117
|
+
const INFOFLOW_CRON_VISIBLE_MARKER = "[infoflow_cron]";
|
|
118
|
+
|
|
119
|
+
function toObject(value: unknown): Record<string, unknown> | null {
|
|
120
|
+
if (!value || typeof value !== "object") {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
return value as Record<string, unknown>;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function toNonNegativeInt(value: unknown): number | null {
|
|
127
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
return value;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function toStringOrUndefined(value: unknown): string | undefined {
|
|
134
|
+
return typeof value === "string" ? value : undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function safeJsonParse(raw: string): unknown {
|
|
138
|
+
try {
|
|
139
|
+
return JSON.parse(raw);
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function encodeBase64Url(raw: string): string {
|
|
146
|
+
return Buffer.from(raw, "utf8").toString("base64url");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function decodeBase64Url(raw: string): string | null {
|
|
150
|
+
try {
|
|
151
|
+
return Buffer.from(raw, "base64url").toString("utf8");
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function splitJsonlLines(raw: string): string[] {
|
|
158
|
+
if (!raw) {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
const lines = raw.split(/\r?\n/);
|
|
162
|
+
if (!raw.endsWith("\n") && !raw.endsWith("\r\n")) {
|
|
163
|
+
lines.pop();
|
|
164
|
+
}
|
|
165
|
+
return lines.map((line) => line.trim()).filter(Boolean);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function truncateText(value: string | undefined, limit = 240): string | undefined {
|
|
169
|
+
const trimmed = value?.trim();
|
|
170
|
+
if (!trimmed) {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
if (trimmed.length <= limit) {
|
|
174
|
+
return trimmed;
|
|
175
|
+
}
|
|
176
|
+
return `${trimmed.slice(0, Math.max(0, limit - 1)).trimEnd()}…`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function buildVisibleDescriptionLines(metadata: InfoflowCronJobMetadata): string[] {
|
|
180
|
+
const lines = [
|
|
181
|
+
INFOFLOW_CRON_VISIBLE_MARKER,
|
|
182
|
+
"channel=infoflow",
|
|
183
|
+
"source=infoflow_cron",
|
|
184
|
+
`target=${metadata.target}`,
|
|
185
|
+
];
|
|
186
|
+
const creatorSenderId = metadata.creatorSenderId?.trim();
|
|
187
|
+
if (creatorSenderId) {
|
|
188
|
+
lines.push(`creatorSenderId=${creatorSenderId}`);
|
|
189
|
+
}
|
|
190
|
+
if (metadata.atAll) {
|
|
191
|
+
lines.push("atAll=true");
|
|
192
|
+
} else if (metadata.mentionUserIds?.length) {
|
|
193
|
+
lines.push(`mentionUserIds=${metadata.mentionUserIds.join(",")}`);
|
|
194
|
+
}
|
|
195
|
+
return lines;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function encodeInfoflowCronJobDescription(params: {
|
|
199
|
+
metadata: InfoflowCronJobMetadata;
|
|
200
|
+
userDescription?: string;
|
|
201
|
+
}): string {
|
|
202
|
+
const metaLine = `${INFOFLOW_CRON_METADATA_PREFIX}${encodeBase64Url(
|
|
203
|
+
JSON.stringify(params.metadata),
|
|
204
|
+
)}`;
|
|
205
|
+
const extra = params.userDescription?.trim();
|
|
206
|
+
const lines = [metaLine, ...buildVisibleDescriptionLines(params.metadata)];
|
|
207
|
+
if (extra) {
|
|
208
|
+
lines.push("", extra);
|
|
209
|
+
}
|
|
210
|
+
return lines.join("\n");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function parseInfoflowCronJobMetadata(
|
|
214
|
+
description: string | undefined,
|
|
215
|
+
): InfoflowCronJobMetadata | null {
|
|
216
|
+
const lines =
|
|
217
|
+
description
|
|
218
|
+
?.split(/\r?\n/)
|
|
219
|
+
.map((line) => line.trim())
|
|
220
|
+
.filter(Boolean) ?? [];
|
|
221
|
+
const metaLine = lines.find((line) => line.startsWith(INFOFLOW_CRON_METADATA_PREFIX));
|
|
222
|
+
if (!metaLine) {
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
const encoded = metaLine.slice(INFOFLOW_CRON_METADATA_PREFIX.length).trim();
|
|
226
|
+
if (!encoded) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
const decoded = decodeBase64Url(encoded);
|
|
230
|
+
if (!decoded) {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
const parsed = toObject(safeJsonParse(decoded));
|
|
234
|
+
const version = toNonNegativeInt(parsed?.version);
|
|
235
|
+
const mode = toStringOrUndefined(parsed?.mode);
|
|
236
|
+
const target = normalizeInfoflowTarget(toStringOrUndefined(parsed?.target) ?? "");
|
|
237
|
+
if (version !== 1 || mode !== "direct" || !target) {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
version: 1,
|
|
242
|
+
mode: "direct",
|
|
243
|
+
target,
|
|
244
|
+
creatorSenderId: toStringOrUndefined(parsed?.creatorSenderId)?.trim() || undefined,
|
|
245
|
+
atAll: parsed?.atAll === true ? true : undefined,
|
|
246
|
+
mentionUserIds: Array.isArray(parsed?.mentionUserIds)
|
|
247
|
+
? parsed.mentionUserIds
|
|
248
|
+
.filter((value): value is string => typeof value === "string")
|
|
249
|
+
.map((value) => value.trim())
|
|
250
|
+
.filter(Boolean)
|
|
251
|
+
: undefined,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function parseCronRunLine(line: string): CronRunLogEntry | null {
|
|
256
|
+
const parsed = toObject(safeJsonParse(line));
|
|
257
|
+
if (!parsed || parsed.action !== "finished") {
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
const ts = toNonNegativeInt(parsed.ts);
|
|
261
|
+
const jobId = toStringOrUndefined(parsed.jobId)?.trim();
|
|
262
|
+
if (ts === null || !jobId) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
const statusRaw = toStringOrUndefined(parsed.status);
|
|
266
|
+
const status =
|
|
267
|
+
statusRaw === "ok" || statusRaw === "error" || statusRaw === "skipped"
|
|
268
|
+
? (statusRaw as CronRunStatus)
|
|
269
|
+
: undefined;
|
|
270
|
+
return {
|
|
271
|
+
ts,
|
|
272
|
+
jobId,
|
|
273
|
+
action: "finished",
|
|
274
|
+
status,
|
|
275
|
+
error: toStringOrUndefined(parsed.error),
|
|
276
|
+
summary: toStringOrUndefined(parsed.summary),
|
|
277
|
+
sessionId: toStringOrUndefined(parsed.sessionId),
|
|
278
|
+
sessionKey: toStringOrUndefined(parsed.sessionKey),
|
|
279
|
+
runAtMs: toNonNegativeInt(parsed.runAtMs) ?? undefined,
|
|
280
|
+
durationMs: toNonNegativeInt(parsed.durationMs) ?? undefined,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function isInfoflowChannel(value: string | undefined): boolean {
|
|
285
|
+
if (!value) {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
return INFOFLOW_CHANNEL_ALIASES.has(value.trim().toLowerCase());
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export function cronJobAlreadyDeliveredToInfoflow(job: CronJobLike | undefined): boolean {
|
|
292
|
+
if (!job) {
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
if (
|
|
296
|
+
job.payload?.kind === "agentTurn" &&
|
|
297
|
+
job.payload?.deliver === true &&
|
|
298
|
+
isInfoflowChannel(job.payload.channel)
|
|
299
|
+
) {
|
|
300
|
+
return true;
|
|
301
|
+
}
|
|
302
|
+
if (job.delivery?.mode === "announce" && isInfoflowChannel(job.delivery.channel)) {
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function resolveCronRelayTargetInfo(
|
|
309
|
+
cfg: OpenClawConfig,
|
|
310
|
+
job: CronJobLike | undefined,
|
|
311
|
+
fallbackTarget?: string,
|
|
312
|
+
): { target?: string; source: RelayTargetSource } {
|
|
313
|
+
const directTarget = parseInfoflowCronJobMetadata(job?.description)?.target;
|
|
314
|
+
if (directTarget) {
|
|
315
|
+
return { target: directTarget, source: "metadata" };
|
|
316
|
+
}
|
|
317
|
+
const payloadTo = normalizeInfoflowTarget(job?.payload?.to ?? "");
|
|
318
|
+
if (payloadTo) {
|
|
319
|
+
return { target: payloadTo, source: "payload" };
|
|
320
|
+
}
|
|
321
|
+
const deliveryTo = normalizeInfoflowTarget(job?.delivery?.to ?? "");
|
|
322
|
+
if (deliveryTo) {
|
|
323
|
+
return { target: deliveryTo, source: "delivery" };
|
|
324
|
+
}
|
|
325
|
+
const fallback = normalizeInfoflowTarget(fallbackTarget ?? "");
|
|
326
|
+
if (fallback) {
|
|
327
|
+
return { target: fallback, source: "session" };
|
|
328
|
+
}
|
|
329
|
+
return { source: "none" };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function resolveCronRelayTarget(
|
|
333
|
+
cfg: OpenClawConfig,
|
|
334
|
+
job: CronJobLike | undefined,
|
|
335
|
+
fallbackTarget?: string,
|
|
336
|
+
): string | undefined {
|
|
337
|
+
return resolveCronRelayTargetInfo(cfg, job, fallbackTarget).target;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function formatStatus(status: CronRunStatus | undefined): string {
|
|
341
|
+
if (status === "ok") {
|
|
342
|
+
return "成功";
|
|
343
|
+
}
|
|
344
|
+
if (status === "error") {
|
|
345
|
+
return "失败";
|
|
346
|
+
}
|
|
347
|
+
if (status === "skipped") {
|
|
348
|
+
return "跳过";
|
|
349
|
+
}
|
|
350
|
+
return "未知";
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function formatTimestamp(ts: number): string {
|
|
354
|
+
return new Date(ts).toLocaleString("zh-CN", { hour12: false });
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function formatRelayStatus(status: RelayStatus): string {
|
|
358
|
+
if (status === "relayed") {
|
|
359
|
+
return "已转发";
|
|
360
|
+
}
|
|
361
|
+
if (status === "pending") {
|
|
362
|
+
return "待处理";
|
|
363
|
+
}
|
|
364
|
+
if (status === "skipped") {
|
|
365
|
+
return "已跳过";
|
|
366
|
+
}
|
|
367
|
+
return "未执行";
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export function buildCronRelayMessage(params: {
|
|
371
|
+
entry: CronRunLogEntry;
|
|
372
|
+
job?: CronJobLike;
|
|
373
|
+
prefix: string;
|
|
374
|
+
}): string {
|
|
375
|
+
const { entry, job, prefix } = params;
|
|
376
|
+
const jobLabel = job?.name?.trim() || entry.jobId;
|
|
377
|
+
const body = entry.summary?.trim() || entry.error?.trim() || "定时任务已执行。";
|
|
378
|
+
const metadata = parseInfoflowCronJobMetadata(job?.description);
|
|
379
|
+
if (metadata?.mode === "direct" && entry.status === "ok" && body) {
|
|
380
|
+
return body;
|
|
381
|
+
}
|
|
382
|
+
return [
|
|
383
|
+
`${prefix} ${jobLabel}`,
|
|
384
|
+
`状态: ${formatStatus(entry.status)}`,
|
|
385
|
+
`时间: ${formatTimestamp(entry.ts)}`,
|
|
386
|
+
`任务ID: ${entry.jobId}`,
|
|
387
|
+
"",
|
|
388
|
+
body,
|
|
389
|
+
].join("\n");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function buildCronRelayContents(params: {
|
|
393
|
+
entry: CronRunLogEntry;
|
|
394
|
+
job?: CronJobLike;
|
|
395
|
+
prefix: string;
|
|
396
|
+
target?: string;
|
|
397
|
+
}): InfoflowMessageContentItem[] {
|
|
398
|
+
const text = buildCronRelayMessage({
|
|
399
|
+
entry: params.entry,
|
|
400
|
+
job: params.job,
|
|
401
|
+
prefix: params.prefix,
|
|
402
|
+
});
|
|
403
|
+
const contents: InfoflowMessageContentItem[] = [];
|
|
404
|
+
const metadata = parseInfoflowCronJobMetadata(params.job?.description);
|
|
405
|
+
const isGroupTarget = /^group:\d+$/i.test(params.target ?? metadata?.target ?? "");
|
|
406
|
+
|
|
407
|
+
if (isGroupTarget) {
|
|
408
|
+
if (metadata?.atAll) {
|
|
409
|
+
contents.push({ type: "at", content: "all" });
|
|
410
|
+
} else if (metadata?.mentionUserIds?.length) {
|
|
411
|
+
contents.push({ type: "at", content: metadata.mentionUserIds.join(",") });
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
contents.push({ type: "markdown", content: text });
|
|
416
|
+
return contents;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function resolveCronRootDir(stateDir: string): string {
|
|
420
|
+
const root = stateDir.trim();
|
|
421
|
+
if (root) {
|
|
422
|
+
return root;
|
|
423
|
+
}
|
|
424
|
+
return path.join(os.homedir(), ".openclaw");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function markdownCell(value: string | undefined): string {
|
|
428
|
+
const normalized = value?.trim();
|
|
429
|
+
if (!normalized) {
|
|
430
|
+
return "-";
|
|
431
|
+
}
|
|
432
|
+
return normalized.replaceAll("|", "\\|").replaceAll("\n", "<br/>");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export function normalizeRelativeDataDir(input: string | undefined): string | null {
|
|
436
|
+
const raw = input?.trim();
|
|
437
|
+
if (!raw || path.isAbsolute(raw)) {
|
|
438
|
+
return null;
|
|
439
|
+
}
|
|
440
|
+
const normalized = path.posix.normalize(raw.replace(/\\/g, "/")).replace(/^\.\//, "").trim();
|
|
441
|
+
if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) {
|
|
442
|
+
return null;
|
|
443
|
+
}
|
|
444
|
+
return normalized;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function resolveCronRelayPrivateDataRoot(params: {
|
|
448
|
+
cfg: OpenClawConfig;
|
|
449
|
+
stateDir: string;
|
|
450
|
+
}): { rootDir: string; privateDataRoot: string; usedDefault: boolean } {
|
|
451
|
+
const rootDir = resolveCronRootDir(params.stateDir);
|
|
452
|
+
const account = resolveInfoflowAccount({ cfg: params.cfg });
|
|
453
|
+
const normalized = normalizeRelativeDataDir(account.config.privateDataDir);
|
|
454
|
+
if (!normalized) {
|
|
455
|
+
return {
|
|
456
|
+
rootDir,
|
|
457
|
+
privateDataRoot: path.join(rootDir, "plugins/infoflow-private"),
|
|
458
|
+
usedDefault: true,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
return {
|
|
462
|
+
rootDir,
|
|
463
|
+
privateDataRoot: path.join(rootDir, normalized),
|
|
464
|
+
usedDefault: false,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function readRunFileLineCount(filePath: string): Promise<number> {
|
|
469
|
+
try {
|
|
470
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
471
|
+
return splitJsonlLines(raw).length;
|
|
472
|
+
} catch {
|
|
473
|
+
return 0;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function readState(filePath: string): Promise<Map<string, number> | null> {
|
|
478
|
+
try {
|
|
479
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
480
|
+
const parsed = toObject(safeJsonParse(raw));
|
|
481
|
+
if (!parsed || parsed.version !== STATE_VERSION) {
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
const files = toObject(parsed.files);
|
|
485
|
+
if (!files) {
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
const map = new Map<string, number>();
|
|
489
|
+
for (const [name, value] of Object.entries(files)) {
|
|
490
|
+
const cursor = toNonNegativeInt(value);
|
|
491
|
+
if (cursor !== null) {
|
|
492
|
+
map.set(name, cursor);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return map;
|
|
496
|
+
} catch {
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async function writeState(filePath: string, cursorMap: Map<string, number>): Promise<void> {
|
|
502
|
+
const files: Record<string, number> = {};
|
|
503
|
+
for (const [name, cursor] of cursorMap) {
|
|
504
|
+
files[name] = cursor;
|
|
505
|
+
}
|
|
506
|
+
const payload: RelayStateFile = {
|
|
507
|
+
version: STATE_VERSION,
|
|
508
|
+
files,
|
|
509
|
+
};
|
|
510
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
511
|
+
await fs.writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function writeRelayStatusArtifacts(params: {
|
|
515
|
+
jsonPath: string;
|
|
516
|
+
markdownPath: string;
|
|
517
|
+
payload: RelayStatusFile;
|
|
518
|
+
}): Promise<void> {
|
|
519
|
+
await fs.mkdir(path.dirname(params.jsonPath), { recursive: true });
|
|
520
|
+
await fs.writeFile(params.jsonPath, `${JSON.stringify(params.payload, null, 2)}\n`, "utf8");
|
|
521
|
+
|
|
522
|
+
const lines = [
|
|
523
|
+
"# Infoflow Cron Relay Status",
|
|
524
|
+
"",
|
|
525
|
+
`更新时间: ${params.payload.updatedAt}`,
|
|
526
|
+
`runsDir: ${params.payload.runsDir}`,
|
|
527
|
+
`jobsPath: ${params.payload.jobsPath}`,
|
|
528
|
+
`statePath: ${params.payload.statePath}`,
|
|
529
|
+
"",
|
|
530
|
+
"## 当前任务",
|
|
531
|
+
"",
|
|
532
|
+
"| 任务 | 目标 | 最近执行 | Relay | 说明 |",
|
|
533
|
+
"| --- | --- | --- | --- | --- |",
|
|
534
|
+
];
|
|
535
|
+
|
|
536
|
+
if (params.payload.jobs.length === 0) {
|
|
537
|
+
lines.push("| - | - | - | - | 当前没有可观测的 Infoflow cron 任务 |");
|
|
538
|
+
} else {
|
|
539
|
+
for (const job of params.payload.jobs) {
|
|
540
|
+
const lastRun = job.lastRun;
|
|
541
|
+
const lastStatus = lastRun ? `${lastRun.time} / ${formatStatus(lastRun.status)}` : "未执行";
|
|
542
|
+
const relayStatus = lastRun
|
|
543
|
+
? formatRelayStatus(lastRun.relayStatus)
|
|
544
|
+
: formatRelayStatus("idle");
|
|
545
|
+
const note = [
|
|
546
|
+
`source=${job.targetSource}`,
|
|
547
|
+
lastRun?.relayReason ? `reason=${lastRun.relayReason}` : "",
|
|
548
|
+
job.creatorSenderId ? `creator=${job.creatorSenderId}` : "",
|
|
549
|
+
]
|
|
550
|
+
.filter(Boolean)
|
|
551
|
+
.join("; ");
|
|
552
|
+
lines.push(
|
|
553
|
+
`| ${markdownCell(job.jobName)} | ${markdownCell(job.target)} | ${markdownCell(lastStatus)} | ${markdownCell(relayStatus)} | ${markdownCell(note)} |`,
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
lines.push(
|
|
559
|
+
"",
|
|
560
|
+
"## 最近执行",
|
|
561
|
+
"",
|
|
562
|
+
"| 时间 | 任务 | 状态 | Relay | 目标 | 摘要 |",
|
|
563
|
+
"| --- | --- | --- | --- | --- | --- |",
|
|
564
|
+
);
|
|
565
|
+
if (params.payload.recentRuns.length === 0) {
|
|
566
|
+
lines.push("| - | - | - | - | - | 暂无运行记录 |");
|
|
567
|
+
} else {
|
|
568
|
+
for (const run of params.payload.recentRuns) {
|
|
569
|
+
const summary = truncateText(run.summary ?? run.error, 120) ?? "-";
|
|
570
|
+
const relay = [formatRelayStatus(run.relayStatus), run.relayReason]
|
|
571
|
+
.filter(Boolean)
|
|
572
|
+
.join(" / ");
|
|
573
|
+
lines.push(
|
|
574
|
+
`| ${markdownCell(run.time)} | ${markdownCell(run.jobName)} | ${markdownCell(formatStatus(run.status))} | ${markdownCell(relay)} | ${markdownCell(run.target)} | ${markdownCell(summary)} |`,
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
await fs.writeFile(params.markdownPath, `${lines.join("\n")}\n`, "utf8");
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async function readJobsFile(filePath: string): Promise<Map<string, CronJobLike>> {
|
|
583
|
+
const result = new Map<string, CronJobLike>();
|
|
584
|
+
try {
|
|
585
|
+
const raw = await fs.readFile(filePath, "utf8");
|
|
586
|
+
const parsed = toObject(safeJsonParse(raw));
|
|
587
|
+
const jobs = Array.isArray(parsed?.jobs) ? parsed.jobs : [];
|
|
588
|
+
for (const jobRaw of jobs) {
|
|
589
|
+
const obj = toObject(jobRaw);
|
|
590
|
+
const id = toStringOrUndefined(obj?.id)?.trim();
|
|
591
|
+
if (!id || result.has(id)) {
|
|
592
|
+
continue;
|
|
593
|
+
}
|
|
594
|
+
result.set(id, {
|
|
595
|
+
id,
|
|
596
|
+
agentId: toStringOrUndefined(obj?.agentId),
|
|
597
|
+
name: toStringOrUndefined(obj?.name),
|
|
598
|
+
description: toStringOrUndefined(obj?.description),
|
|
599
|
+
sessionKey: toStringOrUndefined(obj?.sessionKey),
|
|
600
|
+
payload: toObject(obj?.payload) as CronJobLike["payload"],
|
|
601
|
+
delivery: toObject(obj?.delivery) as CronJobLike["delivery"],
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
} catch {
|
|
605
|
+
// Ignore missing or unreadable files.
|
|
606
|
+
}
|
|
607
|
+
return result;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async function readJobsById(
|
|
611
|
+
filePath: string,
|
|
612
|
+
options?: { includeBackup?: boolean },
|
|
613
|
+
): Promise<Map<string, CronJobLike>> {
|
|
614
|
+
const result = await readJobsFile(filePath);
|
|
615
|
+
if (!options?.includeBackup) {
|
|
616
|
+
return result;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const backup = await readJobsFile(`${filePath}.bak`);
|
|
620
|
+
for (const [jobId, job] of backup) {
|
|
621
|
+
if (!result.has(jobId)) {
|
|
622
|
+
result.set(jobId, job);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return result;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
async function readLatestRunForFile(filePath: string): Promise<{
|
|
629
|
+
fileName: string;
|
|
630
|
+
line: number;
|
|
631
|
+
lineCount: number;
|
|
632
|
+
entry: CronRunLogEntry;
|
|
633
|
+
} | null> {
|
|
634
|
+
const raw = await fs.readFile(filePath, "utf8").catch(() => "");
|
|
635
|
+
const lines = splitJsonlLines(raw);
|
|
636
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
637
|
+
const parsed = parseCronRunLine(lines[i] ?? "");
|
|
638
|
+
if (parsed) {
|
|
639
|
+
return {
|
|
640
|
+
fileName: path.basename(filePath),
|
|
641
|
+
line: i + 1,
|
|
642
|
+
lineCount: lines.length,
|
|
643
|
+
entry: parsed,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function resolveRelayStatusForRun(params: {
|
|
651
|
+
job?: CronJobLike;
|
|
652
|
+
target?: string;
|
|
653
|
+
includeAlreadyDelivered: boolean;
|
|
654
|
+
cursor: number;
|
|
655
|
+
line: number;
|
|
656
|
+
}): { relayStatus: RelayStatus; relayReason?: string } {
|
|
657
|
+
if (!params.target) {
|
|
658
|
+
return {
|
|
659
|
+
relayStatus: "skipped",
|
|
660
|
+
relayReason: "missing explicit relay target",
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
if (!params.includeAlreadyDelivered && cronJobAlreadyDeliveredToInfoflow(params.job)) {
|
|
664
|
+
return {
|
|
665
|
+
relayStatus: "skipped",
|
|
666
|
+
relayReason: "already delivered by OpenClaw delivery",
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
if (params.cursor < params.line) {
|
|
670
|
+
return {
|
|
671
|
+
relayStatus: "pending",
|
|
672
|
+
relayReason: "waiting for relay cursor",
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
return { relayStatus: "relayed" };
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async function buildRelayStatusSnapshot(params: {
|
|
679
|
+
cfg: OpenClawConfig;
|
|
680
|
+
stateDir: string;
|
|
681
|
+
runsDir: string;
|
|
682
|
+
jobsPath: string;
|
|
683
|
+
statePath: string;
|
|
684
|
+
cursorMap: Map<string, number>;
|
|
685
|
+
includeAlreadyDelivered: boolean;
|
|
686
|
+
}): Promise<RelayStatusFile> {
|
|
687
|
+
const currentJobsById = await readJobsById(params.jobsPath);
|
|
688
|
+
const jobsById = await readJobsById(params.jobsPath, { includeBackup: true });
|
|
689
|
+
const sessionStoreCache = new Map<string, Record<string, unknown>>();
|
|
690
|
+
const latestRuns = new Map<
|
|
691
|
+
string,
|
|
692
|
+
{
|
|
693
|
+
fileName: string;
|
|
694
|
+
line: number;
|
|
695
|
+
lineCount: number;
|
|
696
|
+
entry: CronRunLogEntry;
|
|
697
|
+
}
|
|
698
|
+
>();
|
|
699
|
+
const fileNames = (await fs.readdir(params.runsDir).catch(() => []))
|
|
700
|
+
.filter((name) => name.endsWith(".jsonl"))
|
|
701
|
+
.sort();
|
|
702
|
+
for (const fileName of fileNames) {
|
|
703
|
+
const latest = await readLatestRunForFile(path.join(params.runsDir, fileName));
|
|
704
|
+
if (latest) {
|
|
705
|
+
latestRuns.set(latest.entry.jobId, latest);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const jobs: RelayStatusJobEntry[] = [];
|
|
710
|
+
for (const [jobId, job] of currentJobsById) {
|
|
711
|
+
const metadata = parseInfoflowCronJobMetadata(job.description);
|
|
712
|
+
const latest = latestRuns.get(jobId);
|
|
713
|
+
const sessionTarget = await resolveCronRelaySessionTarget({
|
|
714
|
+
cfg: params.cfg,
|
|
715
|
+
stateDir: params.stateDir,
|
|
716
|
+
agentId: job.agentId,
|
|
717
|
+
sessionKey: latest?.entry.sessionKey ?? job.sessionKey,
|
|
718
|
+
cache: sessionStoreCache,
|
|
719
|
+
});
|
|
720
|
+
const targetInfo = resolveCronRelayTargetInfo(params.cfg, job, sessionTarget);
|
|
721
|
+
if (!metadata && !targetInfo.target && !cronJobAlreadyDeliveredToInfoflow(job)) {
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
const relayState = latest
|
|
725
|
+
? resolveRelayStatusForRun({
|
|
726
|
+
job,
|
|
727
|
+
target: targetInfo.target,
|
|
728
|
+
includeAlreadyDelivered: params.includeAlreadyDelivered,
|
|
729
|
+
cursor: params.cursorMap.get(latest.fileName) ?? 0,
|
|
730
|
+
line: latest.line,
|
|
731
|
+
})
|
|
732
|
+
: null;
|
|
733
|
+
jobs.push({
|
|
734
|
+
jobId,
|
|
735
|
+
jobName: job.name?.trim() || jobId,
|
|
736
|
+
channel: "infoflow",
|
|
737
|
+
target: targetInfo.target,
|
|
738
|
+
targetSource: targetInfo.source,
|
|
739
|
+
creatorSenderId: metadata?.creatorSenderId,
|
|
740
|
+
lastRun: latest
|
|
741
|
+
? {
|
|
742
|
+
fileName: latest.fileName,
|
|
743
|
+
line: latest.line,
|
|
744
|
+
status: latest.entry.status,
|
|
745
|
+
relayStatus: relayState?.relayStatus ?? "idle",
|
|
746
|
+
relayReason: relayState?.relayReason,
|
|
747
|
+
time: formatTimestamp(latest.entry.ts),
|
|
748
|
+
durationMs: latest.entry.durationMs,
|
|
749
|
+
summary: truncateText(latest.entry.summary),
|
|
750
|
+
error: truncateText(latest.entry.error),
|
|
751
|
+
}
|
|
752
|
+
: undefined,
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
jobs.sort((a, b) => a.jobName.localeCompare(b.jobName, "zh-CN"));
|
|
757
|
+
|
|
758
|
+
const recentRuns: RelayStatusRecentRun[] = [];
|
|
759
|
+
for (const latest of latestRuns.values()) {
|
|
760
|
+
const job = jobsById.get(latest.entry.jobId);
|
|
761
|
+
const sessionTarget = await resolveCronRelaySessionTarget({
|
|
762
|
+
cfg: params.cfg,
|
|
763
|
+
stateDir: params.stateDir,
|
|
764
|
+
agentId: job?.agentId,
|
|
765
|
+
sessionKey: latest.entry.sessionKey ?? job?.sessionKey,
|
|
766
|
+
cache: sessionStoreCache,
|
|
767
|
+
});
|
|
768
|
+
const targetInfo = resolveCronRelayTargetInfo(params.cfg, job, sessionTarget);
|
|
769
|
+
if (!job && !targetInfo.target) {
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
const relayState = resolveRelayStatusForRun({
|
|
773
|
+
job,
|
|
774
|
+
target: targetInfo.target,
|
|
775
|
+
includeAlreadyDelivered: params.includeAlreadyDelivered,
|
|
776
|
+
cursor: params.cursorMap.get(latest.fileName) ?? 0,
|
|
777
|
+
line: latest.line,
|
|
778
|
+
});
|
|
779
|
+
recentRuns.push({
|
|
780
|
+
jobId: latest.entry.jobId,
|
|
781
|
+
jobName: job?.name?.trim() || latest.entry.jobId,
|
|
782
|
+
target: targetInfo.target,
|
|
783
|
+
targetSource: targetInfo.source,
|
|
784
|
+
status: latest.entry.status,
|
|
785
|
+
relayStatus: relayState.relayStatus,
|
|
786
|
+
relayReason: relayState.relayReason,
|
|
787
|
+
time: formatTimestamp(latest.entry.ts),
|
|
788
|
+
durationMs: latest.entry.durationMs,
|
|
789
|
+
summary: truncateText(latest.entry.summary),
|
|
790
|
+
error: truncateText(latest.entry.error),
|
|
791
|
+
fileName: latest.fileName,
|
|
792
|
+
line: latest.line,
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
recentRuns.sort((a, b) => {
|
|
797
|
+
const ta = latestRuns.get(a.jobId)?.entry.ts ?? 0;
|
|
798
|
+
const tb = latestRuns.get(b.jobId)?.entry.ts ?? 0;
|
|
799
|
+
return tb - ta;
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
return {
|
|
803
|
+
version: RELAY_STATUS_VERSION,
|
|
804
|
+
updatedAt: new Date().toISOString(),
|
|
805
|
+
runsDir: params.runsDir,
|
|
806
|
+
jobsPath: params.jobsPath,
|
|
807
|
+
statePath: params.statePath,
|
|
808
|
+
jobs,
|
|
809
|
+
recentRuns: recentRuns.slice(0, MAX_RECENT_RUNS),
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function normalizeAgentId(value: string | undefined): string {
|
|
814
|
+
const raw = value?.trim().toLowerCase();
|
|
815
|
+
return raw || "main";
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
function expandHomePrefix(filePath: string): string {
|
|
819
|
+
if (filePath === "~") {
|
|
820
|
+
return os.homedir();
|
|
821
|
+
}
|
|
822
|
+
if (filePath.startsWith("~/")) {
|
|
823
|
+
return path.join(os.homedir(), filePath.slice(2));
|
|
824
|
+
}
|
|
825
|
+
return filePath;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function resolveCronRelaySessionStorePath(params: {
|
|
829
|
+
cfg: OpenClawConfig;
|
|
830
|
+
stateDir: string;
|
|
831
|
+
agentId?: string;
|
|
832
|
+
}): string {
|
|
833
|
+
const agentId = normalizeAgentId(params.agentId);
|
|
834
|
+
const sessionConfig = toObject(params.cfg.session);
|
|
835
|
+
const configuredStore = toStringOrUndefined(sessionConfig?.store)?.trim();
|
|
836
|
+
if (!configuredStore) {
|
|
837
|
+
return path.join(params.stateDir, "agents", agentId, "sessions", "sessions.json");
|
|
838
|
+
}
|
|
839
|
+
const expanded = configuredStore.replaceAll("{agentId}", agentId);
|
|
840
|
+
return path.resolve(expandHomePrefix(expanded));
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
export function resolveInfoflowTargetFromSessionEntry(params: {
|
|
844
|
+
entry: unknown;
|
|
845
|
+
sessionKey?: string;
|
|
846
|
+
}): string | undefined {
|
|
847
|
+
const sessionKeyTarget = resolveInfoflowTargetFromSessionKey(params.sessionKey);
|
|
848
|
+
const entry = toObject(params.entry);
|
|
849
|
+
if (!entry) {
|
|
850
|
+
return sessionKeyTarget;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const delivery = toObject(entry.deliveryContext);
|
|
854
|
+
const channel = toStringOrUndefined(delivery?.channel) ?? toStringOrUndefined(entry.lastChannel);
|
|
855
|
+
if (channel && !isInfoflowChannel(channel)) {
|
|
856
|
+
return undefined;
|
|
857
|
+
}
|
|
858
|
+
if (!channel && !params.sessionKey?.toLowerCase().includes(":infoflow:")) {
|
|
859
|
+
return undefined;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
return (
|
|
863
|
+
normalizeInfoflowTarget(
|
|
864
|
+
toStringOrUndefined(delivery?.to) ?? toStringOrUndefined(entry.lastTo) ?? "",
|
|
865
|
+
) ?? sessionKeyTarget
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
export function resolveInfoflowTargetFromSessionKey(
|
|
870
|
+
sessionKey: string | undefined,
|
|
871
|
+
): string | undefined {
|
|
872
|
+
const trimmed = sessionKey?.trim();
|
|
873
|
+
if (!trimmed || !trimmed.toLowerCase().includes(":infoflow:")) {
|
|
874
|
+
return undefined;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
const groupMatch = trimmed.match(/:group:(\d+)(?::member:[^:]+)?$/i);
|
|
878
|
+
if (groupMatch) {
|
|
879
|
+
return normalizeInfoflowTarget(`infoflow:group:${groupMatch[1]}`);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
const directMatch = trimmed.match(/:direct:([^:]+)$/i);
|
|
883
|
+
if (directMatch) {
|
|
884
|
+
return normalizeInfoflowTarget(`infoflow:${directMatch[1]}`);
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
return undefined;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
async function resolveCronRelaySessionTarget(params: {
|
|
891
|
+
cfg: OpenClawConfig;
|
|
892
|
+
stateDir: string;
|
|
893
|
+
agentId?: string;
|
|
894
|
+
sessionKey?: string;
|
|
895
|
+
cache: Map<string, Record<string, unknown>>;
|
|
896
|
+
}): Promise<string | undefined> {
|
|
897
|
+
const sessionKey = params.sessionKey?.trim();
|
|
898
|
+
if (!sessionKey) {
|
|
899
|
+
return undefined;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const storePath = resolveCronRelaySessionStorePath({
|
|
903
|
+
cfg: params.cfg,
|
|
904
|
+
stateDir: params.stateDir,
|
|
905
|
+
agentId: params.agentId,
|
|
906
|
+
});
|
|
907
|
+
let store = params.cache.get(storePath);
|
|
908
|
+
if (!store) {
|
|
909
|
+
const raw = await fs.readFile(storePath, "utf8").catch(() => "");
|
|
910
|
+
const parsed = toObject(safeJsonParse(raw));
|
|
911
|
+
store = parsed ?? {};
|
|
912
|
+
params.cache.set(storePath, store);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
return resolveInfoflowTargetFromSessionEntry({
|
|
916
|
+
entry: store[sessionKey],
|
|
917
|
+
sessionKey,
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
async function relayCronMessage(params: {
|
|
922
|
+
cfg: OpenClawConfig;
|
|
923
|
+
accountId: string;
|
|
924
|
+
to: string;
|
|
925
|
+
contents: InfoflowMessageContentItem[];
|
|
926
|
+
}): Promise<void> {
|
|
927
|
+
const result = await sendInfoflowMessage({
|
|
928
|
+
cfg: params.cfg,
|
|
929
|
+
accountId: params.accountId,
|
|
930
|
+
to: params.to,
|
|
931
|
+
contents: params.contents,
|
|
932
|
+
});
|
|
933
|
+
if (!result.ok) {
|
|
934
|
+
throw new Error(result.error ?? "failed to relay cron message");
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
export function createInfoflowCronRelayService(): OpenClawPluginService {
|
|
939
|
+
let timer: NodeJS.Timeout | null = null;
|
|
940
|
+
let inFlight = false;
|
|
941
|
+
let stopped = false;
|
|
942
|
+
let statePath = "";
|
|
943
|
+
let statusJsonPath = "";
|
|
944
|
+
let statusMarkdownPath = "";
|
|
945
|
+
let runsDir = "";
|
|
946
|
+
let jobsPath = "";
|
|
947
|
+
const cursorMap = new Map<string, number>();
|
|
948
|
+
|
|
949
|
+
const persistRelayStatus = async (
|
|
950
|
+
ctx: Parameters<OpenClawPluginService["start"]>[0],
|
|
951
|
+
includeAlreadyDelivered: boolean,
|
|
952
|
+
): Promise<void> => {
|
|
953
|
+
if (!statusJsonPath || !statusMarkdownPath) {
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
const payload = await buildRelayStatusSnapshot({
|
|
957
|
+
cfg: ctx.config,
|
|
958
|
+
stateDir: ctx.stateDir,
|
|
959
|
+
runsDir,
|
|
960
|
+
jobsPath,
|
|
961
|
+
statePath,
|
|
962
|
+
cursorMap,
|
|
963
|
+
includeAlreadyDelivered,
|
|
964
|
+
});
|
|
965
|
+
await writeRelayStatusArtifacts({
|
|
966
|
+
jsonPath: statusJsonPath,
|
|
967
|
+
markdownPath: statusMarkdownPath,
|
|
968
|
+
payload,
|
|
969
|
+
});
|
|
970
|
+
};
|
|
971
|
+
|
|
972
|
+
const tick = async (ctx: Parameters<OpenClawPluginService["start"]>[0]): Promise<void> => {
|
|
973
|
+
if (stopped || inFlight) {
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
inFlight = true;
|
|
977
|
+
try {
|
|
978
|
+
const cfg = ctx.config;
|
|
979
|
+
const accountId = resolveDefaultInfoflowAccountId(cfg);
|
|
980
|
+
const account = resolveInfoflowAccount({ cfg, accountId });
|
|
981
|
+
if (!account.enabled || !account.config.cronRelay.enabled) {
|
|
982
|
+
ctx.logger.warn(
|
|
983
|
+
`infoflow cron-relay: skipping tick, account.enabled=${account.enabled}, cronRelay.enabled=${account.config.cronRelay.enabled}`,
|
|
984
|
+
);
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const fileNames = (await fs.readdir(runsDir).catch(() => []))
|
|
989
|
+
.filter((name) => name.endsWith(".jsonl"))
|
|
990
|
+
.sort();
|
|
991
|
+
if (fileNames.length === 0) {
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const jobsById = await readJobsById(jobsPath, { includeBackup: true });
|
|
996
|
+
const sessionStoreCache = new Map<string, Record<string, unknown>>();
|
|
997
|
+
let dirty = false;
|
|
998
|
+
for (const fileName of fileNames) {
|
|
999
|
+
const filePath = path.join(runsDir, fileName);
|
|
1000
|
+
const raw = await fs.readFile(filePath, "utf8").catch(() => "");
|
|
1001
|
+
const lines = splitJsonlLines(raw);
|
|
1002
|
+
const currentCursorRaw = cursorMap.get(fileName) ?? 0;
|
|
1003
|
+
const currentCursor = Math.min(Math.max(0, currentCursorRaw), lines.length);
|
|
1004
|
+
|
|
1005
|
+
let nextCursor = currentCursor;
|
|
1006
|
+
for (let i = currentCursor; i < lines.length; i += 1) {
|
|
1007
|
+
try {
|
|
1008
|
+
const parsed = parseCronRunLine(lines[i] ?? "");
|
|
1009
|
+
if (!parsed) {
|
|
1010
|
+
nextCursor = i + 1;
|
|
1011
|
+
dirty = true;
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
const job = jobsById.get(parsed.jobId);
|
|
1016
|
+
if (
|
|
1017
|
+
!account.config.cronRelay.includeAlreadyDelivered &&
|
|
1018
|
+
cronJobAlreadyDeliveredToInfoflow(job)
|
|
1019
|
+
) {
|
|
1020
|
+
nextCursor = i + 1;
|
|
1021
|
+
dirty = true;
|
|
1022
|
+
continue;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
const sessionTarget = await resolveCronRelaySessionTarget({
|
|
1026
|
+
cfg,
|
|
1027
|
+
stateDir: ctx.stateDir,
|
|
1028
|
+
agentId: job?.agentId,
|
|
1029
|
+
sessionKey: parsed.sessionKey ?? job?.sessionKey,
|
|
1030
|
+
cache: sessionStoreCache,
|
|
1031
|
+
});
|
|
1032
|
+
const relayTo = resolveCronRelayTarget(cfg, job, sessionTarget);
|
|
1033
|
+
if (!relayTo) {
|
|
1034
|
+
ctx.logger.warn(
|
|
1035
|
+
`infoflow cron-relay: skip job ${parsed.jobId}, missing explicit relay target (use infoflow_cron or create the cron from an infoflow session with sessionKey)`,
|
|
1036
|
+
);
|
|
1037
|
+
nextCursor = i + 1;
|
|
1038
|
+
dirty = true;
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const relayContents = buildCronRelayContents({
|
|
1043
|
+
entry: parsed,
|
|
1044
|
+
job,
|
|
1045
|
+
prefix: account.config.cronRelay.prefix,
|
|
1046
|
+
target: relayTo,
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
await relayCronMessage({
|
|
1050
|
+
cfg,
|
|
1051
|
+
accountId: account.accountId,
|
|
1052
|
+
to: relayTo,
|
|
1053
|
+
contents: relayContents,
|
|
1054
|
+
});
|
|
1055
|
+
ctx.logger.info(
|
|
1056
|
+
`infoflow cron-relay relayed job=${parsed.jobId} status=${parsed.status ?? "unknown"} to=${relayTo}`,
|
|
1057
|
+
);
|
|
1058
|
+
nextCursor = i + 1;
|
|
1059
|
+
dirty = true;
|
|
1060
|
+
} catch (err) {
|
|
1061
|
+
ctx.logger.warn(
|
|
1062
|
+
`infoflow cron-relay: file=${fileName} line=${i + 1} relay failed: ${String(err)}`,
|
|
1063
|
+
);
|
|
1064
|
+
break;
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
if (nextCursor !== currentCursorRaw) {
|
|
1069
|
+
cursorMap.set(fileName, nextCursor);
|
|
1070
|
+
dirty = true;
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
if (dirty) {
|
|
1075
|
+
await writeState(statePath, cursorMap);
|
|
1076
|
+
}
|
|
1077
|
+
await persistRelayStatus(ctx, account.config.cronRelay.includeAlreadyDelivered);
|
|
1078
|
+
} catch (err) {
|
|
1079
|
+
ctx.logger.warn(`infoflow cron-relay tick failed: ${String(err)}`);
|
|
1080
|
+
const account = resolveInfoflowAccount({ cfg: ctx.config });
|
|
1081
|
+
await persistRelayStatus(ctx, account.config.cronRelay.includeAlreadyDelivered).catch(
|
|
1082
|
+
() => {},
|
|
1083
|
+
);
|
|
1084
|
+
} finally {
|
|
1085
|
+
inFlight = false;
|
|
1086
|
+
}
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
return {
|
|
1090
|
+
id: "infoflow-cron-relay",
|
|
1091
|
+
async start(ctx) {
|
|
1092
|
+
stopped = false;
|
|
1093
|
+
inFlight = false;
|
|
1094
|
+
cursorMap.clear();
|
|
1095
|
+
const { rootDir, privateDataRoot, usedDefault } = resolveCronRelayPrivateDataRoot({
|
|
1096
|
+
cfg: ctx.config,
|
|
1097
|
+
stateDir: ctx.stateDir,
|
|
1098
|
+
});
|
|
1099
|
+
runsDir = path.join(rootDir, "cron", "runs");
|
|
1100
|
+
jobsPath = path.join(rootDir, "cron", "jobs.json");
|
|
1101
|
+
statePath = path.join(privateDataRoot, "cron-relay-state.json");
|
|
1102
|
+
statusJsonPath = path.join(privateDataRoot, "cron-relay-status.json");
|
|
1103
|
+
statusMarkdownPath = path.join(privateDataRoot, "cron-relay-status.md");
|
|
1104
|
+
if (usedDefault) {
|
|
1105
|
+
ctx.logger.warn(
|
|
1106
|
+
"infoflow cron-relay: invalid channels.infoflow.privateDataDir, fallback to default",
|
|
1107
|
+
);
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
const loaded = await readState(statePath);
|
|
1111
|
+
if (loaded) {
|
|
1112
|
+
for (const [fileName, cursor] of loaded) {
|
|
1113
|
+
cursorMap.set(fileName, cursor);
|
|
1114
|
+
}
|
|
1115
|
+
} else {
|
|
1116
|
+
const fileNames = (await fs.readdir(runsDir).catch(() => []))
|
|
1117
|
+
.filter((name) => name.endsWith(".jsonl"))
|
|
1118
|
+
.sort();
|
|
1119
|
+
for (const fileName of fileNames) {
|
|
1120
|
+
const filePath = path.join(runsDir, fileName);
|
|
1121
|
+
cursorMap.set(fileName, await readRunFileLineCount(filePath));
|
|
1122
|
+
}
|
|
1123
|
+
await writeState(statePath, cursorMap);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
const account = resolveInfoflowAccount({ cfg: ctx.config });
|
|
1127
|
+
await persistRelayStatus(ctx, account.config.cronRelay.includeAlreadyDelivered);
|
|
1128
|
+
const pollIntervalMs = Math.max(
|
|
1129
|
+
MIN_POLL_INTERVAL_MS,
|
|
1130
|
+
account.config.cronRelay.pollIntervalMs || DEFAULT_POLL_INTERVAL_MS,
|
|
1131
|
+
);
|
|
1132
|
+
timer = setInterval(() => {
|
|
1133
|
+
void tick(ctx);
|
|
1134
|
+
}, pollIntervalMs);
|
|
1135
|
+
await tick(ctx);
|
|
1136
|
+
ctx.logger.info(
|
|
1137
|
+
`infoflow cron-relay started (runsDir=${runsDir}, statePath=${statePath}, statusPath=${statusMarkdownPath}, pollIntervalMs=${pollIntervalMs})`,
|
|
1138
|
+
);
|
|
1139
|
+
},
|
|
1140
|
+
async stop(ctx) {
|
|
1141
|
+
stopped = true;
|
|
1142
|
+
if (timer) {
|
|
1143
|
+
clearInterval(timer);
|
|
1144
|
+
timer = null;
|
|
1145
|
+
}
|
|
1146
|
+
await writeState(statePath, cursorMap).catch(() => {});
|
|
1147
|
+
const account = resolveInfoflowAccount({ cfg: ctx.config });
|
|
1148
|
+
await persistRelayStatus(ctx, account.config.cronRelay.includeAlreadyDelivered).catch(
|
|
1149
|
+
() => {},
|
|
1150
|
+
);
|
|
1151
|
+
ctx.logger.info("infoflow cron-relay stopped");
|
|
1152
|
+
},
|
|
1153
|
+
};
|
|
1154
|
+
}
|