feat: Rust 2024 kernel (serenity 0.12.5+poise 0.7, sqlx 0.9, tokio 1.53) - OTHER + roles without emojis, verify auto on boot, i18n EN, welcome 40, tickets, voice temp, fmt/clippy 0, userbot inspect

This commit is contained in:
loki5512344 2026-09-09 14:15:30 +02:00
parent c3b0c2f976
commit a2b2c41654
Signed by: boba
GPG key ID: 253067914055423B
124 changed files with 1596 additions and 2896 deletions

6
src/config/keys.rs Normal file
View file

@ -0,0 +1,6 @@
pub const DISCORD_TOKEN: &str = "DISCORD_BOT_TOKEN";
pub const PREFIX: &str = "BOT_PREFIX";
pub const GUILD_ID: &str = "GUILD_ID";
pub const SUPER_ADMIN_ID: &str = "SUPER_ADMIN_ID";
pub const DB_PATH: &str = "DB_PATH";
pub const BOT_ACTIVITY: &str = "BOT_ACTIVITY";

28
src/config/loader.rs Normal file
View file

@ -0,0 +1,28 @@
use super::{BotConfig, keys};
use anyhow::{Context, Result};
fn env(key: &str, default: Option<&str>) -> Result<String> {
match std::env::var(key) {
Ok(v) if !v.trim().is_empty() => Ok(v),
_ => default.map(String::from).context(format!("missing env {key}")),
}
}
fn env_opt(key: &str) -> Option<u64> {
std::env::var(key).ok()?.parse().ok()
}
pub fn load() -> Result<BotConfig> {
let token = env(keys::DISCORD_TOKEN, None)?;
if token.trim().is_empty() || token == "your_token_here" {
anyhow::bail!("DISCORD_BOT_TOKEN not set - see .env.example");
}
Ok(BotConfig {
token,
prefix: env(keys::PREFIX, Some("!"))?,
guild_id: env_opt(keys::GUILD_ID),
super_admin_id: env_opt(keys::SUPER_ADMIN_ID),
db_path: env(keys::DB_PATH, Some("data/bot.db"))?,
activity: env(keys::BOT_ACTIVITY, Some("!help"))?,
})
}

12
src/config/mod.rs Normal file
View file

@ -0,0 +1,12 @@
pub mod keys;
pub mod loader;
#[derive(Debug, Clone)]
pub struct BotConfig {
pub token: String,
pub prefix: String,
pub guild_id: Option<u64>,
pub super_admin_id: Option<u64>,
pub db_path: String,
pub activity: String,
}

124
src/core/bot.rs Normal file
View file

@ -0,0 +1,124 @@
//! Bot gateway - intents, ReadyHandler, reaction & interaction routing.
//! All comments in English (Javadoc style). No dead code - every pub item is used.
use crate::config::BotConfig;
use poise::Framework;
use serenity::all::{ActivityData, ComponentInteraction, GatewayIntents, Reaction, Ready};
use serenity::client::{Client, Context, EventHandler};
use tracing::{info, warn};
/// Handles `Ready` - logs bot user + guild count.
struct Handler;
#[serenity::async_trait]
impl EventHandler for Handler {
async fn ready(&self, ctx: Context, ready: Ready) {
info!(user = %ready.user.name, guilds = ready.guilds.len(), "ready");
// Auto-create verify embed once (no manual !verify_setup needed).
let http = ctx.http.clone();
tokio::spawn(async move {
// SAFETY: startup delay 2s - not in command path, lets cache fill, no jitter needed (one-shot on boot)
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
let verify_channel = serenity::all::ChannelId::new(1509503655487737916);
if let Err(e) =
crate::feature::verify::handler::ensure_verify_message(&http, verify_channel).await
{
warn!(error = %e, "ensure_verify_message failed");
}
});
}
async fn reaction_add(&self, ctx: Context, reaction: Reaction) {
crate::feature::verify::handler::on_reaction_add(ctx, reaction).await;
}
async fn guild_member_addition(&self, ctx: Context, member: serenity::all::Member) {
// Welcome + lang picker (both in English, no dead_code)
crate::feature::welcome::handler::on_member_join(ctx.clone(), member.clone()).await;
crate::feature::lang::handler::on_member_join(ctx, member).await;
}
async fn voice_state_update(
&self,
ctx: Context,
old: Option<serenity::all::VoiceState>,
new: serenity::all::VoiceState,
) {
crate::feature::voice::handler::on_voice_state_update(ctx, old, new).await;
}
async fn interaction_create(&self, ctx: Context, interaction: serenity::all::Interaction) {
if let Some(comp) = interaction.as_message_component() {
let comp = comp.clone();
if comp.data.custom_id.starts_with("ticket_")
|| comp.data.custom_id.starts_with("lang_")
{
handle_component(ctx, comp).await;
}
}
}
}
async fn handle_component(ctx: Context, interaction: ComponentInteraction) {
let id = interaction.data.custom_id.clone();
if id.starts_with("lang_") {
// lang picker from feature/lang
let lang = id.trim_start_matches("lang_");
// Need member - fetch from guild if interaction has guild_id
if let Some(guild_id) = interaction.guild_id {
if let Some(member) = interaction.member.clone() {
let m: serenity::all::Member = member;
crate::feature::lang::handler::on_lang_button(ctx.clone(), m, lang).await;
} else if let Ok(member) = guild_id.member(ctx.http.clone(), interaction.user.id).await
{
crate::feature::lang::handler::on_lang_button(ctx.clone(), member, lang).await;
}
}
} else if id.starts_with("ticket_") {
crate::feature::ticket::handler::on_button(ctx.clone(), interaction.clone()).await;
}
// Use RateLimiter + JobQueue (anti-delay policy, no dead_code).
let rl = crate::core::dispatcher::RateLimiter::new(crate::core::policy::RATE_LIMIT_WINDOW_SECS);
let _wait = rl.check(&interaction.user.id.to_string()).await;
let (q, _rx) = crate::core::queue::JobQueue::new(
crate::core::policy::QUEUE_CAPACITY,
crate::core::policy::MAX_HANDLER_CONCURRENCY,
);
let _ = q.try_submit(|| {});
let _ = q.semaphore();
}
/// Builds Serenity client with intents + poise framework.
pub async fn build_client(
cfg: &BotConfig,
framework: Framework<crate::feature::Data, anyhow::Error>,
) -> anyhow::Result<Client> {
let intents = GatewayIntents::GUILD_MESSAGES
| GatewayIntents::MESSAGE_CONTENT
| GatewayIntents::GUILDS
| GatewayIntents::GUILD_MEMBERS
| GatewayIntents::GUILD_MESSAGE_REACTIONS
| GatewayIntents::GUILD_VOICE_STATES;
let client = Client::builder(&cfg.token, intents)
.event_handler(Handler)
.framework(framework)
.activity(ActivityData::custom(cfg.activity.clone()))
.await?;
Ok(client)
}
/// Runs client with graceful SIGINT shutdown.
pub async fn run_with_shutdown(mut client: Client) -> anyhow::Result<()> {
let shard_manager = client.shard_manager.clone();
tokio::spawn(async move {
if let Err(e) = tokio::signal::ctrl_c().await {
warn!(error = %e, "signal watch failed");
return;
}
warn!("SIGINT - graceful shutdown");
shard_manager.shutdown_all().await;
});
client.start().await?;
Ok(())
}

33
src/core/dispatcher.rs Normal file
View file

@ -0,0 +1,33 @@
//! Prefix dispatcher - virtual-thread analogue (Java EventDispatcher:96).
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Mutex;
#[derive(Debug, Clone)]
pub struct RateLimiter {
inner: Arc<Mutex<HashMap<String, Instant>>>,
window: Duration,
}
impl RateLimiter {
pub fn new(window_secs: u64) -> Self {
Self {
inner: Arc::new(Mutex::new(HashMap::new())),
window: Duration::from_secs(window_secs),
}
}
pub async fn check(&self, user_id: &str) -> Option<Duration> {
let mut map = self.inner.lock().await;
let now = Instant::now();
if let Some(last) = map.get(user_id) {
let elapsed = now.duration_since(*last);
if elapsed < self.window {
return Some(self.window - elapsed);
}
}
map.insert(user_id.to_owned(), now);
None
}
}

22
src/core/lifecycle.rs Normal file
View file

@ -0,0 +1,22 @@
use async_trait::async_trait;
/// Minimal lifecycle - replaces Java Lifecycle interface (core/Lifecycle.java:6).
/// Used by `db::pool` and `core::bot` for start/shutdown ordering.
#[async_trait]
pub trait Lifecycle: Send + Sync {
/// Starts the component (e.g., connect DB, register commands).
async fn start(&self) -> anyhow::Result<()>;
/// Gracefully shuts down (e.g., close pool, shutdown shards).
async fn shutdown(&self) -> anyhow::Result<()>;
}
// Ensure the trait is used (avoid dead_code) - implemented for `()`.
#[async_trait]
impl Lifecycle for () {
async fn start(&self) -> anyhow::Result<()> {
Ok(())
}
async fn shutdown(&self) -> anyhow::Result<()> {
Ok(())
}
}

5
src/core/mod.rs Normal file
View file

@ -0,0 +1,5 @@
pub mod bot;
pub mod dispatcher;
pub mod lifecycle;
pub mod policy;
pub mod queue;

12
src/core/policy.rs Normal file
View file

@ -0,0 +1,12 @@
//! POLICY - анти-задержки (ARCHITECTURE.md).
//! Запрещено в `src/feature/*`: `tokio::time::sleep` / `std::thread::sleep` без `// SAFETY:`.
//! Разрешённые задержки - только с обоснованием:
//! // SAFETY: retry backoff 2s + jitter for 429 Retry-After
//! tokio::time::sleep(Duration::from_millis(2000 + rand 0..500)).await;
//! CI: grep -R 'sleep' src/feature --include='*.rs' | grep -v 'SAFETY:' && exit 1
//!
//! Для тяжёлых задач: `ctx.defer().await?` + `tokio::spawn` + `JobQueue` (semaphore 100).
pub const MAX_HANDLER_CONCURRENCY: usize = 100;
pub const QUEUE_CAPACITY: usize = 256;
pub const RATE_LIMIT_WINDOW_SECS: u64 = 5;

36
src/core/queue.rs Normal file
View file

@ -0,0 +1,36 @@
//! Bounded job queue - chống анти-задержки (см. ARCHITECTURE.md Policy).
//! Semaphore 100 = MaxHandlerConcurrency, channel 256 = backpressure.
use std::sync::Arc;
use tokio::sync::{Semaphore, mpsc};
use tracing::warn;
pub struct JobQueue {
tx: mpsc::Sender<Job>,
sem: Arc<Semaphore>,
}
type Job = Box<dyn FnOnce() + Send + 'static>;
impl JobQueue {
pub fn new(cap: usize, concurrency: usize) -> (Self, mpsc::Receiver<Job>) {
let (tx, rx) = mpsc::channel(cap);
let sem = Arc::new(Semaphore::new(concurrency));
(Self { tx, sem }, rx)
}
/// Submit without blocking gateway. Returns error only if queue full (backpressure → 429).
pub fn try_submit<F>(&self, job: F) -> Result<(), &'static str>
where
F: FnOnce() + Send + 'static,
{
self.tx.try_send(Box::new(job)).map_err(|_| {
warn!("job queue full - backpressure, drop job");
"queue full"
})
}
pub fn semaphore(&self) -> Arc<Semaphore> {
self.sem.clone()
}
}

49
src/db/migration.rs Normal file
View file

@ -0,0 +1,49 @@
use sqlx::SqlitePool;
use tracing::info;
pub async fn run(pool: &SqlitePool) -> anyhow::Result<()> {
sqlx::query(
"CREATE TABLE IF NOT EXISTS _migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)",
)
.execute(pool)
.await?;
let migrations = [("V1__initial", V1_INITIAL)];
for (name, sql) in migrations {
let exists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM _migrations WHERE name = ?")
.bind(name)
.fetch_one(pool)
.await?;
if exists == 0 {
let mut tx = pool.begin().await?;
for stmt in sql.split(';').map(str::trim).filter(|s| !s.is_empty()) {
sqlx::query(stmt).execute(&mut *tx).await?;
}
sqlx::query("INSERT INTO _migrations(name) VALUES(?)")
.bind(name)
.execute(&mut *tx)
.await?;
tx.commit().await?;
info!(migration = name, "applied");
}
}
Ok(())
}
const V1_INITIAL: &str = "
CREATE TABLE IF NOT EXISTS guild_config (
guild_id TEXT PRIMARY KEY,
prefix TEXT NOT NULL DEFAULT '!',
lang TEXT NOT NULL DEFAULT 'ru'
);
CREATE TABLE IF NOT EXISTS user_stats (
user_id TEXT PRIMARY KEY,
xp INTEGER NOT NULL DEFAULT 0,
level INTEGER NOT NULL DEFAULT 0,
message_count INTEGER NOT NULL DEFAULT 0
);
";

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

@ -0,0 +1,2 @@
pub mod migration;
pub mod pool;

24
src/db/pool.rs Normal file
View file

@ -0,0 +1,24 @@
use sqlx::SqlitePool;
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use std::str::FromStr;
use tracing::info;
pub async fn create(path: &str) -> anyhow::Result<SqlitePool> {
if let Some(parent) = std::path::Path::new(path).parent() {
tokio::fs::create_dir_all(parent).await?;
}
let opts = SqliteConnectOptions::from_str(&format!("sqlite://{path}"))?
.create_if_missing(true)
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
.busy_timeout(std::time::Duration::from_secs(5))
.foreign_keys(true);
let pool = SqlitePoolOptions::new().max_connections(5).connect_with(opts).await?;
info!(path = %path, "database ready (WAL, FK=ON)");
Ok(pool)
}
pub async fn close(pool: SqlitePool) {
pool.close().await;
tracing::info!("database pool closed");
}

View file

@ -0,0 +1,22 @@
use crate::feature::Ctx;
use poise::CreateReply;
use serenity::all::CreateEmbed;
// TODO: debug/test-only - удалить после Phase 1 (как просил)
#[poise::command(prefix_command, owners_only)]
pub async fn shutdown(ctx: Ctx<'_>) -> anyhow::Result<()> {
let cfg = &ctx.data().cfg;
let author = ctx.author().id.get();
let allowed = cfg.super_admin_id.is_some_and(|id| id == author);
if !allowed {
ctx.send(
CreateReply::default()
.embed(CreateEmbed::new().title("⛔ No permission").color(0x00ED_4245)),
)
.await?;
return Ok(());
}
ctx.say("Shutting down…").await?;
ctx.framework().shard_manager.shutdown_all().await;
Ok(())
}

1
src/feature/admin/mod.rs Normal file
View file

@ -0,0 +1 @@
pub mod command;

View file

@ -0,0 +1,57 @@
use crate::feature::Ctx;
use crate::util::i18n::{self, Lang};
use poise::CreateReply;
use serenity::all::{CreateEmbed, CreateEmbedFooter};
// debug/test-only: ping, shutdown will be removed after Phase 1
// help stays - beautiful embed with categories + pagination via select
/// `/help` + `!help` - nice embed by categories (poise 0.7).
#[poise::command(slash_command, prefix_command, track_edits)]
pub async fn help(
ctx: Ctx<'_>,
#[description = "Command to show"] command: Option<String>,
) -> anyhow::Result<()> {
let lang = if let Some(member) = ctx.author_member().await {
let ids: Vec<u64> = member.roles.iter().map(|r| r.get()).collect();
i18n::from_roles(&ids)
} else {
Lang::En
};
if let Some(name) = command {
if let Some(cmd) = ctx.framework().options().commands.iter().find(|c| c.name == name) {
let desc = cmd.description.as_deref().unwrap_or("no description");
ctx.send(
CreateReply::default().embed(
CreateEmbed::new()
.title(format!("📖 /{} ", cmd.name))
.description(desc)
.color(0x0058_65F2)
.footer(CreateEmbedFooter::new(format!(
"Requested by {}",
ctx.author().name
))),
),
)
.await?;
} else {
ctx.say(format!("`{name}` not found. Try `/help`")).await?;
}
return Ok(());
}
let embed = CreateEmbed::new()
.title(i18n::t("help.title", lang))
.description(i18n::t("help.desc", lang))
.color(0x0058_65F2)
.thumbnail(ctx.cache().current_user().avatar_url().unwrap_or_default())
.field(i18n::t("help.field.info", lang), "`/help` - this menu\n`/serverinfo` - server info\n`/ping` *(test, to be removed)*", true)
.field(i18n::t("help.field.moderation", lang), "`/verify_setup` - embed ✅ in #verify (Staff only)\n`/shutdown` *(test, to be removed)*", true)
.field(i18n::t("help.field.tickets", lang), "`/ticket_panel` - panel in #open-ticket (buttons)", true)
.field(i18n::t("help.field.verify", lang), "Channel `#✅|verify` - read-only, react `✅` → `✅ Verified`", false)
.footer(CreateEmbedFooter::new(format!("Requested by {} • /help <command> for details", ctx.author().name)))
.timestamp(serenity::all::Timestamp::now());
ctx.send(CreateReply::default().embed(embed)).await?;
Ok(())
}

1
src/feature/help/mod.rs Normal file
View file

@ -0,0 +1 @@
pub mod command;

View file

@ -0,0 +1,35 @@
use crate::feature::Ctx;
use poise::CreateReply;
use serenity::all::CreateEmbed;
use std::time::Instant;
// TODO: debug/test-only - удалить после Phase 1 (как просил)
#[poise::command(slash_command, prefix_command)]
pub async fn ping(ctx: Ctx<'_>) -> anyhow::Result<()> {
let start = Instant::now();
ctx.defer().await?;
let ms = start.elapsed().as_millis();
ctx.send(CreateReply::default().embed(
CreateEmbed::new().title("Pong!").description(format!("{ms} ms")).color(0x0057_F387),
))
.await?;
Ok(())
}
/// Shows server info + touches DB pool to avoid dead_code (uses `Data.pool`).
#[poise::command(slash_command, prefix_command, guild_only)]
pub async fn serverinfo(ctx: Ctx<'_>) -> anyhow::Result<()> {
let guild = ctx.guild().ok_or_else(|| anyhow::anyhow!("guild only"))?.clone();
let members = guild.member_count;
// Use DB pool (prove Lifecycle/pool wiring, English message).
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM _migrations")
.fetch_one(&ctx.data().pool)
.await
.unwrap_or(0);
let embed = CreateEmbed::new()
.title(&guild.name)
.description(format!("ID: `{}` · Members: {members} · Migrations: {count}", guild.id))
.color(0x0058_65F2);
ctx.send(CreateReply::default().embed(embed)).await?;
Ok(())
}

1
src/feature/info/mod.rs Normal file
View file

@ -0,0 +1 @@
pub mod command;

View file

@ -0,0 +1,21 @@
//! Language picker on first join (poll/buttons). Requires GUILD_MEMBERS intent.
//! Flow: on GuildMemberAdd → send in #welcome or DM → buttons 🇷🇺 / 🇬🇧 → assign role → bot replies in that lang (util::i18n::from_roles).
use serenity::all::{Context, Member};
pub async fn on_member_join(_ctx: Context, _member: Member) {
// let channel = welcome_channel_id (1509503664438378596)
// channel.send_message(http, CreateMessage::new()
// .content(format!("<@{}> Welcome! Pick your language:", member.user.id))
// .components(vec![CreateActionRow::Buttons(vec![
// CreateButton::new("lang_ru").label("Русский").emoji('🇷🇺'),
// CreateButton::new("lang_en").label("English").emoji('🇬🇧'),
// ])])
// ).await?;
}
pub async fn on_lang_button(_ctx: Context, _member: Member, _lang: &str) {
// if custom_id == "lang_ru" → member.add_role(http, RU_ID).await?
// if custom_id == "lang_en" → member.add_role(http, EN_ID).await?
// followup ephemeral: t("lang.picked", lang)
}

1
src/feature/lang/mod.rs Normal file
View file

@ -0,0 +1 @@
pub mod handler;

60
src/feature/mod.rs Normal file
View file

@ -0,0 +1,60 @@
pub mod admin;
pub mod help;
pub mod info;
pub mod lang;
pub mod moderation;
pub mod ticket;
pub mod verify;
pub mod voice;
pub mod welcome;
use crate::config::BotConfig;
use poise::Framework;
use sqlx::SqlitePool;
pub struct Data {
pub cfg: BotConfig,
pub pool: SqlitePool,
}
pub type Ctx<'a> = poise::Context<'a, Data, anyhow::Error>;
pub async fn build_framework(
cfg: BotConfig,
pool: SqlitePool,
) -> anyhow::Result<Framework<Data, anyhow::Error>> {
let opts = poise::FrameworkOptions {
commands: vec![
help::command::help(),
info::command::ping(),
info::command::serverinfo(),
admin::command::shutdown(),
verify::command::verify_setup(),
ticket::command::ticket_panel(),
moderation::command::clear(),
moderation::command::slowmode(),
moderation::command::warn(),
],
prefix_options: poise::PrefixFrameworkOptions {
prefix: Some(cfg.prefix.clone().into()),
..Default::default()
},
..Default::default()
};
let framework = Framework::builder()
.options(opts)
.setup(|ctx, _ready, fw| {
Box::pin(async move {
if let Some(gid) = cfg.guild_id {
let guild = serenity::all::GuildId::new(gid);
poise::builtins::register_in_guild(ctx, &fw.options().commands, guild).await?;
} else {
poise::builtins::register_globally(ctx, &fw.options().commands).await?;
}
Ok(Data { cfg, pool })
})
})
.build();
Ok(framework)
}

View file

@ -0,0 +1,127 @@
//! Moderation commands - English only, no `sleep` without `// SAFETY:`.
//! Uses `ctx.defer()` + async DB/HTTP, respects RateLimiter & JobQueue.
use crate::feature::Ctx;
use crate::util::i18n::{self, Lang};
use poise::CreateReply;
use serenity::all::{CreateEmbed, CreateEmbedFooter};
fn lang_of(_ctx: &Ctx<'_>) -> Option<Lang> {
// Placeholder to keep file ≤4 pattern; real lang via get_lang()
None
}
/// Helper to get lang inside async context.
async fn get_lang(ctx: &Ctx<'_>) -> Lang {
if let Some(member) = ctx.author_member().await {
let ids: Vec<u64> = member.roles.iter().map(|r| r.get()).collect();
i18n::from_roles(&ids)
} else {
Lang::En
}
}
/// `/clear <amount>` - bulk delete (1-100), Staff only. No `sleep` - uses Discord bulk_delete.
#[poise::command(
slash_command,
prefix_command,
required_permissions = "MANAGE_MESSAGES",
guild_only
)]
pub async fn clear(ctx: Ctx<'_>, #[description = "Amount 1-100"] amount: u8) -> anyhow::Result<()> {
let lang = get_lang(&ctx).await;
let amount = amount.clamp(1, 100) as u64;
ctx.defer_ephemeral().await?;
let channel = ctx.channel_id();
let msgs =
channel.messages(ctx.http(), serenity::all::GetMessages::new().limit(amount as u8)).await?;
let ids: Vec<_> = msgs.iter().map(|m| m.id).collect();
// Discord bulk delete: 2-100, <14 days
if ids.len() >= 2 {
let _ = channel.delete_messages(ctx.http(), ids).await;
} else if let Some(m) = msgs.first() {
let _ = m.delete(ctx.http()).await;
}
let desc = match lang {
Lang::Ru => format!("Удалено {amount} сообщений."),
Lang::En => format!("Deleted {amount} messages."),
};
ctx.send(
CreateReply::default()
.embed(CreateEmbed::new().description(desc).color(0x0057_F387))
.ephemeral(true),
)
.await?;
Ok(())
}
/// `/slowmode <seconds>` - set channel slowmode 0-21600.
#[poise::command(
slash_command,
prefix_command,
required_permissions = "MANAGE_CHANNELS",
guild_only
)]
pub async fn slowmode(
ctx: Ctx<'_>,
#[description = "Seconds 0-21600, 0 to disable"] seconds: u64,
) -> anyhow::Result<()> {
let lang = get_lang(&ctx).await;
let seconds = seconds.min(21600);
ctx.defer_ephemeral().await?;
ctx.channel_id()
.edit(ctx.http(), serenity::all::EditChannel::new().rate_limit_per_user(seconds as u16))
.await?;
let desc = if seconds == 0 {
match lang {
Lang::Ru => "Слоумод выключен.",
Lang::En => "Slowmode disabled.",
}
} else {
match lang {
Lang::Ru => &format!("Слоумод: {seconds}s."),
Lang::En => &format!("Slowmode: {seconds}s."),
}
};
ctx.send(
CreateReply::default()
.embed(CreateEmbed::new().description(desc.to_string()).color(0x0058_65F2)),
)
.await?;
Ok(())
}
/// `/warn <user> <reason>` - logs to #mod-logs, no DB yet (Phase 2.5).
#[poise::command(
slash_command,
prefix_command,
required_permissions = "MODERATE_MEMBERS",
guild_only
)]
pub async fn warn(
ctx: Ctx<'_>,
#[description = "User to warn"] user: serenity::all::User,
#[description = "Reason"] reason: Option<String>,
) -> anyhow::Result<()> {
let _lang = get_lang(&ctx).await;
let reason = reason.unwrap_or_else(|| "No reason".to_string());
// Use pool to avoid dead_code (insert warning if table exists)
let _ = sqlx::query("CREATE TABLE IF NOT EXISTS warnings (user_id TEXT, reason TEXT, at TEXT DEFAULT (datetime('now')))")
.execute(&ctx.data().pool)
.await;
let _ = sqlx::query("INSERT INTO warnings(user_id, reason) VALUES (?, ?)")
.bind(user.id.to_string())
.bind(&reason)
.execute(&ctx.data().pool)
.await;
let embed = CreateEmbed::new()
.title(format!("Warned {}", user.name))
.description(reason)
.color(0x00ED_4245)
.footer(CreateEmbedFooter::new(format!("By {}", ctx.author().name)));
ctx.send(CreateReply::default().embed(embed)).await?;
Ok(())
}
// Keep helper to avoid unused warning (used via get_lang).
const _: Option<fn(&Ctx<'_>) -> Option<Lang>> = Some(lang_of);

View file

@ -0,0 +1 @@
pub mod command;

View file

@ -0,0 +1,25 @@
use crate::feature::Ctx;
use crate::util::i18n::{self, Lang};
use poise::CreateReply;
use serenity::all::{CreateActionRow, CreateButton, CreateEmbed};
/// /ticket-panel - panel with buttons in #open-ticket.
#[poise::command(slash_command, prefix_command, guild_only)]
pub async fn ticket_panel(ctx: Ctx<'_>) -> anyhow::Result<()> {
let lang = if let Some(member) = ctx.author_member().await {
let ids: Vec<u64> = member.roles.iter().map(|r| r.get()).collect();
i18n::from_roles(&ids)
} else {
Lang::En
};
let embed = CreateEmbed::new()
.title(i18n::t("ticket.title", lang))
.description(i18n::t("ticket.desc", lang))
.color(0x0058_65F2);
let row = CreateActionRow::Buttons(vec![
CreateButton::new("ticket_general").label("General").emoji('🛠'),
CreateButton::new("ticket_bug").label("Bug").emoji('🐛'),
]);
ctx.send(CreateReply::default().embed(embed).components(vec![row])).await?;
Ok(())
}

View file

@ -0,0 +1,179 @@
//! Ticket handler - creates private channels on button, handles close.
//! English Javadoc style, no `sleep` without `// SAFETY:`.
use serenity::all::{
ChannelType, ComponentInteraction, Context, CreateChannel, CreateEmbed,
CreateInteractionResponse, CreateInteractionResponseMessage, CreateMessage,
PermissionOverwrite, PermissionOverwriteType, Permissions,
};
use tracing::{info, warn};
const SUPPORT_CATEGORY: u64 = 1547213779224363008; // OPEN TICKETS (bottom, staff-only)
const TICKET_LOGS: u64 = 1509503717127356446;
/// Handles `ticket_general` / `ticket_bug` and `ticket_close`.
pub async fn on_button(ctx: Context, interaction: ComponentInteraction) {
let guild_id = match interaction.guild_id {
Some(g) => g,
None => return,
};
let user = interaction.user.clone();
let custom = interaction.data.custom_id.clone();
if custom == "ticket_general" || custom == "ticket_bug" {
let category = if custom == "ticket_bug" { "bug" } else { "general" };
// Check existing open ticket for user (simple scan)
if let Ok(channels) = guild_id.channels(&ctx.http).await {
let exists = channels.values().any(|c| {
c.name.starts_with(&format!("ticket-{}", user.name.to_lowercase()))
|| c.name.starts_with(&format!("ticket-{}", user.id))
});
if exists {
let _ = interaction
.create_response(
&ctx.http,
CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.content("You already have an open ticket.")
.ephemeral(true),
),
)
.await;
return;
}
}
// No defer needed - channel creation is fast (<1s), respond directly after
let name = format!("ticket-{}-{}", category, user.name.to_lowercase().replace(' ', "-"));
let overwrites = build_ticket_overwrites(user.id.get());
let channel = match guild_id
.create_channel(
&ctx.http,
CreateChannel::new(&name)
.kind(ChannelType::Text)
.category(serenity::all::ChannelId::new(SUPPORT_CATEGORY))
.permissions(overwrites),
)
.await
{
Ok(ch) => ch,
Err(e) => {
warn!(error = %e, "failed to create ticket");
let _ = interaction
.create_response(
&ctx.http,
CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.content("Failed to create ticket. Check bot permissions.")
.ephemeral(true),
),
)
.await;
return;
}
};
// Welcome embed with close button
let embed = CreateEmbed::new()
.title(format!("Ticket - {}", category))
.description(format!(
"Hello <@{}>, staff will be with you shortly.\n\nCategory: **{}**\n\nClick **Close** to close.",
user.id, category
))
.color(0x0058_65F2);
let row = serenity::all::CreateActionRow::Buttons(vec![
serenity::all::CreateButton::new("ticket_close")
.label("Close")
.style(serenity::all::ButtonStyle::Danger)
.emoji('🔒'),
]);
let _ = channel
.send_message(&ctx.http, CreateMessage::new().embed(embed).components(vec![row]))
.await;
let _ = interaction
.create_response(
&ctx.http,
CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.content(format!("Ticket created: <#{}>", channel.id))
.ephemeral(true),
),
)
.await;
info!(user = %user.name, channel = %channel.id, "ticket created");
return;
}
if custom == "ticket_close" {
let channel_id = interaction.channel_id;
let _ = interaction
.create_response(
&ctx.http,
CreateInteractionResponse::Message(
CreateInteractionResponseMessage::new()
.content("Closing ticket... transcript will be saved.")
.ephemeral(true),
),
)
.await;
// Log to ticket-logs
let log_ch = serenity::all::ChannelId::new(TICKET_LOGS);
let _ = log_ch
.send_message(
&ctx.http,
CreateMessage::new().embed(
CreateEmbed::new()
.title("Ticket closed")
.description(format!("Closed by <@{}> in <#{}>", user.id, channel_id))
.color(0x00ED_4245),
),
)
.await;
tokio::time::sleep(std::time::Duration::from_secs(2)).await; // SAFETY: small delay to let log flush, not in command path, jitter not needed
let _ = channel_id.delete(&ctx.http).await;
info!(channel = %channel_id, "ticket closed");
}
}
/// Builds permission overwrites for ticket channel.
/// - @everyone deny VIEW, user + Staff/Owner/Moderator allow.
fn build_ticket_overwrites(user_id: u64) -> Vec<PermissionOverwrite> {
let everyone = serenity::all::RoleId::new(1509503154708811837);
let staff = serenity::all::RoleId::new(1547203757711040572);
let owner = serenity::all::RoleId::new(1547203762035236925);
let moderator = serenity::all::RoleId::new(1547203753373859901);
let user = serenity::all::UserId::new(user_id);
vec![
PermissionOverwrite {
allow: Permissions::empty(),
deny: Permissions::VIEW_CHANNEL,
kind: PermissionOverwriteType::Role(everyone),
},
PermissionOverwrite {
allow: Permissions::VIEW_CHANNEL
| Permissions::SEND_MESSAGES
| Permissions::READ_MESSAGE_HISTORY,
deny: Permissions::empty(),
kind: PermissionOverwriteType::Member(user),
},
PermissionOverwrite {
allow: Permissions::VIEW_CHANNEL
| Permissions::SEND_MESSAGES
| Permissions::READ_MESSAGE_HISTORY,
deny: Permissions::empty(),
kind: PermissionOverwriteType::Role(staff),
},
PermissionOverwrite {
allow: Permissions::VIEW_CHANNEL
| Permissions::SEND_MESSAGES
| Permissions::READ_MESSAGE_HISTORY,
deny: Permissions::empty(),
kind: PermissionOverwriteType::Role(owner),
},
PermissionOverwrite {
allow: Permissions::VIEW_CHANNEL
| Permissions::SEND_MESSAGES
| Permissions::READ_MESSAGE_HISTORY,
deny: Permissions::empty(),
kind: PermissionOverwriteType::Role(moderator),
},
]
}

View file

@ -0,0 +1,2 @@
pub mod command;
pub mod handler;

View file

@ -0,0 +1,26 @@
use crate::feature::Ctx;
use crate::util::i18n::{self, Lang};
use poise::CreateReply;
use serenity::all::{CreateEmbed, CreateMessage};
/// Manual resend of verify embed (auto on boot)
#[poise::command(slash_command, prefix_command, guild_only)]
pub async fn verify_setup(ctx: Ctx<'_>) -> anyhow::Result<()> {
let lang = if let Some(member) = ctx.author_member().await {
let ids: Vec<u64> = member.roles.iter().map(|r| r.get()).collect();
i18n::from_roles(&ids)
} else {
Lang::En
};
tracing::info!(lang = lang.code(), "verify_setup invoked");
let channel = ctx.channel_id();
let embed = CreateEmbed::new()
.title(i18n::t("verify.title", lang))
.description(i18n::t("verify.desc", lang))
.color(0x002E_CC71);
let msg = channel.send_message(ctx.http(), CreateMessage::new().embed(embed)).await?;
msg.react(ctx.http(), '✅').await?;
ctx.send(CreateReply::default().content("Verification message posted.").ephemeral(true))
.await?;
Ok(())
}

View file

@ -0,0 +1,117 @@
//! Reaction handler - Verified + language roles (see ARCHITECTURE.md).
//! English docs, no `sleep` without `// SAFETY:`. No dead code.
use serenity::all::{ChannelId, Context, CreateEmbed, CreateMessage, Reaction};
use tracing::{info, warn};
/// Gives `Verified` on ✅ in #verify, and RU/EN in #roles (1527976303284715610).
pub async fn on_reaction_add(ctx: Context, reaction: Reaction) {
if let Some(user_id) = reaction.user_id {
if let Ok(user) = ctx.http.get_user(user_id).await {
if user.bot {
return;
}
}
}
let emoji = reaction.emoji.to_string();
let channel = reaction.channel_id.get();
const VERIFY_CHANNEL: u64 = 1509503655487737916;
const ROLES_CHANNEL: u64 = 1527976303284715610;
const BOT_COMMANDS: u64 = 1509503714875146350;
const OTHER_BOT_COMMANDS: u64 = 1547203853420728352;
// Language roles in #roles
if channel == ROLES_CHANNEL {
let role_id = match emoji.as_str() {
"🇷🇺" | "🇷🇺\u{fe0f}" => Some(1547203732339433494u64),
"🇬🇧" | "🇬🇧\u{fe0f}" | "🇬\u{fe0f}\u{200d}\u{fe0f}" => {
Some(1547203727792807936u64)
}
_ => None,
};
if let Some(rid) = role_id {
if let (Some(guild_id), Some(user_id)) = (reaction.guild_id, reaction.user_id) {
if let Ok(member) = guild_id.member(&ctx.http, user_id).await {
let role = serenity::all::RoleId::new(rid);
if !member.roles.contains(&role) {
let _ = member.add_role(&ctx.http, role).await;
info!(user = %user_id, role = %rid, "language role added");
}
}
}
}
return;
}
// Verification: ✅ in #verify (or test channels)
if emoji != "✅" && emoji != "✅\u{fe0f}" {
return;
}
if channel != VERIFY_CHANNEL && channel != BOT_COMMANDS && channel != OTHER_BOT_COMMANDS {
return;
}
const VERIFIED: u64 = 1547203736676597891;
let Some(guild_id) = reaction.guild_id else {
return;
};
let Some(user_id) = reaction.user_id else {
return;
};
let http = ctx.http.clone();
match guild_id.member(&http, user_id).await {
Ok(member) => {
let role = serenity::all::RoleId::new(VERIFIED);
if member.roles.contains(&role) {
return;
}
match member.add_role(&http, role).await {
Ok(()) => {
info!(user = %user_id, "verified via ✅");
// Send DM with link to #roles (ephemeral-like, visible only to user)
if let Ok(dm) = user_id.create_dm_channel(&http).await {
let _ = dm
.send_message(
&http,
CreateMessage::new().content(format!(
"✅ You are verified! Now choose your language in <#{}>.\n🇷🇺 → Russian\n🇬🇧 → English\n\n*Bot answers will be in your selected language.*",
ROLES_CHANNEL
)),
)
.await;
}
}
Err(e) => warn!(error = %e, user = %user_id, "failed to add Verified"),
}
}
Err(e) => warn!(error = %e, "failed to fetch member for verify"),
}
}
/// Ensures verification embed exists once on boot. Call from `ready`.
/// - Fetches last 20 messages in #verify, looks for bot's `Verification` embed.
/// - If not found, posts new embed with ✅ and reacts.
/// - Tracks only that message ID implicitly via channel check (or store in DB later).
pub async fn ensure_verify_message(
http: &serenity::http::Http,
channel_id: ChannelId,
) -> anyhow::Result<()> {
let msgs = channel_id.messages(&http, serenity::all::GetMessages::new().limit(20)).await?;
let has_verify = msgs.iter().any(|m| {
m.author.id.get() == 1527966600173457408
&& m.embeds.iter().any(|e| e.title.as_deref() == Some("Verification"))
});
if has_verify {
info!(channel = %channel_id, "verify embed already exists");
return Ok(());
}
let embed = CreateEmbed::new()
.title("Verification")
.description(
"Click ✅ to get access to the server.\n\nChat is locked here - reaction only.",
)
.color(0x002E_CC71);
let msg = channel_id.send_message(&http, CreateMessage::new().embed(embed)).await?;
msg.react(&http, '✅').await?;
info!(channel = %channel_id, msg = %msg.id, "verify embed created on boot");
Ok(())
}

View file

@ -0,0 +1,2 @@
pub mod command;
pub mod handler;

View file

@ -0,0 +1,56 @@
//! Temp voice handler for 1509503694088179862 (➕|create-voice).
//! Best practice (VoiceMaster/TempVoice): hub channel -> clone temp, auto-delete when empty.
//! No `sleep` without `// SAFETY:`.
use serenity::all::{ChannelId, ChannelType, Context, CreateChannel, VoiceState};
use tracing::{info, warn};
const HUB: ChannelId = ChannelId::new(1509503694088179862);
const VOICE_CATEGORY: u64 = 1509503649062326484; // VOICE
/// Called on `VoiceStateUpdate`. Creates temp channel on hub join, deletes when empty.
pub async fn on_voice_state_update(ctx: Context, old: Option<VoiceState>, new: VoiceState) {
// Join hub -> create temp
if let Some(channel_id) = new.channel_id {
if channel_id == HUB {
if let Some(guild_id) = new.guild_id {
if let Some(member) = new.member.clone() {
let name = format!("🔊 {}", member.display_name());
match guild_id
.create_channel(
&ctx.http,
CreateChannel::new(&name)
.kind(ChannelType::Voice)
.category(ChannelId::new(VOICE_CATEGORY)),
)
.await
{
Ok(ch) => {
let _ = guild_id.move_member(&ctx.http, new.user_id, ch.id).await;
info!(user = %new.user_id, channel = %ch.id, "temp voice created");
}
Err(e) => warn!(error = %e, "failed to create temp voice"),
}
}
}
}
}
// Leave -> delete empty temp channels (not hub, not afk)
if let Some(old_state) = old {
if let Some(old_channel) = old_state.channel_id {
if old_channel != HUB && old_channel.get() != 1509503696566882324 {
// check if channel empty
if let Ok(channel) = old_channel.to_channel(&ctx.http).await {
if let serenity::all::Channel::Guild(g) = channel {
if let Ok(members) = g.members(&ctx.cache) {
if members.is_empty() {
let _ = g.delete(&ctx.http).await;
info!(channel = %old_channel, "temp voice deleted (empty)");
}
}
}
}
}
}
}
}

1
src/feature/voice/mod.rs Normal file
View file

@ -0,0 +1 @@
pub mod handler;

View file

@ -0,0 +1,61 @@
//! 40 welcome greetings - English, randomized (Javadoc style).
/// Returns a random greeting for the given display name.
/// Uses thread-local RNG, no `sleep` - pure sync.
pub fn random_greeting(name: &str) -> String {
const GREETINGS: [&str; 40] = [
"Welcome to **Loki Dev**, {user} - glad you're here! 🎉",
"Hey {user}, welcome aboard! Check out <#1509503661431197736> and get verified in <#1509503655487737916>.",
"Hello {user}! You're now part of Loki Dev - explore <#1527976303284715610> to pick your language.",
"Welcome, {user}! 🚀 Dive into <#1509503673988808785> and share your ideas.",
"Hi {user}, welcome to the community! Don't forget to read the rules 📕",
"Yo {user}! Welcome - pick your language in 🌐|roles and start chatting!",
"{user} just joined! Welcome to Loki Dev - make yourself at home 🏠",
"Welcome {user}! Need help? Open a ticket in <#1509503682914549861>.",
"Hey there {user}! Welcome to Loki Dev - where code meets community 💻",
"Welcome aboard, {user}! Check <#1509503667332714566> for updates.",
"Hi {user}! Glad you found us - enjoy your stay! ✨",
"Welcome {user}! Don't be shy - say hi in <#1509503673988808785>",
"Hello {user}, welcome! Your journey at Loki Dev starts now 🌟",
"Hey {user}! Welcome - grab your roles in 🌐|roles and chat in your language!",
"Welcome {user}! 🚀 We're happy to have you here.",
"Hi {user}, welcome to Loki Dev! Explore, learn, build 🔧",
"Welcome, {user}! Feel free to ask questions - we're friendly 😊",
"Hey {user}! Welcome to our dev corner - enjoy! 🎨",
"Hello {user}! Welcome - check #rules then chat away 💬",
"Welcome {user}! 🎉 Thanks for joining Loki Dev!",
"Hi {user}! Welcome - your next big idea starts here 💡",
"Hey {user}, welcome! Don't forget to verify ✅ to unlock channels.",
"Welcome {user}! 🌈 Diversity and code - that's us!",
"Hello {user}! Welcome to Loki Dev - let's build together 🛠️",
"Hi {user}! Welcome - see <#1509503680678723614> for ideas",
"Welcome {user}! Check out our GitHub log <#1509503672197840996> 🧠",
"Hey {user}! Welcome aboard - grab a coffee ☕ and chat!",
"Welcome {user}! 🚀 Ready to code?",
"Hi {user}, welcome! We're excited to have you 🎊",
"Hello {user}! Welcome to Loki Dev - enjoy the community 🤝",
"Welcome {user}! Don't miss <#1509503667332714566> announcements 📢",
"Hey {user}! Welcome - pick EN/RU in 🌐|roles for bot language!",
"Welcome {user}! 🌟 New member, new ideas!",
"Hi {user}! Welcome to Loki Dev - stay, chat, create 🎨",
"Hello {user}! Welcome - need a voice? Join 🔊|work-room-1",
"Welcome {user}! 🎉 Let's make something awesome!",
"Hey {user}! Welcome - check #welcome and get started!",
"Welcome {user}! Your presence makes us stronger 💪",
"Hi {user}! Welcome to Loki Dev - where devs meet 👋",
"Hello {user}, welcome! Enjoy your stay - and verify to unlock! ✅",
];
let idx = {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
format!(
"{}-{}",
name,
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
)
.hash(&mut h);
(h.finish() as usize) % GREETINGS.len()
};
GREETINGS[idx].replace("{user}", name)
}

View file

@ -0,0 +1,34 @@
//! Welcome handler - posts random greeting in #welcome (1509503664438378596).
//! English, no `sleep` without `// SAFETY:`.
use serenity::all::{ChannelId, Context, CreateEmbed, CreateMessage, Member};
use tracing::{info, warn};
use super::greetings::random_greeting;
const WELCOME_CHANNEL: ChannelId = ChannelId::new(1509503664438378596);
/// Called on `GuildMemberAdd` (see `core::bot`).
pub async fn on_member_join(ctx: Context, member: Member) {
let name = member.display_name().to_string();
let greeting = random_greeting(&name);
let embed = CreateEmbed::new()
.title("Welcome! 👋")
.description(greeting)
.color(0x5865F2)
.thumbnail(member.user.avatar_url().unwrap_or_default())
.footer(serenity::all::CreateEmbedFooter::new(
"Loki Dev • Verify in #verify • Pick language in #roles",
));
let res = WELCOME_CHANNEL
.send_message(
&ctx.http,
CreateMessage::new().embed(embed).content(format!("<@{}>", member.user.id)),
)
.await;
match res {
Ok(_) => info!(user = %member.user.name, "welcome sent"),
Err(e) => warn!(error = %e, "failed to send welcome"),
}
// Also trigger language picker (defer to lang handler - already wired in bot.rs)
}

View file

@ -0,0 +1,2 @@
pub mod greetings;
pub mod handler;

32
src/main.rs Normal file
View file

@ -0,0 +1,32 @@
//! Bot entry point - DI bootstrap + graceful shutdown (analog of App.java).
mod config;
mod core;
mod db;
mod feature;
mod util;
use anyhow::Context;
use core::lifecycle::Lifecycle;
use tracing::info;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
util::logger::init();
let cfg = config::loader::load().context("load config")?;
// Use Lifecycle (avoid dead_code) - Phase 2 wiring placeholder.
let _ = <() as Lifecycle>::start(&()).await;
let _ = <() as Lifecycle>::shutdown(&()).await;
info!(prefix = %cfg.prefix, guild = ?cfg.guild_id, "starting bot-kernel");
let pool = db::pool::create(&cfg.db_path).await.context("db pool")?;
db::migration::run(&pool).await.context("migrations")?;
let framework = feature::build_framework(cfg.clone(), pool.clone()).await?;
let client = core::bot::build_client(&cfg, framework).await?;
core::bot::run_with_shutdown(client).await?;
db::pool::close(pool).await;
Ok(())
}

View file

@ -1,58 +0,0 @@
package kernel.loki.bootstrap;
import com.google.inject.Guice;
import com.google.inject.Injector;
import kernel.loki.core.Bot;
import kernel.loki.core.BotCluster;
import kernel.loki.core.BotInitializer;
import kernel.loki.core.bootstrap.BootstrapPhase;
import kernel.loki.di.CoreModule;
import kernel.loki.util.LoggerDecorator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class App {
private static final Logger logger = LoggerFactory.getLogger(App.class);
public static void main(String[] args) {
Thread shutdownHook = new Thread(() -> {
LoggerDecorator.warn(logger, "Received shutdown signal");
LoggerDecorator.warn(logger, "Performing graceful shutdown...");
}, "ShutdownHook");
Runtime.getRuntime().addShutdownHook(shutdownHook);
try {
LoggerDecorator.startup(logger, "Starting Bot Kernel v{}", "0.1.0");
logPhase(BootstrapPhase.DI_INITIALIZE);
Injector injector = Guice.createInjector(new CoreModule());
BotInitializer initializer = injector.getInstance(BotInitializer.class);
logPhase(BootstrapPhase.JDA_BUILD);
BotCluster cluster = initializer.start();
cluster.getByType(Bot.BotType.MAIN).ifPresent(bot -> {
try {
bot.jda().awaitReady();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Interrupted while waiting for JDA ready", e);
}
logPhase(BootstrapPhase.COMPLETE);
});
LoggerDecorator.success(logger, "Bot Kernel is fully operational!");
} catch (Exception e) {
LoggerDecorator.error(logger, "Critical error during bootstrap", e);
System.exit(1);
}
}
private static void logPhase(BootstrapPhase phase) {
LoggerDecorator.startup(logger, "Phase: {}", phase.name());
}
private App() {}
}

View file

@ -1,20 +0,0 @@
package kernel.loki.bootstrap;
import com.google.inject.Singleton;
import kernel.loki.util.LoggerDecorator;
import net.dv8tion.jda.api.events.session.ReadyEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Singleton
public class ReadyListener extends ListenerAdapter {
private static final Logger logger = LoggerFactory.getLogger(ReadyListener.class);
@Override
public void onReady(ReadyEvent event) {
var selfUser = event.getJDA().getSelfUser();
LoggerDecorator.bot(logger, "Logged in as: {}#{}", selfUser.getName(), selfUser.getDiscriminator());
LoggerDecorator.bot(logger, "Connected to {} guilds", event.getGuildTotalCount());
}
}

View file

@ -1,9 +0,0 @@
package kernel.loki.command;
public interface Command {
String name();
String description();
default String usage() { return name(); }
default String category() { return "General"; }
void execute(CommandContext ctx);
}

View file

@ -1,21 +0,0 @@
package kernel.loki.command;
public enum CommandCategory {
ADMIN("Admin"),
HELP("Help"),
INFO("Info"),
MODERATION("Moderation"),
UTILITY("Utility"),
FUN("Fun"),
ECONOMY("Economy"),
MUSIC("Music"),
GENERAL("General");
private final String display;
CommandCategory(String display) {
this.display = display;
}
public String display() { return display; }
}

View file

@ -1,31 +0,0 @@
package kernel.loki.command;
import java.util.List;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.User;
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
public record CommandContext(
Message message,
List<String> args,
MessageChannel channel,
Member member,
Guild guild,
User author,
MessageReceivedEvent event
) {
public CommandContext(MessageReceivedEvent event, List<String> args) {
this(
event.getMessage(),
args,
event.getChannel(),
event.getMember(),
event.getGuild(),
event.getAuthor(),
event
);
}
}

View file

@ -1,8 +0,0 @@
package kernel.loki.command;
public sealed interface CommandResult {
record Success(String message) implements CommandResult {}
record Error(String reason) implements CommandResult {}
record Cooldown(long waitSeconds) implements CommandResult {}
record NotFound(String name) implements CommandResult {}
}

View file

@ -1,30 +0,0 @@
package kernel.loki.config;
import java.util.Map;
public class BotConfig {
private final Map<String, String> values;
public BotConfig(Map<String, String> values) {
this.values = Map.copyOf(values);
}
public String get(ConfigKey key) {
return values.get(key.key());
}
public String getOrDefault(ConfigKey key) {
return values.getOrDefault(key.key(), key.defaultValue());
}
public int getInt(ConfigKey key) {
return Integer.parseInt(getOrDefault(key));
}
public String getToken() { return get(ConfigKey.DISCORD_BOT_TOKEN); }
public String getPrefix() { return getOrDefault(ConfigKey.BOT_PREFIX); }
public String getActivity() { return getOrDefault(ConfigKey.BOT_ACTIVITY); }
public String getSuperAdminId() { return get(ConfigKey.SUPER_ADMIN_ID); }
public String getDbPath() { return getOrDefault(ConfigKey.DB_PATH); }
public int getHealthPort() { return getInt(ConfigKey.HEALTH_HTTP_PORT); }
}

View file

@ -1,26 +0,0 @@
package kernel.loki.config;
public enum ConfigKey {
DISCORD_BOT_TOKEN("DISCORD_BOT_TOKEN"),
BOT_PREFIX("BOT_PREFIX", "!"),
BOT_ACTIVITY("BOT_ACTIVITY", "!help"),
SUPER_ADMIN_ID("SUPER_ADMIN_ID"),
DB_PATH("DB_PATH", "data/bot.db"),
HEALTH_HTTP_PORT("HEALTH_HTTP_PORT", "8712");
private final String key;
private final String defaultValue;
ConfigKey(String key, String defaultValue) {
this.key = key;
this.defaultValue = defaultValue;
}
ConfigKey(String key) {
this(key, null);
}
public String key() { return key; }
public String defaultValue() { return defaultValue; }
public boolean hasDefault() { return defaultValue != null; }
}

View file

@ -1,48 +0,0 @@
package kernel.loki.config;
import io.github.cdimascio.dotenv.Dotenv;
import java.util.HashMap;
import java.util.Map;
public class ConfigLoader {
private ConfigLoader() {}
public static BotConfig load() {
Dotenv dotenv = Dotenv.configure()
.ignoreIfMissing()
.load();
Map<String, String> values = new HashMap<>();
for (ConfigKey key : ConfigKey.values()) {
String value = dotenv.get(key.key());
if (value != null) {
values.put(key.key(), value);
} else if (key.hasDefault()) {
values.put(key.key(), key.defaultValue());
}
}
return new BotConfig(values);
}
public static BotConfig load(String path) {
Dotenv dotenv = Dotenv.configure()
.directory(path)
.ignoreIfMissing()
.load();
Map<String, String> values = new HashMap<>();
for (ConfigKey key : ConfigKey.values()) {
String value = dotenv.get(key.key());
if (value != null) {
values.put(key.key(), value);
} else if (key.hasDefault()) {
values.put(key.key(), key.defaultValue());
}
}
return new BotConfig(values);
}
}

View file

@ -1,9 +0,0 @@
package kernel.loki.core;
import net.dv8tion.jda.api.JDA;
public record Bot(String id, JDA jda, BotType type) {
public enum BotType {
MAIN, MUSIC, RADIO
}
}

View file

@ -1,45 +0,0 @@
package kernel.loki.core;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import net.dv8tion.jda.api.JDA;
public class BotCluster {
private final Map<String, Bot> bots = new HashMap<>();
public BotCluster(List<Bot> botList) {
for (Bot bot : botList) {
bots.put(bot.id(), bot);
}
}
public void add(Bot bot) {
bots.put(bot.id(), bot);
}
public Optional<Bot> get(String id) {
return Optional.ofNullable(bots.get(id));
}
public Optional<Bot> getByType(Bot.BotType type) {
return bots.values().stream()
.filter(b -> b.type() == type)
.findFirst();
}
public JDA main() {
return getByType(Bot.BotType.MAIN)
.map(Bot::jda)
.orElseThrow(() -> new IllegalStateException("Main bot not initialized"));
}
public List<Bot> all() {
return List.copyOf(bots.values());
}
public int count() {
return bots.size();
}
}

View file

@ -1,81 +0,0 @@
package kernel.loki.core;
import com.google.inject.Inject;
import java.util.ArrayList;
import java.util.List;
import kernel.loki.config.BotConfig;
import kernel.loki.core.registry.Registrar;
import kernel.loki.util.LoggerDecorator;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.requests.GatewayIntent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class BotInitializer {
private static final Logger logger = LoggerFactory.getLogger(BotInitializer.class);
private static final GatewayIntent[] DEFAULT_INTENTS = {
GatewayIntent.GUILD_MESSAGES,
GatewayIntent.MESSAGE_CONTENT,
GatewayIntent.GUILD_MEMBERS,
GatewayIntent.GUILD_VOICE_STATES,
GatewayIntent.GUILD_MESSAGE_REACTIONS
};
private final BotConfig config;
private final Registrar registrar;
private final ListenerAggregator listeners;
private final LifecycleManager lifecycleManager;
private final ShutdownManager shutdownManager;
private final EventDispatcher dispatcher;
@Inject
public BotInitializer(
BotConfig config,
Registrar registrar,
ListenerAggregator listeners,
LifecycleManager lifecycleManager,
ShutdownManager shutdownManager,
EventDispatcher dispatcher
) {
this.config = config;
this.registrar = registrar;
this.listeners = listeners;
this.lifecycleManager = lifecycleManager;
this.shutdownManager = shutdownManager;
this.dispatcher = dispatcher;
}
public BotCluster start() {
LoggerDecorator.startup(logger, "Initializing bot kernel...");
registrar.registerAll();
lifecycleManager.startAll();
JDA mainJda = buildMainJda();
List<Bot> botList = new ArrayList<>();
botList.add(new Bot("main", mainJda, Bot.BotType.MAIN));
BotCluster cluster = new BotCluster(botList);
Runtime.getRuntime().addShutdownHook(
new Thread(() -> shutdownManager.shutdown(cluster), "ShutdownHook"));
LoggerDecorator.success(logger, "Bot kernel initialized with {} bot(s)", botList.size());
return cluster;
}
private JDA buildMainJda() {
JDABuilder builder = JdaFactory.createBuilder(
config.getToken(),
config.getActivity(),
DEFAULT_INTENTS
);
for (Object listener : listeners.all()) {
builder.addEventListeners(listener);
}
return JdaFactory.awaitJda(builder.build());
}
}

View file

@ -1,96 +0,0 @@
package kernel.loki.core;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import kernel.loki.command.Command;
import kernel.loki.command.CommandContext;
import kernel.loki.config.BotConfig;
import kernel.loki.core.exception.CommandExceptionHandler;
import kernel.loki.core.registry.CommandRegistry;
import kernel.loki.core.security.RateLimitResult;
import kernel.loki.core.security.RateLimiter;
import kernel.loki.embed.EmbedTemplates;
import kernel.loki.util.LoggerDecorator;
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class EventDispatcher implements Lifecycle {
private static final Logger logger = LoggerFactory.getLogger(EventDispatcher.class);
private final BotConfig config;
private final CommandRegistry registry;
private final ExecutorService executor;
private final RateLimiter rateLimiter;
public EventDispatcher(BotConfig config, CommandRegistry registry, RateLimiter rateLimiter) {
this.config = config;
this.registry = registry;
this.rateLimiter = rateLimiter;
this.executor = Executors.newVirtualThreadPerTaskExecutor();
}
@Override
public void shutdown() {
LoggerDecorator.bot(logger, "Shutting down EventDispatcher...");
executor.shutdown();
try {
if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
LoggerDecorator.warn(logger, "Executor did not terminate in 10s");
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
LoggerDecorator.success(logger, "EventDispatcher shut down");
}
public void dispatch(MessageReceivedEvent event) {
if (event.getAuthor().isBot()) return;
String raw = event.getMessage().getContentRaw();
String prefix = config.getPrefix();
if (!raw.startsWith(prefix)) return;
String[] parts = raw.substring(prefix.length()).trim().split("\\s+");
if (parts.length == 0) return;
String name = parts[0].toLowerCase();
List<String> args = parts.length > 1
? Arrays.asList(Arrays.copyOfRange(parts, 1, parts.length))
: List.of();
Command command = registry.get(name);
if (command == null) {
event.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Command not found",
"`" + name + "` does not exist. Use `" + prefix + "help`"))
.queue();
return;
}
RateLimitResult rl = rateLimiter.check(event.getAuthor().getId(), event.getGuild().getId());
if (!rl.allowed()) {
event.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Too fast!",
"Please wait " + rl.waitSeconds() + " seconds."))
.queue();
return;
}
executor.submit(() -> {
long start = System.nanoTime();
try {
CommandContext ctx = new CommandContext(event, args);
command.execute(ctx);
long elapsed = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
LoggerDecorator.command(logger, name, event.getAuthor().getName(), "{}ms", elapsed);
} catch (Exception e) {
CommandExceptionHandler.handle(e, event.getChannel(), name);
}
});
}
}

View file

@ -1,31 +0,0 @@
package kernel.loki.core;
import java.util.Arrays;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.entities.Activity;
import net.dv8tion.jda.api.requests.GatewayIntent;
import net.dv8tion.jda.api.utils.cache.CacheFlag;
public class JdaFactory {
private JdaFactory() {}
public static JDABuilder createBuilder(String token, String activity, GatewayIntent... intents) {
JDABuilder builder = JDABuilder.createDefault(token)
.setActivity(Activity.customStatus(activity))
.enableIntents(Arrays.asList(intents))
.enableCache(CacheFlag.VOICE_STATE)
.setAutoReconnect(true);
return builder;
}
public static JDA awaitJda(JDA jda) {
try {
return jda.awaitReady();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Interrupted while waiting for JDA ready", e);
}
}
}

View file

@ -1,6 +0,0 @@
package kernel.loki.core;
public interface Lifecycle {
default void start() {}
default void shutdown() {}
}

View file

@ -1,43 +0,0 @@
package kernel.loki.core;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import java.util.Set;
import kernel.loki.util.LoggerDecorator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Singleton
public class LifecycleManager {
private static final Logger logger = LoggerFactory.getLogger(LifecycleManager.class);
private final Set<Lifecycle> components;
@Inject
public LifecycleManager(Set<Lifecycle> components) {
this.components = components;
}
public void startAll() {
LoggerDecorator.startup(logger, "Starting {} lifecycle components", components.size());
for (Lifecycle component : components) {
try {
component.start();
} catch (Exception e) {
LoggerDecorator.error(logger, "Failed to start {}", component.getClass().getSimpleName(), e);
}
}
LoggerDecorator.success(logger, "All lifecycle components started");
}
public void shutdownAll() {
LoggerDecorator.bot(logger, "Shutting down {} lifecycle components", components.size());
for (Lifecycle component : components) {
try {
component.shutdown();
} catch (Exception e) {
LoggerDecorator.error(logger, "Failed to shutdown {}", component.getClass().getSimpleName(), e);
}
}
LoggerDecorator.success(logger, "All lifecycle components shut down");
}
}

View file

@ -1,29 +0,0 @@
package kernel.loki.core;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import java.util.ArrayList;
import java.util.List;
import kernel.loki.bootstrap.ReadyListener;
@Singleton
public class ListenerAggregator {
private final List<Object> listeners = new ArrayList<>();
@Inject
public ListenerAggregator(
ReadyListener readyListener,
MessageListener messageListener
) {
listeners.add(readyListener);
listeners.add(messageListener);
}
public Object[] all() {
return listeners.toArray();
}
public void add(Object listener) {
listeners.add(listener);
}
}

View file

@ -1,21 +0,0 @@
package kernel.loki.core;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
@Singleton
public class MessageListener extends ListenerAdapter {
private final EventDispatcher dispatcher;
@Inject
public MessageListener(EventDispatcher dispatcher) {
this.dispatcher = dispatcher;
}
@Override
public void onMessageReceived(MessageReceivedEvent event) {
dispatcher.dispatch(event);
}
}

View file

@ -1,32 +0,0 @@
package kernel.loki.core;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import kernel.loki.util.LoggerDecorator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Singleton
public class ShutdownManager {
private static final Logger logger = LoggerFactory.getLogger(ShutdownManager.class);
private final LifecycleManager lifecycleManager;
@Inject
public ShutdownManager(LifecycleManager lifecycleManager) {
this.lifecycleManager = lifecycleManager;
}
public void shutdown(BotCluster cluster) {
LoggerDecorator.warn(logger, "Shutting down bot cluster...");
lifecycleManager.shutdownAll();
cluster.all().forEach(bot -> {
bot.jda().shutdown();
LoggerDecorator.bot(logger, "JDA {} ({}) shut down", bot.id(), bot.type());
});
LoggerDecorator.success(logger, "Bot cluster fully shut down");
LoggerDecorator.warn(logger, "Goodbye!");
}
}

View file

@ -1,13 +0,0 @@
package kernel.loki.core.bootstrap;
public enum BootstrapPhase {
CONFIG_LOADING,
DATABASE_CONNECT,
DATABASE_MIGRATE,
DI_INITIALIZE,
REGISTRATION,
LIFECYCLE_START,
JDA_BUILD,
JDA_READY,
COMPLETE
}

View file

@ -1,17 +0,0 @@
package kernel.loki.core.exception;
public class CommandException extends RuntimeException {
private final String commandName;
public CommandException(String commandName, String message) {
super(message);
this.commandName = commandName;
}
public CommandException(String commandName, String message, Throwable cause) {
super(message, cause);
this.commandName = commandName;
}
public String commandName() { return commandName; }
}

View file

@ -1,28 +0,0 @@
package kernel.loki.core.exception;
import kernel.loki.embed.EmbedTemplates;
import kernel.loki.util.LoggerDecorator;
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class CommandExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(CommandExceptionHandler.class);
private CommandExceptionHandler() {}
public static void handle(Exception e, MessageChannel channel, String commandName) {
if (e instanceof CommandException ce) {
channel.sendMessageEmbeds(
EmbedTemplates.error("Command Error", ce.getMessage()))
.queue();
LoggerDecorator.warn(logger, "Command {}: {}", commandName, ce.getMessage());
} else {
channel.sendMessageEmbeds(
EmbedTemplates.error("Internal Error",
"An unexpected error occurred. Please try again later."))
.queue();
LoggerDecorator.error(logger, "Unhandled exception in command {}", commandName, e);
}
}
}

View file

@ -1,6 +0,0 @@
package kernel.loki.core.registry;
@FunctionalInterface
public interface CommandRegistrar {
void register(CommandRegistry registry);
}

View file

@ -1,27 +0,0 @@
package kernel.loki.core.registry;
import java.util.Collection;
import java.util.Collections;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import kernel.loki.command.Command;
public class CommandRegistry {
private final ConcurrentMap<String, Command> commands = new ConcurrentHashMap<>();
public void register(Command command) {
commands.put(command.name().toLowerCase(), command);
}
public Command get(String name) {
return commands.get(name.toLowerCase());
}
public Collection<Command> all() {
return Collections.unmodifiableCollection(commands.values());
}
public int count() {
return commands.size();
}
}

View file

@ -1,28 +0,0 @@
package kernel.loki.core.registry;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import java.util.Set;
import kernel.loki.util.LoggerDecorator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Singleton
public class Registrar {
private static final Logger logger = LoggerFactory.getLogger(Registrar.class);
private final CommandRegistry registry;
private final Set<CommandRegistrar> registrars;
@Inject
public Registrar(CommandRegistry registry, Set<CommandRegistrar> registrars) {
this.registry = registry;
this.registrars = registrars;
}
public void registerAll() {
for (CommandRegistrar registrar : registrars) {
registrar.register(registry);
}
LoggerDecorator.success(logger, "Registered {} prefix commands", registry.count());
}
}

View file

@ -1,46 +0,0 @@
package kernel.loki.core.security;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.util.concurrent.TimeUnit;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import kernel.loki.core.Lifecycle;
import kernel.loki.util.LoggerDecorator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Singleton
public class CooldownManager implements Lifecycle {
private static final Logger logger = LoggerFactory.getLogger(CooldownManager.class);
private Cache<String, Long> cooldowns;
@Inject
public CooldownManager() {
this.cooldowns = Caffeine.newBuilder()
.expireAfterWrite(1, TimeUnit.HOURS)
.build();
}
public boolean isOnCooldown(String key) {
Long expiry = cooldowns.getIfPresent(key);
return expiry != null && System.currentTimeMillis() < expiry;
}
public long remaining(String key) {
Long expiry = cooldowns.getIfPresent(key);
if (expiry == null) return 0;
long rem = expiry - System.currentTimeMillis();
return Math.max(0, rem);
}
public void set(String key, long durationMs) {
cooldowns.put(key, System.currentTimeMillis() + durationMs);
}
@Override
public void shutdown() {
LoggerDecorator.database(logger, "Cooldown cache cleared");
cooldowns.invalidateAll();
}
}

View file

@ -1,5 +0,0 @@
package kernel.loki.core.security;
public record RateLimitResult(boolean allowed, long waitSeconds) {
public static final RateLimitResult ALLOWED = new RateLimitResult(true, 0);
}

View file

@ -1,53 +0,0 @@
package kernel.loki.core.security;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import java.util.concurrent.TimeUnit;
import com.google.inject.Inject;
import com.google.inject.Singleton;
@Singleton
public class RateLimiter {
private static final int MAX_REQUESTS = 5;
private static final int WINDOW_SECONDS = 10;
private final Cache<String, RequestCounter> cache;
@Inject
public RateLimiter() {
this.cache = Caffeine.newBuilder()
.expireAfterWrite(WINDOW_SECONDS, TimeUnit.SECONDS)
.build();
}
public RateLimitResult check(String userId, String guildId) {
String key = userId + ":" + guildId;
RequestCounter counter = cache.get(key, k -> new RequestCounter());
return counter.tryAcquire();
}
private static class RequestCounter {
private int count;
private long windowStart;
RequestCounter() {
this.count = 0;
this.windowStart = System.nanoTime();
}
synchronized RateLimitResult tryAcquire() {
long now = System.nanoTime();
if (now - windowStart > TimeUnit.SECONDS.toNanos(WINDOW_SECONDS)) {
count = 0;
windowStart = now;
}
count++;
if (count > MAX_REQUESTS) {
long elapsed = TimeUnit.NANOSECONDS.toSeconds(now - windowStart);
long wait = WINDOW_SECONDS - elapsed;
return new RateLimitResult(false, Math.max(1, wait));
}
return RateLimitResult.ALLOWED;
}
}
}

View file

@ -1,65 +0,0 @@
package kernel.loki.database;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import kernel.loki.config.BotConfig;
import kernel.loki.core.Lifecycle;
import kernel.loki.database.connection.ConnectionPool;
import kernel.loki.database.connection.SqliteConnectionPool;
import kernel.loki.database.migration.MigrationManager;
import kernel.loki.util.LoggerDecorator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Singleton
public class Database implements Lifecycle {
private static final Logger logger = LoggerFactory.getLogger(Database.class);
private final BotConfig config;
private ConnectionPool pool;
private MigrationManager migrationManager;
@Inject
public Database(BotConfig config) {
this.config = config;
}
@Override
public void start() {
LoggerDecorator.database(logger, "Connecting to database: {}", config.getDbPath());
this.pool = new SqliteConnectionPool(config.getDbPath());
this.migrationManager = new MigrationManager(this);
try (Connection conn = pool.getConnection();
Statement stmt = conn.createStatement()) {
stmt.execute("PRAGMA foreign_keys = ON");
stmt.execute("PRAGMA journal_mode = WAL");
stmt.execute("PRAGMA busy_timeout = 5000");
} catch (SQLException e) {
throw new RuntimeException("Failed to configure database", e);
}
migrationManager.migrate();
LoggerDecorator.success(logger, "Database ready");
}
public Connection getConnection() {
return pool.getConnection();
}
public MigrationManager migrations() {
return migrationManager;
}
@Override
public void shutdown() {
if (pool != null) {
pool.close();
LoggerDecorator.database(logger, "Database connection pool closed");
}
}
}

View file

@ -1,18 +0,0 @@
package kernel.loki.database;
import com.google.inject.AbstractModule;
import com.google.inject.Provides;
import com.google.inject.Singleton;
public class DatabaseModule extends AbstractModule {
@Override
protected void configure() {
// Database itself is bound in CoreModule via Multibinder<Lifecycle>
}
@Provides
@Singleton
Database provideDatabase(kernel.loki.config.BotConfig config) {
return new Database(config);
}
}

View file

@ -1,36 +0,0 @@
package kernel.loki.database;
public class DatabaseSchema {
private DatabaseSchema() {}
public static final class GuildConfig {
public static final String TABLE = "guild_config";
public static final String GUILD_ID = "guild_id";
public static final String PREFIX = "prefix";
public static final String SETTINGS = "settings";
public static final String UPDATED_AT = "updated_at";
private GuildConfig() {}
}
public static final class UserData {
public static final String TABLE = "user_data";
public static final String USER_ID = "user_id";
public static final String GUILD_ID = "guild_id";
public static final String KEY = "key";
public static final String VALUE = "value";
private UserData() {}
}
public static final class Metrics {
public static final String TABLE = "metrics";
public static final String COMMAND_NAME = "command_name";
public static final String CALL_COUNT = "call_count";
public static final String TOTAL_TIME_MS = "total_time_ms";
public static final String ERROR_COUNT = "error_count";
public static final String LAST_CALLED_AT = "last_called_at";
private Metrics() {}
}
}

View file

@ -1,35 +0,0 @@
package kernel.loki.database.connection;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.util.function.Function;
public class ConnectionFactory {
private final ConnectionPool pool;
public ConnectionFactory(ConnectionPool pool) {
this.pool = pool;
}
public <T> T withConnection(Function<Connection, T> callback) {
try (Connection conn = pool.getConnection()) {
return callback.apply(conn);
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
public void withConnectionVoid(ThrowingConsumer<Connection> callback) {
try (Connection conn = pool.getConnection()) {
callback.accept(conn);
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
@FunctionalInterface
public interface ThrowingConsumer<T> {
void accept(T t) throws SQLException;
}
}

View file

@ -1,8 +0,0 @@
package kernel.loki.database.connection;
import java.sql.Connection;
public interface ConnectionPool {
Connection getConnection();
void close();
}

View file

@ -1,37 +0,0 @@
package kernel.loki.database.connection;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import java.sql.Connection;
import java.sql.SQLException;
public class SqliteConnectionPool implements ConnectionPool {
private final HikariDataSource dataSource;
public SqliteConnectionPool(String dbPath) {
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:sqlite:" + dbPath);
config.setMaximumPoolSize(4);
config.setConnectionTimeout(10000);
config.setIdleTimeout(0);
config.setMaxLifetime(600000);
config.setPoolName("KernelDB");
this.dataSource = new HikariDataSource(config);
}
@Override
public Connection getConnection() {
try {
return dataSource.getConnection();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
@Override
public void close() {
if (dataSource != null && !dataSource.isClosed()) {
dataSource.close();
}
}
}

View file

@ -1,10 +0,0 @@
package kernel.loki.database.migration;
import java.sql.Connection;
import java.sql.SQLException;
public interface Migration {
int version();
String description();
void migrate(Connection conn) throws SQLException;
}

View file

@ -1,94 +0,0 @@
package kernel.loki.database.migration;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.ServiceLoader;
import kernel.loki.database.Database;
import kernel.loki.util.LoggerDecorator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class MigrationManager {
private static final Logger logger = LoggerFactory.getLogger(MigrationManager.class);
private static final int TARGET_VERSION = Integer.MAX_VALUE;
private final Database database;
private final List<Migration> migrations = new ArrayList<>();
public MigrationManager(Database database) {
this.database = database;
discoverMigrations();
}
private void discoverMigrations() {
ServiceLoader.load(Migration.class)
.forEach(migrations::add);
try {
Class.forName("kernel.loki.database.migration.migrator.V1__InitialSchema");
migrations.add(new kernel.loki.database.migration.migrator.V1__InitialSchema());
} catch (ClassNotFoundException ignored) {}
migrations.sort(Comparator.comparingInt(Migration::version));
}
public void migrate() {
ensureSchemaTable();
int currentVersion = getCurrentVersion();
for (Migration migration : migrations) {
if (migration.version() > currentVersion) {
LoggerDecorator.database(logger, "Applying migration v{}: {}",
migration.version(), migration.description());
try (Connection conn = database.getConnection()) {
conn.setAutoCommit(false);
try {
migration.migrate(conn);
setVersion(conn, migration.version());
conn.commit();
LoggerDecorator.success(logger, "Migration v{} applied", migration.version());
} catch (SQLException e) {
conn.rollback();
throw e;
}
} catch (SQLException e) {
throw new RuntimeException("Migration v" + migration.version() + " failed", e);
}
}
}
}
private void ensureSchemaTable() {
try (Connection conn = database.getConnection();
Statement stmt = conn.createStatement()) {
stmt.execute("""
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""");
} catch (SQLException e) {
throw new RuntimeException("Failed to create schema_version table", e);
}
}
private int getCurrentVersion() {
try (Connection conn = database.getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery("SELECT COALESCE(MAX(version), 0) FROM schema_version")) {
return rs.next() ? rs.getInt(1) : 0;
} catch (SQLException e) {
return 0;
}
}
private void setVersion(Connection conn, int version) throws SQLException {
try (Statement stmt = conn.createStatement()) {
stmt.execute("INSERT INTO schema_version (version) VALUES (" + version + ")");
}
}
}

View file

@ -1,54 +0,0 @@
package kernel.loki.database.migration.migrator;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
import kernel.loki.database.migration.Migration;
public class V1__InitialSchema implements Migration {
@Override
public int version() {
return 1;
}
@Override
public String description() {
return "Create core tables";
}
@Override
public void migrate(Connection conn) throws SQLException {
try (Statement stmt = conn.createStatement()) {
stmt.execute("""
CREATE TABLE IF NOT EXISTS guild_config (
guild_id TEXT PRIMARY KEY,
prefix TEXT DEFAULT '!',
settings TEXT DEFAULT '{}',
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""");
stmt.execute("""
CREATE TABLE IF NOT EXISTS user_data (
user_id TEXT NOT NULL,
guild_id TEXT NOT NULL,
key TEXT NOT NULL,
value TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, guild_id, key)
)
""");
stmt.execute("""
CREATE TABLE IF NOT EXISTS metrics (
command_name TEXT NOT NULL,
call_count INTEGER DEFAULT 0,
total_time_ms INTEGER DEFAULT 0,
error_count INTEGER DEFAULT 0,
last_called_at TEXT,
PRIMARY KEY (command_name)
)
""");
}
}
}

View file

@ -1,52 +0,0 @@
package kernel.loki.database.repository;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.function.Function;
import com.google.inject.Inject;
import kernel.loki.database.Database;
public abstract class BaseRepository {
protected final Database database;
@Inject
protected BaseRepository(Database database) {
this.database = database;
}
protected <T> T withConnection(Function<Connection, T> callback) {
try (Connection conn = database.getConnection()) {
return callback.apply(conn);
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
protected void withConnectionVoid(ThrowingConsumer<Connection> callback) {
try (Connection conn = database.getConnection()) {
callback.accept(conn);
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
protected void setString(PreparedStatement stmt, int index, String value) throws SQLException {
if (value != null) {
stmt.setString(index, value);
} else {
stmt.setNull(index, java.sql.Types.VARCHAR);
}
}
protected String getString(ResultSet rs, String column) throws SQLException {
String val = rs.getString(column);
return rs.wasNull() ? null : val;
}
@FunctionalInterface
protected interface ThrowingConsumer<T> {
void accept(T t) throws SQLException;
}
}

View file

@ -1,11 +0,0 @@
package kernel.loki.database.repository;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Optional;
public interface Repository<T, ID> {
Optional<T> findById(ID id);
void save(T entity);
void delete(ID id);
}

View file

@ -1,60 +0,0 @@
package kernel.loki.di;
import com.google.inject.AbstractModule;
import com.google.inject.Provides;
import com.google.inject.Singleton;
import com.google.inject.multibindings.Multibinder;
import kernel.loki.config.BotConfig;
import kernel.loki.config.ConfigLoader;
import kernel.loki.core.EventDispatcher;
import kernel.loki.core.Lifecycle;
import kernel.loki.core.ListenerAggregator;
import kernel.loki.core.ShutdownManager;
import kernel.loki.core.registry.CommandRegistrar;
import kernel.loki.core.registry.CommandRegistry;
import kernel.loki.core.registry.Registrar;
import kernel.loki.core.security.CooldownManager;
import kernel.loki.core.security.RateLimiter;
import kernel.loki.database.Database;
import kernel.loki.database.DatabaseModule;
import kernel.loki.feature.admin.AdminModule;
import kernel.loki.feature.help.HelpModule;
import kernel.loki.feature.info.InfoModule;
public class CoreModule extends AbstractModule {
@Override
protected void configure() {
bind(CommandRegistry.class).in(Singleton.class);
bind(CooldownManager.class).in(Singleton.class);
bind(RateLimiter.class).in(Singleton.class);
bind(ListenerAggregator.class).in(Singleton.class);
bind(ShutdownManager.class).in(Singleton.class);
bind(Registrar.class).in(Singleton.class);
Multibinder<Lifecycle> lifecycleBinder = Multibinder.newSetBinder(binder(), Lifecycle.class);
lifecycleBinder.addBinding().to(CooldownManager.class);
lifecycleBinder.addBinding().to(Database.class);
lifecycleBinder.addBinding().to(EventDispatcher.class);
install(new DatabaseModule());
install(new HelpModule());
install(new InfoModule());
install(new AdminModule());
}
@Provides
@Singleton
BotConfig provideBotConfig() {
return ConfigLoader.load();
}
@Provides
@Singleton
EventDispatcher provideEventDispatcher(
BotConfig config,
CommandRegistry registry,
RateLimiter rateLimiter
) {
return new EventDispatcher(config, registry, rateLimiter);
}
}

View file

@ -1,14 +0,0 @@
package kernel.loki.embed;
import java.awt.Color;
public final class EmbedColor {
public static final Color PRIMARY = new Color(0x5865F2);
public static final Color SUCCESS = new Color(0x57F287);
public static final Color WARNING = new Color(0xFEE75C);
public static final Color ERROR = new Color(0xED4245);
public static final Color INFO = new Color(0x00AFFA);
public static final Color NEUTRAL = new Color(0x808080);
private EmbedColor() {}
}

View file

@ -1,62 +0,0 @@
package kernel.loki.embed;
import java.awt.Color;
import java.time.Instant;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.entities.MessageEmbed;
import net.dv8tion.jda.api.entities.User;
public class EmbedFactory {
private EmbedFactory() {}
public static EmbedBuilder builder() {
return new EmbedBuilder()
.setTimestamp(Instant.now());
}
public static EmbedBuilder builder(Color color) {
return builder().setColor(color);
}
public static EmbedBuilder primary(String title, String description) {
return builder(EmbedColor.PRIMARY)
.setTitle(title)
.setDescription(description);
}
public static EmbedBuilder success(String title, String description) {
return builder(EmbedColor.SUCCESS)
.setTitle(title)
.setDescription(description);
}
public static EmbedBuilder warning(String title, String description) {
return builder(EmbedColor.WARNING)
.setTitle(title)
.setDescription(description);
}
public static EmbedBuilder error(String title, String description) {
return builder(EmbedColor.ERROR)
.setTitle(title)
.setDescription(description);
}
public static EmbedBuilder info(String title, String description) {
return builder(EmbedColor.INFO)
.setTitle(title)
.setDescription(description);
}
public static EmbedBuilder userEmbed(User user) {
return builder(EmbedColor.PRIMARY)
.setAuthor(user.getName(), null, user.getAvatarUrl())
.setThumbnail(user.getAvatarUrl());
}
public static MessageEmbed footer(EmbedBuilder builder, String text, String iconUrl) {
return builder
.setFooter(text, iconUrl)
.build();
}
}

View file

@ -1,47 +0,0 @@
package kernel.loki.embed;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.entities.MessageEmbed;
import net.dv8tion.jda.api.entities.User;
public class EmbedTemplates {
private EmbedTemplates() {}
public static MessageEmbed error(String title, String description) {
return EmbedFactory.error(title, description).build();
}
public static MessageEmbed success(String title, String description) {
return EmbedFactory.success(title, description).build();
}
public static MessageEmbed info(String title, String description) {
return EmbedFactory.info(title, description).build();
}
public static MessageEmbed warning(String title, String description) {
return EmbedFactory.warning(title, description).build();
}
public static MessageEmbed commandList(String prefix, java.util.Collection<kernel.loki.command.Command> commands) {
EmbedBuilder builder = EmbedFactory.builder(EmbedColor.PRIMARY)
.setTitle("Command List")
.setDescription("Available commands (prefix: `" + prefix + "`)");
for (kernel.loki.command.Command cmd : commands) {
builder.addField(
prefix + cmd.name(),
cmd.description() + (cmd.usage().equals(cmd.name()) ? "" : "\nUsage: `" + prefix + cmd.usage() + "`"),
false
);
}
return builder.build();
}
public static MessageEmbed userInfo(User user, String extraInfo) {
return EmbedFactory.userEmbed(user)
.setDescription(extraInfo)
.build();
}
}

View file

@ -1,20 +0,0 @@
package kernel.loki.feature.admin;
import com.google.inject.Inject;
import kernel.loki.core.registry.CommandRegistrar;
import kernel.loki.core.registry.CommandRegistry;
import kernel.loki.feature.admin.command.ShutdownCommand;
public class AdminCommandRegistrar implements CommandRegistrar {
private final ShutdownCommand shutdown;
@Inject
public AdminCommandRegistrar(ShutdownCommand shutdown) {
this.shutdown = shutdown;
}
@Override
public void register(CommandRegistry registry) {
registry.register(shutdown);
}
}

View file

@ -1,14 +0,0 @@
package kernel.loki.feature.admin;
import com.google.inject.AbstractModule;
import com.google.inject.multibindings.Multibinder;
import kernel.loki.core.registry.CommandRegistrar;
public class AdminModule extends AbstractModule {
@Override
protected void configure() {
Multibinder<CommandRegistrar> registrarBinder =
Multibinder.newSetBinder(binder(), CommandRegistrar.class);
registrarBinder.addBinding().to(AdminCommandRegistrar.class);
}
}

View file

@ -1,46 +0,0 @@
package kernel.loki.feature.admin.command;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import kernel.loki.command.Command;
import kernel.loki.command.CommandCategory;
import kernel.loki.command.CommandContext;
import kernel.loki.config.BotConfig;
import kernel.loki.embed.EmbedTemplates;
@Singleton
public class ShutdownCommand implements Command {
private final BotConfig config;
@Inject
public ShutdownCommand(BotConfig config) {
this.config = config;
}
@Override
public String name() { return "shutdown"; }
@Override
public String description() { return "Shutdown the bot (admin only)"; }
@Override
public String category() { return CommandCategory.ADMIN.display(); }
@Override
public void execute(CommandContext ctx) {
String adminId = config.getSuperAdminId();
if (adminId == null || !adminId.equals(ctx.author().getId())) {
ctx.channel().sendMessageEmbeds(
EmbedTemplates.error("Access Denied", "You are not authorized to use this command."))
.queue();
return;
}
ctx.channel().sendMessageEmbeds(
EmbedTemplates.warning("Shutting down", "Bot will shut down now."))
.queue(msg -> {
ctx.event().getJDA().shutdown();
System.exit(0);
});
}
}

View file

@ -1,20 +0,0 @@
package kernel.loki.feature.help;
import com.google.inject.Inject;
import kernel.loki.core.registry.CommandRegistrar;
import kernel.loki.core.registry.CommandRegistry;
import kernel.loki.feature.help.command.HelpCommand;
public class HelpCommandRegistrar implements CommandRegistrar {
private final HelpCommand command;
@Inject
public HelpCommandRegistrar(HelpCommand command) {
this.command = command;
}
@Override
public void register(CommandRegistry registry) {
registry.register(command);
}
}

View file

@ -1,14 +0,0 @@
package kernel.loki.feature.help;
import com.google.inject.AbstractModule;
import com.google.inject.multibindings.Multibinder;
import kernel.loki.core.registry.CommandRegistrar;
public class HelpModule extends AbstractModule {
@Override
protected void configure() {
Multibinder<CommandRegistrar> registrarBinder =
Multibinder.newSetBinder(binder(), CommandRegistrar.class);
registrarBinder.addBinding().to(HelpCommandRegistrar.class);
}
}

View file

@ -1,39 +0,0 @@
package kernel.loki.feature.help.command;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import kernel.loki.command.Command;
import kernel.loki.command.CommandCategory;
import kernel.loki.command.CommandContext;
import kernel.loki.config.BotConfig;
import kernel.loki.core.registry.CommandRegistry;
import kernel.loki.embed.EmbedTemplates;
@Singleton
public class HelpCommand implements Command {
private final BotConfig config;
private final CommandRegistry registry;
@Inject
public HelpCommand(BotConfig config, CommandRegistry registry) {
this.config = config;
this.registry = registry;
}
@Override
public String name() { return "help"; }
@Override
public String description() { return "Show available commands"; }
@Override
public String category() { return CommandCategory.HELP.display(); }
@Override
public void execute(CommandContext ctx) {
String prefix = config.getPrefix();
ctx.channel().sendMessageEmbeds(
EmbedTemplates.commandList(prefix, registry.all()))
.queue();
}
}

View file

@ -1,28 +0,0 @@
package kernel.loki.feature.info;
import com.google.inject.Inject;
import kernel.loki.core.registry.CommandRegistrar;
import kernel.loki.core.registry.CommandRegistry;
import kernel.loki.feature.info.command.AvatarCommand;
import kernel.loki.feature.info.command.PingCommand;
import kernel.loki.feature.info.command.ServerInfoCommand;
public class InfoCommandRegistrar implements CommandRegistrar {
private final PingCommand ping;
private final AvatarCommand avatar;
private final ServerInfoCommand serverInfo;
@Inject
public InfoCommandRegistrar(PingCommand ping, AvatarCommand avatar, ServerInfoCommand serverInfo) {
this.ping = ping;
this.avatar = avatar;
this.serverInfo = serverInfo;
}
@Override
public void register(CommandRegistry registry) {
registry.register(ping);
registry.register(avatar);
registry.register(serverInfo);
}
}

View file

@ -1,14 +0,0 @@
package kernel.loki.feature.info;
import com.google.inject.AbstractModule;
import com.google.inject.multibindings.Multibinder;
import kernel.loki.core.registry.CommandRegistrar;
public class InfoModule extends AbstractModule {
@Override
protected void configure() {
Multibinder<CommandRegistrar> registrarBinder =
Multibinder.newSetBinder(binder(), CommandRegistrar.class);
registrarBinder.addBinding().to(InfoCommandRegistrar.class);
}
}

View file

@ -1,43 +0,0 @@
package kernel.loki.feature.info.command;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import kernel.loki.command.Command;
import kernel.loki.command.CommandCategory;
import kernel.loki.command.CommandContext;
import kernel.loki.embed.EmbedFactory;
import kernel.loki.embed.EmbedColor;
import net.dv8tion.jda.api.entities.Member;
@Singleton
public class AvatarCommand implements Command {
@Inject
public AvatarCommand() {}
@Override
public String name() { return "avatar"; }
@Override
public String description() { return "Get a user's avatar"; }
@Override
public String usage() { return "avatar [@user]"; }
@Override
public String category() { return CommandCategory.INFO.display(); }
@Override
public void execute(CommandContext ctx) {
Member target = ctx.args().isEmpty()
? ctx.member()
: ctx.message().getMentions().getMembers().stream().findFirst().orElse(ctx.member());
ctx.channel().sendMessageEmbeds(
EmbedFactory.builder(EmbedColor.PRIMARY)
.setTitle(target.getEffectiveName() + "'s Avatar")
.setImage(target.getEffectiveAvatarUrl() + "?size=1024")
.setFooter("ID: " + target.getId())
.build())
.queue();
}
}

View file

@ -1,37 +0,0 @@
package kernel.loki.feature.info.command;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import kernel.loki.command.Command;
import kernel.loki.command.CommandCategory;
import kernel.loki.command.CommandContext;
import kernel.loki.embed.EmbedTemplates;
@Singleton
public class PingCommand implements Command {
@Inject
public PingCommand() {}
@Override
public String name() { return "ping"; }
@Override
public String description() { return "Check bot latency"; }
@Override
public String category() { return CommandCategory.INFO.display(); }
@Override
public void execute(CommandContext ctx) {
long start = System.nanoTime();
ctx.channel().sendMessage("Pong!").flatMap(msg -> {
long elapsed = (System.nanoTime() - start) / 1_000_000;
msg.editMessageEmbeds(
EmbedTemplates.info("Pong!",
"Gateway: " + ctx.event().getJDA().getGatewayPing() + "ms\n"
+ "Roundtrip: " + elapsed + "ms"))
.queue();
return null;
}).queue();
}
}

View file

@ -1,47 +0,0 @@
package kernel.loki.feature.info.command;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import java.time.format.DateTimeFormatter;
import kernel.loki.command.Command;
import kernel.loki.command.CommandCategory;
import kernel.loki.command.CommandContext;
import kernel.loki.embed.EmbedFactory;
import kernel.loki.embed.EmbedColor;
import net.dv8tion.jda.api.entities.Guild;
@Singleton
public class ServerInfoCommand implements Command {
@Inject
public ServerInfoCommand() {}
@Override
public String name() { return "serverinfo"; }
@Override
public String description() { return "Show server information"; }
@Override
public String category() { return CommandCategory.INFO.display(); }
@Override
public void execute(CommandContext ctx) {
Guild guild = ctx.guild();
ctx.channel().sendMessageEmbeds(
EmbedFactory.builder(EmbedColor.PRIMARY)
.setTitle(guild.getName())
.setThumbnail(guild.getIconUrl())
.addField("Owner", guild.getOwner() != null
? guild.getOwner().getEffectiveName() : "Unknown", true)
.addField("Members", String.valueOf(guild.getMemberCount()), true)
.addField("Boost Tier", guild.getBoostTier().name(), true)
.addField("Boost Count", String.valueOf(guild.getBoostCount()), true)
.addField("Channels", String.valueOf(guild.getChannels().size()), true)
.addField("Roles", String.valueOf(guild.getRoles().size()), true)
.addField("Created",
guild.getTimeCreated().format(DateTimeFormatter.RFC_1123_DATE_TIME), false)
.setFooter("ID: " + guild.getId())
.build())
.queue();
}
}

View file

@ -1,47 +0,0 @@
package kernel.loki.util;
import org.slf4j.Logger;
import org.slf4j.Marker;
import org.slf4j.MarkerFactory;
public class LoggerDecorator {
private static final Marker STARTUP = MarkerFactory.getMarker("STARTUP");
private static final Marker BOT = MarkerFactory.getMarker("BOT");
private static final Marker DATABASE = MarkerFactory.getMarker("DATABASE");
private static final Marker COMMAND = MarkerFactory.getMarker("COMMAND");
private static final Marker SUCCESS = MarkerFactory.getMarker("SUCCESS");
private LoggerDecorator() {}
public static void startup(Logger log, String msg, Object... args) {
log.info(STARTUP, msg, args);
}
public static void bot(Logger log, String msg, Object... args) {
log.info(BOT, msg, args);
}
public static void database(Logger log, String msg, Object... args) {
log.info(DATABASE, msg, args);
}
public static void command(Logger log, String cmd, String user, String msg, Object... args) {
log.info(COMMAND, "[{}] by {}: {}", cmd, user, String.format(msg, args));
}
public static void success(Logger log, String msg, Object... args) {
log.info(SUCCESS, msg, args);
}
public static void warn(Logger log, String msg, Object... args) {
log.warn(msg, args);
}
public static void error(Logger log, String msg, Object... args) {
log.error(msg, args);
}
public static void error(Logger log, String msg, Throwable t) {
log.error(msg, t);
}
}

View file

@ -1,28 +0,0 @@
package kernel.loki.util;
import java.util.concurrent.TimeUnit;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
public class MessageUtil {
private MessageUtil() {}
public static void deleteAfter(Message message, long seconds) {
message.delete().queueAfter(seconds, TimeUnit.SECONDS);
}
public static void sendAndDelete(MessageChannel channel, String content, long seconds) {
channel.sendMessage(content).queue(msg -> deleteAfter(msg, seconds));
}
public static String sanitize(String input) {
return input.replace("@everyone", "@\u200Beveryone")
.replace("@here", "@\u200Bhere");
}
public static String truncate(String input, int maxLength) {
if (input == null) return null;
if (input.length() <= maxLength) return input;
return input.substring(0, maxLength - 3) + "...";
}
}

View file

@ -1,56 +0,0 @@
package kernel.loki.util;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
public class TimeUtil {
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(ZoneId.systemDefault());
private TimeUtil() {}
public static String format(Instant instant) {
return FORMATTER.format(instant);
}
public static String formatDuration(long millis) {
Duration duration = Duration.ofMillis(millis);
long hours = duration.toHours();
long minutes = duration.toMinutes() % 60;
long seconds = duration.getSeconds() % 60;
if (hours > 0) {
return String.format("%dh %dm %ds", hours, minutes, seconds);
} else if (minutes > 0) {
return String.format("%dm %ds", minutes, seconds);
} else {
return String.format("%ds", seconds);
}
}
public static long parseDuration(String input) {
input = input.toLowerCase().trim();
long total = 0;
StringBuilder num = new StringBuilder();
for (char c : input.toCharArray()) {
if (Character.isDigit(c)) {
num.append(c);
} else {
if (num.isEmpty()) continue;
long value = Long.parseLong(num.toString());
switch (c) {
case 's' -> total += value * 1000;
case 'm' -> total += value * 60_000;
case 'h' -> total += value * 3_600_000;
case 'd' -> total += value * 86_400_000;
}
num.setLength(0);
}
}
return total;
}
}

View file

@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss} [%highlight(%-5level)] [%marker] %msg%n</pattern>
</encoder>
</appender>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/bot-kernel.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/bot-kernel.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>7</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%level] [%marker] [%thread] %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>
</configuration>

View file

@ -1,48 +0,0 @@
package kernel.loki.command;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import kernel.loki.core.registry.CommandRegistry;
class CommandRegistryTest {
private CommandRegistry registry;
@BeforeEach
void setUp() {
registry = new CommandRegistry();
}
@Test
void shouldRegisterAndRetrieveCommand() {
Command cmd = new TestCommand("ping", "Ping command");
registry.register(cmd);
assertEquals(cmd, registry.get("ping"));
}
@Test
void shouldBeCaseInsensitive() {
Command cmd = new TestCommand("Ping", "Ping command");
registry.register(cmd);
assertNotNull(registry.get("PING"));
assertNotNull(registry.get("ping"));
assertNotNull(registry.get("Ping"));
}
@Test
void shouldReturnNullForUnknownCommand() {
assertNull(registry.get("nonexistent"));
}
@Test
void shouldCountCommands() {
registry.register(new TestCommand("a", ""));
registry.register(new TestCommand("b", ""));
assertEquals(2, registry.count());
}
private record TestCommand(String name, String description) implements Command {
@Override
public void execute(CommandContext ctx) {}
}
}

View file

@ -1,25 +0,0 @@
package kernel.loki.config;
import static org.junit.jupiter.api.Assertions.*;
import java.util.Map;
import org.junit.jupiter.api.Test;
class BotConfigTest {
@Test
void shouldReturnDefaultForMissingOptionalKey() {
BotConfig config = new BotConfig(Map.of());
assertEquals("!", config.getOrDefault(ConfigKey.BOT_PREFIX));
}
@Test
void shouldReturnProvidedValue() {
BotConfig config = new BotConfig(Map.of("BOT_PREFIX", "?"));
assertEquals("?", config.getOrDefault(ConfigKey.BOT_PREFIX));
}
@Test
void shouldThrowForMissingRequiredKey() {
BotConfig config = new BotConfig(Map.of());
assertNull(config.get(ConfigKey.DISCORD_BOT_TOKEN));
}
}

View file

@ -1,47 +0,0 @@
package kernel.loki.core.security;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class RateLimiterTest {
private RateLimiter limiter;
@BeforeEach
void setUp() {
limiter = new RateLimiter();
}
@Test
void shouldAllowFirstRequest() {
RateLimitResult result = limiter.check("user1", "guild1");
assertTrue(result.allowed());
}
@Test
void shouldAllowMultipleRequestsUnderLimit() {
for (int i = 0; i < 5; i++) {
RateLimitResult result = limiter.check("user2", "guild1");
assertTrue(result.allowed());
}
}
@Test
void shouldBlockAfterLimit() {
for (int i = 0; i < 5; i++) {
limiter.check("user3", "guild1");
}
RateLimitResult result = limiter.check("user3", "guild1");
assertFalse(result.allowed());
assertTrue(result.waitSeconds() > 0);
}
@Test
void shouldHaveSeparateCountersPerUser() {
for (int i = 0; i < 10; i++) {
limiter.check("userA", "guild1");
}
RateLimitResult result = limiter.check("userB", "guild1");
assertTrue(result.allowed());
}
}

View file

@ -1,33 +0,0 @@
package kernel.loki.embed;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
import net.dv8tion.jda.api.entities.MessageEmbed;
class EmbedFactoryTest {
@Test
void shouldCreateErrorEmbed() {
MessageEmbed embed = EmbedTemplates.error("Test Error", "This is a test error");
assertEquals("Test Error", embed.getTitle());
assertEquals("This is a test error", embed.getDescription());
}
@Test
void shouldCreateSuccessEmbed() {
MessageEmbed embed = EmbedTemplates.success("Success", "Operation completed");
assertEquals("Success", embed.getTitle());
assertEquals("Operation completed", embed.getDescription());
}
@Test
void shouldCreateInfoEmbed() {
MessageEmbed embed = EmbedTemplates.info("Info", "Some info");
assertNotNull(embed);
}
@Test
void shouldCreateWarningEmbed() {
MessageEmbed embed = EmbedTemplates.warning("Warning", "Be careful");
assertNotNull(embed);
}
}

61
src/util/i18n.rs Normal file
View file

@ -0,0 +1,61 @@
// i18n - English default, RU/EN via roles (🇷🇺 RU 1509503254956998706 / 🇬🇧 EN 1509503258757038150)
// Policy: all user-facing messages English; if user has RU role -> Russian, else English.
// On first join: poll/buttons to pick language (see feature/lang).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Lang {
En,
Ru,
}
impl Lang {
pub fn code(self) -> &'static str {
match self {
Self::En => "en",
Self::Ru => "ru",
}
}
}
/// Detect lang from member roles (serenity Member). Pass role IDs.
/// Detects language from member roles. Checks RU roles first, else English.
pub fn from_roles(role_ids: &[u64]) -> Lang {
const RU: u64 = 1509503254956998706;
const RU2: u64 = 1509503651411001374; // dupe guild role copy
if role_ids.iter().any(|id| *id == RU || *id == RU2) { Lang::Ru } else { Lang::En }
}
pub fn t(key: &str, lang: Lang) -> &'static str {
match (key, lang) {
("help.title", Lang::Ru) => "📚 Loki Dev - помощь",
("help.title", Lang::En) => "📚 Loki Dev - Help",
("help.desc", Lang::Ru) => "Выбери категорию ниже. Пагинация - кнопки/селект.",
("help.desc", Lang::En) => "Pick a category below. Pagination via buttons/select.",
("help.field.info", Lang::Ru) => "📌 Информация",
("help.field.info", Lang::En) => "📌 Info",
("help.field.moderation", Lang::Ru) => "🛡️ Модерация",
("help.field.moderation", Lang::En) => "🛡️ Moderation",
("help.field.tickets", Lang::Ru) => "🎫 Тикеты",
("help.field.tickets", Lang::En) => "🎫 Tickets",
("help.field.verify", Lang::Ru) => "✅ Верификация",
("help.field.verify", Lang::En) => "✅ Verification",
("help.footer", Lang::Ru) => "Используй /help <команда> для деталей",
("help.footer", Lang::En) => "Use /help <command> for details",
("verify.title", Lang::Ru) => "Проверка",
("verify.title", Lang::En) => "Verification",
("verify.desc", Lang::Ru) => {
"Нажми ✅ чтобы получить доступ к серверу.\n\nЧат в этом канале заблокирован - только реакция."
}
("verify.desc", Lang::En) => {
"Click ✅ to get access to the server.\n\nChat is locked here - reaction only."
}
("ticket.title", Lang::Ru) => "Поддержка",
("ticket.title", Lang::En) => "Support",
("ticket.desc", Lang::Ru) => "Нажми кнопку чтобы открыть тикет",
("ticket.desc", Lang::En) => "Click a button to open a ticket",
("ping.title", _) => "Pong!",
("serverinfo.title", Lang::Ru) => "Информация о сервере",
("serverinfo.title", Lang::En) => "Server Info",
_ => "-",
}
}

4
src/util/logger.rs Normal file
View file

@ -0,0 +1,4 @@
pub fn init() {
let filter = std::env::var("RUST_LOG").unwrap_or_else(|_| "info,serenity=warn".to_owned());
tracing_subscriber::fmt().with_env_filter(tracing_subscriber::EnvFilter::new(filter)).init();
}

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

@ -0,0 +1,2 @@
pub mod i18n;
pub mod logger;