@atproto/bsky 0.0.18 → 0.0.20

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.
@@ -6880,6 +6880,10 @@ export declare const schemaDict: {
6880
6880
  ref: string;
6881
6881
  };
6882
6882
  };
6883
+ seenAt: {
6884
+ type: string;
6885
+ format: string;
6886
+ };
6883
6887
  };
6884
6888
  };
6885
6889
  };
@@ -5,7 +5,7 @@ export interface QueryParams {
5
5
  actor: string;
6
6
  limit: number;
7
7
  cursor?: string;
8
- filter: 'posts_with_replies' | 'posts_no_replies' | 'posts_with_media' | (string & {});
8
+ filter: 'posts_with_replies' | 'posts_no_replies' | 'posts_with_media' | 'posts_and_author_threads' | (string & {});
9
9
  }
10
10
  export declare type InputSchema = undefined;
11
11
  export interface OutputSchema {
@@ -12,6 +12,7 @@ export declare type InputSchema = undefined;
12
12
  export interface OutputSchema {
13
13
  cursor?: string;
14
14
  notifications: Notification[];
15
+ seenAt?: string;
15
16
  [k: string]: unknown;
16
17
  }
17
18
  export declare type HandlerInput = undefined;
@@ -89,6 +89,7 @@ export declare class ModerationService {
89
89
  legacyRefId: number | null;
90
90
  } | null | undefined>;
91
91
  getSubjectsDueForReversal(): Promise<ModerationSubjectStatusRow[]>;
92
+ isSubjectSuspended(did: string): Promise<boolean>;
92
93
  revertState({ createdBy, createdAt, comment, action, subject, }: ReversibleModerationEvent): Promise<{
93
94
  result: ModerationEventRow;
94
95
  restored?: TakedownSubjects;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atproto/bsky",
3
- "version": "0.0.18",
3
+ "version": "0.0.20",
4
4
  "license": "MIT",
5
5
  "description": "Reference implementation of app.bsky App View (Bluesky API)",
6
6
  "keywords": [
@@ -35,13 +35,13 @@
35
35
  "sharp": "^0.32.6",
36
36
  "typed-emitter": "^2.1.0",
37
37
  "uint8arrays": "3.0.0",
38
- "@atproto/api": "^0.7.1",
38
+ "@atproto/api": "^0.7.3",
39
39
  "@atproto/common": "^0.3.3",
40
40
  "@atproto/crypto": "^0.3.0",
41
41
  "@atproto/syntax": "^0.1.5",
42
+ "@atproto/identity": "^0.3.2",
42
43
  "@atproto/lexicon": "^0.3.1",
43
44
  "@atproto/repo": "^0.3.6",
44
- "@atproto/identity": "^0.3.2",
45
45
  "@atproto/xrpc-server": "^0.4.2"
46
46
  },
47
47
  "devDependencies": {
@@ -52,10 +52,10 @@
52
52
  "@types/pg": "^8.6.6",
53
53
  "@types/qs": "^6.9.7",
54
54
  "axios": "^0.27.2",
55
- "@atproto/api": "^0.7.1",
56
- "@atproto/dev-env": "^0.2.18",
55
+ "@atproto/api": "^0.7.3",
56
+ "@atproto/dev-env": "^0.2.20",
57
57
  "@atproto/lex-cli": "^0.2.5",
58
- "@atproto/pds": "^0.3.6",
58
+ "@atproto/pds": "^0.3.8",
59
59
  "@atproto/xrpc": "^0.4.1"
60
60
  },
61
61
  "scripts": {
@@ -11,6 +11,7 @@ import {
11
11
  } from '../../../../services/actor'
12
12
  import { setRepoRev } from '../../../util'
13
13
  import { createPipeline, noRules } from '../../../../pipeline'
14
+ import { ModerationService } from '../../../../services/moderation'
14
15
 
15
16
  export default function (server: Server, ctx: AppContext) {
16
17
  const getProfile = createPipeline(skeleton, hydration, noRules, presentation)
@@ -19,6 +20,7 @@ export default function (server: Server, ctx: AppContext) {
19
20
  handler: async ({ auth, params, res }) => {
20
21
  const db = ctx.db.getReplica()
21
22
  const actorService = ctx.services.actor(db)
23
+ const modService = ctx.services.moderation(ctx.db.getPrimary())
22
24
  const viewer = 'did' in auth.credentials ? auth.credentials.did : null
23
25
  const canViewTakendownProfile =
24
26
  auth.credentials.type === 'role' && auth.credentials.triage
@@ -26,7 +28,7 @@ export default function (server: Server, ctx: AppContext) {
26
28
  const [result, repoRev] = await Promise.allSettled([
27
29
  getProfile(
28
30
  { ...params, viewer, canViewTakendownProfile },
29
- { db, actorService },
31
+ { db, actorService, modService },
30
32
  ),
31
33
  actorService.getRepoRev(viewer),
32
34
  ])
@@ -50,17 +52,25 @@ const skeleton = async (
50
52
  params: Params,
51
53
  ctx: Context,
52
54
  ): Promise<SkeletonState> => {
53
- const { actorService } = ctx
55
+ const { actorService, modService } = ctx
54
56
  const { canViewTakendownProfile } = params
55
57
  const actor = await actorService.getActor(params.actor, true)
56
58
  if (!actor) {
57
59
  throw new InvalidRequestError('Profile not found')
58
60
  }
59
61
  if (!canViewTakendownProfile && softDeleted(actor)) {
60
- throw new InvalidRequestError(
61
- 'Account has been taken down',
62
- 'AccountTakedown',
63
- )
62
+ const isSuspended = await modService.isSubjectSuspended(actor.did)
63
+ if (isSuspended) {
64
+ throw new InvalidRequestError(
65
+ 'Account has been temporarily suspended',
66
+ 'AccountTakedown',
67
+ )
68
+ } else {
69
+ throw new InvalidRequestError(
70
+ 'Account has been taken down',
71
+ 'AccountTakedown',
72
+ )
73
+ }
64
74
  }
65
75
  return { params, actor }
66
76
  }
@@ -95,6 +105,7 @@ const presentation = (state: HydrationState, ctx: Context) => {
95
105
  type Context = {
96
106
  db: Database
97
107
  actorService: ActorService
108
+ modService: ModerationService
98
109
  }
99
110
 
100
111
  type Params = QueryParams & {
@@ -102,6 +102,13 @@ export const skeleton = async (
102
102
  feedItemsQb = feedItemsQb.where((qb) =>
103
103
  qb.where('post.replyParent', 'is', null).orWhere('type', '=', 'repost'),
104
104
  )
105
+ } else if (filter === 'posts_and_author_threads') {
106
+ feedItemsQb = feedItemsQb.where((qb) =>
107
+ qb
108
+ .where('type', '=', 'repost')
109
+ .orWhere('post.replyParent', 'is', null)
110
+ .orWhere('post.replyRoot', 'like', `at://${actorDid}/%`),
111
+ )
105
112
  }
106
113
 
107
114
  const keyset = new FeedKeyset(ref('feed_item.sortAt'), ref('feed_item.cid'))
@@ -148,7 +148,7 @@ const presentation = (state: HydrationState) => {
148
148
  labels: [...recordLabels, ...recordSelfLabels],
149
149
  }
150
150
  })
151
- return { notifications, cursor }
151
+ return { notifications, cursor, seenAt: lastSeenNotifs }
152
152
  }
153
153
 
154
154
  const getRecordMap = async (
@@ -5572,6 +5572,7 @@ export const schemaDict = {
5572
5572
  'posts_with_replies',
5573
5573
  'posts_no_replies',
5574
5574
  'posts_with_media',
5575
+ 'posts_and_author_threads',
5575
5576
  ],
5576
5577
  default: 'posts_with_replies',
5577
5578
  },
@@ -7324,6 +7325,10 @@ export const schemaDict = {
7324
7325
  ref: 'lex:app.bsky.notification.listNotifications#notification',
7325
7326
  },
7326
7327
  },
7328
+ seenAt: {
7329
+ type: 'string',
7330
+ format: 'datetime',
7331
+ },
7327
7332
  },
7328
7333
  },
7329
7334
  },
@@ -17,6 +17,7 @@ export interface QueryParams {
17
17
  | 'posts_with_replies'
18
18
  | 'posts_no_replies'
19
19
  | 'posts_with_media'
20
+ | 'posts_and_author_threads'
20
21
  | (string & {})
21
22
  }
22
23
 
@@ -21,6 +21,7 @@ export type InputSchema = undefined
21
21
  export interface OutputSchema {
22
22
  cursor?: string
23
23
  notifications: Notification[]
24
+ seenAt?: string
24
25
  [k: string]: unknown
25
26
  }
26
27
 
@@ -323,6 +323,18 @@ export class ModerationService {
323
323
  return subjectsDueForReversal
324
324
  }
325
325
 
326
+ async isSubjectSuspended(did: string): Promise<boolean> {
327
+ const res = await this.db.db
328
+ .selectFrom('moderation_subject_status')
329
+ .where('did', '=', did)
330
+ .where('recordPath', '=', '')
331
+ .where('suspendUntil', '>', new Date().toISOString())
332
+ .select('did')
333
+ .limit(1)
334
+ .executeTakeFirst()
335
+ return !!res
336
+ }
337
+
326
338
  async revertState({
327
339
  createdBy,
328
340
  createdAt,
@@ -0,0 +1,95 @@
1
+ import { SeedClient } from '@atproto/dev-env'
2
+ import basicSeed from './basic'
3
+
4
+ export default async (sc: SeedClient) => {
5
+ await basicSeed(sc)
6
+ await sc.createAccount('eve', {
7
+ email: 'eve@test.com',
8
+ handle: 'eve.test',
9
+ password: 'eve-pass',
10
+ })
11
+ await sc.createAccount('fred', {
12
+ email: 'fred@test.com',
13
+ handle: 'fred.test',
14
+ password: 'fred-pass',
15
+ })
16
+
17
+ const alice = sc.dids.alice
18
+ const eve = sc.dids.eve
19
+ const fred = sc.dids.fred
20
+
21
+ /*
22
+ * Self thread
23
+ */
24
+ await sc.post(eve, evePosts[0])
25
+ await sc.reply(
26
+ eve,
27
+ sc.posts[eve][0].ref,
28
+ sc.posts[eve][0].ref,
29
+ eveOwnThreadReplies[0],
30
+ )
31
+ await sc.reply(
32
+ eve,
33
+ sc.posts[eve][0].ref,
34
+ sc.replies[eve][0].ref,
35
+ eveOwnThreadReplies[1],
36
+ )
37
+ await sc.reply(
38
+ eve,
39
+ sc.posts[eve][0].ref,
40
+ sc.replies[eve][1].ref,
41
+ eveOwnThreadReplies[2],
42
+ )
43
+
44
+ /**
45
+ * Two replies to Alice
46
+ */
47
+ await sc.reply(
48
+ eve,
49
+ sc.posts[alice][1].ref,
50
+ sc.posts[alice][1].ref,
51
+ eveAliceReplies[0],
52
+ )
53
+ await sc.reply(
54
+ eve,
55
+ sc.posts[alice][1].ref,
56
+ sc.replies[eve][3].ref,
57
+ eveAliceReplies[1],
58
+ )
59
+
60
+ /**
61
+ * Two replies to Fred, who replied to Eve's root post. This creates a
62
+ * "detached" thread, where one Fred post breaks the continuity.
63
+ */
64
+ await sc.post(eve, evePosts[1])
65
+ await sc.reply(
66
+ fred,
67
+ sc.posts[eve][1].ref,
68
+ sc.posts[eve][1].ref,
69
+ fredReplies[0],
70
+ )
71
+ await sc.reply(
72
+ eve,
73
+ sc.posts[eve][1].ref,
74
+ sc.replies[fred][0].ref,
75
+ eveFredReplies[0],
76
+ )
77
+ await sc.reply(
78
+ eve,
79
+ sc.posts[eve][1].ref,
80
+ sc.replies[eve][4].ref,
81
+ eveFredReplies[1],
82
+ )
83
+
84
+ return sc
85
+ }
86
+
87
+ const evePosts = ['eve own thread', 'eve detached thread']
88
+ const eveOwnThreadReplies = [
89
+ 'eve own reply 1',
90
+ 'eve own reply 2',
91
+ 'eve own reply 3',
92
+ ]
93
+ const eveAliceReplies = ['eve reply to alice 1', 'eve reply to alice 2']
94
+ const eveFredReplies = ['eve reply to fred 1', 'eve reply to fred 2']
95
+ const fredReplies = ['fred reply to eve 1']
@@ -178,7 +178,7 @@ Array [
178
178
  "createdAt": "1970-01-01T00:00:00.000000Z",
179
179
  "text": "again",
180
180
  },
181
- "replyCount": 2,
181
+ "replyCount": 3,
182
182
  "repostCount": 1,
183
183
  "uri": "record(2)",
184
184
  "viewer": Object {},
@@ -395,7 +395,7 @@ Array [
395
395
  "createdAt": "1970-01-01T00:00:00.000000Z",
396
396
  "text": "again",
397
397
  },
398
- "replyCount": 2,
398
+ "replyCount": 3,
399
399
  "repostCount": 1,
400
400
  "uri": "record(2)",
401
401
  "viewer": Object {},
@@ -590,7 +590,7 @@ Array [
590
590
  "createdAt": "1970-01-01T00:00:00.000000Z",
591
591
  "text": "again",
592
592
  },
593
- "replyCount": 2,
593
+ "replyCount": 3,
594
594
  "repostCount": 1,
595
595
  "uri": "record(1)",
596
596
  "viewer": Object {
@@ -638,7 +638,7 @@ Array [
638
638
  "createdAt": "1970-01-01T00:00:00.000000Z",
639
639
  "text": "again",
640
640
  },
641
- "replyCount": 2,
641
+ "replyCount": 3,
642
642
  "repostCount": 1,
643
643
  "uri": "record(1)",
644
644
  "viewer": Object {
@@ -961,7 +961,7 @@ Array [
961
961
  "createdAt": "1970-01-01T00:00:00.000000Z",
962
962
  "text": "again",
963
963
  },
964
- "replyCount": 2,
964
+ "replyCount": 3,
965
965
  "repostCount": 1,
966
966
  "uri": "record(6)",
967
967
  "viewer": Object {
@@ -1009,7 +1009,7 @@ Array [
1009
1009
  "createdAt": "1970-01-01T00:00:00.000000Z",
1010
1010
  "text": "again",
1011
1011
  },
1012
- "replyCount": 2,
1012
+ "replyCount": 3,
1013
1013
  "repostCount": 1,
1014
1014
  "uri": "record(6)",
1015
1015
  "viewer": Object {
@@ -1326,7 +1326,7 @@ Array [
1326
1326
  "createdAt": "1970-01-01T00:00:00.000000Z",
1327
1327
  "text": "again",
1328
1328
  },
1329
- "replyCount": 2,
1329
+ "replyCount": 3,
1330
1330
  "repostCount": 1,
1331
1331
  "uri": "record(3)",
1332
1332
  "viewer": Object {
@@ -1376,7 +1376,7 @@ Array [
1376
1376
  "createdAt": "1970-01-01T00:00:00.000000Z",
1377
1377
  "text": "again",
1378
1378
  },
1379
- "replyCount": 2,
1379
+ "replyCount": 3,
1380
1380
  "repostCount": 1,
1381
1381
  "uri": "record(3)",
1382
1382
  "viewer": Object {
@@ -1765,7 +1765,7 @@ Array [
1765
1765
  "createdAt": "1970-01-01T00:00:00.000000Z",
1766
1766
  "text": "again",
1767
1767
  },
1768
- "replyCount": 2,
1768
+ "replyCount": 3,
1769
1769
  "repostCount": 1,
1770
1770
  "uri": "record(4)",
1771
1771
  "viewer": Object {
@@ -1987,7 +1987,7 @@ Array [
1987
1987
  "createdAt": "1970-01-01T00:00:00.000000Z",
1988
1988
  "text": "again",
1989
1989
  },
1990
- "replyCount": 2,
1990
+ "replyCount": 3,
1991
1991
  "repostCount": 1,
1992
1992
  "uri": "record(4)",
1993
1993
  "viewer": Object {
@@ -1,7 +1,7 @@
1
1
  import AtpAgent from '@atproto/api'
2
2
  import { TestNetwork, SeedClient } from '@atproto/dev-env'
3
3
  import { forSnapshot, paginateAll, stripViewerFromPost } from '../_util'
4
- import basicSeed from '../seeds/basic'
4
+ import authorFeedSeed from '../seeds/author-feed'
5
5
  import { isRecord } from '../../src/lexicon/types/app/bsky/feed/post'
6
6
  import { isView as isEmbedRecordWithMedia } from '../../src/lexicon/types/app/bsky/embed/recordWithMedia'
7
7
  import { isView as isImageEmbed } from '../../src/lexicon/types/app/bsky/embed/images'
@@ -16,6 +16,7 @@ describe('pds author feed views', () => {
16
16
  let bob: string
17
17
  let carol: string
18
18
  let dan: string
19
+ let eve: string
19
20
 
20
21
  beforeAll(async () => {
21
22
  network = await TestNetwork.create({
@@ -23,12 +24,13 @@ describe('pds author feed views', () => {
23
24
  })
24
25
  agent = network.bsky.getClient()
25
26
  sc = network.getSeedClient()
26
- await basicSeed(sc)
27
+ await authorFeedSeed(sc)
27
28
  await network.processAll()
28
29
  alice = sc.dids.alice
29
30
  bob = sc.dids.bob
30
31
  carol = sc.dids.carol
31
32
  dan = sc.dids.dan
33
+ eve = sc.dids.eve
32
34
  })
33
35
 
34
36
  afterAll(async () => {
@@ -305,4 +307,20 @@ describe('pds author feed views', () => {
305
307
  }),
306
308
  ).toBeTruthy()
307
309
  })
310
+
311
+ it('posts_and_author_threads includes self-replies', async () => {
312
+ const { data: eveFeed } = await agent.api.app.bsky.feed.getAuthorFeed({
313
+ actor: eve,
314
+ filter: 'posts_and_author_threads',
315
+ })
316
+
317
+ expect(eveFeed.feed.length).toEqual(7)
318
+ expect(
319
+ eveFeed.feed.some(({ post }) => {
320
+ return (
321
+ isRecord(post.record) && post.record.reply && post.author.did === eve
322
+ )
323
+ }),
324
+ ).toBeTruthy()
325
+ })
308
326
  })
@@ -176,6 +176,13 @@ describe('notification views', () => {
176
176
  encoding: 'application/json',
177
177
  },
178
178
  )
179
+ const full2 = await agent.api.app.bsky.notification.listNotifications(
180
+ {},
181
+ { headers: await network.serviceHeaders(alice) },
182
+ )
183
+ expect(full2.data.notifications.length).toBe(full.data.notifications.length)
184
+ expect(full2.data.seenAt).toEqual(seenAt)
185
+
179
186
  const notifCount = await agent.api.app.bsky.notification.getUnreadCount(
180
187
  {},
181
188
  { headers: await network.serviceHeaders(alice) },
@@ -224,6 +224,52 @@ describe('pds profile views', () => {
224
224
  )
225
225
  })
226
226
 
227
+ it('blocked by actor suspension', async () => {
228
+ await agent.api.com.atproto.admin.emitModerationEvent(
229
+ {
230
+ event: {
231
+ $type: 'com.atproto.admin.defs#modEventTakedown',
232
+ durationInHours: 1,
233
+ },
234
+ subject: {
235
+ $type: 'com.atproto.admin.defs#repoRef',
236
+ did: alice,
237
+ },
238
+ createdBy: 'did:example:admin',
239
+ reason: 'Y',
240
+ },
241
+ {
242
+ encoding: 'application/json',
243
+ headers: network.pds.adminAuthHeaders(),
244
+ },
245
+ )
246
+ const promise = agent.api.app.bsky.actor.getProfile(
247
+ { actor: alice },
248
+ { headers: await network.serviceHeaders(bob) },
249
+ )
250
+
251
+ await expect(promise).rejects.toThrow(
252
+ 'Account has been temporarily suspended',
253
+ )
254
+
255
+ // Cleanup
256
+ await agent.api.com.atproto.admin.emitModerationEvent(
257
+ {
258
+ event: { $type: 'com.atproto.admin.defs#modEventReverseTakedown' },
259
+ subject: {
260
+ $type: 'com.atproto.admin.defs#repoRef',
261
+ did: alice,
262
+ },
263
+ createdBy: 'did:example:admin',
264
+ reason: 'Y',
265
+ },
266
+ {
267
+ encoding: 'application/json',
268
+ headers: network.pds.adminAuthHeaders(),
269
+ },
270
+ )
271
+ })
272
+
227
273
  async function updateProfile(did: string, record: Record<string, unknown>) {
228
274
  return await pdsAgent.api.com.atproto.repo.putRecord(
229
275
  {