diff --git a/.github/workflows/build-and-push-bsky-aws.yaml b/.github/workflows/build-and-push-bsky-aws.yaml index beaf10eb655..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 - - timeline-limit-1-opt + - 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 481909e53b0..55a8b32be53 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" @@ -470,6 +480,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 9fe2c8e870a..fb56cd251a0 100644 --- a/packages/api/src/client/index.ts +++ b/packages/api/src/client/index.ts @@ -299,6 +299,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 b1814fd4cd9..258d297c69e 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', @@ -723,6 +735,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', @@ -1406,6 +1428,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, @@ -1991,6 +2017,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: { @@ -2018,6 +2045,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 108d7e337fa..aea27e86905 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 } @@ -539,6 +544,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/lexicon/index.ts b/packages/bsky/src/lexicon/index.ts index 5d5b8c1affd..386f77196e7 100644 --- a/packages/bsky/src/lexicon/index.ts +++ b/packages/bsky/src/lexicon/index.ts @@ -136,6 +136,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 b1814fd4cd9..258d297c69e 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', @@ -723,6 +735,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', @@ -1406,6 +1428,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, @@ -1991,6 +2017,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: { @@ -2018,6 +2045,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 4d0d7af7987..8236f848fa0 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 } @@ -539,6 +544,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/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/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/ozone/src/api/admin/queryModerationStatuses.ts b/packages/ozone/src/api/admin/queryModerationStatuses.ts index b9361494c90..fc935e5917a 100644 --- a/packages/ozone/src/api/admin/queryModerationStatuses.ts +++ b/packages/ozone/src/api/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/ozone/src/api/moderation/createReport.ts b/packages/ozone/src/api/moderation/createReport.ts index 61936b957f1..04bdc5883a3 100644 --- a/packages/ozone/src/api/moderation/createReport.ts +++ b/packages/ozone/src/api/moderation/createReport.ts @@ -2,6 +2,8 @@ import { Server } from '../../lexicon' import AppContext from '../../context' import { getReasonType } from './util' import { subjectFromInput } from '../../mod-service/subject' +import { REASONAPPEAL } from '../../lexicon/types/com/atproto/moderation/defs' +import { ForbiddenError } from '@atproto/xrpc-server' export default function (server: Server, ctx: AppContext) { server.com.atproto.moderation.createReport({ @@ -14,8 +16,13 @@ export default function (server: Server, ctx: AppContext) { : ctx.cfg.service.labelerDid const { reasonType, reason } = input.body const subject = subjectFromInput(input.body.subject) - const db = ctx.db + // If the report is an appeal, the requester must be the author of the subject + if (reasonType === REASONAPPEAL && requester !== subject.did) { + throw new ForbiddenError('You cannot appeal this report') + } + + const db = ctx.db const report = await db.transaction(async (dbTxn) => { const moderationTxn = ctx.modService(dbTxn) return moderationTxn.report({ diff --git a/packages/ozone/src/api/moderation/util.ts b/packages/ozone/src/api/moderation/util.ts index c9fcbf9b068..040007d5e79 100644 --- a/packages/ozone/src/api/moderation/util.ts +++ b/packages/ozone/src/api/moderation/util.ts @@ -7,6 +7,7 @@ import { REASONRUDE, REASONSEXUAL, REASONVIOLATION, + REASONAPPEAL, } from '../../lexicon/types/com/atproto/moderation/defs' import { REVIEWCLOSED, @@ -47,6 +48,7 @@ const reasonTypes = new Set([ REASONRUDE, REASONSEXUAL, REASONVIOLATION, + REASONAPPEAL, ]) const eventTypes = new Set([ diff --git a/packages/ozone/src/db/migrations/20231219T205730722Z-init.ts b/packages/ozone/src/db/migrations/20231219T205730722Z-init.ts index 87c47682dfb..1e9ab54e356 100644 --- a/packages/ozone/src/db/migrations/20231219T205730722Z-init.ts +++ b/packages/ozone/src/db/migrations/20231219T205730722Z-init.ts @@ -45,10 +45,12 @@ export async function up(db: Kysely): Promise { // report state .addColumn('lastReportedAt', 'varchar') + .addColumn('lastAppealedAt', 'varchar') // visibility/intervention state .addColumn('takendown', 'boolean', (col) => col.defaultTo(false).notNull()) .addColumn('suspendUntil', 'varchar') + .addColumn('appealed', 'boolean') // timestamps .addColumn('createdAt', 'varchar', (col) => col.notNull()) diff --git a/packages/ozone/src/db/schema/moderation_event.ts b/packages/ozone/src/db/schema/moderation_event.ts index 8bf2652c7fb..0cf7d07c1e5 100644 --- a/packages/ozone/src/db/schema/moderation_event.ts +++ b/packages/ozone/src/db/schema/moderation_event.ts @@ -14,6 +14,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 diff --git a/packages/ozone/src/db/schema/moderation_subject_status.ts b/packages/ozone/src/db/schema/moderation_subject_status.ts index 26245f35693..6e67082f31c 100644 --- a/packages/ozone/src/db/schema/moderation_subject_status.ts +++ b/packages/ozone/src/db/schema/moderation_subject_status.ts @@ -19,9 +19,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/ozone/src/lexicon/index.ts b/packages/ozone/src/lexicon/index.ts index 5d5b8c1affd..386f77196e7 100644 --- a/packages/ozone/src/lexicon/index.ts +++ b/packages/ozone/src/lexicon/index.ts @@ -136,6 +136,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/ozone/src/lexicon/lexicons.ts b/packages/ozone/src/lexicon/lexicons.ts index de8238b60a7..258d297c69e 100644 --- a/packages/ozone/src/lexicon/lexicons.ts +++ b/packages/ozone/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', @@ -723,6 +735,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', @@ -822,6 +844,10 @@ export const schemaDict = { type: 'string', description: 'The subject line of the email sent to the user.', }, + comment: { + type: 'string', + description: 'Additional comment about the outgoing comm.', + }, }, }, }, @@ -1402,6 +1428,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, @@ -1512,6 +1542,11 @@ export const schemaDict = { type: 'string', format: 'did', }, + comment: { + type: 'string', + description: + "Additional comment by the sender that won't be used in the email itself but helpful to provide more context for moderators/reviewers", + }, }, }, }, @@ -1982,6 +2017,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: { @@ -2009,6 +2045,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/ozone/src/lexicon/types/com/atproto/admin/defs.ts b/packages/ozone/src/lexicon/types/com/atproto/admin/defs.ts index 5fc47970b1c..8236f848fa0 100644 --- a/packages/ozone/src/lexicon/types/com/atproto/admin/defs.ts +++ b/packages/ozone/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 } @@ -539,6 +544,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 @@ -675,6 +701,8 @@ export function validateModEventUnmute(v: unknown): ValidationResult { export interface ModEventEmail { /** The subject line of the email sent to the user. */ subjectLine: string + /** Additional comment about the outgoing comm. */ + comment?: string [k: string]: unknown } diff --git a/packages/ozone/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts b/packages/ozone/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts index d4e55aff386..6e1aea1f679 100644 --- a/packages/ozone/src/lexicon/types/com/atproto/admin/queryModerationStatuses.ts +++ b/packages/ozone/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/ozone/src/lexicon/types/com/atproto/admin/sendEmail.ts b/packages/ozone/src/lexicon/types/com/atproto/admin/sendEmail.ts index 91b53d9be81..f94cfb3a083 100644 --- a/packages/ozone/src/lexicon/types/com/atproto/admin/sendEmail.ts +++ b/packages/ozone/src/lexicon/types/com/atproto/admin/sendEmail.ts @@ -15,6 +15,8 @@ export interface InputSchema { content: string subject?: string senderDid: string + /** Additional comment by the sender that won't be used in the email itself but helpful to provide more context for moderators/reviewers */ + comment?: string [k: string]: unknown } diff --git a/packages/ozone/src/lexicon/types/com/atproto/moderation/defs.ts b/packages/ozone/src/lexicon/types/com/atproto/moderation/defs.ts index 81697226189..08e555c2422 100644 --- a/packages/ozone/src/lexicon/types/com/atproto/moderation/defs.ts +++ b/packages/ozone/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/ozone/src/mod-service/index.ts b/packages/ozone/src/mod-service/index.ts index 3558779b72a..d9ca735ff5b 100644 --- a/packages/ozone/src/mod-service/index.ts +++ b/packages/ozone/src/mod-service/index.ts @@ -467,6 +467,7 @@ export class ModerationService { cursor, limit = 50, takendown, + appealed, reviewState, reviewedAfter, reviewedBefore, @@ -482,6 +483,7 @@ export class ModerationService { cursor?: string limit?: number takendown?: boolean + appealed?: boolean | null reviewedBefore?: string reviewState?: ModerationSubjectStatusRow['reviewState'] reviewedAfter?: string @@ -541,6 +543,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/ozone/src/mod-service/status.ts b/packages/ozone/src/mod-service/status.ts index f7da3ead782..598ebe20712 100644 --- a/packages/ozone/src/mod-service/status.ts +++ b/packages/ozone/src/mod-service/status.ts @@ -11,6 +11,7 @@ import { import { ModerationEventRow, ModerationSubjectStatusRow } from './types' import { HOUR } from '@atproto/common' import { sql } from 'kysely' +import { REASONAPPEAL } from '../lexicon/types/com/atproto/moderation/defs' const getSubjectStatusForModerationEvent = ({ action, @@ -81,6 +82,10 @@ const getSubjectStatusForModerationEvent = ({ lastReviewedBy: createdBy, lastReviewedAt: createdAt, } + case 'com.atproto.admin.defs#modEventResolveAppeal': + return { + appealed: false, + } default: return null } @@ -105,6 +110,10 @@ export const adjustModerationSubjectStatus = async ( createdAt, } = moderationEvent + const isAppealEvent = + action === 'com.atproto.admin.defs#modEventReport' && + meta?.reportType === REASONAPPEAL + const subjectStatus = getSubjectStatusForModerationEvent({ action, createdBy, @@ -161,6 +170,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/ozone/tests/moderation-appeals.test.ts b/packages/ozone/tests/moderation-appeals.test.ts new file mode 100644 index 00000000000..4feaa4b4025 --- /dev/null +++ b/packages/ozone/tests/moderation-appeals.test.ts @@ -0,0 +1,268 @@ +import { TestNetwork, SeedClient, basicSeed } from '@atproto/dev-env' +import AtpAgent, { + ComAtprotoAdminDefs, + ComAtprotoAdminEmitModerationEvent, + ComAtprotoAdminQueryModerationStatuses, +} from '@atproto/api' +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: 'ozone_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/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 5d5b8c1affd..386f77196e7 100644 --- a/packages/pds/src/lexicon/index.ts +++ b/packages/pds/src/lexicon/index.ts @@ -136,6 +136,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 b1814fd4cd9..258d297c69e 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', @@ -723,6 +735,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', @@ -1406,6 +1428,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, @@ -1991,6 +2017,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: { @@ -2018,6 +2045,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 4d0d7af7987..8236f848fa0 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 } @@ -539,6 +544,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' diff --git a/services/bsky/api.js b/services/bsky/api.js index c4882335761..42737d72b56 100644 --- a/services/bsky/api.js +++ b/services/bsky/api.js @@ -13,6 +13,7 @@ require('dd-trace') // Only works with commonjs // Tracer code above must come before anything else const path = require('path') const assert = require('assert') +const cluster = require('cluster') const { BunnyInvalidator, CloudfrontInvalidator, @@ -140,12 +141,14 @@ const main = async () => { await bsky.start() // Graceful shutdown (see also https://aws.amazon.com/blogs/containers/graceful-shutdowns-with-ecs/) - process.on('SIGTERM', async () => { + const shutdown = async () => { // Gracefully shutdown periodic-moderation-event-reversal before destroying bsky instance periodicModerationEventReversal.destroy() await periodicModerationEventReversalRunning await bsky.destroy() - }) + } + process.on('SIGTERM', shutdown) + process.on('disconnect', shutdown) // when clustering } const getEnv = () => ({ @@ -223,4 +226,31 @@ const maintainXrpcResource = (span, req) => { } } -main() +const workerCount = maybeParseInt(process.env.CLUSTER_WORKER_COUNT) + +if (workerCount) { + if (cluster.isPrimary) { + console.log(`primary ${process.pid} is running`) + const workers = new Set() + for (let i = 0; i < workerCount; ++i) { + workers.add(cluster.fork()) + } + let teardown = false + cluster.on('exit', (worker) => { + workers.delete(worker) + if (!teardown) { + workers.add(cluster.fork()) // restart on crash + } + }) + process.on('SIGTERM', () => { + teardown = true + console.log('disconnecting workers') + workers.forEach((w) => w.disconnect()) + }) + } else { + console.log(`worker ${process.pid} is running`) + main() + } +} else { + main() // non-clustering +} diff --git a/services/pds/Dockerfile b/services/pds/Dockerfile index c108df56ddd..6d092bb5229 100644 --- a/services/pds/Dockerfile +++ b/services/pds/Dockerfile @@ -1,4 +1,6 @@ -FROM node:18-alpine as build +# @NOTE just a temp fix: alpine3.19 breaks sharp install, see nodejs/docker-node#2009 +# see additional reference to this image further down. +FROM node:18-alpine3.18 as build RUN npm install -g pnpm @@ -35,7 +37,7 @@ RUN pnpm install --prod --shamefully-hoist --frozen-lockfile --prefer-offline > WORKDIR services/pds # Uses assets from build stage to reduce build size -FROM node:18-alpine +FROM node:18-alpine3.18 RUN apk add --update dumb-init