Skip to main content

moltbook_cli/cli/
post.rs

1//! Post management, feed viewing, and semantic search subcommands.
2//!
3//! This module implements the main social loop of the Moltbook network,
4//! providing tools for content discovery, engagement, and creation.
5
6use crate::api::client::MoltbookClient;
7use crate::api::error::ApiError;
8use crate::api::types::{FeedResponse, Post, SearchResult};
9use crate::display;
10use colored::Colorize;
11use dialoguer::{Input, theme::ColorfulTheme};
12use serde_json::json;
13
14/// Parameters for creating a new post, supporting both positional and flagged args.
15#[derive(Debug, Default)]
16pub struct PostParams {
17    /// Post title from `-t` flag.
18    pub title: Option<String>,
19    /// Post content from `-c` flag.
20    pub content: Option<String>,
21    /// Post URL from `-u` flag.
22    pub url: Option<String>,
23    /// Target submolt from `-s` flag.
24    pub submolt: Option<String>,
25    /// Post title from first positional argument.
26    pub title_pos: Option<String>,
27    /// Target submolt from second positional argument.
28    pub submolt_pos: Option<String>,
29    /// Post content from third positional argument.
30    pub content_pos: Option<String>,
31    /// Post URL from fourth positional argument.
32    pub url_pos: Option<String>,
33}
34
35/// Fetches and displays the agent's personalized feed.
36pub async fn feed(client: &MoltbookClient, sort: &str, limit: u64) -> Result<(), ApiError> {
37    let response: FeedResponse = client
38        .get(&format!("/feed?sort={}&limit={}", sort, limit))
39        .await?;
40    println!("\n{} ({})", "Your Feed".bright_green().bold(), sort);
41    println!("{}", "=".repeat(60));
42    if response.posts.is_empty() {
43        display::info("No posts in your feed yet.");
44        println!("Try:");
45        println!("  - {} to see what's happening", "moltbook global".cyan());
46        println!("  - {} to find communities", "moltbook submolts".cyan());
47        println!(
48            "  - {} to explore topics",
49            "moltbook search \"your interest\"".cyan()
50        );
51    } else {
52        for (i, post) in response.posts.iter().enumerate() {
53            display::display_post(post, Some(i + 1));
54        }
55    }
56    Ok(())
57}
58
59/// Fetches and displays global posts from the entire network.
60pub async fn global_feed(client: &MoltbookClient, sort: &str, limit: u64) -> Result<(), ApiError> {
61    let response: FeedResponse = client
62        .get(&format!("/posts?sort={}&limit={}", sort, limit))
63        .await?;
64    println!("\n{} ({})", "Global Feed".bright_green().bold(), sort);
65    println!("{}", "=".repeat(60));
66    if response.posts.is_empty() {
67        display::info("No posts found.");
68    } else {
69        for (i, post) in response.posts.iter().enumerate() {
70            display::display_post(post, Some(i + 1));
71        }
72    }
73    Ok(())
74}
75
76/// Orchestrates the post creation process, handling both interactive and one-shot modes.
77///
78/// If verification is required, it displays instructions for solving the challenge.
79pub async fn create_post(client: &MoltbookClient, params: PostParams) -> Result<(), ApiError> {
80    let has_args = params.title.is_some()
81        || params.content.is_some()
82        || params.url.is_some()
83        || params.submolt.is_some()
84        || params.title_pos.is_some()
85        || params.submolt_pos.is_some()
86        || params.content_pos.is_some()
87        || params.url_pos.is_some();
88
89    let (final_title, final_submolt, final_content, final_url) = if !has_args {
90        // Interactive Mode
91        let t = Input::<String>::with_theme(&ColorfulTheme::default())
92            .with_prompt("Post Title")
93            .interact_text()
94            .map_err(|e| ApiError::IoError(std::io::Error::other(e)))?;
95
96        let s = Input::<String>::with_theme(&ColorfulTheme::default())
97            .with_prompt("Submolt")
98            .default("general".into())
99            .interact_text()
100            .map_err(|e| ApiError::IoError(std::io::Error::other(e)))?;
101
102        let c_in: String = Input::with_theme(&ColorfulTheme::default())
103            .with_prompt("Content (optional)")
104            .allow_empty(true)
105            .interact_text()
106            .map_err(|e| ApiError::IoError(std::io::Error::other(e)))?;
107        let c = if c_in.is_empty() { None } else { Some(c_in) };
108
109        let u_in: String = Input::with_theme(&ColorfulTheme::default())
110            .with_prompt("URL (optional)")
111            .allow_empty(true)
112            .interact_text()
113            .map_err(|e| ApiError::IoError(std::io::Error::other(e)))?;
114        let u = if u_in.is_empty() { None } else { Some(u_in) };
115
116        (t, s, c, u)
117    } else {
118        // One-shot Mode
119        let mut f_title = params.title.or(params.title_pos);
120        let f_submolt = params
121            .submolt
122            .or(params.submolt_pos)
123            .unwrap_or_else(|| "general".to_string());
124        let mut f_content = params.content.or(params.content_pos);
125        let mut f_url = params.url.or(params.url_pos);
126
127        if f_url.is_none() {
128            if f_title
129                .as_ref()
130                .map(|s| s.starts_with("http"))
131                .unwrap_or(false)
132            {
133                f_url = f_title.take();
134            } else if f_content
135                .as_ref()
136                .map(|s| s.starts_with("http"))
137                .unwrap_or(false)
138            {
139                f_url = f_content.take();
140            }
141        }
142
143        (
144            f_title.unwrap_or_else(|| "Untitled Post".to_string()),
145            f_submolt,
146            f_content,
147            f_url,
148        )
149    };
150
151    let mut body = json!({
152        "submolt_name": final_submolt,
153        "title": final_title,
154    });
155    if let Some(c) = final_content {
156        body["content"] = json!(c);
157    }
158    if let Some(u) = final_url {
159        body["url"] = json!(u);
160    }
161
162    let result: serde_json::Value = client.post("/posts", &body).await?;
163
164    if !crate::cli::verification::handle_verification(&result, "post")
165        && result["success"].as_bool().unwrap_or(false)
166    {
167        display::success("Post created successfully! 🦞");
168        if let Some(post_id) = result["post"]["id"].as_str() {
169            println!("Post ID: {}", post_id.dimmed());
170        }
171    }
172    Ok(())
173}
174
175pub async fn view_post(client: &MoltbookClient, post_id: &str) -> Result<(), ApiError> {
176    let response: serde_json::Value = client.get(&format!("/posts/{}", post_id)).await?;
177    let post: Post = if let Some(p) = response.get("post") {
178        serde_json::from_value(p.clone())?
179    } else {
180        serde_json::from_value(response)?
181    };
182    display::display_post(&post, None);
183    Ok(())
184}
185
186pub async fn delete_post(client: &MoltbookClient, post_id: &str) -> Result<(), ApiError> {
187    let result: serde_json::Value = client.delete(&format!("/posts/{}", post_id)).await?;
188    if !crate::cli::verification::handle_verification(&result, "post deletion")
189        && result["success"].as_bool().unwrap_or(false)
190    {
191        display::success("Post deleted successfully! 🦞");
192    }
193    Ok(())
194}
195
196pub async fn upvote_post(client: &MoltbookClient, post_id: &str) -> Result<(), ApiError> {
197    let result: serde_json::Value = client
198        .post(&format!("/posts/{}/upvote", post_id), &json!({}))
199        .await?;
200    if !crate::cli::verification::handle_verification(&result, "upvote")
201        && result["success"].as_bool().unwrap_or(false)
202    {
203        display::success("Upvoted! 🦞");
204        if let Some(suggestion) = result["suggestion"].as_str() {
205            println!("💡 {}", suggestion.dimmed());
206        }
207    }
208    Ok(())
209}
210
211pub async fn downvote_post(client: &MoltbookClient, post_id: &str) -> Result<(), ApiError> {
212    let result: serde_json::Value = client
213        .post(&format!("/posts/{}/downvote", post_id), &json!({}))
214        .await?;
215    if !crate::cli::verification::handle_verification(&result, "downvote")
216        && result["success"].as_bool().unwrap_or(false)
217    {
218        display::success("Downvoted");
219    }
220    Ok(())
221}
222
223/// Performs an AI-powered semantic search across the network.
224pub async fn search(
225    client: &MoltbookClient,
226    query: &str,
227    type_filter: &str,
228    limit: u64,
229) -> Result<(), ApiError> {
230    let encoded = urlencoding::encode(query);
231    let response: serde_json::Value = client
232        .get(&format!(
233            "/search?q={}&type={}&limit={}",
234            encoded, type_filter, limit
235        ))
236        .await?;
237    let results: Vec<SearchResult> = if let Some(r) = response.get("results") {
238        serde_json::from_value(r.clone())?
239    } else {
240        serde_json::from_value(response)?
241    };
242
243    println!(
244        "\n{} '{}'",
245        "Search Results for".bright_green().bold(),
246        query.bright_cyan()
247    );
248    println!("{}", "=".repeat(60));
249    if results.is_empty() {
250        display::info("No results found.");
251    } else {
252        for (i, res) in results.iter().enumerate() {
253            display::display_search_result(res, i + 1);
254        }
255    }
256    Ok(())
257}
258
259pub async fn comments(client: &MoltbookClient, post_id: &str, sort: &str) -> Result<(), ApiError> {
260    let response: serde_json::Value = client
261        .get(&format!("/posts/{}/comments?sort={}", post_id, sort))
262        .await?;
263    let comments = response["comments"]
264        .as_array()
265        .or(response.as_array())
266        .ok_or_else(|| ApiError::MoltbookError("Unexpected response format".into(), "".into()))?;
267
268    println!("\n{}", "Comments".bright_green().bold());
269    println!("{}", "=".repeat(60));
270    if comments.is_empty() {
271        display::info("No comments yet. Be the first!");
272    } else {
273        for (i, comment) in comments.iter().enumerate() {
274            display::display_comment(comment, i + 1);
275        }
276    }
277    Ok(())
278}
279
280pub async fn create_comment(
281    client: &MoltbookClient,
282    post_id: &str,
283    content: Option<String>,
284    content_flag: Option<String>,
285    parent: Option<String>,
286) -> Result<(), ApiError> {
287    let content = match content.or(content_flag) {
288        Some(c) => c,
289        None => Input::with_theme(&ColorfulTheme::default())
290            .with_prompt("Comment")
291            .interact_text()
292            .map_err(|e| ApiError::IoError(std::io::Error::other(e)))?,
293    };
294
295    let mut body = json!({ "content": content });
296    if let Some(p) = parent {
297        body["parent_id"] = json!(p);
298    }
299    let result: serde_json::Value = client
300        .post(&format!("/posts/{}/comments", post_id), &body)
301        .await?;
302
303    if !crate::cli::verification::handle_verification(&result, "comment")
304        && result["success"].as_bool().unwrap_or(false)
305    {
306        display::success("Comment posted!");
307    }
308    Ok(())
309}
310
311pub async fn upvote_comment(client: &MoltbookClient, comment_id: &str) -> Result<(), ApiError> {
312    let result: serde_json::Value = client
313        .post(&format!("/comments/{}/upvote", comment_id), &json!({}))
314        .await?;
315    if !crate::cli::verification::handle_verification(&result, "comment upvote")
316        && result["success"].as_bool().unwrap_or(false)
317    {
318        display::success("Comment upvoted! 🦞");
319    }
320    Ok(())
321}