maxbot 0.1.0

Автоматизация работы с чат-ботами MAX
Documentation
use reqwest::multipart::Part;
use reqwest::Client;
use serde_json::Value;
use std::time::Duration;
use crate::error::{Error, Result};
use std::sync::Arc;
use tokio::sync::Mutex;
use tokio::time::{self, Instant};

pub struct MaxClient {
    http: Client,
    token: String,
    base_url: String,
    last_request_time: Arc<Mutex<Instant>>,
}

const MIN_REQUEST_INTERVAL: Duration = Duration::from_millis(1000 / 30); // ~33.33 мс

impl MaxClient {
    pub fn new(token: impl Into<String>) -> Self {
        Self {
            http: Client::new(),
            token: token.into(),
            base_url: "https://platform-api.max.ru".to_string(),
            last_request_time: Arc::new(Mutex::new(Instant::now() - MIN_REQUEST_INTERVAL)),
        }
    }

    pub(crate) async fn request_with_rate_limit<T: serde::de::DeserializeOwned>(
        &self,
        method: reqwest::Method,
        path: &str,
        query: &[(&str, String)],
        body: Option<serde_json::Value>,
    ) -> Result<T> {
        // Задержка для соблюдения 30 RPS
        {
            let mut last = self.last_request_time.lock().await;
            let now = Instant::now();
            let elapsed = now - *last;
            if elapsed < MIN_REQUEST_INTERVAL {
                time::sleep(MIN_REQUEST_INTERVAL - elapsed).await;
            }
            *last = Instant::now();
        }
        // Выполнить реальный запрос
        self.request(method, path, query, body).await
    }

    pub(crate) async fn request<T: serde::de::DeserializeOwned>(
        &self,
        method: reqwest::Method,
        path: &str,
        query: &[(&str, String)],
        body: Option<Value>,
    ) -> Result<T> {
        let url = format!("{}{}", self.base_url, path);
        let mut req = self.http.request(method, &url)
            .header("Authorization", &self.token)
            .header("Content-Type", "application/json");

        if !query.is_empty() {
            req = req.query(&query);
        }
        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() })
        }
    }

    pub(crate) 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-загрузку с явным MIME-типом
        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()))
    }
}