A better Rust ATProto crate
103
fork

Configure Feed

Select the types of activity you want to include in your feed.

okay this is going to be 0.7.0 release

Orual 7f032314 c251e98b

+269 -123
+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,