@atproto/ozone 0.1.176 → 0.2.1

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 (180) hide show
  1. package/CHANGELOG.md +22 -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 +21 -2
  37. package/dist/lexicon/index.d.ts.map +1 -1
  38. package/dist/lexicon/index.js +36 -2
  39. package/dist/lexicon/index.js.map +1 -1
  40. package/dist/lexicon/lexicons.d.ts +1617 -305
  41. package/dist/lexicon/lexicons.d.ts.map +1 -1
  42. package/dist/lexicon/lexicons.js +762 -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/getConvos.d.ts +22 -0
  111. package/dist/lexicon/types/chat/bsky/moderation/getConvos.d.ts.map +1 -0
  112. package/dist/lexicon/types/chat/bsky/moderation/getConvos.js +5 -0
  113. package/dist/lexicon/types/chat/bsky/moderation/getConvos.js.map +1 -0
  114. package/dist/lexicon/types/chat/bsky/moderation/subscribeModEvents.d.ts +13 -1
  115. package/dist/lexicon/types/chat/bsky/moderation/subscribeModEvents.d.ts.map +1 -1
  116. package/dist/lexicon/types/chat/bsky/moderation/subscribeModEvents.js +7 -0
  117. package/dist/lexicon/types/chat/bsky/moderation/subscribeModEvents.js.map +1 -1
  118. package/dist/lexicon/types/com/atproto/server/getServiceAuth.d.ts +1 -1
  119. package/dist/lexicon/types/com/atproto/server/getServiceAuth.d.ts.map +1 -1
  120. package/dist/lexicon/types/com/atproto/server/getServiceAuth.js.map +1 -1
  121. package/dist/lexicon/types/tools/ozone/moderation/defs.d.ts +10 -3
  122. package/dist/lexicon/types/tools/ozone/moderation/defs.d.ts.map +1 -1
  123. package/dist/lexicon/types/tools/ozone/moderation/defs.js +7 -0
  124. package/dist/lexicon/types/tools/ozone/moderation/defs.js.map +1 -1
  125. package/dist/lexicon/types/tools/ozone/moderation/queryEvents.d.ts +2 -2
  126. package/dist/lexicon/types/tools/ozone/moderation/queryEvents.d.ts.map +1 -1
  127. package/dist/lexicon/types/tools/ozone/moderation/queryEvents.js.map +1 -1
  128. package/dist/lexicon/types/tools/ozone/moderation/queryStatuses.d.ts +2 -2
  129. package/dist/lexicon/types/tools/ozone/moderation/queryStatuses.d.ts.map +1 -1
  130. package/dist/lexicon/types/tools/ozone/moderation/queryStatuses.js.map +1 -1
  131. package/dist/mod-service/expiring-tags.d.ts +3 -0
  132. package/dist/mod-service/expiring-tags.d.ts.map +1 -1
  133. package/dist/mod-service/expiring-tags.js +5 -2
  134. package/dist/mod-service/expiring-tags.js.map +1 -1
  135. package/dist/mod-service/index.d.ts +3 -1
  136. package/dist/mod-service/index.d.ts.map +1 -1
  137. package/dist/mod-service/index.js +48 -10
  138. package/dist/mod-service/index.js.map +1 -1
  139. package/dist/mod-service/status.d.ts +7 -1
  140. package/dist/mod-service/status.d.ts.map +1 -1
  141. package/dist/mod-service/status.js +18 -3
  142. package/dist/mod-service/status.js.map +1 -1
  143. package/dist/mod-service/subject.d.ts +38 -2
  144. package/dist/mod-service/subject.d.ts.map +1 -1
  145. package/dist/mod-service/subject.js +67 -0
  146. package/dist/mod-service/subject.js.map +1 -1
  147. package/dist/mod-service/views.d.ts +2 -1
  148. package/dist/mod-service/views.d.ts.map +1 -1
  149. package/dist/mod-service/views.js +45 -23
  150. package/dist/mod-service/views.js.map +1 -1
  151. package/dist/queue/service.d.ts.map +1 -1
  152. package/dist/queue/service.js +7 -3
  153. package/dist/queue/service.js.map +1 -1
  154. package/dist/safelink/service.d.ts +1 -1
  155. package/package.json +5 -5
  156. package/src/api/chat/getConvo.ts +23 -0
  157. package/src/api/chat/getConvoMembers.ts +23 -0
  158. package/src/api/chat/index.ts +4 -0
  159. package/src/daemon/event-reverser.ts +1 -0
  160. package/src/db/migrations/20260513T202941104Z-add-subject-convo-id.ts +114 -0
  161. package/src/db/migrations/index.ts +1 -0
  162. package/src/db/schema/expiring_tag.ts +1 -0
  163. package/src/db/schema/moderation_event.ts +2 -0
  164. package/src/db/schema/moderation_subject_status.ts +4 -0
  165. package/src/db/schema/report.ts +2 -1
  166. package/src/mod-service/expiring-tags.ts +8 -2
  167. package/src/mod-service/index.ts +52 -16
  168. package/src/mod-service/status.ts +24 -2
  169. package/src/mod-service/subject.ts +79 -1
  170. package/src/mod-service/views.ts +52 -24
  171. package/src/queue/service.ts +8 -4
  172. package/tests/__snapshots__/verification.test.ts.snap +2 -0
  173. package/tests/expiring-tags.test.ts +1 -0
  174. package/tests/moderation-events.test.ts +108 -1
  175. package/tests/moderation-status-tags.test.ts +23 -0
  176. package/tests/moderation-statuses.test.ts +82 -0
  177. package/tests/moderation.test.ts +73 -0
  178. package/tsconfig.build.tsbuildinfo +1 -1
  179. package/dist/lexicon/types/chat/bsky/group/getJoinLinkPreview.d.ts.map +0 -1
  180. package/dist/lexicon/types/chat/bsky/group/getJoinLinkPreview.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atproto/ozone",
3
- "version": "0.1.176",
3
+ "version": "0.2.1",
4
4
  "license": "MIT",
5
5
  "description": "Backend service for moderating the Bluesky network.",
6
6
  "keywords": [
@@ -33,15 +33,15 @@
33
33
  "uint8arrays": "^5.0.0",
34
34
  "undici": "^6.14.1",
35
35
  "ws": "^8.12.0",
36
- "@atproto/api": "^0.20.1",
37
36
  "@atproto/common": "^0.6.1",
38
37
  "@atproto/crypto": "^0.5.0",
39
38
  "@atproto/identity": "^0.5.0",
40
39
  "@atproto/lexicon": "^0.7.1",
40
+ "@atproto/api": "^0.20.8",
41
41
  "@atproto/syntax": "^0.6.1",
42
42
  "@atproto/ws-client": "^0.1.0",
43
43
  "@atproto/xrpc": "^0.8.0",
44
- "@atproto/xrpc-server": "^0.11.0"
44
+ "@atproto/xrpc-server": "^0.11.1"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@did-plc/server": "^0.0.1",
@@ -54,8 +54,8 @@
54
54
  "jest": "^30.0.0",
55
55
  "ts-node": "^10.8.2",
56
56
  "typescript": "^6.0.3",
57
- "@atproto/pds": "^0.4.225",
58
- "@atproto/lex-cli": "^0.10.0"
57
+ "@atproto/lex-cli": "^0.10.0",
58
+ "@atproto/pds": "^0.5.1"
59
59
  },
60
60
  "type": "module",
61
61
  "exports": {
@@ -0,0 +1,23 @@
1
+ import { InvalidRequestError } from '@atproto/xrpc-server'
2
+ import { AppContext } from '../../context.js'
3
+ import { Server } from '../../lexicon/index.js'
4
+ import { ids } from '../../lexicon/lexicons.js'
5
+
6
+ export default function (server: Server, ctx: AppContext) {
7
+ server.chat.bsky.moderation.getConvo({
8
+ auth: ctx.authVerifier.modOrAdminToken,
9
+ handler: async ({ params }) => {
10
+ if (!ctx.chatAgent) {
11
+ throw new InvalidRequestError('No chat agent configured')
12
+ }
13
+ const res = await ctx.chatAgent.api.chat.bsky.moderation.getConvo(
14
+ params,
15
+ await ctx.chatAuth(ids.ChatBskyModerationGetConvo),
16
+ )
17
+ return {
18
+ encoding: 'application/json',
19
+ body: res.data,
20
+ }
21
+ },
22
+ })
23
+ }
@@ -0,0 +1,23 @@
1
+ import { InvalidRequestError } from '@atproto/xrpc-server'
2
+ import { AppContext } from '../../context.js'
3
+ import { Server } from '../../lexicon/index.js'
4
+ import { ids } from '../../lexicon/lexicons.js'
5
+
6
+ export default function (server: Server, ctx: AppContext) {
7
+ server.chat.bsky.moderation.getConvoMembers({
8
+ auth: ctx.authVerifier.modOrAdminToken,
9
+ handler: async ({ params }) => {
10
+ if (!ctx.chatAgent) {
11
+ throw new InvalidRequestError('No chat agent configured')
12
+ }
13
+ const res = await ctx.chatAgent.api.chat.bsky.moderation.getConvoMembers(
14
+ params,
15
+ await ctx.chatAuth(ids.ChatBskyModerationGetConvoMembers),
16
+ )
17
+ return {
18
+ encoding: 'application/json',
19
+ body: res.data,
20
+ }
21
+ },
22
+ })
23
+ }
@@ -1,10 +1,14 @@
1
1
  import { AppContext } from '../../context.js'
2
2
  import { Server } from '../../lexicon/index.js'
3
3
  import getActorMetadata from './getActorMetadata.js'
4
+ import getConvo from './getConvo.js'
5
+ import getConvoMembers from './getConvoMembers.js'
4
6
  import getMessageContext from './getMessageContext.js'
5
7
 
6
8
  export default function (server: Server, ctx: AppContext) {
7
9
  getActorMetadata(server, ctx)
10
+ getConvo(server, ctx)
11
+ getConvoMembers(server, ctx)
8
12
  getMessageContext(server, ctx)
9
13
  return server
10
14
  }
@@ -87,6 +87,7 @@ export class EventReverser {
87
87
  .selectFrom('moderation_subject_status')
88
88
  .where('did', '=', group.did)
89
89
  .where('recordPath', '=', group.recordPath)
90
+ .where('convoId', '=', group.convoId)
90
91
  .selectAll()
91
92
  .executeTakeFirst()
92
93
 
@@ -0,0 +1,114 @@
1
+ import { Kysely, sql } from 'kysely'
2
+
3
+ export async function up(db: Kysely<unknown>): Promise<void> {
4
+ // moderation_event
5
+ await db.schema
6
+ .alterTable('moderation_event')
7
+ .addColumn('subjectConvoId', 'varchar')
8
+ .execute()
9
+ /// broad index to support conversation-based queries
10
+ /// Users is a convo is capped at 50 so volume of matching events should be low
11
+ /// subsequent ordering and filtering will be efficient
12
+ await sql`
13
+ CREATE INDEX "moderation_event_convo_idx"
14
+ ON moderation_event("subjectConvoId")
15
+ WHERE "subjectConvoId" IS NOT NULL
16
+ `.execute(db)
17
+
18
+ // moderation_subject_status
19
+ await db.schema
20
+ .alterTable('moderation_subject_status')
21
+ .addColumn('convoId', 'varchar', (col) => col.notNull().defaultTo(''))
22
+ .execute()
23
+ /// Update unique constraint: [did, recordPath] -> [did, recordPath, convoId]
24
+ /// Create new unique index, drop old constraint, rename new index
25
+ /// Avoids gap without any constraints
26
+ await sql`
27
+ CREATE UNIQUE INDEX "moderation_status_unique_idx_new"
28
+ ON moderation_subject_status(did, "recordPath", "convoId")
29
+ `.execute(db)
30
+ await db.schema
31
+ .alterTable('moderation_subject_status')
32
+ .dropConstraint('moderation_status_unique_idx')
33
+ .execute()
34
+ await db.schema.dropIndex('moderation_status_unique_idx').ifExists().execute()
35
+ await sql`
36
+ ALTER INDEX "moderation_status_unique_idx_new"
37
+ RENAME TO "moderation_status_unique_idx"
38
+ `.execute(db)
39
+ await sql`
40
+ ALTER TABLE moderation_subject_status
41
+ ADD CONSTRAINT "moderation_status_unique_idx"
42
+ UNIQUE USING INDEX "moderation_status_unique_idx"
43
+ `.execute(db)
44
+
45
+ // expiring_tag
46
+ await db.schema
47
+ .alterTable('expiring_tag')
48
+ .addColumn('convoId', 'varchar', (col) => col.notNull().defaultTo(''))
49
+ .execute()
50
+ await db.schema
51
+ .createIndex('idx_expiring_tag_did_record_path_convo_id')
52
+ .on('expiring_tag')
53
+ .columns(['did', 'recordPath', 'convoId'])
54
+ .execute()
55
+
56
+ // report table
57
+ await db.schema
58
+ .alterTable('report')
59
+ .addColumn('subjectConvoId', 'varchar')
60
+ .execute()
61
+ await db.schema.dropIndex('idx_report_unassigned_id').execute()
62
+ await sql`CREATE INDEX idx_report_unassigned_id ON report
63
+ (id)
64
+ INCLUDE (status, "reportType", "recordPath", "subjectMessageId", "subjectConvoId")
65
+ WHERE ("queueId" IS NULL AND status != 'closed')`.execute(db)
66
+ }
67
+
68
+ export async function down(db: Kysely<unknown>): Promise<void> {
69
+ // report
70
+ await db.schema.dropIndex('idx_report_unassigned_id').execute()
71
+ await sql`CREATE INDEX idx_report_unassigned_id ON report
72
+ (id)
73
+ INCLUDE (status, "reportType", "recordPath", "subjectMessageId")
74
+ WHERE ("queueId" IS NULL AND status != 'closed')`.execute(db)
75
+ await db.schema.alterTable('report').dropColumn('subjectConvoId').execute()
76
+
77
+ // expiring_tag
78
+ await db.schema
79
+ .dropIndex('idx_expiring_tag_did_record_path_convo_id')
80
+ .execute()
81
+ await db.schema.alterTable('expiring_tag').dropColumn('convoId').execute()
82
+
83
+ // moderation_subject_status
84
+ /// Reverse the unique constraint change: [did, recordPath, convoId] -> [did, recordPath]
85
+ await sql`
86
+ CREATE UNIQUE INDEX "moderation_status_unique_idx_old"
87
+ ON moderation_subject_status(did, "recordPath")
88
+ `.execute(db)
89
+ await db.schema
90
+ .alterTable('moderation_subject_status')
91
+ .dropConstraint('moderation_status_unique_idx')
92
+ .execute()
93
+ await db.schema.dropIndex('moderation_status_unique_idx').ifExists().execute()
94
+ await sql`
95
+ ALTER INDEX "moderation_status_unique_idx_old"
96
+ RENAME TO "moderation_status_unique_idx"
97
+ `.execute(db)
98
+ await sql`
99
+ ALTER TABLE moderation_subject_status
100
+ ADD CONSTRAINT "moderation_status_unique_idx"
101
+ UNIQUE USING INDEX "moderation_status_unique_idx"
102
+ `.execute(db)
103
+ await db.schema
104
+ .alterTable('moderation_subject_status')
105
+ .dropColumn('convoId')
106
+ .execute()
107
+
108
+ // moderation_event
109
+ await db.schema.dropIndex('moderation_event_convo_idx').execute()
110
+ await db.schema
111
+ .alterTable('moderation_event')
112
+ .dropColumn('subjectConvoId')
113
+ .execute()
114
+ }
@@ -40,3 +40,4 @@ export * as _20260225T000000000Z from './20260225T000000000Z-add-report-queue-ta
40
40
  export * as _20260313T000000000Z from './20260313T000000000Z-add-report-activity-table.js'
41
41
  export * as _20260318T152058935Z from './20260318T152058935Z-add-report-stat.js'
42
42
  export * as _20260428T000000000Z from './20260428T000000000Z-add-expiring-tag-table.js'
43
+ export * as _20260513T202941104Z from './20260513T202941104Z-add-subject-convo-id.js'
@@ -7,6 +7,7 @@ export interface ExpiringTag {
7
7
  eventId: number
8
8
  did: string
9
9
  recordPath: string
10
+ convoId: string
10
11
  tag: string
11
12
  expiresAt: string
12
13
  createdBy: string
@@ -31,10 +31,12 @@ export interface ModerationEvent {
31
31
  | 'com.atproto.admin.defs#repoRef'
32
32
  | 'com.atproto.repo.strongRef'
33
33
  | 'chat.bsky.convo.defs#messageRef'
34
+ | 'chat.bsky.convo.defs#convoRef'
34
35
  subjectDid: string
35
36
  subjectUri: string | null
36
37
  subjectCid: string | null
37
38
  subjectBlobCids: string[] | null
39
+ subjectConvoId: string | null
38
40
  subjectMessageId: string | null
39
41
  createLabelVals: string | null
40
42
  negateLabelVals: string | null
@@ -10,8 +10,12 @@ export const subjectStatusTableName = 'moderation_subject_status'
10
10
 
11
11
  export interface ModerationSubjectStatus {
12
12
  id: Generated<number>
13
+
14
+ // unique columns
13
15
  did: string
14
16
  recordPath: string
17
+ convoId: string
18
+
15
19
  recordCid: string | null
16
20
  blobCids: string[] | null
17
21
  reviewState:
@@ -13,8 +13,9 @@ export interface Report {
13
13
  status: string // 'open', 'closed', 'escalated', 'queued', 'assigned'
14
14
  reportType: string // Denormalized from moderation_event.meta.reportType
15
15
  did: string // Denormalized from moderation_event.subjectDid
16
- recordPath: string // '' = account/message, 'collection/rkey' = record
16
+ recordPath: string // '' = account/message/conversation, 'collection/rkey' = record
17
17
  subjectMessageId: string | null // Denormalized from moderation_event.subjectMessageId
18
+ subjectConvoId: string | null // Denormalized from moderation_event.subjectConvoId
18
19
  createdAt: string
19
20
  updatedAt: string
20
21
  assignedTo: string | null // DID of permanently assigned moderator, null if unassigned
@@ -7,6 +7,7 @@ export type ExpiringTagRow = Selectable<ExpiringTag>
7
7
  export type ExpiringTagGroup = {
8
8
  did: string
9
9
  recordPath: string
10
+ convoId: string
10
11
  createdBy: string
11
12
  tags: string[]
12
13
  ids: number[]
@@ -18,6 +19,7 @@ export async function insertExpiringTags(
18
19
  eventId: number
19
20
  did: string
20
21
  recordPath: string
22
+ convoId: string
21
23
  tags: string[]
22
24
  expiresAt: string
23
25
  createdBy: string
@@ -30,6 +32,7 @@ export async function insertExpiringTags(
30
32
  eventId: params.eventId,
31
33
  did: params.did,
32
34
  recordPath: params.recordPath,
35
+ convoId: params.convoId,
33
36
  tag,
34
37
  expiresAt: params.expiresAt,
35
38
  createdBy: params.createdBy,
@@ -43,6 +46,7 @@ export async function removeExpiringTags(
43
46
  params: {
44
47
  did: string
45
48
  recordPath: string
49
+ convoId: string
46
50
  tags: string[]
47
51
  },
48
52
  ): Promise<void> {
@@ -50,6 +54,7 @@ export async function removeExpiringTags(
50
54
  .deleteFrom('expiring_tag')
51
55
  .where('did', '=', params.did)
52
56
  .where('recordPath', '=', params.recordPath)
57
+ .where('convoId', '=', params.convoId)
53
58
  .where('tag', 'in', params.tags)
54
59
  .execute()
55
60
  }
@@ -73,15 +78,16 @@ export async function getExpiredTags(
73
78
 
74
79
  if (!rows.length) return []
75
80
 
76
- // Group by (did, recordPath, createdBy) so each reversal event has the correct author
81
+ // Group by (did, recordPath, convoId, createdBy) so each reversal event has the correct author
77
82
  const grouped = new Map<string, ExpiringTagGroup>()
78
83
  for (const row of rows) {
79
- const key = `${row.did}|${row.recordPath}|${row.createdBy}`
84
+ const key = `${row.did}|${row.recordPath}|${row.convoId}|${row.createdBy}`
80
85
  let group = grouped.get(key)
81
86
  if (!group) {
82
87
  group = {
83
88
  did: row.did,
84
89
  recordPath: row.recordPath,
90
+ convoId: row.convoId,
85
91
  createdBy: row.createdBy,
86
92
  tags: [],
87
93
  ids: [],
@@ -235,21 +235,34 @@ export class ModerationService {
235
235
 
236
236
  if (subject) {
237
237
  const isSubjectAtUri = subject.startsWith('at://')
238
+ const subjectAtUri = isSubjectAtUri ? new AtUri(subject) : null
238
239
  const subjectDid = isSubjectAtUri ? new AtUri(subject).hostname : subject
239
240
  const subjectUri = isSubjectAtUri ? subject : null
240
241
  // regardless of subjectUri check, we always want to query against subjectDid column since that's indexed
241
242
  builder = builder.where('subjectDid', '=', subjectDid)
242
243
 
243
- // if requester wants to include all user records, let's ignore matching on subjectUri
244
+ // subjectUri or subjectConvoId
244
245
  if (!includeAllUserRecords) {
245
- builder = builder
246
- .if(!subjectUri, (q) => q.where('subjectUri', 'is', null))
247
- .if(!!subjectUri, (q) => q.where('subjectUri', '=', subjectUri))
246
+ if (subjectAtUri?.collection === 'chat.bsky.convo') {
247
+ builder = builder.where('subjectConvoId', '=', subjectAtUri.rkey)
248
+ } else if (subjectUri) {
249
+ builder = builder.where('subjectUri', '=', subjectUri)
250
+ } else {
251
+ // Account-level: subjectUri IS NULL also matches conversation events,
252
+ // so explicitly exclude them.
253
+ builder = builder
254
+ .where('subjectUri', 'is', null)
255
+ .where('subjectConvoId', 'is', null)
256
+ }
248
257
  }
249
258
  } else if (subjectType === 'account') {
250
- builder = builder.where('subjectUri', 'is', null)
259
+ builder = builder
260
+ .where('subjectUri', 'is', null)
261
+ .where('subjectConvoId', 'is', null)
251
262
  } else if (subjectType === 'record') {
252
263
  builder = builder.where('subjectUri', 'is not', null)
264
+ } else if (subjectType === 'conversation') {
265
+ builder = builder.where('subjectConvoId', 'is not', null)
253
266
  }
254
267
 
255
268
  // If subjectType is set to 'account' let that take priority and ignore collections filter
@@ -433,7 +446,9 @@ export class ModerationService {
433
446
  const subjectsToBeResolved = await this.db.db
434
447
  .selectFrom('moderation_subject_status')
435
448
  .where('did', '=', did)
436
- .where('recordPath', '!=', '')
449
+ .where((qb) =>
450
+ qb.where('recordPath', '!=', '').orWhere('convoId', '!=', ''),
451
+ )
437
452
  .where('reviewState', 'in', [REVIEWESCALATED, REVIEWOPEN])
438
453
  .selectAll()
439
454
  .execute()
@@ -686,6 +701,7 @@ export class ModerationService {
686
701
  subjectCid: subjectInfo.subjectCid,
687
702
  subjectBlobCids: jsonb(subjectInfo.subjectBlobCids),
688
703
  subjectMessageId: subjectInfo.subjectMessageId,
704
+ subjectConvoId: subjectInfo.subjectConvoId,
689
705
  modTool: modTool ? jsonb(modTool) : null,
690
706
  externalId: externalId ?? null,
691
707
  severityLevel,
@@ -712,6 +728,7 @@ export class ModerationService {
712
728
  eventId: modEvent.id,
713
729
  did: subjectInfo.subjectDid,
714
730
  recordPath: subjectInfo.subjectUri ?? '',
731
+ convoId: subjectInfo.subjectConvoId ?? '',
715
732
  tags: event.add,
716
733
  expiresAt,
717
734
  createdBy,
@@ -721,6 +738,7 @@ export class ModerationService {
721
738
  await removeExpiringTags(this.db, {
722
739
  did: subjectInfo.subjectDid,
723
740
  recordPath: subjectInfo.subjectUri ?? '',
741
+ convoId: subjectInfo.subjectConvoId ?? '',
724
742
  tags: event.remove,
725
743
  })
726
744
  }
@@ -815,6 +833,7 @@ export class ModerationService {
815
833
  .selectFrom('moderation_subject_status')
816
834
  .where('did', '=', did)
817
835
  .where('recordPath', '=', '')
836
+ .where('convoId', '=', '')
818
837
  .where('suspendUntil', '>', new Date().toISOString())
819
838
  .select('did')
820
839
  .limit(1)
@@ -1135,20 +1154,34 @@ export class ModerationService {
1135
1154
  )
1136
1155
 
1137
1156
  if (!includeAllUserRecords) {
1138
- builder = builder.where((qb) =>
1139
- subjectInfo.recordPath
1140
- ? qb.where(
1141
- 'moderation_subject_status.recordPath',
1142
- '=',
1143
- subjectInfo.recordPath,
1144
- )
1145
- : qb.where('moderation_subject_status.recordPath', '=', ''),
1146
- )
1157
+ if (subjectInfo.convoId) {
1158
+ builder = builder.where(
1159
+ 'moderation_subject_status.convoId',
1160
+ '=',
1161
+ subjectInfo.convoId,
1162
+ )
1163
+ } else if (subjectInfo.recordPath) {
1164
+ builder = builder.where(
1165
+ 'moderation_subject_status.recordPath',
1166
+ '=',
1167
+ subjectInfo.recordPath,
1168
+ )
1169
+ } else {
1170
+ // Account-level: recordPath = '' also matches conversation statuses,
1171
+ // so explicitly exclude them.
1172
+ builder = builder
1173
+ .where('moderation_subject_status.recordPath', '=', '')
1174
+ .where('moderation_subject_status.convoId', '=', '')
1175
+ }
1147
1176
  }
1148
1177
  } else if (subjectType === 'account') {
1149
- builder = builder.where('moderation_subject_status.recordPath', '=', '')
1178
+ builder = builder
1179
+ .where('moderation_subject_status.recordPath', '=', '')
1180
+ .where('moderation_subject_status.convoId', '=', '')
1150
1181
  } else if (subjectType === 'record') {
1151
1182
  builder = builder.where('moderation_subject_status.recordPath', '!=', '')
1183
+ } else if (subjectType === 'conversation') {
1184
+ builder = builder.where('moderation_subject_status.convoId', '!=', '')
1152
1185
  }
1153
1186
 
1154
1187
  // Only fetch items that belongs to the specified queue when specified
@@ -1438,6 +1471,7 @@ export class ModerationService {
1438
1471
  .selectFrom('moderation_subject_status')
1439
1472
  .where('did', '=', subject.did)
1440
1473
  .where('recordPath', '=', subject.recordPath ?? '')
1474
+ .where('convoId', '=', subject.convoId ?? '')
1441
1475
  .selectAll()
1442
1476
  .executeTakeFirst()
1443
1477
  return result ?? null
@@ -1450,6 +1484,7 @@ export class ModerationService {
1450
1484
  .selectFrom('moderation_subject_status')
1451
1485
  .where('did', '=', did)
1452
1486
  .where('recordPath', '=', '')
1487
+ .where('convoId', '=', '')
1453
1488
  .where('muteReportingUntil', '>', new Date().toISOString())
1454
1489
  .select(sql`true`.as('status'))
1455
1490
  .executeTakeFirst()
@@ -1463,6 +1498,7 @@ export class ModerationService {
1463
1498
  .selectFrom('moderation_subject_status')
1464
1499
  .where('did', '=', did)
1465
1500
  .where('recordPath', '=', '')
1501
+ .where('convoId', '=', '')
1466
1502
  .where('muteUntil', '>', new Date().toISOString())
1467
1503
  .select(sql`true`.as('status'))
1468
1504
  .executeTakeFirst()
@@ -283,6 +283,7 @@ export const adjustModerationSubjectStatus = async (
283
283
  subjectDid,
284
284
  subjectUri,
285
285
  subjectCid,
286
+ subjectConvoId,
286
287
  createdBy,
287
288
  meta,
288
289
  addedTags,
@@ -292,7 +293,10 @@ export const adjustModerationSubjectStatus = async (
292
293
  } = moderationEvent
293
294
 
294
295
  // If subjectUri exists, it's not a repoRef so pass along the uri to get identifier back
295
- const identifier = getStatusIdentifierFromSubject(subjectUri || subjectDid)
296
+ const identifier = getStatusIdentifierFromSubject(
297
+ subjectUri || subjectDid,
298
+ subjectConvoId,
299
+ )
296
300
 
297
301
  db.assertTransaction()
298
302
 
@@ -301,6 +305,7 @@ export const adjustModerationSubjectStatus = async (
301
305
  .selectFrom('moderation_subject_status')
302
306
  .where('did', '=', identifier.did)
303
307
  .where('recordPath', '=', identifier.recordPath)
308
+ .where('convoId', '=', identifier.convoId)
304
309
  // Make sure we respect other updates that may be happening at the same time
305
310
  .forUpdate()
306
311
  .selectAll()
@@ -504,14 +509,20 @@ export const adjustModerationSubjectStatus = async (
504
509
  return status || null
505
510
  }
506
511
 
512
+ /**
513
+ * Get moderation_subject_status identifier (did, recordPath, convoId).
514
+ * @note Supports addressing conversations explicitly (via convoId) and implicitly (via properly formed at-uri)
515
+ */
507
516
  export const getStatusIdentifierFromSubject = (
508
517
  subject: string | AtUri,
509
- ): { did: string; recordPath: string } => {
518
+ convoId?: string | null,
519
+ ): { did: string; recordPath: string; convoId: string } => {
510
520
  const isSubjectString = typeof subject === 'string'
511
521
  if (isSubjectString && subject.startsWith('did:')) {
512
522
  return {
513
523
  did: subject,
514
524
  recordPath: '',
525
+ convoId: convoId || '',
515
526
  }
516
527
  }
517
528
 
@@ -520,8 +531,19 @@ export const getStatusIdentifierFromSubject = (
520
531
  }
521
532
 
522
533
  const uri = isSubjectString ? new AtUri(subject) : subject
534
+
535
+ // Handle conversation URIs
536
+ if (uri.collection === 'chat.bsky.convo') {
537
+ return {
538
+ did: uri.host,
539
+ recordPath: '',
540
+ convoId: uri.rkey,
541
+ }
542
+ }
543
+
523
544
  return {
524
545
  did: uri.host,
525
546
  recordPath: `${uri.collection}/${uri.rkey}`,
547
+ convoId: '',
526
548
  }
527
549
  }