use crate::error::AppError;
use crate::http::client_with_timeout;
use crate::mcp::{McpResponse, ToolResult};
use anyhow::Result;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::time::Duration;
use tokio::time::timeout;
use tracing::{debug, info};
#[derive(JsonSchema, Deserialize, Serialize, Clone, Debug)]
pub struct ThreadArgs {
#[schemars(description = "The BlueSky URL or at:// URI of the post to fetch the thread for")]
#[serde(rename = "postURI")]
pub post_uri: String,
#[schemars(description = "Optional BlueSky handle for authenticated access")]
pub login: Option<String>,
#[schemars(description = "Optional BlueSky password")]
pub password: Option<String>,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
struct PostAuthor {
did: String,
handle: String,
#[serde(rename = "displayName")]
display_name: Option<String>,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
struct PostRecord {
text: String,
#[serde(rename = "createdAt")]
created_at: String,
}
#[derive(Deserialize, Serialize, Debug, Clone)]
struct ThreadPost {
uri: String,
cid: String,
author: PostAuthor,
record: PostRecord,
#[serde(rename = "indexedAt")]
indexed_at: Option<String>,
#[serde(rename = "likeCount")]
like_count: Option<i32>,
#[serde(rename = "replyCount")]
reply_count: Option<i32>,
#[serde(rename = "repostCount")]
repost_count: Option<i32>,
#[serde(rename = "quoteCount")]
quote_count: Option<i32>,
}
#[derive(Deserialize, Debug)]
#[serde(tag = "$type")]
enum ThreadNode {
#[serde(rename = "app.bsky.feed.defs#threadViewPost")]
ThreadViewPost {
post: ThreadPost,
#[serde(default)]
replies: Vec<ThreadNode>,
},
#[serde(rename = "app.bsky.feed.defs#notFoundPost")]
NotFoundPost {
uri: String,
#[serde(rename = "notFound")]
not_found: bool,
},
#[serde(rename = "app.bsky.feed.defs#blockedPost")]
BlockedPost {
uri: String,
blocked: bool,
},
}
#[derive(Deserialize)]
struct ThreadResponse {
thread: ThreadNode,
}
pub async fn handle_thread(id: Option<Value>, args: Value) -> McpResponse {
match timeout(Duration::from_secs(120), handle_thread_impl(args)).await {
Ok(result) => match result {
Ok(content) => McpResponse::success(id, serde_json::to_value(content).unwrap()),
Err(e) => McpResponse::error(id, e.error_code(), &e.message()),
},
Err(_) => McpResponse::error(id, "timeout", "Thread request exceeded 120 second timeout"),
}
}
async fn handle_thread_impl(args: Value) -> Result<ToolResult, AppError> {
let thread_args: ThreadArgs = serde_json::from_value(args)
.map_err(|e| AppError::InvalidInput(format!("Invalid arguments: {}", e)))?;
execute_thread(thread_args).await
}
pub async fn execute_thread(thread_args: ThreadArgs) -> Result<ToolResult, AppError> {
info!("Thread request for post: {}", thread_args.post_uri);
let post_uri = parse_post_uri(&thread_args.post_uri)?;
let client = client_with_timeout(Duration::from_secs(30));
let url = format!(
"https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri={}",
urlencoding::encode(&post_uri)
);
debug!("Fetching thread from: {}", url);
let response = client
.get(&url)
.send()
.await
.map_err(|e| AppError::NetworkError(format!("Failed to fetch thread: {}", e)))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
return Err(AppError::NetworkError(format!(
"Thread API returned error {}: {}",
status, error_text
)));
}
let thread_response: ThreadResponse = response
.json()
.await
.map_err(|e| AppError::ParseError(format!("Failed to parse thread response: {}", e)))?;
let markdown = format_thread(&thread_response.thread);
debug!("Thread formatted successfully");
Ok(ToolResult::text(markdown))
}
fn format_thread(node: &ThreadNode) -> String {
let mut markdown = String::new();
markdown.push_str("# Thread\n\n");
format_thread_recursive(node, &mut markdown, 0);
markdown
}
fn format_thread_recursive(node: &ThreadNode, markdown: &mut String, depth: usize) {
match node {
ThreadNode::ThreadViewPost { post, replies } => {
let indent = " ".repeat(depth);
markdown.push_str(&format!("{}## Post by @{}", indent, post.author.handle));
if let Some(display_name) = &post.author.display_name {
markdown.push_str(&format!(" ({})", display_name));
}
markdown.push_str("\n");
markdown.push_str(&format!("{}**URI:** {}\n", indent, post.uri));
if let Some(indexed_at) = &post.indexed_at {
markdown.push_str(&format!("{}**Indexed:** {}\n", indent, indexed_at));
}
markdown.push_str("\n");
for line in post.record.text.lines() {
markdown.push_str(&format!("{}{}\n", indent, line));
}
markdown.push_str("\n");
let stats: Vec<String> = vec![
post.like_count.map(|c| format!("{} likes", c)),
post.reply_count.map(|c| format!("{} replies", c)),
post.repost_count.map(|c| format!("{} reposts", c)),
post.quote_count.map(|c| format!("{} quotes", c)),
]
.into_iter()
.flatten()
.collect();
if !stats.is_empty() {
markdown.push_str(&format!("{}*{}*\n", indent, stats.join(", ")));
}
markdown.push_str("\n");
if !replies.is_empty() {
markdown.push_str(&format!("{}### Replies:\n\n", indent));
for reply in replies {
format_thread_recursive(reply, markdown, depth + 1);
}
}
}
ThreadNode::NotFoundPost { uri, .. } => {
markdown.push_str(&format!("*Post not found: {}*\n\n", uri));
}
ThreadNode::BlockedPost { uri, .. } => {
markdown.push_str(&format!("*Post blocked: {}*\n\n", uri));
}
}
}
fn parse_post_uri(uri: &str) -> Result<String, AppError> {
if uri.starts_with("at://") {
return Ok(uri.to_string());
}
if let Some(captures) = uri.strip_prefix("https://bsky.app/profile/") {
if let Some((handle, rest)) = captures.split_once("/post/") {
return Err(AppError::InvalidInput(format!(
"Please provide the at:// URI directly. URL format is not yet supported. Handle: {}, Post ID: {}",
handle, rest
)));
}
}
Err(AppError::InvalidInput(format!(
"Invalid post URI: {}. Expected at:// URI or https://bsky.app/profile/handle/post/id URL",
uri
)))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_thread_args_deserialize() {
let json = serde_json::json!({
"postURI": "at://did:plc:example/app.bsky.feed.post/123"
});
let args: ThreadArgs = serde_json::from_value(json).unwrap();
assert_eq!(
args.post_uri,
"at://did:plc:example/app.bsky.feed.post/123"
);
}
#[test]
fn test_parse_post_uri_at_protocol() {
let uri = "at://did:plc:example/app.bsky.feed.post/123";
let result = parse_post_uri(uri).unwrap();
assert_eq!(result, uri);
}
#[test]
fn test_parse_post_uri_url_not_supported() {
let uri = "https://bsky.app/profile/alice.bsky.social/post/123";
let result = parse_post_uri(uri);
assert!(result.is_err());
}
#[test]
fn test_parse_post_uri_invalid() {
let uri = "invalid://something";
let result = parse_post_uri(uri);
assert!(result.is_err());
}
}