From f93f6aafc85c1765d90187b158115afb4dc9e944 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Tue, 14 Jul 2026 10:49:39 +0200 Subject: [PATCH] feat: TikTok download command + SOLID/DRY refactoring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add !tiktok command for downloading TikTok videos (no watermark) - TikTokApi service using tikwm.com API - Lifecycle interface for shutdown management (9→3 deps in ShutdownManager) - Extract JdaFactory + LavalinkManagerFactory from BotInitializer - Extract PlayLoadHandler from PlayCommand (207→148 lines) - Extract TrackStarter from TrackScheduler (146→113 lines) - Refactor MuteCommand execute() into methods (199→173 lines) - Move CommandRegistrar bindings to feature modules - RatingConfig: data-driven getLevels(name) + findLevel(value, name) - VoiceHelper.connectForPlayback() for voice connection retry - BotModule: 98→84 lines, BotInitializer: 162→135 lines --- TODO.md | 109 +++++++--- .../creator/loki/core/BotInitializer.java | 57 ++--- .../creator/loki/core/BotJdaRegistry.java | 14 +- .../creator/loki/core/EventDispatcher.java | 2 +- .../java/creator/loki/core/JdaFactory.java | 32 +++ .../loki/core/LavalinkManagerFactory.java | 14 ++ .../java/creator/loki/core/Lifecycle.java | 22 ++ .../creator/loki/core/ShutdownManager.java | 46 +--- .../loki/core/security/CooldownManager.java | 4 +- .../loki/core/security/RateLimiter.java | 3 +- .../java/creator/loki/database/Database.java | 8 +- src/main/java/creator/loki/di/BotModule.java | 28 +-- .../java/creator/loki/di/LifecycleModule.java | 28 +++ .../creator/loki/embed/HelpCategories.java | 3 +- .../service/MemberCounterListener.java | 3 +- .../loki/feature/economy/EconomyModule.java | 8 +- .../EntertainmentCommandRegistrar.java | 7 +- .../entertainment/EntertainmentModule.java | 14 ++ .../feature/help/commands/HelpCommand.java | 6 +- .../loki/feature/image/ImageEmbedHelper.java | 27 ++- .../loki/feature/image/ImageModule.java | 6 + .../feature/image/ImageNavigationHandler.java | 21 +- .../loki/feature/image/ImageService.java | 31 ++- .../loki/feature/image/ImageSession.java | 60 ++++-- .../feature/image/ImageSessionManager.java | 1 - .../image/commands/AbstractImageCommand.java | 80 ++++--- .../feature/image/commands/E621Command.java | 46 +++- .../image/commands/E621RandomCommand.java | 105 ++++++--- .../image/commands/GelbooruCommand.java | 46 +++- .../feature/image/commands/R34Command.java | 46 +++- .../image/commands/YandereCommand.java | 46 +++- .../loki/feature/level/LevelModule.java | 7 + .../feature/level/service/VoiceTracker.java | 3 +- .../feature/metrics/HealthHttpServer.java | 6 +- .../feature/moderation/ModerationModule.java | 8 +- .../feature/moderation/MuteScheduler.java | 3 +- .../commands/punish/MuteCommand.java | 202 ++++++++++++------ .../loki/feature/music/MusicModule.java | 6 + .../feature/music/MusicSlashCommands.java | 3 +- .../feature/music/manage/TrackScheduler.java | 45 ++-- .../feature/music/manage/TrackStarter.java | 53 +++++ .../loki/feature/music/play/PlayCommand.java | 129 ++++------- .../feature/music/play/PlayLoadHandler.java | 116 ++++++++++ .../loki/feature/radio/RadioModule.java | 6 + .../feature/rating/commands/IqCommand.java | 2 +- .../rating/commands/MustacheCommand.java | 2 +- .../feature/rating/commands/PpCommand.java | 2 +- .../feature/rating/config/RatingConfig.java | 36 +++- .../loki/feature/roast/AiSessionManager.java | 15 +- .../loki/feature/roast/AndrertListener.java | 25 ++- .../feature/roast/DebateChannelListener.java | 6 +- .../loki/feature/roast/DebateService.java | 109 ++++++---- .../loki/feature/roast/HelperListener.java | 79 ++++--- .../loki/feature/roast/KnowledgeService.java | 27 +-- .../loki/feature/roast/RoastService.java | 10 +- .../loki/feature/roast/WebSearchService.java | 48 +++-- .../loki/feature/roast/WikipediaService.java | 20 +- .../loki/feature/tiktok/TikTokApi.java | 130 +++++++++++ .../loki/feature/tiktok/TikTokCommand.java | 163 ++++++++++++++ .../loki/feature/tiktok/TikTokModule.java | 17 ++ .../loki/feature/utility/UtilityModule.java | 14 ++ .../java/creator/loki/util/VoiceHelper.java | 22 ++ .../creator/loki/config/BotConfigTest.java | 10 +- .../SmokeCommandRegistrationTest.java | 10 +- .../music/core/LavalinkManagerTest.java | 8 +- 65 files changed, 1599 insertions(+), 666 deletions(-) create mode 100644 src/main/java/creator/loki/core/JdaFactory.java create mode 100644 src/main/java/creator/loki/core/LavalinkManagerFactory.java create mode 100644 src/main/java/creator/loki/core/Lifecycle.java create mode 100644 src/main/java/creator/loki/di/LifecycleModule.java create mode 100644 src/main/java/creator/loki/feature/entertainment/EntertainmentModule.java create mode 100644 src/main/java/creator/loki/feature/music/manage/TrackStarter.java create mode 100644 src/main/java/creator/loki/feature/music/play/PlayLoadHandler.java create mode 100644 src/main/java/creator/loki/feature/tiktok/TikTokApi.java create mode 100644 src/main/java/creator/loki/feature/tiktok/TikTokCommand.java create mode 100644 src/main/java/creator/loki/feature/tiktok/TikTokModule.java create mode 100644 src/main/java/creator/loki/feature/utility/UtilityModule.java diff --git a/TODO.md b/TODO.md index 4d6bb4f..babed4b 100644 --- a/TODO.md +++ b/TODO.md @@ -28,6 +28,15 @@ | A21 | ✅ | BotModule: путь к БД хардкодом | Вынесен в .env (`DB_PATH`) + BotConfig | | A34 | ✅ | CommandCategories: мёртвый код | Уже удалён (старый пакет) | | A37 | ✅ | ProfileCommand: регистрация в PrefixCommandRegistrar | LevelCommandRegistrar | +| A41 | ✅ | TikTok: команда `!tiktok ` для скачивания видео | TikTokApi + TikTokCommand + TikTokModule | +| A42 | ✅ | S1-S5: SOLID рефакторинг | Lifecycle, JdaFactory, PlayLoadHandler, feature modules | +| A43 | ✅ | D4-D6: DRY рефакторинг | VoiceHelper.connectForPlayback, RatingConfig data-driven, SocialCommand.Flavor | +| A44 | ✅ | PlayCommand: 207→148 строк | PlayLoadHandler + VoiceHelper.connectForPlayback | +| A45 | ✅ | TrackScheduler: 146→113 строк | TrackStarter (retry логика) | +| A46 | ✅ | MuteCommand: 199→173 строки | execute() разбит на 6 методов | +| A47 | ✅ | BotInitializer: 162→135 строк | JdaFactory + LavalinkManagerFactory | +| A48 | ✅ | ShutdownManager: 9→3 зависимости | Lifecycle interface + Multibinder | +| A49 | ✅ | BotModule: 98→84 строки | CommandRegistrar bindings в feature модулях | --- @@ -262,32 +271,32 @@ feature/economy/ | D1 | `getDeterministicValue()` — целиком | GayCommand, FatCommand, IqCommand, PpCommand, BoykisserCommand, MustacheCommand, RateCommand | 7 | ✅ RatingService | | D2 | Cooldown check boilerplate (5 строк) | Все entertainment команды | 10+ | ✅ RatingService | | D3 | `trunc()` / `err()` helpers | MuteCommand, WarnCommand, SwapAndCaseCommand, UnmuteCommand, PunishCommand | 5 | ✅ ModerationHelper | -| D4 | Voice connect retry loop (цикл 10×200ms) | PlayCommand, RadioManager | 2 | -| D5 | RatingConfig — 6 методов с идентичной структурой | RatingConfig.java | 6 | -| D6 | Social команды (hug/kiss/slap/poke/tickle/cuddle/feed/pat) — шаблон | 10 файлов | 10 | +| D4 | Voice connect retry loop (цикл 10×200ms) | PlayCommand, RadioManager | 2 | ✅ VoiceHelper.connectForPlayback() | +| D5 | RatingConfig — 6 методов с идентичной структурой | RatingConfig.java | 6 | ✅ Data-driven getLevels(name) + findLevel(value, name) | +| D6 | Social команды (hug/kiss/slap/poke/tickle/cuddle/feed/pat) — шаблон | 10 файлов | 10 | ✅ SocialCommand.Flavor (7 команд), pat/compliment/roast — отдельные | ### SOLID | # | Нарушение | Файл | Описание | |---|-----------|------|----------| -| S1 | SRP | BotModule.java | Биндит всё приложение | -| S2 | SRP | SwapAndCaseCommand.java | Две разные команды в одном файле | -| S3 | SRP | BotInitializer.java | Инициализирует 3 JDA, Lavalink, mutes, counters, health | -| S4 | SRP | ShutdownManager.java | 9 зависимостей в конструкторе | -| S5 | SRP | PlayCommand.java | execute() на 145 строк | +| S1 | SRP | BotModule.java | Биндит всё приложение | ✅ Перенесён в feature модули + LifecycleModule | +| S2 | SRP | SwapAndCaseCommand.java | Две разные команды в одном файле | ✅ Уже разделены: SwapCommand + CaseLookupCommand | +| S3 | SRP | BotInitializer.java | Инициализирует 3 JDA, Lavalink, mutes, counters, health | ✅ JdaFactory + LavalinkManagerFactory | +| S4 | SRP | ShutdownManager.java | 9 зависимостей в конструкторе | ✅ Lifecycle interface, 3 зависимости | +| S5 | SRP | PlayCommand.java | execute() на 145 строк | ✅ PlayLoadHandler + VoiceHelper.connectForPlayback() | | S6 | OCP | Meter-команды (gay/fat/iq/pp) | Добавить новую = копировать 90 строк | ✅ RatingService | -| S7 | DIP | MuteCommand, WarnCommand | >5 конкретных репозиториев вместо интерфейсов | +| S7 | DIP | MuteCommand, WarnCommand | >5 конкретных репозиториев вместо интерфейсов | ✅ Репозитории уже интерфейсы (ModerationCaseRepository, ActiveMuteRepository, WarningRepository) | ### Файлы >150 строк | # | Файл | Строк | |---|------|-------| -| F1 | PlayCommand.java | 219 | +| F1 | PlayCommand.java | 148 ✅ | | F2 | QueueSlashHandler.java | 207 | | F3 | MusicBotControlHandler.java | 207 | | F4 | QueueCommands.java | 207 | -| F5 | TrackScheduler.java | 202 | -| F6 | MuteCommand.java | 199 | +| F5 | TrackScheduler.java | 113 ✅ | +| F6 | MuteCommand.java | 173 ✅ | | F7 | ModerationLogger.java | 199 | | F8 | FeatureTables.java | 193 | | F9 | DatabaseMigration.java | 193 | @@ -297,7 +306,7 @@ feature/economy/ | F13 | RadioManager.java | 182 | | F14 | MetricsCollector.java | 182 | | F15 | WarnCommand.java | 184 | -| F16 | BotInitializer.java | 182 | +| F16 | BotInitializer.java | 135 ✅ | | F17 | SlashHandler's (Queue/Filter) | >100 | | F18 | GenerateRatingsCommand.java | 172 | | F19 | BlackjackService.java | 172 | @@ -337,37 +346,37 @@ feature/economy/ ## 📋 План рефакторинга (KISS/DRY/SOLID) -### Этап 8: Убить дублирование (DRY) — в процессе +### Этап 8: Убить дублирование (DRY) — ✅ завершён - [x] D1/D2: `RatingService` с `getDeterministicValue()`, cooldown, member parsing — 7 команд переписаны - [x] D3: `ModerationHelper` с `trunc()`/`err()` — MuteCommand, UnmuteCommand, WarnCommand, SwapAndCaseCommand, PunishCommand -- [ ] D4: Вынести voice connect retry в утилиту -- [ ] D5: RatingConfig — заменить 6 методов на data-driven (Map или config) -- [ ] D6: Social команды — единый `SocialCommand` с параметром action +- [x] D4: Вынести voice connect retry в утилиту — ✅ VoiceHelper.connectForPlayback() +- [x] D5: RatingConfig — заменить 6 методов на data-driven (Map или config) — ✅ getLevels(name) + findLevel(value, name) +- [x] D6: Social команды — единый `SocialCommand` с параметром action — ✅ SocialCommand.Flavor (7 команд) -### Этап 9: SOLID +### Этап 9: SOLID — ✅ завершён -- [ ] S1: Разбить `BotModule` на per-feature модули (FeatureModule → install в BotModule) -- [ ] S2: Разделить `SwapAndCaseCommand` на два отдельных файла -- [ ] S3: Выделить `MusicBotFactory`, `RadioBotFactory` из `BotInitializer` -- [ ] S4: Уменьшить кол-во зависимостей `ShutdownManager` через события (EventBus) -- [ ] S5: Разбить `PlayCommand.execute()` на методы -- [ ] S6: Meter-команды сделать data-driven через `RatingService` -- [ ] S7: Выделить интерфейсы для репозиториев/сервисов (DIP) +- [x] S1: Разбить `BotModule` на per-feature модули (FeatureModule → install в BotModule) — ✅ 8 feature модулей + LifecycleModule +- [x] S2: Разделить `SwapAndCaseCommand` на два отдельных файла — ✅ Already split (SwapCommand + CaseLookupCommand) +- [x] S3: Выделить `MusicBotFactory`, `RadioBotFactory` из `BotInitializer` — ✅ JdaFactory + LavalinkManagerFactory +- [x] S4: Уменьшить кол-во зависимостей `ShutdownManager` через события (EventBus) — ✅ Lifecycle interface (9→3 deps) +- [x] S5: Разбить `PlayCommand.execute()` на методы — ✅ PlayLoadHandler + VoiceHelper.connectForPlayback() +- [x] S6: Meter-команды сделать data-driven через `RatingService` — ✅ RatingConfig.findLevel(value, name) +- [x] S7: Выделить интерфейсы для репозиториев/сервисов (DIP) — ✅ Already interfaces -### Этап 10: Файлы ≤150 строк +### Этап 10: Файлы ≤150 строк — в процессе -- [ ] F01: PlayCommand.java — разбить на методы/классы +- [x] F01: PlayCommand.java — разбить на методы/классы — ✅ 207→148 строк - [ ] F02-04: QueueSlashHandler, MusicBotControlHandler, QueueCommands — вынести общие части -- [ ] F05: TrackScheduler.java — вывести FilterState в отд. файл или утилиту -- [ ] F06: MuteCommand.java — разбить на subcommand handler'ы +- [x] F05: TrackScheduler.java — вывести FilterState в отд. файл или утилиту — ✅ 146→113 строк (TrackStarter) +- [x] F06: MuteCommand.java — разбить на subcommand handler'ы — ✅ 199→173 строки - [ ] F07: ModerationLogger.java — разбить по типам событий - [ ] F08-09: FeatureTables, DatabaseMigration — разбить по фичам - [ ] F10-12: Voice* — вынести логику в сервисы - [ ] F13: RadioManager.java — вынести voice connect в утилиту - [ ] F14: MetricsCollector.java — разбить по типам метрик - [ ] F15: WarnCommand.java — вынести auto-mute в отдельный сервис -- [ ] F16: BotInitializer.java — вынести фабрики ботов +- [x] F16: BotInitializer.java — вынести фабрики ботов — ✅ 162→135 строк - [ ] Остальные: разбить по SOLID ### Этап 11: ≤6 файлов в папке @@ -394,7 +403,45 @@ feature/economy/ --- -**Версия:** 2.3 | **Последнее обновление:** 2026-07-04 | **Java 21 ✅, God Constructor ✅, Critical баги ✅, Economy ✅, Level Roles ✅, Lavalink Music ✅, SpotBugs ✅, Checkstyle ✅, CI/CD ✅** +**Версия:** 2.4 | **Последнее обновление:** 2026-07-14 | **Java 21 ✅, God Constructor ✅, Critical баги ✅, Economy ✅, Level Roles ✅, Lavalink Music ✅, SpotBugs ✅, Checkstyle ✅, CI/CD ✅, SOLID ✅, TikTok ✅** + +## ✅ Что добавлено в v2.4 + +### 🎬 TikTok Download Command +- [x] Команда `!tiktok ` — скачивание видео из TikTok без watermark +- [x] TikTokApi — сервис для работы с tikwm.com API (OkHttp + Gson) +- [x] Скачивание через virtual threads +- [x] Embed с информацией (title, author, duration, cover) +- [x] Проверка размера файла (< 8MB) +- [x] TikTokModule для DI + +### 🔧 Рефакторинг SOLID/DRY +- [x] **S1**: BotModule — CommandRegistrar bindings перенесены в feature модули (8 модулей) +- [x] **S2**: SwapAndCaseCommand — уже разделены (SwapCommand + CaseLookupCommand) +- [x] **S3**: BotInitializer — извлечены JdaFactory + LavalinkManagerFactory (162→135 строк) +- [x] **S4**: ShutdownManager — Lifecycle interface, 9→3 зависимости (118→89 строк) +- [x] **S5**: PlayCommand — извлечён PlayLoadHandler + VoiceHelper.connectForPlayback() (207→148 строк) +- [x] **S7**: DIP — репозитории уже интерфейсы (ModerationCaseRepository, ActiveMuteRepository, WarningRepository) +- [x] **D4**: Voice connect retry — VoiceHelper.connectForPlayback() +- [x] **D5**: RatingConfig — data-driven getLevels(name) + findLevel(value, name) +- [x] **D6**: Social команды — SocialCommand.Flavor (7 команд), pat/compliment/roast — отдельные +- [x] **TrackScheduler** — извлечён TrackStarter (146→113 строк) +- [x] **MuteCommand** — execute() разбит на методы (199→173 строки, execute() 23 строки) + +### 📦 Новые файлы +- `feature/tiktok/TikTokApi.java` — API сервис +- `feature/tiktok/TikTokCommand.java` — команда +- `feature/tiktok/TikTokModule.java` — Guice модуль +- `core/Lifecycle.java` — интерфейс для shutdown +- `core/JdaFactory.java` — фабрика JDA +- `core/LavalinkManagerFactory.java` — фабрика LavalinkManager +- `core/LifecycleModule.java` — Multibinder для Lifecycle +- `feature/entertainment/EntertainmentModule.java` — CommandRegistrar binding +- `feature/utility/UtilityModule.java` — CommandRegistrar binding +- `feature/music/play/PlayLoadHandler.java` — обработчик загрузки треков +- `feature/music/manage/TrackStarter.java` — retry логика запуска треков + +--- ## ✅ Что добавлено в v2.3 diff --git a/src/main/java/creator/loki/core/BotInitializer.java b/src/main/java/creator/loki/core/BotInitializer.java index 7187374..b417f8e 100644 --- a/src/main/java/creator/loki/core/BotInitializer.java +++ b/src/main/java/creator/loki/core/BotInitializer.java @@ -1,7 +1,5 @@ package creator.loki.core; -import java.util.List; - import com.google.inject.Inject; import creator.loki.config.BotConfig; import creator.loki.core.registry.PrefixCommandRegistrar; @@ -9,6 +7,7 @@ import creator.loki.feature.counter.service.MemberCounterListener; import creator.loki.feature.metrics.HealthHttpServer; import creator.loki.feature.moderation.MuteScheduler; import creator.loki.feature.music.core.LavalinkManager; +import creator.loki.feature.music.core.PlayerManager; import creator.loki.feature.music.listener.MusicBotListener; import creator.loki.feature.music.repository.QueueRepository; import creator.loki.feature.radio.RadioBotListener; @@ -17,11 +16,7 @@ import creator.loki.util.LoggerDecorator; import dev.arbjerg.lavalink.libraries.jda.JDAVoiceUpdateListener; import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDABuilder; -import net.dv8tion.jda.api.OnlineStatus; -import net.dv8tion.jda.api.entities.Activity; - import net.dv8tion.jda.api.requests.GatewayIntent; -import net.dv8tion.jda.api.utils.cache.CacheFlag; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -75,7 +70,7 @@ public class BotInitializer { JDA radioJda = null; if (config.getRadioToken() != null && !config.getRadioToken().isBlank()) { LavalinkManager radioLavalink = - new LavalinkManager(config, queueRepository, config.getRadioToken()); + LavalinkManagerFactory.create(config, queueRepository, config.getRadioToken()); radioJda = createRadioJDA(radioLavalink); if (radioJda != null) { radioManager.setRadioJda(radioJda); @@ -90,73 +85,51 @@ public class BotInitializer { private JDA buildMainJDA() { var builder = - createBuilder( + JdaFactory.createBuilder( config.getMainToken(), config.getActivity(), GatewayIntent.GUILD_MESSAGES, GatewayIntent.MESSAGE_CONTENT, GatewayIntent.GUILD_MEMBERS, GatewayIntent.GUILD_VOICE_STATES); - if (lavalinkManager.getClient() != null) { - builder.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(lavalinkManager.getClient())); - } builder.addEventListeners(listenerAggregator.getAll()); - return awaitJda(builder.build()); + return buildWithLavalink(builder, lavalinkManager); } private JDA createMusicJDA() { if (config.getMusicToken() == null || config.getMusicToken().isBlank()) return null; LoggerDecorator.startup(logger, "Инициализация music бота..."); - LavalinkManager ml = new LavalinkManager(config, queueRepository, config.getMusicToken()); + LavalinkManager ml = + LavalinkManagerFactory.create(config, queueRepository, config.getMusicToken()); var builder = - createBuilder( + JdaFactory.createBuilder( config.getMusicToken(), "🎵 q!play", GatewayIntent.GUILD_MESSAGES, GatewayIntent.MESSAGE_CONTENT, GatewayIntent.GUILD_VOICE_STATES); - if (ml.getClient() != null) { - builder.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(ml.getClient())); - } - builder.addEventListeners( - new MusicBotListener( - ml, queueRepository, new creator.loki.feature.music.core.PlayerManager(ml))); - return awaitJda(builder.build()); + builder.addEventListeners(new MusicBotListener(ml, queueRepository, new PlayerManager(ml))); + return buildWithLavalink(builder, ml); } private JDA createRadioJDA(LavalinkManager radioLavalink) { if (config.getRadioToken() == null || config.getRadioToken().isBlank()) return null; LoggerDecorator.startup(logger, "Инициализация radio бота..."); var builder = - createBuilder( + JdaFactory.createBuilder( config.getRadioToken(), "📻 r!play", GatewayIntent.GUILD_MESSAGES, GatewayIntent.MESSAGE_CONTENT, GatewayIntent.GUILD_VOICE_STATES); - if (radioLavalink.getClient() != null) { - builder.setVoiceDispatchInterceptor( - new JDAVoiceUpdateListener(radioLavalink.getClient())); - } builder.addEventListeners(new RadioBotListener(radioManager)); - return awaitJda(builder.build()); + return buildWithLavalink(builder, radioLavalink); } - private static JDABuilder createBuilder(String token, String activity, GatewayIntent... intents) { - return JDABuilder.createDefault(token) - .setStatus(OnlineStatus.ONLINE) - .setActivity(Activity.playing(activity)) - .enableIntents(List.of(intents)) - .enableCache(CacheFlag.VOICE_STATE); - } - - private static JDA awaitJda(JDA jda) { - try { - jda.awaitReady(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new RuntimeException("JDA прерван во время ожидания готовности", e); + private static JDA buildWithLavalink(JDABuilder builder, LavalinkManager ll) { + if (ll.getClient() != null) { + builder.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(ll.getClient())); } - return jda; + return JdaFactory.awaitJda(builder.build()); } } diff --git a/src/main/java/creator/loki/core/BotJdaRegistry.java b/src/main/java/creator/loki/core/BotJdaRegistry.java index 34b49aa..68fe4df 100644 --- a/src/main/java/creator/loki/core/BotJdaRegistry.java +++ b/src/main/java/creator/loki/core/BotJdaRegistry.java @@ -9,9 +9,17 @@ public class BotJdaRegistry { private JDA music; private JDA radio; - public void setMain(JDA jda) { this.main = jda; } - public void setMusic(JDA jda) { this.music = jda; } - public void setRadio(JDA jda) { this.radio = jda; } + public void setMain(JDA jda) { + this.main = jda; + } + + public void setMusic(JDA jda) { + this.music = jda; + } + + public void setRadio(JDA jda) { + this.radio = jda; + } public JDA getByPersonality(String personality) { return switch (personality) { diff --git a/src/main/java/creator/loki/core/EventDispatcher.java b/src/main/java/creator/loki/core/EventDispatcher.java index c1dfd69..fef74ae 100644 --- a/src/main/java/creator/loki/core/EventDispatcher.java +++ b/src/main/java/creator/loki/core/EventDispatcher.java @@ -19,7 +19,7 @@ import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class EventDispatcher { +public class EventDispatcher implements Lifecycle { private static final Logger logger = LoggerFactory.getLogger(EventDispatcher.class); private final BotConfig config; private final CommandRegistry registry; diff --git a/src/main/java/creator/loki/core/JdaFactory.java b/src/main/java/creator/loki/core/JdaFactory.java new file mode 100644 index 0000000..4ea79ab --- /dev/null +++ b/src/main/java/creator/loki/core/JdaFactory.java @@ -0,0 +1,32 @@ +package creator.loki.core; + +import java.util.List; + +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.JDABuilder; +import net.dv8tion.jda.api.OnlineStatus; +import net.dv8tion.jda.api.entities.Activity; +import net.dv8tion.jda.api.requests.GatewayIntent; +import net.dv8tion.jda.api.utils.cache.CacheFlag; + +public final class JdaFactory { + private JdaFactory() {} + + public static JDABuilder createBuilder(String token, String activity, GatewayIntent... intents) { + return JDABuilder.createDefault(token) + .setStatus(OnlineStatus.ONLINE) + .setActivity(Activity.playing(activity)) + .enableIntents(List.of(intents)) + .enableCache(CacheFlag.VOICE_STATE); + } + + public static JDA awaitJda(JDA jda) { + try { + jda.awaitReady(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("JDA прерван во время ожидания готовности", e); + } + return jda; + } +} diff --git a/src/main/java/creator/loki/core/LavalinkManagerFactory.java b/src/main/java/creator/loki/core/LavalinkManagerFactory.java new file mode 100644 index 0000000..0a37a03 --- /dev/null +++ b/src/main/java/creator/loki/core/LavalinkManagerFactory.java @@ -0,0 +1,14 @@ +package creator.loki.core; + +import creator.loki.config.BotConfig; +import creator.loki.feature.music.core.LavalinkManager; +import creator.loki.feature.music.repository.QueueRepository; + +public final class LavalinkManagerFactory { + private LavalinkManagerFactory() {} + + public static LavalinkManager create( + BotConfig config, QueueRepository queueRepository, String token) { + return new LavalinkManager(config, queueRepository, token); + } +} diff --git a/src/main/java/creator/loki/core/Lifecycle.java b/src/main/java/creator/loki/core/Lifecycle.java new file mode 100644 index 0000000..0eb63d6 --- /dev/null +++ b/src/main/java/creator/loki/core/Lifecycle.java @@ -0,0 +1,22 @@ +package creator.loki.core; + +/** + * Интерфейс для компонентов, требующих корректного завершения работы. + * + *

Реализуется классами, которые управляют ресурсами (потоками, соединениями, планировщиками) и + * должны быть корректно остановлены при завершении работы бота. + */ +public interface Lifecycle { + + /** + * Возвращает имя компонента для логирования при завершении работы. + * + * @return имя компонента (по умолчанию - простое имя класса) + */ + default String name() { + return getClass().getSimpleName(); + } + + /** Корректно завершает работу компонента, освобождая управляемые ресурсы. */ + void shutdown(); +} diff --git a/src/main/java/creator/loki/core/ShutdownManager.java b/src/main/java/creator/loki/core/ShutdownManager.java index ef8f7ad..963259f 100644 --- a/src/main/java/creator/loki/core/ShutdownManager.java +++ b/src/main/java/creator/loki/core/ShutdownManager.java @@ -1,16 +1,11 @@ package creator.loki.core; import java.time.Duration; +import java.util.Set; import com.google.inject.Inject; import com.google.inject.Singleton; -import creator.loki.core.security.CooldownManager; -import creator.loki.core.security.RateLimiter; import creator.loki.database.Database; -import creator.loki.feature.counter.service.MemberCounterListener; -import creator.loki.feature.level.service.VoiceTracker; -import creator.loki.feature.metrics.HealthHttpServer; -import creator.loki.feature.moderation.MuteScheduler; import creator.loki.feature.radio.RadioManager; import creator.loki.util.LoggerDecorator; import net.dv8tion.jda.api.JDA; @@ -21,34 +16,13 @@ import org.slf4j.LoggerFactory; 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 Set lifecycles; private final Database database; private final RadioManager radioManager; @Inject - public ShutdownManager( - HealthHttpServer healthHttpServer, - CooldownManager cooldownManager, - RateLimiter rateLimiter, - VoiceTracker voiceTracker, - MemberCounterListener memberCounterListener, - MuteScheduler muteScheduler, - EventDispatcher dispatcher, - Database database, - RadioManager radioManager) { - this.healthHttpServer = healthHttpServer; - this.cooldownManager = cooldownManager; - this.rateLimiter = rateLimiter; - this.voiceTracker = voiceTracker; - this.memberCounterListener = memberCounterListener; - this.muteScheduler = muteScheduler; - this.dispatcher = dispatcher; + public ShutdownManager(Set lifecycles, Database database, RadioManager radioManager) { + this.lifecycles = lifecycles; this.database = database; this.radioManager = radioManager; } @@ -67,19 +41,15 @@ public class ShutdownManager { logger.info("========================================"); try { - // Stop all radio playback if (cluster.hasRadioBot()) { radioManager.getRadioJda().getGuilds().forEach(g -> radioManager.stopRadio(g.getIdLong())); } - healthHttpServer.stop(); LoggerDecorator.bot(logger, "Остановка менеджеров..."); - cooldownManager.shutdown(); - rateLimiter.shutdown(); - voiceTracker.shutdown(); - memberCounterListener.shutdown(); - muteScheduler.shutdown(); - dispatcher.shutdown(); + for (Lifecycle lifecycle : lifecycles) { + LoggerDecorator.bot(logger, "Остановка " + lifecycle.name() + "..."); + lifecycle.shutdown(); + } LoggerDecorator.database(logger, "Закрытие базы данных..."); database.close(); diff --git a/src/main/java/creator/loki/core/security/CooldownManager.java b/src/main/java/creator/loki/core/security/CooldownManager.java index 60bc652..d9406c8 100644 --- a/src/main/java/creator/loki/core/security/CooldownManager.java +++ b/src/main/java/creator/loki/core/security/CooldownManager.java @@ -6,7 +6,9 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; -public class CooldownManager { +import creator.loki.core.Lifecycle; + +public class CooldownManager implements Lifecycle { private final ConcurrentHashMap cooldowns = new ConcurrentHashMap<>(); private final ScheduledExecutorService cleanupExecutor = Executors.newSingleThreadScheduledExecutor(); diff --git a/src/main/java/creator/loki/core/security/RateLimiter.java b/src/main/java/creator/loki/core/security/RateLimiter.java index c8c62a2..f0dae47 100644 --- a/src/main/java/creator/loki/core/security/RateLimiter.java +++ b/src/main/java/creator/loki/core/security/RateLimiter.java @@ -5,12 +5,13 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import creator.loki.core.Lifecycle; import creator.loki.util.LoggerDecorator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** Rate limiter для защиты от спама Использует алгоритм Token Bucket */ -public class RateLimiter { +public class RateLimiter implements Lifecycle { private static final Logger logger = LoggerFactory.getLogger(RateLimiter.class); // Лимиты по умолчанию diff --git a/src/main/java/creator/loki/database/Database.java b/src/main/java/creator/loki/database/Database.java index 316f9ff..aee46d0 100644 --- a/src/main/java/creator/loki/database/Database.java +++ b/src/main/java/creator/loki/database/Database.java @@ -8,11 +8,12 @@ import java.sql.Statement; import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; +import creator.loki.core.Lifecycle; import creator.loki.util.LoggerDecorator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class Database { +public class Database implements Lifecycle { private static final Logger logger = LoggerFactory.getLogger(Database.class); private final String dbPath; private HikariDataSource dataSource; @@ -105,4 +106,9 @@ public class Database { LoggerDecorator.database(logger, "Пул соединений закрыт"); } } + + @Override + public void shutdown() { + close(); + } } diff --git a/src/main/java/creator/loki/di/BotModule.java b/src/main/java/creator/loki/di/BotModule.java index e8b151b..958a349 100644 --- a/src/main/java/creator/loki/di/BotModule.java +++ b/src/main/java/creator/loki/di/BotModule.java @@ -3,40 +3,33 @@ package creator.loki.di; import com.google.inject.AbstractModule; import com.google.inject.Provides; import com.google.inject.Singleton; -import com.google.inject.multibindings.Multibinder; import creator.loki.config.BotConfig; import creator.loki.config.ConfigLoader; 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.PrefixCommandRegistrar; import creator.loki.core.security.CooldownManager; import creator.loki.core.security.RateLimiter; import creator.loki.database.DatabaseModule; import creator.loki.feature.counter.CounterModule; -import creator.loki.feature.economy.EconomyCommandRegistrar; import creator.loki.feature.economy.EconomyModule; -import creator.loki.feature.entertainment.EntertainmentCommandRegistrar; -import creator.loki.feature.image.ImageCommandRegistrar; +import creator.loki.feature.entertainment.EntertainmentModule; import creator.loki.feature.image.ImageModule; import creator.loki.feature.level.LevelModule; -import creator.loki.feature.level.commands.LevelCommandRegistrar; import creator.loki.feature.metrics.HealthHttpServer; import creator.loki.feature.metrics.MetricsCollector; import creator.loki.feature.metrics.MetricsModule; -import creator.loki.feature.moderation.ModerationCommandRegistrar; import creator.loki.feature.moderation.ModerationModule; -import creator.loki.feature.music.MusicCommandRegistrar; import creator.loki.feature.music.MusicModule; import creator.loki.feature.profile.ProfileModule; -import creator.loki.feature.radio.RadioCommandRegistrar; import creator.loki.feature.radio.RadioModule; import creator.loki.feature.roast.RoastModule; import creator.loki.feature.role.RoleModule; -import creator.loki.feature.utility.UtilityCommandRegistrar; +import creator.loki.feature.tiktok.TikTokModule; +import creator.loki.feature.utility.UtilityModule; import creator.loki.feature.voice.VoiceModule; import creator.loki.feature.welcome.WelcomeModule; @@ -67,17 +60,10 @@ public class BotModule extends AbstractModule { install(new RadioModule()); install(new RoastModule()); install(new ImageModule()); - - Multibinder 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); - commandBinder.addBinding().to(RadioCommandRegistrar.class); - commandBinder.addBinding().to(ImageCommandRegistrar.class); + install(new TikTokModule()); + install(new EntertainmentModule()); + install(new UtilityModule()); + install(new LifecycleModule()); } @Provides diff --git a/src/main/java/creator/loki/di/LifecycleModule.java b/src/main/java/creator/loki/di/LifecycleModule.java new file mode 100644 index 0000000..f610029 --- /dev/null +++ b/src/main/java/creator/loki/di/LifecycleModule.java @@ -0,0 +1,28 @@ +package creator.loki.di; + +import com.google.inject.AbstractModule; +import com.google.inject.multibindings.Multibinder; +import creator.loki.core.EventDispatcher; +import creator.loki.core.Lifecycle; +import creator.loki.core.security.CooldownManager; +import creator.loki.core.security.RateLimiter; +import creator.loki.database.Database; +import creator.loki.feature.counter.service.MemberCounterListener; +import creator.loki.feature.level.service.VoiceTracker; +import creator.loki.feature.metrics.HealthHttpServer; +import creator.loki.feature.moderation.MuteScheduler; + +public class LifecycleModule extends AbstractModule { + @Override + protected void configure() { + Multibinder binder = Multibinder.newSetBinder(binder(), Lifecycle.class); + binder.addBinding().to(CooldownManager.class); + binder.addBinding().to(RateLimiter.class); + binder.addBinding().to(EventDispatcher.class); + binder.addBinding().to(VoiceTracker.class); + binder.addBinding().to(MemberCounterListener.class); + binder.addBinding().to(MuteScheduler.class); + binder.addBinding().to(HealthHttpServer.class); + binder.addBinding().to(Database.class); + } +} diff --git a/src/main/java/creator/loki/embed/HelpCategories.java b/src/main/java/creator/loki/embed/HelpCategories.java index 2f2c409..6ba47f0 100644 --- a/src/main/java/creator/loki/embed/HelpCategories.java +++ b/src/main/java/creator/loki/embed/HelpCategories.java @@ -52,8 +52,7 @@ public final class HelpCategories { Map.entry("Система", "Метрики и служебная информация для админов"), Map.entry("Настройка", "Настройка систем бота (только админы)"), Map.entry("Супер-Админ", "Сервисные команды владельца бота"), - Map.entry( - CommandCategories.NSFW_IMAGES, "Поиск изображений (только в NSFW каналах)")); + Map.entry(CommandCategories.NSFW_IMAGES, "Поиск изображений (только в NSFW каналах)")); } private HelpCategories() {} diff --git a/src/main/java/creator/loki/feature/counter/service/MemberCounterListener.java b/src/main/java/creator/loki/feature/counter/service/MemberCounterListener.java index a845ba6..43c6215 100644 --- a/src/main/java/creator/loki/feature/counter/service/MemberCounterListener.java +++ b/src/main/java/creator/loki/feature/counter/service/MemberCounterListener.java @@ -5,6 +5,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import com.google.inject.Inject; +import creator.loki.core.Lifecycle; import creator.loki.util.LoggerDecorator; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.events.guild.member.GuildMemberJoinEvent; @@ -14,7 +15,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** Listener для счетчика участников */ -public class MemberCounterListener extends ListenerAdapter { +public class MemberCounterListener extends ListenerAdapter implements Lifecycle { private static final Logger logger = LoggerFactory.getLogger(MemberCounterListener.class); private final CounterManager counterManager; private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); diff --git a/src/main/java/creator/loki/feature/economy/EconomyModule.java b/src/main/java/creator/loki/feature/economy/EconomyModule.java index e3fe0d0..0f3c84e 100644 --- a/src/main/java/creator/loki/feature/economy/EconomyModule.java +++ b/src/main/java/creator/loki/feature/economy/EconomyModule.java @@ -3,6 +3,8 @@ package creator.loki.feature.economy; import com.google.inject.AbstractModule; import com.google.inject.Provides; import com.google.inject.Singleton; +import com.google.inject.multibindings.Multibinder; +import creator.loki.core.registry.CommandRegistrar; import creator.loki.core.security.CooldownManager; import creator.loki.database.Database; import creator.loki.feature.economy.repository.EconomyRepository; @@ -16,7 +18,11 @@ import creator.loki.feature.economy.service.SlotService; public class EconomyModule extends AbstractModule { @Override - protected void configure() {} + protected void configure() { + Multibinder.newSetBinder(binder(), CommandRegistrar.class) + .addBinding() + .to(EconomyCommandRegistrar.class); + } @Provides @Singleton diff --git a/src/main/java/creator/loki/feature/entertainment/EntertainmentCommandRegistrar.java b/src/main/java/creator/loki/feature/entertainment/EntertainmentCommandRegistrar.java index 2756296..51aa87c 100644 --- a/src/main/java/creator/loki/feature/entertainment/EntertainmentCommandRegistrar.java +++ b/src/main/java/creator/loki/feature/entertainment/EntertainmentCommandRegistrar.java @@ -32,23 +32,27 @@ import creator.loki.feature.social.commands.ComplimentCommand; import creator.loki.feature.social.commands.PatCommand; import creator.loki.feature.social.commands.RoastCommand; import creator.loki.feature.social.commands.SocialCommand; +import creator.loki.feature.tiktok.TikTokCommand; public class EntertainmentCommandRegistrar implements CommandRegistrar { private final CooldownManager cooldownManager; private final RatingService ratingService; private final ShipPairRepository shipPairRepository; private final BotConfig config; + private final TikTokCommand tikTokCommand; @Inject public EntertainmentCommandRegistrar( CooldownManager cooldownManager, RatingService ratingService, ShipPairRepository shipPairRepository, - BotConfig config) { + BotConfig config, + TikTokCommand tikTokCommand) { this.cooldownManager = cooldownManager; this.ratingService = ratingService; this.shipPairRepository = shipPairRepository; this.config = config; + this.tikTokCommand = tikTokCommand; } @Override @@ -61,6 +65,7 @@ public class EntertainmentCommandRegistrar implements CommandRegistrar { registry.register(new RoastCommand()); registry.register(new ComplimentCommand()); registry.register(new GlazbobaCommand()); + registry.register(tikTokCommand); registry.register(new IqCommand(ratingService)); registry.register(new GayCommand(ratingService)); diff --git a/src/main/java/creator/loki/feature/entertainment/EntertainmentModule.java b/src/main/java/creator/loki/feature/entertainment/EntertainmentModule.java new file mode 100644 index 0000000..2d66da8 --- /dev/null +++ b/src/main/java/creator/loki/feature/entertainment/EntertainmentModule.java @@ -0,0 +1,14 @@ +package creator.loki.feature.entertainment; + +import com.google.inject.AbstractModule; +import com.google.inject.multibindings.Multibinder; +import creator.loki.core.registry.CommandRegistrar; + +public class EntertainmentModule extends AbstractModule { + @Override + protected void configure() { + Multibinder.newSetBinder(binder(), CommandRegistrar.class) + .addBinding() + .to(EntertainmentCommandRegistrar.class); + } +} diff --git a/src/main/java/creator/loki/feature/help/commands/HelpCommand.java b/src/main/java/creator/loki/feature/help/commands/HelpCommand.java index a482e26..97e4a25 100644 --- a/src/main/java/creator/loki/feature/help/commands/HelpCommand.java +++ b/src/main/java/creator/loki/feature/help/commands/HelpCommand.java @@ -97,7 +97,8 @@ public class HelpCommand implements Command { boolean isNsfwChannel) { int totalCommands = commandsByCategory.entrySet().stream() - .filter(e -> HelpCategories.isCategoryVisible(e.getKey(), isAdmin, isSuper, isNsfwChannel)) + .filter( + e -> HelpCategories.isCategoryVisible(e.getKey(), isAdmin, isSuper, isNsfwChannel)) .mapToInt(e -> e.getValue().size()) .sum(); @@ -106,8 +107,7 @@ public class HelpCommand implements Command { .sorted() .forEach( category -> { - if (!HelpCategories.isCategoryVisible( - category, isAdmin, isSuper, isNsfwChannel)) { + if (!HelpCategories.isCategoryVisible(category, isAdmin, isSuper, isNsfwChannel)) { return; } diff --git a/src/main/java/creator/loki/feature/image/ImageEmbedHelper.java b/src/main/java/creator/loki/feature/image/ImageEmbedHelper.java index 2cdb234..8cf30fa 100644 --- a/src/main/java/creator/loki/feature/image/ImageEmbedHelper.java +++ b/src/main/java/creator/loki/feature/image/ImageEmbedHelper.java @@ -1,14 +1,14 @@ package creator.loki.feature.image; -import creator.loki.embed.EmbedFactory; -import net.dv8tion.jda.api.entities.MessageEmbed; -import net.dv8tion.jda.api.components.actionrow.ActionRow; -import net.dv8tion.jda.api.components.buttons.Button; - import java.util.ArrayList; import java.util.List; import java.util.Set; +import creator.loki.embed.EmbedFactory; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.buttons.Button; +import net.dv8tion.jda.api.entities.MessageEmbed; + public final class ImageEmbedHelper { private static final String NAV_PREFIX = "img_nav:"; @@ -24,9 +24,8 @@ public final class ImageEmbedHelper { boolean descUsed = false; for (String url : pageUrls) { - var builder = EmbedFactory.create() - .withTitle("🔞 " + session.site()) - .withColor(session.color()); + var builder = + EmbedFactory.create().withTitle("🔞 " + session.site()).withColor(session.color()); String ext = extension(url); boolean isVideo = isVideoExt(ext); if (!isVideo) { @@ -57,10 +56,11 @@ public final class ImageEmbedHelper { int last = embeds.size() - 1; var lastUrl = pageUrls.get(last); var lastExt = extension(lastUrl); - var lastBuilder = EmbedFactory.create() - .withTitle("🔞 " + session.site()) - .withColor(session.color()) - .withFooter(footer); + var lastBuilder = + EmbedFactory.create() + .withTitle("🔞 " + session.site()) + .withColor(session.color()) + .withFooter(footer); if (!isVideoExt(lastExt)) { lastBuilder.withImage(lastUrl); } @@ -88,8 +88,7 @@ public final class ImageEmbedHelper { boolean noNext = !session.hasNext() && session.exhausted(); return ActionRow.of( Button.primary(NAV_PREFIX + "prev", "◀ Назад").withDisabled(noPrev), - Button.primary(NAV_PREFIX + "next", "Вперёд ▶").withDisabled(noNext) - ); + Button.primary(NAV_PREFIX + "next", "Вперёд ▶").withDisabled(noNext)); } public static String formatTags(String tags) { diff --git a/src/main/java/creator/loki/feature/image/ImageModule.java b/src/main/java/creator/loki/feature/image/ImageModule.java index 11f19be..f697093 100644 --- a/src/main/java/creator/loki/feature/image/ImageModule.java +++ b/src/main/java/creator/loki/feature/image/ImageModule.java @@ -3,7 +3,9 @@ package creator.loki.feature.image; import com.google.inject.AbstractModule; import com.google.inject.Provides; import com.google.inject.Singleton; +import com.google.inject.multibindings.Multibinder; import creator.loki.config.BotConfig; +import creator.loki.core.registry.CommandRegistrar; public class ImageModule extends AbstractModule { @@ -11,6 +13,10 @@ public class ImageModule extends AbstractModule { protected void configure() { bind(ImageSessionManager.class).in(Singleton.class); bind(ImageNavigationHandler.class).in(Singleton.class); + + Multibinder.newSetBinder(binder(), CommandRegistrar.class) + .addBinding() + .to(ImageCommandRegistrar.class); } @Provides diff --git a/src/main/java/creator/loki/feature/image/ImageNavigationHandler.java b/src/main/java/creator/loki/feature/image/ImageNavigationHandler.java index 6b9c443..fda5829 100644 --- a/src/main/java/creator/loki/feature/image/ImageNavigationHandler.java +++ b/src/main/java/creator/loki/feature/image/ImageNavigationHandler.java @@ -1,5 +1,7 @@ package creator.loki.feature.image; +import java.util.List; + import com.google.inject.Inject; import com.google.inject.Singleton; import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; @@ -7,8 +9,6 @@ import net.dv8tion.jda.api.hooks.ListenerAdapter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.List; - @Singleton public class ImageNavigationHandler extends ListenerAdapter { @@ -84,7 +84,8 @@ public class ImageNavigationHandler extends ListenerAdapter { private void loadMore(ButtonInteractionEvent event, ImageSession session, String messageId) { int nextApiPage = session.apiPage() + 1; - logger.debug("loadMore: site={} tags='{}' apiPage={}", session.site(), session.tags(), nextApiPage); + logger.debug( + "loadMore: site={} tags='{}' apiPage={}", session.site(), session.tags(), nextApiPage); List newUrls = fetchPage(session.site(), session.tags(), nextApiPage); logger.debug("loadMore: got {} urls", newUrls.size()); @@ -93,7 +94,8 @@ public class ImageNavigationHandler extends ListenerAdapter { ImageSession updated = session.withMoreUrls(newUrls); sessionManager.update(messageId, updated); - event.getHook() + event + .getHook() .editOriginalEmbeds(ImageEmbedHelper.buildEmbeds(updated)) .setComponents(ImageEmbedHelper.buildActionRow(updated)) .queue(); @@ -101,18 +103,19 @@ public class ImageNavigationHandler extends ListenerAdapter { private List fetchPage(String site, String tags, int apiPage) { return switch (site) { - case "e621.net" -> imageService.fetchE621(tags, PAGE_FETCH, apiPage); - case "rule34.xxx" -> imageService.fetchR34(tags, PAGE_FETCH, apiPage); + case "e621.net" -> imageService.fetchE621(tags, PAGE_FETCH, apiPage); + case "rule34.xxx" -> imageService.fetchR34(tags, PAGE_FETCH, apiPage); case "gelbooru.com" -> imageService.fetchGelbooru(tags, PAGE_FETCH, apiPage); - case "yande.re" -> imageService.fetchYandere(tags, PAGE_FETCH, apiPage); - default -> List.of(); + case "yande.re" -> imageService.fetchYandere(tags, PAGE_FETCH, apiPage); + default -> List.of(); }; } // ── Вспомогательное ────────────────────────────────────────────────────────── private static void editMessage(ButtonInteractionEvent event, ImageSession session) { - event.editMessageEmbeds(ImageEmbedHelper.buildEmbeds(session)) + event + .editMessageEmbeds(ImageEmbedHelper.buildEmbeds(session)) .setComponents(ImageEmbedHelper.buildActionRow(session)) .queue(); } diff --git a/src/main/java/creator/loki/feature/image/ImageService.java b/src/main/java/creator/loki/feature/image/ImageService.java index c514bc2..6591bff 100644 --- a/src/main/java/creator/loki/feature/image/ImageService.java +++ b/src/main/java/creator/loki/feature/image/ImageService.java @@ -1,5 +1,10 @@ package creator.loki.feature.image; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; + import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; @@ -13,11 +18,6 @@ import okhttp3.Response; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.TimeUnit; - @Singleton public class ImageService { private static final Logger logger = LoggerFactory.getLogger(ImageService.class); @@ -48,11 +48,7 @@ public class ImageService { int capped = Math.min(limit, 100); String url = E621_API + "?tags=" + encodeTags(tags) + "&limit=" + capped + "&page=" + page; Request request = - new Request.Builder() - .url(url) - .header("User-Agent", "LokiBot/1.0 (by loki)") - .get() - .build(); + new Request.Builder().url(url).header("User-Agent", "LokiBot/1.0 (by loki)").get().build(); try (Response response = client.newCall(request).execute()) { if (!response.isSuccessful() || response.body() == null) { @@ -91,8 +87,7 @@ public class ImageService { public List fetchR34(String tags, int limit, int page) { int capped = Math.min(limit, 100); - String url = - R34_API + "&tags=" + encodeTags(tags) + "&limit=" + capped + "&pid=" + (page - 1); + String url = R34_API + "&tags=" + encodeTags(tags) + "&limit=" + capped + "&pid=" + (page - 1); if (!r34ApiKey.isEmpty() && !r34UserId.isEmpty()) { url += "&api_key=" + r34ApiKey + "&user_id=" + r34UserId; } @@ -132,11 +127,8 @@ public class ImageService { } private List fetchBooruJson(String url, int limit) { - Request request = new Request.Builder() - .url(url) - .header("User-Agent", "LokiBot/1.0 (by loki)") - .get() - .build(); + Request request = + new Request.Builder().url(url).header("User-Agent", "LokiBot/1.0 (by loki)").get().build(); try (Response response = client.newCall(request).execute()) { if (!response.isSuccessful() || response.body() == null) { @@ -145,7 +137,10 @@ public class ImageService { } String body = response.body().string(); if (body.isBlank() || !body.startsWith("[")) { - logger.warn("Booru API returned non-JSON response (code {}): {}", response.code(), body.length() > 200 ? body.substring(0, 200) : body); + logger.warn( + "Booru API returned non-JSON response (code {}): {}", + response.code(), + body.length() > 200 ? body.substring(0, 200) : body); return List.of(); } JsonArray posts = JsonParser.parseString(body).getAsJsonArray(); diff --git a/src/main/java/creator/loki/feature/image/ImageSession.java b/src/main/java/creator/loki/feature/image/ImageSession.java index 43faf44..ace72fb 100644 --- a/src/main/java/creator/loki/feature/image/ImageSession.java +++ b/src/main/java/creator/loki/feature/image/ImageSession.java @@ -6,8 +6,8 @@ import java.util.List; /** * Иммутабельная сессия навигации по изображениям. * - *

exhausted = true означает, что API вернул пустой ответ на последнем запросе, - * т.е. новых страниц больше нет — тогда кнопка «Вперёд» блокируется. + *

exhausted = true означает, что API вернул пустой ответ на последнем запросе, т.е. новых + * страниц больше нет — тогда кнопка «Вперёд» блокируется. */ public record ImageSession( List urls, @@ -37,8 +37,17 @@ public record ImageSession( long userId, long guildId) { return new ImageSession( - urls, 0, pageSize, site, color, tags, userId, guildId, - System.currentTimeMillis(), 1, false); + urls, + 0, + pageSize, + site, + color, + tags, + userId, + guildId, + System.currentTimeMillis(), + 1, + false); } // ── Навигация ──────────────────────────────────────────────────────────────── @@ -66,25 +75,52 @@ public record ImageSession( public ImageSession withPage(int newPage) { return new ImageSession( - urls, newPage, pageSize, site, color, tags, userId, guildId, - System.currentTimeMillis(), apiPage, exhausted); + urls, + newPage, + pageSize, + site, + color, + tags, + userId, + guildId, + System.currentTimeMillis(), + apiPage, + exhausted); } /** - * Добавляем новые URL из следующей API-страницы и переходим на следующую - * внутреннюю страницу. Если newUrls пустой — помечаем сессию как exhausted. + * Добавляем новые URL из следующей API-страницы и переходим на следующую внутреннюю страницу. + * Если newUrls пустой — помечаем сессию как exhausted. */ public ImageSession withMoreUrls(List newUrls) { if (newUrls.isEmpty()) { // API больше ничего не отдал — остаёмся на месте, ставим флаг return new ImageSession( - urls, page, pageSize, site, color, tags, userId, guildId, - System.currentTimeMillis(), apiPage, true); + urls, + page, + pageSize, + site, + color, + tags, + userId, + guildId, + System.currentTimeMillis(), + apiPage, + true); } List combined = new ArrayList<>(urls); combined.addAll(newUrls); return new ImageSession( - combined, page + 1, pageSize, site, color, tags, userId, guildId, - System.currentTimeMillis(), apiPage + 1, false); + combined, + page + 1, + pageSize, + site, + color, + tags, + userId, + guildId, + System.currentTimeMillis(), + apiPage + 1, + false); } } diff --git a/src/main/java/creator/loki/feature/image/ImageSessionManager.java b/src/main/java/creator/loki/feature/image/ImageSessionManager.java index bcabf70..754ed75 100644 --- a/src/main/java/creator/loki/feature/image/ImageSessionManager.java +++ b/src/main/java/creator/loki/feature/image/ImageSessionManager.java @@ -1,7 +1,6 @@ package creator.loki.feature.image; import java.awt.Color; -import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; diff --git a/src/main/java/creator/loki/feature/image/commands/AbstractImageCommand.java b/src/main/java/creator/loki/feature/image/commands/AbstractImageCommand.java index 9e709ff..85a3430 100644 --- a/src/main/java/creator/loki/feature/image/commands/AbstractImageCommand.java +++ b/src/main/java/creator/loki/feature/image/commands/AbstractImageCommand.java @@ -1,5 +1,8 @@ package creator.loki.feature.image.commands; +import java.awt.Color; +import java.util.List; + import creator.loki.command.Command; import creator.loki.command.CommandContext; import creator.loki.embed.EmbedTemplates; @@ -9,24 +12,21 @@ import creator.loki.feature.image.ImageSession; import creator.loki.feature.image.ImageSessionManager; import net.dv8tion.jda.api.entities.channel.attribute.IAgeRestrictedChannel; -import java.awt.Color; -import java.util.List; - /** - * Общая база для команд поиска изображений. - * Убирает дублирование: NSFW-проверка, parseArgs, buildEmbeds, buildButtons. + * Общая база для команд поиска изображений. Убирает дублирование: NSFW-проверка, parseArgs, + * buildEmbeds, buildButtons. */ public abstract class AbstractImageCommand implements Command { - protected static final int PAGE_SIZE = 10; + protected static final int PAGE_SIZE = 10; protected static final int DEFAULT_FETCH = 30; - protected static final int MAX_FETCH = 50; + protected static final int MAX_FETCH = 50; protected final ImageService imageService; protected final ImageSessionManager sessionManager; protected AbstractImageCommand(ImageService imageService, ImageSessionManager sessionManager) { - this.imageService = imageService; + this.imageService = imageService; this.sessionManager = sessionManager; } @@ -49,40 +49,54 @@ public abstract class AbstractImageCommand implements Command { @Override public void execute(CommandContext context) { if (!isNsfwChannel(context)) { - context.getChannel() - .sendMessageEmbeds(EmbedTemplates.error( - "🔞 NSFW", "Эта команда доступна только в каналах с возрастным ограничением.")) + context + .getChannel() + .sendMessageEmbeds( + EmbedTemplates.error( + "🔞 NSFW", "Эта команда доступна только в каналах с возрастным ограничением.")) .queue(); return; } ParseResult parsed = parseArgs(context.getArgs()); - String searchingMsg = "Ищу изображения на " + siteDisplayName() - + ImageEmbedHelper.tagsDesc(parsed.tags()) + "..."; + String searchingMsg = + "Ищу изображения на " + + siteDisplayName() + + ImageEmbedHelper.tagsDesc(parsed.tags()) + + "..."; - context.getChannel() + context + .getChannel() .sendMessageEmbeds(EmbedTemplates.info("🔍 Поиск", searchingMsg)) - .queue(msg -> { - List urls = fetchUrls(parsed.tags(), parsed.limit()); - if (urls.isEmpty()) { - msg.editMessageEmbeds(EmbedTemplates.warning( - "😔 Ничего не найдено", - "По запросу" + ImageEmbedHelper.tagsDesc(parsed.tags()) + " ничего не найдено.")) - .queue(); - return; - } + .queue( + msg -> { + List urls = fetchUrls(parsed.tags(), parsed.limit()); + if (urls.isEmpty()) { + msg.editMessageEmbeds( + EmbedTemplates.warning( + "😔 Ничего не найдено", + "По запросу" + + ImageEmbedHelper.tagsDesc(parsed.tags()) + + " ничего не найдено.")) + .queue(); + return; + } - ImageSession session = ImageSession.create( - urls, PAGE_SIZE, siteName(), siteColor(), - parsed.tags(), - context.getAuthor().getIdLong(), - context.getGuild().getIdLong()); - sessionManager.put(msg.getId(), session); + ImageSession session = + ImageSession.create( + urls, + PAGE_SIZE, + siteName(), + siteColor(), + parsed.tags(), + context.getAuthor().getIdLong(), + context.getGuild().getIdLong()); + sessionManager.put(msg.getId(), session); - msg.editMessageEmbeds(ImageEmbedHelper.buildEmbeds(session)) - .setComponents(ImageEmbedHelper.buildActionRow(session)) - .queue(); - }); + msg.editMessageEmbeds(ImageEmbedHelper.buildEmbeds(session)) + .setComponents(ImageEmbedHelper.buildActionRow(session)) + .queue(); + }); } // ── Вспомогательное ────────────────────────────────────────────────────────── diff --git a/src/main/java/creator/loki/feature/image/commands/E621Command.java b/src/main/java/creator/loki/feature/image/commands/E621Command.java index 1d16c6f..60bd3a9 100644 --- a/src/main/java/creator/loki/feature/image/commands/E621Command.java +++ b/src/main/java/creator/loki/feature/image/commands/E621Command.java @@ -1,26 +1,52 @@ package creator.loki.feature.image.commands; +import java.awt.Color; +import java.util.List; + import creator.loki.command.CommandCategories; import creator.loki.feature.image.ImageService; import creator.loki.feature.image.ImageSessionManager; -import java.awt.Color; -import java.util.List; - public class E621Command extends AbstractImageCommand { public E621Command(ImageService imageService, ImageSessionManager sessionManager) { super(imageService, sessionManager); } - @Override public String getName() { return "e621"; } - @Override public String getDescription() { return "Поиск изображений на e621.net"; } - @Override public String getUsage() { return "e621 <кол-во до 50> <теги>"; } - @Override public String getCategory() { return CommandCategories.NSFW_IMAGES; } + @Override + public String getName() { + return "e621"; + } - @Override protected String siteName() { return "e621.net"; } - @Override protected String siteDisplayName() { return "e621"; } - @Override protected Color siteColor() { return new Color(102, 45, 145); } + @Override + public String getDescription() { + return "Поиск изображений на e621.net"; + } + + @Override + public String getUsage() { + return "e621 <кол-во до 50> <теги>"; + } + + @Override + public String getCategory() { + return CommandCategories.NSFW_IMAGES; + } + + @Override + protected String siteName() { + return "e621.net"; + } + + @Override + protected String siteDisplayName() { + return "e621"; + } + + @Override + protected Color siteColor() { + return new Color(102, 45, 145); + } @Override protected List fetchUrls(String tags, int limit) { diff --git a/src/main/java/creator/loki/feature/image/commands/E621RandomCommand.java b/src/main/java/creator/loki/feature/image/commands/E621RandomCommand.java index e22bcf9..e27d2fb 100644 --- a/src/main/java/creator/loki/feature/image/commands/E621RandomCommand.java +++ b/src/main/java/creator/loki/feature/image/commands/E621RandomCommand.java @@ -1,5 +1,8 @@ package creator.loki.feature.image.commands; +import java.awt.Color; +import java.util.List; + import creator.loki.command.CommandCategories; import creator.loki.command.CommandContext; import creator.loki.embed.EmbedTemplates; @@ -8,23 +11,46 @@ import creator.loki.feature.image.ImageService; import creator.loki.feature.image.ImageSession; import creator.loki.feature.image.ImageSessionManager; -import java.awt.Color; -import java.util.List; - public class E621RandomCommand extends AbstractImageCommand { public E621RandomCommand(ImageService imageService, ImageSessionManager sessionManager) { super(imageService, sessionManager); } - @Override public String getName() { return "e621-random"; } - @Override public String getDescription() { return "Случайные изображения с e621.net"; } - @Override public String getUsage() { return "e621-random <кол-во до 50>"; } - @Override public String getCategory() { return CommandCategories.NSFW_IMAGES; } + @Override + public String getName() { + return "e621-random"; + } - @Override protected String siteName() { return "e621.net"; } - @Override protected String siteDisplayName() { return "e621 (случайные)"; } - @Override protected Color siteColor() { return new Color(102, 45, 145); } + @Override + public String getDescription() { + return "Случайные изображения с e621.net"; + } + + @Override + public String getUsage() { + return "e621-random <кол-во до 50>"; + } + + @Override + public String getCategory() { + return CommandCategories.NSFW_IMAGES; + } + + @Override + protected String siteName() { + return "e621.net"; + } + + @Override + protected String siteDisplayName() { + return "e621 (случайные)"; + } + + @Override + protected Color siteColor() { + return new Color(102, 45, 145); + } @Override protected List fetchUrls(String tags, int limit) { @@ -35,9 +61,11 @@ public class E621RandomCommand extends AbstractImageCommand { @Override public void execute(CommandContext context) { if (!isNsfwChannel(context)) { - context.getChannel() - .sendMessageEmbeds(EmbedTemplates.error( - "🔞 NSFW", "Эта команда доступна только в каналах с возрастным ограничением.")) + context + .getChannel() + .sendMessageEmbeds( + EmbedTemplates.error( + "🔞 NSFW", "Эта команда доступна только в каналах с возрастным ограничением.")) .queue(); return; } @@ -48,31 +76,40 @@ public class E621RandomCommand extends AbstractImageCommand { try { limit = Integer.parseInt(args.get(0)); limit = Math.max(1, Math.min(limit, MAX_FETCH)); - } catch (NumberFormatException ignored) {} + } catch (NumberFormatException ignored) { + } } final int finalLimit = limit; - context.getChannel() - .sendMessageEmbeds(EmbedTemplates.info("🔍 Случайные", "Ищу случайные изображения на e621...")) - .queue(msg -> { - List urls = imageService.fetchE621("", finalLimit); - if (urls.isEmpty()) { - msg.editMessageEmbeds(EmbedTemplates.warning( - "😔 Ничего не найдено", "Не удалось найти изображения.")) - .queue(); - return; - } + context + .getChannel() + .sendMessageEmbeds( + EmbedTemplates.info("🔍 Случайные", "Ищу случайные изображения на e621...")) + .queue( + msg -> { + List urls = imageService.fetchE621("", finalLimit); + if (urls.isEmpty()) { + msg.editMessageEmbeds( + EmbedTemplates.warning( + "😔 Ничего не найдено", "Не удалось найти изображения.")) + .queue(); + return; + } - ImageSession session = ImageSession.create( - urls, PAGE_SIZE, siteName(), siteColor(), - "", - context.getAuthor().getIdLong(), - context.getGuild().getIdLong()); - sessionManager.put(msg.getId(), session); + ImageSession session = + ImageSession.create( + urls, + PAGE_SIZE, + siteName(), + siteColor(), + "", + context.getAuthor().getIdLong(), + context.getGuild().getIdLong()); + sessionManager.put(msg.getId(), session); - msg.editMessageEmbeds(ImageEmbedHelper.buildEmbeds(session)) - .setComponents(ImageEmbedHelper.buildActionRow(session)) - .queue(); - }); + msg.editMessageEmbeds(ImageEmbedHelper.buildEmbeds(session)) + .setComponents(ImageEmbedHelper.buildActionRow(session)) + .queue(); + }); } } diff --git a/src/main/java/creator/loki/feature/image/commands/GelbooruCommand.java b/src/main/java/creator/loki/feature/image/commands/GelbooruCommand.java index b86c78a..0b845de 100644 --- a/src/main/java/creator/loki/feature/image/commands/GelbooruCommand.java +++ b/src/main/java/creator/loki/feature/image/commands/GelbooruCommand.java @@ -1,26 +1,52 @@ package creator.loki.feature.image.commands; +import java.awt.Color; +import java.util.List; + import creator.loki.command.CommandCategories; import creator.loki.feature.image.ImageService; import creator.loki.feature.image.ImageSessionManager; -import java.awt.Color; -import java.util.List; - public class GelbooruCommand extends AbstractImageCommand { public GelbooruCommand(ImageService imageService, ImageSessionManager sessionManager) { super(imageService, sessionManager); } - @Override public String getName() { return "gelbooru"; } - @Override public String getDescription() { return "Поиск изображений на Gelbooru"; } - @Override public String getUsage() { return "gelbooru <кол-во до 50> <теги>"; } - @Override public String getCategory() { return CommandCategories.NSFW_IMAGES; } + @Override + public String getName() { + return "gelbooru"; + } - @Override protected String siteName() { return "gelbooru.com"; } - @Override protected String siteDisplayName() { return "Gelbooru"; } - @Override protected Color siteColor() { return new Color(50, 100, 180); } + @Override + public String getDescription() { + return "Поиск изображений на Gelbooru"; + } + + @Override + public String getUsage() { + return "gelbooru <кол-во до 50> <теги>"; + } + + @Override + public String getCategory() { + return CommandCategories.NSFW_IMAGES; + } + + @Override + protected String siteName() { + return "gelbooru.com"; + } + + @Override + protected String siteDisplayName() { + return "Gelbooru"; + } + + @Override + protected Color siteColor() { + return new Color(50, 100, 180); + } @Override protected List fetchUrls(String tags, int limit) { diff --git a/src/main/java/creator/loki/feature/image/commands/R34Command.java b/src/main/java/creator/loki/feature/image/commands/R34Command.java index acc78cb..43f2f4d 100644 --- a/src/main/java/creator/loki/feature/image/commands/R34Command.java +++ b/src/main/java/creator/loki/feature/image/commands/R34Command.java @@ -1,26 +1,52 @@ package creator.loki.feature.image.commands; +import java.awt.Color; +import java.util.List; + import creator.loki.command.CommandCategories; import creator.loki.feature.image.ImageService; import creator.loki.feature.image.ImageSessionManager; -import java.awt.Color; -import java.util.List; - public class R34Command extends AbstractImageCommand { public R34Command(ImageService imageService, ImageSessionManager sessionManager) { super(imageService, sessionManager); } - @Override public String getName() { return "r34"; } - @Override public String getDescription() { return "Поиск изображений на Rule34"; } - @Override public String getUsage() { return "r34 <кол-во до 50> <теги>"; } - @Override public String getCategory() { return CommandCategories.NSFW_IMAGES; } + @Override + public String getName() { + return "r34"; + } - @Override protected String siteName() { return "rule34.xxx"; } - @Override protected String siteDisplayName() { return "Rule34"; } - @Override protected Color siteColor() { return new Color(200, 100, 0); } + @Override + public String getDescription() { + return "Поиск изображений на Rule34"; + } + + @Override + public String getUsage() { + return "r34 <кол-во до 50> <теги>"; + } + + @Override + public String getCategory() { + return CommandCategories.NSFW_IMAGES; + } + + @Override + protected String siteName() { + return "rule34.xxx"; + } + + @Override + protected String siteDisplayName() { + return "Rule34"; + } + + @Override + protected Color siteColor() { + return new Color(200, 100, 0); + } @Override protected List fetchUrls(String tags, int limit) { diff --git a/src/main/java/creator/loki/feature/image/commands/YandereCommand.java b/src/main/java/creator/loki/feature/image/commands/YandereCommand.java index 77bfdba..f6069a3 100644 --- a/src/main/java/creator/loki/feature/image/commands/YandereCommand.java +++ b/src/main/java/creator/loki/feature/image/commands/YandereCommand.java @@ -1,26 +1,52 @@ package creator.loki.feature.image.commands; +import java.awt.Color; +import java.util.List; + import creator.loki.command.CommandCategories; import creator.loki.feature.image.ImageService; import creator.loki.feature.image.ImageSessionManager; -import java.awt.Color; -import java.util.List; - public class YandereCommand extends AbstractImageCommand { public YandereCommand(ImageService imageService, ImageSessionManager sessionManager) { super(imageService, sessionManager); } - @Override public String getName() { return "yandere"; } - @Override public String getDescription() { return "Поиск изображений на yande.re"; } - @Override public String getUsage() { return "yandere <кол-во до 50> <теги>"; } - @Override public String getCategory() { return CommandCategories.NSFW_IMAGES; } + @Override + public String getName() { + return "yandere"; + } - @Override protected String siteName() { return "yande.re"; } - @Override protected String siteDisplayName() { return "yande.re"; } - @Override protected Color siteColor() { return new Color(220, 50, 120); } + @Override + public String getDescription() { + return "Поиск изображений на yande.re"; + } + + @Override + public String getUsage() { + return "yandere <кол-во до 50> <теги>"; + } + + @Override + public String getCategory() { + return CommandCategories.NSFW_IMAGES; + } + + @Override + protected String siteName() { + return "yande.re"; + } + + @Override + protected String siteDisplayName() { + return "yande.re"; + } + + @Override + protected Color siteColor() { + return new Color(220, 50, 120); + } @Override protected List fetchUrls(String tags, int limit) { diff --git a/src/main/java/creator/loki/feature/level/LevelModule.java b/src/main/java/creator/loki/feature/level/LevelModule.java index a93ee70..29cceed 100644 --- a/src/main/java/creator/loki/feature/level/LevelModule.java +++ b/src/main/java/creator/loki/feature/level/LevelModule.java @@ -3,9 +3,12 @@ package creator.loki.feature.level; import com.google.inject.AbstractModule; import com.google.inject.Provides; import com.google.inject.Singleton; +import com.google.inject.multibindings.Multibinder; +import creator.loki.core.registry.CommandRegistrar; import creator.loki.database.Database; import creator.loki.feature.config.repository.ConfigRepository; import creator.loki.feature.config.repository.GuildConfigRepository; +import creator.loki.feature.level.commands.LevelCommandRegistrar; import creator.loki.feature.level.commands.ResetLevelsCommand; import creator.loki.feature.level.domain.XpFormula; import creator.loki.feature.level.listener.ResetLevelsButtonHandler; @@ -24,6 +27,10 @@ public class LevelModule extends AbstractModule { protected void configure() { bind(VoiceTracker.class).in(Singleton.class); bind(ResetLevelsButtonHandler.class).in(Singleton.class); + + Multibinder.newSetBinder(binder(), CommandRegistrar.class) + .addBinding() + .to(LevelCommandRegistrar.class); } @Provides diff --git a/src/main/java/creator/loki/feature/level/service/VoiceTracker.java b/src/main/java/creator/loki/feature/level/service/VoiceTracker.java index 4b175a6..9e3842e 100644 --- a/src/main/java/creator/loki/feature/level/service/VoiceTracker.java +++ b/src/main/java/creator/loki/feature/level/service/VoiceTracker.java @@ -8,6 +8,7 @@ import java.util.concurrent.TimeUnit; import com.google.inject.Inject; import com.google.inject.Singleton; +import creator.loki.core.Lifecycle; import creator.loki.feature.economy.service.EconomyService; import creator.loki.feature.level.domain.LevelUpResult; import net.dv8tion.jda.api.entities.Member; @@ -17,7 +18,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; @Singleton -public class VoiceTracker extends ListenerAdapter { +public class VoiceTracker extends ListenerAdapter implements Lifecycle { private static final Logger logger = LoggerFactory.getLogger(VoiceTracker.class); private final LevelService levelService; private final EconomyService economyService; diff --git a/src/main/java/creator/loki/feature/metrics/HealthHttpServer.java b/src/main/java/creator/loki/feature/metrics/HealthHttpServer.java index 30787c0..1414a6b 100644 --- a/src/main/java/creator/loki/feature/metrics/HealthHttpServer.java +++ b/src/main/java/creator/loki/feature/metrics/HealthHttpServer.java @@ -11,6 +11,7 @@ import com.google.inject.Singleton; import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; import creator.loki.config.BotConfig; +import creator.loki.core.Lifecycle; import creator.loki.util.LoggerDecorator; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,7 +21,7 @@ import org.slf4j.LoggerFactory; * BotConfig#getHealthHttpPort()} (0 = выкл). */ @Singleton -public class HealthHttpServer { +public class HealthHttpServer implements Lifecycle { private static final Logger logger = LoggerFactory.getLogger(HealthHttpServer.class); private final BotConfig config; @@ -106,7 +107,8 @@ public class HealthHttpServer { } } - public synchronized void stop() { + @Override + public synchronized void shutdown() { if (server != null) { server.stop(1); server = null; diff --git a/src/main/java/creator/loki/feature/moderation/ModerationModule.java b/src/main/java/creator/loki/feature/moderation/ModerationModule.java index 0c0f3f9..2aa8106 100644 --- a/src/main/java/creator/loki/feature/moderation/ModerationModule.java +++ b/src/main/java/creator/loki/feature/moderation/ModerationModule.java @@ -3,6 +3,8 @@ package creator.loki.feature.moderation; import com.google.inject.AbstractModule; import com.google.inject.Provides; import com.google.inject.Singleton; +import com.google.inject.multibindings.Multibinder; +import creator.loki.core.registry.CommandRegistrar; import creator.loki.database.Database; import creator.loki.feature.config.repository.GuildConfigRepository; import creator.loki.feature.moderation.repository.ActiveMuteRepository; @@ -15,7 +17,11 @@ import creator.loki.feature.moderation.repository.WarningRepository; public class ModerationModule extends AbstractModule { @Override - protected void configure() {} + protected void configure() { + Multibinder.newSetBinder(binder(), CommandRegistrar.class) + .addBinding() + .to(ModerationCommandRegistrar.class); + } @Provides @Singleton diff --git a/src/main/java/creator/loki/feature/moderation/MuteScheduler.java b/src/main/java/creator/loki/feature/moderation/MuteScheduler.java index 0a39ce5..ce61239 100644 --- a/src/main/java/creator/loki/feature/moderation/MuteScheduler.java +++ b/src/main/java/creator/loki/feature/moderation/MuteScheduler.java @@ -7,6 +7,7 @@ import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +import creator.loki.core.Lifecycle; import creator.loki.feature.config.repository.GuildConfigRepository; import creator.loki.feature.moderation.repository.ActiveMuteRepository; import creator.loki.feature.moderation.repository.ModerationCaseRepository; @@ -17,7 +18,7 @@ import net.dv8tion.jda.api.entities.Role; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class MuteScheduler { +public class MuteScheduler implements Lifecycle { private static final Logger logger = LoggerFactory.getLogger(MuteScheduler.class); diff --git a/src/main/java/creator/loki/feature/moderation/commands/punish/MuteCommand.java b/src/main/java/creator/loki/feature/moderation/commands/punish/MuteCommand.java index 1d5f905..462d937 100644 --- a/src/main/java/creator/loki/feature/moderation/commands/punish/MuteCommand.java +++ b/src/main/java/creator/loki/feature/moderation/commands/punish/MuteCommand.java @@ -15,9 +15,13 @@ import creator.loki.feature.moderation.util.ModerationHelper; import creator.loki.util.MuteDurationParser; 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.Role; public class MuteCommand implements Command { + private static final int MAX_REASON_LENGTH = 500; + private static final int MAX_LOG_REASON_LENGTH = 1000; + private final ModerationCaseRepository cases; private final ActiveMuteRepository activeMutes; private final ConfigRepository configRepo; @@ -73,19 +77,49 @@ public class MuteCommand implements Command { err(ctx, "Укажи пользователя"); return; } - var t = mentioned.get(0); - if (t.getId().equals(mod.getId())) { + var target = mentioned.get(0); + if (!validateMute(ctx, target, mod, g)) return; + var parsed = parseDurationAndReason(ctx); + if (parsed == null) return; + Role muteRole; + try { + muteRole = muteRoleService.getOrCreateMuteRole(g); + } catch (Exception e) { + err(ctx, "Ошибка роли"); + return; + } + if (!g.getMember(g.getJDA().getSelfUser()).canInteract(muteRole)) { + err(ctx, "Подними роль бота выше роли **Мут**"); + return; + } + long caseId = createMuteCase(g, target, mod, parsed.dur, parsed.reason); + if (caseId < 0) { + err(ctx, "Ошибка записи кейса"); + return; + } + applyMuteRole(ctx, g, target, muteRole, caseId, parsed.dur, parsed.reason, mod); + } + + private boolean validateMute( + CommandContext ctx, Member target, Member mod, net.dv8tion.jda.api.entities.Guild g) { + if (target.getId().equals(mod.getId())) { err(ctx, "Нельзя замутить себя"); - return; + return false; } - if (!mod.canInteract(t)) { + if (!mod.canInteract(target)) { err(ctx, "Роль выше"); - return; + return false; } - if (!g.getMember(g.getJDA().getSelfUser()).canInteract(t)) { + if (!g.getMember(g.getJDA().getSelfUser()).canInteract(target)) { err(ctx, "Бот не может"); - return; + return false; } + return true; + } + + private record ParsedDuration(MuteDurationParser.MuteDuration dur, String reason) {} + + private ParsedDuration parseDurationAndReason(CommandContext ctx) { MuteDurationParser.MuteDuration dur = null; String durToken = null; for (String a : ctx.getArgs()) { @@ -99,7 +133,7 @@ public class MuteCommand implements Command { } if (dur == null) { err(ctx, "Укажи длительность: `30s`, `5m`, `2h`, `1d` или `perm`"); - return; + return null; } boolean past = false; var sb = new StringBuilder(); @@ -112,85 +146,113 @@ public class MuteCommand implements Command { if (!sb.isEmpty()) sb.append(' '); sb.append(a); } - String reasonTmp = sb.toString().trim(); - String reason = reasonTmp.isEmpty() ? "Без причины" : reasonTmp; - Role muteRole; - try { - muteRole = muteRoleService.getOrCreateMuteRole(g); - } catch (Exception e) { - err(ctx, "Ошибка роли"); - return; - } - if (!g.getMember(g.getJDA().getSelfUser()).canInteract(muteRole)) { - err(ctx, "Подними роль бота выше роли **Мут**"); - return; - } + String tmp = sb.toString().trim(); + String reason = tmp.isEmpty() ? "Без причины" : tmp; + return new ParsedDuration(dur, reason); + } + + private long createMuteCase( + net.dv8tion.jda.api.entities.Guild g, + Member target, + Member mod, + MuteDurationParser.MuteDuration dur, + String reason) { boolean permanent = dur.permanent(); long secs = dur.secondsTotal(); long untilMs = permanent ? 0L : System.currentTimeMillis() + secs * 1000; Long durSec = permanent ? null : secs; long caseId = cases.insert( - g.getId(), "MUTE", t.getId(), mod.getId(), reason, durSec, permanent ? null : untilMs); - if (caseId < 0) { - err(ctx, "Ошибка записи кейса"); - return; + g.getId(), + "MUTE", + target.getId(), + mod.getId(), + reason, + durSec, + permanent ? null : untilMs); + if (caseId >= 0) { + activeMutes.upsert(g.getId(), target.getId(), permanent ? 0L : untilMs, caseId); + muteScheduler.cancelPending(g.getId(), target.getId()); } - activeMutes.upsert(g.getId(), t.getId(), permanent ? 0L : untilMs, caseId); - muteScheduler.cancelPending(g.getId(), t.getId()); - g.addRoleToMember(t, muteRole) + return caseId; + } + + private void applyMuteRole( + CommandContext ctx, + net.dv8tion.jda.api.entities.Guild g, + Member target, + Role muteRole, + long caseId, + MuteDurationParser.MuteDuration dur, + String reason, + Member mod) { + boolean permanent = dur.permanent(); + long secs = dur.secondsTotal(); + long untilMs = permanent ? 0L : System.currentTimeMillis() + secs * 1000; + g.addRoleToMember(target, muteRole) .queue( ok -> { - muteScheduler.scheduleExpiry(g.getId(), t.getId(), permanent ? 0L : untilMs); + muteScheduler.scheduleExpiry(g.getId(), target.getId(), permanent ? 0L : untilMs); ctx.getChannel() - .sendMessageEmbeds( - EmbedFactory.create() - .withTitle("✅ Мут выдан") - .withColor(Color.GREEN) - .withDescription( - "**Кому:** " - + t.getAsMention() - + "\n**Кейс:** `" - + caseId - + "`" - + (permanent - ? "\n**Срок:** навсегда" - : "\n**Срок:** ") - + "\n**Причина:** " - + sub(reason, 500)) - .build()) + .sendMessageEmbeds(buildMuteConfirmEmbed(target, caseId, dur, reason, untilMs)) .queue(); - var logId = configRepo.get("moderation.log_channel_id"); - if (logId != null) { - var log = g.getTextChannelById(logId); - if (log != null) { - log.sendMessageEmbeds( - new EmbedBuilder() - .setTitle("🔇 Мут") - .setColor(Color.DARK_GRAY) - .addField( - "Участник", t.getAsMention() + " (`" + t.getId() + "`)", false) - .addField("Модератор", mod.getAsMention(), false) - .addField("Кейс", "`" + caseId + "`", true) - .addField( - "Срок", - permanent ? "Навсегда" : "", - true) - .addField("Причина", sub(reason, 1000), false) - .setTimestamp(OffsetDateTime.now()) - .build()) - .queue(); - } - } + sendMuteLogEmbed(g, target, mod, caseId, dur, reason, untilMs); }, err2 -> { - activeMutes.delete(g.getId(), t.getId()); + activeMutes.delete(g.getId(), target.getId()); err(ctx, "Ошибка выдачи роли"); }); } - private static String sub(String s, int max) { - return ModerationHelper.trunc(s, max); + private net.dv8tion.jda.api.entities.MessageEmbed buildMuteConfirmEmbed( + Member target, + long caseId, + MuteDurationParser.MuteDuration dur, + String reason, + long untilMs) { + boolean permanent = dur.permanent(); + return EmbedFactory.create() + .withTitle("✅ Мут выдан") + .withColor(Color.GREEN) + .withDescription( + "**Кому:** " + + target.getAsMention() + + "\n**Кейс:** `" + + caseId + + "`" + + (permanent + ? "\n**Срок:** навсегда" + : "\n**Срок:** ") + + "\n**Причина:** " + + ModerationHelper.trunc(reason, MAX_REASON_LENGTH)) + .build(); + } + + private void sendMuteLogEmbed( + net.dv8tion.jda.api.entities.Guild g, + Member target, + Member mod, + long caseId, + MuteDurationParser.MuteDuration dur, + String reason, + long untilMs) { + var logId = configRepo.get("moderation.log_channel_id"); + if (logId == null) return; + var log = g.getTextChannelById(logId); + if (log == null) return; + boolean permanent = dur.permanent(); + log.sendMessageEmbeds( + new EmbedBuilder() + .setTitle("🔇 Мут") + .setColor(Color.DARK_GRAY) + .addField("Участник", target.getAsMention() + " (`" + target.getId() + "`)", false) + .addField("Модератор", mod.getAsMention(), false) + .addField("Кейс", "`" + caseId + "`", true) + .addField("Срок", permanent ? "Навсегда" : "", true) + .addField("Причина", ModerationHelper.trunc(reason, MAX_LOG_REASON_LENGTH), false) + .setTimestamp(OffsetDateTime.now()) + .build()) + .queue(); } private static void err(CommandContext ctx, String msg) { diff --git a/src/main/java/creator/loki/feature/music/MusicModule.java b/src/main/java/creator/loki/feature/music/MusicModule.java index 576cb86..5f6aee6 100644 --- a/src/main/java/creator/loki/feature/music/MusicModule.java +++ b/src/main/java/creator/loki/feature/music/MusicModule.java @@ -2,6 +2,8 @@ package creator.loki.feature.music; import com.google.inject.AbstractModule; import com.google.inject.Singleton; +import com.google.inject.multibindings.Multibinder; +import creator.loki.core.registry.CommandRegistrar; import creator.loki.feature.music.core.LavalinkManager; import creator.loki.feature.music.core.PlayerManager; @@ -10,5 +12,9 @@ public class MusicModule extends AbstractModule { protected void configure() { bind(LavalinkManager.class).in(Singleton.class); bind(PlayerManager.class).in(Singleton.class); + + Multibinder.newSetBinder(binder(), CommandRegistrar.class) + .addBinding() + .to(MusicCommandRegistrar.class); } } diff --git a/src/main/java/creator/loki/feature/music/MusicSlashCommands.java b/src/main/java/creator/loki/feature/music/MusicSlashCommands.java index a249d64..dad43b5 100644 --- a/src/main/java/creator/loki/feature/music/MusicSlashCommands.java +++ b/src/main/java/creator/loki/feature/music/MusicSlashCommands.java @@ -60,7 +60,8 @@ public class MusicSlashCommands extends ListenerAdapter { .addOption(OptionType.USER, "user", "Цель", false), Commands.slash("clear", "Удалить сообщения") .addOption(OptionType.INTEGER, "amount", "Количество (1-100)", true) - .addOption(OptionType.USER, "user", "Оставить только сообщения пользователя", false)) + .addOption( + OptionType.USER, "user", "Оставить только сообщения пользователя", false)) .queue(); } diff --git a/src/main/java/creator/loki/feature/music/manage/TrackScheduler.java b/src/main/java/creator/loki/feature/music/manage/TrackScheduler.java index 87cfe2e..0a8a592 100644 --- a/src/main/java/creator/loki/feature/music/manage/TrackScheduler.java +++ b/src/main/java/creator/loki/feature/music/manage/TrackScheduler.java @@ -5,11 +5,9 @@ import java.util.Collections; import java.util.LinkedList; import java.util.List; import java.util.Queue; -import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import creator.loki.util.LoggerDecorator; -import dev.arbjerg.lavalink.client.Link; import dev.arbjerg.lavalink.client.player.LavalinkPlayer; import dev.arbjerg.lavalink.client.player.Track; import dev.arbjerg.lavalink.protocol.v4.Message.EmittedEvent.TrackEndEvent.AudioTrackEndReason; @@ -20,12 +18,14 @@ public class TrackScheduler { private static final Logger log = LoggerFactory.getLogger(TrackScheduler.class); private static final Consumer LOG_LAV = t -> log.warn("Lav: {}", t.getMessage()); private final GuildMusicManager manager; + private final TrackStarter trackStarter; final Queue queue = new LinkedList<>(); private boolean repeating; public final FilterState filterState = new FilterState(); public TrackScheduler(GuildMusicManager manager) { this.manager = manager; + this.trackStarter = new TrackStarter(manager.getExecutor()); } public void enqueue(Track track) { @@ -103,13 +103,11 @@ public class TrackScheduler { public void onTrackEnd(Track lastTrack, AudioTrackEndReason endReason) { if (endReason == AudioTrackEndReason.LOAD_FAILED) { - LoggerDecorator.warn(log, "Load failed, skipping to next"); - nextOrAutoLeave(); + handleLoadFailed(); } else if (!endReason.getMayStartNext() && endReason != AudioTrackEndReason.FINISHED) { - LoggerDecorator.warn(log, "Track ended unexpectedly: " + endReason); - manager.startAutoLeaveTimer(); + handleUnexpectedEnd(endReason); } else if (repeating && endReason == AudioTrackEndReason.FINISHED) { - startTrack(lastTrack.makeClone()); + handleFinished(lastTrack); } else { nextOrAutoLeave(); } @@ -118,7 +116,21 @@ public class TrackScheduler { void startTrack(Track track) { manager.cancelAutoLeaveTimer(); var link = manager.getLavalinkManager().getOrCreateLink(manager.getGuildId()); - sendTrackDelayed(link, track, 0); + trackStarter.startWithRetry(link, track); + } + + private void handleLoadFailed() { + LoggerDecorator.warn(log, "Load failed, skipping to next"); + nextOrAutoLeave(); + } + + private void handleUnexpectedEnd(AudioTrackEndReason endReason) { + LoggerDecorator.warn(log, "Track ended unexpectedly: " + endReason); + manager.startAutoLeaveTimer(); + } + + private void handleFinished(Track lastTrack) { + startTrack(lastTrack.makeClone()); } private void nextOrAutoLeave() { @@ -126,21 +138,4 @@ public class TrackScheduler { if (next != null) startTrack(next); else manager.startAutoLeaveTimer(); } - - private void scheduleRetry(Runnable r, long delayMs) { - manager.getExecutor().schedule(r, delayMs, TimeUnit.MILLISECONDS); - } - - private void sendTrackDelayed(Link link, Track track, int attempt) { - if (attempt > 5) return; - if (link.getCachedPlayer() == null) { - scheduleRetry(() -> sendTrackDelayed(link, track, attempt + 1), 300); - return; - } - link.createOrUpdatePlayer() - .setTrack(track) - .setVolume(100) - .subscribe( - null, err -> scheduleRetry(() -> sendTrackDelayed(link, track, attempt + 1), 500)); - } } diff --git a/src/main/java/creator/loki/feature/music/manage/TrackStarter.java b/src/main/java/creator/loki/feature/music/manage/TrackStarter.java new file mode 100644 index 0000000..d31d114 --- /dev/null +++ b/src/main/java/creator/loki/feature/music/manage/TrackStarter.java @@ -0,0 +1,53 @@ +package creator.loki.feature.music.manage; + +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import dev.arbjerg.lavalink.client.Link; +import dev.arbjerg.lavalink.client.player.Track; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class TrackStarter { + private static final Logger log = LoggerFactory.getLogger(TrackStarter.class); + private static final int MAX_ATTEMPTS = 5; + private static final long RETRY_DELAY_PLAYER_NULL_MS = 300; + private static final long RETRY_DELAY_SUBSCRIBE_ERROR_MS = 500; + + private final ScheduledExecutorService executor; + + TrackStarter(ScheduledExecutorService executor) { + this.executor = executor; + } + + /** + * Starts a track on the given link with retry logic (up to {@value MAX_ATTEMPTS} attempts). + * + * @param link the Lavalink link to play the track on + * @param track the track to start + */ + void startWithRetry(Link link, Track track) { + startWithRetry(link, track, 0); + } + + private void startWithRetry(Link link, Track track, int attempt) { + if (attempt > MAX_ATTEMPTS) return; + if (link.getCachedPlayer() == null) { + executor.schedule( + () -> startWithRetry(link, track, attempt + 1), + RETRY_DELAY_PLAYER_NULL_MS, + TimeUnit.MILLISECONDS); + return; + } + link.createOrUpdatePlayer() + .setTrack(track) + .setVolume(100) + .subscribe( + null, + err -> + executor.schedule( + () -> startWithRetry(link, track, attempt + 1), + RETRY_DELAY_SUBSCRIBE_ERROR_MS, + TimeUnit.MILLISECONDS)); + } +} diff --git a/src/main/java/creator/loki/feature/music/play/PlayCommand.java b/src/main/java/creator/loki/feature/music/play/PlayCommand.java index 7c7f184..504a369 100644 --- a/src/main/java/creator/loki/feature/music/play/PlayCommand.java +++ b/src/main/java/creator/loki/feature/music/play/PlayCommand.java @@ -11,12 +11,7 @@ import creator.loki.feature.music.core.PlayerManager; import creator.loki.feature.music.core.SearchManager; import creator.loki.feature.music.manage.GuildMusicManager; import creator.loki.util.VoiceHelper; -import dev.arbjerg.lavalink.client.AbstractAudioLoadResultHandler; -import dev.arbjerg.lavalink.client.player.LoadFailed; -import dev.arbjerg.lavalink.client.player.PlaylistLoaded; -import dev.arbjerg.lavalink.client.player.SearchResult; import dev.arbjerg.lavalink.client.player.Track; -import dev.arbjerg.lavalink.client.player.TrackLoaded; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Member; @@ -56,10 +51,7 @@ public class PlayCommand implements Command { if (guild == null) return; Member member = ctx.getAuthor(); if (member == null || !member.getVoiceState().inAudioChannel()) { - ctx.getChannel() - .sendMessageEmbeds( - EmbedTemplates.error("Голосовой канал", "Зайди в голосовой канал сначала.")) - .queue(); + sendErr(ctx, "Зайди в голосовой канал сначала."); return; } var args = ctx.getArgs(); @@ -69,27 +61,43 @@ public class PlayCommand implements Command { } String first = args.get(0); if (SearchHandler.isNumeric(first)) { - Track t = searchHandler.pickResult(guild.getIdLong(), Integer.parseInt(first) - 1); - if (t == null) { - sendErr(ctx, "Поиск устарел. Сделай новый: `!play <запрос>`"); - return; - } - playTrack(ctx, t); + handlePickResult(ctx, first); return; } + if (!resolveVoiceConnection(ctx)) return; var lm = playerManager.getLavalinkManager(); - if (!guild.getSelfMember().getVoiceState().inAudioChannel()) { - var vc = member.getVoiceState().getChannel().asVoiceChannel(); - guild.getJDA().getDirectAudioController().connect(vc); - if (!guild.getAudioManager().isConnected()) guild.getAudioManager().openAudioConnection(vc); - var self = guild.getSelfMember(); - VoiceHelper.waitForCondition(() -> self.getVoiceState().inAudioChannel()); - if (!self.getVoiceState().inAudioChannel()) return; - var link = lm.getOrCreateLink(guild.getIdLong()); - VoiceHelper.waitForCondition(() -> link.getCachedPlayer() != null); - if (link.getCachedPlayer() == null) return; - } var mm = lm.getMusicManager(guild.getIdLong()); + handlePlayQuery(ctx, args, first, lm, mm); + } + + private boolean resolveVoiceConnection(CommandContext ctx) { + var guild = ctx.getGuild(); + var member = ctx.getAuthor(); + var lm = playerManager.getLavalinkManager(); + if (!VoiceHelper.connectForPlayback(guild, member, lm)) { + sendErr(ctx, "Не удалось подключиться к голосовому каналу."); + return false; + } + return true; + } + + private void handlePickResult(CommandContext ctx, String first) { + var guild = ctx.getGuild(); + Track t = searchHandler.pickResult(guild.getIdLong(), Integer.parseInt(first) - 1); + if (t == null) { + sendErr(ctx, "Поиск устарел. Сделай новый: `!play <запрос>`"); + return; + } + if (!resolveVoiceConnection(ctx)) return; + enqueueAndNotify(ctx, t); + } + + private void handlePlayQuery( + CommandContext ctx, + List args, + String first, + LavalinkManager lm, + GuildMusicManager mm) { boolean isSource = SOURCES.contains(first); String raw = String.join(" ", isSource ? args.subList(1, args.size()) : args); if (raw.isEmpty()) { @@ -97,9 +105,9 @@ public class PlayCommand implements Command { return; } boolean isUrl = raw.startsWith("http://") || raw.startsWith("https://"); - String query = isUrl ? null : (first.equals("list") ? raw : raw); + String query = isUrl ? null : raw; String id = buildQuery(first, raw); - loadAndHandle(ctx, lm, guild.getIdLong(), mm, id, isUrl, query, raw); + loadAndHandle(ctx, lm, ctx.getGuild().getIdLong(), mm, id, isUrl, query, raw); } private void loadAndHandle( @@ -111,64 +119,9 @@ public class PlayCommand implements Command { boolean isUrl, String query, String raw) { - String noMatch = - isUrl ? "Ничего не найдено по ссылке." : "Ничего не найдено по запросу: " + raw; lm.getOrCreateLink(guildId) .loadItem(id) - .subscribe( - new AbstractAudioLoadResultHandler() { - @Override - public void ontrackLoaded(TrackLoaded r) { - if (isUrl) playTrack(ctx, r.getTrack()); - else showResults(ctx, query, mm, List.of(r.getTrack())); - } - - @Override - public void onPlaylistLoaded(PlaylistLoaded r) { - if (isUrl) { - int c = 0; - for (Track t : r.getTracks()) { - mm.getScheduler().enqueue(t); - c++; - } - ctx.getChannel() - .sendMessageEmbeds( - EmbedTemplates.info( - "Плейлист добавлен", - "**" + r.getInfo().getName() + "** — " + c + " треков")) - .queue(); - } else if (r.getTracks().size() <= 1 && !r.getTracks().isEmpty()) { - playTrack(ctx, r.getTracks().get(0)); - } else { - showResults(ctx, query, mm, r.getTracks()); - } - } - - @Override - public void onSearchResultLoaded(SearchResult r) { - if (r.getTracks().isEmpty()) { - sendErr(ctx, noMatch); - } else if (isUrl) { - playTrack(ctx, r.getTracks().get(0)); - } else { - showResults(ctx, query, mm, r.getTracks()); - } - } - - @Override - public void noMatches() { - sendErr(ctx, noMatch); - } - - @Override - public void loadFailed(LoadFailed e) { - sendErr( - ctx, - e.getException().getMessage() != null - ? e.getException().getMessage() - : "Неизвестная ошибка"); - } - }); + .subscribe(new PlayLoadHandler(ctx, searchHandler, mm, isUrl, query, raw)); } private static String buildQuery(String first, String raw) { @@ -181,13 +134,7 @@ public class PlayCommand implements Command { return raw; } - private void showResults(CommandContext ctx, String q, GuildMusicManager m, List list) { - if (q == null) return; - searchHandler.storeResults(ctx.getGuild().getIdLong(), list); - ctx.getChannel().sendMessageEmbeds(searchHandler.buildResultsEmbed(q, list).build()).queue(); - } - - private void playTrack(CommandContext ctx, Track track) { + private void enqueueAndNotify(CommandContext ctx, Track track) { var m = playerManager.getLavalinkManager().getMusicManager(ctx.getGuild().getIdLong()); m.getScheduler().enqueue(track); ctx.getChannel() diff --git a/src/main/java/creator/loki/feature/music/play/PlayLoadHandler.java b/src/main/java/creator/loki/feature/music/play/PlayLoadHandler.java new file mode 100644 index 0000000..5a335f9 --- /dev/null +++ b/src/main/java/creator/loki/feature/music/play/PlayLoadHandler.java @@ -0,0 +1,116 @@ +package creator.loki.feature.music.play; + +import java.util.List; + +import creator.loki.command.CommandContext; +import creator.loki.embed.EmbedTemplates; +import creator.loki.feature.music.manage.GuildMusicManager; +import dev.arbjerg.lavalink.client.AbstractAudioLoadResultHandler; +import dev.arbjerg.lavalink.client.player.LoadFailed; +import dev.arbjerg.lavalink.client.player.PlaylistLoaded; +import dev.arbjerg.lavalink.client.player.SearchResult; +import dev.arbjerg.lavalink.client.player.Track; +import dev.arbjerg.lavalink.client.player.TrackLoaded; + +class PlayLoadHandler extends AbstractAudioLoadResultHandler { + private final CommandContext ctx; + private final SearchHandler searchHandler; + private final GuildMusicManager mm; + private final boolean isUrl; + private final String query; + private final String raw; + + PlayLoadHandler( + CommandContext ctx, + SearchHandler searchHandler, + GuildMusicManager mm, + boolean isUrl, + String query, + String raw) { + this.ctx = ctx; + this.searchHandler = searchHandler; + this.mm = mm; + this.isUrl = isUrl; + this.query = query; + this.raw = raw; + } + + @Override + public void ontrackLoaded(TrackLoaded r) { + if (isUrl) enqueueAndNotify(r.getTrack()); + else showResults(List.of(r.getTrack())); + } + + @Override + public void onPlaylistLoaded(PlaylistLoaded r) { + if (isUrl) { + int count = 0; + for (Track t : r.getTracks()) { + mm.getScheduler().enqueue(t); + count++; + } + ctx.getChannel() + .sendMessageEmbeds( + EmbedTemplates.info( + "Плейлист добавлен", "**" + r.getInfo().getName() + "** — " + count + " треков")) + .queue(); + } else if (r.getTracks().size() <= 1 && !r.getTracks().isEmpty()) { + enqueueAndNotify(r.getTracks().get(0)); + } else { + showResults(r.getTracks()); + } + } + + @Override + public void onSearchResultLoaded(SearchResult r) { + String noMatch = + isUrl ? "Ничего не найдено по ссылке." : "Ничего не найдено по запросу: " + raw; + if (r.getTracks().isEmpty()) { + sendErr(noMatch); + } else if (isUrl) { + enqueueAndNotify(r.getTracks().get(0)); + } else { + showResults(r.getTracks()); + } + } + + @Override + public void noMatches() { + String noMatch = + isUrl ? "Ничего не найдено по ссылке." : "Ничего не найдено по запросу: " + raw; + sendErr(noMatch); + } + + @Override + public void loadFailed(LoadFailed e) { + sendErr( + e.getException().getMessage() != null + ? e.getException().getMessage() + : "Неизвестная ошибка"); + } + + private void enqueueAndNotify(Track track) { + mm.getScheduler().enqueue(track); + ctx.getChannel() + .sendMessageEmbeds( + EmbedTemplates.info( + "Добавлено в очередь", + "**" + + track.getInfo().getTitle() + + "**\n" + + SearchHandler.formatDuration(track.getInfo().getLength()))) + .queue(); + } + + private void showResults(List list) { + if (query == null) return; + searchHandler.storeResults(ctx.getGuild().getIdLong(), list); + ctx.getChannel() + .sendMessageEmbeds(searchHandler.buildResultsEmbed(query, list).build()) + .queue(); + } + + private void sendErr(String msg) { + ctx.getChannel().sendMessageEmbeds(EmbedTemplates.error("Ошибка", msg)).queue(); + } +} diff --git a/src/main/java/creator/loki/feature/radio/RadioModule.java b/src/main/java/creator/loki/feature/radio/RadioModule.java index 7b7df27..bc9c48a 100644 --- a/src/main/java/creator/loki/feature/radio/RadioModule.java +++ b/src/main/java/creator/loki/feature/radio/RadioModule.java @@ -2,10 +2,16 @@ package creator.loki.feature.radio; import com.google.inject.AbstractModule; import com.google.inject.Singleton; +import com.google.inject.multibindings.Multibinder; +import creator.loki.core.registry.CommandRegistrar; public class RadioModule extends AbstractModule { @Override protected void configure() { bind(RadioManager.class).in(Singleton.class); + + Multibinder.newSetBinder(binder(), CommandRegistrar.class) + .addBinding() + .to(RadioCommandRegistrar.class); } } diff --git a/src/main/java/creator/loki/feature/rating/commands/IqCommand.java b/src/main/java/creator/loki/feature/rating/commands/IqCommand.java index 22df9f1..b819152 100644 --- a/src/main/java/creator/loki/feature/rating/commands/IqCommand.java +++ b/src/main/java/creator/loki/feature/rating/commands/IqCommand.java @@ -53,7 +53,7 @@ public class IqCommand implements Command { iq = ratingService.getDeterministicValue(target.getId(), 123, 0, 140); } - RatingLevel level = RatingLevel.findLevel(iq, RatingConfig.getIqLevels()); + RatingLevel level = RatingConfig.findLevel(iq, "iq"); context .getChannel() .sendMessageEmbeds( diff --git a/src/main/java/creator/loki/feature/rating/commands/MustacheCommand.java b/src/main/java/creator/loki/feature/rating/commands/MustacheCommand.java index 50408e3..7c00cc5 100644 --- a/src/main/java/creator/loki/feature/rating/commands/MustacheCommand.java +++ b/src/main/java/creator/loki/feature/rating/commands/MustacheCommand.java @@ -58,7 +58,7 @@ public class MustacheCommand implements Command { for (int i = 0; i < filled; i++) bar.append("🧔"); for (int i = filled; i < 10; i++) bar.append("⬜"); - RatingLevel level = RatingLevel.findLevel(mustacheLevel, RatingConfig.getMustacheLevels()); + RatingLevel level = RatingConfig.findLevel(mustacheLevel, "mustache"); context .getChannel() .sendMessageEmbeds( diff --git a/src/main/java/creator/loki/feature/rating/commands/PpCommand.java b/src/main/java/creator/loki/feature/rating/commands/PpCommand.java index ecb15c7..6f8b93d 100644 --- a/src/main/java/creator/loki/feature/rating/commands/PpCommand.java +++ b/src/main/java/creator/loki/feature/rating/commands/PpCommand.java @@ -59,7 +59,7 @@ public class PpCommand implements Command { for (int i = 0; i < size; i++) ppVisual.append("="); ppVisual.append("D"); - RatingLevel level = RatingLevel.findLevel(size, RatingConfig.getPpLevels()); + RatingLevel level = RatingConfig.findLevel(size, "pp"); context .getChannel() .sendMessageEmbeds( diff --git a/src/main/java/creator/loki/feature/rating/config/RatingConfig.java b/src/main/java/creator/loki/feature/rating/config/RatingConfig.java index 2cc65c0..32809b9 100644 --- a/src/main/java/creator/loki/feature/rating/config/RatingConfig.java +++ b/src/main/java/creator/loki/feature/rating/config/RatingConfig.java @@ -104,43 +104,59 @@ public class RatingConfig { LEVELS = Collections.unmodifiableMap(m); } + /** Data-driven accessor — единая точка доступа для всех рейтингов. */ + public static List getLevels(String ratingName) { + var levels = LEVELS.get(ratingName); + if (levels == null) { + throw new IllegalArgumentException("Unknown rating: " + ratingName); + } + return levels; + } + + /** + * Data-driven поиск уровня: `findLevel(value, "iq")` вместо `findLevel(value, getIqLevels())`. + */ + public static RatingLevel findLevel(int value, String ratingName) { + return RatingLevel.findLevel(value, getLevels(ratingName)); + } + public static List getMustacheLevels() { - return LEVELS.get("mustache"); + return getLevels("mustache"); } public static List getIqLevels() { - return LEVELS.get("iq"); + return getLevels("iq"); } public static List getFatLevels() { - return LEVELS.get("fat"); + return getLevels("fat"); } public static List getPpLevels() { - return LEVELS.get("pp"); + return getLevels("pp"); } public static List getBoykisserLevels() { - return LEVELS.get("boykisser"); + return getLevels("boykisser"); } public static List getRizzLevels() { - return LEVELS.get("rizz"); + return getLevels("rizz"); } public static List getLuckLevels() { - return LEVELS.get("luck"); + return getLevels("luck"); } public static List getSimpLevels() { - return LEVELS.get("simp"); + return getLevels("simp"); } public static List getVibeLevels() { - return LEVELS.get("vibe"); + return getLevels("vibe"); } public static List getGlazeLevels() { - return LEVELS.get("glaze"); + return getLevels("glaze"); } } diff --git a/src/main/java/creator/loki/feature/roast/AiSessionManager.java b/src/main/java/creator/loki/feature/roast/AiSessionManager.java index 6b65467..f09f7fe 100644 --- a/src/main/java/creator/loki/feature/roast/AiSessionManager.java +++ b/src/main/java/creator/loki/feature/roast/AiSessionManager.java @@ -27,8 +27,7 @@ public class AiSessionManager { public void addMessage( String channelId, String authorName, String authorId, String content, long timestamp) { - AiSession session = - sessions.computeIfAbsent(channelId, k -> new AiSession()); + AiSession session = sessions.computeIfAbsent(channelId, k -> new AiSession()); session.addMessage(authorName, authorId, content, timestamp); } @@ -75,19 +74,13 @@ public class AiSessionManager { for (int i = start; i < messages.size(); i++) { SessionMessage m = messages.get(i); String label = (i == messages.size() - 1) ? "[now]" : "[history]"; - String formatted = - label - + " " - + m.authorName - + "(" - + m.authorId - + "): " - + m.content; + String formatted = label + " " + m.authorName + "(" + m.authorId + "): " + m.content; result.add(new CachedMessage(formatted, "", m.timestamp)); } return result; } } - private record SessionMessage(String authorName, String authorId, String content, long timestamp) {} + private record SessionMessage( + String authorName, String authorId, String content, long timestamp) {} } diff --git a/src/main/java/creator/loki/feature/roast/AndrertListener.java b/src/main/java/creator/loki/feature/roast/AndrertListener.java index a567979..061c08d 100644 --- a/src/main/java/creator/loki/feature/roast/AndrertListener.java +++ b/src/main/java/creator/loki/feature/roast/AndrertListener.java @@ -28,11 +28,13 @@ public class AndrertListener extends ListenerAdapter { private final String allowedChannelId; private final ConcurrentHashMap trackedUsers = new ConcurrentHashMap<>(); private final ConcurrentHashMap lastAiReplyTime = new ConcurrentHashMap<>(); - private final ConcurrentHashMap> pendingPings = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> pendingPings = + new ConcurrentHashMap<>(); private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); @Inject - public AndrertListener(RoastService roastService, AiSessionManager sessionManager, BotConfig config) { + public AndrertListener( + RoastService roastService, AiSessionManager sessionManager, BotConfig config) { this.roastService = roastService; this.sessionManager = sessionManager; this.allowedChannelId = config.getAiChannelId(); @@ -102,7 +104,8 @@ public class AndrertListener extends ListenerAdapter { System.currentTimeMillis()); String targetName = event.getAuthor().getEffectiveName(); - List context = sessionManager.getContext(event.getChannel().getId(), CHANNEL_CONTEXT); + List context = + sessionManager.getContext(event.getChannel().getId(), CHANNEL_CONTEXT); String response = roastService.generateResponse(context, targetName, userId); if (response == null || response.isBlank()) return; @@ -145,12 +148,13 @@ public class AndrertListener extends ListenerAdapter { String[] lines = { "эй " + targetMention + ", ты чё замолчал? я не закончил", "куда пропал " + targetMention + "? аргументы закончились?", - "ну " + targetMention + ", я жду. или скажешь что тебе нечего ответить?", + "ну " + + targetMention + + ", я жду. или скажешь что тебе нечего ответить?", "тишина в эфире " + targetMention + ". всё, признал поражение?", targetMention + " слился. ожидаемо", }; - String msgText = - lines[(int) (System.currentTimeMillis() % lines.length)]; + String msgText = lines[(int) (System.currentTimeMillis() % lines.length)]; channel .getHistory() @@ -163,11 +167,12 @@ public class AndrertListener extends ListenerAdapter { if (m.getAuthor().isBot()) continue; String a = m.getAuthor().getEffectiveName(); String c = - m.getContentRaw() - + replyInfo(m) - + attachmentInfo(m); + m.getContentRaw() + replyInfo(m) + attachmentInfo(m); sessionManager.addMessage( - channelId, a, m.getAuthor().getId(), c, + channelId, + a, + m.getAuthor().getId(), + c, m.getTimeCreated().toEpochSecond() * 1000); } diff --git a/src/main/java/creator/loki/feature/roast/DebateChannelListener.java b/src/main/java/creator/loki/feature/roast/DebateChannelListener.java index bbee23d..1c55044 100644 --- a/src/main/java/creator/loki/feature/roast/DebateChannelListener.java +++ b/src/main/java/creator/loki/feature/roast/DebateChannelListener.java @@ -31,8 +31,10 @@ public class DebateChannelListener extends ListenerAdapter { if (prev != null) return; seenMessages.entrySet().removeIf(e -> now - e.getValue() > 5000); - logger.info("debate msg ch={} a={} text={}", - event.getChannel().getId(), event.getAuthor().getId(), + logger.info( + "debate msg ch={} a={} text={}", + event.getChannel().getId(), + event.getAuthor().getId(), event.getMessage().getContentRaw().replace("\n", " ")); debateService.onHumanMessage(event); } diff --git a/src/main/java/creator/loki/feature/roast/DebateService.java b/src/main/java/creator/loki/feature/roast/DebateService.java index 15efdc8..90bfb9e 100644 --- a/src/main/java/creator/loki/feature/roast/DebateService.java +++ b/src/main/java/creator/loki/feature/roast/DebateService.java @@ -35,7 +35,8 @@ public class DebateService { Files.isDirectory(Paths.get("/app/prompts")) ? Paths.get("/app/prompts") : Paths.get("data/prompts"); - private static final DateTimeFormatter DT_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final DateTimeFormatter DT_FMT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private static final long BOT_START_TIME = System.currentTimeMillis(); private static final int CHANNEL_CONTEXT = 20; private static final String[] PERSONALITIES = {"arch", "windows", "gentoo"}; @@ -87,7 +88,8 @@ public class DebateService { if (debateId == null || debateId.isBlank()) return; if (!event.getChannel().getId().equals(debateId)) return; if (event.getAuthor().isBot()) return; - if (event.getMessage().getContentRaw().isBlank() && event.getMessage().getAttachments().isEmpty()) return; + if (event.getMessage().getContentRaw().isBlank() + && event.getMessage().getAttachments().isEmpty()) return; TextChannel channel = event.getChannel().asTextChannel(); ChannelState state = channels.computeIfAbsent(debateId, k -> new ChannelState()); @@ -97,8 +99,7 @@ public class DebateService { state.roundCount = 0; state.lastMessageId = event.getMessage().getId(); - state.currentTask = scheduler.schedule(() -> - doRound(channel, state), 0, TimeUnit.SECONDS); + state.currentTask = scheduler.schedule(() -> doRound(channel, state), 0, TimeUnit.SECONDS); } private void doRound(TextChannel channel, ChannelState state) { @@ -109,43 +110,57 @@ public class DebateService { state.cancel(); state.roundDone = true; - channel.getHistory().retrievePast(CHANNEL_CONTEXT).queue(messages -> { - boolean hasNewHuman = messages.stream().anyMatch(m -> - !m.getAuthor().isBot() && m.getTimeCreated().toEpochSecond() * 1000 > state.lastHumanTime); - if (hasNewHuman) { - state.roundDone = false; - state.lastHumanTime = now; - state.lastPersonality = null; - state.currentTask = scheduler.schedule(() -> - doRound(channel, state), 0, TimeUnit.SECONDS); - return; - } + channel + .getHistory() + .retrievePast(CHANNEL_CONTEXT) + .queue( + messages -> { + boolean hasNewHuman = + messages.stream() + .anyMatch( + m -> + !m.getAuthor().isBot() + && m.getTimeCreated().toEpochSecond() * 1000 + > state.lastHumanTime); + if (hasNewHuman) { + state.roundDone = false; + state.lastHumanTime = now; + state.lastPersonality = null; + state.currentTask = + scheduler.schedule(() -> doRound(channel, state), 0, TimeUnit.SECONDS); + return; + } - List ctx = buildContext(messages); + List ctx = buildContext(messages); - if (state.roundCount >= MAX_ROUNDS) return; - state.roundCount++; + if (state.roundCount >= MAX_ROUNDS) return; + state.roundCount++; - String personality = pickPersonality(state.lastPersonality); - if (personality == null) return; - state.lastPersonality = personality; + String personality = pickPersonality(state.lastPersonality); + if (personality == null) return; + state.lastPersonality = personality; - String response = generateResponse(personality, ctx); - if (response == null || response.isBlank()) return; + String response = generateResponse(personality, ctx); + if (response == null || response.isBlank()) return; - JDA jda = jdaRegistry.getByPersonality(personality); - TextChannel target = jda.getTextChannelById(debateId); - if (target == null) return; - String replyToId = state.lastMessageId; - target.sendMessage(response) - .setMessageReference(replyToId) - .queue(m -> state.lastMessageId = m.getId()); + JDA jda = jdaRegistry.getByPersonality(personality); + TextChannel target = jda.getTextChannelById(debateId); + if (target == null) return; + String replyToId = state.lastMessageId; + target + .sendMessage(response) + .setMessageReference(replyToId) + .queue(m -> state.lastMessageId = m.getId()); - state.currentTask = scheduler.schedule(() -> { - state.roundDone = false; - doRound(channel, state); - }, 0, TimeUnit.SECONDS); - }); + state.currentTask = + scheduler.schedule( + () -> { + state.roundDone = false; + doRound(channel, state); + }, + 0, + TimeUnit.SECONDS); + }); } private String pickPersonality(String exclude) { @@ -185,8 +200,16 @@ public class DebateService { long uptimeSec = (System.currentTimeMillis() - BOT_START_TIME) / 1000; String now = LocalDateTime.now().format(DT_FMT); - prompt += "\nтекущее время " + now + " uptime бота " + (uptimeSec / 3600) + "ч " - + ((uptimeSec % 3600) / 60) + "м " + (uptimeSec % 60) + "с"; + prompt += + "\nтекущее время " + + now + + " uptime бота " + + (uptimeSec / 3600) + + "ч " + + ((uptimeSec % 3600) / 60) + + "м " + + (uptimeSec % 60) + + "с"; StringBuilder userContent = new StringBuilder(); for (CachedMessage m : ctx) { @@ -196,7 +219,8 @@ public class DebateService { if (userContent.length() > config.getAiContextChars()) break; } String text = userContent.toString().strip(); - if (text.length() > config.getAiContextChars()) text = text.substring(0, config.getAiContextChars()); + if (text.length() > config.getAiContextChars()) + text = text.substring(0, config.getAiContextChars()); String wiki = wikipediaService.search(text); if (wiki != null) text += "\n\n[википедия]:\n" + wiki; @@ -226,10 +250,11 @@ public class DebateService { name = msg.getAuthor().getEffectiveName(); } String label = (i == messages.size() - 1) ? "[now]" : "[history]"; - result.add(new CachedMessage( - label + " " + name + "(" + msg.getAuthor().getId() + "): " + msg.getContentRaw(), - msg.getChannel().getName(), - msg.getTimeCreated().toEpochSecond() * 1000)); + result.add( + new CachedMessage( + label + " " + name + "(" + msg.getAuthor().getId() + "): " + msg.getContentRaw(), + msg.getChannel().getName(), + msg.getTimeCreated().toEpochSecond() * 1000)); } return result; } diff --git a/src/main/java/creator/loki/feature/roast/HelperListener.java b/src/main/java/creator/loki/feature/roast/HelperListener.java index a9c2953..eeac36d 100644 --- a/src/main/java/creator/loki/feature/roast/HelperListener.java +++ b/src/main/java/creator/loki/feature/roast/HelperListener.java @@ -54,7 +54,8 @@ public class HelperListener extends ListenerAdapter { if (channelId == null || channelId.isBlank()) return; if (!event.getChannel().getId().equals(channelId)) return; if (event.getAuthor().isBot()) return; - if (event.getMessage().getContentRaw().isBlank() && event.getMessage().getAttachments().isEmpty()) return; + if (event.getMessage().getContentRaw().isBlank() + && event.getMessage().getAttachments().isEmpty()) return; String raw = event.getMessage().getContentRaw(); if (!raw.isBlank() && !LanguageFilter.isAllowed(raw)) return; @@ -63,38 +64,53 @@ public class HelperListener extends ListenerAdapter { } private void respond(MessageReceivedEvent event) { - event.getChannel().getHistory().retrievePast(CHANNEL_CONTEXT).queue(messages -> { - String targetName = event.getAuthor().getEffectiveName(); - List context = new ArrayList<>(); - for (int idx = 0; idx < messages.size(); idx++) { - Message msg = messages.get(idx); - boolean isSelf = msg.getAuthor().getId().equals(event.getJDA().getSelfUser().getId()); - String author = isSelf ? "ты" : msg.getAuthor().getEffectiveName(); - String authorId = msg.getAuthor().getId(); + event + .getChannel() + .getHistory() + .retrievePast(CHANNEL_CONTEXT) + .queue( + messages -> { + String targetName = event.getAuthor().getEffectiveName(); + List context = new ArrayList<>(); + for (int idx = 0; idx < messages.size(); idx++) { + Message msg = messages.get(idx); + boolean isSelf = + msg.getAuthor().getId().equals(event.getJDA().getSelfUser().getId()); + String author = isSelf ? "ты" : msg.getAuthor().getEffectiveName(); + String authorId = msg.getAuthor().getId(); - String label = (idx == 0) ? "[now]" : "[history]"; - StringBuilder line = new StringBuilder(); - line.append(label).append(" ").append(author).append("(").append(authorId).append("): ") - .append(msg.getContentRaw()); - line.append(attachmentInfo(msg)); + String label = (idx == 0) ? "[now]" : "[history]"; + StringBuilder line = new StringBuilder(); + line.append(label) + .append(" ") + .append(author) + .append("(") + .append(authorId) + .append("): ") + .append(msg.getContentRaw()); + line.append(attachmentInfo(msg)); - context.add(new CachedMessage( - line.toString(), - event.getChannel().getName(), - msg.getTimeCreated().toEpochSecond() * 1000)); - } - java.util.Collections.reverse(context); + context.add( + new CachedMessage( + line.toString(), + event.getChannel().getName(), + msg.getTimeCreated().toEpochSecond() * 1000)); + } + java.util.Collections.reverse(context); - String response = roastService.generateResponse(context, targetName, null, helperPrompt); - if (response == null || response.isBlank()) return; + String response = + roastService.generateResponse(context, targetName, null, helperPrompt); + if (response == null || response.isBlank()) return; - sendReply(event, response); - }); + sendReply(event, response); + }); } private void sendReply(MessageReceivedEvent event, String text) { - java.util.regex.Matcher m = java.util.regex.Pattern.compile( - "\\[file:([^\\]]+)\\](.*?)\\[/file\\]", java.util.regex.Pattern.DOTALL).matcher(text); + java.util.regex.Matcher m = + java.util.regex.Pattern.compile( + "\\[file:([^\\]]+)\\](.*?)\\[/file\\]", java.util.regex.Pattern.DOTALL) + .matcher(text); if (!m.find()) { sendText(event, text); return; @@ -105,16 +121,15 @@ public class HelperListener extends ListenerAdapter { String filename = m.group(1).strip(); String content = m.group(2); m.appendReplacement(remaining, ""); - var data = net.dv8tion.jda.api.utils.FileUpload.fromData( - content.getBytes(java.nio.charset.StandardCharsets.UTF_8), filename); + var data = + net.dv8tion.jda.api.utils.FileUpload.fromData( + content.getBytes(java.nio.charset.StandardCharsets.UTF_8), filename); if (first) { String msg = remaining.toString().strip(); - event.getMessage().reply(msg == null ? "" : msg) - .addFiles(data).queue(); + event.getMessage().reply(msg == null ? "" : msg).addFiles(data).queue(); first = false; } else { - event.getChannel().sendMessage(" ") - .addFiles(data).queue(); + event.getChannel().sendMessage(" ").addFiles(data).queue(); } } while (m.find()); m.appendTail(remaining); diff --git a/src/main/java/creator/loki/feature/roast/KnowledgeService.java b/src/main/java/creator/loki/feature/roast/KnowledgeService.java index f37a756..0be741a 100644 --- a/src/main/java/creator/loki/feature/roast/KnowledgeService.java +++ b/src/main/java/creator/loki/feature/roast/KnowledgeService.java @@ -7,9 +7,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.Arrays; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -28,17 +26,14 @@ public class KnowledgeService { private static final int RESULT_LIMIT = 3; private static final Set STOP_WORDS = Set.of( - "это", "как", "так", "что", "он", "она", "они", "мы", "вы", "ты", "я", - "не", "да", "нет", "на", "в", "по", "для", "с", "у", "о", "от", "к", - "из", "за", "над", "под", "об", "же", "ли", "бы", "а", "и", "но", - "или", "то", "все", "его", "ее", "их", "еще", "уже", "когда", - "the", "a", "an", "is", "are", "was", "were", "be", "been", - "have", "has", "had", "do", "does", "did", "will", "would", - "can", "could", "may", "might", "shall", "should", "to", "of", - "in", "for", "with", "on", "at", "by", "from", "as", "into", - "it", "its", "this", "that", "these", "those", "i", "you", - "he", "she", "we", "they", "me", "him", "her", "us", "them", - "and", "but", "or", "not", "no", "if", "so", "about"); + "это", "как", "так", "что", "он", "она", "они", "мы", "вы", "ты", "я", "не", "да", "нет", + "на", "в", "по", "для", "с", "у", "о", "от", "к", "из", "за", "над", "под", "об", "же", + "ли", "бы", "а", "и", "но", "или", "то", "все", "его", "ее", "их", "еще", "уже", "когда", + "the", "a", "an", "is", "are", "was", "were", "be", "been", "have", "has", "had", "do", + "does", "did", "will", "would", "can", "could", "may", "might", "shall", "should", "to", + "of", "in", "for", "with", "on", "at", "by", "from", "as", "into", "it", "its", "this", + "that", "these", "those", "i", "you", "he", "she", "we", "they", "me", "him", "her", "us", + "them", "and", "but", "or", "not", "no", "if", "so", "about"); private final List files = new ArrayList<>(); @@ -105,10 +100,8 @@ public class KnowledgeService { } private String[] extractKeywords(String text) { - String cleaned = text.toLowerCase() - .replaceAll("[^a-zа-яё0-9\\s]", " ") - .replaceAll("\\s+", " ") - .strip(); + String cleaned = + text.toLowerCase().replaceAll("[^a-zа-яё0-9\\s]", " ").replaceAll("\\s+", " ").strip(); String[] all = cleaned.split(" "); return Arrays.stream(all) .filter(w -> w.length() > 2) diff --git a/src/main/java/creator/loki/feature/roast/RoastService.java b/src/main/java/creator/loki/feature/roast/RoastService.java index c466d52..f4a5f7b 100644 --- a/src/main/java/creator/loki/feature/roast/RoastService.java +++ b/src/main/java/creator/loki/feature/roast/RoastService.java @@ -25,7 +25,8 @@ public class RoastService { Files.isDirectory(Paths.get("/app/prompts")) ? Paths.get("/app/prompts") : Paths.get("data/prompts"); - private static final DateTimeFormatter DT_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final DateTimeFormatter DT_FMT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); private static final long BOT_START_TIME = System.currentTimeMillis(); private final BotConfig config; @@ -67,7 +68,9 @@ public class RoastService { long mins = (uptimeSec % 3600) / 60; long secs = uptimeSec % 60; String now = LocalDateTime.now().format(DT_FMT); - return String.format("текущее время %s uptime бота %dч %dм %dс всего команд выполнено %d", now, hours, mins, secs, metricsCollector.getTotalCommands()); + return String.format( + "текущее время %s uptime бота %dч %dм %dс всего команд выполнено %d", + now, hours, mins, secs, metricsCollector.getTotalCommands()); } private String loadPrompt(String filename) { @@ -171,7 +174,8 @@ public class RoastService { return deepSeekClient.callModel(systemPrompt, text, 0.9); } - public String generateResponse(List context, String targetName, String userId, String customPrompt) { + public String generateResponse( + List context, String targetName, String userId, String customPrompt) { String escapedName = escapeFormat(targetName != null ? targetName : ""); String systemPrompt = customPrompt.formatted(escapedName, escapedName, escapedName); diff --git a/src/main/java/creator/loki/feature/roast/WebSearchService.java b/src/main/java/creator/loki/feature/roast/WebSearchService.java index 8fe3a26..a275fc0 100644 --- a/src/main/java/creator/loki/feature/roast/WebSearchService.java +++ b/src/main/java/creator/loki/feature/roast/WebSearchService.java @@ -25,11 +25,12 @@ public class WebSearchService { private final OkHttpClient httpClient; public WebSearchService() { - this.httpClient = new OkHttpClient.Builder() - .connectTimeout(8, TimeUnit.SECONDS) - .readTimeout(12, TimeUnit.SECONDS) - .followRedirects(true) - .build(); + this.httpClient = + new OkHttpClient.Builder() + .connectTimeout(8, TimeUnit.SECONDS) + .readTimeout(12, TimeUnit.SECONDS) + .followRedirects(true) + .build(); } public String search(String query) { @@ -68,13 +69,14 @@ public class WebSearchService { private List searchDdg(String query) { List results = new ArrayList<>(); try { - String url = "https://html.duckduckgo.com/html/?q=" - + URLEncoder.encode(query, StandardCharsets.UTF_8); - Request req = new Request.Builder() - .url(url) - .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") - .get() - .build(); + String url = + "https://html.duckduckgo.com/html/?q=" + URLEncoder.encode(query, StandardCharsets.UTF_8); + Request req = + new Request.Builder() + .url(url) + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + .get() + .build(); try (Response resp = httpClient.newCall(req).execute()) { if (!resp.isSuccessful() || resp.body() == null) return results; String html = resp.body().string(); @@ -108,24 +110,28 @@ public class WebSearchService { if (amp > 0) enc = enc.substring(0, amp); return URLDecoder.decode(enc, StandardCharsets.UTF_8); } - } catch (Exception ignored) {} + } catch (Exception ignored) { + } return ddgUrl; } private String fetchPageText(String url) { try { - Request req = new Request.Builder() - .url(url) - .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") - .header("Accept", "text/html,application/xhtml+xml") - .header("Accept-Language", "en,ru;q=0.9") - .get() - .build(); + Request req = + new Request.Builder() + .url(url) + .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + .header("Accept", "text/html,application/xhtml+xml") + .header("Accept-Language", "en,ru;q=0.9") + .get() + .build(); try (Response resp = httpClient.newCall(req).execute()) { if (!resp.isSuccessful() || resp.body() == null) return null; String html = resp.body().string(); Document doc = Jsoup.parse(html); - Element main = doc.selectFirst("main, article, .post-content, .entry-content, .readme, .markdown-body, #readme, .container-lg"); + Element main = + doc.selectFirst( + "main, article, .post-content, .entry-content, .readme, .markdown-body, #readme, .container-lg"); String text; if (main != null) { main.select("script, style, nav, footer, header, .sidebar, aside").remove(); diff --git a/src/main/java/creator/loki/feature/roast/WikipediaService.java b/src/main/java/creator/loki/feature/roast/WikipediaService.java index f29a083..cecec37 100644 --- a/src/main/java/creator/loki/feature/roast/WikipediaService.java +++ b/src/main/java/creator/loki/feature/roast/WikipediaService.java @@ -17,17 +17,20 @@ import org.slf4j.LoggerFactory; @Singleton public class WikipediaService { private static final Logger logger = LoggerFactory.getLogger(WikipediaService.class); - private static final String SEARCH_URL = "https://ru.wikipedia.org/w/api.php?action=query&list=search&srsearch=%s&format=json&srlimit=3&srprop=snippet"; - private static final String EXTRACT_URL = "https://ru.wikipedia.org/w/api.php?action=query&prop=extracts&exintro&explaintext&titles=%s&format=json&exlimit=1"; + private static final String SEARCH_URL = + "https://ru.wikipedia.org/w/api.php?action=query&list=search&srsearch=%s&format=json&srlimit=3&srprop=snippet"; + private static final String EXTRACT_URL = + "https://ru.wikipedia.org/w/api.php?action=query&prop=extracts&exintro&explaintext&titles=%s&format=json&exlimit=1"; private static final int MAX_RESULT_CHARS = 1500; private final OkHttpClient httpClient; public WikipediaService() { - this.httpClient = new OkHttpClient.Builder() - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(10, TimeUnit.SECONDS) - .build(); + this.httpClient = + new OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .build(); } public String search(String query) { @@ -45,9 +48,8 @@ public class WikipediaService { for (int i = 0; i < Math.min(results.size(), 3); i++) { JsonObject r = results.get(i).getAsJsonObject(); String title = r.get("title").getAsString(); - String snippet = r.get("snippet").getAsString() - .replaceAll("<[^>]+>", "") - .replaceAll("&[^;]+;", " "); + String snippet = + r.get("snippet").getAsString().replaceAll("<[^>]+>", "").replaceAll("&[^;]+;", " "); String pageContent = getExtract(title); sb.append("=== ").append(title).append(" ===\n"); if (pageContent != null) { diff --git a/src/main/java/creator/loki/feature/tiktok/TikTokApi.java b/src/main/java/creator/loki/feature/tiktok/TikTokApi.java new file mode 100644 index 0000000..e8fc698 --- /dev/null +++ b/src/main/java/creator/loki/feature/tiktok/TikTokApi.java @@ -0,0 +1,130 @@ +package creator.loki.feature.tiktok; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import okhttp3.ResponseBody; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Singleton +public class TikTokApi { + private static final Logger logger = LoggerFactory.getLogger(TikTokApi.class); + private static final String API_URL = "https://www.tikwm.com/api/"; + private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); + + private final OkHttpClient client; + + @Inject + public TikTokApi() { + this.client = + new OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build(); + } + + public TikTokResult fetchVideo(String url) throws TikTokApiException { + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("url", url); + + Request request = + new Request.Builder() + .url(API_URL) + .post(RequestBody.create(requestBody.toString(), JSON)) + .build(); + + try (Response response = client.newCall(request).execute()) { + ResponseBody body = response.body(); + if (body == null) { + throw new TikTokApiException("Empty response from API"); + } + + String responseStr = body.string(); + JsonObject json = JsonParser.parseString(responseStr).getAsJsonObject(); + + int code = json.has("code") ? json.get("code").getAsInt() : -1; + if (code != 0) { + String msg = json.has("msg") ? json.get("msg").getAsString() : "Unknown error"; + throw new TikTokApiException("API error: " + msg); + } + + JsonObject data = json.getAsJsonObject("data"); + if (data == null) { + throw new TikTokApiException("No data in response"); + } + + String videoUrl = data.has("play") ? data.get("play").getAsString() : null; + String title = data.has("title") ? data.get("title").getAsString() : ""; + int duration = data.has("duration") ? data.get("duration").getAsInt() : 0; + String cover = data.has("cover") ? data.get("cover").getAsString() : null; + + JsonObject authorObj = data.has("author") ? data.getAsJsonObject("author") : null; + String author = + authorObj != null && authorObj.has("nickname") + ? authorObj.get("nickname").getAsString() + : "Unknown"; + + if (videoUrl == null || videoUrl.isBlank()) { + throw new TikTokApiException("No video URL in response"); + } + + return new TikTokResult(videoUrl, title, author, duration, cover); + + } catch (IOException e) { + logger.error("Failed to fetch TikTok video", e); + throw new TikTokApiException("Network error: " + e.getMessage()); + } + } + + public static class TikTokResult { + private final String videoUrl; + private final String title; + private final String author; + private final int duration; + private final String cover; + + public TikTokResult(String videoUrl, String title, String author, int duration, String cover) { + this.videoUrl = videoUrl; + this.title = title; + this.author = author; + this.duration = duration; + this.cover = cover; + } + + public String getVideoUrl() { + return videoUrl; + } + + public String getTitle() { + return title; + } + + public String getAuthor() { + return author; + } + + public int getDuration() { + return duration; + } + + public String getCover() { + return cover; + } + } + + public static class TikTokApiException extends Exception { + public TikTokApiException(String message) { + super(message); + } + } +} diff --git a/src/main/java/creator/loki/feature/tiktok/TikTokCommand.java b/src/main/java/creator/loki/feature/tiktok/TikTokCommand.java new file mode 100644 index 0000000..3da5d23 --- /dev/null +++ b/src/main/java/creator/loki/feature/tiktok/TikTokCommand.java @@ -0,0 +1,163 @@ +package creator.loki.feature.tiktok; + +import java.awt.Color; +import java.io.IOException; +import java.io.InputStream; +import java.util.concurrent.TimeUnit; + +import com.google.inject.Inject; +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.embed.EmbedFactory; +import creator.loki.embed.EmbedTemplates; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.utils.FileUpload; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class TikTokCommand implements Command { + private static final Logger logger = LoggerFactory.getLogger(TikTokCommand.class); + private static final long MAX_FILE_SIZE = 8 * 1024 * 1024; + + private final TikTokApi tikTokApi; + private final OkHttpClient downloadClient; + + @Inject + public TikTokCommand(TikTokApi tikTokApi) { + this.tikTokApi = tikTokApi; + this.downloadClient = + new OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .build(); + } + + @Override + public String getName() { + return "tiktok"; + } + + @Override + public String getDescription() { + return "Скачать видео из TikTok"; + } + + @Override + public String getUsage() { + return "tiktok "; + } + + @Override + public String getCategory() { + return "Развлечения"; + } + + @Override + public void execute(CommandContext context) { + if (context.getArgs().isEmpty()) { + context + .getChannel() + .sendMessageEmbeds(EmbedTemplates.error("Ошибка", "Укажи ссылку на TikTok видео")) + .queue(); + return; + } + + String url = context.getArgs().get(0); + if (!url.contains("tiktok.com")) { + context + .getChannel() + .sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "Невалидная ссылка. Укажи ссылку на TikTok видео")) + .queue(); + return; + } + + context + .getChannel() + .sendMessage("🔍 Загружаю видео...") + .queue( + loadingMsg -> { + Thread.startVirtualThread( + () -> { + try { + TikTokApi.TikTokResult result = tikTokApi.fetchVideo(url); + + byte[] videoBytes = downloadVideo(result.getVideoUrl()); + + if (videoBytes.length > MAX_FILE_SIZE) { + loadingMsg + .editMessageEmbeds( + EmbedTemplates.error("Ошибка", "Видео слишком большое (> 8MB)")) + .queue(); + return; + } + + String durationStr = formatDuration(result.getDuration()); + + MessageEmbed embed = + EmbedFactory.create() + .withTitle(truncate(result.getTitle(), 256)) + .withAuthor("@" + result.getAuthor()) + .withColor(new Color(0, 0, 0)) + .addField("Длительность", durationStr, true) + .withThumbnail(result.getCover()) + .build(); + + context + .getChannel() + .sendFiles(FileUpload.fromData(videoBytes, "tiktok.mp4")) + .setEmbeds(embed) + .queue( + success -> loadingMsg.delete().queue(), + failure -> + loadingMsg + .editMessageEmbeds( + EmbedTemplates.error( + "Ошибка", "Не удалось отправить видео")) + .queue()); + + } catch (TikTokApi.TikTokApiException e) { + loadingMsg + .editMessageEmbeds(EmbedTemplates.error("Ошибка", e.getMessage())) + .queue(); + } catch (IOException e) { + logger.error("Failed to download TikTok video", e); + loadingMsg + .editMessageEmbeds( + EmbedTemplates.error("Ошибка", "Ошибка загрузки видео")) + .queue(); + } catch (Exception e) { + logger.error("Unexpected error in TikTokCommand", e); + loadingMsg + .editMessageEmbeds(EmbedTemplates.error("Ошибка", "Произошла ошибка")) + .queue(); + } + }); + }); + } + + private byte[] downloadVideo(String videoUrl) throws IOException { + Request request = new Request.Builder().url(videoUrl).build(); + try (Response response = downloadClient.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw new IOException("Download failed: HTTP " + response.code()); + } + InputStream is = response.body().byteStream(); + return is.readAllBytes(); + } + } + + private String formatDuration(int seconds) { + int min = seconds / 60; + int sec = seconds % 60; + return String.format("%d:%02d", min, sec); + } + + private String truncate(String str, int maxLen) { + if (str == null) return ""; + return str.length() > maxLen ? str.substring(0, maxLen - 3) + "..." : str; + } +} diff --git a/src/main/java/creator/loki/feature/tiktok/TikTokModule.java b/src/main/java/creator/loki/feature/tiktok/TikTokModule.java new file mode 100644 index 0000000..6629e5a --- /dev/null +++ b/src/main/java/creator/loki/feature/tiktok/TikTokModule.java @@ -0,0 +1,17 @@ +package creator.loki.feature.tiktok; + +import com.google.inject.AbstractModule; +import com.google.inject.Provides; +import com.google.inject.Singleton; + +public class TikTokModule extends AbstractModule { + @Override + protected void configure() { + bind(TikTokApi.class).in(Singleton.class); + } + + @Provides + TikTokCommand provideTikTokCommand(TikTokApi tikTokApi) { + return new TikTokCommand(tikTokApi); + } +} diff --git a/src/main/java/creator/loki/feature/utility/UtilityModule.java b/src/main/java/creator/loki/feature/utility/UtilityModule.java new file mode 100644 index 0000000..d7aeb09 --- /dev/null +++ b/src/main/java/creator/loki/feature/utility/UtilityModule.java @@ -0,0 +1,14 @@ +package creator.loki.feature.utility; + +import com.google.inject.AbstractModule; +import com.google.inject.multibindings.Multibinder; +import creator.loki.core.registry.CommandRegistrar; + +public class UtilityModule extends AbstractModule { + @Override + protected void configure() { + Multibinder.newSetBinder(binder(), CommandRegistrar.class) + .addBinding() + .to(UtilityCommandRegistrar.class); + } +} diff --git a/src/main/java/creator/loki/util/VoiceHelper.java b/src/main/java/creator/loki/util/VoiceHelper.java index d2648a2..9740df4 100644 --- a/src/main/java/creator/loki/util/VoiceHelper.java +++ b/src/main/java/creator/loki/util/VoiceHelper.java @@ -3,6 +3,10 @@ package creator.loki.util; import java.util.concurrent.TimeUnit; import java.util.function.BooleanSupplier; +import creator.loki.feature.music.core.LavalinkManager; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; + public final class VoiceHelper { private static final int RETRIES = 10; private static final long DELAY_MS = 200; @@ -36,4 +40,22 @@ public final class VoiceHelper { } return false; } + + /** + * Подключается к голосовому каналу для воспроизведения. + * + * @return true если подключение успешно, false если не удалось + */ + public static boolean connectForPlayback(Guild guild, Member member, LavalinkManager lm) { + if (guild.getSelfMember().getVoiceState().inAudioChannel()) return true; + var vc = member.getVoiceState().getChannel().asVoiceChannel(); + guild.getJDA().getDirectAudioController().connect(vc); + if (!guild.getAudioManager().isConnected()) guild.getAudioManager().openAudioConnection(vc); + var self = guild.getSelfMember(); + waitForCondition(() -> self.getVoiceState().inAudioChannel()); + if (!self.getVoiceState().inAudioChannel()) return false; + var link = lm.getOrCreateLink(guild.getIdLong()); + waitForCondition(() -> link.getCachedPlayer() != null); + return link.getCachedPlayer() != null; + } } diff --git a/src/test/java/creator/loki/config/BotConfigTest.java b/src/test/java/creator/loki/config/BotConfigTest.java index 081a952..010790a 100644 --- a/src/test/java/creator/loki/config/BotConfigTest.java +++ b/src/test/java/creator/loki/config/BotConfigTest.java @@ -27,7 +27,9 @@ class BotConfigTest { 30, 24, null, - lavalink); + lavalink, + null, + null); assertEquals("token123", config.getMainToken()); assertEquals("token123", config.getToken()); assertNull(config.getMusicToken()); @@ -45,7 +47,8 @@ class BotConfigTest { void handlesNullValues() { BotConfig config = new BotConfig( - null, null, null, null, null, 0, null, null, null, null, 10, 300, 3000, 30, 24, null, null); + null, null, null, null, null, 0, null, null, null, null, 10, 300, 3000, 30, 24, null, + null, null, null); assertNull(config.getMainToken()); assertNull(config.getMusicToken()); assertNull(config.getRadioToken()); @@ -62,7 +65,8 @@ class BotConfigTest { LavalinkConfig lavalink = new LavalinkConfig("", 0, "", false); BotConfig config = new BotConfig( - "", "", "", "", "", 8080, "", "", null, null, 10, 300, 3000, 30, 24, null, lavalink); + "", "", "", "", "", 8080, "", "", null, null, 10, 300, 3000, 30, 24, null, lavalink, + null, null); assertEquals("", config.getMainToken()); assertEquals("", config.getMusicToken()); assertEquals("", config.getRadioToken()); diff --git a/src/test/java/creator/loki/core/registry/SmokeCommandRegistrationTest.java b/src/test/java/creator/loki/core/registry/SmokeCommandRegistrationTest.java index 8b6ea0b..f8d74c1 100644 --- a/src/test/java/creator/loki/core/registry/SmokeCommandRegistrationTest.java +++ b/src/test/java/creator/loki/core/registry/SmokeCommandRegistrationTest.java @@ -36,6 +36,7 @@ import creator.loki.feature.roast.AndrertListener; import creator.loki.feature.roast.RoastService; import creator.loki.feature.role.CustomRoleManager; import creator.loki.feature.ship.repository.ShipPairRepository; +import creator.loki.feature.tiktok.TikTokCommand; import creator.loki.feature.utility.UtilityCommandRegistrar; import creator.loki.feature.voice.service.VoiceChannelManager; import org.junit.jupiter.api.BeforeEach; @@ -102,12 +103,19 @@ class SmokeCommandRegistrationTest { mock(GuildConfigRepository.class)); moderationRegistrar.register(registry); + var tikTokCommand = mock(TikTokCommand.class); + lenient().when(tikTokCommand.getName()).thenReturn("tiktok"); + lenient().when(tikTokCommand.getDescription()).thenReturn("Скачать видео из TikTok"); + lenient().when(tikTokCommand.getUsage()).thenReturn("tiktok "); + lenient().when(tikTokCommand.getCategory()).thenReturn("Развлечения"); + var entertainmentRegistrar = new EntertainmentCommandRegistrar( mock(CooldownManager.class), mock(RatingService.class), mock(ShipPairRepository.class), - mock(BotConfig.class)); + mock(BotConfig.class), + tikTokCommand); entertainmentRegistrar.register(registry); var utilityRegistrar = diff --git a/src/test/java/creator/loki/feature/music/core/LavalinkManagerTest.java b/src/test/java/creator/loki/feature/music/core/LavalinkManagerTest.java index 11fa051..9f6f2be 100644 --- a/src/test/java/creator/loki/feature/music/core/LavalinkManagerTest.java +++ b/src/test/java/creator/loki/feature/music/core/LavalinkManagerTest.java @@ -40,7 +40,9 @@ class LavalinkManagerTest { 30, 24, null, - lavalinkConfig); + lavalinkConfig, + null, + null); var manager = new LavalinkManager(config, queueRepository); @@ -68,7 +70,9 @@ class LavalinkManagerTest { 30, 24, null, - lavalinkConfig); + lavalinkConfig, + null, + null); var manager = new LavalinkManager(config, queueRepository); var first = manager.getMusicManager(123L);