diff --git a/Cargo.lock b/Cargo.lock index e7c71e6..f884dd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,17 +124,6 @@ dependencies = [ "unicode-width", ] -[[package]] -name = "command_attr" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d999d4e7731150ee14aee8f619c7a9aa9a4385bca0606c4fa95aa2f36a05d9a" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "core-foundation" version = "0.9.3" @@ -223,6 +212,41 @@ dependencies = [ "syn", ] +[[package]] +name = "darling" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0dd3cd20dc6b5a876612a6e5accfe7f3dd883db6d07acfbf14c128f61550dfa" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a784d2ccaf7c98501746bf0be29b2022ba41fd62a2e622af997a03e9f972859f" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7618812407e9402654622dd402b0a89dff9ba93badd6540781526117b92aab7e" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "dashmap" version = "5.4.0" @@ -237,6 +261,17 @@ dependencies = [ "serde", ] +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "deunicode" version = "0.4.3" @@ -551,6 +586,12 @@ dependencies = [ "cxx-build", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "0.3.0" @@ -598,10 +639,10 @@ version = "0.1.0" dependencies = [ "dotenv", "fs_extra", + "poise", "rand", "reqwest", "serde_json", - "serenity", "slug", "tokio", "tracing", @@ -624,12 +665,6 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" -[[package]] -name = "levenshtein" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" - [[package]] name = "libc" version = "0.2.137" @@ -877,6 +912,37 @@ version = "0.3.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ac9a59f73473f1b8d852421e59e64809f025994837ef743615c6d0c5b305160" +[[package]] +name = "poise" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee439543df35482730552e7c9ed0c45a5f1d521548e6c0249967c4ba8828f60" +dependencies = [ + "async-trait", + "derivative", + "futures-core", + "futures-util", + "log", + "once_cell", + "parking_lot", + "poise_macros", + "regex", + "serenity", + "tokio", +] + +[[package]] +name = "poise_macros" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d21213ff7aeef5ab69729a5cddfb351a84a9bf3dadf9f470032440d43746c2" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ppv-lite86" version = "0.2.17" @@ -940,6 +1006,21 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076559ef8e241f2ae3479e36f97bd5741c0330689e217ad51ce2c76808b868a" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.6.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848" + [[package]] name = "remove_dir_all" version = "0.5.3" @@ -1029,6 +1110,12 @@ dependencies = [ "base64", ] +[[package]] +name = "rustversion" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97477e48b4cf8603ad5f7aaf897467cf42ab4218a38ef76fb14c2d6773a6d6a8" + [[package]] name = "ryu" version = "1.0.11" @@ -1156,26 +1243,23 @@ dependencies = [ "bytes", "cfg-if", "chrono", - "command_attr", "dashmap", "flate2", "futures", - "levenshtein", "mime", "mime_guess", "parking_lot", "percent-encoding", "reqwest", + "rustversion", "serde", "serde-value", "serde_json", - "static_assertions", "time", "tokio", "tracing", "typemap_rev", "url", - "uwl", ] [[package]] @@ -1248,10 +1332,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" [[package]] -name = "static_assertions" -version = "1.1.0" +name = "strsim" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" [[package]] name = "syn" @@ -1593,12 +1677,6 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" -[[package]] -name = "uwl" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4bf03e0ca70d626ecc4ba6b0763b934b6f2976e8c744088bb3c1d646fbb1ad0" - [[package]] name = "valuable" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 8a32197..7346a45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,11 +15,8 @@ reqwest = "0.11" slug = "0.1" unicode_hfwidth = "0.2" fs_extra = "1.2" +poise = "0.4" [dependencies.tokio] version = "1.0" features = ["macros", "signal", "rt-multi-thread"] - -[dependencies.serenity] -version = "0.11" -features = ["cache", "framework", "standard_framework", "rustls_backend"] diff --git a/src/commands/challenge.rs b/src/commands/challenge.rs index 568ef44..74ae5fa 100644 --- a/src/commands/challenge.rs +++ b/src/commands/challenge.rs @@ -1,20 +1,20 @@ use crate::utils::*; use serde_json::Map; -use serenity::framework::standard::{macros::command, Args, CommandResult}; -use serenity::http::typing::Typing; -use serenity::model::prelude::*; -use serenity::prelude::*; + +use crate::serenity; +use crate::Error; +use crate::{Context, PrefixContext}; +use poise::command; + use slug::slugify; -use std::env; use std::fs; use std::fs::File; use std::io::Write; use std::path::Path; -#[command] -async fn challenge(ctx: &Context, msg: &Message) -> CommandResult { - msg.reply( - &ctx.http, +#[command(prefix_command, slash_command, description_localized("en-US", "View the latest handwriting challenge info."))] +pub async fn challenge(ctx: Context<'_>) -> Result<(), Error> { + ctx.say( format!( "Tegaki Tuesday #{n}: ", n = get_challenge_number() @@ -24,27 +24,26 @@ async fn challenge(ctx: &Context, msg: &Message) -> CommandResult { Ok(()) } -#[command] -async fn submit(ctx: &Context, msg: &Message) -> CommandResult { +#[command(prefix_command, broadcast_typing, description_localized("en-US", "Submit to the latest handwriting challenge."))] +pub async fn submit(ctx: PrefixContext<'_>) -> Result<(), Error> { // TODO: The code for this command needs to be refactored, // there are large duplicated sections that need to be merged somehow. let guild_data = get_guild_data(); - let guild = msg.guild_id.unwrap().as_u64().to_string(); + let guild = ctx.msg.guild_id.unwrap().as_u64().to_string(); if !guild_data.contains_key(&guild) || !&guild_data[&guild] .as_object() .unwrap() .contains_key("submissionChannel") { - msg.reply(&ctx.http, "Submissions aren't enabled for this server yet.") - .await?; + ctx.msg.reply(&ctx.discord.http, "Submissions aren't enabled for this server yet.").await?; return Ok(()); } let current_guild_data = &guild_data[&guild].as_object().unwrap(); let submission_channel = current_guild_data["submissionChannel"].as_str().unwrap(); - if submission_channel != &msg.channel_id.as_u64().to_string() { - msg.reply( - &ctx.http, + if submission_channel != ctx.msg.channel_id.as_u64().to_string() { + ctx.msg.reply( + &ctx.discord.http, format!( "Sorry, submissions aren't permitted here. Please go to <#{}>. Thanks!", guild @@ -53,12 +52,10 @@ async fn submit(ctx: &Context, msg: &Message) -> CommandResult { .await?; return Ok(()); } - if msg.attachments.len() == 0 { - msg.reply(&ctx.http, "Please attach at least one image.") - .await?; + if ctx.msg.attachments.len() == 0 { + ctx.msg.reply(&ctx.discord.http, "Please attach at least one image.").await?; return Ok(()); } - let typing = Typing::start(ctx.http.clone(), *msg.channel_id.as_u64()).unwrap(); let challenge_number = get_challenge_number(); let submission_images_dir = get_submission_images_dir(); @@ -71,10 +68,10 @@ async fn submit(ctx: &Context, msg: &Message) -> CommandResult { let mut invalid_types = false; let mut requires_rebuild = false; for (i, submission) in submission_data.iter_mut().enumerate() { - if is_matching_submission(&submission, &msg) { + if is_matching_submission(&submission, &ctx.msg.author) { existing_submitter = true; let mut images = submission["images"].as_array_mut().unwrap().clone(); - for attachment in msg.attachments.iter() { + for attachment in ctx.msg.attachments.iter() { let extension; if let Some(content_type) = &attachment.content_type { if content_type == "image/png" { @@ -93,8 +90,8 @@ async fn submit(ctx: &Context, msg: &Message) -> CommandResult { let file_name = format!( "{}-{}-{}-{}.{}", i + 1, - slugify(&msg.author.name), - msg.author.discriminator, + slugify(&ctx.msg.author.name), + ctx.msg.author.discriminator, images.len() + 1, extension ); @@ -113,10 +110,10 @@ async fn submit(ctx: &Context, msg: &Message) -> CommandResult { let mut submitter_data = Map::new(); submitter_data.insert( String::from("username"), - format!("{}#{}", msg.author.name, msg.author.discriminator).into(), + format!("{}#{}", ctx.msg.author.name, ctx.msg.author.discriminator).into(), ); let mut images: Vec = Vec::new(); - for attachment in msg.attachments.iter() { + for attachment in ctx.msg.attachments.iter() { let extension; if let Some(content_type) = &attachment.content_type { if content_type == "image/png" { @@ -135,8 +132,8 @@ async fn submit(ctx: &Context, msg: &Message) -> CommandResult { let file_name = format!( "{}-{}-{}{}.{}", submission_data.len() + 1, - slugify(&msg.author.name), - msg.author.discriminator, + slugify(&ctx.msg.author.name), + ctx.msg.author.discriminator, if images.len() == 0 { String::from("") } else { @@ -153,7 +150,7 @@ async fn submit(ctx: &Context, msg: &Message) -> CommandResult { submitter_data.insert(String::from("images"), images.into()); submitter_data.insert( String::from("id"), - msg.author.id.as_u64().to_string().into(), + ctx.msg.author.id.as_u64().to_string().into(), ); submission_data.push(submitter_data.into()); } @@ -169,18 +166,17 @@ async fn submit(ctx: &Context, msg: &Message) -> CommandResult { } else if invalid_types { message.push_str("Sorry, your submission could not be uploaded; only **.png**, **.jpg**, and **.jpeg** files are permitted."); } - let _ = typing.stop(); - msg.reply(&ctx.http, message).await?; + ctx.msg.reply(&ctx.discord.http, message).await?; Ok(()) } -#[command] -async fn images(ctx: &Context, msg: &Message) -> CommandResult { +#[command(prefix_command, slash_command, description_localized("en-US", "List images in your current submission, if available."))] +pub async fn images(ctx: Context<'_>) -> Result<(), Error> { let submission_data = get_current_submission_data(); let images: Vec = { let mut images = Vec::new(); for submission in submission_data.iter() { - if is_matching_submission(&submission, &msg) { + if is_matching_submission(&submission, &ctx.author()) { for image in submission["images"].as_array().unwrap().iter() { images.push(String::from(image.as_str().unwrap())); } @@ -191,8 +187,7 @@ async fn images(ctx: &Context, msg: &Message) -> CommandResult { }; let challenge_number = get_challenge_number(); if images.len() == 0 { - msg.reply( - &ctx.http, + ctx.say( format!( "You haven't submitted anything for Tegaki Tuesday #{}.", challenge_number @@ -213,41 +208,29 @@ async fn images(ctx: &Context, msg: &Message) -> CommandResult { image )); } - msg.reply(&ctx.http, message).await?; + ctx.say(message).await?; Ok(()) } -#[command] -// imageDelete instead of image_delete to keep command naming from Python version -#[allow(non_snake_case)] -async fn imageDelete(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { - let number; - match args.single::() { - Ok(value) => number = value, - Err(_) => { - msg.reply(&ctx.http, format!("Please provide the image number you want to delete. You can get a list of your submitted images using `{}images`.", env::var("PREFIX").unwrap())).await?; - return Ok(()); - } - } +#[command(prefix_command, slash_command, description_localized("en-US", "Delete images from your current submission using image numbers from the images command."))] +pub async fn imagedelete( + ctx: Context<'_>, + number: i32, +) -> Result<(), Error> { if number < 1 { - msg.reply( - &ctx.http, - "That isn't a valid image number. Image numbers start at 1.", - ) - .await?; + ctx.say("That isn't a valid image number. Image numbers start at 1.").await?; return Ok(()); } let challenge_number = get_challenge_number(); let mut submission_data = get_current_submission_data(); for (i, submission) in submission_data.iter_mut().enumerate() { - if !is_matching_submission(&submission, &msg) { + if !is_matching_submission(&submission, &ctx.author()) { continue; } let mut images = submission["images"].as_array().unwrap().clone(); let image_count = images.len(); if image_count < number.try_into().unwrap() { - msg.reply( - &ctx.http, + ctx.say( if image_count == 0 { // This is an edge case that should never happen. // In this scenario, there is submission data with an empty image list. @@ -296,11 +279,12 @@ async fn imageDelete(ctx: &Context, msg: &Message, mut args: Args) -> CommandRes for (j, image) in images.iter_mut().enumerate() { let old = image.as_str().unwrap(); let from = format!("{}/{}", submission_images_dir, old); + let author = ctx.author(); let new = format!( "{}-{}-{}{}.{}", i + 1, - slugify(&msg.author.name), - msg.author.discriminator, + slugify(&author.name), + author.discriminator, if j == 0 { String::from("") } else { @@ -317,11 +301,10 @@ async fn imageDelete(ctx: &Context, msg: &Message, mut args: Args) -> CommandRes } set_submission_data(submission_data); rebuild_site(); - msg.reply(&ctx.http, message).await?; + ctx.say(message).await?; return Ok(()); } - msg.reply( - &ctx.http, + ctx.say( format!( "You haven't submitted anything for Tegaki Tuesday #{}.", challenge_number @@ -331,11 +314,15 @@ async fn imageDelete(ctx: &Context, msg: &Message, mut args: Args) -> CommandRes Ok(()) } -#[command] -#[allow(non_snake_case)] -async fn suggest(ctx: &Context, msg: &Message, args: Args) -> CommandResult { +// TODO: make also slash command +#[command(prefix_command, description_localized("en-US", "Make a suggestion for future challenge prompts!"))] +pub async fn suggest( + ctx: PrefixContext<'_>, + #[description = "Suggestion text. Please include passage and source."] + suggestion: String, +) -> Result<(), Error> { let guild_data = get_guild_data(); - let channel = ChannelId( + let channel = serenity::ChannelId( guild_data["suggestionChannel"] .as_str() .unwrap() @@ -344,33 +331,32 @@ async fn suggest(ctx: &Context, msg: &Message, args: Args) -> CommandResult { ); // User::accent_colour is only available via the REST API // If we just do msg.author.accent_colour here, we will get None - let accent_color = ctx - .http - .get_user(*msg.author.id.as_u64()) + let author = &ctx.msg.author; + let accent_color = ctx.discord.http + .get_user(*author.id.as_u64()) .await .unwrap() .accent_colour; channel - .send_message(&ctx.http, |m| { + .send_message(&ctx.discord.http, |m| { m.allowed_mentions(|am| { am.empty_parse(); am }); m.embed(|e| { - let username = format!("{}#{}", msg.author.name, msg.author.discriminator); + let username = format!("{}#{}", author.name, author.discriminator); e.title("New suggestion"); e.description(format!( - "{}\n\n[See original message]({}) ({})", - args.rest(), - msg.link(), - msg.guild(&ctx).unwrap().name + "{}\n\n[See original message]({})", + suggestion, + ctx.msg.link(), )); - let mut author = serenity::builder::CreateEmbedAuthor::default(); - author - .icon_url(get_avatar(&msg.author)) + let mut embed_author = serenity::builder::CreateEmbedAuthor::default(); + embed_author + .icon_url(get_avatar(&author)) .name(username) - .url(format!("https://discord.com/users/{}", msg.author.id)); - e.set_author(author); + .url(format!("https://discord.com/users/{}", author.id)); + e.set_author(embed_author); if let Some(accent_color) = accent_color { e.color(accent_color); } @@ -380,6 +366,6 @@ async fn suggest(ctx: &Context, msg: &Message, args: Args) -> CommandResult { }) .await .unwrap(); - msg.reply(&ctx.http, "Suggestion sent! Thank you for making a suggestion. If it is chosen to be used in a future challenge, you will be mentioned in the challenge description!").await?; + ctx.msg.reply(&ctx.discord.http, "Suggestion sent! Thank you for making a suggestion. If it is chosen to be used in a future challenge, you will be mentioned in the challenge description!").await?; Ok(()) } diff --git a/src/commands/kanji.rs b/src/commands/kanji.rs index e292bb6..01961f1 100644 --- a/src/commands/kanji.rs +++ b/src/commands/kanji.rs @@ -1,11 +1,16 @@ use crate::utils::*; -use serenity::framework::standard::{macros::command, Args, CommandResult}; -use serenity::model::prelude::*; -use serenity::prelude::*; -#[command] -async fn i(ctx: &Context, msg: &Message, args: Args) -> CommandResult { - let input = args.rest(); +use crate::serenity; +use crate::Error; +use crate::Context; +use poise::command; + +#[command(slash_command, prefix_command, description_localized("en-US", "Get category info and links to Jisho for character(s), with stroke order for single characters."))] +pub async fn i( + ctx: Context<'_>, + #[description = "Input kanji to get info for"] + input: String, +) -> Result<(), Error> { let chars = input.chars(); let mut message = String::from(""); let mut covered_chars: Vec = Vec::new(); @@ -40,9 +45,7 @@ async fn i(ctx: &Context, msg: &Message, args: Args) -> CommandResult { if skipped_chars == 1 { "" } else { "s" } )); } - msg.channel_id - .send_message(&ctx.http, |m| { - m.reference_message(msg); + ctx.send(|m| { m.allowed_mentions(|am| { am.empty_parse(); am @@ -69,50 +72,61 @@ async fn i(ctx: &Context, msg: &Message, args: Args) -> CommandResult { Ok(()) } -#[command] -async fn joyo(ctx: &Context, msg: &Message, args: Args) -> CommandResult { - random_kanji("JOYO", ctx, msg, args).await +#[command(slash_command, prefix_command, description_localized("en-US", "Random Jōyō kanji"))] +pub async fn joyo( + ctx: Context<'_>, +) -> Result<(), Error> { + random_kanji("JOYO", ctx, None).await } -#[command] -async fn jinmeiyo(ctx: &Context, msg: &Message, args: Args) -> CommandResult { - random_kanji("JINMEIYO", ctx, msg, args).await +#[command(slash_command, prefix_command, description_localized("en-US", "Random Jinmeiyō kanji"))] +pub async fn jinmeiyo( + ctx: Context<'_>, +)-> Result<(), Error> { + random_kanji("JINMEIYO", ctx, None).await } -#[command] -async fn kyoiku(ctx: &Context, msg: &Message, args: Args) -> CommandResult { - random_kanji("KYOIKU", ctx, msg, args).await +#[command(slash_command, prefix_command, description_localized("en-US", "Random Kyōiku kanji"))] +pub async fn kyoiku( + ctx: Context<'_>, + #[description = "Kyōiku subcategory. GRADE1, GRADE2, GRADE3, GRADE4, GRADE5, GRADE6, or ALL."] + subcategory: Option, +) -> Result<(), Error> { + random_kanji("KYOIKU", ctx, subcategory).await } -#[command] -async fn jlpt(ctx: &Context, msg: &Message, args: Args) -> CommandResult { - random_kanji("JLPT", ctx, msg, args).await +#[command(slash_command, prefix_command, description_localized("en-US", "Random JLPT kanji"))] +pub async fn jlpt( + ctx: Context<'_>, + #[description = "JLPT subcategory. N1, N2, N3, N4, N5, or ALL"] + subcategory: Option +) -> Result<(), Error> { + random_kanji("JLPT", ctx, subcategory).await } -#[command] -async fn hyogai(ctx: &Context, msg: &Message, args: Args) -> CommandResult { - random_kanji("HYOGAI", ctx, msg, args).await +#[command(slash_command, prefix_command, description_localized("en-US", "Random Hyōgai kanji"))] +pub async fn hyogai( + ctx: Context<'_>, + #[description = "Hyōgai subcategory. ELEMENTS, MAIN, or ALL."] + subcategory: Option, +) -> Result<(), Error> { + random_kanji("HYOGAI", ctx, subcategory).await } -#[command] -async fn so(ctx: &Context, msg: &Message, args: Args) -> CommandResult { +#[command(slash_command, prefix_command, description_localized("en-US", "Get stroke order diagrams for character(s), maximum 4"))] +pub async fn so( + ctx: Context<'_>, + #[description = "Input characters to get stroke order for"] + text: String, +) -> Result<(), Error> { const MAX_CHARS: i32 = 4; - let text = args.rest(); - if text.is_empty() { - msg.reply( - &ctx.http, - "Please provide some text you want the stroke order for.", - ) - .await?; - return Ok(()); - } let mut displayed_characters: Vec = Vec::new(); let mut displayed_character_count = 0; for character in text.chars() { if displayed_character_count >= MAX_CHARS { - msg.channel_id + ctx.channel_id() .say( - &ctx.http, + &ctx.discord().http, ":warning: Maximum number of stroke order diagrams per command reached.", ) .await?; @@ -124,7 +138,7 @@ async fn so(ctx: &Context, msg: &Message, args: Args) -> CommandResult { // Don't show same character twice displayed_characters.push(character); displayed_character_count += 1; - display_kanji(&ctx, &msg, character, "").await?; + display_kanji(ctx, character, "").await?; } Ok(()) } diff --git a/src/commands/meta.rs b/src/commands/meta.rs index f9432e2..c2fb2cb 100644 --- a/src/commands/meta.rs +++ b/src/commands/meta.rs @@ -1,13 +1,14 @@ -use serenity::framework::standard::macros::hook; -use serenity::framework::standard::{macros::command, CommandResult}; -use serenity::model::prelude::*; -use serenity::prelude::*; +use crate::Error; +use crate::Context; +use poise::command; use std::env; -#[command] -async fn help(ctx: &Context, msg: &Message) -> CommandResult { - let prefix = env::var("PREFIX").unwrap(); +// TODO: Implement proper help text for command-specific help, +// see https://github.com/kangalioo/poise/blob/90ac24a8ef621ec6dc3fc452762dc9cfa144f693/examples/framework_usage/main.rs#L18-L38 +#[command(prefix_command, slash_command, description_localized("en-US", "Get help for the 字ちゃん Tegaki Tuesday bot"))] +pub async fn help(ctx: Context<'_>) -> Result<(), Error> { + let p = env::var("PREFIX").unwrap(); let message = format!( "<:jichan:943336845637480478> Hello! I'm 字【じ】ちゃん (Ji-chan), the Tegaki Tuesday bot (and mascot!). For more information about the challenge, check out the website at @@ -26,18 +27,8 @@ __**Kanji 漢字**__ :game_die: `{p}joyo` Random Jōyō kanji :game_die: `{p}kyoiku ` Random Kyōiku kanji :game_die: `{p}jlpt ` Random JLPT kanji -:game_die: `{p}hyogai ` Random Hyōgai kanji", - p = prefix +:game_die: `{p}hyogai ` Random Hyōgai kanji" ); - msg.reply(&ctx.http, message).await?; + ctx.say(message).await?; Ok(()) } - -#[hook] -pub async fn unrecognised_command_hook( - ctx: &Context, - msg: &Message, - unrecognised_command_name: &str, -) { - msg.reply(&ctx.http, &format!("I don't understand the command '{}'. For a list of commands, see `{}help`. Commands are case-sensitive.", unrecognised_command_name, env::var("PREFIX").unwrap())).await.unwrap(); -} diff --git a/src/commands/owner.rs b/src/commands/owner.rs index 8a68e27..508541e 100644 --- a/src/commands/owner.rs +++ b/src/commands/owner.rs @@ -1,38 +1,26 @@ -use serenity::framework::standard::{macros::command, Args, CommandResult}; -use serenity::model::prelude::*; -use serenity::prelude::*; +#![allow(non_snake_case)] + +use crate::Context; +use crate::Error; +use poise::command; use serde_json::json; use std::env; use crate::utils::*; -use crate::ShardManagerContainer; - -#[command] -#[owners_only] -async fn sleep(ctx: &Context, msg: &Message) -> CommandResult { - let data = ctx.data.read().await; - - if let Some(manager) = data.get::() { - msg.reply(ctx, "Good night!").await?; - manager.lock().await.shutdown_all().await; - } else { - msg.reply(ctx, "There was a problem getting the shard manager") - .await?; - - return Ok(()); - } +#[command(prefix_command, hide_in_help, owners_only)] +pub async fn sleep(ctx: Context<'_>) -> Result<(), crate::Error> { + ctx.say("Good night!").await?; + ctx.framework().shard_manager.lock().await.shutdown_all().await; Ok(()) } -#[command] -#[owners_only] -#[allow(non_snake_case)] -async fn setSubmissionChannel(ctx: &Context, msg: &Message) -> CommandResult { +#[command(prefix_command, hide_in_help, owners_only)] +pub async fn setsubmissionchannel(ctx: Context<'_>) -> Result<(), Error> { let mut guild_data = get_guild_data(); - let guild = msg.guild_id.unwrap().as_u64().to_string(); - let channel = msg.channel_id.as_u64().to_string(); + let guild = ctx.guild_id().unwrap().as_u64().to_string(); + let channel = ctx.channel_id().as_u64().to_string(); if guild_data.contains_key(&guild) { let mut current_guild_data = guild_data[&guild].as_object().unwrap().clone(); if current_guild_data.contains_key("submissionChannel") { @@ -45,23 +33,21 @@ async fn setSubmissionChannel(ctx: &Context, msg: &Message) -> CommandResult { guild_data.insert(guild, json!({ "submissionChannel": channel })); } set_guild_data(guild_data); - msg.reply( - &ctx.http, + ctx.say( format!( "Submission channel for **{}** set to <#{}>.", - msg.guild(&ctx).unwrap().name, - msg.channel_id + ctx.guild().unwrap().name, + ctx.channel_id() ), ) .await?; Ok(()) } -#[command] -#[owners_only] -#[allow(non_snake_case)] -async fn setSuggestionChannel(ctx: &Context, msg: &Message) -> CommandResult { - let channel = msg.channel_id.as_u64().to_string(); +#[command(prefix_command, hide_in_help, owners_only)] +pub async fn setsuggestionchannel(ctx: Context<'_>) -> Result<(), Error> { + let channel = ctx.channel_id().as_u64().to_string(); + let message = format!("Submission channel set to <#{}>.", channel); let mut guild_data = get_guild_data(); if guild_data.contains_key("submissionChannel") { guild_data["suggestionChannel"] = channel.into(); @@ -69,29 +55,19 @@ async fn setSuggestionChannel(ctx: &Context, msg: &Message) -> CommandResult { guild_data.insert(String::from("suggestionChannel"), channel.into()); } set_guild_data(guild_data); - msg.reply( - &ctx.http, - format!("Submission channel set to <#{}>.", msg.channel_id), - ) - .await?; + ctx.say(message).await?; Ok(()) } -#[command] -#[owners_only] +#[command(prefix_command, hide_in_help, owners_only)] #[allow(non_snake_case)] -async fn setAnnouncementRole(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult { - let role; - match args.single::() { - Ok(id) => role = id.to_string(), - Err(_) => { - msg.reply(&ctx.http, "Please provide an announcement role ID.") - .await?; - return Ok(()); - } - } +pub async fn setannouncementrole( + ctx: Context<'_>, + #[description = "Announcement role ID"] + role: u64, +) -> Result<(), Error> { let mut guild_data = get_guild_data(); - let guild = msg.guild_id.unwrap().as_u64().to_string(); + let guild = ctx.guild_id().unwrap().as_u64().to_string(); if guild_data.contains_key(&guild) { let mut current_guild_data = guild_data[&guild].as_object().unwrap().clone(); if current_guild_data.contains_key("announcementRole") { @@ -104,20 +80,21 @@ async fn setAnnouncementRole(ctx: &Context, msg: &Message, mut args: Args) -> Co guild_data.insert(guild, json!({ "announcementRole": role })); } set_guild_data(guild_data); - msg.reply(&ctx.http, "Announcement role set.").await?; + ctx.say("Announcement role set.").await?; Ok(()) } -#[command] -#[owners_only] -async fn announce(ctx: &Context, msg: &Message, args: Args) -> CommandResult { - send(ctx, msg, args.rest(), true, false).await +#[command(prefix_command, hide_in_help, owners_only)] +pub async fn announce( + ctx: Context<'_>, + #[description = "Announcement text"] + announcement: String, +) -> Result<(), Error> { + send(ctx, &announcement, true, false).await } -#[command] -#[owners_only] -#[allow(non_snake_case)] -async fn announceChallenge(ctx: &Context, msg: &Message) -> CommandResult { +#[command(prefix_command, hide_in_help, owners_only)] +pub async fn announcechallenge(ctx: Context<'_>) -> Result<(), Error> { let challenge_number = get_challenge_number(); let message = format!("Welcome to the **{n}{th}** weekly **Tegaki Tuesday** (手書きの火曜日) handwriting challenge! :pen_fountain: The prompt is available in both Japanese and English on the website at . @@ -131,15 +108,14 @@ You can make submissions in both languages, but please submit in your target lan }, p = env::var("PREFIX").unwrap() ); - send(ctx, msg, &message, true, false).await + send(ctx, &message, true, false).await?; + ctx.say("Announced!").await?; + Ok(()) } -#[command] -#[owners_only] -#[allow(non_snake_case)] -async fn rebuildSite(ctx: &Context, msg: &Message) -> CommandResult { +#[command(prefix_command, hide_in_help, owners_only)] +pub async fn rebuildsite(ctx: Context<'_>) -> Result<(), Error> { rebuild_site(); - msg.reply(&ctx.http, "Started site rebuild process!") - .await?; + ctx.say("Started site rebuild process!").await?; Ok(()) } diff --git a/src/main.rs b/src/main.rs index c6f3656..3e5d73a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,69 +1,26 @@ mod commands; mod utils; -use std::{collections::HashSet, env, sync::Arc}; +use std::env; + +type Error = Box; +type Context<'a> = poise::Context<'a, Data, Error>; +type PrefixContext<'a> = poise::PrefixContext<'a, Data, Error>; +// User data, which is stored and accessible in all command invocations +pub struct Data {} use commands::{challenge::*, kanji::*, meta::*, owner::*}; -use serenity::model::gateway::Activity; -use serenity::{ - async_trait, - client::bridge::gateway::ShardManager, - framework::{standard::macros::group, StandardFramework}, - http::Http, - model::{ - event::ResumedEvent, - gateway::{GatewayIntents, Ready}, - }, - prelude::*, +use poise::serenity_prelude::{ + model::gateway::GatewayIntents }; -use tracing::{error, info}; - -pub struct ShardManagerContainer; - -impl TypeMapKey for ShardManagerContainer { - type Value = Arc>; -} +use poise::serenity_prelude as serenity; -struct Handler; - -#[async_trait] -impl EventHandler for Handler { - async fn ready(&self, ctx: Context, ready: Ready) { - info!("Connected as {}", ready.user.name); - let activity = Activity::watching("for new submissions"); - ctx.set_activity(activity).await; - } - - async fn resume(&self, _: Context, _: ResumedEvent) { - info!("Resumed"); - } +#[poise::command(prefix_command)] +async fn register(ctx: Context<'_>) -> Result<(), Error> { + poise::builtins::register_application_commands_buttons(ctx).await?; + Ok(()) } -#[group] -#[commands( - i, - joyo, - jinmeiyo, - kyoiku, - jlpt, - hyogai, - so, - rebuildSite, - challenge, - submit, - images, - imageDelete, - help, - sleep, - setSubmissionChannel, - setSuggestionChannel, - setAnnouncementRole, - suggest, - announce, - announceChallenge -)] -struct General; - #[tokio::main] async fn main() { // This will load the environment variables located at `./.env`, relative to @@ -74,55 +31,52 @@ async fn main() { // // In this case, a good default is setting the environment variable // `RUST_LOG` to `debug`. - tracing_subscriber::fmt::init(); - - let token = env::var("DISCORD_TOKEN").expect("Expected a token in the environment"); - let prefix = env::var("PREFIX").expect("Expected a prefix in the environment"); - let http = Http::new(&token); - - // We will fetch your bot's owners and id - let (owners, _bot_id) = match http.get_current_application_info().await { - Ok(info) => { - let mut owners = HashSet::new(); - owners.insert(info.owner.id); - - (owners, info.id) - } - Err(why) => panic!("Could not access application info: {:?}", why), - }; - - // Create the framework - let framework = StandardFramework::new() - .configure(|c| c.owners(owners).prefix(prefix)) - .group(&GENERAL_GROUP) - .unrecognised_command(commands::meta::unrecognised_command_hook); - - let intents = GatewayIntents::GUILD_MESSAGES + let framework = poise::Framework::builder() + .options(poise::FrameworkOptions { + commands: vec![ + register(), + + // owner + sleep(), + setsubmissionchannel(), + setsuggestionchannel(), + setannouncementrole(), + announce(), + announcechallenge(), + rebuildsite(), + + // challenge + challenge(), + submit(), + images(), + imagedelete(), + suggest(), + + // meta + help(), + + // kanji + i(), + joyo(), + jinmeiyo(), + kyoiku(), + jlpt(), + hyogai(), + so() + ], + prefix_options: poise::PrefixFrameworkOptions { + prefix: Some(env::var("PREFIX").expect("Expected a prefix in the environment")), + ..Default::default() + }, + ..Default::default() + }) + .token(std::env::var("DISCORD_TOKEN").expect("Expected a token in the environment")) + .intents(GatewayIntents::GUILD_MESSAGES | GatewayIntents::DIRECT_MESSAGES | GatewayIntents::MESSAGE_CONTENT - | GatewayIntents::GUILDS; // required for getting guild name like in setSubmissionChannel - let mut client = Client::builder(&token, intents) - .framework(framework) - .event_handler(Handler) - .await - .expect("Err creating client"); - - { - let mut data = client.data.write().await; - data.insert::(client.shard_manager.clone()); - } - - let shard_manager = client.shard_manager.clone(); - - tokio::spawn(async move { - tokio::signal::ctrl_c() - .await - .expect("Could not register ctrl+c handler"); - shard_manager.lock().await.shutdown_all().await; - }); + | GatewayIntents::GUILDS) + .user_data_setup(move |_ctx, _ready, _framework| Box::pin(async move { Ok(Data {}) })); - if let Err(why) = client.start().await { - error!("Client error: {:?}", why); - } + framework.run().await.unwrap(); } diff --git a/src/utils.rs b/src/utils.rs index 78c5eee..069df58 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,9 +1,6 @@ use rand::seq::IteratorRandom; use serde_json::Map; use serde_json::Value; -use serenity::framework::standard::{Args, CommandResult}; -use serenity::model::prelude::*; -use serenity::prelude::*; use std::collections::HashMap; use std::env; use std::fs; @@ -12,6 +9,9 @@ use std::fs::OpenOptions; use std::io::Read; use std::io::Write; use std::process::Command; +use crate::serenity; +use crate::Error; +use crate::{Context, PrefixContext}; pub fn get_challenge_number() -> i32 { let challenge_dir = format!("{}/content/challenges", env::var("HUGO").unwrap()); @@ -90,8 +90,8 @@ pub fn set_submission_data(submission_data: Vec) { .unwrap(); } -pub fn is_matching_submission(submission: &Value, msg: &Message) -> bool { - submission["id"].as_str().unwrap() == msg.author.id.as_u64().to_string() +pub fn is_matching_submission(submission: &Value, author: &serenity::User) -> bool { + submission["id"].as_str().unwrap() == author.id.as_u64().to_string() } pub fn to_fullwidth(string: &str) -> String { @@ -153,12 +153,11 @@ pub fn set_guild_data(guild_data: Map) { } pub async fn send( - ctx: &Context, - msg: &Message, + ctx: Context<'_>, message: &str, ping: bool, pin: bool, -) -> CommandResult { +) -> Result<(), Error> { let guild_data = get_guild_data(); let mut announcements_count = 0; for (_guild, data) in guild_data.iter() { @@ -166,7 +165,7 @@ pub async fn send( if !data.contains_key("submissionChannel") { continue; } - let channel = ChannelId( + let channel = serenity::ChannelId( data["submissionChannel"] .as_str() .unwrap() @@ -181,6 +180,7 @@ pub async fn send( )); } message_to_send.push_str(message); + let ctx = ctx.discord(); let sent_message = channel .send_message(&ctx.http, |e| { e.content(message_to_send); @@ -198,8 +198,7 @@ pub async fn send( } announcements_count += 1; } - msg.reply( - &ctx.http, + ctx.say( format!( "Announced to {} server{}!", announcements_count, @@ -222,21 +221,20 @@ pub fn get_so_diagram(kanji: char) -> String { } pub async fn display_kanji( - ctx: &Context, - msg: &Message, + ctx: Context<'_>, kanji: char, comment: &str, -) -> CommandResult { - msg.reply(&ctx.http, format!("{}{}", kanji, comment)) +) -> Result<(), Error> { + ctx.say(format!("{}{}", kanji, comment)) .await?; let url = get_so_diagram(kanji); let client = reqwest::Client::new(); let request = client.head(&url).build().unwrap(); let response = client.execute(request).await?.status(); let link_validated = response != reqwest::StatusCode::NOT_FOUND; - msg.channel_id + ctx.channel_id() .say( - &ctx.http, + &ctx.discord().http, if link_validated { &url } else { @@ -301,10 +299,9 @@ pub fn get_kanji_info(kanji: char) -> String { pub async fn random_kanji( category: &str, - ctx: &Context, - msg: &Message, - mut args: Args, -) -> CommandResult { + ctx: Context<'_>, + subcategory: Option, +) -> Result<(), Error> { let lists_data = get_lists_data(); let category = &lists_data[category]; let default_version = category["default"].as_str().unwrap(); @@ -312,10 +309,9 @@ pub async fn random_kanji( match list.as_str() { Some(string) => { let kanji = random_from_string(string); - display_kanji(&ctx, &msg, kanji, "").await?; + display_kanji(ctx, kanji, "").await?; } None => { - let subcategory = args.single::(); let subcategories = list.as_object().unwrap(); let subcategory_list = { let mut string = String::from("\n"); @@ -335,49 +331,40 @@ pub async fn random_kanji( )); string }; - match subcategory { - Ok(string) => { - let string = string.to_uppercase(); - if string == "ALL" { - let subcategory_key = subcategories - .keys() - .choose(&mut rand::thread_rng()) - .unwrap(); - let list = subcategories[subcategory_key].as_str().unwrap(); - let kanji = random_from_string(&list); - display_kanji(&ctx, &msg, kanji, &format!(", **{}**", subcategory_key)) - .await?; - } else if subcategories.contains_key(&string) { - let list = list[&string].as_str().unwrap(); - let kanji = random_from_string(&list); - display_kanji(&ctx, &msg, kanji, "").await?; - } else { - let message = format!( - "That is an invalid subcategory. Please use {}.", - &subcategory_list - ); - msg.reply(&ctx.http, message).await?; - } - } - Err(_) => { - let mut message = String::from("Please specify a subcategory: "); - message.push_str(&subcategory_list); - msg.reply(&ctx.http, message).await?; - } + let subcategory = subcategory.unwrap().to_uppercase(); + if subcategory == "ALL" { + let subcategory_key = subcategories + .keys() + .choose(&mut rand::thread_rng()) + .unwrap(); + let list = subcategories[subcategory_key].as_str().unwrap(); + let kanji = random_from_string(&list); + display_kanji(ctx, kanji, &format!(", **{}**", subcategory_key)) + .await?; + } else if subcategories.contains_key(&subcategory) { + let list = list[&subcategory].as_str().unwrap(); + let kanji = random_from_string(&list); + display_kanji(ctx, kanji, "").await?; + } else { + let message = format!( + "That is an invalid subcategory. Please use {}.", + &subcategory_list + ); + ctx.say(message).await?; } } } Ok(()) } -pub fn get_avatar(user: &User) -> String { +pub fn get_avatar(user: &serenity::User) -> String { match user.avatar_url() { Some(avatar_url) => avatar_url, None => user.default_avatar_url(), } } -pub async fn leaderboard(ctx: &Context) -> CommandResult { +pub async fn leaderboard(ctx: &PrefixContext<'_>) -> Result<(), Error> { const LENGTH: usize = 10; let mut submission_counts: HashMap = HashMap::new(); for challenge in 1..get_challenge_number() + 1 { @@ -396,12 +383,10 @@ pub async fn leaderboard(ctx: &Context) -> CommandResult { let mut leaderboard_html = String::from(""); for (i, (id, count)) in top_submitters[0..LENGTH].iter().enumerate() { let place = i + 1; - let user = UserId(id.parse::().unwrap()) - .to_user(&ctx.http) - .await?; + let user = &ctx.msg.author; let avatar = get_avatar(&user); let profile = format!("https://discord.com/users/{id}"); - let name = user.name; + let name = &user.name; let discriminator = user.discriminator; leaderboard_html.push_str(&format!("", discriminator)); }
{place}\"avatar\" {name}#{:0>4}{count}