botschat 0.1.13 → 0.1.15

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 (41) hide show
  1. package/package.json +1 -1
  2. package/packages/api/src/do/connection-do.ts +198 -60
  3. package/packages/api/src/env.ts +6 -0
  4. package/packages/api/src/index.ts +67 -15
  5. package/packages/api/src/utils/apns.ts +151 -0
  6. package/packages/api/src/utils/fcm.ts +23 -32
  7. package/packages/plugin/dist/src/channel.d.ts.map +1 -1
  8. package/packages/plugin/dist/src/channel.js +25 -2
  9. package/packages/plugin/dist/src/channel.js.map +1 -1
  10. package/packages/plugin/dist/src/types.d.ts +5 -0
  11. package/packages/plugin/dist/src/types.d.ts.map +1 -1
  12. package/packages/plugin/dist/src/ws-client.d.ts +2 -0
  13. package/packages/plugin/dist/src/ws-client.d.ts.map +1 -1
  14. package/packages/plugin/dist/src/ws-client.js +42 -4
  15. package/packages/plugin/dist/src/ws-client.js.map +1 -1
  16. package/packages/plugin/package.json +1 -1
  17. package/packages/web/dist/assets/index-CbCpFrA9.js +2 -0
  18. package/packages/web/dist/assets/index-Ct0m11C8.js +2 -0
  19. package/packages/web/dist/assets/index-CvbTpaza.js +1516 -0
  20. package/packages/web/dist/assets/{index-Civeg2lm.js → index-DsWBWQD6.js} +1 -1
  21. package/packages/web/dist/assets/index-GwprVhDP.js +1 -0
  22. package/packages/web/dist/assets/{index-Bd_RDcgO.css → index-cm_3YFsA.css} +1 -1
  23. package/packages/web/dist/assets/{index-lVB82JKU.js → index-dMn_npR3.js} +1 -1
  24. package/packages/web/dist/assets/{index.esm-CtMkqqqb.js → index.esm-DdTIpXjl.js} +1 -1
  25. package/packages/web/dist/assets/{web-CUXjh_UA.js → web-DIeOUVhn.js} +1 -1
  26. package/packages/web/dist/assets/{web-vKLTVUul.js → web-Dft_LGIH.js} +1 -1
  27. package/packages/web/dist/index.html +2 -2
  28. package/packages/web/src/App.tsx +92 -5
  29. package/packages/web/src/api.ts +2 -2
  30. package/packages/web/src/components/ChatWindow.tsx +27 -4
  31. package/packages/web/src/components/E2ESettings.tsx +70 -1
  32. package/packages/web/src/components/MobileLayout.tsx +7 -0
  33. package/packages/web/src/components/ThreadPanel.tsx +13 -4
  34. package/packages/web/src/foreground.ts +5 -1
  35. package/packages/web/src/push.ts +27 -3
  36. package/packages/web/src/utils/time.ts +23 -0
  37. package/scripts/mock-openclaw.mjs +13 -3
  38. package/packages/web/dist/assets/index-B9qN5gs6.js +0 -1
  39. package/packages/web/dist/assets/index-BQNMGVyU.js +0 -2
  40. package/packages/web/dist/assets/index-Dk33VSnY.js +0 -2
  41. package/packages/web/dist/assets/index-Kr85Nj_-.js +0 -1516
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "botschat",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "A self-hosted chat interface for OpenClaw AI agents",
5
5
  "workspaces": [
6
6
  "packages/*"
@@ -1,6 +1,7 @@
1
1
  import type { Env } from "../env.js";
2
2
  import { verifyToken, getJwtSecret, signMediaUrl } from "../utils/auth.js";
3
3
  import { getFcmAccessToken, sendPushNotification } from "../utils/fcm.js";
4
+ import { sendApnsNotification, type ApnsConfig } from "../utils/apns.js";
4
5
  import { generateId as generateIdUtil } from "../utils/id.js";
5
6
  import { randomUUID } from "../utils/uuid.js";
6
7
 
@@ -31,6 +32,9 @@ export class ConnectionDO implements DurableObject {
31
32
  /** Browser sessions that report themselves in foreground (push notifications are suppressed). */
32
33
  private foregroundSessions = new Set<string>();
33
34
 
35
+ /** Timestamp of last accepted OpenClaw WebSocket (in-memory, no storage write). */
36
+ private lastOpenClawAcceptedAt = 0;
37
+
34
38
  constructor(state: DurableObjectState, env: Env) {
35
39
  this.state = state;
36
40
  this.env = env;
@@ -38,16 +42,33 @@ export class ConnectionDO implements DurableObject {
38
42
 
39
43
  /** Handle incoming HTTP requests (WebSocket upgrades). */
40
44
  async fetch(request: Request): Promise<Response> {
45
+ try {
46
+ return await this._fetch(request);
47
+ } catch (err) {
48
+ const msg = String(err);
49
+ if (msg.includes("Exceeded")) {
50
+ console.error("[DO] Storage limit exceeded:", msg);
51
+ return new Response("Storage limit exceeded, retry later", {
52
+ status: 503,
53
+ headers: { "Retry-After": "300" },
54
+ });
55
+ }
56
+ throw err;
57
+ }
58
+ }
59
+
60
+ private async _fetch(request: Request): Promise<Response> {
41
61
  const url = new URL(request.url);
42
62
 
43
63
  // Route: /gateway/:accountId — OpenClaw plugin connects here
44
64
  if (url.pathname.startsWith("/gateway/")) {
45
- // Extract and store userId from the gateway path
46
65
  const userId = url.pathname.split("/gateway/")[1]?.split("?")[0];
47
66
  if (userId) {
48
- await this.state.storage.put("userId", userId);
67
+ const stored = await this.state.storage.get<string>("userId");
68
+ if (stored !== userId) {
69
+ await this.state.storage.put("userId", userId);
70
+ }
49
71
  }
50
- // Check if the API worker already verified the token against D1
51
72
  const preVerified = url.searchParams.get("verified") === "1";
52
73
  return this.handleOpenClawConnect(request, preVerified);
53
74
  }
@@ -91,22 +112,32 @@ export class ConnectionDO implements DurableObject {
91
112
 
92
113
  /** Called when a WebSocket receives a message (wakes from hibernation). */
93
114
  async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
94
- const tag = this.getTag(ws);
95
- const data = typeof message === "string" ? message : new TextDecoder().decode(message);
96
-
97
- let parsed: Record<string, unknown>;
98
115
  try {
99
- parsed = JSON.parse(data);
100
- } catch {
101
- return; // Ignore malformed JSON
102
- }
116
+ const tag = this.getTag(ws);
117
+ const data = typeof message === "string" ? message : new TextDecoder().decode(message);
103
118
 
104
- if (tag === "openclaw") {
105
- // Message from OpenClaw → handle auth or forward to browsers
106
- await this.handleOpenClawMessage(ws, parsed);
107
- } else if (tag?.startsWith("browser:")) {
108
- // Message from browser → forward to OpenClaw
109
- await this.handleBrowserMessage(ws, parsed);
119
+ let parsed: Record<string, unknown>;
120
+ try {
121
+ parsed = JSON.parse(data);
122
+ } catch {
123
+ return;
124
+ }
125
+
126
+ if (tag === "openclaw") {
127
+ await this.handleOpenClawMessage(ws, parsed);
128
+ } else if (tag?.startsWith("browser:")) {
129
+ await this.handleBrowserMessage(ws, parsed);
130
+ }
131
+ } catch (err) {
132
+ const msg = String(err);
133
+ if (msg.includes("Exceeded")) {
134
+ console.error("[DO] Storage limit exceeded in webSocketMessage:", msg);
135
+ try {
136
+ ws.send(JSON.stringify({ type: "error", message: "Storage limit exceeded, retry later" }));
137
+ } catch { /* socket may already be dead */ }
138
+ return;
139
+ }
140
+ throw err;
110
141
  }
111
142
  }
112
143
 
@@ -138,10 +169,31 @@ export class ConnectionDO implements DurableObject {
138
169
  return new Response("Expected WebSocket upgrade", { status: 426 });
139
170
  }
140
171
 
172
+ const now = Date.now();
173
+ const cooldownMs = 30_000;
174
+ if (now - this.lastOpenClawAcceptedAt < cooldownMs) {
175
+ const retryAfter = Math.ceil((cooldownMs - (now - this.lastOpenClawAcceptedAt)) / 1000);
176
+ return new Response("Too many connections, retry later", {
177
+ status: 429,
178
+ headers: { "Retry-After": String(retryAfter) },
179
+ });
180
+ }
181
+ this.lastOpenClawAcceptedAt = now;
182
+
183
+ // Safety valve: if stale openclaw sockets accumulated (e.g. from
184
+ // rapid reconnects that authenticated but then lost their edge
185
+ // connection), close them all before accepting a new one.
186
+ const existing = this.state.getWebSockets("openclaw");
187
+ if (existing.length > 3) {
188
+ console.warn(`[DO] Safety valve: ${existing.length} openclaw sockets, closing all`);
189
+ for (const s of existing) {
190
+ try { s.close(4009, "replaced"); } catch { /* dead */ }
191
+ }
192
+ }
193
+
141
194
  const pair = new WebSocketPair();
142
195
  const [client, server] = [pair[0], pair[1]];
143
196
 
144
- // Accept with Hibernation API, tag as "openclaw"
145
197
  this.state.acceptWebSocket(server, ["openclaw"]);
146
198
 
147
199
  // If the API worker already verified the token against D1, mark as
@@ -186,37 +238,35 @@ export class ConnectionDO implements DurableObject {
186
238
  const isValid = attachment?.preVerified || await this.validatePairingToken(token);
187
239
 
188
240
  if (isValid) {
189
- // Close any existing (potentially stale) openclaw sockets before
190
- // marking the new one as authenticated. Cloudflare's edge infra
191
- // terminates WebSocket connections every ~60 min (code 1006). The
192
- // plugin reconnects immediately, but the DO may not have detected
193
- // the old socket's death yet (no close frame → no webSocketClose
194
- // callback yet). Without this cleanup, getOpenClawSocket() could
195
- // return a stale/dead socket, silently dropping all messages.
241
+ // Close ALL other openclaw sockets. Use custom code 4009 so
242
+ // well-behaved plugins know they were replaced (not a crash)
243
+ // and should NOT reconnect. The Worker-level rate limit (10s)
244
+ // prevents the resulting close event from flooding the DO.
196
245
  const existingSockets = this.state.getWebSockets("openclaw");
246
+ let closedCount = 0;
197
247
  for (const oldWs of existingSockets) {
198
248
  if (oldWs !== ws) {
199
249
  try {
200
- oldWs.close(1000, "replaced by new connection");
201
- } catch {
202
- // Socket may already be dead ignore
203
- }
250
+ oldWs.close(4009, "replaced");
251
+ closedCount++;
252
+ } catch { /* already dead */ }
204
253
  }
205
254
  }
206
255
 
207
256
  ws.serializeAttachment({ ...attachment, authenticated: true });
208
- // Include userId so the plugin can derive the E2E key
209
257
  const userId = await this.state.storage.get<string>("userId");
210
- console.log(`[DO] auth.ok → userId=${userId}, closedStale=${existingSockets.length - 1}`);
258
+ console.log(`[DO] auth.ok → userId=${userId}, closed=${closedCount}, total=${existingSockets.length}`);
211
259
  ws.send(JSON.stringify({ type: "auth.ok", userId }));
212
- // Store gateway default model from plugin auth
213
- if (msg.model) {
260
+ if (msg.model && msg.model !== this.defaultModel) {
214
261
  this.defaultModel = msg.model as string;
215
262
  await this.state.storage.put("defaultModel", this.defaultModel);
216
263
  }
217
264
  // After auth, request task scan + models list from the plugin
218
265
  ws.send(JSON.stringify({ type: "task.scan.request" }));
219
266
  ws.send(JSON.stringify({ type: "models.request" }));
267
+ // Send notification preview preference to plugin
268
+ const notifyPreview = await this.getNotifyPreviewSetting(userId);
269
+ ws.send(JSON.stringify({ type: "settings.notifyPreview", enabled: notifyPreview }));
220
270
  // Notify all browser clients that OpenClaw is now connected
221
271
  this.broadcastToBrowsers(
222
272
  JSON.stringify({ type: "connection.status", openclawConnected: true, defaultModel: this.defaultModel, models: this.cachedModels }),
@@ -291,20 +341,24 @@ export class ConnectionDO implements DurableObject {
291
341
  await this.handleTaskScanResult(msg);
292
342
  }
293
343
 
294
- // Handle models list from plugin — persist to storage and broadcast to browsers
295
344
  if (msg.type === "models.list") {
296
- this.cachedModels = (msg.models as Array<{ id: string; name: string; provider: string }>) ?? [];
297
- await this.state.storage.put("cachedModels", this.cachedModels);
298
- console.log(`[DO] Persisted ${this.cachedModels.length} models to storage, broadcasting connection.status`);
345
+ const newModels = (msg.models as Array<{ id: string; name: string; provider: string }>) ?? [];
346
+ const changed = JSON.stringify(newModels) !== JSON.stringify(this.cachedModels);
347
+ this.cachedModels = newModels;
348
+ if (changed) {
349
+ await this.state.storage.put("cachedModels", this.cachedModels);
350
+ console.log(`[DO] Persisted ${this.cachedModels.length} models to storage`);
351
+ }
299
352
  this.broadcastToBrowsers(
300
353
  JSON.stringify({ type: "connection.status", openclawConnected: true, defaultModel: this.defaultModel, models: this.cachedModels }),
301
354
  );
302
355
  }
303
356
 
304
- // Plugin applied BotsChat default model to OpenClaw config — update and broadcast
305
357
  if (msg.type === "defaultModel.updated" && typeof msg.model === "string") {
306
- this.defaultModel = msg.model;
307
- await this.state.storage.put("defaultModel", this.defaultModel);
358
+ if (msg.model !== this.defaultModel) {
359
+ this.defaultModel = msg.model;
360
+ await this.state.storage.put("defaultModel", this.defaultModel);
361
+ }
308
362
  this.broadcastToBrowsers(
309
363
  JSON.stringify({ type: "connection.status", openclawConnected: true, defaultModel: this.defaultModel, models: this.cachedModels }),
310
364
  );
@@ -315,17 +369,19 @@ export class ConnectionDO implements DurableObject {
315
369
  await this.handleJobUpdate(msg);
316
370
  }
317
371
 
318
- // Forward all messages to browser clients
372
+ // Forward all messages to browser clients (strip notifyPreview — plaintext
373
+ // must not be relayed to browser WebSockets; browsers decrypt locally)
319
374
  if (msg.type === "agent.text") {
320
375
  console.log(`[DO] Forwarding agent.text to browsers: encrypted=${msg.encrypted}, messageId=${msg.messageId}, textLen=${typeof msg.text === "string" ? msg.text.length : "?"}`);
321
376
  }
322
- this.broadcastToBrowsers(JSON.stringify(msg));
377
+ const { notifyPreview: _stripped, ...msgForBrowser } = msg;
378
+ this.broadcastToBrowsers(JSON.stringify(msgForBrowser));
323
379
 
324
380
  // Send push notification if no browser session is in foreground
325
381
  if (
326
382
  (msg.type === "agent.text" || msg.type === "agent.media" || msg.type === "agent.a2ui") &&
327
383
  this.foregroundSessions.size === 0 &&
328
- this.env.FCM_SERVICE_ACCOUNT_JSON
384
+ (this.env.FCM_SERVICE_ACCOUNT_JSON || this.env.APNS_AUTH_KEY)
329
385
  ) {
330
386
  this.sendPushNotifications(msg).catch((err) => {
331
387
  console.error("[DO] Push notification failed:", err);
@@ -546,6 +602,24 @@ export class ConnectionDO implements DurableObject {
546
602
 
547
603
  // ---- Helpers ----
548
604
 
605
+ /** Read the user's notifyPreview preference from D1 settings_json. */
606
+ private async getNotifyPreviewSetting(userId?: string | null): Promise<boolean> {
607
+ const uid = userId ?? await this.state.storage.get<string>("userId");
608
+ if (!uid) return false;
609
+ try {
610
+ const row = await this.env.DB.prepare(
611
+ "SELECT settings_json FROM users WHERE id = ?",
612
+ ).bind(uid).first<{ settings_json: string }>();
613
+ if (row?.settings_json) {
614
+ const settings = JSON.parse(row.settings_json);
615
+ return settings.notifyPreview === true;
616
+ }
617
+ } catch (err) {
618
+ console.error("[DO] Failed to read notifyPreview setting:", err);
619
+ }
620
+ return false;
621
+ }
622
+
549
623
  /** Restore cachedModels and defaultModel from durable storage if in-memory cache is empty. */
550
624
  private async ensureCachedModels(): Promise<void> {
551
625
  if (this.cachedModels.length > 0) return;
@@ -599,7 +673,9 @@ export class ConnectionDO implements DurableObject {
599
673
  /**
600
674
  * Send push notifications to all of the user's registered devices.
601
675
  * Called when an agent message arrives and no browser session is in foreground.
602
- * Sends data-only FCM messages so clients can decrypt E2E content before showing.
676
+ *
677
+ * iOS tokens go directly to APNs (Capacitor returns raw APNs device tokens).
678
+ * Web/Android tokens go through FCM HTTP v1 API.
603
679
  */
604
680
  private async sendPushNotifications(msg: Record<string, unknown>): Promise<void> {
605
681
  const userId = await this.state.storage.get<string>("userId");
@@ -612,8 +688,8 @@ export class ConnectionDO implements DurableObject {
612
688
  .all<{ id: string; token: string; platform: string }>();
613
689
 
614
690
  if (!results || results.length === 0) return;
691
+ console.log(`[DO] Push: ${results.length} token(s) for ${userId} (ios: ${results.filter((r) => r.platform === "ios").length})`);
615
692
 
616
- // Build data payload — includes ciphertext so the client can decrypt
617
693
  const data: Record<string, string> = {
618
694
  type: msg.type as string,
619
695
  sessionKey: (msg.sessionKey as string) ?? "",
@@ -621,30 +697,88 @@ export class ConnectionDO implements DurableObject {
621
697
  encrypted: msg.encrypted ? "1" : "0",
622
698
  };
623
699
 
700
+ let notifBody = "New message";
624
701
  if (msg.type === "agent.text") {
625
702
  data.text = (msg.text as string) ?? "";
703
+ if (msg.encrypted && typeof msg.notifyPreview === "string" && msg.notifyPreview) {
704
+ const preview = msg.notifyPreview as string;
705
+ notifBody = preview.length > 100 ? preview.slice(0, 100) + "…" : preview;
706
+ } else if (!msg.encrypted && data.text) {
707
+ notifBody = data.text.length > 100 ? data.text.slice(0, 100) + "…" : data.text;
708
+ } else if (msg.encrypted) {
709
+ notifBody = "New encrypted message";
710
+ }
626
711
  } else if (msg.type === "agent.media") {
627
712
  data.text = (msg.caption as string) || "";
628
713
  data.mediaUrl = (msg.mediaUrl as string) ?? "";
714
+ notifBody = data.text || "Sent a media file";
629
715
  } else if (msg.type === "agent.a2ui") {
630
716
  data.text = "New interactive message";
717
+ notifBody = "New interactive message";
631
718
  }
632
719
 
633
- const accessToken = await getFcmAccessToken(this.env.FCM_SERVICE_ACCOUNT_JSON!);
634
- const projectId = this.env.FIREBASE_PROJECT_ID ?? "botschat-130ff";
635
-
720
+ const iosTokens = results.filter((r) => r.platform === "ios");
721
+ const androidTokens = results.filter((r) => r.platform === "android");
722
+ const webTokens = results.filter((r) => r.platform === "web");
636
723
  const invalidTokenIds: string[] = [];
637
- await Promise.allSettled(
638
- results.map(async (row) => {
639
- const ok = await sendPushNotification({
640
- accessToken,
641
- projectId,
642
- fcmToken: row.token,
643
- data,
644
- });
645
- if (!ok) invalidTokenIds.push(row.id);
646
- }),
647
- );
724
+ const notification = { title: "BotsChat", body: notifBody };
725
+
726
+ // iOS: send via APNs directly (Capacitor registers raw APNs device tokens)
727
+ if (iosTokens.length > 0 && this.env.APNS_AUTH_KEY) {
728
+ const apnsConfig: ApnsConfig = {
729
+ authKey: this.env.APNS_AUTH_KEY,
730
+ keyId: this.env.APNS_KEY_ID ?? "",
731
+ teamId: this.env.APNS_TEAM_ID ?? "",
732
+ bundleId: "app.botschat.console",
733
+ };
734
+ await Promise.allSettled(
735
+ iosTokens.map(async (row) => {
736
+ const ok = await sendApnsNotification({
737
+ config: apnsConfig,
738
+ deviceToken: row.token,
739
+ title: "BotsChat",
740
+ body: notifBody,
741
+ data,
742
+ });
743
+ if (!ok) invalidTokenIds.push(row.id);
744
+ }),
745
+ );
746
+ }
747
+
748
+ // Android: FCM with notification payload (data-only is silent in background)
749
+ if (androidTokens.length > 0 && this.env.FCM_SERVICE_ACCOUNT_JSON) {
750
+ const accessToken = await getFcmAccessToken(this.env.FCM_SERVICE_ACCOUNT_JSON);
751
+ const projectId = this.env.FIREBASE_PROJECT_ID ?? "botschat-130ff";
752
+ await Promise.allSettled(
753
+ androidTokens.map(async (row) => {
754
+ const ok = await sendPushNotification({
755
+ accessToken,
756
+ projectId,
757
+ fcmToken: row.token,
758
+ data,
759
+ notification,
760
+ });
761
+ if (!ok) invalidTokenIds.push(row.id);
762
+ }),
763
+ );
764
+ }
765
+
766
+ // Web: FCM data-only (Service Worker decrypts + shows notification)
767
+ if (webTokens.length > 0 && this.env.FCM_SERVICE_ACCOUNT_JSON) {
768
+ const accessToken = await getFcmAccessToken(this.env.FCM_SERVICE_ACCOUNT_JSON);
769
+ const projectId = this.env.FIREBASE_PROJECT_ID ?? "botschat-130ff";
770
+ await Promise.allSettled(
771
+ webTokens.map(async (row) => {
772
+ const ok = await sendPushNotification({
773
+ accessToken,
774
+ projectId,
775
+ fcmToken: row.token,
776
+ data,
777
+ });
778
+ if (!ok) invalidTokenIds.push(row.id);
779
+ }),
780
+ );
781
+ }
648
782
 
649
783
  // Clean up invalid/expired tokens
650
784
  for (const id of invalidTokenIds) {
@@ -1215,7 +1349,11 @@ export class ConnectionDO implements DurableObject {
1215
1349
  .first<{ user_id: string }>();
1216
1350
 
1217
1351
  const isValid = !!row;
1218
- await this.state.storage.put(cacheKey, { valid: isValid, cachedAt: Date.now() });
1352
+ try {
1353
+ await this.state.storage.put(cacheKey, { valid: isValid, cachedAt: Date.now() });
1354
+ } catch {
1355
+ // Non-critical — skip caching if storage is full
1356
+ }
1219
1357
  return isValid;
1220
1358
  } catch (err) {
1221
1359
  console.error("[DO] Failed to validate pairing token against D1:", err);
@@ -14,4 +14,10 @@ export type Env = {
14
14
  DEV_AUTH_SECRET?: string;
15
15
  /** FCM Service Account JSON for push notifications (stored as secret via `wrangler secret put`). */
16
16
  FCM_SERVICE_ACCOUNT_JSON?: string;
17
+ /** APNs Auth Key (.p8 content) for direct iOS push via APNs HTTP/2 API. */
18
+ APNS_AUTH_KEY?: string;
19
+ /** APNs Key ID (from Apple Developer portal, e.g. "3Q4V693LW4"). */
20
+ APNS_KEY_ID?: string;
21
+ /** Apple Developer Team ID (e.g. "C5N5PPC329"). */
22
+ APNS_TEAM_ID?: string;
17
23
  };
@@ -124,7 +124,7 @@ protectedApp.get("/me", async (c) => {
124
124
 
125
125
  protectedApp.patch("/me", async (c) => {
126
126
  const userId = c.get("userId");
127
- const body = await c.req.json<{ defaultModel?: string }>();
127
+ const body = await c.req.json<{ defaultModel?: string; notifyPreview?: boolean }>();
128
128
 
129
129
  // defaultModel is not stored in D1 — get/set only via plugin (connection.status / push).
130
130
  const existing = await c.env.DB.prepare(
@@ -135,7 +135,11 @@ protectedApp.patch("/me", async (c) => {
135
135
 
136
136
  const settings = JSON.parse(existing?.settings_json || "{}");
137
137
  delete settings.defaultModel;
138
- // Persist other settings (if any) to D1; defaultModel is never written.
138
+
139
+ if (body.notifyPreview !== undefined) {
140
+ settings.notifyPreview = body.notifyPreview;
141
+ }
142
+
139
143
  await c.env.DB.prepare(
140
144
  "UPDATE users SET settings_json = ? WHERE id = ?",
141
145
  )
@@ -161,6 +165,25 @@ protectedApp.patch("/me", async (c) => {
161
165
  }
162
166
  }
163
167
 
168
+ if (body.notifyPreview !== undefined) {
169
+ try {
170
+ const doId = c.env.CONNECTION_DO.idFromName(userId);
171
+ const stub = c.env.CONNECTION_DO.get(doId);
172
+ await stub.fetch(
173
+ new Request("https://internal/send", {
174
+ method: "POST",
175
+ headers: { "Content-Type": "application/json" },
176
+ body: JSON.stringify({
177
+ type: "settings.notifyPreview",
178
+ enabled: body.notifyPreview,
179
+ }),
180
+ }),
181
+ );
182
+ } catch (err) {
183
+ console.error("Failed to push notifyPreview to OpenClaw:", err);
184
+ }
185
+ }
186
+
164
187
  const outSettings = { ...settings };
165
188
  delete outSettings.defaultModel;
166
189
  return c.json({ ok: true, settings: outSettings });
@@ -342,7 +365,6 @@ async function verifyUserAccess(c: { req: { header: (n: string) => string | unde
342
365
  app.all("/api/gateway/:connId", async (c) => {
343
366
  let userId = c.req.param("connId");
344
367
 
345
- // If connId is not a real user ID (e.g. "default"), resolve via token
346
368
  if (!userId.startsWith("u_")) {
347
369
  const token =
348
370
  c.req.query("token") ??
@@ -353,7 +375,6 @@ app.all("/api/gateway/:connId", async (c) => {
353
375
  return c.json({ error: "Token required for gateway connection" }, 401);
354
376
  }
355
377
 
356
- // Look up user by pairing token (exclude revoked tokens)
357
378
  const row = await c.env.DB.prepare(
358
379
  "SELECT user_id FROM pairing_tokens WHERE token = ? AND revoked_at IS NULL",
359
380
  )
@@ -364,26 +385,57 @@ app.all("/api/gateway/:connId", async (c) => {
364
385
  return c.json({ error: "Invalid pairing token" }, 401);
365
386
  }
366
387
  userId = row.user_id;
388
+ }
367
389
 
368
- // Update audit fields: last_connected_at, last_ip, connection_count
390
+ // --- Worker-level rate limit (Cache API) ---
391
+ // Protects the DO from being woken up during reconnection storms.
392
+ // The Cache API persists across Worker isolates within the same colo.
393
+ const GATEWAY_COOLDOWN_S = 10;
394
+ const cache = caches.default;
395
+ const rateCacheUrl = `https://rate.internal/gateway/${userId}`;
396
+ const rateCacheReq = new Request(rateCacheUrl);
397
+ const rateCached = await cache.match(rateCacheReq);
398
+ if (rateCached) {
399
+ return c.text("Too many connections, retry later", 429, {
400
+ "Retry-After": String(GATEWAY_COOLDOWN_S),
401
+ });
402
+ }
403
+
404
+ // Audit: update pairing token stats (only when not rate-limited)
405
+ const token = c.req.query("token") ?? c.req.header("X-Pairing-Token");
406
+ if (token) {
369
407
  const clientIp = c.req.header("CF-Connecting-IP") ?? c.req.header("X-Forwarded-For") ?? "unknown";
370
- await c.env.DB.prepare(
371
- `UPDATE pairing_tokens
372
- SET last_connected_at = unixepoch(), last_ip = ?, connection_count = connection_count + 1
373
- WHERE token = ?`,
374
- )
375
- .bind(clientIp, token)
376
- .run();
408
+ c.executionCtx.waitUntil(
409
+ c.env.DB.prepare(
410
+ `UPDATE pairing_tokens
411
+ SET last_connected_at = unixepoch(), last_ip = ?, connection_count = connection_count + 1
412
+ WHERE token = ?`,
413
+ ).bind(clientIp, token).run(),
414
+ );
377
415
  }
378
416
 
379
417
  const doId = c.env.CONNECTION_DO.idFromName(userId);
380
418
  const stub = c.env.CONNECTION_DO.get(doId);
381
419
  const url = new URL(c.req.url);
382
- // Pass verified userId to DO — the API worker already validated the token
383
- // against D1 above, so DO can trust this.
384
420
  url.pathname = `/gateway/${userId}`;
385
421
  url.searchParams.set("verified", "1");
386
- return stub.fetch(new Request(url.toString(), c.req.raw));
422
+ const doResp = await stub.fetch(new Request(url.toString(), c.req.raw));
423
+
424
+ // Cache the rate limit after the DO responds (success or rate-limited).
425
+ // 101 = WebSocket accepted; 429 = DO's own rate limit.
426
+ // Either way, prevent further DO wake-ups for GATEWAY_COOLDOWN_S.
427
+ if (doResp.status === 101 || doResp.status === 429) {
428
+ c.executionCtx.waitUntil(
429
+ cache.put(
430
+ rateCacheReq,
431
+ new Response(null, {
432
+ headers: { "Cache-Control": `public, max-age=${GATEWAY_COOLDOWN_S}` },
433
+ }),
434
+ ),
435
+ );
436
+ }
437
+
438
+ return doResp;
387
439
  });
388
440
 
389
441
  // Browser client connects to: /api/ws/:userId/:sessionId