botschat 0.1.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/LICENSE +201 -0
- package/README.md +213 -0
- package/migrations/0001_initial.sql +88 -0
- package/migrations/0002_rename_projects_to_channels.sql +53 -0
- package/migrations/0003_messages.sql +14 -0
- package/migrations/0004_jobs.sql +15 -0
- package/migrations/0005_deleted_cron_jobs.sql +6 -0
- package/migrations/0006_tasks_add_model.sql +2 -0
- package/migrations/0007_sessions.sql +25 -0
- package/migrations/0008_remove_openclaw_fields.sql +8 -0
- package/package.json +53 -0
- package/packages/api/package.json +17 -0
- package/packages/api/src/do/connection-do.ts +929 -0
- package/packages/api/src/env.ts +8 -0
- package/packages/api/src/index.ts +297 -0
- package/packages/api/src/routes/agents.ts +68 -0
- package/packages/api/src/routes/auth.ts +105 -0
- package/packages/api/src/routes/channels.ts +185 -0
- package/packages/api/src/routes/jobs.ts +65 -0
- package/packages/api/src/routes/models.ts +22 -0
- package/packages/api/src/routes/pairing.ts +76 -0
- package/packages/api/src/routes/projects.ts +177 -0
- package/packages/api/src/routes/sessions.ts +171 -0
- package/packages/api/src/routes/tasks.ts +375 -0
- package/packages/api/src/routes/upload.ts +52 -0
- package/packages/api/src/utils/auth.ts +101 -0
- package/packages/api/src/utils/id.ts +19 -0
- package/packages/api/tsconfig.json +18 -0
- package/packages/plugin/dist/index.d.ts +19 -0
- package/packages/plugin/dist/index.d.ts.map +1 -0
- package/packages/plugin/dist/index.js +17 -0
- package/packages/plugin/dist/index.js.map +1 -0
- package/packages/plugin/dist/src/accounts.d.ts +12 -0
- package/packages/plugin/dist/src/accounts.d.ts.map +1 -0
- package/packages/plugin/dist/src/accounts.js +103 -0
- package/packages/plugin/dist/src/accounts.js.map +1 -0
- package/packages/plugin/dist/src/channel.d.ts +206 -0
- package/packages/plugin/dist/src/channel.d.ts.map +1 -0
- package/packages/plugin/dist/src/channel.js +1248 -0
- package/packages/plugin/dist/src/channel.js.map +1 -0
- package/packages/plugin/dist/src/runtime.d.ts +3 -0
- package/packages/plugin/dist/src/runtime.d.ts.map +1 -0
- package/packages/plugin/dist/src/runtime.js +18 -0
- package/packages/plugin/dist/src/runtime.js.map +1 -0
- package/packages/plugin/dist/src/types.d.ts +179 -0
- package/packages/plugin/dist/src/types.d.ts.map +1 -0
- package/packages/plugin/dist/src/types.js +6 -0
- package/packages/plugin/dist/src/types.js.map +1 -0
- package/packages/plugin/dist/src/ws-client.d.ts +51 -0
- package/packages/plugin/dist/src/ws-client.d.ts.map +1 -0
- package/packages/plugin/dist/src/ws-client.js +170 -0
- package/packages/plugin/dist/src/ws-client.js.map +1 -0
- package/packages/plugin/openclaw.plugin.json +11 -0
- package/packages/plugin/package.json +39 -0
- package/packages/plugin/tsconfig.json +20 -0
- package/packages/web/dist/assets/index-C-wI8eHy.css +1 -0
- package/packages/web/dist/assets/index-CbPEKHLG.js +93 -0
- package/packages/web/dist/index.html +17 -0
- package/packages/web/index.html +16 -0
- package/packages/web/package.json +29 -0
- package/packages/web/postcss.config.js +6 -0
- package/packages/web/src/App.tsx +827 -0
- package/packages/web/src/api.ts +242 -0
- package/packages/web/src/components/ChatWindow.tsx +864 -0
- package/packages/web/src/components/CronDetail.tsx +943 -0
- package/packages/web/src/components/CronSidebar.tsx +123 -0
- package/packages/web/src/components/DebugLogPanel.tsx +258 -0
- package/packages/web/src/components/IconRail.tsx +163 -0
- package/packages/web/src/components/JobList.tsx +120 -0
- package/packages/web/src/components/LoginPage.tsx +178 -0
- package/packages/web/src/components/MessageContent.tsx +1082 -0
- package/packages/web/src/components/ModelSelect.tsx +87 -0
- package/packages/web/src/components/ScheduleEditor.tsx +403 -0
- package/packages/web/src/components/SessionTabs.tsx +246 -0
- package/packages/web/src/components/Sidebar.tsx +331 -0
- package/packages/web/src/components/TaskBar.tsx +413 -0
- package/packages/web/src/components/ThreadPanel.tsx +212 -0
- package/packages/web/src/debug-log.ts +58 -0
- package/packages/web/src/index.css +170 -0
- package/packages/web/src/main.tsx +10 -0
- package/packages/web/src/store.ts +492 -0
- package/packages/web/src/ws.ts +99 -0
- package/packages/web/tailwind.config.js +65 -0
- package/packages/web/tsconfig.json +18 -0
- package/packages/web/vite.config.ts +20 -0
- package/scripts/dev.sh +122 -0
- package/tsconfig.json +18 -0
- package/wrangler.toml +40 -0
|
@@ -0,0 +1,1248 @@
|
|
|
1
|
+
import { deleteBotsChatAccount, listBotsChatAccountIds, resolveBotsChatAccount, resolveDefaultBotsChatAccountId, setBotsChatAccountEnabled, } from "./accounts.js";
|
|
2
|
+
import { getBotsChatRuntime } from "./runtime.js";
|
|
3
|
+
import { BotsChatCloudClient } from "./ws-client.js";
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// A2UI message-tool hints — injected via agentPrompt.messageToolHints so
|
|
6
|
+
// the agent knows it can output interactive UI components. These strings
|
|
7
|
+
// end up inside the "message" tool documentation section of the system
|
|
8
|
+
// prompt, which the model pays close attention to.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
const A2UI_MESSAGE_TOOL_HINTS = [
|
|
11
|
+
"- This channel renders ```action fenced code blocks as interactive clickable widgets. When your reply offers choices, next steps, or confirmations, you MUST wrap a single-line JSON in an ```action fence instead of using plain-text option lists.",
|
|
12
|
+
"- Action block format: ```action\\n{\"kind\":\"buttons\",\"prompt\":\"What next?\",\"items\":[{\"label\":\"Do X\",\"value\":\"x\",\"style\":\"primary\"},{\"label\":\"Do Y\",\"value\":\"y\"}]}\\n``` — kinds: buttons, confirm, select, input. Styles: \"primary\", \"danger\", or omit.",
|
|
13
|
+
"- NEVER present selectable options as plain-text lists with bullets, numbers, or emojis (✅ • - 🔧 etc.) — they are NOT clickable. Always use an ```action block for choices. Skip action blocks only for purely informational replies.",
|
|
14
|
+
];
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Helper: read agent model from OpenClaw config
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
function readAgentModel(_agentId) {
|
|
19
|
+
try {
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
21
|
+
const fs = require("fs");
|
|
22
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
23
|
+
const path = require("path");
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
25
|
+
const os = require("os");
|
|
26
|
+
const configFile = path.join(os.homedir(), ".openclaw", "openclaw.json");
|
|
27
|
+
if (fs.existsSync(configFile)) {
|
|
28
|
+
const cfg = JSON.parse(fs.readFileSync(configFile, "utf-8"));
|
|
29
|
+
const primary = cfg?.agents?.defaults?.model?.primary;
|
|
30
|
+
if (primary)
|
|
31
|
+
return primary;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch { /* ignore */ }
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
// Connection registry — maps accountId → live WSS client
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
const cloudClients = new Map();
|
|
41
|
+
/** Maps accountId → cloudUrl so handleCloudMessage can resolve relative URLs */
|
|
42
|
+
const cloudUrls = new Map();
|
|
43
|
+
function getCloudClient(accountId) {
|
|
44
|
+
return cloudClients.get(accountId);
|
|
45
|
+
}
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// ChannelPlugin definition
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
export const botschatPlugin = {
|
|
50
|
+
id: "botschat",
|
|
51
|
+
meta: {
|
|
52
|
+
id: "botschat",
|
|
53
|
+
label: "BotsChat",
|
|
54
|
+
selectionLabel: "BotsChat (cloud)",
|
|
55
|
+
docsPath: "/channels/botschat",
|
|
56
|
+
docsLabel: "botschat",
|
|
57
|
+
blurb: "Cloud-based multi-channel chat interface",
|
|
58
|
+
order: 80,
|
|
59
|
+
quickstartAllowFrom: false,
|
|
60
|
+
},
|
|
61
|
+
capabilities: {
|
|
62
|
+
chatTypes: ["direct", "group", "thread"],
|
|
63
|
+
polls: false,
|
|
64
|
+
reactions: false,
|
|
65
|
+
threads: true,
|
|
66
|
+
media: true,
|
|
67
|
+
},
|
|
68
|
+
agentPrompt: {
|
|
69
|
+
messageToolHints: () => A2UI_MESSAGE_TOOL_HINTS,
|
|
70
|
+
},
|
|
71
|
+
reload: { configPrefixes: ["channels.botschat"] },
|
|
72
|
+
config: {
|
|
73
|
+
listAccountIds: (cfg) => listBotsChatAccountIds(cfg),
|
|
74
|
+
resolveAccount: (cfg, accountId) => resolveBotsChatAccount(cfg, accountId),
|
|
75
|
+
defaultAccountId: (cfg) => resolveDefaultBotsChatAccountId(cfg),
|
|
76
|
+
setAccountEnabled: ({ cfg, accountId, enabled }) => setBotsChatAccountEnabled(cfg, accountId, enabled),
|
|
77
|
+
deleteAccount: ({ cfg, accountId }) => deleteBotsChatAccount(cfg, accountId),
|
|
78
|
+
isConfigured: (account) => account.configured,
|
|
79
|
+
isEnabled: (account) => account.enabled,
|
|
80
|
+
describeAccount: (account) => ({
|
|
81
|
+
accountId: account.accountId,
|
|
82
|
+
name: account.name,
|
|
83
|
+
enabled: account.enabled,
|
|
84
|
+
configured: account.configured,
|
|
85
|
+
baseUrl: account.cloudUrl,
|
|
86
|
+
}),
|
|
87
|
+
},
|
|
88
|
+
outbound: {
|
|
89
|
+
deliveryMode: "direct",
|
|
90
|
+
sendText: async (ctx) => {
|
|
91
|
+
const client = getCloudClient(ctx.accountId ?? "default");
|
|
92
|
+
if (!client?.connected) {
|
|
93
|
+
return { ok: false, error: new Error("Not connected to BotsChat cloud") };
|
|
94
|
+
}
|
|
95
|
+
client.send({
|
|
96
|
+
type: "agent.text",
|
|
97
|
+
sessionKey: ctx.to,
|
|
98
|
+
text: ctx.text,
|
|
99
|
+
replyToId: ctx.replyToId ?? undefined,
|
|
100
|
+
threadId: ctx.threadId?.toString(),
|
|
101
|
+
});
|
|
102
|
+
return { ok: true };
|
|
103
|
+
},
|
|
104
|
+
sendMedia: async (ctx) => {
|
|
105
|
+
const client = getCloudClient(ctx.accountId ?? "default");
|
|
106
|
+
if (!client?.connected) {
|
|
107
|
+
return { ok: false, error: new Error("Not connected to BotsChat cloud") };
|
|
108
|
+
}
|
|
109
|
+
if (ctx.mediaUrl) {
|
|
110
|
+
client.send({
|
|
111
|
+
type: "agent.media",
|
|
112
|
+
sessionKey: ctx.to,
|
|
113
|
+
mediaUrl: ctx.mediaUrl,
|
|
114
|
+
caption: ctx.text || undefined,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
client.send({
|
|
119
|
+
type: "agent.text",
|
|
120
|
+
sessionKey: ctx.to,
|
|
121
|
+
text: ctx.text,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return { ok: true };
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
gateway: {
|
|
128
|
+
startAccount: async (ctx) => {
|
|
129
|
+
const { account, accountId, log } = ctx;
|
|
130
|
+
if (!account.configured) {
|
|
131
|
+
log?.warn(`[${accountId}] BotsChat not configured — skipping`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
ctx.setStatus({
|
|
135
|
+
...ctx.getStatus(),
|
|
136
|
+
accountId,
|
|
137
|
+
baseUrl: account.cloudUrl,
|
|
138
|
+
running: true,
|
|
139
|
+
lastStartAt: Date.now(),
|
|
140
|
+
});
|
|
141
|
+
log?.info(`[${accountId}] Starting BotsChat connection to ${account.cloudUrl}`);
|
|
142
|
+
const client = new BotsChatCloudClient({
|
|
143
|
+
cloudUrl: account.cloudUrl,
|
|
144
|
+
accountId,
|
|
145
|
+
pairingToken: account.pairingToken,
|
|
146
|
+
getModel: () => readAgentModel("main"),
|
|
147
|
+
onMessage: (msg) => {
|
|
148
|
+
handleCloudMessage(msg, ctx);
|
|
149
|
+
},
|
|
150
|
+
onStatusChange: (connected) => {
|
|
151
|
+
ctx.setStatus({
|
|
152
|
+
...ctx.getStatus(),
|
|
153
|
+
connected,
|
|
154
|
+
...(connected
|
|
155
|
+
? { lastConnectedAt: Date.now() }
|
|
156
|
+
: { lastDisconnect: { at: Date.now() } }),
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
log,
|
|
160
|
+
});
|
|
161
|
+
cloudClients.set(accountId, client);
|
|
162
|
+
cloudUrls.set(accountId, account.cloudUrl);
|
|
163
|
+
client.connect();
|
|
164
|
+
ctx.abortSignal.addEventListener("abort", () => {
|
|
165
|
+
client.disconnect();
|
|
166
|
+
cloudClients.delete(accountId);
|
|
167
|
+
cloudUrls.delete(accountId);
|
|
168
|
+
});
|
|
169
|
+
return client;
|
|
170
|
+
},
|
|
171
|
+
stopAccount: async (ctx) => {
|
|
172
|
+
const client = cloudClients.get(ctx.accountId);
|
|
173
|
+
if (client) {
|
|
174
|
+
client.disconnect();
|
|
175
|
+
cloudClients.delete(ctx.accountId);
|
|
176
|
+
}
|
|
177
|
+
ctx.setStatus({
|
|
178
|
+
...ctx.getStatus(),
|
|
179
|
+
running: false,
|
|
180
|
+
connected: false,
|
|
181
|
+
lastStopAt: Date.now(),
|
|
182
|
+
});
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
threading: {
|
|
186
|
+
resolveReplyToMode: () => "all",
|
|
187
|
+
buildToolContext: ({ context, hasRepliedRef }) => ({
|
|
188
|
+
currentChannelId: context.To?.trim() || undefined,
|
|
189
|
+
currentChannelProvider: "botschat",
|
|
190
|
+
currentThreadTs: context.MessageThreadId != null ? String(context.MessageThreadId) : context.ReplyToId,
|
|
191
|
+
hasRepliedRef,
|
|
192
|
+
}),
|
|
193
|
+
},
|
|
194
|
+
pairing: {
|
|
195
|
+
idLabel: "botsChatUserId",
|
|
196
|
+
normalizeAllowEntry: (entry) => entry.trim().toLowerCase(),
|
|
197
|
+
},
|
|
198
|
+
security: {
|
|
199
|
+
resolveDmPolicy: (_ctx) => ({
|
|
200
|
+
policy: "token",
|
|
201
|
+
allowFrom: [],
|
|
202
|
+
policyPath: "channels.botschat.pairingToken",
|
|
203
|
+
allowFromPath: "channels.botschat.dm.allowFrom",
|
|
204
|
+
approveHint: "Pair via BotsChat cloud dashboard (get a pairing token at botschat.app)",
|
|
205
|
+
}),
|
|
206
|
+
},
|
|
207
|
+
setup: {
|
|
208
|
+
applyAccountConfig: ({ cfg, input }) => {
|
|
209
|
+
const c = cfg;
|
|
210
|
+
return {
|
|
211
|
+
...c,
|
|
212
|
+
channels: {
|
|
213
|
+
...c?.channels,
|
|
214
|
+
botschat: {
|
|
215
|
+
...c?.channels?.botschat,
|
|
216
|
+
enabled: true,
|
|
217
|
+
cloudUrl: input.url?.trim() ?? c?.channels?.botschat?.cloudUrl ?? "",
|
|
218
|
+
pairingToken: input.token?.trim() ?? c?.channels?.botschat?.pairingToken ?? "",
|
|
219
|
+
...(input.name ? { name: input.name.trim() } : {}),
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
},
|
|
224
|
+
validateInput: ({ input }) => {
|
|
225
|
+
if (input.useEnv)
|
|
226
|
+
return null;
|
|
227
|
+
if (!input.url?.trim())
|
|
228
|
+
return "BotsChat requires --url (e.g., --url botschat.app)";
|
|
229
|
+
if (!input.token?.trim())
|
|
230
|
+
return "BotsChat requires --token (pairing token from botschat.app dashboard)";
|
|
231
|
+
return null;
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
status: {
|
|
235
|
+
defaultRuntime: {
|
|
236
|
+
accountId: "default",
|
|
237
|
+
running: false,
|
|
238
|
+
connected: false,
|
|
239
|
+
lastStartAt: null,
|
|
240
|
+
lastStopAt: null,
|
|
241
|
+
lastError: null,
|
|
242
|
+
},
|
|
243
|
+
buildAccountSnapshot: ({ account, runtime }) => ({
|
|
244
|
+
accountId: account.accountId,
|
|
245
|
+
name: account.name,
|
|
246
|
+
enabled: account.enabled,
|
|
247
|
+
configured: account.configured,
|
|
248
|
+
baseUrl: account.cloudUrl,
|
|
249
|
+
running: runtime?.running ?? false,
|
|
250
|
+
connected: runtime?.connected ?? false,
|
|
251
|
+
lastStartAt: runtime?.lastStartAt ?? null,
|
|
252
|
+
lastStopAt: runtime?.lastStopAt ?? null,
|
|
253
|
+
lastError: runtime?.lastError ?? null,
|
|
254
|
+
lastConnectedAt: runtime?.lastConnectedAt ?? null,
|
|
255
|
+
lastDisconnect: runtime?.lastDisconnect ?? null,
|
|
256
|
+
}),
|
|
257
|
+
collectStatusIssues: (accounts) => accounts.flatMap((a) => {
|
|
258
|
+
const issues = [];
|
|
259
|
+
if (!a.configured) {
|
|
260
|
+
issues.push({
|
|
261
|
+
channel: "botschat",
|
|
262
|
+
accountId: a.accountId,
|
|
263
|
+
kind: "config",
|
|
264
|
+
message: 'Not configured. Run "openclaw channel setup botschat --url <cloud-url> --token <pairing-token>"',
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
if (a.lastError) {
|
|
268
|
+
issues.push({ channel: "botschat", accountId: a.accountId, kind: "runtime", message: `Channel error: ${a.lastError}` });
|
|
269
|
+
}
|
|
270
|
+
return issues;
|
|
271
|
+
}),
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
// Incoming message handler — dispatches cloud messages into the OpenClaw
|
|
276
|
+
// agent pipeline via the runtime.
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
async function handleCloudMessage(msg, ctx) {
|
|
279
|
+
switch (msg.type) {
|
|
280
|
+
case "user.message": {
|
|
281
|
+
ctx.log?.info(`[${ctx.accountId}] Message from ${msg.userId}: ${msg.text.slice(0, 80)}${msg.mediaUrl ? " [+image]" : ""}`);
|
|
282
|
+
try {
|
|
283
|
+
const runtime = getBotsChatRuntime();
|
|
284
|
+
// Load current config
|
|
285
|
+
const cfg = runtime.config?.loadConfig?.() ?? ctx.cfg;
|
|
286
|
+
// Extract threadId from sessionKey pattern: ....:thread:{threadId}
|
|
287
|
+
const threadMatch = msg.sessionKey.match(/:thread:(.+)$/);
|
|
288
|
+
const threadId = threadMatch ? threadMatch[1] : undefined;
|
|
289
|
+
// Build the MsgContext that OpenClaw's dispatch pipeline expects.
|
|
290
|
+
// BotsChat users are authenticated (logged in via the web UI), so
|
|
291
|
+
// mark commands as authorized — this lets directives like /model
|
|
292
|
+
// pass through the command-auth pipeline instead of being silently
|
|
293
|
+
// dropped (the default is false / deny).
|
|
294
|
+
const msgCtx = {
|
|
295
|
+
Body: msg.text,
|
|
296
|
+
RawBody: msg.text,
|
|
297
|
+
CommandBody: msg.text,
|
|
298
|
+
BodyForCommands: msg.text,
|
|
299
|
+
From: `botschat:${msg.userId}`,
|
|
300
|
+
To: msg.sessionKey,
|
|
301
|
+
SessionKey: msg.sessionKey,
|
|
302
|
+
AccountId: ctx.accountId,
|
|
303
|
+
MessageSid: msg.messageId,
|
|
304
|
+
ChatType: threadId ? "thread" : "direct",
|
|
305
|
+
Channel: "botschat",
|
|
306
|
+
MessageChannel: "botschat",
|
|
307
|
+
CommandAuthorized: true,
|
|
308
|
+
// A2UI format instructions are injected via agentPrompt.messageToolHints
|
|
309
|
+
// (inside the message tool docs in the system prompt) — no GroupSystemPrompt needed.
|
|
310
|
+
...(threadId ? { MessageThreadId: threadId, ReplyToId: threadId } : {}),
|
|
311
|
+
// Include image URL if the user sent an image.
|
|
312
|
+
// Resolve relative URLs (e.g. /api/media/...) to absolute using cloudUrl
|
|
313
|
+
// so OpenClaw can fetch the image from the BotsChat cloud.
|
|
314
|
+
...(msg.mediaUrl ? (() => {
|
|
315
|
+
let resolvedUrl = msg.mediaUrl;
|
|
316
|
+
if (resolvedUrl.startsWith("/")) {
|
|
317
|
+
const baseUrl = cloudUrls.get(ctx.accountId);
|
|
318
|
+
if (baseUrl) {
|
|
319
|
+
resolvedUrl = baseUrl.replace(/\/$/, "") + resolvedUrl;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return { MediaUrl: resolvedUrl, NumMedia: "1" };
|
|
323
|
+
})() : {}),
|
|
324
|
+
};
|
|
325
|
+
// Finalize the context (normalizes fields, resolves agent route)
|
|
326
|
+
const finalizedCtx = runtime.channel.reply.finalizeInboundContext(msgCtx);
|
|
327
|
+
// Create a reply dispatcher that sends responses back through the cloud WSS
|
|
328
|
+
const client = getCloudClient(ctx.accountId);
|
|
329
|
+
const deliver = async (payload) => {
|
|
330
|
+
if (!client?.connected)
|
|
331
|
+
return;
|
|
332
|
+
if (payload.mediaUrl) {
|
|
333
|
+
client.send({
|
|
334
|
+
type: "agent.media",
|
|
335
|
+
sessionKey: msg.sessionKey,
|
|
336
|
+
mediaUrl: payload.mediaUrl,
|
|
337
|
+
caption: payload.text,
|
|
338
|
+
threadId,
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
else if (payload.text) {
|
|
342
|
+
client.send({
|
|
343
|
+
type: "agent.text",
|
|
344
|
+
sessionKey: msg.sessionKey,
|
|
345
|
+
text: payload.text,
|
|
346
|
+
threadId,
|
|
347
|
+
});
|
|
348
|
+
// Detect model-change confirmations and emit model.changed
|
|
349
|
+
// Handles both formats:
|
|
350
|
+
// "Model set to provider/model." (no parentheses)
|
|
351
|
+
// "Model set to Friendly Name (provider/model)." (with parentheses)
|
|
352
|
+
const modelMatch = payload.text.match(/Model (?:set to|reset to default)\b.*?([a-zA-Z0-9_-]+(?:\.[a-zA-Z0-9_-]+)*\/[a-zA-Z0-9_-]+(?:\.[a-zA-Z0-9_-]+)*)/);
|
|
353
|
+
if (modelMatch) {
|
|
354
|
+
client.send({
|
|
355
|
+
type: "model.changed",
|
|
356
|
+
model: modelMatch[1],
|
|
357
|
+
sessionKey: msg.sessionKey,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
};
|
|
362
|
+
// --- Streaming support ---
|
|
363
|
+
// Generate a runId to correlate stream events for this reply.
|
|
364
|
+
const runId = `run_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
365
|
+
let streamStarted = false;
|
|
366
|
+
const onPartialReply = (payload) => {
|
|
367
|
+
if (!client?.connected || !payload.text)
|
|
368
|
+
return;
|
|
369
|
+
// Send stream start on first chunk
|
|
370
|
+
if (!streamStarted) {
|
|
371
|
+
streamStarted = true;
|
|
372
|
+
client.send({
|
|
373
|
+
type: "agent.stream.start",
|
|
374
|
+
sessionKey: msg.sessionKey,
|
|
375
|
+
runId,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
// Send the accumulated text so far
|
|
379
|
+
client.send({
|
|
380
|
+
type: "agent.stream.chunk",
|
|
381
|
+
sessionKey: msg.sessionKey,
|
|
382
|
+
runId,
|
|
383
|
+
text: payload.text,
|
|
384
|
+
});
|
|
385
|
+
};
|
|
386
|
+
// Use dispatchReplyFromConfig with a simple dispatcher
|
|
387
|
+
const { dispatcher, replyOptions, markDispatchIdle } = runtime.channel.reply.createReplyDispatcherWithTyping({
|
|
388
|
+
deliver: async (payload) => {
|
|
389
|
+
// The payload from the dispatcher is a ReplyPayload
|
|
390
|
+
const p = payload;
|
|
391
|
+
await deliver(p);
|
|
392
|
+
},
|
|
393
|
+
onTypingStart: () => { },
|
|
394
|
+
onTypingStop: () => { },
|
|
395
|
+
});
|
|
396
|
+
await runtime.channel.reply.dispatchReplyFromConfig({
|
|
397
|
+
ctx: finalizedCtx,
|
|
398
|
+
cfg,
|
|
399
|
+
dispatcher,
|
|
400
|
+
replyOptions: {
|
|
401
|
+
...replyOptions,
|
|
402
|
+
onPartialReply,
|
|
403
|
+
allowPartialStream: true,
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
// Send stream end if streaming was active
|
|
407
|
+
if (streamStarted && client?.connected) {
|
|
408
|
+
client.send({
|
|
409
|
+
type: "agent.stream.end",
|
|
410
|
+
sessionKey: msg.sessionKey,
|
|
411
|
+
runId,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
markDispatchIdle();
|
|
415
|
+
}
|
|
416
|
+
catch (err) {
|
|
417
|
+
ctx.log?.error(`[${ctx.accountId}] Failed to dispatch message: ${err}`);
|
|
418
|
+
}
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
case "user.command":
|
|
422
|
+
ctx.log?.info(`[${ctx.accountId}] Command /${msg.command} in session ${msg.sessionKey}`);
|
|
423
|
+
// Commands are handled the same way — feed as a message with / prefix
|
|
424
|
+
await handleCloudMessage({
|
|
425
|
+
type: "user.message",
|
|
426
|
+
sessionKey: msg.sessionKey,
|
|
427
|
+
text: `/${msg.command}${msg.args ? ` ${msg.args}` : ""}`,
|
|
428
|
+
userId: "command",
|
|
429
|
+
messageId: `cmd-${Date.now()}`,
|
|
430
|
+
}, ctx);
|
|
431
|
+
break;
|
|
432
|
+
case "user.action":
|
|
433
|
+
ctx.log?.info(`[${ctx.accountId}] A2UI action ${msg.action} in session ${msg.sessionKey}`);
|
|
434
|
+
// Feed the user's A2UI interaction back to the agent as a message.
|
|
435
|
+
// This lets the agent continue the conversation based on what the
|
|
436
|
+
// user clicked/selected in the interactive UI component.
|
|
437
|
+
{
|
|
438
|
+
const actionParams = msg.params ?? {};
|
|
439
|
+
const kind = actionParams.kind ?? msg.action ?? "action";
|
|
440
|
+
const value = actionParams.value ?? actionParams.selected ?? "";
|
|
441
|
+
const label = actionParams.label ?? value;
|
|
442
|
+
const actionText = `[Action: kind=${kind}] User selected: "${label}"`;
|
|
443
|
+
await handleCloudMessage({
|
|
444
|
+
type: "user.message",
|
|
445
|
+
sessionKey: msg.sessionKey,
|
|
446
|
+
text: actionText,
|
|
447
|
+
userId: actionParams.userId ?? "action",
|
|
448
|
+
messageId: `action-${Date.now()}`,
|
|
449
|
+
}, ctx);
|
|
450
|
+
}
|
|
451
|
+
break;
|
|
452
|
+
case "user.media":
|
|
453
|
+
ctx.log?.info(`[${ctx.accountId}] Media from user in session ${msg.sessionKey}: ${msg.mediaUrl}`);
|
|
454
|
+
// Handle as a user.message with mediaUrl so the agent can process the image
|
|
455
|
+
await handleCloudMessage({
|
|
456
|
+
type: "user.message",
|
|
457
|
+
sessionKey: msg.sessionKey,
|
|
458
|
+
text: "",
|
|
459
|
+
userId: msg.userId,
|
|
460
|
+
messageId: `media-${Date.now()}`,
|
|
461
|
+
mediaUrl: msg.mediaUrl,
|
|
462
|
+
}, ctx);
|
|
463
|
+
break;
|
|
464
|
+
case "config.request":
|
|
465
|
+
ctx.log?.info(`[${ctx.accountId}] Config request: ${msg.method}`);
|
|
466
|
+
break;
|
|
467
|
+
// ---- Task management messages from BotsChat cloud ----
|
|
468
|
+
case "task.schedule":
|
|
469
|
+
ctx.log?.info(`[${ctx.accountId}] Schedule task: cronJobId=${msg.cronJobId} schedule=${msg.schedule}`);
|
|
470
|
+
await handleTaskSchedule(msg, ctx);
|
|
471
|
+
break;
|
|
472
|
+
case "task.delete":
|
|
473
|
+
ctx.log?.info(`[${ctx.accountId}] Delete task: cronJobId=${msg.cronJobId}`);
|
|
474
|
+
await handleTaskDelete(msg, ctx);
|
|
475
|
+
break;
|
|
476
|
+
case "task.run":
|
|
477
|
+
ctx.log?.info(`[${ctx.accountId}] Run task now: cronJobId=${msg.cronJobId} agentId=${msg.agentId}`);
|
|
478
|
+
await handleTaskRun(msg, ctx);
|
|
479
|
+
break;
|
|
480
|
+
case "task.scan.request":
|
|
481
|
+
ctx.log?.info(`[${ctx.accountId}] Task scan requested by cloud`);
|
|
482
|
+
await handleTaskScanRequest(ctx);
|
|
483
|
+
break;
|
|
484
|
+
case "models.request":
|
|
485
|
+
ctx.log?.info(`[${ctx.accountId}] Models list requested by cloud`);
|
|
486
|
+
await handleModelsRequest(ctx);
|
|
487
|
+
break;
|
|
488
|
+
default:
|
|
489
|
+
break;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
// Task scheduling — configure CronJobs in OpenClaw via runtime
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
/**
|
|
496
|
+
* Convert a human-readable schedule string to OpenClaw's schedule object format.
|
|
497
|
+
* "every 30m" → { kind: "every", everyMs: 1800000 }
|
|
498
|
+
* "every 2h" → { kind: "every", everyMs: 7200000 }
|
|
499
|
+
* "every 10s" → { kind: "every", everyMs: 10000 }
|
|
500
|
+
* "at 09:00" → { kind: "at", at: "09:00" }
|
|
501
|
+
*/
|
|
502
|
+
function parseScheduleToOpenClaw(schedule) {
|
|
503
|
+
if (!schedule)
|
|
504
|
+
return null;
|
|
505
|
+
// Interval: "every {N}{s|m|h}"
|
|
506
|
+
const everyMatch = schedule.match(/^every\s+(\d+(?:\.\d+)?)\s*(s|m|h)$/i);
|
|
507
|
+
if (everyMatch) {
|
|
508
|
+
const value = parseFloat(everyMatch[1]);
|
|
509
|
+
const unit = everyMatch[2].toLowerCase();
|
|
510
|
+
let everyMs;
|
|
511
|
+
if (unit === "s")
|
|
512
|
+
everyMs = value * 1000;
|
|
513
|
+
else if (unit === "m")
|
|
514
|
+
everyMs = value * 60000;
|
|
515
|
+
else
|
|
516
|
+
everyMs = value * 3600000; // h
|
|
517
|
+
return { kind: "every", everyMs };
|
|
518
|
+
}
|
|
519
|
+
// Daily: "at HH:MM"
|
|
520
|
+
const atMatch = schedule.match(/^at\s+(\d{1,2}:\d{2})$/i);
|
|
521
|
+
if (atMatch) {
|
|
522
|
+
return { kind: "at", at: atMatch[1] };
|
|
523
|
+
}
|
|
524
|
+
return null;
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Run `openclaw cron edit` to hot-update the CronService.
|
|
528
|
+
* This uses the gateway's RPC (via CLI) so the in-memory scheduler is updated
|
|
529
|
+
* immediately — no gateway restart needed.
|
|
530
|
+
*/
|
|
531
|
+
async function openclawCronEdit(cronJobId, args, log) {
|
|
532
|
+
const { execFile } = await import("child_process");
|
|
533
|
+
const { promisify } = await import("util");
|
|
534
|
+
const execFileAsync = promisify(execFile);
|
|
535
|
+
const fullArgs = ["cron", "edit", cronJobId, ...args];
|
|
536
|
+
log?.info(`Running: openclaw ${fullArgs.join(" ")}`);
|
|
537
|
+
try {
|
|
538
|
+
const { stdout, stderr } = await execFileAsync("openclaw", fullArgs, {
|
|
539
|
+
timeout: 15_000,
|
|
540
|
+
env: { ...process.env, PATH: `/opt/homebrew/bin:${process.env.PATH}` },
|
|
541
|
+
});
|
|
542
|
+
if (stderr?.trim())
|
|
543
|
+
log?.warn(`openclaw cron edit stderr: ${stderr.trim()}`);
|
|
544
|
+
if (stdout?.trim())
|
|
545
|
+
log?.info(`openclaw cron edit: ${stdout.trim()}`);
|
|
546
|
+
return { ok: true };
|
|
547
|
+
}
|
|
548
|
+
catch (err) {
|
|
549
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
550
|
+
log?.error(`openclaw cron edit failed: ${message}`);
|
|
551
|
+
return { ok: false, error: message };
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Run `openclaw cron add` to create a new cron job.
|
|
556
|
+
* Returns the OpenClaw-generated job ID.
|
|
557
|
+
*/
|
|
558
|
+
async function openclawCronAdd(msg, log) {
|
|
559
|
+
const { execFile } = await import("child_process");
|
|
560
|
+
const { promisify } = await import("util");
|
|
561
|
+
const execFileAsync = promisify(execFile);
|
|
562
|
+
const args = ["cron", "add"];
|
|
563
|
+
// Name (required by openclaw cron add)
|
|
564
|
+
args.push("--name", msg.name || "BotsChat Task");
|
|
565
|
+
// Schedule
|
|
566
|
+
const s = (msg.schedule || "").trim();
|
|
567
|
+
if (/^at\s+/i.test(s)) {
|
|
568
|
+
args.push("--at", s.replace(/^at\s+/i, ""));
|
|
569
|
+
}
|
|
570
|
+
else if (s) {
|
|
571
|
+
args.push("--every", s.replace(/^every\s+/i, ""));
|
|
572
|
+
}
|
|
573
|
+
// Payload
|
|
574
|
+
args.push("--message", msg.instructions || "Run your scheduled task.");
|
|
575
|
+
args.push("--session", "isolated");
|
|
576
|
+
if (msg.agentId)
|
|
577
|
+
args.push("--agent", msg.agentId);
|
|
578
|
+
if (msg.model)
|
|
579
|
+
args.push("--model", msg.model);
|
|
580
|
+
if (!msg.enabled)
|
|
581
|
+
args.push("--disabled");
|
|
582
|
+
args.push("--json");
|
|
583
|
+
log?.info(`Running: openclaw ${args.join(" ")}`);
|
|
584
|
+
try {
|
|
585
|
+
const { stdout } = await execFileAsync("openclaw", args, {
|
|
586
|
+
timeout: 15_000,
|
|
587
|
+
env: { ...process.env, PATH: `/opt/homebrew/bin:${process.env.PATH}` },
|
|
588
|
+
});
|
|
589
|
+
// Parse the JSON output to get the generated ID.
|
|
590
|
+
// stdout may contain Config warnings before the JSON — extract
|
|
591
|
+
// the last {...} block.
|
|
592
|
+
const jsonMatch = stdout.match(/\{[\s\S]*\}/);
|
|
593
|
+
if (!jsonMatch) {
|
|
594
|
+
return { ok: false, error: `openclaw cron add: no JSON in output: ${stdout.slice(0, 200)}` };
|
|
595
|
+
}
|
|
596
|
+
const result = JSON.parse(jsonMatch[0]);
|
|
597
|
+
const cronJobId = result.id;
|
|
598
|
+
if (!cronJobId) {
|
|
599
|
+
return { ok: false, error: "openclaw cron add returned no id" };
|
|
600
|
+
}
|
|
601
|
+
return { ok: true, cronJobId };
|
|
602
|
+
}
|
|
603
|
+
catch (err) {
|
|
604
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
605
|
+
log?.error(`openclaw cron add failed: ${message}`);
|
|
606
|
+
return { ok: false, error: message };
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Check if a cron job exists in OpenClaw by reading jobs.json.
|
|
611
|
+
*/
|
|
612
|
+
async function cronJobExists(cronJobId) {
|
|
613
|
+
try {
|
|
614
|
+
const os = await import("os");
|
|
615
|
+
const fs = await import("fs");
|
|
616
|
+
const path = await import("path");
|
|
617
|
+
const cronFile = path.join(os.homedir(), ".openclaw", "cron", "jobs.json");
|
|
618
|
+
if (!fs.existsSync(cronFile))
|
|
619
|
+
return false;
|
|
620
|
+
const data = JSON.parse(fs.readFileSync(cronFile, "utf-8"));
|
|
621
|
+
return Array.isArray(data.jobs) && data.jobs.some((j) => j.id === cronJobId);
|
|
622
|
+
}
|
|
623
|
+
catch {
|
|
624
|
+
return false;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
async function handleTaskSchedule(msg, ctx) {
|
|
628
|
+
const client = getCloudClient(ctx.accountId);
|
|
629
|
+
try {
|
|
630
|
+
const exists = msg.cronJobId ? await cronJobExists(msg.cronJobId) : false;
|
|
631
|
+
if (exists) {
|
|
632
|
+
// Update existing job via `openclaw cron edit` (hot-updates CronService)
|
|
633
|
+
const args = [];
|
|
634
|
+
if (msg.schedule) {
|
|
635
|
+
const s = msg.schedule.trim();
|
|
636
|
+
if (/^at\s+/i.test(s)) {
|
|
637
|
+
args.push("--at", s.replace(/^at\s+/i, ""));
|
|
638
|
+
}
|
|
639
|
+
else {
|
|
640
|
+
args.push("--every", s.replace(/^every\s+/i, ""));
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
// Always send --message to ensure payload.kind="agentTurn" is set
|
|
644
|
+
// (required for isolated session jobs). If no new instructions, read
|
|
645
|
+
// the existing ones from jobs.json.
|
|
646
|
+
const messageText = msg.instructions || (await readCronJobConfig(msg.cronJobId)).instructions || "Run your scheduled task.";
|
|
647
|
+
args.push("--message", messageText);
|
|
648
|
+
if (msg.model)
|
|
649
|
+
args.push("--model", msg.model);
|
|
650
|
+
if (msg.enabled)
|
|
651
|
+
args.push("--enable");
|
|
652
|
+
else
|
|
653
|
+
args.push("--disable");
|
|
654
|
+
const result = await openclawCronEdit(msg.cronJobId, args, ctx.log);
|
|
655
|
+
if (!result.ok) {
|
|
656
|
+
client?.send({ type: "task.schedule.ack", cronJobId: msg.cronJobId, taskId: msg.taskId, ok: false, error: result.error });
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
ctx.log?.info(`[${ctx.accountId}] Updated cron job ${msg.cronJobId}: ${msg.schedule}`);
|
|
660
|
+
client?.send({ type: "task.schedule.ack", cronJobId: msg.cronJobId, taskId: msg.taskId, ok: true });
|
|
661
|
+
}
|
|
662
|
+
else {
|
|
663
|
+
// New job: use `openclaw cron add --json` (hot-adds to CronService)
|
|
664
|
+
ctx.log?.info(`[${ctx.accountId}] Creating new cron job via openclaw cron add`);
|
|
665
|
+
const addResult = await openclawCronAdd(msg, ctx.log);
|
|
666
|
+
if (!addResult.ok) {
|
|
667
|
+
client?.send({ type: "task.schedule.ack", cronJobId: msg.cronJobId, taskId: msg.taskId, ok: false, error: addResult.error });
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
// Return the OpenClaw-generated ID + taskId so DO can update D1
|
|
671
|
+
ctx.log?.info(`[${ctx.accountId}] Created cron job ${addResult.cronJobId}: ${msg.schedule}`);
|
|
672
|
+
client?.send({ type: "task.schedule.ack", cronJobId: addResult.cronJobId, taskId: msg.taskId, ok: true });
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
catch (err) {
|
|
676
|
+
ctx.log?.error(`[${ctx.accountId}] Failed to schedule task: ${err}`);
|
|
677
|
+
client?.send({ type: "task.schedule.ack", cronJobId: msg.cronJobId, taskId: msg.taskId, ok: false, error: String(err) });
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
async function handleTaskDelete(msg, ctx) {
|
|
681
|
+
try {
|
|
682
|
+
const { execFile } = await import("child_process");
|
|
683
|
+
const { promisify } = await import("util");
|
|
684
|
+
const execFileAsync = promisify(execFile);
|
|
685
|
+
ctx.log?.info(`[${ctx.accountId}] Removing cron job ${msg.cronJobId} via openclaw cron rm`);
|
|
686
|
+
await execFileAsync("openclaw", ["cron", "rm", msg.cronJobId], {
|
|
687
|
+
timeout: 15_000,
|
|
688
|
+
env: { ...process.env, PATH: `/opt/homebrew/bin:${process.env.PATH}` },
|
|
689
|
+
});
|
|
690
|
+
ctx.log?.info(`[${ctx.accountId}] Removed cron job ${msg.cronJobId}`);
|
|
691
|
+
}
|
|
692
|
+
catch (err) {
|
|
693
|
+
ctx.log?.error(`[${ctx.accountId}] Failed to delete task: ${err}`);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
// ---------------------------------------------------------------------------
|
|
697
|
+
// task.run — execute a cron job immediately on demand
|
|
698
|
+
// ---------------------------------------------------------------------------
|
|
699
|
+
/**
|
|
700
|
+
* Read instructions and model for a cron job from OpenClaw's jobs.json (the single source of truth).
|
|
701
|
+
*/
|
|
702
|
+
async function readCronJobConfig(cronJobId) {
|
|
703
|
+
try {
|
|
704
|
+
const os = await import("os");
|
|
705
|
+
const fs = await import("fs");
|
|
706
|
+
const path = await import("path");
|
|
707
|
+
const cronFile = path.join(os.homedir(), ".openclaw", "cron", "jobs.json");
|
|
708
|
+
if (fs.existsSync(cronFile)) {
|
|
709
|
+
const data = JSON.parse(fs.readFileSync(cronFile, "utf-8"));
|
|
710
|
+
const job = (data.jobs ?? []).find((j) => j.id === cronJobId);
|
|
711
|
+
if (job) {
|
|
712
|
+
let instructions = "";
|
|
713
|
+
if (typeof job.payload === "string") {
|
|
714
|
+
instructions = job.payload;
|
|
715
|
+
}
|
|
716
|
+
else if (job.payload && typeof job.payload === "object") {
|
|
717
|
+
instructions = job.payload.message ?? job.payload.text ?? job.payload.prompt ?? "";
|
|
718
|
+
}
|
|
719
|
+
return { instructions, model: job.model };
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
catch { /* ignore */ }
|
|
724
|
+
return { instructions: "" };
|
|
725
|
+
}
|
|
726
|
+
async function handleTaskRun(msg, ctx) {
|
|
727
|
+
const client = getCloudClient(ctx.accountId);
|
|
728
|
+
if (!client?.connected) {
|
|
729
|
+
ctx.log?.error(`[${ctx.accountId}] Cannot run task — not connected`);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
const now = Date.now();
|
|
733
|
+
const jobId = `job_run_${msg.cronJobId}_${now}`;
|
|
734
|
+
const agentId = msg.agentId || "main";
|
|
735
|
+
// Use a unique sessionKey per run so each cron execution starts with a
|
|
736
|
+
// fresh context. Previously all runs shared a single session, which
|
|
737
|
+
// caused the context to grow unboundedly (browser screenshots, tool
|
|
738
|
+
// results, etc.) until the model provider rejected the request body
|
|
739
|
+
// (HTTP 422 "Unsupported request body").
|
|
740
|
+
const sessionKey = `agent:${agentId}:cron:${msg.cronJobId}:run:${now}`;
|
|
741
|
+
const startedAt = Math.floor(now / 1000);
|
|
742
|
+
// Immediately send "running" status
|
|
743
|
+
client.send({
|
|
744
|
+
type: "job.update",
|
|
745
|
+
cronJobId: msg.cronJobId,
|
|
746
|
+
jobId,
|
|
747
|
+
sessionKey,
|
|
748
|
+
status: "running",
|
|
749
|
+
startedAt,
|
|
750
|
+
});
|
|
751
|
+
ctx.log?.info(`[${ctx.accountId}] Task ${msg.cronJobId} started (jobId=${jobId})`);
|
|
752
|
+
let summary = "";
|
|
753
|
+
let status = "ok";
|
|
754
|
+
try {
|
|
755
|
+
const runtime = getBotsChatRuntime();
|
|
756
|
+
// First try: use runtime.cron.runJobNow if available
|
|
757
|
+
if (runtime.cron?.runJobNow) {
|
|
758
|
+
ctx.log?.info(`[${ctx.accountId}] Using runtime.cron.runJobNow`);
|
|
759
|
+
await runtime.cron.runJobNow(msg.cronJobId);
|
|
760
|
+
// Read the output from session file
|
|
761
|
+
summary = await readLastSessionOutput(agentId, msg.cronJobId, ctx);
|
|
762
|
+
}
|
|
763
|
+
else if (runtime.cron?.triggerJob) {
|
|
764
|
+
ctx.log?.info(`[${ctx.accountId}] Using runtime.cron.triggerJob`);
|
|
765
|
+
await runtime.cron.triggerJob(msg.cronJobId);
|
|
766
|
+
summary = await readLastSessionOutput(agentId, msg.cronJobId, ctx);
|
|
767
|
+
}
|
|
768
|
+
else {
|
|
769
|
+
// Fallback: dispatch the instructions as a user message through the agent pipeline
|
|
770
|
+
ctx.log?.info(`[${ctx.accountId}] Fallback: dispatching instructions via agent pipeline`);
|
|
771
|
+
// Read instructions from OpenClaw's jobs.json (single source of truth),
|
|
772
|
+
// falling back to msg.instructions for backward compatibility.
|
|
773
|
+
const jobConfig = await readCronJobConfig(msg.cronJobId);
|
|
774
|
+
const instructions = jobConfig.instructions || msg.instructions || "Run your scheduled task now.";
|
|
775
|
+
const cfg = runtime.config?.loadConfig?.() ?? ctx.cfg;
|
|
776
|
+
const msgCtx = {
|
|
777
|
+
Body: instructions,
|
|
778
|
+
RawBody: instructions,
|
|
779
|
+
CommandBody: instructions,
|
|
780
|
+
BodyForCommands: instructions,
|
|
781
|
+
From: `botschat:cron:${msg.cronJobId}`,
|
|
782
|
+
To: sessionKey,
|
|
783
|
+
SessionKey: sessionKey,
|
|
784
|
+
AccountId: ctx.accountId,
|
|
785
|
+
MessageSid: `cron-run-${Date.now()}`,
|
|
786
|
+
ChatType: "direct",
|
|
787
|
+
Channel: "botschat",
|
|
788
|
+
MessageChannel: "botschat",
|
|
789
|
+
CommandAuthorized: true,
|
|
790
|
+
};
|
|
791
|
+
const finalizedCtx = runtime.channel.reply.finalizeInboundContext(msgCtx);
|
|
792
|
+
// Collect the agent's reply as summary + stream output in real-time
|
|
793
|
+
// We accumulate completed message blocks and current streaming text.
|
|
794
|
+
// The frontend receives the full accumulated text each time and renders
|
|
795
|
+
// each block (separated by \n\n---\n\n) as a stacked message card.
|
|
796
|
+
const completedParts = [];
|
|
797
|
+
let currentStreamText = "";
|
|
798
|
+
let sendTimer = null;
|
|
799
|
+
const THROTTLE_MS = 200;
|
|
800
|
+
const getFullText = () => {
|
|
801
|
+
const parts = [...completedParts];
|
|
802
|
+
if (currentStreamText)
|
|
803
|
+
parts.push(currentStreamText);
|
|
804
|
+
return parts.join("\n\n---\n\n");
|
|
805
|
+
};
|
|
806
|
+
const sendOutput = () => {
|
|
807
|
+
if (!client?.connected)
|
|
808
|
+
return;
|
|
809
|
+
client.send({
|
|
810
|
+
type: "job.output",
|
|
811
|
+
cronJobId: msg.cronJobId,
|
|
812
|
+
jobId,
|
|
813
|
+
text: getFullText(),
|
|
814
|
+
});
|
|
815
|
+
};
|
|
816
|
+
const throttledSendOutput = () => {
|
|
817
|
+
if (sendTimer)
|
|
818
|
+
return; // already scheduled
|
|
819
|
+
sendTimer = setTimeout(() => {
|
|
820
|
+
sendTimer = null;
|
|
821
|
+
sendOutput();
|
|
822
|
+
}, THROTTLE_MS);
|
|
823
|
+
};
|
|
824
|
+
const deliver = async (payload) => {
|
|
825
|
+
if (payload.text) {
|
|
826
|
+
completedParts.push(payload.text);
|
|
827
|
+
currentStreamText = "";
|
|
828
|
+
// Flush immediately on completed message
|
|
829
|
+
if (sendTimer) {
|
|
830
|
+
clearTimeout(sendTimer);
|
|
831
|
+
sendTimer = null;
|
|
832
|
+
}
|
|
833
|
+
sendOutput();
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
// Stream partial output in real-time via job.output (throttled)
|
|
837
|
+
const onPartialReply = (payload) => {
|
|
838
|
+
if (!client?.connected || !payload.text)
|
|
839
|
+
return;
|
|
840
|
+
currentStreamText = payload.text;
|
|
841
|
+
throttledSendOutput();
|
|
842
|
+
};
|
|
843
|
+
const { dispatcher, replyOptions, markDispatchIdle } = runtime.channel.reply.createReplyDispatcherWithTyping({
|
|
844
|
+
deliver: async (payload) => {
|
|
845
|
+
const p = payload;
|
|
846
|
+
await deliver(p);
|
|
847
|
+
},
|
|
848
|
+
onTypingStart: () => { },
|
|
849
|
+
onTypingStop: () => { },
|
|
850
|
+
});
|
|
851
|
+
await runtime.channel.reply.dispatchReplyFromConfig({
|
|
852
|
+
ctx: finalizedCtx,
|
|
853
|
+
cfg,
|
|
854
|
+
dispatcher,
|
|
855
|
+
replyOptions: {
|
|
856
|
+
...replyOptions,
|
|
857
|
+
onPartialReply,
|
|
858
|
+
allowPartialStream: true,
|
|
859
|
+
},
|
|
860
|
+
});
|
|
861
|
+
markDispatchIdle();
|
|
862
|
+
// Flush any pending throttled output
|
|
863
|
+
if (sendTimer) {
|
|
864
|
+
clearTimeout(sendTimer);
|
|
865
|
+
sendTimer = null;
|
|
866
|
+
}
|
|
867
|
+
summary = completedParts.join("\n\n---\n\n");
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
catch (err) {
|
|
871
|
+
status = "error";
|
|
872
|
+
summary = `Task failed: ${String(err)}`;
|
|
873
|
+
ctx.log?.error(`[${ctx.accountId}] Task ${msg.cronJobId} failed: ${err}`);
|
|
874
|
+
}
|
|
875
|
+
const finishedAt = Math.floor(Date.now() / 1000);
|
|
876
|
+
const durationMs = (finishedAt - startedAt) * 1000;
|
|
877
|
+
// Send final status
|
|
878
|
+
client.send({
|
|
879
|
+
type: "job.update",
|
|
880
|
+
cronJobId: msg.cronJobId,
|
|
881
|
+
jobId,
|
|
882
|
+
sessionKey,
|
|
883
|
+
status,
|
|
884
|
+
summary,
|
|
885
|
+
startedAt,
|
|
886
|
+
finishedAt,
|
|
887
|
+
durationMs,
|
|
888
|
+
});
|
|
889
|
+
ctx.log?.info(`[${ctx.accountId}] Task ${msg.cronJobId} finished: status=${status} duration=${durationMs}ms`);
|
|
890
|
+
}
|
|
891
|
+
/**
|
|
892
|
+
* Layer 1 — Read cron run log entries directly from
|
|
893
|
+
* ~/.openclaw/cron/runs/{jobId}.jsonl (most recent last).
|
|
894
|
+
*/
|
|
895
|
+
async function readCronRunLog(jobId, limit = 5) {
|
|
896
|
+
try {
|
|
897
|
+
const os = await import("os");
|
|
898
|
+
const fs = await import("fs");
|
|
899
|
+
const path = await import("path");
|
|
900
|
+
const logFile = path.join(os.homedir(), ".openclaw", "cron", "runs", `${jobId}.jsonl`);
|
|
901
|
+
if (!fs.existsSync(logFile))
|
|
902
|
+
return [];
|
|
903
|
+
const stat = fs.statSync(logFile);
|
|
904
|
+
const readSize = Math.min(stat.size, 32768);
|
|
905
|
+
const buf = Buffer.alloc(readSize);
|
|
906
|
+
const fd = fs.openSync(logFile, "r");
|
|
907
|
+
fs.readSync(fd, buf, 0, readSize, Math.max(0, stat.size - readSize));
|
|
908
|
+
fs.closeSync(fd);
|
|
909
|
+
const tail = buf.toString("utf-8");
|
|
910
|
+
const lines = tail.split("\n").filter(Boolean);
|
|
911
|
+
const entries = [];
|
|
912
|
+
for (let i = lines.length - 1; i >= 0 && entries.length < limit; i--) {
|
|
913
|
+
try {
|
|
914
|
+
const obj = JSON.parse(lines[i]);
|
|
915
|
+
if (obj?.action === "finished" && obj.jobId === jobId) {
|
|
916
|
+
entries.push(obj);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
catch { /* skip malformed line */ }
|
|
920
|
+
}
|
|
921
|
+
return entries.reverse(); // chronological order
|
|
922
|
+
}
|
|
923
|
+
catch {
|
|
924
|
+
return [];
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Layer 2 — CLI fallback: `openclaw cron runs --id <jobId> --limit <n>`.
|
|
929
|
+
* Uses the Gateway RPC under the hood, returns the same data as Layer 1.
|
|
930
|
+
*/
|
|
931
|
+
async function readCronRunLogViaCli(jobId, limit = 5, log) {
|
|
932
|
+
try {
|
|
933
|
+
const { execFile } = await import("child_process");
|
|
934
|
+
const { promisify } = await import("util");
|
|
935
|
+
const execFileAsync = promisify(execFile);
|
|
936
|
+
const { stdout } = await execFileAsync("openclaw", ["cron", "runs", "--id", jobId, "--limit", String(limit)], {
|
|
937
|
+
timeout: 15_000,
|
|
938
|
+
env: { ...process.env, PATH: `/opt/homebrew/bin:${process.env.PATH}` },
|
|
939
|
+
});
|
|
940
|
+
const result = JSON.parse(stdout.trim());
|
|
941
|
+
return (result?.entries ?? []);
|
|
942
|
+
}
|
|
943
|
+
catch (err) {
|
|
944
|
+
log?.warn?.(`CLI openclaw cron runs failed for ${jobId}: ${err}`);
|
|
945
|
+
return [];
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* Layer 3 — Read assistant output from a session JSONL file by sessionId.
|
|
950
|
+
* Returns the last assistant text and model used.
|
|
951
|
+
*/
|
|
952
|
+
async function readSessionOutputById(agentId, sessionId) {
|
|
953
|
+
try {
|
|
954
|
+
const os = await import("os");
|
|
955
|
+
const fs = await import("fs");
|
|
956
|
+
const path = await import("path");
|
|
957
|
+
const jsonlFile = path.join(os.homedir(), ".openclaw", "agents", agentId, "sessions", `${sessionId}.jsonl`);
|
|
958
|
+
if (!fs.existsSync(jsonlFile))
|
|
959
|
+
return { text: "" };
|
|
960
|
+
const stat = fs.statSync(jsonlFile);
|
|
961
|
+
const readSize = Math.min(stat.size, 16384);
|
|
962
|
+
const buf = Buffer.alloc(readSize);
|
|
963
|
+
const fd = fs.openSync(jsonlFile, "r");
|
|
964
|
+
fs.readSync(fd, buf, 0, readSize, Math.max(0, stat.size - readSize));
|
|
965
|
+
fs.closeSync(fd);
|
|
966
|
+
const tail = buf.toString("utf-8");
|
|
967
|
+
const lines = tail.split("\n").filter(Boolean);
|
|
968
|
+
let text = "";
|
|
969
|
+
let model;
|
|
970
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
971
|
+
try {
|
|
972
|
+
const entry = JSON.parse(lines[i]);
|
|
973
|
+
if (entry?.message?.role === "assistant") {
|
|
974
|
+
if (!model && entry.message.model) {
|
|
975
|
+
model = entry.message.model;
|
|
976
|
+
}
|
|
977
|
+
if (!text && Array.isArray(entry.message.content)) {
|
|
978
|
+
const textPart = entry.message.content.find((c) => c.type === "text" && c.text);
|
|
979
|
+
if (textPart)
|
|
980
|
+
text = textPart.text;
|
|
981
|
+
}
|
|
982
|
+
if (text && model)
|
|
983
|
+
break;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
catch { /* skip */ }
|
|
987
|
+
}
|
|
988
|
+
return { text, model };
|
|
989
|
+
}
|
|
990
|
+
catch {
|
|
991
|
+
return { text: "" };
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
/**
|
|
995
|
+
* Read the last cron output using 3-layer strategy:
|
|
996
|
+
* 1. Run log JSONL (~/.openclaw/cron/runs/{jobId}.jsonl) → summary
|
|
997
|
+
* 2. CLI fallback (openclaw cron runs) → same data
|
|
998
|
+
* 3. Session JSONL (~/.openclaw/agents/.../sessions/{sessionId}.jsonl) → full output
|
|
999
|
+
*/
|
|
1000
|
+
async function readLastSessionOutput(agentId, cronJobId, ctx) {
|
|
1001
|
+
// Layer 1: read run log directly
|
|
1002
|
+
let entries = await readCronRunLog(cronJobId, 1);
|
|
1003
|
+
// Layer 2: CLI fallback
|
|
1004
|
+
if (entries.length === 0) {
|
|
1005
|
+
ctx.log?.info?.(`Run log empty for ${cronJobId}, trying CLI fallback`);
|
|
1006
|
+
entries = await readCronRunLogViaCli(cronJobId, 1, ctx.log);
|
|
1007
|
+
}
|
|
1008
|
+
const lastEntry = entries.length > 0 ? entries[entries.length - 1] : undefined;
|
|
1009
|
+
// If run log has a summary, use it
|
|
1010
|
+
if (lastEntry?.summary) {
|
|
1011
|
+
return lastEntry.summary;
|
|
1012
|
+
}
|
|
1013
|
+
// Layer 3: read session JSONL for full output
|
|
1014
|
+
const sessionId = lastEntry?.sessionId;
|
|
1015
|
+
if (sessionId) {
|
|
1016
|
+
const result = await readSessionOutputById(agentId, sessionId);
|
|
1017
|
+
if (result.text)
|
|
1018
|
+
return result.text;
|
|
1019
|
+
}
|
|
1020
|
+
// Final fallback: try sessions.json lookup (original approach)
|
|
1021
|
+
try {
|
|
1022
|
+
const os = await import("os");
|
|
1023
|
+
const fs = await import("fs");
|
|
1024
|
+
const path = await import("path");
|
|
1025
|
+
const sessionsFile = path.join(os.homedir(), ".openclaw", "agents", agentId, "sessions", "sessions.json");
|
|
1026
|
+
if (!fs.existsSync(sessionsFile))
|
|
1027
|
+
return "";
|
|
1028
|
+
const sessData = JSON.parse(fs.readFileSync(sessionsFile, "utf-8"));
|
|
1029
|
+
const sessKey = `agent:${agentId}:cron:${cronJobId}`;
|
|
1030
|
+
const sessEntry = sessData[sessKey];
|
|
1031
|
+
if (!sessEntry?.sessionId)
|
|
1032
|
+
return "";
|
|
1033
|
+
const result = await readSessionOutputById(agentId, sessEntry.sessionId);
|
|
1034
|
+
return result.text;
|
|
1035
|
+
}
|
|
1036
|
+
catch (err) {
|
|
1037
|
+
ctx.log?.warn?.(`Failed to read session output: ${err}`);
|
|
1038
|
+
}
|
|
1039
|
+
return "";
|
|
1040
|
+
}
|
|
1041
|
+
// ---------------------------------------------------------------------------
|
|
1042
|
+
// Startup task scanning — scan existing CronJobs and report to cloud
|
|
1043
|
+
// ---------------------------------------------------------------------------
|
|
1044
|
+
// ---------------------------------------------------------------------------
|
|
1045
|
+
// Models listing — read configured providers from OpenClaw config.
|
|
1046
|
+
// Extracts unique provider names from model keys (provider/model format)
|
|
1047
|
+
// so the dropdown matches what `/models` returns.
|
|
1048
|
+
// ---------------------------------------------------------------------------
|
|
1049
|
+
async function handleModelsRequest(ctx) {
|
|
1050
|
+
const client = getCloudClient(ctx.accountId);
|
|
1051
|
+
if (!client?.connected)
|
|
1052
|
+
return;
|
|
1053
|
+
try {
|
|
1054
|
+
const os = await import("os");
|
|
1055
|
+
const fs = await import("fs");
|
|
1056
|
+
const path = await import("path");
|
|
1057
|
+
const configFile = path.join(os.homedir(), ".openclaw", "openclaw.json");
|
|
1058
|
+
// Collect all model keys, then group by provider
|
|
1059
|
+
const allKeys = [];
|
|
1060
|
+
const addKey = (raw) => {
|
|
1061
|
+
const trimmed = raw.trim();
|
|
1062
|
+
if (trimmed)
|
|
1063
|
+
allKeys.push(trimmed);
|
|
1064
|
+
};
|
|
1065
|
+
if (fs.existsSync(configFile)) {
|
|
1066
|
+
const cfg = JSON.parse(fs.readFileSync(configFile, "utf-8"));
|
|
1067
|
+
// 1. Primary default model
|
|
1068
|
+
const primary = cfg?.agents?.defaults?.model?.primary;
|
|
1069
|
+
if (typeof primary === "string")
|
|
1070
|
+
addKey(primary);
|
|
1071
|
+
// 2. Fallback models
|
|
1072
|
+
const fallbacks = cfg?.agents?.defaults?.model?.fallbacks;
|
|
1073
|
+
if (Array.isArray(fallbacks)) {
|
|
1074
|
+
for (const fb of fallbacks) {
|
|
1075
|
+
if (typeof fb === "string")
|
|
1076
|
+
addKey(fb);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
// 3. Configured models (allowlist)
|
|
1080
|
+
const configuredModels = cfg?.agents?.defaults?.models;
|
|
1081
|
+
if (configuredModels && typeof configuredModels === "object") {
|
|
1082
|
+
for (const key of Object.keys(configuredModels)) {
|
|
1083
|
+
addKey(key);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
// 4. Image model + fallbacks
|
|
1087
|
+
const imagePrimary = cfg?.agents?.defaults?.imageModel?.primary;
|
|
1088
|
+
if (typeof imagePrimary === "string")
|
|
1089
|
+
addKey(imagePrimary);
|
|
1090
|
+
const imageFallbacks = cfg?.agents?.defaults?.imageModel?.fallbacks;
|
|
1091
|
+
if (Array.isArray(imageFallbacks)) {
|
|
1092
|
+
for (const fb of imageFallbacks) {
|
|
1093
|
+
if (typeof fb === "string")
|
|
1094
|
+
addKey(fb);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
// Deduplicate full model keys (provider/model)
|
|
1099
|
+
const seen = new Set();
|
|
1100
|
+
const models = [];
|
|
1101
|
+
for (const key of allKeys) {
|
|
1102
|
+
if (seen.has(key))
|
|
1103
|
+
continue;
|
|
1104
|
+
seen.add(key);
|
|
1105
|
+
const slash = key.indexOf("/");
|
|
1106
|
+
const provider = slash > 0 ? key.slice(0, slash) : key;
|
|
1107
|
+
const model = slash > 0 ? key.slice(slash + 1) : key;
|
|
1108
|
+
models.push({ id: key, name: model, provider });
|
|
1109
|
+
}
|
|
1110
|
+
ctx.log?.info(`[${ctx.accountId}] Models scan: found ${models.length} providers`);
|
|
1111
|
+
client.send({ type: "models.list", models });
|
|
1112
|
+
}
|
|
1113
|
+
catch (err) {
|
|
1114
|
+
ctx.log?.error(`[${ctx.accountId}] Failed to read models: ${err}`);
|
|
1115
|
+
client.send({ type: "models.list", models: [] });
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
// ---------------------------------------------------------------------------
|
|
1119
|
+
// Startup task scanning — scan existing CronJobs and report to cloud
|
|
1120
|
+
// ---------------------------------------------------------------------------
|
|
1121
|
+
async function handleTaskScanRequest(ctx) {
|
|
1122
|
+
const client = getCloudClient(ctx.accountId);
|
|
1123
|
+
if (!client?.connected)
|
|
1124
|
+
return;
|
|
1125
|
+
try {
|
|
1126
|
+
const scannedTasks = [];
|
|
1127
|
+
// Read cron jobs directly from ~/.openclaw/cron/jobs.json
|
|
1128
|
+
// because runtime.cron is not exposed to plugins.
|
|
1129
|
+
const os = await import("os");
|
|
1130
|
+
const fs = await import("fs");
|
|
1131
|
+
const path = await import("path");
|
|
1132
|
+
const cronFile = path.join(os.homedir(), ".openclaw", "cron", "jobs.json");
|
|
1133
|
+
if (fs.existsSync(cronFile)) {
|
|
1134
|
+
const raw = fs.readFileSync(cronFile, "utf-8");
|
|
1135
|
+
const data = JSON.parse(raw);
|
|
1136
|
+
if (Array.isArray(data.jobs)) {
|
|
1137
|
+
for (const job of data.jobs) {
|
|
1138
|
+
// Convert schedule object to a human-readable string
|
|
1139
|
+
let scheduleStr = "";
|
|
1140
|
+
if (job.schedule) {
|
|
1141
|
+
if (job.schedule.kind === "every" && job.schedule.everyMs) {
|
|
1142
|
+
const ms = job.schedule.everyMs;
|
|
1143
|
+
if (ms >= 3600000)
|
|
1144
|
+
scheduleStr = `every ${ms / 3600000}h`;
|
|
1145
|
+
else if (ms >= 60000)
|
|
1146
|
+
scheduleStr = `every ${ms / 60000}m`;
|
|
1147
|
+
else
|
|
1148
|
+
scheduleStr = `every ${ms / 1000}s`;
|
|
1149
|
+
}
|
|
1150
|
+
else if (job.schedule.kind === "at" && job.schedule.at) {
|
|
1151
|
+
scheduleStr = `at ${job.schedule.at}`;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
let lastRun;
|
|
1155
|
+
// Prefer model from jobs.json (explicitly set by user), fallback to session detection
|
|
1156
|
+
let detectedModel = job.model ?? "";
|
|
1157
|
+
if (job.state?.lastRunAtMs) {
|
|
1158
|
+
// 3-layer strategy to get last run output:
|
|
1159
|
+
// Layer 1: run log JSONL → summary
|
|
1160
|
+
// Layer 2: CLI fallback → same data
|
|
1161
|
+
// Layer 3: session JSONL → full assistant output
|
|
1162
|
+
let lastOutput = "";
|
|
1163
|
+
const agentId = job.agentId ?? "main";
|
|
1164
|
+
// Layer 1: read run log directly
|
|
1165
|
+
let runEntries = await readCronRunLog(job.id, 1);
|
|
1166
|
+
// Layer 2: CLI fallback if run log file not found
|
|
1167
|
+
if (runEntries.length === 0) {
|
|
1168
|
+
runEntries = await readCronRunLogViaCli(job.id, 1, ctx.log);
|
|
1169
|
+
}
|
|
1170
|
+
const lastRunEntry = runEntries.length > 0 ? runEntries[runEntries.length - 1] : undefined;
|
|
1171
|
+
if (lastRunEntry?.summary) {
|
|
1172
|
+
lastOutput = lastRunEntry.summary;
|
|
1173
|
+
}
|
|
1174
|
+
// Layer 3: if summary is empty, read session JSONL for full output
|
|
1175
|
+
if (!lastOutput) {
|
|
1176
|
+
const sessionId = lastRunEntry?.sessionId;
|
|
1177
|
+
if (sessionId) {
|
|
1178
|
+
// Use sessionId from run log — no need to look up sessions.json
|
|
1179
|
+
const sessResult = await readSessionOutputById(agentId, sessionId);
|
|
1180
|
+
if (sessResult.text)
|
|
1181
|
+
lastOutput = sessResult.text;
|
|
1182
|
+
if (!detectedModel && sessResult.model)
|
|
1183
|
+
detectedModel = sessResult.model;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
// Use durationMs from run log if available (more accurate)
|
|
1187
|
+
const durationMs = lastRunEntry?.durationMs ?? job.state.lastDurationMs;
|
|
1188
|
+
const status = lastRunEntry?.status ?? job.state.lastStatus ?? "ok";
|
|
1189
|
+
const ts = lastRunEntry?.runAtMs
|
|
1190
|
+
? Math.floor(lastRunEntry.runAtMs / 1000)
|
|
1191
|
+
: Math.floor(job.state.lastRunAtMs / 1000);
|
|
1192
|
+
lastRun = {
|
|
1193
|
+
status,
|
|
1194
|
+
ts,
|
|
1195
|
+
summary: lastOutput || undefined,
|
|
1196
|
+
durationMs,
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
// Extract instructions/prompt from payload
|
|
1200
|
+
let instructions = "";
|
|
1201
|
+
if (job.payload) {
|
|
1202
|
+
if (typeof job.payload === "string") {
|
|
1203
|
+
instructions = job.payload;
|
|
1204
|
+
}
|
|
1205
|
+
else if (typeof job.payload === "object" && job.payload !== null) {
|
|
1206
|
+
const p = job.payload;
|
|
1207
|
+
instructions = p.message ?? p.text ?? p.prompt ?? "";
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
// Also try to get model from agent config if not found in session
|
|
1211
|
+
if (!detectedModel) {
|
|
1212
|
+
try {
|
|
1213
|
+
const agentConfigFile = path.join(os.homedir(), ".openclaw", "agents", job.agentId ?? "main", "config.json");
|
|
1214
|
+
if (fs.existsSync(agentConfigFile)) {
|
|
1215
|
+
const agentCfg = JSON.parse(fs.readFileSync(agentConfigFile, "utf-8"));
|
|
1216
|
+
if (agentCfg?.model)
|
|
1217
|
+
detectedModel = agentCfg.model;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
catch { /* ignore */ }
|
|
1221
|
+
}
|
|
1222
|
+
scannedTasks.push({
|
|
1223
|
+
cronJobId: job.id,
|
|
1224
|
+
name: job.name ?? job.id,
|
|
1225
|
+
schedule: scheduleStr,
|
|
1226
|
+
agentId: job.agentId ?? "",
|
|
1227
|
+
enabled: job.enabled !== false,
|
|
1228
|
+
instructions,
|
|
1229
|
+
model: detectedModel || undefined,
|
|
1230
|
+
lastRun,
|
|
1231
|
+
});
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
ctx.log?.info(`[${ctx.accountId}] Task scan: read ${scannedTasks.length} jobs from ${cronFile}`);
|
|
1235
|
+
}
|
|
1236
|
+
else {
|
|
1237
|
+
ctx.log?.info(`[${ctx.accountId}] Task scan: cron file not found at ${cronFile}`);
|
|
1238
|
+
}
|
|
1239
|
+
ctx.log?.info(`[${ctx.accountId}] Task scan complete: found ${scannedTasks.length} background tasks`);
|
|
1240
|
+
client.send({ type: "task.scan.result", tasks: scannedTasks });
|
|
1241
|
+
}
|
|
1242
|
+
catch (err) {
|
|
1243
|
+
ctx.log?.error(`[${ctx.accountId}] Task scan failed: ${err}`);
|
|
1244
|
+
// Send empty result on error
|
|
1245
|
+
client.send({ type: "task.scan.result", tasks: [] });
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
//# sourceMappingURL=channel.js.map
|