UNPKG

@atproto/ozone

Version:

Backend service for moderating the Bluesky network.

280 lines (253 loc) 7.94 kB
import { Selectable } from 'kysely' import { InvalidRequestError } from '@atproto/xrpc-server' import { ScheduledActionStatus, ScheduledActionType } from '../api/util' import { Database } from '../db' import { ScheduledAction } from '../db/schema/scheduled-action' import { ScheduledActionView } from '../lexicon/types/tools/ozone/moderation/defs' import { dbLogger } from '../logger' import { SchedulingParams } from './types' export type ScheduledActionServiceCreator = ( db: Database, ) => ScheduledActionService export class ScheduledActionService { constructor(public db: Database) {} static creator() { return (db: Database) => new ScheduledActionService(db) } formatScheduledAction( action: Selectable<ScheduledAction>, ): ScheduledActionView { return { id: action.id, action: action.action, eventData: action.eventData as { [x: string]: unknown } | undefined, did: action.did, executeAt: action.executeAt ? new Date(action.executeAt).toISOString() : undefined, executeAfter: action.executeAfter ? new Date(action.executeAfter).toISOString() : undefined, executeUntil: action.executeUntil ? new Date(action.executeUntil).toISOString() : undefined, randomizeExecution: action.randomizeExecution, createdBy: action.createdBy, createdAt: new Date(action.createdAt).toISOString(), updatedAt: new Date(action.updatedAt).toISOString(), status: action.status, lastExecutedAt: action.lastExecutedAt ? new Date(action.lastExecutedAt).toISOString() : undefined, lastFailureReason: action.lastFailureReason || undefined, executionEventId: action.executionEventId || undefined, } } async scheduleAction( schedulingParams: SchedulingParams, ): Promise<Selectable<ScheduledAction>> { const { action, eventData, did, createdBy } = schedulingParams // Only allow one pending action at a time for a given subject and action type const existingAction = await this.getPendingActionForSubject(did, action) if (existingAction) { throw new InvalidRequestError( 'A pending scheduled action already exists for this subject', 'ActionAlreadyExists', ) } // When a time-range for action is specified, ensure that the range is valid if ( 'executeAfter' in schedulingParams && schedulingParams.executeAfter && schedulingParams.executeUntil && schedulingParams.executeAfter >= schedulingParams.executeUntil ) { throw new InvalidRequestError( 'executeAfter must be before executeUntil', 'InvalidScheduling', ) } const now = new Date().toISOString() const randomizeExecution = !('executeAt' in schedulingParams) && 'executeAfter' in schedulingParams const scheduledAction = await this.db.db .insertInto('scheduled_action') .values({ action, eventData: JSON.stringify(eventData), did, executeAt: randomizeExecution ? null : schedulingParams.executeAt?.toISOString(), executeAfter: randomizeExecution ? schedulingParams.executeAfter?.toISOString() : null, executeUntil: randomizeExecution ? schedulingParams.executeUntil?.toISOString() : null, randomizeExecution, createdBy, createdAt: now, updatedAt: now, status: 'pending', }) .returningAll() .executeTakeFirstOrThrow() return scheduledAction } async getPendingActionForSubject( did: string, action: ScheduledActionType, ): Promise<Selectable<ScheduledAction> | null> { const scheduledAction = await this.db.db .selectFrom('scheduled_action') .selectAll() .where('did', '=', did) .where('action', '=', action) .where('status', '=', 'pending') .executeTakeFirst() return scheduledAction || null } async listScheduledActions({ cursor, limit = 50, startTime, endTime, subjects, statuses = [], direction = 'desc', }: { cursor?: string limit?: number startTime?: Date endTime?: Date subjects?: string[] statuses: ScheduledActionStatus[] direction?: 'asc' | 'desc' }): Promise<{ actions: Selectable<ScheduledAction>[] cursor?: string }> { let query = this.db.db .selectFrom('scheduled_action') .where('status', 'in', statuses) .selectAll() if (subjects && subjects.length > 0) { query = query.where('did', 'in', subjects) } if (startTime) { query = query.where((qb) => { return qb .orWhere('executeAt', '>=', startTime.toISOString()) .orWhere('executeAfter', '>=', startTime.toISOString()) }) } if (endTime) { query = query.where((qb) => { return qb .orWhere('executeAt', '<=', endTime.toISOString()) .orWhere('executeUntil', '<=', endTime.toISOString()) .orWhere((sqb) => { return sqb .where('executeUntil', 'is', null) .where('executeAfter', '<=', endTime.toISOString()) }) }) } if (cursor) { query = query.where( 'id', direction === 'asc' ? '>' : '<', parseInt(cursor, 10), ) } const actions = await query.orderBy('id', direction).limit(limit).execute() return { actions, cursor: actions.at(-1)?.id?.toString(), } } async cancelScheduledActions(subjects: string[]): Promise<{ succeeded: string[] failed: { did: string; error: string; errorCode?: string }[] }> { const succeeded: string[] = [] const failed: { did: string; error: string; errorCode?: string }[] = [] for (const did of subjects) { try { const result = await this.db.db .updateTable('scheduled_action') .set({ status: 'cancelled', updatedAt: new Date().toISOString(), }) .where('did', '=', did) .where('status', '=', 'pending') .executeTakeFirst() if (result.numUpdatedRows && result.numUpdatedRows > 0) { succeeded.push(did) } else { failed.push({ did, error: 'No pending scheduled actions found for subject', errorCode: 'NoPendingActions', }) } } catch (err) { dbLogger.error({ err, subjects }, 'Error cancelling scheduled action') failed.push({ did, error: 'Unknown error', errorCode: 'DatabaseError', }) } } return { succeeded, failed } } async getPendingActionsToExecute( now: Date, ): Promise<Selectable<ScheduledAction>[]> { return await this.db.db .selectFrom('scheduled_action') .selectAll() .where('status', '=', 'pending') .where((qb) => { return qb .orWhere('executeAfter', '<=', now.toISOString()) .orWhere('executeAt', '<=', now.toISOString()) }) .execute() } async markActionAsExecuted( actionId: number, executionEventId: number, ): Promise<void> { const now = new Date().toISOString() await this.db.db .updateTable('scheduled_action') .set({ status: 'executed', lastExecutedAt: now, executionEventId, updatedAt: now, }) .where('id', '=', actionId) .execute() } async markActionAsFailed( actionId: number, failureReason: string, ): Promise<void> { const now = new Date().toISOString() await this.db.db .updateTable('scheduled_action') .set({ status: 'failed', lastExecutedAt: now, lastFailureReason: failureReason, updatedAt: now, }) .where('id', '=', actionId) .execute() } }