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.
- package/package.json +1 -1
- package/packages/api/src/do/connection-do.ts +103 -19
- package/packages/api/src/env.ts +6 -0
- package/packages/api/src/index.ts +25 -2
- 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 +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-Bd_RDcgO.css → index-CNSCbd7_.css} +1 -1
- package/packages/web/dist/assets/index-CPOiRHa4.js +2 -0
- package/packages/web/dist/assets/{index-lVB82JKU.js → index-CQPXprFz.js} +1 -1
- package/packages/web/dist/assets/{index-Civeg2lm.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-CtMkqqqb.js → index.esm-DgcFARs7.js} +1 -1
- package/packages/web/dist/assets/{web-vKLTVUul.js → web-Bfku9Io_.js} +1 -1
- package/packages/web/dist/assets/{web-CUXjh_UA.js → web-CnOlwlZw.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 +20 -2
- package/packages/web/src/components/E2ESettings.tsx +70 -1
- package/packages/web/src/components/MobileLayout.tsx +7 -0
- package/packages/web/src/foreground.ts +5 -1
- package/packages/web/src/push.ts +27 -3
- 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
|
|
|
@@ -217,6 +218,9 @@ export class ConnectionDO implements DurableObject {
|
|
|
217
218
|
// After auth, request task scan + models list from the plugin
|
|
218
219
|
ws.send(JSON.stringify({ type: "task.scan.request" }));
|
|
219
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 }));
|
|
220
224
|
// Notify all browser clients that OpenClaw is now connected
|
|
221
225
|
this.broadcastToBrowsers(
|
|
222
226
|
JSON.stringify({ type: "connection.status", openclawConnected: true, defaultModel: this.defaultModel, models: this.cachedModels }),
|
|
@@ -315,17 +319,19 @@ export class ConnectionDO implements DurableObject {
|
|
|
315
319
|
await this.handleJobUpdate(msg);
|
|
316
320
|
}
|
|
317
321
|
|
|
318
|
-
// 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)
|
|
319
324
|
if (msg.type === "agent.text") {
|
|
320
325
|
console.log(`[DO] Forwarding agent.text to browsers: encrypted=${msg.encrypted}, messageId=${msg.messageId}, textLen=${typeof msg.text === "string" ? msg.text.length : "?"}`);
|
|
321
326
|
}
|
|
322
|
-
|
|
327
|
+
const { notifyPreview: _stripped, ...msgForBrowser } = msg;
|
|
328
|
+
this.broadcastToBrowsers(JSON.stringify(msgForBrowser));
|
|
323
329
|
|
|
324
330
|
// Send push notification if no browser session is in foreground
|
|
325
331
|
if (
|
|
326
332
|
(msg.type === "agent.text" || msg.type === "agent.media" || msg.type === "agent.a2ui") &&
|
|
327
333
|
this.foregroundSessions.size === 0 &&
|
|
328
|
-
this.env.FCM_SERVICE_ACCOUNT_JSON
|
|
334
|
+
(this.env.FCM_SERVICE_ACCOUNT_JSON || this.env.APNS_AUTH_KEY)
|
|
329
335
|
) {
|
|
330
336
|
this.sendPushNotifications(msg).catch((err) => {
|
|
331
337
|
console.error("[DO] Push notification failed:", err);
|
|
@@ -546,6 +552,24 @@ export class ConnectionDO implements DurableObject {
|
|
|
546
552
|
|
|
547
553
|
// ---- Helpers ----
|
|
548
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
|
+
|
|
549
573
|
/** Restore cachedModels and defaultModel from durable storage if in-memory cache is empty. */
|
|
550
574
|
private async ensureCachedModels(): Promise<void> {
|
|
551
575
|
if (this.cachedModels.length > 0) return;
|
|
@@ -599,7 +623,9 @@ export class ConnectionDO implements DurableObject {
|
|
|
599
623
|
/**
|
|
600
624
|
* Send push notifications to all of the user's registered devices.
|
|
601
625
|
* Called when an agent message arrives and no browser session is in foreground.
|
|
602
|
-
*
|
|
626
|
+
*
|
|
627
|
+
* iOS tokens go directly to APNs (Capacitor returns raw APNs device tokens).
|
|
628
|
+
* Web/Android tokens go through FCM HTTP v1 API.
|
|
603
629
|
*/
|
|
604
630
|
private async sendPushNotifications(msg: Record<string, unknown>): Promise<void> {
|
|
605
631
|
const userId = await this.state.storage.get<string>("userId");
|
|
@@ -612,8 +638,8 @@ export class ConnectionDO implements DurableObject {
|
|
|
612
638
|
.all<{ id: string; token: string; platform: string }>();
|
|
613
639
|
|
|
614
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})`);
|
|
615
642
|
|
|
616
|
-
// Build data payload — includes ciphertext so the client can decrypt
|
|
617
643
|
const data: Record<string, string> = {
|
|
618
644
|
type: msg.type as string,
|
|
619
645
|
sessionKey: (msg.sessionKey as string) ?? "",
|
|
@@ -621,30 +647,88 @@ export class ConnectionDO implements DurableObject {
|
|
|
621
647
|
encrypted: msg.encrypted ? "1" : "0",
|
|
622
648
|
};
|
|
623
649
|
|
|
650
|
+
let notifBody = "New message";
|
|
624
651
|
if (msg.type === "agent.text") {
|
|
625
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
|
+
}
|
|
626
661
|
} else if (msg.type === "agent.media") {
|
|
627
662
|
data.text = (msg.caption as string) || "";
|
|
628
663
|
data.mediaUrl = (msg.mediaUrl as string) ?? "";
|
|
664
|
+
notifBody = data.text || "Sent a media file";
|
|
629
665
|
} else if (msg.type === "agent.a2ui") {
|
|
630
666
|
data.text = "New interactive message";
|
|
667
|
+
notifBody = "New interactive message";
|
|
631
668
|
}
|
|
632
669
|
|
|
633
|
-
const
|
|
634
|
-
const
|
|
635
|
-
|
|
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");
|
|
636
673
|
const invalidTokenIds: string[] = [];
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
}
|
|
647
|
-
|
|
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
|
+
}
|
|
648
732
|
|
|
649
733
|
// Clean up invalid/expired tokens
|
|
650
734
|
for (const id of invalidTokenIds) {
|
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 });
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* APNs HTTP/2 API — send push notifications directly to iOS devices.
|
|
3
|
+
*
|
|
4
|
+
* Uses a .p8 Auth Key for token-based authentication (ES256 JWT).
|
|
5
|
+
* iOS devices register raw APNs device tokens (hex strings) via Capacitor,
|
|
6
|
+
* which cannot be sent through FCM. This module talks to APNs directly.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
let cachedJwt: string | null = null;
|
|
10
|
+
let cachedJwtExpiry = 0;
|
|
11
|
+
|
|
12
|
+
export type ApnsConfig = {
|
|
13
|
+
authKey: string;
|
|
14
|
+
keyId: string;
|
|
15
|
+
teamId: string;
|
|
16
|
+
bundleId: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
async function getApnsJwt(config: ApnsConfig): Promise<string> {
|
|
20
|
+
const now = Math.floor(Date.now() / 1000);
|
|
21
|
+
if (cachedJwt && cachedJwtExpiry > now + 300) {
|
|
22
|
+
return cachedJwt;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const header = { alg: "ES256", kid: config.keyId };
|
|
26
|
+
const claims = { iss: config.teamId, iat: now };
|
|
27
|
+
|
|
28
|
+
const key = await importP8Key(config.authKey);
|
|
29
|
+
cachedJwt = await signEs256Jwt(header, claims, key);
|
|
30
|
+
cachedJwtExpiry = now + 3600;
|
|
31
|
+
return cachedJwt;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ApnsPushOpts = {
|
|
35
|
+
config: ApnsConfig;
|
|
36
|
+
deviceToken: string;
|
|
37
|
+
title: string;
|
|
38
|
+
body: string;
|
|
39
|
+
/** Custom data included alongside the aps payload. */
|
|
40
|
+
data?: Record<string, string>;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Send a visible push notification via APNs.
|
|
45
|
+
* Returns true on success.
|
|
46
|
+
* Returns false if the token is permanently invalid (410 Unregistered)
|
|
47
|
+
* so the caller can clean it up.
|
|
48
|
+
*/
|
|
49
|
+
export async function sendApnsNotification(opts: ApnsPushOpts): Promise<boolean> {
|
|
50
|
+
const jwt = await getApnsJwt(opts.config);
|
|
51
|
+
|
|
52
|
+
const payload: Record<string, unknown> = {
|
|
53
|
+
aps: {
|
|
54
|
+
alert: { title: opts.title, body: opts.body },
|
|
55
|
+
sound: "default",
|
|
56
|
+
"mutable-content": 1,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
if (opts.data) {
|
|
60
|
+
payload.custom = opts.data;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const headers = {
|
|
64
|
+
Authorization: `Bearer ${jwt}`,
|
|
65
|
+
"apns-topic": opts.config.bundleId,
|
|
66
|
+
"apns-push-type": "alert",
|
|
67
|
+
"apns-priority": "10",
|
|
68
|
+
"apns-expiration": "0",
|
|
69
|
+
};
|
|
70
|
+
const body = JSON.stringify(payload);
|
|
71
|
+
|
|
72
|
+
// Try production first, fall back to sandbox for debug/TestFlight builds
|
|
73
|
+
for (const host of ["api.push.apple.com", "api.sandbox.push.apple.com"]) {
|
|
74
|
+
const res = await fetch(`https://${host}/3/device/${opts.deviceToken}`, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers,
|
|
77
|
+
body,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (res.ok) {
|
|
81
|
+
console.log(`[APNs] Sent via ${host} for ...${opts.deviceToken.slice(-8)}`);
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const err = await res.text();
|
|
86
|
+
const isBadToken = err.includes("BadDeviceToken");
|
|
87
|
+
|
|
88
|
+
// BadDeviceToken on production → likely a sandbox token, try sandbox next
|
|
89
|
+
if (host === "api.push.apple.com" && res.status === 400 && isBadToken) {
|
|
90
|
+
console.log(`[APNs] Production rejected token, trying sandbox...`);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
console.error(`[APNs] ${host} failed for ...${opts.deviceToken.slice(-8)}: ${res.status} ${err}`);
|
|
95
|
+
|
|
96
|
+
// 410 = device unregistered — safe to remove
|
|
97
|
+
if (res.status === 410) return false;
|
|
98
|
+
return !isBadToken;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---- ES256 JWT helpers ----
|
|
105
|
+
|
|
106
|
+
async function importP8Key(pem: string): Promise<CryptoKey> {
|
|
107
|
+
// Handle literal "\n" escape sequences from env vars (.dev.vars / wrangler secret)
|
|
108
|
+
const normalized = pem.replace(/\\n/g, "\n");
|
|
109
|
+
const pemBody = normalized
|
|
110
|
+
.replace(/-----BEGIN PRIVATE KEY-----/, "")
|
|
111
|
+
.replace(/-----END PRIVATE KEY-----/, "")
|
|
112
|
+
.replace(/\s/g, "");
|
|
113
|
+
const binary = atob(pemBody);
|
|
114
|
+
const der = new Uint8Array(binary.length);
|
|
115
|
+
for (let i = 0; i < binary.length; i++) {
|
|
116
|
+
der[i] = binary.charCodeAt(i);
|
|
117
|
+
}
|
|
118
|
+
return crypto.subtle.importKey(
|
|
119
|
+
"pkcs8",
|
|
120
|
+
der.buffer as ArrayBuffer,
|
|
121
|
+
{ name: "ECDSA", namedCurve: "P-256" },
|
|
122
|
+
false,
|
|
123
|
+
["sign"],
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function base64url(data: Uint8Array | string): string {
|
|
128
|
+
const str =
|
|
129
|
+
typeof data === "string"
|
|
130
|
+
? btoa(data)
|
|
131
|
+
: btoa(String.fromCharCode(...data));
|
|
132
|
+
return str.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function signEs256Jwt(
|
|
136
|
+
header: Record<string, string>,
|
|
137
|
+
payload: Record<string, unknown>,
|
|
138
|
+
key: CryptoKey,
|
|
139
|
+
): Promise<string> {
|
|
140
|
+
const headerB64 = base64url(JSON.stringify(header));
|
|
141
|
+
const payloadB64 = base64url(JSON.stringify(payload));
|
|
142
|
+
const input = `${headerB64}.${payloadB64}`;
|
|
143
|
+
|
|
144
|
+
const sig = await crypto.subtle.sign(
|
|
145
|
+
{ name: "ECDSA", hash: "SHA-256" },
|
|
146
|
+
key,
|
|
147
|
+
new TextEncoder().encode(input),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
return `${input}.${base64url(new Uint8Array(sig))}`;
|
|
151
|
+
}
|
|
@@ -65,6 +65,8 @@ export type PushPayload = {
|
|
|
65
65
|
fcmToken: string;
|
|
66
66
|
/** Data payload — sent as FCM data-only message so client can decrypt + show notification. */
|
|
67
67
|
data: Record<string, string>;
|
|
68
|
+
/** If set, include a visible notification (used for Android where data-only is silent in background). */
|
|
69
|
+
notification?: { title: string; body: string };
|
|
68
70
|
};
|
|
69
71
|
|
|
70
72
|
/**
|
|
@@ -75,35 +77,22 @@ export type PushPayload = {
|
|
|
75
77
|
export async function sendPushNotification(opts: PushPayload): Promise<boolean> {
|
|
76
78
|
const url = `https://fcm.googleapis.com/v1/projects/${opts.projectId}/messages:send`;
|
|
77
79
|
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
},
|
|
88
|
-
apns: {
|
|
89
|
-
headers: {
|
|
90
|
-
"apns-priority": "10",
|
|
91
|
-
"apns-push-type": "background",
|
|
92
|
-
},
|
|
93
|
-
payload: {
|
|
94
|
-
aps: {
|
|
95
|
-
"content-available": 1,
|
|
96
|
-
sound: "default",
|
|
97
|
-
},
|
|
98
|
-
},
|
|
99
|
-
},
|
|
100
|
-
webpush: {
|
|
101
|
-
headers: {
|
|
102
|
-
Urgency: "high",
|
|
103
|
-
},
|
|
80
|
+
const msg: Record<string, unknown> = {
|
|
81
|
+
token: opts.fcmToken,
|
|
82
|
+
data: opts.data,
|
|
83
|
+
android: {
|
|
84
|
+
priority: "high" as const,
|
|
85
|
+
},
|
|
86
|
+
webpush: {
|
|
87
|
+
headers: {
|
|
88
|
+
Urgency: "high",
|
|
104
89
|
},
|
|
105
90
|
},
|
|
106
91
|
};
|
|
92
|
+
if (opts.notification) {
|
|
93
|
+
msg.notification = opts.notification;
|
|
94
|
+
}
|
|
95
|
+
const message = { message: msg };
|
|
107
96
|
|
|
108
97
|
const res = await fetch(url, {
|
|
109
98
|
method: "POST",
|
|
@@ -114,13 +103,15 @@ export async function sendPushNotification(opts: PushPayload): Promise<boolean>
|
|
|
114
103
|
body: JSON.stringify(message),
|
|
115
104
|
});
|
|
116
105
|
|
|
117
|
-
if (
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
// Token invalid/expired — caller should remove it
|
|
121
|
-
if (res.status === 404 || res.status === 410) return false;
|
|
106
|
+
if (res.ok) {
|
|
107
|
+
console.log(`[FCM] Sent to ...${opts.fcmToken.slice(-8)} (notification: ${!!opts.notification})`);
|
|
108
|
+
return true;
|
|
122
109
|
}
|
|
123
|
-
|
|
110
|
+
|
|
111
|
+
const err = await res.text();
|
|
112
|
+
console.error(`[FCM] Send failed for token ...${opts.fcmToken.slice(-8)}: ${res.status} ${err}`);
|
|
113
|
+
if (res.status === 404 || res.status === 410) return false;
|
|
114
|
+
return false;
|
|
124
115
|
}
|
|
125
116
|
|
|
126
117
|
// ---- Crypto helpers for RS256 JWT signing ----
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../../src/channel.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAuC,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAC/F,OAAO,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAqDrD,eAAO,MAAM,cAAc;;;;;;;;;;;;;4BAeqB,MAAM,EAAE;;;;;;;;;;;;wCAc/B,MAAM,eAAe,MAAM;;;;;;;uCAY1B,OAAO;uCACP,OAAO,cAAc,MAAM,GAAG,IAAI;yCAEhC,OAAO;kEACkB;YAAE,GAAG,EAAE,OAAO,CAAC;YAAC,SAAS,EAAE,MAAM,CAAC;YAAC,OAAO,EAAE,OAAO,CAAA;SAAE;qDAElE;YAAE,GAAG,EAAE,OAAO,CAAC;YAAC,SAAS,EAAE,MAAM,CAAA;SAAE;yCAE/C,uBAAuB;sCAC1B,uBAAuB;4CACjB,uBAAuB;;;;;;;;;;iCAY5B;YACpB,EAAE,EAAE,MAAM,CAAC;YACX,IAAI,EAAE,MAAM,CAAC;YACb,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;YAC1B,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;YAClC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;SAC3B;;;;;;;
|
|
1
|
+
{"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../../src/channel.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAuC,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAC/F,OAAO,EAAE,mBAAmB,EAAE,MAAM,gBAAgB,CAAC;AAqDrD,eAAO,MAAM,cAAc;;;;;;;;;;;;;4BAeqB,MAAM,EAAE;;;;;;;;;;;;wCAc/B,MAAM,eAAe,MAAM;;;;;;;uCAY1B,OAAO;uCACP,OAAO,cAAc,MAAM,GAAG,IAAI;yCAEhC,OAAO;kEACkB;YAAE,GAAG,EAAE,OAAO,CAAC;YAAC,SAAS,EAAE,MAAM,CAAC;YAAC,OAAO,EAAE,OAAO,CAAA;SAAE;qDAElE;YAAE,GAAG,EAAE,OAAO,CAAC;YAAC,SAAS,EAAE,MAAM,CAAA;SAAE;yCAE/C,uBAAuB;sCAC1B,uBAAuB;4CACjB,uBAAuB;;;;;;;;;;iCAY5B;YACpB,EAAE,EAAE,MAAM,CAAC;YACX,IAAI,EAAE,MAAM,CAAC;YACb,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;YAC1B,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;YAClC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;SAC3B;;;;;;;kCAyCsB;YACrB,EAAE,EAAE,MAAM,CAAC;YACX,IAAI,EAAE,MAAM,CAAC;YACb,QAAQ,CAAC,EAAE,MAAM,CAAC;YAClB,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;SAC3B;;;;;;;;;qCAgDyB;YACxB,GAAG,EAAE,OAAO,CAAC;YACb,SAAS,EAAE,MAAM,CAAC;YAClB,OAAO,EAAE,uBAAuB,CAAC;YACjC,OAAO,EAAE,OAAO,CAAC;YACjB,WAAW,EAAE,WAAW,CAAC;YACzB,GAAG,CAAC,EAAE;gBAAE,IAAI,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;gBAAC,IAAI,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;gBAAC,KAAK,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAA;aAAE,CAAC;YAC3F,SAAS,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;YACzC,SAAS,EAAE,CAAC,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;SACjD;oCAoDwB;YACvB,SAAS,EAAE,MAAM,CAAC;YAClB,SAAS,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;YACzC,SAAS,EAAE,CAAC,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;SACjD;;;;gEAiB8C;YAC7C,OAAO,EAAE;gBAAE,EAAE,CAAC,EAAE,MAAM,CAAC;gBAAC,eAAe,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;gBAAC,SAAS,CAAC,EAAE,MAAM,CAAA;aAAE,CAAC;YAChF,aAAa,CAAC,EAAE;gBAAE,KAAK,EAAE,OAAO,CAAA;aAAE,CAAC;SACpC;;;;;uBAD0B,OAAO;;;;;;8CAWL,MAAM;;;yCAIX;YAAE,OAAO,EAAE,uBAAuB,CAAA;SAAE;;uBAEzC,MAAM,EAAE;;;;;;;sDAQU;YACnC,GAAG,EAAE,OAAO,CAAC;YACb,SAAS,EAAE,MAAM,CAAC;YAClB,KAAK,EAAE;gBAAE,GAAG,CAAC,EAAE,MAAM,CAAC;gBAAC,KAAK,CAAC,EAAE,MAAM,CAAC;gBAAC,IAAI,CAAC,EAAE,MAAM,CAAC;gBAAC,MAAM,CAAC,EAAE,OAAO,CAAA;aAAE,CAAC;SAC1E;;;;;;;;;;;;4CAiB0B;YACzB,GAAG,EAAE,OAAO,CAAC;YACb,SAAS,EAAE,MAAM,CAAC;YAClB,KAAK,EAAE;gBAAE,GAAG,CAAC,EAAE,MAAM,CAAC;gBAAC,KAAK,CAAC,EAAE,MAAM,CAAC;gBAAC,MAAM,CAAC,EAAE,OAAO,CAAA;aAAE,CAAC;SAC3D;;;;;;;;;;;8DAiB4C;YAC3C,OAAO,EAAE,uBAAuB,CAAC;YACjC,GAAG,EAAE,OAAO,CAAC;YACb,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;SACnC;;;;;;;;;;;;;;iDAc+B,KAAK,CAAC;YAAE,SAAS,EAAE,MAAM,CAAC;YAAC,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;YAAC,SAAS,CAAC,EAAE,OAAO,CAAC;YAAC,UAAU,CAAC,EAAE,OAAO,CAAA;SAAE,CAAC;qBAE/F,MAAM;uBAAa,MAAM;kBAAQ,MAAM;qBAAW,MAAM;;;CAerF,CAAC"}
|
|
@@ -124,6 +124,9 @@ export const botschatPlugin = {
|
|
|
124
124
|
return { ok: false, error: new Error(`Encryption failed: ${err}`) };
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
|
+
const notifyPreview = (encrypted && client.notifyPreview && ctx.text)
|
|
128
|
+
? (ctx.text.length > 100 ? ctx.text.slice(0, 100) + "…" : ctx.text)
|
|
129
|
+
: undefined;
|
|
127
130
|
client.send({
|
|
128
131
|
type: "agent.text",
|
|
129
132
|
sessionKey: ctx.to,
|
|
@@ -132,8 +135,9 @@ export const botschatPlugin = {
|
|
|
132
135
|
threadId: ctx.threadId?.toString(),
|
|
133
136
|
messageId,
|
|
134
137
|
encrypted,
|
|
138
|
+
...(notifyPreview ? { notifyPreview } : {}),
|
|
135
139
|
});
|
|
136
|
-
console.log(`[botschat][sendText] sent agent.text, encrypted=${encrypted}`);
|
|
140
|
+
console.log(`[botschat][sendText] sent agent.text, encrypted=${encrypted}, notifyPreview=${!!notifyPreview}`);
|
|
137
141
|
return { ok: true };
|
|
138
142
|
},
|
|
139
143
|
sendMedia: async (ctx) => {
|
|
@@ -155,6 +159,9 @@ export const botschatPlugin = {
|
|
|
155
159
|
return { ok: false, error: new Error(`Encryption failed: ${err}`) };
|
|
156
160
|
}
|
|
157
161
|
}
|
|
162
|
+
const notifyPreview = (encrypted && client.notifyPreview && ctx.text)
|
|
163
|
+
? (ctx.text.length > 100 ? ctx.text.slice(0, 100) + "…" : ctx.text)
|
|
164
|
+
: undefined;
|
|
158
165
|
if (ctx.mediaUrl) {
|
|
159
166
|
client.send({
|
|
160
167
|
type: "agent.media",
|
|
@@ -163,6 +170,7 @@ export const botschatPlugin = {
|
|
|
163
170
|
caption: text || undefined,
|
|
164
171
|
messageId,
|
|
165
172
|
encrypted,
|
|
173
|
+
...(notifyPreview ? { notifyPreview } : {}),
|
|
166
174
|
});
|
|
167
175
|
}
|
|
168
176
|
else {
|
|
@@ -172,6 +180,7 @@ export const botschatPlugin = {
|
|
|
172
180
|
text: text,
|
|
173
181
|
messageId,
|
|
174
182
|
encrypted,
|
|
183
|
+
...(notifyPreview ? { notifyPreview } : {}),
|
|
175
184
|
});
|
|
176
185
|
}
|
|
177
186
|
return { ok: true };
|
|
@@ -495,7 +504,10 @@ async function handleCloudMessage(msg, ctx) {
|
|
|
495
504
|
else {
|
|
496
505
|
console.log(`[botschat][deliver] no encryption: hasKey=${!!client.e2eKey}, textLen=${text.length}`);
|
|
497
506
|
}
|
|
498
|
-
|
|
507
|
+
const notifyPreviewText = (encrypted && client.notifyPreview && payload.text)
|
|
508
|
+
? (payload.text.length > 100 ? payload.text.slice(0, 100) + "…" : payload.text)
|
|
509
|
+
: undefined;
|
|
510
|
+
console.log(`[botschat][deliver] sending: type=${payload.mediaUrl ? "agent.media" : "agent.text"}, encrypted=${encrypted}, messageId=${messageId}, notifyPreview=${!!notifyPreviewText}`);
|
|
499
511
|
if (payload.mediaUrl) {
|
|
500
512
|
client.send({
|
|
501
513
|
type: "agent.media",
|
|
@@ -505,6 +517,7 @@ async function handleCloudMessage(msg, ctx) {
|
|
|
505
517
|
threadId,
|
|
506
518
|
messageId,
|
|
507
519
|
encrypted,
|
|
520
|
+
...(notifyPreviewText ? { notifyPreview: notifyPreviewText } : {}),
|
|
508
521
|
});
|
|
509
522
|
}
|
|
510
523
|
else if (payload.text) {
|
|
@@ -515,6 +528,7 @@ async function handleCloudMessage(msg, ctx) {
|
|
|
515
528
|
threadId,
|
|
516
529
|
messageId,
|
|
517
530
|
encrypted,
|
|
531
|
+
...(notifyPreviewText ? { notifyPreview: notifyPreviewText } : {}),
|
|
518
532
|
});
|
|
519
533
|
// Detect model-change confirmations and emit model.changed
|
|
520
534
|
// Handles both formats:
|
|
@@ -670,6 +684,15 @@ async function handleCloudMessage(msg, ctx) {
|
|
|
670
684
|
}
|
|
671
685
|
break;
|
|
672
686
|
}
|
|
687
|
+
case "settings.notifyPreview": {
|
|
688
|
+
const enabled = msg.enabled === true;
|
|
689
|
+
const client = getCloudClient(ctx.accountId);
|
|
690
|
+
if (client) {
|
|
691
|
+
client.notifyPreview = enabled;
|
|
692
|
+
ctx.log?.info(`[${ctx.accountId}] Notification preview ${enabled ? "enabled" : "disabled"}`);
|
|
693
|
+
}
|
|
694
|
+
break;
|
|
695
|
+
}
|
|
673
696
|
default:
|
|
674
697
|
break;
|
|
675
698
|
}
|