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