botillon-sdk 0.1.0

SDK officiel Botillon : statistiques live, heartbeat, shards, commandes et profil.
Documentation
//! SDK Rust officiel Botillon.
//!
//! La clé n'est jamais journalisée. Toutes les erreurs sont retournées dans
//! [`BotillonError`] ; le SDK n'arrête jamais le process.

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,
    }
}