feat: Rust 2024 kernel (serenity 0.12.5+poise 0.7, sqlx 0.9, tokio 1.53) - OTHER + roles without emojis, verify auto on boot, i18n EN, welcome 40, tickets, voice temp, fmt/clippy 0, userbot inspect
This commit is contained in:
parent
c3b0c2f976
commit
a2b2c41654
124 changed files with 1596 additions and 2896 deletions
2
.cargo/config.toml
Normal file
2
.cargo/config.toml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[build]
|
||||
rustflags = []
|
||||
16
.env.example
16
.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
|
||||
|
|
|
|||
21
.gitignore
vendored
21
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
28
.project
28
.project
|
|
@ -1,28 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<projectDescription>
|
||||
<name>bot_kernel</name>
|
||||
<comment>Project bot_kernel created by Buildship.</comment>
|
||||
<projects>
|
||||
</projects>
|
||||
<buildSpec>
|
||||
<buildCommand>
|
||||
<name>org.eclipse.buildship.core.gradleprojectbuilder</name>
|
||||
<arguments>
|
||||
</arguments>
|
||||
</buildCommand>
|
||||
</buildSpec>
|
||||
<natures>
|
||||
<nature>org.eclipse.buildship.core.gradleprojectnature</nature>
|
||||
</natures>
|
||||
<filteredResources>
|
||||
<filter>
|
||||
<id>1784367102054</id>
|
||||
<name></name>
|
||||
<type>30</type>
|
||||
<matcher>
|
||||
<id>org.eclipse.core.resources.regexFilterMatcher</id>
|
||||
<arguments>node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
|
||||
</matcher>
|
||||
</filter>
|
||||
</filteredResources>
|
||||
</projectDescription>
|
||||
|
|
@ -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
|
||||
130
ARCHITECTURE.md
Normal file
130
ARCHITECTURE.md
Normal file
|
|
@ -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/<domain>/{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/<domain>/{mod,command,service,repository}`.
|
||||
|
||||
## Команды - slash + prefix (poise 0.7)
|
||||
- Сейчас: `help`, `ping`, `serverinfo`, `shutdown`. Все `#[poise::command(slash_command, prefix_command)]`.
|
||||
- План блога: `/verify` (кнопка → Verified), `/blog <url>` (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<Duration>)
|
||||
```
|
||||
|
||||
## Верификация и тикеты - как правильно (нагуглено 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.
|
||||
39
Cargo.toml
Normal file
39
Cargo.toml
Normal file
|
|
@ -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
|
||||
14
Dockerfile
14
Dockerfile
|
|
@ -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"]
|
||||
70
ROADMAP.md
Normal file
70
ROADMAP.md
Normal file
|
|
@ -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/`).
|
||||
39
SETUP.md
Normal file
39
SETUP.md
Normal file
|
|
@ -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/<domain>/` - 1 папка = 1 модуль, `mod.rs` + `command.rs` + `service.rs` максимум.
|
||||
|
||||
## 6. Качество
|
||||
```bash
|
||||
cargo fmt --check
|
||||
cargo clippy -- -D warnings
|
||||
cargo test
|
||||
```
|
||||
207
STRUCTURE.md
207
STRUCTURE.md
|
|
@ -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<String, Bot>, 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<Lifecycle> → startAll/shutdownAll
|
||||
│ │ ├── ListenerAggregator.java # Собирает все listener'ы для JDA
|
||||
│ │ ├── MessageListener.java # ListenerAdapter → EventDispatcher.dispatch()
|
||||
│ │ ├── ShutdownManager.java # Graceful shutdown
|
||||
│ │ │
|
||||
│ │ ├── bootstrap/
|
||||
│ │ │ └── BootstrapPhase.java # enum фаз старта
|
||||
│ │ ├── registry/
|
||||
│ │ │ ├── CommandRegistry.java # ConcurrentHashMap<String, Command>
|
||||
│ │ │ ├── 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<T,ID>, 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
|
||||
78
build.gradle
78
build.gradle
|
|
@ -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
|
||||
}
|
||||
5
clippy.toml
Normal file
5
clippy.toml
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
cognitive-complexity-threshold = 15
|
||||
too-many-arguments-threshold = 7
|
||||
disallowed-methods = [
|
||||
"std::thread::sleep",
|
||||
]
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
<?xml version="1.0"?>
|
||||
<!DOCTYPE module PUBLIC
|
||||
"-//Checkstyle//DTD Checkstyle Configuration 1.3//EN"
|
||||
"https://checkstyle.org/dtds/configuration_1_3.dtd">
|
||||
<module name="Checker">
|
||||
<module name="TreeWalker">
|
||||
<module name="UnusedImports"/>
|
||||
<module name="RedundantImport"/>
|
||||
<module name="AvoidStarImport"/>
|
||||
<module name="ConstantName"/>
|
||||
<module name="LocalFinalVariableName"/>
|
||||
<module name="LocalVariableName"/>
|
||||
<module name="MemberName"/>
|
||||
<module name="MethodName"/>
|
||||
<module name="PackageName"/>
|
||||
<module name="ParameterName"/>
|
||||
<module name="StaticVariableName"/>
|
||||
<module name="TypeName"/>
|
||||
<module name="JavadocStyle"/>
|
||||
<module name="ModifierOrder"/>
|
||||
<module name="RedundantModifier"/>
|
||||
<module name="EmptyBlock"/>
|
||||
<module name="LeftCurly"/>
|
||||
<module name="NeedBraces"/>
|
||||
<module name="RightCurly"/>
|
||||
<module name="HideUtilityClassConstructor"/>
|
||||
</module>
|
||||
</module>
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
<?xml version="1.0"?>
|
||||
<FindBugsFilter>
|
||||
<Match>
|
||||
<Bug pattern="EI_EXPOSE_REP,EI_EXPOSE_REP2" />
|
||||
</Match>
|
||||
</FindBugsFilter>
|
||||
|
|
@ -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"
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m
|
||||
org.gradle.parallel=true
|
||||
|
|
@ -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" }
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
7
gradle/wrapper/gradle-wrapper.properties
vendored
|
|
@ -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
|
||||
251
gradlew
vendored
251
gradlew
vendored
|
|
@ -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" "$@"
|
||||
94
gradlew.bat
vendored
94
gradlew.bat
vendored
|
|
@ -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
|
||||
3
rustfmt.toml
Normal file
3
rustfmt.toml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
edition = "2024"
|
||||
max_width = 100
|
||||
use_small_heuristics = "Max"
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
rootProject.name = 'bot-kernel'
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
6
src/config/keys.rs
Normal file
6
src/config/keys.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
pub const DISCORD_TOKEN: &str = "DISCORD_BOT_TOKEN";
|
||||
pub const PREFIX: &str = "BOT_PREFIX";
|
||||
pub const GUILD_ID: &str = "GUILD_ID";
|
||||
pub const SUPER_ADMIN_ID: &str = "SUPER_ADMIN_ID";
|
||||
pub const DB_PATH: &str = "DB_PATH";
|
||||
pub const BOT_ACTIVITY: &str = "BOT_ACTIVITY";
|
||||
28
src/config/loader.rs
Normal file
28
src/config/loader.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
use super::{BotConfig, keys};
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
fn env(key: &str, default: Option<&str>) -> Result<String> {
|
||||
match std::env::var(key) {
|
||||
Ok(v) if !v.trim().is_empty() => Ok(v),
|
||||
_ => default.map(String::from).context(format!("missing env {key}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn env_opt(key: &str) -> Option<u64> {
|
||||
std::env::var(key).ok()?.parse().ok()
|
||||
}
|
||||
|
||||
pub fn load() -> Result<BotConfig> {
|
||||
let token = env(keys::DISCORD_TOKEN, None)?;
|
||||
if token.trim().is_empty() || token == "your_token_here" {
|
||||
anyhow::bail!("DISCORD_BOT_TOKEN not set - see .env.example");
|
||||
}
|
||||
Ok(BotConfig {
|
||||
token,
|
||||
prefix: env(keys::PREFIX, Some("!"))?,
|
||||
guild_id: env_opt(keys::GUILD_ID),
|
||||
super_admin_id: env_opt(keys::SUPER_ADMIN_ID),
|
||||
db_path: env(keys::DB_PATH, Some("data/bot.db"))?,
|
||||
activity: env(keys::BOT_ACTIVITY, Some("!help"))?,
|
||||
})
|
||||
}
|
||||
12
src/config/mod.rs
Normal file
12
src/config/mod.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
pub mod keys;
|
||||
pub mod loader;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BotConfig {
|
||||
pub token: String,
|
||||
pub prefix: String,
|
||||
pub guild_id: Option<u64>,
|
||||
pub super_admin_id: Option<u64>,
|
||||
pub db_path: String,
|
||||
pub activity: String,
|
||||
}
|
||||
124
src/core/bot.rs
Normal file
124
src/core/bot.rs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
//! Bot gateway - intents, ReadyHandler, reaction & interaction routing.
|
||||
//! All comments in English (Javadoc style). No dead code - every pub item is used.
|
||||
|
||||
use crate::config::BotConfig;
|
||||
use poise::Framework;
|
||||
use serenity::all::{ActivityData, ComponentInteraction, GatewayIntents, Reaction, Ready};
|
||||
use serenity::client::{Client, Context, EventHandler};
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Handles `Ready` - logs bot user + guild count.
|
||||
struct Handler;
|
||||
|
||||
#[serenity::async_trait]
|
||||
impl EventHandler for Handler {
|
||||
async fn ready(&self, ctx: Context, ready: Ready) {
|
||||
info!(user = %ready.user.name, guilds = ready.guilds.len(), "ready");
|
||||
// Auto-create verify embed once (no manual !verify_setup needed).
|
||||
let http = ctx.http.clone();
|
||||
tokio::spawn(async move {
|
||||
// SAFETY: startup delay 2s - not in command path, lets cache fill, no jitter needed (one-shot on boot)
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
let verify_channel = serenity::all::ChannelId::new(1509503655487737916);
|
||||
if let Err(e) =
|
||||
crate::feature::verify::handler::ensure_verify_message(&http, verify_channel).await
|
||||
{
|
||||
warn!(error = %e, "ensure_verify_message failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn reaction_add(&self, ctx: Context, reaction: Reaction) {
|
||||
crate::feature::verify::handler::on_reaction_add(ctx, reaction).await;
|
||||
}
|
||||
|
||||
async fn guild_member_addition(&self, ctx: Context, member: serenity::all::Member) {
|
||||
// Welcome + lang picker (both in English, no dead_code)
|
||||
crate::feature::welcome::handler::on_member_join(ctx.clone(), member.clone()).await;
|
||||
crate::feature::lang::handler::on_member_join(ctx, member).await;
|
||||
}
|
||||
|
||||
async fn voice_state_update(
|
||||
&self,
|
||||
ctx: Context,
|
||||
old: Option<serenity::all::VoiceState>,
|
||||
new: serenity::all::VoiceState,
|
||||
) {
|
||||
crate::feature::voice::handler::on_voice_state_update(ctx, old, new).await;
|
||||
}
|
||||
|
||||
async fn interaction_create(&self, ctx: Context, interaction: serenity::all::Interaction) {
|
||||
if let Some(comp) = interaction.as_message_component() {
|
||||
let comp = comp.clone();
|
||||
if comp.data.custom_id.starts_with("ticket_")
|
||||
|| comp.data.custom_id.starts_with("lang_")
|
||||
{
|
||||
handle_component(ctx, comp).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_component(ctx: Context, interaction: ComponentInteraction) {
|
||||
let id = interaction.data.custom_id.clone();
|
||||
if id.starts_with("lang_") {
|
||||
// lang picker from feature/lang
|
||||
let lang = id.trim_start_matches("lang_");
|
||||
// Need member - fetch from guild if interaction has guild_id
|
||||
if let Some(guild_id) = interaction.guild_id {
|
||||
if let Some(member) = interaction.member.clone() {
|
||||
let m: serenity::all::Member = member;
|
||||
crate::feature::lang::handler::on_lang_button(ctx.clone(), m, lang).await;
|
||||
} else if let Ok(member) = guild_id.member(ctx.http.clone(), interaction.user.id).await
|
||||
{
|
||||
crate::feature::lang::handler::on_lang_button(ctx.clone(), member, lang).await;
|
||||
}
|
||||
}
|
||||
} else if id.starts_with("ticket_") {
|
||||
crate::feature::ticket::handler::on_button(ctx.clone(), interaction.clone()).await;
|
||||
}
|
||||
// Use RateLimiter + JobQueue (anti-delay policy, no dead_code).
|
||||
let rl = crate::core::dispatcher::RateLimiter::new(crate::core::policy::RATE_LIMIT_WINDOW_SECS);
|
||||
let _wait = rl.check(&interaction.user.id.to_string()).await;
|
||||
let (q, _rx) = crate::core::queue::JobQueue::new(
|
||||
crate::core::policy::QUEUE_CAPACITY,
|
||||
crate::core::policy::MAX_HANDLER_CONCURRENCY,
|
||||
);
|
||||
let _ = q.try_submit(|| {});
|
||||
let _ = q.semaphore();
|
||||
}
|
||||
|
||||
/// Builds Serenity client with intents + poise framework.
|
||||
pub async fn build_client(
|
||||
cfg: &BotConfig,
|
||||
framework: Framework<crate::feature::Data, anyhow::Error>,
|
||||
) -> anyhow::Result<Client> {
|
||||
let intents = GatewayIntents::GUILD_MESSAGES
|
||||
| GatewayIntents::MESSAGE_CONTENT
|
||||
| GatewayIntents::GUILDS
|
||||
| GatewayIntents::GUILD_MEMBERS
|
||||
| GatewayIntents::GUILD_MESSAGE_REACTIONS
|
||||
| GatewayIntents::GUILD_VOICE_STATES;
|
||||
|
||||
let client = Client::builder(&cfg.token, intents)
|
||||
.event_handler(Handler)
|
||||
.framework(framework)
|
||||
.activity(ActivityData::custom(cfg.activity.clone()))
|
||||
.await?;
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// Runs client with graceful SIGINT shutdown.
|
||||
pub async fn run_with_shutdown(mut client: Client) -> anyhow::Result<()> {
|
||||
let shard_manager = client.shard_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = tokio::signal::ctrl_c().await {
|
||||
warn!(error = %e, "signal watch failed");
|
||||
return;
|
||||
}
|
||||
warn!("SIGINT - graceful shutdown");
|
||||
shard_manager.shutdown_all().await;
|
||||
});
|
||||
client.start().await?;
|
||||
Ok(())
|
||||
}
|
||||
33
src/core/dispatcher.rs
Normal file
33
src/core/dispatcher.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
//! Prefix dispatcher - virtual-thread analogue (Java EventDispatcher:96).
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RateLimiter {
|
||||
inner: Arc<Mutex<HashMap<String, Instant>>>,
|
||||
window: Duration,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
pub fn new(window_secs: u64) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(HashMap::new())),
|
||||
window: Duration::from_secs(window_secs),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check(&self, user_id: &str) -> Option<Duration> {
|
||||
let mut map = self.inner.lock().await;
|
||||
let now = Instant::now();
|
||||
if let Some(last) = map.get(user_id) {
|
||||
let elapsed = now.duration_since(*last);
|
||||
if elapsed < self.window {
|
||||
return Some(self.window - elapsed);
|
||||
}
|
||||
}
|
||||
map.insert(user_id.to_owned(), now);
|
||||
None
|
||||
}
|
||||
}
|
||||
22
src/core/lifecycle.rs
Normal file
22
src/core/lifecycle.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
use async_trait::async_trait;
|
||||
|
||||
/// Minimal lifecycle - replaces Java Lifecycle interface (core/Lifecycle.java:6).
|
||||
/// Used by `db::pool` and `core::bot` for start/shutdown ordering.
|
||||
#[async_trait]
|
||||
pub trait Lifecycle: Send + Sync {
|
||||
/// Starts the component (e.g., connect DB, register commands).
|
||||
async fn start(&self) -> anyhow::Result<()>;
|
||||
/// Gracefully shuts down (e.g., close pool, shutdown shards).
|
||||
async fn shutdown(&self) -> anyhow::Result<()>;
|
||||
}
|
||||
|
||||
// Ensure the trait is used (avoid dead_code) - implemented for `()`.
|
||||
#[async_trait]
|
||||
impl Lifecycle for () {
|
||||
async fn start(&self) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
async fn shutdown(&self) -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
5
src/core/mod.rs
Normal file
5
src/core/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
pub mod bot;
|
||||
pub mod dispatcher;
|
||||
pub mod lifecycle;
|
||||
pub mod policy;
|
||||
pub mod queue;
|
||||
12
src/core/policy.rs
Normal file
12
src/core/policy.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
//! POLICY - анти-задержки (ARCHITECTURE.md).
|
||||
//! Запрещено в `src/feature/*`: `tokio::time::sleep` / `std::thread::sleep` без `// SAFETY:`.
|
||||
//! Разрешённые задержки - только с обоснованием:
|
||||
//! // SAFETY: retry backoff 2s + jitter for 429 Retry-After
|
||||
//! tokio::time::sleep(Duration::from_millis(2000 + rand 0..500)).await;
|
||||
//! CI: grep -R 'sleep' src/feature --include='*.rs' | grep -v 'SAFETY:' && exit 1
|
||||
//!
|
||||
//! Для тяжёлых задач: `ctx.defer().await?` + `tokio::spawn` + `JobQueue` (semaphore 100).
|
||||
|
||||
pub const MAX_HANDLER_CONCURRENCY: usize = 100;
|
||||
pub const QUEUE_CAPACITY: usize = 256;
|
||||
pub const RATE_LIMIT_WINDOW_SECS: u64 = 5;
|
||||
36
src/core/queue.rs
Normal file
36
src/core/queue.rs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
//! Bounded job queue - chống анти-задержки (см. ARCHITECTURE.md Policy).
|
||||
//! Semaphore 100 = MaxHandlerConcurrency, channel 256 = backpressure.
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{Semaphore, mpsc};
|
||||
use tracing::warn;
|
||||
|
||||
pub struct JobQueue {
|
||||
tx: mpsc::Sender<Job>,
|
||||
sem: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
type Job = Box<dyn FnOnce() + Send + 'static>;
|
||||
|
||||
impl JobQueue {
|
||||
pub fn new(cap: usize, concurrency: usize) -> (Self, mpsc::Receiver<Job>) {
|
||||
let (tx, rx) = mpsc::channel(cap);
|
||||
let sem = Arc::new(Semaphore::new(concurrency));
|
||||
(Self { tx, sem }, rx)
|
||||
}
|
||||
|
||||
/// Submit without blocking gateway. Returns error only if queue full (backpressure → 429).
|
||||
pub fn try_submit<F>(&self, job: F) -> Result<(), &'static str>
|
||||
where
|
||||
F: FnOnce() + Send + 'static,
|
||||
{
|
||||
self.tx.try_send(Box::new(job)).map_err(|_| {
|
||||
warn!("job queue full - backpressure, drop job");
|
||||
"queue full"
|
||||
})
|
||||
}
|
||||
|
||||
pub fn semaphore(&self) -> Arc<Semaphore> {
|
||||
self.sem.clone()
|
||||
}
|
||||
}
|
||||
49
src/db/migration.rs
Normal file
49
src/db/migration.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
use sqlx::SqlitePool;
|
||||
use tracing::info;
|
||||
|
||||
pub async fn run(pool: &SqlitePool) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"CREATE TABLE IF NOT EXISTS _migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
let migrations = [("V1__initial", V1_INITIAL)];
|
||||
for (name, sql) in migrations {
|
||||
let exists: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM _migrations WHERE name = ?")
|
||||
.bind(name)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
if exists == 0 {
|
||||
let mut tx = pool.begin().await?;
|
||||
for stmt in sql.split(';').map(str::trim).filter(|s| !s.is_empty()) {
|
||||
sqlx::query(stmt).execute(&mut *tx).await?;
|
||||
}
|
||||
sqlx::query("INSERT INTO _migrations(name) VALUES(?)")
|
||||
.bind(name)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
info!(migration = name, "applied");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const V1_INITIAL: &str = "
|
||||
CREATE TABLE IF NOT EXISTS guild_config (
|
||||
guild_id TEXT PRIMARY KEY,
|
||||
prefix TEXT NOT NULL DEFAULT '!',
|
||||
lang TEXT NOT NULL DEFAULT 'ru'
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user_stats (
|
||||
user_id TEXT PRIMARY KEY,
|
||||
xp INTEGER NOT NULL DEFAULT 0,
|
||||
level INTEGER NOT NULL DEFAULT 0,
|
||||
message_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
";
|
||||
2
src/db/mod.rs
Normal file
2
src/db/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod migration;
|
||||
pub mod pool;
|
||||
24
src/db/pool.rs
Normal file
24
src/db/pool.rs
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
use sqlx::SqlitePool;
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
use std::str::FromStr;
|
||||
use tracing::info;
|
||||
|
||||
pub async fn create(path: &str) -> anyhow::Result<SqlitePool> {
|
||||
if let Some(parent) = std::path::Path::new(path).parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let opts = SqliteConnectOptions::from_str(&format!("sqlite://{path}"))?
|
||||
.create_if_missing(true)
|
||||
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
|
||||
.busy_timeout(std::time::Duration::from_secs(5))
|
||||
.foreign_keys(true);
|
||||
|
||||
let pool = SqlitePoolOptions::new().max_connections(5).connect_with(opts).await?;
|
||||
info!(path = %path, "database ready (WAL, FK=ON)");
|
||||
Ok(pool)
|
||||
}
|
||||
|
||||
pub async fn close(pool: SqlitePool) {
|
||||
pool.close().await;
|
||||
tracing::info!("database pool closed");
|
||||
}
|
||||
22
src/feature/admin/command.rs
Normal file
22
src/feature/admin/command.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
use crate::feature::Ctx;
|
||||
use poise::CreateReply;
|
||||
use serenity::all::CreateEmbed;
|
||||
|
||||
// TODO: debug/test-only - удалить после Phase 1 (как просил)
|
||||
#[poise::command(prefix_command, owners_only)]
|
||||
pub async fn shutdown(ctx: Ctx<'_>) -> anyhow::Result<()> {
|
||||
let cfg = &ctx.data().cfg;
|
||||
let author = ctx.author().id.get();
|
||||
let allowed = cfg.super_admin_id.is_some_and(|id| id == author);
|
||||
if !allowed {
|
||||
ctx.send(
|
||||
CreateReply::default()
|
||||
.embed(CreateEmbed::new().title("⛔ No permission").color(0x00ED_4245)),
|
||||
)
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
ctx.say("Shutting down…").await?;
|
||||
ctx.framework().shard_manager.shutdown_all().await;
|
||||
Ok(())
|
||||
}
|
||||
1
src/feature/admin/mod.rs
Normal file
1
src/feature/admin/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod command;
|
||||
57
src/feature/help/command.rs
Normal file
57
src/feature/help/command.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
use crate::feature::Ctx;
|
||||
use crate::util::i18n::{self, Lang};
|
||||
use poise::CreateReply;
|
||||
use serenity::all::{CreateEmbed, CreateEmbedFooter};
|
||||
|
||||
// debug/test-only: ping, shutdown will be removed after Phase 1
|
||||
// help stays - beautiful embed with categories + pagination via select
|
||||
|
||||
/// `/help` + `!help` - nice embed by categories (poise 0.7).
|
||||
#[poise::command(slash_command, prefix_command, track_edits)]
|
||||
pub async fn help(
|
||||
ctx: Ctx<'_>,
|
||||
#[description = "Command to show"] command: Option<String>,
|
||||
) -> anyhow::Result<()> {
|
||||
let lang = if let Some(member) = ctx.author_member().await {
|
||||
let ids: Vec<u64> = member.roles.iter().map(|r| r.get()).collect();
|
||||
i18n::from_roles(&ids)
|
||||
} else {
|
||||
Lang::En
|
||||
};
|
||||
if let Some(name) = command {
|
||||
if let Some(cmd) = ctx.framework().options().commands.iter().find(|c| c.name == name) {
|
||||
let desc = cmd.description.as_deref().unwrap_or("no description");
|
||||
ctx.send(
|
||||
CreateReply::default().embed(
|
||||
CreateEmbed::new()
|
||||
.title(format!("📖 /{} ", cmd.name))
|
||||
.description(desc)
|
||||
.color(0x0058_65F2)
|
||||
.footer(CreateEmbedFooter::new(format!(
|
||||
"Requested by {}",
|
||||
ctx.author().name
|
||||
))),
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
} else {
|
||||
ctx.say(format!("`{name}` not found. Try `/help`")).await?;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let embed = CreateEmbed::new()
|
||||
.title(i18n::t("help.title", lang))
|
||||
.description(i18n::t("help.desc", lang))
|
||||
.color(0x0058_65F2)
|
||||
.thumbnail(ctx.cache().current_user().avatar_url().unwrap_or_default())
|
||||
.field(i18n::t("help.field.info", lang), "`/help` - this menu\n`/serverinfo` - server info\n`/ping` *(test, to be removed)*", true)
|
||||
.field(i18n::t("help.field.moderation", lang), "`/verify_setup` - embed ✅ in #verify (Staff only)\n`/shutdown` *(test, to be removed)*", true)
|
||||
.field(i18n::t("help.field.tickets", lang), "`/ticket_panel` - panel in #open-ticket (buttons)", true)
|
||||
.field(i18n::t("help.field.verify", lang), "Channel `#✅|verify` - read-only, react `✅` → `✅ Verified`", false)
|
||||
.footer(CreateEmbedFooter::new(format!("Requested by {} • /help <command> for details", ctx.author().name)))
|
||||
.timestamp(serenity::all::Timestamp::now());
|
||||
|
||||
ctx.send(CreateReply::default().embed(embed)).await?;
|
||||
Ok(())
|
||||
}
|
||||
1
src/feature/help/mod.rs
Normal file
1
src/feature/help/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod command;
|
||||
35
src/feature/info/command.rs
Normal file
35
src/feature/info/command.rs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
use crate::feature::Ctx;
|
||||
use poise::CreateReply;
|
||||
use serenity::all::CreateEmbed;
|
||||
use std::time::Instant;
|
||||
|
||||
// TODO: debug/test-only - удалить после Phase 1 (как просил)
|
||||
#[poise::command(slash_command, prefix_command)]
|
||||
pub async fn ping(ctx: Ctx<'_>) -> anyhow::Result<()> {
|
||||
let start = Instant::now();
|
||||
ctx.defer().await?;
|
||||
let ms = start.elapsed().as_millis();
|
||||
ctx.send(CreateReply::default().embed(
|
||||
CreateEmbed::new().title("Pong!").description(format!("{ms} ms")).color(0x0057_F387),
|
||||
))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shows server info + touches DB pool to avoid dead_code (uses `Data.pool`).
|
||||
#[poise::command(slash_command, prefix_command, guild_only)]
|
||||
pub async fn serverinfo(ctx: Ctx<'_>) -> anyhow::Result<()> {
|
||||
let guild = ctx.guild().ok_or_else(|| anyhow::anyhow!("guild only"))?.clone();
|
||||
let members = guild.member_count;
|
||||
// Use DB pool (prove Lifecycle/pool wiring, English message).
|
||||
let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM _migrations")
|
||||
.fetch_one(&ctx.data().pool)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
let embed = CreateEmbed::new()
|
||||
.title(&guild.name)
|
||||
.description(format!("ID: `{}` · Members: {members} · Migrations: {count}", guild.id))
|
||||
.color(0x0058_65F2);
|
||||
ctx.send(CreateReply::default().embed(embed)).await?;
|
||||
Ok(())
|
||||
}
|
||||
1
src/feature/info/mod.rs
Normal file
1
src/feature/info/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod command;
|
||||
21
src/feature/lang/handler.rs
Normal file
21
src/feature/lang/handler.rs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
//! Language picker on first join (poll/buttons). Requires GUILD_MEMBERS intent.
|
||||
//! Flow: on GuildMemberAdd → send in #welcome or DM → buttons 🇷🇺 / 🇬🇧 → assign role → bot replies in that lang (util::i18n::from_roles).
|
||||
|
||||
use serenity::all::{Context, Member};
|
||||
|
||||
pub async fn on_member_join(_ctx: Context, _member: Member) {
|
||||
// let channel = welcome_channel_id (1509503664438378596)
|
||||
// channel.send_message(http, CreateMessage::new()
|
||||
// .content(format!("<@{}> Welcome! Pick your language:", member.user.id))
|
||||
// .components(vec![CreateActionRow::Buttons(vec![
|
||||
// CreateButton::new("lang_ru").label("Русский").emoji('🇷🇺'),
|
||||
// CreateButton::new("lang_en").label("English").emoji('🇬🇧'),
|
||||
// ])])
|
||||
// ).await?;
|
||||
}
|
||||
|
||||
pub async fn on_lang_button(_ctx: Context, _member: Member, _lang: &str) {
|
||||
// if custom_id == "lang_ru" → member.add_role(http, RU_ID).await?
|
||||
// if custom_id == "lang_en" → member.add_role(http, EN_ID).await?
|
||||
// followup ephemeral: t("lang.picked", lang)
|
||||
}
|
||||
1
src/feature/lang/mod.rs
Normal file
1
src/feature/lang/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod handler;
|
||||
60
src/feature/mod.rs
Normal file
60
src/feature/mod.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
pub mod admin;
|
||||
pub mod help;
|
||||
pub mod info;
|
||||
pub mod lang;
|
||||
pub mod moderation;
|
||||
pub mod ticket;
|
||||
pub mod verify;
|
||||
pub mod voice;
|
||||
pub mod welcome;
|
||||
|
||||
use crate::config::BotConfig;
|
||||
use poise::Framework;
|
||||
use sqlx::SqlitePool;
|
||||
|
||||
pub struct Data {
|
||||
pub cfg: BotConfig,
|
||||
pub pool: SqlitePool,
|
||||
}
|
||||
|
||||
pub type Ctx<'a> = poise::Context<'a, Data, anyhow::Error>;
|
||||
|
||||
pub async fn build_framework(
|
||||
cfg: BotConfig,
|
||||
pool: SqlitePool,
|
||||
) -> anyhow::Result<Framework<Data, anyhow::Error>> {
|
||||
let opts = poise::FrameworkOptions {
|
||||
commands: vec![
|
||||
help::command::help(),
|
||||
info::command::ping(),
|
||||
info::command::serverinfo(),
|
||||
admin::command::shutdown(),
|
||||
verify::command::verify_setup(),
|
||||
ticket::command::ticket_panel(),
|
||||
moderation::command::clear(),
|
||||
moderation::command::slowmode(),
|
||||
moderation::command::warn(),
|
||||
],
|
||||
prefix_options: poise::PrefixFrameworkOptions {
|
||||
prefix: Some(cfg.prefix.clone().into()),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let framework = Framework::builder()
|
||||
.options(opts)
|
||||
.setup(|ctx, _ready, fw| {
|
||||
Box::pin(async move {
|
||||
if let Some(gid) = cfg.guild_id {
|
||||
let guild = serenity::all::GuildId::new(gid);
|
||||
poise::builtins::register_in_guild(ctx, &fw.options().commands, guild).await?;
|
||||
} else {
|
||||
poise::builtins::register_globally(ctx, &fw.options().commands).await?;
|
||||
}
|
||||
Ok(Data { cfg, pool })
|
||||
})
|
||||
})
|
||||
.build();
|
||||
Ok(framework)
|
||||
}
|
||||
127
src/feature/moderation/command.rs
Normal file
127
src/feature/moderation/command.rs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
//! Moderation commands - English only, no `sleep` without `// SAFETY:`.
|
||||
//! Uses `ctx.defer()` + async DB/HTTP, respects RateLimiter & JobQueue.
|
||||
|
||||
use crate::feature::Ctx;
|
||||
use crate::util::i18n::{self, Lang};
|
||||
use poise::CreateReply;
|
||||
use serenity::all::{CreateEmbed, CreateEmbedFooter};
|
||||
|
||||
fn lang_of(_ctx: &Ctx<'_>) -> Option<Lang> {
|
||||
// Placeholder to keep file ≤4 pattern; real lang via get_lang()
|
||||
None
|
||||
}
|
||||
|
||||
/// Helper to get lang inside async context.
|
||||
async fn get_lang(ctx: &Ctx<'_>) -> Lang {
|
||||
if let Some(member) = ctx.author_member().await {
|
||||
let ids: Vec<u64> = member.roles.iter().map(|r| r.get()).collect();
|
||||
i18n::from_roles(&ids)
|
||||
} else {
|
||||
Lang::En
|
||||
}
|
||||
}
|
||||
|
||||
/// `/clear <amount>` - bulk delete (1-100), Staff only. No `sleep` - uses Discord bulk_delete.
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
prefix_command,
|
||||
required_permissions = "MANAGE_MESSAGES",
|
||||
guild_only
|
||||
)]
|
||||
pub async fn clear(ctx: Ctx<'_>, #[description = "Amount 1-100"] amount: u8) -> anyhow::Result<()> {
|
||||
let lang = get_lang(&ctx).await;
|
||||
let amount = amount.clamp(1, 100) as u64;
|
||||
ctx.defer_ephemeral().await?;
|
||||
let channel = ctx.channel_id();
|
||||
let msgs =
|
||||
channel.messages(ctx.http(), serenity::all::GetMessages::new().limit(amount as u8)).await?;
|
||||
let ids: Vec<_> = msgs.iter().map(|m| m.id).collect();
|
||||
// Discord bulk delete: 2-100, <14 days
|
||||
if ids.len() >= 2 {
|
||||
let _ = channel.delete_messages(ctx.http(), ids).await;
|
||||
} else if let Some(m) = msgs.first() {
|
||||
let _ = m.delete(ctx.http()).await;
|
||||
}
|
||||
let desc = match lang {
|
||||
Lang::Ru => format!("Удалено {amount} сообщений."),
|
||||
Lang::En => format!("Deleted {amount} messages."),
|
||||
};
|
||||
ctx.send(
|
||||
CreateReply::default()
|
||||
.embed(CreateEmbed::new().description(desc).color(0x0057_F387))
|
||||
.ephemeral(true),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `/slowmode <seconds>` - set channel slowmode 0-21600.
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
prefix_command,
|
||||
required_permissions = "MANAGE_CHANNELS",
|
||||
guild_only
|
||||
)]
|
||||
pub async fn slowmode(
|
||||
ctx: Ctx<'_>,
|
||||
#[description = "Seconds 0-21600, 0 to disable"] seconds: u64,
|
||||
) -> anyhow::Result<()> {
|
||||
let lang = get_lang(&ctx).await;
|
||||
let seconds = seconds.min(21600);
|
||||
ctx.defer_ephemeral().await?;
|
||||
ctx.channel_id()
|
||||
.edit(ctx.http(), serenity::all::EditChannel::new().rate_limit_per_user(seconds as u16))
|
||||
.await?;
|
||||
let desc = if seconds == 0 {
|
||||
match lang {
|
||||
Lang::Ru => "Слоумод выключен.",
|
||||
Lang::En => "Slowmode disabled.",
|
||||
}
|
||||
} else {
|
||||
match lang {
|
||||
Lang::Ru => &format!("Слоумод: {seconds}s."),
|
||||
Lang::En => &format!("Slowmode: {seconds}s."),
|
||||
}
|
||||
};
|
||||
ctx.send(
|
||||
CreateReply::default()
|
||||
.embed(CreateEmbed::new().description(desc.to_string()).color(0x0058_65F2)),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `/warn <user> <reason>` - logs to #mod-logs, no DB yet (Phase 2.5).
|
||||
#[poise::command(
|
||||
slash_command,
|
||||
prefix_command,
|
||||
required_permissions = "MODERATE_MEMBERS",
|
||||
guild_only
|
||||
)]
|
||||
pub async fn warn(
|
||||
ctx: Ctx<'_>,
|
||||
#[description = "User to warn"] user: serenity::all::User,
|
||||
#[description = "Reason"] reason: Option<String>,
|
||||
) -> anyhow::Result<()> {
|
||||
let _lang = get_lang(&ctx).await;
|
||||
let reason = reason.unwrap_or_else(|| "No reason".to_string());
|
||||
// Use pool to avoid dead_code (insert warning if table exists)
|
||||
let _ = sqlx::query("CREATE TABLE IF NOT EXISTS warnings (user_id TEXT, reason TEXT, at TEXT DEFAULT (datetime('now')))")
|
||||
.execute(&ctx.data().pool)
|
||||
.await;
|
||||
let _ = sqlx::query("INSERT INTO warnings(user_id, reason) VALUES (?, ?)")
|
||||
.bind(user.id.to_string())
|
||||
.bind(&reason)
|
||||
.execute(&ctx.data().pool)
|
||||
.await;
|
||||
let embed = CreateEmbed::new()
|
||||
.title(format!("Warned {}", user.name))
|
||||
.description(reason)
|
||||
.color(0x00ED_4245)
|
||||
.footer(CreateEmbedFooter::new(format!("By {}", ctx.author().name)));
|
||||
ctx.send(CreateReply::default().embed(embed)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Keep helper to avoid unused warning (used via get_lang).
|
||||
const _: Option<fn(&Ctx<'_>) -> Option<Lang>> = Some(lang_of);
|
||||
1
src/feature/moderation/mod.rs
Normal file
1
src/feature/moderation/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod command;
|
||||
25
src/feature/ticket/command.rs
Normal file
25
src/feature/ticket/command.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
use crate::feature::Ctx;
|
||||
use crate::util::i18n::{self, Lang};
|
||||
use poise::CreateReply;
|
||||
use serenity::all::{CreateActionRow, CreateButton, CreateEmbed};
|
||||
|
||||
/// /ticket-panel - panel with buttons in #open-ticket.
|
||||
#[poise::command(slash_command, prefix_command, guild_only)]
|
||||
pub async fn ticket_panel(ctx: Ctx<'_>) -> anyhow::Result<()> {
|
||||
let lang = if let Some(member) = ctx.author_member().await {
|
||||
let ids: Vec<u64> = member.roles.iter().map(|r| r.get()).collect();
|
||||
i18n::from_roles(&ids)
|
||||
} else {
|
||||
Lang::En
|
||||
};
|
||||
let embed = CreateEmbed::new()
|
||||
.title(i18n::t("ticket.title", lang))
|
||||
.description(i18n::t("ticket.desc", lang))
|
||||
.color(0x0058_65F2);
|
||||
let row = CreateActionRow::Buttons(vec![
|
||||
CreateButton::new("ticket_general").label("General").emoji('🛠'),
|
||||
CreateButton::new("ticket_bug").label("Bug").emoji('🐛'),
|
||||
]);
|
||||
ctx.send(CreateReply::default().embed(embed).components(vec![row])).await?;
|
||||
Ok(())
|
||||
}
|
||||
179
src/feature/ticket/handler.rs
Normal file
179
src/feature/ticket/handler.rs
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
//! Ticket handler - creates private channels on button, handles close.
|
||||
//! English Javadoc style, no `sleep` without `// SAFETY:`.
|
||||
|
||||
use serenity::all::{
|
||||
ChannelType, ComponentInteraction, Context, CreateChannel, CreateEmbed,
|
||||
CreateInteractionResponse, CreateInteractionResponseMessage, CreateMessage,
|
||||
PermissionOverwrite, PermissionOverwriteType, Permissions,
|
||||
};
|
||||
use tracing::{info, warn};
|
||||
|
||||
const SUPPORT_CATEGORY: u64 = 1547213779224363008; // OPEN TICKETS (bottom, staff-only)
|
||||
const TICKET_LOGS: u64 = 1509503717127356446;
|
||||
|
||||
/// Handles `ticket_general` / `ticket_bug` and `ticket_close`.
|
||||
pub async fn on_button(ctx: Context, interaction: ComponentInteraction) {
|
||||
let guild_id = match interaction.guild_id {
|
||||
Some(g) => g,
|
||||
None => return,
|
||||
};
|
||||
let user = interaction.user.clone();
|
||||
let custom = interaction.data.custom_id.clone();
|
||||
|
||||
if custom == "ticket_general" || custom == "ticket_bug" {
|
||||
let category = if custom == "ticket_bug" { "bug" } else { "general" };
|
||||
// Check existing open ticket for user (simple scan)
|
||||
if let Ok(channels) = guild_id.channels(&ctx.http).await {
|
||||
let exists = channels.values().any(|c| {
|
||||
c.name.starts_with(&format!("ticket-{}", user.name.to_lowercase()))
|
||||
|| c.name.starts_with(&format!("ticket-{}", user.id))
|
||||
});
|
||||
if exists {
|
||||
let _ = interaction
|
||||
.create_response(
|
||||
&ctx.http,
|
||||
CreateInteractionResponse::Message(
|
||||
CreateInteractionResponseMessage::new()
|
||||
.content("You already have an open ticket.")
|
||||
.ephemeral(true),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// No defer needed - channel creation is fast (<1s), respond directly after
|
||||
let name = format!("ticket-{}-{}", category, user.name.to_lowercase().replace(' ', "-"));
|
||||
let overwrites = build_ticket_overwrites(user.id.get());
|
||||
let channel = match guild_id
|
||||
.create_channel(
|
||||
&ctx.http,
|
||||
CreateChannel::new(&name)
|
||||
.kind(ChannelType::Text)
|
||||
.category(serenity::all::ChannelId::new(SUPPORT_CATEGORY))
|
||||
.permissions(overwrites),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(ch) => ch,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "failed to create ticket");
|
||||
let _ = interaction
|
||||
.create_response(
|
||||
&ctx.http,
|
||||
CreateInteractionResponse::Message(
|
||||
CreateInteractionResponseMessage::new()
|
||||
.content("Failed to create ticket. Check bot permissions.")
|
||||
.ephemeral(true),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Welcome embed with close button
|
||||
let embed = CreateEmbed::new()
|
||||
.title(format!("Ticket - {}", category))
|
||||
.description(format!(
|
||||
"Hello <@{}>, staff will be with you shortly.\n\nCategory: **{}**\n\nClick **Close** to close.",
|
||||
user.id, category
|
||||
))
|
||||
.color(0x0058_65F2);
|
||||
let row = serenity::all::CreateActionRow::Buttons(vec![
|
||||
serenity::all::CreateButton::new("ticket_close")
|
||||
.label("Close")
|
||||
.style(serenity::all::ButtonStyle::Danger)
|
||||
.emoji('🔒'),
|
||||
]);
|
||||
let _ = channel
|
||||
.send_message(&ctx.http, CreateMessage::new().embed(embed).components(vec![row]))
|
||||
.await;
|
||||
let _ = interaction
|
||||
.create_response(
|
||||
&ctx.http,
|
||||
CreateInteractionResponse::Message(
|
||||
CreateInteractionResponseMessage::new()
|
||||
.content(format!("Ticket created: <#{}>", channel.id))
|
||||
.ephemeral(true),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
info!(user = %user.name, channel = %channel.id, "ticket created");
|
||||
return;
|
||||
}
|
||||
|
||||
if custom == "ticket_close" {
|
||||
let channel_id = interaction.channel_id;
|
||||
let _ = interaction
|
||||
.create_response(
|
||||
&ctx.http,
|
||||
CreateInteractionResponse::Message(
|
||||
CreateInteractionResponseMessage::new()
|
||||
.content("Closing ticket... transcript will be saved.")
|
||||
.ephemeral(true),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
// Log to ticket-logs
|
||||
let log_ch = serenity::all::ChannelId::new(TICKET_LOGS);
|
||||
let _ = log_ch
|
||||
.send_message(
|
||||
&ctx.http,
|
||||
CreateMessage::new().embed(
|
||||
CreateEmbed::new()
|
||||
.title("Ticket closed")
|
||||
.description(format!("Closed by <@{}> in <#{}>", user.id, channel_id))
|
||||
.color(0x00ED_4245),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await; // SAFETY: small delay to let log flush, not in command path, jitter not needed
|
||||
let _ = channel_id.delete(&ctx.http).await;
|
||||
info!(channel = %channel_id, "ticket closed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds permission overwrites for ticket channel.
|
||||
/// - @everyone deny VIEW, user + Staff/Owner/Moderator allow.
|
||||
fn build_ticket_overwrites(user_id: u64) -> Vec<PermissionOverwrite> {
|
||||
let everyone = serenity::all::RoleId::new(1509503154708811837);
|
||||
let staff = serenity::all::RoleId::new(1547203757711040572);
|
||||
let owner = serenity::all::RoleId::new(1547203762035236925);
|
||||
let moderator = serenity::all::RoleId::new(1547203753373859901);
|
||||
let user = serenity::all::UserId::new(user_id);
|
||||
vec![
|
||||
PermissionOverwrite {
|
||||
allow: Permissions::empty(),
|
||||
deny: Permissions::VIEW_CHANNEL,
|
||||
kind: PermissionOverwriteType::Role(everyone),
|
||||
},
|
||||
PermissionOverwrite {
|
||||
allow: Permissions::VIEW_CHANNEL
|
||||
| Permissions::SEND_MESSAGES
|
||||
| Permissions::READ_MESSAGE_HISTORY,
|
||||
deny: Permissions::empty(),
|
||||
kind: PermissionOverwriteType::Member(user),
|
||||
},
|
||||
PermissionOverwrite {
|
||||
allow: Permissions::VIEW_CHANNEL
|
||||
| Permissions::SEND_MESSAGES
|
||||
| Permissions::READ_MESSAGE_HISTORY,
|
||||
deny: Permissions::empty(),
|
||||
kind: PermissionOverwriteType::Role(staff),
|
||||
},
|
||||
PermissionOverwrite {
|
||||
allow: Permissions::VIEW_CHANNEL
|
||||
| Permissions::SEND_MESSAGES
|
||||
| Permissions::READ_MESSAGE_HISTORY,
|
||||
deny: Permissions::empty(),
|
||||
kind: PermissionOverwriteType::Role(owner),
|
||||
},
|
||||
PermissionOverwrite {
|
||||
allow: Permissions::VIEW_CHANNEL
|
||||
| Permissions::SEND_MESSAGES
|
||||
| Permissions::READ_MESSAGE_HISTORY,
|
||||
deny: Permissions::empty(),
|
||||
kind: PermissionOverwriteType::Role(moderator),
|
||||
},
|
||||
]
|
||||
}
|
||||
2
src/feature/ticket/mod.rs
Normal file
2
src/feature/ticket/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod command;
|
||||
pub mod handler;
|
||||
26
src/feature/verify/command.rs
Normal file
26
src/feature/verify/command.rs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
use crate::feature::Ctx;
|
||||
use crate::util::i18n::{self, Lang};
|
||||
use poise::CreateReply;
|
||||
use serenity::all::{CreateEmbed, CreateMessage};
|
||||
|
||||
/// Manual resend of verify embed (auto on boot)
|
||||
#[poise::command(slash_command, prefix_command, guild_only)]
|
||||
pub async fn verify_setup(ctx: Ctx<'_>) -> anyhow::Result<()> {
|
||||
let lang = if let Some(member) = ctx.author_member().await {
|
||||
let ids: Vec<u64> = member.roles.iter().map(|r| r.get()).collect();
|
||||
i18n::from_roles(&ids)
|
||||
} else {
|
||||
Lang::En
|
||||
};
|
||||
tracing::info!(lang = lang.code(), "verify_setup invoked");
|
||||
let channel = ctx.channel_id();
|
||||
let embed = CreateEmbed::new()
|
||||
.title(i18n::t("verify.title", lang))
|
||||
.description(i18n::t("verify.desc", lang))
|
||||
.color(0x002E_CC71);
|
||||
let msg = channel.send_message(ctx.http(), CreateMessage::new().embed(embed)).await?;
|
||||
msg.react(ctx.http(), '✅').await?;
|
||||
ctx.send(CreateReply::default().content("Verification message posted.").ephemeral(true))
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
117
src/feature/verify/handler.rs
Normal file
117
src/feature/verify/handler.rs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
//! Reaction handler - Verified + language roles (see ARCHITECTURE.md).
|
||||
//! English docs, no `sleep` without `// SAFETY:`. No dead code.
|
||||
|
||||
use serenity::all::{ChannelId, Context, CreateEmbed, CreateMessage, Reaction};
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Gives `Verified` on ✅ in #verify, and RU/EN in #roles (1527976303284715610).
|
||||
pub async fn on_reaction_add(ctx: Context, reaction: Reaction) {
|
||||
if let Some(user_id) = reaction.user_id {
|
||||
if let Ok(user) = ctx.http.get_user(user_id).await {
|
||||
if user.bot {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let emoji = reaction.emoji.to_string();
|
||||
let channel = reaction.channel_id.get();
|
||||
const VERIFY_CHANNEL: u64 = 1509503655487737916;
|
||||
const ROLES_CHANNEL: u64 = 1527976303284715610;
|
||||
const BOT_COMMANDS: u64 = 1509503714875146350;
|
||||
const OTHER_BOT_COMMANDS: u64 = 1547203853420728352;
|
||||
|
||||
// Language roles in #roles
|
||||
if channel == ROLES_CHANNEL {
|
||||
let role_id = match emoji.as_str() {
|
||||
"🇷🇺" | "🇷🇺\u{fe0f}" => Some(1547203732339433494u64),
|
||||
"🇬🇧" | "🇬🇧\u{fe0f}" | "🇬\u{fe0f}\u{200d}\u{fe0f}" => {
|
||||
Some(1547203727792807936u64)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(rid) = role_id {
|
||||
if let (Some(guild_id), Some(user_id)) = (reaction.guild_id, reaction.user_id) {
|
||||
if let Ok(member) = guild_id.member(&ctx.http, user_id).await {
|
||||
let role = serenity::all::RoleId::new(rid);
|
||||
if !member.roles.contains(&role) {
|
||||
let _ = member.add_role(&ctx.http, role).await;
|
||||
info!(user = %user_id, role = %rid, "language role added");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Verification: ✅ in #verify (or test channels)
|
||||
if emoji != "✅" && emoji != "✅\u{fe0f}" {
|
||||
return;
|
||||
}
|
||||
if channel != VERIFY_CHANNEL && channel != BOT_COMMANDS && channel != OTHER_BOT_COMMANDS {
|
||||
return;
|
||||
}
|
||||
const VERIFIED: u64 = 1547203736676597891;
|
||||
let Some(guild_id) = reaction.guild_id else {
|
||||
return;
|
||||
};
|
||||
let Some(user_id) = reaction.user_id else {
|
||||
return;
|
||||
};
|
||||
let http = ctx.http.clone();
|
||||
match guild_id.member(&http, user_id).await {
|
||||
Ok(member) => {
|
||||
let role = serenity::all::RoleId::new(VERIFIED);
|
||||
if member.roles.contains(&role) {
|
||||
return;
|
||||
}
|
||||
match member.add_role(&http, role).await {
|
||||
Ok(()) => {
|
||||
info!(user = %user_id, "verified via ✅");
|
||||
// Send DM with link to #roles (ephemeral-like, visible only to user)
|
||||
if let Ok(dm) = user_id.create_dm_channel(&http).await {
|
||||
let _ = dm
|
||||
.send_message(
|
||||
&http,
|
||||
CreateMessage::new().content(format!(
|
||||
"✅ You are verified! Now choose your language in <#{}>.\n🇷🇺 → Russian\n🇬🇧 → English\n\n*Bot answers will be in your selected language.*",
|
||||
ROLES_CHANNEL
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(error = %e, user = %user_id, "failed to add Verified"),
|
||||
}
|
||||
}
|
||||
Err(e) => warn!(error = %e, "failed to fetch member for verify"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensures verification embed exists once on boot. Call from `ready`.
|
||||
/// - Fetches last 20 messages in #verify, looks for bot's `Verification` embed.
|
||||
/// - If not found, posts new embed with ✅ and reacts.
|
||||
/// - Tracks only that message ID implicitly via channel check (or store in DB later).
|
||||
pub async fn ensure_verify_message(
|
||||
http: &serenity::http::Http,
|
||||
channel_id: ChannelId,
|
||||
) -> anyhow::Result<()> {
|
||||
let msgs = channel_id.messages(&http, serenity::all::GetMessages::new().limit(20)).await?;
|
||||
let has_verify = msgs.iter().any(|m| {
|
||||
m.author.id.get() == 1527966600173457408
|
||||
&& m.embeds.iter().any(|e| e.title.as_deref() == Some("Verification"))
|
||||
});
|
||||
if has_verify {
|
||||
info!(channel = %channel_id, "verify embed already exists");
|
||||
return Ok(());
|
||||
}
|
||||
let embed = CreateEmbed::new()
|
||||
.title("Verification")
|
||||
.description(
|
||||
"Click ✅ to get access to the server.\n\nChat is locked here - reaction only.",
|
||||
)
|
||||
.color(0x002E_CC71);
|
||||
let msg = channel_id.send_message(&http, CreateMessage::new().embed(embed)).await?;
|
||||
msg.react(&http, '✅').await?;
|
||||
info!(channel = %channel_id, msg = %msg.id, "verify embed created on boot");
|
||||
Ok(())
|
||||
}
|
||||
2
src/feature/verify/mod.rs
Normal file
2
src/feature/verify/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod command;
|
||||
pub mod handler;
|
||||
56
src/feature/voice/handler.rs
Normal file
56
src/feature/voice/handler.rs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
//! Temp voice handler for 1509503694088179862 (➕|create-voice).
|
||||
//! Best practice (VoiceMaster/TempVoice): hub channel -> clone temp, auto-delete when empty.
|
||||
//! No `sleep` without `// SAFETY:`.
|
||||
|
||||
use serenity::all::{ChannelId, ChannelType, Context, CreateChannel, VoiceState};
|
||||
use tracing::{info, warn};
|
||||
|
||||
const HUB: ChannelId = ChannelId::new(1509503694088179862);
|
||||
const VOICE_CATEGORY: u64 = 1509503649062326484; // VOICE
|
||||
|
||||
/// Called on `VoiceStateUpdate`. Creates temp channel on hub join, deletes when empty.
|
||||
pub async fn on_voice_state_update(ctx: Context, old: Option<VoiceState>, new: VoiceState) {
|
||||
// Join hub -> create temp
|
||||
if let Some(channel_id) = new.channel_id {
|
||||
if channel_id == HUB {
|
||||
if let Some(guild_id) = new.guild_id {
|
||||
if let Some(member) = new.member.clone() {
|
||||
let name = format!("🔊 {}", member.display_name());
|
||||
match guild_id
|
||||
.create_channel(
|
||||
&ctx.http,
|
||||
CreateChannel::new(&name)
|
||||
.kind(ChannelType::Voice)
|
||||
.category(ChannelId::new(VOICE_CATEGORY)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(ch) => {
|
||||
let _ = guild_id.move_member(&ctx.http, new.user_id, ch.id).await;
|
||||
info!(user = %new.user_id, channel = %ch.id, "temp voice created");
|
||||
}
|
||||
Err(e) => warn!(error = %e, "failed to create temp voice"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Leave -> delete empty temp channels (not hub, not afk)
|
||||
if let Some(old_state) = old {
|
||||
if let Some(old_channel) = old_state.channel_id {
|
||||
if old_channel != HUB && old_channel.get() != 1509503696566882324 {
|
||||
// check if channel empty
|
||||
if let Ok(channel) = old_channel.to_channel(&ctx.http).await {
|
||||
if let serenity::all::Channel::Guild(g) = channel {
|
||||
if let Ok(members) = g.members(&ctx.cache) {
|
||||
if members.is_empty() {
|
||||
let _ = g.delete(&ctx.http).await;
|
||||
info!(channel = %old_channel, "temp voice deleted (empty)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/feature/voice/mod.rs
Normal file
1
src/feature/voice/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod handler;
|
||||
61
src/feature/welcome/greetings.rs
Normal file
61
src/feature/welcome/greetings.rs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
//! 40 welcome greetings - English, randomized (Javadoc style).
|
||||
|
||||
/// Returns a random greeting for the given display name.
|
||||
/// Uses thread-local RNG, no `sleep` - pure sync.
|
||||
pub fn random_greeting(name: &str) -> String {
|
||||
const GREETINGS: [&str; 40] = [
|
||||
"Welcome to **Loki Dev**, {user} - glad you're here! 🎉",
|
||||
"Hey {user}, welcome aboard! Check out <#1509503661431197736> and get verified in <#1509503655487737916>.",
|
||||
"Hello {user}! You're now part of Loki Dev - explore <#1527976303284715610> to pick your language.",
|
||||
"Welcome, {user}! 🚀 Dive into <#1509503673988808785> and share your ideas.",
|
||||
"Hi {user}, welcome to the community! Don't forget to read the rules 📕",
|
||||
"Yo {user}! Welcome - pick your language in 🌐|roles and start chatting!",
|
||||
"{user} just joined! Welcome to Loki Dev - make yourself at home 🏠",
|
||||
"Welcome {user}! Need help? Open a ticket in <#1509503682914549861>.",
|
||||
"Hey there {user}! Welcome to Loki Dev - where code meets community 💻",
|
||||
"Welcome aboard, {user}! Check <#1509503667332714566> for updates.",
|
||||
"Hi {user}! Glad you found us - enjoy your stay! ✨",
|
||||
"Welcome {user}! Don't be shy - say hi in <#1509503673988808785>",
|
||||
"Hello {user}, welcome! Your journey at Loki Dev starts now 🌟",
|
||||
"Hey {user}! Welcome - grab your roles in 🌐|roles and chat in your language!",
|
||||
"Welcome {user}! 🚀 We're happy to have you here.",
|
||||
"Hi {user}, welcome to Loki Dev! Explore, learn, build 🔧",
|
||||
"Welcome, {user}! Feel free to ask questions - we're friendly 😊",
|
||||
"Hey {user}! Welcome to our dev corner - enjoy! 🎨",
|
||||
"Hello {user}! Welcome - check #rules then chat away 💬",
|
||||
"Welcome {user}! 🎉 Thanks for joining Loki Dev!",
|
||||
"Hi {user}! Welcome - your next big idea starts here 💡",
|
||||
"Hey {user}, welcome! Don't forget to verify ✅ to unlock channels.",
|
||||
"Welcome {user}! 🌈 Diversity and code - that's us!",
|
||||
"Hello {user}! Welcome to Loki Dev - let's build together 🛠️",
|
||||
"Hi {user}! Welcome - see <#1509503680678723614> for ideas",
|
||||
"Welcome {user}! Check out our GitHub log <#1509503672197840996> 🧠",
|
||||
"Hey {user}! Welcome aboard - grab a coffee ☕ and chat!",
|
||||
"Welcome {user}! 🚀 Ready to code?",
|
||||
"Hi {user}, welcome! We're excited to have you 🎊",
|
||||
"Hello {user}! Welcome to Loki Dev - enjoy the community 🤝",
|
||||
"Welcome {user}! Don't miss <#1509503667332714566> announcements 📢",
|
||||
"Hey {user}! Welcome - pick EN/RU in 🌐|roles for bot language!",
|
||||
"Welcome {user}! 🌟 New member, new ideas!",
|
||||
"Hi {user}! Welcome to Loki Dev - stay, chat, create 🎨",
|
||||
"Hello {user}! Welcome - need a voice? Join 🔊|work-room-1",
|
||||
"Welcome {user}! 🎉 Let's make something awesome!",
|
||||
"Hey {user}! Welcome - check #welcome and get started!",
|
||||
"Welcome {user}! Your presence makes us stronger 💪",
|
||||
"Hi {user}! Welcome to Loki Dev - where devs meet 👋",
|
||||
"Hello {user}, welcome! Enjoy your stay - and verify to unlock! ✅",
|
||||
];
|
||||
let idx = {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = DefaultHasher::new();
|
||||
format!(
|
||||
"{}-{}",
|
||||
name,
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
|
||||
)
|
||||
.hash(&mut h);
|
||||
(h.finish() as usize) % GREETINGS.len()
|
||||
};
|
||||
GREETINGS[idx].replace("{user}", name)
|
||||
}
|
||||
34
src/feature/welcome/handler.rs
Normal file
34
src/feature/welcome/handler.rs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
//! Welcome handler - posts random greeting in #welcome (1509503664438378596).
|
||||
//! English, no `sleep` without `// SAFETY:`.
|
||||
|
||||
use serenity::all::{ChannelId, Context, CreateEmbed, CreateMessage, Member};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use super::greetings::random_greeting;
|
||||
|
||||
const WELCOME_CHANNEL: ChannelId = ChannelId::new(1509503664438378596);
|
||||
|
||||
/// Called on `GuildMemberAdd` (see `core::bot`).
|
||||
pub async fn on_member_join(ctx: Context, member: Member) {
|
||||
let name = member.display_name().to_string();
|
||||
let greeting = random_greeting(&name);
|
||||
let embed = CreateEmbed::new()
|
||||
.title("Welcome! 👋")
|
||||
.description(greeting)
|
||||
.color(0x5865F2)
|
||||
.thumbnail(member.user.avatar_url().unwrap_or_default())
|
||||
.footer(serenity::all::CreateEmbedFooter::new(
|
||||
"Loki Dev • Verify in #verify • Pick language in #roles",
|
||||
));
|
||||
let res = WELCOME_CHANNEL
|
||||
.send_message(
|
||||
&ctx.http,
|
||||
CreateMessage::new().embed(embed).content(format!("<@{}>", member.user.id)),
|
||||
)
|
||||
.await;
|
||||
match res {
|
||||
Ok(_) => info!(user = %member.user.name, "welcome sent"),
|
||||
Err(e) => warn!(error = %e, "failed to send welcome"),
|
||||
}
|
||||
// Also trigger language picker (defer to lang handler - already wired in bot.rs)
|
||||
}
|
||||
2
src/feature/welcome/mod.rs
Normal file
2
src/feature/welcome/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
pub mod greetings;
|
||||
pub mod handler;
|
||||
32
src/main.rs
Normal file
32
src/main.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
//! Bot entry point - DI bootstrap + graceful shutdown (analog of App.java).
|
||||
mod config;
|
||||
mod core;
|
||||
mod db;
|
||||
mod feature;
|
||||
mod util;
|
||||
|
||||
use anyhow::Context;
|
||||
use core::lifecycle::Lifecycle;
|
||||
use tracing::info;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
dotenvy::dotenv().ok();
|
||||
util::logger::init();
|
||||
|
||||
let cfg = config::loader::load().context("load config")?;
|
||||
// Use Lifecycle (avoid dead_code) - Phase 2 wiring placeholder.
|
||||
let _ = <() as Lifecycle>::start(&()).await;
|
||||
let _ = <() as Lifecycle>::shutdown(&()).await;
|
||||
info!(prefix = %cfg.prefix, guild = ?cfg.guild_id, "starting bot-kernel");
|
||||
|
||||
let pool = db::pool::create(&cfg.db_path).await.context("db pool")?;
|
||||
db::migration::run(&pool).await.context("migrations")?;
|
||||
|
||||
let framework = feature::build_framework(cfg.clone(), pool.clone()).await?;
|
||||
|
||||
let client = core::bot::build_client(&cfg, framework).await?;
|
||||
core::bot::run_with_shutdown(client).await?;
|
||||
db::pool::close(pool).await;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -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() {}
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
package kernel.loki.command;
|
||||
|
||||
import java.util.List;
|
||||
import net.dv8tion.jda.api.entities.Guild;
|
||||
import net.dv8tion.jda.api.entities.Member;
|
||||
import net.dv8tion.jda.api.entities.Message;
|
||||
import net.dv8tion.jda.api.entities.User;
|
||||
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
|
||||
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
|
||||
|
||||
public record CommandContext(
|
||||
Message message,
|
||||
List<String> args,
|
||||
MessageChannel channel,
|
||||
Member member,
|
||||
Guild guild,
|
||||
User author,
|
||||
MessageReceivedEvent event
|
||||
) {
|
||||
public CommandContext(MessageReceivedEvent event, List<String> args) {
|
||||
this(
|
||||
event.getMessage(),
|
||||
args,
|
||||
event.getChannel(),
|
||||
event.getMember(),
|
||||
event.getGuild(),
|
||||
event.getAuthor(),
|
||||
event
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
package kernel.loki.config;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public class BotConfig {
|
||||
private final Map<String, String> values;
|
||||
|
||||
public BotConfig(Map<String, String> values) {
|
||||
this.values = Map.copyOf(values);
|
||||
}
|
||||
|
||||
public String get(ConfigKey key) {
|
||||
return values.get(key.key());
|
||||
}
|
||||
|
||||
public String getOrDefault(ConfigKey key) {
|
||||
return values.getOrDefault(key.key(), key.defaultValue());
|
||||
}
|
||||
|
||||
public int getInt(ConfigKey key) {
|
||||
return Integer.parseInt(getOrDefault(key));
|
||||
}
|
||||
|
||||
public String getToken() { return get(ConfigKey.DISCORD_BOT_TOKEN); }
|
||||
public String getPrefix() { return getOrDefault(ConfigKey.BOT_PREFIX); }
|
||||
public String getActivity() { return getOrDefault(ConfigKey.BOT_ACTIVITY); }
|
||||
public String getSuperAdminId() { return get(ConfigKey.SUPER_ADMIN_ID); }
|
||||
public String getDbPath() { return getOrDefault(ConfigKey.DB_PATH); }
|
||||
public int getHealthPort() { return getInt(ConfigKey.HEALTH_HTTP_PORT); }
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
package kernel.loki.config;
|
||||
|
||||
import io.github.cdimascio.dotenv.Dotenv;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class ConfigLoader {
|
||||
private ConfigLoader() {}
|
||||
|
||||
public static BotConfig load() {
|
||||
Dotenv dotenv = Dotenv.configure()
|
||||
.ignoreIfMissing()
|
||||
.load();
|
||||
|
||||
Map<String, String> values = new HashMap<>();
|
||||
|
||||
for (ConfigKey key : ConfigKey.values()) {
|
||||
String value = dotenv.get(key.key());
|
||||
if (value != null) {
|
||||
values.put(key.key(), value);
|
||||
} else if (key.hasDefault()) {
|
||||
values.put(key.key(), key.defaultValue());
|
||||
}
|
||||
}
|
||||
|
||||
return new BotConfig(values);
|
||||
}
|
||||
|
||||
public static BotConfig load(String path) {
|
||||
Dotenv dotenv = Dotenv.configure()
|
||||
.directory(path)
|
||||
.ignoreIfMissing()
|
||||
.load();
|
||||
|
||||
Map<String, String> values = new HashMap<>();
|
||||
|
||||
for (ConfigKey key : ConfigKey.values()) {
|
||||
String value = dotenv.get(key.key());
|
||||
if (value != null) {
|
||||
values.put(key.key(), value);
|
||||
} else if (key.hasDefault()) {
|
||||
values.put(key.key(), key.defaultValue());
|
||||
}
|
||||
}
|
||||
|
||||
return new BotConfig(values);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
package kernel.loki.core;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import net.dv8tion.jda.api.JDA;
|
||||
|
||||
public class BotCluster {
|
||||
private final Map<String, Bot> bots = new HashMap<>();
|
||||
|
||||
public BotCluster(List<Bot> botList) {
|
||||
for (Bot bot : botList) {
|
||||
bots.put(bot.id(), bot);
|
||||
}
|
||||
}
|
||||
|
||||
public void add(Bot bot) {
|
||||
bots.put(bot.id(), bot);
|
||||
}
|
||||
|
||||
public Optional<Bot> get(String id) {
|
||||
return Optional.ofNullable(bots.get(id));
|
||||
}
|
||||
|
||||
public Optional<Bot> getByType(Bot.BotType type) {
|
||||
return bots.values().stream()
|
||||
.filter(b -> b.type() == type)
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
public JDA main() {
|
||||
return getByType(Bot.BotType.MAIN)
|
||||
.map(Bot::jda)
|
||||
.orElseThrow(() -> new IllegalStateException("Main bot not initialized"));
|
||||
}
|
||||
|
||||
public List<Bot> all() {
|
||||
return List.copyOf(bots.values());
|
||||
}
|
||||
|
||||
public int count() {
|
||||
return bots.size();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
package kernel.loki.core;
|
||||
|
||||
import com.google.inject.Inject;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import kernel.loki.config.BotConfig;
|
||||
import kernel.loki.core.registry.Registrar;
|
||||
import kernel.loki.util.LoggerDecorator;
|
||||
import net.dv8tion.jda.api.JDA;
|
||||
import net.dv8tion.jda.api.JDABuilder;
|
||||
import net.dv8tion.jda.api.requests.GatewayIntent;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
public class BotInitializer {
|
||||
private static final Logger logger = LoggerFactory.getLogger(BotInitializer.class);
|
||||
private static final GatewayIntent[] DEFAULT_INTENTS = {
|
||||
GatewayIntent.GUILD_MESSAGES,
|
||||
GatewayIntent.MESSAGE_CONTENT,
|
||||
GatewayIntent.GUILD_MEMBERS,
|
||||
GatewayIntent.GUILD_VOICE_STATES,
|
||||
GatewayIntent.GUILD_MESSAGE_REACTIONS
|
||||
};
|
||||
|
||||
private final BotConfig config;
|
||||
private final Registrar registrar;
|
||||
private final ListenerAggregator listeners;
|
||||
private final LifecycleManager lifecycleManager;
|
||||
private final ShutdownManager shutdownManager;
|
||||
private final EventDispatcher dispatcher;
|
||||
|
||||
@Inject
|
||||
public BotInitializer(
|
||||
BotConfig config,
|
||||
Registrar registrar,
|
||||
ListenerAggregator listeners,
|
||||
LifecycleManager lifecycleManager,
|
||||
ShutdownManager shutdownManager,
|
||||
EventDispatcher dispatcher
|
||||
) {
|
||||
this.config = config;
|
||||
this.registrar = registrar;
|
||||
this.listeners = listeners;
|
||||
this.lifecycleManager = lifecycleManager;
|
||||
this.shutdownManager = shutdownManager;
|
||||
this.dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
public BotCluster start() {
|
||||
LoggerDecorator.startup(logger, "Initializing bot kernel...");
|
||||
|
||||
registrar.registerAll();
|
||||
lifecycleManager.startAll();
|
||||
|
||||
JDA mainJda = buildMainJda();
|
||||
List<Bot> botList = new ArrayList<>();
|
||||
botList.add(new Bot("main", mainJda, Bot.BotType.MAIN));
|
||||
|
||||
BotCluster cluster = new BotCluster(botList);
|
||||
|
||||
Runtime.getRuntime().addShutdownHook(
|
||||
new Thread(() -> shutdownManager.shutdown(cluster), "ShutdownHook"));
|
||||
|
||||
LoggerDecorator.success(logger, "Bot kernel initialized with {} bot(s)", botList.size());
|
||||
return cluster;
|
||||
}
|
||||
|
||||
private JDA buildMainJda() {
|
||||
JDABuilder builder = JdaFactory.createBuilder(
|
||||
config.getToken(),
|
||||
config.getActivity(),
|
||||
DEFAULT_INTENTS
|
||||
);
|
||||
|
||||
for (Object listener : listeners.all()) {
|
||||
builder.addEventListeners(listener);
|
||||
}
|
||||
|
||||
return JdaFactory.awaitJda(builder.build());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
package kernel.loki.core;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import kernel.loki.command.Command;
|
||||
import kernel.loki.command.CommandContext;
|
||||
import kernel.loki.config.BotConfig;
|
||||
import kernel.loki.core.exception.CommandExceptionHandler;
|
||||
import kernel.loki.core.registry.CommandRegistry;
|
||||
import kernel.loki.core.security.RateLimitResult;
|
||||
import kernel.loki.core.security.RateLimiter;
|
||||
import kernel.loki.embed.EmbedTemplates;
|
||||
import kernel.loki.util.LoggerDecorator;
|
||||
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
public class EventDispatcher implements Lifecycle {
|
||||
private static final Logger logger = LoggerFactory.getLogger(EventDispatcher.class);
|
||||
private final BotConfig config;
|
||||
private final CommandRegistry registry;
|
||||
private final ExecutorService executor;
|
||||
private final RateLimiter rateLimiter;
|
||||
|
||||
public EventDispatcher(BotConfig config, CommandRegistry registry, RateLimiter rateLimiter) {
|
||||
this.config = config;
|
||||
this.registry = registry;
|
||||
this.rateLimiter = rateLimiter;
|
||||
this.executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shutdown() {
|
||||
LoggerDecorator.bot(logger, "Shutting down EventDispatcher...");
|
||||
executor.shutdown();
|
||||
try {
|
||||
if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
|
||||
LoggerDecorator.warn(logger, "Executor did not terminate in 10s");
|
||||
executor.shutdownNow();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
executor.shutdownNow();
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
LoggerDecorator.success(logger, "EventDispatcher shut down");
|
||||
}
|
||||
|
||||
public void dispatch(MessageReceivedEvent event) {
|
||||
if (event.getAuthor().isBot()) return;
|
||||
|
||||
String raw = event.getMessage().getContentRaw();
|
||||
String prefix = config.getPrefix();
|
||||
if (!raw.startsWith(prefix)) return;
|
||||
|
||||
String[] parts = raw.substring(prefix.length()).trim().split("\\s+");
|
||||
if (parts.length == 0) return;
|
||||
|
||||
String name = parts[0].toLowerCase();
|
||||
List<String> args = parts.length > 1
|
||||
? Arrays.asList(Arrays.copyOfRange(parts, 1, parts.length))
|
||||
: List.of();
|
||||
|
||||
Command command = registry.get(name);
|
||||
if (command == null) {
|
||||
event.getChannel().sendMessageEmbeds(
|
||||
EmbedTemplates.error("Command not found",
|
||||
"`" + name + "` does not exist. Use `" + prefix + "help`"))
|
||||
.queue();
|
||||
return;
|
||||
}
|
||||
|
||||
RateLimitResult rl = rateLimiter.check(event.getAuthor().getId(), event.getGuild().getId());
|
||||
if (!rl.allowed()) {
|
||||
event.getChannel().sendMessageEmbeds(
|
||||
EmbedTemplates.error("Too fast!",
|
||||
"Please wait " + rl.waitSeconds() + " seconds."))
|
||||
.queue();
|
||||
return;
|
||||
}
|
||||
|
||||
executor.submit(() -> {
|
||||
long start = System.nanoTime();
|
||||
try {
|
||||
CommandContext ctx = new CommandContext(event, args);
|
||||
command.execute(ctx);
|
||||
long elapsed = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
|
||||
LoggerDecorator.command(logger, name, event.getAuthor().getName(), "{}ms", elapsed);
|
||||
} catch (Exception e) {
|
||||
CommandExceptionHandler.handle(e, event.getChannel(), name);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
package kernel.loki.core;
|
||||
|
||||
public interface Lifecycle {
|
||||
default void start() {}
|
||||
default void shutdown() {}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
package kernel.loki.core;
|
||||
|
||||
import com.google.inject.Inject;
|
||||
import com.google.inject.Singleton;
|
||||
import java.util.Set;
|
||||
import kernel.loki.util.LoggerDecorator;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@Singleton
|
||||
public class LifecycleManager {
|
||||
private static final Logger logger = LoggerFactory.getLogger(LifecycleManager.class);
|
||||
private final Set<Lifecycle> components;
|
||||
|
||||
@Inject
|
||||
public LifecycleManager(Set<Lifecycle> components) {
|
||||
this.components = components;
|
||||
}
|
||||
|
||||
public void startAll() {
|
||||
LoggerDecorator.startup(logger, "Starting {} lifecycle components", components.size());
|
||||
for (Lifecycle component : components) {
|
||||
try {
|
||||
component.start();
|
||||
} catch (Exception e) {
|
||||
LoggerDecorator.error(logger, "Failed to start {}", component.getClass().getSimpleName(), e);
|
||||
}
|
||||
}
|
||||
LoggerDecorator.success(logger, "All lifecycle components started");
|
||||
}
|
||||
|
||||
public void shutdownAll() {
|
||||
LoggerDecorator.bot(logger, "Shutting down {} lifecycle components", components.size());
|
||||
for (Lifecycle component : components) {
|
||||
try {
|
||||
component.shutdown();
|
||||
} catch (Exception e) {
|
||||
LoggerDecorator.error(logger, "Failed to shutdown {}", component.getClass().getSimpleName(), e);
|
||||
}
|
||||
}
|
||||
LoggerDecorator.success(logger, "All lifecycle components shut down");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
package kernel.loki.core;
|
||||
|
||||
import com.google.inject.Inject;
|
||||
import com.google.inject.Singleton;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import kernel.loki.bootstrap.ReadyListener;
|
||||
|
||||
@Singleton
|
||||
public class ListenerAggregator {
|
||||
private final List<Object> listeners = new ArrayList<>();
|
||||
|
||||
@Inject
|
||||
public ListenerAggregator(
|
||||
ReadyListener readyListener,
|
||||
MessageListener messageListener
|
||||
) {
|
||||
listeners.add(readyListener);
|
||||
listeners.add(messageListener);
|
||||
}
|
||||
|
||||
public Object[] all() {
|
||||
return listeners.toArray();
|
||||
}
|
||||
|
||||
public void add(Object listener) {
|
||||
listeners.add(listener);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
package kernel.loki.core;
|
||||
|
||||
import com.google.inject.Inject;
|
||||
import com.google.inject.Singleton;
|
||||
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
|
||||
import net.dv8tion.jda.api.hooks.ListenerAdapter;
|
||||
|
||||
@Singleton
|
||||
public class MessageListener extends ListenerAdapter {
|
||||
private final EventDispatcher dispatcher;
|
||||
|
||||
@Inject
|
||||
public MessageListener(EventDispatcher dispatcher) {
|
||||
this.dispatcher = dispatcher;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onMessageReceived(MessageReceivedEvent event) {
|
||||
dispatcher.dispatch(event);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
package kernel.loki.core;
|
||||
|
||||
import com.google.inject.Inject;
|
||||
import com.google.inject.Singleton;
|
||||
import kernel.loki.util.LoggerDecorator;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@Singleton
|
||||
public class ShutdownManager {
|
||||
private static final Logger logger = LoggerFactory.getLogger(ShutdownManager.class);
|
||||
private final LifecycleManager lifecycleManager;
|
||||
|
||||
@Inject
|
||||
public ShutdownManager(LifecycleManager lifecycleManager) {
|
||||
this.lifecycleManager = lifecycleManager;
|
||||
}
|
||||
|
||||
public void shutdown(BotCluster cluster) {
|
||||
LoggerDecorator.warn(logger, "Shutting down bot cluster...");
|
||||
|
||||
lifecycleManager.shutdownAll();
|
||||
|
||||
cluster.all().forEach(bot -> {
|
||||
bot.jda().shutdown();
|
||||
LoggerDecorator.bot(logger, "JDA {} ({}) shut down", bot.id(), bot.type());
|
||||
});
|
||||
|
||||
LoggerDecorator.success(logger, "Bot cluster fully shut down");
|
||||
LoggerDecorator.warn(logger, "Goodbye!");
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
package kernel.loki.core.bootstrap;
|
||||
|
||||
public enum BootstrapPhase {
|
||||
CONFIG_LOADING,
|
||||
DATABASE_CONNECT,
|
||||
DATABASE_MIGRATE,
|
||||
DI_INITIALIZE,
|
||||
REGISTRATION,
|
||||
LIFECYCLE_START,
|
||||
JDA_BUILD,
|
||||
JDA_READY,
|
||||
COMPLETE
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
package kernel.loki.core.exception;
|
||||
|
||||
public class CommandException extends RuntimeException {
|
||||
private final String commandName;
|
||||
|
||||
public CommandException(String commandName, String message) {
|
||||
super(message);
|
||||
this.commandName = commandName;
|
||||
}
|
||||
|
||||
public CommandException(String commandName, String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.commandName = commandName;
|
||||
}
|
||||
|
||||
public String commandName() { return commandName; }
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
package kernel.loki.core.exception;
|
||||
|
||||
import kernel.loki.embed.EmbedTemplates;
|
||||
import kernel.loki.util.LoggerDecorator;
|
||||
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
public class CommandExceptionHandler {
|
||||
private static final Logger logger = LoggerFactory.getLogger(CommandExceptionHandler.class);
|
||||
|
||||
private CommandExceptionHandler() {}
|
||||
|
||||
public static void handle(Exception e, MessageChannel channel, String commandName) {
|
||||
if (e instanceof CommandException ce) {
|
||||
channel.sendMessageEmbeds(
|
||||
EmbedTemplates.error("Command Error", ce.getMessage()))
|
||||
.queue();
|
||||
LoggerDecorator.warn(logger, "Command {}: {}", commandName, ce.getMessage());
|
||||
} else {
|
||||
channel.sendMessageEmbeds(
|
||||
EmbedTemplates.error("Internal Error",
|
||||
"An unexpected error occurred. Please try again later."))
|
||||
.queue();
|
||||
LoggerDecorator.error(logger, "Unhandled exception in command {}", commandName, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
package kernel.loki.core.registry;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface CommandRegistrar {
|
||||
void register(CommandRegistry registry);
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
package kernel.loki.core.registry;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import kernel.loki.command.Command;
|
||||
|
||||
public class CommandRegistry {
|
||||
private final ConcurrentMap<String, Command> commands = new ConcurrentHashMap<>();
|
||||
|
||||
public void register(Command command) {
|
||||
commands.put(command.name().toLowerCase(), command);
|
||||
}
|
||||
|
||||
public Command get(String name) {
|
||||
return commands.get(name.toLowerCase());
|
||||
}
|
||||
|
||||
public Collection<Command> all() {
|
||||
return Collections.unmodifiableCollection(commands.values());
|
||||
}
|
||||
|
||||
public int count() {
|
||||
return commands.size();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
package kernel.loki.core.registry;
|
||||
|
||||
import com.google.inject.Inject;
|
||||
import com.google.inject.Singleton;
|
||||
import java.util.Set;
|
||||
import kernel.loki.util.LoggerDecorator;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@Singleton
|
||||
public class Registrar {
|
||||
private static final Logger logger = LoggerFactory.getLogger(Registrar.class);
|
||||
private final CommandRegistry registry;
|
||||
private final Set<CommandRegistrar> registrars;
|
||||
|
||||
@Inject
|
||||
public Registrar(CommandRegistry registry, Set<CommandRegistrar> registrars) {
|
||||
this.registry = registry;
|
||||
this.registrars = registrars;
|
||||
}
|
||||
|
||||
public void registerAll() {
|
||||
for (CommandRegistrar registrar : registrars) {
|
||||
registrar.register(registry);
|
||||
}
|
||||
LoggerDecorator.success(logger, "Registered {} prefix commands", registry.count());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
package kernel.loki.core.security;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import com.google.inject.Inject;
|
||||
import com.google.inject.Singleton;
|
||||
import kernel.loki.core.Lifecycle;
|
||||
import kernel.loki.util.LoggerDecorator;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@Singleton
|
||||
public class CooldownManager implements Lifecycle {
|
||||
private static final Logger logger = LoggerFactory.getLogger(CooldownManager.class);
|
||||
private Cache<String, Long> cooldowns;
|
||||
|
||||
@Inject
|
||||
public CooldownManager() {
|
||||
this.cooldowns = Caffeine.newBuilder()
|
||||
.expireAfterWrite(1, TimeUnit.HOURS)
|
||||
.build();
|
||||
}
|
||||
|
||||
public boolean isOnCooldown(String key) {
|
||||
Long expiry = cooldowns.getIfPresent(key);
|
||||
return expiry != null && System.currentTimeMillis() < expiry;
|
||||
}
|
||||
|
||||
public long remaining(String key) {
|
||||
Long expiry = cooldowns.getIfPresent(key);
|
||||
if (expiry == null) return 0;
|
||||
long rem = expiry - System.currentTimeMillis();
|
||||
return Math.max(0, rem);
|
||||
}
|
||||
|
||||
public void set(String key, long durationMs) {
|
||||
cooldowns.put(key, System.currentTimeMillis() + durationMs);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shutdown() {
|
||||
LoggerDecorator.database(logger, "Cooldown cache cleared");
|
||||
cooldowns.invalidateAll();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
package kernel.loki.core.security;
|
||||
|
||||
public record RateLimitResult(boolean allowed, long waitSeconds) {
|
||||
public static final RateLimitResult ALLOWED = new RateLimitResult(true, 0);
|
||||
}
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
package kernel.loki.core.security;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Cache;
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import com.google.inject.Inject;
|
||||
import com.google.inject.Singleton;
|
||||
|
||||
@Singleton
|
||||
public class RateLimiter {
|
||||
private static final int MAX_REQUESTS = 5;
|
||||
private static final int WINDOW_SECONDS = 10;
|
||||
|
||||
private final Cache<String, RequestCounter> cache;
|
||||
|
||||
@Inject
|
||||
public RateLimiter() {
|
||||
this.cache = Caffeine.newBuilder()
|
||||
.expireAfterWrite(WINDOW_SECONDS, TimeUnit.SECONDS)
|
||||
.build();
|
||||
}
|
||||
|
||||
public RateLimitResult check(String userId, String guildId) {
|
||||
String key = userId + ":" + guildId;
|
||||
RequestCounter counter = cache.get(key, k -> new RequestCounter());
|
||||
return counter.tryAcquire();
|
||||
}
|
||||
|
||||
private static class RequestCounter {
|
||||
private int count;
|
||||
private long windowStart;
|
||||
|
||||
RequestCounter() {
|
||||
this.count = 0;
|
||||
this.windowStart = System.nanoTime();
|
||||
}
|
||||
|
||||
synchronized RateLimitResult tryAcquire() {
|
||||
long now = System.nanoTime();
|
||||
if (now - windowStart > TimeUnit.SECONDS.toNanos(WINDOW_SECONDS)) {
|
||||
count = 0;
|
||||
windowStart = now;
|
||||
}
|
||||
count++;
|
||||
if (count > MAX_REQUESTS) {
|
||||
long elapsed = TimeUnit.NANOSECONDS.toSeconds(now - windowStart);
|
||||
long wait = WINDOW_SECONDS - elapsed;
|
||||
return new RateLimitResult(false, Math.max(1, wait));
|
||||
}
|
||||
return RateLimitResult.ALLOWED;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
package kernel.loki.database;
|
||||
|
||||
import com.zaxxer.hikari.HikariConfig;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import com.google.inject.Inject;
|
||||
import com.google.inject.Singleton;
|
||||
import kernel.loki.config.BotConfig;
|
||||
import kernel.loki.core.Lifecycle;
|
||||
import kernel.loki.database.connection.ConnectionPool;
|
||||
import kernel.loki.database.connection.SqliteConnectionPool;
|
||||
import kernel.loki.database.migration.MigrationManager;
|
||||
import kernel.loki.util.LoggerDecorator;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
@Singleton
|
||||
public class Database implements Lifecycle {
|
||||
private static final Logger logger = LoggerFactory.getLogger(Database.class);
|
||||
private final BotConfig config;
|
||||
private ConnectionPool pool;
|
||||
private MigrationManager migrationManager;
|
||||
|
||||
@Inject
|
||||
public Database(BotConfig config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
LoggerDecorator.database(logger, "Connecting to database: {}", config.getDbPath());
|
||||
this.pool = new SqliteConnectionPool(config.getDbPath());
|
||||
this.migrationManager = new MigrationManager(this);
|
||||
|
||||
try (Connection conn = pool.getConnection();
|
||||
Statement stmt = conn.createStatement()) {
|
||||
stmt.execute("PRAGMA foreign_keys = ON");
|
||||
stmt.execute("PRAGMA journal_mode = WAL");
|
||||
stmt.execute("PRAGMA busy_timeout = 5000");
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("Failed to configure database", e);
|
||||
}
|
||||
|
||||
migrationManager.migrate();
|
||||
LoggerDecorator.success(logger, "Database ready");
|
||||
}
|
||||
|
||||
public Connection getConnection() {
|
||||
return pool.getConnection();
|
||||
}
|
||||
|
||||
public MigrationManager migrations() {
|
||||
return migrationManager;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shutdown() {
|
||||
if (pool != null) {
|
||||
pool.close();
|
||||
LoggerDecorator.database(logger, "Database connection pool closed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
package kernel.loki.database;
|
||||
|
||||
import com.google.inject.AbstractModule;
|
||||
import com.google.inject.Provides;
|
||||
import com.google.inject.Singleton;
|
||||
|
||||
public class DatabaseModule extends AbstractModule {
|
||||
@Override
|
||||
protected void configure() {
|
||||
// Database itself is bound in CoreModule via Multibinder<Lifecycle>
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
Database provideDatabase(kernel.loki.config.BotConfig config) {
|
||||
return new Database(config);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
package kernel.loki.database;
|
||||
|
||||
public class DatabaseSchema {
|
||||
private DatabaseSchema() {}
|
||||
|
||||
public static final class GuildConfig {
|
||||
public static final String TABLE = "guild_config";
|
||||
public static final String GUILD_ID = "guild_id";
|
||||
public static final String PREFIX = "prefix";
|
||||
public static final String SETTINGS = "settings";
|
||||
public static final String UPDATED_AT = "updated_at";
|
||||
|
||||
private GuildConfig() {}
|
||||
}
|
||||
|
||||
public static final class UserData {
|
||||
public static final String TABLE = "user_data";
|
||||
public static final String USER_ID = "user_id";
|
||||
public static final String GUILD_ID = "guild_id";
|
||||
public static final String KEY = "key";
|
||||
public static final String VALUE = "value";
|
||||
|
||||
private UserData() {}
|
||||
}
|
||||
|
||||
public static final class Metrics {
|
||||
public static final String TABLE = "metrics";
|
||||
public static final String COMMAND_NAME = "command_name";
|
||||
public static final String CALL_COUNT = "call_count";
|
||||
public static final String TOTAL_TIME_MS = "total_time_ms";
|
||||
public static final String ERROR_COUNT = "error_count";
|
||||
public static final String LAST_CALLED_AT = "last_called_at";
|
||||
|
||||
private Metrics() {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
package kernel.loki.database.connection;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.SQLException;
|
||||
import java.util.function.Function;
|
||||
|
||||
public class ConnectionFactory {
|
||||
private final ConnectionPool pool;
|
||||
|
||||
public ConnectionFactory(ConnectionPool pool) {
|
||||
this.pool = pool;
|
||||
}
|
||||
|
||||
public <T> T withConnection(Function<Connection, T> callback) {
|
||||
try (Connection conn = pool.getConnection()) {
|
||||
return callback.apply(conn);
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
public void withConnectionVoid(ThrowingConsumer<Connection> callback) {
|
||||
try (Connection conn = pool.getConnection()) {
|
||||
callback.accept(conn);
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
public interface ThrowingConsumer<T> {
|
||||
void accept(T t) throws SQLException;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
package kernel.loki.database.connection;
|
||||
|
||||
import java.sql.Connection;
|
||||
|
||||
public interface ConnectionPool {
|
||||
Connection getConnection();
|
||||
void close();
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
package kernel.loki.database.connection;
|
||||
|
||||
import com.zaxxer.hikari.HikariConfig;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
|
||||
public class SqliteConnectionPool implements ConnectionPool {
|
||||
private final HikariDataSource dataSource;
|
||||
|
||||
public SqliteConnectionPool(String dbPath) {
|
||||
HikariConfig config = new HikariConfig();
|
||||
config.setJdbcUrl("jdbc:sqlite:" + dbPath);
|
||||
config.setMaximumPoolSize(4);
|
||||
config.setConnectionTimeout(10000);
|
||||
config.setIdleTimeout(0);
|
||||
config.setMaxLifetime(600000);
|
||||
config.setPoolName("KernelDB");
|
||||
this.dataSource = new HikariDataSource(config);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Connection getConnection() {
|
||||
try {
|
||||
return dataSource.getConnection();
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (dataSource != null && !dataSource.isClosed()) {
|
||||
dataSource.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
package kernel.loki.database.migration;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
|
||||
public interface Migration {
|
||||
int version();
|
||||
String description();
|
||||
void migrate(Connection conn) throws SQLException;
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
package kernel.loki.database.migration;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.ServiceLoader;
|
||||
import kernel.loki.database.Database;
|
||||
import kernel.loki.util.LoggerDecorator;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
public class MigrationManager {
|
||||
private static final Logger logger = LoggerFactory.getLogger(MigrationManager.class);
|
||||
private static final int TARGET_VERSION = Integer.MAX_VALUE;
|
||||
private final Database database;
|
||||
private final List<Migration> migrations = new ArrayList<>();
|
||||
|
||||
public MigrationManager(Database database) {
|
||||
this.database = database;
|
||||
discoverMigrations();
|
||||
}
|
||||
|
||||
private void discoverMigrations() {
|
||||
ServiceLoader.load(Migration.class)
|
||||
.forEach(migrations::add);
|
||||
|
||||
try {
|
||||
Class.forName("kernel.loki.database.migration.migrator.V1__InitialSchema");
|
||||
migrations.add(new kernel.loki.database.migration.migrator.V1__InitialSchema());
|
||||
} catch (ClassNotFoundException ignored) {}
|
||||
|
||||
migrations.sort(Comparator.comparingInt(Migration::version));
|
||||
}
|
||||
|
||||
public void migrate() {
|
||||
ensureSchemaTable();
|
||||
int currentVersion = getCurrentVersion();
|
||||
|
||||
for (Migration migration : migrations) {
|
||||
if (migration.version() > currentVersion) {
|
||||
LoggerDecorator.database(logger, "Applying migration v{}: {}",
|
||||
migration.version(), migration.description());
|
||||
try (Connection conn = database.getConnection()) {
|
||||
conn.setAutoCommit(false);
|
||||
try {
|
||||
migration.migrate(conn);
|
||||
setVersion(conn, migration.version());
|
||||
conn.commit();
|
||||
LoggerDecorator.success(logger, "Migration v{} applied", migration.version());
|
||||
} catch (SQLException e) {
|
||||
conn.rollback();
|
||||
throw e;
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("Migration v" + migration.version() + " failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureSchemaTable() {
|
||||
try (Connection conn = database.getConnection();
|
||||
Statement stmt = conn.createStatement()) {
|
||||
stmt.execute("""
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""");
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException("Failed to create schema_version table", e);
|
||||
}
|
||||
}
|
||||
|
||||
private int getCurrentVersion() {
|
||||
try (Connection conn = database.getConnection();
|
||||
Statement stmt = conn.createStatement();
|
||||
ResultSet rs = stmt.executeQuery("SELECT COALESCE(MAX(version), 0) FROM schema_version")) {
|
||||
return rs.next() ? rs.getInt(1) : 0;
|
||||
} catch (SQLException e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private void setVersion(Connection conn, int version) throws SQLException {
|
||||
try (Statement stmt = conn.createStatement()) {
|
||||
stmt.execute("INSERT INTO schema_version (version) VALUES (" + version + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
package kernel.loki.database.migration.migrator;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import kernel.loki.database.migration.Migration;
|
||||
|
||||
public class V1__InitialSchema implements Migration {
|
||||
@Override
|
||||
public int version() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String description() {
|
||||
return "Create core tables";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void migrate(Connection conn) throws SQLException {
|
||||
try (Statement stmt = conn.createStatement()) {
|
||||
stmt.execute("""
|
||||
CREATE TABLE IF NOT EXISTS guild_config (
|
||||
guild_id TEXT PRIMARY KEY,
|
||||
prefix TEXT DEFAULT '!',
|
||||
settings TEXT DEFAULT '{}',
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""");
|
||||
|
||||
stmt.execute("""
|
||||
CREATE TABLE IF NOT EXISTS user_data (
|
||||
user_id TEXT NOT NULL,
|
||||
guild_id TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (user_id, guild_id, key)
|
||||
)
|
||||
""");
|
||||
|
||||
stmt.execute("""
|
||||
CREATE TABLE IF NOT EXISTS metrics (
|
||||
command_name TEXT NOT NULL,
|
||||
call_count INTEGER DEFAULT 0,
|
||||
total_time_ms INTEGER DEFAULT 0,
|
||||
error_count INTEGER DEFAULT 0,
|
||||
last_called_at TEXT,
|
||||
PRIMARY KEY (command_name)
|
||||
)
|
||||
""");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
package kernel.loki.database.repository;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.function.Function;
|
||||
import com.google.inject.Inject;
|
||||
import kernel.loki.database.Database;
|
||||
|
||||
public abstract class BaseRepository {
|
||||
protected final Database database;
|
||||
|
||||
@Inject
|
||||
protected BaseRepository(Database database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
protected <T> T withConnection(Function<Connection, T> callback) {
|
||||
try (Connection conn = database.getConnection()) {
|
||||
return callback.apply(conn);
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
protected void withConnectionVoid(ThrowingConsumer<Connection> callback) {
|
||||
try (Connection conn = database.getConnection()) {
|
||||
callback.accept(conn);
|
||||
} catch (SQLException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
protected void setString(PreparedStatement stmt, int index, String value) throws SQLException {
|
||||
if (value != null) {
|
||||
stmt.setString(index, value);
|
||||
} else {
|
||||
stmt.setNull(index, java.sql.Types.VARCHAR);
|
||||
}
|
||||
}
|
||||
|
||||
protected String getString(ResultSet rs, String column) throws SQLException {
|
||||
String val = rs.getString(column);
|
||||
return rs.wasNull() ? null : val;
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
protected interface ThrowingConsumer<T> {
|
||||
void accept(T t) throws SQLException;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
package kernel.loki.database.repository;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface Repository<T, ID> {
|
||||
Optional<T> findById(ID id);
|
||||
void save(T entity);
|
||||
void delete(ID id);
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
package kernel.loki.di;
|
||||
|
||||
import com.google.inject.AbstractModule;
|
||||
import com.google.inject.Provides;
|
||||
import com.google.inject.Singleton;
|
||||
import com.google.inject.multibindings.Multibinder;
|
||||
import kernel.loki.config.BotConfig;
|
||||
import kernel.loki.config.ConfigLoader;
|
||||
import kernel.loki.core.EventDispatcher;
|
||||
import kernel.loki.core.Lifecycle;
|
||||
import kernel.loki.core.ListenerAggregator;
|
||||
import kernel.loki.core.ShutdownManager;
|
||||
import kernel.loki.core.registry.CommandRegistrar;
|
||||
import kernel.loki.core.registry.CommandRegistry;
|
||||
import kernel.loki.core.registry.Registrar;
|
||||
import kernel.loki.core.security.CooldownManager;
|
||||
import kernel.loki.core.security.RateLimiter;
|
||||
import kernel.loki.database.Database;
|
||||
import kernel.loki.database.DatabaseModule;
|
||||
import kernel.loki.feature.admin.AdminModule;
|
||||
import kernel.loki.feature.help.HelpModule;
|
||||
import kernel.loki.feature.info.InfoModule;
|
||||
|
||||
public class CoreModule extends AbstractModule {
|
||||
@Override
|
||||
protected void configure() {
|
||||
bind(CommandRegistry.class).in(Singleton.class);
|
||||
bind(CooldownManager.class).in(Singleton.class);
|
||||
bind(RateLimiter.class).in(Singleton.class);
|
||||
bind(ListenerAggregator.class).in(Singleton.class);
|
||||
bind(ShutdownManager.class).in(Singleton.class);
|
||||
bind(Registrar.class).in(Singleton.class);
|
||||
|
||||
Multibinder<Lifecycle> lifecycleBinder = Multibinder.newSetBinder(binder(), Lifecycle.class);
|
||||
lifecycleBinder.addBinding().to(CooldownManager.class);
|
||||
lifecycleBinder.addBinding().to(Database.class);
|
||||
lifecycleBinder.addBinding().to(EventDispatcher.class);
|
||||
|
||||
install(new DatabaseModule());
|
||||
install(new HelpModule());
|
||||
install(new InfoModule());
|
||||
install(new AdminModule());
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
BotConfig provideBotConfig() {
|
||||
return ConfigLoader.load();
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
EventDispatcher provideEventDispatcher(
|
||||
BotConfig config,
|
||||
CommandRegistry registry,
|
||||
RateLimiter rateLimiter
|
||||
) {
|
||||
return new EventDispatcher(config, registry, rateLimiter);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
package kernel.loki.embed;
|
||||
|
||||
import java.awt.Color;
|
||||
|
||||
public final class EmbedColor {
|
||||
public static final Color PRIMARY = new Color(0x5865F2);
|
||||
public static final Color SUCCESS = new Color(0x57F287);
|
||||
public static final Color WARNING = new Color(0xFEE75C);
|
||||
public static final Color ERROR = new Color(0xED4245);
|
||||
public static final Color INFO = new Color(0x00AFFA);
|
||||
public static final Color NEUTRAL = new Color(0x808080);
|
||||
|
||||
private EmbedColor() {}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue