diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 10d6ca1..8c16aa1 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,6 +1,7 @@ pub mod auth; pub mod token; pub mod record; +pub mod reply; pub mod sync; pub mod push; pub mod notify; diff --git a/src/commands/notify.rs b/src/commands/notify.rs index 98fac33..ad7f50e 100644 --- a/src/commands/notify.rs +++ b/src/commands/notify.rs @@ -113,15 +113,37 @@ pub async fn listen(interval_secs: u64, reasons: &[String]) -> Result<()> { // Output in chronological order (oldest first) for notif in new_items.iter().rev() { + // Build root/parent for reply threading + let root = if notif["record"]["reply"]["root"]["uri"].is_string() { + serde_json::json!({ + "uri": notif["record"]["reply"]["root"]["uri"], + "cid": notif["record"]["reply"]["root"]["cid"], + }) + } else { + // This post is the root + serde_json::json!({ + "uri": notif["uri"], + "cid": notif["cid"], + }) + }; + + let parent = serde_json::json!({ + "uri": notif["uri"], + "cid": notif["cid"], + }); + let out = serde_json::json!({ "reason": notif["reason"], "uri": notif["uri"], + "cid": notif["cid"], "author": { "did": notif["author"]["did"], "handle": notif["author"]["handle"], }, "text": notif["record"]["text"], "indexedAt": notif["indexedAt"], + "root": root, + "parent": parent, }); writeln!(stdout, "{}", serde_json::to_string(&out)?)?; stdout.flush()?; diff --git a/src/commands/reply.rs b/src/commands/reply.rs new file mode 100644 index 0000000..5f38842 --- /dev/null +++ b/src/commands/reply.rs @@ -0,0 +1,86 @@ +use anyhow::{Context, Result}; +use serde_json::Value; + +use super::auth; +use crate::lexicons::com_atproto_repo; +use crate::tid; +use crate::types::{PutRecordRequest, PutRecordResponse}; +use crate::xrpc::XrpcClient; + +/// Reply to a post on ATProto. +/// +/// `root_uri`, `root_cid`: thread root +/// `parent_uri`, `parent_cid`: direct parent being replied to +pub async fn reply( + text: &str, + root_uri: &str, + root_cid: &str, + parent_uri: &str, + parent_cid: &str, +) -> Result<()> { + let session = auth::refresh_session().await?; + let pds = session.pds.as_deref().unwrap_or("bsky.social"); + let client = XrpcClient::new(pds); + + let now = chrono::Utc::now() + .format("%Y-%m-%dT%H:%M:%S%.3fZ") + .to_string(); + + let record = serde_json::json!({ + "$type": "app.bsky.feed.post", + "text": text, + "reply": { + "root": { + "uri": root_uri, + "cid": root_cid, + }, + "parent": { + "uri": parent_uri, + "cid": parent_cid, + }, + }, + "createdAt": now, + }); + + let rkey = tid::generate_tid(); + + let req = PutRecordRequest { + repo: session.did.clone(), + collection: "app.bsky.feed.post".to_string(), + rkey, + record, + }; + + let result: PutRecordResponse = client + .call(&com_atproto_repo::PUT_RECORD, &req, &session.access_jwt) + .await?; + + let out = serde_json::json!({ + "uri": result.uri, + "cid": result.cid, + }); + println!("{}", serde_json::to_string_pretty(&out)?); + + Ok(()) +} + +/// Reply from JSON input (stdin or argument). +/// Expects fields: text, root.uri, root.cid, parent.uri, parent.cid +pub async fn reply_json(json_str: &str, text: &str) -> Result<()> { + let v: Value = serde_json::from_str(json_str).context("Invalid JSON input")?; + + let root_uri = v["root"]["uri"] + .as_str() + .context("missing root.uri")?; + let root_cid = v["root"]["cid"] + .as_str() + .context("missing root.cid")?; + let parent_uri = v["parent"]["uri"] + .as_str() + .context("missing parent.uri")?; + let parent_cid = v["parent"]["cid"] + .as_str() + .context("missing parent.cid")?; + + reply(text, root_uri, root_cid, parent_uri, parent_cid).await +} diff --git a/src/main.rs b/src/main.rs index cbd8b69..da63ce9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -135,6 +135,27 @@ enum Commands { server: String, }, + /// Reply to a post + Reply { + /// Reply text + text: String, + /// Parent post URI (at://...) + #[arg(long)] + uri: Option, + /// Parent post CID + #[arg(long)] + cid: Option, + /// Root post URI (defaults to parent if omitted) + #[arg(long)] + root_uri: Option, + /// Root post CID (defaults to parent if omitted) + #[arg(long)] + root_cid: Option, + /// JSON with root/parent info (from `notify listen` output) + #[arg(long)] + json: Option, + }, + /// Chat with AI #[command(alias = "c")] Chat { @@ -248,6 +269,19 @@ async fn main() -> Result<()> { Commands::Did { handle, server } => { commands::did::resolve(&handle, &server).await?; } + Commands::Reply { text, uri, cid, root_uri, root_cid, json } => { + if let Some(json_str) = json { + commands::reply::reply_json(&json_str, &text).await?; + } else { + let parent_uri = uri.as_deref() + .expect("--uri is required (or use --json)"); + let parent_cid = cid.as_deref() + .expect("--cid is required (or use --json)"); + let r_uri = root_uri.as_deref().unwrap_or(parent_uri); + let r_cid = root_cid.as_deref().unwrap_or(parent_cid); + commands::reply::reply(&text, r_uri, r_cid, parent_uri, parent_cid).await?; + } + } Commands::Chat { message, new } => { lms::chat::run(message.as_deref(), new).await?; }