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