feat: add editing and other stuff

This commit is contained in:
Anna 2021-08-19 22:59:12 -04:00
parent 6dcc3388fb
commit 999f27623a
19 changed files with 471 additions and 116 deletions

19
Cargo.lock generated
View File

@ -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]]

View File

@ -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"

View File

@ -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<Twitch>,
pub rewards_paused: RwLock<HashMap<String, bool>>,
pub user_login_cache: Mutex<TimedCache<UserName, User>>,
pub user_id_cache: Mutex<TimedCache<UserId, User>>,
pub irc: IrcClient,
pub irc_queue: UnboundedSender<String>,
pub user_cooldowns: RwLock<HashMap<(UserId, String), Instant>>,
pub global_cooldowns: RwLock<HashMap<String, Instant>>,
pub rhai: Engine,
pub script_cache: parking_lot::RwLock<HashMap<String, rhai::AST>>,
@ -106,9 +122,13 @@ impl State {
let mut rhai = Engine::new();
rhai.set_max_expr_depths(0, 0);
rhai.register_type::<ExecutorState>()
.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<S: Into<UserId>>(&self, id: S) -> anyhow::Result<Option<User>> {
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<S: Into<UserName>>(&self, login: S) -> anyhow::Result<Option<User>> {
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<User>) {
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<S: Into<String>>(state: Arc<State>, 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());

View File

@ -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<DurationSeconds<f64>>")]
#[serde_as(as = "Option<DurationSecondsWithFrac<f64>>")]
pub global: Option<Duration>,
#[serde_as(as = "Option<DurationSeconds<f64>>")]
#[serde_as(as = "Option<DurationSecondsWithFrac<f64>>")]
pub user: Option<Duration>,
}

12
src/app/duration_tools.rs Normal file
View File

@ -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
}
}

View File

@ -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<Twitch>,
pub state: Arc<State>,
pub args: Vec<rhai::Dynamic>,
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<String> {
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<S: Into<UserId>>(&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<S: Into<String>>(&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<S: Into<UserName>>(&self, username: S) -> Option<UserId> {
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<S: Into<UserName>>(&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<S: Into<UserId>>(&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<S: Into<UserId>>(&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),

View File

@ -23,14 +23,14 @@ pub async fn handle_irc_event(state: Arc<State>, 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<State>, 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<State>, 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<State>, event: WsMessage) -> anyhow::Result<()> {
let json = match event {
@ -116,11 +153,14 @@ pub async fn handle_pubsub(state: Arc<State>, 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);

View File

@ -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<impl Reply, Infallible> {
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::<warp::reject::MissingCookie>() {

View File

@ -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<State>) -> 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<String, String>) -> Result<Command, Rejection> {
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<State>) -> 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<String, String>| {
.and_then(move |form: HashMap<String, String>| {
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<State>) -> BoxedFilter<(impl Reply, )> {
.boxed()
}
fn commands_edit_get(state: Arc<State>) -> 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<State>) -> 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<String, String>| {
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<State>) -> BoxedFilter<(impl Reply, )> {
warp::path("commands")
.and(warp::path("delete"))

View File

@ -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<State>) -> 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<State>) -> BoxedFilter<(impl Reply, )> {
})
.boxed()
}
fn redemptions_list_get(state: Arc<State>) -> 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<CustomReward> = 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<CustomReward>| ListRedemptionsTemplate {
rewards,
})
.boxed()
}

View File

@ -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,
}

View File

@ -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;
}
</style>
{% block head %}{% endblock head %}
</head>

View File

@ -3,9 +3,19 @@
{% block title %}Add command{% endblock %}
{% block body %}
<ul class="breadcrumbs">
<li><a href="/">Home</a></li>
<li><a href="/commands">Commands</a></li>
<li class="current"><a href="/commands/add">Add</a></li>
</ul>
<form action="/commands/add" method="post">
<input type="text" name="name" placeholder="Name"/>
<textarea name="aliases" placeholder="Aliases separated by newlines"></textarea>
<div>
<input type="number" step="0.01" name="cooldown" placeholder="Cooldown (s)" min="0"/>
<input type="number" step="0.01" name="gcd" placeholder="GCD (s)" min="0"/>
</div>
<select name="type">
<option>Text</option>
<option>Rhai</option>

View File

@ -3,6 +3,12 @@
{% block title %}Add redemption{% endblock %}
{% block body %}
<ul class="breadcrumbs">
<li><a href="/">Home</a></li>
<li><a href="/redemptions">Redemptions</a></li>
<li class="current"><a href="/redemptions/add">Add</a></li>
</ul>
<form action="/redemptions/add" method="post">
<input type="text" name="name" placeholder="Name"/>
<input type="text" name="twitch_id" placeholder="Twitch redemption ID"/>

View File

@ -20,9 +20,11 @@
{% endblock %}
{% block body %}
<div>
<a href="/commands/add">Add</a>
</div>
<ul class="breadcrumbs">
<li><a href="/">Home</a></li>
<li class="current"><a href="/commands">Commands</a></li>
<li class="future"><a href="/commands/add">Add</a></li>
</ul>
{% for command in commands %}
<div class="command">
@ -30,6 +32,22 @@
{% if !command.aliases.is_empty() %}
<em>{{ command.aliases.join(", ") }}</em>
{% endif %}
<div>
<span>
{%- match command.cooldowns.user -%}
{%- when Some with (c) -%}
Cooldown: {{ c.seconds_f64() }}s
{%- else -%}
{%- endmatch -%}
</span>
<span>
{%- match command.cooldowns.global -%}
{%- when Some with (c) -%}
GCD: {{ c.seconds_f64() }}s
{%- else -%}
{%- endmatch -%}
</span>
</div>
<pre><code>
{%- match command.executor -%}
{%- when CommandExecutor::Text with (t) -%}
@ -38,6 +56,7 @@
{{ t }}
{%- endmatch -%}
</code></pre>
<a href="/commands/edit/{{ command.name }}">Edit</a>
<form action="/commands/delete" method="post">
<input type="hidden" name="name" value="{{ command.name }}"/>
<button type="submit">Delete</button>

View File

@ -0,0 +1,50 @@
{% extends "_base.html" %}
{% block title %}Edit command{% endblock %}
{% block body %}
<ul class="breadcrumbs">
<li><a href="/">Home</a></li>
<li><a href="/commands">Commands</a></li>
<li class="current"><a href="/commands/edit/{{ command.name }}">Edit</a></li>
</ul>
<form action="/commands/edit" method="post">
<input type="text" name="name" placeholder="Name" value="{{ command.name }}"/>
<textarea name="aliases" placeholder="Aliases separated by newlines">{{ command.aliases.join("\n") }}</textarea>
<div>
{% let user %}
{% match command.cooldowns.user %}
{% when Some with (cd) %}
{% let user = cd.seconds_f64().to_string() %}
{% else %}
{% let user = "".to_string() %}
{% endmatch %}
<input type="number" step="0.01" name="cooldown" placeholder="Cooldown (s)" min="0" value="{{ user }}"/>
{% let global %}
{% match command.cooldowns.global %}
{% when Some with (cd) %}
{% let global = cd.seconds_f64().to_string() %}
{% else %}
{% let global = "".to_string() %}
{% endmatch %}
<input type="number" step="0.01" name="gcd" placeholder="GCD (s)" min="0" value = "{{ global }}"/>
</div>
{% match command.executor %}
{% when CommandExecutor::Text with (text) %}
<select name="type">
<option selected>Text</option>
<option>Rhai</option>
</select>
<textarea name="executor_data" placeholder="Text/script">{{ text }}</textarea>
{% when CommandExecutor::Rhai with (rhai) %}
<select name="type">
<option>Text</option>
<option selected>Rhai</option>
</select>
<textarea name="executor_data" placeholder="Text/script">{{ rhai }}</textarea>
{% endmatch %}
<button type="submit">Edit</button>
</form>
{% endblock %}

View File

@ -11,10 +11,9 @@
<button type="submit">Submit</button>
</form>
<ul>
<li>
<a href="/commands">Commands</a>
<a href="/redemptions">Redemptions</a>
</li>
<ul class="breadcrumbs">
<li class="current"><a href="/">Home</a></li>
<li class="future"><a href="/commands">Commands</a></li>
<li class="future"><a href="/redemptions">Redemptions</a></li>
</ul>
{% endblock %}

View File

@ -20,6 +20,12 @@
{% endblock %}
{% block body %}
<ul class="breadcrumbs">
<li><a href="/">Home</a></li>
<li><a href="/redemptions">Redemptions</a></li>
<li class="current"><a href="/redemptions/list">List</a></li>
</ul>
{% for reward in rewards %}
<div class="reward">
<strong>{{ reward.title }}</strong>

View File

@ -20,9 +20,12 @@
{% endblock %}
{% block body %}
<div>
<a href="/redemptions/add">Add</a>
</div>
<ul class="breadcrumbs">
<li><a href="/">Home</a></li>
<li class="current"><a href="/redemptions">Redemptions</a></li>
<li class="future"><a href="/redemptions/add">Add</a></li>
<li class="future"><a href="/redemptions/list">List</a></li>
</ul>
{% for redemption in redemptions %}
<div class="redemption">