From b643a6a235b0c1c9902b97421f24eff2b0d0a5ac Mon Sep 17 00:00:00 2001 From: seth Date: Fri, 9 Aug 2024 23:35:41 -0400 Subject: tree-wide: end of summer cleanup (#214) * api: refactor & rename module to http * client: split from main.rs * tree-wide: use eyre::Report as error * nix: alejandra -> nixfmt * nix: start using treefmt-nix * nix: simplify flake * nix: refactor derivation & docker image * nix: remove overlay * ci: update & cleanup workflows * commands: assign all commands automatically * commands/copypasta: remove * http/teawie: update response struct for upstream rust rewrite * handlers: rename modules to events; flatten * crates: rename self to teawie-bot * nix: fenix -> rust-overlay i want a specific rust version grrrrrrr * ci: pin rust to 1.79 this is what our nix dev shell uses and what we can compile on. it seems the time crate doesn't like v1.80 of the compiler :( * ci: always run release gates * nix: fix static toolchain * nix: rust-overlay -> nixpkgs * ci: adopt actions-rust-lang actions * nix: use docker arch names for containers * crates/time: 0.3.30 -> 0.3.36 fixes building on rust 1.80.0 --- src/api/guzzle.rs | 20 ----- src/api/mod.rs | 29 ------ src/api/shiggy.rs | 20 ----- src/client.rs | 99 +++++++++++++++++++++ src/commands/general/ask.rs | 6 +- src/commands/general/bing.rs | 6 +- src/commands/general/config.rs | 8 +- src/commands/general/convert.rs | 12 +-- src/commands/general/emoji.rs | 5 +- src/commands/general/pfp.rs | 7 +- src/commands/general/random.rs | 16 ++-- src/commands/general/version.rs | 5 +- src/commands/mod.rs | 31 ++----- src/commands/moderation/clear_messages.rs | 5 +- src/commands/optional/copypasta.rs | 73 --------------- src/commands/optional/mod.rs | 1 - src/commands/optional/teawiespam.rs | 5 +- src/commands/optional/uwurandom.rs | 4 +- src/copypastas/astral.txt | 7 -- src/copypastas/dvd.txt | 1 - src/copypastas/egrill.txt | 1 - src/copypastas/happymeal.txt | 1 - src/copypastas/sus.txt | 1 - src/copypastas/ticktock.txt | 8 -- src/copypastas/twitter.txt | 35 -------- src/events/error.rs | 54 +++++++++++ src/events/guild.rs | 37 ++++++++ src/events/message.rs | 44 +++++++++ src/events/mod.rs | 52 +++++++++++ src/events/pinboard.rs | 81 +++++++++++++++++ src/events/reactboard.rs | 143 ++++++++++++++++++++++++++++++ src/handlers/error.rs | 51 ----------- src/handlers/event/guild.rs | 37 -------- src/handlers/event/message.rs | 44 --------- src/handlers/event/mod.rs | 51 ----------- src/handlers/event/pinboard.rs | 81 ----------------- src/handlers/event/reactboard.rs | 143 ------------------------------ src/handlers/mod.rs | 2 - src/http/mod.rs | 43 +++++++++ src/http/shiggy.rs | 20 +++++ src/http/teawie.rs | 28 ++++++ src/main.rs | 117 ++++-------------------- src/utils.rs | 2 +- 43 files changed, 669 insertions(+), 767 deletions(-) delete mode 100644 src/api/guzzle.rs delete mode 100644 src/api/mod.rs delete mode 100644 src/api/shiggy.rs create mode 100644 src/client.rs delete mode 100644 src/commands/optional/copypasta.rs delete mode 100644 src/copypastas/astral.txt delete mode 100644 src/copypastas/dvd.txt delete mode 100644 src/copypastas/egrill.txt delete mode 100644 src/copypastas/happymeal.txt delete mode 100644 src/copypastas/sus.txt delete mode 100644 src/copypastas/ticktock.txt delete mode 100644 src/copypastas/twitter.txt create mode 100644 src/events/error.rs create mode 100644 src/events/guild.rs create mode 100644 src/events/message.rs create mode 100644 src/events/mod.rs create mode 100644 src/events/pinboard.rs create mode 100644 src/events/reactboard.rs delete mode 100644 src/handlers/error.rs delete mode 100644 src/handlers/event/guild.rs delete mode 100644 src/handlers/event/message.rs delete mode 100644 src/handlers/event/mod.rs delete mode 100644 src/handlers/event/pinboard.rs delete mode 100644 src/handlers/event/reactboard.rs delete mode 100644 src/handlers/mod.rs create mode 100644 src/http/mod.rs create mode 100644 src/http/shiggy.rs create mode 100644 src/http/teawie.rs (limited to 'src') diff --git a/src/api/guzzle.rs b/src/api/guzzle.rs deleted file mode 100644 index 9ad6ad6..0000000 --- a/src/api/guzzle.rs +++ /dev/null @@ -1,20 +0,0 @@ -use eyre::Result; -use log::debug; -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize)] -struct RandomTeawieResponse { - url: String, -} - -// TODO: read this from an env var -const GUZZLE: &str = "https://api.getchoo.com"; -const RANDOM_TEAWIE: &str = "/random_teawie"; - -pub async fn random_teawie() -> Result { - let url = format!("{GUZZLE}{RANDOM_TEAWIE}"); - debug!("Making request to {url}"); - let json: RandomTeawieResponse = super::get_json(&url).await?; - - Ok(json.url) -} diff --git a/src/api/mod.rs b/src/api/mod.rs deleted file mode 100644 index dac9209..0000000 --- a/src/api/mod.rs +++ /dev/null @@ -1,29 +0,0 @@ -use std::sync::OnceLock; - -use eyre::Result; -use reqwest::Client; -use serde::de::DeserializeOwned; - -pub mod guzzle; -pub mod shiggy; - -pub fn client() -> &'static Client { - static USER_AGENT: OnceLock = OnceLock::new(); - static CLIENT: OnceLock = OnceLock::new(); - - let user_agent = USER_AGENT.get_or_init(|| { - let version = option_env!("CARGO_PKG_VERSION").unwrap_or("development"); - - format!("teawieBot/{version}") - }); - - CLIENT.get_or_init(|| Client::builder().user_agent(user_agent).build().unwrap()) -} - -async fn get_json(url: &str) -> Result { - let resp = client().get(url).send().await?; - resp.error_for_status_ref()?; - let json = resp.json().await?; - - Ok(json) -} diff --git a/src/api/shiggy.rs b/src/api/shiggy.rs deleted file mode 100644 index d6a6238..0000000 --- a/src/api/shiggy.rs +++ /dev/null @@ -1,20 +0,0 @@ -use eyre::Result; -use log::debug; -use serde::Deserialize; - -const SHIGGY: &str = "https://safebooru.donmai.us"; -const RANDOM_SHIGGY: &str = "/posts/random.json?tags=kemomimi-chan_(naga_u)+naga_u&only=file_url"; - -#[derive(Deserialize)] -struct SafebooruResponse { - file_url: String, -} - -#[allow(clippy::module_name_repetitions)] -pub async fn random_shiggy() -> Result { - let url = format!("{SHIGGY}{RANDOM_SHIGGY}"); - debug!("Making request to {url}"); - - let resp: SafebooruResponse = super::get_json(&url).await?; - Ok(resp.file_url) -} diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..65c221b --- /dev/null +++ b/src/client.rs @@ -0,0 +1,99 @@ +use crate::{commands, events, http, storage::Storage}; + +use std::{sync::Arc, time::Duration}; + +use eyre::{bail, Context as _, Result}; +use log::{info, trace, warn}; +use poise::{ + serenity_prelude::{self as serenity}, + EditTracker, Framework, FrameworkOptions, PrefixFrameworkOptions, +}; + +pub type Error = eyre::Report; +pub type Context<'a> = poise::Context<'a, Data, Error>; + +#[derive(Clone, Debug, Default)] +pub struct Data { + pub http_client: http::Client, + pub storage: Option, +} + +async fn setup(ctx: &serenity::Context) -> Result { + let storage = Storage::from_env().ok(); + + if let Some(storage) = storage.as_ref() { + if !storage.clone().is_connected() { + bail!("You specified a storage backend but there's no connection! Is it running?"); + } + trace!("Storage backend connected!"); + + poise::builtins::register_globally(ctx, &commands::global()).await?; + info!("Registered global commands!"); + + // register "extra" commands in guilds that allow it + let guilds = storage.get_opted_guilds().await?; + + for guild in guilds { + poise::builtins::register_in_guild(ctx, &commands::optional(), guild).await?; + + info!("Registered guild commands to {}", guild); + } + } else { + warn!("No storage backend was specified. Features requiring storage cannot be used"); + warn!("Registering optional commands globally since there's no storage backend"); + poise::builtins::register_globally(ctx, &commands::all()).await?; + } + + let http_client = ::default(); + let data = Data { + http_client, + storage, + }; + + Ok(data) +} + +pub async fn handle_shutdown(shard_manager: Arc, reason: &str) { + warn!("{reason}! Shutting down bot..."); + shard_manager.shutdown_all().await; + println!("Everything is shutdown. Goodbye!"); +} + +pub async fn get() -> Result { + let token = std::env::var("TOKEN").wrap_err("Couldn't find bot token in environment!")?; + + let intents = + serenity::GatewayIntents::non_privileged() | serenity::GatewayIntents::MESSAGE_CONTENT; + + let options = FrameworkOptions { + commands: commands::all(), + on_error: |error| Box::pin(events::error::handle(error)), + + command_check: Some(|ctx| { + Box::pin(async move { Ok(ctx.author().id != ctx.framework().bot_id) }) + }), + + event_handler: |ctx, event, _framework, data| Box::pin(events::handle(ctx, event, data)), + + prefix_options: PrefixFrameworkOptions { + prefix: Some("!".into()), + edit_tracker: Some(Arc::new(EditTracker::for_timespan(Duration::from_secs( + 3600, + )))), + ..Default::default() + }, + + ..Default::default() + }; + + let framework = Framework::builder() + .options(options) + .setup(|ctx, _ready, _framework| Box::pin(setup(ctx))) + .build(); + + let client = serenity::ClientBuilder::new(token, intents) + .framework(framework) + .await?; + + Ok(client) +} diff --git a/src/commands/general/ask.rs b/src/commands/general/ask.rs index c715e3a..1300e97 100644 --- a/src/commands/general/ask.rs +++ b/src/commands/general/ask.rs @@ -1,6 +1,6 @@ -use crate::{consts, utils, Context, Error}; +use crate::{client::Context, consts, utils}; -use eyre::Context as _; +use eyre::{Context as _, Result}; /// Ask teawie a question! #[poise::command(prefix_command, slash_command)] @@ -10,7 +10,7 @@ pub async fn ask( #[rename = "question"] #[description = "The question you want to ask teawie"] _question: String, -) -> Result<(), Error> { +) -> Result<()> { let resp = utils::random_choice(consts::RESPONSES) .wrap_err("Couldn't choose from random responses!")?; diff --git a/src/commands/general/bing.rs b/src/commands/general/bing.rs index 54ee0dc..28fdf0d 100644 --- a/src/commands/general/bing.rs +++ b/src/commands/general/bing.rs @@ -1,8 +1,10 @@ -use crate::{Context, Error}; +use crate::client::Context; + +use eyre::Result; /// Make sure the wie is alive #[poise::command(prefix_command)] -pub async fn bing(ctx: Context<'_>) -> Result<(), Error> { +pub async fn bing(ctx: Context<'_>) -> Result<()> { ctx.say("bong!").await?; Ok(()) } diff --git a/src/commands/general/config.rs b/src/commands/general/config.rs index 456e791..6adb78b 100644 --- a/src/commands/general/config.rs +++ b/src/commands/general/config.rs @@ -1,5 +1,5 @@ +use crate::client::Context; use crate::storage::settings::{Properties, Settings}; -use crate::{Context, Error}; use std::str::FromStr; @@ -41,7 +41,7 @@ fn prop_to_val(setting: &Properties, settings: &Settings) -> String { required_permissions = "MANAGE_GUILD", default_member_permissions = "MANAGE_GUILD" )] -pub async fn config(_: Context<'_>) -> Result<(), Error> { +pub async fn config(_: Context<'_>) -> Result<()> { Ok(()) } @@ -72,7 +72,7 @@ pub async fn set( #[description = "Toggle ReactBoard"] reactboard_enabled: Option, #[description = "Enables 'extra' commands like teawiespam and copypasta. Defaults to false."] optional_commands_enabled: Option, -) -> Result<(), Error> { +) -> Result<()> { if let Some(storage) = &ctx.data().storage { let gid = ctx.guild_id().unwrap_or_default(); let mut settings = storage.get_guild_settings(&gid).await?; @@ -149,7 +149,7 @@ pub async fn set( pub async fn get( ctx: Context<'_>, #[description = "The setting you want to get"] setting: Properties, -) -> Result<(), Error> { +) -> Result<()> { let gid = &ctx .guild_id() .ok_or_eyre("Failed to get GuildId from context!")?; diff --git a/src/commands/general/convert.rs b/src/commands/general/convert.rs index 4d38eb2..b5e7018 100644 --- a/src/commands/general/convert.rs +++ b/src/commands/general/convert.rs @@ -1,4 +1,4 @@ -use crate::{Context, Error}; +use crate::client::Context; use bottomify::bottom; use eyre::Result; @@ -9,7 +9,7 @@ use poise::serenity_prelude::constants::MESSAGE_CODE_LIMIT; slash_command, subcommands("to_fahrenheit", "to_celsius", "to_bottom", "from_bottom") )] -pub async fn convert(_: Context<'_>) -> Result<(), Error> { +pub async fn convert(_: Context<'_>) -> Result<()> { Ok(()) } @@ -18,7 +18,7 @@ pub async fn convert(_: Context<'_>) -> Result<(), Error> { pub async fn to_celsius( ctx: Context<'_>, #[description = "What teawie will convert"] degrees_fahrenheit: f32, -) -> Result<(), Error> { +) -> Result<()> { let temp = (degrees_fahrenheit - 32.0) * (5.0 / 9.0); ctx.say(temp.to_string()).await?; Ok(()) @@ -29,7 +29,7 @@ pub async fn to_celsius( pub async fn to_fahrenheit( ctx: Context<'_>, #[description = "What teawie will convert"] degrees_celsius: f32, -) -> Result<(), Error> { +) -> Result<()> { let temp = (degrees_celsius * (9.0 / 5.0)) + 32.0; ctx.say(temp.to_string()).await?; Ok(()) @@ -40,7 +40,7 @@ pub async fn to_fahrenheit( pub async fn to_bottom( ctx: Context<'_>, #[description = "What teawie will translate into bottom"] message: String, -) -> Result<(), Error> { +) -> Result<()> { let encoded = bottom::encode_string(&message); ctx.say(encoded).await?; Ok(()) @@ -51,7 +51,7 @@ pub async fn to_bottom( pub async fn from_bottom( ctx: Context<'_>, #[description = "What teawie will translate from bottom"] message: String, -) -> Result<(), Error> { +) -> Result<()> { let resp: String; if let Ok(decoded) = bottom::decode_string(&message.clone()) { diff --git a/src/commands/general/emoji.rs b/src/commands/general/emoji.rs index 81cd9a3..bbae0b5 100644 --- a/src/commands/general/emoji.rs +++ b/src/commands/general/emoji.rs @@ -1,5 +1,6 @@ -use crate::{consts::Colors, Context, Error}; +use crate::{client::Context, consts::Colors}; +use eyre::Result; use poise::{ serenity_prelude::{CreateEmbed, Emoji}, CreateReply, @@ -7,7 +8,7 @@ use poise::{ /// Get the URL for an emoji #[poise::command(slash_command)] -pub async fn emoji(ctx: Context<'_>, emoji: Emoji) -> Result<(), Error> { +pub async fn emoji(ctx: Context<'_>, emoji: Emoji) -> Result<()> { let url = emoji.url(); let embed = CreateEmbed::new() .title(emoji.name) diff --git a/src/commands/general/pfp.rs b/src/commands/general/pfp.rs index 2ad062b..34ae795 100644 --- a/src/commands/general/pfp.rs +++ b/src/commands/general/pfp.rs @@ -1,13 +1,14 @@ +use crate::{client::Context, consts::Colors}; + +use eyre::Result; use poise::{ serenity_prelude::{CreateEmbed, User}, CreateReply, }; -use crate::{consts::Colors, Context, Error}; - /// Get someone's profile pic #[poise::command(context_menu_command = "Get profile picture", slash_command)] -pub async fn pfp(ctx: Context<'_>, user: User) -> Result<(), Error> { +pub async fn pfp(ctx: Context<'_>, user: User) -> Result<()> { let url = user .avatar_url() .unwrap_or_else(|| user.default_avatar_url()); diff --git a/src/commands/general/random.rs b/src/commands/general/random.rs index 92e9188..094123b 100644 --- a/src/commands/general/random.rs +++ b/src/commands/general/random.rs @@ -1,14 +1,16 @@ -use crate::{api, consts, utils, Context, Error}; +use crate::{client::Context, consts, http, utils}; + +use eyre::Result; #[poise::command(slash_command, subcommands("lore", "teawie", "shiggy"))] #[allow(clippy::unused_async)] -pub async fn random(_: Context<'_>) -> Result<(), Error> { +pub async fn random(_: Context<'_>) -> Result<()> { Ok(()) } /// Get a random piece of teawie lore! #[poise::command(prefix_command, slash_command)] -pub async fn lore(ctx: Context<'_>) -> Result<(), Error> { +pub async fn lore(ctx: Context<'_>) -> Result<()> { let resp = utils::random_choice(consts::LORE)?; ctx.say(resp).await?; @@ -17,8 +19,8 @@ pub async fn lore(ctx: Context<'_>) -> Result<(), Error> { /// Get a random teawie #[poise::command(prefix_command, slash_command)] -pub async fn teawie(ctx: Context<'_>) -> Result<(), Error> { - let url = api::guzzle::random_teawie().await?; +pub async fn teawie(ctx: Context<'_>) -> Result<()> { + let url = http::teawie::random(&ctx.data().http_client).await?; utils::send_url_as_embed(ctx, url).await?; Ok(()) @@ -26,8 +28,8 @@ pub async fn teawie(ctx: Context<'_>) -> Result<(), Error> { /// Get a random shiggy #[poise::command(prefix_command, slash_command)] -pub async fn shiggy(ctx: Context<'_>) -> Result<(), Error> { - let url = api::shiggy::random_shiggy().await?; +pub async fn shiggy(ctx: Context<'_>) -> Result<()> { + let url = http::shiggy::random(&ctx.data().http_client).await?; utils::send_url_as_embed(ctx, url).await?; Ok(()) diff --git a/src/commands/general/version.rs b/src/commands/general/version.rs index 5f8eac9..bdf6805 100644 --- a/src/commands/general/version.rs +++ b/src/commands/general/version.rs @@ -1,12 +1,13 @@ -use crate::{consts::Colors, Context, Error}; +use crate::{client::Context, consts::Colors}; use std::env::consts::{ARCH, OS}; +use eyre::Result; use poise::{serenity_prelude::CreateEmbed, CreateReply}; /// Get version info #[poise::command(slash_command)] -pub async fn version(ctx: Context<'_>) -> Result<(), Error> { +pub async fn version(ctx: Context<'_>) -> Result<()> { let sha = option_env!("GIT_SHA").unwrap_or("main"); let revision_url = format!( "[{}]({}/tree/{})", diff --git a/src/commands/mod.rs b/src/commands/mod.rs index e8cac33..b8d0381 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,4 +1,4 @@ -use crate::{Data, Error}; +use crate::client::{Data, Error}; mod general; mod moderation; @@ -17,24 +17,13 @@ macro_rules! cmd { }; } -pub fn to_vec() -> Vec { - vec![ - cmd!(general, ask), - cmd!(general, bing), - cmd!(general, config), - cmd!(general, convert), - cmd!(general, emoji), - cmd!(general, pfp), - cmd!(general, random), - cmd!(general, version), - cmd!(moderation, clear_messages), - cmd!(optional, copypasta), - cmd!(optional, teawiespam), - cmd!(optional, uwurandom), - ] +pub fn all() -> Vec { + let mut all_commands = global(); + all_commands.append(&mut optional()); + all_commands } -pub fn to_vec_global() -> Vec { +pub fn global() -> Vec { vec![ cmd!(general, ask), cmd!(general, bing), @@ -48,10 +37,6 @@ pub fn to_vec_global() -> Vec { ] } -pub fn to_vec_optional() -> Vec { - vec![ - cmd!(optional, copypasta), - cmd!(optional, teawiespam), - cmd!(optional, uwurandom), - ] +pub fn optional() -> Vec { + vec![cmd!(optional, teawiespam), cmd!(optional, uwurandom)] } diff --git a/src/commands/moderation/clear_messages.rs b/src/commands/moderation/clear_messages.rs index 8761bcb..65a30be 100644 --- a/src/commands/moderation/clear_messages.rs +++ b/src/commands/moderation/clear_messages.rs @@ -1,5 +1,6 @@ -use crate::{Context, Error}; +use crate::client::Context; +use eyre::Result; use log::debug; use poise::serenity_prelude::GetMessages; @@ -13,7 +14,7 @@ use poise::serenity_prelude::GetMessages; pub async fn clear_messages( ctx: Context<'_>, #[description = "How many messages to delete"] num_messages: u8, -) -> Result<(), Error> { +) -> Result<()> { ctx.defer_ephemeral().await?; let channel = ctx.channel_id(); diff --git a/src/commands/optional/copypasta.rs b/src/commands/optional/copypasta.rs deleted file mode 100644 index 06440b1..0000000 --- a/src/commands/optional/copypasta.rs +++ /dev/null @@ -1,73 +0,0 @@ -use crate::{Context, Error}; - -use include_dir::{include_dir, Dir}; -use log::debug; - -const COPYPASTAS: Dir = include_dir!("src/copypastas"); - -#[derive(Debug, poise::ChoiceParameter)] -pub enum Copypasta { - Astral, - Dvd, - Egrill, - HappyMeal, - Sus, - TickTock, - Twitter, -} - -impl ToString for Copypasta { - fn to_string(&self) -> String { - let str = match self { - Self::Astral => "astral", - Self::Dvd => "dvd", - Self::Egrill => "egrill", - Self::HappyMeal => "happymeal", - Self::Sus => "sus", - Self::TickTock => "ticktock", - Self::Twitter => "twitter", - }; - str.to_string() - } -} - -impl Copypasta { - fn contents(&self) -> Option<&str> { - let file_name = format!("{}.txt", self.to_string()); - COPYPASTAS - .get_file(file_name) - .and_then(|file| file.contents_utf8()) - } -} - -/// ask teawie to send funni copypasta -#[poise::command(slash_command)] -pub async fn copypasta( - ctx: Context<'_>, - #[description = "the copypasta you want to send"] copypasta: Copypasta, -) -> Result<(), Error> { - if let Some(guild_id) = ctx.guild_id() { - if let Some(storage) = &ctx.data().storage { - let settings = storage.get_guild_settings(&guild_id).await?; - - if !settings.optional_commands_enabled { - debug!("Not running command in {guild_id} since it's disabled"); - ctx.reply("I'm not allowed to do that here").await?; - - return Ok(()); - } - } else { - debug!("Ignoring restrictions on command; no storage backend is attached!"); - } - } else { - debug!("Ignoring restrictions on command; we're not in a guild"); - } - - if let Some(contents) = copypasta.contents() { - ctx.say(contents).await?; - } else { - ctx.reply("I couldn't find that copypasta :(").await?; - } - - Ok(()) -} diff --git a/src/commands/optional/mod.rs b/src/commands/optional/mod.rs index 95c39bd..a3d1bd2 100644 --- a/src/commands/optional/mod.rs +++ b/src/commands/optional/mod.rs @@ -1,3 +1,2 @@ -pub mod copypasta; pub mod teawiespam; pub mod uwurandom; diff --git a/src/commands/optional/teawiespam.rs b/src/commands/optional/teawiespam.rs index 3a9a387..bfac852 100644 --- a/src/commands/optional/teawiespam.rs +++ b/src/commands/optional/teawiespam.rs @@ -1,10 +1,11 @@ -use crate::{Context, Error}; +use crate::client::Context; +use eyre::Result; use log::debug; /// teawie will spam you. #[poise::command(slash_command)] -pub async fn teawiespam(ctx: Context<'_>) -> Result<(), Error> { +pub async fn teawiespam(ctx: Context<'_>) -> Result<()> { if let Some(guild_id) = ctx.guild_id() { if let Some(storage) = &ctx.data().storage { let settings = storage.get_guild_settings(&guild_id).await?; diff --git a/src/commands/optional/uwurandom.rs b/src/commands/optional/uwurandom.rs index e717d5e..c952dee 100644 --- a/src/commands/optional/uwurandom.rs +++ b/src/commands/optional/uwurandom.rs @@ -1,4 +1,4 @@ -use crate::{Context, Error}; +use crate::client::Context; use eyre::Result; use log::debug; @@ -12,7 +12,7 @@ pub async fn uwurandom( #[min = 1] #[max = 2000] length: Option, -) -> Result<(), Error> { +) -> Result<()> { if let Some(guild_id) = ctx.guild_id() { if let Some(storage) = &ctx.data().storage { let settings = storage.get_guild_settings(&guild_id).await?; diff --git a/src/copypastas/astral.txt b/src/copypastas/astral.txt deleted file mode 100644 index 9984f31..0000000 --- a/src/copypastas/astral.txt +++ /dev/null @@ -1,7 +0,0 @@ -Today while astral projecting I summoned allah to try and weaken him so our hexing spells would work better. - -He is so fucking powerful. I’m not at a power level to do this alone. I barely escaped with my life and I’m spiritually injured to a great amount, but I think I’ll make it. - -I can’t imagine what he would do to a new, unsuspecting witch. I’m scared that I will have to face him again soon if I ever want to continue astral projecting. I’m currently burning healing incense and drawing spiritual energy from my crystals to try and heal as quickly as possible. - -Please be safe everyone. Allah is much stronger than I first imagined and we will have to do this together if we want to slay a god. diff --git a/src/copypastas/dvd.txt b/src/copypastas/dvd.txt deleted file mode 100644 index 16d396b..0000000 --- a/src/copypastas/dvd.txt +++ /dev/null @@ -1 +0,0 @@ -𝙏𝙝𝙞𝙨 𝘿𝙞𝙨𝙣𝙚𝙮 𝘿𝙑𝘿 𝙞𝙨 𝙚𝙣𝙝𝙖𝙣𝙘𝙚𝙙 𝙬𝙞𝙩𝙝 𝘿𝙞𝙨𝙣𝙚𝙮’𝙨 𝙁𝙖𝙨𝙩𝙋𝙡𝙖𝙮. 𝙔𝙤𝙪𝙧 𝙢𝙤𝙫𝙞𝙚 𝙖𝙣𝙙 𝙖 𝙨𝙚𝙡𝙚𝙘𝙩𝙞𝙤𝙣 𝙤𝙛 𝙗𝙤𝙣𝙪𝙨 𝙛𝙚𝙖𝙩𝙪𝙧𝙚𝙨 𝙬𝙞𝙡𝙡 𝙗𝙚𝙜𝙞𝙣 𝙖𝙪𝙩𝙤𝙢𝙖𝙩𝙞𝙘𝙖𝙡𝙡𝙮. 𝙏𝙤 𝙗𝙮𝙥𝙖𝙨𝙨 𝙁𝙖𝙨𝙩 𝙋𝙡𝙖𝙮, 𝙨𝙚𝙡𝙚𝙘𝙩 𝙩𝙝𝙚 𝙈𝙖𝙞𝙣 𝙈𝙚𝙣𝙪 𝙗𝙪𝙩𝙩𝙤𝙣 𝙖𝙩 𝙖𝙣𝙮 𝙩𝙞𝙢𝙚. 𝙁𝙖𝙨𝙩 𝙋𝙡𝙖𝙮 𝙬𝙞𝙡𝙡 𝙗𝙚𝙜𝙞𝙣 𝙞𝙣 𝙖 𝙢𝙤𝙢𝙚𝙣𝙩… diff --git a/src/copypastas/egrill.txt b/src/copypastas/egrill.txt deleted file mode 100644 index 02919b8..0000000 --- a/src/copypastas/egrill.txt +++ /dev/null @@ -1 +0,0 @@ -Everyone jokes about grilling, but they all forget about people like me. I have existed in a constant state of grilling since a BBQ in 1997. I have been attending this charcoal grill for twenty-two years. Do you genuinely think I can make time to argue about politics? Don't make me laugh. I have been flipping this exact burger for a third of my life. It's been decades why is it still raw. It's literally not cooking and I'm not allowed to leave. It's been over a fucking a flame for 22 years and it's still fucking frozen. What fucking vengeful god cursed me with this. help diff --git a/src/copypastas/happymeal.txt b/src/copypastas/happymeal.txt deleted file mode 100644 index bf52100..0000000 --- a/src/copypastas/happymeal.txt +++ /dev/null @@ -1 +0,0 @@ -OH MY GOD ITS 3 IN THE MORNING AND IM IN MCDONALDS AND WE JUST FOUND OUT THAT WHEN U PULL UP IN MCDONALDS AT 3 AM YOU CAN BUY THE AMONG US HAPPY MEAL WITH A TOY IN IT WHICH IS EITHER THE IMPOSTOR OR THE CREWMATE AND IF YOU DONT KNOW WHAT AMONG US IS YOU MUST BE MUST REALLY BE LIVING UNDER A ROCK ITS AN AWESOME GAME WITH IMPOSTORS AND CREWMATES AND BASICALLY THE IMPOSTOR TRIES TO SABOTAGE THE WHOLE GAME AND THE CREWMATES NEED TO STOP HIM BUT APPARENTLY WHEN YOU PURCHASE THE AMONG US HAPPY MEAL SOMETHING SCARY HAPPENS diff --git a/src/copypastas/sus.txt b/src/copypastas/sus.txt deleted file mode 100644 index 83deb72..0000000 --- a/src/copypastas/sus.txt +++ /dev/null @@ -1 +0,0 @@ -HOLY SHIT DID YOU JUST SAY THE WORD SUS???😳1?/1😱//1😳/1111!!!! Wait, you don't know what it is from?😳😳😳Let 👆give you a brief r/history. 📚📚📚👨‍🚀If you didn't r/knowyourshit, the r/term sus(suspicious) is a saying from the r/popular r/game r/AmongUs. Among us is so fun😔 👉👈, don't insult it, every youtuber and streamer says so!!!!!!!11 Corpses voice is so deep am i right or am i right😳😳????? I mean Mr beast and Dream play and pull big 🧠 1000000000000 iq moves in their videos..... YOU WERE THE IMPOSTER.... ඞ ඞ ඞ Get it because you don't know what sus means? r/stupidquestions r/youranidot r/stupidcuck. I CAnT BELEeVE YOUU dont KNoW WHT SUS MeaNS?/??!??!?!!🖕🖕🖕🖕🖕 Man why do i have to r/explain this to a r/idiot🤪🤪🤪📚📚📚... Sus is a GREAT WORD from a GREAT VIDEO GAME. in class, YOU CAN PLAY IT ON YOUR PHONE😜😜😜😜😜😜??!?!? such a masterpiece... FOR THE GREAT PRICE OF FREE!!!11!💰💰🤑🤑🤑🤑😜😜😜💰💰 It can also mean gay 😳😳😳😳 diff --git a/src/copypastas/ticktock.txt b/src/copypastas/ticktock.txt deleted file mode 100644 index bd4f36e..0000000 --- a/src/copypastas/ticktock.txt +++ /dev/null @@ -1,8 +0,0 @@ -Tick-tock -Heavy like a Brinks truck -Looking like I'm tip-top -Shining like a wristwatch -Time will grab your wrist -Lock it down 'til the thing pop -Can you stick around for a minute 'til the ring stop? -Please, God diff --git a/src/copypastas/twitter.txt b/src/copypastas/twitter.txt deleted file mode 100644 index 883cd1b..0000000 --- a/src/copypastas/twitter.txt +++ /dev/null @@ -1,35 +0,0 @@ -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm -Twitter's Recommendation Algorithm - diff --git a/src/events/error.rs b/src/events/error.rs new file mode 100644 index 0000000..d98bb5c --- /dev/null +++ b/src/events/error.rs @@ -0,0 +1,54 @@ +use crate::{ + client::{Data, Error}, + consts::Colors, +}; + +use log::error; +use poise::serenity_prelude::{CreateEmbed, Timestamp}; +use poise::{CreateReply, FrameworkError}; + +pub async fn handle(error: poise::FrameworkError<'_, Data, Error>) { + match error { + FrameworkError::Setup { + error, framework, .. + } => { + error!("Error setting up client! Bailing out"); + framework.shard_manager().shutdown_all().await; + + panic!("{error}") + } + + FrameworkError::Command { error, ctx, .. } => { + error!("Error in command {}:\n{error}", ctx.command().name); + + let embed = CreateEmbed::default() + .title("Something went wrong!") + .description("oopsie") + .timestamp(Timestamp::now()) + .color(Colors::Orange); + + let reply = CreateReply::default().embed(embed); + + ctx.send(reply).await.ok(); + } + + FrameworkError::EventHandler { + error, + ctx: _, + event, + framework: _, + .. + } => { + error!( + "Error while handling event {}:\n{error}", + event.snake_case_name() + ); + } + + error => { + if let Err(e) = poise::builtins::on_error(error).await { + error!("Unhandled error occurred:\n{e:#?}"); + } + } + } +} diff --git a/src/events/guild.rs b/src/events/guild.rs new file mode 100644 index 0000000..06af978 --- /dev/null +++ b/src/events/guild.rs @@ -0,0 +1,37 @@ +use crate::{client::Data, storage}; +use storage::settings::Settings; + +use eyre::Result; +use log::{debug, warn}; +use poise::serenity_prelude::{Guild, UnavailableGuild}; + +pub async fn handle_create(guild: &Guild, data: &Data) -> Result<()> { + if let Some(storage) = &data.storage { + if storage.guild_settings_exist(&guild.id).await? { + debug!("Not recreating settings key for {}", guild.id); + return Ok(()); + } + + let settings = Settings { + guild_id: guild.id, + ..Default::default() + }; + + warn!("Creating new settings key for {}:\n{settings:#?}", guild.id); + storage.create_guild_settings(settings).await?; + } else { + warn!("Can't create guild settings; no storage backend found!"); + } + + Ok(()) +} + +pub async fn handle_delete(guild: &UnavailableGuild, data: &Data) -> Result<()> { + if let Some(storage) = &data.storage { + storage.delete_guild_settings(&guild.id).await?; + } else { + warn!("Can't delete guild settings; no storage backend found!"); + } + + Ok(()) +} diff --git a/src/events/message.rs b/src/events/message.rs new file mode 100644 index 0000000..e115eb9 --- /dev/null +++ b/src/events/message.rs @@ -0,0 +1,44 @@ +use crate::{client::Data, consts}; + +use eyre::{eyre, Result}; +use log::{debug, warn}; +use poise::serenity_prelude::{Context, Message}; + +pub async fn handle(ctx: &Context, msg: &Message, data: &Data) -> Result<()> { + if should_echo(ctx, msg, data).await? { + msg.reply(ctx, &msg.content).await?; + } + + Ok(()) +} + +async fn should_echo(ctx: &Context, msg: &Message, data: &Data) -> Result { + if (msg.author.bot && msg.webhook_id.is_none()) || msg.is_own(ctx) { + debug!("Not repeating another bot"); + return Ok(false); + } + + let gid = msg + .guild_id + .ok_or_else(|| eyre!("Couldn't get GuildId from {}!", msg.id))?; + + if let Some(storage) = &data.storage { + let settings = storage.get_guild_settings(&gid).await?; + + if !settings.optional_commands_enabled { + debug!("Not echoing in guild {gid}"); + return Ok(false); + } + } else { + warn!("Ignoring restrictions on echoing messages; no storage backend is attached!"); + } + + let content = &msg.content; + + Ok(content == "🗿" + || consts::TEAMOJIS.contains(&content.as_str()) + || content.to_ascii_lowercase() == "moyai" + || content + .to_ascii_lowercase() + .contains("twitter's recommendation algorithm")) +} diff --git a/src/events/mod.rs b/src/events/mod.rs new file mode 100644 index 0000000..390c3a8 --- /dev/null +++ b/src/events/mod.rs @@ -0,0 +1,52 @@ +use crate::{client::Data, consts}; + +use eyre::Result; +use log::{debug, info}; +use poise::serenity_prelude::{self as serenity, CreateBotAuthParameters}; +use serenity::FullEvent; + +pub mod error; +mod guild; +mod message; +mod pinboard; +mod reactboard; + +pub async fn handle(ctx: &serenity::Context, event: &FullEvent, data: &Data) -> Result<()> { + match event { + FullEvent::Ready { data_about_bot } => { + info!("Logged in as {}!", data_about_bot.user.name); + + if let Ok(invite_link) = CreateBotAuthParameters::new().auto_client_id(ctx).await { + let link = invite_link + .scopes(consts::bot_scopes()) + .permissions(*consts::bot_permissions()) + .build(); + info!("Invite me to your server at {link}"); + } else { + debug!("Not displaying invite_link since we couldn't find our client ID"); + } + } + + FullEvent::Message { new_message } => { + message::handle(ctx, new_message, data).await?; + pinboard::handle(ctx, new_message, data).await?; + } + + FullEvent::ReactionAdd { add_reaction } => { + reactboard::handle(ctx, add_reaction, data).await?; + } + + FullEvent::GuildCreate { guild, is_new: _ } => { + guild::handle_create(guild, data).await?; + } + + FullEvent::GuildDelete { + incomplete, + full: _, + } => guild::handle_delete(incomplete, data).await?, + + _ => {} + } + + Ok(()) +} diff --git a/src/events/pinboard.rs b/src/events/pinboard.rs new file mode 100644 index 0000000..bb0dfe0 --- /dev/null +++ b/src/events/pinboard.rs @@ -0,0 +1,81 @@ +use crate::{client::Data, utils}; + +use eyre::{eyre, Context as _, OptionExt as _, Result}; +use log::{debug, warn}; +use poise::serenity_prelude::{ + ChannelId, Context, CreateAllowedMentions, CreateMessage, Message, MessageType, User, +}; + +pub async fn handle(ctx: &Context, message: &Message, data: &Data) -> Result<()> { + if message.kind != MessageType::PinsAdd { + return Ok(()); + } + + let gid = message.guild_id.unwrap_or_default(); + let Some(storage) = &data.storage else { + warn!("Can't create PinBoard entry; no storage backend found!"); + return Ok(()); + }; + + let settings = storage.get_guild_settings(&gid).await?; + + if !settings.pinboard_enabled { + debug!("PinBoard is disabled in {gid}, ignoring"); + return Ok(()); + } + + let Some(target) = settings.pinboard_channel else { + debug!("PinBoard is disabled in {gid}, ignoring"); + return Ok(()); + }; + + if let Some(sources) = settings.pinboard_watch { + if !sources.contains(&message.channel_id) { + debug!( + "{} not listed in PinBoard settings for {gid}, ignoring", + message.channel_id + ); + + return Ok(()); + } + } + + let reference_id = message + .clone() + .message_reference + .ok_or_eyre("Couldn't get referenced message of pin!")? + .message_id + .ok_or_eyre("Couldn't get id of referenced message of pin!")?; + + let pins = message + .channel_id + .pins(ctx) + .await + .wrap_err("Couldn't get a list of pins!?")?; + + let pin = pins + .iter() + .find(|pin| pin.id == reference_id) + .ok_or_else(|| eyre!("Couldn't find a pin for message {reference_id}!"))?; + + redirect(ctx, pin, &message.author, &target).await?; + pin.unpin(ctx).await?; + + Ok(()) +} + +async fn redirect(ctx: &Context, pin: &Message, pinner: &User, target: &ChannelId) -> Result<()> { + let embed = utils::resolve_message_to_embed(ctx, pin).await; + let mentions = CreateAllowedMentions::new().empty_roles().empty_users(); + let message = CreateMessage::default() + .allowed_mentions(mentions) + .content(format!("📌'd by {pinner} in {}", pin.link())) + .embed(embed); + + target + .send_message(&ctx.http, message) + .await + .wrap_err("Couldn't redirect message")?; + + Ok(()) +} diff --git a/src/events/reactboard.rs b/src/events/reactboard.rs new file mode 100644 index 0000000..c27bd80 --- /dev/null +++ b/src/events/reactboard.rs @@ -0,0 +1,143 @@ +use crate::{client::Data, storage, utils}; +use storage::reactboard::ReactBoardEntry; + +use eyre::{eyre, Context as _, Result}; +use log::{debug, warn}; +use poise::serenity_prelude::{ + Context, CreateMessage, EditMessage, GuildId, Message, MessageReaction, Reaction, +}; + +pub async fn handle(ctx: &Context, reaction: &Reaction, data: &Data) -> Result<()> { + // TODO @getchoo: don't do anything if this message is old + let msg = reaction + .message(&ctx.http) + .await + .wrap_err("Couldn't get reaction from message!")?; + + let matched = msg + .clone() + .reactions + .into_iter() + .find(|r| r.reaction_type == reaction.emoji) + .ok_or_else(|| { + eyre!( + "Couldn't find any matching reactions for {} in message {}!", + reaction.emoji.as_data(), + msg.id + ) + })?; + + send_to_reactboard( + ctx, + &matched, + &msg, + &reaction.guild_id.unwrap_or_default(), + data, + ) + .await?; + + Ok(()) +} + +async fn send_to_reactboard( + ctx: &Context, + reaction: &MessageReaction, + msg: &Message, + guild_id: &GuildId, + data: &Data, +) -> Result<()> { + let Some(storage) = &data.storage else { + warn!("Can't make ReactBoard entry; no storage backend found!"); + return Ok(()); + }; + + let settings = storage.get_guild_settings(guild_id).await?; + + // make sure everything is in order... + if !settings.reactboard_enabled { + debug!("ReactBoard is disabled in {guild_id}, ignoring"); + return Ok(()); + } + + let Some(target) = settings.reactboard_channel else { + debug!("ReactBoard is disabled in {guild_id}, ignoring"); + return Ok(()); + }; + + if !settings.can_use_reaction(&reaction.reaction_type) { + debug!("Reaction {} can't be used!", reaction.reaction_type); + return Ok(()); + } + + let count = if msg + .reaction_users(ctx, reaction.reaction_type.clone(), None, None) + .await? + .contains(&msg.author) + { + reaction.count - 1 + } else { + reaction.count + }; + + if count < settings.reactboard_requirement.unwrap_or(5) { + debug!( + "Ignoring message {} on ReactBoard, not enough reactions", + msg.id + ); + return Ok(()); + } + + let content = format!("{} **#{}**", reaction.reaction_type, count); + + let entry = if storage.reactboard_entry_exists(guild_id, &msg.id).await? { + // bump reaction count if previous entry exists + let mut entry = storage.get_reactboard_entry(guild_id, &msg.id).await?; + + // bail if we don't need to edit anything + if entry.reaction_count >= count { + debug!("Message {} doesn't need updating", msg.id); + return Ok(()); + } + + debug!( + "Bumping {} reaction count from {} to {}", + msg.id, entry.reaction_count, count + ); + + let edited = EditMessage::new().content(content); + + ctx.http + .get_message(entry.posted_channel_id, entry.posted_message_id) + .await + .wrap_err_with(|| { + format!( + "Couldn't get previous message from ReactBoardEntry {} in Redis DB!", + entry.original_message_id + ) + })? + .edit(ctx, edited) + .await?; + + // update reaction count in redis + entry.reaction_count = count; + entry + } else { + // make new message and add entry to redis otherwise + let embed = utils::resolve_message_to_embed(ctx, msg).await; + let message = CreateMessage::default().content(content).embed(embed); + + let resp = target.send_message(ctx, message).await?; + + ReactBoardEntry { + original_message_id: msg.id, + reaction_count: count, + posted_channel_id: resp.channel_id, + posted_message_id: resp.id, + } + }; + + debug!("Creating new ReactBoard entry:\n{entry:#?}"); + storage.create_reactboard_entry(guild_id, entry).await?; + + Ok(()) +} diff --git a/src/handlers/error.rs b/src/handlers/error.rs deleted file mode 100644 index e706fec..0000000 --- a/src/handlers/error.rs +++ /dev/null @@ -1,51 +0,0 @@ -use crate::{consts::Colors, Data, Error}; - -use log::error; -use poise::serenity_prelude::{CreateEmbed, Timestamp}; -use poise::{CreateReply, FrameworkError}; - -pub async fn handle(error: poise::FrameworkError<'_, Data, Error>) { - match error { - FrameworkError::Setup { - error, framework, .. - } => { - error!("Error setting up client! Bailing out"); - framework.shard_manager().shutdown_all().await; - - panic!("{error}") - } - - FrameworkError::Command { error, ctx, .. } => { - error!("Error in command {}:\n{error}", ctx.command().name); - - let embed = CreateEmbed::default() - .title("Something went wrong!") - .description("oopsie") - .timestamp(Timestamp::now()) - .color(Colors::Orange); - - let reply = CreateReply::default().embed(embed); - - ctx.send(reply).await.ok(); - } - - FrameworkError::EventHandler { - error, - ctx: _, - event, - framework: _, - .. - } => { - error!( - "Error while handling event {}:\n{error}", - event.snake_case_name() - ); - } - - error => { - if let Err(e) = poise::builtins::on_error(error).await { - error!("Unhandled error occurred:\n{e:#?}"); - } - } - } -} diff --git a/src/handlers/event/guild.rs b/src/handlers/event/guild.rs deleted file mode 100644 index 774179c..0000000 --- a/src/handlers/event/guild.rs +++ /dev/null @@ -1,37 +0,0 @@ -use eyre::Result; -use log::{debug, warn}; -use poise::serenity_prelude::{Guild, UnavailableGuild}; - -use crate::{storage, Data}; -use storage::settings::Settings; - -pub async fn handle_create(guild: &Guild, data: &Data) -> Result<()> { - if let Some(storage) = &data.storage { - if storage.guild_settings_exist(&guild.id).await? { - debug!("Not recreating settings key for {}", guild.id); - return Ok(()); - } - - let settings = Settings { - guild_id: guild.id, - ..Default::default() - }; - - warn!("Creating new settings key for {}:\n{settings:#?}", guild.id); - storage.create_guild_settings(settings).await?; - } else { - warn!("Can't create guild settings; no storage backend found!"); - } - - Ok(()) -} - -pub async fn handle_delete(guild: &UnavailableGuild, data: &Data) -> Result<()> { - if let Some(storage) = &data.storage { - storage.delete_guild_settings(&guild.id).await?; - } else { - warn!("Can't delete guild settings; no storage backend found!"); - } - - Ok(()) -} diff --git a/src/handlers/event/message.rs b/src/handlers/event/message.rs deleted file mode 100644 index 67dbb21..0000000 --- a/src/handlers/event/message.rs +++ /dev/null @@ -1,44 +0,0 @@ -use crate::{consts, Data}; - -use eyre::{eyre, Result}; -use log::{debug, warn}; -use poise::serenity_prelude::{Context, Message}; - -pub async fn handle(ctx: &Context, msg: &Message, data: &Data) -> Result<()> { - if should_echo(ctx, msg, data).await? { - msg.reply(ctx, &msg.content).await?; - } - - Ok(()) -} - -async fn should_echo(ctx: &Context, msg: &Message, data: &Data) -> Result { - if (msg.author.bot && msg.webhook_id.is_none()) || msg.is_own(ctx) { - debug!("Not repeating another bot"); - return Ok(false); - } - - let gid = msg - .guild_id - .ok_or_else(|| eyre!("Couldn't get GuildId from {}!", msg.id))?; - - if let Some(storage) = &data.storage { - let settings = storage.get_guild_settings(&gid).await?; - - if !settings.optional_commands_enabled { - debug!("Not echoing in guild {gid}"); - return Ok(false); - } - } else { - warn!("Ignoring restrictions on echoing messages; no storage backend is attached!"); - } - - let content = &msg.content; - - Ok(content == "🗿" - || consts::TEAMOJIS.contains(&content.as_str()) - || content.to_ascii_lowercase() == "moyai" - || content - .to_ascii_lowercase() - .contains("twitter's recommendation algorithm")) -} diff --git a/src/handlers/event/mod.rs b/src/handlers/event/mod.rs deleted file mode 100644 index cc7d727..0000000 --- a/src/handlers/event/mod.rs +++ /dev/null @@ -1,51 +0,0 @@ -use crate::{consts, Data, Error}; - -use eyre::Result; -use log::{debug, info}; -use poise::serenity_prelude::{self as serenity, CreateBotAuthParameters}; -use serenity::FullEvent; - -mod guild; -mod message; -mod pinboard; -mod reactboard; - -pub async fn handle(ctx: &serenity::Context, event: &FullEvent, data: &Data) -> Result<(), Error> { - match event { - FullEvent::Ready { data_about_bot } => { - info!("Logged in as {}!", data_about_bot.user.name); - - if let Ok(invite_link) = CreateBotAuthParameters::new().auto_client_id(ctx).await { - let link = invite_link - .scopes(consts::bot_scopes()) - .permissions(*consts::bot_permissions()) - .build(); - info!("Invite me to your server at {link}"); - } else { - debug!("Not displaying invite_link since we couldn't find our client ID"); - } - } - - FullEvent::Message { new_message } => { - message::handle(ctx, new_message, data).await?; - pinboard::handle(ctx, new_message, data).await?; - } - - FullEvent::ReactionAdd { add_reaction } => { - reactboard::handle(ctx, add_reaction, data).await?; - } - - FullEvent::GuildCreate { guild, is_new: _ } => { - guild::handle_create(guild, data).await?; - } - - FullEvent::GuildDelete { - incomplete, - full: _, - } => guild::handle_delete(incomplete, data).await?, - - _ => {} - } - - Ok(()) -} diff --git a/src/handlers/event/pinboard.rs b/src/handlers/event/pinboard.rs deleted file mode 100644 index 5b7d454..0000000 --- a/src/handlers/event/pinboard.rs +++ /dev/null @@ -1,81 +0,0 @@ -use crate::{utils, Data}; - -use eyre::{eyre, Context as _, OptionExt as _, Result}; -use log::{debug, warn}; -use poise::serenity_prelude::{ - ChannelId, Context, CreateAllowedMentions, CreateMessage, Message, MessageType, User, -}; - -pub async fn handle(ctx: &Context, message: &Message, data: &Data) -> Result<()> { - if message.kind != MessageType::PinsAdd { - return Ok(()); - } - - let gid = message.guild_id.unwrap_or_default(); - let Some(storage) = &data.storage else { - warn!("Can't create PinBoard entry; no storage backend found!"); - return Ok(()); - }; - - let settings = storage.get_guild_settings(&gid).await?; - - if !settings.pinboard_enabled { - debug!("PinBoard is disabled in {gid}, ignoring"); - return Ok(()); - } - - let Some(target) = settings.pinboard_channel else { - debug!("PinBoard is disabled in {gid}, ignoring"); - return Ok(()); - }; - - if let Some(sources) = settings.pinboard_watch { - if !sources.contains(&message.channel_id) { - debug!( - "{} not listed in PinBoard settings for {gid}, ignoring", - message.channel_id - ); - - return Ok(()); - } - } - - let reference_id = message - .clone() - .message_reference - .ok_or_eyre("Couldn't get referenced message of pin!")? - .message_id - .ok_or_eyre("Couldn't get id of referenced message of pin!")?; - - let pins = message - .channel_id - .pins(ctx) - .await - .wrap_err("Couldn't get a list of pins!?")?; - - let pin = pins - .iter() - .find(|pin| pin.id == reference_id) - .ok_or_else(|| eyre!("Couldn't find a pin for message {reference_id}!"))?; - - redirect(ctx, pin, &message.author, &target).await?; - pin.unpin(ctx).await?; - - Ok(()) -} - -async fn redirect(ctx: &Context, pin: &Message, pinner: &User, target: &ChannelId) -> Result<()> { - let embed = utils::resolve_message_to_embed(ctx, pin).await; - let mentions = CreateAllowedMentions::new().empty_roles().empty_users(); - let message = CreateMessage::default() - .allowed_mentions(mentions) - .content(format!("📌'd by {pinner} in {}", pin.link())) - .embed(embed); - - target - .send_message(&ctx.http, message) - .await - .wrap_err("Couldn't redirect message")?; - - Ok(()) -} diff --git a/src/handlers/event/reactboard.rs b/src/handlers/event/reactboard.rs deleted file mode 100644 index 75fc858..0000000 --- a/src/handlers/event/reactboard.rs +++ /dev/null @@ -1,143 +0,0 @@ -use crate::{storage, utils, Data}; -use storage::reactboard::ReactBoardEntry; - -use eyre::{eyre, Context as _, Result}; -use log::{debug, warn}; -use poise::serenity_prelude::{ - Context, CreateMessage, EditMessage, GuildId, Message, MessageReaction, Reaction, -}; - -pub async fn handle(ctx: &Context, reaction: &Reaction, data: &Data) -> Result<()> { - // TODO @getchoo: don't do anything if this message is old - let msg = reaction - .message(&ctx.http) - .await - .wrap_err("Couldn't get reaction from message!")?; - - let matched = msg - .clone() - .reactions - .into_iter() - .find(|r| r.reaction_type == reaction.emoji) - .ok_or_else(|| { - eyre!( - "Couldn't find any matching reactions for {} in message {}!", - reaction.emoji.as_data(), - msg.id - ) - })?; - - send_to_reactboard( - ctx, - &matched, - &msg, - &reaction.guild_id.unwrap_or_default(), - data, - ) - .await?; - - Ok(()) -} - -async fn send_to_reactboard( - ctx: &Context, - reaction: &MessageReaction, - msg: &Message, - guild_id: &GuildId, - data: &Data, -) -> Result<()> { - let Some(storage) = &data.storage else { - warn!("Can't make ReactBoard entry; no storage backend found!"); - return Ok(()); - }; - - let settings = storage.get_guild_settings(guild_id).await?; - - // make sure everything is in order... - if !settings.reactboard_enabled { - debug!("ReactBoard is disabled in {guild_id}, ignoring"); - return Ok(()); - } - - let Some(target) = settings.reactboard_channel else { - debug!("ReactBoard is disabled in {guild_id}, ignoring"); - return Ok(()); - }; - - if !settings.can_use_reaction(&reaction.reaction_type) { - debug!("Reaction {} can't be used!", reaction.reaction_type); - return Ok(()); - } - - let count = if msg - .reaction_users(ctx, reaction.reaction_type.clone(), None, None) - .await? - .contains(&msg.author) - { - reaction.count - 1 - } else { - reaction.count - }; - - if count < settings.reactboard_requirement.unwrap_or(5) { - debug!( - "Ignoring message {} on ReactBoard, not enough reactions", - msg.id - ); - return Ok(()); - } - - let content = format!("{} **#{}**", reaction.reaction_type, count); - - let entry = if storage.reactboard_entry_exists(guild_id, &msg.id).await? { - // bump reaction count if previous entry exists - let mut entry = storage.get_reactboard_entry(guild_id, &msg.id).await?; - - // bail if we don't need to edit anything - if entry.reaction_count >= count { - debug!("Message {} doesn't need updating", msg.id); - return Ok(()); - } - - debug!( - "Bumping {} reaction count from {} to {}", - msg.id, entry.reaction_count, count - ); - - let edited = EditMessage::new().content(content); - - ctx.http - .get_message(entry.posted_channel_id, entry.posted_message_id) - .await - .wrap_err_with(|| { - format!( - "Couldn't get previous message from ReactBoardEntry {} in Redis DB!", - entry.original_message_id - ) - })? - .edit(ctx, edited) - .await?; - - // update reaction count in redis - entry.reaction_count = count; - entry - } else { - // make new message and add entry to redis otherwise - let embed = utils::resolve_message_to_embed(ctx, msg).await; - let message = CreateMessage::default().content(content).embed(embed); - - let resp = target.send_message(ctx, message).await?; - - ReactBoardEntry { - original_message_id: msg.id, - reaction_count: count, - posted_channel_id: resp.channel_id, - posted_message_id: resp.id, - } - }; - - debug!("Creating new ReactBoard entry:\n{entry:#?}"); - storage.create_reactboard_entry(guild_id, entry).await?; - - Ok(()) -} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs deleted file mode 100644 index 1610d23..0000000 --- a/src/handlers/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod error; -pub mod event; diff --git a/src/http/mod.rs b/src/http/mod.rs new file mode 100644 index 0000000..0f16852 --- /dev/null +++ b/src/http/mod.rs @@ -0,0 +1,43 @@ +use eyre::Result; +use log::trace; +use serde::de::DeserializeOwned; + +pub mod shiggy; +pub mod teawie; + +pub type Client = reqwest::Client; +pub type Response = reqwest::Response; + +/// Primary extensions for HTTP Client +pub trait Ext { + async fn get_request(&self, url: &str) -> Result; + async fn get_json(&self, url: &str) -> Result; + fn default() -> Self; +} + +impl Ext for Client { + fn default() -> Self { + reqwest::ClientBuilder::new() + .user_agent(format!( + "teawie-bot/{}", + option_env!("CARGO_PKG_VERSION").unwrap_or("development") + )) + .build() + .unwrap() + } + + async fn get_request(&self, url: &str) -> Result { + trace!("Making request to {url}"); + let resp = self.get(url).send().await?; + resp.error_for_status_ref()?; + + Ok(resp) + } + + async fn get_json(&self, url: &str) -> Result { + let resp = self.get_request(url).await?; + let json = resp.json().await?; + + Ok(json) + } +} diff --git a/src/http/shiggy.rs b/src/http/shiggy.rs new file mode 100644 index 0000000..397d397 --- /dev/null +++ b/src/http/shiggy.rs @@ -0,0 +1,20 @@ +use eyre::Result; +use serde::Deserialize; + +const SHIGGY: &str = "https://safebooru.donmai.us"; +const RANDOM: &str = "/posts/random.json?tags=kemomimi-chan_(naga_u)+naga_u&only=file_url"; + +#[derive(Deserialize)] +struct SafebooruResponse { + file_url: String, +} + +pub async fn random(http: &T) -> Result +where + T: super::Ext, +{ + let url = format!("{SHIGGY}{RANDOM}"); + let resp: SafebooruResponse = http.get_json(&url).await?; + + Ok(resp.file_url) +} diff --git a/src/http/teawie.rs b/src/http/teawie.rs new file mode 100644 index 0000000..368fad5 --- /dev/null +++ b/src/http/teawie.rs @@ -0,0 +1,28 @@ +use eyre::{bail, OptionExt, Result}; +use serde::{Deserialize, Serialize}; + +// https://github.com/getchoo/teawieAPI +#[derive(Deserialize, Serialize)] +struct RandomTeawieResponse { + url: Option, + error: Option, +} + +// TODO: read this from an env var +const TEAWIE: &str = "https://api.getchoo.com"; +const RANDOM: &str = "/random_teawie"; + +pub async fn random(http: &T) -> Result +where + T: super::Ext, +{ + let url = format!("{TEAWIE}{RANDOM}"); + let json: RandomTeawieResponse = http.get_json(&url).await?; + + if let Some(error) = json.error { + bail!("TeawieAPI reported error: {error}"); + }; + + json.url + .ok_or_eyre("TeawieAPI didn't return an error or URL???") +} diff --git a/src/main.rs b/src/main.rs index 7f19b9e..e91c8e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,72 +1,18 @@ -use std::{sync::Arc, time::Duration}; - -use eyre::{Context as _, Report, Result}; -use log::{info, trace, warn}; -use poise::{ - serenity_prelude::{self as serenity}, - EditTracker, Framework, FrameworkOptions, PrefixFrameworkOptions, -}; -use tokio::signal::ctrl_c; -#[cfg(target_family = "unix")] -use tokio::signal::unix::{signal, SignalKind}; -#[cfg(target_family = "windows")] -use tokio::signal::windows::ctrl_close; - -mod api; +mod client; mod commands; mod consts; -mod handlers; +mod events; +mod http; mod storage; mod utils; -use storage::Storage; - -type Error = Box; -type Context<'a> = poise::Context<'a, Data, Error>; - -#[derive(Clone, Debug, Default)] -pub struct Data { - storage: Option, -} - -async fn setup(ctx: &serenity::Context) -> Result { - let storage = Storage::from_env().ok(); - - if let Some(storage) = storage.as_ref() { - if !storage.clone().is_connected() { - return Err( - "You specified a storage backend but there's no connection! Is it running?".into(), - ); - } - trace!("Storage backend connected!"); - - poise::builtins::register_globally(ctx, &commands::to_vec_global()).await?; - info!("Registered global commands!"); - - // register "extra" commands in guilds that allow it - let guilds = storage.get_opted_guilds().await?; - - for guild in guilds { - poise::builtins::register_in_guild(ctx, &commands::to_vec_optional(), guild).await?; - - info!("Registered guild commands to {}", guild); - } - } else { - warn!("No storage backend was specified. Features requiring storage will be disabled"); - warn!("Registering optional commands globally since there's no storage backend"); - poise::builtins::register_globally(ctx, &commands::to_vec()).await?; - } - - let data = Data { storage }; - - Ok(data) -} +use eyre::{Report, Result}; -async fn handle_shutdown(shard_manager: Arc, reason: &str) { - warn!("{reason}! Shutting down bot..."); - shard_manager.shutdown_all().await; - println!("Everything is shutdown. Goodbye!"); -} +use tokio::signal::ctrl_c; +#[cfg(target_family = "unix")] +use tokio::signal::unix::{signal, SignalKind}; +#[cfg(target_family = "windows")] +use tokio::signal::windows::ctrl_close; #[tokio::main] async fn main() -> Result<()> { @@ -74,44 +20,9 @@ async fn main() -> Result<()> { color_eyre::install()?; env_logger::init(); - let token = std::env::var("TOKEN").wrap_err("Couldn't find bot token in environment!")?; - - let intents = - serenity::GatewayIntents::non_privileged() | serenity::GatewayIntents::MESSAGE_CONTENT; - - let options = FrameworkOptions { - commands: commands::to_vec(), - on_error: |error| Box::pin(handlers::error::handle(error)), - - command_check: Some(|ctx| { - Box::pin(async move { Ok(ctx.author().id != ctx.framework().bot_id) }) - }), - - event_handler: |ctx, event, _framework, data| { - Box::pin(handlers::event::handle(ctx, event, data)) - }, - - prefix_options: PrefixFrameworkOptions { - prefix: Some("!".into()), - edit_tracker: Some(Arc::new(EditTracker::for_timespan(Duration::from_secs( - 3600, - )))), - ..Default::default() - }, - - ..Default::default() - }; - - let framework = Framework::builder() - .options(options) - .setup(|ctx, _ready, _framework| Box::pin(setup(ctx))) - .build(); - - let mut client = serenity::ClientBuilder::new(token, intents) - .framework(framework) - .await?; + let mut client = client::get().await?; - let shard_manager = client.shard_manager.clone(); + let shard_manager = client.shard_manager.clone(); // We need this to shut down the bot #[cfg(target_family = "unix")] let mut sigterm = signal(SignalKind::terminate())?; #[cfg(target_family = "windows")] @@ -120,11 +31,13 @@ async fn main() -> Result<()> { tokio::select! { result = client.start() => result.map_err(Report::from), _ = sigterm.recv() => { - handle_shutdown(shard_manager, "Received SIGTERM").await; + client::handle_shutdown(shard_manager, "Received SIGTERM").await; + println!("Everything is shutdown. Goodbye!"); std::process::exit(0); }, _ = ctrl_c() => { - handle_shutdown(shard_manager, "Interrupted").await; + client::handle_shutdown(shard_manager, "Interrupted").await; + println!("Everything is shutdown. Goodbye!"); std::process::exit(130); } } diff --git a/src/utils.rs b/src/utils.rs index 9b642a7..3cab8c3 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,4 +1,4 @@ -use crate::{consts::Colors, Context}; +use crate::{client::Context, consts::Colors}; use color_eyre::eyre::{eyre, Result}; use poise::serenity_prelude::{self as serenity, CreateEmbedAuthor, CreateEmbedFooter}; -- cgit v1.2.3