pikadick/commands/
quizizz.rs1use crate::{
2 checks::ENABLED_CHECK,
3 util::LoadingReaction,
4 ClientDataKey,
5};
6use anyhow::Context as _;
7use serenity::{
8 framework::standard::{
9 macros::command,
10 Args,
11 CommandResult,
12 },
13 model::prelude::*,
14 prelude::*,
15};
16use std::{
17 collections::BinaryHeap,
18 sync::Arc,
19 time::{
20 Duration,
21 Instant,
22 },
23};
24use tokio::sync::{
25 watch::{
26 Receiver as WatchReceiver,
27 Sender as WatchSender,
28 },
29 Notify,
30};
31use tracing::{
32 error,
33 info,
34};
35
36pub type SearchResult = Result<Option<String>, Arc<anyhow::Error>>;
37
38const MAX_TRIES: usize = 1_000;
39const MAX_CODE: u32 = 999_999;
40
41const LIMIT_REACHED_MSG: &str = "Reached limit while searching for quizizz code, quitting...";
42
43#[derive(Clone, Debug)]
44pub struct QuizizzClient {
45 finder_task_wakeup: Arc<Notify>,
46 finder_task_rx: WatchReceiver<SearchResult>,
48}
49
50impl QuizizzClient {
51 pub fn new() -> Self {
53 let finder_task_wakeup = Arc::new(Notify::new());
54 let wakeup = finder_task_wakeup.clone();
55 let (watch_tx, finder_task_rx) = tokio::sync::watch::channel(Ok(None));
56
57 tokio::spawn(finder_task(watch_tx, wakeup));
58
59 Self {
60 finder_task_wakeup,
61 finder_task_rx,
62 }
63 }
64
65 pub async fn search_for_code(&self) -> SearchResult {
69 let mut finder_task_rx = self.finder_task_rx.clone();
70
71 finder_task_rx.borrow_and_update();
73
74 self.finder_task_wakeup.notify_one();
86
87 finder_task_rx
89 .changed()
90 .await
91 .context("failed to get response from finder task")?;
92
93 let ret = finder_task_rx.borrow_and_update().clone();
95 ret
96 }
97}
98
99impl Default for QuizizzClient {
100 fn default() -> Self {
101 Self::new()
102 }
103}
104
105#[derive(Debug)]
107pub struct CodeCache {
108 cache: BinaryHeap<(std::cmp::Reverse<Instant>, String)>,
109}
110
111impl CodeCache {
112 pub fn new() -> Self {
114 Self {
116 cache: BinaryHeap::with_capacity(MAX_TRIES),
117 }
118 }
119
120 pub fn len(&self) -> usize {
122 self.cache.len()
123 }
124
125 pub fn is_empty(&self) -> bool {
127 self.cache.is_empty()
128 }
129
130 pub fn trim(&mut self) {
132 while let Some((time, _)) = self.cache.peek() {
133 if time.0.elapsed() > Duration::from_secs(10 * 60) {
134 self.cache.pop();
135 } else {
136 break;
139 }
140 }
141 }
142
143 pub fn trim_pop(&mut self) -> Option<String> {
145 self.trim();
146 Some(self.cache.pop()?.1)
147 }
148
149 pub fn push(&mut self, code_str: String) {
151 self.cache
152 .push((std::cmp::Reverse(Instant::now()), code_str));
153 }
154}
155
156impl Default for CodeCache {
157 fn default() -> Self {
158 Self::new()
159 }
160}
161
162async fn finder_task(watch_tx: WatchSender<SearchResult>, wakeup: Arc<Notify>) {
163 let client = quizizz::Client::new();
164 let mut cache = CodeCache::new();
165
166 while tokio::select! {
167 _ = wakeup.notified() => true,
168 _ = watch_tx.closed() => false,
169 } {
170 if let Some(code_str) = cache.trim_pop() {
172 let _ = watch_tx.send(Ok(Some(code_str))).is_ok();
173 continue;
174 }
175
176 let mut code: u32 = rand::random::<u32>() % MAX_CODE;
178 info!(start_code = code);
179
180 let (tx, mut rx) = tokio::sync::mpsc::channel(MAX_TRIES);
182 for _ in 0..MAX_TRIES {
183 let code_str = format!("{:06}", code);
184 code = code.wrapping_add(1);
185
186 let client = client.clone();
187 let tx = tx.clone();
188 tokio::spawn(async move {
189 let check_room_result = client
190 .check_room(&code_str)
191 .await
192 .and_then(|r| r.error_for_response())
193 .map(|res| res.room);
194
195 let _ = tx.send((code_str, check_room_result)).await.is_ok();
196 });
197 }
198 drop(tx);
199
200 let mut sent_response = false;
202 while let Some((code_str, check_room_result)) = rx.recv().await {
203 match check_room_result {
204 Ok(Some(room)) if room.is_running() => {
205 if !sent_response {
206 let _ = watch_tx.send(Ok(Some(code_str))).is_ok();
207 sent_response = true;
208 } else {
209 cache.push(code_str);
211 }
212 }
213 Ok(None | Some(_)) => {
214 }
217 Err(quizizz::Error::InvalidGenericResponse(e))
218 if e.is_room_not_found() || e.is_player_login_required() =>
219 {
220 }
223 Err(e) => {
224 let e = Err(e)
225 .with_context(|| {
226 format!("failed to search for quizizz code '{}'", code_str)
227 })
228 .map_err(Arc::new);
229 error!("{:?}", e);
230 if !sent_response {
231 let _ = watch_tx.send(e).is_ok();
232 sent_response = true;
233 }
234 }
235 }
236 }
237 if !sent_response {
238 let _ = watch_tx.send(Ok(None)).is_ok();
239 }
240
241 info!("quizizz has {} cached entries", cache.len());
242 }
243}
244
245#[command]
246#[description("Locate a quizizz code")]
247#[bucket("quizizz")]
248#[checks(Enabled)]
249async fn quizizz(ctx: &Context, msg: &Message, _args: Args) -> CommandResult {
250 let data_lock = ctx.data.read().await;
251 let client_data = data_lock
252 .get::<ClientDataKey>()
253 .expect("failed to get client data");
254 let client = client_data.quizizz_client.clone();
255 drop(data_lock);
256
257 let mut loading = LoadingReaction::new(ctx.http.clone(), msg);
258
259 match client.search_for_code().await {
260 Ok(Some(code_str)) => {
261 info!("located quizizz code '{}'", code_str);
262 loading.send_ok();
263 msg.channel_id
264 .say(&ctx.http, format!("Located quizizz code: {}", code_str))
265 .await?;
266 }
267 Ok(None) => {
268 info!("quizziz finder reached limit");
269 msg.channel_id.say(&ctx.http, LIMIT_REACHED_MSG).await?;
270 }
271 Err(e) => {
272 error!("{:?}", e);
273 msg.channel_id.say(&ctx.http, format!("{:?}", e)).await?;
274 }
275 }
276
277 Ok(())
278}