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.
- package/package.json +1 -1
- package/packages/api/src/do/connection-do.ts +198 -60
- package/packages/api/src/env.ts +6 -0
- package/packages/api/src/index.ts +67 -15
- package/packages/api/src/utils/apns.ts +151 -0
- package/packages/api/src/utils/fcm.ts +23 -32
- package/packages/plugin/dist/src/channel.d.ts.map +1 -1
- package/packages/plugin/dist/src/channel.js +25 -2
- package/packages/plugin/dist/src/channel.js.map +1 -1
- package/packages/plugin/dist/src/types.d.ts +5 -0
- package/packages/plugin/dist/src/types.d.ts.map +1 -1
- package/packages/plugin/dist/src/ws-client.d.ts +2 -0
- package/packages/plugin/dist/src/ws-client.d.ts.map +1 -1
- package/packages/plugin/dist/src/ws-client.js +42 -4
- package/packages/plugin/dist/src/ws-client.js.map +1 -1
- package/packages/plugin/package.json +1 -1
- package/packages/web/dist/assets/index-CbCpFrA9.js +2 -0
- package/packages/web/dist/assets/index-Ct0m11C8.js +2 -0
- package/packages/web/dist/assets/index-CvbTpaza.js +1516 -0
- package/packages/web/dist/assets/{index-Civeg2lm.js → index-DsWBWQD6.js} +1 -1
- package/packages/web/dist/assets/index-GwprVhDP.js +1 -0
- package/packages/web/dist/assets/{index-Bd_RDcgO.css → index-cm_3YFsA.css} +1 -1
- package/packages/web/dist/assets/{index-lVB82JKU.js → index-dMn_npR3.js} +1 -1
- package/packages/web/dist/assets/{index.esm-CtMkqqqb.js → index.esm-DdTIpXjl.js} +1 -1
- package/packages/web/dist/assets/{web-CUXjh_UA.js → web-DIeOUVhn.js} +1 -1
- package/packages/web/dist/assets/{web-vKLTVUul.js → web-Dft_LGIH.js} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/src/App.tsx +92 -5
- package/packages/web/src/api.ts +2 -2
- package/packages/web/src/components/ChatWindow.tsx +27 -4
- package/packages/web/src/components/E2ESettings.tsx +70 -1
- package/packages/web/src/components/MobileLayout.tsx +7 -0
- package/packages/web/src/components/ThreadPanel.tsx +13 -4
- package/packages/web/src/foreground.ts +5 -1
- package/packages/web/src/push.ts +27 -3
- package/packages/web/src/utils/time.ts +23 -0
- package/scripts/mock-openclaw.mjs +13 -3
- package/packages/web/dist/assets/index-B9qN5gs6.js +0 -1
- package/packages/web/dist/assets/index-BQNMGVyU.js +0 -2
- package/packages/web/dist/assets/index-Dk33VSnY.js +0 -2
- package/packages/web/dist/assets/index-Kr85Nj_-.js +0 -1516
package/package.json
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
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(
|
|
201
|
-
|
|
202
|
-
|
|
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},
|
|
258
|
+
console.log(`[DO] auth.ok → userId=${userId}, closed=${closedCount}, total=${existingSockets.length}`);
|
|
211
259
|
ws.send(JSON.stringify({ type: "auth.ok", userId }));
|
|
212
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
307
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
634
|
-
const
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
|
|
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);
|
package/packages/api/src/env.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
.bind(clientIp, token)
|
|
376
|
-
|
|
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
|
-
|
|
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
|