use crate::auth::storage::CredentialStorage;
use crate::auth::SessionManager;
use crate::cli::PostArgs;
use crate::error::AppError;
use crate::mcp::{McpResponse, ToolResult};
use anyhow::Result;
use serde_json::Value;
use tokio::time::{timeout, Duration};
use tracing::debug;
pub async fn handle_post(id: Option<Value>, args: Value) -> McpResponse {
match timeout(Duration::from_secs(120), handle_post_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", "Post request exceeded 120 second timeout"),
}
}
async fn handle_post_impl(args: Value) -> Result<ToolResult, AppError> {
let post_args: PostArgs = serde_json::from_value(args)
.map_err(|e| AppError::InvalidInput(format!("Invalid arguments: {}", e)))?;
execute_post(post_args).await
}
pub async fn execute_post(post_args: PostArgs) -> Result<ToolResult, AppError> {
debug!(
"Post request for account: {}, text: '{}'",
post_args.post_as, post_args.text
);
let storage = CredentialStorage::new()?;
let session = if let Some(stored_session) = storage.get_session(&post_args.post_as)? {
debug!("Using stored session for {}", post_args.post_as);
stored_session
} else {
debug!("No stored session, creating new session for {}", post_args.post_as);
let credentials = storage.get_credentials(&post_args.post_as)?;
let session_manager = SessionManager::new()?;
session_manager.login(&credentials).await?
};
debug!("Authenticated as {} (DID: {})", session.handle, session.did);
let reply_ref = if let Some(reply_to) = &post_args.reply_to {
Some(parse_and_fetch_reply(&session, reply_to).await?)
} else {
None
};
let client = crate::http::client_with_timeout(std::time::Duration::from_secs(120));
let url = format!("{}/xrpc/com.atproto.repo.createRecord", session.service);
let mut record = serde_json::json!({
"$type": "app.bsky.feed.post",
"text": post_args.text,
"createdAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
});
if let Some(reply) = reply_ref {
record["reply"] = reply;
}
let body = serde_json::json!({
"repo": session.did,
"collection": "app.bsky.feed.post",
"record": record,
});
debug!("Creating post with body: {}", serde_json::to_string(&body)?);
let response = client
.post(&url)
.header("Authorization", format!("Bearer {}", session.access_jwt))
.json(&body)
.send()
.await
.map_err(|e| AppError::NetworkError(format!("Post creation request failed: {}", e)))?;
let status = response.status();
if !status.is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(AppError::NetworkError(format!(
"Post creation failed with status {}: {}",
status, error_text
)));
}
let result: serde_json::Value = response
.json()
.await
.map_err(|e| AppError::ParseError(format!("Failed to parse response: {}", e)))?;
let post_uri = result["uri"]
.as_str()
.ok_or_else(|| AppError::ParseError("No URI in response".to_string()))?;
debug!("Post created successfully: {}", post_uri);
let markdown = if post_args.reply_to.is_some() {
format!(
"# Reply Posted\n\n**Post URI:** {}\n\n**Text:** {}\n\n**Reply To:** {}\n",
post_uri,
post_args.text,
post_args.reply_to.as_ref().unwrap()
)
} else {
format!(
"# Post Created\n\n**Post URI:** {}\n\n**Text:** {}\n",
post_uri, post_args.text
)
};
Ok(ToolResult::text(markdown))
}
async fn parse_and_fetch_reply(
session: &crate::auth::Session,
reply_to: &str,
) -> Result<serde_json::Value, AppError> {
let post_ref = crate::bluesky::uri::parse_post_uri(reply_to).await?;
let client = crate::http::client_with_timeout(std::time::Duration::from_secs(120));
let url = format!(
"{}/xrpc/com.atproto.repo.getRecord?repo={}&collection=app.bsky.feed.post&rkey={}",
session.service, post_ref.did, post_ref.rkey
);
debug!("Fetching reply-to post from: {}", url);
let response = client
.get(&url)
.header("Authorization", format!("Bearer {}", session.access_jwt))
.send()
.await
.map_err(|e| AppError::NetworkError(format!("Failed to fetch reply-to post: {}", e)))?;
let status = response.status();
if !status.is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
return Err(AppError::NetworkError(format!(
"Failed to fetch reply-to post with status {}: {}",
status, error_text
)));
}
let post_data: serde_json::Value = response
.json()
.await
.map_err(|e| AppError::ParseError(format!("Failed to parse post data: {}", e)))?;
let post_uri = post_data["uri"]
.as_str()
.ok_or_else(|| AppError::ParseError("No URI in post data".to_string()))?;
let post_cid = post_data["cid"]
.as_str()
.ok_or_else(|| AppError::ParseError("No CID in post data".to_string()))?;
let root_ref = if let Some(reply) = post_data["value"].get("reply") {
if let Some(root) = reply.get("root") {
root.clone()
} else {
serde_json::json!({
"uri": post_uri,
"cid": post_cid
})
}
} else {
serde_json::json!({
"uri": post_uri,
"cid": post_cid
})
};
Ok(serde_json::json!({
"root": root_ref,
"parent": {
"uri": post_uri,
"cid": post_cid
}
}))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[tokio::test]
async fn test_post_args_parsing() {
let args = json!({
"postAs": "test.bsky.social",
"text": "Hello, world!"
});
let parsed: PostArgs = serde_json::from_value(args).unwrap();
assert_eq!(parsed.post_as, "test.bsky.social");
assert_eq!(parsed.text, "Hello, world!");
assert!(parsed.reply_to.is_none());
}
#[tokio::test]
async fn test_post_args_with_reply() {
let args = json!({
"postAs": "test.bsky.social",
"text": "Reply text",
"replyTo": "at://did:plc:abc/app.bsky.feed.post/123"
});
let parsed: PostArgs = serde_json::from_value(args).unwrap();
assert_eq!(parsed.post_as, "test.bsky.social");
assert_eq!(parsed.text, "Reply text");
assert_eq!(
parsed.reply_to,
Some("at://did:plc:abc/app.bsky.feed.post/123".to_string())
);
}
}