[READ ONLY MIRROR] Spark Social AppView Server
github.com/sprksocial/server
atproto
deno
hono
lexicon
1import { jsonStringToLex } from "@atp/lexicon";
2import { PushToken, PushTokens } from "../data-plane/routes/push-tokens.ts";
3import { Database } from "../data-plane/db/index.ts";
4
5export interface PushPayload {
6 recipientDid: string;
7 reason: string;
8 author: string;
9 recordUri: string;
10 reasonSubject?: string;
11}
12
13export interface PushConfig {
14 enabled: boolean;
15 fcmServiceAccount?: string; // JSON string of Firebase service account
16}
17
18interface FcmServiceAccount {
19 project_id: string;
20 private_key: string;
21 client_email: string;
22}
23
24export class PushService {
25 private pushTokens: PushTokens;
26 private db: Database;
27 private config: PushConfig;
28 private fcmAccessToken: string | null = null;
29 private fcmTokenExpiry: number = 0;
30 private fcmServiceAccount: FcmServiceAccount | null = null;
31
32 constructor(pushTokens: PushTokens, db: Database, config: PushConfig) {
33 this.pushTokens = pushTokens;
34 this.db = db;
35 this.config = config;
36
37 if (config.fcmServiceAccount) {
38 try {
39 this.fcmServiceAccount = JSON.parse(config.fcmServiceAccount);
40 } catch {
41 console.error("Failed to parse FCM service account JSON");
42 }
43 }
44 }
45
46 get enabled(): boolean {
47 return this.config.enabled;
48 }
49
50 async sendPush(did: string, payload: PushPayload): Promise<void> {
51 if (!this.config.enabled) {
52 return;
53 }
54
55 const tokens = await this.pushTokens.getTokensForDid(did);
56 if (tokens.length === 0) {
57 return;
58 }
59
60 // Get unread count for badge
61 const badgeCount = await this.getUnreadCount(did);
62
63 const invalidTokens: string[] = [];
64
65 for (const token of tokens) {
66 try {
67 const success = await this.sendFcm(token, payload, badgeCount);
68 if (!success) {
69 invalidTokens.push(token.token);
70 }
71 } catch (err) {
72 console.error("Failed to send push notification", {
73 err,
74 platform: token.platform,
75 did,
76 });
77 }
78 }
79
80 // Clean up invalid tokens
81 if (invalidTokens.length > 0) {
82 await this.pushTokens.deleteInvalidTokens(invalidTokens);
83 console.info("Removed invalid push tokens", {
84 count: invalidTokens.length,
85 });
86 }
87 }
88
89 /**
90 * Send a silent push to reset the badge count to 0
91 * Called when notifications are marked as seen
92 */
93 async sendBadgeReset(did: string): Promise<void> {
94 if (!this.config.enabled) {
95 return;
96 }
97
98 const tokens = await this.pushTokens.getTokensForDid(did);
99 if (tokens.length === 0) {
100 return;
101 }
102
103 const invalidTokens: string[] = [];
104
105 for (const token of tokens) {
106 // Only iOS needs badge reset (Android handles badges differently)
107 if (token.platform !== "ios") {
108 continue;
109 }
110
111 try {
112 const success = await this.sendSilentBadgeUpdate(token, 0);
113 if (!success) {
114 invalidTokens.push(token.token);
115 }
116 } catch (err) {
117 console.error("Failed to send badge reset", {
118 err,
119 did,
120 });
121 }
122 }
123
124 // Clean up invalid tokens
125 if (invalidTokens.length > 0) {
126 await this.pushTokens.deleteInvalidTokens(invalidTokens);
127 }
128 }
129
130 /**
131 * Get unread notification count for a user
132 */
133 private async getUnreadCount(did: string): Promise<number> {
134 try {
135 // Get last seen timestamp
136 const actor = await this.db.models.Actor.findOne({ did }).lean();
137 const lastSeen = actor?.lastSeenNotifs;
138
139 // Build query for unread notifications
140 const filter: Record<string, unknown> = { did };
141 if (lastSeen) {
142 filter.sortAt = { $gt: lastSeen };
143 }
144
145 const count = await this.db.models.Notification.countDocuments(filter);
146 return count;
147 } catch (err) {
148 console.error("Failed to get unread count", { err, did });
149 return 1; // Default to 1 if we can't get the count
150 }
151 }
152
153 /**
154 * Send a silent push to update badge without showing notification
155 */
156 private async sendSilentBadgeUpdate(
157 token: PushToken,
158 badge: number,
159 ): Promise<boolean> {
160 if (!this.fcmServiceAccount) {
161 return true;
162 }
163
164 const accessToken = await this.getFcmAccessToken();
165 if (!accessToken) {
166 return true;
167 }
168
169 // Silent push with only badge update (no notification content)
170 const message = {
171 message: {
172 token: token.token,
173 apns: {
174 headers: {
175 "apns-push-type": "background",
176 "apns-priority": "5", // Low priority for background
177 },
178 payload: {
179 aps: {
180 "content-available": 1,
181 badge: badge,
182 },
183 },
184 },
185 },
186 };
187
188 const projectId = this.fcmServiceAccount.project_id;
189 const url =
190 `https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`;
191
192 try {
193 const response = await fetch(url, {
194 method: "POST",
195 headers: {
196 "Authorization": `Bearer ${accessToken}`,
197 "Content-Type": "application/json",
198 },
199 body: JSON.stringify(message),
200 });
201
202 if (!response.ok) {
203 const error = await response.json();
204 if (
205 error.error?.details?.some(
206 (d: { errorCode?: string }) =>
207 d.errorCode === "UNREGISTERED" ||
208 d.errorCode === "INVALID_ARGUMENT",
209 )
210 ) {
211 return false;
212 }
213 console.error("Badge reset FCM request failed", {
214 error,
215 status: response.status,
216 });
217 }
218
219 return true;
220 } catch (err) {
221 console.error("Badge reset FCM request error", { err });
222 return true;
223 }
224 }
225
226 private async sendFcm(
227 token: PushToken,
228 payload: PushPayload,
229 badgeCount: number,
230 ): Promise<boolean> {
231 if (!this.fcmServiceAccount) {
232 console.warn("FCM service account not configured");
233 return true; // Don't mark as invalid if not configured
234 }
235
236 const accessToken = await this.getFcmAccessToken();
237 if (!accessToken) {
238 return true; // Don't mark as invalid if we can't get a token
239 }
240
241 const notification = await this.buildNotificationContent(payload);
242
243 // Build base message
244 const message: FcmMessage = {
245 message: {
246 token: token.token,
247 notification: {
248 title: notification.title,
249 body: notification.body,
250 },
251 data: {
252 reason: payload.reason,
253 author: payload.author,
254 recordUri: payload.recordUri,
255 ...(payload.reasonSubject &&
256 { reasonSubject: payload.reasonSubject }),
257 },
258 },
259 };
260
261 // Add platform-specific options
262 if (token.platform === "ios") {
263 message.message.apns = {
264 headers: {
265 "apns-priority": "10",
266 },
267 payload: {
268 aps: {
269 sound: "default",
270 badge: badgeCount,
271 },
272 },
273 };
274 } else if (token.platform === "android") {
275 message.message.android = {
276 priority: "high",
277 };
278 }
279
280 const projectId = this.fcmServiceAccount.project_id;
281 const url =
282 `https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`;
283
284 try {
285 const response = await fetch(url, {
286 method: "POST",
287 headers: {
288 "Authorization": `Bearer ${accessToken}`,
289 "Content-Type": "application/json",
290 },
291 body: JSON.stringify(message),
292 });
293
294 if (!response.ok) {
295 const error = await response.json();
296 // Check for unregistered token error
297 if (
298 error.error?.details?.some(
299 (d: { errorCode?: string }) =>
300 d.errorCode === "UNREGISTERED" ||
301 d.errorCode === "INVALID_ARGUMENT",
302 )
303 ) {
304 return false; // Mark as invalid
305 }
306 console.error("FCM request failed", {
307 error,
308 status: response.status,
309 });
310 }
311
312 return true;
313 } catch (err) {
314 console.error("FCM request error", { err });
315 return true; // Don't mark as invalid on network errors
316 }
317 }
318
319 private async buildNotificationContent(
320 payload: PushPayload,
321 ): Promise<{ title: string; body: string }> {
322 // Get author handle
323 const author = await this.db.models.Actor.findOne({
324 did: payload.author,
325 }).lean();
326 const handle = author?.handle ? `${author.handle}` : "Someone";
327
328 // Handle follow notifications specially
329 if (payload.reason === "follow") {
330 // Check if recipient follows the author back (making this a "followed you back")
331 const recipientFollowsAuthor = await this.db.models.Follow.findOne({
332 authorDid: payload.recipientDid,
333 subject: payload.author,
334 }).lean();
335
336 const body = recipientFollowsAuthor
337 ? `${handle} followed you back`
338 : `${handle} followed you`;
339
340 return {
341 title: "New Follower",
342 body,
343 };
344 }
345
346 // Build title based on reason
347 const reasonMap: Record<string, string> = {
348 like: "liked your post",
349 repost: "reposted your post",
350 mention: "mentioned you",
351 reply: "replied to your post",
352 "like-via-repost": "liked your repost",
353 "repost-via-repost": "reposted your repost",
354 };
355
356 const action = reasonMap[payload.reason] || "interacted with your content";
357 const title = `${handle} ${action}`;
358
359 // Build body based on reason type
360 let body = "";
361
362 if (
363 payload.reason === "like" || payload.reason === "repost" ||
364 payload.reason === "like-via-repost" ||
365 payload.reason === "repost-via-repost"
366 ) {
367 // For likes/reposts, show the reasonSubject (the post that was liked/reposted)
368 if (payload.reasonSubject) {
369 body = await this.getRecordText(payload.reasonSubject);
370 }
371 } else if (payload.reason === "reply" || payload.reason === "mention") {
372 // For replies/mentions, show the record text (the reply or post with mention)
373 body = await this.getRecordText(payload.recordUri);
374 }
375
376 return { title, body };
377 }
378
379 private async getRecordText(uri: string): Promise<string> {
380 try {
381 const record = await this.db.models.Record.findOne({ uri }).lean();
382 if (!record?.json) return "";
383
384 const parsed = jsonStringToLex(record.json) as {
385 text?: string;
386 caption?: { text?: string };
387 };
388
389 // Try to get text from different record formats
390 const text = parsed.text || parsed.caption?.text || "";
391
392 // Truncate to reasonable length for push notification
393 if (text.length > 100) {
394 return text.substring(0, 97) + "...";
395 }
396 return text;
397 } catch {
398 return "";
399 }
400 }
401
402 private async getFcmAccessToken(): Promise<string | null> {
403 if (!this.fcmServiceAccount) {
404 return null;
405 }
406
407 // Return cached token if still valid
408 if (this.fcmAccessToken && Date.now() < this.fcmTokenExpiry - 60000) {
409 return this.fcmAccessToken;
410 }
411
412 try {
413 const now = Math.floor(Date.now() / 1000);
414 const exp = now + 3600; // 1 hour
415
416 const header = {
417 alg: "RS256",
418 typ: "JWT",
419 };
420
421 const claim = {
422 iss: this.fcmServiceAccount.client_email,
423 scope: "https://www.googleapis.com/auth/firebase.messaging",
424 aud: "https://oauth2.googleapis.com/token",
425 iat: now,
426 exp: exp,
427 };
428
429 // Create JWT
430 const encoder = new TextEncoder();
431 const headerB64 = this.base64UrlEncode(
432 encoder.encode(JSON.stringify(header)),
433 );
434 const claimB64 = this.base64UrlEncode(
435 encoder.encode(JSON.stringify(claim)),
436 );
437 const unsignedJwt = `${headerB64}.${claimB64}`;
438
439 // Import private key and sign
440 const privateKey = await this.importPrivateKey(
441 this.fcmServiceAccount.private_key,
442 );
443 const signature = await crypto.subtle.sign(
444 { name: "RSASSA-PKCS1-v1_5" },
445 privateKey,
446 encoder.encode(unsignedJwt),
447 );
448
449 const signatureB64 = this.base64UrlEncode(new Uint8Array(signature));
450 const jwt = `${unsignedJwt}.${signatureB64}`;
451
452 // Exchange JWT for access token
453 const response = await fetch("https://oauth2.googleapis.com/token", {
454 method: "POST",
455 headers: {
456 "Content-Type": "application/x-www-form-urlencoded",
457 },
458 body: new URLSearchParams({
459 grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
460 assertion: jwt,
461 }),
462 });
463
464 if (!response.ok) {
465 console.error("Failed to get FCM access token", {
466 status: response.status,
467 });
468 return null;
469 }
470
471 const data = await response.json();
472 this.fcmAccessToken = data.access_token;
473 this.fcmTokenExpiry = Date.now() + (data.expires_in * 1000);
474
475 return this.fcmAccessToken;
476 } catch (err) {
477 console.error("Error getting FCM access token", { err });
478 return null;
479 }
480 }
481
482 private async importPrivateKey(pem: string): Promise<CryptoKey> {
483 const pemContents = pem
484 .replace("-----BEGIN PRIVATE KEY-----", "")
485 .replace("-----END PRIVATE KEY-----", "")
486 .replace(/\n/g, "");
487
488 const binaryDer = Uint8Array.from(
489 atob(pemContents),
490 (c) => c.charCodeAt(0),
491 );
492
493 return await crypto.subtle.importKey(
494 "pkcs8",
495 binaryDer,
496 { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
497 false,
498 ["sign"],
499 );
500 }
501
502 private base64UrlEncode(data: Uint8Array): string {
503 const base64 = btoa(String.fromCharCode(...data));
504 return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
505 }
506}
507
508// FCM message types
509interface FcmMessage {
510 message: {
511 token: string;
512 notification: {
513 title: string;
514 body: string;
515 };
516 data: Record<string, string>;
517 android?: {
518 priority: string;
519 };
520 apns?: {
521 headers: Record<string, string>;
522 payload: {
523 aps: {
524 sound?: string;
525 badge?: number;
526 "interruption-level"?: string;
527 "relevance-score"?: number;
528 "mutable-content"?: number;
529 };
530 };
531 };
532 };
533}