1use {
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
47pub 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 fn default() -> Self {
102 Self {
103 hostname: Cow::Borrowed(RACETIME_HOST),
104 port: NonZeroU16::new(443).unwrap(),
105 secure: true,
106 }
107 }
108}
109
110fn 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
128pub 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 pub goal: String,
168 pub goal_is_custom: bool,
170 pub team_race: bool,
171 pub invitational: bool,
173 pub unlisted: bool,
175 pub partitionable: bool,
177 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 pub start_delay: u8,
186 pub time_limit: u8,
189 pub time_limit_auto_complete: bool,
190 pub streaming_required: bool,
194 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 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 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 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}