UNPKG

@atproto/ozone

Version:

Backend service for moderating the Bluesky network.

140 lines (116 loc) 5.27 kB
import AtpAgent, { AppBskyLabelerDefs } from '@atproto/api' import { InvalidRequestError } from '@atproto/xrpc-server' import { OzoneConfig } from '../config' import { REASONAPPEAL, REASONMISLEADING, REASONOTHER, REASONRUDE, REASONSEXUAL, REASONSPAM, REASONVIOLATION, } from '../lexicon/types/com/atproto/moderation/defs' import { httpLogger } from '../logger' // Reverse mapping from new ozone namespaced reason types to old com.atproto namespaced reason types export const NEW_TO_OLD_REASON_MAPPING: Record<string, string> = { 'tools.ozone.report.defs#reasonAppeal': REASONAPPEAL, 'tools.ozone.report.defs#reasonOther': REASONOTHER, 'tools.ozone.report.defs#reasonViolenceAnimal': REASONVIOLATION, 'tools.ozone.report.defs#reasonViolenceThreats': REASONVIOLATION, 'tools.ozone.report.defs#reasonViolenceGraphicContent': REASONVIOLATION, 'tools.ozone.report.defs#reasonViolenceGlorification': REASONVIOLATION, 'tools.ozone.report.defs#reasonViolenceExtremistContent': REASONVIOLATION, 'tools.ozone.report.defs#reasonViolenceTrafficking': REASONVIOLATION, 'tools.ozone.report.defs#reasonViolenceOther': REASONVIOLATION, 'tools.ozone.report.defs#reasonSexualAbuseContent': REASONSEXUAL, 'tools.ozone.report.defs#reasonSexualNCII': REASONSEXUAL, 'tools.ozone.report.defs#reasonSexualDeepfake': REASONSEXUAL, 'tools.ozone.report.defs#reasonSexualAnimal': REASONSEXUAL, 'tools.ozone.report.defs#reasonSexualUnlabeled': REASONSEXUAL, 'tools.ozone.report.defs#reasonSexualOther': REASONSEXUAL, 'tools.ozone.report.defs#reasonChildSafetyCSAM': REASONVIOLATION, 'tools.ozone.report.defs#reasonChildSafetyGroom': REASONVIOLATION, 'tools.ozone.report.defs#reasonChildSafetyPrivacy': REASONVIOLATION, 'tools.ozone.report.defs#reasonChildSafetyHarassment': REASONVIOLATION, 'tools.ozone.report.defs#reasonChildSafetyOther': REASONVIOLATION, 'tools.ozone.report.defs#reasonHarassmentTroll': REASONRUDE, 'tools.ozone.report.defs#reasonHarassmentTargeted': REASONRUDE, 'tools.ozone.report.defs#reasonHarassmentHateSpeech': REASONRUDE, 'tools.ozone.report.defs#reasonHarassmentDoxxing': REASONRUDE, 'tools.ozone.report.defs#reasonHarassmentOther': REASONRUDE, 'tools.ozone.report.defs#reasonMisleadingBot': REASONMISLEADING, 'tools.ozone.report.defs#reasonMisleadingImpersonation': REASONMISLEADING, 'tools.ozone.report.defs#reasonMisleadingSpam': REASONSPAM, 'tools.ozone.report.defs#reasonMisleadingScam': REASONMISLEADING, 'tools.ozone.report.defs#reasonMisleadingElections': REASONMISLEADING, 'tools.ozone.report.defs#reasonMisleadingOther': REASONMISLEADING, 'tools.ozone.report.defs#reasonRuleSiteSecurity': REASONVIOLATION, 'tools.ozone.report.defs#reasonRuleProhibitedSales': REASONVIOLATION, 'tools.ozone.report.defs#reasonRuleBanEvasion': REASONVIOLATION, 'tools.ozone.report.defs#reasonRuleOther': REASONVIOLATION, 'tools.ozone.report.defs#reasonSelfHarmContent': REASONVIOLATION, 'tools.ozone.report.defs#reasonSelfHarmED': REASONVIOLATION, 'tools.ozone.report.defs#reasonSelfHarmStunts': REASONVIOLATION, 'tools.ozone.report.defs#reasonSelfHarmSubstances': REASONVIOLATION, 'tools.ozone.report.defs#reasonSelfHarmOther': REASONVIOLATION, } interface CacheEntry { profile: AppBskyLabelerDefs.LabelerViewDetailed | null timestamp: number } export type ModerationServiceProfileCreator = () => ModerationServiceProfile export class ModerationServiceProfile { private cache: CacheEntry | null = null private CACHE_TTL: number constructor( private cfg: OzoneConfig, private appviewAgent: AtpAgent, cacheTTL?: number, ) { this.CACHE_TTL = cacheTTL || cfg.service.serviceRecordCacheTTL } static creator( cfg: OzoneConfig, appviewAgent: AtpAgent, ): ModerationServiceProfileCreator { return () => new ModerationServiceProfile(cfg, appviewAgent) } async getProfile() { const now = Date.now() if (!this.cache || now - this.cache.timestamp > this.CACHE_TTL) { try { const { data } = await this.appviewAgent.app.bsky.labeler.getServices({ dids: [this.cfg.service.did], detailed: true, }) if (AppBskyLabelerDefs.isLabelerViewDetailed(data.views?.[0])) { this.cache = { profile: data.views[0], timestamp: now, } } } catch (e) { // On error, fail open httpLogger.error(`Failed to fetch labeler profile: ${e?.['message']}`) } } return this.cache?.profile || null } async validateReasonType(reasonType: string): Promise<string> { const profile = await this.getProfile() if (!Array.isArray(profile?.reasonTypes)) { return reasonType } const supportedReasonTypes = profile.reasonTypes // Check if the reason type is directly supported if (supportedReasonTypes.includes(reasonType)) { return reasonType } // Allow new reason types only if they map to a supported old reason type const mappedOldReason = NEW_TO_OLD_REASON_MAPPING[reasonType] if (mappedOldReason && supportedReasonTypes.includes(mappedOldReason)) { return reasonType } throw new InvalidRequestError(`Invalid reason type: ${reasonType}`) } }