···227227/// Rich text utilities for Bluesky posts
228228pub mod richtext;
229229230230+#[cfg(feature = "api")]
231231+/// Moderation decision making for labeled content
232232+pub mod moderation;
233233+230234pub use common::*;
231235#[cfg(feature = "api")]
232236pub use jacquard_api as api;
+46
crates/jacquard/src/moderation.rs
···11+//! Moderation decision making for AT Protocol content
22+//!
33+//! This module provides protocol-agnostic moderation logic for applying label-based
44+//! content filtering. It takes labels from various sources (labeler services, self-labels)
55+//! and user preferences to produce moderation decisions.
66+//!
77+//! # Core Concepts
88+//!
99+//! - **Labels**: Metadata tags applied to content by labelers or authors (see [`Label`](jacquard_api::com_atproto::label::Label))
1010+//! - **Preferences**: User-configured responses to specific label values (hide, warn, ignore)
1111+//! - **Definitions**: Labeler-provided metadata about what labels mean and how they should be displayed
1212+//! - **Decisions**: The output of moderation logic indicating what actions to take
1313+//!
1414+//! # Example
1515+//!
1616+//! ```ignore
1717+//! # use jacquard::moderation::*;
1818+//! # use jacquard_api::app_bsky::feed::PostView;
1919+//! # fn example(post: &PostView<'_>, prefs: &ModerationPrefs<'_>, defs: &LabelerDefs<'_>) {
2020+//! let decision = moderate(post, prefs, defs, &[]);
2121+//! if decision.filter {
2222+//! // hide the post
2323+//! } else if decision.blur != Blur::None {
2424+//! // show with blur
2525+//! }
2626+//! # }
2727+//! ```
2828+2929+mod decision;
3030+#[cfg(feature = "api_bluesky")]
3131+mod fetch;
3232+mod labeled;
3333+mod moderatable;
3434+mod types;
3535+3636+#[cfg(test)]
3737+mod tests;
3838+3939+pub use decision::{ModerationIterExt, moderate, moderate_all};
4040+#[cfg(feature = "api_bluesky")]
4141+pub use fetch::fetch_labeler_defs;
4242+pub use labeled::Labeled;
4343+pub use moderatable::Moderateable;
4444+pub use types::{
4545+ Blur, LabelCause, LabelPref, LabelTarget, LabelerDefs, ModerationDecision, ModerationPrefs,
4646+};
+370
crates/jacquard/src/moderation/decision.rs
···11+use super::{
22+ Blur, LabelCause, LabelPref, LabelTarget, Labeled, LabelerDefs, ModerationDecision,
33+ ModerationPrefs,
44+};
55+use jacquard_api::com_atproto::label::{Label, LabelValue};
66+use jacquard_common::IntoStatic;
77+use jacquard_common::types::string::{Datetime, Did};
88+99+/// Apply moderation logic to a single piece of content
1010+///
1111+/// Takes the content, user preferences, labeler definitions, and list of accepted labelers,
1212+/// and produces a moderation decision indicating what actions to take.
1313+///
1414+/// # Arguments
1515+///
1616+/// * `item` - The content to moderate
1717+/// * `prefs` - User's moderation preferences
1818+/// * `defs` - Labeler definitions describing what labels mean
1919+/// * `accepted_labelers` - Which labelers to trust (usually from CallOptions)
2020+///
2121+/// # Example
2222+///
2323+/// ```ignore
2424+/// # use jacquard::moderation::*;
2525+/// # use jacquard_api::app_bsky::feed::PostView;
2626+/// # fn example(post: &PostView<'_>, prefs: &ModerationPrefs<'_>, defs: &LabelerDefs<'_>) {
2727+/// let decision = moderate(post, prefs, defs, &[]);
2828+/// if decision.filter {
2929+/// println!("This post should be hidden");
3030+/// }
3131+/// # }
3232+/// ```
3333+pub fn moderate<'a, T: Labeled<'a>>(
3434+ item: &'a T,
3535+ prefs: &ModerationPrefs<'_>,
3636+ defs: &LabelerDefs<'_>,
3737+ accepted_labelers: &[Did<'_>],
3838+) -> ModerationDecision {
3939+ let mut decision = ModerationDecision::none();
4040+ let now = Datetime::now();
4141+4242+ // Process labels from labeler services
4343+ for label in item.labels() {
4444+ // Skip expired labels
4545+ if let Some(exp) = &label.exp {
4646+ if exp <= &now {
4747+ continue;
4848+ }
4949+ }
5050+5151+ // Skip labels from untrusted labelers (if acceptance list is provided)
5252+ if !accepted_labelers.is_empty() && !accepted_labelers.contains(&label.src) {
5353+ continue;
5454+ }
5555+5656+ // Handle negation labels (remove previous causes)
5757+ if label.neg.unwrap_or(false) {
5858+ decision.causes.retain(|cause| {
5959+ !(cause.label.as_str() == label.val.as_ref() && cause.source == label.src)
6060+ });
6161+ continue;
6262+ }
6363+6464+ apply_label(label, prefs, defs, &mut decision);
6565+ }
6666+6767+ // Process self-labels
6868+ if let Some(self_labels) = item.self_labels() {
6969+ for self_label in self_labels.values {
7070+ // Self-labels don't have a source DID, so we'll use a placeholder approach
7171+ // In practice, self-labels are usually just used for adult content marking
7272+7373+ // Check user preference for this label
7474+ let pref = prefs
7575+ .labels
7676+ .iter()
7777+ .find(|(k, _)| k.as_ref() == self_label.val.as_ref())
7878+ .map(|(_, v)| v);
7979+8080+ // For self-labels, we generally respect them as warnings/info
8181+ // unless user has explicitly set a preference
8282+ match pref {
8383+ Some(LabelPref::Hide) => {
8484+ decision.filter = true;
8585+ }
8686+ Some(LabelPref::Warn) | None => {
8787+ // Default to warning for self-labels
8888+ if decision.blur == Blur::None {
8989+ decision.blur = Blur::Content;
9090+ }
9191+ decision.inform = true;
9292+ }
9393+ Some(LabelPref::Ignore) => {
9494+ // User chose to ignore
9595+ }
9696+ }
9797+ }
9898+ }
9999+100100+ decision
101101+}
102102+103103+/// Apply a single label to a moderation decision
104104+fn apply_label(
105105+ label: &Label<'_>,
106106+ prefs: &ModerationPrefs<'_>,
107107+ defs: &LabelerDefs<'_>,
108108+ decision: &mut ModerationDecision,
109109+) {
110110+ let label_val = label.val.as_ref();
111111+112112+ // Get user preference (per-labeler override first, then global)
113113+ let pref = prefs
114114+ .labelers
115115+ .get(&label.src)
116116+ .and_then(|labeler_prefs| {
117117+ labeler_prefs
118118+ .iter()
119119+ .find(|(k, _)| k.as_ref() == label_val)
120120+ .map(|(_, v)| v)
121121+ })
122122+ .or_else(|| {
123123+ prefs
124124+ .labels
125125+ .iter()
126126+ .find(|(k, _)| k.as_ref() == label_val)
127127+ .map(|(_, v)| v)
128128+ });
129129+130130+ // Get label definition from the labeler
131131+ let def = defs.find_def(&label.src, label_val);
132132+133133+ // Check if this is an adult-only label and adult content is disabled
134134+ if let Some(def) = def {
135135+ if def.adult_only.unwrap_or(false) && !prefs.adult_content_enabled {
136136+ decision.filter = true;
137137+ decision.no_override = true;
138138+ decision.causes.push(LabelCause {
139139+ label: LabelValue::from(label_val).into_static(),
140140+ source: label.src.clone().into_static(),
141141+ target: determine_target(label),
142142+ });
143143+ return;
144144+ }
145145+ }
146146+147147+ // Apply based on preference or default
148148+ match pref.copied() {
149149+ Some(LabelPref::Hide) => {
150150+ decision.filter = true;
151151+ decision.causes.push(LabelCause {
152152+ label: LabelValue::from(label_val).into_static(),
153153+ source: label.src.clone().into_static(),
154154+ target: determine_target(label),
155155+ });
156156+ }
157157+ Some(LabelPref::Warn) => {
158158+ apply_warning(label, def, decision);
159159+ }
160160+ Some(LabelPref::Ignore) => {
161161+ // User chose to ignore this label
162162+ }
163163+ None => {
164164+ // No user preference - use default from definition or built-in defaults
165165+ apply_default(label, def, decision);
166166+ }
167167+ }
168168+}
169169+170170+/// Apply warning-level moderation based on label definition
171171+fn apply_warning(
172172+ label: &Label<'_>,
173173+ def: Option<&jacquard_api::com_atproto::label::LabelValueDefinition<'_>>,
174174+ decision: &mut ModerationDecision,
175175+) {
176176+ let label_val = label.val.as_ref();
177177+178178+ // Determine blur type from definition
179179+ let blur = if let Some(def) = def {
180180+ match def.blurs.as_ref() {
181181+ "content" => Blur::Content,
182182+ "media" => Blur::Media,
183183+ _ => Blur::None,
184184+ }
185185+ } else {
186186+ // Built-in defaults for known labels
187187+ match label_val {
188188+ "porn" | "sexual" | "nudity" | "nsfl" | "gore" => Blur::Media,
189189+ _ => Blur::Content,
190190+ }
191191+ };
192192+193193+ // Apply blur (keep strongest blur if multiple labels)
194194+ decision.blur = match (decision.blur, blur) {
195195+ (Blur::Content, _) | (_, Blur::Content) => Blur::Content,
196196+ (Blur::Media, _) | (_, Blur::Media) => Blur::Media,
197197+ _ => Blur::None,
198198+ };
199199+200200+ // Determine severity for alert vs inform
201201+ if let Some(def) = def {
202202+ match def.severity.as_ref() {
203203+ "alert" => decision.alert = true,
204204+ "inform" => decision.inform = true,
205205+ _ => {}
206206+ }
207207+ } else {
208208+ // Default to alert for warnings
209209+ decision.alert = true;
210210+ }
211211+212212+ decision.causes.push(LabelCause {
213213+ label: LabelValue::from(label_val).into_static(),
214214+ source: label.src.clone().into_static(),
215215+ target: determine_target(label),
216216+ });
217217+}
218218+219219+/// Apply default moderation when user has no preference
220220+fn apply_default(
221221+ label: &Label<'_>,
222222+ def: Option<&jacquard_api::com_atproto::label::LabelValueDefinition<'_>>,
223223+ decision: &mut ModerationDecision,
224224+) {
225225+ let label_val = label.val.as_ref();
226226+227227+ // Check if definition has a default setting
228228+ if let Some(def) = def {
229229+ if let Some(default_setting) = &def.default_setting {
230230+ match default_setting.as_ref() {
231231+ "hide" => {
232232+ decision.filter = true;
233233+ decision.causes.push(LabelCause {
234234+ label: LabelValue::from(label_val).into_static(),
235235+ source: label.src.clone().into_static(),
236236+ target: determine_target(label),
237237+ });
238238+ return;
239239+ }
240240+ "warn" => {
241241+ apply_warning(label, Some(def), decision);
242242+ return;
243243+ }
244244+ "ignore" => return,
245245+ _ => {}
246246+ }
247247+ }
248248+ }
249249+250250+ // Built-in defaults for system labels (starting with !)
251251+ if label_val.starts_with('!') {
252252+ match label_val {
253253+ "!hide" => {
254254+ decision.filter = true;
255255+ decision.no_override = true;
256256+ decision.causes.push(LabelCause {
257257+ label: LabelValue::from(label_val).into_static(),
258258+ source: label.src.clone().into_static(),
259259+ target: determine_target(label),
260260+ });
261261+ }
262262+ "!warn" => {
263263+ apply_warning(label, def, decision);
264264+ }
265265+ "!no-unauthenticated" => {
266266+ // This should be handled by auth layer, but we can note it
267267+ decision.inform = true;
268268+ }
269269+ _ => {}
270270+ }
271271+ } else {
272272+ // Built-in defaults for known content labels
273273+ match label_val {
274274+ "porn" | "nsfl" => {
275275+ decision.filter = true;
276276+ decision.causes.push(LabelCause {
277277+ label: LabelValue::from(label_val).into_static(),
278278+ source: label.src.clone().into_static(),
279279+ target: determine_target(label),
280280+ });
281281+ }
282282+ "sexual" | "nudity" | "gore" => {
283283+ apply_warning(label, def, decision);
284284+ }
285285+ _ => {
286286+ // Unknown label - default to informational
287287+ decision.inform = true;
288288+ decision.causes.push(LabelCause {
289289+ label: LabelValue::from(label_val).into_static(),
290290+ source: label.src.clone().into_static(),
291291+ target: determine_target(label),
292292+ });
293293+ }
294294+ }
295295+ }
296296+}
297297+298298+/// Determine whether a label targets an account or content
299299+fn determine_target(label: &Label<'_>) -> LabelTarget {
300300+ // Try to parse as a DID - this handles both:
301301+ // - Bare DIDs: did:plc:xyz
302302+ // - at:// URIs with only DID authority: at://did:plc:xyz
303303+ // If it parses successfully, it's account-level.
304304+ // If it fails, it must be a full URI with collection/rkey, so content-level.
305305+ use jacquard_common::types::string::Did;
306306+307307+ if Did::new(label.uri.as_ref()).is_ok() {
308308+ LabelTarget::Account
309309+ } else {
310310+ LabelTarget::Content
311311+ }
312312+}
313313+314314+/// Apply moderation to a slice of items
315315+///
316316+/// Returns a Vec of tuples containing the original item reference and its decision.
317317+///
318318+/// # Example
319319+///
320320+/// ```ignore
321321+/// # use jacquard::moderation::*;
322322+/// # use jacquard_api::app_bsky::feed::PostView;
323323+/// # fn example(posts: &[PostView<'_>], prefs: &ModerationPrefs<'_>, defs: &LabelerDefs<'_>) {
324324+/// let results = moderate_all(posts, prefs, defs, &[]);
325325+/// for (post, decision) in results {
326326+/// if decision.filter {
327327+/// // skip this post
328328+/// }
329329+/// }
330330+/// # }
331331+/// ```
332332+pub fn moderate_all<'a, T: Labeled<'a>>(
333333+ items: &'a [T],
334334+ prefs: &ModerationPrefs<'_>,
335335+ defs: &LabelerDefs<'_>,
336336+ accepted_labelers: &[Did<'_>],
337337+) -> Vec<(&'a T, ModerationDecision)> {
338338+ items
339339+ .iter()
340340+ .map(|item| (item, moderate(item, prefs, defs, accepted_labelers)))
341341+ .collect()
342342+}
343343+344344+/// Extension trait for applying moderation to iterators
345345+///
346346+/// Provides convenience methods for filtering and mapping moderation decisions
347347+/// over collections.
348348+pub trait ModerationIterExt<'a, T: Labeled<'a> + 'a>: Iterator<Item = &'a T> + Sized {
349349+ /// Map each item to a tuple of (item, decision)
350350+ fn with_moderation(
351351+ self,
352352+ prefs: &'a ModerationPrefs<'_>,
353353+ defs: &'a LabelerDefs<'_>,
354354+ accepted_labelers: &'a [Did<'_>],
355355+ ) -> impl Iterator<Item = (&'a T, ModerationDecision)> {
356356+ self.map(move |item| (item, moderate(item, prefs, defs, accepted_labelers)))
357357+ }
358358+359359+ /// Filter out items that should be hidden
360360+ fn filter_moderated(
361361+ self,
362362+ prefs: &'a ModerationPrefs<'_>,
363363+ defs: &'a LabelerDefs<'_>,
364364+ accepted_labelers: &'a [Did<'_>],
365365+ ) -> impl Iterator<Item = &'a T> {
366366+ self.filter(move |item| !moderate(*item, prefs, defs, accepted_labelers).filter)
367367+ }
368368+}
369369+370370+impl<'a, T: Labeled<'a> + 'a, I: Iterator<Item = &'a T>> ModerationIterExt<'a, T> for I {}
+79
crates/jacquard/src/moderation/fetch.rs
···11+use super::LabelerDefs;
22+use jacquard_api::app_bsky::labeler::get_services::{GetServices, GetServicesOutput};
33+use jacquard_common::IntoStatic;
44+use jacquard_common::error::ClientError;
55+use jacquard_common::types::string::Did;
66+use jacquard_common::xrpc::{XrpcClient, XrpcError};
77+88+/// Fetch labeler definitions from app.bsky.labeler.getServices
99+///
1010+/// This is a convenience helper for fetching labeler service records from Bluesky's
1111+/// labeler service. You can also fetch these from other indexes or sources and
1212+/// construct a `LabelerDefs` manually.
1313+///
1414+/// # Arguments
1515+///
1616+/// * `client` - Any XRPC client (Agent, stateless client, etc.)
1717+/// * `dids` - List of labeler DIDs to fetch definitions for
1818+///
1919+/// # Example
2020+///
2121+/// ```no_run
2222+/// # use jacquard::moderation::fetch_labeler_defs;
2323+/// # use jacquard::client::BasicClient;
2424+/// # use jacquard_common::types::string::Did;
2525+/// # #[tokio::main]
2626+/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
2727+/// let client = BasicClient::unauthenticated();
2828+/// let labeler_did = Did::new_static("did:plc:ar7c4by46qjdydhdevvrndac").unwrap();
2929+/// let defs = fetch_labeler_defs(&client, vec![labeler_did]).await?;
3030+/// # Ok(())
3131+/// # }
3232+/// ```
3333+pub async fn fetch_labeler_defs(
3434+ client: &(impl XrpcClient + Sync),
3535+ dids: Vec<Did<'_>>,
3636+) -> Result<LabelerDefs<'static>, ClientError> {
3737+ #[cfg(feature = "tracing")]
3838+ let _span = tracing::debug_span!("fetch_labeler_defs", count = dids.len()).entered();
3939+4040+ let request = GetServices::new().dids(dids).detailed(true).build();
4141+4242+ let response = client.send(request).await?;
4343+ let output: GetServicesOutput<'static> = response.into_output().map_err(|e| match e {
4444+ XrpcError::Auth(auth) => ClientError::Auth(auth),
4545+ XrpcError::Generic(g) => ClientError::Transport(
4646+ jacquard_common::error::TransportError::Other(g.to_string().into()),
4747+ ),
4848+ XrpcError::Decode(e) => ClientError::Decode(e),
4949+ XrpcError::Xrpc(typed) => ClientError::Transport(
5050+ jacquard_common::error::TransportError::Other(format!("{:?}", typed).into()),
5151+ ),
5252+ })?;
5353+5454+ let mut defs = LabelerDefs::new();
5555+5656+ use jacquard_api::app_bsky::labeler::get_services::GetServicesOutputViewsItem;
5757+5858+ for view in output.views {
5959+ match view {
6060+ GetServicesOutputViewsItem::LabelerViewDetailed(detailed) => {
6161+ if let Some(label_value_definitions) = &detailed.policies.label_value_definitions {
6262+ defs.insert(
6363+ detailed.creator.did.clone().into_static(),
6464+ label_value_definitions
6565+ .iter()
6666+ .map(|d| d.clone().into_static())
6767+ .collect(),
6868+ );
6969+ }
7070+ }
7171+ _ => {
7272+ // Unknown or not sufficiently detailed view type, skip
7373+ continue;
7474+ }
7575+ }
7676+ }
7777+7878+ Ok(defs)
7979+}