···1+use jacquard_common::error::{AuthError, ClientError};
2+use jacquard_common::types::did::Did;
3+use jacquard_common::types::nsid::Nsid;
4+use jacquard_common::types::string::{RecordKey, Rkey};
5+use jacquard_common::xrpc::XrpcError;
6+use jacquard_common::{Data, IntoStatic};
7+use smol_str::SmolStr;
8+9+/// Boxed error type for wrapping arbitrary errors
10+pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
11+12+/// Error type for Agent convenience methods
13+#[derive(Debug, thiserror::Error, miette::Diagnostic)]
14+#[error("{kind}")]
15+pub struct AgentError {
16+ #[diagnostic_source]
17+ kind: AgentErrorKind,
18+ #[source]
19+ source: Option<BoxError>,
20+ #[help]
21+ help: Option<SmolStr>,
22+ context: Option<SmolStr>,
23+ url: Option<SmolStr>,
24+ details: Option<SmolStr>,
25+ location: Option<SmolStr>,
26+ xrpc: Option<Data<'static>>,
27+}
28+29+/// Error categories for Agent operations
30+#[derive(Debug, thiserror::Error, miette::Diagnostic)]
31+pub enum AgentErrorKind {
32+ /// Transport/network layer failure
33+ #[error("client error")]
34+ #[diagnostic(code(jacquard::agent::client))]
35+ Client,
36+37+ /// No session available for operations requiring authentication
38+ #[error("no session available")]
39+ #[diagnostic(
40+ code(jacquard::agent::no_session),
41+ help("ensure agent is authenticated before performing operations")
42+ )]
43+ NoSession,
44+45+ /// Authentication error from XRPC layer
46+ #[error("auth error: {0}")]
47+ #[diagnostic(code(jacquard::agent::auth))]
48+ Auth(AuthError),
49+50+ /// Record operation failed with typed error from endpoint
51+ #[error("record operation failed on {collection}/{rkey:?} in repo {repo}")]
52+ #[diagnostic(code(jacquard::agent::record_operation))]
53+ RecordOperation {
54+ /// The repository DID
55+ repo: Did<'static>,
56+ /// The collection NSID
57+ collection: Nsid<'static>,
58+ /// The record key
59+ rkey: RecordKey<Rkey<'static>>,
60+ },
61+62+ /// Multi-step operation failed at sub-step (e.g., get failed in update_record)
63+ #[error("operation failed at step '{step}'")]
64+ #[diagnostic(code(jacquard::agent::sub_operation))]
65+ SubOperation {
66+ /// Description of which step failed
67+ step: &'static str,
68+ },
69+ /// XRPC error
70+ #[error("xrpc error")]
71+ #[diagnostic(code(jacquard::agent::xrpc))]
72+ XrpcError,
73+}
74+75+impl AgentError {
76+ /// Create a new error with the given kind and optional source
77+ pub fn new(kind: AgentErrorKind, source: Option<BoxError>) -> Self {
78+ Self {
79+ kind,
80+ source,
81+ help: None,
82+ context: None,
83+ url: None,
84+ details: None,
85+ location: None,
86+ xrpc: None,
87+ }
88+ }
89+90+ /// Get the error kind
91+ pub fn kind(&self) -> &AgentErrorKind {
92+ &self.kind
93+ }
94+95+ /// Get the source error if present
96+ pub fn source_err(&self) -> Option<&BoxError> {
97+ self.source.as_ref()
98+ }
99+100+ /// Get the context string if present
101+ pub fn context(&self) -> Option<&str> {
102+ self.context.as_ref().map(|s| s.as_str())
103+ }
104+105+ /// Get the URL if present
106+ pub fn url(&self) -> Option<&str> {
107+ self.url.as_ref().map(|s| s.as_str())
108+ }
109+110+ /// Get the details if present
111+ pub fn details(&self) -> Option<&str> {
112+ self.details.as_ref().map(|s| s.as_str())
113+ }
114+115+ /// Get the location if present
116+ pub fn location(&self) -> Option<&str> {
117+ self.location.as_ref().map(|s| s.as_str())
118+ }
119+120+ /// Add help text to this error
121+ pub fn with_help(mut self, help: impl Into<SmolStr>) -> Self {
122+ self.help = Some(help.into());
123+ self
124+ }
125+126+ /// Add context to this error
127+ pub fn with_context(mut self, context: impl Into<SmolStr>) -> Self {
128+ self.context = Some(context.into());
129+ self
130+ }
131+132+ /// Add URL to this error
133+ pub fn with_url(mut self, url: impl Into<SmolStr>) -> Self {
134+ self.url = Some(url.into());
135+ self
136+ }
137+138+ /// Add details to this error
139+ pub fn with_details(mut self, details: impl Into<SmolStr>) -> Self {
140+ self.details = Some(details.into());
141+ self
142+ }
143+144+ /// Add location to this error
145+ pub fn with_location(mut self, location: impl Into<SmolStr>) -> Self {
146+ self.location = Some(location.into());
147+ self
148+ }
149+150+ /// Add XRPC error data to this error for observability
151+ pub fn with_xrpc<E>(mut self, xrpc: XrpcError<E>) -> Self
152+ where
153+ E: std::error::Error + jacquard_common::IntoStatic + serde::Serialize,
154+ {
155+ use jacquard_common::types::value::to_data;
156+ // Attempt to serialize XrpcError to Data for observability
157+ if let Ok(data) = to_data(&xrpc) {
158+ self.xrpc = Some(data.into_static());
159+ }
160+ self
161+ }
162+163+ /// Create an XRPC error with attached error data for observability
164+ pub fn xrpc<E>(error: XrpcError<E>) -> Self
165+ where
166+ E: std::error::Error + jacquard_common::IntoStatic + serde::Serialize + Send + Sync,
167+ <E as IntoStatic>::Output: IntoStatic + std::error::Error + Send + Sync,
168+ {
169+ use jacquard_common::types::value::to_data;
170+ // Attempt to serialize XrpcError to Data for observability
171+ if let Ok(data) = to_data(&error) {
172+ let mut error = Self::new(
173+ AgentErrorKind::XrpcError,
174+ Some(Box::new(error.into_static())),
175+ );
176+ error.xrpc = Some(data.into_static());
177+ error
178+ } else {
179+ Self::new(
180+ AgentErrorKind::XrpcError,
181+ Some(Box::new(error.into_static())),
182+ )
183+ }
184+ }
185+186+ // Constructors
187+188+ /// Create a no session error
189+ pub fn no_session() -> Self {
190+ Self::new(AgentErrorKind::NoSession, None)
191+ }
192+193+ /// Create a sub-operation error for multi-step operations
194+ pub fn sub_operation(
195+ step: &'static str,
196+ source: impl std::error::Error + Send + Sync + 'static,
197+ ) -> Self {
198+ Self::new(
199+ AgentErrorKind::SubOperation { step },
200+ Some(Box::new(source)),
201+ )
202+ }
203+204+ /// Create a record operation error
205+ pub fn record_operation(
206+ repo: Did<'static>,
207+ collection: Nsid<'static>,
208+ rkey: RecordKey<Rkey<'static>>,
209+ source: impl std::error::Error + Send + Sync + 'static,
210+ ) -> Self {
211+ Self::new(
212+ AgentErrorKind::RecordOperation {
213+ repo,
214+ collection,
215+ rkey,
216+ },
217+ Some(Box::new(source)),
218+ )
219+ }
220+221+ /// Create an authentication error
222+ pub fn auth(auth_error: AuthError) -> Self {
223+ Self::new(AgentErrorKind::Auth(auth_error), None)
224+ }
225+}
226+227+impl From<ClientError> for AgentError {
228+ fn from(e: ClientError) -> Self {
229+ Self::new(AgentErrorKind::Client, Some(Box::new(e)))
230+ }
231+}
232+233+impl From<AuthError> for AgentError {
234+ fn from(e: AuthError) -> Self {
235+ Self::new(AgentErrorKind::Auth(e), None)
236+ .with_help("check authentication credentials and session state")
237+ }
238+}
239+240+/// Result type for Agent operations
241+pub type Result<T> = core::result::Result<T, AgentError>;
242+243+impl IntoStatic for AgentError {
244+ type Output = AgentError;
245+246+ fn into_static(self) -> Self::Output {
247+ match self.kind {
248+ AgentErrorKind::RecordOperation {
249+ repo,
250+ collection,
251+ rkey,
252+ } => Self {
253+ kind: AgentErrorKind::RecordOperation {
254+ repo: repo.into_static(),
255+ collection: collection.into_static(),
256+ rkey: rkey.into_static(),
257+ },
258+ source: self.source,
259+ help: self.help,
260+ context: self.context,
261+ url: self.url,
262+ details: self.details,
263+ location: self.location,
264+ xrpc: self.xrpc,
265+ },
266+ AgentErrorKind::Auth(auth) => Self {
267+ kind: AgentErrorKind::Auth(auth.into_static()),
268+ source: self.source,
269+ help: self.help,
270+ context: self.context,
271+ url: self.url,
272+ details: self.details,
273+ location: self.location,
274+ xrpc: self.xrpc,
275+ },
276+ _ => self,
277+ }
278+ }
279+}
+3-3
crates/jacquard/src/moderation.rs
···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
···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//!
···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`][crate::moderation::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`][crate::moderation::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
···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`][crate::moderation::Labeled] trait for a number of types. It's a fairly easy trait to
20//! implement, just not really automatable.
21//!
22//!
+11-14
crates/jacquard/src/moderation/fetch.rs
···9};
10use jacquard_api::com_atproto::label::{Label, query_labels::QueryLabels};
11use jacquard_common::cowstr::ToCowStr;
12-use jacquard_common::error::{ClientError, TransportError};
13use jacquard_common::types::collection::Collection;
14use jacquard_common::types::string::Did;
15use jacquard_common::types::uri::RecordUri;
···3031 let response = client.send(request).await?;
32 let output: GetServicesOutput<'static> = response.into_output().map_err(|e| match e {
33- XrpcError::Auth(auth) => ClientError::Auth(auth),
34- XrpcError::Generic(g) => {
35- ClientError::Transport(TransportError::Other(g.to_string().into()))
36- }
37- XrpcError::Decode(e) => ClientError::Decode(e),
38- XrpcError::Xrpc(typed) => {
39- ClientError::Transport(TransportError::Other(format!("{:?}", typed).into()))
40- }
41 })?;
4243 let mut defs = LabelerDefs::new();
···81pub async fn fetch_labeler_defs_direct(
82 client: &(impl AgentSessionExt + Sync),
83 dids: Vec<Did<'_>>,
84-) -> Result<LabelerDefs<'static>, ClientError> {
85 #[cfg(feature = "tracing")]
86 let _span = tracing::debug_span!("fetch_labeler_defs_direct", count = dids.len()).entered();
87···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 })?;
9596 let output = client.fetch_record(&record_uri).await?;
···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
0140 })?;
141 Ok((labels.labels, labels.cursor))
142}
···157where
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, _) =
···9};
10use jacquard_api::com_atproto::label::{Label, query_labels::QueryLabels};
11use jacquard_common::cowstr::ToCowStr;
12+use jacquard_common::error::ClientError;
13use jacquard_common::types::collection::Collection;
14use jacquard_common::types::string::Did;
15use jacquard_common::types::uri::RecordUri;
···3031 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) => ClientError::decode(g.to_string()),
35+ XrpcError::Decode(e) => ClientError::decode(format!("{:?}", e)),
36+ XrpcError::Xrpc(typed) => ClientError::decode(format!("{:?}", typed)),
000037 })?;
3839 let mut defs = LabelerDefs::new();
···77pub async fn fetch_labeler_defs_direct(
78 client: &(impl AgentSessionExt + Sync),
79 dids: Vec<Did<'_>>,
80+) -> Result<LabelerDefs<'static>, AgentError> {
81 #[cfg(feature = "tracing")]
82 let _span = tracing::debug_span!("fetch_labeler_defs_direct", count = dids.len()).entered();
83···86 for did in dids {
87 let uri = format!("at://{}/app.bsky.labeler.service/self", did.as_str());
88 let record_uri = Service::uri(uri).map_err(|e| {
89+ AgentError::from(ClientError::invalid_request(format!("Invalid URI: {}", e)))
90 })?;
9192 let output = client.fetch_record(&record_uri).await?;
···131 .await?
132 .into_output()
133 .map_err(|e| match e {
134+ XrpcError::Auth(auth) => AgentError::from(auth),
135+ e @ (XrpcError::Generic(_) | XrpcError::Decode(_)) => AgentError::xrpc(e),
136+ XrpcError::Xrpc(typed) => AgentError::xrpc(XrpcError::Xrpc(typed)),
137 })?;
138 Ok((labels.labels, labels.cursor))
139}
···154where
155 R: Collection + From<CollectionOutput<'static, R>>,
156 for<'a> CollectionOutput<'a, R>: IntoStatic<Output = CollectionOutput<'static, R>>,
157+ for<'a> CollectionErr<'a, R>: IntoStatic<Output = CollectionErr<'static, R>> + Send + Sync,
158{
159 let record: R = client.fetch_record(record_uri).await?.into();
160 let (labels, _) =