Better error handling
This commit is contained in:
parent
d7ac4094b2
commit
74f554e058
464
Cargo.lock
generated
464
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
19
Cargo.toml
19
Cargo.toml
|
@ -2,6 +2,19 @@
|
|||
name = "shimmeringmoon"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
autobins = false
|
||||
|
||||
[lib]
|
||||
name = "shimmeringmoon"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "shimmeringmoon-discord-bot"
|
||||
path = "src/bin/discord-bot/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "shimmeringmoon-cli"
|
||||
path = "src/bin/cli/main.rs"
|
||||
|
||||
[dependencies]
|
||||
chrono = "0.4.38"
|
||||
|
@ -25,6 +38,8 @@ clap = { version = "4.5.17", features = ["derive"] }
|
|||
postcard = { version = "1.0.10", features = ["use-std"], default-features = false }
|
||||
serde_with = "3.9.0"
|
||||
anyhow = "1.0.87"
|
||||
sha2 = "0.10.8"
|
||||
base16ct = { version = "0.2.0", features = ["alloc"] }
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 3
|
||||
# [profile.dev.package."*"]
|
||||
# opt-level = 3
|
||||
|
|
43
flake.lock
43
flake.lock
|
@ -8,11 +8,11 @@
|
|||
"rust-analyzer-src": "rust-analyzer-src"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1717827974,
|
||||
"narHash": "sha256-ixopuTeTouxqTxfMuzs6IaRttbT8JqRW5C9Q/57WxQw=",
|
||||
"lastModified": 1727073227,
|
||||
"narHash": "sha256-1kmkEQmFfGVuPBasqSZrNThqyMDV1SzTalQdRZxtDRs=",
|
||||
"owner": "nix-community",
|
||||
"repo": "fenix",
|
||||
"rev": "ab655c627777ab5f9964652fe23bbb1dfbd687a8",
|
||||
"rev": "88cc292eb3c689073c784d6aecc0edbd47e12881",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
@ -41,16 +41,16 @@
|
|||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1718000748,
|
||||
"narHash": "sha256-zliqz7ovpxYdKIK+GlWJZxifXsT9A1CHNQhLxV0G1Hc=",
|
||||
"lastModified": 1726755586,
|
||||
"narHash": "sha256-PmUr/2GQGvFTIJ6/Tvsins7Q43KTMvMFhvG6oaYK+Wk=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "869cab745a802b693b45d193b460c9184da671f3",
|
||||
"rev": "c04d5652cfa9742b1d519688f65d1bbccea9eb7e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "release-24.05",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
|
@ -59,17 +59,18 @@
|
|||
"inputs": {
|
||||
"fenix": "fenix",
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-analyzer-src": {
|
||||
"flake": false,
|
||||
"locked": {
|
||||
"lastModified": 1717583671,
|
||||
"narHash": "sha256-+lRAmz92CNUxorqWusgJbL9VE1eKCnQQojglRemzwkw=",
|
||||
"lastModified": 1726443025,
|
||||
"narHash": "sha256-nCmG4NJpwI0IoIlYlwtDwVA49yuspA2E6OhfCOmiArQ=",
|
||||
"owner": "rust-lang",
|
||||
"repo": "rust-analyzer",
|
||||
"rev": "48bbdd6a74f3176987d5c809894ac33957000d19",
|
||||
"rev": "94b526fc86eaa0e90fb4d54a5ba6313aa1e9b269",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
@ -79,6 +80,26 @@
|
|||
"type": "github"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1727058553,
|
||||
"narHash": "sha256-tY/UU3Qk5gP/J0uUM4DZ6wo4arNLGAVqLKBotILykfQ=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "edc5b0f896170f07bd39ad59d6186fcc7859bbb2",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
|
|
47
flake.nix
47
flake.nix
|
@ -1,17 +1,22 @@
|
|||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/release-24.05";
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
fenix.url = "github:nix-community/fenix";
|
||||
fenix.inputs.nixpkgs.follows = "nixpkgs";
|
||||
rust-overlay.url = "github:oxalica/rust-overlay";
|
||||
rust-overlay.inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{ ... }@inputs:
|
||||
inputs:
|
||||
inputs.flake-utils.lib.eachSystem (with inputs.flake-utils.lib.system; [ x86_64-linux ]) (
|
||||
system:
|
||||
let
|
||||
pkgs = inputs.nixpkgs.legacyPackages.${system}.extend inputs.fenix.overlays.default;
|
||||
pkgs = inputs.nixpkgs.legacyPackages.${system}.extend (import inputs.rust-overlay);
|
||||
# toolchain = pkgs.rust-bin.selectLatestNightlyWith (toolchain: toolchain.default);
|
||||
# toolchain = pkgs.rust-bin.stable.latest.default;
|
||||
toolchain = inputs.fenix.packages.${system}.complete.toolchain;
|
||||
inherit (pkgs) lib;
|
||||
in
|
||||
{
|
||||
|
@ -29,41 +34,35 @@
|
|||
};
|
||||
};
|
||||
};
|
||||
devShell = pkgs.mkShell rec {
|
||||
packages = with pkgs; [
|
||||
(fenix.complete.withComponents [
|
||||
"cargo"
|
||||
"clippy"
|
||||
"rust-src"
|
||||
"rustc"
|
||||
"rustfmt"
|
||||
])
|
||||
rust-analyzer-nightly
|
||||
ruff
|
||||
imagemagick
|
||||
fontconfig
|
||||
freetype
|
||||
|
||||
clang
|
||||
llvmPackages.clang
|
||||
devShell = pkgs.mkShell {
|
||||
nativeBuildInputs = with pkgs; [
|
||||
toolchain
|
||||
# ruff
|
||||
# imagemagick
|
||||
pkg-config
|
||||
|
||||
# clang
|
||||
# llvmPackages.clang
|
||||
];
|
||||
buildInputs = with pkgs; [
|
||||
toolchain
|
||||
freetype
|
||||
fontconfig
|
||||
leptonica
|
||||
tesseract
|
||||
openssl
|
||||
# openssl
|
||||
sqlite
|
||||
];
|
||||
|
||||
LD_LIBRARY_PATH = lib.makeLibraryPath packages;
|
||||
# LD_LIBRARY_PATH = lib.makeLibraryPath buildInputs;
|
||||
|
||||
# compilation of -sys packages requires manually setting LIBCLANG_PATH
|
||||
LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib";
|
||||
# LIBCLANG_PATH = "${pkgs.llvmPackages.libclang.lib}/lib";
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
# {{{ Caching and whatnot
|
||||
# TODO: persist trusted substituters file
|
||||
nixConfig = {
|
||||
extra-substituters = [ "https://nix-community.cachix.org" ];
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ use anyhow::anyhow;
|
|||
use image::RgbaImage;
|
||||
|
||||
use crate::assets::get_data_dir;
|
||||
use crate::context::{Error, UserContext};
|
||||
use crate::context::{ErrorKind, TagError, TaggedError, UserContext};
|
||||
use crate::user::User;
|
||||
|
||||
use super::chart::{Difficulty, Level};
|
||||
|
@ -119,9 +119,8 @@ impl GoalStats {
|
|||
ctx: &UserContext,
|
||||
user: &User,
|
||||
scoring_system: ScoringSystem,
|
||||
) -> Result<Self, Error> {
|
||||
let plays = get_best_plays(ctx, user.id, scoring_system, 0, usize::MAX, None)?
|
||||
.map_err(|s| anyhow!("{s}"))?;
|
||||
) -> Result<Self, TaggedError> {
|
||||
let plays = get_best_plays(ctx, user.id, scoring_system, 0, usize::MAX, None)?;
|
||||
let conn = ctx.db.get()?;
|
||||
|
||||
// {{{ PM count
|
||||
|
@ -141,14 +140,14 @@ impl GoalStats {
|
|||
let peak_ptt = conn
|
||||
.prepare_cached(
|
||||
"
|
||||
SELECT s.creation_ptt
|
||||
FROM plays p
|
||||
JOIN scores s ON s.play_id = p.id
|
||||
WHERE user_id = ?
|
||||
AND scoring_system = ?
|
||||
ORDER BY s.creation_ptt DESC
|
||||
LIMIT 1
|
||||
",
|
||||
SELECT s.creation_ptt
|
||||
FROM plays p
|
||||
JOIN scores s ON s.play_id = p.id
|
||||
WHERE user_id = ?
|
||||
AND scoring_system = ?
|
||||
ORDER BY s.creation_ptt DESC
|
||||
LIMIT 1
|
||||
",
|
||||
)?
|
||||
.query_row(
|
||||
(
|
||||
|
@ -157,7 +156,7 @@ impl GoalStats {
|
|||
),
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|_| anyhow!("No ptt history data found"))?;
|
||||
.map_err(|_| anyhow!("No ptt history data found").tag(ErrorKind::User))?;
|
||||
// }}}
|
||||
// {{{ Peak PM relay
|
||||
let peak_pm_relay = {
|
||||
|
@ -309,6 +308,7 @@ impl Default for AchievementTowers {
|
|||
]);
|
||||
// }}}
|
||||
// {{{ PTT tower
|
||||
#[allow(clippy::zero_prefixed_literal)]
|
||||
let ptt_tower = AchievementTower::new(vec![
|
||||
Achievement::new(PTT(0800)),
|
||||
Achievement::new(PTT(0900)),
|
||||
|
|
|
@ -2,6 +2,7 @@
|
|||
use std::array;
|
||||
use std::num::NonZeroU64;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use anyhow::Context;
|
||||
use chrono::NaiveDateTime;
|
||||
use chrono::Utc;
|
||||
|
@ -13,6 +14,9 @@ use poise::serenity_prelude::{CreateAttachment, CreateEmbed, CreateEmbedAuthor,
|
|||
use rusqlite::Row;
|
||||
|
||||
use crate::arcaea::chart::{Chart, Song};
|
||||
use crate::context::ErrorKind;
|
||||
use crate::context::TagError;
|
||||
use crate::context::TaggedError;
|
||||
use crate::context::{Error, UserContext};
|
||||
use crate::user::User;
|
||||
|
||||
|
@ -61,7 +65,7 @@ impl CreatePlay {
|
|||
}
|
||||
|
||||
// {{{ Save
|
||||
pub fn save(self, ctx: &UserContext, user: &User, chart: &Chart) -> Result<Play, Error> {
|
||||
pub fn save(self, ctx: &UserContext, user: &User, chart: &Chart) -> Result<Play, TaggedError> {
|
||||
let conn = ctx.db.get()?;
|
||||
let attachment_id = self.discord_attachment_id.map(|i| i.get() as i64);
|
||||
|
||||
|
@ -104,9 +108,7 @@ impl CreatePlay {
|
|||
|
||||
for system in ScoringSystem::SCORING_SYSTEMS {
|
||||
let i = system.to_index();
|
||||
let plays = get_best_plays(ctx, user.id, system, 30, 30, None)?.ok();
|
||||
|
||||
let creation_ptt: Option<_> = try { rating_as_fixed(compute_b30_ptt(system, &plays?)) };
|
||||
let creation_ptt = try_compute_ptt(ctx, user.id, system, None)?;
|
||||
|
||||
conn.prepare_cached(
|
||||
"
|
||||
|
@ -321,10 +323,9 @@ impl Play {
|
|||
self.score(ScoringSystem::Standard).0,
|
||||
index
|
||||
);
|
||||
let icon_attachement = match chart.cached_jacket.as_ref() {
|
||||
Some(jacket) => Some(CreateAttachment::bytes(jacket.raw, &attachement_name)),
|
||||
None => None,
|
||||
};
|
||||
let icon_attachement = chart
|
||||
.cached_jacket
|
||||
.map(|jacket| CreateAttachment::bytes(jacket.raw, &attachement_name));
|
||||
|
||||
let mut embed = CreateEmbed::default()
|
||||
.title(format!(
|
||||
|
@ -378,7 +379,7 @@ impl Play {
|
|||
if let Some(max_recall) = self.max_recall {
|
||||
format!("{}", max_recall)
|
||||
} else {
|
||||
format!("-")
|
||||
"-".to_string()
|
||||
},
|
||||
true,
|
||||
)
|
||||
|
@ -409,14 +410,14 @@ impl Play {
|
|||
// {{{ General functions
|
||||
pub type PlayCollection<'a> = Vec<(Play, &'a Song, &'a Chart)>;
|
||||
|
||||
pub fn get_best_plays<'a>(
|
||||
ctx: &'a UserContext,
|
||||
pub fn get_best_plays(
|
||||
ctx: &UserContext,
|
||||
user_id: u32,
|
||||
scoring_system: ScoringSystem,
|
||||
min_amount: usize,
|
||||
max_amount: usize,
|
||||
before: Option<NaiveDateTime>,
|
||||
) -> Result<Result<PlayCollection<'a>, String>, Error> {
|
||||
) -> Result<PlayCollection<'_>, TaggedError> {
|
||||
let conn = ctx.db.get()?;
|
||||
// {{{ DB data fetching
|
||||
let mut plays = conn
|
||||
|
@ -453,10 +454,11 @@ pub fn get_best_plays<'a>(
|
|||
// }}}
|
||||
|
||||
if plays.len() < min_amount {
|
||||
return Ok(Err(format!(
|
||||
return Err(anyhow!(
|
||||
"Not enough plays found ({} out of a minimum of {min_amount})",
|
||||
plays.len()
|
||||
)));
|
||||
)
|
||||
.tag(crate::context::ErrorKind::User));
|
||||
}
|
||||
|
||||
// {{{ B30 computation
|
||||
|
@ -464,7 +466,27 @@ pub fn get_best_plays<'a>(
|
|||
plays.truncate(max_amount);
|
||||
// }}}
|
||||
|
||||
Ok(Ok(plays))
|
||||
Ok(plays)
|
||||
}
|
||||
|
||||
/// Compute the current ptt of a given user.
|
||||
///
|
||||
/// This is similar to directly calling [get_best_plays] and then passing the
|
||||
/// result into [compute_b30_ptt], except any user errors (i.e.: not enough
|
||||
/// plays available) get turned into [None] values.
|
||||
pub fn try_compute_ptt(
|
||||
ctx: &UserContext,
|
||||
user_id: u32,
|
||||
system: ScoringSystem,
|
||||
before: Option<NaiveDateTime>,
|
||||
) -> Result<Option<i32>, Error> {
|
||||
match get_best_plays(ctx, user_id, system, 30, 30, before) {
|
||||
Err(err) => match err.kind {
|
||||
ErrorKind::User => Ok(None),
|
||||
ErrorKind::Internal => Err(err.error),
|
||||
},
|
||||
Ok(plays) => Ok(Some(rating_as_fixed(compute_b30_ptt(system, &plays)))),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
|
@ -478,7 +500,7 @@ pub fn compute_b30_ptt(scoring_system: ScoringSystem, plays: &PlayCollection<'_>
|
|||
}
|
||||
// }}}
|
||||
// {{{ Maintenance functions
|
||||
pub async fn generate_missing_scores(ctx: &UserContext) -> Result<(), Error> {
|
||||
pub async fn generate_missing_scores(ctx: &UserContext) -> Result<(), TaggedError> {
|
||||
let conn = ctx.db.get()?;
|
||||
let mut query = conn.prepare_cached(
|
||||
"
|
||||
|
@ -504,10 +526,8 @@ pub async fn generate_missing_scores(ctx: &UserContext) -> Result<(), Error> {
|
|||
let play = play?;
|
||||
for system in ScoringSystem::SCORING_SYSTEMS {
|
||||
let i = system.to_index();
|
||||
let plays =
|
||||
get_best_plays(&ctx, play.user_id, system, 30, 30, Some(play.created_at))?.ok();
|
||||
let creation_ptt = try_compute_ptt(ctx, play.user_id, system, Some(play.created_at))?;
|
||||
|
||||
let creation_ptt: Option<_> = try { rating_as_fixed(compute_b30_ptt(system, &plays?)) };
|
||||
let raw_score = play.scores.0[i].0;
|
||||
|
||||
conn.prepare_cached(
|
||||
|
|
|
@ -1,7 +1,3 @@
|
|||
pub mod analyse;
|
||||
pub mod context;
|
||||
pub mod prepare_jackets;
|
||||
|
||||
#[derive(clap::Parser)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
pub struct Cli {
|
||||
|
@ -11,8 +7,6 @@ pub struct Cli {
|
|||
|
||||
#[derive(clap::Subcommand)]
|
||||
pub enum Command {
|
||||
/// Start the discord bot
|
||||
Discord {},
|
||||
PrepareJackets {},
|
||||
Analyse(analyse::Args),
|
||||
Analyse(crate::commands::analyse::Args),
|
||||
}
|
|
@ -1,9 +1,9 @@
|
|||
// {{{ Imports
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::cli::context::CliContext;
|
||||
use crate::commands::score::magic_impl;
|
||||
use crate::context::{Error, UserContext};
|
||||
use crate::context::CliContext;
|
||||
use shimmeringmoon::commands::score::magic_impl;
|
||||
use shimmeringmoon::context::{Error, UserContext};
|
||||
// }}}
|
||||
|
||||
#[derive(clap::Args)]
|
2
src/bin/cli/commands/mod.rs
Normal file
2
src/bin/cli/commands/mod.rs
Normal file
|
@ -0,0 +1,2 @@
|
|||
pub mod analyse;
|
||||
pub mod prepare_jackets;
|
|
@ -5,11 +5,11 @@ use std::io::{stdout, Write};
|
|||
use anyhow::{anyhow, bail, Context};
|
||||
use image::imageops::FilterType;
|
||||
|
||||
use crate::arcaea::chart::{Difficulty, SongCache};
|
||||
use crate::arcaea::jacket::{ImageVec, BITMAP_IMAGE_SIZE};
|
||||
use crate::assets::{get_asset_dir, get_data_dir};
|
||||
use crate::context::{connect_db, Error};
|
||||
use crate::recognition::fuzzy_song_name::guess_chart_name;
|
||||
use shimmeringmoon::arcaea::chart::{Difficulty, SongCache};
|
||||
use shimmeringmoon::arcaea::jacket::{ImageVec, BITMAP_IMAGE_SIZE};
|
||||
use shimmeringmoon::assets::{get_asset_dir, get_data_dir};
|
||||
use shimmeringmoon::context::{connect_db, Error};
|
||||
use shimmeringmoon::recognition::fuzzy_song_name::guess_chart_name;
|
||||
// }}}
|
||||
|
||||
/// Hacky function which clears the current line of the standard output.
|
|
@ -5,9 +5,10 @@ use std::str::FromStr;
|
|||
|
||||
use poise::serenity_prelude::{CreateAttachment, CreateMessage};
|
||||
|
||||
use crate::assets::get_var;
|
||||
use crate::context::Error;
|
||||
use crate::{commands::discord::MessageContext, context::UserContext};
|
||||
extern crate shimmeringmoon;
|
||||
use shimmeringmoon::assets::get_var;
|
||||
use shimmeringmoon::context::Error;
|
||||
use shimmeringmoon::{commands::discord::MessageContext, context::UserContext};
|
||||
// }}}
|
||||
|
||||
/// Similar in scope to [crate::commands::discord::mock::MockContext],
|
22
src/bin/cli/main.rs
Normal file
22
src/bin/cli/main.rs
Normal file
|
@ -0,0 +1,22 @@
|
|||
use clap::Parser;
|
||||
use command::{Cli, Command};
|
||||
use shimmeringmoon::context::{Error, UserContext};
|
||||
|
||||
mod command;
|
||||
mod commands;
|
||||
mod context;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Error> {
|
||||
let cli = Cli::parse();
|
||||
match cli.command {
|
||||
Command::PrepareJackets {} => {
|
||||
commands::prepare_jackets::run()?;
|
||||
}
|
||||
Command::Analyse(args) => {
|
||||
commands::analyse::run(args).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
88
src/bin/discord-bot/main.rs
Normal file
88
src/bin/discord-bot/main.rs
Normal file
|
@ -0,0 +1,88 @@
|
|||
use poise::serenity_prelude::{self as serenity};
|
||||
extern crate shimmeringmoon;
|
||||
use shimmeringmoon::arcaea::play::generate_missing_scores;
|
||||
use shimmeringmoon::context::{Error, UserContext};
|
||||
use shimmeringmoon::{commands, timed};
|
||||
use std::{env::var, sync::Arc, time::Duration};
|
||||
|
||||
// {{{ Error handler
|
||||
async fn on_error(error: poise::FrameworkError<'_, UserContext, Error>) {
|
||||
match error {
|
||||
error => {
|
||||
if let Err(e) = poise::builtins::on_error(error).await {
|
||||
println!("Error while handling error: {}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// }}}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// {{{ Poise options
|
||||
let options = poise::FrameworkOptions {
|
||||
commands: vec![
|
||||
commands::help(),
|
||||
commands::score::score(),
|
||||
commands::stats::stats(),
|
||||
commands::chart::chart(),
|
||||
],
|
||||
prefix_options: poise::PrefixFrameworkOptions {
|
||||
stripped_dynamic_prefix: Some(|_ctx, message, _user_ctx| {
|
||||
Box::pin(async {
|
||||
if message.author.bot || Into::<u64>::into(message.author.id) == 1 {
|
||||
Ok(None)
|
||||
} else if message.content.starts_with("!") {
|
||||
Ok(Some(message.content.split_at(1)))
|
||||
} else if message.guild_id.is_none() {
|
||||
if message.content.trim().len() == 0 {
|
||||
Ok(Some(("", "score magic")))
|
||||
} else {
|
||||
Ok(Some(("", &message.content[..])))
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
})
|
||||
}),
|
||||
edit_tracker: Some(Arc::new(poise::EditTracker::for_timespan(
|
||||
Duration::from_secs(3600),
|
||||
))),
|
||||
..Default::default()
|
||||
},
|
||||
on_error: |error| Box::pin(on_error(error)),
|
||||
..Default::default()
|
||||
};
|
||||
// }}}
|
||||
// {{{ Start poise
|
||||
let framework = poise::Framework::builder()
|
||||
.setup(move |ctx, _ready, framework| {
|
||||
Box::pin(async move {
|
||||
println!("Logged in as {}", _ready.user.name);
|
||||
poise::builtins::register_globally(ctx, &framework.options().commands).await?;
|
||||
let ctx = UserContext::new().await?;
|
||||
|
||||
if var("SHIMMERING_REGEN_SCORES").unwrap_or_default() == "1" {
|
||||
timed!("generate_missing_scores", {
|
||||
generate_missing_scores(&ctx).await?;
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ctx)
|
||||
})
|
||||
})
|
||||
.options(options)
|
||||
.build();
|
||||
|
||||
let token =
|
||||
var("SHIMMERING_DISCORD_TOKEN").expect("Missing `SHIMMERING_DISCORD_TOKEN` env var");
|
||||
let intents =
|
||||
serenity::GatewayIntents::non_privileged() | serenity::GatewayIntents::MESSAGE_CONTENT;
|
||||
|
||||
let client = serenity::ClientBuilder::new(token, intents)
|
||||
.framework(framework)
|
||||
.await;
|
||||
|
||||
client.unwrap().start().await.unwrap()
|
||||
// }}}
|
||||
}
|
|
@ -1,11 +1,11 @@
|
|||
// {{{ Imports
|
||||
use anyhow::anyhow;
|
||||
use poise::serenity_prelude::{CreateAttachment, CreateEmbed, CreateMessage};
|
||||
// {{{ Imports
|
||||
use poise::serenity_prelude::{CreateAttachment, CreateEmbed};
|
||||
|
||||
use crate::arcaea::{chart::Side, play::Play};
|
||||
use crate::context::{Context, Error};
|
||||
use crate::get_user;
|
||||
use crate::context::{Context, Error, ErrorKind, TagError, TaggedError};
|
||||
use crate::recognition::fuzzy_song_name::guess_song_and_chart;
|
||||
use crate::user::User;
|
||||
use std::io::Cursor;
|
||||
|
||||
use chrono::DateTime;
|
||||
|
@ -20,7 +20,7 @@ use poise::CreateReply;
|
|||
|
||||
use crate::arcaea::score::{Score, ScoringSystem};
|
||||
|
||||
use super::discord::MessageContext;
|
||||
use super::discord::{CreateReplyExtra, MessageContext};
|
||||
// }}}
|
||||
|
||||
// {{{ Top command
|
||||
|
@ -37,14 +37,13 @@ pub async fn chart(_ctx: Context<'_>) -> Result<(), Error> {
|
|||
// }}}
|
||||
// {{{ Info
|
||||
// {{{ Implementation
|
||||
async fn info_impl(ctx: &mut impl MessageContext, name: &str) -> Result<(), Error> {
|
||||
let (song, chart) = guess_song_and_chart(&ctx.data(), name)?;
|
||||
async fn info_impl(ctx: &mut impl MessageContext, name: &str) -> Result<(), TaggedError> {
|
||||
let (song, chart) = guess_song_and_chart(ctx.data(), name)?;
|
||||
|
||||
let attachement_name = "chart.png";
|
||||
let icon_attachement = match chart.cached_jacket.as_ref() {
|
||||
Some(jacket) => Some(CreateAttachment::bytes(jacket.raw, attachement_name)),
|
||||
None => None,
|
||||
};
|
||||
let icon_attachement = chart
|
||||
.cached_jacket
|
||||
.map(|jacket| CreateAttachment::bytes(jacket.raw, attachement_name));
|
||||
|
||||
let play_count: usize = ctx
|
||||
.data()
|
||||
|
@ -57,7 +56,8 @@ async fn info_impl(ctx: &mut impl MessageContext, name: &str) -> Result<(), Erro
|
|||
WHERE chart_id=?
|
||||
",
|
||||
)?
|
||||
.query_row([chart.id], |row| row.get(0))?;
|
||||
.query_row([chart.id], |row| row.get(0))
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut embed = CreateEmbed::default()
|
||||
.title(format!(
|
||||
|
@ -87,8 +87,13 @@ async fn info_impl(ctx: &mut impl MessageContext, name: &str) -> Result<(), Erro
|
|||
embed = embed.thumbnail(format!("attachment://{}", &attachement_name));
|
||||
}
|
||||
|
||||
ctx.send_files(icon_attachement, CreateMessage::new().embed(embed))
|
||||
.await?;
|
||||
ctx.send(
|
||||
CreateReply::default()
|
||||
.reply(true)
|
||||
.embed(embed)
|
||||
.attachments(icon_attachement),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
@ -138,24 +143,19 @@ async fn info(
|
|||
#[description = "Name of chart to show (difficulty at the end)"]
|
||||
name: String,
|
||||
) -> Result<(), Error> {
|
||||
info_impl(&mut ctx, &name).await?;
|
||||
let res = info_impl(&mut ctx, &name).await;
|
||||
ctx.handle_error(res).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
// }}}
|
||||
// }}}
|
||||
// {{{ Best score
|
||||
/// Show the best score on a given chart
|
||||
#[poise::command(prefix_command, slash_command, user_cooldown = 1)]
|
||||
async fn best(
|
||||
mut ctx: Context<'_>,
|
||||
#[rest]
|
||||
#[description = "Name of chart to show (difficulty at the end)"]
|
||||
name: String,
|
||||
) -> Result<(), Error> {
|
||||
let user = get_user!(&mut ctx);
|
||||
// {{{ Implementation
|
||||
async fn best_impl<C: MessageContext>(ctx: &mut C, name: &str) -> Result<Play, TaggedError> {
|
||||
let user = User::from_context(ctx)?;
|
||||
|
||||
let (song, chart) = guess_song_and_chart(&ctx.data(), &name)?;
|
||||
let (song, chart) = guess_song_and_chart(ctx.data(), name)?;
|
||||
let play = ctx
|
||||
.data()
|
||||
.db
|
||||
|
@ -181,6 +181,7 @@ async fn best(
|
|||
song.title,
|
||||
chart.difficulty
|
||||
)
|
||||
.tag(ErrorKind::User)
|
||||
})?;
|
||||
|
||||
let (embed, attachment) = play.to_embed(
|
||||
|
@ -192,27 +193,91 @@ async fn best(
|
|||
Some(&ctx.fetch_user(&user.discord_id).await?),
|
||||
)?;
|
||||
|
||||
ctx.channel_id()
|
||||
.send_files(ctx.http(), attachment, CreateMessage::new().embed(embed))
|
||||
.await?;
|
||||
ctx.send(
|
||||
CreateReply::default()
|
||||
.reply(true)
|
||||
.embed(embed)
|
||||
.attachments(attachment),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
Ok(play)
|
||||
}
|
||||
// }}}
|
||||
// {{{ Score plot
|
||||
// {{{ Tests
|
||||
// {{{ Tests
|
||||
#[cfg(test)]
|
||||
mod best_tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::{
|
||||
commands::{discord::mock::MockContext, score::magic_impl},
|
||||
with_test_ctx,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_scores() -> Result<(), Error> {
|
||||
with_test_ctx!("test/commands/chart/best/specify_difficulty", async |ctx| {
|
||||
best_impl(ctx, "Pentiment").await?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pick_correct_score() -> Result<(), Error> {
|
||||
with_test_ctx!(
|
||||
"test/commands/chart/best/last_byd",
|
||||
async |ctx: &mut MockContext| {
|
||||
magic_impl(
|
||||
ctx,
|
||||
&[
|
||||
PathBuf::from_str("test/screenshots/fracture_ray_ex.jpg")?,
|
||||
// Make sure we aren't considering higher scores from other stuff
|
||||
PathBuf::from_str("test/screenshots/antithese_74_kerning.jpg")?,
|
||||
PathBuf::from_str("test/screenshots/fracture_ray_missed_ex.jpg")?,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let play = best_impl(ctx, "Fracture ray").await?;
|
||||
assert_eq!(play.score(ScoringSystem::Standard).0, 9_805_651);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
// }}}
|
||||
// }}}
|
||||
// {{{ Discord wrapper
|
||||
/// Show the best score on a given chart
|
||||
#[poise::command(prefix_command, slash_command, user_cooldown = 10)]
|
||||
async fn plot(
|
||||
#[poise::command(prefix_command, slash_command, user_cooldown = 1)]
|
||||
async fn best(
|
||||
mut ctx: Context<'_>,
|
||||
scoring_system: Option<ScoringSystem>,
|
||||
#[rest]
|
||||
#[description = "Name of chart to show (difficulty at the end)"]
|
||||
name: String,
|
||||
) -> Result<(), Error> {
|
||||
let user = get_user!(&mut ctx);
|
||||
let res = best_impl(&mut ctx, &name).await;
|
||||
ctx.handle_error(res).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
// }}}
|
||||
// }}}
|
||||
// {{{ Score plot
|
||||
// {{{ Implementation
|
||||
async fn plot_impl<C: MessageContext>(
|
||||
ctx: &mut C,
|
||||
scoring_system: Option<ScoringSystem>,
|
||||
name: String,
|
||||
) -> Result<(), TaggedError> {
|
||||
let user = User::from_context(ctx)?;
|
||||
let scoring_system = scoring_system.unwrap_or_default();
|
||||
|
||||
let (song, chart) = guess_song_and_chart(&ctx.data(), &name)?;
|
||||
let (song, chart) = guess_song_and_chart(ctx.data(), &name)?;
|
||||
|
||||
// SAFETY: we limit the amount of plotted plays to 1000.
|
||||
let plays = ctx
|
||||
|
@ -236,13 +301,11 @@ async fn plot(
|
|||
.query_map((user.id, chart.id), |row| Play::from_sql(chart, row))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
|
||||
if plays.len() == 0 {
|
||||
ctx.reply(format!(
|
||||
"No plays found on {} [{:?}]",
|
||||
song.title, chart.difficulty
|
||||
))
|
||||
.await?;
|
||||
return Ok(());
|
||||
if plays.is_empty() {
|
||||
return Err(
|
||||
anyhow!("No plays found on {} [{:?}]", song.title, chart.difficulty)
|
||||
.tag(ErrorKind::User),
|
||||
);
|
||||
}
|
||||
|
||||
let min_time = plays.iter().map(|p| p.created_at).min().unwrap();
|
||||
|
@ -255,7 +318,7 @@ async fn plot(
|
|||
.0 as i64;
|
||||
|
||||
if min_score > 9_900_000 {
|
||||
min_score = 9_800_000;
|
||||
min_score = 9_900_000;
|
||||
} else if min_score > 9_800_000 {
|
||||
min_score = 9_800_000;
|
||||
} else if min_score > 9_500_000 {
|
||||
|
@ -331,9 +394,28 @@ async fn plot(
|
|||
let mut cursor = Cursor::new(&mut buffer);
|
||||
image.write_to(&mut cursor, image::ImageFormat::Png)?;
|
||||
|
||||
let reply = CreateReply::default().attachment(CreateAttachment::bytes(buffer, "plot.png"));
|
||||
let reply = CreateReply::default()
|
||||
.reply(true)
|
||||
.attachment(CreateAttachment::bytes(buffer, "plot.png"));
|
||||
ctx.send(reply).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
// }}}
|
||||
// {{{ Discord wrapper
|
||||
/// Show the best score on a given chart
|
||||
#[poise::command(prefix_command, slash_command, user_cooldown = 10)]
|
||||
async fn plot(
|
||||
mut ctx: Context<'_>,
|
||||
scoring_system: Option<ScoringSystem>,
|
||||
#[rest]
|
||||
#[description = "Name of chart to show (difficulty at the end)"]
|
||||
name: String,
|
||||
) -> Result<(), Error> {
|
||||
let res = plot_impl(&mut ctx, scoring_system, name).await;
|
||||
ctx.handle_error(res).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
// }}}
|
||||
// }}}
|
||||
|
|
|
@ -3,10 +3,11 @@ use std::num::NonZeroU64;
|
|||
use std::str::FromStr;
|
||||
|
||||
use poise::serenity_prelude::futures::future::join_all;
|
||||
use poise::serenity_prelude::{CreateAttachment, CreateMessage};
|
||||
use poise::serenity_prelude::{CreateAttachment, CreateEmbed};
|
||||
use poise::CreateReply;
|
||||
|
||||
use crate::arcaea::play::Play;
|
||||
use crate::context::{Error, UserContext};
|
||||
use crate::context::{Error, ErrorKind, TaggedError, UserContext};
|
||||
use crate::timed;
|
||||
// }}}
|
||||
|
||||
|
@ -22,17 +23,8 @@ pub trait MessageContext {
|
|||
/// Reply to the current message
|
||||
async fn reply(&mut self, text: &str) -> Result<(), Error>;
|
||||
|
||||
/// Deliver a message containing references to files.
|
||||
async fn send_files(
|
||||
&mut self,
|
||||
attachments: impl IntoIterator<Item = CreateAttachment>,
|
||||
message: CreateMessage,
|
||||
) -> Result<(), Error>;
|
||||
|
||||
/// Deliver a message
|
||||
async fn send(&mut self, message: CreateMessage) -> Result<(), Error> {
|
||||
self.send_files([], message).await
|
||||
}
|
||||
async fn send(&mut self, message: CreateReply) -> Result<(), Error>;
|
||||
|
||||
// {{{ Input attachments
|
||||
type Attachment;
|
||||
|
@ -61,6 +53,20 @@ pub trait MessageContext {
|
|||
.collect::<Result<_, Error>>()
|
||||
}
|
||||
// }}}
|
||||
// {{{ Erorr handling
|
||||
async fn handle_error<V>(&mut self, res: Result<V, TaggedError>) -> Result<Option<V>, Error> {
|
||||
match res {
|
||||
Ok(v) => Ok(Some(v)),
|
||||
Err(e) => match e.kind {
|
||||
ErrorKind::Internal => Err(e.error),
|
||||
ErrorKind::User => {
|
||||
self.reply(&format!("{}", e.error)).await?;
|
||||
Ok(None)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
// }}}
|
||||
}
|
||||
// }}}
|
||||
// {{{ Poise implementation
|
||||
|
@ -87,14 +93,8 @@ impl<'a> MessageContext for poise::Context<'a, UserContext, Error> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_files(
|
||||
&mut self,
|
||||
attachments: impl IntoIterator<Item = CreateAttachment>,
|
||||
message: CreateMessage,
|
||||
) -> Result<(), Error> {
|
||||
self.channel_id()
|
||||
.send_files(self.http(), attachments, message)
|
||||
.await?;
|
||||
async fn send(&mut self, message: CreateReply) -> Result<(), Error> {
|
||||
poise::send_reply(*self, message).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@ -122,6 +122,10 @@ impl<'a> MessageContext for poise::Context<'a, UserContext, Error> {
|
|||
pub mod mock {
|
||||
use std::{env, fs, path::PathBuf};
|
||||
|
||||
use poise::serenity_prelude::CreateEmbed;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// A mock context usable for testing. Messages and attachments are
|
||||
|
@ -130,7 +134,26 @@ pub mod mock {
|
|||
pub struct MockContext {
|
||||
pub user_id: u64,
|
||||
pub data: UserContext,
|
||||
pub messages: Vec<(CreateMessage, Vec<CreateAttachment>)>,
|
||||
messages: Vec<ReplyEssence>,
|
||||
}
|
||||
|
||||
/// Holds test-relevant data about an attachment.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct AttachmentEssence {
|
||||
filename: String,
|
||||
description: Option<String>,
|
||||
/// SHA-256 hash of the file
|
||||
hash: String,
|
||||
}
|
||||
|
||||
/// Holds test-relevant data about a reply.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct ReplyEssence {
|
||||
reply: bool,
|
||||
ephermal: Option<bool>,
|
||||
content: Option<String>,
|
||||
embeds: Vec<CreateEmbed>,
|
||||
attachments: Vec<AttachmentEssence>,
|
||||
}
|
||||
|
||||
impl MockContext {
|
||||
|
@ -157,10 +180,8 @@ pub mod mock {
|
|||
}
|
||||
|
||||
fs::create_dir_all(path)?;
|
||||
for (i, (message, attachments)) in self.messages.iter().enumerate() {
|
||||
let dir = path.join(format!("{i}"));
|
||||
fs::create_dir_all(&dir)?;
|
||||
let message_file = dir.join("message.toml");
|
||||
for (i, message) in self.messages.iter().enumerate() {
|
||||
let message_file = path.join(format!("{i}.toml"));
|
||||
|
||||
if message_file.exists() {
|
||||
assert_eq!(
|
||||
|
@ -170,28 +191,6 @@ pub mod mock {
|
|||
} else {
|
||||
fs::write(&message_file, toml::to_string_pretty(message)?)?;
|
||||
}
|
||||
|
||||
for attachment in attachments {
|
||||
let path = dir.join(&attachment.filename);
|
||||
|
||||
if path.exists() {
|
||||
if &attachment.data != &fs::read(&path)? {
|
||||
panic!("Attachment differs from {path:?}");
|
||||
}
|
||||
} else {
|
||||
fs::write(&path, &attachment.data)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure there's no extra attachments on disk
|
||||
let file_count = fs::read_dir(dir)?.count();
|
||||
if file_count != attachments.len() + 1 {
|
||||
panic!(
|
||||
"Only {} attachments found instead of {}",
|
||||
attachments.len(),
|
||||
file_count - 1
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
@ -219,18 +218,33 @@ pub mod mock {
|
|||
}
|
||||
|
||||
async fn reply(&mut self, text: &str) -> Result<(), Error> {
|
||||
self.messages
|
||||
.push((CreateMessage::new().content(text), Vec::new()));
|
||||
Ok(())
|
||||
self.send(CreateReply::default().content(text).reply(true))
|
||||
.await
|
||||
}
|
||||
|
||||
async fn send_files(
|
||||
&mut self,
|
||||
attachments: impl IntoIterator<Item = CreateAttachment>,
|
||||
message: CreateMessage,
|
||||
) -> Result<(), Error> {
|
||||
self.messages
|
||||
.push((message, attachments.into_iter().collect()));
|
||||
async fn send(&mut self, message: CreateReply) -> Result<(), Error> {
|
||||
self.messages.push(ReplyEssence {
|
||||
reply: message.reply,
|
||||
ephermal: message.ephemeral,
|
||||
content: message.content,
|
||||
embeds: message.embeds,
|
||||
attachments: message
|
||||
.attachments
|
||||
.into_iter()
|
||||
.map(|attachment| AttachmentEssence {
|
||||
filename: attachment.filename,
|
||||
description: attachment.description,
|
||||
hash: {
|
||||
let hash = Sha256::digest(&attachment.data);
|
||||
let string = base16ct::lower::encode_string(&hash);
|
||||
|
||||
// We allocate twice, but it's only at the end of tests,
|
||||
// so it should be fineeeeeeee
|
||||
format!("sha256_{string}")
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@ -265,4 +279,27 @@ pub mod mock {
|
|||
pub fn play_song_title<'a>(ctx: &'a impl MessageContext, play: &'a Play) -> Result<&'a str, Error> {
|
||||
Ok(&ctx.data().song_cache.lookup_chart(play.chart_id)?.0.title)
|
||||
}
|
||||
|
||||
pub trait CreateReplyExtra {
|
||||
fn attachments(self, attachments: impl IntoIterator<Item = CreateAttachment>) -> Self;
|
||||
fn embeds(self, embeds: impl IntoIterator<Item = CreateEmbed>) -> Self;
|
||||
}
|
||||
|
||||
impl CreateReplyExtra for CreateReply {
|
||||
fn attachments(mut self, attachments: impl IntoIterator<Item = CreateAttachment>) -> Self {
|
||||
for attachment in attachments.into_iter() {
|
||||
self = self.attachment(attachment);
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
fn embeds(mut self, embeds: impl IntoIterator<Item = CreateEmbed>) -> Self {
|
||||
for embed in embeds.into_iter() {
|
||||
self = self.embed(embed);
|
||||
}
|
||||
|
||||
self
|
||||
}
|
||||
}
|
||||
// }}}
|
||||
|
|
|
@ -33,7 +33,7 @@ pub async fn help(
|
|||
/// Explains the different scoring systems
|
||||
#[poise::command(prefix_command, slash_command)]
|
||||
async fn scoring(ctx: Context<'_>) -> Result<(), Error> {
|
||||
static CONTENT: &'static str = "
|
||||
static CONTENT: &str = "
|
||||
## 1. Standard scoring (`standard`):
|
||||
This is the base-game Arcaea scoring system we all know and love! Points are awarded for each note, with a `2:1` pure:far ratio. The score is then scaled up such that `10_000_000` is the maximum. Last but not least, the number of max pures is added to the total.
|
||||
|
||||
|
@ -58,7 +58,7 @@ Most commands take an optional parameter specifying what scoring system to use.
|
|||
/// Explains the different scoring systems using gen-z slang
|
||||
#[poise::command(prefix_command, slash_command)]
|
||||
async fn scoringz(ctx: Context<'_>) -> Result<(), Error> {
|
||||
static CONTENT: &'static str = "
|
||||
static CONTENT: &str = "
|
||||
## 1. Standard scoring (`standard`):
|
||||
Alright, fam, this is the OG Arcaea scoring setup that everyone vibes with! You hit notes, you get points — easy clap. The ratio is straight up `2:1` pure:far. The score then gets a glow-up, maxing out at `10 milly`. And hold up, you even get bonus points for those max pures at the end. No cap, this is the classic way to flex your skills.
|
||||
|
||||
|
|
|
@ -1,16 +1,15 @@
|
|||
// {{{ Imports
|
||||
use crate::arcaea::play::{CreatePlay, Play};
|
||||
use crate::arcaea::score::Score;
|
||||
use crate::context::{Context, Error};
|
||||
use crate::context::{Context, Error, ErrorKind, TagError, TaggedError};
|
||||
use crate::recognition::recognize::{ImageAnalyzer, ScoreKind};
|
||||
use crate::user::User;
|
||||
use crate::{get_user, timed};
|
||||
use crate::{get_user_error, timed};
|
||||
use anyhow::anyhow;
|
||||
use image::DynamicImage;
|
||||
use poise::serenity_prelude as serenity;
|
||||
use poise::serenity_prelude::CreateMessage;
|
||||
use poise::{serenity_prelude as serenity, CreateReply};
|
||||
|
||||
use super::discord::MessageContext;
|
||||
use super::discord::{CreateReplyExtra, MessageContext};
|
||||
// }}}
|
||||
|
||||
// {{{ Score
|
||||
|
@ -30,13 +29,12 @@ pub async fn score(_ctx: Context<'_>) -> Result<(), Error> {
|
|||
pub async fn magic_impl<C: MessageContext>(
|
||||
ctx: &mut C,
|
||||
files: &[C::Attachment],
|
||||
) -> Result<Vec<Play>, Error> {
|
||||
let user = get_user!(ctx);
|
||||
let files = ctx.download_images(&files).await?;
|
||||
) -> Result<Vec<Play>, TaggedError> {
|
||||
let user = User::from_context(ctx)?;
|
||||
let files = ctx.download_images(files).await?;
|
||||
|
||||
if files.len() == 0 {
|
||||
ctx.reply("No images found attached to message").await?;
|
||||
return Ok(vec![]);
|
||||
if files.is_empty() {
|
||||
return Err(anyhow!("No images found attached to message").tag(ErrorKind::User));
|
||||
}
|
||||
|
||||
let mut embeds = Vec::with_capacity(files.len());
|
||||
|
@ -50,7 +48,7 @@ pub async fn magic_impl<C: MessageContext>(
|
|||
let mut grayscale_image = DynamicImage::ImageLuma8(image.to_luma8());
|
||||
// }}}
|
||||
|
||||
let result: Result<(), Error> = try {
|
||||
let result: Result<(), TaggedError> = try {
|
||||
// {{{ Detection
|
||||
|
||||
let kind = timed!("read_score_kind", {
|
||||
|
@ -102,13 +100,13 @@ pub async fn magic_impl<C: MessageContext>(
|
|||
.with_attachment(C::attachment_id(attachment))
|
||||
.with_fars(maybe_fars)
|
||||
.with_max_recall(max_recall)
|
||||
.save(&ctx.data(), &user, &chart)?;
|
||||
.save(ctx.data(), &user, chart)?;
|
||||
// }}}
|
||||
// }}}
|
||||
// {{{ Deliver embed
|
||||
|
||||
let (embed, attachment) = timed!("to embed", {
|
||||
play.to_embed(ctx.data(), &user, &song, &chart, i, None)?
|
||||
play.to_embed(ctx.data(), &user, song, chart, i, None)?
|
||||
});
|
||||
|
||||
plays.push(play);
|
||||
|
@ -118,15 +116,21 @@ pub async fn magic_impl<C: MessageContext>(
|
|||
};
|
||||
|
||||
if let Err(err) = result {
|
||||
let user_err = get_user_error!(err);
|
||||
analyzer
|
||||
.send_discord_error(ctx, &image, C::filename(&attachment), err)
|
||||
.send_discord_error(ctx, &image, C::filename(attachment), user_err)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
if embeds.len() > 0 {
|
||||
ctx.send_files(attachments, CreateMessage::new().embeds(embeds))
|
||||
.await?;
|
||||
if !embeds.is_empty() {
|
||||
ctx.send(
|
||||
CreateReply::default()
|
||||
.reply(true)
|
||||
.embeds(embeds)
|
||||
.attachments(attachments),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(plays)
|
||||
|
@ -203,7 +207,8 @@ pub async fn magic(
|
|||
mut ctx: Context<'_>,
|
||||
#[description = "Images containing scores"] files: Vec<serenity::Attachment>,
|
||||
) -> Result<(), Error> {
|
||||
magic_impl(&mut ctx, &files).await?;
|
||||
let res = magic_impl(&mut ctx, &files).await;
|
||||
ctx.handle_error(res).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
@ -211,10 +216,12 @@ pub async fn magic(
|
|||
// }}}
|
||||
// {{{ Score show
|
||||
// {{{ Implementation
|
||||
pub async fn show_impl<C: MessageContext>(ctx: &mut C, ids: &[u32]) -> Result<Vec<Play>, Error> {
|
||||
if ids.len() == 0 {
|
||||
ctx.reply("Empty ID list provided").await?;
|
||||
return Ok(vec![]);
|
||||
pub async fn show_impl<C: MessageContext>(
|
||||
ctx: &mut C,
|
||||
ids: &[u32],
|
||||
) -> Result<Vec<Play>, TaggedError> {
|
||||
if ids.is_empty() {
|
||||
return Err(anyhow!("Empty ID list provided").tag(ErrorKind::User));
|
||||
}
|
||||
|
||||
let mut embeds = Vec::with_capacity(ids.len());
|
||||
|
@ -225,7 +232,7 @@ pub async fn show_impl<C: MessageContext>(ctx: &mut C, ids: &[u32]) -> Result<Ve
|
|||
let result = conn
|
||||
.prepare_cached(
|
||||
"
|
||||
SELECT
|
||||
SELECT
|
||||
p.id, p.chart_id, p.user_id, p.created_at,
|
||||
p.max_recall, p.far_notes, s.score,
|
||||
u.discord_id
|
||||
|
@ -250,7 +257,7 @@ pub async fn show_impl<C: MessageContext>(ctx: &mut C, ids: &[u32]) -> Result<Ve
|
|||
let (song, chart, play, discord_id) = match result {
|
||||
None => {
|
||||
ctx.send(
|
||||
CreateMessage::new().content(format!("Could not find play with id {}", id)),
|
||||
CreateReply::default().content(format!("Could not find play with id {}", id)),
|
||||
)
|
||||
.await?;
|
||||
continue;
|
||||
|
@ -269,9 +276,14 @@ pub async fn show_impl<C: MessageContext>(ctx: &mut C, ids: &[u32]) -> Result<Ve
|
|||
plays.push(play);
|
||||
}
|
||||
|
||||
if embeds.len() > 0 {
|
||||
ctx.send_files(attachments, CreateMessage::new().embeds(embeds))
|
||||
.await?;
|
||||
if !embeds.is_empty() {
|
||||
ctx.send(
|
||||
CreateReply::default()
|
||||
.reply(true)
|
||||
.embeds(embeds)
|
||||
.attachments(attachments),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(plays)
|
||||
|
@ -333,7 +345,8 @@ pub async fn show(
|
|||
mut ctx: Context<'_>,
|
||||
#[description = "Ids of score to show"] ids: Vec<u32>,
|
||||
) -> Result<(), Error> {
|
||||
show_impl(&mut ctx, &ids).await?;
|
||||
let res = show_impl(&mut ctx, &ids).await;
|
||||
ctx.handle_error(res).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
@ -341,12 +354,11 @@ pub async fn show(
|
|||
// }}}
|
||||
// {{{ Score delete
|
||||
// {{{ Implementation
|
||||
pub async fn delete_impl<C: MessageContext>(ctx: &mut C, ids: &[u32]) -> Result<(), Error> {
|
||||
let user = get_user!(ctx);
|
||||
pub async fn delete_impl<C: MessageContext>(ctx: &mut C, ids: &[u32]) -> Result<(), TaggedError> {
|
||||
let user = User::from_context(ctx)?;
|
||||
|
||||
if ids.len() == 0 {
|
||||
ctx.reply("Empty ID list provided").await?;
|
||||
return Ok(());
|
||||
if ids.is_empty() {
|
||||
return Err(anyhow!("Empty ID list provided").tag(ErrorKind::User));
|
||||
}
|
||||
|
||||
let mut count = 0;
|
||||
|
@ -472,7 +484,8 @@ pub async fn delete(
|
|||
mut ctx: Context<'_>,
|
||||
#[description = "Id of score to delete"] ids: Vec<u32>,
|
||||
) -> Result<(), Error> {
|
||||
delete_impl(&mut ctx, &ids).await?;
|
||||
let res = delete_impl(&mut ctx, &ids).await;
|
||||
ctx.handle_error(res).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
@ -18,10 +18,11 @@ use crate::assets::{
|
|||
TOP_BACKGROUND,
|
||||
};
|
||||
use crate::bitmap::{Align, BitmapCanvas, Color, LayoutDrawer, LayoutManager, Rect};
|
||||
use crate::context::{Context, Error};
|
||||
use crate::context::{Context, Error, TaggedError};
|
||||
use crate::logs::debug_image_log;
|
||||
use crate::user::User;
|
||||
use crate::{assert_is_pookie, get_user, reply_errors, timed};
|
||||
|
||||
use super::discord::MessageContext;
|
||||
// }}}
|
||||
|
||||
// {{{ Stats
|
||||
|
@ -37,31 +38,26 @@ pub async fn stats(_ctx: Context<'_>) -> Result<(), Error> {
|
|||
}
|
||||
// }}}
|
||||
// {{{ Render best plays
|
||||
async fn best_plays(
|
||||
ctx: &mut Context<'_>,
|
||||
async fn best_plays<C: MessageContext>(
|
||||
ctx: &mut C,
|
||||
user: &User,
|
||||
scoring_system: ScoringSystem,
|
||||
grid_size: (u32, u32),
|
||||
require_full: bool,
|
||||
) -> Result<(), Error> {
|
||||
) -> Result<(), TaggedError> {
|
||||
let user_ctx = ctx.data();
|
||||
let plays = reply_errors!(
|
||||
ctx,
|
||||
timed!("get_best_plays", {
|
||||
get_best_plays(
|
||||
user_ctx,
|
||||
user.id,
|
||||
scoring_system,
|
||||
if require_full {
|
||||
grid_size.0 * grid_size.1
|
||||
} else {
|
||||
grid_size.0 * (grid_size.1.max(1) - 1) + 1
|
||||
} as usize,
|
||||
(grid_size.0 * grid_size.1) as usize,
|
||||
None,
|
||||
)?
|
||||
})
|
||||
);
|
||||
let plays = get_best_plays(
|
||||
user_ctx,
|
||||
user.id,
|
||||
scoring_system,
|
||||
if require_full {
|
||||
grid_size.0 * grid_size.1
|
||||
} else {
|
||||
grid_size.0 * (grid_size.1.max(1) - 1) + 1
|
||||
} as usize,
|
||||
(grid_size.0 * grid_size.1) as usize,
|
||||
None,
|
||||
)?;
|
||||
|
||||
// {{{ Layout
|
||||
let mut layout = LayoutManager::default();
|
||||
|
@ -132,7 +128,7 @@ async fn best_plays(
|
|||
let bg_center = Rect::from_image(bg).center();
|
||||
|
||||
// Draw background
|
||||
drawer.blit_rbga(item_area, (-8, jacket_margin as i32), bg);
|
||||
drawer.blit_rbga(item_area, (-8, jacket_margin), bg);
|
||||
with_font(&EXO_FONT, |faces| {
|
||||
drawer.text(
|
||||
item_area,
|
||||
|
@ -420,20 +416,49 @@ async fn best_plays(
|
|||
}
|
||||
// }}}
|
||||
// {{{ B30
|
||||
// {{{ Implementation
|
||||
pub async fn b30_impl<C: MessageContext>(
|
||||
ctx: &mut C,
|
||||
scoring_system: Option<ScoringSystem>,
|
||||
) -> Result<(), TaggedError> {
|
||||
let user = User::from_context(ctx)?;
|
||||
best_plays(ctx, &user, scoring_system.unwrap_or_default(), (5, 6), true).await?;
|
||||
Ok(())
|
||||
}
|
||||
// }}}
|
||||
// {{{ Discord wrapper
|
||||
/// Show the 30 best scores
|
||||
#[poise::command(prefix_command, slash_command, user_cooldown = 30)]
|
||||
pub async fn b30(mut ctx: Context<'_>, scoring_system: Option<ScoringSystem>) -> Result<(), Error> {
|
||||
let user = get_user!(&mut ctx);
|
||||
let res = b30_impl(&mut ctx, scoring_system).await;
|
||||
ctx.handle_error(res).await?;
|
||||
Ok(())
|
||||
}
|
||||
// }}}
|
||||
// }}}
|
||||
// {{{ B-any
|
||||
// {{{ Implementation
|
||||
async fn bany_impl<C: MessageContext>(
|
||||
ctx: &mut C,
|
||||
scoring_system: Option<ScoringSystem>,
|
||||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<(), TaggedError> {
|
||||
let user = User::from_context(ctx)?;
|
||||
user.assert_is_pookie()?;
|
||||
best_plays(
|
||||
&mut ctx,
|
||||
ctx,
|
||||
&user,
|
||||
scoring_system.unwrap_or_default(),
|
||||
(5, 6),
|
||||
true,
|
||||
(width, height),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
// }}}
|
||||
// {{{ Discord wrapper
|
||||
#[poise::command(prefix_command, slash_command, hide_in_help, global_cooldown = 5)]
|
||||
pub async fn bany(
|
||||
mut ctx: Context<'_>,
|
||||
|
@ -441,23 +466,16 @@ pub async fn bany(
|
|||
width: u32,
|
||||
height: u32,
|
||||
) -> Result<(), Error> {
|
||||
let user = get_user!(&mut ctx);
|
||||
assert_is_pookie!(ctx, user);
|
||||
best_plays(
|
||||
&mut ctx,
|
||||
&user,
|
||||
scoring_system.unwrap_or_default(),
|
||||
(width, height),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
let res = bany_impl(&mut ctx, scoring_system, width, height).await;
|
||||
ctx.handle_error(res).await?;
|
||||
Ok(())
|
||||
}
|
||||
// }}}
|
||||
// }}}
|
||||
// {{{ Meta
|
||||
/// Show stats about the bot itself.
|
||||
#[poise::command(prefix_command, slash_command, user_cooldown = 1)]
|
||||
async fn meta(mut ctx: Context<'_>) -> Result<(), Error> {
|
||||
let user = get_user!(&mut ctx);
|
||||
// {{{ Implementation
|
||||
async fn meta_impl<C: MessageContext>(ctx: &mut C) -> Result<(), TaggedError> {
|
||||
let user = User::from_context(ctx)?;
|
||||
let conn = ctx.data().db.get()?;
|
||||
let song_count: usize = conn
|
||||
.prepare_cached("SELECT count() as count FROM songs")?
|
||||
|
@ -504,8 +522,10 @@ async fn meta(mut ctx: Context<'_>) -> Result<(), Error> {
|
|||
.field("Plays", format!("{play_count}"), true)
|
||||
.field("Your plays", format!("{your_play_count}"), true);
|
||||
|
||||
ctx.send(CreateReply::default().embed(embed)).await?;
|
||||
ctx.send(CreateReply::default().reply(true).embed(embed))
|
||||
.await?;
|
||||
|
||||
// TODO: remove once achivement system is implemented
|
||||
println!(
|
||||
"{:?}",
|
||||
GoalStats::make(ctx.data(), &user, ScoringSystem::Standard).await?
|
||||
|
@ -514,3 +534,14 @@ async fn meta(mut ctx: Context<'_>) -> Result<(), Error> {
|
|||
Ok(())
|
||||
}
|
||||
// }}}
|
||||
// {{{ Discord wrapper
|
||||
/// Show stats about the bot itself.
|
||||
#[poise::command(prefix_command, slash_command, user_cooldown = 1)]
|
||||
async fn meta(mut ctx: Context<'_>) -> Result<(), Error> {
|
||||
let res = meta_impl(&mut ctx).await;
|
||||
ctx.handle_error(res).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
// }}}
|
||||
// }}}
|
||||
|
|
|
@ -10,37 +10,3 @@ macro_rules! edit_reply {
|
|||
$handle.edit($ctx, edited)
|
||||
}};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! get_user {
|
||||
($ctx:expr) => {{
|
||||
crate::reply_errors!($ctx, crate::user::User::from_context($ctx))
|
||||
}};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! assert_is_pookie {
|
||||
($ctx:expr, $user:expr) => {{
|
||||
if !$user.is_pookie {
|
||||
$ctx.reply("This feature is reserved for my pookies. Sowwy :3")
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! reply_errors {
|
||||
($ctx:expr, $default:expr, $value:expr) => {
|
||||
match $value {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
crate::commands::discord::MessageContext::reply($ctx, &format!("{err}")).await?;
|
||||
return Ok($default);
|
||||
}
|
||||
}
|
||||
};
|
||||
($ctx:expr, $value:expr) => {
|
||||
crate::reply_errors!($ctx, Default::default(), $value)
|
||||
};
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ use std::sync::LazyLock;
|
|||
|
||||
use crate::arcaea::{chart::SongCache, jacket::JacketCache};
|
||||
use crate::assets::{get_data_dir, EXO_FONT, GEOSANS_FONT, KAZESAWA_BOLD_FONT, KAZESAWA_FONT};
|
||||
use crate::commands::discord::MessageContext;
|
||||
use crate::recognition::{hyperglass::CharMeasurements, ui::UIMeasurements};
|
||||
use crate::timed;
|
||||
// }}}
|
||||
|
@ -17,6 +18,70 @@ use crate::timed;
|
|||
pub type Error = anyhow::Error;
|
||||
pub type Context<'a> = poise::Context<'a, UserContext, Error>;
|
||||
// }}}
|
||||
// {{{ Error handling
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ErrorKind {
|
||||
User,
|
||||
Internal,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct TaggedError {
|
||||
pub kind: ErrorKind,
|
||||
pub error: Error,
|
||||
}
|
||||
|
||||
impl TaggedError {
|
||||
#[inline]
|
||||
pub fn new(kind: ErrorKind, error: Error) -> Self {
|
||||
Self { kind, error }
|
||||
}
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! get_user_error {
|
||||
($err:expr) => {{
|
||||
match $err.kind {
|
||||
$crate::context::ErrorKind::User => $err.error,
|
||||
$crate::context::ErrorKind::Internal => Err($err.error)?,
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
/// Handles a [TaggedError], showing user errors to the user,
|
||||
/// and throwing away anything else.
|
||||
pub async fn discord_error_handler<V>(
|
||||
ctx: &mut impl MessageContext,
|
||||
res: Result<V, TaggedError>,
|
||||
) -> Result<Option<V>, Error> {
|
||||
match res {
|
||||
Ok(v) => Ok(Some(v)),
|
||||
Err(e) => match e.kind {
|
||||
ErrorKind::Internal => Err(e.error),
|
||||
ErrorKind::User => {
|
||||
ctx.reply(&format!("{}", e.error)).await?;
|
||||
Ok(None)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
impl<E: Into<Error>> From<E> for TaggedError {
|
||||
fn from(value: E) -> Self {
|
||||
Self::new(ErrorKind::Internal, value.into())
|
||||
}
|
||||
}
|
||||
|
||||
pub trait TagError {
|
||||
fn tag(self, tag: ErrorKind) -> TaggedError;
|
||||
}
|
||||
|
||||
impl TagError for Error {
|
||||
fn tag(self, tag: ErrorKind) -> TaggedError {
|
||||
TaggedError::new(tag, self)
|
||||
}
|
||||
}
|
||||
// }}}
|
||||
// {{{ DB connection
|
||||
pub type DbConnection = r2d2::Pool<SqliteConnectionManager>;
|
||||
|
||||
|
@ -106,7 +171,7 @@ pub mod testing {
|
|||
.await
|
||||
}
|
||||
|
||||
pub fn import_songs_and_jackets_from(to: &Path) -> () {
|
||||
pub fn import_songs_and_jackets_from(to: &Path) {
|
||||
let out = std::process::Command::new("scripts/copy-chart-info.sh")
|
||||
.arg(get_data_dir())
|
||||
.arg(to)
|
||||
|
@ -124,16 +189,17 @@ pub mod testing {
|
|||
($test_path:expr, $f:expr) => {{
|
||||
use std::str::FromStr;
|
||||
|
||||
let mut data = (*crate::context::testing::get_shared_context().await).clone();
|
||||
let mut data = (*$crate::context::testing::get_shared_context().await).clone();
|
||||
let dir = tempfile::tempdir()?;
|
||||
data.db = crate::context::connect_db(dir.path());
|
||||
crate::context::testing::import_songs_and_jackets_from(dir.path());
|
||||
data.db = $crate::context::connect_db(dir.path());
|
||||
$crate::context::testing::import_songs_and_jackets_from(dir.path());
|
||||
|
||||
let mut ctx = crate::commands::discord::mock::MockContext::new(data);
|
||||
crate::user::User::create_from_context(&ctx)?;
|
||||
let mut ctx = $crate::commands::discord::mock::MockContext::new(data);
|
||||
let res = $crate::user::User::create_from_context(&ctx);
|
||||
ctx.handle_error(res).await?;
|
||||
|
||||
let res: Result<(), Error> = $f(&mut ctx).await;
|
||||
res?;
|
||||
let res: Result<(), $crate::context::TaggedError> = $f(&mut ctx).await;
|
||||
ctx.handle_error(res).await?;
|
||||
|
||||
ctx.golden(&std::path::PathBuf::from_str($test_path)?)?;
|
||||
Ok(())
|
||||
|
|
21
src/lib.rs
Normal file
21
src/lib.rs
Normal file
|
@ -0,0 +1,21 @@
|
|||
#![allow(async_fn_in_trait)]
|
||||
#![feature(iter_map_windows)]
|
||||
#![feature(let_chains)]
|
||||
#![feature(array_try_map)]
|
||||
#![feature(async_closure)]
|
||||
#![feature(try_blocks)]
|
||||
#![feature(thread_local)]
|
||||
#![feature(generic_arg_infer)]
|
||||
#![feature(iter_collect_into)]
|
||||
|
||||
pub mod arcaea;
|
||||
pub mod assets;
|
||||
pub mod bitmap;
|
||||
pub mod commands;
|
||||
pub mod context;
|
||||
pub mod levenshtein;
|
||||
pub mod logs;
|
||||
pub mod recognition;
|
||||
pub mod time;
|
||||
pub mod transform;
|
||||
pub mod user;
|
126
src/main.rs
126
src/main.rs
|
@ -1,126 +0,0 @@
|
|||
#![warn(clippy::str_to_string)]
|
||||
#![feature(iter_map_windows)]
|
||||
#![feature(let_chains)]
|
||||
#![feature(array_try_map)]
|
||||
#![feature(async_closure)]
|
||||
#![feature(try_blocks)]
|
||||
#![feature(thread_local)]
|
||||
#![feature(generic_arg_infer)]
|
||||
#![feature(lazy_cell_consume)]
|
||||
#![feature(iter_collect_into)]
|
||||
|
||||
mod arcaea;
|
||||
mod assets;
|
||||
mod bitmap;
|
||||
mod cli;
|
||||
mod commands;
|
||||
mod context;
|
||||
mod levenshtein;
|
||||
mod logs;
|
||||
mod recognition;
|
||||
mod time;
|
||||
mod transform;
|
||||
mod user;
|
||||
|
||||
use arcaea::play::generate_missing_scores;
|
||||
use clap::Parser;
|
||||
use cli::{Cli, Command};
|
||||
use context::{Error, UserContext};
|
||||
use poise::serenity_prelude::{self as serenity};
|
||||
use std::{env::var, sync::Arc, time::Duration};
|
||||
|
||||
// {{{ Error handler
|
||||
async fn on_error(error: poise::FrameworkError<'_, UserContext, Error>) {
|
||||
match error {
|
||||
error => {
|
||||
if let Err(e) = poise::builtins::on_error(error).await {
|
||||
println!("Error while handling error: {}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// }}}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let cli = Cli::parse();
|
||||
match cli.command {
|
||||
Command::Discord {} => {
|
||||
// {{{ Poise options
|
||||
let options = poise::FrameworkOptions {
|
||||
commands: vec![
|
||||
commands::help(),
|
||||
commands::score::score(),
|
||||
commands::stats::stats(),
|
||||
commands::chart::chart(),
|
||||
],
|
||||
prefix_options: poise::PrefixFrameworkOptions {
|
||||
stripped_dynamic_prefix: Some(|_ctx, message, _user_ctx| {
|
||||
Box::pin(async {
|
||||
if message.author.bot || Into::<u64>::into(message.author.id) == 1 {
|
||||
Ok(None)
|
||||
} else if message.content.starts_with("!") {
|
||||
Ok(Some(message.content.split_at(1)))
|
||||
} else if message.guild_id.is_none() {
|
||||
if message.content.trim().len() == 0 {
|
||||
Ok(Some(("", "score magic")))
|
||||
} else {
|
||||
Ok(Some(("", &message.content[..])))
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
})
|
||||
}),
|
||||
edit_tracker: Some(Arc::new(poise::EditTracker::for_timespan(
|
||||
Duration::from_secs(3600),
|
||||
))),
|
||||
..Default::default()
|
||||
},
|
||||
on_error: |error| Box::pin(on_error(error)),
|
||||
..Default::default()
|
||||
};
|
||||
// }}}
|
||||
// {{{ Start poise
|
||||
let framework = poise::Framework::builder()
|
||||
.setup(move |ctx, _ready, framework| {
|
||||
Box::pin(async move {
|
||||
println!("Logged in as {}", _ready.user.name);
|
||||
poise::builtins::register_globally(ctx, &framework.options().commands)
|
||||
.await?;
|
||||
let ctx = UserContext::new().await?;
|
||||
|
||||
if var("SHIMMERING_REGEN_SCORES").unwrap_or_default() == "1" {
|
||||
timed!("generate_missing_scores", {
|
||||
generate_missing_scores(&ctx).await?;
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ctx)
|
||||
})
|
||||
})
|
||||
.options(options)
|
||||
.build();
|
||||
|
||||
let token = var("SHIMMERING_DISCORD_TOKEN")
|
||||
.expect("Missing `SHIMMERING_DISCORD_TOKEN` env var");
|
||||
let intents = serenity::GatewayIntents::non_privileged()
|
||||
| serenity::GatewayIntents::MESSAGE_CONTENT;
|
||||
|
||||
let client = serenity::ClientBuilder::new(token, intents)
|
||||
.framework(framework)
|
||||
.await;
|
||||
|
||||
client.unwrap().start().await.unwrap()
|
||||
// }}}
|
||||
}
|
||||
Command::PrepareJackets {} => {
|
||||
cli::prepare_jackets::run().expect("Could not prepare jackets");
|
||||
}
|
||||
Command::Analyse(args) => {
|
||||
cli::analyse::run(args)
|
||||
.await
|
||||
.expect("Could not analyse screenshot");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -45,7 +45,7 @@ pub fn guess_song_and_chart<'a>(
|
|||
.or_else(|| strip_case_insensitive_suffix(name, "[ETR]").zip(Some(Difficulty::ETR)))
|
||||
.or_else(|| strip_case_insensitive_suffix(name, "BYD").zip(Some(Difficulty::BYD)))
|
||||
.or_else(|| strip_case_insensitive_suffix(name, "[BYD]").zip(Some(Difficulty::BYD)))
|
||||
.unwrap_or((&name, Difficulty::FTR));
|
||||
.unwrap_or((name, Difficulty::FTR));
|
||||
|
||||
guess_chart_name(name, &ctx.song_cache, Some(difficulty), true)
|
||||
}
|
||||
|
@ -85,7 +85,7 @@ pub fn guess_chart_name<'a>(
|
|||
distance_vec.clear();
|
||||
|
||||
// Apply raw distance
|
||||
let base_distance = edit_distance_with(&text, &song_title, &mut levenshtein_vec);
|
||||
let base_distance = edit_distance_with(text, song_title, &mut levenshtein_vec);
|
||||
if base_distance <= song.title.len() / 3 {
|
||||
distance_vec.push(base_distance * 10 + 2);
|
||||
}
|
||||
|
@ -95,7 +95,7 @@ pub fn guess_chart_name<'a>(
|
|||
if let Some(sliced) = &song_title.get(..shortest_len)
|
||||
&& (text.len() >= 6 || unsafe_heuristics)
|
||||
{
|
||||
let slice_distance = edit_distance_with(&text, sliced, &mut levenshtein_vec);
|
||||
let slice_distance = edit_distance_with(text, sliced, &mut levenshtein_vec);
|
||||
if slice_distance == 0 {
|
||||
distance_vec.push(3);
|
||||
}
|
||||
|
@ -105,7 +105,7 @@ pub fn guess_chart_name<'a>(
|
|||
if let Some(shorthand) = &chart.shorthand
|
||||
&& unsafe_heuristics
|
||||
{
|
||||
let short_distance = edit_distance_with(&text, shorthand, &mut levenshtein_vec);
|
||||
let short_distance = edit_distance_with(text, shorthand, &mut levenshtein_vec);
|
||||
if short_distance <= shorthand.len() / 3 {
|
||||
distance_vec.push(short_distance * 10 + 1);
|
||||
}
|
||||
|
@ -121,7 +121,7 @@ pub fn guess_chart_name<'a>(
|
|||
close_enough.sort_by_key(|(song, _, _)| song.id);
|
||||
close_enough.dedup_by_key(|(song, _, _)| song.id);
|
||||
|
||||
if close_enough.len() == 0 {
|
||||
if close_enough.is_empty() {
|
||||
if text.len() <= 1 {
|
||||
bail!(
|
||||
"Could not find match for chart name '{}' [{:?}]",
|
||||
|
@ -133,13 +133,11 @@ pub fn guess_chart_name<'a>(
|
|||
}
|
||||
} else if close_enough.len() == 1 {
|
||||
break (close_enough[0].0, close_enough[0].1);
|
||||
} else if unsafe_heuristics {
|
||||
close_enough.sort_by_key(|(_, _, distance)| *distance);
|
||||
break (close_enough[0].0, close_enough[0].1);
|
||||
} else {
|
||||
if unsafe_heuristics {
|
||||
close_enough.sort_by_key(|(_, _, distance)| *distance);
|
||||
break (close_enough[0].0, close_enough[0].1);
|
||||
} else {
|
||||
bail!("Name '{}' is too vague to choose a match", raw_text);
|
||||
};
|
||||
bail!("Name '{}' is too vague to choose a match", raw_text);
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -6,7 +6,8 @@ use hypertesseract::{PageSegMode, Tesseract};
|
|||
use image::imageops::FilterType;
|
||||
use image::{DynamicImage, GenericImageView};
|
||||
use num::integer::Roots;
|
||||
use poise::serenity_prelude::{CreateAttachment, CreateEmbed, CreateMessage};
|
||||
use poise::serenity_prelude::{CreateAttachment, CreateEmbed};
|
||||
use poise::CreateReply;
|
||||
|
||||
use crate::arcaea::chart::{Chart, Difficulty, Song, DIFFICULTY_MENU_PIXEL_COLORS};
|
||||
use crate::arcaea::jacket::IMAGE_VEC_DIM;
|
||||
|
@ -114,13 +115,16 @@ impl ImageAnalyzer {
|
|||
"An error occurred, around the time I was extracting data for {ui_rect:?}"
|
||||
));
|
||||
|
||||
let msg = CreateMessage::default().embed(embed);
|
||||
ctx.send_files([error_attachement], msg).await?;
|
||||
ctx.send(
|
||||
CreateReply::default()
|
||||
.embed(embed)
|
||||
.attachment(error_attachement),
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
embed = embed.title("An error occurred");
|
||||
|
||||
let msg = CreateMessage::default().embed(embed);
|
||||
ctx.send_files([], msg).await?;
|
||||
ctx.send(CreateReply::default().embed(embed)).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
@ -355,9 +359,9 @@ impl ImageAnalyzer {
|
|||
}
|
||||
// }}}
|
||||
// {{{ Read max recall
|
||||
pub fn read_max_recall<'a>(
|
||||
pub fn read_max_recall(
|
||||
&mut self,
|
||||
ctx: &'a UserContext,
|
||||
ctx: &UserContext,
|
||||
image: &DynamicImage,
|
||||
) -> Result<u32, Error> {
|
||||
let image = self.interp_crop(ctx, image, ScoreScreen(ScoreScreenRect::MaxRecall))?;
|
||||
|
|
31
src/user.rs
31
src/user.rs
|
@ -2,7 +2,7 @@ use anyhow::anyhow;
|
|||
use rusqlite::Row;
|
||||
|
||||
use crate::commands::discord::MessageContext;
|
||||
use crate::context::{Error, UserContext};
|
||||
use crate::context::{ErrorKind, TagError, TaggedError, UserContext};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct User {
|
||||
|
@ -13,7 +13,7 @@ pub struct User {
|
|||
|
||||
impl User {
|
||||
#[inline]
|
||||
fn from_row<'a, 'b>(row: &'a Row<'b>) -> Result<Self, rusqlite::Error> {
|
||||
fn from_row(row: &Row<'_>) -> Result<Self, rusqlite::Error> {
|
||||
Ok(Self {
|
||||
id: row.get("id")?,
|
||||
discord_id: row.get("discord_id")?,
|
||||
|
@ -21,7 +21,7 @@ impl User {
|
|||
})
|
||||
}
|
||||
|
||||
pub fn create_from_context(ctx: &impl MessageContext) -> Result<Self, Error> {
|
||||
pub fn create_from_context(ctx: &impl MessageContext) -> Result<Self, TaggedError> {
|
||||
let discord_id = ctx.author_id().to_string();
|
||||
let user_id: u32 = ctx
|
||||
.data()
|
||||
|
@ -35,7 +35,7 @@ impl User {
|
|||
)?
|
||||
.query_map([&discord_id], |row| row.get("id"))?
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("Failed to create user"))??;
|
||||
.ok_or_else(|| anyhow!("No id returned from user creation"))??;
|
||||
|
||||
Ok(Self {
|
||||
discord_id,
|
||||
|
@ -44,7 +44,7 @@ impl User {
|
|||
})
|
||||
}
|
||||
|
||||
pub fn from_context(ctx: &impl MessageContext) -> Result<Self, Error> {
|
||||
pub fn from_context(ctx: &impl MessageContext) -> Result<Self, TaggedError> {
|
||||
let id = ctx.author_id();
|
||||
let user = ctx
|
||||
.data()
|
||||
|
@ -53,20 +53,35 @@ impl User {
|
|||
.prepare_cached("SELECT * FROM users WHERE discord_id = ?")?
|
||||
.query_map([id], Self::from_row)?
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("You are not an user in my database, sowwy ^~^"))??;
|
||||
.ok_or_else(|| {
|
||||
anyhow!("You are not an user in my database, sowwy ^~^").tag(ErrorKind::User)
|
||||
})??;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
pub fn by_id(ctx: &UserContext, id: u32) -> Result<Self, Error> {
|
||||
pub fn by_id(ctx: &UserContext, id: u32) -> Result<Self, TaggedError> {
|
||||
let user = ctx
|
||||
.db
|
||||
.get()?
|
||||
.prepare_cached("SELECT * FROM users WHERE id = ?")?
|
||||
.query_map([id], Self::from_row)?
|
||||
.next()
|
||||
.ok_or_else(|| anyhow!("You are not an user in my database, sowwy ^~^"))??;
|
||||
.ok_or_else(|| {
|
||||
anyhow!("You are not an user in my database, sowwy ^~^").tag(ErrorKind::User)
|
||||
})??;
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn assert_is_pookie(&self) -> Result<(), TaggedError> {
|
||||
if !self.is_pookie {
|
||||
return Err(
|
||||
anyhow!("This feature is reserved for my pookies. Sowwy :3").tag(ErrorKind::User)
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue