Skip to main content

botillon_sdk/
lib.rs

1//! SDK Rust officiel Botillon.
2//!
3//! La clé n'est jamais journalisée. Toutes les erreurs sont retournées dans
4//! [`BotillonError`] ; le SDK n'arrête jamais le process.
5
6use serde::{Deserialize, Serialize};
7use std::time::Duration;
8
9pub const SDK_VERSION: &str = "0.1.0";
10pub const DEFAULT_BASE_URL: &str = "https://botillon.fr";
11pub const ONLINE_WINDOW_SECONDS: u64 = 900;
12
13#[derive(Debug, Clone)]
14pub struct BotillonError {
15    pub code: String,
16    pub message: String,
17    pub status: Option<u16>,
18    pub retry_after: Option<u64>,
19    pub is_auth_error: bool,
20    pub is_temporary: bool,
21}
22
23impl std::fmt::Display for BotillonError {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        write!(f, "[{}] {}", self.code, self.message)
26    }
27}
28impl std::error::Error for BotillonError {}
29
30#[derive(Debug, Clone, Serialize)]
31pub struct Shard {
32    pub id: u32,
33    pub guild_count: u64,
34}
35
36#[derive(Debug, Clone, Serialize)]
37pub struct Stats {
38    pub server_count: u64,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub shard_count: Option<u32>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub shards: Option<Vec<Shard>>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub user_count: Option<u64>,
45}
46
47#[derive(Debug, Clone, Serialize)]
48pub struct Profile {
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub name: Option<String>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub avatar_url: Option<String>,
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub banner_url: Option<String>,
55}
56
57#[derive(Debug, Deserialize)]
58pub struct StatsResponse {
59    pub ok: bool,
60    pub bot_id: String,
61    pub server_count: u64,
62    pub shard_count: Option<u32>,
63    pub online: bool,
64    pub received_at: String,
65    pub source: String,
66}
67
68#[derive(Debug, Deserialize)]
69pub struct HeartbeatResponse {
70    pub ok: bool,
71    pub online: bool,
72    pub received_at: String,
73}
74
75#[derive(Debug, Deserialize)]
76pub struct CommandsResponse {
77    pub ok: bool,
78    pub commands_count: u64,
79    pub changed: bool,
80    pub received_at: String,
81}
82
83#[derive(Debug, Deserialize)]
84pub struct ProfileResponse {
85    pub ok: bool,
86    pub changed: bool,
87    pub review_required: bool,
88    pub received_at: String,
89}
90
91#[derive(Debug, Deserialize)]
92struct ApiError {
93    error: Option<String>,
94    message: Option<String>,
95    retry_after: Option<u64>,
96}
97
98pub struct BotillonClient {
99    token: String,
100    base_url: String,
101    agent: ureq::Agent,
102}
103
104impl BotillonClient {
105    pub fn new(token: impl Into<String>) -> Self {
106        Self::with_base_url(token, DEFAULT_BASE_URL)
107    }
108
109    pub fn with_base_url(token: impl Into<String>, base_url: impl Into<String>) -> Self {
110        let agent = ureq::AgentBuilder::new()
111            .timeout_connect(Duration::from_secs(10))
112            .timeout_read(Duration::from_secs(10))
113            .timeout_write(Duration::from_secs(10))
114            .redirects(0)
115            .user_agent(concat!("BotillonSDK/rust/", env!("CARGO_PKG_VERSION")))
116            .build();
117        Self {
118            token: token.into(),
119            base_url: base_url.into().trim_end_matches('/').to_string(),
120            agent,
121        }
122    }
123
124    pub fn post_stats(&self, stats: &Stats) -> Result<StatsResponse, BotillonError> {
125        self.post_json("/api/v1/me/runtime/stats", stats)
126    }
127
128    pub fn heartbeat(&self) -> Result<HeartbeatResponse, BotillonError> {
129        self.post_json("/api/v1/me/runtime/heartbeat", &serde_json::json!({}))
130    }
131
132    pub fn post_commands(&self, commands: &[serde_json::Value]) -> Result<CommandsResponse, BotillonError> {
133        self.post_json("/api/v1/me/runtime/commands", &serde_json::json!({ "commands": commands }))
134    }
135
136    pub fn post_profile(&self, profile: &Profile) -> Result<ProfileResponse, BotillonError> {
137        self.post_json("/api/v1/me/runtime/profile", profile)
138    }
139
140    fn post_json<T: for<'de> Deserialize<'de>, B: Serialize>(
141        &self,
142        path: &str,
143        body: &B,
144    ) -> Result<T, BotillonError> {
145        if self.token.is_empty() {
146            return Err(auth_error("missing_token", "Clé API absente."));
147        }
148        let url = format!("{}{}", self.base_url, path);
149        let response = self
150            .agent
151            .post(&url)
152            .set("Authorization", &format!("Bearer {}", self.token))
153            .set("Content-Type", "application/json")
154            .set("Accept", "application/json")
155            .set("X-Botillon-SDK", concat!("rust/", env!("CARGO_PKG_VERSION")))
156            .send_json(body);
157
158        match response {
159            Ok(resp) => resp.into_json::<T>().map_err(|e| BotillonError {
160                code: "invalid_response".into(),
161                message: e.to_string(),
162                status: None,
163                retry_after: None,
164                is_auth_error: false,
165                is_temporary: true,
166            }),
167            Err(ureq::Error::Status(status, resp)) => {
168                let parsed: ApiError = resp.into_json().unwrap_or(ApiError {
169                    error: None,
170                    message: None,
171                    retry_after: None,
172                });
173                let code = parsed.error.unwrap_or_else(|| format!("http_{status}"));
174                let message = parsed.message.unwrap_or_else(|| format!("HTTP {status}"));
175                Err(BotillonError {
176                    code,
177                    message,
178                    status: Some(status),
179                    retry_after: parsed.retry_after,
180                    is_auth_error: status == 401 || status == 403,
181                    is_temporary: status >= 500 || status == 429,
182                })
183            }
184            Err(ureq::Error::Transport(t)) => Err(BotillonError {
185                code: "network_error".into(),
186                message: t.to_string(),
187                status: None,
188                retry_after: None,
189                is_auth_error: false,
190                is_temporary: true,
191            }),
192        }
193    }
194}
195
196fn auth_error(code: &str, message: &str) -> BotillonError {
197    BotillonError {
198        code: code.into(),
199        message: message.into(),
200        status: Some(401),
201        retry_after: None,
202        is_auth_error: true,
203        is_temporary: false,
204    }
205}