UNPKG

@atproto/ozone

Version:

Backend service for moderating the Bluesky network.

229 lines (215 loc) 6.61 kB
import { AtUri } from '@atproto/syntax' import { InvalidRequestError } from '@atproto/xrpc-server' import * as ChatBskyConvoDefs from '../lexicon/types/chat/bsky/convo/defs' import { RepoRef, isRepoRef } from '../lexicon/types/com/atproto/admin/defs' import { InputSchema as ReportInput } from '../lexicon/types/com/atproto/moderation/createReport' import * as ComAtprotoRepoStrongRef from '../lexicon/types/com/atproto/repo/strongRef' import { InputSchema as ActionInput } from '../lexicon/types/tools/ozone/moderation/emitEvent' import { $Typed, asPredicate } from '../lexicon/util' import { ModerationEventRow, ModerationSubjectStatusRow } from './types' type SubjectInput = ReportInput['subject'] | ActionInput['subject'] type StrongRef = ComAtprotoRepoStrongRef.Main const isStrongRef = asPredicate(ComAtprotoRepoStrongRef.validateMain) type MessageRef = ChatBskyConvoDefs.MessageRef const isValidMessageRef = asPredicate(ChatBskyConvoDefs.validateMessageRef) const isMessageRefWithoutConvoId = ( subject: unknown, ): subject is $Typed<Omit<MessageRef, 'convoId'> & { convoId?: string }> => subject != null && typeof subject === 'object' && isValidMessageRef({ convoId: '', ...subject }) export const subjectFromInput = ( subject: SubjectInput, blobs?: string[], ): ModSubject => { if (isRepoRef(subject)) { if (blobs && blobs.length > 0) { throw new InvalidRequestError('Blobs do not apply to repo subjects') } return new RepoSubject(subject.did) } if (isStrongRef(subject)) { return new RecordSubject(subject.uri, subject.cid, blobs) } // @NOTE #messageRef is not a report input for com.atproto.moderation.createReport. // we are taking advantage of the open union in order for bsky.chat to interoperate here. if (isValidMessageRef(subject)) { return new MessageSubject(subject.did, subject.convoId, subject.messageId) } // @TODO we should start to require subject.convoId is a string in order to properly validate // the #messageRef. temporarily allowing it to be optional as a stopgap for rollout. // The next "if" can be removed once convoId is consistently provided. if (isMessageRefWithoutConvoId(subject)) { return new MessageSubject( subject.did, subject.convoId ?? '', subject.messageId, ) } throw new InvalidRequestError('Invalid subject') } export const subjectFromEventRow = (row: ModerationEventRow): ModSubject => { if ( row.subjectType === 'com.atproto.repo.strongRef' && row.subjectUri && row.subjectCid ) { return new RecordSubject( row.subjectUri, row.subjectCid, row.subjectBlobCids ?? [], ) } else if ( row.subjectType === 'chat.bsky.convo.defs#messageRef' && row.subjectMessageId ) { const convoId = typeof row.meta?.['convoId'] === 'string' ? row.meta['convoId'] : '' return new MessageSubject(row.subjectDid, convoId, row.subjectMessageId) } else { return new RepoSubject(row.subjectDid) } } export const subjectFromStatusRow = ( row: ModerationSubjectStatusRow, ): ModSubject => { if (row.recordPath && row.recordCid) { // Not too intuitive but the recordpath is basically <collection>/<rkey> // which is what the last 2 params of .make() arguments are const uri = AtUri.make(row.did, ...row.recordPath.split('/')).toString() return new RecordSubject(uri.toString(), row.recordCid, row.blobCids ?? []) } else { return new RepoSubject(row.did) } } type SubjectInfo = { subjectType: | 'com.atproto.admin.defs#repoRef' | 'com.atproto.repo.strongRef' | 'chat.bsky.convo.defs#messageRef' subjectDid: string subjectUri: string | null subjectCid: string | null subjectBlobCids: string[] | null subjectMessageId: string | null meta: Record<string, string | undefined> | null } export interface ModSubject { did: string recordPath: string | undefined blobCids?: string[] isRepo(): this is RepoSubject isRecord(): this is RecordSubject isMessage(): this is MessageSubject info(): SubjectInfo lex(): $Typed<RepoRef> | $Typed<StrongRef> | $Typed<MessageRef> } export class RepoSubject implements ModSubject { blobCids = undefined recordPath = undefined constructor(public did: string) {} isRepo(): this is RepoSubject { return true } isRecord(): this is RecordSubject { return false } isMessage(): this is MessageSubject { return false } info() { return { subjectType: 'com.atproto.admin.defs#repoRef' as const, subjectDid: this.did, subjectUri: null, subjectCid: null, subjectBlobCids: null, subjectMessageId: null, meta: null, } } lex(): $Typed<RepoRef> { return { $type: 'com.atproto.admin.defs#repoRef', did: this.did, } } } export class RecordSubject implements ModSubject { parsedUri: AtUri did: string recordPath: string constructor( public uri: string, public cid: string, public blobCids?: string[], ) { this.parsedUri = new AtUri(uri) this.did = this.parsedUri.hostname this.recordPath = `${this.parsedUri.collection}/${this.parsedUri.rkey}` } isRepo(): this is RepoSubject { return false } isRecord(): this is RecordSubject { return true } isMessage(): this is MessageSubject { return false } info() { return { subjectType: 'com.atproto.repo.strongRef' as const, subjectDid: this.did, subjectUri: this.uri, subjectCid: this.cid, subjectBlobCids: this.blobCids ?? [], subjectMessageId: null, meta: null, } } lex(): $Typed<StrongRef> { return { $type: 'com.atproto.repo.strongRef', uri: this.uri, cid: this.cid, } } } export class MessageSubject implements ModSubject { blobCids = undefined recordPath = undefined constructor( public did: string, public convoId: string, public messageId: string, ) {} isRepo(): this is RepoSubject { return false } isRecord(): this is RecordSubject { return false } isMessage(): this is MessageSubject { return true } info() { return { subjectType: 'chat.bsky.convo.defs#messageRef' as const, subjectDid: this.did, subjectUri: null, subjectCid: null, subjectBlobCids: null, subjectMessageId: this.messageId, meta: { convoId: this.convoId || undefined }, } } lex(): $Typed<MessageRef> { return { $type: 'chat.bsky.convo.defs#messageRef', did: this.did, convoId: this.convoId, messageId: this.messageId, } } }