use serde::{Deserialize, Serialize};
use std::time::Duration;
pub const SDK_VERSION: &str = "0.1.0";
pub const DEFAULT_BASE_URL: &str = "https://botillon.fr";
pub const ONLINE_WINDOW_SECONDS: u64 = 900;
#[derive(Debug, Clone)]
pub struct BotillonError {
pub code: String,
pub message: String,
pub status: Option<u16>,
pub retry_after: Option<u64>,
pub is_auth_error: bool,
pub is_temporary: bool,
}
impl std::fmt::Display for BotillonError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[{}] {}", self.code, self.message)
}
}
impl std::error::Error for BotillonError {}
#[derive(Debug, Clone, Serialize)]
pub struct Shard {
pub id: u32,
pub guild_count: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct Stats {
pub server_count: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub shard_count: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub shards: Option<Vec<Shard>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_count: Option<u64>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Profile {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub banner_url: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct StatsResponse {
pub ok: bool,
pub bot_id: String,
pub server_count: u64,
pub shard_count: Option<u32>,
pub online: bool,
pub received_at: String,
pub source: String,
}
#[derive(Debug, Deserialize)]
pub struct HeartbeatResponse {
pub ok: bool,
pub online: bool,
pub received_at: String,
}
#[derive(Debug, Deserialize)]
pub struct CommandsResponse {
pub ok: bool,
pub commands_count: u64,
pub changed: bool,
pub received_at: String,
}
#[derive(Debug, Deserialize)]
pub struct ProfileResponse {
pub ok: bool,
pub changed: bool,
pub review_required: bool,
pub received_at: String,
}
#[derive(Debug, Deserialize)]
struct ApiError {
error: Option<String>,
message: Option<String>,
retry_after: Option<u64>,
}
pub struct BotillonClient {
token: String,
base_url: String,
agent: ureq::Agent,
}
impl BotillonClient {
pub fn new(token: impl Into<String>) -> Self {
Self::with_base_url(token, DEFAULT_BASE_URL)
}
pub fn with_base_url(token: impl Into<String>, base_url: impl Into<String>) -> Self {
let agent = ureq::AgentBuilder::new()
.timeout_connect(Duration::from_secs(10))
.timeout_read(Duration::from_secs(10))
.timeout_write(Duration::from_secs(10))
.redirects(0)
.user_agent(concat!("BotillonSDK/rust/", env!("CARGO_PKG_VERSION")))
.build();
Self {
token: token.into(),
base_url: base_url.into().trim_end_matches('/').to_string(),
agent,
}
}
pub fn post_stats(&self, stats: &Stats) -> Result<StatsResponse, BotillonError> {
self.post_json("/api/v1/me/runtime/stats", stats)
}
pub fn heartbeat(&self) -> Result<HeartbeatResponse, BotillonError> {
self.post_json("/api/v1/me/runtime/heartbeat", &serde_json::json!({}))
}
pub fn post_commands(&self, commands: &[serde_json::Value]) -> Result<CommandsResponse, BotillonError> {
self.post_json("/api/v1/me/runtime/commands", &serde_json::json!({ "commands": commands }))
}
pub fn post_profile(&self, profile: &Profile) -> Result<ProfileResponse, BotillonError> {
self.post_json("/api/v1/me/runtime/profile", profile)
}
fn post_json<T: for<'de> Deserialize<'de>, B: Serialize>(
&self,
path: &str,
body: &B,
) -> Result<T, BotillonError> {
if self.token.is_empty() {
return Err(auth_error("missing_token", "Clé API absente."));
}
let url = format!("{}{}", self.base_url, path);
let response = self
.agent
.post(&url)
.set("Authorization", &format!("Bearer {}", self.token))
.set("Content-Type", "application/json")
.set("Accept", "application/json")
.set("X-Botillon-SDK", concat!("rust/", env!("CARGO_PKG_VERSION")))
.send_json(body);
match response {
Ok(resp) => resp.into_json::<T>().map_err(|e| BotillonError {
code: "invalid_response".into(),
message: e.to_string(),
status: None,
retry_after: None,
is_auth_error: false,
is_temporary: true,
}),
Err(ureq::Error::Status(status, resp)) => {
let parsed: ApiError = resp.into_json().unwrap_or(ApiError {
error: None,
message: None,
retry_after: None,
});
let code = parsed.error.unwrap_or_else(|| format!("http_{status}"));
let message = parsed.message.unwrap_or_else(|| format!("HTTP {status}"));
Err(BotillonError {
code,
message,
status: Some(status),
retry_after: parsed.retry_after,
is_auth_error: status == 401 || status == 403,
is_temporary: status >= 500 || status == 429,
})
}
Err(ureq::Error::Transport(t)) => Err(BotillonError {
code: "network_error".into(),
message: t.to_string(),
status: None,
retry_after: None,
is_auth_error: false,
is_temporary: true,
}),
}
}
}
fn auth_error(code: &str, message: &str) -> BotillonError {
BotillonError {
code: code.into(),
message: message.into(),
status: Some(401),
retry_after: None,
is_auth_error: true,
is_temporary: false,
}
}