[READ ONLY MIRROR] Spark Social AppView Server github.com/sprksocial/server
atproto deno hono lexicon
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 3b73895e29748ca524bbe040b656ddb4e167104b 533 lines 14 kB view raw
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}