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.
- package/README.md +11 -15
- package/migrations/0012_push_tokens.sql +11 -0
- package/package.json +20 -1
- package/packages/api/src/do/connection-do.ts +142 -24
- package/packages/api/src/env.ts +6 -0
- package/packages/api/src/index.ts +7 -0
- package/packages/api/src/routes/auth.ts +85 -9
- package/packages/api/src/routes/channels.ts +3 -2
- package/packages/api/src/routes/dev-auth.ts +45 -0
- package/packages/api/src/routes/push.ts +52 -0
- package/packages/api/src/routes/upload.ts +73 -38
- package/packages/api/src/utils/fcm.ts +167 -0
- package/packages/api/src/utils/firebase.ts +218 -0
- package/packages/plugin/dist/src/channel.d.ts +6 -0
- package/packages/plugin/dist/src/channel.d.ts.map +1 -1
- package/packages/plugin/dist/src/channel.js +71 -15
- package/packages/plugin/dist/src/channel.js.map +1 -1
- package/packages/plugin/package.json +1 -1
- package/packages/web/dist/assets/index-B9qN5gs6.js +1 -0
- package/packages/web/dist/assets/index-BQNMGVyU.js +2 -0
- package/packages/web/dist/assets/{index-Ev5M8VmV.css → index-Bd_RDcgO.css} +1 -1
- package/packages/web/dist/assets/index-Civeg2lm.js +1 -0
- package/packages/web/dist/assets/index-Dk33VSnY.js +2 -0
- package/packages/web/dist/assets/index-Kr85Nj_-.js +1516 -0
- package/packages/web/dist/assets/index-lVB82JKU.js +1 -0
- package/packages/web/dist/assets/index.esm-CtMkqqqb.js +599 -0
- package/packages/web/dist/assets/web-CUXjh_UA.js +1 -0
- package/packages/web/dist/assets/web-vKLTVUul.js +1 -0
- package/packages/web/dist/index.html +6 -4
- package/packages/web/dist/sw.js +158 -1
- package/packages/web/index.html +4 -2
- package/packages/web/package.json +4 -1
- package/packages/web/src/App.tsx +117 -1
- package/packages/web/src/api.ts +21 -1
- package/packages/web/src/components/AccountSettings.tsx +131 -0
- package/packages/web/src/components/ChatWindow.tsx +302 -70
- package/packages/web/src/components/CronSidebar.tsx +89 -24
- package/packages/web/src/components/DataConsentModal.tsx +249 -0
- package/packages/web/src/components/LoginPage.tsx +55 -7
- package/packages/web/src/components/MessageContent.tsx +71 -9
- package/packages/web/src/components/MobileLayout.tsx +28 -118
- package/packages/web/src/components/SessionTabs.tsx +41 -2
- package/packages/web/src/components/Sidebar.tsx +88 -66
- package/packages/web/src/e2e.ts +26 -5
- package/packages/web/src/firebase.ts +215 -3
- package/packages/web/src/foreground.ts +51 -0
- package/packages/web/src/index.css +10 -2
- package/packages/web/src/main.tsx +24 -2
- package/packages/web/src/push.ts +205 -0
- package/packages/web/src/ws.ts +20 -8
- package/scripts/dev.sh +158 -26
- package/scripts/mock-openclaw.mjs +382 -0
- package/scripts/test-e2e-chat.ts +2 -2
- package/scripts/test-e2e-live.ts +1 -1
- package/wrangler.toml +3 -0
- 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
|
-
|
|
14
|
-
|
|
35
|
+
try {
|
|
36
|
+
const userId = c.get("userId");
|
|
37
|
+
const contentType = c.req.header("Content-Type") ?? "";
|
|
15
38
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
39
|
+
if (!contentType.includes("multipart/form-data")) {
|
|
40
|
+
return c.json({ error: "Expected multipart/form-data" }, 400);
|
|
41
|
+
}
|
|
19
42
|
|
|
20
|
-
|
|
21
|
-
|
|
43
|
+
const formData = await c.req.formData();
|
|
44
|
+
const file = formData.get("file") as File | null;
|
|
22
45
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
46
|
+
if (!file) {
|
|
47
|
+
return c.json({ error: "No file provided" }, 400);
|
|
48
|
+
}
|
|
26
49
|
|
|
27
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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":"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
|
|
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
|
|
399
|
-
//
|
|
400
|
-
|
|
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
|
-
|
|
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}`);
|