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 FeedArgs {
#[schemars(description = "Optional feed URI or name. If unspecified, returns the default popular feed")]
pub feed: Option<String>,
#[schemars(description = "Optional BlueSky handle for authenticated access")]
pub login: Option<String>,
#[schemars(description = "Optional BlueSky password")]
pub password: Option<String>,
#[schemars(description = "Optional cursor for pagination")]
pub cursor: Option<String>,
#[schemars(description = "Limit the number of posts (default 20, max 100)")]
pub limit: Option<usize>,
}
#[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 FeedPost {
uri: String,
cid: String,
author: PostAuthor,
record: PostRecord,
#[serde(rename = "indexedAt")]
indexed_at: 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)]
struct FeedViewPost {
post: FeedPost,
}
#[derive(Deserialize)]
struct FeedResponse {
feed: Vec<FeedViewPost>,
cursor: Option<String>,
}
pub async fn handle_feed(id: Option<Value>, args: Value) -> McpResponse {
match timeout(Duration::from_secs(120), handle_feed_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", "Feed request exceeded 120 second timeout"),
}
}
async fn handle_feed_impl(args: Value) -> Result<ToolResult, AppError> {
let feed_args: FeedArgs = serde_json::from_value(args)
.map_err(|e| AppError::InvalidInput(format!("Invalid arguments: {}", e)))?;
execute_feed(feed_args).await
}
pub async fn execute_feed(feed_args: FeedArgs) -> Result<ToolResult, AppError> {
info!("Feed request for feed: {:?}", feed_args.feed);
let client = client_with_timeout(Duration::from_secs(30));
let feed_uri = feed_args.feed.as_deref().unwrap_or(
"at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot"
);
let mut url = format!(
"https://public.api.bsky.app/xrpc/app.bsky.feed.getFeed?feed={}",
urlencoding::encode(feed_uri)
);
if let Some(cursor) = &feed_args.cursor {
url.push_str(&format!("&cursor={}", urlencoding::encode(cursor)));
}
if let Some(limit) = feed_args.limit {
let clamped_limit = limit.clamp(1, 100);
url.push_str(&format!("&limit={}", clamped_limit));
}
debug!("Fetching feed from: {}", url);
let response = client
.get(&url)
.send()
.await
.map_err(|e| AppError::NetworkError(format!("Failed to fetch feed: {}", e)))?;
if !response.status().is_success() {
let status = response.status();
let error_text = response.text().await.unwrap_or_default();
return Err(AppError::NetworkError(format!(
"Feed API returned error {}: {}",
status, error_text
)));
}
let feed_response: FeedResponse = response
.json()
.await
.map_err(|e| AppError::ParseError(format!("Failed to parse feed response: {}", e)))?;
debug!("Received {} posts from feed", feed_response.feed.len());
let mut markdown = String::new();
markdown.push_str("# Feed Posts\n\n");
for (i, feed_post) in feed_response.feed.iter().enumerate() {
let post = &feed_post.post;
markdown.push_str(&format!("## Post {}\n", i + 1));
markdown.push_str(&format!("**Author:** @{}", 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", post.uri));
markdown.push_str(&format!("**Indexed:** {}\n\n", post.indexed_at));
markdown.push_str(&format!("{}\n\n", post.record.text));
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\n", stats.join(", ")));
}
if i < feed_response.feed.len() - 1 {
markdown.push_str("---\n\n");
}
}
if let Some(cursor) = feed_response.cursor {
markdown.push_str(&format!("\n*Cursor for next page: {}*\n", cursor));
} else {
markdown.push_str("\n*No more pages available*\n");
}
Ok(ToolResult::text(markdown))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_feed_args_deserialize() {
let json = serde_json::json!({
"feed": "at://did:plc:example/app.bsky.feed.generator/test",
"limit": 50
});
let args: FeedArgs = serde_json::from_value(json).unwrap();
assert_eq!(
args.feed,
Some("at://did:plc:example/app.bsky.feed.generator/test".to_string())
);
assert_eq!(args.limit, Some(50));
}
#[test]
fn test_feed_args_optional_fields() {
let json = serde_json::json!({});
let args: FeedArgs = serde_json::from_value(json).unwrap();
assert_eq!(args.feed, None);
assert_eq!(args.limit, None);
assert_eq!(args.cursor, None);
}
}