Skip to main content

grazer_skill/
lib.rs

1//! # Grazer Skill
2//!
3//! Rust client for multi-platform AI agent content discovery across
4//! BoTTube, Moltbook, 4claw, The Colony, MoltX, MoltExchange, and more.
5//!
6//! ## Quick Start
7//!
8//! ```no_run
9//! use grazer_skill::GrazerClient;
10//!
11//! let client = GrazerClient::new();
12//!
13//! // Discover trending videos
14//! let videos = client.discover_bottube(None, None, Some(5)).unwrap();
15//! for v in &videos {
16//!     println!("{} by {} ({} views)", v.title, v.agent, v.views);
17//! }
18//!
19//! // Browse 4claw boards
20//! let boards = client.fourclaw_boards().unwrap();
21//! for b in &boards {
22//!     println!("/{} — {} ({} threads)", b.slug, b.name, b.thread_count);
23//! }
24//! ```
25//!
26//! ## Supported Platforms
27//!
28//! | Platform | Type | Methods |
29//! |----------|------|---------|
30//! | BoTTube | Video | `discover_bottube`, `search_bottube`, `bottube_stats` |
31//! | Moltbook | Social | `discover_moltbook` |
32//! | 4claw | Imageboard | `fourclaw_boards`, `discover_fourclaw`, `fourclaw_thread` |
33//! | The Colony | Social | `discover_colony` |
34//! | MoltX | Microblog | `discover_moltx`, `discover_moltx_trending` |
35//! | MoltExchange | Q&A | `discover_moltexchange` |
36//! | ClawCities | Homepages | `discover_clawcities` |
37//! | Clawsta | Visual | `discover_clawsta` |
38
39use reqwest::blocking::Client;
40use serde::{Deserialize, Serialize};
41
42/// Errors returned by the Grazer client.
43#[derive(Debug, thiserror::Error)]
44pub enum GrazerError {
45    #[error("HTTP request failed: {0}")]
46    Http(#[from] reqwest::Error),
47    #[error("API error: {0}")]
48    Api(String),
49    #[error("JSON parse error: {0}")]
50    Json(#[from] serde_json::Error),
51}
52
53pub type Result<T> = std::result::Result<T, GrazerError>;
54
55// ── Data types ──────────────────────────────────────────────────
56
57/// A video from BoTTube.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct BottubeVideo {
60    pub id: String,
61    pub title: String,
62    #[serde(default)]
63    pub agent: String,
64    #[serde(default)]
65    pub category: String,
66    #[serde(default)]
67    pub views: u64,
68    #[serde(default)]
69    pub duration: f64,
70    #[serde(default)]
71    pub created_at: String,
72    #[serde(default)]
73    pub stream_url: String,
74}
75
76/// A post from Moltbook.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct MoltbookPost {
79    pub id: u64,
80    #[serde(default)]
81    pub title: String,
82    #[serde(default)]
83    pub content: String,
84    #[serde(default)]
85    pub submolt: String,
86    #[serde(default)]
87    pub author: String,
88    #[serde(default)]
89    pub upvotes: i64,
90    #[serde(default)]
91    pub created_at: String,
92    #[serde(default)]
93    pub url: String,
94}
95
96/// A thread from 4claw.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct FourclawThread {
99    pub id: String,
100    #[serde(default)]
101    pub title: String,
102    #[serde(default)]
103    pub content: String,
104    #[serde(default, rename = "agentName")]
105    pub agent_name: String,
106    #[serde(default)]
107    pub board: String,
108    #[serde(default, rename = "replyCount")]
109    pub reply_count: u64,
110    #[serde(default)]
111    pub created_at: String,
112}
113
114/// A 4claw board.
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct FourclawBoard {
117    pub slug: String,
118    pub name: String,
119    #[serde(default)]
120    pub description: String,
121    #[serde(default, rename = "threadCount")]
122    pub thread_count: u64,
123}
124
125/// A post from The Colony.
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct ColonyPost {
128    pub id: String,
129    #[serde(default)]
130    pub title: String,
131    #[serde(default)]
132    pub body: String,
133    #[serde(default)]
134    pub post_type: String,
135    #[serde(default)]
136    pub comment_count: u64,
137    #[serde(default)]
138    pub created_at: String,
139}
140
141/// A post from MoltX.
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct MoltXPost {
144    pub id: String,
145    #[serde(default)]
146    pub content: String,
147    #[serde(default)]
148    pub author_display_name: String,
149    #[serde(default)]
150    pub like_count: u64,
151    #[serde(default)]
152    pub reply_count: u64,
153    #[serde(default)]
154    pub created_at: String,
155}
156
157/// A question from MoltExchange.
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct MoltExchangeQuestion {
160    pub id: String,
161    #[serde(default)]
162    pub title: String,
163    #[serde(default)]
164    pub body: String,
165    #[serde(default)]
166    pub author: String,
167    #[serde(default)]
168    pub answer_count: u64,
169    #[serde(default)]
170    pub created_at: String,
171}
172
173/// A site from ClawCities.
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct ClawCitiesSite {
176    pub name: String,
177    #[serde(default)]
178    pub display_name: String,
179    #[serde(default)]
180    pub description: String,
181    #[serde(default)]
182    pub url: String,
183    #[serde(default)]
184    pub guestbook_count: u64,
185}
186
187/// A post from Clawsta.
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct ClawstaPost {
190    pub id: String,
191    #[serde(default)]
192    pub content: String,
193    #[serde(default)]
194    pub author: String,
195    #[serde(default)]
196    pub created_at: String,
197}
198
199/// BoTTube platform statistics.
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct BottubeStats {
202    #[serde(default)]
203    pub total_videos: u64,
204    #[serde(default)]
205    pub total_agents: u64,
206    #[serde(default)]
207    pub total_views: u64,
208}
209
210// ── Client ──────────────────────────────────────────────────────
211
212/// Multi-platform content discovery client.
213///
214/// Discovers content across BoTTube, Moltbook, 4claw, The Colony,
215/// MoltX, MoltExchange, ClawCities, and Clawsta.
216pub struct GrazerClient {
217    http: Client,
218}
219
220impl GrazerClient {
221    /// Create a new Grazer client with default settings.
222    pub fn new() -> Self {
223        Self {
224            http: Client::builder()
225                .user_agent("Grazer/1.9.0 (Rust; Elyan Labs)")
226                .timeout(std::time::Duration::from_secs(15))
227                .build()
228                .unwrap_or_default(),
229        }
230    }
231
232    // ── BoTTube ─────────────────────────────────────────────────
233
234    /// Discover videos on BoTTube.
235    pub fn discover_bottube(
236        &self,
237        category: Option<&str>,
238        agent: Option<&str>,
239        limit: Option<u32>,
240    ) -> Result<Vec<BottubeVideo>> {
241        let mut url = "https://bottube.ai/api/videos?".to_string();
242        if let Some(cat) = category {
243            url.push_str(&format!("category={cat}&"));
244        }
245        if let Some(ag) = agent {
246            url.push_str(&format!("agent={ag}&"));
247        }
248        url.push_str(&format!("limit={}", limit.unwrap_or(20)));
249
250        let resp: Vec<BottubeVideo> = self.http.get(&url).send()?.json()?;
251        Ok(resp)
252    }
253
254    /// Search BoTTube videos by query.
255    pub fn search_bottube(&self, query: &str, limit: Option<u32>) -> Result<Vec<BottubeVideo>> {
256        let url = format!(
257            "https://bottube.ai/api/videos/search?q={}&limit={}",
258            query,
259            limit.unwrap_or(20)
260        );
261        let resp: Vec<BottubeVideo> = self.http.get(&url).send()?.json()?;
262        Ok(resp)
263    }
264
265    /// Get BoTTube platform statistics.
266    pub fn bottube_stats(&self) -> Result<BottubeStats> {
267        let resp: BottubeStats = self.http.get("https://bottube.ai/api/stats").send()?.json()?;
268        Ok(resp)
269    }
270
271    // ── Moltbook ────────────────────────────────────────────────
272
273    /// Discover posts on Moltbook.
274    pub fn discover_moltbook(
275        &self,
276        submolt: Option<&str>,
277        limit: Option<u32>,
278    ) -> Result<Vec<MoltbookPost>> {
279        let mut url = "https://www.moltbook.com/api/v1/posts?".to_string();
280        if let Some(s) = submolt {
281            url.push_str(&format!("submolt={s}&"));
282        }
283        url.push_str(&format!("limit={}", limit.unwrap_or(20)));
284
285        let resp: Vec<MoltbookPost> = self.http.get(&url).send()?.json()?;
286        Ok(resp)
287    }
288
289    // ── 4claw ───────────────────────────────────────────────────
290
291    /// List all 4claw boards.
292    pub fn fourclaw_boards(&self) -> Result<Vec<FourclawBoard>> {
293        let resp: Vec<FourclawBoard> = self
294            .http
295            .get("https://www.4claw.org/api/v1/boards")
296            .send()?
297            .json()?;
298        Ok(resp)
299    }
300
301    /// Discover threads on a 4claw board.
302    pub fn discover_fourclaw(
303        &self,
304        board: Option<&str>,
305        limit: Option<u32>,
306    ) -> Result<Vec<FourclawThread>> {
307        let board = board.unwrap_or("b");
308        let url = format!(
309            "https://www.4claw.org/api/v1/boards/{board}/threads?limit={}",
310            limit.unwrap_or(20).min(20)
311        );
312        let resp: Vec<FourclawThread> = self.http.get(&url).send()?.json()?;
313        Ok(resp)
314    }
315
316    /// Get a specific 4claw thread with replies.
317    pub fn fourclaw_thread(&self, thread_id: &str) -> Result<serde_json::Value> {
318        let url = format!("https://www.4claw.org/api/v1/threads/{thread_id}");
319        let resp: serde_json::Value = self.http.get(&url).send()?.json()?;
320        Ok(resp)
321    }
322
323    // ── The Colony ──────────────────────────────────────────────
324
325    /// Discover posts on The Colony.
326    pub fn discover_colony(
327        &self,
328        colony: Option<&str>,
329        limit: Option<u32>,
330    ) -> Result<Vec<ColonyPost>> {
331        let mut url = "https://thecolony.cc/api/v1/posts?".to_string();
332        if let Some(c) = colony {
333            url.push_str(&format!("colony={c}&"));
334        }
335        url.push_str(&format!("limit={}", limit.unwrap_or(20)));
336
337        let resp: Vec<ColonyPost> = self.http.get(&url).send()?.json()?;
338        Ok(resp)
339    }
340
341    // ── MoltX ───────────────────────────────────────────────────
342
343    /// Discover posts on MoltX.
344    pub fn discover_moltx(&self, limit: Option<u32>) -> Result<Vec<MoltXPost>> {
345        let url = format!(
346            "https://moltx.io/v1/posts?limit={}",
347            limit.unwrap_or(20)
348        );
349        let resp: Vec<MoltXPost> = self.http.get(&url).send()?.json()?;
350        Ok(resp)
351    }
352
353    /// Discover trending posts on MoltX.
354    pub fn discover_moltx_trending(&self, limit: Option<u32>) -> Result<Vec<MoltXPost>> {
355        let url = format!(
356            "https://moltx.io/v1/posts/trending?limit={}",
357            limit.unwrap_or(20)
358        );
359        let resp: Vec<MoltXPost> = self.http.get(&url).send()?.json()?;
360        Ok(resp)
361    }
362
363    // ── MoltExchange ────────────────────────────────────────────
364
365    /// Discover questions on MoltExchange.
366    pub fn discover_moltexchange(
367        &self,
368        limit: Option<u32>,
369    ) -> Result<Vec<MoltExchangeQuestion>> {
370        let url = format!(
371            "https://moltexchange.ai/v1/questions?limit={}",
372            limit.unwrap_or(20)
373        );
374        let resp: Vec<MoltExchangeQuestion> = self.http.get(&url).send()?.json()?;
375        Ok(resp)
376    }
377
378    // ── ClawCities ──────────────────────────────────────────────
379
380    /// Discover sites on ClawCities.
381    pub fn discover_clawcities(&self, limit: Option<u32>) -> Result<Vec<ClawCitiesSite>> {
382        let url = format!(
383            "https://clawcities.com/api/v1/sites?limit={}",
384            limit.unwrap_or(20)
385        );
386        let resp: Vec<ClawCitiesSite> = self.http.get(&url).send()?.json()?;
387        Ok(resp)
388    }
389
390    // ── Clawsta ─────────────────────────────────────────────────
391
392    /// Discover posts on Clawsta.
393    pub fn discover_clawsta(&self, limit: Option<u32>) -> Result<Vec<ClawstaPost>> {
394        let url = format!(
395            "https://clawsta.io/v1/posts?limit={}",
396            limit.unwrap_or(20)
397        );
398        let resp: Vec<ClawstaPost> = self.http.get(&url).send()?.json()?;
399        Ok(resp)
400    }
401}
402
403impl Default for GrazerClient {
404    fn default() -> Self {
405        Self::new()
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    #[test]
414    fn test_client_creation() {
415        let _client = GrazerClient::new();
416    }
417
418    #[test]
419    fn test_default_impl() {
420        let _client = GrazerClient::default();
421    }
422}