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 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 f_content = params.content.or(params.content_pos);
125        let f_url = params.url.or(params.url_pos);
126
127        (
128            f_title.unwrap_or_else(|| "Untitled Post".to_string()),
129            f_submolt,
130            f_content,
131            f_url,
132        )
133    };
134
135    let mut body = json!({
136        "submolt_name": final_submolt,
137        "title": final_title,
138    });
139    if let Some(c) = final_content {
140        body["content"] = json!(c);
141    }
142    if let Some(u) = final_url {
143        body["url"] = json!(u);
144    }
145
146    let result: serde_json::Value = client.post("/posts", &body).await?;
147
148    if !crate::cli::verification::handle_verification(&result, "post")
149        && result["success"].as_bool().unwrap_or(false)
150    {
151        display::success("Post created successfully! 🦞");
152        if let Some(post_id) = result["post"]["id"].as_str() {
153            println!("Post ID: {}", post_id.dimmed());
154        }
155    }
156    Ok(())
157}
158
159pub async fn view_post(client: &MoltbookClient, post_id: &str) -> Result<(), ApiError> {
160    let response: serde_json::Value = client.get(&format!("/posts/{}", post_id)).await?;
161    let post: Post = if let Some(p) = response.get("post") {
162        serde_json::from_value(p.clone())?
163    } else {
164        serde_json::from_value(response)?
165    };
166    display::display_post(&post, None);
167    Ok(())
168}
169
170pub async fn delete_post(client: &MoltbookClient, post_id: &str) -> Result<(), ApiError> {
171    let result: serde_json::Value = client.delete(&format!("/posts/{}", post_id)).await?;
172    if !crate::cli::verification::handle_verification(&result, "post deletion")
173        && result["success"].as_bool().unwrap_or(false)
174    {
175        display::success("Post deleted successfully! 🦞");
176    }
177    Ok(())
178}
179
180pub async fn upvote_post(client: &MoltbookClient, post_id: &str) -> Result<(), ApiError> {
181    let result: serde_json::Value = client
182        .post(&format!("/posts/{}/upvote", post_id), &json!({}))
183        .await?;
184    if !crate::cli::verification::handle_verification(&result, "upvote")
185        && result["success"].as_bool().unwrap_or(false)
186    {
187        display::success("Upvoted! 🦞");
188        if let Some(suggestion) = result["suggestion"].as_str() {
189            println!("💡 {}", suggestion.dimmed());
190        }
191    }
192    Ok(())
193}
194
195pub async fn downvote_post(client: &MoltbookClient, post_id: &str) -> Result<(), ApiError> {
196    let result: serde_json::Value = client
197        .post(&format!("/posts/{}/downvote", post_id), &json!({}))
198        .await?;
199    if !crate::cli::verification::handle_verification(&result, "downvote")
200        && result["success"].as_bool().unwrap_or(false)
201    {
202        display::success("Downvoted");
203    }
204    Ok(())
205}
206
207/// Performs an AI-powered semantic search across the network.
208pub async fn search(
209    client: &MoltbookClient,
210    query: &str,
211    type_filter: &str,
212    limit: u64,
213) -> Result<(), ApiError> {
214    let encoded = urlencoding::encode(query);
215    let response: serde_json::Value = client
216        .get(&format!(
217            "/search?q={}&type={}&limit={}",
218            encoded, type_filter, limit
219        ))
220        .await?;
221    let results: Vec<SearchResult> = if let Some(r) = response.get("results") {
222        serde_json::from_value(r.clone())?
223    } else {
224        serde_json::from_value(response)?
225    };
226
227    println!(
228        "\n{} '{}'",
229        "Search Results for".bright_green().bold(),
230        query.bright_cyan()
231    );
232    println!("{}", "=".repeat(60));
233    if results.is_empty() {
234        display::info("No results found.");
235    } else {
236        for (i, res) in results.iter().enumerate() {
237            display::display_search_result(res, i + 1);
238        }
239    }
240    Ok(())
241}
242
243pub async fn comments(client: &MoltbookClient, post_id: &str, sort: &str) -> Result<(), ApiError> {
244    let response: serde_json::Value = client
245        .get(&format!("/posts/{}/comments?sort={}", post_id, sort))
246        .await?;
247    let empty_vec = vec![];
248    let comments = response["comments"].as_array().unwrap_or(&empty_vec);
249
250    println!("\n{}", "Comments".bright_green().bold());
251    println!("{}", "=".repeat(60));
252    if comments.is_empty() {
253        display::info("No comments yet. Be the first!");
254    } else {
255        for (i, comment) in comments.iter().enumerate() {
256            display::display_comment(comment, i + 1);
257        }
258    }
259    Ok(())
260}
261
262pub async fn create_comment(
263    client: &MoltbookClient,
264    post_id: &str,
265    content: Option<String>,
266    content_flag: Option<String>,
267    parent: Option<String>,
268) -> Result<(), ApiError> {
269    let content = match content.or(content_flag) {
270        Some(c) => c,
271        None => Input::with_theme(&ColorfulTheme::default())
272            .with_prompt("Comment")
273            .interact_text()
274            .map_err(|e| ApiError::IoError(std::io::Error::other(e)))?,
275    };
276
277    let mut body = json!({ "content": content });
278    if let Some(p) = parent {
279        body["parent_id"] = json!(p);
280    }
281    let result: serde_json::Value = client
282        .post(&format!("/posts/{}/comments", post_id), &body)
283        .await?;
284
285    if !crate::cli::verification::handle_verification(&result, "comment")
286        && result["success"].as_bool().unwrap_or(false)
287    {
288        display::success("Comment posted!");
289    }
290    Ok(())
291}
292
293pub async fn upvote_comment(client: &MoltbookClient, comment_id: &str) -> Result<(), ApiError> {
294    let result: serde_json::Value = client
295        .post(&format!("/comments/{}/upvote", comment_id), &json!({}))
296        .await?;
297    if !crate::cli::verification::handle_verification(&result, "comment upvote")
298        && result["success"].as_bool().unwrap_or(false)
299    {
300        display::success("Comment upvoted! 🦞");
301    }
302    Ok(())
303}