use std::{ fs::File, future::Future, path::PathBuf, pin::Pin, result::Result as StdResult, str::FromStr, sync::Mutex, }; use chrono::Datelike; use fnv::{ FnvHashMap, FnvHashSet, }; use lazy_static::lazy_static; use log::{ debug, error, info, trace, warn, }; use serenity::{ all::{ GuildId, ReactionType, }, framework::{ standard::{ BucketBuilder, CommandError, Configuration, }, StandardFramework, }, model::{ channel::Message, event::ResumedEvent, gateway::Ready, id::{ ChannelId, MessageId, }, }, prelude::*, }; use songbird::SerenityInit; use crate::{ commands::register_commands, config::CONFIG, util, util::OAUTH_URL, Error, Result, }; pub struct HttpKey; impl TypeMapKey for HttpKey { type Value = reqwest::Client; } struct Handler; #[serenity::async_trait] impl EventHandler for Handler { async fn ready(&self, ctx: Context, r: Ready) { let guild = r.guilds.iter().find(|g| g.id == CONFIG.discord.guild()); if guild.is_none() { info!("bot isn't in configured guild. join here: {:?}", OAUTH_URL.as_str()); return; } info!("connected"); #[cfg(debug_assertions)] let botname = "thulani (dev)"; #[cfg(not(debug_assertions))] let botname = "thulani"; use serenity::futures::StreamExt; serenity::futures::stream::iter(guild.iter()) .for_each(|g| async move { if let Err(e) = g.id.edit_nickname(&ctx, Some(botname)).await { error!("changing nickname: {:?}", e); } }) .await; } async fn resume(&self, _ctx: Context, _resume: ResumedEvent) { info!("reconnected to discord"); } async fn message_delete( &self, _ctx: Context, _: ChannelId, deleted_message_id: MessageId, _: Option, ) { MESSAGE_WATCH.lock().unwrap().remove(&deleted_message_id); } } lazy_static! { static ref MESSAGE_WATCH: Mutex> = Mutex::new(FnvHashMap::default()); static ref PREFIXES: Vec<&'static str> = vec!["!thulani ", "!thulan ", "!thulando madando ", "!thulando "]; static ref RESTRICTED_PREFIXES: Vec<&'static str> = vec!["!todd ", "!toddbert ", "!toddlani "]; static ref ALL_PREFIXES: Vec<&'static str> = { let mut all_prefixes: Vec<&'static str> = vec![]; all_prefixes.extend(PREFIXES.iter()); all_prefixes.extend(RESTRICTED_PREFIXES.iter()); all_prefixes }; static ref RESTRICT_IDS: FnvHashSet = { let default_path = PathBuf::from_str("restrict.json").unwrap(); let restrict_path = CONFIG.restrict.as_ref().unwrap_or(&default_path); let restrict_ids = File::open(restrict_path) .map_err(Error::from) .and_then(|f| serde_json::from_reader::<_, Vec>(f).map_err(Error::from)); if let Err(ref e) = restrict_ids { warn!("opening restrict file: {}", e); } let result = restrict_ids.unwrap_or_default().into_iter().collect::>(); info!("restricted ids: {:?}", result); result }; } async fn framework() -> StandardFramework { let builder = BucketBuilder::default().delay(1).limit(20).time_span(60); let framework = StandardFramework::new() .before(before_handle) .after(after_handle) .bucket("Standard", builder) .await; let config = Configuration::default() .allow_dm(false) .with_whitespace(true) .prefixes(ALL_PREFIXES.iter().map(|x| x.to_string())) .ignore_bots(true) .on_mention(None) .owners(vec![CONFIG.discord.owner()].into_iter().collect()) .case_insensitivity(true); framework.configure(config); register_commands(framework) } fn before_handle( ctx: &Context, message: &Message, cmd: &str, ) -> Pin + Send>> { debug!("got command '{}' from user '{}' ({})", cmd, message.author.name, message.author.id); Box::pin(async move { if !message.guild_id.map_or(false, |x| x == CONFIG.discord.guild()) { info!("rejecting command '{}' from user '{}': wrong guild", cmd, message.author.name); return false; } if message.author.id == CONFIG.discord.owner() { return true; } let restricted_prefix = RESTRICTED_PREFIXES.iter().any(|prefix| message.content.starts_with(prefix)); if !restricted_prefix { return true; } const PERMITTED_WEEKDAY: chrono::Weekday = chrono::Weekday::Tue; let user_is_restricted = RESTRICT_IDS.contains(&message.author.id.get()); let restrictions_flipped = chrono::Local::now().weekday() == PERMITTED_WEEKDAY; if user_is_restricted == restrictions_flipped { return true; } let reason = if !restrictions_flipped { "restricted prefix".to_owned() } else { format!("it is {:?}", PERMITTED_WEEKDAY) }; info!("rejecting command '{}' from user '{}': {}", cmd, message.author.name, reason); match util::send_result(ctx, message.channel_id, "no", message.tts).await { Err(e) => error!("sending restricted prefix response: {}", e), Ok(msg_id) => { let mut mp = MESSAGE_WATCH.lock().unwrap(); mp.insert(message.id, msg_id); }, } false }) } fn after_handle<'fut>( ctx: &'fut Context, msg: &'fut Message, cmd: &'fut str, err: StdResult<(), CommandError>, ) -> Pin + Send + 'fut>> { Box::pin(async move { match err { Ok(()) => { trace!("command '{}' completed successfully", cmd); }, Err(e) => { if let Err(e) = msg.react(&ctx, ReactionType::Unicode("❌".to_owned())).await { error!("reacting to failed message: {}", e); } if let Err(e) = util::send(ctx, msg.channel_id, "BANIC", msg.tts).await { error!("sending BANIC: {}", e); } error!("error encountered handling command '{}': {:?}", cmd, e); }, } }) } pub async fn run() -> Result<()> { let token = &CONFIG.discord.auth.token; let sb_config = songbird::Config::default(); let mut client = Client::builder(token, GatewayIntents::non_privileged() | GatewayIntents::MESSAGE_CONTENT) .event_handler(Handler) .register_songbird_from_config(sb_config) .type_map_insert::(reqwest::Client::new()) .framework(framework().await) .await?; let shard_manager = client.shard_manager.clone(); ctrlc::set_handler(move || { info!("shutting down"); let shard_manager = shard_manager.clone(); tokio::task::spawn(async move { shard_manager.shutdown_all().await; }); }) .expect("unable to create SIGINT/SIGTERM handlers"); client.start().await?; Ok(()) }