Add proper logging, make commands modularized, add new commands and add event handler
Run cargo test / Run tests (push) Has been cancelled

This commit is contained in:
Elias Wendland
2026-07-04 22:56:07 +02:00
parent 1732644dd1
commit c7be6b70dc
16 changed files with 554 additions and 84 deletions
+13
View File
@@ -0,0 +1,13 @@
TG_BOT_DISCORD_TOKEN=your-token-here
# Database Configuration
TG_BOT_POSTGRES_USER=postgres
TG_BOT_POSTGRES_PASSWORD=changeme
TG_BOT_POSTGRES_DB=tg_dev_srv_bot
TG_BOT_DATABASE_URL=postgres://postgres:changeme@localhost:5432/tg_dev_srv_bot
# Required for sqlx
DATABASE_URL=postgres://postgres:postgres@localhost:5432/tg_dev_srv_bot
# Logging configuration (trace, debug, info, warn, error)
TG_BOT_LOG=info
+45
View File
@@ -0,0 +1,45 @@
name: Run cargo test
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
DATABASE_URL: postgres://postgres:postgres@postgres:5432/tg_dev_srv_bot
DISCORD_TOKEN: testing
jobs:
test:
name: Run tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: tg_dev_srv_bot
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgmp-dev libmpfr-dev libmpc-dev
- name: Set up Rust
uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Run tests
run: cargo test --verbose
+5 -1
View File
@@ -6,9 +6,13 @@ edition = "2024"
[dependencies]
dotenv = "0.15.0"
exmex = "0.21.0"
f128 = "0.2.9"
poise = "0.6.2"
rug = "1.30.0"
serenity = "0.12.5"
sqlx = { version = "0.9.0", features = ["postgres", "runtime-tokio", "macros"] }
tokio = { version = "1.52.3", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[dev-dependencies]
rand = "0.10.2"
+3 -3
View File
@@ -4,9 +4,9 @@ services:
container_name: tg-dev-srv-bot-postgres
restart: always
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-tg_dev_srv_bot}
POSTGRES_USER: ${TG_BOT_POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${TG_BOT_POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${TG_BOT_POSTGRES_DB:-tg_dev_srv_bot}
ports:
- "5432:5432"
volumes:
+7 -1
View File
@@ -1,6 +1,7 @@
CREATE TABLE IF NOT EXISTS auto_roles (
guild_id BIGINT NOT NULL,
role_id BIGINT NOT NULL
role_id BIGINT NOT NULL,
enabled BOOLEAN NOT NULL
);
CREATE TABLE IF NOT EXISTS reaction_role (
@@ -21,3 +22,8 @@ CREATE TABLE IF NOT EXISTS admin_roles (
role_id BIGINT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
guild_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
settings_get_messaged_about_errors BOOLEAN NOT NULL
);
+27 -2
View File
@@ -1,5 +1,6 @@
use crate::{Context, Error};
use poise::ChoiceParameter;
use tracing::{debug, trace, info};
use crate::shared_functions::trace_message;
#[derive(Debug, poise::ChoiceParameter)]
pub enum Precision {
@@ -17,14 +18,38 @@ pub async fn calc(ctx: Context<'_>,
#[description = "Precision"]
precision: Option<Precision>
) -> Result<(), Error> {
debug!("{} has requested to calculate {}", ctx.author().name, expression);
let msg = "Calculating...".to_string();
trace_message(msg.clone(), ctx.channel_id().to_string(), ctx.guild_id().unwrap().to_string()).await;
let response_message = ctx.say(msg).await?;
let start_time = std::time::Instant::now();
trace!("Saving start time: {:?}", start_time);
let value = match precision {
Some(Precision::Bits32) => exmex::eval_str::<f32>(&expression).map_err(|err| err.to_string())?.to_string(),
Some(Precision::Bits64) => exmex::eval_str::<f64>(&expression).map_err(|err| err.to_string())?.to_string(),
None => exmex::eval_str::<f64>(&expression).map_err(|err| err.to_string())?.to_string(),
};
let end_time = std::time::Instant::now();
trace!("Saving end time: {:?}", end_time);
let duration = end_time - start_time;
ctx.say(format!("{} = {} \n -# Precision: {} Compute time : {:?}", expression, value, precision.map(|p| p.name()).unwrap_or("64-bit"), duration)).await?;
trace!("Saving duration: {:?}", duration);
let precision_str = match precision {
Some(Precision::Bits32) => "32-bit".to_string(),
Some(Precision::Bits64) => "64-bit".to_string(),
None => "64-bit".to_string(),
};
let msg = format!("{} = {} \n-# Precision: {} Compute time : {:?}", expression, value, precision_str, duration);
trace_message(msg.clone(), ctx.channel_id().to_string(), ctx.guild_id().unwrap().to_string()).await;
edit_response_message(&response_message, ctx, msg, false).await?;
debug!("Calculation perfomed for {} with result {} in {:?} by {}", expression, value, duration, ctx.author().name);
Ok(())
}
async fn edit_response_message<'a>(response_message: &poise::ReplyHandle<'a>, ctx: Context<'_>, content: String, silent: bool) -> Result<(), Error> {
if silent {
response_message.edit(ctx, poise::CreateReply::default().content(content).allowed_mentions(serenity::all::CreateAllowedMentions::new().empty_users())).await?;
} else {
response_message.edit(ctx, poise::CreateReply::default().content(content)).await?;
}
Ok(())
}
@@ -1,5 +1,7 @@
use crate::{Context, Error};
use crate::shared_functions::get_all_admin_users;
use poise::serenity_prelude as sere;
use tracing::{error, warn, info, debug, trace};
#[derive(Debug, poise::ChoiceParameter)]
pub enum AdminOperationType {
@@ -19,7 +21,7 @@ pub enum AdminOperationType {
/// Admin command
#[poise::command(slash_command, prefix_command)]
pub async fn manage_users(ctx: Context<'_>,
pub async fn manage_admins(ctx: Context<'_>,
#[description = "Operation to perform"]
operation: Option<AdminOperationType>,
#[description = "User"]
@@ -27,9 +29,11 @@ pub async fn manage_users(ctx: Context<'_>,
#[description = "Role"]
role: Option<sere::Role>
) -> Result<(), Error> {
trace!("manage_admins command called by user {}", ctx.author().id.get());
let response_message = ctx.say("Running command...").await?;
if !is_admin(ctx).await? {
debug!("User {} is not an admin, denying access", ctx.author().id.get());
response_message.edit(ctx, poise::CreateReply::default().content("You are not allowed to run this command.")).await?;
return Ok(());
}
@@ -78,59 +82,6 @@ pub async fn manage_users(ctx: Context<'_>,
Ok(())
}
async fn get_all_admin_users(ctx: Context<'_>) -> Result<Vec<sere::User>, Error> {
let guild_id = ctx.guild_id().unwrap();
let pool = &ctx.data().pool;
let db_guild_id = guild_id.get() as i64;
let admin_users_records = sqlx::query!("SELECT user_id FROM admin_users WHERE guild_id = $1;", db_guild_id)
.fetch_all(pool)
.await?;
let admin_roles_records = sqlx::query!("SELECT role_id FROM admin_roles WHERE guild_id = $1;", db_guild_id)
.fetch_all(pool)
.await?;
let admin_role_ids: std::collections::HashSet<_> = admin_roles_records
.into_iter()
.map(|record| sere::RoleId::new(record.role_id as u64))
.collect();
let mut admin_users = Vec::new();
let mut admin_user_ids = std::collections::HashSet::new();
for record in admin_users_records {
let user_id = sere::UserId::new(record.user_id as u64);
if admin_user_ids.insert(user_id) {
if let Ok(user) = user_id.to_user(ctx.http()).await {
admin_users.push(user);
}
}
}
let mut last_member_id = None;
loop {
let members = guild_id.members(ctx.http(), Some(1000), last_member_id).await?;
if members.is_empty() {
break;
}
last_member_id = Some(members.last().unwrap().user.id);
for member in members {
let has_role = member.roles.iter().any(|role_id| admin_role_ids.contains(role_id));
let has_admin_perms = ctx.guild()
.map(|g| g.member_permissions(&member).administrator())
.unwrap_or(false);
if (has_role || has_admin_perms) && admin_user_ids.insert(member.user.id) {
admin_users.push(member.user);
}
}
}
Ok(admin_users)
}
async fn is_admin(ctx: Context<'_>) -> Result<bool, Error> {
let user_running_command = ctx.author().id.get() as i64;
let admins = get_all_admin_users(ctx).await?;
@@ -144,16 +95,20 @@ async fn is_admin(ctx: Context<'_>) -> Result<bool, Error> {
}
async fn add_admin_user<'a>(ctx: Context<'a>, msg: &poise::ReplyHandle<'a>, user: sere::Member) -> Result<(), Error> {
trace!("Executing add_admin_user for user {}", user.user.name);
if is_target_admin_user(ctx, &user).await? {
trace!("User {} is already an admin", user.user.name);
edit_response_message(msg, ctx, format!("{} is already an admin", user.user.name), false).await?;
return Ok(());
}
let query = sqlx::query!("INSERT INTO admin_users (guild_id, user_id) VALUES ($1, $2);", ctx.guild_id().unwrap().get() as i64, user.user.id.get() as i64).execute(&ctx.data().pool).await;
match query {
Ok(_) => {
info!("Added user {} as admin in guild {}", user.user.name, ctx.guild_id().unwrap().get());
edit_response_message(msg, ctx, format!("Added {} as admin", user.user.name), false).await?;
}
Err(e) => {
error!("Failed to add user {} as admin in guild {}: {:?}", user.user.name, ctx.guild_id().unwrap().get(), e);
edit_response_message(msg, ctx, format!("Failed to add {} as admin: {}", user.user.name, e), false).await?;
}
}
@@ -181,16 +136,20 @@ async fn is_target_admin_user(ctx: Context<'_>, user: &sere::Member) -> Result<b
async fn remove_admin_user<'a>(ctx: Context<'a>, msg: &poise::ReplyHandle<'a>, user: sere::Member) -> Result<(), Error> {
trace!("Executing remove_admin_user for user {}", user.user.name);
if !is_target_admin_user(ctx, &user).await? {
trace!("User {} is not an admin", user.user.name);
edit_response_message(msg, ctx, format!("{} is not an admin", user.user.name), false).await?;
return Ok(());
}
let query = sqlx::query!("DELETE FROM admin_users WHERE guild_id = $1 AND user_id = $2;", ctx.guild_id().unwrap().get() as i64, user.user.id.get() as i64).execute(&ctx.data().pool).await;
match query {
Ok(_) => {
info!("Removed user {} as admin in guild {}", user.user.name, ctx.guild_id().unwrap().get());
edit_response_message(msg, ctx, format!("Removed {} as admin", user.user.name), false).await?;
}
Err(e) => {
error!("Failed to remove user {} as admin in guild {}: {:?}", user.user.name, ctx.guild_id().unwrap().get(), e);
edit_response_message(msg, ctx, format!("Failed to remove {} as admin: {}", user.user.name, e), false).await?;
}
}
@@ -205,16 +164,20 @@ async fn list_admin_users<'a>(ctx: Context<'a>, msg: &poise::ReplyHandle<'a>) ->
}
async fn add_admin_role<'a>(ctx: Context<'a>, msg: &poise::ReplyHandle<'a>, role: sere::Role) -> Result<(), Error> {
trace!("Executing add_admin_role for role {}", role.name);
if is_target_admin_role(ctx, &role).await? {
trace!("Role {} is already an admin role", role.name);
edit_response_message(msg, ctx, format!("{} is already an admin role", role.name), false).await?;
return Ok(());
}
let query = sqlx::query!("INSERT INTO admin_roles (guild_id, role_id) VALUES ($1, $2);", ctx.guild_id().unwrap().get() as i64, role.id.get() as i64).execute(&ctx.data().pool).await;
match query {
Ok(_) => {
info!("Added role {} as admin role in guild {}", role.name, ctx.guild_id().unwrap().get());
edit_response_message(msg, ctx, format!("Added {} as admin role", role.name), false).await?;
}
Err(e) => {
error!("Failed to add role {} as admin role in guild {}: {:?}", role.name, ctx.guild_id().unwrap().get(), e);
edit_response_message(msg, ctx, format!("Failed to add {} as admin role: {}", role.name, e), false).await?;
}
}
@@ -222,16 +185,20 @@ async fn add_admin_role<'a>(ctx: Context<'a>, msg: &poise::ReplyHandle<'a>, role
}
async fn remove_admin_role<'a>(ctx: Context<'a>, msg: &poise::ReplyHandle<'a>, role: sere::Role) -> Result<(), Error> {
trace!("Executing remove_admin_role for role {}", role.name);
if !is_target_admin_role(ctx, &role).await? {
trace!("Role {} is not an admin role", role.name);
edit_response_message(msg, ctx, format!("{} is not an admin role", role.name), false).await?;
return Ok(());
}
let query = sqlx::query!("DELETE FROM admin_roles WHERE guild_id = $1 AND role_id = $2;", ctx.guild_id().unwrap().get() as i64, role.id.get() as i64).execute(&ctx.data().pool).await;
match query {
Ok(_) => {
info!("Removed role {} as admin role in guild {}", role.name, ctx.guild_id().unwrap().get());
edit_response_message(msg, ctx, format!("Removed {} as admin role", role.name), false).await?;
}
Err(e) => {
error!("Failed to remove role {} as admin role in guild {}: {:?}", role.name, ctx.guild_id().unwrap().get(), e);
edit_response_message(msg, ctx, format!("Failed to remove {} as admin role: {}", role.name, e), false).await?;
}
}
@@ -262,4 +229,4 @@ async fn is_target_admin_role<'a>(ctx: Context<'a>, role: &sere::Role) -> Result
}
}
Ok(false)
}
}
+227
View File
@@ -0,0 +1,227 @@
use crate::{Context, Error};
use crate::shared_functions::get_all_admin_users;
use poise::serenity_prelude as sere;
use tracing::{error, warn, info, debug, trace};
#[derive(Debug, poise::ChoiceParameter)]
pub enum AutoRoleOperationType {
#[name = "Enable auto-role"]
EnableAutoRole,
#[name = "Set auto-role (does not enable the feature)"]
SetAutoRole,
#[name = "Disable auto-role"]
DisableAutoRole,
#[name = "Show auto-role"]
ShowAutoRole,
}
/// Auto role command
#[poise::command(slash_command, prefix_command)]
pub async fn manage_auto_role(ctx: Context<'_>,
#[description = "Operation to perform"]
operation: Option<AutoRoleOperationType>,
#[description = "Role"]
role: Option<sere::Role>
) -> Result<(), Error> {
trace!("manage_auto_role command called by user {}", ctx.author().id.get());
let response_message = ctx.say("Running command...").await?;
if !is_admin(ctx).await? {
debug!("User {} is not an admin, denying access", ctx.author().id.get());
edit_response_message(&response_message, ctx, "You are not allowed to run this command.".to_string(), false).await?;
return Ok(());
}
match operation {
Some(AutoRoleOperationType::EnableAutoRole) => {
if let Some(s_role) = role {
enable_auto_role(ctx, &response_message, s_role).await?;
} else {
edit_response_message(&response_message, ctx, "Please provide a role".to_string(), false).await?;
}
}
Some(AutoRoleOperationType::SetAutoRole) => {
if let Some(s_role) = role {
set_auto_role(ctx, &response_message, s_role).await?;
} else {
edit_response_message(&response_message, ctx, "Please provide a role".to_string(), false).await?;
}
}
Some(AutoRoleOperationType::DisableAutoRole) => {
if let Some(s_role) = role {
disable_auto_role(ctx, &response_message, s_role).await?;
} else {
edit_response_message(&response_message, ctx, "Please provide a role".to_string(), false).await?;
}
}
Some(AutoRoleOperationType::ShowAutoRole) => {
show_auto_role(ctx, &response_message).await?;
}
None => {
edit_response_message(&response_message, ctx, "Please provide an operation".to_string(), false).await?;
}
}
Ok(())
}
async fn is_admin(ctx: Context<'_>) -> Result<bool, Error> {
let user_running_command = ctx.author().id.get() as i64;
let admins = get_all_admin_users(ctx).await?;
for user in admins {
if user.id.get() as i64 == user_running_command {
return Ok(true);
}
}
Ok(false)
}
async fn enable_auto_role(ctx: Context<'_>, response_message: &poise::ReplyHandle<'_>, role: sere::Role) -> Result<(), Error> {
trace!("Executing enable_auto_role for role {}", role.name);
if !is_any_role_set_in_guild(ctx).await? {
trace!("No role is set in guild {}", ctx.guild_id().unwrap().get());
edit_response_message(response_message, ctx, "No role is set. Please set a role first.".to_string(), false).await?;
return Ok(());
}
if !does_role_exist_in_guild(ctx, &role).await? {
trace!("Role {} does not exist in guild {}", role.name, ctx.guild_id().unwrap().get());
edit_response_message(response_message, ctx, "This role does not exist".to_string(), false).await?;
return Ok(());
}
if is_auto_role_enabled(ctx, &role).await? {
trace!("Auto role {} is already enabled in guild {}", role.name, ctx.guild_id().unwrap().get());
edit_response_message(response_message, ctx, "Auto role is already enabled".to_string(), false).await?;
return Ok(());
}
let query = sqlx::query!("UPDATE auto_roles SET enabled = true WHERE guild_id = $1 AND role_id = $2;", ctx.guild_id().unwrap().get() as i64, role.id.get() as i64).execute(&ctx.data().pool).await;
match query {
Ok(_) => {
info!("Enabled auto role {} in guild {}", role.name, ctx.guild_id().unwrap().get());
edit_response_message(response_message, ctx, format!("Enabled auto role: <@&{}>", role.id.get()), false).await?;
}
Err(e) => {
error!("Failed to enable {} as auto role in guild {}: {}", role.id.get(), ctx.guild_id().unwrap().get(), e);
edit_response_message(response_message, ctx, format!("Failed to enable {} as auto role: {}", role.id.get(), e), false).await?;
}
}
Ok(())
}
async fn is_any_role_set_in_guild(ctx: Context<'_>) -> Result<bool, Error> {
let query = sqlx::query!("SELECT * FROM auto_roles WHERE guild_id = $1", ctx.guild_id().unwrap().get() as i64).fetch_optional(&ctx.data().pool).await?;
if query.is_some() {
return Ok(true);
}
return Ok(false)
}
async fn does_role_exist_in_guild(ctx: Context<'_>, target_role: &sere::Role) -> Result<bool, Error> {
if let Some(guild) = ctx.guild() {
if guild.roles.contains_key(&target_role.id) {
return Ok(true);
}
}
return Ok(false)
}
async fn set_auto_role(ctx: Context<'_>, response_message: &poise::ReplyHandle<'_>, role: sere::Role) -> Result<(), Error> {
trace!("Executing set_auto_role for role {}", role.name);
if !does_role_exist_in_guild(ctx, &role).await? {
trace!("Role {} does not exist in guild {}", role.name, ctx.guild_id().unwrap().get());
edit_response_message(response_message, ctx, "This role does not exist".to_string(), false).await?;
return Ok(());
}
let query = sqlx::query!("INSERT INTO auto_roles (guild_id, role_id, enabled) VALUES ($1, $2, false) ON CONFLICT (guild_id) DO UPDATE SET role_id = $2;", ctx.guild_id().unwrap().get() as i64, role.id.get() as i64).execute(&ctx.data().pool).await;
match query {
Ok(_) => {
info!("Set {} as auto role in guild {}", role.name, ctx.guild_id().unwrap().get());
edit_response_message(response_message, ctx, format!("Set {} as auto role", role.name), false).await?;
}
Err(e) => {
error!("Failed to set {} as auto role in guild {}: {}", role.id.get(), ctx.guild_id().unwrap().get(), e);
edit_response_message(response_message, ctx, format!("Failed to set {} as auto role: {}", role.id.get(), e), false).await?;
}
}
Ok(())
}
async fn disable_auto_role(ctx: Context<'_>, response_message: &poise::ReplyHandle<'_>, role: sere::Role) -> Result<(), Error> {
trace!("Executing disable_auto_role for role {}", role.name);
if !is_any_role_set_in_guild(ctx).await? {
trace!("No role is set in guild {}", ctx.guild_id().unwrap().get());
edit_response_message(response_message, ctx, "No auto role is set. Please set a role first.".to_string(), false).await?;
return Ok(());
}
if !is_auto_role_enabled(ctx, &role).await? {
trace!("Auto role {} is already disabled in guild {}", role.name, ctx.guild_id().unwrap().get());
edit_response_message(response_message, ctx, "Auto role is already disabled".to_string(), false).await?;
return Ok(());
}
let query = sqlx::query!("UPDATE auto_roles SET enabled = false WHERE guild_id = $1 AND role_id = $2;", ctx.guild_id().unwrap().get() as i64, role.id.get() as i64).execute(&ctx.data().pool).await;
match query {
Ok(_) => {
info!("Disabled auto role {} in guild {}", role.name, ctx.guild_id().unwrap().get());
edit_response_message(response_message, ctx, format!("Disabled auto role: {}", role.name), false).await?;
}
Err(e) => {
error!("Failed to disable {} as auto role in guild {}: {}", role.id.get(), ctx.guild_id().unwrap().get(), e);
edit_response_message(response_message, ctx, format!("Failed to disable {} as auto role: {}", role.id.get(), e), false).await?;
}
}
Ok(())
}
async fn edit_response_message<'a>(response_message: &poise::ReplyHandle<'a>, ctx: Context<'_>, content: String, silent: bool) -> Result<(), Error> {
if silent {
response_message.edit(ctx, poise::CreateReply::default().content(content).allowed_mentions(serenity::all::CreateAllowedMentions::new().empty_users())).await?;
} else {
response_message.edit(ctx, poise::CreateReply::default().content(content)).await?;
}
Ok(())
}
async fn is_auto_role_enabled(ctx: Context<'_>, target_role: &sere::Role) -> Result<bool, Error> {
let query = sqlx::query!("SELECT enabled FROM auto_roles WHERE guild_id = $1 AND role_id = $2;", ctx.guild_id().unwrap().get() as i64, target_role.id.get() as i64).fetch_optional(&ctx.data().pool).await?;
if query.is_none() {
return Ok(false);
}
return Ok(query.unwrap().enabled)
}
async fn is_any_auto_role_enabled(ctx: Context<'_>) -> Result<bool, Error> {
let query = sqlx::query!("SELECT role_id FROM auto_roles WHERE guild_id = $1 AND enabled = true", ctx.guild_id().unwrap().get() as i64).fetch_optional(&ctx.data().pool).await?;
if query.is_none() {
return Ok(false);
}
return Ok(true)
}
async fn show_auto_role(ctx: Context<'_>, response_message: &poise::ReplyHandle<'_>) -> Result<(), Error> {
trace!("Executing show_auto_role");
if !is_any_role_set_in_guild(ctx).await? {
trace!("No role is set in guild {}", ctx.guild_id().unwrap().get());
edit_response_message(response_message, ctx, "No role has been set yet.".to_string(), false).await?;
return Ok(());
}
if !is_any_auto_role_enabled(ctx).await? {
trace!("Auto role is disabled in guild {}", ctx.guild_id().unwrap().get());
edit_response_message(response_message, ctx, "Auto role is disabled.".to_string(), false).await?;
return Ok(());
}
let role_id = sqlx::query!("SELECT role_id FROM auto_roles WHERE guild_id = $1", ctx.guild_id().unwrap().get() as i64).fetch_optional(&ctx.data().pool).await?;
edit_response_message(response_message, ctx, format!("Currently, <@&{}> is set as auto role and is enabled.", role_id.unwrap().role_id), false).await?;
Ok(())
}
// async fn list_admin_users<'a>(ctx: Context<'a>, msg: &poise::ReplyHandle<'a>) -> Result<(), Error> {
// let admins = get_all_admin_users(ctx).await?;
// let admin_strings: Vec<String> = admins.iter().map(|user| format!("<@{}>", user.id.get())).collect();
// edit_response_message(msg, ctx, format!("Admin users: {}", admin_strings.join(", ")), true).await?;
// Ok(())
// }
+4 -2
View File
@@ -1,7 +1,9 @@
pub mod ping;
pub mod calc;
pub mod manage_users;
pub mod manage_admins;
pub mod manage_auto_role;
pub use ping::ping;
pub use calc::calc;
pub use manage_users::manage_users;
pub use manage_admins::manage_admins;
pub use manage_auto_role::manage_auto_role;
+19
View File
@@ -0,0 +1,19 @@
use crate::{
Data, Error
};
use poise::serenity_prelude as sere;
pub async fn event_handler(
ctx: &sere::Context,
event: &sere::FullEvent,
_framework: poise::FrameworkContext<'_, Data, Error>,
user_data: &Data,
) -> Result<(), Error> {
match event {
sere::FullEvent::GuildMemberAddition { new_member } => {
crate::events::user_join::user_join(ctx, new_member, &user_data.pool).await;
}
_ => {}
}
Ok(())
}
+3
View File
@@ -0,0 +1,3 @@
pub mod user_join;
pub use user_join::user_join;
+40
View File
@@ -0,0 +1,40 @@
use poise::serenity_prelude as sere;
use tracing::{error, warn, info, debug, trace};
pub async fn user_join(ctx: &sere::Context, new_member: &sere::Member, pool: &sqlx::PgPool) {
trace!("User joined event handler called for guild {}", new_member.guild_id);
// Assign role to new user if the feature is enabled
let guild_id = new_member.guild_id.get() as u64;
let role_result = sqlx::query!(
"SELECT role_id FROM auto_roles WHERE guild_id = $1 AND enabled = true",
guild_id as i64
)
.fetch_optional(pool)
.await;
trace!("Checking if auto role is enabled for guild {}", guild_id);
match role_result {
Ok(Some(row)) => {
trace!("Auto role enabled for guild {}", guild_id);
let role_id = row.role_id as u64;
trace!("Adding role {} to user {}", role_id, new_member.user.name);
give_role_to_user(ctx, new_member, role_id).await;
}
Ok(None) => {
trace!("No auto role enabled for guild {}", guild_id);
}
Err(e) => {
error!("Database error fetching auto role for guild {}: {:?}", guild_id, e);
}
}
}
async fn give_role_to_user(ctx: &sere::Context, member: &sere::Member, role_id: u64) {
if let Err(e) = member.add_role(&ctx.http, sere::RoleId::new(role_id)).await {
error!("Error adding role to member: {:?}", e);
}
debug!("Role {} added to user {} in guild {}", role_id, member.user.name, member.guild_id);
}
+49 -20
View File
@@ -1,6 +1,11 @@
use std::env;
use poise::serenity_prelude as sere;
use tracing::{info, warn, error, debug, trace};
use tracing_subscriber::EnvFilter;
pub mod commands;
pub mod event_handler;
pub mod events;
pub mod shared_functions;
pub struct Data {
pub pool: sqlx::PgPool,
@@ -14,27 +19,60 @@ pub type Context<'a> = poise::Context<'a, Data, Error>;
#[tokio::main]
async fn main() {
// Load env from file
dotenv::dotenv().ok();
let log_level = env::var("TG_BOT_LOG").unwrap_or_else(|_| "info".to_string());
let filter = EnvFilter::new(format!("tg_dev_srv_bot={}", log_level));
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_file(true)
.with_thread_ids(true)
.with_thread_names(true)
.with_line_number(true)
.with_target(true)
.init();
trace!("Logging system started");
info!("Log Level: {}", log_level);
trace!("Env loaded");
// Grab envs
let database_url = env::var("DATABASE_URL").expect("Expected a database url in the environment");
let token = env::var("DISCORD_TOKEN").expect("Expected a token in the environment");
trace!("Loading database url");
let database_url = env::var("TG_BOT_DATABASE_URL").expect("Expected a database url in the environment");
trace!("Database url loaded");
trace!("Loading token");
let token = env::var("TG_BOT_DISCORD_TOKEN").expect("Expected a token in the environment");
trace!("Token loaded");
info!("Starting bot...");
// Initialize database
debug!("Initalizing database");
let pool = sqlx::postgres::PgPoolOptions::new().connect(&database_url).await.expect("Failed to connect to database");
debug!("Database connected");
trace!("Applying sqlschema");
let _res = sqlx::raw_sql(include_str!("../sqlschema.txt")).execute(&pool).await.expect("Failed to execute sqlschema");
debug!("Sqlschema applied");
trace!("Loading intents");
let intents = sere::GatewayIntents::non_privileged()
| sere::GatewayIntents::MESSAGE_CONTENT;
| sere::GatewayIntents::MESSAGE_CONTENT
| sere::GatewayIntents::GUILD_MEMBERS;
trace!("Intents loaded");
trace!("Initalizing framework");
let framework = poise::Framework::builder()
.options(poise::FrameworkOptions {
event_handler: |ctx, event, framework, user_data| {
Box::pin(event_handler(ctx, event, framework, user_data))
Box::pin(event_handler::event_handler(ctx, event, framework, user_data))
},
commands: vec![commands::ping(), commands::calc(), commands::manage_users()],
commands: vec![commands::ping(), commands::calc(), commands::manage_admins(), commands::manage_auto_role()],
..Default::default()
})
.setup(|ctx, _ready, framework| {
@@ -46,23 +84,14 @@ async fn main() {
})
})
.build();
trace!("Framework initialized");
debug!("Connecting to Discord");
let mut client = sere::ClientBuilder::new(&token, intents).framework(framework).await.expect("Err creating client");
debug!("Connected to Discord");
info!("Bot running");
if let Err(why) = client.start().await {
println!("Client error: {why:?}");
error!("Client error: {why:?}");
}
}
async fn event_handler(
ctx: &sere::Context,
event: &sere::FullEvent,
framework: poise::FrameworkContext<'_, Data, Error>,
user_data: &Data,
) -> Result<(), Error> {
if let sere::FullEvent::ChannelCreate {channel} = event {
println!("Channel created: {channel:?}");
}
Ok(())
}
+60
View File
@@ -0,0 +1,60 @@
use crate::{Context, Error};
use poise::serenity_prelude as sere;
use tracing::trace;
pub async fn get_all_admin_users(ctx: Context<'_>) -> Result<Vec<sere::User>, Error> {
let guild_id = ctx.guild_id().unwrap();
let pool = &ctx.data().pool;
let db_guild_id = guild_id.get() as i64;
let admin_users_records = sqlx::query!("SELECT user_id FROM admin_users WHERE guild_id = $1;", db_guild_id)
.fetch_all(pool)
.await?;
let admin_roles_records = sqlx::query!("SELECT role_id FROM admin_roles WHERE guild_id = $1;", db_guild_id)
.fetch_all(pool)
.await?;
let admin_role_ids: std::collections::HashSet<_> = admin_roles_records
.into_iter()
.map(|record| sere::RoleId::new(record.role_id as u64))
.collect();
let mut admin_users = Vec::new();
let mut admin_user_ids = std::collections::HashSet::new();
for record in admin_users_records {
let user_id = sere::UserId::new(record.user_id as u64);
if admin_user_ids.insert(user_id) {
if let Ok(user) = user_id.to_user(ctx.http()).await {
admin_users.push(user);
}
}
}
let mut last_member_id = None;
loop {
let members = guild_id.members(ctx.http(), Some(1000), last_member_id).await?;
if members.is_empty() {
break;
}
last_member_id = Some(members.last().unwrap().user.id);
for member in members {
let has_role = member.roles.iter().any(|role_id| admin_role_ids.contains(role_id));
let has_admin_perms = ctx.guild()
.map(|g| g.member_permissions(&member).administrator())
.unwrap_or(false);
if (has_role || has_admin_perms) && admin_user_ids.insert(member.user.id) {
admin_users.push(member.user);
}
}
}
Ok(admin_users)
}
pub async fn trace_message(msg: String, channel: String, guild: String) {
trace!("Saying \"{}\" in channel {} in guild {}", msg, channel, guild);
}
View File
Executable
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# AI WRITTEN
set -e
# Ensure we run from the script's directory
cd "$(dirname "$0")"
# Load environment variables if they exist
if [ -f .env ]; then
set -a
source .env
set +a
fi
echo "Wiping database volume and restarting containers..."
docker compose -f docker-compose-dev.yml down -v
docker compose -f docker-compose-dev.yml up -d
echo "Waiting for PostgreSQL to be ready..."
until [ "$(docker inspect -f '{{.State.Health.Status}}' tg-dev-srv-bot-postgres 2>/dev/null)" = "healthy" ]; do
printf "."
sleep 1
done
echo ""
echo "PostgreSQL is healthy!"
echo "Applying database schema from sqlschema.txt..."
docker compose -f docker-compose-dev.yml exec -T postgres psql -U "${TG_BOT_POSTGRES_USER:-postgres}" -d "${TG_BOT_POSTGRES_DB:-tg_dev_srv_bot}" -c "$(cat sqlschema.txt)"
echo "Database reset and schema applied successfully!"