verification stat checker

This commit is contained in:
Stachelbeere1248 2025-07-02 18:57:11 +02:00
parent 7886c6b46d
commit ce999bd271
Signed by: Stachelbeere1248
SSH key fingerprint: SHA256:IozEKdw2dB8TZxkpPdMxcWSoWTIMwoLaCcZJ1AJnY2o
8 changed files with 19899 additions and 185 deletions

19524
api_examples/player.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,16 +1,15 @@
use poise::CreateReply;
use poise::{ChoiceParameter, CreateReply};
use reqwest::Client;
use serenity::all::ButtonStyle;
use serenity::all::{
ChannelId, CreateActionRow, CreateAllowedMentions, CreateButton, CreateMessage, ReactionType,
User,
ButtonStyle, ChannelId, CreateActionRow, CreateAllowedMentions, CreateButton, CreateEmbed,
CreateMessage, ReactionType, User,
};
use sqlx::{Pool, Sqlite};
use crate::commands::command_helper::cooldown;
use crate::data::account_links::{Link, LinkId, Uuid};
use crate::data::account_links::{link, Link, Uuid};
use crate::data::hypixel::Arcade;
use crate::error::Error;
use crate::error::Error::Other;
use crate::Caches;
use crate::Context;
@ -25,6 +24,16 @@ pub(crate) async fn account(_ctx: Context<'_>) -> Result<(), Error> {
unreachable!()
}
#[derive(ChoiceParameter, PartialEq, Eq)]
enum Mode {
#[name = "normal"]
Normal,
#[name = "forced"]
Forced,
#[name = "forced + no hypixel api (instant accept)"]
NoApi,
}
#[poise::command(slash_command, ephemeral = "false")]
/// Verify a Minecraft account on the Zombies MultiPlayer Discord.
pub(crate) async fn add(
@ -33,142 +42,85 @@ pub(crate) async fn add(
#[min_length = 2]
#[max_length = 16]
ign: String,
#[description = "Discord User"] user: Option<User>,
#[description = "admin-only"] force: Option<bool>,
#[description = "Discord User"] target: Option<User>,
#[description = "ZMP-admin only"] mode: Option<Mode>,
) -> Result<(), Error> {
ctx.defer().await?;
let force: bool =
force.unwrap_or(false) && ctx.framework().options.owners.contains(&ctx.author().id) && {
let _ = user.as_ref().ok_or(Other(
"Warning: attempted to run forced account add without specifying a target Discord \
account."
.to_string(),
))?;
true
};
let user: User = user.unwrap_or(ctx.author().clone());
let mode = if ctx.framework().options().owners.contains(&ctx.author().id) {
mode.unwrap_or(Mode::Normal)
} else {
Mode::Normal
};
let user: User = target.unwrap_or(if mode == Mode::Normal {
ctx.author().clone()
} else {
return Err(Error::Other(
"Force mode cannot be ran without specifying a different Discord account other than \
your own.".to_string(),
));
});
let uuid: Uuid = Uuid::for_ign(
ign.as_str(),
&ctx.data().clients.general,
&ctx.data().caches,
)
.await?;
match force
|| uuid
.has_discord_user(&user, &ctx.data().clients.hypixel_api_client)
.await?
{
true => {
let pool = &ctx.data().sqlite_pool;
let status: &str = match LinkId::try_from_minecraft(pool, uuid.as_str()).await {
Err(_) => match LinkId::try_from_discord(pool, user.id.get()).await {
Err(_) => {
let id = LinkId::new(pool).await?;
sqlx::query(
format!(
"INSERT INTO discord_links VALUES ({id}, {});",
user.id.get()
)
.as_str(),
)
.execute(pool)
.await?;
sqlx::query(
format!(
"INSERT INTO minecraft_links VALUES ({id}, \"{}\");",
uuid.get()
)
.as_str(),
)
.execute(pool)
.await?;
"Linked your Discord and Minecraft account."
}
Ok(dc_id) => {
sqlx::query(
format!(
"INSERT INTO minecraft_links VALUES ({dc_id}, \"{}\");",
uuid.get()
)
.as_str(),
)
.execute(pool)
.await?;
"Your Discord account has previously had an account linked. Added the new \
link."
}
},
Ok(mc_id) => match LinkId::try_from_discord(pool, user.id.get()).await {
Err(_) => {
sqlx::query(
format!(
"INSERT INTO discord_links VALUES ({mc_id}, {});",
user.id.get()
)
.as_str(),
)
.execute(pool)
.await?;
"Your Minecraft account has previously had an account linked. Added the \
new link."
}
Ok(dc_id) => {
sqlx::query(
format!(
"UPDATE minecraft_links SET link_id = {mc_id} WHERE link_id = {dc_id};",
)
.as_str(),
)
.execute(pool)
.await?;
sqlx::query(
format!(
"UPDATE discord_links SET link_id = {mc_id} WHERE link_id = {dc_id};",
)
.as_str(),
)
.execute(pool)
.await?;
"Both your Discord and Minecraft account had linked accounts. Merged all \
account links."
}
},
};
let s = format!(
"Verification request for <@{}> with IGN `{}`",
user.id.get(),
ign
);
ChannelId::new(1257776992497959075)
let link_status = match mode {
Mode::Normal => {
let profile = uuid
.hypixel_player_data(&ctx.data().clients.hypixel_api_client)
.await?
.map_if_discord(user.name.as_str())?;
let done = link(&ctx.data().sqlite_pool, uuid, &user).await?;
ChannelId::new(1257776992497959075_u64)
.send_message(
ctx,
CreateMessage::new()
.content(s)
.allowed_mentions(
CreateAllowedMentions::new().empty_roles().all_users(true),
)
.components(vec![CreateActionRow::Buttons(vec![
CreateButton::new("accept_verification")
.emoji(ReactionType::from('✅'))
.style(ButtonStyle::Secondary),
CreateButton::new("deny_verification")
.emoji(ReactionType::from('❌'))
.style(ButtonStyle::Secondary),
CreateButton::new("list_accounts")
.emoji(ReactionType::from('📜'))
.style(ButtonStyle::Primary),
])]),
create_verification_message(profile.arcade_stats().copied().unwrap_or_default(), &user, ign)
)
.await?;
ctx.send(CreateReply::default().content(status)).await?;
Ok(())
done
}
false => Err(Error::Other(format!(
"The Discord account linked on Hypixel does not match the specified discord \
account.\nPlease set your linked Discord account on Hypixel to `{}`.",
user.name
))),
}
Mode::Forced => {
let profile = uuid
.hypixel_player_data(&ctx.data().clients.hypixel_api_client)
.await?;
let done = link(&ctx.data().sqlite_pool, uuid, &user).await?;
ChannelId::new(1257776992497959075_u64)
.send_message(
ctx,
create_verification_message(profile.arcade_stats().copied().unwrap_or_default(), &user, ign)
)
.await?;
done
}
Mode::NoApi => {
let done = link(&ctx.data().sqlite_pool, uuid, &user).await?;
done
}
};
ctx.send(CreateReply::default().content(link_status))
.await?;
Ok(())
}
fn create_verification_message(stats: Arcade, user: &User, ign: String) -> CreateMessage {
let embed = CreateEmbed::new()
.fields(Vec::<(&'static str, String, bool)>::from(stats))
.title(format!("Verification request for {user} `{ign}`"));
CreateMessage::new()
.embed(embed)
.allowed_mentions(CreateAllowedMentions::new().empty_roles().all_users(true))
.components(vec![CreateActionRow::Buttons(vec![
CreateButton::new("accept_verification")
.emoji(ReactionType::from('✅'))
.style(ButtonStyle::Secondary),
CreateButton::new("deny_verification")
.emoji(ReactionType::from('❌'))
.style(ButtonStyle::Secondary),
CreateButton::new("list_accounts")
.emoji(ReactionType::from('📜'))
.style(ButtonStyle::Primary),
])])
}
#[poise::command(

View file

@ -1,3 +1,4 @@
use crate::data::account_links::{Link, Uuid};
use crate::data::helpstart_api::fetch_all;
use crate::data::helpstart_api::ListType::*;
use crate::data::mojang::name;
@ -12,7 +13,6 @@ use poise::serenity_prelude::CreateInteractionResponse;
use poise::serenity_prelude::CreateInteractionResponseMessage;
use poise::CreateReply;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::data::account_links::{Link, Uuid};
#[poise::command(
slash_command,
@ -26,10 +26,12 @@ pub(crate) async fn helpstart(ctx: Context<'_>, user: Option<String>) -> Result<
let mc_accounts = match user {
None => {
futures::future::try_join_all(
Vec::<Uuid>::from(Link::try_from_discord(ctx.author(), &ctx.data().sqlite_pool).await?)
.into_iter()
.map(|a| name(&ctx.data().caches, &ctx.data().clients.general, a.get()))
.collect::<Vec<_>>(),
Vec::<Uuid>::from(
Link::try_from_discord(ctx.author(), &ctx.data().sqlite_pool).await?,
)
.into_iter()
.map(|a| name(&ctx.data().caches, &ctx.data().clients.general, a.uuid))
.collect::<Vec<_>>(),
)
.await?
}
@ -39,9 +41,19 @@ pub(crate) async fn helpstart(ctx: Context<'_>, user: Option<String>) -> Result<
let bots = fetch_all(&ctx.data().clients.local_api_client).await?;
let usable = bots
.iter()
.filter(|b| match b.list_type() {
Whitelist => b.list().iter().any(|w| mc_accounts.contains(w)),
Blacklist => mc_accounts.iter().any(|m| !b.list().contains(m)),
.filter(|bot| match bot.list_type() {
Whitelist => bot.list().iter().map(|wl| wl.to_lowercase()).any(|wl| {
mc_accounts
.iter()
.map(|acc| acc.to_lowercase())
.any(|acc| acc == wl)
}),
Blacklist => mc_accounts.iter().map(|acc| acc.to_lowercase()).any(|acc| {
bot.list()
.iter()
.map(|bl| bl.to_lowercase())
.all(|b| b != acc)
}),
})
.collect::<Vec<_>>();

View file

@ -50,7 +50,7 @@ pub enum Difficulty {
#[poise::command(
slash_command,
install_context = "Guild",
interaction_context = "Guild",
interaction_context = "Guild"
)]
/// Find a team for Hypixel Zombies.
pub(crate) async fn lfg(
@ -103,7 +103,10 @@ pub(crate) async fn lfg(
AlienArcadium => Normal,
};
let mut reply_content: String = format!("-# LFG by {}\n## <@&{ping}> {current}/{desired} {map_name}", ctx.author());
let mut reply_content: String = format!(
"-# LFG by {}\n## <@&{ping}> {current}/{desired} {map_name}",
ctx.author()
);
match difficulty {
Normal => {}
Difficulty::Hard | Difficulty::Rip => {
@ -118,7 +121,7 @@ pub(crate) async fn lfg(
}
}
let reply = CreateMessage::default()
let reply = CreateMessage::default()
.content(reply_content)
.allowed_mentions(CreateAllowedMentions::new().roles(vec![ping]));

View file

@ -1,6 +1,8 @@
use super::hypixel::HypixelResponse;
use std::fmt::Display;
use crate::data::hypixel::HypixelPlayer;
use crate::Caches;
use crate::Error::{self, *};
use crate::Error;
use poise::serenity_prelude::User;
use reqwest::Client;
use sqlx::query_as;
@ -9,14 +11,16 @@ use sqlx::Sqlite;
#[derive(PartialEq, sqlx::FromRow)]
pub(crate) struct Uuid {
uuid: String,
pub uuid: String,
}
impl Display for Uuid {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.uuid)
}
}
impl Uuid {
pub(crate) fn get(self) -> String {
self.uuid
}
pub(crate) fn as_str(&self) -> &str {
self.uuid.as_str()
}
@ -30,21 +34,13 @@ impl Uuid {
let ign = crate::data::mojang::name(c, cli, self.uuid.clone()).await?;
Ok(ign)
}
}
impl Uuid {
pub(crate) async fn has_discord_user(
pub(crate) async fn hypixel_player_data(
&self,
user: &User,
client: &Client,
) -> Result<bool, Error> {
let res = HypixelResponse::get(self.uuid.as_str(), client).await?;
let matches = res.discord().ok_or(Other(
"The Hypixel profile has no Discord account linked. Please follow the steps in \
<#1256219552568840263>"
.to_string(),
))? == user.name;
Ok(matches)
) -> Result<HypixelPlayer, Error> {
let p = HypixelPlayer::get(self.uuid.as_str(), client).await?;
Ok(p)
}
}
@ -131,7 +127,7 @@ impl Link {
* let link_id = LinkId::try_from_minecraft(pool, player.uuid.as_str()).await?;
* Link::lookup_by_id(link_id, pool).await
* }
*/
*/
}
#[derive(Clone, Copy)]
@ -186,7 +182,9 @@ impl LinkId {
)
.fetch_optional(pool)
.await?
.ok_or(Other("This user has no accounts linked.".to_string()))
.ok_or(Error::Other(
"This user has no accounts linked.".to_string(),
))
}
pub(crate) async fn try_from_minecraft(
@ -201,6 +199,79 @@ impl LinkId {
)
.fetch_optional(pool)
.await?
.ok_or(Other("This player has no accounts linked.".to_string()))
.ok_or(Error::Other("This player has no accounts linked.".to_string()))
}
}
pub async fn link(pool: &Pool<Sqlite>, uuid: Uuid, user: &User) -> Result<&'static str, Error> {
match LinkId::try_from_minecraft(pool, uuid.as_str()).await {
Err(_) => match LinkId::try_from_discord(pool, user.id.get()).await {
Err(_) => {
let id = LinkId::new(pool).await?;
sqlx::query(
format!(
"INSERT INTO discord_links VALUES ({id}, {});",
user.id.get()
)
.as_str(),
)
.execute(pool)
.await?;
sqlx::query(
format!("INSERT INTO minecraft_links VALUES ({id}, \"{uuid}\");",).as_str(),
)
.execute(pool)
.await?;
Ok("Linked your Discord and Minecraft account.")
}
Ok(dc_id) => {
sqlx::query(
format!("INSERT INTO minecraft_links VALUES ({dc_id}, \"{uuid}\");",).as_str(),
)
.execute(pool)
.await?;
Ok(
"Your Discord account has previously had an account linked. Added the new \
link.",
)
}
},
Ok(mc_id) => match LinkId::try_from_discord(pool, user.id.get()).await {
Err(_) => {
sqlx::query(
format!(
"INSERT INTO discord_links VALUES ({mc_id}, {});",
user.id.get()
)
.as_str(),
)
.execute(pool)
.await?;
Ok(
"Your Minecraft account has previously had an account linked. Added the new \
link.",
)
}
Ok(dc_id) => {
sqlx::query(
format!(
"UPDATE minecraft_links SET link_id = {mc_id} WHERE link_id = {dc_id};",
)
.as_str(),
)
.execute(pool)
.await?;
sqlx::query(
format!("UPDATE discord_links SET link_id = {mc_id} WHERE link_id = {dc_id};",)
.as_str(),
)
.execute(pool)
.await?;
Ok(
"Both your Discord and Minecraft account had linked accounts. Merged all \
account links.",
)
}
},
}
}

View file

@ -1,46 +1,164 @@
use crate::Error;
use crate::{error::LinkingError, Error};
use getset::{CopyGetters, Getters};
use reqwest::Client;
use serde::Deserialize;
#[derive(Deserialize)]
#[derive(Deserialize, Getters)]
#[getset(get)]
struct Links {
#[serde(rename = "DISCORD")]
discord: Option<String>,
}
#[derive(Deserialize)]
#[derive(Deserialize, Getters)]
#[getset(get)]
struct SocialMedia {
links: Option<Links>,
}
#[derive(Deserialize)]
struct HypixelPlayer {
#[derive(Deserialize, CopyGetters, Clone, Copy)]
#[getset(get_copy)]
pub struct Arcade {
#[serde(rename = "wins_zombies")]
wins: Option<u16>, //one day slies will overflow this
#[serde(rename = "fastest_time_30_zombies_deadend_normal")]
de_r30: Option<u16>,
#[serde(rename = "fastest_time_30_zombies_deadend_hard")]
deh_r30: Option<u16>,
#[serde(rename = "fastest_time_30_zombies_deadend_rip")]
der_r30: Option<u16>,
#[serde(rename = "fastest_time_30_zombies_badblood_normal")]
bb_r30: Option<u16>,
#[serde(rename = "fastest_time_30_zombies_badblood_hard")]
bbh_r30: Option<u16>,
#[serde(rename = "fastest_time_30_zombies_badblood_rip")]
bbr_r30: Option<u16>,
#[serde(rename = "fastest_time_30_zombies_alienarcadium_normal")]
aa_win: Option<u16>,
#[serde(rename = "fastest_time_30_zombies_prison_normal")]
p_r30: Option<u16>,
#[serde(rename = "fastest_time_30_zombies_prison_hard")]
ph_r30: Option<u16>,
#[serde(rename = "fastest_time_30_zombies_prison_rip")]
pr_r30: Option<u16>,
}
impl From<Arcade> for Vec<(&'static str, String, bool)> {
fn from(arcade: Arcade) -> Vec<(&'static str, String, bool)> {
let mut vec = Vec::<(&'static str, String, bool)>::with_capacity(11);
fn format(sec: u16) -> String {
let h = sec / 3600;
let m = (sec % 3600) / 60;
let s = sec % 60;
if h > 0 {
format!("{}:{}:{}", h, m, s)
} else {
format!("{}:{}", m, s)
}
}
if let Some(wins) = arcade.wins {
vec.push(("Wins", wins.to_string(), true));
}
macro_rules! push_time_if_some {
($field:ident, $name:expr) => {
if let Some(val) = arcade.$field() {
let formatted = format(val);
vec.push(($name, formatted, true));
}
};
}
push_time_if_some!(de_r30, "DE");
push_time_if_some!(deh_r30, "DE Hard");
push_time_if_some!(der_r30, "DE RIP");
push_time_if_some!(bb_r30, "BB");
push_time_if_some!(bbh_r30, "BB Hard");
push_time_if_some!(bbr_r30, "BB RIP");
push_time_if_some!(aa_win, "AA");
push_time_if_some!(p_r30, "Prison");
push_time_if_some!(ph_r30, "Prison Hard");
push_time_if_some!(pr_r30, "Prison RIP");
vec
}
}
#[derive(Deserialize, Getters)]
struct Stats {
#[getset(get)]
#[serde(rename = "Arcade")]
arcade: Option<Arcade>,
}
#[derive(Deserialize, Getters)]
#[getset(get)]
pub struct HypixelPlayer {
#[serde(rename = "socialMedia")]
social_media: Option<SocialMedia>,
stats: Option<Stats>,
}
impl Default for Arcade {
fn default() -> Self {
Self {
wins: None,
de_r30: None,
deh_r30: None,
der_r30: None,
bb_r30: None,
bbh_r30: None,
bbr_r30: None,
aa_win: None,
p_r30: None,
ph_r30: None,
pr_r30: None,
}
}
}
#[derive(Deserialize)]
pub struct HypixelResponse {
struct HypixelResponse {
#[serde(rename = "player")]
player: HypixelPlayer,
}
impl HypixelResponse {
impl HypixelPlayer {
pub async fn get(uuid: &str, client: &Client) -> Result<Self, Error> {
let player = client
.get(format!("https://api.hypixel.net/v2/player?uuid={uuid}"))
.send()
.await?
.error_for_status()?
.json::<Self>()
.await?;
.json::<HypixelResponse>()
.await?
.player;
Ok(player)
}
pub fn discord(self) -> Option<String> {
self.player
.social_media
.and_then(|p| p.links)
.and_then(|l| l.discord)
fn discord(&self) -> Option<&str> {
self.social_media()
.as_ref()
.and_then(|p| p.links().as_ref())
.and_then(|l| l.discord().as_ref().map(|s| s.as_str()))
}
pub fn map_if_discord(self, discord: &str) -> Result<Self, LinkingError> {
match self.discord() {
Some(d) => {
if d == discord {
Ok(self)
} else {
Err(LinkingError::WrongLink(discord.to_string()))
}
}
None => Err(LinkingError::NotLinked),
}
}
pub fn arcade_stats(&self) -> Option<&Arcade> {
self.stats().as_ref().and_then(|s| s.arcade().as_ref())
}
}

View file

@ -64,4 +64,7 @@ cache_hit_handler!(
"https://api.minecraftservices.com/minecraft/profile/lookup/{}"
);
cache_hit_handler!(uuid, "https://api.minecraftservices.com/minecraft/profile/lookup/name/{}");
cache_hit_handler!(
uuid,
"https://api.minecraftservices.com/minecraft/profile/lookup/name/{}"
);

View file

@ -11,46 +11,77 @@ macro_rules! reply_fail_handler {
}};
}
#[derive(Debug)]
pub enum LinkingError {
NotLinked,
WrongLink(String),
}
impl Display for LinkingError {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
match self {
Self::NotLinked => write!(
f,
"This Minecraft account's Hypixel profile has no Discord account linked. Please \
follow the steps in <#1256219552568840263>."
),
Self::WrongLink(name) => write!(
f,
"This Minecraft account's Hypixel profile has a different Discord account linked. \
If you actually own this account, please set the linked discord to `{name}`"
),
}
}
}
#[derive(Debug)]
pub enum Error {
Sqlx(sqlx::Error),
Api(reqwest::Error),
Serenity(serenity::Error),
OnCooldown(std::time::Duration),
LinkingError(LinkingError),
Other(String),
}
impl Display for Error {
fn fmt(&self, f: &mut Formatter) -> FmtResult {
match self {
Error::Sqlx(e) => write!(f, "SQLx Error: {}", e),
Error::Api(e) => write!(f, "HTTPS Error:\n{}", e),
Error::Serenity(e) => write!(f, "Discord Error:\n {}", e),
Error::OnCooldown(d) => write!(
Self::Sqlx(e) => write!(f, "SQLx Error: {e}"),
Self::Api(e) => write!(f, "HTTPS Error:\n{e}"),
Self::Serenity(e) => write!(f, "Discord Error:\n {e}"),
Self::OnCooldown(d) => write!(
f,
"This command is on cooldown. {}s remaining.",
d.as_secs()
),
Error::Other(s) => write!(f, "{}", s),
Self::LinkingError(l) => write!(f, "{l}"),
Self::Other(s) => write!(f, "{s}"),
}
}
}
impl From<sqlx::Error> for Error {
fn from(error: sqlx::Error) -> Self {
Error::Sqlx(error)
Self::Sqlx(error)
}
}
impl From<reqwest::Error> for Error {
fn from(error: reqwest::Error) -> Self {
Error::Api(error)
Self::Api(error)
}
}
impl From<serenity::Error> for Error {
fn from(error: serenity::Error) -> Self {
Error::Serenity(error)
Self::Serenity(error)
}
}
impl From<LinkingError> for Error {
fn from(value: LinkingError) -> Self {
Self::LinkingError(value)
}
}