unoffical wafrn mirror
wafrn.net
atproto
social-network
activitypub
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}