From 7f032314c3e8a3fc3a0ab5d9ddc01eff74d95b35 Mon Sep 17 00:00:00 2001 From: Orual Date: Sun, 19 Oct 2025 17:25:11 -0400 Subject: [PATCH] okay this is going to be 0.7.0 release --- CHANGELOG.md | 38 ++++++++ Cargo.lock | 28 +++--- Cargo.toml | 2 +- crates/jacquard-api/Cargo.toml | 8 +- crates/jacquard-axum/Cargo.toml | 8 +- crates/jacquard-derive/Cargo.toml | 2 +- crates/jacquard-identity/Cargo.toml | 4 +- crates/jacquard-lexicon/Cargo.toml | 6 +- crates/jacquard-oauth/Cargo.toml | 4 +- crates/jacquard/Cargo.toml | 10 +- crates/jacquard/src/moderation.rs | 33 ++++--- crates/jacquard/src/moderation/fetch.rs | 107 +++++++++++++++++++--- crates/jacquard/src/moderation/labeled.rs | 16 ++++ crates/jacquard/src/richtext.rs | 103 ++++++++++----------- examples/create_post.rs | 23 ++++- 15 files changed, 269 insertions(+), 123 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 886bbf759..6e032533c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,43 @@ # Changelog +## [0.7.0] - 2025-10-19 + +### Added + +**Bluesky-style rich text utilities** (`jacquard`) +- Rich text parsing with automatic facet detection (mentions, links, hashtags) +- Compatible with Bluesky, with the addition of support for markdown-style links (`[display](url)` syntax) +- Embed candidate detection from URLs and at-URIs + - Record embeds (posts, lists, starter packs, feeds) + - External embeds with optional OpenGraph metadata fetching +- Configurable embed domains for at-URI extraction (default: bsky.app, deer.social, blacksky.community, catsky.social) +- Overlap detection and validation for facet byte ranges + +**Moderation/labeling client utilities** (`jacquard`) +- Trait-based content moderation with `Labeled` and `Moderateable` traits +- Generic moderation decision making via `moderate()` and `moderate_all()` +- User preference handling (`ModerationPrefs`) with global and per-labeler overrides +- `ModerationIterExt` trait for filtering/mapping moderation over iterators +- `Labeled` implementations for Bluesky types (PostView, ProfileView, ListView, Generator, Notification, etc.) +- `Labeled` implementations for community lexicons (net.anisota, social.grain) +- `fetch_labels()` and `fetch_labeled_record()` helpers for retrieving labels via XRPC +- `fetch_labeler_defs()` and `fetch_labeler_defs_direct()` for fetching labeler definitions + +**Subscription control** (`jacquard-common`) +- `SubscriptionControlMessage` trait for dynamic subscription configuration +- `SubscriptionController` for sending control messages to active WebSocket subscriptions +- Enables runtime reconfiguration of subscriptions (e.g., Jetstream filtering) + +**Lexicons** (`jacquard-api`) +- teal.fm alpha lexicons for music sharing (fm.teal.alpha.*) + - Actor profiles with music service status + - Feed generation from play history + - Statistics endpoints (top artists, top releases, user stats) + +**Examples** +- Updated `create_post.rs` to demonstrate richtext parsing with automatic facet detection + + ## [0.6.0] - 2025-10-18 ### Added diff --git a/Cargo.lock b/Cargo.lock index 3fd9c884d..3345b7749 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2242,7 +2242,7 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "jacquard" -version = "0.6.0" +version = "0.6.1" dependencies = [ "bon", "bytes", @@ -2251,9 +2251,9 @@ dependencies = [ "getrandom 0.2.16", "http", "image", - "jacquard-api 0.6.1", + "jacquard-api 0.6.2", "jacquard-common 0.6.0", - "jacquard-derive 0.6.0", + "jacquard-derive 0.6.1", "jacquard-identity 0.6.0", "jacquard-oauth", "jose-jwk", @@ -2287,7 +2287,7 @@ dependencies = [ "bon", "bytes", "jacquard-common 0.6.0 (git+https://tangled.org/@nonbinary.computer/jacquard)", - "jacquard-derive 0.6.0 (git+https://tangled.org/@nonbinary.computer/jacquard)", + "jacquard-derive 0.6.0", "miette", "serde", "serde_ipld_dagcbor", @@ -2296,12 +2296,12 @@ dependencies = [ [[package]] name = "jacquard-api" -version = "0.6.1" +version = "0.6.2" dependencies = [ "bon", "bytes", "jacquard-common 0.6.0", - "jacquard-derive 0.6.0", + "jacquard-derive 0.6.1", "miette", "serde", "serde_ipld_dagcbor", @@ -2320,7 +2320,7 @@ dependencies = [ "chrono", "jacquard", "jacquard-common 0.6.0", - "jacquard-derive 0.6.0", + "jacquard-derive 0.6.1", "jacquard-identity 0.6.0", "k256", "miette", @@ -2423,22 +2423,22 @@ dependencies = [ [[package]] name = "jacquard-derive" version = "0.6.0" +source = "git+https://tangled.org/@nonbinary.computer/jacquard#861d9e86c582939ed1d50201954ef1368a91f9b7" dependencies = [ - "jacquard-common 0.6.0", "proc-macro2", "quote", - "serde", - "serde_json", "syn 2.0.106", ] [[package]] name = "jacquard-derive" -version = "0.6.0" -source = "git+https://tangled.org/@nonbinary.computer/jacquard#861d9e86c582939ed1d50201954ef1368a91f9b7" +version = "0.6.1" dependencies = [ + "jacquard-common 0.6.0", "proc-macro2", "quote", + "serde", + "serde_json", "syn 2.0.106", ] @@ -2450,7 +2450,7 @@ dependencies = [ "bytes", "hickory-resolver", "http", - "jacquard-api 0.6.1", + "jacquard-api 0.6.2", "jacquard-common 0.6.0", "miette", "n0-future", @@ -2492,7 +2492,7 @@ dependencies = [ [[package]] name = "jacquard-lexicon" -version = "0.6.0" +version = "0.6.1" dependencies = [ "async-trait", "clap", diff --git a/Cargo.toml b/Cargo.toml index afcda5531..1e527262f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ members = ["crates/*"] [workspace.package] edition = "2024" -version = "0.6.0" +version = "0.7.0" authors = ["Orual "] #repository = "https://github.com/rsform/jacquard" repository = "https://tangled.org/@nonbinary.computer/jacquard" diff --git a/crates/jacquard-api/Cargo.toml b/crates/jacquard-api/Cargo.toml index 8b1a07ea3..0e2afcfdc 100644 --- a/crates/jacquard-api/Cargo.toml +++ b/crates/jacquard-api/Cargo.toml @@ -2,7 +2,7 @@ name = "jacquard-api" description = "Generated AT Protocol API bindings for Jacquard" edition.workspace = true -version = "0.6.1" +version = "0.7.0" authors.workspace = true repository.workspace = true keywords.workspace = true @@ -12,13 +12,13 @@ exclude.workspace = true license.workspace = true [package.metadata.docs.rs] -features = [ "bluesky", "other", "lexicon_community", "ufos", "streaming" ] +features = [ "bluesky", "other", "lexicon_community", "streaming" ] [dependencies] bon.workspace = true bytes = { workspace = true, features = ["serde"] } -jacquard-common = { version = "0.6", path = "../jacquard-common" } -jacquard-derive = { version = "0.6", path = "../jacquard-derive" } +jacquard-common = { version = "0.7", path = "../jacquard-common" } +jacquard-derive = { version = "0.7", path = "../jacquard-derive" } miette.workspace = true serde.workspace = true serde_ipld_dagcbor.workspace = true diff --git a/crates/jacquard-axum/Cargo.toml b/crates/jacquard-axum/Cargo.toml index ea5b623aa..34e96d320 100644 --- a/crates/jacquard-axum/Cargo.toml +++ b/crates/jacquard-axum/Cargo.toml @@ -22,10 +22,10 @@ path = "../../examples/axum_server.rs" [dependencies] axum = "0.8.6" bytes.workspace = true -jacquard = { version = "0.6", path = "../jacquard", default-features = false, features = ["api"] } -jacquard-common = { version = "0.6", path = "../jacquard-common", features = ["reqwest-client"] } -jacquard-derive = { version = "0.6", path = "../jacquard-derive" } -jacquard-identity = { version = "0.6", path = "../jacquard-identity", optional = true } +jacquard = { version = "0.7", path = "../jacquard", default-features = false, features = ["api"] } +jacquard-common = { version = "0.7", path = "../jacquard-common", features = ["reqwest-client"] } +jacquard-derive = { version = "0.7", path = "../jacquard-derive" } +jacquard-identity = { version = "0.7", path = "../jacquard-identity", optional = true } miette.workspace = true multibase = { version = "0.9.1", optional = true } serde.workspace = true diff --git a/crates/jacquard-derive/Cargo.toml b/crates/jacquard-derive/Cargo.toml index 09cdc4437..8575de333 100644 --- a/crates/jacquard-derive/Cargo.toml +++ b/crates/jacquard-derive/Cargo.toml @@ -20,6 +20,6 @@ quote.workspace = true syn.workspace = true [dev-dependencies] -jacquard-common = { version = "0.6", path = "../jacquard-common" } +jacquard-common = { version = "0.7", path = "../jacquard-common" } serde.workspace = true serde_json.workspace = true diff --git a/crates/jacquard-identity/Cargo.toml b/crates/jacquard-identity/Cargo.toml index 4935ada26..17face8ed 100644 --- a/crates/jacquard-identity/Cargo.toml +++ b/crates/jacquard-identity/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "jacquard-identity" edition.workspace = true -version = "0.6.0" +version = "0.7.0" authors.workspace = true repository.workspace = true keywords.workspace = true @@ -21,7 +21,7 @@ streaming = ["jacquard-common/streaming", "dep:n0-future"] trait-variant.workspace = true bon.workspace = true bytes.workspace = true -jacquard-common = { version = "0.6", path = "../jacquard-common", features = ["reqwest-client"] } +jacquard-common = { version = "0.7", path = "../jacquard-common", features = ["reqwest-client"] } jacquard-api = { version = "0.6", path = "../jacquard-api", default-features = false, features = ["minimal"] } percent-encoding.workspace = true reqwest.workspace = true diff --git a/crates/jacquard-lexicon/Cargo.toml b/crates/jacquard-lexicon/Cargo.toml index fd90de212..2b7859e91 100644 --- a/crates/jacquard-lexicon/Cargo.toml +++ b/crates/jacquard-lexicon/Cargo.toml @@ -25,9 +25,9 @@ clap.workspace = true glob = "0.3" heck.workspace = true #itertools.workspace = true -jacquard-api = { version = "0.6", git = "https://tangled.org/@nonbinary.computer/jacquard" } -jacquard-common = { version = "0.6", features = [ "reqwest-client" ], git = "https://tangled.org/@nonbinary.computer/jacquard" } -jacquard-identity = { version = "0.6", git = "https://tangled.org/@nonbinary.computer/jacquard" } +jacquard-api = { version = "0.7", git = "https://tangled.org/@nonbinary.computer/jacquard" } +jacquard-common = { version = "0.7", features = [ "reqwest-client" ], git = "https://tangled.org/@nonbinary.computer/jacquard" } +jacquard-identity = { version = "0.7", git = "https://tangled.org/@nonbinary.computer/jacquard" } kdl = "6" miette = { workspace = true, features = ["fancy"] } prettyplease.workspace = true diff --git a/crates/jacquard-oauth/Cargo.toml b/crates/jacquard-oauth/Cargo.toml index 70b7a22c8..e3092498a 100644 --- a/crates/jacquard-oauth/Cargo.toml +++ b/crates/jacquard-oauth/Cargo.toml @@ -21,8 +21,8 @@ websocket = ["jacquard-common/websocket"] streaming = ["jacquard-common/streaming", "dep:n0-future"] [dependencies] -jacquard-common = { version = "0.6", path = "../jacquard-common", features = ["reqwest-client"] } -jacquard-identity = { version = "0.6", path = "../jacquard-identity" } +jacquard-common = { version = "0.7", path = "../jacquard-common", features = ["reqwest-client"] } +jacquard-identity = { version = "0.7", path = "../jacquard-identity" } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } url = { workspace = true } diff --git a/crates/jacquard/Cargo.toml b/crates/jacquard/Cargo.toml index abddacb91..9ff98a821 100644 --- a/crates/jacquard/Cargo.toml +++ b/crates/jacquard/Cargo.toml @@ -122,13 +122,13 @@ required-features = ["streaming"] [dependencies] -jacquard-api = { version = "0.6", path = "../jacquard-api" } -jacquard-common = { version = "0.6", path = "../jacquard-common", features = [ +jacquard-api = { version = "0.7", path = "../jacquard-api" } +jacquard-common = { version = "0.7", path = "../jacquard-common", features = [ "reqwest-client", ] } -jacquard-oauth = { version = "0.6", path = "../jacquard-oauth" } -jacquard-derive = { version = "0.6", path = "../jacquard-derive", optional = true } -jacquard-identity = { version = "0.6", path = "../jacquard-identity" } +jacquard-oauth = { version = "0.7", path = "../jacquard-oauth" } +jacquard-derive = { version = "0.7", path = "../jacquard-derive", optional = true } +jacquard-identity = { version = "0.7", path = "../jacquard-identity" } bon.workspace = true trait-variant.workspace = true diff --git a/crates/jacquard/src/moderation.rs b/crates/jacquard/src/moderation.rs index 8a2a31750..2add17efe 100644 --- a/crates/jacquard/src/moderation.rs +++ b/crates/jacquard/src/moderation.rs @@ -1,15 +1,24 @@ -//! Moderation decision making for AT Protocol content +//! Moderation //! -//! This module provides protocol-agnostic moderation logic for applying label-based -//! content filtering. It takes labels from various sources (labeler services, self-labels) -//! and user preferences to produce moderation decisions. +//! This is an attempt to semi-generalize the Bluesky moderation system. It avoids +//! depending on their lexicons as much as reasonably possible. This works via a +//! trait, [`Labeled`], which represents things that have labels for moderation +//! applied to them. This way the moderation application functions can operate +//! primarily via the trait, and are thus generic over lexicon types, and are +//! easy to use with your own types. //! -//! # Core Concepts +//! For more complex types which might have labels applied to components, +//! there is the [`Moderateable`] trait. A mostly complete implementation for +//! `FeedViewPost` is available for reference. The trait method outputs a `Vec` +//! of tuples, where the first element is a string tag and the second is the +//! moderation decision for the tagged element. This lets application developers +//! change behaviour based on what part of the content got a label. The functions +//! mostly match Bluesky behaviour (respecting "!hide", and such) by default. +//! +//! I've taken the time to go through the generated API bindings and implement +//! the [`Labeled`] trait for a number of types. It's a fairly easy trait to +//! implement, just not really automatable. //! -//! - **Labels**: Metadata tags applied to content by labelers or authors (see [`Label`](jacquard_api::com_atproto::label::Label)) -//! - **Preferences**: User-configured responses to specific label values (hide, warn, ignore) -//! - **Definitions**: Labeler-provided metadata about what labels mean and how they should be displayed -//! - **Decisions**: The output of moderation logic indicating what actions to take //! //! # Example //! @@ -27,7 +36,7 @@ //! ``` mod decision; -#[cfg(feature = "api_bluesky")] +#[cfg(feature = "api")] mod fetch; mod labeled; mod moderatable; @@ -37,9 +46,11 @@ mod types; mod tests; pub use decision::{ModerationIterExt, moderate, moderate_all}; +#[cfg(feature = "api")] +pub use fetch::{fetch_labeled_record, fetch_labels}; #[cfg(feature = "api_bluesky")] pub use fetch::{fetch_labeler_defs, fetch_labeler_defs_direct}; -pub use labeled::Labeled; +pub use labeled::{Labeled, LabeledRecord}; pub use moderatable::Moderateable; pub use types::{ Blur, LabelCause, LabelPref, LabelTarget, LabelerDefs, ModerationDecision, ModerationPrefs, diff --git a/crates/jacquard/src/moderation/fetch.rs b/crates/jacquard/src/moderation/fetch.rs index ad3023574..d76690dd3 100644 --- a/crates/jacquard/src/moderation/fetch.rs +++ b/crates/jacquard/src/moderation/fetch.rs @@ -1,13 +1,24 @@ use super::LabelerDefs; -use crate::client::AgentSessionExt; -use jacquard_api::app_bsky::labeler::get_services::{GetServices, GetServicesOutput}; -use jacquard_api::app_bsky::labeler::service::Service; -use jacquard_common::IntoStatic; -use jacquard_common::error::ClientError; +use crate::client::{AgentError, AgentSessionExt, CollectionErr, CollectionOutput}; +use crate::moderation::labeled::LabeledRecord; + +#[cfg(feature = "api_bluesky")] +use jacquard_api::app_bsky::labeler::{ + get_services::{GetServices, GetServicesOutput}, + service::Service, +}; +use jacquard_api::com_atproto::label::{Label, query_labels::QueryLabels}; +use jacquard_common::cowstr::ToCowStr; +use jacquard_common::error::{ClientError, TransportError}; +use jacquard_common::types::collection::Collection; use jacquard_common::types::string::Did; -use jacquard_common::xrpc::{XrpcClient, XrpcError}; +use jacquard_common::types::uri::RecordUri; +use jacquard_common::xrpc::{XrpcClient, XrpcError, XrpcResp}; +use jacquard_common::{CowStr, IntoStatic}; +use std::convert::From; /// Fetch labeler definitions from Bluesky's AppView (or a compatible one) +#[cfg(feature = "api_bluesky")] pub async fn fetch_labeler_defs( client: &(impl XrpcClient + Sync), dids: Vec>, @@ -20,13 +31,13 @@ pub async fn fetch_labeler_defs( let response = client.send(request).await?; let output: GetServicesOutput<'static> = response.into_output().map_err(|e| match e { XrpcError::Auth(auth) => ClientError::Auth(auth), - XrpcError::Generic(g) => ClientError::Transport( - jacquard_common::error::TransportError::Other(g.to_string().into()), - ), + XrpcError::Generic(g) => { + ClientError::Transport(TransportError::Other(g.to_string().into())) + } XrpcError::Decode(e) => ClientError::Decode(e), - XrpcError::Xrpc(typed) => ClientError::Transport( - jacquard_common::error::TransportError::Other(format!("{:?}", typed).into()), - ), + XrpcError::Xrpc(typed) => { + ClientError::Transport(TransportError::Other(format!("{:?}", typed).into())) + } })?; let mut defs = LabelerDefs::new(); @@ -61,6 +72,12 @@ pub async fn fetch_labeler_defs( /// This fetches the `app.bsky.labeler.service` record directly from the PDS where /// the labeler is hosted. /// +/// This is much less efficient for the client than querying the AppView, but has +/// the virtue of working without the Bluesky AppView or a compatible one. Other +/// alternatives include querying for definitions +/// created relatively recently, or doing your own scraping and indexing beforehand. +/// +#[cfg(feature = "api_bluesky")] pub async fn fetch_labeler_defs_direct( client: &(impl AgentSessionExt + Sync), dids: Vec>, @@ -73,9 +90,7 @@ pub async fn fetch_labeler_defs_direct( for did in dids { let uri = format!("at://{}/app.bsky.labeler.service/self", did.as_str()); let record_uri = Service::uri(uri).map_err(|e| { - ClientError::Transport(jacquard_common::error::TransportError::Other( - format!("Invalid URI: {}", e).into(), - )) + ClientError::Transport(TransportError::Other(format!("Invalid URI: {}", e).into())) })?; let output = client.fetch_record(&record_uri).await?; @@ -88,3 +103,65 @@ pub async fn fetch_labeler_defs_direct( Ok(defs) } + +/// Convenient wrapper for com.atproto.label.queryLabels +/// +/// Avoids depending on the Bluesky namespace, though it may call out to the +/// Bluesky AppView (or a compatible one configured via atproto-proxy header). +/// +/// Fetches labels directly for a given set of URI patterns. +/// This one defaults to the max number, assuming that you will be fetching +/// in bulk. This is not especially efficient and mostly exists as a demonstration. +/// +/// 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) +/// on labelers to tail their output, and index them alongside the data your app cares about. +pub async fn fetch_labels( + client: &impl AgentSessionExt, + uri_patterns: Vec>, + sources: Vec>, + cursor: Option>, +) -> Result<(Vec>, Option>), AgentError> { + #[cfg(feature = "tracing")] + let _span = tracing::debug_span!("fetch_labels", count = sources.len()).entered(); + + let request = QueryLabels::new() + .maybe_cursor(cursor) + .limit(250) + .uri_patterns(uri_patterns) + .sources(sources) + .build(); + let labels = client + .send(request) + .await? + .into_output() + .map_err(|e| match e { + XrpcError::Generic(e) => AgentError::Generic(e), + _ => unimplemented!(), // We know the error at this point is always GenericXrpcError + })?; + Ok((labels.labels, labels.cursor)) +} + +/// Minimal helper to fetch a URI and any labels. +/// +/// This is *extremely* inefficient and should not be used except in experimentation. +/// It primarily exists as a demonstration that you can hydrate labels without +/// using any Bluesky appview methods. +/// +/// 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) +/// on labelers to tail their output, and index them alongside the data your app cares about. +pub async fn fetch_labeled_record( + client: &impl AgentSessionExt, + record_uri: &RecordUri<'_, R>, + sources: Vec>, +) -> Result, AgentError> +where + R: Collection + From>, + for<'a> CollectionOutput<'a, R>: IntoStatic>, + for<'a> CollectionErr<'a, R>: IntoStatic>, +{ + let record: R = client.fetch_record(record_uri).await?.into(); + let (labels, _) = + fetch_labels(client, vec![record_uri.as_uri().to_cowstr()], sources, None).await?; + + Ok(LabeledRecord { record, labels }) +} diff --git a/crates/jacquard/src/moderation/labeled.rs b/crates/jacquard/src/moderation/labeled.rs index 54a70d1d3..1f270ae7e 100644 --- a/crates/jacquard/src/moderation/labeled.rs +++ b/crates/jacquard/src/moderation/labeled.rs @@ -14,6 +14,22 @@ pub trait Labeled<'a> { } } +/// Record with applied labels +/// +/// Exists as a bare minimum RecordView type primarily for testing/demonstration. +pub struct LabeledRecord<'a, C> { + /// The record we grabbed labels for + pub record: C, + /// The labels applied to the record + pub labels: Vec>, +} + +impl<'a, C> Labeled<'a> for LabeledRecord<'a, C> { + fn labels(&self) -> &[Label<'a>] { + &self.labels + } +} + // Implementations for common Bluesky types #[cfg(feature = "api_bluesky")] mod bluesky_impls { diff --git a/crates/jacquard/src/richtext.rs b/crates/jacquard/src/richtext.rs index 6ac499ce2..9ae4f8125 100644 --- a/crates/jacquard/src/richtext.rs +++ b/crates/jacquard/src/richtext.rs @@ -5,10 +5,21 @@ #[cfg(feature = "api_bluesky")] use crate::api::app_bsky::richtext::facet::Facet; +#[cfg(feature = "api_bluesky")] +use crate::api::com_atproto::repo::strong_ref::StrongRef; use crate::common::CowStr; +#[cfg(feature = "api_bluesky")] +use crate::types::aturi::AtUri; use jacquard_common::IntoStatic; +#[cfg(feature = "api_bluesky")] +use jacquard_common::http_client::HttpClient; use jacquard_common::types::did::{DID_REGEX, Did}; use jacquard_common::types::handle::HANDLE_REGEX; +use jacquard_common::types::string::AtStrError; +use jacquard_common::types::uri::UriParseError; +use jacquard_identity::resolver::IdentityError; +#[cfg(feature = "api_bluesky")] +use jacquard_identity::resolver::IdentityResolver; use regex::Regex; use std::marker::PhantomData; use std::ops::Range; @@ -101,9 +112,9 @@ pub enum EmbedCandidate<'a> { /// Bluesky record (post, list, starterpack, feed) Record { /// The at:// URI identifying the record - at_uri: crate::types::aturi::AtUri<'a>, + at_uri: AtUri<'a>, /// Strong reference (repo + CID) if resolved - strong_ref: Option>, + strong_ref: Option>, }, /// External link embed External { @@ -221,7 +232,7 @@ fn sanitize_text(text: &str) -> String { /// Entry point for parsing text with automatic facet detection /// -/// Uses default embed domains (bsky.app, deer.social) for at-URI extraction. +/// Uses default embed domains (bsky.app, deer.social, blacksky.community, catsky.social) for at-URI extraction. /// For custom domains, use [`parse_with_domains`]. pub fn parse(text: impl AsRef) -> RichTextBuilder { #[cfg(feature = "api_bluesky")] @@ -230,13 +241,13 @@ pub fn parse(text: impl AsRef) -> RichTextBuilder { } #[cfg(not(feature = "api_bluesky"))] { - parse_with_domains(text, &[]) + parse_with_domains(text) } } /// Parse text with custom embed domains for at-URI extraction /// -/// This allows specifying additional domains (beyond bsky.app and deer.social) +/// This allows specifying additional domains (beyond the defaults) /// that use the same URL patterns for records (e.g., /profile/{actor}/post/{rkey}). #[cfg(feature = "api_bluesky")] pub fn parse_with_domains( @@ -300,10 +311,7 @@ pub fn parse_with_domains( /// Parse text without embed detection (no api_bluesky feature) #[cfg(not(feature = "api_bluesky"))] -pub fn parse_with_domains( - text: impl AsRef, - _embed_domains: &[&str], -) -> RichTextBuilder { +pub fn parse_with_domains(text: impl AsRef) -> RichTextBuilder { // Step 0: Sanitize text (remove invisible chars, normalize newlines) let text = sanitize_text(text.as_ref()); @@ -378,7 +386,7 @@ impl RichTextBuilder { } /// Add a mention facet with a resolved DID (requires explicit range) - pub fn mention(mut self, did: &crate::types::did::Did<'_>, range: Range) -> Self { + pub fn mention(mut self, did: &Did<'_>, range: Range) -> Self { self.facet_candidates.push(FacetCandidate::Mention { range, did: Some(did.clone().into_static()), @@ -424,8 +432,8 @@ impl RichTextBuilder { /// Add a record embed candidate pub fn embed_record( mut self, - at_uri: crate::types::aturi::AtUri<'static>, - strong_ref: Option>, + at_uri: AtUri<'static>, + strong_ref: Option>, ) -> Self { self.embed_candidates .get_or_insert_with(Vec::new) @@ -607,8 +615,6 @@ fn detect_tags(text: &str) -> Vec { /// Classifies a URL or at-URI as an embed candidate #[cfg(feature = "api_bluesky")] fn classify_embed(url: &str, embed_domains: &[&str]) -> Option> { - use crate::types::aturi::AtUri; - // Check if it's an at:// URI if url.starts_with("at://") { if let Ok(at_uri) = AtUri::new(url) { @@ -650,12 +656,7 @@ fn classify_embed(url: &str, embed_domains: &[&str]) -> Option Option> { - use crate::types::aturi::AtUri; - +fn extract_at_uri_from_url(url: &str, embed_domains: &[&str]) -> Option> { // Parse URL let url_parsed = url::Url::parse(url).ok()?; @@ -693,11 +694,8 @@ fn extract_at_uri_from_url( AtUri::new(&at_uri_str).ok().map(|u| u.into_static()) } -use jacquard_common::types::string::AtStrError; -use thiserror::Error; - /// Errors that can occur during richtext building -#[derive(Debug, Error)] +#[derive(Debug, thiserror::Error, miette::Diagnostic)] pub enum RichTextError { /// Handle found that needs resolution but no resolver provided #[error("Handle '{0}' requires resolution - use build_async() with an IdentityResolver")] @@ -709,7 +707,7 @@ pub enum RichTextError { /// Identity resolution failed #[error("Failed to resolve identity")] - IdentityResolution(#[from] jacquard_identity::resolver::IdentityError), + IdentityResolution(#[from] IdentityError), /// Invalid byte range #[error("Invalid byte range {start}..{end} for text of length {text_len}")] @@ -728,7 +726,7 @@ pub enum RichTextError { /// Invalid URI #[error("Invalid URI")] - Uri(#[from] jacquard_common::types::uri::UriParseError), + Uri(#[from] UriParseError), } #[cfg(feature = "api_bluesky")] @@ -758,18 +756,19 @@ impl RichTextBuilder { let text_len = self.text.len(); for candidate in candidates { - use crate::api::app_bsky::richtext::facet::{ByteSlice, Facet}; + use crate::api::app_bsky::richtext::facet::{ + ByteSlice, FacetFeaturesItem, Link, Mention, Tag, + }; + use crate::types::uri::Uri; let (range, feature) = match candidate { FacetCandidate::MarkdownLink { display_range, url } => { // MarkdownLink stores URL directly, use display_range for index - let feature = crate::api::app_bsky::richtext::facet::FacetFeaturesItem::Link( - Box::new(crate::api::app_bsky::richtext::facet::Link { - uri: crate::types::uri::Uri::new_owned(&url)?, - extra_data: BTreeMap::new(), - }), - ); + let feature = FacetFeaturesItem::Link(Box::new(Link { + uri: Uri::new_owned(&url)?, + extra_data: BTreeMap::new(), + })); (display_range, feature) } FacetCandidate::Mention { range, did } => { @@ -784,12 +783,10 @@ impl RichTextBuilder { RichTextError::HandleNeedsResolution(handle.to_string()) })?; - let feature = crate::api::app_bsky::richtext::facet::FacetFeaturesItem::Mention( - Box::new(crate::api::app_bsky::richtext::facet::Mention { - did, - extra_data: BTreeMap::new(), - }), - ); + let feature = FacetFeaturesItem::Mention(Box::new(Mention { + did, + extra_data: BTreeMap::new(), + })); (range, feature) } FacetCandidate::Link { range } => { @@ -809,12 +806,10 @@ impl RichTextBuilder { url = format!("https://{}", url); } - let feature = crate::api::app_bsky::richtext::facet::FacetFeaturesItem::Link( - Box::new(crate::api::app_bsky::richtext::facet::Link { - uri: crate::types::uri::Uri::new_owned(&url)?, - extra_data: BTreeMap::new(), - }), - ); + let feature = FacetFeaturesItem::Link(Box::new(Link { + uri: Uri::new_owned(&url)?, + extra_data: BTreeMap::new(), + })); (range, feature) } FacetCandidate::Tag { range } => { @@ -835,12 +830,10 @@ impl RichTextBuilder { .trim_start_matches('#') .trim_start_matches('#'); - let feature = crate::api::app_bsky::richtext::facet::FacetFeaturesItem::Tag( - Box::new(crate::api::app_bsky::richtext::facet::Tag { - tag: CowStr::from(tag.to_smolstr()), - extra_data: BTreeMap::new(), - }), - ); + let feature = FacetFeaturesItem::Tag(Box::new(Tag { + tag: CowStr::from(tag.to_smolstr()), + extra_data: BTreeMap::new(), + })); (range, feature) } }; @@ -884,7 +877,7 @@ impl RichTextBuilder { /// Build richtext, resolving handles to DIDs using the provided resolver pub async fn build_async(self, resolver: &R) -> Result, RichTextError> where - R: jacquard_identity::resolver::IdentityResolver + Sync, + R: IdentityResolver + Sync, { use crate::api::app_bsky::richtext::facet::{ ByteSlice, FacetFeaturesItem, Link, Mention, Tag, @@ -1040,9 +1033,7 @@ impl RichTextBuilder { client: &C, ) -> Result<(RichText<'static>, Option>>), RichTextError> where - C: jacquard_common::http_client::HttpClient - + jacquard_identity::resolver::IdentityResolver - + Sync, + C: HttpClient + IdentityResolver + Sync, { // Extract embed candidates let embed_candidates = self.embed_candidates.take().unwrap_or_default(); @@ -1096,7 +1087,7 @@ async fn fetch_opengraph_metadata( url: &str, ) -> Result>, Box> where - C: jacquard_common::http_client::HttpClient, + C: HttpClient, { // Build HTTP GET request let request = http::Request::builder() diff --git a/examples/create_post.rs b/examples/create_post.rs index bd11206f3..89d9c1c33 100644 --- a/examples/create_post.rs +++ b/examples/create_post.rs @@ -4,15 +4,16 @@ use jacquard::api::app_bsky::feed::post::Post; use jacquard::client::{Agent, AgentSessionExt, FileAuthStore}; use jacquard::oauth::client::OAuthClient; use jacquard::oauth::loopback::LoopbackConfig; +use jacquard::richtext::RichText; use jacquard::types::string::Datetime; #[derive(Parser, Debug)] -#[command(author, version, about = "Create a simple post")] +#[command(author, version, about = "Create a post with automatic facet detection")] struct Args { /// Handle (e.g., alice.bsky.social), DID, or PDS URL input: CowStr<'static>, - /// Post text + /// Post text (can include @mentions, #hashtags, URLs, and [markdown](links)) #[arg(short, long)] text: String, @@ -32,13 +33,25 @@ async fn main() -> miette::Result<()> { let agent: Agent<_> = Agent::from(session); - // Create a simple text post using the Agent convenience method + // Parse richtext with automatic facet detection + // This detects @mentions, #hashtags, URLs, and [markdown](links) + let richtext = RichText::parse(&args.text).build_async(&agent).await?; + + println!("Detected {} facets:", richtext.facets.as_ref().map(|f| f.len()).unwrap_or(0)); + if let Some(facets) = &richtext.facets { + for facet in facets { + let text_slice = &richtext.text[facet.index.byte_start as usize..facet.index.byte_end as usize]; + println!(" - \"{}\" ({:?})", text_slice, facet.features); + } + } + + // Create post with parsed facets let post = Post { - text: CowStr::from(args.text), + text: richtext.text, + facets: richtext.facets, created_at: Datetime::now(), embed: None, entities: None, - facets: None, labels: None, langs: None, reply: None, -- 2.51.2