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

Configure Feed

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

feat: push notifications

+850 -8
+4
api/index.ts
··· 34 34 import listNotifications from "./so/sprk/notification/listNotifications.ts"; 35 35 import getUnreadCount from "./so/sprk/notification/getUnreadCount.ts"; 36 36 import updateSeen from "./so/sprk/notification/updateSeen.ts"; 37 + import registerPush from "./so/sprk/notification/registerPush.ts"; 38 + import unregisterPush from "./so/sprk/notification/unregisterPush.ts"; 37 39 38 40 export default function (server: Server, ctx: AppContext) { 39 41 getAccountInfos(server, ctx); ··· 70 72 listNotifications(server, ctx); 71 73 getUnreadCount(server, ctx); 72 74 updateSeen(server, ctx); 75 + registerPush(server, ctx); 76 + unregisterPush(server, ctx); 73 77 }
+2 -2
api/so/sprk/notification/getUnreadCount.ts
··· 40 40 throw new InvalidRequestError("The seenAt parameter is unsupported"); 41 41 } 42 42 const priority = params.priority ?? false; 43 - 43 + 44 44 // Get the stored lastSeenNotifs timestamp 45 45 const lastSeenRes = await ctx.hydrator.dataplane.notifications 46 46 .getNotificationSeen(params.viewer, priority); 47 - 47 + 48 48 const res = await ctx.hydrator.dataplane.notifications 49 49 .getUnreadNotificationCount( 50 50 params.viewer,
+18
api/so/sprk/notification/registerPush.ts
··· 1 + import { AppContext } from "../../../../context.ts"; 2 + import { Server } from "../../../../lex/index.ts"; 3 + 4 + export default function (server: Server, ctx: AppContext) { 5 + server.so.sprk.notification.registerPush({ 6 + auth: ctx.authVerifier.standard, 7 + handler: async ({ input, auth }) => { 8 + const viewer = auth.credentials.iss; 9 + await ctx.dataplane.pushTokens.upsert({ 10 + did: viewer, 11 + token: input.body.token, 12 + platform: input.body.platform as "ios" | "android" | "web", 13 + appId: input.body.appId, 14 + serviceDid: input.body.serviceDid, 15 + }); 16 + }, 17 + }); 18 + }
+12
api/so/sprk/notification/unregisterPush.ts
··· 1 + import { AppContext } from "../../../../context.ts"; 2 + import { Server } from "../../../../lex/index.ts"; 3 + 4 + export default function (server: Server, ctx: AppContext) { 5 + server.so.sprk.notification.unregisterPush({ 6 + auth: ctx.authVerifier.standard, 7 + handler: async ({ input, auth }) => { 8 + const viewer = auth.credentials.iss; 9 + await ctx.dataplane.pushTokens.delete(viewer, input.body.token); 10 + }, 11 + }); 12 + }
+42
config.ts
··· 33 33 plcUrl?: string; 34 34 35 35 labelsFromIssuerDids: string[]; 36 + 37 + // Push notifications 38 + pushEnabled: boolean; 39 + fcmServiceAccount?: string; 40 + apnsKeyId?: string; 41 + apnsTeamId?: string; 42 + apnsKeyPath?: string; 43 + apnsTopic?: string; 36 44 } 37 45 38 46 export class ServerConfig { ··· 76 84 77 85 const labelsFromIssuerDids = envList("SPRK_LABELS_FROM_ISSUER_DIDS") ?? []; 78 86 87 + // Push notifications 88 + const pushEnabled = Deno.env.get("SPRK_PUSH_ENABLED") === "true"; 89 + const fcmServiceAccount = envStr("SPRK_FCM_SERVICE_ACCOUNT"); 90 + const apnsKeyId = envStr("SPRK_APNS_KEY_ID"); 91 + const apnsTeamId = envStr("SPRK_APNS_TEAM_ID"); 92 + const apnsKeyPath = envStr("SPRK_APNS_KEY_PATH"); 93 + const apnsTopic = envStr("SPRK_APNS_TOPIC"); 94 + 79 95 return new ServerConfig({ 80 96 version, 81 97 debugMode, ··· 102 118 relayUrl, 103 119 plcUrl, 104 120 labelsFromIssuerDids, 121 + pushEnabled, 122 + fcmServiceAccount, 123 + apnsKeyId, 124 + apnsTeamId, 125 + apnsKeyPath, 126 + apnsTopic, 105 127 }); 106 128 } 107 129 ··· 185 207 186 208 get labelsFromIssuerDids() { 187 209 return this.cfg.labelsFromIssuerDids; 210 + } 211 + 212 + // Push notifications 213 + get pushEnabled() { 214 + return this.cfg.pushEnabled; 215 + } 216 + get fcmServiceAccount() { 217 + return this.cfg.fcmServiceAccount; 218 + } 219 + get apnsKeyId() { 220 + return this.cfg.apnsKeyId; 221 + } 222 + get apnsTeamId() { 223 + return this.cfg.apnsTeamId; 224 + } 225 + get apnsKeyPath() { 226 + return this.cfg.apnsKeyPath; 227 + } 228 + get apnsTopic() { 229 + return this.cfg.apnsTopic; 188 230 } 189 231 }
+4
data-plane/db/index.ts
··· 140 140 "Notification", 141 141 models.notificationSchema, 142 142 ), 143 + PushToken: this.connection.model<models.PushTokenDocument>( 144 + "PushToken", 145 + models.pushTokenSchema, 146 + ), 143 147 }; 144 148 145 149 this.logger.info("Started connection to MongoDB");
+23
data-plane/db/models.ts
··· 620 620 .index({ did: 1, sortAt: -1 }) 621 621 .index({ did: 1, reason: 1, sortAt: -1 }); 622 622 623 + // push tokens 624 + 625 + export interface PushTokenDocument extends Document { 626 + did: string; 627 + token: string; 628 + platform: "ios" | "android" | "web"; 629 + appId: string; 630 + serviceDid: string; 631 + createdAt: string; 632 + updatedAt: string; 633 + } 634 + export const pushTokenSchema = new Schema<PushTokenDocument>({ 635 + did: { type: String, required: true, index: true }, 636 + token: { type: String, required: true }, 637 + platform: { type: String, required: true, enum: ["ios", "android", "web"] }, 638 + appId: { type: String, required: true }, 639 + serviceDid: { type: String, required: true }, 640 + createdAt: { type: String, required: true }, 641 + updatedAt: { type: String, required: true }, 642 + }) 643 + .index({ did: 1, token: 1, platform: 1, appId: 1 }, { unique: true }); 644 + 623 645 // Apply plugin to schemas that extend AuthoredDocument 624 646 ([ 625 647 profileSchema, ··· 658 680 Preference: Model<PreferenceDocument>; 659 681 CursorState: Model<CursorStateDocument>; 660 682 Notification: Model<NotificationDocument>; 683 + PushToken: Model<PushTokenDocument>; 661 684 }
+3
data-plane/index.ts
··· 21 21 import { Preferences } from "./routes/preferences.ts"; 22 22 import { Search } from "./routes/search.ts"; 23 23 import { Labels } from "./routes/labels.ts"; 24 + import { PushTokens } from "./routes/push-tokens.ts"; 24 25 25 26 export { RepoSubscription } from "./subscription.ts"; 26 27 ··· 55 56 public preferences: Preferences; 56 57 public search: Search; 57 58 public labels: Labels; 59 + public pushTokens: PushTokens; 58 60 59 61 constructor( 60 62 db: Database, ··· 85 87 this.preferences = new Preferences(db); 86 88 this.search = new Search(db); 87 89 this.labels = new Labels(db); 90 + this.pushTokens = new PushTokens(db); 88 91 } 89 92 }
+12
data-plane/indexing/index.ts
··· 29 29 import { RecordProcessor } from "./processor.ts"; 30 30 import { getLogger, Logger } from "@logtape/logtape"; 31 31 import { ServerConfig } from "../../config.ts"; 32 + import { PushService } from "../../utils/push.ts"; 32 33 33 34 export class IndexingService { 34 35 records: { ··· 45 46 labeler: Labeler.PluginType; 46 47 }; 47 48 logger: Logger; 49 + private pushService?: PushService; 48 50 49 51 constructor( 50 52 public db: Database, 51 53 public cfg: ServerConfig, 52 54 public idResolver: IdResolver, 53 55 public background: BackgroundQueue, 56 + pushService?: PushService, 54 57 ) { 55 58 this.logger = getLogger(["appview", "indexer"]); 59 + this.pushService = pushService; 56 60 this.records = { 57 61 post: Post.makePlugin(this.db, this.background), 58 62 reply: Reply.makePlugin(this.db, this.background), ··· 66 70 audio: Audio.makePlugin(this.db, this.background), 67 71 labeler: Labeler.makePlugin(this.db, this.background), 68 72 }; 73 + 74 + // Set push service on all processors 75 + if (pushService) { 76 + Object.values(this.records).forEach((processor) => { 77 + processor.setPushService(pushService); 78 + }); 79 + } 69 80 } 70 81 71 82 transact(txn: Database) { ··· 74 85 this.cfg, 75 86 this.idResolver, 76 87 this.background, 88 + this.pushService, 77 89 ); 78 90 } 79 91
+21
data-plane/indexing/processor.ts
··· 5 5 import { BackgroundQueue } from "../background.ts"; 6 6 import { Database } from "../db/index.ts"; 7 7 import { chunkArray } from "@atp/common"; 8 + import { PushService } from "../../utils/push.ts"; 8 9 9 10 // @NOTE re: insertions and deletions. Due to how record updates are handled, 10 11 // (insertFn) should have the same effect as (insertFn -> deleteFn -> insertFn). ··· 44 45 export class RecordProcessor<T, S> { 45 46 collection: string; 46 47 db: Database; 48 + private pushService: PushService | null = null; 47 49 48 50 /** 49 51 * RecordProcessor for handling a single AT Protocol collection. ··· 71 73 ) { 72 74 this.db = appDb; 73 75 this.collection = this.params.lexId; 76 + } 77 + 78 + setPushService(pushService: PushService) { 79 + this.pushService = pushService; 74 80 } 75 81 76 82 matchesCollection(uri: AtUri): boolean { ··· 335 341 // Need to ensure notif deletion always happens before creation, otherwise delete may clobber in a race. 336 342 for (const fn of runOnCommit) { 337 343 await fn(this.appDb); // these could be backgrounded 344 + } 345 + 346 + // Queue push notifications in the background 347 + if (this.pushService?.enabled && notifs.length > 0) { 348 + for (const notif of notifs) { 349 + this.background.add(async () => { 350 + await this.pushService?.sendPush(notif.did, { 351 + recipientDid: notif.did, 352 + reason: notif.reason, 353 + author: notif.author, 354 + recordUri: notif.recordUri, 355 + reasonSubject: notif.reasonSubject, 356 + }); 357 + }); 358 + } 338 359 } 339 360 } 340 361
+76
data-plane/routes/push-tokens.ts
··· 1 + import { Database } from "../db/index.ts"; 2 + 3 + export interface PushTokenInput { 4 + did: string; 5 + token: string; 6 + platform: "ios" | "android" | "web"; 7 + appId: string; 8 + serviceDid: string; 9 + } 10 + 11 + export interface PushToken { 12 + did: string; 13 + token: string; 14 + platform: "ios" | "android" | "web"; 15 + appId: string; 16 + serviceDid: string; 17 + createdAt: string; 18 + updatedAt: string; 19 + } 20 + 21 + export class PushTokens { 22 + private db: Database; 23 + 24 + constructor(db: Database) { 25 + this.db = db; 26 + } 27 + 28 + async upsert(input: PushTokenInput): Promise<void> { 29 + const now = new Date().toISOString(); 30 + 31 + await this.db.models.PushToken.findOneAndUpdate( 32 + { 33 + did: input.did, 34 + token: input.token, 35 + platform: input.platform, 36 + appId: input.appId, 37 + }, 38 + { 39 + $set: { 40 + serviceDid: input.serviceDid, 41 + updatedAt: now, 42 + }, 43 + $setOnInsert: { 44 + did: input.did, 45 + token: input.token, 46 + platform: input.platform, 47 + appId: input.appId, 48 + createdAt: now, 49 + }, 50 + }, 51 + { upsert: true }, 52 + ); 53 + } 54 + 55 + async delete(did: string, token: string): Promise<void> { 56 + await this.db.models.PushToken.deleteOne({ did, token }); 57 + } 58 + 59 + async getTokensForDid(did: string): Promise<PushToken[]> { 60 + const tokens = await this.db.models.PushToken.find({ did }).lean(); 61 + return tokens.map((t) => ({ 62 + did: t.did, 63 + token: t.token, 64 + platform: t.platform, 65 + appId: t.appId, 66 + serviceDid: t.serviceDid, 67 + createdAt: t.createdAt, 68 + updatedAt: t.updatedAt, 69 + })); 70 + } 71 + 72 + async deleteInvalidTokens(tokens: string[]): Promise<void> { 73 + if (tokens.length === 0) return; 74 + await this.db.models.PushToken.deleteMany({ token: { $in: tokens } }); 75 + } 76 + }
+16
data-plane/subscription.ts
··· 6 6 import { IndexingService } from "./indexing/index.ts"; 7 7 import { getLogger, Logger } from "@logtape/logtape"; 8 8 import { ServerConfig } from "../config.ts"; 9 + import { PushService } from "../utils/push.ts"; 10 + import { PushTokens } from "./routes/push-tokens.ts"; 9 11 10 12 export class RepoSubscription { 11 13 firehose: Firehose; ··· 13 15 background: BackgroundQueue; 14 16 indexingSvc: IndexingService; 15 17 logger: Logger; 18 + pushService: PushService; 16 19 private firehoseRunning = false; 17 20 18 21 constructor( ··· 26 29 const { db, idResolver, startCursor, cfg } = opts; 27 30 this.logger = getLogger(["appview", "subscription"]); 28 31 this.background = new BackgroundQueue(db, this.logger); 32 + 33 + // Create push service 34 + const pushTokens = new PushTokens(db); 35 + this.pushService = new PushService(pushTokens, db, { 36 + enabled: cfg.pushEnabled, 37 + fcmServiceAccount: cfg.fcmServiceAccount, 38 + apnsKeyId: cfg.apnsKeyId, 39 + apnsTeamId: cfg.apnsTeamId, 40 + apnsKeyPath: cfg.apnsKeyPath, 41 + apnsTopic: cfg.apnsTopic, 42 + }); 43 + 29 44 this.indexingSvc = new IndexingService( 30 45 db, 31 46 cfg, 32 47 idResolver, 33 48 this.background, 49 + this.pushService, 34 50 ); 35 51 36 52 const { runner, firehose } = createFirehose({
+13
lex/index.ts
··· 173 173 import type * as SoSprkNotificationRegisterPush from "./types/so/sprk/notification/registerPush.ts"; 174 174 import type * as SoSprkNotificationPutPreferences from "./types/so/sprk/notification/putPreferences.ts"; 175 175 import type * as SoSprkNotificationUpdateSeen from "./types/so/sprk/notification/updateSeen.ts"; 176 + import type * as SoSprkNotificationUnregisterPush from "./types/so/sprk/notification/unregisterPush.ts"; 176 177 import type * as SoSprkNotificationListNotifications from "./types/so/sprk/notification/listNotifications.ts"; 177 178 import type * as SoSprkNotificationGetUnreadCount from "./types/so/sprk/notification/getUnreadCount.ts"; 178 179 import type * as SoSprkGraphGetSuggestedFollowsByActor from "./types/so/sprk/graph/getSuggestedFollowsByActor.ts"; ··· 2748 2749 >, 2749 2750 ) { 2750 2751 const nsid = "so.sprk.notification.updateSeen"; // @ts-ignore - dynamically generated 2752 + return this._server.xrpc.method(nsid, cfg); 2753 + } 2754 + 2755 + unregisterPush<A extends Auth = void>( 2756 + cfg: MethodConfigOrHandler< 2757 + A, 2758 + SoSprkNotificationUnregisterPush.QueryParams, 2759 + SoSprkNotificationUnregisterPush.HandlerInput, 2760 + SoSprkNotificationUnregisterPush.HandlerOutput 2761 + >, 2762 + ) { 2763 + const nsid = "so.sprk.notification.unregisterPush"; // @ts-ignore - dynamically generated 2751 2764 return this._server.xrpc.method(nsid, cfg); 2752 2765 } 2753 2766
+46 -2
lex/lexicons.ts
··· 16375 16375 }, 16376 16376 }, 16377 16377 }, 16378 + "SoSprkNotificationUnregisterPush": { 16379 + "lexicon": 1, 16380 + "id": "so.sprk.notification.unregisterPush", 16381 + "defs": { 16382 + "main": { 16383 + "type": "procedure", 16384 + "description": 16385 + "The inverse of registerPush - inform a specified service that push notifications should no longer be sent to the given token for the requesting account. Requires auth.", 16386 + "input": { 16387 + "encoding": "application/json", 16388 + "schema": { 16389 + "type": "object", 16390 + "required": [ 16391 + "serviceDid", 16392 + "token", 16393 + "platform", 16394 + "appId", 16395 + ], 16396 + "properties": { 16397 + "serviceDid": { 16398 + "type": "string", 16399 + "format": "did", 16400 + }, 16401 + "token": { 16402 + "type": "string", 16403 + }, 16404 + "platform": { 16405 + "type": "string", 16406 + "knownValues": [ 16407 + "ios", 16408 + "android", 16409 + "web", 16410 + ], 16411 + }, 16412 + "appId": { 16413 + "type": "string", 16414 + }, 16415 + }, 16416 + }, 16417 + }, 16418 + }, 16419 + }, 16420 + }, 16378 16421 "SoSprkNotificationListNotifications": { 16379 16422 "lexicon": 1, 16380 16423 "id": "so.sprk.notification.listNotifications", ··· 16477 16520 "follow", 16478 16521 "mention", 16479 16522 "reply", 16480 - "quote", 16481 - "starterpack-joined", 16523 + "like-via-repost", 16524 + "repost-via-repost", 16482 16525 ], 16483 16526 }, 16484 16527 "reasonSubject": { ··· 26785 26828 SoSprkNotificationRegisterPush: "so.sprk.notification.registerPush", 26786 26829 SoSprkNotificationPutPreferences: "so.sprk.notification.putPreferences", 26787 26830 SoSprkNotificationUpdateSeen: "so.sprk.notification.updateSeen", 26831 + SoSprkNotificationUnregisterPush: "so.sprk.notification.unregisterPush", 26788 26832 SoSprkNotificationListNotifications: "so.sprk.notification.listNotifications", 26789 26833 SoSprkNotificationGetUnreadCount: "so.sprk.notification.getUnreadCount", 26790 26834 SoSprkGraphGetSuggestedFollowsByActor:
+2 -2
lex/types/so/sprk/notification/listNotifications.ts
··· 53 53 | "follow" 54 54 | "mention" 55 55 | "reply" 56 - | "quote" 57 - | "starterpack-joined" 56 + | "like-via-repost" 57 + | "repost-via-repost" 58 58 | (string & globalThis.Record<PropertyKey, never>); 59 59 reasonSubject?: string; 60 60 record: { [_ in string]: unknown };
+27
lex/types/so/sprk/notification/unregisterPush.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + export type QueryParams = globalThis.Record<PropertyKey, never>; 5 + 6 + export interface InputSchema { 7 + serviceDid: string; 8 + token: string; 9 + platform: 10 + | "ios" 11 + | "android" 12 + | "web" 13 + | (string & globalThis.Record<PropertyKey, never>); 14 + appId: string; 15 + } 16 + 17 + export interface HandlerInput { 18 + encoding: "application/json"; 19 + body: InputSchema; 20 + } 21 + 22 + export interface HandlerError { 23 + status: number; 24 + message?: string; 25 + } 26 + 27 + export type HandlerOutput = HandlerError | void;
+2 -2
lexicons/so/sprk/notification/listNotifications.json
··· 68 68 "follow", 69 69 "mention", 70 70 "reply", 71 - "quote", 72 - "starterpack-joined" 71 + "like-via-repost", 72 + "repost-via-repost" 73 73 ] 74 74 }, 75 75 "reasonSubject": { "type": "string", "format": "at-uri" },
+26
lexicons/so/sprk/notification/unregisterPush.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "so.sprk.notification.unregisterPush", 4 + "defs": { 5 + "main": { 6 + "type": "procedure", 7 + "description": "The inverse of registerPush - inform a specified service that push notifications should no longer be sent to the given token for the requesting account. Requires auth.", 8 + "input": { 9 + "encoding": "application/json", 10 + "schema": { 11 + "type": "object", 12 + "required": ["serviceDid", "token", "platform", "appId"], 13 + "properties": { 14 + "serviceDid": { "type": "string", "format": "did" }, 15 + "token": { "type": "string" }, 16 + "platform": { 17 + "type": "string", 18 + "knownValues": ["ios", "android", "web"] 19 + }, 20 + "appId": { "type": "string" } 21 + } 22 + } 23 + } 24 + } 25 + } 26 + }
+5
tests/util.ts
··· 79 79 maxThreadParents: 10, 80 80 labelsFromIssuerDids: [], 81 81 notificationsDelayMs: 1000, 82 + pushEnabled: false, 82 83 }; 83 84 84 85 // ============================================================================ ··· 201 202 Notification: connection.model<models.NotificationDocument>( 202 203 "Notification", 203 204 models.notificationSchema, 205 + ), 206 + PushToken: connection.model<models.PushTokenDocument>( 207 + "PushToken", 208 + models.pushTokenSchema, 204 209 ), 205 210 }; 206 211
+496
utils/push.ts
··· 1 + import { getLogger, Logger } from "@logtape/logtape"; 2 + import { jsonStringToLex } from "@atp/lexicon"; 3 + import { PushToken, PushTokens } from "../data-plane/routes/push-tokens.ts"; 4 + import { Database } from "../data-plane/db/index.ts"; 5 + 6 + export interface PushPayload { 7 + recipientDid: string; 8 + reason: string; 9 + author: string; 10 + recordUri: string; 11 + reasonSubject?: string; 12 + } 13 + 14 + export interface PushConfig { 15 + enabled: boolean; 16 + fcmServiceAccount?: string; // JSON string of Firebase service account 17 + apnsKeyId?: string; 18 + apnsTeamId?: string; 19 + apnsKeyPath?: string; 20 + apnsTopic?: string; // Bundle ID for iOS app 21 + } 22 + 23 + interface FcmServiceAccount { 24 + project_id: string; 25 + private_key: string; 26 + client_email: string; 27 + } 28 + 29 + export class PushService { 30 + private logger: Logger; 31 + private pushTokens: PushTokens; 32 + private db: Database; 33 + private config: PushConfig; 34 + private fcmAccessToken: string | null = null; 35 + private fcmTokenExpiry: number = 0; 36 + private fcmServiceAccount: FcmServiceAccount | null = null; 37 + private apnsPrivateKey: CryptoKey | null = null; 38 + 39 + constructor(pushTokens: PushTokens, db: Database, config: PushConfig) { 40 + this.logger = getLogger(["appview", "push"]); 41 + this.pushTokens = pushTokens; 42 + this.db = db; 43 + this.config = config; 44 + 45 + if (config.fcmServiceAccount) { 46 + try { 47 + this.fcmServiceAccount = JSON.parse(config.fcmServiceAccount); 48 + } catch { 49 + this.logger.error("Failed to parse FCM service account JSON"); 50 + } 51 + } 52 + } 53 + 54 + get enabled(): boolean { 55 + return this.config.enabled; 56 + } 57 + 58 + async sendPush(did: string, payload: PushPayload): Promise<void> { 59 + if (!this.config.enabled) { 60 + return; 61 + } 62 + 63 + const tokens = await this.pushTokens.getTokensForDid(did); 64 + if (tokens.length === 0) { 65 + return; 66 + } 67 + 68 + const invalidTokens: string[] = []; 69 + 70 + for (const token of tokens) { 71 + try { 72 + if (token.platform === "ios") { 73 + const success = await this.sendApns(token, payload); 74 + if (!success) { 75 + invalidTokens.push(token.token); 76 + } 77 + } else if (token.platform === "android") { 78 + const success = await this.sendFcm(token, payload); 79 + if (!success) { 80 + invalidTokens.push(token.token); 81 + } 82 + } 83 + } catch (err) { 84 + this.logger.error("Failed to send push notification", { 85 + err, 86 + platform: token.platform, 87 + did, 88 + }); 89 + } 90 + } 91 + 92 + // Clean up invalid tokens 93 + if (invalidTokens.length > 0) { 94 + await this.pushTokens.deleteInvalidTokens(invalidTokens); 95 + this.logger.info("Removed invalid push tokens", { 96 + count: invalidTokens.length, 97 + }); 98 + } 99 + } 100 + 101 + private async sendFcm( 102 + token: PushToken, 103 + payload: PushPayload, 104 + ): Promise<boolean> { 105 + if (!this.fcmServiceAccount) { 106 + this.logger.warn("FCM service account not configured"); 107 + return true; // Don't mark as invalid if not configured 108 + } 109 + 110 + const accessToken = await this.getFcmAccessToken(); 111 + if (!accessToken) { 112 + return true; // Don't mark as invalid if we can't get a token 113 + } 114 + 115 + const notification = await this.buildNotificationContent(payload); 116 + const message = { 117 + message: { 118 + token: token.token, 119 + notification: { 120 + title: notification.title, 121 + body: notification.body, 122 + }, 123 + data: { 124 + reason: payload.reason, 125 + author: payload.author, 126 + recordUri: payload.recordUri, 127 + ...(payload.reasonSubject && 128 + { reasonSubject: payload.reasonSubject }), 129 + }, 130 + android: { 131 + priority: "high" as const, 132 + }, 133 + }, 134 + }; 135 + 136 + const projectId = this.fcmServiceAccount.project_id; 137 + const url = 138 + `https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`; 139 + 140 + try { 141 + const response = await fetch(url, { 142 + method: "POST", 143 + headers: { 144 + "Authorization": `Bearer ${accessToken}`, 145 + "Content-Type": "application/json", 146 + }, 147 + body: JSON.stringify(message), 148 + }); 149 + 150 + if (!response.ok) { 151 + const error = await response.json(); 152 + // Check for unregistered token error 153 + if ( 154 + error.error?.details?.some( 155 + (d: { errorCode?: string }) => 156 + d.errorCode === "UNREGISTERED" || 157 + d.errorCode === "INVALID_ARGUMENT", 158 + ) 159 + ) { 160 + return false; // Mark as invalid 161 + } 162 + this.logger.error("FCM request failed", { 163 + error, 164 + status: response.status, 165 + }); 166 + } 167 + 168 + return true; 169 + } catch (err) { 170 + this.logger.error("FCM request error", { err }); 171 + return true; // Don't mark as invalid on network errors 172 + } 173 + } 174 + 175 + private async sendApns( 176 + token: PushToken, 177 + payload: PushPayload, 178 + ): Promise<boolean> { 179 + if ( 180 + !this.config.apnsKeyId || !this.config.apnsTeamId || 181 + !this.config.apnsKeyPath 182 + ) { 183 + this.logger.warn("APNs not fully configured"); 184 + return true; // Don't mark as invalid if not configured 185 + } 186 + 187 + const jwt = await this.getApnsJwt(); 188 + if (!jwt) { 189 + return true; // Don't mark as invalid if we can't get a JWT 190 + } 191 + 192 + const notification = await this.buildNotificationContent(payload); 193 + const apnsPayload = { 194 + aps: { 195 + alert: { 196 + title: notification.title, 197 + body: notification.body, 198 + }, 199 + sound: "default", 200 + badge: 1, 201 + }, 202 + reason: payload.reason, 203 + author: payload.author, 204 + recordUri: payload.recordUri, 205 + ...(payload.reasonSubject && { reasonSubject: payload.reasonSubject }), 206 + }; 207 + 208 + const topic = this.config.apnsTopic || token.appId; 209 + const url = `https://api.push.apple.com/3/device/${token.token}`; 210 + 211 + try { 212 + const response = await fetch(url, { 213 + method: "POST", 214 + headers: { 215 + "authorization": `bearer ${jwt}`, 216 + "apns-topic": topic, 217 + "apns-push-type": "alert", 218 + "apns-priority": "10", 219 + }, 220 + body: JSON.stringify(apnsPayload), 221 + }); 222 + 223 + if (!response.ok) { 224 + const status = response.status; 225 + // 400 = Bad device token, 410 = Token is no longer active 226 + if (status === 400 || status === 410) { 227 + return false; // Mark as invalid 228 + } 229 + this.logger.error("APNs request failed", { status }); 230 + } 231 + 232 + return true; 233 + } catch (err) { 234 + this.logger.error("APNs request error", { err }); 235 + return true; // Don't mark as invalid on network errors 236 + } 237 + } 238 + 239 + private async buildNotificationContent( 240 + payload: PushPayload, 241 + ): Promise<{ title: string; body: string }> { 242 + // Get author handle 243 + const author = await this.db.models.Actor.findOne({ 244 + did: payload.author, 245 + }).lean(); 246 + const handle = author?.handle ? `${author.handle}` : "Someone"; 247 + 248 + // Handle follow notifications specially 249 + if (payload.reason === "follow") { 250 + // Check if recipient follows the author back (making this a "followed you back") 251 + const recipientFollowsAuthor = await this.db.models.Follow.findOne({ 252 + authorDid: payload.recipientDid, 253 + subject: payload.author, 254 + }).lean(); 255 + 256 + const body = recipientFollowsAuthor 257 + ? `${handle} followed you back` 258 + : `${handle} followed you`; 259 + 260 + return { 261 + title: "New Follower", 262 + body, 263 + }; 264 + } 265 + 266 + // Build title based on reason 267 + const reasonMap: Record<string, string> = { 268 + like: "liked your post", 269 + repost: "reposted your post", 270 + mention: "mentioned you", 271 + reply: "replied to your post", 272 + "like-via-repost": "liked your repost", 273 + "repost-via-repost": "reposted your repost", 274 + }; 275 + 276 + const action = reasonMap[payload.reason] || "interacted with your content"; 277 + const title = `${handle} ${action}`; 278 + 279 + // Build body based on reason type 280 + let body = ""; 281 + 282 + if ( 283 + payload.reason === "like" || payload.reason === "repost" || 284 + payload.reason === "like-via-repost" || 285 + payload.reason === "repost-via-repost" 286 + ) { 287 + // For likes/reposts, show the reasonSubject (the post that was liked/reposted) 288 + if (payload.reasonSubject) { 289 + body = await this.getRecordText(payload.reasonSubject); 290 + } 291 + } else if (payload.reason === "reply" || payload.reason === "mention") { 292 + // For replies/mentions, show the record text (the reply or post with mention) 293 + body = await this.getRecordText(payload.recordUri); 294 + } 295 + 296 + return { title, body }; 297 + } 298 + 299 + private async getRecordText(uri: string): Promise<string> { 300 + try { 301 + const record = await this.db.models.Record.findOne({ uri }).lean(); 302 + if (!record?.json) return ""; 303 + 304 + const parsed = jsonStringToLex(record.json) as { 305 + text?: string; 306 + caption?: { text?: string }; 307 + }; 308 + 309 + // Try to get text from different record formats 310 + const text = parsed.text || parsed.caption?.text || ""; 311 + 312 + // Truncate to reasonable length for push notification 313 + if (text.length > 100) { 314 + return text.substring(0, 97) + "..."; 315 + } 316 + return text; 317 + } catch { 318 + return ""; 319 + } 320 + } 321 + 322 + private async getFcmAccessToken(): Promise<string | null> { 323 + if (!this.fcmServiceAccount) { 324 + return null; 325 + } 326 + 327 + // Return cached token if still valid 328 + if (this.fcmAccessToken && Date.now() < this.fcmTokenExpiry - 60000) { 329 + return this.fcmAccessToken; 330 + } 331 + 332 + try { 333 + const now = Math.floor(Date.now() / 1000); 334 + const exp = now + 3600; // 1 hour 335 + 336 + const header = { 337 + alg: "RS256", 338 + typ: "JWT", 339 + }; 340 + 341 + const claim = { 342 + iss: this.fcmServiceAccount.client_email, 343 + scope: "https://www.googleapis.com/auth/firebase.messaging", 344 + aud: "https://oauth2.googleapis.com/token", 345 + iat: now, 346 + exp: exp, 347 + }; 348 + 349 + // Create JWT 350 + const encoder = new TextEncoder(); 351 + const headerB64 = this.base64UrlEncode( 352 + encoder.encode(JSON.stringify(header)), 353 + ); 354 + const claimB64 = this.base64UrlEncode( 355 + encoder.encode(JSON.stringify(claim)), 356 + ); 357 + const unsignedJwt = `${headerB64}.${claimB64}`; 358 + 359 + // Import private key and sign 360 + const privateKey = await this.importPrivateKey( 361 + this.fcmServiceAccount.private_key, 362 + ); 363 + const signature = await crypto.subtle.sign( 364 + { name: "RSASSA-PKCS1-v1_5" }, 365 + privateKey, 366 + encoder.encode(unsignedJwt), 367 + ); 368 + 369 + const signatureB64 = this.base64UrlEncode(new Uint8Array(signature)); 370 + const jwt = `${unsignedJwt}.${signatureB64}`; 371 + 372 + // Exchange JWT for access token 373 + const response = await fetch("https://oauth2.googleapis.com/token", { 374 + method: "POST", 375 + headers: { 376 + "Content-Type": "application/x-www-form-urlencoded", 377 + }, 378 + body: new URLSearchParams({ 379 + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", 380 + assertion: jwt, 381 + }), 382 + }); 383 + 384 + if (!response.ok) { 385 + this.logger.error("Failed to get FCM access token", { 386 + status: response.status, 387 + }); 388 + return null; 389 + } 390 + 391 + const data = await response.json(); 392 + this.fcmAccessToken = data.access_token; 393 + this.fcmTokenExpiry = Date.now() + (data.expires_in * 1000); 394 + 395 + return this.fcmAccessToken; 396 + } catch (err) { 397 + this.logger.error("Error getting FCM access token", { err }); 398 + return null; 399 + } 400 + } 401 + 402 + private async getApnsJwt(): Promise<string | null> { 403 + if ( 404 + !this.config.apnsKeyId || !this.config.apnsTeamId || 405 + !this.config.apnsKeyPath 406 + ) { 407 + return null; 408 + } 409 + 410 + try { 411 + // Load APNs private key if not already loaded 412 + if (!this.apnsPrivateKey) { 413 + const keyData = await Deno.readTextFile(this.config.apnsKeyPath); 414 + this.apnsPrivateKey = await this.importApnsKey(keyData); 415 + } 416 + 417 + const now = Math.floor(Date.now() / 1000); 418 + const header = { 419 + alg: "ES256", 420 + kid: this.config.apnsKeyId, 421 + }; 422 + 423 + const claim = { 424 + iss: this.config.apnsTeamId, 425 + iat: now, 426 + }; 427 + 428 + const encoder = new TextEncoder(); 429 + const headerB64 = this.base64UrlEncode( 430 + encoder.encode(JSON.stringify(header)), 431 + ); 432 + const claimB64 = this.base64UrlEncode( 433 + encoder.encode(JSON.stringify(claim)), 434 + ); 435 + const unsignedJwt = `${headerB64}.${claimB64}`; 436 + 437 + const signature = await crypto.subtle.sign( 438 + { name: "ECDSA", hash: "SHA-256" }, 439 + this.apnsPrivateKey, 440 + encoder.encode(unsignedJwt), 441 + ); 442 + 443 + // Convert DER signature to raw format for JWT 444 + const signatureB64 = this.base64UrlEncode(new Uint8Array(signature)); 445 + return `${unsignedJwt}.${signatureB64}`; 446 + } catch (err) { 447 + this.logger.error("Error creating APNs JWT", { err }); 448 + return null; 449 + } 450 + } 451 + 452 + private async importPrivateKey(pem: string): Promise<CryptoKey> { 453 + const pemContents = pem 454 + .replace("-----BEGIN PRIVATE KEY-----", "") 455 + .replace("-----END PRIVATE KEY-----", "") 456 + .replace(/\n/g, ""); 457 + 458 + const binaryDer = Uint8Array.from( 459 + atob(pemContents), 460 + (c) => c.charCodeAt(0), 461 + ); 462 + 463 + return await crypto.subtle.importKey( 464 + "pkcs8", 465 + binaryDer, 466 + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, 467 + false, 468 + ["sign"], 469 + ); 470 + } 471 + 472 + private async importApnsKey(pem: string): Promise<CryptoKey> { 473 + const pemContents = pem 474 + .replace("-----BEGIN PRIVATE KEY-----", "") 475 + .replace("-----END PRIVATE KEY-----", "") 476 + .replace(/\n/g, ""); 477 + 478 + const binaryDer = Uint8Array.from( 479 + atob(pemContents), 480 + (c) => c.charCodeAt(0), 481 + ); 482 + 483 + return await crypto.subtle.importKey( 484 + "pkcs8", 485 + binaryDer, 486 + { name: "ECDSA", namedCurve: "P-256" }, 487 + false, 488 + ["sign"], 489 + ); 490 + } 491 + 492 + private base64UrlEncode(data: Uint8Array): string { 493 + const base64 = btoa(String.fromCharCode(...data)); 494 + return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); 495 + } 496 + }