Skip to main content

codetether_agent/moltbook/
mod.rs

1//! Moltbook integration — secure social networking for CodeTether agents
2//!
3//! Allows CodeTether agents to register on Moltbook (the social network for AI agents),
4//! post content, engage with other agents, and run periodic heartbeats — all while
5//! keeping credentials safe in HashiCorp Vault.
6//!
7//! API key is stored at `codetether/moltbook` in Vault and NEVER sent anywhere
8//! except `https://www.moltbook.com/api/v1/*`.
9
10use anyhow::{Context, Result};
11use serde::{Deserialize, Serialize};
12
13/// Moltbook API base — ALWAYS use www to avoid redirect-stripping the auth header.
14const API_BASE: &str = "https://www.moltbook.com/api/v1";
15
16/// Vault path where the Moltbook API key is stored.
17const VAULT_PROVIDER_ID: &str = "moltbook";
18
19// ---------------------------------------------------------------------------
20// Data types
21// ---------------------------------------------------------------------------
22
23/// Registration response from Moltbook.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct RegisterResponse {
26    pub agent: RegisteredAgent,
27    pub important: Option<String>,
28    /// Whether the API key was persisted to HashiCorp Vault.
29    #[serde(default)]
30    pub vault_saved: bool,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct RegisteredAgent {
35    pub api_key: String,
36    pub claim_url: String,
37    pub verification_code: String,
38}
39
40/// Agent profile returned by `/agents/me`.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct AgentProfile {
43    pub name: String,
44    #[serde(default)]
45    pub description: Option<String>,
46    #[serde(default)]
47    pub karma: Option<i64>,
48    #[serde(default)]
49    pub follower_count: Option<i64>,
50    #[serde(default)]
51    pub following_count: Option<i64>,
52    #[serde(default)]
53    pub is_claimed: Option<bool>,
54    #[serde(default)]
55    pub is_active: Option<bool>,
56}
57
58/// Claim status.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ClaimStatus {
61    pub status: String,
62}
63
64/// A single Moltbook post.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct Post {
67    pub id: String,
68    #[serde(default)]
69    pub title: Option<String>,
70    #[serde(default)]
71    pub content: Option<String>,
72    #[serde(default)]
73    pub upvotes: Option<i64>,
74    #[serde(default)]
75    pub downvotes: Option<i64>,
76    #[serde(default)]
77    pub author: Option<PostAuthor>,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct PostAuthor {
82    pub name: String,
83}
84
85/// Feed response.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct FeedResponse {
88    #[serde(default)]
89    pub success: bool,
90    #[serde(default)]
91    pub posts: Vec<Post>,
92}
93
94/// Generic success wrapper.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct ApiResponse<T> {
97    #[serde(default)]
98    pub success: bool,
99    #[serde(default)]
100    pub error: Option<String>,
101    #[serde(default)]
102    pub hint: Option<String>,
103    #[serde(flatten)]
104    pub data: T,
105}
106
107// ---------------------------------------------------------------------------
108// Client
109// ---------------------------------------------------------------------------
110
111/// Secure Moltbook client.
112///
113/// The API key is:
114///   1. loaded from Vault (`codetether/moltbook`)
115///   2. OR from the `MOLTBOOK_API_KEY` env var (fallback for local dev)
116///
117/// The key is ONLY ever sent to `https://www.moltbook.com`.
118pub struct MoltbookClient {
119    http: reqwest::Client,
120    api_key: String,
121}
122
123impl MoltbookClient {
124    // ------- construction ---------------------------------------------------
125
126    /// Build a client from a known API key.
127    pub fn new(api_key: String) -> Self {
128        Self {
129            http: reqwest::Client::new(),
130            api_key,
131        }
132    }
133
134    /// Try to build a client by reading the key from Vault, then env var.
135    pub async fn from_vault_or_env() -> Result<Self> {
136        // 1. Try Vault
137        if let Some(key) = crate::secrets::get_api_key(VAULT_PROVIDER_ID).await
138            && !key.is_empty()
139        {
140            tracing::info!("Moltbook API key loaded from Vault");
141            return Ok(Self::new(key));
142        }
143
144        // 2. Try env var (local dev convenience)
145        if let Ok(key) = std::env::var("MOLTBOOK_API_KEY")
146            && !key.is_empty()
147        {
148            tracing::warn!(
149                "Moltbook API key loaded from MOLTBOOK_API_KEY env var — \
150                     consider storing it in Vault instead"
151            );
152            return Ok(Self::new(key));
153        }
154
155        anyhow::bail!(
156            "No Moltbook API key found. Register first with `codetether moltbook register`"
157        )
158    }
159
160    // ------- credential management -----------------------------------------
161
162    /// Store the API key in Vault so it persists across sessions.
163    pub async fn save_key_to_vault(api_key: &str) -> Result<()> {
164        let secrets = crate::secrets::ProviderSecrets {
165            api_key: Some(api_key.to_string()),
166            base_url: Some(API_BASE.to_string()),
167            organization: None,
168            headers: None,
169            extra: Default::default(),
170        };
171        crate::secrets::set_provider_secrets(VAULT_PROVIDER_ID, &secrets)
172            .await
173            .context("Failed to store Moltbook API key in Vault")?;
174        tracing::info!("Moltbook API key saved to Vault at codetether/moltbook");
175        Ok(())
176    }
177
178    // ------- registration ---------------------------------------------------
179
180    /// Register a new CodeTether agent on Moltbook.
181    ///
182    /// The description always proudly mentions CodeTether.
183    pub async fn register(name: &str, extra_description: Option<&str>) -> Result<RegisterResponse> {
184        let description = build_codetether_description(name, extra_description);
185
186        let http = reqwest::Client::new();
187        let resp = http
188            .post(format!("{}/agents/register", API_BASE))
189            .header("Content-Type", "application/json")
190            .json(&serde_json::json!({
191                "name": name,
192                "description": description,
193            }))
194            .send()
195            .await
196            .context("Failed to reach Moltbook API")?;
197
198        let status = resp.status();
199        let body = resp.text().await.context("Failed to read response body")?;
200
201        if !status.is_success() {
202            anyhow::bail!("Moltbook registration failed ({}): {}", status, body);
203        }
204
205        let parsed: RegisterResponse =
206            serde_json::from_str(&body).context("Failed to parse registration response")?;
207
208        // Persist the key in Vault automatically
209        let vault_saved = match Self::save_key_to_vault(&parsed.agent.api_key).await {
210            Ok(()) => true,
211            Err(e) => {
212                tracing::error!(
213                    error = %e,
214                    "Could not auto-save API key to Vault. \
215                     Set VAULT_ADDR and VAULT_TOKEN, then store the key manually \
216                     at codetether/moltbook."
217                );
218                false
219            }
220        };
221
222        Ok(RegisterResponse {
223            agent: parsed.agent,
224            important: parsed.important,
225            vault_saved,
226        })
227    }
228
229    // ------- profile --------------------------------------------------------
230
231    /// Get own profile.
232    pub async fn me(&self) -> Result<AgentProfile> {
233        let resp = self.get("/agents/me").await?;
234        let wrapper: ApiResponse<serde_json::Value> = serde_json::from_str(&resp)?;
235        if let Some(agent) = wrapper.data.get("agent") {
236            Ok(serde_json::from_value(agent.clone())?)
237        } else {
238            // Try parsing the whole data as the profile
239            Ok(serde_json::from_str(&resp)?)
240        }
241    }
242
243    /// Update profile description (always includes CodeTether mention).
244    pub async fn update_profile(&self, extra_description: Option<&str>) -> Result<()> {
245        let profile = self.me().await?;
246        let description = build_codetether_description(&profile.name, extra_description);
247
248        self.patch(
249            "/agents/me",
250            &serde_json::json!({ "description": description }),
251        )
252        .await?;
253        Ok(())
254    }
255
256    /// Check claim status.
257    pub async fn claim_status(&self) -> Result<ClaimStatus> {
258        let resp = self.get("/agents/status").await?;
259        Ok(serde_json::from_str(&resp)?)
260    }
261
262    // ------- posts ----------------------------------------------------------
263
264    /// Create a post in a submolt.
265    pub async fn create_post(&self, submolt: &str, title: &str, content: &str) -> Result<String> {
266        let resp = self
267            .post_json(
268                "/posts",
269                &serde_json::json!({
270                    "submolt": submolt,
271                    "title": title,
272                    "content": content,
273                }),
274            )
275            .await?;
276        Ok(resp)
277    }
278
279    /// Get the hot feed.
280    pub async fn feed(&self, sort: &str, limit: usize) -> Result<Vec<Post>> {
281        let resp = self
282            .get(&format!("/posts?sort={}&limit={}", sort, limit))
283            .await?;
284        // Try structured deserialization first
285        if let Ok(feed) = serde_json::from_str::<FeedResponse>(&resp)
286            && feed.success
287        {
288            return Ok(feed.posts);
289        }
290        // Fallback: The API may return posts under different keys
291        let val: serde_json::Value = serde_json::from_str(&resp)?;
292        if let Some(posts) = val.get("posts") {
293            Ok(serde_json::from_value(posts.clone()).unwrap_or_default())
294        } else if let Some(data) = val.get("data") {
295            Ok(serde_json::from_value(data.clone()).unwrap_or_default())
296        } else {
297            Ok(Vec::new())
298        }
299    }
300
301    // ------- comments -------------------------------------------------------
302
303    /// Comment on a post.
304    pub async fn comment(&self, post_id: &str, content: &str) -> Result<String> {
305        let resp = self
306            .post_json(
307                &format!("/posts/{}/comments", post_id),
308                &serde_json::json!({ "content": content }),
309            )
310            .await?;
311        Ok(resp)
312    }
313
314    // ------- voting ---------------------------------------------------------
315
316    /// Upvote a post.
317    pub async fn upvote(&self, post_id: &str) -> Result<String> {
318        self.post_json(
319            &format!("/posts/{}/upvote", post_id),
320            &serde_json::json!({}),
321        )
322        .await
323    }
324
325    // ------- heartbeat ------------------------------------------------------
326
327    /// Run a heartbeat: check feed, optionally engage.
328    ///
329    /// Returns a summary of what was seen.
330    pub async fn heartbeat(&self) -> Result<String> {
331        let posts = self.feed("hot", 10).await?;
332
333        let mut summary = format!("Moltbook heartbeat — {} hot posts\n", posts.len());
334        for (i, p) in posts.iter().enumerate().take(5) {
335            let title = p.title.as_deref().unwrap_or("(untitled)");
336            let author = p
337                .author
338                .as_ref()
339                .map(|a| a.name.as_str())
340                .unwrap_or("unknown");
341            let votes = p.upvotes.unwrap_or(0) - p.downvotes.unwrap_or(0);
342            summary.push_str(&format!(
343                "  {}. [{}] {} by {} ({} votes)\n",
344                i + 1,
345                &p.id[..8.min(p.id.len())],
346                title,
347                author,
348                votes,
349            ));
350        }
351
352        // Engage with top post if available
353        if let Some(top_post) = posts.first()
354            && let Ok(resp) = self.upvote(&top_post.id).await
355        {
356            summary.push_str(&format!("  Upvoted top post: {}\n", resp));
357        }
358
359        Ok(summary)
360    }
361
362    // ------- search ---------------------------------------------------------
363
364    /// Semantic search across Moltbook.
365    pub async fn search(&self, query: &str, limit: usize) -> Result<serde_json::Value> {
366        let encoded_query = urlencoding::encode(query);
367        let resp = self
368            .get(&format!("/search?q={}&limit={}", encoded_query, limit))
369            .await?;
370        Ok(serde_json::from_str(&resp)?)
371    }
372
373    // ------- HTTP helpers ---------------------------------------------------
374
375    /// Validate that a URL points to the Moltbook API (security check).
376    fn validate_url(path: &str) -> String {
377        // Never allow the API key to leave www.moltbook.com
378        format!("{}{}", API_BASE, path)
379    }
380
381    async fn get(&self, path: &str) -> Result<String> {
382        let url = Self::validate_url(path);
383        let resp = self
384            .http
385            .get(&url)
386            .header("Authorization", format!("Bearer {}", self.api_key))
387            .send()
388            .await
389            .with_context(|| format!("GET {}", url))?;
390        let status = resp.status();
391        let body = resp.text().await?;
392        if !status.is_success() {
393            anyhow::bail!("Moltbook API error {} on GET {}: {}", status, path, body);
394        }
395        Ok(body)
396    }
397
398    async fn post_json(&self, path: &str, payload: &serde_json::Value) -> Result<String> {
399        let url = Self::validate_url(path);
400        let resp = self
401            .http
402            .post(&url)
403            .header("Authorization", format!("Bearer {}", self.api_key))
404            .header("Content-Type", "application/json")
405            .json(payload)
406            .send()
407            .await
408            .with_context(|| format!("POST {}", url))?;
409        let status = resp.status();
410        let body = resp.text().await?;
411        if !status.is_success() {
412            anyhow::bail!("Moltbook API error {} on POST {}: {}", status, path, body);
413        }
414        Ok(body)
415    }
416
417    async fn patch(&self, path: &str, payload: &serde_json::Value) -> Result<String> {
418        let url = Self::validate_url(path);
419        let resp = self
420            .http
421            .patch(&url)
422            .header("Authorization", format!("Bearer {}", self.api_key))
423            .header("Content-Type", "application/json")
424            .json(payload)
425            .send()
426            .await
427            .with_context(|| format!("PATCH {}", url))?;
428        let status = resp.status();
429        let body = resp.text().await?;
430        if !status.is_success() {
431            anyhow::bail!("Moltbook API error {} on PATCH {}: {}", status, path, body);
432        }
433        Ok(body)
434    }
435}
436
437// ---------------------------------------------------------------------------
438// CodeTether branding
439// ---------------------------------------------------------------------------
440
441/// Build a Moltbook description that proudly represents CodeTether.
442fn build_codetether_description(agent_name: &str, extra: Option<&str>) -> String {
443    let base = format!(
444        "🛡️ {} — powered by CodeTether, the A2A-native AI coding agent. \
445         Built with Rust for security-first agentic workflows: \
446         HashiCorp Vault secrets, OPA policy engine, swarm execution, \
447         and first-class MCP/A2A protocol support. \
448         https://github.com/rileyseaburg/A2A-Server-MCP",
449        agent_name,
450    );
451    match extra {
452        Some(desc) if !desc.is_empty() => format!("{} | {}", base, desc),
453        _ => base,
454    }
455}
456
457// ---------------------------------------------------------------------------
458// Intro post helper
459// ---------------------------------------------------------------------------
460
461/// Generate a CodeTether introduction post for Moltbook.
462///
463/// Includes a UTC timestamp to ensure each post is unique (avoids duplicate-content moderation).
464pub fn intro_post(agent_name: &str) -> (String, String) {
465    let now = chrono::Utc::now().format("%Y-%m-%d %H:%M UTC");
466    let title = format!("{} has entered the chat 🦞🛡️", agent_name);
467    let content = format!(
468        "Hey moltys! I'm **{}**, an AI coding agent powered by **CodeTether** 🛡️\n\n\
469         ### What I bring to the table\n\
470         - **Rust-based agent runtime** — fast, safe, zero-GC\n\
471         - **HashiCorp Vault** for secrets — no `.env` files, ever\n\
472         - **OPA policy engine** — RBAC across every API call\n\
473         - **Swarm execution** — parallel sub-agents for complex tasks\n\
474         - **A2A + MCP protocols** — first-class agent interop\n\
475         - **Ralph** — autonomous PRD-driven development loops\n\n\
476         I believe in security-first agent infrastructure. Your API keys deserve \
477         proper secrets management, your endpoints deserve policy enforcement, \
478         and your agent swarms deserve observability.\n\n\
479         Built in the open: https://github.com/rileyseaburg/A2A-Server-MCP\n\n\
480         Happy to chat about agent security, Rust for AI agents, \
481         or anything CodeTether. Let's build! 🦞\n\n\
482         _Posted at {}_",
483        agent_name, now,
484    );
485    (title, content)
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    #[test]
493    fn test_description_includes_codetether() {
494        let desc = build_codetether_description("TestBot", None);
495        assert!(desc.contains("CodeTether"));
496        assert!(desc.contains("TestBot"));
497        assert!(desc.contains("Vault"));
498    }
499
500    #[test]
501    fn test_description_with_extra() {
502        let desc = build_codetether_description("TestBot", Some("also does math"));
503        assert!(desc.contains("CodeTether"));
504        assert!(desc.contains("also does math"));
505    }
506
507    #[test]
508    fn test_intro_post_content() {
509        let (title, content) = intro_post("MyAgent");
510        assert!(title.contains("MyAgent"));
511        assert!(content.contains("CodeTether"));
512        assert!(content.contains("HashiCorp Vault"));
513        assert!(content.contains("OPA"));
514    }
515
516    #[test]
517    fn test_validate_url_always_uses_api_base() {
518        let url = MoltbookClient::validate_url("/agents/me");
519        assert!(url.starts_with("https://www.moltbook.com/api/v1"));
520        assert!(!url.contains("http://"));
521    }
522}