@atproto/bsky 0.0.7 → 0.0.8

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.
@@ -5272,6 +5272,16 @@ export declare const schemaDict: {
5272
5272
  type: string;
5273
5273
  refs: string[];
5274
5274
  };
5275
+ tags: {
5276
+ type: string;
5277
+ maxLength: number;
5278
+ items: {
5279
+ type: string;
5280
+ maxLength: number;
5281
+ maxGraphemes: number;
5282
+ };
5283
+ description: string;
5284
+ };
5275
5285
  createdAt: {
5276
5286
  type: string;
5277
5287
  format: string;
@@ -6408,6 +6418,18 @@ export declare const schemaDict: {
6408
6418
  };
6409
6419
  };
6410
6420
  };
6421
+ tag: {
6422
+ type: string;
6423
+ description: string;
6424
+ required: string[];
6425
+ properties: {
6426
+ tag: {
6427
+ type: string;
6428
+ maxLength: number;
6429
+ maxGraphemes: number;
6430
+ };
6431
+ };
6432
+ };
6411
6433
  byteSlice: {
6412
6434
  type: string;
6413
6435
  description: string;
@@ -20,6 +20,7 @@ export interface Record {
20
20
  $type: string;
21
21
  [k: string]: unknown;
22
22
  };
23
+ tags?: string[];
23
24
  createdAt: string;
24
25
  [k: string]: unknown;
25
26
  }
@@ -1,7 +1,7 @@
1
1
  import { ValidationResult } from '@atproto/lexicon';
2
2
  export interface Main {
3
3
  index: ByteSlice;
4
- features: (Mention | Link | {
4
+ features: (Mention | Link | Tag | {
5
5
  $type: string;
6
6
  [k: string]: unknown;
7
7
  })[];
@@ -21,6 +21,12 @@ export interface Link {
21
21
  }
22
22
  export declare function isLink(v: unknown): v is Link;
23
23
  export declare function validateLink(v: unknown): ValidationResult;
24
+ export interface Tag {
25
+ tag: string;
26
+ [k: string]: unknown;
27
+ }
28
+ export declare function isTag(v: unknown): v is Tag;
29
+ export declare function validateTag(v: unknown): ValidationResult;
24
30
  export interface ByteSlice {
25
31
  byteStart: number;
26
32
  byteEnd: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atproto/bsky",
3
- "version": "0.0.7",
3
+ "version": "0.0.8",
4
4
  "license": "MIT",
5
5
  "description": "Reference implementation of app.bsky App View (Bluesky API)",
6
6
  "keywords": [
@@ -37,7 +37,7 @@
37
37
  "sharp": "^0.31.2",
38
38
  "typed-emitter": "^2.1.0",
39
39
  "uint8arrays": "3.0.0",
40
- "@atproto/api": "^0.6.16",
40
+ "@atproto/api": "^0.6.17",
41
41
  "@atproto/common": "^0.3.0",
42
42
  "@atproto/crypto": "^0.2.2",
43
43
  "@atproto/syntax": "^0.1.1",
@@ -55,10 +55,10 @@
55
55
  "@types/qs": "^6.9.7",
56
56
  "@types/sharp": "^0.31.0",
57
57
  "axios": "^0.27.2",
58
- "@atproto/api": "^0.6.16",
59
- "@atproto/dev-env": "^0.2.7",
58
+ "@atproto/api": "^0.6.17",
59
+ "@atproto/dev-env": "^0.2.8",
60
60
  "@atproto/lex-cli": "^0.2.1",
61
- "@atproto/pds": "^0.1.16",
61
+ "@atproto/pds": "^0.1.17",
62
62
  "@atproto/xrpc": "^0.3.1"
63
63
  },
64
64
  "scripts": {
@@ -1,5 +1,6 @@
1
1
  import axios from 'axios'
2
2
  import { CID } from 'multiformats/cid'
3
+ import { AtUri } from '@atproto/syntax'
3
4
  import * as ui8 from 'uint8arrays'
4
5
  import { resolveBlob } from '../api/blob-resolver'
5
6
  import { retryHttp } from '../util/retry'
@@ -8,7 +9,7 @@ import { IdResolver } from '@atproto/identity'
8
9
  import { labelerLogger as log } from '../logger'
9
10
 
10
11
  export interface ImageFlagger {
11
- scanImage(did: string, cid: CID): Promise<string[]>
12
+ scanImage(did: string, cid: CID, uri: AtUri): Promise<string[]>
12
13
  }
13
14
 
14
15
  export class Abyss implements ImageFlagger {
@@ -22,11 +23,11 @@ export class Abyss implements ImageFlagger {
22
23
  this.auth = basicAuth(this.password)
23
24
  }
24
25
 
25
- async scanImage(did: string, cid: CID): Promise<string[]> {
26
+ async scanImage(did: string, cid: CID, uri: AtUri): Promise<string[]> {
26
27
  const start = Date.now()
27
28
  const res = await retryHttp(async () => {
28
29
  try {
29
- return await this.makeReq(did, cid)
30
+ return await this.makeReq(did, cid, uri)
30
31
  } catch (err) {
31
32
  log.warn({ err, did, cid: cid.toString() }, 'abyss request failed')
32
33
  throw err
@@ -39,20 +40,24 @@ export class Abyss implements ImageFlagger {
39
40
  return this.parseRes(res)
40
41
  }
41
42
 
42
- async makeReq(did: string, cid: CID): Promise<ScannerResp> {
43
+ async makeReq(did: string, cid: CID, uri: AtUri): Promise<ScannerResp> {
43
44
  const { stream, contentType } = await resolveBlob(
44
45
  did,
45
46
  cid,
46
47
  this.ctx.db,
47
48
  this.ctx.idResolver,
48
49
  )
49
- const { data } = await axios.post(this.getReqUrl({ did }), stream, {
50
- headers: {
51
- 'Content-Type': contentType,
52
- authorization: this.auth,
50
+ const { data } = await axios.post(
51
+ this.getReqUrl({ did, uri: uri.toString() }),
52
+ stream,
53
+ {
54
+ headers: {
55
+ 'Content-Type': contentType,
56
+ authorization: this.auth,
57
+ },
58
+ timeout: 10000,
53
59
  },
54
- timeout: 10000,
55
- })
60
+ )
56
61
  return data
57
62
  }
58
63
 
@@ -69,8 +74,11 @@ export class Abyss implements ImageFlagger {
69
74
  return labels
70
75
  }
71
76
 
72
- getReqUrl(params: { did: string }) {
73
- return `${this.endpoint}/xrpc/com.atproto.unspecced.scanBlob?did=${params.did}`
77
+ getReqUrl(params: { did: string; uri: string }) {
78
+ const search = new URLSearchParams(params)
79
+ return `${
80
+ this.endpoint
81
+ }/xrpc/com.atproto.unspecced.scanBlob?${search.toString()}`
74
82
  }
75
83
  }
76
84
 
@@ -18,7 +18,10 @@ import { ImageUriBuilder } from '../image/uri'
18
18
  import { ImageInvalidator } from '../image/invalidator'
19
19
  import { Abyss } from './abyss'
20
20
  import { FuzzyMatcher, TextFlagger } from './fuzzy-matcher'
21
- import { REASONOTHER } from '../lexicon/types/com/atproto/moderation/defs'
21
+ import {
22
+ REASONOTHER,
23
+ REASONVIOLATION,
24
+ } from '../lexicon/types/com/atproto/moderation/defs'
22
25
 
23
26
  export class AutoModerator {
24
27
  public pushAgent?: AtpAgent
@@ -172,7 +175,7 @@ export class AutoModerator {
172
175
  async checkImgForTakedown(uri: AtUri, recordCid: CID, imgCids: CID[]) {
173
176
  if (imgCids.length < 0) return
174
177
  const results = await Promise.all(
175
- imgCids.map((cid) => this.imageFlagger?.scanImage(uri.host, cid)),
178
+ imgCids.map((cid) => this.imageFlagger?.scanImage(uri.host, cid, uri)),
176
179
  )
177
180
  const takedownCids: CID[] = []
178
181
  for (let i = 0; i < results.length; i++) {
@@ -207,7 +210,39 @@ export class AutoModerator {
207
210
  takedownCids: CID[],
208
211
  labels: string[],
209
212
  ) {
210
- const reason = `automated takedown for labels: ${labels.join(', ')}`
213
+ const reportReason = `automated takedown (${labels.join(
214
+ ', ',
215
+ )}). account needs review and possibly additional action`
216
+ const takedownReason = `automated takedown for labels: ${labels.join(', ')}`
217
+ log.warn(
218
+ {
219
+ uri: uri.toString(),
220
+ blobCids: takedownCids,
221
+ labels,
222
+ },
223
+ 'hard takedown of record (and blobs) based on auto-matching',
224
+ )
225
+
226
+ if (this.services.moderation) {
227
+ await this.ctx.db.transaction(async (dbTxn) => {
228
+ // directly/locally create report, even if we use pushAgent for the takedown. don't have acctual account credentials for pushAgent, only admin auth
229
+ if (!this.services.moderation) {
230
+ // checked above, outside the transaction
231
+ return
232
+ }
233
+ const modSrvc = this.services.moderation(dbTxn)
234
+ await modSrvc.report({
235
+ reportedBy: this.ctx.cfg.labelerDid,
236
+ reasonType: REASONVIOLATION,
237
+ subject: {
238
+ uri: uri,
239
+ cid: recordCid,
240
+ },
241
+ reason: reportReason,
242
+ })
243
+ })
244
+ }
245
+
211
246
  if (this.pushAgent) {
212
247
  await this.pushAgent.com.atproto.admin.takeModerationAction({
213
248
  action: 'com.atproto.admin.defs#takedown',
@@ -217,7 +252,7 @@ export class AutoModerator {
217
252
  cid: recordCid.toString(),
218
253
  },
219
254
  subjectBlobCids: takedownCids.map((c) => c.toString()),
220
- reason,
255
+ reason: takedownReason,
221
256
  createdBy: this.ctx.cfg.labelerDid,
222
257
  })
223
258
  } else {
@@ -230,7 +265,7 @@ export class AutoModerator {
230
265
  action: 'com.atproto.admin.defs#takedown',
231
266
  subject: { uri, cid: recordCid },
232
267
  subjectBlobCids: takedownCids,
233
- reason,
268
+ reason: takedownReason,
234
269
  createdBy: this.ctx.cfg.labelerDid,
235
270
  })
236
271
  await modSrvc.takedownRecord({
@@ -0,0 +1,9 @@
1
+ import { Kysely } from 'kysely'
2
+
3
+ export async function up(db: Kysely<unknown>): Promise<void> {
4
+ await db.schema.alterTable('post').addColumn('tags', 'jsonb').execute()
5
+ }
6
+
7
+ export async function down(db: Kysely<unknown>): Promise<void> {
8
+ await db.schema.alterTable('post').dropColumn('tags').execute()
9
+ }
@@ -28,3 +28,4 @@ export * as _20230817T195936007Z from './20230817T195936007Z-native-notification
28
28
  export * as _20230830T205507322Z from './20230830T205507322Z-suggested-feeds'
29
29
  export * as _20230904T211011773Z from './20230904T211011773Z-block-lists'
30
30
  export * as _20230906T222220386Z from './20230906T222220386Z-thread-gating'
31
+ export * as _20230920T213858047Z from './20230920T213858047Z-add-tags-to-post'
@@ -12,6 +12,7 @@ export interface Post {
12
12
  replyParent: string | null
13
13
  replyParentCid: string | null
14
14
  langs: string[] | null
15
+ tags: string[] | null
15
16
  invalidReplyRoot: boolean | null
16
17
  violatesThreadGate: boolean | null
17
18
  createdAt: string
@@ -5622,6 +5622,16 @@ export const schemaDict = {
5622
5622
  type: 'union',
5623
5623
  refs: ['lex:com.atproto.label.defs#selfLabels'],
5624
5624
  },
5625
+ tags: {
5626
+ type: 'array',
5627
+ maxLength: 8,
5628
+ items: {
5629
+ type: 'string',
5630
+ maxLength: 640,
5631
+ maxGraphemes: 64,
5632
+ },
5633
+ description: 'Additional non-inline tags describing this post.',
5634
+ },
5625
5635
  createdAt: {
5626
5636
  type: 'string',
5627
5637
  format: 'datetime',
@@ -6761,6 +6771,7 @@ export const schemaDict = {
6761
6771
  refs: [
6762
6772
  'lex:app.bsky.richtext.facet#mention',
6763
6773
  'lex:app.bsky.richtext.facet#link',
6774
+ 'lex:app.bsky.richtext.facet#tag',
6764
6775
  ],
6765
6776
  },
6766
6777
  },
@@ -6788,6 +6799,18 @@ export const schemaDict = {
6788
6799
  },
6789
6800
  },
6790
6801
  },
6802
+ tag: {
6803
+ type: 'object',
6804
+ description: 'A hashtag.',
6805
+ required: ['tag'],
6806
+ properties: {
6807
+ tag: {
6808
+ type: 'string',
6809
+ maxLength: 640,
6810
+ maxGraphemes: 64,
6811
+ },
6812
+ },
6813
+ },
6791
6814
  byteSlice: {
6792
6815
  type: 'object',
6793
6816
  description:
@@ -29,6 +29,8 @@ export interface Record {
29
29
  labels?:
30
30
  | ComAtprotoLabelDefs.SelfLabels
31
31
  | { $type: string; [k: string]: unknown }
32
+ /** Additional non-inline tags describing this post. */
33
+ tags?: string[]
32
34
  createdAt: string
33
35
  [k: string]: unknown
34
36
  }
@@ -8,7 +8,7 @@ import { CID } from 'multiformats/cid'
8
8
 
9
9
  export interface Main {
10
10
  index: ByteSlice
11
- features: (Mention | Link | { $type: string; [k: string]: unknown })[]
11
+ features: (Mention | Link | Tag | { $type: string; [k: string]: unknown })[]
12
12
  [k: string]: unknown
13
13
  }
14
14
 
@@ -61,6 +61,22 @@ export function validateLink(v: unknown): ValidationResult {
61
61
  return lexicons.validate('app.bsky.richtext.facet#link', v)
62
62
  }
63
63
 
64
+ /** A hashtag. */
65
+ export interface Tag {
66
+ tag: string
67
+ [k: string]: unknown
68
+ }
69
+
70
+ export function isTag(v: unknown): v is Tag {
71
+ return (
72
+ isObj(v) && hasProp(v, '$type') && v.$type === 'app.bsky.richtext.facet#tag'
73
+ )
74
+ }
75
+
76
+ export function validateTag(v: unknown): ValidationResult {
77
+ return lexicons.validate('app.bsky.richtext.facet#tag', v)
78
+ }
79
+
64
80
  /** A text segment. Start is inclusive, end is exclusive. Indices are for utf8-encoded strings. */
65
81
  export interface ByteSlice {
66
82
  byteStart: number
@@ -194,8 +194,14 @@ export class ActorViews {
194
194
  mutedByList,
195
195
  blockedBy: !!bam.blockedBy([viewer, did]),
196
196
  blocking: bam.blocking([viewer, did]) ?? undefined,
197
- following: prof?.viewerFollowing || undefined,
198
- followedBy: prof?.viewerFollowedBy || undefined,
197
+ following:
198
+ prof?.viewerFollowing && !bam.block([viewer, did])
199
+ ? prof.viewerFollowing
200
+ : undefined,
201
+ followedBy:
202
+ prof?.viewerFollowedBy && !bam.block([viewer, did])
203
+ ? prof.viewerFollowedBy
204
+ : undefined,
199
205
  }
200
206
  : undefined,
201
207
  labels: [...actorLabels, ...selfLabels],
@@ -314,8 +320,14 @@ export class ActorViews {
314
320
  mutedByList,
315
321
  blockedBy: !!bam.blockedBy([viewer, did]),
316
322
  blocking: bam.blocking([viewer, did]) ?? undefined,
317
- following: prof?.viewerFollowing || undefined,
318
- followedBy: prof?.viewerFollowedBy || undefined,
323
+ following:
324
+ prof?.viewerFollowing && !bam.block([viewer, did])
325
+ ? prof.viewerFollowing
326
+ : undefined,
327
+ followedBy:
328
+ prof?.viewerFollowedBy && !bam.block([viewer, did])
329
+ ? prof.viewerFollowedBy
330
+ : undefined,
319
331
  }
320
332
  : undefined,
321
333
  labels: [...actorLabels, ...selfLabels],
@@ -147,6 +147,7 @@ export class FeedService {
147
147
  'post_agg.likeCount as likeCount',
148
148
  'post_agg.repostCount as repostCount',
149
149
  'post_agg.replyCount as replyCount',
150
+ 'post.tags as tags',
150
151
  db
151
152
  .selectFrom('repost')
152
153
  .if(!viewer, (q) => q.where(noMatch))
@@ -76,6 +76,9 @@ const insertFn = async (
76
76
  langs: obj.langs?.length
77
77
  ? sql<string[]>`${JSON.stringify(obj.langs)}` // sidesteps kysely's array serialization, which is non-jsonb
78
78
  : null,
79
+ tags: obj.tags?.length
80
+ ? sql<string[]>`${JSON.stringify(obj.tags)}` // sidesteps kysely's array serialization, which is non-jsonb
81
+ : null,
79
82
  indexedAt: timestamp,
80
83
  }
81
84
  const [insertedPost] = await Promise.all([
@@ -7,6 +7,7 @@ import { TestNetwork } from '@atproto/dev-env'
7
7
  import { ImageRef, SeedClient } from '../seeds/client'
8
8
  import usersSeed from '../seeds/users'
9
9
  import { CID } from 'multiformats/cid'
10
+ import { AtUri } from '@atproto/syntax'
10
11
  import { ImageFlagger } from '../../src/auto-moderator/abyss'
11
12
  import { ImageInvalidator } from '../../src/image/invalidator'
12
13
  import { sha256 } from '@atproto/crypto'
@@ -157,7 +158,7 @@ class TestInvalidator implements ImageInvalidator {
157
158
  }
158
159
 
159
160
  class TestFlagger implements ImageFlagger {
160
- async scanImage(_did: string, cid: CID): Promise<string[]> {
161
+ async scanImage(_did: string, cid: CID, _uri: AtUri): Promise<string[]> {
161
162
  if (cid.equals(badCid1)) {
162
163
  return ['kill-it']
163
164
  } else if (cid.equals(badCid2)) {
@@ -512,7 +512,6 @@ Object {
512
512
  "viewer": Object {
513
513
  "blockedBy": false,
514
514
  "blocking": "record(0)",
515
- "following": "record(4)",
516
515
  "muted": false,
517
516
  },
518
517
  },
@@ -191,17 +191,35 @@ describe('pds views with blocking', () => {
191
191
  { actor: dan },
192
192
  { headers: await network.serviceHeaders(carol) },
193
193
  )
194
- expect(resCarol.data.viewer?.blocking).toBeUndefined
194
+ expect(resCarol.data.viewer?.blocking).toBeUndefined()
195
195
  expect(resCarol.data.viewer?.blockedBy).toBe(true)
196
196
 
197
197
  const resDan = await agent.api.app.bsky.actor.getProfile(
198
198
  { actor: carol },
199
199
  { headers: await network.serviceHeaders(dan) },
200
200
  )
201
- expect(resDan.data.viewer?.blocking).toBeDefined
201
+ expect(resDan.data.viewer?.blocking).toBeDefined()
202
202
  expect(resDan.data.viewer?.blockedBy).toBe(false)
203
203
  })
204
204
 
205
+ it('unsets viewer follow state when blocked', async () => {
206
+ // there are follows between carol and dan
207
+ const { data: profile } = await agent.api.app.bsky.actor.getProfile(
208
+ { actor: carol },
209
+ { headers: await network.serviceHeaders(dan) },
210
+ )
211
+ expect(profile.viewer?.following).toBeUndefined()
212
+ expect(profile.viewer?.followedBy).toBeUndefined()
213
+ const { data: result } = await agent.api.app.bsky.graph.getBlocks(
214
+ {},
215
+ { headers: await network.serviceHeaders(dan) },
216
+ )
217
+ const blocked = result.blocks.find((block) => block.did === carol)
218
+ expect(blocked).toBeDefined()
219
+ expect(blocked?.viewer?.following).toBeUndefined()
220
+ expect(blocked?.viewer?.followedBy).toBeUndefined()
221
+ })
222
+
205
223
  it('returns block status on getProfiles', async () => {
206
224
  const resCarol = await agent.api.app.bsky.actor.getProfiles(
207
225
  { actors: [alice, dan] },
@@ -1,4 +1,4 @@
1
- import AtpAgent from '@atproto/api'
1
+ import AtpAgent, { AppBskyFeedPost } from '@atproto/api'
2
2
  import { TestNetwork } from '@atproto/dev-env'
3
3
  import { forSnapshot, stripViewerFromPost } from '../_util'
4
4
  import { SeedClient } from '../seeds/client'
@@ -7,6 +7,7 @@ import basicSeed from '../seeds/basic'
7
7
  describe('pds posts views', () => {
8
8
  let network: TestNetwork
9
9
  let agent: AtpAgent
10
+ let pdsAgent: AtpAgent
10
11
  let sc: SeedClient
11
12
 
12
13
  beforeAll(async () => {
@@ -14,7 +15,7 @@ describe('pds posts views', () => {
14
15
  dbPostgresSchema: 'bsky_views_posts',
15
16
  })
16
17
  agent = network.bsky.getClient()
17
- const pdsAgent = network.pds.getClient()
18
+ pdsAgent = network.pds.getClient()
18
19
  sc = new SeedClient(pdsAgent)
19
20
  await basicSeed(sc)
20
21
  await network.processAll()
@@ -83,4 +84,27 @@ describe('pds posts views', () => {
83
84
  ].sort()
84
85
  expect(receivedUris).toEqual(expected)
85
86
  })
87
+
88
+ it('allows for creating posts with tags', async () => {
89
+ const post: AppBskyFeedPost.Record = {
90
+ text: 'hello world',
91
+ tags: ['javascript', 'hehe'],
92
+ createdAt: new Date().toISOString(),
93
+ }
94
+
95
+ const { uri } = await pdsAgent.api.app.bsky.feed.post.create(
96
+ { repo: sc.dids.alice },
97
+ post,
98
+ sc.getHeaders(sc.dids.alice),
99
+ )
100
+
101
+ await network.processAll()
102
+ await network.bsky.processAll()
103
+
104
+ const { data } = await agent.api.app.bsky.feed.getPosts({ uris: [uri] })
105
+
106
+ expect(data.posts.length).toBe(1)
107
+ // @ts-ignore we know it's a post record
108
+ expect(data.posts[0].record.tags).toEqual(['javascript', 'hehe'])
109
+ })
86
110
  })
@@ -11,7 +11,7 @@ describe('suggested follows', () => {
11
11
 
12
12
  beforeAll(async () => {
13
13
  network = await TestNetwork.create({
14
- dbPostgresSchema: 'bsky_views_suggestions',
14
+ dbPostgresSchema: 'bsky_views_suggested_follows',
15
15
  })
16
16
  agent = network.bsky.getClient()
17
17
  pdsAgent = network.pds.getClient()