botschat 0.1.10 → 0.1.13

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 (56) hide show
  1. package/README.md +11 -15
  2. package/migrations/0012_push_tokens.sql +11 -0
  3. package/package.json +20 -1
  4. package/packages/api/src/do/connection-do.ts +142 -24
  5. package/packages/api/src/env.ts +6 -0
  6. package/packages/api/src/index.ts +7 -0
  7. package/packages/api/src/routes/auth.ts +85 -9
  8. package/packages/api/src/routes/channels.ts +3 -2
  9. package/packages/api/src/routes/dev-auth.ts +45 -0
  10. package/packages/api/src/routes/push.ts +52 -0
  11. package/packages/api/src/routes/upload.ts +73 -38
  12. package/packages/api/src/utils/fcm.ts +167 -0
  13. package/packages/api/src/utils/firebase.ts +218 -0
  14. package/packages/plugin/dist/src/channel.d.ts +6 -0
  15. package/packages/plugin/dist/src/channel.d.ts.map +1 -1
  16. package/packages/plugin/dist/src/channel.js +71 -15
  17. package/packages/plugin/dist/src/channel.js.map +1 -1
  18. package/packages/plugin/package.json +1 -1
  19. package/packages/web/dist/assets/index-B9qN5gs6.js +1 -0
  20. package/packages/web/dist/assets/index-BQNMGVyU.js +2 -0
  21. package/packages/web/dist/assets/{index-Ev5M8VmV.css → index-Bd_RDcgO.css} +1 -1
  22. package/packages/web/dist/assets/index-Civeg2lm.js +1 -0
  23. package/packages/web/dist/assets/index-Dk33VSnY.js +2 -0
  24. package/packages/web/dist/assets/index-Kr85Nj_-.js +1516 -0
  25. package/packages/web/dist/assets/index-lVB82JKU.js +1 -0
  26. package/packages/web/dist/assets/index.esm-CtMkqqqb.js +599 -0
  27. package/packages/web/dist/assets/web-CUXjh_UA.js +1 -0
  28. package/packages/web/dist/assets/web-vKLTVUul.js +1 -0
  29. package/packages/web/dist/index.html +6 -4
  30. package/packages/web/dist/sw.js +158 -1
  31. package/packages/web/index.html +4 -2
  32. package/packages/web/package.json +4 -1
  33. package/packages/web/src/App.tsx +117 -1
  34. package/packages/web/src/api.ts +21 -1
  35. package/packages/web/src/components/AccountSettings.tsx +131 -0
  36. package/packages/web/src/components/ChatWindow.tsx +302 -70
  37. package/packages/web/src/components/CronSidebar.tsx +89 -24
  38. package/packages/web/src/components/DataConsentModal.tsx +249 -0
  39. package/packages/web/src/components/LoginPage.tsx +55 -7
  40. package/packages/web/src/components/MessageContent.tsx +71 -9
  41. package/packages/web/src/components/MobileLayout.tsx +28 -118
  42. package/packages/web/src/components/SessionTabs.tsx +41 -2
  43. package/packages/web/src/components/Sidebar.tsx +88 -66
  44. package/packages/web/src/e2e.ts +26 -5
  45. package/packages/web/src/firebase.ts +215 -3
  46. package/packages/web/src/foreground.ts +51 -0
  47. package/packages/web/src/index.css +10 -2
  48. package/packages/web/src/main.tsx +24 -2
  49. package/packages/web/src/push.ts +205 -0
  50. package/packages/web/src/ws.ts +20 -8
  51. package/scripts/dev.sh +158 -26
  52. package/scripts/mock-openclaw.mjs +382 -0
  53. package/scripts/test-e2e-chat.ts +2 -2
  54. package/scripts/test-e2e-live.ts +1 -1
  55. package/wrangler.toml +3 -0
  56. package/packages/web/dist/assets/index-DpW6VzZK.js +0 -1497
@@ -7,14 +7,24 @@
7
7
  * VITE_FIREBASE_API_KEY=AIzaSy...
8
8
  * VITE_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
9
9
  * VITE_FIREBASE_PROJECT_ID=your-project-id
10
+ *
11
+ * For Capacitor iOS/Android, also set:
12
+ * VITE_GOOGLE_IOS_CLIENT_ID=xxx.apps.googleusercontent.com
13
+ * VITE_GOOGLE_WEB_CLIENT_ID=xxx.apps.googleusercontent.com
10
14
  */
11
15
 
16
+ import { Capacitor } from "@capacitor/core";
12
17
  import { initializeApp, type FirebaseApp } from "firebase/app";
13
18
  import {
14
19
  getAuth,
15
20
  GoogleAuthProvider,
16
21
  GithubAuthProvider,
22
+ OAuthProvider,
17
23
  signInWithPopup,
24
+ signInWithCredential,
25
+ indexedDBLocalPersistence,
26
+ inMemoryPersistence,
27
+ setPersistence,
18
28
  type Auth,
19
29
  } from "firebase/auth";
20
30
 
@@ -22,6 +32,8 @@ const firebaseConfig = {
22
32
  apiKey: import.meta.env.VITE_FIREBASE_API_KEY as string,
23
33
  authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN as string,
24
34
  projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID as string,
35
+ messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID as string,
36
+ appId: import.meta.env.VITE_FIREBASE_APP_ID as string,
25
37
  };
26
38
 
27
39
  let app: FirebaseApp | null = null;
@@ -32,6 +44,14 @@ export function isFirebaseConfigured(): boolean {
32
44
  return !!(firebaseConfig.apiKey && firebaseConfig.authDomain && firebaseConfig.projectId);
33
45
  }
34
46
 
47
+ /** Ensure the Firebase app is initialized (for FCM, independent of OAuth). */
48
+ export function ensureFirebaseApp(): FirebaseApp | null {
49
+ if (app) return app;
50
+ if (!isFirebaseConfigured()) return null;
51
+ app = initializeApp(firebaseConfig);
52
+ return app;
53
+ }
54
+
35
55
  function getFirebaseAuth(): Auth {
36
56
  if (!auth) {
37
57
  if (!isFirebaseConfigured()) {
@@ -39,6 +59,12 @@ function getFirebaseAuth(): Auth {
39
59
  }
40
60
  app = initializeApp(firebaseConfig);
41
61
  auth = getAuth(app);
62
+
63
+ // In Capacitor native, WKWebView's IndexedDB can hang Firebase Auth.
64
+ // Use in-memory persistence to avoid this.
65
+ if (Capacitor.isNativePlatform()) {
66
+ setPersistence(auth, inMemoryPersistence).catch(() => {});
67
+ }
42
68
  }
43
69
  return auth;
44
70
  }
@@ -48,13 +74,121 @@ export type FirebaseSignInResult = {
48
74
  email: string;
49
75
  displayName: string | null;
50
76
  photoURL: string | null;
51
- provider: "google" | "github";
77
+ provider: "google" | "github" | "apple";
52
78
  };
53
79
 
80
+ // ---------------------------------------------------------------------------
81
+ // Native Google Sign-In via @capgo/capacitor-social-login
82
+ // ---------------------------------------------------------------------------
83
+
84
+ let _socialLoginInitialized = false;
85
+
86
+ /** Race a promise against a timeout. */
87
+ function withTimeout<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
88
+ return new Promise<T>((resolve, reject) => {
89
+ const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
90
+ promise.then(
91
+ (v) => { clearTimeout(timer); resolve(v); },
92
+ (e) => { clearTimeout(timer); reject(e); },
93
+ );
94
+ });
95
+ }
96
+
54
97
  /**
55
- * Sign in with Google via popup and return the Firebase ID token.
98
+ * Initialize the native social login plugin (called once).
99
+ * On web (non-Capacitor) this is a no-op.
100
+ */
101
+ async function ensureNativeGoogleInit(): Promise<void> {
102
+ if (!Capacitor.isNativePlatform() || _socialLoginInitialized) return;
103
+
104
+ const { SocialLogin } = await import("@capgo/capacitor-social-login");
105
+
106
+ const iosClientId = import.meta.env.VITE_GOOGLE_IOS_CLIENT_ID as string | undefined;
107
+ const webClientId = import.meta.env.VITE_GOOGLE_WEB_CLIENT_ID as string | undefined;
108
+ const platform = Capacitor.getPlatform();
109
+
110
+ console.log("[NativeGoogleSignIn] initialize: platform =", platform, "iOSClientId =", iosClientId?.substring(0, 20) + "...", "webClientId =", webClientId?.substring(0, 20) + "...");
111
+
112
+ await withTimeout(
113
+ SocialLogin.initialize({
114
+ google: {
115
+ webClientId: webClientId || undefined,
116
+ iOSClientId: iosClientId || undefined,
117
+ iOSServerClientId: webClientId || undefined,
118
+ },
119
+ }),
120
+ 10000,
121
+ "SocialLogin.initialize",
122
+ );
123
+
124
+ _socialLoginInitialized = true;
125
+ console.log("[NativeGoogleSignIn] initialized OK");
126
+ }
127
+
128
+ /**
129
+ * Perform native Google Sign-In on iOS/Android, then exchange the Google
130
+ * credential for a Firebase ID token via `signInWithCredential`.
131
+ */
132
+ async function nativeGoogleSignIn(): Promise<FirebaseSignInResult> {
133
+ console.log("[NativeGoogleSignIn] Step 1: ensureNativeGoogleInit");
134
+ await ensureNativeGoogleInit();
135
+
136
+ console.log("[NativeGoogleSignIn] Step 2: calling SocialLogin.login()");
137
+ const { SocialLogin } = await import("@capgo/capacitor-social-login");
138
+
139
+ // SocialLogin.login() opens native Google UI — user picks account.
140
+ // No timeout here because user interaction takes variable time.
141
+ const res = await SocialLogin.login({
142
+ provider: "google",
143
+ options: { scopes: ["email", "profile"] },
144
+ });
145
+
146
+ console.log("[NativeGoogleSignIn] Step 3: SocialLogin.login() returned, responseType =", res?.result?.responseType);
147
+
148
+ const googleResult = res.result;
149
+
150
+ // Narrow the union: online mode returns idToken + profile, offline returns serverAuthCode
151
+ if (googleResult.responseType !== "online") {
152
+ throw new Error(`Google Sign-In returned '${googleResult.responseType}' response; expected 'online'. Full result: ${JSON.stringify(res)}`);
153
+ }
154
+
155
+ const googleIdToken = googleResult.idToken;
156
+ console.log("[NativeGoogleSignIn] Step 4: idToken present =", !!googleIdToken, ", length =", googleIdToken?.length ?? 0);
157
+
158
+ if (!googleIdToken) {
159
+ throw new Error("Google Sign-In did not return an idToken. Ensure Web Client ID (iOSServerClientId) is correct.");
160
+ }
161
+
162
+ // Send the Google ID token directly to the backend — backend now verifies
163
+ // both Firebase ID tokens and native Google ID tokens.
164
+ // (Firebase signInWithCredential hangs in WKWebView on real devices.)
165
+ console.log("[NativeGoogleSignIn] Step 5: Skipping Firebase client, sending Google ID token directly to backend");
166
+
167
+ return {
168
+ idToken: googleIdToken,
169
+ email: googleResult.profile.email ?? "",
170
+ displayName: googleResult.profile.name ?? null,
171
+ photoURL: googleResult.profile.imageUrl ?? null,
172
+ provider: "google",
173
+ };
174
+ }
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Public API
178
+ // ---------------------------------------------------------------------------
179
+
180
+ /**
181
+ * Sign in with Google.
182
+ * - Web: Firebase popup
183
+ * - Native (iOS/Android): Native Google Sign-In → Firebase credential
56
184
  */
57
185
  export async function signInWithGoogle(): Promise<FirebaseSignInResult> {
186
+ // Native: use @capgo/capacitor-social-login + Firebase signInWithCredential
187
+ if (Capacitor.isNativePlatform()) {
188
+ return nativeGoogleSignIn();
189
+ }
190
+
191
+ // Web: use Firebase popup (works fine in browsers)
58
192
  const firebaseAuth = getFirebaseAuth();
59
193
  const provider = new GoogleAuthProvider();
60
194
  provider.addScope("email");
@@ -73,7 +207,10 @@ export async function signInWithGoogle(): Promise<FirebaseSignInResult> {
73
207
  }
74
208
 
75
209
  /**
76
- * Sign in with GitHub via popup and return the Firebase ID token.
210
+ * Sign in with GitHub.
211
+ * - Web: Firebase popup
212
+ * - Native: Firebase popup (GitHub OAuth works in WKWebView with some config)
213
+ * TODO: Implement native GitHub OAuth if popup doesn't work on native.
77
214
  */
78
215
  export async function signInWithGitHub(): Promise<FirebaseSignInResult> {
79
216
  const firebaseAuth = getFirebaseAuth();
@@ -91,3 +228,78 @@ export async function signInWithGitHub(): Promise<FirebaseSignInResult> {
91
228
  provider: "github",
92
229
  };
93
230
  }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Apple Sign-In
234
+ // ---------------------------------------------------------------------------
235
+
236
+ /**
237
+ * Native Apple Sign-In on iOS via @capgo/capacitor-social-login,
238
+ * then exchange for Firebase credential.
239
+ */
240
+ async function nativeAppleSignIn(): Promise<FirebaseSignInResult> {
241
+ console.log("[NativeAppleSignIn] Step 1: initializing");
242
+ const { SocialLogin } = await import("@capgo/capacitor-social-login");
243
+
244
+ if (!_socialLoginInitialized) {
245
+ await withTimeout(
246
+ SocialLogin.initialize({ apple: {} }),
247
+ 10000,
248
+ "SocialLogin.initialize(apple)",
249
+ );
250
+ _socialLoginInitialized = true;
251
+ }
252
+
253
+ console.log("[NativeAppleSignIn] Step 2: calling SocialLogin.login()");
254
+ const res = await SocialLogin.login({
255
+ provider: "apple",
256
+ options: { scopes: ["email", "name"] },
257
+ });
258
+
259
+ console.log("[NativeAppleSignIn] Step 3: SocialLogin.login() returned", JSON.stringify(res).substring(0, 200));
260
+
261
+ const appleResult = res.result as any;
262
+ const appleIdToken = appleResult.idToken;
263
+ if (!appleIdToken) {
264
+ throw new Error("Apple Sign-In did not return an idToken");
265
+ }
266
+
267
+ // Send the Apple ID token directly to the backend — skip Firebase client
268
+ // (signInWithCredential hangs in WKWebView on real devices, same as Google)
269
+ console.log("[NativeAppleSignIn] Step 4: Skipping Firebase client, sending Apple ID token directly to backend");
270
+
271
+ return {
272
+ idToken: appleIdToken,
273
+ email: appleResult.profile?.email ?? appleResult.email ?? "",
274
+ displayName: appleResult.profile?.name ?? appleResult.fullName?.givenName ?? null,
275
+ photoURL: null,
276
+ provider: "apple",
277
+ };
278
+ }
279
+
280
+ /**
281
+ * Sign in with Apple.
282
+ * - Web: Firebase popup with Apple OAuthProvider
283
+ * - Native iOS: Native Apple Sign-In via @capgo/capacitor-social-login → Firebase credential
284
+ */
285
+ export async function signInWithApple(): Promise<FirebaseSignInResult> {
286
+ if (Capacitor.isNativePlatform()) {
287
+ return nativeAppleSignIn();
288
+ }
289
+
290
+ const firebaseAuth = getFirebaseAuth();
291
+ const provider = new OAuthProvider("apple.com");
292
+ provider.addScope("email");
293
+ provider.addScope("name");
294
+
295
+ const result = await signInWithPopup(firebaseAuth, provider);
296
+ const idToken = await result.user.getIdToken();
297
+
298
+ return {
299
+ idToken,
300
+ email: result.user.email ?? "",
301
+ displayName: result.user.displayName,
302
+ photoURL: result.user.photoURL,
303
+ provider: "apple",
304
+ };
305
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Foreground/background detection — notifies the ConnectionDO via WebSocket
3
+ * so it knows whether to send push notifications.
4
+ */
5
+
6
+ import { Capacitor } from "@capacitor/core";
7
+ import type { BotsChatWSClient } from "./ws";
8
+ import { dlog } from "./debug-log";
9
+
10
+ export function setupForegroundDetection(wsClient: BotsChatWSClient): () => void {
11
+ const notifyForeground = () => {
12
+ wsClient.send({ type: "foreground.enter" });
13
+ dlog.info("Foreground", "Entered foreground");
14
+ };
15
+
16
+ const notifyBackground = () => {
17
+ wsClient.send({ type: "foreground.leave" });
18
+ dlog.info("Foreground", "Entered background");
19
+ };
20
+
21
+ if (Capacitor.isNativePlatform()) {
22
+ let cleanup: (() => void) | null = null;
23
+
24
+ import("@capacitor/app").then(({ App }) => {
25
+ const handle = App.addListener("appStateChange", ({ isActive }) => {
26
+ if (isActive) notifyForeground();
27
+ else notifyBackground();
28
+ });
29
+ cleanup = () => handle.then((h) => h.remove());
30
+ });
31
+
32
+ // Report initial foreground state once WS is connected
33
+ notifyForeground();
34
+
35
+ return () => cleanup?.();
36
+ }
37
+
38
+ // Web: Use Page Visibility API
39
+ const handleVisibilityChange = () => {
40
+ if (document.hidden) notifyBackground();
41
+ else notifyForeground();
42
+ };
43
+
44
+ document.addEventListener("visibilitychange", handleVisibilityChange);
45
+
46
+ if (!document.hidden) notifyForeground();
47
+
48
+ return () => {
49
+ document.removeEventListener("visibilitychange", handleVisibilityChange);
50
+ };
51
+ }
@@ -186,8 +186,9 @@ code:not(pre code) {
186
186
  outline-offset: 1px;
187
187
  }
188
188
 
189
- /* Transitions for theme switching */
190
- body, .theme-transition {
189
+ /* Transitions for theme switching — only on opt-in elements, NOT on body
190
+ (body transitions cause visible jitter during keyboard/layout changes on iOS) */
191
+ .theme-transition {
191
192
  transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
192
193
  }
193
194
 
@@ -209,6 +210,13 @@ body, .theme-transition {
209
210
  }
210
211
  }
211
212
 
213
+ /* Prevent long URLs, passwords, and inline code from overflowing message width */
214
+ .prose a,
215
+ .prose code:not(pre code) {
216
+ overflow-wrap: break-word;
217
+ word-break: break-all;
218
+ }
219
+
212
220
  /* Mobile screen transition */
213
221
  .mobile-screen-enter {
214
222
  animation: slideInRight 0.2s ease-out;
@@ -1,19 +1,41 @@
1
1
  import React from "react";
2
2
  import ReactDOM from "react-dom/client";
3
+ import { Capacitor } from "@capacitor/core";
3
4
  import App from "./App";
4
5
  import "./index.css";
5
6
  import { initAnalytics } from "./analytics";
6
7
 
7
8
  initAnalytics();
8
9
 
10
+ // ---- Capacitor native platform setup ----
11
+ if (Capacitor.isNativePlatform()) {
12
+ // Configure status bar and keyboard for native app
13
+ import("@capacitor/status-bar").then(({ StatusBar, Style }) => {
14
+ const isDark = document.documentElement.getAttribute("data-theme") !== "light";
15
+ StatusBar.setStyle({ style: isDark ? Style.Dark : Style.Light }).catch(() => {});
16
+ StatusBar.setOverlaysWebView({ overlay: true }).catch(() => {});
17
+ });
18
+ import("@capacitor/keyboard").then(({ Keyboard }) => {
19
+ Keyboard.setAccessoryBarVisible({ isVisible: true }).catch(() => {});
20
+ // Write keyboard height to CSS variable so MobileLayout can shrink accordingly.
21
+ // This works with Keyboard.resize = "none" to avoid body-resize jitter.
22
+ Keyboard.addListener("keyboardWillShow", (info) => {
23
+ document.documentElement.style.setProperty("--keyboard-height", `${info.keyboardHeight}px`);
24
+ });
25
+ Keyboard.addListener("keyboardWillHide", () => {
26
+ document.documentElement.style.setProperty("--keyboard-height", "0px");
27
+ });
28
+ });
29
+ }
30
+
9
31
  ReactDOM.createRoot(document.getElementById("root")!).render(
10
32
  <React.StrictMode>
11
33
  <App />
12
34
  </React.StrictMode>,
13
35
  );
14
36
 
15
- // Register service worker for PWA support (standalone mode on mobile)
16
- if ("serviceWorker" in navigator) {
37
+ // Register service worker for PWA support — skip in Capacitor (native has its own caching)
38
+ if (!Capacitor.isNativePlatform() && "serviceWorker" in navigator) {
17
39
  window.addEventListener("load", () => {
18
40
  navigator.serviceWorker.register("/sw.js").catch(() => {
19
41
  // SW registration failed — non-critical, app still works
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Push notification initialization for Web (FCM) and Native (Capacitor).
3
+ *
4
+ * - Web: Firebase Cloud Messaging + Service Worker
5
+ * - iOS/Android: @capacitor/push-notifications
6
+ *
7
+ * For E2E encrypted messages, the E2E key is synced to IndexedDB so the
8
+ * Service Worker (web) or native handler can decrypt before showing.
9
+ */
10
+
11
+ import { Capacitor } from "@capacitor/core";
12
+ import { pushApi } from "./api";
13
+ import { dlog } from "./debug-log";
14
+ import { E2eService } from "./e2e";
15
+
16
+ let initialized = false;
17
+
18
+ // ---- IndexedDB helpers for SW E2E key sync ----
19
+
20
+ const IDB_NAME = "botschat-sw";
21
+ const IDB_STORE = "keys";
22
+ const IDB_KEY = "e2e_key";
23
+
24
+ function openDB(): Promise<IDBDatabase> {
25
+ return new Promise((resolve, reject) => {
26
+ const req = indexedDB.open(IDB_NAME, 1);
27
+ req.onupgradeneeded = () => {
28
+ req.result.createObjectStore(IDB_STORE);
29
+ };
30
+ req.onsuccess = () => resolve(req.result);
31
+ req.onerror = () => reject(req.error);
32
+ });
33
+ }
34
+
35
+ /** Sync the current E2E key to IndexedDB so the Service Worker can decrypt. */
36
+ export async function syncE2eKeyToSW(): Promise<void> {
37
+ try {
38
+ const db = await openDB();
39
+ const tx = db.transaction(IDB_STORE, "readwrite");
40
+ const store = tx.objectStore(IDB_STORE);
41
+
42
+ // Read the cached key from localStorage (base64-encoded Uint8Array)
43
+ const cachedKeyB64 = localStorage.getItem("botschat_e2e_key_cache");
44
+ if (cachedKeyB64) {
45
+ // Decode base64 to Uint8Array and store in IDB
46
+ const binary = atob(cachedKeyB64);
47
+ const key = new Uint8Array(binary.length);
48
+ for (let i = 0; i < binary.length; i++) {
49
+ key[i] = binary.charCodeAt(i);
50
+ }
51
+ store.put(key, IDB_KEY);
52
+ } else {
53
+ store.delete(IDB_KEY);
54
+ }
55
+
56
+ await new Promise<void>((resolve, reject) => {
57
+ tx.oncomplete = () => resolve();
58
+ tx.onerror = () => reject(tx.error);
59
+ });
60
+ } catch (err) {
61
+ dlog.warn("Push", "Failed to sync E2E key to SW IndexedDB", err);
62
+ }
63
+ }
64
+
65
+ /** Clear the E2E key from SW IndexedDB (call on logout or key clear). */
66
+ export async function clearE2eKeyFromSW(): Promise<void> {
67
+ try {
68
+ const db = await openDB();
69
+ const tx = db.transaction(IDB_STORE, "readwrite");
70
+ tx.objectStore(IDB_STORE).delete(IDB_KEY);
71
+ await new Promise<void>((resolve, reject) => {
72
+ tx.oncomplete = () => resolve();
73
+ tx.onerror = () => reject(tx.error);
74
+ });
75
+ } catch {
76
+ // Ignore — best effort
77
+ }
78
+ }
79
+
80
+ // ---- Push initialization ----
81
+
82
+ export async function initPushNotifications(): Promise<void> {
83
+ if (initialized) return;
84
+
85
+ // Sync E2E key so push notifications can be decrypted
86
+ await syncE2eKeyToSW();
87
+
88
+ // Subscribe to E2E key changes to keep SW in sync
89
+ E2eService.subscribe(() => {
90
+ syncE2eKeyToSW().catch(() => {});
91
+ });
92
+
93
+ if (Capacitor.isNativePlatform()) {
94
+ await initNativePush();
95
+ } else {
96
+ await initWebPush();
97
+ }
98
+
99
+ initialized = true;
100
+ }
101
+
102
+ // ---- Web Push (Firebase Cloud Messaging) ----
103
+
104
+ async function initWebPush(): Promise<void> {
105
+ try {
106
+ if (!("Notification" in self)) {
107
+ dlog.warn("Push", "Notifications not supported in this browser");
108
+ return;
109
+ }
110
+
111
+ const permission = await Notification.requestPermission();
112
+ if (permission !== "granted") {
113
+ dlog.warn("Push", "Notification permission denied");
114
+ return;
115
+ }
116
+
117
+ const { getMessaging, getToken, onMessage } = await import("firebase/messaging");
118
+ const { ensureFirebaseApp } = await import("./firebase");
119
+
120
+ const firebaseApp = ensureFirebaseApp();
121
+ if (!firebaseApp) {
122
+ dlog.warn("Push", "Firebase not configured (missing env vars)");
123
+ return;
124
+ }
125
+
126
+ const messaging = getMessaging(firebaseApp);
127
+
128
+ // Get service worker registration
129
+ const registration = await navigator.serviceWorker.getRegistration();
130
+ if (!registration) {
131
+ dlog.warn("Push", "No service worker registration found");
132
+ return;
133
+ }
134
+
135
+ const vapidKey = import.meta.env.VITE_FIREBASE_VAPID_KEY as string;
136
+ if (!vapidKey) {
137
+ dlog.warn("Push", "VITE_FIREBASE_VAPID_KEY not set — skipping web push");
138
+ return;
139
+ }
140
+
141
+ const fcmToken = await getToken(messaging, {
142
+ vapidKey,
143
+ serviceWorkerRegistration: registration,
144
+ });
145
+
146
+ if (fcmToken) {
147
+ dlog.info("Push", `FCM token obtained (${fcmToken.slice(0, 20)}...)`);
148
+ await pushApi.register(fcmToken, "web");
149
+ dlog.info("Push", "Token registered with backend");
150
+ }
151
+
152
+ // Suppress foreground notifications (WS already delivers the message)
153
+ onMessage(messaging, (_payload) => {
154
+ dlog.info("Push", "Foreground FCM message received (suppressed)");
155
+ });
156
+ } catch (err) {
157
+ dlog.error("Push", "Web push init failed", err);
158
+ }
159
+ }
160
+
161
+ // ---- Native Push (Capacitor) ----
162
+
163
+ async function initNativePush(): Promise<void> {
164
+ try {
165
+ const { PushNotifications } = await import("@capacitor/push-notifications");
166
+
167
+ const permResult = await PushNotifications.requestPermissions();
168
+ if (permResult.receive !== "granted") {
169
+ dlog.warn("Push", "Native push permission denied");
170
+ return;
171
+ }
172
+
173
+ await PushNotifications.register();
174
+
175
+ PushNotifications.addListener("registration", async (token) => {
176
+ dlog.info("Push", `Native push token: ${token.value.slice(0, 20)}...`);
177
+ const platform = Capacitor.getPlatform() as "ios" | "android";
178
+ await pushApi.register(token.value, platform);
179
+ dlog.info("Push", "Native token registered with backend");
180
+ });
181
+
182
+ PushNotifications.addListener("registrationError", (error) => {
183
+ dlog.error("Push", "Native push registration failed", error);
184
+ });
185
+
186
+ // Data-only messages arrive here in foreground — suppress (WS handles it)
187
+ PushNotifications.addListener("pushNotificationReceived", (_notification) => {
188
+ dlog.info("Push", "Foreground native notification (suppressed)");
189
+ });
190
+
191
+ // User tapped a notification (app was in background)
192
+ PushNotifications.addListener("pushNotificationActionPerformed", (action) => {
193
+ dlog.info("Push", "Notification tapped", action);
194
+ // TODO: navigate to specific session from action.notification.data
195
+ });
196
+ } catch (err) {
197
+ dlog.error("Push", "Native push init failed", err);
198
+ }
199
+ }
200
+
201
+ /** Unregister push token (call on logout). */
202
+ export async function unregisterPush(): Promise<void> {
203
+ initialized = false;
204
+ await clearE2eKeyFromSW();
205
+ }
@@ -1,5 +1,6 @@
1
1
  /** WebSocket client for connecting to the BotsChat ConnectionDO. */
2
2
 
3
+ import { Capacitor } from "@capacitor/core";
3
4
  import { dlog } from "./debug-log";
4
5
  import { E2eService } from "./e2e";
5
6
  import { getToken, tryRefreshAccessToken } from "./api";
@@ -33,10 +34,16 @@ export class BotsChatWSClient {
33
34
 
34
35
  connect(): void {
35
36
  this.intentionalClose = false;
36
- const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
37
- // Token is NOT included in the URL to avoid leaking it in logs/history.
38
- // Authentication is handled via the "auth" message after connection.
39
- const url = `${protocol}//${window.location.host}/api/ws/${this.opts.userId}/${this.opts.sessionId}`;
37
+
38
+ // In Capacitor (native app), WebView runs from capacitor:// so we must
39
+ // use the full production WebSocket URL.
40
+ let url: string;
41
+ if (Capacitor.isNativePlatform()) {
42
+ url = `wss://console.botschat.app/api/ws/${this.opts.userId}/${this.opts.sessionId}`;
43
+ } else {
44
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
45
+ url = `${protocol}//${window.location.host}/api/ws/${this.opts.userId}/${this.opts.sessionId}`;
46
+ }
40
47
 
41
48
  dlog.info("WS", `Connecting to ${url}`);
42
49
  this.ws = new WebSocket(url);
@@ -164,14 +171,19 @@ export class BotsChatWSClient {
164
171
  // E2E Encryption for user messages
165
172
  if (msg.type === "user.message" && E2eService.hasKey() && typeof msg.text === "string") {
166
173
  try {
167
- const { ciphertext, messageId } = await E2eService.encrypt(msg.text);
174
+ // Use the existing messageId as contextId for encryption nonce,
175
+ // so decryption on the plugin side uses the same ID.
176
+ const existingId = (msg.messageId as string) || undefined;
177
+ const { ciphertext, messageId } = await E2eService.encrypt(msg.text, existingId);
168
178
  msg.text = ciphertext;
169
- msg.messageId = messageId;
179
+ // Only set messageId if we didn't have one — preserve the original
180
+ // so message IDs stay consistent between local state and server.
181
+ if (!existingId) {
182
+ msg.messageId = messageId;
183
+ }
170
184
  msg.encrypted = true;
171
185
  } catch (err) {
172
186
  dlog.error("E2E", "Encryption failed", err);
173
- // Fail? or send as plaintext?
174
- // Security first: if key exists but encrypt fails, abort.
175
187
  return;
176
188
  }
177
189
  }