Skip to main content

proto_blue_api/moderation/
subjects.rs

1//! Top-level moderation entry points: `decide_account`, `decide_profile`,
2//! `decide_post`, plus the embed walker used when a post quotes another.
3//!
4//! These mirror TS `@atproto/api` `moderation/subjects/*` — they accept
5//! shallow `SubjectAccount` / `SubjectProfile` / `SubjectPost` structs
6//! assembled from a generated `ProfileView` / `PostView`, apply
7//! label-preference resolution, block / mute / mute-word / hidden
8//! signal aggregation, and for posts walk embedded records so that
9//! quoted-post moderation downgrades propagate to the outer decision.
10//!
11//! The shapes are explicit (rather than pattern-matching the generated
12//! types directly) because moderation logic shouldn't churn every time
13//! a lexicon schema adds a field. Callers typically use the
14//! `moderate_post(view, opts)` convenience at the top of this file to
15//! translate a generated `PostView` into a `SubjectPost` automatically.
16
17use super::decision::ModerationDecision;
18use super::mutewords::{MutedWordMatch, check_muted_words};
19// internal moderation prelude; types tightly coupled to siblings
20#[allow(clippy::wildcard_imports)]
21use super::types::*;
22
23use serde_json::Value as JsonValue;
24
25/// Account-level moderation inputs (the "is this user OK to interact
26/// with at all" level).
27#[derive(Debug, Clone)]
28pub struct SubjectAccount<'a> {
29    pub did: &'a str,
30    /// Raw labels attached to the account (server + user-subscribed labelers).
31    pub labels: &'a [LabelData],
32    /// `true` when the viewer blocks this account.
33    pub viewer_blocking: bool,
34    /// `true` when this account blocks the viewer.
35    pub viewer_blocked_by: bool,
36    /// `true` when the viewer muted the account.
37    pub viewer_muted: bool,
38    /// If the viewer is muted via a moderation list, the list URI and name.
39    pub viewer_muted_by_list: Option<(String, String)>,
40    /// If the viewer is blocked via a moderation list, the list URI and name.
41    pub viewer_blocking_by_list: Option<(String, String)>,
42}
43
44/// Profile-level moderation inputs.
45#[derive(Debug, Clone)]
46pub struct SubjectProfile<'a> {
47    pub did: &'a str,
48    /// Labels attached specifically to the profile record.
49    pub labels: &'a [LabelData],
50}
51
52/// Post-level moderation inputs, including optional quote-embed payload.
53#[derive(Debug, Clone)]
54pub struct SubjectPost<'a> {
55    /// The post author's account-level subject (labels, blocks, mutes, …).
56    pub author: SubjectAccount<'a>,
57    /// Post-content labels (attached via `app.bsky.feed.post` labels).
58    pub labels: &'a [LabelData],
59    /// The literal post text — used for mute-word matching.
60    pub text: &'a str,
61    /// Post languages (`record.langs`) — narrows mute-word matching.
62    pub languages: &'a [String],
63    /// `true` when the viewer has explicitly hidden this post.
64    pub hidden: bool,
65    /// Optional embedded quote. When present, moderation for the
66    /// quoted record is walked recursively and downgraded into this
67    /// post's decision.
68    pub embed: Option<QuoteEmbed<'a>>,
69}
70
71/// A quoted-post embed payload (produced either from
72/// `app.bsky.embed.record#view` or the record half of
73/// `app.bsky.embed.recordWithMedia#view`).
74#[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/// Decide moderation for an account subject.
83///
84/// Aggregates blocks / mutes / labels. The result is a single
85/// [`ModerationDecision`] suitable for feeding into [`ModerationUi`]
86/// via the existing behavior priority machinery.
87#[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    // Block / mute signals never apply to the viewer themselves —
93    // matches TS `moderation/decision.ts`.
94    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/// Decide moderation for a profile subject.
125///
126/// Profile-level labels are separate from account-level: a user can
127/// label their own profile (e.g. `porn`) without that label propagating
128/// to account interactions.
129#[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/// Decide moderation for a post subject, walking a quote embed if
140/// present.
141///
142/// The algorithm mirrors TS `moderation/subjects/post.ts`:
143///
144/// 1. Decide the post's own author (account-level signals).
145/// 2. Decide the post's content labels.
146/// 3. Mute-word match against `text` narrowed by `languages`.
147/// 4. If `hidden` is set, add a Hidden cause.
148/// 5. If there's a quote embed, decide that recursively, **downgrade**
149///    every cause from it, and merge into the outer decision.
150///
151/// The downgrade step is what makes a quote of a blocked user render
152/// with a warning instead of a hard filter — the outer post hasn't
153/// done anything wrong.
154#[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    // Mute-word match against the post text.
163    let matches: Vec<MutedWordMatch> = check_muted_words(
164        &opts.prefs.muted_words,
165        subject.text,
166        /*tags=*/ &[],
167        subject.languages,
168        /*is_following_author=*/ 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            /*tags=*/ &[],
187            embed.languages,
188            /*is_following_author=*/ false,
189        );
190        if !embed_matches.is_empty() {
191            inner.add_mute_word(ModerationCauseSource::User);
192        }
193        // Downgrade every cause from the quoted content — the outer
194        // post is what the user is reading; we want a softer signal.
195        inner.downgrade();
196        // Merge the downgraded inner causes into the outer decision.
197        d.causes.extend(inner.causes);
198    }
199
200    d
201}
202
203/// Extract a quote embed from a generated `PostView.embed` JSON value.
204///
205/// Returns `None` when the embed is absent, of a non-quote kind
206/// (images, external, video), or malformed. Handles both
207/// `app.bsky.embed.record#view` (plain quote) and
208/// `app.bsky.embed.recordWithMedia#view` (quote with media — we
209/// extract only the record half; media moderation happens at the
210/// account/label level on the outer post).
211///
212/// This probes raw JSON because `PostView.embed` is a `serde_json`
213/// `Value` — the embed is a union of several unrelated view types.
214#[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    // A quoted record can itself be a `viewRecord`, `viewNotFound`,
223    // `viewBlocked`, etc. We only extract quote-content when it's a
224    // `viewRecord` — the other shapes produce specific moderation
225    // signals that the caller can surface directly (the data isn't
226    // there to decide).
227    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
234/// Borrowed view over a quoted-post JSON record.
235pub 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        // If subject.did == opts.user_did, blocking/muting/etc don't apply.
330        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        // The outer post has no causes; the quoted author is blocked.
378        // After merge the inner Blocking cause must appear on the
379        // outer decision AND be marked downgraded.
380        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}