Add reporter_stats materialized view and endpoint to fetch reporter stats (#3509)

*  Add reporter_stats materialized view and endpoint to fetch reporter stats

* 🚨 Fix linter issues

*  Change reporter stats query from materialized view to on demand select

* Add "createdAt" as part of the index

---------

Co-authored-by: Matthieu Sieben <matthieu.sieben@gmail.com>
Co-authored-by: Matthieu Sieben <matthieusieben@users.noreply.github.com>
This commit is contained in:
Foysal Ahamed 2025-02-17 21:17:43 +00:00 committed by GitHub
parent f1d323a6ef
commit b41ff4b4e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1000 additions and 0 deletions

View File

@ -812,6 +812,58 @@
"format": "datetime"
}
}
},
"reporterStats": {
"type": "object",
"required": [
"did",
"accountReportCount",
"recordReportCount",
"reportedAccountCount",
"reportedRecordCount",
"takendownAccountCount",
"takendownRecordCount",
"labeledAccountCount",
"labeledRecordCount"
],
"properties": {
"did": {
"type": "string",
"format": "did"
},
"accountReportCount": {
"type": "integer",
"description": "The total number of reports made by the user on accounts."
},
"recordReportCount": {
"type": "integer",
"description": "The total number of reports made by the user on records."
},
"reportedAccountCount": {
"type": "integer",
"description": "The total number of accounts reported by the user."
},
"reportedRecordCount": {
"type": "integer",
"description": "The total number of records reported by the user."
},
"takendownAccountCount": {
"type": "integer",
"description": "The total number of accounts taken down as a result of the user's reports."
},
"takendownRecordCount": {
"type": "integer",
"description": "The total number of records taken down as a result of the user's reports."
},
"labeledAccountCount": {
"type": "integer",
"description": "The total number of accounts labeled as a result of the user's reports."
},
"labeledRecordCount": {
"type": "integer",
"description": "The total number of records labeled as a result of the user's reports."
}
}
}
}
}

View File

@ -0,0 +1,40 @@
{
"lexicon": 1,
"id": "tools.ozone.moderation.getReporterStats",
"defs": {
"main": {
"type": "query",
"description": "Get reporter stats for a list of users.",
"parameters": {
"type": "params",
"required": ["dids"],
"properties": {
"dids": {
"type": "array",
"maxLength": 100,
"items": {
"type": "string",
"format": "did"
}
}
}
},
"output": {
"encoding": "application/json",
"schema": {
"type": "object",
"required": ["stats"],
"properties": {
"stats": {
"type": "array",
"items": {
"type": "ref",
"ref": "tools.ozone.moderation.defs#reporterStats"
}
}
}
}
}
}
}
}

View File

@ -208,6 +208,7 @@ import * as ToolsOzoneModerationGetEvent from './types/tools/ozone/moderation/ge
import * as ToolsOzoneModerationGetRecord from './types/tools/ozone/moderation/getRecord.js'
import * as ToolsOzoneModerationGetRecords from './types/tools/ozone/moderation/getRecords.js'
import * as ToolsOzoneModerationGetRepo from './types/tools/ozone/moderation/getRepo.js'
import * as ToolsOzoneModerationGetReporterStats from './types/tools/ozone/moderation/getReporterStats.js'
import * as ToolsOzoneModerationGetRepos from './types/tools/ozone/moderation/getRepos.js'
import * as ToolsOzoneModerationQueryEvents from './types/tools/ozone/moderation/queryEvents.js'
import * as ToolsOzoneModerationQueryStatuses from './types/tools/ozone/moderation/queryStatuses.js'
@ -437,6 +438,7 @@ export * as ToolsOzoneModerationGetEvent from './types/tools/ozone/moderation/ge
export * as ToolsOzoneModerationGetRecord from './types/tools/ozone/moderation/getRecord.js'
export * as ToolsOzoneModerationGetRecords from './types/tools/ozone/moderation/getRecords.js'
export * as ToolsOzoneModerationGetRepo from './types/tools/ozone/moderation/getRepo.js'
export * as ToolsOzoneModerationGetReporterStats from './types/tools/ozone/moderation/getReporterStats.js'
export * as ToolsOzoneModerationGetRepos from './types/tools/ozone/moderation/getRepos.js'
export * as ToolsOzoneModerationQueryEvents from './types/tools/ozone/moderation/queryEvents.js'
export * as ToolsOzoneModerationQueryStatuses from './types/tools/ozone/moderation/queryStatuses.js'
@ -3722,6 +3724,18 @@ export class ToolsOzoneModerationNS {
})
}
getReporterStats(
params?: ToolsOzoneModerationGetReporterStats.QueryParams,
opts?: ToolsOzoneModerationGetReporterStats.CallOptions,
): Promise<ToolsOzoneModerationGetReporterStats.Response> {
return this._client.call(
'tools.ozone.moderation.getReporterStats',
params,
undefined,
opts,
)
}
getRepos(
params?: ToolsOzoneModerationGetRepos.QueryParams,
opts?: ToolsOzoneModerationGetRepos.CallOptions,

View File

@ -12219,6 +12219,64 @@ export const schemaDict = {
},
},
},
reporterStats: {
type: 'object',
required: [
'did',
'accountReportCount',
'recordReportCount',
'reportedAccountCount',
'reportedRecordCount',
'takendownAccountCount',
'takendownRecordCount',
'labeledAccountCount',
'labeledRecordCount',
],
properties: {
did: {
type: 'string',
format: 'did',
},
accountReportCount: {
type: 'integer',
description:
'The total number of reports made by the user on accounts.',
},
recordReportCount: {
type: 'integer',
description:
'The total number of reports made by the user on records.',
},
reportedAccountCount: {
type: 'integer',
description: 'The total number of accounts reported by the user.',
},
reportedRecordCount: {
type: 'integer',
description: 'The total number of records reported by the user.',
},
takendownAccountCount: {
type: 'integer',
description:
"The total number of accounts taken down as a result of the user's reports.",
},
takendownRecordCount: {
type: 'integer',
description:
"The total number of records taken down as a result of the user's reports.",
},
labeledAccountCount: {
type: 'integer',
description:
"The total number of accounts labeled as a result of the user's reports.",
},
labeledRecordCount: {
type: 'integer',
description:
"The total number of records labeled as a result of the user's reports.",
},
},
},
},
},
ToolsOzoneModerationEmitEvent: {
@ -12430,6 +12488,46 @@ export const schemaDict = {
},
},
},
ToolsOzoneModerationGetReporterStats: {
lexicon: 1,
id: 'tools.ozone.moderation.getReporterStats',
defs: {
main: {
type: 'query',
description: 'Get reporter stats for a list of users.',
parameters: {
type: 'params',
required: ['dids'],
properties: {
dids: {
type: 'array',
maxLength: 100,
items: {
type: 'string',
format: 'did',
},
},
},
},
output: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['stats'],
properties: {
stats: {
type: 'array',
items: {
type: 'ref',
ref: 'lex:tools.ozone.moderation.defs#reporterStats',
},
},
},
},
},
},
},
},
ToolsOzoneModerationGetRepos: {
lexicon: 1,
id: 'tools.ozone.moderation.getRepos',
@ -14133,6 +14231,8 @@ export const ids = {
ToolsOzoneModerationGetRecord: 'tools.ozone.moderation.getRecord',
ToolsOzoneModerationGetRecords: 'tools.ozone.moderation.getRecords',
ToolsOzoneModerationGetRepo: 'tools.ozone.moderation.getRepo',
ToolsOzoneModerationGetReporterStats:
'tools.ozone.moderation.getReporterStats',
ToolsOzoneModerationGetRepos: 'tools.ozone.moderation.getRepos',
ToolsOzoneModerationQueryEvents: 'tools.ozone.moderation.queryEvents',
ToolsOzoneModerationQueryStatuses: 'tools.ozone.moderation.queryStatuses',

View File

@ -836,3 +836,34 @@ export function isRecordHosting<V>(v: V) {
export function validateRecordHosting<V>(v: V) {
return validate<RecordHosting & V>(v, id, hashRecordHosting)
}
export interface ReporterStats {
$type?: 'tools.ozone.moderation.defs#reporterStats'
did: string
/** The total number of reports made by the user on accounts. */
accountReportCount: number
/** The total number of reports made by the user on records. */
recordReportCount: number
/** The total number of accounts reported by the user. */
reportedAccountCount: number
/** The total number of records reported by the user. */
reportedRecordCount: number
/** The total number of accounts taken down as a result of the user's reports. */
takendownAccountCount: number
/** The total number of records taken down as a result of the user's reports. */
takendownRecordCount: number
/** The total number of accounts labeled as a result of the user's reports. */
labeledAccountCount: number
/** The total number of records labeled as a result of the user's reports. */
labeledRecordCount: number
}
const hashReporterStats = 'reporterStats'
export function isReporterStats<V>(v: V) {
return is$typed(v, id, hashReporterStats)
}
export function validateReporterStats<V>(v: V) {
return validate<ReporterStats & V>(v, id, hashReporterStats)
}

View File

@ -0,0 +1,38 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import { HeadersMap, XRPCError } from '@atproto/xrpc'
import { ValidationResult, BlobRef } from '@atproto/lexicon'
import { CID } from 'multiformats/cid'
import { validate as _validate } from '../../../../lexicons'
import { $Typed, is$typed as _is$typed, OmitKey } from '../../../../util'
import type * as ToolsOzoneModerationDefs from './defs.js'
const is$typed = _is$typed,
validate = _validate
const id = 'tools.ozone.moderation.getReporterStats'
export interface QueryParams {
dids: string[]
}
export type InputSchema = undefined
export interface OutputSchema {
stats: ToolsOzoneModerationDefs.ReporterStats[]
}
export interface CallOptions {
signal?: AbortSignal
headers?: HeadersMap
}
export interface Response {
success: boolean
headers: HeadersMap
data: OutputSchema
}
export function toKnownErr(e: any) {
return e
}

View File

@ -45,6 +45,19 @@ export class ModeratorClient {
return result.data
}
async getReporterStats(dids: string[]) {
const result = await this.agent.tools.ozone.moderation.getReporterStats(
{ dids },
{
headers: await this.ozone.modHeaders(
'tools.ozone.moderation.getReporterStats',
'admin',
),
},
)
return result.data
}
async queryEvents(input: QueryEventsParams, role?: ModLevel) {
const result = await this.agent.tools.ozone.moderation.queryEvents(input, {
headers: await this.ozone.modHeaders(

View File

@ -13,6 +13,7 @@ import getEvent from './moderation/getEvent'
import adminGetRecord from './moderation/getRecord'
import adminGetRecords from './moderation/getRecords'
import getRepo from './moderation/getRepo'
import getReporterStats from './moderation/getReporterStats'
import getRepos from './moderation/getRepos'
import queryEvents from './moderation/queryEvents'
import queryStatuses from './moderation/queryStatuses'
@ -72,5 +73,6 @@ export default function (server: Server, ctx: AppContext) {
upsertOption(server, ctx)
listOptions(server, ctx)
removeOptions(server, ctx)
getReporterStats(server, ctx)
return server
}

View File

@ -0,0 +1,18 @@
import { AppContext } from '../../context'
import { Server } from '../../lexicon'
export default function (server: Server, ctx: AppContext) {
server.tools.ozone.moderation.getReporterStats({
auth: ctx.authVerifier.modOrAdminToken,
handler: async ({ params }) => {
const db = ctx.db
const stats = await ctx.modService(db).getReporterStats(params.dids)
return {
encoding: 'application/json',
body: { stats },
}
},
})
}

View File

@ -10,6 +10,7 @@ export class MaterializedViewRefresher extends PeriodicBackgroundTask {
'record_events_stats',
'account_record_events_stats',
'account_record_status_stats',
'reporter_stats',
]) {
if (signal.aborted) break

View File

@ -0,0 +1,38 @@
import { Kysely, sql } from 'kysely'
export async function up(db: Kysely<unknown>): Promise<void> {
await sql`
CREATE INDEX "moderation_event_account_reports_idx"
ON moderation_event("createdBy","subjectDid", "createdAt")
WHERE "subjectUri" IS NULL
AND "action" = 'tools.ozone.moderation.defs#modEventReport'
`.execute(db)
await sql`
CREATE INDEX "moderation_event_record_reports_idx"
ON moderation_event("createdBy","subjectDid","subjectUri", "createdAt")
WHERE "subjectUri" IS NOT NULL
AND "action" = 'tools.ozone.moderation.defs#modEventReport'
`.execute(db)
await sql`
CREATE INDEX "moderation_event_account_actions_ids"
ON moderation_event("subjectDid","action", "createdAt")
WHERE "subjectUri" IS NULL
AND "action" IN ( 'tools.ozone.moderation.defs#modEventTakedown', 'tools.ozone.moderation.defs#modEventLabel')
`.execute(db)
await sql`
CREATE INDEX "moderation_event_record_actions_ids"
ON moderation_event("subjectDid","subjectUri", "action", "createdAt")
WHERE "subjectUri" IS NOT NULL
AND "action" IN ( 'tools.ozone.moderation.defs#modEventTakedown', 'tools.ozone.moderation.defs#modEventLabel')
`.execute(db)
}
export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema.dropIndex('moderation_event_account_reports_idx').execute()
await db.schema.dropIndex('moderation_event_record_reports_idx').execute()
await db.schema.dropIndex('moderation_event_account_actions_ids').execute()
await db.schema.dropIndex('moderation_event_record_actions_ids').execute()
}

View File

@ -19,4 +19,5 @@ export * as _20241018T205730722Z from './20241018T205730722Z-setting'
export * as _20241026T205730722Z from './20241026T205730722Z-add-hosting-status-to-subject-status'
export * as _20241220T144630860Z from './20241220T144630860Z-stats-materialized-views'
export * as _20250204T003647759Z from './20250204T003647759Z-add-subject-priority-score'
export * as _20250211T003647759Z from './20250211T003647759Z-add-reporter-stats-index'
export * as _20250211T132135150Z from './20250211T132135150Z-moderation-event-message-partial-idx'

View File

@ -173,6 +173,7 @@ import * as ToolsOzoneModerationGetEvent from './types/tools/ozone/moderation/ge
import * as ToolsOzoneModerationGetRecord from './types/tools/ozone/moderation/getRecord.js'
import * as ToolsOzoneModerationGetRecords from './types/tools/ozone/moderation/getRecords.js'
import * as ToolsOzoneModerationGetRepo from './types/tools/ozone/moderation/getRepo.js'
import * as ToolsOzoneModerationGetReporterStats from './types/tools/ozone/moderation/getReporterStats.js'
import * as ToolsOzoneModerationGetRepos from './types/tools/ozone/moderation/getRepos.js'
import * as ToolsOzoneModerationQueryEvents from './types/tools/ozone/moderation/queryEvents.js'
import * as ToolsOzoneModerationQueryStatuses from './types/tools/ozone/moderation/queryStatuses.js'
@ -2376,6 +2377,17 @@ export class ToolsOzoneModerationNS {
return this._server.xrpc.method(nsid, cfg)
}
getReporterStats<AV extends AuthVerifier>(
cfg: ConfigOf<
AV,
ToolsOzoneModerationGetReporterStats.Handler<ExtractAuth<AV>>,
ToolsOzoneModerationGetReporterStats.HandlerReqCtx<ExtractAuth<AV>>
>,
) {
const nsid = 'tools.ozone.moderation.getReporterStats' // @ts-ignore
return this._server.xrpc.method(nsid, cfg)
}
getRepos<AV extends AuthVerifier>(
cfg: ConfigOf<
AV,

View File

@ -12219,6 +12219,64 @@ export const schemaDict = {
},
},
},
reporterStats: {
type: 'object',
required: [
'did',
'accountReportCount',
'recordReportCount',
'reportedAccountCount',
'reportedRecordCount',
'takendownAccountCount',
'takendownRecordCount',
'labeledAccountCount',
'labeledRecordCount',
],
properties: {
did: {
type: 'string',
format: 'did',
},
accountReportCount: {
type: 'integer',
description:
'The total number of reports made by the user on accounts.',
},
recordReportCount: {
type: 'integer',
description:
'The total number of reports made by the user on records.',
},
reportedAccountCount: {
type: 'integer',
description: 'The total number of accounts reported by the user.',
},
reportedRecordCount: {
type: 'integer',
description: 'The total number of records reported by the user.',
},
takendownAccountCount: {
type: 'integer',
description:
"The total number of accounts taken down as a result of the user's reports.",
},
takendownRecordCount: {
type: 'integer',
description:
"The total number of records taken down as a result of the user's reports.",
},
labeledAccountCount: {
type: 'integer',
description:
"The total number of accounts labeled as a result of the user's reports.",
},
labeledRecordCount: {
type: 'integer',
description:
"The total number of records labeled as a result of the user's reports.",
},
},
},
},
},
ToolsOzoneModerationEmitEvent: {
@ -12430,6 +12488,46 @@ export const schemaDict = {
},
},
},
ToolsOzoneModerationGetReporterStats: {
lexicon: 1,
id: 'tools.ozone.moderation.getReporterStats',
defs: {
main: {
type: 'query',
description: 'Get reporter stats for a list of users.',
parameters: {
type: 'params',
required: ['dids'],
properties: {
dids: {
type: 'array',
maxLength: 100,
items: {
type: 'string',
format: 'did',
},
},
},
},
output: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['stats'],
properties: {
stats: {
type: 'array',
items: {
type: 'ref',
ref: 'lex:tools.ozone.moderation.defs#reporterStats',
},
},
},
},
},
},
},
},
ToolsOzoneModerationGetRepos: {
lexicon: 1,
id: 'tools.ozone.moderation.getRepos',
@ -14133,6 +14231,8 @@ export const ids = {
ToolsOzoneModerationGetRecord: 'tools.ozone.moderation.getRecord',
ToolsOzoneModerationGetRecords: 'tools.ozone.moderation.getRecords',
ToolsOzoneModerationGetRepo: 'tools.ozone.moderation.getRepo',
ToolsOzoneModerationGetReporterStats:
'tools.ozone.moderation.getReporterStats',
ToolsOzoneModerationGetRepos: 'tools.ozone.moderation.getRepos',
ToolsOzoneModerationQueryEvents: 'tools.ozone.moderation.queryEvents',
ToolsOzoneModerationQueryStatuses: 'tools.ozone.moderation.queryStatuses',

View File

@ -836,3 +836,34 @@ export function isRecordHosting<V>(v: V) {
export function validateRecordHosting<V>(v: V) {
return validate<RecordHosting & V>(v, id, hashRecordHosting)
}
export interface ReporterStats {
$type?: 'tools.ozone.moderation.defs#reporterStats'
did: string
/** The total number of reports made by the user on accounts. */
accountReportCount: number
/** The total number of reports made by the user on records. */
recordReportCount: number
/** The total number of accounts reported by the user. */
reportedAccountCount: number
/** The total number of records reported by the user. */
reportedRecordCount: number
/** The total number of accounts taken down as a result of the user's reports. */
takendownAccountCount: number
/** The total number of records taken down as a result of the user's reports. */
takendownRecordCount: number
/** The total number of accounts labeled as a result of the user's reports. */
labeledAccountCount: number
/** The total number of records labeled as a result of the user's reports. */
labeledRecordCount: number
}
const hashReporterStats = 'reporterStats'
export function isReporterStats<V>(v: V) {
return is$typed(v, id, hashReporterStats)
}
export function validateReporterStats<V>(v: V) {
return validate<ReporterStats & V>(v, id, hashReporterStats)
}

View File

@ -0,0 +1,50 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import express from 'express'
import { ValidationResult, BlobRef } from '@atproto/lexicon'
import { CID } from 'multiformats/cid'
import { validate as _validate } from '../../../../lexicons'
import { $Typed, is$typed as _is$typed, OmitKey } from '../../../../util'
import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server'
import type * as ToolsOzoneModerationDefs from './defs.js'
const is$typed = _is$typed,
validate = _validate
const id = 'tools.ozone.moderation.getReporterStats'
export interface QueryParams {
dids: string[]
}
export type InputSchema = undefined
export interface OutputSchema {
stats: ToolsOzoneModerationDefs.ReporterStats[]
}
export type HandlerInput = undefined
export interface HandlerSuccess {
encoding: 'application/json'
body: OutputSchema
headers?: { [key: string]: string }
}
export interface HandlerError {
status: number
message?: string
}
export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough
export type HandlerReqCtx<HA extends HandlerAuth = never> = {
auth: HA
params: QueryParams
input: HandlerInput
req: express.Request
res: express.Response
resetRouteRateLimits: () => Promise<void>
}
export type Handler<HA extends HandlerAuth = never> = (
ctx: HandlerReqCtx<HA>,
) => Promise<HandlerOutput> | HandlerOutput

View File

@ -58,6 +58,8 @@ import {
ModerationEventRow,
ModerationSubjectStatusRow,
ModerationSubjectStatusRowWithHandle,
ReporterStats,
ReporterStatsResult,
ReversibleModerationEvent,
} from './types'
import { formatLabel, formatLabelRow, signLabel } from './util'
@ -1272,6 +1274,130 @@ export class ModerationService {
throw new InvalidRequestError('Email was accepted but not sent')
}
}
buildModerationQuery(
subjectType: 'account' | 'record',
createdByDids: string[],
isActionQuery: boolean,
): Promise<(Partial<ReporterStatsResult> & { did: string })[]> {
const isAccount = subjectType === 'account'
const actionTypes = [
'tools.ozone.moderation.defs#modEventTakedown',
'tools.ozone.moderation.defs#modEventLabel',
] as const
const query = this.db.db
.selectFrom('moderation_event as reports')
.where(
'reports.action',
'=',
'tools.ozone.moderation.defs#modEventReport',
)
.where('reports.subjectUri', isAccount ? 'is' : 'is not', null)
.where('reports.createdBy', 'in', createdByDids)
.select(['reports.createdBy as did'])
if (isActionQuery) {
return query
.leftJoin('moderation_event as actions', (join) =>
join
.onRef('actions.subjectDid', '=', 'reports.subjectDid')
.on('actions.subjectUri', isAccount ? 'is' : 'is not', null)
.onRef('actions.createdAt', '>', 'reports.createdAt')
.on('actions.action', 'in', actionTypes),
)
.select([
() =>
sql<number>`COUNT(DISTINCT actions."subjectDid") FILTER (
WHERE actions."action" = 'tools.ozone.moderation.defs#modEventTakedown'
)`.as(`takendown${isAccount ? 'Account' : 'Record'}Count`),
() =>
sql<number>`COUNT(DISTINCT actions."subjectDid") FILTER (
WHERE actions."action" = 'tools.ozone.moderation.defs#modEventLabel'
)`.as(`labeled${isAccount ? 'Account' : 'Record'}Count`),
])
.groupBy('reports.createdBy')
.execute()
}
return query
.select([
(eb) =>
eb.fn.count<number>('reports.id').as(`${subjectType}ReportCount`),
(eb) =>
eb.fn
.count<number>(
isAccount ? 'reports.subjectDid' : 'reports.subjectUri',
)
.distinct()
.as(`reported${isAccount ? 'Account' : 'Record'}Count`),
])
.groupBy('reports.createdBy')
.execute()
}
async getReporterStats(dids: string[]) {
const [accountReports, recordReports, accountActions, recordActions] =
await Promise.all([
this.buildModerationQuery('account', dids, false),
this.buildModerationQuery('record', dids, false),
this.buildModerationQuery('account', dids, true),
this.buildModerationQuery('record', dids, true),
])
// Create a map to hold the aggregated stats for each `did`
const statsMap = new Map<string, ReporterStats>()
// Helper function to ensure a `did` entry exists in the map
const ensureDidEntry = (did: string) => {
if (!statsMap.has(did)) {
statsMap.set(did, {
did,
accountReportCount: 0,
recordReportCount: 0,
reportedAccountCount: 0,
reportedRecordCount: 0,
takendownAccountCount: 0,
takendownRecordCount: 0,
labeledAccountCount: 0,
labeledRecordCount: 0,
})
}
return statsMap.get(did)!
}
// Merge accountReports
for (const report of accountReports) {
const entry = ensureDidEntry(report.did)
entry.accountReportCount = report.accountReportCount ?? 0
entry.reportedAccountCount = report.reportedAccountCount ?? 0
}
// Merge recordReports
for (const report of recordReports) {
const entry = ensureDidEntry(report.did)
entry.recordReportCount = report.recordReportCount ?? 0
entry.reportedRecordCount = report.reportedRecordCount ?? 0
}
// Merge accountActions
for (const action of accountActions) {
const entry = ensureDidEntry(action.did)
entry.takendownAccountCount = action.takendownAccountCount ?? 0
entry.labeledAccountCount = action.labeledAccountCount ?? 0
}
// Merge recordActions
for (const action of recordActions) {
const entry = ensureDidEntry(action.did)
entry.takendownRecordCount = action.takendownRecordCount ?? 0
entry.labeledRecordCount = action.labeledRecordCount ?? 0
}
// Convert map values to an array and return
return Array.from(statsMap.values())
}
}
const parseTags = (tags?: string[]) =>

View File

@ -65,3 +65,26 @@ type RecordHostingView = {
export type ModerationSubjectHostingView =
| AccountHostingView
| RecordHostingView
export type ReporterStats = {
did: string
accountReportCount: number
recordReportCount: number
reportedAccountCount: number
reportedRecordCount: number
takendownAccountCount: number
takendownRecordCount: number
labeledAccountCount: number
labeledRecordCount: number
}
export type ReporterStatsResult = {
accountReportCount?: number
recordReportCount?: number
reportedAccountCount?: number
reportedRecordCount?: number
takendownAccountCount?: number
takendownRecordCount?: number
labeledAccountCount?: number
labeledRecordCount?: number
}

View File

@ -0,0 +1,117 @@
import {
ComAtprotoModerationDefs,
ToolsOzoneModerationDefs,
} from '@atproto/api'
import {
ModeratorClient,
SeedClient,
TestNetwork,
basicSeed,
} from '@atproto/dev-env'
describe('reporter-stats', () => {
let network: TestNetwork
let sc: SeedClient
let modClient: ModeratorClient
beforeAll(async () => {
network = await TestNetwork.create({
dbPostgresSchema: 'ozone_reporter_stats',
ozone: {
dbMaterializedViewRefreshIntervalMs: 1000,
},
})
sc = network.getSeedClient()
modClient = network.ozone.getModClient()
await basicSeed(sc)
await network.processAll()
})
afterAll(async () => {
await network.close()
})
const getReporterStats = async (
did: string,
): Promise<ToolsOzoneModerationDefs.ReporterStats | undefined> => {
const { stats } = await modClient.getReporterStats([did])
return stats[0]
}
it('updates reporter stats based on actions', async () => {
const bobsPostSubject = {
$type: 'com.atproto.repo.strongRef',
uri: sc.posts[sc.dids.bob][1].ref.uriStr,
cid: sc.posts[sc.dids.bob][1].ref.cidStr,
}
const carolsAccountSubject = {
$type: 'com.atproto.admin.defs#repoRef',
did: sc.dids.carol,
}
await Promise.all([
sc.createReport({
reportedBy: sc.dids.alice,
reasonType: ComAtprotoModerationDefs.REASONMISLEADING,
reason: 'misleading',
subject: bobsPostSubject,
}),
sc.createReport({
reportedBy: sc.dids.alice,
reasonType: ComAtprotoModerationDefs.REASONOTHER,
reason: 'test',
subject: bobsPostSubject,
}),
sc.createReport({
reportedBy: sc.dids.alice,
reasonType: ComAtprotoModerationDefs.REASONMISLEADING,
reason: 'misleading',
subject: carolsAccountSubject,
}),
])
await network.processAll()
const statsAfterReport = await getReporterStats(sc.dids.alice)
expect(statsAfterReport).toMatchObject({
did: sc.dids.alice,
accountReportCount: 1,
recordReportCount: 2,
reportedAccountCount: 1,
reportedRecordCount: 1,
takendownAccountCount: 0,
takendownRecordCount: 0,
labeledAccountCount: 0,
labeledRecordCount: 0,
})
await Promise.all([
modClient.performTakedown({
subject: bobsPostSubject,
policies: ['trolling'],
}),
modClient.emitEvent({
subject: carolsAccountSubject,
event: {
$type: 'tools.ozone.moderation.defs#modEventLabel',
createLabelVals: ['spam'],
negateLabelVals: [],
},
}),
])
await network.processAll()
await new Promise((resolve) => setTimeout(resolve, 1000))
const statsAfterAction = await getReporterStats(sc.dids.alice)
expect(statsAfterAction).toMatchObject({
did: sc.dids.alice,
accountReportCount: 1,
recordReportCount: 2,
reportedAccountCount: 1,
reportedRecordCount: 1,
takendownAccountCount: 0,
takendownRecordCount: 1,
labeledAccountCount: 1,
labeledRecordCount: 0,
})
})
})

View File

@ -173,6 +173,7 @@ import * as ToolsOzoneModerationGetEvent from './types/tools/ozone/moderation/ge
import * as ToolsOzoneModerationGetRecord from './types/tools/ozone/moderation/getRecord.js'
import * as ToolsOzoneModerationGetRecords from './types/tools/ozone/moderation/getRecords.js'
import * as ToolsOzoneModerationGetRepo from './types/tools/ozone/moderation/getRepo.js'
import * as ToolsOzoneModerationGetReporterStats from './types/tools/ozone/moderation/getReporterStats.js'
import * as ToolsOzoneModerationGetRepos from './types/tools/ozone/moderation/getRepos.js'
import * as ToolsOzoneModerationQueryEvents from './types/tools/ozone/moderation/queryEvents.js'
import * as ToolsOzoneModerationQueryStatuses from './types/tools/ozone/moderation/queryStatuses.js'
@ -2376,6 +2377,17 @@ export class ToolsOzoneModerationNS {
return this._server.xrpc.method(nsid, cfg)
}
getReporterStats<AV extends AuthVerifier>(
cfg: ConfigOf<
AV,
ToolsOzoneModerationGetReporterStats.Handler<ExtractAuth<AV>>,
ToolsOzoneModerationGetReporterStats.HandlerReqCtx<ExtractAuth<AV>>
>,
) {
const nsid = 'tools.ozone.moderation.getReporterStats' // @ts-ignore
return this._server.xrpc.method(nsid, cfg)
}
getRepos<AV extends AuthVerifier>(
cfg: ConfigOf<
AV,

View File

@ -12219,6 +12219,64 @@ export const schemaDict = {
},
},
},
reporterStats: {
type: 'object',
required: [
'did',
'accountReportCount',
'recordReportCount',
'reportedAccountCount',
'reportedRecordCount',
'takendownAccountCount',
'takendownRecordCount',
'labeledAccountCount',
'labeledRecordCount',
],
properties: {
did: {
type: 'string',
format: 'did',
},
accountReportCount: {
type: 'integer',
description:
'The total number of reports made by the user on accounts.',
},
recordReportCount: {
type: 'integer',
description:
'The total number of reports made by the user on records.',
},
reportedAccountCount: {
type: 'integer',
description: 'The total number of accounts reported by the user.',
},
reportedRecordCount: {
type: 'integer',
description: 'The total number of records reported by the user.',
},
takendownAccountCount: {
type: 'integer',
description:
"The total number of accounts taken down as a result of the user's reports.",
},
takendownRecordCount: {
type: 'integer',
description:
"The total number of records taken down as a result of the user's reports.",
},
labeledAccountCount: {
type: 'integer',
description:
"The total number of accounts labeled as a result of the user's reports.",
},
labeledRecordCount: {
type: 'integer',
description:
"The total number of records labeled as a result of the user's reports.",
},
},
},
},
},
ToolsOzoneModerationEmitEvent: {
@ -12430,6 +12488,46 @@ export const schemaDict = {
},
},
},
ToolsOzoneModerationGetReporterStats: {
lexicon: 1,
id: 'tools.ozone.moderation.getReporterStats',
defs: {
main: {
type: 'query',
description: 'Get reporter stats for a list of users.',
parameters: {
type: 'params',
required: ['dids'],
properties: {
dids: {
type: 'array',
maxLength: 100,
items: {
type: 'string',
format: 'did',
},
},
},
},
output: {
encoding: 'application/json',
schema: {
type: 'object',
required: ['stats'],
properties: {
stats: {
type: 'array',
items: {
type: 'ref',
ref: 'lex:tools.ozone.moderation.defs#reporterStats',
},
},
},
},
},
},
},
},
ToolsOzoneModerationGetRepos: {
lexicon: 1,
id: 'tools.ozone.moderation.getRepos',
@ -14133,6 +14231,8 @@ export const ids = {
ToolsOzoneModerationGetRecord: 'tools.ozone.moderation.getRecord',
ToolsOzoneModerationGetRecords: 'tools.ozone.moderation.getRecords',
ToolsOzoneModerationGetRepo: 'tools.ozone.moderation.getRepo',
ToolsOzoneModerationGetReporterStats:
'tools.ozone.moderation.getReporterStats',
ToolsOzoneModerationGetRepos: 'tools.ozone.moderation.getRepos',
ToolsOzoneModerationQueryEvents: 'tools.ozone.moderation.queryEvents',
ToolsOzoneModerationQueryStatuses: 'tools.ozone.moderation.queryStatuses',

View File

@ -836,3 +836,34 @@ export function isRecordHosting<V>(v: V) {
export function validateRecordHosting<V>(v: V) {
return validate<RecordHosting & V>(v, id, hashRecordHosting)
}
export interface ReporterStats {
$type?: 'tools.ozone.moderation.defs#reporterStats'
did: string
/** The total number of reports made by the user on accounts. */
accountReportCount: number
/** The total number of reports made by the user on records. */
recordReportCount: number
/** The total number of accounts reported by the user. */
reportedAccountCount: number
/** The total number of records reported by the user. */
reportedRecordCount: number
/** The total number of accounts taken down as a result of the user's reports. */
takendownAccountCount: number
/** The total number of records taken down as a result of the user's reports. */
takendownRecordCount: number
/** The total number of accounts labeled as a result of the user's reports. */
labeledAccountCount: number
/** The total number of records labeled as a result of the user's reports. */
labeledRecordCount: number
}
const hashReporterStats = 'reporterStats'
export function isReporterStats<V>(v: V) {
return is$typed(v, id, hashReporterStats)
}
export function validateReporterStats<V>(v: V) {
return validate<ReporterStats & V>(v, id, hashReporterStats)
}

View File

@ -0,0 +1,50 @@
/**
* GENERATED CODE - DO NOT MODIFY
*/
import express from 'express'
import { ValidationResult, BlobRef } from '@atproto/lexicon'
import { CID } from 'multiformats/cid'
import { validate as _validate } from '../../../../lexicons'
import { $Typed, is$typed as _is$typed, OmitKey } from '../../../../util'
import { HandlerAuth, HandlerPipeThrough } from '@atproto/xrpc-server'
import type * as ToolsOzoneModerationDefs from './defs.js'
const is$typed = _is$typed,
validate = _validate
const id = 'tools.ozone.moderation.getReporterStats'
export interface QueryParams {
dids: string[]
}
export type InputSchema = undefined
export interface OutputSchema {
stats: ToolsOzoneModerationDefs.ReporterStats[]
}
export type HandlerInput = undefined
export interface HandlerSuccess {
encoding: 'application/json'
body: OutputSchema
headers?: { [key: string]: string }
}
export interface HandlerError {
status: number
message?: string
}
export type HandlerOutput = HandlerError | HandlerSuccess | HandlerPipeThrough
export type HandlerReqCtx<HA extends HandlerAuth = never> = {
auth: HA
params: QueryParams
input: HandlerInput
req: express.Request
res: express.Response
resetRouteRateLimits: () => Promise<void>
}
export type Handler<HA extends HandlerAuth = never> = (
ctx: HandlerReqCtx<HA>,
) => Promise<HandlerOutput> | HandlerOutput