Allow appealing a moderator decision through special report type ()

*  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:
Foysal Ahamed 2024-01-03 01:17:42 +01:00 committed by GitHub
parent ad0d976188
commit 5e7b0136da
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 574 additions and 14 deletions
.github/workflows
lexicons/com/atproto
packages
api/src/client
bsky
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

@ -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'