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

View file

@ -103,6 +103,27 @@ public final class FeatureTables {
left_count INTEGER DEFAULT 0, left_count INTEGER DEFAULT 0,
difference 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 = private static final String RATINGS =
""" """
CREATE TABLE IF NOT EXISTS ratings ( CREATE TABLE IF NOT EXISTS ratings (
@ -123,7 +144,9 @@ public final class FeatureTables {
database.execute(VOICE_CHANNELS); database.execute(VOICE_CHANNELS);
database.execute(VOICE_PRESETS); database.execute(VOICE_PRESETS);
database.execute(COUNTER_STATS); database.execute(COUNTER_STATS);
database.execute(TICKETS);
database.execute(RATINGS); database.execute(RATINGS);
database.execute(REACTION_ROLES);
createIndexes(database); 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.CooldownManager;
import creator.loki.core.security.RateLimiter; import creator.loki.core.security.RateLimiter;
import creator.loki.database.DatabaseModule; import creator.loki.database.DatabaseModule;
import creator.loki.feature.config.ConfigModule;
import creator.loki.feature.counter.CounterModule; import creator.loki.feature.counter.CounterModule;
import creator.loki.feature.economy.EconomyModule; import creator.loki.feature.economy.EconomyModule;
import creator.loki.feature.entertainment.EntertainmentModule; 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.roast.RoastModule;
import creator.loki.feature.role.RoleModule; import creator.loki.feature.role.RoleModule;
import creator.loki.feature.tiktok.TikTokModule; 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.utility.UtilityModule;
import creator.loki.feature.voice.VoiceModule; import creator.loki.feature.voice.VoiceModule;
import creator.loki.feature.welcome.WelcomeModule; import creator.loki.feature.welcome.WelcomeModule;
@ -47,6 +50,7 @@ public class BotModule extends AbstractModule {
bind(PrefixCommandRegistrar.class).in(Singleton.class); bind(PrefixCommandRegistrar.class).in(Singleton.class);
install(new DatabaseModule()); install(new DatabaseModule());
install(new ConfigModule());
install(new VoiceModule()); install(new VoiceModule());
install(new MusicModule()); install(new MusicModule());
install(new LevelModule()); install(new LevelModule());
@ -62,7 +66,9 @@ public class BotModule extends AbstractModule {
install(new ImageModule()); install(new ImageModule());
install(new TikTokModule()); install(new TikTokModule());
install(new EntertainmentModule()); install(new EntertainmentModule());
install(new TicketModule());
install(new UtilityModule()); install(new UtilityModule());
install(new ReactionRolesModule());
install(new LifecycleModule()); 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.commands.QueueCommands;
import creator.loki.feature.music.core.PlayerManager; import creator.loki.feature.music.core.PlayerManager;
import creator.loki.feature.music.core.SearchManager; import creator.loki.feature.music.core.SearchManager;
import creator.loki.feature.music.play.MusicSearchSessionManager;
import creator.loki.feature.music.play.PlayCommand; import creator.loki.feature.music.play.PlayCommand;
public class MusicCommandRegistrar implements CommandRegistrar { public class MusicCommandRegistrar implements CommandRegistrar {
private final PlayerManager playerManager; private final PlayerManager playerManager;
private final SearchManager searchManager; private final SearchManager searchManager;
private final MusicSearchSessionManager sessionManager;
@Inject @Inject
public MusicCommandRegistrar(PlayerManager playerManager, SearchManager searchManager, MusicSearchSessionManager sessionManager) { public MusicCommandRegistrar(PlayerManager playerManager, SearchManager searchManager) {
this.playerManager = playerManager; this.playerManager = playerManager;
this.searchManager = searchManager; this.searchManager = searchManager;
this.sessionManager = sessionManager;
} }
@Override @Override
public void register(CommandRegistry registry) { 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.skip(playerManager));
registry.register(PlayerCommands.stop(playerManager)); registry.register(PlayerCommands.stop(playerManager));
registry.register(PlayerCommands.leave(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.core.registry.CommandRegistrar;
import creator.loki.feature.music.core.LavalinkManager; import creator.loki.feature.music.core.LavalinkManager;
import creator.loki.feature.music.core.PlayerManager; import creator.loki.feature.music.core.PlayerManager;
import creator.loki.feature.music.play.MusicSearchSessionManager;
public class MusicModule extends AbstractModule { public class MusicModule extends AbstractModule {
@Override @Override
protected void configure() { protected void configure() {
bind(LavalinkManager.class).in(Singleton.class); bind(LavalinkManager.class).in(Singleton.class);
bind(PlayerManager.class).in(Singleton.class); bind(PlayerManager.class).in(Singleton.class);
bind(MusicSearchSessionManager.class).in(Singleton.class);
Multibinder.newSetBinder(binder(), CommandRegistrar.class) Multibinder.newSetBinder(binder(), CommandRegistrar.class)
.addBinding() .addBinding()

View file

@ -90,7 +90,7 @@ public class MusicBotControlHandler {
} }
try { try {
var vol = Math.max(0, Math.min(100, Integer.parseInt(args.get(0)))); var vol = Math.max(0, Math.min(100, Integer.parseInt(args.get(0))));
if (link != null && link.getCachedPlayer() != null) if (link != null)
link.createOrUpdatePlayer() link.createOrUpdatePlayer()
.setVolume(vol) .setVolume(vol)
.subscribe(null, err -> log.warn("Lavalink: {}", err.getMessage())); .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) { public void handlePlay(MessageChannel ch, Guild guild, Member member, List<String> args) {
if (reqVoice(ch, member) == null) return; if (reqVoice(ch, member) == null) return;
if (args.isEmpty()) { if (args.isEmpty()) {
send(ch, err("Укажи URL или запрос: `q!play <название>`")); send(ch, err("Укажи URL или запрос: `m!play <название>`"));
return; return;
} }
connect(guild, member, lavalinkManager); 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) { private void startWithRetry(Link link, Track track, int attempt) {
if (attempt > MAX_ATTEMPTS) return; 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() link.createOrUpdatePlayer()
.setTrack(track) .setTrack(track)
.setVolume(100) .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; package creator.loki.feature.music.play;
import java.awt.Color;
import java.util.List; import java.util.List;
import java.util.Set;
import creator.loki.command.Command; import creator.loki.command.Command;
import creator.loki.command.CommandContext; import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedTemplates; import creator.loki.embed.EmbedTemplates;
import creator.loki.feature.music.core.LavalinkManager;
import creator.loki.feature.music.core.PlayerManager; import creator.loki.feature.music.core.PlayerManager;
import creator.loki.feature.music.core.SearchManager; import creator.loki.feature.music.core.SearchManager;
import creator.loki.feature.music.manage.GuildMusicManager;
import creator.loki.util.VoiceHelper; import creator.loki.util.VoiceHelper;
import dev.arbjerg.lavalink.client.AbstractAudioLoadResultHandler;
import dev.arbjerg.lavalink.client.player.LoadFailed;
import dev.arbjerg.lavalink.client.player.PlaylistLoaded;
import dev.arbjerg.lavalink.client.player.SearchResult;
import dev.arbjerg.lavalink.client.player.Track; import dev.arbjerg.lavalink.client.player.Track;
import dev.arbjerg.lavalink.client.player.TrackLoaded;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.components.actionrow.ActionRow;
import net.dv8tion.jda.api.components.buttons.Button;
public class PlayCommand implements Command { 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 PlayerManager playerManager;
private final SearchHandler searchHandler; 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.playerManager = playerManager;
this.searchHandler = new SearchHandler(searchManager); this.searchHandler = new SearchHandler(searchManager);
this.sessionManager = sessionManager;
} }
@Override @Override
@ -46,7 +37,7 @@ public class PlayCommand implements Command {
@Override @Override
public String getUsage() { public String getUsage() {
return "play <URL> | play <номер> | play <запрос>"; return "play [yt|sc|list] <запрос> | play <номер>";
} }
@Override @Override
@ -59,7 +50,10 @@ public class PlayCommand implements Command {
Guild guild = ctx.getGuild(); Guild guild = ctx.getGuild();
if (guild == null) return; if (guild == null) return;
Member member = ctx.getAuthor(); Member member = ctx.getAuthor();
if (member == null) return; if (member == null || !member.getVoiceState().inAudioChannel()) {
sendErr(ctx, "Зайди в голосовой канал сначала.");
return;
}
var args = ctx.getArgs(); var args = ctx.getArgs();
if (args.isEmpty()) { if (args.isEmpty()) {
sendErr(ctx, "Укажи URL, запрос или номер: `" + getUsage() + "`"); sendErr(ctx, "Укажи URL, запрос или номер: `" + getUsage() + "`");
@ -70,86 +64,10 @@ public class PlayCommand implements Command {
handlePickResult(ctx, first); handlePickResult(ctx, first);
return; 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; if (!resolveVoiceConnection(ctx)) return;
var lm = playerManager.getLavalinkManager(); var lm = playerManager.getLavalinkManager();
long guildId = ctx.getGuild().getIdLong(); var mm = lm.getMusicManager(guild.getIdLong());
lm.getOrCreateLink(guildId) handlePlayQuery(ctx, args, first, lm, mm);
.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);
}
});
} }
private boolean resolveVoiceConnection(CommandContext ctx) { private boolean resolveVoiceConnection(CommandContext ctx) {
@ -174,6 +92,47 @@ public class PlayCommand implements Command {
enqueueAndNotify(ctx, t); 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) { private void enqueueAndNotify(CommandContext ctx, Track track) {
var m = playerManager.getLavalinkManager().getMusicManager(ctx.getGuild().getIdLong()); var m = playerManager.getLavalinkManager().getMusicManager(ctx.getGuild().getIdLong());
m.getScheduler().enqueue(track); m.getScheduler().enqueue(track);

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