use super::{post_view, profile_view_basic};
use crate::moderation::decision::DecisionContext;
use crate::moderation::mutewords::has_muted_word;
use crate::moderation::{ModerationPrefs, Moderator};
use atrium_api::app::bsky::actor::defs::{MutedWord, MutedWordData, ViewerState, ViewerStateData};
use atrium_api::app::bsky::richtext::facet::{ByteSliceData, MainData, MainFeaturesItem, TagData};
use atrium_api::types::string::Datetime;
use atrium_api::types::{Union, UnknownData};
use ipld_core::ipld::Ipld;
use std::collections::{BTreeMap, HashMap};
use std::time::Duration;
enum MutedWordTarget {
Content,
#[cfg(feature = "rich-text")]
Tag,
}
enum ActorTarget {
All,
ExcludeFollowing,
}
fn muted_word(value: &str, word_target: MutedWordTarget, actor_target: ActorTarget) -> MutedWord {
MutedWordData {
actor_target: Some(match actor_target {
ActorTarget::All => String::from("all"),
ActorTarget::ExcludeFollowing => String::from("exclude-following"),
}),
expires_at: None,
id: None,
targets: vec![match word_target {
MutedWordTarget::Content => String::from("content"),
#[cfg(feature = "rich-text")]
MutedWordTarget::Tag => String::from("tag"),
}],
value: String::from(value),
}
.into()
}
fn moderation_prefs(
value: &str,
word_target: MutedWordTarget,
actor_target: ActorTarget,
expires_at: Option<Datetime>,
) -> ModerationPrefs {
let mut muted_word = muted_word(value, word_target, actor_target);
muted_word.expires_at = expires_at;
ModerationPrefs {
adult_content_enabled: false,
labels: HashMap::new(),
labelers: Vec::new(),
muted_words: vec![muted_word],
hidden_posts: Vec::new(),
}
}
fn viewer_state(following: Option<String>) -> ViewerState {
ViewerStateData {
activity_subscription: None,
blocked_by: None,
blocking: None,
blocking_by_list: None,
followed_by: None,
following,
known_followers: None,
muted: None,
muted_by_list: None,
}
.into()
}
#[cfg(feature = "rich-text")]
#[tokio::test]
async fn has_muted_word_from_rich_text() -> crate::error::Result<()> {
use crate::rich_text::tests::rich_text_with_detect_facets;
{
let rt = rich_text_with_detect_facets("This is a post #inlineTag").await?;
assert!(has_muted_word(
&[muted_word("outlineTag", MutedWordTarget::Tag, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![String::from("outlineTag")]),
None,
None
));
}
{
let rt = rich_text_with_detect_facets("This is a post #inlineTag").await?;
assert!(has_muted_word(
&[muted_word("inlineTag", MutedWordTarget::Tag, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![String::from("outlineTag")]),
None,
None
));
}
{
let rt = rich_text_with_detect_facets("This is a post #inlineTag").await?;
assert!(has_muted_word(
&[muted_word("inlineTag", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![String::from("outlineTag")]),
None,
None
));
}
{
let rt = rich_text_with_detect_facets("This is a post").await?;
assert!(!has_muted_word(
&[muted_word("post", MutedWordTarget::Tag, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
}
{
let rt = rich_text_with_detect_facets("改善希望です").await?;
assert!(has_muted_word(
&[muted_word("希", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
}
{
let rt = rich_text_with_detect_facets("Idk why ☠︎ but maybe").await?;
assert!(has_muted_word(
&[muted_word("☠︎", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
}
{
let rt = rich_text_with_detect_facets("hey").await?;
assert!(!has_muted_word(
&[muted_word("politics", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
}
{
let rt = rich_text_with_detect_facets("javascript").await?;
assert!(has_muted_word(
&[muted_word("javascript", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
}
{
let rt = rich_text_with_detect_facets("This is a post about javascript").await?;
assert!(has_muted_word(
&[muted_word("javascript", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
}
{
let rt = rich_text_with_detect_facets("Use your brain, Eric").await?;
assert!(!has_muted_word(
&[muted_word("ai", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
}
{
let rt = rich_text_with_detect_facets("Use your\n\tbrain, Eric").await?;
assert!(has_muted_word(
&[muted_word("brain", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
}
{
let rt = rich_text_with_detect_facets("So happy :)").await?;
assert!(has_muted_word(
&[muted_word(":)", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
}
{
let rt = rich_text_with_detect_facets("We're federating, yay!").await?;
assert!(has_muted_word(
&[muted_word("yay!", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
assert!(has_muted_word(
&[muted_word("yay", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
}
{
let rt = rich_text_with_detect_facets("We're federating, y!ppee!!").await?;
assert!(has_muted_word(
&[muted_word("y!ppee", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
assert!(has_muted_word(
&[muted_word("y!ppee!", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
}
{
let rt = rich_text_with_detect_facets("Yay, Bluesky's mutewords work").await?;
assert!(has_muted_word(
&[muted_word("Bluesky's", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
assert!(has_muted_word(
&[muted_word("Bluesky", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
assert!(has_muted_word(
&[muted_word("bluesky", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
assert!(has_muted_word(
&[muted_word("blueskys", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
}
{
let rt = rich_text_with_detect_facets("Why so S@assy?").await?;
assert!(has_muted_word(
&[muted_word("S@assy", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
assert!(has_muted_word(
&[muted_word("s@assy", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
}
{
let rt = rich_text_with_detect_facets("New York Times").await?;
assert!(has_muted_word(
&[muted_word("new york times", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
}
{
let rt = rich_text_with_detect_facets("Idk maybe a bot !command").await?;
assert!(has_muted_word(
&[muted_word("!command", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
assert!(has_muted_word(
&[muted_word("command", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
let rt = rich_text_with_detect_facets("Idk maybe a bot command").await?;
assert!(!has_muted_word(
&[muted_word("!command", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
}
{
let rt = rich_text_with_detect_facets("I'm e/acc pilled").await?;
assert!(has_muted_word(
&[muted_word("e/acc", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
assert!(has_muted_word(
&[muted_word("acc", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
}
{
let rt = rich_text_with_detect_facets("I'm super-bad").await?;
assert!(has_muted_word(
&[muted_word("super-bad", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
assert!(has_muted_word(
&[muted_word("super", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
assert!(has_muted_word(
&[muted_word("bad", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
assert!(has_muted_word(
&[muted_word("super bad", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
assert!(has_muted_word(
&[muted_word("superbad", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
}
{
let rt = rich_text_with_detect_facets("Weird post with idk_what_this_would_be").await?;
assert!(has_muted_word(
&[muted_word("idk what this would be", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
assert!(!has_muted_word(
&[muted_word("idk what this would be for", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
assert!(has_muted_word(
&[muted_word("idk", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
assert!(has_muted_word(
&[muted_word("idkwhatthiswouldbe", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
}
{
let rt = rich_text_with_detect_facets("Post with context(iykyk)").await?;
assert!(has_muted_word(
&[muted_word("context(iykyk)", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
assert!(has_muted_word(
&[muted_word("context", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
assert!(has_muted_word(
&[muted_word("iykyk", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
assert!(has_muted_word(
&[muted_word("(iykyk)", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
}
{
let rt = rich_text_with_detect_facets("Post with 🦋").await?;
assert!(has_muted_word(
&[muted_word("🦋", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
}
{
let rt = rich_text_with_detect_facets(
"I like turtles, or how I learned to stop worrying and love the internet.",
)
.await?;
assert!(has_muted_word(
&[muted_word("stop worrying", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
assert!(has_muted_word(
&[muted_word("turtles, or how", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
None,
None
));
}
{
let rt = rich_text_with_detect_facets("私はカメが好きです、またはどのようにして心配するのをやめてインターネットを愛するようになったのか").await?;
assert!(has_muted_word(
&[muted_word("インターネット", MutedWordTarget::Content, ActorTarget::All)],
&rt.text,
rt.facets.as_ref(),
Some(&vec![]),
Some(&vec!["ja".parse().expect("invalid lang")]),
None
));
}
Ok(())
}
#[test]
fn facet_with_multiple_features() {
{
assert!(has_muted_word(
&[muted_word("bad", MutedWordTarget::Content, ActorTarget::All)],
"tags",
Some(&vec![
MainData {
features: vec![
Union::Refs(MainFeaturesItem::Tag(Box::new(
TagData { tag: String::from("good") }.into()
))),
Union::Refs(MainFeaturesItem::Tag(Box::new(
TagData { tag: String::from("bad") }.into()
)))
],
index: ByteSliceData { byte_end: 4, byte_start: 0 }.into()
}
.into()
]),
Some(&vec![]),
None,
None
))
}
{
assert!(has_muted_word(
&[muted_word("bad", MutedWordTarget::Content, ActorTarget::All)],
"test",
Some(&vec![
MainData {
features: vec![
Union::Unknown(UnknownData {
r#type: String::from("com.example.richtext.facet#other"),
data: Ipld::Map(BTreeMap::from_iter([(
String::from("foo"),
Ipld::String(String::from("bar"))
),]))
}),
Union::Refs(MainFeaturesItem::Tag(Box::new(
TagData { tag: String::from("bad") }.into()
)))
],
index: ByteSliceData { byte_end: 4, byte_start: 0 }.into()
}
.into()
]),
Some(&vec![]),
None,
None
))
}
}
#[test]
fn does_not_mute_own_post() {
let prefs = &moderation_prefs("words", MutedWordTarget::Content, ActorTarget::All, None);
let post = &post_view(&profile_view_basic("bob.test", Some("Bob"), None), "Mute words!", None);
let moderator = Moderator::new(
Some("did:web:alice.test".parse().expect("invalid did")),
prefs.clone(),
HashMap::new(),
);
let result = moderator.moderate_post(post);
assert!(result.ui(DecisionContext::ContentList).filter(), "post should be filtered");
let moderator = Moderator::new(
Some("did:web:bob.test".parse().expect("invalid did")),
prefs.clone(),
HashMap::new(),
);
let result = moderator.moderate_post(post);
assert!(!result.ui(DecisionContext::ContentList).filter(), "post should not be filtered");
}
#[cfg(feature = "rich-text")]
#[tokio::test]
async fn does_not_mute_own_tags() -> crate::error::Result<()> {
use crate::rich_text::tests::rich_text_with_detect_facets;
use atrium_api::types::Unknown;
use std::ops::DerefMut;
let prefs = moderation_prefs("words", MutedWordTarget::Tag, ActorTarget::All, None);
let rt = rich_text_with_detect_facets("Mute #words!").await?;
let mut post = post_view(&profile_view_basic("bob.test", Some("Bob"), None), &rt.text, None);
if let Unknown::Other(data) = &mut post.record {
if let Ipld::Map(m) = data.deref_mut() {
if let Some(facets) = rt.facets {
m.insert(
"facets".to_string(),
ipld_core::serde::to_ipld(facets).expect("failed to serialize facets"),
);
}
}
}
let moderator = Moderator::new(
Some("did:web:bob.test".parse().expect("invalid did")),
prefs,
HashMap::new(),
);
let result = moderator.moderate_post(&post);
assert!(!result.ui(DecisionContext::ContentList).filter(), "post should not be filtered");
Ok(())
}
#[test]
fn timed_mute_words() {
{
let now = chrono::Utc::now().fixed_offset();
let prefs = &moderation_prefs(
"words",
MutedWordTarget::Content,
ActorTarget::All,
Some(Datetime::new(now + Duration::from_secs(1))),
);
let post =
&post_view(&profile_view_basic("bob.test", Some("Bob"), None), "Mute words!", None);
let moderator = Moderator::new(
Some("did:web:alice.test".parse().expect("invalid did")),
prefs.clone(),
HashMap::new(),
);
let result = moderator.moderate_post(post);
assert!(result.ui(DecisionContext::ContentList).filter(), "post should be filtered");
}
{
let now = chrono::Utc::now().fixed_offset();
let prefs = &moderation_prefs(
"words",
MutedWordTarget::Content,
ActorTarget::All,
Some(Datetime::new(now - Duration::from_secs(1))),
);
let post =
&post_view(&profile_view_basic("bob.test", Some("Bob"), None), "Mute words!", None);
let moderator = Moderator::new(
Some("did:web:alice.test".parse().expect("invalid did")),
prefs.clone(),
HashMap::new(),
);
let result = moderator.moderate_post(post);
assert!(!result.ui(DecisionContext::ContentList).filter(), "post should not be filtered");
}
}
#[test]
fn actor_based_mute_words() {
let prefs =
moderation_prefs("words", MutedWordTarget::Content, ActorTarget::ExcludeFollowing, None);
{
let mut author = profile_view_basic("bob.test", Some("Bob"), None);
author.viewer = Some(viewer_state(Some(String::from("true"))));
if let Some(viewer) = author.viewer.as_mut() {
viewer.following = Some(String::from("true"));
}
let moderator = Moderator::new(
Some("did:web:alice.test".parse().expect("invalid did")),
prefs.clone(),
HashMap::new(),
);
let result = moderator.moderate_post(&post_view(&author, "Mute words!", None));
assert!(!result.ui(DecisionContext::ContentList).filter(), "post should not be filtered");
}
{
let mut author = profile_view_basic("carla.test", Some("Carla"), None);
author.viewer = Some(viewer_state(None));
let moderator = Moderator::new(
Some("did:web:alice.test".parse().expect("invalid did")),
prefs.clone(),
HashMap::new(),
);
let result = moderator.moderate_post(&post_view(&author, "Mute words!", None));
assert!(result.ui(DecisionContext::ContentList).filter(), "post should be filtered");
}
}