Refactor: remove TTS system, slash commands folder, reorganize stats leaderboard

This commit is contained in:
loki5512344 2026-05-11 14:12:30 +02:00
parent f43db6a527
commit 64a8928d41
29 changed files with 313 additions and 1764 deletions

309
TODO.md
View file

@ -1,6 +1,313 @@
# TODO - План реализации функций # TODO - План реализации функций
## 🎯 Основные задачи ## Правила разработки
### Архитектура и код:
- 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. Удалить ненужное**
- [x] Удалить `command/slash/` (дубликаты)
- [x] Удалить `audio/` (TTS не работает)
- [x] Удалить `TtsCommand.java`
- [ ] Удалить `SwapCommand.java`
- [ ] Удалить все импорты AudioManager
**2. Реорганизовать util/ (мусорная корзина)**
- [ ] Создать `core/security/`
- [ ] Переместить `CooldownManager` → `core/security/`
- [ ] Переместить `GlobalRateLimiter` → `core/security/`
- [ ] Создать `integration/nekos/`
- [ ] Переместить `NekosLifeApi` → `integration/nekos/`
- [ ] Удалить пустую папку `util/`
**3. Создать feature/level/ (система уровней)**
- [ ] Создать `feature/level/`
- [ ] Создать `feature/level/domain/` для доменных объектов
- [ ] Создать `feature/level/service/` для сервисов
- [ ] Создать `feature/level/repository/` для интерфейсов
- [ ] **Рефакторинг LevelSystem (God Class 5/10):**
- [ ] Вынести `LevelUpResult` в `feature/level/domain/LevelUpResult.java`
- [ ] Вынести `LevelProgress` в `feature/level/domain/LevelProgress.java`
- [ ] Создать `feature/level/domain/XpFormula.java` (инкапсуляция математики)
- [ ] Создать `feature/level/repository/UserStatsRepository.java` (интерфейс)
- [ ] Создать `feature/level/repository/JsonUserStatsRepository.java` (реализация)
- [ ] Создать `feature/level/service/LeaderboardService.java` (топы и агрегации)
- [ ] Исправить формулу: 100 vs 200 (рассогласование в коде)
- [ ] Убрать `messageTimestamps` ConcurrentHashMap, использовать `UserStats.lastMessageTime`
- [ ] Рефакторить `LevelSystem` → `feature/level/service/LevelService.java`
- [ ] **Рефакторинг UserStats (анемичная модель 6/10):**
- [ ] Сделать immutable (убрать сеттеры)
- [ ] Добавить методы `addXP()`, `recordMessage()`, `addVoiceTime()`
- [ ] Добавить `getLevel(XpFormula)` вместо внешних вычислений
- [ ] Добавить `equals()` и `hashCode()`
- [ ] Переименовать `incrementMessages()` → `recordMessage(long timestamp)`
- [ ] Переместить `level/LevelSystem` → `feature/level/service/LevelService.java`
- [ ] Переместить `level/UserStats` → `feature/level/domain/UserStats.java`
- [ ] Переместить `profile/LevelUtils` → `feature/level/` (если нужен)
- [ ] Переместить `command/impl/stats/RankCommand` → `feature/level/`
- [ ] Переместить `command/impl/stats/StatsCommand` → `feature/level/`
- [ ] Переместить `listener/LevelUpNotifier` → `feature/level/`
- [ ] Переместить `listener/VoiceTracker` → `feature/level/`
- [ ] Удалить пустую папку `level/`
**4. Создать feature/voice/ (голосовые каналы)**
- [ ] Создать `feature/voice/`
- [ ] Переместить `voice/*` → `feature/voice/`
- [ ] Переместить `command/impl/setup/VoiceSetupCommand` → `feature/voice/`
- [ ] Переместить `listener/VoiceChannelListener` → `feature/voice/`
- [ ] Переместить `listener/VoiceButtonHandler` → `feature/voice/`
- [ ] Удалить пустую папку `voice/`
**5. Создать feature/counter/ (счетчик)**
- [ ] Создать `feature/counter/`
- [ ] Переместить `counter/*` → `feature/counter/`
- [ ] Переместить `command/impl/setup/CounterSetupCommand` → `feature/counter/`
- [ ] Переместить `listener/MemberCounterListener` → `feature/counter/`
- [ ] Удалить пустую папку `counter/`
**6. Создать feature/role/ (кастомные роли)**
- [ ] Создать `feature/role/`
- [ ] Переместить `role/CustomRoleManager` → `feature/role/`
- [ ] Переместить `command/impl/roles/*` → `feature/role/`
- [ ] Удалить пустые папки `role/` и `command/impl/roles/`
#### Средний приоритет (потом):
**7. Создать feature/reputation/**
- [ ] Создать `feature/reputation/`
- [ ] Создать `ReputationService` (вынести логику из команд)
- [ ] Переместить `command/impl/stats/RepCommand` → `feature/reputation/`
- [ ] Переместить `command/impl/stats/MinusRepCommand` → `feature/reputation/`
- [ ] Переместить `command/impl/stats/RepTopCommand` → `feature/reputation/`
**8. Создать feature/leaderboard/**
- [ ] Создать `feature/leaderboard/`
- [ ] Переместить `command/impl/stats/leaderboard/*` → `feature/leaderboard/`
- [ ] Создать `LeaderboardService`
- [ ] Удалить пустую папку `command/impl/stats/`
**9. Реорганизовать profile/**
- [ ] Создать `feature/profile/`
- [ ] Переместить `profile/*` → `feature/profile/`
- [ ] Переместить `command/impl/utility/ProfileCommand` → `feature/profile/`
- [ ] Создать `ProfileEmbedFactory` (вынести из команды)
- [ ] Удалить пустую папку `profile/`
**10. Реорганизовать event/ (listener)**
- [ ] Создать `event/`
- [ ] Переместить `listener/ReadyListener` → `event/`
- [ ] Переместить `listener/MessageListener` → `event/`
- [ ] Переместить `listener/SlashCommandListener` → `event/`
- [ ] Переместить `listener/HelpMenuHandler` → `event/`
- [ ] Переместить `listener/ModerationLogger` → `event/`
- [ ] Удалить пустую папку `listener/`
**11. Реорганизовать 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/`
- [ ] Удалить пустую папку `exception/`
**12. Реорганизовать command/**
- [ ] Переименовать `command/impl/entertainment/interaction/` → `command/entertainment/social/`
- [ ] Переместить `command/impl/utility/MetricsCommand` → `command/system/`
- [ ] Переместить `command/impl/setup/LogSetupCommand` → `event/` (или удалить setup/)
#### Низкий приоритет (опционально):
**13. Создать repository/ (единый слой данных)**
- [ ] Создать `repository/`
- [ ] Создать интерфейсы `UserRepository`, `GuildConfigRepository`
- [ ] Создать `repository/impl/`
- [ ] Рефакторить `data/JsonDataStore` → `repository/impl/`
- [ ] Рефакторить `database/ConfigRepository` → `repository/impl/`
**14. Обновить документацию**
- [ ] Обновить FEATURES.md (убрать TTS)
- [ ] Обновить README.md (новая структура)
- [ ] Добавить ARCHITECTURE.md (описание доменов)
---
## Основные задачи
### 1. ⚡ Переделать систему опыта (XP System Rework) ### 1. ⚡ Переделать систему опыта (XP System Rework)

View file

@ -1,163 +0,0 @@
package creator.loki.audio;
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager;
import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager;
import com.sedmelluq.discord.lavaplayer.source.AudioSourceManagers;
import com.sedmelluq.discord.lavaplayer.track.playback.NonAllocatingAudioFrameBuffer;
import net.dv8tion.jda.api.entities.Guild;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class AudioManager {
private static final Logger logger = LoggerFactory.getLogger(AudioManager.class);
private static final long CLEANUP_INTERVAL_MINUTES = 10;
private static final long INACTIVE_THRESHOLD_MINUTES = 30;
private final AudioPlayerManager playerManager;
private final Map<Long, GuildMusicManager> musicManagers;
private final Map<Long, Long> lastActivityTime; // guildId -> timestamp
private final Map<Long, TtsQueue> ttsQueues; // guildId -> TtsQueue
private final ScheduledExecutorService cleanupScheduler;
public AudioManager() {
this.musicManagers = new ConcurrentHashMap<>();
this.lastActivityTime = new ConcurrentHashMap<>();
this.ttsQueues = new ConcurrentHashMap<>();
this.playerManager = new DefaultAudioPlayerManager();
this.playerManager.getConfiguration().setFrameBufferFactory(NonAllocatingAudioFrameBuffer::new);
AudioSourceManagers.registerRemoteSources(playerManager);
AudioSourceManagers.registerLocalSource(playerManager);
// Запускаем периодическую очистку неактивных менеджеров
this.cleanupScheduler = Executors.newSingleThreadScheduledExecutor();
this.cleanupScheduler.scheduleAtFixedRate(
this::cleanupInactiveManagers,
CLEANUP_INTERVAL_MINUTES,
CLEANUP_INTERVAL_MINUTES,
TimeUnit.MINUTES
);
logger.info("AudioManager инициализирован с автоочисткой каждые {} минут", CLEANUP_INTERVAL_MINUTES);
}
public synchronized GuildMusicManager getGuildAudioPlayer(Guild guild) {
long guildId = guild.getIdLong();
GuildMusicManager musicManager = musicManagers.get(guildId);
if (musicManager == null) {
musicManager = new GuildMusicManager(playerManager);
musicManagers.put(guildId, musicManager);
logger.debug("Создан новый GuildMusicManager для гильдии: {}", guild.getName());
}
// Обновляем время последней активности
lastActivityTime.put(guildId, System.currentTimeMillis());
guild.getAudioManager().setSendingHandler(musicManager.getSendHandler());
return musicManager;
}
/**
* Очищает неактивные GuildMusicManager
*/
private void cleanupInactiveManagers() {
long currentTime = System.currentTimeMillis();
long thresholdTime = currentTime - TimeUnit.MINUTES.toMillis(INACTIVE_THRESHOLD_MINUTES);
musicManagers.entrySet().removeIf(entry -> {
long guildId = entry.getKey();
Long lastActivity = lastActivityTime.get(guildId);
if (lastActivity == null || lastActivity < thresholdTime) {
GuildMusicManager manager = entry.getValue();
// Проверяем, что плеер не активен
if (manager.getPlayer().getPlayingTrack() == null) {
logger.info("Удаление неактивного GuildMusicManager для гильдии ID: {}", guildId);
manager.getPlayer().destroy();
lastActivityTime.remove(guildId);
return true;
}
}
return false;
});
}
/**
* Принудительно удаляет GuildMusicManager для гильдии
*/
public synchronized void removeGuildAudioPlayer(long guildId) {
GuildMusicManager manager = musicManagers.remove(guildId);
if (manager != null) {
manager.getPlayer().destroy();
lastActivityTime.remove(guildId);
logger.debug("GuildMusicManager удалён для гильдии ID: {}", guildId);
}
}
/**
* Получить или создать TtsQueue для гильдии
*/
public TtsQueue getTtsQueue(Guild guild) {
long guildId = guild.getIdLong();
return ttsQueues.computeIfAbsent(guildId, id -> {
logger.info("Создана новая TtsQueue для гильдии: {}", guild.getName());
return new TtsQueue();
});
}
/**
* Добавить TTS запрос в очередь
*/
public void enqueueTts(Guild guild, String text, net.dv8tion.jda.api.entities.channel.middleman.MessageChannel channel) {
GuildMusicManager musicManager = getGuildAudioPlayer(guild);
TtsQueue queue = getTtsQueue(guild);
TtsRequest request = new TtsRequest(this, musicManager, guild, text, channel);
queue.enqueue(request);
logger.info("TTS запрос добавлен для гильдии: {} (очередь: {})",
guild.getName(), queue.getQueueSize());
}
public AudioPlayerManager getPlayerManager() {
return playerManager;
}
/**
* Завершает работу AudioManager
*/
public void shutdown() {
logger.info("Завершение работы AudioManager...");
// Останавливаем все TTS очереди
ttsQueues.values().forEach(TtsQueue::shutdown);
ttsQueues.clear();
// Останавливаем scheduler
cleanupScheduler.shutdown();
try {
if (!cleanupScheduler.awaitTermination(5, TimeUnit.SECONDS)) {
cleanupScheduler.shutdownNow();
}
} catch (InterruptedException e) {
cleanupScheduler.shutdownNow();
Thread.currentThread().interrupt();
}
// Уничтожаем все плееры
musicManagers.values().forEach(manager -> manager.getPlayer().destroy());
musicManagers.clear();
lastActivityTime.clear();
logger.info("AudioManager завершён");
}
}

View file

@ -1,36 +0,0 @@
package creator.loki.audio;
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.track.playback.MutableAudioFrame;
import net.dv8tion.jda.api.audio.AudioSendHandler;
import java.nio.ByteBuffer;
public class AudioPlayerSendHandler implements AudioSendHandler {
private final AudioPlayer audioPlayer;
private final ByteBuffer buffer;
private final MutableAudioFrame frame;
public AudioPlayerSendHandler(AudioPlayer audioPlayer) {
this.audioPlayer = audioPlayer;
this.buffer = ByteBuffer.allocate(1024);
this.frame = new MutableAudioFrame();
this.frame.setBuffer(buffer);
}
@Override
public boolean canProvide() {
return audioPlayer.provide(frame);
}
@Override
public ByteBuffer provide20MsAudio() {
buffer.flip();
return buffer;
}
@Override
public boolean isOpus() {
return true;
}
}

View file

@ -1,52 +0,0 @@
package creator.loki.audio;
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager;
import com.sedmelluq.discord.lavaplayer.player.event.AudioEventAdapter;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason;
import net.dv8tion.jda.api.entities.Guild;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class GuildMusicManager {
private static final Logger logger = LoggerFactory.getLogger(GuildMusicManager.class);
private final AudioPlayer player;
private final AudioPlayerSendHandler sendHandler;
private Guild guild;
private boolean autoDisconnectEnabled = false;
public GuildMusicManager(AudioPlayerManager manager) {
this.player = manager.createPlayer();
this.sendHandler = new AudioPlayerSendHandler(player);
}
public void setGuild(Guild guild) {
this.guild = guild;
}
public void enableAutoDisconnect() {
if (autoDisconnectEnabled) {
return; // Уже включено
}
autoDisconnectEnabled = true;
player.addListener(new AudioEventAdapter() {
@Override
public void onTrackEnd(AudioPlayer player, AudioTrack track, AudioTrackEndReason endReason) {
if (endReason.mayStartNext && guild != null) {
logger.info("TTS трек завершён, отключаемся от голосового канала");
guild.getAudioManager().closeAudioConnection();
}
}
});
}
public AudioPlayer getPlayer() {
return player;
}
public AudioPlayerSendHandler getSendHandler() {
return sendHandler;
}
}

View file

@ -1,61 +0,0 @@
package creator.loki.audio;
import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler;
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
public class TtsAudioSource {
private static final Logger logger = LoggerFactory.getLogger(TtsAudioSource.class);
private static final String TTS_API = "https://translate.google.com/translate_tts";
public static void playTts(AudioManager audioManager, GuildMusicManager musicManager,
String text, MessageChannel channel) {
try {
// Генерируем URL для TTS
String encodedText = URLEncoder.encode(text, StandardCharsets.UTF_8);
String ttsUrl = TTS_API + "?ie=UTF-8&client=tw-ob&tl=ru&q=" + encodedText;
logger.info("Загрузка TTS: {}", ttsUrl);
// Загружаем напрямую из URL
audioManager.getPlayerManager().loadItem(ttsUrl, new AudioLoadResultHandler() {
@Override
public void trackLoaded(AudioTrack track) {
logger.info("TTS трек загружен успешно");
musicManager.getPlayer().playTrack(track);
}
@Override
public void playlistLoaded(AudioPlaylist playlist) {
logger.info("TTS плейлист загружен");
if (!playlist.getTracks().isEmpty()) {
trackLoaded(playlist.getTracks().get(0));
}
}
@Override
public void noMatches() {
logger.error("TTS: Не найдено совпадений");
channel.sendMessage("❌ Не удалось загрузить TTS").queue();
}
@Override
public void loadFailed(FriendlyException exception) {
logger.error("TTS: Ошибка загрузки", exception);
channel.sendMessage("❌ Ошибка TTS: " + exception.getMessage()).queue();
}
});
} catch (Exception e) {
logger.error("TTS: Ошибка генерации", e);
channel.sendMessage("❌ Ошибка генерации TTS: " + e.getMessage()).queue();
}
}
}

View file

@ -1,176 +0,0 @@
package creator.loki.audio;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.concurrent.*;
/**
* Очередь TTS запросов с автоматическим отключением через 30 секунд бездействия
*/
public class TtsQueue {
private static final Logger logger = LoggerFactory.getLogger(TtsQueue.class);
private static final int DISCONNECT_DELAY_SECONDS = 30;
private final BlockingQueue<TtsRequest> queue = new LinkedBlockingQueue<>();
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
private final ExecutorService processor = Executors.newSingleThreadExecutor();
private ScheduledFuture<?> disconnectTask;
private volatile boolean isProcessing = false;
private volatile boolean isShutdown = false;
public TtsQueue() {
startProcessor();
}
/**
* Добавить TTS запрос в очередь
*/
public void enqueue(TtsRequest request) {
if (isShutdown) {
logger.warn("TtsQueue уже завершена, запрос отклонён");
return;
}
queue.offer(request);
cancelDisconnectTimer();
logger.info("TTS запрос добавлен в очередь. Размер очереди: {}", queue.size());
}
/**
* Запустить обработчик очереди
*/
private void startProcessor() {
processor.submit(() -> {
while (!isShutdown) {
try {
TtsRequest request = queue.poll(1, TimeUnit.SECONDS);
if (request != null) {
processRequest(request);
} else if (!isProcessing && queue.isEmpty()) {
scheduleDisconnect(request != null ? request : getLastRequest());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
} catch (Exception e) {
logger.error("Ошибка обработки TTS запроса", e);
}
}
});
}
/**
* Обработать один TTS запрос
*/
private void processRequest(TtsRequest request) {
isProcessing = true;
cancelDisconnectTimer();
try {
logger.info("Обработка TTS: {}", request.getText());
request.process();
// Ждём завершения воспроизведения
waitForPlaybackEnd(request);
} catch (Exception e) {
logger.error("Ошибка воспроизведения TTS", e);
} finally {
isProcessing = false;
}
}
/**
* Ждать завершения воспроизведения
*/
private void waitForPlaybackEnd(TtsRequest request) {
try {
// Ждём максимум 30 секунд
for (int i = 0; i < 30; i++) {
if (!request.isPlaying()) {
break;
}
Thread.sleep(1000);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
/**
* Запланировать отключение через 30 секунд
*/
private void scheduleDisconnect(TtsRequest lastRequest) {
if (disconnectTask != null && !disconnectTask.isDone()) {
return; // Уже запланировано
}
disconnectTask = scheduler.schedule(() -> {
if (queue.isEmpty() && !isProcessing && lastRequest != null) {
logger.info("30 секунд бездействия, отключаемся от голосового канала");
lastRequest.disconnect();
}
}, DISCONNECT_DELAY_SECONDS, TimeUnit.SECONDS);
logger.info("Запланировано отключение через {} секунд", DISCONNECT_DELAY_SECONDS);
}
/**
* Отменить таймер отключения
*/
private void cancelDisconnectTimer() {
if (disconnectTask != null && !disconnectTask.isDone()) {
disconnectTask.cancel(false);
logger.debug("Таймер отключения отменён");
}
}
/**
* Получить последний запрос (для отключения)
*/
private TtsRequest getLastRequest() {
return queue.peek();
}
/**
* Завершить работу очереди
*/
public void shutdown() {
isShutdown = true;
cancelDisconnectTimer();
scheduler.shutdown();
processor.shutdown();
try {
if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
scheduler.shutdownNow();
}
if (!processor.awaitTermination(5, TimeUnit.SECONDS)) {
processor.shutdownNow();
}
} catch (InterruptedException e) {
scheduler.shutdownNow();
processor.shutdownNow();
Thread.currentThread().interrupt();
}
logger.info("TtsQueue завершена");
}
/**
* Получить размер очереди
*/
public int getQueueSize() {
return queue.size();
}
/**
* Проверить, обрабатывается ли запрос
*/
public boolean isProcessing() {
return isProcessing;
}
}

View file

@ -1,158 +0,0 @@
package creator.loki.audio;
import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler;
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
/**
* Один TTS запрос
*/
public class TtsRequest {
private static final Logger logger = LoggerFactory.getLogger(TtsRequest.class);
private static final String TTS_API = "https://translate.google.com/translate_tts";
private final AudioManager audioManager;
private final GuildMusicManager musicManager;
private final Guild guild;
private final String text;
private final MessageChannel channel;
private final CountDownLatch loadLatch = new CountDownLatch(1);
private volatile boolean isPlaying = false;
private volatile AudioTrack currentTrack;
public TtsRequest(AudioManager audioManager, GuildMusicManager musicManager,
Guild guild, String text, MessageChannel channel) {
this.audioManager = audioManager;
this.musicManager = musicManager;
this.guild = guild;
this.text = text;
this.channel = channel;
}
/**
* Обработать запрос (загрузить и воспроизвести)
*/
public void process() {
try {
String encodedText = URLEncoder.encode(text, StandardCharsets.UTF_8);
String ttsUrl = TTS_API + "?ie=UTF-8&client=tw-ob&tl=ru&q=" + encodedText;
logger.info("Загрузка TTS: {}", ttsUrl);
audioManager.getPlayerManager().loadItem(ttsUrl, new AudioLoadResultHandler() {
@Override
public void trackLoaded(AudioTrack track) {
logger.info("TTS трек загружен: {}", text);
currentTrack = track;
isPlaying = true;
AudioPlayer player = musicManager.getPlayer();
player.playTrack(track);
loadLatch.countDown();
}
@Override
public void playlistLoaded(AudioPlaylist playlist) {
if (!playlist.getTracks().isEmpty()) {
trackLoaded(playlist.getTracks().get(0));
} else {
loadFailed(new FriendlyException("Плейлист пуст",
FriendlyException.Severity.COMMON, null));
}
}
@Override
public void noMatches() {
logger.error("TTS: Не найдено совпадений для '{}'", text);
channel.sendMessage("❌ Не удалось загрузить TTS").queue();
loadLatch.countDown();
}
@Override
public void loadFailed(FriendlyException exception) {
logger.error("TTS: Ошибка загрузки '{}'", text, exception);
channel.sendMessage("❌ Ошибка TTS: " + exception.getMessage()).queue();
loadLatch.countDown();
}
});
// Ждём загрузки (максимум 10 секунд)
if (!loadLatch.await(10, TimeUnit.SECONDS)) {
logger.warn("TTS: Таймаут загрузки для '{}'", text);
return;
}
// Ждём завершения воспроизведения
waitForPlaybackEnd();
} catch (Exception e) {
logger.error("TTS: Ошибка обработки '{}'", text, e);
channel.sendMessage("❌ Ошибка TTS: " + e.getMessage()).queue();
}
}
/**
* Ждать завершения воспроизведения
*/
private void waitForPlaybackEnd() {
AudioPlayer player = musicManager.getPlayer();
// Ждём максимум 30 секунд
for (int i = 0; i < 30; i++) {
AudioTrack playingTrack = player.getPlayingTrack();
if (playingTrack == null || playingTrack != currentTrack) {
isPlaying = false;
logger.info("TTS воспроизведение завершено: {}", text);
break;
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
isPlaying = false;
}
/**
* Отключиться от голосового канала
*/
public void disconnect() {
if (guild != null) {
guild.getAudioManager().closeAudioConnection();
logger.info("Отключились от голосового канала в гильдии: {}", guild.getName());
}
}
/**
* Проверить, воспроизводится ли трек
*/
public boolean isPlaying() {
return isPlaying;
}
public String getText() {
return text;
}
public Guild getGuild() {
return guild;
}
}

View file

@ -1,4 +1,4 @@
package creator.loki.command.impl.stats; package creator.loki.command.impl.stats.leaderboard;
import creator.loki.level.LevelSystem; import creator.loki.level.LevelSystem;
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;

View file

@ -1,4 +1,4 @@
package creator.loki.command.impl.stats; package creator.loki.command.impl.stats.leaderboard;
import net.dv8tion.jda.api.interactions.components.buttons.Button; import net.dv8tion.jda.api.interactions.components.buttons.Button;

View file

@ -1,4 +1,4 @@
package creator.loki.command.impl.stats; package creator.loki.command.impl.stats.leaderboard;
import creator.loki.embed.EmbedFactory; import creator.loki.embed.EmbedFactory;
import creator.loki.level.UserStats; import creator.loki.level.UserStats;

View file

@ -1,4 +1,4 @@
package creator.loki.command.impl.stats; package creator.loki.command.impl.stats.leaderboard;
import creator.loki.command.Command; import creator.loki.command.Command;
import creator.loki.command.CommandContext; import creator.loki.command.CommandContext;

View file

@ -1,106 +0,0 @@
package creator.loki.command.impl.utility;
import creator.loki.audio.AudioManager;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedTemplates;
import creator.loki.util.CooldownManager;
import net.dv8tion.jda.api.entities.GuildVoiceState;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
public class TtsCommand implements Command {
private final CooldownManager cooldownManager;
private final AudioManager audioManager;
public TtsCommand(CooldownManager cooldownManager, AudioManager audioManager) {
this.cooldownManager = cooldownManager;
this.audioManager = audioManager;
}
@Override
public String getName() {
return "tts";
}
@Override
public String getDescription() {
return "Бот заходит в голосовой канал и произносит текст";
}
@Override
public String getUsage() {
return "tts <текст>";
}
@Override
public String getCategory() {
return "Развлечения";
}
@Override
public void execute(CommandContext context) {
if (context.getGuild() == null) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Команда доступна только на сервере")
).queue();
return;
}
if (context.getArgs().isEmpty()) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Укажи текст для TTS")
).queue();
return;
}
Member member = context.getAuthor();
if (member == null) return;
// Проверка cooldown (15 секунд)
if (cooldownManager.isOnCooldown(member.getId(), "tts")) {
long remaining = cooldownManager.getRemainingSeconds(member.getId(), "tts");
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error(
"Подожди!",
"Ты сможешь использовать TTS через " + remaining + " сек."
)
).queue();
return;
}
// Проверка, что пользователь в голосовом канале
GuildVoiceState voiceState = member.getVoiceState();
if (voiceState == null || !voiceState.inAudioChannel()) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Ошибка", "Ты должен быть в голосовом канале")
).queue();
return;
}
VoiceChannel voiceChannel = voiceState.getChannel().asVoiceChannel();
String text = String.join(" ", context.getArgs());
if (text.length() > 200) {
context.getChannel().sendMessageEmbeds(
EmbedTemplates.error("Слишком длинно", "Максимум 200 символов")
).queue();
return;
}
// Подключаемся к голосовому каналу (если ещё не подключены)
net.dv8tion.jda.api.managers.AudioManager audioManagerJda = context.getGuild().getAudioManager();
if (!audioManagerJda.isConnected()) {
audioManagerJda.openAudioConnection(voiceChannel);
}
// Добавляем TTS в очередь
audioManager.enqueueTts(context.getGuild(), text, context.getChannel());
cooldownManager.applyCooldown(member.getId(), "tts", 15);
context.getChannel().sendMessageEmbeds(
EmbedTemplates.success("TTS", "🔊 Добавлено в очередь: " + text)
).queue();
}
}

View file

@ -1,50 +0,0 @@
package creator.loki.command.slash;
import creator.loki.command.SlashCommand;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.entities.User;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
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.CommandData;
import java.awt.Color;
/**
* Slash команда /avatar
*/
public class AvatarSlashCommand implements SlashCommand {
@Override
public void execute(SlashCommandInteractionEvent event) {
User targetUser = event.getOption("user") != null
? event.getOption("user").getAsUser()
: event.getUser();
String avatarUrl = targetUser.getEffectiveAvatarUrl() + "?size=512";
EmbedBuilder embed = new EmbedBuilder()
.setTitle("🖼️ Аватар " + targetUser.getName())
.setImage(avatarUrl)
.setColor(new Color(88, 101, 242))
.setFooter("Запросил: " + event.getUser().getName());
event.replyEmbeds(embed.build()).queue();
}
@Override
public CommandData getCommandData() {
return Commands.slash("avatar", "Показывает аватар пользователя")
.addOption(OptionType.USER, "user", "Пользователь (по умолчанию - вы)", false);
}
@Override
public String getName() {
return "avatar";
}
@Override
public String getDescription() {
return "Показывает аватар пользователя";
}
}

View file

@ -1,103 +0,0 @@
package creator.loki.command.slash;
import creator.loki.command.SlashCommand;
import creator.loki.embed.EmbedTemplates;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
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.CommandData;
public class BanSlashCommand implements SlashCommand {
@Override
public void execute(SlashCommandInteractionEvent event) {
if (event.getGuild() == null) {
event.replyEmbeds(EmbedTemplates.error("Ошибка", "Команда доступна только на сервере")).setEphemeral(true).queue();
return;
}
Member author = event.getMember();
if (author == null || !author.hasPermission(Permission.BAN_MEMBERS)) {
event.replyEmbeds(EmbedTemplates.error("Недостаточно прав", "У вас нет прав на бан пользователей")).setEphemeral(true).queue();
return;
}
if (!event.getGuild().getSelfMember().hasPermission(Permission.BAN_MEMBERS)) {
event.replyEmbeds(EmbedTemplates.error("Недостаточно прав", "У бота нет прав на бан пользователей")).setEphemeral(true).queue();
return;
}
Member target = event.getOption("user").getAsMember();
if (target == null) {
event.replyEmbeds(EmbedTemplates.error("Ошибка", "Пользователь не найден")).setEphemeral(true).queue();
return;
}
if (target.getId().equals(author.getId())) {
event.replyEmbeds(EmbedTemplates.error("Ошибка", "Вы не можете забанить самого себя")).setEphemeral(true).queue();
return;
}
if (target.getUser().isBot() && target.getId().equals(event.getGuild().getSelfMember().getId())) {
event.replyEmbeds(EmbedTemplates.error("Ошибка", "Я не могу забанить самого себя")).setEphemeral(true).queue();
return;
}
if (!author.canInteract(target)) {
event.replyEmbeds(EmbedTemplates.error("Недостаточно прав", "Вы не можете забанить этого пользователя")).setEphemeral(true).queue();
return;
}
if (!event.getGuild().getSelfMember().canInteract(target)) {
event.replyEmbeds(EmbedTemplates.error("Недостаточно прав", "Я не могу забанить этого пользователя")).setEphemeral(true).queue();
return;
}
String reason = event.getOption("reason") != null
? event.getOption("reason").getAsString()
: "Причина не указана";
int deleteDays = event.getOption("delete_days") != null
? event.getOption("delete_days").getAsInt()
: 0;
event.deferReply().queue();
event.getGuild().ban(target, deleteDays, java.util.concurrent.TimeUnit.DAYS)
.reason(reason)
.queue(
success -> event.getHook().editOriginalEmbeds(
EmbedTemplates.success(
"Пользователь забанен",
"**Пользователь:** " + target.getUser().getAsTag() + "\n" +
"**Причина:** " + reason + "\n" +
"**Удалено сообщений за дней:** " + deleteDays + "\n" +
"**Модератор:** " + author.getAsMention()
)
).queue(),
error -> event.getHook().editOriginalEmbeds(
EmbedTemplates.error("Ошибка", "Не удалось забанить: " + error.getMessage())
).queue()
);
}
@Override
public CommandData getCommandData() {
return Commands.slash("ban", "Банит пользователя на сервере")
.addOption(OptionType.USER, "user", "Пользователь для бана", true)
.addOption(OptionType.STRING, "reason", "Причина бана", false)
.addOption(OptionType.INTEGER, "delete_days", "Удалить сообщения за N дней (0-7)", false);
}
@Override
public String getName() {
return "ban";
}
@Override
public String getDescription() {
return "Банит пользователя на сервере";
}
}

View file

@ -1,74 +0,0 @@
package creator.loki.command.slash;
import creator.loki.command.SlashCommand;
import creator.loki.embed.EmbedTemplates;
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.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.Commands;
import net.dv8tion.jda.api.interactions.commands.build.CommandData;
public class ClearSlashCommand implements SlashCommand {
@Override
public void execute(SlashCommandInteractionEvent event) {
if (event.getGuild() == null) {
event.replyEmbeds(EmbedTemplates.error("Ошибка", "Команда доступна только на сервере")).setEphemeral(true).queue();
return;
}
Member author = event.getMember();
if (author == null || !author.hasPermission(Permission.MESSAGE_MANAGE)) {
event.replyEmbeds(EmbedTemplates.error("Недостаточно прав", "У вас нет прав на управление сообщениями")).setEphemeral(true).queue();
return;
}
if (!event.getGuild().getSelfMember().hasPermission(Permission.MESSAGE_MANAGE)) {
event.replyEmbeds(EmbedTemplates.error("Недостаточно прав", "У бота нет прав на управление сообщениями")).setEphemeral(true).queue();
return;
}
int amount = event.getOption("amount").getAsInt();
if (amount < 1 || amount > 100) {
event.replyEmbeds(EmbedTemplates.error("Ошибка", "Количество должно быть от 1 до 100")).setEphemeral(true).queue();
return;
}
event.deferReply(true).queue();
TextChannel channel = event.getChannel().asTextChannel();
channel.getHistory().retrievePast(amount).queue(
messages -> {
channel.purgeMessages(messages);
event.getHook().editOriginalEmbeds(
EmbedTemplates.success(
"Сообщения удалены",
"Удалено сообщений: " + messages.size()
)
).queue();
},
error -> event.getHook().editOriginalEmbeds(
EmbedTemplates.error("Ошибка", "Не удалось удалить сообщения: " + error.getMessage())
).queue()
);
}
@Override
public CommandData getCommandData() {
return Commands.slash("clear", "Удаляет указанное количество сообщений")
.addOption(OptionType.INTEGER, "amount", "Количество сообщений (1-100)", true);
}
@Override
public String getName() {
return "clear";
}
@Override
public String getDescription() {
return "Удаляет указанное количество сообщений";
}
}

View file

@ -1,42 +0,0 @@
package creator.loki.command.slash;
import creator.loki.command.SlashCommand;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.interactions.commands.build.Commands;
import net.dv8tion.jda.api.interactions.commands.build.CommandData;
import java.awt.Color;
import java.util.Random;
public class CoinSlashCommand implements SlashCommand {
private static final Random random = new Random();
@Override
public void execute(SlashCommandInteractionEvent event) {
boolean isHeads = random.nextBoolean();
String result = isHeads ? "Орёл 🦅" : "Решка 🪙";
EmbedBuilder embed = new EmbedBuilder()
.setTitle("🪙 Подбрасывание монетки")
.setDescription("Результат: **" + result + "**")
.setColor(isHeads ? new Color(255, 215, 0) : new Color(192, 192, 192));
event.replyEmbeds(embed.build()).queue();
}
@Override
public CommandData getCommandData() {
return Commands.slash("coin", "Подбросить монетку");
}
@Override
public String getName() {
return "coin";
}
@Override
public String getDescription() {
return "Подбросить монетку";
}
}

View file

@ -1,54 +0,0 @@
package creator.loki.command.slash;
import creator.loki.command.SlashCommand;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.interactions.commands.build.Commands;
import net.dv8tion.jda.api.interactions.commands.build.CommandData;
import java.awt.Color;
import java.util.Random;
public class DiceSlashCommand implements SlashCommand {
private static final Random random = new Random();
@Override
public void execute(SlashCommandInteractionEvent event) {
int result = random.nextInt(6) + 1;
String emoji = getDiceEmoji(result);
EmbedBuilder embed = new EmbedBuilder()
.setTitle("🎲 Бросок кубика")
.setDescription("Выпало: **" + result + "** " + emoji)
.setColor(new Color(88, 101, 242));
event.replyEmbeds(embed.build()).queue();
}
private String getDiceEmoji(int number) {
return switch (number) {
case 1 -> "⚀";
case 2 -> "⚁";
case 3 -> "⚂";
case 4 -> "⚃";
case 5 -> "⚄";
case 6 -> "⚅";
default -> "🎲";
};
}
@Override
public CommandData getCommandData() {
return Commands.slash("dice", "Бросить кубик");
}
@Override
public String getName() {
return "dice";
}
@Override
public String getDescription() {
return "Бросить кубик";
}
}

View file

@ -1,73 +0,0 @@
package creator.loki.command.slash;
import creator.loki.command.SlashCommand;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
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.CommandData;
import java.awt.Color;
import java.util.Random;
public class EightBallSlashCommand implements SlashCommand {
private static final Random random = new Random();
private static final String[] ANSWERS = {
"Бесспорно ✅",
"Предрешено ✅",
"Никаких сомнений ✅",
"Определённо да ✅",
"Можешь быть уверен в этом ✅",
"Мне кажется — да ✅",
"Вероятнее всего ✅",
"Хорошие перспективы ✅",
"Знаки говорят — да ✅",
"Да ✅",
"Пока не ясно 🤔",
"Спроси позже 🤔",
"Лучше не рассказывать 🤔",
"Сейчас нельзя предсказать 🤔",
"Сконцентрируйся и спроси опять 🤔",
"Даже не думай ❌",
"Мой ответ — нет ❌",
"По моим данным — нет ❌",
"Перспективы не очень хорошие ❌",
"Весьма сомнительно ❌"
};
@Override
public void execute(SlashCommandInteractionEvent event) {
var questionOption = event.getOption("question");
if (questionOption == null) {
event.reply("Задайте вопрос!").setEphemeral(true).queue();
return;
}
String question = questionOption.getAsString();
String answer = ANSWERS[random.nextInt(ANSWERS.length)];
EmbedBuilder embed = new EmbedBuilder()
.setTitle("🎱 Магический шар")
.addField("Вопрос", question, false)
.addField("Ответ", answer, false)
.setColor(new Color(88, 101, 242));
event.replyEmbeds(embed.build()).queue();
}
@Override
public CommandData getCommandData() {
return Commands.slash("8ball", "Задать вопрос магическому шару")
.addOption(OptionType.STRING, "question", "Ваш вопрос", true);
}
@Override
public String getName() {
return "8ball";
}
@Override
public String getDescription() {
return "Задать вопрос магическому шару";
}
}

View file

@ -1,48 +0,0 @@
package creator.loki.command.slash;
import creator.loki.command.SlashCommand;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
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.CommandData;
import java.awt.Color;
public class HugSlashCommand implements SlashCommand {
@Override
public void execute(SlashCommandInteractionEvent event) {
var targetOption = event.getOption("user");
if (targetOption == null) {
event.reply("Укажите пользователя!").setEphemeral(true).queue();
return;
}
var target = targetOption.getAsUser();
var author = event.getUser();
EmbedBuilder embed = new EmbedBuilder()
.setDescription(author.getAsMention() + " обнимает " + target.getAsMention() + " 🤗")
.setColor(new Color(255, 182, 193))
.setImage("https://media.tenor.com/images/hug.gif");
event.replyEmbeds(embed.build()).queue();
}
@Override
public CommandData getCommandData() {
return Commands.slash("hug", "Обнять пользователя")
.addOption(OptionType.USER, "user", "Кого обнять", true);
}
@Override
public String getName() {
return "hug";
}
@Override
public String getDescription() {
return "Обнять пользователя";
}
}

View file

@ -1,97 +0,0 @@
package creator.loki.command.slash;
import creator.loki.command.SlashCommand;
import creator.loki.embed.EmbedTemplates;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
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.CommandData;
public class KickSlashCommand implements SlashCommand {
@Override
public void execute(SlashCommandInteractionEvent event) {
if (event.getGuild() == null) {
event.replyEmbeds(EmbedTemplates.error("Ошибка", "Команда доступна только на сервере")).setEphemeral(true).queue();
return;
}
Member author = event.getMember();
if (author == null || !author.hasPermission(Permission.KICK_MEMBERS)) {
event.replyEmbeds(EmbedTemplates.error("Недостаточно прав", "У вас нет прав на кик пользователей")).setEphemeral(true).queue();
return;
}
if (!event.getGuild().getSelfMember().hasPermission(Permission.KICK_MEMBERS)) {
event.replyEmbeds(EmbedTemplates.error("Недостаточно прав", "У бота нет прав на кик пользователей")).setEphemeral(true).queue();
return;
}
Member target = event.getOption("user").getAsMember();
if (target == null) {
event.replyEmbeds(EmbedTemplates.error("Ошибка", "Пользователь не найден")).setEphemeral(true).queue();
return;
}
if (target.getId().equals(author.getId())) {
event.replyEmbeds(EmbedTemplates.error("Ошибка", "Вы не можете кикнуть самого себя")).setEphemeral(true).queue();
return;
}
if (target.getUser().isBot() && target.getId().equals(event.getGuild().getSelfMember().getId())) {
event.replyEmbeds(EmbedTemplates.error("Ошибка", "Я не могу кикнуть самого себя")).setEphemeral(true).queue();
return;
}
if (!author.canInteract(target)) {
event.replyEmbeds(EmbedTemplates.error("Недостаточно прав", "Вы не можете кикнуть этого пользователя")).setEphemeral(true).queue();
return;
}
if (!event.getGuild().getSelfMember().canInteract(target)) {
event.replyEmbeds(EmbedTemplates.error("Недостаточно прав", "Я не могу кикнуть этого пользователя")).setEphemeral(true).queue();
return;
}
String reason = event.getOption("reason") != null
? event.getOption("reason").getAsString()
: "Причина не указана";
event.deferReply().queue();
event.getGuild().kick(target)
.reason(reason)
.queue(
success -> event.getHook().editOriginalEmbeds(
EmbedTemplates.success(
"Пользователь кикнут",
"**Пользователь:** " + target.getUser().getAsTag() + "\n" +
"**Причина:** " + reason + "\n" +
"**Модератор:** " + author.getAsMention()
)
).queue(),
error -> event.getHook().editOriginalEmbeds(
EmbedTemplates.error("Ошибка", "Не удалось кикнуть: " + error.getMessage())
).queue()
);
}
@Override
public CommandData getCommandData() {
return Commands.slash("kick", "Кикает пользователя с сервера")
.addOption(OptionType.USER, "user", "Пользователь для кика", true)
.addOption(OptionType.STRING, "reason", "Причина кика", false);
}
@Override
public String getName() {
return "kick";
}
@Override
public String getDescription() {
return "Кикает пользователя с сервера";
}
}

View file

@ -1,58 +0,0 @@
package creator.loki.command.slash;
import creator.loki.command.SlashCommand;
import creator.loki.metrics.MetricsCollector;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.interactions.commands.build.Commands;
import net.dv8tion.jda.api.interactions.commands.build.CommandData;
import java.awt.Color;
/**
* Slash команда /metrics
*/
public class MetricsSlashCommand implements SlashCommand {
private final MetricsCollector metrics;
public MetricsSlashCommand(MetricsCollector metrics) {
this.metrics = metrics;
}
@Override
public void execute(SlashCommandInteractionEvent event) {
// Только администраторы
if (event.getMember() == null || !event.getMember().hasPermission(Permission.ADMINISTRATOR)) {
event.reply("❌ Эта команда доступна только администраторам.")
.setEphemeral(true)
.queue();
return;
}
String stats = metrics.getFullStats();
EmbedBuilder embed = new EmbedBuilder()
.setTitle("📊 Метрики бота")
.setDescription("```\n" + stats + "\n```")
.setColor(new Color(88, 101, 242))
.setFooter("Slash команда");
event.replyEmbeds(embed.build()).setEphemeral(true).queue();
}
@Override
public CommandData getCommandData() {
return Commands.slash("metrics", "Показывает метрики и статистику бота (только для админов)");
}
@Override
public String getName() {
return "metrics";
}
@Override
public String getDescription() {
return "Показывает метрики и статистику бота";
}
}

View file

@ -1,50 +0,0 @@
package creator.loki.command.slash;
import creator.loki.command.SlashCommand;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.interactions.commands.build.Commands;
import net.dv8tion.jda.api.interactions.commands.build.CommandData;
import java.awt.Color;
/**
* Slash команда /ping
*/
public class PingSlashCommand implements SlashCommand {
@Override
public void execute(SlashCommandInteractionEvent event) {
JDA jda = event.getJDA();
long gatewayPing = jda.getGatewayPing();
event.deferReply().queue(hook -> {
long restPing = hook.getInteraction().getJDA().getRestPing().complete();
EmbedBuilder embed = new EmbedBuilder()
.setTitle("🏓 Pong!")
.setColor(new Color(88, 101, 242))
.addField("Gateway Ping", gatewayPing + " ms", true)
.addField("REST Ping", restPing + " ms", true)
.setFooter("Slash команда");
hook.editOriginalEmbeds(embed.build()).queue();
});
}
@Override
public CommandData getCommandData() {
return Commands.slash("ping", "Проверяет задержку бота");
}
@Override
public String getName() {
return "ping";
}
@Override
public String getDescription() {
return "Проверяет задержку бота";
}
}

View file

@ -1,79 +0,0 @@
package creator.loki.command.slash;
import creator.loki.command.SlashCommand;
import creator.loki.data.JsonDataStore;
import creator.loki.level.LevelSystem;
import creator.loki.profile.ProfileData;
import creator.loki.profile.ProfileService;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.User;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
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.CommandData;
import java.awt.Color;
/**
* Slash команда /profile
*/
public class ProfileSlashCommand implements SlashCommand {
private final ProfileService profileService;
public ProfileSlashCommand(JsonDataStore dataStore) {
this.profileService = new ProfileService(dataStore);
}
@Override
public void execute(SlashCommandInteractionEvent event) {
Member targetMember = event.getOption("user") != null
? event.getOption("user").getAsMember()
: event.getMember();
if (targetMember == null) {
event.reply("❌ Не удалось получить информацию о пользователе.")
.setEphemeral(true)
.queue();
return;
}
ProfileData profile = profileService.getProfileData(targetMember, event.getGuild());
// Создаём прогресс-бар
int progress = (int) ((double) profile.getCurrentXp() / profile.getNextLevelXp() * 20);
String progressBar = "▰".repeat(progress) + "▱".repeat(20 - progress);
EmbedBuilder embed = new EmbedBuilder()
.setTitle("📊 Профиль " + profile.getUsername())
.setThumbnail(profile.getAvatarUrl())
.setColor(new Color(138, 80, 255))
.addField("🎯 Уровень", String.valueOf(profile.getLevel()), true)
.addField("⭐ Ранг", "#" + profile.getRank(), true)
.addField("💬 Сообщений", String.valueOf(profile.getMessagesCount()), true)
.addField("🎤 Голос (мин)", String.valueOf(profile.getVoiceMinutes()), true)
.addField("👍 Репутация", String.valueOf(profile.getReputation()), true)
.addField("📈 Прогресс",
progressBar + "\n" + profile.getCurrentXp() + " / " + profile.getNextLevelXp() + " XP",
false)
.setFooter("Запросил: " + event.getUser().getName());
event.replyEmbeds(embed.build()).queue();
}
@Override
public CommandData getCommandData() {
return Commands.slash("profile", "Показывает профиль пользователя")
.addOption(OptionType.USER, "user", "Пользователь (по умолчанию - вы)", false);
}
@Override
public String getName() {
return "profile";
}
@Override
public String getDescription() {
return "Показывает профиль пользователя";
}
}

View file

@ -1,62 +0,0 @@
package creator.loki.command.slash;
import creator.loki.command.SlashCommand;
import creator.loki.data.JsonDataStore;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.interactions.commands.build.Commands;
import net.dv8tion.jda.api.interactions.commands.build.CommandData;
import java.awt.Color;
import java.util.Map;
public class RankSlashCommand implements SlashCommand {
private final JsonDataStore dataStore;
public RankSlashCommand(JsonDataStore dataStore) {
this.dataStore = dataStore;
}
@Override
public void execute(SlashCommandInteractionEvent event) {
String userId = event.getUser().getId();
String levelKey = "level_" + userId;
String xpKey = "xp_" + userId;
Integer level = dataStore.getData(levelKey, Integer.class);
Integer xp = dataStore.getData(xpKey, Integer.class);
if (level == null) level = 1;
if (xp == null) xp = 0;
int xpNeeded = calculateXpForNextLevel(level);
EmbedBuilder embed = new EmbedBuilder()
.setTitle("📊 Ваш ранг")
.setDescription(event.getUser().getAsMention())
.addField("Уровень", String.valueOf(level), true)
.addField("Опыт", xp + " / " + xpNeeded, true)
.setColor(new Color(88, 101, 242))
.setThumbnail(event.getUser().getAvatarUrl());
event.replyEmbeds(embed.build()).queue();
}
private int calculateXpForNextLevel(int level) {
return 100 * level;
}
@Override
public CommandData getCommandData() {
return Commands.slash("rank", "Показать ваш текущий ранг и уровень");
}
@Override
public String getName() {
return "rank";
}
@Override
public String getDescription() {
return "Показать ваш текущий ранг и уровень";
}
}

View file

@ -1,70 +0,0 @@
package creator.loki.command.slash;
import creator.loki.command.SlashCommand;
import creator.loki.data.JsonDataStore;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
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.CommandData;
import java.awt.Color;
public class RepSlashCommand implements SlashCommand {
private final JsonDataStore dataStore;
public RepSlashCommand(JsonDataStore dataStore) {
this.dataStore = dataStore;
}
@Override
public void execute(SlashCommandInteractionEvent event) {
var targetOption = event.getOption("user");
if (targetOption == null) {
event.reply("Укажите пользователя!").setEphemeral(true).queue();
return;
}
var target = targetOption.getAsUser();
var author = event.getUser();
if (target.getId().equals(author.getId())) {
event.reply("Вы не можете дать репутацию самому себе!").setEphemeral(true).queue();
return;
}
if (target.isBot()) {
event.reply("Нельзя давать репутацию ботам!").setEphemeral(true).queue();
return;
}
String key = "rep_" + target.getId();
Integer currentRep = dataStore.getData(key, Integer.class);
if (currentRep == null) currentRep = 0;
dataStore.setData(key, currentRep + 1);
EmbedBuilder embed = new EmbedBuilder()
.setTitle("✨ Репутация")
.setDescription(author.getAsMention() + " дал репутацию " + target.getAsMention())
.addField("Новая репутация", String.valueOf(currentRep + 1), false)
.setColor(new Color(0, 255, 0));
event.replyEmbeds(embed.build()).queue();
}
@Override
public CommandData getCommandData() {
return Commands.slash("rep", "Дать репутацию пользователю")
.addOption(OptionType.USER, "user", "Кому дать репутацию", true);
}
@Override
public String getName() {
return "rep";
}
@Override
public String getDescription() {
return "Дать репутацию пользователю";
}
}

View file

@ -1,80 +0,0 @@
package creator.loki.command.slash;
import creator.loki.command.SlashCommand;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
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.CommandData;
import java.awt.Color;
import java.util.Random;
public class ShipSlashCommand implements SlashCommand {
private static final Random random = new Random();
@Override
public void execute(SlashCommandInteractionEvent event) {
var user1Option = event.getOption("user1");
var user2Option = event.getOption("user2");
if (user1Option == null || user2Option == null) {
event.reply("Укажите двух пользователей!").setEphemeral(true).queue();
return;
}
var user1 = user1Option.getAsUser();
var user2 = user2Option.getAsUser();
int percentage = random.nextInt(101);
String hearts = getHearts(percentage);
String comment = getComment(percentage);
EmbedBuilder embed = new EmbedBuilder()
.setTitle("💕 Ship-метр")
.setDescription(
user1.getAsMention() + " ❤️ " + user2.getAsMention() + "\n\n" +
hearts + " **" + percentage + "%**\n\n" +
comment
)
.setColor(getColor(percentage));
event.replyEmbeds(embed.build()).queue();
}
private String getHearts(int percentage) {
int hearts = percentage / 10;
return "❤️".repeat(hearts) + "🖤".repeat(10 - hearts);
}
private String getComment(int percentage) {
if (percentage >= 90) return "Идеальная пара! 💖";
if (percentage >= 70) return "Отличная совместимость! 💕";
if (percentage >= 50) return "Неплохие шансы! 💗";
if (percentage >= 30) return "Есть потенциал... 💔";
return "Лучше остаться друзьями 💔";
}
private Color getColor(int percentage) {
if (percentage >= 70) return new Color(255, 105, 180);
if (percentage >= 40) return new Color(255, 182, 193);
return new Color(128, 128, 128);
}
@Override
public CommandData getCommandData() {
return Commands.slash("ship", "Проверить совместимость двух пользователей")
.addOption(OptionType.USER, "user1", "Первый пользователь", true)
.addOption(OptionType.USER, "user2", "Второй пользователь", true);
}
@Override
public String getName() {
return "ship";
}
@Override
public String getDescription() {
return "Проверить совместимость двух пользователей";
}
}

View file

@ -1,64 +0,0 @@
package creator.loki.command.slash;
import creator.loki.command.SlashCommand;
import creator.loki.audio.AudioManager;
import creator.loki.embed.EmbedTemplates;
import net.dv8tion.jda.api.entities.GuildVoiceState;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
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.CommandData;
public class TtsSlashCommand implements SlashCommand {
private final AudioManager audioManager;
public TtsSlashCommand(AudioManager audioManager) {
this.audioManager = audioManager;
}
@Override
public void execute(SlashCommandInteractionEvent event) {
if (event.getGuild() == null) {
event.replyEmbeds(EmbedTemplates.error("Ошибка", "Команда доступна только на сервере")).setEphemeral(true).queue();
return;
}
Member member = event.getMember();
if (member == null) {
event.reply("Ошибка получения информации о пользователе").setEphemeral(true).queue();
return;
}
GuildVoiceState voiceState = member.getVoiceState();
if (voiceState == null || !voiceState.inAudioChannel()) {
event.replyEmbeds(EmbedTemplates.error("Ошибка", "Вы должны находиться в голосовом канале")).setEphemeral(true).queue();
return;
}
String text = event.getOption("text").getAsString();
if (text.length() > 200) {
event.reply("Текст слишком длинный! Максимум 200 символов.").setEphemeral(true).queue();
return;
}
event.reply("🔊 Воспроизведение TTS...").queue();
audioManager.enqueueTts(event.getGuild(), text, event.getChannel());
}
@Override
public CommandData getCommandData() {
return Commands.slash("tts", "Озвучить текст в голосовом канале")
.addOption(OptionType.STRING, "text", "Текст для озвучки (макс. 200 символов)", true);
}
@Override
public String getName() {
return "tts";
}
@Override
public String getDescription() {
return "Озвучить текст в голосовом канале";
}
}

View file

@ -5,7 +5,7 @@ import creator.loki.audio.AudioManager;
import creator.loki.command.impl.setup.CounterSetupCommand; import creator.loki.command.impl.setup.CounterSetupCommand;
import creator.loki.command.impl.setup.LogSetupCommand; import creator.loki.command.impl.setup.LogSetupCommand;
import creator.loki.command.impl.setup.VoiceSetupCommand; import creator.loki.command.impl.setup.VoiceSetupCommand;
import creator.loki.command.impl.stats.LeaderboardButtonListener; import creator.loki.command.impl.stats.leaderboard.LeaderboardButtonListener;
import creator.loki.config.BotConfig; import creator.loki.config.BotConfig;
import creator.loki.counter.CounterManager; import creator.loki.counter.CounterManager;
import creator.loki.data.JsonDataStore; import creator.loki.data.JsonDataStore;

View file

@ -3,7 +3,6 @@ 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 creator.loki.audio.AudioManager;
import creator.loki.config.BotConfig; import creator.loki.config.BotConfig;
import creator.loki.config.ConfigLoader; import creator.loki.config.ConfigLoader;
import creator.loki.core.CommandRegistry; import creator.loki.core.CommandRegistry;
@ -38,7 +37,6 @@ public class BotModule extends AbstractModule {
bind(CooldownManager.class).in(Singleton.class); bind(CooldownManager.class).in(Singleton.class);
bind(GlobalRateLimiter.class).in(Singleton.class); bind(GlobalRateLimiter.class).in(Singleton.class);
bind(MetricsCollector.class).in(Singleton.class); bind(MetricsCollector.class).in(Singleton.class);
bind(AudioManager.class).in(Singleton.class);
} }
@Provides @Provides