feat: economy system with minigames, bank, rob, jobs, fishing, blackjack, slots

+ economy domain (EconomyAccount, Job, SlotCombination, EconomyTransaction)
+ EconomyRepository + SqlEconomyRepository + DB schema
+ EconomyService (transfer, daily, deposit/withdraw, stat upgrades)
+ FishingService (fish grid with difficulty levels, speed bonus)
+ BlackjackService (dealer stands on 16, ties lose)
+ SlotService (weighted combos up to x50)
+ JobService (6 jobs with level reqs and efficiency stat)
+ 11 commands: balance, baltop, pay, daily, bank, rob,
  fish, blackjack, slot, job, work
+ Tests for SqlEconomyRepository
+ EconomyModule + EconomyCommandRegistrar wired into BotModule
This commit is contained in:
loki5512344 2026-06-20 00:23:15 +02:00
parent fcb5cd7207
commit fe121c8546
270 changed files with 18632 additions and 11656 deletions

13
.dockerignore Normal file
View file

@ -0,0 +1,13 @@
.git
.gitignore
.gitattributes
.env
.gradle
build
bin
out
.idea
.vscode
*.md
logs
data

12
.editorconfig Normal file
View file

@ -0,0 +1,12 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8
indent_style = space
indent_size = 4
[*.md]
trim_trailing_whitespace = false

6
.env.example Normal file
View file

@ -0,0 +1,6 @@
DISCORD_BOT_TOKEN=your_token_here
BOT_PREFIX=!
BOT_ACTIVITY=Используй !help
HEALTH_HTTP_PORT=8712
SUPER_ADMIN_ID=your_discord_user_id
DB_PATH=data/bot.db

19
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,19 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: "21"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@v4
- run: ./gradlew build

20
Dockerfile Normal file
View file

@ -0,0 +1,20 @@
FROM eclipse-temurin:21-jdk-alpine AS builder
WORKDIR /build
COPY gradle gradle
COPY gradlew settings.gradle build.gradle ./
COPY src src
RUN ./gradlew jar --no-daemon --stacktrace
FROM eclipse-temurin:21-jre-alpine
RUN addgroup -S bot && adduser -S bot -G bot
WORKDIR /app
COPY --from=builder /build/build/libs/discord-bot-1.0.0.jar app.jar
COPY --from=builder /build/data/ ./data/
RUN mkdir -p /app/data && chown -R bot:bot /app
USER bot
VOLUME /app/data
EXPOSE 8712
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s \
CMD wget --no-verbose --tries=1 --spider http://localhost:8712/health || exit 1
ENV DB_PATH=/app/data/bot.db
ENTRYPOINT ["java", "-jar", "app.jar"]

View file

@ -2,7 +2,7 @@
## 📋 Описание ## 📋 Описание
Многофункциональный Discord бот с системой уровней, личными голосовыми каналами, модерацией и развлекательными командами. Многофункциональный Discord бот с системой уровней, личными голосовыми каналами, модерацией и развлекательными командами. **Все команды — префиксные** (ниже для краткости префикс `!`; реальный префикс задаётся в конфиге бота).
**Технологии:** **Технологии:**
- Java 21 - Java 21
@ -20,8 +20,8 @@
- 10 XP за сообщение (cooldown 60 сек) - 10 XP за сообщение (cooldown 60 сек)
- 1 XP за минуту в войсе - 1 XP за минуту в войсе
- **Команды:** - **Команды:**
- `/rank [@user]` - карточка ранга - `!rank [@user]` — карточка ранга
- `/leaderboard` - топ по уровням/сообщениям/войсу - `!leaderboard` — топ по уровням/сообщениям/войсу
- **Уведомления:** Красивые embed при повышении уровня - **Уведомления:** Красивые embed при повышении уровня
- **Награды:** Роли за достижение уровней (5, 10, 20, 30, 50) - **Награды:** Роли за достижение уровней (5, 10, 20, 30, 50)
@ -34,14 +34,14 @@
- 🎨 Битрейт - 🎨 Битрейт
- 🚫 Удалить - 🚫 Удалить
- **Автоудаление:** Пустые каналы удаляются автоматически - **Автоудаление:** Пустые каналы удаляются автоматически
- **Настройка:** `/voice-setup` - **Настройка:** `!voice-setup`
### 📊 Счетчик участников ### 📊 Счетчик участников
- **Автообновление:** Название канала с количеством участников - **Автообновление:** Название канала с количеством участников
- **Формат:** `【👥】Участники: 123` - **Формат:** `【👥】Участники: 123`
- **Статистика:** Присоединения/выходы за день - **Статистика:** Присоединения/выходы за день
- **История:** 30 дней - **История:** 30 дней
- **Настройка:** `/counter-setup` - **Настройка:** `!counter-setup`
### 📝 Логи модерации ### 📝 Логи модерации
- **Что логируется:** - **Что логируется:**
@ -50,48 +50,47 @@
- 👢 Кики - 👢 Кики
- 🔇 Муты/размуты - 🔇 Муты/размуты
- 🎤 Отключение микрофона/звука - 🎤 Отключение микрофона/звука
- **Настройка:** `/log-setup moderation` - **Настройка:** `!log-setup moderation` / `!log-setup levelup`
### 🎮 Развлекательные команды ### 🎮 Развлекательные команды
**Игры:** **Игры:**
- `/coin` - подбросить монетку - `!coin` — подбросить монетку
- `/dice` - бросить кубик - `!dice` — бросить кубик
- `/8ball` - магический шар - `!8ball` — магический шар
- `/ship @user1 @user2` - совместимость - `!ship @user1 @user2` — совместимость
- `/rate` - оценить что-то - `!rate` — оценить что-то
- `/gay/@fat/@iq/@pp` - процентомеры - `!gay`, `!fat`, `!iq`, `!pp` — процентомеры
**Взаимодействия:** **GIF-интерактив (nekos.life):**
- `/hug/@kiss/@pat/@poke` - обнять/поцеловать/погладить/ткнуть - `!hug`, `!kiss`, `!poke`, `!slap`, `!tickle`, `!cuddle`, `!feed`
- `/slap/@tickle/@cuddle` - шлепнуть/пощекотать/обнять
- `/compliment/@roast` - комплимент/жарка **Развлечения (текст):**
- `/feed` - покормить - `!pat` — погладить (без GIF, несколько pat-pat)
- `!compliment`, `!roast` — комплимент / жарка
### 🛡️ Модерация ### 🛡️ Модерация
- `/ban/@kick` - бан/кик пользователя - `!ban`, `!kick` — бан / кик пользователя
- `/clear <amount>` - очистить сообщения - `!clear <amount>` — очистить сообщения
- Автологирование всех действий - Автологирование всех действий
### 🎨 Кастомные роли ### 🎨 Кастомные роли
- `/createrole` - создать роль - `!createrole` — создать роль
- `/deleterole` - удалить роль - `!deleterole` — удалить роль
- `/editrole` - редактировать роль - `!editrole` — редактировать роль
- `/myroles` - мои роли - `!myroles` — мои роли
### 📈 Статистика ### 📈 Статистика
- `/stats [@user]` - статистика пользователя - `!stats` — статистика сервера
- `/rep/@minusrep` - репутация - `!rep`, `!minusrep` — репутация
- `/reptop` - топ по репутации - `!reptop` — топ по репутации
- `/serverstats` - статистика сервера
### 🔧 Утилиты ### 🔧 Утилиты
- `/avatar [@user]` - аватар пользователя - `!avatar [@user]` — аватар пользователя
- `/info [@user]` - информация о пользователе - `!info [@user]` — информация о пользователе
- `/poll` - создать голосование - `!poll` — создать голосование
- `/ping` - пинг бота - `!ping` — пинг бота
- `/help` - справка - `!help` — справка
- `/tts <text>` - озвучить текст
--- ---
@ -99,26 +98,72 @@
### Личные войс-каналы ### Личные войс-каналы
``` ```
/voice-setup trigger <channel> - канал-триггер !voice-setup trigger <channel> - канал-триггер
/voice-setup category <category> - категория для каналов !voice-setup category <category> - категория для каналов
/voice-setup text-channel <channel> - текстовый канал для панелей !voice-setup text-channel <channel> - текстовый канал для панелей
/voice-setup info - показать настройки !voice-setup info - показать настройки
``` ```
### Счетчик участников ### Счетчик участников
``` ```
/counter-setup channel <channel> - установить канал !counter-setup channel <channel> - установить канал
/counter-setup format <format> - формат ({count}) !counter-setup format <format> - формат ({count})
/counter-setup info - показать настройки !counter-setup info - показать настройки
``` ```
### Логирование ### Логирование
``` ```
/log-setup moderation <channel> - канал логов модерации !log-setup moderation <channel> - канал логов модерации
/log-setup levelup <channel> - канал уведомлений level up !log-setup levelup <channel> - канал уведомлений level up
/log-setup info - показать настройки !log-setup info - показать настройки
``` ```
### Сброс уровней (одноразовая команда)
```
!resetlevels
```
**Описание:** Сбрасывает уровни и XP всех пользователей на сервере.
**Требования:**
- Права администратора
- Команда работает только один раз
- Требует подтверждения через кнопки
**Что сбрасывается:**
- Уровень → 0
- XP → 0
**Что сохраняется:**
- Время в голосовых каналах
- Количество сообщений
- Репутация
---
## 🔐 Команды супер-админа
**Супер-админ ID:** `1414975197269987471`
### Генерация рейтингов
```
!generateratings
```
**Описание:** Генерирует все rating значения для всех участников сервера.
**Генерируемые рейтинги:**
- Ship (0-100%)
- Gay (0-100%)
- IQ (0-200)
- PP (0-30 см)
- Mustache (0-100, редко 1000-1500)
- Fat (0-100%)
- Boykisser (0-100%)
**Особенности:**
- Не перезаписывает существующие значения
- Пропускает ботов
- Генерирует для всех участников сервера
--- ---
## 🗄️ База данных ## 🗄️ База данных
@ -143,15 +188,15 @@ gradle run
2. **Настройка:** 2. **Настройка:**
``` ```
/voice-setup trigger #создать-канал !voice-setup trigger #создать-канал
/voice-setup category Личные каналы !voice-setup category Личные каналы
/voice-setup text-channel #управление !voice-setup text-channel #управление
/counter-setup channel #счетчик !counter-setup channel #счетчик
/counter-setup format 【👥】Участники: {count} !counter-setup format 【👥】Участники: {count}
/log-setup moderation #логи !log-setup moderation #логи
/log-setup levelup #level-up !log-setup levelup #level-up
``` ```
3. **Готово!** Зайди в канал-триггер → создастся личный канал 3. **Готово!** Зайди в канал-триггер → создастся личный канал

View file

@ -8,12 +8,61 @@
# Запуск # Запуск
gradle run gradle run
# Настройка через Discord # Настройка через Discord (префикс по умолчанию «!»)
/voice-setup trigger #создать-канал !voice-setup trigger #создать-канал
/counter-setup channel #счетчик !counter-setup channel #счетчик
/log-setup moderation #логи !log-setup moderation #логи
``` ```
## 🔧 Административные команды
### Сброс уровней (одноразовая команда)
```
!resetlevels
```
**Описание:** Сбрасывает уровни и XP всех пользователей на сервере. Сохраняет время в войсе и количество сообщений.
**Требования:**
- Права администратора
- Команда работает только один раз
- Требует подтверждения через кнопки
**Что сбрасывается:**
- Уровень → 0
- XP → 0
**Что сохраняется:**
- Время в голосовых каналах
- Количество сообщений
- Репутация
---
### Генерация рейтингов (супер-админ)
```
!generateratings
```
**Описание:** Генерирует все rating значения (ship, gay, iq, pp, mustache, fat, boykisser) для всех участников сервера.
**Требования:**
- Только для супер-админа (ID: `1414975197269987471`)
- Не перезаписывает существующие значения
- Сохраняет в SQLite базу данных
**Генерируемые рейтинги:**
- Ship (0-100%)
- Gay (0-100%)
- IQ (0-200)
- PP (0-30 см)
- Mustache (0-100, редко 1000-1500)
- Fat (0-100%)
- Boykisser (0-100%)
**Особенности:**
- Хранится в таблице `ratings` в SQLite
- Пропускает ботов
- Показывает статистику генерации
## 📚 Документация ## 📚 Документация
- **[FEATURES.md](FEATURES.md)** - Полный список функций - **[FEATURES.md](FEATURES.md)** - Полный список функций
@ -41,6 +90,10 @@ gradle run
- Java 21+ - Java 21+
- Gradle 8.x - Gradle 8.x
## ❤️ Health (HTTP)
В `.env` можно задать **`HEALTH_HTTP_PORT`** (0–65535, **0** = не поднимать сервер). После готовности JDA слушает **`GET /health`** и отдаёт JSON (uptime, счётчики команд, версия из манифеста или `dev`).
--- ---
**Версия:** 2.0 | **Лицензия:** MIT **Версия:** 2.0 | **Лицензия:** MIT

928
TODO.md
View file

@ -1,746 +1,254 @@
# TODO - План реализации функций # TODO — Loki Bot v2.1
## Правила разработки
### Архитектура и код:
- KISS, DRY, SOLID принципы
- Файлы максимум 150-200 строк, стараться делать 100 строк
- В одной папке не больше 6 файлов
- Не создавать новые MD файлы без необходимости
- Разделение по доменам, а не по типам артефактов
### Принципы структуры:
- Пакет = домен/фича, а не тип файла (не "команды/листенеры", а "profile/level/voice")
- Каждая фича содержит свои команды, сервисы, репозитории
- Единый слой персистентности через repository/
- Интеграции в отдельном пакете integration/
---
## Рефакторинг структуры проекта
### Целевая оценка: 7/10
**Проблемы:**
1. `util/` - мусорная корзина (CooldownManager, RateLimiter, NekosLifeApi)
2. Два механизма персистентности (data/ и database/) без абстракции
3. `command/impl/entertainment/` - 23 файла в одном пакете
4. `exception/` - избыточный пакет для 2 файлов
5. `embed/` - presentation layer оторван от domain
6. `listener/` - смешаны разные ответственности
7. Техническое разделение вместо доменного
8. **LevelSystem - God Class (5/10):**
- Нарушение SRP: хранение + кэширование + формулы + лидерборды + прогресс-бары
- Прямая зависимость от JsonDataStore (нет интерфейса Repository)
- O(n) на каждое сообщение (читает/пишет весь JSON)
- Кэш messageTimestamps не персистентный (теряется при рестарте)
- Рассогласование формул: комментарий говорит 200, код использует 100
- LevelUpResult и LevelProgress - вложенные классы вместо отдельных
- getAllStats() возвращает mutable map (можно сломать консистентность)
9. **UserStats - анемичная модель (6/10):**
- Сеттеры позволяют нарушить инварианты (setLevel(999) без XP)
- incrementMessages() скрыто меняет lastMessageTime (side effect)
- Поля lastMessageTime и lastVoiceJoin не используются системой
- Нет equals/hashCode для использования в коллекциях
### Целевая структура (по доменам):
```
src/main/java/creator/loki/
├── App.java # Точка входа (временно, потом в bootstrap/)
│
├── core/ # Ядро системы
│ ├── BotInitializer
│ ├── EventDispatcher
│ ├── registry/ # Регистрация команд
│ │ ├── CommandRegistry
│ │ ├── SlashCommandRegistry
│ │ ├── PrefixCommandRegistrar
│ │ └── SlashCommandRegistrar
│ ├── security/ # Безопасность и ограничения
│ │ ├── RateLimiter # (из util/)
│ │ └── CooldownManager # (из util/)
│ └── exception/ # Глобальная обработка ошибок
│ └── CommandExceptionHandler
│
├── command/ # Базовые интерфейсы команд
│ ├── Command.java
│ ├── CommandContext.java
│ ├── CommandException.java # (из exception/)
│ ├── SlashCommand.java
│ ├── moderation/ # Модерация (3 файла)
│ │ ├── BanCommand
│ │ ├── KickCommand
│ │ └── ClearCommand
│ ├── entertainment/ # Развлечения
│ │ ├── games/ # Игры (12 файлов)
│ │ └── social/ # Взаимодействия (11 файлов)
│ ├── utility/ # Утилиты (5 файлов)
│ │ ├── HelpCommand
│ │ ├── PingCommand
│ │ ├── InfoCommand
│ │ ├── AvatarCommand
│ │ └── PollCommand
│ └── system/ # Системные команды
│ └── MetricsCommand # (из utility/)
│
├── feature/ # Крупные фичи/домены
│ ├── level/ # Система уровней
│ │ ├── LevelSystem
│ │ ├── UserStats
│ │ ├── LevelUtils # (из profile/)
│ │ ├── RankCommand # (из command/stats/)
│ │ ├── StatsCommand # (из command/stats/)
│ │ ├── LevelUpNotifier # (из listener/)
│ │ └── VoiceTracker # (из listener/)
│ │
│ ├── profile/ # Профили пользователей
│ │ ├── ProfileService
│ │ ├── ProfileGenerator
│ │ ├── ProfileData
│ │ ├── ProfileCommand # (из command/utility/)
│ │ └── ProfileEmbedFactory # (из embed/)
│ │
│ ├── reputation/ # Система репутации
│ │ ├── ReputationService # (логика из RepCommand)
│ │ ├── RepCommand # (из command/stats/)
│ │ ├── MinusRepCommand # (из command/stats/)
│ │ └── RepTopCommand # (из command/stats/)
│ │
│ ├── leaderboard/ # Таблица лидеров
│ │ ├── LeaderboardCommand # (NewLeaderboardCommand)
│ │ ├── LeaderboardService # (логика из команды)
│ │ ├── LeaderboardFormatter
│ │ ├── LeaderboardButtons
│ │ └── LeaderboardButtonListener
│ │
│ ├── voice/ # Личные голосовые каналы
│ │ ├── VoiceChannelManager
│ │ ├── VoiceChannelData
│ │ ├── VoicePreset
│ │ ├── VoiceSetupCommand # (из command/setup/)
│ │ ├── VoiceChannelListener # (из listener/)
│ │ └── VoiceButtonHandler # (из listener/)
│ │
│ ├── counter/ # Счетчик участников
│ │ ├── CounterManager
│ │ ├── DailyStats
│ │ ├── CounterSetupCommand # (из command/setup/)
│ │ └── MemberCounterListener # (из listener/)
│ │
│ └── role/ # Кастомные роли
│ ├── CustomRoleManager
│ ├── CreateRoleCommand # (из command/roles/)
│ ├── DeleteRoleCommand # (из command/roles/)
│ ├── EditRoleCommand # (из command/roles/)
│ ├── MyRolesCommand # (из command/roles/)
│ └── RoleCommandValidator # (из command/roles/)
│
├── event/ # Обработчики событий Discord
│ ├── ReadyListener # (из listener/)
│ ├── MessageListener # (из listener/)
│ ├── SlashCommandListener # (из listener/)
│ ├── HelpMenuHandler # (из listener/)
│ └── ModerationLogger # (из listener/)
│
├── repository/ # Единый слой данных
│ ├── UserRepository # Интерфейс
│ ├── GuildConfigRepository # Интерфейс
│ └── impl/
│ ├── JsonUserRepository # (из data/)
│ └── SqliteConfigRepository # (из database/)
│
├── database/ # Инфраструктура БД
│ ├── Database
│ ├── DatabaseSchema
│ └── DatabaseMigration
│
├── integration/ # Внешние API
│ └── nekos/
│ └── NekosLifeApi # (из util/)
│
├── config/ # Конфигурация
│ ├── BotConfig
│ └── ConfigLoader
│
├── embed/ # Общие шаблоны embed
│ ├── EmbedFactory
│ └── EmbedTemplates
│
├── metrics/ # Метрики
│ └── MetricsCollector
│
└── di/ # Dependency Injection
└── BotModule
```
### План действий (этапы):
#### Этап 1: Фундамент (делаем первым)
**1. Создать repository/ (средний → высокий приоритет)**
- [x] Создать `repository/`
- [x] Создать интерфейс `repository/UserStatsRepository.java`
- [x] Создать `repository/impl/`
- [x] Создать `repository/impl/JsonUserStatsRepository.java` (обёртка над JsonDataStore)
- [ ] Создать интерфейс `repository/GuildConfigRepository.java`
- [ ] Рефакторить `database/ConfigRepository` → использовать интерфейс
**2. Реорганизовать util/ (мусорная корзина)**
- [x] Создать `core/security/`
- [x] Переместить `util/CooldownManager` → `core/security/CooldownManager`
- [x] Переместить `util/GlobalRateLimiter` → `core/security/RateLimiter`
- [x] Создать `integration/nekos/`
- [x] Переместить `util/NekosLifeApi` → `integration/nekos/NekosLifeApi`
- [x] Удалить пустую папку `util/`
- [x] Обновить все импорты в командах
- [x] Добавить `UserStatsRepository` в `BotModule`
**3. Удалить импорты AudioManager и мертвые ссылки**
- [x] Удалить `import creator.loki.audio.AudioManager` из `BotInitializer`
- [x] Удалить `import creator.loki.audio.AudioManager` из `PrefixCommandRegistrar`
- [x] Удалить `import creator.loki.audio.AudioManager` из `SlashCommandRegistrar`
- [x] Удалить параметр `AudioManager audioManager` из конструкторов
- [x] Удалить поля `private final AudioManager audioManager`
- [x] Удалить `registry.register(new TtsCommand(...))` из `PrefixCommandRegistrar`
- [x] Удалить `registry.register(new TtsSlashCommand(...))` из `SlashCommandRegistrar`
- [x] Исправить `registry.register(new NewLeaderboardCommand(...))` → использовать новый путь
- [x] Удалить все `registry.register(new *SlashCommand(...))` из `SlashCommandRegistrar`
- [x] Удалить `import creator.loki.command.slash.*` из `SlashCommandRegistrar`
- [x] Проверить компиляцию: `gradle build`
---
#### Этап 2: feature/level/ (зависит от Этапа 1)
**4. Создать feature/level/domain/**
- [x] Создать `feature/level/`
- [x] Создать `feature/level/domain/`
- [x] Создать `feature/level/domain/XpFormula.java`
- [x] Вынести `LevelUpResult` из `LevelSystem` → `feature/level/domain/LevelUpResult.java`
- [x] Вынести `LevelProgress` из `LevelSystem` → `feature/level/domain/LevelProgress.java`
- [x] Переместить `level/UserStats` → `feature/level/domain/UserStats.java`
- [x] **НЕ делать immutable сейчас** (Gson требует сеттеры, сделаем после Repository)
- [x] Добавить `equals()` и `hashCode()` в `UserStats`
- [x] Переименовать `incrementMessages()` → `recordMessage(long timestamp)`
**5. Создать feature/level/service/**
- [x] Создать `feature/level/service/`
- [x] Создать `feature/level/service/MessageCooldownService.java`
- [x] Создать `feature/level/service/LeaderboardService.java`
- [x] Рефакторить `level/LevelSystem` → `feature/level/service/LevelService.java`
- [x] Использовать `UserStatsRepository` вместо `JsonDataStore`
- [x] Использовать `XpFormula` для вычислений
- [x] Использовать `MessageCooldownService` для кулдаунов
- [x] Убрать методы лидерборда (в `LeaderboardService`)
- [x] Обновить все импорты UserStats на новый путь
- [x] Удалить старый `level/UserStats.java`
- [x] Добавить сервисы в `BotModule`
**6. Переместить команды и листенеры в feature/level/**
- [x] Переместить `command/impl/stats/RankCommand` → `feature/level/RankCommand`
- [x] Переместить `command/impl/stats/StatsCommand` → `feature/level/StatsCommand`
- [x] Переместить `listener/LevelUpNotifier` → `feature/level/LevelUpNotifier`
- [x] Переместить `listener/VoiceTracker` → `feature/level/VoiceTracker`
- [x] Обновить все импорты
- [x] Обновить `BotInitializer`, `BotModule`, `PrefixCommandRegistrar`
- [x] Обновить команды для использования `LevelService` вместо `LevelSystem`
**7. Создать команду сброса уровней (миграция на новую систему)**
- [ ] Создать `feature/level/ResetLevelsCommand.java`:
- Только для администраторов
- Подтверждение через кнопки (Да/Нет)
- Сброс всех уровней и XP
- Очистка таблицы `user_stats` в БД
- Логирование действия
- Уведомление в канал логов
- Embed с предупреждением о необратимости
- [ ] Зарегистрировать команду в `PrefixCommandRegistrar`
- [ ] Добавить в категорию "Настройка" (только админы)
---
#### Этап 3: Остальные feature/ (параллельно, независимо)
**7. Создать feature/voice/ (голосовые каналы)**
- [ ] Создать `feature/voice/`
- [ ] Переместить `voice/VoiceChannelManager` → `feature/voice/`
- [ ] Переместить `voice/VoiceChannelData` → `feature/voice/`
- [ ] Переместить `voice/VoicePreset` → `feature/voice/`
- [ ] Переместить `command/impl/setup/VoiceSetupCommand` → `feature/voice/`
- [ ] Переместить `listener/VoiceChannelListener` → `feature/voice/`
- [ ] Переместить `listener/VoiceButtonHandler` → `feature/voice/`
- [ ] Удалить пустую папку `voice/`
- [ ] Обновить импорты
**8. Создать feature/counter/ (счетчик участников)**
- [ ] Создать `feature/counter/`
- [ ] Переместить `counter/CounterManager` → `feature/counter/`
- [ ] Переместить `counter/DailyStats` → `feature/counter/`
- [ ] Переместить `command/impl/setup/CounterSetupCommand` → `feature/counter/`
- [ ] Переместить `listener/MemberCounterListener` → `feature/counter/`
- [ ] Удалить пустую папку `counter/`
- [ ] Обновить импорты
**9. Создать feature/role/ (кастомные роли)**
- [ ] Создать `feature/role/`
- [ ] Переместить `role/CustomRoleManager` → `feature/role/`
- [ ] Переместить `command/impl/roles/CreateRoleCommand` → `feature/role/`
- [ ] Переместить `command/impl/roles/DeleteRoleCommand` → `feature/role/`
- [ ] Переместить `command/impl/roles/EditRoleCommand` → `feature/role/`
- [ ] Переместить `command/impl/roles/MyRolesCommand` → `feature/role/`
- [ ] Переместить `command/impl/roles/RoleCommandValidator` → `feature/role/`
- [ ] Удалить пустые папки `role/` и `command/impl/roles/`
- [ ] Обновить импорты
**10. Создать feature/reputation/ (система репутации)**
- [ ] Создать `feature/reputation/`
- [ ] Создать `feature/reputation/ReputationService.java` (вынести логику из команд)
- [ ] Переместить `command/impl/stats/RepCommand` → `feature/reputation/`
- [ ] Переместить `command/impl/stats/MinusRepCommand` → `feature/reputation/`
- [ ] Переместить `command/impl/stats/RepTopCommand` → `feature/reputation/`
- [ ] Рефакторить команды: использовать `ReputationService`
- [ ] Обновить импорты
**11. Создать feature/leaderboard/ (таблица лидеров)**
- [ ] Создать `feature/leaderboard/`
- [ ] Переместить `command/impl/stats/leaderboard/NewLeaderboardCommand` → `feature/leaderboard/LeaderboardCommand`
- [ ] Переместить `command/impl/stats/leaderboard/LeaderboardFormatter` → `feature/leaderboard/`
- [ ] Переместить `command/impl/stats/leaderboard/LeaderboardButtons` → `feature/leaderboard/`
- [ ] Переместить `command/impl/stats/leaderboard/LeaderboardButtonListener` → `feature/leaderboard/`
- [ ] Создать `feature/leaderboard/LeaderboardService.java` (использовать из level/)
- [ ] Удалить пустую папку `command/impl/stats/`
- [ ] Обновить импорты
**12. Создать feature/profile/ (профили пользователей)**
- [ ] Создать `feature/profile/`
- [ ] Переместить `profile/ProfileService` → `feature/profile/`
- [ ] Переместить `profile/ProfileGenerator` → `feature/profile/`
- [ ] Переместить `profile/ProfileData` → `feature/profile/`
- [ ] Переместить `command/impl/utility/ProfileCommand` → `feature/profile/`
- [ ] Создать `feature/profile/ProfileEmbedFactory.java` (вынести из команды)
- [ ] Удалить пустую папку `profile/`
- [ ] Обновить импорты
**13. Создать feature/metrics/ (метрики системы)**
- [ ] Создать `feature/metrics/`
- [ ] Переместить `metrics/MetricsCollector` → `feature/metrics/`
- [ ] Переместить `command/impl/utility/MetricsCommand` → `feature/metrics/`
- [ ] Создать `feature/metrics/MetricsService.java` (если нужна логика)
- [ ] Удалить пустую папку `metrics/`
- [ ] Обновить импорты
---
#### Этап 4: Инфраструктура (после feature/)
**14. Реорганизовать event/ (listener)**
- [ ] Создать `event/`
- [ ] Создать `event/gateway/` (JDA Gateway events)
- [ ] Переместить `listener/ReadyListener` → `event/gateway/`
- [ ] Переместить `listener/MessageListener` → `event/gateway/`
- [ ] Создать `event/gateway/GuildListener.java` (member join/leave + moderation)
- [ ] Переместить `listener/ModerationLogger` → `event/gateway/GuildListener` (или отдельно)
- [ ] Создать `event/interaction/` (Button/Menu interactions)
- [ ] Переместить `listener/SlashCommandListener` → `event/interaction/`
- [ ] Переместить `listener/HelpMenuHandler` → `event/interaction/`
- [ ] Удалить пустую папку `listener/`
- [ ] Обновить импорты
**15. Реорганизовать core/**
- [ ] Создать `core/registry/`
- [ ] Переместить `core/CommandRegistry` → `core/registry/`
- [ ] Переместить `core/SlashCommandRegistry` → `core/registry/`
- [ ] Переместить `core/PrefixCommandRegistrar` → `core/registry/`
- [ ] Переместить `core/SlashCommandRegistrar` → `core/registry/`
- [ ] Создать `core/exception/`
- [ ] Переместить `exception/CommandExceptionHandler` → `core/exception/`
- [ ] Переместить `exception/CommandException` → `command/CommandException`
- [ ] Удалить пустую папку `exception/`
- [ ] Обновить импорты
**16. Почистить command/**
- [ ] Переименовать `command/impl/entertainment/interaction/` → `command/entertainment/social/`
- [ ] Удалить `command/impl/setup/` (команды перенесены в feature/)
- [ ] Удалить `command/impl/utility/SwapCommand.java` (не используется)
- [ ] Оставить в `command/` только:
- Интерфейсы: `Command.java`, `CommandContext.java`, `SlashCommand.java`, `CommandException.java`
- `command/moderation/` (если не выносим в feature/)
- `command/entertainment/` (кросс-доменные развлечения)
- `command/utility/` (HelpCommand, PingCommand, InfoCommand, AvatarCommand, PollCommand)
**17. Вынести App.java из корня**
- [ ] Создать `bootstrap/`
- [ ] Создать `bootstrap/App.java`
- [ ] Переместить логику инициализации из корневого `App.java`
- [ ] Удалить корневой `App.java`
- [ ] Обновить `build.gradle` (mainClass)
---
#### Этап 5: Финальная полировка (опционально)
**18. UserStats immutable (после Repository)**
- [ ] Создать `UserStats.Builder`
- [ ] Убрать public сеттеры (оставить package-private для Gson)
- [ ] Добавить методы `addXP()`, `recordMessage()`, `addVoiceTime()`
- [ ] Добавить `getLevel(XpFormula)` вместо внешних вычислений
- [ ] Обновить `JsonUserStatsRepository` для работы с immutable
**19. Обновить документацию**
- [ ] Обновить `FEATURES.md` (убрать TTS, добавить новые фичи)
- [ ] Обновить `README.md` (новая структура проекта)
- [ ] Создать `ARCHITECTURE.md` (описание доменов и feature/)
- [ ] Обновить комментарии в коде
**20. Финальная проверка**
- [ ] `gradle build` - компиляция без ошибок
- [ ] Проверить все импорты
- [ ] Проверить что нет папок с >6 файлами
- [ ] Проверить что нет файлов >200 строк
- [ ] Запустить бота и протестировать основные команды
---
## Рефакторинг кода (Code Quality)
### Убрать if-else леса в rating командах
**Проблема:**
Все rating команды (MustacheCommand, IqCommand, FatCommand, GayCommand, PpCommand, BoykisserCommand) содержат огромные if-else цепочки для определения уровня/комментария/цвета.
**Пример плохого кода (MustacheCommand):**
```java
String comment;
Color color;
if (mustacheLevel >= 1000) {
comment = "🎩 ЛЕГЕНДАРНЫЕ УСИЩИ!";
color = new Color(255, 215, 0);
} else if (mustacheLevel >= 80) {
comment = "🧔 Роскошные усы!";
color = new Color(139, 69, 19);
} else if (mustacheLevel >= 60) {
comment = "👨 Хорошие усы";
color = new Color(160, 82, 45);
} else if (mustacheLevel >= 40) {
comment = "😊 Усики растут";
color = new Color(205, 133, 63);
} else if (mustacheLevel >= 20) {
comment = "🙂 Лёгкая щетина";
color = new Color(210, 180, 140);
} else if (mustacheLevel > 0) {
comment = "😐 Почти нет усов";
color = new Color(128, 128, 128);
} else {
comment = "🪒 Гладко выбрит";
color = new Color(200, 200, 200);
}
```
**Решение:**
Создать общий класс `RatingLevel` с конфигурацией уровней:
```java
public class RatingLevel {
private final int threshold;
private final String comment;
private final Color color;
public static RatingLevel findLevel(int value, List<RatingLevel> levels) {
return levels.stream()
.filter(level -> value >= level.threshold)
.findFirst()
.orElse(levels.get(levels.size() - 1));
}
}
```
**План действий:**
- [x] Создать `command/impl/entertainment/games/rating/RatingLevel.java`
- [x] Создать `command/impl/entertainment/games/rating/RatingConfig.java` с предустановленными конфигурациями
- [x] Рефакторить MustacheCommand - использовать RatingLevel
- [x] Рефакторить IqCommand - использовать RatingLevel
- [x] Рефакторить FatCommand - использовать RatingLevel
- [x] Рефакторить GayCommand - использовать RatingLevel (без уровней, только прогресс-бар)
- [x] Рефакторить PpCommand - использовать RatingLevel
- [x] Рефакторить BoykisserCommand - использовать RatingLevel
- [x] Проверить что все команды работают корректно
- [x] Удалить дублирующийся код
**Приоритет:** ✅ **ГОТОВО**
---
## Основные задачи
### 1. ⚡ Переделать систему опыта (XP System Rework)
**Текущая проблема:**
- Формула: `XP = 100 * level^1.5` (слишком сложная)
- XP за сообщение: 10 XP
- XP за минуту в войсе: 5 XP
- Cooldown на сообщения: 60 секунд
**Новая система (из FEATURES.md):**
- ✅ **Формула прогрессии:** `XP для следующего уровня = 200 * текущий_уровень`
- Пример: уровень 1 → 2 требует 200 XP
- Уровень 5 → 6 требует 1000 XP
- Уровень 10 → 11 требует 2000 XP
- ✅ **Голосовая активность:**
- 1 XP каждую минуту нахождения в голосовом канале
- Автоматическое отслеживание через `VoiceXpListener`
- Уведомление при повышении уровня в войс-канале
**Файлы для изменения:**
- `src/main/java/creator/loki/level/LevelSystem.java` - изменить формулу
- `src/main/java/creator/loki/listener/VoiceTracker.java` - изменить начисление XP
- Добавить уведомления о level up в войсе
**Статус:** ✅ **ГОТОВО**
---
### 2. 🎤 Система личных голосовых каналов (Voice Management System)
**Концепция:**
Пользователь заходит в специальный канал-триггер → бот автоматически создает личный войс-канал → перемещает пользователя туда → пишет сообщение в чат войса с настройками
**Функционал:**
#### 2.1 Автоматическое создание каналов
- ✅ Создать `VoiceChannelManager` класс
- ✅ Listener на вход в канал-триггер (например, "➕ Создать канал")
- ✅ Автоматическое создание канала с именем пользователя
- ✅ Перемещение пользователя в созданный канал
- ✅ Выдача прав управления владельцу канала
- ✅ Автоматическое удаление канала когда он пустой
#### 2.2 Сообщение с настройками в чате войса
При создании канала бот отправляет embed с кнопками:
- 🔒 **Закрыть/Открыть** - переключение приватности
- 👥 **Лимит** - установить лимит пользователей (0-99)
- ✏️ **Переименовать** - изменить название канала
- 🎨 **Битрейт** - изменить качество звука
- 🚫 **Удалить** - удалить канал
#### 2.3 Система пресетов
- ✅ Сохранение настроек как пресет
- ✅ Быстрое применение сохраненных настроек
- ✅ Хранение в `data/voice_presets.json`
**Структура данных:**
```json
{
"voice_channels": {
"channel_id": {
"owner_id": "user_id",
"created_at": 1234567890,
"settings": {
"name": "Канал пользователя",
"limit": 5,
"bitrate": 64000,
"locked": false
}
}
},
"presets": {
"user_id": [
{
"name": "Gaming",
"limit": 4,
"bitrate": 96000
}
]
}
}
```
**Файлы для создания:**
- `src/main/java/creator/loki/voice/VoiceChannelManager.java`
- `src/main/java/creator/loki/voice/VoiceChannelData.java`
- `src/main/java/creator/loki/voice/VoicePreset.java`
- `src/main/java/creator/loki/listener/VoiceChannelListener.java`
- `src/main/java/creator/loki/command/impl/voice/VoiceSettingsCommand.java` (для кнопок)
**Статус:** ✅ **ГОТОВО** (базовая реализация, кнопки управления требуют обработчиков)
---
### 3. 📊 Счетчик участников (Member Counter)
**Функционал:**
- ✅ Обновление названия голосового канала с количеством участников
- ✅ Формат: `【👥】Участники: 123`
- ✅ Отслеживание дневной статистики:
- Количество присоединившихся за день
- Количество покинувших за день
- Разница за 24 часа
- ✅ Обновление каждые 5 минут
- ✅ Сохранение статистики в `data/counter_data.json`
**Структура данных:**
```json
{
"counter_channel_id": "1234567890",
"daily_stats": {
"date": "2026-05-11",
"joined": 15,
"left": 8,
"difference": 7
},
"history": [
{
"date": "2026-05-10",
"joined": 20,
"left": 12,
"difference": 8
}
]
}
```
**Файлы для создания:**
- `src/main/java/creator/loki/listener/MemberCounterListener.java`
- `src/main/java/creator/loki/counter/CounterManager.java`
- `src/main/java/creator/loki/counter/DailyStats.java`
**Статус:** 🔴 Не начато
---
## ✅ Что уже сделано ## ✅ Что уже сделано
### ✅ Система опыта переделана | # | Статус | Проблема | Изменения |
- Новая формула: `XP для уровня N = 200 * N` |---|--------|----------|-----------|
- 1 XP за минуту в войсе | C1–C15 | ✅ | Предыдущие раунды | См. git history |
- Обновление каждую минуту вместо 5 минут | J21 | ✅ | Java 17 → 21 | `build.gradle`: source/target 21 |
- Метод `addVoiceTime` теперь возвращает `LevelUpResult` для уведомлений | A05 | ✅ | EventDispatcher: CachedThreadPool → VirtualThreadPool | `Executors.newVirtualThreadPerTaskExecutor()` |
| A06 | ✅ | SwapCommand + RepTopCommand: `new Thread()` → `Thread.startVirtualThread()` | |
### ✅ Система личных войс-каналов (базовая) | A25 | ✅ | CommandExceptionHandler: instanceof pattern matching | |
- `VoiceChannelManager` - управление каналами | A26 | ✅ | ClearCommand: instanceof pattern matching | |
- `VoiceChannelListener` - автоматическое создание/удаление | A27 | ✅ | DatabaseMigration: instanceof pattern matching | |
- `VoiceChannelData` и `VoicePreset` - модели данных | A28 | ✅ | InfoCommand: switch → arrow-switch | |
- Автоматическое создание при входе в триггер-канал | A29 | ✅ | RateCommand: `"⭐".repeat(rating)` + `"☆".repeat()` | |
- Автоматическое удаление пустых каналов | A32 | ✅ | .collect(Collectors.toList()) → .toList() | SwapCommand, RepTopCommand, ChoiceOptionParser |
- Панель управления с кнопками (требуется обработчик кнопок) | A33 | ✅ | HelpCategories: Map.ofEntries() вместо HashMap + unmodifiable | |
| A02 | ✅ | RankCardGenerator: блокировка Event Thread | Исправлено (команды на virtual threads) |
### ✅ Счетчик участников | A10 | ✅ | RankCardGenerator: утечка InputStream | try-with-resources в loadFont + loadBackground |
- `CounterManager` - управление счетчиком | A11 | ✅ | RankCardGenerator: шрифты не кэшировались | ConcurrentHashMap cache |
- `MemberCounterListener` - отслеживание входов/выходов | A12 | ✅ | RankCardGenerator: SVG-иконки не работали | Заменены на геометрические фигуры (Java2D) |
- `DailyStats` - дневная статистика | A23 | ✅ | RankCardGenerator: throws Exception | loadFont безопасный |
- Обновление каждые 5 минут | A03 | ✅ | Database: race condition на connection | synchronized методы |
- История за 30 дней | A13 | ✅ | NekosLifeApi: Thread.sleep в catch | Сон вынесен в retry loop |
| A14 | ✅ | GenerateRatingsCommand: блокировка event-потока | `complete()` вместо `queue()` |
### ✅ SQLite база данных + автомиграция | A35 | ✅ | GenerateRatingsCommand: опечатка | `Пропускаемботов` → `Пропускаем ботов` |
- `Database` - менеджер SQLite | A04 | ✅ | PrefixCommandRegistrar: God Constructor (22 param) | Разбит на 4 per-feature CommandRegistrar, Multibinder, 1 param |
- `DatabaseMigration` - автоматическая миграция из JSON | A21 | ✅ | BotModule: путь к БД хардкодом | Вынесен в .env (`DB_PATH`) + BotConfig |
- `ConfigRepository` - работа с конфигурацией | A34 | ✅ | CommandCategories: мёртвый код | Уже удалён (старый пакет) |
- Все таблицы созданы автоматически | A37 | ✅ | ProfileCommand: регистрация в PrefixCommandRegistrar | LevelCommandRegistrar |
- Миграция данных при первом запуске
### ✅ Команды настройки
- `/voice-setup trigger` - установить канал-триггер
- `/voice-setup category` - установить категорию
- `/voice-setup text-channel` - установить текстовый канал
- `/voice-setup info` - показать конфигурацию
- `/counter-setup channel` - установить канал счетчика
- `/counter-setup format` - установить формат
- `/counter-setup info` - показать конфигурацию
--- ---
## 🔧 Дополнительные улучшения ## ♻️ Правила разработки
### 4. 🎨 Улучшение уведомлений о level up - KISS, DRY, SOLID
- ✅ Красивые embed сообщения при повышении уровня - Файлы ≤150–200 строк (в идеале ≤100)
- ✅ Отправка в тот же канал где получен XP - В папке ≤6 файлов
- ✅ Для войса - отправка в текстовый канал связанный с войсом - Пакет = домен/фича, а не тип файла (`feature/level/`, а не `commands/listeners/`)
- ✅ Показывать прогресс до следующего уровня - Каждая фича содержит свои команды, сервисы, репозитории
- Единый слой персистентности через `repository/`
**Статус:** 🔴 Не начато - Интеграции в `integration/`
--- ---
### 5. 🛠️ Команды для настройки систем (Admin Commands) ## 🔴 Проблемы (результаты аудита, которые ещё не исправлены)
#### Voice System Setup: ### HIGH
- `/voice-setup trigger <channel>` - установить канал-триггер
- `/voice-setup category <category>` - установить категорию для личных каналов
- `/voice-setup text-channel <channel>` - установить текстовый канал для уведомлений
#### Counter Setup: | # | Приоритет | Проблема | Статус |
- `/counter-setup channel <voice-channel>` - установить канал для счетчика |---|-----------|----------|--------|
- `/counter-setup format <format>` - изменить формат отображения | A07 | 🟠 **HIGH** | `HealthHttpServer` — single thread executor | ✅ Virtual threads |
- `/counter stats` - показать статистику за период | A08 | 🟠 **HIGH** | Тестовое покрытие ~5% (~34 теста на 149 продакшен-файлов). Нужно 20-30% | ⏳ 268 тестов в 22 файлах |
| A09 | 🟠 **HIGH** | Нет Docker / docker-compose | ✅ Dockerfile + docker-compose + .env.example |
| A01 | ⚪ **INFO** | `.env` с токеном в git-истории (локальный репозиторий) | ➖ |
**Статус:** 🔴 Не начато ### MEDIUM
| # | Приоритет | Проблема | Статус |
|---|-----------|----------|--------|
| A15 | 🟡 **MEDIUM** | ~33 файла команд в `command/impl/` — не перенесены в `feature/*/commands/` | ✅ 33 файла перенесены |
| A16 | 🟡 **MEDIUM** | ~16 репозиториев в `database/` — не разнесены в `feature/*/repository/` | ✅ 13+3 репозитория перенесены |
| A18 | 🟡 **MEDIUM** | `DatabaseMigration` — устаревший код для JSON→SQLite. Нужно оценить | ✅ Harmless, idempotent — keep as-is |
| A19 | 🟡 **MEDIUM** | Нет статического анализа (checkstyle / spotbugs) | ✅ checkstyle.xml + spotbugs + 169 known violations |
| A20 | 🟡 **MEDIUM** | Нет версионного каталога (`libs.versions.toml`) | ✅ gradle/libs.versions.toml, build.gradle переписан |
| A22 | 🟡 **MEDIUM** | Нет пула соединений (HikariCP). Голый JDBC | ✅ HikariCP 5.1.0 + proxy-based prepare() |
| A24 | 🟡 **MEDIUM** | VoiceTracker, RateLimiter, CooldownManager, MuteScheduler — ScheduledExecutorService. virtual threads? | ✅ Virtual threads |
| A40 | 🟡 **MEDIUM** | `HelpMenuHandler` в `event/interaction/` — должен быть в `feature/help/` | ✅ Перенесён |
### LOW
| # | Приоритет | Проблема | Статус |
|---|-----------|----------|--------|
| A30 | 🟢 **LOW** | SlowmodeCommand: ручная проверка границ → Math.clamp (другая семантика) | ➖ |
| A31 | 🟢 **LOW** | ClearCommand: ручная проверка границ → Math.clamp (другая семантика) | ➖ |
| A39 | 🟢 **LOW** | EmbedFactory: `set*` вместо `with*` для builder | ✅ Renamed in 43 files |
| A36 | 🟢 **LOW** | RankCardGenerator: URI → URL, deprecated API | ✅ `URI.toURL()` → `new URL()` |
--- ---
## 📝 Приоритеты реализации ## 📋 План рефакторинга
### Высокий приоритет (сделать в первую очередь): ### Этап 1: Java 21 ✅ (14 items)
1. ⚡ Переделать систему опыта (простая формула) - [x] EventDispatcher: VirtualThreadPool
2. 🎤 Базовая система личных войс-каналов (создание/удаление) - [x] instanceof pattern matching (3 файла)
3. 📊 Счетчик участников - [x] arrow-switch (InfoCommand)
- [x] String.repeat() (RateCommand)
- [x] .toList() (3 файла)
- [x] Map.ofEntries() (HelpCategories)
### Средний приоритет: ### Этап 2: Рефакторинг PrefixCommandRegistrar ✅
4. 🎤 Настройки войс-каналов через кнопки - [x] Создан интерфейс `CommandRegistrar`
5. 🎨 Улучшение уведомлений о level up - [x] 4 per-feature регистратора
6. 🎤 Система пресетов для войс-каналов - [x] Guice Multibinder для сбора регистраторов
- [x] 22 параметра → 1 (`Set<CommandRegistrar>`)
### Низкий приоритет: ### Этап 3: Critical-баги ✅
7. 🛠️ Админ команды для настройки - [x] RankCardGenerator: async + try-with-resources + cache + SVG
- [x] Database: synchronized connection
- [x] NekosLifeApi: чистый retry loop
- [x] GenerateRatingsCommand: async через complete()
- [x] DB path: вынесен в .env
### Этап 4: Перемещение файлов в feature-пакеты ✅
- [x] `command/impl/utility/` → `feature/*/commands/`
- [x] `command/impl/entertainment/` → `feature/*/commands/`
- [x] `command/entertainment/social/` → `feature/social/commands/`
- [x] `database/*/` → `feature/*/repository/`
- [x] `event/interaction/HelpMenuHandler` → `feature/help/`
### Этап 5: Тесты (цель: 20-30% покрытие)
- [x] Тесты на репозитории (in-memory SQLite) — Config, ShipPair, UserStats, Ratings, Warnings, ActiveMutes
- [x] Unit-тесты сервисов — MessageCooldownService, LevelService (был), XpFormula, ChoiceOptionParser
- [x] Unit-тесты value objects — UserStats (был), LevelProgress, LevelUpResult, BotConfig
- [x] Тесты embed — EmbedFactory (builder chaining), EmbedTemplates (factory methods)
- [x] Тесты форматирования — LeaderboardFormatter
- [ ] Unit-тесты на команды (с моками JDA)
- [ ] Smoke-тест: все команды регистрируются без коллизий
- [ ] Тесты на RankCardGenerator (изолированные, без сети)
- [ ] Тесты на EventDispatcher (маршрутизация, rate limiting)
### Этап 6: Инфраструктура ✅
- [x] Dockerfile (multi-stage)
- [x] docker-compose.yml
- [x] Статический анализ: checkstyle / spotbugs
- [x] Версионный каталог: `gradle/libs.versions.toml`
- [x] HikariCP вместо голого JDBC
### Этап 7: Кодстайл
- [x] EmbedFactory: `set*` → `with*` для builder
- [ ] DatabaseMigration: оценить нужность
- [x] HelpMenuHandler: переместить в feature/help/
--- ---
## 🐛 Известные проблемы (Bugs to Fix) ## 🚀 Новые фичи (Roadmap)
### Критические: ## 🚀 Экономика — план реализации (в работе)
- [ ] Проверить корректность начисления XP в войсе
- [ ] Убедиться что VoiceTracker правильно отслеживает время
### Некритические: ### Модель данных
- [ ] Удалить неиспользуемый logger в LevelSystem.java
- [ ] Убрать ненужный @SuppressWarnings в LevelSystem.java
--- ```sql
CREATE TABLE IF NOT EXISTS economy (
user_id TEXT PRIMARY KEY,
cash INTEGER NOT NULL DEFAULT 0, -- на руках
bank INTEGER NOT NULL DEFAULT 0, -- в банке
total_earned INTEGER NOT NULL DEFAULT 0,
total_spent INTEGER NOT NULL DEFAULT 0,
last_daily TEXT, -- ISO date
daily_streak INTEGER DEFAULT 0,
fishing_luck INTEGER DEFAULT 0, -- stat lvl 0-5
efficiency INTEGER DEFAULT 0, -- stat lvl 0-5
last_fish TEXT, -- ISO datetime
job TEXT, -- название работы
last_work TEXT -- ISO datetime
);
```
## 📚 Технические детали ### Заработок
### Зависимости: | Механика | Доход | Кулдаун |
- JDA 5.0.0 (уже есть) |---|---|---|
- Guice для DI (уже есть) | Сообщение | 1–3 LC | 60с |
- Gson для JSON (уже есть) | Голос | 1 LC / 2 мин | 2 мин |
| Ежедневно | 50 LC + streak ×1.5 на 7й день | 24ч |
| +rep (полученная) | 10 LC | — |
| Рыбалка | base × speed_mul × (1 + fishing_luck×0.01) | 2ч |
| Работа | random(range) × (1 + efficiency×0.01) | 6ч |
| Блэкджек | ×1.5 при победе | — |
| Слоты | ×0–×50 | — |
### Конфигурация (добавить в config): ### Миниигры
```yaml
voice: **Рыбалка** (`!fish easy/normal/hard`)
trigger_channel_id: 1234567890 - Сетка N×N (emoji + 🌊), бесплатно, неправильный ответ = 0
category_id: 1234567890 - Множитель скорости: <50% времени = ×1.5, <30% = ×2.0
text_channel_id: 1234567890 - Стат `fishing_luck` (макс 5, стоимость 800×lvl)
auto_delete_empty: true
**Блэкджек** (`!bj <bet>`)
counter: - Дилер стоит на 16, ничья = проигрыш, BJ = ×1.5
channel_id: 1234567890 - Кнопки Hit/Stand
update_interval: 300 # секунды
format: "【👥】Участники: {count}" **Слоты** (`!slot <bet>`)
- 🍒🍒🍒 ×3 (10%), 🔔🔔🔔 ×5 (5%), 💎💎💎 ×10 (2%), 👑👑👑 ×15 (1%), ⚡⚡⚡ ×50 (0.1%)
**Работы** (`!job list`, `!job join`, `!work`)
| Работа | Ур. | Доход |
|---|---|---|
| 🪓 Лесоруб | — | 15–30 |
| ⛏️ Шахтёр | — | 20–40 |
| 🎣 Рыбак | — | 25–50 |
| 🍳 Повар | 15 | 30–60 |
| 💻 Программист | 20 | 40–80 |
| 🧑‍🚀 Космонавт | 35 | 50–100 |
Стат `efficiency` (макс 5, стоимость 800×lvl)
**Rob** (`!rob @user`)
- 24ч кулдаун, 40% успех, крадёт до 15% cash (макс 10к)
- Провал: штраф 10% cash (макс 5к) → жертве
- Банк не трогается
**Банк** (`!bank deposit/withdraw/balance`)
- Без процентов, защита от rob
### Структура пакета
```
feature/economy/
EconomyModule.java
EconomyCommandRegistrar.java
domain/
EconomyAccount.java
EconomyTransaction.java
Job.java
SlotCombination.java
repository/
EconomyRepository.java
SqlEconomyRepository.java
service/
EconomyService.java
FishingService.java
BlackjackService.java
SlotService.java
JobService.java
command/
core/ (Balance, Baltop, Pay, Daily)
minigame/ (Fish, Blackjack, Slot, Job, Work)
social/ (Rob)
bank/ (Bank)
``` ```
--- ---
## ✅ Чеклист перед релизом ### Приоритет 1
- [ ] Система достижений (Achievements)
- [x] Система экономики (Economy) — **в работе**
- [ ] Все функции протестированы ### Приоритет 2
- [ ] Логирование работает корректно - [x] Авто-модерация (3 варна = авто-мут на 1ч)
- [ ] Нет утечек памяти (проверить scheduler'ы) - [x] Музыка (lavaplayer: play, skip, stop, queue, np, volume, leave)
- [ ] Graceful shutdown для всех scheduler'ов - [x] ~~Музыка — поиск с выбором (yt/sc/list + выбор номера)~~
- [ ] Документация обновлена - [x] Server Stats (участники, каналы, роли, бусты)
- [ ] FEATURES.md актуализирован - [ ] Level Roles (роли за уровни)
- [ ] Красивые level-up embed с прогресс-баром
### Приоритет 3
- [ ] Статистика сервера — графики, CSV export
- [ ] Система квестов
- [ ] Wiki по установке и командам
--- ---
**Дата создания:** 2026-05-11 **Версия:** 2.2 | **Последнее обновление:** 2026-06-20 | **Java 21 ✅, God Constructor ✅, Critical баги ✅, Economy 🚧**
**Версия:** 1.0
**Автор:** Loki Bot Team

View file

@ -1,6 +1,8 @@
plugins { plugins {
id 'java' id 'java'
id 'application' id 'application'
id 'checkstyle'
alias(libs.plugins.spotbugs)
} }
group = 'creator.loki' group = 'creator.loki'
@ -11,47 +13,33 @@ repositories {
} }
dependencies { dependencies {
// JDA - Discord API implementation(libs.jda) {
implementation('net.dv8tion:JDA:5.0.0-beta.20') {
exclude module: 'opus-java' exclude module: 'opus-java'
} }
implementation libs.lavaplayer
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
// LavaPlayer для голосового TTS testImplementation libs.junit.jupiter
implementation 'dev.arbjerg:lavaplayer:2.2.2' testImplementation libs.mockito.core
testImplementation libs.mockito.junit.jupiter
// dotenv для загрузки .env testRuntimeOnly libs.junit.platform.launcher
implementation 'io.github.cdimascio:dotenv-java:3.0.0'
// Logging
implementation 'org.slf4j:slf4j-api:2.0.12'
implementation 'ch.qos.logback:logback-classic:1.5.3'
// JSON
implementation 'com.google.code.gson:gson:2.10.1'
// Caffeine Cache
implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8'
// Google Guice - Dependency Injection
implementation 'com.google.inject:guice:7.0.0'
// SQLite Database
implementation 'org.xerial:sqlite-jdbc:3.45.1.0'
// Testing
testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'
testImplementation 'org.mockito:mockito-core:5.11.0'
testImplementation 'org.mockito:mockito-junit-jupiter:5.11.0'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
} }
java { java {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_21
} }
tasks.withType(JavaCompile) { tasks.withType(JavaCompile) {
options.encoding = 'UTF-8' options.encoding = 'UTF-8'
options.compilerArgs << '-Xlint:all'
} }
test { test {
@ -59,13 +47,13 @@ test {
} }
application { application {
mainClass = 'creator.loki.App' mainClass = 'creator.loki.bootstrap.App'
} }
jar { jar {
manifest { manifest {
attributes( attributes(
'Main-Class': 'creator.loki.App' 'Main-Class': 'creator.loki.bootstrap.App'
) )
} }
duplicatesStrategy = DuplicatesStrategy.EXCLUDE duplicatesStrategy = DuplicatesStrategy.EXCLUDE
@ -73,3 +61,14 @@ jar {
configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) } configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) }
} }
} }
checkstyle {
configFile = file("${rootDir}/config/checkstyle/checkstyle.xml")
maxErrors = 999
maxWarnings = 999
}
spotbugs {
excludeFilter = file("${rootDir}/config/spotbugs/exclude.xml")
ignoreFailures = true
}

View file

@ -0,0 +1,90 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE module PUBLIC
"-//Checkstyle//DTD Checkstyle Configuration 1.3//EN"
"https://checkstyle.org/dtds/configuration_1_3.dtd">
<module name="Checker">
<property name="charset" value="UTF-8"/>
<property name="severity" value="error"/>
<property name="fileExtensions" value="java"/>
<module name="BeforeExecutionExclusionFileFilter">
<property name="fileNamePattern" value="module\-info\.java$"/>
</module>
<module name="FileTabCharacter">
<property name="eachLine" value="true"/>
</module>
<module name="LineLength">
<property name="max" value="150"/>
<property name="ignorePattern" value="^package.*|^import.*|a href|href|http://|https://|ftp://"/>
</module>
<module name="RegexpSingleline">
<property name="format" value="\s+$"/>
<property name="message" value="Line has trailing whitespace."/>
</module>
<module name="TreeWalker">
<module name="PackageName">
<property name="format" value="^[a-z]+(\.[a-z][a-z0-9]*)*$"/>
</module>
<module name="TypeName">
<property name="tokens" value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, ANNOTATION_DEF, RECORD_DEF"/>
<property name="format" value="^[A-Z][a-zA-Z0-9]*$"/>
</module>
<module name="MethodName">
<property name="format" value="^[a-z][a-zA-Z0-9]*$"/>
</module>
<module name="ConstantName">
<property name="format" value="^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$|^logger$|^LOGGER$"/>
</module>
<module name="LocalVariableName">
<property name="format" value="^[a-z][a-zA-Z0-9]*$"/>
<property name="allowOneCharVarInForLoop" value="true"/>
</module>
<module name="MemberName">
<property name="format" value="^[a-z][a-zA-Z0-9]*$"/>
</module>
<module name="ParameterName">
<property name="format" value="^[a-z][a-zA-Z0-9]*$"/>
</module>
<module name="LambdaParameterName">
<property name="format" value="^[a-z][a-zA-Z0-9]*$"/>
</module>
<module name="LeftCurly">
<property name="option" value="eol"/>
</module>
<module name="RightCurly">
<property name="option" value="same"/>
</module>
<module name="WhitespaceAround">
<property name="allowEmptyConstructors" value="true"/>
<property name="allowEmptyMethods" value="true"/>
<property name="allowEmptyTypes" value="true"/>
<property name="allowEmptyLoops" value="true"/>
</module>
<module name="NoWhitespaceAfter"/>
<module name="NoWhitespaceBefore"/>
<module name="Indentation">
<property name="basicOffset" value="4"/>
<property name="braceAdjustment" value="0"/>
<property name="caseIndent" value="4"/>
<property name="throwsIndent" value="4"/>
<property name="lineWrappingIndentation" value="4"/>
<property name="arrayInitIndent" value="4"/>
</module>
</module>
</module>

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<FindBugsFilter
xmlns="https://github.com/spotbugs/filter"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://github.com/spotbugs/filter https://raw.githubusercontent.com/spotbugs/spotbugs/7.0.0/spotbugs/etc/findbugsfilter.xsd">
</FindBugsFilter>

21
docker-compose.yml Normal file
View file

@ -0,0 +1,21 @@
version: "3.9"
services:
bot:
build: .
container_name: loki-bot
restart: unless-stopped
ports:
- "8712:8712"
environment:
DISCORD_BOT_TOKEN: ${DISCORD_BOT_TOKEN}
BOT_PREFIX: ${BOT_PREFIX:-!}
BOT_ACTIVITY: ${BOT_ACTIVITY:-Используй !help}
HEALTH_HTTP_PORT: ${HEALTH_HTTP_PORT:-8712}
SUPER_ADMIN_ID: ${SUPER_ADMIN_ID}
DB_PATH: /app/data/bot.db
volumes:
- bot-data:/app/data
volumes:
bot-data:

40
gradle/libs.versions.toml Normal file
View file

@ -0,0 +1,40 @@
[versions]
jda = "5.0.0-beta.20"
lavaplayer = "2.2.2"
dotenv = "3.0.0"
slf4j = "2.0.12"
logback = "1.5.3"
gson = "2.10.1"
caffeine = "3.1.8"
guice = "7.0.0"
sqlite = "3.45.1.0"
hikaricp = "5.1.0"
junit = "5.10.2"
mockito = "5.11.0"
junitPlatformLauncher = "1.10.2"
checkstyle = "10.21.4"
spotbugs = "6.1.7"
spotbugsPlugin = "6.1.7"
[libraries]
jda = { module = "net.dv8tion:JDA", version.ref = "jda" }
lavaplayer = { module = "dev.arbjerg:lavaplayer", version.ref = "lavaplayer" }
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 = "hikaricp" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" }
mockito-junit-jupiter = { module = "org.mockito:mockito-junit-jupiter", version.ref = "mockito" }
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junitPlatformLauncher" }
checkstyle = { module = "com.puppycrawl.tools:checkstyle", version.ref = "checkstyle" }
spotbugs = { module = "com.github.spotbugs:spotbugs", version.ref = "spotbugs" }
spotbugs-annotations = { module = "com.github.spotbugs:spotbugs-annotations", version.ref = "spotbugs" }
[plugins]
checkstyle = { id = "checkstyle", version = "" }
spotbugs = { id = "com.github.spotbugs", version.ref = "spotbugsPlugin" }

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View file

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME

120
gradlew vendored Normal file
View file

@ -0,0 +1,120 @@
#!/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.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
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 "${APP_HOME:-./}" > /dev/null && pwd -P ) || 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
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*)
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
;;
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
;;
esac
fi
# Collect all arguments for the java command, stracks://the current
# application classpath, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$@"
exec "$JAVACMD" "$@"

View file

@ -1 +1,7 @@
rootProject.name = 'discord-bot' rootProject.name = 'discord-bot'
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}

View file

@ -1,50 +1,50 @@
package creator.loki; package creator.loki.bootstrap;
import com.google.inject.Guice; import com.google.inject.Guice;
import com.google.inject.Injector; import com.google.inject.Injector;
import creator.loki.core.BotInitializer; import creator.loki.core.BotInitializer;
import creator.loki.di.BotModule; import creator.loki.di.BotModule;
import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDA;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
public class App { public class App {
private static final Logger logger = LoggerFactory.getLogger(App.class); private static final Logger logger = LoggerFactory.getLogger(App.class);
private static volatile boolean shutdownRequested = false; private static volatile boolean shutdownRequested = false;
public static void main(String[] args) { public static void main(String[] args) {
// Регистрируем shutdown hook ДО создания бота // Регистрируем shutdown hook ДО создания бота
Thread shutdownHook = new Thread(() -> { Thread shutdownHook = new Thread(() -> {
if (!shutdownRequested) { if (!shutdownRequested) {
shutdownRequested = true; shutdownRequested = true;
logger.info("========================================"); logger.info("========================================");
logger.info("Получен сигнал завершения"); logger.info("Получен сигнал завершения");
logger.info("Выполняется graceful shutdown..."); logger.info("Выполняется graceful shutdown...");
logger.info("========================================"); logger.info("========================================");
} }
}, "ShutdownHook"); }, "ShutdownHook");
Runtime.getRuntime().addShutdownHook(shutdownHook); Runtime.getRuntime().addShutdownHook(shutdownHook);
try { try {
logger.info("Запуск Discord бота"); logger.info("Запуск Discord бота");
// Создаём Guice Injector с модулем конфигурации // Создаём Guice Injector с модулем конфигурации
Injector injector = Guice.createInjector(new BotModule()); Injector injector = Guice.createInjector(new BotModule());
// Получаем BotInitializer с автоматически внедрёнными зависимостями // Получаем BotInitializer с автоматически внедрёнными зависимостями
BotInitializer initializer = injector.getInstance(BotInitializer.class); BotInitializer initializer = injector.getInstance(BotInitializer.class);
// Запускаем бота // Запускаем бота
JDA jda = initializer.start(); JDA jda = initializer.start();
// Ждём полной загрузки // Ждём полной загрузки
jda.awaitReady(); jda.awaitReady();
logger.info("Бот полностью готов к работе!"); logger.info("Бот полностью готов к работе!");
} catch (Exception e) { } catch (Exception e) {
logger.error("Критическая ошибка при запуске бота", e); logger.error("Критическая ошибка при запуске бота", e);
System.exit(1); System.exit(1);
} }
} }
} }

View file

@ -1,16 +1,18 @@
package creator.loki.listener; package creator.loki.bootstrap;
import net.dv8tion.jda.api.events.session.ReadyEvent; import com.google.inject.Singleton;
import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.dv8tion.jda.api.events.session.ReadyEvent;
import org.slf4j.Logger; import net.dv8tion.jda.api.hooks.ListenerAdapter;
import org.slf4j.LoggerFactory; import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class ReadyListener extends ListenerAdapter {
private static final Logger logger = LoggerFactory.getLogger(ReadyListener.class); @Singleton
public class ReadyListener extends ListenerAdapter {
@Override private static final Logger logger = LoggerFactory.getLogger(ReadyListener.class);
public void onReady(ReadyEvent event) {
logger.info("Бот {} успешно запущен!", event.getJDA().getSelfUser().getName()); @Override
logger.info("Подключен к {} серверам", event.getGuildTotalCount()); public void onReady(ReadyEvent event) {
} logger.info("Бот {} успешно запущен!", event.getJDA().getSelfUser().getName());
} logger.info("Подключен к {} серверам", event.getGuildTotalCount());
}
}

View file

@ -1,16 +1,16 @@
package creator.loki.command; package creator.loki.command;
public interface Command { public interface Command {
String getName(); String getName();
String getDescription(); String getDescription();
default String getUsage() { default String getUsage() {
return getName(); return getName();
} }
default String getCategory() { default String getCategory() {
return "Общие"; return "Общие";
} }
void execute(CommandContext context); void execute(CommandContext context);
} }

View file

@ -0,0 +1,12 @@
package creator.loki.command;
/**
* Имена категорий для {@link Command#getCategory()} (справка, селектор help).
*/
public final class CommandCategories {
/** Команды с GIF (nekos.life): hug, slap, poke и т.п. */
public static final String GIF_INTERACTIVE = "GIF-интерактив";
private CommandCategories() {}
}

View file

@ -1,44 +1,44 @@
package creator.loki.command; package creator.loki.command;
import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
import java.util.List; import java.util.List;
public class CommandContext { public class CommandContext {
private final Message message; private final Message message;
private final List<String> args; private final List<String> args;
private final MessageChannel channel; private final MessageChannel channel;
private final Member author; private final Member author;
private final Guild guild; private final Guild guild;
public CommandContext(Message message, List<String> args, MessageChannel channel, Member author, Guild guild) { public CommandContext(Message message, List<String> args, MessageChannel channel, Member author, Guild guild) {
this.message = message; this.message = message;
this.args = args; this.args = args;
this.channel = channel; this.channel = channel;
this.author = author; this.author = author;
this.guild = guild; this.guild = guild;
} }
public Message getMessage() { public Message getMessage() {
return message; return message;
} }
public List<String> getArgs() { public List<String> getArgs() {
return args; return args;
} }
public MessageChannel getChannel() { public MessageChannel getChannel() {
return channel; return channel;
} }
public Member getAuthor() { public Member getAuthor() {
return author; return author;
} }
public Guild getGuild() { public Guild getGuild() {
return guild; return guild;
} }
} }

View file

@ -1,29 +0,0 @@
package creator.loki.command;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.interactions.commands.build.CommandData;
/**
* Интерфейс для slash команд
*/
public interface SlashCommand {
/**
* Выполняет slash команду
*/
void execute(SlashCommandInteractionEvent event);
/**
* Возвращает данные команды для регистрации в Discord
*/
CommandData getCommandData();
/**
* Название команды
*/
String getName();
/**
* Описание команды
*/
String getDescription();
}

View file

@ -1,78 +0,0 @@
package creator.loki.command.impl.entertainment.interaction.negative;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedFactory;
import creator.loki.core.security.CooldownManager;
import creator.loki.integration.nekos.NekosLifeApi;
import net.dv8tion.jda.api.entities.Member;
import java.awt.Color;
import java.util.List;
/**
* Тыкнуть пользователя с GIF
*/
public class PokeCommand implements Command {
private final CooldownManager cooldownManager;
public PokeCommand(CooldownManager cooldownManager) {
this.cooldownManager = cooldownManager;
}
@Override
public String getName() {
return "poke";
}
@Override
public String getDescription() {
return "Тыкнуть пользователя";
}
@Override
public String getUsage() {
return "poke <@user>";
}
@Override
public String getCategory() {
return "Развлечения";
}
@Override
public void execute(CommandContext context) {
if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "poke")) {
long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "poke");
context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue();
return;
}
List<Member> mentioned = context.getMessage().getMentions().getMembers();
if (mentioned.isEmpty()) {
context.getChannel().sendMessage("❌ Укажи пользователя через @").queue();
return;
}
Member target = mentioned.get(0);
cooldownManager.applyCooldown(context.getAuthor().getId(), "poke", 5);
String gifUrl = NekosLifeApi.fetchImage("poke");
if (gifUrl == null) {
context.getChannel().sendMessage("❌ Не удалось получить изображение!").queue();
return;
}
context.getChannel().sendMessageEmbeds(
EmbedFactory.create()
.setDescription("👉 " + context.getAuthor().getAsMention() + " тыкает в " + target.getAsMention() + "! 😏")
.setImage(gifUrl)
.setColor(new Color(0, 128, 128))
.setFooter("Powered by nekos.life")
.build()
).queue();
}
}

View file

@ -1,68 +0,0 @@
package creator.loki.command.impl.entertainment.interaction;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedFactory;
import net.dv8tion.jda.api.entities.Member;
import java.awt.Color;
import java.util.List;
import java.util.Random;
public class RoastCommand implements Command {
private final Random random = new Random();
private static final String[] ROASTS = {
"Ты как Internet Explorer — медленный и никому не нужный",
"Твой код работает? Это баг, исправь его",
"Ты пишешь код как будто тебе за каждую строку платят",
"Твоя логика как у JavaScript: непредсказуемая",
"Ты как HTML без CSS — работаешь, но выглядишь ужасно",
"Твой IQ как температура в Антарктиде — ниже нуля",
"Ты как баг в продакшене — неожиданный и нежеланный",
"Твои шутки как deprecated код — устарели",
"Ты как NULL pointer — бесполезный и вызываешь ошибки",
"Твой уровень как индекс массива в Python — может быть отрицательным"
};
@Override
public String getName() {
return "roast";
}
@Override
public String getDescription() {
return "Дружеский рофл над пользователем";
}
@Override
public String getUsage() {
return "roast <@user>";
}
@Override
public String getCategory() {
return "Развлечения";
}
@Override
public void execute(CommandContext context) {
List<Member> mentioned = context.getMessage().getMentions().getMembers();
if (mentioned.isEmpty()) {
context.getChannel().sendMessage("Укажи пользователя через @").queue();
return;
}
Member target = mentioned.get(0);
String roast = ROASTS[random.nextInt(ROASTS.length)];
context.getChannel().sendMessageEmbeds(
EmbedFactory.create()
.setTitle("🔥 Roast")
.setDescription(target.getAsMention() + ", " + roast)
.setColor(new Color(231, 76, 60))
.setFooter("Это шутка, не обижайся!")
.build()
).queue();
}
}

View file

@ -1,78 +0,0 @@
package creator.loki.command.impl.entertainment.interaction.negative;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedFactory;
import creator.loki.core.security.CooldownManager;
import creator.loki.integration.nekos.NekosLifeApi;
import net.dv8tion.jda.api.entities.Member;
import java.awt.Color;
import java.util.List;
/**
* Шлепнуть пользователя с GIF
*/
public class SlapCommand implements Command {
private final CooldownManager cooldownManager;
public SlapCommand(CooldownManager cooldownManager) {
this.cooldownManager = cooldownManager;
}
@Override
public String getName() {
return "slap";
}
@Override
public String getDescription() {
return "Шлепнуть пользователя";
}
@Override
public String getUsage() {
return "slap <@user>";
}
@Override
public String getCategory() {
return "Развлечения";
}
@Override
public void execute(CommandContext context) {
if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "slap")) {
long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "slap");
context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue();
return;
}
List<Member> mentioned = context.getMessage().getMentions().getMembers();
if (mentioned.isEmpty()) {
context.getChannel().sendMessage("❌ Укажи пользователя через @").queue();
return;
}
Member target = mentioned.get(0);
cooldownManager.applyCooldown(context.getAuthor().getId(), "slap", 5);
String gifUrl = NekosLifeApi.fetchImage("slap");
if (gifUrl == null) {
context.getChannel().sendMessage("❌ Не удалось получить изображение!").queue();
return;
}
context.getChannel().sendMessageEmbeds(
EmbedFactory.create()
.setDescription("👋 " + context.getAuthor().getAsMention() + " шлепает " + target.getAsMention() + "! 😠")
.setImage(gifUrl)
.setColor(new Color(255, 165, 0))
.setFooter("Powered by nekos.life")
.build()
).queue();
}
}

View file

@ -1,78 +0,0 @@
package creator.loki.command.impl.entertainment.interaction.positive;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedFactory;
import creator.loki.core.security.CooldownManager;
import creator.loki.integration.nekos.NekosLifeApi;
import net.dv8tion.jda.api.entities.Member;
import java.awt.Color;
import java.util.List;
/**
* Обниматься с пользователем с GIF
*/
public class CuddleCommand implements Command {
private final CooldownManager cooldownManager;
public CuddleCommand(CooldownManager cooldownManager) {
this.cooldownManager = cooldownManager;
}
@Override
public String getName() {
return "cuddle";
}
@Override
public String getDescription() {
return "Обниматься с пользователем";
}
@Override
public String getUsage() {
return "cuddle <@user>";
}
@Override
public String getCategory() {
return "Развлечения";
}
@Override
public void execute(CommandContext context) {
if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "cuddle")) {
long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "cuddle");
context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue();
return;
}
List<Member> mentioned = context.getMessage().getMentions().getMembers();
if (mentioned.isEmpty()) {
context.getChannel().sendMessage("❌ Укажи пользователя через @").queue();
return;
}
Member target = mentioned.get(0);
cooldownManager.applyCooldown(context.getAuthor().getId(), "cuddle", 5);
String gifUrl = NekosLifeApi.fetchImage("cuddle");
if (gifUrl == null) {
context.getChannel().sendMessage("❌ Не удалось получить изображение!").queue();
return;
}
context.getChannel().sendMessageEmbeds(
EmbedFactory.create()
.setDescription("🥰 " + context.getAuthor().getAsMention() + " обнимается с " + target.getAsMention() + "! 💕")
.setImage(gifUrl)
.setColor(new Color(255, 0, 255))
.setFooter("Powered by nekos.life")
.build()
).queue();
}
}

View file

@ -1,78 +0,0 @@
package creator.loki.command.impl.entertainment.interaction.negative;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedFactory;
import creator.loki.core.security.CooldownManager;
import creator.loki.integration.nekos.NekosLifeApi;
import net.dv8tion.jda.api.entities.Member;
import java.awt.Color;
import java.util.List;
/**
* Пощекотать пользователя с GIF
*/
public class TickleCommand implements Command {
private final CooldownManager cooldownManager;
public TickleCommand(CooldownManager cooldownManager) {
this.cooldownManager = cooldownManager;
}
@Override
public String getName() {
return "tickle";
}
@Override
public String getDescription() {
return "Пощекотать пользователя";
}
@Override
public String getUsage() {
return "tickle <@user>";
}
@Override
public String getCategory() {
return "Развлечения";
}
@Override
public void execute(CommandContext context) {
if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "tickle")) {
long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "tickle");
context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue();
return;
}
List<Member> mentioned = context.getMessage().getMentions().getMembers();
if (mentioned.isEmpty()) {
context.getChannel().sendMessage("❌ Укажи пользователя через @").queue();
return;
}
Member target = mentioned.get(0);
cooldownManager.applyCooldown(context.getAuthor().getId(), "tickle", 5);
String gifUrl = NekosLifeApi.fetchImage("tickle");
if (gifUrl == null) {
context.getChannel().sendMessage("❌ Не удалось получить изображение!").queue();
return;
}
context.getChannel().sendMessageEmbeds(
EmbedFactory.create()
.setDescription("🤗 " + context.getAuthor().getAsMention() + " щекочет " + target.getAsMention() + "!")
.setImage(gifUrl)
.setColor(new Color(0, 255, 0))
.setFooter("Powered by nekos.life")
.build()
).queue();
}
}

View file

@ -1,67 +0,0 @@
package creator.loki.command.impl.entertainment.interaction;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedFactory;
import net.dv8tion.jda.api.entities.Member;
import java.awt.Color;
import java.util.List;
import java.util.Random;
public class ComplimentCommand implements Command {
private final Random random = new Random();
private static final String[] COMPLIMENTS = {
"Ты как идеальный код — красивый и без багов",
"Твоя улыбка ярче, чем RGB подсветка",
"Ты как хороший коммит — делаешь всё лучше",
"Твой вайб как у Python — простой и приятный",
"Ты как успешный деплой — всё работает отлично",
"Твоя энергия как у SSD — быстрая и эффективная",
"Ты как open source проект — полезный для всех",
"Твоя харизма как у TypeScript — добавляет уверенности",
"Ты как хороший API — понятный и надёжный",
"Твоё присутствие как кофе для программиста — необходимо"
};
@Override
public String getName() {
return "compliment";
}
@Override
public String getDescription() {
return "Сделать комплимент пользователю";
}
@Override
public String getUsage() {
return "compliment <@user>";
}
@Override
public String getCategory() {
return "Развлечения";
}
@Override
public void execute(CommandContext context) {
List<Member> mentioned = context.getMessage().getMentions().getMembers();
if (mentioned.isEmpty()) {
context.getChannel().sendMessage("Укажи пользователя через @").queue();
return;
}
Member target = mentioned.get(0);
String compliment = COMPLIMENTS[random.nextInt(COMPLIMENTS.length)];
context.getChannel().sendMessageEmbeds(
EmbedFactory.create()
.setTitle("💖 Комплимент")
.setDescription(target.getAsMention() + ", " + compliment)
.setColor(new Color(255, 182, 193))
.build()
).queue();
}
}

View file

@ -1,78 +0,0 @@
package creator.loki.command.impl.entertainment.interaction.positive;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedFactory;
import creator.loki.core.security.CooldownManager;
import creator.loki.integration.nekos.NekosLifeApi;
import net.dv8tion.jda.api.entities.Member;
import java.awt.Color;
import java.util.List;
/**
* Покормить пользователя с GIF
*/
public class FeedCommand implements Command {
private final CooldownManager cooldownManager;
public FeedCommand(CooldownManager cooldownManager) {
this.cooldownManager = cooldownManager;
}
@Override
public String getName() {
return "feed";
}
@Override
public String getDescription() {
return "Покормить пользователя";
}
@Override
public String getUsage() {
return "feed <@user>";
}
@Override
public String getCategory() {
return "Развлечения";
}
@Override
public void execute(CommandContext context) {
if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "feed")) {
long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "feed");
context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue();
return;
}
List<Member> mentioned = context.getMessage().getMentions().getMembers();
if (mentioned.isEmpty()) {
context.getChannel().sendMessage("❌ Укажи пользователя через @").queue();
return;
}
Member target = mentioned.get(0);
cooldownManager.applyCooldown(context.getAuthor().getId(), "feed", 5);
String gifUrl = NekosLifeApi.fetchImage("feed");
if (gifUrl == null) {
context.getChannel().sendMessage("❌ Не удалось получить изображение!").queue();
return;
}
context.getChannel().sendMessageEmbeds(
EmbedFactory.create()
.setDescription("🍽️ " + context.getAuthor().getAsMention() + " кормит " + target.getAsMention() + "! 😋")
.setImage(gifUrl)
.setColor(new Color(255, 200, 100))
.setFooter("Powered by nekos.life")
.build()
).queue();
}
}

View file

@ -1,61 +0,0 @@
package creator.loki.command.impl.entertainment.interaction.positive;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedFactory;
import creator.loki.integration.nekos.NekosLifeApi;
import net.dv8tion.jda.api.entities.Member;
import java.awt.Color;
import java.util.List;
public class HugCommand implements Command {
@Override
public String getName() {
return "hug";
}
@Override
public String getDescription() {
return "Обнять пользователя";
}
@Override
public String getUsage() {
return "hug <@user>";
}
@Override
public String getCategory() {
return "Развлечения";
}
@Override
public void execute(CommandContext context) {
List<Member> mentioned = context.getMessage().getMentions().getMembers();
if (mentioned.isEmpty()) {
context.getChannel().sendMessage("❌ Укажи пользователя через @").queue();
return;
}
Member target = mentioned.get(0);
// Получаем GIF из nekos.life API
String gifUrl = NekosLifeApi.fetchImage("hug");
if (gifUrl == null) {
context.getChannel().sendMessage("❌ Не удалось получить изображение!").queue();
return;
}
context.getChannel().sendMessageEmbeds(
EmbedFactory.create()
.setDescription("🤗 " + context.getAuthor().getAsMention() + " обнял " + target.getAsMention() + " 💕")
.setImage(gifUrl)
.setColor(new Color(255, 182, 193))
.setFooter("Powered by nekos.life")
.build()
).queue();
}
}

View file

@ -1,92 +0,0 @@
package creator.loki.command.impl.entertainment.interaction.positive;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedFactory;
import creator.loki.core.security.CooldownManager;
import creator.loki.integration.nekos.NekosLifeApi;
import net.dv8tion.jda.api.entities.Member;
import java.awt.Color;
import java.util.List;
/**
* Поцелуй пользователя с GIF
*/
public class KissCommand implements Command {
private final CooldownManager cooldownManager;
public KissCommand(CooldownManager cooldownManager) {
this.cooldownManager = cooldownManager;
}
@Override
public String getName() {
return "kiss";
}
@Override
public String getDescription() {
return "Поцеловать пользователя";
}
@Override
public String getUsage() {
return "kiss <@user>";
}
@Override
public String getCategory() {
return "Развлечения";
}
@Override
public void execute(CommandContext context) {
// Проверка cooldown (5 секунд)
if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "kiss")) {
long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "kiss");
context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue();
return;
}
List<Member> mentioned = context.getMessage().getMentions().getMembers();
if (mentioned.isEmpty()) {
context.getChannel().sendMessage("❌ Укажи кого поцеловать! Использование: `!kiss @user`").queue();
return;
}
Member target = mentioned.get(0);
if (target.getId().equals(context.getAuthor().getId())) {
context.getChannel().sendMessage("❌ Нельзя целовать самого себя!").queue();
return;
}
if (target.getUser().isBot()) {
context.getChannel().sendMessage("❌ Боты не умеют целоваться!").queue();
return;
}
// Применяем cooldown
cooldownManager.applyCooldown(context.getAuthor().getId(), "kiss", 5);
// Получаем GIF из nekos.life API
String gifUrl = NekosLifeApi.fetchImage("kiss");
if (gifUrl == null) {
context.getChannel().sendMessage("❌ Не удалось получить изображение!").queue();
return;
}
context.getChannel().sendMessageEmbeds(
EmbedFactory.create()
.setDescription("💋 " + context.getAuthor().getAsMention() + " целует " + target.getAsMention() + " ❤️")
.setImage(gifUrl)
.setColor(new Color(255, 105, 180))
.setFooter("Powered by nekos.life")
.build()
).queue();
}
}

View file

@ -1,91 +0,0 @@
package creator.loki.command.impl.entertainment.interaction.positive;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.core.security.CooldownManager;
import net.dv8tion.jda.api.entities.Member;
import java.util.List;
public class PatCommand implements Command {
private static final int COOLDOWN_SECONDS = 5;
private final CooldownManager cooldownManager;
public PatCommand(CooldownManager cooldownManager) {
this.cooldownManager = cooldownManager;
}
@Override
public String getName() {
return "pat";
}
@Override
public String getDescription() {
return "Погладить пользователя";
}
@Override
public String getUsage() {
return "pat <@user> <количество 1-7>";
}
@Override
public String getCategory() {
return "Развлечения";
}
@Override
public void execute(CommandContext context) {
String userId = context.getAuthor().getId();
// Проверка cooldown
if (cooldownManager.isOnCooldown(userId, "pat")) {
long remaining = cooldownManager.getRemainingSeconds(userId, "pat");
context.getChannel().sendMessage(
"⏰ Подожди " + remaining + " сек."
).queue();
return;
}
// Проверка аргументов
if (context.getArgs().size() < 2) {
context.getChannel().sendMessage(
"❌ Используй: `!pat <@user> <количество 1-7>`"
).queue();
return;
}
// Получение упомянутого пользователя
List<Member> mentioned = context.getMessage().getMentions().getMembers();
if (mentioned.isEmpty()) {
context.getChannel().sendMessage("❌ Укажи пользователя через @").queue();
return;
}
Member target = mentioned.get(0);
// Парсинг количества
int count;
try {
count = Integer.parseInt(context.getArgs().get(1));
} catch (NumberFormatException e) {
context.getChannel().sendMessage("❌ Укажи число от 1 до 7").queue();
return;
}
// Валидация количества
if (count < 1 || count > 7) {
context.getChannel().sendMessage("❌ Количество должно быть от 1 до 7").queue();
return;
}
// Применяем cooldown
cooldownManager.applyCooldown(userId, "pat", COOLDOWN_SECONDS);
// Отправляем каждое "pat-pat" отдельным сообщением
for (int i = 0; i < count; i++) {
context.getChannel().sendMessage("pat-pat " + target.getAsMention()).queue();
}
}
}

View file

@ -1,126 +0,0 @@
package creator.loki.command.impl.moderation;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedTemplates;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.Member;
import java.util.List;
public class BanCommand implements Command {
@Override
public String getName() {
return "ban";
}
@Override
public String getDescription() {
return "Банит пользователя на сервере";
}
@Override
public String getUsage() {
return "ban <@user> [причина]";
}
@Override
public String getCategory() {
return "Модерация";
}
@Override
public void execute(CommandContext context) {
if (context.getGuild() == null) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Команда доступна только на сервере")
).queue();
return;
}
// Проверка прав модератора
if (context.getAuthor() == null || !context.getAuthor().hasPermission(Permission.BAN_MEMBERS)) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Недостаточно прав", "У вас нет прав на бан пользователей")
).queue();
return;
}
// Проверка прав бота
if (!context.getGuild().getSelfMember().hasPermission(Permission.BAN_MEMBERS)) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Недостаточно прав", "У бота нет прав на бан пользователей")
).queue();
return;
}
// Получение упомянутого пользователя
List<Member> mentioned = context.getMessage().getMentions().getMembers();
if (mentioned.isEmpty()) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Укажите пользователя через @")
).queue();
return;
}
Member target = mentioned.get(0);
// Нельзя забанить самого себя
if (target.getId().equals(context.getAuthor().getId())) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Вы не можете забанить самого себя")
).queue();
return;
}
// Нельзя забанить бота
if (target.getUser().isBot() && target.getId().equals(context.getGuild().getSelfMember().getId())) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Я не могу забанить самого себя")
).queue();
return;
}
// Проверка иерархии ролей
if (!context.getAuthor().canInteract(target)) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Недостаточно прав", "Вы не можете забанить этого пользователя (роль выше вашей)")
).queue();
return;
}
if (!context.getGuild().getSelfMember().canInteract(target)) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Недостаточно прав", "Я не могу забанить этого пользователя (роль выше моей)")
).queue();
return;
}
// Причина бана
String reason = context.getArgs().size() > 1
? String.join(" ", context.getArgs().subList(1, context.getArgs().size()))
: "Причина не указана";
// Бан
context.getGuild().ban(target, 0, java.util.concurrent.TimeUnit.DAYS)
.reason(reason)
.queue(
success -> {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.success(
"Пользователь забанен",
"**Пользователь:** " + target.getUser().getAsTag() + "\n" +
"**Причина:** " + reason + "\n" +
"**Модератор:** " + context.getAuthor().getAsMention()
)
).queue();
},
error -> {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Не удалось забанить: " + error.getMessage())
).queue();
}
);
}
}

View file

@ -1,108 +0,0 @@
package creator.loki.command.impl.moderation;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedTemplates;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import java.util.concurrent.TimeUnit;
public class ClearCommand implements Command {
@Override
public String getName() {
return "clear";
}
@Override
public String getDescription() {
return "Удаляет указанное количество сообщений";
}
@Override
public String getUsage() {
return "clear <количество>";
}
@Override
public void execute(CommandContext context) {
// Проверка прав
if (context.getAuthor() == null || !context.getAuthor().hasPermission(Permission.MESSAGE_MANAGE)) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error(
"Недостаточно прав",
"У вас нет прав на управление сообщениями"
)
).queue();
return;
}
// Проверка аргументов
if (context.getArgs().isEmpty()) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error(
"Неверное количество",
"Укажите число от 1 до 100"
)
).queue();
return;
}
int amount;
try {
amount = Integer.parseInt(context.getArgs().get(0));
} catch (NumberFormatException e) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error(
"Неверное количество",
"Укажите число от 1 до 100"
)
).queue();
return;
}
if (amount < 1 || amount > 100) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error(
"Неверное количество",
"Укажите число от 1 до 100"
)
).queue();
return;
}
// Удаление сообщений
if (!(context.getChannel() instanceof TextChannel)) {
return;
}
TextChannel channel = (TextChannel) context.getChannel();
channel.getHistory().retrievePast(amount + 1).queue(messages -> {
channel.deleteMessages(messages).queue(
success -> {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.success(
"Сообщения удалены",
"Удалено сообщений: " + messages.size()
)
).queue(msg -> msg.delete().queueAfter(5, TimeUnit.SECONDS));
},
error -> {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error(
"Ошибка удаления",
"Не удалось удалить сообщения: " + error.getMessage()
)
).queue();
}
);
});
}
@Override
public String getCategory() {
return "Модерация";
}
}

View file

@ -1,118 +0,0 @@
package creator.loki.command.impl.moderation;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedTemplates;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.Member;
import java.util.List;
public class KickCommand implements Command {
@Override
public String getName() {
return "kick";
}
@Override
public String getDescription() {
return "Кикает пользователя с сервера";
}
@Override
public String getUsage() {
return "kick <@user> [причина]";
}
@Override
public String getCategory() {
return "Модерация";
}
@Override
public void execute(CommandContext context) {
if (context.getGuild() == null) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Команда доступна только на сервере")
).queue();
return;
}
if (context.getAuthor() == null || !context.getAuthor().hasPermission(Permission.KICK_MEMBERS)) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Недостаточно прав", "У вас нет прав на кик пользователей")
).queue();
return;
}
if (!context.getGuild().getSelfMember().hasPermission(Permission.KICK_MEMBERS)) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Недостаточно прав", "У бота нет прав на кик пользователей")
).queue();
return;
}
List<Member> mentioned = context.getMessage().getMentions().getMembers();
if (mentioned.isEmpty()) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Укажите пользователя через @")
).queue();
return;
}
Member target = mentioned.get(0);
if (target.getId().equals(context.getAuthor().getId())) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Вы не можете кикнуть самого себя")
).queue();
return;
}
if (target.getUser().isBot() && target.getId().equals(context.getGuild().getSelfMember().getId())) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Я не могу кикнуть самого себя")
).queue();
return;
}
if (!context.getAuthor().canInteract(target)) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Недостаточно прав", "Вы не можете кикнуть этого пользователя")
).queue();
return;
}
if (!context.getGuild().getSelfMember().canInteract(target)) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Недостаточно прав", "Я не могу кикнуть этого пользователя")
).queue();
return;
}
String reason = context.getArgs().size() > 1
? String.join(" ", context.getArgs().subList(1, context.getArgs().size()))
: "Причина не указана";
context.getGuild().kick(target)
.reason(reason)
.queue(
success -> {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.success(
"Пользователь кикнут",
"**Пользователь:** " + target.getUser().getAsTag() + "\n" +
"**Причина:** " + reason + "\n" +
"**Модератор:** " + context.getAuthor().getAsMention()
)
).queue();
},
error -> {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Не удалось кикнуть: " + error.getMessage())
).queue();
}
);
}
}

View file

@ -1,122 +0,0 @@
package creator.loki.command.impl.setup;
import creator.loki.database.ConfigRepository;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.Commands;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.awt.Color;
/**
* Команда настройки логирования
*/
public class LogSetupCommand extends ListenerAdapter {
private static final Logger logger = LoggerFactory.getLogger(LogSetupCommand.class);
private final ConfigRepository configRepo;
public LogSetupCommand(ConfigRepository configRepo) {
this.configRepo = configRepo;
}
@Override
public void onSlashCommandInteraction(SlashCommandInteractionEvent event) {
if (!"log-setup".equals(event.getName())) {
return;
}
Member member = event.getMember();
if (member == null || !member.hasPermission(Permission.ADMINISTRATOR)) {
event.reply("❌ У вас нет прав для использования этой команды!").setEphemeral(true).queue();
return;
}
String subcommand = event.getSubcommandName();
if (subcommand == null) {
return;
}
switch (subcommand) {
case "moderation" -> handleModeration(event);
case "levelup" -> handleLevelUp(event);
case "info" -> handleInfo(event);
}
}
private void handleModeration(SlashCommandInteractionEvent event) {
TextChannel channel = event.getOption("channel").getAsChannel().asTextChannel();
configRepo.set("moderation.log_channel_id", channel.getId());
EmbedBuilder embed = new EmbedBuilder()
.setTitle("✅ Канал логов модерации установлен")
.setDescription("Логи модерации будут отправляться в " + channel.getAsMention())
.addField("Что логируется",
"• Удаление сообщений\n" +
"• Баны/разбаны\n" +
"• Кики\n" +
"• Муты/размуты\n" +
"• Отключение микрофона/звука", false)
.setColor(Color.GREEN);
event.replyEmbeds(embed.build()).queue();
logger.info("Установлен канал логов модерации: {} ({})", channel.getName(), channel.getId());
}
private void handleLevelUp(SlashCommandInteractionEvent event) {
TextChannel channel = event.getOption("channel").getAsChannel().asTextChannel();
configRepo.set("levelup.notification_channel_id", channel.getId());
EmbedBuilder embed = new EmbedBuilder()
.setTitle("✅ Канал уведомлений о level up установлен")
.setDescription("Уведомления о повышении уровня будут отправляться в " + channel.getAsMention())
.setColor(Color.GREEN);
event.replyEmbeds(embed.build()).queue();
logger.info("Установлен канал уведомлений level up: {} ({})", channel.getName(), channel.getId());
}
private void handleInfo(SlashCommandInteractionEvent event) {
String moderationChannelId = configRepo.get("moderation.log_channel_id");
String levelupChannelId = configRepo.get("levelup.notification_channel_id");
EmbedBuilder embed = new EmbedBuilder()
.setTitle("📋 Конфигурация логирования")
.setColor(Color.BLUE);
if (moderationChannelId != null) {
embed.addField("Логи модерации", "<#" + moderationChannelId + ">", false);
} else {
embed.addField("Логи модерации", "❌ Не установлен", false);
}
if (levelupChannelId != null) {
embed.addField("Уведомления level up", "<#" + levelupChannelId + ">", false);
} else {
embed.addField("Уведомления level up", "❌ Не установлен", false);
}
event.replyEmbeds(embed.build()).setEphemeral(true).queue();
}
public static void register(net.dv8tion.jda.api.JDA jda) {
jda.updateCommands().addCommands(
Commands.slash("log-setup", "Настройка логирования")
.addSubcommands(
new SubcommandData("moderation", "Установить канал для логов модерации")
.addOption(OptionType.CHANNEL, "channel", "Текстовый канал", true),
new SubcommandData("levelup", "Установить канал для уведомлений о level up")
.addOption(OptionType.CHANNEL, "channel", "Текстовый канал", true),
new SubcommandData("info", "Показать текущую конфигурацию")
)
).queue();
}
}

View file

@ -1,89 +0,0 @@
package creator.loki.command.impl.stats;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedFactory;
import creator.loki.feature.level.domain.LevelProgress;
import creator.loki.feature.level.domain.UserStats;
import creator.loki.level.LevelSystem;
import net.dv8tion.jda.api.entities.Member;
import java.awt.Color;
/**
* Команда для просмотра уровня и статистики
*/
public class RankCommand implements Command {
private final LevelSystem levelSystem;
public RankCommand(LevelSystem levelSystem) {
this.levelSystem = levelSystem;
}
@Override
public String getName() {
return "rank";
}
@Override
public String getDescription() {
return "Посмотреть свой уровень и статистику";
}
@Override
public String getUsage() {
return "rank [@user]";
}
@Override
public String getCategory() {
return "Статистика";
}
@Override
public void execute(CommandContext context) {
Member target;
if (!context.getMessage().getMentions().getMembers().isEmpty()) {
target = context.getMessage().getMentions().getMembers().get(0);
} else {
target = context.getAuthor();
}
if (target == null) {
context.getChannel().sendMessage("❌ Пользователь не найден!").queue();
return;
}
String userId = target.getId();
UserStats stats = levelSystem.getUserStats(userId);
LevelProgress progress = levelSystem.getProgress(userId);
// Прогресс-бар
int barLength = 20;
int filled = (int) (progress.getPercentage() / 100.0 * barLength);
StringBuilder progressBar = new StringBuilder();
for (int i = 0; i < barLength; i++) {
progressBar.append(i < filled ? "█" : "░");
}
long hours = stats.getVoiceMinutes() / 60;
long minutes = stats.getVoiceMinutes() % 60;
var embed = EmbedFactory.create()
.setTitle("📊 Статистика " + target.getEffectiveName())
.setThumbnail(target.getEffectiveAvatarUrl())
.addField("🎯 Уровень", "**" + stats.getLevel() + "**", true)
.addField("⭐ Всего XP", "**" + stats.getTotalXP() + "**", true)
.addField("📈 Прогресс",
progressBar.toString() + " **" + String.format("%.1f", progress.getPercentage()) + "%**\n" +
progress.getCurrentLevelXP() + " / " + progress.getXpForNextLevel() + " XP до следующего уровня",
false)
.addField("💬 Сообщений", "**" + stats.getMessageCount() + "**", true)
.addField("🎤 Время в войсе", "**" + hours + "ч " + minutes + "м**", true)
.setColor(new Color(46, 204, 113))
.build();
context.getChannel().sendMessageEmbeds(embed).queue();
}
}

View file

@ -1,75 +0,0 @@
package creator.loki.command.impl.stats;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedFactory;
import net.dv8tion.jda.api.OnlineStatus;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import java.awt.Color;
public class StatsCommand implements Command {
@Override
public String getName() {
return "stats";
}
@Override
public String getDescription() {
return "Статистика сервера";
}
@Override
public String getUsage() {
return "stats";
}
@Override
public String getCategory() {
return "Статистика";
}
@Override
public void execute(CommandContext context) {
if (context.getGuild() == null) {
context.getChannel().sendMessage("Команда доступна только на сервере").queue();
return;
}
Guild guild = context.getGuild();
// Подсчёт статистики
int totalMembers = guild.getMemberCount();
long onlineMembers = guild.getMembers().stream()
.filter(m -> m.getOnlineStatus() == OnlineStatus.ONLINE)
.count();
long inVoice = guild.getMembers().stream()
.filter(m -> m.getVoiceState() != null && m.getVoiceState().inAudioChannel())
.count();
int textChannels = guild.getTextChannels().size();
int voiceChannels = guild.getVoiceChannels().size();
int roles = guild.getRoles().size();
int bots = (int) guild.getMembers().stream()
.filter(m -> m.getUser().isBot())
.count();
context.getChannel().sendMessageEmbeds(
EmbedFactory.create()
.setTitle("📊 Статистика сервера")
.setThumbnail(guild.getIconUrl())
.addField("👥 Участников", String.valueOf(totalMembers), true)
.addField("🟢 Онлайн", String.valueOf(onlineMembers), true)
.addField("🔊 В войсе", String.valueOf(inVoice), true)
.addField("💬 Текстовых каналов", String.valueOf(textChannels), true)
.addField("🎤 Голосовых каналов", String.valueOf(voiceChannels), true)
.addField("🎭 Ролей", String.valueOf(roles), true)
.addField("🤖 Ботов", String.valueOf(bots), true)
.addField("👤 Людей", String.valueOf(totalMembers - bots), true)
.setColor(new Color(52, 152, 219))
.setFooter("Сервер: " + guild.getName())
.build()
).queue();
}
}

View file

@ -1,159 +0,0 @@
package creator.loki.command.impl.utility;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.config.BotConfig;
import creator.loki.core.registry.CommandRegistry;
import creator.loki.embed.EmbedFactory;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.interactions.components.selections.StringSelectMenu;
import java.awt.Color;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class HelpCommand implements Command {
private final CommandRegistry registry;
private final BotConfig config;
private static final Map<String, String> CATEGORY_EMOJIS = new HashMap<>();
private static final Map<String, Color> CATEGORY_COLORS = new HashMap<>();
private static final Map<String, String> CATEGORY_DESCRIPTIONS = new HashMap<>();
static {
CATEGORY_EMOJIS.put("Общие", "📋");
CATEGORY_EMOJIS.put("Роли", "🎭");
CATEGORY_EMOJIS.put("Развлечения", "🎮");
CATEGORY_EMOJIS.put("Модерация", "🛡️");
CATEGORY_EMOJIS.put("Статистика", "📊");
CATEGORY_EMOJIS.put("Утилиты", "🔧");
CATEGORY_EMOJIS.put("Настройка", "⚙️");
CATEGORY_COLORS.put("Общие", new Color(52, 152, 219));
CATEGORY_COLORS.put("Роли", new Color(155, 89, 182));
CATEGORY_COLORS.put("Развлечения", new Color(241, 196, 15));
CATEGORY_COLORS.put("Модерация", new Color(231, 76, 60));
CATEGORY_COLORS.put("Статистика", new Color(46, 204, 113));
CATEGORY_COLORS.put("Утилиты", new Color(52, 152, 219));
CATEGORY_COLORS.put("Настройка", new Color(149, 165, 166));
CATEGORY_DESCRIPTIONS.put("Общие", "Основные команды бота");
CATEGORY_DESCRIPTIONS.put("Роли", "Управление кастомными ролями");
CATEGORY_DESCRIPTIONS.put("Развлечения", "Игры и взаимодействия");
CATEGORY_DESCRIPTIONS.put("Модерация", "Команды модерации сервера");
CATEGORY_DESCRIPTIONS.put("Статистика", "Уровни, репутация и статистика");
CATEGORY_DESCRIPTIONS.put("Утилиты", "Полезные инструменты");
CATEGORY_DESCRIPTIONS.put("Настройка", "Настройка систем бота (только админы)");
}
public HelpCommand(CommandRegistry registry, BotConfig config) {
this.registry = registry;
this.config = config;
}
@Override
public String getName() {
return "help";
}
@Override
public String getDescription() {
return "Показывает список всех команд";
}
@Override
public void execute(CommandContext context) {
var commands = registry.getAllCommands();
Map<String, List<Command>> commandsByCategory = commands.stream()
.collect(Collectors.groupingBy(Command::getCategory));
// Главная страница
EmbedFactory embed = createMainPage(context, commandsByCategory);
// Создаём селектор категорий
StringSelectMenu.Builder menuBuilder = StringSelectMenu.create("help:category")
.setPlaceholder("📂 Выберите категорию");
boolean isAdmin = context.getAuthor() != null &&
context.getAuthor().hasPermission(Permission.ADMINISTRATOR);
commandsByCategory.keySet().stream()
.sorted()
.forEach(category -> {
// Скрываем категорию "Настройка" для не-админов
if (category.equals("Настройка") && !isAdmin) {
return;
}
String emoji = CATEGORY_EMOJIS.getOrDefault(category, "📌");
String description = CATEGORY_DESCRIPTIONS.getOrDefault(category, "Команды категории");
int count = commandsByCategory.get(category).size();
menuBuilder.addOption(
category,
"category:" + category,
description + " (" + count + " команд)",
net.dv8tion.jda.api.entities.emoji.Emoji.fromUnicode(emoji)
);
});
context.getChannel()
.sendMessageEmbeds(embed.build())
.addActionRow(menuBuilder.build())
.queue();
}
private EmbedFactory createMainPage(CommandContext context, Map<String, List<Command>> commandsByCategory) {
int totalCommands = commandsByCategory.values().stream()
.mapToInt(List::size)
.sum();
boolean isAdmin = context.getAuthor() != null &&
context.getAuthor().hasPermission(Permission.ADMINISTRATOR);
StringBuilder categoriesText = new StringBuilder();
commandsByCategory.keySet().stream()
.sorted()
.forEach(category -> {
// Скрываем категорию "Настройка" для не-админов
if (category.equals("Настройка") && !isAdmin) {
return;
}
String emoji = CATEGORY_EMOJIS.getOrDefault(category, "📌");
int count = commandsByCategory.get(category).size();
categoriesText.append(emoji).append(" **").append(category)
.append("** — ").append(count).append(" команд\n");
});
return EmbedFactory.create()
.setTitle("📚 Справка по командам Loki Bot")
.setDescription(
"Привет! Я многофункциональный Discord бот с системой уровней, " +
"личными голосовыми каналами и множеством развлекательных команд.\n\n" +
"**Префикс команд:** `" + config.getPrefix() + "`\n" +
"**Всего команд:** " + totalCommands + "\n\n" +
"**📂 Категории команд:**\n" + categoriesText.toString() + "\n" +
"Используй селектор ниже для просмотра команд категории."
)
.setColor(new Color(88, 101, 242))
.setThumbnail(context.getGuild() != null ? context.getGuild().getIconUrl() : null)
.addField("💡 Как использовать",
"Большинство команд используют префикс `!`\n" +
"Пример: `!rank`, `!ship @user1 @user2`\n\n" +
"Некоторые важные команды используют `/`\n" +
"Пример: `/voice-setup`, `/counter-setup`",
false)
.setFooter(
"Запросил: " + context.getAuthor().getUser().getName(),
context.getAuthor().getUser().getAvatarUrl()
);
}
@Override
public String getCategory() {
return "Утилиты";
}
}

View file

@ -1,88 +0,0 @@
package creator.loki.command.impl.utility;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.data.JsonDataStore;
import creator.loki.profile.ProfileData;
import creator.loki.profile.ProfileGenerator;
import creator.loki.profile.ProfileService;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.utils.FileUpload;
import java.util.List;
/**
* Команда для отображения профиля пользователя
*/
public class ProfileCommand implements Command {
private final ProfileService profileService;
private final ProfileGenerator profileGenerator;
public ProfileCommand(JsonDataStore dataStore) {
this.profileService = new ProfileService(dataStore);
this.profileGenerator = new ProfileGenerator();
}
@Override
public String getName() {
return "profile";
}
@Override
public String getDescription() {
return "Показать профиль пользователя";
}
@Override
public String getUsage() {
return "profile [@user]";
}
@Override
public String getCategory() {
return "Общие";
}
@Override
public void execute(CommandContext context) {
if (context.getGuild() == null) {
context.getChannel().sendMessage("Команда доступна только на сервере").queue();
return;
}
// Определяем целевого пользователя
Member target;
List<Member> mentioned = context.getMessage().getMentions().getMembers();
if (mentioned.isEmpty()) {
target = context.getAuthor();
} else {
target = mentioned.get(0);
}
if (target == null) {
context.getChannel().sendMessage("Пользователь не найден").queue();
return;
}
// Отправляем сообщение о генерации
context.getChannel().sendMessage("🎨 Генерирую профиль...").queue(loadingMsg -> {
try {
// Получаем данные профиля
ProfileData profileData = profileService.getProfileData(target, context.getGuild());
// Генерируем изображение
byte[] imageBytes = profileGenerator.generateProfile(profileData);
// Отправляем изображение
loadingMsg.delete().queue();
context.getChannel().sendFiles(
FileUpload.fromData(imageBytes, "profile.png")
).queue();
} catch (Exception e) {
loadingMsg.editMessage("❌ Ошибка генерации профиля: " + e.getMessage()).queue();
e.printStackTrace();
}
});
}
}

View file

@ -1,63 +0,0 @@
package creator.loki.command.impl.utility.system;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedFactory;
import creator.loki.metrics.MetricsCollector;
import net.dv8tion.jda.api.Permission;
import java.awt.Color;
public class MetricsCommand implements Command {
private final MetricsCollector metrics;
public MetricsCommand(MetricsCollector metrics) {
this.metrics = metrics;
}
@Override
public void execute(CommandContext ctx) {
// Только администраторы могут видеть метрики
if (ctx.getAuthor() == null || !ctx.getAuthor().hasPermission(Permission.ADMINISTRATOR)) {
ctx.getChannel().sendMessageEmbeds(
EmbedFactory.create()
.setTitle("❌ Недостаточно прав")
.setDescription("Эта команда доступна только администраторам.")
.setColor(Color.RED)
.build()
).queue();
return;
}
String stats = metrics.getFullStats();
ctx.getChannel().sendMessageEmbeds(
EmbedFactory.create()
.setTitle("📊 Метрики бота")
.setDescription("```\n" + stats + "\n```")
.setColor(new Color(88, 101, 242))
.setFooter("Используй !metrics reset для сброса")
.build()
).queue();
}
@Override
public String getName() {
return "metrics";
}
@Override
public String getDescription() {
return "Показывает метрики и статистику бота (только для администраторов)";
}
@Override
public String getUsage() {
return "metrics";
}
@Override
public String getCategory() {
return "Система";
}
}

View file

@ -1,25 +1,26 @@
package creator.loki.config; package creator.loki.config;
public class BotConfig { public class BotConfig {
private final String token; private final String token;
private final String prefix; private final String prefix;
private final String activity; private final String activity;
private final int healthHttpPort;
public BotConfig(String token, String prefix, String activity) { private final String superAdminId;
this.token = token; private final String dbPath;
this.prefix = prefix;
this.activity = activity; public BotConfig(String token, String prefix, String activity, int healthHttpPort, String superAdminId, String dbPath) {
} this.token = token;
this.prefix = prefix;
public String getToken() { this.activity = activity;
return token; this.healthHttpPort = healthHttpPort;
} this.superAdminId = superAdminId;
this.dbPath = dbPath;
public String getPrefix() { }
return prefix;
} public String getToken() { return token; }
public String getPrefix() { return prefix; }
public String getActivity() { public String getActivity() { return activity; }
return activity; public int getHealthHttpPort() { return healthHttpPort; }
} public String getSuperAdminId() { return superAdminId; }
} public String getDbPath() { return dbPath; }
}

View file

@ -1,39 +1,67 @@
package creator.loki.config; package creator.loki.config;
import io.github.cdimascio.dotenv.Dotenv; import io.github.cdimascio.dotenv.Dotenv;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
public final class ConfigLoader { public final class ConfigLoader {
private static final Logger logger = LoggerFactory.getLogger(ConfigLoader.class); private static final Logger logger = LoggerFactory.getLogger(ConfigLoader.class);
private ConfigLoader() { private ConfigLoader() {
// Запрет создания экземпляров }
}
private static int parsePort(String raw, String key) {
public static BotConfig load() { if (raw == null || raw.isBlank()) return 0;
Dotenv dotenv = Dotenv.configure() try {
.ignoreIfMissing() int port = Integer.parseInt(raw.trim());
.load(); if (port < 0 || port > 65535) {
throw new IllegalArgumentException("порт вне диапазона 0..65535");
String token = dotenv.get("DISCORD_BOT_TOKEN"); }
if (token == null || token.isBlank()) { return port;
throw new IllegalStateException( } catch (NumberFormatException e) {
"Токен бота не найден! Установите переменную окружения DISCORD_BOT_TOKEN или создайте .env файл" throw new IllegalStateException(
); "Некорректный " + key + ": \"" + raw + "\". Ожидается число 0..65535.",
} e
);
String prefix = dotenv.get("BOT_PREFIX"); }
if (prefix == null || prefix.isBlank()) { }
prefix = "!";
} public static BotConfig load() {
Dotenv dotenv = Dotenv.configure()
String activity = dotenv.get("BOT_ACTIVITY"); .ignoreIfMissing()
if (activity == null || activity.isBlank()) { .load();
activity = "Используй !help";
} String token = dotenv.get("DISCORD_BOT_TOKEN");
if (token == null || token.isBlank()) {
logger.info("Конфигурация загружена успешно"); throw new IllegalStateException(
return new BotConfig(token, prefix, activity); "Токен бота не найден! Установите переменную окружения DISCORD_BOT_TOKEN или создайте .env файл"
} );
} }
String prefix = dotenv.get("BOT_PREFIX");
if (prefix == null || prefix.isBlank()) {
prefix = "!";
}
String activity = dotenv.get("BOT_ACTIVITY");
if (activity == null || activity.isBlank()) {
activity = "Используй !help";
}
int healthHttpPort = parsePort(dotenv.get("HEALTH_HTTP_PORT"), "HEALTH_HTTP_PORT");
String superAdminId = dotenv.get("SUPER_ADMIN_ID");
if (superAdminId == null || superAdminId.isBlank()) {
superAdminId = "1414975197269987471";
logger.warn("SUPER_ADMIN_ID не задан, используется значение по умолчанию");
}
String dbPath = dotenv.get("DB_PATH");
if (dbPath == null || dbPath.isBlank()) {
dbPath = "data/bot.db";
}
logger.info("Конфигурация загружена успешно");
return new BotConfig(token, prefix, activity, healthHttpPort, superAdminId, dbPath);
}
}

View file

@ -1,37 +1,11 @@
package creator.loki.core; package creator.loki.core;
import com.google.inject.Inject; import com.google.inject.Inject;
import creator.loki.command.impl.setup.LogSetupCommand;
import creator.loki.command.impl.stats.leaderboard.LeaderboardButtonListener;
import creator.loki.config.BotConfig; import creator.loki.config.BotConfig;
import creator.loki.core.EventDispatcher;
import creator.loki.core.registry.CommandRegistry;
import creator.loki.core.registry.PrefixCommandRegistrar; import creator.loki.core.registry.PrefixCommandRegistrar;
import creator.loki.core.registry.SlashCommandRegistrar;
import creator.loki.core.registry.SlashCommandRegistry;
import creator.loki.feature.counter.CounterManager;
import creator.loki.data.JsonDataStore;
import creator.loki.database.ConfigRepository;
import creator.loki.database.Database;
import creator.loki.feature.counter.CounterSetupCommand;
import creator.loki.feature.counter.MemberCounterListener; import creator.loki.feature.counter.MemberCounterListener;
import creator.loki.feature.level.LevelUpNotifier; import creator.loki.feature.moderation.MuteScheduler;
import creator.loki.feature.level.service.LevelService; import creator.loki.feature.metrics.HealthHttpServer;
import creator.loki.feature.voice.VoiceButtonHandler;
import creator.loki.feature.voice.VoiceChannelListener;
import creator.loki.feature.voice.VoiceChannelManager;
import creator.loki.feature.voice.VoiceSetupCommand;
import creator.loki.feature.level.VoiceTracker;
import creator.loki.level.LevelSystem;
import creator.loki.listener.*;
import creator.loki.listener.interaction.SlashCommandListener;
import creator.loki.metrics.MetricsCollector;
import creator.loki.profile.ProfileService;
import creator.loki.feature.role.CustomRoleManager;
import creator.loki.core.security.CooldownManager;
import creator.loki.core.security.RateLimiter;
import creator.loki.feature.voice.VoiceChannelManager;
import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder; import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.OnlineStatus; import net.dv8tion.jda.api.OnlineStatus;
@ -44,118 +18,52 @@ public class BotInitializer {
private static final Logger logger = LoggerFactory.getLogger(BotInitializer.class); private static final Logger logger = LoggerFactory.getLogger(BotInitializer.class);
private final BotConfig config; private final BotConfig config;
private final CommandRegistry registry; private final PrefixCommandRegistrar registrar;
private final SlashCommandRegistry slashRegistry; private final ListenerAggregator listenerAggregator;
private final EventDispatcher dispatcher; private final MuteScheduler muteScheduler;
private final JsonDataStore dataStore;
private final CustomRoleManager roleManager;
private final CooldownManager cooldownManager;
private final RateLimiter rateLimiter;
private final MetricsCollector metrics;
private final LevelSystem levelSystem;
private final LevelService levelService;
private final VoiceTracker voiceTracker;
private final ProfileService profileService;
private final creator.loki.feature.profile.ProfileService newProfileService;
private final VoiceChannelManager voiceChannelManager;
private final CounterManager counterManager;
private final VoiceChannelListener voiceChannelListener;
private final MemberCounterListener memberCounterListener; private final MemberCounterListener memberCounterListener;
private final Database database; private final HealthHttpServer healthHttpServer;
private final ConfigRepository configRepo; private final ShutdownManager shutdownManager;
private final VoiceSetupCommand voiceSetupCommand;
private final CounterSetupCommand counterSetupCommand;
private final LogSetupCommand logSetupCommand;
private final VoiceButtonHandler voiceButtonHandler;
private final ModerationLogger moderationLogger;
private final LevelUpNotifier levelUpNotifier;
private final HelpMenuHandler helpMenuHandler;
@Inject @Inject
public BotInitializer( public BotInitializer(
BotConfig config, BotConfig config,
CommandRegistry registry, PrefixCommandRegistrar registrar,
SlashCommandRegistry slashRegistry, ListenerAggregator listenerAggregator,
EventDispatcher dispatcher, MuteScheduler muteScheduler,
JsonDataStore dataStore, MemberCounterListener memberCounterListener,
CustomRoleManager roleManager, HealthHttpServer healthHttpServer,
CooldownManager cooldownManager, ShutdownManager shutdownManager
RateLimiter rateLimiter,
MetricsCollector metrics,
LevelSystem levelSystem,
LevelService levelService,
VoiceTracker voiceTracker,
ProfileService profileService,
creator.loki.feature.profile.ProfileService newProfileService,
VoiceChannelManager voiceChannelManager,
CounterManager counterManager,
Database database,
ConfigRepository configRepo,
LevelUpNotifier levelUpNotifier
) { ) {
this.config = config; this.config = config;
this.registry = registry; this.registrar = registrar;
this.slashRegistry = slashRegistry; this.listenerAggregator = listenerAggregator;
this.dispatcher = dispatcher; this.muteScheduler = muteScheduler;
this.dataStore = dataStore; this.memberCounterListener = memberCounterListener;
this.roleManager = roleManager; this.healthHttpServer = healthHttpServer;
this.cooldownManager = cooldownManager; this.shutdownManager = shutdownManager;
this.rateLimiter = rateLimiter;
this.metrics = metrics;
this.levelSystem = levelSystem;
this.levelService = levelService;
this.voiceTracker = voiceTracker;
this.profileService = profileService;
this.newProfileService = newProfileService;
this.voiceChannelManager = voiceChannelManager;
this.counterManager = counterManager;
this.database = database;
this.configRepo = configRepo;
this.levelUpNotifier = levelUpNotifier;
this.voiceChannelListener = new VoiceChannelListener(voiceChannelManager);
this.memberCounterListener = new MemberCounterListener(counterManager);
this.voiceSetupCommand = new VoiceSetupCommand(configRepo);
this.counterSetupCommand = new CounterSetupCommand(configRepo);
this.logSetupCommand = new LogSetupCommand(configRepo);
this.voiceButtonHandler = new VoiceButtonHandler(voiceChannelManager);
this.moderationLogger = new ModerationLogger(configRepo);
this.helpMenuHandler = new HelpMenuHandler(registry, config);
} }
public JDA start() { public JDA start() {
logger.info("Инициализация бота..."); logger.info("Инициализация бота...");
registrar.registerAll();
// Регистрируем команды через специализированные регистраторы
registerCommands();
registerSlashCommands();
// Создаём JDA
JDA jda = createJDA(); JDA jda = createJDA();
try {
// Регистрируем slash команды в Discord jda.awaitReady();
registerSlashCommandsInDiscord(jda); } catch (InterruptedException e) {
Thread.currentThread().interrupt();
// Настраиваем graceful shutdown throw new RuntimeException("Бот прерван во время ожидания готовности", e);
setupShutdownHook(jda); }
jda.updateCommands().queue();
muteScheduler.restorePending(jda);
for (var guild : jda.getGuilds()) {
memberCounterListener.startScheduler(guild);
}
healthHttpServer.start();
registerShutdownHook(jda);
return jda; return jda;
} }
private void registerCommands() {
PrefixCommandRegistrar registrar = new PrefixCommandRegistrar(
registry, config, dataStore, roleManager, cooldownManager,
levelSystem, levelService, newProfileService, metrics
);
registrar.registerAll();
}
private void registerSlashCommands() {
SlashCommandRegistrar registrar = new SlashCommandRegistrar(
slashRegistry, dataStore, metrics
);
registrar.registerAll();
}
private JDA createJDA() { private JDA createJDA() {
return JDABuilder.createDefault(config.getToken()) return JDABuilder.createDefault(config.getToken())
.setStatus(OnlineStatus.ONLINE) .setStatus(OnlineStatus.ONLINE)
@ -166,85 +74,12 @@ public class BotInitializer {
GatewayIntent.GUILD_MEMBERS, GatewayIntent.GUILD_MEMBERS,
GatewayIntent.GUILD_VOICE_STATES GatewayIntent.GUILD_VOICE_STATES
) )
.addEventListeners( .addEventListeners(listenerAggregator.getAll())
new ReadyListener(),
new MessageListener(dispatcher, dataStore, levelSystem),
new SlashCommandListener(slashRegistry, rateLimiter, metrics),
voiceTracker,
voiceChannelListener,
memberCounterListener,
voiceSetupCommand,
counterSetupCommand,
logSetupCommand,
voiceButtonHandler,
moderationLogger,
helpMenuHandler,
new LeaderboardButtonListener(levelSystem)
)
.build(); .build();
} }
private void registerSlashCommandsInDiscord(JDA jda) { private void registerShutdownHook(JDA jda) {
try { Runtime.getRuntime().addShutdownHook(new Thread(
jda.awaitReady(); () -> shutdownManager.shutdown(jda), "ShutdownHook"));
slashRegistry.registerCommandsInDiscord(jda);
// Регистрируем setup команды
VoiceSetupCommand.register(jda);
CounterSetupCommand.register(jda);
LogSetupCommand.register(jda);
// Запускаем планировщик счетчика участников
if (!jda.getGuilds().isEmpty()) {
memberCounterListener.startScheduler(jda.getGuilds().get(0));
}
} catch (InterruptedException e) {
logger.error("Прервано ожидание готовности JDA", e);
Thread.currentThread().interrupt();
}
}
private void setupShutdownHook(JDA jda) {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
logger.info("========================================");
logger.info("Получен сигнал завершения работы бота...");
logger.info("========================================");
shutdownComponents(jda);
logger.info("========================================");
logger.info("Бот успешно завершён!");
logger.info("========================================");
}, "ShutdownHook"));
}
private void shutdownComponents(JDA jda) {
try {
logger.info("Сохранение данных...");
dataStore.flush();
logger.info("Остановка менеджеров...");
cooldownManager.shutdown();
rateLimiter.shutdown();
voiceTracker.shutdown();
memberCounterListener.shutdown();
dispatcher.shutdown();
logger.info("Завершение JsonDataStore...");
dataStore.shutdown();
logger.info("Закрытие базы данных...");
database.close();
logger.info("Отключение JDA...");
jda.shutdown();
if (!jda.awaitShutdown(java.time.Duration.ofSeconds(10))) {
logger.warn("JDA не завершился за 10 секунд, принудительная остановка");
jda.shutdownNow();
}
} catch (Exception e) {
logger.error("Ошибка при завершении работы бота", e);
}
} }
} }

View file

@ -1,130 +1,130 @@
package creator.loki.core; package creator.loki.core;
import creator.loki.command.Command; import creator.loki.command.Command;
import creator.loki.command.CommandContext; import creator.loki.command.CommandContext;
import creator.loki.config.BotConfig; import creator.loki.config.BotConfig;
import creator.loki.core.registry.CommandRegistry; import creator.loki.core.registry.CommandRegistry;
import creator.loki.embed.EmbedTemplates; import creator.loki.embed.EmbedTemplates;
import creator.loki.exception.CommandExceptionHandler; import creator.loki.core.exception.CommandExceptionHandler;
import creator.loki.metrics.MetricsCollector; import creator.loki.feature.metrics.MetricsCollector;
import creator.loki.core.security.RateLimiter; import creator.loki.core.security.RateLimiter;
import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.util.Arrays; import java.util.Arrays;
import java.util.List; import java.util.List;
import java.util.concurrent.ExecutorService; import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
public class EventDispatcher { public class EventDispatcher {
private static final Logger logger = LoggerFactory.getLogger(EventDispatcher.class); private static final Logger logger = LoggerFactory.getLogger(EventDispatcher.class);
private final BotConfig config; private final BotConfig config;
private final CommandRegistry registry; private final CommandRegistry registry;
private final ExecutorService executorService; private final ExecutorService executorService;
private final RateLimiter rateLimiter; private final RateLimiter rateLimiter;
private final MetricsCollector metrics; private final MetricsCollector metrics;
public EventDispatcher(BotConfig config, CommandRegistry registry, RateLimiter rateLimiter, MetricsCollector metrics) { public EventDispatcher(BotConfig config, CommandRegistry registry, RateLimiter rateLimiter, MetricsCollector metrics) {
this.config = config; this.config = config;
this.registry = registry; this.registry = registry;
this.executorService = Executors.newCachedThreadPool(); this.executorService = Executors.newVirtualThreadPerTaskExecutor();
this.rateLimiter = rateLimiter; this.rateLimiter = rateLimiter;
this.metrics = metrics; this.metrics = metrics;
} }
public void handleMessage(MessageReceivedEvent event) { public void handleMessage(MessageReceivedEvent event) {
// Игнорируем ботов // Игнорируем ботов
if (event.getAuthor().isBot()) { if (event.getAuthor().isBot()) {
return; return;
} }
String content = event.getMessage().getContentRaw(); String content = event.getMessage().getContentRaw();
if (!content.startsWith(config.getPrefix())) { if (!content.startsWith(config.getPrefix())) {
return; return;
} }
// Парсим команду и аргументы // Парсим команду и аргументы
String[] parts = content.substring(config.getPrefix().length()).trim().split("\\s+"); String[] parts = content.substring(config.getPrefix().length()).trim().split("\\s+");
if (parts.length == 0) { if (parts.length == 0) {
return; return;
} }
String commandName = parts[0].toLowerCase(); String commandName = parts[0].toLowerCase();
List<String> args = parts.length > 1 List<String> args = parts.length > 1
? Arrays.asList(Arrays.copyOfRange(parts, 1, parts.length)) ? Arrays.asList(Arrays.copyOfRange(parts, 1, parts.length))
: List.of(); : List.of();
Command command = registry.getCommand(commandName); Command command = registry.getCommand(commandName);
if (command == null) { if (command == null) {
event.getChannel().sendMessageEmbeds( event.getChannel().sendMessageEmbeds(
EmbedTemplates.error( EmbedTemplates.error(
"Команда не найдена", "Команда не найдена",
"Команда `" + commandName + "` не существует. Используй `" + config.getPrefix() + "help`" "Команда `" + commandName + "` не существует. Используй `" + config.getPrefix() + "help`"
) )
).queue(); ).queue();
return; return;
} }
// Проверяем глобальный rate limit // Проверяем глобальный rate limit
String userId = event.getAuthor().getId(); String userId = event.getAuthor().getId();
String guildId = event.getGuild() != null ? event.getGuild().getId() : "DM"; String guildId = event.getGuild() != null ? event.getGuild().getId() : "DM";
if (!rateLimiter.allow(userId, guildId)) { if (!rateLimiter.allow(userId, guildId)) {
long waitTime = rateLimiter.getUserWaitTime(userId); long waitTime = rateLimiter.getUserWaitTime(userId);
event.getChannel().sendMessageEmbeds( event.getChannel().sendMessageEmbeds(
EmbedTemplates.error( EmbedTemplates.error(
"⏱️ Слишком быстро!", "⏱️ Слишком быстро!",
"Ты отправляешь команды слишком часто. Подожди " + waitTime + " секунд." "Ты отправляешь команды слишком часто. Подожди " + waitTime + " секунд."
) )
).queue(); ).queue();
logger.warn("Rate limit для пользователя {} в гильдии {}", userId, guildId); logger.warn("Rate limit для пользователя {} в гильдии {}", userId, guildId);
return; return;
} }
// Выполняем команду асинхронно // Выполняем команду асинхронно
executorService.submit(() -> { executorService.submit(() -> {
long startTime = System.currentTimeMillis(); long startTime = System.currentTimeMillis();
try { try {
CommandContext context = new CommandContext( CommandContext context = new CommandContext(
event.getMessage(), event.getMessage(),
args, args,
event.getChannel(), event.getChannel(),
event.getMember(), event.getMember(),
event.getGuild() event.getGuild()
); );
command.execute(context); command.execute(context);
// Записываем метрики // Записываем метрики
long executionTime = System.currentTimeMillis() - startTime; long executionTime = System.currentTimeMillis() - startTime;
metrics.recordCommand(commandName, executionTime); metrics.recordCommand(commandName, executionTime);
logger.info("Выполнена команда: {} от {} за {} мс", logger.info("Выполнена команда: {} от {} за {} мс",
commandName, event.getAuthor().getName(), executionTime); commandName, event.getAuthor().getName(), executionTime);
} catch (Exception e) { } catch (Exception e) {
// Записываем ошибку в метрики // Записываем ошибку в метрики
metrics.recordError(commandName); metrics.recordError(commandName);
// Используем централизованный обработчик ошибок // Используем централизованный обработчик ошибок
CommandExceptionHandler.handle(e, event.getChannel(), commandName); CommandExceptionHandler.handle(e, event.getChannel(), commandName);
} }
}); });
} }
public void shutdown() { public void shutdown() {
logger.info("Завершение работы EventDispatcher..."); logger.info("Завершение работы EventDispatcher...");
executorService.shutdown(); executorService.shutdown();
try { try {
if (!executorService.awaitTermination(10, TimeUnit.SECONDS)) { if (!executorService.awaitTermination(10, TimeUnit.SECONDS)) {
logger.warn("ExecutorService не завершился за 10 секунд, принудительная остановка"); logger.warn("ExecutorService не завершился за 10 секунд, принудительная остановка");
executorService.shutdownNow(); executorService.shutdownNow();
} }
} catch (InterruptedException e) { } catch (InterruptedException e) {
logger.error("Прервано ожидание завершения ExecutorService", e); logger.error("Прервано ожидание завершения ExecutorService", e);
executorService.shutdownNow(); executorService.shutdownNow();
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
} }
logger.info("EventDispatcher завершён"); logger.info("EventDispatcher завершён");
} }
} }

View file

@ -0,0 +1,54 @@
package creator.loki.core;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import creator.loki.feature.level.MessageListener;
import creator.loki.feature.moderation.ModerationLogger;
import creator.loki.bootstrap.ReadyListener;
import creator.loki.feature.help.HelpMenuHandler;
import creator.loki.feature.counter.MemberCounterListener;
import creator.loki.feature.leaderboard.LeaderboardButtonListener;
import creator.loki.feature.level.LevelUpNotifier;
import creator.loki.feature.level.ResetLevelsButtonHandler;
import creator.loki.feature.level.VoiceTracker;
import creator.loki.feature.voice.VoiceButtonHandler;
import creator.loki.feature.voice.VoiceChannelListener;
import creator.loki.feature.voice.VoiceModalHandler;
import creator.loki.feature.voice.VoiceSyncListener;
import creator.loki.feature.welcome.WelcomeListener;
@Singleton
public class ListenerAggregator {
private final Object[] listeners;
@Inject
public ListenerAggregator(
ReadyListener readyListener,
MessageListener messageListener,
VoiceTracker voiceTracker,
VoiceChannelListener voiceChannelListener,
MemberCounterListener memberCounterListener,
VoiceButtonHandler voiceButtonHandler,
VoiceModalHandler voiceModalHandler,
VoiceSyncListener voiceSyncListener,
WelcomeListener welcomeListener,
ModerationLogger moderationLogger,
HelpMenuHandler helpMenuHandler,
ResetLevelsButtonHandler resetLevelsButtonHandler,
LeaderboardButtonListener leaderboardButtonListener,
MessageCache messageCache
) {
this.listeners = new Object[]{
readyListener, messageListener, voiceTracker,
voiceChannelListener, memberCounterListener,
voiceButtonHandler, voiceModalHandler, voiceSyncListener,
welcomeListener, moderationLogger, helpMenuHandler,
resetLevelsButtonHandler, leaderboardButtonListener,
messageCache
};
}
public Object[] getAll() {
return listeners;
}
}

View file

@ -0,0 +1,32 @@
package creator.loki.core;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import java.util.concurrent.TimeUnit;
public class MessageCache extends ListenerAdapter {
private final Cache<String, String> cache;
public MessageCache() {
this.cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(1, TimeUnit.HOURS)
.build();
}
@Override
public void onMessageReceived(MessageReceivedEvent event) {
if (event.getAuthor().isBot()) return;
String content = event.getMessage().getContentRaw();
if (!content.isBlank()) {
cache.put(event.getMessageId(), content);
}
}
public String getContent(String messageId) {
return cache.getIfPresent(messageId);
}
}

View file

@ -0,0 +1,85 @@
package creator.loki.core;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import creator.loki.database.Database;
import creator.loki.feature.counter.MemberCounterListener;
import creator.loki.feature.level.VoiceTracker;
import creator.loki.feature.moderation.MuteScheduler;
import creator.loki.feature.metrics.HealthHttpServer;
import creator.loki.core.security.CooldownManager;
import creator.loki.core.security.RateLimiter;
import net.dv8tion.jda.api.JDA;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.Duration;
@Singleton
public class ShutdownManager {
private static final Logger logger = LoggerFactory.getLogger(ShutdownManager.class);
private final HealthHttpServer healthHttpServer;
private final CooldownManager cooldownManager;
private final RateLimiter rateLimiter;
private final VoiceTracker voiceTracker;
private final MemberCounterListener memberCounterListener;
private final MuteScheduler muteScheduler;
private final EventDispatcher dispatcher;
private final Database database;
@Inject
public ShutdownManager(
HealthHttpServer healthHttpServer,
CooldownManager cooldownManager,
RateLimiter rateLimiter,
VoiceTracker voiceTracker,
MemberCounterListener memberCounterListener,
MuteScheduler muteScheduler,
EventDispatcher dispatcher,
Database database
) {
this.healthHttpServer = healthHttpServer;
this.cooldownManager = cooldownManager;
this.rateLimiter = rateLimiter;
this.voiceTracker = voiceTracker;
this.memberCounterListener = memberCounterListener;
this.muteScheduler = muteScheduler;
this.dispatcher = dispatcher;
this.database = database;
}
public void shutdown(JDA jda) {
logger.info("========================================");
logger.info("Получен сигнал завершения работы бота...");
logger.info("========================================");
try {
healthHttpServer.stop();
logger.info("Остановка менеджеров...");
cooldownManager.shutdown();
rateLimiter.shutdown();
voiceTracker.shutdown();
memberCounterListener.shutdown();
muteScheduler.shutdown();
dispatcher.shutdown();
logger.info("Закрытие базы данных...");
database.close();
logger.info("Отключение JDA...");
jda.shutdown();
if (!jda.awaitShutdown(Duration.ofSeconds(10))) {
logger.warn("JDA не завершился за 10 секунд, принудительная остановка");
jda.shutdownNow();
}
} catch (Exception e) {
logger.error("Ошибка при завершении работы бота", e);
}
logger.info("========================================");
logger.info("Бот успешно завершён!");
logger.info("========================================");
}
}

View file

@ -1,22 +1,23 @@
package creator.loki.exception; package creator.loki.core.exception;
/** /**
* Исключение для ошибок выполнения команд * Исключение для ошибок выполнения команд
*/ */
public class CommandException extends Exception { public class CommandException extends Exception {
private final String userMessage; private static final long serialVersionUID = 1L;
private final String userMessage;
public CommandException(String userMessage) {
super(userMessage); public CommandException(String userMessage) {
this.userMessage = userMessage; super(userMessage);
} this.userMessage = userMessage;
}
public CommandException(String userMessage, Throwable cause) {
super(userMessage, cause); public CommandException(String userMessage, Throwable cause) {
this.userMessage = userMessage; super(userMessage, cause);
} this.userMessage = userMessage;
}
public String getUserMessage() {
return userMessage; public String getUserMessage() {
} return userMessage;
} }
}

View file

@ -1,63 +1,63 @@
package creator.loki.exception; package creator.loki.core.exception;
import creator.loki.embed.EmbedFactory; import creator.loki.embed.EmbedFactory;
import net.dv8tion.jda.api.entities.MessageEmbed; import net.dv8tion.jda.api.entities.MessageEmbed;
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.awt.Color; import java.awt.Color;
/** /**
* Обработчик исключений для команд * Обработчик исключений для команд
*/ */
public class CommandExceptionHandler { public class CommandExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(CommandExceptionHandler.class); private static final Logger logger = LoggerFactory.getLogger(CommandExceptionHandler.class);
/** /**
* Обрабатывает исключение и отправляет сообщение пользователю * Обрабатывает исключение и отправляет сообщение пользователю
*/ */
public static void handle(Throwable throwable, MessageChannel channel, String commandName) { public static void handle(Throwable throwable, MessageChannel channel, String commandName) {
logger.error("Ошибка при выполнении команды: {}", commandName, throwable); logger.error("Ошибка при выполнении команды: {}", commandName, throwable);
String userMessage; String userMessage;
if (throwable instanceof CommandException) { if (throwable instanceof CommandException ce) {
userMessage = ((CommandException) throwable).getUserMessage(); userMessage = ce.getUserMessage();
} else { } else {
userMessage = "Произошла непредвиденная ошибка при выполнении команды."; userMessage = "Не удалось выполнить команду. Попробуйте позже или обратитесь к администратору.";
} }
MessageEmbed errorEmbed = EmbedFactory.create() MessageEmbed errorEmbed = EmbedFactory.create()
.setTitle("❌ Ошибка") .withTitle("❌ Ошибка")
.setDescription(userMessage) .withDescription(userMessage)
.setColor(Color.RED) .withColor(Color.RED)
.addField("Команда", commandName, true) .addField("Команда", commandName, true)
.setFooter("Если ошибка повторяется, обратитесь к администратору") .withFooter("Если ошибка повторяется, обратитесь к администратору")
.build(); .build();
channel.sendMessageEmbeds(errorEmbed).queue( channel.sendMessageEmbeds(errorEmbed).queue(
success -> {}, success -> {},
error -> logger.error("Не удалось отправить сообщение об ошибке", error) error -> logger.error("Не удалось отправить сообщение об ошибке", error)
); );
} }
/** /**
* Обрабатывает исключение с кастомным сообщением * Обрабатывает исключение с кастомным сообщением
*/ */
public static void handle(Throwable throwable, MessageChannel channel, String commandName, String customMessage) { public static void handle(Throwable throwable, MessageChannel channel, String commandName, String customMessage) {
logger.error("Ошибка при выполнении команды: {}", commandName, throwable); logger.error("Ошибка при выполнении команды: {}", commandName, throwable);
MessageEmbed errorEmbed = EmbedFactory.create() MessageEmbed errorEmbed = EmbedFactory.create()
.setTitle("❌ Ошибка") .withTitle("❌ Ошибка")
.setDescription(customMessage) .withDescription(customMessage)
.setColor(Color.RED) .withColor(Color.RED)
.addField("Команда", commandName, true) .addField("Команда", commandName, true)
.setFooter("Если ошибка повторяется, обратитесь к администратору") .withFooter("Если ошибка повторяется, обратитесь к администратору")
.build(); .build();
channel.sendMessageEmbeds(errorEmbed).queue( channel.sendMessageEmbeds(errorEmbed).queue(
success -> {}, success -> {},
error -> logger.error("Не удалось отправить сообщение об ошибке", error) error -> logger.error("Не удалось отправить сообщение об ошибке", error)
); );
} }
} }

View file

@ -0,0 +1,6 @@
package creator.loki.core.registry;
@FunctionalInterface
public interface CommandRegistrar {
void register(CommandRegistry registry);
}

View file

@ -1,32 +1,38 @@
package creator.loki.core.registry; package creator.loki.core.registry;
import creator.loki.command.Command; import creator.loki.command.Command;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.util.Collection; import java.util.Collection;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
public class CommandRegistry { public class CommandRegistry {
private static final Logger logger = LoggerFactory.getLogger(CommandRegistry.class); private static final Logger logger = LoggerFactory.getLogger(CommandRegistry.class);
private final Map<String, Command> commands = new HashMap<>(); private final Map<String, Command> commands = new HashMap<>();
public void register(Command command) { public void register(Command command) {
commands.put(command.getName().toLowerCase(), command); String key = command.getName().toLowerCase();
logger.debug("Зарегистрирована команда: {}", command.getName()); Command previous = commands.put(key, command);
} if (previous != null) {
logger.warn("Имя команды «{}» уже занято: {} заменена на {}",
public Command getCommand(String name) { key, previous.getClass().getSimpleName(), command.getClass().getSimpleName());
return commands.get(name.toLowerCase()); } else {
} logger.debug("Зарегистрирована команда: {}", command.getName());
}
public Collection<Command> getAllCommands() { }
return Collections.unmodifiableCollection(commands.values());
} public Command getCommand(String name) {
return commands.get(name.toLowerCase());
public int getCommandCount() { }
return commands.size();
} public Collection<Command> getAllCommands() {
} return Collections.unmodifiableCollection(commands.values());
}
public int getCommandCount() {
return commands.size();
}
}

View file

@ -1,156 +1,29 @@
package creator.loki.core.registry; package creator.loki.core.registry;
import creator.loki.command.impl.entertainment.games.*; import com.google.inject.Inject;
import creator.loki.command.impl.entertainment.games.random.CoinCommand; import com.google.inject.Singleton;
import creator.loki.command.impl.entertainment.games.random.DiceCommand;
import creator.loki.command.impl.entertainment.games.random.GlazbobaCommand;
import creator.loki.command.impl.entertainment.games.random.MemeCommand;
import creator.loki.command.impl.entertainment.games.random.RateCommand;
import creator.loki.command.impl.entertainment.games.rating.BoykisserCommand;
import creator.loki.command.impl.entertainment.games.rating.FatCommand;
import creator.loki.command.impl.entertainment.games.rating.GayCommand;
import creator.loki.command.impl.entertainment.games.rating.IqCommand;
import creator.loki.command.impl.entertainment.games.rating.MustacheCommand;
import creator.loki.command.impl.entertainment.games.rating.PpCommand;
import creator.loki.command.impl.entertainment.interaction.*;
import creator.loki.command.impl.entertainment.interaction.negative.PokeCommand;
import creator.loki.command.impl.entertainment.interaction.negative.SlapCommand;
import creator.loki.command.impl.entertainment.interaction.negative.TickleCommand;
import creator.loki.command.impl.entertainment.interaction.positive.CuddleCommand;
import creator.loki.command.impl.entertainment.interaction.positive.FeedCommand;
import creator.loki.command.impl.entertainment.interaction.positive.HugCommand;
import creator.loki.command.impl.entertainment.interaction.positive.KissCommand;
import creator.loki.command.impl.entertainment.interaction.positive.PatCommand;
import creator.loki.command.impl.moderation.*;
import creator.loki.feature.role.*;
import creator.loki.command.impl.stats.*;
import creator.loki.command.impl.utility.*;
import creator.loki.command.impl.utility.info.*;
import creator.loki.command.impl.utility.system.*;
import creator.loki.config.BotConfig;
import creator.loki.data.JsonDataStore;
import creator.loki.feature.level.service.LevelService;
import creator.loki.feature.role.CreateRoleCommand;
import creator.loki.feature.role.DeleteRoleCommand;
import creator.loki.feature.role.EditRoleCommand;
import creator.loki.feature.role.MyRolesCommand;
import creator.loki.level.LevelSystem;
import creator.loki.metrics.MetricsCollector;
import creator.loki.profile.ProfileService;
import creator.loki.feature.role.CustomRoleManager;
import creator.loki.core.security.CooldownManager;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
/** import java.util.Set;
* Регистратор префиксных команд (!) - Single Responsibility
*/ @Singleton
public class PrefixCommandRegistrar { public class PrefixCommandRegistrar {
private static final Logger logger = LoggerFactory.getLogger(PrefixCommandRegistrar.class); private static final Logger logger = LoggerFactory.getLogger(PrefixCommandRegistrar.class);
private final CommandRegistry registry; private final CommandRegistry registry;
private final BotConfig config; private final Set<CommandRegistrar> registrars;
private final JsonDataStore dataStore;
private final CustomRoleManager roleManager;
private final CooldownManager cooldownManager;
private final LevelSystem levelSystem;
private final LevelService levelService;
private final creator.loki.feature.profile.ProfileService newProfileService;
private final MetricsCollector metrics;
public PrefixCommandRegistrar( @Inject
CommandRegistry registry, public PrefixCommandRegistrar(CommandRegistry registry, Set<CommandRegistrar> registrars) {
BotConfig config,
JsonDataStore dataStore,
CustomRoleManager roleManager,
CooldownManager cooldownManager,
LevelSystem levelSystem,
LevelService levelService,
creator.loki.feature.profile.ProfileService newProfileService,
MetricsCollector metrics
) {
this.registry = registry; this.registry = registry;
this.config = config; this.registrars = registrars;
this.dataStore = dataStore;
this.roleManager = roleManager;
this.cooldownManager = cooldownManager;
this.levelSystem = levelSystem;
this.levelService = levelService;
this.newProfileService = newProfileService;
this.metrics = metrics;
} }
public void registerAll() { public void registerAll() {
registerUtilityCommands(); for (CommandRegistrar registrar : registrars) {
registerRoleCommands(); registrar.register(registry);
registerInteractionCommands(); }
registerGameCommands();
registerStatsCommands();
registerModerationCommands();
logger.info("Зарегистрировано префиксных команд: {}", registry.getCommandCount()); logger.info("Зарегистрировано префиксных команд: {}", registry.getCommandCount());
} }
private void registerUtilityCommands() {
registry.register(new PingCommand());
registry.register(new HelpCommand(registry, config));
registry.register(new InfoCommand());
registry.register(new ClearCommand());
registry.register(new ProfileCommand(dataStore));
registry.register(new creator.loki.feature.profile.ProfileCommand(newProfileService));
registry.register(new MetricsCommand(metrics));
registry.register(new AvatarCommand());
registry.register(new PollCommand());
registry.register(new SwapCommand());
}
private void registerRoleCommands() {
registry.register(new CreateRoleCommand(roleManager));
registry.register(new DeleteRoleCommand(roleManager));
registry.register(new MyRolesCommand(roleManager));
registry.register(new EditRoleCommand(roleManager));
}
private void registerInteractionCommands() {
registry.register(new PatCommand(cooldownManager));
registry.register(new HugCommand());
registry.register(new KissCommand(cooldownManager));
registry.register(new SlapCommand(cooldownManager));
registry.register(new TickleCommand(cooldownManager));
registry.register(new PokeCommand(cooldownManager));
registry.register(new CuddleCommand(cooldownManager));
registry.register(new FeedCommand(cooldownManager));
}
private void registerGameCommands() {
registry.register(new EightBallCommand());
registry.register(new CoinCommand());
registry.register(new DiceCommand());
registry.register(new MemeCommand());
registry.register(new ShipCommand(dataStore));
registry.register(new RoastCommand());
registry.register(new ComplimentCommand());
registry.register(new GlazbobaCommand());
registry.register(new IqCommand(cooldownManager));
registry.register(new GayCommand(cooldownManager));
registry.register(new PpCommand(cooldownManager));
registry.register(new RateCommand(cooldownManager));
registry.register(new BoykisserCommand(cooldownManager));
registry.register(new MustacheCommand(cooldownManager));
registry.register(new FatCommand(cooldownManager));
}
private void registerStatsCommands() {
registry.register(new creator.loki.feature.level.RankCommand(levelService));
registry.register(new creator.loki.command.impl.stats.leaderboard.NewLeaderboardCommand(levelSystem));
registry.register(new RepCommand(dataStore, cooldownManager));
registry.register(new MinusRepCommand(dataStore, cooldownManager));
registry.register(new RepTopCommand(dataStore));
registry.register(new creator.loki.feature.level.StatsCommand());
}
private void registerModerationCommands() {
registry.register(new BanCommand());
registry.register(new KickCommand());
}
} }

View file

@ -1,34 +0,0 @@
package creator.loki.core.registry;
import creator.loki.data.JsonDataStore;
import creator.loki.metrics.MetricsCollector;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Регистратор slash команд (/) - Single Responsibility
*/
public class SlashCommandRegistrar {
private static final Logger logger = LoggerFactory.getLogger(SlashCommandRegistrar.class);
private final SlashCommandRegistry registry;
private final JsonDataStore dataStore;
private final MetricsCollector metrics;
public SlashCommandRegistrar(
SlashCommandRegistry registry,
JsonDataStore dataStore,
MetricsCollector metrics
) {
this.registry = registry;
this.dataStore = dataStore;
this.metrics = metrics;
}
public void registerAll() {
// Все slash команды удалены согласно рефакторингу
// Используются только prefix команды (!)
logger.info("Зарегистрировано slash команд: {}", registry.getCommandCount());
}
}

View file

@ -1,67 +0,0 @@
package creator.loki.core.registry;
import creator.loki.command.SlashCommand;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.interactions.commands.build.CommandData;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Реестр для slash команд
*/
public class SlashCommandRegistry {
private static final Logger logger = LoggerFactory.getLogger(SlashCommandRegistry.class);
private final Map<String, SlashCommand> commands = new HashMap<>();
/**
* Регистрирует slash команду
*/
public void register(SlashCommand command) {
commands.put(command.getName().toLowerCase(), command);
logger.debug("Зарегистрирована slash команда: {}", command.getName());
}
/**
* Получает slash команду по имени
*/
public SlashCommand getCommand(String name) {
return commands.get(name.toLowerCase());
}
/**
* Возвращает все slash команды
*/
public Collection<SlashCommand> getAllCommands() {
return Collections.unmodifiableCollection(commands.values());
}
/**
* Возвращает количество slash команд
*/
public int getCommandCount() {
return commands.size();
}
/**
* Регистрирует все slash команды в Discord
*/
public void registerCommandsInDiscord(JDA jda) {
List<CommandData> commandDataList = new ArrayList<>();
for (SlashCommand command : commands.values()) {
commandDataList.add(command.getCommandData());
}
jda.updateCommands().addCommands(commandDataList).queue(
success -> logger.info("Зарегистрировано {} slash команд в Discord", commandDataList.size()),
error -> logger.error("Ошибка при регистрации slash команд", error)
);
}
}

View file

@ -1,83 +1,83 @@
package creator.loki.core.security; package creator.loki.core.security;
import java.time.Instant; import java.time.Instant;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
public class CooldownManager { public class CooldownManager {
private final ConcurrentHashMap<String, Instant> cooldowns = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, Instant> cooldowns = new ConcurrentHashMap<>();
private final ScheduledExecutorService cleanupExecutor = Executors.newSingleThreadScheduledExecutor(); private final ScheduledExecutorService cleanupExecutor = Executors.newSingleThreadScheduledExecutor();
public CooldownManager() { @SuppressWarnings("this-escape")
// Очистка истёкших cooldown'ов каждые 5 минут public CooldownManager() {
cleanupExecutor.scheduleAtFixedRate(this::cleanup, 5, 5, TimeUnit.MINUTES); cleanupExecutor.scheduleAtFixedRate(() -> Thread.startVirtualThread(this::cleanup), 5, 5, TimeUnit.MINUTES);
} }
/** /**
* Проверяет, находится ли пользователь на cooldown для команды * Проверяет, находится ли пользователь на cooldown для команды
*/ */
public boolean isOnCooldown(String userId, String commandName) { public boolean isOnCooldown(String userId, String commandName) {
String key = userId + ":" + commandName; String key = userId + ":" + commandName;
Instant cooldownEnd = cooldowns.get(key); Instant cooldownEnd = cooldowns.get(key);
if (cooldownEnd == null) { if (cooldownEnd == null) {
return false; return false;
} }
if (Instant.now().isAfter(cooldownEnd)) { if (Instant.now().isAfter(cooldownEnd)) {
cooldowns.remove(key); cooldowns.remove(key);
return false; return false;
} }
return true; return true;
} }
/** /**
* Возвращает оставшееся время cooldown в секундах * Возвращает оставшееся время cooldown в секундах
*/ */
public long getRemainingSeconds(String userId, String commandName) { public long getRemainingSeconds(String userId, String commandName) {
String key = userId + ":" + commandName; String key = userId + ":" + commandName;
Instant cooldownEnd = cooldowns.get(key); Instant cooldownEnd = cooldowns.get(key);
if (cooldownEnd == null) { if (cooldownEnd == null) {
return 0; return 0;
} }
long remaining = cooldownEnd.getEpochSecond() - Instant.now().getEpochSecond(); long remaining = cooldownEnd.getEpochSecond() - Instant.now().getEpochSecond();
return Math.max(0, remaining); return Math.max(0, remaining);
} }
/** /**
* Применяет cooldown для пользователя на команду * Применяет cooldown для пользователя на команду
*/ */
public void applyCooldown(String userId, String commandName, int seconds) { public void applyCooldown(String userId, String commandName, int seconds) {
String key = userId + ":" + commandName; String key = userId + ":" + commandName;
Instant cooldownEnd = Instant.now().plusSeconds(seconds); Instant cooldownEnd = Instant.now().plusSeconds(seconds);
cooldowns.put(key, cooldownEnd); cooldowns.put(key, cooldownEnd);
} }
/** /**
* Очищает истёкшие cooldown'ы * Очищает истёкшие cooldown'ы
*/ */
public void cleanup() { public void cleanup() {
Instant now = Instant.now(); Instant now = Instant.now();
cooldowns.entrySet().removeIf(entry -> now.isAfter(entry.getValue())); cooldowns.entrySet().removeIf(entry -> now.isAfter(entry.getValue()));
} }
/** /**
* Завершает работу менеджера * Завершает работу менеджера
*/ */
public void shutdown() { public void shutdown() {
cleanupExecutor.shutdown(); cleanupExecutor.shutdown();
try { try {
if (!cleanupExecutor.awaitTermination(5, TimeUnit.SECONDS)) { if (!cleanupExecutor.awaitTermination(5, TimeUnit.SECONDS)) {
cleanupExecutor.shutdownNow(); cleanupExecutor.shutdownNow();
} }
} catch (InterruptedException e) { } catch (InterruptedException e) {
cleanupExecutor.shutdownNow(); cleanupExecutor.shutdownNow();
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
} }
} }
} }

View file

@ -1,157 +1,163 @@
package creator.loki.core.security; package creator.loki.core.security;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.time.Instant; import java.time.Instant;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
/** /**
* Rate limiter для защиты от спама * Rate limiter для защиты от спама
* Использует алгоритм Token Bucket * Использует алгоритм Token Bucket
*/ */
public class RateLimiter { public class RateLimiter {
private static final Logger logger = LoggerFactory.getLogger(RateLimiter.class); private static final Logger logger = LoggerFactory.getLogger(RateLimiter.class);
// Лимиты по умолчанию // Лимиты по умолчанию
private static final int DEFAULT_USER_CAPACITY = 10; // Максимум токенов на пользователя private static final int DEFAULT_USER_CAPACITY = 10; // Максимум токенов на пользователя
private static final int DEFAULT_USER_REFILL_RATE = 2; // Токенов в секунду private static final int DEFAULT_USER_REFILL_RATE = 2; // Токенов в секунду
private static final int DEFAULT_GUILD_CAPACITY = 50; // Максимум токенов на гильдию private static final int DEFAULT_GUILD_CAPACITY = 50; // Максимум токенов на гильдию
private static final int DEFAULT_GUILD_REFILL_RATE = 10; // Токенов в секунду private static final int DEFAULT_GUILD_REFILL_RATE = 10; // Токенов в секунду
private final ConcurrentHashMap<String, TokenBucket> userBuckets = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, TokenBucket> userBuckets = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, TokenBucket> guildBuckets = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, TokenBucket> guildBuckets = new ConcurrentHashMap<>();
private final ScheduledExecutorService cleanupExecutor = Executors.newSingleThreadScheduledExecutor(); private final ScheduledExecutorService cleanupExecutor = Executors.newSingleThreadScheduledExecutor();
public RateLimiter() { public RateLimiter() {
// Очистка старых bucket'ов каждые 5 минут // Очистка старых bucket'ов каждые 5 минут
cleanupExecutor.scheduleAtFixedRate(this::cleanup, 5, 5, TimeUnit.MINUTES); cleanupExecutor.scheduleAtFixedRate(() -> Thread.startVirtualThread(this::cleanup), 5, 5, TimeUnit.MINUTES);
logger.info("RateLimiter инициализирован"); logger.info("RateLimiter инициализирован");
} }
/** /**
* Проверяет, может ли пользователь выполнить действие * Проверяет, может ли пользователь выполнить действие
*/ */
public boolean allowUser(String userId) { public boolean allowUser(String userId) {
TokenBucket bucket = userBuckets.computeIfAbsent( TokenBucket bucket = userBuckets.computeIfAbsent(
userId, userId,
k -> new TokenBucket(DEFAULT_USER_CAPACITY, DEFAULT_USER_REFILL_RATE) k -> new TokenBucket(DEFAULT_USER_CAPACITY, DEFAULT_USER_REFILL_RATE)
); );
return bucket.tryConsume(); return bucket.tryConsume();
} }
/** /**
* Проверяет, может ли гильдия выполнить действие * Проверяет, может ли гильдия выполнить действие
*/ */
public boolean allowGuild(String guildId) { public boolean allowGuild(String guildId) {
TokenBucket bucket = guildBuckets.computeIfAbsent( TokenBucket bucket = guildBuckets.computeIfAbsent(
guildId, guildId,
k -> new TokenBucket(DEFAULT_GUILD_CAPACITY, DEFAULT_GUILD_REFILL_RATE) k -> new TokenBucket(DEFAULT_GUILD_CAPACITY, DEFAULT_GUILD_REFILL_RATE)
); );
return bucket.tryConsume(); return bucket.tryConsume();
} }
/** /**
* Проверяет оба лимита: пользователя и гильдии * Проверяет оба лимита: пользователя и гильдии
*/ */
public boolean allow(String userId, String guildId) { public boolean allow(String userId, String guildId) {
return allowUser(userId) && allowGuild(guildId); return allowUser(userId) && allowGuild(guildId);
} }
/** /**
* Возвращает оставшееся время до восстановления для пользователя (в секундах) * Возвращает оставшееся время до восстановления для пользователя (в секундах)
*/ */
public long getUserWaitTime(String userId) { public long getUserWaitTime(String userId) {
TokenBucket bucket = userBuckets.get(userId); TokenBucket bucket = userBuckets.get(userId);
if (bucket == null) { if (bucket == null) {
return 0; return 0;
} }
return bucket.getWaitTime(); return bucket.getWaitTime();
} }
/** /**
* Очищает неактивные bucket'ы * Очищает неактивные bucket'ы
*/ */
private void cleanup() { private void cleanup() {
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
long threshold = now - TimeUnit.MINUTES.toMillis(10); long threshold = now - TimeUnit.MINUTES.toMillis(10);
userBuckets.entrySet().removeIf(entry -> entry.getValue().getLastAccessTime() < threshold); int usersBefore = userBuckets.size();
guildBuckets.entrySet().removeIf(entry -> entry.getValue().getLastAccessTime() < threshold); int guildsBefore = guildBuckets.size();
userBuckets.entrySet().removeIf(entry -> entry.getValue().getLastAccessTime() < threshold);
logger.debug("Очистка rate limiter: осталось {} user buckets, {} guild buckets", guildBuckets.entrySet().removeIf(entry -> entry.getValue().getLastAccessTime() < threshold);
userBuckets.size(), guildBuckets.size());
} int removedUsers = usersBefore - userBuckets.size();
int removedGuilds = guildsBefore - guildBuckets.size();
/** if (removedUsers > 0 || removedGuilds > 0) {
* Завершает работу rate limiter logger.debug("Очистка rate limiter: удалено {} user / {} guild buckets, осталось {} / {}",
*/ removedUsers, removedGuilds, userBuckets.size(), guildBuckets.size());
public void shutdown() { }
logger.info("Завершение работы RateLimiter..."); }
cleanupExecutor.shutdown();
try { /**
if (!cleanupExecutor.awaitTermination(5, TimeUnit.SECONDS)) { * Завершает работу rate limiter
cleanupExecutor.shutdownNow(); */
} public void shutdown() {
} catch (InterruptedException e) { logger.info("Завершение работы RateLimiter...");
cleanupExecutor.shutdownNow(); cleanupExecutor.shutdown();
Thread.currentThread().interrupt(); try {
} if (!cleanupExecutor.awaitTermination(5, TimeUnit.SECONDS)) {
logger.info("RateLimiter завершён"); cleanupExecutor.shutdownNow();
} }
} catch (InterruptedException e) {
/** cleanupExecutor.shutdownNow();
* Token Bucket для rate limiting Thread.currentThread().interrupt();
*/ }
private static class TokenBucket { logger.info("RateLimiter завершён");
private final int capacity; }
private final double refillRate; // токенов в секунду
private double tokens; /**
private long lastRefillTime; * Token Bucket для rate limiting
private long lastAccessTime; */
private static class TokenBucket {
public TokenBucket(int capacity, double refillRate) { private final int capacity;
this.capacity = capacity; private final double refillRate; // токенов в секунду
this.refillRate = refillRate; private double tokens;
this.tokens = capacity; private long lastRefillTime;
this.lastRefillTime = System.currentTimeMillis(); private long lastAccessTime;
this.lastAccessTime = System.currentTimeMillis();
} public TokenBucket(int capacity, double refillRate) {
this.capacity = capacity;
public synchronized boolean tryConsume() { this.refillRate = refillRate;
refill(); this.tokens = capacity;
lastAccessTime = System.currentTimeMillis(); this.lastRefillTime = System.currentTimeMillis();
this.lastAccessTime = System.currentTimeMillis();
if (tokens >= 1.0) { }
tokens -= 1.0;
return true; public synchronized boolean tryConsume() {
} refill();
return false; lastAccessTime = System.currentTimeMillis();
}
if (tokens >= 1.0) {
private void refill() { tokens -= 1.0;
long now = System.currentTimeMillis(); return true;
long timePassed = now - lastRefillTime; }
double tokensToAdd = (timePassed / 1000.0) * refillRate; return false;
}
tokens = Math.min(capacity, tokens + tokensToAdd);
lastRefillTime = now; private void refill() {
} long now = System.currentTimeMillis();
long timePassed = now - lastRefillTime;
public synchronized long getWaitTime() { double tokensToAdd = (timePassed / 1000.0) * refillRate;
refill();
if (tokens >= 1.0) { tokens = Math.min(capacity, tokens + tokensToAdd);
return 0; lastRefillTime = now;
} }
double tokensNeeded = 1.0 - tokens;
return (long) Math.ceil(tokensNeeded / refillRate); public synchronized long getWaitTime() {
} refill();
if (tokens >= 1.0) {
public long getLastAccessTime() { return 0;
return lastAccessTime; }
} double tokensNeeded = 1.0 - tokens;
} return (long) Math.ceil(tokensNeeded / refillRate);
} }
public long getLastAccessTime() {
return lastAccessTime;
}
}
}

View file

@ -1,62 +1,55 @@
package creator.loki.database; package creator.loki.database;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.sql.*; import java.lang.reflect.Proxy;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.SQLException;
import java.sql.Statement;
/**
* Менеджер SQLite базы данных
*/
public class Database { public class Database {
private static final Logger logger = LoggerFactory.getLogger(Database.class); private static final Logger logger = LoggerFactory.getLogger(Database.class);
private final String dbPath; private final String dbPath;
private Connection connection; private HikariDataSource dataSource;
public Database(String dbPath) { public Database(String dbPath) {
this.dbPath = dbPath; this.dbPath = dbPath;
} }
/**
* Подключиться к базе данных
*/
public void connect() { public void connect() {
try { HikariConfig config = new HikariConfig();
String url = "jdbc:sqlite:" + dbPath; config.setJdbcUrl("jdbc:sqlite:" + dbPath);
connection = DriverManager.getConnection(url); config.setMaximumPoolSize(5);
logger.info("Подключение к базе данных: {}", dbPath); config.setConnectionTimeout(10000);
config.setIdleTimeout(300000);
// Включаем foreign keys config.setMaxLifetime(600000);
try (Statement stmt = connection.createStatement()) { config.setPoolName("LokiDB");
stmt.execute("PRAGMA foreign_keys = ON");
} dataSource = new HikariDataSource(config);
logger.info("Подключение к базе данных: {} (pool size: {})", dbPath, config.getMaximumPoolSize());
try (Connection conn = dataSource.getConnection(); Statement stmt = conn.createStatement()) {
stmt.execute("PRAGMA foreign_keys = ON");
} catch (SQLException e) { } catch (SQLException e) {
logger.error("Ошибка подключения к базе данных", e); logger.error("Ошибка включения foreign keys", e);
}
}
public Connection getConnection() {
try {
return dataSource.getConnection();
} catch (SQLException e) {
logger.error("Ошибка получения соединения из пула", e);
throw new RuntimeException(e); throw new RuntimeException(e);
} }
} }
/**
* Получить соединение
*/
public Connection getConnection() {
try {
if (connection == null || connection.isClosed()) {
connect();
}
return connection;
} catch (SQLException e) {
logger.error("Ошибка проверки соединения", e);
connect();
return connection;
}
}
/**
* Выполнить SQL запрос
*/
public void execute(String sql) { public void execute(String sql) {
try (Statement stmt = getConnection().createStatement()) { try (Connection conn = getConnection(); Statement stmt = conn.createStatement()) {
stmt.execute(sql); stmt.execute(sql);
} catch (SQLException e) { } catch (SQLException e) {
logger.error("Ошибка выполнения SQL: {}", sql, e); logger.error("Ошибка выполнения SQL: {}", sql, e);
@ -64,29 +57,34 @@ public class Database {
} }
} }
/**
* Выполнить SQL запрос с параметрами
*/
public PreparedStatement prepare(String sql) { public PreparedStatement prepare(String sql) {
try { try {
return getConnection().prepareStatement(sql); Connection conn = getConnection();
PreparedStatement stmt = conn.prepareStatement(sql);
return (PreparedStatement) Proxy.newProxyInstance(
PreparedStatement.class.getClassLoader(),
new Class<?>[]{PreparedStatement.class},
(proxy, method, args) -> {
if (method.getName().equals("close")) {
try {
return method.invoke(stmt, args);
} finally {
conn.close();
}
}
return method.invoke(stmt, args);
}
);
} catch (SQLException e) { } catch (SQLException e) {
logger.error("Ошибка подготовки SQL: {}", sql, e); logger.error("Ошибка подготовки SQL: {}", sql, e);
throw new RuntimeException(e); throw new RuntimeException(e);
} }
} }
/**
* Закрыть соединение
*/
public void close() { public void close() {
try { if (dataSource != null && !dataSource.isClosed()) {
if (connection != null && !connection.isClosed()) { dataSource.close();
connection.close(); logger.info("Пул соединений закрыт");
logger.info("Соединение с базой данных закрыто");
}
} catch (SQLException e) {
logger.error("Ошибка закрытия соединения", e);
} }
} }
} }

View file

@ -1,191 +1,190 @@
package creator.loki.database; package creator.loki.database;
import com.google.gson.Gson; import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken; import com.google.gson.reflect.TypeToken;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.io.FileReader; import java.io.FileReader;
import java.lang.reflect.Type; import java.lang.reflect.Type;
import java.sql.PreparedStatement; import java.sql.PreparedStatement;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.util.Map; import java.util.Map;
/** /**
* Миграция данных из JSON в SQLite * Миграция данных из JSON в SQLite
*/ */
public class DatabaseMigration { public class DatabaseMigration {
private static final Logger logger = LoggerFactory.getLogger(DatabaseMigration.class); private static final Logger logger = LoggerFactory.getLogger(DatabaseMigration.class);
private final Database database; private final Database database;
private final Gson gson = new Gson(); private final Gson gson = new Gson();
public DatabaseMigration(Database database) { public DatabaseMigration(Database database) {
this.database = database; this.database = database;
} }
/** /**
* Мигрировать данные из JSON * Мигрировать данные из JSON
*/ */
public void migrateFromJson(String jsonPath) { public void migrateFromJson(String jsonPath) {
logger.info("Начало миграции данных из JSON: {}", jsonPath); logger.info("Начало миграции данных из JSON: {}", jsonPath);
try (FileReader reader = new FileReader(jsonPath)) { try (FileReader reader = new FileReader(jsonPath)) {
Type type = new TypeToken<Map<String, Object>>(){}.getType(); Type type = new TypeToken<Map<String, Object>>(){}.getType();
Map<String, Object> data = gson.fromJson(reader, type); Map<String, Object> data = gson.fromJson(reader, type);
if (data == null) { if (data == null) {
logger.warn("JSON файл пустой или не существует"); logger.warn("JSON файл пустой или не существует");
return; return;
} }
if (isMigrated()) { if (isMigrated()) {
logger.info("Данные уже мигрированы, пропускаем"); logger.info("Данные уже мигрированы, пропускаем");
return; return;
} }
migrateUserStats(data); migrateUserStats(data);
migrateReputation(data); migrateReputation(data);
migrateShipPairs(data); migrateShipPairs(data);
migrateCustomRoles(data); migrateCustomRoles(data);
markMigrated(); markMigrated();
logger.info("Миграция завершена успешно"); logger.info("Миграция завершена успешно");
} catch (Exception e) { } catch (Exception e) {
logger.error("Ошибка миграции данных", e); logger.error("Ошибка миграции данных", e);
} }
} }
private boolean isMigrated() { private boolean isMigrated() {
try (PreparedStatement stmt = database.prepare("SELECT value FROM config WHERE key = 'migrated'")) { try (PreparedStatement stmt = database.prepare("SELECT value FROM config WHERE key = 'migrated'")) {
ResultSet rs = stmt.executeQuery(); ResultSet rs = stmt.executeQuery();
return rs.next() && "true".equals(rs.getString("value")); return rs.next() && "true".equals(rs.getString("value"));
} catch (SQLException e) { } catch (SQLException e) {
return false; return false;
} }
} }
private void markMigrated() { private void markMigrated() {
try (PreparedStatement stmt = database.prepare( try (PreparedStatement stmt = database.prepare(
"INSERT OR REPLACE INTO config (key, value) VALUES ('migrated', 'true')" "INSERT OR REPLACE INTO config (key, value) VALUES ('migrated', 'true')"
)) { )) {
stmt.executeUpdate(); stmt.executeUpdate();
} catch (SQLException e) { } catch (SQLException e) {
logger.error("Ошибка сохранения статуса миграции", e); logger.error("Ошибка сохранения статуса миграции", e);
} }
} }
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private void migrateUserStats(Map<String, Object> data) { private void migrateUserStats(Map<String, Object> data) {
Map<String, Object> userStats = (Map<String, Object>) data.get("userStats"); Map<String, Object> userStats = (Map<String, Object>) data.get("userStats");
if (userStats == null) return; if (userStats == null) return;
logger.info("Миграция userStats: {} записей", userStats.size()); logger.info("Миграция userStats: {} записей", userStats.size());
String sql = """ String sql = """
INSERT OR REPLACE INTO user_stats INSERT OR REPLACE INTO user_stats
(user_id, level, total_xp, message_count, voice_minutes, last_message_time, last_voice_join) (user_id, level, total_xp, message_count, voice_minutes, last_message_time, last_voice_join)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)""";
""";
try (PreparedStatement stmt = database.prepare(sql)) {
try (PreparedStatement stmt = database.prepare(sql)) { for (Map.Entry<String, Object> entry : userStats.entrySet()) {
for (Map.Entry<String, Object> entry : userStats.entrySet()) { Map<String, Object> stats = (Map<String, Object>) entry.getValue();
Map<String, Object> stats = (Map<String, Object>) entry.getValue();
stmt.setString(1, (String) stats.get("userId"));
stmt.setString(1, (String) stats.get("userId")); stmt.setInt(2, ((Number) stats.get("level")).intValue());
stmt.setInt(2, ((Number) stats.get("level")).intValue()); stmt.setInt(3, ((Number) stats.get("totalXP")).intValue());
stmt.setInt(3, ((Number) stats.get("totalXP")).intValue()); stmt.setInt(4, ((Number) stats.get("messageCount")).intValue());
stmt.setInt(4, ((Number) stats.get("messageCount")).intValue()); stmt.setLong(5, ((Number) stats.get("voiceMinutes")).longValue());
stmt.setLong(5, ((Number) stats.get("voiceMinutes")).longValue()); stmt.setLong(6, ((Number) stats.get("lastMessageTime")).longValue());
stmt.setLong(6, ((Number) stats.get("lastMessageTime")).longValue()); stmt.setLong(7, ((Number) stats.get("lastVoiceJoin")).longValue());
stmt.setLong(7, ((Number) stats.get("lastVoiceJoin")).longValue());
stmt.executeUpdate();
stmt.executeUpdate(); }
} logger.info("userStats мигрированы");
logger.info("userStats мигрированы"); } catch (SQLException e) {
} catch (SQLException e) { logger.error("Ошибка миграции userStats", e);
logger.error("Ошибка миграции userStats", e); }
} }
}
@SuppressWarnings("unchecked")
@SuppressWarnings("unchecked") private void migrateReputation(Map<String, Object> data) {
private void migrateReputation(Map<String, Object> data) { Map<String, Object> reputation = (Map<String, Object>) data.get("reputation");
Map<String, Object> reputation = (Map<String, Object>) data.get("reputation"); if (reputation == null) return;
if (reputation == null) return;
logger.info("Миграция reputation: {} записей", reputation.size());
logger.info("Миграция reputation: {} записей", reputation.size());
try (PreparedStatement stmt = database.prepare(
try (PreparedStatement stmt = database.prepare( "INSERT OR REPLACE INTO reputation (user_id, reputation) VALUES (?, ?)"
"INSERT OR REPLACE INTO reputation (user_id, reputation) VALUES (?, ?)" )) {
)) { for (Map.Entry<String, Object> entry : reputation.entrySet()) {
for (Map.Entry<String, Object> entry : reputation.entrySet()) { stmt.setString(1, entry.getKey());
stmt.setString(1, entry.getKey()); stmt.setDouble(2, ((Number) entry.getValue()).doubleValue());
stmt.setDouble(2, ((Number) entry.getValue()).doubleValue()); stmt.executeUpdate();
stmt.executeUpdate(); }
} logger.info("reputation мигрирована");
logger.info("reputation мигрирована"); } catch (SQLException e) {
} catch (SQLException e) { logger.error("Ошибка миграции reputation", e);
logger.error("Ошибка миграции reputation", e); }
} }
}
@SuppressWarnings("unchecked")
@SuppressWarnings("unchecked") private void migrateShipPairs(Map<String, Object> data) {
private void migrateShipPairs(Map<String, Object> data) { Map<String, Object> shipPairs = (Map<String, Object>) data.get("shipPairs");
Map<String, Object> shipPairs = (Map<String, Object>) data.get("shipPairs"); if (shipPairs == null) return;
if (shipPairs == null) return;
logger.info("Миграция shipPairs: {} записей", shipPairs.size());
logger.info("Миграция shipPairs: {} записей", shipPairs.size());
try (PreparedStatement stmt = database.prepare(
try (PreparedStatement stmt = database.prepare( "INSERT OR REPLACE INTO ship_pairs (user1_id, user2_id, compatibility) VALUES (?, ?, ?)"
"INSERT OR REPLACE INTO ship_pairs (user1_id, user2_id, compatibility) VALUES (?, ?, ?)" )) {
)) { for (Map.Entry<String, Object> entry : shipPairs.entrySet()) {
for (Map.Entry<String, Object> entry : shipPairs.entrySet()) { String[] users = entry.getKey().split("-");
String[] users = entry.getKey().split("-"); if (users.length == 2) {
if (users.length == 2) { stmt.setString(1, users[0]);
stmt.setString(1, users[0]); stmt.setString(2, users[1]);
stmt.setString(2, users[1]); stmt.setDouble(3, ((Number) entry.getValue()).doubleValue());
stmt.setDouble(3, ((Number) entry.getValue()).doubleValue()); stmt.executeUpdate();
stmt.executeUpdate(); }
} }
} logger.info("shipPairs мигрированы");
logger.info("shipPairs мигрированы"); } catch (SQLException e) {
} catch (SQLException e) { logger.error("Ошибка миграции shipPairs", e);
logger.error("Ошибка миграции shipPairs", e); }
} }
}
@SuppressWarnings("unchecked")
@SuppressWarnings("unchecked") private void migrateCustomRoles(Map<String, Object> data) {
private void migrateCustomRoles(Map<String, Object> data) { Map<String, Object> customRoles = (Map<String, Object>) data.get("custom_roles");
Map<String, Object> customRoles = (Map<String, Object>) data.get("custom_roles"); if (customRoles == null) return;
if (customRoles == null) return;
logger.info("Миграция custom_roles");
logger.info("Миграция custom_roles");
try (PreparedStatement stmt = database.prepare(
try (PreparedStatement stmt = database.prepare( "INSERT OR REPLACE INTO custom_roles (guild_id, user_id, role_id) VALUES (?, ?, ?)"
"INSERT OR REPLACE INTO custom_roles (guild_id, user_id, role_id) VALUES (?, ?, ?)" )) {
)) { for (Map.Entry<String, Object> guildEntry : customRoles.entrySet()) {
for (Map.Entry<String, Object> guildEntry : customRoles.entrySet()) { String guildId = guildEntry.getKey();
String guildId = guildEntry.getKey(); Map<String, Object> users = (Map<String, Object>) guildEntry.getValue();
Map<String, Object> users = (Map<String, Object>) guildEntry.getValue();
for (Map.Entry<String, Object> userEntry : users.entrySet()) {
for (Map.Entry<String, Object> userEntry : users.entrySet()) { String userId = userEntry.getKey();
String userId = userEntry.getKey(); Object rolesObj = userEntry.getValue();
Object rolesObj = userEntry.getValue();
if (rolesObj instanceof java.util.List<?> rolesList) {
if (rolesObj instanceof java.util.List) { for (Object roleId : rolesList) {
for (Object roleId : (java.util.List<?>) rolesObj) { stmt.setString(1, guildId);
stmt.setString(1, guildId); stmt.setString(2, userId);
stmt.setString(2, userId); stmt.setString(3, roleId.toString());
stmt.setString(3, roleId.toString()); stmt.executeUpdate();
stmt.executeUpdate(); }
} }
} }
} }
} logger.info("custom_roles мигрированы");
logger.info("custom_roles мигрированы"); } catch (SQLException e) {
} catch (SQLException e) { logger.error("Ошибка миграции custom_roles", e);
logger.error("Ошибка миграции custom_roles", e); }
} }
} }
}

View file

@ -1,112 +1,249 @@
package creator.loki.database; package creator.loki.database;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
/** /**
* Схема базы данных - создание таблиц * Схема базы данных - создание таблиц
*/ */
public class DatabaseSchema { public class DatabaseSchema {
private static final Logger logger = LoggerFactory.getLogger(DatabaseSchema.class); private static final Logger logger = LoggerFactory.getLogger(DatabaseSchema.class);
private final Database database; private final Database database;
public DatabaseSchema(Database database) { public DatabaseSchema(Database database) {
this.database = database; this.database = database;
} }
/** /**
* Создать все таблицы * Создать все таблицы
*/ */
public void createTables() { public void createTables() {
logger.info("Создание таблиц базы данных..."); logger.info("Создание таблиц базы данных...");
createConfigTable(); createConfigTable();
createUserStatsTable(); createUserStatsTable();
createReputationTable(); createReputationTable();
createShipPairsTable(); createShipPairsTable();
createCustomRolesTable(); createCustomRolesTable();
createVoiceChannelsTable(); createVoiceChannelsTable();
createCounterStatsTable(); createVoicePresetsTable();
createCounterStatsTable();
logger.info("Таблицы созданы успешно"); createRatingsTable();
} createWarningsTable();
createModerationCasesTable();
private void createConfigTable() { createActiveMutesTable();
database.execute(""" createEconomyTable();
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY, createIndexes();
value TEXT NOT NULL
) logger.info("Таблицы созданы успешно");
"""); }
}
private void createConfigTable() {
private void createUserStatsTable() { database.execute("""
database.execute(""" CREATE TABLE IF NOT EXISTS config (
CREATE TABLE IF NOT EXISTS user_stats ( key TEXT PRIMARY KEY,
user_id TEXT PRIMARY KEY, value TEXT NOT NULL
level INTEGER DEFAULT 0, )
total_xp INTEGER DEFAULT 0, """);
message_count INTEGER DEFAULT 0, }
voice_minutes INTEGER DEFAULT 0,
last_message_time INTEGER DEFAULT 0, private void createUserStatsTable() {
last_voice_join INTEGER DEFAULT 0 database.execute("""
) CREATE TABLE IF NOT EXISTS user_stats (
"""); user_id TEXT PRIMARY KEY,
} level INTEGER DEFAULT 0,
total_xp INTEGER DEFAULT 0,
private void createReputationTable() { message_count INTEGER DEFAULT 0,
database.execute(""" voice_minutes INTEGER DEFAULT 0,
CREATE TABLE IF NOT EXISTS reputation ( last_message_time INTEGER DEFAULT 0,
user_id TEXT PRIMARY KEY, last_voice_join INTEGER DEFAULT 0
reputation REAL DEFAULT 0.0 )
) """);
"""); }
}
private void createReputationTable() {
private void createShipPairsTable() { database.execute("""
database.execute(""" CREATE TABLE IF NOT EXISTS reputation (
CREATE TABLE IF NOT EXISTS ship_pairs ( user_id TEXT PRIMARY KEY,
user1_id TEXT NOT NULL, reputation REAL DEFAULT 0.0
user2_id TEXT NOT NULL, )
compatibility REAL NOT NULL, """);
PRIMARY KEY (user1_id, user2_id) }
)
"""); private void createShipPairsTable() {
} database.execute("""
CREATE TABLE IF NOT EXISTS ship_pairs (
private void createCustomRolesTable() { user1_id TEXT NOT NULL,
database.execute(""" user2_id TEXT NOT NULL,
CREATE TABLE IF NOT EXISTS custom_roles ( compatibility REAL NOT NULL,
guild_id TEXT NOT NULL, PRIMARY KEY (user1_id, user2_id)
user_id TEXT NOT NULL, )
role_id TEXT NOT NULL, """);
PRIMARY KEY (guild_id, user_id, role_id) }
)
"""); private void createCustomRolesTable() {
} database.execute("""
CREATE TABLE IF NOT EXISTS custom_roles (
private void createVoiceChannelsTable() { guild_id TEXT NOT NULL,
database.execute(""" user_id TEXT NOT NULL,
CREATE TABLE IF NOT EXISTS voice_channels ( role_id TEXT NOT NULL,
channel_id TEXT PRIMARY KEY, PRIMARY KEY (guild_id, user_id, role_id)
owner_id TEXT NOT NULL, )
created_at INTEGER NOT NULL, """);
name TEXT, }
user_limit INTEGER DEFAULT 0,
bitrate INTEGER DEFAULT 64000, private void createVoiceChannelsTable() {
locked INTEGER DEFAULT 0 database.execute("""
) CREATE TABLE IF NOT EXISTS voice_channels (
"""); channel_id TEXT PRIMARY KEY,
} owner_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
private void createCounterStatsTable() { name TEXT,
database.execute(""" user_limit INTEGER DEFAULT 0,
CREATE TABLE IF NOT EXISTS counter_stats ( bitrate INTEGER DEFAULT 64000,
date TEXT PRIMARY KEY, locked INTEGER DEFAULT 0,
joined INTEGER DEFAULT 0, text_channel_id TEXT
left_count INTEGER DEFAULT 0, )
difference INTEGER DEFAULT 0 """);
) }
""");
} private void createVoicePresetsTable() {
} database.execute("""
CREATE TABLE IF NOT EXISTS voice_presets (
user_id TEXT PRIMARY KEY,
name TEXT,
user_limit INTEGER DEFAULT 0,
bitrate INTEGER DEFAULT 64000,
region TEXT DEFAULT 'auto',
locked INTEGER DEFAULT 0,
allowed_role_id TEXT,
denied_role_id TEXT
)
""");
}
private void createCounterStatsTable() {
database.execute("""
CREATE TABLE IF NOT EXISTS counter_stats (
date TEXT PRIMARY KEY,
joined INTEGER DEFAULT 0,
left_count INTEGER DEFAULT 0,
difference INTEGER DEFAULT 0
)
""");
}
private void createRatingsTable() {
database.execute("""
CREATE TABLE IF NOT EXISTS ratings (
guild_id TEXT NOT NULL,
user_id TEXT NOT NULL,
rating_type TEXT NOT NULL,
value INTEGER NOT NULL,
PRIMARY KEY (guild_id, user_id, rating_type)
)
""");
}
private void createWarningsTable() {
database.execute("""
CREATE TABLE IF NOT EXISTS warnings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guild_id TEXT NOT NULL,
user_id TEXT NOT NULL,
moderator_id TEXT NOT NULL,
reason TEXT NOT NULL,
created_at INTEGER NOT NULL
)
""");
}
private void createModerationCasesTable() {
database.execute("""
CREATE TABLE IF NOT EXISTS moderation_cases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guild_id TEXT NOT NULL,
action TEXT NOT NULL,
target_id TEXT NOT NULL,
moderator_id TEXT NOT NULL,
reason TEXT NOT NULL,
created_at INTEGER NOT NULL,
duration_sec INTEGER,
until_ms INTEGER
)
""");
}
private void createActiveMutesTable() {
database.execute("""
CREATE TABLE IF NOT EXISTS active_mutes (
guild_id TEXT NOT NULL,
user_id TEXT NOT NULL,
ends_at_ms INTEGER NOT NULL,
case_id INTEGER NOT NULL,
PRIMARY KEY (guild_id, user_id)
)
""");
}
private void createEconomyTable() {
database.execute("""
CREATE TABLE IF NOT EXISTS economy (
user_id TEXT PRIMARY KEY,
cash INTEGER NOT NULL DEFAULT 0,
bank INTEGER NOT NULL DEFAULT 0,
total_earned INTEGER NOT NULL DEFAULT 0,
total_spent INTEGER NOT NULL DEFAULT 0,
last_daily TEXT,
daily_streak INTEGER DEFAULT 0,
fishing_luck INTEGER DEFAULT 0,
efficiency INTEGER DEFAULT 0,
last_fish TEXT,
job TEXT,
last_work TEXT
)
""");
}
/**
* Создать индексы для ускорения запросов
*/
private void createIndexes() {
logger.info("Создание индексов...");
// Индексы для user_stats
database.execute("CREATE INDEX IF NOT EXISTS idx_user_stats_level ON user_stats(level DESC)");
database.execute("CREATE INDEX IF NOT EXISTS idx_user_stats_total_xp ON user_stats(total_xp DESC)");
database.execute("CREATE INDEX IF NOT EXISTS idx_user_stats_message_count ON user_stats(message_count DESC)");
database.execute("CREATE INDEX IF NOT EXISTS idx_user_stats_voice_minutes ON user_stats(voice_minutes DESC)");
// Индексы для reputation
database.execute("CREATE INDEX IF NOT EXISTS idx_reputation_value ON reputation(reputation DESC)");
// Индексы для ratings
database.execute("CREATE INDEX IF NOT EXISTS idx_ratings_guild_type ON ratings(guild_id, rating_type)");
database.execute("CREATE INDEX IF NOT EXISTS idx_ratings_value ON ratings(value DESC)");
// Индексы для voice_channels
database.execute("CREATE INDEX IF NOT EXISTS idx_voice_channels_owner ON voice_channels(owner_id)");
database.execute("CREATE INDEX IF NOT EXISTS idx_voice_channels_created ON voice_channels(created_at)");
// Индексы для custom_roles
database.execute("CREATE INDEX IF NOT EXISTS idx_custom_roles_guild ON custom_roles(guild_id)");
database.execute("CREATE INDEX IF NOT EXISTS idx_custom_roles_user ON custom_roles(user_id)");
// Индексы для counter_stats
database.execute("CREATE INDEX IF NOT EXISTS idx_counter_stats_date ON counter_stats(date DESC)");
database.execute("CREATE INDEX IF NOT EXISTS idx_warnings_guild_user ON warnings(guild_id, user_id)");
database.execute("CREATE INDEX IF NOT EXISTS idx_warnings_guild_time ON warnings(guild_id, created_at DESC)");
database.execute("CREATE INDEX IF NOT EXISTS idx_mod_cases_guild_id ON moderation_cases(guild_id, id DESC)");
database.execute("CREATE INDEX IF NOT EXISTS idx_active_mutes_ends ON active_mutes(ends_at_ms)");
database.execute("CREATE INDEX IF NOT EXISTS idx_economy_balance ON economy((cash + bank) DESC)");
logger.info("Индексы созданы успешно");
}
}

View file

@ -3,46 +3,78 @@ package creator.loki.di;
import com.google.inject.AbstractModule; import com.google.inject.AbstractModule;
import com.google.inject.Provides; import com.google.inject.Provides;
import com.google.inject.Singleton; import com.google.inject.Singleton;
import com.google.inject.multibindings.Multibinder;
import creator.loki.config.BotConfig; import creator.loki.config.BotConfig;
import creator.loki.config.ConfigLoader; import creator.loki.config.ConfigLoader;
import creator.loki.core.EventDispatcher; import creator.loki.core.EventDispatcher;
import creator.loki.core.ListenerAggregator;
import creator.loki.core.MessageCache;
import creator.loki.core.ShutdownManager;
import creator.loki.core.registry.CommandRegistrar;
import creator.loki.core.registry.CommandRegistry; import creator.loki.core.registry.CommandRegistry;
import creator.loki.core.registry.SlashCommandRegistry; import creator.loki.core.registry.PrefixCommandRegistrar;
import creator.loki.core.security.CooldownManager; import creator.loki.core.security.CooldownManager;
import creator.loki.core.security.RateLimiter; import creator.loki.core.security.RateLimiter;
import creator.loki.feature.counter.CounterManager;
import creator.loki.data.JsonDataStore;
import creator.loki.database.ConfigRepository;
import creator.loki.database.Database; import creator.loki.database.Database;
import creator.loki.database.DatabaseMigration; import creator.loki.database.DatabaseMigration;
import creator.loki.database.DatabaseSchema; import creator.loki.database.DatabaseSchema;
import creator.loki.feature.level.domain.XpFormula; import creator.loki.feature.config.repository.ConfigRepository;
import creator.loki.feature.level.LevelUpNotifier; import creator.loki.feature.config.repository.PrefixedGuildConfigRepository;
import creator.loki.feature.level.service.LeaderboardService; import creator.loki.feature.rating.repository.RatingsRepository;
import creator.loki.feature.level.service.LevelService; import creator.loki.feature.reputation.repository.ReputationRepository;
import creator.loki.feature.level.service.MessageCooldownService; import creator.loki.feature.ship.repository.ShipPairRepository;
import creator.loki.feature.level.VoiceTracker; import creator.loki.feature.counter.CounterModule;
import creator.loki.level.LevelSystem; import creator.loki.feature.entertainment.EntertainmentCommandRegistrar;
import creator.loki.metrics.MetricsCollector; import creator.loki.feature.level.LevelCommandRegistrar;
import creator.loki.profile.ProfileService; import creator.loki.feature.level.LevelModule;
import creator.loki.repository.UserStatsRepository; import creator.loki.feature.metrics.MetricsCollector;
import creator.loki.repository.impl.JsonUserStatsRepository; import creator.loki.feature.metrics.HealthHttpServer;
import creator.loki.feature.role.CustomRoleManager; import creator.loki.feature.metrics.MetricsModule;
import creator.loki.feature.voice.VoiceChannelManager; import creator.loki.feature.moderation.ModerationCommandRegistrar;
import creator.loki.feature.moderation.ModerationModule;
import creator.loki.feature.profile.ProfileModule;
import creator.loki.feature.role.RoleModule;
import creator.loki.feature.utility.UtilityCommandRegistrar;
import creator.loki.feature.music.MusicModule;
import creator.loki.feature.music.MusicCommandRegistrar;
import creator.loki.feature.voice.VoiceModule;
import creator.loki.feature.welcome.WelcomeModule;
import creator.loki.feature.config.repository.GuildConfigRepository;
import creator.loki.feature.economy.EconomyCommandRegistrar;
import creator.loki.feature.economy.EconomyModule;
/**
* Главный модуль Guice для конфигурации Dependency Injection
*/
public class BotModule extends AbstractModule { public class BotModule extends AbstractModule {
@Override @Override
protected void configure() { protected void configure() {
// Все основные компоненты - Singleton
bind(CommandRegistry.class).in(Singleton.class); bind(CommandRegistry.class).in(Singleton.class);
bind(SlashCommandRegistry.class).in(Singleton.class);
bind(CooldownManager.class).in(Singleton.class); bind(CooldownManager.class).in(Singleton.class);
bind(RateLimiter.class).in(Singleton.class); bind(RateLimiter.class).in(Singleton.class);
bind(MetricsCollector.class).in(Singleton.class); bind(MetricsCollector.class).in(Singleton.class);
bind(HealthHttpServer.class).in(Singleton.class);
bind(ListenerAggregator.class).in(Singleton.class);
bind(MessageCache.class).in(Singleton.class);
bind(ShutdownManager.class).in(Singleton.class);
bind(PrefixCommandRegistrar.class).in(Singleton.class);
install(new VoiceModule());
install(new MusicModule());
install(new LevelModule());
install(new ModerationModule());
install(new RoleModule());
install(new CounterModule());
install(new ProfileModule());
install(new MetricsModule());
install(new WelcomeModule());
install(new EconomyModule());
Multibinder<CommandRegistrar> commandBinder = Multibinder.newSetBinder(binder(), CommandRegistrar.class);
commandBinder.addBinding().to(LevelCommandRegistrar.class);
commandBinder.addBinding().to(ModerationCommandRegistrar.class);
commandBinder.addBinding().to(EntertainmentCommandRegistrar.class);
commandBinder.addBinding().to(UtilityCommandRegistrar.class);
commandBinder.addBinding().to(MusicCommandRegistrar.class);
commandBinder.addBinding().to(EconomyCommandRegistrar.class);
} }
@Provides @Provides
@ -51,38 +83,6 @@ public class BotModule extends AbstractModule {
return ConfigLoader.load(); return ConfigLoader.load();
} }
@Provides
@Singleton
JsonDataStore provideJsonDataStore() {
JsonDataStore dataStore = new JsonDataStore("data/bot_data.json");
dataStore.load();
return dataStore;
}
@Provides
@Singleton
CustomRoleManager provideCustomRoleManager(JsonDataStore dataStore) {
return new CustomRoleManager(dataStore);
}
@Provides
@Singleton
LevelSystem provideLevelSystem(JsonDataStore dataStore) {
return new LevelSystem(dataStore);
}
@Provides
@Singleton
VoiceTracker provideVoiceTracker(LevelService levelService) {
return new VoiceTracker(levelService);
}
@Provides
@Singleton
ProfileService provideProfileService(JsonDataStore dataStore) {
return new ProfileService(dataStore);
}
@Provides @Provides
@Singleton @Singleton
EventDispatcher provideEventDispatcher( EventDispatcher provideEventDispatcher(
@ -96,18 +96,13 @@ public class BotModule extends AbstractModule {
@Provides @Provides
@Singleton @Singleton
Database provideDatabase() { Database provideDatabase(BotConfig config) {
Database database = new Database("data/bot.db"); Database database = new Database(config.getDbPath());
database.connect(); database.connect();
// Создаем таблицы
DatabaseSchema schema = new DatabaseSchema(database); DatabaseSchema schema = new DatabaseSchema(database);
schema.createTables(); schema.createTables();
// Мигрируем данные из JSON если нужно
DatabaseMigration migration = new DatabaseMigration(database); DatabaseMigration migration = new DatabaseMigration(database);
migration.migrateFromJson("data/bot_data.json"); migration.migrateFromJson("data/bot_data.json");
return database; return database;
} }
@ -119,69 +114,25 @@ public class BotModule extends AbstractModule {
@Provides @Provides
@Singleton @Singleton
LevelUpNotifier provideLevelUpNotifier(ConfigRepository configRepo, LevelService levelService) { GuildConfigRepository provideGuildConfigRepository(ConfigRepository configRepository) {
return new LevelUpNotifier(configRepo, levelService); return new PrefixedGuildConfigRepository(configRepository);
} }
@Provides @Provides
@Singleton @Singleton
VoiceChannelManager provideVoiceChannelManager(JsonDataStore dataStore, ConfigRepository configRepo) { RatingsRepository provideRatingsRepository(Database database) {
return new VoiceChannelManager(dataStore, configRepo); return new RatingsRepository(database);
} }
@Provides @Provides
@Singleton @Singleton
UserStatsRepository provideUserStatsRepository(JsonDataStore dataStore) { ReputationRepository provideReputationRepository(Database database) {
return new JsonUserStatsRepository(dataStore); return new ReputationRepository(database);
} }
@Provides @Provides
@Singleton @Singleton
CounterManager provideCounterManager(JsonDataStore dataStore, ConfigRepository configRepo) { ShipPairRepository provideShipPairRepository(Database database) {
return new CounterManager(dataStore, configRepo); return new ShipPairRepository(database);
}
@Provides
@Singleton
XpFormula provideXpFormula() {
return new XpFormula();
}
@Provides
@Singleton
MessageCooldownService provideMessageCooldownService(UserStatsRepository repository) {
return new MessageCooldownService(repository);
}
@Provides
@Singleton
LeaderboardService provideLeaderboardService(UserStatsRepository repository) {
return new LeaderboardService(repository);
}
@Provides
@Singleton
LevelService provideLevelService(
UserStatsRepository repository,
XpFormula xpFormula,
MessageCooldownService cooldownService
) {
return new LevelService(repository, xpFormula, cooldownService);
}
@Provides
@Singleton
ProfileService provideOldProfileService(JsonDataStore dataStore) {
return new ProfileService(dataStore);
}
@Provides
@Singleton
creator.loki.feature.profile.ProfileService provideNewProfileService(
UserStatsRepository repository,
LevelService levelService,
LeaderboardService leaderboardService
) {
return new creator.loki.feature.profile.ProfileService(repository, levelService, leaderboardService);
} }
} }

View file

@ -1,79 +1,79 @@
package creator.loki.embed; package creator.loki.embed;
import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.entities.MessageEmbed; import net.dv8tion.jda.api.entities.MessageEmbed;
import java.awt.Color; import java.awt.Color;
import java.time.Instant; import java.time.Instant;
public class EmbedFactory { public class EmbedFactory {
private final EmbedBuilder builder; private final EmbedBuilder builder;
private EmbedFactory() { private EmbedFactory() {
this.builder = new EmbedBuilder(); this.builder = new EmbedBuilder();
} }
public static EmbedFactory create() { public static EmbedFactory create() {
return new EmbedFactory(); return new EmbedFactory();
} }
public EmbedFactory setTitle(String title) { public EmbedFactory withTitle(String title) {
builder.setTitle(title); builder.setTitle(title);
return this; return this;
} }
public EmbedFactory setDescription(String description) { public EmbedFactory withDescription(String description) {
builder.setDescription(description); builder.setDescription(description);
return this; return this;
} }
public EmbedFactory setColor(Color color) { public EmbedFactory withColor(Color color) {
builder.setColor(color); builder.setColor(color);
return this; return this;
} }
public EmbedFactory addField(String name, String value, boolean inline) { public EmbedFactory addField(String name, String value, boolean inline) {
builder.addField(name, value, inline); builder.addField(name, value, inline);
return this; return this;
} }
public EmbedFactory setFooter(String text) { public EmbedFactory withFooter(String text) {
builder.setFooter(text); builder.setFooter(text);
return this; return this;
} }
public EmbedFactory setFooter(String text, String iconUrl) { public EmbedFactory withFooter(String text, String iconUrl) {
builder.setFooter(text, iconUrl); builder.setFooter(text, iconUrl);
return this; return this;
} }
public EmbedFactory setThumbnail(String url) { public EmbedFactory withThumbnail(String url) {
builder.setThumbnail(url); builder.setThumbnail(url);
return this; return this;
} }
public EmbedFactory setImage(String url) { public EmbedFactory withImage(String url) {
builder.setImage(url); builder.setImage(url);
return this; return this;
} }
public EmbedFactory setAuthor(String name) { public EmbedFactory withAuthor(String name) {
builder.setAuthor(name); builder.setAuthor(name);
return this; return this;
} }
public EmbedFactory setAuthor(String name, String url) { public EmbedFactory withAuthor(String name, String url) {
builder.setAuthor(name, url); builder.setAuthor(name, url);
return this; return this;
} }
public EmbedFactory setAuthor(String name, String url, String iconUrl) { public EmbedFactory withAuthor(String name, String url, String iconUrl) {
builder.setAuthor(name, url, iconUrl); builder.setAuthor(name, url, iconUrl);
return this; return this;
} }
public MessageEmbed build() { public MessageEmbed build() {
builder.setTimestamp(Instant.now()); builder.setTimestamp(Instant.now());
return builder.build(); return builder.build();
} }
} }

View file

@ -1,50 +1,50 @@
package creator.loki.embed; package creator.loki.embed;
import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.entities.MessageEmbed; import net.dv8tion.jda.api.entities.MessageEmbed;
import java.awt.Color; import java.awt.Color;
import java.time.Instant; import java.time.Instant;
public final class EmbedTemplates { public final class EmbedTemplates {
private EmbedTemplates() { private EmbedTemplates() {
// Запрет создания экземпляров // Запрет создания экземпляров
} }
public static MessageEmbed success(String title, String description) { public static MessageEmbed success(String title, String description) {
return new EmbedBuilder() return new EmbedBuilder()
.setColor(Color.GREEN) .setColor(Color.GREEN)
.setTitle("✅ " + title) .setTitle("✅ " + title)
.setDescription(description) .setDescription(description)
.setTimestamp(Instant.now()) .setTimestamp(Instant.now())
.build(); .build();
} }
public static MessageEmbed error(String title, String description) { public static MessageEmbed error(String title, String description) {
return new EmbedBuilder() return new EmbedBuilder()
.setColor(Color.RED) .setColor(Color.RED)
.setTitle("❌ " + title) .setTitle("❌ " + title)
.setDescription(description) .setDescription(description)
.setTimestamp(Instant.now()) .setTimestamp(Instant.now())
.build(); .build();
} }
public static MessageEmbed info(String title, String description) { public static MessageEmbed info(String title, String description) {
return new EmbedBuilder() return new EmbedBuilder()
.setColor(Color.CYAN) .setColor(Color.CYAN)
.setTitle("ℹ️ " + title) .setTitle("ℹ️ " + title)
.setDescription(description) .setDescription(description)
.setTimestamp(Instant.now()) .setTimestamp(Instant.now())
.build(); .build();
} }
public static MessageEmbed warning(String title, String description) { public static MessageEmbed warning(String title, String description) {
return new EmbedBuilder() return new EmbedBuilder()
.setColor(Color.ORANGE) .setColor(Color.ORANGE)
.setTitle("⚠️ " + title) .setTitle("⚠️ " + title)
.setDescription(description) .setDescription(description)
.setTimestamp(Instant.now()) .setTimestamp(Instant.now())
.build(); .build();
} }
} }

View file

@ -0,0 +1,75 @@
package creator.loki.embed;
import creator.loki.command.CommandCategories;
import java.awt.Color;
import java.util.Map;
public final class HelpCategories {
private static final Map<String, String> EMOJIS;
private static final Map<String, Color> COLORS;
private static final Map<String, String> DESCRIPTIONS;
static {
EMOJIS = Map.ofEntries(
Map.entry("Общие", "📋"),
Map.entry("Роли", "🎭"),
Map.entry("Развлечения", "🎮"),
Map.entry(CommandCategories.GIF_INTERACTIVE, "💞"),
Map.entry("Модерация", "🛡️"),
Map.entry("Статистика", "📊"),
Map.entry("Утилиты", "🔧"),
Map.entry("Система", "💠"),
Map.entry("Настройка", "⚙️"),
Map.entry("Супер-Админ", "🔐")
);
COLORS = Map.ofEntries(
Map.entry("Общие", new Color(52, 152, 219)),
Map.entry("Роли", new Color(155, 89, 182)),
Map.entry("Развлечения", new Color(241, 196, 15)),
Map.entry(CommandCategories.GIF_INTERACTIVE, new Color(233, 30, 99)),
Map.entry("Модерация", new Color(231, 76, 60)),
Map.entry("Статистика", new Color(46, 204, 113)),
Map.entry("Утилиты", new Color(52, 152, 219)),
Map.entry("Система", new Color(52, 73, 94)),
Map.entry("Настройка", new Color(149, 165, 166)),
Map.entry("Супер-Админ", new Color(113, 54, 138))
);
DESCRIPTIONS = Map.ofEntries(
Map.entry("Общие", "Основные команды бота"),
Map.entry("Роли", "Управление кастомными ролями"),
Map.entry("Развлечения", "Игры, мемы, рейтинги, текст"),
Map.entry(CommandCategories.GIF_INTERACTIVE, "Действия с участником в GIF (nekos.life)"),
Map.entry("Модерация", "Команды модерации сервера"),
Map.entry("Статистика", "Уровни, репутация и статистика"),
Map.entry("Утилиты", "Полезные инструменты"),
Map.entry("Система", "Метрики и служебная информация для админов"),
Map.entry("Настройка", "Настройка систем бота (только админы)"),
Map.entry("Супер-Админ", "Сервисные команды владельца бота")
);
}
private HelpCategories() {
}
public static String getEmoji(String category) {
return EMOJIS.getOrDefault(category, "📌");
}
public static Color getColor(String category) {
return COLORS.getOrDefault(category, new Color(88, 101, 242));
}
public static String getDescription(String category) {
return DESCRIPTIONS.getOrDefault(category, "Команды категории");
}
public static boolean isCategoryVisible(String category, boolean isAdmin, boolean isSuper) {
if ("Настройка".equals(category) && !isAdmin) {
return false;
}
return !"Супер-Админ".equals(category) || isSuper;
}
}

View file

@ -0,0 +1,158 @@
package creator.loki.feature.admin.commands;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.config.BotConfig;
import creator.loki.feature.rating.repository.RatingsRepository;
import creator.loki.embed.EmbedFactory;
import net.dv8tion.jda.api.entities.Member;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.awt.Color;
import java.util.Random;
/**
* Команда для генерации всех rating значений для всех участников сервера
* Доступна только супер-админу
*/
public class GenerateRatingsCommand implements Command {
private static final Logger logger = LoggerFactory.getLogger(GenerateRatingsCommand.class);
private final RatingsRepository ratingsRepository;
private final BotConfig config;
private final Random random = new Random();
public GenerateRatingsCommand(RatingsRepository ratingsRepository, BotConfig config) {
this.ratingsRepository = ratingsRepository;
this.config = config;
}
@Override
public String getName() {
return "generateratings";
}
@Override
public String getDescription() {
return "Генерирует все rating значения для всех участников сервера (только для супер-админа)";
}
@Override
public String getUsage() {
return "generateratings";
}
@Override
public String getCategory() {
return "Супер-Админ";
}
@Override
public void execute(CommandContext context) {
if (!config.getSuperAdminId().equals(context.getAuthor().getUser().getId())) {
context.getChannel().sendMessageEmbeds(
EmbedFactory.create()
.withTitle("❌ Недостаточно прав")
.withDescription("Эта команда доступна только супер-администратору бота.")
.withColor(Color.RED)
.build()
).queue();
return;
}
if (context.getGuild() == null) {
context.getChannel().sendMessage("❌ Команда работает только на сервере.").queue();
return;
}
var initial = context.getChannel().sendMessageEmbeds(
EmbedFactory.create()
.withTitle("⏳ Генерация рейтингов...")
.withDescription("Начинаю генерацию всех rating значений для участников сервера...")
.withColor(Color.YELLOW)
.build()
).complete();
try {
String guildId = context.getGuild().getId();
int memberCount = 0;
int generatedCount = 0;
for (Member member : context.getGuild().getMembers()) {
if (member.getUser().isBot()) {
continue;
}
memberCount++;
String userId = member.getUser().getId();
generateRatingForUser(guildId, userId, "ship");
generateRatingForUser(guildId, userId, "gay");
generateRatingForUser(guildId, userId, "iq");
generateRatingForUser(guildId, userId, "pp");
generateRatingForUser(guildId, userId, "mustache");
generateRatingForUser(guildId, userId, "fat");
generateRatingForUser(guildId, userId, "boykisser");
generatedCount++;
}
initial.editMessageEmbeds(
EmbedFactory.create()
.withTitle("✅ Генерация завершена")
.withDescription(
"Успешно сгенерированы все rating значения!\n\n" +
"**Статистика:**\n" +
"• Обработано участников: **" + memberCount + "**\n" +
"• Сгенерировано значений: **" + (generatedCount * 7) + "**\n" +
"• Типы рейтингов: ship, gay, iq, pp, mustache, fat, boykisser"
)
.withColor(Color.GREEN)
.withFooter("Инициатор: " + context.getAuthor().getUser().getName(), context.getAuthor().getAvatarUrl())
.build()
).queue();
logger.info("Супер-админ {} сгенерировал рейтинги для {} участников",
context.getAuthor().getUser().getName(), memberCount);
} catch (Exception e) {
logger.error("Ошибка при генерации рейтингов", e);
initial.editMessageEmbeds(
EmbedFactory.create()
.withTitle("❌ Ошибка")
.withDescription("Произошла ошибка при генерации рейтингов: " + e.getMessage())
.withColor(Color.RED)
.build()
).queue();
}
}
/**
* Генерирует rating значение для пользователя
*/
private void generateRatingForUser(String guildId, String userId, String ratingType) {
// Проверяем существует ли уже значение
if (ratingsRepository.hasRating(guildId, userId, ratingType)) {
return; // Не перезаписываем существующие значения
}
// Генерируем значение в зависимости от типа
int value = switch (ratingType) {
case "iq" -> random.nextInt(201); // 0-200
case "pp" -> random.nextInt(31); // 0-30 см
case "mustache" -> {
// Редкий шанс на легендарные усы
if (random.nextInt(100) < 1) {
yield 1000 + random.nextInt(500); // 1000-1500
}
yield random.nextInt(101); // 0-100
}
case "fat" -> random.nextInt(101); // 0-100%
case "boykisser" -> random.nextInt(101); // 0-100%
default -> random.nextInt(101); // ship, gay: 0-100%
};
// Сохраняем значение в БД
ratingsRepository.setRating(guildId, userId, ratingType, value);
}
}

View file

@ -1,5 +1,7 @@
package creator.loki.database; package creator.loki.feature.config.repository;
import creator.loki.database.Database;
import creator.loki.feature.config.repository.StringConfigStore;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -10,7 +12,7 @@ import java.sql.SQLException;
/** /**
* Репозиторий для работы с конфигурацией * Репозиторий для работы с конфигурацией
*/ */
public class ConfigRepository { public class ConfigRepository implements StringConfigStore {
private static final Logger logger = LoggerFactory.getLogger(ConfigRepository.class); private static final Logger logger = LoggerFactory.getLogger(ConfigRepository.class);
private final Database database; private final Database database;
@ -25,7 +27,7 @@ public class ConfigRepository {
try (PreparedStatement stmt = database.prepare("SELECT value FROM config WHERE key = ?")) { try (PreparedStatement stmt = database.prepare("SELECT value FROM config WHERE key = ?")) {
stmt.setString(1, key); stmt.setString(1, key);
ResultSet rs = stmt.executeQuery(); ResultSet rs = stmt.executeQuery();
if (rs.next()) { if (rs.next()) {
return rs.getString("value"); return rs.getString("value");
} }

View file

@ -0,0 +1,16 @@
package creator.loki.feature.config.repository;
/**
* Ключи конфигурации с привязкой к гильдии (хранение: {@code guildId + ":" + key} в общей таблице config).
*/
public interface GuildConfigRepository {
/**
* @return значение или {@code null}, если нет записи
*/
String get(String guildId, String key);
void set(String guildId, String key, String value);
void delete(String guildId, String key);
}

View file

@ -0,0 +1,35 @@
package creator.loki.feature.config.repository;
import creator.loki.feature.config.repository.GuildConfigRepository;
import creator.loki.feature.config.repository.StringConfigStore;
/**
* Реализация {@link GuildConfigRepository} поверх {@link StringConfigStore} с префиксом {@code guildId:}.
*/
public final class PrefixedGuildConfigRepository implements GuildConfigRepository {
private final StringConfigStore store;
public PrefixedGuildConfigRepository(StringConfigStore store) {
this.store = store;
}
private static String storageKey(String guildId, String key) {
return guildId + ":" + key;
}
@Override
public String get(String guildId, String key) {
return store.get(storageKey(guildId, key));
}
@Override
public void set(String guildId, String key, String value) {
store.set(storageKey(guildId, key), value);
}
@Override
public void delete(String guildId, String key) {
store.delete(storageKey(guildId, key));
}
}

View file

@ -0,0 +1,16 @@
package creator.loki.feature.config.repository;
/**
* Плоское хранилище строковых ключей (глобальная таблица config и т.п.).
*/
public interface StringConfigStore {
/**
* @return значение или {@code null}
*/
String get(String key);
void set(String key, String value);
void delete(String key);
}

View file

@ -1,226 +1,110 @@
package creator.loki.feature.counter; package creator.loki.feature.counter;
import com.google.gson.reflect.TypeToken; import creator.loki.feature.config.repository.ConfigRepository;
import creator.loki.data.JsonDataStore; import creator.loki.feature.counter.repository.CounterStatsRepository;
import creator.loki.database.ConfigRepository; import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; import org.slf4j.Logger;
import org.slf4j.Logger; import org.slf4j.LoggerFactory;
import org.slf4j.LoggerFactory;
import java.util.*;
import java.lang.reflect.Type;
import java.time.LocalDate; /**
import java.util.*; * Менеджер счетчика участников
*/
/** public class CounterManager {
* Менеджер счетчика участников private static final Logger logger = LoggerFactory.getLogger(CounterManager.class);
*/ private final CounterStatsRepository statsRepo;
public class CounterManager { private final ConfigRepository configRepo;
private static final Logger logger = LoggerFactory.getLogger(CounterManager.class);
private final JsonDataStore dataStore; private String counterChannelId;
private final ConfigRepository configRepo; private String format = "【👥】Участники: {count}";
private String counterChannelId; public CounterManager(CounterStatsRepository statsRepo, ConfigRepository configRepo) {
private String format = "【👥】Участники: {count}"; this.statsRepo = statsRepo;
this.configRepo = configRepo;
public CounterManager(JsonDataStore dataStore, ConfigRepository configRepo) { loadConfig();
this.dataStore = dataStore; }
this.configRepo = configRepo;
loadConfig(); /**
} * Загрузить конфигурацию
*/
/** private void loadConfig() {
* Загрузить конфигурацию this.counterChannelId = configRepo.get("counter.channel_id");
*/ String savedFormat = configRepo.get("counter.format");
private void loadConfig() { if (savedFormat != null) {
this.counterChannelId = configRepo.get("counter.channel_id"); this.format = savedFormat;
String savedFormat = configRepo.get("counter.format"); }
if (savedFormat != null) { }
this.format = savedFormat;
} /**
} * Получить текущую дневную статистику
*/
/** public DailyStats getDailyStats() {
* Получить данные счетчика return statsRepo.getTodayStats();
*/ }
@SuppressWarnings("unchecked")
private Map<String, Object> getCounterData() { /**
Type type = new TypeToken<Map<String, Object>>(){}.getType(); * Сохранить статистику
Map<String, Object> data = dataStore.getData("counter_data", type); */
return data != null ? data : new HashMap<>(); private void saveDailyStats(DailyStats stats) {
} statsRepo.saveStats(stats);
}
/**
* Сохранить данные счетчика /**
*/ * Зарегистрировать присоединение участника
private void saveCounterData(Map<String, Object> data) { */
dataStore.setData("counter_data", data); public void recordJoin() {
} DailyStats stats = getDailyStats();
stats.incrementJoined();
/** saveDailyStats(stats);
* Установить канал счетчика logger.debug("Зарегистрировано присоединение. Всего за день: {}", stats.getJoined());
*/ }
public void setCounterChannel(String channelId) {
configRepo.set("counter.channel_id", channelId); /**
this.counterChannelId = channelId; * Зарегистрировать выход участника
logger.info("Установлен канал счетчика: {}", channelId); */
} public void recordLeave() {
DailyStats stats = getDailyStats();
/** stats.incrementLeft();
* Установить формат отображения saveDailyStats(stats);
*/ logger.debug("Зарегистрирован выход. Всего за день: {}", stats.getLeft());
public void setFormat(String format) { }
configRepo.set("counter.format", format);
this.format = format; /**
logger.info("Установлен формат: {}", format); * Обновить счетчик участников
} */
public void updateCounter(Guild guild) {
/** if (counterChannelId == null || counterChannelId.isEmpty()) {
* Получить текущую дневную статистику return;
*/ }
@SuppressWarnings("unchecked")
public DailyStats getDailyStats() { VoiceChannel channel = guild.getVoiceChannelById(counterChannelId);
Map<String, Object> data = getCounterData(); if (channel == null) {
Map<String, Object> statsMap = (Map<String, Object>) data.get("daily_stats"); logger.warn("Канал счетчика не найден: {}", counterChannelId);
return;
if (statsMap == null) { }
return new DailyStats();
} int memberCount = guild.getMemberCount();
String newName = format.replace("{count}", String.valueOf(memberCount));
DailyStats stats = new DailyStats();
stats.setDate((String) statsMap.getOrDefault("date", LocalDate.now().toString())); // Обновляем название канала только если оно изменилось
stats.setJoined(((Number) statsMap.getOrDefault("joined", 0)).intValue()); if (!channel.getName().equals(newName)) {
stats.setLeft(((Number) statsMap.getOrDefault("left", 0)).intValue()); channel.getManager().setName(newName).queue(
success -> logger.debug("Обновлен счетчик участников: {}", memberCount),
// Если статистика не сегодняшняя, создаем новую error -> logger.error("Ошибка при обновлении счетчика", error)
if (!stats.isToday()) { );
// Сохраняем старую в историю }
saveToHistory(stats); }
stats = new DailyStats();
} /**
* Получить историю статистики
return stats; */
} public List<DailyStats> getHistory(int days) {
return statsRepo.getHistory(days);
/** }
* Сохранить статистику
*/ // Getters
private void saveDailyStats(DailyStats stats) { public String getCounterChannelId() { return counterChannelId; }
Map<String, Object> data = getCounterData(); public String getFormat() { return format; }
}
Map<String, Object> statsMap = new HashMap<>();
statsMap.put("date", stats.getDate());
statsMap.put("joined", stats.getJoined());
statsMap.put("left", stats.getLeft());
statsMap.put("difference", stats.getDifference());
data.put("daily_stats", statsMap);
saveCounterData(data);
}
/**
* Сохранить статистику в историю
*/
@SuppressWarnings("unchecked")
private void saveToHistory(DailyStats stats) {
Map<String, Object> data = getCounterData();
List<Map<String, Object>> history = (List<Map<String, Object>>) data.get("history");
if (history == null) {
history = new ArrayList<>();
}
Map<String, Object> statsMap = new HashMap<>();
statsMap.put("date", stats.getDate());
statsMap.put("joined", stats.getJoined());
statsMap.put("left", stats.getLeft());
statsMap.put("difference", stats.getDifference());
history.add(statsMap);
// Храним только последние 30 дней
if (history.size() > 30) {
history.remove(0);
}
data.put("history", history);
saveCounterData(data);
}
/**
* Зарегистрировать присоединение участника
*/
public void recordJoin() {
DailyStats stats = getDailyStats();
stats.incrementJoined();
saveDailyStats(stats);
logger.debug("Зарегистрировано присоединение. Всего за день: {}", stats.getJoined());
}
/**
* Зарегистрировать выход участника
*/
public void recordLeave() {
DailyStats stats = getDailyStats();
stats.incrementLeft();
saveDailyStats(stats);
logger.debug("Зарегистрирован выход. Всего за день: {}", stats.getLeft());
}
/**
* Обновить счетчик участников
*/
public void updateCounter(Guild guild) {
if (counterChannelId == null || counterChannelId.isEmpty()) {
return;
}
VoiceChannel channel = guild.getVoiceChannelById(counterChannelId);
if (channel == null) {
logger.warn("Канал счетчика не найден: {}", counterChannelId);
return;
}
int memberCount = guild.getMemberCount();
String newName = format.replace("{count}", String.valueOf(memberCount));
// Обновляем название канала только если оно изменилось
if (!channel.getName().equals(newName)) {
channel.getManager().setName(newName).queue(
success -> logger.debug("Обновлен счетчик участников: {}", memberCount),
error -> logger.error("Ошибка при обновлении счетчика", error)
);
}
}
/**
* Получить историю статистики
*/
@SuppressWarnings("unchecked")
public List<DailyStats> getHistory(int days) {
Map<String, Object> data = getCounterData();
List<Map<String, Object>> historyMaps = (List<Map<String, Object>>) data.get("history");
if (historyMaps == null) {
return new ArrayList<>();
}
List<DailyStats> history = new ArrayList<>();
int start = Math.max(0, historyMaps.size() - days);
for (int i = start; i < historyMaps.size(); i++) {
Map<String, Object> statsMap = historyMaps.get(i);
DailyStats stats = new DailyStats();
stats.setDate((String) statsMap.get("date"));
stats.setJoined(((Number) statsMap.get("joined")).intValue());
stats.setLeft(((Number) statsMap.get("left")).intValue());
history.add(stats);
}
return history;
}
// Getters
public String getCounterChannelId() { return counterChannelId; }
public String getFormat() { return format; }
}

View file

@ -0,0 +1,27 @@
package creator.loki.feature.counter;
import com.google.inject.AbstractModule;
import com.google.inject.Provides;
import com.google.inject.Singleton;
import creator.loki.database.Database;
import creator.loki.feature.config.repository.ConfigRepository;
import creator.loki.feature.counter.repository.CounterStatsRepository;
public class CounterModule extends AbstractModule {
@Override
protected void configure() {
}
@Provides
@Singleton
CounterStatsRepository provideCounterStatsRepository(Database database) {
return new CounterStatsRepository(database);
}
@Provides
@Singleton
CounterManager provideCounterManager(CounterStatsRepository statsRepo, ConfigRepository configRepo) {
return new CounterManager(statsRepo, configRepo);
}
}

View file

@ -1,159 +0,0 @@
package creator.loki.feature.counter;
import creator.loki.database.ConfigRepository;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.Commands;
import net.dv8tion.jda.api.interactions.commands.build.SubcommandData;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.awt.Color;
/**
* Команда настройки счетчика участников
*/
public class CounterSetupCommand extends ListenerAdapter {
private static final Logger logger = LoggerFactory.getLogger(CounterSetupCommand.class);
private final ConfigRepository configRepo;
public CounterSetupCommand(ConfigRepository configRepo) {
this.configRepo = configRepo;
}
@Override
public void onSlashCommandInteraction(SlashCommandInteractionEvent event) {
if (!"counter-setup".equals(event.getName())) {
return;
}
// Проверка прав администратора
Member member = event.getMember();
if (member == null || !member.hasPermission(Permission.ADMINISTRATOR)) {
event.reply("❌ У вас нет прав для использования этой команды!").setEphemeral(true).queue();
return;
}
String subcommand = event.getSubcommandName();
if (subcommand == null) {
return;
}
switch (subcommand) {
case "channel" -> handleChannel(event);
case "format" -> handleFormat(event);
case "info" -> handleInfo(event);
}
}
/**
* Установить канал счетчика
*/
private void handleChannel(SlashCommandInteractionEvent event) {
VoiceChannel channel = event.getOption("channel").getAsChannel().asVoiceChannel();
configRepo.set("counter.channel_id", channel.getId());
// Сразу обновляем название канала
int memberCount = event.getGuild().getMemberCount();
String format = configRepo.get("counter.format");
if (format == null) {
format = "【👥】Участники: {count}";
configRepo.set("counter.format", format);
}
String newName = format.replace("{count}", String.valueOf(memberCount));
channel.getManager().setName(newName).queue();
EmbedBuilder embed = new EmbedBuilder()
.setTitle("✅ Канал счетчика установлен")
.setDescription("Канал " + channel.getAsMention() + " теперь отображает количество участников")
.addField("Текущее количество", String.valueOf(memberCount), false)
.setColor(Color.GREEN);
event.replyEmbeds(embed.build()).queue();
logger.info("Установлен канал счетчика: {} ({})", channel.getName(), channel.getId());
}
/**
* Установить формат отображения
*/
private void handleFormat(SlashCommandInteractionEvent event) {
String format = event.getOption("format").getAsString();
if (!format.contains("{count}")) {
event.reply("❌ Формат должен содержать `{count}`!").setEphemeral(true).queue();
return;
}
configRepo.set("counter.format", format);
// Обновляем канал если он установлен
String channelId = configRepo.get("counter.channel_id");
if (channelId != null) {
VoiceChannel channel = event.getGuild().getVoiceChannelById(channelId);
if (channel != null) {
int memberCount = event.getGuild().getMemberCount();
String newName = format.replace("{count}", String.valueOf(memberCount));
channel.getManager().setName(newName).queue();
}
}
EmbedBuilder embed = new EmbedBuilder()
.setTitle("✅ Формат обновлен")
.setDescription("Новый формат: `" + format + "`")
.addField("Пример", format.replace("{count}", "123"), false)
.setColor(Color.GREEN);
event.replyEmbeds(embed.build()).queue();
logger.info("Установлен формат счетчика: {}", format);
}
/**
* Показать текущую конфигурацию
*/
private void handleInfo(SlashCommandInteractionEvent event) {
String channelId = configRepo.get("counter.channel_id");
String format = configRepo.get("counter.format");
EmbedBuilder embed = new EmbedBuilder()
.setTitle("📊 Конфигурация счетчика участников")
.setColor(Color.BLUE);
if (channelId != null) {
embed.addField("Канал", "<#" + channelId + ">", false);
} else {
embed.addField("Канал", "❌ Не установлен", false);
}
if (format != null) {
embed.addField("Формат", "`" + format + "`", false);
embed.addField("Пример", format.replace("{count}", String.valueOf(event.getGuild().getMemberCount())), false);
} else {
embed.addField("Формат", "❌ Не установлен", false);
}
event.replyEmbeds(embed.build()).setEphemeral(true).queue();
}
/**
* Зарегистрировать команду в Discord
*/
public static void register(net.dv8tion.jda.api.JDA jda) {
jda.updateCommands().addCommands(
Commands.slash("counter-setup", "Настройка счетчика участников")
.addSubcommands(
new SubcommandData("channel", "Установить канал для счетчика")
.addOption(OptionType.CHANNEL, "channel", "Голосовой канал", true),
new SubcommandData("format", "Установить формат отображения")
.addOption(OptionType.STRING, "format", "Формат (используйте {count})", true),
new SubcommandData("info", "Показать текущую конфигурацию")
)
).queue();
}
}

View file

@ -0,0 +1,186 @@
package creator.loki.feature.counter;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.feature.config.repository.ConfigRepository;
import creator.loki.embed.EmbedFactory;
import creator.loki.util.GuildChannelResolve;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
import java.awt.Color;
import java.util.List;
/**
* Prefix команда настройки счетчика участников
*/
public class CounterSetupPrefixCommand implements Command {
private final CounterManager counterManager;
private final ConfigRepository configRepo;
public CounterSetupPrefixCommand(CounterManager counterManager, ConfigRepository configRepo) {
this.counterManager = counterManager;
this.configRepo = configRepo;
}
@Override
public String getName() {
return "counter-setup";
}
@Override
public String getDescription() {
return "Настройка счетчика участников";
}
@Override
public String getUsage() {
return "counter-setup <channel|format|info> [значение]";
}
@Override
public String getCategory() {
return "Настройка";
}
@Override
public void execute(CommandContext context) {
// Проверка админ прав
if (!context.getAuthor().hasPermission(Permission.ADMINISTRATOR)) {
context.getChannel().sendMessage("❌ У вас нет прав администратора!").queue();
return;
}
List<String> args = context.getArgs();
if (args.isEmpty()) {
sendHelp(context);
return;
}
String action = args.get(0).toLowerCase();
switch (action) {
case "channel" -> handleChannel(context, args);
case "format" -> handleFormat(context, args);
case "info" -> handleInfo(context);
default -> sendHelp(context);
}
}
private void handleChannel(CommandContext context, List<String> args) {
if (args.size() < 2) {
context.getChannel().sendMessage(
"❌ Укажите голосовой канал: `#канал`, `<#id>` или числовой id. Пример: `!counter-setup channel 123456789012345678`"
).queue();
return;
}
VoiceChannel channel = GuildChannelResolve.voice(context.getGuild(), context.getMessage(), args, 1);
if (channel == null) {
context.getChannel().sendMessage(
"❌ Не найден голосовой канал. Укажите `#войс`, `<#id>` или id из 17-20 цифр."
).queue();
return;
}
configRepo.set("counter.channel_id", channel.getId());
// Сразу обновляем название канала
int memberCount = context.getGuild().getMemberCount();
String format = configRepo.get("counter.format");
if (format == null) {
format = "【👥】Участники: {count}";
configRepo.set("counter.format", format);
}
String newName = format.replace("{count}", String.valueOf(memberCount));
channel.getManager().setName(newName).queue();
context.getChannel().sendMessageEmbeds(
EmbedFactory.create()
.withTitle("✅ Канал счетчика установлен")
.withDescription("Канал " + channel.getAsMention() + " теперь отображает количество участников")
.addField("Текущее количество", String.valueOf(memberCount), false)
.withColor(Color.GREEN)
.build()
).queue();
}
private void handleFormat(CommandContext context, List<String> args) {
if (args.size() < 2) {
context.getChannel().sendMessage("❌ Укажите формат! Пример: `!counter-setup format 【👥】Участники: {count}`").queue();
return;
}
// Объединяем все аргументы в одну строку
String format = String.join(" ", args.subList(1, args.size()));
if (!format.contains("{count}")) {
context.getChannel().sendMessage("❌ Формат должен содержать `{count}`!").queue();
return;
}
configRepo.set("counter.format", format);
// Обновляем канал если он установлен
String channelId = configRepo.get("counter.channel_id");
if (channelId != null) {
VoiceChannel channel = context.getGuild().getVoiceChannelById(channelId);
if (channel != null) {
int memberCount = context.getGuild().getMemberCount();
String newName = format.replace("{count}", String.valueOf(memberCount));
channel.getManager().setName(newName).queue();
}
}
context.getChannel().sendMessageEmbeds(
EmbedFactory.create()
.withTitle("✅ Формат обновлен")
.withDescription("Новый формат: `" + format + "`")
.addField("Пример", format.replace("{count}", "123"), false)
.withColor(Color.GREEN)
.build()
).queue();
}
private void handleInfo(CommandContext context) {
String channelId = configRepo.get("counter.channel_id");
String format = configRepo.get("counter.format");
var embed = EmbedFactory.create()
.withTitle("📊 Конфигурация счетчика участников")
.withColor(Color.BLUE);
if (channelId != null) {
embed.addField("Канал", "<#" + channelId + ">", false);
} else {
embed.addField("Канал", "❌ Не установлен", false);
}
if (format != null) {
embed.addField("Формат", "`" + format + "`", false);
embed.addField("Пример", format.replace("{count}", String.valueOf(context.getGuild().getMemberCount())), false);
} else {
embed.addField("Формат", "❌ Не установлен", false);
}
context.getChannel().sendMessageEmbeds(embed.build()).queue();
}
private void sendHelp(CommandContext context) {
context.getChannel().sendMessageEmbeds(
EmbedFactory.create()
.withTitle("📊 Настройка счетчика участников")
.withDescription("""
**Доступные команды:**
• `!counter-setup channel #войс` или `id` - канал счётчика
• `!counter-setup format 【👥】Участники: {count}` - Установить формат
• `!counter-setup info` - Показать текущую конфигурацию
В формате используйте `{count}` для отображения количества.
Только для администраторов!
""")
.withColor(Color.BLUE)
.build()
).queue();
}
}

View file

@ -1,66 +1,66 @@
package creator.loki.feature.counter; package creator.loki.feature.counter;
import java.time.LocalDate; import java.time.LocalDate;
/** /**
* Дневная статистика участников * Дневная статистика участников
*/ */
public class DailyStats { public class DailyStats {
private String date; private String date;
private int joined; private int joined;
private int left; private int left;
private int difference; private int difference;
public DailyStats() { public DailyStats() {
this.date = LocalDate.now().toString(); this.date = LocalDate.now().toString();
this.joined = 0; this.joined = 0;
this.left = 0; this.left = 0;
this.difference = 0; this.difference = 0;
} }
public DailyStats(String date, int joined, int left) { public DailyStats(String date, int joined, int left) {
this.date = date; this.date = date;
this.joined = joined; this.joined = joined;
this.left = left; this.left = left;
this.difference = joined - left; this.difference = joined - left;
} }
// Getters // Getters
public String getDate() { return date; } public String getDate() { return date; }
public int getJoined() { return joined; } public int getJoined() { return joined; }
public int getLeft() { return left; } public int getLeft() { return left; }
public int getDifference() { return difference; } public int getDifference() { return difference; }
// Setters // Setters
public void setDate(String date) { this.date = date; } public void setDate(String date) { this.date = date; }
public void setJoined(int joined) { public void setJoined(int joined) {
this.joined = joined; this.joined = joined;
updateDifference(); updateDifference();
} }
public void setLeft(int left) { public void setLeft(int left) {
this.left = left; this.left = left;
updateDifference(); updateDifference();
} }
// Методы // Методы
public void incrementJoined() { public void incrementJoined() {
this.joined++; this.joined++;
updateDifference(); updateDifference();
} }
public void incrementLeft() { public void incrementLeft() {
this.left++; this.left++;
updateDifference(); updateDifference();
} }
private void updateDifference() { private void updateDifference() {
this.difference = this.joined - this.left; this.difference = this.joined - this.left;
} }
/** /**
* Проверить является ли статистика сегодняшней * Проверить является ли статистика сегодняшней
*/ */
public boolean isToday() { public boolean isToday() {
return this.date.equals(LocalDate.now().toString()); return this.date.equals(LocalDate.now().toString());
} }
} }

View file

@ -1,86 +1,86 @@
package creator.loki.feature.counter; package creator.loki.feature.counter;
import creator.loki.feature.counter.CounterManager; import creator.loki.feature.counter.CounterManager;
import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.events.guild.member.GuildMemberJoinEvent; import net.dv8tion.jda.api.events.guild.member.GuildMemberJoinEvent;
import net.dv8tion.jda.api.events.guild.member.GuildMemberRemoveEvent; import net.dv8tion.jda.api.events.guild.member.GuildMemberRemoveEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.dv8tion.jda.api.hooks.ListenerAdapter;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.util.concurrent.Executors; import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
/** /**
* Listener для счетчика участников * Listener для счетчика участников
*/ */
public class MemberCounterListener extends ListenerAdapter { public class MemberCounterListener extends ListenerAdapter {
private static final Logger logger = LoggerFactory.getLogger(MemberCounterListener.class); private static final Logger logger = LoggerFactory.getLogger(MemberCounterListener.class);
private final CounterManager counterManager; private final CounterManager counterManager;
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
private Guild guild; private Guild guild;
public MemberCounterListener(CounterManager counterManager) { public MemberCounterListener(CounterManager counterManager) {
this.counterManager = counterManager; this.counterManager = counterManager;
} }
/** /**
* Запустить периодическое обновление счетчика * Запустить периодическое обновление счетчика
*/ */
public void startScheduler(Guild guild) { public void startScheduler(Guild guild) {
this.guild = guild; this.guild = guild;
// Обновляем счетчик каждые 5 минут // Обновляем счетчик каждые 5 минут
scheduler.scheduleAtFixedRate(() -> { scheduler.scheduleAtFixedRate(() -> Thread.startVirtualThread(() -> {
try { try {
counterManager.updateCounter(guild); counterManager.updateCounter(guild);
} catch (Exception e) { } catch (Exception e) {
logger.error("Ошибка при обновлении счетчика", e); logger.error("Ошибка при обновлении счетчика", e);
} }
}, 0, 5, TimeUnit.MINUTES); }), 0, 5, TimeUnit.MINUTES);
logger.info("Запущен планировщик обновления счетчика участников"); logger.info("Запущен планировщик обновления счетчика участников");
} }
@Override @Override
public void onGuildMemberJoin(GuildMemberJoinEvent event) { public void onGuildMemberJoin(GuildMemberJoinEvent event) {
// Игнорируем ботов // Игнорируем ботов
if (event.getUser().isBot()) { if (event.getUser().isBot()) {
return; return;
} }
counterManager.recordJoin(); counterManager.recordJoin();
counterManager.updateCounter(event.getGuild()); counterManager.updateCounter(event.getGuild());
logger.info("Участник {} присоединился к серверу", event.getUser().getName()); logger.info("Участник {} присоединился к серверу", event.getUser().getName());
} }
@Override @Override
public void onGuildMemberRemove(GuildMemberRemoveEvent event) { public void onGuildMemberRemove(GuildMemberRemoveEvent event) {
// Игнорируем ботов // Игнорируем ботов
if (event.getUser().isBot()) { if (event.getUser().isBot()) {
return; return;
} }
counterManager.recordLeave(); counterManager.recordLeave();
counterManager.updateCounter(event.getGuild()); counterManager.updateCounter(event.getGuild());
logger.info("Участник {} покинул сервер", event.getUser().getName()); logger.info("Участник {} покинул сервер", event.getUser().getName());
} }
/** /**
* Остановить планировщик * Остановить планировщик
*/ */
public void shutdown() { public void shutdown() {
scheduler.shutdown(); scheduler.shutdown();
try { try {
if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) { if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
scheduler.shutdownNow(); scheduler.shutdownNow();
} }
} catch (InterruptedException e) { } catch (InterruptedException e) {
scheduler.shutdownNow(); scheduler.shutdownNow();
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
} }
} }
} }

View file

@ -0,0 +1,137 @@
package creator.loki.feature.counter.repository;
import creator.loki.database.Database;
import creator.loki.feature.counter.DailyStats;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
/**
* Репозиторий для работы со статистикой счетчика участников
*/
public class CounterStatsRepository {
private static final Logger logger = LoggerFactory.getLogger(CounterStatsRepository.class);
private final Database database;
public CounterStatsRepository(Database database) {
this.database = database;
}
/**
* Получить статистику за дату
*/
public DailyStats getStats(String date) {
String sql = "SELECT * FROM counter_stats WHERE date = ?";
try (PreparedStatement stmt = database.prepare(sql)) {
stmt.setString(1, date);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
DailyStats stats = new DailyStats();
stats.setDate(rs.getString("date"));
stats.setJoined(rs.getInt("joined"));
stats.setLeft(rs.getInt("left_count"));
return stats;
}
}
} catch (SQLException e) {
logger.error("Ошибка получения статистики за {}", date, e);
}
return null;
}
/**
* Получить статистику за сегодня
*/
public DailyStats getTodayStats() {
String today = LocalDate.now().toString();
DailyStats stats = getStats(today);
if (stats == null) {
stats = new DailyStats();
stats.setDate(today);
}
return stats;
}
/**
* Сохранить статистику
*/
public void saveStats(DailyStats stats) {
String sql = """
INSERT INTO counter_stats (date, joined, left_count, difference)
VALUES (?, ?, ?, ?)
ON CONFLICT(date)
DO UPDATE SET
joined = excluded.joined,
left_count = excluded.left_count,
difference = excluded.difference
""";
try (PreparedStatement stmt = database.prepare(sql)) {
stmt.setString(1, stats.getDate());
stmt.setInt(2, stats.getJoined());
stmt.setInt(3, stats.getLeft());
stmt.setInt(4, stats.getDifference());
stmt.executeUpdate();
} catch (SQLException e) {
logger.error("Ошибка сохранения статистики за {}", stats.getDate(), e);
}
}
/**
* Получить историю за последние N дней
*/
public List<DailyStats> getHistory(int days) {
List<DailyStats> history = new ArrayList<>();
String sql = "SELECT * FROM counter_stats ORDER BY date DESC LIMIT ?";
try (PreparedStatement stmt = database.prepare(sql)) {
stmt.setInt(1, days);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
DailyStats stats = new DailyStats();
stats.setDate(rs.getString("date"));
stats.setJoined(rs.getInt("joined"));
stats.setLeft(rs.getInt("left_count"));
history.add(stats);
}
}
} catch (SQLException e) {
logger.error("Ошибка получения истории", e);
}
java.util.Collections.reverse(history);
return history;
}
/**
* Удалить старую статистику (старше N дней)
*/
public void cleanOldStats(int daysToKeep) {
String sql = """
DELETE FROM counter_stats
WHERE date < date('now', '-' || ? || ' days')
""";
try (PreparedStatement stmt = database.prepare(sql)) {
stmt.setInt(1, daysToKeep);
int deleted = stmt.executeUpdate();
if (deleted > 0) {
logger.info("Удалено {} старых записей статистики", deleted);
}
} catch (SQLException e) {
logger.error("Ошибка очистки старой статистики", e);
}
}
}

View file

@ -0,0 +1,63 @@
package creator.loki.feature.economy;
import com.google.inject.Inject;
import creator.loki.core.registry.CommandRegistrar;
import creator.loki.core.registry.CommandRegistry;
import creator.loki.core.security.CooldownManager;
import creator.loki.feature.economy.command.bank.BankCommand;
import creator.loki.feature.economy.command.core.BalanceCommand;
import creator.loki.feature.economy.command.core.BaltopCommand;
import creator.loki.feature.economy.command.core.DailyCommand;
import creator.loki.feature.economy.command.core.PayCommand;
import creator.loki.feature.economy.command.minigame.BlackjackCommand;
import creator.loki.feature.economy.command.minigame.FishCommand;
import creator.loki.feature.economy.command.minigame.JobCommand;
import creator.loki.feature.economy.command.minigame.SlotCommand;
import creator.loki.feature.economy.command.minigame.WorkCommand;
import creator.loki.feature.economy.command.social.RobCommand;
import creator.loki.feature.economy.service.BlackjackService;
import creator.loki.feature.economy.service.EconomyService;
import creator.loki.feature.economy.service.FishingService;
import creator.loki.feature.economy.service.JobService;
import creator.loki.feature.economy.service.SlotService;
public class EconomyCommandRegistrar implements CommandRegistrar {
private final EconomyService economyService;
private final FishingService fishingService;
private final BlackjackService blackjackService;
private final SlotService slotService;
private final JobService jobService;
private final CooldownManager cooldownManager;
@Inject
public EconomyCommandRegistrar(
EconomyService economyService,
FishingService fishingService,
BlackjackService blackjackService,
SlotService slotService,
JobService jobService,
CooldownManager cooldownManager
) {
this.economyService = economyService;
this.fishingService = fishingService;
this.blackjackService = blackjackService;
this.slotService = slotService;
this.jobService = jobService;
this.cooldownManager = cooldownManager;
}
@Override
public void register(CommandRegistry registry) {
registry.register(new BalanceCommand(economyService));
registry.register(new BaltopCommand(economyService));
registry.register(new PayCommand(economyService));
registry.register(new DailyCommand(economyService));
registry.register(new BankCommand(economyService));
registry.register(new RobCommand(economyService, cooldownManager));
registry.register(new FishCommand(fishingService));
registry.register(new BlackjackCommand(blackjackService, economyService));
registry.register(new SlotCommand(slotService, economyService));
registry.register(new JobCommand(jobService));
registry.register(new WorkCommand(jobService));
}
}

View file

@ -0,0 +1,57 @@
package creator.loki.feature.economy;
import com.google.inject.AbstractModule;
import com.google.inject.Provides;
import com.google.inject.Singleton;
import creator.loki.core.security.CooldownManager;
import creator.loki.database.Database;
import creator.loki.feature.economy.repository.EconomyRepository;
import creator.loki.feature.economy.repository.SqlEconomyRepository;
import creator.loki.feature.economy.service.BlackjackService;
import creator.loki.feature.economy.service.EconomyService;
import creator.loki.feature.economy.service.FishingService;
import creator.loki.feature.economy.service.JobService;
import creator.loki.feature.economy.service.SlotService;
public class EconomyModule extends AbstractModule {
@Override
protected void configure() {
}
@Provides
@Singleton
EconomyRepository provideEconomyRepository(Database database) {
return new SqlEconomyRepository(database);
}
@Provides
@Singleton
EconomyService provideEconomyService(EconomyRepository repository, CooldownManager cooldownManager) {
return new EconomyService(repository, cooldownManager);
}
@Provides
@Singleton
FishingService provideFishingService(EconomyRepository repository) {
return new FishingService(repository);
}
@Provides
@Singleton
BlackjackService provideBlackjackService(EconomyRepository repository) {
return new BlackjackService(repository);
}
@Provides
@Singleton
SlotService provideSlotService(EconomyRepository repository) {
return new SlotService(repository);
}
@Provides
@Singleton
JobService provideJobService(EconomyRepository repository) {
return new JobService(repository);
}
}

View file

@ -0,0 +1,93 @@
package creator.loki.feature.economy.command.bank;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.feature.economy.domain.EconomyAccount;
import creator.loki.feature.economy.service.EconomyService;
import creator.loki.embed.EmbedFactory;
import creator.loki.embed.EmbedTemplates;
import java.awt.Color;
import java.util.List;
public class BankCommand implements Command {
private final EconomyService economyService;
public BankCommand(EconomyService economyService) {
this.economyService = economyService;
}
@Override
public String getName() { return "bank"; }
@Override
public String getDescription() { return "Управление банковским счётом"; }
@Override
public String getUsage() { return "bank [deposit|withdraw|balance] <amount>"; }
@Override
public String getCategory() { return "Экономика"; }
@Override
public void execute(CommandContext ctx) {
List<String> args = ctx.getArgs();
String uid = ctx.getAuthor().getId();
if (args.isEmpty() || args.get(0).equalsIgnoreCase("balance")) {
EconomyAccount acc = economyService.getAccount(uid);
ctx.getChannel().sendMessageEmbeds(
EmbedFactory.create()
.withTitle("🏦 Банк — " + ctx.getAuthor().getEffectiveName())
.withColor(new Color(52, 152, 219))
.addField("Наличные", "**" + acc.getCash() + "** LC", true)
.addField("Банк", "**" + acc.getBank() + "** LC", true)
.addField("Всего", "**" + acc.getTotalBalance() + "** LC", true)
.build()
).queue();
return;
}
String sub = args.get(0).toLowerCase();
if (!sub.equals("deposit") && !sub.equals("withdraw")) {
ctx.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Неизвестная подкоманда. Используй: " + getUsage())
).queue();
return;
}
if (args.size() < 2) {
ctx.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Укажите сумму: bank " + sub + " <amount>")
).queue();
return;
}
long amount;
try {
amount = Long.parseLong(args.get(1));
} catch (NumberFormatException e) {
ctx.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Сумма должна быть числом!")
).queue();
return;
}
if (amount <= 0) {
ctx.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Сумма должна быть положительной!")
).queue();
return;
}
EconomyAccount acc = economyService.getAccount(uid);
boolean isDep = sub.equals("deposit");
long balance = isDep ? acc.getCash() : acc.getBank();
if (balance < amount) {
ctx.getChannel().sendMessageEmbeds(EmbedTemplates.error("Ошибка",
"Недостаточно " + (isDep ? "наличных" : "в банке") + "! У вас **" + balance + "** LC")
).queue();
return;
}
if (isDep) economyService.deposit(uid, amount);
else economyService.withdraw(uid, amount);
ctx.getChannel().sendMessageEmbeds(
EmbedTemplates.success(isDep ? "Депозит" : "Снятие",
"Ты " + (isDep ? "положил" : "снял") + " **" + amount + "** LC " + (isDep ? "в банк!" : "из банка!"))
).queue();
}
}

View file

@ -0,0 +1,57 @@
package creator.loki.feature.economy.command.core;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.feature.economy.domain.EconomyAccount;
import creator.loki.feature.economy.service.EconomyService;
import creator.loki.embed.EmbedFactory;
import net.dv8tion.jda.api.entities.Member;
import java.awt.Color;
import java.util.List;
public class BalanceCommand implements Command {
private final EconomyService economyService;
public BalanceCommand(EconomyService economyService) {
this.economyService = economyService;
}
@Override
public String getName() {
return "balance";
}
@Override
public String getDescription() {
return "Показать баланс пользователя";
}
@Override
public String getUsage() {
return "balance [@user]";
}
@Override
public String getCategory() {
return "Экономика";
}
@Override
public void execute(CommandContext context) {
List<Member> mentioned = context.getMessage().getMentions().getMembers();
Member target = mentioned.isEmpty() ? context.getAuthor() : mentioned.get(0);
EconomyAccount account = economyService.getAccount(target.getId());
context.getChannel().sendMessageEmbeds(
EmbedFactory.create()
.withTitle("💰 Баланс — " + target.getEffectiveName())
.withColor(new Color(255, 215, 0))
.addField("Наличные", "**" + account.getCash() + "** LC", true)
.addField("Банк", "**" + account.getBank() + "** LC", true)
.addField("Всего", "**" + account.getTotalBalance() + "** LC", true)
.build()
).queue();
}
}

View file

@ -0,0 +1,57 @@
package creator.loki.feature.economy.command.core;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.feature.economy.domain.EconomyAccount;
import creator.loki.feature.economy.service.EconomyService;
import creator.loki.embed.EmbedFactory;
import java.awt.Color;
import java.util.List;
public class BaltopCommand implements Command {
private final EconomyService economyService;
public BaltopCommand(EconomyService economyService) {
this.economyService = economyService;
}
@Override
public String getName() {
return "baltop";
}
@Override
public String getDescription() {
return "Топ 10 богатейших пользователей";
}
@Override
public String getCategory() {
return "Экономика";
}
@Override
public void execute(CommandContext context) {
List<EconomyAccount> top = economyService.getTopBalances(10);
StringBuilder sb = new StringBuilder();
for (int i = 0; i < top.size(); i++) {
EconomyAccount acc = top.get(i);
String name = context.getGuild().getMemberById(acc.getUserId()) != null
? context.getGuild().getMemberById(acc.getUserId()).getEffectiveName()
: acc.getUserId();
sb.append("**").append(i + 1).append(".** ")
.append(name)
.append(" — **").append(acc.getTotalBalance()).append("** LC\n");
}
context.getChannel().sendMessageEmbeds(
EmbedFactory.create()
.withTitle("🏆 Топ богатейших")
.withDescription(sb.toString())
.withColor(new Color(255, 215, 0))
.build()
).queue();
}
}

View file

@ -0,0 +1,55 @@
package creator.loki.feature.economy.command.core;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.feature.economy.service.EconomyService;
import creator.loki.embed.EmbedFactory;
import creator.loki.embed.EmbedTemplates;
import java.awt.Color;
public class DailyCommand implements Command {
private final EconomyService economyService;
public DailyCommand(EconomyService economyService) {
this.economyService = economyService;
}
@Override
public String getName() {
return "daily";
}
@Override
public String getDescription() {
return "Получить ежедневную награду";
}
@Override
public String getCategory() {
return "Экономика";
}
@Override
public void execute(CommandContext context) {
EconomyService.DailyResult result = economyService.claimDaily(context.getAuthor().getId());
if (result == null) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.warning("Ежедневная награда",
"Ты уже получил награду сегодня! Возвращайся завтра.")
).queue();
return;
}
context.getChannel().sendMessageEmbeds(
EmbedFactory.create()
.withTitle("🎁 Ежедневная награда")
.withDescription("Ты получил **" + result.amount() + "** LC!")
.withColor(new Color(46, 204, 113))
.addField("Стрик", "**" + result.streak() + "** дней", true)
.addField("Баланс", "**" + result.newCash() + "** LC", true)
.build()
).queue();
}
}

View file

@ -0,0 +1,96 @@
package creator.loki.feature.economy.command.core;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.feature.economy.service.EconomyService;
import creator.loki.embed.EmbedTemplates;
import net.dv8tion.jda.api.entities.Member;
import java.util.List;
public class PayCommand implements Command {
private final EconomyService economyService;
public PayCommand(EconomyService economyService) {
this.economyService = economyService;
}
@Override
public String getName() {
return "pay";
}
@Override
public String getDescription() {
return "Перевести деньги другому пользователю";
}
@Override
public String getUsage() {
return "pay <@user> <amount>";
}
@Override
public String getCategory() {
return "Экономика";
}
@Override
public void execute(CommandContext context) {
List<Member> mentioned = context.getMessage().getMentions().getMembers();
if (mentioned.isEmpty()) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Укажите пользователя: " + getUsage())
).queue();
return;
}
Member target = mentioned.get(0);
if (target.getId().equals(context.getAuthor().getId())) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Нельзя переводить деньги самому себе!")
).queue();
return;
}
List<String> args = context.getArgs();
if (args.size() < 2) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Укажите сумму: " + getUsage())
).queue();
return;
}
long amount;
try {
amount = Long.parseLong(args.get(1));
} catch (NumberFormatException e) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Сумма должна быть числом!")
).queue();
return;
}
if (amount <= 0) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Сумма должна быть положительной!")
).queue();
return;
}
var senderAccount = economyService.getAccount(context.getAuthor().getId());
if (senderAccount.getCash() < amount) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Недостаточно средств! У вас **" + senderAccount.getCash() + "** LC")
).queue();
return;
}
economyService.transfer(context.getAuthor().getId(), target.getId(), amount);
context.getChannel().sendMessageEmbeds(
EmbedTemplates.success("Перевод выполнен",
context.getAuthor().getAsMention() + " перевёл **" + amount + "** LC " + target.getAsMention())
).queue();
}
}

View file

@ -0,0 +1,148 @@
package creator.loki.feature.economy.command.minigame;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.feature.economy.service.BlackjackService;
import creator.loki.feature.economy.service.EconomyService;
import creator.loki.embed.EmbedFactory;
import creator.loki.embed.EmbedTemplates;
import net.dv8tion.jda.api.entities.MessageEmbed;
import java.awt.Color;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
public class BlackjackCommand implements Command {
private final BlackjackService blackjackService;
private final EconomyService economyService;
private final Set<String> activeGames = ConcurrentHashMap.newKeySet();
public BlackjackCommand(BlackjackService blackjackService, EconomyService economyService) {
this.blackjackService = blackjackService;
this.economyService = economyService;
}
@Override
public String getName() {
return "blackjack";
}
@Override
public String getDescription() {
return "Блэкджек — играй против дилера";
}
@Override
public String getUsage() {
return "bj <ставка> — начать игру\nbj hit — взять карту\nbj stand — остановиться";
}
@Override
public String getCategory() {
return "Экономика";
}
@Override
public void execute(CommandContext context) {
var args = context.getArgs();
var userId = context.getAuthor().getId();
if (args.isEmpty()) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.warning("Блэкджек", "Используй: `!bj <ставка>` чтобы начать")
).queue();
return;
}
var action = args.get(0).toLowerCase();
if (action.equals("hit")) {
handleAction(context, userId, true);
} else if (action.equals("stand")) {
handleAction(context, userId, false);
} else {
handleStart(context, userId, action);
}
}
private void handleStart(CommandContext context, String userId, String betStr) {
if (activeGames.contains(userId)) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "У тебя уже есть активная игра! Используй `!bj hit` или `!bj stand`")
).queue();
return;
}
long bet;
try {
bet = Long.parseLong(betStr);
if (bet <= 0) throw new NumberFormatException();
} catch (NumberFormatException e) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Укажи положительное число в качестве ставки")
).queue();
return;
}
var account = economyService.getAccount(userId);
if (account.getCash() < bet) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Недостаточно средств! Твой баланс: " + account.getCash() + " LC")
).queue();
return;
}
BlackjackService.BlackjackState state = blackjackService.startGame(userId, bet);
if (state == null) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Не удалось начать игру.")
).queue();
return;
}
if (!state.isOver()) activeGames.add(userId);
context.getChannel().sendMessageEmbeds(buildGameEmbed(state)).queue();
}
private void handleAction(CommandContext context, String userId, boolean isHit) {
if (!activeGames.contains(userId)) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Нет активной игры! Начни с `!bj <ставка>`")
).queue();
return;
}
BlackjackService.BlackjackState state = isHit
? blackjackService.hit(userId)
: blackjackService.stand(userId);
if (state.isOver()) activeGames.remove(userId);
context.getChannel().sendMessageEmbeds(buildGameEmbed(state)).queue();
}
private MessageEmbed buildGameEmbed(BlackjackService.BlackjackState state) {
var embed = EmbedFactory.create()
.withTitle("🃏 Блэкджек")
.withColor(new Color(34, 139, 34))
.addField("Твои карты", formatCards(state.playerCards(), state.playerSum()), true);
if (state.isOver()) {
embed.addField("Карты дилера", formatCards(state.dealerCards(), state.dealerSum()), true)
.addField("Результат", formatResult(state), false);
} else {
embed.addField("Карты дилера", formatHiddenDealerCards(state.dealerCards()), true)
.withFooter("!bj hit — взять карту | !bj stand — остановиться");
}
return embed.addField("Ставка", state.bet() + " LC", false).build();
}
private String formatCards(String[] cards, int sum) {
return String.join(" + ", cards) + " = " + sum;
}
private String formatHiddenDealerCards(String[] cards) {
var sb = new StringBuilder("?");
for (int i = 1; i < cards.length; i++) sb.append(" + ").append(cards[i]);
return sb.toString();
}
private String formatResult(BlackjackService.BlackjackState state) {
long payout = state.payout();
if (payout > state.bet()) return "🎉 Блэкджек! +" + payout + " LC";
if (payout > 0) return "✅ Ты выиграл +" + payout + " LC";
if (payout == state.bet()) return "🤝 Ничья. Возврат " + payout + " LC";
return "💔 Ты проиграл " + state.bet() + " LC";
}
}

View file

@ -0,0 +1,106 @@
package creator.loki.feature.economy.command.minigame;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.feature.economy.service.FishingService;
import creator.loki.embed.EmbedFactory;
import creator.loki.embed.EmbedTemplates;
import java.awt.Color;
import java.util.concurrent.ConcurrentHashMap;
public class FishCommand implements Command {
private static record FishSession(String difficulty, long startTime) {}
private final FishingService fishingService;
private final ConcurrentHashMap<String, FishSession> sessions = new ConcurrentHashMap<>();
public FishCommand(FishingService fishingService) {
this.fishingService = fishingService;
}
@Override
public String getName() {
return "fish";
}
@Override
public String getDescription() {
return "Рыбалка — угадай количество рыбы в пруду";
}
@Override
public String getUsage() {
return "fish <easy/normal/hard> — начать рыбалку\nfish <число> — ответить";
}
@Override
public String getCategory() {
return "Экономика";
}
@Override
public void execute(CommandContext context) {
var args = context.getArgs();
var userId = context.getAuthor().getId();
if (args.isEmpty()) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.warning("Рыбалка", "Используй: `!fish easy/normal/hard`")
).queue();
return;
}
var first = args.get(0).toLowerCase();
if (first.equals("easy") || first.equals("normal") || first.equals("hard")) {
FishingService.FishResult result = fishingService.startFishing(userId, first);
sessions.put(userId, new FishSession(first, System.currentTimeMillis()));
context.getChannel().sendMessageEmbeds(
EmbedFactory.create()
.withTitle("🎣 Рыбалка")
.withDescription("```\n" + String.join("\n", result.grid()) + "\n```")
.withColor(new Color(0, 150, 255))
.addField("Вопрос", "Сколько **" + result.targetFish() + "** ты видишь?", false)
.withFooter("Напиши !fish <число>, чтобы ответить")
.build()
).queue();
return;
}
try {
int answer = Integer.parseInt(first);
FishSession session = sessions.remove(userId);
if (session == null) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Нет активной рыбалки! Начни с `!fish easy/normal/hard`")
).queue();
return;
}
FishingService.FishResult result = fishingService.checkAnswer(userId, answer, session.startTime(), session.difficulty());
var embed = EmbedFactory.create()
.withTitle("🎣 Рыбалка — Результат")
.withDescription("```\n" + String.join("\n", result.grid()) + "\n```")
.withColor(new Color(255, 215, 0));
if (result.reward() > 0) {
embed.addField("Твой ответ", String.valueOf(answer), true)
.addField("Правильный ответ", String.valueOf(result.count()), true)
.addField("Награда", "+" + result.reward() + " LC", true)
.addField("Новый баланс", result.cashAfter() + " LC", true);
} else {
embed.withDescription("❌ Неверно! Правильный ответ: **" + result.count() + "**\n\n```\n"
+ String.join("\n", result.grid()) + "\n```");
}
context.getChannel().sendMessageEmbeds(embed.build()).queue();
} catch (NumberFormatException e) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Неверный аргумент. Используй `!fish easy/normal/hard` или `!fish <число>`")
).queue();
}
}
}

View file

@ -0,0 +1,146 @@
package creator.loki.feature.economy.command.minigame;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.feature.economy.service.JobService;
import creator.loki.embed.EmbedFactory;
import creator.loki.embed.EmbedTemplates;
import java.awt.Color;
public class JobCommand implements Command {
private final JobService jobService;
public JobCommand(JobService jobService) {
this.jobService = jobService;
}
@Override
public String getName() {
return "job";
}
@Override
public String getDescription() {
return "Управление работой — список, присоединиться";
}
@Override
public String getUsage() {
return "job — текущая работа\njob list — список работ\njob join <название> — устроиться";
}
@Override
public String getCategory() {
return "Экономика";
}
@Override
public void execute(CommandContext context) {
var args = context.getArgs();
var userId = context.getAuthor().getId();
if (args.isEmpty()) {
handleCurrent(context, userId);
} else if (args.get(0).equalsIgnoreCase("list")) {
handleList(context);
} else if (args.get(0).equalsIgnoreCase("join")) {
if (args.size() < 2) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Укажи название работы: `!job join <название>`")
).queue();
return;
}
handleJoin(context, userId, args.get(1));
} else {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.warning("Работа", "Используй: `!job`, `!job list`, `!job join <название>`")
).queue();
}
}
private void handleCurrent(CommandContext context, String userId) {
String current = jobService.getCurrentJob(userId);
if (current == null) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.info("Работа", "У тебя нет работы. Используй `!job list` чтобы посмотреть доступные.")
).queue();
return;
}
JobService.JobInfo info = findJob(current);
if (info == null) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.warning("Работа", "Текущая работа: **" + current + "**")
).queue();
return;
}
context.getChannel().sendMessageEmbeds(
EmbedFactory.create()
.withTitle("💼 Твоя работа")
.withColor(new Color(100, 200, 255))
.addField("Должность", info.emoji() + " " + info.displayName(), false)
.addField("Зарплата", info.minPayout() + " – " + info.maxPayout() + " LC", true)
.addField("Требуемый уровень", String.valueOf(info.requiredLevel()), true)
.build()
).queue();
}
private void handleList(CommandContext context) {
var jobs = jobService.listJobs();
var desc = new StringBuilder();
for (var job : jobs) {
desc.append(job.emoji()).append(" **").append(job.displayName())
.append("** — ").append(job.minPayout()).append("–").append(job.maxPayout())
.append(" LC (ур. ").append(job.requiredLevel()).append(")\n");
}
context.getChannel().sendMessageEmbeds(
EmbedFactory.create()
.withTitle("📋 Доступные работы")
.withDescription(desc.toString())
.withColor(new Color(100, 200, 255))
.withFooter("!job join <название> — устроиться на работу")
.build()
).queue();
}
private void handleJoin(CommandContext context, String userId, String name) {
var jobs = jobService.listJobs();
JobService.JobInfo target = null;
for (var job : jobs) {
if (job.name().equalsIgnoreCase(name) || job.displayName().equalsIgnoreCase(name)) {
target = job;
break;
}
}
if (target == null) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Работа **" + name + "** не найдена. Используй `!job list`")
).queue();
return;
}
jobService.setJob(userId, target.name());
context.getChannel().sendMessageEmbeds(
EmbedTemplates.success("Устройство на работу",
"Ты устроился на работу **" + target.emoji() + " " + target.displayName() + "**!\n"
+ "Используй `!work` чтобы начать работать.")
).queue();
}
private JobService.JobInfo findJob(String name) {
for (var job : jobService.listJobs()) {
if (job.name().equalsIgnoreCase(name) || job.displayName().equalsIgnoreCase(name)) {
return job;
}
}
return null;
}
}

View file

@ -0,0 +1,96 @@
package creator.loki.feature.economy.command.minigame;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.feature.economy.service.EconomyService;
import creator.loki.feature.economy.service.SlotService;
import creator.loki.embed.EmbedFactory;
import creator.loki.embed.EmbedTemplates;
import java.awt.Color;
public class SlotCommand implements Command {
private final SlotService slotService;
private final EconomyService economyService;
public SlotCommand(SlotService slotService, EconomyService economyService) {
this.slotService = slotService;
this.economyService = economyService;
}
@Override
public String getName() {
return "slot";
}
@Override
public String getDescription() {
return "Крути слот-машину и выигрывай";
}
@Override
public String getUsage() {
return "slot <ставка>";
}
@Override
public String getCategory() {
return "Экономика";
}
@Override
public void execute(CommandContext context) {
var args = context.getArgs();
var userId = context.getAuthor().getId();
if (args.isEmpty()) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.warning("Слоты", "Используй: `!slot <ставка>`")
).queue();
return;
}
long bet;
try {
bet = Long.parseLong(args.get(0));
if (bet <= 0) throw new NumberFormatException();
} catch (NumberFormatException e) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Ставка должна быть положительным числом!")
).queue();
return;
}
var account = economyService.getAccount(userId);
if (account.getCash() < bet) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Недостаточно средств! Твой баланс: " + account.getCash() + " LC")
).queue();
return;
}
SlotService.SlotResult result = slotService.spin(userId, bet);
var comboDisplay = result.emoji1() + " | " + result.emoji2() + " | " + result.emoji3();
var desc = new StringBuilder("**" + comboDisplay + "**\n");
if (result.multiplier() > 0) {
desc.append("\n🎰 **").append(result.comboName()).append("!**")
.append(" x").append(result.multiplier())
.append("\n\n+**").append(result.winAmount()).append("** LC");
} else {
desc.append("\n\n😔 Повезёт в следующий раз!");
}
desc.append("\n\nБаланс: **").append(result.cashAfter()).append("** LC");
context.getChannel().sendMessageEmbeds(
EmbedFactory.create()
.withTitle("🎰 Слот-машина")
.withDescription(desc.toString())
.withColor(new Color(255, 215, 0))
.build()
).queue();
}
}

View file

@ -0,0 +1,78 @@
package creator.loki.feature.economy.command.minigame;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.feature.economy.service.JobService;
import creator.loki.embed.EmbedFactory;
import creator.loki.embed.EmbedTemplates;
import java.awt.Color;
public class WorkCommand implements Command {
private final JobService jobService;
public WorkCommand(JobService jobService) {
this.jobService = jobService;
}
@Override
public String getName() {
return "work";
}
@Override
public String getDescription() {
return "Работать и зарабатывать деньги";
}
@Override
public String getCategory() {
return "Экономика";
}
@Override
public void execute(CommandContext context) {
var userId = context.getAuthor().getId();
String currentJob = jobService.getCurrentJob(userId);
if (currentJob == null) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.warning("Работа", "У тебя нет работы! Используй `!job list` чтобы выбрать.")
).queue();
return;
}
JobService.JobInfo info = findJob(currentJob);
JobService.WorkResult result = jobService.work(userId, 0);
if (result == null) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "⏳ Ты уже работал сегодня! Отдохни и возвращайся завтра.")
).queue();
return;
}
String emoji = info != null ? info.emoji() : "💼";
String jobName = info != null ? info.displayName() : currentJob;
context.getChannel().sendMessageEmbeds(
EmbedFactory.create()
.withTitle("💼 Работа завершена")
.withColor(new Color(100, 200, 255))
.addField("Должность", emoji + " " + jobName, false)
.addField("Заработано", "+**" + result.amount() + "** LC", true)
.addField("Новый баланс", "**" + result.newCash() + "** LC", true)
.build()
).queue();
}
private JobService.JobInfo findJob(String name) {
for (var job : jobService.listJobs()) {
if (job.name().equalsIgnoreCase(name) || job.displayName().equalsIgnoreCase(name)) {
return job;
}
}
return null;
}
}

View file

@ -0,0 +1,99 @@
package creator.loki.feature.economy.command.social;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.core.security.CooldownManager;
import creator.loki.feature.economy.service.EconomyService;
import creator.loki.embed.EmbedFactory;
import creator.loki.embed.EmbedTemplates;
import net.dv8tion.jda.api.entities.Member;
import java.awt.Color;
import java.util.List;
import java.util.Random;
import java.util.concurrent.ThreadLocalRandom;
public class RobCommand implements Command {
private final EconomyService economyService;
private final CooldownManager cooldownManager;
private final Random random;
public RobCommand(EconomyService economyService, CooldownManager cooldownManager) {
this.economyService = economyService;
this.cooldownManager = cooldownManager;
this.random = ThreadLocalRandom.current();
}
@Override
public String getName() { return "rob"; }
@Override
public String getDescription() { return "Ограбить пользователя"; }
@Override
public String getUsage() { return "rob <@user>"; }
@Override
public String getCategory() { return "Экономика"; }
@Override
public void execute(CommandContext ctx) {
List<Member> mentioned = ctx.getMessage().getMentions().getMembers();
if (mentioned.isEmpty()) {
ctx.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Укажите пользователя: " + getUsage())
).queue();
return;
}
Member target = mentioned.get(0);
String uid = ctx.getAuthor().getId();
String tid = target.getId();
if (tid.equals(uid)) {
ctx.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Нельзя ограбить самого себя!")
).queue();
return;
}
if (cooldownManager.isOnCooldown(uid, "rob")) {
long rem = cooldownManager.getRemainingSeconds(uid, "rob");
ctx.getChannel().sendMessageEmbeds(EmbedTemplates.warning("Кдн",
"Подожди ещё **" + (rem / 3600) + "ч " + ((rem % 3600) / 60) + "м**")
).queue();
return;
}
cooldownManager.applyCooldown(uid, "rob", 86400);
var sender = economyService.getAccount(uid);
var victim = economyService.getAccount(tid);
if (sender.getCash() <= 0 || victim.getCash() <= 0) {
ctx.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "У одного из вас нет наличных!")
).queue();
return;
}
if (random.nextDouble() < 0.4) {
long steal = Math.min(Math.min((long) (victim.getCash() * 0.15), 10000L), victim.getCash());
if (steal <= 0) {
ctx.getChannel().sendMessageEmbeds(EmbedTemplates.info("Ограбление", "Слишком мелкая цель...")).queue();
return;
}
economyService.transfer(tid, uid, steal);
ctx.getChannel().sendMessageEmbeds(
EmbedFactory.create()
.withTitle("🔫 Ограбление удалось!")
.withDescription("Ты украл **" + steal + "** LC у " + target.getAsMention())
.withColor(new Color(46, 204, 113))
.build()
).queue();
} else {
long penalty = Math.min(Math.min((long) (sender.getCash() * 0.10), 5000L), sender.getCash());
if (penalty > 0) economyService.transfer(uid, tid, penalty);
ctx.getChannel().sendMessageEmbeds(
EmbedFactory.create()
.withTitle("👮 Ограбление провалено!")
.withDescription("Ты заплатил **" + penalty + "** LC " + target.getAsMention())
.withColor(new Color(231, 76, 60))
.build()
).queue();
}
}
}

View file

@ -0,0 +1,151 @@
package creator.loki.feature.economy.domain;
import java.util.Objects;
public class EconomyAccount {
private final String userId;
private final long cash;
private final long bank;
private final long totalEarned;
private final long totalSpent;
private final String lastDaily;
private final int dailyStreak;
private final int fishingLuck;
private final int efficiency;
private final String lastFish;
private final String job;
private final String lastWork;
public EconomyAccount(String userId) {
this.userId = userId;
this.cash = 0;
this.bank = 0;
this.totalEarned = 0;
this.totalSpent = 0;
this.lastDaily = null;
this.dailyStreak = 0;
this.fishingLuck = 0;
this.efficiency = 0;
this.lastFish = null;
this.job = null;
this.lastWork = null;
}
private EconomyAccount(Builder builder) {
this.userId = builder.userId;
this.cash = builder.cash;
this.bank = builder.bank;
this.totalEarned = builder.totalEarned;
this.totalSpent = builder.totalSpent;
this.lastDaily = builder.lastDaily;
this.dailyStreak = builder.dailyStreak;
this.fishingLuck = builder.fishingLuck;
this.efficiency = builder.efficiency;
this.lastFish = builder.lastFish;
this.job = builder.job;
this.lastWork = builder.lastWork;
}
public String getUserId() { return userId; }
public long getCash() { return cash; }
public long getBank() { return bank; }
public long getTotalEarned() { return totalEarned; }
public long getTotalSpent() { return totalSpent; }
public String getLastDaily() { return lastDaily; }
public int getDailyStreak() { return dailyStreak; }
public int getFishingLuck() { return fishingLuck; }
public int getEfficiency() { return efficiency; }
public String getLastFish() { return lastFish; }
public String getJob() { return job; }
public String getLastWork() { return lastWork; }
public long getTotalBalance() { return cash + bank; }
public EconomyAccount withCash(long cash) {
return new Builder(this).cash(cash).build();
}
public EconomyAccount withBank(long bank) {
return new Builder(this).bank(bank).build();
}
public EconomyAccount withAddedEarned(long amount) {
return new Builder(this).totalEarned(this.totalEarned + amount).build();
}
public EconomyAccount withAddedSpent(long amount) {
return new Builder(this).totalSpent(this.totalSpent + amount).build();
}
public EconomyAccount withFishingLuck(int level) {
return new Builder(this).fishingLuck(level).build();
}
public EconomyAccount withEfficiency(int level) {
return new Builder(this).efficiency(level).build();
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
EconomyAccount that = (EconomyAccount) o;
return Objects.equals(userId, that.userId);
}
@Override
public int hashCode() { return Objects.hash(userId); }
@Override
public String toString() {
return String.format("EconomyAccount[%s: Cash %d, Bank %d]",
userId, cash, bank);
}
public static class Builder {
private String userId;
private long cash;
private long bank;
private long totalEarned;
private long totalSpent;
private String lastDaily;
private int dailyStreak;
private int fishingLuck;
private int efficiency;
private String lastFish;
private String job;
private String lastWork;
public Builder() {}
public Builder(EconomyAccount existing) {
this.userId = existing.userId;
this.cash = existing.cash;
this.bank = existing.bank;
this.totalEarned = existing.totalEarned;
this.totalSpent = existing.totalSpent;
this.lastDaily = existing.lastDaily;
this.dailyStreak = existing.dailyStreak;
this.fishingLuck = existing.fishingLuck;
this.efficiency = existing.efficiency;
this.lastFish = existing.lastFish;
this.job = existing.job;
this.lastWork = existing.lastWork;
}
public Builder userId(String userId) { this.userId = userId; return this; }
public Builder cash(long cash) { this.cash = cash; return this; }
public Builder bank(long bank) { this.bank = bank; return this; }
public Builder totalEarned(long totalEarned) { this.totalEarned = totalEarned; return this; }
public Builder totalSpent(long totalSpent) { this.totalSpent = totalSpent; return this; }
public Builder lastDaily(String lastDaily) { this.lastDaily = lastDaily; return this; }
public Builder dailyStreak(int dailyStreak) { this.dailyStreak = dailyStreak; return this; }
public Builder fishingLuck(int fishingLuck) { this.fishingLuck = fishingLuck; return this; }
public Builder efficiency(int efficiency) { this.efficiency = efficiency; return this; }
public Builder lastFish(String lastFish) { this.lastFish = lastFish; return this; }
public Builder job(String job) { this.job = job; return this; }
public Builder lastWork(String lastWork) { this.lastWork = lastWork; return this; }
public EconomyAccount build() { return new EconomyAccount(this); }
}
}

View file

@ -0,0 +1,9 @@
package creator.loki.feature.economy.domain;
public record EconomyTransaction(
String fromId,
String toId,
long amount,
String type,
long timestamp
) {}

View file

@ -0,0 +1,39 @@
package creator.loki.feature.economy.domain;
public enum Job {
WOODCUTTER("Лесоруб", 0, 15, 30, "\uD83E\uDE93"),
MINER("Шахтёр", 0, 20, 40, "\u26CF\uFE0F"),
FISHERMAN("Рыбак", 0, 25, 50, "\uD83C\uDFA3"),
COOK("Повар", 15, 30, 60, "\uD83C\uDF73"),
PROGRAMMER("Программист", 20, 40, 80, "\uD83D\uDCBB"),
ASTRONAUT("Космонавт", 35, 50, 100, "\uD83E\uDEB8");
private final String displayName;
private final int requiredLevel;
private final int minPayout;
private final int maxPayout;
private final String emoji;
Job(String displayName, int requiredLevel, int minPayout, int maxPayout, String emoji) {
this.displayName = displayName;
this.requiredLevel = requiredLevel;
this.minPayout = minPayout;
this.maxPayout = maxPayout;
this.emoji = emoji;
}
public String getDisplayName() { return displayName; }
public int getRequiredLevel() { return requiredLevel; }
public int getMinPayout() { return minPayout; }
public int getMaxPayout() { return maxPayout; }
public String getEmoji() { return emoji; }
public static Job fromName(String name) {
for (Job j : values()) {
if (j.name().equalsIgnoreCase(name) || j.getDisplayName().equalsIgnoreCase(name)) {
return j;
}
}
return null;
}
}

View file

@ -0,0 +1,31 @@
package creator.loki.feature.economy.domain;
public enum SlotCombination {
CHERRY(new String[]{"\uD83C\uDF52", "\uD83C\uDF52", "\uD83C\uDF52"}, 3, 10.0),
BELL(new String[]{"\uD83D\uDD14", "\uD83D\uDD14", "\uD83D\uDD14"}, 5, 5.0),
DIAMOND(new String[]{"\uD83D\uDC8E", "\uD83D\uDC8E", "\uD83D\uDC8E"}, 10, 2.0),
CROWN(new String[]{"\uD83D\uDC51", "\uD83D\uDC51", "\uD83D\uDC51"}, 15, 1.0),
JACKPOT(new String[]{"\u26A1", "\u26A1", "\u26A1"}, 50, 0.1);
private final String[] emojis;
private final int multiplier;
private final double chance;
SlotCombination(String[] emojis, int multiplier, double chance) {
this.emojis = emojis;
this.multiplier = multiplier;
this.chance = chance;
}
public String[] getEmojis() { return emojis; }
public int getMultiplier() { return multiplier; }
public double getChance() { return chance; }
public static SlotCombination match(String e1, String e2, String e3) {
if (!e1.equals(e2) || !e2.equals(e3)) return null;
for (SlotCombination sc : values()) {
if (sc.emojis[0].equals(e1)) return sc;
}
return null;
}
}

View file

@ -0,0 +1,17 @@
package creator.loki.feature.economy.repository;
import creator.loki.feature.economy.domain.EconomyAccount;
import java.util.List;
import java.util.Optional;
public interface EconomyRepository {
Optional<EconomyAccount> findById(String userId);
void save(EconomyAccount account);
boolean exists(String userId);
List<EconomyAccount> findTopByBalance(int limit);
default EconomyAccount getAccount(String userId) {
return findById(userId).orElse(new EconomyAccount(userId));
}
}

View file

@ -0,0 +1,165 @@
package creator.loki.feature.economy.repository;
import creator.loki.database.Database;
import creator.loki.feature.economy.domain.EconomyAccount;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
public class SqlEconomyRepository implements EconomyRepository {
private static final Logger logger = LoggerFactory.getLogger(SqlEconomyRepository.class);
private static final int MAX_TOP = 100;
private final Database database;
public SqlEconomyRepository(Database database) {
this.database = database;
}
@Override
public Optional<EconomyAccount> findById(String userId) {
String sql = """
SELECT user_id, cash, bank, total_earned, total_spent,
last_daily, daily_streak, fishing_luck, efficiency,
last_fish, job, last_work
FROM economy
WHERE user_id = ?
""";
try (PreparedStatement stmt = database.prepare(sql)) {
stmt.setString(1, userId);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
return Optional.of(mapResultSetToAccount(rs));
}
}
} catch (SQLException e) {
logger.error("Error fetching economy account for {}", userId, e);
}
return Optional.empty();
}
@Override
public void save(EconomyAccount account) {
String sql = """
INSERT INTO economy (user_id, cash, bank, total_earned, total_spent,
last_daily, daily_streak, fishing_luck, efficiency,
last_fish, job, last_work)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id)
DO UPDATE SET
cash = excluded.cash,
bank = excluded.bank,
total_earned = excluded.total_earned,
total_spent = excluded.total_spent,
last_daily = excluded.last_daily,
daily_streak = excluded.daily_streak,
fishing_luck = excluded.fishing_luck,
efficiency = excluded.efficiency,
last_fish = excluded.last_fish,
job = excluded.job,
last_work = excluded.last_work
""";
try (PreparedStatement stmt = database.prepare(sql)) {
stmt.setString(1, account.getUserId());
stmt.setLong(2, account.getCash());
stmt.setLong(3, account.getBank());
stmt.setLong(4, account.getTotalEarned());
stmt.setLong(5, account.getTotalSpent());
if (account.getLastDaily() != null) {
stmt.setString(6, account.getLastDaily());
} else {
stmt.setNull(6, java.sql.Types.VARCHAR);
}
stmt.setInt(7, account.getDailyStreak());
stmt.setInt(8, account.getFishingLuck());
stmt.setInt(9, account.getEfficiency());
if (account.getLastFish() != null) {
stmt.setString(10, account.getLastFish());
} else {
stmt.setNull(10, java.sql.Types.VARCHAR);
}
if (account.getJob() != null) {
stmt.setString(11, account.getJob());
} else {
stmt.setNull(11, java.sql.Types.VARCHAR);
}
if (account.getLastWork() != null) {
stmt.setString(12, account.getLastWork());
} else {
stmt.setNull(12, java.sql.Types.VARCHAR);
}
stmt.executeUpdate();
} catch (SQLException e) {
logger.error("Error saving economy account for {}", account.getUserId(), e);
}
}
@Override
public boolean exists(String userId) {
String sql = "SELECT 1 FROM economy WHERE user_id = ?";
try (PreparedStatement stmt = database.prepare(sql)) {
stmt.setString(1, userId);
try (ResultSet rs = stmt.executeQuery()) {
return rs.next();
}
} catch (SQLException e) {
logger.error("Error checking economy existence for {}", userId, e);
}
return false;
}
@Override
public List<EconomyAccount> findTopByBalance(int limit) {
int lim = clampLimit(limit);
String sql = """
SELECT user_id, cash, bank, total_earned, total_spent,
last_daily, daily_streak, fishing_luck, efficiency,
last_fish, job, last_work
FROM economy
ORDER BY (cash + bank) DESC, cash DESC, user_id ASC
LIMIT ?
""";
List<EconomyAccount> list = new ArrayList<>();
try (PreparedStatement stmt = database.prepare(sql)) {
stmt.setInt(1, lim);
try (ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
list.add(mapResultSetToAccount(rs));
}
}
} catch (SQLException e) {
logger.error("Error fetching top by balance", e);
}
return Collections.unmodifiableList(list);
}
private static int clampLimit(int limit) {
if (limit <= 0) return 1;
return Math.min(limit, MAX_TOP);
}
private EconomyAccount mapResultSetToAccount(ResultSet rs) throws SQLException {
return new EconomyAccount.Builder()
.userId(rs.getString("user_id"))
.cash(rs.getLong("cash"))
.bank(rs.getLong("bank"))
.totalEarned(rs.getLong("total_earned"))
.totalSpent(rs.getLong("total_spent"))
.lastDaily(rs.getString("last_daily"))
.dailyStreak(rs.getInt("daily_streak"))
.fishingLuck(rs.getInt("fishing_luck"))
.efficiency(rs.getInt("efficiency"))
.lastFish(rs.getString("last_fish"))
.job(rs.getString("job"))
.lastWork(rs.getString("last_work"))
.build();
}
}

View file

@ -0,0 +1,139 @@
package creator.loki.feature.economy.service;
import creator.loki.feature.economy.domain.EconomyAccount;
import creator.loki.feature.economy.repository.EconomyRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ThreadLocalRandom;
public class BlackjackService {
private static final Logger logger = LoggerFactory.getLogger(BlackjackService.class);
private static final String[] DECK = {"2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K", "A"};
private final EconomyRepository repository;
private final ConcurrentHashMap<String, ActiveBlackjack> activeGames = new ConcurrentHashMap<>();
public BlackjackService(EconomyRepository repository) {
this.repository = repository;
}
private record ActiveBlackjack(List<String> playerCards, List<String> dealerCards, long bet, String userId) {}
public BlackjackState startGame(String userId, long bet) {
if (bet <= 0 || activeGames.containsKey(userId)) return null;
EconomyAccount account = repository.getAccount(userId);
if (account.getCash() < bet) return null;
repository.save(account.withCash(account.getCash() - bet));
List<String> playerCards = new ArrayList<>(List.of(drawCard(), drawCard()));
List<String> dealerCards = new ArrayList<>(List.of(drawCard(), drawCard()));
int playerSum = calculateSum(playerCards);
int dealerSum = calculateSum(dealerCards);
boolean playerBj = playerSum == 21;
boolean dealerBj = dealerSum == 21;
if (playerBj || dealerBj) {
long payout;
if (playerBj && !dealerBj) {
payout = (long) (bet * 2.5);
} else {
payout = 0;
}
repository.save(repository.getAccount(userId).withCash(repository.getAccount(userId).getCash() + payout));
return new BlackjackState(
playerCards.toArray(String[]::new), dealerCards.toArray(String[]::new),
playerSum, dealerSum, bet, true, payout
);
}
ActiveBlackjack game = new ActiveBlackjack(playerCards, dealerCards, bet, userId);
activeGames.put(userId, game);
return new BlackjackState(
playerCards.toArray(String[]::new), dealerCards.toArray(String[]::new),
playerSum, dealerSum, bet, false, 0
);
}
public BlackjackState hit(String userId) {
ActiveBlackjack game = activeGames.get(userId);
if (game == null) return null;
game.playerCards().add(drawCard());
int sum = calculateSum(game.playerCards());
if (sum > 21) {
activeGames.remove(userId);
return new BlackjackState(
game.playerCards().toArray(String[]::new), game.dealerCards().toArray(String[]::new),
sum, calculateSum(game.dealerCards()), game.bet(), true, 0
);
}
return new BlackjackState(
game.playerCards().toArray(String[]::new), game.dealerCards().toArray(String[]::new),
sum, calculateSum(game.dealerCards()), game.bet(), false, 0
);
}
public BlackjackState stand(String userId) {
ActiveBlackjack game = activeGames.remove(userId);
if (game == null) return null;
List<String> dealerCards = new ArrayList<>(game.dealerCards());
while (calculateSum(dealerCards) < 16) {
dealerCards.add(drawCard());
}
int playerSum = calculateSum(game.playerCards());
int dealerSum = calculateSum(dealerCards);
long payout;
if (dealerSum > 21 || playerSum > dealerSum) {
payout = game.bet() * 2;
} else {
payout = 0;
}
EconomyAccount account = repository.getAccount(userId);
repository.save(account.withCash(account.getCash() + payout));
return new BlackjackState(
game.playerCards().toArray(String[]::new), dealerCards.toArray(String[]::new),
playerSum, dealerSum, game.bet(), true, payout
);
}
private String drawCard() {
return DECK[ThreadLocalRandom.current().nextInt(DECK.length)];
}
private int calculateSum(List<String> cards) {
int sum = 0;
int aces = 0;
for (String c : cards) {
switch (c) {
case "J", "Q", "K" -> sum += 10;
case "A" -> { sum += 11; aces++; }
default -> sum += Integer.parseInt(c);
}
}
while (sum > 21 && aces > 0) {
sum -= 10;
aces--;
}
return sum;
}
public record BlackjackState(String[] playerCards, String[] dealerCards, int playerSum, int dealerSum, long bet, boolean isOver, long payout) {}
public record BlackjackResult(boolean playerWon, long payout, String reason) {}
}

View file

@ -0,0 +1,118 @@
package creator.loki.feature.economy.service;
import creator.loki.core.security.CooldownManager;
import creator.loki.feature.economy.domain.EconomyAccount;
import creator.loki.feature.economy.repository.EconomyRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.time.LocalDate;
import java.util.List;
public class EconomyService {
private static final Logger logger = LoggerFactory.getLogger(EconomyService.class);
private final EconomyRepository repository;
private final CooldownManager cooldownManager;
public EconomyService(EconomyRepository repository, CooldownManager cooldownManager) {
this.repository = repository;
this.cooldownManager = cooldownManager;
}
public EconomyAccount getAccount(String userId) {
return repository.getAccount(userId);
}
public boolean transfer(String fromId, String toId, long amount) {
if (amount <= 0) return false;
EconomyAccount from = repository.getAccount(fromId);
if (from.getCash() < amount) return false;
EconomyAccount to = repository.getAccount(toId);
repository.save(from.withCash(from.getCash() - amount));
repository.save(to.withCash(to.getCash() + amount));
return true;
}
public boolean deposit(String userId, long amount) {
if (amount <= 0) return false;
EconomyAccount account = repository.getAccount(userId);
if (account.getCash() < amount) return false;
repository.save(account.withCash(account.getCash() - amount).withBank(account.getBank() + amount));
return true;
}
public boolean withdraw(String userId, long amount) {
if (amount <= 0) return false;
EconomyAccount account = repository.getAccount(userId);
if (account.getBank() < amount) return false;
repository.save(account.withBank(account.getBank() - amount).withCash(account.getCash() + amount));
return true;
}
public long earnCash(String userId, long amount) {
EconomyAccount account = repository.getAccount(userId);
EconomyAccount updated = account.withCash(account.getCash() + amount).withAddedEarned(amount);
repository.save(updated);
return updated.getCash();
}
public boolean spendCash(String userId, long amount) {
if (amount <= 0) return false;
EconomyAccount account = repository.getAccount(userId);
if (account.getCash() < amount) return false;
repository.save(account.withCash(account.getCash() - amount).withAddedSpent(amount));
return true;
}
public DailyResult claimDaily(String userId) {
EconomyAccount account = repository.getAccount(userId);
String today = LocalDate.now().toString();
if (today.equals(account.getLastDaily())) return null;
String yesterday = LocalDate.now().minusDays(1).toString();
int streak;
if (yesterday.equals(account.getLastDaily())) {
streak = account.getDailyStreak() + 1;
} else {
streak = 1;
}
long amount = streak >= 7 ? (long) (50 * 1.5) : 50;
long newCash = account.getCash() + amount;
EconomyAccount updated = new EconomyAccount.Builder(account)
.cash(newCash)
.totalEarned(account.getTotalEarned() + amount)
.lastDaily(today)
.dailyStreak(streak)
.build();
repository.save(updated);
return new DailyResult(amount, streak, newCash);
}
public int upgradeFishingLuck(String userId) {
EconomyAccount account = repository.getAccount(userId);
if (account.getFishingLuck() >= 5) return -1;
long cost = 800L * (account.getFishingLuck() + 1);
if (account.getCash() < cost) return -1;
int newLevel = account.getFishingLuck() + 1;
repository.save(account.withCash(account.getCash() - cost).withFishingLuck(newLevel).withAddedSpent(cost));
return newLevel;
}
public int upgradeEfficiency(String userId) {
EconomyAccount account = repository.getAccount(userId);
if (account.getEfficiency() >= 5) return -1;
long cost = 800L * (account.getEfficiency() + 1);
if (account.getCash() < cost) return -1;
int newLevel = account.getEfficiency() + 1;
repository.save(account.withCash(account.getCash() - cost).withEfficiency(newLevel).withAddedSpent(cost));
return newLevel;
}
public List<EconomyAccount> getTopBalances(int limit) {
return repository.findTopByBalance(limit);
}
public record DailyResult(long amount, int streak, long newCash) {}
}

View file

@ -0,0 +1,120 @@
package creator.loki.feature.economy.service;
import creator.loki.feature.economy.domain.EconomyAccount;
import creator.loki.feature.economy.repository.EconomyRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Arrays;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ThreadLocalRandom;
public class FishingService {
private static final Logger logger = LoggerFactory.getLogger(FishingService.class);
private static final String[] FISH = {"\uD83D\uDC1F", "\uD83D\uDC20", "\uD83D\uDC21", "\uD83D\uDC19"};
private static final String WAVE = "\uD83C\uDF0A";
private static final long COOLDOWN_MS = 7200000;
private final EconomyRepository repository;
private final ConcurrentHashMap<String, ActiveFish> activeFishing = new ConcurrentHashMap<>();
public FishingService(EconomyRepository repository) {
this.repository = repository;
}
private record ActiveFish(String targetFish, int correctCount, long startTime, String difficulty, String[] grid) {}
public FishResult startFishing(String userId, String difficulty) {
EconomyAccount account = repository.getAccount(userId);
if (account.getLastFish() != null) {
long elapsed = System.currentTimeMillis() - Long.parseLong(account.getLastFish());
if (elapsed < COOLDOWN_MS) return null;
}
int size;
int fishTypes;
int baseReward;
long timeLimit;
switch (difficulty.toLowerCase()) {
case "easy" -> { size = 5; fishTypes = 2; baseReward = 15; timeLimit = 30000; }
case "normal" -> { size = 7; fishTypes = 3; baseReward = 30; timeLimit = 45000; }
case "hard" -> { size = 9; fishTypes = 4; baseReward = 50; timeLimit = 60000; }
default -> { return null; }
}
String[] available = Arrays.copyOf(FISH, fishTypes);
String[][] grid = new String[size][size];
int[] counts = new int[fishTypes];
var rng = ThreadLocalRandom.current();
for (int r = 0; r < size; r++) {
for (int c = 0; c < size; c++) {
if (rng.nextInt(100) < 40) {
grid[r][c] = WAVE;
} else {
int fi = rng.nextInt(fishTypes);
grid[r][c] = available[fi];
counts[fi]++;
}
}
}
int targetIdx;
do {
targetIdx = rng.nextInt(fishTypes);
} while (counts[targetIdx] == 0);
String targetFish = available[targetIdx];
int correctCount = counts[targetIdx];
String[] rows = new String[size];
for (int r = 0; r < size; r++) {
rows[r] = String.join(" ", grid[r]);
}
ActiveFish session = new ActiveFish(targetFish, correctCount, System.currentTimeMillis(), difficulty, rows);
activeFishing.put(userId, session);
return new FishResult(rows, targetFish, correctCount, 0, account.getCash());
}
public FishResult checkAnswer(String userId, int answer, long startTime, String difficulty) {
ActiveFish session = activeFishing.remove(userId);
if (session == null) return null;
if (session.startTime() != startTime || !session.difficulty().equals(difficulty)) return null;
EconomyAccount account = repository.getAccount(userId);
long elapsed = System.currentTimeMillis() - session.startTime();
int baseReward;
long timeLimit;
switch (difficulty.toLowerCase()) {
case "easy" -> { baseReward = 15; timeLimit = 30000; }
case "normal" -> { baseReward = 30; timeLimit = 45000; }
case "hard" -> { baseReward = 50; timeLimit = 60000; }
default -> { return null; }
}
long reward = 0;
if (answer == session.correctCount() && elapsed <= timeLimit) {
double ratio = (double) elapsed / timeLimit;
double speedMult = ratio < 0.3 ? 2.0 : ratio < 0.5 ? 1.5 : 1.0;
reward = (long) (baseReward * speedMult * (1 + account.getFishingLuck() * 0.01));
}
long newCash = account.getCash() + reward;
EconomyAccount updated = new EconomyAccount.Builder(account)
.cash(newCash)
.totalEarned(account.getTotalEarned() + reward)
.lastFish(String.valueOf(System.currentTimeMillis()))
.build();
repository.save(updated);
return new FishResult(session.grid(), session.targetFish(), session.correctCount(), reward, newCash);
}
public record FishResult(String[] grid, String targetFish, int count, long reward, long cashAfter) {}
}

View file

@ -0,0 +1,91 @@
package creator.loki.feature.economy.service;
import creator.loki.feature.economy.domain.EconomyAccount;
import creator.loki.feature.economy.domain.Job;
import creator.loki.feature.economy.repository.EconomyRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ThreadLocalRandom;
public class JobService {
private static final Logger logger = LoggerFactory.getLogger(JobService.class);
private static final long WORK_COOLDOWN = 21600000;
private final EconomyRepository repository;
public JobService(EconomyRepository repository) {
this.repository = repository;
}
public void setJob(String userId, String jobName) {
Job job = Job.fromName(jobName);
if (job == null) return;
EconomyAccount account = repository.getAccount(userId);
EconomyAccount updated = new EconomyAccount.Builder(account)
.job(job.name())
.build();
repository.save(updated);
}
public WorkResult work(String userId, int userLevel) {
EconomyAccount account = repository.getAccount(userId);
String jobName = account.getJob();
if (jobName == null) return null;
Job job = Job.fromName(jobName);
if (job == null) return null;
if (userLevel < job.getRequiredLevel()) return null;
if (account.getLastWork() != null) {
long elapsed = System.currentTimeMillis() - Long.parseLong(account.getLastWork());
if (elapsed < WORK_COOLDOWN) return null;
}
var rng = ThreadLocalRandom.current();
long basePayout = rng.nextLong(job.getMinPayout(), job.getMaxPayout() + 1);
long amount = (long) (basePayout * (1 + account.getEfficiency() * 0.01));
long newCash = account.getCash() + amount;
EconomyAccount updated = new EconomyAccount.Builder(account)
.cash(newCash)
.totalEarned(account.getTotalEarned() + amount)
.lastWork(String.valueOf(System.currentTimeMillis()))
.build();
repository.save(updated);
return new WorkResult(amount, newCash);
}
public List<JobInfo> listJobs() {
List<JobInfo> infos = new ArrayList<>();
for (Job job : Job.values()) {
infos.add(new JobInfo(
job.name(),
job.getDisplayName(),
job.getRequiredLevel(),
job.getMinPayout(),
job.getMaxPayout(),
job.getEmoji()
));
}
return infos;
}
public String getCurrentJob(String userId) {
EconomyAccount account = repository.getAccount(userId);
String jobName = account.getJob();
if (jobName == null) return null;
Job job = Job.fromName(jobName);
return job != null ? job.getDisplayName() : null;
}
public record WorkResult(long amount, long newCash) {}
public record JobInfo(String name, String displayName, int requiredLevel, int minPayout, int maxPayout, String emoji) {}
}

Some files were not shown because too many files have changed in this diff Show more