✨ Allow appealing a moderator decision through special report type (#1969)
* ✨ Allow appealing a moderator decision through special report type * ✨ Allow querying subjects by appealed status * ✨ Move to appealed boolean state column * ✨ Remove leftover * ✨ Move appealed status to new boolean column * ✨ Throw when non-author attempts to appeal a subject * 🚨 Appease the linter gods * build --------- Co-authored-by: Devin Ivy <devinivy@gmail.com>
This commit is contained in:
parent
ad0d976188
commit
5e7b0136da
.github/workflows
lexicons/com/atproto
packages
api/src/client
bsky
src
api/com/atproto
db
lexicon
services/moderation
tests/admin
dev-env/src
lex-cli/src/codegen
pds/src
api/com/atproto/moderation
lexicon
@ -3,7 +3,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- bsky-node-clustering
|
||||
- appeal-report
|
||||
env:
|
||||
REGISTRY: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_REGISTRY }}
|
||||
USERNAME: ${{ secrets.AWS_ECR_REGISTRY_USEAST2_PACKAGES_USERNAME }}
|
||||
|
@ -69,7 +69,8 @@
|
||||
"#modEventLabel",
|
||||
"#modEventAcknowledge",
|
||||
"#modEventEscalate",
|
||||
"#modEventMute"
|
||||
"#modEventMute",
|
||||
"#modEventResolveAppeal"
|
||||
]
|
||||
},
|
||||
"subject": {
|
||||
@ -167,9 +168,18 @@
|
||||
"type": "string",
|
||||
"format": "datetime"
|
||||
},
|
||||
"lastAppealedAt": {
|
||||
"type": "string",
|
||||
"format": "datetime",
|
||||
"description": "Timestamp referencing when the author of the subject appealed a moderation action"
|
||||
},
|
||||
"takendown": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"appealed": {
|
||||
"type": "boolean",
|
||||
"description": "True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators."
|
||||
},
|
||||
"suspendUntil": {
|
||||
"type": "string",
|
||||
"format": "datetime"
|
||||
@ -469,6 +479,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"modEventResolveAppeal": {
|
||||
"type": "object",
|
||||
"description": "Resolve appeal on a subject",
|
||||
"properties": {
|
||||
"comment": {
|
||||
"type": "string",
|
||||
"description": "Describe resolution."
|
||||
}
|
||||
}
|
||||
},
|
||||
"modEventComment": {
|
||||
"type": "object",
|
||||
"description": "Add a comment to a subject",
|
||||
|
@ -64,6 +64,10 @@
|
||||
"type": "boolean",
|
||||
"description": "Get subjects that were taken down"
|
||||
},
|
||||
"appealed": {
|
||||
"type": "boolean",
|
||||
"description": "Get subjects in unresolved appealed status"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
|
@ -10,7 +10,8 @@
|
||||
"com.atproto.moderation.defs#reasonMisleading",
|
||||
"com.atproto.moderation.defs#reasonSexual",
|
||||
"com.atproto.moderation.defs#reasonRude",
|
||||
"com.atproto.moderation.defs#reasonOther"
|
||||
"com.atproto.moderation.defs#reasonOther",
|
||||
"com.atproto.moderation.defs#reasonAppeal"
|
||||
]
|
||||
},
|
||||
"reasonSpam": {
|
||||
@ -36,6 +37,10 @@
|
||||
"reasonOther": {
|
||||
"type": "token",
|
||||
"description": "Other: reports not falling under another report category"
|
||||
},
|
||||
"reasonAppeal": {
|
||||
"type": "token",
|
||||
"description": "Appeal: appeal a previously taken moderation action"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -297,6 +297,7 @@ export const COM_ATPROTO_MODERATION = {
|
||||
DefsReasonSexual: 'com.atproto.moderation.defs#reasonSexual',
|
||||
DefsReasonRude: 'com.atproto.moderation.defs#reasonRude',
|
||||
DefsReasonOther: 'com.atproto.moderation.defs#reasonOther',
|
||||
DefsReasonAppeal: 'com.atproto.moderation.defs#reasonAppeal',
|
||||
}
|
||||
export const APP_BSKY_GRAPH = {
|
||||
DefsModlist: 'app.bsky.graph.defs#modlist',
|
||||
|
@ -102,6 +102,7 @@ export const schemaDict = {
|
||||
'lex:com.atproto.admin.defs#modEventAcknowledge',
|
||||
'lex:com.atproto.admin.defs#modEventEscalate',
|
||||
'lex:com.atproto.admin.defs#modEventMute',
|
||||
'lex:com.atproto.admin.defs#modEventResolveAppeal',
|
||||
],
|
||||
},
|
||||
subject: {
|
||||
@ -237,9 +238,20 @@ export const schemaDict = {
|
||||
type: 'string',
|
||||
format: 'datetime',
|
||||
},
|
||||
lastAppealedAt: {
|
||||
type: 'string',
|
||||
format: 'datetime',
|
||||
description:
|
||||
'Timestamp referencing when the author of the subject appealed a moderation action',
|
||||
},
|
||||
takendown: {
|
||||
type: 'boolean',
|
||||
},
|
||||
appealed: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators.',
|
||||
},
|
||||
suspendUntil: {
|
||||
type: 'string',
|
||||
format: 'datetime',
|
||||
@ -717,6 +729,16 @@ export const schemaDict = {
|
||||
},
|
||||
},
|
||||
},
|
||||
modEventResolveAppeal: {
|
||||
type: 'object',
|
||||
description: 'Resolve appeal on a subject',
|
||||
properties: {
|
||||
comment: {
|
||||
type: 'string',
|
||||
description: 'Describe resolution.',
|
||||
},
|
||||
},
|
||||
},
|
||||
modEventComment: {
|
||||
type: 'object',
|
||||
description: 'Add a comment to a subject',
|
||||
@ -1361,6 +1383,10 @@ export const schemaDict = {
|
||||
type: 'boolean',
|
||||
description: 'Get subjects that were taken down',
|
||||
},
|
||||
appealed: {
|
||||
type: 'boolean',
|
||||
description: 'Get subjects in unresolved appealed status',
|
||||
},
|
||||
limit: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
@ -1946,6 +1972,7 @@ export const schemaDict = {
|
||||
'com.atproto.moderation.defs#reasonSexual',
|
||||
'com.atproto.moderation.defs#reasonRude',
|
||||
'com.atproto.moderation.defs#reasonOther',
|
||||
'com.atproto.moderation.defs#reasonAppeal',
|
||||
],
|
||||
},
|
||||
reasonSpam: {
|
||||
@ -1973,6 +2000,10 @@ export const schemaDict = {
|
||||
type: 'token',
|
||||
description: 'Other: reports not falling under another report category',
|
||||
},
|
||||
reasonAppeal: {
|
||||
type: 'token',
|
||||
description: 'Appeal: appeal a previously taken moderation action',
|
||||
},
|
||||
},
|
||||
},
|
||||
ComAtprotoRepoApplyWrites: {
|
||||
|
@ -76,6 +76,7 @@ export interface ModEventViewDetail {
|
||||
| ModEventAcknowledge
|
||||
| ModEventEscalate
|
||||
| ModEventMute
|
||||
| ModEventResolveAppeal
|
||||
| { $type: string; [k: string]: unknown }
|
||||
subject:
|
||||
| RepoView
|
||||
@ -147,7 +148,11 @@ export interface SubjectStatusView {
|
||||
lastReviewedBy?: string
|
||||
lastReviewedAt?: string
|
||||
lastReportedAt?: string
|
||||
/** Timestamp referencing when the author of the subject appealed a moderation action */
|
||||
lastAppealedAt?: string
|
||||
takendown?: boolean
|
||||
/** True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. */
|
||||
appealed?: boolean
|
||||
suspendUntil?: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
@ -538,6 +543,27 @@ export function validateModEventReverseTakedown(v: unknown): ValidationResult {
|
||||
return lexicons.validate('com.atproto.admin.defs#modEventReverseTakedown', v)
|
||||
}
|
||||
|
||||
/** Resolve appeal on a subject */
|
||||
export interface ModEventResolveAppeal {
|
||||
/** Describe resolution. */
|
||||
comment?: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
export function isModEventResolveAppeal(
|
||||
v: unknown,
|
||||
): v is ModEventResolveAppeal {
|
||||
return (
|
||||
isObj(v) &&
|
||||
hasProp(v, '$type') &&
|
||||
v.$type === 'com.atproto.admin.defs#modEventResolveAppeal'
|
||||
)
|
||||
}
|
||||
|
||||
export function validateModEventResolveAppeal(v: unknown): ValidationResult {
|
||||
return lexicons.validate('com.atproto.admin.defs#modEventResolveAppeal', v)
|
||||
}
|
||||
|
||||
/** Add a comment to a subject */
|
||||
export interface ModEventComment {
|
||||
comment: string
|
||||
|
@ -31,6 +31,8 @@ export interface QueryParams {
|
||||
sortDirection?: 'asc' | 'desc'
|
||||
/** Get subjects that were taken down */
|
||||
takendown?: boolean
|
||||
/** Get subjects in unresolved appealed status */
|
||||
appealed?: boolean
|
||||
limit?: number
|
||||
cursor?: string
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ export type ReasonType =
|
||||
| 'com.atproto.moderation.defs#reasonSexual'
|
||||
| 'com.atproto.moderation.defs#reasonRude'
|
||||
| 'com.atproto.moderation.defs#reasonOther'
|
||||
| 'com.atproto.moderation.defs#reasonAppeal'
|
||||
| (string & {})
|
||||
|
||||
/** Spam: frequent unwanted promotion, replies, mentions */
|
||||
@ -27,3 +28,5 @@ export const REASONSEXUAL = 'com.atproto.moderation.defs#reasonSexual'
|
||||
export const REASONRUDE = 'com.atproto.moderation.defs#reasonRude'
|
||||
/** Other: reports not falling under another report category */
|
||||
export const REASONOTHER = 'com.atproto.moderation.defs#reasonOther'
|
||||
/** Appeal: appeal a previously taken moderation action */
|
||||
export const REASONAPPEAL = 'com.atproto.moderation.defs#reasonAppeal'
|
||||
|
@ -9,6 +9,7 @@ export default function (server: Server, ctx: AppContext) {
|
||||
const {
|
||||
subject,
|
||||
takendown,
|
||||
appealed,
|
||||
reviewState,
|
||||
reviewedAfter,
|
||||
reviewedBefore,
|
||||
@ -28,6 +29,7 @@ export default function (server: Server, ctx: AppContext) {
|
||||
reviewState: getReviewState(reviewState),
|
||||
subject,
|
||||
takendown,
|
||||
appealed,
|
||||
reviewedAfter,
|
||||
reviewedBefore,
|
||||
reportedAfter,
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { AuthRequiredError } from '@atproto/xrpc-server'
|
||||
import { AuthRequiredError, ForbiddenError } from '@atproto/xrpc-server'
|
||||
import { Server } from '../../../../lexicon'
|
||||
import AppContext from '../../../../context'
|
||||
import { getReasonType, getSubject } from './util'
|
||||
import { softDeleted } from '../../../../db/util'
|
||||
import { REASONAPPEAL } from '../../../../lexicon/types/com/atproto/moderation/defs'
|
||||
|
||||
export default function (server: Server, ctx: AppContext) {
|
||||
server.com.atproto.moderation.createReport({
|
||||
@ -22,12 +23,22 @@ export default function (server: Server, ctx: AppContext) {
|
||||
}
|
||||
}
|
||||
|
||||
const reportReasonType = getReasonType(reasonType)
|
||||
const reportSubject = getSubject(subject)
|
||||
const subjectDid =
|
||||
'did' in reportSubject ? reportSubject.did : reportSubject.uri.host
|
||||
|
||||
// If the report is an appeal, the requester must be the author of the subject
|
||||
if (reasonType === REASONAPPEAL && requester !== subjectDid) {
|
||||
throw new ForbiddenError('You cannot appeal this report')
|
||||
}
|
||||
|
||||
const report = await db.transaction(async (dbTxn) => {
|
||||
const moderationTxn = ctx.services.moderation(dbTxn)
|
||||
return moderationTxn.report({
|
||||
reasonType: getReasonType(reasonType),
|
||||
reasonType: reportReasonType,
|
||||
reason,
|
||||
subject: getSubject(subject),
|
||||
subject: reportSubject,
|
||||
reportedBy: requester || ctx.cfg.serverDid,
|
||||
})
|
||||
})
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
REASONRUDE,
|
||||
REASONSEXUAL,
|
||||
REASONVIOLATION,
|
||||
REASONAPPEAL,
|
||||
} from '../../../../lexicon/types/com/atproto/moderation/defs'
|
||||
import {
|
||||
REVIEWCLOSED,
|
||||
@ -73,6 +74,7 @@ const reasonTypes = new Set([
|
||||
REASONRUDE,
|
||||
REASONSEXUAL,
|
||||
REASONVIOLATION,
|
||||
REASONAPPEAL,
|
||||
])
|
||||
|
||||
const eventTypes = new Set([
|
||||
|
@ -0,0 +1,23 @@
|
||||
import { Kysely } from 'kysely'
|
||||
|
||||
export async function up(db: Kysely<unknown>): Promise<void> {
|
||||
await db.schema
|
||||
.alterTable('moderation_subject_status')
|
||||
.addColumn('lastAppealedAt', 'varchar')
|
||||
.execute()
|
||||
await db.schema
|
||||
.alterTable('moderation_subject_status')
|
||||
.addColumn('appealed', 'boolean')
|
||||
.execute()
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<unknown>): Promise<void> {
|
||||
await db.schema
|
||||
.alterTable('moderation_subject_status')
|
||||
.dropColumn('lastAppealedAt')
|
||||
.execute()
|
||||
await db.schema
|
||||
.alterTable('moderation_subject_status')
|
||||
.dropColumn('appealed')
|
||||
.execute()
|
||||
}
|
@ -32,3 +32,4 @@ export * as _20230920T213858047Z from './20230920T213858047Z-add-tags-to-post'
|
||||
export * as _20230929T192920807Z from './20230929T192920807Z-record-cursor-indexes'
|
||||
export * as _20231003T202833377Z from './20231003T202833377Z-create-moderation-subject-status'
|
||||
export * as _20231205T000257238Z from './20231205T000257238Z-remove-did-cache'
|
||||
export * as _20231213T181744386Z from './20231213T181744386Z-moderation-subject-appeal'
|
||||
|
@ -20,6 +20,7 @@ export interface ModerationEvent {
|
||||
| 'com.atproto.admin.defs#modEventMute'
|
||||
| 'com.atproto.admin.defs#modEventReverseTakedown'
|
||||
| 'com.atproto.admin.defs#modEventEmail'
|
||||
| 'com.atproto.admin.defs#modEventResolveAppeal'
|
||||
subjectType: 'com.atproto.admin.defs#repoRef' | 'com.atproto.repo.strongRef'
|
||||
subjectDid: string
|
||||
subjectUri: string | null
|
||||
@ -47,9 +48,11 @@ export interface ModerationSubjectStatus {
|
||||
lastReviewedBy: string | null
|
||||
lastReviewedAt: string | null
|
||||
lastReportedAt: string | null
|
||||
lastAppealedAt: string | null
|
||||
muteUntil: string | null
|
||||
suspendUntil: string | null
|
||||
takendown: boolean
|
||||
appealed: boolean | null
|
||||
comment: string | null
|
||||
}
|
||||
|
||||
|
@ -135,6 +135,7 @@ export const COM_ATPROTO_MODERATION = {
|
||||
DefsReasonSexual: 'com.atproto.moderation.defs#reasonSexual',
|
||||
DefsReasonRude: 'com.atproto.moderation.defs#reasonRude',
|
||||
DefsReasonOther: 'com.atproto.moderation.defs#reasonOther',
|
||||
DefsReasonAppeal: 'com.atproto.moderation.defs#reasonAppeal',
|
||||
}
|
||||
export const APP_BSKY_GRAPH = {
|
||||
DefsModlist: 'app.bsky.graph.defs#modlist',
|
||||
|
@ -102,6 +102,7 @@ export const schemaDict = {
|
||||
'lex:com.atproto.admin.defs#modEventAcknowledge',
|
||||
'lex:com.atproto.admin.defs#modEventEscalate',
|
||||
'lex:com.atproto.admin.defs#modEventMute',
|
||||
'lex:com.atproto.admin.defs#modEventResolveAppeal',
|
||||
],
|
||||
},
|
||||
subject: {
|
||||
@ -237,9 +238,20 @@ export const schemaDict = {
|
||||
type: 'string',
|
||||
format: 'datetime',
|
||||
},
|
||||
lastAppealedAt: {
|
||||
type: 'string',
|
||||
format: 'datetime',
|
||||
description:
|
||||
'Timestamp referencing when the author of the subject appealed a moderation action',
|
||||
},
|
||||
takendown: {
|
||||
type: 'boolean',
|
||||
},
|
||||
appealed: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators.',
|
||||
},
|
||||
suspendUntil: {
|
||||
type: 'string',
|
||||
format: 'datetime',
|
||||
@ -717,6 +729,16 @@ export const schemaDict = {
|
||||
},
|
||||
},
|
||||
},
|
||||
modEventResolveAppeal: {
|
||||
type: 'object',
|
||||
description: 'Resolve appeal on a subject',
|
||||
properties: {
|
||||
comment: {
|
||||
type: 'string',
|
||||
description: 'Describe resolution.',
|
||||
},
|
||||
},
|
||||
},
|
||||
modEventComment: {
|
||||
type: 'object',
|
||||
description: 'Add a comment to a subject',
|
||||
@ -1361,6 +1383,10 @@ export const schemaDict = {
|
||||
type: 'boolean',
|
||||
description: 'Get subjects that were taken down',
|
||||
},
|
||||
appealed: {
|
||||
type: 'boolean',
|
||||
description: 'Get subjects in unresolved appealed status',
|
||||
},
|
||||
limit: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
@ -1946,6 +1972,7 @@ export const schemaDict = {
|
||||
'com.atproto.moderation.defs#reasonSexual',
|
||||
'com.atproto.moderation.defs#reasonRude',
|
||||
'com.atproto.moderation.defs#reasonOther',
|
||||
'com.atproto.moderation.defs#reasonAppeal',
|
||||
],
|
||||
},
|
||||
reasonSpam: {
|
||||
@ -1973,6 +2000,10 @@ export const schemaDict = {
|
||||
type: 'token',
|
||||
description: 'Other: reports not falling under another report category',
|
||||
},
|
||||
reasonAppeal: {
|
||||
type: 'token',
|
||||
description: 'Appeal: appeal a previously taken moderation action',
|
||||
},
|
||||
},
|
||||
},
|
||||
ComAtprotoRepoApplyWrites: {
|
||||
|
@ -76,6 +76,7 @@ export interface ModEventViewDetail {
|
||||
| ModEventAcknowledge
|
||||
| ModEventEscalate
|
||||
| ModEventMute
|
||||
| ModEventResolveAppeal
|
||||
| { $type: string; [k: string]: unknown }
|
||||
subject:
|
||||
| RepoView
|
||||
@ -147,7 +148,11 @@ export interface SubjectStatusView {
|
||||
lastReviewedBy?: string
|
||||
lastReviewedAt?: string
|
||||
lastReportedAt?: string
|
||||
/** Timestamp referencing when the author of the subject appealed a moderation action */
|
||||
lastAppealedAt?: string
|
||||
takendown?: boolean
|
||||
/** True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. */
|
||||
appealed?: boolean
|
||||
suspendUntil?: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
@ -538,6 +543,27 @@ export function validateModEventReverseTakedown(v: unknown): ValidationResult {
|
||||
return lexicons.validate('com.atproto.admin.defs#modEventReverseTakedown', v)
|
||||
}
|
||||
|
||||
/** Resolve appeal on a subject */
|
||||
export interface ModEventResolveAppeal {
|
||||
/** Describe resolution. */
|
||||
comment?: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
export function isModEventResolveAppeal(
|
||||
v: unknown,
|
||||
): v is ModEventResolveAppeal {
|
||||
return (
|
||||
isObj(v) &&
|
||||
hasProp(v, '$type') &&
|
||||
v.$type === 'com.atproto.admin.defs#modEventResolveAppeal'
|
||||
)
|
||||
}
|
||||
|
||||
export function validateModEventResolveAppeal(v: unknown): ValidationResult {
|
||||
return lexicons.validate('com.atproto.admin.defs#modEventResolveAppeal', v)
|
||||
}
|
||||
|
||||
/** Add a comment to a subject */
|
||||
export interface ModEventComment {
|
||||
comment: string
|
||||
|
@ -32,6 +32,8 @@ export interface QueryParams {
|
||||
sortDirection: 'asc' | 'desc'
|
||||
/** Get subjects that were taken down */
|
||||
takendown?: boolean
|
||||
/** Get subjects in unresolved appealed status */
|
||||
appealed?: boolean
|
||||
limit: number
|
||||
cursor?: string
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ export type ReasonType =
|
||||
| 'com.atproto.moderation.defs#reasonSexual'
|
||||
| 'com.atproto.moderation.defs#reasonRude'
|
||||
| 'com.atproto.moderation.defs#reasonOther'
|
||||
| 'com.atproto.moderation.defs#reasonAppeal'
|
||||
| (string & {})
|
||||
|
||||
/** Spam: frequent unwanted promotion, replies, mentions */
|
||||
@ -27,3 +28,5 @@ export const REASONSEXUAL = 'com.atproto.moderation.defs#reasonSexual'
|
||||
export const REASONRUDE = 'com.atproto.moderation.defs#reasonRude'
|
||||
/** Other: reports not falling under another report category */
|
||||
export const REASONOTHER = 'com.atproto.moderation.defs#reasonOther'
|
||||
/** Appeal: appeal a previously taken moderation action */
|
||||
export const REASONAPPEAL = 'com.atproto.moderation.defs#reasonAppeal'
|
||||
|
@ -539,6 +539,7 @@ export class ModerationService {
|
||||
cursor,
|
||||
limit = 50,
|
||||
takendown,
|
||||
appealed,
|
||||
reviewState,
|
||||
reviewedAfter,
|
||||
reviewedBefore,
|
||||
@ -554,6 +555,7 @@ export class ModerationService {
|
||||
cursor?: string
|
||||
limit?: number
|
||||
takendown?: boolean
|
||||
appealed?: boolean | null
|
||||
reviewedBefore?: string
|
||||
reviewState?: ModerationSubjectStatusRow['reviewState']
|
||||
reviewedAfter?: string
|
||||
@ -615,6 +617,13 @@ export class ModerationService {
|
||||
builder = builder.where('takendown', '=', true)
|
||||
}
|
||||
|
||||
if (appealed !== undefined) {
|
||||
builder =
|
||||
appealed === null
|
||||
? builder.where('appealed', 'is', null)
|
||||
: builder.where('appealed', '=', appealed)
|
||||
}
|
||||
|
||||
if (!includeMuted) {
|
||||
builder = builder.where((qb) =>
|
||||
qb
|
||||
|
@ -12,6 +12,7 @@ import { ModerationEventRow, ModerationSubjectStatusRow } from './types'
|
||||
import { HOUR } from '@atproto/common'
|
||||
import { CID } from 'multiformats/cid'
|
||||
import { sql } from 'kysely'
|
||||
import { REASONAPPEAL } from '../../lexicon/types/com/atproto/moderation/defs'
|
||||
|
||||
const getSubjectStatusForModerationEvent = ({
|
||||
action,
|
||||
@ -82,6 +83,10 @@ const getSubjectStatusForModerationEvent = ({
|
||||
lastReviewedBy: createdBy,
|
||||
lastReviewedAt: createdAt,
|
||||
}
|
||||
case 'com.atproto.admin.defs#modEventResolveAppeal':
|
||||
return {
|
||||
appealed: false,
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
@ -106,6 +111,10 @@ export const adjustModerationSubjectStatus = async (
|
||||
createdAt,
|
||||
} = moderationEvent
|
||||
|
||||
const isAppealEvent =
|
||||
action === 'com.atproto.admin.defs#modEventReport' &&
|
||||
meta?.reportType === REASONAPPEAL
|
||||
|
||||
const subjectStatus = getSubjectStatusForModerationEvent({
|
||||
action,
|
||||
createdBy,
|
||||
@ -162,6 +171,21 @@ export const adjustModerationSubjectStatus = async (
|
||||
subjectStatus.takendown = false
|
||||
}
|
||||
|
||||
if (isAppealEvent) {
|
||||
newStatus.appealed = true
|
||||
subjectStatus.appealed = true
|
||||
newStatus.lastAppealedAt = createdAt
|
||||
subjectStatus.lastAppealedAt = createdAt
|
||||
}
|
||||
|
||||
if (
|
||||
action === 'com.atproto.admin.defs#modEventResolveAppeal' &&
|
||||
subjectStatus.appealed
|
||||
) {
|
||||
newStatus.appealed = false
|
||||
subjectStatus.appealed = false
|
||||
}
|
||||
|
||||
if (action === 'com.atproto.admin.defs#modEventComment' && meta?.sticky) {
|
||||
newStatus.comment = comment
|
||||
subjectStatus.comment = comment
|
||||
|
@ -485,9 +485,11 @@ export class ModerationViews {
|
||||
lastReviewedBy: subjectStatus.lastReviewedBy ?? undefined,
|
||||
lastReviewedAt: subjectStatus.lastReviewedAt ?? undefined,
|
||||
lastReportedAt: subjectStatus.lastReportedAt ?? undefined,
|
||||
lastAppealedAt: subjectStatus.lastAppealedAt ?? undefined,
|
||||
muteUntil: subjectStatus.muteUntil ?? undefined,
|
||||
suspendUntil: subjectStatus.suspendUntil ?? undefined,
|
||||
takendown: subjectStatus.takendown ?? undefined,
|
||||
appealed: subjectStatus.appealed ?? undefined,
|
||||
subjectRepoHandle: subjectStatus.handle ?? undefined,
|
||||
subjectBlobCids: subjectStatus.blobCids || [],
|
||||
subject: !subjectStatus.recordPath
|
||||
|
269
packages/bsky/tests/admin/moderation-appeals.test.ts
Normal file
269
packages/bsky/tests/admin/moderation-appeals.test.ts
Normal file
@ -0,0 +1,269 @@
|
||||
import { TestNetwork, SeedClient } from '@atproto/dev-env'
|
||||
import AtpAgent, {
|
||||
ComAtprotoAdminDefs,
|
||||
ComAtprotoAdminEmitModerationEvent,
|
||||
ComAtprotoAdminQueryModerationStatuses,
|
||||
} from '@atproto/api'
|
||||
import basicSeed from '../seeds/basic'
|
||||
import {
|
||||
REASONMISLEADING,
|
||||
REASONSPAM,
|
||||
} from '../../src/lexicon/types/com/atproto/moderation/defs'
|
||||
import {
|
||||
REVIEWCLOSED,
|
||||
REVIEWOPEN,
|
||||
} from '@atproto/api/src/client/types/com/atproto/admin/defs'
|
||||
import { REASONAPPEAL } from '@atproto/api/src/client/types/com/atproto/moderation/defs'
|
||||
import { REVIEWESCALATED } from '../../src/lexicon/types/com/atproto/admin/defs'
|
||||
|
||||
describe('moderation-appeals', () => {
|
||||
let network: TestNetwork
|
||||
let agent: AtpAgent
|
||||
let pdsAgent: AtpAgent
|
||||
let sc: SeedClient
|
||||
|
||||
const emitModerationEvent = async (
|
||||
eventData: ComAtprotoAdminEmitModerationEvent.InputSchema,
|
||||
) => {
|
||||
return pdsAgent.api.com.atproto.admin.emitModerationEvent(eventData, {
|
||||
encoding: 'application/json',
|
||||
headers: network.bsky.adminAuthHeaders('moderator'),
|
||||
})
|
||||
}
|
||||
|
||||
const queryModerationStatuses = (
|
||||
statusQuery: ComAtprotoAdminQueryModerationStatuses.QueryParams,
|
||||
) =>
|
||||
agent.api.com.atproto.admin.queryModerationStatuses(statusQuery, {
|
||||
headers: network.bsky.adminAuthHeaders('moderator'),
|
||||
})
|
||||
|
||||
beforeAll(async () => {
|
||||
network = await TestNetwork.create({
|
||||
dbPostgresSchema: 'bsky_moderation_statuses',
|
||||
})
|
||||
agent = network.bsky.getClient()
|
||||
pdsAgent = network.pds.getClient()
|
||||
sc = network.getSeedClient()
|
||||
await basicSeed(sc)
|
||||
await network.processAll()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await network.close()
|
||||
})
|
||||
|
||||
const assertSubjectStatus = async (
|
||||
subject: string,
|
||||
status: string,
|
||||
appealed: boolean | undefined,
|
||||
): Promise<ComAtprotoAdminDefs.SubjectStatusView | undefined> => {
|
||||
const { data } = await queryModerationStatuses({
|
||||
subject,
|
||||
})
|
||||
expect(data.subjectStatuses[0]?.reviewState).toEqual(status)
|
||||
expect(data.subjectStatuses[0]?.appealed).toEqual(appealed)
|
||||
return data.subjectStatuses[0]
|
||||
}
|
||||
describe('appeals from users', () => {
|
||||
const getBobsPostSubject = () => ({
|
||||
$type: 'com.atproto.repo.strongRef',
|
||||
uri: sc.posts[sc.dids.bob][1].ref.uriStr,
|
||||
cid: sc.posts[sc.dids.bob][1].ref.cidStr,
|
||||
})
|
||||
const getCarolPostSubject = () => ({
|
||||
$type: 'com.atproto.repo.strongRef',
|
||||
uri: sc.posts[sc.dids.carol][0].ref.uriStr,
|
||||
cid: sc.posts[sc.dids.carol][0].ref.cidStr,
|
||||
})
|
||||
const assertBobsPostStatus = async (
|
||||
status: string,
|
||||
appealed: boolean | undefined,
|
||||
) => assertSubjectStatus(getBobsPostSubject().uri, status, appealed)
|
||||
|
||||
it('only changes subject status if original author of the content or a moderator is appealing', async () => {
|
||||
// Create a report by alice
|
||||
await emitModerationEvent({
|
||||
event: {
|
||||
$type: 'com.atproto.admin.defs#modEventReport',
|
||||
reportType: REASONMISLEADING,
|
||||
},
|
||||
subject: getBobsPostSubject(),
|
||||
createdBy: sc.dids.alice,
|
||||
})
|
||||
|
||||
await assertBobsPostStatus(REVIEWOPEN, undefined)
|
||||
|
||||
// Create a report as normal user with appeal type
|
||||
expect(
|
||||
sc.createReport({
|
||||
reportedBy: sc.dids.carol,
|
||||
reasonType: REASONAPPEAL,
|
||||
reason: 'appealing',
|
||||
subject: getBobsPostSubject(),
|
||||
}),
|
||||
).rejects.toThrow('You cannot appeal this report')
|
||||
|
||||
// Verify that the appeal status did not change
|
||||
await assertBobsPostStatus(REVIEWOPEN, undefined)
|
||||
|
||||
// Emit report event as moderator
|
||||
await emitModerationEvent({
|
||||
event: {
|
||||
$type: 'com.atproto.admin.defs#modEventReport',
|
||||
reportType: REASONAPPEAL,
|
||||
},
|
||||
subject: getBobsPostSubject(),
|
||||
createdBy: sc.dids.alice,
|
||||
})
|
||||
|
||||
// Verify that appeal status changed when appeal report was emitted by moderator
|
||||
const status = await assertBobsPostStatus(REVIEWOPEN, true)
|
||||
expect(status?.appealedAt).not.toBeNull()
|
||||
|
||||
// Create a report as normal user for carol's post
|
||||
await sc.createReport({
|
||||
reportedBy: sc.dids.alice,
|
||||
reasonType: REASONMISLEADING,
|
||||
reason: 'lies!',
|
||||
subject: getCarolPostSubject(),
|
||||
})
|
||||
|
||||
// Verify that the appeal status on carol's post is undefined
|
||||
await assertSubjectStatus(
|
||||
getCarolPostSubject().uri,
|
||||
REVIEWOPEN,
|
||||
undefined,
|
||||
)
|
||||
|
||||
await sc.createReport({
|
||||
reportedBy: sc.dids.carol,
|
||||
reasonType: REASONAPPEAL,
|
||||
reason: 'appealing',
|
||||
subject: getCarolPostSubject(),
|
||||
})
|
||||
// Verify that the appeal status on carol's post is true
|
||||
await assertSubjectStatus(getCarolPostSubject().uri, REVIEWOPEN, true)
|
||||
})
|
||||
it('allows multiple appeals and updates last appealed timestamp', async () => {
|
||||
// Resolve appeal with acknowledge
|
||||
await emitModerationEvent({
|
||||
event: {
|
||||
$type: 'com.atproto.admin.defs#modEventResolveAppeal',
|
||||
},
|
||||
subject: getBobsPostSubject(),
|
||||
createdBy: sc.dids.carol,
|
||||
})
|
||||
|
||||
const previousStatus = await assertBobsPostStatus(REVIEWOPEN, false)
|
||||
|
||||
await emitModerationEvent({
|
||||
event: {
|
||||
$type: 'com.atproto.admin.defs#modEventReport',
|
||||
reportType: REASONAPPEAL,
|
||||
},
|
||||
subject: getBobsPostSubject(),
|
||||
createdBy: sc.dids.bob,
|
||||
})
|
||||
|
||||
// Verify that even after the appeal event by bob for his post, the appeal status is true again with new timestamp
|
||||
const newStatus = await assertBobsPostStatus(REVIEWOPEN, true)
|
||||
expect(
|
||||
new Date(`${previousStatus?.lastAppealedAt}`).getTime(),
|
||||
).toBeLessThan(new Date(`${newStatus?.lastAppealedAt}`).getTime())
|
||||
})
|
||||
})
|
||||
|
||||
describe('appeal resolution', () => {
|
||||
const getAlicesPostSubject = () => ({
|
||||
$type: 'com.atproto.repo.strongRef',
|
||||
uri: sc.posts[sc.dids.alice][1].ref.uriStr,
|
||||
cid: sc.posts[sc.dids.alice][1].ref.cidStr,
|
||||
})
|
||||
it('appeal status is maintained while review state changes based on incoming events', async () => {
|
||||
// Bob reports alice's post
|
||||
await emitModerationEvent({
|
||||
event: {
|
||||
$type: 'com.atproto.admin.defs#modEventReport',
|
||||
reportType: REASONMISLEADING,
|
||||
},
|
||||
subject: getAlicesPostSubject(),
|
||||
createdBy: sc.dids.bob,
|
||||
})
|
||||
|
||||
// Moderator acknowledges the report, assume a label was applied too
|
||||
await emitModerationEvent({
|
||||
event: {
|
||||
$type: 'com.atproto.admin.defs#modEventAcknowledge',
|
||||
},
|
||||
subject: getAlicesPostSubject(),
|
||||
createdBy: sc.dids.carol,
|
||||
})
|
||||
|
||||
// Alice appeals the report
|
||||
await emitModerationEvent({
|
||||
event: {
|
||||
$type: 'com.atproto.admin.defs#modEventReport',
|
||||
reportType: REASONAPPEAL,
|
||||
},
|
||||
subject: getAlicesPostSubject(),
|
||||
createdBy: sc.dids.alice,
|
||||
})
|
||||
|
||||
await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWOPEN, true)
|
||||
|
||||
// Bob reports it again
|
||||
await emitModerationEvent({
|
||||
event: {
|
||||
$type: 'com.atproto.admin.defs#modEventReport',
|
||||
reportType: REASONSPAM,
|
||||
},
|
||||
subject: getAlicesPostSubject(),
|
||||
createdBy: sc.dids.bob,
|
||||
})
|
||||
|
||||
// Assert that the status is still REVIEWOPEN, as report events are meant to do
|
||||
await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWOPEN, true)
|
||||
|
||||
// Emit an escalation event
|
||||
await emitModerationEvent({
|
||||
event: {
|
||||
$type: 'com.atproto.admin.defs#modEventEscalate',
|
||||
},
|
||||
subject: getAlicesPostSubject(),
|
||||
createdBy: sc.dids.carol,
|
||||
})
|
||||
|
||||
await assertSubjectStatus(
|
||||
getAlicesPostSubject().uri,
|
||||
REVIEWESCALATED,
|
||||
true,
|
||||
)
|
||||
|
||||
// Emit an acknowledge event
|
||||
await emitModerationEvent({
|
||||
event: {
|
||||
$type: 'com.atproto.admin.defs#modEventAcknowledge',
|
||||
},
|
||||
subject: getAlicesPostSubject(),
|
||||
createdBy: sc.dids.carol,
|
||||
})
|
||||
|
||||
// Assert that status moved on to reviewClosed while appealed status is still true
|
||||
await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWCLOSED, true)
|
||||
|
||||
// Emit a resolveAppeal event
|
||||
await emitModerationEvent({
|
||||
event: {
|
||||
$type: 'com.atproto.admin.defs#modEventResolveAppeal',
|
||||
comment: 'lgtm',
|
||||
},
|
||||
subject: getAlicesPostSubject(),
|
||||
createdBy: sc.dids.carol,
|
||||
})
|
||||
|
||||
// Assert that status stayed the same while appealed status is still true
|
||||
await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWCLOSED, false)
|
||||
})
|
||||
})
|
||||
})
|
@ -448,7 +448,7 @@ export class SeedClient {
|
||||
reason?: string
|
||||
createdBy?: string
|
||||
}) {
|
||||
const { id, subject, reason = 'X', createdBy = 'did:example:admin' } = opts
|
||||
const { subject, reason = 'X', createdBy = 'did:example:admin' } = opts
|
||||
const result = await this.agent.api.com.atproto.admin.emitModerationEvent(
|
||||
{
|
||||
subject,
|
||||
|
@ -4,13 +4,7 @@ import {
|
||||
SourceFile,
|
||||
VariableDeclarationKind,
|
||||
} from 'ts-morph'
|
||||
import {
|
||||
Lexicons,
|
||||
LexiconDoc,
|
||||
LexXrpcProcedure,
|
||||
LexXrpcQuery,
|
||||
LexRecord,
|
||||
} from '@atproto/lexicon'
|
||||
import { Lexicons, LexiconDoc, LexRecord } from '@atproto/lexicon'
|
||||
import { NSID } from '@atproto/syntax'
|
||||
import { gen, utilTs, lexiconsTs } from './common'
|
||||
import { GeneratedAPI } from '../types'
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
REASONRUDE,
|
||||
REASONSEXUAL,
|
||||
REASONVIOLATION,
|
||||
REASONAPPEAL,
|
||||
} from '../../../../lexicon/types/com/atproto/moderation/defs'
|
||||
import { parseCidParam } from '../../../../util/params'
|
||||
|
||||
@ -49,4 +50,5 @@ const reasonTypes = new Set([
|
||||
REASONRUDE,
|
||||
REASONSEXUAL,
|
||||
REASONVIOLATION,
|
||||
REASONAPPEAL,
|
||||
])
|
||||
|
@ -135,6 +135,7 @@ export const COM_ATPROTO_MODERATION = {
|
||||
DefsReasonSexual: 'com.atproto.moderation.defs#reasonSexual',
|
||||
DefsReasonRude: 'com.atproto.moderation.defs#reasonRude',
|
||||
DefsReasonOther: 'com.atproto.moderation.defs#reasonOther',
|
||||
DefsReasonAppeal: 'com.atproto.moderation.defs#reasonAppeal',
|
||||
}
|
||||
export const APP_BSKY_GRAPH = {
|
||||
DefsModlist: 'app.bsky.graph.defs#modlist',
|
||||
|
@ -102,6 +102,7 @@ export const schemaDict = {
|
||||
'lex:com.atproto.admin.defs#modEventAcknowledge',
|
||||
'lex:com.atproto.admin.defs#modEventEscalate',
|
||||
'lex:com.atproto.admin.defs#modEventMute',
|
||||
'lex:com.atproto.admin.defs#modEventResolveAppeal',
|
||||
],
|
||||
},
|
||||
subject: {
|
||||
@ -237,9 +238,20 @@ export const schemaDict = {
|
||||
type: 'string',
|
||||
format: 'datetime',
|
||||
},
|
||||
lastAppealedAt: {
|
||||
type: 'string',
|
||||
format: 'datetime',
|
||||
description:
|
||||
'Timestamp referencing when the author of the subject appealed a moderation action',
|
||||
},
|
||||
takendown: {
|
||||
type: 'boolean',
|
||||
},
|
||||
appealed: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators.',
|
||||
},
|
||||
suspendUntil: {
|
||||
type: 'string',
|
||||
format: 'datetime',
|
||||
@ -717,6 +729,16 @@ export const schemaDict = {
|
||||
},
|
||||
},
|
||||
},
|
||||
modEventResolveAppeal: {
|
||||
type: 'object',
|
||||
description: 'Resolve appeal on a subject',
|
||||
properties: {
|
||||
comment: {
|
||||
type: 'string',
|
||||
description: 'Describe resolution.',
|
||||
},
|
||||
},
|
||||
},
|
||||
modEventComment: {
|
||||
type: 'object',
|
||||
description: 'Add a comment to a subject',
|
||||
@ -1361,6 +1383,10 @@ export const schemaDict = {
|
||||
type: 'boolean',
|
||||
description: 'Get subjects that were taken down',
|
||||
},
|
||||
appealed: {
|
||||
type: 'boolean',
|
||||
description: 'Get subjects in unresolved appealed status',
|
||||
},
|
||||
limit: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
@ -1946,6 +1972,7 @@ export const schemaDict = {
|
||||
'com.atproto.moderation.defs#reasonSexual',
|
||||
'com.atproto.moderation.defs#reasonRude',
|
||||
'com.atproto.moderation.defs#reasonOther',
|
||||
'com.atproto.moderation.defs#reasonAppeal',
|
||||
],
|
||||
},
|
||||
reasonSpam: {
|
||||
@ -1973,6 +2000,10 @@ export const schemaDict = {
|
||||
type: 'token',
|
||||
description: 'Other: reports not falling under another report category',
|
||||
},
|
||||
reasonAppeal: {
|
||||
type: 'token',
|
||||
description: 'Appeal: appeal a previously taken moderation action',
|
||||
},
|
||||
},
|
||||
},
|
||||
ComAtprotoRepoApplyWrites: {
|
||||
|
@ -76,6 +76,7 @@ export interface ModEventViewDetail {
|
||||
| ModEventAcknowledge
|
||||
| ModEventEscalate
|
||||
| ModEventMute
|
||||
| ModEventResolveAppeal
|
||||
| { $type: string; [k: string]: unknown }
|
||||
subject:
|
||||
| RepoView
|
||||
@ -147,7 +148,11 @@ export interface SubjectStatusView {
|
||||
lastReviewedBy?: string
|
||||
lastReviewedAt?: string
|
||||
lastReportedAt?: string
|
||||
/** Timestamp referencing when the author of the subject appealed a moderation action */
|
||||
lastAppealedAt?: string
|
||||
takendown?: boolean
|
||||
/** True indicates that the a previously taken moderator action was appealed against, by the author of the content. False indicates last appeal was resolved by moderators. */
|
||||
appealed?: boolean
|
||||
suspendUntil?: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
@ -538,6 +543,27 @@ export function validateModEventReverseTakedown(v: unknown): ValidationResult {
|
||||
return lexicons.validate('com.atproto.admin.defs#modEventReverseTakedown', v)
|
||||
}
|
||||
|
||||
/** Resolve appeal on a subject */
|
||||
export interface ModEventResolveAppeal {
|
||||
/** Describe resolution. */
|
||||
comment?: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
export function isModEventResolveAppeal(
|
||||
v: unknown,
|
||||
): v is ModEventResolveAppeal {
|
||||
return (
|
||||
isObj(v) &&
|
||||
hasProp(v, '$type') &&
|
||||
v.$type === 'com.atproto.admin.defs#modEventResolveAppeal'
|
||||
)
|
||||
}
|
||||
|
||||
export function validateModEventResolveAppeal(v: unknown): ValidationResult {
|
||||
return lexicons.validate('com.atproto.admin.defs#modEventResolveAppeal', v)
|
||||
}
|
||||
|
||||
/** Add a comment to a subject */
|
||||
export interface ModEventComment {
|
||||
comment: string
|
||||
|
@ -32,6 +32,8 @@ export interface QueryParams {
|
||||
sortDirection: 'asc' | 'desc'
|
||||
/** Get subjects that were taken down */
|
||||
takendown?: boolean
|
||||
/** Get subjects in unresolved appealed status */
|
||||
appealed?: boolean
|
||||
limit: number
|
||||
cursor?: string
|
||||
}
|
||||
|
@ -13,6 +13,7 @@ export type ReasonType =
|
||||
| 'com.atproto.moderation.defs#reasonSexual'
|
||||
| 'com.atproto.moderation.defs#reasonRude'
|
||||
| 'com.atproto.moderation.defs#reasonOther'
|
||||
| 'com.atproto.moderation.defs#reasonAppeal'
|
||||
| (string & {})
|
||||
|
||||
/** Spam: frequent unwanted promotion, replies, mentions */
|
||||
@ -27,3 +28,5 @@ export const REASONSEXUAL = 'com.atproto.moderation.defs#reasonSexual'
|
||||
export const REASONRUDE = 'com.atproto.moderation.defs#reasonRude'
|
||||
/** Other: reports not falling under another report category */
|
||||
export const REASONOTHER = 'com.atproto.moderation.defs#reasonOther'
|
||||
/** Appeal: appeal a previously taken moderation action */
|
||||
export const REASONAPPEAL = 'com.atproto.moderation.defs#reasonAppeal'
|
||||
|
Loading…
x
Reference in New Issue
Block a user