diff --git a/Cargo.lock b/Cargo.lock index 22b5b51..3d836ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -181,6 +181,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b700ce4376041dcd0a327fd0097c41095743c4c8af8887265942faf1100bd040" +[[package]] +name = "cached" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99e696f7b2696ed5eae0d462a9eeafaea111d99e39b2c8ceb418afe1013bcfc" +dependencies = [ + "hashbrown 0.9.1", + "once_cell", +] + [[package]] name = "cc" version = "1.0.69" @@ -214,6 +224,7 @@ dependencies = [ "anyhow", "askama", "askama_warp", + "cached", "chrono", "futures", "irc", @@ -566,6 +577,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "hashbrown" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7afe4a420e3fe79967a00898cc1f4db7c8a49a9333a29f8a4bd76a253d5cd04" + [[package]] name = "hashbrown" version = "0.11.2" @@ -707,7 +724,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc633605454125dec4b66843673f01c7df2b89479b32e0ed634e43a91cff62a5" dependencies = [ "autocfg", - "hashbrown", + "hashbrown 0.11.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 25f4942..39cc83e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ edition = "2018" anyhow = "1" askama = { version = "0.10", features = ["with-warp"] } askama_warp = "0.11" +cached = { version = "0.25", default-features = false } chrono = "0.4" futures = "0.3" irc = "0.15" diff --git a/src/app.rs b/src/app.rs index 93d3093..b901e52 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,13 +1,34 @@ pub mod config; +pub mod duration_tools; pub mod rhai_tools; pub mod twitch; pub mod user_config; pub mod web; -use rhai::Engine; -use irc::client::prelude::{Client as IrcClient, Config as IrcConfig, Capability}; use anyhow::Result; -use twitch_api2::TwitchClient; +use cached::{TimedCache, Cached}; +use futures::{StreamExt, SinkExt}; +use irc::client::prelude::{Client as IrcClient, Config as IrcConfig, Capability}; +use rhai::Engine; +use serde::{Deserialize, Serialize}; +use tokio::{ + runtime::Handle, + sync::{ + Mutex, RwLock, + mpsc::UnboundedSender, + }, +}; +use tokio_tungstenite::tungstenite::Message as WsMessage; +use twitch_api2::{ + TwitchClient, + helix::{ + Scope, + users::User, + }, + pubsub::Topic, + types::{UserId, UserName}, + twitch_oauth2::{ClientSecret, UserToken, TwitchToken, ClientId, RefreshToken, AccessToken}, +}; use crate::app::{ config::Config, rhai_tools::{ExecutorState, ExecutorOutput}, @@ -15,20 +36,11 @@ use crate::app::{ twitch::Twitch, }; use std::{ + collections::HashMap, ops::{Deref, DerefMut}, sync::Arc, + time::Instant, }; -use futures::{StreamExt, SinkExt}; -use twitch_api2::twitch_oauth2::{ClientSecret, UserToken, TwitchToken, ClientId, RefreshToken, AccessToken}; -use twitch_api2::helix::Scope; -use serde::{Deserialize, Serialize}; -use tokio::sync::RwLock; -use twitch_api2::types::UserId; -use tokio::sync::mpsc::UnboundedSender; -use tokio::runtime::Handle; -use std::collections::HashMap; -use twitch_api2::pubsub::Topic; -use tokio_tungstenite::tungstenite::Message as WsMessage; pub struct State { pub user_config: UserConfig, @@ -38,9 +50,13 @@ pub struct State { pub twitch: Arc, pub rewards_paused: RwLock>, + pub user_login_cache: Mutex>, + pub user_id_cache: Mutex>, pub irc: IrcClient, pub irc_queue: UnboundedSender, + pub user_cooldowns: RwLock>, + pub global_cooldowns: RwLock>, pub rhai: Engine, pub script_cache: parking_lot::RwLock>, @@ -106,9 +122,13 @@ impl State { let mut rhai = Engine::new(); rhai.set_max_expr_depths(0, 0); rhai.register_type::() + .register_get("args", ExecutorState::args) .register_get("initiator", ExecutorState::initiator) .register_get("initiator_id", ExecutorState::initiator_id) - .register_get("args", ExecutorState::args) + .register_get("broadcaster", ExecutorState::broadcaster) + .register_get("moderator", ExecutorState::moderator) + .register_get("vip", ExecutorState::vip) + .register_get("subscriber", ExecutorState::subscriber) .register_fn("get_username", ExecutorState::get_username::<&str>) .register_fn("get_user_id", ExecutorState::get_user_id::<&str>) .register_fn("get_channel_info", ExecutorState::get_channel_info::<&str>); @@ -125,9 +145,13 @@ impl State { twitch, rewards_paused: Default::default(), + user_login_cache: Mutex::new(TimedCache::with_lifespan(3600)), + user_id_cache: Mutex::new(TimedCache::with_lifespan(3600)), irc, irc_queue: queue_tx, + user_cooldowns: Default::default(), + global_cooldowns: Default::default(), rhai, script_cache: Default::default(), @@ -210,6 +234,37 @@ impl State { Ok(state) } + + pub async fn get_user_from_id>(&self, id: S) -> anyhow::Result> { + let id = id.into(); + if let Some(user) = self.user_id_cache.lock().await.cache_get(&id) { + return Ok(Some(user.clone())); + } + + let user = self.twitch.client.helix.get_user_from_id(id, &self.twitch.bot_token).await?; + self.add_user_to_cache(&user).await; + + Ok(user) + } + + pub async fn get_user_from_login>(&self, login: S) -> anyhow::Result> { + let login = login.into(); + if let Some(user) = self.user_login_cache.lock().await.cache_get(&login) { + return Ok(Some(user.clone())); + } + + let user = self.twitch.client.helix.get_user_from_login(login, &self.twitch.bot_token).await?; + self.add_user_to_cache(&user).await; + + Ok(user) + } + + async fn add_user_to_cache(&self, user: &Option) { + if let Some(user) = user { + self.user_id_cache.lock().await.cache_set(user.id.clone(), user.clone()); + self.user_login_cache.lock().await.cache_set(user.login.clone(), user.clone()); + } + } } #[derive(Clone)] @@ -267,7 +322,7 @@ pub fn run_rhai>(state: Arc, script: S, fn_state: Executo let ast = state.rhai.compile(&script)?; state.script_cache.write().insert(script.clone(), ast.clone()); ast - }, + } }; let mut output = rhai::Dynamic::from(ExecutorOutput::default()); diff --git a/src/app/config.rs b/src/app/config.rs index 120187f..e32cb73 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -1,6 +1,6 @@ use chrono::Duration; use serde::{Deserialize, Serialize}; -use serde_with::{serde_as, DurationSeconds}; +use serde_with::{serde_as, DurationSecondsWithFrac}; use crate::app::FullUserToken; #[derive(Deserialize, Serialize, Default)] @@ -30,9 +30,9 @@ pub enum CommandExecutor { #[serde_as] #[derive(Deserialize, Serialize, Default, Clone)] pub struct Cooldowns { - #[serde_as(as = "Option>")] + #[serde_as(as = "Option>")] pub global: Option, - #[serde_as(as = "Option>")] + #[serde_as(as = "Option>")] pub user: Option, } diff --git a/src/app/duration_tools.rs b/src/app/duration_tools.rs new file mode 100644 index 0000000..17c116e --- /dev/null +++ b/src/app/duration_tools.rs @@ -0,0 +1,12 @@ +use chrono::Duration; + +pub trait DurationTools { + fn seconds_f64(&self) -> f64; +} + +impl DurationTools for Duration { + fn seconds_f64(&self) -> f64 { + let millis = self.num_milliseconds(); + millis as f64 / 1_000.0 + } +} diff --git a/src/app/rhai_tools.rs b/src/app/rhai_tools.rs index c5e63fa..b809f2e 100644 --- a/src/app/rhai_tools.rs +++ b/src/app/rhai_tools.rs @@ -1,8 +1,9 @@ -use crate::app::twitch::Twitch; +use twitch_api2::{ + types::{UserId, UserName}, + helix::channels::{GetChannelInformationRequest, ChannelInformation}, +}; +use crate::app::State; use std::sync::Arc; -use twitch_api2::types::{UserId, UserName}; -use tokio::runtime::Handle; -use twitch_api2::helix::channels::{GetChannelInformationRequest, ChannelInformation}; #[derive(Default, Clone)] pub struct ExecutorOutput { @@ -17,17 +18,20 @@ impl ExecutorOutput { #[derive(Clone)] pub struct ExecutorState { - pub runtime: Handle, - pub twitch: Arc, + pub state: Arc, pub args: Vec, pub initiator: String, pub initiator_id: UserId, - // TODO - // pub moderator: bool, - // pub broadcaster: bool, - // pub vip: bool, + /// Always false if this is a channel point reward event + pub broadcaster: bool, + /// Always false if this is a channel point reward event + pub moderator: bool, + /// Always false if this is a channel point reward event + pub vip: bool, + /// Always false if this is a channel point reward event + pub subscriber: bool, } impl ExecutorState { @@ -44,39 +48,45 @@ impl ExecutorState { self.args.clone() } - async fn internal_get_username(&self, id: UserId) -> Option { - self.twitch.client.helix.get_user_from_id(id, &self.twitch.bot_token) - .await - .ok()? + pub fn broadcaster(&mut self) -> bool { + self.broadcaster + } + + pub fn moderator(&mut self) -> bool { + self.moderator + } + + pub fn vip(&mut self) -> bool { + self.vip + } + + pub fn subscriber(&mut self) -> bool { + self.subscriber + } + + pub fn get_username>(&mut self, id: S) -> rhai::Dynamic { + self.state.runtime.block_on(self.state.get_user_from_id(id)) + .ok() + .flatten() .map(|user| user.login.to_string()) - } - - pub fn get_username>(&mut self, id: S) -> rhai::Dynamic { - match self.runtime.block_on(self.internal_get_username(UserId::new(id.into()))) { - Some(x) => rhai::Dynamic::from(x), - None => rhai::Dynamic::from(()), - } - } - - async fn internal_get_id>(&self, username: S) -> Option { - self.twitch.client.helix.get_user_from_login(username, &self.twitch.bot_token) - .await - .ok()? - .map(|user| user.id) + .map(rhai::Dynamic::from) + .unwrap_or_else(|| rhai::Dynamic::from(())) } pub fn get_user_id>(&mut self, username: S) -> rhai::Dynamic { - match self.runtime.block_on(self.internal_get_id(username)).map(|user| user.to_string()) { - Some(x) => rhai::Dynamic::from(x), - None => rhai::Dynamic::from(()), - } + self.state.runtime.block_on(self.state.get_user_from_login(username)) + .ok() + .flatten() + .map(|user| user.id.to_string()) + .map(rhai::Dynamic::from) + .unwrap_or_else(|| rhai::Dynamic::from(())) } async fn internal_get_channel_info>(&self, id: S) -> Option<(String, String, String)> { let req = GetChannelInformationRequest::builder() .broadcaster_id(id.into()) .build(); - self.twitch.client.helix.req_get(req, &self.twitch.bot_token) + self.state.twitch.client.helix.req_get(req, &self.state.twitch.bot_token) .await .ok()? .data @@ -84,7 +94,7 @@ impl ExecutorState { } pub fn get_channel_info>(&mut self, id: S) -> rhai::Dynamic { - match self.runtime.block_on(self.internal_get_channel_info(id)) { + match self.state.runtime.block_on(self.internal_get_channel_info(id)) { Some(x) => rhai::Dynamic::from(vec![ rhai::Dynamic::from(x.0), rhai::Dynamic::from(x.1), diff --git a/src/app/twitch.rs b/src/app/twitch.rs index 4b143e0..f22c40b 100644 --- a/src/app/twitch.rs +++ b/src/app/twitch.rs @@ -23,14 +23,14 @@ pub async fn handle_irc_event(state: Arc, event: Message) -> anyhow::Resu match &event.command { Command::Response(resp, _) if *resp == Response::RPL_WELCOME => { state.irc.send_join(&channel_name)?; - }, + } // FIXME: do correct checking here Command::PRIVMSG(channel, message) if *channel == channel_name => { on_privmsg(state, &event, &message).await?; - }, + } _ => { // eprintln!("{:#?}", c); - }, + } } Ok(()) @@ -41,14 +41,26 @@ async fn on_privmsg(state: Arc, event: &Message, message: &str) -> anyhow .ok_or_else(|| anyhow::anyhow!("missing source"))? .to_string(); - let initiator_id = event.tags - .as_ref() - .ok_or_else(|| anyhow::anyhow!("missing tags"))? + let tags = event.tags.as_ref().ok_or_else(|| anyhow::anyhow!("missing tags"))?; + + let initiator_id = tags .iter() .find(|tag| tag.0 == "user-id") .and_then(|tag| tag.1.clone()) .ok_or_else(|| anyhow::anyhow!("missing user id"))?; + let badges: Vec<&str> = tags + .iter() + .find(|tag| tag.0 == "badges") + .and_then(|tag| tag.1.as_ref()) + .map(|s| s.split(',').collect()) + .unwrap_or_default(); + + let broadcaster = badges.iter().any(|badge| badge.starts_with("broadcaster/")); + let moderator = badges.iter().any(|badge| badge.starts_with("moderator/")); + let vip = badges.iter().any(|badge| badge.starts_with("vip/")); + let subscriber = badges.iter().any(|badge| badge.starts_with("subscriber/")); + let words: Vec<&str> = message.split(' ').collect(); let command_name = words[0]; let args = words[1..].iter() @@ -61,23 +73,47 @@ async fn on_privmsg(state: Arc, event: &Message, message: &str) -> anyhow .commands .iter().find(|command| command.name == command_name || command.aliases.iter().any(|alias| alias == command_name)) .map(Clone::clone); - let command = match command { + let command: crate::app::config::Command = match command { Some(c) => c, None => return Ok(()) }; + if let Some(gcd) = &command.cooldowns.global { + if let Some(last_used) = state.global_cooldowns.read().await.get(&command.name).cloned() { + if last_used.elapsed() < gcd.to_std().unwrap() { + return Ok(()); + } + } + + state.global_cooldowns.write().await.insert(command.name.clone(), Instant::now()); + } + + if let Some(cooldown) = &command.cooldowns.user { + let user_id = UserId::new(&initiator_id); + if let Some(last_used) = state.user_cooldowns.read().await.get(&(user_id.clone(), command.name.clone())).cloned() { + if last_used.elapsed() < cooldown.to_std().unwrap() { + return Ok(()); + } + } + + state.user_cooldowns.write().await.insert((user_id, command.name.clone()), Instant::now()); + } + match &command.executor { - CommandExecutor::Text(t) => { state.irc_queue.send(t.to_string()).ok(); }, + CommandExecutor::Text(t) => { state.irc_queue.send(t.to_string()).ok(); } CommandExecutor::Rhai(t) => { let fn_state = ExecutorState { - twitch: Arc::clone(&state.twitch), - initiator_id: UserId::new(initiator_id), - initiator, + state: Arc::clone(&state), args, - runtime: state.runtime.clone(), + initiator, + initiator_id: UserId::new(initiator_id), + broadcaster, + moderator, + vip, + subscriber, }; crate::app::run_rhai(Arc::clone(&state), t, fn_state); - }, + } } Ok(()) @@ -88,6 +124,7 @@ use twitch_api2::pubsub::{ TopicData, channel_points::ChannelPointsChannelV1Reply, }; +use std::time::Instant; pub async fn handle_pubsub(state: Arc, event: WsMessage) -> anyhow::Result<()> { let json = match event { @@ -116,11 +153,14 @@ pub async fn handle_pubsub(state: Arc, event: WsMessage) -> anyhow::Resul .map(|input| input.split(' ').map(ToOwned::to_owned).map(rhai::Dynamic::from).collect()) .unwrap_or_default(); let fn_state = ExecutorState { - twitch: Arc::clone(&state.twitch), + state: Arc::clone(&state), + args, initiator: redemption.user.login.to_string(), initiator_id: redemption.user.id, - args, - runtime: state.runtime.clone(), + broadcaster: false, + moderator: false, + vip: false, + subscriber: false, }; crate::app::run_rhai(Arc::clone(&state), &action.rhai, fn_state); diff --git a/src/app/web.rs b/src/app/web.rs index 01d0b28..b912de6 100644 --- a/src/app/web.rs +++ b/src/app/web.rs @@ -45,6 +45,7 @@ enum CustomRejection { InvalidAccessToken, InvalidForm, TwitchError, + InvalidCommand, } impl warp::reject::Reject for CustomRejection {} @@ -57,6 +58,7 @@ async fn handle_rejection(err: Rejection) -> Result { CustomRejection::InvalidAccessToken => (StatusCode::UNAUTHORIZED, Cow::from("invalid access token")), CustomRejection::InvalidForm => (StatusCode::BAD_REQUEST, Cow::from("invalid form submission")), CustomRejection::TwitchError => (StatusCode::INTERNAL_SERVER_ERROR, Cow::from("twitch error")), + CustomRejection::InvalidCommand => (StatusCode::BAD_REQUEST, Cow::from("invalid command")), // CustomRejection::Askama(e) => (StatusCode::INTERNAL_SERVER_ERROR, Cow::from(format!("templating error: {:#?}", e))), } } else if let Some(e) = err.find::() { diff --git a/src/app/web/route/commands.rs b/src/app/web/route/commands.rs index 29d5828..caaf9f8 100644 --- a/src/app/web/route/commands.rs +++ b/src/app/web/route/commands.rs @@ -1,14 +1,10 @@ -use warp::{ - Filter, Reply, - filters::BoxedFilter, - http::Uri, -}; +use warp::{Filter, Reply, filters::BoxedFilter, http::Uri, Rejection}; use crate::app::{ State, config::{CommandExecutor, Command, Cooldowns}, web::{ CustomRejection, - template::commands::{CommandsTemplate, AddCommandTemplate}, + template::commands::{CommandsTemplate, AddCommandTemplate, EditCommandTemplate}, }, }; use std::{ @@ -16,16 +12,20 @@ use std::{ convert::Infallible, sync::Arc, }; +use std::str::FromStr; +use chrono::Duration; pub fn commands_routes(state: Arc) -> BoxedFilter<(impl Reply, )> { warp::get() .and( commands_get(Arc::clone(&state)) .or(commands_add_get()) + .or(commands_edit_get(Arc::clone(&state))) ) .or(warp::post().and( commands_add_post(Arc::clone(&state)) .or(commands_delete_post(Arc::clone(&state))) + .or(commands_edit_post(Arc::clone(&state))) )) .boxed() } @@ -52,41 +52,60 @@ fn commands_add_get() -> BoxedFilter<(impl Reply, )> { .boxed() } +fn get_command_from_body(mut form: HashMap) -> Result { + let form_get = try { + let name = form.remove("name")?; + let aliases = form.remove("aliases")?; + let cooldown = form.remove("cooldown").and_then(|t| if t.trim().is_empty() { None } else { Some(t) }); + let gcd = form.remove("gcd").and_then(|t| if t.trim().is_empty() { None } else { Some(t) }); + let kind = form.remove("type")?; + let script = form.remove("executor_data")?; + (name, aliases, cooldown, gcd, kind, script) + }; + + let (name, aliases, cooldown, gcd, kind, script) = match form_get { + Some(x) => x, + None => return Err(warp::reject::custom(CustomRejection::InvalidForm)), + }; + + let gcd = gcd + .and_then(|t| f64::from_str(&t).ok()) + .map(|f| Duration::milliseconds((f * 1_000.0) as i64)); + + let cooldown = cooldown + .and_then(|t| f64::from_str(&t).ok()) + .map(|f| Duration::milliseconds((f * 1_000.0) as i64)); + + let executor = match &*kind { + "Text" => CommandExecutor::Text(script), + "Rhai" => CommandExecutor::Rhai(script), + _ => return Err(warp::reject::custom(CustomRejection::InvalidForm)), + }; + + Ok(Command { + name, + executor, + aliases: aliases.split("\n").map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(), + cooldowns: Cooldowns { + global: gcd, + user: cooldown, + }, + }) +} + fn commands_add_post(state: Arc) -> BoxedFilter<(impl Reply, )> { warp::path("commands") .and(warp::path("add")) .and(warp::path::end()) .and(warp::body::content_length_limit(1024 * 5)) .and(warp::body::form()) - .and_then(move |mut form: HashMap| { + .and_then(move |form: HashMap| { let state = Arc::clone(&state); async move { - let form_get = try { - let name = form.remove("name")?; - let aliases = form.remove("aliases")?; - let kind = form.remove("type")?; - let script = form.remove("executor_data")?; - (name, aliases, kind, script) + let command = match get_command_from_body(form) { + Ok(c) => c, + Err(e) => return Err(e), }; - - let (name, aliases, kind, script) = match form_get { - Some(x) => x, - None => return Err(warp::reject::custom(CustomRejection::InvalidForm)), - }; - - let executor = match &*kind { - "Text" => CommandExecutor::Text(script), - "Rhai" => CommandExecutor::Rhai(script), - _ => return Err(warp::reject::custom(CustomRejection::InvalidForm)), - }; - - let command = Command { - name, - executor, - aliases: aliases.split("\n").map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect(), - cooldowns: Cooldowns::default(), - }; - state.config.write().await.commands.push(command); Ok(warp::redirect(Uri::from_static("/commands"))) @@ -95,6 +114,57 @@ fn commands_add_post(state: Arc) -> BoxedFilter<(impl Reply, )> { .boxed() } +fn commands_edit_get(state: Arc) -> BoxedFilter<(impl Reply, )> { + warp::path("commands") + .and(warp::path("edit")) + .and(warp::path::param()) + .and(warp::path::end()) + .and_then(move |name: String| { + let state = Arc::clone(&state); + async move { + match state.config.read().await.commands + .iter() + .find(|command| command.name == name) + .cloned() + { + Some(command) => Ok(command), + None => Err(warp::reject::custom(CustomRejection::InvalidCommand)), + } + } + }) + .map(|command: Command| EditCommandTemplate { + command, + }) + .boxed() +} + +fn commands_edit_post(state: Arc) -> BoxedFilter<(impl Reply, )> { + warp::path("commands") + .and(warp::path("edit")) + .and(warp::path::end()) + .and(warp::body::content_length_limit(1024 * 5)) + .and(warp::body::form()) + .and_then(move |form: HashMap| { + let state = Arc::clone(&state); + async move { + let command = match get_command_from_body(form) { + Ok(c) => c, + Err(e) => return Err(e), + }; + let mut config = state.config.write().await; + for existing in config.commands.iter_mut() { + if existing.name == &*command.name { + *existing = command; + break; + } + } + + Ok(warp::redirect(Uri::from_static("/commands"))) + } + }) + .boxed() +} + fn commands_delete_post(state: Arc) -> BoxedFilter<(impl Reply, )> { warp::path("commands") .and(warp::path("delete")) diff --git a/src/app/web/route/redemptions.rs b/src/app/web/route/redemptions.rs index fd801d7..109cfaf 100644 --- a/src/app/web/route/redemptions.rs +++ b/src/app/web/route/redemptions.rs @@ -17,12 +17,15 @@ use std::{ convert::Infallible, sync::Arc, }; +use twitch_api2::helix::points::{GetCustomRewardRequest, CustomReward}; +use crate::app::web::template::redemptions::ListRedemptionsTemplate; pub fn redemptions_routes(state: Arc) -> BoxedFilter<(impl Reply, )> { warp::get() .and( redemptions_get(Arc::clone(&state)) .or(redemptions_add_get()) + .or(redemptions_list_get(Arc::clone(&state))) ) .or(warp::post().and( redemptions_add_post(Arc::clone(&state)) @@ -109,3 +112,26 @@ fn redemptions_delete_post(state: Arc) -> BoxedFilter<(impl Reply, )> { }) .boxed() } + +fn redemptions_list_get(state: Arc) -> BoxedFilter<(impl Reply, )> { + warp::path("redemptions") + .and(warp::path("list")) + .and(warp::path::end()) + .and_then( move || { + let state = Arc::clone(&state); + async move { + let req = GetCustomRewardRequest::builder() + .broadcaster_id(state.user_config.twitch.channel_id.to_string()) + .build(); + let rewards: Vec = match state.twitch.client.helix.req_get(req, &state.twitch.user_token).await { + Ok(resp) => resp.data, + Err(_) => return Err(warp::reject::custom(CustomRejection::TwitchError)), + }; + Ok(rewards) + } + }) + .map(|rewards: Vec| ListRedemptionsTemplate { + rewards, + }) + .boxed() +} diff --git a/src/app/web/template/commands.rs b/src/app/web/template/commands.rs index 9ec1341..028d259 100644 --- a/src/app/web/template/commands.rs +++ b/src/app/web/template/commands.rs @@ -1,7 +1,10 @@ use askama::Template; -use crate::app::config::{ - Command, - CommandExecutor, +use crate::app::{ + duration_tools::DurationTools, + config::{ + Command, + CommandExecutor, + }, }; #[derive(Template)] @@ -13,3 +16,9 @@ pub struct CommandsTemplate { #[derive(Template)] #[template(path = "add_command.html")] pub struct AddCommandTemplate; + +#[derive(Template)] +#[template(path = "edit_command.html")] +pub struct EditCommandTemplate { + pub command: Command, +} diff --git a/templates/_base.html b/templates/_base.html index 9c89a87..837e606 100644 --- a/templates/_base.html +++ b/templates/_base.html @@ -69,6 +69,26 @@ form > * { margin-bottom: .5em; } + + .breadcrumbs { + display: flex; + list-style: none; + } + + .breadcrumbs li:not(:first-child)::before { + content: '/'; + margin-left: .5em; + margin-right: .5em; + font-style: normal; + } + + .breadcrumbs li.future { + font-style: italic; + } + + .breadcrumbs li.current { + font-weight: bold; + } {% block head %}{% endblock head %} diff --git a/templates/add_command.html b/templates/add_command.html index b55dfff..e07e8b3 100644 --- a/templates/add_command.html +++ b/templates/add_command.html @@ -3,9 +3,19 @@ {% block title %}Add command{% endblock %} {% block body %} + +
+
+ + +
diff --git a/templates/commands.html b/templates/commands.html index 82153ec..67b48d8 100644 --- a/templates/commands.html +++ b/templates/commands.html @@ -20,9 +20,11 @@ {% endblock %} {% block body %} -
- Add -
+ {% for command in commands %}
@@ -30,6 +32,22 @@ {% if !command.aliases.is_empty() %} {{ command.aliases.join(", ") }} {% endif %} +
+ + {%- match command.cooldowns.user -%} + {%- when Some with (c) -%} + Cooldown: {{ c.seconds_f64() }}s + {%- else -%} + {%- endmatch -%} + + + {%- match command.cooldowns.global -%} + {%- when Some with (c) -%} + GCD: {{ c.seconds_f64() }}s + {%- else -%} + {%- endmatch -%} + +

             {%- match command.executor -%}
                 {%- when CommandExecutor::Text with (t) -%}
@@ -38,6 +56,7 @@
                     {{ t }}
             {%- endmatch -%}
     
+ Edit diff --git a/templates/edit_command.html b/templates/edit_command.html new file mode 100644 index 0000000..6af0cf7 --- /dev/null +++ b/templates/edit_command.html @@ -0,0 +1,50 @@ +{% extends "_base.html" %} + +{% block title %}Edit command{% endblock %} + +{% block body %} + + + + + +
+ {% let user %} + {% match command.cooldowns.user %} + {% when Some with (cd) %} + {% let user = cd.seconds_f64().to_string() %} + {% else %} + {% let user = "".to_string() %} + {% endmatch %} + + + {% let global %} + {% match command.cooldowns.global %} + {% when Some with (cd) %} + {% let global = cd.seconds_f64().to_string() %} + {% else %} + {% let global = "".to_string() %} + {% endmatch %} + +
+ {% match command.executor %} + {% when CommandExecutor::Text with (text) %} + + + {% when CommandExecutor::Rhai with (rhai) %} + + + {% endmatch %} + + +{% endblock %} diff --git a/templates/index.html b/templates/index.html index c352d88..65e4c04 100644 --- a/templates/index.html +++ b/templates/index.html @@ -11,10 +11,9 @@ -
    -
  • - Commands - Redemptions -
  • + {% endblock %} diff --git a/templates/list_redemptions.html b/templates/list_redemptions.html index 9385ef6..0a1f543 100644 --- a/templates/list_redemptions.html +++ b/templates/list_redemptions.html @@ -20,6 +20,12 @@ {% endblock %} {% block body %} + + {% for reward in rewards %}
    {{ reward.title }} diff --git a/templates/redemptions.html b/templates/redemptions.html index e306d76..9449656 100644 --- a/templates/redemptions.html +++ b/templates/redemptions.html @@ -20,9 +20,12 @@ {% endblock %} {% block body %} -
    - Add -
    + {% for redemption in redemptions %}