botschat 0.1.12 → 0.1.14
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/README.md +11 -15
- package/migrations/0012_push_tokens.sql +11 -0
- package/package.json +7 -1
- package/packages/api/src/do/connection-do.ts +176 -3
- package/packages/api/src/env.ts +8 -0
- package/packages/api/src/index.ts +29 -3
- package/packages/api/src/routes/auth.ts +39 -6
- package/packages/api/src/routes/push.ts +52 -0
- package/packages/api/src/utils/apns.ts +151 -0
- package/packages/api/src/utils/fcm.ts +158 -0
- package/packages/api/src/utils/firebase.ts +89 -1
- 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 +1 -0
- package/packages/plugin/dist/src/ws-client.d.ts.map +1 -1
- package/packages/plugin/dist/src/ws-client.js +1 -0
- package/packages/plugin/dist/src/ws-client.js.map +1 -1
- package/packages/plugin/package.json +1 -1
- package/packages/web/dist/assets/index-BJye3VHV.js +1516 -0
- package/packages/web/dist/assets/{index-CCBhODDo.css → index-CNSCbd7_.css} +1 -1
- package/packages/web/dist/assets/index-CPOiRHa4.js +2 -0
- package/packages/web/dist/assets/{index-Dx64BDkP.js → index-CQPXprFz.js} +1 -1
- package/packages/web/dist/assets/{index-CCFgKLX_.js → index-CkIgZfHf.js} +1 -1
- package/packages/web/dist/assets/index-DbUyNI4d.js +1 -0
- package/packages/web/dist/assets/index-Dpvhc_dU.js +2 -0
- package/packages/web/dist/assets/index.esm-DgcFARs7.js +599 -0
- package/packages/web/dist/assets/web-Bfku9Io_.js +1 -0
- package/packages/web/dist/assets/{web-DJQW-VLX.js → web-CnOlwlZw.js} +1 -1
- package/packages/web/dist/index.html +6 -4
- package/packages/web/dist/sw.js +158 -1
- package/packages/web/index.html +4 -2
- package/packages/web/src/App.tsx +130 -3
- package/packages/web/src/api.ts +12 -2
- package/packages/web/src/components/AccountSettings.tsx +131 -0
- package/packages/web/src/components/ChatWindow.tsx +20 -2
- package/packages/web/src/components/DataConsentModal.tsx +249 -0
- package/packages/web/src/components/E2ESettings.tsx +70 -1
- package/packages/web/src/components/LoginPage.tsx +49 -9
- package/packages/web/src/components/MobileLayout.tsx +7 -0
- package/packages/web/src/firebase.ts +89 -2
- package/packages/web/src/foreground.ts +55 -0
- package/packages/web/src/main.tsx +2 -1
- package/packages/web/src/push.ts +229 -0
- package/scripts/dev.sh +139 -13
- package/scripts/mock-openclaw.mjs +392 -0
- package/packages/web/dist/assets/index-D8mBAwjS.js +0 -1516
- package/packages/web/dist/assets/index-E-nzPZl8.js +0 -2
package/README.md
CHANGED
|
@@ -117,24 +117,17 @@ Clone, install, and run the server on your machine. Wrangler uses [Miniflare](ht
|
|
|
117
117
|
git clone https://github.com/botschat-app/botsChat.git
|
|
118
118
|
cd botsChat
|
|
119
119
|
npm install
|
|
120
|
-
# One-command startup: build web → migrate D1 → start on 0.0.0.0:8787
|
|
121
120
|
./scripts/dev.sh
|
|
122
121
|
```
|
|
123
122
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
```bash
|
|
127
|
-
npm run build -w packages/web # Build the React frontend
|
|
128
|
-
npm run db:migrate # Apply D1 migrations (local)
|
|
129
|
-
npx wrangler dev --config wrangler.toml --ip 0.0.0.0 # Start on port 8787
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
Open `http://localhost:8787` in your browser.
|
|
123
|
+
One command does everything: build frontend → migrate database → start server → launch Mock AI → open browser with auto-login. No environment variables to set, no separate terminals to manage.
|
|
133
124
|
|
|
134
125
|
Other dev commands:
|
|
135
126
|
|
|
136
127
|
```bash
|
|
137
|
-
./scripts/dev.sh reset # Nuke local DB → re-migrate → start
|
|
128
|
+
./scripts/dev.sh reset # Nuke local DB → re-migrate → start full dev env
|
|
129
|
+
./scripts/dev.sh server # Server only (no mock AI, no browser)
|
|
130
|
+
./scripts/dev.sh mock # Start mock OpenClaw standalone (foreground)
|
|
138
131
|
./scripts/dev.sh migrate # Only run D1 migrations
|
|
139
132
|
./scripts/dev.sh build # Only build web frontend
|
|
140
133
|
./scripts/dev.sh sync # Sync plugin to remote OpenClaw host + restart gateway
|
|
@@ -282,16 +275,19 @@ openclaw plugins remove botschat
|
|
|
282
275
|
|
|
283
276
|
## Development
|
|
284
277
|
|
|
285
|
-
|
|
278
|
+
See **[CONTRIBUTING.md](CONTRIBUTING.md)** for the full development guide — local setup, Mock OpenClaw for testing, architecture principles, WebSocket protocol, database migrations, and more.
|
|
279
|
+
|
|
280
|
+
### Quick Start
|
|
286
281
|
|
|
287
282
|
```bash
|
|
288
|
-
|
|
283
|
+
./scripts/dev.sh # Build + migrate + start server + mock AI + open browser
|
|
289
284
|
```
|
|
290
285
|
|
|
291
|
-
###
|
|
286
|
+
### Build
|
|
292
287
|
|
|
293
288
|
```bash
|
|
294
|
-
npm run
|
|
289
|
+
npm run build -w packages/web # Build frontend
|
|
290
|
+
npm run build -w packages/plugin # Build plugin
|
|
295
291
|
```
|
|
296
292
|
|
|
297
293
|
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
-- Push notification device tokens (FCM registration tokens)
|
|
2
|
+
CREATE TABLE IF NOT EXISTS push_tokens (
|
|
3
|
+
id TEXT PRIMARY KEY,
|
|
4
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
5
|
+
token TEXT NOT NULL,
|
|
6
|
+
platform TEXT NOT NULL CHECK (platform IN ('web', 'ios', 'android')),
|
|
7
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
8
|
+
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
9
|
+
UNIQUE(user_id, token)
|
|
10
|
+
);
|
|
11
|
+
CREATE INDEX IF NOT EXISTS idx_push_tokens_user ON push_tokens(user_id);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "botschat",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
4
4
|
"description": "A self-hosted chat interface for OpenClaw AI agents",
|
|
5
5
|
"workspaces": [
|
|
6
6
|
"packages/*"
|
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
"ios:open": "npx cap open ios",
|
|
21
21
|
"ios:run": "npx cap run ios",
|
|
22
22
|
"ios:sync": "npx cap sync ios",
|
|
23
|
+
"android:build": "npm run build -w packages/web && npx cap sync android",
|
|
24
|
+
"android:open": "npx cap open android",
|
|
25
|
+
"android:run": "npx cap run android",
|
|
26
|
+
"android:sync": "npx cap sync android",
|
|
23
27
|
"test:e2e": "npx tsx scripts/verify-e2e.ts && npx tsx packages/e2e-crypto/e2e-crypto.test.ts",
|
|
24
28
|
"test:e2e-db": "npx tsx scripts/verify-e2e-db.ts"
|
|
25
29
|
},
|
|
@@ -58,11 +62,13 @@
|
|
|
58
62
|
"wrangler": "^3.100.0"
|
|
59
63
|
},
|
|
60
64
|
"dependencies": {
|
|
65
|
+
"@capacitor/android": "^8.1.0",
|
|
61
66
|
"@capacitor/app": "^8.0.1",
|
|
62
67
|
"@capacitor/core": "^8.1.0",
|
|
63
68
|
"@capacitor/haptics": "^8.0.0",
|
|
64
69
|
"@capacitor/ios": "^8.1.0",
|
|
65
70
|
"@capacitor/keyboard": "^8.0.0",
|
|
71
|
+
"@capacitor/push-notifications": "^8.0.1",
|
|
66
72
|
"@capacitor/splash-screen": "^8.0.1",
|
|
67
73
|
"@capacitor/status-bar": "^8.0.1",
|
|
68
74
|
"@capgo/capacitor-social-login": "^8.3.1",
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Env } from "../env.js";
|
|
2
2
|
import { verifyToken, getJwtSecret, signMediaUrl } from "../utils/auth.js";
|
|
3
|
+
import { getFcmAccessToken, sendPushNotification } from "../utils/fcm.js";
|
|
4
|
+
import { sendApnsNotification, type ApnsConfig } from "../utils/apns.js";
|
|
3
5
|
import { generateId as generateIdUtil } from "../utils/id.js";
|
|
4
6
|
import { randomUUID } from "../utils/uuid.js";
|
|
5
7
|
|
|
@@ -27,6 +29,9 @@ export class ConnectionDO implements DurableObject {
|
|
|
27
29
|
/** Pending resolve for a real-time task.scan.request → task.scan.result round-trip. */
|
|
28
30
|
private pendingScanResolve: ((tasks: Array<Record<string, unknown>>) => void) | null = null;
|
|
29
31
|
|
|
32
|
+
/** Browser sessions that report themselves in foreground (push notifications are suppressed). */
|
|
33
|
+
private foregroundSessions = new Set<string>();
|
|
34
|
+
|
|
30
35
|
constructor(state: DurableObjectState, env: Env) {
|
|
31
36
|
this.state = state;
|
|
32
37
|
this.env = env;
|
|
@@ -115,7 +120,10 @@ export class ConnectionDO implements DurableObject {
|
|
|
115
120
|
JSON.stringify({ type: "openclaw.disconnected" }),
|
|
116
121
|
);
|
|
117
122
|
}
|
|
118
|
-
//
|
|
123
|
+
// Clean up foreground tracking for browser sessions
|
|
124
|
+
if (tag?.startsWith("browser:")) {
|
|
125
|
+
this.foregroundSessions.delete(tag);
|
|
126
|
+
}
|
|
119
127
|
}
|
|
120
128
|
|
|
121
129
|
/** Called when a WebSocket encounters an error. */
|
|
@@ -210,6 +218,9 @@ export class ConnectionDO implements DurableObject {
|
|
|
210
218
|
// After auth, request task scan + models list from the plugin
|
|
211
219
|
ws.send(JSON.stringify({ type: "task.scan.request" }));
|
|
212
220
|
ws.send(JSON.stringify({ type: "models.request" }));
|
|
221
|
+
// Send notification preview preference to plugin
|
|
222
|
+
const notifyPreview = await this.getNotifyPreviewSetting(userId);
|
|
223
|
+
ws.send(JSON.stringify({ type: "settings.notifyPreview", enabled: notifyPreview }));
|
|
213
224
|
// Notify all browser clients that OpenClaw is now connected
|
|
214
225
|
this.broadcastToBrowsers(
|
|
215
226
|
JSON.stringify({ type: "connection.status", openclawConnected: true, defaultModel: this.defaultModel, models: this.cachedModels }),
|
|
@@ -308,11 +319,24 @@ export class ConnectionDO implements DurableObject {
|
|
|
308
319
|
await this.handleJobUpdate(msg);
|
|
309
320
|
}
|
|
310
321
|
|
|
311
|
-
// Forward all messages to browser clients
|
|
322
|
+
// Forward all messages to browser clients (strip notifyPreview — plaintext
|
|
323
|
+
// must not be relayed to browser WebSockets; browsers decrypt locally)
|
|
312
324
|
if (msg.type === "agent.text") {
|
|
313
325
|
console.log(`[DO] Forwarding agent.text to browsers: encrypted=${msg.encrypted}, messageId=${msg.messageId}, textLen=${typeof msg.text === "string" ? msg.text.length : "?"}`);
|
|
314
326
|
}
|
|
315
|
-
|
|
327
|
+
const { notifyPreview: _stripped, ...msgForBrowser } = msg;
|
|
328
|
+
this.broadcastToBrowsers(JSON.stringify(msgForBrowser));
|
|
329
|
+
|
|
330
|
+
// Send push notification if no browser session is in foreground
|
|
331
|
+
if (
|
|
332
|
+
(msg.type === "agent.text" || msg.type === "agent.media" || msg.type === "agent.a2ui") &&
|
|
333
|
+
this.foregroundSessions.size === 0 &&
|
|
334
|
+
(this.env.FCM_SERVICE_ACCOUNT_JSON || this.env.APNS_AUTH_KEY)
|
|
335
|
+
) {
|
|
336
|
+
this.sendPushNotifications(msg).catch((err) => {
|
|
337
|
+
console.error("[DO] Push notification failed:", err);
|
|
338
|
+
});
|
|
339
|
+
}
|
|
316
340
|
}
|
|
317
341
|
|
|
318
342
|
private async handleBrowserMessage(
|
|
@@ -370,6 +394,18 @@ export class ConnectionDO implements DurableObject {
|
|
|
370
394
|
return;
|
|
371
395
|
}
|
|
372
396
|
|
|
397
|
+
// Handle foreground/background state tracking for push notifications
|
|
398
|
+
if (msg.type === "foreground.enter") {
|
|
399
|
+
const tag = attachment.tag;
|
|
400
|
+
if (tag) this.foregroundSessions.add(tag);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (msg.type === "foreground.leave") {
|
|
404
|
+
const tag = attachment.tag;
|
|
405
|
+
if (tag) this.foregroundSessions.delete(tag);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
373
409
|
// Persist user messages to D1
|
|
374
410
|
if (msg.type === "user.message") {
|
|
375
411
|
console.log("[DO] User inbound:", JSON.stringify({
|
|
@@ -516,6 +552,24 @@ export class ConnectionDO implements DurableObject {
|
|
|
516
552
|
|
|
517
553
|
// ---- Helpers ----
|
|
518
554
|
|
|
555
|
+
/** Read the user's notifyPreview preference from D1 settings_json. */
|
|
556
|
+
private async getNotifyPreviewSetting(userId?: string | null): Promise<boolean> {
|
|
557
|
+
const uid = userId ?? await this.state.storage.get<string>("userId");
|
|
558
|
+
if (!uid) return false;
|
|
559
|
+
try {
|
|
560
|
+
const row = await this.env.DB.prepare(
|
|
561
|
+
"SELECT settings_json FROM users WHERE id = ?",
|
|
562
|
+
).bind(uid).first<{ settings_json: string }>();
|
|
563
|
+
if (row?.settings_json) {
|
|
564
|
+
const settings = JSON.parse(row.settings_json);
|
|
565
|
+
return settings.notifyPreview === true;
|
|
566
|
+
}
|
|
567
|
+
} catch (err) {
|
|
568
|
+
console.error("[DO] Failed to read notifyPreview setting:", err);
|
|
569
|
+
}
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
572
|
+
|
|
519
573
|
/** Restore cachedModels and defaultModel from durable storage if in-memory cache is empty. */
|
|
520
574
|
private async ensureCachedModels(): Promise<void> {
|
|
521
575
|
if (this.cachedModels.length > 0) return;
|
|
@@ -564,6 +618,125 @@ export class ConnectionDO implements DurableObject {
|
|
|
564
618
|
}
|
|
565
619
|
}
|
|
566
620
|
|
|
621
|
+
// ---- Push notifications ----
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Send push notifications to all of the user's registered devices.
|
|
625
|
+
* Called when an agent message arrives and no browser session is in foreground.
|
|
626
|
+
*
|
|
627
|
+
* iOS tokens go directly to APNs (Capacitor returns raw APNs device tokens).
|
|
628
|
+
* Web/Android tokens go through FCM HTTP v1 API.
|
|
629
|
+
*/
|
|
630
|
+
private async sendPushNotifications(msg: Record<string, unknown>): Promise<void> {
|
|
631
|
+
const userId = await this.state.storage.get<string>("userId");
|
|
632
|
+
if (!userId) return;
|
|
633
|
+
|
|
634
|
+
const { results } = await this.env.DB.prepare(
|
|
635
|
+
"SELECT id, token, platform FROM push_tokens WHERE user_id = ?",
|
|
636
|
+
)
|
|
637
|
+
.bind(userId)
|
|
638
|
+
.all<{ id: string; token: string; platform: string }>();
|
|
639
|
+
|
|
640
|
+
if (!results || results.length === 0) return;
|
|
641
|
+
console.log(`[DO] Push: ${results.length} token(s) for ${userId} (ios: ${results.filter((r) => r.platform === "ios").length})`);
|
|
642
|
+
|
|
643
|
+
const data: Record<string, string> = {
|
|
644
|
+
type: msg.type as string,
|
|
645
|
+
sessionKey: (msg.sessionKey as string) ?? "",
|
|
646
|
+
messageId: (msg.messageId as string) ?? "",
|
|
647
|
+
encrypted: msg.encrypted ? "1" : "0",
|
|
648
|
+
};
|
|
649
|
+
|
|
650
|
+
let notifBody = "New message";
|
|
651
|
+
if (msg.type === "agent.text") {
|
|
652
|
+
data.text = (msg.text as string) ?? "";
|
|
653
|
+
if (msg.encrypted && typeof msg.notifyPreview === "string" && msg.notifyPreview) {
|
|
654
|
+
const preview = msg.notifyPreview as string;
|
|
655
|
+
notifBody = preview.length > 100 ? preview.slice(0, 100) + "…" : preview;
|
|
656
|
+
} else if (!msg.encrypted && data.text) {
|
|
657
|
+
notifBody = data.text.length > 100 ? data.text.slice(0, 100) + "…" : data.text;
|
|
658
|
+
} else if (msg.encrypted) {
|
|
659
|
+
notifBody = "New encrypted message";
|
|
660
|
+
}
|
|
661
|
+
} else if (msg.type === "agent.media") {
|
|
662
|
+
data.text = (msg.caption as string) || "";
|
|
663
|
+
data.mediaUrl = (msg.mediaUrl as string) ?? "";
|
|
664
|
+
notifBody = data.text || "Sent a media file";
|
|
665
|
+
} else if (msg.type === "agent.a2ui") {
|
|
666
|
+
data.text = "New interactive message";
|
|
667
|
+
notifBody = "New interactive message";
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const iosTokens = results.filter((r) => r.platform === "ios");
|
|
671
|
+
const androidTokens = results.filter((r) => r.platform === "android");
|
|
672
|
+
const webTokens = results.filter((r) => r.platform === "web");
|
|
673
|
+
const invalidTokenIds: string[] = [];
|
|
674
|
+
const notification = { title: "BotsChat", body: notifBody };
|
|
675
|
+
|
|
676
|
+
// iOS: send via APNs directly (Capacitor registers raw APNs device tokens)
|
|
677
|
+
if (iosTokens.length > 0 && this.env.APNS_AUTH_KEY) {
|
|
678
|
+
const apnsConfig: ApnsConfig = {
|
|
679
|
+
authKey: this.env.APNS_AUTH_KEY,
|
|
680
|
+
keyId: this.env.APNS_KEY_ID ?? "",
|
|
681
|
+
teamId: this.env.APNS_TEAM_ID ?? "",
|
|
682
|
+
bundleId: "app.botschat.console",
|
|
683
|
+
};
|
|
684
|
+
await Promise.allSettled(
|
|
685
|
+
iosTokens.map(async (row) => {
|
|
686
|
+
const ok = await sendApnsNotification({
|
|
687
|
+
config: apnsConfig,
|
|
688
|
+
deviceToken: row.token,
|
|
689
|
+
title: "BotsChat",
|
|
690
|
+
body: notifBody,
|
|
691
|
+
data,
|
|
692
|
+
});
|
|
693
|
+
if (!ok) invalidTokenIds.push(row.id);
|
|
694
|
+
}),
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Android: FCM with notification payload (data-only is silent in background)
|
|
699
|
+
if (androidTokens.length > 0 && this.env.FCM_SERVICE_ACCOUNT_JSON) {
|
|
700
|
+
const accessToken = await getFcmAccessToken(this.env.FCM_SERVICE_ACCOUNT_JSON);
|
|
701
|
+
const projectId = this.env.FIREBASE_PROJECT_ID ?? "botschat-130ff";
|
|
702
|
+
await Promise.allSettled(
|
|
703
|
+
androidTokens.map(async (row) => {
|
|
704
|
+
const ok = await sendPushNotification({
|
|
705
|
+
accessToken,
|
|
706
|
+
projectId,
|
|
707
|
+
fcmToken: row.token,
|
|
708
|
+
data,
|
|
709
|
+
notification,
|
|
710
|
+
});
|
|
711
|
+
if (!ok) invalidTokenIds.push(row.id);
|
|
712
|
+
}),
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Web: FCM data-only (Service Worker decrypts + shows notification)
|
|
717
|
+
if (webTokens.length > 0 && this.env.FCM_SERVICE_ACCOUNT_JSON) {
|
|
718
|
+
const accessToken = await getFcmAccessToken(this.env.FCM_SERVICE_ACCOUNT_JSON);
|
|
719
|
+
const projectId = this.env.FIREBASE_PROJECT_ID ?? "botschat-130ff";
|
|
720
|
+
await Promise.allSettled(
|
|
721
|
+
webTokens.map(async (row) => {
|
|
722
|
+
const ok = await sendPushNotification({
|
|
723
|
+
accessToken,
|
|
724
|
+
projectId,
|
|
725
|
+
fcmToken: row.token,
|
|
726
|
+
data,
|
|
727
|
+
});
|
|
728
|
+
if (!ok) invalidTokenIds.push(row.id);
|
|
729
|
+
}),
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Clean up invalid/expired tokens
|
|
734
|
+
for (const id of invalidTokenIds) {
|
|
735
|
+
await this.env.DB.prepare("DELETE FROM push_tokens WHERE id = ?").bind(id).run();
|
|
736
|
+
console.log(`[DO] Removed invalid push token: ${id}`);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
567
740
|
// ---- Media caching ----
|
|
568
741
|
|
|
569
742
|
// ---- SSRF protection ----
|
package/packages/api/src/env.ts
CHANGED
|
@@ -12,4 +12,12 @@ export type Env = {
|
|
|
12
12
|
PUBLIC_URL?: string;
|
|
13
13
|
/** Secret for dev-token auth bypass (automated testing). Endpoint is 404 when unset. */
|
|
14
14
|
DEV_AUTH_SECRET?: string;
|
|
15
|
+
/** FCM Service Account JSON for push notifications (stored as secret via `wrangler secret put`). */
|
|
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;
|
|
15
23
|
};
|
|
@@ -11,6 +11,7 @@ import { models } from "./routes/models.js";
|
|
|
11
11
|
import { pairing } from "./routes/pairing.js";
|
|
12
12
|
import { sessions } from "./routes/sessions.js";
|
|
13
13
|
import { upload } from "./routes/upload.js";
|
|
14
|
+
import { push } from "./routes/push.js";
|
|
14
15
|
import { setup } from "./routes/setup.js";
|
|
15
16
|
import { devAuth } from "./routes/dev-auth.js";
|
|
16
17
|
|
|
@@ -25,7 +26,8 @@ const PRODUCTION_ORIGINS = [
|
|
|
25
26
|
"https://botschat.app",
|
|
26
27
|
"https://botschat-api.auxtenwpc.workers.dev",
|
|
27
28
|
"capacitor://localhost", // iOS Capacitor app
|
|
28
|
-
"http://localhost", // Android Capacitor app
|
|
29
|
+
"http://localhost", // Android Capacitor app (http scheme)
|
|
30
|
+
"https://localhost", // Android Capacitor app (https scheme)
|
|
29
31
|
];
|
|
30
32
|
|
|
31
33
|
// CORS and security headers — skip for WebSocket upgrade requests
|
|
@@ -122,7 +124,7 @@ protectedApp.get("/me", async (c) => {
|
|
|
122
124
|
|
|
123
125
|
protectedApp.patch("/me", async (c) => {
|
|
124
126
|
const userId = c.get("userId");
|
|
125
|
-
const body = await c.req.json<{ defaultModel?: string }>();
|
|
127
|
+
const body = await c.req.json<{ defaultModel?: string; notifyPreview?: boolean }>();
|
|
126
128
|
|
|
127
129
|
// defaultModel is not stored in D1 — get/set only via plugin (connection.status / push).
|
|
128
130
|
const existing = await c.env.DB.prepare(
|
|
@@ -133,7 +135,11 @@ protectedApp.patch("/me", async (c) => {
|
|
|
133
135
|
|
|
134
136
|
const settings = JSON.parse(existing?.settings_json || "{}");
|
|
135
137
|
delete settings.defaultModel;
|
|
136
|
-
|
|
138
|
+
|
|
139
|
+
if (body.notifyPreview !== undefined) {
|
|
140
|
+
settings.notifyPreview = body.notifyPreview;
|
|
141
|
+
}
|
|
142
|
+
|
|
137
143
|
await c.env.DB.prepare(
|
|
138
144
|
"UPDATE users SET settings_json = ? WHERE id = ?",
|
|
139
145
|
)
|
|
@@ -159,6 +165,25 @@ protectedApp.patch("/me", async (c) => {
|
|
|
159
165
|
}
|
|
160
166
|
}
|
|
161
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
|
+
|
|
162
187
|
const outSettings = { ...settings };
|
|
163
188
|
delete outSettings.defaultModel;
|
|
164
189
|
return c.json({ ok: true, settings: outSettings });
|
|
@@ -275,6 +300,7 @@ protectedApp.route("/channels/:channelId/tasks/:taskId/jobs", jobs);
|
|
|
275
300
|
// Nested session routes under /api/channels/:channelId/sessions
|
|
276
301
|
protectedApp.route("/channels/:channelId/sessions", sessions);
|
|
277
302
|
protectedApp.route("/pairing-tokens", pairing);
|
|
303
|
+
protectedApp.route("/push-tokens", push);
|
|
278
304
|
protectedApp.route("/upload", upload);
|
|
279
305
|
|
|
280
306
|
// ---- Media serving route (signed URL or Bearer auth) ----
|
|
@@ -155,11 +155,6 @@ async function handleFirebaseAuth(c: {
|
|
|
155
155
|
return c.json({ error: msg }, 401);
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
const email = firebaseUser.email?.toLowerCase();
|
|
159
|
-
if (!email) {
|
|
160
|
-
return c.json({ error: "Account has no email address" }, 400);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
158
|
const firebaseUid = firebaseUser.sub;
|
|
164
159
|
// Determine provider from Firebase token (google.com, github.com, etc.)
|
|
165
160
|
const signInProvider = firebaseUser.firebase?.sign_in_provider ?? "unknown";
|
|
@@ -167,7 +162,21 @@ async function handleFirebaseAuth(c: {
|
|
|
167
162
|
? "google"
|
|
168
163
|
: signInProvider.includes("github")
|
|
169
164
|
? "github"
|
|
170
|
-
: signInProvider
|
|
165
|
+
: signInProvider.includes("apple")
|
|
166
|
+
? "apple"
|
|
167
|
+
: signInProvider;
|
|
168
|
+
|
|
169
|
+
// Apple Sign-In may hide the user's real email; generate a short placeholder
|
|
170
|
+
let email = firebaseUser.email?.toLowerCase() || null;
|
|
171
|
+
if (!email && authProvider === "apple") {
|
|
172
|
+
const hash = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(firebaseUid))))
|
|
173
|
+
.slice(0, 6).map(b => b.toString(16).padStart(2, "0")).join("");
|
|
174
|
+
email = `apple_${hash}@privaterelay.appleid.com`;
|
|
175
|
+
}
|
|
176
|
+
if (!email) {
|
|
177
|
+
return c.json({ error: "Account has no email address" }, 400);
|
|
178
|
+
}
|
|
179
|
+
|
|
171
180
|
const displayName = firebaseUser.name ?? email.split("@")[0];
|
|
172
181
|
|
|
173
182
|
// 2. Look up existing user by firebase_uid first, then by email
|
|
@@ -241,6 +250,7 @@ async function handleFirebaseAuth(c: {
|
|
|
241
250
|
auth.post("/firebase", (c) => handleFirebaseAuth(c));
|
|
242
251
|
auth.post("/google", (c) => handleFirebaseAuth(c));
|
|
243
252
|
auth.post("/github", (c) => handleFirebaseAuth(c));
|
|
253
|
+
auth.post("/apple", (c) => handleFirebaseAuth(c));
|
|
244
254
|
|
|
245
255
|
/**
|
|
246
256
|
* POST /api/auth/dev-login — development-only passwordless login by email.
|
|
@@ -307,6 +317,7 @@ auth.get("/config", (c) => {
|
|
|
307
317
|
emailEnabled: isDev,
|
|
308
318
|
googleEnabled: !!c.env.FIREBASE_PROJECT_ID,
|
|
309
319
|
githubEnabled: !!c.env.FIREBASE_PROJECT_ID,
|
|
320
|
+
appleEnabled: !!c.env.FIREBASE_PROJECT_ID,
|
|
310
321
|
});
|
|
311
322
|
});
|
|
312
323
|
|
|
@@ -342,4 +353,26 @@ auth.get("/me", async (c) => {
|
|
|
342
353
|
});
|
|
343
354
|
});
|
|
344
355
|
|
|
356
|
+
/** DELETE /api/auth/account — permanently delete the authenticated user's account and all data */
|
|
357
|
+
auth.delete("/account", async (c) => {
|
|
358
|
+
const userId = c.get("userId" as never) as string;
|
|
359
|
+
if (!userId) return c.json({ error: "Unauthorized" }, 401);
|
|
360
|
+
|
|
361
|
+
// Delete all user media from R2
|
|
362
|
+
const prefix = `${userId}/`;
|
|
363
|
+
let cursor: string | undefined;
|
|
364
|
+
do {
|
|
365
|
+
const listed = await c.env.MEDIA.list({ prefix, cursor });
|
|
366
|
+
if (listed.objects.length > 0) {
|
|
367
|
+
await Promise.all(listed.objects.map(obj => c.env.MEDIA.delete(obj.key)));
|
|
368
|
+
}
|
|
369
|
+
cursor = listed.truncated ? listed.cursor : undefined;
|
|
370
|
+
} while (cursor);
|
|
371
|
+
|
|
372
|
+
// Delete user record — all related tables use ON DELETE CASCADE
|
|
373
|
+
await c.env.DB.prepare("DELETE FROM users WHERE id = ?").bind(userId).run();
|
|
374
|
+
|
|
375
|
+
return c.json({ ok: true });
|
|
376
|
+
});
|
|
377
|
+
|
|
345
378
|
export { auth };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { Env } from "../env.js";
|
|
3
|
+
import { generateId } from "../utils/id.js";
|
|
4
|
+
|
|
5
|
+
const push = new Hono<{ Bindings: Env; Variables: { userId: string } }>();
|
|
6
|
+
|
|
7
|
+
/** POST /api/push-tokens — register or update a device push token */
|
|
8
|
+
push.post("/", async (c) => {
|
|
9
|
+
const userId = c.get("userId");
|
|
10
|
+
const { token, platform } = await c.req.json<{ token: string; platform: string }>();
|
|
11
|
+
|
|
12
|
+
if (!token || !platform) {
|
|
13
|
+
return c.json({ error: "Missing token or platform" }, 400);
|
|
14
|
+
}
|
|
15
|
+
if (!["web", "ios", "android"].includes(platform)) {
|
|
16
|
+
return c.json({ error: "Invalid platform (web | ios | android)" }, 400);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const id = generateId("pt_");
|
|
20
|
+
const now = Math.floor(Date.now() / 1000);
|
|
21
|
+
|
|
22
|
+
// Upsert: if the same user+token already exists, update the timestamp
|
|
23
|
+
await c.env.DB.prepare(
|
|
24
|
+
`INSERT INTO push_tokens (id, user_id, token, platform, created_at, updated_at)
|
|
25
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
26
|
+
ON CONFLICT(user_id, token) DO UPDATE SET platform = excluded.platform, updated_at = excluded.updated_at`,
|
|
27
|
+
)
|
|
28
|
+
.bind(id, userId, token, platform, now, now)
|
|
29
|
+
.run();
|
|
30
|
+
|
|
31
|
+
return c.json({ ok: true, id }, 201);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** DELETE /api/push-tokens — unregister a device push token */
|
|
35
|
+
push.delete("/", async (c) => {
|
|
36
|
+
const userId = c.get("userId");
|
|
37
|
+
const { token } = await c.req.json<{ token: string }>().catch(() => ({ token: "" }));
|
|
38
|
+
|
|
39
|
+
if (!token) {
|
|
40
|
+
return c.json({ error: "Missing token" }, 400);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
await c.env.DB.prepare(
|
|
44
|
+
"DELETE FROM push_tokens WHERE user_id = ? AND token = ?",
|
|
45
|
+
)
|
|
46
|
+
.bind(userId, token)
|
|
47
|
+
.run();
|
|
48
|
+
|
|
49
|
+
return c.json({ ok: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
export { push };
|