chore: initial commit - kernel core, security, embeds, tests
This commit is contained in:
commit
c3b0c2f976
79 changed files with 2907 additions and 0 deletions
11
.env.example
Normal file
11
.env.example
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# === Discord ===
|
||||||
|
DISCORD_BOT_TOKEN=your_token_here
|
||||||
|
BOT_PREFIX=!
|
||||||
|
BOT_ACTIVITY=Use !help
|
||||||
|
SUPER_ADMIN_ID=your_discord_user_id
|
||||||
|
|
||||||
|
# === HTTP Health ===
|
||||||
|
HEALTH_HTTP_PORT=8712
|
||||||
|
|
||||||
|
# === Database ===
|
||||||
|
DB_PATH=data/bot.db
|
||||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
bin/
|
||||||
|
data/
|
||||||
|
logs/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
*.db
|
||||||
|
.DS_Store
|
||||||
|
.gradle/
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
out/
|
||||||
28
.project
Normal file
28
.project
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?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>
|
||||||
13
.settings/org.eclipse.buildship.core.prefs
Normal file
13
.settings/org.eclipse.buildship.core.prefs
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
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
|
||||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
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"]
|
||||||
207
STRUCTURE.md
Normal file
207
STRUCTURE.md
Normal file
|
|
@ -0,0 +1,207 @@
|
||||||
|
# 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
Normal file
78
build.gradle
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
28
config/checkstyle/checkstyle.xml
Normal file
28
config/checkstyle/checkstyle.xml
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?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>
|
||||||
6
config/spotbugs/exclude.xml
Normal file
6
config/spotbugs/exclude.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<FindBugsFilter>
|
||||||
|
<Match>
|
||||||
|
<Bug pattern="EI_EXPOSE_REP,EI_EXPOSE_REP2" />
|
||||||
|
</Match>
|
||||||
|
</FindBugsFilter>
|
||||||
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
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"
|
||||||
2
gradle.properties
Normal file
2
gradle.properties
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m
|
||||||
|
org.gradle.parallel=true
|
||||||
35
gradle/libs.versions.toml
Normal file
35
gradle/libs.versions.toml
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
[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
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
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
Executable file
251
gradlew
vendored
Executable file
|
|
@ -0,0 +1,251 @@
|
||||||
|
#!/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
Normal file
94
gradlew.bat
vendored
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
@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
|
||||||
7
settings.gradle
Normal file
7
settings.gradle
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
rootProject.name = 'bot-kernel'
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/main/java/kernel/loki/bootstrap/App.java
Normal file
58
src/main/java/kernel/loki/bootstrap/App.java
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
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() {}
|
||||||
|
}
|
||||||
20
src/main/java/kernel/loki/bootstrap/ReadyListener.java
Normal file
20
src/main/java/kernel/loki/bootstrap/ReadyListener.java
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/main/java/kernel/loki/command/Command.java
Normal file
9
src/main/java/kernel/loki/command/Command.java
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
21
src/main/java/kernel/loki/command/CommandCategory.java
Normal file
21
src/main/java/kernel/loki/command/CommandCategory.java
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
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; }
|
||||||
|
}
|
||||||
31
src/main/java/kernel/loki/command/CommandContext.java
Normal file
31
src/main/java/kernel/loki/command/CommandContext.java
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/main/java/kernel/loki/command/CommandResult.java
Normal file
8
src/main/java/kernel/loki/command/CommandResult.java
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
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 {}
|
||||||
|
}
|
||||||
30
src/main/java/kernel/loki/config/BotConfig.java
Normal file
30
src/main/java/kernel/loki/config/BotConfig.java
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
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); }
|
||||||
|
}
|
||||||
26
src/main/java/kernel/loki/config/ConfigKey.java
Normal file
26
src/main/java/kernel/loki/config/ConfigKey.java
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
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; }
|
||||||
|
}
|
||||||
48
src/main/java/kernel/loki/config/ConfigLoader.java
Normal file
48
src/main/java/kernel/loki/config/ConfigLoader.java
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/main/java/kernel/loki/core/Bot.java
Normal file
9
src/main/java/kernel/loki/core/Bot.java
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/main/java/kernel/loki/core/BotCluster.java
Normal file
45
src/main/java/kernel/loki/core/BotCluster.java
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/main/java/kernel/loki/core/BotInitializer.java
Normal file
81
src/main/java/kernel/loki/core/BotInitializer.java
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
96
src/main/java/kernel/loki/core/EventDispatcher.java
Normal file
96
src/main/java/kernel/loki/core/EventDispatcher.java
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/main/java/kernel/loki/core/JdaFactory.java
Normal file
31
src/main/java/kernel/loki/core/JdaFactory.java
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/main/java/kernel/loki/core/Lifecycle.java
Normal file
6
src/main/java/kernel/loki/core/Lifecycle.java
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
package kernel.loki.core;
|
||||||
|
|
||||||
|
public interface Lifecycle {
|
||||||
|
default void start() {}
|
||||||
|
default void shutdown() {}
|
||||||
|
}
|
||||||
43
src/main/java/kernel/loki/core/LifecycleManager.java
Normal file
43
src/main/java/kernel/loki/core/LifecycleManager.java
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/main/java/kernel/loki/core/ListenerAggregator.java
Normal file
29
src/main/java/kernel/loki/core/ListenerAggregator.java
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/main/java/kernel/loki/core/MessageListener.java
Normal file
21
src/main/java/kernel/loki/core/MessageListener.java
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/main/java/kernel/loki/core/ShutdownManager.java
Normal file
32
src/main/java/kernel/loki/core/ShutdownManager.java
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
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!");
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/main/java/kernel/loki/core/bootstrap/BootstrapPhase.java
Normal file
13
src/main/java/kernel/loki/core/bootstrap/BootstrapPhase.java
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package kernel.loki.core.bootstrap;
|
||||||
|
|
||||||
|
public enum BootstrapPhase {
|
||||||
|
CONFIG_LOADING,
|
||||||
|
DATABASE_CONNECT,
|
||||||
|
DATABASE_MIGRATE,
|
||||||
|
DI_INITIALIZE,
|
||||||
|
REGISTRATION,
|
||||||
|
LIFECYCLE_START,
|
||||||
|
JDA_BUILD,
|
||||||
|
JDA_READY,
|
||||||
|
COMPLETE
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package kernel.loki.core.registry;
|
||||||
|
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface CommandRegistrar {
|
||||||
|
void register(CommandRegistry registry);
|
||||||
|
}
|
||||||
27
src/main/java/kernel/loki/core/registry/CommandRegistry.java
Normal file
27
src/main/java/kernel/loki/core/registry/CommandRegistry.java
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/main/java/kernel/loki/core/registry/Registrar.java
Normal file
28
src/main/java/kernel/loki/core/registry/Registrar.java
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/main/java/kernel/loki/core/security/CooldownManager.java
Normal file
46
src/main/java/kernel/loki/core/security/CooldownManager.java
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
package kernel.loki.core.security;
|
||||||
|
|
||||||
|
public record RateLimitResult(boolean allowed, long waitSeconds) {
|
||||||
|
public static final RateLimitResult ALLOWED = new RateLimitResult(true, 0);
|
||||||
|
}
|
||||||
53
src/main/java/kernel/loki/core/security/RateLimiter.java
Normal file
53
src/main/java/kernel/loki/core/security/RateLimiter.java
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/main/java/kernel/loki/database/Database.java
Normal file
65
src/main/java/kernel/loki/database/Database.java
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/main/java/kernel/loki/database/DatabaseModule.java
Normal file
18
src/main/java/kernel/loki/database/DatabaseModule.java
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/main/java/kernel/loki/database/DatabaseSchema.java
Normal file
36
src/main/java/kernel/loki/database/DatabaseSchema.java
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
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() {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package kernel.loki.database.connection;
|
||||||
|
|
||||||
|
import java.sql.Connection;
|
||||||
|
|
||||||
|
public interface ConnectionPool {
|
||||||
|
Connection getConnection();
|
||||||
|
void close();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/main/java/kernel/loki/database/migration/Migration.java
Normal file
10
src/main/java/kernel/loki/database/migration/Migration.java
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
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 + ")");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
60
src/main/java/kernel/loki/di/CoreModule.java
Normal file
60
src/main/java/kernel/loki/di/CoreModule.java
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/main/java/kernel/loki/embed/EmbedColor.java
Normal file
14
src/main/java/kernel/loki/embed/EmbedColor.java
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
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() {}
|
||||||
|
}
|
||||||
62
src/main/java/kernel/loki/embed/EmbedFactory.java
Normal file
62
src/main/java/kernel/loki/embed/EmbedFactory.java
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
package kernel.loki.embed;
|
||||||
|
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.time.Instant;
|
||||||
|
import net.dv8tion.jda.api.EmbedBuilder;
|
||||||
|
import net.dv8tion.jda.api.entities.MessageEmbed;
|
||||||
|
import net.dv8tion.jda.api.entities.User;
|
||||||
|
|
||||||
|
public class EmbedFactory {
|
||||||
|
private EmbedFactory() {}
|
||||||
|
|
||||||
|
public static EmbedBuilder builder() {
|
||||||
|
return new EmbedBuilder()
|
||||||
|
.setTimestamp(Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static EmbedBuilder builder(Color color) {
|
||||||
|
return builder().setColor(color);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static EmbedBuilder primary(String title, String description) {
|
||||||
|
return builder(EmbedColor.PRIMARY)
|
||||||
|
.setTitle(title)
|
||||||
|
.setDescription(description);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static EmbedBuilder success(String title, String description) {
|
||||||
|
return builder(EmbedColor.SUCCESS)
|
||||||
|
.setTitle(title)
|
||||||
|
.setDescription(description);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static EmbedBuilder warning(String title, String description) {
|
||||||
|
return builder(EmbedColor.WARNING)
|
||||||
|
.setTitle(title)
|
||||||
|
.setDescription(description);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static EmbedBuilder error(String title, String description) {
|
||||||
|
return builder(EmbedColor.ERROR)
|
||||||
|
.setTitle(title)
|
||||||
|
.setDescription(description);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static EmbedBuilder info(String title, String description) {
|
||||||
|
return builder(EmbedColor.INFO)
|
||||||
|
.setTitle(title)
|
||||||
|
.setDescription(description);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static EmbedBuilder userEmbed(User user) {
|
||||||
|
return builder(EmbedColor.PRIMARY)
|
||||||
|
.setAuthor(user.getName(), null, user.getAvatarUrl())
|
||||||
|
.setThumbnail(user.getAvatarUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MessageEmbed footer(EmbedBuilder builder, String text, String iconUrl) {
|
||||||
|
return builder
|
||||||
|
.setFooter(text, iconUrl)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/main/java/kernel/loki/embed/EmbedTemplates.java
Normal file
47
src/main/java/kernel/loki/embed/EmbedTemplates.java
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
package kernel.loki.embed;
|
||||||
|
|
||||||
|
import net.dv8tion.jda.api.EmbedBuilder;
|
||||||
|
import net.dv8tion.jda.api.entities.MessageEmbed;
|
||||||
|
import net.dv8tion.jda.api.entities.User;
|
||||||
|
|
||||||
|
public class EmbedTemplates {
|
||||||
|
private EmbedTemplates() {}
|
||||||
|
|
||||||
|
public static MessageEmbed error(String title, String description) {
|
||||||
|
return EmbedFactory.error(title, description).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MessageEmbed success(String title, String description) {
|
||||||
|
return EmbedFactory.success(title, description).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MessageEmbed info(String title, String description) {
|
||||||
|
return EmbedFactory.info(title, description).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MessageEmbed warning(String title, String description) {
|
||||||
|
return EmbedFactory.warning(title, description).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MessageEmbed commandList(String prefix, java.util.Collection<kernel.loki.command.Command> commands) {
|
||||||
|
EmbedBuilder builder = EmbedFactory.builder(EmbedColor.PRIMARY)
|
||||||
|
.setTitle("Command List")
|
||||||
|
.setDescription("Available commands (prefix: `" + prefix + "`)");
|
||||||
|
|
||||||
|
for (kernel.loki.command.Command cmd : commands) {
|
||||||
|
builder.addField(
|
||||||
|
prefix + cmd.name(),
|
||||||
|
cmd.description() + (cmd.usage().equals(cmd.name()) ? "" : "\nUsage: `" + prefix + cmd.usage() + "`"),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MessageEmbed userInfo(User user, String extraInfo) {
|
||||||
|
return EmbedFactory.userEmbed(user)
|
||||||
|
.setDescription(extraInfo)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
package kernel.loki.feature.admin;
|
||||||
|
|
||||||
|
import com.google.inject.Inject;
|
||||||
|
import kernel.loki.core.registry.CommandRegistrar;
|
||||||
|
import kernel.loki.core.registry.CommandRegistry;
|
||||||
|
import kernel.loki.feature.admin.command.ShutdownCommand;
|
||||||
|
|
||||||
|
public class AdminCommandRegistrar implements CommandRegistrar {
|
||||||
|
private final ShutdownCommand shutdown;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public AdminCommandRegistrar(ShutdownCommand shutdown) {
|
||||||
|
this.shutdown = shutdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void register(CommandRegistry registry) {
|
||||||
|
registry.register(shutdown);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/main/java/kernel/loki/feature/admin/AdminModule.java
Normal file
14
src/main/java/kernel/loki/feature/admin/AdminModule.java
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
package kernel.loki.feature.admin;
|
||||||
|
|
||||||
|
import com.google.inject.AbstractModule;
|
||||||
|
import com.google.inject.multibindings.Multibinder;
|
||||||
|
import kernel.loki.core.registry.CommandRegistrar;
|
||||||
|
|
||||||
|
public class AdminModule extends AbstractModule {
|
||||||
|
@Override
|
||||||
|
protected void configure() {
|
||||||
|
Multibinder<CommandRegistrar> registrarBinder =
|
||||||
|
Multibinder.newSetBinder(binder(), CommandRegistrar.class);
|
||||||
|
registrarBinder.addBinding().to(AdminCommandRegistrar.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
package kernel.loki.feature.admin.command;
|
||||||
|
|
||||||
|
import com.google.inject.Inject;
|
||||||
|
import com.google.inject.Singleton;
|
||||||
|
import kernel.loki.command.Command;
|
||||||
|
import kernel.loki.command.CommandCategory;
|
||||||
|
import kernel.loki.command.CommandContext;
|
||||||
|
import kernel.loki.config.BotConfig;
|
||||||
|
import kernel.loki.embed.EmbedTemplates;
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
public class ShutdownCommand implements Command {
|
||||||
|
private final BotConfig config;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public ShutdownCommand(BotConfig config) {
|
||||||
|
this.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() { return "shutdown"; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() { return "Shutdown the bot (admin only)"; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String category() { return CommandCategory.ADMIN.display(); }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void execute(CommandContext ctx) {
|
||||||
|
String adminId = config.getSuperAdminId();
|
||||||
|
if (adminId == null || !adminId.equals(ctx.author().getId())) {
|
||||||
|
ctx.channel().sendMessageEmbeds(
|
||||||
|
EmbedTemplates.error("Access Denied", "You are not authorized to use this command."))
|
||||||
|
.queue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.channel().sendMessageEmbeds(
|
||||||
|
EmbedTemplates.warning("Shutting down", "Bot will shut down now."))
|
||||||
|
.queue(msg -> {
|
||||||
|
ctx.event().getJDA().shutdown();
|
||||||
|
System.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
package kernel.loki.feature.help;
|
||||||
|
|
||||||
|
import com.google.inject.Inject;
|
||||||
|
import kernel.loki.core.registry.CommandRegistrar;
|
||||||
|
import kernel.loki.core.registry.CommandRegistry;
|
||||||
|
import kernel.loki.feature.help.command.HelpCommand;
|
||||||
|
|
||||||
|
public class HelpCommandRegistrar implements CommandRegistrar {
|
||||||
|
private final HelpCommand command;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public HelpCommandRegistrar(HelpCommand command) {
|
||||||
|
this.command = command;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void register(CommandRegistry registry) {
|
||||||
|
registry.register(command);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/main/java/kernel/loki/feature/help/HelpModule.java
Normal file
14
src/main/java/kernel/loki/feature/help/HelpModule.java
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
package kernel.loki.feature.help;
|
||||||
|
|
||||||
|
import com.google.inject.AbstractModule;
|
||||||
|
import com.google.inject.multibindings.Multibinder;
|
||||||
|
import kernel.loki.core.registry.CommandRegistrar;
|
||||||
|
|
||||||
|
public class HelpModule extends AbstractModule {
|
||||||
|
@Override
|
||||||
|
protected void configure() {
|
||||||
|
Multibinder<CommandRegistrar> registrarBinder =
|
||||||
|
Multibinder.newSetBinder(binder(), CommandRegistrar.class);
|
||||||
|
registrarBinder.addBinding().to(HelpCommandRegistrar.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
package kernel.loki.feature.help.command;
|
||||||
|
|
||||||
|
import com.google.inject.Inject;
|
||||||
|
import com.google.inject.Singleton;
|
||||||
|
import kernel.loki.command.Command;
|
||||||
|
import kernel.loki.command.CommandCategory;
|
||||||
|
import kernel.loki.command.CommandContext;
|
||||||
|
import kernel.loki.config.BotConfig;
|
||||||
|
import kernel.loki.core.registry.CommandRegistry;
|
||||||
|
import kernel.loki.embed.EmbedTemplates;
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
public class HelpCommand implements Command {
|
||||||
|
private final BotConfig config;
|
||||||
|
private final CommandRegistry registry;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public HelpCommand(BotConfig config, CommandRegistry registry) {
|
||||||
|
this.config = config;
|
||||||
|
this.registry = registry;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() { return "help"; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() { return "Show available commands"; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String category() { return CommandCategory.HELP.display(); }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void execute(CommandContext ctx) {
|
||||||
|
String prefix = config.getPrefix();
|
||||||
|
ctx.channel().sendMessageEmbeds(
|
||||||
|
EmbedTemplates.commandList(prefix, registry.all()))
|
||||||
|
.queue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
package kernel.loki.feature.info;
|
||||||
|
|
||||||
|
import com.google.inject.Inject;
|
||||||
|
import kernel.loki.core.registry.CommandRegistrar;
|
||||||
|
import kernel.loki.core.registry.CommandRegistry;
|
||||||
|
import kernel.loki.feature.info.command.AvatarCommand;
|
||||||
|
import kernel.loki.feature.info.command.PingCommand;
|
||||||
|
import kernel.loki.feature.info.command.ServerInfoCommand;
|
||||||
|
|
||||||
|
public class InfoCommandRegistrar implements CommandRegistrar {
|
||||||
|
private final PingCommand ping;
|
||||||
|
private final AvatarCommand avatar;
|
||||||
|
private final ServerInfoCommand serverInfo;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public InfoCommandRegistrar(PingCommand ping, AvatarCommand avatar, ServerInfoCommand serverInfo) {
|
||||||
|
this.ping = ping;
|
||||||
|
this.avatar = avatar;
|
||||||
|
this.serverInfo = serverInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void register(CommandRegistry registry) {
|
||||||
|
registry.register(ping);
|
||||||
|
registry.register(avatar);
|
||||||
|
registry.register(serverInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/main/java/kernel/loki/feature/info/InfoModule.java
Normal file
14
src/main/java/kernel/loki/feature/info/InfoModule.java
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
package kernel.loki.feature.info;
|
||||||
|
|
||||||
|
import com.google.inject.AbstractModule;
|
||||||
|
import com.google.inject.multibindings.Multibinder;
|
||||||
|
import kernel.loki.core.registry.CommandRegistrar;
|
||||||
|
|
||||||
|
public class InfoModule extends AbstractModule {
|
||||||
|
@Override
|
||||||
|
protected void configure() {
|
||||||
|
Multibinder<CommandRegistrar> registrarBinder =
|
||||||
|
Multibinder.newSetBinder(binder(), CommandRegistrar.class);
|
||||||
|
registrarBinder.addBinding().to(InfoCommandRegistrar.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
package kernel.loki.feature.info.command;
|
||||||
|
|
||||||
|
import com.google.inject.Inject;
|
||||||
|
import com.google.inject.Singleton;
|
||||||
|
import kernel.loki.command.Command;
|
||||||
|
import kernel.loki.command.CommandCategory;
|
||||||
|
import kernel.loki.command.CommandContext;
|
||||||
|
import kernel.loki.embed.EmbedFactory;
|
||||||
|
import kernel.loki.embed.EmbedColor;
|
||||||
|
import net.dv8tion.jda.api.entities.Member;
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
public class AvatarCommand implements Command {
|
||||||
|
@Inject
|
||||||
|
public AvatarCommand() {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() { return "avatar"; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() { return "Get a user's avatar"; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String usage() { return "avatar [@user]"; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String category() { return CommandCategory.INFO.display(); }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void execute(CommandContext ctx) {
|
||||||
|
Member target = ctx.args().isEmpty()
|
||||||
|
? ctx.member()
|
||||||
|
: ctx.message().getMentions().getMembers().stream().findFirst().orElse(ctx.member());
|
||||||
|
|
||||||
|
ctx.channel().sendMessageEmbeds(
|
||||||
|
EmbedFactory.builder(EmbedColor.PRIMARY)
|
||||||
|
.setTitle(target.getEffectiveName() + "'s Avatar")
|
||||||
|
.setImage(target.getEffectiveAvatarUrl() + "?size=1024")
|
||||||
|
.setFooter("ID: " + target.getId())
|
||||||
|
.build())
|
||||||
|
.queue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
package kernel.loki.feature.info.command;
|
||||||
|
|
||||||
|
import com.google.inject.Inject;
|
||||||
|
import com.google.inject.Singleton;
|
||||||
|
import kernel.loki.command.Command;
|
||||||
|
import kernel.loki.command.CommandCategory;
|
||||||
|
import kernel.loki.command.CommandContext;
|
||||||
|
import kernel.loki.embed.EmbedTemplates;
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
public class PingCommand implements Command {
|
||||||
|
@Inject
|
||||||
|
public PingCommand() {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() { return "ping"; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() { return "Check bot latency"; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String category() { return CommandCategory.INFO.display(); }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void execute(CommandContext ctx) {
|
||||||
|
long start = System.nanoTime();
|
||||||
|
ctx.channel().sendMessage("Pong!").flatMap(msg -> {
|
||||||
|
long elapsed = (System.nanoTime() - start) / 1_000_000;
|
||||||
|
msg.editMessageEmbeds(
|
||||||
|
EmbedTemplates.info("Pong!",
|
||||||
|
"Gateway: " + ctx.event().getJDA().getGatewayPing() + "ms\n"
|
||||||
|
+ "Roundtrip: " + elapsed + "ms"))
|
||||||
|
.queue();
|
||||||
|
return null;
|
||||||
|
}).queue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,47 @@
|
||||||
|
package kernel.loki.feature.info.command;
|
||||||
|
|
||||||
|
import com.google.inject.Inject;
|
||||||
|
import com.google.inject.Singleton;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import kernel.loki.command.Command;
|
||||||
|
import kernel.loki.command.CommandCategory;
|
||||||
|
import kernel.loki.command.CommandContext;
|
||||||
|
import kernel.loki.embed.EmbedFactory;
|
||||||
|
import kernel.loki.embed.EmbedColor;
|
||||||
|
import net.dv8tion.jda.api.entities.Guild;
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
public class ServerInfoCommand implements Command {
|
||||||
|
@Inject
|
||||||
|
public ServerInfoCommand() {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String name() { return "serverinfo"; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String description() { return "Show server information"; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String category() { return CommandCategory.INFO.display(); }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void execute(CommandContext ctx) {
|
||||||
|
Guild guild = ctx.guild();
|
||||||
|
ctx.channel().sendMessageEmbeds(
|
||||||
|
EmbedFactory.builder(EmbedColor.PRIMARY)
|
||||||
|
.setTitle(guild.getName())
|
||||||
|
.setThumbnail(guild.getIconUrl())
|
||||||
|
.addField("Owner", guild.getOwner() != null
|
||||||
|
? guild.getOwner().getEffectiveName() : "Unknown", true)
|
||||||
|
.addField("Members", String.valueOf(guild.getMemberCount()), true)
|
||||||
|
.addField("Boost Tier", guild.getBoostTier().name(), true)
|
||||||
|
.addField("Boost Count", String.valueOf(guild.getBoostCount()), true)
|
||||||
|
.addField("Channels", String.valueOf(guild.getChannels().size()), true)
|
||||||
|
.addField("Roles", String.valueOf(guild.getRoles().size()), true)
|
||||||
|
.addField("Created",
|
||||||
|
guild.getTimeCreated().format(DateTimeFormatter.RFC_1123_DATE_TIME), false)
|
||||||
|
.setFooter("ID: " + guild.getId())
|
||||||
|
.build())
|
||||||
|
.queue();
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/main/java/kernel/loki/util/LoggerDecorator.java
Normal file
47
src/main/java/kernel/loki/util/LoggerDecorator.java
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
package kernel.loki.util;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.Marker;
|
||||||
|
import org.slf4j.MarkerFactory;
|
||||||
|
|
||||||
|
public class LoggerDecorator {
|
||||||
|
private static final Marker STARTUP = MarkerFactory.getMarker("STARTUP");
|
||||||
|
private static final Marker BOT = MarkerFactory.getMarker("BOT");
|
||||||
|
private static final Marker DATABASE = MarkerFactory.getMarker("DATABASE");
|
||||||
|
private static final Marker COMMAND = MarkerFactory.getMarker("COMMAND");
|
||||||
|
private static final Marker SUCCESS = MarkerFactory.getMarker("SUCCESS");
|
||||||
|
|
||||||
|
private LoggerDecorator() {}
|
||||||
|
|
||||||
|
public static void startup(Logger log, String msg, Object... args) {
|
||||||
|
log.info(STARTUP, msg, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void bot(Logger log, String msg, Object... args) {
|
||||||
|
log.info(BOT, msg, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void database(Logger log, String msg, Object... args) {
|
||||||
|
log.info(DATABASE, msg, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void command(Logger log, String cmd, String user, String msg, Object... args) {
|
||||||
|
log.info(COMMAND, "[{}] by {}: {}", cmd, user, String.format(msg, args));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void success(Logger log, String msg, Object... args) {
|
||||||
|
log.info(SUCCESS, msg, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void warn(Logger log, String msg, Object... args) {
|
||||||
|
log.warn(msg, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void error(Logger log, String msg, Object... args) {
|
||||||
|
log.error(msg, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void error(Logger log, String msg, Throwable t) {
|
||||||
|
log.error(msg, t);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/main/java/kernel/loki/util/MessageUtil.java
Normal file
28
src/main/java/kernel/loki/util/MessageUtil.java
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
package kernel.loki.util;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import net.dv8tion.jda.api.entities.Message;
|
||||||
|
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
|
||||||
|
|
||||||
|
public class MessageUtil {
|
||||||
|
private MessageUtil() {}
|
||||||
|
|
||||||
|
public static void deleteAfter(Message message, long seconds) {
|
||||||
|
message.delete().queueAfter(seconds, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void sendAndDelete(MessageChannel channel, String content, long seconds) {
|
||||||
|
channel.sendMessage(content).queue(msg -> deleteAfter(msg, seconds));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String sanitize(String input) {
|
||||||
|
return input.replace("@everyone", "@\u200Beveryone")
|
||||||
|
.replace("@here", "@\u200Bhere");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String truncate(String input, int maxLength) {
|
||||||
|
if (input == null) return null;
|
||||||
|
if (input.length() <= maxLength) return input;
|
||||||
|
return input.substring(0, maxLength - 3) + "...";
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/main/java/kernel/loki/util/TimeUtil.java
Normal file
56
src/main/java/kernel/loki/util/TimeUtil.java
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
package kernel.loki.util;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
|
public class TimeUtil {
|
||||||
|
private static final DateTimeFormatter FORMATTER =
|
||||||
|
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||||
|
.withZone(ZoneId.systemDefault());
|
||||||
|
|
||||||
|
private TimeUtil() {}
|
||||||
|
|
||||||
|
public static String format(Instant instant) {
|
||||||
|
return FORMATTER.format(instant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String formatDuration(long millis) {
|
||||||
|
Duration duration = Duration.ofMillis(millis);
|
||||||
|
long hours = duration.toHours();
|
||||||
|
long minutes = duration.toMinutes() % 60;
|
||||||
|
long seconds = duration.getSeconds() % 60;
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return String.format("%dh %dm %ds", hours, minutes, seconds);
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
return String.format("%dm %ds", minutes, seconds);
|
||||||
|
} else {
|
||||||
|
return String.format("%ds", seconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static long parseDuration(String input) {
|
||||||
|
input = input.toLowerCase().trim();
|
||||||
|
long total = 0;
|
||||||
|
StringBuilder num = new StringBuilder();
|
||||||
|
|
||||||
|
for (char c : input.toCharArray()) {
|
||||||
|
if (Character.isDigit(c)) {
|
||||||
|
num.append(c);
|
||||||
|
} else {
|
||||||
|
if (num.isEmpty()) continue;
|
||||||
|
long value = Long.parseLong(num.toString());
|
||||||
|
switch (c) {
|
||||||
|
case 's' -> total += value * 1000;
|
||||||
|
case 'm' -> total += value * 60_000;
|
||||||
|
case 'h' -> total += value * 3_600_000;
|
||||||
|
case 'd' -> total += value * 86_400_000;
|
||||||
|
}
|
||||||
|
num.setLength(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/main/resources/logback.xml
Normal file
24
src/main/resources/logback.xml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<configuration>
|
||||||
|
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||||
|
<encoder>
|
||||||
|
<pattern>%d{HH:mm:ss} [%highlight(%-5level)] [%marker] %msg%n</pattern>
|
||||||
|
</encoder>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||||
|
<file>logs/bot-kernel.log</file>
|
||||||
|
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||||
|
<fileNamePattern>logs/bot-kernel.%d{yyyy-MM-dd}.log</fileNamePattern>
|
||||||
|
<maxHistory>7</maxHistory>
|
||||||
|
</rollingPolicy>
|
||||||
|
<encoder>
|
||||||
|
<pattern>%d{yyyy-MM-dd HH:mm:ss} [%level] [%marker] [%thread] %msg%n</pattern>
|
||||||
|
</encoder>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<root level="INFO">
|
||||||
|
<appender-ref ref="CONSOLE"/>
|
||||||
|
<appender-ref ref="FILE"/>
|
||||||
|
</root>
|
||||||
|
</configuration>
|
||||||
48
src/test/java/kernel/loki/command/CommandRegistryTest.java
Normal file
48
src/test/java/kernel/loki/command/CommandRegistryTest.java
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
package kernel.loki.command;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import kernel.loki.core.registry.CommandRegistry;
|
||||||
|
|
||||||
|
class CommandRegistryTest {
|
||||||
|
private CommandRegistry registry;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
registry = new CommandRegistry();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldRegisterAndRetrieveCommand() {
|
||||||
|
Command cmd = new TestCommand("ping", "Ping command");
|
||||||
|
registry.register(cmd);
|
||||||
|
assertEquals(cmd, registry.get("ping"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldBeCaseInsensitive() {
|
||||||
|
Command cmd = new TestCommand("Ping", "Ping command");
|
||||||
|
registry.register(cmd);
|
||||||
|
assertNotNull(registry.get("PING"));
|
||||||
|
assertNotNull(registry.get("ping"));
|
||||||
|
assertNotNull(registry.get("Ping"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnNullForUnknownCommand() {
|
||||||
|
assertNull(registry.get("nonexistent"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldCountCommands() {
|
||||||
|
registry.register(new TestCommand("a", ""));
|
||||||
|
registry.register(new TestCommand("b", ""));
|
||||||
|
assertEquals(2, registry.count());
|
||||||
|
}
|
||||||
|
|
||||||
|
private record TestCommand(String name, String description) implements Command {
|
||||||
|
@Override
|
||||||
|
public void execute(CommandContext ctx) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/test/java/kernel/loki/config/BotConfigTest.java
Normal file
25
src/test/java/kernel/loki/config/BotConfigTest.java
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package kernel.loki.config;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class BotConfigTest {
|
||||||
|
@Test
|
||||||
|
void shouldReturnDefaultForMissingOptionalKey() {
|
||||||
|
BotConfig config = new BotConfig(Map.of());
|
||||||
|
assertEquals("!", config.getOrDefault(ConfigKey.BOT_PREFIX));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnProvidedValue() {
|
||||||
|
BotConfig config = new BotConfig(Map.of("BOT_PREFIX", "?"));
|
||||||
|
assertEquals("?", config.getOrDefault(ConfigKey.BOT_PREFIX));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldThrowForMissingRequiredKey() {
|
||||||
|
BotConfig config = new BotConfig(Map.of());
|
||||||
|
assertNull(config.get(ConfigKey.DISCORD_BOT_TOKEN));
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/test/java/kernel/loki/core/security/RateLimiterTest.java
Normal file
47
src/test/java/kernel/loki/core/security/RateLimiterTest.java
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
package kernel.loki.core.security;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class RateLimiterTest {
|
||||||
|
private RateLimiter limiter;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
limiter = new RateLimiter();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldAllowFirstRequest() {
|
||||||
|
RateLimitResult result = limiter.check("user1", "guild1");
|
||||||
|
assertTrue(result.allowed());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldAllowMultipleRequestsUnderLimit() {
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
RateLimitResult result = limiter.check("user2", "guild1");
|
||||||
|
assertTrue(result.allowed());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldBlockAfterLimit() {
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
limiter.check("user3", "guild1");
|
||||||
|
}
|
||||||
|
RateLimitResult result = limiter.check("user3", "guild1");
|
||||||
|
assertFalse(result.allowed());
|
||||||
|
assertTrue(result.waitSeconds() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldHaveSeparateCountersPerUser() {
|
||||||
|
for (int i = 0; i < 10; i++) {
|
||||||
|
limiter.check("userA", "guild1");
|
||||||
|
}
|
||||||
|
RateLimitResult result = limiter.check("userB", "guild1");
|
||||||
|
assertTrue(result.allowed());
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/test/java/kernel/loki/embed/EmbedFactoryTest.java
Normal file
33
src/test/java/kernel/loki/embed/EmbedFactoryTest.java
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
package kernel.loki.embed;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import net.dv8tion.jda.api.entities.MessageEmbed;
|
||||||
|
|
||||||
|
class EmbedFactoryTest {
|
||||||
|
@Test
|
||||||
|
void shouldCreateErrorEmbed() {
|
||||||
|
MessageEmbed embed = EmbedTemplates.error("Test Error", "This is a test error");
|
||||||
|
assertEquals("Test Error", embed.getTitle());
|
||||||
|
assertEquals("This is a test error", embed.getDescription());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldCreateSuccessEmbed() {
|
||||||
|
MessageEmbed embed = EmbedTemplates.success("Success", "Operation completed");
|
||||||
|
assertEquals("Success", embed.getTitle());
|
||||||
|
assertEquals("Operation completed", embed.getDescription());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldCreateInfoEmbed() {
|
||||||
|
MessageEmbed embed = EmbedTemplates.info("Info", "Some info");
|
||||||
|
assertNotNull(embed);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldCreateWarningEmbed() {
|
||||||
|
MessageEmbed embed = EmbedTemplates.warning("Warning", "Be careful");
|
||||||
|
assertNotNull(embed);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue