bsky-app/src/lib/moderatePost_wrapped.ts
Samuel Newman 1512b5cf68 run linter
2024-03-12 22:02:28 +00:00

380 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
AppBskyActorDefs,
AppBskyEmbedExternal,
AppBskyEmbedImages,
AppBskyEmbedRecord,
AppBskyEmbedRecordWithMedia,
AppBskyFeedPost,
AppBskyRichtextFacet,
moderatePost,
} from '@atproto/api'
type ModeratePost = typeof moderatePost
type Options = Parameters<ModeratePost>[1] & {
hiddenPosts?: string[]
mutedWords?: AppBskyActorDefs.MutedWord[]
}
const REGEX = {
LEADING_TRAILING_PUNCTUATION: /(?:^\p{P}+|\p{P}+$)/gu,
ESCAPE: /[[\]{}()*+?.\\^$|\s]/g,
SEPARATORS: /[\/\-\\—\(\)\[\]\_]+/g,
WORD_BOUNDARY: /[\s\n\t\r\f\v]+?/g,
}
/**
* List of 2-letter lang codes for languages that either don't use spaces, or
* don't use spaces in a way conducive to word-based filtering.
*
* For these, we use a simple `String.includes` to check for a match.
*/
const LANGUAGE_EXCEPTIONS = [
'ja', // Japanese
'zh', // Chinese
'ko', // Korean
'th', // Thai
'vi', // Vietnamese
]
export function hasMutedWord({
mutedWords,
text,
facets,
outlineTags,
languages,
isOwnPost,
}: {
mutedWords: AppBskyActorDefs.MutedWord[]
text: string
facets?: AppBskyRichtextFacet.Main[]
outlineTags?: string[]
languages?: string[]
isOwnPost: boolean
}) {
if (isOwnPost) return false
const exception = LANGUAGE_EXCEPTIONS.includes(languages?.[0] || '')
const tags = ([] as string[])
.concat(outlineTags || [])
.concat(
facets
?.filter(facet => {
return facet.features.find(feature =>
AppBskyRichtextFacet.isTag(feature),
)
})
.map(t => t.features[0].tag as string) || [],
)
.map(t => t.toLowerCase())
for (const mute of mutedWords) {
const mutedWord = mute.value.toLowerCase()
const postText = text.toLowerCase()
// `content` applies to tags as well
if (tags.includes(mutedWord)) return true
// rest of the checks are for `content` only
if (!mute.targets.includes('content')) continue
// single character or other exception, has to use includes
if ((mutedWord.length === 1 || exception) && postText.includes(mutedWord))
return true
// too long
if (mutedWord.length > postText.length) continue
// exact match
if (mutedWord === postText) return true
// any muted phrase with space or punctuation
if (/(?:\s|\p{P})+?/u.test(mutedWord) && postText.includes(mutedWord))
return true
// check individual character groups
const words = postText.split(REGEX.WORD_BOUNDARY)
for (const word of words) {
if (word === mutedWord) return true
// compare word without leading/trailing punctuation, but allow internal
// punctuation (such as `s@ssy`)
const wordTrimmedPunctuation = word.replace(
REGEX.LEADING_TRAILING_PUNCTUATION,
'',
)
if (mutedWord === wordTrimmedPunctuation) return true
if (mutedWord.length > wordTrimmedPunctuation.length) continue
// handle hyphenated, slash separated words, etc
if (REGEX.SEPARATORS.test(wordTrimmedPunctuation)) {
// check against full normalized phrase
const wordNormalizedSeparators = wordTrimmedPunctuation.replace(
REGEX.SEPARATORS,
' ',
)
const mutedWordNormalizedSeparators = mutedWord.replace(
REGEX.SEPARATORS,
' ',
)
// hyphenated (or other sep) to spaced words
if (wordNormalizedSeparators === mutedWordNormalizedSeparators)
return true
/* Disabled for now e.g. `super-cool` to `supercool`
const wordNormalizedCompressed = wordNormalizedSeparators.replace(
REGEX.WORD_BOUNDARY,
'',
)
const mutedWordNormalizedCompressed =
mutedWordNormalizedSeparators.replace(/\s+?/g, '')
// hyphenated (or other sep) to non-hyphenated contiguous word
if (mutedWordNormalizedCompressed === wordNormalizedCompressed)
return true
*/
// then individual parts of separated phrases/words
const wordParts = wordTrimmedPunctuation.split(REGEX.SEPARATORS)
for (const wp of wordParts) {
// still retain internal punctuation
if (wp === mutedWord) return true
}
}
}
}
return false
}
export function moderatePost_wrapped(
subject: Parameters<ModeratePost>[0],
opts: Options,
) {
const {hiddenPosts = [], mutedWords = [], ...options} = opts
const moderations = moderatePost(subject, options)
const isOwnPost = subject.author.did === opts.userDid
if (hiddenPosts.includes(subject.uri)) {
moderations.content.filter = true
moderations.content.blur = true
if (!moderations.content.cause) {
moderations.content.cause = {
// @ts-ignore Temporary extension to the moderation system -prf
type: 'post-hidden',
source: {type: 'user'},
priority: 1,
}
}
}
if (AppBskyFeedPost.isRecord(subject.record)) {
let muted = hasMutedWord({
mutedWords,
text: subject.record.text,
facets: subject.record.facets || [],
outlineTags: subject.record.tags || [],
languages: subject.record.langs,
isOwnPost,
})
if (
subject.record.embed &&
AppBskyEmbedImages.isMain(subject.record.embed)
) {
for (const image of subject.record.embed.images) {
muted =
muted ||
hasMutedWord({
mutedWords,
text: image.alt,
facets: [],
outlineTags: [],
languages: subject.record.langs,
isOwnPost,
})
}
}
if (muted) {
moderations.content.filter = true
moderations.content.blur = true
if (!moderations.content.cause) {
moderations.content.cause = {
// @ts-ignore Temporary extension to the moderation system -prf
type: 'muted-word',
source: {type: 'user'},
priority: 1,
}
}
}
}
if (subject.embed) {
let embedHidden = false
let embedMuted = false
let externalMuted = false
if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
embedHidden = hiddenPosts.includes(subject.embed.record.uri)
}
if (
AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
) {
embedHidden = hiddenPosts.includes(subject.embed.record.record.uri)
}
if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
if (AppBskyFeedPost.isRecord(subject.embed.record.value)) {
const embeddedPost = subject.embed.record.value
embedMuted =
embedMuted ||
hasMutedWord({
mutedWords,
text: embeddedPost.text,
facets: embeddedPost.facets,
outlineTags: embeddedPost.tags,
languages: embeddedPost.langs,
isOwnPost,
})
if (AppBskyEmbedImages.isMain(embeddedPost.embed)) {
for (const image of embeddedPost.embed.images) {
embedMuted =
embedMuted ||
hasMutedWord({
mutedWords,
text: image.alt,
facets: [],
outlineTags: [],
languages: embeddedPost.langs,
isOwnPost,
})
}
}
if (AppBskyEmbedExternal.isMain(embeddedPost.embed)) {
const {external} = embeddedPost.embed
embedMuted =
embedMuted ||
hasMutedWord({
mutedWords,
text: external.title + ' ' + external.description,
facets: [],
outlineTags: [],
languages: [],
isOwnPost,
})
}
if (AppBskyEmbedRecordWithMedia.isMain(embeddedPost.embed)) {
if (AppBskyEmbedExternal.isMain(embeddedPost.embed.media)) {
const {external} = embeddedPost.embed.media
embedMuted =
embedMuted ||
hasMutedWord({
mutedWords,
text: external.title + ' ' + external.description,
facets: [],
outlineTags: [],
languages: [],
isOwnPost,
})
}
if (AppBskyEmbedImages.isMain(embeddedPost.embed.media)) {
for (const image of embeddedPost.embed.media.images) {
embedMuted =
embedMuted ||
hasMutedWord({
mutedWords,
text: image.alt,
facets: [],
outlineTags: [],
languages: AppBskyFeedPost.isRecord(embeddedPost.record)
? embeddedPost.langs
: [],
isOwnPost,
})
}
}
}
}
}
if (AppBskyEmbedExternal.isView(subject.embed)) {
const {external} = subject.embed
externalMuted =
externalMuted ||
hasMutedWord({
mutedWords,
text: external.title + ' ' + external.description,
facets: [],
outlineTags: [],
languages: [],
isOwnPost,
})
}
if (
AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
) {
if (AppBskyFeedPost.isRecord(subject.embed.record.record.value)) {
const post = subject.embed.record.record.value
embedMuted =
embedMuted ||
hasMutedWord({
mutedWords,
text: post.text,
facets: post.facets,
outlineTags: post.tags,
languages: post.langs,
isOwnPost,
})
}
if (AppBskyEmbedImages.isView(subject.embed.media)) {
for (const image of subject.embed.media.images) {
embedMuted =
embedMuted ||
hasMutedWord({
mutedWords,
text: image.alt,
facets: [],
outlineTags: [],
languages: AppBskyFeedPost.isRecord(subject.record)
? subject.record.langs
: [],
isOwnPost,
})
}
}
}
if (embedHidden) {
moderations.embed.filter = true
moderations.embed.blur = true
if (!moderations.embed.cause) {
moderations.embed.cause = {
// @ts-ignore Temporary extension to the moderation system -prf
type: 'post-hidden',
source: {type: 'user'},
priority: 1,
}
}
} else if (externalMuted || embedMuted) {
moderations.content.filter = true
moderations.content.blur = true
if (!moderations.content.cause) {
moderations.content.cause = {
// @ts-ignore Temporary extension to the moderation system -prf
type: 'muted-word',
source: {type: 'user'},
priority: 1,
}
}
}
}
return moderations
}