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,929 @@
|
|
|
1
|
+
import type { Env } from "../env.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ConnectionDO — one Durable Object instance per BotsChat user.
|
|
5
|
+
*
|
|
6
|
+
* Responsibilities:
|
|
7
|
+
* - Hold the persistent WSS from the user's OpenClaw instance
|
|
8
|
+
* - Hold WebSocket(s) from the user's browser sessions
|
|
9
|
+
* - Bidirectionally relay messages between OpenClaw and browsers
|
|
10
|
+
* - Use WebSocket Hibernation API so idle users cost zero compute
|
|
11
|
+
*
|
|
12
|
+
* Connection tagging (via serializeAttachment / deserializeAttachment):
|
|
13
|
+
* - "openclaw" = the WebSocket from the OpenClaw plugin
|
|
14
|
+
* - "browser:<sessionId>" = a browser client WebSocket
|
|
15
|
+
*/
|
|
16
|
+
export class ConnectionDO implements DurableObject {
|
|
17
|
+
private state: DurableObjectState;
|
|
18
|
+
private env: Env;
|
|
19
|
+
/** Global default model from OpenClaw config (gateway primary model) */
|
|
20
|
+
private defaultModel: string | null = null;
|
|
21
|
+
/** Cached models list from OpenClaw plugin */
|
|
22
|
+
private cachedModels: Array<{ id: string; name: string; provider: string }> = [];
|
|
23
|
+
|
|
24
|
+
/** Pending resolve for a real-time task.scan.request → task.scan.result round-trip. */
|
|
25
|
+
private pendingScanResolve: ((tasks: Array<Record<string, unknown>>) => void) | null = null;
|
|
26
|
+
|
|
27
|
+
constructor(state: DurableObjectState, env: Env) {
|
|
28
|
+
this.state = state;
|
|
29
|
+
this.env = env;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Handle incoming HTTP requests (WebSocket upgrades). */
|
|
33
|
+
async fetch(request: Request): Promise<Response> {
|
|
34
|
+
const url = new URL(request.url);
|
|
35
|
+
|
|
36
|
+
// Route: /gateway/:accountId — OpenClaw plugin connects here
|
|
37
|
+
if (url.pathname.startsWith("/gateway/")) {
|
|
38
|
+
// Extract and store userId from the gateway path
|
|
39
|
+
const userId = url.pathname.split("/gateway/")[1];
|
|
40
|
+
if (userId) {
|
|
41
|
+
await this.state.storage.put("userId", userId);
|
|
42
|
+
}
|
|
43
|
+
return this.handleOpenClawConnect(request);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Route: /client/:sessionId — Browser client connects here
|
|
47
|
+
if (url.pathname.startsWith("/client/")) {
|
|
48
|
+
return this.handleBrowserConnect(request);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Route: /messages — Fetch message history (REST)
|
|
52
|
+
if (url.pathname === "/messages" && request.method === "GET") {
|
|
53
|
+
return this.handleGetMessages(url);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Route: /models — Available models (REST)
|
|
57
|
+
if (url.pathname === "/models") {
|
|
58
|
+
await this.ensureCachedModels();
|
|
59
|
+
console.log(`[DO] GET /models — returning ${this.cachedModels.length} models`);
|
|
60
|
+
return Response.json({ models: this.cachedModels });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Route: /scan-data — Cached OpenClaw scan data (schedule/instructions/model)
|
|
64
|
+
if (url.pathname === "/scan-data") {
|
|
65
|
+
return this.handleGetScanData();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Route: /status — Connection status (REST)
|
|
69
|
+
if (url.pathname === "/status") {
|
|
70
|
+
return this.handleStatus();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Route: /send — Send a message to OpenClaw (REST, used by API worker)
|
|
74
|
+
if (url.pathname === "/send" && request.method === "POST") {
|
|
75
|
+
return this.handleSendToOpenClaw(request);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return new Response("Not found", { status: 404 });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---- WebSocket Hibernation API handlers ----
|
|
82
|
+
|
|
83
|
+
/** Called when a WebSocket receives a message (wakes from hibernation). */
|
|
84
|
+
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
|
|
85
|
+
const tag = this.getTag(ws);
|
|
86
|
+
const data = typeof message === "string" ? message : new TextDecoder().decode(message);
|
|
87
|
+
|
|
88
|
+
let parsed: Record<string, unknown>;
|
|
89
|
+
try {
|
|
90
|
+
parsed = JSON.parse(data);
|
|
91
|
+
} catch {
|
|
92
|
+
return; // Ignore malformed JSON
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (tag === "openclaw") {
|
|
96
|
+
// Message from OpenClaw → handle auth or forward to browsers
|
|
97
|
+
await this.handleOpenClawMessage(ws, parsed);
|
|
98
|
+
} else if (tag?.startsWith("browser:")) {
|
|
99
|
+
// Message from browser → forward to OpenClaw
|
|
100
|
+
await this.handleBrowserMessage(ws, parsed);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Called when a WebSocket is closed. */
|
|
105
|
+
async webSocketClose(ws: WebSocket, code: number, _reason: string, _wasClean: boolean): Promise<void> {
|
|
106
|
+
const tag = this.getTag(ws);
|
|
107
|
+
if (tag === "openclaw") {
|
|
108
|
+
// OpenClaw disconnected — notify all browser clients
|
|
109
|
+
this.broadcastToBrowsers(
|
|
110
|
+
JSON.stringify({ type: "openclaw.disconnected" }),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
// No explicit cleanup needed — the runtime manages the socket list
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Called when a WebSocket encounters an error. */
|
|
117
|
+
async webSocketError(ws: WebSocket, error: unknown): Promise<void> {
|
|
118
|
+
const tag = this.getTag(ws);
|
|
119
|
+
console.error(`WebSocket error for ${tag}:`, error);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---- Connection handlers ----
|
|
123
|
+
|
|
124
|
+
private handleOpenClawConnect(request: Request): Response {
|
|
125
|
+
if (request.headers.get("Upgrade") !== "websocket") {
|
|
126
|
+
return new Response("Expected WebSocket upgrade", { status: 426 });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const pair = new WebSocketPair();
|
|
130
|
+
const [client, server] = [pair[0], pair[1]];
|
|
131
|
+
|
|
132
|
+
// Accept with Hibernation API, tag as "openclaw"
|
|
133
|
+
this.state.acceptWebSocket(server, ["openclaw"]);
|
|
134
|
+
|
|
135
|
+
// Store initial state: not yet authenticated
|
|
136
|
+
server.serializeAttachment({ authenticated: false, tag: "openclaw" });
|
|
137
|
+
|
|
138
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private handleBrowserConnect(request: Request): Response {
|
|
142
|
+
if (request.headers.get("Upgrade") !== "websocket") {
|
|
143
|
+
return new Response("Expected WebSocket upgrade", { status: 426 });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const url = new URL(request.url);
|
|
147
|
+
const sessionId = url.pathname.split("/client/")[1] || crypto.randomUUID();
|
|
148
|
+
|
|
149
|
+
const pair = new WebSocketPair();
|
|
150
|
+
const [client, server] = [pair[0], pair[1]];
|
|
151
|
+
|
|
152
|
+
const tag = `browser:${sessionId}`;
|
|
153
|
+
this.state.acceptWebSocket(server, [tag]);
|
|
154
|
+
server.serializeAttachment({ authenticated: false, tag });
|
|
155
|
+
|
|
156
|
+
return new Response(null, { status: 101, webSocket: client });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---- Message routing ----
|
|
160
|
+
|
|
161
|
+
private async handleOpenClawMessage(
|
|
162
|
+
ws: WebSocket,
|
|
163
|
+
msg: Record<string, unknown>,
|
|
164
|
+
): Promise<void> {
|
|
165
|
+
const attachment = ws.deserializeAttachment() as { authenticated: boolean; tag: string } | null;
|
|
166
|
+
|
|
167
|
+
// Handle auth handshake
|
|
168
|
+
if (msg.type === "auth") {
|
|
169
|
+
const token = msg.token as string;
|
|
170
|
+
const isValid = await this.validatePairingToken(token);
|
|
171
|
+
|
|
172
|
+
if (isValid) {
|
|
173
|
+
ws.serializeAttachment({ ...attachment, authenticated: true });
|
|
174
|
+
ws.send(JSON.stringify({ type: "auth.ok" }));
|
|
175
|
+
// Store gateway default model from plugin auth
|
|
176
|
+
if (msg.model) {
|
|
177
|
+
this.defaultModel = msg.model as string;
|
|
178
|
+
await this.state.storage.put("defaultModel", this.defaultModel);
|
|
179
|
+
}
|
|
180
|
+
// After auth, request task scan + models list from the plugin
|
|
181
|
+
ws.send(JSON.stringify({ type: "task.scan.request" }));
|
|
182
|
+
ws.send(JSON.stringify({ type: "models.request" }));
|
|
183
|
+
// Notify all browser clients that OpenClaw is now connected
|
|
184
|
+
this.broadcastToBrowsers(
|
|
185
|
+
JSON.stringify({ type: "connection.status", openclawConnected: true, defaultModel: this.defaultModel, models: this.cachedModels }),
|
|
186
|
+
);
|
|
187
|
+
} else {
|
|
188
|
+
ws.send(JSON.stringify({ type: "auth.fail", reason: "Invalid pairing token" }));
|
|
189
|
+
ws.close(4001, "Authentication failed");
|
|
190
|
+
}
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Reject unauthenticated messages
|
|
195
|
+
if (!attachment?.authenticated) {
|
|
196
|
+
ws.send(JSON.stringify({ type: "auth.fail", reason: "Not authenticated" }));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Persist agent messages to D1 (skip transient stream events)
|
|
201
|
+
if (msg.type === "agent.text" || msg.type === "agent.media" || msg.type === "agent.a2ui") {
|
|
202
|
+
console.log("[DO] Agent outbound:", JSON.stringify({
|
|
203
|
+
type: msg.type,
|
|
204
|
+
sessionKey: msg.sessionKey,
|
|
205
|
+
threadId: msg.threadId,
|
|
206
|
+
replyToId: msg.replyToId,
|
|
207
|
+
hasMedia: !!msg.mediaUrl,
|
|
208
|
+
}));
|
|
209
|
+
|
|
210
|
+
// For agent.media, cache external images to R2 so they remain accessible
|
|
211
|
+
// even after the original URL expires (e.g. DALL-E temporary URLs).
|
|
212
|
+
let persistedMediaUrl = msg.mediaUrl as string | undefined;
|
|
213
|
+
if (msg.type === "agent.media" && persistedMediaUrl) {
|
|
214
|
+
const cachedUrl = await this.cacheExternalMedia(persistedMediaUrl);
|
|
215
|
+
if (cachedUrl) {
|
|
216
|
+
persistedMediaUrl = cachedUrl;
|
|
217
|
+
// Update the message object so browsers get the cached URL
|
|
218
|
+
msg.mediaUrl = cachedUrl;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
await this.persistMessage({
|
|
223
|
+
sender: "agent",
|
|
224
|
+
sessionKey: msg.sessionKey as string,
|
|
225
|
+
threadId: (msg.threadId ?? msg.replyToId) as string | undefined,
|
|
226
|
+
text: (msg.text ?? msg.caption ?? "") as string,
|
|
227
|
+
mediaUrl: persistedMediaUrl,
|
|
228
|
+
a2ui: msg.jsonl as string | undefined,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// model.changed is a per-session event — just log and forward to browsers
|
|
233
|
+
// (forwarding happens at the bottom of this method). DO does NOT track per-session models.
|
|
234
|
+
if (msg.type === "model.changed" && msg.model) {
|
|
235
|
+
console.log(`[DO] Session model changed to: ${msg.model} (sessionKey: ${msg.sessionKey ?? "?"})`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Handle task schedule ack — update D1 with the OpenClaw-generated cronJobId
|
|
239
|
+
if (msg.type === "task.schedule.ack" && msg.ok && msg.taskId && msg.cronJobId) {
|
|
240
|
+
try {
|
|
241
|
+
await this.env.DB.prepare(
|
|
242
|
+
"UPDATE tasks SET openclaw_cron_job_id = ? WHERE id = ?",
|
|
243
|
+
).bind(msg.cronJobId, msg.taskId).run();
|
|
244
|
+
console.log(`[DO] Updated task ${msg.taskId} with cronJobId ${msg.cronJobId}`);
|
|
245
|
+
} catch (err) {
|
|
246
|
+
console.error(`[DO] Failed to update task cronJobId: ${err}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Handle task scan results from plugin — sync to D1 and forward to browsers
|
|
251
|
+
if (msg.type === "task.scan.result") {
|
|
252
|
+
await this.handleTaskScanResult(msg);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Handle models list from plugin — persist to storage and broadcast to browsers
|
|
256
|
+
if (msg.type === "models.list") {
|
|
257
|
+
this.cachedModels = (msg.models as Array<{ id: string; name: string; provider: string }>) ?? [];
|
|
258
|
+
await this.state.storage.put("cachedModels", this.cachedModels);
|
|
259
|
+
console.log(`[DO] Persisted ${this.cachedModels.length} models to storage, broadcasting connection.status`);
|
|
260
|
+
this.broadcastToBrowsers(
|
|
261
|
+
JSON.stringify({ type: "connection.status", openclawConnected: true, defaultModel: this.defaultModel, models: this.cachedModels }),
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Handle job updates from plugin — persist and forward to browsers
|
|
266
|
+
if (msg.type === "job.update") {
|
|
267
|
+
await this.handleJobUpdate(msg);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Forward all messages to browser clients
|
|
271
|
+
this.broadcastToBrowsers(JSON.stringify(msg));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private async handleBrowserMessage(
|
|
275
|
+
ws: WebSocket,
|
|
276
|
+
msg: Record<string, unknown>,
|
|
277
|
+
): Promise<void> {
|
|
278
|
+
const attachment = ws.deserializeAttachment() as { authenticated: boolean; tag: string } | null;
|
|
279
|
+
|
|
280
|
+
// Handle browser auth (session cookie/token validation)
|
|
281
|
+
if (msg.type === "auth") {
|
|
282
|
+
// For now, accept browser connections. In production, validate
|
|
283
|
+
// the session token against D1.
|
|
284
|
+
ws.serializeAttachment({ ...attachment, authenticated: true });
|
|
285
|
+
ws.send(JSON.stringify({ type: "auth.ok" }));
|
|
286
|
+
|
|
287
|
+
// Send current OpenClaw connection status + cached models
|
|
288
|
+
await this.ensureCachedModels();
|
|
289
|
+
const openclawConnected = this.getOpenClawSocket() !== null;
|
|
290
|
+
ws.send(
|
|
291
|
+
JSON.stringify({
|
|
292
|
+
type: "connection.status",
|
|
293
|
+
openclawConnected,
|
|
294
|
+
defaultModel: this.defaultModel,
|
|
295
|
+
models: this.cachedModels,
|
|
296
|
+
}),
|
|
297
|
+
);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (!attachment?.authenticated) {
|
|
302
|
+
ws.send(JSON.stringify({ type: "auth.fail", reason: "Not authenticated" }));
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Persist user messages to D1
|
|
307
|
+
if (msg.type === "user.message") {
|
|
308
|
+
console.log("[DO] User inbound:", JSON.stringify({
|
|
309
|
+
type: msg.type,
|
|
310
|
+
sessionKey: msg.sessionKey,
|
|
311
|
+
messageId: msg.messageId,
|
|
312
|
+
hasMedia: !!msg.mediaUrl,
|
|
313
|
+
}));
|
|
314
|
+
await this.persistMessage({
|
|
315
|
+
id: msg.messageId as string | undefined,
|
|
316
|
+
sender: "user",
|
|
317
|
+
sessionKey: msg.sessionKey as string,
|
|
318
|
+
text: (msg.text ?? "") as string,
|
|
319
|
+
mediaUrl: msg.mediaUrl as string | undefined,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Forward user messages to OpenClaw
|
|
324
|
+
const openclawWs = this.getOpenClawSocket();
|
|
325
|
+
if (openclawWs) {
|
|
326
|
+
openclawWs.send(JSON.stringify(msg));
|
|
327
|
+
} else {
|
|
328
|
+
ws.send(
|
|
329
|
+
JSON.stringify({
|
|
330
|
+
type: "error",
|
|
331
|
+
message: "OpenClaw is not connected. Please check your OpenClaw instance.",
|
|
332
|
+
}),
|
|
333
|
+
);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ---- REST handlers ----
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Fetch OpenClaw scan data in real time by sending task.scan.request to the
|
|
341
|
+
* plugin and waiting for the task.scan.result response.
|
|
342
|
+
* No local cache — data always comes directly from OpenClaw.
|
|
343
|
+
*/
|
|
344
|
+
private async handleGetScanData(): Promise<Response> {
|
|
345
|
+
const openclawWs = this.getOpenClawSocket();
|
|
346
|
+
if (!openclawWs) {
|
|
347
|
+
return Response.json(
|
|
348
|
+
{ error: "OpenClaw not connected", tasks: [] },
|
|
349
|
+
{ status: 503 },
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
try {
|
|
354
|
+
const tasks = await new Promise<Array<Record<string, unknown>>>((resolve, reject) => {
|
|
355
|
+
this.pendingScanResolve = resolve;
|
|
356
|
+
openclawWs.send(JSON.stringify({ type: "task.scan.request" }));
|
|
357
|
+
|
|
358
|
+
// Timeout after 15 seconds
|
|
359
|
+
setTimeout(() => {
|
|
360
|
+
if (this.pendingScanResolve === resolve) {
|
|
361
|
+
this.pendingScanResolve = null;
|
|
362
|
+
reject(new Error("Scan request timed out"));
|
|
363
|
+
}
|
|
364
|
+
}, 15_000);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// Map to the fields the frontend needs
|
|
368
|
+
const result = tasks.map((t) => ({
|
|
369
|
+
cronJobId: t.cronJobId as string,
|
|
370
|
+
schedule: (t.schedule as string) ?? "",
|
|
371
|
+
instructions: (t.instructions as string) ?? "",
|
|
372
|
+
model: (t.model as string) ?? "",
|
|
373
|
+
enabled: t.enabled as boolean,
|
|
374
|
+
}));
|
|
375
|
+
return Response.json({ tasks: result });
|
|
376
|
+
} catch (err) {
|
|
377
|
+
console.error("[DO] Scan request failed:", err);
|
|
378
|
+
return Response.json(
|
|
379
|
+
{ error: String(err), tasks: [] },
|
|
380
|
+
{ status: 504 },
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private handleStatus(): Response {
|
|
386
|
+
const sockets = this.state.getWebSockets();
|
|
387
|
+
const openclawSocket = sockets.find((s) => this.getTag(s) === "openclaw");
|
|
388
|
+
const browserCount = sockets.filter((s) =>
|
|
389
|
+
this.getTag(s)?.startsWith("browser:"),
|
|
390
|
+
).length;
|
|
391
|
+
|
|
392
|
+
let openclawAuthenticated = false;
|
|
393
|
+
if (openclawSocket) {
|
|
394
|
+
const att = openclawSocket.deserializeAttachment() as { authenticated: boolean } | null;
|
|
395
|
+
openclawAuthenticated = att?.authenticated ?? false;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return Response.json({
|
|
399
|
+
openclawConnected: !!openclawSocket,
|
|
400
|
+
openclawAuthenticated,
|
|
401
|
+
browserClients: browserCount,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
private async handleSendToOpenClaw(request: Request): Promise<Response> {
|
|
406
|
+
const body = await request.json<Record<string, unknown>>();
|
|
407
|
+
const openclawWs = this.getOpenClawSocket();
|
|
408
|
+
|
|
409
|
+
if (!openclawWs) {
|
|
410
|
+
return Response.json(
|
|
411
|
+
{ error: "OpenClaw not connected" },
|
|
412
|
+
{ status: 503 },
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
openclawWs.send(JSON.stringify(body));
|
|
417
|
+
return Response.json({ ok: true });
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ---- Helpers ----
|
|
421
|
+
|
|
422
|
+
/** Restore cachedModels and defaultModel from durable storage if in-memory cache is empty. */
|
|
423
|
+
private async ensureCachedModels(): Promise<void> {
|
|
424
|
+
if (this.cachedModels.length > 0) return;
|
|
425
|
+
const stored = await this.state.storage.get<Array<{ id: string; name: string; provider: string }>>("cachedModels");
|
|
426
|
+
if (stored && stored.length > 0) {
|
|
427
|
+
this.cachedModels = stored;
|
|
428
|
+
}
|
|
429
|
+
if (!this.defaultModel) {
|
|
430
|
+
const storedModel = await this.state.storage.get<string>("defaultModel");
|
|
431
|
+
if (storedModel) this.defaultModel = storedModel;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
private getTag(ws: WebSocket): string | null {
|
|
436
|
+
const att = ws.deserializeAttachment() as { tag?: string } | null;
|
|
437
|
+
return att?.tag ?? null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
private getOpenClawSocket(): WebSocket | null {
|
|
441
|
+
const sockets = this.state.getWebSockets("openclaw");
|
|
442
|
+
// Return the first authenticated OpenClaw socket
|
|
443
|
+
for (const s of sockets) {
|
|
444
|
+
const att = s.deserializeAttachment() as { authenticated: boolean } | null;
|
|
445
|
+
if (att?.authenticated) return s;
|
|
446
|
+
}
|
|
447
|
+
return sockets[0] ?? null;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private broadcastToBrowsers(message: string): void {
|
|
451
|
+
const sockets = this.state.getWebSockets();
|
|
452
|
+
for (const s of sockets) {
|
|
453
|
+
const tag = this.getTag(s);
|
|
454
|
+
if (tag?.startsWith("browser:")) {
|
|
455
|
+
const att = s.deserializeAttachment() as { authenticated: boolean } | null;
|
|
456
|
+
if (att?.authenticated) {
|
|
457
|
+
try {
|
|
458
|
+
s.send(message);
|
|
459
|
+
} catch {
|
|
460
|
+
// Socket might be closing, ignore
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ---- Media caching ----
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Download an external image and cache it in R2. Returns the local
|
|
471
|
+
* API URL (e.g. /api/media/...) or null if caching fails.
|
|
472
|
+
* Skips URLs that are already local (/api/media/...).
|
|
473
|
+
*/
|
|
474
|
+
private async cacheExternalMedia(url: string): Promise<string | null> {
|
|
475
|
+
// Skip already-cached URLs or data URIs
|
|
476
|
+
if (url.startsWith("/api/media/") || url.startsWith("data:")) return null;
|
|
477
|
+
// Also skip URLs that point back to our own media endpoint (absolute form)
|
|
478
|
+
if (/\/api\/media\//.test(url)) return null;
|
|
479
|
+
|
|
480
|
+
console.log(`[DO] cacheExternalMedia: attempting to cache ${url.slice(0, 120)}`);
|
|
481
|
+
|
|
482
|
+
try {
|
|
483
|
+
const userId = (await this.state.storage.get<string>("userId")) ?? "unknown";
|
|
484
|
+
|
|
485
|
+
// Download the external image — use arrayBuffer to avoid stream issues
|
|
486
|
+
const controller = new AbortController();
|
|
487
|
+
const timeoutId = setTimeout(() => controller.abort(), 30_000);
|
|
488
|
+
let response: Response;
|
|
489
|
+
try {
|
|
490
|
+
response = await fetch(url, { signal: controller.signal });
|
|
491
|
+
} finally {
|
|
492
|
+
clearTimeout(timeoutId);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (!response.ok) {
|
|
496
|
+
console.error(`[DO] cacheExternalMedia: HTTP ${response.status} for ${url.slice(0, 120)}`);
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const contentType = response.headers.get("Content-Type") ?? "image/png";
|
|
501
|
+
// Validate that the response is actually an image
|
|
502
|
+
if (!contentType.startsWith("image/")) {
|
|
503
|
+
console.warn(`[DO] cacheExternalMedia: unexpected Content-Type "${contentType}" for ${url.slice(0, 120)}`);
|
|
504
|
+
// Still try to cache it — some servers return wrong content types
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Read the body as ArrayBuffer for maximum compatibility with R2
|
|
508
|
+
const body = await response.arrayBuffer();
|
|
509
|
+
if (body.byteLength === 0) {
|
|
510
|
+
console.warn(`[DO] cacheExternalMedia: empty body for ${url.slice(0, 120)}`);
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Determine extension from Content-Type
|
|
515
|
+
const extMap: Record<string, string> = {
|
|
516
|
+
"image/png": "png",
|
|
517
|
+
"image/jpeg": "jpg",
|
|
518
|
+
"image/gif": "gif",
|
|
519
|
+
"image/webp": "webp",
|
|
520
|
+
"image/svg+xml": "svg",
|
|
521
|
+
};
|
|
522
|
+
const ext = extMap[contentType] ?? "png";
|
|
523
|
+
const key = `media/${userId}/${Date.now()}-${crypto.randomUUID().slice(0, 8)}.${ext}`;
|
|
524
|
+
|
|
525
|
+
// Upload to R2
|
|
526
|
+
await this.env.MEDIA.put(key, body, {
|
|
527
|
+
httpMetadata: { contentType },
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
const localUrl = `/api/media/${key.replace("media/", "")}`;
|
|
531
|
+
console.log(`[DO] cacheExternalMedia: OK ${url.slice(0, 80)} → ${localUrl} (${body.byteLength} bytes)`);
|
|
532
|
+
return localUrl;
|
|
533
|
+
} catch (err) {
|
|
534
|
+
console.error(`[DO] cacheExternalMedia: FAILED for ${url.slice(0, 120)}: ${err}`);
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ---- Message persistence ----
|
|
540
|
+
|
|
541
|
+
private async persistMessage(opts: {
|
|
542
|
+
id?: string;
|
|
543
|
+
sender: "user" | "agent";
|
|
544
|
+
sessionKey: string;
|
|
545
|
+
threadId?: string;
|
|
546
|
+
text: string;
|
|
547
|
+
mediaUrl?: string;
|
|
548
|
+
a2ui?: string;
|
|
549
|
+
}): Promise<void> {
|
|
550
|
+
try {
|
|
551
|
+
const userId = (await this.state.storage.get<string>("userId")) ?? "unknown";
|
|
552
|
+
const id = opts.id ?? crypto.randomUUID();
|
|
553
|
+
|
|
554
|
+
// Extract threadId from sessionKey pattern: ....:thread:{threadId}
|
|
555
|
+
let threadId = opts.threadId;
|
|
556
|
+
if (!threadId) {
|
|
557
|
+
const match = opts.sessionKey.match(/:thread:(.+)$/);
|
|
558
|
+
if (match) threadId = match[1];
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
await this.env.DB.prepare(
|
|
562
|
+
`INSERT INTO messages (id, user_id, session_key, thread_id, sender, text, media_url, a2ui)
|
|
563
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
564
|
+
)
|
|
565
|
+
.bind(id, userId, opts.sessionKey, threadId ?? null, opts.sender, opts.text, opts.mediaUrl ?? null, opts.a2ui ?? null)
|
|
566
|
+
.run();
|
|
567
|
+
} catch (err) {
|
|
568
|
+
console.error("Failed to persist message:", err);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
private async handleGetMessages(url: URL): Promise<Response> {
|
|
573
|
+
const sessionKey = url.searchParams.get("sessionKey");
|
|
574
|
+
if (!sessionKey) {
|
|
575
|
+
return Response.json({ error: "sessionKey required" }, { status: 400 });
|
|
576
|
+
}
|
|
577
|
+
const threadId = url.searchParams.get("threadId");
|
|
578
|
+
const limit = Math.min(Number(url.searchParams.get("limit") ?? 200), 500);
|
|
579
|
+
|
|
580
|
+
try {
|
|
581
|
+
let result;
|
|
582
|
+
const replyCounts: Record<string, number> = {};
|
|
583
|
+
|
|
584
|
+
if (threadId) {
|
|
585
|
+
// Load thread messages
|
|
586
|
+
result = await this.env.DB.prepare(
|
|
587
|
+
`SELECT id, session_key, thread_id, sender, text, media_url, a2ui, created_at
|
|
588
|
+
FROM messages
|
|
589
|
+
WHERE session_key = ? AND thread_id = ?
|
|
590
|
+
ORDER BY created_at ASC
|
|
591
|
+
LIMIT ?`,
|
|
592
|
+
)
|
|
593
|
+
.bind(sessionKey, threadId, limit)
|
|
594
|
+
.all();
|
|
595
|
+
} else {
|
|
596
|
+
// Load main session messages (exclude thread messages from the main list)
|
|
597
|
+
result = await this.env.DB.prepare(
|
|
598
|
+
`SELECT id, session_key, thread_id, sender, text, media_url, a2ui, created_at
|
|
599
|
+
FROM messages
|
|
600
|
+
WHERE session_key = ? AND thread_id IS NULL
|
|
601
|
+
ORDER BY created_at ASC
|
|
602
|
+
LIMIT ?`,
|
|
603
|
+
)
|
|
604
|
+
.bind(sessionKey, limit)
|
|
605
|
+
.all();
|
|
606
|
+
|
|
607
|
+
// Also load reply counts for all threads belonging to this session.
|
|
608
|
+
// Use range-based prefix match instead of LIKE to avoid
|
|
609
|
+
// "LIKE or GLOB pattern too complex" errors with long session keys.
|
|
610
|
+
const prefixStart = `${sessionKey}:thread:`;
|
|
611
|
+
const prefixEnd = `${sessionKey}:thread;`; // ';' is the char after ':' in ASCII
|
|
612
|
+
const replyCountResult = await this.env.DB.prepare(
|
|
613
|
+
`SELECT thread_id, COUNT(*) as count
|
|
614
|
+
FROM messages
|
|
615
|
+
WHERE thread_id IS NOT NULL AND session_key >= ? AND session_key < ?
|
|
616
|
+
GROUP BY thread_id`,
|
|
617
|
+
)
|
|
618
|
+
.bind(prefixStart, prefixEnd)
|
|
619
|
+
.all();
|
|
620
|
+
|
|
621
|
+
for (const row of (replyCountResult.results ?? [])) {
|
|
622
|
+
const tid = row.thread_id as string;
|
|
623
|
+
const count = row.count as number;
|
|
624
|
+
if (tid) replyCounts[tid] = count;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const messages = (result.results ?? []).map((row: Record<string, unknown>) => ({
|
|
629
|
+
id: row.id,
|
|
630
|
+
sender: row.sender,
|
|
631
|
+
text: row.text ?? "",
|
|
632
|
+
timestamp: ((row.created_at as number) ?? 0) * 1000, // unix seconds → ms
|
|
633
|
+
mediaUrl: row.media_url ?? undefined,
|
|
634
|
+
a2ui: row.a2ui ?? undefined,
|
|
635
|
+
threadId: row.thread_id ?? undefined,
|
|
636
|
+
}));
|
|
637
|
+
|
|
638
|
+
return Response.json({ messages, replyCounts });
|
|
639
|
+
} catch (err) {
|
|
640
|
+
console.error("Failed to load messages:", err);
|
|
641
|
+
return Response.json({ error: "Failed to load messages" }, { status: 500 });
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// ---- Task / Job handling ----
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Handle task.scan.result from plugin — the plugin reports existing cron jobs.
|
|
649
|
+
* We update tasks in D1 that match by cronJobId.
|
|
650
|
+
* If a scanned cron job has no matching task, auto-create it under a "Default" channel.
|
|
651
|
+
*/
|
|
652
|
+
private async handleTaskScanResult(msg: Record<string, unknown>): Promise<void> {
|
|
653
|
+
try {
|
|
654
|
+
const tasks = msg.tasks as Array<{
|
|
655
|
+
cronJobId: string;
|
|
656
|
+
name: string;
|
|
657
|
+
schedule: string;
|
|
658
|
+
agentId: string;
|
|
659
|
+
enabled: boolean;
|
|
660
|
+
instructions?: string;
|
|
661
|
+
model?: string;
|
|
662
|
+
lastRun?: { status: string; ts: number; summary?: string; durationMs?: number };
|
|
663
|
+
}>;
|
|
664
|
+
|
|
665
|
+
if (!Array.isArray(tasks) || tasks.length === 0) {
|
|
666
|
+
// Resolve pending scan request even if empty
|
|
667
|
+
if (this.pendingScanResolve) {
|
|
668
|
+
this.pendingScanResolve([]);
|
|
669
|
+
this.pendingScanResolve = null;
|
|
670
|
+
}
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// If a REST /scan-data request is waiting, resolve it with the raw data
|
|
675
|
+
if (this.pendingScanResolve) {
|
|
676
|
+
this.pendingScanResolve(tasks as unknown as Array<Record<string, unknown>>);
|
|
677
|
+
this.pendingScanResolve = null;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const userId = (await this.state.storage.get<string>("userId")) ?? "unknown";
|
|
681
|
+
|
|
682
|
+
// Lazily resolved default channel for orphan tasks
|
|
683
|
+
let defaultChannelId: string | null = null;
|
|
684
|
+
|
|
685
|
+
// Load the set of explicitly deleted cron job IDs so we skip them
|
|
686
|
+
const deletedRows = await this.env.DB.prepare(
|
|
687
|
+
"SELECT cron_job_id FROM deleted_cron_jobs WHERE user_id = ?",
|
|
688
|
+
)
|
|
689
|
+
.bind(userId)
|
|
690
|
+
.all<{ cron_job_id: string }>();
|
|
691
|
+
const deletedCronJobIds = new Set(
|
|
692
|
+
(deletedRows.results ?? []).map((r) => r.cron_job_id),
|
|
693
|
+
);
|
|
694
|
+
|
|
695
|
+
// Track which cron job IDs were seen in this scan (for cleanup)
|
|
696
|
+
const seenCronJobIds = new Set<string>();
|
|
697
|
+
|
|
698
|
+
for (const t of tasks) {
|
|
699
|
+
seenCronJobIds.add(t.cronJobId);
|
|
700
|
+
|
|
701
|
+
// Skip cron jobs that the user explicitly deleted
|
|
702
|
+
if (deletedCronJobIds.has(t.cronJobId)) {
|
|
703
|
+
console.log(`[DO] Skipping deleted cron job: ${t.cronJobId}`);
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Check if a matching task already exists
|
|
708
|
+
const existingTask = await this.env.DB.prepare(
|
|
709
|
+
"SELECT id, session_key FROM tasks WHERE openclaw_cron_job_id = ?",
|
|
710
|
+
)
|
|
711
|
+
.bind(t.cronJobId)
|
|
712
|
+
.first<{ id: string; session_key: string }>();
|
|
713
|
+
|
|
714
|
+
if (existingTask) {
|
|
715
|
+
// Update existing task from OpenClaw — only sync enabled (D1-owned).
|
|
716
|
+
// Schedule, instructions, and model are NOT stored in D1.
|
|
717
|
+
// They belong to OpenClaw and are delivered to the frontend via this
|
|
718
|
+
// task.scan.result WebSocket message (broadcast to browsers below).
|
|
719
|
+
const updateParts = [
|
|
720
|
+
"enabled = ?",
|
|
721
|
+
"updated_at = unixepoch()",
|
|
722
|
+
];
|
|
723
|
+
const updateVals: unknown[] = [
|
|
724
|
+
t.enabled ? 1 : 0,
|
|
725
|
+
t.cronJobId,
|
|
726
|
+
];
|
|
727
|
+
await this.env.DB.prepare(
|
|
728
|
+
`UPDATE tasks SET ${updateParts.join(", ")} WHERE openclaw_cron_job_id = ?`,
|
|
729
|
+
)
|
|
730
|
+
.bind(...updateVals)
|
|
731
|
+
.run();
|
|
732
|
+
} else {
|
|
733
|
+
// No matching task — create one under the default channel
|
|
734
|
+
if (!defaultChannelId) {
|
|
735
|
+
defaultChannelId = await this.ensureDefaultChannel(userId);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const taskId = this.generateId("tsk_");
|
|
739
|
+
const agentId = t.agentId || "main";
|
|
740
|
+
const sessionKey = `agent:${agentId}:botschat:${userId}:task:${taskId}`;
|
|
741
|
+
// Derive a friendly name: strip "botschat:" prefix if present
|
|
742
|
+
const taskName = t.name.startsWith("botschat:") ? t.name.slice(9) : t.name;
|
|
743
|
+
|
|
744
|
+
// D1 only stores basic task metadata — schedule/instructions/model
|
|
745
|
+
// belong to OpenClaw and are delivered via task.scan.result WebSocket.
|
|
746
|
+
await this.env.DB.prepare(
|
|
747
|
+
`INSERT INTO tasks (id, channel_id, name, kind, openclaw_cron_job_id, session_key, enabled)
|
|
748
|
+
VALUES (?, ?, ?, 'background', ?, ?, ?)`,
|
|
749
|
+
)
|
|
750
|
+
.bind(taskId, defaultChannelId, taskName, t.cronJobId, sessionKey, t.enabled ? 1 : 0)
|
|
751
|
+
.run();
|
|
752
|
+
|
|
753
|
+
console.log(`[DO] Auto-imported task "${taskName}" (cronJobId=${t.cronJobId}) into default channel`);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// Resolve the task record for job persistence (may have just been created)
|
|
757
|
+
const taskRecord = await this.env.DB.prepare(
|
|
758
|
+
"SELECT id, session_key FROM tasks WHERE openclaw_cron_job_id = ?",
|
|
759
|
+
)
|
|
760
|
+
.bind(t.cronJobId)
|
|
761
|
+
.first<{ id: string; session_key: string }>();
|
|
762
|
+
|
|
763
|
+
// If there's a last run, persist it as a job (insert or update summary/duration)
|
|
764
|
+
if (t.lastRun && taskRecord) {
|
|
765
|
+
const jobId = `job_scan_${t.cronJobId}_${t.lastRun.ts}`;
|
|
766
|
+
const existing = await this.env.DB.prepare(
|
|
767
|
+
"SELECT id FROM jobs WHERE id = ?",
|
|
768
|
+
)
|
|
769
|
+
.bind(jobId)
|
|
770
|
+
.first();
|
|
771
|
+
|
|
772
|
+
if (!existing) {
|
|
773
|
+
await this.env.DB.prepare(
|
|
774
|
+
`INSERT INTO jobs (id, task_id, user_id, session_key, status, started_at, duration_ms, summary)
|
|
775
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
776
|
+
)
|
|
777
|
+
.bind(
|
|
778
|
+
jobId,
|
|
779
|
+
taskRecord.id,
|
|
780
|
+
userId,
|
|
781
|
+
taskRecord.session_key ?? "",
|
|
782
|
+
t.lastRun.status,
|
|
783
|
+
t.lastRun.ts,
|
|
784
|
+
t.lastRun.durationMs ?? null,
|
|
785
|
+
t.lastRun.summary ?? "",
|
|
786
|
+
)
|
|
787
|
+
.run();
|
|
788
|
+
} else if (t.lastRun.summary) {
|
|
789
|
+
// Update summary/duration if we now have richer data from session files
|
|
790
|
+
await this.env.DB.prepare(
|
|
791
|
+
`UPDATE jobs SET summary = ?, duration_ms = COALESCE(?, duration_ms) WHERE id = ?`,
|
|
792
|
+
)
|
|
793
|
+
.bind(t.lastRun.summary, t.lastRun.durationMs ?? null, jobId)
|
|
794
|
+
.run();
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Clean up deleted_cron_jobs entries for cron jobs that OpenClaw no
|
|
800
|
+
// longer reports — they were successfully removed on the OpenClaw side.
|
|
801
|
+
for (const deletedId of deletedCronJobIds) {
|
|
802
|
+
if (!seenCronJobIds.has(deletedId)) {
|
|
803
|
+
await this.env.DB.prepare(
|
|
804
|
+
"DELETE FROM deleted_cron_jobs WHERE cron_job_id = ? AND user_id = ?",
|
|
805
|
+
)
|
|
806
|
+
.bind(deletedId, userId)
|
|
807
|
+
.run();
|
|
808
|
+
console.log(`[DO] Cleaned up deleted_cron_jobs entry: ${deletedId}`);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
} catch (err) {
|
|
812
|
+
console.error("Failed to handle task scan result:", err);
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* Ensure a "Default" channel exists for the user. Returns the channel ID.
|
|
818
|
+
*/
|
|
819
|
+
private async ensureDefaultChannel(userId: string): Promise<string> {
|
|
820
|
+
// Check if a default channel already exists
|
|
821
|
+
const existing = await this.env.DB.prepare(
|
|
822
|
+
"SELECT id FROM channels WHERE user_id = ? AND openclaw_agent_id = 'main' ORDER BY created_at ASC LIMIT 1",
|
|
823
|
+
)
|
|
824
|
+
.bind(userId)
|
|
825
|
+
.first<{ id: string }>();
|
|
826
|
+
|
|
827
|
+
if (existing) return existing.id;
|
|
828
|
+
|
|
829
|
+
// Create the default channel
|
|
830
|
+
const channelId = this.generateId("ch_");
|
|
831
|
+
await this.env.DB.prepare(
|
|
832
|
+
"INSERT INTO channels (id, user_id, name, description, openclaw_agent_id, system_prompt) VALUES (?, ?, 'Default', 'Auto-created channel for imported background tasks', 'main', '')",
|
|
833
|
+
)
|
|
834
|
+
.bind(channelId, userId)
|
|
835
|
+
.run();
|
|
836
|
+
|
|
837
|
+
// Create the default adhoc task for this channel
|
|
838
|
+
const taskId = this.generateId("tsk_");
|
|
839
|
+
const sessionKey = `agent:main:botschat:${userId}:adhoc`;
|
|
840
|
+
await this.env.DB.prepare(
|
|
841
|
+
"INSERT INTO tasks (id, channel_id, name, kind, session_key) VALUES (?, ?, 'Ad Hoc Chat', 'adhoc', ?)",
|
|
842
|
+
)
|
|
843
|
+
.bind(taskId, channelId, sessionKey)
|
|
844
|
+
.run();
|
|
845
|
+
|
|
846
|
+
console.log(`[DO] Created default channel (${channelId}) for user ${userId}`);
|
|
847
|
+
return channelId;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/** Generate a short random ID (URL-safe). */
|
|
851
|
+
private generateId(prefix = ""): string {
|
|
852
|
+
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
853
|
+
let id = prefix;
|
|
854
|
+
for (let i = 0; i < 16; i++) {
|
|
855
|
+
id += chars[Math.floor(Math.random() * chars.length)];
|
|
856
|
+
}
|
|
857
|
+
return id;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Handle job.update from plugin — a cron job ran and reported results.
|
|
862
|
+
* Persist the job in D1.
|
|
863
|
+
*/
|
|
864
|
+
private async handleJobUpdate(msg: Record<string, unknown>): Promise<void> {
|
|
865
|
+
try {
|
|
866
|
+
const userId = (await this.state.storage.get<string>("userId")) ?? "unknown";
|
|
867
|
+
const cronJobId = msg.cronJobId as string;
|
|
868
|
+
const jobId = (msg.jobId as string) ?? `job_${cronJobId}_${Date.now()}`;
|
|
869
|
+
const sessionKey = (msg.sessionKey as string) ?? "";
|
|
870
|
+
const status = (msg.status as string) ?? "ok";
|
|
871
|
+
const summary = (msg.summary as string) ?? "";
|
|
872
|
+
const startedAt = (msg.startedAt as number) ?? Math.floor(Date.now() / 1000);
|
|
873
|
+
const finishedAt = msg.finishedAt as number | undefined;
|
|
874
|
+
const durationMs = msg.durationMs as number | undefined;
|
|
875
|
+
|
|
876
|
+
// Find the task by cronJobId
|
|
877
|
+
const task = await this.env.DB.prepare(
|
|
878
|
+
"SELECT id FROM tasks WHERE openclaw_cron_job_id = ?",
|
|
879
|
+
)
|
|
880
|
+
.bind(cronJobId)
|
|
881
|
+
.first<{ id: string }>();
|
|
882
|
+
|
|
883
|
+
if (!task) {
|
|
884
|
+
console.error(`Job update: no task found for cronJobId ${cronJobId}`);
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
await this.env.DB.prepare(
|
|
889
|
+
`INSERT OR REPLACE INTO jobs (id, task_id, user_id, session_key, status, started_at, finished_at, duration_ms, summary)
|
|
890
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
891
|
+
)
|
|
892
|
+
.bind(
|
|
893
|
+
jobId,
|
|
894
|
+
task.id,
|
|
895
|
+
userId,
|
|
896
|
+
sessionKey,
|
|
897
|
+
status,
|
|
898
|
+
startedAt,
|
|
899
|
+
finishedAt ?? null,
|
|
900
|
+
durationMs ?? null,
|
|
901
|
+
summary,
|
|
902
|
+
)
|
|
903
|
+
.run();
|
|
904
|
+
} catch (err) {
|
|
905
|
+
console.error("Failed to handle job update:", err);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
private async validatePairingToken(token: string): Promise<boolean> {
|
|
910
|
+
// Query D1 to validate the pairing token
|
|
911
|
+
// The DO receives the env via constructor, but D1 queries from DO
|
|
912
|
+
// require fetching through the API worker. For simplicity in the DO,
|
|
913
|
+
// we store validated tokens in DO storage after first validation.
|
|
914
|
+
//
|
|
915
|
+
// Check DO-local cache first:
|
|
916
|
+
const cached = await this.state.storage.get<boolean>(`token:${token}`);
|
|
917
|
+
if (cached === true) return true;
|
|
918
|
+
if (cached === false) return false;
|
|
919
|
+
|
|
920
|
+
// If not cached, we accept the token optimistically and let the
|
|
921
|
+
// API worker validate it on the next REST call. In production,
|
|
922
|
+
// the API worker should validate before routing to the DO.
|
|
923
|
+
//
|
|
924
|
+
// For now, accept any non-empty bc_pat_ token:
|
|
925
|
+
const isValid = token.startsWith("bc_pat_") && token.length > 10;
|
|
926
|
+
await this.state.storage.put(`token:${token}`, isValid);
|
|
927
|
+
return isValid;
|
|
928
|
+
}
|
|
929
|
+
}
|