//! Bluesky channel: queries the public AppView (no auth) for the user's //! recent posts and tries each post's text as a KEZ proof. //! //! Endpoint: `GET https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=&limit=100` //! //! Each post in the feed has `post.record.text`. We feed that text through //! the standard proof parser, which handles Markdown-fenced, compact, and //! JSON forms uniformly. use async_trait::async_trait; use kez_core::Identity; use reqwest::Client; use serde_json::Value; use crate::{Channel, ChannelError, ChannelHit, ChannelResult, parse_and_verify_for}; const DEFAULT_APPVIEW: &str = "https://public.api.bsky.app"; const USER_AGENT: &str = "kez-channels/0.1 (+https://example.invalid/kez)"; #[derive(Clone)] pub struct BlueskyChannel { client: Client, appview_base: String, } impl BlueskyChannel { pub fn new() -> anyhow::Result { let client = Client::builder().user_agent(USER_AGENT).build()?; Ok(Self::with_base(client, DEFAULT_APPVIEW.to_owned())) } /// For tests / custom AppViews. pub fn with_base(client: Client, appview_base: String) -> Self { Self { client, appview_base, } } } #[async_trait] impl Channel for BlueskyChannel { fn system(&self) -> &'static str { "bluesky" } async fn fetch_and_verify(&self, identity: &Identity) -> ChannelResult { let actor = identity.value(); if actor.is_empty() { return Err(ChannelError::Other(anyhow::anyhow!( "bluesky identity has empty handle" ))); } let url = author_feed_url(&self.appview_base, actor); let resp = self .client .get(&url) .send() .await .map_err(|e| ChannelError::Unreachable(format!("GET {url}: {e}")))? .error_for_status() .map_err(|e| ChannelError::Unreachable(format!("GET {url}: {e}")))?; let body: Value = resp .json() .await .map_err(|e| ChannelError::Other(anyhow::anyhow!("parse feed: {e}")))?; let candidates = extract_post_texts(&body); let mut last_error: Option = None; for text in candidates { match parse_and_verify_for(&text, identity) { Ok(hit) => return Ok(hit), Err(err) => last_error = Some(err), } } Err(last_error.unwrap_or_else(|| ChannelError::NotFound(identity.clone()))) } } /// Pure: build the `getAuthorFeed` URL. pub fn author_feed_url(base: &str, actor: &str) -> String { // We URL-encode minimally; AppView is forgiving with handles and // reqwest will re-quote anything truly malformed. format!("{base}/xrpc/app.bsky.feed.getAuthorFeed?actor={actor}&limit=100") } /// Pure: pull every post's text out of a `getAuthorFeed` response body. /// Skips posts without text (reposts, replies-only structures, etc.). pub fn extract_post_texts(body: &Value) -> Vec { let Some(feed) = body.get("feed").and_then(|f| f.as_array()) else { return Vec::new(); }; let mut out = Vec::new(); for item in feed { let Some(text) = item .get("post") .and_then(|p| p.get("record")) .and_then(|r| r.get("text")) .and_then(|t| t.as_str()) else { continue; }; if text.trim().is_empty() { continue; } out.push(text.to_owned()); } out } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn author_feed_url_includes_actor_and_limit() { let url = author_feed_url("https://public.api.bsky.app", "jason.bsky.social"); assert_eq!( url, "https://public.api.bsky.app/xrpc/app.bsky.feed.getAuthorFeed?actor=jason.bsky.social&limit=100" ); } #[test] fn extract_post_texts_pulls_from_feed_items() { let body = json!({ "feed": [ { "post": { "record": { "text": "hello" } } }, { "post": { "record": { "text": "kez:z1:abc" } } }, { "post": { "record": {} } }, // no text { "no_post_field": true }, ] }); let texts = extract_post_texts(&body); assert_eq!(texts, vec!["hello".to_owned(), "kez:z1:abc".to_owned()]); } #[test] fn extract_post_texts_skips_blank_and_whitespace() { let body = json!({ "feed": [ { "post": { "record": { "text": " " } } }, { "post": { "record": { "text": "" } } }, { "post": { "record": { "text": "real" } } }, ] }); assert_eq!(extract_post_texts(&body), vec!["real".to_owned()]); } #[test] fn extract_post_texts_handles_missing_feed() { assert!(extract_post_texts(&json!({})).is_empty()); assert!(extract_post_texts(&json!({ "feed": "not an array" })).is_empty()); } }