proto_blue_api/moderation/
subjects.rs1use super::decision::ModerationDecision;
18use super::mutewords::{MutedWordMatch, check_muted_words};
19#[allow(clippy::wildcard_imports)]
21use super::types::*;
22
23use serde_json::Value as JsonValue;
24
25#[derive(Debug, Clone)]
28pub struct SubjectAccount<'a> {
29 pub did: &'a str,
30 pub labels: &'a [LabelData],
32 pub viewer_blocking: bool,
34 pub viewer_blocked_by: bool,
36 pub viewer_muted: bool,
38 pub viewer_muted_by_list: Option<(String, String)>,
40 pub viewer_blocking_by_list: Option<(String, String)>,
42}
43
44#[derive(Debug, Clone)]
46pub struct SubjectProfile<'a> {
47 pub did: &'a str,
48 pub labels: &'a [LabelData],
50}
51
52#[derive(Debug, Clone)]
54pub struct SubjectPost<'a> {
55 pub author: SubjectAccount<'a>,
57 pub labels: &'a [LabelData],
59 pub text: &'a str,
61 pub languages: &'a [String],
63 pub hidden: bool,
65 pub embed: Option<QuoteEmbed<'a>>,
69}
70
71#[derive(Debug, Clone)]
75pub struct QuoteEmbed<'a> {
76 pub author: SubjectAccount<'a>,
77 pub labels: &'a [LabelData],
78 pub text: &'a str,
79 pub languages: &'a [String],
80}
81
82#[must_use]
88pub fn decide_account(subject: &SubjectAccount, opts: &ModerationOpts) -> ModerationDecision {
89 let is_me = opts.user_did.as_deref() == Some(subject.did);
90 let mut d = ModerationDecision::new(subject.did, is_me);
91
92 if !is_me {
95 if subject.viewer_blocking {
96 d.add_blocking(ModerationCauseSource::User);
97 }
98 if subject.viewer_blocked_by {
99 d.add_blocked_by(ModerationCauseSource::User);
100 }
101 if let Some((uri, name)) = &subject.viewer_blocking_by_list {
102 d.add_block_other(ModerationCauseSource::List {
103 uri: uri.clone(),
104 name: name.clone(),
105 });
106 }
107 if subject.viewer_muted {
108 d.add_muted(ModerationCauseSource::User);
109 }
110 if let Some((uri, name)) = &subject.viewer_muted_by_list {
111 d.add_muted(ModerationCauseSource::List {
112 uri: uri.clone(),
113 name: name.clone(),
114 });
115 }
116 }
117
118 for label in subject.labels.iter().filter(|l| l.neg != Some(true)) {
119 d.add_label(label.clone(), LabelTarget::Account, opts);
120 }
121 d
122}
123
124#[must_use]
130pub fn decide_profile(subject: &SubjectProfile, opts: &ModerationOpts) -> ModerationDecision {
131 let is_me = opts.user_did.as_deref() == Some(subject.did);
132 let mut d = ModerationDecision::new(subject.did, is_me);
133 for label in subject.labels.iter().filter(|l| l.neg != Some(true)) {
134 d.add_label(label.clone(), LabelTarget::Profile, opts);
135 }
136 d
137}
138
139#[must_use]
155pub fn decide_post(subject: &SubjectPost, opts: &ModerationOpts) -> ModerationDecision {
156 let mut d = decide_account(&subject.author, opts);
157
158 for label in subject.labels.iter().filter(|l| l.neg != Some(true)) {
159 d.add_label(label.clone(), LabelTarget::Content, opts);
160 }
161
162 let matches: Vec<MutedWordMatch> = check_muted_words(
164 &opts.prefs.muted_words,
165 subject.text,
166 &[],
167 subject.languages,
168 false,
169 );
170 if !matches.is_empty() {
171 d.add_mute_word(ModerationCauseSource::User);
172 }
173
174 if subject.hidden {
175 d.add_hidden(ModerationCauseSource::User);
176 }
177
178 if let Some(embed) = &subject.embed {
179 let mut inner = decide_account(&embed.author, opts);
180 for label in embed.labels.iter().filter(|l| l.neg != Some(true)) {
181 inner.add_label(label.clone(), LabelTarget::Content, opts);
182 }
183 let embed_matches: Vec<MutedWordMatch> = check_muted_words(
184 &opts.prefs.muted_words,
185 embed.text,
186 &[],
187 embed.languages,
188 false,
189 );
190 if !embed_matches.is_empty() {
191 inner.add_mute_word(ModerationCauseSource::User);
192 }
193 inner.downgrade();
196 d.causes.extend(inner.causes);
198 }
199
200 d
201}
202
203#[must_use]
215pub fn extract_quote_embed(embed: &JsonValue) -> Option<EmbedView<'_>> {
216 let ty = embed.get("$type")?.as_str()?;
217 let record = match ty {
218 "app.bsky.embed.record#view" => embed.get("record")?,
219 "app.bsky.embed.recordWithMedia#view" => embed.get("record")?.get("record")?,
220 _ => return None,
221 };
222 let record_ty = record.get("$type")?.as_str()?;
228 if record_ty != "app.bsky.embed.record#viewRecord" {
229 return None;
230 }
231 Some(EmbedView { record })
232}
233
234pub struct EmbedView<'a> {
236 pub record: &'a JsonValue,
237}
238
239impl<'a> EmbedView<'a> {
240 #[must_use]
241 pub fn author_did(&self) -> Option<&'a str> {
242 self.record.get("author")?.get("did")?.as_str()
243 }
244
245 #[must_use]
246 pub fn text(&self) -> Option<&'a str> {
247 self.record.get("value")?.get("text")?.as_str()
248 }
249
250 #[must_use]
251 pub fn languages(&self) -> Vec<String> {
252 self.record
253 .get("value")
254 .and_then(|v| v.get("langs"))
255 .and_then(|v| v.as_array())
256 .map(|arr| {
257 arr.iter()
258 .filter_map(|v| v.as_str().map(String::from))
259 .collect()
260 })
261 .unwrap_or_default()
262 }
263
264 #[must_use]
265 pub fn labels(&self) -> Vec<LabelData> {
266 self.record
267 .get("labels")
268 .and_then(|v| v.as_array())
269 .map(|arr| {
270 arr.iter()
271 .filter_map(|v| serde_json::from_value::<LabelData>(v.clone()).ok())
272 .collect()
273 })
274 .unwrap_or_default()
275 }
276}
277
278#[cfg(test)]
279mod tests {
280 use super::*;
281 use std::collections::HashMap;
282
283 fn base_opts() -> ModerationOpts {
284 ModerationOpts {
285 user_did: Some("did:plc:viewer".into()),
286 prefs: ModerationPrefs {
287 adult_content_enabled: false,
288 labels: HashMap::new(),
289 labelers: Vec::new(),
290 muted_words: Vec::new(),
291 hidden_posts: Vec::new(),
292 },
293 label_defs: HashMap::new(),
294 }
295 }
296
297 fn subject_account<'a>(did: &'a str) -> SubjectAccount<'a> {
298 SubjectAccount {
299 did,
300 labels: &[],
301 viewer_blocking: false,
302 viewer_blocked_by: false,
303 viewer_muted: false,
304 viewer_muted_by_list: None,
305 viewer_blocking_by_list: None,
306 }
307 }
308
309 #[test]
310 fn decide_account_clean_yields_no_causes() {
311 let a = subject_account("did:plc:alice");
312 let d = decide_account(&a, &base_opts());
313 assert!(d.causes.is_empty());
314 }
315
316 #[test]
317 fn decide_account_blocking_adds_cause() {
318 let a = SubjectAccount {
319 viewer_blocking: true,
320 ..subject_account("did:plc:alice")
321 };
322 let d = decide_account(&a, &base_opts());
323 assert_eq!(d.causes.len(), 1);
324 assert!(matches!(d.causes[0], ModerationCause::Blocking { .. }));
325 }
326
327 #[test]
328 fn decide_account_self_ignores_blocks_and_mutes() {
329 let a = SubjectAccount {
331 viewer_blocking: true,
332 viewer_muted: true,
333 ..subject_account("did:plc:viewer")
334 };
335 let d = decide_account(&a, &base_opts());
336 assert!(d.causes.is_empty(), "self-block should not produce causes");
337 assert!(d.is_me);
338 }
339
340 #[test]
341 fn decide_account_negated_label_is_skipped() {
342 let labels = vec![LabelData {
343 src: "did:plc:labeler".into(),
344 uri: "at://did:plc:alice".into(),
345 val: "spam".into(),
346 neg: Some(true),
347 }];
348 let a = SubjectAccount {
349 labels: &labels,
350 ..subject_account("did:plc:alice")
351 };
352 let d = decide_account(&a, &base_opts());
353 assert!(d.causes.is_empty(), "negated label should not apply");
354 }
355
356 #[test]
357 fn decide_post_hidden_adds_cause() {
358 let a = subject_account("did:plc:alice");
359 let post = SubjectPost {
360 author: a,
361 labels: &[],
362 text: "hi",
363 languages: &[],
364 hidden: true,
365 embed: None,
366 };
367 let d = decide_post(&post, &base_opts());
368 assert!(
369 d.causes
370 .iter()
371 .any(|c| matches!(c, ModerationCause::Hidden { .. }))
372 );
373 }
374
375 #[test]
376 fn decide_post_embed_causes_are_downgraded() {
377 let outer = subject_account("did:plc:alice");
381 let inner = SubjectAccount {
382 viewer_blocking: true,
383 ..subject_account("did:plc:bob")
384 };
385 let embed = QuoteEmbed {
386 author: inner,
387 labels: &[],
388 text: "quoted",
389 languages: &[],
390 };
391 let post = SubjectPost {
392 author: outer,
393 labels: &[],
394 text: "i'm quoting bob",
395 languages: &[],
396 hidden: false,
397 embed: Some(embed),
398 };
399 let d = decide_post(&post, &base_opts());
400 let blocking = d
401 .causes
402 .iter()
403 .find(|c| matches!(c, ModerationCause::Blocking { .. }))
404 .expect("inner blocking cause should propagate");
405 assert!(
406 blocking.is_downgraded(),
407 "embed-derived causes must be downgraded",
408 );
409 }
410
411 #[test]
412 fn decide_post_mute_word_match_adds_cause() {
413 let mut opts = base_opts();
414 opts.prefs.muted_words.push(MutedWord {
415 value: "forbidden".into(),
416 targets: vec!["content".into()],
417 actor_target: None,
418 expires_at: None,
419 });
420 let a = subject_account("did:plc:alice");
421 let post = SubjectPost {
422 author: a,
423 labels: &[],
424 text: "contains the forbidden word",
425 languages: &[],
426 hidden: false,
427 embed: None,
428 };
429 let d = decide_post(&post, &opts);
430 assert!(
431 d.causes
432 .iter()
433 .any(|c| matches!(c, ModerationCause::MuteWord { .. }))
434 );
435 }
436
437 #[test]
438 fn extract_quote_embed_from_record_view() {
439 let embed = serde_json::json!({
440 "$type": "app.bsky.embed.record#view",
441 "record": {
442 "$type": "app.bsky.embed.record#viewRecord",
443 "uri": "at://did:plc:bob/app.bsky.feed.post/abc",
444 "cid": "bafy",
445 "author": {"did": "did:plc:bob", "handle": "bob.bsky.social"},
446 "value": {"text": "quoted content", "langs": ["en", "es"]},
447 "labels": []
448 }
449 });
450 let view = extract_quote_embed(&embed).expect("should extract");
451 assert_eq!(view.author_did(), Some("did:plc:bob"));
452 assert_eq!(view.text(), Some("quoted content"));
453 assert_eq!(view.languages(), vec!["en", "es"]);
454 }
455
456 #[test]
457 fn extract_quote_embed_from_record_with_media() {
458 let embed = serde_json::json!({
459 "$type": "app.bsky.embed.recordWithMedia#view",
460 "record": {
461 "$type": "app.bsky.embed.record#view",
462 "record": {
463 "$type": "app.bsky.embed.record#viewRecord",
464 "uri": "at://did:plc:bob/app.bsky.feed.post/abc",
465 "cid": "bafy",
466 "author": {"did": "did:plc:bob", "handle": "bob"},
467 "value": {"text": "with media"},
468 "labels": []
469 }
470 },
471 "media": {"$type": "app.bsky.embed.images#view"}
472 });
473 let view = extract_quote_embed(&embed).expect("should extract record side");
474 assert_eq!(view.text(), Some("with media"));
475 }
476
477 #[test]
478 fn extract_quote_embed_returns_none_for_plain_images() {
479 let embed = serde_json::json!({
480 "$type": "app.bsky.embed.images#view",
481 "images": []
482 });
483 assert!(extract_quote_embed(&embed).is_none());
484 }
485
486 #[test]
487 fn extract_quote_embed_returns_none_for_view_blocked() {
488 let embed = serde_json::json!({
489 "$type": "app.bsky.embed.record#view",
490 "record": {
491 "$type": "app.bsky.embed.record#viewBlocked",
492 "uri": "at://did:plc:x/app.bsky.feed.post/y",
493 "blocked": true
494 }
495 });
496 assert!(extract_quote_embed(&embed).is_none());
497 }
498}