Implement ignore command and add needed hooks

This commit is contained in:
Natsu Kagami 2025-03-27 15:04:33 +01:00
parent 0445b5ae0b
commit abe3e284dc
Signed by: nki
GPG key ID: 55A032EB38B49ADB
7 changed files with 266 additions and 23 deletions

1
Cargo.lock generated
View file

@ -3426,6 +3426,7 @@ dependencies = [
"dashmap 5.5.3", "dashmap 5.5.3",
"flume 0.10.14", "flume 0.10.14",
"futures-util", "futures-util",
"poise",
"rand", "rand",
"serde", "serde",
"serenity", "serenity",

View file

@ -19,6 +19,8 @@ futures-util = "0.3.21"
tokio = { version = "1.19.2", features = ["time"] } tokio = { version = "1.19.2", features = ["time"] }
flume = "0.10.13" flume = "0.10.13"
dashmap = "5.3.4" dashmap = "5.3.4"
poise = { git = "https://github.com/serenity-rs/poise", branch = "current" }
youmubot-db = { path = "../youmubot-db" } youmubot-db = { path = "../youmubot-db" }
youmubot-db-sql = { path = "../youmubot-db-sql" }
youmubot-prelude = { path = "../youmubot-prelude" } youmubot-prelude = { path = "../youmubot-prelude" }

View file

@ -0,0 +1,152 @@
use std::sync::Arc;
use chrono::{DateTime, Utc};
use dashmap::DashMap;
use serenity::all::User;
use youmubot_db_sql::models::ignore_list as model;
use youmubot_prelude::*;
use crate::HasCoreEnv;
// Should we ignore this user?
pub fn should_ignore(env: &impl HasCoreEnv, id: UserId) -> bool {
env.core_env().ignore.query(id).is_some()
}
/// Ignore: make Youmu ignore all commands from an user.
#[poise::command(
slash_command,
subcommands("add", "remove", "list"),
owners_only,
install_context = "User",
interaction_context = "Guild|BotDm"
)]
pub async fn ignore<U: HasCoreEnv>(_ctx: CmdContext<'_, U>) -> Result<()> {
Ok(())
}
/// Add an user to ignore list.
#[poise::command(slash_command, owners_only)]
async fn add<U: HasCoreEnv>(
ctx: CmdContext<'_, U>,
#[description = "Discord username"] discord_name: User,
) -> Result<()> {
let env = ctx.data().core_env();
ctx.defer().await?;
let msg = format!("User **{}** ignored!", discord_name.name);
env.ignore
.add(&env.prelude, UserId(discord_name.id), discord_name.name)
.await?;
ctx.say(msg).await?;
Ok(())
}
/// Remove an user from ignore list.
#[poise::command(slash_command, owners_only)]
async fn remove<U: HasCoreEnv>(
ctx: CmdContext<'_, U>,
#[description = "Discord username"] discord_name: User,
) -> Result<()> {
let env = ctx.data().core_env();
ctx.defer().await?;
env.ignore
.remove(&env.prelude, UserId(discord_name.id))
.await?;
let msg = format!("User **{}** removed from ignore list!", discord_name.name);
ctx.say(msg).await?;
Ok(())
}
/// List ignored users.
#[poise::command(slash_command, owners_only)]
async fn list<U: HasCoreEnv>(ctx: CmdContext<'_, U>) -> Result<()> {
let env = ctx.data().core_env();
let is_dm = ctx.guild_id().is_none();
ctx.defer().await?;
let users = env
.ignore
.list
.clone()
.iter()
.map(|v| {
format!(
"- {} ({}), since <t:{}:R>",
v.username,
if is_dm {
v.id.0.mention().to_string()
} else {
format!("`{}`", v.id.0.get())
},
v.ignored_since.timestamp(),
)
})
.collect::<Vec<_>>()
.join("\n");
let users = if users == "" {
"No one is being ignored!"
} else {
&users[..]
};
let msg = format!("Ignored users:\n{}", users);
ctx.say(msg).await?;
Ok(())
}
#[derive(Debug, Clone)]
pub(crate) struct IgnoredUsers {
list: Arc<DashMap<UserId, IgnoredUser>>,
}
impl IgnoredUsers {
pub async fn from_db(env: &Env) -> Result<Self> {
let list = model::IgnoredUser::get_all(&env.sql).await?;
let mp: DashMap<_, _> = list
.into_iter()
.map(|v| {
let id = (v.id as u64).into();
(
id,
IgnoredUser {
id,
username: v.username,
ignored_since: v.ignored_since,
},
)
})
.collect();
Ok(Self { list: Arc::new(mp) })
}
pub fn query<'a>(
&'a self,
id: UserId,
) -> Option<impl std::ops::Deref<Target = IgnoredUser> + 'a> {
self.list.get(&id)
}
pub async fn add(&self, env: &Env, id: UserId, username: String) -> Result<()> {
let iu = model::IgnoredUser::add(&env.sql, id.0.get() as i64, username).await?;
self.list.insert(
id,
IgnoredUser {
id,
username: iu.username,
ignored_since: iu.ignored_since,
},
);
Ok(())
}
pub async fn remove(&self, env: &Env, id: UserId) -> Result<bool> {
model::IgnoredUser::remove(&env.sql, id.0.get() as i64).await?;
Ok(self.list.remove(&id).is_some())
}
}
#[derive(Debug, Clone)]
pub(crate) struct IgnoredUser {
pub id: UserId,
pub username: String,
pub ignored_since: DateTime<Utc>,
}

View file

@ -13,6 +13,8 @@ use youmubot_prelude::*;
mod soft_ban; mod soft_ban;
pub use soft_ban::watch_soft_bans; pub use soft_ban::watch_soft_bans;
pub mod ignore;
#[group] #[group]
#[description = "Administrative commands for the server."] #[description = "Administrative commands for the server."]
#[commands(clean, ban, kick, soft_ban, soft_ban_init)] #[commands(clean, ban, kick, soft_ban, soft_ban_init)]

View file

@ -17,11 +17,32 @@ pub mod community;
mod db; mod db;
pub mod fun; pub mod fun;
#[derive(Debug, Clone)]
pub struct CoreEnv {
pub(crate) prelude: Env,
pub(crate) ignore: admin::ignore::IgnoredUsers,
}
impl CoreEnv {
async fn new(prelude: Env) -> Result<Self> {
let ignore = admin::ignore::IgnoredUsers::from_db(&prelude).await?;
Ok(Self { prelude, ignore })
}
}
/// Gets an [CoreEnv] from the current environment.
pub trait HasCoreEnv: Send + Sync {
fn core_env(&self) -> &CoreEnv;
}
impl<T: AsRef<CoreEnv> + Send + Sync> HasCoreEnv for T {
fn core_env(&self) -> &CoreEnv {
self.as_ref()
}
}
/// Sets up all databases in the client. /// Sets up all databases in the client.
pub fn setup( pub async fn setup(path: &std::path::Path, data: &mut TypeMap, prelude: Env) -> Result<CoreEnv> {
path: &std::path::Path,
data: &mut TypeMap,
) -> serenity::framework::standard::CommandResult {
db::SoftBans::insert_into(&mut *data, &path.join("soft_bans.yaml"))?; db::SoftBans::insert_into(&mut *data, &path.join("soft_bans.yaml"))?;
db::load_role_list( db::load_role_list(
&mut *data, &mut *data,
@ -32,7 +53,7 @@ pub fn setup(
// Start reaction handlers // Start reaction handlers
data.insert::<community::ReactionWatchers>(community::ReactionWatchers::new(&*data)?); data.insert::<community::ReactionWatchers>(community::ReactionWatchers::new(&*data)?);
Ok(()) CoreEnv::new(prelude).await
} }
pub fn ready_hook(ctx: &Context) -> CommandResult { pub fn ready_hook(ctx: &Context) -> CommandResult {

View file

@ -189,9 +189,15 @@ mod ids {
use super::ParseError; use super::ParseError;
/// An `UserId` parsed the old way. /// An `UserId` parsed the old way.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct UserId(pub id::UserId); pub struct UserId(pub id::UserId);
impl From<u64> for UserId {
fn from(value: u64) -> Self {
Self(id::UserId::new(value))
}
}
impl FromStr for UserId { impl FromStr for UserId {
type Err = ParseError; type Err = ParseError;

View file

@ -53,6 +53,8 @@ struct Env {
prelude: youmubot_prelude::Env, prelude: youmubot_prelude::Env,
#[cfg(feature = "osu")] #[cfg(feature = "osu")]
osu: youmubot_osu::discord::OsuEnv, osu: youmubot_osu::discord::OsuEnv,
#[cfg(feature = "core")]
core: youmubot_core::CoreEnv,
} }
impl AsRef<youmubot_prelude::Env> for Env { impl AsRef<youmubot_prelude::Env> for Env {
@ -68,6 +70,13 @@ impl AsRef<youmubot_osu::discord::OsuEnv> for Env {
} }
} }
#[cfg(feature = "core")]
impl AsRef<youmubot_core::CoreEnv> for Env {
fn as_ref(&self) -> &youmubot_core::CoreEnv {
&self.core
}
}
impl TypeMapKey for Env { impl TypeMapKey for Env {
type Value = Env; type Value = Env;
} }
@ -219,7 +228,9 @@ async fn main() {
let prelude = setup::setup_prelude(&db_path, sql_path, &mut data).await; let prelude = setup::setup_prelude(&db_path, sql_path, &mut data).await;
// Setup core // Setup core
#[cfg(feature = "core")] #[cfg(feature = "core")]
youmubot_core::setup(&db_path, &mut data).expect("Setup db should succeed"); let core = youmubot_core::setup(&db_path, &mut data, prelude.clone())
.await
.expect("Setup db should succeed");
// osu! // osu!
#[cfg(feature = "osu")] #[cfg(feature = "osu")]
let osu = youmubot_osu::discord::setup(&mut data, prelude.clone(), &mut announcers) let osu = youmubot_osu::discord::setup(&mut data, prelude.clone(), &mut announcers)
@ -233,6 +244,8 @@ async fn main() {
prelude, prelude,
#[cfg(feature = "osu")] #[cfg(feature = "osu")]
osu, osu,
#[cfg(feature = "core")]
core,
} }
}; };
@ -266,9 +279,13 @@ async fn main() {
case_insensitive_commands: true, case_insensitive_commands: true,
..Default::default() ..Default::default()
}, },
command_check: Some(|ctx| {
Box::pin(command_check(ctx.data(), UserId(ctx.author().id)).map(Ok))
}),
on_error: |err| { on_error: |err| {
Box::pin(async move { Box::pin(async move {
if let poise::FrameworkError::Command { error, ctx, .. } = err { match err {
poise::FrameworkError::Command { error, ctx, .. } => {
let reply = format!( let reply = format!(
"Command '{}' returned error: {:?}", "Command '{}' returned error: {:?}",
ctx.invoked_command_name(), ctx.invoked_command_name(),
@ -278,15 +295,42 @@ async fn main() {
ctx.send(poise::CreateReply::default().content(reply).ephemeral(true)) ctx.send(poise::CreateReply::default().content(reply).ephemeral(true))
.await .await
.pls_ok(); .pls_ok();
} else { }
poise::FrameworkError::NotAnOwner { ctx, .. } => {
ctx.send(
poise::CreateReply::default()
.content("You have to be an owner to run this command!")
.ephemeral(true),
)
.await
.pls_ok();
}
poise::FrameworkError::CommandCheckFailed { error: _, ctx, .. }
| poise::FrameworkError::CooldownHit {
remaining_cooldown: _,
ctx,
..
} => {
ctx.send(
poise::CreateReply::default()
.content("You are being rate-limited, please try again later!")
.ephemeral(true),
)
.await
.pls_ok();
}
_ => {
eprintln!("Poise error: {:?}", err) eprintln!("Poise error: {:?}", err)
} }
}
}) })
}, },
commands: vec![ commands: vec![
poise_register(), poise_register(),
#[cfg(feature = "osu")] #[cfg(feature = "osu")]
youmubot_osu::discord::osu_command(), youmubot_osu::discord::osu_command(),
#[cfg(feature = "core")]
youmubot_core::admin::ignore::ignore(),
], ],
..Default::default() ..Default::default()
}) })
@ -398,25 +442,37 @@ async fn poise_register(ctx: CmdContext<'_, Env>) -> Result<()> {
Ok(()) Ok(())
} }
async fn command_check(env: &Env, author: UserId) -> bool {
#[cfg(feature = "core")]
if youmubot_core::admin::ignore::should_ignore(env, author) {
tracing::info!("User is in ignore list, skipping...");
return false;
}
true
}
// Hooks! // Hooks!
#[hook] #[hook]
async fn before_hook(_: &Context, msg: &Message, command_name: &str) -> bool { async fn before_hook(ctx: &Context, msg: &Message, command_name: &str) -> bool {
println!( let env = ctx.data.read().await;
let env = env.get::<Env>().unwrap();
tracing::info!(
"Got command '{}' by user '{}'", "Got command '{}' by user '{}'",
command_name, msg.author.name command_name,
msg.author.name
); );
true command_check(env, UserId(msg.author.id)).await
} }
#[hook] #[hook]
async fn after_hook(ctx: &Context, msg: &Message, command_name: &str, error: CommandResult) { async fn after_hook(ctx: &Context, msg: &Message, command_name: &str, error: CommandResult) {
match error { match error {
Ok(()) => println!("Processed command '{}'", command_name), Ok(()) => tracing::info!("Processed command '{}'", command_name),
Err(why) => { Err(why) => {
let reply = format!("Command '{}' returned error {:?}", command_name, why); let reply = format!("Command '{}' returned error {:?}", command_name, why);
msg.reply(&ctx, &reply).await.ok(); msg.reply(&ctx, &reply).await.ok();
println!("{}", reply) tracing::info!("{}", reply)
} }
} }
} }
@ -441,6 +497,9 @@ async fn on_dispatch_error(ctx: &Context, msg: &Message, error: DispatchError, _
max, given max, given
), ),
DispatchError::OnlyForGuilds => "🔇 This command cannot be used in DMs.".to_owned(), DispatchError::OnlyForGuilds => "🔇 This command cannot be used in DMs.".to_owned(),
DispatchError::OnlyForOwners => {
"🔇 This command can only be used by bot owners.".to_owned()
}
_ => return, _ => return,
}, },
) )