unoffical wafrn mirror wafrn.net
atproto social-network activitypub
at virtualscroll 251 lines 8.9 kB view raw
1import { Expo } from 'expo-server-sdk' 2import { Follows, Notification, PushNotificationToken, UserOptions } from '../../models/index.js' 3import { logger } from '../logger.js' 4import { 5 getNotificationBody, 6 getNotificationTitle, 7 handleDeliveryError, 8 type NotificationBody, 9 type NotificationContext 10} from '../pushNotifications.js' 11import { Job, Queue } from 'bullmq' 12import { Op } from 'sequelize' 13import { getMutedPosts } from '../cacheGetters/getMutedPosts.js' 14import { sendWebPushNotifications } from '../webpush.js' 15import getBlockedIds from '../cacheGetters/getBlockedIds.js' 16import { completeEnvironment } from '../backendOptions.js' 17 18const deliveryCheckQueue = new Queue('checkPushNotificationDelivery', { 19 connection: completeEnvironment.bullmqConnection, 20 defaultJobOptions: { 21 removeOnComplete: true, 22 attempts: 3, 23 backoff: { 24 type: 'exponential', 25 delay: 1000 26 } 27 } 28}) 29 30const websocketQueue = new Queue('updateNotificationsSocket', { 31 connection: completeEnvironment.bullmqConnection, 32 defaultJobOptions: { 33 removeOnComplete: true, 34 attempts: 3, 35 backoff: { 36 type: 'exponential', 37 delay: 1000 38 } 39 } 40}) 41 42const expoClient = new Expo() 43 44type PushNotificationPayload = { 45 notifications: NotificationBody[] 46 context?: NotificationContext 47} 48 49export async function sendPushNotification(job: Job<PushNotificationPayload>) { 50 const { notifications, context } = job.data 51 let notificationsToSend: NotificationBody[] = [] 52 for await (const notification of notifications) { 53 const mutedPosts = new Set( 54 (await getMutedPosts(notification.notifiedUserId, false)).concat( 55 await getMutedPosts(notification.notifiedUserId, true) 56 ) 57 ) 58 if (!mutedPosts.has(notification.postId ? notification.postId : '')) { 59 const blockedUsers = await getBlockedIds(notification.notifiedUserId) // do not push notification if muted user 60 if (notification.userId == notification.notifiedUserId || blockedUsers.includes(notification.userId)) { 61 // this is from a blocked user or same user. do not notify 62 continue 63 } 64 // TODO this part of code is repeated. take it to a function another day 65 const options = await UserOptions.findAll({ 66 where: { 67 userId: notification.notifiedUserId, 68 optionName: { 69 [Op.in]: [ 70 'wafrn.notificationsFrom', 71 'wafrn.notifyMentions', 72 'wafrn.notifyReactions', 73 'wafrn.notifyQuotes', 74 'wafrn.notifyFollows', 75 'wafrn.notifyRewoots' 76 ] 77 } 78 } 79 }) 80 const optionNotificationsFrom = options.find((elem) => elem.optionName == 'wafrn.notificationsFrom') 81 const optionNotifyQuotes = options.find((elem) => elem.optionName == 'wafrn.notifyQuotes') 82 const optionNotifyMentions = options.find((elem) => elem.optionName == 'wafrn.notifyMentions') 83 const optionNotifyReactions = options.find((elem) => elem.optionName == 'wafrn.notifyReactions') 84 const optionNotifyFollows = options.find((elem) => elem.optionName == 'wafrn.notifyFollows') 85 const optionNotifyRewoots = options.find((elem) => elem.optionName == 'wafrn.notifyRewoots') 86 87 const notificationTypes = [] 88 if (!optionNotifyQuotes || optionNotifyQuotes.optionValue != 'false') { 89 notificationTypes.push('QUOTE') 90 } 91 if (!optionNotifyMentions || optionNotifyMentions.optionValue != 'false') { 92 notificationTypes.push('MENTION') 93 } 94 if (!optionNotifyReactions || optionNotifyReactions.optionValue != 'false') { 95 notificationTypes.push('EMOJIREACT') 96 notificationTypes.push('LIKE') 97 notificationTypes.push('POSTBITE') 98 notificationTypes.push('USERBITE') 99 } 100 if (!optionNotifyFollows || optionNotifyFollows.optionValue != 'false') { 101 notificationTypes.push('FOLLOW') 102 } 103 if (!optionNotifyRewoots || optionNotifyRewoots.optionValue != 'false') { 104 notificationTypes.push('REWOOT') 105 } 106 if (notificationTypes.includes(notification.notificationType)) { 107 if (optionNotificationsFrom && optionNotificationsFrom.optionValue != '1') { 108 let validUsers: string[] = [] 109 switch (optionNotificationsFrom.optionValue) { 110 case '2': // followers 111 validUsers = ( 112 await Follows.findAll({ 113 where: { 114 accepted: true, 115 followedId: notification.notifiedUserId 116 } 117 }) 118 ).map((elem) => elem.followerId) 119 case '3': // followees 120 validUsers = ( 121 await Follows.findAll({ 122 where: { 123 accepted: true, 124 followerId: notification.notifiedUserId 125 } 126 }) 127 ).map((elem) => elem.followedId) 128 case '4': // mutuals 129 const followerIds = ( 130 await Follows.findAll({ 131 where: { 132 accepted: true, 133 followedId: notification.notifiedUserId 134 } 135 }) 136 ).map((elem) => elem.followerId) 137 validUsers = ( 138 await Follows.findAll({ 139 where: { 140 accepted: true, 141 followerId: notification.notifiedUserId, 142 followedId: { 143 [Op.in]: followerIds 144 } 145 } 146 }) 147 ).map((elem) => elem.followedId) 148 if (validUsers.includes(notification.userId)) { 149 notificationsToSend.push(notification) 150 } 151 continue 152 } 153 } else { 154 notificationsToSend.push(notification) 155 continue 156 } 157 } 158 } 159 } 160 161 if (notificationsToSend.length > 0) { 162 await sendWebPushNotifications(notificationsToSend, context) 163 await sendExpoNotifications(notificationsToSend, context) 164 await sendWsNotifications(notificationsToSend, context) 165 } 166} 167 168export async function sendExpoNotifications(notifications: NotificationBody[], context?: NotificationContext) { 169 const userIds = notifications.map((elem) => elem.notifiedUserId) 170 const tokenRows = await PushNotificationToken.findAll({ 171 where: { 172 userId: { 173 [Op.in]: userIds 174 } 175 } 176 }) 177 178 if (tokenRows.length === 0) { 179 return 180 } 181 const payloads = notifications.map((notification) => { 182 const tokens = tokenRows.filter((row) => row.userId === notification.notifiedUserId).map((row) => row.token) 183 184 // send the same notification to all the devices of each notified user 185 return { 186 to: tokens, 187 sound: 'default', 188 title: getNotificationTitle(notification, context), 189 body: getNotificationBody(notification, context), 190 data: { notification, context } 191 } 192 }) 193 194 // this will chunk the payloads into chunks of 1000 (max) and compress notifications with similar content 195 const okTickets = [] 196 const filteredPayloads: { 197 to: any[] 198 sound: string 199 title: string 200 body: string 201 data: { 202 notification: NotificationBody 203 context: NotificationContext | undefined 204 } 205 }[] = [] 206 for await (const payload of payloads) { 207 const mutedPosts = (await getMutedPosts(payload.data.notification.notifiedUserId, false)).concat( 208 await getMutedPosts(payload.data.notification.notifiedUserId, true) 209 ) 210 if (!mutedPosts.includes(payload.data.notification.postId as string)) { 211 filteredPayloads.push(payload) 212 } 213 } 214 const chunks = expoClient.chunkPushNotifications(filteredPayloads) 215 for (const chunk of chunks) { 216 const responses = await expoClient.sendPushNotificationsAsync(chunk) 217 for (const response of responses) { 218 if (response.status === 'ok') { 219 okTickets.push(response.id) 220 } else { 221 await handleDeliveryError(response) 222 } 223 } 224 } 225 226 await scheduleNotificationCheck(okTickets) 227} 228 229// schedule a job to check the delivery of the notifications after 30 minutes of being sent 230// this guarantees that the notification was delivered to the messaging services even in cases of high load 231function scheduleNotificationCheck(ticketIds: string[]) { 232 const delay = 1000 * 60 * 30 // 30 minutes 233 return deliveryCheckQueue.add('checkPushNotificationDelivery', { ticketIds }, { delay }) 234} 235 236async function sendWsNotifications(notifications: NotificationBody[], context?: NotificationContext) { 237 await websocketQueue.addBulk( 238 notifications.map((elem) => { 239 // we just tell the user to update the notifications 240 return { 241 name: 'updateNotificationsSocket', 242 data: { 243 userId: elem.notifiedUserId, 244 type: elem.notificationType, 245 from: elem.userId, 246 postId: elem.postId ? elem.postId : '' 247 } 248 } 249 }) 250 ) 251}