use crate::utils::*; use serde_json::Map; use crate::serenity; use crate::Error; use crate::{Context, PrefixContext}; use poise::command; use slug::slugify; use std::fs; use std::fs::File; use std::io::Write; use std::path::Path; #[command( prefix_command, slash_command, ephemeral, 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() )) .await?; Ok(()) } #[command( prefix_command, slash_command, ephemeral, broadcast_typing, description_localized("en-US", "Submit to the latest handwriting challenge.") )] pub async fn submit(ctx: Context<'_>, submission: serenity::Attachment) -> Result<(), Error> { // TODO: The code for this command needs to be refactored, // there are large duplicated sections that need to be merged somehow. match ctx { Context::Application(_) => ctx.defer_ephemeral().await?, Context::Prefix(ctx) => { if ctx.msg.attachments.len() == 0 { ctx.msg .reply(&ctx.discord.http, "Please attach at least one image.") .await?; return Ok(()); } } } let guild_data = get_guild_data(); let guild = ctx.guild_id().unwrap().as_u64().to_string(); if !guild_data.contains_key(&guild) || !&guild_data[&guild] .as_object() .unwrap() .contains_key("submissionChannel") { ctx.say("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 != ctx.channel_id().as_u64().to_string() { ctx.defer_ephemeral().await?; ctx.say(format!( "Sorry, submissions aren't permitted here. Please go to <#{}>. Thanks!", guild )) .await?; return Ok(()); } let challenge_number = get_challenge_number(); let submission_images_dir = get_submission_images_dir(); // Ensure that submission_images_dir exists let path = Path::new(&submission_images_dir); std::fs::create_dir_all(path)?; let mut submission_data = get_current_submission_data(); let mut existing_submitter = false; let mut invalid_types = false; let mut requires_rebuild = false; let attachments = vec![submission]; let attachments = match ctx { Context::Application(_) => &attachments, Context::Prefix(ctx) => &ctx.msg.attachments, }; let author = ctx.author(); let mut submitted_images = Vec::new(); for (i, submission) in submission_data.iter_mut().enumerate() { if is_matching_submission(&submission, author) { existing_submitter = true; let images: Vec = submission["images"].as_array_mut().unwrap().clone().iter().map(|value| value.as_str().unwrap().to_owned()).collect(); for attachment in attachments.iter() { let extension; if let Some(content_type) = &attachment.content_type { if content_type == "image/png" { extension = "png"; } else if content_type == "image/jpeg" { extension = "jpg"; } else { invalid_types = true; continue; } } else { invalid_types = true; continue; } requires_rebuild = true; let file_name = format!( "{}-{}-{}-{}.{}", i + 1, slugify(&author.name), author.discriminator, images.len() + 1, extension ); let image = reqwest::get(&attachment.url).await?.bytes().await?; let mut image_file = File::create(format!("{}/{}", submission_images_dir, file_name))?; image_file.write_all(&image)?; image_file.flush()?; submitted_images.push(file_name); } submission["images"] = images.clone().into(); break; } } if !existing_submitter { let mut submitter_data = Map::new(); submitter_data.insert( String::from("username"), format!("{}#{}", author.name, author.discriminator).into(), ); let mut images: Vec = Vec::new(); for attachment in attachments.iter() { let extension; if let Some(content_type) = &attachment.content_type { if content_type == "image/png" { extension = "png"; } else if content_type == "image/jpeg" { extension = "jpg"; } else { invalid_types = true; continue; } } else { invalid_types = true; continue; } requires_rebuild = true; let file_name = format!( "{}-{}-{}{}.{}", submission_data.len() + 1, slugify(&author.name), author.discriminator, if images.len() == 0 { String::from("") } else { format!("-{}", images.len() + 1) }, extension ); images.push(file_name.clone().into()); let image = reqwest::get(&attachment.url).await?.bytes().await?; let mut image_file = File::create(format!("{}/{}", submission_images_dir, file_name))?; image_file.write_all(&image)?; image_file.flush()?; submitted_images.push(file_name); } submitter_data.insert(String::from("images"), images.into()); submitter_data.insert(String::from("id"), author.id.as_u64().to_string().into()); submission_data.push(submitter_data.into()); } set_submission_data(submission_data); let mut message = String::new(); if requires_rebuild { let thank_you = &format!("Thank you for submitting! You can view your submission at ", challenge_number); let mut repost_here = true; match ctx { Context::Application(_) => message.push_str(thank_you), Context::Prefix(ctx) => match ctx.msg.delete(&ctx.discord.http).await { Ok(_) => {} // don't repost image on this server if no manage messages perm // (if we can't delete user's image it'll be displayed twice) Err(_) => { repost_here = false; message.push_str(thank_you); } }, }; if invalid_types { message.push_str("\nSome of your attachments could not be uploaded; only **.png**, **.jpg**, and **.jpeg** files are permitted."); } leaderboard(&ctx).await?; rebuild_site(); let guild = ctx.guild().unwrap(); let data = guild_data[&guild.id.to_string()].as_object().unwrap(); let invite = if data.contains_key("invite") { Some(data["invite"].as_str().unwrap()) } else { None }; for image in submitted_images.iter() { for (other_guild_id, data) in guild_data.iter() { let here = other_guild_id.eq(&ctx.guild_id().unwrap().as_u64().to_string()); if !repost_here && here { continue; } let data = data.as_object().unwrap(); if !data.contains_key("submissionChannel") { continue; } let channel = serenity::ChannelId( data["submissionChannel"] .as_str() .unwrap() .parse::() .unwrap(), ); let accent_color = ctx .discord() .http .get_user(*author.id.as_u64()) .await .unwrap() .accent_colour; let mut pathbuf = std::path::PathBuf::new(); pathbuf.push(&submission_images_dir); pathbuf.push(&image); channel.send_message(&ctx.discord().http, |m| { m.add_file(&pathbuf); m.embed(|e| { let username = format!("{}#{}", author.name, author.discriminator); let n = get_challenge_number(); let mut description = format!("New submission to [Tegaki Tuesday #{n}](https://tegakituesday.com/{n})!"); if !here { description.push_str(&if let Some(invite) = invite { format!("\nCrossposted from [{}](https://discord.gg/{invite})", guild.name) } else { format!("\nCrossposted from {}", guild.name) }); }; e.description(description); let mut embed_author = serenity::builder::CreateEmbedAuthor::default(); embed_author .icon_url(get_avatar(&author)) .name(username) .url(format!("https://discord.com/users/{}", author.id)); e.set_author(embed_author); e.image(format!("attachment://{image}")); if let Some(accent_color) = accent_color { e.color(accent_color); } e }); m }).await?; } } } else if invalid_types { message.push_str("Sorry, your submission could not be uploaded; only **.png**, **.jpg**, and **.jpeg** files are permitted."); } if !message.is_empty() { ctx.say(message).await?; } Ok(()) } #[command( prefix_command, slash_command, ephemeral, 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, &ctx.author()) { for image in submission["images"].as_array().unwrap().iter() { images.push(String::from(image.as_str().unwrap())); } break; } } images }; let challenge_number = get_challenge_number(); if images.len() == 0 { ctx.say(format!( "You haven't submitted anything for Tegaki Tuesday #{}.", challenge_number )) .await?; return Ok(()); } let mut message = String::from(format!( "Your submission images for Tegaki Tuesday #{}:\n", challenge_number )); for (i, image) in images.iter().enumerate() { message.push_str(&format!( "({})\n", to_fullwidth(&(i + 1).to_string()), challenge_number, image )); } ctx.say(message).await?; Ok(()) } #[command( prefix_command, slash_command, ephemeral, 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 { 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, &ctx.author()) { continue; } let mut images = submission["images"].as_array().unwrap().clone(); let image_count = images.len(); if image_count < number.try_into().unwrap() { 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. // Submission data should be deleted uppon imageDelete if there are no images remaining. format!( "You haven't submitted anything for Tegaki Tuesday #{}.", challenge_number ) } else { format!( "That image number doesn't exist, you only have {} image{}.", image_count, if image_count == 1 { "" } else { "s" } ) }) .await?; return Ok(()); } let index = number as usize - 1; let image = images[index].as_str().unwrap().to_owned(); let submission_images_dir = get_submission_images_dir(); let image_path = format!("{}/{}", submission_images_dir, image); match fs::remove_file(image_path) { Ok(_) => (), // No need to worry about if the file is already missing Err(ref e) if e.kind() == std::io::ErrorKind::NotFound => (), Err(_) => panic!("Failed to remove file"), }; let mut message = String::from(format!("Deleted **{}** from your submission.", image)); if images.len() == 1 { message.push_str(" As there are no more images left attached to your submission, it has been deleted."); submission_data.remove(i); } else { images.remove(index); // One must rename all of the submitted images to prevent overwrites. // Consider the following scenario: // The submissions are ["bob-1234.png", "bob-1234-2.png"] // Bob uses the command imageDelete 1 // The submissions become ["bob-1234-2.png"] // Bob makes a second submission // The submissions become ["bob-1234-2.png", "bob-1234-2.png"] // The original bob-1234-2.png gets overwriten, // and both submissions end up pointing to the same image. // In order to prevent this, images need to be renamed according to their index. 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(&author.name), author.discriminator, if j == 0 { String::from("") } else { format!("-{}", j + 1) }, Path::new(old).extension().unwrap().to_str().unwrap() ); let to = format!("{}/{}", submission_images_dir, new); fs_extra::file::move_file(from, to, &fs_extra::file::CopyOptions::default()) .unwrap(); *image = new.into(); } submission["images"] = images.into(); } set_submission_data(submission_data); rebuild_site(); ctx.say(message).await?; return Ok(()); } ctx.say(format!( "You haven't submitted anything for Tegaki Tuesday #{}.", challenge_number )) .await?; Ok(()) } // 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 = serenity::ChannelId( guild_data["suggestionChannel"] .as_str() .unwrap() .parse::() .unwrap(), ); // User::accent_colour is only available via the REST API // If we just do msg.author.accent_colour here, we will get None 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.discord.http, |m| { m.allowed_mentions(|am| { am.empty_parse(); am }); m.embed(|e| { let username = format!("{}#{}", author.name, author.discriminator); e.title("New suggestion"); e.description(format!( "{}\n\n[See original message]({})", suggestion, ctx.msg.link(), )); let mut embed_author = serenity::builder::CreateEmbedAuthor::default(); embed_author .icon_url(get_avatar(&author)) .name(username) .url(format!("https://discord.com/users/{}", author.id)); e.set_author(embed_author); if let Some(accent_color) = accent_color { e.color(accent_color); } e }); m }) .await .unwrap(); 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(()) }