diff --git a/Cargo.lock b/Cargo.lock index 68d65e5..71c1a1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -411,6 +411,12 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "hashbrown" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7afe4a420e3fe79967a00898cc1f4db7c8a49a9333a29f8a4bd76a253d5cd04" + [[package]] name = "hermit-abi" version = "0.1.13" @@ -524,11 +530,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "1.3.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "076f042c5b7b98f31d205f1249267e12a6518c1481e9dae9764af19b707d2292" +checksum = "55e2e4c765aa53a0424761bf9f41aa7a6ac1efa87238f59560640e27fca028f2" dependencies = [ "autocfg", + "hashbrown", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 19cbc97..67f1650 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,4 +18,4 @@ lazy_static = "1.4.0" log = "0.4.0" env_logger = "0.7.1" envy = "0.4" -indexmap = "1.3" +indexmap = "1.6" diff --git a/src/api.rs b/src/api.rs index 3768976..c0b08df 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,19 +1,34 @@ -use crate::{ - commands::{Args, Result}, - db::DB, - schema::roles, -}; +use crate::{command_history::CommandHistory, commands::Args, db::DB, schema::roles, Error}; use diesel::prelude::*; use serenity::{model::prelude::*, utils::parse_username}; /// Send a reply to the channel the message was received on. -pub(crate) fn send_reply(args: &Args, message: &str) -> Result<()> { - args.msg.channel_id.say(&args.cx, message)?; +pub(crate) fn send_reply(args: &Args, message: &str) -> Result<(), Error> { + if let Some(response_id) = response_exists(args) { + info!("editing message: {:?}", response_id); + args.msg + .channel_id + .edit_message(&args.cx, response_id, |msg| msg.content(message))?; + } else { + let command_id = args.msg.id; + let response = args.msg.channel_id.say(&args.cx, message)?; + + let mut data = args.cx.data.write(); + let history = data.get_mut::().unwrap(); + history.insert(command_id, response.id); + } + Ok(()) } +fn response_exists(args: &Args) -> Option { + let data = args.cx.data.read(); + let history = data.get::().unwrap(); + history.get(&args.msg.id).cloned() +} + /// Determine if a member sending a message has the `Role`. -pub(crate) fn has_role(args: &Args, role: &RoleId) -> Result { +pub(crate) fn has_role(args: &Args, role: &RoleId) -> Result { Ok(args .msg .member @@ -23,7 +38,7 @@ pub(crate) fn has_role(args: &Args, role: &RoleId) -> Result { .contains(role)) } -fn check_permission(args: &Args, role: Option) -> Result { +fn check_permission(args: &Args, role: Option) -> Result { use std::str::FromStr; if let Some(role_id) = role { Ok(has_role(args, &RoleId::from(u64::from_str(&role_id)?))?) @@ -33,7 +48,7 @@ fn check_permission(args: &Args, role: Option) -> Result { } /// Return whether or not the user is a mod. -pub(crate) fn is_mod(args: &Args) -> Result { +pub(crate) fn is_mod(args: &Args) -> Result { let role = roles::table .filter(roles::name.eq("mod")) .first::<(i32, String, String)>(&DB.get()?) @@ -42,7 +57,7 @@ pub(crate) fn is_mod(args: &Args) -> Result { check_permission(args, role.map(|(_, role_id, _)| role_id)) } -pub(crate) fn is_wg_and_teams(args: &Args) -> Result { +pub(crate) fn is_wg_and_teams(args: &Args) -> Result { let role = roles::table .filter(roles::name.eq("wg_and_teams")) .first::<(i32, String, String)>(&DB.get()?) @@ -54,7 +69,7 @@ pub(crate) fn is_wg_and_teams(args: &Args) -> Result { /// Set slow mode for a channel. /// /// A `seconds` value of 0 will disable slowmode -pub(crate) fn slow_mode(args: Args) -> Result<()> { +pub(crate) fn slow_mode(args: Args) -> Result<(), Error> { use std::str::FromStr; if is_mod(&args)? { @@ -75,7 +90,7 @@ pub(crate) fn slow_mode(args: Args) -> Result<()> { Ok(()) } -pub(crate) fn slow_mode_help(args: Args) -> Result<()> { +pub(crate) fn slow_mode_help(args: Args) -> Result<(), Error> { let help_string = " Set slowmode on a channel ``` @@ -99,7 +114,7 @@ will disable slowmode on the `#bot-usage` channel."; /// Kick a user from the guild. /// /// Requires the kick members permission -pub(crate) fn kick(args: Args) -> Result<()> { +pub(crate) fn kick(args: Args) -> Result<(), Error> { if is_mod(&args)? { let user_id = parse_username( &args @@ -117,7 +132,7 @@ pub(crate) fn kick(args: Args) -> Result<()> { Ok(()) } -pub(crate) fn kick_help(args: Args) -> Result<()> { +pub(crate) fn kick_help(args: Args) -> Result<(), Error> { let help_string = " Kick a user from the guild ``` diff --git a/src/ban.rs b/src/ban.rs index 9d36112..9cc3e9d 100644 --- a/src/ban.rs +++ b/src/ban.rs @@ -1,22 +1,11 @@ use crate::{ - api, - commands::{Args, Result}, - db::DB, - schema::bans, - text::ban_message, + api, commands::Args, db::DB, schema::bans, text::ban_message, Error, SendSyncError, HOUR, }; use diesel::prelude::*; use serenity::{model::prelude::*, prelude::*, utils::parse_username}; -use std::{ - sync::atomic::{AtomicBool, Ordering}, - thread::sleep, - time::{Duration, SystemTime}, -}; - -const HOUR: u64 = 3600; -static UNBAN_THREAD_INITIALIZED: AtomicBool = AtomicBool::new(false); +use std::time::{Duration, SystemTime}; -pub(crate) fn save_ban(user_id: String, guild_id: String, hours: u64) -> Result<()> { +pub(crate) fn save_ban(user_id: String, guild_id: String, hours: u64) -> Result<(), Error> { info!("Recording ban for user {}", &user_id); let conn = DB.get()?; diesel::insert_into(bans::table) @@ -33,7 +22,7 @@ pub(crate) fn save_ban(user_id: String, guild_id: String, hours: u64) -> Result< Ok(()) } -pub(crate) fn save_unban(user_id: String, guild_id: String) -> Result<()> { +pub(crate) fn save_unban(user_id: String, guild_id: String) -> Result<(), Error> { info!("Recording unban for user {}", &user_id); let conn = DB.get()?; diesel::update(bans::table) @@ -48,37 +37,30 @@ pub(crate) fn save_unban(user_id: String, guild_id: String) -> Result<()> { Ok(()) } -pub(crate) fn start_unban_thread(cx: Context) { +pub(crate) fn unban_users(cx: &Context) -> Result<(), SendSyncError> { use std::str::FromStr; - if !UNBAN_THREAD_INITIALIZED.load(Ordering::SeqCst) { - UNBAN_THREAD_INITIALIZED.store(true, Ordering::SeqCst); - type SendSyncError = Box; - std::thread::spawn(move || -> std::result::Result<(), SendSyncError> { - loop { - let conn = DB.get()?; - let to_unban = bans::table - .filter( - bans::unbanned - .eq(false) - .and(bans::end_time.le(SystemTime::now())), - ) - .load::<(i32, String, String, bool, SystemTime, SystemTime)>(&conn)?; - - for row in &to_unban { - let guild_id = GuildId::from(u64::from_str(&row.2)?); - info!("Unbanning user {}", &row.1); - guild_id.unban(&cx, u64::from_str(&row.1)?)?; - } - sleep(Duration::new(HOUR, 0)); - } - }); + + let conn = DB.get()?; + let to_unban = bans::table + .filter( + bans::unbanned + .eq(false) + .and(bans::end_time.le(SystemTime::now())), + ) + .load::<(i32, String, String, bool, SystemTime, SystemTime)>(&conn)?; + + for row in &to_unban { + let guild_id = GuildId::from(u64::from_str(&row.2)?); + info!("Unbanning user {}", &row.1); + guild_id.unban(&cx, u64::from_str(&row.1)?)?; } + Ok(()) } /// Temporarily ban an user from the guild. /// /// Requires the ban members permission -pub(crate) fn temp_ban(args: Args) -> Result<()> { +pub(crate) fn temp_ban(args: Args) -> Result<(), Error> { let user_id = parse_username( &args .params @@ -118,7 +100,7 @@ pub(crate) fn temp_ban(args: Args) -> Result<()> { Ok(()) } -pub(crate) fn help(args: Args) -> Result<()> { +pub(crate) fn help(args: Args) -> Result<(), Error> { let hours = 24; let reason = "violating the code of conduct"; diff --git a/src/command_history.rs b/src/command_history.rs new file mode 100644 index 0000000..042844d --- /dev/null +++ b/src/command_history.rs @@ -0,0 +1,57 @@ +use crate::{ + commands::{Commands, PREFIX}, + Error, SendSyncError, HOUR, +}; +use indexmap::IndexMap; +use serenity::{model::prelude::*, prelude::*, utils::CustomMessage}; +use std::time::Duration; + +const MESSAGE_AGE_MAX: Duration = Duration::from_secs(HOUR); + +pub(crate) struct CommandHistory; + +impl TypeMapKey for CommandHistory { + type Value = IndexMap; +} + +pub(crate) fn replay_message( + cx: Context, + ev: MessageUpdateEvent, + cmds: &Commands, +) -> Result<(), Error> { + let age = ev.timestamp.and_then(|create| { + ev.edited_timestamp + .and_then(|edit| edit.signed_duration_since(create).to_std().ok()) + }); + + if age.is_some() && age.unwrap() < MESSAGE_AGE_MAX { + let mut msg = CustomMessage::new(); + msg.id(ev.id) + .channel_id(ev.channel_id) + .content(ev.content.unwrap_or_default()); + + let msg = msg.build(); + + if msg.content.starts_with(PREFIX) { + info!( + "sending edited message - {:?} {:?}", + msg.content, msg.author + ); + cmds.execute(cx, &msg); + } + } + + Ok(()) +} + +pub(crate) fn clear_command_history(cx: &Context) -> Result<(), SendSyncError> { + let mut data = cx.data.write(); + let history = data.get_mut::().unwrap(); + + // always keep the last command in history + if history.len() > 0 { + info!("Clearing command history"); + history.drain(..history.len() - 1); + } + Ok(()) +} diff --git a/src/commands.rs b/src/commands.rs index c2ce23d..c022215 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -1,27 +1,27 @@ use crate::{ api, state_machine::{CharacterSet, StateMachine}, + Error, }; use indexmap::IndexMap; use reqwest::blocking::Client as HttpClient; use serenity::{model::channel::Message, prelude::Context}; use std::{collections::HashMap, sync::Arc}; -const PREFIX: &'static str = "?"; -pub(crate) type Result = std::result::Result>; -pub(crate) type GuardFn = fn(&Args) -> Result; +pub(crate) const PREFIX: &str = "?"; +pub(crate) type GuardFn = fn(&Args) -> Result; struct Command { guard: GuardFn, - ptr: Box Fn(Args<'m>) -> Result<()> + Send + Sync>, + ptr: Box Fn(Args<'m>) -> Result<(), Error> + Send + Sync>, } impl Command { - fn authorize(&self, args: &Args) -> Result { + fn authorize(&self, args: &Args) -> Result { (self.guard)(&args) } - fn call(&self, args: Args) -> Result<()> { + fn call(&self, args: Args) -> Result<(), Error> { (self.ptr)(args) } } @@ -51,7 +51,7 @@ impl Commands { pub(crate) fn add( &mut self, command: &'static str, - handler: impl Fn(Args) -> Result<()> + Send + Sync + 'static, + handler: impl Fn(Args) -> Result<(), Error> + Send + Sync + 'static, ) { self.add_protected(command, handler, |_| Ok(true)); } @@ -59,7 +59,7 @@ impl Commands { pub(crate) fn add_protected( &mut self, command: &'static str, - handler: impl Fn(Args) -> Result<()> + Send + Sync + 'static, + handler: impl Fn(Args) -> Result<(), Error> + Send + Sync + 'static, guard: GuardFn, ) { info!("Adding command {}", &command); @@ -68,6 +68,11 @@ impl Commands { let mut opt_lambda_state = None; let mut opt_final_states = vec![]; + let handler = Arc::new(Command { + guard, + ptr: Box::new(handler), + }); + command .split(' ') .filter(|segment| segment.len() > 0) @@ -90,18 +95,25 @@ impl Commands { } else { opt_lambda_state = None; opt_final_states.truncate(0); + let last_state = state; state = self.add_space(state, i); if segment.starts_with("```\n") && segment.ends_with("```") { state = self.add_code_segment_multi_line(state, segment); } else if segment.starts_with("```") && segment.ends_with("```") { - state = self.add_code_segment_single_line(state, 3, segment); - } else if segment.starts_with("`") && segment.ends_with("`") { - state = self.add_code_segment_single_line(state, 1, segment); - } else if segment.starts_with("{") && segment.ends_with("}") { + state = self.add_code_segment_single_line(state, segment, 3); + } else if segment.starts_with('`') && segment.ends_with('`') { + state = self.add_code_segment_single_line(state, segment, 1); + } else if segment.starts_with('{') && segment.ends_with('}') { state = self.add_dynamic_segment(state, segment); } else if segment.ends_with("...") { - state = self.add_remaining_segment(state, segment); + if segment == "..." { + self.state_machine.set_final_state(last_state); + self.state_machine.set_handler(last_state, handler.clone()); + state = self.add_unnamed_remaining_segment(last_state); + } else { + state = self.add_remaining_segment(state, segment); + } } else { segment.chars().for_each(|ch| { state = self.state_machine.add(state, CharacterSet::from_char(ch)) @@ -110,11 +122,6 @@ impl Commands { } }); - let handler = Arc::new(Command { - guard, - ptr: Box::new(handler), - }); - if opt_lambda_state.is_some() { opt_final_states.iter().for_each(|state| { self.state_machine.set_final_state(*state); @@ -122,7 +129,7 @@ impl Commands { }); } else { self.state_machine.set_final_state(state); - self.state_machine.set_handler(state, handler.clone()); + self.state_machine.set_handler(state, handler); } } @@ -130,7 +137,7 @@ impl Commands { &mut self, cmd: &'static str, desc: &'static str, - handler: impl Fn(Args) -> Result<()> + Send + Sync + 'static, + handler: impl Fn(Args) -> Result<(), Error> + Send + Sync + 'static, ) { self.help_protected(cmd, desc, handler, |_| Ok(true)); } @@ -139,7 +146,7 @@ impl Commands { &mut self, cmd: &'static str, desc: &'static str, - handler: impl Fn(Args) -> Result<()> + Send + Sync + 'static, + handler: impl Fn(Args) -> Result<(), Error> + Send + Sync + 'static, guard: GuardFn, ) { let base_cmd = &cmd[1..]; @@ -166,7 +173,7 @@ impl Commands { self.menu.take() } - pub(crate) fn execute<'m>(&'m self, cx: Context, msg: &Message) { + pub(crate) fn execute(&self, cx: Context, msg: &Message) { let message = &msg.content; if !msg.is_own(&cx) && message.starts_with(PREFIX) { self.state_machine.process(message).map(|matched| { @@ -201,8 +208,7 @@ impl Commands { fn add_space(&mut self, mut state: usize, i: usize) -> usize { if i > 0 { - let mut char_set = CharacterSet::from_char(' '); - char_set.insert('\n'); + let char_set = CharacterSet::from_chars(&[' ', '\n']); state = self.state_machine.add(state, char_set); self.state_machine.add_next_state(state, state); @@ -226,7 +232,7 @@ impl Commands { let name = &s[1..s.len() - 1]; let mut char_set = CharacterSet::any(); - char_set.remove(' '); + char_set.remove(&[' ']); state = self.state_machine.add(state, char_set); self.state_machine.add_next_state(state, state); self.state_machine.start_parse(state, name); @@ -247,6 +253,14 @@ impl Commands { state } + fn add_unnamed_remaining_segment(&mut self, mut state: usize) -> usize { + let char_set = CharacterSet::any(); + state = self.state_machine.add(state, char_set); + self.state_machine.add_next_state(state, state); + + state + } + fn add_code_segment_multi_line(&mut self, mut state: usize, s: &'static str) -> usize { let name = &s[4..s.len() - 3]; @@ -257,9 +271,7 @@ impl Commands { let lambda = state; let mut char_set = CharacterSet::any(); - char_set.remove('`'); - char_set.remove(' '); - char_set.remove('\n'); + char_set.remove(&['`', ' ', '\n']); state = self.state_machine.add(state, char_set); self.state_machine.add_next_state(state, state); @@ -282,8 +294,8 @@ impl Commands { fn add_code_segment_single_line( &mut self, mut state: usize, - n_backticks: usize, s: &'static str, + n_backticks: usize, ) -> usize { use std::iter::repeat; @@ -310,8 +322,7 @@ impl Commands { state = self.state_machine.add(state, CharacterSet::from_char('=')); let mut char_set = CharacterSet::any(); - char_set.remove(' '); - char_set.remove('\n'); + char_set.remove(&[' ', '\n']); state = self.state_machine.add(state, char_set); self.state_machine.add_next_state(state, state); self.state_machine.start_parse(state, name); @@ -323,7 +334,7 @@ impl Commands { fn key_value_pair(s: &'static str) -> Option<&'static str> { s.match_indices("={}") - .nth(0) + .next() .map(|pair| { let name = &s[0..pair.0]; if name.len() > 0 { diff --git a/src/crates.rs b/src/crates.rs index 3c38e97..07e1674 100644 --- a/src/crates.rs +++ b/src/crates.rs @@ -1,7 +1,4 @@ -use crate::{ - api, - commands::{Args, Result}, -}; +use crate::{api, commands::Args, Error}; use reqwest::header; use serde::Deserialize; @@ -21,36 +18,39 @@ struct Crate { #[serde(rename = "updated_at")] updated: String, downloads: u64, - description: String, + description: Option, documentation: Option, } -fn get_crate(args: &Args) -> Result> { +fn get_crate(args: &Args) -> Result, Error> { let query = args .params .get("query") .ok_or("Unable to retrieve param: query")?; - info!("searching for crate `{}`", query); + let mut query_iter = query.splitn(2, "::"); + let crate_name = query_iter.next().unwrap(); + + info!("searching for crate `{}`", crate_name); let crate_list = args .http .get("https://crates.io/api/v1/crates") .header(header::USER_AGENT, USER_AGENT) - .query(&[("q", query)]) + .query(&[("q", crate_name)]) .send()? .json::()?; - Ok(crate_list.crates.into_iter().nth(0)) + Ok(crate_list.crates.into_iter().next()) } -pub fn search(args: Args) -> Result<()> { +pub fn search(args: Args) -> Result<(), Error> { if let Some(krate) = get_crate(&args)? { args.msg.channel_id.send_message(&args.cx, |m| { m.embed(|e| { e.title(&krate.name) .url(format!("https://crates.io/crates/{}", krate.id)) - .description(&krate.description) + .description(krate.description.unwrap_or_default()) .field("version", &krate.version, true) .field("downloads", &krate.downloads, true) .timestamp(krate.updated.as_str()) @@ -79,7 +79,7 @@ fn rustc_crate(crate_name: &str) -> Option<&str> { } } -pub fn doc_search(args: Args) -> Result<()> { +pub fn doc_search(args: Args) -> Result<(), Error> { let query = args .params .get("query") @@ -114,7 +114,7 @@ pub fn doc_search(args: Args) -> Result<()> { } /// Print the help message -pub fn help(args: Args) -> Result<()> { +pub fn help(args: Args) -> Result<(), Error> { let help_string = "search for a crate on crates.io ``` ?crate query... @@ -124,7 +124,7 @@ pub fn help(args: Args) -> Result<()> { } /// Print the help message -pub fn doc_help(args: Args) -> Result<()> { +pub fn doc_help(args: Args) -> Result<(), Error> { let help_string = "retrieve documentation for a given crate ``` ?docs crate_name... diff --git a/src/db.rs b/src/db.rs index 843c4f5..fab06bf 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,4 +1,4 @@ -use crate::commands::Result; +use crate::Error; use diesel::prelude::*; use diesel::r2d2; use lazy_static::lazy_static; @@ -12,7 +12,7 @@ lazy_static! { .expect("Unable to connect to database"); } -pub(crate) fn run_migrations() -> Result<()> { +pub(crate) fn run_migrations() -> Result<(), Error> { let conn = PgConnection::establish(&std::env::var("DATABASE_URL")?)?; diesel_migrations::embed_migrations!(); diff --git a/src/jobs.rs b/src/jobs.rs new file mode 100644 index 0000000..5f1cdf0 --- /dev/null +++ b/src/jobs.rs @@ -0,0 +1,23 @@ +use crate::{ban::unban_users, command_history::clear_command_history, SendSyncError, HOUR}; +use serenity::client::Context; +use std::{ + sync::atomic::{AtomicBool, Ordering}, + thread::sleep, + time::Duration, +}; + +static JOBS_THREAD_INITIALIZED: AtomicBool = AtomicBool::new(false); + +pub(crate) fn start_jobs(cx: Context) { + if !JOBS_THREAD_INITIALIZED.load(Ordering::SeqCst) { + JOBS_THREAD_INITIALIZED.store(true, Ordering::SeqCst); + std::thread::spawn(move || -> Result<(), SendSyncError> { + loop { + unban_users(&cx)?; + clear_command_history(&cx)?; + + sleep(Duration::new(HOUR, 0)); + } + }); + } +} diff --git a/src/main.rs b/src/main.rs index c236eae..d3f100c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,9 +9,11 @@ extern crate log; mod api; mod ban; +mod command_history; mod commands; mod crates; mod db; +mod jobs; mod playground; mod schema; mod state_machine; @@ -20,13 +22,17 @@ mod text; mod welcome; use crate::db::DB; -use commands::{Args, Commands, GuardFn, Result}; +use commands::{Args, Commands, GuardFn}; use diesel::prelude::*; -use envy; use indexmap::IndexMap; use serde::Deserialize; use serenity::{model::prelude::*, prelude::*}; +pub(crate) type Error = Box; +pub(crate) type SendSyncError = Box; + +pub(crate) const HOUR: u64 = 3600; + #[derive(Deserialize)] struct Config { tags: bool, @@ -38,13 +44,13 @@ struct Config { wg_and_teams_id: Option, } -fn init_data(config: &Config) -> Result<()> { +fn init_data(config: &Config) -> Result<(), Error> { use crate::schema::roles; info!("Loading data into database"); let conn = DB.get()?; - let upsert_role = |name: &str, role_id: &str| -> Result<()> { + let upsert_role = |name: &str, role_id: &str| -> Result<(), Error> { diesel::insert_into(roles::table) .values((roles::role.eq(role_id), roles::name.eq(name))) .on_conflict(roles::name) @@ -66,7 +72,7 @@ fn init_data(config: &Config) -> Result<()> { let wg_and_teams_role = config .wg_and_teams_id .as_ref() - .ok_or_else(|| "missing value for field wg_and_teams_id.\n\nIf you enabled tags or crates then you need the WG_AND_TEAMS_ID env var.")?; + .ok_or(text::WG_AND_TEAMS_MISSING_ENV_VAR)?; upsert_role("wg_and_teams", &wg_and_teams_role)?; } @@ -76,7 +82,7 @@ fn init_data(config: &Config) -> Result<()> { Ok(()) } -fn app() -> Result<()> { +fn app() -> Result<(), Error> { let config = envy::from_env::()?; info!("starting..."); @@ -95,6 +101,11 @@ fn app() -> Result<()> { tags::post, api::is_wg_and_teams, ); + cmds.add_protected( + "?tags update {key} value...", + tags::update, + api::is_wg_and_teams, + ); cmds.add("?tag {key}", tags::get); cmds.add("?tags", tags::get_all); cmds.help("?tags", "A key value store", tags::help); @@ -113,7 +124,7 @@ fn app() -> Result<()> { if config.eval { // rust playground cmds.add( - "?play mode={} edition={} channel={} warn={} ```\ncode```", + "?play mode={} edition={} channel={} warn={} ```\ncode``` ...", playground::run, ); cmds.add("?play code...", playground::err); @@ -124,15 +135,15 @@ fn app() -> Result<()> { ); cmds.add( - "?eval mode={} edition={} channel={} warn={} ```\ncode```", + "?eval mode={} edition={} channel={} warn={} ```\ncode``` ...", playground::eval, ); cmds.add( - "?eval mode={} edition={} channel={} warn={} ```code```", + "?eval mode={} edition={} channel={} warn={} ```code``` ...", playground::eval, ); cmds.add( - "?eval mode={} edition={} channel={} warn={} `code`", + "?eval mode={} edition={} channel={} warn={} `code` ...", playground::eval, ); cmds.add("?eval code...", playground::eval_err); @@ -186,7 +197,7 @@ fn app() -> Result<()> { }); let mut client = Client::new_with_extras(&config.discord_token, |e| { - e.raw_event_handler(Events { cmds }); + e.event_handler(Events { cmds }); e })?; @@ -209,6 +220,8 @@ fn main_menu(args: &Args, commands: &IndexMap<&str, (&str, GuardFn)>) -> String menu += &format!("\t{help:<12}This menu\n", help = "?help"); menu += "\nType ?help command for more info on a command."; + menu += "\n\nAdditional Info:\n"; + menu += "\tYou can edit your message to the bot and the bot will edit its response."; menu } @@ -225,29 +238,69 @@ struct Events { cmds: Commands, } -impl RawEventHandler for Events { - fn raw_event(&self, cx: Context, event: Event) { - match event { - Event::Ready(ev) => { - info!("{} connected to discord", ev.ready.user.name); - ban::start_unban_thread(cx); - } - Event::MessageCreate(ev) => { - self.cmds.execute(cx, &ev.message); - } - Event::ReactionAdd(ev) => { - if let Err(e) = welcome::assign_talk_role(&cx, &ev) { - error!("{}", e); - } - } - Event::GuildBanRemove(ev) => { - if let Err(e) = - ban::save_unban(format!("{}", ev.user.id), format!("{}", ev.guild_id)) - { - error!("{}", e); - } +impl EventHandler for Events { + fn ready(&self, cx: Context, ready: Ready) { + info!("{} connected to discord", ready.user.name); + { + let mut data = cx.data.write(); + data.insert::(IndexMap::new()); + } + + jobs::start_jobs(cx); + } + + fn message(&self, cx: Context, message: Message) { + self.cmds.execute(cx, &message); + } + + fn message_update( + &self, + cx: Context, + _: Option, + _: Option, + ev: MessageUpdateEvent, + ) { + let age = ev.timestamp.and_then(|create| { + ev.edited_timestamp + .and_then(|edit| edit.signed_duration_since(create).to_std().ok()) + }); + + if age.is_some() && age.unwrap() < MESSAGE_AGE_MAX { + let mut msg = CustomMessage::new(); + msg.id(ev.id) + .channel_id(ev.channel_id) + .content(ev.content.unwrap_or_else(|| String::new())); + + let msg = msg.build(); + + if msg.content.starts_with(commands::PREFIX) { + info!( + "sending edited message - {:?} {:?}", + msg.content, msg.author + ); + self.cmds.execute(cx, &msg); } - _ => (), + } + } + + fn message_delete(&self, cx: Context, channel_id: ChannelId, message_id: MessageId) { + let mut data = cx.data.write(); + let history = data.get_mut::().unwrap(); + if let Some(response_id) = history.remove(&message_id) { + info!("deleting message: {:?}", response_id); + let _ = channel_id.delete_message(&cx, response_id); + } + } + + fn reaction_add(&self, cx: Context, reaction: Reaction) { + if let Err(e) = welcome::assign_talk_role(&cx, &reaction) { + error!("{}", e); + } + } + + fn guild_ban_removal(&self, _cx: Context, guild_id: GuildId, user: User) { + if let Err(e) = ban::save_unban(format!("{}", user.id), format!("{}", guild_id)) { + error!("{}", e); } } } diff --git a/src/playground.rs b/src/playground.rs index a664f9d..9e3ec6f 100644 --- a/src/playground.rs +++ b/src/playground.rs @@ -1,9 +1,6 @@ //! run rust code on the rust-lang playground -use crate::{ - api, - commands::{Args, Result}, -}; +use crate::{api, commands::Args, Error}; use reqwest::header; use serde::{Deserialize, Serialize}; @@ -45,6 +42,7 @@ impl<'a> PlaygroundCode<'a> { let edition = match self.edition { Edition::E2015 => "2015", Edition::E2018 => "2018", + Edition::E2021 => "2021", }; let mode = match self.mode { @@ -70,7 +68,7 @@ enum Channel { impl FromStr for Channel { type Err = Box; - fn from_str(s: &str) -> Result { + fn from_str(s: &str) -> Result { match s { "stable" => Ok(Channel::Stable), "beta" => Ok(Channel::Beta), @@ -86,15 +84,18 @@ enum Edition { E2015, #[serde(rename = "2018")] E2018, + #[serde(rename = "2021")] + E2021, } impl FromStr for Edition { type Err = Box; - fn from_str(s: &str) -> Result { + fn from_str(s: &str) -> Result { match s { "2015" => Ok(Edition::E2015), "2018" => Ok(Edition::E2018), + "2021" => Ok(Edition::E2021), _ => Err(format!("invalid edition `{}`", s).into()), } } @@ -118,7 +119,7 @@ enum Mode { impl FromStr for Mode { type Err = Box; - fn from_str(s: &str) -> Result { + fn from_str(s: &str) -> Result { match s { "debug" => Ok(Mode::Debug), "release" => Ok(Mode::Release), @@ -134,13 +135,13 @@ struct PlayResult { stderr: String, } -fn run_code(args: &Args, code: &str) -> Result { +fn run_code(args: &Args, code: &str) -> Result { let mut errors = String::new(); - let warnings = args.params.get("warn").unwrap_or_else(|| &"false"); - let channel = args.params.get("channel").unwrap_or_else(|| &"nightly"); - let mode = args.params.get("mode").unwrap_or_else(|| &"debug"); - let edition = args.params.get("edition").unwrap_or_else(|| &"2018"); + let warnings = args.params.get("warn").unwrap_or(&"false"); + let channel = args.params.get("channel").unwrap_or(&"nightly"); + let mode = args.params.get("mode").unwrap_or(&"debug"); + let edition = args.params.get("edition").unwrap_or(&"2018"); let mut request = PlaygroundCode::new(code); @@ -189,14 +190,14 @@ fn run_code(args: &Args, code: &str) -> Result { get_playground_link(args, code, &request)? ) } else if result.len() == 0 { - format!("{}compilation succeded.", errors) + format!("{}compilation succeeded.", errors) } else { format!("{}```\n{}```", errors, result) }, ) } -fn get_playground_link(args: &Args, code: &str, request: &PlaygroundCode) -> Result { +fn get_playground_link(args: &Args, code: &str, request: &PlaygroundCode) -> Result { let mut payload = HashMap::new(); payload.insert("code", code); @@ -215,7 +216,7 @@ fn get_playground_link(args: &Args, code: &str, request: &PlaygroundCode) -> Res .ok_or_else(|| "no gist found".into()) } -pub fn run(args: Args) -> Result<()> { +pub fn run(args: Args) -> Result<(), Error> { let code = args .params .get("code") @@ -226,14 +227,14 @@ pub fn run(args: Args) -> Result<()> { Ok(()) } -pub fn help(args: Args, name: &str) -> Result<()> { +pub fn help(args: Args, name: &str) -> Result<(), Error> { let message = format!( "Compile and run rust code. All code is executed on https://play.rust-lang.org. ```?{} mode={{}} channel={{}} edition={{}} warn={{}} ``\u{200B}`code``\u{200B}` ``` Optional arguments: \tmode: debug, release (default: debug) \tchannel: stable, beta, nightly (default: nightly) - \tedition: 2015, 2018 (default: 2018) + \tedition: 2015, 2018, 2021 (default: 2018) \twarn: boolean flag to enable compilation warnings ", name @@ -243,7 +244,7 @@ Optional arguments: Ok(()) } -pub fn err(args: Args) -> Result<()> { +pub fn err(args: Args) -> Result<(), Error> { let message = "Missing code block. Please use the following markdown: \\`\\`\\`rust code here @@ -254,27 +255,25 @@ pub fn err(args: Args) -> Result<()> { Ok(()) } -pub fn eval(args: Args) -> Result<()> { +pub fn eval(args: Args) -> Result<(), Error> { let code = args .params .get("code") .ok_or("Unable to retrieve param: query")?; - let code = format!( - "fn main(){{ - println!(\"{{:?}}\",{{ - {} - }}); -}}", - code - ); + if code.contains("fn main") { + api::send_reply(&args, "code passed to ?eval should not contain `fn main`")?; + } else { + let code = format!("fn main(){{ println!(\"{{:?}}\",{{ {} }}); }}", code); + + let result = run_code(&args, &code)?; + api::send_reply(&args, &result)?; + } - let result = run_code(&args, &code)?; - api::send_reply(&args, &result)?; Ok(()) } -pub fn eval_err(args: Args) -> Result<()> { +pub fn eval_err(args: Args) -> Result<(), Error> { let message = "Missing code block. Please use the following markdown: \\`code here\\` or diff --git a/src/state_machine.rs b/src/state_machine.rs index 3f5206c..211a17e 100644 --- a/src/state_machine.rs +++ b/src/state_machine.rs @@ -34,31 +34,33 @@ impl CharacterSet { match val { 0..=63 => { let bit = 1 << val; - self.low_mask = self.low_mask | bit; + self.low_mask |= bit; } 64..=127 => { let bit = 1 << val - 64; - self.high_mask = self.high_mask | bit; + self.high_mask |= bit; } _ => {} } } - /// Remove a character from the character set. - pub(crate) fn remove(&mut self, ch: char) { - let val = ch as u32 - 1; + /// Remove characters from the character set. + pub(crate) fn remove(&mut self, chs: &[char]) { + chs.iter().for_each(|ch| { + let val = *ch as u32 - 1; - match val { - 0..=63 => { - let bit = 1 << val; - self.low_mask = self.low_mask & !bit; - } - 64..=127 => { - let bit = 1 << val - 64; - self.high_mask = self.high_mask & !bit; + match val { + 0..=63 => { + let bit = 1 << val; + self.low_mask &= !bit; + } + 64..=127 => { + let bit = 1 << val - 64; + self.high_mask &= !bit; + } + _ => {} } - _ => {} - } + }); } /// Check if the character `ch` is a member of the character set. @@ -85,6 +87,13 @@ impl CharacterSet { chars.insert(ch); chars } + + /// Insert the characters `chs` into the character set. + pub(crate) fn from_chars(chs: &[char]) -> Self { + let mut chars = Self::new(); + chs.iter().for_each(|ch| chars.insert(*ch)); + chars + } } pub(crate) struct State { diff --git a/src/tags.rs b/src/tags.rs index 78a3783..2da1872 100644 --- a/src/tags.rs +++ b/src/tags.rs @@ -1,63 +1,73 @@ -use crate::{ - api, - commands::{Args, Result}, - db::DB, - schema::tags, -}; +use crate::{api, commands::Args, db::DB, schema::tags, Error}; use diesel::prelude::*; /// Remove a key value pair from the tags. -pub fn delete(args: Args) -> Result<()> { - if api::is_wg_and_teams(&args)? { - let conn = DB.get()?; - let key = args - .params - .get("key") - .ok_or("Unable to retrieve param: key")?; - - match diesel::delete(tags::table.filter(tags::key.eq(key))).execute(&conn) { - Ok(_) => args.msg.react(args.cx, "✅")?, - Err(_) => api::send_reply(&args, "A database error occurred when deleting the tag.")?, - } +pub fn delete(args: Args) -> Result<(), Error> { + let conn = DB.get()?; + let key = args + .params + .get("key") + .ok_or("Unable to retrieve param: key")?; + + match diesel::delete(tags::table.filter(tags::key.eq(key))).execute(&conn) { + Ok(_) => args.msg.react(args.cx, "✅")?, + Err(_) => api::send_reply(&args, "A database error occurred when deleting the tag.")?, } Ok(()) } /// Add a key value pair to the tags. -pub fn post(args: Args) -> Result<()> { - if api::is_wg_and_teams(&args)? { - let conn = DB.get()?; - - let key = args - .params - .get("key") - .ok_or("Unable to retrieve param: key")?; - - let value = args - .params - .get("value") - .ok_or("Unable to retrieve param: value")?; - - match diesel::insert_into(tags::table) - .values((tags::key.eq(key), tags::value.eq(value))) - .execute(&conn) - { - Ok(_) => args.msg.react(args.cx, "✅")?, - Err(_) => api::send_reply(&args, "A database error occurred when creating the tag.")?, - } - } else { - api::send_reply( - &args, - "Please reach out to a Rust team/WG member to create a tag.", - )?; +pub fn post(args: Args) -> Result<(), Error> { + let conn = DB.get()?; + + let key = args + .params + .get("key") + .ok_or("Unable to retrieve param: key")?; + + let value = args + .params + .get("value") + .ok_or("Unable to retrieve param: value")?; + + match diesel::insert_into(tags::table) + .values((tags::key.eq(key), tags::value.eq(value))) + .execute(&conn) + { + Ok(_) => args.msg.react(args.cx, "✅")?, + Err(_) => api::send_reply(&args, "A database error occurred when creating the tag.")?, + } + Ok(()) +} + +/// Update an existing tag. +pub fn update(args: Args) -> Result<(), Error> { + let conn = DB.get()?; + + let key = args + .params + .get("key") + .ok_or("Unable to retrieve param: key")?; + + let value = args + .params + .get("value") + .ok_or("Unable to retrieve param: value")?; + + match diesel::update(tags::table.filter(tags::key.eq(key))) + .set(tags::value.eq(value)) + .execute(&conn) + { + Ok(_) => args.msg.react(args.cx, "✅")?, + Err(_) => api::send_reply(&args, "A database error occurred when updating the tag.")?, } Ok(()) } /// Retrieve a value by key from the tags. -pub fn get(args: Args) -> Result<()> { +pub fn get(args: Args) -> Result<(), Error> { let conn = DB.get()?; let key = args.params.get("key").ok_or("unable to read params")?; @@ -76,7 +86,7 @@ pub fn get(args: Args) -> Result<()> { } /// Retrieve all tags -pub fn get_all(args: Args) -> Result<()> { +pub fn get_all(args: Args) -> Result<(), Error> { let conn = DB.get()?; let results = tags::table.load::<(i32, String, String)>(&conn)?; @@ -85,19 +95,24 @@ pub fn get_all(args: Args) -> Result<()> { api::send_reply(&args, "No tags found")?; } else { let tags = &results.iter().fold(String::new(), |prev, row| { - prev + &row.1 + ": " + &row.2 + "\n" + if prev.len() < 1980 { + prev + &row.1 + "\n" + } else { + prev + } }); - api::send_reply(&args, &format!("\n{}", &tags))?; + api::send_reply(&args, &format!("All tags: ```\n{}```", &tags))?; } Ok(()) } /// Print the help message -pub fn help(args: Args) -> Result<()> { +pub fn help(args: Args) -> Result<(), Error> { let help_string = "``` ?tags create {key} value... Create a tag. Limited to WG & Teams. +?tags update {key} value... Update a tag. Limited to WG & Teams. ?tags delete {key} Delete a tag. Limited to WG & Teams. ?tags help This menu. ?tags Get all the tags. diff --git a/src/text.rs b/src/text.rs index 13e670f..641972a 100644 --- a/src/text.rs +++ b/src/text.rs @@ -1,4 +1,4 @@ -pub(crate) const WELCOME_BILLBOARD: &'static str = "By participating in this community, you agree to follow the Rust Code of Conduct, as linked below. Please click the :white_check_mark: below to acknowledge and gain access to the channels. +pub(crate) const WELCOME_BILLBOARD: &str = "By participating in this community, you agree to follow the Rust Code of Conduct, as linked below. Please click the :white_check_mark: below to acknowledge and gain access to the channels. https://www.rust-lang.org/policies/code-of-conduct @@ -7,3 +7,5 @@ If you see someone behaving inappropriately, or otherwise against the Code of Co pub(crate) fn ban_message(reason: &str, hours: u64) -> String { format!("You have been banned from The Rust Programming Language discord server for {}. The ban will expire in {} hours. If you feel this action was taken unfairly, you can reach the Rust moderation team at discord-mods@rust-lang.org", reason, hours) } + +pub(crate) const WG_AND_TEAMS_MISSING_ENV_VAR: &str = "missing value for field wg_and_teams_id.\n\nIf you enabled tags or crates then you need the WG_AND_TEAMS_ID env var."; diff --git a/src/welcome.rs b/src/welcome.rs index 665183a..b010258 100644 --- a/src/welcome.rs +++ b/src/welcome.rs @@ -1,15 +1,16 @@ use crate::{ api, - commands::{Args, Result}, + commands::Args, db::DB, schema::{messages, roles, users}, text::WELCOME_BILLBOARD, + Error, }; use diesel::prelude::*; use serenity::{model::prelude::*, prelude::*}; /// Write the welcome message to the welcome channel. -pub(crate) fn post_message(args: Args) -> Result<()> { +pub(crate) fn post_message(args: Args) -> Result<(), Error> { use std::str::FromStr; if api::is_mod(&args)? { @@ -63,9 +64,7 @@ pub(crate) fn post_message(args: Args) -> Result<()> { Ok(()) } -pub(crate) fn assign_talk_role(cx: &Context, ev: &ReactionAddEvent) -> Result<()> { - let reaction = &ev.reaction; - +pub(crate) fn assign_talk_role(cx: &Context, reaction: &Reaction) -> Result<(), Error> { let channel = reaction.channel(cx)?; let channel_id = ChannelId::from(&channel); let message = reaction.message(cx)?; @@ -114,20 +113,20 @@ pub(crate) fn assign_talk_role(cx: &Context, ev: &ReactionAddEvent) -> Result<() // Requires ManageMessage permission if let Some((_, _, user_id)) = me { - if ev.reaction.user_id.0.to_string() != user_id { - ev.reaction.delete(cx)?; + if reaction.user_id.0.to_string() != user_id { + reaction.delete(cx)?; } } } } else { - ev.reaction.delete(cx)?; + reaction.delete(cx)?; } } } Ok(()) } -pub(crate) fn help(args: Args) -> Result<()> { +pub(crate) fn help(args: Args) -> Result<(), Error> { let help_string = format!( " Post the welcome message to `channel`