feat: setup dashboard, reaction roles, tickets; fix music modifiers & search UX

- /setup slash command with interactive dashboard (voice/counter/logs/levelroles)
- Reaction roles via !reactionrole command
- Ticket system via !ticketsetup with create/close/delete buttons
- Fixed TrackStarter chicken-egg bug (music player never created)
- Fixed music modifiers: m!volume, m!bassboost, m!nightcore, m!vaporwave
- Replaced !play <number> with StringSelectMenu (dropdown) for picking tracks
- Fixed SoundCloud search (!play sc <query>)
- Changed music bot prefix: q! -> m!
This commit is contained in:
loki5512344 2026-07-14 16:23:18 +02:00
parent 102cec3b56
commit ff9d3462f6
Signed by: boba
GPG key ID: 253067914055423B
27 changed files with 1505 additions and 433 deletions

View file

@ -6,6 +6,7 @@ import creator.loki.bootstrap.ReadyListener;
import creator.loki.feature.counter.service.MemberCounterListener;
import creator.loki.feature.economy.FishMessageListener;
import creator.loki.feature.help.HelpMenuHandler;
import creator.loki.feature.reactroles.ReactionRoleListener;
import creator.loki.feature.image.ImageNavigationHandler;
import creator.loki.feature.leaderboard.LeaderboardButtonListener;
import creator.loki.feature.level.listener.MessageListener;
@ -13,8 +14,10 @@ import creator.loki.feature.level.listener.ResetLevelsButtonHandler;
import creator.loki.feature.level.service.VoiceTracker;
import creator.loki.feature.moderation.commands.utils.ClearSlashCommands;
import creator.loki.feature.moderation.log.ModerationLogger;
import creator.loki.feature.music.listener.MusicSearchButtonHandler;
import creator.loki.feature.config.SetupSlashHandler;
import creator.loki.feature.music.MusicSlashCommands;
import creator.loki.feature.music.listener.PlaySelectHandler;
import creator.loki.feature.ticket.TicketListener;
import creator.loki.feature.roast.AndrertListener;
import creator.loki.feature.roast.HelperListener;
import creator.loki.feature.voice.handler.VoiceButtonHandler;
@ -43,13 +46,16 @@ public class ListenerAggregator {
ResetLevelsButtonHandler resetLevelsButtonHandler,
LeaderboardButtonListener leaderboardButtonListener,
MessageCache messageCache,
MusicSearchButtonHandler musicSearchButtonHandler,
MusicSlashCommands musicSlashCommands,
PlaySelectHandler playSelectHandler,
FishMessageListener fishMessageListener,
AndrertListener andrertListener,
ClearSlashCommands clearSlashCommands,
HelperListener helperListener,
ImageNavigationHandler imageNavigationHandler) {
ImageNavigationHandler imageNavigationHandler,
SetupSlashHandler setupSlashHandler,
TicketListener ticketListener,
ReactionRoleListener reactionRoleListener) {
this.listeners =
new Object[] {
readyListener,
@ -66,13 +72,16 @@ public class ListenerAggregator {
resetLevelsButtonHandler,
leaderboardButtonListener,
messageCache,
musicSearchButtonHandler,
musicSlashCommands,
playSelectHandler,
fishMessageListener,
andrertListener,
clearSlashCommands,
helperListener,
imageNavigationHandler
imageNavigationHandler,
setupSlashHandler,
ticketListener,
reactionRoleListener
};
}

View file

@ -103,6 +103,27 @@ public final class FeatureTables {
left_count INTEGER DEFAULT 0,
difference INTEGER DEFAULT 0
)""";
private static final String REACTION_ROLES =
"""
CREATE TABLE IF NOT EXISTS reaction_roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guild_id TEXT NOT NULL,
channel_id TEXT NOT NULL,
message_id TEXT NOT NULL,
emoji TEXT NOT NULL,
role_id TEXT NOT NULL,
UNIQUE(message_id, emoji)
)""";
private static final String TICKETS =
"""
CREATE TABLE IF NOT EXISTS tickets (
channel_id TEXT PRIMARY KEY,
guild_id TEXT NOT NULL,
creator_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
closed INTEGER DEFAULT 0,
claimed_by TEXT
)""";
private static final String RATINGS =
"""
CREATE TABLE IF NOT EXISTS ratings (
@ -123,7 +144,9 @@ public final class FeatureTables {
database.execute(VOICE_CHANNELS);
database.execute(VOICE_PRESETS);
database.execute(COUNTER_STATS);
database.execute(TICKETS);
database.execute(RATINGS);
database.execute(REACTION_ROLES);
createIndexes(database);
}

View file

@ -14,6 +14,7 @@ import creator.loki.core.registry.PrefixCommandRegistrar;
import creator.loki.core.security.CooldownManager;
import creator.loki.core.security.RateLimiter;
import creator.loki.database.DatabaseModule;
import creator.loki.feature.config.ConfigModule;
import creator.loki.feature.counter.CounterModule;
import creator.loki.feature.economy.EconomyModule;
import creator.loki.feature.entertainment.EntertainmentModule;
@ -29,6 +30,8 @@ import creator.loki.feature.radio.RadioModule;
import creator.loki.feature.roast.RoastModule;
import creator.loki.feature.role.RoleModule;
import creator.loki.feature.tiktok.TikTokModule;
import creator.loki.feature.ticket.TicketModule;
import creator.loki.feature.reactroles.ReactionRolesModule;
import creator.loki.feature.utility.UtilityModule;
import creator.loki.feature.voice.VoiceModule;
import creator.loki.feature.welcome.WelcomeModule;
@ -47,6 +50,7 @@ public class BotModule extends AbstractModule {
bind(PrefixCommandRegistrar.class).in(Singleton.class);
install(new DatabaseModule());
install(new ConfigModule());
install(new VoiceModule());
install(new MusicModule());
install(new LevelModule());
@ -62,7 +66,9 @@ public class BotModule extends AbstractModule {
install(new ImageModule());
install(new TikTokModule());
install(new EntertainmentModule());
install(new TicketModule());
install(new UtilityModule());
install(new ReactionRolesModule());
install(new LifecycleModule());
}

View file

@ -0,0 +1,11 @@
package creator.loki.feature.config;
import com.google.inject.AbstractModule;
import com.google.inject.Singleton;
public class ConfigModule extends AbstractModule {
@Override
protected void configure() {
bind(SetupSlashHandler.class).in(Singleton.class);
}
}

View file

@ -0,0 +1,432 @@
package creator.loki.feature.config;
import java.awt.Color;
import java.util.Map;
import java.util.stream.Collectors;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import creator.loki.feature.config.repository.ConfigRepository;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.components.actionrow.ActionRow;
import net.dv8tion.jda.api.components.buttons.Button;
import net.dv8tion.jda.api.components.label.Label;
import net.dv8tion.jda.api.components.selections.EntitySelectMenu;
import net.dv8tion.jda.api.components.selections.StringSelectMenu;
import net.dv8tion.jda.api.components.textinput.TextInput;
import net.dv8tion.jda.api.components.textinput.TextInputStyle;
import net.dv8tion.jda.api.events.guild.GuildReadyEvent;
import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
import net.dv8tion.jda.api.events.interaction.component.EntitySelectInteractionEvent;
import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import net.dv8tion.jda.api.interactions.InteractionHook;
import net.dv8tion.jda.api.interactions.commands.build.Commands;
import net.dv8tion.jda.api.modals.Modal;
import org.jetbrains.annotations.NotNull;
@Singleton
public class SetupSlashHandler extends ListenerAdapter {
private static final Color BLURPLE = new Color(88, 101, 242);
private final ConfigRepository configRepo;
private volatile boolean registered = false;
@Inject
public SetupSlashHandler(ConfigRepository configRepo) {
this.configRepo = configRepo;
}
@Override
public void onGuildReady(@NotNull GuildReadyEvent event) {
if (registered) return;
registered = true;
event.getJDA().updateCommands()
.addCommands(Commands.slash("setup", "Панель управления сервером"))
.queue();
}
@Override
public void onSlashCommandInteraction(@NotNull SlashCommandInteractionEvent event) {
if (!event.getName().equals("setup")) return;
event.deferReply().queue();
showDashboard(event.getGuild(), event.getHook());
}
@Override
public void onButtonInteraction(@NotNull ButtonInteractionEvent event) {
String id = event.getComponentId();
if (!id.startsWith("setup_")) return;
event.deferEdit().queue();
switch (id) {
case "setup_voice" -> showVoicePanel(event.getGuild(), event.getHook());
case "setup_counter" -> showCounterPanel(event.getGuild(), event.getHook());
case "setup_logs" -> showLoggingPanel(event.getGuild(), event.getHook());
case "setup_levelroles" -> showLevelRolesPanel(event.getGuild(), event.getHook());
case "setup_back" -> showDashboard(event.getGuild(), event.getHook());
case "setup_counter_format" -> openCounterFormatModal(event);
case "setup_levelrole_add" -> openLevelRoleAddModal(event);
case "setup_levelrole_remove" -> openLevelRoleRemoveSelect(event);
}
}
@Override
public void onEntitySelectInteraction(@NotNull EntitySelectInteractionEvent event) {
String id = event.getComponentId();
if (!id.startsWith("setup_")) return;
event.deferEdit().queue();
String value = event.getValues().get(0).getId();
switch (id) {
case "setup_voice_trigger" -> {
configRepo.set("voice.trigger_channel_id", value);
showVoicePanel(event.getGuild(), event.getHook());
}
case "setup_voice_category" -> {
configRepo.set("voice.category_id", value);
showVoicePanel(event.getGuild(), event.getHook());
}
case "setup_counter_channel" -> {
configRepo.set("counter.channel_id", value);
updateCounterChannelName(event.getGuild());
showCounterPanel(event.getGuild(), event.getHook());
}
case "setup_logs_moderation" -> {
configRepo.set("moderation.log_channel_id", value);
showLoggingPanel(event.getGuild(), event.getHook());
}
case "setup_logs_levelup" -> {
configRepo.set("levelup.notification_channel_id", value);
showLoggingPanel(event.getGuild(), event.getHook());
}
}
}
@Override
public void onStringSelectInteraction(@NotNull StringSelectInteractionEvent event) {
String id = event.getComponentId();
if (!id.startsWith("setup_")) return;
event.deferEdit().queue();
if (id.equals("setup_levelrole_remove_select")) {
String selected = event.getValues().get(0);
configRepo.delete("level_role_" + selected);
showLevelRolesPanel(event.getGuild(), event.getHook());
}
}
@Override
public void onModalInteraction(@NotNull ModalInteractionEvent event) {
String id = event.getModalId();
if (!id.startsWith("setup_")) return;
event.deferEdit().queue();
switch (id) {
case "setup_counter_format_modal" -> {
String format = event.getValue("format").getAsString();
configRepo.set("counter.format", format);
updateCounterChannelName(event.getGuild());
showCounterPanel(event.getGuild(), event.getHook());
}
case "setup_levelrole_add_modal" -> {
String levelStr = event.getValue("level").getAsString();
String role = event.getValue("role").getAsString();
configRepo.set("level_role_" + levelStr, role);
showLevelRolesPanel(event.getGuild(), event.getHook());
}
}
}
private void showDashboard(Guild guild, InteractionHook hook) {
hook.editOriginalEmbeds(buildDashboardEmbed(guild).build())
.setComponents(buildDashboardButtons())
.queue();
}
private void showVoicePanel(Guild guild, InteractionHook hook) {
EmbedBuilder embed = new EmbedBuilder()
.setTitle("🎤 Настройка голосовых каналов")
.setColor(BLURPLE);
String triggerId = configRepo.get("voice.trigger_channel_id");
if (triggerId != null) {
embed.addField("Канал-триггер", "<#" + triggerId + ">", false);
} else {
embed.addField("Канал-триггер", "❌ Не настроено", false);
}
String categoryId = configRepo.get("voice.category_id");
if (categoryId != null) {
var cat = guild.getCategoryById(categoryId);
embed.addField("Категория", cat != null ? cat.getName() : "❌ Не найдена", false);
} else {
embed.addField("Категория", "❌ Не настроено", false);
}
hook.editOriginalEmbeds(embed.build())
.setComponents(
ActionRow.of(EntitySelectMenu.create("setup_voice_trigger", EntitySelectMenu.SelectTarget.CHANNEL)
.setChannelTypes(ChannelType.VOICE)
.setPlaceholder("Выбери канал-триггер")
.build()),
ActionRow.of(EntitySelectMenu.create("setup_voice_category", EntitySelectMenu.SelectTarget.CHANNEL)
.setChannelTypes(ChannelType.CATEGORY)
.setPlaceholder("Выбери категорию")
.build()),
ActionRow.of(Button.secondary("setup_back", "🔙 Назад")))
.queue();
}
private void showCounterPanel(Guild guild, InteractionHook hook) {
EmbedBuilder embed = new EmbedBuilder()
.setTitle("📊 Настройка счётчика участников")
.setColor(BLURPLE);
String channelId = configRepo.get("counter.channel_id");
if (channelId != null) {
embed.addField("Канал", "<#" + channelId + ">", false);
} else {
embed.addField("Канал", "❌ Не настроено", false);
}
String format = configRepo.get("counter.format");
embed.addField("Формат", format != null ? "`" + format + "`" : "❌ Не настроено", false);
hook.editOriginalEmbeds(embed.build())
.setComponents(
ActionRow.of(EntitySelectMenu.create("setup_counter_channel", EntitySelectMenu.SelectTarget.CHANNEL)
.setChannelTypes(ChannelType.VOICE)
.setPlaceholder("Выбери канал")
.build()),
ActionRow.of(
Button.secondary("setup_counter_format", "✏️ Формат"),
Button.secondary("setup_back", "🔙 Назад")))
.queue();
}
private void showLoggingPanel(Guild guild, InteractionHook hook) {
EmbedBuilder embed = new EmbedBuilder()
.setTitle("📝 Настройка логирования")
.setColor(BLURPLE);
String modId = configRepo.get("moderation.log_channel_id");
embed.addField("Логи модерации", modId != null ? "<#" + modId + ">" : "❌ Не настроено", false);
String lvlId = configRepo.get("levelup.notification_channel_id");
embed.addField("Уведомления level-up", lvlId != null ? "<#" + lvlId + ">" : "❌ Не настроено", false);
hook.editOriginalEmbeds(embed.build())
.setComponents(
ActionRow.of(EntitySelectMenu.create("setup_logs_moderation", EntitySelectMenu.SelectTarget.CHANNEL)
.setChannelTypes(ChannelType.TEXT)
.setPlaceholder("Канал логов модерации")
.build()),
ActionRow.of(EntitySelectMenu.create("setup_logs_levelup", EntitySelectMenu.SelectTarget.CHANNEL)
.setChannelTypes(ChannelType.TEXT)
.setPlaceholder("Канал level-up уведомлений")
.build()),
ActionRow.of(Button.secondary("setup_back", "🔙 Назад")))
.queue();
}
private void showLevelRolesPanel(Guild guild, InteractionHook hook) {
Map<String, String> allConfig = configRepo.getAll();
var levelRoles = allConfig.entrySet().stream()
.filter(e -> e.getKey().startsWith("level_role_"))
.collect(Collectors.toMap(
e -> Integer.parseInt(e.getKey().substring("level_role_".length())),
Map.Entry::getValue));
EmbedBuilder embed = new EmbedBuilder()
.setTitle("🏆 Роли за уровни")
.setColor(BLURPLE);
if (levelRoles.isEmpty()) {
embed.setDescription("❌ Роли не настроены");
} else {
StringBuilder sb = new StringBuilder();
levelRoles.entrySet().stream()
.sorted(Map.Entry.comparingByKey())
.forEach(e -> sb.append("**Уровень ").append(e.getKey()).append(":** `").append(e.getValue()).append("`\n"));
embed.setDescription(sb.toString());
}
var buttons = new java.util.ArrayList<Button>();
buttons.add(Button.success("setup_levelrole_add", "➕ Добавить"));
if (!levelRoles.isEmpty()) {
buttons.add(Button.danger("setup_levelrole_remove", "➖ Удалить"));
}
buttons.add(Button.secondary("setup_back", "🔙 Назад"));
hook.editOriginalEmbeds(embed.build())
.setComponents(ActionRow.of(buttons))
.queue();
}
private void openCounterFormatModal(ButtonInteractionEvent event) {
String currentFormat = configRepo.get("counter.format");
if (currentFormat == null) {
currentFormat = "【👥】Участники: {count}";
}
TextInput formatInput = TextInput.create("format", TextInputStyle.SHORT)
.setValue(currentFormat)
.setPlaceholder("【👥】Участники: {count}")
.setRequired(true)
.build();
Modal modal = Modal.create("setup_counter_format_modal", "✏️ Формат счётчика")
.addComponents(Label.of("Формат строки", formatInput))
.build();
event.replyModal(modal).queue();
}
private void openLevelRoleAddModal(ButtonInteractionEvent event) {
TextInput levelInput = TextInput.create("level", TextInputStyle.SHORT)
.setPlaceholder("10")
.setRequired(true)
.build();
TextInput roleInput = TextInput.create("role", TextInputStyle.SHORT)
.setPlaceholder("Название роли")
.setRequired(true)
.build();
Modal modal = Modal.create("setup_levelrole_add_modal", "➕ Добавить роль за уровень")
.addComponents(Label.of("Уровень", levelInput), Label.of("Название роли", roleInput))
.build();
event.replyModal(modal).queue();
}
private void openLevelRoleRemoveSelect(ButtonInteractionEvent event) {
Map<String, String> allConfig = configRepo.getAll();
var options = allConfig.entrySet().stream()
.filter(e -> e.getKey().startsWith("level_role_"))
.map(e -> {
String level = e.getKey().substring("level_role_".length());
return "Уровень " + level + " — " + e.getValue();
})
.toList();
if (options.isEmpty()) {
showLevelRolesPanel(event.getGuild(), event.getHook());
return;
}
StringSelectMenu.Builder menuBuilder = StringSelectMenu.create("setup_levelrole_remove_select")
.setPlaceholder("Выбери роль для удаления");
var levelKeys = allConfig.keySet().stream()
.filter(k -> k.startsWith("level_role_"))
.collect(Collectors.toList());
for (String key : levelKeys) {
String level = key.substring("level_role_".length());
String roleName = allConfig.get(key);
String label = "Уровень " + level + " — " + roleName;
menuBuilder.addOption(label, level);
}
event.getHook().editOriginalComponents(
ActionRow.of(menuBuilder.build()),
ActionRow.of(Button.secondary("setup_back", "🔙 Назад"))).queue();
}
private EmbedBuilder buildDashboardEmbed(Guild guild) {
EmbedBuilder embed = new EmbedBuilder()
.setTitle("🎛 Панель управления сервером")
.setColor(BLURPLE)
.setFooter("Выбери категорию для настройки");
String triggerId = configRepo.get("voice.trigger_channel_id");
String categoryId = configRepo.get("voice.category_id");
if (triggerId != null || categoryId != null) {
StringBuilder sb = new StringBuilder();
if (triggerId != null) {
sb.append("Триггер: <#").append(triggerId).append(">");
} else {
sb.append("Триггер: ❌ Не настроено");
}
sb.append("\n");
if (categoryId != null) {
var cat = guild.getCategoryById(categoryId);
sb.append("Категория: ").append(cat != null ? cat.getName() : "❌ Не найдена");
} else {
sb.append("Категория: ❌ Не настроено");
}
embed.addField("🎤 Голосовые каналы", sb.toString(), false);
} else {
embed.addField("🎤 Голосовые каналы", "❌ Не настроено", false);
}
String counterChannelId = configRepo.get("counter.channel_id");
String counterFormat = configRepo.get("counter.format");
if (counterChannelId != null) {
StringBuilder sb = new StringBuilder();
sb.append("Канал: <#").append(counterChannelId).append(">");
if (counterFormat != null) {
sb.append("\nФормат: `").append(counterFormat).append("`");
}
embed.addField("📊 Счётчик", sb.toString(), false);
} else {
embed.addField("📊 Счётчик", "❌ Не настроено", false);
}
String modLogId = configRepo.get("moderation.log_channel_id");
String levelupLogId = configRepo.get("levelup.notification_channel_id");
if (modLogId != null || levelupLogId != null) {
StringBuilder sb = new StringBuilder();
if (modLogId != null) {
sb.append("Модерация: <#").append(modLogId).append(">");
} else {
sb.append("Модерация: ❌ Не настроено");
}
sb.append("\n");
if (levelupLogId != null) {
sb.append("Level-up: <#").append(levelupLogId).append(">");
} else {
sb.append("Level-up: ❌ Не настроено");
}
embed.addField("📝 Логи", sb.toString(), false);
} else {
embed.addField("📝 Логи", "❌ Не настроено", false);
}
Map<String, String> allConfig = configRepo.getAll();
long levelRoleCount = allConfig.keySet().stream().filter(k -> k.startsWith("level_role_")).count();
embed.addField("🏆 Роли за уровни", "Настроено ролей: **" + levelRoleCount + "**", false);
return embed;
}
private ActionRow[] buildDashboardButtons() {
return new ActionRow[] {
ActionRow.of(
Button.secondary("setup_voice", "🎤 Голосовые"),
Button.secondary("setup_counter", "📊 Счётчик"),
Button.secondary("setup_logs", "📝 Логи"),
Button.secondary("setup_levelroles", "🏆 Роли"))
};
}
private void updateCounterChannelName(Guild guild) {
if (guild == null) return;
String channelId = configRepo.get("counter.channel_id");
String format = configRepo.get("counter.format");
if (channelId != null && format != null) {
var channel = guild.getVoiceChannelById(channelId);
if (channel != null) {
int count = guild.getMemberCount();
channel.getManager().setName(format.replace("{count}", String.valueOf(count))).queue();
}
}
}
}

View file

@ -8,24 +8,21 @@ import creator.loki.feature.music.commands.PlayerCommands;
import creator.loki.feature.music.commands.QueueCommands;
import creator.loki.feature.music.core.PlayerManager;
import creator.loki.feature.music.core.SearchManager;
import creator.loki.feature.music.play.MusicSearchSessionManager;
import creator.loki.feature.music.play.PlayCommand;
public class MusicCommandRegistrar implements CommandRegistrar {
private final PlayerManager playerManager;
private final SearchManager searchManager;
private final MusicSearchSessionManager sessionManager;
@Inject
public MusicCommandRegistrar(PlayerManager playerManager, SearchManager searchManager, MusicSearchSessionManager sessionManager) {
public MusicCommandRegistrar(PlayerManager playerManager, SearchManager searchManager) {
this.playerManager = playerManager;
this.searchManager = searchManager;
this.sessionManager = sessionManager;
}
@Override
public void register(CommandRegistry registry) {
registry.register(new PlayCommand(playerManager, searchManager, sessionManager));
registry.register(new PlayCommand(playerManager, searchManager));
registry.register(PlayerCommands.skip(playerManager));
registry.register(PlayerCommands.stop(playerManager));
registry.register(PlayerCommands.leave(playerManager));

View file

@ -6,14 +6,12 @@ import com.google.inject.multibindings.Multibinder;
import creator.loki.core.registry.CommandRegistrar;
import creator.loki.feature.music.core.LavalinkManager;
import creator.loki.feature.music.core.PlayerManager;
import creator.loki.feature.music.play.MusicSearchSessionManager;
public class MusicModule extends AbstractModule {
@Override
protected void configure() {
bind(LavalinkManager.class).in(Singleton.class);
bind(PlayerManager.class).in(Singleton.class);
bind(MusicSearchSessionManager.class).in(Singleton.class);
Multibinder.newSetBinder(binder(), CommandRegistrar.class)
.addBinding()

View file

@ -90,7 +90,7 @@ public class MusicBotControlHandler {
}
try {
var vol = Math.max(0, Math.min(100, Integer.parseInt(args.get(0))));
if (link != null && link.getCachedPlayer() != null)
if (link != null)
link.createOrUpdatePlayer()
.setVolume(vol)
.subscribe(null, err -> log.warn("Lavalink: {}", err.getMessage()));

View file

@ -25,7 +25,7 @@ public class MusicBotPlayHandler {
public void handlePlay(MessageChannel ch, Guild guild, Member member, List<String> args) {
if (reqVoice(ch, member) == null) return;
if (args.isEmpty()) {
send(ch, err("Укажи URL или запрос: `q!play <название>`"));
send(ch, err("Укажи URL или запрос: `m!play <название>`"));
return;
}
connect(guild, member, lavalinkManager);

View file

@ -1,242 +0,0 @@
package creator.loki.feature.music.listener;
import java.awt.Color;
import java.util.ArrayList;
import java.util.List;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import creator.loki.embed.EmbedTemplates;
import creator.loki.feature.music.core.LavalinkManager;
import creator.loki.feature.music.core.SearchManager;
import creator.loki.feature.music.manage.GuildMusicManager;
import creator.loki.feature.music.play.MusicSearchSession;
import creator.loki.feature.music.play.MusicSearchSessionManager;
import creator.loki.feature.music.play.SearchHandler;
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.EmbedBuilder;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import net.dv8tion.jda.api.components.actionrow.ActionRow;
import net.dv8tion.jda.api.components.buttons.Button;
import org.jetbrains.annotations.NotNull;
@Singleton
public class MusicSearchButtonHandler extends ListenerAdapter {
private static final Color BRAND = new Color(88, 101, 242);
private static final int MAX_RESULTS = 10;
private final MusicSearchSessionManager sessionManager;
private final LavalinkManager lavalinkManager;
private final SearchHandler searchHandler;
@Inject
public MusicSearchButtonHandler(
MusicSearchSessionManager sessionManager,
LavalinkManager lavalinkManager,
SearchManager searchManager) {
this.sessionManager = sessionManager;
this.lavalinkManager = lavalinkManager;
this.searchHandler = new SearchHandler(searchManager);
}
@Override
public void onButtonInteraction(@NotNull ButtonInteractionEvent event) {
String id = event.getComponentId();
if (id.startsWith("ms_platform_")) {
handlePlatform(event, id.replace("ms_platform_", ""));
} else if (id.startsWith("ms_pick_")) {
handlePick(event, id.replace("ms_pick_", ""));
}
}
private void handlePlatform(ButtonInteractionEvent event, String source) {
String messageId = event.getMessageId();
MusicSearchSession session = sessionManager.get(messageId);
if (session == null) {
event
.replyEmbeds(
EmbedTemplates.error(
"Поиск устарел", "Сделай новый поиск: `" + getPrefix() + "play <запрос>`"))
.setEphemeral(true)
.queue();
return;
}
event.deferEdit().queue();
String query = session.getQuery();
String searchId = switch (source) {
case "sc" -> "scsearch:" + query;
case "ytm" -> "ytmsearch:" + query;
default -> "ytsearch:" + query;
};
event.getHook()
.editOriginalEmbeds(
new EmbedBuilder()
.setColor(BRAND)
.setTitle("🔍 Ищу: " + query)
.setDescription("Поиск на " + sourceLabel(source) + "...")
.build())
.setComponents()
.queue();
lavalinkManager
.getOrCreateLink(session.getGuildId())
.loadItem(searchId)
.subscribe(
new AbstractAudioLoadResultHandler() {
@Override
public void onSearchResultLoaded(SearchResult result) {
if (result.getTracks().isEmpty()) {
noMatch();
return;
}
showResults(event, result.getTracks(), query, source);
}
@Override
public void ontrackLoaded(TrackLoaded result) {
showResults(event, List.of(result.getTrack()), query, source);
}
@Override
public void onPlaylistLoaded(PlaylistLoaded result) {
showResults(event, result.getTracks(), query, source);
}
@Override
public void noMatches() {
noMatch();
}
@Override
public void loadFailed(LoadFailed e) {
String msg =
e.getException().getMessage() != null
? e.getException().getMessage()
: "Неизвестная ошибка";
event
.getHook()
.editOriginalEmbeds(EmbedTemplates.error("Ошибка загрузки", msg))
.queue();
}
private void noMatch() {
event
.getHook()
.editOriginalEmbeds(
EmbedTemplates.error("Не найдено", "Ничего не найдено: " + query))
.queue();
}
});
}
private void showResults(
ButtonInteractionEvent event, List<Track> tracks, String query, String source) {
String messageId = event.getMessageId();
MusicSearchSession session = sessionManager.get(messageId);
if (session == null) return;
int show = Math.min(MAX_RESULTS, tracks.size());
List<Track> display = tracks.subList(0, show);
session.setResults(tracks);
EmbedBuilder embed = searchHandler.buildResultsEmbed(
query, display, source, getPrefix(), "play");
var rows = buildNumberButtons(show);
event.getHook().editOriginalEmbeds(embed.build()).setComponents(rows).queue();
}
private void handlePick(ButtonInteractionEvent event, String indexStr) {
int index = Integer.parseInt(indexStr);
String messageId = event.getMessageId();
MusicSearchSession session = sessionManager.get(messageId);
if (session == null) {
event
.replyEmbeds(
EmbedTemplates.error(
"Поиск устарел", "Сделай новый поиск: `" + getPrefix() + "play <запрос>`"))
.setEphemeral(true)
.queue();
return;
}
List<Track> tracks = session.getResults();
if (tracks == null || index < 0 || index >= tracks.size()) {
event
.replyEmbeds(EmbedTemplates.error("Ошибка", "Результаты поиска устарели."))
.setEphemeral(true)
.queue();
return;
}
Track track = tracks.get(index);
Member member = event.getMember();
Guild guild = event.getGuild();
if (member == null || !member.getVoiceState().inAudioChannel()) {
event
.replyEmbeds(
EmbedTemplates.error("Голосовой канал", "Зайди в голосовой канал сначала."))
.setEphemeral(true)
.queue();
return;
}
event.deferEdit().queue();
VoiceHelper.connectForPlayback(guild, member, lavalinkManager);
GuildMusicManager mm = lavalinkManager.getMusicManager(session.getGuildId());
mm.getScheduler().enqueue(track);
String duration = SearchHandler.formatDuration(track.getInfo().getLength());
event
.getHook()
.editOriginalEmbeds(
EmbedTemplates.info(
"Добавлено в очередь",
"**" + track.getInfo().getTitle() + "**\n" + duration))
.setComponents()
.queue();
sessionManager.remove(messageId);
}
private static List<ActionRow> buildNumberButtons(int count) {
int rows = (count + 4) / 5;
var list = new java.util.ArrayList<ActionRow>();
for (int r = 0; r < rows; r++) {
var builder = new java.util.ArrayList<Button>();
for (int c = 0; c < 5; c++) {
int idx = r * 5 + c;
if (idx >= count) break;
builder.add(Button.primary("ms_pick_" + idx, String.valueOf(idx + 1)));
}
list.add(ActionRow.of(builder));
}
return list;
}
private static String sourceLabel(String source) {
return switch (source) {
case "sc" -> "SoundCloud";
case "ytm" -> "YouTube Music";
default -> "YouTube";
};
}
private String getPrefix() {
return lavalinkManager.getConfig().getPrefix();
}
}

View file

@ -0,0 +1,65 @@
package creator.loki.feature.music.listener;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import creator.loki.embed.EmbedTemplates;
import creator.loki.feature.music.core.PlayerManager;
import creator.loki.feature.music.core.SearchManager;
import creator.loki.feature.music.play.SearchHandler;
import creator.loki.util.VoiceHelper;
import dev.arbjerg.lavalink.client.player.Track;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import org.jetbrains.annotations.NotNull;
@Singleton
public class PlaySelectHandler extends ListenerAdapter {
private final PlayerManager playerManager;
private final SearchManager searchManager;
@Inject
public PlaySelectHandler(PlayerManager playerManager, SearchManager searchManager) {
this.playerManager = playerManager;
this.searchManager = searchManager;
}
@Override
public void onStringSelectInteraction(@NotNull StringSelectInteractionEvent event) {
if (!event.getComponentId().equals("play_select")) return;
var guild = event.getGuild();
var member = event.getMember();
if (guild == null || member == null) return;
if (!member.getVoiceState().inAudioChannel()) {
event.replyEmbeds(EmbedTemplates.error("Ошибка", "Зайди в голосовой канал сначала."))
.setEphemeral(true)
.queue();
return;
}
int index = Integer.parseInt(event.getValues().get(0));
Track track = searchManager.pick(guild.getIdLong(), index);
if (track == null) {
event.replyEmbeds(EmbedTemplates.error("Ошибка", "Поиск устарел"))
.setEphemeral(true)
.queue();
return;
}
event.deferEdit().queue();
VoiceHelper.connectForPlayback(guild, member, playerManager.getLavalinkManager());
playerManager.getMusicManager(guild.getIdLong()).getScheduler().enqueue(track);
event.getHook()
.editOriginalEmbeds(
EmbedTemplates.info(
"Добавлено в очередь",
"**" + track.getInfo().getTitle() + "**\n" + SearchHandler.formatDuration(track.getInfo().getLength())))
.setComponents()
.queue();
}
}

View file

@ -32,13 +32,6 @@ class TrackStarter {
private void startWithRetry(Link link, Track track, int attempt) {
if (attempt > MAX_ATTEMPTS) return;
if (link.getCachedPlayer() == null) {
executor.schedule(
() -> startWithRetry(link, track, attempt + 1),
RETRY_DELAY_PLAYER_NULL_MS,
TimeUnit.MILLISECONDS);
return;
}
link.createOrUpdatePlayer()
.setTrack(track)
.setVolume(100)

View file

@ -1,38 +0,0 @@
package creator.loki.feature.music.play;
import java.util.List;
import dev.arbjerg.lavalink.client.player.Track;
public class MusicSearchSession {
private final long guildId;
private final long userId;
private final String query;
private List<Track> results;
public MusicSearchSession(long guildId, long userId, String query) {
this.guildId = guildId;
this.userId = userId;
this.query = query;
}
public long getGuildId() {
return guildId;
}
public long getUserId() {
return userId;
}
public String getQuery() {
return query;
}
public List<Track> getResults() {
return results;
}
public void setResults(List<Track> results) {
this.results = results;
}
}

View file

@ -1,27 +0,0 @@
package creator.loki.feature.music.play;
import java.util.concurrent.TimeUnit;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
public class MusicSearchSessionManager {
private final Cache<String, MusicSearchSession> sessions;
public MusicSearchSessionManager() {
this.sessions =
Caffeine.newBuilder().maximumSize(100).expireAfterWrite(3, TimeUnit.MINUTES).build();
}
public void put(String messageId, MusicSearchSession session) {
sessions.put(messageId, session);
}
public MusicSearchSession get(String messageId) {
return sessions.getIfPresent(messageId);
}
public void remove(String messageId) {
sessions.invalidate(messageId);
}
}

View file

@ -1,37 +1,28 @@
package creator.loki.feature.music.play;
import java.awt.Color;
import java.util.List;
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.EmbedBuilder;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.components.actionrow.ActionRow;
import net.dv8tion.jda.api.components.buttons.Button;
public class PlayCommand implements Command {
private static final Color BRAND = new Color(88, 101, 242);
private static final Set<String> SOURCES = Set.of("yt", "sc", "list");
private final PlayerManager playerManager;
private final SearchHandler searchHandler;
private final MusicSearchSessionManager sessionManager;
public PlayCommand(PlayerManager playerManager, SearchManager searchManager, MusicSearchSessionManager sessionManager) {
public PlayCommand(PlayerManager playerManager, SearchManager searchManager) {
this.playerManager = playerManager;
this.searchHandler = new SearchHandler(searchManager);
this.sessionManager = sessionManager;
}
@Override
@ -46,7 +37,7 @@ public class PlayCommand implements Command {
@Override
public String getUsage() {
return "play <URL> | play <номер> | play <запрос>";
return "play [yt|sc|list] <запрос> | play <номер>";
}
@Override
@ -59,7 +50,10 @@ public class PlayCommand implements Command {
Guild guild = ctx.getGuild();
if (guild == null) return;
Member member = ctx.getAuthor();
if (member == null) return;
if (member == null || !member.getVoiceState().inAudioChannel()) {
sendErr(ctx, "Зайди в голосовой канал сначала.");
return;
}
var args = ctx.getArgs();
if (args.isEmpty()) {
sendErr(ctx, "Укажи URL, запрос или номер: `" + getUsage() + "`");
@ -70,86 +64,10 @@ public class PlayCommand implements Command {
handlePickResult(ctx, first);
return;
}
if (first.startsWith("http://") || first.startsWith("https://")) {
handleUrl(ctx, first);
return;
}
showPlatformSelection(ctx, first);
}
private void showPlatformSelection(CommandContext ctx, String query) {
Guild guild = ctx.getGuild();
Member member = ctx.getAuthor();
var lm = playerManager.getLavalinkManager();
var embed = new EmbedBuilder()
.setColor(BRAND)
.setTitle("Выбери платформу")
.setDescription("Результаты поиска для: **" + query + "**\nВыбери платформу для поиска:")
.build();
var row = ActionRow.of(
Button.primary("ms_platform_yt", "🇾 YouTube"),
Button.primary("ms_platform_sc", "🎵 SoundCloud"),
Button.primary("ms_platform_ytm", "▶️ YouTube Music"));
var message = ctx.getChannel()
.sendMessageEmbeds(embed)
.addComponents(row)
.complete();
var session = new MusicSearchSession(guild.getIdLong(), member.getIdLong(), query);
sessionManager.put(message.getId(), session);
}
private void handleUrl(CommandContext ctx, String url) {
if (!resolveVoiceConnection(ctx)) return;
var lm = playerManager.getLavalinkManager();
long guildId = ctx.getGuild().getIdLong();
lm.getOrCreateLink(guildId)
.loadItem(url)
.subscribe(new AbstractAudioLoadResultHandler() {
@Override
public void ontrackLoaded(TrackLoaded result) {
enqueueAndNotify(ctx, result.getTrack());
}
@Override
public void onSearchResultLoaded(SearchResult result) {
if (result.getTracks().isEmpty()) {
sendErr(ctx, "Ничего не найдено.");
} else {
enqueueAndNotify(ctx, result.getTracks().get(0));
}
}
@Override
public void onPlaylistLoaded(PlaylistLoaded result) {
var mm = lm.getMusicManager(guildId);
for (Track t : result.getTracks()) {
mm.getScheduler().enqueue(t);
}
ctx.getChannel()
.sendMessageEmbeds(
EmbedTemplates.info(
"Плейлист добавлен",
"**" + result.getInfo().getName() + "** — " + result.getTracks().size() + " треков"))
.queue();
}
@Override
public void noMatches() {
sendErr(ctx, "Ничего не найдено.");
}
@Override
public void loadFailed(LoadFailed e) {
String msg = e.getException().getMessage() != null
? e.getException().getMessage()
: "Ошибка загрузки";
sendErr(ctx, msg);
}
});
var mm = lm.getMusicManager(guild.getIdLong());
handlePlayQuery(ctx, args, first, lm, mm);
}
private boolean resolveVoiceConnection(CommandContext ctx) {
@ -174,6 +92,47 @@ public class PlayCommand implements Command {
enqueueAndNotify(ctx, t);
}
private void handlePlayQuery(
CommandContext ctx,
List<String> args,
String first,
LavalinkManager lm,
GuildMusicManager mm) {
boolean isSource = SOURCES.contains(first);
String raw = String.join(" ", isSource ? args.subList(1, args.size()) : args);
if (raw.isEmpty()) {
sendErr(ctx, "Укажи запрос после источника.");
return;
}
boolean isUrl = raw.startsWith("http://") || raw.startsWith("https://");
String query = isUrl ? null : raw;
String id = buildQuery(first, raw);
loadAndHandle(ctx, lm, ctx.getGuild().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) {
lm.getOrCreateLink(guildId)
.loadItem(id)
.subscribe(new PlayLoadHandler(ctx, searchHandler, mm, isUrl, query, raw));
}
private static String buildQuery(String first, String raw) {
if (raw.startsWith("http://") || raw.startsWith("https://")) return raw;
if (raw.startsWith("ytsearch:")
|| raw.startsWith("ytmsearch:")
|| raw.startsWith("scsearch:")) return raw;
if ("sc".equals(first)) return "scsearch:" + raw;
return "ytsearch:" + raw;
}
private void enqueueAndNotify(CommandContext ctx, Track track) {
var m = playerManager.getLavalinkManager().getMusicManager(ctx.getGuild().getIdLong());
m.getScheduler().enqueue(track);

View file

@ -0,0 +1,135 @@
package creator.loki.feature.music.play;
import java.util.List;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedTemplates;
import creator.loki.feature.music.manage.GuildMusicManager;
import dev.arbjerg.lavalink.client.AbstractAudioLoadResultHandler;
import dev.arbjerg.lavalink.client.player.LoadFailed;
import dev.arbjerg.lavalink.client.player.PlaylistLoaded;
import dev.arbjerg.lavalink.client.player.SearchResult;
import dev.arbjerg.lavalink.client.player.Track;
import dev.arbjerg.lavalink.client.player.TrackLoaded;
import net.dv8tion.jda.api.components.actionrow.ActionRow;
import net.dv8tion.jda.api.components.selections.StringSelectMenu;
class PlayLoadHandler extends AbstractAudioLoadResultHandler {
private final CommandContext ctx;
private final SearchHandler searchHandler;
private final GuildMusicManager mm;
private final boolean isUrl;
private final String query;
private final String raw;
PlayLoadHandler(
CommandContext ctx,
SearchHandler searchHandler,
GuildMusicManager mm,
boolean isUrl,
String query,
String raw) {
this.ctx = ctx;
this.searchHandler = searchHandler;
this.mm = mm;
this.isUrl = isUrl;
this.query = query;
this.raw = raw;
}
@Override
public void ontrackLoaded(TrackLoaded r) {
if (isUrl) enqueueAndNotify(r.getTrack());
else showResults(List.of(r.getTrack()));
}
@Override
public void onPlaylistLoaded(PlaylistLoaded r) {
if (isUrl) {
int count = 0;
for (Track t : r.getTracks()) {
mm.getScheduler().enqueue(t);
count++;
}
ctx.getChannel()
.sendMessageEmbeds(
EmbedTemplates.info(
"Плейлист добавлен", "**" + r.getInfo().getName() + "** — " + count + " треков"))
.queue();
} else if (r.getTracks().size() <= 1 && !r.getTracks().isEmpty()) {
enqueueAndNotify(r.getTracks().get(0));
} else {
showResults(r.getTracks());
}
}
@Override
public void onSearchResultLoaded(SearchResult r) {
String noMatch =
isUrl ? "Ничего не найдено по ссылке." : "Ничего не найдено по запросу: " + raw;
if (r.getTracks().isEmpty()) {
sendErr(noMatch);
} else if (isUrl) {
enqueueAndNotify(r.getTracks().get(0));
} else {
showResults(r.getTracks());
}
}
@Override
public void noMatches() {
String noMatch =
isUrl ? "Ничего не найдено по ссылке." : "Ничего не найдено по запросу: " + raw;
sendErr(noMatch);
}
@Override
public void loadFailed(LoadFailed e) {
sendErr(
e.getException().getMessage() != null
? e.getException().getMessage()
: "Неизвестная ошибка");
}
private void enqueueAndNotify(Track track) {
mm.getScheduler().enqueue(track);
ctx.getChannel()
.sendMessageEmbeds(
EmbedTemplates.info(
"Добавлено в очередь",
"**"
+ track.getInfo().getTitle()
+ "**\n"
+ SearchHandler.formatDuration(track.getInfo().getLength())))
.queue();
}
private void showResults(List<Track> list) {
if (query == null) return;
searchHandler.storeResults(ctx.getGuild().getIdLong(), list);
int show = Math.min(25, list.size());
var builder = StringSelectMenu.create("play_select")
.setPlaceholder("Выбери трек...");
for (int i = 0; i < show; i++) {
Track t = list.get(i);
builder.addOption(
truncate(t.getInfo().getTitle(), 100),
String.valueOf(i),
truncate(t.getInfo().getAuthor(), 100));
}
ctx.getChannel()
.sendMessageEmbeds(searchHandler.buildResultsEmbed(query, list).build())
.addComponents(ActionRow.of(builder.build()))
.queue();
}
private void sendErr(String msg) {
ctx.getChannel().sendMessageEmbeds(EmbedTemplates.error("Ошибка", msg)).queue();
}
private static String truncate(String s, int max) {
if (s == null) return "";
if (s.length() <= max) return s;
return s.substring(0, max - 1) + "…";
}
}

View file

@ -7,7 +7,7 @@ import dev.arbjerg.lavalink.client.player.Track;
import net.dv8tion.jda.api.EmbedBuilder;
public class SearchHandler {
private static final int SHOW_RESULTS = 10;
private static final int SHOW_RESULTS = 5;
private final SearchManager searchManager;
@ -31,8 +31,7 @@ public class SearchHandler {
searchManager.clear(guildId);
}
public EmbedBuilder buildResultsEmbed(
String query, List<Track> tracks, String source, String prefix, String commandName) {
public EmbedBuilder buildResultsEmbed(String query, List<Track> tracks) {
int show = Math.min(SHOW_RESULTS, tracks.size());
StringBuilder sb = new StringBuilder();
for (int i = 0; i < show; i++) {
@ -45,16 +44,10 @@ public class SearchHandler {
.append(formatDuration(t.getInfo().getLength()))
.append("\n");
}
String pickHint = "Выбери `" + prefix + commandName + " 1`–`" + show + "`";
String sourceLabel = switch (source) {
case "sc" -> "SoundCloud";
case "ytm" -> "YouTube Music";
default -> "YouTube";
};
return new EmbedBuilder()
.setColor(0x00AAFF)
.setTitle("Результаты поиска: " + query + " на " + sourceLabel)
.setDescription(sb + "\n" + pickHint)
.setTitle("Результаты поиска: " + query)
.setDescription(sb + "\nВыбери `!" + "play" + " 1`–`" + show + "`")
.setFooter("Результаты доступны 2 минуты");
}

View file

@ -0,0 +1,156 @@
package creator.loki.feature.reactroles;
import java.util.List;
import com.google.inject.Inject;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedTemplates;
import net.dv8tion.jda.api.Permission;
public class ReactionRoleCommand implements Command {
private final ReactionRoleRepository repository;
@Inject
public ReactionRoleCommand(ReactionRoleRepository repository) {
this.repository = repository;
}
@Override
public String getName() {
return "reactionrole";
}
@Override
public String getDescription() {
return "Управление реакциями-ролями";
}
@Override
public String getUsage() {
return "reactionrole setup <messageId> <emoji> <role> | remove <messageId> <emoji> | list [messageId]";
}
@Override
public String getCategory() {
return "Настройка";
}
@Override
public void execute(CommandContext context) {
if (!context.getAuthor().hasPermission(Permission.ADMINISTRATOR)) {
context.getChannel()
.sendMessageEmbeds(EmbedTemplates.error("Нет доступа", "Требуются права администратора"))
.queue();
return;
}
var guild = context.getGuild();
if (guild == null) {
context.getChannel()
.sendMessageEmbeds(EmbedTemplates.error("Ошибка", "Команда доступна только на сервере"))
.queue();
return;
}
var args = context.getArgs();
if (args.isEmpty()) {
sendHelp(context);
return;
}
var subcommand = args.get(0).toLowerCase();
switch (subcommand) {
case "setup" -> handleSetup(context, guild, args);
case "remove" -> handleRemove(context, args);
case "list" -> handleList(context, guild, args);
default -> sendHelp(context);
}
}
private void handleSetup(CommandContext context, net.dv8tion.jda.api.entities.Guild guild, List<String> args) {
if (args.size() < 4) {
context.getChannel()
.sendMessageEmbeds(EmbedTemplates.error("Неверное использование",
"Используйте: `reactionrole setup <messageId> <emoji> <role>`"))
.queue();
return;
}
String messageId = args.get(1);
String emoji = args.get(2);
String roleInput = args.get(3);
String roleId = roleInput.replaceAll("<@&|>", "");
var role = guild.getRoleById(roleId);
if (role == null) {
context.getChannel()
.sendMessageEmbeds(EmbedTemplates.error("Роль не найдена",
"Убедитесь, что роль существует и указан ID или упоминание"))
.queue();
return;
}
repository.save(guild.getId(), context.getChannel().getId(), messageId, emoji, roleId);
context.getChannel()
.sendMessageEmbeds(EmbedTemplates.success("Reaction-role добавлена",
"Роль " + role.getAsMention() + " будет выдаваться при реакции " + emoji))
.queue();
}
private void handleRemove(CommandContext context, List<String> args) {
if (args.size() < 3) {
context.getChannel()
.sendMessageEmbeds(EmbedTemplates.error("Неверное использование",
"Используйте: `reactionrole remove <messageId> <emoji>`"))
.queue();
return;
}
String messageId = args.get(1);
String emoji = args.get(2);
repository.deleteByMessageAndEmoji(messageId, emoji);
context.getChannel()
.sendMessageEmbeds(EmbedTemplates.success("Reaction-role удалена",
"Связь реакции " + emoji + " на сообщении " + messageId + " удалена"))
.queue();
}
private void handleList(CommandContext context, net.dv8tion.jda.api.entities.Guild guild, List<String> args) {
List<ReactionRoleRepository.ReactionRole> bindings;
if (args.size() >= 2) {
bindings = repository.findByMessage(args.get(1));
} else {
bindings = repository.findByGuild(guild.getId());
}
if (bindings.isEmpty()) {
context.getChannel()
.sendMessageEmbeds(EmbedTemplates.info("Список пуст", "Reaction-роли не найдены"))
.queue();
return;
}
var sb = new StringBuilder();
for (var b : bindings) {
var role = guild.getRoleById(b.roleId());
String roleName = role != null ? role.getAsMention() : b.roleId();
sb.append("• `").append(b.messageId()).append("` — ").append(b.emoji()).append(" → ").append(roleName).append("\n");
}
context.getChannel()
.sendMessageEmbeds(EmbedTemplates.info("Reaction-роли (" + bindings.size() + ")", sb.toString()))
.queue();
}
private void sendHelp(CommandContext context) {
context.getChannel()
.sendMessageEmbeds(EmbedTemplates.info("Reaction Role",
"`reactionrole setup <messageId> <emoji> <role>` — добавить связь\n"
+ "`reactionrole remove <messageId> <emoji>` — удалить связь\n"
+ "`reactionrole list [messageId]` — список связей"))
.queue();
}
}

View file

@ -0,0 +1,75 @@
package creator.loki.feature.reactroles;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import creator.loki.util.LoggerDecorator;
import net.dv8tion.jda.api.events.message.react.MessageReactionAddEvent;
import net.dv8tion.jda.api.events.message.react.MessageReactionRemoveEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Singleton
public class ReactionRoleListener extends ListenerAdapter {
private static final Logger logger = LoggerFactory.getLogger(ReactionRoleListener.class);
private final ReactionRoleRepository repository;
@Inject
public ReactionRoleListener(ReactionRoleRepository repository) {
this.repository = repository;
}
@Override
public void onMessageReactionAdd(@NotNull MessageReactionAddEvent event) {
if (event.getUser() == null || event.getUser().isBot()) return;
if (event.getGuild() == null) return;
var emoji = event.getEmoji().getAsReactionCode();
var messageId = event.getMessageId();
var bindings = repository.findByMessage(messageId);
var binding = bindings.stream().filter(b -> b.emoji().equals(emoji)).findFirst();
if (binding.isEmpty()) return;
var role = event.getGuild().getRoleById(binding.get().roleId());
if (role == null) {
LoggerDecorator.warn(logger, "Роль " + binding.get().roleId() + " не найдена для reaction-role");
return;
}
var member = event.getMember();
if (member == null) return;
event.getGuild().addRoleToMember(member, role).queue(
ok -> {},
error -> LoggerDecorator.error(logger, "Ошибка выдачи роли " + binding.get().roleId(), error)
);
}
@Override
public void onMessageReactionRemove(@NotNull MessageReactionRemoveEvent event) {
if (event.getUser() == null || event.getUser().isBot()) return;
if (event.getGuild() == null) return;
var emoji = event.getEmoji().getAsReactionCode();
var messageId = event.getMessageId();
var bindings = repository.findByMessage(messageId);
var binding = bindings.stream().filter(b -> b.emoji().equals(emoji)).findFirst();
if (binding.isEmpty()) return;
var role = event.getGuild().getRoleById(binding.get().roleId());
if (role == null) {
LoggerDecorator.warn(logger, "Роль " + binding.get().roleId() + " не найдена для reaction-role");
return;
}
event.getGuild().removeRoleFromMember(event.getUser(), role).queue(
ok -> {},
error -> LoggerDecorator.error(logger, "Ошибка снятия роли " + binding.get().roleId(), error)
);
}
}

View file

@ -0,0 +1,117 @@
package creator.loki.feature.reactroles;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import creator.loki.database.Database;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Singleton
public class ReactionRoleRepository {
private static final Logger logger = LoggerFactory.getLogger(ReactionRoleRepository.class);
private final Database database;
@Inject
public ReactionRoleRepository(Database database) {
this.database = database;
}
public void save(String guildId, String channelId, String messageId, String emoji, String roleId) {
String sql =
"""
INSERT OR REPLACE INTO reaction_roles (guild_id, channel_id, message_id, emoji, role_id)
VALUES (?, ?, ?, ?, ?)
""";
try (PreparedStatement stmt = database.prepare(sql)) {
stmt.setString(1, guildId);
stmt.setString(2, channelId);
stmt.setString(3, messageId);
stmt.setString(4, emoji);
stmt.setString(5, roleId);
stmt.executeUpdate();
} catch (SQLException e) {
logger.error("Ошибка сохранения reaction-role", e);
}
}
public List<ReactionRole> findByMessage(String messageId) {
String sql = "SELECT id, guild_id, channel_id, message_id, emoji, role_id FROM reaction_roles WHERE message_id = ?";
try (PreparedStatement stmt = database.prepare(sql)) {
stmt.setString(1, messageId);
try (ResultSet rs = stmt.executeQuery()) {
List<ReactionRole> results = new ArrayList<>();
while (rs.next()) {
results.add(mapRow(rs));
}
return results;
}
} catch (SQLException e) {
logger.error("Ошибка поиска reaction-roles по сообщению {}", messageId, e);
return List.of();
}
}
public List<ReactionRole> findByGuild(String guildId) {
String sql = "SELECT id, guild_id, channel_id, message_id, emoji, role_id FROM reaction_roles WHERE guild_id = ?";
try (PreparedStatement stmt = database.prepare(sql)) {
stmt.setString(1, guildId);
try (ResultSet rs = stmt.executeQuery()) {
List<ReactionRole> results = new ArrayList<>();
while (rs.next()) {
results.add(mapRow(rs));
}
return results;
}
} catch (SQLException e) {
logger.error("Ошибка поиска reaction-roles по гильдии {}", guildId, e);
return List.of();
}
}
public void delete(int id) {
String sql = "DELETE FROM reaction_roles WHERE id = ?";
try (PreparedStatement stmt = database.prepare(sql)) {
stmt.setInt(1, id);
stmt.executeUpdate();
} catch (SQLException e) {
logger.error("Ошибка удаления reaction-role {}", id, e);
}
}
public void deleteByMessageAndEmoji(String messageId, String emoji) {
String sql = "DELETE FROM reaction_roles WHERE message_id = ? AND emoji = ?";
try (PreparedStatement stmt = database.prepare(sql)) {
stmt.setString(1, messageId);
stmt.setString(2, emoji);
stmt.executeUpdate();
} catch (SQLException e) {
logger.error("Ошибка удаления reaction-role {} {}", messageId, emoji, e);
}
}
private ReactionRole mapRow(ResultSet rs) throws SQLException {
return new ReactionRole(
rs.getInt("id"),
rs.getString("guild_id"),
rs.getString("channel_id"),
rs.getString("message_id"),
rs.getString("emoji"),
rs.getString("role_id"));
}
public record ReactionRole(int id, String guildId, String channelId, String messageId, String emoji, String roleId) {}
}

View file

@ -0,0 +1,19 @@
package creator.loki.feature.reactroles;
import com.google.inject.Inject;
import creator.loki.core.registry.CommandRegistrar;
import creator.loki.core.registry.CommandRegistry;
public class ReactionRolesCommandRegistrar implements CommandRegistrar {
private final ReactionRoleRepository repository;
@Inject
public ReactionRolesCommandRegistrar(ReactionRoleRepository repository) {
this.repository = repository;
}
@Override
public void register(CommandRegistry registry) {
registry.register(new ReactionRoleCommand(repository));
}
}

View file

@ -0,0 +1,17 @@
package creator.loki.feature.reactroles;
import com.google.inject.AbstractModule;
import com.google.inject.Singleton;
import com.google.inject.multibindings.Multibinder;
import creator.loki.core.registry.CommandRegistrar;
public class ReactionRolesModule extends AbstractModule {
@Override
protected void configure() {
bind(ReactionRoleRepository.class).in(Singleton.class);
bind(ReactionRoleListener.class).in(Singleton.class);
Multibinder.newSetBinder(binder(), CommandRegistrar.class)
.addBinding()
.to(ReactionRolesCommandRegistrar.class);
}
}

View file

@ -0,0 +1,19 @@
package creator.loki.feature.ticket;
import com.google.inject.Inject;
import creator.loki.core.registry.CommandRegistrar;
import creator.loki.core.registry.CommandRegistry;
public class TicketCommandRegistrar implements CommandRegistrar {
private final TicketSetupCommand ticketSetupCommand;
@Inject
public TicketCommandRegistrar(TicketSetupCommand ticketSetupCommand) {
this.ticketSetupCommand = ticketSetupCommand;
}
@Override
public void register(CommandRegistry registry) {
registry.register(ticketSetupCommand);
}
}

View file

@ -0,0 +1,157 @@
package creator.loki.feature.ticket;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import creator.loki.feature.ticket.TicketRepository.Ticket;
import net.dv8tion.jda.api.EmbedBuilder;
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.channel.concrete.Category;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import net.dv8tion.jda.api.managers.channel.concrete.TextChannelManager;
import net.dv8tion.jda.api.requests.restaction.ChannelAction;
import net.dv8tion.jda.api.components.buttons.Button;
import net.dv8tion.jda.api.components.actionrow.ActionRow;
import java.awt.Color;
import java.util.EnumSet;
@Singleton
public class TicketListener extends ListenerAdapter {
private final TicketRepository ticketRepository;
@Inject
public TicketListener(TicketRepository ticketRepository) {
this.ticketRepository = ticketRepository;
}
@Override
public void onButtonInteraction(ButtonInteractionEvent event) {
String buttonId = event.getComponentId();
if (!buttonId.startsWith("ticket_")) return;
switch (buttonId) {
case "ticket_create" -> handleCreate(event);
case "ticket_close" -> handleClose(event);
case "ticket_delete" -> handleDelete(event);
default -> { /* ignore */ }
}
}
private void handleCreate(ButtonInteractionEvent event) {
Guild guild = event.getGuild();
if (guild == null) return;
Member member = event.getMember();
if (member == null) return;
String username = member.getUser().getName();
String channelName = "ticket-" + username;
Category category = findTicketCategory(guild);
ChannelAction<TextChannel> action = guild.createTextChannel(channelName);
if (category != null) {
action = action.setParent(category);
}
action.queue(channel -> {
TextChannelManager manager = channel.getManager();
manager.putPermissionOverride(guild.getPublicRole(), null, EnumSet.of(Permission.VIEW_CHANNEL)).queue();
manager.putPermissionOverride(member, EnumSet.of(Permission.VIEW_CHANNEL, Permission.MESSAGE_SEND, Permission.MESSAGE_HISTORY), null).queue();
manager.putPermissionOverride(guild.getSelfMember(), EnumSet.of(Permission.VIEW_CHANNEL, Permission.MESSAGE_SEND, Permission.MESSAGE_MANAGE, Permission.MESSAGE_HISTORY), null).queue();
var embed = new EmbedBuilder()
.setColor(Color.CYAN)
.setTitle("🎫 Поддержка")
.setDescription("Опишите вашу проблему, администраторы скоро ответят.")
.build();
channel.sendMessageEmbeds(embed)
.addComponents(ActionRow.of(Button.danger("ticket_close", "🔒 Закрыть")))
.queue();
ticketRepository.createTicket(channel.getId(), guild.getId(), member.getId());
event.reply("🎫 Создан тикет: " + channel.getAsMention())
.setEphemeral(true)
.queue();
});
}
private void handleClose(ButtonInteractionEvent event) {
Guild guild = event.getGuild();
if (guild == null) return;
Member member = event.getMember();
if (member == null) return;
TextChannel channel = event.getChannel().asTextChannel();
Ticket ticket = ticketRepository.findByChannel(channel.getId());
if (ticket == null) {
event.reply("❌ Тикет не найден в базе данных.").setEphemeral(true).queue();
return;
}
boolean isCreator = ticket.creatorId().equals(member.getId());
boolean isAdmin = member.hasPermission(Permission.MANAGE_CHANNEL);
if (!isCreator && !isAdmin) {
event.reply("❌ У вас нет прав для закрытия этого тикета.").setEphemeral(true).queue();
return;
}
ticketRepository.closeTicket(channel.getId());
Member ticketCreator = guild.getMemberById(ticket.creatorId());
if (ticketCreator != null) {
channel.upsertPermissionOverride(ticketCreator)
.deny(Permission.MESSAGE_SEND)
.queue();
}
event.getMessage().editMessageEmbeds(
new EmbedBuilder()
.setColor(Color.GRAY)
.setTitle("✅ Тикет закрыт")
.setDescription("Тикет закрыт пользователем " + member.getAsMention())
.build()
).setComponents(ActionRow.of(Button.danger("ticket_delete", "🗑 Удалить канал"))).queue();
event.reply("✅ Тикет закрыт.").setEphemeral(true).queue();
}
private void handleDelete(ButtonInteractionEvent event) {
Member member = event.getMember();
if (member == null || !member.hasPermission(Permission.MANAGE_CHANNEL)) {
event.reply("❌ У вас нет прав на удаление каналов.").setEphemeral(true).queue();
return;
}
TextChannel channel = event.getChannel().asTextChannel();
ticketRepository.deleteTicket(channel.getId());
channel.delete().queue();
event.reply("🗑 Канал будет удалён.").setEphemeral(true).queue();
}
private Category findTicketCategory(Guild guild) {
for (Category category : guild.getCategories()) {
if (category.getName().equalsIgnoreCase("Tickets")) {
return category;
}
}
if (!guild.getCategories().isEmpty()) {
return guild.getCategories().get(0);
}
return null;
}
}

View file

@ -0,0 +1,18 @@
package creator.loki.feature.ticket;
import com.google.inject.AbstractModule;
import com.google.inject.Singleton;
import com.google.inject.multibindings.Multibinder;
import creator.loki.core.registry.CommandRegistrar;
public class TicketModule extends AbstractModule {
@Override
protected void configure() {
bind(TicketRepository.class).in(Singleton.class);
bind(TicketListener.class).in(Singleton.class);
Multibinder.newSetBinder(binder(), CommandRegistrar.class)
.addBinding()
.to(TicketCommandRegistrar.class);
}
}

View file

@ -0,0 +1,121 @@
package creator.loki.feature.ticket;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import creator.loki.database.Database;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Singleton
public class TicketRepository {
private static final Logger logger = LoggerFactory.getLogger(TicketRepository.class);
private final Database database;
@Inject
public TicketRepository(Database database) {
this.database = database;
}
public void createTicket(String channelId, String guildId, String creatorId) {
String sql =
"""
INSERT INTO tickets (channel_id, guild_id, creator_id, created_at, closed, claimed_by)
VALUES (?, ?, ?, ?, 0, NULL)
""";
try (PreparedStatement stmt = database.prepare(sql)) {
stmt.setString(1, channelId);
stmt.setString(2, guildId);
stmt.setString(3, creatorId);
stmt.setLong(4, System.currentTimeMillis());
stmt.executeUpdate();
} catch (SQLException e) {
logger.error("Ошибка создания тикета {}", channelId, e);
}
}
public void closeTicket(String channelId) {
String sql = "UPDATE tickets SET closed = 1 WHERE channel_id = ?";
try (PreparedStatement stmt = database.prepare(sql)) {
stmt.setString(1, channelId);
stmt.executeUpdate();
} catch (SQLException e) {
logger.error("Ошибка закрытия тикета {}", channelId, e);
}
}
public void deleteTicket(String channelId) {
String sql = "DELETE FROM tickets WHERE channel_id = ?";
try (PreparedStatement stmt = database.prepare(sql)) {
stmt.setString(1, channelId);
stmt.executeUpdate();
} catch (SQLException e) {
logger.error("Ошибка удаления тикета {}", channelId, e);
}
}
public List<Ticket> findOpenByGuild(String guildId) {
String sql =
"SELECT channel_id, guild_id, creator_id, created_at, closed, claimed_by FROM tickets WHERE guild_id = ? AND closed = 0";
try (PreparedStatement stmt = database.prepare(sql)) {
stmt.setString(1, guildId);
try (ResultSet rs = stmt.executeQuery()) {
List<Ticket> results = new ArrayList<>();
while (rs.next()) {
results.add(mapRow(rs));
}
return results;
}
} catch (SQLException e) {
logger.error("Ошибка поиска открытых тикетов для гильдии {}", guildId, e);
return List.of();
}
}
public Ticket findByChannel(String channelId) {
String sql =
"SELECT channel_id, guild_id, creator_id, created_at, closed, claimed_by FROM tickets WHERE channel_id = ?";
try (PreparedStatement stmt = database.prepare(sql)) {
stmt.setString(1, channelId);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
return mapRow(rs);
}
}
} catch (SQLException e) {
logger.error("Ошибка поиска тикета {}", channelId, e);
}
return null;
}
private Ticket mapRow(ResultSet rs) throws SQLException {
return new Ticket(
rs.getString("channel_id"),
rs.getString("guild_id"),
rs.getString("creator_id"),
rs.getLong("created_at"),
rs.getBoolean("closed"),
rs.getString("claimed_by"));
}
public record Ticket(
String channelId,
String guildId,
String creatorId,
long createdAt,
boolean closed,
String claimedBy) {}
}

View file

@ -0,0 +1,59 @@
package creator.loki.feature.ticket;
import com.google.inject.Inject;
import creator.loki.command.Command;
import creator.loki.command.CommandContext;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.components.buttons.Button;
import net.dv8tion.jda.api.components.actionrow.ActionRow;
import java.awt.Color;
public class TicketSetupCommand implements Command {
private final TicketRepository ticketRepository;
@Inject
public TicketSetupCommand(TicketRepository ticketRepository) {
this.ticketRepository = ticketRepository;
}
@Override
public String getName() {
return "ticketsetup";
}
@Override
public String getDescription() {
return "Создать панель создания тикетов в текущем канале";
}
@Override
public String getUsage() {
return "ticketsetup";
}
@Override
public String getCategory() {
return "Настройка";
}
@Override
public void execute(CommandContext context) {
if (!context.getAuthor().hasPermission(Permission.ADMINISTRATOR)) {
context.getChannel().sendMessage("❌ Только администраторы могут использовать эту команду.").queue();
return;
}
var embed = new EmbedBuilder()
.setColor(Color.CYAN)
.setTitle("🎫 Поддержка")
.setDescription("Нажми кнопку, чтобы создать тикет")
.build();
context.getChannel()
.sendMessageEmbeds(embed)
.addComponents(ActionRow.of(Button.success("ticket_create", "🎫 Создать тикет")))
.queue();
}
}