chore: initial commit

This commit is contained in:
Anna 2021-08-18 01:45:44 -04:00
commit 0da2212258
Signed by: anna
GPG Key ID: 0B391D8F06FCD9E0
26 changed files with 3648 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/target
/config.toml
/data.json

2146
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

30
Cargo.toml Normal file
View File

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

323
src/app.rs Normal file
View File

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

44
src/app/config.rs Normal file
View File

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

96
src/app/rhai_tools.rs Normal file
View File

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

128
src/app/twitch.rs Normal file
View File

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

20
src/app/user_config.rs Normal file
View File

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

71
src/app/web.rs Normal file
View File

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

View File

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

View File

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

View File

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

9
src/app/web/route/mod.rs Normal file
View File

@ -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::*;

View File

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

View File

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

View File

@ -0,0 +1,5 @@
use askama::Template;
#[derive(Template)]
#[template(path = "index.html")]
pub struct IndexTemplate;

View File

@ -0,0 +1,3 @@
pub mod commands;
pub mod index;
pub mod redemptions;

View File

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

60
src/main.rs Normal file
View File

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

76
templates/_base.html Normal file
View File

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

View File

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

View File

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

47
templates/commands.html Normal file
View File

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

20
templates/index.html Normal file
View File

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

View File

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

View File

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