A better Rust ATProto crate

okay this is going to be 0.7.0 release

Orual 7f032314 c251e98b

Changed files
+269 -123
crates
jacquard
jacquard-api
jacquard-axum
jacquard-derive
jacquard-identity
jacquard-lexicon
jacquard-oauth
examples
+38
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + ## [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 + 3 41 ## [0.6.0] - 2025-10-18 4 42 5 43 ### Added
+14 -14
Cargo.lock
··· 2242 2242 2243 2243 [[package]] 2244 2244 name = "jacquard" 2245 - version = "0.6.0" 2245 + version = "0.6.1" 2246 2246 dependencies = [ 2247 2247 "bon", 2248 2248 "bytes", ··· 2251 2251 "getrandom 0.2.16", 2252 2252 "http", 2253 2253 "image", 2254 - "jacquard-api 0.6.1", 2254 + "jacquard-api 0.6.2", 2255 2255 "jacquard-common 0.6.0", 2256 - "jacquard-derive 0.6.0", 2256 + "jacquard-derive 0.6.1", 2257 2257 "jacquard-identity 0.6.0", 2258 2258 "jacquard-oauth", 2259 2259 "jose-jwk", ··· 2287 2287 "bon", 2288 2288 "bytes", 2289 2289 "jacquard-common 0.6.0 (git+https://tangled.org/@nonbinary.computer/jacquard)", 2290 - "jacquard-derive 0.6.0 (git+https://tangled.org/@nonbinary.computer/jacquard)", 2290 + "jacquard-derive 0.6.0", 2291 2291 "miette", 2292 2292 "serde", 2293 2293 "serde_ipld_dagcbor", ··· 2296 2296 2297 2297 [[package]] 2298 2298 name = "jacquard-api" 2299 - version = "0.6.1" 2299 + version = "0.6.2" 2300 2300 dependencies = [ 2301 2301 "bon", 2302 2302 "bytes", 2303 2303 "jacquard-common 0.6.0", 2304 - "jacquard-derive 0.6.0", 2304 + "jacquard-derive 0.6.1", 2305 2305 "miette", 2306 2306 "serde", 2307 2307 "serde_ipld_dagcbor", ··· 2320 2320 "chrono", 2321 2321 "jacquard", 2322 2322 "jacquard-common 0.6.0", 2323 - "jacquard-derive 0.6.0", 2323 + "jacquard-derive 0.6.1", 2324 2324 "jacquard-identity 0.6.0", 2325 2325 "k256", 2326 2326 "miette", ··· 2423 2423 [[package]] 2424 2424 name = "jacquard-derive" 2425 2425 version = "0.6.0" 2426 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#861d9e86c582939ed1d50201954ef1368a91f9b7" 2426 2427 dependencies = [ 2427 - "jacquard-common 0.6.0", 2428 2428 "proc-macro2", 2429 2429 "quote", 2430 - "serde", 2431 - "serde_json", 2432 2430 "syn 2.0.106", 2433 2431 ] 2434 2432 2435 2433 [[package]] 2436 2434 name = "jacquard-derive" 2437 - version = "0.6.0" 2438 - source = "git+https://tangled.org/@nonbinary.computer/jacquard#861d9e86c582939ed1d50201954ef1368a91f9b7" 2435 + version = "0.6.1" 2439 2436 dependencies = [ 2437 + "jacquard-common 0.6.0", 2440 2438 "proc-macro2", 2441 2439 "quote", 2440 + "serde", 2441 + "serde_json", 2442 2442 "syn 2.0.106", 2443 2443 ] 2444 2444 ··· 2450 2450 "bytes", 2451 2451 "hickory-resolver", 2452 2452 "http", 2453 - "jacquard-api 0.6.1", 2453 + "jacquard-api 0.6.2", 2454 2454 "jacquard-common 0.6.0", 2455 2455 "miette", 2456 2456 "n0-future", ··· 2492 2492 2493 2493 [[package]] 2494 2494 name = "jacquard-lexicon" 2495 - version = "0.6.0" 2495 + version = "0.6.1" 2496 2496 dependencies = [ 2497 2497 "async-trait", 2498 2498 "clap",
+1 -1
Cargo.toml
··· 5 5 6 6 [workspace.package] 7 7 edition = "2024" 8 - version = "0.6.0" 8 + version = "0.7.0" 9 9 authors = ["Orual <orual@nonbinary.computer>"] 10 10 #repository = "https://github.com/rsform/jacquard" 11 11 repository = "https://tangled.org/@nonbinary.computer/jacquard"
+4 -4
crates/jacquard-api/Cargo.toml
··· 2 2 name = "jacquard-api" 3 3 description = "Generated AT Protocol API bindings for Jacquard" 4 4 edition.workspace = true 5 - version = "0.6.1" 5 + version = "0.7.0" 6 6 authors.workspace = true 7 7 repository.workspace = true 8 8 keywords.workspace = true ··· 12 12 license.workspace = true 13 13 14 14 [package.metadata.docs.rs] 15 - features = [ "bluesky", "other", "lexicon_community", "ufos", "streaming" ] 15 + features = [ "bluesky", "other", "lexicon_community", "streaming" ] 16 16 17 17 [dependencies] 18 18 bon.workspace = true 19 19 bytes = { workspace = true, features = ["serde"] } 20 - jacquard-common = { version = "0.6", path = "../jacquard-common" } 21 - jacquard-derive = { version = "0.6", path = "../jacquard-derive" } 20 + jacquard-common = { version = "0.7", path = "../jacquard-common" } 21 + jacquard-derive = { version = "0.7", path = "../jacquard-derive" } 22 22 miette.workspace = true 23 23 serde.workspace = true 24 24 serde_ipld_dagcbor.workspace = true
+4 -4
crates/jacquard-axum/Cargo.toml
··· 22 22 [dependencies] 23 23 axum = "0.8.6" 24 24 bytes.workspace = true 25 - jacquard = { version = "0.6", path = "../jacquard", default-features = false, features = ["api"] } 26 - jacquard-common = { version = "0.6", path = "../jacquard-common", features = ["reqwest-client"] } 27 - jacquard-derive = { version = "0.6", path = "../jacquard-derive" } 28 - jacquard-identity = { version = "0.6", path = "../jacquard-identity", optional = true } 25 + jacquard = { version = "0.7", path = "../jacquard", default-features = false, features = ["api"] } 26 + jacquard-common = { version = "0.7", path = "../jacquard-common", features = ["reqwest-client"] } 27 + jacquard-derive = { version = "0.7", path = "../jacquard-derive" } 28 + jacquard-identity = { version = "0.7", path = "../jacquard-identity", optional = true } 29 29 miette.workspace = true 30 30 multibase = { version = "0.9.1", optional = true } 31 31 serde.workspace = true
+1 -1
crates/jacquard-derive/Cargo.toml
··· 20 20 syn.workspace = true 21 21 22 22 [dev-dependencies] 23 - jacquard-common = { version = "0.6", path = "../jacquard-common" } 23 + jacquard-common = { version = "0.7", path = "../jacquard-common" } 24 24 serde.workspace = true 25 25 serde_json.workspace = true
+2 -2
crates/jacquard-identity/Cargo.toml
··· 1 1 [package] 2 2 name = "jacquard-identity" 3 3 edition.workspace = true 4 - version = "0.6.0" 4 + version = "0.7.0" 5 5 authors.workspace = true 6 6 repository.workspace = true 7 7 keywords.workspace = true ··· 21 21 trait-variant.workspace = true 22 22 bon.workspace = true 23 23 bytes.workspace = true 24 - jacquard-common = { version = "0.6", path = "../jacquard-common", features = ["reqwest-client"] } 24 + jacquard-common = { version = "0.7", path = "../jacquard-common", features = ["reqwest-client"] } 25 25 jacquard-api = { version = "0.6", path = "../jacquard-api", default-features = false, features = ["minimal"] } 26 26 percent-encoding.workspace = true 27 27 reqwest.workspace = true
+3 -3
crates/jacquard-lexicon/Cargo.toml
··· 25 25 glob = "0.3" 26 26 heck.workspace = true 27 27 #itertools.workspace = true 28 - jacquard-api = { version = "0.6", git = "https://tangled.org/@nonbinary.computer/jacquard" } 29 - jacquard-common = { version = "0.6", features = [ "reqwest-client" ], git = "https://tangled.org/@nonbinary.computer/jacquard" } 30 - jacquard-identity = { version = "0.6", git = "https://tangled.org/@nonbinary.computer/jacquard" } 28 + jacquard-api = { version = "0.7", git = "https://tangled.org/@nonbinary.computer/jacquard" } 29 + jacquard-common = { version = "0.7", features = [ "reqwest-client" ], git = "https://tangled.org/@nonbinary.computer/jacquard" } 30 + jacquard-identity = { version = "0.7", git = "https://tangled.org/@nonbinary.computer/jacquard" } 31 31 kdl = "6" 32 32 miette = { workspace = true, features = ["fancy"] } 33 33 prettyplease.workspace = true
+2 -2
crates/jacquard-oauth/Cargo.toml
··· 21 21 streaming = ["jacquard-common/streaming", "dep:n0-future"] 22 22 23 23 [dependencies] 24 - jacquard-common = { version = "0.6", path = "../jacquard-common", features = ["reqwest-client"] } 25 - jacquard-identity = { version = "0.6", path = "../jacquard-identity" } 24 + jacquard-common = { version = "0.7", path = "../jacquard-common", features = ["reqwest-client"] } 25 + jacquard-identity = { version = "0.7", path = "../jacquard-identity" } 26 26 serde = { workspace = true, features = ["derive"] } 27 27 serde_json = { workspace = true } 28 28 url = { workspace = true }
+5 -5
crates/jacquard/Cargo.toml
··· 122 122 123 123 124 124 [dependencies] 125 - jacquard-api = { version = "0.6", path = "../jacquard-api" } 126 - jacquard-common = { version = "0.6", path = "../jacquard-common", features = [ 125 + jacquard-api = { version = "0.7", path = "../jacquard-api" } 126 + jacquard-common = { version = "0.7", path = "../jacquard-common", features = [ 127 127 "reqwest-client", 128 128 ] } 129 - jacquard-oauth = { version = "0.6", path = "../jacquard-oauth" } 130 - jacquard-derive = { version = "0.6", path = "../jacquard-derive", optional = true } 131 - jacquard-identity = { version = "0.6", path = "../jacquard-identity" } 129 + jacquard-oauth = { version = "0.7", path = "../jacquard-oauth" } 130 + jacquard-derive = { version = "0.7", path = "../jacquard-derive", optional = true } 131 + jacquard-identity = { version = "0.7", path = "../jacquard-identity" } 132 132 133 133 bon.workspace = true 134 134 trait-variant.workspace = true
+22 -11
crates/jacquard/src/moderation.rs
··· 1 - //! Moderation decision making for AT Protocol content 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. 2 9 //! 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. 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. 6 17 //! 7 - //! # Core Concepts 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. 8 21 //! 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 22 //! 14 23 //! # Example 15 24 //! ··· 27 36 //! ``` 28 37 29 38 mod decision; 30 - #[cfg(feature = "api_bluesky")] 39 + #[cfg(feature = "api")] 31 40 mod fetch; 32 41 mod labeled; 33 42 mod moderatable; ··· 37 46 mod tests; 38 47 39 48 pub use decision::{ModerationIterExt, moderate, moderate_all}; 49 + #[cfg(feature = "api")] 50 + pub use fetch::{fetch_labeled_record, fetch_labels}; 40 51 #[cfg(feature = "api_bluesky")] 41 52 pub use fetch::{fetch_labeler_defs, fetch_labeler_defs_direct}; 42 - pub use labeled::Labeled; 53 + pub use labeled::{Labeled, LabeledRecord}; 43 54 pub use moderatable::Moderateable; 44 55 pub use types::{ 45 56 Blur, LabelCause, LabelPref, LabelTarget, LabelerDefs, ModerationDecision, ModerationPrefs,
+92 -15
crates/jacquard/src/moderation/fetch.rs
··· 1 1 use 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; 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; 7 14 use jacquard_common::types::string::Did; 8 - use jacquard_common::xrpc::{XrpcClient, XrpcError}; 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; 9 19 10 20 /// Fetch labeler definitions from Bluesky's AppView (or a compatible one) 21 + #[cfg(feature = "api_bluesky")] 11 22 pub async fn fetch_labeler_defs( 12 23 client: &(impl XrpcClient + Sync), 13 24 dids: Vec<Did<'_>>, ··· 20 31 let response = client.send(request).await?; 21 32 let output: GetServicesOutput<'static> = response.into_output().map_err(|e| match e { 22 33 XrpcError::Auth(auth) => ClientError::Auth(auth), 23 - XrpcError::Generic(g) => ClientError::Transport( 24 - jacquard_common::error::TransportError::Other(g.to_string().into()), 25 - ), 34 + XrpcError::Generic(g) => { 35 + ClientError::Transport(TransportError::Other(g.to_string().into())) 36 + } 26 37 XrpcError::Decode(e) => ClientError::Decode(e), 27 - XrpcError::Xrpc(typed) => ClientError::Transport( 28 - jacquard_common::error::TransportError::Other(format!("{:?}", typed).into()), 29 - ), 38 + XrpcError::Xrpc(typed) => { 39 + ClientError::Transport(TransportError::Other(format!("{:?}", typed).into())) 40 + } 30 41 })?; 31 42 32 43 let mut defs = LabelerDefs::new(); ··· 61 72 /// This fetches the `app.bsky.labeler.service` record directly from the PDS where 62 73 /// the labeler is hosted. 63 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")] 64 81 pub async fn fetch_labeler_defs_direct( 65 82 client: &(impl AgentSessionExt + Sync), 66 83 dids: Vec<Did<'_>>, ··· 73 90 for did in dids { 74 91 let uri = format!("at://{}/app.bsky.labeler.service/self", did.as_str()); 75 92 let record_uri = Service::uri(uri).map_err(|e| { 76 - ClientError::Transport(jacquard_common::error::TransportError::Other( 77 - format!("Invalid URI: {}", e).into(), 78 - )) 93 + ClientError::Transport(TransportError::Other(format!("Invalid URI: {}", e).into())) 79 94 })?; 80 95 81 96 let output = client.fetch_record(&record_uri).await?; ··· 88 103 89 104 Ok(defs) 90 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 14 } 15 15 } 16 16 17 + /// 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 + 17 33 // Implementations for common Bluesky types 18 34 #[cfg(feature = "api_bluesky")] 19 35 mod bluesky_impls {
+47 -56
crates/jacquard/src/richtext.rs
··· 5 5 6 6 #[cfg(feature = "api_bluesky")] 7 7 use crate::api::app_bsky::richtext::facet::Facet; 8 + #[cfg(feature = "api_bluesky")] 9 + use crate::api::com_atproto::repo::strong_ref::StrongRef; 8 10 use crate::common::CowStr; 11 + #[cfg(feature = "api_bluesky")] 12 + use crate::types::aturi::AtUri; 9 13 use jacquard_common::IntoStatic; 14 + #[cfg(feature = "api_bluesky")] 15 + use jacquard_common::http_client::HttpClient; 10 16 use jacquard_common::types::did::{DID_REGEX, Did}; 11 17 use 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; 12 23 use regex::Regex; 13 24 use std::marker::PhantomData; 14 25 use std::ops::Range; ··· 101 112 /// Bluesky record (post, list, starterpack, feed) 102 113 Record { 103 114 /// The at:// URI identifying the record 104 - at_uri: crate::types::aturi::AtUri<'a>, 115 + at_uri: AtUri<'a>, 105 116 /// Strong reference (repo + CID) if resolved 106 - strong_ref: Option<crate::api::com_atproto::repo::strong_ref::StrongRef<'a>>, 117 + strong_ref: Option<StrongRef<'a>>, 107 118 }, 108 119 /// External link embed 109 120 External { ··· 221 232 222 233 /// Entry point for parsing text with automatic facet detection 223 234 /// 224 - /// Uses default embed domains (bsky.app, deer.social) for at-URI extraction. 235 + /// Uses default embed domains (bsky.app, deer.social, blacksky.community, catsky.social) for at-URI extraction. 225 236 /// For custom domains, use [`parse_with_domains`]. 226 237 pub fn parse(text: impl AsRef<str>) -> RichTextBuilder<Unresolved> { 227 238 #[cfg(feature = "api_bluesky")] ··· 230 241 } 231 242 #[cfg(not(feature = "api_bluesky"))] 232 243 { 233 - parse_with_domains(text, &[]) 244 + parse_with_domains(text) 234 245 } 235 246 } 236 247 237 248 /// Parse text with custom embed domains for at-URI extraction 238 249 /// 239 - /// This allows specifying additional domains (beyond bsky.app and deer.social) 250 + /// This allows specifying additional domains (beyond the defaults) 240 251 /// that use the same URL patterns for records (e.g., /profile/{actor}/post/{rkey}). 241 252 #[cfg(feature = "api_bluesky")] 242 253 pub fn parse_with_domains( ··· 300 311 301 312 /// Parse text without embed detection (no api_bluesky feature) 302 313 #[cfg(not(feature = "api_bluesky"))] 303 - pub fn parse_with_domains( 304 - text: impl AsRef<str>, 305 - _embed_domains: &[&str], 306 - ) -> RichTextBuilder<Unresolved> { 314 + pub fn parse_with_domains(text: impl AsRef<str>) -> RichTextBuilder<Unresolved> { 307 315 // Step 0: Sanitize text (remove invisible chars, normalize newlines) 308 316 let text = sanitize_text(text.as_ref()); 309 317 ··· 378 386 } 379 387 380 388 /// 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 { 389 + pub fn mention(mut self, did: &Did<'_>, range: Range<usize>) -> Self { 382 390 self.facet_candidates.push(FacetCandidate::Mention { 383 391 range, 384 392 did: Some(did.clone().into_static()), ··· 424 432 /// Add a record embed candidate 425 433 pub fn embed_record( 426 434 mut self, 427 - at_uri: crate::types::aturi::AtUri<'static>, 428 - strong_ref: Option<crate::api::com_atproto::repo::strong_ref::StrongRef<'static>>, 435 + at_uri: AtUri<'static>, 436 + strong_ref: Option<StrongRef<'static>>, 429 437 ) -> Self { 430 438 self.embed_candidates 431 439 .get_or_insert_with(Vec::new) ··· 607 615 /// Classifies a URL or at-URI as an embed candidate 608 616 #[cfg(feature = "api_bluesky")] 609 617 fn classify_embed(url: &str, embed_domains: &[&str]) -> Option<EmbedCandidate<'static>> { 610 - use crate::types::aturi::AtUri; 611 - 612 618 // Check if it's an at:// URI 613 619 if url.starts_with("at://") { 614 620 if let Ok(at_uri) = AtUri::new(url) { ··· 650 656 /// 651 657 /// Only works for domains in the provided `embed_domains` list. 652 658 #[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 + fn extract_at_uri_from_url(url: &str, embed_domains: &[&str]) -> Option<AtUri<'static>> { 659 660 // Parse URL 660 661 let url_parsed = url::Url::parse(url).ok()?; 661 662 ··· 693 694 AtUri::new(&at_uri_str).ok().map(|u| u.into_static()) 694 695 } 695 696 696 - use jacquard_common::types::string::AtStrError; 697 - use thiserror::Error; 698 - 699 697 /// Errors that can occur during richtext building 700 - #[derive(Debug, Error)] 698 + #[derive(Debug, thiserror::Error, miette::Diagnostic)] 701 699 pub enum RichTextError { 702 700 /// Handle found that needs resolution but no resolver provided 703 701 #[error("Handle '{0}' requires resolution - use build_async() with an IdentityResolver")] ··· 709 707 710 708 /// Identity resolution failed 711 709 #[error("Failed to resolve identity")] 712 - IdentityResolution(#[from] jacquard_identity::resolver::IdentityError), 710 + IdentityResolution(#[from] IdentityError), 713 711 714 712 /// Invalid byte range 715 713 #[error("Invalid byte range {start}..{end} for text of length {text_len}")] ··· 728 726 729 727 /// Invalid URI 730 728 #[error("Invalid URI")] 731 - Uri(#[from] jacquard_common::types::uri::UriParseError), 729 + Uri(#[from] UriParseError), 732 730 } 733 731 734 732 #[cfg(feature = "api_bluesky")] ··· 758 756 let text_len = self.text.len(); 759 757 760 758 for candidate in candidates { 761 - use crate::api::app_bsky::richtext::facet::{ByteSlice, Facet}; 759 + use crate::api::app_bsky::richtext::facet::{ 760 + ByteSlice, FacetFeaturesItem, Link, Mention, Tag, 761 + }; 762 + use crate::types::uri::Uri; 762 763 763 764 let (range, feature) = match candidate { 764 765 FacetCandidate::MarkdownLink { display_range, url } => { 765 766 // MarkdownLink stores URL directly, use display_range for index 766 767 767 - 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 - ); 768 + let feature = FacetFeaturesItem::Link(Box::new(Link { 769 + uri: Uri::new_owned(&url)?, 770 + extra_data: BTreeMap::new(), 771 + })); 773 772 (display_range, feature) 774 773 } 775 774 FacetCandidate::Mention { range, did } => { ··· 784 783 RichTextError::HandleNeedsResolution(handle.to_string()) 785 784 })?; 786 785 787 - 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 - ); 786 + let feature = FacetFeaturesItem::Mention(Box::new(Mention { 787 + did, 788 + extra_data: BTreeMap::new(), 789 + })); 793 790 (range, feature) 794 791 } 795 792 FacetCandidate::Link { range } => { ··· 809 806 url = format!("https://{}", url); 810 807 } 811 808 812 - 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 - ); 809 + let feature = FacetFeaturesItem::Link(Box::new(Link { 810 + uri: Uri::new_owned(&url)?, 811 + extra_data: BTreeMap::new(), 812 + })); 818 813 (range, feature) 819 814 } 820 815 FacetCandidate::Tag { range } => { ··· 835 830 .trim_start_matches('#') 836 831 .trim_start_matches('#'); 837 832 838 - 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 - ); 833 + let feature = FacetFeaturesItem::Tag(Box::new(Tag { 834 + tag: CowStr::from(tag.to_smolstr()), 835 + extra_data: BTreeMap::new(), 836 + })); 844 837 (range, feature) 845 838 } 846 839 }; ··· 884 877 /// Build richtext, resolving handles to DIDs using the provided resolver 885 878 pub async fn build_async<R>(self, resolver: &R) -> Result<RichText<'static>, RichTextError> 886 879 where 887 - R: jacquard_identity::resolver::IdentityResolver + Sync, 880 + R: IdentityResolver + Sync, 888 881 { 889 882 use crate::api::app_bsky::richtext::facet::{ 890 883 ByteSlice, FacetFeaturesItem, Link, Mention, Tag, ··· 1040 1033 client: &C, 1041 1034 ) -> Result<(RichText<'static>, Option<Vec<EmbedCandidate<'static>>>), RichTextError> 1042 1035 where 1043 - C: jacquard_common::http_client::HttpClient 1044 - + jacquard_identity::resolver::IdentityResolver 1045 - + Sync, 1036 + C: HttpClient + IdentityResolver + Sync, 1046 1037 { 1047 1038 // Extract embed candidates 1048 1039 let embed_candidates = self.embed_candidates.take().unwrap_or_default(); ··· 1096 1087 url: &str, 1097 1088 ) -> Result<Option<ExternalMetadata<'static>>, Box<dyn std::error::Error + Send + Sync>> 1098 1089 where 1099 - C: jacquard_common::http_client::HttpClient, 1090 + C: HttpClient, 1100 1091 { 1101 1092 // Build HTTP GET request 1102 1093 let request = http::Request::builder()
+18 -5
examples/create_post.rs
··· 4 4 use jacquard::client::{Agent, AgentSessionExt, FileAuthStore}; 5 5 use jacquard::oauth::client::OAuthClient; 6 6 use jacquard::oauth::loopback::LoopbackConfig; 7 + use jacquard::richtext::RichText; 7 8 use jacquard::types::string::Datetime; 8 9 9 10 #[derive(Parser, Debug)] 10 - #[command(author, version, about = "Create a simple post")] 11 + #[command(author, version, about = "Create a post with automatic facet detection")] 11 12 struct Args { 12 13 /// Handle (e.g., alice.bsky.social), DID, or PDS URL 13 14 input: CowStr<'static>, 14 15 15 - /// Post text 16 + /// Post text (can include @mentions, #hashtags, URLs, and [markdown](links)) 16 17 #[arg(short, long)] 17 18 text: String, 18 19 ··· 32 33 33 34 let agent: Agent<_> = Agent::from(session); 34 35 35 - // Create a simple text post using the Agent convenience method 36 + // 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 36 49 let post = Post { 37 - text: CowStr::from(args.text), 50 + text: richtext.text, 51 + facets: richtext.facets, 38 52 created_at: Datetime::now(), 39 53 embed: None, 40 54 entities: None, 41 - facets: None, 42 55 labels: None, 43 56 langs: None, 44 57 reply: None,