use super::decision::ModerationDecision;
use super::mutewords::{MutedWordMatch, check_muted_words};
#[allow(clippy::wildcard_imports)]
use super::types::*;
use serde_json::Value as JsonValue;
#[derive(Debug, Clone)]
pub struct SubjectAccount<'a> {
pub did: &'a str,
pub labels: &'a [LabelData],
pub viewer_blocking: bool,
pub viewer_blocked_by: bool,
pub viewer_muted: bool,
pub viewer_muted_by_list: Option<(String, String)>,
pub viewer_blocking_by_list: Option<(String, String)>,
}
#[derive(Debug, Clone)]
pub struct SubjectProfile<'a> {
pub did: &'a str,
pub labels: &'a [LabelData],
}
#[derive(Debug, Clone)]
pub struct SubjectPost<'a> {
pub author: SubjectAccount<'a>,
pub labels: &'a [LabelData],
pub text: &'a str,
pub languages: &'a [String],
pub hidden: bool,
pub embed: Option<QuoteEmbed<'a>>,
}
#[derive(Debug, Clone)]
pub struct QuoteEmbed<'a> {
pub author: SubjectAccount<'a>,
pub labels: &'a [LabelData],
pub text: &'a str,
pub languages: &'a [String],
}
#[must_use]
pub fn decide_account(subject: &SubjectAccount, opts: &ModerationOpts) -> ModerationDecision {
let is_me = opts.user_did.as_deref() == Some(subject.did);
let mut d = ModerationDecision::new(subject.did, is_me);
if !is_me {
if subject.viewer_blocking {
d.add_blocking(ModerationCauseSource::User);
}
if subject.viewer_blocked_by {
d.add_blocked_by(ModerationCauseSource::User);
}
if let Some((uri, name)) = &subject.viewer_blocking_by_list {
d.add_block_other(ModerationCauseSource::List {
uri: uri.clone(),
name: name.clone(),
});
}
if subject.viewer_muted {
d.add_muted(ModerationCauseSource::User);
}
if let Some((uri, name)) = &subject.viewer_muted_by_list {
d.add_muted(ModerationCauseSource::List {
uri: uri.clone(),
name: name.clone(),
});
}
}
for label in subject.labels.iter().filter(|l| l.neg != Some(true)) {
d.add_label(label.clone(), LabelTarget::Account, opts);
}
d
}
#[must_use]
pub fn decide_profile(subject: &SubjectProfile, opts: &ModerationOpts) -> ModerationDecision {
let is_me = opts.user_did.as_deref() == Some(subject.did);
let mut d = ModerationDecision::new(subject.did, is_me);
for label in subject.labels.iter().filter(|l| l.neg != Some(true)) {
d.add_label(label.clone(), LabelTarget::Profile, opts);
}
d
}
#[must_use]
pub fn decide_post(subject: &SubjectPost, opts: &ModerationOpts) -> ModerationDecision {
let mut d = decide_account(&subject.author, opts);
for label in subject.labels.iter().filter(|l| l.neg != Some(true)) {
d.add_label(label.clone(), LabelTarget::Content, opts);
}
let matches: Vec<MutedWordMatch> = check_muted_words(
&opts.prefs.muted_words,
subject.text,
&[],
subject.languages,
false,
);
if !matches.is_empty() {
d.add_mute_word(ModerationCauseSource::User);
}
if subject.hidden {
d.add_hidden(ModerationCauseSource::User);
}
if let Some(embed) = &subject.embed {
let mut inner = decide_account(&embed.author, opts);
for label in embed.labels.iter().filter(|l| l.neg != Some(true)) {
inner.add_label(label.clone(), LabelTarget::Content, opts);
}
let embed_matches: Vec<MutedWordMatch> = check_muted_words(
&opts.prefs.muted_words,
embed.text,
&[],
embed.languages,
false,
);
if !embed_matches.is_empty() {
inner.add_mute_word(ModerationCauseSource::User);
}
inner.downgrade();
d.causes.extend(inner.causes);
}
d
}
#[must_use]
pub fn extract_quote_embed(embed: &JsonValue) -> Option<EmbedView<'_>> {
let ty = embed.get("$type")?.as_str()?;
let record = match ty {
"app.bsky.embed.record#view" => embed.get("record")?,
"app.bsky.embed.recordWithMedia#view" => embed.get("record")?.get("record")?,
_ => return None,
};
let record_ty = record.get("$type")?.as_str()?;
if record_ty != "app.bsky.embed.record#viewRecord" {
return None;
}
Some(EmbedView { record })
}
pub struct EmbedView<'a> {
pub record: &'a JsonValue,
}
impl<'a> EmbedView<'a> {
#[must_use]
pub fn author_did(&self) -> Option<&'a str> {
self.record.get("author")?.get("did")?.as_str()
}
#[must_use]
pub fn text(&self) -> Option<&'a str> {
self.record.get("value")?.get("text")?.as_str()
}
#[must_use]
pub fn languages(&self) -> Vec<String> {
self.record
.get("value")
.and_then(|v| v.get("langs"))
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
#[must_use]
pub fn labels(&self) -> Vec<LabelData> {
self.record
.get("labels")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| serde_json::from_value::<LabelData>(v.clone()).ok())
.collect()
})
.unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn base_opts() -> ModerationOpts {
ModerationOpts {
user_did: Some("did:plc:viewer".into()),
prefs: ModerationPrefs {
adult_content_enabled: false,
labels: HashMap::new(),
labelers: Vec::new(),
muted_words: Vec::new(),
hidden_posts: Vec::new(),
},
label_defs: HashMap::new(),
}
}
fn subject_account<'a>(did: &'a str) -> SubjectAccount<'a> {
SubjectAccount {
did,
labels: &[],
viewer_blocking: false,
viewer_blocked_by: false,
viewer_muted: false,
viewer_muted_by_list: None,
viewer_blocking_by_list: None,
}
}
#[test]
fn decide_account_clean_yields_no_causes() {
let a = subject_account("did:plc:alice");
let d = decide_account(&a, &base_opts());
assert!(d.causes.is_empty());
}
#[test]
fn decide_account_blocking_adds_cause() {
let a = SubjectAccount {
viewer_blocking: true,
..subject_account("did:plc:alice")
};
let d = decide_account(&a, &base_opts());
assert_eq!(d.causes.len(), 1);
assert!(matches!(d.causes[0], ModerationCause::Blocking { .. }));
}
#[test]
fn decide_account_self_ignores_blocks_and_mutes() {
let a = SubjectAccount {
viewer_blocking: true,
viewer_muted: true,
..subject_account("did:plc:viewer")
};
let d = decide_account(&a, &base_opts());
assert!(d.causes.is_empty(), "self-block should not produce causes");
assert!(d.is_me);
}
#[test]
fn decide_account_negated_label_is_skipped() {
let labels = vec![LabelData {
src: "did:plc:labeler".into(),
uri: "at://did:plc:alice".into(),
val: "spam".into(),
neg: Some(true),
}];
let a = SubjectAccount {
labels: &labels,
..subject_account("did:plc:alice")
};
let d = decide_account(&a, &base_opts());
assert!(d.causes.is_empty(), "negated label should not apply");
}
#[test]
fn decide_post_hidden_adds_cause() {
let a = subject_account("did:plc:alice");
let post = SubjectPost {
author: a,
labels: &[],
text: "hi",
languages: &[],
hidden: true,
embed: None,
};
let d = decide_post(&post, &base_opts());
assert!(
d.causes
.iter()
.any(|c| matches!(c, ModerationCause::Hidden { .. }))
);
}
#[test]
fn decide_post_embed_causes_are_downgraded() {
let outer = subject_account("did:plc:alice");
let inner = SubjectAccount {
viewer_blocking: true,
..subject_account("did:plc:bob")
};
let embed = QuoteEmbed {
author: inner,
labels: &[],
text: "quoted",
languages: &[],
};
let post = SubjectPost {
author: outer,
labels: &[],
text: "i'm quoting bob",
languages: &[],
hidden: false,
embed: Some(embed),
};
let d = decide_post(&post, &base_opts());
let blocking = d
.causes
.iter()
.find(|c| matches!(c, ModerationCause::Blocking { .. }))
.expect("inner blocking cause should propagate");
assert!(
blocking.is_downgraded(),
"embed-derived causes must be downgraded",
);
}
#[test]
fn decide_post_mute_word_match_adds_cause() {
let mut opts = base_opts();
opts.prefs.muted_words.push(MutedWord {
value: "forbidden".into(),
targets: vec!["content".into()],
actor_target: None,
expires_at: None,
});
let a = subject_account("did:plc:alice");
let post = SubjectPost {
author: a,
labels: &[],
text: "contains the forbidden word",
languages: &[],
hidden: false,
embed: None,
};
let d = decide_post(&post, &opts);
assert!(
d.causes
.iter()
.any(|c| matches!(c, ModerationCause::MuteWord { .. }))
);
}
#[test]
fn extract_quote_embed_from_record_view() {
let embed = serde_json::json!({
"$type": "app.bsky.embed.record#view",
"record": {
"$type": "app.bsky.embed.record#viewRecord",
"uri": "at://did:plc:bob/app.bsky.feed.post/abc",
"cid": "bafy",
"author": {"did": "did:plc:bob", "handle": "bob.bsky.social"},
"value": {"text": "quoted content", "langs": ["en", "es"]},
"labels": []
}
});
let view = extract_quote_embed(&embed).expect("should extract");
assert_eq!(view.author_did(), Some("did:plc:bob"));
assert_eq!(view.text(), Some("quoted content"));
assert_eq!(view.languages(), vec!["en", "es"]);
}
#[test]
fn extract_quote_embed_from_record_with_media() {
let embed = serde_json::json!({
"$type": "app.bsky.embed.recordWithMedia#view",
"record": {
"$type": "app.bsky.embed.record#view",
"record": {
"$type": "app.bsky.embed.record#viewRecord",
"uri": "at://did:plc:bob/app.bsky.feed.post/abc",
"cid": "bafy",
"author": {"did": "did:plc:bob", "handle": "bob"},
"value": {"text": "with media"},
"labels": []
}
},
"media": {"$type": "app.bsky.embed.images#view"}
});
let view = extract_quote_embed(&embed).expect("should extract record side");
assert_eq!(view.text(), Some("with media"));
}
#[test]
fn extract_quote_embed_returns_none_for_plain_images() {
let embed = serde_json::json!({
"$type": "app.bsky.embed.images#view",
"images": []
});
assert!(extract_quote_embed(&embed).is_none());
}
#[test]
fn extract_quote_embed_returns_none_for_view_blocked() {
let embed = serde_json::json!({
"$type": "app.bsky.embed.record#view",
"record": {
"$type": "app.bsky.embed.record#viewBlocked",
"uri": "at://did:plc:x/app.bsky.feed.post/y",
"blocked": true
}
});
assert!(extract_quote_embed(&embed).is_none());
}
}