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