diff --git a/lexicons/com/atproto/admin/defs.json b/lexicons/com/atproto/admin/defs.json index 949f021aa8e..220cc687adf 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,7 +168,7 @@ "type": "string", "format": "datetime" }, - "appealedAt": { + "lastAppealedAt": { "type": "string", "format": "datetime", "description": "Timestamp referencing when the owner of the subject appealed a moderation action" @@ -175,6 +176,10 @@ "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" @@ -437,12 +442,7 @@ }, "subjectReviewState": { "type": "string", - "knownValues": [ - "#reviewOpen", - "#reviewEscalated", - "#reviewClosed", - "#reviewAppealed" - ] + "knownValues": ["#reviewOpen", "#reviewEscalated", "#reviewClosed"] }, "reviewOpen": { "type": "token", @@ -456,10 +456,6 @@ "type": "token", "description": "Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator" }, - "reviewAppealed": { - "type": "token", - "description": "Moderator review status of a subject: Appealed. Indicates that the a previously taken moderator action was appealed agains, by the author of the content" - }, "modEventTakedown": { "type": "object", "description": "Take down a subject permanently or temporarily", @@ -483,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/packages/api/src/client/index.ts b/packages/api/src/client/index.ts index 375a9a9c07f..df55181aef0 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -289,7 +289,6 @@ export const COM_ATPROTO_ADMIN = { DefsReviewOpen: 'com.atproto.admin.defs#reviewOpen', DefsReviewEscalated: 'com.atproto.admin.defs#reviewEscalated', DefsReviewClosed: 'com.atproto.admin.defs#reviewClosed', - DefsReviewAppealed: 'com.atproto.admin.defs#reviewAppealed', } export const COM_ATPROTO_MODERATION = { DefsReasonSpam: 'com.atproto.moderation.defs#reasonSpam', diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index dc1cce6ac8c..2ff45b93e4a 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,7 +238,7 @@ export const schemaDict = { type: 'string', format: 'datetime', }, - appealedAt: { + lastAppealedAt: { type: 'string', format: 'datetime', description: @@ -246,6 +247,11 @@ export const schemaDict = { 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. Null indicates no prior appeal on the subject.', + }, suspendUntil: { type: 'string', format: 'datetime', @@ -682,7 +688,6 @@ export const schemaDict = { 'lex:com.atproto.admin.defs#reviewOpen', 'lex:com.atproto.admin.defs#reviewEscalated', 'lex:com.atproto.admin.defs#reviewClosed', - 'lex:com.atproto.admin.defs#reviewAppealed', ], }, reviewOpen: { @@ -700,11 +705,6 @@ export const schemaDict = { description: 'Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator', }, - reviewAppealed: { - type: 'token', - description: - 'Moderator review status of a subject: Appealed. Indicates that the a previously taken moderator action was appealed agains, by the author of the content', - }, modEventTakedown: { type: 'object', description: 'Take down a subject permanently or temporarily', @@ -729,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', @@ -1369,6 +1379,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, 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 8201f3ba208..10adece39ff 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 @@ -148,8 +149,10 @@ export interface SubjectStatusView { lastReviewedAt?: string lastReportedAt?: string /** Timestamp referencing when the owner of the subject appealed a moderation action */ - appealedAt?: string + 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. Null indicates no prior appeal on the subject. */ + appealed?: boolean suspendUntil?: string [k: string]: unknown } @@ -490,7 +493,6 @@ export type SubjectReviewState = | 'lex:com.atproto.admin.defs#reviewOpen' | 'lex:com.atproto.admin.defs#reviewEscalated' | 'lex:com.atproto.admin.defs#reviewClosed' - | 'lex:com.atproto.admin.defs#reviewAppealed' | (string & {}) /** Moderator review status of a subject: Open. Indicates that the subject needs to be reviewed by a moderator */ @@ -499,8 +501,6 @@ export const REVIEWOPEN = 'com.atproto.admin.defs#reviewOpen' export const REVIEWESCALATED = 'com.atproto.admin.defs#reviewEscalated' /** Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator */ export const REVIEWCLOSED = 'com.atproto.admin.defs#reviewClosed' -/** Moderator review status of a subject: Appealed. Indicates that the a previously taken moderator action was appealed agains, by the author of the content */ -export const REVIEWAPPEALED = 'com.atproto.admin.defs#reviewAppealed' /** Take down a subject permanently or temporarily */ export interface ModEventTakedown { @@ -543,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/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/db/migrations/20231213T181744386Z-moderation-subject-appeal.ts b/packages/bsky/src/db/migrations/20231213T181744386Z-moderation-subject-appeal.ts index cce1f1546da..95662737a63 100644 --- a/packages/bsky/src/db/migrations/20231213T181744386Z-moderation-subject-appeal.ts +++ b/packages/bsky/src/db/migrations/20231213T181744386Z-moderation-subject-appeal.ts @@ -3,13 +3,21 @@ import { Kysely } from 'kysely' export async function up(db: Kysely): Promise { await db.schema .alterTable('moderation_subject_status') - .addColumn('appealedAt', 'varchar') + .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('appealedAt') + .dropColumn('lastAppealedAt') + .execute() + await db.schema + .alterTable('moderation_subject_status') + .dropColumn('appealed') .execute() } diff --git a/packages/bsky/src/db/tables/moderation.ts b/packages/bsky/src/db/tables/moderation.ts index 8632c908bb4..99f5e73310d 100644 --- a/packages/bsky/src/db/tables/moderation.ts +++ b/packages/bsky/src/db/tables/moderation.ts @@ -3,7 +3,6 @@ import { REVIEWCLOSED, REVIEWOPEN, REVIEWESCALATED, - REVIEWAPPEALED, } from '../../lexicon/types/com/atproto/admin/defs' export const eventTableName = 'moderation_event' @@ -21,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 @@ -42,20 +42,17 @@ export interface ModerationSubjectStatus { recordPath: string recordCid: string | null blobCids: string[] | null - reviewState: - | typeof REVIEWCLOSED - | typeof REVIEWOPEN - | typeof REVIEWESCALATED - | typeof REVIEWAPPEALED + reviewState: typeof REVIEWCLOSED | typeof REVIEWOPEN | typeof REVIEWESCALATED createdAt: string updatedAt: string lastReviewedBy: string | null lastReviewedAt: string | null lastReportedAt: string | null - appealedAt: 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 7b2cb30be3d..40c50cd1687 100644 --- a/packages/bsky/src/lexicon/index.ts +++ b/packages/bsky/src/lexicon/index.ts @@ -127,7 +127,6 @@ export const COM_ATPROTO_ADMIN = { DefsReviewOpen: 'com.atproto.admin.defs#reviewOpen', DefsReviewEscalated: 'com.atproto.admin.defs#reviewEscalated', DefsReviewClosed: 'com.atproto.admin.defs#reviewClosed', - DefsReviewAppealed: 'com.atproto.admin.defs#reviewAppealed', } export const COM_ATPROTO_MODERATION = { DefsReasonSpam: 'com.atproto.moderation.defs#reasonSpam', @@ -1599,11 +1598,13 @@ type RouteRateLimitOpts = { calcKey?: (ctx: T) => string calcPoints?: (ctx: T) => number } +type HandlerOpts = { blobLimit?: number } type HandlerRateLimitOpts = SharedRateLimitOpts | RouteRateLimitOpts type ConfigOf = | Handler | { auth?: Auth + opts?: HandlerOpts rateLimit?: HandlerRateLimitOpts | HandlerRateLimitOpts[] handler: Handler } diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index dc1cce6ac8c..2ff45b93e4a 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,7 +238,7 @@ export const schemaDict = { type: 'string', format: 'datetime', }, - appealedAt: { + lastAppealedAt: { type: 'string', format: 'datetime', description: @@ -246,6 +247,11 @@ export const schemaDict = { 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. Null indicates no prior appeal on the subject.', + }, suspendUntil: { type: 'string', format: 'datetime', @@ -682,7 +688,6 @@ export const schemaDict = { 'lex:com.atproto.admin.defs#reviewOpen', 'lex:com.atproto.admin.defs#reviewEscalated', 'lex:com.atproto.admin.defs#reviewClosed', - 'lex:com.atproto.admin.defs#reviewAppealed', ], }, reviewOpen: { @@ -700,11 +705,6 @@ export const schemaDict = { description: 'Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator', }, - reviewAppealed: { - type: 'token', - description: - 'Moderator review status of a subject: Appealed. Indicates that the a previously taken moderator action was appealed agains, by the author of the content', - }, modEventTakedown: { type: 'object', description: 'Take down a subject permanently or temporarily', @@ -729,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', @@ -1369,6 +1379,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, 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 850ac620f8d..1a4643ecff9 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 @@ -148,8 +149,10 @@ export interface SubjectStatusView { lastReviewedAt?: string lastReportedAt?: string /** Timestamp referencing when the owner of the subject appealed a moderation action */ - appealedAt?: string + 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. Null indicates no prior appeal on the subject. */ + appealed?: boolean suspendUntil?: string [k: string]: unknown } @@ -490,7 +493,6 @@ export type SubjectReviewState = | 'lex:com.atproto.admin.defs#reviewOpen' | 'lex:com.atproto.admin.defs#reviewEscalated' | 'lex:com.atproto.admin.defs#reviewClosed' - | 'lex:com.atproto.admin.defs#reviewAppealed' | (string & {}) /** Moderator review status of a subject: Open. Indicates that the subject needs to be reviewed by a moderator */ @@ -499,8 +501,6 @@ export const REVIEWOPEN = 'com.atproto.admin.defs#reviewOpen' export const REVIEWESCALATED = 'com.atproto.admin.defs#reviewEscalated' /** Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator */ export const REVIEWCLOSED = 'com.atproto.admin.defs#reviewClosed' -/** Moderator review status of a subject: Appealed. Indicates that the a previously taken moderator action was appealed agains, by the author of the content */ -export const REVIEWAPPEALED = 'com.atproto.admin.defs#reviewAppealed' /** Take down a subject permanently or temporarily */ export interface ModEventTakedown { @@ -543,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/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 6c8e22b67dc..9e82eb19827 100644 --- a/packages/bsky/src/services/moderation/status.ts +++ b/packages/bsky/src/services/moderation/status.ts @@ -7,7 +7,6 @@ import { REVIEWOPEN, REVIEWCLOSED, REVIEWESCALATED, - REVIEWAPPEALED, } from '../../lexicon/types/com/atproto/admin/defs' import { ModerationEventRow, ModerationSubjectStatusRow } from './types' import { HOUR } from '@atproto/common' @@ -84,6 +83,10 @@ const getSubjectStatusForModerationEvent = ({ lastReviewedBy: createdBy, lastReviewedAt: createdAt, } + case 'com.atproto.admin.defs#modEventResolveAppeal': + return { + appealed: false, + } default: return null } @@ -142,31 +145,13 @@ export const adjustModerationSubjectStatus = async ( .selectAll() .executeTakeFirst() - // If the incoming event is an appeal and the subject has never been appealed before, allow setting the state to appealed - if (isAppealEvent) { - if (!currentStatus?.appealedAt) { - subjectStatus.reviewState = REVIEWAPPEALED - subjectStatus.appealedAt = createdAt - } else { - // If the subject has been appealed before, we don't want to change the state to reviewOpen caused by the report event - // so we set the reviewState to whatever is the current state in DB - subjectStatus.reviewState = currentStatus.reviewState - } - } else if ( + if ( currentStatus?.reviewState === REVIEWESCALATED && subjectStatus.reviewState === REVIEWOPEN ) { // If the current status is escalated and the incoming event is to open the review // We want to keep the status as escalated subjectStatus.reviewState = REVIEWESCALATED - } else if ( - currentStatus?.reviewState === REVIEWAPPEALED && - subjectStatus.reviewState !== REVIEWCLOSED - ) { - // Keep the status as appealed if the incoming event is not to close the review - // But don't exit here because there may be other properties that - // require updating such lastReportedAt or lastReviewedAt - subjectStatus.reviewState = REVIEWAPPEALED } // Set these because we don't want to override them if they're already set @@ -191,6 +176,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 f133780d416..654a6e54291 100644 --- a/packages/bsky/src/services/moderation/views.ts +++ b/packages/bsky/src/services/moderation/views.ts @@ -485,10 +485,11 @@ export class ModerationViews { lastReviewedBy: subjectStatus.lastReviewedBy ?? undefined, lastReviewedAt: subjectStatus.lastReviewedAt ?? undefined, lastReportedAt: subjectStatus.lastReportedAt ?? undefined, - appealedAt: subjectStatus.appealedAt ?? 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 index 8d1701f73fe..4a3eb8b13ee 100644 --- a/packages/bsky/tests/admin/moderation-appeals.test.ts +++ b/packages/bsky/tests/admin/moderation-appeals.test.ts @@ -11,11 +11,11 @@ import { REASONSPAM, } from '../../src/lexicon/types/com/atproto/moderation/defs' import { - REVIEWAPPEALED, 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 @@ -57,11 +57,13 @@ describe('moderation-appeals', () => { 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', () => { @@ -70,8 +72,10 @@ describe('moderation-appeals', () => { uri: sc.posts[sc.dids.bob][1].ref.uriStr, cid: sc.posts[sc.dids.bob][1].ref.cidStr, }) - const assertBobsPostStatus = async (status: string) => - assertSubjectStatus(getBobsPostSubject().uri, status) + const assertBobsPostStatus = async ( + status: string, + appealed: boolean | undefined, + ) => assertSubjectStatus(getBobsPostSubject().uri, status, appealed) it('only changes subject status if original author of the content is appealing', async () => { // Create a report by alice @@ -84,7 +88,7 @@ describe('moderation-appeals', () => { createdBy: sc.dids.alice, }) - await assertBobsPostStatus(REVIEWOPEN) + await assertBobsPostStatus(REVIEWOPEN, undefined) await emitModerationEvent({ event: { @@ -96,7 +100,7 @@ describe('moderation-appeals', () => { }) // Verify that since the appeal was emitted by alice instead of bob, the status is still REVIEWOPEN - await assertBobsPostStatus(REVIEWOPEN) + await assertBobsPostStatus(REVIEWOPEN, undefined) await emitModerationEvent({ event: { @@ -107,21 +111,21 @@ describe('moderation-appeals', () => { createdBy: sc.dids.bob, }) - // Verify that since the appeal was emitted by alice instead of bob, the status is still REVIEWOPEN - const status = await assertBobsPostStatus(REVIEWAPPEALED) + // Verify that since the appeal was emitted by bob, the appealed state has been set to true + const status = await assertBobsPostStatus(REVIEWOPEN, true) expect(status?.appealedAt).not.toBeNull() }) - it('does not change status to appealed if an appeal was already received', async () => { + it('allows multiple appeals and updates last appealed timestamp', async () => { // Resolve appeal with acknowledge await emitModerationEvent({ event: { - $type: 'com.atproto.admin.defs#modEventAcknowledge', + $type: 'com.atproto.admin.defs#modEventResolveAppeal', }, subject: getBobsPostSubject(), createdBy: sc.dids.carol, }) - await assertBobsPostStatus(REVIEWCLOSED) + const previousStatus = await assertBobsPostStatus(REVIEWOPEN, false) await emitModerationEvent({ event: { @@ -132,8 +136,11 @@ describe('moderation-appeals', () => { createdBy: sc.dids.bob, }) - // Verify that even after the appeal event by bob for his post, the status is still REVIEWCLOSED - await assertBobsPostStatus(REVIEWCLOSED) + // 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()) }) }) @@ -143,7 +150,7 @@ describe('moderation-appeals', () => { uri: sc.posts[sc.dids.alice][1].ref.uriStr, cid: sc.posts[sc.dids.alice][1].ref.cidStr, }) - it('only allows changing appealed status to closed', async () => { + it('appeal status is maintained while review state changes based on incoming events', async () => { // Bob reports alice's post await emitModerationEvent({ event: { @@ -173,9 +180,9 @@ describe('moderation-appeals', () => { createdBy: sc.dids.alice, }) - await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWAPPEALED) + await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWOPEN, true) - // Another bob reports it again + // Bob reports it again await emitModerationEvent({ event: { $type: 'com.atproto.admin.defs#modEventReport', @@ -186,7 +193,7 @@ describe('moderation-appeals', () => { }) // Assert that the status is still REVIEWAPPEALED and not REVIEWOPEN, as report events are meant to do - await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWAPPEALED) + await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWOPEN, true) // Emit an escalation event await emitModerationEvent({ @@ -197,7 +204,11 @@ describe('moderation-appeals', () => { createdBy: sc.dids.carol, }) - await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWAPPEALED) + await assertSubjectStatus( + getAlicesPostSubject().uri, + REVIEWESCALATED, + true, + ) // Emit an acknowledge event await emitModerationEvent({ @@ -208,8 +219,21 @@ describe('moderation-appeals', () => { createdBy: sc.dids.carol, }) - // Assert that status moved on to reviewClosed - await assertSubjectStatus(getAlicesPostSubject().uri, REVIEWCLOSED) + // 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/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts index 7b2cb30be3d..40c50cd1687 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -127,7 +127,6 @@ export const COM_ATPROTO_ADMIN = { DefsReviewOpen: 'com.atproto.admin.defs#reviewOpen', DefsReviewEscalated: 'com.atproto.admin.defs#reviewEscalated', DefsReviewClosed: 'com.atproto.admin.defs#reviewClosed', - DefsReviewAppealed: 'com.atproto.admin.defs#reviewAppealed', } export const COM_ATPROTO_MODERATION = { DefsReasonSpam: 'com.atproto.moderation.defs#reasonSpam', @@ -1599,11 +1598,13 @@ type RouteRateLimitOpts = { calcKey?: (ctx: T) => string calcPoints?: (ctx: T) => number } +type HandlerOpts = { blobLimit?: number } type HandlerRateLimitOpts = SharedRateLimitOpts | RouteRateLimitOpts type ConfigOf = | Handler | { auth?: Auth + opts?: HandlerOpts rateLimit?: HandlerRateLimitOpts | HandlerRateLimitOpts[] handler: Handler } diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index dc1cce6ac8c..2ff45b93e4a 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,7 +238,7 @@ export const schemaDict = { type: 'string', format: 'datetime', }, - appealedAt: { + lastAppealedAt: { type: 'string', format: 'datetime', description: @@ -246,6 +247,11 @@ export const schemaDict = { 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. Null indicates no prior appeal on the subject.', + }, suspendUntil: { type: 'string', format: 'datetime', @@ -682,7 +688,6 @@ export const schemaDict = { 'lex:com.atproto.admin.defs#reviewOpen', 'lex:com.atproto.admin.defs#reviewEscalated', 'lex:com.atproto.admin.defs#reviewClosed', - 'lex:com.atproto.admin.defs#reviewAppealed', ], }, reviewOpen: { @@ -700,11 +705,6 @@ export const schemaDict = { description: 'Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator', }, - reviewAppealed: { - type: 'token', - description: - 'Moderator review status of a subject: Appealed. Indicates that the a previously taken moderator action was appealed agains, by the author of the content', - }, modEventTakedown: { type: 'object', description: 'Take down a subject permanently or temporarily', @@ -729,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', @@ -1369,6 +1379,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, 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 850ac620f8d..1a4643ecff9 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 @@ -148,8 +149,10 @@ export interface SubjectStatusView { lastReviewedAt?: string lastReportedAt?: string /** Timestamp referencing when the owner of the subject appealed a moderation action */ - appealedAt?: string + 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. Null indicates no prior appeal on the subject. */ + appealed?: boolean suspendUntil?: string [k: string]: unknown } @@ -490,7 +493,6 @@ export type SubjectReviewState = | 'lex:com.atproto.admin.defs#reviewOpen' | 'lex:com.atproto.admin.defs#reviewEscalated' | 'lex:com.atproto.admin.defs#reviewClosed' - | 'lex:com.atproto.admin.defs#reviewAppealed' | (string & {}) /** Moderator review status of a subject: Open. Indicates that the subject needs to be reviewed by a moderator */ @@ -499,8 +501,6 @@ export const REVIEWOPEN = 'com.atproto.admin.defs#reviewOpen' export const REVIEWESCALATED = 'com.atproto.admin.defs#reviewEscalated' /** Moderator review status of a subject: Closed. Indicates that the subject was already reviewed and resolved by a moderator */ export const REVIEWCLOSED = 'com.atproto.admin.defs#reviewClosed' -/** Moderator review status of a subject: Appealed. Indicates that the a previously taken moderator action was appealed agains, by the author of the content */ -export const REVIEWAPPEALED = 'com.atproto.admin.defs#reviewAppealed' /** Take down a subject permanently or temporarily */ export interface ModEventTakedown { @@ -543,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 }