refactor: KISS/DRY/SOLID раунд + багфиксы радио/voice/message

Что сделано:
- RatingService (getDeterministicValue + cooldown — одна точка)
- ModerationHelper (trunc/err — одна точка)
- VoiceHelper (waitForCondition с таймаутом)
- SocialCommand (7 файлов → 1 Flavor enum)
- SwapCommand + CaseLookupCommand (Split SRP)
- PlayCommand 219→150, BotInitializer 182→135, RankCardGenerator (1→4)
- MetricsCollector 182→143, TrackScheduler 202→146, VoiceChannelManager 187→150
- QueueCommands 207→149, VoiceSetupPrefixCommand 183→146, ServerStatsService 187→150
- GuildMusicManager 167→150, SqlEconomyRepository 169→149, GenerateRatingsCommand 172→147
- RadioManager: таймауты, volume при старте, защита от утечки
- VoiceTracker: race condition fix + LevelUpNotifier
- MessageCooldownService: in-memory cache
- MessageListener: LevelUpNotifier вместо inline embed
- ResetLevelsButtonHandler: правильный парсинг кнопки
- OkHttp: java.net.http → OkHttp 5.4.0
- SlowmodeCommandTest: SelfMember fix
Всё: 415 tests, 2 pre-existing, checkstyle чисто
This commit is contained in:
loki5512344 2026-07-04 18:46:27 +02:00
parent 42948520e8
commit 0d916fbd96
79 changed files with 2639 additions and 3245 deletions

32
.classpath Normal file
View file

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="src" output="bin/main" path="src/main/java">
<attributes>
<attribute name="gradle_scope" value="main"/>
<attribute name="gradle_used_by_scope" value="main,test"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="bin/main" path="src/main/resources">
<attributes>
<attribute name="gradle_scope" value="main"/>
<attribute name="gradle_used_by_scope" value="main,test"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="bin/test" path="src/test/java">
<attributes>
<attribute name="gradle_scope" value="test"/>
<attribute name="gradle_used_by_scope" value="test"/>
<attribute name="test" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="bin/test" path="src/test/resources">
<attributes>
<attribute name="gradle_scope" value="test"/>
<attribute name="gradle_used_by_scope" value="test"/>
<attribute name="test" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-21/"/>
<classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
<classpathentry kind="output" path="bin/default"/>
</classpath>

34
.project Normal file
View file

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>discord-bot-bot</name>
<comment>Project bot created by Buildship.</comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.buildship.core.gradleprojectbuilder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.jdt.core.javanature</nature>
<nature>org.eclipse.buildship.core.gradleprojectnature</nature>
</natures>
<filteredResources>
<filter>
<id>1783162972591</id>
<name></name>
<type>30</type>
<matcher>
<id>org.eclipse.core.resources.regexFilterMatcher</id>
<arguments>node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
</matcher>
</filter>
</filteredResources>
</projectDescription>

View file

@ -0,0 +1,13 @@
arguments=--init-script /home/loki/.cache/opencode/bin/jdtls/config_linux/org.eclipse.osgi/57/0/.cp/gradle/init/init.gradle
auto.sync=false
build.scans.enabled=false
connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER)
connection.project.dir=
eclipse.preferences.version=1
gradle.user.home=
java.home=/usr/lib/jvm/java-21-openjdk
jvm.arguments=
offline.mode=false
override.workspace.settings=true
show.console.view=true
show.executions.view=true

145
TODO.md
View file

@ -251,7 +251,150 @@ feature/economy/
---
**Версия:** 2.3 | **Последнее обновление:** 2026-07-02 | **Java 21 ✅, God Constructor ✅, Critical баги ✅, Economy ✅, Level Roles ✅, Lavalink Music ✅, SpotBugs ✅, Checkstyle ✅, CI/CD ✅**
---
## 🔴 Результаты аудита KISS/DRY/SOLID (2026-07-04)
### DRY — дублирование кода (критично)
| # | Что дублируется | Где | Сколько раз | Статус |
|---|----------------|-----|-------------|--------|
| D1 | `getDeterministicValue()` — целиком | GayCommand, FatCommand, IqCommand, PpCommand, BoykisserCommand, MustacheCommand, RateCommand | 7 | ✅ RatingService |
| D2 | Cooldown check boilerplate (5 строк) | Все entertainment команды | 10+ | ✅ RatingService |
| D3 | `trunc()` / `err()` helpers | MuteCommand, WarnCommand, SwapAndCaseCommand, UnmuteCommand, PunishCommand | 5 | ✅ ModerationHelper |
| D4 | Voice connect retry loop (цикл 10×200ms) | PlayCommand, RadioManager | 2 |
| D5 | RatingConfig — 6 методов с идентичной структурой | RatingConfig.java | 6 |
| D6 | Social команды (hug/kiss/slap/poke/tickle/cuddle/feed/pat) — шаблон | 10 файлов | 10 |
### SOLID
| # | Нарушение | Файл | Описание |
|---|-----------|------|----------|
| S1 | SRP | BotModule.java | Биндит всё приложение |
| S2 | SRP | SwapAndCaseCommand.java | Две разные команды в одном файле |
| S3 | SRP | BotInitializer.java | Инициализирует 3 JDA, Lavalink, mutes, counters, health |
| S4 | SRP | ShutdownManager.java | 9 зависимостей в конструкторе |
| S5 | SRP | PlayCommand.java | execute() на 145 строк |
| S6 | OCP | Meter-команды (gay/fat/iq/pp) | Добавить новую = копировать 90 строк | ✅ RatingService |
| S7 | DIP | MuteCommand, WarnCommand | >5 конкретных репозиториев вместо интерфейсов |
### Файлы >150 строк
| # | Файл | Строк |
|---|------|-------|
| F1 | PlayCommand.java | 219 |
| F2 | QueueSlashHandler.java | 207 |
| F3 | MusicBotControlHandler.java | 207 |
| F4 | QueueCommands.java | 207 |
| F5 | TrackScheduler.java | 202 |
| F6 | MuteCommand.java | 199 |
| F7 | ModerationLogger.java | 199 |
| F8 | FeatureTables.java | 193 |
| F9 | DatabaseMigration.java | 193 |
| F10 | VoiceChannelManager.java | 187 |
| F11 | ServerStatsService.java | 187 |
| F12 | VoiceSetupPrefixCommand.java | 183 |
| F13 | RadioManager.java | 182 |
| F14 | MetricsCollector.java | 182 |
| F15 | WarnCommand.java | 184 |
| F16 | BotInitializer.java | 182 |
| F17 | SlashHandler's (Queue/Filter) | >100 |
| F18 | GenerateRatingsCommand.java | 172 |
| F19 | BlackjackService.java | 172 |
| F20 | SqlEconomyRepository.java | 169 |
| F21 | GuildMusicManager.java | 167 |
| F22 | ProfileCommand/RankCardGen | >150 |
### >3 файла в папке
| # | Папка | Файлов |
|---|-------|--------|
| P1 | feature/entertainment/commands/ | 15 |
| P2 | feature/social/commands/ | 10 |
| P3 | feature/economy/command/minigame/ | 7 |
| P4 | feature/level/ (все субпапки) | 21 |
| P5 | feature/moderation/ (все субпакеты) | 18 |
| P6 | feature/music/ (все субпакеты) | 22 |
| P7 | feature/voice/ (все субпакеты) | 12 |
### Нет тестов
| # | Фича | Файлов |
|---|------|--------|
| T1 | music | 20+ |
| T2 | voice | 12 |
| T3 | social | 10 |
| T4 | role | 9 |
| T5 | radio | 5 |
| T6 | reputation | 4 |
| T7 | counter | 7 |
| T8 | admin | 1 |
| T9 | help | 2 |
| T10 | utility | 2 |
| T11 | welcome | 2 |
| T12 | profile | 5 |
| T13 | serverstats | 1 |
## 📋 План рефакторинга (KISS/DRY/SOLID)
### Этап 8: Убить дублирование (DRY) — в процессе
- [x] D1/D2: `RatingService` с `getDeterministicValue()`, cooldown, member parsing — 7 команд переписаны
- [x] D3: `ModerationHelper` с `trunc()`/`err()` — MuteCommand, UnmuteCommand, WarnCommand, SwapAndCaseCommand, PunishCommand
- [ ] D4: Вынести voice connect retry в утилиту
- [ ] D5: RatingConfig — заменить 6 методов на data-driven (Map или config)
- [ ] D6: Social команды — единый `SocialCommand` с параметром action
### Этап 9: SOLID
- [ ] S1: Разбить `BotModule` на per-feature модули (FeatureModule → install в BotModule)
- [ ] S2: Разделить `SwapAndCaseCommand` на два отдельных файла
- [ ] S3: Выделить `MusicBotFactory`, `RadioBotFactory` из `BotInitializer`
- [ ] S4: Уменьшить кол-во зависимостей `ShutdownManager` через события (EventBus)
- [ ] S5: Разбить `PlayCommand.execute()` на методы
- [ ] S6: Meter-команды сделать data-driven через `RatingService`
- [ ] S7: Выделить интерфейсы для репозиториев/сервисов (DIP)
### Этап 10: Файлы ≤150 строк
- [ ] F01: PlayCommand.java — разбить на методы/классы
- [ ] F02-04: QueueSlashHandler, MusicBotControlHandler, QueueCommands — вынести общие части
- [ ] F05: TrackScheduler.java — вывести FilterState в отд. файл или утилиту
- [ ] F06: MuteCommand.java — разбить на subcommand handler'ы
- [ ] F07: ModerationLogger.java — разбить по типам событий
- [ ] F08-09: FeatureTables, DatabaseMigration — разбить по фичам
- [ ] F10-12: Voice* — вынести логику в сервисы
- [ ] F13: RadioManager.java — вынести voice connect в утилиту
- [ ] F14: MetricsCollector.java — разбить по типам метрик
- [ ] F15: WarnCommand.java — вынести auto-mute в отдельный сервис
- [ ] F16: BotInitializer.java — вынести фабрики ботов
- [ ] Остальные: разбить по SOLID
### Этап 11: ≤6 файлов в папке
- [ ] P1: Entertainment команды — группировать по смыслу (coin/dice → `random/`, gay/iq/pp → `rating/`)
- [ ] P2: Social команды — объединить в `SocialCommand` с параметром
- [ ] P4: Level — разбить на commands/, service/, repository/
- [ ] P5: Moderation — разбить по поддоменам (commands/punish, commands/utils, warn)
- [ ] P6: Music — разбить на commands/, player/, lavalink/
### Этап 12: Тесты
- [ ] T1: Music — написать тесты на LavalinkManager, TrackScheduler, PlayCommand
- [ ] T2: Voice — VoiceChannelManager, VoiceButtonHandler
- [ ] T3: Social — интеграционные тесты с NekosLifeApi (мок)
- [ ] T4: Radio — RadioManager, RadioCommand
- [ ] T5: Role — CustomRoleManager, RoleCommandValidator
- [ ] T6-13: Остальные фичи — минимум smoke test
### Техдолг (баги)
- [ ] B1: EmbedFactoryTest — 2 падающих теста (`withTitle`, `withColor`) — IllegalStateException
- [ ] B2: SlowmodeCommandTest — починен (было 7 ошибок компиляции из-за JDA 6.4 SelfMember)
---
**Версия:** 2.3 | **Последнее обновление:** 2026-07-04 | **Java 21 ✅, God Constructor ✅, Critical баги ✅, Economy ✅, Level Roles ✅, Lavalink Music ✅, SpotBugs ✅, Checkstyle ✅, CI/CD ✅**
## ✅ Что добавлено в v2.3

View file

@ -23,6 +23,8 @@ dependencies {
implementation libs.guice
implementation libs.sqlite.jdbc
implementation libs.hikaricp
implementation libs.okhttp
implementation libs.logging.interceptor
testImplementation libs.junit.jupiter
testImplementation libs.mockito.core

View file

@ -10,6 +10,7 @@ caffeine = "3.2.0"
guice = "7.0.0"
sqlite = "3.49.1.0"
hikaricp = "6.3.0"
okhttp = "5.4.0"
junit = "5.12.1"
mockito = "5.15.2"
junitPlatformLauncher = "1.12.1"
@ -29,6 +30,8 @@ caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version.ref = "c
guice = { module = "com.google.inject:guice", version.ref = "guice" }
sqlite-jdbc = { module = "org.xerial:sqlite-jdbc", version.ref = "sqlite" }
hikaricp = { module = "com.zaxxer:HikariCP", version.ref = "hikaricp" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
logging-interceptor = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockito" }
mockito-junit-jupiter = { module = "org.mockito:mockito-junit-jupiter", version.ref = "mockito" }

View file

@ -1,5 +1,7 @@
package creator.loki.core;
import java.util.List;
import com.google.inject.Inject;
import creator.loki.config.BotConfig;
import creator.loki.core.registry.PrefixCommandRegistrar;
@ -63,119 +65,92 @@ public class BotInitializer {
public BotCluster start() {
LoggerDecorator.startup(logger, "Инициализация main бота...");
registrar.registerAll();
JDA mainJda = createMainJDA();
try {
mainJda.awaitReady();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Main бот прерван во время ожидания готовности", e);
}
JDA mainJda = buildMainJDA();
mainJda.updateCommands().queue();
muteScheduler.restorePending(mainJda);
for (var guild : mainJda.getGuilds()) {
memberCounterListener.startScheduler(guild);
}
mainJda.getGuilds().forEach(g -> memberCounterListener.startScheduler(g));
healthHttpServer.start();
JDA musicJda = null;
if (config.getMusicToken() != null && !config.getMusicToken().isBlank()) {
LoggerDecorator.startup(logger, "Инициализация music бота...");
musicJda = createMusicJDA();
try {
musicJda.awaitReady();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Music бот прерван во время ожидания готовности", e);
}
}
JDA radioJda = null;
if (config.getRadioToken() != null && !config.getRadioToken().isBlank()) {
LoggerDecorator.startup(logger, "Инициализация radio бота...");
radioJda = createRadioJDA();
try {
radioJda.awaitReady();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Radio бот прерван во время ожидания готовности", e);
}
JDA musicJda = createMusicJDA();
JDA radioJda = createRadioJDA();
if (radioJda != null) {
radioManager.setRadioJda(radioJda);
radioManager.setLavalinkManager(lavalinkManager);
}
BotCluster cluster = new BotCluster(mainJda, musicJda, radioJda);
registerShutdownHook(cluster);
Runtime.getRuntime()
.addShutdownHook(new Thread(() -> shutdownManager.shutdown(cluster), "ShutdownHook"));
return cluster;
}
private JDA createMainJDA() {
private JDA buildMainJDA() {
var builder =
JDABuilder.createDefault(config.getMainToken())
.setStatus(OnlineStatus.ONLINE)
.setActivity(Activity.playing(config.getActivity()))
.enableIntents(
GatewayIntent.GUILD_MESSAGES,
GatewayIntent.MESSAGE_CONTENT,
GatewayIntent.GUILD_MEMBERS,
GatewayIntent.GUILD_VOICE_STATES)
.enableCache(CacheFlag.VOICE_STATE)
.addEventListeners(listenerAggregator.getAll());
createBuilder(
config.getMainToken(),
config.getActivity(),
GatewayIntent.GUILD_MESSAGES,
GatewayIntent.MESSAGE_CONTENT,
GatewayIntent.GUILD_MEMBERS,
GatewayIntent.GUILD_VOICE_STATES);
if (lavalinkManager.getClient() != null) {
builder.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(lavalinkManager.getClient()));
}
return builder.build();
builder.addEventListeners(listenerAggregator.getAll());
return awaitJda(builder.build());
}
private JDA createMusicJDA() {
LavalinkManager musicLavalink =
new LavalinkManager(config, queueRepository, config.getMusicToken());
if (config.getMusicToken() == null || config.getMusicToken().isBlank()) return null;
LoggerDecorator.startup(logger, "Инициализация music бота...");
LavalinkManager ml = new LavalinkManager(config, queueRepository, config.getMusicToken());
var builder =
JDABuilder.createDefault(config.getMusicToken())
.setStatus(OnlineStatus.ONLINE)
.setActivity(Activity.playing("🎵 q!play"))
.enableIntents(
GatewayIntent.GUILD_MESSAGES,
GatewayIntent.MESSAGE_CONTENT,
GatewayIntent.GUILD_VOICE_STATES)
.enableCache(CacheFlag.VOICE_STATE);
if (musicLavalink.getClient() != null) {
builder.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(musicLavalink.getClient()));
createBuilder(
config.getMusicToken(),
"🎵 q!play",
GatewayIntent.GUILD_MESSAGES,
GatewayIntent.MESSAGE_CONTENT,
GatewayIntent.GUILD_VOICE_STATES);
if (ml.getClient() != null) {
builder.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(ml.getClient()));
}
builder.addEventListeners(
new MusicBotListener(
musicLavalink,
queueRepository,
new creator.loki.feature.music.core.PlayerManager(musicLavalink)));
return builder.build();
ml, queueRepository, new creator.loki.feature.music.core.PlayerManager(ml)));
return awaitJda(builder.build());
}
private JDA createRadioJDA() {
if (config.getRadioToken() == null || config.getRadioToken().isBlank()) return null;
LoggerDecorator.startup(logger, "Инициализация radio бота...");
var builder =
JDABuilder.createDefault(config.getRadioToken())
.setStatus(OnlineStatus.ONLINE)
.setActivity(Activity.playing("📻 r!play"))
.enableIntents(
GatewayIntent.GUILD_MESSAGES,
GatewayIntent.MESSAGE_CONTENT,
GatewayIntent.GUILD_VOICE_STATES)
.enableCache(CacheFlag.VOICE_STATE);
createBuilder(
config.getRadioToken(),
"📻 r!play",
GatewayIntent.GUILD_MESSAGES,
GatewayIntent.MESSAGE_CONTENT,
GatewayIntent.GUILD_VOICE_STATES);
if (lavalinkManager.getClient() != null) {
builder.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(lavalinkManager.getClient()));
}
builder.addEventListeners(new RadioBotListener(radioManager));
return builder.build();
return awaitJda(builder.build());
}
private void registerShutdownHook(BotCluster cluster) {
Runtime.getRuntime()
.addShutdownHook(new Thread(() -> shutdownManager.shutdown(cluster), "ShutdownHook"));
private static JDABuilder createBuilder(String token, String activity, GatewayIntent... intents) {
return JDABuilder.createDefault(token)
.setStatus(OnlineStatus.ONLINE)
.setActivity(Activity.playing(activity))
.enableIntents(List.of(intents))
.enableCache(CacheFlag.VOICE_STATE);
}
private static JDA awaitJda(JDA jda) {
try {
jda.awaitReady();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("JDA прерван во время ожидания готовности", e);
}
return jda;
}
}

View file

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

View file

@ -3,6 +3,7 @@ package creator.loki.feature.economy.repository;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Types;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@ -19,24 +20,44 @@ public class SqlEconomyRepository implements EconomyRepository {
private static final int MAX_TOP = 100;
private final Database database;
private static final String COLUMNS =
"user_id, cash, bank, total_earned, total_spent, last_daily, daily_streak, fishing_luck, efficiency, last_fish, job, last_work";
public SqlEconomyRepository(Database database) {
this.database = database;
}
private static String selectSql() {
return "SELECT " + COLUMNS + " FROM economy WHERE user_id = ?";
}
private static String upsertSql() {
return "INSERT INTO economy ("
+ COLUMNS
+ ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
+ "ON CONFLICT(user_id) DO UPDATE SET "
+ "cash = excluded.cash, bank = excluded.bank, total_earned = excluded.total_earned, "
+ "total_spent = excluded.total_spent, last_daily = excluded.last_daily, "
+ "daily_streak = excluded.daily_streak, fishing_luck = excluded.fishing_luck, "
+ "efficiency = excluded.efficiency, last_fish = excluded.last_fish, "
+ "job = excluded.job, last_work = excluded.last_work";
}
private static void setNullableString(PreparedStatement stmt, int index, String value)
throws SQLException {
if (value != null) {
stmt.setString(index, value);
} else {
stmt.setNull(index, Types.VARCHAR);
}
}
@Override
public Optional<EconomyAccount> findById(String userId) {
String sql =
"""
SELECT user_id, cash, bank, total_earned, total_spent,
last_daily, daily_streak, fishing_luck, efficiency,
last_fish, job, last_work
FROM economy
WHERE user_id = ?
""";
try (PreparedStatement stmt = database.prepare(sql)) {
var sql = selectSql();
try (var stmt = database.prepare(sql)) {
stmt.setString(1, userId);
try (ResultSet rs = stmt.executeQuery()) {
try (var rs = stmt.executeQuery()) {
if (rs.next()) {
return Optional.of(mapResultSetToAccount(rs));
}
@ -49,56 +70,20 @@ public class SqlEconomyRepository implements EconomyRepository {
@Override
public void save(EconomyAccount account) {
String sql =
"""
INSERT INTO economy (user_id, cash, bank, total_earned, total_spent,
last_daily, daily_streak, fishing_luck, efficiency,
last_fish, job, last_work)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id)
DO UPDATE SET
cash = excluded.cash,
bank = excluded.bank,
total_earned = excluded.total_earned,
total_spent = excluded.total_spent,
last_daily = excluded.last_daily,
daily_streak = excluded.daily_streak,
fishing_luck = excluded.fishing_luck,
efficiency = excluded.efficiency,
last_fish = excluded.last_fish,
job = excluded.job,
last_work = excluded.last_work
""";
try (PreparedStatement stmt = database.prepare(sql)) {
var sql = upsertSql();
try (var stmt = database.prepare(sql)) {
stmt.setString(1, account.getUserId());
stmt.setLong(2, account.getCash());
stmt.setLong(3, account.getBank());
stmt.setLong(4, account.getTotalEarned());
stmt.setLong(5, account.getTotalSpent());
if (account.getLastDaily() != null) {
stmt.setString(6, account.getLastDaily());
} else {
stmt.setNull(6, java.sql.Types.VARCHAR);
}
setNullableString(stmt, 6, account.getLastDaily());
stmt.setInt(7, account.getDailyStreak());
stmt.setInt(8, account.getFishingLuck());
stmt.setInt(9, account.getEfficiency());
if (account.getLastFish() != null) {
stmt.setString(10, account.getLastFish());
} else {
stmt.setNull(10, java.sql.Types.VARCHAR);
}
if (account.getJob() != null) {
stmt.setString(11, account.getJob());
} else {
stmt.setNull(11, java.sql.Types.VARCHAR);
}
if (account.getLastWork() != null) {
stmt.setString(12, account.getLastWork());
} else {
stmt.setNull(12, java.sql.Types.VARCHAR);
}
setNullableString(stmt, 10, account.getLastFish());
setNullableString(stmt, 11, account.getJob());
setNullableString(stmt, 12, account.getLastWork());
stmt.executeUpdate();
} catch (SQLException e) {
logger.error("Error saving economy account for {}", account.getUserId(), e);
@ -107,10 +92,10 @@ public class SqlEconomyRepository implements EconomyRepository {
@Override
public boolean exists(String userId) {
String sql = "SELECT 1 FROM economy WHERE user_id = ?";
try (PreparedStatement stmt = database.prepare(sql)) {
var sql = "SELECT 1 FROM economy WHERE user_id = ?";
try (var stmt = database.prepare(sql)) {
stmt.setString(1, userId);
try (ResultSet rs = stmt.executeQuery()) {
try (var rs = stmt.executeQuery()) {
return rs.next();
}
} catch (SQLException e) {
@ -121,20 +106,15 @@ public class SqlEconomyRepository implements EconomyRepository {
@Override
public List<EconomyAccount> findTopByBalance(int limit) {
int lim = clampLimit(limit);
String sql =
"""
SELECT user_id, cash, bank, total_earned, total_spent,
last_daily, daily_streak, fishing_luck, efficiency,
last_fish, job, last_work
FROM economy
ORDER BY (cash + bank) DESC, cash DESC, user_id ASC
LIMIT ?
""";
List<EconomyAccount> list = new ArrayList<>();
try (PreparedStatement stmt = database.prepare(sql)) {
var lim = clampLimit(limit);
var sql =
"SELECT "
+ COLUMNS
+ " FROM economy ORDER BY (cash + bank) DESC, cash DESC, user_id ASC LIMIT ?";
var list = new ArrayList<EconomyAccount>();
try (var stmt = database.prepare(sql)) {
stmt.setInt(1, lim);
try (ResultSet rs = stmt.executeQuery()) {
try (var rs = stmt.executeQuery()) {
while (rs.next()) {
list.add(mapResultSetToAccount(rs));
}

View file

@ -5,41 +5,41 @@ import creator.loki.config.BotConfig;
import creator.loki.core.registry.CommandRegistrar;
import creator.loki.core.registry.CommandRegistry;
import creator.loki.core.security.CooldownManager;
import creator.loki.feature.entertainment.commands.BoykisserCommand;
import creator.loki.feature.entertainment.commands.CoinCommand;
import creator.loki.feature.entertainment.commands.DiceCommand;
import creator.loki.feature.entertainment.commands.EightBallCommand;
import creator.loki.feature.entertainment.commands.FatCommand;
import creator.loki.feature.entertainment.commands.GayCommand;
import creator.loki.feature.entertainment.commands.GlazbobaCommand;
import creator.loki.feature.entertainment.commands.IqCommand;
import creator.loki.feature.entertainment.commands.MemeCommand;
import creator.loki.feature.entertainment.commands.MustacheCommand;
import creator.loki.feature.entertainment.commands.PpCommand;
import creator.loki.feature.entertainment.commands.RandomChoiceCommand;
import creator.loki.feature.entertainment.commands.RateCommand;
import creator.loki.feature.entertainment.commands.rating.BoykisserCommand;
import creator.loki.feature.entertainment.commands.rating.FatCommand;
import creator.loki.feature.entertainment.commands.rating.GayCommand;
import creator.loki.feature.entertainment.commands.rating.IqCommand;
import creator.loki.feature.entertainment.commands.rating.MustacheCommand;
import creator.loki.feature.entertainment.commands.rating.PpCommand;
import creator.loki.feature.entertainment.commands.rating.RateCommand;
import creator.loki.feature.entertainment.service.RatingService;
import creator.loki.feature.ship.ShipCommand;
import creator.loki.feature.ship.repository.ShipPairRepository;
import creator.loki.feature.social.commands.ComplimentCommand;
import creator.loki.feature.social.commands.CuddleCommand;
import creator.loki.feature.social.commands.FeedCommand;
import creator.loki.feature.social.commands.RoastCommand;
import creator.loki.feature.social.commands.HugCommand;
import creator.loki.feature.social.commands.KissCommand;
import creator.loki.feature.social.commands.PatCommand;
import creator.loki.feature.social.commands.PokeCommand;
import creator.loki.feature.social.commands.SlapCommand;
import creator.loki.feature.social.commands.TickleCommand;
import creator.loki.feature.social.commands.RoastCommand;
import creator.loki.feature.social.commands.SocialCommand;
public class EntertainmentCommandRegistrar implements CommandRegistrar {
private final CooldownManager cooldownManager;
private final RatingService ratingService;
private final ShipPairRepository shipPairRepository;
private final BotConfig config;
@Inject
public EntertainmentCommandRegistrar(
CooldownManager cooldownManager, ShipPairRepository shipPairRepository, BotConfig config) {
CooldownManager cooldownManager,
RatingService ratingService,
ShipPairRepository shipPairRepository,
BotConfig config) {
this.cooldownManager = cooldownManager;
this.ratingService = ratingService;
this.shipPairRepository = shipPairRepository;
this.config = config;
}
@ -55,22 +55,18 @@ public class EntertainmentCommandRegistrar implements CommandRegistrar {
registry.register(new ComplimentCommand());
registry.register(new GlazbobaCommand());
registry.register(new IqCommand(cooldownManager));
registry.register(new GayCommand(cooldownManager));
registry.register(new PpCommand(cooldownManager));
registry.register(new RateCommand(cooldownManager));
registry.register(new BoykisserCommand(cooldownManager));
registry.register(new MustacheCommand(cooldownManager));
registry.register(new FatCommand(cooldownManager));
registry.register(new IqCommand(ratingService));
registry.register(new GayCommand(ratingService));
registry.register(new PpCommand(ratingService));
registry.register(new RateCommand(ratingService));
registry.register(new BoykisserCommand(ratingService));
registry.register(new MustacheCommand(ratingService));
registry.register(new FatCommand(ratingService));
registry.register(new PatCommand(cooldownManager));
registry.register(new HugCommand());
registry.register(new KissCommand(cooldownManager));
registry.register(new SlapCommand(cooldownManager));
registry.register(new TickleCommand(cooldownManager));
registry.register(new PokeCommand(cooldownManager));
registry.register(new CuddleCommand(cooldownManager));
registry.register(new FeedCommand(cooldownManager));
for (var flavor : SocialCommand.Flavor.values()) {
registry.register(new SocialCommand(flavor, cooldownManager));
}
registry.register(
new RandomChoiceCommand(config, "choose", RandomChoiceCommand.Flavor.DEFAULT));

View file

@ -1,104 +0,0 @@
package creator.loki.feature.entertainment.commands;
import java.util.List;
import java.util.Random;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.core.security.CooldownManager;
import creator.loki.embed.EmbedFactory;
import net.dv8tion.jda.api.entities.Member;
/** Boykisser метр с детерминированным результатом */
public class BoykisserCommand implements Command {
private final CooldownManager cooldownManager;
public BoykisserCommand(CooldownManager cooldownManager) {
this.cooldownManager = cooldownManager;
}
@Override
public String getName() {
return "boykisser";
}
@Override
public String getDescription() {
return "На сколько ты boykisser (результат всегда одинаковый)";
}
@Override
public String getUsage() {
return "boykisser [@user]";
}
@Override
public String getCategory() {
return "Развлечения";
}
@Override
public void execute(CommandContext context) {
// Проверка cooldown (5 секунд)
if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "boykisser")) {
long remaining =
cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "boykisser");
context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue();
return;
}
List<Member> mentioned = context.getMessage().getMentions().getMembers();
Member target = mentioned.isEmpty() ? context.getAuthor() : mentioned.get(0);
if (target == null) return;
if (target.getUser().isBot()) {
context.getChannel().sendMessage("❌ Боты не целуют мальчиков!").queue();
return;
}
// Применяем cooldown
cooldownManager.applyCooldown(context.getAuthor().getId(), "boykisser", 5);
// Детерминированный процент
int boykisserLevel = getDeterministicValue(target.getId(), 777, 0, 100);
// Прогресс-бар с поцелуями
StringBuilder bar = new StringBuilder();
int filled = boykisserLevel / 10;
for (int i = 0; i < filled; i++) {
bar.append("💋");
}
for (int i = filled; i < 10; i++) {
bar.append("⬜");
}
// Определяем уровень
RatingLevel level = RatingLevel.findLevel(boykisserLevel, RatingConfig.getBoykisserLevels());
context
.getChannel()
.sendMessageEmbeds(
EmbedFactory.create()
.withTitle("💋 Boykisser Метр")
.withDescription(target.getAsMention())
.addField(
"Уровень",
bar.toString() + "\n**" + boykisserLevel + "%** - " + level.getComment(),
false)
.withThumbnail(target.getUser().getAvatarUrl())
.withColor(level.getColor())
.withFooter("💡 Детерминировано вашим Discord ID")
.build())
.queue();
}
private static final Random RANDOM = new Random();
private int getDeterministicValue(String userId, int salt, int min, int max) {
long seed = Long.parseLong(userId) + salt;
RANDOM.setSeed(seed);
return RANDOM.nextInt(max - min + 1) + min;
}
}

View file

@ -1,98 +0,0 @@
package creator.loki.feature.entertainment.commands;
import java.util.List;
import java.util.Random;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.core.security.CooldownManager;
import creator.loki.embed.EmbedFactory;
import net.dv8tion.jda.api.entities.Member;
/** Fat метр - проверка на жирность */
public class FatCommand implements Command {
private final CooldownManager cooldownManager;
public FatCommand(CooldownManager cooldownManager) {
this.cooldownManager = cooldownManager;
}
@Override
public String getName() {
return "fat";
}
@Override
public String getDescription() {
return "Проверка на жирность (результат всегда одинаковый)";
}
@Override
public String getUsage() {
return "fat [@user]";
}
@Override
public String getCategory() {
return "Развлечения";
}
@Override
public void execute(CommandContext context) {
// Проверка cooldown (5 секунд)
if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "fat")) {
long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "fat");
context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue();
return;
}
List<Member> mentioned = context.getMessage().getMentions().getMembers();
Member target = mentioned.isEmpty() ? context.getAuthor() : mentioned.get(0);
if (target == null) return;
// Применяем cooldown
cooldownManager.applyCooldown(context.getAuthor().getId(), "fat", 5);
// Детерминированный процент от 0 до 100
int fatLevel = getDeterministicValue(target.getId(), 999, 0, 100);
// Прогресс-бар
StringBuilder bar = new StringBuilder();
int filled = fatLevel / 10;
for (int i = 0; i < filled; i++) {
bar.append("🟩");
}
for (int i = filled; i < 10; i++) {
bar.append("⬜");
}
// Определяем уровень
RatingLevel level = RatingLevel.findLevel(fatLevel, RatingConfig.getFatLevels());
context
.getChannel()
.sendMessageEmbeds(
EmbedFactory.create()
.withTitle("🐷 Fat Метр")
.withDescription(target.getAsMention())
.addField(
"Жирность",
bar.toString() + "\n**" + fatLevel + "%** - " + level.getComment(),
false)
.withThumbnail(target.getUser().getAvatarUrl())
.withColor(level.getColor())
.withFooter("💡 Детерминировано вашим Discord ID")
.build())
.queue();
}
private static final Random RANDOM = new Random();
private int getDeterministicValue(String userId, int salt, int min, int max) {
long seed = Long.parseLong(userId) + salt;
RANDOM.setSeed(seed);
return RANDOM.nextInt(max - min + 1) + min;
}
}

View file

@ -1,99 +0,0 @@
package creator.loki.feature.entertainment.commands;
import java.awt.Color;
import java.util.List;
import java.util.Random;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.core.security.CooldownManager;
import creator.loki.embed.EmbedFactory;
import net.dv8tion.jda.api.entities.Member;
/** Gay-метр с детерминированным результатом */
public class GayCommand implements Command {
private final CooldownManager cooldownManager;
public GayCommand(CooldownManager cooldownManager) {
this.cooldownManager = cooldownManager;
}
@Override
public String getName() {
return "gay";
}
@Override
public String getDescription() {
return "Гей-метр (результат всегда одинаковый)";
}
@Override
public String getUsage() {
return "gay [@user]";
}
@Override
public String getCategory() {
return "Развлечения";
}
@Override
public void execute(CommandContext context) {
// Проверка cooldown (5 секунд)
if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "gay")) {
long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "gay");
context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue();
return;
}
List<Member> mentioned = context.getMessage().getMentions().getMembers();
Member target = mentioned.isEmpty() ? context.getAuthor() : mentioned.get(0);
if (target == null) return;
if (target.getUser().isBot()) {
context.getChannel().sendMessage("❌ Боты не имеют ориентации!").queue();
return;
}
// Применяем cooldown
cooldownManager.applyCooldown(context.getAuthor().getId(), "gay", 5);
// Детерминированный процент
int gayLevel = getDeterministicValue(target.getId(), 456, 0, 100);
// Прогресс-бар с радугой
String[] rainbow = {"🟥", "🟧", "🟨", "🟩", "🟦", "🟪"};
StringBuilder bar = new StringBuilder();
int filled = Math.min(gayLevel / 17 + 1, 6);
for (int i = 0; i < filled; i++) {
bar.append(rainbow[i]);
}
for (int i = filled; i < 6; i++) {
bar.append("⬜");
}
context
.getChannel()
.sendMessageEmbeds(
EmbedFactory.create()
.withTitle("🏳️‍🌈 Гей-метр")
.withDescription(target.getAsMention())
.addField("Уровень", bar.toString() + "\n**" + gayLevel + "%**", false)
.withThumbnail(target.getUser().getAvatarUrl())
.withColor(new Color(255, 0, 255))
.withFooter("💡 Это просто мем, не воспринимайте серьезно")
.build())
.queue();
}
private static final Random RANDOM = new Random();
private int getDeterministicValue(String userId, int salt, int min, int max) {
long seed = Long.parseLong(userId) + salt;
RANDOM.setSeed(seed);
return RANDOM.nextInt(max - min + 1) + min;
}
}

View file

@ -1,92 +0,0 @@
package creator.loki.feature.entertainment.commands;
import java.util.List;
import java.util.Random;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.core.security.CooldownManager;
import creator.loki.embed.EmbedFactory;
import net.dv8tion.jda.api.entities.Member;
/** IQ тест с детерминированным результатом */
public class IqCommand implements Command {
private final CooldownManager cooldownManager;
public IqCommand(CooldownManager cooldownManager) {
this.cooldownManager = cooldownManager;
}
@Override
public String getName() {
return "iq";
}
@Override
public String getDescription() {
return "Тест IQ (результат всегда одинаковый)";
}
@Override
public String getUsage() {
return "iq [@user]";
}
@Override
public String getCategory() {
return "Развлечения";
}
@Override
public void execute(CommandContext context) {
// Проверка cooldown (5 секунд)
if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "iq")) {
long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "iq");
context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue();
return;
}
List<Member> mentioned = context.getMessage().getMentions().getMembers();
Member target = mentioned.isEmpty() ? context.getAuthor() : mentioned.get(0);
if (target == null) return;
// Применяем cooldown
cooldownManager.applyCooldown(context.getAuthor().getId(), "iq", 5);
int iq;
if (target.getUser().isBot()) {
iq = 9000; // Боты умные!
} else if (target.getId().equals("1414975197269987471")) {
iq = 1000; // Специальное значение
} else {
// Детерминированный IQ от 0 до 140
iq = getDeterministicValue(target.getId(), 123, 0, 140);
}
// Определяем уровень
RatingLevel level = RatingLevel.findLevel(iq, RatingConfig.getIqLevels());
context
.getChannel()
.sendMessageEmbeds(
EmbedFactory.create()
.withTitle("🧠 Тест IQ")
.withDescription("Результат для " + target.getAsMention())
.addField("IQ Уровень", "**" + iq + "** - " + level.getComment(), false)
.withThumbnail(target.getUser().getAvatarUrl())
.withColor(level.getColor())
.withFooter("💡 Научно доказано вашим ID")
.build())
.queue();
}
private static final Random RANDOM = new Random();
private int getDeterministicValue(String userId, int salt, int min, int max) {
long seed = Long.parseLong(userId) + salt;
RANDOM.setSeed(seed);
return RANDOM.nextInt(max - min + 1) + min;
}
}

View file

@ -1,107 +0,0 @@
package creator.loki.feature.entertainment.commands;
import java.util.List;
import java.util.Random;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.core.security.CooldownManager;
import creator.loki.embed.EmbedFactory;
import net.dv8tion.jda.api.entities.Member;
/** Mustache метр - проверка на усатость */
public class MustacheCommand implements Command {
private final CooldownManager cooldownManager;
public MustacheCommand(CooldownManager cooldownManager) {
this.cooldownManager = cooldownManager;
}
@Override
public String getName() {
return "mustache";
}
@Override
public String getDescription() {
return "Проверка на усатость (результат всегда одинаковый)";
}
@Override
public String getUsage() {
return "mustache [@user]";
}
@Override
public String getCategory() {
return "Развлечения";
}
@Override
public void execute(CommandContext context) {
// Проверка cooldown (5 секунд)
if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "mustache")) {
long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "mustache");
context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue();
return;
}
List<Member> mentioned = context.getMessage().getMentions().getMembers();
Member target = mentioned.isEmpty() ? context.getAuthor() : mentioned.get(0);
if (target == null) return;
// Применяем cooldown
cooldownManager.applyCooldown(context.getAuthor().getId(), "mustache", 5);
int mustacheLevel;
// Специальные значения
if (target.getUser().isBot() || target.getId().equals("1414975197269987471")) {
mustacheLevel = 0; // Боты и специальный ID = 0%
} else if (target.getId().equals("678283217094180915")) {
mustacheLevel = 1000; // Специальный ID = 1000%
} else {
// Детерминированный процент от 0 до 100
mustacheLevel = getDeterministicValue(target.getId(), 888, 0, 100);
}
// Прогресс-бар с усами
StringBuilder bar = new StringBuilder();
int filled = Math.min(mustacheLevel / 10, 10);
for (int i = 0; i < filled; i++) {
bar.append("🧔");
}
for (int i = filled; i < 10; i++) {
bar.append("⬜");
}
// Определяем уровень
RatingLevel level = RatingLevel.findLevel(mustacheLevel, RatingConfig.getMustacheLevels());
context
.getChannel()
.sendMessageEmbeds(
EmbedFactory.create()
.withTitle("🧔 Mustache Метр")
.withDescription(target.getAsMention())
.addField(
"Усатость",
bar.toString() + "\n**" + mustacheLevel + "%** - " + level.getComment(),
false)
.withThumbnail(target.getUser().getAvatarUrl())
.withColor(level.getColor())
.withFooter("💡 Детерминировано вашим Discord ID")
.build())
.queue();
}
private static final Random RANDOM = new Random();
private int getDeterministicValue(String userId, int salt, int min, int max) {
long seed = Long.parseLong(userId) + salt;
RANDOM.setSeed(seed);
return RANDOM.nextInt(max - min + 1) + min;
}
}

View file

@ -1,111 +0,0 @@
package creator.loki.feature.entertainment.commands;
import java.util.List;
import java.util.Random;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.core.security.CooldownManager;
import creator.loki.embed.EmbedFactory;
import net.dv8tion.jda.api.entities.Member;
/** PP размер с детерминированным результатом */
public class PpCommand implements Command {
private final CooldownManager cooldownManager;
public PpCommand(CooldownManager cooldownManager) {
this.cooldownManager = cooldownManager;
}
@Override
public String getName() {
return "pp";
}
@Override
public String getDescription() {
return "Измеритель pp (результат всегда одинаковый)";
}
@Override
public String getUsage() {
return "pp [@user]";
}
@Override
public String getCategory() {
return "Развлечения";
}
@Override
public void execute(CommandContext context) {
// Проверка cooldown (5 секунд)
if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "pp")) {
long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "pp");
context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue();
return;
}
List<Member> mentioned = context.getMessage().getMentions().getMembers();
Member target = mentioned.isEmpty() ? context.getAuthor() : mentioned.get(0);
if (target == null) return;
if (target.getUser().isBot()) {
context.getChannel().sendMessage("❌ У ботов нет pp!").queue();
return;
}
// Применяем cooldown
cooldownManager.applyCooldown(context.getAuthor().getId(), "pp", 5);
// Специальное значение для конкретного пользователя
int size;
if (target.getId().equals("1414975197269987471")) {
size = 100;
} else {
// Детерминированный размер от 0 до 30 см
size = getDeterministicValue(target.getId(), 6969, 0, 30);
}
// Визуализация
StringBuilder ppVisual = new StringBuilder("8");
for (int i = 0; i < size; i++) {
ppVisual.append("=");
}
ppVisual.append("D");
// Определяем уровень
RatingLevel level = RatingLevel.findLevel(size, RatingConfig.getPpLevels());
context
.getChannel()
.sendMessageEmbeds(
EmbedFactory.create()
.withTitle("🍆 PP Размер")
.withDescription(target.getAsMention())
.addField(
"Размер",
"```"
+ ppVisual.toString()
+ "```\n**"
+ size
+ " см** - "
+ level.getComment(),
false)
.withThumbnail(target.getUser().getAvatarUrl())
.withColor(level.getColor())
.withFooter("💡 Детерминировано вашим Discord ID")
.build())
.queue();
}
private static final Random RANDOM = new Random();
private int getDeterministicValue(String userId, int salt, int min, int max) {
long seed = Long.parseLong(userId) + salt;
RANDOM.setSeed(seed);
return RANDOM.nextInt(max - min + 1) + min;
}
}

View file

@ -1,64 +0,0 @@
package creator.loki.feature.entertainment.commands;
import java.awt.Color;
import java.util.Arrays;
import java.util.List;
/** Конфигурации уровней для различных rating команд */
public class RatingConfig {
/** Уровни для Mustache команды */
public static List<RatingLevel> getMustacheLevels() {
return Arrays.asList(
new RatingLevel(1000, "🎩 ЛЕГЕНДАРНЫЕ УСИЩИ!", new Color(255, 215, 0)),
new RatingLevel(80, "🧔 Роскошные усы!", new Color(139, 69, 19)),
new RatingLevel(60, "👨 Хорошие усы", new Color(160, 82, 45)),
new RatingLevel(40, "😊 Усики растут", new Color(205, 133, 63)),
new RatingLevel(20, "🙂 Лёгкая щетина", new Color(210, 180, 140)),
new RatingLevel(1, "😐 Почти нет усов", new Color(128, 128, 128)),
new RatingLevel(0, "🪒 Гладко выбрит", new Color(200, 200, 200)));
}
/** Уровни для IQ команды */
public static List<RatingLevel> getIqLevels() {
return Arrays.asList(
new RatingLevel(140, "🧠 Гений!", new Color(255, 215, 0)),
new RatingLevel(120, "🎓 Очень умный", new Color(100, 255, 150)),
new RatingLevel(100, "📚 Средний", new Color(52, 152, 219)),
new RatingLevel(80, "📖 Ниже среднего", new Color(255, 165, 0)),
new RatingLevel(0, "🤔 Низкий", new Color(231, 76, 60)));
}
/** Уровни для Fat команды */
public static List<RatingLevel> getFatLevels() {
return Arrays.asList(
new RatingLevel(90, "🐷 СВИНОТА!", new Color(255, 0, 0)),
new RatingLevel(70, "🍔 Очень жирный", new Color(255, 100, 0)),
new RatingLevel(50, "🍕 Жирноват", new Color(255, 165, 0)),
new RatingLevel(30, "👍 Нормальный вес", new Color(0, 200, 0)),
new RatingLevel(10, "🥗 Худой", new Color(100, 200, 255)),
new RatingLevel(0, "💀 Очень худой", new Color(150, 150, 150)));
}
/** Уровни для PP команды (размер в см) */
public static List<RatingLevel> getPpLevels() {
return Arrays.asList(
new RatingLevel(25, "😳 НЕВЕРОЯТНО!", new Color(255, 215, 0)),
new RatingLevel(20, "😮 Впечатляет", new Color(100, 255, 150)),
new RatingLevel(15, "👍 Выше среднего", new Color(52, 152, 219)),
new RatingLevel(10, "🙂 Средний", new Color(128, 128, 128)),
new RatingLevel(5, "😐 Ниже среднего", new Color(255, 165, 0)),
new RatingLevel(0, "💀 Микро", new Color(231, 76, 60)));
}
/** Уровни для Boykisser команды */
public static List<RatingLevel> getBoykisserLevels() {
return Arrays.asList(
new RatingLevel(90, "💋 ЛЕГЕНДАРНЫЙ BOYKISSER!", new Color(255, 105, 180)),
new RatingLevel(70, "😘 Активный boykisser", new Color(255, 182, 193)),
new RatingLevel(50, "😊 Любит целоваться", new Color(255, 192, 203)),
new RatingLevel(30, "🙂 Иногда целуется", new Color(255, 218, 185)),
new RatingLevel(10, "😐 Редко целуется", new Color(176, 196, 222)),
new RatingLevel(0, "😶 Не целуется", new Color(128, 128, 128)));
}
}

View file

@ -0,0 +1,70 @@
package creator.loki.feature.entertainment.commands.rating;
import com.google.inject.Inject;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedFactory;
import creator.loki.feature.entertainment.service.RatingService;
import net.dv8tion.jda.api.entities.Member;
public class BoykisserCommand implements Command {
private final RatingService ratingService;
@Inject
public BoykisserCommand(RatingService ratingService) {
this.ratingService = ratingService;
}
@Override
public String getName() {
return "boykisser";
}
@Override
public String getDescription() {
return "На сколько ты boykisser (результат всегда одинаковый)";
}
@Override
public String getUsage() {
return "boykisser [@user]";
}
@Override
public String getCategory() {
return "Развлечения";
}
@Override
public void execute(CommandContext context) {
if (!ratingService.checkAndApplyCooldown(context, "boykisser")) return;
Member target = ratingService.resolveTarget(context);
if (target == null) return;
if (target.getUser().isBot()) {
context.getChannel().sendMessage("❌ Боты не целуют мальчиков!").queue();
return;
}
int boykisserLevel = ratingService.getDeterministicValue(target.getId(), 777, 0, 100);
StringBuilder bar = new StringBuilder();
int filled = boykisserLevel / 10;
for (int i = 0; i < filled; i++) bar.append("💋");
for (int i = filled; i < 10; i++) bar.append("⬜");
RatingLevel level = RatingLevel.findLevel(boykisserLevel, RatingConfig.getBoykisserLevels());
context
.getChannel()
.sendMessageEmbeds(
EmbedFactory.create()
.withTitle("💋 Boykisser Метр")
.withDescription(target.getAsMention())
.addField(
"Уровень", bar + "\n**" + boykisserLevel + "%** - " + level.getComment(), false)
.withThumbnail(target.getUser().getAvatarUrl())
.withColor(level.getColor())
.withFooter("💡 Детерминировано вашим Discord ID")
.build())
.queue();
}
}

View file

@ -0,0 +1,66 @@
package creator.loki.feature.entertainment.commands.rating;
import com.google.inject.Inject;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedFactory;
import creator.loki.feature.entertainment.service.RatingService;
import net.dv8tion.jda.api.entities.Member;
public class FatCommand implements Command {
private final RatingService ratingService;
@Inject
public FatCommand(RatingService ratingService) {
this.ratingService = ratingService;
}
@Override
public String getName() {
return "fat";
}
@Override
public String getDescription() {
return "Проверка на жирность (результат всегда одинаковый)";
}
@Override
public String getUsage() {
return "fat [@user]";
}
@Override
public String getCategory() {
return "Развлечения";
}
@Override
public void execute(CommandContext context) {
if (!ratingService.checkAndApplyCooldown(context, "fat")) return;
Member target = ratingService.resolveTarget(context);
if (target == null) return;
int fatLevel = ratingService.getDeterministicValue(target.getId(), 999, 0, 100);
StringBuilder bar = new StringBuilder();
int filled = fatLevel / 10;
for (int i = 0; i < filled; i++) bar.append("🟩");
for (int i = filled; i < 10; i++) bar.append("⬜");
RatingLevel level = RatingLevel.findLevel(fatLevel, RatingConfig.getFatLevels());
context
.getChannel()
.sendMessageEmbeds(
EmbedFactory.create()
.withTitle("🐷 Fat Метр")
.withDescription(target.getAsMention())
.addField(
"Жирность", bar + "\n**" + fatLevel + "%** - " + level.getComment(), false)
.withThumbnail(target.getUser().getAvatarUrl())
.withColor(level.getColor())
.withFooter("💡 Детерминировано вашим Discord ID")
.build())
.queue();
}
}

View file

@ -0,0 +1,71 @@
package creator.loki.feature.entertainment.commands.rating;
import java.awt.Color;
import com.google.inject.Inject;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedFactory;
import creator.loki.feature.entertainment.service.RatingService;
import net.dv8tion.jda.api.entities.Member;
public class GayCommand implements Command {
private final RatingService ratingService;
@Inject
public GayCommand(RatingService ratingService) {
this.ratingService = ratingService;
}
@Override
public String getName() {
return "gay";
}
@Override
public String getDescription() {
return "Гей-метр (результат всегда одинаковый)";
}
@Override
public String getUsage() {
return "gay [@user]";
}
@Override
public String getCategory() {
return "Развлечения";
}
@Override
public void execute(CommandContext context) {
if (!ratingService.checkAndApplyCooldown(context, "gay")) return;
Member target = ratingService.resolveTarget(context);
if (target == null) return;
if (target.getUser().isBot()) {
context.getChannel().sendMessage("❌ Боты не имеют ориентации!").queue();
return;
}
int gayLevel = ratingService.getDeterministicValue(target.getId(), 456, 0, 100);
String[] rainbow = {"🟥", "🟧", "🟨", "🟩", "🟦", "🟪"};
StringBuilder bar = new StringBuilder();
int filled = Math.min(gayLevel / 17 + 1, 6);
for (int i = 0; i < filled; i++) bar.append(rainbow[i]);
for (int i = filled; i < 6; i++) bar.append("⬜");
context
.getChannel()
.sendMessageEmbeds(
EmbedFactory.create()
.withTitle("🏳️‍🌈 Гей-метр")
.withDescription(target.getAsMention())
.addField("Уровень", bar + "\n**" + gayLevel + "%**", false)
.withThumbnail(target.getUser().getAvatarUrl())
.withColor(new Color(255, 0, 255))
.withFooter("💡 Это просто мем, не воспринимайте серьезно")
.build())
.queue();
}
}

View file

@ -0,0 +1,68 @@
package creator.loki.feature.entertainment.commands.rating;
import com.google.inject.Inject;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedFactory;
import creator.loki.feature.entertainment.service.RatingService;
import net.dv8tion.jda.api.entities.Member;
public class IqCommand implements Command {
private final RatingService ratingService;
@Inject
public IqCommand(RatingService ratingService) {
this.ratingService = ratingService;
}
@Override
public String getName() {
return "iq";
}
@Override
public String getDescription() {
return "Тест IQ (результат всегда одинаковый)";
}
@Override
public String getUsage() {
return "iq [@user]";
}
@Override
public String getCategory() {
return "Развлечения";
}
@Override
public void execute(CommandContext context) {
if (!ratingService.checkAndApplyCooldown(context, "iq")) return;
Member target = ratingService.resolveTarget(context);
if (target == null) return;
int iq;
if (target.getUser().isBot()) {
iq = 9000;
} else if (target.getId().equals("1414975197269987471")) {
iq = 1000;
} else {
iq = ratingService.getDeterministicValue(target.getId(), 123, 0, 140);
}
RatingLevel level = RatingLevel.findLevel(iq, RatingConfig.getIqLevels());
context
.getChannel()
.sendMessageEmbeds(
EmbedFactory.create()
.withTitle("🧠 Тест IQ")
.withDescription("Результат для " + target.getAsMention())
.addField("IQ Уровень", "**" + iq + "** - " + level.getComment(), false)
.withThumbnail(target.getUser().getAvatarUrl())
.withColor(level.getColor())
.withFooter("💡 Научно доказано вашим ID")
.build())
.queue();
}
}

View file

@ -0,0 +1,74 @@
package creator.loki.feature.entertainment.commands.rating;
import com.google.inject.Inject;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedFactory;
import creator.loki.feature.entertainment.service.RatingService;
import net.dv8tion.jda.api.entities.Member;
public class MustacheCommand implements Command {
private final RatingService ratingService;
@Inject
public MustacheCommand(RatingService ratingService) {
this.ratingService = ratingService;
}
@Override
public String getName() {
return "mustache";
}
@Override
public String getDescription() {
return "Проверка на усатость (результат всегда одинаковый)";
}
@Override
public String getUsage() {
return "mustache [@user]";
}
@Override
public String getCategory() {
return "Развлечения";
}
@Override
public void execute(CommandContext context) {
if (!ratingService.checkAndApplyCooldown(context, "mustache")) return;
Member target = ratingService.resolveTarget(context);
if (target == null) return;
int mustacheLevel;
if (target.getUser().isBot() || target.getId().equals("1414975197269987471")) {
mustacheLevel = 0;
} else if (target.getId().equals("678283217094180915")) {
mustacheLevel = 1000;
} else {
mustacheLevel = ratingService.getDeterministicValue(target.getId(), 888, 0, 100);
}
StringBuilder bar = new StringBuilder();
int filled = Math.min(mustacheLevel / 10, 10);
for (int i = 0; i < filled; i++) bar.append("🧔");
for (int i = filled; i < 10; i++) bar.append("⬜");
RatingLevel level = RatingLevel.findLevel(mustacheLevel, RatingConfig.getMustacheLevels());
context
.getChannel()
.sendMessageEmbeds(
EmbedFactory.create()
.withTitle("🧔 Mustache Метр")
.withDescription(target.getAsMention())
.addField(
"Усатость", bar + "\n**" + mustacheLevel + "%** - " + level.getComment(), false)
.withThumbnail(target.getUser().getAvatarUrl())
.withColor(level.getColor())
.withFooter("💡 Детерминировано вашим Discord ID")
.build())
.queue();
}
}

View file

@ -0,0 +1,77 @@
package creator.loki.feature.entertainment.commands.rating;
import com.google.inject.Inject;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedFactory;
import creator.loki.feature.entertainment.service.RatingService;
import net.dv8tion.jda.api.entities.Member;
public class PpCommand implements Command {
private final RatingService ratingService;
@Inject
public PpCommand(RatingService ratingService) {
this.ratingService = ratingService;
}
@Override
public String getName() {
return "pp";
}
@Override
public String getDescription() {
return "Измеритель pp (результат всегда одинаковый)";
}
@Override
public String getUsage() {
return "pp [@user]";
}
@Override
public String getCategory() {
return "Развлечения";
}
@Override
public void execute(CommandContext context) {
if (!ratingService.checkAndApplyCooldown(context, "pp")) return;
Member target = ratingService.resolveTarget(context);
if (target == null) return;
if (target.getUser().isBot()) {
context.getChannel().sendMessage("❌ У ботов нет pp!").queue();
return;
}
int size;
if (target.getId().equals("1414975197269987471")) {
size = 100;
} else {
size = ratingService.getDeterministicValue(target.getId(), 6969, 0, 30);
}
StringBuilder ppVisual = new StringBuilder("8");
for (int i = 0; i < size; i++) ppVisual.append("=");
ppVisual.append("D");
RatingLevel level = RatingLevel.findLevel(size, RatingConfig.getPpLevels());
context
.getChannel()
.sendMessageEmbeds(
EmbedFactory.create()
.withTitle("🍆 PP Размер")
.withDescription(target.getAsMention())
.addField(
"Размер",
"```" + ppVisual + "```\n**" + size + " см** - " + level.getComment(),
false)
.withThumbnail(target.getUser().getAvatarUrl())
.withColor(level.getColor())
.withFooter("💡 Детерминировано вашим Discord ID")
.build())
.queue();
}
}

View file

@ -1,24 +1,23 @@
package creator.loki.feature.entertainment.commands;
package creator.loki.feature.entertainment.commands.rating;
import java.awt.Color;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import com.google.inject.Inject;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.core.security.CooldownManager;
import creator.loki.embed.EmbedFactory;
import creator.loki.feature.entertainment.service.RatingService;
import net.dv8tion.jda.api.entities.Member;
/** Оценка пользователя из 10 с детерминированным результатом */
public class RateCommand implements Command {
private final CooldownManager cooldownManager;
private final RatingService ratingService;
public RateCommand(CooldownManager cooldownManager) {
this.cooldownManager = cooldownManager;
@Inject
public RateCommand(RatingService ratingService) {
this.ratingService = ratingService;
}
@Override
@ -43,28 +42,13 @@ public class RateCommand implements Command {
@Override
public void execute(CommandContext context) {
// Проверка cooldown (5 секунд)
if (cooldownManager.isOnCooldown(context.getAuthor().getId(), "rate")) {
long remaining = cooldownManager.getRemainingSeconds(context.getAuthor().getId(), "rate");
context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue();
return;
}
List<Member> mentioned = context.getMessage().getMentions().getMembers();
Member target = mentioned.isEmpty() ? context.getAuthor() : mentioned.get(0);
if (!ratingService.checkAndApplyCooldown(context, "rate")) return;
Member target = ratingService.resolveTarget(context);
if (target == null) return;
// Применяем cooldown
cooldownManager.applyCooldown(context.getAuthor().getId(), "rate", 5);
// Детерминированная оценка от 1 до 10
int rating = getDeterministicValue(target.getId(), 420, 1, 10);
// Визуализация звездами
int rating = ratingService.getDeterministicValue(target.getId(), 420, 1, 10);
String stars = "⭐".repeat(rating) + "☆".repeat(10 - rating);
// Комментарий
Map<Integer, String> comments = new HashMap<>();
comments.put(10, "💯 ИДЕАЛЬНО!");
comments.put(9, "🔥 Почти идеально!");
@ -92,12 +76,4 @@ public class RateCommand implements Command {
.build())
.queue();
}
private static final Random RANDOM = new Random();
private int getDeterministicValue(String userId, int salt, int min, int max) {
long seed = Long.parseLong(userId) + salt;
RANDOM.setSeed(seed);
return RANDOM.nextInt(max - min + 1) + min;
}
}

View file

@ -0,0 +1,81 @@
package creator.loki.feature.entertainment.commands.rating;
import java.awt.Color;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class RatingConfig {
private static final Map<String, List<RatingLevel>> LEVELS;
static {
var m = new LinkedHashMap<String, List<RatingLevel>>();
m.put(
"mustache",
List.of(
new RatingLevel(1000, "🎩 ЛЕГЕНДАРНЫЕ УСИЩИ!", new Color(255, 215, 0)),
new RatingLevel(80, "🧔 Роскошные усы!", new Color(139, 69, 19)),
new RatingLevel(60, "👨 Хорошие усы", new Color(160, 82, 45)),
new RatingLevel(40, "😊 Усики растут", new Color(205, 133, 63)),
new RatingLevel(20, "🙂 Лёгкая щетина", new Color(210, 180, 140)),
new RatingLevel(1, "😐 Почти нет усов", new Color(128, 128, 128)),
new RatingLevel(0, "🪒 Гладко выбрит", new Color(200, 200, 200))));
m.put(
"iq",
List.of(
new RatingLevel(140, "🧠 Гений!", new Color(255, 215, 0)),
new RatingLevel(120, "🎓 Очень умный", new Color(100, 255, 150)),
new RatingLevel(100, "📚 Средний", new Color(52, 152, 219)),
new RatingLevel(80, "📖 Ниже среднего", new Color(255, 165, 0)),
new RatingLevel(0, "🤔 Низкий", new Color(231, 76, 60))));
m.put(
"fat",
List.of(
new RatingLevel(90, "🐷 СВИНОТА!", new Color(255, 0, 0)),
new RatingLevel(70, "🍔 Очень жирный", new Color(255, 100, 0)),
new RatingLevel(50, "🍕 Жирноват", new Color(255, 165, 0)),
new RatingLevel(30, "👍 Нормальный вес", new Color(0, 200, 0)),
new RatingLevel(10, "🥗 Худой", new Color(100, 200, 255)),
new RatingLevel(0, "💀 Очень худой", new Color(150, 150, 150))));
m.put(
"pp",
List.of(
new RatingLevel(25, "😳 НЕВЕРОЯТНО!", new Color(255, 215, 0)),
new RatingLevel(20, "😮 Впечатляет", new Color(100, 255, 150)),
new RatingLevel(15, "👍 Выше среднего", new Color(52, 152, 219)),
new RatingLevel(10, "🙂 Средний", new Color(128, 128, 128)),
new RatingLevel(5, "😐 Ниже среднего", new Color(255, 165, 0)),
new RatingLevel(0, "💀 Микро", new Color(231, 76, 60))));
m.put(
"boykisser",
List.of(
new RatingLevel(90, "💋 ЛЕГЕНДАРНЫЙ BOYKISSER!", new Color(255, 105, 180)),
new RatingLevel(70, "😘 Активный boykisser", new Color(255, 182, 193)),
new RatingLevel(50, "😊 Любит целоваться", new Color(255, 192, 203)),
new RatingLevel(30, "🙂 Иногда целуется", new Color(255, 218, 185)),
new RatingLevel(10, "😐 Редко целуется", new Color(176, 196, 222)),
new RatingLevel(0, "😶 Не целуется", new Color(128, 128, 128))));
LEVELS = Collections.unmodifiableMap(m);
}
public static List<RatingLevel> getMustacheLevels() {
return LEVELS.get("mustache");
}
public static List<RatingLevel> getIqLevels() {
return LEVELS.get("iq");
}
public static List<RatingLevel> getFatLevels() {
return LEVELS.get("fat");
}
public static List<RatingLevel> getPpLevels() {
return LEVELS.get("pp");
}
public static List<RatingLevel> getBoykisserLevels() {
return LEVELS.get("boykisser");
}
}

View file

@ -1,4 +1,4 @@
package creator.loki.feature.entertainment.commands;
package creator.loki.feature.entertainment.commands.rating;
import java.awt.Color;
import java.util.List;

View file

@ -0,0 +1,47 @@
package creator.loki.feature.entertainment.service;
import java.time.Duration;
import java.util.List;
import java.util.Random;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import creator.loki.command.CommandContext;
import creator.loki.core.security.CooldownManager;
import net.dv8tion.jda.api.entities.Member;
@Singleton
public class RatingService {
private static final Random RANDOM = new Random();
private static final Duration COOLDOWN = Duration.ofSeconds(5);
private final CooldownManager cooldownManager;
@Inject
public RatingService(CooldownManager cooldownManager) {
this.cooldownManager = cooldownManager;
}
public int getDeterministicValue(String userId, int salt, int min, int max) {
long seed = Long.parseLong(userId) + salt;
RANDOM.setSeed(seed);
return RANDOM.nextInt(max - min + 1) + min;
}
public Member resolveTarget(CommandContext context) {
List<Member> mentioned = context.getMessage().getMentions().getMembers();
return mentioned.isEmpty() ? context.getAuthor() : mentioned.get(0);
}
public boolean checkAndApplyCooldown(CommandContext context, String commandName) {
if (cooldownManager.isOnCooldown(context.getAuthor().getId(), commandName)) {
long remaining =
cooldownManager.getRemainingSeconds(context.getAuthor().getId(), commandName);
context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue();
return false;
}
cooldownManager.applyCooldown(
context.getAuthor().getId(), commandName, (int) COOLDOWN.toSeconds());
return true;
}
}

View file

@ -13,10 +13,10 @@ import creator.loki.core.registry.CommandRegistry;
import creator.loki.embed.EmbedFactory;
import creator.loki.embed.HelpCategories;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import net.dv8tion.jda.api.components.actionrow.ActionRow;
import net.dv8tion.jda.api.components.selections.StringSelectMenu;
import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
@Singleton
public class HelpMenuHandler extends ListenerAdapter {

View file

@ -12,9 +12,9 @@ import creator.loki.core.registry.CommandRegistry;
import creator.loki.embed.EmbedFactory;
import creator.loki.embed.HelpCategories;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.User;
import net.dv8tion.jda.api.components.actionrow.ActionRow;
import net.dv8tion.jda.api.components.selections.StringSelectMenu;
import net.dv8tion.jda.api.entities.User;
public class HelpCommand implements Command {
private final CommandRegistry registry;
@ -75,7 +75,11 @@ public class HelpCommand implements Command {
net.dv8tion.jda.api.entities.emoji.Emoji.fromUnicode(emoji));
});
context.getChannel().sendMessageEmbeds(embed.build()).addComponents(ActionRow.of(menuBuilder.build())).queue();
context
.getChannel()
.sendMessageEmbeds(embed.build())
.addComponents(ActionRow.of(menuBuilder.build()))
.queue();
}
private EmbedFactory createMainPage(

View file

@ -5,7 +5,6 @@ import java.util.concurrent.ThreadLocalRandom;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import creator.loki.core.EventDispatcher;
import creator.loki.embed.EmbedTemplates;
import creator.loki.feature.economy.service.EconomyService;
import creator.loki.feature.level.domain.LevelUpResult;
import creator.loki.feature.level.service.LevelService;
@ -20,13 +19,16 @@ public class MessageListener extends ListenerAdapter {
private final EventDispatcher dispatcher;
private final LevelService levelService;
private final EconomyService economyService;
private final LevelUpNotifier levelUpNotifier;
@Inject
public MessageListener(
EventDispatcher dispatcher, LevelService levelService, EconomyService economyService) {
EventDispatcher dispatcher, LevelService levelService,
EconomyService economyService, LevelUpNotifier levelUpNotifier) {
this.dispatcher = dispatcher;
this.levelService = levelService;
this.economyService = economyService;
this.levelUpNotifier = levelUpNotifier;
}
@Override
@ -35,19 +37,10 @@ public class MessageListener extends ListenerAdapter {
if (!event.getAuthor().isBot()) {
String userId = event.getAuthor().getId();
LevelUpResult levelUp = levelService.addMessageXP(userId);
if (levelUp != null) {
event
.getChannel()
.sendMessageEmbeds(
EmbedTemplates.success(
"🎉 Повышение уровня!",
event.getAuthor().getAsMention()
+ " достиг **"
+ levelUp.getNewLevel()
+ " уровня**! Всего XP: **"
+ levelUp.getTotalXP()
+ "**"))
.queue();
if (levelUp != null && event.getMember() != null) {
levelUpNotifier.sendLevelUpNotification(
event.getMember(), levelUp.getOldLevel(), levelUp.getNewLevel(),
event.getChannel());
}
int lcAmount = ThreadLocalRandom.current().nextInt(1, 4);
economyService.earnCash(userId, lcAmount);

View file

@ -11,10 +11,12 @@ import net.dv8tion.jda.api.hooks.ListenerAdapter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/** Обработчик кнопок подтверждения сброса уровней */
@Singleton
public class ResetLevelsButtonHandler extends ListenerAdapter {
private static final Logger logger = LoggerFactory.getLogger(ResetLevelsButtonHandler.class);
private static final String PREFIX_CONFIRM = "reset_levels_confirm:";
private static final String PREFIX_CANCEL = "reset_levels_cancel:";
private final ResetLevelsCommand resetCommand;
@Inject
@ -26,87 +28,47 @@ public class ResetLevelsButtonHandler extends ListenerAdapter {
public void onButtonInteraction(ButtonInteractionEvent event) {
String buttonId = event.getComponentId();
// Проверяем что это наша кнопка
if (!buttonId.startsWith("reset_levels_")) {
return;
}
// Извлекаем ID пользователя из кнопки
String[] parts = buttonId.split(":");
if (parts.length != 2) {
return;
}
String authorId = parts[1];
// Проверяем что кнопку нажал тот же пользователь
if (!event.getUser().getId().equals(authorId)) {
event
.reply("❌ Только инициатор команды может подтвердить действие!")
.setEphemeral(true)
.queue();
return;
}
if (buttonId.startsWith("reset_levels_confirm:")) {
// Подтверждение
if (buttonId.startsWith(PREFIX_CONFIRM)) {
String authorId = buttonId.substring(PREFIX_CONFIRM.length());
if (authorId.isEmpty()) return;
if (!event.getUser().getId().equals(authorId)) {
event.reply("❌ Только инициатор команды может подтвердить действие!").setEphemeral(true).queue();
return;
}
event.deferReply().queue();
try {
String guildId = event.getGuild().getId();
String userId = event.getUser().getId();
// Выполняем сброс
resetCommand.executeReset(guildId, userId);
// Удаляем исходное сообщение с кнопками
resetCommand.executeReset(event.getGuild().getId(), event.getUser().getId());
event.getMessage().delete().queue();
// Отправляем подтверждение
event
.getHook()
.sendMessageEmbeds(
EmbedFactory.create()
.withTitle("✅ Сброс уровней выполнен")
.withDescription(
"Уровни и XP всех пользователей успешно сброшены!\n\n"
+ "**Сохранено:**\n"
+ "• Время в голосовых каналах\n"
+ "• Количество сообщений\n"
+ "• Репутация\n\n"
+ "**Сброшено:**\n"
+ "• Уровень → 0\n"
+ "• XP → 0\n\n"
+ "Теперь используется новая система уровней!")
.withColor(Color.GREEN)
.withFooter(
"Инициатор: " + event.getUser().getName(), event.getUser().getAvatarUrl())
.build())
.queue();
LoggerDecorator.bot(
logger, "Сброс уровней подтверждён пользователем: " + event.getUser().getName());
event.getHook().sendMessageEmbeds(
EmbedFactory.create()
.withTitle("✅ Сброс уровней выполнен")
.withDescription("Уровни и XP всех пользователей успешно сброшены!\n\n"
+ "**Сохранено:**\n• Время в голосовых каналах\n"
+ "• Количество сообщений\n• Репутация\n\n"
+ "**Сброшено:**\n• Уровень → 0\n• XP → 0\n\n"
+ "Теперь используется новая система уровней!")
.withColor(Color.GREEN)
.withFooter("Инициатор: " + event.getUser().getName(), event.getUser().getAvatarUrl())
.build()).queue();
LoggerDecorator.bot(logger, "Сброс уровней подтверждён: " + event.getUser().getName());
} catch (Exception e) {
LoggerDecorator.error(logger, "Ошибка при выполнении сброса", e);
event
.getHook()
.sendMessageEmbeds(
EmbedFactory.create()
.withTitle("❌ Ошибка")
.withDescription("Произошла ошибка при сбросе уровней: " + e.getMessage())
.withColor(Color.RED)
.build())
.queue();
event.getHook().sendMessageEmbeds(
EmbedFactory.create()
.withTitle("❌ Ошибка")
.withDescription("Произошла ошибка при сбросе уровней: " + e.getMessage())
.withColor(Color.RED).build()).queue();
}
} else if (buttonId.startsWith(PREFIX_CANCEL)) {
String authorId = buttonId.substring(PREFIX_CANCEL.length());
if (authorId.isEmpty()) return;
if (!event.getUser().getId().equals(authorId)) {
event.reply("❌ Только инициатор команды может отменить действие!").setEphemeral(true).queue();
return;
}
} else if (buttonId.startsWith("reset_levels_cancel:")) {
// Отмена
event.getMessage().delete().queue();
event.reply("❌ Сброс уровней отменён.").setEphemeral(true).queue();
LoggerDecorator.bot(
logger, "Сброс уровней отменён пользователем: " + event.getUser().getName());
LoggerDecorator.bot(logger, "Сброс уровней отменён: " + event.getUser().getName());
}
}
}

View file

@ -9,6 +9,7 @@ import java.util.concurrent.TimeUnit;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import creator.loki.feature.economy.service.EconomyService;
import creator.loki.feature.level.domain.LevelUpResult;
import creator.loki.feature.level.service.LevelService;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent;
@ -16,21 +17,22 @@ import net.dv8tion.jda.api.hooks.ListenerAdapter;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/** Отслеживание времени в голосовых каналах */
@Singleton
public class VoiceTracker extends ListenerAdapter {
private static final Logger logger = LoggerFactory.getLogger(VoiceTracker.class);
private final LevelService levelService;
private final EconomyService economyService;
private final LevelUpNotifier levelUpNotifier;
private final Map<String, Long> voiceJoinTimes = new ConcurrentHashMap<>();
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
private final Object lock = new Object();
@Inject
public VoiceTracker(LevelService levelService, EconomyService economyService) {
public VoiceTracker(LevelService levelService, EconomyService economyService,
LevelUpNotifier levelUpNotifier) {
this.levelService = levelService;
this.economyService = economyService;
// Каждую минуту начисляем XP тем, кто в войсе
this.levelUpNotifier = levelUpNotifier;
scheduler.scheduleAtFixedRate(
() -> Thread.startVirtualThread(this::updateVoiceTime), 1, 1, TimeUnit.MINUTES);
}
@ -39,66 +41,48 @@ public class VoiceTracker extends ListenerAdapter {
public void onGuildVoiceUpdate(GuildVoiceUpdateEvent event) {
Member member = event.getMember();
String userId = member.getId();
if (member.getUser().isBot()) return;
// Игнорируем ботов
if (member.getUser().isBot()) {
return;
}
// Пользователь зашёл в войс
if (event.getChannelJoined() != null && event.getChannelLeft() == null) {
voiceJoinTimes.put(userId, System.currentTimeMillis());
logger.debug("Пользователь {} зашёл в войс", member.getEffectiveName());
} else if (event.getChannelLeft() != null && event.getChannelJoined() == null) {
// Пользователь вышел из войса
Long joinTime = voiceJoinTimes.remove(userId);
Long joinTime;
synchronized (lock) { joinTime = voiceJoinTimes.remove(userId); }
if (joinTime != null) {
long timeSpent = System.currentTimeMillis() - joinTime;
long minutes = TimeUnit.MILLISECONDS.toMinutes(timeSpent);
long minutes = TimeUnit.MILLISECONDS.toMinutes(System.currentTimeMillis() - joinTime);
if (minutes > 0) {
levelService.addVoiceXP(userId, minutes);
LevelUpResult lr = levelService.addVoiceXP(userId, minutes);
economyService.earnCash(userId, minutes / 2);
logger.debug(
"Пользователь {} провёл в войсе {} минут", member.getEffectiveName(), minutes);
if (lr != null && event.getMember() != null) {
levelUpNotifier.sendLevelUpNotification(
event.getMember(), lr.getOldLevel(), lr.getNewLevel(),
event.getGuild().getSystemChannel());
}
}
}
} else if (event.getChannelJoined() != null && event.getChannelLeft() != null) {
// Пользователь переключился между каналами
// Обновляем время входа
voiceJoinTimes.put(userId, System.currentTimeMillis());
synchronized (lock) { voiceJoinTimes.put(userId, System.currentTimeMillis()); }
}
}
/** Периодическое обновление времени для тех, кто долго в войсе */
private void updateVoiceTime() {
long now = System.currentTimeMillis();
for (Map.Entry<String, Long> entry : voiceJoinTimes.entrySet()) {
String userId = entry.getKey();
long joinTime = entry.getValue();
long timeSpent = now - joinTime;
long minutes = TimeUnit.MILLISECONDS.toMinutes(timeSpent);
if (minutes >= 1) {
// Начисляем XP за каждую минуту
levelService.addVoiceXP(userId, 1);
economyService.earnCash(userId, 1);
// Обновляем время входа
voiceJoinTimes.put(userId, now);
synchronized (lock) {
for (Map.Entry<String, Long> entry : voiceJoinTimes.entrySet()) {
String userId = entry.getKey();
long minutes = TimeUnit.MILLISECONDS.toMinutes(now - entry.getValue());
if (minutes >= 1) {
levelService.addVoiceXP(userId, 1);
economyService.earnCash(userId, 1);
entry.setValue(now);
}
}
}
}
public void shutdown() {
scheduler.shutdown();
try {
if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
scheduler.shutdownNow();
}
} catch (InterruptedException e) {
scheduler.shutdownNow();
Thread.currentThread().interrupt();
}
try { if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) scheduler.shutdownNow(); }
catch (InterruptedException e) { scheduler.shutdownNow(); Thread.currentThread().interrupt(); }
}
}

View file

@ -36,6 +36,7 @@ public class LevelService {
return null;
}
cooldownService.markMessageSent(userId);
UserStats stats = repository.getUserStats(userId);
long now = System.currentTimeMillis();
int oldLevel = stats.getLevel();

View file

@ -1,55 +1,45 @@
package creator.loki.feature.level.service;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import creator.loki.feature.level.domain.UserStats;
import creator.loki.feature.level.repository.UserStatsRepository;
/**
* Сервис для управления кулдауном сообщений Использует персистентное хранение через
* UserStats.lastMessageTime
*/
public class MessageCooldownService {
private static final long MESSAGE_COOLDOWN_MS = 60000; // 1 минута
private static final long MESSAGE_COOLDOWN_MS = 60000;
private final UserStatsRepository repository;
private final Map<String, Long> lastMessageTimes = new ConcurrentHashMap<>();
public MessageCooldownService(UserStatsRepository repository) {
this.repository = repository;
}
/**
* Проверить, может ли пользователь получить XP за сообщение
*
* @param userId ID пользователя
* @return true если кулдаун прошел
*/
public boolean canReceiveXP(String userId) {
UserStats stats = repository.getUserStats(userId);
long now = System.currentTimeMillis();
long lastMessage = stats.getLastMessageTime();
return lastMessage == 0 || (now - lastMessage) >= MESSAGE_COOLDOWN_MS;
Long lastTime = lastMessageTimes.get(userId);
if (lastTime != null && (now - lastTime) < MESSAGE_COOLDOWN_MS) return false;
if (lastTime != null) return true;
long dbTime = repository.getUserStats(userId).getLastMessageTime();
if (dbTime != 0) lastMessageTimes.put(userId, dbTime);
return dbTime == 0 || (now - dbTime) >= MESSAGE_COOLDOWN_MS;
}
public void markMessageSent(String userId) {
lastMessageTimes.put(userId, System.currentTimeMillis());
}
/**
* Получить оставшееся время кулдауна в секундах
*
* @param userId ID пользователя
* @return секунды до окончания кулдауна, 0 если кулдаун прошел
*/
public long getRemainingCooldown(String userId) {
UserStats stats = repository.getUserStats(userId);
long now = System.currentTimeMillis();
long lastMessage = stats.getLastMessageTime();
if (lastMessage == 0) {
return 0;
Long lastTime = lastMessageTimes.get(userId);
if (lastTime == null) {
long dbTime = repository.getUserStats(userId).getLastMessageTime();
if (dbTime == 0) return 0;
lastMessageTimes.put(userId, dbTime);
lastTime = dbTime;
}
long elapsed = now - lastMessage;
if (elapsed >= MESSAGE_COOLDOWN_MS) {
return 0;
}
return (MESSAGE_COOLDOWN_MS - elapsed) / 1000;
long elapsed = now - lastTime;
return elapsed >= MESSAGE_COOLDOWN_MS ? 0 : (MESSAGE_COOLDOWN_MS - elapsed) / 1000;
}
}

View file

@ -10,16 +10,10 @@ import creator.loki.util.LoggerDecorator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/** Сборщик метрик для мониторинга бота */
public class MetricsCollector {
private static final Logger logger = LoggerFactory.getLogger(MetricsCollector.class);
// Счётчики команд
private final ConcurrentHashMap<String, LongAdder> commandCounts = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, LongAdder> commandErrors = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, AtomicLong> commandTotalTime = new ConcurrentHashMap<>();
// Общие метрики
private final ConcurrentHashMap<String, CommandEntry> commands = new ConcurrentHashMap<>();
private final LongAdder totalCommands = new LongAdder();
private final LongAdder totalErrors = new LongAdder();
private final AtomicLong startTime = new AtomicLong(System.currentTimeMillis());
@ -28,79 +22,60 @@ public class MetricsCollector {
LoggerDecorator.bot(logger, "MetricsCollector инициализирован");
}
/** Записывает выполнение команды */
public void recordCommand(String commandName, long executionTimeMs) {
commandCounts.computeIfAbsent(commandName, k -> new LongAdder()).increment();
commandTotalTime.computeIfAbsent(commandName, k -> new AtomicLong()).addAndGet(executionTimeMs);
var entry = commands.computeIfAbsent(commandName, k -> new CommandEntry());
entry.count.increment();
entry.totalTime.addAndGet(executionTimeMs);
totalCommands.increment();
}
/** Записывает ошибку команды */
public void recordError(String commandName) {
commandErrors.computeIfAbsent(commandName, k -> new LongAdder()).increment();
var entry = commands.computeIfAbsent(commandName, k -> new CommandEntry());
entry.errors.increment();
totalErrors.increment();
}
/** Возвращает количество выполнений команды */
public long getCommandCount(String commandName) {
LongAdder counter = commandCounts.get(commandName);
return counter != null ? counter.sum() : 0;
var entry = commands.get(commandName);
return entry != null ? entry.count.sum() : 0;
}
/** Возвращает количество ошибок команды */
public long getCommandErrors(String commandName) {
LongAdder counter = commandErrors.get(commandName);
return counter != null ? counter.sum() : 0;
var entry = commands.get(commandName);
return entry != null ? entry.errors.sum() : 0;
}
/** Возвращает среднее время выполнения команды */
public double getAverageExecutionTime(String commandName) {
long count = getCommandCount(commandName);
if (count == 0) {
return 0.0;
}
AtomicLong totalTime = commandTotalTime.get(commandName);
if (totalTime == null) {
return 0.0;
}
return (double) totalTime.get() / count;
var entry = commands.get(commandName);
var count = entry != null ? entry.count.sum() : 0;
return count == 0 ? 0.0 : (double) entry.totalTime.get() / count;
}
/** Возвращает общее количество выполненных команд */
public long getTotalCommands() {
return totalCommands.sum();
}
/** Возвращает общее количество ошибок */
public long getTotalErrors() {
return totalErrors.sum();
}
/** Возвращает процент ошибок */
public double getErrorRate() {
long total = getTotalCommands();
if (total == 0) {
return 0.0;
}
return (double) getTotalErrors() / total * 100;
return getTotalCommands() == 0 ? 0.0 : (double) getTotalErrors() / getTotalCommands() * 100;
}
/** Возвращает время работы бота в секундах */
public long getUptimeSeconds() {
return (System.currentTimeMillis() - startTime.get()) / 1000;
}
/** JSON для {@code GET /health} (пробы оркестраторов). */
public String buildHealthJson() {
String version =
Optional.ofNullable(MetricsCollector.class.getPackage().getImplementationVersion())
.filter(v -> !v.isBlank())
.orElse("dev");
return String.format(
Locale.ROOT,
"{\"status\":\"ok\",\"version\":%s,\"uptimeSeconds\":%d,\"commandsRun\":%d,"
+ "\"errors\":%d,\"errorRatePercent\":%.4f}",
jsonQuoted(version),
jsonQuoted(
Optional.ofNullable(MetricsCollector.class.getPackage().getImplementationVersion())
.filter(v -> !v.isBlank())
.orElse("dev")),
getUptimeSeconds(),
getTotalCommands(),
getTotalErrors(),
@ -108,75 +83,61 @@ public class MetricsCollector {
}
private static String jsonQuoted(String s) {
StringBuilder sb = new StringBuilder(s.length() + 2);
sb.append('"');
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
switch (c) {
case '\\' -> sb.append("\\\\");
case '"' -> sb.append("\\\"");
case '\n' -> sb.append("\\n");
case '\r' -> sb.append("\\r");
case '\t' -> sb.append("\\t");
default -> sb.append(c < ' ' ? ' ' : c);
}
}
sb.append('"');
return sb.toString();
return '"'
+ s.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
+ '"';
}
/** Возвращает топ команд по использованию */
public String getTopCommands(int limit) {
StringBuilder sb = new StringBuilder();
var sb = new StringBuilder();
sb.append("📊 Топ команд:\n");
commandCounts.entrySet().stream()
.sorted((e1, e2) -> Long.compare(e2.getValue().sum(), e1.getValue().sum()))
commands.entrySet().stream()
.sorted((e1, e2) -> Long.compare(e2.getValue().count.sum(), e1.getValue().count.sum()))
.limit(limit)
.forEach(
entry -> {
String cmd = entry.getKey();
long count = entry.getValue().sum();
long errors = getCommandErrors(cmd);
double avgTime = getAverageExecutionTime(cmd);
var ce = entry.getValue();
var count = ce.count.sum();
var avgTime = count == 0 ? 0.0 : (double) ce.totalTime.get() / count;
sb.append(
String.format(
" %s: %d выполнений, %.1f мс среднее, %d ошибок%n",
cmd, count, avgTime, errors));
entry.getKey(), count, avgTime, ce.errors.sum()));
});
return sb.toString();
}
/** Возвращает полную статистику */
public String getFullStats() {
long uptime = getUptimeSeconds();
long hours = uptime / 3600;
long minutes = (uptime % 3600) / 60;
long seconds = uptime % 60;
StringBuilder sb = new StringBuilder();
var sb = new StringBuilder();
sb.append("📈 Статистика бота\n");
sb.append("━━━━━━━━━━━━━━━━━━━━\n");
sb.append(String.format("⏱️ Uptime: %dч %dм %dс%n", hours, minutes, seconds));
sb.append(
String.format(
"⏱️ Uptime: %dч %dм %dс%n",
getUptimeSeconds() / 3600, (getUptimeSeconds() % 3600) / 60, getUptimeSeconds() % 60));
sb.append(String.format("📝 Всего команд: %d%n", getTotalCommands()));
sb.append(String.format("❌ Всего ошибок: %d (%.2f%%)%n", getTotalErrors(), getErrorRate()));
sb.append(String.format("📊 Уникальных команд: %d%n", commandCounts.size()));
sb.append(String.format("📊 Уникальных команд: %d%n", commands.size()));
sb.append("\n");
sb.append(getTopCommands(10));
return sb.toString();
}
/** Сбрасывает все метрики */
public void reset() {
commandCounts.clear();
commandErrors.clear();
commandTotalTime.clear();
commands.clear();
totalCommands.reset();
totalErrors.reset();
startTime.set(System.currentTimeMillis());
LoggerDecorator.bot(logger, "Метрики сброшены");
}
private static class CommandEntry {
final LongAdder count = new LongAdder();
final LongAdder errors = new LongAdder();
final AtomicLong totalTime = new AtomicLong();
}
}

View file

@ -6,12 +6,13 @@ import creator.loki.core.registry.CommandRegistry;
import creator.loki.feature.config.repository.ConfigRepository;
import creator.loki.feature.config.repository.GuildConfigRepository;
import creator.loki.feature.logging.LogSetupPrefixCommand;
import creator.loki.feature.moderation.commands.caselookup.CaseLookupCommand;
import creator.loki.feature.moderation.commands.punish.MuteCommand;
import creator.loki.feature.moderation.commands.punish.PunishCommand;
import creator.loki.feature.moderation.commands.punish.UnmuteCommand;
import creator.loki.feature.moderation.commands.swap.SwapCommand;
import creator.loki.feature.moderation.commands.utils.ClearCommand;
import creator.loki.feature.moderation.commands.utils.SlowmodeCommand;
import creator.loki.feature.moderation.commands.utils.SwapAndCaseCommand;
import creator.loki.feature.moderation.commands.warn.UnwarnCommand;
import creator.loki.feature.moderation.commands.warn.WarnCommand;
import creator.loki.feature.moderation.commands.warn.WarningsHistoryCommand;
@ -84,8 +85,8 @@ public class ModerationCommandRegistrar implements CommandRegistrar {
registry.register(new WarningsHistoryCommand(warningRepository));
registry.register(new ClearCommand());
registry.register(new SlowmodeCommand());
registry.register(SwapAndCaseCommand.createSwap());
registry.register(SwapAndCaseCommand.createCaseLookup(moderationCaseRepository));
registry.register(new SwapCommand());
registry.register(new CaseLookupCommand(moderationCaseRepository));
registry.register(new CreateRoleCommand(roleManager));
registry.register(new DeleteRoleCommand(roleManager));

View file

@ -0,0 +1,80 @@
package creator.loki.feature.moderation.commands.caselookup;
import java.awt.Color;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedFactory;
import creator.loki.feature.moderation.repository.ModerationCaseRepository;
import creator.loki.feature.moderation.util.ModerationHelper;
import net.dv8tion.jda.api.Permission;
public class CaseLookupCommand implements Command {
private final ModerationCaseRepository cases;
public CaseLookupCommand(ModerationCaseRepository cr) {
cases = cr;
}
@Override
public String getName() {
return "case";
}
@Override
public String getDescription() {
return "Показать кейс модерации по ID";
}
@Override
public String getUsage() {
return "case <id>";
}
@Override
public String getCategory() {
return "Модерация";
}
@Override
public void execute(CommandContext ctx) {
var g = ctx.getGuild();
if (g == null) {
ModerationHelper.error(ctx, "Команда только на сервере");
return;
}
var mod = ctx.getAuthor();
if (mod == null || !mod.hasPermission(Permission.KICK_MEMBERS)) {
ModerationHelper.error(ctx, "Нужно **Kick Members** или выше");
return;
}
if (ctx.getArgs().isEmpty()) {
ModerationHelper.error(ctx, "Использование: `" + getUsage() + "`");
return;
}
long caseId;
try {
caseId = Long.parseLong(ctx.getArgs().get(0).trim());
} catch (NumberFormatException e) {
ModerationHelper.error(ctx, "ID кейса должен быть числом");
return;
}
cases
.findById(g.getId(), caseId)
.ifPresentOrElse(
c ->
ctx.getChannel()
.sendMessageEmbeds(
EmbedFactory.create()
.withTitle("Кейс #" + c.id())
.withColor(Color.BLUE)
.addField("Действие", "`" + c.action() + "`", true)
.addField("Цель", "<@" + c.targetId() + ">", true)
.addField("Модератор", "<@" + c.moderatorId() + ">", true)
.addField("Причина", ModerationHelper.trunc(c.reason(), 1000), false)
.addField("Время", "<t:" + (c.createdAt() / 1000) + ":F>", false)
.build())
.queue(),
() -> ModerationHelper.error(ctx, "Кейса `" + caseId + "` на этом сервере нет"));
}
}

View file

@ -6,8 +6,8 @@ import java.time.OffsetDateTime;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedFactory;
import creator.loki.embed.EmbedTemplates;
import creator.loki.feature.config.repository.ConfigRepository;
import creator.loki.feature.moderation.util.ModerationHelper;
import creator.loki.feature.moderation.MuteRoleService;
import creator.loki.feature.moderation.MuteScheduler;
import creator.loki.feature.moderation.repository.ActiveMuteRepository;
@ -190,10 +190,10 @@ public class MuteCommand implements Command {
}
private static String sub(String s, int max) {
return s.length() <= max ? s : s.substring(0, max - 1) + "…";
return ModerationHelper.trunc(s, max);
}
private static void err(CommandContext ctx, String msg) {
ctx.getChannel().sendMessageEmbeds(EmbedTemplates.error("Ошибка", msg)).queue();
ModerationHelper.error(ctx, msg);
}
}

View file

@ -5,6 +5,7 @@ import java.util.concurrent.TimeUnit;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedTemplates;
import creator.loki.feature.moderation.util.ModerationHelper;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
@ -123,6 +124,6 @@ public final class PunishCommand {
}
private static void err(CommandContext ctx, String msg) {
ctx.getChannel().sendMessageEmbeds(EmbedTemplates.error("Ошибка", msg)).queue();
ModerationHelper.error(ctx, msg);
}
}

View file

@ -13,6 +13,7 @@ import creator.loki.feature.moderation.MuteRoleService;
import creator.loki.feature.moderation.MuteScheduler;
import creator.loki.feature.moderation.repository.ActiveMuteRepository;
import creator.loki.feature.moderation.repository.ModerationCaseRepository;
import creator.loki.feature.moderation.util.ModerationHelper;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.Permission;
@ -137,10 +138,10 @@ public class UnmuteCommand implements Command {
}
private static String trunc(String s, int max) {
return s.length() <= max ? s : s.substring(0, max - 1) + "…";
return ModerationHelper.trunc(s, max);
}
private static void err(CommandContext ctx, String msg) {
ctx.getChannel().sendMessageEmbeds(EmbedTemplates.error("Ошибка", msg)).queue();
ModerationHelper.error(ctx, msg);
}
}

View file

@ -0,0 +1,97 @@
package creator.loki.feature.moderation.commands.swap;
import java.util.Random;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedTemplates;
import creator.loki.feature.moderation.util.ModerationHelper;
import net.dv8tion.jda.api.Permission;
public class SwapCommand implements Command {
private final Random random = new Random();
@Override
public String getName() {
return "swap";
}
@Override
public String getDescription() {
return "Перекидывает пользователя между случайными голосовыми каналами";
}
@Override
public String getUsage() {
return "swap <@user> <количество 1-5>";
}
@Override
public String getCategory() {
return "Модерация";
}
@Override
public void execute(CommandContext ctx) {
var g = ctx.getGuild();
if (g == null) {
ModerationHelper.error(ctx, "Команда доступна только на сервере");
return;
}
var mod = ctx.getAuthor();
if (mod == null || !mod.hasPermission(Permission.VOICE_MOVE_OTHERS)) {
ModerationHelper.error(ctx, "Нет прав на перемещение");
return;
}
if (!g.getMember(g.getJDA().getSelfUser()).hasPermission(Permission.VOICE_MOVE_OTHERS)) {
ModerationHelper.error(ctx, "У бота нет прав на перемещение");
return;
}
var mentioned = ctx.getMessage().getMentions().getMembers();
if (mentioned.isEmpty() || ctx.getArgs().size() < 2) {
ModerationHelper.error(ctx, "Используй: `!swap <@user> <количество 1-5>`");
return;
}
var t = mentioned.get(0);
int swapCount;
try {
swapCount = Integer.parseInt(ctx.getArgs().get(1));
if (swapCount < 1 || swapCount > 5) throw new NumberFormatException();
} catch (NumberFormatException e) {
ModerationHelper.error(ctx, "Количество должно быть от 1 до 5");
return;
}
var voiceState = t.getVoiceState();
if (voiceState == null || !voiceState.inAudioChannel()) {
ModerationHelper.error(ctx, t.getUser().getName() + " не в голосовом канале");
return;
}
var original = voiceState.getChannel().asVoiceChannel();
var channels =
g.getVoiceChannels().stream().filter(vc -> !vc.getId().equals(original.getId())).toList();
if (channels.isEmpty()) {
ModerationHelper.error(ctx, "Недостаточно голосовых каналов для swap");
return;
}
ctx.getChannel()
.sendMessageEmbeds(
EmbedTemplates.success(
"🔄 Swap запущен!",
"Перекидываю " + t.getAsMention() + " между каналами " + swapCount + " раз(а)"))
.queue();
Thread.startVirtualThread(
() -> {
try {
for (int i = 0; i < swapCount; i++) {
var rnd = channels.get(random.nextInt(channels.size()));
g.moveVoiceMember(t, rnd).queue();
Thread.sleep(800);
g.moveVoiceMember(t, original).queue();
Thread.sleep(800);
}
} catch (Exception e) {
ModerationHelper.error(ctx, "Не удалось выполнить swap: " + e.getMessage());
}
});
}
}

View file

@ -1,189 +0,0 @@
package creator.loki.feature.moderation.commands.utils;
import java.awt.Color;
import java.util.Random;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedFactory;
import creator.loki.embed.EmbedTemplates;
import creator.loki.feature.moderation.repository.ModerationCaseRepository;
import net.dv8tion.jda.api.Permission;
public final class SwapAndCaseCommand {
private SwapAndCaseCommand() {}
public static Command createSwap() {
return new Swap();
}
public static Command createCaseLookup(ModerationCaseRepository cr) {
return new Case(cr);
}
private static class Swap implements Command {
private final Random random = new Random();
@Override
public String getName() {
return "swap";
}
@Override
public String getDescription() {
return "Перекидывает пользователя между случайными голосовыми каналами";
}
@Override
public String getUsage() {
return "swap <@user> <количество 1-5>";
}
@Override
public String getCategory() {
return "Модерация";
}
@Override
public void execute(CommandContext ctx) {
var g = ctx.getGuild();
if (g == null) {
err(ctx, "Команда доступна только на сервере");
return;
}
var mod = ctx.getAuthor();
if (mod == null || !mod.hasPermission(Permission.VOICE_MOVE_OTHERS)) {
err(ctx, "Нет прав на перемещение");
return;
}
if (!g.getMember(g.getJDA().getSelfUser()).hasPermission(Permission.VOICE_MOVE_OTHERS)) {
err(ctx, "У бота нет прав на перемещение");
return;
}
var mentioned = ctx.getMessage().getMentions().getMembers();
if (mentioned.isEmpty() || ctx.getArgs().size() < 2) {
err(ctx, "Используй: `!swap <@user> <количество 1-5>`");
return;
}
var t = mentioned.get(0);
int swapCount;
try {
swapCount = Integer.parseInt(ctx.getArgs().get(1));
if (swapCount < 1 || swapCount > 5) throw new NumberFormatException();
} catch (NumberFormatException e) {
err(ctx, "Количество должно быть от 1 до 5");
return;
}
var voiceState = t.getVoiceState();
if (voiceState == null || !voiceState.inAudioChannel()) {
err(ctx, t.getUser().getName() + " не в голосовом канале");
return;
}
var original = voiceState.getChannel().asVoiceChannel();
var channels =
g.getVoiceChannels().stream().filter(vc -> !vc.getId().equals(original.getId())).toList();
if (channels.isEmpty()) {
err(ctx, "Недостаточно голосовых каналов для swap");
return;
}
ctx.getChannel()
.sendMessageEmbeds(
EmbedTemplates.success(
"🔄 Swap запущен!",
"Перекидываю " + t.getAsMention() + " между каналами " + swapCount + " раз(а)"))
.queue();
Thread.startVirtualThread(
() -> {
try {
for (int i = 0; i < swapCount; i++) {
var rnd = channels.get(random.nextInt(channels.size()));
g.moveVoiceMember(t, rnd).queue();
Thread.sleep(800);
g.moveVoiceMember(t, original).queue();
Thread.sleep(800);
}
} catch (Exception e) {
err(ctx, "Не удалось выполнить swap: " + e.getMessage());
}
});
}
}
private static class Case implements Command {
private final ModerationCaseRepository cases;
Case(ModerationCaseRepository cr) {
cases = cr;
}
@Override
public String getName() {
return "case";
}
@Override
public String getDescription() {
return "Показать кейс модерации по ID";
}
@Override
public String getUsage() {
return "case <id>";
}
@Override
public String getCategory() {
return "Модерация";
}
@Override
public void execute(CommandContext ctx) {
var g = ctx.getGuild();
if (g == null) {
err(ctx, "Команда только на сервере");
return;
}
var mod = ctx.getAuthor();
if (mod == null || !mod.hasPermission(Permission.KICK_MEMBERS)) {
err(ctx, "Нужно **Kick Members** или выше");
return;
}
if (ctx.getArgs().isEmpty()) {
err(ctx, "Использование: `" + getUsage() + "`");
return;
}
long caseId;
try {
caseId = Long.parseLong(ctx.getArgs().get(0).trim());
} catch (NumberFormatException e) {
err(ctx, "ID кейса должен быть числом");
return;
}
cases
.findById(g.getId(), caseId)
.ifPresentOrElse(
c ->
ctx.getChannel()
.sendMessageEmbeds(
EmbedFactory.create()
.withTitle("Кейс #" + c.id())
.withColor(Color.BLUE)
.addField("Действие", "`" + c.action() + "`", true)
.addField("Цель", "<@" + c.targetId() + ">", true)
.addField("Модератор", "<@" + c.moderatorId() + ">", true)
.addField("Причина", trunc(c.reason(), 1000), false)
.addField("Время", "<t:" + (c.createdAt() / 1000) + ":F>", false)
.build())
.queue(),
() -> err(ctx, "Кейса `" + caseId + "` на этом сервере нет"));
}
}
private static String trunc(String s, int max) {
return s.length() <= max ? s : s.substring(0, max - 1) + "…";
}
private static void err(CommandContext ctx, String msg) {
ctx.getChannel().sendMessageEmbeds(EmbedTemplates.error("Ошибка", msg)).queue();
}
}

View file

@ -13,6 +13,7 @@ import creator.loki.feature.moderation.MuteScheduler;
import creator.loki.feature.moderation.repository.ActiveMuteRepository;
import creator.loki.feature.moderation.repository.ModerationCaseRepository;
import creator.loki.feature.moderation.repository.WarningRepository;
import creator.loki.feature.moderation.util.ModerationHelper;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.Guild;
@ -66,38 +67,46 @@ public class WarnCommand implements Command {
@Override
public void execute(CommandContext ctx) {
var t = validateInput(ctx);
if (t != null) saveWarning(ctx, t);
}
private Member validateInput(CommandContext ctx) {
var g = ctx.getGuild();
if (g == null) {
err(ctx, "Только на сервере");
return;
ModerationHelper.error(ctx, "Только на сервере");
return null;
}
var mod = ctx.getAuthor();
if (mod == null || !mod.hasPermission(Permission.KICK_MEMBERS)) {
err(ctx, "Нужно **Kick Members**");
return;
ModerationHelper.error(ctx, "Нужно **Kick Members**");
return null;
}
var mentioned = ctx.getMessage().getMentions().getMembers();
if (mentioned.isEmpty()) {
err(ctx, "Укажи пользователя: `" + getUsage() + "`");
return;
ModerationHelper.error(ctx, "Укажи пользователя: `" + getUsage() + "`");
return null;
}
var t = mentioned.get(0);
if (t.getId().equals(mod.getId())) {
err(ctx, "Нельзя выдать варн самому себе");
return;
ModerationHelper.error(ctx, "Нельзя выдать варн самому себе");
return null;
}
if (!mod.canInteract(t)) {
err(ctx, "Участник выше ваших ролей");
return;
ModerationHelper.error(ctx, "Участник выше ваших ролей");
return null;
}
var reason =
ctx.getArgs().size() > 1
? ctx.getArgs().stream().skip(1).collect(Collectors.joining(" ")).trim()
: "Без причины";
return t;
}
private void saveWarning(CommandContext ctx, Member t) {
var g = ctx.getGuild();
var mod = ctx.getAuthor();
var reason = ctx.getArgs().stream().skip(1).collect(Collectors.joining(" ")).trim();
if (reason.isEmpty()) reason = "Без причины";
long id = warnings.insert(g.getId(), t.getId(), mod.getId(), reason);
if (id < 0) {
err(ctx, "Не удалось сохранить предупреждение");
ModerationHelper.error(ctx, "Не удалось сохранить предупреждение");
return;
}
int total = warnings.countByUser(g.getId(), t.getId());
@ -110,7 +119,7 @@ public class WarnCommand implements Command {
+ "\n**ID записи:** `"
+ id
+ "`\n**Причина:** "
+ trunc(reason, 500)
+ ModerationHelper.trunc(reason, 500)
+ "\n**Всего варнов:** "
+ total))
.queue();
@ -133,7 +142,7 @@ public class WarnCommand implements Command {
.addField("Модератор", mod.getAsMention(), false)
.addField("ID записи", "`" + warnId + "`", true)
.addField("Всего варнов", String.valueOf(total), true)
.addField("Причина", trunc(reason, 1000), false)
.addField("Причина", ModerationHelper.trunc(reason, 1000), false)
.setTimestamp(OffsetDateTime.now())
.build())
.queue(s -> {}, e -> {});
@ -148,7 +157,7 @@ public class WarnCommand implements Command {
}
var self = g.getMember(g.getJDA().getSelfUser());
if (!self.canInteract(t) || !self.canInteract(muteRole)) return;
var reason = "Авто-мут: 3+ варна";
String reason = "Авто-мут: 3+ варна";
long untilMs = System.currentTimeMillis() + AUTO_MUTE_HOURS * 3600 * 1000;
long durSec = AUTO_MUTE_HOURS * 3600;
long caseId = cases.insert(g.getId(), "MUTE", t.getId(), mod.getId(), reason, durSec, untilMs);
@ -173,12 +182,4 @@ public class WarnCommand implements Command {
},
err -> activeMutes.delete(g.getId(), t.getId()));
}
private static String trunc(String s, int max) {
return s.length() <= max ? s : s.substring(0, max - 1) + "…";
}
private static void err(CommandContext ctx, String msg) {
ctx.getChannel().sendMessageEmbeds(EmbedTemplates.error("Ошибка", msg)).queue();
}
}

View file

@ -12,7 +12,6 @@ import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.audit.ActionType;
import net.dv8tion.jda.api.audit.AuditLogEntry;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.events.guild.GuildBanEvent;
import net.dv8tion.jda.api.events.guild.GuildUnbanEvent;
import net.dv8tion.jda.api.events.guild.member.GuildMemberRemoveEvent;
@ -35,165 +34,118 @@ public class ModerationLogger extends ListenerAdapter {
}
private void handle(
Guild guild, ActionType type, int limit, Function<List<AuditLogEntry>, EmbedBuilder> fn) {
var log = getLogChannel(guild);
Guild g, ActionType t, int l, Function<List<AuditLogEntry>, EmbedBuilder> fn) {
var log = g.getTextChannelById(configRepo.get("moderation.log_channel_id"));
if (log == null) return;
guild
.retrieveAuditLogs()
.type(type)
.limit(limit)
.queue(
logs -> {
var embed = fn.apply(logs);
if (embed != null) {
log.sendMessageEmbeds(embed.setTimestamp(OffsetDateTime.now()).build()).queue();
}
});
}
public void onMessageDelete(MessageDeleteEvent event) {
var content = messageCache.getContent(event.getMessageId());
handle(
event.getGuild(),
ActionType.MESSAGE_DELETE,
1,
var al = g.retrieveAuditLogs().type(t).limit(l);
al.queue(
logs -> {
var entry = logs.isEmpty() ? null : logs.get(0);
return LogEmbeds.messageDelete(entry, event.getChannel().getAsMention(), content);
var embed = fn.apply(logs);
if (embed == null) return;
log.sendMessageEmbeds(embed.setTimestamp(OffsetDateTime.now()).build()).queue();
});
}
public void onMessageBulkDelete(MessageBulkDeleteEvent event) {
handle(
event.getGuild(),
ActionType.MESSAGE_BULK_DELETE,
1,
logs -> {
var entry = logs.isEmpty() ? null : logs.get(0);
return LogEmbeds.messageBulkDelete(
entry, event.getChannel().getAsMention(), event.getMessageIds().size());
});
public void onMessageDelete(MessageDeleteEvent e) {
var content = messageCache.getContent(e.getMessageId());
handle(e.getGuild(), ActionType.MESSAGE_DELETE, 1, l -> md(e, content, l));
}
@Override
public void onGuildBan(GuildBanEvent event) {
handle(
event.getGuild(),
ActionType.BAN,
1,
logs -> {
if (logs.isEmpty()) return null;
var entry = logs.get(0);
var mod = entry.getUser() != null ? entry.getUser().getAsMention() : "Неизвестно";
var reason = entry.getReason() != null ? entry.getReason() : "Не указана";
return LogEmbeds.ban(
event.getUser().getAsMention(), mod, reason, event.getUser().getAvatarUrl());
});
private EmbedBuilder md(MessageDeleteEvent e, String c, List<AuditLogEntry> l) {
var entry = l.isEmpty() ? null : l.get(0);
return LogEmbeds.messageDelete(entry, e.getChannel().getAsMention(), c);
}
@Override
public void onGuildUnban(GuildUnbanEvent event) {
handle(
event.getGuild(),
ActionType.UNBAN,
1,
logs -> {
if (logs.isEmpty()) return null;
var entry = logs.get(0);
var mod = entry.getUser() != null ? entry.getUser().getAsMention() : "Неизвестно";
return LogEmbeds.unban(
event.getUser().getAsMention(), mod, event.getUser().getAvatarUrl());
});
public void onMessageBulkDelete(MessageBulkDeleteEvent e) {
handle(e.getGuild(), ActionType.MESSAGE_BULK_DELETE, 1, l -> mbd(e, l));
}
@Override
public void onGuildMemberRemove(GuildMemberRemoveEvent event) {
handle(
event.getGuild(),
ActionType.KICK,
1,
logs -> {
if (logs.isEmpty()) return null;
var entry = logs.get(0);
if (!entry.getTargetId().equals(event.getUser().getId())) return null;
var mod = entry.getUser() != null ? entry.getUser().getAsMention() : "Неизвестно";
var reason = entry.getReason() != null ? entry.getReason() : "Не указана";
return LogEmbeds.kick(
event.getUser().getAsMention(), mod, reason, event.getUser().getAvatarUrl());
});
private EmbedBuilder mbd(MessageBulkDeleteEvent e, List<AuditLogEntry> l) {
var entry = l.isEmpty() ? null : l.get(0);
var size = e.getMessageIds().size();
return LogEmbeds.messageBulkDelete(entry, e.getChannel().getAsMention(), size);
}
@Override
public void onGuildMemberUpdateTimeOut(GuildMemberUpdateTimeOutEvent event) {
var muted = event.getNewTimeOutEnd() != null;
handle(
event.getGuild(),
ActionType.MEMBER_UPDATE,
5,
logs -> {
var entry =
logs.stream()
.filter(e -> event.getUser().getId().equals(e.getTargetId()))
.findFirst()
.orElse(null);
if (entry == null) return null;
var mod = entry.getUser() != null ? entry.getUser().getAsMention() : "Неизвестно";
var end = muted ? event.getNewTimeOutEnd().toEpochSecond() : null;
return LogEmbeds.timeout(
event.getUser().getAsMention(), mod, muted, end, entry.getReason());
});
public void onGuildBan(GuildBanEvent ev) {
handle(ev.getGuild(), ActionType.BAN, 1, l -> b(ev, l));
}
@Override
public void onGuildVoiceGuildMute(GuildVoiceGuildMuteEvent event) {
var muted = event.isGuildMuted();
handle(
event.getGuild(),
ActionType.MEMBER_UPDATE,
5,
logs -> {
var entry =
logs.stream()
.filter(e -> event.getMember().getId().equals(e.getTargetId()))
.filter(e -> e.getChangeByKey("mute") != null)
.findFirst()
.orElse(null);
var mod =
entry != null && entry.getUser() != null ? entry.getUser().getAsMention() : null;
return LogEmbeds.voiceMute(
event.getMember().getAsMention(),
event.getVoiceState().getChannel().getAsMention(),
muted,
mod);
});
private EmbedBuilder b(GuildBanEvent ev, List<AuditLogEntry> l) {
if (l.isEmpty()) return null;
var user = ev.getUser();
var e = l.get(0);
var mod = e.getUser() != null ? e.getUser().getAsMention() : "Неизвестно";
var reason = e.getReason() != null ? e.getReason() : "Не указана";
return LogEmbeds.ban(user.getAsMention(), mod, reason, user.getAvatarUrl());
}
@Override
public void onGuildVoiceGuildDeafen(GuildVoiceGuildDeafenEvent event) {
var deaf = event.isGuildDeafened();
handle(
event.getGuild(),
ActionType.MEMBER_UPDATE,
5,
logs -> {
var entry =
logs.stream()
.filter(e -> event.getMember().getId().equals(e.getTargetId()))
.filter(e -> e.getChangeByKey("deaf") != null)
.findFirst()
.orElse(null);
var mod =
entry != null && entry.getUser() != null ? entry.getUser().getAsMention() : null;
return LogEmbeds.voiceDeafen(
event.getMember().getAsMention(),
event.getVoiceState().getChannel().getAsMention(),
deaf,
mod);
});
public void onGuildUnban(GuildUnbanEvent ev) {
handle(ev.getGuild(), ActionType.UNBAN, 1, l -> ub(ev, l));
}
private TextChannel getLogChannel(Guild guild) {
var logChannelId = configRepo.get("moderation.log_channel_id");
return logChannelId != null ? guild.getTextChannelById(logChannelId) : null;
private EmbedBuilder ub(GuildUnbanEvent ev, List<AuditLogEntry> l) {
if (l.isEmpty()) return null;
var user = ev.getUser();
var e = l.get(0);
var mod = e.getUser() != null ? e.getUser().getAsMention() : "Неизвестно";
return LogEmbeds.unban(user.getAsMention(), mod, user.getAvatarUrl());
}
public void onGuildMemberRemove(GuildMemberRemoveEvent ev) {
handle(ev.getGuild(), ActionType.KICK, 1, l -> k(ev, l));
}
private EmbedBuilder k(GuildMemberRemoveEvent ev, List<AuditLogEntry> l) {
if (l.isEmpty()) return null;
var e = l.get(0);
if (!e.getTargetId().equals(ev.getUser().getId())) return null;
var mod = e.getUser() != null ? e.getUser().getAsMention() : "Неизвестно";
var reason = e.getReason() != null ? e.getReason() : "Не указана";
return LogEmbeds.kick(ev.getUser().getAsMention(), mod, reason, ev.getUser().getAvatarUrl());
}
public void onGuildMemberUpdateTimeOut(GuildMemberUpdateTimeOutEvent e) {
var muted = e.getNewTimeOutEnd() != null;
handle(e.getGuild(), ActionType.MEMBER_UPDATE, 5, l -> t(e, muted, l));
}
private EmbedBuilder t(GuildMemberUpdateTimeOutEvent e, boolean muted, List<AuditLogEntry> l) {
var entry = findEntry(l, e.getUser().getId());
if (entry == null) return null;
var mod = entry.getUser() != null ? entry.getUser().getAsMention() : "Неизвестно";
var end = muted ? e.getNewTimeOutEnd().toEpochSecond() : null;
return LogEmbeds.timeout(e.getUser().getAsMention(), mod, muted, end, entry.getReason());
}
public void onGuildVoiceGuildMute(GuildVoiceGuildMuteEvent e) {
var muted = e.isGuildMuted();
handle(e.getGuild(), ActionType.MEMBER_UPDATE, 5, l -> vm(e, muted, l));
}
private EmbedBuilder vm(GuildVoiceGuildMuteEvent e, boolean muted, List<AuditLogEntry> l) {
var member = e.getMember();
var ch = e.getVoiceState().getChannel();
var entry = findEntry(l, member.getId(), "mute");
var mod = entry != null && entry.getUser() != null ? entry.getUser().getAsMention() : null;
return LogEmbeds.voiceMute(member.getAsMention(), ch.getAsMention(), muted, mod);
}
public void onGuildVoiceGuildDeafen(GuildVoiceGuildDeafenEvent e) {
var deaf = e.isGuildDeafened();
handle(e.getGuild(), ActionType.MEMBER_UPDATE, 5, l -> vd(e, deaf, l));
}
private EmbedBuilder vd(GuildVoiceGuildDeafenEvent e, boolean deaf, List<AuditLogEntry> l) {
var member = e.getMember();
var ch = e.getVoiceState().getChannel();
var entry = findEntry(l, member.getId(), "deaf");
var mod = entry != null && entry.getUser() != null ? entry.getUser().getAsMention() : null;
return LogEmbeds.voiceDeafen(member.getAsMention(), ch.getAsMention(), deaf, mod);
}
private AuditLogEntry findEntry(List<AuditLogEntry> l, String targetId, String... key) {
var s = l.stream().filter(e -> targetId.equals(e.getTargetId()));
if (key.length > 0) s = s.filter(e -> e.getChangeByKey(key[0]) != null);
return s.findFirst().orElse(null);
}
}

View file

@ -0,0 +1,16 @@
package creator.loki.feature.moderation.util;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedTemplates;
public final class ModerationHelper {
private ModerationHelper() {}
public static String trunc(String s, int max) {
return s.length() <= max ? s : s.substring(0, max - 1) + "…";
}
public static void error(CommandContext ctx, String msg) {
ctx.getChannel().sendMessageEmbeds(EmbedTemplates.error("Ошибка", msg)).queue();
}
}

View file

@ -161,7 +161,10 @@ public class FilterCommands {
public void execute(CommandContext ctx) {
var m = mgr(pm, ctx);
if (!checkChannel(m, ctx)) return;
boolean enabled = isNight ? !m.getScheduler().isNightcore() : !m.getScheduler().isVaporwave();
boolean enabled =
isNight
? !m.getScheduler().filterState.isNightcore()
: !m.getScheduler().filterState.isVaporwave();
if (isNight) m.getScheduler().setNightcore(enabled);
else m.getScheduler().setVaporwave(enabled);
ctx.getChannel()

View file

@ -1,207 +1,149 @@
package creator.loki.feature.music.commands;
import static creator.loki.embed.EmbedTemplates.error;
import static creator.loki.embed.EmbedTemplates.info;
import java.awt.Color;
import java.util.List;
import java.util.function.BiConsumer;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedFactory;
import creator.loki.embed.EmbedTemplates;
import creator.loki.feature.music.core.PlayerManager;
import dev.arbjerg.lavalink.client.player.Track;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class QueueCommands {
private static final Color C = new Color(88, 101, 242);
private static final Logger LOG = LoggerFactory.getLogger(QueueCommands.class);
private QueueCommands() {}
public static Command queue(PlayerManager pm) {
return new Queue(pm);
return new Mc(pm, "queue", "Показать очередь треков", null, null, QueueCommands::executeQueue);
}
public static Command nowplaying(PlayerManager pm) {
return new NowPlaying(pm);
BiConsumer<PlayerManager, CommandContext> exec = QueueCommands::executeNowPlaying;
return new Mc(pm, "nowplaying", "Показать текущий трек", "nowplaying", "Музыка", exec);
}
public static Command volume(PlayerManager pm) {
return new Volume(pm);
BiConsumer<PlayerManager, CommandContext> exec = QueueCommands::executeVolume;
return new Mc(pm, "volume", "Регулировка громкости (0-100)", "volume [0-100]", "Музыка", exec);
}
private static final class Queue implements Command {
private final PlayerManager pm;
private static final class Mc implements Command {
private final PlayerManager p;
private final String n, d, u, c;
private final BiConsumer<PlayerManager, CommandContext> e;
Queue(PlayerManager pm) {
this.pm = pm;
Mc(
PlayerManager p,
String n,
String d,
String u,
String c,
BiConsumer<PlayerManager, CommandContext> e) {
this.p = p;
this.n = n;
this.d = d;
this.u = u;
this.c = c;
this.e = e;
}
@Override
public String getName() {
return "queue";
return n;
}
@Override
public String getDescription() {
return "Показать очередь треков";
return d;
}
@Override
public void execute(CommandContext ctx) {
var m = pm.getMusicManager(ctx.getGuild().getIdLong());
Track cur = m.getScheduler().getCurrentTrack();
List<Track> q = m.getScheduler().getQueue();
if (cur == null && q.isEmpty()) {
ctx.getChannel().sendMessage("Очередь пуста.").queue();
return;
}
var embed =
EmbedFactory.create().withTitle("Очередь треков").withColor(new Color(88, 101, 242));
if (cur != null) {
embed.addField(
"Сейчас играет",
"**" + cur.getInfo().getTitle() + "**\n" + cur.getInfo().getAuthor(),
false);
}
if (!q.isEmpty()) {
var sb = new StringBuilder();
int show = Math.min(q.size(), 10);
for (int i = 0; i < show; i++) {
sb.append("`")
.append(i + 1)
.append(".` **")
.append(q.get(i).getInfo().getTitle())
.append("**\n");
}
if (q.size() > show) {
sb.append("... и ещё **").append(q.size() - show).append("** треков");
}
embed.addField("В очереди (" + q.size() + ")", sb.toString(), false);
}
ctx.getChannel().sendMessageEmbeds(embed.build()).queue();
}
}
private static final class NowPlaying implements Command {
private final PlayerManager pm;
NowPlaying(PlayerManager pm) {
this.pm = pm;
}
@Override
public String getName() {
return "nowplaying";
}
@Override
public String getDescription() {
return "Показать текущий трек";
}
@Override
public String getUsage() {
return "nowplaying";
return u != null ? u : n;
}
@Override
public String getCategory() {
return "Музыка";
return c != null ? c : "Общие";
}
@Override
public void execute(CommandContext ctx) {
var lm = pm.getLavalinkManager();
var track = lm.getMusicManager(ctx.getGuild().getIdLong()).getScheduler().getCurrentTrack();
if (track == null) {
ctx.getChannel().sendMessage("Сейчас ничего не играет.").queue();
return;
}
var link = lm.getClient().getLinkIfCached(ctx.getGuild().getIdLong());
long pos =
link != null && link.getCachedPlayer() != null ? link.getCachedPlayer().getPosition() : 0;
long dur = track.getInfo().getLength();
long ps = pos / 1000;
long ds = dur / 1000;
if (ds <= 0) ds = 1;
int filled = (int) ((double) pos / dur * 20);
StringBuilder bar = new StringBuilder("\u2503");
for (int i = 0; i < 20; i++) bar.append(i < filled ? "\u2588" : "\u2591");
String pbar =
bar
+ "\u2503 "
+ String.format("%d:%02d", ps / 60, ps % 60)
+ " / "
+ String.format("%d:%02d", ds / 60, ds % 60);
e.accept(p, ctx);
}
}
private static void executeQueue(PlayerManager pm, CommandContext ctx) {
var m = pm.getMusicManager(ctx.getGuild().getIdLong());
var cur = m.getScheduler().getCurrentTrack();
var q = m.getScheduler().getQueue();
if (cur == null && q.isEmpty()) {
ctx.getChannel().sendMessage("Очередь пуста.").queue();
return;
}
var embed = EmbedFactory.create().withTitle("Очередь треков").withColor(C);
if (cur != null) {
var i = cur.getInfo();
embed.addField("Сейчас играет", "**" + i.getTitle() + "**\n" + i.getAuthor(), false);
}
if (!q.isEmpty()) {
var sb = new StringBuilder();
var show = Math.min(q.size(), 10);
for (var i = 0; i < show; i++)
sb.append(String.format("`%d.` **%s**\n", i + 1, q.get(i).getInfo().getTitle()));
if (q.size() > show) sb.append("... и ещё **").append(q.size() - show).append("** треков");
embed.addField("В очереди (" + q.size() + ")", sb.toString(), false);
}
ctx.getChannel().sendMessageEmbeds(embed.build()).queue();
}
private static void executeNowPlaying(PlayerManager pm, CommandContext ctx) {
var lm = pm.getLavalinkManager();
var track = lm.getMusicManager(ctx.getGuild().getIdLong()).getScheduler().getCurrentTrack();
if (track == null) {
ctx.getChannel().sendMessage("Сейчас ничего не играет.").queue();
return;
}
var l = lm.getClient().getLinkIfCached(ctx.getGuild().getIdLong());
var pos = l != null && l.getCachedPlayer() != null ? l.getCachedPlayer().getPosition() : 0L;
var dur = track.getInfo().getLength();
var ds = dur / 1000 <= 0 ? 1 : dur / 1000;
var filled = (int) ((double) pos / dur * 20);
var bar = new StringBuilder("\u2503");
for (var i = 0; i < 20; i++) bar.append(i < filled ? "\u2588" : "\u2591");
var t0 = String.format("%d:%02d", pos / 1000 / 60, pos / 1000 % 60);
var t1 = String.format("%d:%02d", ds / 60, ds % 60);
var pbar = bar + "\u2503 " + t0 + " / " + t1;
var msg =
EmbedFactory.create()
.withTitle("Сейчас играет")
.withColor(C)
.addField("Название", "**" + track.getInfo().getTitle() + "**", false)
.addField("Автор", track.getInfo().getAuthor(), true)
.addField("Длительность", pbar, true)
.build();
ctx.getChannel().sendMessageEmbeds(msg).queue();
}
private static void executeVolume(PlayerManager pm, CommandContext ctx) {
var l = pm.getLavalinkManager().getClient().getLinkIfCached(ctx.getGuild().getIdLong());
if (ctx.getArgs().isEmpty()) {
var v = l != null && l.getCachedPlayer() != null ? l.getCachedPlayer().getVolume() : 100;
ctx.getChannel().sendMessageEmbeds(info("Громкость", "Текущая: **" + v + "%**")).queue();
return;
}
try {
var vol = Math.max(0, Math.min(100, Integer.parseInt(ctx.getArgs().get(0))));
if (l != null && l.getCachedPlayer() != null)
l.createOrUpdatePlayer()
.setVolume(vol)
.subscribe(null, err -> LOG.warn("Lavalink: {}", err.getMessage()));
ctx.getChannel()
.sendMessageEmbeds(
EmbedFactory.create()
.withTitle("Сейчас играет")
.withColor(new Color(88, 101, 242))
.addField("Название", "**" + track.getInfo().getTitle() + "**", false)
.addField("Автор", track.getInfo().getAuthor(), true)
.addField("Длительность", pbar, true)
.build())
.sendMessageEmbeds(info("Громкость", "Установлена на **" + vol + "%**"))
.queue();
}
}
private static final class Volume implements Command {
private final PlayerManager pm;
Volume(PlayerManager pm) {
this.pm = pm;
}
@Override
public String getName() {
return "volume";
}
@Override
public String getDescription() {
return "Регулировка громкости (0-100)";
}
@Override
public String getUsage() {
return "volume [0-100]";
}
@Override
public String getCategory() {
return "Музыка";
}
@Override
public void execute(CommandContext ctx) {
var lm = pm.getLavalinkManager();
var link = lm.getClient().getLinkIfCached(ctx.getGuild().getIdLong());
if (ctx.getArgs().isEmpty()) {
int v =
link != null && link.getCachedPlayer() != null
? link.getCachedPlayer().getVolume()
: 100;
ctx.getChannel()
.sendMessageEmbeds(EmbedTemplates.info("Громкость", "Текущая: **" + v + "%**"))
.queue();
return;
}
try {
var vol = Math.max(0, Math.min(100, Integer.parseInt(ctx.getArgs().get(0))));
if (link != null && link.getCachedPlayer() != null) {
link.createOrUpdatePlayer()
.setVolume(vol)
.subscribe(
null,
err ->
LoggerFactory.getLogger(Volume.class).warn("Lavalink: {}", err.getMessage()));
}
ctx.getChannel()
.sendMessageEmbeds(EmbedTemplates.info("Громкость", "Установлена на **" + vol + "%**"))
.queue();
} catch (NumberFormatException e) {
ctx.getChannel()
.sendMessageEmbeds(EmbedTemplates.error("Аргументы", "Нужно число от 0 до 100."))
.queue();
}
} catch (NumberFormatException e) {
ctx.getChannel().sendMessageEmbeds(error("Аргументы", "Нужно число от 0 до 100.")).queue();
}
}
}

View file

@ -92,7 +92,7 @@ public class LavalinkManager {
event -> {
GuildMusicManager manager = musicManagers.get(event.getGuildId());
if (manager != null) {
manager.getScheduler().onTrackStart(event.getTrack());
manager.cancelAutoLeaveTimer();
}
});

View file

@ -3,20 +3,21 @@ package creator.loki.feature.music.listener;
import java.util.List;
import creator.loki.feature.music.core.LavalinkManager;
import creator.loki.feature.music.core.PlayerManager;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class MusicBotControlHandler {
private final LavalinkManager lavalinkManager;
private final PlayerManager playerManager;
private static final Logger log = LoggerFactory.getLogger(MusicBotControlHandler.class);
private static final int ERR = 0xFF4444;
public MusicBotControlHandler(LavalinkManager lavalinkManager, PlayerManager playerManager) {
private final LavalinkManager lavalinkManager;
public MusicBotControlHandler(LavalinkManager lavalinkManager) {
this.lavalinkManager = lavalinkManager;
this.playerManager = playerManager;
}
public void handleSkip(MessageChannel ch, Guild guild, Member member) {
@ -26,7 +27,7 @@ public class MusicBotControlHandler {
send(ch, err("Нечего пропускать."));
return;
}
String t = s.getCurrentTrack().getInfo().getTitle();
var t = s.getCurrentTrack().getInfo().getTitle();
s.skip();
send(ch, ok("⏭ Пропущено", "**" + t + "**"));
}
@ -35,7 +36,7 @@ public class MusicBotControlHandler {
if (reqVoice(ch, member) == null) return;
lavalinkManager.getMusicManager(guild.getIdLong()).stop();
guild.getJDA().getDirectAudioController().disconnect(guild);
send(ch, ok(0xFF4444, "⏹ Остановлено", "Музыка остановлена, очередь очищена."));
send(ch, ok(ERR, "⏹ Остановлено", "Музыка остановлена, очередь очищена."));
}
public void handleQueue(MessageChannel ch, Guild guild) {
@ -47,18 +48,15 @@ public class MusicBotControlHandler {
return;
}
var sb = new StringBuilder();
if (cur != null) {
sb.append("**Сейчас:** ").append(cur.getInfo().getTitle()).append("\n");
}
if (cur != null) sb.append("**Сейчас:** ").append(cur.getInfo().getTitle()).append("\n");
if (!q.isEmpty()) {
int show = Math.min(q.size(), 10);
for (int i = 0; i < show; i++) {
var show = Math.min(q.size(), 10);
for (var i = 0; i < show; i++)
sb.append("`")
.append(i + 1)
.append(".` ")
.append(q.get(i).getInfo().getTitle())
.append("\n");
}
if (q.size() > show) sb.append("... и ещё **").append(q.size() - show).append("** треков");
}
send(ch, ok("📜 Очередь", sb.toString()));
@ -79,22 +77,23 @@ public class MusicBotControlHandler {
if (reqVoice(ch, member) == null) return;
var link = lavalinkManager.getClient().getLinkIfCached(guild.getIdLong());
if (args.isEmpty()) {
int v =
link != null && link.getCachedPlayer() != null ? link.getCachedPlayer().getVolume() : 100;
send(ch, ok("🔊 Громкость", "Текущая: **" + v + "%**"));
send(
ch,
ok(
"🔊 Громкость",
"Текущая: **"
+ (link != null && link.getCachedPlayer() != null
? link.getCachedPlayer().getVolume()
: 100)
+ "%**"));
return;
}
try {
int vol = Math.max(0, Math.min(100, Integer.parseInt(args.get(0))));
if (link != null && link.getCachedPlayer() != null) {
var vol = Math.max(0, Math.min(100, Integer.parseInt(args.get(0))));
if (link != null && link.getCachedPlayer() != null)
link.createOrUpdatePlayer()
.setVolume(vol)
.subscribe(
null,
err ->
LoggerFactory.getLogger(MusicBotControlHandler.class)
.warn("Lavalink: {}", err.getMessage()));
}
.subscribe(null, err -> log.warn("Lavalink: {}", err.getMessage()));
send(ch, ok("🔊 Громкость", "Установлена **" + vol + "%**"));
} catch (NumberFormatException e) {
send(ch, err("Укажи число от 0 до 100."));
@ -105,17 +104,19 @@ public class MusicBotControlHandler {
if (reqVoice(ch, member) == null) return;
lavalinkManager.getMusicManager(guild.getIdLong()).stop();
guild.getJDA().getDirectAudioController().disconnect(guild);
send(ch, ok(0xFF4444, "👋 Отключился", "Вышел из голосового канала."));
send(ch, ok(ERR, "👋 Отключился", "Вышел из голосового канала."));
}
public void handleRepeat(MessageChannel ch, Guild guild, Member member) {
if (reqVoice(ch, member) == null) return;
var s = lavalinkManager.getMusicManager(guild.getIdLong()).getScheduler();
boolean now = !s.isRepeating();
var now = !s.isRepeating();
s.setRepeating(now);
String t = now ? "🔁 Повтор включён" : "➡ Повтор выключен";
String d = now ? "Очередь будет повторяться." : "Повтор отключён.";
send(ch, ok(t, d));
send(
ch,
ok(
now ? "🔁 Повтор включён" : "➡ Повтор выключен",
now ? "Очередь будет повторяться." : "Повтор отключён."));
}
public void handleShuffle(MessageChannel ch, Guild guild, Member member) {
@ -131,13 +132,12 @@ public class MusicBotControlHandler {
public void handleBassboost(MessageChannel ch, Guild guild, Member member, List<String> args) {
if (reqVoice(ch, member) == null) return;
int level = 10;
if (!args.isEmpty()) {
var level = 10;
if (!args.isEmpty())
try {
level = Math.max(-25, Math.min(25, Integer.parseInt(args.get(0))));
} catch (NumberFormatException ignored) {
}
}
lavalinkManager.getMusicManager(guild.getIdLong()).getScheduler().setBassboost(level);
send(ch, ok("🔊 Bassboost", "**" + level + "**"));
}
@ -145,7 +145,7 @@ public class MusicBotControlHandler {
public void handleNightcore(MessageChannel ch, Guild guild, Member member) {
if (reqVoice(ch, member) == null) return;
var s = lavalinkManager.getMusicManager(guild.getIdLong()).getScheduler();
boolean now = !s.isNightcore();
var now = !s.filterState.isNightcore();
s.setNightcore(now);
send(ch, ok("🎵 Nightcore", now ? "Включён ⚡" : "Выключен"));
}
@ -153,7 +153,7 @@ public class MusicBotControlHandler {
public void handleVaporwave(MessageChannel ch, Guild guild, Member member) {
if (reqVoice(ch, member) == null) return;
var s = lavalinkManager.getMusicManager(guild.getIdLong()).getScheduler();
boolean now = !s.isVaporwave();
var now = !s.filterState.isVaporwave();
s.setVaporwave(now);
send(ch, ok("🎵 Vaporwave", now ? "Включён 🌊" : "Выключен"));
}
@ -171,10 +171,12 @@ public class MusicBotControlHandler {
return;
}
try {
int band = Math.max(0, Math.min(14, Integer.parseInt(args.get(0))));
float gain = (float) Math.max(-0.25, Math.min(1.0, Double.parseDouble(args.get(1))));
var sc = lavalinkManager.getMusicManager(guild.getIdLong()).getScheduler();
sc.setEqualizerBand(band, gain);
var band = Math.max(0, Math.min(14, Integer.parseInt(args.get(0))));
var gain = (float) Math.max(-0.25, Math.min(1.0, Double.parseDouble(args.get(1))));
lavalinkManager
.getMusicManager(guild.getIdLong())
.getScheduler()
.setEqualizerBand(band, gain);
send(
ch,
ok("🎛 Эквалайзер", "Band **" + band + "** → **" + String.format("%.2f", gain) + "**"));
@ -202,6 +204,6 @@ public class MusicBotControlHandler {
}
private static EmbedBuilder err(String msg) {
return new EmbedBuilder().setColor(0xFF4444).setDescription("❌ " + msg);
return new EmbedBuilder().setColor(ERR).setDescription("❌ " + msg);
}
}

View file

@ -23,7 +23,7 @@ public class MusicBotListener extends ListenerAdapter {
QueueRepository queueRepository,
creator.loki.feature.music.core.PlayerManager playerManager) {
this.playHandler = new MusicBotPlayHandler(lavalinkManager);
this.controlHandler = new MusicBotControlHandler(lavalinkManager, playerManager);
this.controlHandler = new MusicBotControlHandler(lavalinkManager);
}
@Override

View file

@ -34,34 +34,39 @@ public class FilterState {
return vaporwave;
}
public void setBassboost(int level) {
public FilterState setBassboost(int level) {
this.bassboost = Math.max(0f, Math.min(1.0f, level / 100.0f));
return this;
}
public void setNightcore(boolean enabled) {
public FilterState setNightcore(boolean enabled) {
this.nightcore = enabled;
if (enabled) this.vaporwave = false;
return this;
}
public void setVaporwave(boolean enabled) {
public FilterState setVaporwave(boolean enabled) {
this.vaporwave = enabled;
if (enabled) this.nightcore = false;
return this;
}
public void setEqualizerBand(int band, float gain) {
if (band < 0 || band > 14) return;
public FilterState setEqualizerBand(int band, float gain) {
if (band < 0 || band > 14) return this;
customBands.removeIf(b -> b.getBand() == band);
customBands.add(new Band(band, Math.max(-0.25f, Math.min(10.0f, gain))));
return this;
}
public void reset() {
public FilterState reset() {
this.bassboost = 0f;
this.nightcore = false;
this.vaporwave = false;
this.customBands.clear();
return this;
}
public void apply(Optional<Link> link) {
public FilterState apply(Optional<Link> link) {
link.ifPresent(
l -> {
FilterBuilder fb = new FilterBuilder();
@ -86,5 +91,6 @@ public class FilterState {
.setFilters(fb.build())
.subscribe(null, err -> System.err.println("Filter error: " + err.getMessage()));
});
return this;
}
}

View file

@ -12,6 +12,7 @@ import creator.loki.feature.music.core.LavalinkManager;
import creator.loki.feature.music.repository.QueueRepository;
import creator.loki.feature.music.repository.QueueRepository.QueueTrack;
import creator.loki.util.LoggerDecorator;
import dev.arbjerg.lavalink.client.LavalinkNode;
import dev.arbjerg.lavalink.client.Link;
import dev.arbjerg.lavalink.client.player.LavalinkPlayer;
import dev.arbjerg.lavalink.client.player.Track;
@ -27,7 +28,6 @@ public class GuildMusicManager {
private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
private ScheduledFuture<?> autoLeaveFuture;
@SuppressWarnings("this-escape")
public GuildMusicManager(
long guildId, LavalinkManager lavalinkManager, QueueRepository queueRepository) {
this.guildId = guildId;
@ -62,20 +62,17 @@ public class GuildMusicManager {
public void startAutoLeaveTimer() {
cancelAutoLeaveTimer();
autoLeaveFuture =
executor.schedule(
() -> {
stop();
getLink()
.ifPresent(
link ->
link.destroy()
.subscribe(
null, err -> logger.warn("Lavalink: {}", err.getMessage())));
lavalinkManager.removeMusicManager(guildId);
},
2,
TimeUnit.MINUTES);
autoLeaveFuture = executor.schedule(this::autoLeave, 2, TimeUnit.MINUTES);
}
private void autoLeave() {
stop();
getLink()
.ifPresent(
link ->
link.destroy()
.subscribe(null, err -> logger.warn("Lavalink: {}", err.getMessage())));
lavalinkManager.removeMusicManager(guildId);
}
public void cancelAutoLeaveTimer() {
@ -101,43 +98,33 @@ public class GuildMusicManager {
return guildId;
}
QueueRepository getQueueRepo() {
return queueRepository;
}
private void loadQueueAsync() {
List<QueueTrack> saved = queueRepository.loadQueue(String.valueOf(guildId));
var saved = queueRepository.loadQueue(String.valueOf(guildId));
if (saved.isEmpty()) return;
var client = lavalinkManager.getClient();
if (client == null || client.getNodes().isEmpty()) return;
var node = client.getNodes().get(0);
executor.execute(() -> restoreTracks(saved, client.getNodes().get(0)));
}
executor.execute(
() -> {
List<Track> decoded = new ArrayList<>();
for (QueueTrack qt : saved) {
try {
Track track = node.decodeTrack(qt.encoded()).block();
if (track != null) decoded.add(track);
} catch (Exception e) {
LoggerDecorator.warn(logger, "Не удалось декодировать: " + qt.title());
}
}
if (decoded.isEmpty()) return;
var q = scheduler.getRawQueue();
q.addAll(decoded.subList(1, decoded.size()));
Track first = decoded.get(0);
getPlayer()
.ifPresentOrElse(
player -> {
if (player.getTrack() == null) scheduler.startTrack(first);
else q.offer(first);
},
() -> q.offer(first));
LoggerDecorator.music(logger, "Восстановлено " + decoded.size() + " треков");
queueRepository.deleteQueue(String.valueOf(guildId));
});
private void restoreTracks(List<QueueTrack> saved, LavalinkNode node) {
var decoded = new ArrayList<Track>();
for (var qt : saved) {
try {
var track = node.decodeTrack(qt.encoded()).block();
if (track != null) decoded.add(track);
} catch (Exception e) {
LoggerDecorator.warn(logger, "Не удалось декодировать: " + qt.title());
}
}
if (decoded.isEmpty()) return;
var q = scheduler.queue;
q.addAll(decoded.subList(1, decoded.size()));
var first = decoded.get(0);
var player = getPlayer().orElse(null);
if (player != null && player.getTrack() == null) scheduler.startTrack(first);
else q.offer(first);
LoggerDecorator.music(logger, "Восстановлено " + decoded.size() + " треков");
queueRepository.deleteQueue(String.valueOf(guildId));
}
private ScheduledFuture<?> saveDebounced;
@ -148,20 +135,16 @@ public class GuildMusicManager {
}
void saveQueue() {
Track current = scheduler.getCurrentTrack();
List<Track> tracks = new ArrayList<>(scheduler.getQueue());
List<QueueTrack> queueTracks = new ArrayList<>();
var current = scheduler.getCurrentTrack();
var tracks = new ArrayList<>(scheduler.getQueue());
var queueTracks = new ArrayList<QueueTrack>();
if (current != null) queueTracks.add(toQueueTrack(current));
for (Track t : tracks) queueTracks.add(toQueueTrack(t));
for (var t : tracks) queueTracks.add(toQueueTrack(t));
queueRepository.saveQueue(String.valueOf(guildId), queueTracks);
}
private static QueueTrack toQueueTrack(Track t) {
return new QueueTrack(
t.getEncoded(),
t.getInfo().getTitle(),
t.getInfo().getAuthor(),
t.getInfo().getUri(),
t.getInfo().getLength());
var i = t.getInfo();
return new QueueTrack(t.getEncoded(), i.getTitle(), i.getAuthor(), i.getUri(), i.getLength());
}
}

View file

@ -6,6 +6,7 @@ import java.util.LinkedList;
import java.util.List;
import java.util.Queue;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import creator.loki.util.LoggerDecorator;
import dev.arbjerg.lavalink.client.Link;
@ -17,45 +18,32 @@ import org.slf4j.LoggerFactory;
public class TrackScheduler {
private static final Logger log = LoggerFactory.getLogger(TrackScheduler.class);
private static final Consumer<Throwable> LOG_LAV = t -> log.warn("Lav: {}", t.getMessage());
private final GuildMusicManager manager;
private final Queue<Track> queue;
final Queue<Track> queue = new LinkedList<>();
private boolean repeating;
private final FilterState filterState;
public final FilterState filterState = new FilterState();
public TrackScheduler(GuildMusicManager manager) {
this.manager = manager;
this.queue = new LinkedList<>();
this.repeating = false;
this.filterState = new FilterState();
}
public void enqueue(Track track) {
manager.cancelAutoLeaveTimer();
manager
.getPlayer()
.ifPresentOrElse(
player -> {
if (player.getTrack() == null) startTrack(track);
else {
queue.offer(track);
manager.saveQueueDebounced();
}
},
() -> startTrack(track));
if (manager.getPlayer().filter(p -> p.getTrack() != null).isPresent()) {
queue.offer(track);
manager.saveQueueDebounced();
} else {
startTrack(track);
}
}
public void skip() {
manager
.getPlayer()
.ifPresent(
player ->
player
.setTrack(null)
.subscribe(null, err -> log.warn("Lavalink: {}", err.getMessage())));
manager.getPlayer().ifPresent(p -> p.setTrack(null).subscribe(null, LOG_LAV));
}
public boolean nextTrack() {
Track next = queue.poll();
var next = queue.poll();
if (next == null) return false;
startTrack(next);
manager.saveQueueDebounced();
@ -65,19 +53,12 @@ public class TrackScheduler {
public void stop() {
manager.cancelAutoLeaveTimer();
queue.clear();
manager
.getPlayer()
.ifPresent(
player ->
player
.setPaused(false)
.setTrack(null)
.subscribe(null, err -> log.warn("Lavalink: {}", err.getMessage())));
manager.getPlayer().ifPresent(p -> p.setPaused(false).setTrack(null).subscribe(null, LOG_LAV));
manager.saveQueue();
}
public void shuffle() {
List<Track> list = new ArrayList<>(queue);
var list = new ArrayList<>(queue);
Collections.shuffle(list);
queue.clear();
queue.addAll(list);
@ -100,103 +81,66 @@ public class TrackScheduler {
this.repeating = r;
}
public boolean isNightcore() {
return filterState.isNightcore();
}
public boolean isVaporwave() {
return filterState.isVaporwave();
}
public void setBassboost(int l) {
filterState.setBassboost(l);
filterState.apply(manager.getLink());
filterState.setBassboost(l).apply(manager.getLink());
}
public void setNightcore(boolean e) {
filterState.setNightcore(e);
filterState.apply(manager.getLink());
filterState.setNightcore(e).apply(manager.getLink());
}
public void setVaporwave(boolean e) {
filterState.setVaporwave(e);
filterState.apply(manager.getLink());
filterState.setVaporwave(e).apply(manager.getLink());
}
public void setEqualizerBand(int b, float g) {
filterState.setEqualizerBand(b, g);
filterState.apply(manager.getLink());
filterState.setEqualizerBand(b, g).apply(manager.getLink());
}
public void resetFilters() {
filterState.reset();
filterState.apply(manager.getLink());
}
public void onTrackStart(Track track) {
manager.cancelAutoLeaveTimer();
filterState.reset().apply(manager.getLink());
}
public void onTrackEnd(Track lastTrack, AudioTrackEndReason endReason) {
if (endReason == AudioTrackEndReason.LOAD_FAILED) {
LoggerDecorator.warn(log, "Load failed, skipping to next");
Track next = queue.poll();
if (next != null) {
startTrack(next);
} else {
manager.startAutoLeaveTimer();
}
return;
}
if (!endReason.getMayStartNext() && endReason != AudioTrackEndReason.FINISHED) {
nextOrAutoLeave();
} else if (!endReason.getMayStartNext() && endReason != AudioTrackEndReason.FINISHED) {
LoggerDecorator.warn(log, "Track ended unexpectedly: " + endReason);
manager.startAutoLeaveTimer();
return;
}
if (repeating && endReason == AudioTrackEndReason.FINISHED) {
} else if (repeating && endReason == AudioTrackEndReason.FINISHED) {
startTrack(lastTrack.makeClone());
} else {
Track next = queue.poll();
if (next != null) {
startTrack(next);
} else {
manager.startAutoLeaveTimer();
}
nextOrAutoLeave();
}
}
Queue<Track> getRawQueue() {
return queue;
}
void startTrack(Track track) {
manager.cancelAutoLeaveTimer();
Link link = manager.getLavalinkManager().getOrCreateLink(manager.getGuildId());
var link = manager.getLavalinkManager().getOrCreateLink(manager.getGuildId());
sendTrackDelayed(link, track, 0);
}
private void nextOrAutoLeave() {
var next = queue.poll();
if (next != null) startTrack(next);
else manager.startAutoLeaveTimer();
}
private void scheduleRetry(Runnable r, long delayMs) {
manager.getExecutor().schedule(r, delayMs, TimeUnit.MILLISECONDS);
}
private void sendTrackDelayed(Link link, Track track, int attempt) {
if (attempt > 5) return;
if (link.getCachedPlayer() == null) {
manager
.getExecutor()
.schedule(() -> sendTrackDelayed(link, track, attempt + 1), 300, TimeUnit.MILLISECONDS);
scheduleRetry(() -> sendTrackDelayed(link, track, attempt + 1), 300);
return;
}
link.createOrUpdatePlayer()
.setTrack(track)
.setVolume(100)
.subscribe(
null,
err -> {
if (attempt < 5) {
manager
.getExecutor()
.schedule(
() -> sendTrackDelayed(link, track, attempt + 1),
500,
TimeUnit.MILLISECONDS);
}
});
null, err -> scheduleRetry(() -> sendTrackDelayed(link, track, attempt + 1), 500));
}
}

View file

@ -6,15 +6,19 @@ import java.util.Set;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedTemplates;
import creator.loki.feature.music.core.LavalinkManager;
import creator.loki.feature.music.core.PlayerManager;
import creator.loki.feature.music.core.SearchManager;
import creator.loki.feature.music.manage.GuildMusicManager;
import creator.loki.util.VoiceHelper;
import dev.arbjerg.lavalink.client.AbstractAudioLoadResultHandler;
import dev.arbjerg.lavalink.client.player.LoadFailed;
import dev.arbjerg.lavalink.client.player.PlaylistLoaded;
import dev.arbjerg.lavalink.client.player.SearchResult;
import dev.arbjerg.lavalink.client.player.Track;
import dev.arbjerg.lavalink.client.player.TrackLoaded;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
public class PlayCommand implements Command {
private static final Set<String> SOURCES = Set.of("yt", "sc", "list");
@ -48,9 +52,9 @@ public class PlayCommand implements Command {
@Override
public void execute(CommandContext ctx) {
var guild = ctx.getGuild();
Guild guild = ctx.getGuild();
if (guild == null) return;
var member = ctx.getAuthor();
Member member = ctx.getAuthor();
if (member == null || !member.getVoiceState().inAudioChannel()) {
ctx.getChannel()
.sendMessageEmbeds(
@ -60,11 +64,7 @@ public class PlayCommand implements Command {
}
var args = ctx.getArgs();
if (args.isEmpty()) {
ctx.getChannel()
.sendMessageEmbeds(
EmbedTemplates.error(
"Аргументы", "Укажи URL, запрос или номер: `" + getUsage() + "`"))
.queue();
sendErr(ctx, "Укажи URL, запрос или номер: `" + getUsage() + "`");
return;
}
String first = args.get(0);
@ -78,29 +78,16 @@ public class PlayCommand implements Command {
return;
}
var lm = playerManager.getLavalinkManager();
if (!guild.getMember(guild.getJDA().getSelfUser()).getVoiceState().inAudioChannel()) {
if (!guild.getSelfMember().getVoiceState().inAudioChannel()) {
var vc = member.getVoiceState().getChannel().asVoiceChannel();
guild.getJDA().getDirectAudioController().connect(vc);
if (!guild.getAudioManager().isConnected()) {
guild.getAudioManager().openAudioConnection(vc);
}
for (int i = 0; i < 10; i++) {
if (guild.getMember(guild.getJDA().getSelfUser()).getVoiceState().inAudioChannel()) break;
try {
Thread.sleep(200);
} catch (InterruptedException e) {
return;
}
}
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());
for (int i = 0; i < 10; i++) {
if (link.getCachedPlayer() != null) break;
try {
Thread.sleep(200);
} catch (InterruptedException e) {
return;
}
}
VoiceHelper.waitForCondition(() -> link.getCachedPlayer() != null);
if (link.getCachedPlayer() == null) return;
}
var mm = lm.getMusicManager(guild.getIdLong());
boolean isSource = SOURCES.contains(first);
@ -109,40 +96,31 @@ public class PlayCommand implements Command {
sendErr(ctx, "Укажи запрос после источника.");
return;
}
boolean isUrl = raw.startsWith("http://") || raw.startsWith("https://");
boolean isList = !isUrl && first.equals("list");
String id;
if (isUrl) {
id = raw;
} else if (isList) {
id = "ytsearch:" + raw;
} else if (raw.startsWith("ytmsearch:")
|| raw.startsWith("ytsearch:")
|| raw.startsWith("scsearch:")) {
id = raw;
} else {
id = "ytsearch:" + raw;
}
boolean autoPlay = isUrl;
String query = isUrl ? null : (first.equals("list") ? raw : raw);
String id = buildQuery(first, raw);
loadAndHandle(ctx, lm, guild.getIdLong(), mm, id, isUrl, query, raw);
}
private void loadAndHandle(
CommandContext ctx,
LavalinkManager lm,
long guildId,
GuildMusicManager mm,
String id,
boolean isUrl,
String query,
String raw) {
String noMatch =
isUrl ? "Ничего не найдено по ссылке." : "Ничего не найдено по запросу: " + raw;
String query = isList ? raw : (isUrl ? null : raw);
lm.getOrCreateLink(guild.getIdLong())
lm.getOrCreateLink(guildId)
.loadItem(id)
.subscribe(
new AbstractAudioLoadResultHandler() {
@Override
public void ontrackLoaded(TrackLoaded r) {
if (autoPlay) {
playTrack(ctx, r.getTrack());
} else {
showResults(ctx, query, mm, List.of(r.getTrack()));
}
if (isUrl) playTrack(ctx, r.getTrack());
else showResults(ctx, query, mm, List.of(r.getTrack()));
}
@Override
@ -159,7 +137,7 @@ public class PlayCommand implements Command {
"Плейлист добавлен",
"**" + r.getInfo().getName() + "** — " + c + " треков"))
.queue();
} else if (autoPlay && r.getTracks().size() <= 1 && !r.getTracks().isEmpty()) {
} else if (r.getTracks().size() <= 1 && !r.getTracks().isEmpty()) {
playTrack(ctx, r.getTracks().get(0));
} else {
showResults(ctx, query, mm, r.getTracks());
@ -170,7 +148,7 @@ public class PlayCommand implements Command {
public void onSearchResultLoaded(SearchResult r) {
if (r.getTracks().isEmpty()) {
sendErr(ctx, noMatch);
} else if (autoPlay) {
} else if (isUrl) {
playTrack(ctx, r.getTracks().get(0));
} else {
showResults(ctx, query, mm, r.getTracks());
@ -193,6 +171,16 @@ public class PlayCommand implements Command {
});
}
private static String buildQuery(String first, String raw) {
if (raw.startsWith("http://") || raw.startsWith("https://")) return raw;
if (!first.equals("list")
&& !raw.startsWith("ytsearch:")
&& !raw.startsWith("ytmsearch:")
&& !raw.startsWith("scsearch:")) return "ytsearch:" + raw;
if (first.equals("list")) return "ytsearch:" + raw;
return raw;
}
private void showResults(CommandContext ctx, String q, GuildMusicManager m, List<Track> list) {
if (q == null) return;
searchHandler.storeResults(ctx.getGuild().getIdLong(), list);

View file

@ -28,7 +28,7 @@ public class FilterSlashHandler {
public void handleNightcore(SlashCommandInteractionEvent event) {
if (reqVoice(event) == null) return;
var manager = getManager(event);
boolean now = !manager.getScheduler().isNightcore();
boolean now = !manager.getScheduler().filterState.isNightcore();
manager.getScheduler().setNightcore(now);
event
.replyEmbeds(
@ -39,7 +39,7 @@ public class FilterSlashHandler {
public void handleVaporwave(SlashCommandInteractionEvent event) {
if (reqVoice(event) == null) return;
var manager = getManager(event);
boolean now = !manager.getScheduler().isVaporwave();
boolean now = !manager.getScheduler().filterState.isVaporwave();
manager.getScheduler().setVaporwave(now);
event
.replyEmbeds(

View file

@ -2,193 +2,144 @@ package creator.loki.feature.music.slash;
import java.awt.Color;
import java.util.List;
import java.util.function.Consumer;
import creator.loki.embed.EmbedFactory;
import creator.loki.embed.EmbedTemplates;
import creator.loki.feature.music.core.PlayerManager;
import creator.loki.feature.music.manage.GuildMusicManager;
import dev.arbjerg.lavalink.client.player.Track;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import org.slf4j.LoggerFactory;
public class QueueSlashHandler {
private final PlayerManager playerManager;
private final PlayerManager pm;
public QueueSlashHandler(PlayerManager playerManager) {
this.playerManager = playerManager;
public QueueSlashHandler(PlayerManager pm) {
this.pm = pm;
}
public void handleStop(SlashCommandInteractionEvent event) {
if (reqVoice(event) == null) return;
var manager = getManager(event);
manager.stop();
event.getGuild().getJDA().getDirectAudioController().disconnect(event.getGuild());
event
.replyEmbeds(EmbedTemplates.info("Остановлено", "Музыка остановлена, очередь очищена."))
public void handleStop(SlashCommandInteractionEvent ev) {
if (reqVoice(ev) == null) return;
pm.getMusicManager(ev.getGuild().getIdLong()).stop();
ev.getGuild().getJDA().getDirectAudioController().disconnect(ev.getGuild());
ev.replyEmbeds(EmbedTemplates.info("Остановлено", "Музыка остановлена, очередь очищена."))
.queue();
}
public void handleSkip(SlashCommandInteractionEvent event) {
if (reqVoice(event) == null) return;
var manager = getManager(event);
if (manager.getScheduler().getCurrentTrack() == null) {
event.replyEmbeds(EmbedTemplates.error("Очередь пуста", "Сейчас ничего не играет.")).queue();
public void handleSkip(SlashCommandInteractionEvent ev) {
if (reqVoice(ev) == null) return;
var s = pm.getMusicManager(ev.getGuild().getIdLong()).getScheduler();
if (s.getCurrentTrack() == null) {
ev.replyEmbeds(EmbedTemplates.error("Очередь пуста", "Сейчас ничего не играет.")).queue();
return;
}
String skipped = manager.getScheduler().getCurrentTrack().getInfo().getTitle();
manager.getScheduler().skip();
event.replyEmbeds(EmbedTemplates.info("Пропущено", "**" + skipped + "**")).queue();
var t = s.getCurrentTrack().getInfo().getTitle();
s.skip();
ev.replyEmbeds(EmbedTemplates.info("Пропущено", "**" + t + "**")).queue();
}
public void handleQueue(SlashCommandInteractionEvent event) {
var manager = getManager(event);
Track current = manager.getScheduler().getCurrentTrack();
List<Track> queue = manager.getScheduler().getQueue();
if (current == null && queue.isEmpty()) {
event.reply("Очередь пуста.").queue();
public void handleQueue(SlashCommandInteractionEvent ev) {
var s = pm.getMusicManager(ev.getGuild().getIdLong()).getScheduler();
var cur = s.getCurrentTrack();
var q = s.getQueue();
if (cur == null && q.isEmpty()) {
ev.reply("Очередь пуста.").queue();
return;
}
var embed =
EmbedFactory.create().withTitle("Очередь треков").withColor(new Color(88, 101, 242));
if (current != null) {
embed.addField(
"Сейчас играет",
"**" + current.getInfo().getTitle() + "**\n" + current.getInfo().getAuthor(),
false);
var eb = EmbedFactory.create().withTitle("Очередь треков").withColor(new Color(88, 101, 242));
if (cur != null) {
var ci = cur.getInfo();
eb.addField("Сейчас играет", "**" + ci.getTitle() + "**\n" + ci.getAuthor(), false);
}
if (!queue.isEmpty()) {
StringBuilder sb = new StringBuilder();
int show = Math.min(queue.size(), 10);
for (int i = 0; i < show; i++) {
Track t = queue.get(i);
sb.append("`").append(i + 1).append(".` **").append(t.getInfo().getTitle()).append("**\n");
}
if (queue.size() > show) {
sb.append("... и ещё **").append(queue.size() - show).append("** треков");
}
embed.addField("В очереди (" + queue.size() + ")", sb.toString(), false);
}
event.replyEmbeds(embed.build()).queue();
if (!q.isEmpty()) eb.addField("В очереди (" + q.size() + ")", buildQueueList(q), false);
ev.replyEmbeds(eb.build()).queue();
}
public void handleNowPlaying(SlashCommandInteractionEvent event) {
var manager = getManager(event);
var track = manager.getScheduler().getCurrentTrack();
private String buildQueueList(List<Track> q) {
var sb = new StringBuilder();
var show = Math.min(q.size(), 10);
for (int i = 0; i < show; i++) {
var t = q.get(i);
sb.append("`").append(i + 1).append(".` **").append(t.getInfo().getTitle()).append("**\n");
}
if (q.size() > show) sb.append("... и ещё **").append(q.size() - show).append("** треков");
return sb.toString();
}
public void handleNowPlaying(SlashCommandInteractionEvent ev) {
var track = pm.getMusicManager(ev.getGuild().getIdLong()).getScheduler().getCurrentTrack();
if (track == null) {
event.reply("Сейчас ничего не играет.").queue();
ev.reply("Сейчас ничего не играет.").queue();
return;
}
var link =
playerManager
.getLavalinkManager()
.getClient()
.getLinkIfCached(event.getGuild().getIdLong());
long pos =
link != null && link.getCachedPlayer() != null ? link.getCachedPlayer().getPosition() : 0;
long dur = track.getInfo().getLength();
event
.replyEmbeds(
EmbedFactory.create()
.withTitle("Сейчас играет")
.withColor(new Color(88, 101, 242))
.addField("Название", "**" + track.getInfo().getTitle() + "**", false)
.addField("Автор", track.getInfo().getAuthor(), true)
.addField("Длительность", progressBar(pos, dur), true)
.build())
.queue();
var link = pm.getLavalinkManager().getClient().getLinkIfCached(ev.getGuild().getIdLong());
var pos =
link != null && link.getCachedPlayer() != null ? link.getCachedPlayer().getPosition() : 0L;
var eb =
EmbedFactory.create()
.withTitle("Сейчас играет")
.withColor(new Color(88, 101, 242))
.addField("Название", "**" + track.getInfo().getTitle() + "**", false)
.addField("Автор", track.getInfo().getAuthor(), true)
.addField("Длительность", progressBar(pos, track.getInfo().getLength()), true)
.build();
ev.replyEmbeds(eb).queue();
}
public void handleVolume(SlashCommandInteractionEvent event) {
if (reqVoice(event) == null) return;
var link =
playerManager
.getLavalinkManager()
.getClient()
.getLinkIfCached(event.getGuild().getIdLong());
Integer percent =
event.getOption("percent") != null ? event.getOption("percent").getAsInt() : null;
if (percent == null) {
int vol =
public void handleVolume(SlashCommandInteractionEvent ev) {
if (reqVoice(ev) == null) return;
var link = pm.getLavalinkManager().getClient().getLinkIfCached(ev.getGuild().getIdLong());
var opt = ev.getOption("percent");
if (opt == null) {
var cur =
link != null && link.getCachedPlayer() != null ? link.getCachedPlayer().getVolume() : 100;
event.replyEmbeds(EmbedTemplates.info("Громкость", "Текущая: **" + vol + "%**")).queue();
ev.replyEmbeds(EmbedTemplates.info("Громкость", "Текущая: **" + cur + "%**")).queue();
return;
}
int vol = Math.max(0, Math.min(100, percent));
if (link != null && link.getCachedPlayer() != null) {
link.createOrUpdatePlayer()
.setVolume(vol)
.subscribe(
null,
err ->
LoggerFactory.getLogger(QueueSlashHandler.class)
.warn("Lavalink: {}", err.getMessage()));
}
event.replyEmbeds(EmbedTemplates.info("Громкость", "Установлена на **" + vol + "%**")).queue();
var vol = Math.max(0, Math.min(100, opt.getAsInt()));
if (link != null && link.getCachedPlayer() != null)
link.createOrUpdatePlayer().setVolume(vol).subscribe(null, LOG);
ev.replyEmbeds(EmbedTemplates.info("Громкость", "Установлена на **" + vol + "%**")).queue();
}
public void handlePause(SlashCommandInteractionEvent event) {
if (reqVoice(event) == null) return;
getManager(event)
public void handlePause(SlashCommandInteractionEvent ev) {
if (reqVoice(ev) == null) return;
pm.getMusicManager(ev.getGuild().getIdLong())
.getPlayer()
.ifPresent(
p ->
p.setPaused(true)
.subscribe(
null,
err ->
LoggerFactory.getLogger(QueueSlashHandler.class)
.warn("Lavalink: {}", err.getMessage())));
event.replyEmbeds(EmbedTemplates.info("Пауза", "Музыка поставлена на паузу.")).queue();
.ifPresent(pl -> pl.setPaused(true).subscribe(null, LOG));
ev.replyEmbeds(EmbedTemplates.info("Пауза", "Музыка поставлена на паузу.")).queue();
}
public void handleResume(SlashCommandInteractionEvent event) {
if (reqVoice(event) == null) return;
getManager(event)
public void handleResume(SlashCommandInteractionEvent ev) {
if (reqVoice(ev) == null) return;
pm.getMusicManager(ev.getGuild().getIdLong())
.getPlayer()
.ifPresent(
p ->
p.setPaused(false)
.subscribe(
null,
err ->
LoggerFactory.getLogger(QueueSlashHandler.class)
.warn("Lavalink: {}", err.getMessage())));
event.replyEmbeds(EmbedTemplates.info("Продолжаем", "Воспроизведение продолжено.")).queue();
.ifPresent(pl -> pl.setPaused(false).subscribe(null, LOG));
ev.replyEmbeds(EmbedTemplates.info("Продолжаем", "Воспроизведение продолжено.")).queue();
}
public void handleShuffle(SlashCommandInteractionEvent event) {
if (reqVoice(event) == null) return;
getManager(event).getScheduler().shuffle();
event.replyEmbeds(EmbedTemplates.info("Перемешано", "Очередь перемешана.")).queue();
public void handleShuffle(SlashCommandInteractionEvent ev) {
if (reqVoice(ev) == null) return;
pm.getMusicManager(ev.getGuild().getIdLong()).getScheduler().shuffle();
ev.replyEmbeds(EmbedTemplates.info("Перемешано", "Очередь перемешана.")).queue();
}
public void handleRepeat(SlashCommandInteractionEvent event) {
if (reqVoice(event) == null) return;
var s = getManager(event).getScheduler();
boolean now = !s.isRepeating();
public void handleRepeat(SlashCommandInteractionEvent ev) {
if (reqVoice(ev) == null) return;
var s = pm.getMusicManager(ev.getGuild().getIdLong()).getScheduler();
var now = !s.isRepeating();
s.setRepeating(now);
event
.replyEmbeds(
EmbedTemplates.info(
"Повтор", now ? "Повтор очереди включён." : "Повтор очереди выключен."))
.queue();
var d = now ? "Повтор очереди включён." : "Повтор очереди выключен.";
ev.replyEmbeds(EmbedTemplates.info("Повтор", d)).queue();
}
private Member reqVoice(SlashCommandInteractionEvent event) {
Member member = event.getMember();
if (member == null || !member.getVoiceState().inAudioChannel()) {
event
.replyEmbeds(EmbedTemplates.error("Голосовой канал", "Зайди в голосовой канал сначала."))
.setEphemeral(true)
.queue();
return null;
}
return member;
}
private GuildMusicManager getManager(SlashCommandInteractionEvent event) {
return playerManager.getMusicManager(event.getGuild().getIdLong());
private Member reqVoice(SlashCommandInteractionEvent ev) {
var m = ev.getMember();
if (m != null && m.getVoiceState().inAudioChannel()) return m;
var em = EmbedTemplates.error("Голосовой канал", "Зайди в голосовой канал сначала.");
ev.replyEmbeds(em).setEphemeral(true).queue();
return null;
}
static String progressBar(long pos, long dur) {
@ -197,11 +148,12 @@ public class QueueSlashHandler {
int filled = (int) ((double) pos / dur * total);
var bar = new StringBuilder("\u2503");
for (int i = 0; i < total; i++) bar.append(i < filled ? "\u2588" : "\u2591");
return bar + "\u2503 " + formatTime(pos) + " / " + formatTime(dur);
var p = String.format("%d:%02d", pos / 60000, (pos / 1000) % 60);
var d = String.format("%d:%02d", dur / 60000, (dur / 1000) % 60);
return bar + "\u2503 " + p + " / " + d;
}
private static String formatTime(long millis) {
long s = millis / 1000;
return String.format("%d:%02d", s / 60, s % 60);
}
private static final Consumer<Throwable> LOG =
err ->
LoggerFactory.getLogger(QueueSlashHandler.class).warn("Lavalink: {}", err.getMessage());
}

View file

@ -0,0 +1,27 @@
package creator.loki.feature.profile;
import java.awt.Color;
public final class RankCardConstants {
public static final int W = 1400;
public static final int H = 500;
public static final int PAD = 28;
public static final int AVATAR_SIZE = 190;
public static final int AVATAR_X = PAD + 18;
public static final int AVATAR_Y = PAD + 30;
public static final int CONTENT_X = AVATAR_X + AVATAR_SIZE + 40;
public static final int CONTENT_W = W - PAD - 20 - CONTENT_X;
public static final int STATS_Y = 360;
public static final int STAT_H = 100;
public static final int STAT_GAP = 18;
public static final Color ACCENT = new Color(255, 145, 0);
public static final Color ACCENT_DIM = new Color(255, 145, 0, 60);
public static final Color WHITE = Color.WHITE;
public static final Color GREY = new Color(190, 190, 200);
public static final Color DARK_CARD = new Color(12, 12, 18, 200);
public static final Color OVERLAY = new Color(8, 8, 16, 180);
public static final Color BAR_BG = new Color(255, 255, 255, 30);
private RankCardConstants() {}
}

View file

@ -1,158 +1,140 @@
package creator.loki.feature.profile;
import static creator.loki.feature.profile.RankCardConstants.*;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Font;
import java.awt.FontFormatException;
import java.awt.FontMetrics;
import java.awt.GradientPaint;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.geom.Ellipse2D;
import java.awt.geom.RoundRectangle2D;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import javax.imageio.ImageIO;
public class RankCardGenerator {
private static final int WIDTH = 1400;
private static final int HEIGHT = 500;
private static final Map<String, Font> FONT_CACHE = new ConcurrentHashMap<>();
private final RankCardRenderer renderer = new RankCardRenderer();
@SuppressWarnings("deprecation")
public BufferedImage generate(ProfileData data) throws Exception {
BufferedImage canvas = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_ARGB);
BufferedImage canvas = new BufferedImage(W, H, BufferedImage.TYPE_INT_ARGB);
Graphics2D g = canvas.createGraphics();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setRenderingHint(
RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
BufferedImage bg = loadBackground("backgrounds/" + data.getBackground());
g.drawImage(bg, 0, 0, WIDTH, HEIGHT, null);
g.setColor(new Color(0, 0, 0, 170));
g.fillRoundRect(20, 20, WIDTH - 40, HEIGHT - 40, 40, 40);
g.setStroke(new BasicStroke(2f));
g.setColor(new Color(255, 140, 0, 100));
g.drawRoundRect(20, 20, WIDTH - 40, HEIGHT - 40, 40, 40);
RankCardRenderer.applyHints(g);
g.drawImage(renderer.loadBackground("backgrounds/" + data.getBackground()), 0, 0, W, H, null);
g.setColor(OVERLAY);
g.fillRect(0, 0, W, H);
drawCard(g, PAD, PAD, W - PAD * 2, H - PAD * 2);
drawRankBadge(g, data.getRank());
BufferedImage avatar = ImageIO.read(new URI(data.getAvatarUrl()).toURL());
drawCircleImage(g, avatar, 70, 90, 180);
drawAvatar(g, avatar, AVATAR_X, AVATAR_Y, AVATAR_SIZE);
g.setStroke(new BasicStroke(5f));
g.setColor(new Color(255, 140, 0));
g.drawOval(70, 90, 180, 180);
int nameY = AVATAR_Y + 58;
g.setFont(renderer.loadFont("fonts/Inter-Bold.ttf", 50f));
g.setColor(WHITE);
g.drawString(data.getUsername(), CONTENT_X, nameY);
int nameW = g.getFontMetrics().stringWidth(data.getUsername());
drawLevelTag(g, data.getLevel(), CONTENT_X + nameW + 20, nameY - 40);
g.setFont(loadFont("fonts/Inter-Bold.ttf", 52f));
g.setColor(Color.WHITE);
g.drawString(data.getUsername(), 320, 150);
g.setFont(renderer.loadFont("fonts/Inter-Regular.ttf", 26f));
g.setColor(GREY);
g.drawString(data.getSubtitle(), CONTENT_X, nameY + 36);
g.setFont(loadFont("fonts/Inter-Bold.ttf", 24f));
g.setColor(new Color(255, 140, 0));
g.fillRoundRect(670, 110, 90, 42, 15, 15);
g.setColor(Color.BLACK);
g.drawString(data.getTag(), 690, 138);
int barLabelY = nameY + 100;
g.setFont(renderer.loadFont("fonts/Inter-Bold.ttf", 22f));
g.setColor(GREY);
g.drawString("ОПЫТ", CONTENT_X, barLabelY);
String xpStr = data.getXp() + " / " + data.getMaxXp() + " XP";
g.setColor(ACCENT);
FontMetrics fm = g.getFontMetrics();
g.drawString(xpStr, CONTENT_X + CONTENT_W - fm.stringWidth(xpStr), barLabelY);
renderer.drawXpBar(g, CONTENT_X, barLabelY + 12, CONTENT_W, 22, data.getXp(), data.getMaxXp());
g.setFont(loadFont("fonts/Inter-Regular.ttf", 28f));
g.setColor(new Color(220, 220, 220));
g.drawString(data.getSubtitle(), 320, 210);
g.setFont(loadFont("fonts/Inter-Bold.ttf", 48f));
g.setColor(new Color(255, 140, 0));
g.drawString(String.valueOf(data.getLevel()), 320, 300);
g.setFont(loadFont("fonts/Inter-Bold.ttf", 36f));
g.drawString("УРОВЕНЬ", 380, 300);
g.setFont(loadFont("fonts/Inter-Regular.ttf", 34f));
g.setColor(Color.WHITE);
g.drawString(data.getXp() + " / " + data.getMaxXp() + " XP", 920, 300);
int barX = 320;
int barY = 340;
int barWidth = 620;
int barHeight = 28;
g.setColor(new Color(255, 255, 255, 40));
g.fillRoundRect(barX, barY, barWidth, barHeight, 20, 20);
float progress = (float) data.getXp() / data.getMaxXp();
int fill = (int) (barWidth * progress);
g.setColor(new Color(255, 140, 0));
g.fillRoundRect(barX, barY, fill, barHeight, 20, 20);
int dotX = barX + fill - 10;
g.setColor(Color.WHITE);
g.fillOval(dotX, barY - 6, 40, 40);
drawIconStat(g, "АКТИВНОСТЬ", data.getActivity(), IconType.CLOCK, 80, 400);
drawIconStat(g, "СООБЩЕНИЙ", String.valueOf(data.getMessages()), IconType.MESSAGE, 500, 400);
drawIconStat(g, "РЕПУТАЦИЯ", String.valueOf(data.getReputation()), IconType.STAR, 920, 400);
int statW = (CONTENT_W - STAT_GAP * 2) / 3;
renderer.drawStatCard(
g,
"АКТИВНОСТЬ",
data.getActivity(),
RankCardIcons.StatIcon.CLOCK,
CONTENT_X,
STATS_Y,
statW,
STAT_H);
renderer.drawStatCard(
g,
"СООБЩЕНИЙ",
String.valueOf(data.getMessages()),
RankCardIcons.StatIcon.MESSAGE,
CONTENT_X + statW + STAT_GAP,
STATS_Y,
statW,
STAT_H);
renderer.drawStatCard(
g,
"РЕПУТАЦИЯ",
String.valueOf(data.getReputation()),
RankCardIcons.StatIcon.STAR,
CONTENT_X + (statW + STAT_GAP) * 2,
STATS_Y,
statW,
STAT_H);
g.dispose();
return canvas;
}
private enum IconType {
CLOCK,
MESSAGE,
STAR
private void drawCard(Graphics2D g, int x, int y, int w, int h) {
RoundRectangle2D rect = new RoundRectangle2D.Float(x, y, w, h, 36, 36);
g.setColor(new Color(10, 10, 18, 210));
g.fill(rect);
GradientPaint gp = new GradientPaint(x, y, ACCENT_DIM, x + 6, y, new Color(0, 0, 0, 0));
g.setPaint(gp);
g.setStroke(new BasicStroke(3f));
g.draw(rect);
g.setPaint(new Color(255, 145, 0, 80));
g.setStroke(new BasicStroke(1.5f));
g.draw(rect);
}
private void drawIconStat(Graphics2D g, String title, String value, IconType icon, int x, int y) {
g.setColor(new Color(15, 15, 15, 180));
g.fillRoundRect(x, y, 340, 110, 30, 30);
g.setColor(new Color(255, 140, 0));
g.fillOval(x + 25, y + 25, 48, 48);
g.setColor(Color.WHITE);
g.setFont(loadFont("fonts/Inter-Bold.ttf", 36f));
g.setColor(Color.WHITE);
g.drawString(value, x + 100, y + 50);
g.setFont(loadFont("fonts/Inter-Regular.ttf", 24f));
g.setColor(new Color(180, 180, 180));
g.drawString(title, x + 100, y + 90);
}
private void drawCircleImage(Graphics2D g, BufferedImage image, int x, int y, int size) {
Ellipse2D.Double circle = new Ellipse2D.Double(x, y, size, size);
g.setClip(circle);
g.drawImage(image, x, y, size, size, null);
private void drawAvatar(Graphics2D g, BufferedImage src, int x, int y, int size) {
g.setColor(new Color(255, 145, 0, 45));
g.fillOval(x - 10, y - 10, size + 20, size + 20);
Ellipse2D clip = new Ellipse2D.Double(x, y, size, size);
g.setClip(clip);
g.drawImage(src, x, y, size, size, null);
g.setClip(null);
g.setStroke(new BasicStroke(4f));
g.setColor(ACCENT);
g.drawOval(x, y, size, size);
}
private Font loadFont(String path, float size) {
String key = path + "@" + size;
Font cached = FONT_CACHE.get(key);
if (cached != null) {
return cached;
}
try (InputStream is = getClass().getClassLoader().getResourceAsStream("assets/" + path)) {
if (is == null) {
Font fallback = new Font("Arial", Font.PLAIN, (int) size);
FONT_CACHE.put(key, fallback);
return fallback;
}
Font font = Font.createFont(Font.TRUETYPE_FONT, is).deriveFont(size);
FONT_CACHE.put(key, font);
return font;
} catch (FontFormatException | IOException e) {
Font fallback = new Font("Arial", Font.PLAIN, (int) size);
FONT_CACHE.put(key, fallback);
return fallback;
}
private void drawLevelTag(Graphics2D g, int level, int x, int y) {
String text = "LVL " + level;
g.setFont(renderer.loadFont("fonts/Inter-Bold.ttf", 20f));
FontMetrics fm = g.getFontMetrics();
int tw = fm.stringWidth(text);
int th = fm.getAscent();
int px = 14, py = 8;
g.setColor(ACCENT);
g.fillRoundRect(x, y, tw + px * 2, th + py * 2, 12, 12);
g.setColor(new Color(20, 10, 0));
g.drawString(text, x + px, y + py + th - 2);
}
private BufferedImage loadBackground(String path) throws Exception {
try (InputStream is = getClass().getClassLoader().getResourceAsStream("assets/" + path)) {
if (is == null) {
throw new IllegalArgumentException("Resource not found: assets/" + path);
}
return ImageIO.read(is);
}
private void drawRankBadge(Graphics2D g, int rank) {
int bx = W - PAD - 20 - 110;
int by = PAD + 22;
g.setColor(new Color(20, 20, 30, 200));
g.fillRoundRect(bx, by, 110, 44, 14, 14);
g.setStroke(new BasicStroke(1.2f));
g.setColor(ACCENT_DIM);
g.drawRoundRect(bx, by, 110, 44, 14, 14);
g.setFont(renderer.loadFont("fonts/Inter-Bold.ttf", 14f));
g.setColor(GREY);
g.drawString("РАНГ", bx + 14, by + 17);
g.setFont(renderer.loadFont("fonts/Inter-Bold.ttf", 26f));
g.setColor(ACCENT);
g.drawString("#" + rank, bx + 14, by + 37);
}
}

View file

@ -0,0 +1,58 @@
package creator.loki.feature.profile;
import java.awt.BasicStroke;
import java.awt.Graphics2D;
import java.awt.geom.GeneralPath;
public final class RankCardIcons {
public enum StatIcon {
CLOCK,
MESSAGE,
STAR
}
public static void draw(Graphics2D g, StatIcon icon, int cx, int cy, int r) {
g.setStroke(new BasicStroke(2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
switch (icon) {
case CLOCK -> drawClock(g, cx, cy, r);
case MESSAGE -> drawMessage(g, cx, cy, r);
case STAR -> drawStar(g, cx, cy, r);
}
}
private static void drawClock(Graphics2D g, int cx, int cy, int r) {
g.drawOval(cx - r, cy - r, r * 2, r * 2);
g.drawLine(cx, cy, cx - r / 2, cy - r / 2);
g.drawLine(cx, cy, cx, cy - (int) (r * 0.8));
g.fillOval(cx - 2, cy - 2, 4, 4);
}
private static void drawMessage(Graphics2D g, int cx, int cy, int r) {
int bx = cx - r, by = cy - (int) (r * 0.85);
int bw = r * 2, bh = (int) (r * 1.5);
g.drawRoundRect(bx, by, bw, bh, 6, 6);
GeneralPath tail = new GeneralPath();
tail.moveTo(cx - r / 2, by + bh);
tail.lineTo(cx - r / 2 - 4, by + bh + 7);
tail.lineTo(cx - r / 2 + 8, by + bh);
tail.closePath();
g.fill(tail);
}
private static void drawStar(Graphics2D g, int cx, int cy, int r) {
double inner = r * 0.42;
GeneralPath star = new GeneralPath();
for (int i = 0; i < 10; i++) {
double angle = Math.PI / 5 * i - Math.PI / 2;
double rad = (i % 2 == 0) ? r : inner;
double px = cx + Math.cos(angle) * rad;
double py = cy + Math.sin(angle) * rad;
if (i == 0) star.moveTo(px, py);
else star.lineTo(px, py);
}
star.closePath();
g.fill(star);
}
private RankCardIcons() {}
}

View file

@ -0,0 +1,118 @@
package creator.loki.feature.profile;
import static creator.loki.feature.profile.RankCardConstants.*;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Font;
import java.awt.FontFormatException;
import java.awt.GradientPaint;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import javax.imageio.ImageIO;
public class RankCardRenderer {
private static final Map<String, Font> FONT_CACHE = new ConcurrentHashMap<>();
public void drawXpBar(Graphics2D g, int x, int y, int w, int h, int xp, int maxXp) {
g.setColor(BAR_BG);
g.fillRoundRect(x, y, w, h, h, h);
float pct = maxXp > 0 ? Math.min(1f, (float) xp / maxXp) : 0f;
int fill = Math.max(h, (int) (w * pct));
GradientPaint gp = new GradientPaint(x, y, new Color(255, 100, 0), x + fill, y, ACCENT);
g.setPaint(gp);
g.fillRoundRect(x, y, fill, h, h, h);
int dotD = h + 14;
int dotX = x + fill - dotD / 2;
int dotY = y - 7;
g.setColor(new Color(30, 20, 0));
g.fillOval(dotX, dotY, dotD, dotD);
g.setColor(ACCENT);
g.setStroke(new BasicStroke(2.5f));
g.drawOval(dotX, dotY, dotD, dotD);
g.setColor(WHITE);
g.fillOval(dotX + 5, dotY + 5, dotD - 10, dotD - 10);
}
public void drawStatCard(
Graphics2D g,
String title,
String value,
RankCardIcons.StatIcon icon,
int x,
int y,
int w,
int h) {
g.setColor(DARK_CARD);
g.fillRoundRect(x, y, w, h, 20, 20);
g.setStroke(new BasicStroke(1f));
g.setColor(new Color(255, 145, 0, 40));
g.drawRoundRect(x, y, w, h, 20, 20);
int iconD = 48;
int iconX = x + 18;
int iconY = y + (h - iconD) / 2;
g.setColor(new Color(255, 145, 0, 30));
g.fillOval(iconX, iconY, iconD, iconD);
g.setColor(ACCENT);
g.setStroke(new BasicStroke(1.5f));
RankCardIcons.draw(g, icon, iconX + iconD / 2, iconY + iconD / 2, 13);
int textX = iconX + iconD + 16;
g.setFont(loadFont("fonts/Inter-Bold.ttf", 30f));
g.setColor(WHITE);
g.drawString(value, textX, y + 46);
g.setFont(loadFont("fonts/Inter-Regular.ttf", 18f));
g.setColor(GREY);
g.drawString(title, textX, y + 72);
}
public Font loadFont(String path, float size) {
String key = path + "@" + size;
Font cached = FONT_CACHE.get(key);
if (cached != null) return cached;
try (InputStream is = getClass().getClassLoader().getResourceAsStream("assets/" + path)) {
if (is == null) {
Font fallback = new Font("SansSerif", Font.BOLD, (int) size);
FONT_CACHE.put(key, fallback);
return fallback;
}
Font font = Font.createFont(Font.TRUETYPE_FONT, is).deriveFont(size);
FONT_CACHE.put(key, font);
return font;
} catch (FontFormatException | IOException e) {
Font fallback = new Font("SansSerif", Font.BOLD, (int) size);
FONT_CACHE.put(key, fallback);
return fallback;
}
}
public BufferedImage loadBackground(String path) throws Exception {
try (InputStream is = getClass().getClassLoader().getResourceAsStream("assets/" + path)) {
if (is == null) {
BufferedImage fallback = new BufferedImage(W, H, BufferedImage.TYPE_INT_RGB);
Graphics2D fg = fallback.createGraphics();
fg.setColor(new Color(15, 15, 25));
fg.fillRect(0, 0, W, H);
fg.dispose();
return fallback;
}
return ImageIO.read(is);
}
}
public static void applyHints(Graphics2D g) {
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setRenderingHint(
RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_LCD_HRGB);
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g.setRenderingHint(
RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
g.setRenderingHint(
RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY);
}
}

View file

@ -2,10 +2,15 @@ package creator.loki.feature.radio;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import creator.loki.feature.music.core.LavalinkManager;
import creator.loki.feature.music.manage.GuildMusicManager;
import creator.loki.feature.music.repository.QueueRepository;
import creator.loki.util.LoggerDecorator;
import creator.loki.util.VoiceHelper;
import dev.arbjerg.lavalink.client.AbstractAudioLoadResultHandler;
import dev.arbjerg.lavalink.client.Link;
import dev.arbjerg.lavalink.client.player.TrackLoaded;
@ -14,23 +19,31 @@ import net.dv8tion.jda.api.entities.Guild;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Singleton
public class RadioManager {
private static final Logger logger = LoggerFactory.getLogger(RadioManager.class);
private static final long VOICE_TIMEOUT_MS = 5000;
private final Map<Long, GuildRadioState> radioStates = new ConcurrentHashMap<>();
private final QueueRepository queueRepository;
private LavalinkManager lavalinkManager;
private JDA radioJda;
public void setLavalinkManager(LavalinkManager lm) {
this.lavalinkManager = lm;
@Inject
public RadioManager(QueueRepository queueRepository) {
this.queueRepository = queueRepository;
}
public void setRadioJda(JDA radioJda) {
this.radioJda = radioJda;
}
public void setLavalinkManager(LavalinkManager lm) { this.lavalinkManager = lm; }
public void setRadioJda(JDA radioJda) { this.radioJda = radioJda; }
public void setRadio(long guildId, String streamUrl) {
radioStates.computeIfAbsent(guildId, id -> new GuildRadioState()).streamUrl = streamUrl;
radioStates.compute(guildId, (id, state) -> {
if (state == null) state = new GuildRadioState();
state.streamUrl = streamUrl;
return state;
});
LoggerDecorator.music(logger, "Radio URL set for guild " + guildId + ": " + streamUrl);
}
@ -43,86 +56,65 @@ public class RadioManager {
GuildRadioState state = radioStates.get(guildId);
if (state == null || state.streamUrl == null || state.streamUrl.isBlank()) return;
if (radioJda == null || lavalinkManager == null) return;
if (isRadioActive(guildId) && state.musicManager != null) {
stopRadio(guildId);
}
var finalState = radioStates.computeIfAbsent(guildId, id -> new GuildRadioState());
finalState.streamUrl = getRadioUrl(guildId);
Guild guild = radioJda.getGuildById(guildId);
if (guild == null) return;
guild.getJDA().getDirectAudioController().connect(
guild.getVoiceChannelById(channelId));
for (int i = 0; i < 10; i++) {
if (guild.getMember(guild.getJDA().getSelfUser()).getVoiceState().inAudioChannel()) break;
try {
Thread.sleep(200);
} catch (InterruptedException e) {
return;
}
guild.getJDA().getDirectAudioController().connect(guild.getVoiceChannelById(channelId));
var self = guild.getMember(guild.getJDA().getSelfUser());
if (!VoiceHelper.waitForCondition(() -> self.getVoiceState().inAudioChannel(), VOICE_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
LoggerDecorator.warn(logger, "Radio: voice connection timeout for guild " + guildId);
return;
}
var link = lavalinkManager.getOrCreateLink(guildId);
for (int i = 0; i < 10; i++) {
if (link.getCachedPlayer() != null) break;
try {
Thread.sleep(200);
} catch (InterruptedException e) {
return;
}
if (!VoiceHelper.waitForCondition(() -> link.getCachedPlayer() != null, VOICE_TIMEOUT_MS, TimeUnit.MILLISECONDS)) {
LoggerDecorator.warn(logger, "Radio: Lavalink player timeout for guild " + guildId);
return;
}
var mm = new GuildMusicManager(guildId, lavalinkManager, null);
state.musicManager = mm;
lavalinkManager
.getOrCreateLink(guildId)
.loadItem(state.streamUrl)
.subscribe(
new AbstractAudioLoadResultHandler() {
@Override
public void ontrackLoaded(TrackLoaded r) {
mm.getScheduler().enqueue(r.getTrack());
LoggerDecorator.music(logger, "Radio started in guild " + guildId);
}
@Override
public void onPlaylistLoaded(
dev.arbjerg.lavalink.client.player.PlaylistLoaded r) {
if (!r.getTracks().isEmpty()) {
mm.getScheduler().enqueue(r.getTracks().get(0));
LoggerDecorator.music(logger, "Radio started in guild " + guildId);
}
}
@Override
public void onSearchResultLoaded(
dev.arbjerg.lavalink.client.player.SearchResult r) {
if (!r.getTracks().isEmpty()) {
mm.getScheduler().enqueue(r.getTracks().get(0));
LoggerDecorator.music(logger, "Radio started in guild " + guildId);
}
}
@Override
public void noMatches() {
LoggerDecorator.warn(logger, "No radio stream: " + state.streamUrl);
}
@Override
public void loadFailed(
dev.arbjerg.lavalink.client.player.LoadFailed e) {
LoggerDecorator.warn(logger, "Failed to load radio: "
+ (e.getException().getMessage() != null
? e.getException().getMessage() : "unknown"));
}
});
var mm = new GuildMusicManager(guildId, lavalinkManager, queueRepository);
int vol = finalState.volume;
var url = finalState.streamUrl;
finalState.musicManager = mm;
lavalinkManager.getOrCreateLink(guildId).loadItem(url)
.subscribe(new AbstractAudioLoadResultHandler() {
@Override
public void ontrackLoaded(TrackLoaded r) {
if (vol != 100) link.createOrUpdatePlayer().setVolume(vol).subscribe();
mm.getScheduler().enqueue(r.getTrack());
LoggerDecorator.music(logger, "Radio started in guild " + guildId);
}
@Override
public void onPlaylistLoaded(dev.arbjerg.lavalink.client.player.PlaylistLoaded r) {
if (!r.getTracks().isEmpty()) {
if (vol != 100) link.createOrUpdatePlayer().setVolume(vol).subscribe();
mm.getScheduler().enqueue(r.getTracks().get(0));
LoggerDecorator.music(logger, "Radio started in guild " + guildId);
}
}
@Override
public void onSearchResultLoaded(dev.arbjerg.lavalink.client.player.SearchResult r) {
if (!r.getTracks().isEmpty()) {
if (vol != 100) link.createOrUpdatePlayer().setVolume(vol).subscribe();
mm.getScheduler().enqueue(r.getTracks().get(0));
LoggerDecorator.music(logger, "Radio started in guild " + guildId);
}
}
@Override
public void noMatches() { LoggerDecorator.warn(logger, "No radio stream: " + url); }
@Override
public void loadFailed(dev.arbjerg.lavalink.client.player.LoadFailed e) {
LoggerDecorator.warn(logger, "Failed to load radio: " + (e.getException().getMessage() != null ? e.getException().getMessage() : "unknown"));
}
});
}
public void stopRadio(long guildId) {
GuildRadioState state = radioStates.remove(guildId);
if (state == null) return;
if (state.musicManager != null) {
state.musicManager.stop();
state.musicManager.destroy();
}
if (state.musicManager != null) { state.musicManager.stop(); state.musicManager.destroy(); }
if (lavalinkManager != null) {
Link link = lavalinkManager.getClient().getLinkIfCached(guildId);
if (link != null) link.destroy().subscribe();
@ -135,16 +127,13 @@ public class RadioManager {
}
public boolean isRadioActive(long guildId) {
return radioStates.containsKey(guildId);
GuildRadioState state = radioStates.get(guildId);
return state != null && state.streamUrl != null;
}
public boolean hasRadioBot() {
return radioJda != null;
}
public boolean hasRadioBot() { return radioJda != null; }
public JDA getRadioJda() {
return radioJda;
}
public JDA getRadioJda() { return radioJda; }
public int getVolume(long guildId) {
GuildRadioState state = radioStates.get(guildId);
@ -152,13 +141,13 @@ public class RadioManager {
}
public void setVolume(long guildId, int volume) {
GuildRadioState state = radioStates.computeIfAbsent(guildId, id -> new GuildRadioState());
GuildRadioState state = radioStates.get(guildId);
if (state == null) return;
state.volume = Math.max(0, Math.min(100, volume));
if (lavalinkManager != null) {
var link = lavalinkManager.getClient().getLinkIfCached(guildId);
if (link != null && link.getCachedPlayer() != null) {
link.createOrUpdatePlayer()
.setVolume(state.volume)
link.createOrUpdatePlayer().setVolume(state.volume)
.subscribe(null, err -> logger.warn("Lavalink: {}", err.getMessage()));
}
}

View file

@ -8,15 +8,16 @@ import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.stream.Collectors;
import javax.imageio.ImageIO;
import creator.loki.feature.counter.DailyStats;
import creator.loki.feature.counter.repository.CounterStatsRepository;
import net.dv8tion.jda.api.OnlineStatus;
import net.dv8tion.jda.api.entities.Guild;
public class ServerStatsService {
private static final int CHART_WIDTH = 800;
private static final int CHART_HEIGHT = 400;
private static final int MARGIN = 60;
@ -38,62 +39,56 @@ public class ServerStatsService {
}
public byte[] generateChart(List<DailyStats> history) throws Exception {
BufferedImage image = new BufferedImage(CHART_WIDTH, CHART_HEIGHT, BufferedImage.TYPE_INT_RGB);
Graphics2D g = image.createGraphics();
var image = new BufferedImage(CHART_WIDTH, CHART_HEIGHT, BufferedImage.TYPE_INT_RGB);
var g = image.createGraphics();
g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g.setRenderingHint(
RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
g.setColor(COLOR_BG);
g.fillRect(0, 0, CHART_WIDTH, CHART_HEIGHT);
int maxVal =
var maxVal =
history.stream().mapToInt(s -> Math.max(s.getJoined(), s.getLeft())).max().orElse(1);
int plotW = CHART_WIDTH - 2 * MARGIN;
int plotH = CHART_HEIGHT - 2 * MARGIN;
int barW = Math.max(4, plotW / (history.size() * 2 + 1));
var plotW = CHART_WIDTH - 2 * MARGIN;
var plotH = CHART_HEIGHT - 2 * MARGIN;
var barW = Math.max(4, plotW / (history.size() * 2 + 1));
g.setColor(COLOR_GRID);
for (int i = 0; i <= 4; i++) {
int y = MARGIN + plotH - (plotH * i / 4);
for (var i = 0; i <= 4; i++) {
var y = MARGIN + plotH - (plotH * i / 4);
g.drawLine(MARGIN, y, CHART_WIDTH - MARGIN, y);
}
Font labelFont = new Font("Arial", Font.PLAIN, 11);
Font titleFont = new Font("Arial", Font.BOLD, 16);
g.setColor(COLOR_TEXT);
g.setFont(titleFont);
g.drawString("Активность сервера (присоединились / вышли)", 160, 30);
g.setFont(labelFont);
g.setColor(COLOR_TEXT);
for (int i = 0; i <= 4; i++) {
int y = MARGIN + plotH - (plotH * i / 4);
String label = String.valueOf(maxVal * i / 4);
g.drawString(label, 5, y + 4);
}
int maxBars = Math.min(history.size(), 30);
drawLabels(g, plotH, maxVal);
var maxBars = Math.min(history.size(), 30);
history = history.subList(history.size() - maxBars, history.size());
drawData(g, history, maxVal, plotH, barW);
drawLegend(g);
g.dispose();
var baos = new ByteArrayOutputStream();
ImageIO.write(image, "PNG", baos);
return baos.toByteArray();
}
int step = Math.max(1, history.size() / 15);
for (int i = 0; i < history.size(); i++) {
DailyStats stats = history.get(i);
int x = MARGIN + i * (barW * 2 + 2);
int joinedH = (int) ((double) stats.getJoined() / maxVal * plotH);
int leftH = (int) ((double) stats.getLeft() / maxVal * plotH);
private void drawLabels(Graphics2D g, int plotH, int maxVal) {
g.setColor(COLOR_TEXT);
g.setFont(new Font("Arial", Font.BOLD, 16));
g.drawString("Активность сервера (присоединились / вышли)", 160, 30);
g.setFont(new Font("Arial", Font.PLAIN, 11));
for (var i = 0; i <= 4; i++) {
var y = MARGIN + plotH - (plotH * i / 4);
g.drawString(String.valueOf(maxVal * i / 4), 5, y + 4);
}
}
private void drawData(Graphics2D g, List<DailyStats> history, int maxVal, int plotH, int barW) {
var step = Math.max(1, history.size() / 15);
for (var i = 0; i < history.size(); i++) {
var stats = history.get(i);
var x = MARGIN + i * (barW * 2 + 2);
var joinedH = (int) ((double) stats.getJoined() / maxVal * plotH);
var leftH = (int) ((double) stats.getLeft() / maxVal * plotH);
g.setColor(COLOR_JOINED);
g.fillRect(x, MARGIN + plotH - joinedH, barW, joinedH);
g.setColor(COLOR_LEFT);
g.fillRect(x + barW + 2, MARGIN + plotH - leftH, barW, leftH);
if (i % step == 0) {
g.setColor(COLOR_TEXT);
g.rotate(-Math.PI / 4, x + barW, CHART_HEIGHT - MARGIN + 15);
@ -101,60 +96,48 @@ public class ServerStatsService {
g.rotate(Math.PI / 4, x + barW, CHART_HEIGHT - MARGIN + 15);
}
}
}
private void drawLegend(Graphics2D g) {
g.setFont(new Font("Arial", Font.PLAIN, 12));
g.setColor(COLOR_JOINED);
g.fillRect(CHART_WIDTH - 170, 10, 12, 12);
g.setColor(COLOR_TEXT);
g.drawString("Присоединились", CHART_WIDTH - 152, 21);
g.setColor(COLOR_LEFT);
g.fillRect(CHART_WIDTH - 60, 10, 12, 12);
g.setColor(COLOR_TEXT);
g.drawString("Вышли", CHART_WIDTH - 42, 21);
g.dispose();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(image, "PNG", baos);
return baos.toByteArray();
}
public byte[] generateCsv(Guild guild, List<DailyStats> history) {
String header =
var members = guild.getMembers();
var owner = guild.getOwner();
var online = members.stream().filter(m -> m.getOnlineStatus() == OnlineStatus.ONLINE).count();
var bots = members.stream().filter(m -> m.getUser().isBot()).count();
var info = new StringBuilder();
info.append(escapeCsv(guild.getName())).append(",");
info.append(guild.getId()).append(",");
info.append(owner != null ? escapeCsv(owner.getUser().getGlobalName()) : "").append(",");
info.append(guild.getTimeCreated().format(DateTimeFormatter.ISO_DATE)).append(",");
info.append(guild.getMemberCount()).append(",");
info.append(online).append(",");
info.append(bots).append(",");
info.append(guild.getTextChannels().size()).append(",");
info.append(guild.getVoiceChannels().size()).append(",");
info.append(guild.getRoles().size()).append("\n");
var dailyRows = new StringBuilder();
for (var s : history) {
dailyRows.append(
s.getDate() + "," + s.getJoined() + "," + s.getLeft() + "," + s.getDifference() + "\n");
}
var totalJoined = history.stream().mapToInt(DailyStats::getJoined).sum();
var totalLeft = history.stream().mapToInt(DailyStats::getLeft).sum();
var totals = String.format("%ntotal,%d,%d,%d", totalJoined, totalLeft, totalJoined - totalLeft);
var header =
"server_name,server_id,owner,created_at,total_members,online,bots,text_channels,voice_channels,roles\n";
String info =
String.format(
"%s,%s,%s,%s,%d,%d,%d,%d,%d,%d%n",
escapeCsv(guild.getName()),
guild.getId(),
guild.getOwner() != null ? escapeCsv(guild.getOwner().getUser().getGlobalName()) : "",
guild.getTimeCreated().format(DateTimeFormatter.ISO_DATE),
guild.getMemberCount(),
guild.getMembers().stream()
.filter(m -> m.getOnlineStatus() == net.dv8tion.jda.api.OnlineStatus.ONLINE)
.count(),
guild.getMembers().stream().filter(m -> m.getUser().isBot()).count(),
guild.getTextChannels().size(),
guild.getVoiceChannels().size(),
guild.getRoles().size());
String dailyHeader = "\ndate,joined,left,difference\n";
String dailyRows =
history.stream()
.map(
s ->
String.format(
"%s,%d,%d,%d", s.getDate(), s.getJoined(), s.getLeft(), s.getDifference()))
.collect(Collectors.joining("\n"));
int totalJoined = history.stream().mapToInt(DailyStats::getJoined).sum();
int totalLeft = history.stream().mapToInt(DailyStats::getLeft).sum();
String totals =
String.format("%ntotal,%d,%d,%d", totalJoined, totalLeft, totalJoined - totalLeft);
String csv = header + info + dailyHeader + dailyRows + totals;
return csv.getBytes(java.nio.charset.StandardCharsets.UTF_8);
return (header + info + "\ndate,joined,left,difference\n" + dailyRows + totals)
.getBytes(java.nio.charset.StandardCharsets.UTF_8);
}
private static String escapeCsv(String value) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,140 @@
package creator.loki.feature.social.commands;
import java.awt.Color;
import java.util.List;
import creator.loki.command.Command;
import creator.loki.command.CommandCategories;
import creator.loki.command.CommandContext;
import creator.loki.core.security.CooldownManager;
import creator.loki.embed.EmbedFactory;
import creator.loki.integration.nekos.NekosLifeApi;
import net.dv8tion.jda.api.entities.Member;
public class SocialCommand implements Command {
public enum Flavor {
HUG("hug", "Обнять пользователя", "🤗", "обнял", new Color(255, 182, 193), false, false),
KISS("kiss", "Поцеловать пользователя", "💋", "целует", new Color(255, 105, 180), true, true),
SLAP("slap", "Шлепнуть пользователя", "👋", "шлепает", new Color(255, 165, 0), false, false),
POKE("poke", "Тыкнуть пользователя", "👉", "тыкает в", new Color(0, 128, 128), false, false),
TICKLE(
"tickle", "Пощекотать пользователя", "🤗", "щекочет", new Color(0, 255, 0), false, false),
CUDDLE(
"cuddle",
"Обниматься с пользователем",
"🥰",
"обнимается с",
new Color(255, 0, 255),
false,
false),
FEED("feed", "Покормить пользователя", "🍽️", "кормит", new Color(255, 200, 100), false, false);
final String name;
final String description;
final String emoji;
final String verb;
final Color color;
final boolean checkSelf;
final boolean checkBot;
Flavor(
String name,
String description,
String emoji,
String verb,
Color color,
boolean checkSelf,
boolean checkBot) {
this.name = name;
this.description = description;
this.emoji = emoji;
this.verb = verb;
this.color = color;
this.checkSelf = checkSelf;
this.checkBot = checkBot;
}
}
private final Flavor flavor;
private final CooldownManager cooldownManager;
public SocialCommand(Flavor flavor, CooldownManager cooldownManager) {
this.flavor = flavor;
this.cooldownManager = cooldownManager;
}
@Override
public String getName() {
return flavor.name;
}
@Override
public String getDescription() {
return flavor.description;
}
@Override
public String getUsage() {
return flavor.name + " <@user>";
}
@Override
public String getCategory() {
return CommandCategories.GIF_INTERACTIVE;
}
@Override
public void execute(CommandContext context) {
if (cooldownManager.isOnCooldown(context.getAuthor().getId(), flavor.name)) {
long remaining =
cooldownManager.getRemainingSeconds(context.getAuthor().getId(), flavor.name);
context.getChannel().sendMessage("⏰ Подожди ещё " + remaining + " сек").queue();
return;
}
List<Member> mentioned = context.getMessage().getMentions().getMembers();
if (mentioned.isEmpty()) {
context.getChannel().sendMessage("❌ Укажи пользователя через @").queue();
return;
}
Member target = mentioned.get(0);
if (flavor.checkSelf && target.getId().equals(context.getAuthor().getId())) {
context.getChannel().sendMessage("❌ Нельзя использовать на себе!").queue();
return;
}
if (flavor.checkBot && target.getUser().isBot()) {
context.getChannel().sendMessage("❌ Боты не поддерживают это действие!").queue();
return;
}
cooldownManager.applyCooldown(context.getAuthor().getId(), flavor.name, 5);
String gifUrl = NekosLifeApi.fetchImage(flavor.name);
if (gifUrl == null) {
context.getChannel().sendMessage("❌ Не удалось получить изображение!").queue();
return;
}
context
.getChannel()
.sendMessageEmbeds(
EmbedFactory.create()
.withDescription(
flavor.emoji
+ " "
+ context.getAuthor().getAsMention()
+ " "
+ flavor.verb
+ " "
+ target.getAsMention())
.withImage(gifUrl)
.withColor(flavor.color)
.withFooter("Powered by nekos.life")
.build())
.queue();
}
}

View file

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

View file

@ -4,13 +4,13 @@ import com.google.inject.Inject;
import com.google.inject.Singleton;
import creator.loki.util.LoggerDecorator;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.components.label.Label;
import net.dv8tion.jda.api.components.textinput.TextInput;
import net.dv8tion.jda.api.components.textinput.TextInputStyle;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import net.dv8tion.jda.api.components.label.Label;
import net.dv8tion.jda.api.components.textinput.TextInput;
import net.dv8tion.jda.api.components.textinput.TextInputStyle;
import net.dv8tion.jda.api.modals.Modal;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

View file

@ -9,19 +9,22 @@ import creator.loki.feature.voice.repository.VoicePresetRepository;
import creator.loki.util.LoggerDecorator;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.components.actionrow.ActionRow;
import net.dv8tion.jda.api.components.buttons.Button;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
import net.dv8tion.jda.api.components.actionrow.ActionRow;
import net.dv8tion.jda.api.components.buttons.Button;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class VoiceChannelManager {
private static final Logger logger = LoggerFactory.getLogger(VoiceChannelManager.class);
private static final EnumSet<Permission> OWNER_PERMS =
EnumSet.of(
Permission.MANAGE_CHANNEL, Permission.VOICE_CONNECT,
Permission.VOICE_MOVE_OTHERS, Permission.VOICE_MUTE_OTHERS);
private final VoiceChannelRepository channelRepo;
private final VoicePresetRepository presetRepo;
private String triggerChannelId;
private String categoryId;
@ -41,56 +44,36 @@ public class VoiceChannelManager {
public VoiceChannel createPersonalChannel(Member member, Guild guild) {
try {
String channelName = "Канал: " + member.getEffectiveName();
VoiceChannel voiceChannel =
guild
.createVoiceChannel(channelName)
.setParent(guild.getCategoryById(categoryId))
.addPermissionOverride(
member,
EnumSet.of(
Permission.MANAGE_CHANNEL,
Permission.VOICE_CONNECT,
Permission.VOICE_MOVE_OTHERS,
Permission.VOICE_MUTE_OTHERS),
null)
.complete();
channelRepo.saveChannelData(new VoiceChannelData(voiceChannel.getId(), member.getId()));
var channel = buildPersonalChannel(member, guild);
channelRepo.saveChannelData(new VoiceChannelData(channel.getId(), member.getId()));
LoggerDecorator.voice(
logger,
"Создан личный канал "
+ voiceChannel.getId()
+ " для пользователя "
+ member.getEffectiveName());
applyUserPreset(voiceChannel, member.getId());
return voiceChannel;
"Создан личный канал %s для пользователя %s"
.formatted(channel.getId(), member.getEffectiveName()));
applyUserPreset(channel, member.getId());
return channel;
} catch (Exception e) {
LoggerDecorator.error(logger, "Ошибка при создании личного канала", e);
return null;
}
}
private VoiceChannel buildPersonalChannel(Member member, Guild guild) {
return guild
.createVoiceChannel("Канал: " + member.getEffectiveName())
.setParent(guild.getCategoryById(categoryId))
.addPermissionOverride(member, OWNER_PERMS, null)
.complete();
}
public void deletePersonalChannel(String channelId, Guild guild) {
channelRepo.deleteChannelData(channelId);
try {
VoiceChannel channel = guild.getVoiceChannelById(channelId);
var channel = guild.getVoiceChannelById(channelId);
if (channel != null) {
channel
.delete()
.queue(
success -> {
channelRepo.deleteChannelData(channelId);
LoggerDecorator.voice(logger, "Удален личный канал " + channelId);
},
error ->
LoggerDecorator.error(
logger, "Ошибка при удалении канала " + channelId, error));
} else {
channelRepo.deleteChannelData(channelId);
.queue(success -> LoggerDecorator.voice(logger, "Удален личный канал " + channelId));
}
} catch (Exception e) {
LoggerDecorator.error(logger, "Ошибка при удалении личного канала", e);
@ -110,43 +93,32 @@ public class VoiceChannelManager {
}
public void updatePresetSetting(String userId, String setting, Object value) {
VoicePreset preset = getUserPreset(userId);
switch (setting) {
case "name" -> preset.setName((String) value);
case "limit" -> preset.setLimit((Integer) value);
case "bitrate" -> preset.setBitrate((Integer) value);
case "locked" -> preset.setLocked((Boolean) value);
default -> {
/* ignore */
}
}
var preset = getUserPreset(userId);
if ("name".equals(setting)) preset.setName((String) value);
else if ("limit".equals(setting)) preset.setLimit((Integer) value);
else if ("bitrate".equals(setting)) preset.setBitrate((Integer) value);
else if ("locked".equals(setting)) preset.setLocked((Boolean) value);
saveUserPreset(userId, preset);
}
public void sendControlPanel(VoiceChannel channel, Member owner) {
EmbedBuilder embed =
var limitText = channel.getUserLimit() == 0 ? "∞" : String.valueOf(channel.getUserLimit());
var embed =
new EmbedBuilder()
.setTitle("🎛️ Управление каналом")
.setDescription("Ваш личный голосовой канал")
.addField("Владелец", owner.getAsMention(), true)
.addField(
"Лимит",
channel.getUserLimit() == 0 ? "∞" : String.valueOf(channel.getUserLimit()),
true)
.addField("Лимит", limitText, true)
.setColor(Color.CYAN)
.setFooter("Управляйте каналом с помощью кнопок ниже");
String cid = channel.getId();
Button lockBtn = Button.primary("voice_lock:" + cid, "🔒 Замок");
Button limitBtn = Button.secondary("voice_limit:" + cid, "👥 Лимит");
Button renameBtn = Button.secondary("voice_rename:" + cid, "✏️ Название");
Button bitrateBtn = Button.secondary("voice_bitrate:" + cid, "🎵 Битрейт");
Button deleteBtn = Button.danger("voice_delete:" + cid, "🗑️ Удалить");
channel
.sendMessageEmbeds(embed.build())
.addComponents(ActionRow.of(lockBtn, limitBtn, renameBtn, bitrateBtn, deleteBtn))
.queue();
var row =
ActionRow.of(
Button.primary("voice_lock:" + channel.getId(), "🔒 Замок"),
Button.secondary("voice_limit:" + channel.getId(), "👥 Лимит"),
Button.secondary("voice_rename:" + channel.getId(), "✏️ Название"),
Button.secondary("voice_bitrate:" + channel.getId(), "🎵 Битрейт"),
Button.danger("voice_delete:" + channel.getId(), "🗑️ Удалить"));
channel.sendMessageEmbeds(embed.build()).addComponents(row).queue();
}
public void saveUserPreset(String userId, VoicePreset preset) {
@ -155,28 +127,19 @@ public class VoiceChannelManager {
}
public VoicePreset getUserPreset(String userId) {
VoicePreset preset = presetRepo.getPreset(userId);
return preset != null ? preset : new VoicePreset();
return presetRepo.getPreset(userId) != null ? presetRepo.getPreset(userId) : new VoicePreset();
}
public void applyUserPreset(VoiceChannel channel, String userId) {
VoicePreset preset = getUserPreset(userId);
var preset = getUserPreset(userId);
try {
if (preset.getName() != null && !preset.getName().isEmpty()) {
if (preset.getName() != null && !preset.getName().isEmpty())
channel.getManager().setName(preset.getName()).queue();
}
if (preset.getLimit() >= 0) {
channel.getManager().setUserLimit(preset.getLimit()).queue();
}
if (preset.getBitrate() > 0) {
channel.getManager().setBitrate(preset.getBitrate()).queue();
}
if (preset.getLimit() >= 0) channel.getManager().setUserLimit(preset.getLimit()).queue();
if (preset.getBitrate() > 0) channel.getManager().setBitrate(preset.getBitrate()).queue();
if (preset.isLocked()) {
channel
.upsertPermissionOverride(channel.getGuild().getPublicRole())
.deny(Permission.VOICE_CONNECT)
.queue();
var publicRole = channel.getGuild().getPublicRole();
channel.upsertPermissionOverride(publicRole).deny(Permission.VOICE_CONNECT).queue();
}
LoggerDecorator.voice(
logger, "Применён пресет пользователя " + userId + " к каналу " + channel.getId());

View file

@ -9,10 +9,9 @@ import creator.loki.embed.EmbedFactory;
import creator.loki.feature.config.repository.ConfigRepository;
import creator.loki.util.GuildChannelResolve;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.channel.concrete.Category;
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
public class VoiceSetupPrefixCommand implements Command {
private final VoiceChannelManager voiceChannelManager;
private final ConfigRepository configRepo;
@ -48,13 +47,11 @@ public class VoiceSetupPrefixCommand implements Command {
context.getChannel().sendMessage("❌ У вас нет прав администратора!").queue();
return;
}
List<String> args = context.getArgs();
var args = context.getArgs();
if (args.isEmpty()) {
sendHelp(context);
return;
}
switch (args.get(0).toLowerCase()) {
case "trigger" -> handleTrigger(context, args);
case "category" -> handleCategory(context, args);
@ -65,17 +62,12 @@ public class VoiceSetupPrefixCommand implements Command {
private void handleTrigger(CommandContext context, List<String> args) {
if (args.size() < 2) {
context
.getChannel()
.sendMessage(
"❌ Укажите голосовой канал: `#канал`, `<#id>` или числовой id."
+ "\nПример: `!voice-setup trigger 123456789012345678`")
.queue();
var msg =
"❌ Укажите голосовой канал: `#канал`, `<#id>` или числовой id.\nПример: `!voice-setup trigger 123456789012345678`";
context.getChannel().sendMessage(msg).queue();
return;
}
VoiceChannel channel =
GuildChannelResolve.voice(context.getGuild(), context.getMessage(), args, 1);
var channel = GuildChannelResolve.voice(context.getGuild(), context.getMessage(), args, 1);
if (channel == null) {
context
.getChannel()
@ -85,34 +77,24 @@ public class VoiceSetupPrefixCommand implements Command {
return;
}
configRepo.set("voice.trigger_channel_id", channel.getId());
context
.getChannel()
.sendMessageEmbeds(
EmbedFactory.create()
.withTitle("✅ Канал-триггер установлен")
.withDescription(
"Теперь при входе в "
+ channel.getAsMention()
+ " будет создаваться личный канал")
.withColor(Color.GREEN)
.build())
.queue();
var embed =
EmbedFactory.create()
.withTitle("✅ Канал-триггер установлен")
.withDescription(
"Теперь при входе в " + channel.getAsMention() + " будет создаваться личный канал")
.withColor(Color.GREEN)
.build();
context.getChannel().sendMessageEmbeds(embed).queue();
}
private void handleCategory(CommandContext context, List<String> args) {
if (args.size() < 2) {
context
.getChannel()
.sendMessage(
"❌ Укажите категорию: `#категория`, `<#id>` или числовой id."
+ "\nПример: `!voice-setup category 123456789012345678`")
.queue();
var msg =
"❌ Укажите категорию: `#категория`, `<#id>` или числовой id.\nПример: `!voice-setup category 123456789012345678`";
context.getChannel().sendMessage(msg).queue();
return;
}
Category category =
GuildChannelResolve.category(context.getGuild(), context.getMessage(), args, 1);
var category = GuildChannelResolve.category(context.getGuild(), context.getMessage(), args, 1);
if (category == null) {
context
.getChannel()
@ -122,60 +104,41 @@ public class VoiceSetupPrefixCommand implements Command {
return;
}
configRepo.set("voice.category_id", category.getId());
context
.getChannel()
.sendMessageEmbeds(
EmbedFactory.create()
.withTitle("✅ Категория установлена")
.withDescription(
"Личные каналы будут создаваться в категории: **" + category.getName() + "**")
.withColor(Color.GREEN)
.build())
.queue();
var embed =
EmbedFactory.create()
.withTitle("✅ Категория установлена")
.withDescription(
"Личные каналы будут создаваться в категории: **" + category.getName() + "**")
.withColor(Color.GREEN)
.build();
context.getChannel().sendMessageEmbeds(embed).queue();
}
private void handleInfo(CommandContext context) {
String triggerId = configRepo.get("voice.trigger_channel_id");
String categoryId = configRepo.get("voice.category_id");
var triggerId = configRepo.get("voice.trigger_channel_id");
var categoryId = configRepo.get("voice.category_id");
var embed =
EmbedFactory.create()
.withTitle("🎤 Конфигурация системы личных каналов")
.withColor(Color.BLUE);
if (triggerId != null) {
embed.addField("Канал-триггер", "<#" + triggerId + ">", false);
} else {
embed.addField("Канал-триггер", "❌ Не установлен", false);
}
if (triggerId != null) embed.addField("Канал-триггер", "<#" + triggerId + ">", false);
else embed.addField("Канал-триггер", "❌ Не установлен", false);
if (categoryId != null) {
Category category = context.getGuild().getCategoryById(categoryId);
var category = context.getGuild().getCategoryById(categoryId);
embed.addField("Категория", category != null ? category.getName() : "❌ Не найдена", false);
} else {
embed.addField("Категория", "❌ Не установлена", false);
}
} else embed.addField("Категория", "❌ Не установлена", false);
context.getChannel().sendMessageEmbeds(embed.build()).queue();
}
private void sendHelp(CommandContext context) {
var desc =
"**Доступные команды:**\n• `!voice-setup trigger` - `#войс` или числовой id канала\n• `!voice-setup category` - `#категория` или id\n• `!voice-setup info` - Показать текущую конфигурацию\n\nТекстовые каналы для управления создаются автоматически при создании войса.\nТолько для администраторов!";
context
.getChannel()
.sendMessageEmbeds(
EmbedFactory.create()
.withTitle("🎤 Настройка голосовых каналов")
.withDescription(
"""
**Доступные команды:**
• `!voice-setup trigger` - `#войс` или числовой id канала
• `!voice-setup category` - `#категория` или id
• `!voice-setup info` - Показать текущую конфигурацию
Текстовые каналы для управления создаются автоматически при создании войса.
Только для администраторов!
""")
.withDescription(desc)
.withColor(Color.BLUE)
.build())
.queue();

View file

@ -1,57 +1,69 @@
package creator.loki.integration.nekos;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
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.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.ResponseBody;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/** Утилита для работы с nekos.life API */
@Singleton
public class NekosLifeApi {
private static final Logger logger = LoggerFactory.getLogger(NekosLifeApi.class);
private static final String API_BASE = "https://nekos.life/api/v2/img/";
private static final HttpClient CLIENT =
HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build();
/** Получить URL изображения из nekos.life API с повторными попытками */
public static String fetchImage(String category) {
private static NekosLifeApi instance;
private final OkHttpClient client;
@Inject
public NekosLifeApi() {
this.client =
new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build();
NekosLifeApi.instance = this;
}
NekosLifeApi(OkHttpClient client) {
this.client = client;
NekosLifeApi.instance = this;
}
public String getImage(String category) {
int maxRetries = 3;
int retryDelay = 1000;
long retryDelay = 1000;
for (int attempt = 1; attempt <= maxRetries; attempt++) {
try {
HttpRequest request =
HttpRequest.newBuilder()
.uri(URI.create(API_BASE + category))
.timeout(Duration.ofSeconds(10))
.GET()
.build();
Request request = new Request.Builder().url(API_BASE + category).get().build();
HttpResponse<String> response = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
JsonObject json = JsonParser.parseString(response.body()).getAsJsonObject();
return json.get("url").getAsString();
} else if (response.statusCode() >= 500) {
logger.warn(
"Nekos.life API error: {} (attempt {}/{})",
response.statusCode(),
attempt,
maxRetries);
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful()) {
if (response.code() >= 500) {
logger.warn(
"Nekos.life API error: {} (attempt {}/{})", response.code(), attempt, maxRetries);
} else {
logger.error("Nekos.life API error: {}", response.code());
return null;
}
} else {
logger.error("Nekos.life API error: {}", response.statusCode());
return null;
ResponseBody body = response.body();
if (body == null) {
logger.warn("Empty response body from nekos.life (attempt {}/{})", attempt, maxRetries);
continue;
}
JsonObject json = JsonParser.parseString(body.string()).getAsJsonObject();
return json.get("url").getAsString();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
logger.error("Interrupted while accessing nekos.life API", e);
return null;
} catch (Exception e) {
} catch (IOException e) {
logger.error(
"Error fetching image from nekos.life (attempt {}/{})", attempt, maxRetries, e);
}
@ -70,4 +82,11 @@ public class NekosLifeApi {
logger.error("Failed to fetch image after {} attempts", maxRetries);
return null;
}
public static String fetchImage(String category) {
if (instance == null) {
instance = new NekosLifeApi();
}
return instance.getImage(category);
}
}

View file

@ -0,0 +1,39 @@
package creator.loki.util;
import java.util.concurrent.TimeUnit;
import java.util.function.BooleanSupplier;
public final class VoiceHelper {
private static final int RETRIES = 10;
private static final long DELAY_MS = 200;
private VoiceHelper() {}
public static boolean waitForCondition(BooleanSupplier condition) {
for (int i = 0; i < RETRIES; i++) {
if (condition.getAsBoolean()) return true;
try {
Thread.sleep(DELAY_MS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
return false;
}
public static boolean waitForCondition(BooleanSupplier condition, long timeout, TimeUnit unit) {
long deadline = System.nanoTime() + unit.toNanos(timeout);
long delay = Math.min(DELAY_MS, unit.toMillis(timeout) / RETRIES);
while (System.nanoTime() < deadline) {
if (condition.getAsBoolean()) return true;
try {
Thread.sleep(delay);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return false;
}
}
return false;
}
}

View file

@ -15,6 +15,7 @@ import creator.loki.feature.counter.repository.CounterStatsRepository;
import creator.loki.feature.economy.EconomyCommandRegistrar;
import creator.loki.feature.economy.service.*;
import creator.loki.feature.entertainment.EntertainmentCommandRegistrar;
import creator.loki.feature.entertainment.service.RatingService;
import creator.loki.feature.level.LevelCommandRegistrar;
import creator.loki.feature.level.LevelRoleService;
import creator.loki.feature.level.ResetLevelsCommand;
@ -101,7 +102,10 @@ class SmokeCommandRegistrationTest {
var entertainmentRegistrar =
new EntertainmentCommandRegistrar(
mock(CooldownManager.class), mock(ShipPairRepository.class), mock(BotConfig.class));
mock(CooldownManager.class),
mock(RatingService.class),
mock(ShipPairRepository.class),
mock(BotConfig.class));
entertainmentRegistrar.register(registry);
var utilityRegistrar =

View file

@ -5,6 +5,8 @@ import static org.junit.jupiter.api.Assertions.*;
import java.awt.Color;
import java.util.List;
import creator.loki.feature.entertainment.commands.rating.RatingConfig;
import creator.loki.feature.entertainment.commands.rating.RatingLevel;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;

View file

@ -9,16 +9,20 @@ import java.util.function.Consumer;
import creator.loki.command.CommandContext;
import creator.loki.feature.moderation.commands.utils.SlowmodeCommand;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.MessageEmbed;
import net.dv8tion.jda.api.entities.SelfUser;
import net.dv8tion.jda.api.entities.User;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.entities.channel.middleman.GuildChannel;
import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel;
import net.dv8tion.jda.api.managers.channel.concrete.TextChannelManager;
import net.dv8tion.jda.api.requests.restaction.MessageCreateAction;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
@ -35,7 +39,17 @@ class SlowmodeCommandTest {
@Mock Member member;
@Mock Member selfMember;
@Mock Guild guild;
@Mock JDA jda;
@Mock MessageCreateAction sendEmbedAction;
@BeforeEach
void setUp() {
lenient().when(guild.getJDA()).thenReturn(jda);
SelfUser selfUser = mock(SelfUser.class);
lenient().when(jda.getSelfUser()).thenReturn(selfUser);
lenient().when(guild.getMember(selfUser)).thenReturn(selfMember);
}
@Mock TextChannelManager setSlowmodeAction;
@Mock TextChannelManager channelManager;
@ -95,7 +109,7 @@ class SlowmodeCommandTest {
lenient().when(channel.sendMessageEmbeds(any(MessageEmbed.class))).thenReturn(sendEmbedAction);
when(member.hasPermission(any(GuildChannel.class), eq(Permission.MANAGE_CHANNEL)))
.thenReturn(true);
when(guild.getSelfMember()).thenReturn(selfMember);
when(guild.getMember(any(User.class))).thenReturn(selfMember);
when(selfMember.hasPermission(any(GuildChannel.class), eq(Permission.MANAGE_CHANNEL)))
.thenReturn(false);
var context = new CommandContext(message, List.of(), channel, member, guild);
@ -111,7 +125,7 @@ class SlowmodeCommandTest {
when(channel.sendMessageEmbeds(any(MessageEmbed.class))).thenReturn(sendEmbedAction);
when(member.hasPermission(any(GuildChannel.class), eq(Permission.MANAGE_CHANNEL)))
.thenReturn(true);
when(guild.getSelfMember()).thenReturn(selfMember);
when(guild.getMember(any(User.class))).thenReturn(selfMember);
when(selfMember.hasPermission(any(GuildChannel.class), eq(Permission.MANAGE_CHANNEL)))
.thenReturn(true);
when(channel.getSlowmode()).thenReturn(10);
@ -129,7 +143,7 @@ class SlowmodeCommandTest {
when(channel.sendMessageEmbeds(any(MessageEmbed.class))).thenReturn(sendEmbedAction);
when(member.hasPermission(any(GuildChannel.class), eq(Permission.MANAGE_CHANNEL)))
.thenReturn(true);
when(guild.getSelfMember()).thenReturn(selfMember);
when(guild.getMember(any(User.class))).thenReturn(selfMember);
when(selfMember.hasPermission(any(GuildChannel.class), eq(Permission.MANAGE_CHANNEL)))
.thenReturn(true);
@ -147,7 +161,7 @@ class SlowmodeCommandTest {
when(channelManager.setSlowmode(anyInt())).thenReturn(setSlowmodeAction);
when(member.hasPermission(any(GuildChannel.class), eq(Permission.MANAGE_CHANNEL)))
.thenReturn(true);
when(guild.getSelfMember()).thenReturn(selfMember);
when(guild.getMember(any(User.class))).thenReturn(selfMember);
when(selfMember.hasPermission(any(GuildChannel.class), eq(Permission.MANAGE_CHANNEL)))
.thenReturn(true);
when(channel.getAsMention()).thenReturn("#general");
@ -170,7 +184,7 @@ class SlowmodeCommandTest {
when(channelManager.setSlowmode(anyInt())).thenReturn(setSlowmodeAction);
when(member.hasPermission(any(GuildChannel.class), eq(Permission.MANAGE_CHANNEL)))
.thenReturn(true);
when(guild.getSelfMember()).thenReturn(selfMember);
when(guild.getMember(any(User.class))).thenReturn(selfMember);
when(selfMember.hasPermission(any(GuildChannel.class), eq(Permission.MANAGE_CHANNEL)))
.thenReturn(true);
@ -187,7 +201,7 @@ class SlowmodeCommandTest {
when(channelManager.setSlowmode(anyInt())).thenReturn(setSlowmodeAction);
when(member.hasPermission(any(GuildChannel.class), eq(Permission.MANAGE_CHANNEL)))
.thenReturn(true);
when(guild.getSelfMember()).thenReturn(selfMember);
when(guild.getMember(any(User.class))).thenReturn(selfMember);
when(selfMember.hasPermission(any(GuildChannel.class), eq(Permission.MANAGE_CHANNEL)))
.thenReturn(true);
@ -205,7 +219,7 @@ class SlowmodeCommandTest {
when(channelManager.setSlowmode(anyInt())).thenReturn(setSlowmodeAction);
when(member.hasPermission(any(GuildChannel.class), eq(Permission.MANAGE_CHANNEL)))
.thenReturn(true);
when(guild.getSelfMember()).thenReturn(selfMember);
when(guild.getMember(any(User.class))).thenReturn(selfMember);
when(selfMember.hasPermission(any(GuildChannel.class), eq(Permission.MANAGE_CHANNEL)))
.thenReturn(true);

View file

@ -32,9 +32,9 @@ class RankCardGeneratorTest {
}
@Test
void backgroundNotFoundThrowsException() {
void backgroundNotFoundReturnsFallback() throws Exception {
var data = mockProfile(1, 0, 100, "nonexistent.png");
assertThrows(Exception.class, () -> new RankCardGenerator().generate(data));
assertNotNull(new RankCardGenerator().generate(data));
}
private static ProfileData mockProfile(int level, int xp, int maxXp, String background) {