diff --git a/.gitignore b/.gitignore index 0af23b5..b9ae509 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ /target -config.toml \ No newline at end of file +Config.toml \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index fffdc09..92f9d8a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,6 +13,7 @@ dependencies = [ "futures", "serde", "tokio", + "tokio-stream", "toml", "tracing", "tracing-subscriber", @@ -230,9 +231,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90fa4cc29d25b0687b8570b0da86eac698dcb525110ad8b938fe6712baa711ec" +checksum = "da9052a1a50244d8d5aa9bf55cbc2fb6f357c86cc52e46c62ed390a7180cf150" dependencies = [ "futures-channel", "futures-core", @@ -245,9 +246,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31ebc390c6913de330e418add60e1a7e5af4cb5ec600d19111b339cafcdcc027" +checksum = "f2d31b7ec7efab6eefc7c57233bb10b847986139d88cc2f5a02a1ae6871a1846" dependencies = [ "futures-core", "futures-sink", @@ -255,15 +256,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "089bd0baf024d3216916546338fffe4fc8dfffdd901e33c278abb091e0d52111" +checksum = "79e5145dde8da7d1b3892dad07a9c98fc04bc39892b1ecc9692cf53e2b780a65" [[package]] name = "futures-executor" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0cb59f15119671c94cd9cc543dc9a50b8d5edc468b4ff5f0bb8567f66c6b48a" +checksum = "e9e59fdc009a4b3096bf94f740a0f2424c082521f20a9b08c5c07c48d90fd9b9" dependencies = [ "futures-core", "futures-task", @@ -272,15 +273,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3868967e4e5ab86614e2176c99949eeef6cbcacaee737765f6ae693988273997" +checksum = "28be053525281ad8259d47e4de5de657b25e7bac113458555bb4b70bc6870500" [[package]] name = "futures-macro" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95778720c3ee3c179cd0d8fd5a0f9b40aa7d745c080f86a8f8bed33c4fd89758" +checksum = "c287d25add322d9f9abdcdc5927ca398917996600182178774032e9f8258fedd" dependencies = [ "proc-macro-hack", "proc-macro2", @@ -290,15 +291,15 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e0f6be0ec0357772fd58fb751958dd600bd0b3edfd429e77793e4282831360" +checksum = "caf5c69029bda2e743fddd0582d1083951d65cc9539aebf8812f36c3491342d6" [[package]] name = "futures-task" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "868090f28a925db6cb7462938c51d807546e298fb314088239f0e52fb4338b96" +checksum = "13de07eb8ea81ae445aca7b69f5f7bf15d7bf4912d8ca37d6645c77ae8a58d86" dependencies = [ "once_cell", ] @@ -311,9 +312,9 @@ checksum = "e64b03909df88034c26dc1547e8970b91f98bdb65165d6a4e9110d94263dbb2c" [[package]] name = "futures-util" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cad5e82786df758d407932aded1235e24d8e2eb438b6adafd37930c2462fb5d1" +checksum = "632a8cd0f2a4b3fdea1657f08bde063848c3bd00f9bbf6e256b8be78802e624b" dependencies = [ "futures-channel", "futures-core", @@ -389,9 +390,9 @@ checksum = "d7afe4a420e3fe79967a00898cc1f4db7c8a49a9333a29f8a4bd76a253d5cd04" [[package]] name = "hermit-abi" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5aca5565f760fb5b220e499d72710ed156fdb74e631659e99377d9ebfbd13ae8" +checksum = "322f4de77956e22ed0e5032c359a0f1273f1f7f0d79bfa3b8ffbc730d7fbcc5c" dependencies = [ "libc", ] @@ -500,15 +501,6 @@ dependencies = [ "bytes 0.5.6", ] -[[package]] -name = "instant" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61124eeebbd69b8190558df225adf7e4caafce0d743919e5d6b19652314ec5ec" -dependencies = [ - "cfg-if 1.0.0", -] - [[package]] name = "itoa" version = "0.4.7" @@ -547,15 +539,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "lock_api" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd96ffd135b2fd7b973ac026d28085defbe8983df057ced3eb4f2130b0831312" -dependencies = [ - "scopeguard", -] - [[package]] name = "log" version = "0.4.13" @@ -684,31 +667,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "parking_lot" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d7744ac029df22dca6284efe4e898991d28e3085c706c972bcd7da4a27a15eb" -dependencies = [ - "instant", - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ccb628cad4f84851442432c60ad8e1f607e29752d0bf072cbd0baf28aa34272" -dependencies = [ - "cfg-if 1.0.0", - "instant", - "libc", - "redox_syscall", - "smallvec", - "winapi", -] - [[package]] name = "percent-encoding" version = "2.1.0" @@ -890,12 +848,6 @@ dependencies = [ "rand_core 0.6.1", ] -[[package]] -name = "redox_syscall" -version = "0.1.57" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" - [[package]] name = "regex" version = "1.4.3" @@ -977,12 +929,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "scopeguard" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" - [[package]] name = "sct" version = "0.6.0" @@ -1175,9 +1121,9 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" [[package]] name = "tokio" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d258221f566b6c803c7b4714abadc080172b272090cdc5e244a6d4dd13c3a6bd" +checksum = "0ca04cec6ff2474c638057b65798f60ac183e5e79d3448bb7163d36a39cff6ec" dependencies = [ "autocfg", "bytes 1.0.1", @@ -1186,24 +1132,11 @@ dependencies = [ "mio", "num_cpus", "once_cell", - "parking_lot", "pin-project-lite", "signal-hook-registry", - "tokio-macros", "winapi", ] -[[package]] -name = "tokio-macros" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42517d2975ca3114b22a16192634e8241dc5cc1f130be194645970cc1c371494" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "tokio-rustls" version = "0.22.0" @@ -1369,7 +1302,7 @@ dependencies = [ [[package]] name = "twilight-cache-inmemory" version = "0.3.0" -source = "git+https://github.com/sam-kirby/twilight.git?branch=fix-emoji-encoding#9f42dac2cc4acb54decd94b98bb6095da0a520b8" +source = "git+https://github.com/twilight-rs/twilight.git?branch=trunk#79a02ddae9fa6d67b030a85c8470a0b2c7fa3d02" dependencies = [ "bitflags", "dashmap", @@ -1381,7 +1314,7 @@ dependencies = [ [[package]] name = "twilight-command-parser" version = "0.3.0" -source = "git+https://github.com/sam-kirby/twilight.git?branch=fix-emoji-encoding#9f42dac2cc4acb54decd94b98bb6095da0a520b8" +source = "git+https://github.com/twilight-rs/twilight.git?branch=trunk#79a02ddae9fa6d67b030a85c8470a0b2c7fa3d02" dependencies = [ "unicase", ] @@ -1389,7 +1322,7 @@ dependencies = [ [[package]] name = "twilight-embed-builder" version = "0.3.0" -source = "git+https://github.com/sam-kirby/twilight.git?branch=fix-emoji-encoding#9f42dac2cc4acb54decd94b98bb6095da0a520b8" +source = "git+https://github.com/twilight-rs/twilight.git?branch=trunk#79a02ddae9fa6d67b030a85c8470a0b2c7fa3d02" dependencies = [ "twilight-model", ] @@ -1397,7 +1330,7 @@ dependencies = [ [[package]] name = "twilight-gateway" version = "0.3.1" -source = "git+https://github.com/sam-kirby/twilight.git?branch=fix-emoji-encoding#9f42dac2cc4acb54decd94b98bb6095da0a520b8" +source = "git+https://github.com/twilight-rs/twilight.git?branch=trunk#79a02ddae9fa6d67b030a85c8470a0b2c7fa3d02" dependencies = [ "async-tungstenite", "bitflags", @@ -1420,7 +1353,7 @@ dependencies = [ [[package]] name = "twilight-gateway-queue" version = "0.3.0" -source = "git+https://github.com/sam-kirby/twilight.git?branch=fix-emoji-encoding#9f42dac2cc4acb54decd94b98bb6095da0a520b8" +source = "git+https://github.com/twilight-rs/twilight.git?branch=trunk#79a02ddae9fa6d67b030a85c8470a0b2c7fa3d02" dependencies = [ "futures-channel", "futures-util", @@ -1432,7 +1365,7 @@ dependencies = [ [[package]] name = "twilight-http" version = "0.3.1" -source = "git+https://github.com/sam-kirby/twilight.git?branch=fix-emoji-encoding#9f42dac2cc4acb54decd94b98bb6095da0a520b8" +source = "git+https://github.com/twilight-rs/twilight.git?branch=trunk#79a02ddae9fa6d67b030a85c8470a0b2c7fa3d02" dependencies = [ "bytes 1.0.1", "futures-channel", @@ -1452,7 +1385,7 @@ dependencies = [ [[package]] name = "twilight-mention" version = "0.3.0" -source = "git+https://github.com/sam-kirby/twilight.git?branch=fix-emoji-encoding#9f42dac2cc4acb54decd94b98bb6095da0a520b8" +source = "git+https://github.com/twilight-rs/twilight.git?branch=trunk#79a02ddae9fa6d67b030a85c8470a0b2c7fa3d02" dependencies = [ "twilight-model", ] @@ -1460,7 +1393,7 @@ dependencies = [ [[package]] name = "twilight-model" version = "0.3.1" -source = "git+https://github.com/sam-kirby/twilight.git?branch=fix-emoji-encoding#9f42dac2cc4acb54decd94b98bb6095da0a520b8" +source = "git+https://github.com/twilight-rs/twilight.git?branch=trunk#79a02ddae9fa6d67b030a85c8470a0b2c7fa3d02" dependencies = [ "bitflags", "serde", @@ -1472,7 +1405,7 @@ dependencies = [ [[package]] name = "twilight-standby" version = "0.3.0" -source = "git+https://github.com/sam-kirby/twilight.git?branch=fix-emoji-encoding#9f42dac2cc4acb54decd94b98bb6095da0a520b8" +source = "git+https://github.com/twilight-rs/twilight.git?branch=trunk#79a02ddae9fa6d67b030a85c8470a0b2c7fa3d02" dependencies = [ "dashmap", "futures-channel", @@ -1484,7 +1417,7 @@ dependencies = [ [[package]] name = "twilight-util" version = "0.3.0" -source = "git+https://github.com/sam-kirby/twilight.git?branch=fix-emoji-encoding#9f42dac2cc4acb54decd94b98bb6095da0a520b8" +source = "git+https://github.com/twilight-rs/twilight.git?branch=trunk#79a02ddae9fa6d67b030a85c8470a0b2c7fa3d02" [[package]] name = "typenum" diff --git a/Cargo.toml b/Cargo.toml index 5476382..ab57e70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,18 +8,19 @@ edition = "2018" [dependencies] futures = "0.3" +tokio-stream = "0.1" toml = "0.5" tracing = "0.1" tracing-subscriber = "0.2" -twilight-cache-inmemory = { git = "https://github.com/sam-kirby/twilight.git", branch = "fix-emoji-encoding" } -twilight-command-parser = { git = "https://github.com/sam-kirby/twilight.git", branch = "fix-emoji-encoding" } -twilight-embed-builder = { git = "https://github.com/sam-kirby/twilight.git", branch = "fix-emoji-encoding" } -twilight-gateway = { git = "https://github.com/sam-kirby/twilight.git", branch = "fix-emoji-encoding" } -twilight-http = { git = "https://github.com/sam-kirby/twilight.git", branch = "fix-emoji-encoding" } -twilight-mention = { git = "https://github.com/sam-kirby/twilight.git", branch = "fix-emoji-encoding" } -twilight-model = { git = "https://github.com/sam-kirby/twilight.git", branch = "fix-emoji-encoding" } -twilight-standby = { git = "https://github.com/sam-kirby/twilight.git", branch = "fix-emoji-encoding" } -twilight-util = { git = "https://github.com/sam-kirby/twilight.git", branch = "fix-emoji-encoding" } +twilight-cache-inmemory = { git = "https://github.com/twilight-rs/twilight.git", branch = "trunk" } +twilight-command-parser = { git = "https://github.com/twilight-rs/twilight.git", branch = "trunk" } +twilight-embed-builder = { git = "https://github.com/twilight-rs/twilight.git", branch = "trunk" } +twilight-gateway = { git = "https://github.com/twilight-rs/twilight.git", branch = "trunk" } +twilight-http = { git = "https://github.com/twilight-rs/twilight.git", branch = "trunk" } +twilight-mention = { git = "https://github.com/twilight-rs/twilight.git", branch = "trunk" } +twilight-model = { git = "https://github.com/twilight-rs/twilight.git", branch = "trunk" } +twilight-standby = { git = "https://github.com/twilight-rs/twilight.git", branch = "trunk" } +twilight-util = { git = "https://github.com/twilight-rs/twilight.git", branch = "trunk" } [dependencies.serde] version = "1" @@ -27,4 +28,4 @@ features = ["derive"] [dependencies.tokio] version = "1" -features = ["full"] +features = ["rt-multi-thread", "signal", "sync", "time"] diff --git a/src/bot.rs b/src/bot.rs new file mode 100644 index 0000000..e64cb78 --- /dev/null +++ b/src/bot.rs @@ -0,0 +1,529 @@ +use crate::{ + config::Config, + constants::{DEAD_EMOJI, EMER_EMOJI}, + Result, +}; + +use std::{ + collections::HashSet, path::Path, result::Result as StdResult, sync::Arc, time::Duration, +}; + +use futures::Future; +use tokio::{signal::ctrl_c, sync::RwLock, task::JoinHandle, time::sleep}; +use tracing::error; +use twilight_cache_inmemory::{model::CachedMember, InMemoryCache as DiscordCache, ResourceType}; +use twilight_command_parser::{Arguments, Command, CommandParserConfig, Parser}; +use twilight_gateway::{shard::Events, Event, EventTypeFlags, Intents, Shard}; +use twilight_http::{ + error::Result as TwiResult, + request::channel::{message::CreateMessage, reaction::RequestReactionType}, + Client as DiscordHttp, +}; +use twilight_mention::{Mention, ParseMention}; +use twilight_model::{ + channel::GuildChannel, + channel::{Message, Reaction, ReactionType}, + id::{ChannelId, GuildId, MessageId, UserId}, +}; + +struct Game { + dead: HashSet, + ctrl_channel: ChannelId, + ctrl_msg: MessageId, + ctrl_user: UserId, + guild_id: GuildId, + meeting_in_progress: bool, +} + +#[derive(Clone)] +pub struct Bot<'p> { + bot_id: Option, + cache: DiscordCache, + config: Arc, + discord_http: DiscordHttp, + game: Arc>>, + owners: Arc>>>, + parser: Arc>, + shard: Shard, +} + +impl<'p> Bot<'p> { + pub fn new(config_path: impl AsRef) -> Result { + let config = Arc::new(Config::from_file(config_path)?); + + let discord_http = DiscordHttp::new(&config.token); + + let cache = DiscordCache::builder() + .resource_types( + ResourceType::CHANNEL + | ResourceType::GUILD + | ResourceType::MEMBER + | ResourceType::USER + | ResourceType::VOICE_STATE, + ) + .build(); + + let shard = Shard::new( + &config.token, + Intents::GUILDS + | Intents::GUILD_MESSAGES + | Intents::GUILD_MESSAGE_REACTIONS + | Intents::GUILD_VOICE_STATES, + ); + + let parser = { + let mut parser_config = CommandParserConfig::new(); + parser_config.add_prefix("~"); + parser_config.add_command("new", false); + parser_config.add_command("end", false); + parser_config.add_command("dead", false); + parser_config.add_command("stop", false); + + Arc::new(Parser::new(parser_config)) + }; + + Ok(Bot { + bot_id: None, + cache, + config, + discord_http, + game: Arc::new(RwLock::new(None)), + owners: Arc::new(RwLock::new(None)), + parser, + shard, + }) + } + + pub async fn fetch_app_details(&mut self) -> Result<()> { + let (owners, current_user) = { + let mut owners = HashSet::new(); + + let app_info = self.discord_http.current_user_application().await?; + if let Some(team) = app_info.team { + owners.extend(team.members.iter().map(|tm| tm.user.id)); + } else { + owners.insert(app_info.owner.id); + } + (owners, UserId(app_info.id.0)) + }; + + self.owners.write().await.replace(owners); + self.bot_id.replace(current_user); + + Ok(()) + } + + pub async fn start_gateway(&mut self) -> Result { + let shutdown_handle = self.shard.clone(); + tokio::spawn(async move { + if let Err(e) = ctrl_c().await { + error!("Error registering ctrl+c handler!\n{}", e); + } + + shutdown_handle.shutdown(); + }); + + self.shard.start().await?; + + let event_flags: EventTypeFlags = EventTypeFlags::GUILD_CREATE + | EventTypeFlags::MESSAGE_CREATE + | EventTypeFlags::MESSAGE_DELETE + | EventTypeFlags::REACTION_ADD + | EventTypeFlags::REACTION_REMOVE + | EventTypeFlags::VOICE_STATE_UPDATE; + + Ok(self.shard.some_events(event_flags)) + } + + pub fn update_cache(&self, event: &Event) { + self.cache.update(event); + } + + pub fn bot_id(&self) -> UserId { + self.bot_id + .expect("Expected bot ID - must fetch app info first!") + } + + pub async fn process_command(&'p self, msg: &Message) -> Result<()> { + match self.parser.parse(&msg.content) { + Some(Command { + name: "new", + arguments, + .. + }) => { + self.discord_http + .delete_message(msg.channel_id, msg.id) + .await?; + self.begin_game(msg, arguments).await? + } + Some(Command { name: "end", .. }) => { + self.discord_http + .delete_message(msg.channel_id, msg.id) + .await?; + + if self.is_in_control(msg.author.id).await { + self.end_game().await?; + } + } + Some(Command { + name: "dead", + arguments, + .. + }) => { + self.discord_http + .delete_message(msg.channel_id, msg.id) + .await?; + + self.deadify(msg, arguments).await?; + } + Some(Command { name: "stop", .. }) => { + self.discord_http + .delete_message(msg.channel_id, msg.id) + .await?; + + if self.is_in_control(msg.author.id).await { + if self.is_game_in_progress().await { + self.end_game().await?; + } + + self.shard.shutdown(); + } + } + _ => {} + } + + Ok(()) + } + + pub async fn reaction_add_handler(&self, reaction: &Reaction) -> Result<()> { + if self.is_reacting_to_control(&reaction).await { + match reaction.emoji { + ReactionType::Unicode { ref name } if name == EMER_EMOJI => { + if self.is_in_control(reaction.user_id).await { + self.begin_meeting().await?; + } + } + ReactionType::Unicode { ref name } if name == DEAD_EMOJI => { + self.make_dead(reaction.user_id).await; + } + _ => {} + } + } + + Ok(()) + } + + pub async fn reaction_remove_handler(&self, reaction: &Reaction) -> Result<()> { + if self.is_reacting_to_control(&reaction).await + && self.is_in_control(reaction.user_id).await + && matches!(reaction.emoji, ReactionType::Unicode { ref name } if name == EMER_EMOJI) + { + self.end_meeting().await?; + } + + Ok(()) + } + + async fn begin_game(&self, msg: &Message, mut args: Arguments<'_>) -> Result<()> { + let ctrl_msg = self + .discord_http + .create_message(msg.channel_id) + .content(format!( + "A game is in progress, {} can react to this message with {} to call a \ + meeting.\nAnyone can react to this message with {} to access dead chat \ + after the next meeting", + msg.author.mention(), + EMER_EMOJI, + DEAD_EMOJI + ))? + .await?; + + let discord_http = self.discord_http.clone(); + let reaction_ctrl_msg = ctrl_msg.clone(); + + // Adding emoji takes ~1 second; don't hold up starting a game by doing it concurrently + let res: JoinHandle> = tokio::spawn(async move { + let emojis = vec![ + RequestReactionType::Unicode { + name: EMER_EMOJI.into(), + }, + RequestReactionType::Unicode { + name: DEAD_EMOJI.into(), + }, + ]; + + for emoji in emojis { + discord_http + .create_reaction(reaction_ctrl_msg.channel_id, reaction_ctrl_msg.id, emoji) + .await?; + } + + Ok(()) + }); + + self.game.write().await.replace(Game { + dead: HashSet::new(), + ctrl_channel: msg.channel_id, + ctrl_msg: ctrl_msg.id, + ctrl_user: msg.author.id, + guild_id: msg.guild_id.unwrap(), + meeting_in_progress: false, + }); + + let duration = match args.next().and_then(|s| s.parse().ok()) { + Some(time) if time == 0 => None, + Some(time) => Some(Duration::from_secs(time)), + None => Some(Duration::from_secs(5)), + }; + + if let Some(duration) = duration { + sleep(duration).await; + } + + self.end_meeting().await?; + + res.await??; + + Ok(()) + } + + async fn end_game(&self) -> Result<()> { + if let Some(game) = self.game.write().await.take() { + self.discord_http + .delete_message(game.ctrl_channel, game.ctrl_msg) + .await?; + } else { + return Ok(()); + } + + let living_channel = self + .cache + .guild_channel(self.config.living_channel) + .unwrap(); + let dead_channel = self.cache.guild_channel(self.config.dead_channel).unwrap(); + + let mut futures = Vec::new(); + + for member in self.get_members_in_channel(living_channel).await { + futures.push( + self.discord_http + .update_guild_member(member.guild_id, member.user.id) + .mute(false), + ); + } + + for member in self.get_members_in_channel(dead_channel).await { + futures.push( + self.discord_http + .update_guild_member(member.guild_id, member.user.id) + .channel_id(self.config.living_channel), + ); + } + + self.batch(futures).await; + + Ok(()) + } + + async fn begin_meeting(&self) -> Result<()> { + let living_channel = self + .cache + .guild_channel(self.config.living_channel) + .unwrap(); + let dead_channel = self.cache.guild_channel(self.config.dead_channel).unwrap(); + + let mut futures = Vec::new(); + + { + let game_lock = self.game.read().await; + let game = game_lock.as_ref().unwrap(); + + for member in self.get_members_in_channel(living_channel).await { + if game.dead.contains(&member.user.id) { + continue; + } + futures.push( + self.discord_http + .update_guild_member(member.guild_id, member.user.id) + .mute(false), + ); + } + } + + for member in self.get_members_in_channel(dead_channel).await { + futures.push( + self.discord_http + .update_guild_member(member.guild_id, member.user.id) + .channel_id(self.config.living_channel) + .mute(true), + ) + } + + self.batch(futures).await; + + let mut game_lock = self.game.write().await; + let g = game_lock.as_mut().expect("expected game"); + g.meeting_in_progress = true; + + Ok(()) + } + + async fn end_meeting(&self) -> Result<()> { + let living_channel = self + .cache + .guild_channel(self.config.living_channel) + .unwrap(); + + let (alive_players, dead_players): (Vec<_>, Vec<_>) = { + let game_lock = self.game.read().await; + let game = game_lock.as_ref().unwrap(); + + self.get_members_in_channel(living_channel) + .await + .into_iter() + .partition(|p| !game.dead.contains(&p.user.id)) + }; + + let mut futures = Vec::new(); + + for player in alive_players { + futures.push( + self.discord_http + .update_guild_member(player.guild_id, player.user.id) + .mute(true), + ); + } + + for player in dead_players { + futures.push( + self.discord_http + .update_guild_member(player.guild_id, player.user.id) + .channel_id(self.config.dead_channel) + .mute(false), + ); + } + + self.batch(futures).await; + + let mut game_lock = self.game.write().await; + let g = game_lock.as_mut().expect("expected game"); + g.meeting_in_progress = false; + + Ok(()) + } + + async fn deadify(&self, msg: &Message, mut args: Arguments<'_>) -> Result<()> { + if let Some(broadcast) = self.broadcast().await { + if self.is_in_control(msg.author.id).await { + match args.next().map(UserId::parse) { + Some(Ok(target)) => { + let reply = broadcast + .content(format!("deadifying {}", target.mention()))? + .await?; + self.make_dead(target).await; + sleep(Duration::from_secs(5)).await; + self.discord_http + .delete_message(reply.channel_id, reply.id) + .await?; + } + _ => { + broadcast + .content("You must mention the user you wish to die")? + .await?; + } + } + } else { + broadcast + .content( + "You must have started the game or be an owner of the bot to make \ + others dead\nTo make yourself dead, please use the reactions", + )? + .await?; + } + } else { + self.discord_http + .create_message(msg.channel_id) + .content("There is no game running")? + .await?; + } + + Ok(()) + } + + async fn make_dead(&self, target: UserId) { + if let Some(game) = self.game.write().await.as_mut() { + if game.dead.insert(target) && game.meeting_in_progress { + if let Err(why) = self + .discord_http + .update_guild_member(game.guild_id, target) + .mute(true) + .await + { + error!("Error occurred when making {} dead:\n{}", target, why); + } + } + } + } + + async fn batch(&self, futs: Vec) + where + F: Future>, + { + let errors = futures::future::join_all(futs) + .await + .into_iter() + .filter_map(StdResult::err) + .collect::>(); + if !errors.is_empty() { + if let Some(channel) = self.game.read().await.as_ref().map(|g| g.ctrl_channel) { + let _ = self + .discord_http + .create_message(channel) + .content("errors occurred; check log") + .unwrap() + .await; + } + for error in errors { + error!("{}", error); + } + } + } + + async fn broadcast(&self) -> Option> { + self.game + .read() + .await + .as_ref() + .map(|g| self.discord_http.create_message(g.ctrl_channel)) + } + + async fn get_members_in_channel( + &self, + voice_channel: Arc, + ) -> Vec> { + self.cache + .voice_channel_states(voice_channel.id()) + .map_or(Vec::new(), |vs| { + vs.iter() + .map(|vs| self.cache.member(vs.guild_id.unwrap(), vs.user_id).unwrap()) + .filter(|m| !m.user.bot && !m.roles.contains(&self.config.spectator_role)) + .collect() + }) + } + + async fn is_game_in_progress(&self) -> bool { + self.game.read().await.is_some() + } + + async fn is_in_control(&self, user_id: UserId) -> bool { + matches!(self.owners.read().await.as_ref(), Some(owners) if owners.contains(&user_id)) + || matches!(self.game.read().await.as_ref(), Some(game) if game.ctrl_user == user_id) + } + + async fn is_reacting_to_control(&self, reaction: &Reaction) -> bool { + matches!( + self.game.read().await.as_ref(), + Some(game) if game.ctrl_msg == reaction.message_id, + ) + } +} diff --git a/src/config.rs b/src/config.rs index eddb01c..eecb790 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,9 +1,8 @@ -use std::path::Path; - use crate::Result; +use std::{fs::File, io::Read, path::Path}; + use serde::Deserialize; -use tokio::{fs::File, io::AsyncReadExt}; use twilight_model::id::{ChannelId, RoleId}; #[derive(Deserialize)] @@ -15,10 +14,10 @@ pub struct Config { } impl Config { - pub async fn from_file(path: impl AsRef) -> Result { - let mut file = File::open(path.as_ref()).await?; + pub fn from_file(path: impl AsRef) -> Result { + let mut file = File::open(path.as_ref())?; let mut contents = Vec::new(); - file.read_to_end(&mut contents).await?; + file.read_to_end(&mut contents)?; let config_str = String::from_utf8(contents)?; diff --git a/src/constants.rs b/src/constants.rs new file mode 100644 index 0000000..d5685b7 --- /dev/null +++ b/src/constants.rs @@ -0,0 +1,4 @@ +#[allow(clippy::non_ascii_literal)] +pub const EMER_EMOJI: &str = "🔴"; +#[allow(clippy::non_ascii_literal)] +pub const DEAD_EMOJI: &str = "💀"; diff --git a/src/context.rs b/src/context.rs deleted file mode 100644 index dabd68c..0000000 --- a/src/context.rs +++ /dev/null @@ -1,274 +0,0 @@ -use std::{collections::HashSet, result::Result as StdResult, sync::Arc}; - -use crate::{config::Config, Result}; - -use futures::Future; -use tokio::sync::RwLock; -use tracing::error; -use twilight_cache_inmemory::{model::CachedMember, InMemoryCache as DiscordCache}; -use twilight_gateway::Shard; -use twilight_http::{ - error::Result as TwiResult, request::channel::message::CreateMessage, Client as DiscordHttp, -}; -use twilight_model::{ - channel::GuildChannel, - channel::{Message, Reaction}, - id::{ChannelId, GuildId, MessageId, UserId}, -}; - -#[derive(Clone)] -pub struct Context { - pub config: Arc, - pub discord_http: DiscordHttp, - pub cache: DiscordCache, - pub shard: Shard, - pub owners: Arc>, - game: Arc>>, -} - -impl Context { - pub fn new( - config: Config, - discord_http: DiscordHttp, - cache: DiscordCache, - shard: Shard, - owners: HashSet, - ) -> Self { - Context { - config: Arc::new(config), - discord_http, - cache, - shard, - owners: Arc::new(owners), - game: Arc::new(RwLock::new(None)), - } - } - - pub async fn batch(&self, futs: Vec) - where - F: Future>, - { - let errors = futures::future::join_all(futs) - .await - .into_iter() - .filter_map(StdResult::err) - .collect::>(); - if !errors.is_empty() { - if let Some(channel) = self.game.read().await.as_ref().map(|g| g.ctrl_channel) { - let _ = self - .discord_http - .create_message(channel) - .content("errors occurred; check log") - .unwrap() - .await; - } - for error in errors { - error!("{}", error); - } - } - } - - pub async fn broadcast(&self) -> Option> { - if let Some(game) = self.game.read().await.as_ref() { - Some(self.discord_http.create_message(game.ctrl_channel)) - } else { - None - } - } - - pub async fn make_dead(&self, target: &UserId) { - if let Some(game) = self.game.write().await.as_mut() { - if game.dead.insert(*target) && game.meeting_in_progress { - if let Err(why) = self - .discord_http - .update_guild_member(game.guild_id, *target) - .mute(true) - .await - { - error!("Error occurred when making {} dead:\n{}", target, why); - } - } - } - } - - pub async fn is_in_control(&self, user_id: &UserId) -> bool { - self.owners.contains(&user_id) - || self - .game - .read() - .await - .as_ref() - .map_or(false, |g| g.ctrl_user == *user_id) - } - - pub async fn is_reacting_to_control(&self, reaction: &Reaction) -> bool { - self.game - .read() - .await - .as_ref() - .map_or(false, |g| g.ctrl_msg == reaction.message_id) - } - - pub async fn start_game(&self, msg: &Message, ctrl_user: UserId, guild_id: GuildId) { - self.game.write().await.replace(Game { - dead: HashSet::new(), - ctrl_channel: msg.channel_id, - ctrl_msg: msg.id, - ctrl_user, - guild_id, - meeting_in_progress: false, - }); - } - - pub async fn is_game_in_progress(&self) -> bool { - self.game.read().await.is_some() - } - - pub async fn get_members_in_channel( - &self, - voice_channel: Arc, - ) -> Vec> { - match self.cache.voice_channel_states(voice_channel.id()) { - Some(vs) => vs - .iter() - .map(|vs| self.cache.member(vs.guild_id.unwrap(), vs.user_id).unwrap()) - .filter(|m| !m.user.bot && !m.roles.contains(&self.config.spectator_role)) - .collect(), - None => Vec::new(), - } - } - - pub async fn mute_players(&self) -> Result<()> { - let living_channel = self - .cache - .guild_channel(self.config.living_channel) - .unwrap(); - - let (alive_players, dead_players): (Vec<_>, Vec<_>) = { - let game_lock = self.game.read().await; - let game = game_lock.as_ref().unwrap(); - - self.get_members_in_channel(living_channel) - .await - .into_iter() - .partition(|p| !game.dead.contains(&p.user.id)) - }; - - let mut futures = Vec::new(); - - for player in alive_players { - futures.push( - self.discord_http - .update_guild_member(player.guild_id, player.user.id) - .mute(true), - ); - } - - for player in dead_players { - futures.push( - self.discord_http - .update_guild_member(player.guild_id, player.user.id) - .channel_id(self.config.dead_channel) - .mute(false), - ); - } - - self.batch(futures).await; - - let mut game_lock = self.game.write().await; - let g = game_lock.as_mut().expect("expected game"); - g.meeting_in_progress = false; - - Ok(()) - } - - pub async fn emergency_meeting(&self) -> Result<()> { - let living_channel = self - .cache - .guild_channel(self.config.living_channel) - .unwrap(); - let dead_channel = self.cache.guild_channel(self.config.dead_channel).unwrap(); - - let mut futures = Vec::new(); - - { - let game_lock = self.game.read().await; - let game = game_lock.as_ref().unwrap(); - - for member in self.get_members_in_channel(living_channel).await { - if game.dead.contains(&member.user.id) { - continue; - } - futures.push( - self.discord_http - .update_guild_member(member.guild_id, member.user.id) - .mute(false), - ); - } - } - - for member in self.get_members_in_channel(dead_channel).await { - futures.push( - self.discord_http - .update_guild_member(member.guild_id, member.user.id) - .channel_id(self.config.living_channel) - .mute(true), - ) - } - - self.batch(futures).await; - - let mut game_lock = self.game.write().await; - let g = game_lock.as_mut().expect("expected game"); - g.meeting_in_progress = true; - - Ok(()) - } - - pub async fn end_game(&self) -> Result<()> { - if let Some(game) = self.game.write().await.take() { - self.discord_http - .delete_message(game.ctrl_channel, game.ctrl_msg) - .await?; - } else { - return Ok(()); - } - - let living_channel = self - .cache - .guild_channel(self.config.living_channel) - .unwrap(); - let dead_channel = self.cache.guild_channel(self.config.dead_channel).unwrap(); - - let mut futures = Vec::new(); - - for member in self.get_members_in_channel(living_channel).await { - futures.push( - self.discord_http - .update_guild_member(member.guild_id, member.user.id) - .mute(false), - ); - } - - for member in self.get_members_in_channel(dead_channel).await { - futures.push( - self.discord_http - .update_guild_member(member.guild_id, member.user.id) - .channel_id(self.config.living_channel), - ); - } - - self.batch(futures).await; - - Ok(()) - } -} - -struct Game { - dead: HashSet, - ctrl_channel: ChannelId, - ctrl_msg: MessageId, - ctrl_user: UserId, - guild_id: GuildId, - meeting_in_progress: bool, -} diff --git a/src/main.rs b/src/main.rs index c451478..ce2ad3a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,265 +1,73 @@ -use std::{collections::HashSet, time::Duration}; - +#![deny( + clippy::all, + clippy::pedantic, + future_incompatible, + nonstandard_style, + rust_2018_idioms, + unused, + warnings +)] + +mod bot; mod config; -mod context; +mod constants; -use config::Config; -use context::Context; +use crate::bot::Bot; -use futures::StreamExt; -use tokio::{task::JoinHandle, time::sleep}; +use tokio_stream::StreamExt; use tracing::error; -use twilight_cache_inmemory::{InMemoryCache as DiscordCache, ResourceType}; -use twilight_command_parser::{Command, CommandParserConfig, Parser}; -use twilight_gateway::{shard::Shard, EventTypeFlags, Intents}; -use twilight_http::{request::channel::reaction::RequestReactionType, Client as DiscordHttp}; -use twilight_mention::{Mention, ParseMention}; -use twilight_model::{channel::Message, channel::ReactionType, gateway::event::Event, id::UserId}; +use twilight_model::gateway::event::Event; type Result = std::result::Result>; -const EMER_EMOJI: &str = "🔴"; -const DEAD_EMOJI: &str = "💀"; +fn main() -> Result<()> { + tokio::runtime::Runtime::new() + .unwrap() + .block_on(async { bot_main().await }) +} -#[tokio::main] -async fn main() -> Result<()> { +async fn bot_main() -> Result<()> { // Setup tracing_subscriber::fmt::init(); - let config = Config::from_file("./config.toml").await?; - - let cache = DiscordCache::builder() - .resource_types( - ResourceType::CHANNEL - | ResourceType::GUILD - | ResourceType::MEMBER - | ResourceType::USER - | ResourceType::VOICE_STATE, - ) - .build(); - - let discord_http = DiscordHttp::new(&config.token); - - let (owners, current_user) = { - let mut owners = HashSet::new(); - - let app_info = discord_http.current_user_application().await?; - if let Some(team) = app_info.team { - owners.extend(team.members.iter().map(|tm| tm.user.id)); - } else { - owners.insert(app_info.owner.id); - } - (owners, UserId(app_info.id.0)) - }; + let mut bot = Bot::new("./Config.toml")?; - let mut shard = Shard::new( - &config.token, - Intents::GUILDS - | Intents::GUILD_MESSAGES - | Intents::GUILD_MESSAGE_REACTIONS - | Intents::GUILD_VOICE_STATES, - ); - let shutdown_handle = shard.clone(); + bot.fetch_app_details().await?; // Start gateway - shard.start().await?; - - let event_flags: EventTypeFlags = EventTypeFlags::GUILD_CREATE - | EventTypeFlags::MESSAGE_CREATE - | EventTypeFlags::MESSAGE_DELETE - | EventTypeFlags::REACTION_ADD - | EventTypeFlags::REACTION_REMOVE - | EventTypeFlags::VOICE_STATE_UPDATE; - - let mut events = shard.some_events(event_flags); - - let context = Context::new(config, discord_http, cache, shutdown_handle, owners); - - let parser = { - let mut parser_config = CommandParserConfig::new(); - parser_config.add_prefix("~"); - parser_config.add_command("new", false); - parser_config.add_command("end", false); - parser_config.add_command("dead", false); - parser_config.add_command("stop", false); - - Parser::new(parser_config) - }; + let mut events = bot.start_gateway().await?; // Gateway event loop while let Some(event) = events.next().await { - context.cache.update(&event); + bot.update_cache(&event); match event { Event::MessageCreate(message) if !message.author.bot => { - let context_clone = context.clone(); - let parser_clone = parser.clone(); + let bot_clone = bot.clone(); tokio::spawn(async move { - if let Err(e) = process_command(context_clone, parser_clone, &message).await { - error!("{}", e); + if let Err(e) = bot_clone.process_command(&message).await { + error!("Error processing command\nMessage: {:?}\nError: {}", &message, e); } }); } - Event::ReactionAdd(reaction) if reaction.user_id != current_user => { - if context.is_reacting_to_control(&reaction).await { - match reaction.emoji { - ReactionType::Unicode { ref name } if name == EMER_EMOJI => { - if context.is_in_control(&reaction.user_id).await { - context.emergency_meeting().await?; - } - } - ReactionType::Unicode { ref name } if name == DEAD_EMOJI => { - context.make_dead(&reaction.user_id).await; - } - _ => {} + Event::ReactionAdd(reaction) if reaction.user_id != bot.bot_id() => { + let bot_clone = bot.clone(); + tokio::spawn(async move { + if let Err(e) = bot_clone.reaction_add_handler(&reaction).await { + error!("Error handling reaction add: {}", e); } - } - } - Event::ReactionRemove(reaction) if reaction.user_id != current_user => { - if matches!(reaction.emoji, ReactionType::Unicode { ref name } if name == EMER_EMOJI) - && context.is_reacting_to_control(&reaction).await - && context.is_in_control(&reaction.user_id).await - { - context.mute_players().await?; - } - } - _ => {} - } - } - - Ok(()) -} - -async fn process_command(ctx: Context, parser: Parser<'_>, msg: &Message) -> Result<()> { - match parser.parse(&msg.content) { - Some(Command { - name: "new", - mut arguments, - .. - }) => { - ctx.discord_http - .delete_message(msg.channel_id, msg.id) - .await?; - - let ctrl_msg = ctx - .discord_http - .create_message(msg.channel_id) - .content(format!( - "A game is in progress, {} can react to this message with {} to call a \ - meeting.\nAnyone can react to this message with {} to access dead chat after \ - the next meeting", - msg.author.mention(), - EMER_EMOJI, - DEAD_EMOJI - ))? - .await?; - - let reaction_ctx = ctx.clone(); - let reaction_ctrl_msg = ctrl_msg.clone(); - - let res: JoinHandle> = tokio::spawn(async move { - let emojis = vec![ - RequestReactionType::Unicode { - name: EMER_EMOJI.into(), - }, - RequestReactionType::Unicode { - name: DEAD_EMOJI.into(), - }, - ]; - - for emoji in emojis { - reaction_ctx - .discord_http - .create_reaction(reaction_ctrl_msg.channel_id, reaction_ctrl_msg.id, emoji) - .await?; - } - - Ok(()) - }); - - ctx.start_game(&ctrl_msg, msg.author.id, msg.guild_id.unwrap()) - .await; - - let duration = match arguments.next().and_then(|s| s.parse().ok()) { - Some(time) if time == 0 => None, - Some(time) => Some(Duration::from_secs(time)), - None => Some(Duration::from_secs(5)), - }; - - if let Some(duration) = duration { - sleep(duration).await; - } - - ctx.mute_players().await?; - - res.await??; - } - Some(Command { name: "end", .. }) => { - ctx.discord_http - .delete_message(msg.channel_id, msg.id) - .await?; - - if ctx.is_in_control(&msg.author.id).await { - ctx.end_game().await?; + }); } - } - Some(Command { - name: "dead", - mut arguments, - .. - }) => { - ctx.discord_http - .delete_message(msg.channel_id, msg.id) - .await?; - - if let Some(broadcast) = ctx.broadcast().await { - if ctx.is_in_control(&msg.author.id).await { - match arguments.next().map(UserId::parse) { - Some(Ok(target)) => { - let reply = broadcast - .content(format!("deadifying {}", target.mention()))? - .await?; - ctx.make_dead(&target).await; - sleep(Duration::from_secs(5)).await; - ctx.discord_http - .delete_message(reply.channel_id, reply.id) - .await?; - } - _ => { - broadcast - .content("You must mention the user you wish to die")? - .await?; - } + Event::ReactionRemove(reaction) if reaction.user_id != bot.bot_id() => { + let bot_clone = bot.clone(); + tokio::spawn(async move { + if let Err(e) = bot_clone.reaction_remove_handler(&reaction).await { + error!("Error handling reaction remove: {}", e); } - } else { - broadcast - .content( - "You must have started the game or be an owner of the bot to make \ - others dead\nTo make yourself dead, please use the reactions", - )? - .await?; - } - } else { - ctx.discord_http - .create_message(msg.channel_id) - .content("There is no game running")? - .await?; - } - } - Some(Command { name: "stop", .. }) => { - ctx.discord_http - .delete_message(msg.channel_id, msg.id) - .await?; - - if ctx.is_in_control(&msg.author.id).await { - if ctx.is_game_in_progress().await { - ctx.end_game().await?; - } - - ctx.shard.shutdown(); + }); } + _ => {} } - _ => {} } Ok(())