···1# Changelog
23+## [0.7.0] - 2025-10-19
4+5+### Added
6+7+**Bluesky-style rich text utilities** (`jacquard`)
8+- Rich text parsing with automatic facet detection (mentions, links, hashtags)
9+- Compatible with Bluesky, with the addition of support for markdown-style links (`[display](url)` syntax)
10+- Embed candidate detection from URLs and at-URIs
11+ - Record embeds (posts, lists, starter packs, feeds)
12+ - External embeds with optional OpenGraph metadata fetching
13+- Configurable embed domains for at-URI extraction (default: bsky.app, deer.social, blacksky.community, catsky.social)
14+- Overlap detection and validation for facet byte ranges
15+16+**Moderation/labeling client utilities** (`jacquard`)
17+- Trait-based content moderation with `Labeled` and `Moderateable` traits
18+- Generic moderation decision making via `moderate()` and `moderate_all()`
19+- User preference handling (`ModerationPrefs`) with global and per-labeler overrides
20+- `ModerationIterExt` trait for filtering/mapping moderation over iterators
21+- `Labeled` implementations for Bluesky types (PostView, ProfileView, ListView, Generator, Notification, etc.)
22+- `Labeled` implementations for community lexicons (net.anisota, social.grain)
23+- `fetch_labels()` and `fetch_labeled_record()` helpers for retrieving labels via XRPC
24+- `fetch_labeler_defs()` and `fetch_labeler_defs_direct()` for fetching labeler definitions
25+26+**Subscription control** (`jacquard-common`)
27+- `SubscriptionControlMessage` trait for dynamic subscription configuration
28+- `SubscriptionController` for sending control messages to active WebSocket subscriptions
29+- Enables runtime reconfiguration of subscriptions (e.g., Jetstream filtering)
30+31+**Lexicons** (`jacquard-api`)
32+- teal.fm alpha lexicons for music sharing (fm.teal.alpha.*)
33+ - Actor profiles with music service status
34+ - Feed generation from play history
35+ - Statistics endpoints (top artists, top releases, user stats)
36+37+**Examples**
38+- Updated `create_post.rs` to demonstrate richtext parsing with automatic facet detection
39+40+41## [0.6.0] - 2025-10-18
4243### Added
···1-//! Moderation decision making for AT Protocol content
00000002//!
3-//! This module provides protocol-agnostic moderation logic for applying label-based
4-//! content filtering. It takes labels from various sources (labeler services, self-labels)
5-//! and user preferences to produce moderation decisions.
00006//!
7-//! # Core Concepts
008//!
9-//! - **Labels**: Metadata tags applied to content by labelers or authors (see [`Label`](jacquard_api::com_atproto::label::Label))
10-//! - **Preferences**: User-configured responses to specific label values (hide, warn, ignore)
11-//! - **Definitions**: Labeler-provided metadata about what labels mean and how they should be displayed
12-//! - **Decisions**: The output of moderation logic indicating what actions to take
13//!
14//! # Example
15//!
···27//! ```
2829mod decision;
30-#[cfg(feature = "api_bluesky")]
31mod fetch;
32mod labeled;
33mod moderatable;
···37mod tests;
3839pub use decision::{ModerationIterExt, moderate, moderate_all};
0040#[cfg(feature = "api_bluesky")]
41pub use fetch::{fetch_labeler_defs, fetch_labeler_defs_direct};
42-pub use labeled::Labeled;
43pub use moderatable::Moderateable;
44pub use types::{
45 Blur, LabelCause, LabelPref, LabelTarget, LabelerDefs, ModerationDecision, ModerationPrefs,
···1+//! Moderation
2+//!
3+//! This is an attempt to semi-generalize the Bluesky moderation system. It avoids
4+//! depending on their lexicons as much as reasonably possible. This works via a
5+//! trait, [`Labeled`], which represents things that have labels for moderation
6+//! applied to them. This way the moderation application functions can operate
7+//! primarily via the trait, and are thus generic over lexicon types, and are
8+//! easy to use with your own types.
9//!
10+//! For more complex types which might have labels applied to components,
11+//! there is the [`Moderateable`] trait. A mostly complete implementation for
12+//! `FeedViewPost` is available for reference. The trait method outputs a `Vec`
13+//! of tuples, where the first element is a string tag and the second is the
14+//! moderation decision for the tagged element. This lets application developers
15+//! change behaviour based on what part of the content got a label. The functions
16+//! mostly match Bluesky behaviour (respecting "!hide", and such) by default.
17//!
18+//! I've taken the time to go through the generated API bindings and implement
19+//! the [`Labeled`] trait for a number of types. It's a fairly easy trait to
20+//! implement, just not really automatable.
21//!
000022//!
23//! # Example
24//!
···36//! ```
3738mod decision;
39+#[cfg(feature = "api")]
40mod fetch;
41mod labeled;
42mod moderatable;
···46mod tests;
4748pub use decision::{ModerationIterExt, moderate, moderate_all};
49+#[cfg(feature = "api")]
50+pub use fetch::{fetch_labeled_record, fetch_labels};
51#[cfg(feature = "api_bluesky")]
52pub use fetch::{fetch_labeler_defs, fetch_labeler_defs_direct};
53+pub use labeled::{Labeled, LabeledRecord};
54pub use moderatable::Moderateable;
55pub use types::{
56 Blur, LabelCause, LabelPref, LabelTarget, LabelerDefs, ModerationDecision, ModerationPrefs,
+92-15
crates/jacquard/src/moderation/fetch.rs
···1use super::LabelerDefs;
2-use crate::client::AgentSessionExt;
3-use jacquard_api::app_bsky::labeler::get_services::{GetServices, GetServicesOutput};
4-use jacquard_api::app_bsky::labeler::service::Service;
5-use jacquard_common::IntoStatic;
6-use jacquard_common::error::ClientError;
00000007use jacquard_common::types::string::Did;
8-use jacquard_common::xrpc::{XrpcClient, XrpcError};
000910/// Fetch labeler definitions from Bluesky's AppView (or a compatible one)
011pub async fn fetch_labeler_defs(
12 client: &(impl XrpcClient + Sync),
13 dids: Vec<Did<'_>>,
···20 let response = client.send(request).await?;
21 let output: GetServicesOutput<'static> = response.into_output().map_err(|e| match e {
22 XrpcError::Auth(auth) => ClientError::Auth(auth),
23- XrpcError::Generic(g) => ClientError::Transport(
24- jacquard_common::error::TransportError::Other(g.to_string().into()),
25- ),
26 XrpcError::Decode(e) => ClientError::Decode(e),
27- XrpcError::Xrpc(typed) => ClientError::Transport(
28- jacquard_common::error::TransportError::Other(format!("{:?}", typed).into()),
29- ),
30 })?;
3132 let mut defs = LabelerDefs::new();
···61/// This fetches the `app.bsky.labeler.service` record directly from the PDS where
62/// the labeler is hosted.
63///
00000064pub async fn fetch_labeler_defs_direct(
65 client: &(impl AgentSessionExt + Sync),
66 dids: Vec<Did<'_>>,
···73 for did in dids {
74 let uri = format!("at://{}/app.bsky.labeler.service/self", did.as_str());
75 let record_uri = Service::uri(uri).map_err(|e| {
76- ClientError::Transport(jacquard_common::error::TransportError::Other(
77- format!("Invalid URI: {}", e).into(),
78- ))
79 })?;
8081 let output = client.fetch_record(&record_uri).await?;
···8889 Ok(defs)
90}
00000000000000000000000000000000000000000000000000000000000000
···1use super::LabelerDefs;
2+use crate::client::{AgentError, AgentSessionExt, CollectionErr, CollectionOutput};
3+use crate::moderation::labeled::LabeledRecord;
4+5+#[cfg(feature = "api_bluesky")]
6+use jacquard_api::app_bsky::labeler::{
7+ get_services::{GetServices, GetServicesOutput},
8+ service::Service,
9+};
10+use jacquard_api::com_atproto::label::{Label, query_labels::QueryLabels};
11+use jacquard_common::cowstr::ToCowStr;
12+use jacquard_common::error::{ClientError, TransportError};
13+use jacquard_common::types::collection::Collection;
14use jacquard_common::types::string::Did;
15+use jacquard_common::types::uri::RecordUri;
16+use jacquard_common::xrpc::{XrpcClient, XrpcError, XrpcResp};
17+use jacquard_common::{CowStr, IntoStatic};
18+use std::convert::From;
1920/// Fetch labeler definitions from Bluesky's AppView (or a compatible one)
21+#[cfg(feature = "api_bluesky")]
22pub async fn fetch_labeler_defs(
23 client: &(impl XrpcClient + Sync),
24 dids: Vec<Did<'_>>,
···31 let response = client.send(request).await?;
32 let output: GetServicesOutput<'static> = response.into_output().map_err(|e| match e {
33 XrpcError::Auth(auth) => ClientError::Auth(auth),
34+ XrpcError::Generic(g) => {
35+ ClientError::Transport(TransportError::Other(g.to_string().into()))
36+ }
37 XrpcError::Decode(e) => ClientError::Decode(e),
38+ XrpcError::Xrpc(typed) => {
39+ ClientError::Transport(TransportError::Other(format!("{:?}", typed).into()))
40+ }
41 })?;
4243 let mut defs = LabelerDefs::new();
···72/// This fetches the `app.bsky.labeler.service` record directly from the PDS where
73/// the labeler is hosted.
74///
75+/// This is much less efficient for the client than querying the AppView, but has
76+/// the virtue of working without the Bluesky AppView or a compatible one. Other
77+/// alternatives include querying <https://ufos.microcosm.blue> for definitions
78+/// created relatively recently, or doing your own scraping and indexing beforehand.
79+///
80+#[cfg(feature = "api_bluesky")]
81pub async fn fetch_labeler_defs_direct(
82 client: &(impl AgentSessionExt + Sync),
83 dids: Vec<Did<'_>>,
···90 for did in dids {
91 let uri = format!("at://{}/app.bsky.labeler.service/self", did.as_str());
92 let record_uri = Service::uri(uri).map_err(|e| {
93+ ClientError::Transport(TransportError::Other(format!("Invalid URI: {}", e).into()))
0094 })?;
9596 let output = client.fetch_record(&record_uri).await?;
···103104 Ok(defs)
105}
106+107+/// Convenient wrapper for com.atproto.label.queryLabels
108+///
109+/// Avoids depending on the Bluesky namespace, though it may call out to the
110+/// Bluesky AppView (or a compatible one configured via atproto-proxy header).
111+///
112+/// Fetches labels directly for a given set of URI patterns.
113+/// This one defaults to the max number, assuming that you will be fetching
114+/// in bulk. This is not especially efficient and mostly exists as a demonstration.
115+///
116+/// In practice if you are running an app server, you should call [`subscribeLabels`](https://tangled.org/@nonbinary.computer/jacquard/blob/main/crates/jacquard-api/src/com_atproto/label/subscribe_labels.rs)
117+/// on labelers to tail their output, and index them alongside the data your app cares about.
118+pub async fn fetch_labels(
119+ client: &impl AgentSessionExt,
120+ uri_patterns: Vec<CowStr<'_>>,
121+ sources: Vec<Did<'_>>,
122+ cursor: Option<CowStr<'_>>,
123+) -> Result<(Vec<Label<'static>>, Option<CowStr<'static>>), AgentError> {
124+ #[cfg(feature = "tracing")]
125+ let _span = tracing::debug_span!("fetch_labels", count = sources.len()).entered();
126+127+ let request = QueryLabels::new()
128+ .maybe_cursor(cursor)
129+ .limit(250)
130+ .uri_patterns(uri_patterns)
131+ .sources(sources)
132+ .build();
133+ let labels = client
134+ .send(request)
135+ .await?
136+ .into_output()
137+ .map_err(|e| match e {
138+ XrpcError::Generic(e) => AgentError::Generic(e),
139+ _ => unimplemented!(), // We know the error at this point is always GenericXrpcError
140+ })?;
141+ Ok((labels.labels, labels.cursor))
142+}
143+144+/// Minimal helper to fetch a URI and any labels.
145+///
146+/// This is *extremely* inefficient and should not be used except in experimentation.
147+/// It primarily exists as a demonstration that you can hydrate labels without
148+/// using any Bluesky appview methods.
149+///
150+/// In practice if you are running an app server, you should call [`subscribeLabels`](https://tangled.org/@nonbinary.computer/jacquard/blob/main/crates/jacquard-api/src/com_atproto/label/subscribe_labels.rs)
151+/// on labelers to tail their output, and index them alongside the data your app cares about.
152+pub async fn fetch_labeled_record<R>(
153+ client: &impl AgentSessionExt,
154+ record_uri: &RecordUri<'_, R>,
155+ sources: Vec<Did<'_>>,
156+) -> Result<LabeledRecord<'static, R>, AgentError>
157+where
158+ R: Collection + From<CollectionOutput<'static, R>>,
159+ for<'a> CollectionOutput<'a, R>: IntoStatic<Output = CollectionOutput<'static, R>>,
160+ for<'a> CollectionErr<'a, R>: IntoStatic<Output = CollectionErr<'static, R>>,
161+{
162+ let record: R = client.fetch_record(record_uri).await?.into();
163+ let (labels, _) =
164+ fetch_labels(client, vec![record_uri.as_uri().to_cowstr()], sources, None).await?;
165+166+ Ok(LabeledRecord { record, labels })
167+}
+16
crates/jacquard/src/moderation/labeled.rs
···14 }
15}
16000000000000000017// Implementations for common Bluesky types
18#[cfg(feature = "api_bluesky")]
19mod bluesky_impls {
···14 }
15}
1617+/// Record with applied labels
18+///
19+/// Exists as a bare minimum RecordView type primarily for testing/demonstration.
20+pub struct LabeledRecord<'a, C> {
21+ /// The record we grabbed labels for
22+ pub record: C,
23+ /// The labels applied to the record
24+ pub labels: Vec<Label<'a>>,
25+}
26+27+impl<'a, C> Labeled<'a> for LabeledRecord<'a, C> {
28+ fn labels(&self) -> &[Label<'a>] {
29+ &self.labels
30+ }
31+}
32+33// Implementations for common Bluesky types
34#[cfg(feature = "api_bluesky")]
35mod bluesky_impls {
+47-56
crates/jacquard/src/richtext.rs
···56#[cfg(feature = "api_bluesky")]
7use crate::api::app_bsky::richtext::facet::Facet;
008use crate::common::CowStr;
009use jacquard_common::IntoStatic;
0010use jacquard_common::types::did::{DID_REGEX, Did};
11use jacquard_common::types::handle::HANDLE_REGEX;
0000012use regex::Regex;
13use std::marker::PhantomData;
14use std::ops::Range;
···101 /// Bluesky record (post, list, starterpack, feed)
102 Record {
103 /// The at:// URI identifying the record
104- at_uri: crate::types::aturi::AtUri<'a>,
105 /// Strong reference (repo + CID) if resolved
106- strong_ref: Option<crate::api::com_atproto::repo::strong_ref::StrongRef<'a>>,
107 },
108 /// External link embed
109 External {
···221222/// Entry point for parsing text with automatic facet detection
223///
224-/// Uses default embed domains (bsky.app, deer.social) for at-URI extraction.
225/// For custom domains, use [`parse_with_domains`].
226pub fn parse(text: impl AsRef<str>) -> RichTextBuilder<Unresolved> {
227 #[cfg(feature = "api_bluesky")]
···230 }
231 #[cfg(not(feature = "api_bluesky"))]
232 {
233- parse_with_domains(text, &[])
234 }
235}
236237/// Parse text with custom embed domains for at-URI extraction
238///
239-/// This allows specifying additional domains (beyond bsky.app and deer.social)
240/// that use the same URL patterns for records (e.g., /profile/{actor}/post/{rkey}).
241#[cfg(feature = "api_bluesky")]
242pub fn parse_with_domains(
···300301/// Parse text without embed detection (no api_bluesky feature)
302#[cfg(not(feature = "api_bluesky"))]
303-pub fn parse_with_domains(
304- text: impl AsRef<str>,
305- _embed_domains: &[&str],
306-) -> RichTextBuilder<Unresolved> {
307 // Step 0: Sanitize text (remove invisible chars, normalize newlines)
308 let text = sanitize_text(text.as_ref());
309···378 }
379380 /// Add a mention facet with a resolved DID (requires explicit range)
381- pub fn mention(mut self, did: &crate::types::did::Did<'_>, range: Range<usize>) -> Self {
382 self.facet_candidates.push(FacetCandidate::Mention {
383 range,
384 did: Some(did.clone().into_static()),
···424 /// Add a record embed candidate
425 pub fn embed_record(
426 mut self,
427- at_uri: crate::types::aturi::AtUri<'static>,
428- strong_ref: Option<crate::api::com_atproto::repo::strong_ref::StrongRef<'static>>,
429 ) -> Self {
430 self.embed_candidates
431 .get_or_insert_with(Vec::new)
···607/// Classifies a URL or at-URI as an embed candidate
608#[cfg(feature = "api_bluesky")]
609fn classify_embed(url: &str, embed_domains: &[&str]) -> Option<EmbedCandidate<'static>> {
610- use crate::types::aturi::AtUri;
611-612 // Check if it's an at:// URI
613 if url.starts_with("at://") {
614 if let Ok(at_uri) = AtUri::new(url) {
···650///
651/// Only works for domains in the provided `embed_domains` list.
652#[cfg(feature = "api_bluesky")]
653-fn extract_at_uri_from_url(
654- url: &str,
655- embed_domains: &[&str],
656-) -> Option<crate::types::aturi::AtUri<'static>> {
657- use crate::types::aturi::AtUri;
658-659 // Parse URL
660 let url_parsed = url::Url::parse(url).ok()?;
661···693 AtUri::new(&at_uri_str).ok().map(|u| u.into_static())
694}
695696-use jacquard_common::types::string::AtStrError;
697-use thiserror::Error;
698-699/// Errors that can occur during richtext building
700-#[derive(Debug, Error)]
701pub enum RichTextError {
702 /// Handle found that needs resolution but no resolver provided
703 #[error("Handle '{0}' requires resolution - use build_async() with an IdentityResolver")]
···709710 /// Identity resolution failed
711 #[error("Failed to resolve identity")]
712- IdentityResolution(#[from] jacquard_identity::resolver::IdentityError),
713714 /// Invalid byte range
715 #[error("Invalid byte range {start}..{end} for text of length {text_len}")]
···728729 /// Invalid URI
730 #[error("Invalid URI")]
731- Uri(#[from] jacquard_common::types::uri::UriParseError),
732}
733734#[cfg(feature = "api_bluesky")]
···758 let text_len = self.text.len();
759760 for candidate in candidates {
761- use crate::api::app_bsky::richtext::facet::{ByteSlice, Facet};
000762763 let (range, feature) = match candidate {
764 FacetCandidate::MarkdownLink { display_range, url } => {
765 // MarkdownLink stores URL directly, use display_range for index
766767- let feature = crate::api::app_bsky::richtext::facet::FacetFeaturesItem::Link(
768- Box::new(crate::api::app_bsky::richtext::facet::Link {
769- uri: crate::types::uri::Uri::new_owned(&url)?,
770- extra_data: BTreeMap::new(),
771- }),
772- );
773 (display_range, feature)
774 }
775 FacetCandidate::Mention { range, did } => {
···784 RichTextError::HandleNeedsResolution(handle.to_string())
785 })?;
786787- let feature = crate::api::app_bsky::richtext::facet::FacetFeaturesItem::Mention(
788- Box::new(crate::api::app_bsky::richtext::facet::Mention {
789- did,
790- extra_data: BTreeMap::new(),
791- }),
792- );
793 (range, feature)
794 }
795 FacetCandidate::Link { range } => {
···809 url = format!("https://{}", url);
810 }
811812- let feature = crate::api::app_bsky::richtext::facet::FacetFeaturesItem::Link(
813- Box::new(crate::api::app_bsky::richtext::facet::Link {
814- uri: crate::types::uri::Uri::new_owned(&url)?,
815- extra_data: BTreeMap::new(),
816- }),
817- );
818 (range, feature)
819 }
820 FacetCandidate::Tag { range } => {
···835 .trim_start_matches('#')
836 .trim_start_matches('#');
837838- let feature = crate::api::app_bsky::richtext::facet::FacetFeaturesItem::Tag(
839- Box::new(crate::api::app_bsky::richtext::facet::Tag {
840- tag: CowStr::from(tag.to_smolstr()),
841- extra_data: BTreeMap::new(),
842- }),
843- );
844 (range, feature)
845 }
846 };
···884 /// Build richtext, resolving handles to DIDs using the provided resolver
885 pub async fn build_async<R>(self, resolver: &R) -> Result<RichText<'static>, RichTextError>
886 where
887- R: jacquard_identity::resolver::IdentityResolver + Sync,
888 {
889 use crate::api::app_bsky::richtext::facet::{
890 ByteSlice, FacetFeaturesItem, Link, Mention, Tag,
···1040 client: &C,
1041 ) -> Result<(RichText<'static>, Option<Vec<EmbedCandidate<'static>>>), RichTextError>
1042 where
1043- C: jacquard_common::http_client::HttpClient
1044- + jacquard_identity::resolver::IdentityResolver
1045- + Sync,
1046 {
1047 // Extract embed candidates
1048 let embed_candidates = self.embed_candidates.take().unwrap_or_default();
···1096 url: &str,
1097) -> Result<Option<ExternalMetadata<'static>>, Box<dyn std::error::Error + Send + Sync>>
1098where
1099- C: jacquard_common::http_client::HttpClient,
1100{
1101 // Build HTTP GET request
1102 let request = http::Request::builder()
···56#[cfg(feature = "api_bluesky")]
7use crate::api::app_bsky::richtext::facet::Facet;
8+#[cfg(feature = "api_bluesky")]
9+use crate::api::com_atproto::repo::strong_ref::StrongRef;
10use crate::common::CowStr;
11+#[cfg(feature = "api_bluesky")]
12+use crate::types::aturi::AtUri;
13use jacquard_common::IntoStatic;
14+#[cfg(feature = "api_bluesky")]
15+use jacquard_common::http_client::HttpClient;
16use jacquard_common::types::did::{DID_REGEX, Did};
17use jacquard_common::types::handle::HANDLE_REGEX;
18+use jacquard_common::types::string::AtStrError;
19+use jacquard_common::types::uri::UriParseError;
20+use jacquard_identity::resolver::IdentityError;
21+#[cfg(feature = "api_bluesky")]
22+use jacquard_identity::resolver::IdentityResolver;
23use regex::Regex;
24use std::marker::PhantomData;
25use std::ops::Range;
···112 /// Bluesky record (post, list, starterpack, feed)
113 Record {
114 /// The at:// URI identifying the record
115+ at_uri: AtUri<'a>,
116 /// Strong reference (repo + CID) if resolved
117+ strong_ref: Option<StrongRef<'a>>,
118 },
119 /// External link embed
120 External {
···232233/// Entry point for parsing text with automatic facet detection
234///
235+/// Uses default embed domains (bsky.app, deer.social, blacksky.community, catsky.social) for at-URI extraction.
236/// For custom domains, use [`parse_with_domains`].
237pub fn parse(text: impl AsRef<str>) -> RichTextBuilder<Unresolved> {
238 #[cfg(feature = "api_bluesky")]
···241 }
242 #[cfg(not(feature = "api_bluesky"))]
243 {
244+ parse_with_domains(text)
245 }
246}
247248/// Parse text with custom embed domains for at-URI extraction
249///
250+/// This allows specifying additional domains (beyond the defaults)
251/// that use the same URL patterns for records (e.g., /profile/{actor}/post/{rkey}).
252#[cfg(feature = "api_bluesky")]
253pub fn parse_with_domains(
···311312/// Parse text without embed detection (no api_bluesky feature)
313#[cfg(not(feature = "api_bluesky"))]
314+pub fn parse_with_domains(text: impl AsRef<str>) -> RichTextBuilder<Unresolved> {
000315 // Step 0: Sanitize text (remove invisible chars, normalize newlines)
316 let text = sanitize_text(text.as_ref());
317···386 }
387388 /// Add a mention facet with a resolved DID (requires explicit range)
389+ pub fn mention(mut self, did: &Did<'_>, range: Range<usize>) -> Self {
390 self.facet_candidates.push(FacetCandidate::Mention {
391 range,
392 did: Some(did.clone().into_static()),
···432 /// Add a record embed candidate
433 pub fn embed_record(
434 mut self,
435+ at_uri: AtUri<'static>,
436+ strong_ref: Option<StrongRef<'static>>,
437 ) -> Self {
438 self.embed_candidates
439 .get_or_insert_with(Vec::new)
···615/// Classifies a URL or at-URI as an embed candidate
616#[cfg(feature = "api_bluesky")]
617fn classify_embed(url: &str, embed_domains: &[&str]) -> Option<EmbedCandidate<'static>> {
00618 // Check if it's an at:// URI
619 if url.starts_with("at://") {
620 if let Ok(at_uri) = AtUri::new(url) {
···656///
657/// Only works for domains in the provided `embed_domains` list.
658#[cfg(feature = "api_bluesky")]
659+fn extract_at_uri_from_url(url: &str, embed_domains: &[&str]) -> Option<AtUri<'static>> {
00000660 // Parse URL
661 let url_parsed = url::Url::parse(url).ok()?;
662···694 AtUri::new(&at_uri_str).ok().map(|u| u.into_static())
695}
696000697/// Errors that can occur during richtext building
698+#[derive(Debug, thiserror::Error, miette::Diagnostic)]
699pub enum RichTextError {
700 /// Handle found that needs resolution but no resolver provided
701 #[error("Handle '{0}' requires resolution - use build_async() with an IdentityResolver")]
···707708 /// Identity resolution failed
709 #[error("Failed to resolve identity")]
710+ IdentityResolution(#[from] IdentityError),
711712 /// Invalid byte range
713 #[error("Invalid byte range {start}..{end} for text of length {text_len}")]
···726727 /// Invalid URI
728 #[error("Invalid URI")]
729+ Uri(#[from] UriParseError),
730}
731732#[cfg(feature = "api_bluesky")]
···756 let text_len = self.text.len();
757758 for candidate in candidates {
759+ use crate::api::app_bsky::richtext::facet::{
760+ ByteSlice, FacetFeaturesItem, Link, Mention, Tag,
761+ };
762+ use crate::types::uri::Uri;
763764 let (range, feature) = match candidate {
765 FacetCandidate::MarkdownLink { display_range, url } => {
766 // MarkdownLink stores URL directly, use display_range for index
767768+ let feature = FacetFeaturesItem::Link(Box::new(Link {
769+ uri: Uri::new_owned(&url)?,
770+ extra_data: BTreeMap::new(),
771+ }));
00772 (display_range, feature)
773 }
774 FacetCandidate::Mention { range, did } => {
···783 RichTextError::HandleNeedsResolution(handle.to_string())
784 })?;
785786+ let feature = FacetFeaturesItem::Mention(Box::new(Mention {
787+ did,
788+ extra_data: BTreeMap::new(),
789+ }));
00790 (range, feature)
791 }
792 FacetCandidate::Link { range } => {
···806 url = format!("https://{}", url);
807 }
808809+ let feature = FacetFeaturesItem::Link(Box::new(Link {
810+ uri: Uri::new_owned(&url)?,
811+ extra_data: BTreeMap::new(),
812+ }));
00813 (range, feature)
814 }
815 FacetCandidate::Tag { range } => {
···830 .trim_start_matches('#')
831 .trim_start_matches('#');
832833+ let feature = FacetFeaturesItem::Tag(Box::new(Tag {
834+ tag: CowStr::from(tag.to_smolstr()),
835+ extra_data: BTreeMap::new(),
836+ }));
00837 (range, feature)
838 }
839 };
···877 /// Build richtext, resolving handles to DIDs using the provided resolver
878 pub async fn build_async<R>(self, resolver: &R) -> Result<RichText<'static>, RichTextError>
879 where
880+ R: IdentityResolver + Sync,
881 {
882 use crate::api::app_bsky::richtext::facet::{
883 ByteSlice, FacetFeaturesItem, Link, Mention, Tag,
···1033 client: &C,
1034 ) -> Result<(RichText<'static>, Option<Vec<EmbedCandidate<'static>>>), RichTextError>
1035 where
1036+ C: HttpClient + IdentityResolver + Sync,
001037 {
1038 // Extract embed candidates
1039 let embed_candidates = self.embed_candidates.take().unwrap_or_default();
···1087 url: &str,
1088) -> Result<Option<ExternalMetadata<'static>>, Box<dyn std::error::Error + Send + Sync>>
1089where
1090+ C: HttpClient,
1091{
1092 // Build HTTP GET request
1093 let request = http::Request::builder()
+18-5
examples/create_post.rs
···4use jacquard::client::{Agent, AgentSessionExt, FileAuthStore};
5use jacquard::oauth::client::OAuthClient;
6use jacquard::oauth::loopback::LoopbackConfig;
07use jacquard::types::string::Datetime;
89#[derive(Parser, Debug)]
10-#[command(author, version, about = "Create a simple post")]
11struct Args {
12 /// Handle (e.g., alice.bsky.social), DID, or PDS URL
13 input: CowStr<'static>,
1415- /// Post text
16 #[arg(short, long)]
17 text: String,
18···3233 let agent: Agent<_> = Agent::from(session);
3435- // Create a simple text post using the Agent convenience method
00000000000036 let post = Post {
37- text: CowStr::from(args.text),
038 created_at: Datetime::now(),
39 embed: None,
40 entities: None,
41- facets: None,
42 labels: None,
43 langs: None,
44 reply: None,
···4use jacquard::client::{Agent, AgentSessionExt, FileAuthStore};
5use jacquard::oauth::client::OAuthClient;
6use jacquard::oauth::loopback::LoopbackConfig;
7+use jacquard::richtext::RichText;
8use jacquard::types::string::Datetime;
910#[derive(Parser, Debug)]
11+#[command(author, version, about = "Create a post with automatic facet detection")]
12struct Args {
13 /// Handle (e.g., alice.bsky.social), DID, or PDS URL
14 input: CowStr<'static>,
1516+ /// Post text (can include @mentions, #hashtags, URLs, and [markdown](links))
17 #[arg(short, long)]
18 text: String,
19···3334 let agent: Agent<_> = Agent::from(session);
3536+ // Parse richtext with automatic facet detection
37+ // This detects @mentions, #hashtags, URLs, and [markdown](links)
38+ let richtext = RichText::parse(&args.text).build_async(&agent).await?;
39+40+ println!("Detected {} facets:", richtext.facets.as_ref().map(|f| f.len()).unwrap_or(0));
41+ if let Some(facets) = &richtext.facets {
42+ for facet in facets {
43+ let text_slice = &richtext.text[facet.index.byte_start as usize..facet.index.byte_end as usize];
44+ println!(" - \"{}\" ({:?})", text_slice, facet.features);
45+ }
46+ }
47+48+ // Create post with parsed facets
49 let post = Post {
50+ text: richtext.text,
51+ facets: richtext.facets,
52 created_at: Datetime::now(),
53 embed: None,
54 entities: None,
055 labels: None,
56 langs: None,
57 reply: None,