From 7cd74f95a22945b988f06bc3593ad9d22f76c4bc Mon Sep 17 00:00:00 2001 From: Daniel Flanagan Date: Mon, 5 Aug 2024 19:52:50 -0500 Subject: [PATCH] Breakin' out modules --- Cargo.lock | 14 +- Cargo.toml | 4 + apps/yourcloud/Cargo.toml | 4 +- apps/yourcloud/src/chatbot.rs | 9 +- apps/yourcloud/src/main.rs | 11 +- libs/discord_bot/Cargo.toml | 10 ++ libs/discord_bot/src/lib.rs | 268 ++++++++++++++++++++++++++++++++++ libs/http_client/Cargo.toml | 6 +- 8 files changed, 311 insertions(+), 15 deletions(-) create mode 100644 libs/discord_bot/Cargo.toml create mode 100644 libs/discord_bot/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index d8ddb33..df4c81f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -464,6 +464,16 @@ dependencies = [ "websocket", ] +[[package]] +name = "discord_bot" +version = "1.0.0" +dependencies = [ + "discord", + "thiserror", + "tokio", + "tracing", +] + [[package]] name = "dlv-list" version = "0.5.2" @@ -830,7 +840,7 @@ dependencies = [ [[package]] name = "http_client" -version = "0.1.0" +version = "1.0.0" dependencies = [ "reqwest 0.11.27", "reqwest-middleware 0.2.5", @@ -3228,7 +3238,7 @@ dependencies = [ "axum", "color-eyre", "config", - "discord", + "discord_bot", "http_client", "redact", "reqwest 0.12.5", diff --git a/Cargo.toml b/Cargo.toml index 10fcf18..6bba8b3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,11 @@ resolver = "2" members = ["apps/yourcloud"] [workspace.dependencies] +tokio = { version = "1.37.0", features = ["macros", "rt-multi-thread"] } http_client = { path = "libs/http_client" } +discord_bot = { path = "libs/discord_bot" } +thiserror = "1.0.63" +tracing = "0.1.40" [profile.dev.package.backtrace] opt-level = 3 diff --git a/apps/yourcloud/Cargo.toml b/apps/yourcloud/Cargo.toml index cff44fe..3b72882 100644 --- a/apps/yourcloud/Cargo.toml +++ b/apps/yourcloud/Cargo.toml @@ -7,7 +7,6 @@ edition = "2021" axum = "0.7.5" color-eyre = "0.6.3" config = "0.14.0" -discord = { git = "https://github.com/SpaceManiac/discord-rs" } redact = { version = "0.1.9", features = ["serde"] } reqwest = { version = "0.12.3", features = ["json", "socks"] } reqwest-middleware = "0.3.0" @@ -16,11 +15,12 @@ reqwest-tracing = "0.5.0" serde = { version = "1.0.197", features = ["derive"] } serde_json = "1.0.115" serde_with = "3.7.0" -tokio = { version = "1.37.0", features = ["macros", "rt-multi-thread"] } +tokio = { workspace = true } tracing = "0.1.40" tracing-subscriber = { version = "0.3.18", features = ["env-filter"] } urlencoding = "2.1.3" http_client = { workspace = true } +discord_bot = { workspace = true } [profile.dev] opt-level = 1 diff --git a/apps/yourcloud/src/chatbot.rs b/apps/yourcloud/src/chatbot.rs index bd43ede..392250a 100644 --- a/apps/yourcloud/src/chatbot.rs +++ b/apps/yourcloud/src/chatbot.rs @@ -1,11 +1,10 @@ // TODO: family reminders? // TODO: connect to Discord -// TODO: handle messages -// TODO: data persistence? (sqlite? sled?) - -use std::{any::Any, future}; +// TODO: data persistence? (sqlite? sled? postgres?) +use crate::prelude::*; use discord::model::{ChannelId, Event, Message, ReadyEvent}; +use std::{any::Any, future}; use tokio::sync::Mutex; const COMMAND_PREFIX: &str = "!"; @@ -249,8 +248,6 @@ impl Discord { } } -use crate::prelude::*; - pub async fn start(conf: Arc) -> Result<()> { if conf.discord.is_none() { warn!("Chatbot starting without Discord token. Nothing will happen."); diff --git a/apps/yourcloud/src/main.rs b/apps/yourcloud/src/main.rs index daf6a13..5c9d39f 100644 --- a/apps/yourcloud/src/main.rs +++ b/apps/yourcloud/src/main.rs @@ -1,6 +1,5 @@ #![forbid(unsafe_code)] -mod chatbot; mod config; mod minecraft_server_status; mod observe; @@ -24,7 +23,15 @@ async fn main() -> Result<()> { let mut set = tokio::task::JoinSet::new(); set.spawn(webserver::start(conf.clone())); - set.spawn(chatbot::start(conf.clone())); + + if let Some(bot_token) = conf + .clone() + .discord + .clone() + .map(|d| d.bot_token.expose_secret().clone()) + { + set.spawn(async move { discord_bot::start(&bot_token).await.map_err(|e| e.into()) }); + } let result = set.join_next().await; match result { diff --git a/libs/discord_bot/Cargo.toml b/libs/discord_bot/Cargo.toml new file mode 100644 index 0000000..9778b24 --- /dev/null +++ b/libs/discord_bot/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "discord_bot" +version = "1.0.0" +edition = "2021" + +[dependencies] +discord = { git = "https://github.com/SpaceManiac/discord-rs" } +thiserror = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } diff --git a/libs/discord_bot/src/lib.rs b/libs/discord_bot/src/lib.rs new file mode 100644 index 0000000..5836f91 --- /dev/null +++ b/libs/discord_bot/src/lib.rs @@ -0,0 +1,268 @@ +// TODO: family reminders? +// TODO: connect to Discord +// TODO: data persistence? (sqlite? sled? postgres?) + +pub use discord; +use discord::model::{ChannelId, Event, Message, ReadyEvent}; +use std::{any::Any, sync::Arc}; +use tokio::sync::Mutex; +use tracing::{error, info, instrument, trace, warn}; + +const COMMAND_PREFIX: &str = "!"; + +struct Discord { + discord: discord::Discord, + connection: Option>>, + current_user: Option, +} + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("discord error: {0}")] + Discord(#[from] discord::Error), + #[error("already connected")] + AlreadyConnected, +} + +pub type Result = std::result::Result; + +impl Discord { + pub fn try_new(bot_token: &str) -> Result { + let discord = discord::Discord::from_bot_token(bot_token)?; + Ok(Self { + discord, + connection: None, + current_user: None, + }) + } + + pub async fn connect(&mut self) -> Result { + if self.connection.is_some() { + return Err(Error::AlreadyConnected); + } + let (connection, ready_ev) = self.discord.connect()?; + self.connection = Some(Arc::new(Mutex::new(connection))); + self.current_user = self.discord.get_current_user().ok(); + info!("Discord current user: {:?}", self.current_user); + Ok(ready_ev) + } + + pub async fn handle_commands(self: Arc) -> Result<()> { + if let Some(conn) = self.connection.clone() { + loop { + // TODO: I'm suspicious this will just always be locked here and + // I'm doing something stupid. + let mut conn = conn.lock().await; + while let Ok(event) = conn.recv_event() { + let rself = self.clone(); + tokio::spawn(async move { + match rself.handle_event(event).await { + Ok(_) => {} + Err(err) => { + error!("Failed to handle Discord event: {err}") + } + } + }); + } + } + } + + Ok(()) + } + + #[instrument(skip(self))] + pub async fn handle_event(&self, event: Event) -> Result<()> { + match event { + Event::Ready(_) => return Ok(self.ignore_event(event)), + Event::Resumed { trace: _ } => return Ok(self.ignore_event(event)), + Event::UserUpdate(_) => return Ok(self.ignore_event(event)), + Event::UserNoteUpdate(_, _) => return Ok(self.ignore_event(event)), + Event::UserSettingsUpdate { + detect_platform_accounts: _, + developer_mode: _, + enable_tts_command: _, + inline_attachment_media: _, + inline_embed_media: _, + locale: _, + message_display_compact: _, + render_embeds: _, + server_positions: _, + show_current_game: _, + status: _, + theme: _, + convert_emoticons: _, + friend_source_flags: _, + } => return Ok(self.ignore_event(event)), + Event::UserServerSettingsUpdate(_) => return Ok(self.ignore_event(event)), + Event::VoiceStateUpdate(_, _) => return Ok(self.ignore_event(event)), + Event::VoiceServerUpdate { + server_id: _, + channel_id: _, + endpoint: _, + token: _, + } => return Ok(self.ignore_event(event)), + Event::CallCreate(_) => return Ok(self.ignore_event(event)), + Event::CallUpdate { + channel_id: _, + message_id: _, + region: _, + ringing: _, + } => return Ok(self.ignore_event(event)), + Event::CallDelete(_) => return Ok(self.ignore_event(event)), + Event::ChannelRecipientAdd(_, _) => return Ok(self.ignore_event(event)), + Event::ChannelRecipientRemove(_, _) => return Ok(self.ignore_event(event)), + Event::TypingStart { + channel_id: _, + user_id: _, + timestamp: _, + } => return Ok(self.ignore_event(event)), + Event::PresenceUpdate { + presence: _, + server_id: _, + roles: _, + } => return Ok(self.ignore_event(event)), + Event::PresencesReplace(_) => return Ok(self.ignore_event(event)), + Event::RelationshipAdd(_) => return Ok(self.ignore_event(event)), + Event::RelationshipRemove(_, _) => return Ok(self.ignore_event(event)), + Event::MessageCreate(msg) => return Ok(self.handle_message(msg)?), + Event::MessageUpdate { + id: _, + channel_id: _, + kind: _, + content: _, + nonce: _, + tts: _, + pinned: _, + timestamp: _, + edited_timestamp: _, + author: _, + mention_everyone: _, + mentions: _, + mention_roles: _, + attachments: _, + embeds: _, + } => return Ok(self.ignore_event(event)), + Event::MessageAck { + channel_id: _, + message_id: _, + } => return Ok(self.ignore_event(event)), + Event::MessageDelete { + channel_id: _, + message_id: _, + } => return Ok(self.ignore_event(event)), + Event::MessageDeleteBulk { + channel_id: _, + ids: _, + } => return Ok(self.ignore_event(event)), + Event::ServerCreate(_) => return Ok(self.ignore_event(event)), + Event::ServerUpdate(_) => return Ok(self.ignore_event(event)), + Event::ServerDelete(_) => return Ok(self.ignore_event(event)), + Event::ServerMemberAdd(_, _) => return Ok(self.ignore_event(event)), + Event::ServerMemberUpdate { + server_id: _, + roles: _, + user: _, + nick: _, + } => return Ok(self.ignore_event(event)), + Event::ServerMemberRemove(_, _) => return Ok(self.ignore_event(event)), + Event::ServerMembersChunk(_, _) => return Ok(self.ignore_event(event)), + Event::ServerSync { + server_id: _, + large: _, + members: _, + presences: _, + } => return Ok(self.ignore_event(event)), + Event::ServerRoleCreate(_, _) => return Ok(self.ignore_event(event)), + Event::ServerRoleUpdate(_, _) => return Ok(self.ignore_event(event)), + Event::ServerRoleDelete(_, _) => return Ok(self.ignore_event(event)), + Event::ServerBanAdd(_, _) => return Ok(self.ignore_event(event)), + Event::ServerBanRemove(_, _) => return Ok(self.ignore_event(event)), + Event::ServerIntegrationsUpdate(_) => return Ok(self.ignore_event(event)), + Event::ServerEmojisUpdate(_, _) => return Ok(self.ignore_event(event)), + Event::ChannelCreate(_) => return Ok(self.ignore_event(event)), + Event::ChannelUpdate(_) => return Ok(self.ignore_event(event)), + Event::ChannelDelete(_) => return Ok(self.ignore_event(event)), + Event::ChannelPinsAck { + channel_id: _, + timestamp: _, + } => return Ok(self.ignore_event(event)), + Event::ChannelPinsUpdate { + channel_id: _, + last_pin_timestamp: _, + } => return Ok(self.ignore_event(event)), + Event::ReactionAdd(_) => return Ok(self.ignore_event(event)), + Event::ReactionRemove(_) => return Ok(self.ignore_event(event)), + Event::Unknown(_, _) => return Ok(self.ignore_event(event)), + _ => return Ok(self.ignore_event(event)), + } + } + + pub fn ignore_event(&self, event: Event) -> () { + let event_type_id = event.type_id(); + info!("Ignoring Discord event of type: {event_type_id:?}") + } + + #[instrument(skip(self))] + pub fn handle_message(&self, msg: Message) -> Result<()> { + if let Some(current_user) = &self.current_user { + if msg.author.id == current_user.id { + trace!("Ignoring message from self"); + } + return Ok(()); + } + if msg.author.bot { + info!( + "Ignoring bot message in channel {} with content: {}", + msg.channel_id, msg.content + ); + return Ok(()); + } + + info!( + "Recieved Discord message in channel {} with content: {}", + msg.channel_id, msg.content + ); + + let text = msg.content.clone(); + let trimmed_text = text.trim(); + if trimmed_text.starts_with(COMMAND_PREFIX) { + self.handle_text_command(trimmed_text, msg)? + } + + Ok(()) + } + + #[instrument(skip(self))] + fn send_message(&self, channel: ChannelId, text: &str) -> Result { + self.discord + .send_message(channel, text, "", false) + .map_err(|e| e.into()) + } + + #[instrument(skip(self))] + fn handle_text_command(&self, trimmed_text: &str, msg: Message) -> Result<()> { + let trimmed_text = trimmed_text.trim_start_matches(COMMAND_PREFIX); + match trimmed_text { + s if s.starts_with("ping") => { + let _ = self.send_message(msg.channel_id, "`pong`")?; + } + s if s.starts_with("dm-me") => { + let dm = self.discord.create_dm(msg.author.id)?; + self.send_message( + dm.id, + format!("DM'ing you as requested via `{}dm-me", COMMAND_PREFIX).as_str(), + )?; + } + _ => {} + } + Ok(()) + } +} + +pub async fn start(bot_token: &str) -> Result<()> { + let mut discord = Discord::try_new(bot_token)?; + let ready_ev = discord.connect().await; + info!("Discord connection ready: {ready_ev:?}"); + let adiscord = Arc::new(discord); + adiscord.handle_commands().await +} diff --git a/libs/http_client/Cargo.toml b/libs/http_client/Cargo.toml index 8bd37cb..cbd8115 100644 --- a/libs/http_client/Cargo.toml +++ b/libs/http_client/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "http_client" -version = "0.1.0" +version = "1.0.0" edition = "2021" [dependencies] @@ -10,6 +10,6 @@ reqwest-retry = "0.4.0" reqwest-tracing = "0.4.8" serde = { version = "1.0.197", features = ["derive"] } serde_json = "1.0.114" -thiserror = "1.0.63" -tracing = "0.1.40" +thiserror = { workspace = true } +tracing = { workspace = true } url = "2.5.2"