commit c3b0c2f97684e7a9ba0e5c5aa039b078e710684c Author: loki5512344 Date: Mon Aug 3 11:03:34 2026 +0200 chore: initial commit - kernel core, security, embeds, tests diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..da3f80f --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bba2c4d --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.gradle/ +build/ +bin/ +data/ +logs/ +.env +*.log +*.db +.DS_Store +.gradle/ +.idea/ +*.iml +out/ diff --git a/.project b/.project new file mode 100644 index 0000000..c796c69 --- /dev/null +++ b/.project @@ -0,0 +1,28 @@ + + + bot_kernel + Project bot_kernel created by Buildship. + + + + + org.eclipse.buildship.core.gradleprojectbuilder + + + + + + org.eclipse.buildship.core.gradleprojectnature + + + + 1784367102054 + + 30 + + org.eclipse.core.resources.regexFilterMatcher + node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__ + + + + diff --git a/.settings/org.eclipse.buildship.core.prefs b/.settings/org.eclipse.buildship.core.prefs new file mode 100644 index 0000000..1fc41f2 --- /dev/null +++ b/.settings/org.eclipse.buildship.core.prefs @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..597a9df --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/STRUCTURE.md b/STRUCTURE.md new file mode 100644 index 0000000..abfc94e --- /dev/null +++ b/STRUCTURE.md @@ -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, getByType() + │ │ ├── BotInitializer.java # DI → регистрация → JDA → BotCluster + │ │ ├── EventDispatcher.java # VirtualThread executor, dispatch message → command + │ │ ├── JdaFactory.java # JDABuilder factory + │ │ ├── Lifecycle.java # interface { start(), shutdown() } + │ │ ├── LifecycleManager.java # Multibinder → startAll/shutdownAll + │ │ ├── ListenerAggregator.java # Собирает все listener'ы для JDA + │ │ ├── MessageListener.java # ListenerAdapter → EventDispatcher.dispatch() + │ │ ├── ShutdownManager.java # Graceful shutdown + │ │ │ + │ │ ├── bootstrap/ + │ │ │ └── BootstrapPhase.java # enum фаз старта + │ │ ├── registry/ + │ │ │ ├── CommandRegistry.java # ConcurrentHashMap + │ │ │ ├── CommandRegistrar.java # @FunctionalInterface + │ │ │ └── Registrar.java # Авто-регистрация через Multibinder + │ │ ├── security/ + │ │ │ ├── CooldownManager.java # Caffeine cache per-key + │ │ │ ├── RateLimitResult.java # record + │ │ │ └── RateLimiter.java # Sliding window rate limit + │ │ └── exception/ + │ │ ├── CommandException.java + │ │ └── CommandExceptionHandler.java + │ │ + │ ├── database/ # База данных + │ │ ├── Database.java # HikariCP + SQLite, Lifecycle + │ │ ├── DatabaseModule.java # Guice module + │ │ ├── DatabaseSchema.java # Константы колонок + │ │ ├── connection/ + │ │ │ ├── ConnectionPool.java # interface + │ │ │ ├── SqliteConnectionPool.java # HikariDataSource + │ │ │ └── ConnectionFactory.java # Functional wrapper + │ │ ├── migration/ + │ │ │ ├── Migration.java # interface { version(), migrate() } + │ │ │ ├── MigrationManager.java # Auto-discover + version tracking + │ │ │ └── migrator/ + │ │ │ └── V1__InitialSchema.java # guild_config, user_data, metrics + │ │ └── repository/ + │ │ ├── Repository.java # interface (findById, save, delete) + │ │ └── BaseRepository.java # Abstract with connection helpers + │ │ + │ ├── di/ + │ │ └── CoreModule.java # Главный Guice модуль + │ │ + │ ├── embed/ + │ │ ├── EmbedColor.java # Константы цветов + │ │ ├── EmbedFactory.java # EmbedBuilder factory + │ │ └── EmbedTemplates.java # Готовые шаблоны + │ │ + │ ├── feature/ # Фичи (подключаемые модули) + │ │ ├── help/ # !help + │ │ ├── info/ # !ping, !avatar, !serverinfo + │ │ └── admin/ # !shutdown + │ │ + │ └── util/ + │ ├── LoggerDecorator.java # SLF4J с маркерами + │ ├── MessageUtil.java # sanitize, truncate, deleteAfter + │ └── TimeUtil.java # форматирование, парсинг duration + │ + ├── main/resources/ + │ ├── logback.xml + │ └── assets/ + │ + └── test/java/kernel/loki/ + ├── command/CommandRegistryTest.java + ├── config/BotConfigTest.java + ├── core/security/RateLimiterTest.java + └── embed/EmbedFactoryTest.java +``` + +## Принципы (KISS + DRY + SOLID) + +| Принцип | Как соблюдается | +|---------|----------------| +| **S**ingle Responsibility | Каждый класс — одна сущность: Command → execute, Repository → data, Service → logic | +| **O**pen/Closed | Feature-модули добавляются без изменения ядра (Multibinder) | +| **L**iskov Substitution | Command → все команды interchangeable | +| **I**nterface Segregation | Repository, Migration, Lifecycle — минимальные интерфейсы | +| **D**ependency Inversion | Все через Guice DI, нет new() в бизнес-логике | +| **KISS** | record, enum, sealed interface — минимум boilerplate | +| **DRY** | BaseRepository, EmbedFactory, LifecycleManager — общие паттерны вынесены | + +## Правила разработки + +1. **Пакеты** — `kernel.loki.{слой}.{подслой}` +2. **Фичи** — каждая фича = папка в `feature/` с модулем, командами, сервисами, репозиториями +3. **Добавление фичи**: + - Создать `*Module.java` extends `AbstractModule` + - Забиндить `CommandRegistrar` через `Multibinder` + - Забиндить `Lifecycle` если нужно + - Подключить модуль в `CoreModule.configure()` +4. **Команды** — implements `Command`, регистрируются через `CommandRegistrar` +5. **БД** — миграции через `Migration`, репозитории extends `BaseRepository` +6. **Тесты** — JUnit 5, Mockito, testcontainers для БД +7. **Стиль** — Google Java Format, spotless, checkstyle, spotbugs + +--- + +## План фич для Loki Dev Studio + +### Сервер: Loki Dev (ID: 1509503154708811837) + +``` +📁 INFO + #rules, #welcome, #announcements, #releases, #github-log +📁 COMMUNITY + #general, #ru-chat, #en-chat, #ideas +📁 SUPPORT + #open-ticket, #bug-report, #ticket-faq +📁 VOICE + work-room-1, work-room-2, create-voice, afk +📁 STAFF (🔒 staff-only) + #staff-chat, #mod-logs, #bot-commands, #ticket-logs +# ✅ verify (uncategorized) +``` + +### Очередность разработки + +#### Phase 1 — Ядро ✅ (Готово) +- [x] Bootstrap, DI, Config +- [x] Command framework +- [x] Event dispatching (virtual threads) +- [x] Rate limiter + Cooldown +- [x] Database (HikariCP + SQLite + Migrations) +- [x] Base embed templates +- [x] Базовые команды: help, ping, avatar, serverinfo, shutdown + +#### Phase 2 — Модерация и управление +- [ ] **Verification** — кнопка в `#verify`, выдача роли Verified + RU/EN +- [ ] **Auto-role** — выбор языка (RU/EN) через кнопки +- [ ] **Moderation** — mute, warn, kick, ban, purge, case-lookup +- [ ] **Logging** — audit log в `#mod-logs` (join/leave, message edit/delete, voice, mod actions) +- [ ] **Mute role** — роль `🔕 Muted`, синхронизация по гильдиям + +#### Phase 3 — Поддержка клиентов +- [ ] **Ticket System** — кнопка в `#open-ticket`, создание канала в категории +- [ ] **Ticket panels** — FAQ в `#ticket-faq` +- [ ] **Bug Reports** — модальное окно в `#bug-report` +- [ ] **Client management** — БД клиентов, проектов, контрактов +- [ ] **Invoice/Payment tracking** — статусы оплат + +#### Phase 4 — Комьюнити и продуктивность +- [ ] **Welcome** — приветствие новых участников в `#welcome` +- [ ] **Polls** — создание опросов в `#ideas` +- [ ] **GitHub integration** — вебхуки в `#github-log` +- [ ] **Scheduler** — отложенные сообщения, напоминания +- [ ] **Counters** — счётчики участников в названиях каналов + +#### Phase 5 — Голосовые каналы +- [ ] **Temp Voice** — `#create-voice` → создание временного канала +- [ ] **Voice roles** — выдача роли за нахождение в войсе +- [ ] **Music** — Lavalink плеер + +#### Phase 6 — AI и автоматизация +- [ ] **AI Assistant** — DeepSeek/GPT для ответов на вопросы клиентов +- [ ] **Auto-mod** — фильтр спама, ссылок, капслока +- [ ] **Stats** — статистика сервера, активность участников + +#### Phase 7 — Профили и экономика (опционально) +- [ ] **Level/XP** — за сообщения и войс +- [ ] **Reputation** — +/rep +- [ ] **Profile cards** — ранговые карточки + +--- + +## Стек технологий + +- **Java 21** — virtual threads, pattern matching, records, sealed classes +- **Gradle 9.6.1** — version catalog, build cache +- **JDA 6.4.2** — Discord API +- **Guice 7.0** — dependency injection +- **HikariCP + SQLite** — database (может быть заменена на PostgreSQL) +- **SLF4J + Logback** — logging +- **Caffeine** — кэширование (cooldowns, rate limits) +- **JUnit 5 + Mockito** — тестирование +- **Checkstyle + SpotBugs + Spotless** — code quality diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..2445211 --- /dev/null +++ b/build.gradle @@ -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 +} diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml new file mode 100644 index 0000000..41c3f33 --- /dev/null +++ b/config/checkstyle/checkstyle.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/config/spotbugs/exclude.xml b/config/spotbugs/exclude.xml new file mode 100644 index 0000000..981d7ea --- /dev/null +++ b/config/spotbugs/exclude.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e07a6d8 --- /dev/null +++ b/docker-compose.yml @@ -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" diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..6d264c1 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m +org.gradle.parallel=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..40a02fc --- /dev/null +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..9bbc975 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a351597 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..faf9300 --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..20bf6b9 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,7 @@ +rootProject.name = 'bot-kernel' + +dependencyResolutionManagement { + repositories { + mavenCentral() + } +} diff --git a/src/main/java/kernel/loki/bootstrap/App.java b/src/main/java/kernel/loki/bootstrap/App.java new file mode 100644 index 0000000..3ded420 --- /dev/null +++ b/src/main/java/kernel/loki/bootstrap/App.java @@ -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() {} +} diff --git a/src/main/java/kernel/loki/bootstrap/ReadyListener.java b/src/main/java/kernel/loki/bootstrap/ReadyListener.java new file mode 100644 index 0000000..9a9830a --- /dev/null +++ b/src/main/java/kernel/loki/bootstrap/ReadyListener.java @@ -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()); + } +} diff --git a/src/main/java/kernel/loki/command/Command.java b/src/main/java/kernel/loki/command/Command.java new file mode 100644 index 0000000..9d948a6 --- /dev/null +++ b/src/main/java/kernel/loki/command/Command.java @@ -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); +} diff --git a/src/main/java/kernel/loki/command/CommandCategory.java b/src/main/java/kernel/loki/command/CommandCategory.java new file mode 100644 index 0000000..0744855 --- /dev/null +++ b/src/main/java/kernel/loki/command/CommandCategory.java @@ -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; } +} diff --git a/src/main/java/kernel/loki/command/CommandContext.java b/src/main/java/kernel/loki/command/CommandContext.java new file mode 100644 index 0000000..df050db --- /dev/null +++ b/src/main/java/kernel/loki/command/CommandContext.java @@ -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 args, + MessageChannel channel, + Member member, + Guild guild, + User author, + MessageReceivedEvent event +) { + public CommandContext(MessageReceivedEvent event, List args) { + this( + event.getMessage(), + args, + event.getChannel(), + event.getMember(), + event.getGuild(), + event.getAuthor(), + event + ); + } +} diff --git a/src/main/java/kernel/loki/command/CommandResult.java b/src/main/java/kernel/loki/command/CommandResult.java new file mode 100644 index 0000000..3c6a2e2 --- /dev/null +++ b/src/main/java/kernel/loki/command/CommandResult.java @@ -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 {} +} diff --git a/src/main/java/kernel/loki/config/BotConfig.java b/src/main/java/kernel/loki/config/BotConfig.java new file mode 100644 index 0000000..3e79a7b --- /dev/null +++ b/src/main/java/kernel/loki/config/BotConfig.java @@ -0,0 +1,30 @@ +package kernel.loki.config; + +import java.util.Map; + +public class BotConfig { + private final Map values; + + public BotConfig(Map values) { + this.values = Map.copyOf(values); + } + + public String get(ConfigKey key) { + return values.get(key.key()); + } + + public String getOrDefault(ConfigKey key) { + return values.getOrDefault(key.key(), key.defaultValue()); + } + + public int getInt(ConfigKey key) { + return Integer.parseInt(getOrDefault(key)); + } + + public String getToken() { return get(ConfigKey.DISCORD_BOT_TOKEN); } + public String getPrefix() { return getOrDefault(ConfigKey.BOT_PREFIX); } + public String getActivity() { return getOrDefault(ConfigKey.BOT_ACTIVITY); } + public String getSuperAdminId() { return get(ConfigKey.SUPER_ADMIN_ID); } + public String getDbPath() { return getOrDefault(ConfigKey.DB_PATH); } + public int getHealthPort() { return getInt(ConfigKey.HEALTH_HTTP_PORT); } +} diff --git a/src/main/java/kernel/loki/config/ConfigKey.java b/src/main/java/kernel/loki/config/ConfigKey.java new file mode 100644 index 0000000..7bd1bb2 --- /dev/null +++ b/src/main/java/kernel/loki/config/ConfigKey.java @@ -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; } +} diff --git a/src/main/java/kernel/loki/config/ConfigLoader.java b/src/main/java/kernel/loki/config/ConfigLoader.java new file mode 100644 index 0000000..c997867 --- /dev/null +++ b/src/main/java/kernel/loki/config/ConfigLoader.java @@ -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 values = new HashMap<>(); + + for (ConfigKey key : ConfigKey.values()) { + String value = dotenv.get(key.key()); + if (value != null) { + values.put(key.key(), value); + } else if (key.hasDefault()) { + values.put(key.key(), key.defaultValue()); + } + } + + return new BotConfig(values); + } + + public static BotConfig load(String path) { + Dotenv dotenv = Dotenv.configure() + .directory(path) + .ignoreIfMissing() + .load(); + + Map values = new HashMap<>(); + + for (ConfigKey key : ConfigKey.values()) { + String value = dotenv.get(key.key()); + if (value != null) { + values.put(key.key(), value); + } else if (key.hasDefault()) { + values.put(key.key(), key.defaultValue()); + } + } + + return new BotConfig(values); + } +} diff --git a/src/main/java/kernel/loki/core/Bot.java b/src/main/java/kernel/loki/core/Bot.java new file mode 100644 index 0000000..c1136da --- /dev/null +++ b/src/main/java/kernel/loki/core/Bot.java @@ -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 + } +} diff --git a/src/main/java/kernel/loki/core/BotCluster.java b/src/main/java/kernel/loki/core/BotCluster.java new file mode 100644 index 0000000..b621b74 --- /dev/null +++ b/src/main/java/kernel/loki/core/BotCluster.java @@ -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 bots = new HashMap<>(); + + public BotCluster(List botList) { + for (Bot bot : botList) { + bots.put(bot.id(), bot); + } + } + + public void add(Bot bot) { + bots.put(bot.id(), bot); + } + + public Optional get(String id) { + return Optional.ofNullable(bots.get(id)); + } + + public Optional getByType(Bot.BotType type) { + return bots.values().stream() + .filter(b -> b.type() == type) + .findFirst(); + } + + public JDA main() { + return getByType(Bot.BotType.MAIN) + .map(Bot::jda) + .orElseThrow(() -> new IllegalStateException("Main bot not initialized")); + } + + public List all() { + return List.copyOf(bots.values()); + } + + public int count() { + return bots.size(); + } +} diff --git a/src/main/java/kernel/loki/core/BotInitializer.java b/src/main/java/kernel/loki/core/BotInitializer.java new file mode 100644 index 0000000..0c46d89 --- /dev/null +++ b/src/main/java/kernel/loki/core/BotInitializer.java @@ -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 botList = new ArrayList<>(); + botList.add(new Bot("main", mainJda, Bot.BotType.MAIN)); + + BotCluster cluster = new BotCluster(botList); + + Runtime.getRuntime().addShutdownHook( + new Thread(() -> shutdownManager.shutdown(cluster), "ShutdownHook")); + + LoggerDecorator.success(logger, "Bot kernel initialized with {} bot(s)", botList.size()); + return cluster; + } + + private JDA buildMainJda() { + JDABuilder builder = JdaFactory.createBuilder( + config.getToken(), + config.getActivity(), + DEFAULT_INTENTS + ); + + for (Object listener : listeners.all()) { + builder.addEventListeners(listener); + } + + return JdaFactory.awaitJda(builder.build()); + } +} diff --git a/src/main/java/kernel/loki/core/EventDispatcher.java b/src/main/java/kernel/loki/core/EventDispatcher.java new file mode 100644 index 0000000..f9b211c --- /dev/null +++ b/src/main/java/kernel/loki/core/EventDispatcher.java @@ -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 args = parts.length > 1 + ? Arrays.asList(Arrays.copyOfRange(parts, 1, parts.length)) + : List.of(); + + Command command = registry.get(name); + if (command == null) { + event.getChannel().sendMessageEmbeds( + EmbedTemplates.error("Command not found", + "`" + name + "` does not exist. Use `" + prefix + "help`")) + .queue(); + return; + } + + RateLimitResult rl = rateLimiter.check(event.getAuthor().getId(), event.getGuild().getId()); + if (!rl.allowed()) { + event.getChannel().sendMessageEmbeds( + EmbedTemplates.error("Too fast!", + "Please wait " + rl.waitSeconds() + " seconds.")) + .queue(); + return; + } + + executor.submit(() -> { + long start = System.nanoTime(); + try { + CommandContext ctx = new CommandContext(event, args); + command.execute(ctx); + long elapsed = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); + LoggerDecorator.command(logger, name, event.getAuthor().getName(), "{}ms", elapsed); + } catch (Exception e) { + CommandExceptionHandler.handle(e, event.getChannel(), name); + } + }); + } +} diff --git a/src/main/java/kernel/loki/core/JdaFactory.java b/src/main/java/kernel/loki/core/JdaFactory.java new file mode 100644 index 0000000..519e982 --- /dev/null +++ b/src/main/java/kernel/loki/core/JdaFactory.java @@ -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); + } + } +} diff --git a/src/main/java/kernel/loki/core/Lifecycle.java b/src/main/java/kernel/loki/core/Lifecycle.java new file mode 100644 index 0000000..07ac7cb --- /dev/null +++ b/src/main/java/kernel/loki/core/Lifecycle.java @@ -0,0 +1,6 @@ +package kernel.loki.core; + +public interface Lifecycle { + default void start() {} + default void shutdown() {} +} diff --git a/src/main/java/kernel/loki/core/LifecycleManager.java b/src/main/java/kernel/loki/core/LifecycleManager.java new file mode 100644 index 0000000..da7102b --- /dev/null +++ b/src/main/java/kernel/loki/core/LifecycleManager.java @@ -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 components; + + @Inject + public LifecycleManager(Set components) { + this.components = components; + } + + public void startAll() { + LoggerDecorator.startup(logger, "Starting {} lifecycle components", components.size()); + for (Lifecycle component : components) { + try { + component.start(); + } catch (Exception e) { + LoggerDecorator.error(logger, "Failed to start {}", component.getClass().getSimpleName(), e); + } + } + LoggerDecorator.success(logger, "All lifecycle components started"); + } + + public void shutdownAll() { + LoggerDecorator.bot(logger, "Shutting down {} lifecycle components", components.size()); + for (Lifecycle component : components) { + try { + component.shutdown(); + } catch (Exception e) { + LoggerDecorator.error(logger, "Failed to shutdown {}", component.getClass().getSimpleName(), e); + } + } + LoggerDecorator.success(logger, "All lifecycle components shut down"); + } +} diff --git a/src/main/java/kernel/loki/core/ListenerAggregator.java b/src/main/java/kernel/loki/core/ListenerAggregator.java new file mode 100644 index 0000000..8d7a4c8 --- /dev/null +++ b/src/main/java/kernel/loki/core/ListenerAggregator.java @@ -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 listeners = new ArrayList<>(); + + @Inject + public ListenerAggregator( + ReadyListener readyListener, + MessageListener messageListener + ) { + listeners.add(readyListener); + listeners.add(messageListener); + } + + public Object[] all() { + return listeners.toArray(); + } + + public void add(Object listener) { + listeners.add(listener); + } +} diff --git a/src/main/java/kernel/loki/core/MessageListener.java b/src/main/java/kernel/loki/core/MessageListener.java new file mode 100644 index 0000000..7487a4f --- /dev/null +++ b/src/main/java/kernel/loki/core/MessageListener.java @@ -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); + } +} diff --git a/src/main/java/kernel/loki/core/ShutdownManager.java b/src/main/java/kernel/loki/core/ShutdownManager.java new file mode 100644 index 0000000..47b6f98 --- /dev/null +++ b/src/main/java/kernel/loki/core/ShutdownManager.java @@ -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!"); + } +} diff --git a/src/main/java/kernel/loki/core/bootstrap/BootstrapPhase.java b/src/main/java/kernel/loki/core/bootstrap/BootstrapPhase.java new file mode 100644 index 0000000..5267e6d --- /dev/null +++ b/src/main/java/kernel/loki/core/bootstrap/BootstrapPhase.java @@ -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 +} diff --git a/src/main/java/kernel/loki/core/exception/CommandException.java b/src/main/java/kernel/loki/core/exception/CommandException.java new file mode 100644 index 0000000..3891102 --- /dev/null +++ b/src/main/java/kernel/loki/core/exception/CommandException.java @@ -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; } +} diff --git a/src/main/java/kernel/loki/core/exception/CommandExceptionHandler.java b/src/main/java/kernel/loki/core/exception/CommandExceptionHandler.java new file mode 100644 index 0000000..35ea1a2 --- /dev/null +++ b/src/main/java/kernel/loki/core/exception/CommandExceptionHandler.java @@ -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); + } + } +} diff --git a/src/main/java/kernel/loki/core/registry/CommandRegistrar.java b/src/main/java/kernel/loki/core/registry/CommandRegistrar.java new file mode 100644 index 0000000..ded2eeb --- /dev/null +++ b/src/main/java/kernel/loki/core/registry/CommandRegistrar.java @@ -0,0 +1,6 @@ +package kernel.loki.core.registry; + +@FunctionalInterface +public interface CommandRegistrar { + void register(CommandRegistry registry); +} diff --git a/src/main/java/kernel/loki/core/registry/CommandRegistry.java b/src/main/java/kernel/loki/core/registry/CommandRegistry.java new file mode 100644 index 0000000..8ddd965 --- /dev/null +++ b/src/main/java/kernel/loki/core/registry/CommandRegistry.java @@ -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 commands = new ConcurrentHashMap<>(); + + public void register(Command command) { + commands.put(command.name().toLowerCase(), command); + } + + public Command get(String name) { + return commands.get(name.toLowerCase()); + } + + public Collection all() { + return Collections.unmodifiableCollection(commands.values()); + } + + public int count() { + return commands.size(); + } +} diff --git a/src/main/java/kernel/loki/core/registry/Registrar.java b/src/main/java/kernel/loki/core/registry/Registrar.java new file mode 100644 index 0000000..6b2a420 --- /dev/null +++ b/src/main/java/kernel/loki/core/registry/Registrar.java @@ -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 registrars; + + @Inject + public Registrar(CommandRegistry registry, Set registrars) { + this.registry = registry; + this.registrars = registrars; + } + + public void registerAll() { + for (CommandRegistrar registrar : registrars) { + registrar.register(registry); + } + LoggerDecorator.success(logger, "Registered {} prefix commands", registry.count()); + } +} diff --git a/src/main/java/kernel/loki/core/security/CooldownManager.java b/src/main/java/kernel/loki/core/security/CooldownManager.java new file mode 100644 index 0000000..d6e9532 --- /dev/null +++ b/src/main/java/kernel/loki/core/security/CooldownManager.java @@ -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 cooldowns; + + @Inject + public CooldownManager() { + this.cooldowns = Caffeine.newBuilder() + .expireAfterWrite(1, TimeUnit.HOURS) + .build(); + } + + public boolean isOnCooldown(String key) { + Long expiry = cooldowns.getIfPresent(key); + return expiry != null && System.currentTimeMillis() < expiry; + } + + public long remaining(String key) { + Long expiry = cooldowns.getIfPresent(key); + if (expiry == null) return 0; + long rem = expiry - System.currentTimeMillis(); + return Math.max(0, rem); + } + + public void set(String key, long durationMs) { + cooldowns.put(key, System.currentTimeMillis() + durationMs); + } + + @Override + public void shutdown() { + LoggerDecorator.database(logger, "Cooldown cache cleared"); + cooldowns.invalidateAll(); + } +} diff --git a/src/main/java/kernel/loki/core/security/RateLimitResult.java b/src/main/java/kernel/loki/core/security/RateLimitResult.java new file mode 100644 index 0000000..4f775c0 --- /dev/null +++ b/src/main/java/kernel/loki/core/security/RateLimitResult.java @@ -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); +} diff --git a/src/main/java/kernel/loki/core/security/RateLimiter.java b/src/main/java/kernel/loki/core/security/RateLimiter.java new file mode 100644 index 0000000..6586202 --- /dev/null +++ b/src/main/java/kernel/loki/core/security/RateLimiter.java @@ -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 cache; + + @Inject + public RateLimiter() { + this.cache = Caffeine.newBuilder() + .expireAfterWrite(WINDOW_SECONDS, TimeUnit.SECONDS) + .build(); + } + + public RateLimitResult check(String userId, String guildId) { + String key = userId + ":" + guildId; + RequestCounter counter = cache.get(key, k -> new RequestCounter()); + return counter.tryAcquire(); + } + + private static class RequestCounter { + private int count; + private long windowStart; + + RequestCounter() { + this.count = 0; + this.windowStart = System.nanoTime(); + } + + synchronized RateLimitResult tryAcquire() { + long now = System.nanoTime(); + if (now - windowStart > TimeUnit.SECONDS.toNanos(WINDOW_SECONDS)) { + count = 0; + windowStart = now; + } + count++; + if (count > MAX_REQUESTS) { + long elapsed = TimeUnit.NANOSECONDS.toSeconds(now - windowStart); + long wait = WINDOW_SECONDS - elapsed; + return new RateLimitResult(false, Math.max(1, wait)); + } + return RateLimitResult.ALLOWED; + } + } +} diff --git a/src/main/java/kernel/loki/database/Database.java b/src/main/java/kernel/loki/database/Database.java new file mode 100644 index 0000000..4ba7067 --- /dev/null +++ b/src/main/java/kernel/loki/database/Database.java @@ -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"); + } + } +} diff --git a/src/main/java/kernel/loki/database/DatabaseModule.java b/src/main/java/kernel/loki/database/DatabaseModule.java new file mode 100644 index 0000000..4bb19f8 --- /dev/null +++ b/src/main/java/kernel/loki/database/DatabaseModule.java @@ -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 + } + + @Provides + @Singleton + Database provideDatabase(kernel.loki.config.BotConfig config) { + return new Database(config); + } +} diff --git a/src/main/java/kernel/loki/database/DatabaseSchema.java b/src/main/java/kernel/loki/database/DatabaseSchema.java new file mode 100644 index 0000000..21becff --- /dev/null +++ b/src/main/java/kernel/loki/database/DatabaseSchema.java @@ -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() {} + } +} diff --git a/src/main/java/kernel/loki/database/connection/ConnectionFactory.java b/src/main/java/kernel/loki/database/connection/ConnectionFactory.java new file mode 100644 index 0000000..8729d87 --- /dev/null +++ b/src/main/java/kernel/loki/database/connection/ConnectionFactory.java @@ -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 withConnection(Function callback) { + try (Connection conn = pool.getConnection()) { + return callback.apply(conn); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + public void withConnectionVoid(ThrowingConsumer callback) { + try (Connection conn = pool.getConnection()) { + callback.accept(conn); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + @FunctionalInterface + public interface ThrowingConsumer { + void accept(T t) throws SQLException; + } +} diff --git a/src/main/java/kernel/loki/database/connection/ConnectionPool.java b/src/main/java/kernel/loki/database/connection/ConnectionPool.java new file mode 100644 index 0000000..330de56 --- /dev/null +++ b/src/main/java/kernel/loki/database/connection/ConnectionPool.java @@ -0,0 +1,8 @@ +package kernel.loki.database.connection; + +import java.sql.Connection; + +public interface ConnectionPool { + Connection getConnection(); + void close(); +} diff --git a/src/main/java/kernel/loki/database/connection/SqliteConnectionPool.java b/src/main/java/kernel/loki/database/connection/SqliteConnectionPool.java new file mode 100644 index 0000000..425e056 --- /dev/null +++ b/src/main/java/kernel/loki/database/connection/SqliteConnectionPool.java @@ -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(); + } + } +} diff --git a/src/main/java/kernel/loki/database/migration/Migration.java b/src/main/java/kernel/loki/database/migration/Migration.java new file mode 100644 index 0000000..9ec8b70 --- /dev/null +++ b/src/main/java/kernel/loki/database/migration/Migration.java @@ -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; +} diff --git a/src/main/java/kernel/loki/database/migration/MigrationManager.java b/src/main/java/kernel/loki/database/migration/MigrationManager.java new file mode 100644 index 0000000..2989492 --- /dev/null +++ b/src/main/java/kernel/loki/database/migration/MigrationManager.java @@ -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 migrations = new ArrayList<>(); + + public MigrationManager(Database database) { + this.database = database; + discoverMigrations(); + } + + private void discoverMigrations() { + ServiceLoader.load(Migration.class) + .forEach(migrations::add); + + try { + Class.forName("kernel.loki.database.migration.migrator.V1__InitialSchema"); + migrations.add(new kernel.loki.database.migration.migrator.V1__InitialSchema()); + } catch (ClassNotFoundException ignored) {} + + migrations.sort(Comparator.comparingInt(Migration::version)); + } + + public void migrate() { + ensureSchemaTable(); + int currentVersion = getCurrentVersion(); + + for (Migration migration : migrations) { + if (migration.version() > currentVersion) { + LoggerDecorator.database(logger, "Applying migration v{}: {}", + migration.version(), migration.description()); + try (Connection conn = database.getConnection()) { + conn.setAutoCommit(false); + try { + migration.migrate(conn); + setVersion(conn, migration.version()); + conn.commit(); + LoggerDecorator.success(logger, "Migration v{} applied", migration.version()); + } catch (SQLException e) { + conn.rollback(); + throw e; + } + } catch (SQLException e) { + throw new RuntimeException("Migration v" + migration.version() + " failed", e); + } + } + } + } + + private void ensureSchemaTable() { + try (Connection conn = database.getConnection(); + Statement stmt = conn.createStatement()) { + stmt.execute(""" + CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + """); + } catch (SQLException e) { + throw new RuntimeException("Failed to create schema_version table", e); + } + } + + private int getCurrentVersion() { + try (Connection conn = database.getConnection(); + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT COALESCE(MAX(version), 0) FROM schema_version")) { + return rs.next() ? rs.getInt(1) : 0; + } catch (SQLException e) { + return 0; + } + } + + private void setVersion(Connection conn, int version) throws SQLException { + try (Statement stmt = conn.createStatement()) { + stmt.execute("INSERT INTO schema_version (version) VALUES (" + version + ")"); + } + } +} diff --git a/src/main/java/kernel/loki/database/migration/migrator/V1__InitialSchema.java b/src/main/java/kernel/loki/database/migration/migrator/V1__InitialSchema.java new file mode 100644 index 0000000..ebc5071 --- /dev/null +++ b/src/main/java/kernel/loki/database/migration/migrator/V1__InitialSchema.java @@ -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) + ) + """); + } + } +} diff --git a/src/main/java/kernel/loki/database/repository/BaseRepository.java b/src/main/java/kernel/loki/database/repository/BaseRepository.java new file mode 100644 index 0000000..1760dca --- /dev/null +++ b/src/main/java/kernel/loki/database/repository/BaseRepository.java @@ -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 withConnection(Function callback) { + try (Connection conn = database.getConnection()) { + return callback.apply(conn); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + protected void withConnectionVoid(ThrowingConsumer callback) { + try (Connection conn = database.getConnection()) { + callback.accept(conn); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + + protected void setString(PreparedStatement stmt, int index, String value) throws SQLException { + if (value != null) { + stmt.setString(index, value); + } else { + stmt.setNull(index, java.sql.Types.VARCHAR); + } + } + + protected String getString(ResultSet rs, String column) throws SQLException { + String val = rs.getString(column); + return rs.wasNull() ? null : val; + } + + @FunctionalInterface + protected interface ThrowingConsumer { + void accept(T t) throws SQLException; + } +} diff --git a/src/main/java/kernel/loki/database/repository/Repository.java b/src/main/java/kernel/loki/database/repository/Repository.java new file mode 100644 index 0000000..4a0f5f4 --- /dev/null +++ b/src/main/java/kernel/loki/database/repository/Repository.java @@ -0,0 +1,11 @@ +package kernel.loki.database.repository; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Optional; + +public interface Repository { + Optional findById(ID id); + void save(T entity); + void delete(ID id); +} diff --git a/src/main/java/kernel/loki/di/CoreModule.java b/src/main/java/kernel/loki/di/CoreModule.java new file mode 100644 index 0000000..20f7da8 --- /dev/null +++ b/src/main/java/kernel/loki/di/CoreModule.java @@ -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 lifecycleBinder = Multibinder.newSetBinder(binder(), Lifecycle.class); + lifecycleBinder.addBinding().to(CooldownManager.class); + lifecycleBinder.addBinding().to(Database.class); + lifecycleBinder.addBinding().to(EventDispatcher.class); + + install(new DatabaseModule()); + install(new HelpModule()); + install(new InfoModule()); + install(new AdminModule()); + } + + @Provides + @Singleton + BotConfig provideBotConfig() { + return ConfigLoader.load(); + } + + @Provides + @Singleton + EventDispatcher provideEventDispatcher( + BotConfig config, + CommandRegistry registry, + RateLimiter rateLimiter + ) { + return new EventDispatcher(config, registry, rateLimiter); + } +} diff --git a/src/main/java/kernel/loki/embed/EmbedColor.java b/src/main/java/kernel/loki/embed/EmbedColor.java new file mode 100644 index 0000000..663eb17 --- /dev/null +++ b/src/main/java/kernel/loki/embed/EmbedColor.java @@ -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() {} +} diff --git a/src/main/java/kernel/loki/embed/EmbedFactory.java b/src/main/java/kernel/loki/embed/EmbedFactory.java new file mode 100644 index 0000000..118d40d --- /dev/null +++ b/src/main/java/kernel/loki/embed/EmbedFactory.java @@ -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(); + } +} diff --git a/src/main/java/kernel/loki/embed/EmbedTemplates.java b/src/main/java/kernel/loki/embed/EmbedTemplates.java new file mode 100644 index 0000000..485b11f --- /dev/null +++ b/src/main/java/kernel/loki/embed/EmbedTemplates.java @@ -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 commands) { + EmbedBuilder builder = EmbedFactory.builder(EmbedColor.PRIMARY) + .setTitle("Command List") + .setDescription("Available commands (prefix: `" + prefix + "`)"); + + for (kernel.loki.command.Command cmd : commands) { + builder.addField( + prefix + cmd.name(), + cmd.description() + (cmd.usage().equals(cmd.name()) ? "" : "\nUsage: `" + prefix + cmd.usage() + "`"), + false + ); + } + + return builder.build(); + } + + public static MessageEmbed userInfo(User user, String extraInfo) { + return EmbedFactory.userEmbed(user) + .setDescription(extraInfo) + .build(); + } +} diff --git a/src/main/java/kernel/loki/feature/admin/AdminCommandRegistrar.java b/src/main/java/kernel/loki/feature/admin/AdminCommandRegistrar.java new file mode 100644 index 0000000..1cc4e97 --- /dev/null +++ b/src/main/java/kernel/loki/feature/admin/AdminCommandRegistrar.java @@ -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); + } +} diff --git a/src/main/java/kernel/loki/feature/admin/AdminModule.java b/src/main/java/kernel/loki/feature/admin/AdminModule.java new file mode 100644 index 0000000..68ece81 --- /dev/null +++ b/src/main/java/kernel/loki/feature/admin/AdminModule.java @@ -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 registrarBinder = + Multibinder.newSetBinder(binder(), CommandRegistrar.class); + registrarBinder.addBinding().to(AdminCommandRegistrar.class); + } +} diff --git a/src/main/java/kernel/loki/feature/admin/command/ShutdownCommand.java b/src/main/java/kernel/loki/feature/admin/command/ShutdownCommand.java new file mode 100644 index 0000000..4d03210 --- /dev/null +++ b/src/main/java/kernel/loki/feature/admin/command/ShutdownCommand.java @@ -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); + }); + } +} diff --git a/src/main/java/kernel/loki/feature/help/HelpCommandRegistrar.java b/src/main/java/kernel/loki/feature/help/HelpCommandRegistrar.java new file mode 100644 index 0000000..00f2220 --- /dev/null +++ b/src/main/java/kernel/loki/feature/help/HelpCommandRegistrar.java @@ -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); + } +} diff --git a/src/main/java/kernel/loki/feature/help/HelpModule.java b/src/main/java/kernel/loki/feature/help/HelpModule.java new file mode 100644 index 0000000..b5d8c44 --- /dev/null +++ b/src/main/java/kernel/loki/feature/help/HelpModule.java @@ -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 registrarBinder = + Multibinder.newSetBinder(binder(), CommandRegistrar.class); + registrarBinder.addBinding().to(HelpCommandRegistrar.class); + } +} diff --git a/src/main/java/kernel/loki/feature/help/command/HelpCommand.java b/src/main/java/kernel/loki/feature/help/command/HelpCommand.java new file mode 100644 index 0000000..00f7199 --- /dev/null +++ b/src/main/java/kernel/loki/feature/help/command/HelpCommand.java @@ -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(); + } +} diff --git a/src/main/java/kernel/loki/feature/info/InfoCommandRegistrar.java b/src/main/java/kernel/loki/feature/info/InfoCommandRegistrar.java new file mode 100644 index 0000000..4b056c4 --- /dev/null +++ b/src/main/java/kernel/loki/feature/info/InfoCommandRegistrar.java @@ -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); + } +} diff --git a/src/main/java/kernel/loki/feature/info/InfoModule.java b/src/main/java/kernel/loki/feature/info/InfoModule.java new file mode 100644 index 0000000..27a32de --- /dev/null +++ b/src/main/java/kernel/loki/feature/info/InfoModule.java @@ -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 registrarBinder = + Multibinder.newSetBinder(binder(), CommandRegistrar.class); + registrarBinder.addBinding().to(InfoCommandRegistrar.class); + } +} diff --git a/src/main/java/kernel/loki/feature/info/command/AvatarCommand.java b/src/main/java/kernel/loki/feature/info/command/AvatarCommand.java new file mode 100644 index 0000000..4362525 --- /dev/null +++ b/src/main/java/kernel/loki/feature/info/command/AvatarCommand.java @@ -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(); + } +} diff --git a/src/main/java/kernel/loki/feature/info/command/PingCommand.java b/src/main/java/kernel/loki/feature/info/command/PingCommand.java new file mode 100644 index 0000000..6163b90 --- /dev/null +++ b/src/main/java/kernel/loki/feature/info/command/PingCommand.java @@ -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(); + } +} diff --git a/src/main/java/kernel/loki/feature/info/command/ServerInfoCommand.java b/src/main/java/kernel/loki/feature/info/command/ServerInfoCommand.java new file mode 100644 index 0000000..ad5393c --- /dev/null +++ b/src/main/java/kernel/loki/feature/info/command/ServerInfoCommand.java @@ -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(); + } +} diff --git a/src/main/java/kernel/loki/util/LoggerDecorator.java b/src/main/java/kernel/loki/util/LoggerDecorator.java new file mode 100644 index 0000000..94bcb4d --- /dev/null +++ b/src/main/java/kernel/loki/util/LoggerDecorator.java @@ -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); + } +} diff --git a/src/main/java/kernel/loki/util/MessageUtil.java b/src/main/java/kernel/loki/util/MessageUtil.java new file mode 100644 index 0000000..c500128 --- /dev/null +++ b/src/main/java/kernel/loki/util/MessageUtil.java @@ -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) + "..."; + } +} diff --git a/src/main/java/kernel/loki/util/TimeUtil.java b/src/main/java/kernel/loki/util/TimeUtil.java new file mode 100644 index 0000000..3b65559 --- /dev/null +++ b/src/main/java/kernel/loki/util/TimeUtil.java @@ -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; + } +} diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..5ccb46f --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,24 @@ + + + + + %d{HH:mm:ss} [%highlight(%-5level)] [%marker] %msg%n + + + + + logs/bot-kernel.log + + logs/bot-kernel.%d{yyyy-MM-dd}.log + 7 + + + %d{yyyy-MM-dd HH:mm:ss} [%level] [%marker] [%thread] %msg%n + + + + + + + + diff --git a/src/test/java/kernel/loki/command/CommandRegistryTest.java b/src/test/java/kernel/loki/command/CommandRegistryTest.java new file mode 100644 index 0000000..032ddaa --- /dev/null +++ b/src/test/java/kernel/loki/command/CommandRegistryTest.java @@ -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) {} + } +} diff --git a/src/test/java/kernel/loki/config/BotConfigTest.java b/src/test/java/kernel/loki/config/BotConfigTest.java new file mode 100644 index 0000000..2ca98a1 --- /dev/null +++ b/src/test/java/kernel/loki/config/BotConfigTest.java @@ -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)); + } +} diff --git a/src/test/java/kernel/loki/core/security/RateLimiterTest.java b/src/test/java/kernel/loki/core/security/RateLimiterTest.java new file mode 100644 index 0000000..6823a06 --- /dev/null +++ b/src/test/java/kernel/loki/core/security/RateLimiterTest.java @@ -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()); + } +} diff --git a/src/test/java/kernel/loki/embed/EmbedFactoryTest.java b/src/test/java/kernel/loki/embed/EmbedFactoryTest.java new file mode 100644 index 0000000..75cf923 --- /dev/null +++ b/src/test/java/kernel/loki/embed/EmbedFactoryTest.java @@ -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); + } +}