use std::time::Duration;
use serde::Deserialize;
use serde_json::{json, Value};
use tokio::time::{sleep, Instant};
use crate::error::{Error, Result};
use crate::proxy::{ClientProxy, ProxyType};
use crate::requests::CaptchaRequest;
use crate::timeouts::GetResultTimeouts;
pub const DEFAULT_SERVICE_URL: &str = "https://api.capmonster.cloud";
pub const DEFAULT_SOFT_ID: u32 = 152;
#[derive(Clone)]
pub struct ClientOptions {
pub api_key: String,
pub service_url: String,
pub soft_id: u32,
pub client_timeout: Duration,
pub client_proxy: Option<ClientProxy>,
pub timeouts_override: Option<GetResultTimeouts>,
}
impl ClientOptions {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
service_url: DEFAULT_SERVICE_URL.to_string(),
soft_id: DEFAULT_SOFT_ID,
client_timeout: Duration::from_secs(20),
client_proxy: None,
timeouts_override: None,
}
}
pub fn with_service_url(mut self, url: impl Into<String>) -> Self {
self.service_url = url.into();
self
}
pub fn with_soft_id(mut self, soft_id: u32) -> Self {
self.soft_id = soft_id;
self
}
pub fn with_client_timeout(mut self, timeout: Duration) -> Self {
self.client_timeout = timeout;
self
}
pub fn with_client_proxy(mut self, proxy: ClientProxy) -> Self {
self.client_proxy = Some(proxy);
self
}
pub fn with_timeouts_override(mut self, timeouts: GetResultTimeouts) -> Self {
self.timeouts_override = Some(timeouts);
self
}
}
impl std::fmt::Debug for ClientOptions {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ClientOptions")
.field("api_key", &"***")
.field("service_url", &self.service_url)
.field("soft_id", &self.soft_id)
.field("client_timeout", &self.client_timeout)
.field("client_proxy", &self.client_proxy)
.field("timeouts_override", &self.timeouts_override)
.finish()
}
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ApiResponse {
error_id: i64,
error_code: Option<String>,
error_description: Option<String>,
task_id: Option<u64>,
status: Option<String>,
solution: Option<Value>,
balance: Option<f64>,
}
impl ApiResponse {
fn code(&self) -> String {
self.error_code.clone().unwrap_or_default()
}
fn description(&self) -> String {
self.error_description.clone().unwrap_or_default()
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum TaskStatus {
Processing,
Ready(Value),
}
#[derive(Debug, Clone)]
pub struct CapMonsterClient {
options: ClientOptions,
http: reqwest::Client,
}
impl CapMonsterClient {
pub fn new(options: ClientOptions) -> Result<Self> {
let user_agent = format!(
"Zennolab.CapMonsterCloud.Client.Rust/{}",
env!("CARGO_PKG_VERSION")
);
let mut builder = reqwest::Client::builder()
.user_agent(user_agent)
.timeout(options.client_timeout);
if let Some(p) = &options.client_proxy {
if !matches!(p.proxy_type, ProxyType::Http | ProxyType::Https) {
return Err(Error::UnsupportedProxyType(
p.proxy_type.as_str().to_string(),
));
}
let url = format!("{}://{}:{}", p.proxy_type.as_str(), p.address, p.port);
let mut proxy = reqwest::Proxy::all(url)?;
if let (Some(login), Some(password)) = (&p.login, &p.password) {
proxy = proxy.basic_auth(login, password);
}
builder = builder.proxy(proxy);
}
Ok(Self {
options,
http: builder.build()?,
})
}
pub fn options(&self) -> &ClientOptions {
&self.options
}
pub async fn get_balance(&self) -> Result<f64> {
let resp = self
.post("getBalance", json!({ "clientKey": self.options.api_key }))
.await?;
if resp.error_id != 0 {
return Err(Error::GetBalance {
code: resp.code(),
description: resp.description(),
});
}
resp.balance
.ok_or_else(|| Error::MalformedResponse("getBalance: no `balance` field".into()))
}
pub async fn create_task<R: CaptchaRequest>(&self, request: &R) -> Result<u64> {
let body = json!({
"clientKey": self.options.api_key,
"task": request.to_task()?,
"softId": self.options.soft_id,
});
let resp = self.post("createTask", body).await?;
if resp.error_id != 0 {
return Err(Error::CreateTask {
code: resp.code(),
description: resp.description(),
});
}
resp.task_id
.ok_or_else(|| Error::MalformedResponse("createTask: no `taskId` field".into()))
}
pub async fn get_task_result(&self, task_id: u64) -> Result<TaskStatus> {
let body = json!({ "clientKey": self.options.api_key, "taskId": task_id });
let resp = match self.post("getTaskResult", body).await {
Ok(r) => r,
Err(Error::Status { status: 500, .. }) => return Ok(TaskStatus::Processing),
Err(e) => return Err(e),
};
if resp.error_id != 0 {
return Err(Error::GetResult {
code: resp.code(),
description: resp.description(),
});
}
match resp.status.as_deref() {
Some("ready") => Ok(TaskStatus::Ready(resp.solution.unwrap_or(Value::Null))),
_ => Ok(TaskStatus::Processing),
}
}
pub async fn solve_captcha<R: CaptchaRequest>(&self, request: &R) -> Result<Value> {
let timeouts = self
.options
.timeouts_override
.unwrap_or_else(|| request.timeouts());
let task_id = self.create_task(request).await?;
let now = Instant::now();
let deadline = now
.checked_add(timeouts.timeout)
.unwrap_or_else(|| now + Duration::from_secs(365 * 24 * 3600));
sleep(timeouts.first_request_delay).await;
while Instant::now() < deadline {
if let TaskStatus::Ready(solution) = self.get_task_result(task_id).await? {
return Ok(solution);
}
sleep(timeouts.requests_interval).await;
}
Err(Error::Timeout(timeouts.timeout))
}
async fn post(&self, method: &'static str, body: Value) -> Result<ApiResponse> {
let url = format!(
"{}/{}",
self.options.service_url.trim_end_matches('/'),
method
);
let resp = self.http.post(url).json(&body).send().await?;
let status = resp.status();
if !status.is_success() {
return Err(Error::Status {
method,
status: status.as_u16(),
});
}
let text = resp.text().await?;
Ok(serde_json::from_str(&text)?)
}
}