use std::collections::HashMap;
use futures_util::stream::BoxStream;
use reqwest::Method;
use reqwest::header::ACCEPT;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::api::gameplay::games::LichessGame;
use crate::client::LichessClient;
use crate::config::Host;
use crate::error::Result;
use crate::http;
use crate::model::{LichessColor, LichessSpeed, LichessTitle, LichessVariantKey};
#[derive(Debug)]
pub struct ChallengesApi<'a> {
client: &'a LichessClient,
}
impl<'a> ChallengesApi<'a> {
pub(crate) fn new(client: &'a LichessClient) -> Self {
Self { client }
}
pub async fn list(&self) -> Result<LichessChallenges> {
let request = self
.client
.request(Method::GET, Host::Default, "/api/challenge");
http::json(request, "LichessChallenges").await
}
pub async fn show(&self, challenge_id: &str) -> Result<LichessChallenge> {
let path = format!("/api/challenge/{}/show", http::segment(challenge_id));
let request = self.client.request(Method::GET, Host::Default, &path);
http::json(request, "LichessChallenge").await
}
#[must_use]
pub fn challenge(&self, username: &'a str) -> ChallengeRequest<'a> {
ChallengeRequest::new(self.client, username)
}
#[must_use]
pub fn challenge_ai(&self, level: u8) -> AiChallengeRequest<'a> {
AiChallengeRequest::new(self.client, level)
}
pub async fn accept(&self, challenge_id: &str, color: Option<LichessColor>) -> Result<()> {
let path = format!("/api/challenge/{}/accept", http::segment(challenge_id));
let request = self
.client
.request(Method::POST, Host::Default, &path)
.query(&[("color", color)]);
http::ok(request).await
}
pub async fn decline(&self, challenge_id: &str, reason: Option<&str>) -> Result<()> {
let path = format!("/api/challenge/{}/decline", http::segment(challenge_id));
let mut request = self.client.request(Method::POST, Host::Default, &path);
if let Some(reason) = reason {
request = request.form(&[("reason", reason)]);
}
http::ok(request).await
}
pub async fn cancel(&self, challenge_id: &str, opponent_token: Option<&str>) -> Result<()> {
let path = format!("/api/challenge/{}/cancel", http::segment(challenge_id));
let request = self
.client
.request(Method::POST, Host::Default, &path)
.query(&[("opponentToken", opponent_token)]);
http::ok(request).await
}
pub async fn add_time(&self, game_id: &str, seconds: u32) -> Result<()> {
let path = format!("/api/round/{}/add-time/{seconds}", http::segment(game_id));
http::ok(self.client.request(Method::POST, Host::Default, &path)).await
}
#[must_use]
pub fn create_open(&self) -> OpenChallengeRequest<'_> {
OpenChallengeRequest::new(self.client)
}
pub async fn start_clocks(
&self,
game_id: &str,
token1: &str,
token2: Option<&str>,
) -> Result<()> {
let path = format!("/api/challenge/{}/start-clocks", http::segment(game_id));
let request = self
.client
.request(Method::POST, Host::Default, &path)
.query(&[("token1", Some(token1)), ("token2", token2)]);
http::ok(request).await
}
pub async fn admin_challenge_tokens(
&self,
user_ids: &[&str],
description: &str,
) -> Result<HashMap<String, String>> {
let request = self
.client
.request(Method::POST, Host::Default, "/api/token/admin-challenge")
.form(&[
("users", user_ids.join(",").as_str()),
("description", description),
]);
http::json(request, "admin challenge tokens").await
}
}
impl LichessClient {
#[must_use]
pub fn challenges(&self) -> ChallengesApi<'_> {
ChallengesApi::new(self)
}
}
#[derive(Debug, Default, Serialize)]
struct ChallengeForm<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
rated: Option<bool>,
#[serde(rename = "clock.limit", skip_serializing_if = "Option::is_none")]
clock_limit: Option<u32>,
#[serde(rename = "clock.increment", skip_serializing_if = "Option::is_none")]
clock_increment: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
days: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
color: Option<LichessChallengeColor>,
#[serde(skip_serializing_if = "Option::is_none")]
variant: Option<LichessVariantKey>,
#[serde(skip_serializing_if = "Option::is_none")]
fen: Option<&'a str>,
#[serde(rename = "keepAliveStream", skip_serializing_if = "Option::is_none")]
keep_alive_stream: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
rules: Option<&'a str>,
}
#[derive(Debug)]
pub struct ChallengeRequest<'a> {
client: &'a LichessClient,
username: &'a str,
form: ChallengeForm<'a>,
}
impl<'a> ChallengeRequest<'a> {
pub(crate) fn new(client: &'a LichessClient, username: &'a str) -> Self {
Self {
client,
username,
form: ChallengeForm::default(),
}
}
#[must_use]
pub fn rated(mut self, rated: bool) -> Self {
self.form.rated = Some(rated);
self
}
#[must_use]
pub fn clock(mut self, limit_secs: u32, increment_secs: u32) -> Self {
self.form.clock_limit = Some(limit_secs);
self.form.clock_increment = Some(increment_secs);
self
}
#[must_use]
pub fn days(mut self, days: u32) -> Self {
self.form.days = Some(days);
self
}
#[must_use]
pub fn color(mut self, color: LichessChallengeColor) -> Self {
self.form.color = Some(color);
self
}
#[must_use]
pub fn variant(mut self, variant: LichessVariantKey) -> Self {
self.form.variant = Some(variant);
self
}
#[must_use]
pub fn fen(mut self, fen: &'a str) -> Self {
self.form.fen = Some(fen);
self
}
#[must_use]
pub fn rules(mut self, rules: &'a str) -> Self {
self.form.rules = Some(rules);
self
}
pub async fn send(self) -> Result<LichessChallenge> {
let request = self.request();
http::json(request, "LichessChallenge").await
}
pub async fn stream(mut self) -> Result<BoxStream<'static, Result<Value>>> {
self.form.keep_alive_stream = Some(true);
let request = self.request().header(ACCEPT, http::ACCEPT_NDJSON);
http::stream(request, self.client.max_line_bytes()).await
}
fn request(&self) -> http::ApiRequest {
let path = format!("/api/challenge/{}", http::segment(self.username));
self.client
.request(Method::POST, Host::Default, &path)
.form(&self.form)
}
}
#[derive(Debug, Serialize)]
struct AiChallengeForm<'a> {
level: u8,
#[serde(rename = "clock.limit", skip_serializing_if = "Option::is_none")]
clock_limit: Option<u32>,
#[serde(rename = "clock.increment", skip_serializing_if = "Option::is_none")]
clock_increment: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
days: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
color: Option<LichessChallengeColor>,
#[serde(skip_serializing_if = "Option::is_none")]
variant: Option<LichessVariantKey>,
#[serde(skip_serializing_if = "Option::is_none")]
fen: Option<&'a str>,
}
#[derive(Debug)]
pub struct AiChallengeRequest<'a> {
client: &'a LichessClient,
form: AiChallengeForm<'a>,
}
impl<'a> AiChallengeRequest<'a> {
pub(crate) fn new(client: &'a LichessClient, level: u8) -> Self {
Self {
client,
form: AiChallengeForm {
level,
clock_limit: None,
clock_increment: None,
days: None,
color: None,
variant: None,
fen: None,
},
}
}
#[must_use]
pub fn clock(mut self, limit_secs: u32, increment_secs: u32) -> Self {
self.form.clock_limit = Some(limit_secs);
self.form.clock_increment = Some(increment_secs);
self
}
#[must_use]
pub fn days(mut self, days: u32) -> Self {
self.form.days = Some(days);
self
}
#[must_use]
pub fn color(mut self, color: LichessChallengeColor) -> Self {
self.form.color = Some(color);
self
}
#[must_use]
pub fn variant(mut self, variant: LichessVariantKey) -> Self {
self.form.variant = Some(variant);
self
}
#[must_use]
pub fn fen(mut self, fen: &'a str) -> Self {
self.form.fen = Some(fen);
self
}
pub async fn send(self) -> Result<LichessGame> {
let request = self
.client
.request(Method::POST, Host::Default, "/api/challenge/ai")
.form(&self.form);
http::json(request, "LichessGame").await
}
}
#[derive(Debug, Default, Serialize)]
struct OpenForm<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
rated: Option<bool>,
#[serde(rename = "clock.limit", skip_serializing_if = "Option::is_none")]
clock_limit: Option<u32>,
#[serde(rename = "clock.increment", skip_serializing_if = "Option::is_none")]
clock_increment: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
days: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
variant: Option<LichessVariantKey>,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
fen: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
rules: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
users: Option<&'a str>,
#[serde(rename = "expiresAt", skip_serializing_if = "Option::is_none")]
expires_at: Option<i64>,
}
#[derive(Debug)]
pub struct OpenChallengeRequest<'a> {
client: &'a LichessClient,
form: OpenForm<'a>,
}
impl<'a> OpenChallengeRequest<'a> {
pub(crate) fn new(client: &'a LichessClient) -> Self {
Self {
client,
form: OpenForm::default(),
}
}
#[must_use]
pub fn rated(mut self, rated: bool) -> Self {
self.form.rated = Some(rated);
self
}
#[must_use]
pub fn clock(mut self, limit_secs: u32, increment_secs: u32) -> Self {
self.form.clock_limit = Some(limit_secs);
self.form.clock_increment = Some(increment_secs);
self
}
#[must_use]
pub fn days(mut self, days: u32) -> Self {
self.form.days = Some(days);
self
}
#[must_use]
pub fn variant(mut self, variant: LichessVariantKey) -> Self {
self.form.variant = Some(variant);
self
}
#[must_use]
pub fn name(mut self, name: &'a str) -> Self {
self.form.name = Some(name);
self
}
#[must_use]
pub fn fen(mut self, fen: &'a str) -> Self {
self.form.fen = Some(fen);
self
}
#[must_use]
pub fn rules(mut self, rules: &'a str) -> Self {
self.form.rules = Some(rules);
self
}
#[must_use]
pub fn users(mut self, users: &'a str) -> Self {
self.form.users = Some(users);
self
}
#[must_use]
pub fn expires_at(mut self, timestamp: i64) -> Self {
self.form.expires_at = Some(timestamp);
self
}
pub async fn send(self) -> Result<LichessOpenChallenge> {
let request = self
.client
.request(Method::POST, Host::Default, "/api/challenge/open")
.form(&self.form);
http::json(request, "LichessOpenChallenge").await
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum LichessChallengeStatus {
Created,
Offline,
Canceled,
Declined,
Accepted,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum LichessChallengeColor {
White,
Black,
Random,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LichessChallengeUser {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rating: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<LichessTitle>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provisional: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub online: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lag: Option<u32>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
#[non_exhaustive]
pub enum LichessTimeControl {
Clock {
#[serde(default, skip_serializing_if = "Option::is_none")]
limit: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
increment: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
show: Option<String>,
},
Correspondence {
#[serde(
rename = "daysPerTurn",
default,
skip_serializing_if = "Option::is_none"
)]
days_per_turn: Option<u32>,
},
Unlimited,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessChallengePerf {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessChallengeVariant {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<LichessVariantKey>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub short: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LichessChallenge {
pub id: String,
pub url: String,
pub status: LichessChallengeStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub challenger: Option<LichessChallengeUser>,
#[serde(default)]
pub dest_user: Option<LichessChallengeUser>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub variant: Option<LichessChallengeVariant>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rated: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub speed: Option<LichessSpeed>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub time_control: Option<LichessTimeControl>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<LichessChallengeColor>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_color: Option<LichessColor>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub perf: Option<LichessChallengePerf>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub direction: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub initial_fen: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rematch_of: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LichessChallenges {
#[serde(rename = "in", default)]
pub incoming: Vec<LichessChallenge>,
#[serde(rename = "out", default)]
pub outgoing: Vec<LichessChallenge>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct LichessOpenChallenge {
pub id: String,
pub url: String,
pub status: LichessChallengeStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rated: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub variant: Option<LichessChallengeVariant>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub time_control: Option<LichessTimeControl>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url_white: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url_black: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_challenge_with_clock_time_control() {
let json = r#"{"id":"H9fIRZUk","url":"https://lichess.org/H9fIRZUk",
"status":"created","challenger":{"id":"bot1","name":"Bot1","rating":1500},
"destUser":{"id":"bobby","name":"Bobby"},"rated":true,"speed":"rapid",
"timeControl":{"type":"clock","limit":600,"increment":0,"show":"10+0"},
"color":"random","finalColor":"black","direction":"out"}"#;
let challenge: LichessChallenge = serde_json::from_str(json).unwrap();
assert_eq!(challenge.id, "H9fIRZUk");
assert_eq!(challenge.color, Some(LichessChallengeColor::Random));
assert_eq!(
challenge.time_control,
Some(LichessTimeControl::Clock {
limit: Some(600),
increment: Some(0),
show: Some("10+0".to_owned()),
})
);
}
#[test]
fn parses_challenge_list_in_out() {
let json = r#"{"in":[],"out":[{"id":"x","url":"u","status":"created"}]}"#;
let challenges: LichessChallenges = serde_json::from_str(json).unwrap();
assert!(challenges.incoming.is_empty());
assert_eq!(challenges.outgoing.len(), 1);
}
}