@atproto/ozone 0.1.133 → 0.1.134

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 (46) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/api/index.d.ts.map +1 -1
  3. package/dist/api/index.js +2 -0
  4. package/dist/api/index.js.map +1 -1
  5. package/dist/api/moderation/getAccountTimeline.d.ts +4 -0
  6. package/dist/api/moderation/getAccountTimeline.d.ts.map +1 -0
  7. package/dist/api/moderation/getAccountTimeline.js +139 -0
  8. package/dist/api/moderation/getAccountTimeline.js.map +1 -0
  9. package/dist/api/util.d.ts +1 -1
  10. package/dist/lexicon/index.d.ts +5 -0
  11. package/dist/lexicon/index.d.ts.map +1 -1
  12. package/dist/lexicon/index.js +7 -0
  13. package/dist/lexicon/index.js.map +1 -1
  14. package/dist/lexicon/lexicons.d.ts +168 -0
  15. package/dist/lexicon/lexicons.d.ts.map +1 -1
  16. package/dist/lexicon/lexicons.js +116 -0
  17. package/dist/lexicon/lexicons.js.map +1 -1
  18. package/dist/lexicon/types/tools/ozone/moderation/defs.d.ts +6 -0
  19. package/dist/lexicon/types/tools/ozone/moderation/defs.d.ts.map +1 -1
  20. package/dist/lexicon/types/tools/ozone/moderation/defs.js +7 -1
  21. package/dist/lexicon/types/tools/ozone/moderation/defs.js.map +1 -1
  22. package/dist/lexicon/types/tools/ozone/moderation/getAccountTimeline.d.ts +41 -0
  23. package/dist/lexicon/types/tools/ozone/moderation/getAccountTimeline.d.ts.map +1 -0
  24. package/dist/lexicon/types/tools/ozone/moderation/getAccountTimeline.js +25 -0
  25. package/dist/lexicon/types/tools/ozone/moderation/getAccountTimeline.js.map +1 -0
  26. package/dist/mod-service/index.d.ts +7 -1
  27. package/dist/mod-service/index.d.ts.map +1 -1
  28. package/dist/mod-service/index.js +20 -0
  29. package/dist/mod-service/index.js.map +1 -1
  30. package/dist/mod-service/util.d.ts +3 -0
  31. package/dist/mod-service/util.d.ts.map +1 -1
  32. package/dist/mod-service/util.js +11 -1
  33. package/dist/mod-service/util.js.map +1 -1
  34. package/package.json +4 -4
  35. package/src/api/index.ts +2 -0
  36. package/src/api/moderation/getAccountTimeline.ts +160 -0
  37. package/src/lexicon/index.ts +19 -0
  38. package/src/lexicon/lexicons.ts +121 -0
  39. package/src/lexicon/types/tools/ozone/moderation/defs.ts +7 -0
  40. package/src/lexicon/types/tools/ozone/moderation/getAccountTimeline.ts +102 -0
  41. package/src/mod-service/index.ts +24 -0
  42. package/src/mod-service/util.ts +11 -0
  43. package/tests/__snapshots__/get-account-timeline.test.ts.snap +36 -0
  44. package/tests/get-account-timeline.test.ts +85 -0
  45. package/tsconfig.build.tsbuildinfo +1 -1
  46. package/tsconfig.tests.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atproto/ozone",
3
- "version": "0.1.133",
3
+ "version": "0.1.134",
4
4
  "license": "MIT",
5
5
  "description": "Backend service for moderating the Bluesky network.",
6
6
  "keywords": [
@@ -35,14 +35,14 @@
35
35
  "uint8arrays": "3.0.0",
36
36
  "undici": "^6.14.1",
37
37
  "ws": "^8.12.0",
38
- "@atproto/api": "^0.16.0",
39
38
  "@atproto/common": "^0.4.11",
39
+ "@atproto/api": "^0.16.1",
40
40
  "@atproto/crypto": "^0.4.4",
41
41
  "@atproto/identity": "^0.4.8",
42
42
  "@atproto/lexicon": "^0.4.12",
43
43
  "@atproto/syntax": "^0.4.0",
44
44
  "@atproto/xrpc": "^0.7.1",
45
- "@atproto/xrpc-server": "^0.9.0"
45
+ "@atproto/xrpc-server": "^0.9.1"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@did-plc/server": "^0.0.1",
@@ -55,7 +55,7 @@
55
55
  "ts-node": "^10.8.2",
56
56
  "typescript": "^5.6.3",
57
57
  "@atproto/lex-cli": "^0.9.1",
58
- "@atproto/pds": "^0.4.163"
58
+ "@atproto/pds": "^0.4.164"
59
59
  },
60
60
  "scripts": {
61
61
  "codegen": "lex gen-server --yes ./src/lexicon ../../lexicons/com/atproto/*/* ../../lexicons/app/bsky/*/* ../../lexicons/chat/bsky/*/* ../../lexicons/tools/ozone/*/*",
package/src/api/index.ts CHANGED
@@ -9,6 +9,7 @@ import fetchLabels from './label/fetchLabels'
9
9
  import queryLabels from './label/queryLabels'
10
10
  import subscribeLabels from './label/subscribeLabels'
11
11
  import emitEvent from './moderation/emitEvent'
12
+ import getAccountTimeline from './moderation/getAccountTimeline'
12
13
  import getEvent from './moderation/getEvent'
13
14
  import adminGetRecord from './moderation/getRecord'
14
15
  import adminGetRecords from './moderation/getRecords'
@@ -92,5 +93,6 @@ export default function (server: Server, ctx: AppContext) {
92
93
  removeSafelinkRule(server, ctx)
93
94
  querySafelinkEvents(server, ctx)
94
95
  querySafelinkRules(server, ctx)
96
+ getAccountTimeline(server, ctx)
95
97
  return server
96
98
  }
@@ -0,0 +1,160 @@
1
+ import { ToolsOzoneModerationGetAccountTimeline } from '@atproto/api'
2
+ import { AppContext } from '../../context'
3
+ import { Server } from '../../lexicon'
4
+ import { ids } from '../../lexicon/lexicons'
5
+ import { dateFromDatetime } from '../../mod-service/util'
6
+
7
+ export default function (server: Server, ctx: AppContext) {
8
+ server.tools.ozone.moderation.getAccountTimeline({
9
+ auth: ctx.authVerifier.modOrAdminToken,
10
+ handler: async ({ params }) => {
11
+ const { did } = params
12
+ const db = ctx.db
13
+ const modService = ctx.modService(db)
14
+ const [modEventHistory, accountHistory, plcHistory] =
15
+ await Promise.allSettled([
16
+ modService.getAccountTimeline(did),
17
+ getAccountHistory(ctx, did),
18
+ getPlcHistory(ctx, did),
19
+ ])
20
+ const timelineByDay = new Map<
21
+ string,
22
+ ToolsOzoneModerationGetAccountTimeline.TimelineItemSummary[]
23
+ >()
24
+
25
+ if (modEventHistory.status === 'fulfilled') {
26
+ for (const row of modEventHistory.value) {
27
+ const day = timelineByDay.get(row.day)
28
+ const summary = {
29
+ eventSubjectType: row.subjectUri ? 'record' : 'account',
30
+ eventType: row.action,
31
+ count: row.count,
32
+ }
33
+ if (day) {
34
+ day.push(summary)
35
+ timelineByDay.set(row.day, day)
36
+ } else {
37
+ timelineByDay.set(row.day, [summary])
38
+ }
39
+ }
40
+ } else {
41
+ throw modEventHistory.reason
42
+ }
43
+
44
+ if (accountHistory.status === 'fulfilled') {
45
+ for (const [rowDay, row] of Object.entries(accountHistory.value)) {
46
+ const day = timelineByDay.get(rowDay)
47
+ const summaries: ToolsOzoneModerationGetAccountTimeline.TimelineItemSummary[] =
48
+ []
49
+ for (const [eventType, count] of Object.entries(row)) {
50
+ summaries.push({
51
+ eventSubjectType: 'account',
52
+ eventType,
53
+ count,
54
+ })
55
+ }
56
+ if (day) {
57
+ day.push(...summaries)
58
+ timelineByDay.set(rowDay, day)
59
+ } else {
60
+ timelineByDay.set(rowDay, summaries)
61
+ }
62
+ }
63
+ }
64
+
65
+ if (plcHistory.status === 'fulfilled') {
66
+ for (const [rowDay, row] of Object.entries(plcHistory.value)) {
67
+ const day = timelineByDay.get(rowDay)
68
+ const summaries: ToolsOzoneModerationGetAccountTimeline.TimelineItemSummary[] =
69
+ []
70
+ for (const [eventType, count] of Object.entries(row)) {
71
+ summaries.push({
72
+ eventSubjectType: 'account',
73
+ eventType,
74
+ count,
75
+ })
76
+ }
77
+ if (day) {
78
+ day.push(...summaries)
79
+ timelineByDay.set(rowDay, day)
80
+ } else {
81
+ timelineByDay.set(rowDay, summaries)
82
+ }
83
+ }
84
+ }
85
+
86
+ const timeline: ToolsOzoneModerationGetAccountTimeline.TimelineItem[] = []
87
+
88
+ for (const [day, summary] of timelineByDay.entries()) {
89
+ timeline.push({ day, summary: summary.flat() })
90
+ }
91
+
92
+ return {
93
+ encoding: 'application/json',
94
+ body: { timeline },
95
+ }
96
+ },
97
+ })
98
+ }
99
+
100
+ const getAccountHistory = async (ctx: AppContext, did: string) => {
101
+ const events: Record<string, Record<string, number>> = {}
102
+
103
+ if (!ctx.pdsAgent) {
104
+ return events
105
+ }
106
+
107
+ const auth = await ctx.pdsAuth(ids.ToolsOzoneHostingGetAccountHistory)
108
+ let cursor: string | undefined = undefined
109
+
110
+ do {
111
+ const { data } = await ctx.pdsAgent.tools.ozone.hosting.getAccountHistory(
112
+ { did, cursor },
113
+ auth,
114
+ )
115
+ cursor = data.cursor
116
+ for (const event of data.events) {
117
+ // This should never happen and the check is here only because typescript screams at us otherwise
118
+ if (!event.$type) {
119
+ continue
120
+ }
121
+
122
+ const day = dateFromDatetime(new Date(event.createdAt))
123
+ events[day] ??= {}
124
+ events[day][event.$type] ??= 0
125
+ events[day][event.$type]++
126
+ }
127
+ } while (cursor)
128
+
129
+ return events
130
+ }
131
+
132
+ const PLC_OPERATION_MAP = {
133
+ create: 'tools.ozone.moderation.defs#timelineEventPlcCreate',
134
+ plc_operation: 'tools.ozone.moderation.defs#timelineEventPlcOperation',
135
+ plc_tombstone: 'tools.ozone.moderation.defs#timelineEventPlcTombstone',
136
+ }
137
+
138
+ const getPlcHistory = async (ctx: AppContext, did: string) => {
139
+ const events: Record<string, Record<string, number>> = {}
140
+
141
+ if (!ctx.plcClient) {
142
+ return events
143
+ }
144
+
145
+ const result = await ctx.plcClient.getAuditableLog(did)
146
+ for (const event of result) {
147
+ // Skip events that are not mapped, this means we will have to add correct mapping if/when new event types are introduced here
148
+ if (!Object.hasOwn(PLC_OPERATION_MAP, event.operation.type)) {
149
+ continue
150
+ }
151
+ const day = dateFromDatetime(new Date(event.createdAt))
152
+ events[day] ??= {}
153
+ const eventType =
154
+ PLC_OPERATION_MAP[event.operation.type] || event.operation.type
155
+ events[day][eventType] ??= 0
156
+ events[day][eventType]++
157
+ }
158
+
159
+ return events
160
+ }
@@ -201,6 +201,7 @@ import * as ToolsOzoneCommunicationListTemplates from './types/tools/ozone/commu
201
201
  import * as ToolsOzoneCommunicationUpdateTemplate from './types/tools/ozone/communication/updateTemplate.js'
202
202
  import * as ToolsOzoneHostingGetAccountHistory from './types/tools/ozone/hosting/getAccountHistory.js'
203
203
  import * as ToolsOzoneModerationEmitEvent from './types/tools/ozone/moderation/emitEvent.js'
204
+ import * as ToolsOzoneModerationGetAccountTimeline from './types/tools/ozone/moderation/getAccountTimeline.js'
204
205
  import * as ToolsOzoneModerationGetEvent from './types/tools/ozone/moderation/getEvent.js'
205
206
  import * as ToolsOzoneModerationGetRecord from './types/tools/ozone/moderation/getRecord.js'
206
207
  import * as ToolsOzoneModerationGetRecords from './types/tools/ozone/moderation/getRecords.js'
@@ -275,6 +276,12 @@ export const TOOLS_OZONE_MODERATION = {
275
276
  DefsReviewEscalated: 'tools.ozone.moderation.defs#reviewEscalated',
276
277
  DefsReviewClosed: 'tools.ozone.moderation.defs#reviewClosed',
277
278
  DefsReviewNone: 'tools.ozone.moderation.defs#reviewNone',
279
+ DefsTimelineEventPlcCreate:
280
+ 'tools.ozone.moderation.defs#timelineEventPlcCreate',
281
+ DefsTimelineEventPlcOperation:
282
+ 'tools.ozone.moderation.defs#timelineEventPlcOperation',
283
+ DefsTimelineEventPlcTombstone:
284
+ 'tools.ozone.moderation.defs#timelineEventPlcTombstone',
278
285
  }
279
286
  export const TOOLS_OZONE_TEAM = {
280
287
  DefsRoleAdmin: 'tools.ozone.team.defs#roleAdmin',
@@ -2918,6 +2925,18 @@ export class ToolsOzoneModerationNS {
2918
2925
  return this._server.xrpc.method(nsid, cfg)
2919
2926
  }
2920
2927
 
2928
+ getAccountTimeline<A extends Auth = void>(
2929
+ cfg: MethodConfigOrHandler<
2930
+ A,
2931
+ ToolsOzoneModerationGetAccountTimeline.QueryParams,
2932
+ ToolsOzoneModerationGetAccountTimeline.HandlerInput,
2933
+ ToolsOzoneModerationGetAccountTimeline.HandlerOutput
2934
+ >,
2935
+ ) {
2936
+ const nsid = 'tools.ozone.moderation.getAccountTimeline' // @ts-ignore
2937
+ return this._server.xrpc.method(nsid, cfg)
2938
+ }
2939
+
2921
2940
  getEvent<A extends Auth = void>(
2922
2941
  cfg: MethodConfigOrHandler<
2923
2942
  A,
@@ -15006,6 +15006,21 @@ export const schemaDict = {
15006
15006
  },
15007
15007
  },
15008
15008
  },
15009
+ timelineEventPlcCreate: {
15010
+ type: 'token',
15011
+ description:
15012
+ 'Moderation event timeline event for a PLC create operation',
15013
+ },
15014
+ timelineEventPlcOperation: {
15015
+ type: 'token',
15016
+ description:
15017
+ 'Moderation event timeline event for generic PLC operation',
15018
+ },
15019
+ timelineEventPlcTombstone: {
15020
+ type: 'token',
15021
+ description:
15022
+ 'Moderation event timeline event for a PLC tombstone operation',
15023
+ },
15009
15024
  },
15010
15025
  },
15011
15026
  ToolsOzoneModerationEmitEvent: {
@@ -15097,6 +15112,110 @@ export const schemaDict = {
15097
15112
  },
15098
15113
  },
15099
15114
  },
15115
+ ToolsOzoneModerationGetAccountTimeline: {
15116
+ lexicon: 1,
15117
+ id: 'tools.ozone.moderation.getAccountTimeline',
15118
+ defs: {
15119
+ main: {
15120
+ type: 'query',
15121
+ description:
15122
+ 'Get timeline of all available events of an account. This includes moderation events, account history and did history.',
15123
+ parameters: {
15124
+ type: 'params',
15125
+ required: ['did'],
15126
+ properties: {
15127
+ did: {
15128
+ type: 'string',
15129
+ format: 'did',
15130
+ },
15131
+ },
15132
+ },
15133
+ output: {
15134
+ encoding: 'application/json',
15135
+ schema: {
15136
+ type: 'object',
15137
+ required: ['timeline'],
15138
+ properties: {
15139
+ timeline: {
15140
+ type: 'array',
15141
+ items: {
15142
+ type: 'ref',
15143
+ ref: 'lex:tools.ozone.moderation.getAccountTimeline#timelineItem',
15144
+ },
15145
+ },
15146
+ },
15147
+ },
15148
+ },
15149
+ errors: [
15150
+ {
15151
+ name: 'RepoNotFound',
15152
+ },
15153
+ ],
15154
+ },
15155
+ timelineItem: {
15156
+ type: 'object',
15157
+ required: ['day', 'summary'],
15158
+ properties: {
15159
+ day: {
15160
+ type: 'string',
15161
+ },
15162
+ summary: {
15163
+ type: 'array',
15164
+ items: {
15165
+ type: 'ref',
15166
+ ref: 'lex:tools.ozone.moderation.getAccountTimeline#timelineItemSummary',
15167
+ },
15168
+ },
15169
+ },
15170
+ },
15171
+ timelineItemSummary: {
15172
+ type: 'object',
15173
+ required: ['eventSubjectType', 'eventType', 'count'],
15174
+ properties: {
15175
+ eventSubjectType: {
15176
+ type: 'string',
15177
+ knownValues: ['account', 'record', 'chat'],
15178
+ },
15179
+ eventType: {
15180
+ type: 'string',
15181
+ knownValues: [
15182
+ 'tools.ozone.moderation.defs#modEventTakedown',
15183
+ 'tools.ozone.moderation.defs#modEventReverseTakedown',
15184
+ 'tools.ozone.moderation.defs#modEventComment',
15185
+ 'tools.ozone.moderation.defs#modEventReport',
15186
+ 'tools.ozone.moderation.defs#modEventLabel',
15187
+ 'tools.ozone.moderation.defs#modEventAcknowledge',
15188
+ 'tools.ozone.moderation.defs#modEventEscalate',
15189
+ 'tools.ozone.moderation.defs#modEventMute',
15190
+ 'tools.ozone.moderation.defs#modEventUnmute',
15191
+ 'tools.ozone.moderation.defs#modEventMuteReporter',
15192
+ 'tools.ozone.moderation.defs#modEventUnmuteReporter',
15193
+ 'tools.ozone.moderation.defs#modEventEmail',
15194
+ 'tools.ozone.moderation.defs#modEventResolveAppeal',
15195
+ 'tools.ozone.moderation.defs#modEventDivert',
15196
+ 'tools.ozone.moderation.defs#modEventTag',
15197
+ 'tools.ozone.moderation.defs#accountEvent',
15198
+ 'tools.ozone.moderation.defs#identityEvent',
15199
+ 'tools.ozone.moderation.defs#recordEvent',
15200
+ 'tools.ozone.moderation.defs#modEventPriorityScore',
15201
+ 'tools.ozone.moderation.defs#ageAssuranceEvent',
15202
+ 'tools.ozone.moderation.defs#ageAssuranceOverrideEvent',
15203
+ 'tools.ozone.moderation.defs#timelineEventPlcCreate',
15204
+ 'tools.ozone.moderation.defs#timelineEventPlcOperation',
15205
+ 'tools.ozone.moderation.defs#timelineEventPlcTombstone',
15206
+ 'tools.ozone.hosting.getAccountHistory#accountCreated',
15207
+ 'tools.ozone.hosting.getAccountHistory#emailConfirmed',
15208
+ 'tools.ozone.hosting.getAccountHistory#passwordUpdated',
15209
+ 'tools.ozone.hosting.getAccountHistory#handleUpdated',
15210
+ ],
15211
+ },
15212
+ count: {
15213
+ type: 'integer',
15214
+ },
15215
+ },
15216
+ },
15217
+ },
15218
+ },
15100
15219
  ToolsOzoneModerationGetEvent: {
15101
15220
  lexicon: 1,
15102
15221
  id: 'tools.ozone.moderation.getEvent',
@@ -17914,6 +18033,8 @@ export const ids = {
17914
18033
  ToolsOzoneHostingGetAccountHistory: 'tools.ozone.hosting.getAccountHistory',
17915
18034
  ToolsOzoneModerationDefs: 'tools.ozone.moderation.defs',
17916
18035
  ToolsOzoneModerationEmitEvent: 'tools.ozone.moderation.emitEvent',
18036
+ ToolsOzoneModerationGetAccountTimeline:
18037
+ 'tools.ozone.moderation.getAccountTimeline',
17917
18038
  ToolsOzoneModerationGetEvent: 'tools.ozone.moderation.getEvent',
17918
18039
  ToolsOzoneModerationGetRecord: 'tools.ozone.moderation.getRecord',
17919
18040
  ToolsOzoneModerationGetRecords: 'tools.ozone.moderation.getRecords',
@@ -980,3 +980,10 @@ export function isModTool<V>(v: V) {
980
980
  export function validateModTool<V>(v: V) {
981
981
  return validate<ModTool & V>(v, id, hashModTool)
982
982
  }
983
+
984
+ /** Moderation event timeline event for a PLC create operation */
985
+ export const TIMELINEEVENTPLCCREATE = `${id}#timelineEventPlcCreate`
986
+ /** Moderation event timeline event for generic PLC operation */
987
+ export const TIMELINEEVENTPLCOPERATION = `${id}#timelineEventPlcOperation`
988
+ /** Moderation event timeline event for a PLC tombstone operation */
989
+ export const TIMELINEEVENTPLCTOMBSTONE = `${id}#timelineEventPlcTombstone`
@@ -0,0 +1,102 @@
1
+ /**
2
+ * GENERATED CODE - DO NOT MODIFY
3
+ */
4
+ import { type ValidationResult, BlobRef } from '@atproto/lexicon'
5
+ import { CID } from 'multiformats/cid'
6
+ import { validate as _validate } from '../../../../lexicons'
7
+ import {
8
+ type $Typed,
9
+ is$typed as _is$typed,
10
+ type OmitKey,
11
+ } from '../../../../util'
12
+
13
+ const is$typed = _is$typed,
14
+ validate = _validate
15
+ const id = 'tools.ozone.moderation.getAccountTimeline'
16
+
17
+ export type QueryParams = {
18
+ did: string
19
+ }
20
+ export type InputSchema = undefined
21
+
22
+ export interface OutputSchema {
23
+ timeline: TimelineItem[]
24
+ }
25
+
26
+ export type HandlerInput = void
27
+
28
+ export interface HandlerSuccess {
29
+ encoding: 'application/json'
30
+ body: OutputSchema
31
+ headers?: { [key: string]: string }
32
+ }
33
+
34
+ export interface HandlerError {
35
+ status: number
36
+ message?: string
37
+ error?: 'RepoNotFound'
38
+ }
39
+
40
+ export type HandlerOutput = HandlerError | HandlerSuccess
41
+
42
+ export interface TimelineItem {
43
+ $type?: 'tools.ozone.moderation.getAccountTimeline#timelineItem'
44
+ day: string
45
+ summary: TimelineItemSummary[]
46
+ }
47
+
48
+ const hashTimelineItem = 'timelineItem'
49
+
50
+ export function isTimelineItem<V>(v: V) {
51
+ return is$typed(v, id, hashTimelineItem)
52
+ }
53
+
54
+ export function validateTimelineItem<V>(v: V) {
55
+ return validate<TimelineItem & V>(v, id, hashTimelineItem)
56
+ }
57
+
58
+ export interface TimelineItemSummary {
59
+ $type?: 'tools.ozone.moderation.getAccountTimeline#timelineItemSummary'
60
+ eventSubjectType: 'account' | 'record' | 'chat' | (string & {})
61
+ eventType:
62
+ | 'tools.ozone.moderation.defs#modEventTakedown'
63
+ | 'tools.ozone.moderation.defs#modEventReverseTakedown'
64
+ | 'tools.ozone.moderation.defs#modEventComment'
65
+ | 'tools.ozone.moderation.defs#modEventReport'
66
+ | 'tools.ozone.moderation.defs#modEventLabel'
67
+ | 'tools.ozone.moderation.defs#modEventAcknowledge'
68
+ | 'tools.ozone.moderation.defs#modEventEscalate'
69
+ | 'tools.ozone.moderation.defs#modEventMute'
70
+ | 'tools.ozone.moderation.defs#modEventUnmute'
71
+ | 'tools.ozone.moderation.defs#modEventMuteReporter'
72
+ | 'tools.ozone.moderation.defs#modEventUnmuteReporter'
73
+ | 'tools.ozone.moderation.defs#modEventEmail'
74
+ | 'tools.ozone.moderation.defs#modEventResolveAppeal'
75
+ | 'tools.ozone.moderation.defs#modEventDivert'
76
+ | 'tools.ozone.moderation.defs#modEventTag'
77
+ | 'tools.ozone.moderation.defs#accountEvent'
78
+ | 'tools.ozone.moderation.defs#identityEvent'
79
+ | 'tools.ozone.moderation.defs#recordEvent'
80
+ | 'tools.ozone.moderation.defs#modEventPriorityScore'
81
+ | 'tools.ozone.moderation.defs#ageAssuranceEvent'
82
+ | 'tools.ozone.moderation.defs#ageAssuranceOverrideEvent'
83
+ | 'tools.ozone.moderation.defs#timelineEventPlcCreate'
84
+ | 'tools.ozone.moderation.defs#timelineEventPlcOperation'
85
+ | 'tools.ozone.moderation.defs#timelineEventPlcTombstone'
86
+ | 'tools.ozone.hosting.getAccountHistory#accountCreated'
87
+ | 'tools.ozone.hosting.getAccountHistory#emailConfirmed'
88
+ | 'tools.ozone.hosting.getAccountHistory#passwordUpdated'
89
+ | 'tools.ozone.hosting.getAccountHistory#handleUpdated'
90
+ | (string & {})
91
+ count: number
92
+ }
93
+
94
+ const hashTimelineItemSummary = 'timelineItemSummary'
95
+
96
+ export function isTimelineItemSummary<V>(v: V) {
97
+ return is$typed(v, id, hashTimelineItemSummary)
98
+ }
99
+
100
+ export function validateTimelineItemSummary<V>(v: V) {
101
+ return validate<TimelineItemSummary & V>(v, id, hashTimelineItemSummary)
102
+ }
@@ -64,6 +64,7 @@ import {
64
64
  ReversibleModerationEvent,
65
65
  } from './types'
66
66
  import {
67
+ dateFromDbDatetime,
67
68
  formatLabel,
68
69
  formatLabelRow,
69
70
  getPdsAgentForRepo,
@@ -1545,6 +1546,29 @@ export class ModerationService {
1545
1546
  // Convert map values to an array and return
1546
1547
  return Array.from(statsMap.values())
1547
1548
  }
1549
+
1550
+ async getAccountTimeline(did: string) {
1551
+ const { ref } = this.db.db.dynamic
1552
+ // Without the subquery approach, pg tries to do the sort operation first which can be super expensive when a subjectDid has too many entries
1553
+ const result = await this.db.db
1554
+ .selectFrom(
1555
+ this.db.db
1556
+ .selectFrom('moderation_event')
1557
+ .where('subjectDid', '=', did)
1558
+ .select([
1559
+ dateFromDbDatetime(ref('createdAt')).as('day'),
1560
+ 'subjectUri',
1561
+ 'action',
1562
+ sql<number>`count(*)`.as('count'),
1563
+ ])
1564
+ .groupBy(['day', 'subjectUri', 'action'])
1565
+ .as('results'),
1566
+ )
1567
+ .selectAll()
1568
+ .orderBy('day', 'desc')
1569
+ .execute()
1570
+ return result
1571
+ }
1548
1572
  }
1549
1573
 
1550
1574
  const parseTags = (tags?: string[]) =>
@@ -1,9 +1,11 @@
1
1
  import net from 'node:net'
2
+ import { sql } from 'kysely'
2
3
  import AtpAgent from '@atproto/api'
3
4
  import { cborEncode, noUndefinedVals } from '@atproto/common'
4
5
  import { Keypair } from '@atproto/crypto'
5
6
  import { IdResolver } from '@atproto/identity'
6
7
  import { LabelRow } from '../db/schema/label'
8
+ import { DbRef } from '../db/types'
7
9
  import { Label } from '../lexicon/types/com/atproto/label/defs'
8
10
 
9
11
  export type SignedLabel = Label & { sig: Uint8Array }
@@ -83,3 +85,12 @@ export const getPdsAgentForRepo = async (
83
85
 
84
86
  return { url, agent: new AtpAgent({ service: url }) }
85
87
  }
88
+
89
+ export const dateFromDatetime = (datetime: Date) => {
90
+ const [date] = datetime.toISOString().split('T')
91
+ return date
92
+ }
93
+
94
+ export const dateFromDbDatetime = (dateRef: DbRef) => {
95
+ return sql<string>`SPLIT_PART(${dateRef}, 'T', 1)`
96
+ }
@@ -0,0 +1,36 @@
1
+ // Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+ exports[`account timeline Returns entire timeline of events for a given account 1`] = `
4
+ Array [
5
+ Object {
6
+ "count": 1,
7
+ "eventSubjectType": "record",
8
+ "eventType": "tools.ozone.moderation.defs#modEventReport",
9
+ },
10
+ Object {
11
+ "count": 1,
12
+ "eventSubjectType": "record",
13
+ "eventType": "tools.ozone.moderation.defs#modEventTag",
14
+ },
15
+ Object {
16
+ "count": 2,
17
+ "eventSubjectType": "account",
18
+ "eventType": "tools.ozone.moderation.defs#modEventReport",
19
+ },
20
+ Object {
21
+ "count": 1,
22
+ "eventSubjectType": "account",
23
+ "eventType": "tools.ozone.moderation.defs#modEventTag",
24
+ },
25
+ Object {
26
+ "count": 1,
27
+ "eventSubjectType": "account",
28
+ "eventType": "tools.ozone.moderation.defs#modEventTakedown",
29
+ },
30
+ Object {
31
+ "count": 1,
32
+ "eventSubjectType": "account",
33
+ "eventType": "tools.ozone.moderation.defs#timelineEventPlcOperation",
34
+ },
35
+ ]
36
+ `;
@@ -0,0 +1,85 @@
1
+ import AtpAgent from '@atproto/api'
2
+ import {
3
+ ModeratorClient,
4
+ SeedClient,
5
+ TestNetwork,
6
+ basicSeed,
7
+ } from '@atproto/dev-env'
8
+ import { REASONSPAM } from '../dist/lexicon/types/com/atproto/moderation/defs'
9
+ import { ids } from '../src/lexicon/lexicons'
10
+ import { forSnapshot } from './_util'
11
+
12
+ describe('account timeline', () => {
13
+ let network: TestNetwork
14
+ let sc: SeedClient
15
+ let modClient: ModeratorClient
16
+ let agent: AtpAgent
17
+
18
+ beforeAll(async () => {
19
+ network = await TestNetwork.create({
20
+ dbPostgresSchema: 'ozone_account_timeline_test',
21
+ })
22
+ sc = network.getSeedClient()
23
+ agent = network.ozone.getClient()
24
+ modClient = network.ozone.getModClient()
25
+ await basicSeed(sc)
26
+
27
+ // Trigger some moderation events
28
+ await Promise.all([
29
+ sc.createReport({
30
+ subject: {
31
+ $type: 'com.atproto.admin.defs#repoRef',
32
+ did: sc.dids.alice,
33
+ },
34
+ reasonType: REASONSPAM,
35
+ reportedBy: sc.dids.bob,
36
+ }),
37
+ sc.createReport({
38
+ subject: {
39
+ $type: 'com.atproto.admin.defs#repoRef',
40
+ did: sc.dids.alice,
41
+ },
42
+ reasonType: REASONSPAM,
43
+ reportedBy: sc.dids.carol,
44
+ }),
45
+ sc.createReport({
46
+ subject: {
47
+ $type: 'com.atproto.repo.strongRef',
48
+ uri: sc.posts[sc.dids.alice][0].ref.uriStr,
49
+ cid: sc.posts[sc.dids.alice][0].ref.cidStr,
50
+ },
51
+ reasonType: REASONSPAM,
52
+ reportedBy: sc.dids.bob,
53
+ }),
54
+ ])
55
+ await modClient.performTakedown({
56
+ subject: {
57
+ $type: 'com.atproto.admin.defs#repoRef',
58
+ did: sc.dids.alice,
59
+ },
60
+ })
61
+ await network.processAll()
62
+ })
63
+
64
+ afterAll(async () => {
65
+ await network.close()
66
+ })
67
+
68
+ it('Returns entire timeline of events for a given account', async () => {
69
+ const getAccountTimeline = async (did: string) =>
70
+ agent.tools.ozone.moderation.getAccountTimeline(
71
+ { did },
72
+ {
73
+ headers: await network.ozone.modHeaders(
74
+ ids.ToolsOzoneModerationGetAccountTimeline,
75
+ ),
76
+ },
77
+ )
78
+
79
+ const {
80
+ data: { timeline },
81
+ } = await getAccountTimeline(sc.dids.alice)
82
+
83
+ expect(forSnapshot(timeline[0].summary)).toMatchSnapshot()
84
+ })
85
+ })