diff --git a/.github/workflows/build-and-push-bsky-aws.yaml b/.github/workflows/build-and-push-bsky-aws.yaml index 34bba3070cd..9df469c0615 100644 --- a/.github/workflows/build-and-push-bsky-aws.yaml +++ b/.github/workflows/build-and-push-bsky-aws.yaml @@ -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 }} diff --git a/lexicons/com/atproto/admin/defs.json b/lexicons/com/atproto/admin/defs.json index fae60e45cf7..23448b7ac8d 100644 --- a/lexicons/com/atproto/admin/defs.json +++ b/lexicons/com/atproto/admin/defs.json @@ -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", diff --git a/lexicons/com/atproto/admin/queryModerationStatuses.json b/lexicons/com/atproto/admin/queryModerationStatuses.json index 98fec5bd642..e3e2a859bd2 100644 --- a/lexicons/com/atproto/admin/queryModerationStatuses.json +++ b/lexicons/com/atproto/admin/queryModerationStatuses.json @@ -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, diff --git a/lexicons/com/atproto/moderation/defs.json b/lexicons/com/atproto/moderation/defs.json index a06579a502e..b9e980df779 100644 --- a/lexicons/com/atproto/moderation/defs.json +++ b/lexicons/com/atproto/moderation/defs.json @@ -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" } } } diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index a42dbd9320a..df55181aef0 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -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', diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 55de861d600..c0e7e51fddc 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -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: { diff --git a/packages/api/src/client/types/com/atproto/admin/defs.ts b/packages/api/src/client/types/com/atproto/admin/defs.ts index 42a587bcc62..d4b35ae8056 100644 --- a/packages/api/src/client/types/com/atproto/admin/defs.ts +++ b/packages/api/src/client/types/com/atproto/admin/defs.ts @@ -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 diff --git a/packages/api/src/client/types/com/atproto/admin/queryModerationStatuses.ts b/packages/api/src/client/types/com/atproto/admin/queryModerationStatuses.ts index 80eb17d8cb3..0039016a353 100644 --- a/packages/api/src/client/types/com/atproto/admin/queryModerationStatuses.ts +++ b/packages/api/src/client/types/com/atproto/admin/queryModerationStatuses.ts @@ -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 } diff --git a/packages/api/src/client/types/com/atproto/moderation/defs.ts b/packages/api/src/client/types/com/atproto/moderation/defs.ts index b6463993614..802cd2bc996 100644 --- a/packages/api/src/client/types/com/atproto/moderation/defs.ts +++ b/packages/api/src/client/types/com/atproto/moderation/defs.ts @@ -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' diff --git a/packages/bsky/src/api/com/atproto/admin/queryModerationStatuses.ts b/packages/bsky/src/api/com/atproto/admin/queryModerationStatuses.ts index 5a74bfca3ae..e664e90343c 100644 --- a/packages/bsky/src/api/com/atproto/admin/queryModerationStatuses.ts +++ b/packages/bsky/src/api/com/atproto/admin/queryModerationStatuses.ts @@ -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, diff --git a/packages/bsky/src/api/com/atproto/moderation/createReport.ts b/packages/bsky/src/api/com/atproto/moderation/createReport.ts index b247a319527..4a98d0629d4 100644 --- a/packages/bsky/src/api/com/atproto/moderation/createReport.ts +++ b/packages/bsky/src/api/com/atproto/moderation/createReport.ts @@ -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, }) }) diff --git a/packages/bsky/src/api/com/atproto/moderation/util.ts b/packages/bsky/src/api/com/atproto/moderation/util.ts index bc0ece2ff9f..fbb144b1c0a 100644 --- a/packages/bsky/src/api/com/atproto/moderation/util.ts +++ b/packages/bsky/src/api/com/atproto/moderation/util.ts @@ -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([ diff --git a/packages/bsky/src/db/migrations/20231213T181744386Z-moderation-subject-appeal.ts b/packages/bsky/src/db/migrations/20231213T181744386Z-moderation-subject-appeal.ts new file mode 100644 index 00000000000..95662737a63 --- /dev/null +++ b/packages/bsky/src/db/migrations/20231213T181744386Z-moderation-subject-appeal.ts @@ -0,0 +1,23 @@ +import { Kysely } from 'kysely' + +export async function up(db: Kysely): Promise { + 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): Promise { + await db.schema + .alterTable('moderation_subject_status') + .dropColumn('lastAppealedAt') + .execute() + await db.schema + .alterTable('moderation_subject_status') + .dropColumn('appealed') + .execute() +} diff --git a/packages/bsky/src/db/migrations/index.ts b/packages/bsky/src/db/migrations/index.ts index f3ed5bc4dbd..ea14e775383 100644 --- a/packages/bsky/src/db/migrations/index.ts +++ b/packages/bsky/src/db/migrations/index.ts @@ -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' diff --git a/packages/bsky/src/db/tables/moderation.ts b/packages/bsky/src/db/tables/moderation.ts index f1ac3572785..99f5e73310d 100644 --- a/packages/bsky/src/db/tables/moderation.ts +++ b/packages/bsky/src/db/tables/moderation.ts @@ -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 } diff --git a/packages/bsky/src/lexicon/index.ts b/packages/bsky/src/lexicon/index.ts index c51998a66e6..40c50cd1687 100644 --- a/packages/bsky/src/lexicon/index.ts +++ b/packages/bsky/src/lexicon/index.ts @@ -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', diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index 55de861d600..c0e7e51fddc 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -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: { diff --git a/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts b/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts index 33a4ccd1b9a..4be9efb21a9 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/admin/defs.ts @@ -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 diff --git a/packages/bsky/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts b/packages/bsky/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts index d4e55aff386..6e1aea1f679 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts @@ -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 } diff --git a/packages/bsky/src/lexicon/types/com/atproto/moderation/defs.ts b/packages/bsky/src/lexicon/types/com/atproto/moderation/defs.ts index 81697226189..08e555c2422 100644 --- a/packages/bsky/src/lexicon/types/com/atproto/moderation/defs.ts +++ b/packages/bsky/src/lexicon/types/com/atproto/moderation/defs.ts @@ -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' diff --git a/packages/bsky/src/services/moderation/index.ts b/packages/bsky/src/services/moderation/index.ts index 717155d0317..84769100ae9 100644 --- a/packages/bsky/src/services/moderation/index.ts +++ b/packages/bsky/src/services/moderation/index.ts @@ -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 diff --git a/packages/bsky/src/services/moderation/status.ts b/packages/bsky/src/services/moderation/status.ts index 2362da5d556..151f6137a05 100644 --- a/packages/bsky/src/services/moderation/status.ts +++ b/packages/bsky/src/services/moderation/status.ts @@ -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 diff --git a/packages/bsky/src/services/moderation/views.ts b/packages/bsky/src/services/moderation/views.ts index 2dc9c5ec7e4..654a6e54291 100644 --- a/packages/bsky/src/services/moderation/views.ts +++ b/packages/bsky/src/services/moderation/views.ts @@ -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 diff --git a/packages/bsky/tests/admin/moderation-appeals.test.ts b/packages/bsky/tests/admin/moderation-appeals.test.ts new file mode 100644 index 00000000000..8b2af9a5a42 --- /dev/null +++ b/packages/bsky/tests/admin/moderation-appeals.test.ts @@ -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 => { + 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) + }) + }) +}) diff --git a/packages/dev-env/src/seed-client.ts b/packages/dev-env/src/seed-client.ts index 71dfebd53c0..7fc57d52081 100644 --- a/packages/dev-env/src/seed-client.ts +++ b/packages/dev-env/src/seed-client.ts @@ -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, diff --git a/packages/lex-cli/src/codegen/client.ts b/packages/lex-cli/src/codegen/client.ts index 33b3a53ff9d..bf7c8892819 100644 --- a/packages/lex-cli/src/codegen/client.ts +++ b/packages/lex-cli/src/codegen/client.ts @@ -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' diff --git a/packages/pds/src/api/com/atproto/moderation/util.ts b/packages/pds/src/api/com/atproto/moderation/util.ts index 4de1e8cd4bc..e7c33629b5a 100644 --- a/packages/pds/src/api/com/atproto/moderation/util.ts +++ b/packages/pds/src/api/com/atproto/moderation/util.ts @@ -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, ]) diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index c51998a66e6..40c50cd1687 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -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', diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 55de861d600..c0e7e51fddc 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -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: { diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts b/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts index 33a4ccd1b9a..4be9efb21a9 100644 --- a/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts +++ b/packages/pds/src/lexicon/types/com/atproto/admin/defs.ts @@ -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 diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts b/packages/pds/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts index d4e55aff386..6e1aea1f679 100644 --- a/packages/pds/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts +++ b/packages/pds/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts @@ -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 } diff --git a/packages/pds/src/lexicon/types/com/atproto/moderation/defs.ts b/packages/pds/src/lexicon/types/com/atproto/moderation/defs.ts index 81697226189..08e555c2422 100644 --- a/packages/pds/src/lexicon/types/com/atproto/moderation/defs.ts +++ b/packages/pds/src/lexicon/types/com/atproto/moderation/defs.ts @@ -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'