1use 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}