chore: initial commit
This commit is contained in:
commit
0da2212258
|
@ -0,0 +1,3 @@
|
||||||
|
/target
|
||||||
|
/config.toml
|
||||||
|
/data.json
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,30 @@
|
||||||
|
[package]
|
||||||
|
name = "clemsbot"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2018"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
askama = { version = "0.10", features = ["with-warp"] }
|
||||||
|
askama_warp = "0.11"
|
||||||
|
chrono = "0.4"
|
||||||
|
futures = "0.3"
|
||||||
|
irc = "0.15"
|
||||||
|
parking_lot = "0.11"
|
||||||
|
reqwest = "0.11"
|
||||||
|
rhai = { version = "1", features = ["sync"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
serde_with = { version = "1", features = ["chrono"] }
|
||||||
|
toml = "0.5"
|
||||||
|
tokio-tungstenite = { version = "0.15", features = ["rustls-tls"] }
|
||||||
|
twitch_api2 = { version = "0.6.0-rc.2", features = ["twitch_oauth2", "client", "reqwest_client", "helix", "pubsub"] }
|
||||||
|
url = "2"
|
||||||
|
warp = "0.3"
|
||||||
|
|
||||||
|
[dependencies.tokio]
|
||||||
|
version = "1"
|
||||||
|
default-features = false
|
||||||
|
features = ["rt-multi-thread", "macros", "fs", "io-util", "signal"]
|
|
@ -0,0 +1,323 @@
|
||||||
|
pub mod config;
|
||||||
|
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 crate::app::{
|
||||||
|
config::Config,
|
||||||
|
rhai_tools::{ExecutorState, ExecutorOutput},
|
||||||
|
user_config::UserConfig,
|
||||||
|
twitch::Twitch,
|
||||||
|
};
|
||||||
|
use std::{
|
||||||
|
ops::{Deref, DerefMut},
|
||||||
|
sync::Arc,
|
||||||
|
};
|
||||||
|
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,
|
||||||
|
pub config: RwLock<Config>,
|
||||||
|
|
||||||
|
pub channel_name: String,
|
||||||
|
|
||||||
|
pub twitch: Arc<Twitch>,
|
||||||
|
pub rewards_paused: RwLock<HashMap<String, bool>>,
|
||||||
|
|
||||||
|
pub irc: IrcClient,
|
||||||
|
pub irc_queue: UnboundedSender<String>,
|
||||||
|
|
||||||
|
pub rhai: Engine,
|
||||||
|
pub script_cache: parking_lot::RwLock<HashMap<String, rhai::AST>>,
|
||||||
|
pub runtime: Handle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State {
|
||||||
|
#[allow(unreachable_code)]
|
||||||
|
pub async fn new(runtime: Handle, user_config: UserConfig, mut config: Config) -> Result<Arc<Self>> {
|
||||||
|
let http_client = reqwest::Client::new();
|
||||||
|
|
||||||
|
println!("Verifying bot token");
|
||||||
|
verify_token(&mut config.bot_token, &user_config, &http_client, vec![
|
||||||
|
// IRC
|
||||||
|
Scope::ChatRead,
|
||||||
|
Scope::ChatEdit,
|
||||||
|
]).await?;
|
||||||
|
println!("Bot token ready");
|
||||||
|
|
||||||
|
println!("Verifying user token");
|
||||||
|
verify_token(&mut config.user_token, &user_config, &http_client, vec![
|
||||||
|
// Channel points redemptions
|
||||||
|
Scope::ChannelReadRedemptions,
|
||||||
|
Scope::ChannelManageRedemptions,
|
||||||
|
|
||||||
|
// Mod stuff
|
||||||
|
Scope::ChannelModerate,
|
||||||
|
Scope::ModerationRead,
|
||||||
|
Scope::ModeratorManageAutoMod,
|
||||||
|
]).await?;
|
||||||
|
println!("User token ready");
|
||||||
|
|
||||||
|
let twitch_client = TwitchClient::with_client(http_client);
|
||||||
|
let twitch = Arc::new(Twitch {
|
||||||
|
client: twitch_client,
|
||||||
|
bot_token: config.bot_token.clone().unwrap().token,
|
||||||
|
user_token: config.user_token.clone().unwrap().token,
|
||||||
|
});
|
||||||
|
|
||||||
|
let user_id = UserId::new(user_config.twitch.channel_id.to_string());
|
||||||
|
let channel_name = twitch.client.helix.get_user_from_id(user_id, &twitch.bot_token)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no channel for id {}", user_config.twitch.channel_id))?
|
||||||
|
.login
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let irc_config = IrcConfig {
|
||||||
|
server: Some("irc.chat.twitch.tv".into()),
|
||||||
|
port: Some(6697),
|
||||||
|
username: Some(twitch.bot_token.login.clone()),
|
||||||
|
nickname: Some(twitch.bot_token.login.clone()),
|
||||||
|
password: Some(format!("oauth:{}", twitch.bot_token.access_token.secret())),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut irc = IrcClient::from_config(irc_config).await?;
|
||||||
|
irc.send_cap_req(&[
|
||||||
|
Capability::Custom("twitch.tv/tags"),
|
||||||
|
Capability::Custom("twitch.tv/commands"),
|
||||||
|
])?;
|
||||||
|
irc.identify()?;
|
||||||
|
let mut irc_stream = irc.stream()?;
|
||||||
|
|
||||||
|
let mut rhai = Engine::new();
|
||||||
|
rhai.set_max_expr_depths(0, 0);
|
||||||
|
rhai.register_type::<ExecutorState>()
|
||||||
|
.register_get("initiator", ExecutorState::initiator)
|
||||||
|
.register_get("initiator_id", ExecutorState::initiator_id)
|
||||||
|
.register_get("args", ExecutorState::args)
|
||||||
|
.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>);
|
||||||
|
rhai.register_type::<ExecutorOutput>()
|
||||||
|
.register_fn("send", ExecutorOutput::send::<&str>);
|
||||||
|
|
||||||
|
let (queue_tx, mut queue_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||||
|
|
||||||
|
let state = Arc::new(Self {
|
||||||
|
user_config,
|
||||||
|
config: RwLock::new(config),
|
||||||
|
|
||||||
|
channel_name,
|
||||||
|
|
||||||
|
twitch,
|
||||||
|
rewards_paused: Default::default(),
|
||||||
|
|
||||||
|
irc,
|
||||||
|
irc_queue: queue_tx,
|
||||||
|
|
||||||
|
rhai,
|
||||||
|
script_cache: Default::default(),
|
||||||
|
runtime,
|
||||||
|
});
|
||||||
|
|
||||||
|
// start web task
|
||||||
|
tokio::task::spawn(crate::app::web::start_web(Arc::clone(&state)));
|
||||||
|
|
||||||
|
// start pubsub
|
||||||
|
let redemption_topic = twitch_api2::pubsub::channel_points::ChannelPointsChannelV1 {
|
||||||
|
channel_id: state.user_config.twitch.channel_id as u32,
|
||||||
|
}.into_topic();
|
||||||
|
let auth_token: Option<String> = state.config
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.user_token
|
||||||
|
.as_ref()
|
||||||
|
.map(|token| token.access_token.secret().to_string());
|
||||||
|
let listen_command = twitch_api2::pubsub::listen_command(
|
||||||
|
&[redemption_topic],
|
||||||
|
auth_token.as_deref(),
|
||||||
|
"1",
|
||||||
|
)?;
|
||||||
|
let task_state = Arc::clone(&state);
|
||||||
|
tokio::task::spawn(async move {
|
||||||
|
let res: Result<()> = try {
|
||||||
|
let (ws, _) = tokio_tungstenite::connect_async("wss://pubsub-edge.twitch.tv").await?;
|
||||||
|
let (mut write, mut read) = ws.split();
|
||||||
|
write.send(WsMessage::Text(listen_command)).await?;
|
||||||
|
let mut ping = tokio::time::interval(chrono::Duration::minutes(2).to_std().unwrap());
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
_ = ping.tick() => {
|
||||||
|
write.send(WsMessage::Ping(vec![1, 2, 3, 4])).await?;
|
||||||
|
write.send(WsMessage::Text(r#"{"type":"PING"}"#.into())).await?;
|
||||||
|
},
|
||||||
|
message = read.next() => {
|
||||||
|
if let Some(Ok(message)) = message {
|
||||||
|
if let Err(e) = crate::app::twitch::handle_pubsub(Arc::clone(&task_state), message).await {
|
||||||
|
eprintln!("error in pubsub: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = res {
|
||||||
|
eprintln!("error connecting to websocket: {:?}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// start irc task
|
||||||
|
let task_state = Arc::clone(&state);
|
||||||
|
tokio::task::spawn(async move {
|
||||||
|
// FIXME: handle reconnects
|
||||||
|
while let Some(event) = irc_stream.next().await.transpose()? {
|
||||||
|
let task_state = Arc::clone(&task_state);
|
||||||
|
if let Err(e) = crate::app::twitch::handle_irc_event(task_state, event).await {
|
||||||
|
eprintln!("irc error: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Result::<(), anyhow::Error>::Ok(())
|
||||||
|
});
|
||||||
|
|
||||||
|
// start irc message queue
|
||||||
|
let task_state = Arc::clone(&state);
|
||||||
|
tokio::task::spawn(async move {
|
||||||
|
let channel = format!("#{}", task_state.channel_name);
|
||||||
|
|
||||||
|
while let Some(message) = queue_rx.recv().await {
|
||||||
|
if let Err(e) = task_state.irc.send_privmsg(&channel, message) {
|
||||||
|
eprintln!("error sending message: {:?}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct FullUserToken {
|
||||||
|
pub token: UserToken,
|
||||||
|
pub client_id: ClientId,
|
||||||
|
pub client_secret: ClientSecret,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for FullUserToken {
|
||||||
|
fn deserialize<D>(de: D) -> std::result::Result<FullUserToken, D::Error>
|
||||||
|
where D: serde::de::Deserializer<'de>, {
|
||||||
|
let (refresh, access, login, user_id, client_id, client_secret, scopes): (RefreshToken, AccessToken, _, _, ClientId, ClientSecret, Vec<Scope>) = Deserialize::deserialize(de)?;
|
||||||
|
let token = UserToken::from_existing_unchecked(access, refresh, client_id.clone(), client_secret.clone(), login, user_id, Some(scopes), None);
|
||||||
|
Ok(FullUserToken {
|
||||||
|
token,
|
||||||
|
client_id,
|
||||||
|
client_secret,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for FullUserToken {
|
||||||
|
fn serialize<S>(&self, ser: S) -> std::result::Result<S::Ok, S::Error>
|
||||||
|
where S: serde::ser::Serializer,
|
||||||
|
{
|
||||||
|
let to_ser = (&self.token.refresh_token, &self.token.access_token, &self.token.login, &self.token.user_id, &self.client_id, &self.client_secret, self.token.scopes());
|
||||||
|
to_ser.serialize(ser)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Deref for FullUserToken {
|
||||||
|
type Target = UserToken;
|
||||||
|
|
||||||
|
fn deref(&self) -> &Self::Target {
|
||||||
|
&self.token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DerefMut for FullUserToken {
|
||||||
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||||
|
&mut self.token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_rhai<S: Into<String>>(state: Arc<State>, script: S, fn_state: ExecutorState) {
|
||||||
|
let script = script.into();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let res: anyhow::Result<()> = try {
|
||||||
|
let mut scope = rhai::Scope::new();
|
||||||
|
let ast = state.script_cache.read().get(&script).map(ToOwned::to_owned);
|
||||||
|
let ast = match ast {
|
||||||
|
Some(ast) => ast,
|
||||||
|
None => {
|
||||||
|
let ast = state.rhai.compile(&script)?;
|
||||||
|
state.script_cache.write().insert(script.clone(), ast.clone());
|
||||||
|
ast
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut output = rhai::Dynamic::from(ExecutorOutput::default());
|
||||||
|
|
||||||
|
state.rhai.call_fn_dynamic(
|
||||||
|
&mut scope,
|
||||||
|
&ast,
|
||||||
|
true,
|
||||||
|
"run",
|
||||||
|
Some(&mut output),
|
||||||
|
[rhai::Dynamic::from(fn_state)],
|
||||||
|
)?;
|
||||||
|
|
||||||
|
let output: ExecutorOutput = output.cast();
|
||||||
|
for message in output.to_send {
|
||||||
|
state.irc_queue.send(message).ok();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = res {
|
||||||
|
eprintln!("error in rhai script: {:?}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn verify_token(full_token: &mut Option<FullUserToken>, user_config: &UserConfig, http_client: &reqwest::Client, scopes: Vec<Scope>) -> anyhow::Result<()> {
|
||||||
|
match full_token {
|
||||||
|
Some(t) => if t.validate_token(http_client).await.is_err() {
|
||||||
|
println!("Refreshing token");
|
||||||
|
t.refresh_token(http_client).await?;
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
let mut builder = UserToken::builder(
|
||||||
|
user_config.twitch.client_id.clone(),
|
||||||
|
user_config.twitch.client_secret.clone(),
|
||||||
|
url::Url::parse("http://localhost/").unwrap(),
|
||||||
|
).set_scopes(scopes);
|
||||||
|
let (url, csrf) = builder.generate_url();
|
||||||
|
println!("go to {}", url);
|
||||||
|
println!("once done, paste code:");
|
||||||
|
let mut code = String::new();
|
||||||
|
std::io::stdin().read_line(&mut code)?;
|
||||||
|
let token: UserToken = builder.get_user_token(http_client, csrf.as_str(), code.trim()).await?;
|
||||||
|
*full_token = Some(FullUserToken {
|
||||||
|
token,
|
||||||
|
client_id: user_config.twitch.client_id.clone(),
|
||||||
|
client_secret: user_config.twitch.client_secret.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
|
@ -0,0 +1,44 @@
|
||||||
|
use chrono::Duration;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_with::{serde_as, DurationSeconds};
|
||||||
|
use crate::app::FullUserToken;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Default)]
|
||||||
|
pub struct Config {
|
||||||
|
#[serde(default)]
|
||||||
|
pub bot_token: Option<FullUserToken>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub user_token: Option<FullUserToken>,
|
||||||
|
pub commands: Vec<Command>,
|
||||||
|
pub redemptions: Vec<Redemption>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Clone)]
|
||||||
|
pub struct Command {
|
||||||
|
pub name: String,
|
||||||
|
pub aliases: Vec<String>,
|
||||||
|
pub cooldowns: Cooldowns,
|
||||||
|
pub executor: CommandExecutor,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Clone)]
|
||||||
|
pub enum CommandExecutor {
|
||||||
|
Text(String),
|
||||||
|
Rhai(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[serde_as]
|
||||||
|
#[derive(Deserialize, Serialize, Default, Clone)]
|
||||||
|
pub struct Cooldowns {
|
||||||
|
#[serde_as(as = "Option<DurationSeconds<f64>>")]
|
||||||
|
pub global: Option<Duration>,
|
||||||
|
#[serde_as(as = "Option<DurationSeconds<f64>>")]
|
||||||
|
pub user: Option<Duration>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Clone)]
|
||||||
|
pub struct Redemption {
|
||||||
|
pub name: String,
|
||||||
|
pub twitch_id: twitch_api2::types::RewardId,
|
||||||
|
pub rhai: String,
|
||||||
|
}
|
|
@ -0,0 +1,96 @@
|
||||||
|
use crate::app::twitch::Twitch;
|
||||||
|
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 {
|
||||||
|
pub to_send: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExecutorOutput {
|
||||||
|
pub fn send<S: Into<String>>(&mut self, s: S) {
|
||||||
|
self.to_send.push(s.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ExecutorState {
|
||||||
|
pub runtime: Handle,
|
||||||
|
pub twitch: Arc<Twitch>,
|
||||||
|
|
||||||
|
pub args: Vec<rhai::Dynamic>,
|
||||||
|
|
||||||
|
pub initiator: String,
|
||||||
|
pub initiator_id: UserId,
|
||||||
|
// TODO
|
||||||
|
// pub moderator: bool,
|
||||||
|
// pub broadcaster: bool,
|
||||||
|
// pub vip: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ExecutorState {
|
||||||
|
// FIXME: make this return &str
|
||||||
|
pub fn initiator(&mut self) -> String {
|
||||||
|
self.initiator.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn initiator_id(&mut self) -> String {
|
||||||
|
self.initiator_id.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn args(&mut self) -> rhai::Array {
|
||||||
|
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()?
|
||||||
|
.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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
.await
|
||||||
|
.ok()?
|
||||||
|
.data
|
||||||
|
.map(|info: ChannelInformation| (info.broadcaster_name.to_string(), info.broadcaster_login.to_string(), info.game_name.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
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)) {
|
||||||
|
Some(x) => rhai::Dynamic::from(vec![
|
||||||
|
rhai::Dynamic::from(x.0),
|
||||||
|
rhai::Dynamic::from(x.1),
|
||||||
|
rhai::Dynamic::from(x.2),
|
||||||
|
]),
|
||||||
|
None => rhai::Dynamic::from(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,128 @@
|
||||||
|
use twitch_api2::{
|
||||||
|
TwitchClient,
|
||||||
|
types::UserId,
|
||||||
|
twitch_oauth2::UserToken,
|
||||||
|
};
|
||||||
|
use irc::client::prelude::Message;
|
||||||
|
use irc::proto::{Command, Response};
|
||||||
|
use crate::app::State;
|
||||||
|
use crate::app::config::CommandExecutor;
|
||||||
|
use crate::app::rhai_tools::ExecutorState;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio_tungstenite::tungstenite::Message as WsMessage;
|
||||||
|
|
||||||
|
pub struct Twitch {
|
||||||
|
pub client: TwitchClient<'static, reqwest::Client>,
|
||||||
|
pub bot_token: UserToken,
|
||||||
|
pub user_token: UserToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_irc_event(state: Arc<State>, event: Message) -> anyhow::Result<()> {
|
||||||
|
let channel_name = format!("#{}", state.channel_name);
|
||||||
|
|
||||||
|
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(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn on_privmsg(state: Arc<State>, event: &Message, message: &str) -> anyhow::Result<()> {
|
||||||
|
let initiator = event.source_nickname()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing source"))?
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let initiator_id = event.tags
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing tags"))?
|
||||||
|
.iter()
|
||||||
|
.find(|tag| tag.0 == "user-id")
|
||||||
|
.and_then(|tag| tag.1.clone())
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing user id"))?;
|
||||||
|
|
||||||
|
let words: Vec<&str> = message.split(' ').collect();
|
||||||
|
let command_name = words[0];
|
||||||
|
let args = words[1..].iter()
|
||||||
|
.map(ToString::to_string)
|
||||||
|
.map(rhai::Dynamic::from)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let command = state.config.read()
|
||||||
|
.await
|
||||||
|
.commands
|
||||||
|
.iter().find(|command| command.name == command_name || command.aliases.iter().any(|alias| alias == command_name))
|
||||||
|
.map(Clone::clone);
|
||||||
|
let command = match command {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return Ok(())
|
||||||
|
};
|
||||||
|
|
||||||
|
match &command.executor {
|
||||||
|
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,
|
||||||
|
args,
|
||||||
|
runtime: state.runtime.clone(),
|
||||||
|
};
|
||||||
|
crate::app::run_rhai(Arc::clone(&state), t, fn_state);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
use twitch_api2::pubsub::{
|
||||||
|
Response as PubSubResponse,
|
||||||
|
TopicData,
|
||||||
|
channel_points::ChannelPointsChannelV1Reply,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn handle_pubsub(state: Arc<State>, event: WsMessage) -> anyhow::Result<()> {
|
||||||
|
let json = match event {
|
||||||
|
WsMessage::Text(json) => json,
|
||||||
|
_ => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = twitch_api2::pubsub::Response::parse(&json)?;
|
||||||
|
|
||||||
|
let reply = match response {
|
||||||
|
PubSubResponse::Message { data: TopicData::ChannelPointsChannelV1 { reply, .. } } => reply,
|
||||||
|
_ => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let redemption = match *reply {
|
||||||
|
ChannelPointsChannelV1Reply::RewardRedeemed { redemption, .. } => redemption,
|
||||||
|
_ => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let action = match state.config.read().await.redemptions.iter().find(|re| re.twitch_id == redemption.reward.id).map(Clone::clone) {
|
||||||
|
Some(a) => a,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let args = redemption.user_input
|
||||||
|
.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),
|
||||||
|
initiator: redemption.user.login.to_string(),
|
||||||
|
initiator_id: redemption.user.id,
|
||||||
|
args,
|
||||||
|
runtime: state.runtime.clone(),
|
||||||
|
};
|
||||||
|
crate::app::run_rhai(Arc::clone(&state), &action.rhai, fn_state);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
use serde::Deserialize;
|
||||||
|
use twitch_api2::twitch_oauth2::{ClientId, ClientSecret};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct UserConfig {
|
||||||
|
pub twitch: Twitch,
|
||||||
|
pub bot: Bot,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Twitch {
|
||||||
|
pub client_id: ClientId,
|
||||||
|
pub client_secret: ClientSecret,
|
||||||
|
pub channel_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Bot {
|
||||||
|
pub access_token: String,
|
||||||
|
}
|
|
@ -0,0 +1,71 @@
|
||||||
|
mod template;
|
||||||
|
mod route;
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use crate::app::State;
|
||||||
|
use warp::{Filter, Rejection, Reply};
|
||||||
|
use std::convert::Infallible;
|
||||||
|
use warp::http::StatusCode;
|
||||||
|
use std::borrow::Cow;
|
||||||
|
use self::route::*;
|
||||||
|
|
||||||
|
pub async fn start_web(state: Arc<State>) {
|
||||||
|
let cookie_state = Arc::clone(&state);
|
||||||
|
let authed = warp::cookie("access_token")
|
||||||
|
.or(warp::header("x-api-key"))
|
||||||
|
.unify()
|
||||||
|
.and_then(move |access_token: String| {
|
||||||
|
let state = Arc::clone(&cookie_state);
|
||||||
|
async move {
|
||||||
|
if access_token == state.user_config.bot.access_token {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(warp::reject::custom(CustomRejection::InvalidAccessToken))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.untuple_one()
|
||||||
|
.and(
|
||||||
|
commands_routes(Arc::clone(&state))
|
||||||
|
.or(redemptions_routes(Arc::clone(&state)))
|
||||||
|
.or(livesplit_routes(Arc::clone(&state)))
|
||||||
|
);
|
||||||
|
|
||||||
|
let unauthed = access_token_routes();
|
||||||
|
|
||||||
|
let routes = authed.or(unauthed).recover(handle_rejection);
|
||||||
|
|
||||||
|
warp::serve(routes)
|
||||||
|
.run(([0, 0, 0, 0], 8000))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum CustomRejection {
|
||||||
|
InvalidAccessToken,
|
||||||
|
InvalidForm,
|
||||||
|
TwitchError,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl warp::reject::Reject for CustomRejection {}
|
||||||
|
|
||||||
|
async fn handle_rejection(err: Rejection) -> Result<impl Reply, Infallible> {
|
||||||
|
let (code, message) = if err.is_not_found() {
|
||||||
|
(StatusCode::NOT_FOUND, Cow::from("404 - Not Found"))
|
||||||
|
} else if let Some(custom) = err.find::<CustomRejection>() {
|
||||||
|
match custom {
|
||||||
|
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::Askama(e) => (StatusCode::INTERNAL_SERVER_ERROR, Cow::from(format!("templating error: {:#?}", e))),
|
||||||
|
}
|
||||||
|
} else if let Some(e) = err.find::<warp::reject::MissingCookie>() {
|
||||||
|
(StatusCode::BAD_REQUEST, Cow::from(format!("missing `{}` cookie", e.name())))
|
||||||
|
} else if let Some(e) = err.find::<warp::reject::InvalidHeader>() {
|
||||||
|
(StatusCode::BAD_REQUEST, Cow::from(format!("missing `{}` header", e.name())))
|
||||||
|
} else {
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, Cow::from(format!("unhandled error: {:#?}", err)))
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(warp::reply::with_status(message, code))
|
||||||
|
}
|
|
@ -0,0 +1,40 @@
|
||||||
|
use warp::{
|
||||||
|
Filter, Reply,
|
||||||
|
filters::BoxedFilter,
|
||||||
|
http::Uri,
|
||||||
|
};
|
||||||
|
use crate::app::web::{
|
||||||
|
CustomRejection,
|
||||||
|
template::index::IndexTemplate,
|
||||||
|
};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
pub fn access_token_routes() -> BoxedFilter<(impl Reply, )> {
|
||||||
|
warp::get().and(access_token_page())
|
||||||
|
.or(warp::post().and(access_token_submit()))
|
||||||
|
.boxed()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn access_token_page() -> BoxedFilter<(impl Reply, )> {
|
||||||
|
warp::path::end()
|
||||||
|
.map(|| IndexTemplate)
|
||||||
|
.boxed()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn access_token_submit() -> BoxedFilter<(impl Reply, )> {
|
||||||
|
warp::path::end()
|
||||||
|
.and(warp::body::content_length_limit(1024))
|
||||||
|
.and(warp::body::form())
|
||||||
|
.and_then(|form: HashMap<String, String>| async move {
|
||||||
|
let token = match form.get("access_token") {
|
||||||
|
Some(token) => token,
|
||||||
|
None => return Err(warp::reject::custom(CustomRejection::InvalidForm)),
|
||||||
|
};
|
||||||
|
Ok(warp::reply::with_header(
|
||||||
|
warp::redirect(Uri::from_static("/")),
|
||||||
|
"Set-Cookie",
|
||||||
|
format!("access_token={}; SameSite=Lax; Secure; HttpOnly", token),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.boxed()
|
||||||
|
}
|
|
@ -0,0 +1,118 @@
|
||||||
|
use warp::{
|
||||||
|
Filter, Reply,
|
||||||
|
filters::BoxedFilter,
|
||||||
|
http::Uri,
|
||||||
|
};
|
||||||
|
use crate::app::{
|
||||||
|
State,
|
||||||
|
config::{CommandExecutor, Command, Cooldowns},
|
||||||
|
web::{
|
||||||
|
CustomRejection,
|
||||||
|
template::commands::{CommandsTemplate, AddCommandTemplate},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
convert::Infallible,
|
||||||
|
sync::Arc,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn commands_routes(state: Arc<State>) -> BoxedFilter<(impl Reply, )> {
|
||||||
|
warp::get()
|
||||||
|
.and(
|
||||||
|
commands_get(Arc::clone(&state))
|
||||||
|
.or(commands_add_get())
|
||||||
|
)
|
||||||
|
.or(warp::post().and(
|
||||||
|
commands_add_post(Arc::clone(&state))
|
||||||
|
.or(commands_delete_post(Arc::clone(&state)))
|
||||||
|
))
|
||||||
|
.boxed()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commands_get(state: Arc<State>) -> BoxedFilter<(impl Reply, )> {
|
||||||
|
warp::path("commands")
|
||||||
|
.and(warp::path::end())
|
||||||
|
.and_then(move || {
|
||||||
|
let state = Arc::clone(&state);
|
||||||
|
async move {
|
||||||
|
Result::<CommandsTemplate, Infallible>::Ok(CommandsTemplate {
|
||||||
|
commands: state.config.read().await.commands.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.boxed()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commands_add_get() -> BoxedFilter<(impl Reply, )> {
|
||||||
|
warp::path("commands")
|
||||||
|
.and(warp::path("add"))
|
||||||
|
.and(warp::path::end())
|
||||||
|
.map(|| AddCommandTemplate)
|
||||||
|
.boxed()
|
||||||
|
}
|
||||||
|
|
||||||
|
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>| {
|
||||||
|
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 (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")))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.boxed()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commands_delete_post(state: Arc<State>) -> BoxedFilter<(impl Reply, )> {
|
||||||
|
warp::path("commands")
|
||||||
|
.and(warp::path("delete"))
|
||||||
|
.and(warp::path::end())
|
||||||
|
.and(warp::body::content_length_limit(1024 * 5))
|
||||||
|
.and(warp::body::form())
|
||||||
|
.and_then(move |mut form: HashMap<String, String>| {
|
||||||
|
let state = Arc::clone(&state);
|
||||||
|
async move {
|
||||||
|
let name = match form.remove("name") {
|
||||||
|
Some(n) => n,
|
||||||
|
None => return Err(warp::reject::custom(CustomRejection::InvalidForm)),
|
||||||
|
};
|
||||||
|
|
||||||
|
state.config.write().await.commands.drain_filter(|command| command.name == name);
|
||||||
|
|
||||||
|
Ok(warp::redirect(Uri::from_static("/commands")))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.boxed()
|
||||||
|
}
|
|
@ -0,0 +1,169 @@
|
||||||
|
use futures::Future;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use twitch_api2::helix::points::{UpdateCustomRewardRequest, UpdateCustomRewardBody};
|
||||||
|
use warp::{Filter, Reply, filters::BoxedFilter, Rejection};
|
||||||
|
use crate::app::{
|
||||||
|
State,
|
||||||
|
web::CustomRejection,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
pub fn livesplit_routes(state: Arc<State>) -> BoxedFilter<(impl Reply, )> {
|
||||||
|
warp::post()
|
||||||
|
.and(
|
||||||
|
livesplit_start(Arc::clone(&state))
|
||||||
|
.or(livesplit_split(Arc::clone(&state)))
|
||||||
|
.or(livesplit_reset(Arc::clone(&state)))
|
||||||
|
.or(livesplit_finish(Arc::clone(&state)))
|
||||||
|
)
|
||||||
|
.boxed()
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RewardPauseInfo {
|
||||||
|
id: &'static str,
|
||||||
|
games: &'static [(&'static str, isize)],
|
||||||
|
}
|
||||||
|
|
||||||
|
const REWARDS: &[RewardPauseInfo] = &[
|
||||||
|
// reset
|
||||||
|
RewardPauseInfo {
|
||||||
|
id: "8ac47f16-2396-4c2d-9c43-d30bd9074a4f",
|
||||||
|
games: &[
|
||||||
|
// in RCT1, only enable after the third split
|
||||||
|
("RollerCoaster Tycoon 1", 3),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// no reset
|
||||||
|
RewardPauseInfo {
|
||||||
|
id: "0da50268-3237-4832-9849-9baac518e4de",
|
||||||
|
games: &[
|
||||||
|
// in RCT1, only enable after the third split
|
||||||
|
("RollerCoaster Tycoon 1", 3),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async fn set_reward_paused(state: Arc<State>, id: String, paused: bool) -> anyhow::Result<()> {
|
||||||
|
let request = UpdateCustomRewardRequest::builder()
|
||||||
|
.broadcaster_id(state.user_config.twitch.channel_id.to_string())
|
||||||
|
.id(id)
|
||||||
|
.build();
|
||||||
|
let body = UpdateCustomRewardBody::builder()
|
||||||
|
.is_paused(paused)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
state.twitch.client.helix.req_patch(request, body, &state.twitch.user_token).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_rewards_paused(state: Arc<State>, data: LiveSplitBody, paused: bool) -> Vec<anyhow::Result<()>> {
|
||||||
|
let mut results = Vec::with_capacity(REWARDS.len());
|
||||||
|
for info in REWARDS {
|
||||||
|
let is_paused = state.rewards_paused.read().await.get(info.id).copied();
|
||||||
|
let should_apply = match info.games.iter().find(|(name, _)| data.run.game_name.as_deref().map(|run_name| run_name == *name).unwrap_or_default()) {
|
||||||
|
Some((_, split_idx)) => {
|
||||||
|
// if we're unpausing and the current split index is gte to the configured index for this game
|
||||||
|
if !paused && data.current_split_index >= *split_idx {
|
||||||
|
true
|
||||||
|
} else if !paused && data.current_split_index < *split_idx {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if !should_apply || is_paused == Some(paused) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.rewards_paused.write().await.insert(info.id.to_string(), paused);
|
||||||
|
results.push(set_reward_paused(Arc::clone(&state), info.id.to_string(), paused).await);
|
||||||
|
}
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rewards_filter(state: Arc<State>, data: LiveSplitBody, paused: bool) -> impl Future<Output=Result<(), Rejection>> {
|
||||||
|
async move {
|
||||||
|
for result in set_rewards_paused(state, data, paused).await {
|
||||||
|
if result.is_err() {
|
||||||
|
return Err(warp::reject::custom(CustomRejection::TwitchError));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn livesplit_start(state: Arc<State>) -> BoxedFilter<(impl Reply, )> {
|
||||||
|
livesplit_route(state, "start", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn livesplit_split(state: Arc<State>) -> BoxedFilter<(impl Reply, )> {
|
||||||
|
livesplit_route(state, "split", false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn livesplit_reset(state: Arc<State>) -> BoxedFilter<(impl Reply, )> {
|
||||||
|
livesplit_route(state, "reset", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn livesplit_finish(state: Arc<State>) -> BoxedFilter<(impl Reply, )> {
|
||||||
|
livesplit_route(state, "finish", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn livesplit_route(state: Arc<State>, path: &'static str, paused: bool) -> BoxedFilter<(impl Reply,)> {
|
||||||
|
warp::path("livesplit")
|
||||||
|
.and(warp::path(path))
|
||||||
|
.and(warp::path::end())
|
||||||
|
.and(warp::body::json())
|
||||||
|
.and_then(move |body: LiveSplitBody| {
|
||||||
|
let state = Arc::clone(&state);
|
||||||
|
rewards_filter(state, body, paused)
|
||||||
|
})
|
||||||
|
.untuple_one()
|
||||||
|
.map(|| warp::reply())
|
||||||
|
.boxed()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
struct LiveSplitBody {
|
||||||
|
current_phase: usize,
|
||||||
|
run: LiveSplitRun,
|
||||||
|
attempt_started: LiveSplitAtomicTime,
|
||||||
|
attempt_ended: LiveSplitAtomicTime,
|
||||||
|
current_split_index: isize,
|
||||||
|
current_split_name: Option<String>,
|
||||||
|
current_time: LiveSplitTime,
|
||||||
|
current_attempt_duration: String,
|
||||||
|
current_timing_method: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
struct LiveSplitRun {
|
||||||
|
game_name: Option<String>,
|
||||||
|
category_name: Option<String>,
|
||||||
|
offset: String,
|
||||||
|
attempt_count: usize,
|
||||||
|
segment_names: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
struct LiveSplitAtomicTime {
|
||||||
|
time: String,
|
||||||
|
synced_with_atomic_clock: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "PascalCase")]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
struct LiveSplitTime {
|
||||||
|
real_time: Option<String>,
|
||||||
|
game_time: Option<String>,
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
pub mod access_token;
|
||||||
|
pub mod commands;
|
||||||
|
pub mod livesplit;
|
||||||
|
pub mod redemptions;
|
||||||
|
|
||||||
|
pub use self::access_token::*;
|
||||||
|
pub use self::commands::*;
|
||||||
|
pub use self::livesplit::*;
|
||||||
|
pub use self::redemptions::*;
|
|
@ -0,0 +1,111 @@
|
||||||
|
use twitch_api2::types::RewardId;
|
||||||
|
use warp::{
|
||||||
|
Filter, Reply,
|
||||||
|
filters::BoxedFilter,
|
||||||
|
http::Uri,
|
||||||
|
};
|
||||||
|
use crate::app::{
|
||||||
|
State,
|
||||||
|
config::Redemption,
|
||||||
|
web::{
|
||||||
|
CustomRejection,
|
||||||
|
template::redemptions::{RedemptionsTemplate, AddRedemptionTemplate},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use std::{
|
||||||
|
collections::HashMap,
|
||||||
|
convert::Infallible,
|
||||||
|
sync::Arc,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn redemptions_routes(state: Arc<State>) -> BoxedFilter<(impl Reply, )> {
|
||||||
|
warp::get()
|
||||||
|
.and(
|
||||||
|
redemptions_get(Arc::clone(&state))
|
||||||
|
.or(redemptions_add_get())
|
||||||
|
)
|
||||||
|
.or(warp::post().and(
|
||||||
|
redemptions_add_post(Arc::clone(&state))
|
||||||
|
.or(redemptions_delete_post(Arc::clone(&state)))
|
||||||
|
))
|
||||||
|
.boxed()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redemptions_get(state: Arc<State>) -> BoxedFilter<(impl Reply, )> {
|
||||||
|
warp::path("redemptions")
|
||||||
|
.and(warp::path::end())
|
||||||
|
.and_then(move || {
|
||||||
|
let state = Arc::clone(&state);
|
||||||
|
async move {
|
||||||
|
Result::<RedemptionsTemplate, Infallible>::Ok(RedemptionsTemplate {
|
||||||
|
redemptions: state.config.read().await.redemptions.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.boxed()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redemptions_add_get() -> BoxedFilter<(impl Reply, )> {
|
||||||
|
warp::path("redemptions")
|
||||||
|
.and(warp::path("add"))
|
||||||
|
.and(warp::path::end())
|
||||||
|
.map(|| AddRedemptionTemplate)
|
||||||
|
.boxed()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redemptions_add_post(state: Arc<State>) -> BoxedFilter<(impl Reply, )> {
|
||||||
|
warp::path("redemptions")
|
||||||
|
.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>| {
|
||||||
|
let state = Arc::clone(&state);
|
||||||
|
async move {
|
||||||
|
let form_get = try {
|
||||||
|
let name = form.remove("name")?;
|
||||||
|
let twitch_id = form.remove("twitch_id")?;
|
||||||
|
let rhai = form.remove("rhai")?;
|
||||||
|
(name, twitch_id, rhai)
|
||||||
|
};
|
||||||
|
|
||||||
|
let (name, twitch_id, rhai) = match form_get {
|
||||||
|
Some(x) => x,
|
||||||
|
None => return Err(warp::reject::custom(CustomRejection::InvalidForm)),
|
||||||
|
};
|
||||||
|
|
||||||
|
let redemption = Redemption {
|
||||||
|
name,
|
||||||
|
twitch_id: RewardId::new(twitch_id),
|
||||||
|
rhai,
|
||||||
|
};
|
||||||
|
|
||||||
|
state.config.write().await.redemptions.push(redemption);
|
||||||
|
|
||||||
|
Ok(warp::redirect(Uri::from_static("/redemptions")))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.boxed()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn redemptions_delete_post(state: Arc<State>) -> BoxedFilter<(impl Reply, )> {
|
||||||
|
warp::path("redemptions")
|
||||||
|
.and(warp::path("delete"))
|
||||||
|
.and(warp::path::end())
|
||||||
|
.and(warp::body::content_length_limit(1024 * 5))
|
||||||
|
.and(warp::body::form())
|
||||||
|
.and_then(move |mut form: HashMap<String, String>| {
|
||||||
|
let state = Arc::clone(&state);
|
||||||
|
async move {
|
||||||
|
let name = match form.remove("name") {
|
||||||
|
Some(n) => n,
|
||||||
|
None => return Err(warp::reject::custom(CustomRejection::InvalidForm)),
|
||||||
|
};
|
||||||
|
|
||||||
|
state.config.write().await.redemptions.drain_filter(|redemption| redemption.name == name);
|
||||||
|
|
||||||
|
Ok(warp::redirect(Uri::from_static("/redemptions")))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.boxed()
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
use askama::Template;
|
||||||
|
use crate::app::config::{
|
||||||
|
Command,
|
||||||
|
CommandExecutor,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "commands.html")]
|
||||||
|
pub struct CommandsTemplate {
|
||||||
|
pub commands: Vec<Command>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "add_command.html")]
|
||||||
|
pub struct AddCommandTemplate;
|
|
@ -0,0 +1,5 @@
|
||||||
|
use askama::Template;
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "index.html")]
|
||||||
|
pub struct IndexTemplate;
|
|
@ -0,0 +1,3 @@
|
||||||
|
pub mod commands;
|
||||||
|
pub mod index;
|
||||||
|
pub mod redemptions;
|
|
@ -0,0 +1,19 @@
|
||||||
|
use askama::Template;
|
||||||
|
use crate::app::config::Redemption;
|
||||||
|
use twitch_api2::helix::points::CustomReward;
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "redemptions.html")]
|
||||||
|
pub struct RedemptionsTemplate {
|
||||||
|
pub redemptions: Vec<Redemption>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "add_redemption.html")]
|
||||||
|
pub struct AddRedemptionTemplate;
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "list_redemptions.html")]
|
||||||
|
pub struct ListRedemptionsTemplate {
|
||||||
|
pub rewards: Vec<CustomReward>,
|
||||||
|
}
|
|
@ -0,0 +1,60 @@
|
||||||
|
#![feature(try_blocks)]
|
||||||
|
#![feature(drain_filter)]
|
||||||
|
|
||||||
|
mod app;
|
||||||
|
|
||||||
|
use tokio::runtime::{Builder, Handle};
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use crate::app::{
|
||||||
|
State,
|
||||||
|
config::Config,
|
||||||
|
user_config::UserConfig,
|
||||||
|
};
|
||||||
|
use std::path::Path;
|
||||||
|
use tokio::fs::OpenOptions;
|
||||||
|
use futures::FutureExt;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
fn main() -> anyhow::Result<()> {
|
||||||
|
let runtime = Builder::new_multi_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()?;
|
||||||
|
|
||||||
|
let handle = runtime.handle().clone();
|
||||||
|
runtime.block_on(inner(handle))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn inner(runtime: Handle) -> anyhow::Result<()> {
|
||||||
|
let mut uc_toml = String::new();
|
||||||
|
tokio::fs::File::open("config.toml").await?.read_to_string(&mut uc_toml).await?;
|
||||||
|
let user_config: UserConfig = toml::from_str(&uc_toml)?;
|
||||||
|
|
||||||
|
let c_path = Path::new("data.json");
|
||||||
|
let config: Config = if c_path.exists() {
|
||||||
|
let mut c_json = String::new();
|
||||||
|
tokio::fs::File::open(c_path).await?.read_to_string(&mut c_json).await?;
|
||||||
|
serde_json::from_str(&c_json)?
|
||||||
|
} else {
|
||||||
|
Config::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let state = State::new(runtime, user_config, config).await?;
|
||||||
|
save_config(c_path, Arc::clone(&state)).await?;
|
||||||
|
|
||||||
|
tokio::signal::ctrl_c()
|
||||||
|
.then(|_| save_config(c_path, Arc::clone(&state)))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn save_config(path: &Path, state: Arc<State>) -> anyhow::Result<()> {
|
||||||
|
let mut config_file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.truncate(true)
|
||||||
|
.open(path)
|
||||||
|
.await?;
|
||||||
|
config_file.write_all(&serde_json::to_vec(&*state.config.read().await)?).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
|
@ -0,0 +1,76 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>{% block title %}{% endblock %}</title>
|
||||||
|
<style>
|
||||||
|
/*! minireset.css v0.0.6 | MIT License | github.com/jgthms/minireset.css */
|
||||||
|
html, body, p, ol, ul, li, dl, dt, dd, blockquote, figure, fieldset, legend, textarea, pre, iframe, hr, h1, h2, h3, h4, h5, h6 {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-size: 100%;
|
||||||
|
font-weight: normal
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
list-style: none
|
||||||
|
}
|
||||||
|
|
||||||
|
button, input, select {
|
||||||
|
margin: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
box-sizing: border-box
|
||||||
|
}
|
||||||
|
|
||||||
|
*, *::before, *::after {
|
||||||
|
box-sizing: inherit
|
||||||
|
}
|
||||||
|
|
||||||
|
img, video {
|
||||||
|
height: auto;
|
||||||
|
max-width: 100%
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe {
|
||||||
|
border: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
border-spacing: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
td, th {
|
||||||
|
padding: 0
|
||||||
|
}
|
||||||
|
/* no longer minireset */
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: sans-serif;
|
||||||
|
background-color: #333;
|
||||||
|
color: #ababab;
|
||||||
|
margin: 1em 1em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a, a:visited, a:focus {
|
||||||
|
color: #aad7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
form > * {
|
||||||
|
margin-bottom: .5em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% block head %}{% endblock head %}
|
||||||
|
</head>
|
||||||
|
<body>{% block body %}{% endblock %}</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,16 @@
|
||||||
|
{% extends "_base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Add command{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<form action="/commands/add" method="post">
|
||||||
|
<input type="text" name="name" placeholder="Name"/>
|
||||||
|
<textarea name="aliases" placeholder="Aliases separated by newlines"></textarea>
|
||||||
|
<select name="type">
|
||||||
|
<option>Text</option>
|
||||||
|
<option>Rhai</option>
|
||||||
|
</select>
|
||||||
|
<textarea name="executor_data" placeholder="Text/script"></textarea>
|
||||||
|
<button type="submit">Add</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,12 @@
|
||||||
|
{% extends "_base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Add redemption{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<form action="/redemptions/add" method="post">
|
||||||
|
<input type="text" name="name" placeholder="Name"/>
|
||||||
|
<input type="text" name="twitch_id" placeholder="Twitch redemption ID"/>
|
||||||
|
<textarea name="rhai" placeholder="Script"></textarea>
|
||||||
|
<button type="submit">Add</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,47 @@
|
||||||
|
{% extends "_base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Commands{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div>
|
||||||
|
<a href="/commands/add">Add</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for command in commands %}
|
||||||
|
<div class="command">
|
||||||
|
<strong>{{ command.name }}</strong>
|
||||||
|
{% if !command.aliases.is_empty() %}
|
||||||
|
<em>{{ command.aliases.join(", ") }}</em>
|
||||||
|
{% endif %}
|
||||||
|
<pre><code>
|
||||||
|
{%- match command.executor -%}
|
||||||
|
{%- when CommandExecutor::Text with (t) -%}
|
||||||
|
{{ t }}
|
||||||
|
{%- when CommandExecutor::Rhai with (t) -%}
|
||||||
|
{{ t }}
|
||||||
|
{%- endmatch -%}
|
||||||
|
</code></pre>
|
||||||
|
<form action="/commands/delete" method="post">
|
||||||
|
<input type="hidden" name="name" value="{{ command.name }}"/>
|
||||||
|
<button type="submit">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,20 @@
|
||||||
|
{% extends "_base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Test{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<form action="/" method="post">
|
||||||
|
<label>
|
||||||
|
Access token
|
||||||
|
<input type="password" name="access_token"/>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Submit</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<a href="/commands">Commands</a>
|
||||||
|
<a href="/redemptions">Redemptions</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,29 @@
|
||||||
|
{% extends "_base.html" %}
|
||||||
|
|
||||||
|
{% block title %}List rewards{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reward {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
{% for reward in rewards %}
|
||||||
|
<div class="reward">
|
||||||
|
<strong>{{ reward.title }}</strong>
|
||||||
|
<em>{{ reward.id }}</em>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
|
@ -0,0 +1,38 @@
|
||||||
|
{% extends "_base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Redemptions{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.redemption {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div>
|
||||||
|
<a href="/redemptions/add">Add</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for redemption in redemptions %}
|
||||||
|
<div class="redemption">
|
||||||
|
<strong>{{ redemption.name }}</strong>
|
||||||
|
<em>{{ redemption.twitch_id }}</em>
|
||||||
|
<pre><code>{{ redemption.rhai }}</code></pre>
|
||||||
|
<form action="/redemptions/delete" method="post">
|
||||||
|
<input type="hidden" name="name" value="{{ redemption.name }}"/>
|
||||||
|
<button type="submit">Delete</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endblock %}
|
Loading…
Reference in New Issue