feat: add editing and other stuff
This commit is contained in:
parent
6dcc3388fb
commit
999f27623a
|
@ -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]]
|
||||
|
|
|
@ -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"
|
||||
|
|
87
src/app.rs
87
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<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());
|
||||
|
|
|
@ -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>,
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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>() {
|
||||
|
|
|
@ -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"))
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"/>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 %}
|
|
@ -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 %}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
|
|
Loading…
Reference in New Issue