diff --git a/Cargo.toml b/Cargo.toml index 99bb731..2f3fd60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ rand = "0.7" Inflector = "0.11" levenshtein = "1.0" # serenity = { version = "0.10", features = ["collector"] } -serenity = { git = "https://github.com/serenity-rs/serenity", branch = "next", features = ["collector"] } +serenity = { git = "https://github.com/serenity-rs/serenity", branch = "next", features = ["collector", "unstable_discord_api"] } sqlx = { version = "0.5", features = ["runtime-tokio-rustls", "macros", "mysql", "bigdecimal", "chrono"]} [dependencies.regex_command_attr] diff --git a/src/commands/info_cmds.rs b/src/commands/info_cmds.rs index bd5456e..6f89730 100644 --- a/src/commands/info_cmds.rs +++ b/src/commands/info_cmds.rs @@ -1,16 +1,22 @@ use regex_command_attr::command; -use serenity::{client::Context, model::channel::Message}; +use serenity::{ + builder::CreateEmbedFooter, + client::Context, + model::{ + channel::Message, + interactions::{Interaction, InteractionResponseType}, + }, +}; use chrono::offset::Utc; use crate::{ command_help, consts::DEFAULT_PREFIX, get_ctx_data, language_manager::LanguageManager, - models::UserData, FrameworkCtx, THEME_COLOR, + models::CtxGuildData, models::UserData, FrameworkCtx, THEME_COLOR, }; -use crate::models::CtxGuildData; -use serenity::builder::CreateEmbedFooter; +use inflector::Inflector; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; @@ -130,6 +136,137 @@ async fn help(ctx: &Context, msg: &Message, args: String) { } } +pub async fn help_interaction(ctx: &Context, interaction: Interaction) { + async fn default_help( + ctx: &Context, + interaction: Interaction, + lm: Arc, + language: &str, + ) { + let desc = lm.get(language, "help/desc").replace("{prefix}", "/"); + let footer = footer(ctx).await; + + interaction + .create_interaction_response(ctx, |response| { + response + .kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|data| { + data.embed(move |e| { + e.title("Help Menu") + .description(desc) + .field( + lm.get(language, "help/setup_title"), + "`lang` `timezone` `meridian`", + true, + ) + .field( + lm.get(language, "help/mod_title"), + "`prefix` `blacklist` `restrict` `alias`", + true, + ) + .field( + lm.get(language, "help/reminder_title"), + "`remind` `interval` `natural` `look` `countdown`", + true, + ) + .field( + lm.get(language, "help/reminder_mod_title"), + "`del` `offset` `pause` `nudge`", + true, + ) + .field( + lm.get(language, "help/info_title"), + "`help` `info` `donate` `clock`", + true, + ) + .field( + lm.get(language, "help/todo_title"), + "`todo` `todos` `todoc`", + true, + ) + .field(lm.get(language, "help/other_title"), "`timer`", true) + .footer(footer) + .color(*THEME_COLOR) + }) + }) + }) + .await + .unwrap(); + } + + async fn command_help( + ctx: &Context, + interaction: Interaction, + lm: Arc, + language: &str, + command_name: &str, + ) { + interaction + .create_interaction_response(ctx, |r| { + r.kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|data| { + data.embed(move |e| { + e.title(format!("{} Help", command_name.to_title_case())) + .description( + lm.get(&language, &format!("help/{}", command_name)) + .replace("{prefix}", "/"), + ) + .footer(|f| { + f.text(concat!( + env!("CARGO_PKG_NAME"), + " ver ", + env!("CARGO_PKG_VERSION") + )) + }) + .color(*THEME_COLOR) + }) + }) + }) + .await + .unwrap(); + } + + let (pool, lm) = get_ctx_data(&ctx).await; + + let language = UserData::language_of(interaction.member.user.id, &pool); + + if let Some(data) = &interaction.data { + if let Some(command_name) = data + .options + .first() + .map(|opt| { + opt.value + .clone() + .map(|inner| inner.as_str().unwrap().to_string()) + }) + .flatten() + { + let framework = ctx + .data + .read() + .await + .get::() + .cloned() + .expect("Could not get FrameworkCtx from data"); + + let matched = framework + .commands + .get(&command_name) + .map(|inner| inner.name); + + if let Some(command_name) = matched { + command_help(ctx, interaction, lm, &language.await, command_name).await + } else { + default_help(ctx, interaction, lm, &language.await).await; + } + } else { + default_help(ctx, interaction, lm, &language.await).await; + } + } else { + default_help(ctx, interaction, lm, &language.await).await; + } +} + #[command] async fn info(ctx: &Context, msg: &Message, _args: String) { let (pool, lm) = get_ctx_data(&ctx).await; @@ -158,6 +295,36 @@ async fn info(ctx: &Context, msg: &Message, _args: String) { .await; } +pub async fn info_interaction(ctx: &Context, interaction: Interaction) { + let (pool, lm) = get_ctx_data(&ctx).await; + + let language = UserData::language_of(&interaction.member, &pool); + let current_user = ctx.cache.current_user(); + let footer = footer(ctx).await; + + let desc = lm + .get(&language.await, "info") + .replacen("{user}", ¤t_user.await.name, 1) + .replace("{default_prefix}", &*DEFAULT_PREFIX) + .replace("{prefix}", "/"); + + interaction + .create_interaction_response(ctx, |response| { + response + .kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|data| { + data.embed(move |e| { + e.title("Info") + .description(desc) + .footer(footer) + .color(*THEME_COLOR) + }) + }) + }) + .await + .unwrap(); +} + #[command] async fn donate(ctx: &Context, msg: &Message, _args: String) { let (pool, lm) = get_ctx_data(&ctx).await; @@ -179,6 +346,30 @@ async fn donate(ctx: &Context, msg: &Message, _args: String) { .await; } +pub async fn donate_interaction(ctx: &Context, interaction: Interaction) { + let (pool, lm) = get_ctx_data(&ctx).await; + + let language = UserData::language_of(&interaction.member, &pool).await; + let desc = lm.get(&language, "donate"); + let footer = footer(ctx).await; + + interaction + .create_interaction_response(ctx, |response| { + response + .kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|data| { + data.embed(move |e| { + e.title("Donate") + .description(desc) + .footer(footer) + .color(*THEME_COLOR) + }) + }) + }) + .await + .unwrap(); +} + #[command] async fn dashboard(ctx: &Context, msg: &Message, _args: String) { let footer = footer(ctx).await; @@ -216,3 +407,30 @@ async fn clock(ctx: &Context, msg: &Message, _args: String) { ) .await; } + +pub async fn clock_interaction(ctx: &Context, interaction: Interaction) { + let (pool, lm) = get_ctx_data(&ctx).await; + + let language = UserData::language_of(&interaction.member, &pool).await; + let timezone = UserData::timezone_of(&interaction.member, &pool).await; + let meridian = UserData::meridian_of(&interaction.member, &pool).await; + + let now = Utc::now().with_timezone(&timezone); + + let clock_display = lm.get(&language, "clock/time"); + + interaction + .create_interaction_response(ctx, |response| { + response + .kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|data| { + data.content(clock_display.replacen( + "{}", + &now.format(meridian.fmt_str()).to_string(), + 1, + )) + }) + }) + .await + .unwrap(); +} diff --git a/src/commands/moderation_cmds.rs b/src/commands/moderation_cmds.rs index cd29fce..dfd4378 100644 --- a/src/commands/moderation_cmds.rs +++ b/src/commands/moderation_cmds.rs @@ -6,8 +6,8 @@ use serenity::{ model::{ channel::ReactionType, channel::{Channel, Message}, - id::ChannelId, - id::RoleId, + id::{ChannelId, RoleId}, + interactions::{Interaction, InteractionResponseType}, }, }; @@ -28,9 +28,6 @@ use crate::{ FrameworkCtx, PopularTimezones, }; -#[cfg(feature = "prefix-cache")] -use crate::PrefixCache; - use crate::models::CtxGuildData; use std::{collections::HashMap, iter, time::Duration}; @@ -218,6 +215,116 @@ async fn timezone(ctx: &Context, msg: &Message, args: String) { } } +pub async fn timezone_interaction(ctx: &Context, interaction: Interaction) { + let (pool, lm) = get_ctx_data(&&ctx).await; + + let mut user_data = UserData::from_user(&interaction.member.user, &ctx, &pool) + .await + .unwrap(); + + let footer_text = lm.get(&user_data.language, "timezone/footer").replacen( + "{timezone}", + &user_data.timezone, + 1, + ); + + if let Some(data) = &interaction.data { + if let Some(timezone) = data + .options + .first() + .map(|inner| { + inner + .value + .clone() + .map(|v| v.as_str().map(|s| s.to_string())) + .flatten() + }) + .flatten() + .map(|tz| tz.parse::().ok()) + .flatten() + { + user_data.timezone = timezone.to_string(); + user_data.commit_changes(&pool).await; + + let now = Utc::now().with_timezone(&user_data.timezone()); + + let content = lm + .get(&user_data.language, "timezone/set_p") + .replacen("{timezone}", &user_data.timezone, 1) + .replacen( + "{time}", + &now.format(user_data.meridian().fmt_str_short()).to_string(), + 1, + ); + + interaction + .create_interaction_response(&ctx, |response| { + response + .kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|data| { + data.embed(|e| { + e.title(lm.get(&user_data.language, "timezone/set_p_title")) + .description(content) + .color(*THEME_COLOR) + .footer(|f| { + f.text( + lm.get(&user_data.language, "timezone/footer") + .replacen("{timezone}", &user_data.timezone, 1), + ) + }) + }) + }) + }) + .await + .unwrap(); + } else { + let content = lm + .get(&user_data.language, "timezone/no_argument") + .replace("{prefix}", "/"); + + let popular_timezones = ctx + .data + .read() + .await + .get::() + .cloned() + .unwrap(); + + let popular_timezones_iter = popular_timezones.iter().map(|t| { + ( + t.to_string(), + format!( + "🕗 `{}`", + Utc::now() + .with_timezone(t) + .format(user_data.meridian().fmt_str_short()) + .to_string() + ), + true, + ) + }); + + interaction + .create_interaction_response(&ctx, |response| { + response + .kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|data| { + data.embed(|e| { + e.title(lm.get(&user_data.language, "timezone/no_argument_title")) + .description(content) + .color(*THEME_COLOR) + .fields(popular_timezones_iter) + .footer(|f| f.text(footer_text)) + .url("https://gist.github.com/JellyWX/913dfc8b63d45192ad6cb54c829324ee") + }) + }) + }) + .await + .unwrap(); + } + } +} + #[command("meridian")] async fn change_meridian(ctx: &Context, msg: &Message, args: String) { let (pool, lm) = get_ctx_data(&ctx).await; @@ -393,15 +500,44 @@ async fn language(ctx: &Context, msg: &Message, args: String) { } } +pub async fn language_interaction(ctx: &Context, interaction: Interaction) { + let (pool, lm) = get_ctx_data(&ctx).await; + + let mut user_data = UserData::from_user(&interaction.member.user, &ctx, &pool) + .await + .unwrap(); + + if let Some(data) = &interaction.data { + let option = &data.options[0]; + + user_data.language = option.value.clone().unwrap().as_str().unwrap().to_string(); + user_data.commit_changes(&pool).await; + + interaction + .create_interaction_response(ctx, |response| { + response + .kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|data| { + data.embed(|e| { + e.title(lm.get(&user_data.language, "lang/set_p_title")) + .color(*THEME_COLOR) + .description(lm.get(&user_data.language, "lang/set_p")) + }) + }) + }) + .await + .unwrap(); + } +} + #[command] #[supports_dm(false)] #[permission_level(Restricted)] async fn prefix(ctx: &Context, msg: &Message, args: String) { let (pool, lm) = get_ctx_data(&ctx).await; - let mut guild_data = GuildData::from_guild(msg.guild(&ctx).await.unwrap(), &pool) - .await - .unwrap(); + let guild_data = ctx.guild_data(msg.guild_id.unwrap()).await.unwrap(); + let language = UserData::language_of(&msg.author, &pool).await; if args.len() > 5 { @@ -415,23 +551,62 @@ async fn prefix(ctx: &Context, msg: &Message, args: String) { .say(&ctx, lm.get(&language, "prefix/no_argument")) .await; } else { - guild_data.prefix = args; + guild_data.write().await.prefix = args; + guild_data.read().await.commit_changes(&pool).await; - #[cfg(feature = "prefix-cache")] - let prefix_cache = ctx.data.read().await.get::().cloned().unwrap(); - #[cfg(feature = "prefix-cache")] - prefix_cache.insert(msg.guild_id.unwrap(), guild_data.prefix.clone()); - - guild_data.commit_changes(&pool).await; - - let content = - lm.get(&language, "prefix/success") - .replacen("{prefix}", &guild_data.prefix, 1); + let content = lm.get(&language, "prefix/success").replacen( + "{prefix}", + &guild_data.read().await.prefix, + 1, + ); let _ = msg.channel_id.say(&ctx, content).await; } } +pub async fn prefix_interaction(ctx: &Context, interaction: Interaction) { + let (pool, lm) = get_ctx_data(&ctx).await; + + let guild_data = ctx.guild_data(interaction.guild_id).await.unwrap(); + + let language = UserData::language_of(&interaction.member, &pool).await; + + if let Some(data) = &interaction.data { + let option = &data.options[0]; + + let new_prefix = option.value.clone().unwrap().as_str().unwrap().to_string(); + + if new_prefix.len() > 5 { + interaction + .create_interaction_response(ctx, |response| { + response + .kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|data| { + data.content(lm.get(&language, "prefix/too_long")) + }) + }) + .await + .unwrap(); + } else { + guild_data.write().await.prefix = new_prefix.clone(); + guild_data.read().await.commit_changes(&pool).await; + + let content = lm + .get(&language, "prefix/success") + .replacen("{prefix}", &new_prefix, 1); + + interaction + .create_interaction_response(ctx, |response| { + response + .kind(InteractionResponseType::ChannelMessageWithSource) + .interaction_response_data(|data| data.content(content)) + }) + .await + .unwrap(); + } + } +} + #[command] #[supports_dm(false)] #[permission_level(Restricted)] diff --git a/src/main.rs b/src/main.rs index ad03cb5..1dbc7f8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -47,6 +47,9 @@ use dashmap::DashMap; use tokio::sync::RwLock; use chrono_tz::Tz; +use serenity::model::interactions::{Interaction, InteractionType}; +use serenity::model::prelude::ApplicationCommandOptionType; +use std::collections::HashSet; struct GuildDataCache; @@ -194,6 +197,29 @@ DELETE FROM guilds WHERE guild = ? .await .unwrap(); } + + async fn interaction_create(&self, ctx: Context, interaction: Interaction) { + match interaction.kind { + InteractionType::ApplicationCommand => { + if let Some(data) = &interaction.data { + match data.name.as_str() { + "timezone" => { + moderation_cmds::timezone_interaction(&ctx, interaction).await + } + "lang" => moderation_cmds::language_interaction(&ctx, interaction).await, + "prefix" => moderation_cmds::prefix_interaction(&ctx, interaction).await, + "help" => info_cmds::help_interaction(&ctx, interaction).await, + "info" => info_cmds::info_interaction(&ctx, interaction).await, + "donate" => info_cmds::donate_interaction(&ctx, interaction).await, + "clock" => info_cmds::clock_interaction(&ctx, interaction).await, + _ => {} + } + } + } + + _ => {} + } + } } #[tokio::main] @@ -280,6 +306,15 @@ async fn main() -> Result<(), Box> { .await .expect("Error occurred creating client"); + let language_manager = Arc::new( + LanguageManager::from_compiled(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/", + env!("STRINGS_FILE") + ))) + .unwrap(), + ); + { let guild_data_cache = dashmap::DashMap::new(); @@ -289,13 +324,6 @@ async fn main() -> Result<(), Box> { .await .unwrap(); - let language_manager = LanguageManager::from_compiled(include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/assets/", - env!("STRINGS_FILE") - ))) - .unwrap(); - let popular_timezones = sqlx::query!( "SELECT timezone FROM users GROUP BY timezone ORDER BY COUNT(timezone) DESC LIMIT 21" ) @@ -314,9 +342,16 @@ async fn main() -> Result<(), Box> { data.insert::(Arc::new(popular_timezones)); data.insert::(Arc::new(reqwest::Client::new())); data.insert::(framework_arc.clone()); - data.insert::(Arc::new(language_manager)) + data.insert::(language_manager.clone()) } + create_interactions( + &client.cache_and_http, + framework_arc.clone(), + language_manager.clone(), + ) + .await; + if let Ok((Some(lower), Some(upper))) = env::var("SHARD_RANGE").map(|sr| { let mut split = sr .split(',') @@ -360,6 +395,135 @@ async fn main() -> Result<(), Box> { Ok(()) } +async fn create_interactions( + cache_http: impl CacheHttp, + framework: Arc, + lm: Arc, +) { + let http = cache_http.http(); + let app_id = { + let app_info = http.get_current_application_info().await.unwrap(); + + app_info.id.as_u64().to_owned() + }; + + if let Some(guild_id) = env::var("TEST_GUILD") + .map(|i| i.parse::().ok().map(|u| GuildId(u))) + .ok() + .flatten() + { + guild_id + .create_application_command(&http, app_id, |command| { + command + .name("timezone") + .description("Select your local timezone. Do `/timezone` for more information") + .create_interaction_option(|option| { + option + .name("region") + .description("Name of your time region") + .kind(ApplicationCommandOptionType::String) + }) + }) + .await + .unwrap(); + + guild_id + .create_application_command(&http, app_id, |command| { + command + .name("lang") + .description("Select your language") + .create_interaction_option(|option| { + option + .name("language") + .description("Name of supported language you wish to use") + .kind(ApplicationCommandOptionType::String) + .required(true); + + for (code, language) in lm.all_languages() { + option.add_string_choice(language, code); + } + + option + }) + }) + .await + .unwrap(); + + guild_id + .create_application_command(&http, app_id, |command| { + command + .name("prefix") + .description("Select the prefix for normal commands") + .create_interaction_option(|option| { + option + .name("prefix") + .description("New prefix to use") + .kind(ApplicationCommandOptionType::String) + .required(true) + }) + }) + .await + .unwrap(); + + guild_id + .create_application_command(&http, app_id, |command| { + command + .name("info") + .description("Get information about the bot") + }) + .await + .unwrap(); + + guild_id + .create_application_command(&http, app_id, |command| { + command + .name("donate") + .description("View information about the Patreon") + }) + .await + .unwrap(); + + guild_id + .create_application_command(&http, app_id, |command| { + command + .name("clock") + .description("View the current time in your timezone") + }) + .await + .unwrap(); + + guild_id + .create_application_command(&http, app_id, |command| { + command + .name("help") + .description("Get details about commands. Do `/help` to view all commands") + .create_interaction_option(|option| { + option + .name("command") + .description("Name of the command to view help for") + .kind(ApplicationCommandOptionType::String); + + let mut command_set = HashSet::new(); + command_set.insert("help"); + command_set.insert("info"); + command_set.insert("donate"); + + for (_, command) in &framework.commands { + if !command_set.contains(command.name) { + option.add_string_choice(&command.name, &command.name); + + command_set.insert(command.name); + } + } + + option + }) + }) + .await + .unwrap(); + } +} + pub async fn check_subscription(cache_http: impl CacheHttp, user_id: impl Into) -> bool { if let Some(subscription_guild) = *CNC_GUILD { let guild_member = GuildId(subscription_guild)