@atproto/ozone 0.1.176 → 0.2.0

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 (176) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/api/chat/getConvo.d.ts +4 -0
  3. package/dist/api/chat/getConvo.d.ts.map +1 -0
  4. package/dist/api/chat/getConvo.js +18 -0
  5. package/dist/api/chat/getConvo.js.map +1 -0
  6. package/dist/api/chat/getConvoMembers.d.ts +4 -0
  7. package/dist/api/chat/getConvoMembers.d.ts.map +1 -0
  8. package/dist/api/chat/getConvoMembers.js +18 -0
  9. package/dist/api/chat/getConvoMembers.js.map +1 -0
  10. package/dist/api/chat/index.d.ts.map +1 -1
  11. package/dist/api/chat/index.js +4 -0
  12. package/dist/api/chat/index.js.map +1 -1
  13. package/dist/daemon/event-reverser.d.ts.map +1 -1
  14. package/dist/daemon/event-reverser.js +1 -0
  15. package/dist/daemon/event-reverser.js.map +1 -1
  16. package/dist/db/migrations/20260513T202941104Z-add-subject-convo-id.d.ts +4 -0
  17. package/dist/db/migrations/20260513T202941104Z-add-subject-convo-id.d.ts.map +1 -0
  18. package/dist/db/migrations/20260513T202941104Z-add-subject-convo-id.js +107 -0
  19. package/dist/db/migrations/20260513T202941104Z-add-subject-convo-id.js.map +1 -0
  20. package/dist/db/migrations/index.d.ts +1 -0
  21. package/dist/db/migrations/index.d.ts.map +1 -1
  22. package/dist/db/migrations/index.js +1 -0
  23. package/dist/db/migrations/index.js.map +1 -1
  24. package/dist/db/schema/expiring_tag.d.ts +1 -0
  25. package/dist/db/schema/expiring_tag.d.ts.map +1 -1
  26. package/dist/db/schema/expiring_tag.js.map +1 -1
  27. package/dist/db/schema/moderation_event.d.ts +2 -1
  28. package/dist/db/schema/moderation_event.d.ts.map +1 -1
  29. package/dist/db/schema/moderation_event.js.map +1 -1
  30. package/dist/db/schema/moderation_subject_status.d.ts +1 -0
  31. package/dist/db/schema/moderation_subject_status.d.ts.map +1 -1
  32. package/dist/db/schema/moderation_subject_status.js.map +1 -1
  33. package/dist/db/schema/report.d.ts +1 -0
  34. package/dist/db/schema/report.d.ts.map +1 -1
  35. package/dist/db/schema/report.js.map +1 -1
  36. package/dist/lexicon/index.d.ts +19 -2
  37. package/dist/lexicon/index.d.ts.map +1 -1
  38. package/dist/lexicon/index.js +32 -2
  39. package/dist/lexicon/index.js.map +1 -1
  40. package/dist/lexicon/lexicons.d.ts +1364 -132
  41. package/dist/lexicon/lexicons.d.ts.map +1 -1
  42. package/dist/lexicon/lexicons.js +721 -44
  43. package/dist/lexicon/lexicons.js.map +1 -1
  44. package/dist/lexicon/types/app/bsky/actor/defs.d.ts +4 -0
  45. package/dist/lexicon/types/app/bsky/actor/defs.d.ts.map +1 -1
  46. package/dist/lexicon/types/app/bsky/actor/defs.js.map +1 -1
  47. package/dist/lexicon/types/app/bsky/embed/external.d.ts +48 -2
  48. package/dist/lexicon/types/app/bsky/embed/external.d.ts.map +1 -1
  49. package/dist/lexicon/types/app/bsky/embed/external.js +21 -0
  50. package/dist/lexicon/types/app/bsky/embed/external.js.map +1 -1
  51. package/dist/lexicon/types/app/bsky/embed/getEmbedExternalView.d.ts +31 -0
  52. package/dist/lexicon/types/app/bsky/embed/getEmbedExternalView.d.ts.map +1 -0
  53. package/dist/lexicon/types/app/bsky/embed/getEmbedExternalView.js +5 -0
  54. package/dist/lexicon/types/app/bsky/embed/getEmbedExternalView.js.map +1 -0
  55. package/dist/lexicon/types/chat/bsky/actor/getStatus.d.ts +24 -0
  56. package/dist/lexicon/types/chat/bsky/actor/getStatus.d.ts.map +1 -0
  57. package/dist/lexicon/types/chat/bsky/{group/getJoinLinkPreview.js → actor/getStatus.js} +2 -2
  58. package/dist/lexicon/types/chat/bsky/actor/getStatus.js.map +1 -0
  59. package/dist/lexicon/types/chat/bsky/convo/defs.d.ts +39 -7
  60. package/dist/lexicon/types/chat/bsky/convo/defs.d.ts.map +1 -1
  61. package/dist/lexicon/types/chat/bsky/convo/defs.js +21 -0
  62. package/dist/lexicon/types/chat/bsky/convo/defs.js.map +1 -1
  63. package/dist/lexicon/types/chat/bsky/convo/getConvoForMembers.d.ts +1 -1
  64. package/dist/lexicon/types/chat/bsky/convo/getConvoForMembers.d.ts.map +1 -1
  65. package/dist/lexicon/types/chat/bsky/convo/getConvoForMembers.js.map +1 -1
  66. package/dist/lexicon/types/chat/bsky/convo/getLog.d.ts +1 -1
  67. package/dist/lexicon/types/chat/bsky/convo/getLog.d.ts.map +1 -1
  68. package/dist/lexicon/types/chat/bsky/convo/getLog.js.map +1 -1
  69. package/dist/lexicon/types/chat/bsky/convo/listConvoRequests.d.ts +1 -1
  70. package/dist/lexicon/types/chat/bsky/convo/listConvoRequests.d.ts.map +1 -1
  71. package/dist/lexicon/types/chat/bsky/convo/listConvoRequests.js.map +1 -1
  72. package/dist/lexicon/types/chat/bsky/embed/joinLink.d.ts +19 -0
  73. package/dist/lexicon/types/chat/bsky/embed/joinLink.d.ts.map +1 -0
  74. package/dist/lexicon/types/chat/bsky/embed/joinLink.js +19 -0
  75. package/dist/lexicon/types/chat/bsky/embed/joinLink.js.map +1 -0
  76. package/dist/lexicon/types/chat/bsky/group/addMembers.d.ts +1 -1
  77. package/dist/lexicon/types/chat/bsky/group/addMembers.d.ts.map +1 -1
  78. package/dist/lexicon/types/chat/bsky/group/addMembers.js.map +1 -1
  79. package/dist/lexicon/types/chat/bsky/group/createGroup.d.ts +1 -1
  80. package/dist/lexicon/types/chat/bsky/group/createGroup.d.ts.map +1 -1
  81. package/dist/lexicon/types/chat/bsky/group/createGroup.js.map +1 -1
  82. package/dist/lexicon/types/chat/bsky/group/defs.d.ts +23 -0
  83. package/dist/lexicon/types/chat/bsky/group/defs.d.ts.map +1 -1
  84. package/dist/lexicon/types/chat/bsky/group/defs.js +14 -0
  85. package/dist/lexicon/types/chat/bsky/group/defs.js.map +1 -1
  86. package/dist/lexicon/types/chat/bsky/group/{getJoinLinkPreview.d.ts → getJoinLinkPreviews.d.ts} +3 -4
  87. package/dist/lexicon/types/chat/bsky/group/getJoinLinkPreviews.d.ts.map +1 -0
  88. package/dist/lexicon/types/chat/bsky/group/getJoinLinkPreviews.js +5 -0
  89. package/dist/lexicon/types/chat/bsky/group/getJoinLinkPreviews.js.map +1 -0
  90. package/dist/lexicon/types/chat/bsky/group/updateJoinRequestsRead.d.ts +24 -0
  91. package/dist/lexicon/types/chat/bsky/group/updateJoinRequestsRead.d.ts.map +1 -0
  92. package/dist/lexicon/types/chat/bsky/group/updateJoinRequestsRead.js +5 -0
  93. package/dist/lexicon/types/chat/bsky/group/updateJoinRequestsRead.js.map +1 -0
  94. package/dist/lexicon/types/chat/bsky/group/withdrawJoinRequest.d.ts +24 -0
  95. package/dist/lexicon/types/chat/bsky/group/withdrawJoinRequest.d.ts.map +1 -0
  96. package/dist/lexicon/types/chat/bsky/group/withdrawJoinRequest.js +5 -0
  97. package/dist/lexicon/types/chat/bsky/group/withdrawJoinRequest.js.map +1 -0
  98. package/dist/lexicon/types/chat/bsky/moderation/defs.d.ts +42 -0
  99. package/dist/lexicon/types/chat/bsky/moderation/defs.d.ts.map +1 -0
  100. package/dist/lexicon/types/chat/bsky/moderation/defs.js +26 -0
  101. package/dist/lexicon/types/chat/bsky/moderation/defs.js.map +1 -0
  102. package/dist/lexicon/types/chat/bsky/moderation/getConvo.d.ts +23 -0
  103. package/dist/lexicon/types/chat/bsky/moderation/getConvo.d.ts.map +1 -0
  104. package/dist/lexicon/types/chat/bsky/moderation/getConvo.js +5 -0
  105. package/dist/lexicon/types/chat/bsky/moderation/getConvo.js.map +1 -0
  106. package/dist/lexicon/types/chat/bsky/moderation/getConvoMembers.d.ts +26 -0
  107. package/dist/lexicon/types/chat/bsky/moderation/getConvoMembers.d.ts.map +1 -0
  108. package/dist/lexicon/types/chat/bsky/moderation/getConvoMembers.js +5 -0
  109. package/dist/lexicon/types/chat/bsky/moderation/getConvoMembers.js.map +1 -0
  110. package/dist/lexicon/types/chat/bsky/moderation/subscribeModEvents.d.ts +13 -1
  111. package/dist/lexicon/types/chat/bsky/moderation/subscribeModEvents.d.ts.map +1 -1
  112. package/dist/lexicon/types/chat/bsky/moderation/subscribeModEvents.js +7 -0
  113. package/dist/lexicon/types/chat/bsky/moderation/subscribeModEvents.js.map +1 -1
  114. package/dist/lexicon/types/com/atproto/server/getServiceAuth.d.ts +1 -1
  115. package/dist/lexicon/types/com/atproto/server/getServiceAuth.d.ts.map +1 -1
  116. package/dist/lexicon/types/com/atproto/server/getServiceAuth.js.map +1 -1
  117. package/dist/lexicon/types/tools/ozone/moderation/defs.d.ts +10 -3
  118. package/dist/lexicon/types/tools/ozone/moderation/defs.d.ts.map +1 -1
  119. package/dist/lexicon/types/tools/ozone/moderation/defs.js +7 -0
  120. package/dist/lexicon/types/tools/ozone/moderation/defs.js.map +1 -1
  121. package/dist/lexicon/types/tools/ozone/moderation/queryEvents.d.ts +2 -2
  122. package/dist/lexicon/types/tools/ozone/moderation/queryEvents.d.ts.map +1 -1
  123. package/dist/lexicon/types/tools/ozone/moderation/queryEvents.js.map +1 -1
  124. package/dist/lexicon/types/tools/ozone/moderation/queryStatuses.d.ts +2 -2
  125. package/dist/lexicon/types/tools/ozone/moderation/queryStatuses.d.ts.map +1 -1
  126. package/dist/lexicon/types/tools/ozone/moderation/queryStatuses.js.map +1 -1
  127. package/dist/mod-service/expiring-tags.d.ts +3 -0
  128. package/dist/mod-service/expiring-tags.d.ts.map +1 -1
  129. package/dist/mod-service/expiring-tags.js +5 -2
  130. package/dist/mod-service/expiring-tags.js.map +1 -1
  131. package/dist/mod-service/index.d.ts +3 -1
  132. package/dist/mod-service/index.d.ts.map +1 -1
  133. package/dist/mod-service/index.js +48 -10
  134. package/dist/mod-service/index.js.map +1 -1
  135. package/dist/mod-service/status.d.ts +7 -1
  136. package/dist/mod-service/status.d.ts.map +1 -1
  137. package/dist/mod-service/status.js +18 -3
  138. package/dist/mod-service/status.js.map +1 -1
  139. package/dist/mod-service/subject.d.ts +38 -2
  140. package/dist/mod-service/subject.d.ts.map +1 -1
  141. package/dist/mod-service/subject.js +67 -0
  142. package/dist/mod-service/subject.js.map +1 -1
  143. package/dist/mod-service/views.d.ts +2 -1
  144. package/dist/mod-service/views.d.ts.map +1 -1
  145. package/dist/mod-service/views.js +24 -22
  146. package/dist/mod-service/views.js.map +1 -1
  147. package/dist/queue/service.d.ts.map +1 -1
  148. package/dist/queue/service.js +7 -3
  149. package/dist/queue/service.js.map +1 -1
  150. package/dist/safelink/service.d.ts +1 -1
  151. package/package.json +5 -5
  152. package/src/api/chat/getConvo.ts +23 -0
  153. package/src/api/chat/getConvoMembers.ts +23 -0
  154. package/src/api/chat/index.ts +4 -0
  155. package/src/daemon/event-reverser.ts +1 -0
  156. package/src/db/migrations/20260513T202941104Z-add-subject-convo-id.ts +114 -0
  157. package/src/db/migrations/index.ts +1 -0
  158. package/src/db/schema/expiring_tag.ts +1 -0
  159. package/src/db/schema/moderation_event.ts +2 -0
  160. package/src/db/schema/moderation_subject_status.ts +4 -0
  161. package/src/db/schema/report.ts +2 -1
  162. package/src/mod-service/expiring-tags.ts +8 -2
  163. package/src/mod-service/index.ts +52 -16
  164. package/src/mod-service/status.ts +24 -2
  165. package/src/mod-service/subject.ts +79 -1
  166. package/src/mod-service/views.ts +28 -23
  167. package/src/queue/service.ts +8 -4
  168. package/tests/__snapshots__/verification.test.ts.snap +2 -0
  169. package/tests/expiring-tags.test.ts +1 -0
  170. package/tests/moderation-events.test.ts +108 -1
  171. package/tests/moderation-status-tags.test.ts +23 -0
  172. package/tests/moderation-statuses.test.ts +82 -0
  173. package/tests/moderation.test.ts +73 -0
  174. package/tsconfig.build.tsbuildinfo +1 -1
  175. package/dist/lexicon/types/chat/bsky/group/getJoinLinkPreview.d.ts.map +0 -1
  176. package/dist/lexicon/types/chat/bsky/group/getJoinLinkPreview.js.map +0 -1
@@ -51,7 +51,11 @@ import { Un$Typed, asPredicate } from '../lexicon/util.js'
51
51
  import { dbLogger, httpLogger } from '../logger.js'
52
52
  import { ParsedLabelers } from '../util.js'
53
53
  import { moderationSubjectStatusQueryBuilder } from './status.js'
54
- import { subjectFromEventRow, subjectFromStatusRow } from './subject.js'
54
+ import {
55
+ ModSubject,
56
+ subjectFromEventRow,
57
+ subjectFromStatusRow,
58
+ } from './subject.js'
55
59
  import {
56
60
  ModerationEventRowWithHandle,
57
61
  ModerationSubjectStatusRowWithHandle,
@@ -297,14 +301,8 @@ export class ModerationViews {
297
301
  async eventDetail(
298
302
  result: ModerationEventRowWithHandle,
299
303
  ): Promise<ModEventViewDetail> {
300
- const subjectId =
301
- result.subjectType === 'com.atproto.admin.defs#repoRef'
302
- ? result.subjectDid
303
- : result.subjectUri
304
- if (!subjectId) {
305
- throw new Error(`Bad subject: ${result.id}`)
306
- }
307
- const subject = await this.subject(subjectId)
304
+ const modSubject = subjectFromEventRow(result)
305
+ const subject = await this.subject(modSubject)
308
306
  const eventView = this.formatEvent(result)
309
307
  const allBlobs = 'value' in subject ? findBlobRefs(subject.value) : []
310
308
  const subjectBlobs = await this.blob(
@@ -555,10 +553,16 @@ export class ModerationViews {
555
553
  }
556
554
  // Partial view for subjects
557
555
 
558
- async subject(subject: string): Promise<SubjectView> {
559
- if (subject.startsWith('did:')) {
560
- const repos = await this.repos([subject])
561
- const repo = repos.get(subject)
556
+ async subject(subject: ModSubject): Promise<SubjectView> {
557
+ if (subject.isConvo()) {
558
+ return {
559
+ $type: 'tools.ozone.moderation.defs#convoView',
560
+ did: subject.did,
561
+ convoId: subject.convoId,
562
+ }
563
+ } else if (subject.isRepo()) {
564
+ const repos = await this.repos([subject.did])
565
+ const repo = repos.get(subject.did)
562
566
  if (repo) {
563
567
  return {
564
568
  ...repo,
@@ -567,24 +571,24 @@ export class ModerationViews {
567
571
  } else {
568
572
  return {
569
573
  $type: 'tools.ozone.moderation.defs#repoViewNotFound',
570
- did: subject,
574
+ did: subject.did,
571
575
  }
572
576
  }
573
- } else {
574
- const records = await this.records([{ uri: subject }])
575
- const record = records.get(subject)
577
+ } else if (subject.isRecord()) {
578
+ const uri = subject.uri
579
+ const records = await this.records([{ uri }])
580
+ const record = records.get(uri)
576
581
  if (record) {
577
582
  return {
578
583
  ...record,
579
584
  $type: 'tools.ozone.moderation.defs#recordView',
580
585
  }
581
- } else {
582
- return {
583
- $type: 'tools.ozone.moderation.defs#recordViewNotFound',
584
- uri: subject,
585
- }
586
586
  }
587
587
  }
588
+ return {
589
+ $type: 'tools.ozone.moderation.defs#repoViewNotFound',
590
+ did: subject.did,
591
+ }
588
592
  }
589
593
 
590
594
  // Partial view for blobs
@@ -691,7 +695,8 @@ export class ModerationViews {
691
695
  'moderation_subject_status.recordPath',
692
696
  '=',
693
697
  sub.recordPath ?? '',
694
- ),
698
+ )
699
+ .where('moderation_subject_status.convoId', '=', ''),
695
700
  )
696
701
  }
697
702
  return qb
@@ -13,7 +13,7 @@ import { viewQueueStats } from '../report/views.js'
13
13
  const MOD_EVENT_REPORT_ACTION = 'tools.ozone.moderation.defs#modEventReport'
14
14
  const REASON_OTHER = 'com.atproto.moderation.defs#reasonOther'
15
15
 
16
- type SubjectType = 'account' | 'record' | 'message'
16
+ type SubjectType = 'account' | 'record' | 'message' | 'conversation'
17
17
 
18
18
  type ResolvedAssignment = {
19
19
  queueId: number
@@ -478,6 +478,7 @@ export class QueueService {
478
478
  'subjectDid',
479
479
  'subjectUri',
480
480
  'subjectMessageId',
481
+ 'subjectConvoId',
481
482
  'meta',
482
483
  'createdAt',
483
484
  ])
@@ -503,9 +504,11 @@ export class QueueService {
503
504
  const rows = events.map((event) => {
504
505
  const subjectType: SubjectType = event.subjectMessageId
505
506
  ? 'message'
506
- : event.subjectUri
507
- ? 'record'
508
- : 'account'
507
+ : event.subjectConvoId
508
+ ? 'conversation'
509
+ : event.subjectUri
510
+ ? 'record'
511
+ : 'account'
509
512
 
510
513
  let collection: string | null = null
511
514
  let recordPath = ''
@@ -545,6 +548,7 @@ export class QueueService {
545
548
  did: event.subjectDid,
546
549
  recordPath,
547
550
  subjectMessageId: event.subjectMessageId,
551
+ subjectConvoId: event.subjectConvoId,
548
552
  createdAt: now,
549
553
  updatedAt: now,
550
554
  }
@@ -122,6 +122,8 @@ exports[`verification list returns paginated list of verifications 1`] = `
122
122
  "createdAt": "1970-01-01T00:00:00.000Z",
123
123
  "isValid": true,
124
124
  "issuer": "user(0)",
125
+ "issuerDisplayName": "ali",
126
+ "issuerHandle": "alice.test",
125
127
  "uri": "record(0)",
126
128
  },
127
129
  ],
@@ -192,6 +192,7 @@ describe('expiring tags', () => {
192
192
  eventId: 0,
193
193
  did: sc.dids.carol,
194
194
  recordPath: '',
195
+ convoId: '',
195
196
  tag: 'skip-tag',
196
197
  expiresAt: new Date(Date.now() - 1000).toISOString(),
197
198
  createdBy: sc.dids.alice,
@@ -207,8 +207,25 @@ describe('moderation-events', () => {
207
207
  }),
208
208
  ])
209
209
 
210
- expect(misleadingEvents.events.length).toEqual(2)
210
+ // Verify all spam events have the correct report type
211
211
  expect(spamEvents.events.length).toEqual(6)
212
+ spamEvents.events.forEach((event) => {
213
+ expect(event.event.$type).toEqual(
214
+ 'tools.ozone.moderation.defs#modEventReport',
215
+ )
216
+ expect((event.event as any).reportType).toEqual(REASONSPAM)
217
+ })
218
+
219
+ // Verify all misleading events have one of the correct report types
220
+ expect(misleadingEvents.events.length).toEqual(2)
221
+ misleadingEvents.events.forEach((event) => {
222
+ expect(event.event.$type).toEqual(
223
+ 'tools.ozone.moderation.defs#modEventReport',
224
+ )
225
+ expect([REASONMISLEADING, REASONAPPEAL]).toContain(
226
+ (event.event as any).reportType,
227
+ )
228
+ })
212
229
  })
213
230
 
214
231
  it('returns events matching keyword in comment', async () => {
@@ -501,6 +518,96 @@ describe('moderation-events', () => {
501
518
  expect(event.createdBy).toEqual(network.ozone.moderatorAccnt.did)
502
519
  })
503
520
  })
521
+
522
+ it('queries events by conversation', async () => {
523
+ const convoId1 = 'conversation-123'
524
+ const convoId2 = 'conversation-456'
525
+
526
+ // create reports
527
+ await sc.createReport({
528
+ reasonType: REASONSPAM,
529
+ reason: 'spam in convo 1',
530
+ subject: {
531
+ $type: 'chat.bsky.convo.defs#convoRef',
532
+ did: sc.dids.carol,
533
+ convoId: convoId1,
534
+ },
535
+ reportedBy: sc.dids.alice,
536
+ })
537
+ await sc.createReport({
538
+ reasonType: REASONMISLEADING,
539
+ reason: 'misleading in convo 1',
540
+ subject: {
541
+ $type: 'chat.bsky.convo.defs#convoRef',
542
+ did: sc.dids.carol,
543
+ convoId: convoId1,
544
+ },
545
+ reportedBy: sc.dids.bob,
546
+ })
547
+ await sc.createReport({
548
+ reasonType: REASONSPAM,
549
+ reason: 'spam in convo 2',
550
+ subject: {
551
+ $type: 'chat.bsky.convo.defs#convoRef',
552
+ did: sc.dids.carol,
553
+ convoId: convoId2,
554
+ },
555
+ reportedBy: sc.dids.alice,
556
+ })
557
+
558
+ // Query events (filter by report type since auto-tagging creates extra events)
559
+ const convo1Events = await modClient.queryEvents({
560
+ subject: `at://${sc.dids.carol}/chat.bsky.convo/${convoId1}`,
561
+ includeAllUserRecords: false,
562
+ types: ['tools.ozone.moderation.defs#modEventReport'],
563
+ })
564
+ const convo2Events = await modClient.queryEvents({
565
+ subject: `at://${sc.dids.carol}/chat.bsky.convo/${convoId2}`,
566
+ includeAllUserRecords: false,
567
+ types: ['tools.ozone.moderation.defs#modEventReport'],
568
+ })
569
+
570
+ // Verify conversation 1 events are correct
571
+ expect(convo1Events.events.length).toBeGreaterThan(0)
572
+ convo1Events.events.forEach((e) => {
573
+ // All events should be conversation refs
574
+ expect(e.subject.$type).toEqual('chat.bsky.convo.defs#convoRef')
575
+ // All events should be for conversation 1
576
+ const subject = e.subject as any
577
+ expect(subject.convoId).toEqual(convoId1)
578
+ expect(subject.did).toEqual(sc.dids.carol)
579
+ // All events should be reports
580
+ expect(e.event.$type).toEqual(
581
+ 'tools.ozone.moderation.defs#modEventReport',
582
+ )
583
+ })
584
+ // Verify we got both reports we created
585
+ const convo1Comments = convo1Events.events.map(
586
+ (e) => (e.event as any).comment,
587
+ )
588
+ expect(convo1Comments).toContain('spam in convo 1')
589
+ expect(convo1Comments).toContain('misleading in convo 1')
590
+
591
+ // Verify conversation 2 events are correct
592
+ expect(convo2Events.events.length).toBeGreaterThan(0)
593
+ convo2Events.events.forEach((e) => {
594
+ // All events should be conversation refs
595
+ expect(e.subject.$type).toEqual('chat.bsky.convo.defs#convoRef')
596
+ // All events should be for conversation 2
597
+ const subject = e.subject as any
598
+ expect(subject.convoId).toEqual(convoId2)
599
+ expect(subject.did).toEqual(sc.dids.carol)
600
+ // All events should be reports
601
+ expect(e.event.$type).toEqual(
602
+ 'tools.ozone.moderation.defs#modEventReport',
603
+ )
604
+ })
605
+ // Verify we got the report we created
606
+ const convo2Comments = convo2Events.events.map(
607
+ (e) => (e.event as any).comment,
608
+ )
609
+ expect(convo2Comments).toContain('spam in convo 2')
610
+ })
504
611
  })
505
612
 
506
613
  describe('get event', () => {
@@ -113,5 +113,28 @@ describe('moderation-status-tags', () => {
113
113
  expect(englishOrJapaneseDids).toContain(sc.dids.alice)
114
114
  expect(englishOrJapaneseDids).toContain(sc.dids.bob)
115
115
  })
116
+
117
+ it('adds tag to conversation', async () => {
118
+ const subject = {
119
+ $type: 'chat.bsky.convo.defs#convoRef',
120
+ did: sc.dids.alice,
121
+ convoId: '123',
122
+ }
123
+ await modClient.emitEvent({
124
+ subject: subject,
125
+ event: {
126
+ $type: 'tools.ozone.moderation.defs#modEventTag',
127
+ add: ['interaction-churn'],
128
+ remove: [],
129
+ },
130
+ })
131
+ const status = await network.ozone.ctx.db.db
132
+ .selectFrom('moderation_subject_status')
133
+ .selectAll()
134
+ .where('did', '=', subject.did)
135
+ .where('convoId', '=', subject.convoId)
136
+ .executeTakeFirstOrThrow()
137
+ expect(status.tags).toContain('interaction-churn')
138
+ })
116
139
  })
117
140
  })
@@ -276,6 +276,88 @@ describe('moderation-statuses', () => {
276
276
  "only bob's account statuses are returned, no events have a URI even though the subjectType is record",
277
277
  )
278
278
  })
279
+
280
+ it('returns statuses for conversations', async () => {
281
+ const convoId1 = 'test-convo-123'
282
+ const convoId2 = 'test-convo-456'
283
+
284
+ // Create reports for conversation 1
285
+ await sc.createReport({
286
+ reasonType: REASONSPAM,
287
+ reason: 'spam in convo 1',
288
+ subject: {
289
+ $type: 'chat.bsky.convo.defs#convoRef',
290
+ did: sc.dids.carol,
291
+ convoId: convoId1,
292
+ },
293
+ reportedBy: sc.dids.alice,
294
+ })
295
+
296
+ // Create another report for conversation 1
297
+ await sc.createReport({
298
+ reasonType: REASONMISLEADING,
299
+ reason: 'misleading in convo 1',
300
+ subject: {
301
+ $type: 'chat.bsky.convo.defs#convoRef',
302
+ did: sc.dids.carol,
303
+ convoId: convoId1,
304
+ },
305
+ reportedBy: sc.dids.bob,
306
+ })
307
+
308
+ // Create report for conversation 2
309
+ await sc.createReport({
310
+ reasonType: REASONSPAM,
311
+ reason: 'spam in convo 2',
312
+ subject: {
313
+ $type: 'chat.bsky.convo.defs#convoRef',
314
+ did: sc.dids.carol,
315
+ convoId: convoId2,
316
+ },
317
+ reportedBy: sc.dids.alice,
318
+ })
319
+
320
+ // Query statuses for conversation 1 using AT URI format
321
+ const convo1Statuses = await modClient.queryStatuses({
322
+ subject: `at://${sc.dids.carol}/chat.bsky.convo/${convoId1}`,
323
+ })
324
+
325
+ // Query statuses for conversation 2
326
+ const convo2Statuses = await modClient.queryStatuses({
327
+ subject: `at://${sc.dids.carol}/chat.bsky.convo/${convoId2}`,
328
+ })
329
+
330
+ // Query all conversation statuses for carol
331
+ const allCarolConvoStatuses = await modClient.queryStatuses({
332
+ subject: sc.dids.carol,
333
+ includeAllUserRecords: true,
334
+ })
335
+
336
+ // Verify conversation 1 has exactly 1 status (multiple reports create one status)
337
+ expect(convo1Statuses.subjectStatuses.length).toEqual(1)
338
+ expect(convo1Statuses.subjectStatuses[0].subject.$type).toEqual(
339
+ 'chat.bsky.convo.defs#convoRef',
340
+ )
341
+ expect(convo1Statuses.subjectStatuses[0].reviewState).toEqual(REVIEWOPEN)
342
+
343
+ // Verify conversation 2 has exactly 1 status
344
+ expect(convo2Statuses.subjectStatuses.length).toEqual(1)
345
+ expect(convo2Statuses.subjectStatuses[0].subject.$type).toEqual(
346
+ 'chat.bsky.convo.defs#convoRef',
347
+ )
348
+
349
+ // Verify statuses are properly isolated by conversation
350
+ const convo1Subject = convo1Statuses.subjectStatuses[0].subject as any
351
+ const convo2Subject = convo2Statuses.subjectStatuses[0].subject as any
352
+ expect(convo1Subject.convoId).toEqual(convoId1)
353
+ expect(convo2Subject.convoId).toEqual(convoId2)
354
+
355
+ // Verify includeAllUserRecords includes conversations
356
+ const convoStatuses = allCarolConvoStatuses.subjectStatuses.filter(
357
+ (s) => s.subject.$type === 'chat.bsky.convo.defs#convoRef',
358
+ )
359
+ expect(convoStatuses.length).toBeGreaterThanOrEqual(2)
360
+ })
279
361
  })
280
362
 
281
363
  describe('reviewState changes', () => {
@@ -197,6 +197,53 @@ describe('moderation', () => {
197
197
  ),
198
198
  ).toBe(true)
199
199
  })
200
+
201
+ it('creates reports of convo', async () => {
202
+ const convoId1 = 'convoId1'
203
+ const convoId2 = 'convoId2'
204
+ const reportA = await sc.createReport({
205
+ reportedBy: sc.dids.alice,
206
+ reasonType: REASONSPAM,
207
+ subject: {
208
+ $type: 'chat.bsky.convo.defs#convoRef',
209
+ did: sc.dids.carol,
210
+ convoId: convoId1,
211
+ },
212
+ })
213
+ const reportB = await sc.createReport({
214
+ reportedBy: sc.dids.carol,
215
+ reasonType: REASONOTHER,
216
+ reason: 'defamation',
217
+ subject: {
218
+ $type: 'chat.bsky.convo.defs#convoRef',
219
+ did: sc.dids.carol,
220
+ convoId: convoId2,
221
+ },
222
+ })
223
+
224
+ // Verify reportA
225
+ expect(reportA.subject.$type).toBe('chat.bsky.convo.defs#convoRef')
226
+ expect(ChatBskyConvoDefs.isConvoRef(reportA.subject)).toBe(true)
227
+ if (ChatBskyConvoDefs.isConvoRef(reportA.subject)) {
228
+ expect(reportA.subject.convoId).toBe(convoId1)
229
+ expect(reportA.subject.did).toBe(sc.dids.carol)
230
+ }
231
+ expect(reportA.reasonType).toBe(REASONSPAM)
232
+ expect(reportA.reportedBy).toBe(sc.dids.alice)
233
+ expect(reportA.id).toBeGreaterThan(0)
234
+
235
+ // Verify reportB
236
+ expect(reportB.subject.$type).toBe('chat.bsky.convo.defs#convoRef')
237
+ expect(ChatBskyConvoDefs.isConvoRef(reportB.subject)).toBe(true)
238
+ if (ChatBskyConvoDefs.isConvoRef(reportB.subject)) {
239
+ expect(reportB.subject.convoId).toBe(convoId2)
240
+ expect(reportB.subject.did).toBe(sc.dids.carol)
241
+ }
242
+ expect(reportB.reasonType).toBe(REASONOTHER)
243
+ expect(reportB.reason).toBe('defamation')
244
+ expect(reportB.reportedBy).toBe(sc.dids.carol)
245
+ expect(reportB.id).toBeGreaterThan(reportA.id)
246
+ })
200
247
  })
201
248
 
202
249
  describe('actioning', () => {
@@ -729,6 +776,32 @@ describe('moderation', () => {
729
776
  })
730
777
  })
731
778
 
779
+ it('allows conversation escalate', async () => {
780
+ const subject = {
781
+ $type: 'chat.bsky.convo.defs#convoRef',
782
+ did: sc.dids.bob,
783
+ convoId: '123',
784
+ }
785
+ await modClient.emitEvent({
786
+ event: {
787
+ $type: 'tools.ozone.moderation.defs#modEventEscalate',
788
+ comment: 'Y',
789
+ },
790
+ subject,
791
+ createdBy: 'did:example:admin',
792
+ })
793
+
794
+ const status = await network.ozone.ctx.db.db
795
+ .selectFrom('moderation_subject_status')
796
+ .selectAll()
797
+ .where('did', '=', subject.did)
798
+ .where('recordPath', '=', '')
799
+ .where('convoId', '=', subject.convoId)
800
+ .executeTakeFirst()
801
+
802
+ expect(status?.reviewState).toEqual(REVIEWESCALATED)
803
+ })
804
+
732
805
  async function emitLabelEvent(
733
806
  opts: Partial<ToolsOzoneModerationEmitEvent.InputSchema> & {
734
807
  subject: ToolsOzoneModerationEmitEvent.InputSchema['subject']