botschat 0.1.15 → 0.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/package.json +4 -1
  2. package/packages/api/src/do/connection-do.ts +106 -18
  3. package/packages/api/src/index.ts +59 -3
  4. package/packages/plugin/dist/src/channel.d.ts.map +1 -1
  5. package/packages/plugin/dist/src/channel.js +75 -6
  6. package/packages/plugin/dist/src/channel.js.map +1 -1
  7. package/packages/plugin/dist/src/types.d.ts +1 -0
  8. package/packages/plugin/dist/src/types.d.ts.map +1 -1
  9. package/packages/plugin/dist/src/ws-client.d.ts.map +1 -1
  10. package/packages/plugin/dist/src/ws-client.js +11 -6
  11. package/packages/plugin/dist/src/ws-client.js.map +1 -1
  12. package/packages/plugin/package.json +1 -1
  13. package/packages/web/dist/assets/{index-cm_3YFsA.css → index-B5GU1yVt.css} +1 -1
  14. package/packages/web/dist/assets/index-CO9YgLst.js +2 -0
  15. package/packages/web/dist/assets/index-ClDrCe_c.js +1 -0
  16. package/packages/web/dist/assets/{index-DsWBWQD6.js → index-D3T7sc-R.js} +1 -1
  17. package/packages/web/dist/assets/index-DPEosppm.js +2 -0
  18. package/packages/web/dist/assets/{index-dMn_npR3.js → index-DzYqprDN.js} +1 -1
  19. package/packages/web/dist/assets/index-IVUdSd9w.js +1516 -0
  20. package/packages/web/dist/assets/{index.esm-DdTIpXjl.js → index.esm-COzWPkKi.js} +1 -1
  21. package/packages/web/dist/assets/{web-Dft_LGIH.js → web-CxXbaApe.js} +1 -1
  22. package/packages/web/dist/assets/{web-DIeOUVhn.js → web-DFQypSd0.js} +1 -1
  23. package/packages/web/dist/index.html +2 -2
  24. package/packages/web/dist/sw.js +9 -1
  25. package/packages/web/src/App.tsx +34 -5
  26. package/packages/web/src/api.ts +7 -4
  27. package/packages/web/src/components/ChatWindow.tsx +139 -74
  28. package/packages/web/src/components/CronSidebar.tsx +5 -1
  29. package/packages/web/src/components/LoginPage.tsx +3 -1
  30. package/packages/web/src/components/OnboardingPage.tsx +3 -1
  31. package/packages/web/src/components/ScheduleEditor.tsx +120 -47
  32. package/packages/web/src/components/SessionTabs.tsx +5 -1
  33. package/packages/web/src/components/Sidebar.tsx +8 -2
  34. package/packages/web/src/components/TaskBar.tsx +5 -1
  35. package/packages/web/src/components/ThreadPanel.tsx +26 -3
  36. package/packages/web/src/firebase.ts +3 -2
  37. package/packages/web/src/foreground.ts +40 -10
  38. package/packages/web/src/hooks/useIMEComposition.ts +36 -0
  39. package/packages/web/src/main.tsx +3 -2
  40. package/packages/web/src/push.ts +88 -1
  41. package/packages/web/src/store.ts +4 -0
  42. package/packages/web/src/ws.ts +4 -3
  43. package/packages/web/dist/assets/index-CbCpFrA9.js +0 -2
  44. package/packages/web/dist/assets/index-Ct0m11C8.js +0 -2
  45. package/packages/web/dist/assets/index-CvbTpaza.js +0 -1516
  46. package/packages/web/dist/assets/index-GwprVhDP.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botschat",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "A self-hosted chat interface for OpenClaw AI agents",
5
5
  "workspaces": [
6
6
  "packages/*"
@@ -24,6 +24,9 @@
24
24
  "android:open": "npx cap open android",
25
25
  "android:run": "npx cap run android",
26
26
  "android:sync": "npx cap sync android",
27
+ "mac:build": "npm run build -w packages/web && cd macos && xcodegen generate",
28
+ "mac:open": "cd macos && xcodegen generate && open BotsChatMac.xcodeproj",
29
+ "mac:run": "npm run mac:build && xcodebuild -project macos/BotsChatMac.xcodeproj -scheme BotsChatMac -configuration Debug build && open macos/build/Debug/BotsChat.app",
27
30
  "test:e2e": "npx tsx scripts/verify-e2e.ts && npx tsx packages/e2e-crypto/e2e-crypto.test.ts",
28
31
  "test:e2e-db": "npx tsx scripts/verify-e2e-db.ts"
29
32
  },
@@ -5,6 +5,20 @@ import { sendApnsNotification, type ApnsConfig } from "../utils/apns.js";
5
5
  import { generateId as generateIdUtil } from "../utils/id.js";
6
6
  import { randomUUID } from "../utils/uuid.js";
7
7
 
8
+ /** Presence info stored in browser WebSocket attachments (survives DO hibernation). */
9
+ interface BrowserAttachment {
10
+ authenticated: boolean;
11
+ tag: string;
12
+ foreground: boolean;
13
+ sessionKey: string | null;
14
+ /** Timestamp (ms) when the session last went to background. */
15
+ backgroundAt: number | null;
16
+ }
17
+
18
+ /** Grace period constants for push notification suppression. */
19
+ const BG_GRACE_MS = 15_000; // 15 s after going background
20
+ const DC_GRACE_MS = 30_000; // 30 s after WebSocket disconnect
21
+
8
22
  /**
9
23
  * ConnectionDO — one Durable Object instance per BotsChat user.
10
24
  *
@@ -29,8 +43,12 @@ export class ConnectionDO implements DurableObject {
29
43
  /** Pending resolve for a real-time task.scan.request → task.scan.result round-trip. */
30
44
  private pendingScanResolve: ((tasks: Array<Record<string, unknown>>) => void) | null = null;
31
45
 
32
- /** Browser sessions that report themselves in foreground (push notifications are suppressed). */
33
- private foregroundSessions = new Set<string>();
46
+ /**
47
+ * Recently disconnected browser sessions — provides a grace period so that
48
+ * brief network blips don't immediately trigger push notifications.
49
+ * In-memory only; if the DO hibernates, the grace period has expired anyway.
50
+ */
51
+ private recentDisconnects = new Map<string, number>();
34
52
 
35
53
  /** Timestamp of last accepted OpenClaw WebSocket (in-memory, no storage write). */
36
54
  private lastOpenClawAcceptedAt = 0;
@@ -150,9 +168,13 @@ export class ConnectionDO implements DurableObject {
150
168
  JSON.stringify({ type: "openclaw.disconnected" }),
151
169
  );
152
170
  }
153
- // Clean up foreground tracking for browser sessions
171
+ // Disconnect grace: remember recently-disconnected browser sessions so
172
+ // push notifications are still suppressed during brief network blips.
154
173
  if (tag?.startsWith("browser:")) {
155
- this.foregroundSessions.delete(tag);
174
+ const att = ws.deserializeAttachment() as BrowserAttachment | null;
175
+ if (att?.foreground) {
176
+ this.recentDisconnects.set(tag, Date.now());
177
+ }
156
178
  }
157
179
  }
158
180
 
@@ -217,7 +239,17 @@ export class ConnectionDO implements DurableObject {
217
239
 
218
240
  const tag = `browser:${sessionId}`;
219
241
  this.state.acceptWebSocket(server, [tag]);
220
- server.serializeAttachment({ authenticated: false, tag });
242
+ const att: BrowserAttachment = {
243
+ authenticated: false,
244
+ tag,
245
+ foreground: false,
246
+ sessionKey: null,
247
+ backgroundAt: null,
248
+ };
249
+ server.serializeAttachment(att);
250
+
251
+ // Clear disconnect grace entry — this session is back.
252
+ this.recentDisconnects.delete(tag);
221
253
 
222
254
  return new Response(null, { status: 101, webSocket: client });
223
255
  }
@@ -296,16 +328,19 @@ export class ConnectionDO implements DurableObject {
296
328
 
297
329
  // For agent.media, cache external images to R2 so they remain accessible
298
330
  // even after the original URL expires (e.g. DALL-E temporary URLs).
331
+ // Skip caching if the plugin already uploaded E2E-encrypted media.
299
332
  let persistedMediaUrl = msg.mediaUrl as string | undefined;
300
- if (msg.type === "agent.media" && persistedMediaUrl) {
333
+ if (msg.type === "agent.media" && persistedMediaUrl && !msg.mediaEncrypted) {
301
334
  const cachedUrl = await this.cacheExternalMedia(persistedMediaUrl);
302
335
  if (cachedUrl) {
303
336
  persistedMediaUrl = cachedUrl;
304
- // Update the message object so browsers get the cached URL
305
337
  msg.mediaUrl = cachedUrl;
306
338
  }
307
339
  }
308
340
 
341
+ // Bitmask: bit 0 = text encrypted, bit 1 = media encrypted
342
+ const encryptedBits = (msg.encrypted ? 1 : 0) | (msg.mediaEncrypted ? 2 : 0);
343
+
309
344
  await this.persistMessage({
310
345
  id: msg.messageId as string | undefined,
311
346
  sender: "agent",
@@ -314,7 +349,7 @@ export class ConnectionDO implements DurableObject {
314
349
  text: (msg.text ?? msg.caption ?? "") as string,
315
350
  mediaUrl: persistedMediaUrl,
316
351
  a2ui: msg.jsonl as string | undefined,
317
- encrypted: msg.encrypted ? 1 : 0,
352
+ encrypted: encryptedBits,
318
353
  });
319
354
  }
320
355
 
@@ -377,10 +412,10 @@ export class ConnectionDO implements DurableObject {
377
412
  const { notifyPreview: _stripped, ...msgForBrowser } = msg;
378
413
  this.broadcastToBrowsers(JSON.stringify(msgForBrowser));
379
414
 
380
- // Send push notification if no browser session is in foreground
415
+ // Send push notification unless a device is (or was recently) in the foreground
381
416
  if (
382
417
  (msg.type === "agent.text" || msg.type === "agent.media" || msg.type === "agent.a2ui") &&
383
- this.foregroundSessions.size === 0 &&
418
+ !this.shouldSuppressPush() &&
384
419
  (this.env.FCM_SERVICE_ACCOUNT_JSON || this.env.APNS_AUTH_KEY)
385
420
  ) {
386
421
  this.sendPushNotifications(msg).catch((err) => {
@@ -393,7 +428,7 @@ export class ConnectionDO implements DurableObject {
393
428
  ws: WebSocket,
394
429
  msg: Record<string, unknown>,
395
430
  ): Promise<void> {
396
- const attachment = ws.deserializeAttachment() as { authenticated: boolean; tag: string } | null;
431
+ const attachment = ws.deserializeAttachment() as BrowserAttachment | null;
397
432
 
398
433
  // Handle browser auth — verify JWT token
399
434
  if (msg.type === "auth") {
@@ -444,15 +479,29 @@ export class ConnectionDO implements DurableObject {
444
479
  return;
445
480
  }
446
481
 
447
- // Handle foreground/background state tracking for push notifications
482
+ // ---- Presence / focus tracking (stored in WS attachment, hibernation-safe) ----
448
483
  if (msg.type === "foreground.enter") {
449
- const tag = attachment.tag;
450
- if (tag) this.foregroundSessions.add(tag);
484
+ ws.serializeAttachment({
485
+ ...attachment,
486
+ foreground: true,
487
+ sessionKey: (msg.sessionKey as string) ?? attachment.sessionKey ?? null,
488
+ backgroundAt: null,
489
+ } satisfies BrowserAttachment);
451
490
  return;
452
491
  }
453
492
  if (msg.type === "foreground.leave") {
454
- const tag = attachment.tag;
455
- if (tag) this.foregroundSessions.delete(tag);
493
+ ws.serializeAttachment({
494
+ ...attachment,
495
+ foreground: false,
496
+ backgroundAt: Date.now(),
497
+ } satisfies BrowserAttachment);
498
+ return;
499
+ }
500
+ if (msg.type === "focus.update") {
501
+ ws.serializeAttachment({
502
+ ...attachment,
503
+ sessionKey: (msg.sessionKey as string) ?? null,
504
+ } satisfies BrowserAttachment);
456
505
  return;
457
506
  }
458
507
 
@@ -668,6 +717,39 @@ export class ConnectionDO implements DurableObject {
668
717
  }
669
718
  }
670
719
 
720
+ // ---- Presence helpers ----
721
+
722
+ /**
723
+ * Determine whether push notifications should be suppressed because a device
724
+ * is (or was very recently) in the foreground.
725
+ *
726
+ * Checks three layers:
727
+ * 1. Any connected browser socket with `foreground === true`
728
+ * 2. Background grace: socket went background < BG_GRACE_MS ago
729
+ * 3. Disconnect grace: socket disconnected < DC_GRACE_MS ago
730
+ */
731
+ private shouldSuppressPush(): boolean {
732
+ const now = Date.now();
733
+
734
+ // 1 + 2: scan connected browser sockets
735
+ const sockets = this.state.getWebSockets();
736
+ for (const s of sockets) {
737
+ const att = s.deserializeAttachment() as BrowserAttachment | null;
738
+ if (!att || !att.tag?.startsWith("browser:") || !att.authenticated) continue;
739
+ if (att.foreground) return true;
740
+ if (att.backgroundAt && now - att.backgroundAt < BG_GRACE_MS) return true;
741
+ }
742
+
743
+ // 3: recently disconnected sessions
744
+ for (const [tag, disconnectedAt] of this.recentDisconnects) {
745
+ if (now - disconnectedAt < DC_GRACE_MS) return true;
746
+ // Expired — prune
747
+ this.recentDisconnects.delete(tag);
748
+ }
749
+
750
+ return false;
751
+ }
752
+
671
753
  // ---- Push notifications ----
672
754
 
673
755
  /**
@@ -1035,15 +1117,21 @@ export class ConnectionDO implements DurableObject {
1035
1117
  if (mediaUrl) {
1036
1118
  mediaUrl = await this.refreshMediaUrl(mediaUrl, secret);
1037
1119
  }
1120
+ const encBits = (row.encrypted as number) ?? 0;
1121
+ // Derive mediaEncrypted:
1122
+ // - User messages with encrypted=1: media was always encrypted by browser
1123
+ // - Any message with bit 1 set (encrypted >= 2): media was E2E encrypted
1124
+ const mediaEncrypted = (row.sender === "user" && encBits >= 1) || (encBits & 2) !== 0;
1038
1125
  return {
1039
1126
  id: row.id,
1040
1127
  sender: row.sender,
1041
1128
  text: row.text ?? "",
1042
- timestamp: ((row.created_at as number) ?? 0) * 1000, // unix seconds → ms
1129
+ timestamp: ((row.created_at as number) ?? 0) * 1000,
1043
1130
  mediaUrl,
1044
1131
  a2ui: row.a2ui ?? undefined,
1045
1132
  threadId: row.thread_id ?? undefined,
1046
- encrypted: row.encrypted ?? 0,
1133
+ encrypted: encBits,
1134
+ mediaEncrypted,
1047
1135
  };
1048
1136
  }),
1049
1137
  );
@@ -1,7 +1,8 @@
1
1
  import { Hono } from "hono";
2
2
  import { cors } from "hono/cors";
3
3
  import type { Env } from "./env.js";
4
- import { authMiddleware, verifyToken, getJwtSecret, verifyMediaSignature } from "./utils/auth.js";
4
+ import { authMiddleware, verifyToken, getJwtSecret, verifyMediaSignature, signMediaUrl } from "./utils/auth.js";
5
+ import { randomUUID } from "./utils/uuid.js";
5
6
  import { auth } from "./routes/auth.js";
6
7
  import { agents } from "./routes/agents.js";
7
8
  import { channels } from "./routes/channels.js";
@@ -423,13 +424,21 @@ app.all("/api/gateway/:connId", async (c) => {
423
424
 
424
425
  // Cache the rate limit after the DO responds (success or rate-limited).
425
426
  // 101 = WebSocket accepted; 429 = DO's own rate limit.
426
- // Either way, prevent further DO wake-ups for GATEWAY_COOLDOWN_S.
427
+ // Either way, prevent further DO wake-ups.
428
+ // When the DO returns 429, honour its Retry-After so the Worker cache
429
+ // aligns with the DO's actual cooldown (avoids the client retrying after
430
+ // the Worker cache expires but before the DO cooldown ends).
427
431
  if (doResp.status === 101 || doResp.status === 429) {
432
+ let cacheTtl = GATEWAY_COOLDOWN_S;
433
+ if (doResp.status === 429) {
434
+ const doRetry = parseInt(doResp.headers.get("Retry-After") ?? "", 10);
435
+ if (doRetry > 0) cacheTtl = doRetry;
436
+ }
428
437
  c.executionCtx.waitUntil(
429
438
  cache.put(
430
439
  rateCacheReq,
431
440
  new Response(null, {
432
- headers: { "Cache-Control": `public, max-age=${GATEWAY_COOLDOWN_S}` },
441
+ headers: { "Cache-Control": `public, max-age=${cacheTtl}` },
433
442
  }),
434
443
  ),
435
444
  );
@@ -477,6 +486,53 @@ app.get("/api/messages/:userId", async (c) => {
477
486
  return stub.fetch(new Request(url.toString()));
478
487
  });
479
488
 
489
+ // ---- Plugin upload (pairing token auth) ----
490
+ app.post("/api/plugin-upload", async (c) => {
491
+ const token = c.req.header("X-Pairing-Token");
492
+ if (!token) {
493
+ return c.json({ error: "Missing X-Pairing-Token header" }, 401);
494
+ }
495
+ const row = await c.env.DB.prepare(
496
+ "SELECT user_id FROM pairing_tokens WHERE token = ? AND revoked_at IS NULL",
497
+ )
498
+ .bind(token)
499
+ .first<{ user_id: string }>();
500
+ if (!row) {
501
+ return c.json({ error: "Invalid pairing token" }, 401);
502
+ }
503
+
504
+ const userId = row.user_id;
505
+ const contentType = c.req.header("Content-Type") ?? "";
506
+ if (!contentType.includes("multipart/form-data")) {
507
+ return c.json({ error: "Expected multipart/form-data" }, 400);
508
+ }
509
+
510
+ const formData = await c.req.formData();
511
+ const file = formData.get("file") as File | null;
512
+ if (!file) {
513
+ return c.json({ error: "No file provided" }, 400);
514
+ }
515
+
516
+ const MAX_SIZE = 20 * 1024 * 1024;
517
+ if (file.size > MAX_SIZE) {
518
+ return c.json({ error: "File too large (max 20 MB)" }, 413);
519
+ }
520
+
521
+ const fileType = file.type || "application/octet-stream";
522
+ const ext = file.name.split(".").pop()?.toLowerCase() ?? "bin";
523
+ const safeExt = /^[a-z0-9]{1,5}$/.test(ext) ? ext : "bin";
524
+ const filename = `${Date.now()}-${randomUUID().slice(0, 8)}.${safeExt}`;
525
+ const key = `media/${userId}/${filename}`;
526
+
527
+ await c.env.MEDIA.put(key, file.stream(), {
528
+ httpMetadata: { contentType: fileType },
529
+ });
530
+
531
+ const secret = getJwtSecret(c.env);
532
+ const url = await signMediaUrl(userId, filename, secret, 3600);
533
+ return c.json({ url, key });
534
+ });
535
+
480
536
  // ---- Protected routes (require Bearer token) — AFTER ws routes ----
481
537
  app.route("/api", protectedApp);
482
538
 
@@ -1 +1 @@
1
- {"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../../src/channel.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../../src/channel.ts"],"names":[],"mappings":"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"}
@@ -2,7 +2,7 @@ import { deleteBotsChatAccount, listBotsChatAccountIds, resolveBotsChatAccount,
2
2
  import { getBotsChatRuntime } from "./runtime.js";
3
3
  import { BotsChatCloudClient } from "./ws-client.js";
4
4
  import crypto from "crypto";
5
- import { encryptText, decryptText, decryptBytes, toBase64, fromBase64 } from "./e2e-crypto.js";
5
+ import { encryptText, encryptBytes, decryptText, decryptBytes, toBase64, fromBase64 } from "./e2e-crypto.js";
6
6
  // ---------------------------------------------------------------------------
7
7
  // A2UI message-tool hints — injected via agentPrompt.messageToolHints so
8
8
  // the agent knows it can output interactive UI components. These strings
@@ -42,6 +42,8 @@ function readAgentModel(_agentId) {
42
42
  const cloudClients = new Map();
43
43
  /** Maps accountId → cloudUrl so handleCloudMessage can resolve relative URLs */
44
44
  const cloudUrls = new Map();
45
+ /** Maps accountId → pairingToken for plugin HTTP uploads */
46
+ const pairingTokens = new Map();
45
47
  function getCloudClient(accountId) {
46
48
  return cloudClients.get(accountId);
47
49
  }
@@ -141,16 +143,17 @@ export const botschatPlugin = {
141
143
  return { ok: true };
142
144
  },
143
145
  sendMedia: async (ctx) => {
144
- const client = getCloudClient(ctx.accountId ?? "default");
146
+ const accountId = ctx.accountId ?? "default";
147
+ const client = getCloudClient(accountId);
145
148
  if (!client?.connected) {
146
149
  return { ok: false, error: new Error("Not connected to BotsChat cloud") };
147
150
  }
148
151
  const messageId = crypto.randomUUID();
149
152
  let text = ctx.text;
150
153
  let encrypted = false;
151
- if (client.e2eKey && text) { // Only encrypt checksum if present
154
+ let mediaEncrypted = false;
155
+ if (client.e2eKey && text) {
152
156
  try {
153
- // Encrypt caption using messageId as contextId
154
157
  const ciphertext = await encryptText(client.e2eKey, text, messageId);
155
158
  text = toBase64(ciphertext);
156
159
  encrypted = true;
@@ -159,17 +162,60 @@ export const botschatPlugin = {
159
162
  return { ok: false, error: new Error(`Encryption failed: ${err}`) };
160
163
  }
161
164
  }
165
+ let finalMediaUrl = ctx.mediaUrl;
166
+ if (client.e2eKey && ctx.mediaUrl && !ctx.mediaUrl.startsWith("/api/media/")) {
167
+ try {
168
+ const baseUrl = cloudUrls.get(accountId);
169
+ const token = pairingTokens.get(accountId);
170
+ if (baseUrl && token) {
171
+ const resp = await fetch(ctx.mediaUrl, { signal: AbortSignal.timeout(15_000) });
172
+ if (resp.ok) {
173
+ const rawBytes = new Uint8Array(await resp.arrayBuffer());
174
+ const encBytes = await encryptBytes(client.e2eKey, rawBytes, `${messageId}:media`);
175
+ const contentType = resp.headers.get("Content-Type") ?? "application/octet-stream";
176
+ const extMap = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp" };
177
+ const ext = extMap[contentType] ?? (contentType.startsWith("image/") ? "png" : "bin");
178
+ const formData = new FormData();
179
+ const blob = new Blob([encBytes], { type: contentType });
180
+ formData.append("file", blob, `encrypted.${ext}`);
181
+ const uploadUrl = `${baseUrl.replace(/\/$/, "")}/api/plugin-upload`;
182
+ const uploadResp = await fetch(uploadUrl, {
183
+ method: "POST",
184
+ headers: { "X-Pairing-Token": token },
185
+ body: formData,
186
+ signal: AbortSignal.timeout(30_000),
187
+ });
188
+ if (uploadResp.ok) {
189
+ const result = await uploadResp.json();
190
+ finalMediaUrl = result.url;
191
+ mediaEncrypted = true;
192
+ console.log(`[botschat][sendMedia] E2E encrypted media uploaded (${rawBytes.length} → ${encBytes.length} bytes)`);
193
+ }
194
+ else {
195
+ console.error(`[botschat][sendMedia] Plugin upload failed: HTTP ${uploadResp.status}`);
196
+ }
197
+ }
198
+ else {
199
+ console.error(`[botschat][sendMedia] Failed to download media: HTTP ${resp.status}`);
200
+ }
201
+ }
202
+ }
203
+ catch (err) {
204
+ console.error(`[botschat][sendMedia] E2E media encryption failed, sending unencrypted:`, err);
205
+ }
206
+ }
162
207
  const notifyPreview = (encrypted && client.notifyPreview && ctx.text)
163
208
  ? (ctx.text.length > 100 ? ctx.text.slice(0, 100) + "…" : ctx.text)
164
209
  : undefined;
165
- if (ctx.mediaUrl) {
210
+ if (finalMediaUrl) {
166
211
  client.send({
167
212
  type: "agent.media",
168
213
  sessionKey: ctx.to,
169
- mediaUrl: ctx.mediaUrl,
214
+ mediaUrl: finalMediaUrl,
170
215
  caption: text || undefined,
171
216
  messageId,
172
217
  encrypted,
218
+ mediaEncrypted,
173
219
  ...(notifyPreview ? { notifyPreview } : {}),
174
220
  });
175
221
  }
@@ -193,6 +239,18 @@ export const botschatPlugin = {
193
239
  log?.warn(`[${accountId}] BotsChat not configured — skipping`);
194
240
  return;
195
241
  }
242
+ const existingClient = cloudClients.get(accountId);
243
+ if (existingClient?.connected) {
244
+ log?.info(`[${accountId}] Already connected — skipping restart`);
245
+ return existingClient;
246
+ }
247
+ if (existingClient) {
248
+ log?.info(`[${accountId}] Disconnecting stale client before reconnect`);
249
+ existingClient.disconnect();
250
+ cloudClients.delete(accountId);
251
+ cloudUrls.delete(accountId);
252
+ pairingTokens.delete(accountId);
253
+ }
196
254
  ctx.setStatus({
197
255
  ...ctx.getStatus(),
198
256
  accountId,
@@ -223,11 +281,13 @@ export const botschatPlugin = {
223
281
  });
224
282
  cloudClients.set(accountId, client);
225
283
  cloudUrls.set(accountId, account.cloudUrl);
284
+ pairingTokens.set(accountId, account.pairingToken);
226
285
  client.connect();
227
286
  ctx.abortSignal.addEventListener("abort", () => {
228
287
  client.disconnect();
229
288
  cloudClients.delete(accountId);
230
289
  cloudUrls.delete(accountId);
290
+ pairingTokens.delete(accountId);
231
291
  });
232
292
  return client;
233
293
  },
@@ -800,6 +860,9 @@ async function openclawCronAdd(msg, log) {
800
860
  if (/^at\s+/i.test(s)) {
801
861
  args.push("--at", s.replace(/^at\s+/i, ""));
802
862
  }
863
+ else if (/^cron\s+/i.test(s)) {
864
+ args.push("--cron", s.replace(/^cron\s+/i, ""));
865
+ }
803
866
  else if (s) {
804
867
  args.push("--every", s.replace(/^every\s+/i, ""));
805
868
  }
@@ -869,6 +932,9 @@ async function handleTaskSchedule(msg, ctx) {
869
932
  if (/^at\s+/i.test(s)) {
870
933
  args.push("--at", s.replace(/^at\s+/i, ""));
871
934
  }
935
+ else if (/^cron\s+/i.test(s)) {
936
+ args.push("--cron", s.replace(/^cron\s+/i, ""));
937
+ }
872
938
  else {
873
939
  args.push("--every", s.replace(/^every\s+/i, ""));
874
940
  }
@@ -1399,6 +1465,9 @@ async function handleTaskScanRequest(ctx) {
1399
1465
  else if (job.schedule.kind === "at" && job.schedule.at) {
1400
1466
  scheduleStr = `at ${job.schedule.at}`;
1401
1467
  }
1468
+ else if (job.schedule.kind === "cron" && job.schedule.expr) {
1469
+ scheduleStr = `cron ${job.schedule.expr}`;
1470
+ }
1402
1471
  }
1403
1472
  let lastRun;
1404
1473
  // Model: OpenClaw stores it in job.payload.model (agentTurn), not at job top level