botschat 0.1.13 → 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.
Files changed (39) hide show
  1. package/package.json +1 -1
  2. package/packages/api/src/do/connection-do.ts +103 -19
  3. package/packages/api/src/env.ts +6 -0
  4. package/packages/api/src/index.ts +25 -2
  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 +1 -0
  13. package/packages/plugin/dist/src/ws-client.d.ts.map +1 -1
  14. package/packages/plugin/dist/src/ws-client.js +1 -0
  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-BJye3VHV.js +1516 -0
  18. package/packages/web/dist/assets/{index-Bd_RDcgO.css → index-CNSCbd7_.css} +1 -1
  19. package/packages/web/dist/assets/index-CPOiRHa4.js +2 -0
  20. package/packages/web/dist/assets/{index-lVB82JKU.js → index-CQPXprFz.js} +1 -1
  21. package/packages/web/dist/assets/{index-Civeg2lm.js → index-CkIgZfHf.js} +1 -1
  22. package/packages/web/dist/assets/index-DbUyNI4d.js +1 -0
  23. package/packages/web/dist/assets/index-Dpvhc_dU.js +2 -0
  24. package/packages/web/dist/assets/{index.esm-CtMkqqqb.js → index.esm-DgcFARs7.js} +1 -1
  25. package/packages/web/dist/assets/{web-vKLTVUul.js → web-Bfku9Io_.js} +1 -1
  26. package/packages/web/dist/assets/{web-CUXjh_UA.js → web-CnOlwlZw.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 +20 -2
  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/foreground.ts +5 -1
  34. package/packages/web/src/push.ts +27 -3
  35. package/scripts/mock-openclaw.mjs +13 -3
  36. package/packages/web/dist/assets/index-B9qN5gs6.js +0 -1
  37. package/packages/web/dist/assets/index-BQNMGVyU.js +0 -2
  38. package/packages/web/dist/assets/index-Dk33VSnY.js +0 -2
  39. package/packages/web/dist/assets/index-Kr85Nj_-.js +0 -1516
@@ -13,7 +13,7 @@ import {
13
13
  import { getToken, setToken, setRefreshToken, agentsApi, channelsApi, tasksApi, jobsApi, authApi, messagesApi, modelsApi, meApi, sessionsApi, type ModelInfo } from "./api";
14
14
  import { ModelSelect } from "./components/ModelSelect";
15
15
  import { BotsChatWSClient, type WSMessage } from "./ws";
16
- import { initPushNotifications } from "./push";
16
+ import { initPushNotifications, getPendingPushNav, clearPendingPushNav } from "./push";
17
17
  import { setupForegroundDetection } from "./foreground";
18
18
  import { IconRail } from "./components/IconRail";
19
19
  import { Sidebar } from "./components/Sidebar";
@@ -51,6 +51,7 @@ export default function App() {
51
51
  const wsClientRef = useRef<BotsChatWSClient | null>(null);
52
52
  const handleWSMessageRef = useRef<(msg: WSMessage) => void>(() => {});
53
53
  const creatingGeneralRef = useRef(false);
54
+ const pushNavTargetRef = useRef<string | null>(null);
54
55
 
55
56
  const [showSettings, setShowSettings] = useState(false);
56
57
  const [settingsTab, setSettingsTab] = useState<"general" | "connection" | "security">("general");
@@ -64,6 +65,9 @@ export default function App() {
64
65
  return E2eService.subscribe(() => setE2eReady(E2eService.hasKey()));
65
66
  }, []);
66
67
 
68
+ // Foreground resume counter — triggers message reload when app returns from background
69
+ const [foregroundResumeCount, setForegroundResumeCount] = useState(0);
70
+
67
71
  // Responsive layout hooks (must be called unconditionally)
68
72
  const isMobile = useIsMobile();
69
73
  const mainLayout = useDefaultLayout({ id: "botschat-main" });
@@ -259,6 +263,20 @@ export default function App() {
259
263
  dlog.info("Agents", `Loaded ${agents.length} agents`, agents.map((a) => ({ id: a.id, name: a.name, channelId: a.channelId })));
260
264
  dispatch({ type: "SET_AGENTS", agents });
261
265
  if (agents.length > 0 && !state.selectedAgentId) {
266
+ // Push notification deep-link takes priority over localStorage
267
+ const pushNav = pushNavTargetRef.current;
268
+ if (pushNav) {
269
+ const m = pushNav.match(/^agent:([^:]+):/);
270
+ if (m) {
271
+ const ta = agents.find((a) => a.sessionKey.startsWith(`agent:${m[1]}:`));
272
+ if (ta) {
273
+ dlog.info("Push", `Agent select from push nav: ${ta.name} (${ta.id})`);
274
+ dispatch({ type: "SET_ACTIVE_VIEW", view: "messages" });
275
+ dispatch({ type: "SELECT_AGENT", agentId: ta.id, sessionKey: ta.sessionKey });
276
+ return;
277
+ }
278
+ }
279
+ }
262
280
  // Restore last selected channel from localStorage if available
263
281
  let target = agents[0];
264
282
  try {
@@ -371,6 +389,18 @@ export default function App() {
371
389
  dlog.info("Sessions", `Loaded ${sessions.length} sessions for channel ${agent.channelId}`);
372
390
  dispatch({ type: "SET_SESSIONS", sessions });
373
391
  if (sessions.length > 0) {
392
+ // Push notification deep-link takes priority
393
+ const pushNav = pushNavTargetRef.current;
394
+ if (pushNav) {
395
+ const ts = sessions.find((s) => s.sessionKey === pushNav);
396
+ if (ts) {
397
+ dlog.info("Push", `Session select from push nav: ${ts.name} (${ts.id})`);
398
+ pushNavTargetRef.current = null;
399
+ clearPendingPushNav();
400
+ dispatch({ type: "SELECT_SESSION", sessionId: ts.id, sessionKey: ts.sessionKey });
401
+ return;
402
+ }
403
+ }
374
404
  // Restore last selected session from localStorage if available
375
405
  let target = sessions[0];
376
406
  try {
@@ -498,7 +528,7 @@ export default function App() {
498
528
  console.error("Failed to load message history:", err);
499
529
  });
500
530
  return () => { stale = true; };
501
- }, [state.user, state.selectedSessionKey, e2eReady]);
531
+ }, [state.user, state.selectedSessionKey, e2eReady, foregroundResumeCount]);
502
532
 
503
533
  // Keep a ref to state for use in WS handler (avoids stale closures)
504
534
  const stateRef = useRef(state);
@@ -506,6 +536,56 @@ export default function App() {
506
536
  stateRef.current = state;
507
537
  }, [state]);
508
538
 
539
+ // ---- Push notification deep-link navigation ----
540
+ const navigateToPushTarget = useCallback((sessionKey: string) => {
541
+ dlog.info("Push", `Navigating to session: ${sessionKey}`);
542
+ const baseKey = sessionKey.replace(/:thread:.+$/, "");
543
+ pushNavTargetRef.current = baseKey;
544
+
545
+ const match = baseKey.match(/^agent:([^:]+):/);
546
+ if (!match) return;
547
+ const openclawAgentId = match[1];
548
+
549
+ const st = stateRef.current;
550
+ const targetAgent = st.agents.find((a) =>
551
+ a.sessionKey.startsWith(`agent:${openclawAgentId}:`),
552
+ );
553
+ if (!targetAgent) return;
554
+
555
+ if (st.activeView !== "messages") {
556
+ dispatch({ type: "SET_ACTIVE_VIEW", view: "messages" });
557
+ }
558
+ if (st.selectedAgentId !== targetAgent.id) {
559
+ dispatch({
560
+ type: "SELECT_AGENT",
561
+ agentId: targetAgent.id,
562
+ sessionKey: targetAgent.sessionKey,
563
+ });
564
+ } else {
565
+ const targetSession = st.sessions.find((s) => s.sessionKey === baseKey);
566
+ if (targetSession) {
567
+ pushNavTargetRef.current = null;
568
+ clearPendingPushNav();
569
+ dispatch({
570
+ type: "SELECT_SESSION",
571
+ sessionId: targetSession.id,
572
+ sessionKey: targetSession.sessionKey,
573
+ });
574
+ }
575
+ }
576
+ }, [dispatch]);
577
+
578
+ useEffect(() => {
579
+ function onPushNav(e: Event) {
580
+ const sk = (e as CustomEvent).detail?.sessionKey;
581
+ if (sk) navigateToPushTarget(sk);
582
+ }
583
+ window.addEventListener("botschat:push-nav", onPushNav);
584
+ const pending = getPendingPushNav();
585
+ if (pending) navigateToPushTarget(pending);
586
+ return () => window.removeEventListener("botschat:push-nav", onPushNav);
587
+ }, [navigateToPushTarget]);
588
+
509
589
  // ---- WS message handler ----
510
590
  const handleWSMessage = useCallback(
511
591
  (msg: WSMessage) => {
@@ -815,10 +895,17 @@ export default function App() {
815
895
  wsClientRef.current = client;
816
896
 
817
897
  // Initialize push notifications and foreground detection
818
- initPushNotifications().catch((err) => {
819
- dlog.warn("Push", `Push init failed: ${err}`);
898
+ initPushNotifications()
899
+ .then(() => {
900
+ const pending = getPendingPushNav();
901
+ if (pending) navigateToPushTarget(pending);
902
+ })
903
+ .catch((err) => {
904
+ dlog.warn("Push", `Push init failed: ${err}`);
905
+ });
906
+ const cleanupForeground = setupForegroundDetection(client, () => {
907
+ setForegroundResumeCount((c) => c + 1);
820
908
  });
821
- const cleanupForeground = setupForegroundDetection(client);
822
909
 
823
910
  return () => {
824
911
  cleanupForeground();
@@ -122,7 +122,7 @@ async function request<T>(
122
122
  // ---- Auth ----
123
123
  export type AuthResponse = { id: string; email: string; token: string; refreshToken?: string; displayName?: string };
124
124
 
125
- export type UserSettings = { defaultModel?: string };
125
+ export type UserSettings = { defaultModel?: string; notifyPreview?: boolean };
126
126
 
127
127
  export type AuthConfig = {
128
128
  emailEnabled: boolean;
@@ -147,7 +147,7 @@ export const authApi = {
147
147
 
148
148
  // ---- User settings ----
149
149
  export const meApi = {
150
- updateSettings: (data: { defaultModel?: string }) =>
150
+ updateSettings: (data: { defaultModel?: string; notifyPreview?: boolean }) =>
151
151
  request<{ ok: boolean; settings: UserSettings }>("PATCH", "/me", data),
152
152
  };
153
153
 
@@ -650,8 +650,26 @@ export function ChatWindow({ sendMessage }: ChatWindowProps) {
650
650
  </div>
651
651
  )}
652
652
 
653
- {/* Session tabs shown for all agents (including default/General) */}
654
- {showSessionTabs && <SessionTabs channelId={channelId} />}
653
+ {/* Session tabs + model selector (mobile: inline with tabs) */}
654
+ {showSessionTabs && (
655
+ <div className="flex items-center flex-shrink-0">
656
+ <div className="flex-1 min-w-0">
657
+ <SessionTabs channelId={channelId} />
658
+ </div>
659
+ {isMobile && (
660
+ <div className="flex-shrink-0 pr-2" style={{ borderBottom: "1px solid var(--border)" }}>
661
+ <ModelSelect
662
+ value={currentModel ?? ""}
663
+ onChange={handleModelChange}
664
+ models={state.models}
665
+ disabled={!state.openclawConnected}
666
+ placeholder="No model"
667
+ compact
668
+ />
669
+ </div>
670
+ )}
671
+ </div>
672
+ )}
655
673
 
656
674
  {/* Messages – flat-row layout (overflow-x-hidden prevents horizontal scroll from long URLs/code) */}
657
675
  <div className="flex-1 min-h-0 overflow-y-auto overflow-x-hidden">
@@ -1,5 +1,6 @@
1
1
  import React, { useEffect, useState } from "react";
2
2
  import { E2eService } from "../e2e";
3
+ import { meApi, authApi } from "../api";
3
4
  import { AppStateContext } from "../store";
4
5
 
5
6
  export function E2ESettings() {
@@ -10,6 +11,8 @@ export function E2ESettings() {
10
11
  const [busy, setBusy] = useState(false);
11
12
  const [error, setError] = useState<string | null>(null);
12
13
  const [showPassword, setShowPassword] = useState(false);
14
+ const [notifyPreview, setNotifyPreview] = useState(false);
15
+ const [notifyPreviewLoading, setNotifyPreviewLoading] = useState(false);
13
16
 
14
17
  // Subscribe to E2eService changes
15
18
  useEffect(() => {
@@ -18,6 +21,28 @@ export function E2ESettings() {
18
21
  });
19
22
  }, []);
20
23
 
24
+ // Load notifyPreview preference from server
25
+ useEffect(() => {
26
+ authApi.me().then((data) => {
27
+ if (data?.settings?.notifyPreview) {
28
+ setNotifyPreview(true);
29
+ }
30
+ }).catch(() => {});
31
+ }, []);
32
+
33
+ const handleNotifyPreviewToggle = async (enabled: boolean) => {
34
+ setNotifyPreview(enabled);
35
+ setNotifyPreviewLoading(true);
36
+ try {
37
+ await meApi.updateSettings({ notifyPreview: enabled });
38
+ } catch (err) {
39
+ console.error("Failed to update notification preview setting:", err);
40
+ setNotifyPreview(!enabled);
41
+ } finally {
42
+ setNotifyPreviewLoading(false);
43
+ }
44
+ };
45
+
21
46
  const handleUnlock = async () => {
22
47
  if (!password || !user) return;
23
48
  setBusy(true);
@@ -133,9 +158,53 @@ export function E2ESettings() {
133
158
  )}
134
159
  </div>
135
160
 
161
+ {/* Notification Preview Toggle */}
162
+ <div className="p-4 rounded-md border" style={{ borderColor: "var(--border)", background: "var(--bg-surface)" }}>
163
+ <div className="flex items-center justify-between mb-3">
164
+ <div className="flex items-center gap-2">
165
+ <svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" style={{ color: "var(--text-secondary)" }}>
166
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
167
+ </svg>
168
+ <span className="text-caption font-bold" style={{ color: "var(--text-primary)" }}>
169
+ Notification Preview
170
+ </span>
171
+ </div>
172
+ <button
173
+ role="switch"
174
+ aria-checked={notifyPreview}
175
+ onClick={() => handleNotifyPreviewToggle(!notifyPreview)}
176
+ disabled={notifyPreviewLoading}
177
+ className="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
178
+ style={{
179
+ background: notifyPreview ? "var(--bg-active, #6366f1)" : "var(--border)",
180
+ opacity: notifyPreviewLoading ? 0.5 : 1,
181
+ }}
182
+ >
183
+ <span
184
+ className="inline-block h-4 w-4 rounded-full bg-white transition-transform"
185
+ style={{ transform: notifyPreview ? "translateX(1.375rem)" : "translateX(0.25rem)" }}
186
+ />
187
+ </button>
188
+ </div>
189
+ <p className="text-caption mb-2" style={{ color: "var(--text-muted)" }}>
190
+ Show message text in push notifications on iOS and Android.
191
+ </p>
192
+ {notifyPreview && (
193
+ <div className="p-3 rounded border text-caption" style={{ borderColor: "var(--accent-yellow, #d69e2e)", background: "rgba(214, 158, 46, 0.08)", color: "var(--text-secondary)" }}>
194
+ <p className="font-bold mb-1" style={{ color: "var(--accent-yellow, #d69e2e)" }}>Security trade-off</p>
195
+ <ul className="list-disc ml-4 space-y-0.5">
196
+ <li>Message previews will briefly pass through our server to deliver notifications.</li>
197
+ <li>Previews are <strong>never stored</strong> — they exist only in memory during delivery.</li>
198
+ <li>Your full message history remains end-to-end encrypted.</li>
199
+ <li>Web browser notifications are not affected (they decrypt locally).</li>
200
+ </ul>
201
+ </div>
202
+ )}
203
+ </div>
204
+
136
205
  <div className="text-caption" style={{ color: "var(--text-muted)" }}>
137
206
  <p className="font-bold text-red-400 mb-1">Warning:</p>
138
- <ul className="list-disc ml-5 space-y-1">
207
+ <ul className="list-disc ml-4 space-y-1">
139
208
  <li>If you lose this password, your encrypted history is lost forever.</li>
140
209
  <li>We do not store this password on our servers.</li>
141
210
  <li>You must use the same password on all devices to access your history.</li>
@@ -60,6 +60,13 @@ export function MobileLayout({
60
60
  // App.tsx auto-selects an agent on mount — that would navigate to an empty
61
61
  // chat screen before sessions have loaded (issue #4a / #4b).
62
62
 
63
+ // Push notification tap → navigate to chat
64
+ React.useEffect(() => {
65
+ function onPushNav() { setScreen("chat"); }
66
+ window.addEventListener("botschat:push-nav", onPushNav);
67
+ return () => window.removeEventListener("botschat:push-nav", onPushNav);
68
+ }, []);
69
+
63
70
  // Navigate to thread when thread opens
64
71
  React.useEffect(() => {
65
72
  if (state.activeThreadId && screen === "chat") {
@@ -7,9 +7,13 @@ import { Capacitor } from "@capacitor/core";
7
7
  import type { BotsChatWSClient } from "./ws";
8
8
  import { dlog } from "./debug-log";
9
9
 
10
- export function setupForegroundDetection(wsClient: BotsChatWSClient): () => void {
10
+ export function setupForegroundDetection(
11
+ wsClient: BotsChatWSClient,
12
+ onResume?: () => void,
13
+ ): () => void {
11
14
  const notifyForeground = () => {
12
15
  wsClient.send({ type: "foreground.enter" });
16
+ onResume?.();
13
17
  dlog.info("Foreground", "Entered foreground");
14
18
  };
15
19
 
@@ -15,6 +15,25 @@ import { E2eService } from "./e2e";
15
15
 
16
16
  let initialized = false;
17
17
 
18
+ // ---- Push navigation (deep-link on notification tap) ----
19
+
20
+ let pendingNavSessionKey: string | null = null;
21
+
22
+ export function getPendingPushNav(): string | null {
23
+ return pendingNavSessionKey;
24
+ }
25
+
26
+ export function clearPendingPushNav(): void {
27
+ pendingNavSessionKey = null;
28
+ }
29
+
30
+ function firePushNav(sessionKey: string): void {
31
+ pendingNavSessionKey = sessionKey;
32
+ window.dispatchEvent(
33
+ new CustomEvent("botschat:push-nav", { detail: { sessionKey } }),
34
+ );
35
+ }
36
+
18
37
  // ---- IndexedDB helpers for SW E2E key sync ----
19
38
 
20
39
  const IDB_NAME = "botschat-sw";
@@ -183,15 +202,20 @@ async function initNativePush(): Promise<void> {
183
202
  dlog.error("Push", "Native push registration failed", error);
184
203
  });
185
204
 
186
- // Data-only messages arrive here in foreground — suppress (WS handles it)
187
205
  PushNotifications.addListener("pushNotificationReceived", (_notification) => {
188
206
  dlog.info("Push", "Foreground native notification (suppressed)");
189
207
  });
190
208
 
191
- // User tapped a notification (app was in background)
192
209
  PushNotifications.addListener("pushNotificationActionPerformed", (action) => {
193
210
  dlog.info("Push", "Notification tapped", action);
194
- // TODO: navigate to specific session from action.notification.data
211
+ // iOS: custom data nested under "custom" key; Android: at root level
212
+ const data = action.notification?.data;
213
+ const sessionKey: string | undefined =
214
+ data?.custom?.sessionKey || data?.sessionKey;
215
+ if (sessionKey) {
216
+ dlog.info("Push", `Push nav target: ${sessionKey}`);
217
+ firePushNav(sessionKey);
218
+ }
195
219
  });
196
220
  } catch (err) {
197
221
  dlog.error("Push", "Native push init failed", err);
@@ -99,6 +99,7 @@ let ws = null;
99
99
  let pingTimer = null;
100
100
  let intentionalClose = false;
101
101
  let userId = null;
102
+ let notifyPreview = false;
102
103
 
103
104
  function buildWsUrl() {
104
105
  let host = SERVER_URL.replace(/^https?:\/\//, "");
@@ -266,6 +267,11 @@ function handleMessage(msg) {
266
267
  logSend(`[defaultModel.updated] ${msg.defaultModel}`);
267
268
  break;
268
269
 
270
+ case "settings.notifyPreview":
271
+ notifyPreview = msg.enabled === true;
272
+ logRecv(`[settings.notifyPreview] enabled=${notifyPreview}`);
273
+ break;
274
+
269
275
  default:
270
276
  logWarn(`Unhandled message type: ${msg.type}`);
271
277
  }
@@ -299,13 +305,17 @@ async function handleUserMessage(msg) {
299
305
  messageId: randomUUID(),
300
306
  });
301
307
  } else {
302
- send({
308
+ const msgPayload = {
303
309
  type: "agent.text",
304
310
  sessionKey: msg.sessionKey,
305
311
  text: replyText,
306
312
  messageId: randomUUID(),
307
- });
308
- logSend(`[agent.text] "${truncate(replyText, 60)}"`);
313
+ };
314
+ if (notifyPreview) {
315
+ msgPayload.notifyPreview = truncate(replyText, 100);
316
+ }
317
+ send(msgPayload);
318
+ logSend(`[agent.text] "${truncate(replyText, 60)}"${notifyPreview ? " +preview" : ""}`);
309
319
  }
310
320
  }
311
321
 
@@ -1 +0,0 @@
1
- import{r as i}from"./index-Kr85Nj_-.js";const t=i("PushNotifications",{});export{t as PushNotifications};
@@ -1,2 +0,0 @@
1
- const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/web-vKLTVUul.js","assets/index-Kr85Nj_-.js","assets/index-Bd_RDcgO.css"])))=>i.map(i=>d[i]);
2
- import{r as p,f as r}from"./index-Kr85Nj_-.js";const o=p("App",{web:()=>r(()=>import("./web-vKLTVUul.js"),__vite__mapDeps([0,1,2])).then(e=>new e.AppWeb)});export{o as App};
@@ -1,2 +0,0 @@
1
- const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/web-CUXjh_UA.js","assets/index-Kr85Nj_-.js","assets/index-Bd_RDcgO.css"])))=>i.map(i=>d[i]);
2
- import{r as o,f as t}from"./index-Kr85Nj_-.js";const n=o("SocialLogin",{web:()=>t(()=>import("./web-CUXjh_UA.js"),__vite__mapDeps([0,1,2])).then(e=>new e.SocialLoginWeb)});export{n as SocialLogin};