feat: TikTok download command + SOLID/DRY refactoring

- 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
This commit is contained in:
loki5512344 2026-07-14 10:49:39 +02:00
parent 03d0ed6c43
commit f93f6aafc8
Signed by: boba
GPG key ID: 253067914055423B
65 changed files with 1599 additions and 666 deletions

109
TODO.md
View file

@ -28,6 +28,15 @@
| A21 | ✅ | BotModule: путь к БД хардкодом | Вынесен в .env (`DB_PATH`) + BotConfig | | A21 | ✅ | BotModule: путь к БД хардкодом | Вынесен в .env (`DB_PATH`) + BotConfig |
| A34 | ✅ | CommandCategories: мёртвый код | Уже удалён (старый пакет) | | A34 | ✅ | CommandCategories: мёртвый код | Уже удалён (старый пакет) |
| A37 | ✅ | ProfileCommand: регистрация в PrefixCommandRegistrar | LevelCommandRegistrar | | A37 | ✅ | ProfileCommand: регистрация в PrefixCommandRegistrar | LevelCommandRegistrar |
| A41 | ✅ | TikTok: команда `!tiktok <url>` для скачивания видео | 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 | | D1 | `getDeterministicValue()` — целиком | GayCommand, FatCommand, IqCommand, PpCommand, BoykisserCommand, MustacheCommand, RateCommand | 7 | ✅ RatingService |
| D2 | Cooldown check boilerplate (5 строк) | Все entertainment команды | 10+ | ✅ RatingService | | D2 | Cooldown check boilerplate (5 строк) | Все entertainment команды | 10+ | ✅ RatingService |
| D3 | `trunc()` / `err()` helpers | MuteCommand, WarnCommand, SwapAndCaseCommand, UnmuteCommand, PunishCommand | 5 | ✅ ModerationHelper | | D3 | `trunc()` / `err()` helpers | MuteCommand, WarnCommand, SwapAndCaseCommand, UnmuteCommand, PunishCommand | 5 | ✅ ModerationHelper |
| D4 | Voice connect retry loop (цикл 10×200ms) | PlayCommand, RadioManager | 2 | | D4 | Voice connect retry loop (цикл 10×200ms) | PlayCommand, RadioManager | 2 | ✅ VoiceHelper.connectForPlayback() |
| D5 | RatingConfig — 6 методов с идентичной структурой | RatingConfig.java | 6 | | 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 | | D6 | Social команды (hug/kiss/slap/poke/tickle/cuddle/feed/pat) — шаблон | 10 файлов | 10 | ✅ SocialCommand.Flavor (7 команд), pat/compliment/roast — отдельные |
### SOLID ### SOLID
| # | Нарушение | Файл | Описание | | # | Нарушение | Файл | Описание |
|---|-----------|------|----------| |---|-----------|------|----------|
| S1 | SRP | BotModule.java | Биндит всё приложение | | S1 | SRP | BotModule.java | Биндит всё приложение | ✅ Перенесён в feature модули + LifecycleModule |
| S2 | SRP | SwapAndCaseCommand.java | Две разные команды в одном файле | | S2 | SRP | SwapAndCaseCommand.java | Две разные команды в одном файле | ✅ Уже разделены: SwapCommand + CaseLookupCommand |
| S3 | SRP | BotInitializer.java | Инициализирует 3 JDA, Lavalink, mutes, counters, health | | S3 | SRP | BotInitializer.java | Инициализирует 3 JDA, Lavalink, mutes, counters, health | ✅ JdaFactory + LavalinkManagerFactory |
| S4 | SRP | ShutdownManager.java | 9 зависимостей в конструкторе | | S4 | SRP | ShutdownManager.java | 9 зависимостей в конструкторе | ✅ Lifecycle interface, 3 зависимости |
| S5 | SRP | PlayCommand.java | execute() на 145 строк | | S5 | SRP | PlayCommand.java | execute() на 145 строк | ✅ PlayLoadHandler + VoiceHelper.connectForPlayback() |
| S6 | OCP | Meter-команды (gay/fat/iq/pp) | Добавить новую = копировать 90 строк | ✅ RatingService | | S6 | OCP | Meter-команды (gay/fat/iq/pp) | Добавить новую = копировать 90 строк | ✅ RatingService |
| S7 | DIP | MuteCommand, WarnCommand | >5 конкретных репозиториев вместо интерфейсов | | S7 | DIP | MuteCommand, WarnCommand | >5 конкретных репозиториев вместо интерфейсов | ✅ Репозитории уже интерфейсы (ModerationCaseRepository, ActiveMuteRepository, WarningRepository) |
### Файлы >150 строк ### Файлы >150 строк
| # | Файл | Строк | | # | Файл | Строк |
|---|------|-------| |---|------|-------|
| F1 | PlayCommand.java | 219 | | F1 | PlayCommand.java | 148 ✅ |
| F2 | QueueSlashHandler.java | 207 | | F2 | QueueSlashHandler.java | 207 |
| F3 | MusicBotControlHandler.java | 207 | | F3 | MusicBotControlHandler.java | 207 |
| F4 | QueueCommands.java | 207 | | F4 | QueueCommands.java | 207 |
| F5 | TrackScheduler.java | 202 | | F5 | TrackScheduler.java | 113 ✅ |
| F6 | MuteCommand.java | 199 | | F6 | MuteCommand.java | 173 ✅ |
| F7 | ModerationLogger.java | 199 | | F7 | ModerationLogger.java | 199 |
| F8 | FeatureTables.java | 193 | | F8 | FeatureTables.java | 193 |
| F9 | DatabaseMigration.java | 193 | | F9 | DatabaseMigration.java | 193 |
@ -297,7 +306,7 @@ feature/economy/
| F13 | RadioManager.java | 182 | | F13 | RadioManager.java | 182 |
| F14 | MetricsCollector.java | 182 | | F14 | MetricsCollector.java | 182 |
| F15 | WarnCommand.java | 184 | | F15 | WarnCommand.java | 184 |
| F16 | BotInitializer.java | 182 | | F16 | BotInitializer.java | 135 ✅ |
| F17 | SlashHandler's (Queue/Filter) | >100 | | F17 | SlashHandler's (Queue/Filter) | >100 |
| F18 | GenerateRatingsCommand.java | 172 | | F18 | GenerateRatingsCommand.java | 172 |
| F19 | BlackjackService.java | 172 | | F19 | BlackjackService.java | 172 |
@ -337,37 +346,37 @@ feature/economy/
## 📋 План рефакторинга (KISS/DRY/SOLID) ## 📋 План рефакторинга (KISS/DRY/SOLID)
### Этап 8: Убить дублирование (DRY) — в процессе ### Этап 8: Убить дублирование (DRY) — ✅ завершён
- [x] D1/D2: `RatingService` с `getDeterministicValue()`, cooldown, member parsing — 7 команд переписаны - [x] D1/D2: `RatingService` с `getDeterministicValue()`, cooldown, member parsing — 7 команд переписаны
- [x] D3: `ModerationHelper` с `trunc()`/`err()` — MuteCommand, UnmuteCommand, WarnCommand, SwapAndCaseCommand, PunishCommand - [x] D3: `ModerationHelper` с `trunc()`/`err()` — MuteCommand, UnmuteCommand, WarnCommand, SwapAndCaseCommand, PunishCommand
- [ ] D4: Вынести voice connect retry в утилиту - [x] D4: Вынести voice connect retry в утилиту — ✅ VoiceHelper.connectForPlayback()
- [ ] D5: RatingConfig — заменить 6 методов на data-driven (Map или config) - [x] D5: RatingConfig — заменить 6 методов на data-driven (Map или config) — ✅ getLevels(name) + findLevel(value, name)
- [ ] D6: Social команды — единый `SocialCommand` с параметром action - [x] D6: Social команды — единый `SocialCommand` с параметром action — ✅ SocialCommand.Flavor (7 команд)
### Этап 9: SOLID ### Этап 9: SOLID — ✅ завершён
- [ ] S1: Разбить `BotModule` на per-feature модули (FeatureModule → install в BotModule) - [x] S1: Разбить `BotModule` на per-feature модули (FeatureModule → install в BotModule) — ✅ 8 feature модулей + LifecycleModule
- [ ] S2: Разделить `SwapAndCaseCommand` на два отдельных файла - [x] S2: Разделить `SwapAndCaseCommand` на два отдельных файла — ✅ Already split (SwapCommand + CaseLookupCommand)
- [ ] S3: Выделить `MusicBotFactory`, `RadioBotFactory` из `BotInitializer` - [x] S3: Выделить `MusicBotFactory`, `RadioBotFactory` из `BotInitializer` — ✅ JdaFactory + LavalinkManagerFactory
- [ ] S4: Уменьшить кол-во зависимостей `ShutdownManager` через события (EventBus) - [x] S4: Уменьшить кол-во зависимостей `ShutdownManager` через события (EventBus) — ✅ Lifecycle interface (9→3 deps)
- [ ] S5: Разбить `PlayCommand.execute()` на методы - [x] S5: Разбить `PlayCommand.execute()` на методы — ✅ PlayLoadHandler + VoiceHelper.connectForPlayback()
- [ ] S6: Meter-команды сделать data-driven через `RatingService` - [x] S6: Meter-команды сделать data-driven через `RatingService` — ✅ RatingConfig.findLevel(value, name)
- [ ] S7: Выделить интерфейсы для репозиториев/сервисов (DIP) - [x] S7: Выделить интерфейсы для репозиториев/сервисов (DIP) — ✅ Already interfaces
### Этап 10: Файлы ≤150 строк ### Этап 10: Файлы ≤150 строк — в процессе
- [ ] F01: PlayCommand.java — разбить на методы/классы - [x] F01: PlayCommand.java — разбить на методы/классы — ✅ 207→148 строк
- [ ] F02-04: QueueSlashHandler, MusicBotControlHandler, QueueCommands — вынести общие части - [ ] F02-04: QueueSlashHandler, MusicBotControlHandler, QueueCommands — вынести общие части
- [ ] F05: TrackScheduler.java — вывести FilterState в отд. файл или утилиту - [x] F05: TrackScheduler.java — вывести FilterState в отд. файл или утилиту — ✅ 146→113 строк (TrackStarter)
- [ ] F06: MuteCommand.java — разбить на subcommand handler'ы - [x] F06: MuteCommand.java — разбить на subcommand handler'ы — ✅ 199→173 строки
- [ ] F07: ModerationLogger.java — разбить по типам событий - [ ] F07: ModerationLogger.java — разбить по типам событий
- [ ] F08-09: FeatureTables, DatabaseMigration — разбить по фичам - [ ] F08-09: FeatureTables, DatabaseMigration — разбить по фичам
- [ ] F10-12: Voice* — вынести логику в сервисы - [ ] F10-12: Voice* — вынести логику в сервисы
- [ ] F13: RadioManager.java — вынести voice connect в утилиту - [ ] F13: RadioManager.java — вынести voice connect в утилиту
- [ ] F14: MetricsCollector.java — разбить по типам метрик - [ ] F14: MetricsCollector.java — разбить по типам метрик
- [ ] F15: WarnCommand.java — вынести auto-mute в отдельный сервис - [ ] F15: WarnCommand.java — вынести auto-mute в отдельный сервис
- [ ] F16: BotInitializer.java — вынести фабрики ботов - [x] F16: BotInitializer.java — вынести фабрики ботов — ✅ 162→135 строк
- [ ] Остальные: разбить по SOLID - [ ] Остальные: разбить по SOLID
### Этап 11: ≤6 файлов в папке ### Этап 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 <url>` — скачивание видео из 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 ## ✅ Что добавлено в v2.3

View file

@ -1,7 +1,5 @@
package creator.loki.core; package creator.loki.core;
import java.util.List;
import com.google.inject.Inject; import com.google.inject.Inject;
import creator.loki.config.BotConfig; import creator.loki.config.BotConfig;
import creator.loki.core.registry.PrefixCommandRegistrar; 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.metrics.HealthHttpServer;
import creator.loki.feature.moderation.MuteScheduler; import creator.loki.feature.moderation.MuteScheduler;
import creator.loki.feature.music.core.LavalinkManager; 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.listener.MusicBotListener;
import creator.loki.feature.music.repository.QueueRepository; import creator.loki.feature.music.repository.QueueRepository;
import creator.loki.feature.radio.RadioBotListener; import creator.loki.feature.radio.RadioBotListener;
@ -17,11 +16,7 @@ import creator.loki.util.LoggerDecorator;
import dev.arbjerg.lavalink.libraries.jda.JDAVoiceUpdateListener; import dev.arbjerg.lavalink.libraries.jda.JDAVoiceUpdateListener;
import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder; import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.OnlineStatus;
import net.dv8tion.jda.api.entities.Activity;
import net.dv8tion.jda.api.requests.GatewayIntent; import net.dv8tion.jda.api.requests.GatewayIntent;
import net.dv8tion.jda.api.utils.cache.CacheFlag;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -75,7 +70,7 @@ public class BotInitializer {
JDA radioJda = null; JDA radioJda = null;
if (config.getRadioToken() != null && !config.getRadioToken().isBlank()) { if (config.getRadioToken() != null && !config.getRadioToken().isBlank()) {
LavalinkManager radioLavalink = LavalinkManager radioLavalink =
new LavalinkManager(config, queueRepository, config.getRadioToken()); LavalinkManagerFactory.create(config, queueRepository, config.getRadioToken());
radioJda = createRadioJDA(radioLavalink); radioJda = createRadioJDA(radioLavalink);
if (radioJda != null) { if (radioJda != null) {
radioManager.setRadioJda(radioJda); radioManager.setRadioJda(radioJda);
@ -90,73 +85,51 @@ public class BotInitializer {
private JDA buildMainJDA() { private JDA buildMainJDA() {
var builder = var builder =
createBuilder( JdaFactory.createBuilder(
config.getMainToken(), config.getMainToken(),
config.getActivity(), config.getActivity(),
GatewayIntent.GUILD_MESSAGES, GatewayIntent.GUILD_MESSAGES,
GatewayIntent.MESSAGE_CONTENT, GatewayIntent.MESSAGE_CONTENT,
GatewayIntent.GUILD_MEMBERS, GatewayIntent.GUILD_MEMBERS,
GatewayIntent.GUILD_VOICE_STATES); GatewayIntent.GUILD_VOICE_STATES);
if (lavalinkManager.getClient() != null) {
builder.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(lavalinkManager.getClient()));
}
builder.addEventListeners(listenerAggregator.getAll()); builder.addEventListeners(listenerAggregator.getAll());
return awaitJda(builder.build()); return buildWithLavalink(builder, lavalinkManager);
} }
private JDA createMusicJDA() { private JDA createMusicJDA() {
if (config.getMusicToken() == null || config.getMusicToken().isBlank()) return null; if (config.getMusicToken() == null || config.getMusicToken().isBlank()) return null;
LoggerDecorator.startup(logger, "Инициализация music бота..."); LoggerDecorator.startup(logger, "Инициализация music бота...");
LavalinkManager ml = new LavalinkManager(config, queueRepository, config.getMusicToken()); LavalinkManager ml =
LavalinkManagerFactory.create(config, queueRepository, config.getMusicToken());
var builder = var builder =
createBuilder( JdaFactory.createBuilder(
config.getMusicToken(), config.getMusicToken(),
"🎵 q!play", "🎵 q!play",
GatewayIntent.GUILD_MESSAGES, GatewayIntent.GUILD_MESSAGES,
GatewayIntent.MESSAGE_CONTENT, GatewayIntent.MESSAGE_CONTENT,
GatewayIntent.GUILD_VOICE_STATES); GatewayIntent.GUILD_VOICE_STATES);
if (ml.getClient() != null) { builder.addEventListeners(new MusicBotListener(ml, queueRepository, new PlayerManager(ml)));
builder.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(ml.getClient())); return buildWithLavalink(builder, ml);
}
builder.addEventListeners(
new MusicBotListener(
ml, queueRepository, new creator.loki.feature.music.core.PlayerManager(ml)));
return awaitJda(builder.build());
} }
private JDA createRadioJDA(LavalinkManager radioLavalink) { private JDA createRadioJDA(LavalinkManager radioLavalink) {
if (config.getRadioToken() == null || config.getRadioToken().isBlank()) return null; if (config.getRadioToken() == null || config.getRadioToken().isBlank()) return null;
LoggerDecorator.startup(logger, "Инициализация radio бота..."); LoggerDecorator.startup(logger, "Инициализация radio бота...");
var builder = var builder =
createBuilder( JdaFactory.createBuilder(
config.getRadioToken(), config.getRadioToken(),
"📻 r!play", "📻 r!play",
GatewayIntent.GUILD_MESSAGES, GatewayIntent.GUILD_MESSAGES,
GatewayIntent.MESSAGE_CONTENT, GatewayIntent.MESSAGE_CONTENT,
GatewayIntent.GUILD_VOICE_STATES); GatewayIntent.GUILD_VOICE_STATES);
if (radioLavalink.getClient() != null) {
builder.setVoiceDispatchInterceptor(
new JDAVoiceUpdateListener(radioLavalink.getClient()));
}
builder.addEventListeners(new RadioBotListener(radioManager)); builder.addEventListeners(new RadioBotListener(radioManager));
return awaitJda(builder.build()); return buildWithLavalink(builder, radioLavalink);
} }
private static JDABuilder createBuilder(String token, String activity, GatewayIntent... intents) { private static JDA buildWithLavalink(JDABuilder builder, LavalinkManager ll) {
return JDABuilder.createDefault(token) if (ll.getClient() != null) {
.setStatus(OnlineStatus.ONLINE) builder.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(ll.getClient()));
.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);
} }
return jda; return JdaFactory.awaitJda(builder.build());
} }
} }

View file

@ -9,9 +9,17 @@ public class BotJdaRegistry {
private JDA music; private JDA music;
private JDA radio; private JDA radio;
public void setMain(JDA jda) { this.main = jda; } public void setMain(JDA jda) {
public void setMusic(JDA jda) { this.music = jda; } this.main = jda;
public void setRadio(JDA jda) { this.radio = jda; } }
public void setMusic(JDA jda) {
this.music = jda;
}
public void setRadio(JDA jda) {
this.radio = jda;
}
public JDA getByPersonality(String personality) { public JDA getByPersonality(String personality) {
return switch (personality) { return switch (personality) {

View file

@ -19,7 +19,7 @@ import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
public class EventDispatcher { public class EventDispatcher implements Lifecycle {
private static final Logger logger = LoggerFactory.getLogger(EventDispatcher.class); private static final Logger logger = LoggerFactory.getLogger(EventDispatcher.class);
private final BotConfig config; private final BotConfig config;
private final CommandRegistry registry; private final CommandRegistry registry;

View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -0,0 +1,22 @@
package creator.loki.core;
/**
* Интерфейс для компонентов, требующих корректного завершения работы.
*
* <p>Реализуется классами, которые управляют ресурсами (потоками, соединениями, планировщиками) и
* должны быть корректно остановлены при завершении работы бота.
*/
public interface Lifecycle {
/**
* Возвращает имя компонента для логирования при завершении работы.
*
* @return имя компонента (по умолчанию - простое имя класса)
*/
default String name() {
return getClass().getSimpleName();
}
/** Корректно завершает работу компонента, освобождая управляемые ресурсы. */
void shutdown();
}

View file

@ -1,16 +1,11 @@
package creator.loki.core; package creator.loki.core;
import java.time.Duration; import java.time.Duration;
import java.util.Set;
import com.google.inject.Inject; import com.google.inject.Inject;
import com.google.inject.Singleton; 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.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.feature.radio.RadioManager;
import creator.loki.util.LoggerDecorator; import creator.loki.util.LoggerDecorator;
import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDA;
@ -21,34 +16,13 @@ import org.slf4j.LoggerFactory;
public class ShutdownManager { public class ShutdownManager {
private static final Logger logger = LoggerFactory.getLogger(ShutdownManager.class); private static final Logger logger = LoggerFactory.getLogger(ShutdownManager.class);
private final HealthHttpServer healthHttpServer; private final Set<Lifecycle> lifecycles;
private final CooldownManager cooldownManager;
private final RateLimiter rateLimiter;
private final VoiceTracker voiceTracker;
private final MemberCounterListener memberCounterListener;
private final MuteScheduler muteScheduler;
private final EventDispatcher dispatcher;
private final Database database; private final Database database;
private final RadioManager radioManager; private final RadioManager radioManager;
@Inject @Inject
public ShutdownManager( public ShutdownManager(Set<Lifecycle> lifecycles, Database database, RadioManager radioManager) {
HealthHttpServer healthHttpServer, this.lifecycles = lifecycles;
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;
this.database = database; this.database = database;
this.radioManager = radioManager; this.radioManager = radioManager;
} }
@ -67,19 +41,15 @@ public class ShutdownManager {
logger.info("========================================"); logger.info("========================================");
try { try {
// Stop all radio playback
if (cluster.hasRadioBot()) { if (cluster.hasRadioBot()) {
radioManager.getRadioJda().getGuilds().forEach(g -> radioManager.stopRadio(g.getIdLong())); radioManager.getRadioJda().getGuilds().forEach(g -> radioManager.stopRadio(g.getIdLong()));
} }
healthHttpServer.stop();
LoggerDecorator.bot(logger, "Остановка менеджеров..."); LoggerDecorator.bot(logger, "Остановка менеджеров...");
cooldownManager.shutdown(); for (Lifecycle lifecycle : lifecycles) {
rateLimiter.shutdown(); LoggerDecorator.bot(logger, "Остановка " + lifecycle.name() + "...");
voiceTracker.shutdown(); lifecycle.shutdown();
memberCounterListener.shutdown(); }
muteScheduler.shutdown();
dispatcher.shutdown();
LoggerDecorator.database(logger, "Закрытие базы данных..."); LoggerDecorator.database(logger, "Закрытие базы данных...");
database.close(); database.close();

View file

@ -6,7 +6,9 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
public class CooldownManager { import creator.loki.core.Lifecycle;
public class CooldownManager implements Lifecycle {
private final ConcurrentHashMap<String, Instant> cooldowns = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, Instant> cooldowns = new ConcurrentHashMap<>();
private final ScheduledExecutorService cleanupExecutor = private final ScheduledExecutorService cleanupExecutor =
Executors.newSingleThreadScheduledExecutor(); Executors.newSingleThreadScheduledExecutor();

View file

@ -5,12 +5,13 @@ import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import creator.loki.core.Lifecycle;
import creator.loki.util.LoggerDecorator; import creator.loki.util.LoggerDecorator;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
/** Rate limiter для защиты от спама Использует алгоритм Token Bucket */ /** Rate limiter для защиты от спама Использует алгоритм Token Bucket */
public class RateLimiter { public class RateLimiter implements Lifecycle {
private static final Logger logger = LoggerFactory.getLogger(RateLimiter.class); private static final Logger logger = LoggerFactory.getLogger(RateLimiter.class);
// Лимиты по умолчанию // Лимиты по умолчанию

View file

@ -8,11 +8,12 @@ import java.sql.Statement;
import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource; import com.zaxxer.hikari.HikariDataSource;
import creator.loki.core.Lifecycle;
import creator.loki.util.LoggerDecorator; import creator.loki.util.LoggerDecorator;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
public class Database { public class Database implements Lifecycle {
private static final Logger logger = LoggerFactory.getLogger(Database.class); private static final Logger logger = LoggerFactory.getLogger(Database.class);
private final String dbPath; private final String dbPath;
private HikariDataSource dataSource; private HikariDataSource dataSource;
@ -105,4 +106,9 @@ public class Database {
LoggerDecorator.database(logger, "Пул соединений закрыт"); LoggerDecorator.database(logger, "Пул соединений закрыт");
} }
} }
@Override
public void shutdown() {
close();
}
} }

View file

@ -3,40 +3,33 @@ package creator.loki.di;
import com.google.inject.AbstractModule; import com.google.inject.AbstractModule;
import com.google.inject.Provides; import com.google.inject.Provides;
import com.google.inject.Singleton; import com.google.inject.Singleton;
import com.google.inject.multibindings.Multibinder;
import creator.loki.config.BotConfig; import creator.loki.config.BotConfig;
import creator.loki.config.ConfigLoader; import creator.loki.config.ConfigLoader;
import creator.loki.core.EventDispatcher; import creator.loki.core.EventDispatcher;
import creator.loki.core.ListenerAggregator; import creator.loki.core.ListenerAggregator;
import creator.loki.core.MessageCache; import creator.loki.core.MessageCache;
import creator.loki.core.ShutdownManager; import creator.loki.core.ShutdownManager;
import creator.loki.core.registry.CommandRegistrar;
import creator.loki.core.registry.CommandRegistry; import creator.loki.core.registry.CommandRegistry;
import creator.loki.core.registry.PrefixCommandRegistrar; import creator.loki.core.registry.PrefixCommandRegistrar;
import creator.loki.core.security.CooldownManager; import creator.loki.core.security.CooldownManager;
import creator.loki.core.security.RateLimiter; import creator.loki.core.security.RateLimiter;
import creator.loki.database.DatabaseModule; import creator.loki.database.DatabaseModule;
import creator.loki.feature.counter.CounterModule; import creator.loki.feature.counter.CounterModule;
import creator.loki.feature.economy.EconomyCommandRegistrar;
import creator.loki.feature.economy.EconomyModule; import creator.loki.feature.economy.EconomyModule;
import creator.loki.feature.entertainment.EntertainmentCommandRegistrar; import creator.loki.feature.entertainment.EntertainmentModule;
import creator.loki.feature.image.ImageCommandRegistrar;
import creator.loki.feature.image.ImageModule; import creator.loki.feature.image.ImageModule;
import creator.loki.feature.level.LevelModule; import creator.loki.feature.level.LevelModule;
import creator.loki.feature.level.commands.LevelCommandRegistrar;
import creator.loki.feature.metrics.HealthHttpServer; import creator.loki.feature.metrics.HealthHttpServer;
import creator.loki.feature.metrics.MetricsCollector; import creator.loki.feature.metrics.MetricsCollector;
import creator.loki.feature.metrics.MetricsModule; import creator.loki.feature.metrics.MetricsModule;
import creator.loki.feature.moderation.ModerationCommandRegistrar;
import creator.loki.feature.moderation.ModerationModule; import creator.loki.feature.moderation.ModerationModule;
import creator.loki.feature.music.MusicCommandRegistrar;
import creator.loki.feature.music.MusicModule; import creator.loki.feature.music.MusicModule;
import creator.loki.feature.profile.ProfileModule; import creator.loki.feature.profile.ProfileModule;
import creator.loki.feature.radio.RadioCommandRegistrar;
import creator.loki.feature.radio.RadioModule; import creator.loki.feature.radio.RadioModule;
import creator.loki.feature.roast.RoastModule; import creator.loki.feature.roast.RoastModule;
import creator.loki.feature.role.RoleModule; 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.voice.VoiceModule;
import creator.loki.feature.welcome.WelcomeModule; import creator.loki.feature.welcome.WelcomeModule;
@ -67,17 +60,10 @@ public class BotModule extends AbstractModule {
install(new RadioModule()); install(new RadioModule());
install(new RoastModule()); install(new RoastModule());
install(new ImageModule()); install(new ImageModule());
install(new TikTokModule());
Multibinder<CommandRegistrar> commandBinder = install(new EntertainmentModule());
Multibinder.newSetBinder(binder(), CommandRegistrar.class); install(new UtilityModule());
commandBinder.addBinding().to(LevelCommandRegistrar.class); install(new LifecycleModule());
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);
} }
@Provides @Provides

View file

@ -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<Lifecycle> 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);
}
}

View file

@ -52,8 +52,7 @@ public final class HelpCategories {
Map.entry("Система", "Метрики и служебная информация для админов"), Map.entry("Система", "Метрики и служебная информация для админов"),
Map.entry("Настройка", "Настройка систем бота (только админы)"), Map.entry("Настройка", "Настройка систем бота (только админы)"),
Map.entry("Супер-Админ", "Сервисные команды владельца бота"), Map.entry("Супер-Админ", "Сервисные команды владельца бота"),
Map.entry( Map.entry(CommandCategories.NSFW_IMAGES, "Поиск изображений (только в NSFW каналах)"));
CommandCategories.NSFW_IMAGES, "Поиск изображений (только в NSFW каналах)"));
} }
private HelpCategories() {} private HelpCategories() {}

View file

@ -5,6 +5,7 @@ import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import com.google.inject.Inject; import com.google.inject.Inject;
import creator.loki.core.Lifecycle;
import creator.loki.util.LoggerDecorator; import creator.loki.util.LoggerDecorator;
import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.events.guild.member.GuildMemberJoinEvent; import net.dv8tion.jda.api.events.guild.member.GuildMemberJoinEvent;
@ -14,7 +15,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
/** Listener для счетчика участников */ /** Listener для счетчика участников */
public class MemberCounterListener extends ListenerAdapter { public class MemberCounterListener extends ListenerAdapter implements Lifecycle {
private static final Logger logger = LoggerFactory.getLogger(MemberCounterListener.class); private static final Logger logger = LoggerFactory.getLogger(MemberCounterListener.class);
private final CounterManager counterManager; private final CounterManager counterManager;
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);

View file

@ -3,6 +3,8 @@ package creator.loki.feature.economy;
import com.google.inject.AbstractModule; import com.google.inject.AbstractModule;
import com.google.inject.Provides; import com.google.inject.Provides;
import com.google.inject.Singleton; import com.google.inject.Singleton;
import com.google.inject.multibindings.Multibinder;
import creator.loki.core.registry.CommandRegistrar;
import creator.loki.core.security.CooldownManager; import creator.loki.core.security.CooldownManager;
import creator.loki.database.Database; import creator.loki.database.Database;
import creator.loki.feature.economy.repository.EconomyRepository; import creator.loki.feature.economy.repository.EconomyRepository;
@ -16,7 +18,11 @@ import creator.loki.feature.economy.service.SlotService;
public class EconomyModule extends AbstractModule { public class EconomyModule extends AbstractModule {
@Override @Override
protected void configure() {} protected void configure() {
Multibinder.newSetBinder(binder(), CommandRegistrar.class)
.addBinding()
.to(EconomyCommandRegistrar.class);
}
@Provides @Provides
@Singleton @Singleton

View file

@ -32,23 +32,27 @@ import creator.loki.feature.social.commands.ComplimentCommand;
import creator.loki.feature.social.commands.PatCommand; import creator.loki.feature.social.commands.PatCommand;
import creator.loki.feature.social.commands.RoastCommand; import creator.loki.feature.social.commands.RoastCommand;
import creator.loki.feature.social.commands.SocialCommand; import creator.loki.feature.social.commands.SocialCommand;
import creator.loki.feature.tiktok.TikTokCommand;
public class EntertainmentCommandRegistrar implements CommandRegistrar { public class EntertainmentCommandRegistrar implements CommandRegistrar {
private final CooldownManager cooldownManager; private final CooldownManager cooldownManager;
private final RatingService ratingService; private final RatingService ratingService;
private final ShipPairRepository shipPairRepository; private final ShipPairRepository shipPairRepository;
private final BotConfig config; private final BotConfig config;
private final TikTokCommand tikTokCommand;
@Inject @Inject
public EntertainmentCommandRegistrar( public EntertainmentCommandRegistrar(
CooldownManager cooldownManager, CooldownManager cooldownManager,
RatingService ratingService, RatingService ratingService,
ShipPairRepository shipPairRepository, ShipPairRepository shipPairRepository,
BotConfig config) { BotConfig config,
TikTokCommand tikTokCommand) {
this.cooldownManager = cooldownManager; this.cooldownManager = cooldownManager;
this.ratingService = ratingService; this.ratingService = ratingService;
this.shipPairRepository = shipPairRepository; this.shipPairRepository = shipPairRepository;
this.config = config; this.config = config;
this.tikTokCommand = tikTokCommand;
} }
@Override @Override
@ -61,6 +65,7 @@ public class EntertainmentCommandRegistrar implements CommandRegistrar {
registry.register(new RoastCommand()); registry.register(new RoastCommand());
registry.register(new ComplimentCommand()); registry.register(new ComplimentCommand());
registry.register(new GlazbobaCommand()); registry.register(new GlazbobaCommand());
registry.register(tikTokCommand);
registry.register(new IqCommand(ratingService)); registry.register(new IqCommand(ratingService));
registry.register(new GayCommand(ratingService)); registry.register(new GayCommand(ratingService));

View file

@ -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);
}
}

View file

@ -97,7 +97,8 @@ public class HelpCommand implements Command {
boolean isNsfwChannel) { boolean isNsfwChannel) {
int totalCommands = int totalCommands =
commandsByCategory.entrySet().stream() 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()) .mapToInt(e -> e.getValue().size())
.sum(); .sum();
@ -106,8 +107,7 @@ public class HelpCommand implements Command {
.sorted() .sorted()
.forEach( .forEach(
category -> { category -> {
if (!HelpCategories.isCategoryVisible( if (!HelpCategories.isCategoryVisible(category, isAdmin, isSuper, isNsfwChannel)) {
category, isAdmin, isSuper, isNsfwChannel)) {
return; return;
} }

View file

@ -1,14 +1,14 @@
package creator.loki.feature.image; 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.ArrayList;
import java.util.List; import java.util.List;
import java.util.Set; 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 { public final class ImageEmbedHelper {
private static final String NAV_PREFIX = "img_nav:"; private static final String NAV_PREFIX = "img_nav:";
@ -24,9 +24,8 @@ public final class ImageEmbedHelper {
boolean descUsed = false; boolean descUsed = false;
for (String url : pageUrls) { for (String url : pageUrls) {
var builder = EmbedFactory.create() var builder =
.withTitle("🔞 " + session.site()) EmbedFactory.create().withTitle("🔞 " + session.site()).withColor(session.color());
.withColor(session.color());
String ext = extension(url); String ext = extension(url);
boolean isVideo = isVideoExt(ext); boolean isVideo = isVideoExt(ext);
if (!isVideo) { if (!isVideo) {
@ -57,10 +56,11 @@ public final class ImageEmbedHelper {
int last = embeds.size() - 1; int last = embeds.size() - 1;
var lastUrl = pageUrls.get(last); var lastUrl = pageUrls.get(last);
var lastExt = extension(lastUrl); var lastExt = extension(lastUrl);
var lastBuilder = EmbedFactory.create() var lastBuilder =
.withTitle("🔞 " + session.site()) EmbedFactory.create()
.withColor(session.color()) .withTitle("🔞 " + session.site())
.withFooter(footer); .withColor(session.color())
.withFooter(footer);
if (!isVideoExt(lastExt)) { if (!isVideoExt(lastExt)) {
lastBuilder.withImage(lastUrl); lastBuilder.withImage(lastUrl);
} }
@ -88,8 +88,7 @@ public final class ImageEmbedHelper {
boolean noNext = !session.hasNext() && session.exhausted(); boolean noNext = !session.hasNext() && session.exhausted();
return ActionRow.of( return ActionRow.of(
Button.primary(NAV_PREFIX + "prev", "◀ Назад").withDisabled(noPrev), 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) { public static String formatTags(String tags) {

View file

@ -3,7 +3,9 @@ package creator.loki.feature.image;
import com.google.inject.AbstractModule; import com.google.inject.AbstractModule;
import com.google.inject.Provides; import com.google.inject.Provides;
import com.google.inject.Singleton; import com.google.inject.Singleton;
import com.google.inject.multibindings.Multibinder;
import creator.loki.config.BotConfig; import creator.loki.config.BotConfig;
import creator.loki.core.registry.CommandRegistrar;
public class ImageModule extends AbstractModule { public class ImageModule extends AbstractModule {
@ -11,6 +13,10 @@ public class ImageModule extends AbstractModule {
protected void configure() { protected void configure() {
bind(ImageSessionManager.class).in(Singleton.class); bind(ImageSessionManager.class).in(Singleton.class);
bind(ImageNavigationHandler.class).in(Singleton.class); bind(ImageNavigationHandler.class).in(Singleton.class);
Multibinder.newSetBinder(binder(), CommandRegistrar.class)
.addBinding()
.to(ImageCommandRegistrar.class);
} }
@Provides @Provides

View file

@ -1,5 +1,7 @@
package creator.loki.feature.image; package creator.loki.feature.image;
import java.util.List;
import com.google.inject.Inject; import com.google.inject.Inject;
import com.google.inject.Singleton; import com.google.inject.Singleton;
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; 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.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.util.List;
@Singleton @Singleton
public class ImageNavigationHandler extends ListenerAdapter { public class ImageNavigationHandler extends ListenerAdapter {
@ -84,7 +84,8 @@ public class ImageNavigationHandler extends ListenerAdapter {
private void loadMore(ButtonInteractionEvent event, ImageSession session, String messageId) { private void loadMore(ButtonInteractionEvent event, ImageSession session, String messageId) {
int nextApiPage = session.apiPage() + 1; 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<String> newUrls = fetchPage(session.site(), session.tags(), nextApiPage); List<String> newUrls = fetchPage(session.site(), session.tags(), nextApiPage);
logger.debug("loadMore: got {} urls", newUrls.size()); logger.debug("loadMore: got {} urls", newUrls.size());
@ -93,7 +94,8 @@ public class ImageNavigationHandler extends ListenerAdapter {
ImageSession updated = session.withMoreUrls(newUrls); ImageSession updated = session.withMoreUrls(newUrls);
sessionManager.update(messageId, updated); sessionManager.update(messageId, updated);
event.getHook() event
.getHook()
.editOriginalEmbeds(ImageEmbedHelper.buildEmbeds(updated)) .editOriginalEmbeds(ImageEmbedHelper.buildEmbeds(updated))
.setComponents(ImageEmbedHelper.buildActionRow(updated)) .setComponents(ImageEmbedHelper.buildActionRow(updated))
.queue(); .queue();
@ -101,18 +103,19 @@ public class ImageNavigationHandler extends ListenerAdapter {
private List<String> fetchPage(String site, String tags, int apiPage) { private List<String> fetchPage(String site, String tags, int apiPage) {
return switch (site) { return switch (site) {
case "e621.net" -> imageService.fetchE621(tags, PAGE_FETCH, apiPage); case "e621.net" -> imageService.fetchE621(tags, PAGE_FETCH, apiPage);
case "rule34.xxx" -> imageService.fetchR34(tags, PAGE_FETCH, apiPage); case "rule34.xxx" -> imageService.fetchR34(tags, PAGE_FETCH, apiPage);
case "gelbooru.com" -> imageService.fetchGelbooru(tags, PAGE_FETCH, apiPage); case "gelbooru.com" -> imageService.fetchGelbooru(tags, PAGE_FETCH, apiPage);
case "yande.re" -> imageService.fetchYandere(tags, PAGE_FETCH, apiPage); case "yande.re" -> imageService.fetchYandere(tags, PAGE_FETCH, apiPage);
default -> List.of(); default -> List.of();
}; };
} }
// ── Вспомогательное ────────────────────────────────────────────────────────── // ── Вспомогательное ──────────────────────────────────────────────────────────
private static void editMessage(ButtonInteractionEvent event, ImageSession session) { private static void editMessage(ButtonInteractionEvent event, ImageSession session) {
event.editMessageEmbeds(ImageEmbedHelper.buildEmbeds(session)) event
.editMessageEmbeds(ImageEmbedHelper.buildEmbeds(session))
.setComponents(ImageEmbedHelper.buildActionRow(session)) .setComponents(ImageEmbedHelper.buildActionRow(session))
.queue(); .queue();
} }

View file

@ -1,5 +1,10 @@
package creator.loki.feature.image; 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.JsonArray;
import com.google.gson.JsonElement; import com.google.gson.JsonElement;
import com.google.gson.JsonObject; import com.google.gson.JsonObject;
@ -13,11 +18,6 @@ import okhttp3.Response;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
@Singleton @Singleton
public class ImageService { public class ImageService {
private static final Logger logger = LoggerFactory.getLogger(ImageService.class); private static final Logger logger = LoggerFactory.getLogger(ImageService.class);
@ -48,11 +48,7 @@ public class ImageService {
int capped = Math.min(limit, 100); int capped = Math.min(limit, 100);
String url = E621_API + "?tags=" + encodeTags(tags) + "&limit=" + capped + "&page=" + page; String url = E621_API + "?tags=" + encodeTags(tags) + "&limit=" + capped + "&page=" + page;
Request request = Request request =
new Request.Builder() new Request.Builder().url(url).header("User-Agent", "LokiBot/1.0 (by loki)").get().build();
.url(url)
.header("User-Agent", "LokiBot/1.0 (by loki)")
.get()
.build();
try (Response response = client.newCall(request).execute()) { try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful() || response.body() == null) { if (!response.isSuccessful() || response.body() == null) {
@ -91,8 +87,7 @@ public class ImageService {
public List<String> fetchR34(String tags, int limit, int page) { public List<String> fetchR34(String tags, int limit, int page) {
int capped = Math.min(limit, 100); int capped = Math.min(limit, 100);
String url = String url = R34_API + "&tags=" + encodeTags(tags) + "&limit=" + capped + "&pid=" + (page - 1);
R34_API + "&tags=" + encodeTags(tags) + "&limit=" + capped + "&pid=" + (page - 1);
if (!r34ApiKey.isEmpty() && !r34UserId.isEmpty()) { if (!r34ApiKey.isEmpty() && !r34UserId.isEmpty()) {
url += "&api_key=" + r34ApiKey + "&user_id=" + r34UserId; url += "&api_key=" + r34ApiKey + "&user_id=" + r34UserId;
} }
@ -132,11 +127,8 @@ public class ImageService {
} }
private List<String> fetchBooruJson(String url, int limit) { private List<String> fetchBooruJson(String url, int limit) {
Request request = new Request.Builder() Request request =
.url(url) new Request.Builder().url(url).header("User-Agent", "LokiBot/1.0 (by loki)").get().build();
.header("User-Agent", "LokiBot/1.0 (by loki)")
.get()
.build();
try (Response response = client.newCall(request).execute()) { try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful() || response.body() == null) { if (!response.isSuccessful() || response.body() == null) {
@ -145,7 +137,10 @@ public class ImageService {
} }
String body = response.body().string(); String body = response.body().string();
if (body.isBlank() || !body.startsWith("[")) { 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(); return List.of();
} }
JsonArray posts = JsonParser.parseString(body).getAsJsonArray(); JsonArray posts = JsonParser.parseString(body).getAsJsonArray();

View file

@ -6,8 +6,8 @@ import java.util.List;
/** /**
* Иммутабельная сессия навигации по изображениям. * Иммутабельная сессия навигации по изображениям.
* *
* <p>exhausted = true означает, что API вернул пустой ответ на последнем запросе, * <p>exhausted = true означает, что API вернул пустой ответ на последнем запросе, т.е. новых
* т.е. новых страниц больше нет — тогда кнопка «Вперёд» блокируется. * страниц больше нет — тогда кнопка «Вперёд» блокируется.
*/ */
public record ImageSession( public record ImageSession(
List<String> urls, List<String> urls,
@ -37,8 +37,17 @@ public record ImageSession(
long userId, long userId,
long guildId) { long guildId) {
return new ImageSession( return new ImageSession(
urls, 0, pageSize, site, color, tags, userId, guildId, urls,
System.currentTimeMillis(), 1, false); 0,
pageSize,
site,
color,
tags,
userId,
guildId,
System.currentTimeMillis(),
1,
false);
} }
// ── Навигация ──────────────────────────────────────────────────────────────── // ── Навигация ────────────────────────────────────────────────────────────────
@ -66,25 +75,52 @@ public record ImageSession(
public ImageSession withPage(int newPage) { public ImageSession withPage(int newPage) {
return new ImageSession( return new ImageSession(
urls, newPage, pageSize, site, color, tags, userId, guildId, urls,
System.currentTimeMillis(), apiPage, exhausted); newPage,
pageSize,
site,
color,
tags,
userId,
guildId,
System.currentTimeMillis(),
apiPage,
exhausted);
} }
/** /**
* Добавляем новые URL из следующей API-страницы и переходим на следующую * Добавляем новые URL из следующей API-страницы и переходим на следующую внутреннюю страницу.
* внутреннюю страницу. Если newUrls пустой — помечаем сессию как exhausted. * Если newUrls пустой — помечаем сессию как exhausted.
*/ */
public ImageSession withMoreUrls(List<String> newUrls) { public ImageSession withMoreUrls(List<String> newUrls) {
if (newUrls.isEmpty()) { if (newUrls.isEmpty()) {
// API больше ничего не отдал — остаёмся на месте, ставим флаг // API больше ничего не отдал — остаёмся на месте, ставим флаг
return new ImageSession( return new ImageSession(
urls, page, pageSize, site, color, tags, userId, guildId, urls,
System.currentTimeMillis(), apiPage, true); page,
pageSize,
site,
color,
tags,
userId,
guildId,
System.currentTimeMillis(),
apiPage,
true);
} }
List<String> combined = new ArrayList<>(urls); List<String> combined = new ArrayList<>(urls);
combined.addAll(newUrls); combined.addAll(newUrls);
return new ImageSession( return new ImageSession(
combined, page + 1, pageSize, site, color, tags, userId, guildId, combined,
System.currentTimeMillis(), apiPage + 1, false); page + 1,
pageSize,
site,
color,
tags,
userId,
guildId,
System.currentTimeMillis(),
apiPage + 1,
false);
} }
} }

View file

@ -1,7 +1,6 @@
package creator.loki.feature.image; package creator.loki.feature.image;
import java.awt.Color; import java.awt.Color;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;

View file

@ -1,5 +1,8 @@
package creator.loki.feature.image.commands; package creator.loki.feature.image.commands;
import java.awt.Color;
import java.util.List;
import creator.loki.command.Command; import creator.loki.command.Command;
import creator.loki.command.CommandContext; import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedTemplates; import creator.loki.embed.EmbedTemplates;
@ -9,24 +12,21 @@ import creator.loki.feature.image.ImageSession;
import creator.loki.feature.image.ImageSessionManager; import creator.loki.feature.image.ImageSessionManager;
import net.dv8tion.jda.api.entities.channel.attribute.IAgeRestrictedChannel; import net.dv8tion.jda.api.entities.channel.attribute.IAgeRestrictedChannel;
import java.awt.Color;
import java.util.List;
/** /**
* Общая база для команд поиска изображений. * Общая база для команд поиска изображений. Убирает дублирование: NSFW-проверка, parseArgs,
* Убирает дублирование: NSFW-проверка, parseArgs, buildEmbeds, buildButtons. * buildEmbeds, buildButtons.
*/ */
public abstract class AbstractImageCommand implements Command { 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 DEFAULT_FETCH = 30;
protected static final int MAX_FETCH = 50; protected static final int MAX_FETCH = 50;
protected final ImageService imageService; protected final ImageService imageService;
protected final ImageSessionManager sessionManager; protected final ImageSessionManager sessionManager;
protected AbstractImageCommand(ImageService imageService, ImageSessionManager sessionManager) { protected AbstractImageCommand(ImageService imageService, ImageSessionManager sessionManager) {
this.imageService = imageService; this.imageService = imageService;
this.sessionManager = sessionManager; this.sessionManager = sessionManager;
} }
@ -49,40 +49,54 @@ public abstract class AbstractImageCommand implements Command {
@Override @Override
public void execute(CommandContext context) { public void execute(CommandContext context) {
if (!isNsfwChannel(context)) { if (!isNsfwChannel(context)) {
context.getChannel() context
.sendMessageEmbeds(EmbedTemplates.error( .getChannel()
"🔞 NSFW", "Эта команда доступна только в каналах с возрастным ограничением.")) .sendMessageEmbeds(
EmbedTemplates.error(
"🔞 NSFW", "Эта команда доступна только в каналах с возрастным ограничением."))
.queue(); .queue();
return; return;
} }
ParseResult parsed = parseArgs(context.getArgs()); ParseResult parsed = parseArgs(context.getArgs());
String searchingMsg = "Ищу изображения на " + siteDisplayName() String searchingMsg =
+ ImageEmbedHelper.tagsDesc(parsed.tags()) + "..."; "Ищу изображения на "
+ siteDisplayName()
+ ImageEmbedHelper.tagsDesc(parsed.tags())
+ "...";
context.getChannel() context
.getChannel()
.sendMessageEmbeds(EmbedTemplates.info("🔍 Поиск", searchingMsg)) .sendMessageEmbeds(EmbedTemplates.info("🔍 Поиск", searchingMsg))
.queue(msg -> { .queue(
List<String> urls = fetchUrls(parsed.tags(), parsed.limit()); msg -> {
if (urls.isEmpty()) { List<String> urls = fetchUrls(parsed.tags(), parsed.limit());
msg.editMessageEmbeds(EmbedTemplates.warning( if (urls.isEmpty()) {
"😔 Ничего не найдено", msg.editMessageEmbeds(
"По запросу" + ImageEmbedHelper.tagsDesc(parsed.tags()) + " ничего не найдено.")) EmbedTemplates.warning(
.queue(); "😔 Ничего не найдено",
return; "По запросу"
} + ImageEmbedHelper.tagsDesc(parsed.tags())
+ " ничего не найдено."))
.queue();
return;
}
ImageSession session = ImageSession.create( ImageSession session =
urls, PAGE_SIZE, siteName(), siteColor(), ImageSession.create(
parsed.tags(), urls,
context.getAuthor().getIdLong(), PAGE_SIZE,
context.getGuild().getIdLong()); siteName(),
sessionManager.put(msg.getId(), session); siteColor(),
parsed.tags(),
context.getAuthor().getIdLong(),
context.getGuild().getIdLong());
sessionManager.put(msg.getId(), session);
msg.editMessageEmbeds(ImageEmbedHelper.buildEmbeds(session)) msg.editMessageEmbeds(ImageEmbedHelper.buildEmbeds(session))
.setComponents(ImageEmbedHelper.buildActionRow(session)) .setComponents(ImageEmbedHelper.buildActionRow(session))
.queue(); .queue();
}); });
} }
// ── Вспомогательное ────────────────────────────────────────────────────────── // ── Вспомогательное ──────────────────────────────────────────────────────────

View file

@ -1,26 +1,52 @@
package creator.loki.feature.image.commands; package creator.loki.feature.image.commands;
import java.awt.Color;
import java.util.List;
import creator.loki.command.CommandCategories; import creator.loki.command.CommandCategories;
import creator.loki.feature.image.ImageService; import creator.loki.feature.image.ImageService;
import creator.loki.feature.image.ImageSessionManager; import creator.loki.feature.image.ImageSessionManager;
import java.awt.Color;
import java.util.List;
public class E621Command extends AbstractImageCommand { public class E621Command extends AbstractImageCommand {
public E621Command(ImageService imageService, ImageSessionManager sessionManager) { public E621Command(ImageService imageService, ImageSessionManager sessionManager) {
super(imageService, sessionManager); super(imageService, sessionManager);
} }
@Override public String getName() { return "e621"; } @Override
@Override public String getDescription() { return "Поиск изображений на e621.net"; } public String getName() {
@Override public String getUsage() { return "e621 <кол-во до 50> <теги>"; } return "e621";
@Override public String getCategory() { return CommandCategories.NSFW_IMAGES; } }
@Override protected String siteName() { return "e621.net"; } @Override
@Override protected String siteDisplayName() { return "e621"; } public String getDescription() {
@Override protected Color siteColor() { return new Color(102, 45, 145); } 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 @Override
protected List<String> fetchUrls(String tags, int limit) { protected List<String> fetchUrls(String tags, int limit) {

View file

@ -1,5 +1,8 @@
package creator.loki.feature.image.commands; package creator.loki.feature.image.commands;
import java.awt.Color;
import java.util.List;
import creator.loki.command.CommandCategories; import creator.loki.command.CommandCategories;
import creator.loki.command.CommandContext; import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedTemplates; 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.ImageSession;
import creator.loki.feature.image.ImageSessionManager; import creator.loki.feature.image.ImageSessionManager;
import java.awt.Color;
import java.util.List;
public class E621RandomCommand extends AbstractImageCommand { public class E621RandomCommand extends AbstractImageCommand {
public E621RandomCommand(ImageService imageService, ImageSessionManager sessionManager) { public E621RandomCommand(ImageService imageService, ImageSessionManager sessionManager) {
super(imageService, sessionManager); super(imageService, sessionManager);
} }
@Override public String getName() { return "e621-random"; } @Override
@Override public String getDescription() { return "Случайные изображения с e621.net"; } public String getName() {
@Override public String getUsage() { return "e621-random <кол-во до 50>"; } return "e621-random";
@Override public String getCategory() { return CommandCategories.NSFW_IMAGES; } }
@Override protected String siteName() { return "e621.net"; } @Override
@Override protected String siteDisplayName() { return "e621 (случайные)"; } public String getDescription() {
@Override protected Color siteColor() { return new Color(102, 45, 145); } 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 @Override
protected List<String> fetchUrls(String tags, int limit) { protected List<String> fetchUrls(String tags, int limit) {
@ -35,9 +61,11 @@ public class E621RandomCommand extends AbstractImageCommand {
@Override @Override
public void execute(CommandContext context) { public void execute(CommandContext context) {
if (!isNsfwChannel(context)) { if (!isNsfwChannel(context)) {
context.getChannel() context
.sendMessageEmbeds(EmbedTemplates.error( .getChannel()
"🔞 NSFW", "Эта команда доступна только в каналах с возрастным ограничением.")) .sendMessageEmbeds(
EmbedTemplates.error(
"🔞 NSFW", "Эта команда доступна только в каналах с возрастным ограничением."))
.queue(); .queue();
return; return;
} }
@ -48,31 +76,40 @@ public class E621RandomCommand extends AbstractImageCommand {
try { try {
limit = Integer.parseInt(args.get(0)); limit = Integer.parseInt(args.get(0));
limit = Math.max(1, Math.min(limit, MAX_FETCH)); limit = Math.max(1, Math.min(limit, MAX_FETCH));
} catch (NumberFormatException ignored) {} } catch (NumberFormatException ignored) {
}
} }
final int finalLimit = limit; final int finalLimit = limit;
context.getChannel() context
.sendMessageEmbeds(EmbedTemplates.info("🔍 Случайные", "Ищу случайные изображения на e621...")) .getChannel()
.queue(msg -> { .sendMessageEmbeds(
List<String> urls = imageService.fetchE621("", finalLimit); EmbedTemplates.info("🔍 Случайные", "Ищу случайные изображения на e621..."))
if (urls.isEmpty()) { .queue(
msg.editMessageEmbeds(EmbedTemplates.warning( msg -> {
"😔 Ничего не найдено", "Не удалось найти изображения.")) List<String> urls = imageService.fetchE621("", finalLimit);
.queue(); if (urls.isEmpty()) {
return; msg.editMessageEmbeds(
} EmbedTemplates.warning(
"😔 Ничего не найдено", "Не удалось найти изображения."))
.queue();
return;
}
ImageSession session = ImageSession.create( ImageSession session =
urls, PAGE_SIZE, siteName(), siteColor(), ImageSession.create(
"", urls,
context.getAuthor().getIdLong(), PAGE_SIZE,
context.getGuild().getIdLong()); siteName(),
sessionManager.put(msg.getId(), session); siteColor(),
"",
context.getAuthor().getIdLong(),
context.getGuild().getIdLong());
sessionManager.put(msg.getId(), session);
msg.editMessageEmbeds(ImageEmbedHelper.buildEmbeds(session)) msg.editMessageEmbeds(ImageEmbedHelper.buildEmbeds(session))
.setComponents(ImageEmbedHelper.buildActionRow(session)) .setComponents(ImageEmbedHelper.buildActionRow(session))
.queue(); .queue();
}); });
} }
} }

View file

@ -1,26 +1,52 @@
package creator.loki.feature.image.commands; package creator.loki.feature.image.commands;
import java.awt.Color;
import java.util.List;
import creator.loki.command.CommandCategories; import creator.loki.command.CommandCategories;
import creator.loki.feature.image.ImageService; import creator.loki.feature.image.ImageService;
import creator.loki.feature.image.ImageSessionManager; import creator.loki.feature.image.ImageSessionManager;
import java.awt.Color;
import java.util.List;
public class GelbooruCommand extends AbstractImageCommand { public class GelbooruCommand extends AbstractImageCommand {
public GelbooruCommand(ImageService imageService, ImageSessionManager sessionManager) { public GelbooruCommand(ImageService imageService, ImageSessionManager sessionManager) {
super(imageService, sessionManager); super(imageService, sessionManager);
} }
@Override public String getName() { return "gelbooru"; } @Override
@Override public String getDescription() { return "Поиск изображений на Gelbooru"; } public String getName() {
@Override public String getUsage() { return "gelbooru <кол-во до 50> <теги>"; } return "gelbooru";
@Override public String getCategory() { return CommandCategories.NSFW_IMAGES; } }
@Override protected String siteName() { return "gelbooru.com"; } @Override
@Override protected String siteDisplayName() { return "Gelbooru"; } public String getDescription() {
@Override protected Color siteColor() { return new Color(50, 100, 180); } 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 @Override
protected List<String> fetchUrls(String tags, int limit) { protected List<String> fetchUrls(String tags, int limit) {

View file

@ -1,26 +1,52 @@
package creator.loki.feature.image.commands; package creator.loki.feature.image.commands;
import java.awt.Color;
import java.util.List;
import creator.loki.command.CommandCategories; import creator.loki.command.CommandCategories;
import creator.loki.feature.image.ImageService; import creator.loki.feature.image.ImageService;
import creator.loki.feature.image.ImageSessionManager; import creator.loki.feature.image.ImageSessionManager;
import java.awt.Color;
import java.util.List;
public class R34Command extends AbstractImageCommand { public class R34Command extends AbstractImageCommand {
public R34Command(ImageService imageService, ImageSessionManager sessionManager) { public R34Command(ImageService imageService, ImageSessionManager sessionManager) {
super(imageService, sessionManager); super(imageService, sessionManager);
} }
@Override public String getName() { return "r34"; } @Override
@Override public String getDescription() { return "Поиск изображений на Rule34"; } public String getName() {
@Override public String getUsage() { return "r34 <кол-во до 50> <теги>"; } return "r34";
@Override public String getCategory() { return CommandCategories.NSFW_IMAGES; } }
@Override protected String siteName() { return "rule34.xxx"; } @Override
@Override protected String siteDisplayName() { return "Rule34"; } public String getDescription() {
@Override protected Color siteColor() { return new Color(200, 100, 0); } 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 @Override
protected List<String> fetchUrls(String tags, int limit) { protected List<String> fetchUrls(String tags, int limit) {

View file

@ -1,26 +1,52 @@
package creator.loki.feature.image.commands; package creator.loki.feature.image.commands;
import java.awt.Color;
import java.util.List;
import creator.loki.command.CommandCategories; import creator.loki.command.CommandCategories;
import creator.loki.feature.image.ImageService; import creator.loki.feature.image.ImageService;
import creator.loki.feature.image.ImageSessionManager; import creator.loki.feature.image.ImageSessionManager;
import java.awt.Color;
import java.util.List;
public class YandereCommand extends AbstractImageCommand { public class YandereCommand extends AbstractImageCommand {
public YandereCommand(ImageService imageService, ImageSessionManager sessionManager) { public YandereCommand(ImageService imageService, ImageSessionManager sessionManager) {
super(imageService, sessionManager); super(imageService, sessionManager);
} }
@Override public String getName() { return "yandere"; } @Override
@Override public String getDescription() { return "Поиск изображений на yande.re"; } public String getName() {
@Override public String getUsage() { return "yandere <кол-во до 50> <теги>"; } return "yandere";
@Override public String getCategory() { return CommandCategories.NSFW_IMAGES; } }
@Override protected String siteName() { return "yande.re"; } @Override
@Override protected String siteDisplayName() { return "yande.re"; } public String getDescription() {
@Override protected Color siteColor() { return new Color(220, 50, 120); } 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 @Override
protected List<String> fetchUrls(String tags, int limit) { protected List<String> fetchUrls(String tags, int limit) {

View file

@ -3,9 +3,12 @@ package creator.loki.feature.level;
import com.google.inject.AbstractModule; import com.google.inject.AbstractModule;
import com.google.inject.Provides; import com.google.inject.Provides;
import com.google.inject.Singleton; import com.google.inject.Singleton;
import com.google.inject.multibindings.Multibinder;
import creator.loki.core.registry.CommandRegistrar;
import creator.loki.database.Database; import creator.loki.database.Database;
import creator.loki.feature.config.repository.ConfigRepository; import creator.loki.feature.config.repository.ConfigRepository;
import creator.loki.feature.config.repository.GuildConfigRepository; 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.commands.ResetLevelsCommand;
import creator.loki.feature.level.domain.XpFormula; import creator.loki.feature.level.domain.XpFormula;
import creator.loki.feature.level.listener.ResetLevelsButtonHandler; import creator.loki.feature.level.listener.ResetLevelsButtonHandler;
@ -24,6 +27,10 @@ public class LevelModule extends AbstractModule {
protected void configure() { protected void configure() {
bind(VoiceTracker.class).in(Singleton.class); bind(VoiceTracker.class).in(Singleton.class);
bind(ResetLevelsButtonHandler.class).in(Singleton.class); bind(ResetLevelsButtonHandler.class).in(Singleton.class);
Multibinder.newSetBinder(binder(), CommandRegistrar.class)
.addBinding()
.to(LevelCommandRegistrar.class);
} }
@Provides @Provides

View file

@ -8,6 +8,7 @@ import java.util.concurrent.TimeUnit;
import com.google.inject.Inject; import com.google.inject.Inject;
import com.google.inject.Singleton; import com.google.inject.Singleton;
import creator.loki.core.Lifecycle;
import creator.loki.feature.economy.service.EconomyService; import creator.loki.feature.economy.service.EconomyService;
import creator.loki.feature.level.domain.LevelUpResult; import creator.loki.feature.level.domain.LevelUpResult;
import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Member;
@ -17,7 +18,7 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@Singleton @Singleton
public class VoiceTracker extends ListenerAdapter { public class VoiceTracker extends ListenerAdapter implements Lifecycle {
private static final Logger logger = LoggerFactory.getLogger(VoiceTracker.class); private static final Logger logger = LoggerFactory.getLogger(VoiceTracker.class);
private final LevelService levelService; private final LevelService levelService;
private final EconomyService economyService; private final EconomyService economyService;

View file

@ -11,6 +11,7 @@ import com.google.inject.Singleton;
import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer; import com.sun.net.httpserver.HttpServer;
import creator.loki.config.BotConfig; import creator.loki.config.BotConfig;
import creator.loki.core.Lifecycle;
import creator.loki.util.LoggerDecorator; import creator.loki.util.LoggerDecorator;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -20,7 +21,7 @@ import org.slf4j.LoggerFactory;
* BotConfig#getHealthHttpPort()} (0 = выкл). * BotConfig#getHealthHttpPort()} (0 = выкл).
*/ */
@Singleton @Singleton
public class HealthHttpServer { public class HealthHttpServer implements Lifecycle {
private static final Logger logger = LoggerFactory.getLogger(HealthHttpServer.class); private static final Logger logger = LoggerFactory.getLogger(HealthHttpServer.class);
private final BotConfig config; private final BotConfig config;
@ -106,7 +107,8 @@ public class HealthHttpServer {
} }
} }
public synchronized void stop() { @Override
public synchronized void shutdown() {
if (server != null) { if (server != null) {
server.stop(1); server.stop(1);
server = null; server = null;

View file

@ -3,6 +3,8 @@ package creator.loki.feature.moderation;
import com.google.inject.AbstractModule; import com.google.inject.AbstractModule;
import com.google.inject.Provides; import com.google.inject.Provides;
import com.google.inject.Singleton; import com.google.inject.Singleton;
import com.google.inject.multibindings.Multibinder;
import creator.loki.core.registry.CommandRegistrar;
import creator.loki.database.Database; import creator.loki.database.Database;
import creator.loki.feature.config.repository.GuildConfigRepository; import creator.loki.feature.config.repository.GuildConfigRepository;
import creator.loki.feature.moderation.repository.ActiveMuteRepository; import creator.loki.feature.moderation.repository.ActiveMuteRepository;
@ -15,7 +17,11 @@ import creator.loki.feature.moderation.repository.WarningRepository;
public class ModerationModule extends AbstractModule { public class ModerationModule extends AbstractModule {
@Override @Override
protected void configure() {} protected void configure() {
Multibinder.newSetBinder(binder(), CommandRegistrar.class)
.addBinding()
.to(ModerationCommandRegistrar.class);
}
@Provides @Provides
@Singleton @Singleton

View file

@ -7,6 +7,7 @@ import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
import creator.loki.core.Lifecycle;
import creator.loki.feature.config.repository.GuildConfigRepository; import creator.loki.feature.config.repository.GuildConfigRepository;
import creator.loki.feature.moderation.repository.ActiveMuteRepository; import creator.loki.feature.moderation.repository.ActiveMuteRepository;
import creator.loki.feature.moderation.repository.ModerationCaseRepository; 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.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
public class MuteScheduler { public class MuteScheduler implements Lifecycle {
private static final Logger logger = LoggerFactory.getLogger(MuteScheduler.class); private static final Logger logger = LoggerFactory.getLogger(MuteScheduler.class);

View file

@ -15,9 +15,13 @@ import creator.loki.feature.moderation.util.ModerationHelper;
import creator.loki.util.MuteDurationParser; import creator.loki.util.MuteDurationParser;
import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.Permission; import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Role; import net.dv8tion.jda.api.entities.Role;
public class MuteCommand implements Command { 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 ModerationCaseRepository cases;
private final ActiveMuteRepository activeMutes; private final ActiveMuteRepository activeMutes;
private final ConfigRepository configRepo; private final ConfigRepository configRepo;
@ -73,19 +77,49 @@ public class MuteCommand implements Command {
err(ctx, "Укажи пользователя"); err(ctx, "Укажи пользователя");
return; return;
} }
var t = mentioned.get(0); var target = mentioned.get(0);
if (t.getId().equals(mod.getId())) { 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, "Нельзя замутить себя"); err(ctx, "Нельзя замутить себя");
return; return false;
} }
if (!mod.canInteract(t)) { if (!mod.canInteract(target)) {
err(ctx, "Роль выше"); err(ctx, "Роль выше");
return; return false;
} }
if (!g.getMember(g.getJDA().getSelfUser()).canInteract(t)) { if (!g.getMember(g.getJDA().getSelfUser()).canInteract(target)) {
err(ctx, "Бот не может"); err(ctx, "Бот не может");
return; return false;
} }
return true;
}
private record ParsedDuration(MuteDurationParser.MuteDuration dur, String reason) {}
private ParsedDuration parseDurationAndReason(CommandContext ctx) {
MuteDurationParser.MuteDuration dur = null; MuteDurationParser.MuteDuration dur = null;
String durToken = null; String durToken = null;
for (String a : ctx.getArgs()) { for (String a : ctx.getArgs()) {
@ -99,7 +133,7 @@ public class MuteCommand implements Command {
} }
if (dur == null) { if (dur == null) {
err(ctx, "Укажи длительность: `30s`, `5m`, `2h`, `1d` или `perm`"); err(ctx, "Укажи длительность: `30s`, `5m`, `2h`, `1d` или `perm`");
return; return null;
} }
boolean past = false; boolean past = false;
var sb = new StringBuilder(); var sb = new StringBuilder();
@ -112,85 +146,113 @@ public class MuteCommand implements Command {
if (!sb.isEmpty()) sb.append(' '); if (!sb.isEmpty()) sb.append(' ');
sb.append(a); sb.append(a);
} }
String reasonTmp = sb.toString().trim(); String tmp = sb.toString().trim();
String reason = reasonTmp.isEmpty() ? "Без причины" : reasonTmp; String reason = tmp.isEmpty() ? "Без причины" : tmp;
Role muteRole; return new ParsedDuration(dur, reason);
try { }
muteRole = muteRoleService.getOrCreateMuteRole(g);
} catch (Exception e) { private long createMuteCase(
err(ctx, "Ошибка роли"); net.dv8tion.jda.api.entities.Guild g,
return; Member target,
} Member mod,
if (!g.getMember(g.getJDA().getSelfUser()).canInteract(muteRole)) { MuteDurationParser.MuteDuration dur,
err(ctx, "Подними роль бота выше роли **Мут**"); String reason) {
return;
}
boolean permanent = dur.permanent(); boolean permanent = dur.permanent();
long secs = dur.secondsTotal(); long secs = dur.secondsTotal();
long untilMs = permanent ? 0L : System.currentTimeMillis() + secs * 1000; long untilMs = permanent ? 0L : System.currentTimeMillis() + secs * 1000;
Long durSec = permanent ? null : secs; Long durSec = permanent ? null : secs;
long caseId = long caseId =
cases.insert( cases.insert(
g.getId(), "MUTE", t.getId(), mod.getId(), reason, durSec, permanent ? null : untilMs); g.getId(),
if (caseId < 0) { "MUTE",
err(ctx, "Ошибка записи кейса"); target.getId(),
return; 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); return caseId;
muteScheduler.cancelPending(g.getId(), t.getId()); }
g.addRoleToMember(t, muteRole)
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( .queue(
ok -> { ok -> {
muteScheduler.scheduleExpiry(g.getId(), t.getId(), permanent ? 0L : untilMs); muteScheduler.scheduleExpiry(g.getId(), target.getId(), permanent ? 0L : untilMs);
ctx.getChannel() ctx.getChannel()
.sendMessageEmbeds( .sendMessageEmbeds(buildMuteConfirmEmbed(target, caseId, dur, reason, untilMs))
EmbedFactory.create()
.withTitle("✅ Мут выдан")
.withColor(Color.GREEN)
.withDescription(
"**Кому:** "
+ t.getAsMention()
+ "\n**Кейс:** `"
+ caseId
+ "`"
+ (permanent
? "\n**Срок:** навсегда"
: "\n**Срок:** <t:" + (untilMs / 1000) + ":R>")
+ "\n**Причина:** "
+ sub(reason, 500))
.build())
.queue(); .queue();
var logId = configRepo.get("moderation.log_channel_id"); sendMuteLogEmbed(g, target, mod, caseId, dur, reason, untilMs);
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 ? "Навсегда" : "<t:" + (untilMs / 1000) + ":F>",
true)
.addField("Причина", sub(reason, 1000), false)
.setTimestamp(OffsetDateTime.now())
.build())
.queue();
}
}
}, },
err2 -> { err2 -> {
activeMutes.delete(g.getId(), t.getId()); activeMutes.delete(g.getId(), target.getId());
err(ctx, "Ошибка выдачи роли"); err(ctx, "Ошибка выдачи роли");
}); });
} }
private static String sub(String s, int max) { private net.dv8tion.jda.api.entities.MessageEmbed buildMuteConfirmEmbed(
return ModerationHelper.trunc(s, max); 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**Срок:** <t:" + (untilMs / 1000) + ":R>")
+ "\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 ? "Навсегда" : "<t:" + (untilMs / 1000) + ":F>", true)
.addField("Причина", ModerationHelper.trunc(reason, MAX_LOG_REASON_LENGTH), false)
.setTimestamp(OffsetDateTime.now())
.build())
.queue();
} }
private static void err(CommandContext ctx, String msg) { private static void err(CommandContext ctx, String msg) {

View file

@ -2,6 +2,8 @@ package creator.loki.feature.music;
import com.google.inject.AbstractModule; import com.google.inject.AbstractModule;
import com.google.inject.Singleton; 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.LavalinkManager;
import creator.loki.feature.music.core.PlayerManager; import creator.loki.feature.music.core.PlayerManager;
@ -10,5 +12,9 @@ public class MusicModule extends AbstractModule {
protected void configure() { protected void configure() {
bind(LavalinkManager.class).in(Singleton.class); bind(LavalinkManager.class).in(Singleton.class);
bind(PlayerManager.class).in(Singleton.class); bind(PlayerManager.class).in(Singleton.class);
Multibinder.newSetBinder(binder(), CommandRegistrar.class)
.addBinding()
.to(MusicCommandRegistrar.class);
} }
} }

View file

@ -60,7 +60,8 @@ public class MusicSlashCommands extends ListenerAdapter {
.addOption(OptionType.USER, "user", "Цель", false), .addOption(OptionType.USER, "user", "Цель", false),
Commands.slash("clear", "Удалить сообщения") Commands.slash("clear", "Удалить сообщения")
.addOption(OptionType.INTEGER, "amount", "Количество (1-100)", true) .addOption(OptionType.INTEGER, "amount", "Количество (1-100)", true)
.addOption(OptionType.USER, "user", "Оставить только сообщения пользователя", false)) .addOption(
OptionType.USER, "user", "Оставить только сообщения пользователя", false))
.queue(); .queue();
} }

View file

@ -5,11 +5,9 @@ import java.util.Collections;
import java.util.LinkedList; import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Queue; import java.util.Queue;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer; import java.util.function.Consumer;
import creator.loki.util.LoggerDecorator; import creator.loki.util.LoggerDecorator;
import dev.arbjerg.lavalink.client.Link;
import dev.arbjerg.lavalink.client.player.LavalinkPlayer; import dev.arbjerg.lavalink.client.player.LavalinkPlayer;
import dev.arbjerg.lavalink.client.player.Track; import dev.arbjerg.lavalink.client.player.Track;
import dev.arbjerg.lavalink.protocol.v4.Message.EmittedEvent.TrackEndEvent.AudioTrackEndReason; 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 Logger log = LoggerFactory.getLogger(TrackScheduler.class);
private static final Consumer<Throwable> LOG_LAV = t -> log.warn("Lav: {}", t.getMessage()); private static final Consumer<Throwable> LOG_LAV = t -> log.warn("Lav: {}", t.getMessage());
private final GuildMusicManager manager; private final GuildMusicManager manager;
private final TrackStarter trackStarter;
final Queue<Track> queue = new LinkedList<>(); final Queue<Track> queue = new LinkedList<>();
private boolean repeating; private boolean repeating;
public final FilterState filterState = new FilterState(); public final FilterState filterState = new FilterState();
public TrackScheduler(GuildMusicManager manager) { public TrackScheduler(GuildMusicManager manager) {
this.manager = manager; this.manager = manager;
this.trackStarter = new TrackStarter(manager.getExecutor());
} }
public void enqueue(Track track) { public void enqueue(Track track) {
@ -103,13 +103,11 @@ public class TrackScheduler {
public void onTrackEnd(Track lastTrack, AudioTrackEndReason endReason) { public void onTrackEnd(Track lastTrack, AudioTrackEndReason endReason) {
if (endReason == AudioTrackEndReason.LOAD_FAILED) { if (endReason == AudioTrackEndReason.LOAD_FAILED) {
LoggerDecorator.warn(log, "Load failed, skipping to next"); handleLoadFailed();
nextOrAutoLeave();
} else if (!endReason.getMayStartNext() && endReason != AudioTrackEndReason.FINISHED) { } else if (!endReason.getMayStartNext() && endReason != AudioTrackEndReason.FINISHED) {
LoggerDecorator.warn(log, "Track ended unexpectedly: " + endReason); handleUnexpectedEnd(endReason);
manager.startAutoLeaveTimer();
} else if (repeating && endReason == AudioTrackEndReason.FINISHED) { } else if (repeating && endReason == AudioTrackEndReason.FINISHED) {
startTrack(lastTrack.makeClone()); handleFinished(lastTrack);
} else { } else {
nextOrAutoLeave(); nextOrAutoLeave();
} }
@ -118,7 +116,21 @@ public class TrackScheduler {
void startTrack(Track track) { void startTrack(Track track) {
manager.cancelAutoLeaveTimer(); manager.cancelAutoLeaveTimer();
var link = manager.getLavalinkManager().getOrCreateLink(manager.getGuildId()); 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() { private void nextOrAutoLeave() {
@ -126,21 +138,4 @@ public class TrackScheduler {
if (next != null) startTrack(next); if (next != null) startTrack(next);
else manager.startAutoLeaveTimer(); 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));
}
} }

View file

@ -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));
}
}

View file

@ -11,12 +11,7 @@ import creator.loki.feature.music.core.PlayerManager;
import creator.loki.feature.music.core.SearchManager; import creator.loki.feature.music.core.SearchManager;
import creator.loki.feature.music.manage.GuildMusicManager; import creator.loki.feature.music.manage.GuildMusicManager;
import creator.loki.util.VoiceHelper; 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.Track;
import dev.arbjerg.lavalink.client.player.TrackLoaded;
import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Member;
@ -56,10 +51,7 @@ public class PlayCommand implements Command {
if (guild == null) return; if (guild == null) return;
Member member = ctx.getAuthor(); Member member = ctx.getAuthor();
if (member == null || !member.getVoiceState().inAudioChannel()) { if (member == null || !member.getVoiceState().inAudioChannel()) {
ctx.getChannel() sendErr(ctx, "Зайди в голосовой канал сначала.");
.sendMessageEmbeds(
EmbedTemplates.error("Голосовой канал", "Зайди в голосовой канал сначала."))
.queue();
return; return;
} }
var args = ctx.getArgs(); var args = ctx.getArgs();
@ -69,27 +61,43 @@ public class PlayCommand implements Command {
} }
String first = args.get(0); String first = args.get(0);
if (SearchHandler.isNumeric(first)) { if (SearchHandler.isNumeric(first)) {
Track t = searchHandler.pickResult(guild.getIdLong(), Integer.parseInt(first) - 1); handlePickResult(ctx, first);
if (t == null) {
sendErr(ctx, "Поиск устарел. Сделай новый: `!play <запрос>`");
return;
}
playTrack(ctx, t);
return; return;
} }
if (!resolveVoiceConnection(ctx)) return;
var lm = playerManager.getLavalinkManager(); 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()); 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<String> args,
String first,
LavalinkManager lm,
GuildMusicManager mm) {
boolean isSource = SOURCES.contains(first); boolean isSource = SOURCES.contains(first);
String raw = String.join(" ", isSource ? args.subList(1, args.size()) : args); String raw = String.join(" ", isSource ? args.subList(1, args.size()) : args);
if (raw.isEmpty()) { if (raw.isEmpty()) {
@ -97,9 +105,9 @@ public class PlayCommand implements Command {
return; return;
} }
boolean isUrl = raw.startsWith("http://") || raw.startsWith("https://"); 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); 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( private void loadAndHandle(
@ -111,64 +119,9 @@ public class PlayCommand implements Command {
boolean isUrl, boolean isUrl,
String query, String query,
String raw) { String raw) {
String noMatch =
isUrl ? "Ничего не найдено по ссылке." : "Ничего не найдено по запросу: " + raw;
lm.getOrCreateLink(guildId) lm.getOrCreateLink(guildId)
.loadItem(id) .loadItem(id)
.subscribe( .subscribe(new PlayLoadHandler(ctx, searchHandler, mm, isUrl, query, raw));
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()
: "Неизвестная ошибка");
}
});
} }
private static String buildQuery(String first, String raw) { private static String buildQuery(String first, String raw) {
@ -181,13 +134,7 @@ public class PlayCommand implements Command {
return raw; return raw;
} }
private void showResults(CommandContext ctx, String q, GuildMusicManager m, List<Track> list) { private void enqueueAndNotify(CommandContext ctx, Track track) {
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) {
var m = playerManager.getLavalinkManager().getMusicManager(ctx.getGuild().getIdLong()); var m = playerManager.getLavalinkManager().getMusicManager(ctx.getGuild().getIdLong());
m.getScheduler().enqueue(track); m.getScheduler().enqueue(track);
ctx.getChannel() ctx.getChannel()

View file

@ -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<Track> 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();
}
}

View file

@ -2,10 +2,16 @@ package creator.loki.feature.radio;
import com.google.inject.AbstractModule; import com.google.inject.AbstractModule;
import com.google.inject.Singleton; import com.google.inject.Singleton;
import com.google.inject.multibindings.Multibinder;
import creator.loki.core.registry.CommandRegistrar;
public class RadioModule extends AbstractModule { public class RadioModule extends AbstractModule {
@Override @Override
protected void configure() { protected void configure() {
bind(RadioManager.class).in(Singleton.class); bind(RadioManager.class).in(Singleton.class);
Multibinder.newSetBinder(binder(), CommandRegistrar.class)
.addBinding()
.to(RadioCommandRegistrar.class);
} }
} }

View file

@ -53,7 +53,7 @@ public class IqCommand implements Command {
iq = ratingService.getDeterministicValue(target.getId(), 123, 0, 140); iq = ratingService.getDeterministicValue(target.getId(), 123, 0, 140);
} }
RatingLevel level = RatingLevel.findLevel(iq, RatingConfig.getIqLevels()); RatingLevel level = RatingConfig.findLevel(iq, "iq");
context context
.getChannel() .getChannel()
.sendMessageEmbeds( .sendMessageEmbeds(

View file

@ -58,7 +58,7 @@ public class MustacheCommand implements Command {
for (int i = 0; i < filled; i++) bar.append("🧔"); for (int i = 0; i < filled; i++) bar.append("🧔");
for (int i = filled; i < 10; 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 context
.getChannel() .getChannel()
.sendMessageEmbeds( .sendMessageEmbeds(

View file

@ -59,7 +59,7 @@ public class PpCommand implements Command {
for (int i = 0; i < size; i++) ppVisual.append("="); for (int i = 0; i < size; i++) ppVisual.append("=");
ppVisual.append("D"); ppVisual.append("D");
RatingLevel level = RatingLevel.findLevel(size, RatingConfig.getPpLevels()); RatingLevel level = RatingConfig.findLevel(size, "pp");
context context
.getChannel() .getChannel()
.sendMessageEmbeds( .sendMessageEmbeds(

View file

@ -104,43 +104,59 @@ public class RatingConfig {
LEVELS = Collections.unmodifiableMap(m); LEVELS = Collections.unmodifiableMap(m);
} }
/** Data-driven accessor — единая точка доступа для всех рейтингов. */
public static List<RatingLevel> 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<RatingLevel> getMustacheLevels() { public static List<RatingLevel> getMustacheLevels() {
return LEVELS.get("mustache"); return getLevels("mustache");
} }
public static List<RatingLevel> getIqLevels() { public static List<RatingLevel> getIqLevels() {
return LEVELS.get("iq"); return getLevels("iq");
} }
public static List<RatingLevel> getFatLevels() { public static List<RatingLevel> getFatLevels() {
return LEVELS.get("fat"); return getLevels("fat");
} }
public static List<RatingLevel> getPpLevels() { public static List<RatingLevel> getPpLevels() {
return LEVELS.get("pp"); return getLevels("pp");
} }
public static List<RatingLevel> getBoykisserLevels() { public static List<RatingLevel> getBoykisserLevels() {
return LEVELS.get("boykisser"); return getLevels("boykisser");
} }
public static List<RatingLevel> getRizzLevels() { public static List<RatingLevel> getRizzLevels() {
return LEVELS.get("rizz"); return getLevels("rizz");
} }
public static List<RatingLevel> getLuckLevels() { public static List<RatingLevel> getLuckLevels() {
return LEVELS.get("luck"); return getLevels("luck");
} }
public static List<RatingLevel> getSimpLevels() { public static List<RatingLevel> getSimpLevels() {
return LEVELS.get("simp"); return getLevels("simp");
} }
public static List<RatingLevel> getVibeLevels() { public static List<RatingLevel> getVibeLevels() {
return LEVELS.get("vibe"); return getLevels("vibe");
} }
public static List<RatingLevel> getGlazeLevels() { public static List<RatingLevel> getGlazeLevels() {
return LEVELS.get("glaze"); return getLevels("glaze");
} }
} }

View file

@ -27,8 +27,7 @@ public class AiSessionManager {
public void addMessage( public void addMessage(
String channelId, String authorName, String authorId, String content, long timestamp) { String channelId, String authorName, String authorId, String content, long timestamp) {
AiSession session = AiSession session = sessions.computeIfAbsent(channelId, k -> new AiSession());
sessions.computeIfAbsent(channelId, k -> new AiSession());
session.addMessage(authorName, authorId, content, timestamp); session.addMessage(authorName, authorId, content, timestamp);
} }
@ -75,19 +74,13 @@ public class AiSessionManager {
for (int i = start; i < messages.size(); i++) { for (int i = start; i < messages.size(); i++) {
SessionMessage m = messages.get(i); SessionMessage m = messages.get(i);
String label = (i == messages.size() - 1) ? "[now]" : "[history]"; String label = (i == messages.size() - 1) ? "[now]" : "[history]";
String formatted = String formatted = label + " " + m.authorName + "(" + m.authorId + "): " + m.content;
label
+ " "
+ m.authorName
+ "("
+ m.authorId
+ "): "
+ m.content;
result.add(new CachedMessage(formatted, "", m.timestamp)); result.add(new CachedMessage(formatted, "", m.timestamp));
} }
return result; return result;
} }
} }
private record SessionMessage(String authorName, String authorId, String content, long timestamp) {} private record SessionMessage(
String authorName, String authorId, String content, long timestamp) {}
} }

View file

@ -28,11 +28,13 @@ public class AndrertListener extends ListenerAdapter {
private final String allowedChannelId; private final String allowedChannelId;
private final ConcurrentHashMap<String, Long> trackedUsers = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, Long> trackedUsers = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, Long> lastAiReplyTime = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, Long> lastAiReplyTime = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, ScheduledFuture<?>> pendingPings = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, ScheduledFuture<?>> pendingPings =
new ConcurrentHashMap<>();
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
@Inject @Inject
public AndrertListener(RoastService roastService, AiSessionManager sessionManager, BotConfig config) { public AndrertListener(
RoastService roastService, AiSessionManager sessionManager, BotConfig config) {
this.roastService = roastService; this.roastService = roastService;
this.sessionManager = sessionManager; this.sessionManager = sessionManager;
this.allowedChannelId = config.getAiChannelId(); this.allowedChannelId = config.getAiChannelId();
@ -102,7 +104,8 @@ public class AndrertListener extends ListenerAdapter {
System.currentTimeMillis()); System.currentTimeMillis());
String targetName = event.getAuthor().getEffectiveName(); String targetName = event.getAuthor().getEffectiveName();
List<CachedMessage> context = sessionManager.getContext(event.getChannel().getId(), CHANNEL_CONTEXT); List<CachedMessage> context =
sessionManager.getContext(event.getChannel().getId(), CHANNEL_CONTEXT);
String response = roastService.generateResponse(context, targetName, userId); String response = roastService.generateResponse(context, targetName, userId);
if (response == null || response.isBlank()) return; if (response == null || response.isBlank()) return;
@ -145,12 +148,13 @@ public class AndrertListener extends ListenerAdapter {
String[] lines = { String[] lines = {
"эй " + targetMention + ", ты чё замолчал? я не закончил", "эй " + targetMention + ", ты чё замолчал? я не закончил",
"куда пропал " + targetMention + "? аргументы закончились?", "куда пропал " + targetMention + "? аргументы закончились?",
"ну " + targetMention + ", я жду. или скажешь что тебе нечего ответить?", "ну "
+ targetMention
+ ", я жду. или скажешь что тебе нечего ответить?",
"тишина в эфире " + targetMention + ". всё, признал поражение?", "тишина в эфире " + targetMention + ". всё, признал поражение?",
targetMention + " слился. ожидаемо", targetMention + " слился. ожидаемо",
}; };
String msgText = String msgText = lines[(int) (System.currentTimeMillis() % lines.length)];
lines[(int) (System.currentTimeMillis() % lines.length)];
channel channel
.getHistory() .getHistory()
@ -163,11 +167,12 @@ public class AndrertListener extends ListenerAdapter {
if (m.getAuthor().isBot()) continue; if (m.getAuthor().isBot()) continue;
String a = m.getAuthor().getEffectiveName(); String a = m.getAuthor().getEffectiveName();
String c = String c =
m.getContentRaw() m.getContentRaw() + replyInfo(m) + attachmentInfo(m);
+ replyInfo(m)
+ attachmentInfo(m);
sessionManager.addMessage( sessionManager.addMessage(
channelId, a, m.getAuthor().getId(), c, channelId,
a,
m.getAuthor().getId(),
c,
m.getTimeCreated().toEpochSecond() * 1000); m.getTimeCreated().toEpochSecond() * 1000);
} }

View file

@ -31,8 +31,10 @@ public class DebateChannelListener extends ListenerAdapter {
if (prev != null) return; if (prev != null) return;
seenMessages.entrySet().removeIf(e -> now - e.getValue() > 5000); seenMessages.entrySet().removeIf(e -> now - e.getValue() > 5000);
logger.info("debate msg ch={} a={} text={}", logger.info(
event.getChannel().getId(), event.getAuthor().getId(), "debate msg ch={} a={} text={}",
event.getChannel().getId(),
event.getAuthor().getId(),
event.getMessage().getContentRaw().replace("\n", " ")); event.getMessage().getContentRaw().replace("\n", " "));
debateService.onHumanMessage(event); debateService.onHumanMessage(event);
} }

View file

@ -35,7 +35,8 @@ public class DebateService {
Files.isDirectory(Paths.get("/app/prompts")) Files.isDirectory(Paths.get("/app/prompts"))
? Paths.get("/app/prompts") ? Paths.get("/app/prompts")
: Paths.get("data/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 long BOT_START_TIME = System.currentTimeMillis();
private static final int CHANNEL_CONTEXT = 20; private static final int CHANNEL_CONTEXT = 20;
private static final String[] PERSONALITIES = {"arch", "windows", "gentoo"}; private static final String[] PERSONALITIES = {"arch", "windows", "gentoo"};
@ -87,7 +88,8 @@ public class DebateService {
if (debateId == null || debateId.isBlank()) return; if (debateId == null || debateId.isBlank()) return;
if (!event.getChannel().getId().equals(debateId)) return; if (!event.getChannel().getId().equals(debateId)) return;
if (event.getAuthor().isBot()) 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(); TextChannel channel = event.getChannel().asTextChannel();
ChannelState state = channels.computeIfAbsent(debateId, k -> new ChannelState()); ChannelState state = channels.computeIfAbsent(debateId, k -> new ChannelState());
@ -97,8 +99,7 @@ public class DebateService {
state.roundCount = 0; state.roundCount = 0;
state.lastMessageId = event.getMessage().getId(); state.lastMessageId = event.getMessage().getId();
state.currentTask = scheduler.schedule(() -> state.currentTask = scheduler.schedule(() -> doRound(channel, state), 0, TimeUnit.SECONDS);
doRound(channel, state), 0, TimeUnit.SECONDS);
} }
private void doRound(TextChannel channel, ChannelState state) { private void doRound(TextChannel channel, ChannelState state) {
@ -109,43 +110,57 @@ public class DebateService {
state.cancel(); state.cancel();
state.roundDone = true; state.roundDone = true;
channel.getHistory().retrievePast(CHANNEL_CONTEXT).queue(messages -> { channel
boolean hasNewHuman = messages.stream().anyMatch(m -> .getHistory()
!m.getAuthor().isBot() && m.getTimeCreated().toEpochSecond() * 1000 > state.lastHumanTime); .retrievePast(CHANNEL_CONTEXT)
if (hasNewHuman) { .queue(
state.roundDone = false; messages -> {
state.lastHumanTime = now; boolean hasNewHuman =
state.lastPersonality = null; messages.stream()
state.currentTask = scheduler.schedule(() -> .anyMatch(
doRound(channel, state), 0, TimeUnit.SECONDS); m ->
return; !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<CachedMessage> ctx = buildContext(messages); List<CachedMessage> ctx = buildContext(messages);
if (state.roundCount >= MAX_ROUNDS) return; if (state.roundCount >= MAX_ROUNDS) return;
state.roundCount++; state.roundCount++;
String personality = pickPersonality(state.lastPersonality); String personality = pickPersonality(state.lastPersonality);
if (personality == null) return; if (personality == null) return;
state.lastPersonality = personality; state.lastPersonality = personality;
String response = generateResponse(personality, ctx); String response = generateResponse(personality, ctx);
if (response == null || response.isBlank()) return; if (response == null || response.isBlank()) return;
JDA jda = jdaRegistry.getByPersonality(personality); JDA jda = jdaRegistry.getByPersonality(personality);
TextChannel target = jda.getTextChannelById(debateId); TextChannel target = jda.getTextChannelById(debateId);
if (target == null) return; if (target == null) return;
String replyToId = state.lastMessageId; String replyToId = state.lastMessageId;
target.sendMessage(response) target
.setMessageReference(replyToId) .sendMessage(response)
.queue(m -> state.lastMessageId = m.getId()); .setMessageReference(replyToId)
.queue(m -> state.lastMessageId = m.getId());
state.currentTask = scheduler.schedule(() -> { state.currentTask =
state.roundDone = false; scheduler.schedule(
doRound(channel, state); () -> {
}, 0, TimeUnit.SECONDS); state.roundDone = false;
}); doRound(channel, state);
},
0,
TimeUnit.SECONDS);
});
} }
private String pickPersonality(String exclude) { private String pickPersonality(String exclude) {
@ -185,8 +200,16 @@ public class DebateService {
long uptimeSec = (System.currentTimeMillis() - BOT_START_TIME) / 1000; long uptimeSec = (System.currentTimeMillis() - BOT_START_TIME) / 1000;
String now = LocalDateTime.now().format(DT_FMT); String now = LocalDateTime.now().format(DT_FMT);
prompt += "\nтекущее время " + now + " uptime бота " + (uptimeSec / 3600) + "ч " prompt +=
+ ((uptimeSec % 3600) / 60) + "м " + (uptimeSec % 60) + "с"; "\nтекущее время "
+ now
+ " uptime бота "
+ (uptimeSec / 3600)
+ "ч "
+ ((uptimeSec % 3600) / 60)
+ "м "
+ (uptimeSec % 60)
+ "с";
StringBuilder userContent = new StringBuilder(); StringBuilder userContent = new StringBuilder();
for (CachedMessage m : ctx) { for (CachedMessage m : ctx) {
@ -196,7 +219,8 @@ public class DebateService {
if (userContent.length() > config.getAiContextChars()) break; if (userContent.length() > config.getAiContextChars()) break;
} }
String text = userContent.toString().strip(); 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); String wiki = wikipediaService.search(text);
if (wiki != null) text += "\n\n[википедия]:\n" + wiki; if (wiki != null) text += "\n\n[википедия]:\n" + wiki;
@ -226,10 +250,11 @@ public class DebateService {
name = msg.getAuthor().getEffectiveName(); name = msg.getAuthor().getEffectiveName();
} }
String label = (i == messages.size() - 1) ? "[now]" : "[history]"; String label = (i == messages.size() - 1) ? "[now]" : "[history]";
result.add(new CachedMessage( result.add(
label + " " + name + "(" + msg.getAuthor().getId() + "): " + msg.getContentRaw(), new CachedMessage(
msg.getChannel().getName(), label + " " + name + "(" + msg.getAuthor().getId() + "): " + msg.getContentRaw(),
msg.getTimeCreated().toEpochSecond() * 1000)); msg.getChannel().getName(),
msg.getTimeCreated().toEpochSecond() * 1000));
} }
return result; return result;
} }

View file

@ -54,7 +54,8 @@ public class HelperListener extends ListenerAdapter {
if (channelId == null || channelId.isBlank()) return; if (channelId == null || channelId.isBlank()) return;
if (!event.getChannel().getId().equals(channelId)) return; if (!event.getChannel().getId().equals(channelId)) return;
if (event.getAuthor().isBot()) 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(); String raw = event.getMessage().getContentRaw();
if (!raw.isBlank() && !LanguageFilter.isAllowed(raw)) return; if (!raw.isBlank() && !LanguageFilter.isAllowed(raw)) return;
@ -63,38 +64,53 @@ public class HelperListener extends ListenerAdapter {
} }
private void respond(MessageReceivedEvent event) { private void respond(MessageReceivedEvent event) {
event.getChannel().getHistory().retrievePast(CHANNEL_CONTEXT).queue(messages -> { event
String targetName = event.getAuthor().getEffectiveName(); .getChannel()
List<CachedMessage> context = new ArrayList<>(); .getHistory()
for (int idx = 0; idx < messages.size(); idx++) { .retrievePast(CHANNEL_CONTEXT)
Message msg = messages.get(idx); .queue(
boolean isSelf = msg.getAuthor().getId().equals(event.getJDA().getSelfUser().getId()); messages -> {
String author = isSelf ? "ты" : msg.getAuthor().getEffectiveName(); String targetName = event.getAuthor().getEffectiveName();
String authorId = msg.getAuthor().getId(); List<CachedMessage> 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]"; String label = (idx == 0) ? "[now]" : "[history]";
StringBuilder line = new StringBuilder(); StringBuilder line = new StringBuilder();
line.append(label).append(" ").append(author).append("(").append(authorId).append("): ") line.append(label)
.append(msg.getContentRaw()); .append(" ")
line.append(attachmentInfo(msg)); .append(author)
.append("(")
.append(authorId)
.append("): ")
.append(msg.getContentRaw());
line.append(attachmentInfo(msg));
context.add(new CachedMessage( context.add(
line.toString(), new CachedMessage(
event.getChannel().getName(), line.toString(),
msg.getTimeCreated().toEpochSecond() * 1000)); event.getChannel().getName(),
} msg.getTimeCreated().toEpochSecond() * 1000));
java.util.Collections.reverse(context); }
java.util.Collections.reverse(context);
String response = roastService.generateResponse(context, targetName, null, helperPrompt); String response =
if (response == null || response.isBlank()) return; 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) { private void sendReply(MessageReceivedEvent event, String text) {
java.util.regex.Matcher m = java.util.regex.Pattern.compile( java.util.regex.Matcher m =
"\\[file:([^\\]]+)\\](.*?)\\[/file\\]", java.util.regex.Pattern.DOTALL).matcher(text); java.util.regex.Pattern.compile(
"\\[file:([^\\]]+)\\](.*?)\\[/file\\]", java.util.regex.Pattern.DOTALL)
.matcher(text);
if (!m.find()) { if (!m.find()) {
sendText(event, text); sendText(event, text);
return; return;
@ -105,16 +121,15 @@ public class HelperListener extends ListenerAdapter {
String filename = m.group(1).strip(); String filename = m.group(1).strip();
String content = m.group(2); String content = m.group(2);
m.appendReplacement(remaining, ""); m.appendReplacement(remaining, "");
var data = net.dv8tion.jda.api.utils.FileUpload.fromData( var data =
content.getBytes(java.nio.charset.StandardCharsets.UTF_8), filename); net.dv8tion.jda.api.utils.FileUpload.fromData(
content.getBytes(java.nio.charset.StandardCharsets.UTF_8), filename);
if (first) { if (first) {
String msg = remaining.toString().strip(); String msg = remaining.toString().strip();
event.getMessage().reply(msg == null ? "" : msg) event.getMessage().reply(msg == null ? "" : msg).addFiles(data).queue();
.addFiles(data).queue();
first = false; first = false;
} else { } else {
event.getChannel().sendMessage(" ") event.getChannel().sendMessage(" ").addFiles(data).queue();
.addFiles(data).queue();
} }
} while (m.find()); } while (m.find());
m.appendTail(remaining); m.appendTail(remaining);

View file

@ -7,9 +7,7 @@ import java.nio.file.Path;
import java.nio.file.Paths; import java.nio.file.Paths;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -28,17 +26,14 @@ public class KnowledgeService {
private static final int RESULT_LIMIT = 3; private static final int RESULT_LIMIT = 3;
private static final Set<String> STOP_WORDS = private static final Set<String> STOP_WORDS =
Set.of( Set.of(
"это", "как", "так", "что", "он", "она", "они", "мы", "вы", "ты", "я", "это", "как", "так", "что", "он", "она", "они", "мы", "вы", "ты", "я", "не", "да", "нет",
"не", "да", "нет", "на", "в", "по", "для", "с", "у", "о", "от", "к", "на", "в", "по", "для", "с", "у", "о", "от", "к", "из", "за", "над", "под", "об", "же",
"из", "за", "над", "под", "об", "же", "ли", "бы", "а", "и", "но", "ли", "бы", "а", "и", "но", "или", "то", "все", "его", "ее", "их", "еще", "уже", "когда",
"или", "то", "все", "его", "ее", "их", "еще", "уже", "когда", "the", "a", "an", "is", "are", "was", "were", "be", "been", "have", "has", "had", "do",
"the", "a", "an", "is", "are", "was", "were", "be", "been", "does", "did", "will", "would", "can", "could", "may", "might", "shall", "should", "to",
"have", "has", "had", "do", "does", "did", "will", "would", "of", "in", "for", "with", "on", "at", "by", "from", "as", "into", "it", "its", "this",
"can", "could", "may", "might", "shall", "should", "to", "of", "that", "these", "those", "i", "you", "he", "she", "we", "they", "me", "him", "her", "us",
"in", "for", "with", "on", "at", "by", "from", "as", "into", "them", "and", "but", "or", "not", "no", "if", "so", "about");
"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<KnowledgeFile> files = new ArrayList<>(); private final List<KnowledgeFile> files = new ArrayList<>();
@ -105,10 +100,8 @@ public class KnowledgeService {
} }
private String[] extractKeywords(String text) { private String[] extractKeywords(String text) {
String cleaned = text.toLowerCase() String cleaned =
.replaceAll("[^a-zа-яё0-9\\s]", " ") text.toLowerCase().replaceAll("[^a-zа-яё0-9\\s]", " ").replaceAll("\\s+", " ").strip();
.replaceAll("\\s+", " ")
.strip();
String[] all = cleaned.split(" "); String[] all = cleaned.split(" ");
return Arrays.stream(all) return Arrays.stream(all)
.filter(w -> w.length() > 2) .filter(w -> w.length() > 2)

View file

@ -25,7 +25,8 @@ public class RoastService {
Files.isDirectory(Paths.get("/app/prompts")) Files.isDirectory(Paths.get("/app/prompts"))
? Paths.get("/app/prompts") ? Paths.get("/app/prompts")
: Paths.get("data/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 long BOT_START_TIME = System.currentTimeMillis();
private final BotConfig config; private final BotConfig config;
@ -67,7 +68,9 @@ public class RoastService {
long mins = (uptimeSec % 3600) / 60; long mins = (uptimeSec % 3600) / 60;
long secs = uptimeSec % 60; long secs = uptimeSec % 60;
String now = LocalDateTime.now().format(DT_FMT); 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) { private String loadPrompt(String filename) {
@ -171,7 +174,8 @@ public class RoastService {
return deepSeekClient.callModel(systemPrompt, text, 0.9); return deepSeekClient.callModel(systemPrompt, text, 0.9);
} }
public String generateResponse(List<CachedMessage> context, String targetName, String userId, String customPrompt) { public String generateResponse(
List<CachedMessage> context, String targetName, String userId, String customPrompt) {
String escapedName = escapeFormat(targetName != null ? targetName : ""); String escapedName = escapeFormat(targetName != null ? targetName : "");
String systemPrompt = customPrompt.formatted(escapedName, escapedName, escapedName); String systemPrompt = customPrompt.formatted(escapedName, escapedName, escapedName);

View file

@ -25,11 +25,12 @@ public class WebSearchService {
private final OkHttpClient httpClient; private final OkHttpClient httpClient;
public WebSearchService() { public WebSearchService() {
this.httpClient = new OkHttpClient.Builder() this.httpClient =
.connectTimeout(8, TimeUnit.SECONDS) new OkHttpClient.Builder()
.readTimeout(12, TimeUnit.SECONDS) .connectTimeout(8, TimeUnit.SECONDS)
.followRedirects(true) .readTimeout(12, TimeUnit.SECONDS)
.build(); .followRedirects(true)
.build();
} }
public String search(String query) { public String search(String query) {
@ -68,13 +69,14 @@ public class WebSearchService {
private List<SearchResult> searchDdg(String query) { private List<SearchResult> searchDdg(String query) {
List<SearchResult> results = new ArrayList<>(); List<SearchResult> results = new ArrayList<>();
try { try {
String url = "https://html.duckduckgo.com/html/?q=" String url =
+ URLEncoder.encode(query, StandardCharsets.UTF_8); "https://html.duckduckgo.com/html/?q=" + URLEncoder.encode(query, StandardCharsets.UTF_8);
Request req = new Request.Builder() Request req =
.url(url) new Request.Builder()
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") .url(url)
.get() .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
.build(); .get()
.build();
try (Response resp = httpClient.newCall(req).execute()) { try (Response resp = httpClient.newCall(req).execute()) {
if (!resp.isSuccessful() || resp.body() == null) return results; if (!resp.isSuccessful() || resp.body() == null) return results;
String html = resp.body().string(); String html = resp.body().string();
@ -108,24 +110,28 @@ public class WebSearchService {
if (amp > 0) enc = enc.substring(0, amp); if (amp > 0) enc = enc.substring(0, amp);
return URLDecoder.decode(enc, StandardCharsets.UTF_8); return URLDecoder.decode(enc, StandardCharsets.UTF_8);
} }
} catch (Exception ignored) {} } catch (Exception ignored) {
}
return ddgUrl; return ddgUrl;
} }
private String fetchPageText(String url) { private String fetchPageText(String url) {
try { try {
Request req = new Request.Builder() Request req =
.url(url) new Request.Builder()
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") .url(url)
.header("Accept", "text/html,application/xhtml+xml") .header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
.header("Accept-Language", "en,ru;q=0.9") .header("Accept", "text/html,application/xhtml+xml")
.get() .header("Accept-Language", "en,ru;q=0.9")
.build(); .get()
.build();
try (Response resp = httpClient.newCall(req).execute()) { try (Response resp = httpClient.newCall(req).execute()) {
if (!resp.isSuccessful() || resp.body() == null) return null; if (!resp.isSuccessful() || resp.body() == null) return null;
String html = resp.body().string(); String html = resp.body().string();
Document doc = Jsoup.parse(html); 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; String text;
if (main != null) { if (main != null) {
main.select("script, style, nav, footer, header, .sidebar, aside").remove(); main.select("script, style, nav, footer, header, .sidebar, aside").remove();

View file

@ -17,17 +17,20 @@ import org.slf4j.LoggerFactory;
@Singleton @Singleton
public class WikipediaService { public class WikipediaService {
private static final Logger logger = LoggerFactory.getLogger(WikipediaService.class); 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 SEARCH_URL =
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"; "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 static final int MAX_RESULT_CHARS = 1500;
private final OkHttpClient httpClient; private final OkHttpClient httpClient;
public WikipediaService() { public WikipediaService() {
this.httpClient = new OkHttpClient.Builder() this.httpClient =
.connectTimeout(10, TimeUnit.SECONDS) new OkHttpClient.Builder()
.readTimeout(10, TimeUnit.SECONDS) .connectTimeout(10, TimeUnit.SECONDS)
.build(); .readTimeout(10, TimeUnit.SECONDS)
.build();
} }
public String search(String query) { public String search(String query) {
@ -45,9 +48,8 @@ public class WikipediaService {
for (int i = 0; i < Math.min(results.size(), 3); i++) { for (int i = 0; i < Math.min(results.size(), 3); i++) {
JsonObject r = results.get(i).getAsJsonObject(); JsonObject r = results.get(i).getAsJsonObject();
String title = r.get("title").getAsString(); String title = r.get("title").getAsString();
String snippet = r.get("snippet").getAsString() String snippet =
.replaceAll("<[^>]+>", "") r.get("snippet").getAsString().replaceAll("<[^>]+>", "").replaceAll("&[^;]+;", " ");
.replaceAll("&[^;]+;", " ");
String pageContent = getExtract(title); String pageContent = getExtract(title);
sb.append("=== ").append(title).append(" ===\n"); sb.append("=== ").append(title).append(" ===\n");
if (pageContent != null) { if (pageContent != null) {

View file

@ -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);
}
}
}

View file

@ -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 <url>";
}
@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;
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -3,6 +3,10 @@ package creator.loki.util;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.function.BooleanSupplier; 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 { public final class VoiceHelper {
private static final int RETRIES = 10; private static final int RETRIES = 10;
private static final long DELAY_MS = 200; private static final long DELAY_MS = 200;
@ -36,4 +40,22 @@ public final class VoiceHelper {
} }
return false; 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;
}
} }

View file

@ -27,7 +27,9 @@ class BotConfigTest {
30, 30,
24, 24,
null, null,
lavalink); lavalink,
null,
null);
assertEquals("token123", config.getMainToken()); assertEquals("token123", config.getMainToken());
assertEquals("token123", config.getToken()); assertEquals("token123", config.getToken());
assertNull(config.getMusicToken()); assertNull(config.getMusicToken());
@ -45,7 +47,8 @@ class BotConfigTest {
void handlesNullValues() { void handlesNullValues() {
BotConfig config = BotConfig config =
new BotConfig( 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.getMainToken());
assertNull(config.getMusicToken()); assertNull(config.getMusicToken());
assertNull(config.getRadioToken()); assertNull(config.getRadioToken());
@ -62,7 +65,8 @@ class BotConfigTest {
LavalinkConfig lavalink = new LavalinkConfig("", 0, "", false); LavalinkConfig lavalink = new LavalinkConfig("", 0, "", false);
BotConfig config = BotConfig config =
new BotConfig( 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.getMainToken());
assertEquals("", config.getMusicToken()); assertEquals("", config.getMusicToken());
assertEquals("", config.getRadioToken()); assertEquals("", config.getRadioToken());

View file

@ -36,6 +36,7 @@ import creator.loki.feature.roast.AndrertListener;
import creator.loki.feature.roast.RoastService; import creator.loki.feature.roast.RoastService;
import creator.loki.feature.role.CustomRoleManager; import creator.loki.feature.role.CustomRoleManager;
import creator.loki.feature.ship.repository.ShipPairRepository; import creator.loki.feature.ship.repository.ShipPairRepository;
import creator.loki.feature.tiktok.TikTokCommand;
import creator.loki.feature.utility.UtilityCommandRegistrar; import creator.loki.feature.utility.UtilityCommandRegistrar;
import creator.loki.feature.voice.service.VoiceChannelManager; import creator.loki.feature.voice.service.VoiceChannelManager;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
@ -102,12 +103,19 @@ class SmokeCommandRegistrationTest {
mock(GuildConfigRepository.class)); mock(GuildConfigRepository.class));
moderationRegistrar.register(registry); 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 <url>");
lenient().when(tikTokCommand.getCategory()).thenReturn("Развлечения");
var entertainmentRegistrar = var entertainmentRegistrar =
new EntertainmentCommandRegistrar( new EntertainmentCommandRegistrar(
mock(CooldownManager.class), mock(CooldownManager.class),
mock(RatingService.class), mock(RatingService.class),
mock(ShipPairRepository.class), mock(ShipPairRepository.class),
mock(BotConfig.class)); mock(BotConfig.class),
tikTokCommand);
entertainmentRegistrar.register(registry); entertainmentRegistrar.register(registry);
var utilityRegistrar = var utilityRegistrar =

View file

@ -40,7 +40,9 @@ class LavalinkManagerTest {
30, 30,
24, 24,
null, null,
lavalinkConfig); lavalinkConfig,
null,
null);
var manager = new LavalinkManager(config, queueRepository); var manager = new LavalinkManager(config, queueRepository);
@ -68,7 +70,9 @@ class LavalinkManagerTest {
30, 30,
24, 24,
null, null,
lavalinkConfig); lavalinkConfig,
null,
null);
var manager = new LavalinkManager(config, queueRepository); var manager = new LavalinkManager(config, queueRepository);
var first = manager.getMusicManager(123L); var first = manager.getMusicManager(123L);