maxbot 0.7.5

Автоматизация работы с чат-ботами на платформе MAX (max.ru)
Documentation
use crate::config;
use crate::error::{Error, Result};
use crate::rate_limiter;
use reqwest::{multipart::Part, Client};
use serde_json::Value;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
use url::Url;

/// Клиент для взаимодействия с API MAX.
///
/// `MaxClient` инкапсулирует HTTP‑клиент, токен авторизации и настройки ограничения
/// частоты запросов (RPS). Он позволяет переопределять глобальные параметры (базовый URL,
/// максимальный RPS) для конкретного экземпляра.
///
/// # Примеры
///
/// ```no_run
/// use maxbot::MaxClient;
///
/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
/// let mut client = MaxClient::new("your_api_token");
/// client.set_base_url("https://custom-api.max.ru");
/// client.set_max_rps(Some(10)); // локальные ограничения 10 запросов в секунду
/// # Ok(())
/// # }
/// ```
pub struct MaxClient {
    pub(crate) http: Client,
    pub(crate) token: String,
    base_url: String,
    max_rps: Option<usize>,
    last_request_time: Option<Mutex<Instant>>,
}

impl MaxClient {
    /// Создаёт новый экземпляр клиента с глобальными настройками по умолчанию.
    ///
    /// Используется глобальный базовый URL (см. [`set_global_base_url`][crate::set_global_base_url])
    /// и глобальный лимит RPS (см. [`set_global_max_rps`][crate::set_global_max_rps]).
    ///
    /// # Аргументы
    ///
    /// * `token` - токен доступа к API MAX.
    pub fn new(token: impl Into<String>) -> Self {
        Self {
            http: Client::new(),
            token: token.into(),
            base_url: config::get_global_base_url(),
            max_rps: None,
            last_request_time: None,
        }
    }

    /// Создаёт новый экземпляр клиента из переменных окружения.
    ///
    /// Читает `MAXBOT_TOKEN` (обязательно) и `MAXBOT_PROXY` (необязательно, по умолчанию
    /// `https://platform-api.max.ru`).
    ///
    /// # Ошибка
    /// Возвращает `std::env::VarError` когда `MAXBOT_TOKEN` не установлен.
    pub fn from_env() -> Result<Self> {
        let token = std::env::var("MAXBOT_TOKEN").map_err(|e|
            Error::InvalidInput(format!("MAXBOT_TOKEN not set: {}", e))
        )?;
        let mut client = Self::new(token);
        if let Ok(base_url) = std::env::var("MAXBOT_PROXY") {
            client.set_base_url(base_url);
        }
        Ok(client)
    }

    /// Устанавливает базовый URL для данного клиента.
    ///
    /// Это значение переопределяет глобальный URL, заданный через
    /// [`set_global_base_url`][crate::set_global_base_url].
    ///
    /// # Аргументы
    ///
    /// * `url` - полный базовый URL API (например, `"https://platform-api.max.ru"`).
    pub fn set_base_url(&mut self, url: impl Into<String>) {
        self.base_url = url.into();
    }

    /// Устанавливает локальное ограничение частоты запросов (RPS) для клиента.
    ///
    /// - Если передано `Some(rps)` и `rps > 0`, клиент будет выдерживать паузу
    ///   не менее `1/rps` секунд между запросами.
    /// - `Some(0)` полностью отключает ограничение для этого клиента.
    /// - `None` заставляет клиента использовать глобальный лимит (см.
    ///   [`set_global_max_rps`][crate::set_global_max_rps]).
    ///
    /// # Аргументы
    ///
    /// * `rps` — желаемое количество запросов в секунду, или `None` для использования
    ///   глобальной настройки.
    ///
    /// Примечание: в документации MAX рекомендуется не превышать 30 rps для стабильной
    /// работы бота, но по факту иногда не выдерживает и 10 rps. По наблюдениям, лучше
    /// вообще отправлять не чаще одного раза в секунду.
    pub fn set_max_rps(&mut self, rps: Option<usize>) {
        self.max_rps = rps;
        if rps.is_some() {
            if self.last_request_time.is_none() {
                self.last_request_time = Some(Mutex::new(Instant::now()));
            }
        } else {
            self.last_request_time = None;
        }
    }

    /// Применяет ограничение частоты запросов согласно локальной или глобальной настройке.
    ///
    /// Этот метод автоматически вызывается перед каждым HTTP‑запросом внутри клиента.
    async fn apply_rate_limit(&self) {
        if let Some(max_rps) = self.max_rps {
            if max_rps > 0 {
                let min_interval = Duration::from_millis(1000 / max_rps as u64);
                let last = self.last_request_time.as_ref().unwrap();
                let mut last_guard = last.lock().await;
                let now = Instant::now();
                let elapsed = now - *last_guard;
                if elapsed < min_interval {
                    tokio::time::sleep(min_interval - elapsed).await;
                }
                *last_guard = Instant::now();
            }
        } else {
            // Используем глобальный rate limiter
            rate_limiter::enforce_global_rate_limit().await;
        }
    }

    /// Выполняет HTTP‑запрос с предварительным соблюдением rate limiting.
    ///
    /// Этот метод используется внутри других публичных методов клиента.
    pub(crate) async fn request_with_rate_limit<T: serde::de::DeserializeOwned>(
        &self,
        method: reqwest::Method,
        path: &str,
        query_params: &[(&str, String)],
        body: Option<Value>,
    ) -> Result<T> {
        self.apply_rate_limit().await;
        self.request(method, path, query_params, body).await
    }

    /// Выполняет HTTP‑запрос без учёта ограничений частоты.
    ///
    /// Предполагается, что вызывающий код уже применил необходимые паузы.
    pub(crate) async fn request<T: serde::de::DeserializeOwned>(
        &self,
        method: reqwest::Method,
        path: &str,
        query_params: &[(&str, String)],
        body: Option<Value>,
    ) -> Result<T> {
        // Формируем базовый URL
        let base_url = Url::parse(&self.base_url).map_err(|e| Error::InvalidInput(e.to_string()))?;
        let mut url = base_url.join(path).map_err(|e| Error::InvalidInput(e.to_string()))?;

        // Добавляем query-параметры, если они есть
        if !query_params.is_empty() {
            let pairs: Vec<(&str, &str)> = query_params
                .iter()
                .map(|(k, v)| (*k, v.as_str()))
                .collect();
            url.query_pairs_mut().extend_pairs(pairs);
        }

        let mut req = self
            .http
            .request(method, url)
            .header("Authorization", &self.token)
            .header("Content-Type", "application/json");

        if let Some(body) = body {
            req = req.json(&body);
        }

        let resp = req.send().await?;
        let status = resp.status();
        let data: Value = resp.json().await?;

        if status.is_success() {
            serde_json::from_value(data).map_err(Error::from)
        } else {
            let message = data
                .get("message")
                .and_then(|v| v.as_str())
                .unwrap_or("unknown error");
            Err(Error::Api {
                code: status.as_u16(),
                message: message.to_string(),
            })
        }
    }

    /// Загружает файл на сервер MAX и возвращает токен вложения.
    ///
    /// Процесс загрузки состоит из нескольких шагов:
    /// 1. Получение временного URL для загрузки от API.
    /// 2. Отправка содержимого файла multipart‑запросом.
    /// 3. Извлечение токена вложения из ответа.
    ///
    /// Этот метод можно использовать для предварительной загрузки файла,
    /// чтобы потом отправить его в нескольких сообщениях без повторной загрузки.
    ///
    /// # Аргументы
    ///
    /// * `file_path` - путь к локальному файлу.
    /// * `file_type` - тип файла: `"image"`, `"video"`, `"audio"`, `"file"`.
    ///
    /// # Возвращаемое значение
    ///
    /// Токен вложения, который можно использовать в [`Attachment::image_token`]
    /// и аналогичных конструкторах.
    ///
    /// # Ошибки
    ///
    /// Возвращает `Error::InvalidInput`, если ответ сервера не содержит ожидаемых полей
    /// (`url` или `token`). Может вернуть `Error::Api` при ошибке HTTP.
    ///
    /// # Пример
    ///
    /// ```no_run
    /// # use maxbot::MaxClient;
    /// # async fn example(client: &MaxClient) -> Result<(), Box<dyn std::error::Error>> {
    /// let token = client.upload_file(std::path::Path::new("cat.jpg"), "image").await?;
    /// // Позже можно отправить сообщение с этим токеном
    /// # Ok(())
    /// # }
    /// ```
    pub async fn upload_file(
        &self,
        file_path: &std::path::Path,
        file_type: &str,
    ) -> Result<String> {
        // 1. Получить URL для загрузки (и, возможно, токен)
        let upload_info: Value = self
            .request(
                reqwest::Method::POST,
                "/uploads",
                &[("type", file_type.to_string())],
                None,
            )
            .await?;

        let upload_url = upload_info["url"]
            .as_str()
            .ok_or_else(|| Error::InvalidInput("No upload URL in response".into()))?;
        let token_from_upload = upload_info
            .get("token")
            .and_then(|v| v.as_str())
            .map(String::from);

        // 2. Прочитать файл
        let file_content = tokio::fs::read(file_path).await?;
        let file_name = file_path
            .file_name()
            .unwrap()
            .to_string_lossy()
            .to_string();

        // 3. Выполнить multipart-загрузку
        let part = Part::bytes(file_content)
            .file_name(file_name)
            .mime_str("application/octet-stream")?;
        let form = reqwest::multipart::Form::new().part("data", part);
        let upload_resp = self
            .http
            .post(upload_url)
            .header("Authorization", &self.token)
            .multipart(form)
            .send()
            .await?;

        let status = upload_resp.status();
        if !status.is_success() {
            let err_text = upload_resp.text().await.unwrap_or_default();
            eprintln!("Upload failed with status {}: {}", status, err_text);
            return Err(Error::Api {
                code: status.as_u16(),
                message: format!("Upload failed: {}", err_text),
            });
        }

        // 4. Извлечь токен
        if let Some(tok) = token_from_upload {
            // Для видео/аудио токен уже был в первом ответе
            return Ok(tok);
        }

        // Для image/file парсим JSON-ответ
        let resp_json: Value = upload_resp.json().await?;
        // Ищем токен в поле "token" (может быть в корне)
        if let Some(tok) = resp_json["token"].as_str() {
            return Ok(tok.to_string());
        }
        // Или в "photos" (для изображений)
        if let Some(photos) = resp_json["photos"].as_object() {
            for photo in photos.values() {
                if let Some(tok) = photo["token"].as_str() {
                    return Ok(tok.to_string());
                }
            }
        }
        Err(Error::InvalidInput(
            "No token found in upload response".into(),
        ))
    }

    /// Скачивает файл по URL во временную директорию и возвращает путь к нему.
    ///
    /// # Аргументы
    ///
    /// * `url` - путь к файлу в сети.
    /// * `desired_filename` - желаемое имя файла с расширением.
    /// Используется для корректного определения MIME-типа при последующей загрузке в MAX.
    ///
    /// # Возвращаемое значение
    ///
    /// Путь к локальному файлу во временной директории.
    ///
    /// # Ошибки
    /// Возвращает `Error::InvalidInput`, если HTTP-статус ответа не успешный,
    /// или ошибки ввода-вывода при создании директории/записи файла.
    pub(crate) async fn download_to_temp_dir(
        &self,
        url: &str,
        desired_filename: &str,
    ) -> Result<PathBuf> {
        let response = self.http.get(url).send().await?;
        if !response.status().is_success() {
            return Err(crate::error::Error::InvalidInput(format!(
                "Failed to download from URL: HTTP {}",
                response.status()
            )));
        }
        let bytes = response.bytes().await?;
        let temp_dir = std::env::temp_dir().join(format!("maxbot_{}", uuid::Uuid::new_v4()));
        tokio::fs::create_dir(&temp_dir).await?;
        let file_path = temp_dir.join(desired_filename);
        tokio::fs::write(&file_path, bytes).await?;
        Ok(file_path)
    }

    /// Сохранённый токен бота.
    pub fn token(&self) -> &str {
        &self.token
    }
}