Skip to main content

racetime/
lib.rs

1//! Utilities for creating chat bots for [racetime.gg](https://racetime.gg/).
2//!
3//! The main entry point is [`Bot::run`]. You can also create new race rooms using [`StartRace::start`].
4//!
5//! For documentation, see also <https://github.com/racetimeGG/racetime-app/wiki/Category-bots>.
6//!
7//! # MSRV policy
8//!
9//! This library is kept compatible with [the version of Rust packaged in the latest stable release of Nixpkgs](https://search.nixos.org/packages?query=cargo).
10//! MSRV bumps that follow this policy are not considered breaking changes.
11
12use {
13    std::{
14        borrow::Cow,
15        collections::BTreeMap,
16        num::NonZeroU16,
17    },
18    collect_mac::collect,
19    futures::future::{
20        Either,
21        FutureExt as _,
22    },
23    lazy_regex::regex_captures,
24    serde::Deserialize,
25    tokio::{
26        net::ToSocketAddrs,
27        time::{
28            error::Elapsed,
29            timeout,
30        },
31    },
32    url::Url,
33};
34pub use crate::{
35    bot::Bot,
36    builder::BotBuilder,
37    handler::RaceHandler,
38};
39
40pub mod bot;
41mod builder;
42pub mod handler;
43pub mod model;
44
45const RACETIME_HOST: &str = "racetime.gg";
46
47/// An unsigned duration. This is a reexport of [`std::time::Duration`].
48pub type UDuration = std::time::Duration;
49
50fn maybe_timeout<T>(duration: Option<UDuration>, fut: impl Future<Output = T>) -> impl Future<Output = Result<T, Elapsed>> {
51    if let Some(duration) = duration {
52        Either::Left(timeout(duration, fut))
53    } else {
54        Either::Right(fut.map(Ok))
55    }
56}
57
58#[derive(Debug, Clone)]
59pub struct HostInfo {
60    pub hostname: Cow<'static, str>,
61    pub port: NonZeroU16,
62    pub secure: bool,
63}
64
65impl HostInfo {
66    pub fn new(hostname: impl Into<Cow<'static, str>>, port: NonZeroU16, secure: bool) -> Self {
67        Self {
68            hostname: hostname.into(),
69            secure, port,
70        }
71    }
72
73    fn http_protocol(&self) -> &'static str {
74        match self.secure {
75            true => "https",
76            false => "http",
77        }
78    }
79
80    fn websocket_protocol(&self) -> &'static str {
81        match self.secure {
82            true => "wss",
83            false => "ws",
84        }
85    }
86    fn http_uri(&self, url: &str) -> Result<Url, url::ParseError>  {
87        uri(self.http_protocol(), &self.hostname, self.port, url)
88    }
89
90    fn websocket_uri(&self, url: &str) -> Result<Url, url::ParseError> {
91        uri(self.websocket_protocol(), &self.hostname, self.port, url)
92    }
93
94    fn websocket_socketaddrs(&self) -> impl ToSocketAddrs + '_ {
95        (&*self.hostname, self.port.get())
96    }
97}
98
99impl Default for HostInfo {
100    /// Returns the host info for racetime.gg.
101    fn default() -> Self {
102        Self {
103            hostname: Cow::Borrowed(RACETIME_HOST),
104            port: NonZeroU16::new(443).unwrap(),
105            secure: true,
106        }
107    }
108}
109
110/// Generate a URI from the given protocol and URL path fragment.
111fn uri(proto: &str, host: &str, port: NonZeroU16, url: &str) -> Result<Url, url::ParseError> {
112    Ok(format!("{proto}://{host}:{port}{url}").parse()?)
113}
114
115#[derive(Debug, thiserror::Error)]
116pub enum AuthError {
117    #[error(transparent)] Http(#[from] reqwest::Error),
118    #[error(transparent)] Url(#[from] url::ParseError),
119    #[error("{inner}, body:\n\n{}", .text.as_ref().map(|text| text.clone()).unwrap_or_else(|e| e.to_string()))]
120    ResponseStatus {
121        #[source]
122        inner: reqwest::Error,
123        headers: reqwest::header::HeaderMap,
124        text: reqwest::Result<String>,
125    },
126}
127
128/// Get an OAuth2 token from the authentication server.
129pub async fn authorize(client_id: &str, client_secret: &str, client: &reqwest::Client) -> Result<(String, UDuration), AuthError> {
130    authorize_with_host(&HostInfo::default(), client_id, client_secret, client).await
131}
132
133pub async fn authorize_with_host(host_info: &HostInfo, client_id: &str, client_secret: &str, client: &reqwest::Client) -> Result<(String, UDuration), AuthError> {
134    #[derive(Deserialize)]
135    struct AuthResponse {
136        access_token: String,
137        expires_in: Option<u64>,
138    }
139
140    let response = client.post(host_info.http_uri("/o/token")?)
141        .form(&collect![as BTreeMap<_, _>:
142            "client_id" => client_id,
143            "client_secret" => client_secret,
144            "grant_type" => "client_credentials",
145        ])
146        .send().await?;
147    let data = match response.error_for_status_ref() {
148        Ok(_) => response.json::<AuthResponse>().await?,
149        Err(inner) => return Err(AuthError::ResponseStatus {
150            headers: response.headers().clone(),
151            text: response.text().await,
152            inner,
153        }),
154    };
155    Ok((
156        data.access_token,
157        UDuration::from_secs(data.expires_in.unwrap_or(36000)),
158    ))
159}
160
161fn form_bool(value: &bool) -> Cow<'static, str> {
162    Cow::Borrowed(if *value { "true" } else { "false" })
163}
164
165pub struct StartRace {
166    /// If the race has already started, this must match the current goal.
167    pub goal: String,
168    /// If the race has already started, this must match the current goal.
169    pub goal_is_custom: bool,
170    pub team_race: bool,
171    /// If editing the race, this must match the current state. Use [`RaceContext::set_invitational`](handler::RaceContext::set_invitational) or [`RaceContext::set_open`](handler::RaceContext::set_open) instead.
172    pub invitational: bool,
173    /// Bots always have permission to set this field.
174    pub unlisted: bool,
175    /// Only available if category is opted-in to the 1v1 ladder beta.
176    pub partitionable: bool,
177    /// Only available if category is opted-in to the anonymised races beta.
178    pub hide_entrants: bool,
179    pub ranked: bool,
180    pub info_user: String,
181    pub info_bot: String,
182    pub require_even_teams: bool,
183    /// Number of seconds the countdown should run for. Must be in `10..=60`.
184    /// If the race has already started, this must match the current delay.
185    pub start_delay: u8,
186    /// Maximum number of hours the race is allowed to run for. Must be in `1..=72`.
187    /// If the race has already started, this must match the current limit.
188    pub time_limit: u8,
189    pub time_limit_auto_complete: bool,
190    /// Bots always have permission to set this field.
191    ///
192    /// If the race has already started, this cannot be changed.
193    pub streaming_required: bool,
194    /// If the race has already started, this cannot be changed.
195    pub auto_start: bool,
196    pub allow_comments: bool,
197    pub hide_comments: bool,
198    pub allow_prerace_chat: bool,
199    pub allow_midrace_chat: bool,
200    pub allow_non_entrant_chat: bool,
201    /// Number of seconds to hold a message for before displaying it. Doesn't affect race monitors or moderators. Must be in `0..=90`.
202    pub chat_message_delay: u8,
203}
204
205#[derive(Debug, thiserror::Error)]
206pub enum StartError {
207    #[error(transparent)] HeaderToStr(#[from] reqwest::header::ToStrError),
208    #[error(transparent)] Http(#[from] reqwest::Error),
209    #[error(transparent)] Url(#[from] url::ParseError),
210    #[error("the startrace location did not match the input category")]
211    LocationCategory,
212    #[error("the startrace location header did not have the expected format")]
213    LocationFormat,
214    #[error("the startrace response did not include a location header")]
215    MissingLocationHeader,
216    #[error("{inner}, body:\n\n{}", .text.as_ref().map(|text| text.clone()).unwrap_or_else(|e| e.to_string()))]
217    ResponseStatus {
218        #[source]
219        inner: reqwest::Error,
220        headers: reqwest::header::HeaderMap,
221        text: reqwest::Result<String>,
222    },
223}
224
225#[derive(Debug, thiserror::Error)]
226pub enum EditError {
227    #[error(transparent)] Http(#[from] reqwest::Error),
228    #[error(transparent)] Url(#[from] url::ParseError),
229    #[error("{inner}, body:\n\n{}", .text.as_ref().map(|text| text.clone()).unwrap_or_else(|e| e.to_string()))]
230    ResponseStatus {
231        #[source]
232        inner: reqwest::Error,
233        headers: reqwest::header::HeaderMap,
234        text: reqwest::Result<String>,
235    },
236}
237
238impl StartRace {
239    fn form(&self) -> BTreeMap<&'static str, Cow<'_, str>> {
240        let Self {
241            goal,
242            goal_is_custom,
243            team_race,
244            invitational,
245            unlisted,
246            partitionable,
247            hide_entrants,
248            ranked,
249            info_user,
250            info_bot,
251            require_even_teams,
252            start_delay,
253            time_limit,
254            time_limit_auto_complete,
255            streaming_required,
256            auto_start,
257            allow_comments,
258            hide_comments,
259            allow_prerace_chat,
260            allow_midrace_chat,
261            allow_non_entrant_chat,
262            chat_message_delay,
263        } = self;
264        collect![
265            if *goal_is_custom { "custom_goal" } else { "goal" } => Cow::Borrowed(&**goal),
266            "team_race" => form_bool(team_race),
267            "invitational" => form_bool(invitational),
268            "unlisted" => form_bool(unlisted),
269            "partitionable" => form_bool(partitionable),
270            "hide_entrants" => form_bool(hide_entrants),
271            "ranked" => form_bool(ranked),
272            "info_user" => Cow::Borrowed(&**info_user),
273            "info_bot" => Cow::Borrowed(&**info_bot),
274            "require_even_teams" => form_bool(require_even_teams),
275            "start_delay" => Cow::Owned(start_delay.to_string()),
276            "time_limit" => Cow::Owned(time_limit.to_string()),
277            "time_limit_auto_complete" => form_bool(time_limit_auto_complete),
278            "streaming_required" => form_bool(streaming_required),
279            "auto_start" => form_bool(auto_start),
280            "allow_comments" => form_bool(allow_comments),
281            "hide_comments" => form_bool(hide_comments),
282            "allow_prerace_chat" => form_bool(allow_prerace_chat),
283            "allow_midrace_chat" => form_bool(allow_midrace_chat),
284            "allow_non_entrant_chat" => form_bool(allow_non_entrant_chat),
285            "chat_message_delay" => Cow::Owned(chat_message_delay.to_string()),
286        ]
287    }
288
289    /// Creates a race room with the specified configuration and returns its slug.
290    ///
291    /// An access token can be obtained using [`authorize`].
292    pub async fn start(&self, access_token: &str, client: &reqwest::Client, category: &str) -> Result<String, StartError> {
293        self.start_with_host(&HostInfo::default(), access_token, client, category).await
294    }
295
296    pub async fn start_with_host(&self, host_info: &HostInfo, access_token: &str, client: &reqwest::Client, category: &str) -> Result<String, StartError> {
297        let response = client.post(host_info.http_uri(&format!("/o/{category}/startrace"))?)
298            .bearer_auth(access_token)
299            .form(&self.form())
300            .send().await?;
301        if let Err(inner) = response.error_for_status_ref() {
302            return Err(StartError::ResponseStatus {
303                headers: response.headers().clone(),
304                text: response.text().await,
305                inner,
306            })
307        }
308        let location = response
309            .headers()
310            .get("location").ok_or(StartError::MissingLocationHeader)?
311            .to_str()?;
312        let (_, location_category, slug) = regex_captures!("^/([^/]+)/([^/]+)$", location).ok_or(StartError::LocationFormat)?;
313        if location_category != category { return Err(StartError::LocationCategory) }
314        Ok(slug.to_owned())
315    }
316
317    /// Edits the given race room.
318    ///
319    /// Due to a limitation of the racetime.gg API, all fields including ones that should remain the same must be specified.
320    ///
321    /// An access token can be obtained using [`authorize`].
322    pub async fn edit(&self, access_token: &str, client: &reqwest::Client, category: &str, race_slug: &str) -> Result<(), EditError> {
323        self.edit_with_host(&HostInfo::default(), access_token, client, category, race_slug).await
324    }
325
326    pub async fn edit_with_host(&self, host_info: &HostInfo, access_token: &str, client: &reqwest::Client, category: &str, race_slug: &str) -> Result<(), EditError> {
327        let response = client.post(host_info.http_uri(&format!("/o/{category}/{race_slug}/edit"))?)
328            .bearer_auth(access_token)
329            .form(&self.form())
330            .send().await?;
331        match response.error_for_status_ref() {
332            Ok(_) => Ok(()),
333            Err(inner) => Err(EditError::ResponseStatus {
334                headers: response.headers().clone(),
335                text: response.text().await,
336                inner,
337            }),
338        }
339    }
340}