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
@@ -8,50 +8,85 @@ export const upload = new Hono<{
8
8
  Variables: { userId: string };
9
9
  }>();
10
10
 
11
+ /** Allowed non-image MIME types for file attachments. */
12
+ const ALLOWED_FILE_TYPES = new Set([
13
+ "application/pdf",
14
+ "text/plain",
15
+ "text/csv",
16
+ "text/markdown",
17
+ "application/json",
18
+ "application/zip",
19
+ "application/gzip",
20
+ "application/x-tar",
21
+ "audio/mpeg", "audio/wav", "audio/ogg", "audio/mp4", "audio/webm", "audio/aac",
22
+ "video/mp4", "video/webm", "video/quicktime",
23
+ ]);
24
+
25
+ /** Safe file extensions for each category. */
26
+ const SAFE_EXTENSIONS = new Set([
27
+ "jpg", "jpeg", "png", "gif", "webp", "bmp", "ico",
28
+ "pdf", "txt", "csv", "md", "json", "zip", "gz", "tar",
29
+ "mp3", "wav", "ogg", "m4a", "aac", "webm",
30
+ "mp4", "mov",
31
+ ]);
32
+
11
33
  /** POST / — Upload a file to R2 and return a signed URL. */
12
34
  upload.post("/", async (c) => {
13
- const userId = c.get("userId");
14
- const contentType = c.req.header("Content-Type") ?? "";
35
+ try {
36
+ const userId = c.get("userId");
37
+ const contentType = c.req.header("Content-Type") ?? "";
15
38
 
16
- if (!contentType.includes("multipart/form-data")) {
17
- return c.json({ error: "Expected multipart/form-data" }, 400);
18
- }
39
+ if (!contentType.includes("multipart/form-data")) {
40
+ return c.json({ error: "Expected multipart/form-data" }, 400);
41
+ }
19
42
 
20
- const formData = await c.req.formData();
21
- const file = formData.get("file") as File | null;
43
+ const formData = await c.req.formData();
44
+ const file = formData.get("file") as File | null;
22
45
 
23
- if (!file) {
24
- return c.json({ error: "No file provided" }, 400);
25
- }
46
+ if (!file) {
47
+ return c.json({ error: "No file provided" }, 400);
48
+ }
26
49
 
27
- // Validate file type only raster images allowed (SVG is an XSS vector)
28
- if (!file.type.startsWith("image/") || file.type.includes("svg")) {
29
- return c.json({ error: "Only image files are allowed (SVG is not permitted)" }, 400);
30
- }
50
+ const fileType = file.type || "";
31
51
 
32
- // Limit file size to 10 MB
33
- const MAX_SIZE = 10 * 1024 * 1024;
34
- if (file.size > MAX_SIZE) {
35
- return c.json({ error: "File too large (max 10 MB)" }, 413);
36
- }
52
+ // Block SVG (XSS vector) and executables
53
+ if (fileType.includes("svg") || fileType.includes("executable") || fileType.includes("javascript")) {
54
+ return c.json({ error: "File type not permitted (SVG, executables, scripts are blocked)" }, 400);
55
+ }
37
56
 
38
- // Generate a unique key: media/{userId}/{timestamp}-{random}.{ext}
39
- const ext = file.name.split(".").pop()?.toLowerCase() ?? "png";
40
- // SVG is excluded — it can contain <script> tags and is a known XSS vector
41
- const safeExt = ["jpg", "jpeg", "png", "gif", "webp", "bmp", "ico"].includes(ext) ? ext : "png";
42
- const filename = `${Date.now()}-${randomUUID().slice(0, 8)}.${safeExt}`;
43
- const key = `media/${userId}/${filename}`;
44
-
45
- // Upload to R2
46
- await c.env.MEDIA.put(key, file.stream(), {
47
- httpMetadata: {
48
- contentType: file.type,
49
- },
50
- });
51
-
52
- // Return a signed URL (1 hour expiry)
53
- const secret = getJwtSecret(c.env);
54
- const url = await signMediaUrl(userId, filename, secret, 3600);
55
-
56
- return c.json({ url, key });
57
+ // Allow images (except SVG) and a curated set of other file types
58
+ const isImage = fileType.startsWith("image/");
59
+ const isAllowedFile = ALLOWED_FILE_TYPES.has(fileType);
60
+ if (!isImage && !isAllowedFile) {
61
+ return c.json({ error: `File type '${fileType}' is not supported` }, 400);
62
+ }
63
+
64
+ // Limit file size to 10 MB
65
+ const MAX_SIZE = 10 * 1024 * 1024;
66
+ if (file.size > MAX_SIZE) {
67
+ return c.json({ error: "File too large (max 10 MB)" }, 413);
68
+ }
69
+
70
+ // Generate a unique key: media/{userId}/{timestamp}-{random}.{ext}
71
+ const ext = file.name.split(".").pop()?.toLowerCase() ?? "bin";
72
+ const safeExt = SAFE_EXTENSIONS.has(ext) ? ext : (isImage ? "png" : "bin");
73
+ const filename = `${Date.now()}-${randomUUID().slice(0, 8)}.${safeExt}`;
74
+ const key = `media/${userId}/${filename}`;
75
+
76
+ // Upload to R2
77
+ await c.env.MEDIA.put(key, file.stream(), {
78
+ httpMetadata: {
79
+ contentType: fileType || "application/octet-stream",
80
+ },
81
+ });
82
+
83
+ // Return a signed URL (1 hour expiry)
84
+ const secret = getJwtSecret(c.env);
85
+ const url = await signMediaUrl(userId, filename, secret, 3600);
86
+
87
+ return c.json({ url, key });
88
+ } catch (err) {
89
+ console.error("[upload] Error:", err);
90
+ return c.json({ error: `Upload failed: ${err instanceof Error ? err.message : String(err)}` }, 500);
91
+ }
57
92
  });
@@ -0,0 +1,167 @@
1
+ /**
2
+ * FCM HTTP v1 API — send push notifications from Cloudflare Workers.
3
+ *
4
+ * Uses a Google Service Account to obtain OAuth2 access tokens,
5
+ * then sends data-only messages via FCM so clients can decrypt
6
+ * E2E-encrypted content before showing notifications.
7
+ */
8
+
9
+ type ServiceAccount = {
10
+ client_email: string;
11
+ private_key: string;
12
+ token_uri: string;
13
+ };
14
+
15
+ // Module-level token cache (survives within DO lifecycle)
16
+ let cachedAccessToken: string | null = null;
17
+ let cachedTokenExpiry = 0;
18
+
19
+ /**
20
+ * Get a valid Google OAuth2 access token for FCM.
21
+ * Caches the token in memory; refreshes 5 minutes before expiry.
22
+ */
23
+ export async function getFcmAccessToken(serviceAccountJson: string): Promise<string> {
24
+ const now = Math.floor(Date.now() / 1000);
25
+ if (cachedAccessToken && cachedTokenExpiry > now + 300) {
26
+ return cachedAccessToken;
27
+ }
28
+
29
+ const sa: ServiceAccount = JSON.parse(serviceAccountJson);
30
+
31
+ // Build JWT assertion for Google OAuth2
32
+ const header = { alg: "RS256", typ: "JWT" };
33
+ const claims = {
34
+ iss: sa.client_email,
35
+ scope: "https://www.googleapis.com/auth/firebase.messaging",
36
+ aud: sa.token_uri,
37
+ iat: now,
38
+ exp: now + 3600,
39
+ };
40
+
41
+ const key = await importPKCS8Key(sa.private_key);
42
+ const jwt = await signJwt(header, claims, key);
43
+
44
+ // Exchange JWT for access token
45
+ const res = await fetch(sa.token_uri, {
46
+ method: "POST",
47
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
48
+ body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${jwt}`,
49
+ });
50
+
51
+ if (!res.ok) {
52
+ const err = await res.text();
53
+ throw new Error(`FCM OAuth2 token exchange failed: ${res.status} ${err}`);
54
+ }
55
+
56
+ const data = (await res.json()) as { access_token: string; expires_in: number };
57
+ cachedAccessToken = data.access_token;
58
+ cachedTokenExpiry = now + data.expires_in;
59
+ return data.access_token;
60
+ }
61
+
62
+ export type PushPayload = {
63
+ accessToken: string;
64
+ projectId: string;
65
+ fcmToken: string;
66
+ /** Data payload — sent as FCM data-only message so client can decrypt + show notification. */
67
+ data: Record<string, string>;
68
+ };
69
+
70
+ /**
71
+ * Send a data-only push notification via FCM HTTP v1 API.
72
+ * Returns true on success. Returns false if the token is invalid (404/410)
73
+ * so the caller can clean it up.
74
+ */
75
+ export async function sendPushNotification(opts: PushPayload): Promise<boolean> {
76
+ const url = `https://fcm.googleapis.com/v1/projects/${opts.projectId}/messages:send`;
77
+
78
+ const message = {
79
+ message: {
80
+ token: opts.fcmToken,
81
+ // Data-only message — no "notification" field.
82
+ // Client receives the data and shows a local notification after decryption.
83
+ data: opts.data,
84
+ // Platform-specific overrides for data-only delivery
85
+ android: {
86
+ priority: "high" as const,
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
+ },
104
+ },
105
+ },
106
+ };
107
+
108
+ const res = await fetch(url, {
109
+ method: "POST",
110
+ headers: {
111
+ Authorization: `Bearer ${opts.accessToken}`,
112
+ "Content-Type": "application/json",
113
+ },
114
+ body: JSON.stringify(message),
115
+ });
116
+
117
+ if (!res.ok) {
118
+ const err = await res.text();
119
+ console.error(`[FCM] Send failed for token ...${opts.fcmToken.slice(-8)}: ${res.status} ${err}`);
120
+ // Token invalid/expired — caller should remove it
121
+ if (res.status === 404 || res.status === 410) return false;
122
+ }
123
+ return res.ok;
124
+ }
125
+
126
+ // ---- Crypto helpers for RS256 JWT signing ----
127
+
128
+ async function importPKCS8Key(pem: string): Promise<CryptoKey> {
129
+ const pemContents = pem
130
+ .replace(/-----BEGIN PRIVATE KEY-----/, "")
131
+ .replace(/-----END PRIVATE KEY-----/, "")
132
+ .replace(/\n/g, "");
133
+ const binary = atob(pemContents);
134
+ const der = new Uint8Array(binary.length);
135
+ for (let i = 0; i < binary.length; i++) {
136
+ der[i] = binary.charCodeAt(i);
137
+ }
138
+ return crypto.subtle.importKey(
139
+ "pkcs8",
140
+ der.buffer as ArrayBuffer,
141
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
142
+ false,
143
+ ["sign"],
144
+ );
145
+ }
146
+
147
+ function base64url(data: string | Uint8Array): string {
148
+ const str = typeof data === "string" ? btoa(data) : btoa(String.fromCharCode(...data));
149
+ return str.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
150
+ }
151
+
152
+ async function signJwt(
153
+ header: Record<string, string>,
154
+ payload: Record<string, unknown>,
155
+ key: CryptoKey,
156
+ ): Promise<string> {
157
+ const headerB64 = base64url(JSON.stringify(header));
158
+ const payloadB64 = base64url(JSON.stringify(payload));
159
+ const input = `${headerB64}.${payloadB64}`;
160
+ const sig = await crypto.subtle.sign(
161
+ "RSASSA-PKCS1-v1_5",
162
+ key,
163
+ new TextEncoder().encode(input),
164
+ );
165
+ const sigB64 = base64url(new Uint8Array(sig));
166
+ return `${input}.${sigB64}`;
167
+ }
@@ -121,6 +121,224 @@ export async function verifyFirebaseIdToken(
121
121
  return verifyWithKey(matchingKey, signedContent, signatureBytes, payload, projectId);
122
122
  }
123
123
 
124
+ // ---------------------------------------------------------------------------
125
+ // Google ID Token verification (for native iOS/Android sign-in)
126
+ // ---------------------------------------------------------------------------
127
+
128
+ const GOOGLE_OAUTH_JWKS_URL =
129
+ "https://www.googleapis.com/oauth2/v3/certs";
130
+
131
+ let cachedGoogleOAuthKeys: JsonWebKey[] | null = null;
132
+ let cachedGoogleOAuthAt = 0;
133
+
134
+ async function getGoogleOAuthPublicKeys(): Promise<JsonWebKey[]> {
135
+ const now = Date.now();
136
+ if (cachedGoogleOAuthKeys && now - cachedGoogleOAuthAt < CACHE_TTL_MS) {
137
+ return cachedGoogleOAuthKeys;
138
+ }
139
+ const resp = await fetch(GOOGLE_OAUTH_JWKS_URL);
140
+ if (!resp.ok) {
141
+ throw new Error(`Failed to fetch Google OAuth JWKS: ${resp.status}`);
142
+ }
143
+ const jwks = (await resp.json()) as { keys: JsonWebKey[] };
144
+ cachedGoogleOAuthKeys = jwks.keys;
145
+ cachedGoogleOAuthAt = now;
146
+ return jwks.keys;
147
+ }
148
+
149
+ /**
150
+ * Verify a Google ID token (from native Google Sign-In) and return the payload.
151
+ * Google ID tokens have iss=accounts.google.com and aud=<web-client-id>.
152
+ */
153
+ export async function verifyGoogleIdToken(
154
+ idToken: string,
155
+ allowedClientIds: string[],
156
+ ): Promise<FirebaseTokenPayload> {
157
+ const { header, payload, signatureBytes, signedContent } =
158
+ parseJwtUnverified(idToken);
159
+
160
+ if (header.alg !== "RS256") {
161
+ throw new Error(`Unsupported algorithm: ${header.alg}`);
162
+ }
163
+
164
+ // Find matching key from Google's OAuth JWKS
165
+ let keys = await getGoogleOAuthPublicKeys();
166
+ let matchingKey = keys.find((k) => (k as { kid?: string }).kid === header.kid);
167
+ if (!matchingKey) {
168
+ cachedGoogleOAuthKeys = null;
169
+ keys = await getGoogleOAuthPublicKeys();
170
+ matchingKey = keys.find((k) => (k as { kid?: string }).kid === header.kid);
171
+ if (!matchingKey) {
172
+ throw new Error(`No matching Google OAuth key for kid: ${header.kid}`);
173
+ }
174
+ }
175
+
176
+ return verifyGoogleTokenWithKey(matchingKey, signedContent, signatureBytes, payload, allowedClientIds);
177
+ }
178
+
179
+ async function verifyGoogleTokenWithKey(
180
+ jwk: JsonWebKey,
181
+ signedContent: string,
182
+ signatureBytes: Uint8Array,
183
+ payload: FirebaseTokenPayload,
184
+ allowedClientIds: string[],
185
+ ): Promise<FirebaseTokenPayload> {
186
+ const key = await crypto.subtle.importKey(
187
+ "jwk", jwk,
188
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
189
+ false, ["verify"],
190
+ );
191
+
192
+ const valid = await crypto.subtle.verify(
193
+ "RSASSA-PKCS1-v1_5", key, signatureBytes,
194
+ new TextEncoder().encode(signedContent),
195
+ );
196
+ if (!valid) throw new Error("Invalid Google token signature");
197
+
198
+ const now = Math.floor(Date.now() / 1000);
199
+ if (payload.exp < now) throw new Error("Google token has expired");
200
+ if (payload.iat > now + 300) throw new Error("Google token issued in the future");
201
+
202
+ // Google ID tokens have iss = "accounts.google.com" or "https://accounts.google.com"
203
+ if (payload.iss !== "accounts.google.com" && payload.iss !== "https://accounts.google.com") {
204
+ throw new Error(`Invalid Google token issuer: ${payload.iss}`);
205
+ }
206
+
207
+ // Audience must be one of our allowed client IDs
208
+ if (!allowedClientIds.includes(payload.aud)) {
209
+ throw new Error(`Invalid Google token audience: ${payload.aud}`);
210
+ }
211
+
212
+ if (!payload.sub) throw new Error("Missing subject in Google token");
213
+
214
+ // Synthesize firebase-like fields so the rest of the auth flow works
215
+ if (!payload.firebase) {
216
+ payload.firebase = {
217
+ sign_in_provider: "google.com",
218
+ identities: { "google.com": [payload.sub] },
219
+ };
220
+ }
221
+
222
+ return payload;
223
+ }
224
+
225
+ /**
226
+ * Detect whether a token is a Firebase ID token or a Google ID token,
227
+ * and verify accordingly.
228
+ */
229
+ export async function verifyAnyGoogleToken(
230
+ idToken: string,
231
+ firebaseProjectId: string,
232
+ allowedGoogleClientIds: string[],
233
+ ): Promise<FirebaseTokenPayload> {
234
+ // Peek at the issuer to decide which verification path to use
235
+ const { payload: peek } = parseJwtUnverified(idToken);
236
+
237
+ if (peek.iss === `${FIREBASE_TOKEN_ISSUER_PREFIX}${firebaseProjectId}`) {
238
+ // Standard Firebase ID token (from web Firebase popup)
239
+ return verifyFirebaseIdToken(idToken, firebaseProjectId);
240
+ }
241
+
242
+ if (peek.iss === "accounts.google.com" || peek.iss === "https://accounts.google.com") {
243
+ // Native Google ID token (from iOS/Android)
244
+ return verifyGoogleIdToken(idToken, allowedGoogleClientIds);
245
+ }
246
+
247
+ if (peek.iss === "https://appleid.apple.com") {
248
+ // Native Apple ID token (from iOS Sign in with Apple)
249
+ return verifyAppleIdToken(idToken, []);
250
+ }
251
+
252
+ throw new Error(`Unrecognized token issuer: ${peek.iss}`);
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // Apple ID Token verification (for native iOS Sign in with Apple)
257
+ // ---------------------------------------------------------------------------
258
+
259
+ const APPLE_JWKS_URL = "https://appleid.apple.com/auth/keys";
260
+
261
+ let cachedAppleKeys: JsonWebKey[] | null = null;
262
+ let cachedAppleAt = 0;
263
+
264
+ async function getApplePublicKeys(): Promise<JsonWebKey[]> {
265
+ const now = Date.now();
266
+ if (cachedAppleKeys && now - cachedAppleAt < CACHE_TTL_MS) {
267
+ return cachedAppleKeys;
268
+ }
269
+ const resp = await fetch(APPLE_JWKS_URL);
270
+ if (!resp.ok) {
271
+ throw new Error(`Failed to fetch Apple JWKS: ${resp.status}`);
272
+ }
273
+ const jwks = (await resp.json()) as { keys: JsonWebKey[] };
274
+ cachedAppleKeys = jwks.keys;
275
+ cachedAppleAt = now;
276
+ return jwks.keys;
277
+ }
278
+
279
+ export async function verifyAppleIdToken(
280
+ idToken: string,
281
+ allowedAudiences: string[],
282
+ ): Promise<FirebaseTokenPayload> {
283
+ const { header, payload, signatureBytes, signedContent } =
284
+ parseJwtUnverified(idToken);
285
+
286
+ if (header.alg !== "RS256") {
287
+ throw new Error(`Unsupported algorithm: ${header.alg}`);
288
+ }
289
+
290
+ let keys = await getApplePublicKeys();
291
+ let matchingKey = keys.find((k) => (k as { kid?: string }).kid === header.kid);
292
+ if (!matchingKey) {
293
+ cachedAppleKeys = null;
294
+ keys = await getApplePublicKeys();
295
+ matchingKey = keys.find((k) => (k as { kid?: string }).kid === header.kid);
296
+ if (!matchingKey) {
297
+ throw new Error(`No matching Apple key for kid: ${header.kid}`);
298
+ }
299
+ }
300
+
301
+ const key = await crypto.subtle.importKey(
302
+ "jwk", matchingKey,
303
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
304
+ false, ["verify"],
305
+ );
306
+
307
+ const valid = await crypto.subtle.verify(
308
+ "RSASSA-PKCS1-v1_5", key, signatureBytes,
309
+ new TextEncoder().encode(signedContent),
310
+ );
311
+ if (!valid) throw new Error("Invalid Apple token signature");
312
+
313
+ const now = Math.floor(Date.now() / 1000);
314
+ if (payload.exp < now) throw new Error("Apple token has expired");
315
+ if (payload.iat > now + 300) throw new Error("Apple token issued in the future");
316
+
317
+ if (payload.iss !== "https://appleid.apple.com") {
318
+ throw new Error(`Invalid Apple token issuer: ${payload.iss}`);
319
+ }
320
+
321
+ if (allowedAudiences.length > 0 && !allowedAudiences.includes(payload.aud)) {
322
+ throw new Error(`Invalid Apple token audience: ${payload.aud}`);
323
+ }
324
+
325
+ if (!payload.sub) throw new Error("Missing subject in Apple token");
326
+
327
+ // Synthesize firebase-like fields so the rest of the auth flow works
328
+ if (!payload.firebase) {
329
+ payload.firebase = {
330
+ sign_in_provider: "apple.com",
331
+ identities: { "apple.com": [payload.sub] },
332
+ };
333
+ }
334
+
335
+ return payload;
336
+ }
337
+
338
+ // ---------------------------------------------------------------------------
339
+ // Shared verification helpers
340
+ // ---------------------------------------------------------------------------
341
+
124
342
  async function verifyWithKey(
125
343
  jwk: JsonWebKey,
126
344
  signedContent: string,
@@ -22,6 +22,12 @@ export declare const botschatPlugin: {
22
22
  readonly agentPrompt: {
23
23
  readonly messageToolHints: () => string[];
24
24
  };
25
+ readonly messaging: {
26
+ readonly targetResolver: {
27
+ readonly hint: "Use the session key (e.g. agent:botschat:botschat:u_xxx:ses:ses_xxx)";
28
+ readonly looksLikeId: (raw: string, _normalized: string) => boolean;
29
+ };
30
+ };
25
31
  readonly reload: {
26
32
  readonly configPrefixes: readonly ["channels.botschat"];
27
33
  };
@@ -1 +1 @@
1
- {"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../../src/channel.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../../src/channel.ts"],"names":[],"mappings":"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"}
@@ -2,7 +2,7 @@ import { deleteBotsChatAccount, listBotsChatAccountIds, resolveBotsChatAccount,
2
2
  import { getBotsChatRuntime } from "./runtime.js";
3
3
  import { BotsChatCloudClient } from "./ws-client.js";
4
4
  import crypto from "crypto";
5
- import { encryptText, decryptText, toBase64, fromBase64 } from "./e2e-crypto.js";
5
+ import { encryptText, decryptText, decryptBytes, toBase64, fromBase64 } from "./e2e-crypto.js";
6
6
  // ---------------------------------------------------------------------------
7
7
  // A2UI message-tool hints — injected via agentPrompt.messageToolHints so
8
8
  // the agent knows it can output interactive UI components. These strings
@@ -70,6 +70,19 @@ export const botschatPlugin = {
70
70
  agentPrompt: {
71
71
  messageToolHints: () => A2UI_MESSAGE_TOOL_HINTS,
72
72
  },
73
+ messaging: {
74
+ targetResolver: {
75
+ hint: "Use the session key (e.g. agent:botschat:botschat:u_xxx:ses:ses_xxx)",
76
+ looksLikeId: (raw, _normalized) => {
77
+ const t = raw.trim();
78
+ if (t.startsWith("agent:") || t.startsWith("botschat:"))
79
+ return true;
80
+ if (/^(ses_|u_)/.test(t))
81
+ return true;
82
+ return false;
83
+ },
84
+ },
85
+ },
73
86
  reload: { configPrefixes: ["channels.botschat"] },
74
87
  config: {
75
88
  listAccountIds: (cfg) => listBotsChatAccountIds(cfg),
@@ -338,6 +351,7 @@ async function handleCloudMessage(msg, ctx) {
338
351
  }
339
352
  }
340
353
  ctx.log?.info(`[${ctx.accountId}] Message from ${msg.userId}: ${text.slice(0, 80)}${msg.mediaUrl ? " [+image]" : ""}`);
354
+ console.error(`[botschat-msg] from=${msg.userId} text=${text.slice(0, 40)} mediaUrl=${msg.mediaUrl ?? "none"}`);
341
355
  try {
342
356
  const runtime = getBotsChatRuntime();
343
357
  // Load current config
@@ -369,6 +383,58 @@ async function handleCloudMessage(msg, ctx) {
369
383
  parentContext = `[Thread context — this conversation is a thread reply to the following ${parentSender === "user" ? "user" : "assistant"} message]\n${parentText}`;
370
384
  ctx.log?.info(`[${ctx.accountId}] Thread parent context injected (${parentText.length} chars)`);
371
385
  }
386
+ // Download inbound media (if any) to a local path so OpenClaw's
387
+ // vision pipeline can attach it to the model (requires MediaPath).
388
+ console.error(`[botschat-media] mediaUrl=${msg.mediaUrl ?? "none"}`);
389
+ if (msg.mediaUrl) {
390
+ let resolvedUrl = msg.mediaUrl;
391
+ if (resolvedUrl.startsWith("/")) {
392
+ const baseUrl = cloudUrls.get(ctx.accountId);
393
+ if (baseUrl) {
394
+ resolvedUrl = baseUrl.replace(/\/$/, "") + resolvedUrl;
395
+ }
396
+ }
397
+ try {
398
+ const os = await import("os");
399
+ const fsM = await import("fs");
400
+ const pathM = await import("path");
401
+ const mediaDir = pathM.join(os.homedir(), ".openclaw", "media", "inbound");
402
+ await fsM.promises.mkdir(mediaDir, { recursive: true });
403
+ ctx.log?.info(`[${ctx.accountId}] Downloading media from ${resolvedUrl}`);
404
+ const resp = await fetch(resolvedUrl);
405
+ if (resp.ok) {
406
+ let buffer = Buffer.from(await resp.arrayBuffer());
407
+ const contentType = resp.headers.get("content-type") || "image/png";
408
+ // E2E: decrypt media if the message was encrypted
409
+ if (msg.encrypted && client?.e2eKey && msg.messageId) {
410
+ try {
411
+ const mediaCtx = `${msg.messageId}:media`;
412
+ const decrypted = await decryptBytes(client.e2eKey, new Uint8Array(buffer), mediaCtx);
413
+ buffer = Buffer.from(decrypted);
414
+ ctx.log?.info(`[${ctx.accountId}] E2E decrypted media (${buffer.length} bytes, ctx=${mediaCtx.slice(0, 12)}…)`);
415
+ }
416
+ catch (e2eErr) {
417
+ ctx.log?.warn?.(`[${ctx.accountId}] E2E media decryption failed (using raw): ${e2eErr}`);
418
+ }
419
+ }
420
+ const extMap = { "image/png": ".png", "image/jpeg": ".jpg", "image/gif": ".gif", "image/webp": ".webp" };
421
+ const ext = extMap[contentType] || ".png";
422
+ const fileName = `botschat-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`;
423
+ const filePath = pathM.join(mediaDir, fileName);
424
+ await fsM.promises.writeFile(filePath, buffer);
425
+ ctx.log?.info(`[${ctx.accountId}] Downloaded media to ${filePath} (${buffer.length} bytes, ${contentType})`);
426
+ msg.__resolvedMedia = { MediaUrl: resolvedUrl, MediaPath: filePath, MediaType: contentType, NumMedia: "1" };
427
+ }
428
+ else {
429
+ ctx.log?.error(`[${ctx.accountId}] Failed to download media: HTTP ${resp.status}`);
430
+ msg.__resolvedMedia = { MediaUrl: resolvedUrl, NumMedia: "1" };
431
+ }
432
+ }
433
+ catch (err) {
434
+ ctx.log?.error(`[${ctx.accountId}] Failed to download media: ${err}`);
435
+ msg.__resolvedMedia = { MediaUrl: msg.mediaUrl, NumMedia: "1" };
436
+ }
437
+ }
372
438
  // Build the MsgContext that OpenClaw's dispatch pipeline expects.
373
439
  // BotsChat users are authenticated (logged in via the web UI), so
374
440
  // mark commands as authorized — this lets directives like /model
@@ -395,24 +461,14 @@ async function handleCloudMessage(msg, ctx) {
395
461
  // Inject parent message as GroupSystemPrompt for thread context.
396
462
  ...(parentContext ? { GroupSystemPrompt: parentContext } : {}),
397
463
  ...(threadId ? { MessageThreadId: threadId, ReplyToId: threadId } : {}),
398
- // Include image URL if the user sent an image.
399
- // Resolve relative URLs (e.g. /api/media/...) to absolute using cloudUrl
400
- // so OpenClaw can fetch the image from the BotsChat cloud.
401
- ...(msg.mediaUrl ? (() => {
402
- let resolvedUrl = msg.mediaUrl;
403
- if (resolvedUrl.startsWith("/")) {
404
- const baseUrl = cloudUrls.get(ctx.accountId);
405
- if (baseUrl) {
406
- resolvedUrl = baseUrl.replace(/\/$/, "") + resolvedUrl;
407
- }
408
- }
409
- return { MediaUrl: resolvedUrl, NumMedia: "1" };
410
- })() : {}),
464
+ // Include image: download to local path so OpenClaw vision pipeline
465
+ // can attach it to the model request (requires MediaPath).
466
+ ...(msg.__resolvedMedia || {}),
411
467
  };
412
468
  // Finalize the context (normalizes fields, resolves agent route)
413
469
  const finalizedCtx = runtime.channel.reply.finalizeInboundContext(msgCtx);
414
470
  // Create a reply dispatcher that sends responses back through the cloud WSS
415
- const client = getCloudClient(ctx.accountId);
471
+ // NOTE: reuses `client` from line ~424 (same block scope, same value)
416
472
  console.log(`[botschat] client for accountId=${ctx.accountId}: connected=${client?.connected}`);
417
473
  const deliver = async (payload) => {
418
474
  console.log(`[botschat][deliver] called, connected=${client?.connected}, hasKey=${!!client?.e2eKey}, textLen=${(payload.text || "").length}`);