Want to contribute? Fork me on Codeberg.org!
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ji-chan/src/commands/challenge.rs

479 lines
18 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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}: <https://tegakituesday.com/{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<String> = 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<String> = 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()?;
}
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 <https://tegakituesday.com/{}>", 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::<u64>()
.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<String> = {
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!(
"{}<https://tegakituesday.com/{}/{}>\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::<u64>()
.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(())
}