From a2b2c416544540f54d6f558cd8be89268f271d25 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Wed, 9 Sep 2026 14:15:30 +0200 Subject: [PATCH] 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 --- .cargo/config.toml | 2 + .env.example | 16 +- .gitignore | 21 +- .project | 28 -- .settings/org.eclipse.buildship.core.prefs | 13 - ARCHITECTURE.md | 130 +++++++++ Cargo.toml | 39 +++ Dockerfile | 14 - ROADMAP.md | 70 +++++ SETUP.md | 39 +++ STRUCTURE.md | 207 --------------- build.gradle | 78 ------ clippy.toml | 5 + config/checkstyle/checkstyle.xml | 28 -- config/spotbugs/exclude.xml | 6 - docker-compose.yml | 13 - gradle.properties | 2 - gradle/libs.versions.toml | 35 --- gradle/wrapper/gradle-wrapper.jar | Bin 43705 -> 0 bytes gradle/wrapper/gradle-wrapper.properties | 7 - gradlew | 251 ------------------ gradlew.bat | 94 ------- rustfmt.toml | 3 + settings.gradle | 7 - src/config/keys.rs | 6 + src/config/loader.rs | 28 ++ src/config/mod.rs | 12 + src/core/bot.rs | 124 +++++++++ src/core/dispatcher.rs | 33 +++ src/core/lifecycle.rs | 22 ++ src/core/mod.rs | 5 + src/core/policy.rs | 12 + src/core/queue.rs | 36 +++ src/db/migration.rs | 49 ++++ src/db/mod.rs | 2 + src/db/pool.rs | 24 ++ src/feature/admin/command.rs | 22 ++ src/feature/admin/mod.rs | 1 + src/feature/help/command.rs | 57 ++++ src/feature/help/mod.rs | 1 + src/feature/info/command.rs | 35 +++ src/feature/info/mod.rs | 1 + src/feature/lang/handler.rs | 21 ++ src/feature/lang/mod.rs | 1 + src/feature/mod.rs | 60 +++++ src/feature/moderation/command.rs | 127 +++++++++ src/feature/moderation/mod.rs | 1 + src/feature/ticket/command.rs | 25 ++ src/feature/ticket/handler.rs | 179 +++++++++++++ src/feature/ticket/mod.rs | 2 + src/feature/verify/command.rs | 26 ++ src/feature/verify/handler.rs | 117 ++++++++ src/feature/verify/mod.rs | 2 + src/feature/voice/handler.rs | 56 ++++ src/feature/voice/mod.rs | 1 + src/feature/welcome/greetings.rs | 61 +++++ src/feature/welcome/handler.rs | 34 +++ src/feature/welcome/mod.rs | 2 + src/main.rs | 32 +++ src/main/java/kernel/loki/bootstrap/App.java | 58 ---- .../kernel/loki/bootstrap/ReadyListener.java | 20 -- .../java/kernel/loki/command/Command.java | 9 - .../kernel/loki/command/CommandCategory.java | 21 -- .../kernel/loki/command/CommandContext.java | 31 --- .../kernel/loki/command/CommandResult.java | 8 - .../java/kernel/loki/config/BotConfig.java | 30 --- .../java/kernel/loki/config/ConfigKey.java | 26 -- .../java/kernel/loki/config/ConfigLoader.java | 48 ---- src/main/java/kernel/loki/core/Bot.java | 9 - .../java/kernel/loki/core/BotCluster.java | 45 ---- .../java/kernel/loki/core/BotInitializer.java | 81 ------ .../kernel/loki/core/EventDispatcher.java | 96 ------- .../java/kernel/loki/core/JdaFactory.java | 31 --- src/main/java/kernel/loki/core/Lifecycle.java | 6 - .../kernel/loki/core/LifecycleManager.java | 43 --- .../kernel/loki/core/ListenerAggregator.java | 29 -- .../kernel/loki/core/MessageListener.java | 21 -- .../kernel/loki/core/ShutdownManager.java | 32 --- .../loki/core/bootstrap/BootstrapPhase.java | 13 - .../loki/core/exception/CommandException.java | 17 -- .../exception/CommandExceptionHandler.java | 28 -- .../loki/core/registry/CommandRegistrar.java | 6 - .../loki/core/registry/CommandRegistry.java | 27 -- .../kernel/loki/core/registry/Registrar.java | 28 -- .../loki/core/security/CooldownManager.java | 46 ---- .../loki/core/security/RateLimitResult.java | 5 - .../loki/core/security/RateLimiter.java | 53 ---- .../java/kernel/loki/database/Database.java | 65 ----- .../kernel/loki/database/DatabaseModule.java | 18 -- .../kernel/loki/database/DatabaseSchema.java | 36 --- .../connection/ConnectionFactory.java | 35 --- .../database/connection/ConnectionPool.java | 8 - .../connection/SqliteConnectionPool.java | 37 --- .../loki/database/migration/Migration.java | 10 - .../database/migration/MigrationManager.java | 94 ------- .../migration/migrator/V1__InitialSchema.java | 54 ---- .../database/repository/BaseRepository.java | 52 ---- .../loki/database/repository/Repository.java | 11 - src/main/java/kernel/loki/di/CoreModule.java | 60 ----- .../java/kernel/loki/embed/EmbedColor.java | 14 - .../java/kernel/loki/embed/EmbedFactory.java | 62 ----- .../kernel/loki/embed/EmbedTemplates.java | 47 ---- .../feature/admin/AdminCommandRegistrar.java | 20 -- .../loki/feature/admin/AdminModule.java | 14 - .../admin/command/ShutdownCommand.java | 46 ---- .../feature/help/HelpCommandRegistrar.java | 20 -- .../kernel/loki/feature/help/HelpModule.java | 14 - .../feature/help/command/HelpCommand.java | 39 --- .../feature/info/InfoCommandRegistrar.java | 28 -- .../kernel/loki/feature/info/InfoModule.java | 14 - .../feature/info/command/AvatarCommand.java | 43 --- .../feature/info/command/PingCommand.java | 37 --- .../info/command/ServerInfoCommand.java | 47 ---- .../kernel/loki/util/LoggerDecorator.java | 47 ---- .../java/kernel/loki/util/MessageUtil.java | 28 -- src/main/java/kernel/loki/util/TimeUtil.java | 56 ---- src/main/resources/logback.xml | 24 -- .../loki/command/CommandRegistryTest.java | 48 ---- .../kernel/loki/config/BotConfigTest.java | 25 -- .../loki/core/security/RateLimiterTest.java | 47 ---- .../kernel/loki/embed/EmbedFactoryTest.java | 33 --- src/util/i18n.rs | 61 +++++ src/util/logger.rs | 4 + src/util/mod.rs | 2 + 124 files changed, 1596 insertions(+), 2896 deletions(-) create mode 100644 .cargo/config.toml delete mode 100644 .project delete mode 100644 .settings/org.eclipse.buildship.core.prefs create mode 100644 ARCHITECTURE.md create mode 100644 Cargo.toml delete mode 100644 Dockerfile create mode 100644 ROADMAP.md create mode 100644 SETUP.md delete mode 100644 STRUCTURE.md delete mode 100644 build.gradle create mode 100644 clippy.toml delete mode 100644 config/checkstyle/checkstyle.xml delete mode 100644 config/spotbugs/exclude.xml delete mode 100644 docker-compose.yml delete mode 100644 gradle.properties delete mode 100644 gradle/libs.versions.toml delete mode 100644 gradle/wrapper/gradle-wrapper.jar delete mode 100644 gradle/wrapper/gradle-wrapper.properties delete mode 100755 gradlew delete mode 100644 gradlew.bat create mode 100644 rustfmt.toml delete mode 100644 settings.gradle create mode 100644 src/config/keys.rs create mode 100644 src/config/loader.rs create mode 100644 src/config/mod.rs create mode 100644 src/core/bot.rs create mode 100644 src/core/dispatcher.rs create mode 100644 src/core/lifecycle.rs create mode 100644 src/core/mod.rs create mode 100644 src/core/policy.rs create mode 100644 src/core/queue.rs create mode 100644 src/db/migration.rs create mode 100644 src/db/mod.rs create mode 100644 src/db/pool.rs create mode 100644 src/feature/admin/command.rs create mode 100644 src/feature/admin/mod.rs create mode 100644 src/feature/help/command.rs create mode 100644 src/feature/help/mod.rs create mode 100644 src/feature/info/command.rs create mode 100644 src/feature/info/mod.rs create mode 100644 src/feature/lang/handler.rs create mode 100644 src/feature/lang/mod.rs create mode 100644 src/feature/mod.rs create mode 100644 src/feature/moderation/command.rs create mode 100644 src/feature/moderation/mod.rs create mode 100644 src/feature/ticket/command.rs create mode 100644 src/feature/ticket/handler.rs create mode 100644 src/feature/ticket/mod.rs create mode 100644 src/feature/verify/command.rs create mode 100644 src/feature/verify/handler.rs create mode 100644 src/feature/verify/mod.rs create mode 100644 src/feature/voice/handler.rs create mode 100644 src/feature/voice/mod.rs create mode 100644 src/feature/welcome/greetings.rs create mode 100644 src/feature/welcome/handler.rs create mode 100644 src/feature/welcome/mod.rs create mode 100644 src/main.rs delete mode 100644 src/main/java/kernel/loki/bootstrap/App.java delete mode 100644 src/main/java/kernel/loki/bootstrap/ReadyListener.java delete mode 100644 src/main/java/kernel/loki/command/Command.java delete mode 100644 src/main/java/kernel/loki/command/CommandCategory.java delete mode 100644 src/main/java/kernel/loki/command/CommandContext.java delete mode 100644 src/main/java/kernel/loki/command/CommandResult.java delete mode 100644 src/main/java/kernel/loki/config/BotConfig.java delete mode 100644 src/main/java/kernel/loki/config/ConfigKey.java delete mode 100644 src/main/java/kernel/loki/config/ConfigLoader.java delete mode 100644 src/main/java/kernel/loki/core/Bot.java delete mode 100644 src/main/java/kernel/loki/core/BotCluster.java delete mode 100644 src/main/java/kernel/loki/core/BotInitializer.java delete mode 100644 src/main/java/kernel/loki/core/EventDispatcher.java delete mode 100644 src/main/java/kernel/loki/core/JdaFactory.java delete mode 100644 src/main/java/kernel/loki/core/Lifecycle.java delete mode 100644 src/main/java/kernel/loki/core/LifecycleManager.java delete mode 100644 src/main/java/kernel/loki/core/ListenerAggregator.java delete mode 100644 src/main/java/kernel/loki/core/MessageListener.java delete mode 100644 src/main/java/kernel/loki/core/ShutdownManager.java delete mode 100644 src/main/java/kernel/loki/core/bootstrap/BootstrapPhase.java delete mode 100644 src/main/java/kernel/loki/core/exception/CommandException.java delete mode 100644 src/main/java/kernel/loki/core/exception/CommandExceptionHandler.java delete mode 100644 src/main/java/kernel/loki/core/registry/CommandRegistrar.java delete mode 100644 src/main/java/kernel/loki/core/registry/CommandRegistry.java delete mode 100644 src/main/java/kernel/loki/core/registry/Registrar.java delete mode 100644 src/main/java/kernel/loki/core/security/CooldownManager.java delete mode 100644 src/main/java/kernel/loki/core/security/RateLimitResult.java delete mode 100644 src/main/java/kernel/loki/core/security/RateLimiter.java delete mode 100644 src/main/java/kernel/loki/database/Database.java delete mode 100644 src/main/java/kernel/loki/database/DatabaseModule.java delete mode 100644 src/main/java/kernel/loki/database/DatabaseSchema.java delete mode 100644 src/main/java/kernel/loki/database/connection/ConnectionFactory.java delete mode 100644 src/main/java/kernel/loki/database/connection/ConnectionPool.java delete mode 100644 src/main/java/kernel/loki/database/connection/SqliteConnectionPool.java delete mode 100644 src/main/java/kernel/loki/database/migration/Migration.java delete mode 100644 src/main/java/kernel/loki/database/migration/MigrationManager.java delete mode 100644 src/main/java/kernel/loki/database/migration/migrator/V1__InitialSchema.java delete mode 100644 src/main/java/kernel/loki/database/repository/BaseRepository.java delete mode 100644 src/main/java/kernel/loki/database/repository/Repository.java delete mode 100644 src/main/java/kernel/loki/di/CoreModule.java delete mode 100644 src/main/java/kernel/loki/embed/EmbedColor.java delete mode 100644 src/main/java/kernel/loki/embed/EmbedFactory.java delete mode 100644 src/main/java/kernel/loki/embed/EmbedTemplates.java delete mode 100644 src/main/java/kernel/loki/feature/admin/AdminCommandRegistrar.java delete mode 100644 src/main/java/kernel/loki/feature/admin/AdminModule.java delete mode 100644 src/main/java/kernel/loki/feature/admin/command/ShutdownCommand.java delete mode 100644 src/main/java/kernel/loki/feature/help/HelpCommandRegistrar.java delete mode 100644 src/main/java/kernel/loki/feature/help/HelpModule.java delete mode 100644 src/main/java/kernel/loki/feature/help/command/HelpCommand.java delete mode 100644 src/main/java/kernel/loki/feature/info/InfoCommandRegistrar.java delete mode 100644 src/main/java/kernel/loki/feature/info/InfoModule.java delete mode 100644 src/main/java/kernel/loki/feature/info/command/AvatarCommand.java delete mode 100644 src/main/java/kernel/loki/feature/info/command/PingCommand.java delete mode 100644 src/main/java/kernel/loki/feature/info/command/ServerInfoCommand.java delete mode 100644 src/main/java/kernel/loki/util/LoggerDecorator.java delete mode 100644 src/main/java/kernel/loki/util/MessageUtil.java delete mode 100644 src/main/java/kernel/loki/util/TimeUtil.java delete mode 100644 src/main/resources/logback.xml delete mode 100644 src/test/java/kernel/loki/command/CommandRegistryTest.java delete mode 100644 src/test/java/kernel/loki/config/BotConfigTest.java delete mode 100644 src/test/java/kernel/loki/core/security/RateLimiterTest.java delete mode 100644 src/test/java/kernel/loki/embed/EmbedFactoryTest.java create mode 100644 src/util/i18n.rs create mode 100644 src/util/logger.rs create mode 100644 src/util/mod.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..38bd089 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +rustflags = [] diff --git a/.env.example b/.env.example index da3f80f..85c903e 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,11 @@ -# === Discord === -DISCORD_BOT_TOKEN=your_token_here +# Bot (Rust) - use BOT token (generate in Discord Developer Portal) +DISCORD_BOT_TOKEN=your_bot_token_here BOT_PREFIX=! -BOT_ACTIVITY=Use !help +BOT_ACTIVITY=!help +GUILD_ID=your_guild_id SUPER_ADMIN_ID=your_discord_user_id - -# === HTTP Health === -HEALTH_HTTP_PORT=8712 - -# === Database === DB_PATH=data/bot.db +RUST_LOG=info,serenity=warn,sqlx=warn + +# JS inspector (tools/userbot/.env) - USER token only for local view (self-bot, ToS ban risk) +# DISCORD_USER_TOKEN=your_user_token_here diff --git a/.gitignore b/.gitignore index bba2c4d..3ac4f04 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,24 @@ +/target/ +**/*.rs.bk +Cargo.lock +.env +*.db +*.log +data/ +staroe/ .gradle/ build/ bin/ -data/ logs/ -.env -*.log -*.db .DS_Store -.gradle/ .idea/ *.iml out/ + + +# Added by cargo + +/target +tools/userbot/.env +tools/userbot/node_modules/ +tools/userbot/ diff --git a/.project b/.project deleted file mode 100644 index c796c69..0000000 --- a/.project +++ /dev/null @@ -1,28 +0,0 @@ - - - bot_kernel - Project bot_kernel created by Buildship. - - - - - org.eclipse.buildship.core.gradleprojectbuilder - - - - - - org.eclipse.buildship.core.gradleprojectnature - - - - 1784367102054 - - 30 - - org.eclipse.core.resources.regexFilterMatcher - node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__ - - - - diff --git a/.settings/org.eclipse.buildship.core.prefs b/.settings/org.eclipse.buildship.core.prefs deleted file mode 100644 index 1fc41f2..0000000 --- a/.settings/org.eclipse.buildship.core.prefs +++ /dev/null @@ -1,13 +0,0 @@ -arguments=--init-script /home/loki/.cache/opencode/bin/jdtls/config_linux/org.eclipse.osgi/57/0/.cp/gradle/init/init.gradle -auto.sync=false -build.scans.enabled=false -connection.gradle.distribution=GRADLE_DISTRIBUTION(LOCAL_INSTALLATION(/usr/share/java/gradle)) -connection.project.dir= -eclipse.preferences.version=1 -gradle.user.home= -java.home=/usr/lib/jvm/java-21-openjdk -jvm.arguments= -offline.mode=false -override.workspace.settings=true -show.console.view=true -show.executions.view=true diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..b01bcb9 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,130 @@ +# Architecture - Rust kernel (edition 2024, guild 1509503154708811837) + +``` +bot-kernel/ # cargo init --edition 2024 --name bot-kernel + ├─ Cargo.toml # serenity 0.12.5 + poise 0.7.0 + sqlx 0.9.0 + tokio 1.53 + reqwest 0.13 (cargo add) + ├─ .cargo/config.toml / rustfmt.toml / clippy.toml + ├─ .env.example # BOT token only; USER token → tools/userbot/.env (isolated) + ├─ ROADMAP.md / ARCHITECTURE.md / SETUP.md + ├─ staroe/ # архив Java kernel (21→25, build.gradle, src/kernel/loki) + ├─ src/ # Rust bot (BOT token, slash + prefix) + │ ├─ main.rs # dotenv → config → pool (WAL/FK) → migration → poise → serenity + │ ├─ config/ # keys.rs, loader.rs (env→BotConfig) + │ ├─ core/ # lifecycle.rs, bot.rs (ReadyHandler + shard), dispatcher.rs, queue.rs, policy.rs + │ ├─ db/ # pool.rs (WAL, FK=ON, busy_timeout 5s), migration.rs (tx idempotent) + │ ├─ feature/ # ≤4 файла/папку, ≤200 строк/файл (см. SOLID) + │ │ ├─ help/command.rs # /help, !help (slash+prefix via poise) + │ │ ├─ info/command.rs # /ping, /serverinfo + │ │ └─ admin/command.rs # /shutdown (owners_only) + │ ├─ util/logger.rs + │ └─ bin/ds_setup.rs # Rust inspector (BOT token) - дублирует JS, но не user-token + └─ tools/userbot/ # ← ОТДЕЛЬНАЯ ПАПКА для юзер-бота (JS, только просмотр) + ├─ package.json (dotenv) # node index.js inspect --user-token --guild-id 1509503154708811837 + ├─ index.js # fetch https://discord.com/api/v10/* с Authorization + ├─ .env.example + └─ README.md +``` + +> **Важно:** папка `tools/userbot` (была `tools/ds-inspector`) - **изолирована** от `src/`. Rust-бот никогда не импортирует user-token. JS-просмотрщик - read-only, не шлёт сообщения. + +## Guild 1509503154708811837 - Loki Dev (проверено 09.09.2026, целевой - только он) +- Владелец: ты (loki_boba 1414975197269987471), 22.guilds, bot `kernel:1527966600173457408` уже в гильдии (permissions 18014398509481983). Не приглашать на другие - как просил. +- Каналы 27: INFO (rules, welcome, announcements, releases, github-log, ✅|verify, roles), COMMUNITY (general, ru-chat, en-chat, ideas), SUPPORT (open-ticket, bug-report, ticket-faq), VOICE (work-room-1/2, create-voice, afk), STAFF (staff-chat, mod-logs, bot-commands, ticket-logs). +- Роли 16: dupe `👑 Loki/Staff/Client/Verified/RU/EN/Muted` (две копии - надо чистить), `kernel` (bot), `@everyone`. +- Токены валидны, хранятся в `.env` / `tools/userbot/.env` (gitignore), не коммитим. + +## Code Style & Quality (English, Javadoc, no dead code, fmt/clippy) +- **Language:** all docs, comments, commit messages - **English only** (guild multi-language → EN default, RU via role `i18n.rs`). Chat in code = English. +- **Javadoc-style comments:** every `pub` item has `///` doc comment (like Java `/** ... */`): purpose, args, returns, example, `// SAFETY:` for sleeps. No undocumented `pub fn`. +- **Files:** `≤200` lines, `≤4` files per folder (`feature//{mod,command,handler,service}.rs`). Split if >150. +- **Dead code forbidden:** `#[allow(dead_code)]` / `#[allow(unused)]` **forbidden** - delete unused code, don't hide it. CI fails on `dead_code`/`unused`. Use `cargo fix`, not `allow`. +- **Formatting mandatory:** `cargo fmt --check` must pass. `rustfmt.toml: edition 2024, max_width 100`. Run `cargo fmt` before commit. +- **Clippy mandatory:** `cargo clippy -- -D warnings` must pass. `clippy.toml: disallowed-methods = ["std::thread::sleep"]`. Fix all `clippy::pedantic`/`nursery` warnings, no `#[allow(clippy::...)]` without `// SAFETY:` + issue link. +- **CI:** `cargo fmt --check && cargo clippy -- -D warnings && cargo test && cargo check` - blocked on failure. + +## SOLID / KISS / DRY (≤200 строк/файл, ≤4 файла/папку) +- `Lifecycle` trait - ISP, каждая фича `impl Lifecycle`. +- `BotConfig` SRP (только env), `SqlitePool` DIP (инжект через `feature::Data`). +- `RateLimiter` + `JobQueue` DRY - не копипастим cooldown как в старом `D2` (7 команд копировали `getDeterministicValue`). +- `≤200` строк - `clippy::too_many_lines` + `rustfmt max_width 100`, `≤4` файла → `feature//{mod,command,service,repository}`. + +## Команды - slash + prefix (poise 0.7) +- Сейчас: `help`, `ping`, `serverinfo`, `shutdown`. Все `#[poise::command(slash_command, prefix_command)]`. +- План блога: `/verify` (кнопка → Verified), `/blog ` (RSS→embed), `/ticket` (кнопка), `/post` - все slash-first, prefix как fallback. +- Регистрация: `register_in_guild(GUILD_ID=1509503154708811837)` в dev (мгновенно), `register_globally` в проде (до 1ч). Не используй Global Sync при итерации (антипаттерн). + +## Очередь / мультиядерность / анти-задержки (POLICY) +**Запрещено:** `tokio::time::sleep`, `std::thread::sleep`, `std::thread::park` внутри `feature/*/command.rs` **без** комментария `// SAFETY: <причина + длительность + jitter>`. + +- **Почему:** блокировка gateway → `Missed heartbeat`, 429, CloudFlare ban. Старый бот падал на `HealthHttpServer single thread` (A07), `TrackScheduler` утечках, `M!play sleep 4с` (M2). +- **Как правильно:** + 1. `ctx.defer().await?` → ответ `Thinking...`, затем тяжёлая работа в `tokio::spawn` / `JobQueue`. + 2. `core/queue.rs` - bounded `tokio::sync::Semaphore(100)` + `mpsc::channel(256)` (аналог `MaxHandlerConcurrency=100` из go антипаттерна). Burst не спавнит unbounded tasks. + 3. `RateLimiter` per-user + global token bucket (429 `Retry-After` + jitter, не хардкод `sleep(1000)`). + 4. `sqlx` async, `reqwest` с `timeout 10s + context::withTimeout` - не `context.Background()` бесконечно. + 5. `poise::FrameworkError::CommandPanic` - ловим панику handler'а, не крашим весь процесс. +- **Проверка:** `clippy.toml` `disallowed-methods = ["std::thread::sleep"]`, CI `grep -R 'sleep' src/feature --include='*.rs' | grep -v 'SAFETY:' && exit 1`. + +``` +src/core/queue.rs - BoundedJobQueue (Semaphore 100, channel 256, backpressure) +src/core/policy.rs - документация запрета задержек +src/core/dispatcher.rs - RateLimiter (check per user → Option) +``` + +## Верификация и тикеты - как правильно (нагуглено 09.09.2026) + +### Верификация (требование: embed + реакция ✅, чат заблокирован) +- **Канал `#✅|verify` (1509503655487737916):** только чтения для всех. Overwrites: `@everyone` → `VIEW_CHANNEL ✅`, `READ_MESSAGE_HISTORY ✅`, `ADD_REACTIONS ✅`, `SEND_MESSAGES ❌`, `ADD_REACTIONS` (add new) ✅. Остальные каналы скрыты пока нет `✅ Verified`. +- **Embed:** бот `/verify-setup` (admin only) постит embed `Title: Проверка • Description: Нажми ✅ чтобы получить доступ` + `msg.react('✅')`. Храним `message_id` в `guild_config.verify_message_id`. +- **Событие:** `on_raw_reaction_add` (`GUILD_MESSAGE_REACTIONS` intent + `partials: MESSAGE/CHANNEL/REACTION`). Проверяем `payload.message_id == verify_message_id && emoji == ✅ && !bot`. Даём роль `✅ Verified (1509503250997448754 / 1509503647480811610)` через `member.add_roles`. Роль бота **выше** Verified в иерархии, иначе 403 (частая ошибка StackOverflow). +- **Безопасность:** `Verification Level` в Guild Settings → `Medium` (email +5min) или `High` (+10min) + `@everyone` без `SEND_MESSAGES` в остальных каналах. Альтернатива кнопке - `Button` (components V2) современнее, но ты просил реакцию - делаем реакцию (Discord не рекомендует для новых, но работает). +- **Ошибки:** бот должен иметь `MANAGE_ROLES`, роль выше Verified, partials включены, не `reaction.emoji.id` для unicode. + +### Тикет-система (SUPPORT) +- **Панель:** embed в `#🎫|open-ticket` с кнопками (`General`, `Bug`, `Support`) - современный способ (не реакции). Старый `🎫` реакция - легаси, кнопки лучше (нет лимита 20 reactions, не надо `ADD_REACTIONS`). +- **Создание:** `on_interaction_create` (button) → `guild.create_text_channel(name=ticket-username-001, parent=SUPPORT, overwrites={@everyone: deny VIEW, user: allow VIEW+SEND, Staff: allow})` **в одну операцию** `overwrites` (не `set_permissions` по очереди - медленно, антипаттерн SO). +- **Варианты:** `channel` (до 500 каналов, видно в списке) vs `thread` (до 1000, не в лимит, авто-архив). Для Loki Dev - `channel` ок, для хайлоада - `thread`. +- **Управление:** `/close`, `/add`, `/remove`, claim, transcript (fetch messages → Hastebin/file), лог в `#🧾|ticket-logs` + DM юзеру. Чёрный список per-guild. +- **Best practices:** категории по типам, claim чтобы не дублировать ответы, транскрипты для сокращения FAQ. + +## Анти-принципы (что НЕ делать) - нагуглено 09.09.2026 +Собрано из `discord.go/anti-patterns`, `discords.ai` (wiki 2026), `Rank.top` (rate limits), `discord.js`, `discord.py` issues: + +### 1. Токены и секреты +- **Коммитить токены/`.env`** → бан, скам. Фикс: `dotenvy`, `.gitignore` + `DISCORD_BOT_TOKEN` из env, `Reset Token` при утечке (у тебя оба токена уже утекли - регенерируй!). +- **Hardcode webhook URL** → спам. + +### 2. Блокировка gateway / задержки +- **Blocking event handler** (sleep, тяжёлый CPU/DB/HTTP в handler) → dispatch pipeline стоп, `Missed 2 heartbeats`. Фикс: `defer + tokio::spawn` (см. policy выше). +- **`sleep` без объяснения** → запрещено (policy). Нужен jitter/backoff, не `sleep(200ms)` в цикле. +- **Sync Blocking Loops** (тяжёлые вычисления в async loop) → фриз всех юзеров. Фикс: `spawn_blocking`. +- **Not Setting MaxHandlerConcurrency** → unbounded goroutines/tasks при burst. Фикс: semaphore 100. + +### 3. Rate limits (429, CloudFlare) +- **Игнор 429 / хардкод таймер** → каскадный фейл, IP-бан (10k invalid /10min → CloudFlare). Фикс: парсить `X-RateLimit-Remaining/Reset-After/Retry-After`, глобальная очередь, `exponential backoff + jitter` (full/decorrelated). Не `get 429 then retry` без queue. +- **Global REST ~50 req/s, Gateway 1 Identify/5s, 120 events/60s per WS** → шарить `max_concurrency`, sharding при росте. +- **Channel rename 2/10min, message edit 5/5s** → queue, не `msg.edit()` 10 раз подряд. + +### 4. Slash vs prefix +- **Глобальный sync при разработке** → ждать 1ч. Фикс: `GuildCommandSync(GUILD_ID)` для Loki Dev. +- **Второй initial response после Defer** → Discord reject. Фикс: после `defer` только `followup`. +- **Reply "invalid command" на несуществующие** → спам при коллизии префиксов. Фикс: silent fail, только `invalid args`. + +### 5. Интенты и кэш +- **Все интеты `Intents::all()`** → OOM, верификация. Фикс: `GUILD_MESSAGES|MESSAGE_CONTENT|GUILDS|GUILD_MEMBERS` только нужные (у тебя уже так). +- **Неиспользуемые привилегированные `MESSAGE_CONTENT`, `Presence`** → доп. верификация. + +### 6. Прочее +- **Invite scraping** (бот джойнит все сервера по инвайтам) → бан. +- **Greedy prefixes (`!,$,.`)** → коллизии, используй уникальный (`!` ок, но можно `!loki`). +- **Overuse mentions / @everyone** → бот-лупы, мьют. +- **Unbounded listeners / cooldown=0** → спам тяжёлых DB. Фикс: `CooldownManager` + `RateLimiter`. +- **Игнор паник** → краш всего бота. Фикс: `poise::builtins::on_error` + `HandlerPanicError`. +- **Embeds в Components V2** → reject, используй text components. +- **Not handling Promise rejections / unhandled Result** → молчаливый краш. + +## От старого бота - что уже пофикшено +- Нет `God Module`/`PrefixCommandRegistrar 22 param` → per-feature `Module + Registrar`. +- Нет блокирующего `JDBC` на gateway → `sqlx::SqlitePool` async. +- `WAL + FK=ON + busy_timeout 5s` через `SqliteConnectOptions` (фикс `C6`), миграции транзакционно (фикс `C5`), `UndeclaredThrowableException` анврап не нужен (Rust `anyhow`). +- `RateLimiter` + `JobQueue` вместо `CachedThreadPool` (A05) и `M2/M3 sleep 4s` на gateway. diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1a4e252 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "bot-kernel" +version = "0.1.0" +edition = "2024" +description = "Loki Blog Discord Bot — Rust kernel (serenity 0.12.5 + poise 0.7.0), edition 2024" +authors = ["loki"] +license = "MIT" +rust-version = "1.85" + +[dependencies] +anyhow = "1.0.104" +async-trait = "0.1.92" +clap = { version = "4.6.6", features = ["derive"] } +dotenvy = "0.15.7" +poise = "0.7.0" +reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls"] } +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +serenity = { version = "0.12.5", features = ["gateway", "cache", "client", "model", "rustls_backend", "utils"] } +sqlx = { version = "0.9.0", features = ["sqlite", "runtime-tokio", "migrate", "macros"] } +thiserror = "2.0.20" +tokio = { version = "1.53.1", features = ["full"] } +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter", "fmt"] } + +[lints.rust] +unsafe_code = "forbid" +missing_docs = "allow" +unreachable_pub = "allow" + +[lints.clippy] +pedantic = "allow" +nursery = "allow" + +[profile.release] +lto = true +codegen-units = 1 +panic = "abort" +strip = true diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 597a9df..0000000 --- a/Dockerfile +++ /dev/null @@ -1,14 +0,0 @@ -FROM eclipse-temurin:21-jdk-alpine AS builder -WORKDIR /app -COPY gradle gradle/ -COPY gradlew build.gradle settings.gradle gradle.properties ./ -RUN ./gradlew dependencies -COPY src src/ -RUN ./gradlew build -x test - -FROM eclipse-temurin:21-jre-alpine -WORKDIR /app -COPY --from=builder /app/build/libs/*-all.jar app.jar -COPY .env.example .env -EXPOSE 8712 -ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..fbf353f --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,70 @@ +# ROADMAP - Loki Blog Bot (Rust kernel) + +> Цель: не клон `discord-bot` (экономика/музыка/левел), а бот для **блога/портфолио** - чистый, KISS, ≤200 строк/файл, ≤4 файла/папку. + +--- + +## Сервер блога - желаемая структура Discord + +``` +📁 INFO #rules, #welcome, #announcements, #blog-feed (RSS → webhook), #changelog +📁 COMMUNITY #general, #ideas, #showcase (юзеры постят проекты), #feedback +📁 SUPPORT #open-ticket, #faq +📁 VOICE create-voice (temp), afk +📁 STAFF (🔒) #mod-logs, #bot-logs +``` + +Роли: `@Verified` (кнопка в #welcome), `@Blogger`, `@Support`, `@Staff`, `@Muted`. + +--- + +## Phase 1 - Ядро ✅ (скелет готов) +- [x] Rust 1.82, tokio, serenity 0.12 + poise 0.6 (slash + prefix `!`) +- [x] `config/loader` dotenv → BotConfig, `db/pool` WAL + FK=ON (фикс C6 старого бота) +- [x] `db/migration` idempotent транзакции (фикс C5) +- [x] `core/lifecycle` trait, `core/bot` ReadyEvent + graceful shutdown +- [x] `feature/help|info|admin` - `!ping`, `!serverinfo`, `!shutdown` (owner) +- [x] `ds-setup` CLI: `inspect` / `bootstrap --dry-run/--apply` (BOT token, user-token с ворнингом) + +**Запуск:** +```bash +cp .env.example .env # вставь DISCORD_BOT_TOKEN, GUILD_ID +cargo run --bin bot +cargo run --bin ds-setup -- inspect --guild-id 123 +cargo run --bin ds-setup -- bootstrap --guild-id 123 --dry-run +``` + +## Phase 2 - Верификация и доступ (guild 1509503154708811837, next) +- [ ] **Verify (embed+✅)** - `#✅|verify` read-only (`@everyone: VIEW ✅, SEND ❌, ADD_REACTIONS ✅`), embed + `✅` реакция → выдача `✅ Verified` (1509503647480811610), бот роль выше Verified, `GUILD_MESSAGE_REACTIONS` + partials. Кнопка - альтернатива, но делаем реакцию как просил. Фикс F3: проверка `MANAGE_ROLES`. +- [ ] `!mute/!unmute/!warn/!purge/!slowmode` - атомарные транзакции, без `UndeclaredThrowableException` (фикс C1) +- [ ] Logging в #mod-logs (join/leave, edit/delete, mod actions) +- [ ] RateLimiter + `core/queue` Semaphore 100 (уже заготовка) + +## Phase 3 - Блог-фичи (главное отличие от старого бота) +- [ ] RSS/Telegram → #blog-feed (webhook, `reqwest` + `feed-rs`) +- [ ] `!post` - анонс поста (embed с cover, линк) +- [ ] Showcase: треды в #showcase, реакции ⭐ +- [ ] GitHub webhook → #changelog +- [ ] Ticket (кнопки в #open-ticket → приватный канал/тред в SUPPORT, overwrites за 1 операцию, claim, transcript → #ticket-logs) - см. ARCHITECTURE.md + +## Phase 4 - Комьюнити +- [ ] Welcome embed, counters (member count в канале) +- [ ] Polls в #ideas +- [ ] Scheduler (напоминания) +- [ ] Temp voice (`#create-voice` → временный канал) + +## Phase 5 - Опционально (AI) +- [ ] AI FAQ (DeepSeek/GPT) в #faq - только по базе знаний блога +- [ ] Auto-mod (спам/ссылки) + +--- + +## Уроки старого бота - что НЕ повторяем +F1,F2,F4 - экономика: атомарные `UPDATE ... WHERE cash>=?`, daily `WHERE last_daily != today`. +F8/C1 - не блокировать gateway: все DB/HTTP вне EventHandler, `tokio::spawn`, `sqlx` async. +C6 - `foreign_keys=ON` через `SqliteConnectOptions::foreign_keys(true)`, не одним `PRAGMA`. +M1 - `destroy()` на `ScheduledExecutorService` → в Rust `JoinHandle::abort` / `pool.close().await`. + +## Качество +`cargo fmt --check`, `cargo clippy -- -D warnings`, `cargo test`, `cargo audit`. Лимит 200 строк - `rustfmt max_width=100`. +Каждый `feature/*` ≤4 файла, >150 строк → дробить (`service/`, `repository/`). diff --git a/SETUP.md b/SETUP.md new file mode 100644 index 0000000..de709b8 --- /dev/null +++ b/SETUP.md @@ -0,0 +1,39 @@ +# SETUP - как запустить и настроить сервер блога + +## 1. Токен (важно про user-token) +**Не используй user-токен для автоматизации - это self-bot, бан по ToS Discord.** +Создай **Bot** в https://discord.com/developers/applications → Bot → Token, пригласи с правами `bot` + `applications.commands` + `MANAGE_GUILD` `MANAGE_CHANNELS` `MANAGE_ROLES` `SEND_MESSAGES`. + +Если хочешь просканировать свой сервер без бота - `ds-setup` умеет `--user-token`, но выведет ворнинг и может улететь в бан. + +## 2. Запуск ядра +```bash +cp .env.example .env +# вставь DISCORD_BOT_TOKEN, GUILD_ID (ПКМ по серверу → Copy ID), SUPER_ADMIN_ID +cargo run --bin bot +``` + +## 3. Инспекция сервера +```bash +cargo run --bin ds-setup -- inspect --guild-id 144603123456789 +# или через .env: GUILD_ID=... cargo run --bin ds-setup -- inspect +``` +Выведет каналы (`type` 0=text, 4=category, 2=voice), роли, цвета. Скопируй вывод - по нему правим структуру. + +## 4. Bootstrap структуры блога +```bash +cargo run --bin ds-setup -- bootstrap --guild-id 144603123456789 --dry-run +cargo run --bin ds-setup -- bootstrap --guild-id 144603123456789 --apply +``` +Dry-run только планирует `INFO/COMMUNITY/SUPPORT` (см. ROADMAP). Apply пока idempotent diff (создает недостающие). Требует BOT token. + +## 5. Что дальше +- В `staroe/STRUCTURE.md` лежит старый план Loki Dev Studio - не копируй его 1в1, это другой бот. +- Добавляй фичи в `src/feature//` - 1 папка = 1 модуль, `mod.rs` + `command.rs` + `service.rs` максимум. + +## 6. Качество +```bash +cargo fmt --check +cargo clippy -- -D warnings +cargo test +``` diff --git a/STRUCTURE.md b/STRUCTURE.md deleted file mode 100644 index abfc94e..0000000 --- a/STRUCTURE.md +++ /dev/null @@ -1,207 +0,0 @@ -# Bot Kernel — Loki Dev Studio - -## Архитектура - -``` -bot_kernel/ -├── build.gradle # Java 21, Gradle 9.6.1, JDA 6.4.2 -├── settings.gradle # rootProject.name = 'bot-kernel' -├── gradle/libs.versions.toml # version catalog -├── Dockerfile / docker-compose.yml -├── STRUCTURE.md # этот файл -│ -└── src/ - ├── main/java/kernel/loki/ - │ ├── bootstrap/ # Точка входа - │ │ ├── App.java # main(), Guice.createInjector() - │ │ └── ReadyListener.java # JDA ReadyEvent → логирование - │ │ - │ ├── config/ # Конфигурация - │ │ ├── ConfigKey.java # enum с ключами + дефолты - │ │ ├── ConfigLoader.java # dotenv → BotConfig - │ │ └── BotConfig.java # Immutable record-like config - │ │ - │ ├── command/ # Команды - │ │ ├── Command.java # interface { name(), description(), execute() } - │ │ ├── CommandContext.java # record (message, args, channel, member, guild, author, event) - │ │ ├── CommandCategory.java # enum (ADMIN, HELP, INFO, MODERATION, ...) - │ │ └── CommandResult.java # sealed interface (Success|Error|Cooldown|NotFound) - │ │ - │ ├── core/ # Ядро - │ │ ├── Bot.java # record (id, jda, type) - │ │ ├── BotCluster.java # Map, getByType() - │ │ ├── BotInitializer.java # DI → регистрация → JDA → BotCluster - │ │ ├── EventDispatcher.java # VirtualThread executor, dispatch message → command - │ │ ├── JdaFactory.java # JDABuilder factory - │ │ ├── Lifecycle.java # interface { start(), shutdown() } - │ │ ├── LifecycleManager.java # Multibinder → startAll/shutdownAll - │ │ ├── ListenerAggregator.java # Собирает все listener'ы для JDA - │ │ ├── MessageListener.java # ListenerAdapter → EventDispatcher.dispatch() - │ │ ├── ShutdownManager.java # Graceful shutdown - │ │ │ - │ │ ├── bootstrap/ - │ │ │ └── BootstrapPhase.java # enum фаз старта - │ │ ├── registry/ - │ │ │ ├── CommandRegistry.java # ConcurrentHashMap - │ │ │ ├── CommandRegistrar.java # @FunctionalInterface - │ │ │ └── Registrar.java # Авто-регистрация через Multibinder - │ │ ├── security/ - │ │ │ ├── CooldownManager.java # Caffeine cache per-key - │ │ │ ├── RateLimitResult.java # record - │ │ │ └── RateLimiter.java # Sliding window rate limit - │ │ └── exception/ - │ │ ├── CommandException.java - │ │ └── CommandExceptionHandler.java - │ │ - │ ├── database/ # База данных - │ │ ├── Database.java # HikariCP + SQLite, Lifecycle - │ │ ├── DatabaseModule.java # Guice module - │ │ ├── DatabaseSchema.java # Константы колонок - │ │ ├── connection/ - │ │ │ ├── ConnectionPool.java # interface - │ │ │ ├── SqliteConnectionPool.java # HikariDataSource - │ │ │ └── ConnectionFactory.java # Functional wrapper - │ │ ├── migration/ - │ │ │ ├── Migration.java # interface { version(), migrate() } - │ │ │ ├── MigrationManager.java # Auto-discover + version tracking - │ │ │ └── migrator/ - │ │ │ └── V1__InitialSchema.java # guild_config, user_data, metrics - │ │ └── repository/ - │ │ ├── Repository.java # interface (findById, save, delete) - │ │ └── BaseRepository.java # Abstract with connection helpers - │ │ - │ ├── di/ - │ │ └── CoreModule.java # Главный Guice модуль - │ │ - │ ├── embed/ - │ │ ├── EmbedColor.java # Константы цветов - │ │ ├── EmbedFactory.java # EmbedBuilder factory - │ │ └── EmbedTemplates.java # Готовые шаблоны - │ │ - │ ├── feature/ # Фичи (подключаемые модули) - │ │ ├── help/ # !help - │ │ ├── info/ # !ping, !avatar, !serverinfo - │ │ └── admin/ # !shutdown - │ │ - │ └── util/ - │ ├── LoggerDecorator.java # SLF4J с маркерами - │ ├── MessageUtil.java # sanitize, truncate, deleteAfter - │ └── TimeUtil.java # форматирование, парсинг duration - │ - ├── main/resources/ - │ ├── logback.xml - │ └── assets/ - │ - └── test/java/kernel/loki/ - ├── command/CommandRegistryTest.java - ├── config/BotConfigTest.java - ├── core/security/RateLimiterTest.java - └── embed/EmbedFactoryTest.java -``` - -## Принципы (KISS + DRY + SOLID) - -| Принцип | Как соблюдается | -|---------|----------------| -| **S**ingle Responsibility | Каждый класс — одна сущность: Command → execute, Repository → data, Service → logic | -| **O**pen/Closed | Feature-модули добавляются без изменения ядра (Multibinder) | -| **L**iskov Substitution | Command → все команды interchangeable | -| **I**nterface Segregation | Repository, Migration, Lifecycle — минимальные интерфейсы | -| **D**ependency Inversion | Все через Guice DI, нет new() в бизнес-логике | -| **KISS** | record, enum, sealed interface — минимум boilerplate | -| **DRY** | BaseRepository, EmbedFactory, LifecycleManager — общие паттерны вынесены | - -## Правила разработки - -1. **Пакеты** — `kernel.loki.{слой}.{подслой}` -2. **Фичи** — каждая фича = папка в `feature/` с модулем, командами, сервисами, репозиториями -3. **Добавление фичи**: - - Создать `*Module.java` extends `AbstractModule` - - Забиндить `CommandRegistrar` через `Multibinder` - - Забиндить `Lifecycle` если нужно - - Подключить модуль в `CoreModule.configure()` -4. **Команды** — implements `Command`, регистрируются через `CommandRegistrar` -5. **БД** — миграции через `Migration`, репозитории extends `BaseRepository` -6. **Тесты** — JUnit 5, Mockito, testcontainers для БД -7. **Стиль** — Google Java Format, spotless, checkstyle, spotbugs - ---- - -## План фич для Loki Dev Studio - -### Сервер: Loki Dev (ID: 1509503154708811837) - -``` -📁 INFO - #rules, #welcome, #announcements, #releases, #github-log -📁 COMMUNITY - #general, #ru-chat, #en-chat, #ideas -📁 SUPPORT - #open-ticket, #bug-report, #ticket-faq -📁 VOICE - work-room-1, work-room-2, create-voice, afk -📁 STAFF (🔒 staff-only) - #staff-chat, #mod-logs, #bot-commands, #ticket-logs -# ✅ verify (uncategorized) -``` - -### Очередность разработки - -#### Phase 1 — Ядро ✅ (Готово) -- [x] Bootstrap, DI, Config -- [x] Command framework -- [x] Event dispatching (virtual threads) -- [x] Rate limiter + Cooldown -- [x] Database (HikariCP + SQLite + Migrations) -- [x] Base embed templates -- [x] Базовые команды: help, ping, avatar, serverinfo, shutdown - -#### Phase 2 — Модерация и управление -- [ ] **Verification** — кнопка в `#verify`, выдача роли Verified + RU/EN -- [ ] **Auto-role** — выбор языка (RU/EN) через кнопки -- [ ] **Moderation** — mute, warn, kick, ban, purge, case-lookup -- [ ] **Logging** — audit log в `#mod-logs` (join/leave, message edit/delete, voice, mod actions) -- [ ] **Mute role** — роль `🔕 Muted`, синхронизация по гильдиям - -#### Phase 3 — Поддержка клиентов -- [ ] **Ticket System** — кнопка в `#open-ticket`, создание канала в категории -- [ ] **Ticket panels** — FAQ в `#ticket-faq` -- [ ] **Bug Reports** — модальное окно в `#bug-report` -- [ ] **Client management** — БД клиентов, проектов, контрактов -- [ ] **Invoice/Payment tracking** — статусы оплат - -#### Phase 4 — Комьюнити и продуктивность -- [ ] **Welcome** — приветствие новых участников в `#welcome` -- [ ] **Polls** — создание опросов в `#ideas` -- [ ] **GitHub integration** — вебхуки в `#github-log` -- [ ] **Scheduler** — отложенные сообщения, напоминания -- [ ] **Counters** — счётчики участников в названиях каналов - -#### Phase 5 — Голосовые каналы -- [ ] **Temp Voice** — `#create-voice` → создание временного канала -- [ ] **Voice roles** — выдача роли за нахождение в войсе -- [ ] **Music** — Lavalink плеер - -#### Phase 6 — AI и автоматизация -- [ ] **AI Assistant** — DeepSeek/GPT для ответов на вопросы клиентов -- [ ] **Auto-mod** — фильтр спама, ссылок, капслока -- [ ] **Stats** — статистика сервера, активность участников - -#### Phase 7 — Профили и экономика (опционально) -- [ ] **Level/XP** — за сообщения и войс -- [ ] **Reputation** — +/rep -- [ ] **Profile cards** — ранговые карточки - ---- - -## Стек технологий - -- **Java 21** — virtual threads, pattern matching, records, sealed classes -- **Gradle 9.6.1** — version catalog, build cache -- **JDA 6.4.2** — Discord API -- **Guice 7.0** — dependency injection -- **HikariCP + SQLite** — database (может быть заменена на PostgreSQL) -- **SLF4J + Logback** — logging -- **Caffeine** — кэширование (cooldowns, rate limits) -- **JUnit 5 + Mockito** — тестирование -- **Checkstyle + SpotBugs + Spotless** — code quality diff --git a/build.gradle b/build.gradle deleted file mode 100644 index 2445211..0000000 --- a/build.gradle +++ /dev/null @@ -1,78 +0,0 @@ -plugins { - id 'java' - id 'application' - id 'checkstyle' - alias(libs.plugins.spotbugs) - id 'com.diffplug.spotless' version '6.25.0' -} - -group = 'kernel.loki' -version = '0.1.0' - -dependencies { - implementation libs.jda - implementation libs.dotenv - implementation libs.slf4j.api - implementation libs.logback.classic - implementation libs.gson - implementation libs.caffeine - implementation libs.guice - implementation libs.sqlite.jdbc - implementation libs.hikaricp - implementation libs.okhttp - implementation libs.jsoup - - testImplementation libs.junit.jupiter - testImplementation libs.mockito.core - testImplementation libs.mockito.junit.jupiter - testRuntimeOnly libs.junit.launcher -} - -java { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 -} - -tasks.withType(JavaCompile) { - options.encoding = 'UTF-8' - options.compilerArgs << '-Xlint:all' -} - -test { - useJUnitPlatform() -} - -application { - mainClass = 'kernel.loki.bootstrap.App' -} - -jar { - manifest { - attributes( - 'Main-Class': 'kernel.loki.bootstrap.App' - ) - } - duplicatesStrategy = DuplicatesStrategy.EXCLUDE - from { - configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } - } -} - -spotless { - java { - googleJavaFormat() - importOrder('java', 'javax', '') - removeUnusedImports() - trimTrailingWhitespace() - endWithNewline() - } -} - -checkstyle { - configFile = file("${rootDir}/config/checkstyle/checkstyle.xml") -} - -spotbugs { - excludeFilter = file("${rootDir}/config/spotbugs/exclude.xml") - ignoreFailures = false -} diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..3172234 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,5 @@ +cognitive-complexity-threshold = 15 +too-many-arguments-threshold = 7 +disallowed-methods = [ + "std::thread::sleep", +] diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml deleted file mode 100644 index 41c3f33..0000000 --- a/config/checkstyle/checkstyle.xml +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/config/spotbugs/exclude.xml b/config/spotbugs/exclude.xml deleted file mode 100644 index 981d7ea..0000000 --- a/config/spotbugs/exclude.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index e07a6d8..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,13 +0,0 @@ -version: '3.8' -services: - bot: - build: . - container_name: bot-kernel - restart: unless-stopped - env_file: - - .env - volumes: - - ./data:/app/data - - ./logs:/app/logs - ports: - - "8712:8712" diff --git a/gradle.properties b/gradle.properties deleted file mode 100644 index 6d264c1..0000000 --- a/gradle.properties +++ /dev/null @@ -1,2 +0,0 @@ -org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -org.gradle.parallel=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml deleted file mode 100644 index 40a02fc..0000000 --- a/gradle/libs.versions.toml +++ /dev/null @@ -1,35 +0,0 @@ -[versions] -jda = "6.4.2" -dotenv = "3.2.0" -slf4j = "2.1.0-alpha1" -logback = "1.5.18" -gson = "2.14.0" -caffeine = "3.2.0" -guice = "7.0.0" -sqlite = "3.49.1.0" -hikari = "6.3.0" -okhttp = "5.4.0" -jsoup = "1.19.1" -junit = "5.12.1" -mockito = "5.15.2" -junitLauncher = "1.12.1" - -[libraries] -jda = { module = "net.dv8tion:JDA", version.ref = "jda" } -dotenv = { module = "io.github.cdimascio:dotenv-java", version.ref = "dotenv" } -slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } -logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } -gson = { module = "com.google.code.gson:gson", version.ref = "gson" } -caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version.ref = "caffeine" } -guice = { module = "com.google.inject:guice", version.ref = "guice" } -sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite" } -hikaricp = { module = "com.zaxxer:HikariCP", version.ref = "hikari" } -okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } -jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } -junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" } -junit-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junitLauncher" } -mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" } -mockito-junit-jupiter = { module = "org.mockito:mockito-junit-jupiter", version.ref = "mockito" } - -[plugins] -spotbugs = { id = "com.github.spotbugs", version = "6.1.7" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 9bbc975c742b298b441bfb90dbc124400a3751b9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 43705 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 DHx0+F diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index a351597..0000000 --- a/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,7 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip -networkTimeout=10000 -validateDistributionUrl=true -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew deleted file mode 100755 index faf9300..0000000 --- a/gradlew +++ /dev/null @@ -1,251 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015-2021 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# SPDX-License-Identifier: Apache-2.0 -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, -# and any embedded shellness will be escaped. -# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be -# treated as '${Hostname}' itself on the command line. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat deleted file mode 100644 index 9b42019..0000000 --- a/gradlew.bat +++ /dev/null @@ -1,94 +0,0 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..38cba21 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,3 @@ +edition = "2024" +max_width = 100 +use_small_heuristics = "Max" diff --git a/settings.gradle b/settings.gradle deleted file mode 100644 index 20bf6b9..0000000 --- a/settings.gradle +++ /dev/null @@ -1,7 +0,0 @@ -rootProject.name = 'bot-kernel' - -dependencyResolutionManagement { - repositories { - mavenCentral() - } -} diff --git a/src/config/keys.rs b/src/config/keys.rs new file mode 100644 index 0000000..db3c5d2 --- /dev/null +++ b/src/config/keys.rs @@ -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"; diff --git a/src/config/loader.rs b/src/config/loader.rs new file mode 100644 index 0000000..1d5ff3b --- /dev/null +++ b/src/config/loader.rs @@ -0,0 +1,28 @@ +use super::{BotConfig, keys}; +use anyhow::{Context, Result}; + +fn env(key: &str, default: Option<&str>) -> Result { + 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 { + std::env::var(key).ok()?.parse().ok() +} + +pub fn load() -> Result { + 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"))?, + }) +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..9e6423c --- /dev/null +++ b/src/config/mod.rs @@ -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, + pub super_admin_id: Option, + pub db_path: String, + pub activity: String, +} diff --git a/src/core/bot.rs b/src/core/bot.rs new file mode 100644 index 0000000..2dc8168 --- /dev/null +++ b/src/core/bot.rs @@ -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, + 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, +) -> anyhow::Result { + 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(()) +} diff --git a/src/core/dispatcher.rs b/src/core/dispatcher.rs new file mode 100644 index 0000000..5589ad5 --- /dev/null +++ b/src/core/dispatcher.rs @@ -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>>, + 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 { + 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 + } +} diff --git a/src/core/lifecycle.rs b/src/core/lifecycle.rs new file mode 100644 index 0000000..0285fd6 --- /dev/null +++ b/src/core/lifecycle.rs @@ -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(()) + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs new file mode 100644 index 0000000..a1a9f54 --- /dev/null +++ b/src/core/mod.rs @@ -0,0 +1,5 @@ +pub mod bot; +pub mod dispatcher; +pub mod lifecycle; +pub mod policy; +pub mod queue; diff --git a/src/core/policy.rs b/src/core/policy.rs new file mode 100644 index 0000000..9e17c0d --- /dev/null +++ b/src/core/policy.rs @@ -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; diff --git a/src/core/queue.rs b/src/core/queue.rs new file mode 100644 index 0000000..93738c5 --- /dev/null +++ b/src/core/queue.rs @@ -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, + sem: Arc, +} + +type Job = Box; + +impl JobQueue { + pub fn new(cap: usize, concurrency: usize) -> (Self, mpsc::Receiver) { + 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(&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 { + self.sem.clone() + } +} diff --git a/src/db/migration.rs b/src/db/migration.rs new file mode 100644 index 0000000..75405d5 --- /dev/null +++ b/src/db/migration.rs @@ -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 +); +"; diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 0000000..2c4819c --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1,2 @@ +pub mod migration; +pub mod pool; diff --git a/src/db/pool.rs b/src/db/pool.rs new file mode 100644 index 0000000..83740df --- /dev/null +++ b/src/db/pool.rs @@ -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 { + 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"); +} diff --git a/src/feature/admin/command.rs b/src/feature/admin/command.rs new file mode 100644 index 0000000..7e1da5b --- /dev/null +++ b/src/feature/admin/command.rs @@ -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(()) +} diff --git a/src/feature/admin/mod.rs b/src/feature/admin/mod.rs new file mode 100644 index 0000000..9fe7961 --- /dev/null +++ b/src/feature/admin/mod.rs @@ -0,0 +1 @@ +pub mod command; diff --git a/src/feature/help/command.rs b/src/feature/help/command.rs new file mode 100644 index 0000000..700ba19 --- /dev/null +++ b/src/feature/help/command.rs @@ -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, +) -> anyhow::Result<()> { + let lang = if let Some(member) = ctx.author_member().await { + let ids: Vec = 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 for details", ctx.author().name))) + .timestamp(serenity::all::Timestamp::now()); + + ctx.send(CreateReply::default().embed(embed)).await?; + Ok(()) +} diff --git a/src/feature/help/mod.rs b/src/feature/help/mod.rs new file mode 100644 index 0000000..9fe7961 --- /dev/null +++ b/src/feature/help/mod.rs @@ -0,0 +1 @@ +pub mod command; diff --git a/src/feature/info/command.rs b/src/feature/info/command.rs new file mode 100644 index 0000000..76e23b7 --- /dev/null +++ b/src/feature/info/command.rs @@ -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(()) +} diff --git a/src/feature/info/mod.rs b/src/feature/info/mod.rs new file mode 100644 index 0000000..9fe7961 --- /dev/null +++ b/src/feature/info/mod.rs @@ -0,0 +1 @@ +pub mod command; diff --git a/src/feature/lang/handler.rs b/src/feature/lang/handler.rs new file mode 100644 index 0000000..cacf8b3 --- /dev/null +++ b/src/feature/lang/handler.rs @@ -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) +} diff --git a/src/feature/lang/mod.rs b/src/feature/lang/mod.rs new file mode 100644 index 0000000..062ae9d --- /dev/null +++ b/src/feature/lang/mod.rs @@ -0,0 +1 @@ +pub mod handler; diff --git a/src/feature/mod.rs b/src/feature/mod.rs new file mode 100644 index 0000000..7022f68 --- /dev/null +++ b/src/feature/mod.rs @@ -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> { + 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) +} diff --git a/src/feature/moderation/command.rs b/src/feature/moderation/command.rs new file mode 100644 index 0000000..4709014 --- /dev/null +++ b/src/feature/moderation/command.rs @@ -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 { + // 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 = member.roles.iter().map(|r| r.get()).collect(); + i18n::from_roles(&ids) + } else { + Lang::En + } +} + +/// `/clear ` - 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 ` - 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 ` - 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, +) -> 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) -> Option> = Some(lang_of); diff --git a/src/feature/moderation/mod.rs b/src/feature/moderation/mod.rs new file mode 100644 index 0000000..9fe7961 --- /dev/null +++ b/src/feature/moderation/mod.rs @@ -0,0 +1 @@ +pub mod command; diff --git a/src/feature/ticket/command.rs b/src/feature/ticket/command.rs new file mode 100644 index 0000000..7c42034 --- /dev/null +++ b/src/feature/ticket/command.rs @@ -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 = 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(()) +} diff --git a/src/feature/ticket/handler.rs b/src/feature/ticket/handler.rs new file mode 100644 index 0000000..771adc4 --- /dev/null +++ b/src/feature/ticket/handler.rs @@ -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 { + 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), + }, + ] +} diff --git a/src/feature/ticket/mod.rs b/src/feature/ticket/mod.rs new file mode 100644 index 0000000..a2f4939 --- /dev/null +++ b/src/feature/ticket/mod.rs @@ -0,0 +1,2 @@ +pub mod command; +pub mod handler; diff --git a/src/feature/verify/command.rs b/src/feature/verify/command.rs new file mode 100644 index 0000000..0df7cbf --- /dev/null +++ b/src/feature/verify/command.rs @@ -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 = 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(()) +} diff --git a/src/feature/verify/handler.rs b/src/feature/verify/handler.rs new file mode 100644 index 0000000..4ea6e7b --- /dev/null +++ b/src/feature/verify/handler.rs @@ -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(()) +} diff --git a/src/feature/verify/mod.rs b/src/feature/verify/mod.rs new file mode 100644 index 0000000..a2f4939 --- /dev/null +++ b/src/feature/verify/mod.rs @@ -0,0 +1,2 @@ +pub mod command; +pub mod handler; diff --git a/src/feature/voice/handler.rs b/src/feature/voice/handler.rs new file mode 100644 index 0000000..fa8ed3c --- /dev/null +++ b/src/feature/voice/handler.rs @@ -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, 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)"); + } + } + } + } + } + } + } +} diff --git a/src/feature/voice/mod.rs b/src/feature/voice/mod.rs new file mode 100644 index 0000000..062ae9d --- /dev/null +++ b/src/feature/voice/mod.rs @@ -0,0 +1 @@ +pub mod handler; diff --git a/src/feature/welcome/greetings.rs b/src/feature/welcome/greetings.rs new file mode 100644 index 0000000..bb39d66 --- /dev/null +++ b/src/feature/welcome/greetings.rs @@ -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) +} diff --git a/src/feature/welcome/handler.rs b/src/feature/welcome/handler.rs new file mode 100644 index 0000000..24b9f10 --- /dev/null +++ b/src/feature/welcome/handler.rs @@ -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) +} diff --git a/src/feature/welcome/mod.rs b/src/feature/welcome/mod.rs new file mode 100644 index 0000000..d3daaf9 --- /dev/null +++ b/src/feature/welcome/mod.rs @@ -0,0 +1,2 @@ +pub mod greetings; +pub mod handler; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..02b703b --- /dev/null +++ b/src/main.rs @@ -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(()) +} diff --git a/src/main/java/kernel/loki/bootstrap/App.java b/src/main/java/kernel/loki/bootstrap/App.java deleted file mode 100644 index 3ded420..0000000 --- a/src/main/java/kernel/loki/bootstrap/App.java +++ /dev/null @@ -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() {} -} diff --git a/src/main/java/kernel/loki/bootstrap/ReadyListener.java b/src/main/java/kernel/loki/bootstrap/ReadyListener.java deleted file mode 100644 index 9a9830a..0000000 --- a/src/main/java/kernel/loki/bootstrap/ReadyListener.java +++ /dev/null @@ -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()); - } -} diff --git a/src/main/java/kernel/loki/command/Command.java b/src/main/java/kernel/loki/command/Command.java deleted file mode 100644 index 9d948a6..0000000 --- a/src/main/java/kernel/loki/command/Command.java +++ /dev/null @@ -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); -} diff --git a/src/main/java/kernel/loki/command/CommandCategory.java b/src/main/java/kernel/loki/command/CommandCategory.java deleted file mode 100644 index 0744855..0000000 --- a/src/main/java/kernel/loki/command/CommandCategory.java +++ /dev/null @@ -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; } -} diff --git a/src/main/java/kernel/loki/command/CommandContext.java b/src/main/java/kernel/loki/command/CommandContext.java deleted file mode 100644 index df050db..0000000 --- a/src/main/java/kernel/loki/command/CommandContext.java +++ /dev/null @@ -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 args, - MessageChannel channel, - Member member, - Guild guild, - User author, - MessageReceivedEvent event -) { - public CommandContext(MessageReceivedEvent event, List args) { - this( - event.getMessage(), - args, - event.getChannel(), - event.getMember(), - event.getGuild(), - event.getAuthor(), - event - ); - } -} diff --git a/src/main/java/kernel/loki/command/CommandResult.java b/src/main/java/kernel/loki/command/CommandResult.java deleted file mode 100644 index 3c6a2e2..0000000 --- a/src/main/java/kernel/loki/command/CommandResult.java +++ /dev/null @@ -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 {} -} diff --git a/src/main/java/kernel/loki/config/BotConfig.java b/src/main/java/kernel/loki/config/BotConfig.java deleted file mode 100644 index 3e79a7b..0000000 --- a/src/main/java/kernel/loki/config/BotConfig.java +++ /dev/null @@ -1,30 +0,0 @@ -package kernel.loki.config; - -import java.util.Map; - -public class BotConfig { - private final Map values; - - public BotConfig(Map 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); } -} diff --git a/src/main/java/kernel/loki/config/ConfigKey.java b/src/main/java/kernel/loki/config/ConfigKey.java deleted file mode 100644 index 7bd1bb2..0000000 --- a/src/main/java/kernel/loki/config/ConfigKey.java +++ /dev/null @@ -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; } -} diff --git a/src/main/java/kernel/loki/config/ConfigLoader.java b/src/main/java/kernel/loki/config/ConfigLoader.java deleted file mode 100644 index c997867..0000000 --- a/src/main/java/kernel/loki/config/ConfigLoader.java +++ /dev/null @@ -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 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 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); - } -} diff --git a/src/main/java/kernel/loki/core/Bot.java b/src/main/java/kernel/loki/core/Bot.java deleted file mode 100644 index c1136da..0000000 --- a/src/main/java/kernel/loki/core/Bot.java +++ /dev/null @@ -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 - } -} diff --git a/src/main/java/kernel/loki/core/BotCluster.java b/src/main/java/kernel/loki/core/BotCluster.java deleted file mode 100644 index b621b74..0000000 --- a/src/main/java/kernel/loki/core/BotCluster.java +++ /dev/null @@ -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 bots = new HashMap<>(); - - public BotCluster(List botList) { - for (Bot bot : botList) { - bots.put(bot.id(), bot); - } - } - - public void add(Bot bot) { - bots.put(bot.id(), bot); - } - - public Optional get(String id) { - return Optional.ofNullable(bots.get(id)); - } - - public Optional 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 all() { - return List.copyOf(bots.values()); - } - - public int count() { - return bots.size(); - } -} diff --git a/src/main/java/kernel/loki/core/BotInitializer.java b/src/main/java/kernel/loki/core/BotInitializer.java deleted file mode 100644 index 0c46d89..0000000 --- a/src/main/java/kernel/loki/core/BotInitializer.java +++ /dev/null @@ -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 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()); - } -} diff --git a/src/main/java/kernel/loki/core/EventDispatcher.java b/src/main/java/kernel/loki/core/EventDispatcher.java deleted file mode 100644 index f9b211c..0000000 --- a/src/main/java/kernel/loki/core/EventDispatcher.java +++ /dev/null @@ -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 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); - } - }); - } -} diff --git a/src/main/java/kernel/loki/core/JdaFactory.java b/src/main/java/kernel/loki/core/JdaFactory.java deleted file mode 100644 index 519e982..0000000 --- a/src/main/java/kernel/loki/core/JdaFactory.java +++ /dev/null @@ -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); - } - } -} diff --git a/src/main/java/kernel/loki/core/Lifecycle.java b/src/main/java/kernel/loki/core/Lifecycle.java deleted file mode 100644 index 07ac7cb..0000000 --- a/src/main/java/kernel/loki/core/Lifecycle.java +++ /dev/null @@ -1,6 +0,0 @@ -package kernel.loki.core; - -public interface Lifecycle { - default void start() {} - default void shutdown() {} -} diff --git a/src/main/java/kernel/loki/core/LifecycleManager.java b/src/main/java/kernel/loki/core/LifecycleManager.java deleted file mode 100644 index da7102b..0000000 --- a/src/main/java/kernel/loki/core/LifecycleManager.java +++ /dev/null @@ -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 components; - - @Inject - public LifecycleManager(Set 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"); - } -} diff --git a/src/main/java/kernel/loki/core/ListenerAggregator.java b/src/main/java/kernel/loki/core/ListenerAggregator.java deleted file mode 100644 index 8d7a4c8..0000000 --- a/src/main/java/kernel/loki/core/ListenerAggregator.java +++ /dev/null @@ -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 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); - } -} diff --git a/src/main/java/kernel/loki/core/MessageListener.java b/src/main/java/kernel/loki/core/MessageListener.java deleted file mode 100644 index 7487a4f..0000000 --- a/src/main/java/kernel/loki/core/MessageListener.java +++ /dev/null @@ -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); - } -} diff --git a/src/main/java/kernel/loki/core/ShutdownManager.java b/src/main/java/kernel/loki/core/ShutdownManager.java deleted file mode 100644 index 47b6f98..0000000 --- a/src/main/java/kernel/loki/core/ShutdownManager.java +++ /dev/null @@ -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!"); - } -} diff --git a/src/main/java/kernel/loki/core/bootstrap/BootstrapPhase.java b/src/main/java/kernel/loki/core/bootstrap/BootstrapPhase.java deleted file mode 100644 index 5267e6d..0000000 --- a/src/main/java/kernel/loki/core/bootstrap/BootstrapPhase.java +++ /dev/null @@ -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 -} diff --git a/src/main/java/kernel/loki/core/exception/CommandException.java b/src/main/java/kernel/loki/core/exception/CommandException.java deleted file mode 100644 index 3891102..0000000 --- a/src/main/java/kernel/loki/core/exception/CommandException.java +++ /dev/null @@ -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; } -} diff --git a/src/main/java/kernel/loki/core/exception/CommandExceptionHandler.java b/src/main/java/kernel/loki/core/exception/CommandExceptionHandler.java deleted file mode 100644 index 35ea1a2..0000000 --- a/src/main/java/kernel/loki/core/exception/CommandExceptionHandler.java +++ /dev/null @@ -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); - } - } -} diff --git a/src/main/java/kernel/loki/core/registry/CommandRegistrar.java b/src/main/java/kernel/loki/core/registry/CommandRegistrar.java deleted file mode 100644 index ded2eeb..0000000 --- a/src/main/java/kernel/loki/core/registry/CommandRegistrar.java +++ /dev/null @@ -1,6 +0,0 @@ -package kernel.loki.core.registry; - -@FunctionalInterface -public interface CommandRegistrar { - void register(CommandRegistry registry); -} diff --git a/src/main/java/kernel/loki/core/registry/CommandRegistry.java b/src/main/java/kernel/loki/core/registry/CommandRegistry.java deleted file mode 100644 index 8ddd965..0000000 --- a/src/main/java/kernel/loki/core/registry/CommandRegistry.java +++ /dev/null @@ -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 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 all() { - return Collections.unmodifiableCollection(commands.values()); - } - - public int count() { - return commands.size(); - } -} diff --git a/src/main/java/kernel/loki/core/registry/Registrar.java b/src/main/java/kernel/loki/core/registry/Registrar.java deleted file mode 100644 index 6b2a420..0000000 --- a/src/main/java/kernel/loki/core/registry/Registrar.java +++ /dev/null @@ -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 registrars; - - @Inject - public Registrar(CommandRegistry registry, Set 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()); - } -} diff --git a/src/main/java/kernel/loki/core/security/CooldownManager.java b/src/main/java/kernel/loki/core/security/CooldownManager.java deleted file mode 100644 index d6e9532..0000000 --- a/src/main/java/kernel/loki/core/security/CooldownManager.java +++ /dev/null @@ -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 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(); - } -} diff --git a/src/main/java/kernel/loki/core/security/RateLimitResult.java b/src/main/java/kernel/loki/core/security/RateLimitResult.java deleted file mode 100644 index 4f775c0..0000000 --- a/src/main/java/kernel/loki/core/security/RateLimitResult.java +++ /dev/null @@ -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); -} diff --git a/src/main/java/kernel/loki/core/security/RateLimiter.java b/src/main/java/kernel/loki/core/security/RateLimiter.java deleted file mode 100644 index 6586202..0000000 --- a/src/main/java/kernel/loki/core/security/RateLimiter.java +++ /dev/null @@ -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 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; - } - } -} diff --git a/src/main/java/kernel/loki/database/Database.java b/src/main/java/kernel/loki/database/Database.java deleted file mode 100644 index 4ba7067..0000000 --- a/src/main/java/kernel/loki/database/Database.java +++ /dev/null @@ -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"); - } - } -} diff --git a/src/main/java/kernel/loki/database/DatabaseModule.java b/src/main/java/kernel/loki/database/DatabaseModule.java deleted file mode 100644 index 4bb19f8..0000000 --- a/src/main/java/kernel/loki/database/DatabaseModule.java +++ /dev/null @@ -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 - } - - @Provides - @Singleton - Database provideDatabase(kernel.loki.config.BotConfig config) { - return new Database(config); - } -} diff --git a/src/main/java/kernel/loki/database/DatabaseSchema.java b/src/main/java/kernel/loki/database/DatabaseSchema.java deleted file mode 100644 index 21becff..0000000 --- a/src/main/java/kernel/loki/database/DatabaseSchema.java +++ /dev/null @@ -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() {} - } -} diff --git a/src/main/java/kernel/loki/database/connection/ConnectionFactory.java b/src/main/java/kernel/loki/database/connection/ConnectionFactory.java deleted file mode 100644 index 8729d87..0000000 --- a/src/main/java/kernel/loki/database/connection/ConnectionFactory.java +++ /dev/null @@ -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 withConnection(Function callback) { - try (Connection conn = pool.getConnection()) { - return callback.apply(conn); - } catch (SQLException e) { - throw new RuntimeException(e); - } - } - - public void withConnectionVoid(ThrowingConsumer callback) { - try (Connection conn = pool.getConnection()) { - callback.accept(conn); - } catch (SQLException e) { - throw new RuntimeException(e); - } - } - - @FunctionalInterface - public interface ThrowingConsumer { - void accept(T t) throws SQLException; - } -} diff --git a/src/main/java/kernel/loki/database/connection/ConnectionPool.java b/src/main/java/kernel/loki/database/connection/ConnectionPool.java deleted file mode 100644 index 330de56..0000000 --- a/src/main/java/kernel/loki/database/connection/ConnectionPool.java +++ /dev/null @@ -1,8 +0,0 @@ -package kernel.loki.database.connection; - -import java.sql.Connection; - -public interface ConnectionPool { - Connection getConnection(); - void close(); -} diff --git a/src/main/java/kernel/loki/database/connection/SqliteConnectionPool.java b/src/main/java/kernel/loki/database/connection/SqliteConnectionPool.java deleted file mode 100644 index 425e056..0000000 --- a/src/main/java/kernel/loki/database/connection/SqliteConnectionPool.java +++ /dev/null @@ -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(); - } - } -} diff --git a/src/main/java/kernel/loki/database/migration/Migration.java b/src/main/java/kernel/loki/database/migration/Migration.java deleted file mode 100644 index 9ec8b70..0000000 --- a/src/main/java/kernel/loki/database/migration/Migration.java +++ /dev/null @@ -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; -} diff --git a/src/main/java/kernel/loki/database/migration/MigrationManager.java b/src/main/java/kernel/loki/database/migration/MigrationManager.java deleted file mode 100644 index 2989492..0000000 --- a/src/main/java/kernel/loki/database/migration/MigrationManager.java +++ /dev/null @@ -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 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 + ")"); - } - } -} diff --git a/src/main/java/kernel/loki/database/migration/migrator/V1__InitialSchema.java b/src/main/java/kernel/loki/database/migration/migrator/V1__InitialSchema.java deleted file mode 100644 index ebc5071..0000000 --- a/src/main/java/kernel/loki/database/migration/migrator/V1__InitialSchema.java +++ /dev/null @@ -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) - ) - """); - } - } -} diff --git a/src/main/java/kernel/loki/database/repository/BaseRepository.java b/src/main/java/kernel/loki/database/repository/BaseRepository.java deleted file mode 100644 index 1760dca..0000000 --- a/src/main/java/kernel/loki/database/repository/BaseRepository.java +++ /dev/null @@ -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 withConnection(Function callback) { - try (Connection conn = database.getConnection()) { - return callback.apply(conn); - } catch (SQLException e) { - throw new RuntimeException(e); - } - } - - protected void withConnectionVoid(ThrowingConsumer 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 { - void accept(T t) throws SQLException; - } -} diff --git a/src/main/java/kernel/loki/database/repository/Repository.java b/src/main/java/kernel/loki/database/repository/Repository.java deleted file mode 100644 index 4a0f5f4..0000000 --- a/src/main/java/kernel/loki/database/repository/Repository.java +++ /dev/null @@ -1,11 +0,0 @@ -package kernel.loki.database.repository; - -import java.sql.Connection; -import java.sql.SQLException; -import java.util.Optional; - -public interface Repository { - Optional findById(ID id); - void save(T entity); - void delete(ID id); -} diff --git a/src/main/java/kernel/loki/di/CoreModule.java b/src/main/java/kernel/loki/di/CoreModule.java deleted file mode 100644 index 20f7da8..0000000 --- a/src/main/java/kernel/loki/di/CoreModule.java +++ /dev/null @@ -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 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); - } -} diff --git a/src/main/java/kernel/loki/embed/EmbedColor.java b/src/main/java/kernel/loki/embed/EmbedColor.java deleted file mode 100644 index 663eb17..0000000 --- a/src/main/java/kernel/loki/embed/EmbedColor.java +++ /dev/null @@ -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() {} -} diff --git a/src/main/java/kernel/loki/embed/EmbedFactory.java b/src/main/java/kernel/loki/embed/EmbedFactory.java deleted file mode 100644 index 118d40d..0000000 --- a/src/main/java/kernel/loki/embed/EmbedFactory.java +++ /dev/null @@ -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(); - } -} diff --git a/src/main/java/kernel/loki/embed/EmbedTemplates.java b/src/main/java/kernel/loki/embed/EmbedTemplates.java deleted file mode 100644 index 485b11f..0000000 --- a/src/main/java/kernel/loki/embed/EmbedTemplates.java +++ /dev/null @@ -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 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(); - } -} diff --git a/src/main/java/kernel/loki/feature/admin/AdminCommandRegistrar.java b/src/main/java/kernel/loki/feature/admin/AdminCommandRegistrar.java deleted file mode 100644 index 1cc4e97..0000000 --- a/src/main/java/kernel/loki/feature/admin/AdminCommandRegistrar.java +++ /dev/null @@ -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); - } -} diff --git a/src/main/java/kernel/loki/feature/admin/AdminModule.java b/src/main/java/kernel/loki/feature/admin/AdminModule.java deleted file mode 100644 index 68ece81..0000000 --- a/src/main/java/kernel/loki/feature/admin/AdminModule.java +++ /dev/null @@ -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 registrarBinder = - Multibinder.newSetBinder(binder(), CommandRegistrar.class); - registrarBinder.addBinding().to(AdminCommandRegistrar.class); - } -} diff --git a/src/main/java/kernel/loki/feature/admin/command/ShutdownCommand.java b/src/main/java/kernel/loki/feature/admin/command/ShutdownCommand.java deleted file mode 100644 index 4d03210..0000000 --- a/src/main/java/kernel/loki/feature/admin/command/ShutdownCommand.java +++ /dev/null @@ -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); - }); - } -} diff --git a/src/main/java/kernel/loki/feature/help/HelpCommandRegistrar.java b/src/main/java/kernel/loki/feature/help/HelpCommandRegistrar.java deleted file mode 100644 index 00f2220..0000000 --- a/src/main/java/kernel/loki/feature/help/HelpCommandRegistrar.java +++ /dev/null @@ -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); - } -} diff --git a/src/main/java/kernel/loki/feature/help/HelpModule.java b/src/main/java/kernel/loki/feature/help/HelpModule.java deleted file mode 100644 index b5d8c44..0000000 --- a/src/main/java/kernel/loki/feature/help/HelpModule.java +++ /dev/null @@ -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 registrarBinder = - Multibinder.newSetBinder(binder(), CommandRegistrar.class); - registrarBinder.addBinding().to(HelpCommandRegistrar.class); - } -} diff --git a/src/main/java/kernel/loki/feature/help/command/HelpCommand.java b/src/main/java/kernel/loki/feature/help/command/HelpCommand.java deleted file mode 100644 index 00f7199..0000000 --- a/src/main/java/kernel/loki/feature/help/command/HelpCommand.java +++ /dev/null @@ -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(); - } -} diff --git a/src/main/java/kernel/loki/feature/info/InfoCommandRegistrar.java b/src/main/java/kernel/loki/feature/info/InfoCommandRegistrar.java deleted file mode 100644 index 4b056c4..0000000 --- a/src/main/java/kernel/loki/feature/info/InfoCommandRegistrar.java +++ /dev/null @@ -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); - } -} diff --git a/src/main/java/kernel/loki/feature/info/InfoModule.java b/src/main/java/kernel/loki/feature/info/InfoModule.java deleted file mode 100644 index 27a32de..0000000 --- a/src/main/java/kernel/loki/feature/info/InfoModule.java +++ /dev/null @@ -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 registrarBinder = - Multibinder.newSetBinder(binder(), CommandRegistrar.class); - registrarBinder.addBinding().to(InfoCommandRegistrar.class); - } -} diff --git a/src/main/java/kernel/loki/feature/info/command/AvatarCommand.java b/src/main/java/kernel/loki/feature/info/command/AvatarCommand.java deleted file mode 100644 index 4362525..0000000 --- a/src/main/java/kernel/loki/feature/info/command/AvatarCommand.java +++ /dev/null @@ -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(); - } -} diff --git a/src/main/java/kernel/loki/feature/info/command/PingCommand.java b/src/main/java/kernel/loki/feature/info/command/PingCommand.java deleted file mode 100644 index 6163b90..0000000 --- a/src/main/java/kernel/loki/feature/info/command/PingCommand.java +++ /dev/null @@ -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(); - } -} diff --git a/src/main/java/kernel/loki/feature/info/command/ServerInfoCommand.java b/src/main/java/kernel/loki/feature/info/command/ServerInfoCommand.java deleted file mode 100644 index ad5393c..0000000 --- a/src/main/java/kernel/loki/feature/info/command/ServerInfoCommand.java +++ /dev/null @@ -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(); - } -} diff --git a/src/main/java/kernel/loki/util/LoggerDecorator.java b/src/main/java/kernel/loki/util/LoggerDecorator.java deleted file mode 100644 index 94bcb4d..0000000 --- a/src/main/java/kernel/loki/util/LoggerDecorator.java +++ /dev/null @@ -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); - } -} diff --git a/src/main/java/kernel/loki/util/MessageUtil.java b/src/main/java/kernel/loki/util/MessageUtil.java deleted file mode 100644 index c500128..0000000 --- a/src/main/java/kernel/loki/util/MessageUtil.java +++ /dev/null @@ -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) + "..."; - } -} diff --git a/src/main/java/kernel/loki/util/TimeUtil.java b/src/main/java/kernel/loki/util/TimeUtil.java deleted file mode 100644 index 3b65559..0000000 --- a/src/main/java/kernel/loki/util/TimeUtil.java +++ /dev/null @@ -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; - } -} diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml deleted file mode 100644 index 5ccb46f..0000000 --- a/src/main/resources/logback.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - %d{HH:mm:ss} [%highlight(%-5level)] [%marker] %msg%n - - - - - logs/bot-kernel.log - - logs/bot-kernel.%d{yyyy-MM-dd}.log - 7 - - - %d{yyyy-MM-dd HH:mm:ss} [%level] [%marker] [%thread] %msg%n - - - - - - - - diff --git a/src/test/java/kernel/loki/command/CommandRegistryTest.java b/src/test/java/kernel/loki/command/CommandRegistryTest.java deleted file mode 100644 index 032ddaa..0000000 --- a/src/test/java/kernel/loki/command/CommandRegistryTest.java +++ /dev/null @@ -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) {} - } -} diff --git a/src/test/java/kernel/loki/config/BotConfigTest.java b/src/test/java/kernel/loki/config/BotConfigTest.java deleted file mode 100644 index 2ca98a1..0000000 --- a/src/test/java/kernel/loki/config/BotConfigTest.java +++ /dev/null @@ -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)); - } -} diff --git a/src/test/java/kernel/loki/core/security/RateLimiterTest.java b/src/test/java/kernel/loki/core/security/RateLimiterTest.java deleted file mode 100644 index 6823a06..0000000 --- a/src/test/java/kernel/loki/core/security/RateLimiterTest.java +++ /dev/null @@ -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()); - } -} diff --git a/src/test/java/kernel/loki/embed/EmbedFactoryTest.java b/src/test/java/kernel/loki/embed/EmbedFactoryTest.java deleted file mode 100644 index 75cf923..0000000 --- a/src/test/java/kernel/loki/embed/EmbedFactoryTest.java +++ /dev/null @@ -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); - } -} diff --git a/src/util/i18n.rs b/src/util/i18n.rs new file mode 100644 index 0000000..5476212 --- /dev/null +++ b/src/util/i18n.rs @@ -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 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", + _ => "-", + } +} diff --git a/src/util/logger.rs b/src/util/logger.rs new file mode 100644 index 0000000..21d26d9 --- /dev/null +++ b/src/util/logger.rs @@ -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(); +} diff --git a/src/util/mod.rs b/src/util/mod.rs new file mode 100644 index 0000000..c63a5e3 --- /dev/null +++ b/src/util/mod.rs @@ -0,0 +1,2 @@ +pub mod i18n; +pub mod logger;