@atproto/bsky 0.0.25 → 0.0.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/buf.gen.yaml +12 -0
  3. package/dist/api/app/bsky/unspecced/getTaggedSuggestions.d.ts +3 -0
  4. package/dist/bsync.d.ts +8 -0
  5. package/dist/config.d.ts +20 -0
  6. package/dist/context.d.ts +6 -3
  7. package/dist/courier.d.ts +8 -0
  8. package/dist/db/database-schema.d.ts +2 -1
  9. package/dist/db/index.js +15 -1
  10. package/dist/db/index.js.map +3 -3
  11. package/dist/db/migrations/20240124T023719200Z-tagged-suggestions.d.ts +3 -0
  12. package/dist/db/migrations/index.d.ts +1 -0
  13. package/dist/db/tables/tagged-suggestion.d.ts +9 -0
  14. package/dist/index.js +47930 -16807
  15. package/dist/index.js.map +3 -3
  16. package/dist/indexer/config.d.ts +8 -0
  17. package/dist/indexer/context.d.ts +3 -0
  18. package/dist/ingester/config.d.ts +8 -0
  19. package/dist/ingester/context.d.ts +3 -0
  20. package/dist/ingester/mute-subscription.d.ts +22 -0
  21. package/dist/lexicon/index.d.ts +2 -0
  22. package/dist/lexicon/lexicons.d.ts +48 -0
  23. package/dist/lexicon/types/app/bsky/unspecced/getTaggedSuggestions.d.ts +39 -0
  24. package/dist/notifications.d.ts +27 -16
  25. package/dist/proto/bsync_connect.d.ts +25 -0
  26. package/dist/proto/bsync_pb.d.ts +90 -0
  27. package/dist/proto/courier_connect.d.ts +25 -0
  28. package/dist/proto/courier_pb.d.ts +91 -0
  29. package/dist/services/actor/index.d.ts +2 -2
  30. package/dist/services/indexing/index.d.ts +2 -2
  31. package/dist/services/util/post.d.ts +6 -6
  32. package/dist/util/retry.d.ts +2 -0
  33. package/package.json +15 -7
  34. package/proto/courier.proto +56 -0
  35. package/src/api/app/bsky/graph/muteActor.ts +32 -5
  36. package/src/api/app/bsky/graph/muteActorList.ts +32 -5
  37. package/src/api/app/bsky/graph/unmuteActor.ts +32 -5
  38. package/src/api/app/bsky/graph/unmuteActorList.ts +32 -5
  39. package/src/api/app/bsky/notification/registerPush.ts +42 -8
  40. package/src/api/app/bsky/unspecced/getTaggedSuggestions.ts +21 -0
  41. package/src/api/index.ts +2 -0
  42. package/src/bsync.ts +41 -0
  43. package/src/config.ts +79 -0
  44. package/src/context.ts +12 -6
  45. package/src/courier.ts +41 -0
  46. package/src/db/database-schema.ts +2 -0
  47. package/src/db/migrations/20240124T023719200Z-tagged-suggestions.ts +15 -0
  48. package/src/db/migrations/index.ts +1 -0
  49. package/src/db/tables/tagged-suggestion.ts +11 -0
  50. package/src/index.ts +26 -3
  51. package/src/indexer/config.ts +36 -0
  52. package/src/indexer/context.ts +6 -0
  53. package/src/indexer/index.ts +27 -3
  54. package/src/ingester/config.ts +34 -0
  55. package/src/ingester/context.ts +6 -0
  56. package/src/ingester/index.ts +18 -0
  57. package/src/ingester/mute-subscription.ts +213 -0
  58. package/src/lexicon/index.ts +12 -0
  59. package/src/lexicon/lexicons.ts +50 -0
  60. package/src/lexicon/types/app/bsky/unspecced/getTaggedSuggestions.ts +65 -0
  61. package/src/notifications.ts +165 -149
  62. package/src/proto/bsync_connect.ts +54 -0
  63. package/src/proto/bsync_pb.ts +459 -0
  64. package/src/proto/courier_connect.ts +50 -0
  65. package/src/proto/courier_pb.ts +473 -0
  66. package/src/services/actor/index.ts +17 -2
  67. package/src/services/indexing/processor.ts +1 -1
  68. package/src/util/retry.ts +12 -0
  69. package/tests/notification-server.test.ts +59 -19
  70. package/tests/subscription/mutes.test.ts +170 -0
  71. package/tests/views/suggestions.test.ts +22 -0
@@ -1,6 +1,8 @@
1
1
  import axios from 'axios'
2
2
  import { Insertable, sql } from 'kysely'
3
3
  import TTLCache from '@isaacs/ttlcache'
4
+ import { Struct, Timestamp } from '@bufbuild/protobuf'
5
+ import murmur from 'murmurhash'
4
6
  import { AtUri } from '@atproto/api'
5
7
  import { MINUTE, chunkArray } from '@atproto/common'
6
8
  import Database from './db/primary'
@@ -9,11 +11,13 @@ import { NotificationPushToken as PushToken } from './db/tables/notification-pus
9
11
  import logger from './indexer/logger'
10
12
  import { notSoftDeletedClause, valuesList } from './db/util'
11
13
  import { ids } from './lexicon/lexicons'
12
- import { retryHttp } from './util/retry'
14
+ import { retryConnect, retryHttp } from './util/retry'
15
+ import { Notification as CourierNotification } from './proto/courier_pb'
16
+ import { CourierClient } from './courier'
13
17
 
14
18
  export type Platform = 'ios' | 'android' | 'web'
15
19
 
16
- type PushNotification = {
20
+ type GorushNotification = {
17
21
  tokens: string[]
18
22
  platform: 1 | 2 // 1 = ios, 2 = android
19
23
  title: string
@@ -26,161 +30,24 @@ type PushNotification = {
26
30
  collapse_key?: string
27
31
  }
28
32
 
29
- type InsertableNotif = Insertable<Notification>
33
+ type NotifRow = Insertable<Notification>
30
34
 
31
- type NotifDisplay = {
35
+ type NotifView = {
32
36
  key: string
33
37
  rateLimit: boolean
34
38
  title: string
35
39
  body: string
36
- notif: InsertableNotif
40
+ notif: NotifRow
37
41
  }
38
42
 
39
- export class NotificationServer {
40
- private rateLimiter = new RateLimiter(1, 30 * MINUTE)
41
-
42
- constructor(public db: Database, public pushEndpoint?: string) {}
43
-
44
- async getTokensByDid(dids: string[]) {
45
- if (!dids.length) return {}
46
- const tokens = await this.db.db
47
- .selectFrom('notification_push_token')
48
- .where('did', 'in', dids)
49
- .selectAll()
50
- .execute()
51
- return tokens.reduce((acc, token) => {
52
- acc[token.did] ??= []
53
- acc[token.did].push(token)
54
- return acc
55
- }, {} as Record<string, PushToken[]>)
56
- }
57
-
58
- async prepareNotifsToSend(notifications: InsertableNotif[]) {
59
- const now = Date.now()
60
- const notifsToSend: PushNotification[] = []
61
- const tokensByDid = await this.getTokensByDid(
62
- unique(notifications.map((n) => n.did)),
63
- )
64
- // views for all notifications that have tokens
65
- const notificationViews = await this.getNotificationDisplayAttributes(
66
- notifications.filter((n) => tokensByDid[n.did]),
67
- )
68
-
69
- for (const notifView of notificationViews) {
70
- if (!isRecent(notifView.notif.sortAt, 10 * MINUTE)) {
71
- continue // if the notif is from > 10 minutes ago, don't send push notif
72
- }
73
- const { did: userDid } = notifView.notif
74
- const userTokens = tokensByDid[userDid] ?? []
75
- for (const t of userTokens) {
76
- const { appId, platform, token } = t
77
- if (notifView.rateLimit && !this.rateLimiter.check(token, now)) {
78
- continue
79
- }
80
- if (platform === 'ios' || platform === 'android') {
81
- notifsToSend.push({
82
- tokens: [token],
83
- platform: platform === 'ios' ? 1 : 2,
84
- title: notifView.title,
85
- message: notifView.body,
86
- topic: appId,
87
- data: {
88
- reason: notifView.notif.reason,
89
- recordUri: notifView.notif.recordUri,
90
- recordCid: notifView.notif.recordCid,
91
- },
92
- collapse_id: notifView.key,
93
- collapse_key: notifView.key,
94
- })
95
- } else {
96
- // @TODO: Handle web notifs
97
- logger.warn({ did: userDid }, 'cannot send web notification to user')
98
- }
99
- }
100
- }
43
+ export abstract class NotificationServer<N = unknown> {
44
+ constructor(public db: Database) {}
101
45
 
102
- return notifsToSend
103
- }
46
+ abstract prepareNotifications(notifs: NotifRow[]): Promise<N[]>
104
47
 
105
- /**
106
- * The function `addNotificationsToQueue` adds push notifications to a queue, taking into account rate
107
- * limiting and batching the notifications for efficient processing.
108
- * @param {PushNotification[]} notifs - An array of PushNotification objects. Each PushNotification
109
- * object has a "tokens" property which is an array of tokens.
110
- * @returns void
111
- */
112
- async processNotifications(notifs: PushNotification[]) {
113
- for (const batch of chunkArray(notifs, 20)) {
114
- try {
115
- await this.sendPushNotifications(batch)
116
- } catch (err) {
117
- logger.error({ err, batch }, 'notification push batch failed')
118
- }
119
- }
120
- }
48
+ abstract processNotifications(prepared: N[]): Promise<void>
121
49
 
122
- /** 1. Get the user's token (APNS or FCM for iOS and Android respectively) from the database
123
- User token will be in the format:
124
- did || token || platform (1 = iOS, 2 = Android, 3 = Web)
125
- 2. Send notification to `gorush` server with token
126
- Notification will be in the format:
127
- "notifications": [
128
- {
129
- "tokens": string[],
130
- "platform": 1 | 2,
131
- "message": string,
132
- "title": string,
133
- "priority": "normal" | "high",
134
- "image": string, (Android only)
135
- "expiration": number, (iOS only)
136
- "badge": number, (iOS only)
137
- }
138
- ]
139
- 3. `gorush` will send notification to APNS or FCM
140
- 4. store response from `gorush` which contains the ID of the notification
141
- 5. If notification needs to be updated or deleted, find the ID of the notification from the database and send a new notification to `gorush` with the ID (repeat step 2)
142
- */
143
- private async sendPushNotifications(notifications: PushNotification[]) {
144
- // if pushEndpoint is not defined, we are not running in the indexer service, so we can't send push notifications
145
- if (!this.pushEndpoint) {
146
- throw new Error('Push endpoint not defined')
147
- }
148
- // if no notifications, skip and return early
149
- if (notifications.length === 0) {
150
- return
151
- }
152
- const pushEndpoint = this.pushEndpoint
153
- await retryHttp(() =>
154
- axios.post(
155
- pushEndpoint,
156
- { notifications },
157
- {
158
- headers: {
159
- 'Content-Type': 'application/json',
160
- accept: 'application/json',
161
- },
162
- },
163
- ),
164
- )
165
- }
166
-
167
- async registerDeviceForPushNotifications(
168
- did: string,
169
- token: string,
170
- platform: Platform,
171
- appId: string,
172
- ) {
173
- // if token doesn't exist, insert it, on conflict do nothing
174
- await this.db.db
175
- .insertInto('notification_push_token')
176
- .values({ did, token, platform, appId })
177
- .onConflict((oc) => oc.doNothing())
178
- .execute()
179
- }
180
-
181
- async getNotificationDisplayAttributes(
182
- notifs: InsertableNotif[],
183
- ): Promise<NotifDisplay[]> {
50
+ async getNotificationViews(notifs: NotifRow[]): Promise<NotifView[]> {
184
51
  const { ref } = this.db.db.dynamic
185
52
  const authorDids = notifs.map((n) => n.author)
186
53
  const subjectUris = notifs.flatMap((n) => n.reasonSubject ?? [])
@@ -219,7 +86,7 @@ export class NotificationServer {
219
86
  return acc
220
87
  }, {} as Record<string, { text: string }>)
221
88
 
222
- const results: NotifDisplay[] = []
89
+ const results: NotifView[] = []
223
90
 
224
91
  for (const notif of notifs) {
225
92
  const {
@@ -310,7 +177,7 @@ export class NotificationServer {
310
177
  return results
311
178
  }
312
179
 
313
- async findBlocksAndMutes(notifs: InsertableNotif[]) {
180
+ private async findBlocksAndMutes(notifs: NotifRow[]) {
314
181
  const pairs = notifs.map((n) => ({ author: n.author, receiver: n.did }))
315
182
  const { ref } = this.db.db.dynamic
316
183
  const blockQb = this.db.db
@@ -353,6 +220,155 @@ export class NotificationServer {
353
220
  }
354
221
  }
355
222
 
223
+ export class GorushNotificationServer extends NotificationServer<GorushNotification> {
224
+ private rateLimiter = new RateLimiter(1, 30 * MINUTE)
225
+
226
+ constructor(public db: Database, public pushEndpoint: string) {
227
+ super(db)
228
+ }
229
+
230
+ async prepareNotifications(
231
+ notifs: NotifRow[],
232
+ ): Promise<GorushNotification[]> {
233
+ const now = Date.now()
234
+ const notifsToSend: GorushNotification[] = []
235
+ const tokensByDid = await this.getTokensByDid(
236
+ unique(notifs.map((n) => n.did)),
237
+ )
238
+ // views for all notifications that have tokens
239
+ const notificationViews = await this.getNotificationViews(
240
+ notifs.filter((n) => tokensByDid[n.did]),
241
+ )
242
+
243
+ for (const notifView of notificationViews) {
244
+ if (!isRecent(notifView.notif.sortAt, 10 * MINUTE)) {
245
+ continue // if the notif is from > 10 minutes ago, don't send push notif
246
+ }
247
+ const { did: userDid } = notifView.notif
248
+ const userTokens = tokensByDid[userDid] ?? []
249
+ for (const t of userTokens) {
250
+ const { appId, platform, token } = t
251
+ if (notifView.rateLimit && !this.rateLimiter.check(token, now)) {
252
+ continue
253
+ }
254
+ if (platform === 'ios' || platform === 'android') {
255
+ notifsToSend.push({
256
+ tokens: [token],
257
+ platform: platform === 'ios' ? 1 : 2,
258
+ title: notifView.title,
259
+ message: notifView.body,
260
+ topic: appId,
261
+ data: {
262
+ reason: notifView.notif.reason,
263
+ recordUri: notifView.notif.recordUri,
264
+ recordCid: notifView.notif.recordCid,
265
+ },
266
+ collapse_id: notifView.key,
267
+ collapse_key: notifView.key,
268
+ })
269
+ } else {
270
+ // @TODO: Handle web notifs
271
+ logger.warn({ did: userDid }, 'cannot send web notification to user')
272
+ }
273
+ }
274
+ }
275
+ return notifsToSend
276
+ }
277
+
278
+ async getTokensByDid(dids: string[]) {
279
+ if (!dids.length) return {}
280
+ const tokens = await this.db.db
281
+ .selectFrom('notification_push_token')
282
+ .where('did', 'in', dids)
283
+ .selectAll()
284
+ .execute()
285
+ return tokens.reduce((acc, token) => {
286
+ acc[token.did] ??= []
287
+ acc[token.did].push(token)
288
+ return acc
289
+ }, {} as Record<string, PushToken[]>)
290
+ }
291
+
292
+ async processNotifications(prepared: GorushNotification[]): Promise<void> {
293
+ for (const batch of chunkArray(prepared, 20)) {
294
+ try {
295
+ await this.sendToGorush(batch)
296
+ } catch (err) {
297
+ logger.error({ err, batch }, 'notification push batch failed')
298
+ }
299
+ }
300
+ }
301
+
302
+ private async sendToGorush(prepared: GorushNotification[]) {
303
+ // if no notifications, skip and return early
304
+ if (prepared.length === 0) {
305
+ return
306
+ }
307
+ const pushEndpoint = this.pushEndpoint
308
+ await retryHttp(() =>
309
+ axios.post(
310
+ pushEndpoint,
311
+ { notifications: prepared },
312
+ {
313
+ headers: {
314
+ 'content-type': 'application/json',
315
+ accept: 'application/json',
316
+ },
317
+ },
318
+ ),
319
+ )
320
+ }
321
+ }
322
+
323
+ export class CourierNotificationServer extends NotificationServer<CourierNotification> {
324
+ constructor(public db: Database, public courierClient: CourierClient) {
325
+ super(db)
326
+ }
327
+
328
+ async prepareNotifications(
329
+ notifs: NotifRow[],
330
+ ): Promise<CourierNotification[]> {
331
+ const notificationViews = await this.getNotificationViews(notifs)
332
+ const notifsToSend = notificationViews.map((n) => {
333
+ return new CourierNotification({
334
+ id: getCourierId(n),
335
+ recipientDid: n.notif.did,
336
+ title: n.title,
337
+ message: n.body,
338
+ collapseKey: n.key,
339
+ alwaysDeliver: !n.rateLimit,
340
+ timestamp: Timestamp.fromDate(new Date(n.notif.sortAt)),
341
+ additional: Struct.fromJson({
342
+ uri: n.notif.recordUri,
343
+ reason: n.notif.reason,
344
+ subject: n.notif.reasonSubject || '',
345
+ }),
346
+ })
347
+ })
348
+ return notifsToSend
349
+ }
350
+
351
+ async processNotifications(prepared: CourierNotification[]): Promise<void> {
352
+ try {
353
+ await retryConnect(() =>
354
+ this.courierClient.pushNotifications({ notifications: prepared }),
355
+ )
356
+ } catch (err) {
357
+ logger.error({ err }, 'notification push to courier failed')
358
+ }
359
+ }
360
+ }
361
+
362
+ const getCourierId = (notif: NotifView) => {
363
+ const key = [
364
+ notif.notif.recordUri,
365
+ notif.notif.did,
366
+ notif.notif.reason,
367
+ notif.notif.reasonSubject || '',
368
+ ].join('::')
369
+ return murmur.v3(key).toString(16)
370
+ }
371
+
356
372
  const isRecent = (isoTime: string, timeDiff: number): boolean => {
357
373
  const diff = Date.now() - new Date(isoTime).getTime()
358
374
  return diff < timeDiff
@@ -0,0 +1,54 @@
1
+ // @generated by protoc-gen-connect-es v1.3.0 with parameter "target=ts,import_extension=.ts"
2
+ // @generated from file bsync.proto (package bsync, syntax proto3)
3
+ /* eslint-disable */
4
+ // @ts-nocheck
5
+
6
+ import {
7
+ AddMuteOperationRequest,
8
+ AddMuteOperationResponse,
9
+ PingRequest,
10
+ PingResponse,
11
+ ScanMuteOperationsRequest,
12
+ ScanMuteOperationsResponse,
13
+ } from './bsync_pb.ts'
14
+ import { MethodKind } from '@bufbuild/protobuf'
15
+
16
+ /**
17
+ * @generated from service bsync.Service
18
+ */
19
+ export const Service = {
20
+ typeName: 'bsync.Service',
21
+ methods: {
22
+ /**
23
+ * Sync
24
+ *
25
+ * @generated from rpc bsync.Service.AddMuteOperation
26
+ */
27
+ addMuteOperation: {
28
+ name: 'AddMuteOperation',
29
+ I: AddMuteOperationRequest,
30
+ O: AddMuteOperationResponse,
31
+ kind: MethodKind.Unary,
32
+ },
33
+ /**
34
+ * @generated from rpc bsync.Service.ScanMuteOperations
35
+ */
36
+ scanMuteOperations: {
37
+ name: 'ScanMuteOperations',
38
+ I: ScanMuteOperationsRequest,
39
+ O: ScanMuteOperationsResponse,
40
+ kind: MethodKind.Unary,
41
+ },
42
+ /**
43
+ * Ping
44
+ *
45
+ * @generated from rpc bsync.Service.Ping
46
+ */
47
+ ping: {
48
+ name: 'Ping',
49
+ I: PingRequest,
50
+ O: PingResponse,
51
+ kind: MethodKind.Unary,
52
+ },
53
+ },
54
+ } as const