A better Rust ATProto crate

okay this is going to be 0.7.0 release

Orual 7f032314 c251e98b

+269 -123
+38
CHANGELOG.md
··· 1 # Changelog 2 3 ## [0.6.0] - 2025-10-18 4 5 ### Added
··· 1 # Changelog 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 + 41 ## [0.6.0] - 2025-10-18 42 43 ### Added
+14 -14
Cargo.lock
··· 2242 2243 [[package]] 2244 name = "jacquard" 2245 - version = "0.6.0" 2246 dependencies = [ 2247 "bon", 2248 "bytes", ··· 2251 "getrandom 0.2.16", 2252 "http", 2253 "image", 2254 - "jacquard-api 0.6.1", 2255 "jacquard-common 0.6.0", 2256 - "jacquard-derive 0.6.0", 2257 "jacquard-identity 0.6.0", 2258 "jacquard-oauth", 2259 "jose-jwk", ··· 2287 "bon", 2288 "bytes", 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)", 2291 "miette", 2292 "serde", 2293 "serde_ipld_dagcbor", ··· 2296 2297 [[package]] 2298 name = "jacquard-api" 2299 - version = "0.6.1" 2300 dependencies = [ 2301 "bon", 2302 "bytes", 2303 "jacquard-common 0.6.0", 2304 - "jacquard-derive 0.6.0", 2305 "miette", 2306 "serde", 2307 "serde_ipld_dagcbor", ··· 2320 "chrono", 2321 "jacquard", 2322 "jacquard-common 0.6.0", 2323 - "jacquard-derive 0.6.0", 2324 "jacquard-identity 0.6.0", 2325 "k256", 2326 "miette", ··· 2423 [[package]] 2424 name = "jacquard-derive" 2425 version = "0.6.0" 2426 dependencies = [ 2427 - "jacquard-common 0.6.0", 2428 "proc-macro2", 2429 "quote", 2430 - "serde", 2431 - "serde_json", 2432 "syn 2.0.106", 2433 ] 2434 2435 [[package]] 2436 name = "jacquard-derive" 2437 - version = "0.6.0" 2438 - source = "git+https://tangled.org/@nonbinary.computer/jacquard#861d9e86c582939ed1d50201954ef1368a91f9b7" 2439 dependencies = [ 2440 "proc-macro2", 2441 "quote", 2442 "syn 2.0.106", 2443 ] 2444 ··· 2450 "bytes", 2451 "hickory-resolver", 2452 "http", 2453 - "jacquard-api 0.6.1", 2454 "jacquard-common 0.6.0", 2455 "miette", 2456 "n0-future", ··· 2492 2493 [[package]] 2494 name = "jacquard-lexicon" 2495 - version = "0.6.0" 2496 dependencies = [ 2497 "async-trait", 2498 "clap",
··· 2242 2243 [[package]] 2244 name = "jacquard" 2245 + version = "0.6.1" 2246 dependencies = [ 2247 "bon", 2248 "bytes", ··· 2251 "getrandom 0.2.16", 2252 "http", 2253 "image", 2254 + "jacquard-api 0.6.2", 2255 "jacquard-common 0.6.0", 2256 + "jacquard-derive 0.6.1", 2257 "jacquard-identity 0.6.0", 2258 "jacquard-oauth", 2259 "jose-jwk", ··· 2287 "bon", 2288 "bytes", 2289 "jacquard-common 0.6.0 (git+https://tangled.org/@nonbinary.computer/jacquard)", 2290 + "jacquard-derive 0.6.0", 2291 "miette", 2292 "serde", 2293 "serde_ipld_dagcbor", ··· 2296 2297 [[package]] 2298 name = "jacquard-api" 2299 + version = "0.6.2" 2300 dependencies = [ 2301 "bon", 2302 "bytes", 2303 "jacquard-common 0.6.0", 2304 + "jacquard-derive 0.6.1", 2305 "miette", 2306 "serde", 2307 "serde_ipld_dagcbor", ··· 2320 "chrono", 2321 "jacquard", 2322 "jacquard-common 0.6.0", 2323 + "jacquard-derive 0.6.1", 2324 "jacquard-identity 0.6.0", 2325 "k256", 2326 "miette", ··· 2423 [[package]] 2424 name = "jacquard-derive" 2425 version = "0.6.0" 2426 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#861d9e86c582939ed1d50201954ef1368a91f9b7" 2427 dependencies = [ 2428 "proc-macro2", 2429 "quote", 2430 "syn 2.0.106", 2431 ] 2432 2433 [[package]] 2434 name = "jacquard-derive" 2435 + version = "0.6.1" 2436 dependencies = [ 2437 + "jacquard-common 0.6.0", 2438 "proc-macro2", 2439 "quote", 2440 + "serde", 2441 + "serde_json", 2442 "syn 2.0.106", 2443 ] 2444 ··· 2450 "bytes", 2451 "hickory-resolver", 2452 "http", 2453 + "jacquard-api 0.6.2", 2454 "jacquard-common 0.6.0", 2455 "miette", 2456 "n0-future", ··· 2492 2493 [[package]] 2494 name = "jacquard-lexicon" 2495 + version = "0.6.1" 2496 dependencies = [ 2497 "async-trait", 2498 "clap",
+1 -1
Cargo.toml
··· 5 6 [workspace.package] 7 edition = "2024" 8 - version = "0.6.0" 9 authors = ["Orual <orual@nonbinary.computer>"] 10 #repository = "https://github.com/rsform/jacquard" 11 repository = "https://tangled.org/@nonbinary.computer/jacquard"
··· 5 6 [workspace.package] 7 edition = "2024" 8 + version = "0.7.0" 9 authors = ["Orual <orual@nonbinary.computer>"] 10 #repository = "https://github.com/rsform/jacquard" 11 repository = "https://tangled.org/@nonbinary.computer/jacquard"
+4 -4
crates/jacquard-api/Cargo.toml
··· 2 name = "jacquard-api" 3 description = "Generated AT Protocol API bindings for Jacquard" 4 edition.workspace = true 5 - version = "0.6.1" 6 authors.workspace = true 7 repository.workspace = true 8 keywords.workspace = true ··· 12 license.workspace = true 13 14 [package.metadata.docs.rs] 15 - features = [ "bluesky", "other", "lexicon_community", "ufos", "streaming" ] 16 17 [dependencies] 18 bon.workspace = true 19 bytes = { workspace = true, features = ["serde"] } 20 - jacquard-common = { version = "0.6", path = "../jacquard-common" } 21 - jacquard-derive = { version = "0.6", path = "../jacquard-derive" } 22 miette.workspace = true 23 serde.workspace = true 24 serde_ipld_dagcbor.workspace = true
··· 2 name = "jacquard-api" 3 description = "Generated AT Protocol API bindings for Jacquard" 4 edition.workspace = true 5 + version = "0.7.0" 6 authors.workspace = true 7 repository.workspace = true 8 keywords.workspace = true ··· 12 license.workspace = true 13 14 [package.metadata.docs.rs] 15 + features = [ "bluesky", "other", "lexicon_community", "streaming" ] 16 17 [dependencies] 18 bon.workspace = true 19 bytes = { workspace = true, features = ["serde"] } 20 + jacquard-common = { version = "0.7", path = "../jacquard-common" } 21 + jacquard-derive = { version = "0.7", path = "../jacquard-derive" } 22 miette.workspace = true 23 serde.workspace = true 24 serde_ipld_dagcbor.workspace = true
+4 -4
crates/jacquard-axum/Cargo.toml
··· 22 [dependencies] 23 axum = "0.8.6" 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 } 29 miette.workspace = true 30 multibase = { version = "0.9.1", optional = true } 31 serde.workspace = true
··· 22 [dependencies] 23 axum = "0.8.6" 24 bytes.workspace = 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 miette.workspace = true 30 multibase = { version = "0.9.1", optional = true } 31 serde.workspace = true
+1 -1
crates/jacquard-derive/Cargo.toml
··· 20 syn.workspace = true 21 22 [dev-dependencies] 23 - jacquard-common = { version = "0.6", path = "../jacquard-common" } 24 serde.workspace = true 25 serde_json.workspace = true
··· 20 syn.workspace = true 21 22 [dev-dependencies] 23 + jacquard-common = { version = "0.7", path = "../jacquard-common" } 24 serde.workspace = true 25 serde_json.workspace = true
+2 -2
crates/jacquard-identity/Cargo.toml
··· 1 [package] 2 name = "jacquard-identity" 3 edition.workspace = true 4 - version = "0.6.0" 5 authors.workspace = true 6 repository.workspace = true 7 keywords.workspace = true ··· 21 trait-variant.workspace = true 22 bon.workspace = true 23 bytes.workspace = true 24 - jacquard-common = { version = "0.6", path = "../jacquard-common", features = ["reqwest-client"] } 25 jacquard-api = { version = "0.6", path = "../jacquard-api", default-features = false, features = ["minimal"] } 26 percent-encoding.workspace = true 27 reqwest.workspace = true
··· 1 [package] 2 name = "jacquard-identity" 3 edition.workspace = true 4 + version = "0.7.0" 5 authors.workspace = true 6 repository.workspace = true 7 keywords.workspace = true ··· 21 trait-variant.workspace = true 22 bon.workspace = true 23 bytes.workspace = true 24 + jacquard-common = { version = "0.7", path = "../jacquard-common", features = ["reqwest-client"] } 25 jacquard-api = { version = "0.6", path = "../jacquard-api", default-features = false, features = ["minimal"] } 26 percent-encoding.workspace = true 27 reqwest.workspace = true
+3 -3
crates/jacquard-lexicon/Cargo.toml
··· 25 glob = "0.3" 26 heck.workspace = true 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" } 31 kdl = "6" 32 miette = { workspace = true, features = ["fancy"] } 33 prettyplease.workspace = true
··· 25 glob = "0.3" 26 heck.workspace = true 27 #itertools.workspace = true 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 kdl = "6" 32 miette = { workspace = true, features = ["fancy"] } 33 prettyplease.workspace = true
+2 -2
crates/jacquard-oauth/Cargo.toml
··· 21 streaming = ["jacquard-common/streaming", "dep:n0-future"] 22 23 [dependencies] 24 - jacquard-common = { version = "0.6", path = "../jacquard-common", features = ["reqwest-client"] } 25 - jacquard-identity = { version = "0.6", path = "../jacquard-identity" } 26 serde = { workspace = true, features = ["derive"] } 27 serde_json = { workspace = true } 28 url = { workspace = true }
··· 21 streaming = ["jacquard-common/streaming", "dep:n0-future"] 22 23 [dependencies] 24 + jacquard-common = { version = "0.7", path = "../jacquard-common", features = ["reqwest-client"] } 25 + jacquard-identity = { version = "0.7", path = "../jacquard-identity" } 26 serde = { workspace = true, features = ["derive"] } 27 serde_json = { workspace = true } 28 url = { workspace = true }
+5 -5
crates/jacquard/Cargo.toml
··· 122 123 124 [dependencies] 125 - jacquard-api = { version = "0.6", path = "../jacquard-api" } 126 - jacquard-common = { version = "0.6", path = "../jacquard-common", features = [ 127 "reqwest-client", 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" } 132 133 bon.workspace = true 134 trait-variant.workspace = true
··· 122 123 124 [dependencies] 125 + jacquard-api = { version = "0.7", path = "../jacquard-api" } 126 + jacquard-common = { version = "0.7", path = "../jacquard-common", features = [ 127 "reqwest-client", 128 ] } 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 133 bon.workspace = true 134 trait-variant.workspace = true
+22 -11
crates/jacquard/src/moderation.rs
··· 1 - //! Moderation decision making for AT Protocol content 2 //! 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. 6 //! 7 - //! # Core Concepts 8 //! 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 //! ``` 28 29 mod decision; 30 - #[cfg(feature = "api_bluesky")] 31 mod fetch; 32 mod labeled; 33 mod moderatable; ··· 37 mod tests; 38 39 pub use decision::{ModerationIterExt, moderate, moderate_all}; 40 #[cfg(feature = "api_bluesky")] 41 pub use fetch::{fetch_labeler_defs, fetch_labeler_defs_direct}; 42 - pub use labeled::Labeled; 43 pub use moderatable::Moderateable; 44 pub 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 //! 22 //! 23 //! # Example 24 //! ··· 36 //! ``` 37 38 mod decision; 39 + #[cfg(feature = "api")] 40 mod fetch; 41 mod labeled; 42 mod moderatable; ··· 46 mod tests; 47 48 pub use decision::{ModerationIterExt, moderate, moderate_all}; 49 + #[cfg(feature = "api")] 50 + pub use fetch::{fetch_labeled_record, fetch_labels}; 51 #[cfg(feature = "api_bluesky")] 52 pub use fetch::{fetch_labeler_defs, fetch_labeler_defs_direct}; 53 + pub use labeled::{Labeled, LabeledRecord}; 54 pub use moderatable::Moderateable; 55 pub use types::{ 56 Blur, LabelCause, LabelPref, LabelTarget, LabelerDefs, ModerationDecision, ModerationPrefs,
+92 -15
crates/jacquard/src/moderation/fetch.rs
··· 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; 7 use jacquard_common::types::string::Did; 8 - use jacquard_common::xrpc::{XrpcClient, XrpcError}; 9 10 /// Fetch labeler definitions from Bluesky's AppView (or a compatible one) 11 pub 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 })?; 31 32 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 /// 64 pub 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 })?; 80 81 let output = client.fetch_record(&record_uri).await?; ··· 88 89 Ok(defs) 90 }
··· 1 use 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; 14 use 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; 19 20 /// Fetch labeler definitions from Bluesky's AppView (or a compatible one) 21 + #[cfg(feature = "api_bluesky")] 22 pub 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 })?; 42 43 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")] 81 pub 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())) 94 })?; 95 96 let output = client.fetch_record(&record_uri).await?; ··· 103 104 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 } 16 17 // Implementations for common Bluesky types 18 #[cfg(feature = "api_bluesky")] 19 mod bluesky_impls {
··· 14 } 15 } 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 + 33 // Implementations for common Bluesky types 34 #[cfg(feature = "api_bluesky")] 35 mod bluesky_impls {
+47 -56
crates/jacquard/src/richtext.rs
··· 5 6 #[cfg(feature = "api_bluesky")] 7 use crate::api::app_bsky::richtext::facet::Facet; 8 use crate::common::CowStr; 9 use jacquard_common::IntoStatic; 10 use jacquard_common::types::did::{DID_REGEX, Did}; 11 use jacquard_common::types::handle::HANDLE_REGEX; 12 use regex::Regex; 13 use std::marker::PhantomData; 14 use 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 { ··· 221 222 /// 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`]. 226 pub 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 } 236 237 /// 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")] 242 pub fn parse_with_domains( ··· 300 301 /// 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 } 379 380 /// 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")] 609 fn 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 } 695 696 - use jacquard_common::types::string::AtStrError; 697 - use thiserror::Error; 698 - 699 /// Errors that can occur during richtext building 700 - #[derive(Debug, Error)] 701 pub enum RichTextError { 702 /// Handle found that needs resolution but no resolver provided 703 #[error("Handle '{0}' requires resolution - use build_async() with an IdentityResolver")] ··· 709 710 /// Identity resolution failed 711 #[error("Failed to resolve identity")] 712 - IdentityResolution(#[from] jacquard_identity::resolver::IdentityError), 713 714 /// Invalid byte range 715 #[error("Invalid byte range {start}..{end} for text of length {text_len}")] ··· 728 729 /// Invalid URI 730 #[error("Invalid URI")] 731 - Uri(#[from] jacquard_common::types::uri::UriParseError), 732 } 733 734 #[cfg(feature = "api_bluesky")] ··· 758 let text_len = self.text.len(); 759 760 for candidate in candidates { 761 - use crate::api::app_bsky::richtext::facet::{ByteSlice, Facet}; 762 763 let (range, feature) = match candidate { 764 FacetCandidate::MarkdownLink { display_range, url } => { 765 // MarkdownLink stores URL directly, use display_range for index 766 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 - ); 773 (display_range, feature) 774 } 775 FacetCandidate::Mention { range, did } => { ··· 784 RichTextError::HandleNeedsResolution(handle.to_string()) 785 })?; 786 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 - ); 793 (range, feature) 794 } 795 FacetCandidate::Link { range } => { ··· 809 url = format!("https://{}", url); 810 } 811 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 - ); 818 (range, feature) 819 } 820 FacetCandidate::Tag { range } => { ··· 835 .trim_start_matches('#') 836 .trim_start_matches('#'); 837 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 - ); 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>> 1098 where 1099 - C: jacquard_common::http_client::HttpClient, 1100 { 1101 // Build HTTP GET request 1102 let request = http::Request::builder()
··· 5 6 #[cfg(feature = "api_bluesky")] 7 use crate::api::app_bsky::richtext::facet::Facet; 8 + #[cfg(feature = "api_bluesky")] 9 + use crate::api::com_atproto::repo::strong_ref::StrongRef; 10 use crate::common::CowStr; 11 + #[cfg(feature = "api_bluesky")] 12 + use crate::types::aturi::AtUri; 13 use jacquard_common::IntoStatic; 14 + #[cfg(feature = "api_bluesky")] 15 + use jacquard_common::http_client::HttpClient; 16 use jacquard_common::types::did::{DID_REGEX, Did}; 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; 23 use regex::Regex; 24 use std::marker::PhantomData; 25 use 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 { ··· 232 233 /// 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`]. 237 pub 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 } 247 248 /// 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")] 253 pub fn parse_with_domains( ··· 311 312 /// 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> { 315 // Step 0: Sanitize text (remove invisible chars, normalize newlines) 316 let text = sanitize_text(text.as_ref()); 317 ··· 386 } 387 388 /// 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")] 617 fn classify_embed(url: &str, embed_domains: &[&str]) -> Option<EmbedCandidate<'static>> { 618 // 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>> { 660 // 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 } 696 697 /// Errors that can occur during richtext building 698 + #[derive(Debug, thiserror::Error, miette::Diagnostic)] 699 pub enum RichTextError { 700 /// Handle found that needs resolution but no resolver provided 701 #[error("Handle '{0}' requires resolution - use build_async() with an IdentityResolver")] ··· 707 708 /// Identity resolution failed 709 #[error("Failed to resolve identity")] 710 + IdentityResolution(#[from] IdentityError), 711 712 /// Invalid byte range 713 #[error("Invalid byte range {start}..{end} for text of length {text_len}")] ··· 726 727 /// Invalid URI 728 #[error("Invalid URI")] 729 + Uri(#[from] UriParseError), 730 } 731 732 #[cfg(feature = "api_bluesky")] ··· 756 let text_len = self.text.len(); 757 758 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; 763 764 let (range, feature) = match candidate { 765 FacetCandidate::MarkdownLink { display_range, url } => { 766 // MarkdownLink stores URL directly, use display_range for index 767 768 + let feature = FacetFeaturesItem::Link(Box::new(Link { 769 + uri: Uri::new_owned(&url)?, 770 + extra_data: BTreeMap::new(), 771 + })); 772 (display_range, feature) 773 } 774 FacetCandidate::Mention { range, did } => { ··· 783 RichTextError::HandleNeedsResolution(handle.to_string()) 784 })?; 785 786 + let feature = FacetFeaturesItem::Mention(Box::new(Mention { 787 + did, 788 + extra_data: BTreeMap::new(), 789 + })); 790 (range, feature) 791 } 792 FacetCandidate::Link { range } => { ··· 806 url = format!("https://{}", url); 807 } 808 809 + let feature = FacetFeaturesItem::Link(Box::new(Link { 810 + uri: Uri::new_owned(&url)?, 811 + extra_data: BTreeMap::new(), 812 + })); 813 (range, feature) 814 } 815 FacetCandidate::Tag { range } => { ··· 830 .trim_start_matches('#') 831 .trim_start_matches('#'); 832 833 + let feature = FacetFeaturesItem::Tag(Box::new(Tag { 834 + tag: CowStr::from(tag.to_smolstr()), 835 + extra_data: BTreeMap::new(), 836 + })); 837 (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, 1037 { 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>> 1089 where 1090 + C: HttpClient, 1091 { 1092 // Build HTTP GET request 1093 let request = http::Request::builder()
+18 -5
examples/create_post.rs
··· 4 use jacquard::client::{Agent, AgentSessionExt, FileAuthStore}; 5 use jacquard::oauth::client::OAuthClient; 6 use jacquard::oauth::loopback::LoopbackConfig; 7 use jacquard::types::string::Datetime; 8 9 #[derive(Parser, Debug)] 10 - #[command(author, version, about = "Create a simple post")] 11 struct Args { 12 /// Handle (e.g., alice.bsky.social), DID, or PDS URL 13 input: CowStr<'static>, 14 15 - /// Post text 16 #[arg(short, long)] 17 text: String, 18 ··· 32 33 let agent: Agent<_> = Agent::from(session); 34 35 - // Create a simple text post using the Agent convenience method 36 let post = Post { 37 - text: CowStr::from(args.text), 38 created_at: Datetime::now(), 39 embed: None, 40 entities: None, 41 - facets: None, 42 labels: None, 43 langs: None, 44 reply: None,
··· 4 use jacquard::client::{Agent, AgentSessionExt, FileAuthStore}; 5 use jacquard::oauth::client::OAuthClient; 6 use jacquard::oauth::loopback::LoopbackConfig; 7 + use jacquard::richtext::RichText; 8 use jacquard::types::string::Datetime; 9 10 #[derive(Parser, Debug)] 11 + #[command(author, version, about = "Create a post with automatic facet detection")] 12 struct Args { 13 /// Handle (e.g., alice.bsky.social), DID, or PDS URL 14 input: CowStr<'static>, 15 16 + /// Post text (can include @mentions, #hashtags, URLs, and [markdown](links)) 17 #[arg(short, long)] 18 text: String, 19 ··· 33 34 let agent: Agent<_> = Agent::from(session); 35 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 49 let post = Post { 50 + text: richtext.text, 51 + facets: richtext.facets, 52 created_at: Datetime::now(), 53 embed: None, 54 entities: None, 55 labels: None, 56 langs: None, 57 reply: None,