From 9fa6a88d883ae1f4ac184b75a79a0d05338d3dfd Mon Sep 17 00:00:00 2001 From: "Kszinhu@POP_os" Date: Mon, 14 Aug 2023 20:39:23 -0300 Subject: [PATCH 01/17] ghost commit - backup --- Cargo.toml | 1 + README.md | 12 +- public/locales/en-US.yml | 17 ++ public/locales/pt-BR.yml | 17 ++ src/commands/jingle.rs | 28 ++- src/commands/language.rs | 54 ++++- src/commands/mod.rs | 237 +++++++++++++++++++- src/commands/ping.rs | 38 +++- src/commands/poll/database.rs | 76 +++++++ src/commands/poll/help.rs | 157 ++++++++++++++ src/commands/poll/management/mod.rs | 0 src/commands/poll/mod.rs | 324 ++++++++++++++++++++++++++++ src/commands/poll/setup/create.rs | 34 +++ src/commands/poll/setup/mod.rs | 23 ++ src/commands/poll/setup/options.rs | 43 ++++ src/commands/poll/utils/mod.rs | 53 +++++ src/commands/radio/mod.rs | 60 ++++++ src/commands/voice/join.rs | 56 ++++- src/commands/voice/leave.rs | 61 +++++- src/commands/voice/mod.rs | 2 +- src/commands/voice/mute.rs | 79 +++++-- src/components/button.rs | 46 ++++ src/components/mod.rs | 1 + src/database/locale.rs | 25 ++- src/database/mod.rs | 110 +++++++++- src/integrations/jukera.rs | 25 ++- src/internal/constants.rs | 7 + src/lib.rs | 3 +- src/main.rs | 169 +++++++++------ 29 files changed, 1590 insertions(+), 168 deletions(-) create mode 100644 src/commands/poll/database.rs create mode 100644 src/commands/poll/help.rs create mode 100644 src/commands/poll/management/mod.rs create mode 100644 src/commands/poll/mod.rs create mode 100644 src/commands/poll/setup/create.rs create mode 100644 src/commands/poll/setup/mod.rs create mode 100644 src/commands/poll/setup/options.rs create mode 100644 src/commands/poll/utils/mod.rs create mode 100644 src/components/button.rs create mode 100644 src/components/mod.rs diff --git a/Cargo.toml b/Cargo.toml index 7e157b9..1e74546 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,3 +27,4 @@ once_cell = { version = "1.18.0", features = ["std"] } rust-i18n = "2.0.0" colored = "2.0.4" yaml-rust = "0.4.5" +uuid = { version = "^1.4.1", features = ["v4", "fast-rng"] } diff --git a/README.md b/README.md index 56bc3e2..3bb7504 100644 --- a/README.md +++ b/README.md @@ -53,18 +53,20 @@ Created for fun and to learn more about the Rust language. ## Roadmap -### Moderação +### Moderation - [ ] Clear messages - [ ] Ban - [ ] Kick -### Utilidades +### Utils -- [ ] Create a poll +- [-] Create a poll + - [x] Create a poll with buttons + - [ ] With timer - [ ] Welcome message -### Diversão +### Fun - [ ] Detect user activities to send messages - [-] Play music @@ -75,7 +77,7 @@ Created for fun and to learn more about the Rust language. - [x] Radio [88.3 FM][perderneiras-fm-url] - [ ] Add noise to the audio (like a radio) -### Integrações +### Integrations - [x] [Jukes Box](https://discord.com/api/oauth2/authorize?client_id=716828755003310091&permissions=3271680&scope=applications.commands%20bot) diff --git a/public/locales/en-US.yml b/public/locales/en-US.yml index 1bb1d09..61db4b1 100644 --- a/public/locales/en-US.yml +++ b/public/locales/en-US.yml @@ -31,3 +31,20 @@ commands: mute: Silence un_mute: Shine's again leave: bye folk's + poll: + types: + single_choice: + label: Single choice + description: It will be possible to choose only one option + multiple_choice: + label: Multiple choice + description: It will be possible to choose more than one option + management: + label: Management + description: Manage polls + setup: + label: Setup + description: Setup a poll + help: + label: Help + description: Show help message for poll commands diff --git a/public/locales/pt-BR.yml b/public/locales/pt-BR.yml index 5e5b1e5..b531dfa 100644 --- a/public/locales/pt-BR.yml +++ b/public/locales/pt-BR.yml @@ -31,3 +31,20 @@ commands: mute: Vou é ficar surdinho un_mute: IMBROXÁVEL leave: Vô fuzila a petralhada aqui do Acre + poll: + types: + single_choice: + label: Escolha única + description: Será possível escolher apenas uma opção + multiple_choice: + label: Escolha múltipla + description: Será possível escolher mais de uma opção + management: + label: Gerenciar + description: Gerencia uma votação + setup: + label: Configurar + description: Configura uma votação + help: + label: Ajuda + description: Exibe mensagem de ajuda para os comandos de votação diff --git a/src/commands/jingle.rs b/src/commands/jingle.rs index eb2356a..24768cf 100644 --- a/src/commands/jingle.rs +++ b/src/commands/jingle.rs @@ -1,8 +1,20 @@ +use super::Command; + +use serenity::async_trait; use serenity::builder::CreateApplicationCommand; -use serenity::model::prelude::interaction::application_command::CommandDataOption; -pub async fn run(_options: &Vec) -> String { - "Tanke o Bostil ou deixe-o".to_string() +struct Jingle; + +#[async_trait] +impl super::RunnerFn for Jingle { + async fn run( + &self, + _args: &Vec>, + ) -> super::InternalCommandResult { + Ok(super::CommandResponse::String( + "Tanke o Bostil ou deixe-o".to_string(), + )) + } } pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { @@ -10,3 +22,13 @@ pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicatio .name("jingle") .description("Tanke o Bostil ou deixe-o") } + +pub fn get_command() -> Command { + Command::new( + "jingle", + "Tanke o Bostil ou deixe-o", + super::CommandCategory::Fun, + vec![super::ArgumentsLevel::None], + Box::new(Jingle {}), + ) +} diff --git a/src/commands/language.rs b/src/commands/language.rs index 2ee2695..02e935c 100644 --- a/src/commands/language.rs +++ b/src/commands/language.rs @@ -1,23 +1,45 @@ use crate::database::locale::apply_locale; use rust_i18n::{locale as current_locale, t}; +use serenity::async_trait; use serenity::builder::CreateApplicationCommand; -use serenity::client::Context; use serenity::model::prelude::{ - command, interaction::application_command::CommandDataOption, GuildId, + command, interaction::application_command::CommandDataOption, Guild, }; -pub async fn run(options: &Vec, _ctx: &Context, guild_id: &GuildId) -> String { - if let Some(language_option) = options.get(0) { - let selected_language = language_option.value.as_ref().unwrap().as_str().unwrap(); +use super::{ + ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, +}; + +struct Language; + +#[async_trait] +impl RunnerFn for Language { + async fn run(&self, args: &Vec>) -> InternalCommandResult { + let options = args + .iter() + .filter_map(|arg| arg.downcast_ref::>()) + .collect::>>()[0]; + let guild = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>()[0]; - apply_locale(selected_language, &guild_id, false); + if let Some(language_option) = options.get(0) { + let selected_language = language_option.value.as_ref().unwrap().as_str().unwrap(); - let current_locale_name = t!(&format!("commands.language.{}", selected_language)); - t!("commands.language.reply", "language_name" => current_locale_name) - } else { - let current_locale_name = t!(&format!("commands.language.{}", current_locale())); - t!("commands.language.current_language", "language_name" => current_locale_name, "language_code" => current_locale()) + apply_locale(selected_language, &guild.id, false); + + let current_locale_name = t!(&format!("commands.language.{}", selected_language)); + Ok(CommandResponse::String( + t!("commands.language.reply", "language_name" => current_locale_name), + )) + } else { + let current_locale_name = t!(&format!("commands.language.{}", current_locale())); + Ok(CommandResponse::String( + t!("commands.language.current_language", "language_name" => current_locale_name, "language_code" => current_locale()), + )) + } } } @@ -46,3 +68,13 @@ pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicatio ) }) } + +pub fn get_command() -> Command { + Command::new( + "language", + "Language Preferences Menu", + CommandCategory::General, + vec![ArgumentsLevel::Options, ArgumentsLevel::Guild], + Box::new(Language {}), + ) +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index f575f4d..1ac2134 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,7 +1,242 @@ +use std::{any::Any, ops::DerefMut}; + +use serenity::{ + async_trait, + builder::{CreateEmbed, CreateMessage, EditInteractionResponse}, + framework::standard::CommandResult, + model::{ + prelude::{application_command::CommandDataOption, Embed, Guild}, + user::User, + }, + prelude::Context, +}; + pub mod jingle; pub mod language; pub mod ping; +pub mod poll; pub mod radio; pub mod voice; -// TODO: split commands by global and guild, and then by category (fun, moderation) to iterate over them +#[derive(Debug, Clone, Copy)] +pub enum CommandCategory { + Fun, + Moderation, + Music, + Misc, + Voice, + Admin, + General, +} + +/** + Arguments to provide to a run function + - None: No arguments + - Value: 0 + - Options: options (&command.data.options) + - Value: 1 + - Context: context (&context) + - Value: 2 + - Guild: guild (&guild) + - Value: 3 + - User: user (&user) + - Value: 4 +*/ +#[derive(Debug, Clone, Copy)] +pub enum ArgumentsLevel { + None, + Options, + Context, + Guild, + User, +} + +pub struct Command { + pub name: String, + pub description: String, + pub category: CommandCategory, + pub arguments: Vec, + pub runner: Box, +} + +impl Command { + pub fn new( + name: &str, + description: &str, + category: CommandCategory, + arguments: Vec, + runner: Box, + ) -> Self { + let sorted_arguments = { + let mut sorted_arguments = arguments.clone(); + sorted_arguments.sort_by(|a, b| a.value().cmp(&b.value())); + sorted_arguments + }; + + Self { + arguments: sorted_arguments, + category, + runner, + description: description.to_string(), + name: name.to_string(), + } + } +} + +impl ArgumentsLevel { + pub fn value(&self) -> u8 { + match self { + ArgumentsLevel::None => 0, + ArgumentsLevel::Options => 1, + ArgumentsLevel::Context => 2, + ArgumentsLevel::Guild => 3, + ArgumentsLevel::User => 4, + } + } + + // function to provide the arguments to the run function + pub fn provide( + command: &Command, + context: &Context, + guild: &Guild, + user: &User, + options: &Vec, + ) -> Vec> { + let mut arguments: Vec> = vec![]; + + for argument in &command.arguments { + match argument { + ArgumentsLevel::None => (), + ArgumentsLevel::Options => arguments.push(Box::new(options.clone())), + ArgumentsLevel::Context => arguments.push(Box::new(context.clone())), + ArgumentsLevel::Guild => arguments.push(Box::new(guild.clone())), + ArgumentsLevel::User => arguments.push(Box::new(user.clone())), + } + } + + arguments + } +} + +pub enum CommandResponse { + String(String), + Embed(Embed), + Message(EditInteractionResponse), + None, +} + +impl CommandResponse { + pub fn to_embed(&self) -> CreateEmbed { + match self { + CommandResponse::String(string) => { + let mut embed = CreateEmbed::default(); + embed.description(string); + + embed + } + CommandResponse::Embed(command_embed) => { + let mut embed = CreateEmbed::default(); + embed.author(|a| { + a.name(command_embed.author.clone().unwrap().name.clone()) + .icon_url(command_embed.author.clone().unwrap().icon_url.unwrap()) + .url(command_embed.author.clone().unwrap().url.unwrap()) + }); + embed.title(command_embed.title.clone().unwrap()); + embed.description(command_embed.description.clone().unwrap()); + embed.fields( + command_embed + .fields + .clone() + .iter() + .map(|field| (field.name.clone(), field.value.clone(), field.inline)), + ); + embed.colour(command_embed.colour.clone().unwrap()); + embed.footer(|f| { + f.text(command_embed.footer.clone().unwrap().text.clone()) + .icon_url(command_embed.footer.clone().unwrap().icon_url.unwrap()) + }); + + embed + } + _ => CreateEmbed::default(), + } + } + + pub fn to_string(&self) -> String { + match self { + CommandResponse::String(string) => string.clone(), + CommandResponse::Embed(embed) => embed.description.clone().unwrap(), + _ => "".to_string(), + } + } +} + +impl PartialEq for CommandResponse { + fn eq(&self, other: &Self) -> bool { + match self { + CommandResponse::String(string) => match other { + CommandResponse::String(other_string) => string == other_string, + _ => false, + }, + CommandResponse::Embed(embed) => match other { + CommandResponse::Embed(other_embed) => { + Some(embed.title.clone()) == Some(other_embed.title.clone()) + } + _ => false, + }, + _ => match other { + CommandResponse::None => true, + _ => false, + }, + } + } + fn ne(&self, other: &Self) -> bool { + match self { + CommandResponse::String(string) => match other { + CommandResponse::String(other_string) => string != other_string, + _ => true, + }, + CommandResponse::Embed(embed) => match other { + CommandResponse::Embed(other_embed) => { + Some(embed.title.clone()) != Some(other_embed.title.clone()) + } + _ => true, + }, + _ => match other { + CommandResponse::None => false, + _ => true, + }, + } + } +} + +impl std::fmt::Display for CommandResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CommandResponse::String(string) => write!(f, "{}", string), + CommandResponse::Embed(embed) => write!(f, "{}", embed.description.clone().unwrap()), + CommandResponse::Message(_) => write!(f, "Message"), + _ => write!(f, "None"), + } + } +} + +// command result must be a string or an embed +pub type InternalCommandResult = CommandResult; + +#[async_trait] +pub trait RunnerFn { + async fn run(&self, arguments: &Vec>) -> InternalCommandResult; +} + +pub fn collect_commands() -> Vec { + vec![ + self::ping::get_command(), + self::language::get_command(), + self::jingle::get_command(), + self::radio::get_command(), + self::voice::join::get_command(), + self::voice::leave::get_command(), + self::voice::mute::get_command(), + ] +} diff --git a/src/commands/ping.rs b/src/commands/ping.rs index b79287c..7934981 100644 --- a/src/commands/ping.rs +++ b/src/commands/ping.rs @@ -1,16 +1,28 @@ +use super::{ArgumentsLevel, Command, CommandCategory, InternalCommandResult, RunnerFn}; +use crate::commands::CommandResponse; + +use serenity::async_trait; use serenity::builder::CreateApplicationCommand; -use serenity::model::prelude::interaction::application_command::CommandDataOption; +use std::any::Any; use tokio::time::Instant; -pub async fn run(_options: &Vec) -> String { - let get_latency = { - let now = Instant::now(); +struct Ping; + +#[async_trait] +impl RunnerFn for Ping { + async fn run(&self, _: &Vec>) -> InternalCommandResult { + let get_latency = { + let now = Instant::now(); - let _ = reqwest::get("https://discord.com/api/v8/gateway").await; - now.elapsed().as_millis() as f64 - }; + let _ = reqwest::get("https://discord.com/api/v8/gateway").await; + now.elapsed().as_millis() as f64 + }; - format!("Pong! Latency: {}ms", get_latency) + Ok(CommandResponse::String(format!( + "Pong! Latency: {}ms", + get_latency + ))) + } } pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { @@ -18,3 +30,13 @@ pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicatio .name("ping") .description("Check if the bot is alive, and test the latency to the server") } + +pub fn get_command() -> Command { + Command::new( + "ping", + "Check if the bot is alive, and test the latency to the server", + CommandCategory::General, + vec![ArgumentsLevel::None], + Box::new(Ping {}), + ) +} diff --git a/src/commands/poll/database.rs b/src/commands/poll/database.rs new file mode 100644 index 0000000..575bd07 --- /dev/null +++ b/src/commands/poll/database.rs @@ -0,0 +1,76 @@ +use super::{Poll, PollDatabaseModel as PollModel, PollStatus, PollType, Vote}; +use crate::database::{get_database, save_database, GuildDatabaseModel}; +use crate::internal::debug::{log_message, MessageTypes}; + +use serenity::model::prelude::{GuildId, UserId}; +use std::borrow::BorrowMut; +use yaml_rust::Yaml; + +impl PollModel { + pub fn from(poll: &Poll, votes: Vec, user_id: &UserId) -> PollModel { + PollModel { + votes, + id: poll.id, + kind: poll.kind, + timer: poll.timer, + status: poll.status, + name: poll.name.clone(), + description: poll.description.clone(), + options: poll.options.clone(), + created_at: std::time::SystemTime::now(), + created_by: user_id.clone(), + } + } + + pub fn from_yaml(yaml: &Yaml) -> PollModel { + PollModel { + votes: Vec::new(), + id: uuid::Uuid::parse_str(yaml["id"].as_str().unwrap()).unwrap(), + name: yaml["name"].as_str().unwrap().to_string(), + description: match yaml["description"].as_str() { + Some(description) => Some(description.to_string()), + None => None, + }, + kind: match yaml["kind"].as_str().unwrap() { + "single_choice" => PollType::SingleChoice, + "multiple_choice" => PollType::MultipleChoice, + _ => PollType::SingleChoice, + }, + options: yaml["options"] + .as_vec() + .unwrap() + .iter() + .map(|option| option.as_str().unwrap().to_string()) + .collect::>(), + timer: std::time::Duration::from_secs(yaml["timer"].as_i64().unwrap() as u64), + created_at: std::time::SystemTime::UNIX_EPOCH + + std::time::Duration::from_secs(yaml["created_at"].as_i64().unwrap() as u64), + status: match yaml["status"].as_str().unwrap() { + "open" => PollStatus::Open, + "closed" => PollStatus::Closed, + "stopped" => PollStatus::Stopped, + _ => PollStatus::Open, + }, + created_by: UserId(yaml["created_by"].as_i64().unwrap().try_into().unwrap()), + } + } +} + +pub fn save_poll(guild_id: GuildId, user_id: &UserId, poll: &Poll, votes: Vec) { + let database = get_database(); + let poll_model = PollModel::from(poll, votes, user_id); + + if let Some(guild) = database.lock().unwrap().guilds.get_mut(&guild_id) { + guild.polls.push(poll_model); + } else { + database.lock().unwrap().guilds.insert( + guild_id, + GuildDatabaseModel { + locale: "en-US".to_string(), + polls: vec![poll_model], + }, + ); + } + + save_database(database.lock().unwrap().borrow_mut()); +} diff --git a/src/commands/poll/help.rs b/src/commands/poll/help.rs new file mode 100644 index 0000000..e8e2276 --- /dev/null +++ b/src/commands/poll/help.rs @@ -0,0 +1,157 @@ +use rust_i18n::t; +use serenity::{ + async_trait, builder::CreateApplicationCommandOption, + model::prelude::command::CommandOptionType, +}; + +use crate::{ + commands::{ + ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, + }, + internal::constants::CommandHelp, +}; + +use super::PollType; + +/** + * Command: help + * + * Return the help message for the poll command + * - Usage: /poll help + */ + +struct PollHelpCommand; + +#[async_trait] +impl RunnerFn for PollHelpCommand { + async fn run(&self, _: &Vec>) -> InternalCommandResult { + let mut help_message: String = "```".to_string(); + + for helper in collect_command_help() { + help_message.push_str(&format!("/poll {} {}\n", helper.name, helper.description)); + + for option in helper.options { + help_message.push_str(&format!(" {}\n", option)); + } + + help_message.push_str("\n"); + } + + help_message.push_str("```"); + + Ok(CommandResponse::String(help_message)) + } +} + +fn create_help() -> CommandHelp { + CommandHelp { + name: "poll".to_string(), + description: "Create a poll".to_string(), + options: vec![ + "name: The name of the poll".to_string(), + "description: The description of the poll".to_string(), + format!( + "type: The type of the poll ({} or {})", + PollType::SingleChoice.to_label(), + PollType::MultipleChoice.to_label() + ), + "options: It is a voting option".to_string(), + ], + } +} + +fn setup_help() -> CommandHelp { + CommandHelp { + name: "setup".to_string(), + description: "Setup the poll".to_string(), + options: vec![ + format!( + "type: The type of the poll + {}: {} + {}: {} + ", + PollType::SingleChoice.to_string(), + PollType::SingleChoice.to_label(), + PollType::MultipleChoice.to_string(), + PollType::MultipleChoice.to_label(), + ), + "channel: The channel of the poll + \"current\": The current channel + \"\": The channel id + " + .to_string(), + "timer: Optional, the timer of the poll".to_string(), + ], + } +} + +fn management_help() -> CommandHelp { + CommandHelp { + name: "management".to_string(), + description: "Manage the poll".to_string(), + options: vec![ + "status: The status of the poll + \"open\": Open the poll + \"close\": Close the poll + \"stop\": Stop the poll + " + .to_string(), + "info: The info of the poll + \"name\": The name of the poll + \"description\": The description of the poll + \"type\": The type of the poll + \"options\": The options of the poll + \"timer\": The timer of the poll + \"status\": The status of the poll + \"votes\": The votes of the poll (only available for closed polls) + \"created_at\": The created at of the poll + \"created_by\": The created by of the poll + " + .to_string(), + ], + } +} + +fn collect_command_help() -> Vec { + vec![create_help(), setup_help(), management_help()] +} + +pub fn register_option<'a>() -> CreateApplicationCommandOption { + let mut command_option = CreateApplicationCommandOption::default(); + + command_option + .name("help") + .name_localized("pt-BR", "ajuda") + .description("Show the help message for poll commands") + .description_localized( + "pt-BR", + "Mostra a mensagem de ajuda para os comandos de votação", + ) + .kind(CommandOptionType::SubCommand) + .create_sub_option(|sub_option| { + sub_option + .name("poll_command") + .name_localized("pt-BR", "comando_de_votação") + .description("The command to show the help message for poll commands") + .description_localized( + "pt-BR", + "O comando para mostrar a mensagem de ajuda para os comandos de votação", + ) + .kind(CommandOptionType::String) + .required(true) + .add_string_choice(t!("commands.poll.setup.label"), "setup_command") + .add_string_choice(t!("commands.poll.management.label"), "management_command") + }); + + command_option +} + +pub fn get_command() -> Command { + Command::new( + "help", + "Show the help message for poll commands", + CommandCategory::Misc, + vec![ArgumentsLevel::None], + Box::new(PollHelpCommand {}), + ) +} diff --git a/src/commands/poll/management/mod.rs b/src/commands/poll/management/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/commands/poll/mod.rs b/src/commands/poll/mod.rs new file mode 100644 index 0000000..f9c5e83 --- /dev/null +++ b/src/commands/poll/mod.rs @@ -0,0 +1,324 @@ +use self::utils::progress_bar; +use super::{ + ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, +}; +use crate::{components::button::Button, internal::debug::log_message}; + +use regex::Regex; +use rust_i18n::t; +use serenity::{ + async_trait, + builder::{CreateEmbed, CreateMessage, EditInteractionResponse}, + framework::standard::CommandResult, + model::{ + prelude::{ + application_command::{CommandDataOption, CommandDataOptionValue}, + component::ButtonStyle, + UserId, + }, + user::User, + }, +}; +use std::{ + borrow::BorrowMut, + time::{Duration, SystemTime}, +}; + +mod database; +pub mod help; +pub mod management; +pub mod setup; +mod utils; + +struct PollCommand; + +#[derive(Debug)] +pub struct Vote { + pub user_id: UserId, + pub options: Vec, +} + +#[derive(Debug, Clone, Copy)] +pub enum PollType { + SingleChoice, + MultipleChoice, +} + +#[derive(Debug, Clone, Copy)] +pub enum PollStatus { + Open, + Closed, + Stopped, +} + +#[derive(Debug)] +pub struct Poll { + id: uuid::Uuid, + name: String, + description: Option, + kind: PollType, + options: Vec, + timer: Duration, + status: PollStatus, +} + +#[derive(Debug)] +pub struct PollDatabaseModel { + pub id: uuid::Uuid, + pub name: String, + pub description: Option, + pub kind: PollType, + pub status: PollStatus, + pub options: Vec, + pub timer: Duration, + pub votes: Vec, + pub created_at: SystemTime, + pub created_by: UserId, +} + +impl Poll { + pub fn new( + name: String, + description: Option, + kind: PollType, + options: Vec, + // Receives a minute value as a string (e.g. "0.5" for 30 seconds, "1" for 1 minute, "2" for 2 minutes, etc.) + timer: Option, + status: Option, + ) -> Poll { + Poll { + name, + description, + kind, + options, + id: uuid::Uuid::new_v4(), + status: status.unwrap_or(PollStatus::Open), + timer: match timer { + Some(timer) => { + let timer = timer.parse::().unwrap_or(0.0); + Duration::from_secs_f64(timer * 60.0) + } + None => Duration::from_secs(60), + }, + } + } +} + +impl PollType { + pub fn to_string(&self) -> String { + match self { + PollType::SingleChoice => t!("commands.poll.types.single_choice"), + PollType::MultipleChoice => t!("commands.poll.types.multiple_choice"), + } + } + + pub fn to_label(&self) -> String { + // TODO: add i18n + match self { + PollType::SingleChoice => "Single Choice".to_string(), + PollType::MultipleChoice => "Multiple Choice".to_string(), + } + } +} + +impl PollStatus { + pub fn to_string(&self) -> String { + match self { + PollStatus::Open => "open".to_string(), + PollStatus::Closed => "closed".to_string(), + PollStatus::Stopped => "stopped".to_string(), + } + } +} + +fn poll_serializer(command_options: &Vec) -> Poll { + let option_regex: Regex = Regex::new(r"^option_\d+$").unwrap(); + let kind = match command_options.iter().find(|option| option.name == "type") { + Some(option) => match option.resolved.as_ref().unwrap() { + CommandDataOptionValue::String(value) => match value.as_str() { + "single_choice" => PollType::SingleChoice, + "multiple_choice" => PollType::MultipleChoice, + _ => PollType::SingleChoice, + }, + _ => PollType::SingleChoice, + }, + None => PollType::SingleChoice, + }; + + Poll::new( + command_options + .iter() + .find(|option| option.name == "name") + .unwrap() + .value + .as_ref() + .unwrap() + .to_string(), + Some( + command_options + .iter() + .find(|option| option.name == "description") + .unwrap() + .value + .as_ref() + .unwrap() + .to_string(), + ), + kind, + command_options + .iter() + .filter(|option| option_regex.is_match(&option.name)) + .map(|option| match option.resolved.as_ref().unwrap() { + CommandDataOptionValue::String(value) => value.to_string(), + _ => "".to_string(), + }) + .collect::>(), + Some( + command_options + .iter() + .find(|option| option.name == "timer") + .unwrap() + .value + .as_ref() + .unwrap() + .to_string(), + ), + Some(PollStatus::Open), + ) +} + +fn create_message( + mut message_builder: EditInteractionResponse, + poll: PollDatabaseModel, +) -> CommandResult { + let time_remaining = match poll.timer.as_secs() / 60 > 1 { + true => format!("{} minutes", poll.timer.as_secs() / 60), + false => format!("{} seconds", poll.timer.as_secs()), + }; + let mut embed = CreateEmbed::default(); + embed + .title(poll.name) + .description(poll.description.unwrap_or("".to_string())); + + // first row (id, status, user) + embed.field( + "ID", + format!("`{}`", poll.id.to_string().split_at(8).0), + true, + ); + embed.field("Status", poll.status.to_string(), true); + embed.field("User", format!("<@{}>", poll.created_by), true); + + // separator + embed.field("\u{200B}", "\u{200B}", false); + + poll.options.iter().for_each(|option| { + embed.field(option, option, false); + }); + + // separator + embed.field("\u{200B}", "\u{200B}", false); + + embed.field( + "Partial Results (Live)", + format!( + "```diff\n{}\n```", + progress_bar(poll.votes, poll.options.clone()) + ), + false, + ); + + // separator + embed.field("\u{200B}", "\u{200B}", false); + + embed.field( + "Time remaining", + format!("{} remaining", time_remaining), + false, + ); + + message_builder.set_embed(embed); + message_builder.components(|component| { + component.create_action_row(|action_row| { + poll.options.iter().for_each(|option| { + action_row + .add_button(Button::new(option, option, ButtonStyle::Primary, None).create()); + }); + + action_row + }) + }); + + Ok(message_builder) +} + +// TODO: timer to close poll +// fn create_interaction() { +// // Wait for multiple interactions +// let mut interaction_stream = +// m.await_component_interactions(&ctx).timeout(Duration::from_secs(60 * 3)).build(); + +// while let Some(interaction) = interaction_stream.next().await { +// let sound = &interaction.data.custom_id; +// // Acknowledge the interaction and send a reply +// interaction +// .create_interaction_response(&ctx, |r| { +// // This time we dont edit the message but reply to it +// r.kind(InteractionResponseType::ChannelMessageWithSource) +// .interaction_response_data(|d| { +// // Make the message hidden for other users by setting `ephemeral(true)`. +// d.ephemeral(true) +// .content(format!("The **{}** says __{}__", animal, sound)) +// }) +// }) +// .await +// .unwrap(); +// } +// m.delete(&ctx).await?; +// } + +#[async_trait] +impl RunnerFn for PollCommand { + async fn run(&self, args: &Vec>) -> InternalCommandResult { + let debug = std::env::var("DEBUG").is_ok(); + let options = args + .iter() + .filter_map(|arg| arg.downcast_ref::>()) + .collect::>>(); + + let user_id = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>() + .get(0) + .unwrap() + .id; + + let poll = poll_serializer(options.get(0).unwrap()); + + if debug { + log_message( + format!("{:?}", poll).as_str(), + crate::internal::debug::MessageTypes::Debug, + ); + } + + let message = create_message( + EditInteractionResponse::default(), + PollDatabaseModel::from(&poll, vec![], &user_id), + ) + .unwrap(); + + Ok(CommandResponse::Message(message)) + } +} + +pub fn get_command() -> Command { + Command::new( + "poll", + "Poll commands", + CommandCategory::Misc, + vec![ArgumentsLevel::Options, ArgumentsLevel::User], + Box::new(PollCommand), + ) +} diff --git a/src/commands/poll/setup/create.rs b/src/commands/poll/setup/create.rs new file mode 100644 index 0000000..cf5c60c --- /dev/null +++ b/src/commands/poll/setup/create.rs @@ -0,0 +1,34 @@ +use serenity::{ + builder::CreateApplicationCommandOption, model::prelude::command::CommandOptionType, +}; + +pub fn register_option<'a>() -> CreateApplicationCommandOption { + let mut command_option = CreateApplicationCommandOption::default(); + + command_option + .name("setup") + .name_localized("pt-BR", "configurar") + .description("Setup a poll") + .description_localized("pt-BR", "Configura uma votação") + .kind(CommandOptionType::SubCommand) + .create_sub_option(|sub_option| { + sub_option + .name("poll_name") + .name_localized("pt-BR", "nome_da_votação") + .description("The name of the option (max 25 characters)") + .description_localized("pt-BR", "O nome da opção (máx 25 caracteres)") + .kind(CommandOptionType::String) + .required(true) + }) + .create_sub_option(|sub_option| { + sub_option + .name("poll_description") + .name_localized("pt-BR", "descrição_da_votação") + .description("The description of the option (max 100 characters)") + .description_localized("pt-BR", "A descrição da votação") + .kind(CommandOptionType::String) + .required(true) + }); + + command_option +} diff --git a/src/commands/poll/setup/mod.rs b/src/commands/poll/setup/mod.rs new file mode 100644 index 0000000..cedc0cd --- /dev/null +++ b/src/commands/poll/setup/mod.rs @@ -0,0 +1,23 @@ +use serenity::builder::CreateApplicationCommand; + +pub mod create; +pub mod options; + +/** + * commands: + * - poll setup (name, description, type, timer) + * ~ Setup creates a thread to add options with the poll (status: stopped) + * - poll options (name, description) + * ~ Options adds a new option to the poll (status: stopped) + * - poll status set (status: open, close, stop) + */ +pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { + command + .name("poll") + .name_localized("pt-BR", "urna") + .description("Create, edit or remove a poll") + .description_localized("pt-BR", "Cria, edita ou remove uma votação") + .add_option(self::options::register_option()) + .add_option(self::create::register_option()) + .add_option(super::help::register_option()) +} diff --git a/src/commands/poll/setup/options.rs b/src/commands/poll/setup/options.rs new file mode 100644 index 0000000..1d6fd94 --- /dev/null +++ b/src/commands/poll/setup/options.rs @@ -0,0 +1,43 @@ +use serenity::{ + builder::CreateApplicationCommandOption, model::prelude::command::CommandOptionType, +}; + +pub fn register_option<'a>() -> CreateApplicationCommandOption { + let mut command_option = CreateApplicationCommandOption::default(); + + command_option + .name("options") + .name_localized("pt-BR", "opções") + .description("Add options to the poll") + .description_localized("pt-BR", "Adiciona opções à votação") + .kind(CommandOptionType::SubCommand) + .create_sub_option(|sub_option| { + sub_option + .name("poll_id") + .name_localized("pt-BR", "id_da_votação") + .description("The poll id") + .description_localized("pt-BR", "O id da votação") + .kind(CommandOptionType::String) + .required(true) + }) + .create_sub_option(|sub_option| { + sub_option + .name("option_name") + .name_localized("pt-BR", "nome_da_opção") + .description("The name of the option (max 25 characters)") + .description_localized("pt-BR", "O nome da opção (máx 25 caracteres)") + .kind(CommandOptionType::String) + .required(true) + }) + .create_sub_option(|sub_option| { + sub_option + .name("option_description") + .name_localized("pt-BR", "descrição_da_opção") + .description("The description of the option (max 100 characters)") + .description_localized("pt-BR", "A descrição da votação") + .kind(CommandOptionType::String) + .required(true) + }); + + command_option +} diff --git a/src/commands/poll/utils/mod.rs b/src/commands/poll/utils/mod.rs new file mode 100644 index 0000000..04100cf --- /dev/null +++ b/src/commands/poll/utils/mod.rs @@ -0,0 +1,53 @@ +use super::Vote; + +type PartialResults = Vec<(String, u64)>; + +pub fn partial_results(votes: Vec, options: Vec) -> PartialResults { + let mut results = Vec::new(); + + for option in options { + let mut count = 0; + + for vote in &votes { + if vote.options.contains(&option) { + count += 1; + } + } + + results.push((option, count)); + } + + results +} + +/** + Returns a string with a progress bar for each option. + + e.g.: + Option 1: ████░░░░░░ 45% + Option 2: ████████░░ 75% +*/ +pub fn progress_bar(votes: Vec, options: Vec) -> String { + let results = partial_results(votes, options); + let mut progress_bar = String::new(); + + let total_votes = results.iter().fold(0, |acc, (_, count)| acc + count); + + for (option, count) in results { + let percentage = (count as f64 / total_votes as f64 * 100.0) as u64; + + progress_bar.push_str(&format!("{}: ", option)); + + for _ in 0..percentage / 10 { + progress_bar.push('█'); + } + + for _ in 0..(100 - percentage) / 10 { + progress_bar.push('░'); + } + + progress_bar.push_str(&format!(" {}%\n", percentage)); + } + + progress_bar +} diff --git a/src/commands/radio/mod.rs b/src/commands/radio/mod.rs index c27d140..d5386bc 100644 --- a/src/commands/radio/mod.rs +++ b/src/commands/radio/mod.rs @@ -8,15 +8,23 @@ use crate::{ use rust_i18n::t; use serenity::{ + async_trait, builder::CreateApplicationCommand, framework::standard::CommandResult, model::{ application::interaction::application_command::CommandDataOptionValue, prelude::{command, interaction::application_command::CommandDataOption, Guild, UserId}, + user::User, }, prelude::Context, }; +use super::{ + ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, +}; + +struct RadioCommand; + #[derive(Debug, Clone, Copy)] pub enum Radio { CanoaGrandeFM, @@ -67,6 +75,43 @@ impl std::fmt::Display for Radio { } } +#[async_trait] +impl RunnerFn for RadioCommand { + async fn run(&self, args: &Vec>) -> InternalCommandResult { + let ctx = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>(); + let guild = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>(); + let user_id = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>() + .get(0) + .unwrap() + .id; + let options = args + .iter() + .filter_map(|arg| arg.downcast_ref::>()) + .collect::>>(); + + match run( + options.get(0).unwrap(), + ctx.get(0).unwrap(), + guild.get(0).unwrap(), + &user_id, + ) + .await + { + Ok(response) => Ok(CommandResponse::String(response)), + Err(_) => Ok(CommandResponse::None), + } + } +} + pub async fn run( options: &Vec, ctx: &Context, @@ -181,3 +226,18 @@ pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicatio ) }) } + +pub fn get_command() -> Command { + Command::new( + "radio", + "Tune in to the best radios in \"Bostil\"", + CommandCategory::Voice, + vec![ + ArgumentsLevel::Options, + ArgumentsLevel::Context, + ArgumentsLevel::Guild, + ArgumentsLevel::User, + ], + Box::new(RadioCommand {}), + ) +} diff --git a/src/commands/voice/join.rs b/src/commands/voice/join.rs index 7e26703..4b37a7b 100644 --- a/src/commands/voice/join.rs +++ b/src/commands/voice/join.rs @@ -1,19 +1,39 @@ -use crate::events::voice::join; - +use crate::{ + commands::{ + ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, + }, + events::voice::join, +}; use serenity::{ + async_trait, builder::CreateApplicationCommand, - framework::standard::CommandResult, - model::prelude::{interaction::application_command::CommandDataOption, Guild, UserId}, + model::prelude::{Guild, UserId}, prelude::Context, }; -pub async fn run( - ctx: &Context, - guild: &Guild, - user_id: &UserId, - _options: &Vec, -) -> CommandResult { - join(ctx, guild, user_id).await +struct JoinCommand; + +#[async_trait] +impl RunnerFn for JoinCommand { + async fn run(&self, args: &Vec>) -> InternalCommandResult { + let ctx = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>()[0]; + let guild = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>()[0]; + let user_id = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>()[0]; + + match join(ctx, guild, user_id).await { + Ok(_) => Ok(CommandResponse::None), + Err(_) => Ok(CommandResponse::None), + } + } } pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { @@ -23,3 +43,17 @@ pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicatio .description("Join the voice channel you are in") .description_localized("pt-BR", "Entra no canal de voz que você está") } + +pub fn get_command() -> Command { + Command::new( + "join", + "Join the voice channel you are in", + CommandCategory::Voice, + vec![ + ArgumentsLevel::Context, + ArgumentsLevel::Guild, + ArgumentsLevel::User, + ], + Box::new(JoinCommand {}), + ) +} diff --git a/src/commands/voice/leave.rs b/src/commands/voice/leave.rs index 58d393c..f0fa9e6 100644 --- a/src/commands/voice/leave.rs +++ b/src/commands/voice/leave.rs @@ -1,19 +1,46 @@ -use crate::events::voice::leave; +use crate::{ + commands::{ + ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, + }, + events::voice::leave, +}; use serenity::{ + async_trait, builder::CreateApplicationCommand, - framework::standard::CommandResult, - model::prelude::{interaction::application_command::CommandDataOption, Guild, UserId}, + model::{ + prelude::{Guild, UserId}, + user::User, + }, prelude::Context, }; -pub async fn run( - ctx: &Context, - guild: &Guild, - user_id: &UserId, - _options: &Vec, -) -> CommandResult { - leave(ctx, guild, user_id).await +struct LeaveCommand; + +#[async_trait] +impl RunnerFn for LeaveCommand { + async fn run(&self, args: &Vec>) -> InternalCommandResult { + let ctx = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>(); + let guild = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>(); + let user_id = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>() + .get(0) + .unwrap() + .id; + + match leave(ctx.get(0).unwrap(), guild.get(0).unwrap(), &user_id).await { + Ok(_) => Ok(CommandResponse::None), + Err(_) => Ok(CommandResponse::None), + } + } } pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { @@ -23,3 +50,17 @@ pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicatio .description("Leave the voice channel you are in") .description_localized("pt-BR", "Sai do canal de voz que você está") } + +pub fn get_command() -> Command { + Command::new( + "leave", + "Leave the voice channel you are in", + CommandCategory::Voice, + vec![ + ArgumentsLevel::Context, + ArgumentsLevel::Guild, + ArgumentsLevel::User, + ], + Box::new(LeaveCommand {}), + ) +} diff --git a/src/commands/voice/mod.rs b/src/commands/voice/mod.rs index 849b27d..f02b363 100644 --- a/src/commands/voice/mod.rs +++ b/src/commands/voice/mod.rs @@ -1,3 +1,3 @@ -pub mod mute; pub mod join; pub mod leave; +pub mod mute; diff --git a/src/commands/voice/mute.rs b/src/commands/voice/mute.rs index d391033..c0b732a 100644 --- a/src/commands/voice/mute.rs +++ b/src/commands/voice/mute.rs @@ -1,6 +1,12 @@ -use crate::events::voice::{mute, unmute}; +use crate::{ + commands::{ + ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, + }, + events::voice::{mute, unmute}, +}; use serenity::{ + async_trait, builder::CreateApplicationCommand, framework::standard::CommandResult, model::prelude::{ @@ -10,24 +16,47 @@ use serenity::{ prelude::Context, }; -pub async fn run( - ctx: &Context, - guild: &Guild, - user_id: &UserId, - options: &Vec, -) -> CommandResult { - let enable_sound = options - .get(0) - .unwrap() - .value - .as_ref() - .unwrap() - .as_bool() - .unwrap(); +struct MuteCommand; + +#[async_trait] +impl RunnerFn for MuteCommand { + async fn run(&self, args: &Vec>) -> InternalCommandResult { + let ctx = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>()[0]; + let guild = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>()[0]; + let user_id = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>()[0]; + let options = args + .iter() + .filter_map(|arg| arg.downcast_ref::>()) + .collect::>>()[0]; - match enable_sound { - true => unmute(ctx, guild, user_id).await, - false => mute(ctx, guild, user_id).await, + let enable_sound = options + .get(0) + .unwrap() + .value + .as_ref() + .unwrap() + .as_bool() + .unwrap(); + + match enable_sound { + true => match unmute(ctx, guild, user_id).await { + Ok(_) => Ok(CommandResponse::None), + Err(_) => Ok(CommandResponse::None), + }, + false => match mute(ctx, guild, user_id).await { + Ok(_) => Ok(CommandResponse::None), + Err(_) => Ok(CommandResponse::None), + }, + } } } @@ -46,3 +75,17 @@ pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicatio .kind(CommandOptionType::Boolean) }) } + +pub fn get_command() -> Command { + Command::new( + "mute", + "Disable sound from a bot", + CommandCategory::Voice, + vec![ + ArgumentsLevel::Context, + ArgumentsLevel::Guild, + ArgumentsLevel::User, + ], + Box::new(MuteCommand {}), + ) +} diff --git a/src/components/button.rs b/src/components/button.rs new file mode 100644 index 0000000..758806e --- /dev/null +++ b/src/components/button.rs @@ -0,0 +1,46 @@ +use serenity::{ + builder::CreateButton, + model::prelude::{component::ButtonStyle, ReactionType}, +}; + +pub struct Button { + name: String, + emoji: Option, + label: String, + style: ButtonStyle, +} + +impl Button { + pub fn new(name: &str, label: &str, style: ButtonStyle, emoji: Option) -> Self { + Self { + emoji, + style, + name: name.to_string(), + label: label.to_string(), + } + } + + pub fn label(mut self, label: &str) -> Self { + self.label = label.to_string(); + self + } + + pub fn style(mut self, style: ButtonStyle) -> Self { + self.style = style; + self + } + + pub fn create(&self) -> CreateButton { + let mut b = CreateButton::default(); + + b.custom_id(&self.name); + b.label(&self.label); + b.style(self.style.clone()); + + if let Some(emoji) = &self.emoji { + b.emoji(emoji.clone()); + } + + b + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs new file mode 100644 index 0000000..aa200ca --- /dev/null +++ b/src/components/mod.rs @@ -0,0 +1 @@ +pub mod button; diff --git a/src/database/locale.rs b/src/database/locale.rs index 6d96df1..cf7e7a3 100644 --- a/src/database/locale.rs +++ b/src/database/locale.rs @@ -1,7 +1,10 @@ use std::borrow::BorrowMut; use super::{get_database, save_database}; -use crate::internal::debug::{log_message, MessageTypes}; +use crate::{ + database::GuildDatabaseModel, + internal::debug::{log_message, MessageTypes}, +}; use rust_i18n::{available_locales, set_locale}; use serenity::model::prelude::GuildId; @@ -10,11 +13,11 @@ pub fn apply_locale(new_locale: &str, guild_id: &GuildId, is_preflight: bool) { if available_locales!().contains(&new_locale) { let local_database = get_database(); - if let Some(locale) = local_database.lock().unwrap().locale.get(guild_id) { - if locale == new_locale { + if let Some(guild) = local_database.lock().unwrap().guilds.get(guild_id) { + if guild.locale == new_locale { return; - } else if locale != new_locale && is_preflight { - set_locale(locale); + } else if guild.locale != new_locale && is_preflight { + set_locale(guild.locale.as_str()); return; } @@ -22,11 +25,13 @@ pub fn apply_locale(new_locale: &str, guild_id: &GuildId, is_preflight: bool) { set_locale(new_locale); - local_database - .lock() - .unwrap() - .locale - .insert(guild_id.clone(), new_locale.to_string()); + local_database.lock().unwrap().guilds.insert( + *guild_id, + GuildDatabaseModel { + locale: new_locale.to_string(), + polls: Vec::new(), + }, + ); save_database(local_database.lock().unwrap().borrow_mut()); diff --git a/src/database/mod.rs b/src/database/mod.rs index 3ece630..619f0cd 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -5,6 +5,24 @@ pub mod locale; {GUILD_ID}: locale: "en-US" + polls: + - id: {POLL_ID} + name: "Poll name" + description: "Poll description" + kind: "single_choice" + options: + - "Option 1" + - "Option 2" + timer: 60 + votes: + - user_id: "USER_ID" + options: + - "Option 1" + - user_id: "USER_ID" + options: + - "Option 1" + - "Option 2" + created_at: 2021-01-01T00:00:00Z using Arc and Mutex to share the parsed database between threads */ @@ -21,22 +39,38 @@ use serenity::prelude::*; use yaml_rust::YamlLoader; +use crate::commands::poll::PollDatabaseModel; + +#[derive(Debug)] +pub struct GuildDatabaseModel { + pub locale: String, + pub polls: Vec, +} + #[derive(Debug)] pub struct Database { - pub locale: HashMap, + pub guilds: HashMap, } impl TypeMapKey for Database { type Value = Arc>; } +impl Database { + pub fn init() -> Arc> { + // TODO: CREATE INIT FOR DATABASE FILE + let database = get_database(); + + Arc::clone(&database) + } +} + fn open_or_create_file(database_path: &String) -> File { let file = File::open(database_path); match file { Ok(file) => file, Err(_) => { - // check if directory exists and create if not let mut dir_path = std::path::PathBuf::from(database_path); dir_path.pop(); @@ -63,16 +97,32 @@ pub fn get_database() -> Arc> { let docs = YamlLoader::load_from_str(&contents).unwrap(); let mut database = Database { - locale: HashMap::new(), + guilds: HashMap::new(), }; for doc in docs { for (guild_id, guild) in doc.as_hash().unwrap() { let guild_id = GuildId(guild_id.as_i64().unwrap() as u64); - let locale = guild["locale"].as_str().unwrap().to_string(); - - database.locale.insert(guild_id, locale); + let locale = match guild["locale"].as_str() { + Some(locale) => locale.to_string(), + None => "".to_string(), + }; + let polls = match guild["polls"].as_vec() { + Some(polls) => polls.to_vec(), + None => vec![] as Vec, + }; + + database.guilds.insert( + guild_id, + GuildDatabaseModel { + locale, + polls: polls + .iter() + .map(|poll| PollDatabaseModel::from_yaml(poll)) + .collect::>(), + }, + ); } } @@ -86,9 +136,51 @@ pub fn save_database(database: &Database) { let mut contents = String::new(); - for (guild_id, locale) in &database.locale { - contents.push_str(&format!("{}:\n", guild_id)); - contents.push_str(&format!(" locale: \"{}\"\n", locale)); + for (guild_id, guild) in &database.guilds { + contents.push_str(&format!("{}:\n", guild_id.as_u64())); + + contents.push_str(&format!(" locale: \"{}\"\n", guild.locale)); + contents.push_str(" polls:\n"); + + guild.polls.iter().for_each(|poll| { + println!("chegou no iter poll"); + + contents.push_str(&format!(" - id: {}\n", poll.id)); + + contents.push_str(&format!(" name: \"{}\"\n", poll.name)); + + if let Some(description) = &poll.description { + contents.push_str(&format!(" description: \"{}\"\n", description)); + } + + contents.push_str(&format!(" kind: \"{}\"\n", poll.kind.to_string())); + + contents.push_str(" options:\n"); + + for option in &poll.options { + contents.push_str(&format!(" - \"{}\"\n", option)); + } + + contents.push_str(&format!(" timer: {}\n", poll.timer.as_secs())); + + contents.push_str(" votes:\n"); + + for vote in &poll.votes { + contents.push_str(&format!(" - user_id: {}\n", vote.user_id)); + + for option in &vote.options { + contents.push_str(&format!(" - \"{}\"\n", option)); + } + } + + contents.push_str(&format!( + " created_at: {}\n", + poll.created_at + .duration_since(std::time::SystemTime::UNIX_EPOCH) + .unwrap() + .as_secs() + )); + }); } file.write_all(contents.as_bytes()) diff --git a/src/integrations/jukera.rs b/src/integrations/jukera.rs index 6b4d396..be5c44c 100644 --- a/src/integrations/jukera.rs +++ b/src/integrations/jukera.rs @@ -17,15 +17,22 @@ impl CallbackFn for Jukera { async fn run(message: &Message, ctx: &Context, user_id: &UserId) { match user_id == USERS.get("jukes_box").unwrap() { true => { - let current_music = message - .embeds - .first() - .unwrap() - .description - .as_ref() - .unwrap(); - - ctx.set_activity(Activity::listening(current_music)).await; + // check if message is a embed message (music session) + if message.embeds.is_empty() { + ctx.set_activity(Activity::competing( + "Campeonato de Leitada, Modalidade: Volume", + )) + .await; + + return; + } + + let current_music = match message.embeds.first() { + Some(embed) => embed.description.as_ref().unwrap(), + None => return, + }; + + ctx.set_activity(Activity::listening(current_music)).await } false => {} } diff --git a/src/internal/constants.rs b/src/internal/constants.rs index e22be6e..d61807d 100644 --- a/src/internal/constants.rs +++ b/src/internal/constants.rs @@ -1 +1,8 @@ pub const USERS_FILE_PATH: &str = "./public/static/users.json"; + +#[derive(Debug, Clone)] +pub struct CommandHelp { + pub name: String, + pub description: String, + pub options: Vec, +} diff --git a/src/lib.rs b/src/lib.rs index ffeb8d6..24c9111 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,8 +4,9 @@ extern crate rust_i18n; i18n!("./public/locales", fallback = "en-US"); pub mod commands; +pub mod components; pub mod database; +pub mod events; pub mod integrations; pub mod interactions; pub mod internal; -pub mod events; diff --git a/src/main.rs b/src/main.rs index 4cce33e..a1aec6b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,9 @@ include!("lib.rs"); -use std::env; use std::sync::Arc; +use std::{borrow::BorrowMut, env}; +use commands::{collect_commands, ArgumentsLevel, CommandResponse}; use serenity::async_trait; use serenity::client::bridge::gateway::ShardManager; use serenity::framework::StandardFramework; @@ -126,80 +127,104 @@ impl EventHandler for Handler { } let _ = command.defer(&ctx.http.clone()).await; + let registered_commands = collect_commands(); - let content = match command.data.name.as_str() { - "ping" => commands::ping::run(&command.data.options).await, - "jingle" => commands::jingle::run(&command.data.options).await, - "language" => { - commands::language::run(&command.data.options, &ctx, &command.guild_id.unwrap()) - .await + match registered_commands + .iter() + .enumerate() + .find(|(_, c)| c.name == command.data.name) + { + Some((_, command_interface)) => { + let command_response = command_interface + .runner + .run(&ArgumentsLevel::provide( + &command_interface, + &ctx, + &command + .guild_id + .unwrap() + .to_guild_cached(&ctx.cache) + .unwrap(), + &command.user, + &command.data.options, + )) + .await; + + match command_response { + Ok(command_response) => { + if debug { + log_message( + format!("Responding with: {}", command_response.to_string()) + .as_str(), + MessageTypes::Debug, + ); + } + + if CommandResponse::None != command_response { + if let Err(why) = command + .edit_original_interaction_response(&ctx.http, |response| { + match command_response { + CommandResponse::String(string) => { + response.content(string) + } + CommandResponse::Embed(embed) => response.set_embed( + CommandResponse::Embed(embed).to_embed(), + ), + CommandResponse::Message(message) => { + *response.borrow_mut() = message; + + response + } + CommandResponse::None => response, + } + }) + .await + { + log_message( + format!("Cannot respond to slash command: {}", why) + .as_str(), + MessageTypes::Error, + ); + } + } else { + if debug { + log_message( + format!("Deleting slash command: {}", command.data.name) + .as_str(), + MessageTypes::Debug, + ); + } + + if let Err(why) = command + .delete_original_interaction_response(&ctx.http) + .await + { + log_message( + format!("Cannot respond to slash command: {}", why) + .as_str(), + MessageTypes::Error, + ); + } + } + } + Err(why) => { + log_message( + format!("Cannot run slash command: {}", why).as_str(), + MessageTypes::Error, + ); + } + } + } + None => { + log_message( + format!("Command {} not found", command.data.name).as_str(), + MessageTypes::Error, + ); } - "radio" => commands::radio::run( - &command.data.options, - &ctx, - &command - .guild_id - .unwrap() - .to_guild_cached(&ctx.cache) - .unwrap(), - &command.user.id, - ) - .await - .unwrap(), - "mute" => commands::voice::mute::run( - &ctx, - &command - .guild_id - .unwrap() - .to_guild_cached(&ctx.cache) - .unwrap(), - &command.user.id, - &command.data.options, - ) - .await - .unwrap(), - "leave" => commands::voice::leave::run( - &ctx, - &command - .guild_id - .unwrap() - .to_guild_cached(&ctx.cache) - .unwrap(), - &command.user.id, - &command.data.options, - ) - .await - .unwrap(), - "join" => commands::voice::join::run( - &ctx, - &command - .guild_id - .unwrap() - .to_guild_cached(&ctx.cache) - .unwrap(), - &command.user.id, - &command.data.options, - ) - .await - .unwrap(), - _ => "Not implemented".to_string(), }; - - log_message( - format!("Responding with: {}", content).as_str(), - MessageTypes::Debug, - ); - - if let Err(why) = command - .edit_original_interaction_response(ctx.http, |response| response.content(content)) - .await - { - log_message( - format!("Cannot respond to slash command: {}", why).as_str(), - MessageTypes::Error, - ); - } } + + return (); } async fn ready(&self, ctx: Context, ready: Ready) { @@ -237,6 +262,8 @@ impl EventHandler for Handler { .create_application_command(|command| commands::voice::mute::register(command)); commands .create_application_command(|command| commands::voice::join::register(command)); + commands + .create_application_command(|command| commands::poll::setup::register(command)); commands }) From fb999febe416e026e96f5895b3e3dd15378d285b Mon Sep 17 00:00:00 2001 From: "Kszinhu@POP_os" Date: Wed, 23 Aug 2023 17:46:50 -0300 Subject: [PATCH 02/17] feat: add to command result interaction-response-data struct --- src/commands/jingle.rs | 4 +- src/commands/language.rs | 2 +- src/commands/mod.rs | 31 +++++-- src/commands/ping.rs | 2 +- src/commands/poll/database.rs | 26 ++++-- src/commands/poll/help.rs | 2 +- src/commands/poll/mod.rs | 144 ++++++++--------------------- src/commands/poll/setup/create.rs | 123 +++++++++++++++++++++++- src/commands/poll/setup/options.rs | 29 +++++- src/commands/radio/mod.rs | 2 +- src/commands/voice/join.rs | 2 +- src/commands/voice/leave.rs | 5 +- src/commands/voice/mute.rs | 6 +- src/main.rs | 46 +++++---- 14 files changed, 275 insertions(+), 149 deletions(-) diff --git a/src/commands/jingle.rs b/src/commands/jingle.rs index 24768cf..01d459e 100644 --- a/src/commands/jingle.rs +++ b/src/commands/jingle.rs @@ -7,10 +7,10 @@ struct Jingle; #[async_trait] impl super::RunnerFn for Jingle { - async fn run( + async fn run<'a>( &self, _args: &Vec>, - ) -> super::InternalCommandResult { + ) -> super::InternalCommandResult<'a> { Ok(super::CommandResponse::String( "Tanke o Bostil ou deixe-o".to_string(), )) diff --git a/src/commands/language.rs b/src/commands/language.rs index 02e935c..bb61e13 100644 --- a/src/commands/language.rs +++ b/src/commands/language.rs @@ -15,7 +15,7 @@ struct Language; #[async_trait] impl RunnerFn for Language { - async fn run(&self, args: &Vec>) -> InternalCommandResult { + async fn run<'a>(&self, args: &Vec>) -> InternalCommandResult<'a> { let options = args .iter() .filter_map(|arg| arg.downcast_ref::>()) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 1ac2134..8fc4948 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,11 +1,14 @@ -use std::{any::Any, ops::DerefMut}; +use std::{ + any::Any, + ops::{Deref, DerefMut}, +}; use serenity::{ async_trait, - builder::{CreateEmbed, CreateMessage, EditInteractionResponse}, + builder::{CreateEmbed, CreateInteractionResponseData, EditInteractionResponse}, framework::standard::CommandResult, model::{ - prelude::{application_command::CommandDataOption, Embed, Guild}, + prelude::{application_command::CommandDataOption, Embed, Guild, InteractionId}, user::User, }, prelude::Context, @@ -41,6 +44,8 @@ pub enum CommandCategory { - Value: 3 - User: user (&user) - Value: 4 + - InteractionId: interaction_id (&interaction_id) + - Value: 5 */ #[derive(Debug, Clone, Copy)] pub enum ArgumentsLevel { @@ -49,6 +54,7 @@ pub enum ArgumentsLevel { Context, Guild, User, + InteractionId, } pub struct Command { @@ -91,6 +97,7 @@ impl ArgumentsLevel { ArgumentsLevel::Context => 2, ArgumentsLevel::Guild => 3, ArgumentsLevel::User => 4, + ArgumentsLevel::InteractionId => 5, } } @@ -101,6 +108,7 @@ impl ArgumentsLevel { guild: &Guild, user: &User, options: &Vec, + interaction_id: &InteractionId, ) -> Vec> { let mut arguments: Vec> = vec![]; @@ -111,6 +119,7 @@ impl ArgumentsLevel { ArgumentsLevel::Context => arguments.push(Box::new(context.clone())), ArgumentsLevel::Guild => arguments.push(Box::new(guild.clone())), ArgumentsLevel::User => arguments.push(Box::new(user.clone())), + ArgumentsLevel::InteractionId => arguments.push(Box::new(interaction_id.clone())), } } @@ -118,14 +127,15 @@ impl ArgumentsLevel { } } -pub enum CommandResponse { +#[derive(Debug, Clone)] +pub enum CommandResponse<'a> { String(String), Embed(Embed), - Message(EditInteractionResponse), + Message(CreateInteractionResponseData<'a>), None, } -impl CommandResponse { +impl CommandResponse<'_> { pub fn to_embed(&self) -> CreateEmbed { match self { CommandResponse::String(string) => { @@ -171,7 +181,7 @@ impl CommandResponse { } } -impl PartialEq for CommandResponse { +impl PartialEq for CommandResponse<'_> { fn eq(&self, other: &Self) -> bool { match self { CommandResponse::String(string) => match other { @@ -210,7 +220,7 @@ impl PartialEq for CommandResponse { } } -impl std::fmt::Display for CommandResponse { +impl std::fmt::Display for CommandResponse<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { CommandResponse::String(string) => write!(f, "{}", string), @@ -222,16 +232,17 @@ impl std::fmt::Display for CommandResponse { } // command result must be a string or an embed -pub type InternalCommandResult = CommandResult; +pub type InternalCommandResult<'a> = CommandResult>; #[async_trait] pub trait RunnerFn { - async fn run(&self, arguments: &Vec>) -> InternalCommandResult; + async fn run<'a>(&self, arguments: &Vec>) -> InternalCommandResult<'a>; } pub fn collect_commands() -> Vec { vec![ self::ping::get_command(), + self::poll::get_command(), self::language::get_command(), self::jingle::get_command(), self::radio::get_command(), diff --git a/src/commands/ping.rs b/src/commands/ping.rs index 7934981..a9884a5 100644 --- a/src/commands/ping.rs +++ b/src/commands/ping.rs @@ -10,7 +10,7 @@ struct Ping; #[async_trait] impl RunnerFn for Ping { - async fn run(&self, _: &Vec>) -> InternalCommandResult { + async fn run<'a>(&self, _: &Vec>) -> InternalCommandResult<'a> { let get_latency = { let now = Instant::now(); diff --git a/src/commands/poll/database.rs b/src/commands/poll/database.rs index 575bd07..c4bc5c2 100644 --- a/src/commands/poll/database.rs +++ b/src/commands/poll/database.rs @@ -1,13 +1,19 @@ +use std::borrow::BorrowMut; + use super::{Poll, PollDatabaseModel as PollModel, PollStatus, PollType, Vote}; use crate::database::{get_database, save_database, GuildDatabaseModel}; use crate::internal::debug::{log_message, MessageTypes}; -use serenity::model::prelude::{GuildId, UserId}; -use std::borrow::BorrowMut; +use serenity::model::prelude::{GuildId, MessageId, UserId}; use yaml_rust::Yaml; impl PollModel { - pub fn from(poll: &Poll, votes: Vec, user_id: &UserId) -> PollModel { + pub fn from( + poll: &Poll, + votes: Vec, + user_id: &UserId, + message_id: &MessageId, + ) -> PollModel { PollModel { votes, id: poll.id, @@ -17,6 +23,7 @@ impl PollModel { name: poll.name.clone(), description: poll.description.clone(), options: poll.options.clone(), + message_id: message_id.clone(), created_at: std::time::SystemTime::now(), created_by: user_id.clone(), } @@ -43,6 +50,7 @@ impl PollModel { .map(|option| option.as_str().unwrap().to_string()) .collect::>(), timer: std::time::Duration::from_secs(yaml["timer"].as_i64().unwrap() as u64), + message_id: MessageId(yaml["message_id"].as_i64().unwrap().try_into().unwrap()), created_at: std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(yaml["created_at"].as_i64().unwrap() as u64), status: match yaml["status"].as_str().unwrap() { @@ -51,14 +59,20 @@ impl PollModel { "stopped" => PollStatus::Stopped, _ => PollStatus::Open, }, - created_by: UserId(yaml["created_by"].as_i64().unwrap().try_into().unwrap()), + created_by: UserId(yaml["created_by"].as_i64().unwrap() as u64), } } } -pub fn save_poll(guild_id: GuildId, user_id: &UserId, poll: &Poll, votes: Vec) { +pub fn save_poll( + guild_id: GuildId, + user_id: &UserId, + message_id: &MessageId, + poll: &Poll, + votes: Vec, +) { let database = get_database(); - let poll_model = PollModel::from(poll, votes, user_id); + let poll_model = PollModel::from(poll, votes, user_id, message_id); if let Some(guild) = database.lock().unwrap().guilds.get_mut(&guild_id) { guild.polls.push(poll_model); diff --git a/src/commands/poll/help.rs b/src/commands/poll/help.rs index e8e2276..eb00a34 100644 --- a/src/commands/poll/help.rs +++ b/src/commands/poll/help.rs @@ -24,7 +24,7 @@ struct PollHelpCommand; #[async_trait] impl RunnerFn for PollHelpCommand { - async fn run(&self, _: &Vec>) -> InternalCommandResult { + async fn run<'a>(&self, _: &Vec>) -> InternalCommandResult<'a> { let mut help_message: String = "```".to_string(); for helper in collect_command_help() { diff --git a/src/commands/poll/mod.rs b/src/commands/poll/mod.rs index f9c5e83..c06c1c0 100644 --- a/src/commands/poll/mod.rs +++ b/src/commands/poll/mod.rs @@ -1,22 +1,16 @@ -use self::utils::progress_bar; use super::{ ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, }; -use crate::{components::button::Button, internal::debug::log_message}; use regex::Regex; use rust_i18n::t; use serenity::{ async_trait, - builder::{CreateEmbed, CreateMessage, EditInteractionResponse}, - framework::standard::CommandResult, - model::{ - prelude::{ - application_command::{CommandDataOption, CommandDataOptionValue}, - component::ButtonStyle, - UserId, - }, - user::User, + builder::CreateInteractionResponseData, + futures::TryFutureExt, + model::prelude::{ + application_command::{CommandDataOption, CommandDataOptionValue}, + MessageId, UserId, }, }; use std::{ @@ -72,6 +66,7 @@ pub struct PollDatabaseModel { pub options: Vec, pub timer: Duration, pub votes: Vec, + pub message_id: MessageId, pub created_at: SystemTime, pub created_by: UserId, } @@ -107,16 +102,15 @@ impl Poll { impl PollType { pub fn to_string(&self) -> String { match self { - PollType::SingleChoice => t!("commands.poll.types.single_choice"), - PollType::MultipleChoice => t!("commands.poll.types.multiple_choice"), + PollType::SingleChoice => "single_choice".to_string(), + PollType::MultipleChoice => "multiple_choice".to_string(), } } pub fn to_label(&self) -> String { - // TODO: add i18n match self { - PollType::SingleChoice => "Single Choice".to_string(), - PollType::MultipleChoice => "Multiple Choice".to_string(), + PollType::SingleChoice => t!("commands.poll.types.single_choice.label"), + PollType::MultipleChoice => t!("commands.poll.types.single_choice.label"), } } } @@ -187,71 +181,6 @@ fn poll_serializer(command_options: &Vec) -> Poll { ) } -fn create_message( - mut message_builder: EditInteractionResponse, - poll: PollDatabaseModel, -) -> CommandResult { - let time_remaining = match poll.timer.as_secs() / 60 > 1 { - true => format!("{} minutes", poll.timer.as_secs() / 60), - false => format!("{} seconds", poll.timer.as_secs()), - }; - let mut embed = CreateEmbed::default(); - embed - .title(poll.name) - .description(poll.description.unwrap_or("".to_string())); - - // first row (id, status, user) - embed.field( - "ID", - format!("`{}`", poll.id.to_string().split_at(8).0), - true, - ); - embed.field("Status", poll.status.to_string(), true); - embed.field("User", format!("<@{}>", poll.created_by), true); - - // separator - embed.field("\u{200B}", "\u{200B}", false); - - poll.options.iter().for_each(|option| { - embed.field(option, option, false); - }); - - // separator - embed.field("\u{200B}", "\u{200B}", false); - - embed.field( - "Partial Results (Live)", - format!( - "```diff\n{}\n```", - progress_bar(poll.votes, poll.options.clone()) - ), - false, - ); - - // separator - embed.field("\u{200B}", "\u{200B}", false); - - embed.field( - "Time remaining", - format!("{} remaining", time_remaining), - false, - ); - - message_builder.set_embed(embed); - message_builder.components(|component| { - component.create_action_row(|action_row| { - poll.options.iter().for_each(|option| { - action_row - .add_button(Button::new(option, option, ButtonStyle::Primary, None).create()); - }); - - action_row - }) - }); - - Ok(message_builder) -} - // TODO: timer to close poll // fn create_interaction() { // // Wait for multiple interactions @@ -279,38 +208,40 @@ fn create_message( #[async_trait] impl RunnerFn for PollCommand { - async fn run(&self, args: &Vec>) -> InternalCommandResult { - let debug = std::env::var("DEBUG").is_ok(); + async fn run<'a>( + &self, + args: &Vec>, + ) -> InternalCommandResult<'a> { let options = args .iter() .filter_map(|arg| arg.downcast_ref::>()) .collect::>>(); + let first_option = options.get(0).unwrap(); + let command_name = first_option.get(0).unwrap().name.clone(); - let user_id = args - .iter() - .filter_map(|arg| arg.downcast_ref::()) - .collect::>() - .get(0) - .unwrap() - .id; + let command_runner = command_suite(command_name); - let poll = poll_serializer(options.get(0).unwrap()); + let response = command_runner.run(&args); - if debug { - log_message( - format!("{:?}", poll).as_str(), - crate::internal::debug::MessageTypes::Debug, - ); + match response.await { + Ok(response) => match response.to_owned() { + CommandResponse::Message(message) => Ok(CommandResponse::Message(message)), + _ => Ok(CommandResponse::None), + }, + Err(e) => Err(e), } + } +} - let message = create_message( - EditInteractionResponse::default(), - PollDatabaseModel::from(&poll, vec![], &user_id), - ) - .unwrap(); +fn command_suite(command_name: String) -> Box { + let command_runner = match command_name.as_str() { + "help" => self::help::get_command().runner, + "setup" => self::setup::create::get_command().runner, + "options" => self::setup::options::get_command().runner, + _ => get_command().runner, + }; - Ok(CommandResponse::Message(message)) - } + command_runner } pub fn get_command() -> Command { @@ -318,7 +249,12 @@ pub fn get_command() -> Command { "poll", "Poll commands", CommandCategory::Misc, - vec![ArgumentsLevel::Options, ArgumentsLevel::User], + vec![ + ArgumentsLevel::Options, + ArgumentsLevel::Context, + ArgumentsLevel::Guild, + ArgumentsLevel::User, + ], Box::new(PollCommand), ) } diff --git a/src/commands/poll/setup/create.rs b/src/commands/poll/setup/create.rs index cf5c60c..9b317d4 100644 --- a/src/commands/poll/setup/create.rs +++ b/src/commands/poll/setup/create.rs @@ -1,7 +1,118 @@ +use crate::{ + commands::{ + poll::{utils::progress_bar, PollDatabaseModel}, + ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, + }, + components::button::Button, +}; + use serenity::{ - builder::CreateApplicationCommandOption, model::prelude::command::CommandOptionType, + async_trait, + builder::{CreateApplicationCommandOption, CreateEmbed, EditInteractionResponse}, + framework::standard::CommandResult, + model::prelude::{command::CommandOptionType, component::ButtonStyle}, }; +struct CreatePollRunner; + +#[async_trait] +impl RunnerFn for CreatePollRunner { + async fn run<'a>(&self, _: &Vec>) -> InternalCommandResult<'a> { + Ok(CommandResponse::None) + } +} + +// fn create_interaction() { +// // Wait for multiple interactions +// let mut interaction_stream = +// m.await_component_interactions(&ctx).timeout(Duration::from_secs(60 * 3)).build(); + +// while let Some(interaction) = interaction_stream.next().await { +// let sound = &interaction.data.custom_id; +// // Acknowledge the interaction and send a reply +// interaction +// .create_interaction_response(&ctx, |r| { +// // This time we dont edit the message but reply to it +// r.kind(InteractionResponseType::ChannelMessageWithSource) +// .interaction_response_data(|d| { +// // Make the message hidden for other users by setting `ephemeral(true)`. +// d.ephemeral(true) +// .content(format!("The **{}** says __{}__", animal, sound)) +// }) +// }) +// .await +// .unwrap(); +// } +// m.delete(&ctx).await?; +// } + +fn vote_interaction() {} + +fn create_message( + mut message_builder: EditInteractionResponse, + poll: PollDatabaseModel, +) -> CommandResult { + let time_remaining = match poll.timer.as_secs() / 60 > 1 { + true => format!("{} minutes", poll.timer.as_secs() / 60), + false => format!("{} seconds", poll.timer.as_secs()), + }; + let mut embed = CreateEmbed::default(); + embed + .title(poll.name) + .description(poll.description.unwrap_or("".to_string())); + + // first row (id, status, user) + embed.field( + "ID", + format!("`{}`", poll.id.to_string().split_at(8).0), + true, + ); + embed.field("Status", poll.status.to_string(), true); + embed.field("User", format!("<@{}>", poll.created_by), true); + + // separator + embed.field("\u{200B}", "\u{200B}", false); + + poll.options.iter().for_each(|option| { + embed.field(option, option, false); + }); + + // separator + embed.field("\u{200B}", "\u{200B}", false); + + embed.field( + "Partial Results (Live)", + format!( + "```diff\n{}\n```", + progress_bar(poll.votes, poll.options.clone()) + ), + false, + ); + + // separator + embed.field("\u{200B}", "\u{200B}", false); + + embed.field( + "Time remaining", + format!("{} remaining", time_remaining), + false, + ); + + message_builder.set_embed(embed); + message_builder.components(|component| { + component.create_action_row(|action_row| { + poll.options.iter().for_each(|option| { + action_row + .add_button(Button::new(option, option, ButtonStyle::Primary, None).create()); + }); + + action_row + }) + }); + + Ok(message_builder) +} + pub fn register_option<'a>() -> CreateApplicationCommandOption { let mut command_option = CreateApplicationCommandOption::default(); @@ -32,3 +143,13 @@ pub fn register_option<'a>() -> CreateApplicationCommandOption { command_option } + +pub fn get_command() -> Command { + Command::new( + "setup", + "Setup a poll", + CommandCategory::Misc, + vec![ArgumentsLevel::User], + Box::new(CreatePollRunner), + ) +} diff --git a/src/commands/poll/setup/options.rs b/src/commands/poll/setup/options.rs index 1d6fd94..748b61b 100644 --- a/src/commands/poll/setup/options.rs +++ b/src/commands/poll/setup/options.rs @@ -1,7 +1,24 @@ +use crate::commands::{ + ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, +}; + use serenity::{ - builder::CreateApplicationCommandOption, model::prelude::command::CommandOptionType, + async_trait, builder::CreateApplicationCommandOption, + model::prelude::command::CommandOptionType, }; +struct OptionsPollRunner; + +#[async_trait] +impl RunnerFn for OptionsPollRunner { + async fn run<'a>( + &self, + _: &Vec>, + ) -> InternalCommandResult<'a> { + Ok(CommandResponse::None) + } +} + pub fn register_option<'a>() -> CreateApplicationCommandOption { let mut command_option = CreateApplicationCommandOption::default(); @@ -41,3 +58,13 @@ pub fn register_option<'a>() -> CreateApplicationCommandOption { command_option } + +pub fn get_command() -> Command { + Command::new( + "options", + "Add options to the poll", + CommandCategory::Misc, + vec![ArgumentsLevel::User], + Box::new(OptionsPollRunner), + ) +} diff --git a/src/commands/radio/mod.rs b/src/commands/radio/mod.rs index d5386bc..b6ccf38 100644 --- a/src/commands/radio/mod.rs +++ b/src/commands/radio/mod.rs @@ -77,7 +77,7 @@ impl std::fmt::Display for Radio { #[async_trait] impl RunnerFn for RadioCommand { - async fn run(&self, args: &Vec>) -> InternalCommandResult { + async fn run<'a>(&self, args: &Vec>) -> InternalCommandResult<'a> { let ctx = args .iter() .filter_map(|arg| arg.downcast_ref::()) diff --git a/src/commands/voice/join.rs b/src/commands/voice/join.rs index 4b37a7b..2c234fd 100644 --- a/src/commands/voice/join.rs +++ b/src/commands/voice/join.rs @@ -15,7 +15,7 @@ struct JoinCommand; #[async_trait] impl RunnerFn for JoinCommand { - async fn run(&self, args: &Vec>) -> InternalCommandResult { + async fn run<'a>(&self, args: &Vec>) -> InternalCommandResult<'a> { let ctx = args .iter() .filter_map(|arg| arg.downcast_ref::()) diff --git a/src/commands/voice/leave.rs b/src/commands/voice/leave.rs index f0fa9e6..0451aae 100644 --- a/src/commands/voice/leave.rs +++ b/src/commands/voice/leave.rs @@ -19,7 +19,10 @@ struct LeaveCommand; #[async_trait] impl RunnerFn for LeaveCommand { - async fn run(&self, args: &Vec>) -> InternalCommandResult { + async fn run<'a>( + &self, + args: &Vec>, + ) -> InternalCommandResult<'a> { let ctx = args .iter() .filter_map(|arg| arg.downcast_ref::()) diff --git a/src/commands/voice/mute.rs b/src/commands/voice/mute.rs index c0b732a..b506dd7 100644 --- a/src/commands/voice/mute.rs +++ b/src/commands/voice/mute.rs @@ -8,7 +8,6 @@ use crate::{ use serenity::{ async_trait, builder::CreateApplicationCommand, - framework::standard::CommandResult, model::prelude::{ command::CommandOptionType, interaction::application_command::CommandDataOption, Guild, UserId, @@ -20,7 +19,10 @@ struct MuteCommand; #[async_trait] impl RunnerFn for MuteCommand { - async fn run(&self, args: &Vec>) -> InternalCommandResult { + async fn run<'a>( + &self, + args: &Vec>, + ) -> InternalCommandResult<'a> { let ctx = args .iter() .filter_map(|arg| arg.downcast_ref::()) diff --git a/src/main.rs b/src/main.rs index a1aec6b..85c6d6a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ use serenity::model::application::interaction::Interaction; use serenity::model::gateway::Ready; use serenity::model::id::GuildId; use serenity::model::prelude::command::Command; +use serenity::model::prelude::InteractionResponseType; use serenity::model::voice::VoiceState; use serenity::prelude::*; @@ -126,7 +127,8 @@ impl EventHandler for Handler { ); } - let _ = command.defer(&ctx.http.clone()).await; + command.defer(&ctx.http.clone()).await.unwrap(); + let registered_commands = collect_commands(); match registered_commands @@ -147,6 +149,7 @@ impl EventHandler for Handler { .unwrap(), &command.user, &command.data.options, + &command.id, )) .await; @@ -162,22 +165,31 @@ impl EventHandler for Handler { if CommandResponse::None != command_response { if let Err(why) = command - .edit_original_interaction_response(&ctx.http, |response| { - match command_response { - CommandResponse::String(string) => { - response.content(string) - } - CommandResponse::Embed(embed) => response.set_embed( - CommandResponse::Embed(embed).to_embed(), - ), - CommandResponse::Message(message) => { - *response.borrow_mut() = message; - - response - } - CommandResponse::None => response, - } - }) + .create_interaction_response( + &ctx.http, + |interaction_response| { + interaction_response + .kind(InteractionResponseType::UpdateMessage) + .interaction_response_data(|response| { + match command_response { + CommandResponse::String(string) => { + response.content(string) + } + CommandResponse::Embed(embed) => response + .set_embed( + CommandResponse::Embed(embed) + .to_embed(), + ), + CommandResponse::Message(message) => { + *response.borrow_mut() = message; + + response + } + CommandResponse::None => response, + } + }) + }, + ) .await { log_message( From df60c6d79cfa48c179a9e852076f35fc51d2da88 Mon Sep 17 00:00:00 2001 From: "Kszinhu@POP_os" Date: Wed, 23 Aug 2023 23:02:03 -0300 Subject: [PATCH 03/17] feat: add create thread on "/poll setup" --- src/commands/mod.rs | 13 ++++- src/commands/poll/database.rs | 34 ++++++++--- src/commands/poll/mod.rs | 26 ++++++--- src/commands/poll/setup/create.rs | 94 +++++++++++++++++++++++++++++-- src/main.rs | 11 +++- 5 files changed, 153 insertions(+), 25 deletions(-) diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 8fc4948..ee9373a 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -8,7 +8,7 @@ use serenity::{ builder::{CreateEmbed, CreateInteractionResponseData, EditInteractionResponse}, framework::standard::CommandResult, model::{ - prelude::{application_command::CommandDataOption, Embed, Guild, InteractionId}, + prelude::{application_command::CommandDataOption, ChannelId, Embed, Guild, InteractionId}, user::User, }, prelude::Context, @@ -46,6 +46,8 @@ pub enum CommandCategory { - Value: 4 - InteractionId: interaction_id (&interaction_id) - Value: 5 + - ChannelId: channel_id (&channel_id) + - Value: 6 */ #[derive(Debug, Clone, Copy)] pub enum ArgumentsLevel { @@ -55,6 +57,7 @@ pub enum ArgumentsLevel { Guild, User, InteractionId, + ChannelId, } pub struct Command { @@ -98,6 +101,7 @@ impl ArgumentsLevel { ArgumentsLevel::Guild => 3, ArgumentsLevel::User => 4, ArgumentsLevel::InteractionId => 5, + ArgumentsLevel::ChannelId => 6, } } @@ -109,6 +113,7 @@ impl ArgumentsLevel { user: &User, options: &Vec, interaction_id: &InteractionId, + channel_id: &ChannelId, ) -> Vec> { let mut arguments: Vec> = vec![]; @@ -120,6 +125,7 @@ impl ArgumentsLevel { ArgumentsLevel::Guild => arguments.push(Box::new(guild.clone())), ArgumentsLevel::User => arguments.push(Box::new(user.clone())), ArgumentsLevel::InteractionId => arguments.push(Box::new(interaction_id.clone())), + ArgumentsLevel::ChannelId => arguments.push(Box::new(channel_id.clone())), } } @@ -236,7 +242,10 @@ pub type InternalCommandResult<'a> = CommandResult>; #[async_trait] pub trait RunnerFn { - async fn run<'a>(&self, arguments: &Vec>) -> InternalCommandResult<'a>; + async fn run<'a>( + &self, + arguments: &Vec>, + ) -> InternalCommandResult<'a>; } pub fn collect_commands() -> Vec { diff --git a/src/commands/poll/database.rs b/src/commands/poll/database.rs index c4bc5c2..016eb53 100644 --- a/src/commands/poll/database.rs +++ b/src/commands/poll/database.rs @@ -1,10 +1,10 @@ use std::borrow::BorrowMut; -use super::{Poll, PollDatabaseModel as PollModel, PollStatus, PollType, Vote}; +use super::{PartialPoll, Poll, PollDatabaseModel as PollModel, PollStatus, PollType, Vote}; use crate::database::{get_database, save_database, GuildDatabaseModel}; use crate::internal::debug::{log_message, MessageTypes}; -use serenity::model::prelude::{GuildId, MessageId, UserId}; +use serenity::model::prelude::{ChannelId, GuildChannel, GuildId, UserId}; use yaml_rust::Yaml; impl PollModel { @@ -12,7 +12,7 @@ impl PollModel { poll: &Poll, votes: Vec, user_id: &UserId, - message_id: &MessageId, + thread_id: &ChannelId, ) -> PollModel { PollModel { votes, @@ -23,7 +23,8 @@ impl PollModel { name: poll.name.clone(), description: poll.description.clone(), options: poll.options.clone(), - message_id: message_id.clone(), + thread_id: thread_id.clone(), + partial: false, created_at: std::time::SystemTime::now(), created_by: user_id.clone(), } @@ -50,7 +51,7 @@ impl PollModel { .map(|option| option.as_str().unwrap().to_string()) .collect::>(), timer: std::time::Duration::from_secs(yaml["timer"].as_i64().unwrap() as u64), - message_id: MessageId(yaml["message_id"].as_i64().unwrap().try_into().unwrap()), + thread_id: ChannelId(yaml["thread_id"].as_i64().unwrap().try_into().unwrap()), created_at: std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(yaml["created_at"].as_i64().unwrap() as u64), status: match yaml["status"].as_str().unwrap() { @@ -59,20 +60,39 @@ impl PollModel { "stopped" => PollStatus::Stopped, _ => PollStatus::Open, }, + partial: yaml["partial"].as_bool().unwrap(), created_by: UserId(yaml["created_by"].as_i64().unwrap() as u64), } } + + fn from_partial_poll(partial_poll: &PartialPoll) -> PollModel { + let poll = Poll::new( + partial_poll.name.clone(), + partial_poll.description.clone(), + partial_poll.kind, + vec![], + None, + Some(PollStatus::Creating), + ); + + PollModel::from( + &poll, + vec![], + &partial_poll.created_by, + &partial_poll.thread_id, + ) + } } pub fn save_poll( guild_id: GuildId, user_id: &UserId, - message_id: &MessageId, + thread_id: &ChannelId, poll: &Poll, votes: Vec, ) { let database = get_database(); - let poll_model = PollModel::from(poll, votes, user_id, message_id); + let poll_model = PollModel::from(poll, votes, user_id, thread_id); if let Some(guild) = database.lock().unwrap().guilds.get_mut(&guild_id) { guild.polls.push(poll_model); diff --git a/src/commands/poll/mod.rs b/src/commands/poll/mod.rs index c06c1c0..2df9330 100644 --- a/src/commands/poll/mod.rs +++ b/src/commands/poll/mod.rs @@ -6,17 +6,12 @@ use regex::Regex; use rust_i18n::t; use serenity::{ async_trait, - builder::CreateInteractionResponseData, - futures::TryFutureExt, model::prelude::{ application_command::{CommandDataOption, CommandDataOptionValue}, - MessageId, UserId, + ChannelId, UserId, }, }; -use std::{ - borrow::BorrowMut, - time::{Duration, SystemTime}, -}; +use std::time::{Duration, SystemTime}; mod database; pub mod help; @@ -43,6 +38,7 @@ pub enum PollStatus { Open, Closed, Stopped, + Creating, } #[derive(Debug)] @@ -66,11 +62,21 @@ pub struct PollDatabaseModel { pub options: Vec, pub timer: Duration, pub votes: Vec, - pub message_id: MessageId, + pub partial: bool, + pub thread_id: ChannelId, pub created_at: SystemTime, pub created_by: UserId, } +#[derive(Debug)] +pub struct PartialPoll { + pub thread_id: ChannelId, + pub name: String, + pub description: Option, + pub kind: PollType, + pub created_by: UserId, +} + impl Poll { pub fn new( name: String, @@ -121,6 +127,7 @@ impl PollStatus { PollStatus::Open => "open".to_string(), PollStatus::Closed => "closed".to_string(), PollStatus::Stopped => "stopped".to_string(), + PollStatus::Creating => "creating".to_string(), } } } @@ -221,7 +228,7 @@ impl RunnerFn for PollCommand { let command_runner = command_suite(command_name); - let response = command_runner.run(&args); + let response = command_runner.run(args.clone()); match response.await { Ok(response) => match response.to_owned() { @@ -254,6 +261,7 @@ pub fn get_command() -> Command { ArgumentsLevel::Context, ArgumentsLevel::Guild, ArgumentsLevel::User, + ArgumentsLevel::ChannelId, ], Box::new(PollCommand), ) diff --git a/src/commands/poll/setup/create.rs b/src/commands/poll/setup/create.rs index 9b317d4..6ab8dc0 100644 --- a/src/commands/poll/setup/create.rs +++ b/src/commands/poll/setup/create.rs @@ -1,23 +1,98 @@ use crate::{ commands::{ - poll::{utils::progress_bar, PollDatabaseModel}, + poll::{utils::progress_bar, PartialPoll, PollDatabaseModel as Poll, PollType}, ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, }, components::button::Button, + internal::debug::{log_message, MessageTypes}, }; use serenity::{ async_trait, builder::{CreateApplicationCommandOption, CreateEmbed, EditInteractionResponse}, framework::standard::CommandResult, - model::prelude::{command::CommandOptionType, component::ButtonStyle}, + model::{ + prelude::{ + application_command::CommandDataOption, command::CommandOptionType, + component::ButtonStyle, ChannelId, + }, + user::User, + }, + prelude::Context, }; struct CreatePollRunner; #[async_trait] impl RunnerFn for CreatePollRunner { - async fn run<'a>(&self, _: &Vec>) -> InternalCommandResult<'a> { + async fn run<'a>( + &self, + args: &Vec>, + ) -> InternalCommandResult<'a> { + let options = args + .iter() + .filter_map(|arg| arg.downcast_ref::>()) + .collect::>>(); + let subcommand_options = &options.get(0).unwrap().get(0).unwrap().options; + + let poll_name = subcommand_options + .iter() + .find(|option| option.name == "poll_name") + .unwrap() + .value + .as_ref() + .unwrap() + .as_str() + .unwrap(); + let poll_description = subcommand_options + .iter() + .find(|option| option.name == "poll_description") + .unwrap() + .value + .as_ref() + .unwrap() + .as_str() + .unwrap(); + let ctx = args + .iter() + .find_map(|arg| arg.downcast_ref::()) + .unwrap(); + let channel_id = args + .iter() + .find_map(|arg| arg.downcast_ref::()) + .unwrap(); + let user_id = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>() + .get(0) + .unwrap() + .id; + + // Create thread + let thread_channel = channel_id + .create_private_thread(ctx.http.clone(), |thread| thread.name(poll_name)) + .await?; + + thread_channel + .id + .add_thread_member(ctx.http.clone(), user_id) + .await?; + + // Create partial poll + let partial_poll = PartialPoll { + name: poll_name.to_string(), + description: Some(poll_description.to_string()), + created_by: user_id.clone(), + kind: PollType::SingleChoice, + thread_id: thread_channel.id, + }; + + log_message( + format!("Partial poll: {:?}", partial_poll).as_str(), + MessageTypes::Debug, + ); + Ok(CommandResponse::None) } } @@ -48,9 +123,9 @@ impl RunnerFn for CreatePollRunner { fn vote_interaction() {} -fn create_message( +fn create_embed_poll( mut message_builder: EditInteractionResponse, - poll: PollDatabaseModel, + poll: Poll, ) -> CommandResult { let time_remaining = match poll.timer.as_secs() / 60 > 1 { true => format!("{} minutes", poll.timer.as_secs() / 60), @@ -149,7 +224,14 @@ pub fn get_command() -> Command { "setup", "Setup a poll", CommandCategory::Misc, - vec![ArgumentsLevel::User], + vec![ + ArgumentsLevel::Options, + ArgumentsLevel::Context, + ArgumentsLevel::Guild, + ArgumentsLevel::User, + ArgumentsLevel::ChannelId, + ArgumentsLevel::InteractionId, + ], Box::new(CreatePollRunner), ) } diff --git a/src/main.rs b/src/main.rs index 85c6d6a..1147b2c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -127,7 +127,15 @@ impl EventHandler for Handler { ); } - command.defer(&ctx.http.clone()).await.unwrap(); + match command.defer(&ctx.http.clone()).await { + Ok(_) => {} + Err(why) => { + log_message( + format!("Cannot defer slash command: {}", why).as_str(), + MessageTypes::Error, + ); + } + } let registered_commands = collect_commands(); @@ -150,6 +158,7 @@ impl EventHandler for Handler { &command.user, &command.data.options, &command.id, + &command.channel_id, )) .await; From 2daa6fb9ce4a16925e067610a3d874fd655dc4f3 Mon Sep 17 00:00:00 2001 From: "Kszinhu@POP_os" Date: Fri, 25 Aug 2023 00:42:15 -0300 Subject: [PATCH 04/17] feat: add create modal with input_texts --- Cargo.toml | 1 + public/locales/en-US.yml | 9 + public/locales/pt-BR.yml | 12 + src/commands/poll/mod.rs | 2 +- src/commands/poll/setup/create.rs | 292 ++++++++++++++++-------- src/commands/poll/setup/embeds/mod.rs | 2 + src/commands/poll/setup/embeds/setup.rs | 27 +++ src/commands/poll/setup/embeds/vote.rs | 75 ++++++ src/commands/poll/setup/mod.rs | 1 + src/main.rs | 2 +- 10 files changed, 321 insertions(+), 102 deletions(-) create mode 100644 src/commands/poll/setup/embeds/mod.rs create mode 100644 src/commands/poll/setup/embeds/setup.rs create mode 100644 src/commands/poll/setup/embeds/vote.rs diff --git a/Cargo.toml b/Cargo.toml index 1e74546..9a492b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ serenity = { default-features = false, features = [ "voice", "rustls_backend", "model", + "collector", ], version = "*" } songbird = { version = "*" } tokio = { version = "*", features = ["macros", "rt-multi-thread", "signal"] } diff --git a/public/locales/en-US.yml b/public/locales/en-US.yml index 61db4b1..6852daa 100644 --- a/public/locales/en-US.yml +++ b/public/locales/en-US.yml @@ -43,8 +43,17 @@ commands: label: Management description: Manage polls setup: + response: Starting the configuration of a voting on the channel <#%{channel_id}> Successful label: Setup description: Setup a poll + embed: + description_none: Without description + description: "Use the following commands:\n + \n- `/poll setup Finish` to finish the configuration + \n- `/Poll Setup Cancel` to cancel the configuration" + fields: + options: Poll options + footer: Use the command `/poll help` for more information help: label: Help description: Show help message for poll commands diff --git a/public/locales/pt-BR.yml b/public/locales/pt-BR.yml index b531dfa..4ccd59f 100644 --- a/public/locales/pt-BR.yml +++ b/public/locales/pt-BR.yml @@ -43,8 +43,20 @@ commands: label: Gerenciar description: Gerencia uma votação setup: + response: + initial: Iniciada a configuração de uma votação no canal <#%{channel_id}> com sucesso + success: Votação configurada com sucesso e está disponível no canal <#%{channel_id}> label: Configurar description: Configura uma votação + embed: + description_none: Sem descrição + description: "Use os seguintes comandos:\n\n + - `/poll options` para cadastrar as novas opções\n + - `/poll setup finish` para finalizar a configuração\n + - `/poll setup cancel` para cancelar a configuração\n" + fields: + options: Opções da votação + footer: Use o comando `/poll help` para mais informações help: label: Ajuda description: Exibe mensagem de ajuda para os comandos de votação diff --git a/src/commands/poll/mod.rs b/src/commands/poll/mod.rs index 2df9330..4c1b726 100644 --- a/src/commands/poll/mod.rs +++ b/src/commands/poll/mod.rs @@ -68,7 +68,7 @@ pub struct PollDatabaseModel { pub created_by: UserId, } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct PartialPoll { pub thread_id: ChannelId, pub name: String, diff --git a/src/commands/poll/setup/create.rs b/src/commands/poll/setup/create.rs index 6ab8dc0..f631c23 100644 --- a/src/commands/poll/setup/create.rs +++ b/src/commands/poll/setup/create.rs @@ -1,25 +1,30 @@ +use super::embeds; use crate::{ commands::{ - poll::{utils::progress_bar, PartialPoll, PollDatabaseModel as Poll, PollType}, + poll::{PartialPoll, PollType}, ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, }, - components::button::Button, internal::debug::{log_message, MessageTypes}, }; +use rust_i18n::t; use serenity::{ async_trait, - builder::{CreateApplicationCommandOption, CreateEmbed, EditInteractionResponse}, - framework::standard::CommandResult, + builder::CreateApplicationCommandOption, + futures::StreamExt, model::{ prelude::{ - application_command::CommandDataOption, command::CommandOptionType, - component::ButtonStyle, ChannelId, + application_command::CommandDataOption, + command::CommandOptionType, + component::{ButtonStyle, InputTextStyle}, + modal::ModalSubmitInteraction, + ChannelId, InteractionResponseType, }, user::User, }, prelude::Context, }; +use std::time::Duration; struct CreatePollRunner; @@ -79,6 +84,173 @@ impl RunnerFn for CreatePollRunner { .add_thread_member(ctx.http.clone(), user_id) .await?; + // Setup poll + let mut message = thread_channel + .send_message(&ctx.http, |message| { + let embed = embeds::setup::embed( + poll_name.to_string(), + Some(poll_description.to_string()), + user_id.clone(), + ) + .unwrap(); + + message.set_embed(embed) + }) + .await?; + + // Add buttons (kind) + message + .edit(&ctx.http, |message| { + message.components(|components| { + components.create_action_row(|action_row| { + action_row + .create_button(|button| { + button + .style(ButtonStyle::Primary) + .label("Single choice") + .custom_id("single_choice") + }) + .create_button(|button| { + button + .style(ButtonStyle::Primary) + .label("Multiple choice") + .custom_id("multiple_choice") + }) + }) + }) + }) + .await?; + + let mut interaction_stream = message + .await_component_interactions(&ctx) + .timeout(Duration::from_secs(60 * 3)) + .build(); + + while let Some(interaction) = interaction_stream.next().await { + let interaction_id = interaction.data.custom_id.as_str(); + let interaction_user = interaction.user.clone(); + + if interaction_user.id != user_id { + match interaction + .create_interaction_response(&ctx.http, |response| { + response.kind(InteractionResponseType::DeferredUpdateMessage) + }) + .await + { + Ok(_) => {} + Err(_) => { + log_message("Failed to defer update message", MessageTypes::Error); + } + } + } + + match interaction_id { + "single_choice" => { + match interaction + .create_interaction_response(&ctx.http, |response| { + response.kind(InteractionResponseType::Modal); + + response.interaction_response_data(|message| { + message + .title("Single choice") + .custom_id("option_data") + .components(|components| { + components + .create_action_row(|action_row| { + action_row.create_input_text(|input| { + input + .custom_id("option_name") + .required(true) + .label("Name of the option") + .placeholder("Insert a name") + .style(InputTextStyle::Short) + }) + }) + .create_action_row(|action_row| { + action_row.create_input_text(|input| { + input + .custom_id("option_description") + .required(true) + .label("Description of the option") + .placeholder("Insert a description") + .style(InputTextStyle::Paragraph) + }) + }) + }) + }) + }) + .await + { + Ok(_) => {} + Err(why) => { + log_message( + &format!("Failed to create interaction response: {}", why), + MessageTypes::Error, + ); + } + } + } + "multiple_choice" => { + match interaction + .create_interaction_response(&ctx.http, |response| { + response.kind(InteractionResponseType::Modal); + + response.interaction_response_data(|message| { + message + .title("Single choice") + .custom_id("option_data") + .components(|components| { + components + .create_action_row(|action_row| { + action_row.create_input_text(|input| { + input + .custom_id("option_name") + .required(true) + .label("Name of the option") + .placeholder("Insert a name") + .style(InputTextStyle::Short) + }) + }) + .create_action_row(|action_row| { + action_row.create_input_text(|input| { + input + .custom_id("option_description") + .required(true) + .label("Description of the option") + .placeholder("Insert a description") + .style(InputTextStyle::Paragraph) + }) + }) + }) + }) + }) + .await + { + Ok(_) => {} + Err(why) => { + log_message( + &format!("Failed to create interaction response: {}", why), + MessageTypes::Error, + ); + } + } + } + + _ => { + log_message( + format!("Unknown interaction id: {}", interaction_id).as_str(), + MessageTypes::Error, + ); + + interaction + .create_interaction_response(&ctx.http, |response| { + response.kind(InteractionResponseType::DeferredUpdateMessage) + }) + .await?; + } + } + } + // Create partial poll let partial_poll = PartialPoll { name: poll_name.to_string(), @@ -88,104 +260,24 @@ impl RunnerFn for CreatePollRunner { thread_id: thread_channel.id, }; - log_message( - format!("Partial poll: {:?}", partial_poll).as_str(), - MessageTypes::Debug, - ); - - Ok(CommandResponse::None) + Ok(CommandResponse::String( + t!("commands.poll.setup.response.success", "channel_id" => thread_channel.id.to_string()), + )) } } -// fn create_interaction() { -// // Wait for multiple interactions -// let mut interaction_stream = -// m.await_component_interactions(&ctx).timeout(Duration::from_secs(60 * 3)).build(); - -// while let Some(interaction) = interaction_stream.next().await { -// let sound = &interaction.data.custom_id; -// // Acknowledge the interaction and send a reply -// interaction -// .create_interaction_response(&ctx, |r| { -// // This time we dont edit the message but reply to it -// r.kind(InteractionResponseType::ChannelMessageWithSource) -// .interaction_response_data(|d| { -// // Make the message hidden for other users by setting `ephemeral(true)`. -// d.ephemeral(true) -// .content(format!("The **{}** says __{}__", animal, sound)) -// }) -// }) -// .await -// .unwrap(); -// } -// m.delete(&ctx).await?; -// } - -fn vote_interaction() {} - -fn create_embed_poll( - mut message_builder: EditInteractionResponse, - poll: Poll, -) -> CommandResult { - let time_remaining = match poll.timer.as_secs() / 60 > 1 { - true => format!("{} minutes", poll.timer.as_secs() / 60), - false => format!("{} seconds", poll.timer.as_secs()), - }; - let mut embed = CreateEmbed::default(); - embed - .title(poll.name) - .description(poll.description.unwrap_or("".to_string())); - - // first row (id, status, user) - embed.field( - "ID", - format!("`{}`", poll.id.to_string().split_at(8).0), - true, - ); - embed.field("Status", poll.status.to_string(), true); - embed.field("User", format!("<@{}>", poll.created_by), true); - - // separator - embed.field("\u{200B}", "\u{200B}", false); - - poll.options.iter().for_each(|option| { - embed.field(option, option, false); - }); - - // separator - embed.field("\u{200B}", "\u{200B}", false); - - embed.field( - "Partial Results (Live)", - format!( - "```diff\n{}\n```", - progress_bar(poll.votes, poll.options.clone()) - ), - false, - ); - - // separator - embed.field("\u{200B}", "\u{200B}", false); - - embed.field( - "Time remaining", - format!("{} remaining", time_remaining), - false, - ); - - message_builder.set_embed(embed); - message_builder.components(|component| { - component.create_action_row(|action_row| { - poll.options.iter().for_each(|option| { - action_row - .add_button(Button::new(option, option, ButtonStyle::Primary, None).create()); - }); - - action_row +pub async fn handle_modal(ctx: &Context, command: &ModalSubmitInteraction) { + if let Err(why) = command + .create_interaction_response(&ctx.http, |m| { + m.kind(InteractionResponseType::DeferredUpdateMessage) }) - }); - - Ok(message_builder) + .await + { + log_message( + &format!("Failed to create interaction response: {}", why), + MessageTypes::Error, + ); + } } pub fn register_option<'a>() -> CreateApplicationCommandOption { diff --git a/src/commands/poll/setup/embeds/mod.rs b/src/commands/poll/setup/embeds/mod.rs new file mode 100644 index 0000000..de49d68 --- /dev/null +++ b/src/commands/poll/setup/embeds/mod.rs @@ -0,0 +1,2 @@ +pub mod vote; +pub mod setup; \ No newline at end of file diff --git a/src/commands/poll/setup/embeds/setup.rs b/src/commands/poll/setup/embeds/setup.rs new file mode 100644 index 0000000..4b03df5 --- /dev/null +++ b/src/commands/poll/setup/embeds/setup.rs @@ -0,0 +1,27 @@ +use rust_i18n::t; +use serenity::{builder::CreateEmbed, framework::standard::CommandResult, model::prelude::UserId}; + +pub fn embed( + name: String, + description: Option, + created_by: UserId, +) -> CommandResult { + let mut embed = CreateEmbed::default(); + embed + .title(name) + .description(t!("commands.poll.setup.embed.description").as_str()); + + // first row (id, status, user) + embed.field("User", format!("<@{}>", created_by), true); + + // separator + embed.field("\u{200B}", "\u{200B}", false); + + embed.field( + "Description", + description.unwrap_or(t!("commands.poll.setup.embed.description_none")), + false, + ); + + Ok(embed) +} diff --git a/src/commands/poll/setup/embeds/vote.rs b/src/commands/poll/setup/embeds/vote.rs new file mode 100644 index 0000000..fd1f159 --- /dev/null +++ b/src/commands/poll/setup/embeds/vote.rs @@ -0,0 +1,75 @@ +use serenity::{ + builder::{CreateEmbed, EditInteractionResponse}, + framework::standard::CommandResult, + model::prelude::component::ButtonStyle, +}; + +use crate::{ + commands::poll::{utils::progress_bar, PollDatabaseModel as Poll}, + components::button::Button, +}; + +pub fn embed( + mut message_builder: EditInteractionResponse, + poll: Poll, +) -> CommandResult { + let time_remaining = match poll.timer.as_secs() / 60 > 1 { + true => format!("{} minutes", poll.timer.as_secs() / 60), + false => format!("{} seconds", poll.timer.as_secs()), + }; + let mut embed = CreateEmbed::default(); + embed + .title(poll.name) + .description(poll.description.unwrap_or("".to_string())); + + // first row (id, status, user) + embed.field( + "ID", + format!("`{}`", poll.id.to_string().split_at(8).0), + true, + ); + embed.field("Status", poll.status.to_string(), true); + embed.field("User", format!("<@{}>", poll.created_by), true); + + // separator + embed.field("\u{200B}", "\u{200B}", false); + + poll.options.iter().for_each(|option| { + embed.field(option, option, false); + }); + + // separator + embed.field("\u{200B}", "\u{200B}", false); + + embed.field( + "Partial Results (Live)", + format!( + "```diff\n{}\n```", + progress_bar(poll.votes, poll.options.clone()) + ), + false, + ); + + // separator + embed.field("\u{200B}", "\u{200B}", false); + + embed.field( + "Time remaining", + format!("{} remaining", time_remaining), + false, + ); + + message_builder.set_embed(embed); + message_builder.components(|component| { + component.create_action_row(|action_row| { + poll.options.iter().for_each(|option| { + action_row + .add_button(Button::new(option, option, ButtonStyle::Primary, None).create()); + }); + + action_row + }) + }); + + Ok(message_builder) +} diff --git a/src/commands/poll/setup/mod.rs b/src/commands/poll/setup/mod.rs index cedc0cd..127ef7f 100644 --- a/src/commands/poll/setup/mod.rs +++ b/src/commands/poll/setup/mod.rs @@ -2,6 +2,7 @@ use serenity::builder::CreateApplicationCommand; pub mod create; pub mod options; +pub mod embeds; /** * commands: diff --git a/src/main.rs b/src/main.rs index 1147b2c..b02a95a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -119,7 +119,7 @@ impl EventHandler for Handler { if debug { log_message( format!( - "Received command {} interaction from User: {:#?}", + "Received command \"{}\" interaction from User: {:#?}", command.data.name, command.user.name ) .as_str(), From 5bcff5853c951d91a2a030fbabfe2549037983e0 Mon Sep 17 00:00:00 2001 From: "Kszinhu@POP_os" Date: Wed, 27 Dec 2023 15:57:44 -0300 Subject: [PATCH 05/17] feat: add default equalizer and something about poll command --- .gitignore | 9 + .idea/.gitignore | 8 + .idea/bostil-bot.iml | 11 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + .vscode/settings.json | 3 + README.md | 1 + public/locales/en-US.yml | 1 + public/locales/pt-BR.yml | 1 + src/commands/mod.rs | 84 +---- src/commands/poll/database.rs | 76 ++-- src/commands/poll/management/mod.rs | 1 + src/commands/poll/mod.rs | 100 +++--- src/commands/poll/setup/create.rs | 142 +++++--- src/commands/poll/setup/embeds/setup.rs | 20 +- src/commands/poll/setup/embeds/vote.rs | 6 +- src/commands/radio/consumer.rs | 31 +- src/commands/radio/mod.rs | 32 +- src/commands/voice/leave.rs | 5 +- src/database/mod.rs | 2 +- src/interactions/chat/love.rs | 22 +- src/interactions/mod.rs | 47 +-- src/interactions/modal/mod.rs | 1 + src/interactions/modal/poll_option.rs | 103 ++++++ src/internal/arguments.rs | 90 +++++ src/internal/mod.rs | 1 + src/lib.rs | 1 + src/main.rs | 458 ++++++++++++++---------- src/modules/equalizers.rs | 156 ++++++++ src/modules/mod.rs | 1 + 30 files changed, 995 insertions(+), 432 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/bostil-bot.iml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 .vscode/settings.json create mode 100644 src/interactions/modal/mod.rs create mode 100644 src/interactions/modal/poll_option.rs create mode 100644 src/internal/arguments.rs create mode 100644 src/modules/equalizers.rs create mode 100644 src/modules/mod.rs diff --git a/.gitignore b/.gitignore index 9c3b798..3acbde5 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,12 @@ Cargo.lock # Database folder public/database/ + +# Jetbrains IDEs +.idea/ +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/bostil-bot.iml b/.idea/bostil-bot.iml new file mode 100644 index 0000000..cf84ae4 --- /dev/null +++ b/.idea/bostil-bot.iml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..278dfe2 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..77cc3a7 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "rust-analyzer.linkedProjects": ["./Cargo.toml", "./Cargo.toml"] +} diff --git a/README.md b/README.md index 3bb7504..56907d6 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ Created for fun and to learn more about the Rust language. - [x] Radio [89 FM][89-fm-url] - [x] Radio [88.3 FM][perderneiras-fm-url] - [ ] Add noise to the audio (like a radio) +- [ ] Recording audio only from a "SCALIZA" user ### Integrations diff --git a/public/locales/en-US.yml b/public/locales/en-US.yml index 6852daa..7f0e498 100644 --- a/public/locales/en-US.yml +++ b/public/locales/en-US.yml @@ -47,6 +47,7 @@ commands: label: Setup description: Setup a poll embed: + id_none: Without ID description_none: Without description description: "Use the following commands:\n \n- `/poll setup Finish` to finish the configuration diff --git a/public/locales/pt-BR.yml b/public/locales/pt-BR.yml index 4ccd59f..96f5b9b 100644 --- a/public/locales/pt-BR.yml +++ b/public/locales/pt-BR.yml @@ -49,6 +49,7 @@ commands: label: Configurar description: Configura uma votação embed: + id_none: Sem ID description_none: Sem descrição description: "Use os seguintes comandos:\n\n - `/poll options` para cadastrar as novas opções\n diff --git a/src/commands/mod.rs b/src/commands/mod.rs index ee9373a..725c490 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,19 +1,14 @@ -use std::{ - any::Any, - ops::{Deref, DerefMut}, -}; +use std::any::Any; use serenity::{ async_trait, - builder::{CreateEmbed, CreateInteractionResponseData, EditInteractionResponse}, + builder::{CreateEmbed, CreateInteractionResponseData}, framework::standard::CommandResult, - model::{ - prelude::{application_command::CommandDataOption, ChannelId, Embed, Guild, InteractionId}, - user::User, - }, - prelude::Context, + model::prelude::Embed, }; +use crate::internal::arguments::ArgumentsLevel; + pub mod jingle; pub mod language; pub mod ping; @@ -32,34 +27,6 @@ pub enum CommandCategory { General, } -/** - Arguments to provide to a run function - - None: No arguments - - Value: 0 - - Options: options (&command.data.options) - - Value: 1 - - Context: context (&context) - - Value: 2 - - Guild: guild (&guild) - - Value: 3 - - User: user (&user) - - Value: 4 - - InteractionId: interaction_id (&interaction_id) - - Value: 5 - - ChannelId: channel_id (&channel_id) - - Value: 6 -*/ -#[derive(Debug, Clone, Copy)] -pub enum ArgumentsLevel { - None, - Options, - Context, - Guild, - User, - InteractionId, - ChannelId, -} - pub struct Command { pub name: String, pub description: String, @@ -92,47 +59,6 @@ impl Command { } } -impl ArgumentsLevel { - pub fn value(&self) -> u8 { - match self { - ArgumentsLevel::None => 0, - ArgumentsLevel::Options => 1, - ArgumentsLevel::Context => 2, - ArgumentsLevel::Guild => 3, - ArgumentsLevel::User => 4, - ArgumentsLevel::InteractionId => 5, - ArgumentsLevel::ChannelId => 6, - } - } - - // function to provide the arguments to the run function - pub fn provide( - command: &Command, - context: &Context, - guild: &Guild, - user: &User, - options: &Vec, - interaction_id: &InteractionId, - channel_id: &ChannelId, - ) -> Vec> { - let mut arguments: Vec> = vec![]; - - for argument in &command.arguments { - match argument { - ArgumentsLevel::None => (), - ArgumentsLevel::Options => arguments.push(Box::new(options.clone())), - ArgumentsLevel::Context => arguments.push(Box::new(context.clone())), - ArgumentsLevel::Guild => arguments.push(Box::new(guild.clone())), - ArgumentsLevel::User => arguments.push(Box::new(user.clone())), - ArgumentsLevel::InteractionId => arguments.push(Box::new(interaction_id.clone())), - ArgumentsLevel::ChannelId => arguments.push(Box::new(channel_id.clone())), - } - } - - arguments - } -} - #[derive(Debug, Clone)] pub enum CommandResponse<'a> { String(String), diff --git a/src/commands/poll/database.rs b/src/commands/poll/database.rs index 016eb53..ae40ce0 100644 --- a/src/commands/poll/database.rs +++ b/src/commands/poll/database.rs @@ -1,10 +1,15 @@ use std::borrow::BorrowMut; -use super::{PartialPoll, Poll, PollDatabaseModel as PollModel, PollStatus, PollType, Vote}; -use crate::database::{get_database, save_database, GuildDatabaseModel}; -use crate::internal::debug::{log_message, MessageTypes}; +use super::{Poll, PollDatabaseModel as PollModel, PollStatus, PollType, Vote}; +use crate::{ + database::{get_database, save_database, GuildDatabaseModel}, + internal::debug::{log_message, MessageTypes}, +}; -use serenity::model::prelude::{ChannelId, GuildChannel, GuildId, UserId}; +use serenity::model::{ + id::MessageId, + prelude::{ChannelId, GuildId, UserId}, +}; use yaml_rust::Yaml; impl PollModel { @@ -13,18 +18,19 @@ impl PollModel { votes: Vec, user_id: &UserId, thread_id: &ChannelId, + message_id: &MessageId, ) -> PollModel { PollModel { votes, id: poll.id, kind: poll.kind, - timer: poll.timer, + timer: Some(poll.timer), status: poll.status, name: poll.name.clone(), description: poll.description.clone(), options: poll.options.clone(), thread_id: thread_id.clone(), - partial: false, + message_id: message_id.clone(), created_at: std::time::SystemTime::now(), created_by: user_id.clone(), } @@ -50,7 +56,10 @@ impl PollModel { .iter() .map(|option| option.as_str().unwrap().to_string()) .collect::>(), - timer: std::time::Duration::from_secs(yaml["timer"].as_i64().unwrap() as u64), + timer: Some(std::time::Duration::from_secs( + yaml["timer"].as_i64().unwrap() as u64, + )), + message_id: MessageId(yaml["message_id"].as_i64().unwrap() as u64), thread_id: ChannelId(yaml["thread_id"].as_i64().unwrap().try_into().unwrap()), created_at: std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(yaml["created_at"].as_i64().unwrap() as u64), @@ -60,27 +69,45 @@ impl PollModel { "stopped" => PollStatus::Stopped, _ => PollStatus::Open, }, - partial: yaml["partial"].as_bool().unwrap(), created_by: UserId(yaml["created_by"].as_i64().unwrap() as u64), } } - fn from_partial_poll(partial_poll: &PartialPoll) -> PollModel { - let poll = Poll::new( - partial_poll.name.clone(), - partial_poll.description.clone(), - partial_poll.kind, - vec![], - None, - Some(PollStatus::Creating), - ); + pub fn save(&self, guild_id: GuildId) { + let database = get_database(); + + match database.lock().unwrap().guilds.get_mut(&guild_id) { + Some(guild) => { + let poll = guild + .polls + .iter_mut() + .find(|poll| poll.id == self.id) + .unwrap(); + + *poll = self.clone(); + + save_database(database.lock().unwrap().borrow_mut()); + + log_message("Poll saved", MessageTypes::Success); + } + None => { + log_message("Guild not found in database", MessageTypes::Failed); + } + } + + if let Some(guild) = database.lock().unwrap().guilds.get_mut(&guild_id) { + guild.polls.push(self.clone()); + } else { + database.lock().unwrap().guilds.insert( + guild_id, + GuildDatabaseModel { + locale: "en-US".to_string(), + polls: vec![self.clone()], + }, + ); + } - PollModel::from( - &poll, - vec![], - &partial_poll.created_by, - &partial_poll.thread_id, - ) + save_database(database.lock().unwrap().borrow_mut()); } } @@ -88,11 +115,12 @@ pub fn save_poll( guild_id: GuildId, user_id: &UserId, thread_id: &ChannelId, + message_id: &MessageId, poll: &Poll, votes: Vec, ) { let database = get_database(); - let poll_model = PollModel::from(poll, votes, user_id, thread_id); + let poll_model = PollModel::from(poll, votes, user_id, thread_id, message_id); if let Some(guild) = database.lock().unwrap().guilds.get_mut(&guild_id) { guild.polls.push(poll_model); diff --git a/src/commands/poll/management/mod.rs b/src/commands/poll/management/mod.rs index e69de29..8b13789 100644 --- a/src/commands/poll/management/mod.rs +++ b/src/commands/poll/management/mod.rs @@ -0,0 +1 @@ + diff --git a/src/commands/poll/mod.rs b/src/commands/poll/mod.rs index 4c1b726..544c53c 100644 --- a/src/commands/poll/mod.rs +++ b/src/commands/poll/mod.rs @@ -1,3 +1,5 @@ +use crate::database::{get_database, Database}; + use super::{ ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, }; @@ -6,9 +8,12 @@ use regex::Regex; use rust_i18n::t; use serenity::{ async_trait, - model::prelude::{ - application_command::{CommandDataOption, CommandDataOptionValue}, - ChannelId, UserId, + model::{ + id::{GuildId, MessageId}, + prelude::{ + application_command::{CommandDataOption, CommandDataOptionValue}, + ChannelId, UserId, + }, }, }; use std::time::{Duration, SystemTime}; @@ -21,7 +26,7 @@ mod utils; struct PollCommand; -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct Vote { pub user_id: UserId, pub options: Vec, @@ -52,29 +57,34 @@ pub struct Poll { status: PollStatus, } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct PollDatabaseModel { pub id: uuid::Uuid, pub name: String, pub description: Option, pub kind: PollType, pub status: PollStatus, + pub timer: Option, pub options: Vec, - pub timer: Duration, pub votes: Vec, - pub partial: bool, pub thread_id: ChannelId, + pub message_id: MessageId, pub created_at: SystemTime, pub created_by: UserId, } -#[derive(Debug, Clone)] -pub struct PartialPoll { - pub thread_id: ChannelId, - pub name: String, - pub description: Option, - pub kind: PollType, - pub created_by: UserId, +impl PollDatabaseModel { + pub fn from_id(id: uuid::Uuid) -> PollDatabaseModel { + let database_manager = get_database(); + let database = database_manager.lock().unwrap(); + + let poll = database + .guilds + .iter() + .find_map(|(_, guild)| guild.polls.iter().find(|poll| poll.id == id)); + + poll.unwrap().clone() + } } impl Poll { @@ -103,6 +113,35 @@ impl Poll { }, } } + + pub fn save( + &self, + user_id: UserId, + channel_id: ChannelId, + guild_id: GuildId, + message_id: MessageId, + ) { + let poll = PollDatabaseModel { + message_id, + id: self.id, + name: self.name.clone(), + description: self.description.clone(), + kind: self.kind, + status: self.status, + options: self.options.clone(), + timer: Some(self.timer), + votes: vec![], + thread_id: channel_id, + created_at: SystemTime::now(), + created_by: user_id, + }; + + poll.save(guild_id); + } + + pub fn from_id(id: uuid::Uuid) -> PollDatabaseModel { + PollDatabaseModel::from_id(id) + } } impl PollType { @@ -188,31 +227,6 @@ fn poll_serializer(command_options: &Vec) -> Poll { ) } -// TODO: timer to close poll -// fn create_interaction() { -// // Wait for multiple interactions -// let mut interaction_stream = -// m.await_component_interactions(&ctx).timeout(Duration::from_secs(60 * 3)).build(); - -// while let Some(interaction) = interaction_stream.next().await { -// let sound = &interaction.data.custom_id; -// // Acknowledge the interaction and send a reply -// interaction -// .create_interaction_response(&ctx, |r| { -// // This time we dont edit the message but reply to it -// r.kind(InteractionResponseType::ChannelMessageWithSource) -// .interaction_response_data(|d| { -// // Make the message hidden for other users by setting `ephemeral(true)`. -// d.ephemeral(true) -// .content(format!("The **{}** says __{}__", animal, sound)) -// }) -// }) -// .await -// .unwrap(); -// } -// m.delete(&ctx).await?; -// } - #[async_trait] impl RunnerFn for PollCommand { async fn run<'a>( @@ -221,10 +235,12 @@ impl RunnerFn for PollCommand { ) -> InternalCommandResult<'a> { let options = args .iter() - .filter_map(|arg| arg.downcast_ref::>()) - .collect::>>(); + .filter_map(|arg| arg.downcast_ref::>>()) + .collect::>>>()[0] + .as_ref() + .unwrap(); let first_option = options.get(0).unwrap(); - let command_name = first_option.get(0).unwrap().name.clone(); + let command_name = first_option.name.clone(); let command_runner = command_suite(command_name); diff --git a/src/commands/poll/setup/create.rs b/src/commands/poll/setup/create.rs index f631c23..acd2663 100644 --- a/src/commands/poll/setup/create.rs +++ b/src/commands/poll/setup/create.rs @@ -1,7 +1,7 @@ use super::embeds; use crate::{ commands::{ - poll::{PartialPoll, PollType}, + poll::{Poll, PollStatus, PollType}, ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, }, internal::debug::{log_message, MessageTypes}, @@ -36,9 +36,11 @@ impl RunnerFn for CreatePollRunner { ) -> InternalCommandResult<'a> { let options = args .iter() - .filter_map(|arg| arg.downcast_ref::>()) - .collect::>>(); - let subcommand_options = &options.get(0).unwrap().get(0).unwrap().options; + .filter_map(|arg| arg.downcast_ref::>>()) + .collect::>>>()[0] + .as_ref() + .unwrap(); + let subcommand_options = &options[0].options; let poll_name = subcommand_options .iter() @@ -89,8 +91,9 @@ impl RunnerFn for CreatePollRunner { .send_message(&ctx.http, |message| { let embed = embeds::setup::embed( poll_name.to_string(), - Some(poll_description.to_string()), user_id.clone(), + Some(poll_description.to_string()), + None, ) .unwrap(); @@ -146,6 +149,30 @@ impl RunnerFn for CreatePollRunner { match interaction_id { "single_choice" => { + let poll = Poll::new( + poll_name.to_string(), + Some(poll_description.to_string()), + PollType::SingleChoice, + vec![], + None, + Some(PollStatus::Open), + ); + + // Edit message adds poll_id + message + .edit(&ctx.http, |message| { + let embed = embeds::setup::embed( + poll.name.clone(), + user_id.clone(), + poll.description.clone(), + Some(poll.id.clone()), + ) + .unwrap(); + + message.set_embed(embed) + }) + .await?; + match interaction .create_interaction_response(&ctx.http, |response| { response.kind(InteractionResponseType::Modal); @@ -153,7 +180,7 @@ impl RunnerFn for CreatePollRunner { response.interaction_response_data(|message| { message .title("Single choice") - .custom_id("option_data") + .custom_id(&format!("option_data_poll/{}", poll.id)) .components(|components| { components .create_action_row(|action_row| { @@ -191,42 +218,72 @@ impl RunnerFn for CreatePollRunner { } } "multiple_choice" => { + let poll = Poll::new( + poll_name.to_string(), + Some(poll_description.to_string()), + PollType::MultipleChoice, + vec![], + None, + Some(PollStatus::Open), + ); + + // Edit message adds poll_id + message + .edit(&ctx.http, |message| { + let embed = embeds::setup::embed( + poll.name.clone(), + user_id.clone(), + poll.description.clone(), + Some(poll.id.clone()), + ) + .unwrap(); + + message.set_embed(embed) + }) + .await?; + + // Modal for adding options match interaction .create_interaction_response(&ctx.http, |response| { - response.kind(InteractionResponseType::Modal); - - response.interaction_response_data(|message| { - message - .title("Single choice") - .custom_id("option_data") - .components(|components| { - components - .create_action_row(|action_row| { - action_row.create_input_text(|input| { - input - .custom_id("option_name") - .required(true) - .label("Name of the option") - .placeholder("Insert a name") - .style(InputTextStyle::Short) + response + .kind(InteractionResponseType::Modal) + .interaction_response_data(|message| { + message + .title("Add option") + .custom_id(&format!("option_data_poll/{}", poll.id)) + .components(|components| { + components + .create_action_row(|action_row| { + action_row.create_input_text(|input| { + input + .custom_id("option_name") + .required(true) + .label("Name of the option") + .placeholder("Insert a name") + .style(InputTextStyle::Short) + }) }) - }) - .create_action_row(|action_row| { - action_row.create_input_text(|input| { - input - .custom_id("option_description") - .required(true) - .label("Description of the option") - .placeholder("Insert a description") - .style(InputTextStyle::Paragraph) + .create_action_row(|action_row| { + action_row.create_input_text(|input| { + input + .custom_id("option_description") + .required(true) + .label("Description of the option") + .placeholder("Insert a description") + .style(InputTextStyle::Paragraph) + }) }) - }) - }) - }) + }) + }) }) .await { - Ok(_) => {} + Ok(_) => { + log_message( + &format!("Created modal for {}", interaction_id), + MessageTypes::Info, + ); + } Err(why) => { log_message( &format!("Failed to create interaction response: {}", why), @@ -251,15 +308,6 @@ impl RunnerFn for CreatePollRunner { } } - // Create partial poll - let partial_poll = PartialPoll { - name: poll_name.to_string(), - description: Some(poll_description.to_string()), - created_by: user_id.clone(), - kind: PollType::SingleChoice, - thread_id: thread_channel.id, - }; - Ok(CommandResponse::String( t!("commands.poll.setup.response.success", "channel_id" => thread_channel.id.to_string()), )) @@ -303,8 +351,12 @@ pub fn register_option<'a>() -> CreateApplicationCommandOption { .name("poll_description") .name_localized("pt-BR", "descrição_da_votação") .description("The description of the option (max 100 characters)") - .description_localized("pt-BR", "A descrição da votação") + .description_localized( + "pt-BR", + "A descrição dessa opção (máximo de 100 caracteres)", + ) .kind(CommandOptionType::String) + .max_length(100) .required(true) }); diff --git a/src/commands/poll/setup/embeds/setup.rs b/src/commands/poll/setup/embeds/setup.rs index 4b03df5..dce965c 100644 --- a/src/commands/poll/setup/embeds/setup.rs +++ b/src/commands/poll/setup/embeds/setup.rs @@ -1,10 +1,17 @@ use rust_i18n::t; -use serenity::{builder::CreateEmbed, framework::standard::CommandResult, model::prelude::UserId}; +use serenity::{ + builder::CreateEmbed, client::Context, framework::standard::CommandResult, + model::prelude::UserId, +}; +use uuid::Uuid; + +use crate::commands::poll::Poll; pub fn embed( name: String, - description: Option, created_by: UserId, + description: Option, + id: Option, ) -> CommandResult { let mut embed = CreateEmbed::default(); embed @@ -12,6 +19,11 @@ pub fn embed( .description(t!("commands.poll.setup.embed.description").as_str()); // first row (id, status, user) + embed.field( + "ID", + id.map_or(t!("commands.poll.setup.embed.id_none"), |id| id.to_string()), + true, + ); embed.field("User", format!("<@{}>", created_by), true); // separator @@ -25,3 +37,7 @@ pub fn embed( Ok(embed) } + +impl Poll { + pub fn update_message(&self, ctx: Context) {} +} diff --git a/src/commands/poll/setup/embeds/vote.rs b/src/commands/poll/setup/embeds/vote.rs index fd1f159..68fda3c 100644 --- a/src/commands/poll/setup/embeds/vote.rs +++ b/src/commands/poll/setup/embeds/vote.rs @@ -13,9 +13,9 @@ pub fn embed( mut message_builder: EditInteractionResponse, poll: Poll, ) -> CommandResult { - let time_remaining = match poll.timer.as_secs() / 60 > 1 { - true => format!("{} minutes", poll.timer.as_secs() / 60), - false => format!("{} seconds", poll.timer.as_secs()), + let time_remaining = match poll.timer.unwrap().as_secs() / 60 > 1 { + true => format!("{} minutes", poll.timer.unwrap().as_secs() / 60), + false => format!("{} seconds", poll.timer.unwrap().as_secs()), }; let mut embed = CreateEmbed::default(); embed diff --git a/src/commands/radio/consumer.rs b/src/commands/radio/consumer.rs index 2912735..10bfce1 100644 --- a/src/commands/radio/consumer.rs +++ b/src/commands/radio/consumer.rs @@ -1,13 +1,38 @@ use super::Radio; +use crate::{ + internal::debug::{log_message, MessageTypes}, + modules::equalizers::RADIO_EQUALIZER, +}; -use songbird::{input::Input, ytdl}; +use songbird::input::{ffmpeg_optioned, Input}; pub async fn consumer(radio: Radio) -> Result { let url = radio.get_url().unwrap(); - let input = ytdl(&url).await; + let input = ffmpeg_optioned( + &url, + &[], + RADIO_EQUALIZER + .get_filter() + .iter() + .map(|s| s.as_str()) + .collect::>() + .as_slice(), + ) + .await; match input { - Ok(input) => Ok(input), + Ok(input) => { + log_message( + format!( + "Playing radio: {}\n\tWith equalizer: {}", + radio, RADIO_EQUALIZER.name + ) + .as_str(), + MessageTypes::Info, + ); + + Ok(input) + } Err(why) => Err(why.to_string()), } } diff --git a/src/commands/radio/mod.rs b/src/commands/radio/mod.rs index b6ccf38..f92db48 100644 --- a/src/commands/radio/mod.rs +++ b/src/commands/radio/mod.rs @@ -77,35 +77,31 @@ impl std::fmt::Display for Radio { #[async_trait] impl RunnerFn for RadioCommand { - async fn run<'a>(&self, args: &Vec>) -> InternalCommandResult<'a> { + async fn run<'a>( + &self, + args: &Vec>, + ) -> InternalCommandResult<'a> { let ctx = args .iter() .filter_map(|arg| arg.downcast_ref::()) - .collect::>(); + .collect::>()[0]; let guild = args .iter() .filter_map(|arg| arg.downcast_ref::()) - .collect::>(); - let user_id = args + .collect::>()[0]; + let user_id = &args .iter() .filter_map(|arg| arg.downcast_ref::()) - .collect::>() - .get(0) - .unwrap() + .collect::>()[0] .id; let options = args .iter() - .filter_map(|arg| arg.downcast_ref::>()) - .collect::>>(); - - match run( - options.get(0).unwrap(), - ctx.get(0).unwrap(), - guild.get(0).unwrap(), - &user_id, - ) - .await - { + .filter_map(|arg| arg.downcast_ref::>>()) + .collect::>>>()[0] + .as_ref() + .unwrap(); + + match run(options, ctx, guild, user_id).await { Ok(response) => Ok(CommandResponse::String(response)), Err(_) => Ok(CommandResponse::None), } diff --git a/src/commands/voice/leave.rs b/src/commands/voice/leave.rs index 0451aae..d9b534b 100644 --- a/src/commands/voice/leave.rs +++ b/src/commands/voice/leave.rs @@ -8,10 +8,7 @@ use crate::{ use serenity::{ async_trait, builder::CreateApplicationCommand, - model::{ - prelude::{Guild, UserId}, - user::User, - }, + model::{prelude::Guild, user::User}, prelude::Context, }; diff --git a/src/database/mod.rs b/src/database/mod.rs index 619f0cd..9799cb9 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -161,7 +161,7 @@ pub fn save_database(database: &Database) { contents.push_str(&format!(" - \"{}\"\n", option)); } - contents.push_str(&format!(" timer: {}\n", poll.timer.as_secs())); + contents.push_str(&format!(" timer: {}\n", poll.timer.unwrap().as_secs())); contents.push_str(" votes:\n"); diff --git a/src/interactions/chat/love.rs b/src/interactions/chat/love.rs index 51bea24..09b9d65 100644 --- a/src/interactions/chat/love.rs +++ b/src/interactions/chat/love.rs @@ -1,4 +1,5 @@ -use crate::interactions::{CallbackFn, Interaction, InteractionType}; +use crate::interactions::{RunnerFn, Interaction, InteractionType}; +use crate::internal::arguments::ArgumentsLevel; use crate::internal::debug::{log_message, MessageTypes}; use crate::internal::users::USERS; @@ -8,6 +9,7 @@ use rust_i18n::t; use serenity::async_trait; use serenity::client::Context; use serenity::model::prelude::{ChannelId, UserId}; +use serenity::model::user::User; thread_local! { static COUNTER: RefCell = RefCell::new(0); @@ -17,8 +19,21 @@ thread_local! { struct Love {} #[async_trait] -impl CallbackFn for Love { - async fn run(&self, channel: &ChannelId, ctx: &Context, user_id: &UserId) -> () { +impl RunnerFn for Love { + async fn run(&self, args: &Vec>) -> () { + let ctx = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>()[0]; + let channel = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>()[0]; + let user_id = &args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>()[0].id; + match user_id == USERS.get("isadora").unwrap() { true => { let message = COUNTER.with(|counter| { @@ -64,6 +79,7 @@ pub fn get_love_interaction() -> Interaction { "love", "Love me", InteractionType::Chat, + vec![ArgumentsLevel::Context, ArgumentsLevel::ChannelId, ArgumentsLevel::User], Box::new(Love {}), ) } diff --git a/src/interactions/mod.rs b/src/interactions/mod.rs index 7960fdd..5f17128 100644 --- a/src/interactions/mod.rs +++ b/src/interactions/mod.rs @@ -1,39 +1,28 @@ use serenity::async_trait; -use serenity::client::Context; -use serenity::model::prelude::{ChannelId, UserId}; -use crate::internal::debug::{log_message, MessageTypes}; +use crate::internal::arguments::ArgumentsLevel; pub mod chat; +pub mod modal; pub mod voice_channel; -pub fn interaction_callback( - name: &str, - callback: Box, -) -> Box { - log_message( - format!("Running integration {}", name).as_str(), - MessageTypes::Info, - ); - - callback -} - pub enum InteractionType { Chat, + Modal, VoiceChannel, } #[async_trait] -pub trait CallbackFn { - async fn run(&self, channel: &ChannelId, ctx: &Context, user_id: &UserId) -> (); +pub trait RunnerFn { + async fn run(&self, arguments: &Vec>) -> (); } pub struct Interaction { pub name: String, pub description: String, pub interaction_type: InteractionType, - pub callback: Box, + pub arguments: Vec, + pub runner: Box, } impl Interaction { @@ -41,13 +30,21 @@ impl Interaction { name: &str, description: &str, interaction_type: InteractionType, - callback: Box, - ) -> Interaction { - Interaction { + arguments: Vec, + runner: Box, + ) -> Self { + let sorted_arguments = { + let mut sorted_arguments = arguments.clone(); + sorted_arguments.sort_by(|a, b| a.value().cmp(&b.value())); + sorted_arguments + }; + + Self { + runner, + interaction_type, + arguments: sorted_arguments, name: name.to_string(), description: description.to_string(), - interaction_type, - callback: interaction_callback(name, callback), } } } @@ -60,6 +57,10 @@ pub fn get_voice_channel_interactions() -> Vec { vec![] } +pub fn get_modal_interactions() -> Vec { + vec![modal::poll_option::get_poll_option_modal_interaction()] +} + pub async fn get_interactions() -> Vec { let mut interactions = get_chat_interactions(); interactions.append(&mut get_voice_channel_interactions()); diff --git a/src/interactions/modal/mod.rs b/src/interactions/modal/mod.rs new file mode 100644 index 0000000..db0f59b --- /dev/null +++ b/src/interactions/modal/mod.rs @@ -0,0 +1 @@ +pub mod poll_option; diff --git a/src/interactions/modal/poll_option.rs b/src/interactions/modal/poll_option.rs new file mode 100644 index 0000000..75f14c2 --- /dev/null +++ b/src/interactions/modal/poll_option.rs @@ -0,0 +1,103 @@ +use serenity::{ + async_trait, + model::{ + application::{ + component::ActionRowComponent, interaction::modal::ModalSubmitInteractionData, + }, + guild::Guild, + }, +}; + +use crate::{ + commands::poll::Poll, + interactions::{Interaction, InteractionType, RunnerFn}, + internal::{ + arguments::ArgumentsLevel, + debug::{log_message, MessageTypes}, + }, +}; + +struct PollOptionModalReceiver {} + +#[async_trait] +impl RunnerFn for PollOptionModalReceiver { + async fn run(&self, args: &Vec>) -> () { + let guild_id = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>()[0] + .id; + let submit_data = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>()[0]; + + // Step 1: Recover poll data from database + let poll_id = submit_data.custom_id.split("/").collect::>()[1]; + let mut poll = Poll::from_id(poll_id.parse::().unwrap()); + + log_message( + format!("Received interaction with custom_id: {}", poll_id).as_str(), + MessageTypes::Info, + ); + + // Step 2: Get new option to add to poll + let name = + submit_data.components[0] + .components + .iter() + .find_map(|component| match component { + ActionRowComponent::InputText(input) => { + if input.custom_id == "option_name" { + Some(input.value.clone()) + } else { + None + } + } + _ => None, + }); + + let description = submit_data.components[1] + .components + .iter() + .find_map(|component| match component { + ActionRowComponent::InputText(input) => { + if input.custom_id == "option_description" { + Some(input.value.clone()) + } else { + None + } + } + _ => None, + }); + + log_message( + format!("Name: {:?}, Description: {:?}", name, description).as_str(), + MessageTypes::Debug, + ); + + // Step 3: Add new option to poll + poll.options.push(name.unwrap()); + + // Step 4: Save poll to database + poll.save(guild_id) + + // Step 5: Update poll message + + } +} + +pub fn get_poll_option_modal_interaction() -> Interaction { + Interaction::new( + "option_data_poll", + "Save a poll option", + InteractionType::Modal, + vec![ + ArgumentsLevel::User, + ArgumentsLevel::ChannelId, + ArgumentsLevel::Guild, + ArgumentsLevel::ModalSubmitData, + ], + Box::new(PollOptionModalReceiver {}), + ) +} diff --git a/src/internal/arguments.rs b/src/internal/arguments.rs new file mode 100644 index 0000000..052f311 --- /dev/null +++ b/src/internal/arguments.rs @@ -0,0 +1,90 @@ +use std::any::Any; + +use serenity::{ + client::Context, + model::{ + application::interaction::{ + application_command::CommandDataOption, modal::ModalSubmitInteractionData, + }, + guild::Guild, + id::{ChannelId, InteractionId}, + user::User, + }, +}; + +/** + Arguments to provide to a run function + - None: No arguments + - Value: 0 + - Options: options (&command.data.options) + - Value: 1 + - Context: context (&context) + - Value: 2 + - Guild: guild (&guild) + - Value: 3 + - User: user (&user) + - Value: 4 + - InteractionId: interaction_id (&interaction_id) + - Value: 5 + - ChannelId: channel_id (&channel_id) + - Value: 6 + - ModalSubmitInteractionData: modal_submit_data (&modal_submit_data) + - Value: 7 +*/ +#[derive(Debug, Clone, Copy)] +pub enum ArgumentsLevel { + None, + Options, + Context, + Guild, + User, + InteractionId, + ChannelId, + ModalSubmitData, +} + +impl ArgumentsLevel { + pub fn value(&self) -> u8 { + match self { + ArgumentsLevel::None => 0, + ArgumentsLevel::Options => 1, + ArgumentsLevel::Context => 2, + ArgumentsLevel::Guild => 3, + ArgumentsLevel::User => 4, + ArgumentsLevel::InteractionId => 5, + ArgumentsLevel::ChannelId => 6, + ArgumentsLevel::ModalSubmitData => 7, + } + } + + // function to provide the arguments to the run function + pub fn provide( + requested_arguments: &Vec, + context: &Context, + guild: &Guild, + user: &User, + channel_id: &ChannelId, + options: Option>, + interaction_id: Option, + modal_submit_data: Option<&ModalSubmitInteractionData>, + ) -> Vec> { + let mut arguments: Vec> = vec![]; + + for argument in requested_arguments { + match argument { + ArgumentsLevel::None => (), + ArgumentsLevel::Options => arguments.push(Box::new(options.clone())), + ArgumentsLevel::Context => arguments.push(Box::new(context.clone())), + ArgumentsLevel::Guild => arguments.push(Box::new(guild.clone())), + ArgumentsLevel::User => arguments.push(Box::new(user.clone())), + ArgumentsLevel::InteractionId => arguments.push(Box::new(interaction_id.clone())), + ArgumentsLevel::ChannelId => arguments.push(Box::new(channel_id.clone())), + ArgumentsLevel::ModalSubmitData => { + arguments.push(Box::new(modal_submit_data.unwrap().clone())) + } + } + } + + arguments + } +} diff --git a/src/internal/mod.rs b/src/internal/mod.rs index bba4dc8..851fe8a 100644 --- a/src/internal/mod.rs +++ b/src/internal/mod.rs @@ -1,3 +1,4 @@ +pub mod arguments; pub mod constants; pub mod debug; pub mod users; diff --git a/src/lib.rs b/src/lib.rs index 24c9111..0d04117 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,3 +10,4 @@ pub mod events; pub mod integrations; pub mod interactions; pub mod internal; +pub mod modules; diff --git a/src/main.rs b/src/main.rs index b02a95a..e9c5a63 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,11 @@ include!("lib.rs"); use std::sync::Arc; +use std::vec; use std::{borrow::BorrowMut, env}; -use commands::{collect_commands, ArgumentsLevel, CommandResponse}; +use commands::{collect_commands, CommandResponse}; +use internal::arguments::ArgumentsLevel; use serenity::async_trait; use serenity::client::bridge::gateway::ShardManager; use serenity::framework::StandardFramework; @@ -20,6 +22,7 @@ use songbird::SerenityInit; use database::locale::apply_locale; use integrations::get_chat_integrations as integrations; use interactions::get_chat_interactions as chat_interactions; +use interactions::get_modal_interactions as modal_interactions; use interactions::voice_channel::join_channel as voice_channel; use internal::debug::{log_message, MessageTypes}; @@ -33,47 +36,6 @@ struct Handler; #[async_trait] impl EventHandler for Handler { - // On User connect to voice channel - async fn voice_state_update(&self, ctx: Context, old: Option, new: VoiceState) { - let debug: bool = env::var("DEBUG").is_ok(); - - let is_bot: bool = new.user_id.to_user(&ctx.http).await.unwrap().bot; - let has_connected: bool = new.channel_id.is_some() && old.is_none(); - - if has_connected && !is_bot { - if debug { - log_message( - format!( - "User connected to voice channel: {:#?}", - new.channel_id.unwrap().to_string() - ) - .as_str(), - MessageTypes::Debug, - ); - } - - voice_channel::join_channel(&new.channel_id.unwrap(), &ctx, &new.user_id).await; - } - - match old { - Some(old) => { - if old.channel_id.is_some() && new.channel_id.is_none() && !is_bot { - if debug { - log_message( - format!( - "User disconnected from voice channel: {:#?}", - old.channel_id.unwrap().to_string() - ) - .as_str(), - MessageTypes::Debug, - ); - } - } - } - None => {} - } - } - // Each message on the server async fn message(&self, ctx: Context, msg: serenity::model::channel::Message) { let debug: bool = env::var("DEBUG").is_ok(); @@ -89,12 +51,23 @@ impl EventHandler for Handler { let interactions = chat_interactions().into_iter(); for interaction in interactions { - let channel = msg.channel_id; - let user_id = msg.author.id; + let guild = msg.guild_id.unwrap().to_guild_cached(&ctx.cache).unwrap(); match interaction.interaction_type { interactions::InteractionType::Chat => { - let _ = interaction.callback.run(&channel, &ctx, &user_id).await; + let _ = interaction + .runner + .run(&ArgumentsLevel::provide( + &interaction.arguments, + &ctx, + &guild, + &msg.author, + &msg.channel_id, + None, + None, + None, + )) + .await; } _ => {} } @@ -111,143 +84,6 @@ impl EventHandler for Handler { } } - // Slash commands - async fn interaction_create(&self, ctx: Context, interaction: Interaction) { - let debug: bool = env::var("DEBUG").is_ok(); - - if let Interaction::ApplicationCommand(command) = interaction { - if debug { - log_message( - format!( - "Received command \"{}\" interaction from User: {:#?}", - command.data.name, command.user.name - ) - .as_str(), - MessageTypes::Debug, - ); - } - - match command.defer(&ctx.http.clone()).await { - Ok(_) => {} - Err(why) => { - log_message( - format!("Cannot defer slash command: {}", why).as_str(), - MessageTypes::Error, - ); - } - } - - let registered_commands = collect_commands(); - - match registered_commands - .iter() - .enumerate() - .find(|(_, c)| c.name == command.data.name) - { - Some((_, command_interface)) => { - let command_response = command_interface - .runner - .run(&ArgumentsLevel::provide( - &command_interface, - &ctx, - &command - .guild_id - .unwrap() - .to_guild_cached(&ctx.cache) - .unwrap(), - &command.user, - &command.data.options, - &command.id, - &command.channel_id, - )) - .await; - - match command_response { - Ok(command_response) => { - if debug { - log_message( - format!("Responding with: {}", command_response.to_string()) - .as_str(), - MessageTypes::Debug, - ); - } - - if CommandResponse::None != command_response { - if let Err(why) = command - .create_interaction_response( - &ctx.http, - |interaction_response| { - interaction_response - .kind(InteractionResponseType::UpdateMessage) - .interaction_response_data(|response| { - match command_response { - CommandResponse::String(string) => { - response.content(string) - } - CommandResponse::Embed(embed) => response - .set_embed( - CommandResponse::Embed(embed) - .to_embed(), - ), - CommandResponse::Message(message) => { - *response.borrow_mut() = message; - - response - } - CommandResponse::None => response, - } - }) - }, - ) - .await - { - log_message( - format!("Cannot respond to slash command: {}", why) - .as_str(), - MessageTypes::Error, - ); - } - } else { - if debug { - log_message( - format!("Deleting slash command: {}", command.data.name) - .as_str(), - MessageTypes::Debug, - ); - } - - if let Err(why) = command - .delete_original_interaction_response(&ctx.http) - .await - { - log_message( - format!("Cannot respond to slash command: {}", why) - .as_str(), - MessageTypes::Error, - ); - } - } - } - Err(why) => { - log_message( - format!("Cannot run slash command: {}", why).as_str(), - MessageTypes::Error, - ); - } - } - } - None => { - log_message( - format!("Command {} not found", command.data.name).as_str(), - MessageTypes::Error, - ); - } - }; - } - - return (); - } - async fn ready(&self, ctx: Context, ready: Ready) { log_message( format!("Connected on Guilds: {}", ready.guilds.len()).as_str(), @@ -258,7 +94,7 @@ impl EventHandler for Handler { let commands = Command::set_global_application_commands(&ctx.http, |commands| { commands.create_application_command(|command| commands::ping::register(command)) }) - .await; + .await; if let Err(why) = commands { log_message( @@ -288,7 +124,7 @@ impl EventHandler for Handler { commands }) - .await; + .await; apply_locale( &guild @@ -316,7 +152,259 @@ impl EventHandler for Handler { ctx.set_activity(serenity::model::gateway::Activity::playing( "O auxílio emergencial no PIX do Mito", )) - .await; + .await; + } + + // On User connect to voice channel + async fn voice_state_update(&self, ctx: Context, old: Option, new: VoiceState) { + let debug: bool = env::var("DEBUG").is_ok(); + + let is_bot: bool = new.user_id.to_user(&ctx.http).await.unwrap().bot; + let has_connected: bool = new.channel_id.is_some() && old.is_none(); + + if has_connected && !is_bot { + if debug { + log_message( + format!( + "User connected to voice channel: {:#?}", + new.channel_id.unwrap().to_string() + ) + .as_str(), + MessageTypes::Debug, + ); + } + + voice_channel::join_channel(&new.channel_id.unwrap(), &ctx, &new.user_id).await; + } + + match old { + Some(old) => { + if old.channel_id.is_some() && new.channel_id.is_none() && !is_bot { + if debug { + log_message( + format!( + "User disconnected from voice channel: {:#?}", + old.channel_id.unwrap().to_string() + ) + .as_str(), + MessageTypes::Debug, + ); + } + } + } + None => {} + } + } + + // Slash commands + async fn interaction_create(&self, ctx: Context, interaction: Interaction) { + let debug: bool = env::var("DEBUG").is_ok(); + + match interaction { + Interaction::ModalSubmit(submit) => { + submit.defer(&ctx.http.clone()).await.unwrap(); + + if debug { + log_message( + format!( + "Received modal submit interaction from User: {:#?}", + submit.user.name + ) + .as_str(), + MessageTypes::Debug, + ); + } + + let registered_interactions = modal_interactions(); + + // custom_id is in the format: '/' + match registered_interactions.iter().enumerate().find(|(_, i)| { + i.name + == submit + .clone() + .data + .custom_id + .split("/") + .collect::>() + .first() + .unwrap() + .to_string() + }) { + Some((_, interaction)) => { + interaction + .runner + .run(&ArgumentsLevel::provide( + &interaction.arguments, + &ctx, + &submit + .guild_id + .unwrap() + .to_guild_cached(&ctx.cache) + .unwrap(), + &submit.user, + &submit.channel_id, + None, + Some(submit.id), + Some(&submit.data), + )) + .await; + } + + None => { + log_message( + format!( + "Modal submit interaction {} not found", + submit.data.custom_id.split("/").collect::>()[0] + ) + .as_str(), + MessageTypes::Error, + ); + } + }; + } + + Interaction::ApplicationCommand(command) => { + if debug { + log_message( + format!( + "Received command \"{}\" interaction from User: {:#?}", + command.data.name, command.user.name + ) + .as_str(), + MessageTypes::Debug, + ); + } + + match command.defer(&ctx.http.clone()).await { + Ok(_) => {} + Err(why) => { + log_message( + format!("Cannot defer slash command: {}", why).as_str(), + MessageTypes::Error, + ); + } + } + + let registered_commands = collect_commands(); + + match registered_commands + .iter() + .enumerate() + .find(|(_, c)| c.name == command.data.name) + { + Some((_, command_interface)) => { + let command_response = command_interface + .runner + .run(&ArgumentsLevel::provide( + &command_interface.arguments, + &ctx, + &command + .guild_id + .unwrap() + .to_guild_cached(&ctx.cache) + .unwrap(), + &command.user, + &command.channel_id, + Some(command.data.options.clone()), + Some(command.id), + None, + )) + .await; + + match command_response { + Ok(command_response) => { + if debug { + log_message( + format!( + "Responding with: {}", + command_response.to_string() + ) + .as_str(), + MessageTypes::Debug, + ); + } + + if CommandResponse::None != command_response { + if let Err(why) = command + .create_interaction_response( + &ctx.http, + |interaction_response| { + interaction_response + .kind(InteractionResponseType::UpdateMessage) + .interaction_response_data(|response| { + match command_response { + CommandResponse::String(string) => { + response.content(string) + } + CommandResponse::Embed(embed) => { + response.set_embed( + CommandResponse::Embed(embed) + .to_embed(), + ) + } + CommandResponse::Message(message) => { + *response.borrow_mut() = message; + + response + } + CommandResponse::None => response, + } + }) + }, + ) + .await + { + log_message( + format!("Cannot respond to slash command: {}", why) + .as_str(), + MessageTypes::Error, + ); + } + } else { + if debug { + log_message( + format!( + "Deleting slash command: {}", + command.data.name + ) + .as_str(), + MessageTypes::Debug, + ); + } + + if let Err(why) = command + .delete_original_interaction_response(&ctx.http) + .await + { + log_message( + format!("Cannot respond to slash command: {}", why) + .as_str(), + MessageTypes::Error, + ); + } + } + } + Err(why) => { + log_message( + format!("Cannot run slash command: {}", why).as_str(), + MessageTypes::Error, + ); + } + } + } + None => { + log_message( + format!("Command {} not found", command.data.name).as_str(), + MessageTypes::Error, + ); + } + }; + } + + _ => {} + } + + return (); } } diff --git a/src/modules/equalizers.rs b/src/modules/equalizers.rs new file mode 100644 index 0000000..553e386 --- /dev/null +++ b/src/modules/equalizers.rs @@ -0,0 +1,156 @@ +// Equalizer is a struct that represents FFMPEG's equalizer filter. +// ex: equalizer=f=1000:t=q:w=1:g=2,equalizer=f=100:t=q:w=2:g=-5 + +use std::vec; + +use once_cell::sync::Lazy; + +#[derive(Debug, Clone, Copy)] +pub enum Equalizer { + RadioEqualizer, + RockEqualizer, + PopEqualizer, + JazzEqualizer, +} + +#[derive(Clone, Debug)] +pub struct EqualizerFilter { + bands: Vec, + pub name: Equalizer, +} + +#[derive(Debug, Clone, PartialEq)] +struct EqualizerBand { + frequency: f32, + width_type: String, // q, h, o, s, k + width: f32, + gain: f32, +} + +impl EqualizerFilter { + pub fn get_filter(&self) -> Vec { + let default_params = [ + "-f", + "s16le", + "-ac", + "2", + "-ar", + "48000", + "-b:a", + "8k", + "-acodec", + "pcm_f32le", + ] + .map(|s| s.to_string()); + + let mut params = default_params.to_vec(); + let mut equalizer_params = vec![]; + + if self.bands.is_empty() { + return params; + } + + params.push("-af".to_string()); + + for band in &self.bands { + let formatted_string = format!( + "equalizer=f={}:t={}:w={}:g={}", + band.frequency, band.width_type, band.width, band.gain + ); + + equalizer_params.push(formatted_string); + } + + params.push( + // resample + equalizer + format!( + "aresample=8000:resampler=soxr:precision=33:osf=s16:dither_method=triangular,{}", + equalizer_params.join(",") + ), + ); + + params.push("-".to_string()); + + params + } +} + +impl Equalizer { + pub fn get_filter(&self) -> EqualizerFilter { + match self { + Equalizer::RadioEqualizer => RADIO_EQUALIZER.clone(), + _ => RADIO_EQUALIZER.clone(), + } + } +} + +impl std::fmt::Display for Equalizer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Equalizer::RadioEqualizer => write!(f, "Radio Equalizer"), + Equalizer::RockEqualizer => write!(f, "Rock Equalizer"), + Equalizer::PopEqualizer => write!(f, "Pop Equalizer"), + Equalizer::JazzEqualizer => write!(f, "Jazz Equalizer"), + } + } +} + +// ------ +// RadioEqualizer +// ------ +// Bands are based on the following: +// 20Hz = - 30dB +// 35Hz = - 30dB +// 40Hz = - 12dB +// 55Hz = - 3dB +// 60Hz = 0dB +// 4000Hz = 0dB +// 5200Hz = - 3dB + +pub static RADIO_EQUALIZER: Lazy = Lazy::new(|| EqualizerFilter { + name: Equalizer::RadioEqualizer, + bands: vec![ + EqualizerBand { + frequency: 20.0, + width_type: String::from("h"), + width: 1.0, + gain: -30.0, + }, + EqualizerBand { + frequency: 35.0, + width_type: String::from("h"), + width: 1.0, + gain: -30.0, + }, + EqualizerBand { + frequency: 40.0, + width_type: String::from("h"), + width: 1.0, + gain: -12.0, + }, + EqualizerBand { + frequency: 55.0, + width_type: String::from("h"), + width: 1.0, + gain: -3.0, + }, + EqualizerBand { + frequency: 60.0, + width_type: String::from("h"), + width: 1.0, + gain: 0.0, + }, + EqualizerBand { + frequency: 4000.0, + width_type: String::from("h"), + width: 1.0, + gain: 0.0, + }, + EqualizerBand { + frequency: 5200.0, + width_type: String::from("h"), + width: 1.0, + gain: -3.0, + }, + ], +}); diff --git a/src/modules/mod.rs b/src/modules/mod.rs new file mode 100644 index 0000000..c7c5c8d --- /dev/null +++ b/src/modules/mod.rs @@ -0,0 +1 @@ +pub mod equalizers; \ No newline at end of file From d083acc33c428547ffa042ca1da1b96474310ab1 Mon Sep 17 00:00:00 2001 From: "Kszinhu@POP_os" Date: Wed, 10 Jan 2024 19:06:58 -0300 Subject: [PATCH 06/17] backup changes --- .env.example | 2 + .vscode/i18n-ally-custom-framework.yml | 8 + .vscode/settings.json | 15 +- Cargo.toml | 8 +- README.md | 2 +- diesel.toml | 9 + migrations/.keep | 0 .../down.sql | 6 + .../up.sql | 36 ++ .../2024-01-09-225829_create_guilds/down.sql | 2 + .../2024-01-09-225829_create_guilds/up.sql | 8 + .../2024-01-10-033946_create_users/down.sql | 1 + .../2024-01-10-033946_create_users/up.sql | 5 + .../2024-01-10-034005_create_polls/down.sql | 7 + .../2024-01-10-034005_create_polls/up.sql | 36 ++ public/locales/en-US.yml | 67 ++- public/locales/pt-BR.yml | 58 ++- src/commands/language.rs | 80 ---- src/commands/poll/mod.rs | 284 ------------- src/commands/poll/setup/create.rs | 381 ------------------ src/commands/poll/setup/embeds/mod.rs | 2 - src/commands/poll/setup/embeds/setup.rs | 43 -- src/components/mod.rs | 1 - src/database/locale.rs | 48 --- src/database/mod.rs | 188 --------- src/events/mod.rs | 10 - src/interactions/chat/mod.rs | 1 - src/interactions/voice_channel/mod.rs | 1 - src/internal/users.rs | 21 - src/lib.rs | 25 +- src/main.rs | 90 ++--- src/{ => modules/app}/commands/jingle.rs | 5 +- src/modules/app/commands/language.rs | 54 +++ src/{ => modules/app}/commands/mod.rs | 16 +- src/{ => modules/app}/commands/ping.rs | 11 +- .../app}/commands/poll/database.rs | 58 ++- src/{ => modules/app}/commands/poll/help.rs | 5 +- .../app}/commands/poll/management/mod.rs | 0 src/modules/app/commands/poll/mod.rs | 345 ++++++++++++++++ src/modules/app/commands/poll/setup/create.rs | 329 +++++++++++++++ .../app/commands/poll/setup/embeds/mod.rs | 45 +++ .../app/commands/poll/setup/embeds/setup.rs | 63 +++ .../app}/commands/poll/setup/embeds/vote.rs | 22 +- .../app}/commands/poll/setup/mod.rs | 0 .../app}/commands/poll/setup/options.rs | 0 .../app}/commands/poll/utils/mod.rs | 27 +- .../app}/commands/radio/consumer.rs | 8 +- .../{ => app/commands/radio}/equalizers.rs | 0 src/{ => modules/app}/commands/radio/mod.rs | 24 +- src/{ => modules/app}/commands/voice/join.rs | 21 +- src/{ => modules/app}/commands/voice/leave.rs | 17 +- src/{ => modules/app}/commands/voice/mod.rs | 0 src/{ => modules/app}/commands/voice/mute.rs | 19 +- .../app/listeners}/chat/love.rs | 2 +- src/modules/app/listeners/chat/mod.rs | 1 + src/modules/app/listeners/command/mod.rs | 0 .../app/listeners}/mod.rs | 5 +- .../app/listeners}/modal/mod.rs | 0 .../app/listeners}/modal/poll_option.rs | 23 +- .../app/listeners/voice}/join_channel.rs | 0 src/modules/app/listeners/voice/mod.rs | 1 + src/modules/app/mod.rs | 3 + .../app/services}/integrations/jukera.rs | 0 .../app/services}/integrations/mod.rs | 2 +- src/modules/app/services/mod.rs | 1 + src/modules/core/actions/mod.rs | 1 + src/{events => modules/core/actions}/voice.rs | 4 +- src/modules/core/constants.rs | 0 src/modules/core/entities/guild.rs | 13 + src/modules/core/entities/mod.rs | 158 ++++++++ src/modules/core/entities/poll.rs | 43 ++ src/modules/core/entities/user.rs | 12 + .../core/helpers}/components/button.rs | 8 +- src/modules/core/helpers/components/mod.rs | 1 + src/modules/core/helpers/mod.rs | 1 + .../core/lib}/arguments.rs | 6 +- .../core/lib}/constants.rs | 0 src/{internal => modules/core/lib}/debug.rs | 0 src/modules/core/lib/embeds.rs | 86 ++++ src/{internal => modules/core/lib}/mod.rs | 2 +- src/modules/core/mod.rs | 5 + src/modules/mod.rs | 3 +- src/schema.rs | 89 ++++ 83 files changed, 1691 insertions(+), 1293 deletions(-) create mode 100644 .vscode/i18n-ally-custom-framework.yml create mode 100644 diesel.toml create mode 100644 migrations/.keep create mode 100644 migrations/00000000000000_diesel_initial_setup/down.sql create mode 100644 migrations/00000000000000_diesel_initial_setup/up.sql create mode 100644 migrations/2024-01-09-225829_create_guilds/down.sql create mode 100644 migrations/2024-01-09-225829_create_guilds/up.sql create mode 100644 migrations/2024-01-10-033946_create_users/down.sql create mode 100644 migrations/2024-01-10-033946_create_users/up.sql create mode 100644 migrations/2024-01-10-034005_create_polls/down.sql create mode 100644 migrations/2024-01-10-034005_create_polls/up.sql delete mode 100644 src/commands/language.rs delete mode 100644 src/commands/poll/mod.rs delete mode 100644 src/commands/poll/setup/create.rs delete mode 100644 src/commands/poll/setup/embeds/mod.rs delete mode 100644 src/commands/poll/setup/embeds/setup.rs delete mode 100644 src/components/mod.rs delete mode 100644 src/database/locale.rs delete mode 100644 src/database/mod.rs delete mode 100644 src/events/mod.rs delete mode 100644 src/interactions/chat/mod.rs delete mode 100644 src/interactions/voice_channel/mod.rs delete mode 100644 src/internal/users.rs rename src/{ => modules/app}/commands/jingle.rs (82%) create mode 100644 src/modules/app/commands/language.rs rename src/{ => modules/app}/commands/mod.rs (94%) rename src/{ => modules/app}/commands/ping.rs (75%) rename src/{ => modules/app}/commands/poll/database.rs (72%) rename src/{ => modules/app}/commands/poll/help.rs (97%) rename src/{ => modules/app}/commands/poll/management/mod.rs (100%) create mode 100644 src/modules/app/commands/poll/mod.rs create mode 100644 src/modules/app/commands/poll/setup/create.rs create mode 100644 src/modules/app/commands/poll/setup/embeds/mod.rs create mode 100644 src/modules/app/commands/poll/setup/embeds/setup.rs rename src/{ => modules/app}/commands/poll/setup/embeds/vote.rs (77%) rename src/{ => modules/app}/commands/poll/setup/mod.rs (100%) rename src/{ => modules/app}/commands/poll/setup/options.rs (100%) rename src/{ => modules/app}/commands/poll/utils/mod.rs (60%) rename src/{ => modules/app}/commands/radio/consumer.rs (80%) rename src/modules/{ => app/commands/radio}/equalizers.rs (100%) rename src/{ => modules/app}/commands/radio/mod.rs (93%) rename src/{ => modules/app}/commands/voice/join.rs (75%) rename src/{ => modules/app}/commands/voice/leave.rs (83%) rename src/{ => modules/app}/commands/voice/mod.rs (100%) rename src/{ => modules/app}/commands/voice/mute.rs (84%) rename src/{interactions => modules/app/listeners}/chat/love.rs (98%) create mode 100644 src/modules/app/listeners/chat/mod.rs create mode 100644 src/modules/app/listeners/command/mod.rs rename src/{interactions => modules/app/listeners}/mod.rs (94%) rename src/{interactions => modules/app/listeners}/modal/mod.rs (100%) rename src/{interactions => modules/app/listeners}/modal/poll_option.rs (82%) rename src/{interactions/voice_channel => modules/app/listeners/voice}/join_channel.rs (100%) create mode 100644 src/modules/app/listeners/voice/mod.rs create mode 100644 src/modules/app/mod.rs rename src/{ => modules/app/services}/integrations/jukera.rs (100%) rename src/{ => modules/app/services}/integrations/mod.rs (94%) create mode 100644 src/modules/app/services/mod.rs create mode 100644 src/modules/core/actions/mod.rs rename src/{events => modules/core/actions}/voice.rs (98%) create mode 100644 src/modules/core/constants.rs create mode 100644 src/modules/core/entities/guild.rs create mode 100644 src/modules/core/entities/mod.rs create mode 100644 src/modules/core/entities/poll.rs create mode 100644 src/modules/core/entities/user.rs rename src/{ => modules/core/helpers}/components/button.rs (81%) create mode 100644 src/modules/core/helpers/components/mod.rs create mode 100644 src/modules/core/helpers/mod.rs rename src/{internal => modules/core/lib}/arguments.rs (92%) rename src/{internal => modules/core/lib}/constants.rs (100%) rename src/{internal => modules/core/lib}/debug.rs (100%) create mode 100644 src/modules/core/lib/embeds.rs rename src/{internal => modules/core/lib}/mod.rs (76%) create mode 100644 src/modules/core/mod.rs create mode 100644 src/schema.rs diff --git a/.env.example b/.env.example index 68e7500..20022d5 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,6 @@ DISCORD_TOKEN={DISCORD_TOKEN} +# url to the database +DATABASE_URL={DATABASE_URL} # path to the database file (.yml) DATABASE_PATH={DATABASE_PATH} # debug levels are "minimal", "info", "success", "error", "verbose" diff --git a/.vscode/i18n-ally-custom-framework.yml b/.vscode/i18n-ally-custom-framework.yml new file mode 100644 index 0000000..0daf3ef --- /dev/null +++ b/.vscode/i18n-ally-custom-framework.yml @@ -0,0 +1,8 @@ +languageIds: + - rust + +usageMatchRegex: + # .add_string_choice(t!("commands.poll.management.label"), "management_command") + - "[^\\w\\d]t!\\([\\s\\n\\r]*['\"]({key})['\"]" + +monopoly: true diff --git a/.vscode/settings.json b/.vscode/settings.json index 77cc3a7..beee681 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,16 @@ { - "rust-analyzer.linkedProjects": ["./Cargo.toml", "./Cargo.toml"] + "rust-analyzer.linkedProjects": ["./Cargo.toml", "./Cargo.toml"], + "i18n-ally.localesPaths": ["public/locales"], + "i18n-ally.keystyle": "nested", + "sqltools.connections": [ + { + "previewLimit": 50, + "server": "localhost", + "port": 5432, + "driver": "PostgreSQL", + "name": "local", + "database": "bostil_bot", + "username": "root" + } + ] } diff --git a/Cargo.toml b/Cargo.toml index 9a492b5..201b64a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,8 +19,8 @@ serenity = { default-features = false, features = [ "rustls_backend", "model", "collector", -], version = "*" } -songbird = { version = "*" } +], version = "0.12.0" } +songbird = { version = "0.4.0" } tokio = { version = "*", features = ["macros", "rt-multi-thread", "signal"] } serde = { version = "1.0.171", features = ["derive"] } serde_json = "1.0.103" @@ -29,3 +29,7 @@ rust-i18n = "2.0.0" colored = "2.0.4" yaml-rust = "0.4.5" uuid = { version = "^1.4.1", features = ["v4", "fast-rng"] } +nanoid = "0.4.0" +diesel = { version = "2.1.4", features = ["postgres", "time", "uuid"] } +dotenvy = "0.15.7" +time = "0.3.31" diff --git a/README.md b/README.md index 56907d6..7f7a68f 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ Created for fun and to learn more about the Rust language. - [x] Radio [94 FM][94-fm-url] - [x] Radio [89 FM][89-fm-url] - [x] Radio [88.3 FM][perderneiras-fm-url] - - [ ] Add noise to the audio (like a radio) + - [x] Add noise to the audio (like a radio) - [ ] Recording audio only from a "SCALIZA" user ### Integrations diff --git a/diesel.toml b/diesel.toml new file mode 100644 index 0000000..7c617f5 --- /dev/null +++ b/diesel.toml @@ -0,0 +1,9 @@ +# For documentation on how to configure this file, +# see https://diesel.rs/guides/configuring-diesel-cli + +[print_schema] +file = "src/schema.rs" +import_types = ["diesel::sql_types::*", "crate::modules::core::models::exports::*"] + +[migrations_directory] +dir = "migrations" diff --git a/migrations/.keep b/migrations/.keep new file mode 100644 index 0000000..e69de29 diff --git a/migrations/00000000000000_diesel_initial_setup/down.sql b/migrations/00000000000000_diesel_initial_setup/down.sql new file mode 100644 index 0000000..a9f5260 --- /dev/null +++ b/migrations/00000000000000_diesel_initial_setup/down.sql @@ -0,0 +1,6 @@ +-- This file was automatically created by Diesel to setup helper functions +-- and other internal bookkeeping. This file is safe to edit, any future +-- changes will be added to existing projects as new migrations. + +DROP FUNCTION IF EXISTS diesel_manage_updated_at(_tbl regclass); +DROP FUNCTION IF EXISTS diesel_set_updated_at(); diff --git a/migrations/00000000000000_diesel_initial_setup/up.sql b/migrations/00000000000000_diesel_initial_setup/up.sql new file mode 100644 index 0000000..d68895b --- /dev/null +++ b/migrations/00000000000000_diesel_initial_setup/up.sql @@ -0,0 +1,36 @@ +-- This file was automatically created by Diesel to setup helper functions +-- and other internal bookkeeping. This file is safe to edit, any future +-- changes will be added to existing projects as new migrations. + + + + +-- Sets up a trigger for the given table to automatically set a column called +-- `updated_at` whenever the row is modified (unless `updated_at` was included +-- in the modified columns) +-- +-- # Example +-- +-- ```sql +-- CREATE TABLE users (id SERIAL PRIMARY KEY, updated_at TIMESTAMP NOT NULL DEFAULT NOW()); +-- +-- SELECT diesel_manage_updated_at('users'); +-- ``` +CREATE OR REPLACE FUNCTION diesel_manage_updated_at(_tbl regclass) RETURNS VOID AS $$ +BEGIN + EXECUTE format('CREATE TRIGGER set_updated_at BEFORE UPDATE ON %s + FOR EACH ROW EXECUTE PROCEDURE diesel_set_updated_at()', _tbl); +END; +$$ LANGUAGE plpgsql; + +CREATE OR REPLACE FUNCTION diesel_set_updated_at() RETURNS trigger AS $$ +BEGIN + IF ( + NEW IS DISTINCT FROM OLD AND + NEW.updated_at IS NOT DISTINCT FROM OLD.updated_at + ) THEN + NEW.updated_at := current_timestamp; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; diff --git a/migrations/2024-01-09-225829_create_guilds/down.sql b/migrations/2024-01-09-225829_create_guilds/down.sql new file mode 100644 index 0000000..7da3df7 --- /dev/null +++ b/migrations/2024-01-09-225829_create_guilds/down.sql @@ -0,0 +1,2 @@ +DROP TYPE IF EXISTS language; +DROP TABLE guilds; \ No newline at end of file diff --git a/migrations/2024-01-09-225829_create_guilds/up.sql b/migrations/2024-01-09-225829_create_guilds/up.sql new file mode 100644 index 0000000..8572c1d --- /dev/null +++ b/migrations/2024-01-09-225829_create_guilds/up.sql @@ -0,0 +1,8 @@ +CREATE TYPE language AS ENUM ('en-US', 'pt-BR'); + +CREATE TABLE guilds ( + id BIGINT PRIMARY KEY, + language language NOT NULL DEFAULT 'en-US', + added_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() PRIMARY KEY (id) +) \ No newline at end of file diff --git a/migrations/2024-01-10-033946_create_users/down.sql b/migrations/2024-01-10-033946_create_users/down.sql new file mode 100644 index 0000000..441087a --- /dev/null +++ b/migrations/2024-01-10-033946_create_users/down.sql @@ -0,0 +1 @@ +DROP TABLE users; \ No newline at end of file diff --git a/migrations/2024-01-10-033946_create_users/up.sql b/migrations/2024-01-10-033946_create_users/up.sql new file mode 100644 index 0000000..f8e085b --- /dev/null +++ b/migrations/2024-01-10-033946_create_users/up.sql @@ -0,0 +1,5 @@ +CREATE TABLE users ( + id BIGINT PRIMARY KEY, + added_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); \ No newline at end of file diff --git a/migrations/2024-01-10-034005_create_polls/down.sql b/migrations/2024-01-10-034005_create_polls/down.sql new file mode 100644 index 0000000..cc84c56 --- /dev/null +++ b/migrations/2024-01-10-034005_create_polls/down.sql @@ -0,0 +1,7 @@ +DROP TYPE poll_kind CASCADE; + +DROP TABLE poll_votes; + +DROP TABLE poll_choices; + +DROP TABLE polls; \ No newline at end of file diff --git a/migrations/2024-01-10-034005_create_polls/up.sql b/migrations/2024-01-10-034005_create_polls/up.sql new file mode 100644 index 0000000..a9b76ad --- /dev/null +++ b/migrations/2024-01-10-034005_create_polls/up.sql @@ -0,0 +1,36 @@ +CREATE TYPE poll_kind AS ENUM ('single_choice', 'multiple_choice'); + +CREATE TABLE polls ( + id UUID PRIMARY KEY, + name VARCHAR(50) NOT NULL, + description TEXT, + kind poll_kind NOT NULL, + timer BIGINT NOT NULL, + thread_id BIGINT NOT NULL, + embed_message_id BIGINT NOT NULL, + poll_message_id BIGINT, + started_at TIMESTAMP WITH TIME ZONE, + ended_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + created_by BIGINT NOT NULL +); + +CREATE TABLE poll_choices ( + value VARCHAR(50) NOT NULL, + label VARCHAR(25) NOT NULL, + description TEXT, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + poll_id UUID NOT NULL REFERENCES polls(id) ON + DELETE CASCADE, + PRIMARY KEY (poll_id, value) +); + +CREATE TABLE poll_votes ( + user_id BIGINT NOT NULL, + choice_value VARCHAR(50) NOT NULL, + poll_id UUID NOT NULL, + voted_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + FOREIGN KEY (choice_value, poll_id) REFERENCES poll_choices(value, poll_id) ON + DELETE CASCADE, + PRIMARY KEY (user_id, choice_value, poll_id) +); \ No newline at end of file diff --git a/public/locales/en-US.yml b/public/locales/en-US.yml index 7f0e498..8a60d7c 100644 --- a/public/locales/en-US.yml +++ b/public/locales/en-US.yml @@ -34,27 +34,58 @@ commands: poll: types: single_choice: - label: Single choice - description: It will be possible to choose only one option + label: Escolha única + description: Será possível escolher apenas uma opção multiple_choice: - label: Multiple choice - description: It will be possible to choose more than one option + label: Múltipla escolha + description: Será possível escolher mais de uma opção management: - label: Management - description: Manage polls + label: Gerenciar + description: Gerencia uma votação setup: - response: Starting the configuration of a voting on the channel <#%{channel_id}> Successful - label: Setup - description: Setup a poll + response: + initial: Iniciada a configuração de uma votação no canal <#%{thread_id}> com sucesso + success: Votação configurada com sucesso e está disponível no canal <#%{channel_id}> + label: Configurar + description: Configura uma votação embed: - id_none: Without ID - description_none: Without description - description: "Use the following commands:\n - \n- `/poll setup Finish` to finish the configuration - \n- `/Poll Setup Cancel` to cancel the configuration" + properties: + name: "- Nome: %{poll.name}" + channel: "- Canal: <#%{poll.channel_id}>" + type: "- Tipo: %{poll.type}" + timeout: "- Tempo limite: %{poll.timeout}" + pendencies: + type: "- Selecione o tipo de votação" + options: "- Adicione as opções de votação" + timeout: "- Se desejar que a votação tenha um tempo limite, clique em `Temporizador`" + stages: + setup: + title: Configurando a votação + description: "Dados da votação: \n\n + + %{properties}\n\n + + Pendências: \n + + %{pendencies}\n\n" + voting: + title: Votação em andamento + description: "A votação está ocorrendo no canal <#%{channel_id}>\n + Para encerrar a votação: \n + - Pressione o botão `Encerrar`" + closed: + title: Votação encerrada + description: + "A votação foi encerrada, este tópico será excluído em 10 segundos\n + Para ver o resultado da votação: \n + - Pressione o botão `Ver resultado`" fields: - options: Poll options - footer: Use the command `/poll help` for more information + cancel_info: Para cancelar a configuração da votação, clique em `Cancelar` + id_none: Sem ID + options_none: Sem opções + options: Opções da votação + time_remaining: Tempo restante + footer: Use o comando `/poll help` para mais informações help: - label: Help - description: Show help message for poll commands + label: Ajuda + description: Exibe mensagem de ajuda para os comandos de votação diff --git a/public/locales/pt-BR.yml b/public/locales/pt-BR.yml index 96f5b9b..5e950ad 100644 --- a/public/locales/pt-BR.yml +++ b/public/locales/pt-BR.yml @@ -37,27 +37,69 @@ commands: label: Escolha única description: Será possível escolher apenas uma opção multiple_choice: - label: Escolha múltipla + label: Múltipla escolha description: Será possível escolher mais de uma opção management: label: Gerenciar description: Gerencia uma votação setup: response: - initial: Iniciada a configuração de uma votação no canal <#%{channel_id}> com sucesso + initial: Iniciada a configuração de uma votação no canal <#%{thread_id}> com sucesso success: Votação configurada com sucesso e está disponível no canal <#%{channel_id}> label: Configurar description: Configura uma votação embed: - id_none: Sem ID - description_none: Sem descrição - description: "Use os seguintes comandos:\n\n - - `/poll options` para cadastrar as novas opções\n - - `/poll setup finish` para finalizar a configuração\n - - `/poll setup cancel` para cancelar a configuração\n" + properties: + name: "- Nome: %{poll.name}" + channel: "- Canal: <#%{poll.channel_id}>" + type: "- Tipo: %{poll.type}" + timeout: "- Tempo limite: %{poll.timeout}" + pendencies: + type: "- Selecione o tipo de votação" + options: "- Adicione as opções de votação" + timeout: "- Se desejar que a votação tenha um tempo limite, clique em `Temporizador`" + stages: + setup: + title: Configurando a votação + description: "Dados da votação:\n\n + + %{properties}\n + + Pendências:\n + + %{pendencies}\n\n" + voting: + title: Votação em andamento + description: "A votação está ocorrendo no canal <#%{channel_id}>\n + Para encerrar a votação: \n + - Pressione o botão `Encerrar`" + closed: + title: Votação encerrada + description: + "A votação foi encerrada, este tópico será excluído em 10 segundos\n + Para ver o resultado da votação: \n + - Pressione o botão `Ver resultado`" fields: + cancel_info: Para cancelar a configuração da votação, clique em `Cancelar` + id_none: Sem ID + options_none: Sem opções options: Opções da votação + time_remaining: Tempo restante footer: Use o comando `/poll help` para mais informações help: label: Ajuda description: Exibe mensagem de ajuda para os comandos de votação +general: + time: + day: dia + days: dias + hour: hora + hours: horas + minute: minuto + minutes: minutos + second: segundo + seconds: segundos + yes: Sim + no: Não + cancel: Cancelar + close: Encerrar diff --git a/src/commands/language.rs b/src/commands/language.rs deleted file mode 100644 index bb61e13..0000000 --- a/src/commands/language.rs +++ /dev/null @@ -1,80 +0,0 @@ -use crate::database::locale::apply_locale; -use rust_i18n::{locale as current_locale, t}; - -use serenity::async_trait; -use serenity::builder::CreateApplicationCommand; -use serenity::model::prelude::{ - command, interaction::application_command::CommandDataOption, Guild, -}; - -use super::{ - ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, -}; - -struct Language; - -#[async_trait] -impl RunnerFn for Language { - async fn run<'a>(&self, args: &Vec>) -> InternalCommandResult<'a> { - let options = args - .iter() - .filter_map(|arg| arg.downcast_ref::>()) - .collect::>>()[0]; - let guild = args - .iter() - .filter_map(|arg| arg.downcast_ref::()) - .collect::>()[0]; - - if let Some(language_option) = options.get(0) { - let selected_language = language_option.value.as_ref().unwrap().as_str().unwrap(); - - apply_locale(selected_language, &guild.id, false); - - let current_locale_name = t!(&format!("commands.language.{}", selected_language)); - Ok(CommandResponse::String( - t!("commands.language.reply", "language_name" => current_locale_name), - )) - } else { - let current_locale_name = t!(&format!("commands.language.{}", current_locale())); - Ok(CommandResponse::String( - t!("commands.language.current_language", "language_name" => current_locale_name, "language_code" => current_locale()), - )) - } - } -} - -pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { - command - .name("language") - .name_localized("pt-BR", "idioma") - .description("Language Preferences Menu") - .description_localized("pt-BR", "Menu de preferências de idioma") - .create_option(|option| { - option - .name("choose_language") - .name_localized("pt-BR", "alterar_idioma") - .description("Choose the language of preference") - .description_localized("pt-BR", "Escolha o idioma de preferência") - .kind(command::CommandOptionType::String) - .add_string_choice_localized( - "Portuguese", - "pt-BR", - [("pt-BR", "Português"), ("en-US", "Portuguese")], - ) - .add_string_choice_localized( - "English", - "en-US", - [("pt-BR", "Inglês"), ("en-US", "English")], - ) - }) -} - -pub fn get_command() -> Command { - Command::new( - "language", - "Language Preferences Menu", - CommandCategory::General, - vec![ArgumentsLevel::Options, ArgumentsLevel::Guild], - Box::new(Language {}), - ) -} diff --git a/src/commands/poll/mod.rs b/src/commands/poll/mod.rs deleted file mode 100644 index 544c53c..0000000 --- a/src/commands/poll/mod.rs +++ /dev/null @@ -1,284 +0,0 @@ -use crate::database::{get_database, Database}; - -use super::{ - ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, -}; - -use regex::Regex; -use rust_i18n::t; -use serenity::{ - async_trait, - model::{ - id::{GuildId, MessageId}, - prelude::{ - application_command::{CommandDataOption, CommandDataOptionValue}, - ChannelId, UserId, - }, - }, -}; -use std::time::{Duration, SystemTime}; - -mod database; -pub mod help; -pub mod management; -pub mod setup; -mod utils; - -struct PollCommand; - -#[derive(Debug, Clone)] -pub struct Vote { - pub user_id: UserId, - pub options: Vec, -} - -#[derive(Debug, Clone, Copy)] -pub enum PollType { - SingleChoice, - MultipleChoice, -} - -#[derive(Debug, Clone, Copy)] -pub enum PollStatus { - Open, - Closed, - Stopped, - Creating, -} - -#[derive(Debug)] -pub struct Poll { - id: uuid::Uuid, - name: String, - description: Option, - kind: PollType, - options: Vec, - timer: Duration, - status: PollStatus, -} - -#[derive(Debug, Clone)] -pub struct PollDatabaseModel { - pub id: uuid::Uuid, - pub name: String, - pub description: Option, - pub kind: PollType, - pub status: PollStatus, - pub timer: Option, - pub options: Vec, - pub votes: Vec, - pub thread_id: ChannelId, - pub message_id: MessageId, - pub created_at: SystemTime, - pub created_by: UserId, -} - -impl PollDatabaseModel { - pub fn from_id(id: uuid::Uuid) -> PollDatabaseModel { - let database_manager = get_database(); - let database = database_manager.lock().unwrap(); - - let poll = database - .guilds - .iter() - .find_map(|(_, guild)| guild.polls.iter().find(|poll| poll.id == id)); - - poll.unwrap().clone() - } -} - -impl Poll { - pub fn new( - name: String, - description: Option, - kind: PollType, - options: Vec, - // Receives a minute value as a string (e.g. "0.5" for 30 seconds, "1" for 1 minute, "2" for 2 minutes, etc.) - timer: Option, - status: Option, - ) -> Poll { - Poll { - name, - description, - kind, - options, - id: uuid::Uuid::new_v4(), - status: status.unwrap_or(PollStatus::Open), - timer: match timer { - Some(timer) => { - let timer = timer.parse::().unwrap_or(0.0); - Duration::from_secs_f64(timer * 60.0) - } - None => Duration::from_secs(60), - }, - } - } - - pub fn save( - &self, - user_id: UserId, - channel_id: ChannelId, - guild_id: GuildId, - message_id: MessageId, - ) { - let poll = PollDatabaseModel { - message_id, - id: self.id, - name: self.name.clone(), - description: self.description.clone(), - kind: self.kind, - status: self.status, - options: self.options.clone(), - timer: Some(self.timer), - votes: vec![], - thread_id: channel_id, - created_at: SystemTime::now(), - created_by: user_id, - }; - - poll.save(guild_id); - } - - pub fn from_id(id: uuid::Uuid) -> PollDatabaseModel { - PollDatabaseModel::from_id(id) - } -} - -impl PollType { - pub fn to_string(&self) -> String { - match self { - PollType::SingleChoice => "single_choice".to_string(), - PollType::MultipleChoice => "multiple_choice".to_string(), - } - } - - pub fn to_label(&self) -> String { - match self { - PollType::SingleChoice => t!("commands.poll.types.single_choice.label"), - PollType::MultipleChoice => t!("commands.poll.types.single_choice.label"), - } - } -} - -impl PollStatus { - pub fn to_string(&self) -> String { - match self { - PollStatus::Open => "open".to_string(), - PollStatus::Closed => "closed".to_string(), - PollStatus::Stopped => "stopped".to_string(), - PollStatus::Creating => "creating".to_string(), - } - } -} - -fn poll_serializer(command_options: &Vec) -> Poll { - let option_regex: Regex = Regex::new(r"^option_\d+$").unwrap(); - let kind = match command_options.iter().find(|option| option.name == "type") { - Some(option) => match option.resolved.as_ref().unwrap() { - CommandDataOptionValue::String(value) => match value.as_str() { - "single_choice" => PollType::SingleChoice, - "multiple_choice" => PollType::MultipleChoice, - _ => PollType::SingleChoice, - }, - _ => PollType::SingleChoice, - }, - None => PollType::SingleChoice, - }; - - Poll::new( - command_options - .iter() - .find(|option| option.name == "name") - .unwrap() - .value - .as_ref() - .unwrap() - .to_string(), - Some( - command_options - .iter() - .find(|option| option.name == "description") - .unwrap() - .value - .as_ref() - .unwrap() - .to_string(), - ), - kind, - command_options - .iter() - .filter(|option| option_regex.is_match(&option.name)) - .map(|option| match option.resolved.as_ref().unwrap() { - CommandDataOptionValue::String(value) => value.to_string(), - _ => "".to_string(), - }) - .collect::>(), - Some( - command_options - .iter() - .find(|option| option.name == "timer") - .unwrap() - .value - .as_ref() - .unwrap() - .to_string(), - ), - Some(PollStatus::Open), - ) -} - -#[async_trait] -impl RunnerFn for PollCommand { - async fn run<'a>( - &self, - args: &Vec>, - ) -> InternalCommandResult<'a> { - let options = args - .iter() - .filter_map(|arg| arg.downcast_ref::>>()) - .collect::>>>()[0] - .as_ref() - .unwrap(); - let first_option = options.get(0).unwrap(); - let command_name = first_option.name.clone(); - - let command_runner = command_suite(command_name); - - let response = command_runner.run(args.clone()); - - match response.await { - Ok(response) => match response.to_owned() { - CommandResponse::Message(message) => Ok(CommandResponse::Message(message)), - _ => Ok(CommandResponse::None), - }, - Err(e) => Err(e), - } - } -} - -fn command_suite(command_name: String) -> Box { - let command_runner = match command_name.as_str() { - "help" => self::help::get_command().runner, - "setup" => self::setup::create::get_command().runner, - "options" => self::setup::options::get_command().runner, - _ => get_command().runner, - }; - - command_runner -} - -pub fn get_command() -> Command { - Command::new( - "poll", - "Poll commands", - CommandCategory::Misc, - vec![ - ArgumentsLevel::Options, - ArgumentsLevel::Context, - ArgumentsLevel::Guild, - ArgumentsLevel::User, - ArgumentsLevel::ChannelId, - ], - Box::new(PollCommand), - ) -} diff --git a/src/commands/poll/setup/create.rs b/src/commands/poll/setup/create.rs deleted file mode 100644 index acd2663..0000000 --- a/src/commands/poll/setup/create.rs +++ /dev/null @@ -1,381 +0,0 @@ -use super::embeds; -use crate::{ - commands::{ - poll::{Poll, PollStatus, PollType}, - ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, - }, - internal::debug::{log_message, MessageTypes}, -}; - -use rust_i18n::t; -use serenity::{ - async_trait, - builder::CreateApplicationCommandOption, - futures::StreamExt, - model::{ - prelude::{ - application_command::CommandDataOption, - command::CommandOptionType, - component::{ButtonStyle, InputTextStyle}, - modal::ModalSubmitInteraction, - ChannelId, InteractionResponseType, - }, - user::User, - }, - prelude::Context, -}; -use std::time::Duration; - -struct CreatePollRunner; - -#[async_trait] -impl RunnerFn for CreatePollRunner { - async fn run<'a>( - &self, - args: &Vec>, - ) -> InternalCommandResult<'a> { - let options = args - .iter() - .filter_map(|arg| arg.downcast_ref::>>()) - .collect::>>>()[0] - .as_ref() - .unwrap(); - let subcommand_options = &options[0].options; - - let poll_name = subcommand_options - .iter() - .find(|option| option.name == "poll_name") - .unwrap() - .value - .as_ref() - .unwrap() - .as_str() - .unwrap(); - let poll_description = subcommand_options - .iter() - .find(|option| option.name == "poll_description") - .unwrap() - .value - .as_ref() - .unwrap() - .as_str() - .unwrap(); - let ctx = args - .iter() - .find_map(|arg| arg.downcast_ref::()) - .unwrap(); - let channel_id = args - .iter() - .find_map(|arg| arg.downcast_ref::()) - .unwrap(); - let user_id = args - .iter() - .filter_map(|arg| arg.downcast_ref::()) - .collect::>() - .get(0) - .unwrap() - .id; - - // Create thread - let thread_channel = channel_id - .create_private_thread(ctx.http.clone(), |thread| thread.name(poll_name)) - .await?; - - thread_channel - .id - .add_thread_member(ctx.http.clone(), user_id) - .await?; - - // Setup poll - let mut message = thread_channel - .send_message(&ctx.http, |message| { - let embed = embeds::setup::embed( - poll_name.to_string(), - user_id.clone(), - Some(poll_description.to_string()), - None, - ) - .unwrap(); - - message.set_embed(embed) - }) - .await?; - - // Add buttons (kind) - message - .edit(&ctx.http, |message| { - message.components(|components| { - components.create_action_row(|action_row| { - action_row - .create_button(|button| { - button - .style(ButtonStyle::Primary) - .label("Single choice") - .custom_id("single_choice") - }) - .create_button(|button| { - button - .style(ButtonStyle::Primary) - .label("Multiple choice") - .custom_id("multiple_choice") - }) - }) - }) - }) - .await?; - - let mut interaction_stream = message - .await_component_interactions(&ctx) - .timeout(Duration::from_secs(60 * 3)) - .build(); - - while let Some(interaction) = interaction_stream.next().await { - let interaction_id = interaction.data.custom_id.as_str(); - let interaction_user = interaction.user.clone(); - - if interaction_user.id != user_id { - match interaction - .create_interaction_response(&ctx.http, |response| { - response.kind(InteractionResponseType::DeferredUpdateMessage) - }) - .await - { - Ok(_) => {} - Err(_) => { - log_message("Failed to defer update message", MessageTypes::Error); - } - } - } - - match interaction_id { - "single_choice" => { - let poll = Poll::new( - poll_name.to_string(), - Some(poll_description.to_string()), - PollType::SingleChoice, - vec![], - None, - Some(PollStatus::Open), - ); - - // Edit message adds poll_id - message - .edit(&ctx.http, |message| { - let embed = embeds::setup::embed( - poll.name.clone(), - user_id.clone(), - poll.description.clone(), - Some(poll.id.clone()), - ) - .unwrap(); - - message.set_embed(embed) - }) - .await?; - - match interaction - .create_interaction_response(&ctx.http, |response| { - response.kind(InteractionResponseType::Modal); - - response.interaction_response_data(|message| { - message - .title("Single choice") - .custom_id(&format!("option_data_poll/{}", poll.id)) - .components(|components| { - components - .create_action_row(|action_row| { - action_row.create_input_text(|input| { - input - .custom_id("option_name") - .required(true) - .label("Name of the option") - .placeholder("Insert a name") - .style(InputTextStyle::Short) - }) - }) - .create_action_row(|action_row| { - action_row.create_input_text(|input| { - input - .custom_id("option_description") - .required(true) - .label("Description of the option") - .placeholder("Insert a description") - .style(InputTextStyle::Paragraph) - }) - }) - }) - }) - }) - .await - { - Ok(_) => {} - Err(why) => { - log_message( - &format!("Failed to create interaction response: {}", why), - MessageTypes::Error, - ); - } - } - } - "multiple_choice" => { - let poll = Poll::new( - poll_name.to_string(), - Some(poll_description.to_string()), - PollType::MultipleChoice, - vec![], - None, - Some(PollStatus::Open), - ); - - // Edit message adds poll_id - message - .edit(&ctx.http, |message| { - let embed = embeds::setup::embed( - poll.name.clone(), - user_id.clone(), - poll.description.clone(), - Some(poll.id.clone()), - ) - .unwrap(); - - message.set_embed(embed) - }) - .await?; - - // Modal for adding options - match interaction - .create_interaction_response(&ctx.http, |response| { - response - .kind(InteractionResponseType::Modal) - .interaction_response_data(|message| { - message - .title("Add option") - .custom_id(&format!("option_data_poll/{}", poll.id)) - .components(|components| { - components - .create_action_row(|action_row| { - action_row.create_input_text(|input| { - input - .custom_id("option_name") - .required(true) - .label("Name of the option") - .placeholder("Insert a name") - .style(InputTextStyle::Short) - }) - }) - .create_action_row(|action_row| { - action_row.create_input_text(|input| { - input - .custom_id("option_description") - .required(true) - .label("Description of the option") - .placeholder("Insert a description") - .style(InputTextStyle::Paragraph) - }) - }) - }) - }) - }) - .await - { - Ok(_) => { - log_message( - &format!("Created modal for {}", interaction_id), - MessageTypes::Info, - ); - } - Err(why) => { - log_message( - &format!("Failed to create interaction response: {}", why), - MessageTypes::Error, - ); - } - } - } - - _ => { - log_message( - format!("Unknown interaction id: {}", interaction_id).as_str(), - MessageTypes::Error, - ); - - interaction - .create_interaction_response(&ctx.http, |response| { - response.kind(InteractionResponseType::DeferredUpdateMessage) - }) - .await?; - } - } - } - - Ok(CommandResponse::String( - t!("commands.poll.setup.response.success", "channel_id" => thread_channel.id.to_string()), - )) - } -} - -pub async fn handle_modal(ctx: &Context, command: &ModalSubmitInteraction) { - if let Err(why) = command - .create_interaction_response(&ctx.http, |m| { - m.kind(InteractionResponseType::DeferredUpdateMessage) - }) - .await - { - log_message( - &format!("Failed to create interaction response: {}", why), - MessageTypes::Error, - ); - } -} - -pub fn register_option<'a>() -> CreateApplicationCommandOption { - let mut command_option = CreateApplicationCommandOption::default(); - - command_option - .name("setup") - .name_localized("pt-BR", "configurar") - .description("Setup a poll") - .description_localized("pt-BR", "Configura uma votação") - .kind(CommandOptionType::SubCommand) - .create_sub_option(|sub_option| { - sub_option - .name("poll_name") - .name_localized("pt-BR", "nome_da_votação") - .description("The name of the option (max 25 characters)") - .description_localized("pt-BR", "O nome da opção (máx 25 caracteres)") - .kind(CommandOptionType::String) - .required(true) - }) - .create_sub_option(|sub_option| { - sub_option - .name("poll_description") - .name_localized("pt-BR", "descrição_da_votação") - .description("The description of the option (max 100 characters)") - .description_localized( - "pt-BR", - "A descrição dessa opção (máximo de 100 caracteres)", - ) - .kind(CommandOptionType::String) - .max_length(100) - .required(true) - }); - - command_option -} - -pub fn get_command() -> Command { - Command::new( - "setup", - "Setup a poll", - CommandCategory::Misc, - vec![ - ArgumentsLevel::Options, - ArgumentsLevel::Context, - ArgumentsLevel::Guild, - ArgumentsLevel::User, - ArgumentsLevel::ChannelId, - ArgumentsLevel::InteractionId, - ], - Box::new(CreatePollRunner), - ) -} diff --git a/src/commands/poll/setup/embeds/mod.rs b/src/commands/poll/setup/embeds/mod.rs deleted file mode 100644 index de49d68..0000000 --- a/src/commands/poll/setup/embeds/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod vote; -pub mod setup; \ No newline at end of file diff --git a/src/commands/poll/setup/embeds/setup.rs b/src/commands/poll/setup/embeds/setup.rs deleted file mode 100644 index dce965c..0000000 --- a/src/commands/poll/setup/embeds/setup.rs +++ /dev/null @@ -1,43 +0,0 @@ -use rust_i18n::t; -use serenity::{ - builder::CreateEmbed, client::Context, framework::standard::CommandResult, - model::prelude::UserId, -}; -use uuid::Uuid; - -use crate::commands::poll::Poll; - -pub fn embed( - name: String, - created_by: UserId, - description: Option, - id: Option, -) -> CommandResult { - let mut embed = CreateEmbed::default(); - embed - .title(name) - .description(t!("commands.poll.setup.embed.description").as_str()); - - // first row (id, status, user) - embed.field( - "ID", - id.map_or(t!("commands.poll.setup.embed.id_none"), |id| id.to_string()), - true, - ); - embed.field("User", format!("<@{}>", created_by), true); - - // separator - embed.field("\u{200B}", "\u{200B}", false); - - embed.field( - "Description", - description.unwrap_or(t!("commands.poll.setup.embed.description_none")), - false, - ); - - Ok(embed) -} - -impl Poll { - pub fn update_message(&self, ctx: Context) {} -} diff --git a/src/components/mod.rs b/src/components/mod.rs deleted file mode 100644 index aa200ca..0000000 --- a/src/components/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod button; diff --git a/src/database/locale.rs b/src/database/locale.rs deleted file mode 100644 index cf7e7a3..0000000 --- a/src/database/locale.rs +++ /dev/null @@ -1,48 +0,0 @@ -use std::borrow::BorrowMut; - -use super::{get_database, save_database}; -use crate::{ - database::GuildDatabaseModel, - internal::debug::{log_message, MessageTypes}, -}; - -use rust_i18n::{available_locales, set_locale}; -use serenity::model::prelude::GuildId; - -pub fn apply_locale(new_locale: &str, guild_id: &GuildId, is_preflight: bool) { - if available_locales!().contains(&new_locale) { - let local_database = get_database(); - - if let Some(guild) = local_database.lock().unwrap().guilds.get(guild_id) { - if guild.locale == new_locale { - return; - } else if guild.locale != new_locale && is_preflight { - set_locale(guild.locale.as_str()); - - return; - } - } - - set_locale(new_locale); - - local_database.lock().unwrap().guilds.insert( - *guild_id, - GuildDatabaseModel { - locale: new_locale.to_string(), - polls: Vec::new(), - }, - ); - - save_database(local_database.lock().unwrap().borrow_mut()); - - log_message( - format!("Applied locale {} for guild {}", new_locale, guild_id).as_str(), - MessageTypes::Success, - ); - } else { - log_message( - format!("Locale {} not available for guild {}", new_locale, guild_id).as_str(), - MessageTypes::Failed, - ); - } -} diff --git a/src/database/mod.rs b/src/database/mod.rs deleted file mode 100644 index 9799cb9..0000000 --- a/src/database/mod.rs +++ /dev/null @@ -1,188 +0,0 @@ -pub mod locale; - -/* - Create a database file (.yml) on DATABASE_PATH path with the following content: - - {GUILD_ID}: - locale: "en-US" - polls: - - id: {POLL_ID} - name: "Poll name" - description: "Poll description" - kind: "single_choice" - options: - - "Option 1" - - "Option 2" - timer: 60 - votes: - - user_id: "USER_ID" - options: - - "Option 1" - - user_id: "USER_ID" - options: - - "Option 1" - - "Option 2" - created_at: 2021-01-01T00:00:00Z - - using Arc and Mutex to share the parsed database between threads -*/ - -use std::collections::HashMap; -use std::env; -use std::fmt::Debug; -use std::fs::File; -use std::io::prelude::*; -use std::sync::{Arc, Mutex}; - -use serenity::model::id::GuildId; -use serenity::prelude::*; - -use yaml_rust::YamlLoader; - -use crate::commands::poll::PollDatabaseModel; - -#[derive(Debug)] -pub struct GuildDatabaseModel { - pub locale: String, - pub polls: Vec, -} - -#[derive(Debug)] -pub struct Database { - pub guilds: HashMap, -} - -impl TypeMapKey for Database { - type Value = Arc>; -} - -impl Database { - pub fn init() -> Arc> { - // TODO: CREATE INIT FOR DATABASE FILE - let database = get_database(); - - Arc::clone(&database) - } -} - -fn open_or_create_file(database_path: &String) -> File { - let file = File::open(database_path); - - match file { - Ok(file) => file, - Err(_) => { - let mut dir_path = std::path::PathBuf::from(database_path); - - dir_path.pop(); - if !dir_path.exists() { - std::fs::create_dir_all(dir_path).expect("Failed to create directory"); - } - - File::create(database_path).expect("Something went wrong creating the file") - } - } -} - -pub fn get_database() -> Arc> { - let database_path = env::var("DATABASE_PATH").expect("DATABASE_PATH not found"); - - let mut file = open_or_create_file(&database_path); - let mut contents = String::new(); - - if file.metadata().unwrap().len() > 0 { - file.read_to_string(&mut contents) - .expect("Something went wrong reading the file"); - } - - let docs = YamlLoader::load_from_str(&contents).unwrap(); - - let mut database = Database { - guilds: HashMap::new(), - }; - - for doc in docs { - for (guild_id, guild) in doc.as_hash().unwrap() { - let guild_id = GuildId(guild_id.as_i64().unwrap() as u64); - - let locale = match guild["locale"].as_str() { - Some(locale) => locale.to_string(), - None => "".to_string(), - }; - let polls = match guild["polls"].as_vec() { - Some(polls) => polls.to_vec(), - None => vec![] as Vec, - }; - - database.guilds.insert( - guild_id, - GuildDatabaseModel { - locale, - polls: polls - .iter() - .map(|poll| PollDatabaseModel::from_yaml(poll)) - .collect::>(), - }, - ); - } - } - - Arc::new(Mutex::new(database)) -} - -pub fn save_database(database: &Database) { - let database_path = env::var("DATABASE_PATH").expect("DATABASE_PATH not found"); - - let mut file = File::create(database_path).expect("Database file not found"); - - let mut contents = String::new(); - - for (guild_id, guild) in &database.guilds { - contents.push_str(&format!("{}:\n", guild_id.as_u64())); - - contents.push_str(&format!(" locale: \"{}\"\n", guild.locale)); - contents.push_str(" polls:\n"); - - guild.polls.iter().for_each(|poll| { - println!("chegou no iter poll"); - - contents.push_str(&format!(" - id: {}\n", poll.id)); - - contents.push_str(&format!(" name: \"{}\"\n", poll.name)); - - if let Some(description) = &poll.description { - contents.push_str(&format!(" description: \"{}\"\n", description)); - } - - contents.push_str(&format!(" kind: \"{}\"\n", poll.kind.to_string())); - - contents.push_str(" options:\n"); - - for option in &poll.options { - contents.push_str(&format!(" - \"{}\"\n", option)); - } - - contents.push_str(&format!(" timer: {}\n", poll.timer.unwrap().as_secs())); - - contents.push_str(" votes:\n"); - - for vote in &poll.votes { - contents.push_str(&format!(" - user_id: {}\n", vote.user_id)); - - for option in &vote.options { - contents.push_str(&format!(" - \"{}\"\n", option)); - } - } - - contents.push_str(&format!( - " created_at: {}\n", - poll.created_at - .duration_since(std::time::SystemTime::UNIX_EPOCH) - .unwrap() - .as_secs() - )); - }); - } - - file.write_all(contents.as_bytes()) - .expect("Something went wrong writing the file"); -} diff --git a/src/events/mod.rs b/src/events/mod.rs deleted file mode 100644 index a63fcb1..0000000 --- a/src/events/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -use serenity::model::channel::Message; -use serenity::Result as SerenityResult; - -pub mod voice; - -pub fn check_msg(result: SerenityResult) { - if let Err(why) = result { - println!("Error sending message: {:?}", why); - } -} diff --git a/src/interactions/chat/mod.rs b/src/interactions/chat/mod.rs deleted file mode 100644 index 3d4edb4..0000000 --- a/src/interactions/chat/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod love; \ No newline at end of file diff --git a/src/interactions/voice_channel/mod.rs b/src/interactions/voice_channel/mod.rs deleted file mode 100644 index 851864e..0000000 --- a/src/interactions/voice_channel/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod join_channel; \ No newline at end of file diff --git a/src/internal/users.rs b/src/internal/users.rs deleted file mode 100644 index 54430a8..0000000 --- a/src/internal/users.rs +++ /dev/null @@ -1,21 +0,0 @@ -use std::{collections::HashMap, fs}; - -use once_cell::sync::Lazy; -use serenity::model::prelude::UserId; - -use crate::internal::constants::USERS_FILE_PATH; - -fn parse_users(users_json: String) -> Result, serde_json::Error> { - let v: HashMap = serde_json::from_str(users_json.as_str())?; - Ok(v) -} - -fn get_users() -> HashMap { - let users = fs::read_to_string(USERS_FILE_PATH).expect("Something went wrong reading the file"); - - let users = parse_users(users); - - users.unwrap() -} - -pub const USERS: Lazy> = Lazy::new(|| get_users()); diff --git a/src/lib.rs b/src/lib.rs index 0d04117..9e91e71 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,13 +1,22 @@ +use diesel::pg::PgConnection; +use diesel::Connection; +use dotenvy::dotenv; +use std::env; + #[macro_use(i18n)] extern crate rust_i18n; +extern crate diesel; + +// TODO: implementar algum jeito para que cada servidor tenha seu próprio idioma e não alterar o idioma de todos os servidores // CHECK Backend implementation +i18n!("public/locales", fallback = "en-US"); + +pub fn establish_connection() -> PgConnection { + dotenv().ok(); -i18n!("./public/locales", fallback = "en-US"); + let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); + PgConnection::establish(&database_url) + .unwrap_or_else(|_| panic!("Error connecting to {}", database_url)) +} -pub mod commands; -pub mod components; -pub mod database; -pub mod events; -pub mod integrations; -pub mod interactions; -pub mod internal; pub mod modules; +pub mod schema; diff --git a/src/main.rs b/src/main.rs index e9c5a63..f4c3035 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,11 @@ include!("lib.rs"); use std::sync::Arc; -use std::vec; use std::{borrow::BorrowMut, env}; use commands::{collect_commands, CommandResponse}; use internal::arguments::ArgumentsLevel; +use serenity::all::ActivityType; use serenity::async_trait; use serenity::client::bridge::gateway::ShardManager; use serenity::framework::StandardFramework; @@ -13,7 +13,6 @@ use serenity::model::application::interaction::Interaction; use serenity::model::gateway::Ready; use serenity::model::id::GuildId; use serenity::model::prelude::command::Command; -use serenity::model::prelude::InteractionResponseType; use serenity::model::voice::VoiceState; use serenity::prelude::*; @@ -94,7 +93,7 @@ impl EventHandler for Handler { let commands = Command::set_global_application_commands(&ctx.http, |commands| { commands.create_application_command(|command| commands::ping::register(command)) }) - .await; + .await; if let Err(why) = commands { log_message( @@ -107,7 +106,7 @@ impl EventHandler for Handler { // guild commands and apply language to each guild for guild in ready.guilds.iter() { - let commands = GuildId::set_application_commands(&guild.id, &ctx.http, |commands| { + let commands = GuildId::set_commands(&guild.id, &ctx.http, |commands| { commands.create_application_command(|command| commands::jingle::register(command)); commands .create_application_command(|command| commands::language::register(command)); @@ -124,7 +123,7 @@ impl EventHandler for Handler { commands }) - .await; + .await; apply_locale( &guild @@ -149,10 +148,10 @@ impl EventHandler for Handler { ); } - ctx.set_activity(serenity::model::gateway::Activity::playing( + ctx.set_activity(ActivityType::Playing( "O auxílio emergencial no PIX do Mito", )) - .await; + .await; } // On User connect to voice channel @@ -169,7 +168,7 @@ impl EventHandler for Handler { "User connected to voice channel: {:#?}", new.channel_id.unwrap().to_string() ) - .as_str(), + .as_str(), MessageTypes::Debug, ); } @@ -186,7 +185,7 @@ impl EventHandler for Handler { "User disconnected from voice channel: {:#?}", old.channel_id.unwrap().to_string() ) - .as_str(), + .as_str(), MessageTypes::Debug, ); } @@ -210,7 +209,7 @@ impl EventHandler for Handler { "Received modal submit interaction from User: {:#?}", submit.user.name ) - .as_str(), + .as_str(), MessageTypes::Debug, ); } @@ -221,14 +220,14 @@ impl EventHandler for Handler { match registered_interactions.iter().enumerate().find(|(_, i)| { i.name == submit - .clone() - .data - .custom_id - .split("/") - .collect::>() - .first() - .unwrap() - .to_string() + .clone() + .data + .custom_id + .split("/") + .collect::>() + .first() + .unwrap() + .to_string() }) { Some((_, interaction)) => { interaction @@ -256,7 +255,7 @@ impl EventHandler for Handler { "Modal submit interaction {} not found", submit.data.custom_id.split("/").collect::>()[0] ) - .as_str(), + .as_str(), MessageTypes::Error, ); } @@ -270,11 +269,12 @@ impl EventHandler for Handler { "Received command \"{}\" interaction from User: {:#?}", command.data.name, command.user.name ) - .as_str(), + .as_str(), MessageTypes::Debug, ); } + // Defer the interaction and edit it later match command.defer(&ctx.http.clone()).await { Ok(_) => {} Err(why) => { @@ -319,39 +319,30 @@ impl EventHandler for Handler { "Responding with: {}", command_response.to_string() ) - .as_str(), + .as_str(), MessageTypes::Debug, ); } if CommandResponse::None != command_response { if let Err(why) = command - .create_interaction_response( - &ctx.http, - |interaction_response| { - interaction_response - .kind(InteractionResponseType::UpdateMessage) - .interaction_response_data(|response| { - match command_response { - CommandResponse::String(string) => { - response.content(string) - } - CommandResponse::Embed(embed) => { - response.set_embed( - CommandResponse::Embed(embed) - .to_embed(), - ) - } - CommandResponse::Message(message) => { - *response.borrow_mut() = message; - - response - } - CommandResponse::None => response, - } - }) - }, - ) + .edit_original_interaction_response(&ctx.http, |response| { + match command_response { + CommandResponse::String(string) => { + response.content(string) + } + CommandResponse::Embed(embed) => response + .set_embed( + CommandResponse::Embed(embed).to_embed(), + ), + CommandResponse::Message(message) => { + *response.borrow_mut() = message; + + response + } + CommandResponse::None => response, + } + }) .await { log_message( @@ -367,7 +358,7 @@ impl EventHandler for Handler { "Deleting slash command: {}", command.data.name ) - .as_str(), + .as_str(), MessageTypes::Debug, ); } @@ -412,6 +403,9 @@ impl EventHandler for Handler { async fn main() { let token = env::var("DISCORD_TOKEN").expect("Expected a token in the environment"); + // database check + establish_connection(); + let intents = GatewayIntents::MESSAGE_CONTENT | GatewayIntents::DIRECT_MESSAGES | GatewayIntents::GUILD_WEBHOOKS diff --git a/src/commands/jingle.rs b/src/modules/app/commands/jingle.rs similarity index 82% rename from src/commands/jingle.rs rename to src/modules/app/commands/jingle.rs index 01d459e..35a0c7e 100644 --- a/src/commands/jingle.rs +++ b/src/modules/app/commands/jingle.rs @@ -1,7 +1,7 @@ use super::Command; use serenity::async_trait; -use serenity::builder::CreateApplicationCommand; +use serenity::builder::CreateCommand; struct Jingle; @@ -17,10 +17,11 @@ impl super::RunnerFn for Jingle { } } -pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { +pub fn register(command: &mut CreateCommand) -> &mut CreateCommand { command .name("jingle") .description("Tanke o Bostil ou deixe-o") + .into() } pub fn get_command() -> Command { diff --git a/src/modules/app/commands/language.rs b/src/modules/app/commands/language.rs new file mode 100644 index 0000000..22ed2b5 --- /dev/null +++ b/src/modules/app/commands/language.rs @@ -0,0 +1,54 @@ +use serenity::builder::CreateCommand; +use serenity::{all::CommandOptionType, async_trait}; + +use super::{ + ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, +}; + +struct Language; + +#[async_trait] +impl RunnerFn for Language { + async fn run<'a>( + &self, + args: &Vec>, + ) -> InternalCommandResult<'a> { + Ok(CommandResponse::String("".to_string())) + } +} + +pub fn register(command: &mut CreateCommand) -> &mut CreateCommand { + command + .name("language") + .name_localized("pt-BR", "idioma") + .description("Language Preferences Menu") + .description_localized("pt-BR", "Menu de preferências de idioma") + .create_option(|option| { + option + .name("choose_language") + .name_localized("pt-BR", "alterar_idioma") + .description("Choose the language of preference") + .description_localized("pt-BR", "Escolha o idioma de preferência") + .kind(CommandOptionType::String) + .add_string_choice_localized( + "Portuguese", + "pt-BR", + [("pt-BR", "Português"), ("en-US", "Portuguese")], + ) + .add_string_choice_localized( + "English", + "en-US", + [("pt-BR", "Inglês"), ("en-US", "English")], + ) + }) +} + +pub fn get_command() -> Command { + Command::new( + "language", + "Language Preferences Menu", + CommandCategory::General, + vec![ArgumentsLevel::Options, ArgumentsLevel::Guild], + Box::new(Language {}), + ) +} diff --git a/src/commands/mod.rs b/src/modules/app/commands/mod.rs similarity index 94% rename from src/commands/mod.rs rename to src/modules/app/commands/mod.rs index 725c490..7155778 100644 --- a/src/commands/mod.rs +++ b/src/modules/app/commands/mod.rs @@ -2,12 +2,12 @@ use std::any::Any; use serenity::{ async_trait, - builder::{CreateEmbed, CreateInteractionResponseData}, + builder::{CreateEmbed, EditInteractionResponse}, framework::standard::CommandResult, model::prelude::Embed, }; -use crate::internal::arguments::ArgumentsLevel; +use crate::modules::core::lib::arguments::ArgumentsLevel; pub mod jingle; pub mod language; @@ -60,14 +60,14 @@ impl Command { } #[derive(Debug, Clone)] -pub enum CommandResponse<'a> { +pub enum CommandResponse { String(String), Embed(Embed), - Message(CreateInteractionResponseData<'a>), + Message(EditInteractionResponse), None, } -impl CommandResponse<'_> { +impl CommandResponse { pub fn to_embed(&self) -> CreateEmbed { match self { CommandResponse::String(string) => { @@ -113,7 +113,7 @@ impl CommandResponse<'_> { } } -impl PartialEq for CommandResponse<'_> { +impl PartialEq for CommandResponse { fn eq(&self, other: &Self) -> bool { match self { CommandResponse::String(string) => match other { @@ -152,7 +152,7 @@ impl PartialEq for CommandResponse<'_> { } } -impl std::fmt::Display for CommandResponse<'_> { +impl std::fmt::Display for CommandResponse { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { CommandResponse::String(string) => write!(f, "{}", string), @@ -164,7 +164,7 @@ impl std::fmt::Display for CommandResponse<'_> { } // command result must be a string or an embed -pub type InternalCommandResult<'a> = CommandResult>; +pub type InternalCommandResult<'a> = CommandResult; #[async_trait] pub trait RunnerFn { diff --git a/src/commands/ping.rs b/src/modules/app/commands/ping.rs similarity index 75% rename from src/commands/ping.rs rename to src/modules/app/commands/ping.rs index a9884a5..f078eb2 100644 --- a/src/commands/ping.rs +++ b/src/modules/app/commands/ping.rs @@ -1,8 +1,8 @@ -use super::{ArgumentsLevel, Command, CommandCategory, InternalCommandResult, RunnerFn}; -use crate::commands::CommandResponse; +use super::{ + ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, +}; -use serenity::async_trait; -use serenity::builder::CreateApplicationCommand; +use serenity::{async_trait, builder::CreateCommand}; use std::any::Any; use tokio::time::Instant; @@ -25,10 +25,11 @@ impl RunnerFn for Ping { } } -pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { +pub fn register(command: &mut CreateCommand) -> &mut CreateCommand { command .name("ping") .description("Check if the bot is alive, and test the latency to the server") + .into() } pub fn get_command() -> Command { diff --git a/src/commands/poll/database.rs b/src/modules/app/commands/poll/database.rs similarity index 72% rename from src/commands/poll/database.rs rename to src/modules/app/commands/poll/database.rs index ae40ce0..c308ce2 100644 --- a/src/commands/poll/database.rs +++ b/src/modules/app/commands/poll/database.rs @@ -1,45 +1,22 @@ use std::borrow::BorrowMut; -use super::{Poll, PollDatabaseModel as PollModel, PollStatus, PollType, Vote}; +use super::{Poll, PollDatabaseModel, PollOption, PollStatus, PollType, Vote}; use crate::{ database::{get_database, save_database, GuildDatabaseModel}, internal::debug::{log_message, MessageTypes}, }; +use nanoid::nanoid; use serenity::model::{ id::MessageId, prelude::{ChannelId, GuildId, UserId}, }; use yaml_rust::Yaml; -impl PollModel { - pub fn from( - poll: &Poll, - votes: Vec, - user_id: &UserId, - thread_id: &ChannelId, - message_id: &MessageId, - ) -> PollModel { - PollModel { - votes, - id: poll.id, - kind: poll.kind, - timer: Some(poll.timer), - status: poll.status, - name: poll.name.clone(), - description: poll.description.clone(), - options: poll.options.clone(), - thread_id: thread_id.clone(), - message_id: message_id.clone(), - created_at: std::time::SystemTime::now(), - created_by: user_id.clone(), - } - } - - pub fn from_yaml(yaml: &Yaml) -> PollModel { - PollModel { - votes: Vec::new(), - id: uuid::Uuid::parse_str(yaml["id"].as_str().unwrap()).unwrap(), +impl PollDatabaseModel { + pub fn from_yaml(yaml: &Yaml) -> PollDatabaseModel { + PollDatabaseModel { + id: nanoid!().into(), name: yaml["name"].as_str().unwrap().to_string(), description: match yaml["description"].as_str() { Some(description) => Some(description.to_string()), @@ -54,12 +31,26 @@ impl PollModel { .as_vec() .unwrap() .iter() - .map(|option| option.as_str().unwrap().to_string()) - .collect::>(), + .map(|option| PollOption { + value: option["value"].as_str().unwrap().to_string(), + description: match option["description"].as_str() { + Some(description) => Some(description.to_string()), + None => None, + }, + votes: option["votes"] + .as_vec() + .unwrap() + .iter() + .map(|vote| Vote { + user_id: UserId(vote["user_id"].as_i64().unwrap() as u64), + }) + .collect::>(), + }) + .collect::>(), timer: Some(std::time::Duration::from_secs( yaml["timer"].as_i64().unwrap() as u64, )), - message_id: MessageId(yaml["message_id"].as_i64().unwrap() as u64), + embed_message_id: MessageId(yaml["message_id"].as_i64().unwrap() as u64), thread_id: ChannelId(yaml["thread_id"].as_i64().unwrap().try_into().unwrap()), created_at: std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(yaml["created_at"].as_i64().unwrap() as u64), @@ -117,10 +108,9 @@ pub fn save_poll( thread_id: &ChannelId, message_id: &MessageId, poll: &Poll, - votes: Vec, ) { let database = get_database(); - let poll_model = PollModel::from(poll, votes, user_id, thread_id, message_id); + let poll_model = PollModel::from(poll, user_id, thread_id, message_id); if let Some(guild) = database.lock().unwrap().guilds.get_mut(&guild_id) { guild.polls.push(poll_model); diff --git a/src/commands/poll/help.rs b/src/modules/app/commands/poll/help.rs similarity index 97% rename from src/commands/poll/help.rs rename to src/modules/app/commands/poll/help.rs index eb00a34..3eb1d9d 100644 --- a/src/commands/poll/help.rs +++ b/src/modules/app/commands/poll/help.rs @@ -24,7 +24,10 @@ struct PollHelpCommand; #[async_trait] impl RunnerFn for PollHelpCommand { - async fn run<'a>(&self, _: &Vec>) -> InternalCommandResult<'a> { + async fn run<'a>( + &self, + _: &Vec>, + ) -> InternalCommandResult<'a> { let mut help_message: String = "```".to_string(); for helper in collect_command_help() { diff --git a/src/commands/poll/management/mod.rs b/src/modules/app/commands/poll/management/mod.rs similarity index 100% rename from src/commands/poll/management/mod.rs rename to src/modules/app/commands/poll/management/mod.rs diff --git a/src/modules/app/commands/poll/mod.rs b/src/modules/app/commands/poll/mod.rs new file mode 100644 index 0000000..477d901 --- /dev/null +++ b/src/modules/app/commands/poll/mod.rs @@ -0,0 +1,345 @@ +use crate::database::get_database; + +use super::{ + ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, +}; + +use core::fmt; +use regex::Regex; +use rust_i18n::t; +use serenity::{ + async_trait, + model::{ + id::{GuildId, MessageId}, + prelude::{ + application_command::{CommandDataOption, CommandDataOptionValue}, + ChannelId, UserId, + }, + }, + utils::Colour, +}; +use std::time::{Duration, SystemTime}; + +mod database; +pub mod help; +pub mod management; +pub mod setup; +mod utils; + +struct PollCommand; + +#[derive(Debug, Clone)] +pub struct Vote { + pub user_id: UserId, +} + +#[derive(Debug, Clone, Copy)] +pub enum PollType { + SingleChoice, + MultipleChoice, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum PollStatus { + Open, + Closed, + Stopped, + Ready, + NotReady, +} + +#[derive(Debug, Clone, Copy)] +pub enum PollStage { + Setup, + Voting, + Closed, +} + +#[derive(Debug, Clone)] +pub struct PollOption { + pub value: String, + pub description: Option, + pub votes: Vec, +} + +#[derive(Debug)] +pub struct Poll { + id: String, + name: String, + description: Option, + kind: PollType, + options: Vec, + timer: Duration, + status: PollStatus, +} + +#[derive(Clone)] +pub struct PartialPoll { + id: Option, + name: String, + description: Option, + kind: Option, + options: Option>, + timer: Duration, + status: PollStatus, + created_by: UserId, +} + +#[derive(Debug, Clone)] +pub struct PollDatabaseModel { + pub id: String, + pub name: String, + pub description: Option, + pub kind: Option, + pub status: Option, + pub timer: Option, + pub options: Option>, + pub thread_id: Option, + pub embed_message_id: Option, + pub poll_message_id: Option, + pub created_at: SystemTime, + pub created_by: UserId, +} + +impl PollStage { + pub fn embed_color(&self) -> Colour { + match self { + PollStage::Setup => Colour::ORANGE, + PollStage::Voting => Colour::RED, + PollStage::Closed => Colour::DARK_GREEN, + } + } + + pub fn to_status(&self) -> PollStatus { + match self { + PollStage::Setup => PollStatus::NotReady, + PollStage::Voting => PollStatus::Open, + PollStage::Closed => PollStatus::Closed, + } + } +} + +impl PollType { + pub fn to_int(&self) -> i32 { + match self { + PollType::SingleChoice => 1, + PollType::MultipleChoice => 2, + } + } +} + +impl PartialPoll { + pub fn new( + name: &str, + description: Option, + kind: Option, + options: Option>, + // Receives a minute value as a string (e.g. "0.5" for 30 seconds, "1" for 1 minute, "2" for 2 minutes, etc.) + timer: Option, + status: Option, + created_by: UserId, + ) -> PartialPoll { + PartialPoll { + id: nanoid::nanoid!().into(), + name: name.to_string(), + description, + kind, + options, + timer: match timer { + Some(timer) => { + let timer = timer.parse::().unwrap_or(0.0); + Duration::from_secs_f64(timer * 60.0) + } + None => Duration::from_secs(60), + }, + status: status.unwrap_or(PollStatus::NotReady), + created_by, + } + } +} + +impl PollDatabaseModel { + pub fn from_id(id: String) -> PollDatabaseModel { + let database_manager = get_database(); + let database = database_manager.lock().unwrap(); + + let poll = database + .guilds + .iter() + .find_map(|(_, guild)| guild.polls.iter().find(|poll| poll.id == id)); + + poll.unwrap().clone() + } +} + +impl Poll { + pub fn new( + name: String, + description: Option, + kind: PollType, + options: Vec, + // Receives a minute value as a string (e.g. "0.5" for 30 seconds, "1" for 1 minute, "2" for 2 minutes, etc.) + timer: Option, + status: Option, + ) -> Poll { + Poll { + name, + description, + kind, + options, + id: nanoid::nanoid!(), + status: status.unwrap_or(PollStatus::Open), + timer: match timer { + Some(timer) => { + let timer = timer.parse::().unwrap_or(0.0); + Duration::from_secs_f64(timer * 60.0) + } + None => Duration::from_secs(60), + }, + } + } + + pub fn from_id(id: String) -> PollDatabaseModel { + PollDatabaseModel::from_id(id) + } +} + +impl PollType { + pub fn to_string(&self) -> String { + match self { + PollType::SingleChoice => "single_choice".to_string(), + PollType::MultipleChoice => "multiple_choice".to_string(), + } + } + + pub fn to_label(&self) -> String { + match self { + PollType::SingleChoice => t!("commands.poll.types.single_choice.label"), + PollType::MultipleChoice => t!("commands.poll.types.single_choice.label"), + } + } +} + +impl PollStatus { + pub fn to_string(&self) -> String { + match self { + PollStatus::Open => "Open".to_string(), + PollStatus::Closed => "Closed".to_string(), + PollStatus::Stopped => "Stopped".to_string(), + PollStatus::NotReady => "Not Ready".to_string(), + PollStatus::Ready => "Ready".to_string(), + } + } +} + +impl fmt::Display for PollOption { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.value) + } +} + +// fn poll_serializer(command_options: &Vec) -> Poll { +// let option_regex: Regex = Regex::new(r"^option_\d+$").unwrap(); +// let kind = match command_options.iter().find(|option| option.name == "type") { +// Some(option) => match option.resolved.as_ref().unwrap() { +// CommandDataOptionValue::String(value) => match value.as_str() { +// "single_choice" => PollType::SingleChoice, +// "multiple_choice" => PollType::MultipleChoice, +// _ => PollType::SingleChoice, +// }, +// _ => PollType::SingleChoice, +// }, +// None => PollType::SingleChoice, +// }; + +// Poll::new( +// command_options +// .iter() +// .find(|option| option.name == "name") +// .unwrap() +// .value +// .as_ref() +// .unwrap() +// .to_string(), +// Some( +// command_options +// .iter() +// .find(|option| option.name == "description") +// .unwrap() +// .value +// .as_ref() +// .unwrap() +// .to_string(), +// ), +// kind, +// command_options +// .iter() +// .filter(|option| option_regex.is_match(&option.name)) +// .map(|option| PollOption { +// value: option.value.as_ref().unwrap().to_string(), +// description: None, +// votes: vec![], +// }) +// .collect::>(), +// Some( +// command_options +// .iter() +// .find(|option| option.name == "timer") +// .unwrap() +// .value +// .as_ref() +// .unwrap() +// .to_string(), +// ), +// Some(PollStatus::Open), +// ) +// } + +#[async_trait] +impl RunnerFn for PollCommand { + async fn run<'a>( + &self, + args: &Vec>, + ) -> InternalCommandResult<'a> { + let options = args + .iter() + .filter_map(|arg| arg.downcast_ref::>>()) + .collect::>>>()[0] + .as_ref() + .unwrap(); + let first_option = options.get(0).unwrap(); + let command_name = first_option.name.clone(); + + let command_runner = command_suite(command_name); + + let response = command_runner.run(args); + + response.await + } +} + +fn command_suite(command_name: String) -> Box { + let command_runner = match command_name.as_str() { + "help" => self::help::get_command().runner, + "setup" => self::setup::create::get_command().runner, + _ => get_command().runner, + }; + + command_runner +} + +pub fn get_command() -> Command { + Command::new( + "poll", + "Poll commands", + CommandCategory::Misc, + vec![ + ArgumentsLevel::Options, + ArgumentsLevel::Context, + ArgumentsLevel::Guild, + ArgumentsLevel::User, + ArgumentsLevel::ChannelId, + ], + Box::new(PollCommand), + ) +} diff --git a/src/modules/app/commands/poll/setup/create.rs b/src/modules/app/commands/poll/setup/create.rs new file mode 100644 index 0000000..b86437e --- /dev/null +++ b/src/modules/app/commands/poll/setup/create.rs @@ -0,0 +1,329 @@ +use std::{os::fd::IntoRawFd, sync::Arc, time::Duration}; + +use super::embeds::setup::get_embed; +use crate::{ + commands::{ + poll::{PartialPoll, Poll, PollStage, PollStatus, PollType}, + ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, + }, + components, + internal::debug::{log_message, MessageTypes}, +}; + +use rust_i18n::t; +use serenity::{ + async_trait, + builder::CreateApplicationCommandOption, + collector::ComponentInteractionCollector, + futures::StreamExt, + model::{ + application::interaction::message_component::MessageComponentInteraction, + prelude::{ + application_command::CommandDataOption, + command::CommandOptionType, + component::{ButtonStyle, InputTextStyle}, + modal::ModalSubmitInteraction, + ChannelId, InteractionResponseType, + }, + user::User, + }, + prelude::Context, +}; + +struct CreatePollRunner; + +#[async_trait] +impl RunnerFn for CreatePollRunner { + async fn run<'a>( + &self, + args: &Vec>, + ) -> InternalCommandResult<'a> { + let options = args + .iter() + .filter_map(|arg| arg.downcast_ref::>>()) + .collect::>>>()[0] + .as_ref() + .unwrap(); + let subcommand_options = &options[0].options; + + let poll_name = match subcommand_options + .iter() + .find(|option| option.name == "name") + { + Some(option) => option.value.as_ref().unwrap().as_str().unwrap(), + None => { + panic!("Poll name is required") + } + }; + let poll_description = match subcommand_options + .iter() + .find(|option| option.name == "description") + { + Some(option) => Some(option.value.as_ref().unwrap().to_string()), + None => None, + }; + let ctx = args + .iter() + .find_map(|arg| arg.downcast_ref::()) + .unwrap(); + let channel_id = args + .iter() + .find_map(|arg| arg.downcast_ref::()) + .unwrap(); + let user_id = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>() + .get(0) + .unwrap() + .id; + + // Step 1: Create thread + let thread_channel = channel_id + .create_private_thread(ctx.http.clone(), |thread| thread.name(poll_name)) + .await?; + + thread_channel + .id + .add_thread_member(ctx.http.clone(), user_id) + .await?; + + let mut setup_embed = get_embed(); + + // Step 2: Create a partial poll and send it to the thread + setup_embed.arguments = vec![ + Box::new(PartialPoll::new( + poll_name, + poll_description, + None, + Some(vec![]), + None, + Some(PollStatus::NotReady), + user_id, + )), + Box::new(PollStage::Setup), + ]; + + setup_embed.send_message(thread_channel.clone(), ctx).await; + + // Step 3: Add buttons to the message to choose between add options, starting poll and cancel + setup_embed + .message + .clone() + .unwrap() + .edit(&ctx.http, |message| { + message.components(|components| { + // Action row for buttons + components.create_action_row(|action_row| { + action_row + .create_button(|button| { + button + .custom_id("add_option") + .label("Add option") + .style(ButtonStyle::Secondary) + }) + .create_button(|button| { + button + .custom_id("start_poll") + .label("Start poll") + .disabled(true) + .style(ButtonStyle::Primary) + }) + .create_button(|button| { + button + .custom_id("cancel") + .label("Cancel") + .style(ButtonStyle::Danger) + }) + }); + + components.create_action_row(|action_row| { + // Select menu for poll type + action_row.create_select_menu(|select_menu| { + select_menu + .custom_id("poll_type") + .placeholder("Escolha o tipo da votação") + .options(|options| { + options + .create_option(|option| { + option + .label("Single choice") + .value(PollType::SingleChoice.to_int().to_string()) + .description("Single choice poll") + }) + .create_option(|option| { + option + .label("Multiple choice") + .value( + PollType::MultipleChoice.to_int().to_string(), + ) + .description("Multiple choice poll") + }) + }) + }) + }) + }) + }) + .await?; + + // Step 5: Add interaction listener + let interaction_stream = setup_embed + .message + .clone() + .unwrap() + .await_component_interactions(&ctx) + .timeout(Duration::from_secs(60 * 60 * 24)) // 1 Day to configure the poll + .build(); + + interaction_handler(interaction_stream, ctx).await; + + Ok(CommandResponse::String(t!( + "commands.poll.setup.response.initial", + "thread_id" => thread_channel.id, + ))) + } +} + +async fn interaction_handler(mut interaction_stream: ComponentInteractionCollector, ctx: &Context) { + match interaction_stream.next().await { + Some(interaction) => { + let interaction_id = interaction.data.custom_id.as_str(); + + match interaction_id { + "add_option" => add_option(interaction, ctx).await, + _ => {} + } + } + + None => { + log_message("No interaction received in 1 day", MessageTypes::Failed); + } + } +} + +async fn add_option(interaction: Arc, ctx: &Context) { + match interaction + .create_interaction_response(&ctx.http, |response| { + response + .kind(InteractionResponseType::Modal) + .interaction_response_data(|message| { + message + .custom_id(format!("option_data_poll/{}", interaction.id)) + .title("Adicionar opção") + .components(|components| { + components + .create_action_row(|action_row| { + action_row.create_input_text(|field| { + field + .custom_id(format!("name_option/{}", interaction.id)) + .label("Nome da opção") + .placeholder("Digite o nome da opção") + .max_length(25) + .min_length(1) + .required(true) + .style(InputTextStyle::Short) + }) + }) + .create_action_row(|action_row| { + action_row.create_input_text(|field| { + field + .custom_id(format!( + "description_option/{}", + interaction.id + )) + .label("Descrição da opção") + .placeholder("Digite a descrição da opção") + .max_length(200) + .min_length(1) + .style(InputTextStyle::Paragraph) + }) + }) + }) + }) + }) + .await + { + Ok(_) => {} + Err(why) => { + log_message( + &format!("Failed to create interaction response: {}", why), + MessageTypes::Error, + ); + } + } +} + +// pub async fn handle_modal(ctx: &Context, command: &ModalSubmitInteraction) { +// if let Err(why) = command +// .create_interaction_response(&ctx.http, |m| { +// m.kind(InteractionResponseType::DeferredUpdateMessage) +// }) +// .await +// { +// log_message( +// &format!("Failed to create interaction response: {}", why), +// MessageTypes::Error, +// ); +// } +// } + +pub fn register_option<'a>() -> CreateApplicationCommandOption { + let mut command_option = CreateApplicationCommandOption::default(); + + command_option + .name("setup") + .name_localized("pt-BR", "configurar") + .description("Setup a poll") + .description_localized("pt-BR", "Configura uma votação") + .kind(CommandOptionType::SubCommand) + .create_sub_option(|sub_option| { + sub_option + .name("name") + .name_localized("pt-BR", "nome") + .description("The name of the option (max 25 characters)") + .description_localized("pt-BR", "O nome da opção (máx 25 caracteres)") + .kind(CommandOptionType::String) + .max_length(25) + .required(true) + }) + .create_sub_option(|sub_option| { + sub_option + .name("channel") + .name_localized("pt-BR", "canal") + .description("The channel where the poll will be created") + .description_localized("pt-BR", "O canal onde a votação será realizada") + .kind(CommandOptionType::Channel) + .required(true) + }) + .create_sub_option(|sub_option| { + sub_option + .name("description") + .name_localized("pt-BR", "descrição") + .description("The description of the option (max 365 characters)") + .description_localized( + "pt-BR", + "A descrição dessa opção (máximo de 365 caracteres)", + ) + .kind(CommandOptionType::String) + .max_length(365) + }); + + command_option +} + +pub fn get_command() -> Command { + Command::new( + "setup", + "Setup a poll", + CommandCategory::Misc, + vec![ + ArgumentsLevel::Options, + ArgumentsLevel::Context, + ArgumentsLevel::Guild, + ArgumentsLevel::User, + ArgumentsLevel::ChannelId, + ArgumentsLevel::InteractionId, + ], + Box::new(CreatePollRunner {}), + ) +} diff --git a/src/modules/app/commands/poll/setup/embeds/mod.rs b/src/modules/app/commands/poll/setup/embeds/mod.rs new file mode 100644 index 0000000..175c00c --- /dev/null +++ b/src/modules/app/commands/poll/setup/embeds/mod.rs @@ -0,0 +1,45 @@ +use serenity::builder::CreateEmbed; +use serenity::model::prelude::{ChannelId, GuildId, MessageId}; +use serenity::prelude::Context; + +use crate::commands::poll::PollDatabaseModel; + +pub mod setup; +pub mod vote; + +/** + * EmbedPoll is a struct that represents the embed message that is sent to the channel + * + * builder is a function that takes a PollDatabaseModel sends a message to the channel + */ +struct EmbedPoll { + name: String, + message_id: Option, + channel_id: ChannelId, + guild_id: GuildId, + builder: Box CreateEmbed + Send + Sync>, +} + +impl EmbedPoll { + pub async fn update_message(&self, poll: PollDatabaseModel, ctx: Context) -> () { + let mut message = self + .channel_id + .message(&ctx.http, self.message_id.unwrap()) + .await + .unwrap(); + + let embed = self.builder.as_ref()(poll); + + message + .edit(&ctx.http, |m| m.set_embed(embed)) + .await + .unwrap(); + } + + pub async fn remove_message(&self, ctx: Context) { + self.channel_id + .delete_message(&ctx.http, self.message_id.unwrap()) + .await + .unwrap(); + } +} diff --git a/src/modules/app/commands/poll/setup/embeds/setup.rs b/src/modules/app/commands/poll/setup/embeds/setup.rs new file mode 100644 index 0000000..0858a19 --- /dev/null +++ b/src/modules/app/commands/poll/setup/embeds/setup.rs @@ -0,0 +1,63 @@ +use rust_i18n::t; +use serenity::{builder::CreateEmbed, framework::standard::CommandResult}; + +use crate::{ + commands::poll::{PartialPoll, PollStage}, + internal::embeds::{ApplicationEmbed, EmbedRunnerFn}, +}; +struct PollSetupEmbed; + +impl EmbedRunnerFn for PollSetupEmbed { + fn run(&self, arguments: &Vec>) -> CreateEmbed { + let poll = arguments[0].downcast_ref::().unwrap(); + let stage = arguments[1].downcast_ref::().unwrap(); + + runner(poll.clone(), stage.clone()).unwrap() + } +} + +fn runner(poll: PartialPoll, stage: PollStage) -> CommandResult { + let mut embed = CreateEmbed::default(); + + embed.color(stage.embed_color()); + + match stage { + PollStage::Setup => { + embed.title(t!("commands.poll.setup.embed.stages.setup.title")); + embed.description(t!("commands.poll.setup.embed.stages.setup.description")); + + embed.field( + "ID", + poll.id + .map_or(t!("commands.poll.setup.embed.id_none"), |id| id.to_string()), + true, + ); + embed.field("User", format!("<@{}>", poll.created_by), true); + embed.field("\u{200B}", "\u{200B}", false); // Separator + } + PollStage::Voting => { + embed.title(t!("commands.poll.setup.embed.stages.voting.title")); + embed.description(t!("commands.poll.setup.stages.voting.description")); + } + PollStage::Closed => { + embed.title(t!("commands.poll.setup.embed.stages.closed.title")); + embed.description(t!("commands.poll.setup.stages.closed.description")); + } + } + + Ok(embed) +} + +pub fn get_embed() -> ApplicationEmbed { + ApplicationEmbed { + name: "Poll Setup".to_string(), + description: Some("Embed to configure poll".to_string()), + builder: Box::new(PollSetupEmbed), + arguments: vec![ + Box::new(None::>), + Box::new(None::>), + ], + message_content: None, + message: None, + } +} diff --git a/src/commands/poll/setup/embeds/vote.rs b/src/modules/app/commands/poll/setup/embeds/vote.rs similarity index 77% rename from src/commands/poll/setup/embeds/vote.rs rename to src/modules/app/commands/poll/setup/embeds/vote.rs index 68fda3c..ff29502 100644 --- a/src/commands/poll/setup/embeds/vote.rs +++ b/src/modules/app/commands/poll/setup/embeds/vote.rs @@ -35,7 +35,11 @@ pub fn embed( embed.field("\u{200B}", "\u{200B}", false); poll.options.iter().for_each(|option| { - embed.field(option, option, false); + embed.field( + option.value.clone(), + format!("{} votes", option.votes.len()), + true, + ); }); // separator @@ -43,10 +47,7 @@ pub fn embed( embed.field( "Partial Results (Live)", - format!( - "```diff\n{}\n```", - progress_bar(poll.votes, poll.options.clone()) - ), + format!("```diff\n{}\n```", progress_bar(poll.options.clone())), false, ); @@ -63,8 +64,15 @@ pub fn embed( message_builder.components(|component| { component.create_action_row(|action_row| { poll.options.iter().for_each(|option| { - action_row - .add_button(Button::new(option, option, ButtonStyle::Primary, None).create()); + action_row.add_button( + Button::new( + option.value.as_str(), + option.value.as_str(), + ButtonStyle::Primary, + None, + ) + .create(), + ); }); action_row diff --git a/src/commands/poll/setup/mod.rs b/src/modules/app/commands/poll/setup/mod.rs similarity index 100% rename from src/commands/poll/setup/mod.rs rename to src/modules/app/commands/poll/setup/mod.rs diff --git a/src/commands/poll/setup/options.rs b/src/modules/app/commands/poll/setup/options.rs similarity index 100% rename from src/commands/poll/setup/options.rs rename to src/modules/app/commands/poll/setup/options.rs diff --git a/src/commands/poll/utils/mod.rs b/src/modules/app/commands/poll/utils/mod.rs similarity index 60% rename from src/commands/poll/utils/mod.rs rename to src/modules/app/commands/poll/utils/mod.rs index 04100cf..6ce6c9a 100644 --- a/src/commands/poll/utils/mod.rs +++ b/src/modules/app/commands/poll/utils/mod.rs @@ -1,25 +1,7 @@ -use super::Vote; +use super::PollOption; type PartialResults = Vec<(String, u64)>; -pub fn partial_results(votes: Vec, options: Vec) -> PartialResults { - let mut results = Vec::new(); - - for option in options { - let mut count = 0; - - for vote in &votes { - if vote.options.contains(&option) { - count += 1; - } - } - - results.push((option, count)); - } - - results -} - /** Returns a string with a progress bar for each option. @@ -27,8 +9,11 @@ pub fn partial_results(votes: Vec, options: Vec) -> PartialResults Option 1: ████░░░░░░ 45% Option 2: ████████░░ 75% */ -pub fn progress_bar(votes: Vec, options: Vec) -> String { - let results = partial_results(votes, options); +pub fn progress_bar(options: Vec) -> String { + let results: PartialResults = options + .iter() + .map(|option| (option.value.clone(), option.votes.len() as u64)) + .collect(); let mut progress_bar = String::new(); let total_votes = results.iter().fold(0, |acc, (_, count)| acc + count); diff --git a/src/commands/radio/consumer.rs b/src/modules/app/commands/radio/consumer.rs similarity index 80% rename from src/commands/radio/consumer.rs rename to src/modules/app/commands/radio/consumer.rs index 10bfce1..9cc1971 100644 --- a/src/commands/radio/consumer.rs +++ b/src/modules/app/commands/radio/consumer.rs @@ -1,10 +1,6 @@ -use super::Radio; -use crate::{ - internal::debug::{log_message, MessageTypes}, - modules::equalizers::RADIO_EQUALIZER, -}; +use super::{equalizers::RADIO_EQUALIZER, Radio}; -use songbird::input::{ffmpeg_optioned, Input}; +use songbird::input::Input; pub async fn consumer(radio: Radio) -> Result { let url = radio.get_url().unwrap(); diff --git a/src/modules/equalizers.rs b/src/modules/app/commands/radio/equalizers.rs similarity index 100% rename from src/modules/equalizers.rs rename to src/modules/app/commands/radio/equalizers.rs diff --git a/src/commands/radio/mod.rs b/src/modules/app/commands/radio/mod.rs similarity index 93% rename from src/commands/radio/mod.rs rename to src/modules/app/commands/radio/mod.rs index f92db48..3680c94 100644 --- a/src/commands/radio/mod.rs +++ b/src/modules/app/commands/radio/mod.rs @@ -1,24 +1,21 @@ pub mod consumer; - -use crate::{ - events::voice::join, - internal::debug::{log_message, MessageTypes}, -}; +pub mod equalizers; use rust_i18n::t; use serenity::{ + all::{CommandDataOption, CommandDataOptionValue, CommandOptionType, Guild, User, UserId}, async_trait, - builder::CreateApplicationCommand, + builder::CreateCommand, framework::standard::CommandResult, - model::{ - application::interaction::application_command::CommandDataOptionValue, - prelude::{command, interaction::application_command::CommandDataOption, Guild, UserId}, - user::User, - }, prelude::Context, }; +use crate::modules::core::{ + actions::voice::join, + lib::debug::{log_message, MessageTypes}, +}; + use super::{ ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, }; @@ -178,7 +175,7 @@ pub async fn run( Ok(t!("commands.radio.reply", "radio_name" => radio.to_string())) } -pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { +pub fn register(command: &mut CreateCommand) -> &mut CreateCommand { command .name("radio") .description("Tune in to the best radios in Bostil") @@ -188,7 +185,7 @@ pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicatio .name("radio") .description("The radio to tune in") .description_localized("pt-BR", "A rádio para sintonizar") - .kind(command::CommandOptionType::String) + .kind(CommandOptionType::String) .required(true) .add_string_choice_localized( "Canoa Grande FM", @@ -221,6 +218,7 @@ pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicatio [("pt-BR", "94 FM"), ("en-US", "94 FM")], ) }) + .into() } pub fn get_command() -> Command { diff --git a/src/commands/voice/join.rs b/src/modules/app/commands/voice/join.rs similarity index 75% rename from src/commands/voice/join.rs rename to src/modules/app/commands/voice/join.rs index 2c234fd..7186f34 100644 --- a/src/commands/voice/join.rs +++ b/src/modules/app/commands/voice/join.rs @@ -1,21 +1,23 @@ -use crate::{ - commands::{ - ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, - }, - events::voice::join, -}; use serenity::{ async_trait, - builder::CreateApplicationCommand, + builder::CreateCommand, model::prelude::{Guild, UserId}, prelude::Context, }; +use crate::modules::{ + app::commands::{Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn}, + core::{actions::voice::join, lib::arguments::ArgumentsLevel}, +}; + struct JoinCommand; #[async_trait] impl RunnerFn for JoinCommand { - async fn run<'a>(&self, args: &Vec>) -> InternalCommandResult<'a> { + async fn run<'a>( + &self, + args: &Vec>, + ) -> InternalCommandResult<'a> { let ctx = args .iter() .filter_map(|arg| arg.downcast_ref::()) @@ -36,12 +38,13 @@ impl RunnerFn for JoinCommand { } } -pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { +pub fn register(command: &mut CreateCommand) -> &mut CreateCommand { command .name("join") .name_localized("pt-BR", "entrar") .description("Join the voice channel you are in") .description_localized("pt-BR", "Entra no canal de voz que você está") + .into() } pub fn get_command() -> Command { diff --git a/src/commands/voice/leave.rs b/src/modules/app/commands/voice/leave.rs similarity index 83% rename from src/commands/voice/leave.rs rename to src/modules/app/commands/voice/leave.rs index d9b534b..c5ad63a 100644 --- a/src/commands/voice/leave.rs +++ b/src/modules/app/commands/voice/leave.rs @@ -1,17 +1,15 @@ -use crate::{ - commands::{ - ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, - }, - events::voice::leave, -}; - use serenity::{ async_trait, - builder::CreateApplicationCommand, + builder::CreateCommand, model::{prelude::Guild, user::User}, prelude::Context, }; +use crate::modules::{ + app::commands::{Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn}, + core::{actions::voice::leave, lib::arguments::ArgumentsLevel}, +}; + struct LeaveCommand; #[async_trait] @@ -43,12 +41,13 @@ impl RunnerFn for LeaveCommand { } } -pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { +pub fn register(command: &mut CreateCommand) -> &mut CreateCommand { command .name("leave") .name_localized("pt-BR", "sair") .description("Leave the voice channel you are in") .description_localized("pt-BR", "Sai do canal de voz que você está") + .into() } pub fn get_command() -> Command { diff --git a/src/commands/voice/mod.rs b/src/modules/app/commands/voice/mod.rs similarity index 100% rename from src/commands/voice/mod.rs rename to src/modules/app/commands/voice/mod.rs diff --git a/src/commands/voice/mute.rs b/src/modules/app/commands/voice/mute.rs similarity index 84% rename from src/commands/voice/mute.rs rename to src/modules/app/commands/voice/mute.rs index b506dd7..92e8717 100644 --- a/src/commands/voice/mute.rs +++ b/src/modules/app/commands/voice/mute.rs @@ -1,17 +1,15 @@ -use crate::{ - commands::{ - ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, +use crate::modules::{ + app::commands::{Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn}, + core::{ + actions::voice::{mute, unmute}, + lib::arguments::ArgumentsLevel, }, - events::voice::{mute, unmute}, }; use serenity::{ + all::{CommandDataOption, CommandOptionType, Guild, UserId}, async_trait, - builder::CreateApplicationCommand, - model::prelude::{ - command::CommandOptionType, interaction::application_command::CommandDataOption, Guild, - UserId, - }, + builder::CreateCommand, prelude::Context, }; @@ -62,7 +60,7 @@ impl RunnerFn for MuteCommand { } } -pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { +pub fn register(command: &mut CreateCommand) -> &mut CreateCommand { command .name("mute") .name_localized("pt-BR", "silenciar") @@ -76,6 +74,7 @@ pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicatio .description_localized("pt-BR", "Habilitar som") .kind(CommandOptionType::Boolean) }) + .into() } pub fn get_command() -> Command { diff --git a/src/interactions/chat/love.rs b/src/modules/app/listeners/chat/love.rs similarity index 98% rename from src/interactions/chat/love.rs rename to src/modules/app/listeners/chat/love.rs index 09b9d65..ef534e6 100644 --- a/src/interactions/chat/love.rs +++ b/src/modules/app/listeners/chat/love.rs @@ -8,7 +8,7 @@ use std::cell::RefCell; use rust_i18n::t; use serenity::async_trait; use serenity::client::Context; -use serenity::model::prelude::{ChannelId, UserId}; +use serenity::model::prelude::ChannelId; use serenity::model::user::User; thread_local! { diff --git a/src/modules/app/listeners/chat/mod.rs b/src/modules/app/listeners/chat/mod.rs new file mode 100644 index 0000000..32a393c --- /dev/null +++ b/src/modules/app/listeners/chat/mod.rs @@ -0,0 +1 @@ +pub mod love; diff --git a/src/modules/app/listeners/command/mod.rs b/src/modules/app/listeners/command/mod.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/interactions/mod.rs b/src/modules/app/listeners/mod.rs similarity index 94% rename from src/interactions/mod.rs rename to src/modules/app/listeners/mod.rs index 5f17128..38931b8 100644 --- a/src/interactions/mod.rs +++ b/src/modules/app/listeners/mod.rs @@ -1,10 +1,11 @@ use serenity::async_trait; -use crate::internal::arguments::ArgumentsLevel; +use crate::modules::core::lib::arguments::ArgumentsLevel; pub mod chat; +pub mod command; pub mod modal; -pub mod voice_channel; +pub mod voice; pub enum InteractionType { Chat, diff --git a/src/interactions/modal/mod.rs b/src/modules/app/listeners/modal/mod.rs similarity index 100% rename from src/interactions/modal/mod.rs rename to src/modules/app/listeners/modal/mod.rs diff --git a/src/interactions/modal/poll_option.rs b/src/modules/app/listeners/modal/poll_option.rs similarity index 82% rename from src/interactions/modal/poll_option.rs rename to src/modules/app/listeners/modal/poll_option.rs index 75f14c2..f076674 100644 --- a/src/interactions/modal/poll_option.rs +++ b/src/modules/app/listeners/modal/poll_option.rs @@ -9,7 +9,7 @@ use serenity::{ }; use crate::{ - commands::poll::Poll, + commands::poll::{Poll, PollOption}, interactions::{Interaction, InteractionType, RunnerFn}, internal::{ arguments::ArgumentsLevel, @@ -34,7 +34,7 @@ impl RunnerFn for PollOptionModalReceiver { // Step 1: Recover poll data from database let poll_id = submit_data.custom_id.split("/").collect::>()[1]; - let mut poll = Poll::from_id(poll_id.parse::().unwrap()); + let mut poll = Poll::from_id(poll_id.to_string()); log_message( format!("Received interaction with custom_id: {}", poll_id).as_str(), @@ -77,13 +77,28 @@ impl RunnerFn for PollOptionModalReceiver { ); // Step 3: Add new option to poll - poll.options.push(name.unwrap()); + match poll.options { + Some(ref mut options) => { + options.push(PollOption { + value: name.unwrap(), + description, + votes: vec![], + }); + } + None => { + poll.options = Some(vec![PollOption { + value: name.unwrap(), + description, + votes: vec![], + }]); + } + } // Step 4: Save poll to database poll.save(guild_id) // Step 5: Update poll message - + // poll.embed.update_message(ctx).await; } } diff --git a/src/interactions/voice_channel/join_channel.rs b/src/modules/app/listeners/voice/join_channel.rs similarity index 100% rename from src/interactions/voice_channel/join_channel.rs rename to src/modules/app/listeners/voice/join_channel.rs diff --git a/src/modules/app/listeners/voice/mod.rs b/src/modules/app/listeners/voice/mod.rs new file mode 100644 index 0000000..27324f9 --- /dev/null +++ b/src/modules/app/listeners/voice/mod.rs @@ -0,0 +1 @@ +pub mod join_channel; diff --git a/src/modules/app/mod.rs b/src/modules/app/mod.rs new file mode 100644 index 0000000..4136322 --- /dev/null +++ b/src/modules/app/mod.rs @@ -0,0 +1,3 @@ +pub mod commands; +pub mod listeners; +pub mod services; diff --git a/src/integrations/jukera.rs b/src/modules/app/services/integrations/jukera.rs similarity index 100% rename from src/integrations/jukera.rs rename to src/modules/app/services/integrations/jukera.rs diff --git a/src/integrations/mod.rs b/src/modules/app/services/integrations/mod.rs similarity index 94% rename from src/integrations/mod.rs rename to src/modules/app/services/integrations/mod.rs index a7e4f03..2cd0927 100644 --- a/src/integrations/mod.rs +++ b/src/modules/app/services/integrations/mod.rs @@ -1,4 +1,4 @@ -use crate::internal::debug::{log_message, MessageTypes}; +use crate::modules::core::lib::debug::{log_message, MessageTypes}; use serenity::async_trait; use serenity::model::channel::Message; diff --git a/src/modules/app/services/mod.rs b/src/modules/app/services/mod.rs new file mode 100644 index 0000000..6592868 --- /dev/null +++ b/src/modules/app/services/mod.rs @@ -0,0 +1 @@ +pub mod integrations; \ No newline at end of file diff --git a/src/modules/core/actions/mod.rs b/src/modules/core/actions/mod.rs new file mode 100644 index 0000000..5e2e12b --- /dev/null +++ b/src/modules/core/actions/mod.rs @@ -0,0 +1 @@ +pub mod voice; diff --git a/src/events/voice.rs b/src/modules/core/actions/voice.rs similarity index 98% rename from src/events/voice.rs rename to src/modules/core/actions/voice.rs index 9b530f4..7c72d84 100644 --- a/src/events/voice.rs +++ b/src/modules/core/actions/voice.rs @@ -1,11 +1,11 @@ -use crate::internal::debug::{log_message, MessageTypes}; - use rust_i18n::t; use serenity::framework::standard::CommandResult; use serenity::model::prelude::{Guild, UserId}; use serenity::prelude::Context; +use crate::modules::core::lib::debug::{log_message, MessageTypes}; + pub async fn join(ctx: &Context, guild: &Guild, user_id: &UserId) -> CommandResult { let debug = std::env::var("DEBUG").is_ok(); let channel_id = guild.voice_states.get(user_id).unwrap().channel_id; diff --git a/src/modules/core/constants.rs b/src/modules/core/constants.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/core/entities/guild.rs b/src/modules/core/entities/guild.rs new file mode 100644 index 0000000..c6558cf --- /dev/null +++ b/src/modules/core/entities/guild.rs @@ -0,0 +1,13 @@ +use diesel::prelude::*; + +use super::{GuildIdWrapper, Language}; + +#[derive(Queryable, Selectable, Identifiable)] +#[diesel(table_name = crate::schema::guilds)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct Guild { + pub id: GuildIdWrapper, + pub language: Language, + pub added_at: time::OffsetDateTime, + pub updated_at: time::OffsetDateTime, +} diff --git a/src/modules/core/entities/mod.rs b/src/modules/core/entities/mod.rs new file mode 100644 index 0000000..fe09fd5 --- /dev/null +++ b/src/modules/core/entities/mod.rs @@ -0,0 +1,158 @@ +use diesel::deserialize::{self, FromSql, FromSqlRow}; +use diesel::expression::AsExpression; +use diesel::pg::{Pg, PgValue}; +use diesel::serialize::{self, IsNull, Output, ToSql}; +use serenity::model::id::{ChannelId, GuildId, MessageId, UserId}; +use std::io::Write; + +// TODO: implement macro to generate trait for discord id wrappers + +#[derive(Debug, AsExpression, FromSqlRow)] +#[diesel(sql_type = diesel::sql_types::BigInt)] +pub struct ChannelIdWrapper(pub ChannelId); + +impl ToSql for ChannelIdWrapper { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + ToSql::::to_sql(&i64::from(self.0), out) + } +} + +impl FromSql for ChannelIdWrapper { + fn from_sql(bytes: PgValue) -> deserialize::Result { + i64::from_sql(bytes).map(|id| Self(ChannelId::new(id as u64))) + } +} + +impl FromSql, Pg> for ChannelIdWrapper { + fn from_sql(bytes: PgValue) -> deserialize::Result { + i64::from_sql(bytes).map(|id| Self(ChannelId::new(id as u64))) + } +} + +#[derive(Debug, AsExpression, FromSqlRow, Hash, PartialEq, Eq)] +#[diesel(primary_key(id))] +#[diesel(sql_type = diesel::sql_types::BigInt)] +pub struct GuildIdWrapper(pub GuildId); + +impl ToSql for GuildIdWrapper { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + ToSql::::to_sql(&i64::from(self.0), out) + } +} + +impl FromSql for GuildIdWrapper { + fn from_sql(bytes: PgValue) -> deserialize::Result { + i64::from_sql(bytes).map(|id| Self(GuildId::new(id as u64))) + } +} + +impl FromSql, Pg> for GuildIdWrapper { + fn from_sql(bytes: PgValue) -> deserialize::Result { + i64::from_sql(bytes).map(|id| Self(GuildId::new(id as u64))) + } +} + +#[derive(Debug, AsExpression, FromSqlRow)] +#[diesel(sql_type = diesel::sql_types::BigInt)] +pub struct MessageIdWrapper(pub MessageId); + +impl ToSql for MessageIdWrapper { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + ToSql::::to_sql(&i64::from(self.0), out) + } +} + +impl FromSql for MessageIdWrapper { + fn from_sql(bytes: PgValue) -> deserialize::Result { + i64::from_sql(bytes).map(|id| Self(MessageId::new(id as u64))) + } +} + +impl FromSql, Pg> for MessageIdWrapper { + fn from_sql(bytes: PgValue) -> deserialize::Result { + i64::from_sql(bytes).map(|id| Self(MessageId::new(id as u64))) + } +} + +#[derive(Debug, AsExpression, FromSqlRow, Hash, PartialEq, Eq)] +#[diesel(primary_key(id))] +#[diesel(sql_type = diesel::sql_types::BigInt)] +pub struct UserIdWrapper(pub UserId); + +impl ToSql for UserIdWrapper { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + ToSql::::to_sql(&i64::from(self.0), out) + } +} + +impl FromSql for UserIdWrapper { + fn from_sql(bytes: PgValue) -> deserialize::Result { + i64::from_sql(bytes).map(|id| Self(UserId::new(id as u64))) + } +} + +#[derive(Debug, AsExpression, FromSqlRow)] +#[diesel(sql_type = crate::schema::sql_types::Language)] +pub enum Language { + En, + Pt, +} + +impl ToSql for Language { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + match *self { + Language::En => out.write_all(b"en")?, + Language::Pt => out.write_all(b"pt")?, + } + Ok(IsNull::No) + } +} + +impl FromSql for Language { + fn from_sql(bytes: PgValue) -> deserialize::Result { + match bytes.as_bytes() { + b"en" => Ok(Language::En), + b"pt" => Ok(Language::Pt), + _ => Err("Unrecognized enum variant".into()), + } + } +} + +#[derive(Debug, AsExpression, FromSqlRow)] +#[diesel(sql_type = crate::schema::sql_types::PollKind)] +pub enum PollKind { + SingleChoice, + MultipleChoice, +} + +impl ToSql for PollKind { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + match *self { + PollKind::SingleChoice => out.write_all(b"single_choice")?, + PollKind::MultipleChoice => out.write_all(b"multiple_choice")?, + } + Ok(IsNull::No) + } +} + +impl FromSql for PollKind { + fn from_sql(bytes: PgValue) -> deserialize::Result { + match bytes.as_bytes() { + b"single_choice" => Ok(PollKind::SingleChoice), + b"multiple_choice" => Ok(PollKind::MultipleChoice), + _ => Err("Unrecognized enum variant".into()), + } + } +} + +pub mod exports { + pub use super::guild as Guild; + pub use super::poll as Poll; + pub use super::user as User; + pub use super::Language; + pub use super::PollKind; +} + +pub mod guild; +pub mod poll; +pub mod user; diff --git a/src/modules/core/entities/poll.rs b/src/modules/core/entities/poll.rs new file mode 100644 index 0000000..2989331 --- /dev/null +++ b/src/modules/core/entities/poll.rs @@ -0,0 +1,43 @@ +use diesel::prelude::*; + +use super::{ChannelIdWrapper, MessageIdWrapper, PollKind, UserIdWrapper}; + +#[derive(Queryable, Selectable, Identifiable, Insertable)] +#[diesel(table_name = crate::schema::polls)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct Poll { + pub id: uuid::Uuid, + pub name: String, + pub description: Option, + pub kind: PollKind, + pub thread_id: ChannelIdWrapper, + pub embed_message_id: MessageIdWrapper, + pub poll_message_id: MessageIdWrapper, + pub started_at: Option, + pub ended_at: Option, + pub created_at: time::OffsetDateTime, + pub created_by: UserIdWrapper, +} + +#[derive(Queryable, Selectable, Identifiable, Insertable)] +#[diesel(primary_key(poll_id, value))] +#[diesel(table_name = crate::schema::poll_choices)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct PollChoice { + pub poll_id: uuid::Uuid, + pub value: String, + pub label: String, + pub description: Option, + pub created_at: time::OffsetDateTime, +} + +#[derive(Queryable, Selectable, Identifiable, Insertable)] +#[diesel(primary_key(user_id, poll_id, choice_value))] +#[diesel(table_name = crate::schema::poll_votes)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct PollVote { + pub poll_id: uuid::Uuid, + pub choice_value: String, + pub user_id: UserIdWrapper, + pub voted_at: time::OffsetDateTime, +} diff --git a/src/modules/core/entities/user.rs b/src/modules/core/entities/user.rs new file mode 100644 index 0000000..fabf65e --- /dev/null +++ b/src/modules/core/entities/user.rs @@ -0,0 +1,12 @@ +use diesel::prelude::*; + +use super::UserIdWrapper; + +#[derive(Queryable, Selectable, Identifiable)] +#[diesel(table_name = crate::schema::users)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct User { + pub id: UserIdWrapper, + pub added_at: time::OffsetDateTime, + pub updated_at: time::OffsetDateTime, +} diff --git a/src/components/button.rs b/src/modules/core/helpers/components/button.rs similarity index 81% rename from src/components/button.rs rename to src/modules/core/helpers/components/button.rs index 758806e..1b10b71 100644 --- a/src/components/button.rs +++ b/src/modules/core/helpers/components/button.rs @@ -1,7 +1,4 @@ -use serenity::{ - builder::CreateButton, - model::prelude::{component::ButtonStyle, ReactionType}, -}; +use serenity::{all::ButtonStyle, builder::CreateButton, model::prelude::ReactionType}; pub struct Button { name: String, @@ -31,9 +28,8 @@ impl Button { } pub fn create(&self) -> CreateButton { - let mut b = CreateButton::default(); + let mut b = CreateButton::new(self.name); - b.custom_id(&self.name); b.label(&self.label); b.style(self.style.clone()); diff --git a/src/modules/core/helpers/components/mod.rs b/src/modules/core/helpers/components/mod.rs new file mode 100644 index 0000000..8ddf452 --- /dev/null +++ b/src/modules/core/helpers/components/mod.rs @@ -0,0 +1 @@ +pub mod button; \ No newline at end of file diff --git a/src/modules/core/helpers/mod.rs b/src/modules/core/helpers/mod.rs new file mode 100644 index 0000000..f188f2c --- /dev/null +++ b/src/modules/core/helpers/mod.rs @@ -0,0 +1 @@ +pub mod components; diff --git a/src/internal/arguments.rs b/src/modules/core/lib/arguments.rs similarity index 92% rename from src/internal/arguments.rs rename to src/modules/core/lib/arguments.rs index 052f311..3f7cb10 100644 --- a/src/internal/arguments.rs +++ b/src/modules/core/lib/arguments.rs @@ -1,11 +1,9 @@ use std::any::Any; use serenity::{ + all::{CommandDataOption, ModalInteractionData}, client::Context, model::{ - application::interaction::{ - application_command::CommandDataOption, modal::ModalSubmitInteractionData, - }, guild::Guild, id::{ChannelId, InteractionId}, user::User, @@ -66,7 +64,7 @@ impl ArgumentsLevel { channel_id: &ChannelId, options: Option>, interaction_id: Option, - modal_submit_data: Option<&ModalSubmitInteractionData>, + modal_submit_data: Option<&ModalInteractionData>, ) -> Vec> { let mut arguments: Vec> = vec![]; diff --git a/src/internal/constants.rs b/src/modules/core/lib/constants.rs similarity index 100% rename from src/internal/constants.rs rename to src/modules/core/lib/constants.rs diff --git a/src/internal/debug.rs b/src/modules/core/lib/debug.rs similarity index 100% rename from src/internal/debug.rs rename to src/modules/core/lib/debug.rs diff --git a/src/modules/core/lib/embeds.rs b/src/modules/core/lib/embeds.rs new file mode 100644 index 0000000..480ab18 --- /dev/null +++ b/src/modules/core/lib/embeds.rs @@ -0,0 +1,86 @@ +use serenity::{ + builder::CreateEmbed, client::Context, model::channel::GuildChannel, model::channel::Message, +}; +use std::any::Any; + +use super::debug::{log_message, MessageTypes}; + +pub trait EmbedRunnerFn { + fn run(&self, arguments: &Vec>) -> CreateEmbed; +} + +pub struct ApplicationEmbed { + pub name: String, + pub description: Option, + pub message_content: Option, + pub arguments: Vec>, + pub builder: Box, + pub message: Option, +} + +impl ApplicationEmbed { + pub fn new( + name: String, + description: Option, + message_content: Option, + arguments: Vec>, + builder: Box, + ) -> Self { + Self { + name, + builder, + arguments, + description, + message_content, + message: None, + } + } + + pub async fn update(&self, channel: GuildChannel, ctx: &Context) { + if let Err(why) = channel + .edit_message(ctx, self.message.clone().unwrap().id, |m| { + m.embed(|e| { + e.clone_from(&self.builder.run(&self.arguments)); + e + }) + }) + .await + { + log_message( + format!("Error updating message: {:?}", why).as_str(), + MessageTypes::Error, + ); + } + } + + pub async fn send_message(&mut self, channel: GuildChannel, ctx: &Context) { + match channel + .send_message(ctx, |m| { + m.embed(|e| { + e.clone_from(&self.builder.run(&self.arguments)); + e + }) + }) + .await + { + Ok(message) => { + self.message = Some(message); + } + Err(why) => log_message( + format!("Error sending message: {:?}", why).as_str(), + MessageTypes::Error, + ), + } + } +} + +impl std::fmt::Display for ApplicationEmbed { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Embed: {} \n {}", + self.name, + self.description.clone().unwrap() + ) + } +} diff --git a/src/internal/mod.rs b/src/modules/core/lib/mod.rs similarity index 76% rename from src/internal/mod.rs rename to src/modules/core/lib/mod.rs index 851fe8a..aab83bf 100644 --- a/src/internal/mod.rs +++ b/src/modules/core/lib/mod.rs @@ -1,4 +1,4 @@ pub mod arguments; pub mod constants; pub mod debug; -pub mod users; +pub mod embeds; diff --git a/src/modules/core/mod.rs b/src/modules/core/mod.rs new file mode 100644 index 0000000..7fe488f --- /dev/null +++ b/src/modules/core/mod.rs @@ -0,0 +1,5 @@ +pub mod actions; +pub mod constants; +pub mod entities; +pub mod helpers; +pub mod lib; diff --git a/src/modules/mod.rs b/src/modules/mod.rs index c7c5c8d..c73d7a9 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -1 +1,2 @@ -pub mod equalizers; \ No newline at end of file +pub mod app; +pub mod core; diff --git a/src/schema.rs b/src/schema.rs new file mode 100644 index 0000000..e8ad419 --- /dev/null +++ b/src/schema.rs @@ -0,0 +1,89 @@ +// @generated automatically by Diesel CLI. + +pub mod sql_types { + #[derive(diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "language"))] + pub struct Language; + + #[derive(diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "poll_kind"))] + pub struct PollKind; +} + +diesel::table! { + use diesel::sql_types::*; + use crate::modules::core::entities::exports::*; + use super::sql_types::Language; + + guilds (id) { + id -> Int8, + language -> Language, + added_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + +diesel::table! { + use diesel::sql_types::*; + use crate::modules::core::entities::exports::*; + + poll_choices (poll_id, value) { + #[max_length = 50] + value -> Varchar, + #[max_length = 25] + label -> Varchar, + description -> Nullable, + created_at -> Timestamptz, + poll_id -> Uuid, + } +} + +diesel::table! { + use diesel::sql_types::*; + use crate::modules::core::entities::exports::*; + + poll_votes (user_id, choice_value, poll_id) { + user_id -> Int8, + #[max_length = 50] + choice_value -> Varchar, + poll_id -> Uuid, + voted_at -> Timestamptz, + } +} + +diesel::table! { + use diesel::sql_types::*; + use crate::modules::core::entities::exports::*; + use super::sql_types::PollKind; + + polls (id) { + id -> Uuid, + #[max_length = 50] + name -> Varchar, + description -> Nullable, + kind -> PollKind, + timer -> Int8, + thread_id -> Int8, + embed_message_id -> Int8, + poll_message_id -> Nullable, + started_at -> Nullable, + ended_at -> Nullable, + created_at -> Timestamptz, + created_by -> Int8, + } +} + +diesel::table! { + use diesel::sql_types::*; + use crate::modules::core::entities::exports::*; + + users (id) { + id -> Int8, + added_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + +diesel::joinable!(poll_choices -> polls (poll_id)); + +diesel::allow_tables_to_appear_in_same_query!(guilds, poll_choices, poll_votes, polls, users,); From 196bd2b0c50e47cfb09d2d88211d047326594621 Mon Sep 17 00:00:00 2001 From: "Kszinhu@POP_os" Date: Fri, 16 Feb 2024 02:11:10 -0300 Subject: [PATCH 07/17] feat: update dependencies and refactor to split the bot into two crates - To update serenity it was necessary use builders instead of functions - It was necessary to split the bot into two crates to abstract strctures --- .gitignore | 4 + .idea/bostil-bot.iml | 4 + .vscode/settings.json | 16 - Cargo.toml | 45 +- Dockerfile | 7 +- app/Cargo.toml | 40 ++ diesel.toml => app/diesel.toml | 6 +- {migrations => app/migrations}/.keep | 0 .../down.sql | 0 .../up.sql | 0 .../2024-01-09-225829_create_guilds/down.sql | 3 + .../2024-01-09-225829_create_guilds/up.sql | 2 +- .../2024-01-10-033946_create_users/down.sql | 0 .../2024-01-10-033946_create_users/up.sql | 1 + .../2024-01-10-034005_create_polls/down.sql | 15 + .../2024-01-10-034005_create_polls/up.sql | 36 +- {public => app/public}/locales/en-US.yml | 0 {public => app/public}/locales/pt-BR.yml | 0 {public => app/public}/static/users.json | 0 app/src/lib.rs | 32 ++ app/src/main.rs | 379 +++++++++++++++ app/src/modules/app/commands/jingle.rs | 32 ++ app/src/modules/app/commands/language.rs | 47 ++ app/src/modules/app/commands/mod.rs | 17 + app/src/modules/app/commands/ping.rs | 79 ++++ .../modules/app/commands/poll/embeds/mod.rs | 8 + .../modules/app/commands/poll/embeds/setup.rs | 85 ++++ .../modules/app/commands/poll/embeds/vote.rs | 127 +++++ app/src/modules/app/commands/poll/mod.rs | 89 ++++ .../modules/app/commands/poll/progress_bar.rs | 26 +- app/src/modules/app/commands/poll/setup.rs | 265 +++++++++++ .../modules/app/commands/radio/consumer.rs | 16 + .../modules/app/commands/radio/equalizers.rs | 0 app/src/modules/app/commands/radio/mod.rs | 213 +++++++++ app/src/modules/app/commands/voice/join.rs | 61 +++ .../src}/modules/app/commands/voice/leave.rs | 41 +- .../src}/modules/app/commands/voice/mod.rs | 0 app/src/modules/app/commands/voice/mute.rs | 88 ++++ app/src/modules/app/listeners/chat/love.rs | 109 +++++ app/src/modules/app/listeners/chat/mod.rs | 3 + .../src}/modules/app/listeners/command/mod.rs | 0 app/src/modules/app/listeners/mod.rs | 4 + .../src}/modules/app/listeners/modal/mod.rs | 0 .../app/listeners/modal/poll_option.rs | 147 ++++++ .../app/listeners/voice/join_channel.rs | 123 +++++ app/src/modules/app/listeners/voice/mod.rs | 3 + {src => app/src}/modules/app/mod.rs | 0 .../app/services/integrations/jukera.rs | 89 ++++ .../modules/app/services/integrations/mod.rs | 5 + {src => app/src}/modules/app/services/mod.rs | 0 app/src/modules/core/actions/collectors.rs | 41 ++ app/src/modules/core/actions/mod.rs | 2 + app/src/modules/core/actions/voice.rs | 112 +++++ .../src}/modules/core/entities/guild.rs | 0 app/src/modules/core/entities/mod.rs | 320 +++++++++++++ app/src/modules/core/entities/poll.rs | 95 ++++ .../src}/modules/core/entities/user.rs | 3 +- .../src/modules/core/helpers/database.rs | 15 +- app/src/modules/core/helpers/http_client.rs | 12 + app/src/modules/core/helpers/mod.rs | 5 + {src => app/src}/modules/core/mod.rs | 2 - {src => app/src}/modules/mod.rs | 0 {src => app/src}/schema.rs | 15 +- core/Cargo.toml | 38 ++ .../core/lib => core/src}/arguments.rs | 24 +- core/src/collectors/command.rs | 42 ++ core/src/collectors/listener.rs | 31 ++ core/src/collectors/mod.rs | 5 + core/src/commands.rs | 89 ++++ core/src/embeds.rs | 145 ++++++ core/src/integrations.rs | 82 ++++ core/src/lib.rs | 9 + core/src/listeners.rs | 45 ++ core/src/runners/command.rs | 102 ++++ core/src/runners/listener.rs | 16 + core/src/runners/mod.rs | 9 + docker-compose.local.yml | 16 +- docker-compose.yml | 15 +- .../2024-01-09-225829_create_guilds/down.sql | 2 - .../2024-01-10-034005_create_polls/down.sql | 7 - src/main.rs | 437 ------------------ src/modules/app/commands/jingle.rs | 35 -- src/modules/app/commands/language.rs | 54 --- src/modules/app/commands/mod.rs | 188 -------- src/modules/app/commands/ping.rs | 43 -- src/modules/app/commands/poll/database.rs | 128 ----- src/modules/app/commands/poll/help.rs | 160 ------- .../app/commands/poll/management/mod.rs | 1 - src/modules/app/commands/poll/mod.rs | 345 -------------- src/modules/app/commands/poll/setup/create.rs | 329 ------------- .../app/commands/poll/setup/embeds/mod.rs | 45 -- .../app/commands/poll/setup/embeds/setup.rs | 63 --- .../app/commands/poll/setup/embeds/vote.rs | 83 ---- src/modules/app/commands/poll/setup/mod.rs | 24 - .../app/commands/poll/setup/options.rs | 70 --- src/modules/app/commands/radio/consumer.rs | 34 -- src/modules/app/commands/radio/mod.rs | 237 ---------- src/modules/app/commands/voice/join.rs | 62 --- src/modules/app/commands/voice/mute.rs | 92 ---- src/modules/app/listeners/chat/love.rs | 85 ---- src/modules/app/listeners/chat/mod.rs | 1 - src/modules/app/listeners/mod.rs | 70 --- .../app/listeners/modal/poll_option.rs | 118 ----- .../app/listeners/voice/join_channel.rs | 103 ----- src/modules/app/listeners/voice/mod.rs | 1 - .../app/services/integrations/jukera.rs | 48 -- src/modules/app/services/integrations/mod.rs | 57 --- src/modules/core/actions/mod.rs | 1 - src/modules/core/actions/voice.rs | 148 ------ src/modules/core/constants.rs | 0 src/modules/core/entities/mod.rs | 158 ------- src/modules/core/entities/poll.rs | 43 -- src/modules/core/helpers/components/button.rs | 42 -- src/modules/core/helpers/components/mod.rs | 1 - src/modules/core/helpers/mod.rs | 1 - src/modules/core/lib/constants.rs | 8 - src/modules/core/lib/debug.rs | 93 ---- src/modules/core/lib/embeds.rs | 86 ---- src/modules/core/lib/mod.rs | 4 - 119 files changed, 3446 insertions(+), 3618 deletions(-) delete mode 100644 .vscode/settings.json create mode 100644 app/Cargo.toml rename diesel.toml => app/diesel.toml (53%) rename {migrations => app/migrations}/.keep (100%) rename {migrations => app/migrations}/00000000000000_diesel_initial_setup/down.sql (100%) rename {migrations => app/migrations}/00000000000000_diesel_initial_setup/up.sql (100%) create mode 100644 app/migrations/2024-01-09-225829_create_guilds/down.sql rename {migrations => app/migrations}/2024-01-09-225829_create_guilds/up.sql (72%) rename {migrations => app/migrations}/2024-01-10-033946_create_users/down.sql (100%) rename {migrations => app/migrations}/2024-01-10-033946_create_users/up.sql (83%) create mode 100644 app/migrations/2024-01-10-034005_create_polls/down.sql rename {migrations => app/migrations}/2024-01-10-034005_create_polls/up.sql (50%) rename {public => app/public}/locales/en-US.yml (100%) rename {public => app/public}/locales/pt-BR.yml (100%) rename {public => app/public}/static/users.json (100%) create mode 100644 app/src/lib.rs create mode 100644 app/src/main.rs create mode 100644 app/src/modules/app/commands/jingle.rs create mode 100644 app/src/modules/app/commands/language.rs create mode 100644 app/src/modules/app/commands/mod.rs create mode 100644 app/src/modules/app/commands/ping.rs create mode 100644 app/src/modules/app/commands/poll/embeds/mod.rs create mode 100644 app/src/modules/app/commands/poll/embeds/setup.rs create mode 100644 app/src/modules/app/commands/poll/embeds/vote.rs create mode 100644 app/src/modules/app/commands/poll/mod.rs rename src/modules/app/commands/poll/utils/mod.rs => app/src/modules/app/commands/poll/progress_bar.rs (50%) create mode 100644 app/src/modules/app/commands/poll/setup.rs create mode 100644 app/src/modules/app/commands/radio/consumer.rs rename {src => app/src}/modules/app/commands/radio/equalizers.rs (100%) create mode 100644 app/src/modules/app/commands/radio/mod.rs create mode 100644 app/src/modules/app/commands/voice/join.rs rename {src => app/src}/modules/app/commands/voice/leave.rs (58%) rename {src => app/src}/modules/app/commands/voice/mod.rs (100%) create mode 100644 app/src/modules/app/commands/voice/mute.rs create mode 100644 app/src/modules/app/listeners/chat/love.rs create mode 100644 app/src/modules/app/listeners/chat/mod.rs rename {src => app/src}/modules/app/listeners/command/mod.rs (100%) create mode 100644 app/src/modules/app/listeners/mod.rs rename {src => app/src}/modules/app/listeners/modal/mod.rs (100%) create mode 100644 app/src/modules/app/listeners/modal/poll_option.rs create mode 100644 app/src/modules/app/listeners/voice/join_channel.rs create mode 100644 app/src/modules/app/listeners/voice/mod.rs rename {src => app/src}/modules/app/mod.rs (100%) create mode 100644 app/src/modules/app/services/integrations/jukera.rs create mode 100644 app/src/modules/app/services/integrations/mod.rs rename {src => app/src}/modules/app/services/mod.rs (100%) create mode 100644 app/src/modules/core/actions/collectors.rs create mode 100644 app/src/modules/core/actions/mod.rs create mode 100644 app/src/modules/core/actions/voice.rs rename {src => app/src}/modules/core/entities/guild.rs (100%) create mode 100644 app/src/modules/core/entities/mod.rs create mode 100644 app/src/modules/core/entities/poll.rs rename {src => app/src}/modules/core/entities/user.rs (73%) rename src/lib.rs => app/src/modules/core/helpers/database.rs (70%) create mode 100644 app/src/modules/core/helpers/http_client.rs create mode 100644 app/src/modules/core/helpers/mod.rs rename {src => app/src}/modules/core/mod.rs (61%) rename {src => app/src}/modules/mod.rs (100%) rename {src => app/src}/schema.rs (83%) create mode 100644 core/Cargo.toml rename {src/modules/core/lib => core/src}/arguments.rs (79%) create mode 100644 core/src/collectors/command.rs create mode 100644 core/src/collectors/listener.rs create mode 100644 core/src/collectors/mod.rs create mode 100644 core/src/commands.rs create mode 100644 core/src/embeds.rs create mode 100644 core/src/integrations.rs create mode 100644 core/src/lib.rs create mode 100644 core/src/listeners.rs create mode 100644 core/src/runners/command.rs create mode 100644 core/src/runners/listener.rs create mode 100644 core/src/runners/mod.rs delete mode 100644 migrations/2024-01-09-225829_create_guilds/down.sql delete mode 100644 migrations/2024-01-10-034005_create_polls/down.sql delete mode 100644 src/main.rs delete mode 100644 src/modules/app/commands/jingle.rs delete mode 100644 src/modules/app/commands/language.rs delete mode 100644 src/modules/app/commands/mod.rs delete mode 100644 src/modules/app/commands/ping.rs delete mode 100644 src/modules/app/commands/poll/database.rs delete mode 100644 src/modules/app/commands/poll/help.rs delete mode 100644 src/modules/app/commands/poll/management/mod.rs delete mode 100644 src/modules/app/commands/poll/mod.rs delete mode 100644 src/modules/app/commands/poll/setup/create.rs delete mode 100644 src/modules/app/commands/poll/setup/embeds/mod.rs delete mode 100644 src/modules/app/commands/poll/setup/embeds/setup.rs delete mode 100644 src/modules/app/commands/poll/setup/embeds/vote.rs delete mode 100644 src/modules/app/commands/poll/setup/mod.rs delete mode 100644 src/modules/app/commands/poll/setup/options.rs delete mode 100644 src/modules/app/commands/radio/consumer.rs delete mode 100644 src/modules/app/commands/radio/mod.rs delete mode 100644 src/modules/app/commands/voice/join.rs delete mode 100644 src/modules/app/commands/voice/mute.rs delete mode 100644 src/modules/app/listeners/chat/love.rs delete mode 100644 src/modules/app/listeners/chat/mod.rs delete mode 100644 src/modules/app/listeners/mod.rs delete mode 100644 src/modules/app/listeners/modal/poll_option.rs delete mode 100644 src/modules/app/listeners/voice/join_channel.rs delete mode 100644 src/modules/app/listeners/voice/mod.rs delete mode 100644 src/modules/app/services/integrations/jukera.rs delete mode 100644 src/modules/app/services/integrations/mod.rs delete mode 100644 src/modules/core/actions/mod.rs delete mode 100644 src/modules/core/actions/voice.rs delete mode 100644 src/modules/core/constants.rs delete mode 100644 src/modules/core/entities/mod.rs delete mode 100644 src/modules/core/entities/poll.rs delete mode 100644 src/modules/core/helpers/components/button.rs delete mode 100644 src/modules/core/helpers/components/mod.rs delete mode 100644 src/modules/core/helpers/mod.rs delete mode 100644 src/modules/core/lib/constants.rs delete mode 100644 src/modules/core/lib/debug.rs delete mode 100644 src/modules/core/lib/embeds.rs delete mode 100644 src/modules/core/lib/mod.rs diff --git a/.gitignore b/.gitignore index 3acbde5..d85b372 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,7 @@ public/database/ .idea/**/dictionaries .idea/**/shelf +# VS Code +.vscode/ +.vscode/**/settings.json + diff --git a/.idea/bostil-bot.iml b/.idea/bostil-bot.iml index cf84ae4..19f3a9d 100644 --- a/.idea/bostil-bot.iml +++ b/.idea/bostil-bot.iml @@ -2,7 +2,11 @@ + + + + diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index beee681..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "rust-analyzer.linkedProjects": ["./Cargo.toml", "./Cargo.toml"], - "i18n-ally.localesPaths": ["public/locales"], - "i18n-ally.keystyle": "nested", - "sqltools.connections": [ - { - "previewLimit": 50, - "server": "localhost", - "port": 5432, - "driver": "PostgreSQL", - "name": "local", - "database": "bostil_bot", - "username": "root" - } - ] -} diff --git a/Cargo.toml b/Cargo.toml index 201b64a..5591cc5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,35 +1,34 @@ -[package] -name = "bostil-bot" -authors = ["Cassiano Rodrigues "] -repository = "https://github.com/kszinhu/bostil-bot" -version = "0.1.0" -edition = "2021" +[workspace] +resolver = "2" +members = ["app", "core"] # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html -[dependencies] -reqwest = { version = "*", features = ["json"] } -regex = "*" +[workspace.package] +authors = ["Cassiano Rodrigues "] +edition = "2021" + +[workspace.dependencies] +# Discord serenity = { default-features = false, features = [ "cache", "client", + "http", + "framework", "standard_framework", "gateway", "voice", "rustls_backend", "model", "collector", -], version = "0.12.0" } -songbird = { version = "0.4.0" } -tokio = { version = "*", features = ["macros", "rt-multi-thread", "signal"] } -serde = { version = "1.0.171", features = ["derive"] } -serde_json = "1.0.103" -once_cell = { version = "1.18.0", features = ["std"] } -rust-i18n = "2.0.0" -colored = "2.0.4" -yaml-rust = "0.4.5" -uuid = { version = "^1.4.1", features = ["v4", "fast-rng"] } -nanoid = "0.4.0" -diesel = { version = "2.1.4", features = ["postgres", "time", "uuid"] } -dotenvy = "0.15.7" -time = "0.3.31" +], version = "0.12" } +songbird = { features = ["builtin-queue"], version = "0.4" } + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +tracing-futures = "0.2" + +# Other +lazy_static = "*" +once_cell = { version = "*", features = ["std"] } diff --git a/Dockerfile b/Dockerfile index fe49a49..6eb8a28 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ #---------------- # Build stage #---------------- -FROM rust:1.71.0-alpine3.17 as builder +FROM rust:1.76.0-alpine3.19 as builder # System dependencies RUN apk add --no-cache \ @@ -9,8 +9,7 @@ RUN apk add --no-cache \ cmake \ musl-dev \ curl \ - ffmpeg \ - youtube-dl \ + yt-dlp \ pkgconfig \ openssl-dev \ git @@ -48,7 +47,7 @@ FROM alpine:latest AS runtime ARG APP=/usr/src/app # System dependencies -RUN apk add --no-cache ca-certificates tzdata youtube-dl ffmpeg +RUN apk add --no-cache ca-certificates tzdata yt-dlp # Copy the binary from the builder stage COPY --from=builder /usr/src/app/bostil-bot/target/release/bostil-bot ${APP}/bostil-bot diff --git a/app/Cargo.toml b/app/Cargo.toml new file mode 100644 index 0000000..f3fae33 --- /dev/null +++ b/app/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "bostil-bot" +repository = "https://github.com/kszinhu/bostil-bot" +version = "0.1.0" +description = "Bostil Discord Bot" +authors = { workspace = true } +edition = { workspace = true } + +[dependencies] +# Internal crates +bostil-core = { path = "../core" } + +# Discord (Main dependencies) +serenity = { workspace = true } +songbird = { workspace = true } +tokio = { features = ["full"], version = "1" } +symphonia = { features = ["aac", "mp3", "isomp4", "alac"], version = "0.5.2" } +reqwest = { version = "0.11" } + +# Database +uuid = { version = "^1.4.1", features = ["v4", "fast-rng"] } +diesel = { version = "2.1.4", features = ["postgres", "time", "uuid"] } +dotenvy = "0.15.7" +time = "0.3" + +# Logging +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +tracing-futures = { workspace = true } + +# Internationalization (yml) +rust-i18n = "*" +serde_yaml = "*" + +# Other +lazy_static = { workspace = true } +once_cell = { workspace = true } + +# Potentially remove later +nanoid = "0.4" diff --git a/diesel.toml b/app/diesel.toml similarity index 53% rename from diesel.toml rename to app/diesel.toml index 7c617f5..755d421 100644 --- a/diesel.toml +++ b/app/diesel.toml @@ -3,7 +3,11 @@ [print_schema] file = "src/schema.rs" -import_types = ["diesel::sql_types::*", "crate::modules::core::models::exports::*"] +custom_type_derives = ["diesel::sql_types::SqlType", "std::fmt::Debug"] +import_types = [ + "diesel::sql_types::*", + "crate::modules::core::entities::exports::*", +] [migrations_directory] dir = "migrations" diff --git a/migrations/.keep b/app/migrations/.keep similarity index 100% rename from migrations/.keep rename to app/migrations/.keep diff --git a/migrations/00000000000000_diesel_initial_setup/down.sql b/app/migrations/00000000000000_diesel_initial_setup/down.sql similarity index 100% rename from migrations/00000000000000_diesel_initial_setup/down.sql rename to app/migrations/00000000000000_diesel_initial_setup/down.sql diff --git a/migrations/00000000000000_diesel_initial_setup/up.sql b/app/migrations/00000000000000_diesel_initial_setup/up.sql similarity index 100% rename from migrations/00000000000000_diesel_initial_setup/up.sql rename to app/migrations/00000000000000_diesel_initial_setup/up.sql diff --git a/app/migrations/2024-01-09-225829_create_guilds/down.sql b/app/migrations/2024-01-09-225829_create_guilds/down.sql new file mode 100644 index 0000000..5e44637 --- /dev/null +++ b/app/migrations/2024-01-09-225829_create_guilds/down.sql @@ -0,0 +1,3 @@ +DROP TYPE IF EXISTS language CASCADE; + +DROP TABLE guilds; \ No newline at end of file diff --git a/migrations/2024-01-09-225829_create_guilds/up.sql b/app/migrations/2024-01-09-225829_create_guilds/up.sql similarity index 72% rename from migrations/2024-01-09-225829_create_guilds/up.sql rename to app/migrations/2024-01-09-225829_create_guilds/up.sql index 8572c1d..59eec64 100644 --- a/migrations/2024-01-09-225829_create_guilds/up.sql +++ b/app/migrations/2024-01-09-225829_create_guilds/up.sql @@ -4,5 +4,5 @@ CREATE TABLE guilds ( id BIGINT PRIMARY KEY, language language NOT NULL DEFAULT 'en-US', added_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() PRIMARY KEY (id) + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() ) \ No newline at end of file diff --git a/migrations/2024-01-10-033946_create_users/down.sql b/app/migrations/2024-01-10-033946_create_users/down.sql similarity index 100% rename from migrations/2024-01-10-033946_create_users/down.sql rename to app/migrations/2024-01-10-033946_create_users/down.sql diff --git a/migrations/2024-01-10-033946_create_users/up.sql b/app/migrations/2024-01-10-033946_create_users/up.sql similarity index 83% rename from migrations/2024-01-10-033946_create_users/up.sql rename to app/migrations/2024-01-10-033946_create_users/up.sql index f8e085b..2224f41 100644 --- a/migrations/2024-01-10-033946_create_users/up.sql +++ b/app/migrations/2024-01-10-033946_create_users/up.sql @@ -1,5 +1,6 @@ CREATE TABLE users ( id BIGINT PRIMARY KEY, + username VARCHAR(255) NOT NULL, added_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() ); \ No newline at end of file diff --git a/app/migrations/2024-01-10-034005_create_polls/down.sql b/app/migrations/2024-01-10-034005_create_polls/down.sql new file mode 100644 index 0000000..94f3cdf --- /dev/null +++ b/app/migrations/2024-01-10-034005_create_polls/down.sql @@ -0,0 +1,15 @@ +DROP TYPE IF EXISTS poll_kind CASCADE; + +DROP TYPE IF EXISTS poll_state CASCADE; + +DROP TABLE poll_votes; + +DROP TABLE poll_choices; + +DROP TABLE polls; + +DROP INDEX IF EXISTS poll_votes_poll_id; + +DROP INDEX IF EXISTS poll_choices_poll_id_value; + +DROP FUNCTION update_poll_state(); \ No newline at end of file diff --git a/migrations/2024-01-10-034005_create_polls/up.sql b/app/migrations/2024-01-10-034005_create_polls/up.sql similarity index 50% rename from migrations/2024-01-10-034005_create_polls/up.sql rename to app/migrations/2024-01-10-034005_create_polls/up.sql index a9b76ad..c8cf2a1 100644 --- a/migrations/2024-01-10-034005_create_polls/up.sql +++ b/app/migrations/2024-01-10-034005_create_polls/up.sql @@ -1,10 +1,15 @@ CREATE TYPE poll_kind AS ENUM ('single_choice', 'multiple_choice'); +CREATE TYPE poll_state AS ENUM ('created', 'started', 'stopped', 'ended'); + +-- Diesel don't support composite primary key yet, so we need to create a unique index + CREATE TABLE polls ( id UUID PRIMARY KEY, name VARCHAR(50) NOT NULL, description TEXT, kind poll_kind NOT NULL, + state poll_state NOT NULL DEFAULT 'created', timer BIGINT NOT NULL, thread_id BIGINT NOT NULL, embed_message_id BIGINT NOT NULL, @@ -16,21 +21,40 @@ CREATE TABLE polls ( ); CREATE TABLE poll_choices ( + poll_id UUID NOT NULL REFERENCES polls(id) ON DELETE CASCADE, value VARCHAR(50) NOT NULL, label VARCHAR(25) NOT NULL, description TEXT, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - poll_id UUID NOT NULL REFERENCES polls(id) ON - DELETE CASCADE, PRIMARY KEY (poll_id, value) ); +CREATE UNIQUE INDEX poll_choices_poll_id_value ON poll_choices(poll_id, value); + CREATE TABLE poll_votes ( user_id BIGINT NOT NULL, choice_value VARCHAR(50) NOT NULL, - poll_id UUID NOT NULL, + poll_id UUID NOT NULL REFERENCES polls(id) ON DELETE CASCADE, voted_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - FOREIGN KEY (choice_value, poll_id) REFERENCES poll_choices(value, poll_id) ON - DELETE CASCADE, PRIMARY KEY (user_id, choice_value, poll_id) -); \ No newline at end of file +); + +CREATE INDEX poll_votes_poll_id ON poll_votes(user_id, choice_value, poll_id); + +-- Triggers +CREATE +OR REPLACE FUNCTION update_poll_state() RETURNS TRIGGER AS $$ +BEGIN IF NEW .state = 'started' THEN NEW .started_at = NOW(); + +ELSIF NEW .state = 'ended' THEN NEW .ended_at = NOW(); + +END IF; + +RETURN NEW; + +END; + +$$ LANGUAGE plpgsql; + +CREATE TRIGGER update_poll_state BEFORE +UPDATE ON polls FOR EACH ROW EXECUTE PROCEDURE update_poll_state(); \ No newline at end of file diff --git a/public/locales/en-US.yml b/app/public/locales/en-US.yml similarity index 100% rename from public/locales/en-US.yml rename to app/public/locales/en-US.yml diff --git a/public/locales/pt-BR.yml b/app/public/locales/pt-BR.yml similarity index 100% rename from public/locales/pt-BR.yml rename to app/public/locales/pt-BR.yml diff --git a/public/static/users.json b/app/public/static/users.json similarity index 100% rename from public/static/users.json rename to app/public/static/users.json diff --git a/app/src/lib.rs b/app/src/lib.rs new file mode 100644 index 0000000..9bd5f52 --- /dev/null +++ b/app/src/lib.rs @@ -0,0 +1,32 @@ +use bostil_core::collectors::{CommandCollector, ListenerCollector}; +use lazy_static::lazy_static; +use reqwest::Client as HttpClient; +use songbird::typemap::TypeMapKey; + +#[macro_use(i18n)] +extern crate rust_i18n; +extern crate diesel; + +struct ShardManagerContainer; +struct HttpKey; + +impl TypeMapKey for ShardManagerContainer { + type Value = std::sync::Arc; +} + +impl TypeMapKey for HttpKey { + type Value = HttpClient; +} + +// TODO: implementar algum jeito para que cada servidor tenha seu próprio idioma e não alterar o idioma de todos os servidores +i18n!("public/locales", fallback = "en-US"); + +pub mod modules; +pub mod schema; + +lazy_static! { + pub static ref COMMAND_COLLECTOR: std::sync::Mutex = + std::sync::Mutex::new(CommandCollector::new()); + pub static ref LISTENER_COLLECTOR: std::sync::Mutex = + std::sync::Mutex::new(ListenerCollector::new()); +} diff --git a/app/src/main.rs b/app/src/main.rs new file mode 100644 index 0000000..6e57843 --- /dev/null +++ b/app/src/main.rs @@ -0,0 +1,379 @@ +include!("lib.rs"); + +use bostil_core::{ + arguments::ArgumentsLevel, commands::CommandContext, runners::runners::CommandResponse, +}; +use serenity::{ + all::{Command, GatewayIntents, GuildId, Interaction, Message, Ready, VoiceState}, + async_trait, + builder::EditInteractionResponse, + client::Context, + framework::StandardFramework, + gateway::ActivityData, + prelude::EventHandler, + Client, +}; +use songbird::SerenityInit; +use std::env; +use tracing::{debug, error, info, warn}; + +use crate::modules::{ + app::listeners::voice::join_channel, core::actions, core::helpers::establish_connection, +}; + +struct Handler; + +#[async_trait] +impl EventHandler for Handler { + // --------- + // On receive message + // --------- + async fn message(&self, _ctx: Context, msg: Message) { + debug!("Received message from User: {:#?}", msg.author.name); + + // TODO: use integrations and listeners collectors instead of hardcoding + } + + // --------- + // On bot ready + // --------- + async fn ready(&self, ctx: Context, ready: Ready) { + info!("Connected on Guilds: {}", ready.guilds.len()); + + let collector = match COMMAND_COLLECTOR.lock() { + Ok(collector) => collector.clone(), + Err(why) => { + error!("Cannot get command collector: {}", why); + return; + } + }; + + let global_commands = collector + .clone() + .get_fingerprints(Some(CommandContext::Global)); + let guild_commands = collector + .clone() + .get_fingerprints(Some(CommandContext::Guild)); + + if global_commands.len() == 0 && guild_commands.len() == 0 { + warn!("No commands to register"); + return; + } + + if let Err(why) = Command::set_global_commands(&ctx.http, global_commands.clone()).await { + error!("Cannot register global slash commands: {}", why); + return; + } + + info!("Registered global slash commands"); + debug!( + "Registered {:#?} global commands", + global_commands.clone().len() + ); + debug!( + "Registered {:#?} guild commands", + guild_commands.clone().len() + ); + + for guild in ready.guilds.iter() { + let commands = GuildId::set_commands(guild.id, &ctx.http, guild_commands.clone()).await; + + if let Err(why) = commands { + error!("Cannot register slash commands: {}", why); + } + + info!("Registered slash commands for guild {}", guild.id); + } + + ctx.set_activity(Some(ActivityData::playing( + "O Auxílio Emergencial no PIX do Mito", + ))) + } + + // --------- + // On User connect to voice channel + // --------- + async fn voice_state_update(&self, ctx: Context, old: Option, new: VoiceState) { + let is_bot: bool = new.user_id.to_user(&ctx.http).await.unwrap().bot; + let has_connected: bool = new.channel_id.is_some() && old.is_none(); + + if has_connected && !is_bot { + debug!("User connected to voice channel: {:#?}", new.channel_id); + + join_channel(&new.channel_id.unwrap(), &ctx, &new.user_id).await; + } + + match old { + Some(old) => { + if old.channel_id.is_some() && new.channel_id.is_none() && !is_bot { + debug!( + "User disconnected from voice channel: {:#?}", + old.channel_id + ); + } + } + None => {} + } + } + + // --------- + // On create interaction (slash command, button, select menu, etc.) + // --------- + async fn interaction_create(&self, ctx: Context, interaction: Interaction) { + match interaction { + Interaction::Modal(submit) => { + submit.defer(&ctx.http.clone()).await.unwrap(); + + debug!( + "Received modal submit interaction from User: {:#?}", + submit.user.name + ); + + // let registered_interactions = get_modal_interactions(); + + // // custom_id is in the format: '/' + // match registered_interactions.iter().enumerate().find(|(_, i)| { + // i.name + // == submit + // .clone() + // .data + // .custom_id + // .split("/") + // .collect::>() + // .first() + // .unwrap() + // .to_string() + // }) { + // Some((_, interaction)) => { + // let Some(guild) = ({ + // let cloned_ctx = ctx.clone(); + // let guild_reference = cloned_ctx.cache.guild(submit.guild_id.unwrap()); + + // match guild_reference { + // Some(guild) => Some(guild.clone()), + // None => None, + // } + // }) else { + // error!("Cannot get guild from cache"); + // return; + // }; + + // interaction + // .runner + // .run(&ArgumentsLevel::provide( + // &interaction.arguments, + // &ctx, + // &guild, + // &submit.user, + // &submit.channel_id, + // None, + // Some(submit.id), + // Some(&submit.data), + // None, + // )) + // .await; + // } + + // None => { + // error!( + // "Modal submit interaction {} not found", + // submit.data.custom_id.split("/").collect::>()[0] + // ); + // } + // }; + } + + Interaction::Command(command) => { + info!( + "Received command \"{}\" interaction from User: {:#?}", + command.data.name, command.user.name + ); + + // Defer the interaction and edit it later + match command.defer(&ctx.http.clone()).await { + Ok(_) => {} + Err(why) => { + error!("Cannot defer slash command: {}", why); + } + } + + let collector = match COMMAND_COLLECTOR.lock() { + Ok(collector) => collector.clone(), + Err(why) => { + error!("Cannot get command collector: {}", why); + return; + } + }; + + debug!("Running command: {}", command.data.name); + + match collector + .commands + .iter() + .enumerate() + .find(|(_, c)| c.name == command.data.name) + { + Some((_, command_interface)) => { + let Some(guild) = ({ + let cloned_ctx = ctx.clone(); + let guild_reference = cloned_ctx.cache.guild(command.guild_id.unwrap()); + + match guild_reference { + Some(guild) => Some(guild.clone()), + None => None, + } + }) else { + error!("Cannot get guild from cache"); + return; + }; + + match command_interface + .runner + .run(&ArgumentsLevel::provide( + &command_interface.arguments, + &ctx, + &guild, + &command.user, + &command.channel_id, + Some(command.data.options.clone()), + Some(command.id), + None, + None, + )) + .await + { + Ok(command_response) => { + debug!("Responding to slash command: {}", command.data.name); + + if CommandResponse::None != command_response { + if let Err(why) = match command_response { + CommandResponse::String(string) => { + command + .edit_response( + &ctx.http, + EditInteractionResponse::default() + .content(string), + ) + .await + } + CommandResponse::Embed(embed) => { + command + .edit_response( + &ctx.http, + EditInteractionResponse::default() + .embed(embed.into()), + ) + .await + } + CommandResponse::Message(message) => { + command.edit_response(&ctx.http, message).await + } + // if none is returned ignore + CommandResponse::None => todo!(), + } { + error!("Cannot respond to slash command: {}", why); + } + } else { + debug!("Deleting slash command: {}", command.data.name); + + if let Err(why) = command + .edit_response(&ctx.http, EditInteractionResponse::new()) + .await + { + error!("Cannot respond to slash command: {}", why); + } + } + } + Err(why) => { + error!("Cannot run slash command: {}", why); + } + } + } + None => { + error!("Command {} not found", command.data.name); + } + }; + } + _ => {} + } + } +} + +#[tokio::main] +async fn main() { + use dotenvy::dotenv; + + dotenv().ok(); + // tracing subscriber with default env variable + let filter = tracing_subscriber::EnvFilter::from_default_env(); + tracing_subscriber::fmt() + .with_env_filter(filter) + .compact() + .init(); + + let token = env::var("DISCORD_TOKEN").expect("Expected a token in the environment"); + + establish_connection(); + + let mut command_collector = match COMMAND_COLLECTOR.lock() { + Ok(collector) => collector.clone(), + Err(why) => { + error!("Cannot get command collector: {}", why); + return; + } + }; + + let mut listener_collector = match LISTENER_COLLECTOR.lock() { + Ok(collector) => collector.clone(), + Err(why) => { + error!("Cannot get listener collector: {}", why); + return; + } + }; + + info!("Starting bot"); + + actions::collectors::register_commands(&mut command_collector); + actions::collectors::register_listeners(&mut listener_collector); + + info!("Collected commands: {:#?}", command_collector.length); + info!("Collected listeners: {:#?}", listener_collector.length); + + // save the collector + *COMMAND_COLLECTOR.lock().unwrap() = command_collector; + + let intents = GatewayIntents::MESSAGE_CONTENT + | GatewayIntents::DIRECT_MESSAGES + | GatewayIntents::GUILD_WEBHOOKS + | GatewayIntents::GUILD_MESSAGES + | GatewayIntents::GUILDS + | GatewayIntents::GUILD_MEMBERS + | GatewayIntents::GUILD_VOICE_STATES + | GatewayIntents::GUILD_INTEGRATIONS; + + let framework = StandardFramework::new(); + + let mut client = Client::builder(token, intents) + .event_handler(Handler) + .framework(framework) + .register_songbird() + .type_map_insert::(HttpClient::new()) + .await + .expect("Error on creating client"); + + { + let mut data = client.data.write().await; + + data.insert::(std::sync::Arc::clone(&client.shard_manager)); + } + + tokio::spawn(async move { + let _main_process = client + .start() + .await + .map_err(|why| println!("Client ended: {:?}", why)); + }); + + tokio::signal::ctrl_c().await.unwrap(); + println!("\rReceived Ctrl-C, shutting down..."); +} diff --git a/app/src/modules/app/commands/jingle.rs b/app/src/modules/app/commands/jingle.rs new file mode 100644 index 0000000..2ec045e --- /dev/null +++ b/app/src/modules/app/commands/jingle.rs @@ -0,0 +1,32 @@ +use bostil_core::{ + arguments::ArgumentsLevel, + commands::{Command, CommandCategory, CommandContext}, + runners::runners::{CommandResponse, CommandResult, CommandRunnerFn}, +}; +use lazy_static::lazy_static; +use serenity::{async_trait, builder::CreateCommand}; +use std::any::Any; + +#[derive(Clone)] +struct Jingle; + +#[async_trait] +impl CommandRunnerFn for Jingle { + async fn run<'a>(&self, _args: &Vec>) -> CommandResult<'a> { + Ok(CommandResponse::String( + "Tanke o Bostil ou deixe-o".to_string(), + )) + } +} + +lazy_static! { + pub static ref JINGLE_COMMAND: Command = Command::new( + "jingle", + "Tanke o Bostil ou deixe-o", + CommandContext::Guild, + CommandCategory::Fun, + vec![ArgumentsLevel::None], + Box::new(Jingle {}), + Some(CreateCommand::new("jingle").description("Tanke o Bostil ou deixe-o")), + ); +} diff --git a/app/src/modules/app/commands/language.rs b/app/src/modules/app/commands/language.rs new file mode 100644 index 0000000..2620540 --- /dev/null +++ b/app/src/modules/app/commands/language.rs @@ -0,0 +1,47 @@ +use bostil_core::{ + commands::{Command, CommandCategory, CommandContext}, + runners::runners::{CommandResponse, CommandResult, CommandRunnerFn}, +}; +use lazy_static::lazy_static; +use serenity::{ + all::CommandOptionType, + async_trait, + builder::{CreateCommand, CreateCommandOption}, +}; +use std::any::Any; + +#[derive(Clone)] +struct Language; + +#[async_trait] +impl CommandRunnerFn for Language { + async fn run<'a>(&self, _args: &Vec>) -> CommandResult<'a> { + Ok(CommandResponse::String("".to_string())) + } +} + +lazy_static! { + /// Command to set the language of bot responses within a guild + pub static ref LANGUAGE_COMMAND: Command = Command::new( + "language", + "Sets the language of the bot", + CommandContext::Guild, + CommandCategory::General, + vec![], + Box::new(Language), + Some( + CreateCommand::new("language") + .description("Language Preferences Menu") + .add_option( + CreateCommandOption::new( + CommandOptionType::String, + "choose_language", + "Choose the language of preference" + ) + .add_string_choice("Portuguese", "pt-BR") + .add_string_choice("English", "en-US") + .required(true) + ), + ), + ); +} diff --git a/app/src/modules/app/commands/mod.rs b/app/src/modules/app/commands/mod.rs new file mode 100644 index 0000000..5513168 --- /dev/null +++ b/app/src/modules/app/commands/mod.rs @@ -0,0 +1,17 @@ +mod jingle; +mod language; +mod ping; +mod poll; +mod radio; +mod voice; + +pub mod commands { + pub use super::jingle::JINGLE_COMMAND as jingle; + pub use super::language::LANGUAGE_COMMAND as language; + pub use super::ping::PING_COMMAND as ping; + pub use super::poll::POLL_COMMANDS as poll; + pub use super::radio::RADIO_COMMAND as radio; + pub use super::voice::join::JOIN_COMMAND as join; + pub use super::voice::leave::LEAVE_COMMAND as leave; + pub use super::voice::mute::MUTE_COMMAND as mute; +} diff --git a/app/src/modules/app/commands/ping.rs b/app/src/modules/app/commands/ping.rs new file mode 100644 index 0000000..5a421ae --- /dev/null +++ b/app/src/modules/app/commands/ping.rs @@ -0,0 +1,79 @@ +use bostil_core::{ + arguments::ArgumentsLevel, + commands::{Command, CommandCategory, CommandContext}, + runners::runners::{CommandResponse, CommandResult, CommandRunnerFn}, +}; +use lazy_static::lazy_static; +use serenity::{async_trait, builder::CreateCommand, client::Context}; +use std::{any::Any, time::Duration}; +use tracing::{debug, error, info}; + +use crate::ShardManagerContainer; + +#[derive(Clone)] +struct Ping; + +#[async_trait] +impl CommandRunnerFn for Ping { + async fn run<'a>(&self, arguments: &Vec>) -> CommandResult<'a> { + let context = arguments + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>()[0]; + + let data = context.data.read().await; + + let shard_manager = match data.get::() { + Some(v) => v, + None => { + error!("No shard manager found"); + + return Ok(CommandResponse::String( + "There was a problem getting the shard manager".to_string(), + )); + } + }; + + debug!("ShardManager: #{:?}", shard_manager); + + let runners = shard_manager.runners.lock().await; + + let runner = match runners.get(&context.shard_id) { + Some(runner) => runner, + None => { + error!("No shard runner found for shard {}", context.shard_id); + + return Ok(CommandResponse::String( + "There was a problem getting the shard runner".to_string(), + )); + } + }; + + if runner.latency.is_none() { + info!("The shard runner latency is not available"); + } + + Ok(CommandResponse::String(format!( + "Pong! The shard runner latency is: {} ms", + runner.latency.unwrap_or(Duration::from_secs(0)).as_millis() + ))) + } +} + +lazy_static! { + /// # Ping Command + /// + /// > Command to check if the bot is alive, and test the latency to the server + pub static ref PING_COMMAND: Command = Command::new( + "ping", + "Check if the bot is alive, and test the latency to the server", + CommandContext::Global, + CommandCategory::General, + vec![ArgumentsLevel::Context], + Box::new(Ping), + Some( + CreateCommand::new("ping") + .description("Check if the bot is alive, and test the latency to the server"), + ), + ); +} diff --git a/app/src/modules/app/commands/poll/embeds/mod.rs b/app/src/modules/app/commands/poll/embeds/mod.rs new file mode 100644 index 0000000..7b54990 --- /dev/null +++ b/app/src/modules/app/commands/poll/embeds/mod.rs @@ -0,0 +1,8 @@ +mod setup; +mod vote; + +#[allow(unused_imports)] +pub mod embeds { + pub use super::setup::SETUP_EMBED; + pub use super::vote::VOTE_EMBED; +} diff --git a/app/src/modules/app/commands/poll/embeds/setup.rs b/app/src/modules/app/commands/poll/embeds/setup.rs new file mode 100644 index 0000000..83c2658 --- /dev/null +++ b/app/src/modules/app/commands/poll/embeds/setup.rs @@ -0,0 +1,85 @@ +use bostil_core::embeds::{ApplicationEmbed, EmbedLifetime}; +use diesel::{BoolExpressionMethods, ExpressionMethods}; +use once_cell::sync::Lazy; +use rust_i18n::t; +use serenity::{all::MessageId, builder::CreateEmbed}; +use uuid::Uuid; + +use crate::{ + modules::{ + app::commands::poll::PollStage, + core::{ + entities::{poll::Poll, MessageIdWrapper}, + helpers::establish_connection, + }, + }, + schema::polls, +}; + +/// Embed to show the poll configuration and status during the voting stage +struct PollSetupEmbed; + +impl EmbedLifetime for PollSetupEmbed { + fn build(&self, arguments: &Vec>) -> CreateEmbed { + use crate::diesel::{QueryDsl, RunQueryDsl, SelectableHelper}; + + let poll_id = arguments[0].downcast_ref::().unwrap(); + let stage = arguments[1].downcast_ref::().unwrap(); + + let connection = &mut establish_connection(); + let poll = polls::table + .find(poll_id) + .select(Poll::as_select()) + .first::(connection) + .expect("Error loading poll"); + + let embed = CreateEmbed::default().color(stage.embed_color()); + + match stage { + PollStage::Closed => embed + .title(t!("commands.poll.setup.embed.stages.closed.title")) + .description(t!("commands.poll.setup.stages.closed.description")), + PollStage::Voting => embed + .title(t!("commands.poll.setup.embed.stages.voting.title")) + .description(t!("commands.poll.setup.stages.voting.description")), + PollStage::Setup => embed + .title(t!("commands.poll.setup.embed.stages.setup.title")) + .description(t!("commands.poll.setup.embed.stages.setup.description")) + .field("ID", poll.id.to_string(), true) + .field("User", format!("<@{}>", poll.created_by), true) + .field("\u{200B}", "\u{200B}", false), // Separator + } + } + + fn after_sent(&self, arguments: &Vec>) { + use crate::diesel::{QueryDsl, RunQueryDsl}; + + let poll_id = arguments[0].downcast_ref::().unwrap(); + let embed_message_id = arguments[1].downcast_ref::().unwrap(); + + let connection = &mut establish_connection(); + + diesel::update( + polls::table.filter(polls::id.eq(poll_id).and(polls::embed_message_id.is_null())), + ) + .set(polls::embed_message_id.eq(MessageIdWrapper(*embed_message_id))) + .execute(connection) + .expect("Error updating poll"); + } +} + +pub static SETUP_EMBED: Lazy = Lazy::new(|| { + ApplicationEmbed::new( + "Poll Setup", + Some("Embed to configure poll"), + Some("Estamos configurando a enquete abaixo:"), + vec![ + Box::new(None::>), + Box::new(None::>), + ], + Box::new(PollSetupEmbed), + None, + None, + None, + ) +}); diff --git a/app/src/modules/app/commands/poll/embeds/vote.rs b/app/src/modules/app/commands/poll/embeds/vote.rs new file mode 100644 index 0000000..7459d88 --- /dev/null +++ b/app/src/modules/app/commands/poll/embeds/vote.rs @@ -0,0 +1,127 @@ +use bostil_core::embeds::{ApplicationEmbed, EmbedLifetime}; +use once_cell::sync::Lazy; +use serenity::builder::CreateEmbed; +use uuid::Uuid; + +use crate::{ + modules::{ + app::commands::poll::PollStage, + core::{entities::poll::Poll, helpers::establish_connection}, + }, + schema::polls, +}; + +struct PollVoteEmbed; + +impl EmbedLifetime for PollVoteEmbed { + fn build(&self, arguments: &Vec>) -> CreateEmbed { + use crate::diesel::{QueryDsl, RunQueryDsl, SelectableHelper}; + + let poll_id = arguments[0].downcast_ref::().unwrap(); + let stage = arguments[1].downcast_ref::().unwrap(); + + let connection = &mut establish_connection(); + let poll = polls::table + .find(poll_id) + .select(Poll::as_select()) + .first::(connection) + .expect("Error loading poll"); + + CreateEmbed::default() + } +} + +pub static VOTE_EMBED: Lazy = Lazy::new(|| { + ApplicationEmbed::new( + "Poll Voting embed", + Some("Embed to choose an choice in a poll"), + Some("Selecione uma opção para votar"), + vec![ + Box::new(None::>), + Box::new(None::>), + ], + Box::new(PollVoteEmbed), + None, + None, + None, + ) +}); + +// pub fn embed( +// mut message_builder: EditInteractionResponse, +// poll: Poll, +// ) -> CommandResult { +// let time_remaining = match poll.timer.is_some() { +// true => { +// let time_remaining = poll.timer.unwrap() - poll.started_at.unwrap().timestamp(); +// let minutes = time_remaining / 60; +// let seconds = time_remaining % 60; + +// format!("{}m {}s", minutes, seconds) +// } +// false => "∞".to_string(), +// }; +// let mut embed = CreateEmbed::default(); +// embed +// .title(poll.name) +// .description(poll.description.unwrap_or("".to_string())); + +// // first row (id, status, user) +// embed.field( +// "ID", +// format!("`{}`", poll.id.to_string().split_at(8).0), +// true, +// ); +// embed.field("Status", poll.status.to_string(), true); +// embed.field("User", format!("<@{}>", poll.created_by), true); + +// // separator +// embed.field("\u{200B}", "\u{200B}", false); + +// poll.options.iter().for_each(|option| { +// embed.field( +// option.value.clone(), +// format!("{} votes", option.votes.len()), +// true, +// ); +// }); + +// // separator +// embed.field("\u{200B}", "\u{200B}", false); + +// embed.field( +// "Partial Results (Live)", +// format!("```diff\n{}\n```", progress_bar(poll.options.clone())), +// false, +// ); + +// // separator +// embed.field("\u{200B}", "\u{200B}", false); + +// embed.field( +// "Time remaining", +// format!("{} remaining", time_remaining), +// false, +// ); + +// message_builder.set_embed(embed); +// message_builder.components(|component| { +// component.create_action_row(|action_row| { +// poll.options.iter().for_each(|option| { +// action_row.add_button( +// Button::new( +// option.value.as_str(), +// option.value.as_str(), +// ButtonStyle::Primary, +// None, +// ) +// .create(), +// ); +// }); + +// action_row +// }) +// }); + +// Ok(message_builder) +// } diff --git a/app/src/modules/app/commands/poll/mod.rs b/app/src/modules/app/commands/poll/mod.rs new file mode 100644 index 0000000..a8088dc --- /dev/null +++ b/app/src/modules/app/commands/poll/mod.rs @@ -0,0 +1,89 @@ +use bostil_core::{ + arguments::ArgumentsLevel, + commands::{Command, CommandCategory, CommandContext}, + runners::runners::{CommandResult, CommandRunnerFn}, +}; +use lazy_static::lazy_static; +use serenity::{all::CommandDataOption, async_trait, builder::CreateCommand, model::Colour}; + +mod embeds; +mod progress_bar; +mod setup; + +#[derive(Clone)] +struct PollCommand; + +#[derive(Debug, Clone, Copy)] +pub enum PollStage { + Setup, + Voting, + Closed, +} + +impl PollStage { + pub fn embed_color(&self) -> Colour { + match self { + PollStage::Setup => Colour::ORANGE, + PollStage::Voting => Colour::RED, + PollStage::Closed => Colour::DARK_GREEN, + } + } +} + +#[async_trait] +impl CommandRunnerFn for PollCommand { + async fn run<'a>( + &self, + args: &Vec>, + ) -> CommandResult<'a> { + let options = args + .iter() + .filter_map(|arg| arg.downcast_ref::>>()) + .collect::>>>()[0] + .as_ref() + .unwrap(); + let first_option = options.get(0).unwrap(); + let command_name = first_option.name.clone(); + + let command_runner = command_suite(command_name); + + let response = command_runner.run(args); + + response.await + } +} + +fn command_suite(command_name: String) -> &'static Box { + let command_runner = match command_name.as_str() { + "setup" => &setup::SETUP_COMMAND.runner, + _ => { + panic!("Command not found"); + } + }; + + command_runner +} + +lazy_static! { + pub static ref POLL_COMMANDS: Command = Command::new( + "poll", + "Poll commands", + CommandContext::Guild, + CommandCategory::Misc, + vec![ + ArgumentsLevel::Options, + ArgumentsLevel::Context, + ArgumentsLevel::Guild, + ArgumentsLevel::User, + ArgumentsLevel::ChannelId, + ], + Box::new(PollCommand), + Some( + CreateCommand::new("poll") + .name_localized("pt-BR", "urna") + .description("Create and manage polls") + .description_localized("pt-BR", "Crie e administre enquetes") + .add_option(setup::SETUP_OPTION.clone()), + ), + ); +} diff --git a/src/modules/app/commands/poll/utils/mod.rs b/app/src/modules/app/commands/poll/progress_bar.rs similarity index 50% rename from src/modules/app/commands/poll/utils/mod.rs rename to app/src/modules/app/commands/poll/progress_bar.rs index 6ce6c9a..22dda72 100644 --- a/src/modules/app/commands/poll/utils/mod.rs +++ b/app/src/modules/app/commands/poll/progress_bar.rs @@ -1,4 +1,4 @@ -use super::PollOption; +use crate::modules::core::entities::poll::PollWithChoicesAndVotes; type PartialResults = Vec<(String, u64)>; @@ -9,11 +9,25 @@ type PartialResults = Vec<(String, u64)>; Option 1: ████░░░░░░ 45% Option 2: ████████░░ 75% */ -pub fn progress_bar(options: Vec) -> String { - let results: PartialResults = options - .iter() - .map(|option| (option.value.clone(), option.votes.len() as u64)) - .collect(); +pub fn progress_bar(options: Vec) -> String { + let results: PartialResults = options.iter().fold(Vec::new(), |mut acc, poll| { + for choice in &poll.choices { + let choice_value = choice.value.clone(); + let choice_count = poll + .votes + .iter() + .filter(|vote| vote.choice_value == choice_value) + .count() as u64; + + if let Some((_, count)) = acc.iter_mut().find(|(option, _)| *option == choice_value) { + *count += choice_count; + } else { + acc.push((choice_value, choice_count)); + } + } + + acc + }); let mut progress_bar = String::new(); let total_votes = results.iter().fold(0, |acc, (_, count)| acc + count); diff --git a/app/src/modules/app/commands/poll/setup.rs b/app/src/modules/app/commands/poll/setup.rs new file mode 100644 index 0000000..eb12d06 --- /dev/null +++ b/app/src/modules/app/commands/poll/setup.rs @@ -0,0 +1,265 @@ +use bostil_core::{ + arguments::ArgumentsLevel, + commands::{Command, CommandCategory, CommandContext}, + runners::runners::{CommandResponse, CommandResult, CommandRunnerFn}, +}; +use lazy_static::lazy_static; +use once_cell::sync::Lazy; +use rust_i18n::t; +use serenity::{ + all::{ + AutoArchiveDuration, ButtonStyle, ChannelId, ChannelType, CommandDataOption, + CommandOptionType, ComponentInteraction, InputTextStyle, User, + }, + async_trait, + builder::{ + CreateActionRow, CreateButton, CreateCommandOption, CreateInputText, + CreateInteractionResponse, CreateModal, CreateSelectMenu, CreateSelectMenuKind, + CreateSelectMenuOption, CreateThread, EditMessage, + }, + collector::ComponentInteractionCollector, + futures::StreamExt, + prelude::Context, +}; +use std::{time::Duration, vec}; +use tracing::error; + +#[derive(Clone)] +struct CreatePollRunner; + +#[async_trait] +impl CommandRunnerFn for CreatePollRunner { + async fn run<'a>(&self, args: &Vec>) -> CommandResult<'a> { + use super::embeds::embeds::SETUP_EMBED; + + let options = args + .iter() + .filter_map(|arg| arg.downcast_ref::>>()) + .collect::>>>()[0] + .as_ref() + .unwrap(); + + let poll_name = match options.iter().find(|option| option.name == "name") { + Some(option) => option.value.as_str().unwrap(), + None => { + panic!("Poll name is required") + } + }; + let poll_description = match options.iter().find(|option| option.name == "description") { + Some(option) => Some(option.value.as_str().unwrap().to_string()), + None => None, + }; + let ctx = args + .iter() + .find_map(|arg| arg.downcast_ref::()) + .unwrap(); + let channel_id = args + .iter() + .find_map(|arg| arg.downcast_ref::()) + .unwrap(); + let user_id = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>() + .get(0) + .unwrap() + .id; + + // Step 1: Create thread + let thread_channel = channel_id + .create_thread( + &ctx.http, + CreateThread::new(poll_name) + .kind(ChannelType::PrivateThread) + .invitable(true) + .auto_archive_duration(AutoArchiveDuration::OneDay), + ) + .await?; + + thread_channel + .id + .add_thread_member(&ctx.http, user_id) + .await?; + + // Step 2: Create a partial poll and send it to the thread + let mut embed_message = match SETUP_EMBED.send_message(&ctx, &thread_channel).await { + Ok(message) => message, + Err(_) => { + error!("Failed to send message to thread {}", thread_channel.id); + + return Ok(CommandResponse::String( + t!("commands.poll.setup.response.error", "thread_id" => thread_channel.id) + .to_string(), + )); + } + }; + + // Step 3: Add buttons to the message to choose between add options, starting poll and cancel + embed_message + .edit( + &ctx.http, + EditMessage::default().components(vec![ + CreateActionRow::Buttons(vec![ + CreateButton::new("add_option") + .style(ButtonStyle::Secondary) + .label("Adicionar opção"), + CreateButton::new("start_poll") + .style(ButtonStyle::Primary) + .label("Iniciar votação"), + CreateButton::new("cancel_poll") + .style(ButtonStyle::Danger) + .label("Cancelar votação"), + ]), + CreateActionRow::SelectMenu( + CreateSelectMenu::new( + "poll_kind", + CreateSelectMenuKind::String { + options: vec![ + CreateSelectMenuOption::new("Escolha única", "single_choice") + .description("Cada usuário pode votar em apenas uma opção"), + CreateSelectMenuOption::new( + "Múltipla escolha", + "multiple_choice", + ) + .description("Cada usuário pode votar em mais de uma opção"), + ], + }, + ) + .placeholder("Escolha o tipo de votação") + .min_values(1) + .max_values(1), + ), + ]), + ) + .await?; + + // Step 5: Add interaction listener + let interaction_stream = embed_message + .await_component_interactions(&ctx) + .timeout(Duration::from_secs(60 * 60 * 24)); // 1 Day to configure the poll + + interaction_handler(interaction_stream, ctx).await; + + Ok(CommandResponse::String( + t!( + "commands.poll.setup.response.initial", + "thread_id" => thread_channel.id, + ) + .to_string(), + )) + } +} + +async fn interaction_handler(interaction_stream: ComponentInteractionCollector, ctx: &Context) { + match interaction_stream.stream().next().await { + Some(interaction) => { + let interaction_id = interaction.data.custom_id.as_str(); + + match interaction_id { + "add_option" => add_option(interaction, ctx).await, + _ => {} + } + } + + None => { + error!("No interaction received in 1 day"); + } + } +} + +async fn add_option(interaction: ComponentInteraction, ctx: &Context) { + match interaction + .create_response( + &ctx.http, + CreateInteractionResponse::Modal( + CreateModal::new( + format!("option_data_poll/{}", interaction.id), + "Adicionar opção", + ) + .components(vec![ + CreateActionRow::InputText( + CreateInputText::new(InputTextStyle::Short, "Nome da Opção", "name_option") + .placeholder("Digite o nome da opção") + .max_length(25) + .min_length(1) + .required(true), + ), + CreateActionRow::InputText( + CreateInputText::new( + InputTextStyle::Paragraph, + "Descrição da Opção", + "description_option", + ) + .placeholder("Digite a descrição da opção") + .max_length(500) + .min_length(1), + ), + ]), + ), + ) + .await + { + Ok(_) => {} + Err(why) => { + error!("Failed to create interaction response: {}", why); + } + } +} + +pub static SETUP_OPTION: Lazy = Lazy::new(|| { + CreateCommandOption::new(CommandOptionType::SubCommand, "setup", "Setup a poll") + .name_localized("pt-BR", "configurar") + .description_localized("pt-BR", "Configura uma votação") + .add_sub_option( + CreateCommandOption::new( + CommandOptionType::String, + "name", + "The name of the option (max 25 characters)", + ) + .name_localized("pt-BR", "nome") + .description_localized("pt-BR", "O nome da opção (máx 25 caracteres)") + .max_length(25) + .required(true), + ) + .add_sub_option( + CreateCommandOption::new( + CommandOptionType::Channel, + "channel", + "The channel where the poll will be created", + ) + .name_localized("pt-BR", "canal") + .description_localized("pt-BR", "O canal onde a votação será realizada") + .required(true), + ) + .add_sub_option( + CreateCommandOption::new( + CommandOptionType::String, + "description", + "The description of the option (max 365 characters)", + ) + .name_localized("pt-BR", "descrição") + .description_localized( + "pt-BR", + "A descrição dessa opção (máximo de 365 caracteres)", + ) + .max_length(365), + ) +}); + +lazy_static! { + pub static ref SETUP_COMMAND: Command = Command::new( + "setup", + "Setup a poll", + CommandContext::Guild, + CommandCategory::Misc, + vec![ + ArgumentsLevel::Options, + ArgumentsLevel::Context, + ArgumentsLevel::Guild, + ArgumentsLevel::User, + ArgumentsLevel::ChannelId, + ], + Box::new(CreatePollRunner), + None, + ); +} diff --git a/app/src/modules/app/commands/radio/consumer.rs b/app/src/modules/app/commands/radio/consumer.rs new file mode 100644 index 0000000..776251b --- /dev/null +++ b/app/src/modules/app/commands/radio/consumer.rs @@ -0,0 +1,16 @@ +use super::Radio; +use crate::modules::core::helpers::get_client; + +use serenity::client::Context; +use songbird::input::{Input, YoutubeDl}; + +pub async fn get_source(radio: Radio, ctx: &Context) -> Result { + if let Some(url) = radio.get_url() { + let http_client = get_client(ctx).await; + let source = YoutubeDl::new(http_client, url); + + Ok(source.into()) + } else { + Err("Failed to get radio URL".to_string()) + } +} diff --git a/src/modules/app/commands/radio/equalizers.rs b/app/src/modules/app/commands/radio/equalizers.rs similarity index 100% rename from src/modules/app/commands/radio/equalizers.rs rename to app/src/modules/app/commands/radio/equalizers.rs diff --git a/app/src/modules/app/commands/radio/mod.rs b/app/src/modules/app/commands/radio/mod.rs new file mode 100644 index 0000000..d608c64 --- /dev/null +++ b/app/src/modules/app/commands/radio/mod.rs @@ -0,0 +1,213 @@ +pub mod consumer; +pub mod equalizers; + +use bostil_core::{ + arguments::ArgumentsLevel, + commands::{Command, CommandCategory, CommandContext}, + runners::runners::{CommandResponse, CommandResult, CommandRunnerFn}, +}; +use lazy_static::lazy_static; +use rust_i18n::t; +use serenity::{ + all::{CommandDataOption, CommandDataOptionValue, CommandOptionType, Guild, User}, + async_trait, + builder::{CreateCommand, CreateCommandOption}, + framework::standard::CommandResult as SerenityCommandResult, + prelude::Context, +}; +use tracing::{debug, error}; + +use crate::modules::core::actions::voice::join; + +#[derive(Clone)] +struct RadioCommand; + +#[derive(Debug, Clone, Copy)] +pub enum Radio { + CanoaGrandeFM, + TupiFM, + EightyNineFM, + EightyEightFM, + NinetyFourFm, + PingoNosIFs, +} + +impl Radio { + pub fn get_url(&self) -> Option { + match self { + Radio::CanoaGrandeFM => { + Some("https://servidor39-4.brlogic.com:8300/live?source=website".to_string()) + } + Radio::TupiFM => Some("https://ice.fabricahost.com.br/topfmbauru".to_string()), + Radio::EightyNineFM => Some("https://r13.ciclano.io:15223/stream".to_string()), + Radio::EightyEightFM => Some("http://cast.hoost.com.br:8803/live.m3u".to_string()), + Radio::NinetyFourFm => { + Some("https://cast2.hoost.com.br:28456/stream?1691035067242".to_string()) + } + Radio::PingoNosIFs => None, + } + } + pub fn to_string(&self) -> String { + match self { + Radio::CanoaGrandeFM => "Canoa Grande FM".to_string(), + Radio::PingoNosIFs => "Pingo nos IFs".to_string(), + Radio::TupiFM => "Tupi FM".to_string(), + Radio::EightyNineFM => "89 FM".to_string(), + Radio::EightyEightFM => "88.3 FM".to_string(), + Radio::NinetyFourFm => "94 FM".to_string(), + } + } +} + +impl std::fmt::Display for Radio { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Radio::CanoaGrandeFM => write!(f, "Canoa Grande FM"), + Radio::PingoNosIFs => write!(f, "Pingo nos IFs"), + Radio::TupiFM => write!(f, "Tupi FM"), + Radio::EightyNineFM => write!(f, "89 FM"), + Radio::EightyEightFM => write!(f, "88.3 FM"), + Radio::NinetyFourFm => write!(f, "94 FM"), + } + } +} + +#[async_trait] +impl CommandRunnerFn for RadioCommand { + async fn run<'a>(&self, args: &Vec>) -> CommandResult<'a> { + let ctx = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>()[0]; + let guild = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>()[0]; + let user = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>()[0]; + let options = args + .iter() + .filter_map(|arg| arg.downcast_ref::>>()) + .collect::>>>()[0] + .as_ref() + .unwrap(); + + if let Err(_) = join(ctx, guild, &user.id).await { + error!("Failed to join voice channel"); + + return Ok(CommandResponse::String("Dá não pai".to_string())); + } + + match run(options, ctx, guild).await { + Ok(response) => Ok(CommandResponse::String(response)), + Err(_) => Ok(CommandResponse::String("Deu não pai".to_string())), + } + } +} + +pub async fn run( + options: &Vec, + ctx: &Context, + guild: &Guild, +) -> SerenityCommandResult { + let radio = match options[0].value.clone() { + CommandDataOptionValue::String(radio) => match radio.as_str() { + "Canoa Grande FM" => Radio::CanoaGrandeFM, + "Pingo nos IFs" => Radio::PingoNosIFs, + "Tupi FM" => Radio::TupiFM, + "89 FM" => Radio::EightyNineFM, + "88.3 FM" => Radio::EightyEightFM, + "94 FM" => Radio::NinetyFourFm, + _ => return Ok(t!("commands.radio.radio_not_found").to_string()), + }, + _ => return Ok(t!("commands.radio.radio_not_found").to_string()), + }; + + let manager = songbird::get(ctx) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + if let Some(handler_lock) = manager.get(guild.id) { + let mut voice_handler = handler_lock.lock().await; + + match consumer::get_source(radio, ctx).await { + Ok(source) => { + let _ = voice_handler.enqueue_input(source.into()).await; + debug!("Playing radio: {}", radio.to_string()); + } + Err(_) => { + return Ok(t!("commands.radio.failed_to_get_radio_url").to_string()); + } + } + } else { + debug!("Bot not connected to a voice channel"); + + return Ok(t!("commands.radio.bot_not_connected").to_string()); + } + + Ok(t!("commands.radio.reply", "radio_name" => radio.to_string()).to_string()) +} + +lazy_static! { + pub static ref RADIO_COMMAND: Command = Command::new( + "radio", + "Tune in to the best radios in \"Bostil\"", + CommandContext::Guild, + CommandCategory::Voice, + vec![ + ArgumentsLevel::Options, + ArgumentsLevel::Context, + ArgumentsLevel::Guild, + ArgumentsLevel::User, + ], + Box::new(RadioCommand), + Some( + CreateCommand::new("radio") + .description("Tune in to the best radios in Bostil") + .description_localized("pt-BR", "Sintonize a as melhores rádios do Bostil") + .add_option( + CreateCommandOption::new( + CommandOptionType::String, + "radio", + "The radio to tune in", + ) + .description_localized("pt-BR", "A rádio para sintonizar") + .kind(CommandOptionType::String) + .required(true) + .add_string_choice_localized( + "Canoa Grande FM", + Radio::CanoaGrandeFM.to_string(), + [("pt-BR", "Canoa Grande FM"), ("en-US", "Big Boat FM")], + ) + .add_string_choice_localized( + "Pingo nos IFs", + Radio::PingoNosIFs.to_string(), + [("pt-BR", "Pingo nos IFs"), ("en-US", "Ping in the IFs")], + ) + .add_string_choice_localized( + "Tupi FM", + Radio::TupiFM.to_string(), + [("pt-BR", "Tupi FM"), ("en-US", "Tupi FM")], + ) + .add_string_choice_localized( + "88.3 FM", + Radio::EightyEightFM.to_string(), + [("pt-BR", "88.3 FM"), ("en-US", "88.3 FM")], + ) + .add_string_choice_localized( + "89 FM", + Radio::EightyNineFM.to_string(), + [("pt-BR", "89 FM"), ("en-US", "89 FM")], + ) + .add_string_choice_localized( + "94 FM", + Radio::NinetyFourFm.to_string(), + [("pt-BR", "94 FM"), ("en-US", "94 FM")], + ), + ), + ), + ); +} diff --git a/app/src/modules/app/commands/voice/join.rs b/app/src/modules/app/commands/voice/join.rs new file mode 100644 index 0000000..49a63aa --- /dev/null +++ b/app/src/modules/app/commands/voice/join.rs @@ -0,0 +1,61 @@ +use bostil_core::{ + arguments::ArgumentsLevel, + commands::{Command, CommandCategory, CommandContext}, + runners::runners::{CommandResponse, CommandResult, CommandRunnerFn}, +}; +use lazy_static::lazy_static; +use serenity::{ + all::{Guild, User}, + async_trait, + builder::CreateCommand, + prelude::Context, +}; + +use crate::modules::core::actions::voice::join; + +#[derive(Clone)] +struct JoinCommand; + +#[async_trait] +impl CommandRunnerFn for JoinCommand { + async fn run<'a>(&self, args: &Vec>) -> CommandResult<'a> { + let ctx = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>()[0]; + let guild = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>()[0]; + let user = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>()[0]; + + match join(ctx, guild, &user.id).await { + Ok(_) => Ok(CommandResponse::String("Entrei capeta!".to_string())), + Err(_) => Ok(CommandResponse::String("Dá não pai".to_string())), + } + } +} + +lazy_static! { + pub static ref JOIN_COMMAND: Command = Command::new( + "join", + "Join the voice channel you are in", + CommandContext::Guild, + CommandCategory::Voice, + vec![ + ArgumentsLevel::Context, + ArgumentsLevel::Guild, + ArgumentsLevel::User, + ], + Box::new(JoinCommand {}), + Some( + CreateCommand::new("join") + .name_localized("pt-BR", "entrar") + .description("Join the voice channel you are in") + .description_localized("pt-BR", "Entra no canal de voz que você está"), + ), + ); +} diff --git a/src/modules/app/commands/voice/leave.rs b/app/src/modules/app/commands/voice/leave.rs similarity index 58% rename from src/modules/app/commands/voice/leave.rs rename to app/src/modules/app/commands/voice/leave.rs index c5ad63a..0d36723 100644 --- a/src/modules/app/commands/voice/leave.rs +++ b/app/src/modules/app/commands/voice/leave.rs @@ -1,3 +1,9 @@ +use bostil_core::{ + arguments::ArgumentsLevel, + commands::{Command, CommandCategory, CommandContext}, + runners::runners::{CommandResponse, CommandResult, CommandRunnerFn}, +}; +use lazy_static::lazy_static; use serenity::{ async_trait, builder::CreateCommand, @@ -5,19 +11,14 @@ use serenity::{ prelude::Context, }; -use crate::modules::{ - app::commands::{Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn}, - core::{actions::voice::leave, lib::arguments::ArgumentsLevel}, -}; +use crate::modules::core::actions::voice::leave; +#[derive(Clone)] struct LeaveCommand; #[async_trait] -impl RunnerFn for LeaveCommand { - async fn run<'a>( - &self, - args: &Vec>, - ) -> InternalCommandResult<'a> { +impl CommandRunnerFn for LeaveCommand { + async fn run<'a>(&self, args: &Vec>) -> CommandResult<'a> { let ctx = args .iter() .filter_map(|arg| arg.downcast_ref::()) @@ -41,19 +42,11 @@ impl RunnerFn for LeaveCommand { } } -pub fn register(command: &mut CreateCommand) -> &mut CreateCommand { - command - .name("leave") - .name_localized("pt-BR", "sair") - .description("Leave the voice channel you are in") - .description_localized("pt-BR", "Sai do canal de voz que você está") - .into() -} - -pub fn get_command() -> Command { - Command::new( +lazy_static! { + pub static ref LEAVE_COMMAND: Command = Command::new( "leave", "Leave the voice channel you are in", + CommandContext::Guild, CommandCategory::Voice, vec![ ArgumentsLevel::Context, @@ -61,5 +54,11 @@ pub fn get_command() -> Command { ArgumentsLevel::User, ], Box::new(LeaveCommand {}), - ) + Some( + CreateCommand::new("leave") + .name_localized("pt-BR", "sair") + .description("Leave the voice channel you are in") + .description_localized("pt-BR", "Sai do canal de voz que você está"), + ), + ); } diff --git a/src/modules/app/commands/voice/mod.rs b/app/src/modules/app/commands/voice/mod.rs similarity index 100% rename from src/modules/app/commands/voice/mod.rs rename to app/src/modules/app/commands/voice/mod.rs diff --git a/app/src/modules/app/commands/voice/mute.rs b/app/src/modules/app/commands/voice/mute.rs new file mode 100644 index 0000000..4d20598 --- /dev/null +++ b/app/src/modules/app/commands/voice/mute.rs @@ -0,0 +1,88 @@ +use bostil_core::{ + arguments::ArgumentsLevel, + commands::{Command, CommandCategory, CommandContext}, + runners::runners::{CommandResponse, CommandResult, CommandRunnerFn}, +}; +use lazy_static::lazy_static; +use serenity::{ + all::{CommandDataOption, CommandOptionType, Guild, User}, + async_trait, + builder::{CreateCommand, CreateCommandOption}, + prelude::Context, +}; + +use crate::modules::core::actions::voice::{mute, unmute}; + +#[derive(Clone)] +struct MuteCommand; + +#[async_trait] +impl CommandRunnerFn for MuteCommand { + async fn run<'a>(&self, args: &Vec>) -> CommandResult<'a> { + let ctx = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>()[0]; + let guild = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>()[0]; + let user = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>()[0]; + let options = args + .iter() + .filter_map(|arg| arg.downcast_ref::>()) + .collect::>>()[0]; + + let enable_sound = options + .iter() + .filter(|option| option.name == "enable_sound") + .collect::>()[0] + .value + .clone(); + + match enable_sound.as_bool().unwrap() { + true => match unmute(ctx, guild, &user.id).await { + Ok(_) => Ok(CommandResponse::None), + Err(_) => Ok(CommandResponse::None), + }, + false => match mute(ctx, guild, &user.id).await { + Ok(_) => Ok(CommandResponse::None), + Err(_) => Ok(CommandResponse::None), + }, + } + } +} + +lazy_static! { + pub static ref MUTE_COMMAND: Command = Command::new( + "mute", + "Disable sound from a bot", + CommandContext::Guild, + CommandCategory::Voice, + vec![ + ArgumentsLevel::Context, + ArgumentsLevel::Guild, + ArgumentsLevel::User, + ], + Box::new(MuteCommand), + Some( + CreateCommand::new("mute") + .name_localized("pt-BR", "silenciar") + .description("Disable sound from a bot") + .description_localized("pt-BR", "Mute o bot") + .add_option( + CreateCommandOption::new( + CommandOptionType::Boolean, + "enable_sound", + "Enable sound", + ) + .name_localized("pt-BR", "habilitar_som") + .description_localized("pt-BR", "Habilitar o som do bot") + .required(true), + ), + ), + ); +} diff --git a/app/src/modules/app/listeners/chat/love.rs b/app/src/modules/app/listeners/chat/love.rs new file mode 100644 index 0000000..88438c5 --- /dev/null +++ b/app/src/modules/app/listeners/chat/love.rs @@ -0,0 +1,109 @@ +use bostil_core::{ + arguments::ArgumentsLevel, + listeners::{Listener, ListenerKind}, + runners::runners::ListenerRunnerFn, +}; +use diesel::{query_dsl::methods::FilterDsl, ExpressionMethods, RunQueryDsl}; +use lazy_static::lazy_static; +use rust_i18n::t; +use serenity::{ + all::{ChannelId, User}, + async_trait, + client::Context, +}; +use std::{any::Any, cell::RefCell}; +use tracing::error; + +use crate::modules::core::{entities::user::User as UserDB, helpers::establish_connection}; + +thread_local! { + static COUNTER: RefCell = RefCell::new(0); + static LAST_MESSAGE_TIME: RefCell = RefCell::new(0); +} + +#[derive(Clone)] +struct Love; + +#[async_trait] +impl ListenerRunnerFn for Love { + async fn run<'a>(&self, args: &Vec>) -> () { + use crate::schema::users::dsl::{username, users}; + + let binding = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>(); + let ctx = *binding.first().unwrap(); + + let binding = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>(); + let channel = *binding.first().unwrap(); + + let binding = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>(); + let user_id = *binding.first().unwrap(); + + let connection = &mut establish_connection(); + let user = users + .filter(username.eq("Isadora")) + .first::(connection) + .unwrap() as UserDB; + + match user.id == user_id.id { + true => { + let message = COUNTER.with(|counter| { + LAST_MESSAGE_TIME.with(|last_message_time| { + let mut counter = counter.borrow_mut(); + let mut last_message_time = last_message_time.borrow_mut(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as u32; + + if now - *last_message_time < 5 { + *last_message_time = now; + + return None.into(); + } else { + *last_message_time = now; + *counter += 1; + + if *counter == 1 { + return t!("interactions.chat.love.reply", "user_id" => *user_id).into(); + } + + return t!("interactions.chat.love.reply_counter", "counter" => *counter, "user_id" => *user_id) + .into(); + } + }) + }); + + if let Some(message) = message { + if let Err(why) = channel.say(&ctx.http, message).await { + error!("Error sending message: {:?}", why); + } + } + } + false => {} + } + } +} + +lazy_static! { + /// Listener for love messages + pub static ref LOVE_LISTENER: Listener = Listener::new( + "love", + "Interact with user 'Isadora' to send love messages", + ListenerKind::Message, + vec![ + ArgumentsLevel::Context, + ArgumentsLevel::User, + ArgumentsLevel::ChannelId, + ], + Box::new(Love) + ); +} diff --git a/app/src/modules/app/listeners/chat/mod.rs b/app/src/modules/app/listeners/chat/mod.rs new file mode 100644 index 0000000..5a0a631 --- /dev/null +++ b/app/src/modules/app/listeners/chat/mod.rs @@ -0,0 +1,3 @@ +mod love; + +pub use love::LOVE_LISTENER; diff --git a/src/modules/app/listeners/command/mod.rs b/app/src/modules/app/listeners/command/mod.rs similarity index 100% rename from src/modules/app/listeners/command/mod.rs rename to app/src/modules/app/listeners/command/mod.rs diff --git a/app/src/modules/app/listeners/mod.rs b/app/src/modules/app/listeners/mod.rs new file mode 100644 index 0000000..736baca --- /dev/null +++ b/app/src/modules/app/listeners/mod.rs @@ -0,0 +1,4 @@ +pub mod chat; +pub mod command; +pub mod modal; +pub mod voice; diff --git a/src/modules/app/listeners/modal/mod.rs b/app/src/modules/app/listeners/modal/mod.rs similarity index 100% rename from src/modules/app/listeners/modal/mod.rs rename to app/src/modules/app/listeners/modal/mod.rs diff --git a/app/src/modules/app/listeners/modal/poll_option.rs b/app/src/modules/app/listeners/modal/poll_option.rs new file mode 100644 index 0000000..75ea23d --- /dev/null +++ b/app/src/modules/app/listeners/modal/poll_option.rs @@ -0,0 +1,147 @@ +use bostil_core::{ + arguments::ArgumentsLevel, + listeners::{Listener, ListenerKind}, + runners::runners::ListenerRunnerFn, +}; +use lazy_static::lazy_static; +use serenity::{ + all::{ActionRowComponent, Guild, ModalInteractionData, UserId}, + async_trait, + client::Context, +}; +use std::any::Any; +use tracing::{debug, error}; +use uuid::Uuid; + +use crate::modules::core::{ + entities::{ + exports::{Poll, PollChoice, PollVote}, + poll::PollWithChoicesAndVotes, + }, + helpers::establish_connection, +}; + +#[derive(Clone)] +struct PollOptionModalReceiver; + +#[async_trait] +impl ListenerRunnerFn for PollOptionModalReceiver { + async fn run<'a>(&self, args: &Vec>) -> () { + use crate::schema::{poll_choices, poll_votes, polls}; + use diesel::{ExpressionMethods, JoinOnDsl, QueryDsl, RunQueryDsl, SelectableHelper}; + + let ctx = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>()[0]; + let user_id = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>()[0]; + let guild_id = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>()[0] + .id; + let submit_data = args + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>()[0]; + let poll_id = submit_data.custom_id.split("/").collect::>()[1] + .parse::() + .unwrap(); + + // Step 1: Recover poll data from database (join with poll_choices, and poll_votes) + let connection = &mut establish_connection(); + let polls: Vec<(Poll, PollChoice, PollVote)> = polls::dsl::polls + .filter(polls::dsl::id.eq(poll_id)) + .inner_join( + poll_choices::dsl::poll_choices.on(polls::dsl::id.eq(poll_choices::dsl::poll_id)), + ) + .inner_join(poll_votes::dsl::poll_votes.on(polls::dsl::id.eq(poll_votes::dsl::poll_id))) + .select(( + Poll::as_select(), + PollChoice::as_select(), + PollVote::as_select(), + )) + .load::<(Poll, PollChoice, PollVote)>(connection) + .expect("Error getting poll data"); + + let poll = PollWithChoicesAndVotes::from(polls); + + println!("Poll test: {poll:?}"); + + // Step 2: Get new option to add to poll + let name = submit_data.components[0] + .components + .iter() + .find_map(|component| match component { + ActionRowComponent::InputText(input) => match input.custom_id == "option_name" { + true => input.value.clone(), + false => None, + }, + _ => None, + }) + .expect("Error getting option name"); + + let description = submit_data.components[1] + .components + .iter() + .find_map(|component| match component { + ActionRowComponent::InputText(input) => { + match input.custom_id == "option_description" { + true => input.value.clone(), + false => None, + } + } + _ => None, + }); + + // value is a name instead of spaces replaced by underscores + let value = name.clone().replace(" ", "_"); + + debug!("Name: {:?}, Description: {:?}", name, description); + + // Step 3: Add new option to poll + diesel::insert_into(poll_choices::table) + .values(( + poll_choices::dsl::poll_id.eq(poll.id), + poll_choices::dsl::value.eq(value), + poll_choices::dsl::label.eq(name), + poll_choices::dsl::description.eq(description), + )) + .execute(connection) + .expect("Error inserting new option"); + + // Step 4: Update poll message + match ctx + .http + .get_message(poll.thread_id.0, poll.embed_message_id.0) + .await + { + Ok(mut message) => { + // TODO: Get EmbedModel and use update + } + + Err(why) => { + error!("Error getting poll message: {:?}", why); + } + } + } +} + +lazy_static! { + pub static ref POLL_OPTION_MODAL_INTERACTION: Listener = Listener::new( + "option_data_poll", + "Save a poll option", + ListenerKind::Modal, + vec![ + ArgumentsLevel::ChannelId, + ArgumentsLevel::Context, + ArgumentsLevel::Guild, + ArgumentsLevel::User, + ArgumentsLevel::ModalSubmitData, + ], + Box::new(PollOptionModalReceiver), + ); +} diff --git a/app/src/modules/app/listeners/voice/join_channel.rs b/app/src/modules/app/listeners/voice/join_channel.rs new file mode 100644 index 0000000..8fe9eb1 --- /dev/null +++ b/app/src/modules/app/listeners/voice/join_channel.rs @@ -0,0 +1,123 @@ +use diesel::result::Error; +use rust_i18n::t; +use tracing::{error, info}; + +use std::cell::RefCell; +use std::collections::HashMap; +use std::sync::Arc; + +use serenity::client::Context; +use serenity::model::id::UserId; +use serenity::model::prelude::ChannelId; +use tokio::time; + +use crate::modules::core::entities::user::User; +use crate::modules::core::entities::UserIdWrapper; +use crate::modules::core::helpers::establish_connection; + +type Cache = HashMap; + +thread_local! { + static CACHE: Arc> = Arc::new(RefCell::new(HashMap::new())); +} + +pub async fn clear_cache() { + info!("Starting clear cache task"); + + loop { + time::sleep(time::Duration::from_secs(86400)).await; + info!("Clearing cache"); + + CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + + cache.clear(); + }); + } +} + +pub async fn join_channel(channel: &ChannelId, ctx: &Context, user_id: &UserId) -> () { + use crate::schema::users; + use diesel::{QueryDsl, RunQueryDsl}; + + let members = channel + .to_channel(&ctx) + .await + .unwrap() + .guild() + .unwrap() + .members(&ctx) + .unwrap(); + + let connection = &mut establish_connection(); + let user = users::table + .find(UserIdWrapper(*user_id)) + .first::(connection) as Result; + + match user { + Ok(user) => { + info!("{} joined channel", user.username); + + let message = CACHE.with(|cache| { + let mut cache = cache.borrow_mut(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as u32; + + if let Some((counter, last_update, _)) = cache.get_mut(user_id) { + if now - *last_update < 5 { + *last_update = now; + *counter += 1; + + return None; + } + } + + if let Some((counter, last_update, _)) = cache.get_mut(user_id) { + if now - *last_update < 5 { + *last_update = now; + + None + } else { + *last_update = now; + *counter += 1; + + if user.username == "scaliza" { + if members.len() == 1 { + return t!("interactions.join_channel.scaliza.empty_channel", user_id => user_id).to_string().into(); + } else if members.len() >= 3 { + return t!("interactions.join_channel.scaliza.many_users", user_id => user_id).to_string().into(); + } + + return format!("O CAPETA CHEGOU {} vezes 😡", counter).to_string().into() + } + + let key = format!("interactions.join_channel.{}", (*counter as u8).min(2)); + + t!(key.as_str(), user_id => user_id).to_string().into() + } + } else { + cache.insert(*user_id, (1, now, *user_id)); + info!("Added {} to cache", user.username); + + if user.username == "scaliza" { + return t!("interactions.join_channel.scaliza.0", user_id => user_id).to_string().into(); + } + + return t!("interactions.join_channel.0", user_id => user_id).to_string().into(); + } + }); + + if let Some(message) = message { + if let Err(why) = channel.say(&ctx.http, message).await { + error!("Error sending message: {:?}", why); + } + } + } + + Err(_) => { + error!("User not found") + } + } +} diff --git a/app/src/modules/app/listeners/voice/mod.rs b/app/src/modules/app/listeners/voice/mod.rs new file mode 100644 index 0000000..edfc1bf --- /dev/null +++ b/app/src/modules/app/listeners/voice/mod.rs @@ -0,0 +1,3 @@ +mod join_channel; + +pub use join_channel::{clear_cache, join_channel}; diff --git a/src/modules/app/mod.rs b/app/src/modules/app/mod.rs similarity index 100% rename from src/modules/app/mod.rs rename to app/src/modules/app/mod.rs diff --git a/app/src/modules/app/services/integrations/jukera.rs b/app/src/modules/app/services/integrations/jukera.rs new file mode 100644 index 0000000..b132d82 --- /dev/null +++ b/app/src/modules/app/services/integrations/jukera.rs @@ -0,0 +1,89 @@ +use bostil_core::{ + arguments::ArgumentsLevel, + integrations::{CallbackParams, Integration}, + listeners::ListenerKind, + runners::runners::ListenerRunnerFn, +}; +use diesel::{query_dsl::methods::FilterDsl, ExpressionMethods, RunQueryDsl}; +use lazy_static::lazy_static; +use serenity::{ + all::{Context, Message, UserId}, + async_trait, + gateway::ActivityData, +}; +use std::any::Any; + +use crate::modules::core::{entities::user::User, helpers::establish_connection}; + +#[derive(Clone)] +struct Jukera; + +#[async_trait] +impl ListenerRunnerFn for Jukera { + async fn run<'a>(&self, arguments: &Vec>) { + let ctx = arguments + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>()[0]; + let message = arguments + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>()[0]; + let user_id = arguments + .iter() + .filter_map(|arg| arg.downcast_ref::()) + .collect::>()[0]; + + run(&message, &ctx, &user_id).await; + } +} + +async fn run(message: &Message, ctx: &Context, user_id: &UserId) { + use crate::schema::users::dsl::{username, users}; + + let connection = &mut establish_connection(); + let user = users + .filter(username.eq("Isadora")) + .first::(connection) + .unwrap() as User; + + match user.id == *user_id { + true => { + // check if message is a embed message (music session) + match message.embeds.is_empty() { + true => { + ctx.set_activity(Some(ActivityData::competing( + "Campeonato de Leitada, Modalidade: Volume", + ))); + } + false => { + let current_music = match message.embeds.first() { + Some(embed) => embed.description.as_ref().unwrap(), + None => return, + }; + + ctx.set_activity(Some(ActivityData::listening(current_music))) + } + } + } + false => {} + } +} + +lazy_static! { + /// # Jukera integration + /// + /// > On listen messages from jukera check if the user currently listening to music and set the activity + pub static ref JUKERA_INTEGRATION: Integration = Integration::new( + "jukera", + "Listening to jukes_box", + vec![ + ArgumentsLevel::Context, + ArgumentsLevel::User, + ArgumentsLevel::Message, + ], + ListenerKind::Message, + Box::new(Jukera), + None:: + ); +} diff --git a/app/src/modules/app/services/integrations/mod.rs b/app/src/modules/app/services/integrations/mod.rs new file mode 100644 index 0000000..044965b --- /dev/null +++ b/app/src/modules/app/services/integrations/mod.rs @@ -0,0 +1,5 @@ +mod jukera; + +pub mod integrations { + pub use super::jukera::JUKERA_INTEGRATION; +} diff --git a/src/modules/app/services/mod.rs b/app/src/modules/app/services/mod.rs similarity index 100% rename from src/modules/app/services/mod.rs rename to app/src/modules/app/services/mod.rs diff --git a/app/src/modules/core/actions/collectors.rs b/app/src/modules/core/actions/collectors.rs new file mode 100644 index 0000000..2c8f42d --- /dev/null +++ b/app/src/modules/core/actions/collectors.rs @@ -0,0 +1,41 @@ +use bostil_core::collectors::{CommandCollector, ListenerCollector}; + +use crate::modules::app::{ + commands::commands, listeners::chat, services::integrations::integrations, +}; + +/// Command registration +pub fn register_commands(collector: &mut CommandCollector) { + let commands = [ + commands::language.to_command(), + commands::ping.to_command(), + commands::jingle.to_command(), + commands::poll.to_command(), + commands::radio.to_command(), + commands::join.to_command(), + commands::leave.to_command(), + commands::mute.to_command(), + ]; + + for command in commands.iter().cloned() { + collector.store_command(command); + } +} + +/// Store all the integrations +pub fn register_integrations(collector: &mut ListenerCollector) { + let integrations = [integrations::JUKERA_INTEGRATION.to_listener()]; + + for integration in integrations.iter().cloned() { + collector.store_listener(integration); + } +} + +/// Store all the listeners +pub fn register_listeners(collector: &mut ListenerCollector) { + let listeners = [chat::LOVE_LISTENER.to_listener()]; + + for listener in listeners.iter().cloned() { + collector.store_listener(listener); + } +} diff --git a/app/src/modules/core/actions/mod.rs b/app/src/modules/core/actions/mod.rs new file mode 100644 index 0000000..a60e5e7 --- /dev/null +++ b/app/src/modules/core/actions/mod.rs @@ -0,0 +1,2 @@ +pub mod collectors; +pub mod voice; diff --git a/app/src/modules/core/actions/voice.rs b/app/src/modules/core/actions/voice.rs new file mode 100644 index 0000000..3df4494 --- /dev/null +++ b/app/src/modules/core/actions/voice.rs @@ -0,0 +1,112 @@ +use rust_i18n::t; + +use serenity::framework::standard::CommandResult; +use serenity::model::prelude::{Guild, UserId}; +use serenity::prelude::Context; +use tracing::{debug, error, info}; + +pub async fn join(ctx: &Context, guild: &Guild, user_id: &UserId) -> CommandResult { + let channel_id = guild.voice_states.get(user_id).unwrap().channel_id; + + debug!("User is in voice channel: {:?}", channel_id); + + let connect_to = match channel_id { + Some(channel) => channel, + None => { + error!("User is not in a voice channel"); + + return Ok(t!("commands.voice.user_not_connected").to_string()); + } + }; + + debug!("Connecting to voice channel: {}", connect_to); + + let manager = songbird::get(ctx) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + debug!("Manager: {:?}", manager); + + if let Err(why) = manager.join(guild.id, connect_to).await { + error!("Failed to join voice channel: {:?}", why); + + return Ok(t!("commands.voice.join_failed").to_string()); + } + + info!("Joined voice channel"); + + Ok(t!("commands.voice.join").to_string()) +} + +pub async fn mute(ctx: &Context, guild: &Guild, _user_id: &UserId) -> CommandResult { + let manager = songbird::get(ctx) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + let handler = match manager.get(guild.id) { + Some(handler) => handler, + None => { + error!("Bot not connected to a voice channel"); + + return Ok(t!("commands.voice.bot_not_connected").to_string()); + } + }; + let mut handler = handler.lock().await; + + if handler.is_mute() { + debug!("User already muted"); + } else { + if let Err(why) = handler.mute(true).await { + error!("Failed to mute user: {:?}", why); + } + } + + Ok(t!("commands.voice.mute").to_string()) +} + +pub async fn unmute(ctx: &Context, guild: &Guild, _user_id: &UserId) -> CommandResult { + let manager = songbird::get(ctx) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + + let handler = match manager.get(guild.id) { + Some(handler) => handler, + None => { + error!("Bot not connected to a voice channel"); + + return Ok(t!("commands.voice.bot_not_connected").to_string()); + } + }; + let mut handler = handler.lock().await; + + if handler.is_mute() { + if let Err(why) = handler.mute(false).await { + error!("Failed to unmute user: {:?}", why); + } + } + + Ok(t!("commands.voice.un_mute").to_string()) +} + +pub async fn leave(ctx: &Context, guild: &Guild, _user_id: &UserId) -> CommandResult { + let manager = songbird::get(ctx) + .await + .expect("Songbird Voice client placed in at initialisation.") + .clone(); + let has_handler = manager.get(guild.id).is_some(); + + if has_handler { + if let Err(why) = manager.remove(guild.id).await { + error!("Failed to leave voice channel: {:?}", why); + } + } else { + error!("Bot not connected to a voice channel"); + + return Ok(t!("commands.voice.bot_not_connected").to_string()); + } + + Ok(t!("commands.voice.leave").to_string()) +} diff --git a/src/modules/core/entities/guild.rs b/app/src/modules/core/entities/guild.rs similarity index 100% rename from src/modules/core/entities/guild.rs rename to app/src/modules/core/entities/guild.rs diff --git a/app/src/modules/core/entities/mod.rs b/app/src/modules/core/entities/mod.rs new file mode 100644 index 0000000..4472d63 --- /dev/null +++ b/app/src/modules/core/entities/mod.rs @@ -0,0 +1,320 @@ +use diesel::{ + backend::Backend, + deserialize::{self, FromSql, FromSqlRow}, + expression::AsExpression, + pg::Pg, + serialize::{self, ToSql}, + sql_types::{BigInt, Integer, Nullable}, +}; + +use serenity::model::id::{ChannelId, GuildId, MessageId, UserId}; + +use crate::schema::sql_types::{ + Language as LanguageType, PollKind as PollKindType, PollState as PollStateType, +}; + +// TODO: implement macro to generate trait for discord id wrappers + +#[derive(FromSqlRow, Debug, AsExpression, Clone, Copy)] +#[diesel(sql_type = BigInt)] +pub struct ChannelIdWrapper(pub ChannelId); + +impl ToSql for ChannelIdWrapper +where + i64: ToSql, +{ + fn to_sql<'b>(&'b self, out: &mut serialize::Output<'b, '_, Pg>) -> serialize::Result { + >::to_sql(&i64::from(self.0), &mut out.reborrow()) + } +} + +impl FromSql for ChannelIdWrapper +where + DB: Backend, + i64: FromSql, +{ + fn from_sql(bytes: ::RawValue<'_>) -> deserialize::Result { + let id = >::from_sql(bytes)?; + Ok(Self(ChannelId::new(id as u64))) + } + + fn from_nullable_sql( + bytes: Option<::RawValue<'_>>, + ) -> deserialize::Result { + match bytes { + Some(bytes) => Self::from_sql(bytes), + None => Err("Unexpected null for non-null column".into()), + } + } +} + +#[derive(Debug, AsExpression, FromSqlRow, Hash, PartialEq, Eq, Clone, Copy)] +#[diesel(primary_key(id))] +#[diesel(sql_type = BigInt)] +pub struct GuildIdWrapper(pub GuildId); + +impl PartialEq for GuildIdWrapper { + fn eq(&self, other: &GuildId) -> bool { + self.0 == *other + } +} + +impl std::fmt::Display for GuildIdWrapper { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl ToSql for GuildIdWrapper +where + i64: ToSql, +{ + fn to_sql<'b>(&'b self, out: &mut serialize::Output<'b, '_, Pg>) -> serialize::Result { + >::to_sql(&i64::from(self.0), &mut out.reborrow()) + } +} + +impl FromSql for GuildIdWrapper +where + i64: FromSql, +{ + fn from_sql(bytes: ::RawValue<'_>) -> deserialize::Result { + let id = >::from_sql(bytes)?; + Ok(Self(GuildId::new(id as u64))) + } + + fn from_nullable_sql( + bytes: Option<::RawValue<'_>>, + ) -> deserialize::Result { + match bytes { + Some(bytes) => Self::from_sql(bytes), + None => Err("Unexpected null for non-null column".into()), + } + } +} + +#[derive(Debug, AsExpression, FromSqlRow, Clone, Copy)] +#[diesel(sql_type = diesel::sql_types::BigInt)] +pub struct MessageIdWrapper(pub MessageId); + +impl ToSql for MessageIdWrapper +where + i64: ToSql, +{ + fn to_sql<'b>(&'b self, out: &mut serialize::Output<'b, '_, Pg>) -> serialize::Result { + >::to_sql(&i64::from(self.0), &mut out.reborrow()) + } +} + +impl FromSql for MessageIdWrapper +where + i64: FromSql, +{ + fn from_sql(bytes: ::RawValue<'_>) -> deserialize::Result { + let id = >::from_sql(bytes)?; + Ok(Self(MessageId::new(id as u64))) + } +} + +impl FromSql, DB> for MessageIdWrapper +where + i64: FromSql, DB>, +{ + fn from_sql(bytes: ::RawValue<'_>) -> deserialize::Result { + let id = , DB>>::from_sql(bytes)?; + Ok(Self(MessageId::new(id as u64))) + } +} + +#[derive(Debug, AsExpression, FromSqlRow, Hash, PartialEq, Eq, Clone, Copy)] +#[diesel(primary_key(id))] +#[diesel(sql_type = diesel::sql_types::BigInt)] +pub struct UserIdWrapper(pub UserId); + +impl PartialEq for UserIdWrapper { + fn eq(&self, other: &UserId) -> bool { + self.0 == *other + } +} + +impl std::fmt::Display for UserIdWrapper { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) + } +} + +impl ToSql for UserIdWrapper +where + i64: ToSql, +{ + fn to_sql<'b>(&'b self, out: &mut serialize::Output<'b, '_, Pg>) -> serialize::Result { + >::to_sql(&i64::from(self.0), &mut out.reborrow()) + } +} + +impl FromSql for UserIdWrapper +where + i64: FromSql, +{ + fn from_sql(bytes: ::RawValue<'_>) -> deserialize::Result { + let id = >::from_sql(bytes)?; + Ok(Self(UserId::new(id as u64))) + } +} + +impl FromSql, DB> for UserIdWrapper +where + i64: FromSql, DB>, +{ + fn from_sql(bytes: ::RawValue<'_>) -> deserialize::Result { + let id = , DB>>::from_sql(bytes)?; + Ok(Self(UserId::new(id as u64))) + } +} + +#[derive(FromSqlRow, AsExpression, Debug, Clone, Copy)] +#[diesel(sql_type = crate::schema::sql_types::Language)] +pub enum Language { + En, + Pt, +} + +impl FromSql for Language +where + DB: Backend, + String: FromSql, +{ + fn from_sql(bytes: DB::RawValue<'_>) -> deserialize::Result { + match String::from_sql(bytes)?.as_str() { + "en" => Ok(Language::En), + "pt" => Ok(Language::Pt), + _ => Err("Unrecognized enum variant".into()), + } + } +} + +impl ToSql for Language +where + String: ToSql, +{ + fn to_sql(&self, out: &mut serialize::Output) -> serialize::Result { + match self { + Language::En => >::to_sql( + &"en".to_string(), + &mut out.reborrow(), + ), + Language::Pt => >::to_sql( + &"pt".to_string(), + &mut out.reborrow(), + ), + } + } +} + +#[repr(i32)] +#[derive(FromSqlRow, AsExpression, Debug, Clone, Copy)] +#[diesel(sql_type = crate::schema::sql_types::PollKind)] +pub enum PollKind { + SingleChoice, + MultipleChoice, +} + +impl PollKind { + pub fn to_int(&self) -> i32 { + match self { + PollKind::SingleChoice => 0, + PollKind::MultipleChoice => 1, + } + } + + pub fn from_i32(value: i32) -> Option { + match value { + 0 => Some(PollKind::SingleChoice), + 1 => Some(PollKind::MultipleChoice), + _ => None, + } + } +} + +impl ToSql for PollKind +where + i32: ToSql, +{ + fn to_sql<'b>(&'b self, out: &mut serialize::Output<'b, '_, Pg>) -> serialize::Result { + >::to_sql(&self.to_int(), &mut out.reborrow()) + } +} + +impl FromSql for PollKind +where + DB: Backend, + i32: FromSql, +{ + fn from_sql(bytes: DB::RawValue<'_>) -> deserialize::Result { + let value = i32::from_sql(bytes)?; + Ok(Self::from_i32(value).ok_or("Unrecognized enum variant")?) + } +} + +#[derive(Debug, FromSqlRow, AsExpression, Clone, Copy)] +#[diesel(sql_type = crate::schema::sql_types::PollState)] +pub enum PollState { + Created, + Started, + Stopped, + Ended, +} + +impl PollState { + pub fn from_i32(value: i32) -> Option { + match value { + 0 => Some(PollState::Created), + 1 => Some(PollState::Started), + 2 => Some(PollState::Stopped), + 3 => Some(PollState::Ended), + _ => None, + } + } + + pub fn to_i32(&self) -> i32 { + match self { + PollState::Created => 0, + PollState::Started => 1, + PollState::Stopped => 2, + PollState::Ended => 3, + } + } +} + +impl ToSql for PollState +where + i32: ToSql, +{ + fn to_sql(&self, out: &mut serialize::Output) -> serialize::Result { + >::to_sql(&self.to_i32(), &mut out.reborrow()) + } +} + +impl FromSql for PollState +where + DB: Backend, + i32: FromSql, +{ + fn from_sql(bytes: DB::RawValue<'_>) -> deserialize::Result { + let value = i32::from_sql(bytes)?; + Ok(Self::from_i32(value).ok_or("Unrecognized enum variant")?) + } +} + +pub mod exports { + pub use super::guild as Guild; + pub use super::poll::{Poll, PollChoice, PollVote}; + pub use super::user as User; + pub use super::Language; + pub use super::PollKind; + pub use super::PollState; +} + +pub mod guild; +pub mod poll; +pub mod user; diff --git a/app/src/modules/core/entities/poll.rs b/app/src/modules/core/entities/poll.rs new file mode 100644 index 0000000..dd41d2e --- /dev/null +++ b/app/src/modules/core/entities/poll.rs @@ -0,0 +1,95 @@ +use diesel::prelude::*; + +use super::{ChannelIdWrapper, MessageIdWrapper, PollKind, PollState, UserIdWrapper}; + +#[derive(Queryable, Selectable, Identifiable, Insertable, Debug, Clone)] +#[diesel(table_name = crate::schema::polls)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct Poll { + pub id: uuid::Uuid, + pub name: String, + pub description: Option, + pub kind: PollKind, + pub state: PollState, + pub thread_id: ChannelIdWrapper, + pub embed_message_id: MessageIdWrapper, + pub poll_message_id: Option, + pub started_at: Option, + pub ended_at: Option, + pub created_at: time::OffsetDateTime, + pub created_by: UserIdWrapper, +} + +#[derive(Queryable, Selectable, Identifiable, Insertable, Associations, Debug, Clone)] +#[diesel(belongs_to(Poll))] +#[diesel(primary_key(poll_id, value))] +#[diesel(table_name = crate::schema::poll_choices)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct PollChoice { + pub poll_id: uuid::Uuid, + pub value: String, + pub label: String, + pub description: Option, + pub created_at: time::OffsetDateTime, +} + +#[derive(Queryable, Selectable, Identifiable, Insertable, Associations, Debug, Clone)] +#[diesel(belongs_to(PollChoice, foreign_key = choice_value))] +#[diesel(belongs_to(Poll))] +#[diesel(primary_key(user_id, poll_id, choice_value))] +#[diesel(table_name = crate::schema::poll_votes)] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct PollVote { + pub poll_id: uuid::Uuid, + pub choice_value: String, + pub user_id: UserIdWrapper, + pub voted_at: time::OffsetDateTime, +} + +#[derive(Debug)] +pub struct PollWithChoicesAndVotes { + pub id: uuid::Uuid, + pub name: String, + pub description: Option, + pub kind: PollKind, + pub state: PollState, + pub thread_id: ChannelIdWrapper, + pub embed_message_id: MessageIdWrapper, + pub poll_message_id: Option, + pub started_at: Option, + pub ended_at: Option, + pub created_at: time::OffsetDateTime, + pub created_by: UserIdWrapper, + pub choices: Vec, + pub votes: Vec, +} + +impl PollWithChoicesAndVotes { + pub fn from(polls: Vec<(Poll, PollChoice, PollVote)>) -> Self { + let poll = polls[0].0.clone(); + let mut choices = Vec::new(); + let mut votes = Vec::new(); + + for (_, choice, vote) in polls { + choices.push(choice); + votes.push(vote); + } + + Self { + id: poll.id, + name: poll.name, + description: poll.description, + kind: poll.kind, + state: poll.state, + thread_id: poll.thread_id, + embed_message_id: poll.embed_message_id, + poll_message_id: poll.poll_message_id, + started_at: poll.started_at, + ended_at: poll.ended_at, + created_at: poll.created_at, + created_by: poll.created_by, + choices, + votes, + } + } +} diff --git a/src/modules/core/entities/user.rs b/app/src/modules/core/entities/user.rs similarity index 73% rename from src/modules/core/entities/user.rs rename to app/src/modules/core/entities/user.rs index fabf65e..90480ca 100644 --- a/src/modules/core/entities/user.rs +++ b/app/src/modules/core/entities/user.rs @@ -2,11 +2,12 @@ use diesel::prelude::*; use super::UserIdWrapper; -#[derive(Queryable, Selectable, Identifiable)] +#[derive(Queryable, Selectable, Identifiable, Insertable, PartialEq)] #[diesel(table_name = crate::schema::users)] #[diesel(check_for_backend(diesel::pg::Pg))] pub struct User { pub id: UserIdWrapper, + pub username: String, pub added_at: time::OffsetDateTime, pub updated_at: time::OffsetDateTime, } diff --git a/src/lib.rs b/app/src/modules/core/helpers/database.rs similarity index 70% rename from src/lib.rs rename to app/src/modules/core/helpers/database.rs index 9e91e71..b3ebb87 100644 --- a/src/lib.rs +++ b/app/src/modules/core/helpers/database.rs @@ -1,22 +1,15 @@ -use diesel::pg::PgConnection; -use diesel::Connection; +use diesel::{pg::PgConnection, Connection}; use dotenvy::dotenv; -use std::env; -#[macro_use(i18n)] -extern crate rust_i18n; -extern crate diesel; - -// TODO: implementar algum jeito para que cada servidor tenha seu próprio idioma e não alterar o idioma de todos os servidores // CHECK Backend implementation +// TODO: implementar algum jeito para que cada servidor tenha seu próprio idioma e não alterar o idioma de todos os servidores i18n!("public/locales", fallback = "en-US"); pub fn establish_connection() -> PgConnection { + use std::env; + dotenv().ok(); let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); PgConnection::establish(&database_url) .unwrap_or_else(|_| panic!("Error connecting to {}", database_url)) } - -pub mod modules; -pub mod schema; diff --git a/app/src/modules/core/helpers/http_client.rs b/app/src/modules/core/helpers/http_client.rs new file mode 100644 index 0000000..9d156e4 --- /dev/null +++ b/app/src/modules/core/helpers/http_client.rs @@ -0,0 +1,12 @@ +use reqwest::Client; +use serenity::client::Context; + +use crate::HttpKey; + +pub async fn get_client(ctx: &Context) -> Client { + let data = ctx.data.read().await; + + data.get::() + .cloned() + .expect("Guaranteed to exist in the typemap.") +} diff --git a/app/src/modules/core/helpers/mod.rs b/app/src/modules/core/helpers/mod.rs new file mode 100644 index 0000000..5d9405e --- /dev/null +++ b/app/src/modules/core/helpers/mod.rs @@ -0,0 +1,5 @@ +mod database; +mod http_client; + +pub use database::establish_connection; +pub use http_client::get_client; diff --git a/src/modules/core/mod.rs b/app/src/modules/core/mod.rs similarity index 61% rename from src/modules/core/mod.rs rename to app/src/modules/core/mod.rs index 7fe488f..642f4c6 100644 --- a/src/modules/core/mod.rs +++ b/app/src/modules/core/mod.rs @@ -1,5 +1,3 @@ pub mod actions; -pub mod constants; pub mod entities; pub mod helpers; -pub mod lib; diff --git a/src/modules/mod.rs b/app/src/modules/mod.rs similarity index 100% rename from src/modules/mod.rs rename to app/src/modules/mod.rs diff --git a/src/schema.rs b/app/src/schema.rs similarity index 83% rename from src/schema.rs rename to app/src/schema.rs index e8ad419..ecd294d 100644 --- a/src/schema.rs +++ b/app/src/schema.rs @@ -1,13 +1,17 @@ // @generated automatically by Diesel CLI. pub mod sql_types { - #[derive(diesel::sql_types::SqlType)] + #[derive(diesel::sql_types::SqlType, std::fmt::Debug)] #[diesel(postgres_type(name = "language"))] pub struct Language; - #[derive(diesel::sql_types::SqlType)] + #[derive(diesel::sql_types::SqlType, std::fmt::Debug)] #[diesel(postgres_type(name = "poll_kind"))] pub struct PollKind; + + #[derive(diesel::sql_types::SqlType, std::fmt::Debug)] + #[diesel(postgres_type(name = "poll_state"))] + pub struct PollState; } diesel::table! { @@ -28,13 +32,13 @@ diesel::table! { use crate::modules::core::entities::exports::*; poll_choices (poll_id, value) { + poll_id -> Uuid, #[max_length = 50] value -> Varchar, #[max_length = 25] label -> Varchar, description -> Nullable, created_at -> Timestamptz, - poll_id -> Uuid, } } @@ -55,6 +59,7 @@ diesel::table! { use diesel::sql_types::*; use crate::modules::core::entities::exports::*; use super::sql_types::PollKind; + use super::sql_types::PollState; polls (id) { id -> Uuid, @@ -62,6 +67,7 @@ diesel::table! { name -> Varchar, description -> Nullable, kind -> PollKind, + state -> PollState, timer -> Int8, thread_id -> Int8, embed_message_id -> Int8, @@ -79,11 +85,14 @@ diesel::table! { users (id) { id -> Int8, + #[max_length = 255] + username -> Varchar, added_at -> Timestamptz, updated_at -> Timestamptz, } } diesel::joinable!(poll_choices -> polls (poll_id)); +diesel::joinable!(poll_votes -> polls (poll_id)); diesel::allow_tables_to_appear_in_same_query!(guilds, poll_choices, poll_votes, polls, users,); diff --git a/core/Cargo.toml b/core/Cargo.toml new file mode 100644 index 0000000..04f61ef --- /dev/null +++ b/core/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "bostil-core" +repository = "https://github.com/kszinhu/bostil-bot" +version = "0.1.0" +description = """ + Core library for the Bostil Discord bot. + This library contains the core functionality of the bot, such as the commands, the event handlers, and the configuration. + It also contains the proc macros used by the bot. +""" +authors = { workspace = true } +edition = { workspace = true } + +[dependencies] +# Proc macros +syn = { version = "2", features = ["full"] } +quote = "1" +proc-macro2 = "1" + +# Trait factories +dyn-clone = "*" + +lazy_static = { workspace = true } +once_cell = { workspace = true } + +# Discord (Main dependencies) +serenity = { workspace = true } +songbird = { workspace = true } + +# Logging +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +tracing-futures = { workspace = true } + +# Internationalization +serde_yaml = "*" + +# Other +colored = "*" diff --git a/src/modules/core/lib/arguments.rs b/core/src/arguments.rs similarity index 79% rename from src/modules/core/lib/arguments.rs rename to core/src/arguments.rs index 3f7cb10..047efae 100644 --- a/src/modules/core/lib/arguments.rs +++ b/core/src/arguments.rs @@ -1,7 +1,7 @@ use std::any::Any; use serenity::{ - all::{CommandDataOption, ModalInteractionData}, + all::{CommandDataOption, Message, ModalInteractionData}, client::Context, model::{ guild::Guild, @@ -12,22 +12,24 @@ use serenity::{ /** Arguments to provide to a run function - - None: No arguments + - `None`: No arguments - Value: 0 - - Options: options (&command.data.options) + - `Options`: options (&command.data.options) - Value: 1 - - Context: context (&context) + - `Context`: context (&context) - Value: 2 - - Guild: guild (&guild) + - `Guild`: guild (&guild) - Value: 3 - - User: user (&user) + - `User`: user (&user) - Value: 4 - - InteractionId: interaction_id (&interaction_id) + - `InteractionId`: interaction_id (&interaction_id) - Value: 5 - - ChannelId: channel_id (&channel_id) + - `ChannelId`: channel_id (&channel_id) - Value: 6 - - ModalSubmitInteractionData: modal_submit_data (&modal_submit_data) + - `ModalSubmitData`: modal_submit_data (&modal_submit_data) - Value: 7 + - `Message`: message (&message) + - Value: 8 */ #[derive(Debug, Clone, Copy)] pub enum ArgumentsLevel { @@ -39,6 +41,7 @@ pub enum ArgumentsLevel { InteractionId, ChannelId, ModalSubmitData, + Message, } impl ArgumentsLevel { @@ -52,6 +55,7 @@ impl ArgumentsLevel { ArgumentsLevel::InteractionId => 5, ArgumentsLevel::ChannelId => 6, ArgumentsLevel::ModalSubmitData => 7, + ArgumentsLevel::Message => 8, } } @@ -65,6 +69,7 @@ impl ArgumentsLevel { options: Option>, interaction_id: Option, modal_submit_data: Option<&ModalInteractionData>, + message: Option, ) -> Vec> { let mut arguments: Vec> = vec![]; @@ -80,6 +85,7 @@ impl ArgumentsLevel { ArgumentsLevel::ModalSubmitData => { arguments.push(Box::new(modal_submit_data.unwrap().clone())) } + ArgumentsLevel::Message => arguments.push(Box::new(message.clone())), } } diff --git a/core/src/collectors/command.rs b/core/src/collectors/command.rs new file mode 100644 index 0000000..282c266 --- /dev/null +++ b/core/src/collectors/command.rs @@ -0,0 +1,42 @@ +use serenity::builder::CreateCommand; + +use crate::commands::{Command, CommandContext}; + +#[derive(Clone)] +pub struct CommandCollector { + pub commands: Vec, + pub length: usize, +} + +impl CommandCollector { + pub fn new() -> Self { + Self { + commands: Vec::new(), + length: 0, + } + } + + /// Store a command in the collector + pub fn store_command(&mut self, command: Command) { + self.commands.push(command); + self.length += 1; + } + + /// Get the fingerprints of all the commands in the collector + /// + /// Args: + /// - `context` - The context to filter the commands by + /// + /// Returns: + /// - A vector of fingerprints of the commands + pub fn get_fingerprints(self, context: Option) -> Vec { + self.commands + .iter() + .filter(|command| match context { + Some(context) => command.context == context, + None => true, + }) + .map(|command| command.fingerprint.clone().unwrap()) + .collect::>() + } +} diff --git a/core/src/collectors/listener.rs b/core/src/collectors/listener.rs new file mode 100644 index 0000000..0c2d3bf --- /dev/null +++ b/core/src/collectors/listener.rs @@ -0,0 +1,31 @@ +use crate::listeners::{Listener, ListenerKind}; + +#[derive(Clone)] +pub struct ListenerCollector { + pub listeners: Vec, + pub length: usize, +} + +impl ListenerCollector { + pub fn new() -> Self { + Self { + listeners: Vec::new(), + length: 0, + } + } + + /// Store a listener in the collector + pub fn store_listener(&mut self, listener: Listener) { + self.listeners.push(listener); + self.length += 1; + } + + /// Get all the listeners in the collector of a specific kind + pub fn filter_listeners(&self, kind: ListenerKind) -> Vec { + self.listeners + .iter() + .filter(|listener| listener.kind == kind) + .cloned() + .collect() + } +} diff --git a/core/src/collectors/mod.rs b/core/src/collectors/mod.rs new file mode 100644 index 0000000..bcbd537 --- /dev/null +++ b/core/src/collectors/mod.rs @@ -0,0 +1,5 @@ +mod command; +mod listener; + +pub use command::CommandCollector; +pub use listener::ListenerCollector; diff --git a/core/src/commands.rs b/core/src/commands.rs new file mode 100644 index 0000000..1c8ab6f --- /dev/null +++ b/core/src/commands.rs @@ -0,0 +1,89 @@ +use super::arguments::ArgumentsLevel; +use crate::runners::runners::CommandRunnerFn; + +use serenity::builder::CreateCommand; + +/// Context of the command that can be used in a guild or global +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +pub enum CommandContext { + Global, + Guild, +} + +/// Category is a type of funcionalities that the command is used (eg.: Fun, Moderation, ...) +#[derive(Debug, Clone, Copy)] +pub enum CommandCategory { + Fun, + Moderation, + Music, + Misc, + Voice, + Admin, + General, +} + +impl CommandCategory { + pub fn from(category: &str) -> Self { + match category { + "Fun" => Self::Fun, + "Moderation" => Self::Moderation, + "Music" => Self::Music, + "Misc" => Self::Misc, + "Voice" => Self::Voice, + "Admin" => Self::Admin, + "General" => Self::General, + _ => Self::General, + } + } +} + +#[derive(Clone)] +/// Struct for Application Command used to executes and register application command +pub struct Command { + /// Name is the identifier of the command (unique) + pub name: String, + /// Description is a short description of the command + pub description: String, + /// Category is a type of funcionalities that the command is used (eg.: Fun, Moderation, ...) + pub category: CommandCategory, + /// Context is a type of command that can be used in a guild or global + pub context: CommandContext, + /// Arguments is a list of arguments that the command uses on Runner + pub arguments: Vec, + /// Runner is a function that will be executed when the command is called + pub runner: Box, + /// Fingerprint is resgiter struct for application command + pub fingerprint: Option, +} + +impl Command { + pub fn new( + name: &str, + description: &str, + context: CommandContext, + category: CommandCategory, + arguments: Vec, + runner: Box, + fingerprint: Option, + ) -> Self { + let sorted_arguments = { + let mut sorted_arguments = arguments.clone(); + sorted_arguments.sort_by(|a, b| a.value().cmp(&b.value())); + sorted_arguments + }; + + Self { + runner, + category, + fingerprint, + context, + arguments: sorted_arguments, + description: description.to_string(), + name: name.to_string(), + } + } + + pub fn to_command(&self) -> Command { + self.clone() + } +} diff --git a/core/src/embeds.rs b/core/src/embeds.rs new file mode 100644 index 0000000..7200422 --- /dev/null +++ b/core/src/embeds.rs @@ -0,0 +1,145 @@ +use serenity::{ + builder::{CreateEmbed, CreateMessage, EditMessage}, + client::Context, + model::channel::GuildChannel, + model::channel::Message, +}; +use std::any::Any; +use tracing::{error, info}; + +pub trait EmbedLifetime { + /// Function to create the embed (BUILDER) + fn build(&self, arguments: &Vec>) -> CreateEmbed; + /// Function to run when the embed is being updated + fn on_update(&self, arguments: &Vec>) -> CreateEmbed { + self.build(arguments) + } + /// Function to run when the embed is being sent (after build) + fn after_sent(&self, _arguments: &Vec>) {} + /// Function to check if the embed should be updated + fn should_update(&self, _arguments: &Vec>) -> bool { + false + } + /// Function to check if the embed should be removed + fn should_delete(&self, _arguments: &Vec>) -> bool { + false + } +} + +pub struct ApplicationEmbed { + /// The name of the embed + pub name: String, + /// The description of the embed + pub description: Option, + /// The content of the message that will be sent + pub message: Option, + pub arguments: Vec>, + /// The lifetime of the embed + pub lifetime: Box, + /// The embed was saved to the database and can be recovered + pub is_recoverable: bool, + /// The identifier of the embed on the database + pub database_id: Option, + /// The message id related of sent message + pub message_id: Option, +} + +impl ApplicationEmbed { + pub fn new( + name: &str, + description: Option<&str>, + message: Option<&str>, + arguments: Vec>, + lifetime: Box, + is_recoverable: Option, + database_id: Option, + message_id: Option, + ) -> Self { + Self { + lifetime, + arguments, + database_id, + message_id, + is_recoverable: match is_recoverable { + Some(val) => val, + None => false, + }, + name: name.to_string(), + description: match description { + Some(desc) => Some(desc.to_string()), + None => None, + }, + message: match message { + Some(msg) => Some(msg.to_string()), + None => None, + }, + } + } + + pub async fn send_message(&self, ctx: &Context, channel: &GuildChannel) -> Result { + match channel + .send_message( + &ctx.http, + CreateMessage::default() + .content(self.message.clone().unwrap()) + .embed(self.lifetime.build(&self.arguments)), + ) + .await + { + Ok(sent_message) => { + info!("Embed {} sent", self.name); + + Ok(sent_message) + } + + Err(_) => { + error!("Embed {} not sent", self.name); + + Err(()) + } + } + } + + pub async fn update_message( + &self, + ctx: &Context, + mut sent_message: Message, + ) -> Result { + match sent_message + .edit( + &ctx.http, + EditMessage::default().embed(self.lifetime.on_update(&self.arguments)), + ) + .await + { + Ok(_) => { + info!("Embed {} updated", self.name); + + Ok(sent_message.clone()) + } + + Err(_) => { + info!("Embed {} not updated", self.name); + + Err(()) + } + } + } + + pub async fn delete_message(&self, ctx: &Context, sent_message: Message) -> Result<(), ()> { + sent_message.delete(&ctx.http).await.map_err(|_| { + error!("Embed failed to delete"); + }) + } +} + +impl std::fmt::Display for ApplicationEmbed { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Embed: {} \n {}", + self.name, + self.description.clone().unwrap() + ) + } +} diff --git a/core/src/integrations.rs b/core/src/integrations.rs new file mode 100644 index 0000000..4574883 --- /dev/null +++ b/core/src/integrations.rs @@ -0,0 +1,82 @@ +use tracing::info; + +use crate::{ + arguments::ArgumentsLevel, + listeners::{Listener, ListenerKind}, + runners::runners::ListenerRunnerFn, +}; + +/// Integration is a representation of listerner that interacts with some other service +#[derive(Clone)] +pub struct Integration { + /// Name of the integration + pub name: String, + /// Description of the integration + pub description: String, + /// Arguments that the integration uses + pub arguments: Vec, + /// Kind of the listener + pub kind: ListenerKind, + /// Runner of the integration when it is called + pub runner: Box, +} + +pub type CallbackParams = (String, String, Vec, ListenerKind); + +impl Integration { + pub fn new( + name: &str, + description: &str, + arguments: Vec, + kind: ListenerKind, + runner: Box, + callback: Option, + ) -> Self { + Self { + kind, + arguments: arguments.clone(), + name: name.to_string(), + description: description.to_string(), + runner: { + info!("Running {} integration", name); + + if callback.is_some() { + callback.unwrap()((name.to_string(), description.to_string(), arguments, kind)); + } + + runner.clone() + }, + } + } + + /// Propagate statics items to listener conversion + pub fn to_listener(&self) -> Listener { + self.into() + } +} + +// implements equal integration to listener +impl From for Listener { + fn from(integration: Integration) -> Self { + Self { + name: integration.name, + description: integration.description, + kind: integration.kind, + arguments: integration.arguments, + runner: integration.runner, + } + } +} + +// implements equal listener to integration +impl From<&Integration> for Listener { + fn from(integration: &Integration) -> Self { + Self { + name: integration.name.clone(), + description: integration.description.clone(), + kind: integration.kind, + arguments: integration.arguments.clone(), + runner: integration.runner.clone(), + } + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs new file mode 100644 index 0000000..692c771 --- /dev/null +++ b/core/src/lib.rs @@ -0,0 +1,9 @@ +extern crate proc_macro; + +pub mod arguments; +pub mod collectors; +pub mod commands; +pub mod embeds; +pub mod integrations; +pub mod listeners; +pub mod runners; diff --git a/core/src/listeners.rs b/core/src/listeners.rs new file mode 100644 index 0000000..8b8349f --- /dev/null +++ b/core/src/listeners.rs @@ -0,0 +1,45 @@ +use crate::{arguments::ArgumentsLevel, runners::runners::ListenerRunnerFn}; + +/// ListenerKind is an enum that represents the different types of listeners that can be used in the bot. +#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)] +pub enum ListenerKind { + /// Message is a listener that listens to chat messages + Message, + /// Reaction is a listener that listens to reactions + Reaction, + /// VoiceState is a listener that listens to voice state updates + VoiceState, + /// Modal is a listener that listens to modal submissions + Modal, +} + +#[derive(Clone)] +pub struct Listener { + pub name: String, + pub description: String, + pub kind: ListenerKind, + pub arguments: Vec, + pub runner: Box, +} + +impl Listener { + pub fn new( + name: &str, + description: &str, + kind: ListenerKind, + arguments: Vec, + runner: Box, + ) -> Self { + Self { + kind, + arguments, + runner, + name: name.to_string(), + description: description.to_string(), + } + } + + pub fn to_listener(&self) -> Self { + self.clone() + } +} diff --git a/core/src/runners/command.rs b/core/src/runners/command.rs new file mode 100644 index 0000000..246828d --- /dev/null +++ b/core/src/runners/command.rs @@ -0,0 +1,102 @@ +use dyn_clone::DynClone; +use serenity::{ + all::Embed, + async_trait, + builder::{CreateEmbed, EditInteractionResponse}, + framework::standard::CommandResult as SerenityCommandResult, +}; +use std::any::Any; + +/// CommandResponse is a type of response that the command can return +#[derive(Debug, Clone)] +pub enum CommandResponse { + String(String), + Embed(Embed), + Message(EditInteractionResponse), + None, +} + +/// CommandResult is a type of result (ok or error) that the command can return +pub type CommandResult<'a> = SerenityCommandResult; + +/// Function that will be executed when the command is called +#[async_trait] +pub trait CommandRunnerFn: DynClone { + async fn run<'a>(&self, arguments: &Vec>) -> CommandResult<'a>; +} + +dyn_clone::clone_trait_object!(CommandRunnerFn); + +impl std::fmt::Debug for dyn CommandRunnerFn { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "") + } +} + +impl CommandResponse { + pub fn to_embed(&self) -> CreateEmbed { + match self { + CommandResponse::String(string) => CreateEmbed::default().description(string.clone()), + CommandResponse::Embed(command_embed) => CreateEmbed::from(command_embed.clone()), + _ => CreateEmbed::default(), + } + } + + pub fn to_string(&self) -> String { + match self { + CommandResponse::String(string) => string.clone(), + CommandResponse::Embed(embed) => embed.description.clone().unwrap(), + _ => "".to_string(), + } + } +} + +impl PartialEq for CommandResponse { + fn eq(&self, other: &Self) -> bool { + match self { + CommandResponse::String(string) => match other { + CommandResponse::String(other_string) => string == other_string, + _ => false, + }, + CommandResponse::Embed(embed) => match other { + CommandResponse::Embed(other_embed) => { + Some(embed.title.clone()) == Some(other_embed.title.clone()) + } + _ => false, + }, + _ => match other { + CommandResponse::None => true, + _ => false, + }, + } + } + fn ne(&self, other: &Self) -> bool { + match self { + CommandResponse::String(string) => match other { + CommandResponse::String(other_string) => string != other_string, + _ => true, + }, + CommandResponse::Embed(embed) => match other { + CommandResponse::Embed(other_embed) => { + Some(embed.title.clone()) != Some(other_embed.title.clone()) + } + _ => true, + }, + _ => match other { + CommandResponse::None => false, + _ => true, + }, + } + } +} + +impl std::fmt::Display for CommandResponse { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CommandResponse::String(string) => write!(f, "{}", string), + CommandResponse::Embed(embed) => write!(f, "{}", embed.description.clone().unwrap()), + CommandResponse::Message(_) => write!(f, "Message"), + _ => write!(f, "None"), + } + } +} diff --git a/core/src/runners/listener.rs b/core/src/runners/listener.rs new file mode 100644 index 0000000..3d85f62 --- /dev/null +++ b/core/src/runners/listener.rs @@ -0,0 +1,16 @@ +use dyn_clone::DynClone; +use serenity::async_trait; +use std::any::Any; + +#[async_trait] +pub trait ListenerRunnerFn: DynClone { + async fn run<'a>(&self, arguments: &Vec>) -> (); +} + +dyn_clone::clone_trait_object!(ListenerRunnerFn); + +impl std::fmt::Debug for dyn ListenerRunnerFn { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "") + } +} diff --git a/core/src/runners/mod.rs b/core/src/runners/mod.rs new file mode 100644 index 0000000..820997e --- /dev/null +++ b/core/src/runners/mod.rs @@ -0,0 +1,9 @@ +mod command; +mod listener; + +pub mod runners { + pub use super::command::CommandResponse; + pub use super::command::CommandResult; + pub use super::command::CommandRunnerFn; + pub use super::listener::ListenerRunnerFn; +} diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 98ca433..a33c621 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -2,14 +2,22 @@ version: "3.8" services: client: container_name: bostil-bot - restart: unless-stopped build: context: . dockerfile: Dockerfile env_file: - .env - volumes: - - database:/app/src/public/database + + database: + container_name: bostil-bot-db + image: postgres:16 + volumes: + - db-data:/var/lib/postgresql/data + env_file: + - .env + - .env.local + ports: + - "5432:5432" volumes: - database: + db-data: diff --git a/docker-compose.yml b/docker-compose.yml index 47f0b71..5d126b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,18 +1,27 @@ version: "3.8" services: client: - container_name: bostil-client + container_name: bostil-bot-client image: ghcr.io/kszinhu/bostil-bot:master restart: unless-stopped networks: - kszinhu env_file: - stack.env + + database: + container_name: bostil-bot-database + image: postgres:16 + restart: unless-stopped + networks: + - kszinhu volumes: - - database:/usr/src/app/database + - bostil-database:/var/lib/postgresql/data + env_file: + - stack.env volumes: - database: + bostil-database: networks: kszinhu: name: kszinhu diff --git a/migrations/2024-01-09-225829_create_guilds/down.sql b/migrations/2024-01-09-225829_create_guilds/down.sql deleted file mode 100644 index 7da3df7..0000000 --- a/migrations/2024-01-09-225829_create_guilds/down.sql +++ /dev/null @@ -1,2 +0,0 @@ -DROP TYPE IF EXISTS language; -DROP TABLE guilds; \ No newline at end of file diff --git a/migrations/2024-01-10-034005_create_polls/down.sql b/migrations/2024-01-10-034005_create_polls/down.sql deleted file mode 100644 index cc84c56..0000000 --- a/migrations/2024-01-10-034005_create_polls/down.sql +++ /dev/null @@ -1,7 +0,0 @@ -DROP TYPE poll_kind CASCADE; - -DROP TABLE poll_votes; - -DROP TABLE poll_choices; - -DROP TABLE polls; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index f4c3035..0000000 --- a/src/main.rs +++ /dev/null @@ -1,437 +0,0 @@ -include!("lib.rs"); - -use std::sync::Arc; -use std::{borrow::BorrowMut, env}; - -use commands::{collect_commands, CommandResponse}; -use internal::arguments::ArgumentsLevel; -use serenity::all::ActivityType; -use serenity::async_trait; -use serenity::client::bridge::gateway::ShardManager; -use serenity::framework::StandardFramework; -use serenity::model::application::interaction::Interaction; -use serenity::model::gateway::Ready; -use serenity::model::id::GuildId; -use serenity::model::prelude::command::Command; -use serenity::model::voice::VoiceState; -use serenity::prelude::*; - -use songbird::SerenityInit; - -use database::locale::apply_locale; -use integrations::get_chat_integrations as integrations; -use interactions::get_chat_interactions as chat_interactions; -use interactions::get_modal_interactions as modal_interactions; -use interactions::voice_channel::join_channel as voice_channel; -use internal::debug::{log_message, MessageTypes}; - -struct ShardManagerContainer; - -impl TypeMapKey for ShardManagerContainer { - type Value = Arc>; -} - -struct Handler; - -#[async_trait] -impl EventHandler for Handler { - // Each message on the server - async fn message(&self, ctx: Context, msg: serenity::model::channel::Message) { - let debug: bool = env::var("DEBUG").is_ok(); - - if debug { - log_message( - format!("Received message from User: {:#?}", msg.author.name).as_str(), - MessageTypes::Debug, - ); - } - - let integrations = integrations().into_iter(); - let interactions = chat_interactions().into_iter(); - - for interaction in interactions { - let guild = msg.guild_id.unwrap().to_guild_cached(&ctx.cache).unwrap(); - - match interaction.interaction_type { - interactions::InteractionType::Chat => { - let _ = interaction - .runner - .run(&ArgumentsLevel::provide( - &interaction.arguments, - &ctx, - &guild, - &msg.author, - &msg.channel_id, - None, - None, - None, - )) - .await; - } - _ => {} - } - } - - for integration in integrations { - let user_id = msg.author.id; - - match integration.integration_type { - integrations::IntegrationType::Chat => { - let _ = integration.callback.run(&msg, &ctx, &user_id).await; - } - } - } - } - - async fn ready(&self, ctx: Context, ready: Ready) { - log_message( - format!("Connected on Guilds: {}", ready.guilds.len()).as_str(), - MessageTypes::Server, - ); - - // global commands - let commands = Command::set_global_application_commands(&ctx.http, |commands| { - commands.create_application_command(|command| commands::ping::register(command)) - }) - .await; - - if let Err(why) = commands { - log_message( - format!("Cannot register slash commands: {}", why).as_str(), - MessageTypes::Failed, - ); - } - - log_message("Registered global slash commands", MessageTypes::Success); - - // guild commands and apply language to each guild - for guild in ready.guilds.iter() { - let commands = GuildId::set_commands(&guild.id, &ctx.http, |commands| { - commands.create_application_command(|command| commands::jingle::register(command)); - commands - .create_application_command(|command| commands::language::register(command)); - commands.create_application_command(|command| commands::radio::register(command)); - commands.create_application_command(|command| { - commands::voice::leave::register(command) - }); - commands - .create_application_command(|command| commands::voice::mute::register(command)); - commands - .create_application_command(|command| commands::voice::join::register(command)); - commands - .create_application_command(|command| commands::poll::setup::register(command)); - - commands - }) - .await; - - apply_locale( - &guild - .id - .to_guild_cached(&ctx.cache) - .unwrap() - .preferred_locale, - &guild.id, - true, - ); - - if let Err(why) = commands { - log_message( - format!("Cannot register slash commands: {}", why).as_str(), - MessageTypes::Failed, - ); - } - - log_message( - format!("Registered slash commands for guild {}", guild.id).as_str(), - MessageTypes::Success, - ); - } - - ctx.set_activity(ActivityType::Playing( - "O auxílio emergencial no PIX do Mito", - )) - .await; - } - - // On User connect to voice channel - async fn voice_state_update(&self, ctx: Context, old: Option, new: VoiceState) { - let debug: bool = env::var("DEBUG").is_ok(); - - let is_bot: bool = new.user_id.to_user(&ctx.http).await.unwrap().bot; - let has_connected: bool = new.channel_id.is_some() && old.is_none(); - - if has_connected && !is_bot { - if debug { - log_message( - format!( - "User connected to voice channel: {:#?}", - new.channel_id.unwrap().to_string() - ) - .as_str(), - MessageTypes::Debug, - ); - } - - voice_channel::join_channel(&new.channel_id.unwrap(), &ctx, &new.user_id).await; - } - - match old { - Some(old) => { - if old.channel_id.is_some() && new.channel_id.is_none() && !is_bot { - if debug { - log_message( - format!( - "User disconnected from voice channel: {:#?}", - old.channel_id.unwrap().to_string() - ) - .as_str(), - MessageTypes::Debug, - ); - } - } - } - None => {} - } - } - - // Slash commands - async fn interaction_create(&self, ctx: Context, interaction: Interaction) { - let debug: bool = env::var("DEBUG").is_ok(); - - match interaction { - Interaction::ModalSubmit(submit) => { - submit.defer(&ctx.http.clone()).await.unwrap(); - - if debug { - log_message( - format!( - "Received modal submit interaction from User: {:#?}", - submit.user.name - ) - .as_str(), - MessageTypes::Debug, - ); - } - - let registered_interactions = modal_interactions(); - - // custom_id is in the format: '/' - match registered_interactions.iter().enumerate().find(|(_, i)| { - i.name - == submit - .clone() - .data - .custom_id - .split("/") - .collect::>() - .first() - .unwrap() - .to_string() - }) { - Some((_, interaction)) => { - interaction - .runner - .run(&ArgumentsLevel::provide( - &interaction.arguments, - &ctx, - &submit - .guild_id - .unwrap() - .to_guild_cached(&ctx.cache) - .unwrap(), - &submit.user, - &submit.channel_id, - None, - Some(submit.id), - Some(&submit.data), - )) - .await; - } - - None => { - log_message( - format!( - "Modal submit interaction {} not found", - submit.data.custom_id.split("/").collect::>()[0] - ) - .as_str(), - MessageTypes::Error, - ); - } - }; - } - - Interaction::ApplicationCommand(command) => { - if debug { - log_message( - format!( - "Received command \"{}\" interaction from User: {:#?}", - command.data.name, command.user.name - ) - .as_str(), - MessageTypes::Debug, - ); - } - - // Defer the interaction and edit it later - match command.defer(&ctx.http.clone()).await { - Ok(_) => {} - Err(why) => { - log_message( - format!("Cannot defer slash command: {}", why).as_str(), - MessageTypes::Error, - ); - } - } - - let registered_commands = collect_commands(); - - match registered_commands - .iter() - .enumerate() - .find(|(_, c)| c.name == command.data.name) - { - Some((_, command_interface)) => { - let command_response = command_interface - .runner - .run(&ArgumentsLevel::provide( - &command_interface.arguments, - &ctx, - &command - .guild_id - .unwrap() - .to_guild_cached(&ctx.cache) - .unwrap(), - &command.user, - &command.channel_id, - Some(command.data.options.clone()), - Some(command.id), - None, - )) - .await; - - match command_response { - Ok(command_response) => { - if debug { - log_message( - format!( - "Responding with: {}", - command_response.to_string() - ) - .as_str(), - MessageTypes::Debug, - ); - } - - if CommandResponse::None != command_response { - if let Err(why) = command - .edit_original_interaction_response(&ctx.http, |response| { - match command_response { - CommandResponse::String(string) => { - response.content(string) - } - CommandResponse::Embed(embed) => response - .set_embed( - CommandResponse::Embed(embed).to_embed(), - ), - CommandResponse::Message(message) => { - *response.borrow_mut() = message; - - response - } - CommandResponse::None => response, - } - }) - .await - { - log_message( - format!("Cannot respond to slash command: {}", why) - .as_str(), - MessageTypes::Error, - ); - } - } else { - if debug { - log_message( - format!( - "Deleting slash command: {}", - command.data.name - ) - .as_str(), - MessageTypes::Debug, - ); - } - - if let Err(why) = command - .delete_original_interaction_response(&ctx.http) - .await - { - log_message( - format!("Cannot respond to slash command: {}", why) - .as_str(), - MessageTypes::Error, - ); - } - } - } - Err(why) => { - log_message( - format!("Cannot run slash command: {}", why).as_str(), - MessageTypes::Error, - ); - } - } - } - None => { - log_message( - format!("Command {} not found", command.data.name).as_str(), - MessageTypes::Error, - ); - } - }; - } - - _ => {} - } - - return (); - } -} - -#[tokio::main] -async fn main() { - let token = env::var("DISCORD_TOKEN").expect("Expected a token in the environment"); - - // database check - establish_connection(); - - let intents = GatewayIntents::MESSAGE_CONTENT - | GatewayIntents::DIRECT_MESSAGES - | GatewayIntents::GUILD_WEBHOOKS - | GatewayIntents::GUILD_MESSAGES - | GatewayIntents::GUILDS - | GatewayIntents::GUILD_MEMBERS - | GatewayIntents::GUILD_VOICE_STATES - | GatewayIntents::GUILD_INTEGRATIONS; - - let framework = StandardFramework::new(); - - let mut client = Client::builder(token, intents) - .event_handler(Handler) - .framework(framework) - .register_songbird() - .await - .expect("Error on creating client"); - - tokio::spawn(async move { - let _main_process = client - .start() - .await - .map_err(|why| println!("Client ended: {:?}", why)); - let _clear_process = voice_channel::clear_cache().await; - }); - - tokio::signal::ctrl_c().await.unwrap(); - println!("Received Ctrl-C, shutting down..."); -} diff --git a/src/modules/app/commands/jingle.rs b/src/modules/app/commands/jingle.rs deleted file mode 100644 index 35a0c7e..0000000 --- a/src/modules/app/commands/jingle.rs +++ /dev/null @@ -1,35 +0,0 @@ -use super::Command; - -use serenity::async_trait; -use serenity::builder::CreateCommand; - -struct Jingle; - -#[async_trait] -impl super::RunnerFn for Jingle { - async fn run<'a>( - &self, - _args: &Vec>, - ) -> super::InternalCommandResult<'a> { - Ok(super::CommandResponse::String( - "Tanke o Bostil ou deixe-o".to_string(), - )) - } -} - -pub fn register(command: &mut CreateCommand) -> &mut CreateCommand { - command - .name("jingle") - .description("Tanke o Bostil ou deixe-o") - .into() -} - -pub fn get_command() -> Command { - Command::new( - "jingle", - "Tanke o Bostil ou deixe-o", - super::CommandCategory::Fun, - vec![super::ArgumentsLevel::None], - Box::new(Jingle {}), - ) -} diff --git a/src/modules/app/commands/language.rs b/src/modules/app/commands/language.rs deleted file mode 100644 index 22ed2b5..0000000 --- a/src/modules/app/commands/language.rs +++ /dev/null @@ -1,54 +0,0 @@ -use serenity::builder::CreateCommand; -use serenity::{all::CommandOptionType, async_trait}; - -use super::{ - ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, -}; - -struct Language; - -#[async_trait] -impl RunnerFn for Language { - async fn run<'a>( - &self, - args: &Vec>, - ) -> InternalCommandResult<'a> { - Ok(CommandResponse::String("".to_string())) - } -} - -pub fn register(command: &mut CreateCommand) -> &mut CreateCommand { - command - .name("language") - .name_localized("pt-BR", "idioma") - .description("Language Preferences Menu") - .description_localized("pt-BR", "Menu de preferências de idioma") - .create_option(|option| { - option - .name("choose_language") - .name_localized("pt-BR", "alterar_idioma") - .description("Choose the language of preference") - .description_localized("pt-BR", "Escolha o idioma de preferência") - .kind(CommandOptionType::String) - .add_string_choice_localized( - "Portuguese", - "pt-BR", - [("pt-BR", "Português"), ("en-US", "Portuguese")], - ) - .add_string_choice_localized( - "English", - "en-US", - [("pt-BR", "Inglês"), ("en-US", "English")], - ) - }) -} - -pub fn get_command() -> Command { - Command::new( - "language", - "Language Preferences Menu", - CommandCategory::General, - vec![ArgumentsLevel::Options, ArgumentsLevel::Guild], - Box::new(Language {}), - ) -} diff --git a/src/modules/app/commands/mod.rs b/src/modules/app/commands/mod.rs deleted file mode 100644 index 7155778..0000000 --- a/src/modules/app/commands/mod.rs +++ /dev/null @@ -1,188 +0,0 @@ -use std::any::Any; - -use serenity::{ - async_trait, - builder::{CreateEmbed, EditInteractionResponse}, - framework::standard::CommandResult, - model::prelude::Embed, -}; - -use crate::modules::core::lib::arguments::ArgumentsLevel; - -pub mod jingle; -pub mod language; -pub mod ping; -pub mod poll; -pub mod radio; -pub mod voice; - -#[derive(Debug, Clone, Copy)] -pub enum CommandCategory { - Fun, - Moderation, - Music, - Misc, - Voice, - Admin, - General, -} - -pub struct Command { - pub name: String, - pub description: String, - pub category: CommandCategory, - pub arguments: Vec, - pub runner: Box, -} - -impl Command { - pub fn new( - name: &str, - description: &str, - category: CommandCategory, - arguments: Vec, - runner: Box, - ) -> Self { - let sorted_arguments = { - let mut sorted_arguments = arguments.clone(); - sorted_arguments.sort_by(|a, b| a.value().cmp(&b.value())); - sorted_arguments - }; - - Self { - arguments: sorted_arguments, - category, - runner, - description: description.to_string(), - name: name.to_string(), - } - } -} - -#[derive(Debug, Clone)] -pub enum CommandResponse { - String(String), - Embed(Embed), - Message(EditInteractionResponse), - None, -} - -impl CommandResponse { - pub fn to_embed(&self) -> CreateEmbed { - match self { - CommandResponse::String(string) => { - let mut embed = CreateEmbed::default(); - embed.description(string); - - embed - } - CommandResponse::Embed(command_embed) => { - let mut embed = CreateEmbed::default(); - embed.author(|a| { - a.name(command_embed.author.clone().unwrap().name.clone()) - .icon_url(command_embed.author.clone().unwrap().icon_url.unwrap()) - .url(command_embed.author.clone().unwrap().url.unwrap()) - }); - embed.title(command_embed.title.clone().unwrap()); - embed.description(command_embed.description.clone().unwrap()); - embed.fields( - command_embed - .fields - .clone() - .iter() - .map(|field| (field.name.clone(), field.value.clone(), field.inline)), - ); - embed.colour(command_embed.colour.clone().unwrap()); - embed.footer(|f| { - f.text(command_embed.footer.clone().unwrap().text.clone()) - .icon_url(command_embed.footer.clone().unwrap().icon_url.unwrap()) - }); - - embed - } - _ => CreateEmbed::default(), - } - } - - pub fn to_string(&self) -> String { - match self { - CommandResponse::String(string) => string.clone(), - CommandResponse::Embed(embed) => embed.description.clone().unwrap(), - _ => "".to_string(), - } - } -} - -impl PartialEq for CommandResponse { - fn eq(&self, other: &Self) -> bool { - match self { - CommandResponse::String(string) => match other { - CommandResponse::String(other_string) => string == other_string, - _ => false, - }, - CommandResponse::Embed(embed) => match other { - CommandResponse::Embed(other_embed) => { - Some(embed.title.clone()) == Some(other_embed.title.clone()) - } - _ => false, - }, - _ => match other { - CommandResponse::None => true, - _ => false, - }, - } - } - fn ne(&self, other: &Self) -> bool { - match self { - CommandResponse::String(string) => match other { - CommandResponse::String(other_string) => string != other_string, - _ => true, - }, - CommandResponse::Embed(embed) => match other { - CommandResponse::Embed(other_embed) => { - Some(embed.title.clone()) != Some(other_embed.title.clone()) - } - _ => true, - }, - _ => match other { - CommandResponse::None => false, - _ => true, - }, - } - } -} - -impl std::fmt::Display for CommandResponse { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - CommandResponse::String(string) => write!(f, "{}", string), - CommandResponse::Embed(embed) => write!(f, "{}", embed.description.clone().unwrap()), - CommandResponse::Message(_) => write!(f, "Message"), - _ => write!(f, "None"), - } - } -} - -// command result must be a string or an embed -pub type InternalCommandResult<'a> = CommandResult; - -#[async_trait] -pub trait RunnerFn { - async fn run<'a>( - &self, - arguments: &Vec>, - ) -> InternalCommandResult<'a>; -} - -pub fn collect_commands() -> Vec { - vec![ - self::ping::get_command(), - self::poll::get_command(), - self::language::get_command(), - self::jingle::get_command(), - self::radio::get_command(), - self::voice::join::get_command(), - self::voice::leave::get_command(), - self::voice::mute::get_command(), - ] -} diff --git a/src/modules/app/commands/ping.rs b/src/modules/app/commands/ping.rs deleted file mode 100644 index f078eb2..0000000 --- a/src/modules/app/commands/ping.rs +++ /dev/null @@ -1,43 +0,0 @@ -use super::{ - ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, -}; - -use serenity::{async_trait, builder::CreateCommand}; -use std::any::Any; -use tokio::time::Instant; - -struct Ping; - -#[async_trait] -impl RunnerFn for Ping { - async fn run<'a>(&self, _: &Vec>) -> InternalCommandResult<'a> { - let get_latency = { - let now = Instant::now(); - - let _ = reqwest::get("https://discord.com/api/v8/gateway").await; - now.elapsed().as_millis() as f64 - }; - - Ok(CommandResponse::String(format!( - "Pong! Latency: {}ms", - get_latency - ))) - } -} - -pub fn register(command: &mut CreateCommand) -> &mut CreateCommand { - command - .name("ping") - .description("Check if the bot is alive, and test the latency to the server") - .into() -} - -pub fn get_command() -> Command { - Command::new( - "ping", - "Check if the bot is alive, and test the latency to the server", - CommandCategory::General, - vec![ArgumentsLevel::None], - Box::new(Ping {}), - ) -} diff --git a/src/modules/app/commands/poll/database.rs b/src/modules/app/commands/poll/database.rs deleted file mode 100644 index c308ce2..0000000 --- a/src/modules/app/commands/poll/database.rs +++ /dev/null @@ -1,128 +0,0 @@ -use std::borrow::BorrowMut; - -use super::{Poll, PollDatabaseModel, PollOption, PollStatus, PollType, Vote}; -use crate::{ - database::{get_database, save_database, GuildDatabaseModel}, - internal::debug::{log_message, MessageTypes}, -}; - -use nanoid::nanoid; -use serenity::model::{ - id::MessageId, - prelude::{ChannelId, GuildId, UserId}, -}; -use yaml_rust::Yaml; - -impl PollDatabaseModel { - pub fn from_yaml(yaml: &Yaml) -> PollDatabaseModel { - PollDatabaseModel { - id: nanoid!().into(), - name: yaml["name"].as_str().unwrap().to_string(), - description: match yaml["description"].as_str() { - Some(description) => Some(description.to_string()), - None => None, - }, - kind: match yaml["kind"].as_str().unwrap() { - "single_choice" => PollType::SingleChoice, - "multiple_choice" => PollType::MultipleChoice, - _ => PollType::SingleChoice, - }, - options: yaml["options"] - .as_vec() - .unwrap() - .iter() - .map(|option| PollOption { - value: option["value"].as_str().unwrap().to_string(), - description: match option["description"].as_str() { - Some(description) => Some(description.to_string()), - None => None, - }, - votes: option["votes"] - .as_vec() - .unwrap() - .iter() - .map(|vote| Vote { - user_id: UserId(vote["user_id"].as_i64().unwrap() as u64), - }) - .collect::>(), - }) - .collect::>(), - timer: Some(std::time::Duration::from_secs( - yaml["timer"].as_i64().unwrap() as u64, - )), - embed_message_id: MessageId(yaml["message_id"].as_i64().unwrap() as u64), - thread_id: ChannelId(yaml["thread_id"].as_i64().unwrap().try_into().unwrap()), - created_at: std::time::SystemTime::UNIX_EPOCH - + std::time::Duration::from_secs(yaml["created_at"].as_i64().unwrap() as u64), - status: match yaml["status"].as_str().unwrap() { - "open" => PollStatus::Open, - "closed" => PollStatus::Closed, - "stopped" => PollStatus::Stopped, - _ => PollStatus::Open, - }, - created_by: UserId(yaml["created_by"].as_i64().unwrap() as u64), - } - } - - pub fn save(&self, guild_id: GuildId) { - let database = get_database(); - - match database.lock().unwrap().guilds.get_mut(&guild_id) { - Some(guild) => { - let poll = guild - .polls - .iter_mut() - .find(|poll| poll.id == self.id) - .unwrap(); - - *poll = self.clone(); - - save_database(database.lock().unwrap().borrow_mut()); - - log_message("Poll saved", MessageTypes::Success); - } - None => { - log_message("Guild not found in database", MessageTypes::Failed); - } - } - - if let Some(guild) = database.lock().unwrap().guilds.get_mut(&guild_id) { - guild.polls.push(self.clone()); - } else { - database.lock().unwrap().guilds.insert( - guild_id, - GuildDatabaseModel { - locale: "en-US".to_string(), - polls: vec![self.clone()], - }, - ); - } - - save_database(database.lock().unwrap().borrow_mut()); - } -} - -pub fn save_poll( - guild_id: GuildId, - user_id: &UserId, - thread_id: &ChannelId, - message_id: &MessageId, - poll: &Poll, -) { - let database = get_database(); - let poll_model = PollModel::from(poll, user_id, thread_id, message_id); - - if let Some(guild) = database.lock().unwrap().guilds.get_mut(&guild_id) { - guild.polls.push(poll_model); - } else { - database.lock().unwrap().guilds.insert( - guild_id, - GuildDatabaseModel { - locale: "en-US".to_string(), - polls: vec![poll_model], - }, - ); - } - - save_database(database.lock().unwrap().borrow_mut()); -} diff --git a/src/modules/app/commands/poll/help.rs b/src/modules/app/commands/poll/help.rs deleted file mode 100644 index 3eb1d9d..0000000 --- a/src/modules/app/commands/poll/help.rs +++ /dev/null @@ -1,160 +0,0 @@ -use rust_i18n::t; -use serenity::{ - async_trait, builder::CreateApplicationCommandOption, - model::prelude::command::CommandOptionType, -}; - -use crate::{ - commands::{ - ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, - }, - internal::constants::CommandHelp, -}; - -use super::PollType; - -/** - * Command: help - * - * Return the help message for the poll command - * - Usage: /poll help - */ - -struct PollHelpCommand; - -#[async_trait] -impl RunnerFn for PollHelpCommand { - async fn run<'a>( - &self, - _: &Vec>, - ) -> InternalCommandResult<'a> { - let mut help_message: String = "```".to_string(); - - for helper in collect_command_help() { - help_message.push_str(&format!("/poll {} {}\n", helper.name, helper.description)); - - for option in helper.options { - help_message.push_str(&format!(" {}\n", option)); - } - - help_message.push_str("\n"); - } - - help_message.push_str("```"); - - Ok(CommandResponse::String(help_message)) - } -} - -fn create_help() -> CommandHelp { - CommandHelp { - name: "poll".to_string(), - description: "Create a poll".to_string(), - options: vec![ - "name: The name of the poll".to_string(), - "description: The description of the poll".to_string(), - format!( - "type: The type of the poll ({} or {})", - PollType::SingleChoice.to_label(), - PollType::MultipleChoice.to_label() - ), - "options: It is a voting option".to_string(), - ], - } -} - -fn setup_help() -> CommandHelp { - CommandHelp { - name: "setup".to_string(), - description: "Setup the poll".to_string(), - options: vec![ - format!( - "type: The type of the poll - {}: {} - {}: {} - ", - PollType::SingleChoice.to_string(), - PollType::SingleChoice.to_label(), - PollType::MultipleChoice.to_string(), - PollType::MultipleChoice.to_label(), - ), - "channel: The channel of the poll - \"current\": The current channel - \"\": The channel id - " - .to_string(), - "timer: Optional, the timer of the poll".to_string(), - ], - } -} - -fn management_help() -> CommandHelp { - CommandHelp { - name: "management".to_string(), - description: "Manage the poll".to_string(), - options: vec![ - "status: The status of the poll - \"open\": Open the poll - \"close\": Close the poll - \"stop\": Stop the poll - " - .to_string(), - "info: The info of the poll - \"name\": The name of the poll - \"description\": The description of the poll - \"type\": The type of the poll - \"options\": The options of the poll - \"timer\": The timer of the poll - \"status\": The status of the poll - \"votes\": The votes of the poll (only available for closed polls) - \"created_at\": The created at of the poll - \"created_by\": The created by of the poll - " - .to_string(), - ], - } -} - -fn collect_command_help() -> Vec { - vec![create_help(), setup_help(), management_help()] -} - -pub fn register_option<'a>() -> CreateApplicationCommandOption { - let mut command_option = CreateApplicationCommandOption::default(); - - command_option - .name("help") - .name_localized("pt-BR", "ajuda") - .description("Show the help message for poll commands") - .description_localized( - "pt-BR", - "Mostra a mensagem de ajuda para os comandos de votação", - ) - .kind(CommandOptionType::SubCommand) - .create_sub_option(|sub_option| { - sub_option - .name("poll_command") - .name_localized("pt-BR", "comando_de_votação") - .description("The command to show the help message for poll commands") - .description_localized( - "pt-BR", - "O comando para mostrar a mensagem de ajuda para os comandos de votação", - ) - .kind(CommandOptionType::String) - .required(true) - .add_string_choice(t!("commands.poll.setup.label"), "setup_command") - .add_string_choice(t!("commands.poll.management.label"), "management_command") - }); - - command_option -} - -pub fn get_command() -> Command { - Command::new( - "help", - "Show the help message for poll commands", - CommandCategory::Misc, - vec![ArgumentsLevel::None], - Box::new(PollHelpCommand {}), - ) -} diff --git a/src/modules/app/commands/poll/management/mod.rs b/src/modules/app/commands/poll/management/mod.rs deleted file mode 100644 index 8b13789..0000000 --- a/src/modules/app/commands/poll/management/mod.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/modules/app/commands/poll/mod.rs b/src/modules/app/commands/poll/mod.rs deleted file mode 100644 index 477d901..0000000 --- a/src/modules/app/commands/poll/mod.rs +++ /dev/null @@ -1,345 +0,0 @@ -use crate::database::get_database; - -use super::{ - ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, -}; - -use core::fmt; -use regex::Regex; -use rust_i18n::t; -use serenity::{ - async_trait, - model::{ - id::{GuildId, MessageId}, - prelude::{ - application_command::{CommandDataOption, CommandDataOptionValue}, - ChannelId, UserId, - }, - }, - utils::Colour, -}; -use std::time::{Duration, SystemTime}; - -mod database; -pub mod help; -pub mod management; -pub mod setup; -mod utils; - -struct PollCommand; - -#[derive(Debug, Clone)] -pub struct Vote { - pub user_id: UserId, -} - -#[derive(Debug, Clone, Copy)] -pub enum PollType { - SingleChoice, - MultipleChoice, -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum PollStatus { - Open, - Closed, - Stopped, - Ready, - NotReady, -} - -#[derive(Debug, Clone, Copy)] -pub enum PollStage { - Setup, - Voting, - Closed, -} - -#[derive(Debug, Clone)] -pub struct PollOption { - pub value: String, - pub description: Option, - pub votes: Vec, -} - -#[derive(Debug)] -pub struct Poll { - id: String, - name: String, - description: Option, - kind: PollType, - options: Vec, - timer: Duration, - status: PollStatus, -} - -#[derive(Clone)] -pub struct PartialPoll { - id: Option, - name: String, - description: Option, - kind: Option, - options: Option>, - timer: Duration, - status: PollStatus, - created_by: UserId, -} - -#[derive(Debug, Clone)] -pub struct PollDatabaseModel { - pub id: String, - pub name: String, - pub description: Option, - pub kind: Option, - pub status: Option, - pub timer: Option, - pub options: Option>, - pub thread_id: Option, - pub embed_message_id: Option, - pub poll_message_id: Option, - pub created_at: SystemTime, - pub created_by: UserId, -} - -impl PollStage { - pub fn embed_color(&self) -> Colour { - match self { - PollStage::Setup => Colour::ORANGE, - PollStage::Voting => Colour::RED, - PollStage::Closed => Colour::DARK_GREEN, - } - } - - pub fn to_status(&self) -> PollStatus { - match self { - PollStage::Setup => PollStatus::NotReady, - PollStage::Voting => PollStatus::Open, - PollStage::Closed => PollStatus::Closed, - } - } -} - -impl PollType { - pub fn to_int(&self) -> i32 { - match self { - PollType::SingleChoice => 1, - PollType::MultipleChoice => 2, - } - } -} - -impl PartialPoll { - pub fn new( - name: &str, - description: Option, - kind: Option, - options: Option>, - // Receives a minute value as a string (e.g. "0.5" for 30 seconds, "1" for 1 minute, "2" for 2 minutes, etc.) - timer: Option, - status: Option, - created_by: UserId, - ) -> PartialPoll { - PartialPoll { - id: nanoid::nanoid!().into(), - name: name.to_string(), - description, - kind, - options, - timer: match timer { - Some(timer) => { - let timer = timer.parse::().unwrap_or(0.0); - Duration::from_secs_f64(timer * 60.0) - } - None => Duration::from_secs(60), - }, - status: status.unwrap_or(PollStatus::NotReady), - created_by, - } - } -} - -impl PollDatabaseModel { - pub fn from_id(id: String) -> PollDatabaseModel { - let database_manager = get_database(); - let database = database_manager.lock().unwrap(); - - let poll = database - .guilds - .iter() - .find_map(|(_, guild)| guild.polls.iter().find(|poll| poll.id == id)); - - poll.unwrap().clone() - } -} - -impl Poll { - pub fn new( - name: String, - description: Option, - kind: PollType, - options: Vec, - // Receives a minute value as a string (e.g. "0.5" for 30 seconds, "1" for 1 minute, "2" for 2 minutes, etc.) - timer: Option, - status: Option, - ) -> Poll { - Poll { - name, - description, - kind, - options, - id: nanoid::nanoid!(), - status: status.unwrap_or(PollStatus::Open), - timer: match timer { - Some(timer) => { - let timer = timer.parse::().unwrap_or(0.0); - Duration::from_secs_f64(timer * 60.0) - } - None => Duration::from_secs(60), - }, - } - } - - pub fn from_id(id: String) -> PollDatabaseModel { - PollDatabaseModel::from_id(id) - } -} - -impl PollType { - pub fn to_string(&self) -> String { - match self { - PollType::SingleChoice => "single_choice".to_string(), - PollType::MultipleChoice => "multiple_choice".to_string(), - } - } - - pub fn to_label(&self) -> String { - match self { - PollType::SingleChoice => t!("commands.poll.types.single_choice.label"), - PollType::MultipleChoice => t!("commands.poll.types.single_choice.label"), - } - } -} - -impl PollStatus { - pub fn to_string(&self) -> String { - match self { - PollStatus::Open => "Open".to_string(), - PollStatus::Closed => "Closed".to_string(), - PollStatus::Stopped => "Stopped".to_string(), - PollStatus::NotReady => "Not Ready".to_string(), - PollStatus::Ready => "Ready".to_string(), - } - } -} - -impl fmt::Display for PollOption { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.value) - } -} - -// fn poll_serializer(command_options: &Vec) -> Poll { -// let option_regex: Regex = Regex::new(r"^option_\d+$").unwrap(); -// let kind = match command_options.iter().find(|option| option.name == "type") { -// Some(option) => match option.resolved.as_ref().unwrap() { -// CommandDataOptionValue::String(value) => match value.as_str() { -// "single_choice" => PollType::SingleChoice, -// "multiple_choice" => PollType::MultipleChoice, -// _ => PollType::SingleChoice, -// }, -// _ => PollType::SingleChoice, -// }, -// None => PollType::SingleChoice, -// }; - -// Poll::new( -// command_options -// .iter() -// .find(|option| option.name == "name") -// .unwrap() -// .value -// .as_ref() -// .unwrap() -// .to_string(), -// Some( -// command_options -// .iter() -// .find(|option| option.name == "description") -// .unwrap() -// .value -// .as_ref() -// .unwrap() -// .to_string(), -// ), -// kind, -// command_options -// .iter() -// .filter(|option| option_regex.is_match(&option.name)) -// .map(|option| PollOption { -// value: option.value.as_ref().unwrap().to_string(), -// description: None, -// votes: vec![], -// }) -// .collect::>(), -// Some( -// command_options -// .iter() -// .find(|option| option.name == "timer") -// .unwrap() -// .value -// .as_ref() -// .unwrap() -// .to_string(), -// ), -// Some(PollStatus::Open), -// ) -// } - -#[async_trait] -impl RunnerFn for PollCommand { - async fn run<'a>( - &self, - args: &Vec>, - ) -> InternalCommandResult<'a> { - let options = args - .iter() - .filter_map(|arg| arg.downcast_ref::>>()) - .collect::>>>()[0] - .as_ref() - .unwrap(); - let first_option = options.get(0).unwrap(); - let command_name = first_option.name.clone(); - - let command_runner = command_suite(command_name); - - let response = command_runner.run(args); - - response.await - } -} - -fn command_suite(command_name: String) -> Box { - let command_runner = match command_name.as_str() { - "help" => self::help::get_command().runner, - "setup" => self::setup::create::get_command().runner, - _ => get_command().runner, - }; - - command_runner -} - -pub fn get_command() -> Command { - Command::new( - "poll", - "Poll commands", - CommandCategory::Misc, - vec![ - ArgumentsLevel::Options, - ArgumentsLevel::Context, - ArgumentsLevel::Guild, - ArgumentsLevel::User, - ArgumentsLevel::ChannelId, - ], - Box::new(PollCommand), - ) -} diff --git a/src/modules/app/commands/poll/setup/create.rs b/src/modules/app/commands/poll/setup/create.rs deleted file mode 100644 index b86437e..0000000 --- a/src/modules/app/commands/poll/setup/create.rs +++ /dev/null @@ -1,329 +0,0 @@ -use std::{os::fd::IntoRawFd, sync::Arc, time::Duration}; - -use super::embeds::setup::get_embed; -use crate::{ - commands::{ - poll::{PartialPoll, Poll, PollStage, PollStatus, PollType}, - ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, - }, - components, - internal::debug::{log_message, MessageTypes}, -}; - -use rust_i18n::t; -use serenity::{ - async_trait, - builder::CreateApplicationCommandOption, - collector::ComponentInteractionCollector, - futures::StreamExt, - model::{ - application::interaction::message_component::MessageComponentInteraction, - prelude::{ - application_command::CommandDataOption, - command::CommandOptionType, - component::{ButtonStyle, InputTextStyle}, - modal::ModalSubmitInteraction, - ChannelId, InteractionResponseType, - }, - user::User, - }, - prelude::Context, -}; - -struct CreatePollRunner; - -#[async_trait] -impl RunnerFn for CreatePollRunner { - async fn run<'a>( - &self, - args: &Vec>, - ) -> InternalCommandResult<'a> { - let options = args - .iter() - .filter_map(|arg| arg.downcast_ref::>>()) - .collect::>>>()[0] - .as_ref() - .unwrap(); - let subcommand_options = &options[0].options; - - let poll_name = match subcommand_options - .iter() - .find(|option| option.name == "name") - { - Some(option) => option.value.as_ref().unwrap().as_str().unwrap(), - None => { - panic!("Poll name is required") - } - }; - let poll_description = match subcommand_options - .iter() - .find(|option| option.name == "description") - { - Some(option) => Some(option.value.as_ref().unwrap().to_string()), - None => None, - }; - let ctx = args - .iter() - .find_map(|arg| arg.downcast_ref::()) - .unwrap(); - let channel_id = args - .iter() - .find_map(|arg| arg.downcast_ref::()) - .unwrap(); - let user_id = args - .iter() - .filter_map(|arg| arg.downcast_ref::()) - .collect::>() - .get(0) - .unwrap() - .id; - - // Step 1: Create thread - let thread_channel = channel_id - .create_private_thread(ctx.http.clone(), |thread| thread.name(poll_name)) - .await?; - - thread_channel - .id - .add_thread_member(ctx.http.clone(), user_id) - .await?; - - let mut setup_embed = get_embed(); - - // Step 2: Create a partial poll and send it to the thread - setup_embed.arguments = vec![ - Box::new(PartialPoll::new( - poll_name, - poll_description, - None, - Some(vec![]), - None, - Some(PollStatus::NotReady), - user_id, - )), - Box::new(PollStage::Setup), - ]; - - setup_embed.send_message(thread_channel.clone(), ctx).await; - - // Step 3: Add buttons to the message to choose between add options, starting poll and cancel - setup_embed - .message - .clone() - .unwrap() - .edit(&ctx.http, |message| { - message.components(|components| { - // Action row for buttons - components.create_action_row(|action_row| { - action_row - .create_button(|button| { - button - .custom_id("add_option") - .label("Add option") - .style(ButtonStyle::Secondary) - }) - .create_button(|button| { - button - .custom_id("start_poll") - .label("Start poll") - .disabled(true) - .style(ButtonStyle::Primary) - }) - .create_button(|button| { - button - .custom_id("cancel") - .label("Cancel") - .style(ButtonStyle::Danger) - }) - }); - - components.create_action_row(|action_row| { - // Select menu for poll type - action_row.create_select_menu(|select_menu| { - select_menu - .custom_id("poll_type") - .placeholder("Escolha o tipo da votação") - .options(|options| { - options - .create_option(|option| { - option - .label("Single choice") - .value(PollType::SingleChoice.to_int().to_string()) - .description("Single choice poll") - }) - .create_option(|option| { - option - .label("Multiple choice") - .value( - PollType::MultipleChoice.to_int().to_string(), - ) - .description("Multiple choice poll") - }) - }) - }) - }) - }) - }) - .await?; - - // Step 5: Add interaction listener - let interaction_stream = setup_embed - .message - .clone() - .unwrap() - .await_component_interactions(&ctx) - .timeout(Duration::from_secs(60 * 60 * 24)) // 1 Day to configure the poll - .build(); - - interaction_handler(interaction_stream, ctx).await; - - Ok(CommandResponse::String(t!( - "commands.poll.setup.response.initial", - "thread_id" => thread_channel.id, - ))) - } -} - -async fn interaction_handler(mut interaction_stream: ComponentInteractionCollector, ctx: &Context) { - match interaction_stream.next().await { - Some(interaction) => { - let interaction_id = interaction.data.custom_id.as_str(); - - match interaction_id { - "add_option" => add_option(interaction, ctx).await, - _ => {} - } - } - - None => { - log_message("No interaction received in 1 day", MessageTypes::Failed); - } - } -} - -async fn add_option(interaction: Arc, ctx: &Context) { - match interaction - .create_interaction_response(&ctx.http, |response| { - response - .kind(InteractionResponseType::Modal) - .interaction_response_data(|message| { - message - .custom_id(format!("option_data_poll/{}", interaction.id)) - .title("Adicionar opção") - .components(|components| { - components - .create_action_row(|action_row| { - action_row.create_input_text(|field| { - field - .custom_id(format!("name_option/{}", interaction.id)) - .label("Nome da opção") - .placeholder("Digite o nome da opção") - .max_length(25) - .min_length(1) - .required(true) - .style(InputTextStyle::Short) - }) - }) - .create_action_row(|action_row| { - action_row.create_input_text(|field| { - field - .custom_id(format!( - "description_option/{}", - interaction.id - )) - .label("Descrição da opção") - .placeholder("Digite a descrição da opção") - .max_length(200) - .min_length(1) - .style(InputTextStyle::Paragraph) - }) - }) - }) - }) - }) - .await - { - Ok(_) => {} - Err(why) => { - log_message( - &format!("Failed to create interaction response: {}", why), - MessageTypes::Error, - ); - } - } -} - -// pub async fn handle_modal(ctx: &Context, command: &ModalSubmitInteraction) { -// if let Err(why) = command -// .create_interaction_response(&ctx.http, |m| { -// m.kind(InteractionResponseType::DeferredUpdateMessage) -// }) -// .await -// { -// log_message( -// &format!("Failed to create interaction response: {}", why), -// MessageTypes::Error, -// ); -// } -// } - -pub fn register_option<'a>() -> CreateApplicationCommandOption { - let mut command_option = CreateApplicationCommandOption::default(); - - command_option - .name("setup") - .name_localized("pt-BR", "configurar") - .description("Setup a poll") - .description_localized("pt-BR", "Configura uma votação") - .kind(CommandOptionType::SubCommand) - .create_sub_option(|sub_option| { - sub_option - .name("name") - .name_localized("pt-BR", "nome") - .description("The name of the option (max 25 characters)") - .description_localized("pt-BR", "O nome da opção (máx 25 caracteres)") - .kind(CommandOptionType::String) - .max_length(25) - .required(true) - }) - .create_sub_option(|sub_option| { - sub_option - .name("channel") - .name_localized("pt-BR", "canal") - .description("The channel where the poll will be created") - .description_localized("pt-BR", "O canal onde a votação será realizada") - .kind(CommandOptionType::Channel) - .required(true) - }) - .create_sub_option(|sub_option| { - sub_option - .name("description") - .name_localized("pt-BR", "descrição") - .description("The description of the option (max 365 characters)") - .description_localized( - "pt-BR", - "A descrição dessa opção (máximo de 365 caracteres)", - ) - .kind(CommandOptionType::String) - .max_length(365) - }); - - command_option -} - -pub fn get_command() -> Command { - Command::new( - "setup", - "Setup a poll", - CommandCategory::Misc, - vec![ - ArgumentsLevel::Options, - ArgumentsLevel::Context, - ArgumentsLevel::Guild, - ArgumentsLevel::User, - ArgumentsLevel::ChannelId, - ArgumentsLevel::InteractionId, - ], - Box::new(CreatePollRunner {}), - ) -} diff --git a/src/modules/app/commands/poll/setup/embeds/mod.rs b/src/modules/app/commands/poll/setup/embeds/mod.rs deleted file mode 100644 index 175c00c..0000000 --- a/src/modules/app/commands/poll/setup/embeds/mod.rs +++ /dev/null @@ -1,45 +0,0 @@ -use serenity::builder::CreateEmbed; -use serenity::model::prelude::{ChannelId, GuildId, MessageId}; -use serenity::prelude::Context; - -use crate::commands::poll::PollDatabaseModel; - -pub mod setup; -pub mod vote; - -/** - * EmbedPoll is a struct that represents the embed message that is sent to the channel - * - * builder is a function that takes a PollDatabaseModel sends a message to the channel - */ -struct EmbedPoll { - name: String, - message_id: Option, - channel_id: ChannelId, - guild_id: GuildId, - builder: Box CreateEmbed + Send + Sync>, -} - -impl EmbedPoll { - pub async fn update_message(&self, poll: PollDatabaseModel, ctx: Context) -> () { - let mut message = self - .channel_id - .message(&ctx.http, self.message_id.unwrap()) - .await - .unwrap(); - - let embed = self.builder.as_ref()(poll); - - message - .edit(&ctx.http, |m| m.set_embed(embed)) - .await - .unwrap(); - } - - pub async fn remove_message(&self, ctx: Context) { - self.channel_id - .delete_message(&ctx.http, self.message_id.unwrap()) - .await - .unwrap(); - } -} diff --git a/src/modules/app/commands/poll/setup/embeds/setup.rs b/src/modules/app/commands/poll/setup/embeds/setup.rs deleted file mode 100644 index 0858a19..0000000 --- a/src/modules/app/commands/poll/setup/embeds/setup.rs +++ /dev/null @@ -1,63 +0,0 @@ -use rust_i18n::t; -use serenity::{builder::CreateEmbed, framework::standard::CommandResult}; - -use crate::{ - commands::poll::{PartialPoll, PollStage}, - internal::embeds::{ApplicationEmbed, EmbedRunnerFn}, -}; -struct PollSetupEmbed; - -impl EmbedRunnerFn for PollSetupEmbed { - fn run(&self, arguments: &Vec>) -> CreateEmbed { - let poll = arguments[0].downcast_ref::().unwrap(); - let stage = arguments[1].downcast_ref::().unwrap(); - - runner(poll.clone(), stage.clone()).unwrap() - } -} - -fn runner(poll: PartialPoll, stage: PollStage) -> CommandResult { - let mut embed = CreateEmbed::default(); - - embed.color(stage.embed_color()); - - match stage { - PollStage::Setup => { - embed.title(t!("commands.poll.setup.embed.stages.setup.title")); - embed.description(t!("commands.poll.setup.embed.stages.setup.description")); - - embed.field( - "ID", - poll.id - .map_or(t!("commands.poll.setup.embed.id_none"), |id| id.to_string()), - true, - ); - embed.field("User", format!("<@{}>", poll.created_by), true); - embed.field("\u{200B}", "\u{200B}", false); // Separator - } - PollStage::Voting => { - embed.title(t!("commands.poll.setup.embed.stages.voting.title")); - embed.description(t!("commands.poll.setup.stages.voting.description")); - } - PollStage::Closed => { - embed.title(t!("commands.poll.setup.embed.stages.closed.title")); - embed.description(t!("commands.poll.setup.stages.closed.description")); - } - } - - Ok(embed) -} - -pub fn get_embed() -> ApplicationEmbed { - ApplicationEmbed { - name: "Poll Setup".to_string(), - description: Some("Embed to configure poll".to_string()), - builder: Box::new(PollSetupEmbed), - arguments: vec![ - Box::new(None::>), - Box::new(None::>), - ], - message_content: None, - message: None, - } -} diff --git a/src/modules/app/commands/poll/setup/embeds/vote.rs b/src/modules/app/commands/poll/setup/embeds/vote.rs deleted file mode 100644 index ff29502..0000000 --- a/src/modules/app/commands/poll/setup/embeds/vote.rs +++ /dev/null @@ -1,83 +0,0 @@ -use serenity::{ - builder::{CreateEmbed, EditInteractionResponse}, - framework::standard::CommandResult, - model::prelude::component::ButtonStyle, -}; - -use crate::{ - commands::poll::{utils::progress_bar, PollDatabaseModel as Poll}, - components::button::Button, -}; - -pub fn embed( - mut message_builder: EditInteractionResponse, - poll: Poll, -) -> CommandResult { - let time_remaining = match poll.timer.unwrap().as_secs() / 60 > 1 { - true => format!("{} minutes", poll.timer.unwrap().as_secs() / 60), - false => format!("{} seconds", poll.timer.unwrap().as_secs()), - }; - let mut embed = CreateEmbed::default(); - embed - .title(poll.name) - .description(poll.description.unwrap_or("".to_string())); - - // first row (id, status, user) - embed.field( - "ID", - format!("`{}`", poll.id.to_string().split_at(8).0), - true, - ); - embed.field("Status", poll.status.to_string(), true); - embed.field("User", format!("<@{}>", poll.created_by), true); - - // separator - embed.field("\u{200B}", "\u{200B}", false); - - poll.options.iter().for_each(|option| { - embed.field( - option.value.clone(), - format!("{} votes", option.votes.len()), - true, - ); - }); - - // separator - embed.field("\u{200B}", "\u{200B}", false); - - embed.field( - "Partial Results (Live)", - format!("```diff\n{}\n```", progress_bar(poll.options.clone())), - false, - ); - - // separator - embed.field("\u{200B}", "\u{200B}", false); - - embed.field( - "Time remaining", - format!("{} remaining", time_remaining), - false, - ); - - message_builder.set_embed(embed); - message_builder.components(|component| { - component.create_action_row(|action_row| { - poll.options.iter().for_each(|option| { - action_row.add_button( - Button::new( - option.value.as_str(), - option.value.as_str(), - ButtonStyle::Primary, - None, - ) - .create(), - ); - }); - - action_row - }) - }); - - Ok(message_builder) -} diff --git a/src/modules/app/commands/poll/setup/mod.rs b/src/modules/app/commands/poll/setup/mod.rs deleted file mode 100644 index 127ef7f..0000000 --- a/src/modules/app/commands/poll/setup/mod.rs +++ /dev/null @@ -1,24 +0,0 @@ -use serenity::builder::CreateApplicationCommand; - -pub mod create; -pub mod options; -pub mod embeds; - -/** - * commands: - * - poll setup (name, description, type, timer) - * ~ Setup creates a thread to add options with the poll (status: stopped) - * - poll options (name, description) - * ~ Options adds a new option to the poll (status: stopped) - * - poll status set (status: open, close, stop) - */ -pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand { - command - .name("poll") - .name_localized("pt-BR", "urna") - .description("Create, edit or remove a poll") - .description_localized("pt-BR", "Cria, edita ou remove uma votação") - .add_option(self::options::register_option()) - .add_option(self::create::register_option()) - .add_option(super::help::register_option()) -} diff --git a/src/modules/app/commands/poll/setup/options.rs b/src/modules/app/commands/poll/setup/options.rs deleted file mode 100644 index 748b61b..0000000 --- a/src/modules/app/commands/poll/setup/options.rs +++ /dev/null @@ -1,70 +0,0 @@ -use crate::commands::{ - ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, -}; - -use serenity::{ - async_trait, builder::CreateApplicationCommandOption, - model::prelude::command::CommandOptionType, -}; - -struct OptionsPollRunner; - -#[async_trait] -impl RunnerFn for OptionsPollRunner { - async fn run<'a>( - &self, - _: &Vec>, - ) -> InternalCommandResult<'a> { - Ok(CommandResponse::None) - } -} - -pub fn register_option<'a>() -> CreateApplicationCommandOption { - let mut command_option = CreateApplicationCommandOption::default(); - - command_option - .name("options") - .name_localized("pt-BR", "opções") - .description("Add options to the poll") - .description_localized("pt-BR", "Adiciona opções à votação") - .kind(CommandOptionType::SubCommand) - .create_sub_option(|sub_option| { - sub_option - .name("poll_id") - .name_localized("pt-BR", "id_da_votação") - .description("The poll id") - .description_localized("pt-BR", "O id da votação") - .kind(CommandOptionType::String) - .required(true) - }) - .create_sub_option(|sub_option| { - sub_option - .name("option_name") - .name_localized("pt-BR", "nome_da_opção") - .description("The name of the option (max 25 characters)") - .description_localized("pt-BR", "O nome da opção (máx 25 caracteres)") - .kind(CommandOptionType::String) - .required(true) - }) - .create_sub_option(|sub_option| { - sub_option - .name("option_description") - .name_localized("pt-BR", "descrição_da_opção") - .description("The description of the option (max 100 characters)") - .description_localized("pt-BR", "A descrição da votação") - .kind(CommandOptionType::String) - .required(true) - }); - - command_option -} - -pub fn get_command() -> Command { - Command::new( - "options", - "Add options to the poll", - CommandCategory::Misc, - vec![ArgumentsLevel::User], - Box::new(OptionsPollRunner), - ) -} diff --git a/src/modules/app/commands/radio/consumer.rs b/src/modules/app/commands/radio/consumer.rs deleted file mode 100644 index 9cc1971..0000000 --- a/src/modules/app/commands/radio/consumer.rs +++ /dev/null @@ -1,34 +0,0 @@ -use super::{equalizers::RADIO_EQUALIZER, Radio}; - -use songbird::input::Input; - -pub async fn consumer(radio: Radio) -> Result { - let url = radio.get_url().unwrap(); - let input = ffmpeg_optioned( - &url, - &[], - RADIO_EQUALIZER - .get_filter() - .iter() - .map(|s| s.as_str()) - .collect::>() - .as_slice(), - ) - .await; - - match input { - Ok(input) => { - log_message( - format!( - "Playing radio: {}\n\tWith equalizer: {}", - radio, RADIO_EQUALIZER.name - ) - .as_str(), - MessageTypes::Info, - ); - - Ok(input) - } - Err(why) => Err(why.to_string()), - } -} diff --git a/src/modules/app/commands/radio/mod.rs b/src/modules/app/commands/radio/mod.rs deleted file mode 100644 index 3680c94..0000000 --- a/src/modules/app/commands/radio/mod.rs +++ /dev/null @@ -1,237 +0,0 @@ -pub mod consumer; -pub mod equalizers; - -use rust_i18n::t; - -use serenity::{ - all::{CommandDataOption, CommandDataOptionValue, CommandOptionType, Guild, User, UserId}, - async_trait, - builder::CreateCommand, - framework::standard::CommandResult, - prelude::Context, -}; - -use crate::modules::core::{ - actions::voice::join, - lib::debug::{log_message, MessageTypes}, -}; - -use super::{ - ArgumentsLevel, Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn, -}; - -struct RadioCommand; - -#[derive(Debug, Clone, Copy)] -pub enum Radio { - CanoaGrandeFM, - TupiFM, - EightyNineFM, - EightyEightFM, - NinetyFourFm, - PingoNosIFs, -} - -impl Radio { - pub fn get_url(&self) -> Option { - match self { - Radio::CanoaGrandeFM => { - Some("https://servidor39-4.brlogic.com:8300/live?source=website".to_string()) - } - Radio::TupiFM => Some("https://ice.fabricahost.com.br/topfmbauru".to_string()), - Radio::EightyNineFM => Some("https://r13.ciclano.io:15223/stream".to_string()), - Radio::EightyEightFM => Some("http://cast.hoost.com.br:8803/live.m3u".to_string()), - Radio::NinetyFourFm => { - Some("https://cast2.hoost.com.br:28456/stream?1691035067242".to_string()) - } - Radio::PingoNosIFs => None, - } - } - pub fn to_string(&self) -> String { - match self { - Radio::CanoaGrandeFM => "Canoa Grande FM".to_string(), - Radio::PingoNosIFs => "Pingo nos IFs".to_string(), - Radio::TupiFM => "Tupi FM".to_string(), - Radio::EightyNineFM => "89 FM".to_string(), - Radio::EightyEightFM => "88.3 FM".to_string(), - Radio::NinetyFourFm => "94 FM".to_string(), - } - } -} - -impl std::fmt::Display for Radio { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Radio::CanoaGrandeFM => write!(f, "Canoa Grande FM"), - Radio::PingoNosIFs => write!(f, "Pingo nos IFs"), - Radio::TupiFM => write!(f, "Tupi FM"), - Radio::EightyNineFM => write!(f, "89 FM"), - Radio::EightyEightFM => write!(f, "88.3 FM"), - Radio::NinetyFourFm => write!(f, "94 FM"), - } - } -} - -#[async_trait] -impl RunnerFn for RadioCommand { - async fn run<'a>( - &self, - args: &Vec>, - ) -> InternalCommandResult<'a> { - let ctx = args - .iter() - .filter_map(|arg| arg.downcast_ref::()) - .collect::>()[0]; - let guild = args - .iter() - .filter_map(|arg| arg.downcast_ref::()) - .collect::>()[0]; - let user_id = &args - .iter() - .filter_map(|arg| arg.downcast_ref::()) - .collect::>()[0] - .id; - let options = args - .iter() - .filter_map(|arg| arg.downcast_ref::>>()) - .collect::>>>()[0] - .as_ref() - .unwrap(); - - match run(options, ctx, guild, user_id).await { - Ok(response) => Ok(CommandResponse::String(response)), - Err(_) => Ok(CommandResponse::None), - } - } -} - -pub async fn run( - options: &Vec, - ctx: &Context, - guild: &Guild, - user_id: &UserId, -) -> CommandResult { - let debug = std::env::var("DEBUG").is_ok(); - - let radio = match options[0].resolved.as_ref().unwrap() { - CommandDataOptionValue::String(radio) => match radio.as_str() { - "Canoa Grande FM" => Radio::CanoaGrandeFM, - "Pingo nos IFs" => Radio::PingoNosIFs, - "Tupi FM" => Radio::TupiFM, - "89 FM" => Radio::EightyNineFM, - "88.3 FM" => Radio::EightyEightFM, - "94 FM" => Radio::NinetyFourFm, - _ => { - return Ok(t!("commands.radio.radio_not_found")); - } - }, - _ => { - return Ok(t!("commands.radio.radio_not_found")); - } - }; - - let manager = songbird::get(ctx) - .await - .expect("Songbird Voice client placed in at initialisation.") - .clone(); - - if debug { - log_message( - format!("Radio: {}", radio.to_string()).as_str(), - MessageTypes::Debug, - ); - } - - join(ctx, guild, user_id).await?; - - if debug { - log_message("Joined voice channel successfully", MessageTypes::Debug); - } - - if let Some(handler_lock) = manager.get(guild.id) { - let mut handler = handler_lock.lock().await; - - let source = match consumer::consumer(radio).await { - Ok(source) => source, - Err(why) => { - log_message( - format!("Error while getting source: {}", why).as_str(), - MessageTypes::Error, - ); - - return Ok(t!("commands.radio.connection_error")); - } - }; - - handler.play_source(source); - } else { - if debug { - log_message("User not connected to a voice channel", MessageTypes::Debug); - } - - return Ok(t!("commands.radio.user_not_connected")); - } - - Ok(t!("commands.radio.reply", "radio_name" => radio.to_string())) -} - -pub fn register(command: &mut CreateCommand) -> &mut CreateCommand { - command - .name("radio") - .description("Tune in to the best radios in Bostil") - .description_localized("pt-BR", "Sintonize a as melhores rádios do Bostil") - .create_option(|option| { - option - .name("radio") - .description("The radio to tune in") - .description_localized("pt-BR", "A rádio para sintonizar") - .kind(CommandOptionType::String) - .required(true) - .add_string_choice_localized( - "Canoa Grande FM", - Radio::CanoaGrandeFM, - [("pt-BR", "Canoa Grande FM"), ("en-US", "Big Boat FM")], - ) - .add_string_choice_localized( - "Pingo nos IFs", - Radio::PingoNosIFs, - [("pt-BR", "Pingo nos IFs"), ("en-US", "Ping in the IFs")], - ) - .add_string_choice_localized( - "Tupi FM", - Radio::TupiFM, - [("pt-BR", "Tupi FM"), ("en-US", "Tupi FM")], - ) - .add_string_choice_localized( - "88.3 FM", - Radio::EightyEightFM, - [("pt-BR", "88.3 FM"), ("en-US", "88.3 FM")], - ) - .add_string_choice_localized( - "89 FM", - Radio::EightyNineFM, - [("pt-BR", "89 FM"), ("en-US", "89 FM")], - ) - .add_string_choice_localized( - "94 FM", - Radio::NinetyFourFm, - [("pt-BR", "94 FM"), ("en-US", "94 FM")], - ) - }) - .into() -} - -pub fn get_command() -> Command { - Command::new( - "radio", - "Tune in to the best radios in \"Bostil\"", - CommandCategory::Voice, - vec![ - ArgumentsLevel::Options, - ArgumentsLevel::Context, - ArgumentsLevel::Guild, - ArgumentsLevel::User, - ], - Box::new(RadioCommand {}), - ) -} diff --git a/src/modules/app/commands/voice/join.rs b/src/modules/app/commands/voice/join.rs deleted file mode 100644 index 7186f34..0000000 --- a/src/modules/app/commands/voice/join.rs +++ /dev/null @@ -1,62 +0,0 @@ -use serenity::{ - async_trait, - builder::CreateCommand, - model::prelude::{Guild, UserId}, - prelude::Context, -}; - -use crate::modules::{ - app::commands::{Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn}, - core::{actions::voice::join, lib::arguments::ArgumentsLevel}, -}; - -struct JoinCommand; - -#[async_trait] -impl RunnerFn for JoinCommand { - async fn run<'a>( - &self, - args: &Vec>, - ) -> InternalCommandResult<'a> { - let ctx = args - .iter() - .filter_map(|arg| arg.downcast_ref::()) - .collect::>()[0]; - let guild = args - .iter() - .filter_map(|arg| arg.downcast_ref::()) - .collect::>()[0]; - let user_id = args - .iter() - .filter_map(|arg| arg.downcast_ref::()) - .collect::>()[0]; - - match join(ctx, guild, user_id).await { - Ok(_) => Ok(CommandResponse::None), - Err(_) => Ok(CommandResponse::None), - } - } -} - -pub fn register(command: &mut CreateCommand) -> &mut CreateCommand { - command - .name("join") - .name_localized("pt-BR", "entrar") - .description("Join the voice channel you are in") - .description_localized("pt-BR", "Entra no canal de voz que você está") - .into() -} - -pub fn get_command() -> Command { - Command::new( - "join", - "Join the voice channel you are in", - CommandCategory::Voice, - vec![ - ArgumentsLevel::Context, - ArgumentsLevel::Guild, - ArgumentsLevel::User, - ], - Box::new(JoinCommand {}), - ) -} diff --git a/src/modules/app/commands/voice/mute.rs b/src/modules/app/commands/voice/mute.rs deleted file mode 100644 index 92e8717..0000000 --- a/src/modules/app/commands/voice/mute.rs +++ /dev/null @@ -1,92 +0,0 @@ -use crate::modules::{ - app::commands::{Command, CommandCategory, CommandResponse, InternalCommandResult, RunnerFn}, - core::{ - actions::voice::{mute, unmute}, - lib::arguments::ArgumentsLevel, - }, -}; - -use serenity::{ - all::{CommandDataOption, CommandOptionType, Guild, UserId}, - async_trait, - builder::CreateCommand, - prelude::Context, -}; - -struct MuteCommand; - -#[async_trait] -impl RunnerFn for MuteCommand { - async fn run<'a>( - &self, - args: &Vec>, - ) -> InternalCommandResult<'a> { - let ctx = args - .iter() - .filter_map(|arg| arg.downcast_ref::()) - .collect::>()[0]; - let guild = args - .iter() - .filter_map(|arg| arg.downcast_ref::()) - .collect::>()[0]; - let user_id = args - .iter() - .filter_map(|arg| arg.downcast_ref::()) - .collect::>()[0]; - let options = args - .iter() - .filter_map(|arg| arg.downcast_ref::>()) - .collect::>>()[0]; - - let enable_sound = options - .get(0) - .unwrap() - .value - .as_ref() - .unwrap() - .as_bool() - .unwrap(); - - match enable_sound { - true => match unmute(ctx, guild, user_id).await { - Ok(_) => Ok(CommandResponse::None), - Err(_) => Ok(CommandResponse::None), - }, - false => match mute(ctx, guild, user_id).await { - Ok(_) => Ok(CommandResponse::None), - Err(_) => Ok(CommandResponse::None), - }, - } - } -} - -pub fn register(command: &mut CreateCommand) -> &mut CreateCommand { - command - .name("mute") - .name_localized("pt-BR", "silenciar") - .description("Disable sound from a bot") - .description_localized("pt-BR", "Mute o bot") - .create_option(|option| { - option - .name("enable_sound") - .name_localized("pt-BR", "habilitar_som") - .description("Enable sound") - .description_localized("pt-BR", "Habilitar som") - .kind(CommandOptionType::Boolean) - }) - .into() -} - -pub fn get_command() -> Command { - Command::new( - "mute", - "Disable sound from a bot", - CommandCategory::Voice, - vec![ - ArgumentsLevel::Context, - ArgumentsLevel::Guild, - ArgumentsLevel::User, - ], - Box::new(MuteCommand {}), - ) -} diff --git a/src/modules/app/listeners/chat/love.rs b/src/modules/app/listeners/chat/love.rs deleted file mode 100644 index ef534e6..0000000 --- a/src/modules/app/listeners/chat/love.rs +++ /dev/null @@ -1,85 +0,0 @@ -use crate::interactions::{RunnerFn, Interaction, InteractionType}; -use crate::internal::arguments::ArgumentsLevel; -use crate::internal::debug::{log_message, MessageTypes}; -use crate::internal::users::USERS; - -use std::cell::RefCell; - -use rust_i18n::t; -use serenity::async_trait; -use serenity::client::Context; -use serenity::model::prelude::ChannelId; -use serenity::model::user::User; - -thread_local! { - static COUNTER: RefCell = RefCell::new(0); - static LAST_MESSAGE_TIME: RefCell = RefCell::new(0); -} - -struct Love {} - -#[async_trait] -impl RunnerFn for Love { - async fn run(&self, args: &Vec>) -> () { - let ctx = args - .iter() - .filter_map(|arg| arg.downcast_ref::()) - .collect::>()[0]; - let channel = args - .iter() - .filter_map(|arg| arg.downcast_ref::()) - .collect::>()[0]; - let user_id = &args - .iter() - .filter_map(|arg| arg.downcast_ref::()) - .collect::>()[0].id; - - match user_id == USERS.get("isadora").unwrap() { - true => { - let message = COUNTER.with(|counter| { - LAST_MESSAGE_TIME.with(|last_message_time| { - let mut counter = counter.borrow_mut(); - let mut last_message_time = last_message_time.borrow_mut(); - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() as u32; - - if now - *last_message_time < 5 { - *last_message_time = now; - - return None.into(); - } else { - *last_message_time = now; - *counter += 1; - - if *counter == 1 { - return t!("interactions.chat.love.reply", "user_id" => user_id).into(); - } - - return t!("interactions.chat.love.reply_counter", "counter" => *counter, "user_id" => user_id) - .into(); - } - }) - }); - - if let Some(message) = message { - if let Err(why) = channel.say(&ctx.http, message).await { - log_message(format!("Error sending message: {:?}", why).as_str(), MessageTypes::Error); - } - } - }, - false => {}, - } - } -} - -pub fn get_love_interaction() -> Interaction { - Interaction::new( - "love", - "Love me", - InteractionType::Chat, - vec![ArgumentsLevel::Context, ArgumentsLevel::ChannelId, ArgumentsLevel::User], - Box::new(Love {}), - ) -} diff --git a/src/modules/app/listeners/chat/mod.rs b/src/modules/app/listeners/chat/mod.rs deleted file mode 100644 index 32a393c..0000000 --- a/src/modules/app/listeners/chat/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod love; diff --git a/src/modules/app/listeners/mod.rs b/src/modules/app/listeners/mod.rs deleted file mode 100644 index 38931b8..0000000 --- a/src/modules/app/listeners/mod.rs +++ /dev/null @@ -1,70 +0,0 @@ -use serenity::async_trait; - -use crate::modules::core::lib::arguments::ArgumentsLevel; - -pub mod chat; -pub mod command; -pub mod modal; -pub mod voice; - -pub enum InteractionType { - Chat, - Modal, - VoiceChannel, -} - -#[async_trait] -pub trait RunnerFn { - async fn run(&self, arguments: &Vec>) -> (); -} - -pub struct Interaction { - pub name: String, - pub description: String, - pub interaction_type: InteractionType, - pub arguments: Vec, - pub runner: Box, -} - -impl Interaction { - pub fn new( - name: &str, - description: &str, - interaction_type: InteractionType, - arguments: Vec, - runner: Box, - ) -> Self { - let sorted_arguments = { - let mut sorted_arguments = arguments.clone(); - sorted_arguments.sort_by(|a, b| a.value().cmp(&b.value())); - sorted_arguments - }; - - Self { - runner, - interaction_type, - arguments: sorted_arguments, - name: name.to_string(), - description: description.to_string(), - } - } -} - -pub fn get_chat_interactions() -> Vec { - vec![chat::love::get_love_interaction()] -} - -pub fn get_voice_channel_interactions() -> Vec { - vec![] -} - -pub fn get_modal_interactions() -> Vec { - vec![modal::poll_option::get_poll_option_modal_interaction()] -} - -pub async fn get_interactions() -> Vec { - let mut interactions = get_chat_interactions(); - interactions.append(&mut get_voice_channel_interactions()); - - interactions -} diff --git a/src/modules/app/listeners/modal/poll_option.rs b/src/modules/app/listeners/modal/poll_option.rs deleted file mode 100644 index f076674..0000000 --- a/src/modules/app/listeners/modal/poll_option.rs +++ /dev/null @@ -1,118 +0,0 @@ -use serenity::{ - async_trait, - model::{ - application::{ - component::ActionRowComponent, interaction::modal::ModalSubmitInteractionData, - }, - guild::Guild, - }, -}; - -use crate::{ - commands::poll::{Poll, PollOption}, - interactions::{Interaction, InteractionType, RunnerFn}, - internal::{ - arguments::ArgumentsLevel, - debug::{log_message, MessageTypes}, - }, -}; - -struct PollOptionModalReceiver {} - -#[async_trait] -impl RunnerFn for PollOptionModalReceiver { - async fn run(&self, args: &Vec>) -> () { - let guild_id = args - .iter() - .filter_map(|arg| arg.downcast_ref::()) - .collect::>()[0] - .id; - let submit_data = args - .iter() - .filter_map(|arg| arg.downcast_ref::()) - .collect::>()[0]; - - // Step 1: Recover poll data from database - let poll_id = submit_data.custom_id.split("/").collect::>()[1]; - let mut poll = Poll::from_id(poll_id.to_string()); - - log_message( - format!("Received interaction with custom_id: {}", poll_id).as_str(), - MessageTypes::Info, - ); - - // Step 2: Get new option to add to poll - let name = - submit_data.components[0] - .components - .iter() - .find_map(|component| match component { - ActionRowComponent::InputText(input) => { - if input.custom_id == "option_name" { - Some(input.value.clone()) - } else { - None - } - } - _ => None, - }); - - let description = submit_data.components[1] - .components - .iter() - .find_map(|component| match component { - ActionRowComponent::InputText(input) => { - if input.custom_id == "option_description" { - Some(input.value.clone()) - } else { - None - } - } - _ => None, - }); - - log_message( - format!("Name: {:?}, Description: {:?}", name, description).as_str(), - MessageTypes::Debug, - ); - - // Step 3: Add new option to poll - match poll.options { - Some(ref mut options) => { - options.push(PollOption { - value: name.unwrap(), - description, - votes: vec![], - }); - } - None => { - poll.options = Some(vec![PollOption { - value: name.unwrap(), - description, - votes: vec![], - }]); - } - } - - // Step 4: Save poll to database - poll.save(guild_id) - - // Step 5: Update poll message - // poll.embed.update_message(ctx).await; - } -} - -pub fn get_poll_option_modal_interaction() -> Interaction { - Interaction::new( - "option_data_poll", - "Save a poll option", - InteractionType::Modal, - vec![ - ArgumentsLevel::User, - ArgumentsLevel::ChannelId, - ArgumentsLevel::Guild, - ArgumentsLevel::ModalSubmitData, - ], - Box::new(PollOptionModalReceiver {}), - ) -} diff --git a/src/modules/app/listeners/voice/join_channel.rs b/src/modules/app/listeners/voice/join_channel.rs deleted file mode 100644 index f425117..0000000 --- a/src/modules/app/listeners/voice/join_channel.rs +++ /dev/null @@ -1,103 +0,0 @@ -use crate::internal::debug::{log_message, MessageTypes}; -use crate::internal::users::USERS; -use rust_i18n::t; - -use std::cell::RefCell; -use std::collections::HashMap; -use std::sync::Arc; - -use serenity::client::Context; -use serenity::model::id::UserId; -use serenity::model::prelude::ChannelId; -use tokio::time; - -type Cache = HashMap; - -thread_local! { - static CACHE: Arc> = Arc::new(RefCell::new(HashMap::new())); -} - -pub async fn clear_cache() { - log_message("Starting clear cache task", MessageTypes::Server); - loop { - time::sleep(time::Duration::from_secs(86400)).await; - log_message("Clearing cache", MessageTypes::Server); - - CACHE.with(|cache| { - let mut cache = cache.borrow_mut(); - - cache.clear(); - }); - } -} - -pub async fn join_channel(channel: &ChannelId, ctx: &Context, user_id: &UserId) -> () { - let user = user_id.to_user(&ctx.http).await.unwrap(); - let members = channel - .to_channel(&ctx) - .await - .unwrap() - .guild() - .unwrap() - .members(&ctx) - .await - .unwrap(); - - let message = CACHE.with(|cache| { - let mut cache = cache.borrow_mut(); - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs() as u32; - - if let Some((counter, last_update, _)) = cache.get_mut(&user.id) { - if now - *last_update < 5 { - *last_update = now; - *counter += 1; - - return None; - } - } - - if let Some((counter, last_update, _)) = cache.get_mut(&user.id) { - if now - *last_update < 5 { - *last_update = now; - - return None; - } else { - *last_update = now; - *counter += 1; - - if user_id == USERS.get("scaliza").unwrap() { - if members.len() == 1 { - return t!(&format!("interactions.join_channel.scaliza.empty_channel"), user_id => user.id).into(); - } else if members.len() >= 3 { - return t!(&format!("interactions.join_channel.scaliza.many_users"), user_id => user.id).into(); - } - - return format!("O CAPETA CHEGOU {} vezes 😡", counter).into(); - } - - return t!(&format!("interactions.join_channel.{}", (*counter as u8).min(2)), user_id => user.id).into(); - } - } else { - cache.insert(*user_id, (1, now, *user_id)); - log_message(format!("Added {} to cache", user.name).as_str(), MessageTypes::Success); - - if user_id == USERS.get("scaliza").unwrap() { - return t!(&format!("interactions.join_channel.scaliza.0"), user_id => user.id).into(); - } - - return t!(&format!("interactions.join_channel.0"), user_id => user.id).into(); - } - }); - - if let Some(message) = message { - if let Err(why) = channel.say(&ctx.http, message).await { - log_message( - format!("Error sending message: {:?}", why).as_str(), - MessageTypes::Error, - ); - } - } -} diff --git a/src/modules/app/listeners/voice/mod.rs b/src/modules/app/listeners/voice/mod.rs deleted file mode 100644 index 27324f9..0000000 --- a/src/modules/app/listeners/voice/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod join_channel; diff --git a/src/modules/app/services/integrations/jukera.rs b/src/modules/app/services/integrations/jukera.rs deleted file mode 100644 index be5c44c..0000000 --- a/src/modules/app/services/integrations/jukera.rs +++ /dev/null @@ -1,48 +0,0 @@ -use super::{CallbackFn, Integration, IntegrationType}; -use crate::internal::users::USERS; - -use serenity::async_trait; -use serenity::model::{channel::Message, gateway::Activity, id::UserId}; -use serenity::prelude::Context; - -struct Jukera {} - -#[async_trait] -impl CallbackFn for Jukera { - async fn run(&self, message: &Message, ctx: &Context, user_id: &UserId) { - run(message, ctx, user_id).await; - } -} - -async fn run(message: &Message, ctx: &Context, user_id: &UserId) { - match user_id == USERS.get("jukes_box").unwrap() { - true => { - // check if message is a embed message (music session) - if message.embeds.is_empty() { - ctx.set_activity(Activity::competing( - "Campeonato de Leitada, Modalidade: Volume", - )) - .await; - - return; - } - - let current_music = match message.embeds.first() { - Some(embed) => embed.description.as_ref().unwrap(), - None => return, - }; - - ctx.set_activity(Activity::listening(current_music)).await - } - false => {} - } -} - -pub fn register() -> Integration { - Integration::new( - "jukera", - "Jukera Integration, Listening to jukes_box", - IntegrationType::Chat, - Box::new(Jukera {}), - ) -} diff --git a/src/modules/app/services/integrations/mod.rs b/src/modules/app/services/integrations/mod.rs deleted file mode 100644 index 2cd0927..0000000 --- a/src/modules/app/services/integrations/mod.rs +++ /dev/null @@ -1,57 +0,0 @@ -use crate::modules::core::lib::debug::{log_message, MessageTypes}; - -use serenity::async_trait; -use serenity::model::channel::Message; -use serenity::model::id::UserId; -use serenity::prelude::Context; - -pub mod jukera; - -pub fn integration_callback( - name: &str, - callback: Box, -) -> Box { - log_message( - format!("Running integration {}", name).as_str(), - MessageTypes::Info, - ); - - callback -} - -#[async_trait] -pub trait CallbackFn { - async fn run(&self, msg: &Message, a: &Context, c: &UserId) -> (); -} - -#[derive(Clone, Copy)] -pub enum IntegrationType { - Chat, -} - -pub struct Integration { - pub name: String, - pub description: String, - pub integration_type: IntegrationType, - pub callback: Box, -} - -impl Integration { - pub fn new( - name: &str, - description: &str, - integration_type: IntegrationType, - callback: Box, - ) -> Integration { - Integration { - name: name.to_string(), - description: description.to_string(), - integration_type, - callback: integration_callback(name, callback), - } - } -} - -pub fn get_chat_integrations() -> Vec { - vec![jukera::register()] -} diff --git a/src/modules/core/actions/mod.rs b/src/modules/core/actions/mod.rs deleted file mode 100644 index 5e2e12b..0000000 --- a/src/modules/core/actions/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod voice; diff --git a/src/modules/core/actions/voice.rs b/src/modules/core/actions/voice.rs deleted file mode 100644 index 7c72d84..0000000 --- a/src/modules/core/actions/voice.rs +++ /dev/null @@ -1,148 +0,0 @@ -use rust_i18n::t; - -use serenity::framework::standard::CommandResult; -use serenity::model::prelude::{Guild, UserId}; -use serenity::prelude::Context; - -use crate::modules::core::lib::debug::{log_message, MessageTypes}; - -pub async fn join(ctx: &Context, guild: &Guild, user_id: &UserId) -> CommandResult { - let debug = std::env::var("DEBUG").is_ok(); - let channel_id = guild.voice_states.get(user_id).unwrap().channel_id; - - let connect_to = match channel_id { - Some(channel) => channel, - None => { - log_message( - format!("User is not in a voice channel").as_str(), - MessageTypes::Debug, - ); - - return Ok(t!("commands.voice.user_not_connected")); - } - }; - - if debug { - log_message( - format!("Connecting to voice channel: {}", connect_to).as_str(), - MessageTypes::Debug, - ); - } - - let manager = songbird::get(ctx) - .await - .expect("Songbird Voice client placed in at initialisation.") - .clone(); - - if debug { - log_message( - format!("Manager: {:?}", manager).as_str(), - MessageTypes::Debug, - ); - } - - let handler = manager.join(guild.id, connect_to).await; - - match handler.1 { - Ok(_) => {} - Err(why) => { - log_message(format!("Failed: {:?}", why).as_str(), MessageTypes::Error); - - return Ok(t!("commands.voice.join_failed")); - } - } - - log_message( - format!("Joined voice channel").as_str(), - MessageTypes::Success, - ); - - Ok(t!("commands.voice.join")) -} - -pub async fn mute(ctx: &Context, guild: &Guild, _user_id: &UserId) -> CommandResult { - let debug = std::env::var("DEBUG").is_ok(); - - let manager = songbird::get(ctx) - .await - .expect("Songbird Voice client placed in at initialisation.") - .clone(); - - let handler_lock = match manager.get(guild.id) { - Some(handler) => handler, - None => { - log_message( - format!("Bot not connected to a voice channel").as_str(), - MessageTypes::Failed, - ); - - return Ok(t!("commands.voice.bot_not_connected")); - } - }; - - let mut handler = handler_lock.lock().await; - - if handler.is_mute() { - if debug { - log_message(format!("User already muted").as_str(), MessageTypes::Debug); - } - } else { - if let Err(why) = handler.mute(true).await { - log_message(format!("Failed: {:?}", why).as_str(), MessageTypes::Error); - } - } - - Ok(t!("commands.voice.mute")) -} - -pub async fn unmute(ctx: &Context, guild: &Guild, _user_id: &UserId) -> CommandResult { - let manager = songbird::get(ctx) - .await - .expect("Songbird Voice client placed in at initialisation.") - .clone(); - - let handler_lock = match manager.get(guild.id) { - Some(handler) => handler, - None => { - log_message( - format!("Bot not connected to a voice channel").as_str(), - MessageTypes::Failed, - ); - - return Ok(t!("commands.voice.bot_not_connected")); - } - }; - - let mut handler = handler_lock.lock().await; - - if handler.is_mute() { - if let Err(why) = handler.mute(false).await { - log_message(format!("Failed: {:?}", why).as_str(), MessageTypes::Error); - } - } - - Ok(t!("commands.voice.un_mute")) -} - -pub async fn leave(ctx: &Context, guild: &Guild, _user_id: &UserId) -> CommandResult { - let manager = songbird::get(ctx) - .await - .expect("Songbird Voice client placed in at initialisation.") - .clone(); - let has_handler = manager.get(guild.id).is_some(); - - if has_handler { - if let Err(why) = manager.remove(guild.id).await { - log_message(format!("Failed: {:?}", why).as_str(), MessageTypes::Error); - } - } else { - log_message( - format!("Bot not connected to a voice channel").as_str(), - MessageTypes::Failed, - ); - - return Ok(t!("commands.voice.bot_not_connected")); - } - - Ok(t!("commands.voice.leave")) -} diff --git a/src/modules/core/constants.rs b/src/modules/core/constants.rs deleted file mode 100644 index e69de29..0000000 diff --git a/src/modules/core/entities/mod.rs b/src/modules/core/entities/mod.rs deleted file mode 100644 index fe09fd5..0000000 --- a/src/modules/core/entities/mod.rs +++ /dev/null @@ -1,158 +0,0 @@ -use diesel::deserialize::{self, FromSql, FromSqlRow}; -use diesel::expression::AsExpression; -use diesel::pg::{Pg, PgValue}; -use diesel::serialize::{self, IsNull, Output, ToSql}; -use serenity::model::id::{ChannelId, GuildId, MessageId, UserId}; -use std::io::Write; - -// TODO: implement macro to generate trait for discord id wrappers - -#[derive(Debug, AsExpression, FromSqlRow)] -#[diesel(sql_type = diesel::sql_types::BigInt)] -pub struct ChannelIdWrapper(pub ChannelId); - -impl ToSql for ChannelIdWrapper { - fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { - ToSql::::to_sql(&i64::from(self.0), out) - } -} - -impl FromSql for ChannelIdWrapper { - fn from_sql(bytes: PgValue) -> deserialize::Result { - i64::from_sql(bytes).map(|id| Self(ChannelId::new(id as u64))) - } -} - -impl FromSql, Pg> for ChannelIdWrapper { - fn from_sql(bytes: PgValue) -> deserialize::Result { - i64::from_sql(bytes).map(|id| Self(ChannelId::new(id as u64))) - } -} - -#[derive(Debug, AsExpression, FromSqlRow, Hash, PartialEq, Eq)] -#[diesel(primary_key(id))] -#[diesel(sql_type = diesel::sql_types::BigInt)] -pub struct GuildIdWrapper(pub GuildId); - -impl ToSql for GuildIdWrapper { - fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { - ToSql::::to_sql(&i64::from(self.0), out) - } -} - -impl FromSql for GuildIdWrapper { - fn from_sql(bytes: PgValue) -> deserialize::Result { - i64::from_sql(bytes).map(|id| Self(GuildId::new(id as u64))) - } -} - -impl FromSql, Pg> for GuildIdWrapper { - fn from_sql(bytes: PgValue) -> deserialize::Result { - i64::from_sql(bytes).map(|id| Self(GuildId::new(id as u64))) - } -} - -#[derive(Debug, AsExpression, FromSqlRow)] -#[diesel(sql_type = diesel::sql_types::BigInt)] -pub struct MessageIdWrapper(pub MessageId); - -impl ToSql for MessageIdWrapper { - fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { - ToSql::::to_sql(&i64::from(self.0), out) - } -} - -impl FromSql for MessageIdWrapper { - fn from_sql(bytes: PgValue) -> deserialize::Result { - i64::from_sql(bytes).map(|id| Self(MessageId::new(id as u64))) - } -} - -impl FromSql, Pg> for MessageIdWrapper { - fn from_sql(bytes: PgValue) -> deserialize::Result { - i64::from_sql(bytes).map(|id| Self(MessageId::new(id as u64))) - } -} - -#[derive(Debug, AsExpression, FromSqlRow, Hash, PartialEq, Eq)] -#[diesel(primary_key(id))] -#[diesel(sql_type = diesel::sql_types::BigInt)] -pub struct UserIdWrapper(pub UserId); - -impl ToSql for UserIdWrapper { - fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { - ToSql::::to_sql(&i64::from(self.0), out) - } -} - -impl FromSql for UserIdWrapper { - fn from_sql(bytes: PgValue) -> deserialize::Result { - i64::from_sql(bytes).map(|id| Self(UserId::new(id as u64))) - } -} - -#[derive(Debug, AsExpression, FromSqlRow)] -#[diesel(sql_type = crate::schema::sql_types::Language)] -pub enum Language { - En, - Pt, -} - -impl ToSql for Language { - fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { - match *self { - Language::En => out.write_all(b"en")?, - Language::Pt => out.write_all(b"pt")?, - } - Ok(IsNull::No) - } -} - -impl FromSql for Language { - fn from_sql(bytes: PgValue) -> deserialize::Result { - match bytes.as_bytes() { - b"en" => Ok(Language::En), - b"pt" => Ok(Language::Pt), - _ => Err("Unrecognized enum variant".into()), - } - } -} - -#[derive(Debug, AsExpression, FromSqlRow)] -#[diesel(sql_type = crate::schema::sql_types::PollKind)] -pub enum PollKind { - SingleChoice, - MultipleChoice, -} - -impl ToSql for PollKind { - fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { - match *self { - PollKind::SingleChoice => out.write_all(b"single_choice")?, - PollKind::MultipleChoice => out.write_all(b"multiple_choice")?, - } - Ok(IsNull::No) - } -} - -impl FromSql for PollKind { - fn from_sql(bytes: PgValue) -> deserialize::Result { - match bytes.as_bytes() { - b"single_choice" => Ok(PollKind::SingleChoice), - b"multiple_choice" => Ok(PollKind::MultipleChoice), - _ => Err("Unrecognized enum variant".into()), - } - } -} - -pub mod exports { - pub use super::guild as Guild; - pub use super::poll as Poll; - pub use super::user as User; - pub use super::Language; - pub use super::PollKind; -} - -pub mod guild; -pub mod poll; -pub mod user; diff --git a/src/modules/core/entities/poll.rs b/src/modules/core/entities/poll.rs deleted file mode 100644 index 2989331..0000000 --- a/src/modules/core/entities/poll.rs +++ /dev/null @@ -1,43 +0,0 @@ -use diesel::prelude::*; - -use super::{ChannelIdWrapper, MessageIdWrapper, PollKind, UserIdWrapper}; - -#[derive(Queryable, Selectable, Identifiable, Insertable)] -#[diesel(table_name = crate::schema::polls)] -#[diesel(check_for_backend(diesel::pg::Pg))] -pub struct Poll { - pub id: uuid::Uuid, - pub name: String, - pub description: Option, - pub kind: PollKind, - pub thread_id: ChannelIdWrapper, - pub embed_message_id: MessageIdWrapper, - pub poll_message_id: MessageIdWrapper, - pub started_at: Option, - pub ended_at: Option, - pub created_at: time::OffsetDateTime, - pub created_by: UserIdWrapper, -} - -#[derive(Queryable, Selectable, Identifiable, Insertable)] -#[diesel(primary_key(poll_id, value))] -#[diesel(table_name = crate::schema::poll_choices)] -#[diesel(check_for_backend(diesel::pg::Pg))] -pub struct PollChoice { - pub poll_id: uuid::Uuid, - pub value: String, - pub label: String, - pub description: Option, - pub created_at: time::OffsetDateTime, -} - -#[derive(Queryable, Selectable, Identifiable, Insertable)] -#[diesel(primary_key(user_id, poll_id, choice_value))] -#[diesel(table_name = crate::schema::poll_votes)] -#[diesel(check_for_backend(diesel::pg::Pg))] -pub struct PollVote { - pub poll_id: uuid::Uuid, - pub choice_value: String, - pub user_id: UserIdWrapper, - pub voted_at: time::OffsetDateTime, -} diff --git a/src/modules/core/helpers/components/button.rs b/src/modules/core/helpers/components/button.rs deleted file mode 100644 index 1b10b71..0000000 --- a/src/modules/core/helpers/components/button.rs +++ /dev/null @@ -1,42 +0,0 @@ -use serenity::{all::ButtonStyle, builder::CreateButton, model::prelude::ReactionType}; - -pub struct Button { - name: String, - emoji: Option, - label: String, - style: ButtonStyle, -} - -impl Button { - pub fn new(name: &str, label: &str, style: ButtonStyle, emoji: Option) -> Self { - Self { - emoji, - style, - name: name.to_string(), - label: label.to_string(), - } - } - - pub fn label(mut self, label: &str) -> Self { - self.label = label.to_string(); - self - } - - pub fn style(mut self, style: ButtonStyle) -> Self { - self.style = style; - self - } - - pub fn create(&self) -> CreateButton { - let mut b = CreateButton::new(self.name); - - b.label(&self.label); - b.style(self.style.clone()); - - if let Some(emoji) = &self.emoji { - b.emoji(emoji.clone()); - } - - b - } -} diff --git a/src/modules/core/helpers/components/mod.rs b/src/modules/core/helpers/components/mod.rs deleted file mode 100644 index 8ddf452..0000000 --- a/src/modules/core/helpers/components/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod button; \ No newline at end of file diff --git a/src/modules/core/helpers/mod.rs b/src/modules/core/helpers/mod.rs deleted file mode 100644 index f188f2c..0000000 --- a/src/modules/core/helpers/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod components; diff --git a/src/modules/core/lib/constants.rs b/src/modules/core/lib/constants.rs deleted file mode 100644 index d61807d..0000000 --- a/src/modules/core/lib/constants.rs +++ /dev/null @@ -1,8 +0,0 @@ -pub const USERS_FILE_PATH: &str = "./public/static/users.json"; - -#[derive(Debug, Clone)] -pub struct CommandHelp { - pub name: String, - pub description: String, - pub options: Vec, -} diff --git a/src/modules/core/lib/debug.rs b/src/modules/core/lib/debug.rs deleted file mode 100644 index 7253baa..0000000 --- a/src/modules/core/lib/debug.rs +++ /dev/null @@ -1,93 +0,0 @@ -use colored::Colorize; - -#[derive(Debug, Clone, Copy)] -enum DebugLevel { - Info, - Error, - Success, - Verbose, - Minimal, -} - -impl DebugLevel { - fn get_level(&self) -> Vec { - match self { - DebugLevel::Minimal => vec![1], - DebugLevel::Info => vec![1, 2, 3], - DebugLevel::Success => vec![2], - DebugLevel::Error => vec![3], - DebugLevel::Verbose => vec![1, 2, 3, 4], - } - } - fn get_current_level() -> DebugLevel { - let debug_level = std::env::var("DEBUG").unwrap_or("minimal".to_string()); - - match debug_level.as_str() { - "minimal" => DebugLevel::Minimal, - "info" => DebugLevel::Info, - "success" => DebugLevel::Success, - "error" => DebugLevel::Error, - "verbose" => DebugLevel::Verbose, - _ => DebugLevel::Minimal, - } - } -} - -#[derive(Debug, Clone, Copy)] -pub enum MessageTypes { - Info, - Error, - Success, - Failed, - Server, - Debug, -} - -impl MessageTypes { - fn get_console_color(&self) -> &'static str { - match self { - MessageTypes::Info => "blue", - MessageTypes::Error => "red", - MessageTypes::Success => "green", - MessageTypes::Failed => "red", - MessageTypes::Server => "yellow", - MessageTypes::Debug => "white", - } - } - fn get_console_prefix(&self) -> &'static str { - match self { - MessageTypes::Info => "INFO", - MessageTypes::Error => "ERROR", - MessageTypes::Success => "SUCCESS", - MessageTypes::Failed => "FAILED", - MessageTypes::Server => "SERVER", - MessageTypes::Debug => "DEBUG", - } - } - fn get_debug_level(&self) -> u8 { - match self { - MessageTypes::Info => 1, - MessageTypes::Success => 2, - MessageTypes::Failed => 2, - MessageTypes::Error => 3, - MessageTypes::Server => 1, - MessageTypes::Debug => 4, - } - } -} - -pub fn log_message(message: &str, message_type: MessageTypes) { - let debug_level = DebugLevel::get_current_level(); - - if !DebugLevel::get_level(&debug_level).contains(&message_type.get_debug_level()) { - return; - } - - println!( - "{} - {}", - message_type - .get_console_prefix() - .color(message_type.get_console_color()), - message - ); -} diff --git a/src/modules/core/lib/embeds.rs b/src/modules/core/lib/embeds.rs deleted file mode 100644 index 480ab18..0000000 --- a/src/modules/core/lib/embeds.rs +++ /dev/null @@ -1,86 +0,0 @@ -use serenity::{ - builder::CreateEmbed, client::Context, model::channel::GuildChannel, model::channel::Message, -}; -use std::any::Any; - -use super::debug::{log_message, MessageTypes}; - -pub trait EmbedRunnerFn { - fn run(&self, arguments: &Vec>) -> CreateEmbed; -} - -pub struct ApplicationEmbed { - pub name: String, - pub description: Option, - pub message_content: Option, - pub arguments: Vec>, - pub builder: Box, - pub message: Option, -} - -impl ApplicationEmbed { - pub fn new( - name: String, - description: Option, - message_content: Option, - arguments: Vec>, - builder: Box, - ) -> Self { - Self { - name, - builder, - arguments, - description, - message_content, - message: None, - } - } - - pub async fn update(&self, channel: GuildChannel, ctx: &Context) { - if let Err(why) = channel - .edit_message(ctx, self.message.clone().unwrap().id, |m| { - m.embed(|e| { - e.clone_from(&self.builder.run(&self.arguments)); - e - }) - }) - .await - { - log_message( - format!("Error updating message: {:?}", why).as_str(), - MessageTypes::Error, - ); - } - } - - pub async fn send_message(&mut self, channel: GuildChannel, ctx: &Context) { - match channel - .send_message(ctx, |m| { - m.embed(|e| { - e.clone_from(&self.builder.run(&self.arguments)); - e - }) - }) - .await - { - Ok(message) => { - self.message = Some(message); - } - Err(why) => log_message( - format!("Error sending message: {:?}", why).as_str(), - MessageTypes::Error, - ), - } - } -} - -impl std::fmt::Display for ApplicationEmbed { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "Embed: {} \n {}", - self.name, - self.description.clone().unwrap() - ) - } -} diff --git a/src/modules/core/lib/mod.rs b/src/modules/core/lib/mod.rs deleted file mode 100644 index aab83bf..0000000 --- a/src/modules/core/lib/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod arguments; -pub mod constants; -pub mod debug; -pub mod embeds; From b5039083e74b419786bcd718f89b8e2048736274 Mon Sep 17 00:00:00 2001 From: "Kszinhu@POP_os" Date: Fri, 16 Feb 2024 02:29:10 -0300 Subject: [PATCH 08/17] build: rename container-names to deploy environment pattern --- docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 5d126b9..07ad34d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ version: "3.8" services: client: - container_name: bostil-bot-client + container_name: kszinhu.bostil_bot-client image: ghcr.io/kszinhu/bostil-bot:master restart: unless-stopped networks: @@ -10,7 +10,7 @@ services: - stack.env database: - container_name: bostil-bot-database + container_name: kszinhu.bostil_bot-database image: postgres:16 restart: unless-stopped networks: From f21cd096dba6cbc95569d14425db92bdf089330f Mon Sep 17 00:00:00 2001 From: "Kszinhu@POP_os" Date: Fri, 16 Feb 2024 02:35:04 -0300 Subject: [PATCH 09/17] build: copy app and core crates instead of old single crate --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6eb8a28..a244aac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,15 +21,15 @@ RUN cargo new --bin bostil-bot WORKDIR /usr/src/app/bostil-bot COPY Cargo.toml ./Cargo.toml -COPY public ./public -COPY src ./src +COPY app ./app +COPY core ./core # Build the dependencies RUN cargo clean RUN cargo build --release # Remove the source code -RUN rm src/**/*.rs +RUN rm ./**/*.rs ADD . ./ From b6de56843ec93148b6f418bdf7650b22a07dfbe2 Mon Sep 17 00:00:00 2001 From: "Kszinhu@POP_os" Date: Sun, 18 Feb 2024 16:50:00 -0300 Subject: [PATCH 10/17] chore: change features of crate reqwest --- app/Cargo.toml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/Cargo.toml b/app/Cargo.toml index f3fae33..b0a19a5 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -15,10 +15,15 @@ serenity = { workspace = true } songbird = { workspace = true } tokio = { features = ["full"], version = "1" } symphonia = { features = ["aac", "mp3", "isomp4", "alac"], version = "0.5.2" } -reqwest = { version = "0.11" } +reqwest = { version = "0.11", default-features = false, features = [ + "blocking", + "json", + "rustls-tls-native-roots", +] } # Database uuid = { version = "^1.4.1", features = ["v4", "fast-rng"] } +postgres = { version = "0.19" } diesel = { version = "2.1.4", features = ["postgres", "time", "uuid"] } dotenvy = "0.15.7" time = "0.3" From afaed06516c846c7ff01f7f21425af01e124af7b Mon Sep 17 00:00:00 2001 From: "Kszinhu@POP_os" Date: Sun, 18 Feb 2024 18:14:59 -0300 Subject: [PATCH 11/17] build: add database service --- docker-compose.local.yml | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/docker-compose.local.yml b/docker-compose.local.yml index a33c621..e7fc48f 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -7,17 +7,19 @@ services: dockerfile: Dockerfile env_file: - .env + depends_on: + - database - database: - container_name: bostil-bot-db - image: postgres:16 - volumes: - - db-data:/var/lib/postgresql/data - env_file: - - .env - - .env.local - ports: - - "5432:5432" + database: + container_name: bostil-bot-db + image: postgres:16 + volumes: + - db-data:/var/lib/postgresql/data + env_file: + - .env + - .env.local + ports: + - "5432:5432" volumes: db-data: From 1ee5545bf3e8e2e5c195ede7f71908698251f043 Mon Sep 17 00:00:00 2001 From: "Kszinhu@POP_os" Date: Sun, 18 Feb 2024 18:15:54 -0300 Subject: [PATCH 12/17] build: update path to modules crate --- Dockerfile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index a244aac..ad2ef3b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -47,13 +47,13 @@ FROM alpine:latest AS runtime ARG APP=/usr/src/app # System dependencies -RUN apk add --no-cache ca-certificates tzdata yt-dlp +RUN apk add --no-cache ca-certificates tzdata yt-dlp postgresql-dev # Copy the binary from the builder stage -COPY --from=builder /usr/src/app/bostil-bot/target/release/bostil-bot ${APP}/bostil-bot +COPY --from=builder /usr/src/app/bostil-bot/target/x86_64-unknown-linux-musl/release/bostil-bot ${APP}/bostil-bot # Copy public files from the builder stage -COPY --from=builder /usr/src/app/bostil-bot/public ${APP}/public +COPY --from=builder /usr/src/app/bostil-bot/app/public ${APP}/public RUN chmod +x ${APP}/bostil-bot WORKDIR ${APP} From 74c59373815e1f1e8ee5f87288a7cc381a8a63e6 Mon Sep 17 00:00:00 2001 From: "Kszinhu@POP_os" Date: Mon, 19 Feb 2024 02:31:50 -0300 Subject: [PATCH 13/17] build: add diesel running pending migrations and fix build script --- Dockerfile | 62 ++++++++++++------------ app/Cargo.toml | 4 +- app/src/lib.rs | 1 + app/src/main.rs | 19 ++++++-- app/src/modules/core/helpers/database.rs | 3 ++ app/src/modules/core/helpers/mod.rs | 2 +- docker-compose.yml | 1 + 7 files changed, 55 insertions(+), 37 deletions(-) diff --git a/Dockerfile b/Dockerfile index ad2ef3b..833c6d2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,61 +1,61 @@ #---------------- # Build stage #---------------- -FROM rust:1.76.0-alpine3.19 as builder +FROM rust:1.76-alpine AS builder + +ARG RUSTFLAGS="-C target-feature=-crt-static" +ARG APP=/usr/src/app +ARG TARGET_PLATFORM=x86_64-unknown-linux-musl +ARG CRATE_NAME=bostil-bot # System dependencies RUN apk add --no-cache \ - build-base \ - cmake \ - musl-dev \ - curl \ - yt-dlp \ - pkgconfig \ - openssl-dev \ - git + build-base cmake musl-dev pkgconfig openssl-dev \ + libpq-dev \ + curl git yt-dlp -WORKDIR /usr/src/app +WORKDIR ${APP} -RUN cargo new --bin bostil-bot +RUN cargo new --bin ${CRATE_NAME} -WORKDIR /usr/src/app/bostil-bot +WORKDIR ${APP}/${CRATE_NAME} -COPY Cargo.toml ./Cargo.toml +COPY Cargo.toml Cargo.lock ./ COPY app ./app COPY core ./core -# Build the dependencies -RUN cargo clean -RUN cargo build --release +RUN cargo install diesel_cli --no-default-features --features postgres -# Remove the source code -RUN rm ./**/*.rs +RUN --mount=type=cache,target=/usr/local/cargo/registry,id=${TARGET_PLATFORM} --mount=type=cache,target=/target,id=${TARGET_PLATFORM} \ + cargo build --release && \ + mv target/release/${CRATE_NAME} . -ADD . ./ +COPY . . -# Remove the target directory -RUN rm ./target/release/deps/bostil_bot* +RUN --mount=type=cache,target=/usr/local/cargo/registry < info!("Migrations ran successfully"), + Err(why) => { + error!("Cannot run migrations: {}", why); + return; + } + } let mut command_collector = match COMMAND_COLLECTOR.lock() { Ok(collector) => collector.clone(), @@ -339,7 +351,6 @@ async fn main() { info!("Collected commands: {:#?}", command_collector.length); info!("Collected listeners: {:#?}", listener_collector.length); - // save the collector *COMMAND_COLLECTOR.lock().unwrap() = command_collector; let intents = GatewayIntents::MESSAGE_CONTENT diff --git a/app/src/modules/core/helpers/database.rs b/app/src/modules/core/helpers/database.rs index b3ebb87..aef33cc 100644 --- a/app/src/modules/core/helpers/database.rs +++ b/app/src/modules/core/helpers/database.rs @@ -1,4 +1,5 @@ use diesel::{pg::PgConnection, Connection}; +use diesel_migrations::{embed_migrations, EmbeddedMigrations}; use dotenvy::dotenv; // TODO: implementar algum jeito para que cada servidor tenha seu próprio idioma e não alterar o idioma de todos os servidores @@ -13,3 +14,5 @@ pub fn establish_connection() -> PgConnection { PgConnection::establish(&database_url) .unwrap_or_else(|_| panic!("Error connecting to {}", database_url)) } + +pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("./migrations"); diff --git a/app/src/modules/core/helpers/mod.rs b/app/src/modules/core/helpers/mod.rs index 5d9405e..8afc6cc 100644 --- a/app/src/modules/core/helpers/mod.rs +++ b/app/src/modules/core/helpers/mod.rs @@ -1,5 +1,5 @@ mod database; mod http_client; -pub use database::establish_connection; +pub use database::{establish_connection, MIGRATIONS}; pub use http_client::get_client; diff --git a/docker-compose.yml b/docker-compose.yml index 07ad34d..ec96c99 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,6 +22,7 @@ services: volumes: bostil-database: + networks: kszinhu: name: kszinhu From cd63131aff378222d8bff2d8d3957d1ead6c6805 Mon Sep 17 00:00:00 2001 From: "Kszinhu@POP_os" Date: Mon, 19 Feb 2024 02:39:07 -0300 Subject: [PATCH 14/17] build: remove unnused 'cargo.lock' --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 833c6d2..bc47054 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,7 +20,7 @@ RUN cargo new --bin ${CRATE_NAME} WORKDIR ${APP}/${CRATE_NAME} -COPY Cargo.toml Cargo.lock ./ +COPY Cargo.toml ./ COPY app ./app COPY core ./core From e1694eda63100aef410d960866850b16117c9b57 Mon Sep 17 00:00:00 2001 From: "Kszinhu@POP_os" Date: Mon, 19 Feb 2024 02:52:12 -0300 Subject: [PATCH 15/17] build: add bind port to access database --- docker-compose.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index ec96c99..3e6372f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,10 +19,13 @@ services: - bostil-database:/var/lib/postgresql/data env_file: - stack.env + ports: + - "${DB_PORT}:5432" volumes: bostil-database: + networks: kszinhu: name: kszinhu From 6f8eacdd6a27ebd6947c8084d828d79ca922cefb Mon Sep 17 00:00:00 2001 From: "Kszinhu@POP_os" Date: Mon, 19 Feb 2024 21:34:26 -0300 Subject: [PATCH 16/17] ci/cd: add caching to ephemeral build --- .github/workflows/check-build.yml | 27 ++++++++++++++++++++++++--- Cargo.toml | 8 ++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/.github/workflows/check-build.yml b/.github/workflows/check-build.yml index 3ed0710..753857b 100644 --- a/.github/workflows/check-build.yml +++ b/.github/workflows/check-build.yml @@ -15,6 +15,27 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Build the Docker image - run: docker build --file ./Dockerfile . + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + target: x86_64-unknown-linux-musl + + - name: Configure sccache env var and set build profile to ephemeral build + run: | + echo "RUSTC_WRAPPER=sccache" >> $GITHUB_ENV + echo "SCCACHE_GHA_ENABLED=true" >> $GITHUB_ENV + echo "RUSTFLAGS='--cfg profile=ephemeral-build'" >> $GITHUB_ENV + + - name: Run sccache-cache + uses: mozilla-actions/sccache-action@v0.0.4 + + - name: Run build + uses: actions-rs/cargo@v1 + with: + command: build + args: --target x86_64-unknown-linux-musl --release diff --git a/Cargo.toml b/Cargo.toml index 5591cc5..44ba508 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,3 +32,11 @@ tracing-futures = "0.2" # Other lazy_static = "*" once_cell = { version = "*", features = ["std"] } + +[profile.release] +opt-level = 3 +codegen-units = 16 + +[profile.ephemeral-build] +opt-level = 2 +codegen-units = 8 From 2d5946926880af2c0518e05002afc44956d658e5 Mon Sep 17 00:00:00 2001 From: "Kszinhu@POP_os" Date: Tue, 20 Feb 2024 02:06:24 -0300 Subject: [PATCH 17/17] ci/cd: instead of build docker image, build directly workspace --- .github/workflows/check-build.yml | 38 +++++++++++++++++-------------- Cargo.toml | 7 ++---- app/Cargo.toml | 2 +- 3 files changed, 24 insertions(+), 23 deletions(-) diff --git a/.github/workflows/check-build.yml b/.github/workflows/check-build.yml index 753857b..3b6f8d7 100644 --- a/.github/workflows/check-build.yml +++ b/.github/workflows/check-build.yml @@ -13,29 +13,33 @@ on: jobs: build: runs-on: ubuntu-latest + name: Build Pull Request steps: - - name: Checkout repository - uses: actions/checkout@v3 + - name: 📥 Checkout + uses: actions/checkout@v4 - - name: Install Rust + - name: ⚡️ Rust Cache + uses: Swatinem/rust-cache@v2 + + - name: ⚡️ Shared Compilation Cache + uses: mozilla-actions/sccache-action@v0.0.4 + + - name: 📦 Install Dependencies + run: | + sudo apt-get update + sudo apt-get install -y build-essential gcc cmake musl-dev pkg-config libpq-dev openssl libssl-dev + + - name: 🛠️ Set up Rust uses: actions-rs/toolchain@v1 with: toolchain: stable override: true - target: x86_64-unknown-linux-musl - - name: Configure sccache env var and set build profile to ephemeral build + - name: 🚀 Build + env: + SCCACHE_GHA_ENABLED: "true" + RUSTC_WRAPPER: "sccache" + RUSTFLAGS: "-C target-feature=-crt-static" run: | - echo "RUSTC_WRAPPER=sccache" >> $GITHUB_ENV - echo "SCCACHE_GHA_ENABLED=true" >> $GITHUB_ENV - echo "RUSTFLAGS='--cfg profile=ephemeral-build'" >> $GITHUB_ENV - - - name: Run sccache-cache - uses: mozilla-actions/sccache-action@v0.0.4 - - - name: Run build - uses: actions-rs/cargo@v1 - with: - command: build - args: --target x86_64-unknown-linux-musl --release + cargo build --profile=ephemeral-build diff --git a/Cargo.toml b/Cargo.toml index 44ba508..0e04e5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,10 +33,7 @@ tracing-futures = "0.2" lazy_static = "*" once_cell = { version = "*", features = ["std"] } -[profile.release] -opt-level = 3 -codegen-units = 16 - [profile.ephemeral-build] -opt-level = 2 +opt-level = 1 codegen-units = 8 +inherits = "release" diff --git a/app/Cargo.toml b/app/Cargo.toml index 37e0413..d340945 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -44,4 +44,4 @@ once_cell = { workspace = true } # Potentially remove later nanoid = "0.4" -openssl = "*" +openssl = { version = "*", features = ["vendored"] }