initial commit

This commit is contained in:
lucy 2025-06-15 13:21:51 +02:00
commit b2e6bb1dfa
20 changed files with 4504 additions and 0 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
/target
.direnv
/logs
config.json
config.toml
config/

View file

@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT * FROM custom_commands WHERE command = $1;",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "command",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "response",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "90608f616dfbc17de3ccfe5a30b3ad7dc75ad438de55755aefe3b9a7e46f5c0c"
}

View file

@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT command FROM custom_commands WHERE command LIKE $1;",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "command",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "ab9481dff0717ae2d901ffd4a03b742d80c687f8d29ec6e86cc0f0e066f4be82"
}

View file

@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO custom_commands (command, response) VALUES (LOWER($1), $2)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "d8866cf840461fd954a5345cce31b1419c2075ca1c411ba52c6057b9e35fab2a"
}

View file

@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM custom_commands WHERE command = $1;",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "f3b61b3e21833c2bb158febb1df80b46af882d1391552389f771893a304f5981"
}

3515
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

24
Cargo.toml Normal file
View file

@ -0,0 +1,24 @@
[package]
name = "blizzpacksbot"
version = "0.1.0"
edition = "2024"
[dependencies]
color-eyre = "0.6.5"
config = "0.15.11"
poise = "0.6.1"
serde = { version = "1.0.219", features = ["derive"] }
serde_json = "1.0.140"
sqlx = { version = "0.8.6", features = ["runtime-tokio", "postgres"] }
tokio = { version = "1.45.1", features = [
"tracing",
"macros",
"rt-multi-thread",
] }
tracing = "0.1.41"
tracing-appender = "0.2.3"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
[features]
default = ["cache"]
cache = ["poise/cache"]

169
flake.lock Normal file
View file

@ -0,0 +1,169 @@
{
"nodes": {
"crane": {
"locked": {
"lastModified": 1745454774,
"narHash": "sha256-oLvmxOnsEKGtwczxp/CwhrfmQUG2ym24OMWowcoRhH8=",
"owner": "ipetkov",
"repo": "crane",
"rev": "efd36682371678e2b6da3f108fdb5c613b3ec598",
"type": "github"
},
"original": {
"owner": "ipetkov",
"repo": "crane",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": "nixpkgs-lib"
},
"locked": {
"lastModified": 1748821116,
"narHash": "sha256-F82+gS044J1APL0n4hH50GYdPRv/5JWm34oCJYmVKdE=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "49f0870db23e8c1ca0b5259734a02cd9e1e371a1",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1748929857,
"narHash": "sha256-lcZQ8RhsmhsK8u7LIFsJhsLh/pzR9yZ8yqpTzyGdj+Q=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c2a03962b8e24e669fb37b7df10e7c79531ff1a4",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-lib": {
"locked": {
"lastModified": 1748740939,
"narHash": "sha256-rQaysilft1aVMwF14xIdGS3sj1yHlI6oKQNBRTF40cc=",
"owner": "nix-community",
"repo": "nixpkgs.lib",
"rev": "656a64127e9d791a334452c6b6606d17539476e2",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixpkgs.lib",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1745234285,
"narHash": "sha256-GfpyMzxwkfgRVN0cTGQSkTC0OHhEkv3Jf6Tcjm//qZ0=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "c11863f1e964833214b767f4a369c6e6a7aba141",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_3": {
"locked": {
"lastModified": 1747958103,
"narHash": "sha256-qmmFCrfBwSHoWw7cVK4Aj+fns+c54EBP8cGqp/yK410=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "fe51d34885f7b5e3e7b59572796e1bcb427eccb1",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-parts": "flake-parts",
"nixpkgs": "nixpkgs",
"rust-flake": "rust-flake",
"treefmt-nix": "treefmt-nix"
}
},
"rust-flake": {
"inputs": {
"crane": "crane",
"nixpkgs": "nixpkgs_2",
"rust-overlay": "rust-overlay"
},
"locked": {
"lastModified": 1745474556,
"narHash": "sha256-ZOK3isVPOsJoAXw7dWnWq31elONt2xF1Q9THLfhKA7w=",
"owner": "juspay",
"repo": "rust-flake",
"rev": "f69408a404f09afe0d85be88eddff07a054c2397",
"type": "github"
},
"original": {
"owner": "juspay",
"repo": "rust-flake",
"type": "github"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"rust-flake",
"nixpkgs"
]
},
"locked": {
"lastModified": 1745462120,
"narHash": "sha256-TbVjPOl+Cg5vZ7TIn1KpQ8SOfHKD6OEgu84b6YSCfKE=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "79d3acd1a7e67fb9315fa5c5556eb6adf93dc2da",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"treefmt-nix": {
"inputs": {
"nixpkgs": "nixpkgs_3"
},
"locked": {
"lastModified": 1749194973,
"narHash": "sha256-eEy8cuS0mZ2j/r/FE0/LYBSBcIs/MKOIVakwHVuqTfk=",
"owner": "numtide",
"repo": "treefmt-nix",
"rev": "a05be418a1af1198ca0f63facb13c985db4cb3c5",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "treefmt-nix",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

57
flake.nix Normal file
View file

@ -0,0 +1,57 @@
{
description = "Description for the project";
inputs = {
flake-parts.url = "github:hercules-ci/flake-parts";
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
rust-flake.url = "github:juspay/rust-flake";
treefmt-nix.url = "github:numtide/treefmt-nix";
};
outputs =
inputs@{ flake-parts, ... }:
flake-parts.lib.mkFlake { inherit inputs; } {
imports = [
inputs.rust-flake.flakeModules.default
inputs.rust-flake.flakeModules.nixpkgs
inputs.treefmt-nix.flakeModule
];
systems = [
"x86_64-linux"
"aarch64-linux"
"aarch64-darwin"
"x86_64-darwin"
];
perSystem =
{
pkgs,
self',
...
}:
{
# Per-system attributes can be defined here. The self' and inputs'
# module parameters provide easy access to attributes of the same
# system.
# Equivalent to inputs'.nixpkgs.legacyPackages.hello;
packages.default = pkgs.hello;
treefmt.programs = {
nixfmt.enable = true;
deadnix.enable = true;
statix.enable = true;
rustfmt.enable = true;
};
devShells.default = pkgs.mkShell {
inputsFrom = [ self'.devShells.rust ];
buildInputs = with pkgs; [ sqlx-cli postgresql libpq ];
DATABASE_URL="postgres://twitchbot@noproxy.groundcrafter.de/twitchbot";
};
};
flake = {
# The usual flake attributes can be defined here, including system-
# agnostic ones like nixosModule and system-enumerating ones, although
# those are more easily expressed in perSystem.
};
};
}

View file

@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS custom_commands (
id SERIAL PRIMARY KEY,
command TEXT UNIQUE NOT NULL,
response TEXT NOT NULL
);

2
rust-toolchain.toml Normal file
View file

@ -0,0 +1,2 @@
[toolchain]
channel = "nightly"

17
src/builtins.rs Normal file
View file

@ -0,0 +1,17 @@
use crate::{Context, Error};
#[cfg(feature = "cache")]
#[poise::command(slash_command, prefix_command)]
pub async fn servers(ctx: Context<'_>) -> Result<(), Error> {
poise::builtins::servers(ctx).await?;
Ok(())
}
#[poise::command(slash_command, prefix_command)]
pub async fn help(ctx: Context<'_>, command: Option<String>) -> Result<(), Error> {
let configuration = poise::builtins::HelpConfiguration {
..Default::default()
};
poise::builtins::help(ctx, command.as_deref(), configuration).await?;
Ok(())
}

105
src/commands/admin.rs Normal file
View file

@ -0,0 +1,105 @@
use color_eyre::{Section, eyre};
use poise::serenity_prelude::{
ChannelType, CreateChannel, PermissionOverwrite, PermissionOverwriteType, Permissions, RoleId,
};
use crate::Context;
async fn has_mod_role(ctx: Context<'_>) -> eyre::Result<bool> {
let mod_role = ctx.data().settings.roles.moderator;
match ctx.author_member().await {
Some(member) => match member.roles(ctx) {
Some(roles) => Ok(roles.iter().any(|role| role.id.get() == mod_role)),
None => Ok(false),
},
None => Err(eyre::eyre!("no member")),
}
}
#[poise::command(
slash_command,
check = "has_mod_role",
guild_only,
required_bot_permissions = "MANAGE_CHANNELS|MANAGE_ROLES"
)]
pub async fn new_modpack(
ctx: Context<'_>,
#[description = "modpack name"] name: String,
) -> eyre::Result<()> {
let Some(guild) = ctx.guild().map(|g| g.clone()) else {
ctx.reply("Diser Befehl muss in einem Server ausgeführt werden.")
.await?;
return Err(eyre::eyre!("new_modpack is a guild-only command."));
};
let base_perms = Vec::new();
let parent_channel = guild
.create_channel(
ctx,
CreateChannel::new(name)
.kind(ChannelType::Category)
.permissions(base_perms.clone()),
)
.await
.map_err(|e| eyre::eyre!(e).wrap_err("Error creating category channel"))?;
guild
.create_channel(
ctx,
CreateChannel::new("announcements")
.kind(
if guild.features.contains(&"COMMUNITY".to_string())
|| guild.features.contains(&"NEWS".to_string())
{
ChannelType::News
} else {
ChannelType::Text
},
)
.permissions(vec![PermissionOverwrite {
allow: Permissions::empty(),
deny: Permissions::SEND_MESSAGES,
kind: PermissionOverwriteType::Role(RoleId::new(guild.id.into())),
}])
.category(&parent_channel),
)
.await
.map_err(|e| eyre::eyre!(e).wrap_err("Error creating announcements channel"))?;
guild
.create_channel(
ctx,
CreateChannel::new("bug-reports")
.kind(ChannelType::Text)
.category(&parent_channel),
)
.await
.map_err(|e| eyre::Report::msg("Error creating bug-reports channel").error(e))?;
guild
.create_channel(
ctx,
CreateChannel::new("suggestions")
.kind(ChannelType::Text)
.category(&parent_channel),
)
.await
.map_err(|e| eyre::eyre!(e).wrap_err("Creating suggestions channel"))?;
guild
.create_channel(
ctx,
CreateChannel::new("main")
.kind(ChannelType::Text)
.category(&parent_channel),
)
.await
.map_err(|e| eyre::eyre!(e).wrap_err("Creating main channel"))?;
guild
.create_channel(
ctx,
CreateChannel::new("Voice")
.kind(ChannelType::Voice)
.category(&parent_channel),
)
.await
.map_err(|e| eyre::eyre!(e).wrap_err("Creating Voice channel"))?;
ctx.reply("Modpack Kategorie und Kanäle erstellt.").await?;
Ok(())
}

View file

@ -0,0 +1,140 @@
use color_eyre::eyre::{self, eyre};
use poise::serenity_prelude::{self, Message};
use tracing::{error, info};
use crate::{Context, Data};
pub async fn execute(
ctx: &serenity_prelude::Context,
data: &Data,
msg: &Message,
command_name: String,
) -> eyre::Result<()> {
let full_command_name = format!("!{}", command_name.to_lowercase());
let db = &data.db;
let query = sqlx::query!(
"SELECT * FROM custom_commands WHERE command = $1;",
command_name
);
match query.fetch_optional(db).await {
Ok(Some(response)) => match msg.reply(ctx, response.response).await {
Ok(_) => {
info!("replied to customcommand response");
}
Err(why) => {
error!("Failed to send customcommand response: {why}");
}
},
Ok(None) => {
info!("Could not find customcommand {full_command_name}");
}
Err(why) => {
return Err(eyre!("Failed to fetch customcommand from database: {why}"));
}
}
Ok(())
}
#[poise::command(slash_command, prefix_command)]
pub async fn add(ctx: Context<'_>, command_name: String, response: String) -> eyre::Result<()> {
match sqlx::query!(
r#"INSERT INTO custom_commands (command, response) VALUES (LOWER($1), $2)"#,
command_name,
response
)
.execute(&ctx.data().db)
.await
{
Ok(result) => {
if result.rows_affected() > 0 {
ctx.reply(format!(
"Successfully added command `{}{command_name}`.",
ctx.data().settings.prefix
))
.await?;
}
}
Err(why) => return Err(why.into()),
}
Ok(())
}
async fn autocomplete_commandname(ctx: Context<'_>, partial: &str) -> Vec<String> {
let arg = format!("%{}%", partial.to_lowercase());
let query = sqlx::query!(
"SELECT command FROM custom_commands WHERE command LIKE $1;",
arg
);
query
.fetch_all(&ctx.data().db)
.await
.unwrap_or(vec![])
.iter()
.map(|e| e.command.clone())
.collect()
}
#[poise::command(slash_command, prefix_command)]
pub async fn del(
ctx: Context<'_>,
#[autocomplete = "autocomplete_commandname"] command_name: String,
) -> eyre::Result<()> {
let query = sqlx::query!(
r#"DELETE FROM custom_commands WHERE command = $1;"#,
command_name
);
match query.execute(&ctx.data().db).await {
Ok(result) => {
if result.rows_affected() == 1 {
ctx.reply(format!(
"Der Befehl {}{command_name} wurde gelöscht.",
ctx.data().settings.prefix
))
.await?;
}
}
Err(why) => {
ctx.reply("Ein Fehler ist aufgetreten.").await?;
return Err(why.into());
}
}
Ok(())
}
#[poise::command(slash_command)]
pub async fn edit(
ctx: Context<'_>,
#[autocomplete = "autocomplete_commandname"] command_name: String,
response: String,
) -> eyre::Result<()> {
let query = sqlx::query!(
r#"SELECT * FROM custom_commands WHERE command = $1;"#,
command_name
);
match query.fetch_optional(&ctx.data().db).await {
Ok(Some(result)) => {
let update_query = sqlx::query!(
"UPDATE custom_commands SET response = $2 WHERE id = $1;",
result.id,
response
);
match update_query.execute(&ctx.data().db).await {
Ok(_) => {
ctx.reply(format!("Customcommand {command_name} wurde aktualisiert."))
.await?;
}
Err(why) => {
ctx.reply(format!("Beim aktualisieren des Customcommands {command_name} ist ein Fehler aufgetreten.")).await?;
return Err(why.into());
}
}
}
Ok(None) => {
ctx.reply(format!("there is no customcommand {command_name}"))
.await?;
}
Err(why) => return Err(why.into()),
}
Ok(())
}

2
src/commands/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod admin;
pub mod custom_commands;

166
src/event_handler.rs Normal file
View file

@ -0,0 +1,166 @@
use color_eyre::eyre;
use poise::serenity_prelude::{
self as serenity, Channel, ChannelId, Colour, CreateEmbed, CreateEmbedAuthor,
CreateEmbedFooter, CreateMessage,
};
use tracing::{error, info, warn};
use crate::Data;
async fn get_parent_channel_name(ctx: &serenity::Context, channel: &Channel) -> String {
match channel
.clone()
.guild()
.and_then(|channel| channel.parent_id)
.map(|parent_id| parent_id.name(&ctx))
{
Some(p) => p.await.map(|e| e + " - ").unwrap_or(String::new()),
None => String::new(),
}
}
async fn deleted_log(
ctx: &serenity::Context,
channel_id: &ChannelId,
deleted_message_id: &serenity::MessageId,
data: &Data,
) {
let embed = if let Some(msg) = ctx
.cache
.message(channel_id, deleted_message_id)
.map(|m| m.clone())
{
let embed_color = message_member_color(ctx, &msg);
let message_channel = msg.channel(&ctx);
let channel_name = get_channel_name(ctx, message_channel).await;
CreateEmbed::new()
.author(
CreateEmbedAuthor::new(msg.author.tag()).icon_url(
msg.author
.avatar_url()
.unwrap_or_else(|| msg.author.default_avatar_url()),
),
)
.description(msg.content_safe(ctx))
.color(embed_color.await)
.footer(CreateEmbedFooter::new(channel_name))
} else {
CreateEmbed::new().title("Deleted").description(format!(
"Could not get message details. ({deleted_message_id})"
))
};
let res = ChannelId::new(data.settings.channels.log)
.send_message(ctx, CreateMessage::new().add_embed(embed));
match res.await {
Ok(_m) => info!("Successfully sent deleted log for {deleted_message_id}"),
Err(why) => error!("Failed to send a message to deleted log: {why}"),
}
}
async fn message_member_color(ctx: &serenity::Context, msg: &serenity::Message) -> Colour {
if let Ok(mem) = msg.member(&ctx).await {
if let Some(colour) = mem.colour(ctx) {
return colour;
}
}
Colour::default()
}
async fn get_channel_name(
ctx: &serenity::Context,
message_channel: impl Future<Output = Result<Channel, serenity::Error>>,
) -> String {
match message_channel.await {
Ok(c) => {
let parent_name = get_parent_channel_name(ctx, &c).await;
parent_name + &c.guild().map_or(String::new(), |gc| gc.name().to_string())
}
Err(_) => "<unknown-channel>".to_owned(),
}
}
pub async fn event_handler(
ctx: &serenity::Context,
event: &serenity::FullEvent,
data: &Data,
) -> eyre::Result<()> {
use serenity::FullEvent;
match event {
FullEvent::MessageDelete {
channel_id,
deleted_message_id,
guild_id: _,
} => {
Box::pin(deleted_log(ctx, channel_id, deleted_message_id, data)).await;
}
FullEvent::Message { .. }
| FullEvent::MessageDeleteBulk { .. }
| FullEvent::CacheReady { .. }
| FullEvent::ShardsReady { .. }
| FullEvent::ChannelCreate { .. }
| FullEvent::CategoryCreate { .. }
| FullEvent::CategoryDelete { .. }
| FullEvent::ChannelDelete { .. }
| FullEvent::ChannelPinsUpdate { .. }
| FullEvent::ChannelUpdate { .. }
| FullEvent::GuildAuditLogEntryCreate { .. }
| FullEvent::GuildBanRemoval { .. }
| FullEvent::GuildCreate { .. }
| FullEvent::GuildDelete { .. }
| FullEvent::GuildEmojisUpdate { .. }
| FullEvent::GuildIntegrationsUpdate { .. }
| FullEvent::GuildMemberAddition { .. }
| FullEvent::GuildMemberRemoval { .. }
| FullEvent::GuildMemberUpdate { .. }
| FullEvent::GuildMembersChunk { .. }
| FullEvent::GuildRoleCreate { .. }
| FullEvent::GuildRoleDelete { .. }
| FullEvent::GuildRoleUpdate { .. }
| FullEvent::GuildStickersUpdate { .. }
| FullEvent::GuildUpdate { .. }
| FullEvent::InviteCreate { .. }
| FullEvent::InviteDelete { .. }
| FullEvent::MessageUpdate { .. }
| FullEvent::ReactionAdd { .. }
| FullEvent::ReactionRemove { .. }
| FullEvent::ReactionRemoveAll { .. }
| FullEvent::ReactionRemoveEmoji { .. }
| FullEvent::Ready { .. }
| FullEvent::PresenceUpdate { .. }
| FullEvent::Resume { .. }
| FullEvent::ShardStageUpdate { .. }
| FullEvent::TypingStart { .. }
| FullEvent::UserUpdate { .. }
| FullEvent::VoiceServerUpdate { .. }
| FullEvent::VoiceStateUpdate { .. }
| FullEvent::VoiceChannelStatusUpdate { .. }
| FullEvent::WebhookUpdate { .. }
| FullEvent::InteractionCreate { .. }
| FullEvent::IntegrationCreate { .. }
| FullEvent::IntegrationUpdate { .. }
| FullEvent::IntegrationDelete { .. }
| FullEvent::StageInstanceCreate { .. }
| FullEvent::StageInstanceUpdate { .. }
| FullEvent::StageInstanceDelete { .. }
| FullEvent::ThreadCreate { .. }
| FullEvent::ThreadUpdate { .. }
| FullEvent::ThreadDelete { .. }
| FullEvent::ThreadListSync { .. }
| FullEvent::ThreadMemberUpdate { .. }
| FullEvent::ThreadMembersUpdate { .. }
| FullEvent::GuildScheduledEventCreate { .. }
| FullEvent::GuildScheduledEventUpdate { .. }
| FullEvent::GuildScheduledEventDelete { .. }
| FullEvent::GuildScheduledEventUserAdd { .. }
| FullEvent::GuildScheduledEventUserRemove { .. }
| FullEvent::EntitlementCreate { .. }
| FullEvent::EntitlementUpdate { .. }
| FullEvent::EntitlementDelete { .. }
| FullEvent::MessagePollVoteAdd { .. }
| FullEvent::MessagePollVoteRemove { .. }
| FullEvent::Ratelimit { .. } => {}
e => {
warn!("Encountered an unknown event: {}", e.snake_case_name());
}
}
Ok(())
}

41
src/logging.rs Normal file
View file

@ -0,0 +1,41 @@
use tracing::level_filters::LevelFilter;
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
pub fn init() {
let file_appender = RollingFileAppender::builder()
.rotation(Rotation::DAILY)
.max_log_files(7)
.filename_suffix("log")
.build("logs")
.expect("failed to construct rolling file appender");
let console_layer = fmt::layer()
.with_target(true)
.with_level(true)
.with_ansi(true)
.with_line_number(true)
.with_file(true)
.with_span_events(fmt::format::FmtSpan::CLOSE)
.pretty();
let file_layer = fmt::layer()
.with_writer(file_appender)
.with_ansi(false)
.with_target(true)
.with_level(true)
.with_line_number(true)
.with_file(true)
.with_span_events(fmt::format::FmtSpan::CLOSE)
.compact();
let crate_name = env!("CARGO_PKG_NAME").replace('-', "_");
let filter = EnvFilter::builder()
.with_default_directive(LevelFilter::ERROR.into())
.parse(format!("{},{}={}", "error", crate_name, LevelFilter::INFO))
.expect("Failed to build EnvFilter");
tracing_subscriber::registry()
.with(filter)
.with(console_layer)
.with(file_layer)
.init();
}

131
src/main.rs Normal file
View file

@ -0,0 +1,131 @@
mod builtins;
mod commands;
mod event_handler;
mod logging;
mod settings;
use color_eyre::eyre;
use poise::serenity_prelude as serenity;
use serenity::cache::Settings as CacheSettings;
use sqlx::PgPool;
use tracing::{debug, error, info};
use crate::{commands::custom_commands, event_handler::event_handler, settings::Settings};
type Error = eyre::ErrReport;
type Context<'a> = poise::Context<'a, Data, Error>;
#[derive(Debug)]
pub struct Data {
pub settings: Settings,
pub db: PgPool,
}
#[tokio::main]
async fn main() -> eyre::Result<()> {
logging::init();
let settings = Settings::load()?;
debug!("parsed settings: {settings:?}");
let db = PgPool::connect(&settings.database_url).await?;
sqlx::migrate!().run(&db).await?;
let settings_clone = settings.clone();
let framework = build_framework(settings.clone(), db);
let token = std::env::var("DISCORD_TOKEN").unwrap_or(settings_clone.token);
let intents = serenity::GatewayIntents::non_privileged()
| serenity::GatewayIntents::MESSAGE_CONTENT
| serenity::GatewayIntents::GUILD_MESSAGES
| serenity::GatewayIntents::GUILD_MESSAGE_REACTIONS
| serenity::GatewayIntents::GUILDS;
let mut cache_settings = CacheSettings::default();
cache_settings.max_messages = 1000;
let mut client = serenity::ClientBuilder::new(token, intents)
.cache_settings(cache_settings)
.framework(framework)
.await?;
client.start().await.map_err(Into::into)
}
fn build_framework(settings: Settings, db: PgPool) -> poise::Framework<Data, eyre::ErrReport> {
poise::Framework::builder()
.options(poise::FrameworkOptions {
commands: vec![
#[cfg(feature = "cache")]
builtins::servers(),
builtins::help(),
commands::custom_commands::add(),
commands::custom_commands::del(),
commands::custom_commands::edit(),
commands::admin::new_modpack(),
],
prefix_options: poise::PrefixFrameworkOptions {
prefix: Some(settings.prefix.clone()),
..Default::default()
},
event_handler: |ctx, event, _framework, data| {
Box::pin(async move { Box::pin(event_handler(ctx, event, data)).await })
},
on_error: |error| {
Box::pin(async move {
match error {
poise::FrameworkError::ArgumentParse { error, .. } => {
if let Some(error) = error.downcast_ref::<serenity::RoleParseError>() {
error!("Found a RoleParseError: {error:?}");
} else {
error!("Not a RoleParseError :(");
}
}
poise::FrameworkError::Command { error, ctx, .. } => {
error!(
"An error occurred executing command {}: {:?}",
ctx.command().name,
error
);
poise::builtins::on_error(poise::FrameworkError::new_command(
ctx, error,
))
.await
.unwrap();
}
poise::FrameworkError::UnknownCommand {
ctx,
msg,
prefix,
msg_content,
framework,
..
} => {
// TODO: add error handling
let _ = custom_commands::execute(
ctx,
framework.user_data,
msg,
msg_content.replace(prefix, ""),
)
.await;
}
other => {
error!("An error occurred.");
poise::builtins::on_error(other).await.unwrap();
}
}
})
},
..Default::default()
})
.setup(move |ctx, ready, framework| {
Box::pin(async move {
info!("[Ready] {} connected", ready.user.display_name());
let main_guild = ctx.http.get_guild(settings.guild.into()).await;
if let Err(why) = main_guild {
error!("Failed to fetch main guild: {why}");
}
poise::builtins::register_globally(ctx, &framework.options().commands).await?;
Ok(Data { settings, db })
})
})
.build()
}

38
src/settings.rs Normal file
View file

@ -0,0 +1,38 @@
use config::Config;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
pub token: String,
pub channels: ChannelConfig,
pub roles: RoleConfig,
pub database_url: String,
pub prefix: String,
pub guild: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RoleConfig {
pub moderator: u64,
pub trial_mod: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelConfig {
pub command: u64,
pub languages: u64,
pub verify: u64,
pub log: u64,
}
impl Settings {
pub fn load() -> Result<Self, config::ConfigError> {
Config::builder()
.add_source(config::File::with_name("config/config.json").required(false))
.add_source(config::File::with_name("config/config.toml").required(false))
.add_source(config::File::with_name("./config.json").required(false))
.add_source(config::File::with_name("./config.toml").required(false))
.add_source(config::Environment::default())
.build()?
.try_deserialize()
}
}