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 9bbc975..0000000 Binary files a/gradle/wrapper/gradle-wrapper.jar and /dev/null differ 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;