diff --git a/src/main/java/creator/loki/config/BotConfig.java b/src/main/java/creator/loki/config/BotConfig.java index 133ef0b..d6193d7 100644 --- a/src/main/java/creator/loki/config/BotConfig.java +++ b/src/main/java/creator/loki/config/BotConfig.java @@ -7,7 +7,10 @@ public class BotConfig { String prefix, String activity, int healthHttpPort, String superAdminId, String dbPath) {} public record AiContext( - String deepseekApiKey, String aiChannelId, int aiContextMsgs, int aiMsgLength, + String deepseekApiKey, + String aiChannelId, + int aiContextMsgs, + int aiMsgLength, int aiContextChars) {} public record AiDossier(int aiDossierMsgs, int aiDossierUpdateHours, String aiDebateChannelId) {} diff --git a/src/main/java/creator/loki/config/ConfigLoader.java b/src/main/java/creator/loki/config/ConfigLoader.java index b80b458..37e2b46 100644 --- a/src/main/java/creator/loki/config/ConfigLoader.java +++ b/src/main/java/creator/loki/config/ConfigLoader.java @@ -93,7 +93,8 @@ public final class ConfigLoader { return new BotConfig( new BotConfig.Tokens(mainToken, musicToken, radioToken), new BotConfig.BotSettings(prefix, activity, healthHttpPort, superAdminId, dbPath), - new BotConfig.AiContext(deepseekApiKey, aiChannelId, aiContextMsgs, aiMsgLength, aiContextChars), + new BotConfig.AiContext( + deepseekApiKey, aiChannelId, aiContextMsgs, aiMsgLength, aiContextChars), new BotConfig.AiDossier(aiDossierMsgs, aiDossierUpdateHours, aiDebateChannelId), lavalinkConfig, externalUrl); diff --git a/src/main/java/creator/loki/database/DatabaseMigration.java b/src/main/java/creator/loki/database/DatabaseMigration.java index 3a7b49c..72b0c60 100644 --- a/src/main/java/creator/loki/database/DatabaseMigration.java +++ b/src/main/java/creator/loki/database/DatabaseMigration.java @@ -16,10 +16,12 @@ import org.slf4j.LoggerFactory; public class DatabaseMigration { private static final Logger logger = LoggerFactory.getLogger(DatabaseMigration.class); private final Database database; + private final JsonMigration jsonMigration; private final Gson gson = new Gson(); public DatabaseMigration(Database database) { this.database = database; + this.jsonMigration = new JsonMigration(database, logger); } public void migrateFromJson(final String jsonPath) { @@ -32,24 +34,33 @@ public class DatabaseMigration { if (data == null) { return; } + if (alreadyMigrated()) { + return; + } + jsonMigration.userStats(data); + jsonMigration.reputation(data); + jsonMigration.shipPairs(data); + jsonMigration.customRoles(data); + markMigrated(); + LoggerDecorator.success(logger, "Миграция завершена успешно"); + } + + private boolean alreadyMigrated() { try (var chk = database.prepare("SELECT value FROM config WHERE key = 'migrated'"); var rs = chk.executeQuery()) { - if (rs.next() && "true".equals(rs.getString("value"))) { - return; - } + return rs.next() && "true".equals(rs.getString("value")); } catch (SQLException ignored) { + return false; } - migrateUserStats(data); - migrateReputation(data); - migrateShipPairs(data); - migrateCustomRoles(data); + } + + private void markMigrated() { var sql = "INSERT OR REPLACE INTO config (key, value) VALUES ('migrated', 'true')"; try (var p = database.prepare(sql)) { p.executeUpdate(); } catch (SQLException e) { LoggerDecorator.error(logger, "Ошибка сохранения статуса миграции", e); } - LoggerDecorator.success(logger, "Миграция завершена успешно"); } @SuppressWarnings("unchecked") @@ -62,108 +73,4 @@ public class DatabaseMigration { return null; } } - - @SuppressWarnings("unchecked") - private void migrateUserStats(final Map data) { - var userStats = (Map) data.get("userStats"); - if (userStats == null) { - return; - } - LoggerDecorator.database(logger, "Миграция userStats: " + userStats.size() + " записей"); - var sql = - "INSERT OR REPLACE INTO user_stats" - + " (user_id, level, total_xp, message_count," - + " voice_minutes, last_message_time, last_voice_join)" - + " VALUES (?, ?, ?, ?, ?, ?, ?)"; - try (var stmt = database.prepare(sql)) { - for (var entry : userStats.entrySet()) { - var stats = (Map) entry.getValue(); - stmt.setString(1, (String) stats.get("userId")); - stmt.setInt(2, ((Number) stats.get("level")).intValue()); - stmt.setInt(3, ((Number) stats.get("totalXP")).intValue()); - stmt.setInt(4, ((Number) stats.get("messageCount")).intValue()); - stmt.setLong(5, ((Number) stats.get("voiceMinutes")).longValue()); - stmt.setLong(6, ((Number) stats.get("lastMessageTime")).longValue()); - stmt.setLong(7, ((Number) stats.get("lastVoiceJoin")).longValue()); - stmt.executeUpdate(); - } - LoggerDecorator.success(logger, "userStats мигрированы"); - } catch (SQLException e) { - LoggerDecorator.error(logger, "Ошибка миграции userStats", e); - } - } - - @SuppressWarnings("unchecked") - private void migrateReputation(final Map data) { - var reputation = (Map) data.get("reputation"); - if (reputation == null) { - return; - } - LoggerDecorator.database(logger, "Миграция reputation: " + reputation.size() + " записей"); - try (var a = - database.prepare("INSERT OR REPLACE INTO reputation (user_id, reputation) VALUES (?, ?)")) { - for (var entry : reputation.entrySet()) { - a.setString(1, entry.getKey()); - a.setDouble(2, ((Number) entry.getValue()).doubleValue()); - a.executeUpdate(); - } - LoggerDecorator.success(logger, "reputation мигрирована"); - } catch (SQLException e) { - LoggerDecorator.error(logger, "Ошибка миграции reputation", e); - } - } - - @SuppressWarnings("unchecked") - private void migrateShipPairs(final Map data) { - var shipPairs = (Map) data.get("shipPairs"); - if (shipPairs == null) { - return; - } - LoggerDecorator.database(logger, "Миграция shipPairs: " + shipPairs.size() + " записей"); - try (var b = - database.prepare( - "INSERT OR REPLACE INTO ship_pairs (user1_id, user2_id, compatibility) VALUES (?, ?, ?)")) { - for (var entry : shipPairs.entrySet()) { - var users = entry.getKey().split("-"); - if (users.length == 2) { - b.setString(1, users[0]); - b.setString(2, users[1]); - b.setDouble(3, ((Number) entry.getValue()).doubleValue()); - b.executeUpdate(); - } - } - LoggerDecorator.success(logger, "shipPairs мигрированы"); - } catch (SQLException e) { - LoggerDecorator.error(logger, "Ошибка миграции shipPairs", e); - } - } - - @SuppressWarnings("unchecked") - private void migrateCustomRoles(final Map data) { - var customRoles = (Map) data.get("custom_roles"); - if (customRoles == null) { - return; - } - LoggerDecorator.database(logger, "Миграция custom_roles"); - try (var c = - database.prepare( - "INSERT OR REPLACE INTO custom_roles (guild_id, user_id, role_id) VALUES (?, ?, ?)")) { - for (var guildEntry : customRoles.entrySet()) { - var users = (Map) guildEntry.getValue(); - for (var userEntry : users.entrySet()) { - if (userEntry.getValue() instanceof java.util.List rolesList) { - for (var roleId : rolesList) { - c.setString(1, guildEntry.getKey()); - c.setString(2, userEntry.getKey()); - c.setString(3, roleId.toString()); - c.executeUpdate(); - } - } - } - } - LoggerDecorator.success(logger, "custom_roles мигрированы"); - } catch (SQLException e) { - LoggerDecorator.error(logger, "Ошибка миграции custom_roles", e); - } - } } diff --git a/src/main/java/creator/loki/database/JsonMigration.java b/src/main/java/creator/loki/database/JsonMigration.java new file mode 100644 index 0000000..924ba81 --- /dev/null +++ b/src/main/java/creator/loki/database/JsonMigration.java @@ -0,0 +1,121 @@ +package creator.loki.database; + +import java.sql.SQLException; +import java.util.Map; + +import creator.loki.util.LoggerDecorator; +import org.slf4j.Logger; + +final class JsonMigration { + private final Database database; + private final Logger logger; + + JsonMigration(Database database, Logger logger) { + this.database = database; + this.logger = logger; + } + + @SuppressWarnings("unchecked") + void userStats(final Map data) { + var userStats = (Map) data.get("userStats"); + if (userStats == null) { + return; + } + LoggerDecorator.database(logger, "Миграция userStats: " + userStats.size() + " записей"); + var sql = + "INSERT OR REPLACE INTO user_stats" + + " (user_id, level, total_xp, message_count," + + " voice_minutes, last_message_time, last_voice_join)" + + " VALUES (?, ?, ?, ?, ?, ?, ?)"; + try (var stmt = database.prepare(sql)) { + for (var entry : userStats.entrySet()) { + var stats = (Map) entry.getValue(); + stmt.setString(1, (String) stats.get("userId")); + stmt.setInt(2, ((Number) stats.get("level")).intValue()); + stmt.setInt(3, ((Number) stats.get("totalXP")).intValue()); + stmt.setInt(4, ((Number) stats.get("messageCount")).intValue()); + stmt.setLong(5, ((Number) stats.get("voiceMinutes")).longValue()); + stmt.setLong(6, ((Number) stats.get("lastMessageTime")).longValue()); + stmt.setLong(7, ((Number) stats.get("lastVoiceJoin")).longValue()); + stmt.executeUpdate(); + } + LoggerDecorator.success(logger, "userStats мигрированы"); + } catch (SQLException e) { + LoggerDecorator.error(logger, "Ошибка миграции userStats", e); + } + } + + @SuppressWarnings("unchecked") + void reputation(final Map data) { + var reputation = (Map) data.get("reputation"); + if (reputation == null) { + return; + } + LoggerDecorator.database(logger, "Миграция reputation: " + reputation.size() + " записей"); + try (var a = + database.prepare("INSERT OR REPLACE INTO reputation (user_id, reputation) VALUES (?, ?)")) { + for (var entry : reputation.entrySet()) { + a.setString(1, entry.getKey()); + a.setDouble(2, ((Number) entry.getValue()).doubleValue()); + a.executeUpdate(); + } + LoggerDecorator.success(logger, "reputation мигрирована"); + } catch (SQLException e) { + LoggerDecorator.error(logger, "Ошибка миграции reputation", e); + } + } + + @SuppressWarnings("unchecked") + void shipPairs(final Map data) { + var shipPairs = (Map) data.get("shipPairs"); + if (shipPairs == null) { + return; + } + LoggerDecorator.database(logger, "Миграция shipPairs: " + shipPairs.size() + " записей"); + try (var b = + database.prepare( + "INSERT OR REPLACE INTO ship_pairs (user1_id, user2_id, compatibility) VALUES (?, ?, ?)")) { + for (var entry : shipPairs.entrySet()) { + var users = entry.getKey().split("-"); + if (users.length == 2) { + b.setString(1, users[0]); + b.setString(2, users[1]); + b.setDouble(3, ((Number) entry.getValue()).doubleValue()); + b.executeUpdate(); + } + } + LoggerDecorator.success(logger, "shipPairs мигрированы"); + } catch (SQLException e) { + LoggerDecorator.error(logger, "Ошибка миграции shipPairs", e); + } + } + + @SuppressWarnings("unchecked") + void customRoles(final Map data) { + var customRoles = (Map) data.get("custom_roles"); + if (customRoles == null) { + return; + } + LoggerDecorator.database(logger, "Миграция custom_roles"); + try (var c = + database.prepare( + "INSERT OR REPLACE INTO custom_roles (guild_id, user_id, role_id) VALUES (?, ?, ?)")) { + for (var guildEntry : customRoles.entrySet()) { + var users = (Map) guildEntry.getValue(); + for (var userEntry : users.entrySet()) { + if (userEntry.getValue() instanceof java.util.List rolesList) { + for (var roleId : rolesList) { + c.setString(1, guildEntry.getKey()); + c.setString(2, userEntry.getKey()); + c.setString(3, roleId.toString()); + c.executeUpdate(); + } + } + } + } + LoggerDecorator.success(logger, "custom_roles мигрированы"); + } catch (SQLException e) { + LoggerDecorator.error(logger, "Ошибка миграции custom_roles", e); + } + } +} diff --git a/src/main/java/creator/loki/database/schema/CounterTables.java b/src/main/java/creator/loki/database/schema/CounterTables.java new file mode 100644 index 0000000..46668bf --- /dev/null +++ b/src/main/java/creator/loki/database/schema/CounterTables.java @@ -0,0 +1,23 @@ +package creator.loki.database.schema; + +import creator.loki.database.Database; + +final class CounterTables { + private CounterTables() {} + + static void create(final Database database) { + database.execute( + """ + CREATE TABLE IF NOT EXISTS counter_stats ( + date TEXT PRIMARY KEY, + joined INTEGER DEFAULT 0, + left_count INTEGER DEFAULT 0, + difference INTEGER DEFAULT 0 + )"""); + } + + static void createIndexes(final Database database) { + database.execute( + "CREATE INDEX IF NOT EXISTS idx_counter_stats_date ON counter_stats(date DESC)"); + } +} diff --git a/src/main/java/creator/loki/database/schema/EconomyTables.java b/src/main/java/creator/loki/database/schema/EconomyTables.java new file mode 100644 index 0000000..af39eda --- /dev/null +++ b/src/main/java/creator/loki/database/schema/EconomyTables.java @@ -0,0 +1,55 @@ +package creator.loki.database.schema; + +import creator.loki.database.Database; + +final class EconomyTables { + private EconomyTables() {} + + static void create(final Database database) { + database.execute( + """ + CREATE TABLE IF NOT EXISTS economy ( + user_id TEXT PRIMARY KEY, + cash INTEGER NOT NULL DEFAULT 0, + bank INTEGER NOT NULL DEFAULT 0, + total_earned INTEGER NOT NULL DEFAULT 0, + total_spent INTEGER NOT NULL DEFAULT 0, + last_daily TEXT, + daily_streak INTEGER DEFAULT 0, + fishing_luck INTEGER DEFAULT 0, + efficiency INTEGER DEFAULT 0, + last_fish TEXT, + job TEXT, + last_work TEXT + )"""); + database.execute( + """ + CREATE TABLE IF NOT EXISTS ratings ( + guild_id TEXT NOT NULL, + user_id TEXT NOT NULL, + rating_type TEXT NOT NULL, + value INTEGER NOT NULL, + PRIMARY KEY (guild_id, user_id, rating_type) + )"""); + database.execute( + """ + CREATE TABLE IF NOT EXISTS music_queue ( + guild_id TEXT NOT NULL, + position INTEGER NOT NULL, + track_encoded TEXT NOT NULL, + track_title TEXT, + track_author TEXT, + track_uri TEXT, + track_duration BIGINT, + PRIMARY KEY (guild_id, position) + )"""); + } + + static void createIndexes(final Database database) { + database.execute( + "CREATE INDEX IF NOT EXISTS idx_ratings_guild_type ON ratings(guild_id, rating_type)"); + database.execute("CREATE INDEX IF NOT EXISTS idx_ratings_value ON ratings(value DESC)"); + database.execute( + "CREATE INDEX IF NOT EXISTS idx_economy_balance ON economy((cash + bank) DESC)"); + } +} diff --git a/src/main/java/creator/loki/database/schema/FeatureTables.java b/src/main/java/creator/loki/database/schema/FeatureTables.java index 5d81c81..609561f 100644 --- a/src/main/java/creator/loki/database/schema/FeatureTables.java +++ b/src/main/java/creator/loki/database/schema/FeatureTables.java @@ -5,170 +5,19 @@ import creator.loki.database.Database; public final class FeatureTables { private FeatureTables() {} - private static final String ECONOMY = - """ - CREATE TABLE IF NOT EXISTS economy ( - user_id TEXT PRIMARY KEY, - cash INTEGER NOT NULL DEFAULT 0, - bank INTEGER NOT NULL DEFAULT 0, - total_earned INTEGER NOT NULL DEFAULT 0, - total_spent INTEGER NOT NULL DEFAULT 0, - last_daily TEXT, - daily_streak INTEGER DEFAULT 0, - fishing_luck INTEGER DEFAULT 0, - efficiency INTEGER DEFAULT 0, - last_fish TEXT, - job TEXT, - last_work TEXT - )"""; - private static final String MUSIC_QUEUE = - """ - CREATE TABLE IF NOT EXISTS music_queue ( - guild_id TEXT NOT NULL, - position INTEGER NOT NULL, - track_encoded TEXT NOT NULL, - track_title TEXT, - track_author TEXT, - track_uri TEXT, - track_duration BIGINT, - PRIMARY KEY (guild_id, position) - )"""; - private static final String WARNINGS = - """ - CREATE TABLE IF NOT EXISTS warnings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - guild_id TEXT NOT NULL, - user_id TEXT NOT NULL, - moderator_id TEXT NOT NULL, - reason TEXT NOT NULL, - created_at INTEGER NOT NULL - )"""; - private static final String MODERATION_CASES = - """ - CREATE TABLE IF NOT EXISTS moderation_cases ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - guild_id TEXT NOT NULL, - action TEXT NOT NULL, - target_id TEXT NOT NULL, - moderator_id TEXT NOT NULL, - reason TEXT NOT NULL, - created_at INTEGER NOT NULL, - duration_sec INTEGER, - until_ms INTEGER - )"""; - private static final String ACTIVE_MUTES = - """ - CREATE TABLE IF NOT EXISTS active_mutes ( - guild_id TEXT NOT NULL, - user_id TEXT NOT NULL, - ends_at_ms INTEGER NOT NULL, - case_id INTEGER NOT NULL, - PRIMARY KEY (guild_id, user_id) - )"""; - private static final String CUSTOM_ROLES = - """ - CREATE TABLE IF NOT EXISTS custom_roles ( - guild_id TEXT NOT NULL, - user_id TEXT NOT NULL, - role_id TEXT NOT NULL, - PRIMARY KEY (guild_id, user_id, role_id) - )"""; - private static final String VOICE_CHANNELS = - """ - CREATE TABLE IF NOT EXISTS voice_channels ( - channel_id TEXT PRIMARY KEY, - owner_id TEXT NOT NULL, - created_at INTEGER NOT NULL, - name TEXT, - user_limit INTEGER DEFAULT 0, - bitrate INTEGER DEFAULT 64000, - locked INTEGER DEFAULT 0, - text_channel_id TEXT - )"""; - private static final String VOICE_PRESETS = - """ - CREATE TABLE IF NOT EXISTS voice_presets ( - user_id TEXT PRIMARY KEY, - name TEXT, - user_limit INTEGER DEFAULT 0, - bitrate INTEGER DEFAULT 64000, - region TEXT DEFAULT 'auto', - locked INTEGER DEFAULT 0, - allowed_role_id TEXT, - denied_role_id TEXT - )"""; - private static final String COUNTER_STATS = - """ - CREATE TABLE IF NOT EXISTS counter_stats ( - date TEXT PRIMARY KEY, - joined INTEGER DEFAULT 0, - 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 ( - guild_id TEXT NOT NULL, - user_id TEXT NOT NULL, - rating_type TEXT NOT NULL, - value INTEGER NOT NULL, - PRIMARY KEY (guild_id, user_id, rating_type) - )"""; - public static void create(final Database database) { - database.execute(ECONOMY); - database.execute(MUSIC_QUEUE); - database.execute(WARNINGS); - database.execute(MODERATION_CASES); - database.execute(ACTIVE_MUTES); - database.execute(CUSTOM_ROLES); - database.execute(VOICE_CHANNELS); - database.execute(VOICE_PRESETS); - database.execute(COUNTER_STATS); - database.execute(TICKETS); - database.execute(RATINGS); - database.execute(REACTION_ROLES); + ModerationTables.create(database); + InteractionTables.create(database); + EconomyTables.create(database); + VoiceTables.create(database); + CounterTables.create(database); createIndexes(database); } private static void createIndexes(final Database database) { - for (var sql : - new String[] { - "CREATE INDEX IF NOT EXISTS idx_ratings_guild_type ON ratings(guild_id, rating_type)", - "CREATE INDEX IF NOT EXISTS idx_ratings_value ON ratings(value DESC)", - "CREATE INDEX IF NOT EXISTS idx_voice_channels_owner ON voice_channels(owner_id)", - "CREATE INDEX IF NOT EXISTS idx_voice_channels_created ON voice_channels(created_at)", - "CREATE INDEX IF NOT EXISTS idx_custom_roles_guild ON custom_roles(guild_id)", - "CREATE INDEX IF NOT EXISTS idx_custom_roles_user ON custom_roles(user_id)", - "CREATE INDEX IF NOT EXISTS idx_counter_stats_date ON counter_stats(date DESC)", - "CREATE INDEX IF NOT EXISTS idx_warnings_guild_user ON warnings(guild_id, user_id)", - "CREATE INDEX IF NOT EXISTS idx_warnings_guild_time ON warnings(guild_id, created_at DESC)", - "CREATE INDEX IF NOT EXISTS idx_mod_cases_guild_id ON moderation_cases(guild_id, id DESC)", - "CREATE INDEX IF NOT EXISTS idx_active_mutes_ends ON active_mutes(ends_at_ms)", - "CREATE INDEX IF NOT EXISTS idx_economy_balance ON economy((cash + bank) DESC)" - }) { - database.execute(sql); - } + ModerationTables.createIndexes(database); + EconomyTables.createIndexes(database); + VoiceTables.createIndexes(database); + CounterTables.createIndexes(database); } } diff --git a/src/main/java/creator/loki/database/schema/InteractionTables.java b/src/main/java/creator/loki/database/schema/InteractionTables.java new file mode 100644 index 0000000..4180504 --- /dev/null +++ b/src/main/java/creator/loki/database/schema/InteractionTables.java @@ -0,0 +1,31 @@ +package creator.loki.database.schema; + +import creator.loki.database.Database; + +final class InteractionTables { + private InteractionTables() {} + + static void create(final Database database) { + database.execute( + """ + 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) + )"""); + database.execute( + """ + 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 + )"""); + } +} diff --git a/src/main/java/creator/loki/database/schema/ModerationTables.java b/src/main/java/creator/loki/database/schema/ModerationTables.java new file mode 100644 index 0000000..755d603 --- /dev/null +++ b/src/main/java/creator/loki/database/schema/ModerationTables.java @@ -0,0 +1,53 @@ +package creator.loki.database.schema; + +import creator.loki.database.Database; + +final class ModerationTables { + private ModerationTables() {} + + static void create(final Database database) { + database.execute( + """ + CREATE TABLE IF NOT EXISTS warnings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT NOT NULL, + user_id TEXT NOT NULL, + moderator_id TEXT NOT NULL, + reason TEXT NOT NULL, + created_at INTEGER NOT NULL + )"""); + database.execute( + """ + CREATE TABLE IF NOT EXISTS moderation_cases ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT NOT NULL, + action TEXT NOT NULL, + target_id TEXT NOT NULL, + moderator_id TEXT NOT NULL, + reason TEXT NOT NULL, + created_at INTEGER NOT NULL, + duration_sec INTEGER, + until_ms INTEGER + )"""); + database.execute( + """ + CREATE TABLE IF NOT EXISTS active_mutes ( + guild_id TEXT NOT NULL, + user_id TEXT NOT NULL, + ends_at_ms INTEGER NOT NULL, + case_id INTEGER NOT NULL, + PRIMARY KEY (guild_id, user_id) + )"""); + } + + static void createIndexes(final Database database) { + database.execute( + "CREATE INDEX IF NOT EXISTS idx_warnings_guild_user ON warnings(guild_id, user_id)"); + database.execute( + "CREATE INDEX IF NOT EXISTS idx_warnings_guild_time ON warnings(guild_id, created_at DESC)"); + database.execute( + "CREATE INDEX IF NOT EXISTS idx_mod_cases_guild_id ON moderation_cases(guild_id, id DESC)"); + database.execute( + "CREATE INDEX IF NOT EXISTS idx_active_mutes_ends ON active_mutes(ends_at_ms)"); + } +} diff --git a/src/main/java/creator/loki/database/schema/VoiceTables.java b/src/main/java/creator/loki/database/schema/VoiceTables.java new file mode 100644 index 0000000..c349d33 --- /dev/null +++ b/src/main/java/creator/loki/database/schema/VoiceTables.java @@ -0,0 +1,51 @@ +package creator.loki.database.schema; + +import creator.loki.database.Database; + +final class VoiceTables { + private VoiceTables() {} + + static void create(final Database database) { + database.execute( + """ + CREATE TABLE IF NOT EXISTS custom_roles ( + guild_id TEXT NOT NULL, + user_id TEXT NOT NULL, + role_id TEXT NOT NULL, + PRIMARY KEY (guild_id, user_id, role_id) + )"""); + database.execute( + """ + CREATE TABLE IF NOT EXISTS voice_channels ( + channel_id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + name TEXT, + user_limit INTEGER DEFAULT 0, + bitrate INTEGER DEFAULT 64000, + locked INTEGER DEFAULT 0, + text_channel_id TEXT + )"""); + database.execute( + """ + CREATE TABLE IF NOT EXISTS voice_presets ( + user_id TEXT PRIMARY KEY, + name TEXT, + user_limit INTEGER DEFAULT 0, + bitrate INTEGER DEFAULT 64000, + region TEXT DEFAULT 'auto', + locked INTEGER DEFAULT 0, + allowed_role_id TEXT, + denied_role_id TEXT + )"""); + } + + static void createIndexes(final Database database) { + database.execute( + "CREATE INDEX IF NOT EXISTS idx_voice_channels_owner ON voice_channels(owner_id)"); + database.execute( + "CREATE INDEX IF NOT EXISTS idx_voice_channels_created ON voice_channels(created_at)"); + database.execute("CREATE INDEX IF NOT EXISTS idx_custom_roles_guild ON custom_roles(guild_id)"); + database.execute("CREATE INDEX IF NOT EXISTS idx_custom_roles_user ON custom_roles(user_id)"); + } +} diff --git a/src/main/java/creator/loki/feature/config/CounterChannelUpdater.java b/src/main/java/creator/loki/feature/config/CounterChannelUpdater.java new file mode 100644 index 0000000..1c72583 --- /dev/null +++ b/src/main/java/creator/loki/feature/config/CounterChannelUpdater.java @@ -0,0 +1,27 @@ +package creator.loki.feature.config; + +import creator.loki.feature.config.repository.ConfigRepository; +import net.dv8tion.jda.api.entities.Guild; + +final class CounterChannelUpdater { + private final ConfigRepository configRepo; + + CounterChannelUpdater(ConfigRepository configRepo) { + this.configRepo = configRepo; + } + + void update(final 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(); + } + } + } +} diff --git a/src/main/java/creator/loki/feature/config/LevelRoleConfig.java b/src/main/java/creator/loki/feature/config/LevelRoleConfig.java new file mode 100644 index 0000000..92b56c7 --- /dev/null +++ b/src/main/java/creator/loki/feature/config/LevelRoleConfig.java @@ -0,0 +1,20 @@ +package creator.loki.feature.config; + +import java.util.Map; +import java.util.TreeMap; + +final class LevelRoleConfig { + private static final String PREFIX = "level_role_"; + + private LevelRoleConfig() {} + + static Map byLevel(final Map all) { + Map result = new TreeMap<>(); + for (var entry : all.entrySet()) { + if (entry.getKey().startsWith(PREFIX)) { + result.put(Integer.parseInt(entry.getKey().substring(PREFIX.length())), entry.getValue()); + } + } + return result; + } +} diff --git a/src/main/java/creator/loki/feature/config/SetupDashboardBuilder.java b/src/main/java/creator/loki/feature/config/SetupDashboardBuilder.java index 26cd418..fa4cec6 100644 --- a/src/main/java/creator/loki/feature/config/SetupDashboardBuilder.java +++ b/src/main/java/creator/loki/feature/config/SetupDashboardBuilder.java @@ -2,19 +2,10 @@ package creator.loki.feature.config; import java.awt.Color; import java.util.Map; -import java.util.stream.Collectors; import creator.loki.feature.config.repository.ConfigRepository; import net.dv8tion.jda.api.EmbedBuilder; -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.StringSelectMenu; -import net.dv8tion.jda.api.components.textinput.TextInput; -import net.dv8tion.jda.api.components.textinput.TextInputStyle; import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; -import net.dv8tion.jda.api.modals.Modal; class SetupDashboardBuilder { private static final Color BLURPLE = new Color(88, 101, 242); @@ -90,91 +81,7 @@ class SetupDashboardBuilder { } private void addLevelRolesSection(final EmbedBuilder embed) { - Map allConfig = configRepo.getAll(); - long levelRoleCount = - allConfig.keySet().stream().filter(k -> k.startsWith("level_role_")).count(); - embed.addField("🏆 Роли за уровни", "Настроено ролей: **" + levelRoleCount + "**", false); - } - - void openCounterFormatModal(final 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(); - } - - void openLevelRoleAddModal(final 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(); - } - - void openLevelRoleRemoveSelect(final ButtonInteractionEvent event) { - Map 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()) { - 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(); + Map levelRoles = LevelRoleConfig.byLevel(configRepo.getAll()); + embed.addField("🏆 Роли за уровни", "Настроено ролей: **" + levelRoles.size() + "**", false); } } diff --git a/src/main/java/creator/loki/feature/config/SetupInteractions.java b/src/main/java/creator/loki/feature/config/SetupInteractions.java new file mode 100644 index 0000000..35b309a --- /dev/null +++ b/src/main/java/creator/loki/feature/config/SetupInteractions.java @@ -0,0 +1,72 @@ +package creator.loki.feature.config; + +import creator.loki.feature.config.repository.ConfigRepository; +import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.EntitySelectInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent; + +final class SetupInteractions { + private final ConfigRepository configRepo; + private final SetupPanelBuilder panelBuilder; + private final CounterChannelUpdater counterUpdater; + + SetupInteractions(ConfigRepository configRepo, SetupPanelBuilder panelBuilder) { + this.configRepo = configRepo; + this.panelBuilder = panelBuilder; + this.counterUpdater = new CounterChannelUpdater(configRepo); + } + + void onEntitySelect(final EntitySelectInteractionEvent event) { + String value = event.getValues().get(0).getId(); + switch (event.getComponentId()) { + case "setup_voice_trigger" -> { + configRepo.set("voice.trigger_channel_id", value); + panelBuilder.showVoicePanel(event.getGuild(), event.getHook()); + } + case "setup_voice_category" -> { + configRepo.set("voice.category_id", value); + panelBuilder.showVoicePanel(event.getGuild(), event.getHook()); + } + case "setup_counter_channel" -> { + configRepo.set("counter.channel_id", value); + counterUpdater.update(event.getGuild()); + panelBuilder.showCounterPanel(event.getGuild(), event.getHook()); + } + case "setup_logs_moderation" -> { + configRepo.set("moderation.log_channel_id", value); + panelBuilder.showLoggingPanel(event.getGuild(), event.getHook()); + } + case "setup_logs_levelup" -> { + configRepo.set("levelup.notification_channel_id", value); + panelBuilder.showLoggingPanel(event.getGuild(), event.getHook()); + } + default -> {} + } + } + + void onStringSelect(final StringSelectInteractionEvent event) { + if ("setup_levelrole_remove_select".equals(event.getComponentId())) { + String selected = event.getValues().get(0); + configRepo.delete("level_role_" + selected); + panelBuilder.showLevelRolesPanel(event.getGuild(), event.getHook()); + } + } + + void onModal(final ModalInteractionEvent event) { + switch (event.getModalId()) { + case "setup_counter_format_modal" -> { + String format = event.getValue("format").getAsString(); + configRepo.set("counter.format", format); + counterUpdater.update(event.getGuild()); + panelBuilder.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); + panelBuilder.showLevelRolesPanel(event.getGuild(), event.getHook()); + } + default -> {} + } + } +} diff --git a/src/main/java/creator/loki/feature/config/SetupModals.java b/src/main/java/creator/loki/feature/config/SetupModals.java new file mode 100644 index 0000000..42d4443 --- /dev/null +++ b/src/main/java/creator/loki/feature/config/SetupModals.java @@ -0,0 +1,85 @@ +package creator.loki.feature.config; + +import creator.loki.feature.config.repository.ConfigRepository; +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.StringSelectMenu; +import net.dv8tion.jda.api.components.textinput.TextInput; +import net.dv8tion.jda.api.components.textinput.TextInputStyle; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.modals.Modal; + +final class SetupModals { + private final ConfigRepository configRepo; + + SetupModals(ConfigRepository configRepo) { + this.configRepo = configRepo; + } + + void openCounterFormatModal(final 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(); + } + + void openLevelRoleAddModal(final 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(); + } + + void openLevelRoleRemoveSelect(final ButtonInteractionEvent event) { + var levelRoles = LevelRoleConfig.byLevel(configRepo.getAll()); + + if (levelRoles.isEmpty()) { + return; + } + + StringSelectMenu.Builder menuBuilder = + StringSelectMenu.create("setup_levelrole_remove_select") + .setPlaceholder("Выбери роль для удаления"); + + for (var entry : levelRoles.entrySet()) { + menuBuilder.addOption( + "Уровень " + entry.getKey() + " — " + entry.getValue(), entry.getKey().toString()); + } + + event + .getHook() + .editOriginalComponents( + ActionRow.of(menuBuilder.build()), + ActionRow.of(Button.secondary("setup_back", "🔙 Назад"))) + .queue(); + } +} diff --git a/src/main/java/creator/loki/feature/config/SetupPanelBuilder.java b/src/main/java/creator/loki/feature/config/SetupPanelBuilder.java index 2816993..0f3d5b0 100644 --- a/src/main/java/creator/loki/feature/config/SetupPanelBuilder.java +++ b/src/main/java/creator/loki/feature/config/SetupPanelBuilder.java @@ -3,7 +3,6 @@ package creator.loki.feature.config; import java.awt.Color; import java.util.ArrayList; import java.util.Map; -import java.util.stream.Collectors; import creator.loki.feature.config.repository.ConfigRepository; import net.dv8tion.jda.api.EmbedBuilder; @@ -115,14 +114,7 @@ class SetupPanelBuilder { } void showLevelRolesPanel(Guild guild, final InteractionHook hook) { - Map 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)); + Map levelRoles = LevelRoleConfig.byLevel(configRepo.getAll()); EmbedBuilder embed = new EmbedBuilder().setTitle("🏆 Роли за уровни").setColor(BLURPLE); @@ -130,15 +122,9 @@ class SetupPanelBuilder { 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")); + levelRoles.forEach( + (level, role) -> + sb.append("**Уровень ").append(level).append(":** `").append(role).append("`\n")); embed.setDescription(sb.toString()); } diff --git a/src/main/java/creator/loki/feature/config/SetupSlashHandler.java b/src/main/java/creator/loki/feature/config/SetupSlashHandler.java index 126dd6e..28cb8c7 100644 --- a/src/main/java/creator/loki/feature/config/SetupSlashHandler.java +++ b/src/main/java/creator/loki/feature/config/SetupSlashHandler.java @@ -5,11 +5,11 @@ import com.google.inject.Singleton; import creator.loki.feature.config.repository.ConfigRepository; import net.dv8tion.jda.api.entities.Guild; 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.events.interaction.ModalInteractionEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.dv8tion.jda.api.interactions.InteractionHook; import net.dv8tion.jda.api.interactions.commands.build.Commands; @@ -20,6 +20,8 @@ public class SetupSlashHandler extends ListenerAdapter { private final ConfigRepository configRepo; private final SetupPanelBuilder panelBuilder; private final SetupDashboardBuilder dashBuilder; + private final SetupModals modals; + private final SetupInteractions interactions; private volatile boolean registered = false; @Inject @@ -27,6 +29,8 @@ public class SetupSlashHandler extends ListenerAdapter { this.configRepo = configRepo; this.panelBuilder = new SetupPanelBuilder(configRepo); this.dashBuilder = new SetupDashboardBuilder(configRepo); + this.modals = new SetupModals(configRepo); + this.interactions = new SetupInteractions(configRepo, panelBuilder); } @Override @@ -63,89 +67,41 @@ public class SetupSlashHandler extends ListenerAdapter { case "setup_voice" -> panelBuilder.showVoicePanel(event.getGuild(), event.getHook()); case "setup_counter" -> panelBuilder.showCounterPanel(event.getGuild(), event.getHook()); case "setup_logs" -> panelBuilder.showLoggingPanel(event.getGuild(), event.getHook()); - case "setup_levelroles" -> panelBuilder.showLevelRolesPanel(event.getGuild(), event.getHook()); + case "setup_levelroles" -> + panelBuilder.showLevelRolesPanel(event.getGuild(), event.getHook()); case "setup_back" -> showDashboard(event.getGuild(), event.getHook()); - case "setup_counter_format" -> dashBuilder.openCounterFormatModal(event); - case "setup_levelrole_add" -> dashBuilder.openLevelRoleAddModal(event); - case "setup_levelrole_remove" -> dashBuilder.openLevelRoleRemoveSelect(event); + case "setup_counter_format" -> modals.openCounterFormatModal(event); + case "setup_levelrole_add" -> modals.openLevelRoleAddModal(event); + case "setup_levelrole_remove" -> modals.openLevelRoleRemoveSelect(event); default -> {} } } @Override public void onEntitySelectInteraction(@NotNull final EntitySelectInteractionEvent event) { - String id = event.getComponentId(); - if (!id.startsWith("setup_")) { + if (!event.getComponentId().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); - panelBuilder.showVoicePanel(event.getGuild(), event.getHook()); - } - case "setup_voice_category" -> { - configRepo.set("voice.category_id", value); - panelBuilder.showVoicePanel(event.getGuild(), event.getHook()); - } - case "setup_counter_channel" -> { - configRepo.set("counter.channel_id", value); - updateCounterChannelName(event.getGuild()); - panelBuilder.showCounterPanel(event.getGuild(), event.getHook()); - } - case "setup_logs_moderation" -> { - configRepo.set("moderation.log_channel_id", value); - panelBuilder.showLoggingPanel(event.getGuild(), event.getHook()); - } - case "setup_logs_levelup" -> { - configRepo.set("levelup.notification_channel_id", value); - panelBuilder.showLoggingPanel(event.getGuild(), event.getHook()); - } - default -> {} - } + interactions.onEntitySelect(event); } @Override public void onStringSelectInteraction(@NotNull final StringSelectInteractionEvent event) { - String id = event.getComponentId(); - if (!id.startsWith("setup_")) { + if (!event.getComponentId().startsWith("setup_")) { return; } event.deferEdit().queue(); - - if ("setup_levelrole_remove_select".equals(id)) { - String selected = event.getValues().get(0); - configRepo.delete("level_role_" + selected); - panelBuilder.showLevelRolesPanel(event.getGuild(), event.getHook()); - } + interactions.onStringSelect(event); } @Override public void onModalInteraction(@NotNull final ModalInteractionEvent event) { - String id = event.getModalId(); - if (!id.startsWith("setup_")) { + if (!event.getModalId().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()); - panelBuilder.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); - panelBuilder.showLevelRolesPanel(event.getGuild(), event.getHook()); - } - default -> {} - } + interactions.onModal(event); } private void showDashboard(Guild guild, final InteractionHook hook) { @@ -153,19 +109,4 @@ public class SetupSlashHandler extends ListenerAdapter { .setComponents(panelBuilder.buildDashboardButtons()) .queue(); } - - 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(); - } - } - } } diff --git a/src/main/java/creator/loki/feature/economy/command/bank/BankCommand.java b/src/main/java/creator/loki/feature/economy/command/bank/BankCommand.java index 8467098..f9a2a2d 100644 --- a/src/main/java/creator/loki/feature/economy/command/bank/BankCommand.java +++ b/src/main/java/creator/loki/feature/economy/command/bank/BankCommand.java @@ -89,8 +89,7 @@ public class BankCommand implements Command { if (balance < amount) { sendError( ctx, - "Недостаточно " + (isDep ? "наличных" : "в банке") - + "! У вас **" + balance + "** LC"); + "Недостаточно " + (isDep ? "наличных" : "в банке") + "! У вас **" + balance + "** LC"); return; } if (isDep) { @@ -102,8 +101,11 @@ public class BankCommand implements Command { .sendMessageEmbeds( EmbedTemplates.success( isDep ? "Депозит" : "Снятие", - "Ты " + (isDep ? "положил" : "снял") - + " **" + amount + "** LC " + "Ты " + + (isDep ? "положил" : "снял") + + " **" + + amount + + "** LC " + (isDep ? "в банк!" : "из банка!"))) .queue(); } @@ -123,8 +125,6 @@ public class BankCommand implements Command { } private void sendError(final CommandContext ctx, final String msg) { - ctx.getChannel() - .sendMessageEmbeds(EmbedTemplates.error("Ошибка", msg)) - .queue(); + ctx.getChannel().sendMessageEmbeds(EmbedTemplates.error("Ошибка", msg)).queue(); } } diff --git a/src/main/java/creator/loki/feature/economy/command/job/JobCommand.java b/src/main/java/creator/loki/feature/economy/command/job/JobCommand.java index 4e87c8d..1ed57a0 100644 --- a/src/main/java/creator/loki/feature/economy/command/job/JobCommand.java +++ b/src/main/java/creator/loki/feature/economy/command/job/JobCommand.java @@ -1,11 +1,8 @@ package creator.loki.feature.economy.command.job; -import java.awt.Color; - import creator.loki.command.Command; import creator.loki.command.CommandCategories; import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; import creator.loki.embed.EmbedTemplates; import creator.loki.feature.economy.service.game.JobService; @@ -78,7 +75,7 @@ public class JobCommand implements Command { return; } - JobService.JobInfo info = findJob(current); + JobService.JobInfo info = JobEmbeds.findJob(jobService.listJobs(), current); if (info == null) { context .getChannel() @@ -88,59 +85,15 @@ public class JobCommand implements Command { return; } - context - .getChannel() - .sendMessageEmbeds( - EmbedFactory.create() - .withTitle("💼 Твоя работа") - .withColor(new Color(100, 200, 255)) - .addField("Должность", info.emoji() + " " + info.displayName(), false) - .addField("Зарплата", info.minPayout() + " – " + info.maxPayout() + " LC", true) - .addField("Требуемый уровень", String.valueOf(info.requiredLevel()), true) - .build()) - .queue(); + context.getChannel().sendMessageEmbeds(JobEmbeds.current(info)).queue(); } private void handleList(final CommandContext context) { - var jobs = jobService.listJobs(); - var desc = new StringBuilder(); - - for (var job : jobs) { - desc.append(job.emoji()) - .append(" **") - .append(job.displayName()) - .append("** — ") - .append(job.minPayout()) - .append("–") - .append(job.maxPayout()) - .append(" LC (ур. ") - .append(job.requiredLevel()) - .append(")\n"); - } - - context - .getChannel() - .sendMessageEmbeds( - EmbedFactory.create() - .withTitle("📋 Доступные работы") - .withDescription(desc.toString()) - .withColor(new Color(100, 200, 255)) - .withFooter("!job join <название> — устроиться на работу") - .build()) - .queue(); + context.getChannel().sendMessageEmbeds(JobEmbeds.list(jobService.listJobs())).queue(); } private void handleJoin(final CommandContext context, final String userId, final String name) { - var jobs = jobService.listJobs(); - JobService.JobInfo target = null; - - for (var job : jobs) { - if (job.name().equalsIgnoreCase(name) || job.displayName().equalsIgnoreCase(name)) { - target = job; - break; - } - } - + JobService.JobInfo target = JobEmbeds.findJob(jobService.listJobs(), name); if (target == null) { context .getChannel() @@ -166,13 +119,4 @@ public class JobCommand implements Command { + "Используй `!work` чтобы начать работать.")) .queue(); } - - private JobService.JobInfo findJob(final String name) { - for (var job : jobService.listJobs()) { - if (job.name().equalsIgnoreCase(name) || job.displayName().equalsIgnoreCase(name)) { - return job; - } - } - return null; - } } diff --git a/src/main/java/creator/loki/feature/economy/command/job/JobEmbeds.java b/src/main/java/creator/loki/feature/economy/command/job/JobEmbeds.java new file mode 100644 index 0000000..a406016 --- /dev/null +++ b/src/main/java/creator/loki/feature/economy/command/job/JobEmbeds.java @@ -0,0 +1,56 @@ +package creator.loki.feature.economy.command.job; + +import java.awt.Color; +import java.util.List; + +import creator.loki.embed.EmbedFactory; +import creator.loki.feature.economy.service.game.JobService; +import net.dv8tion.jda.api.entities.MessageEmbed; + +/** Построение embed-сообщений для команды работы. */ +public final class JobEmbeds { + private static final Color COLOR = new Color(100, 200, 255); + + private JobEmbeds() {} + + public static MessageEmbed current(final JobService.JobInfo info) { + return EmbedFactory.create() + .withTitle("💼 Твоя работа") + .withColor(COLOR) + .addField("Должность", info.emoji() + " " + info.displayName(), false) + .addField("Зарплата", info.minPayout() + " – " + info.maxPayout() + " LC", true) + .addField("Требуемый уровень", String.valueOf(info.requiredLevel()), true) + .build(); + } + + public static MessageEmbed list(final List jobs) { + var desc = new StringBuilder(); + for (var job : jobs) { + desc.append(job.emoji()) + .append(" **") + .append(job.displayName()) + .append("** — ") + .append(job.minPayout()) + .append("–") + .append(job.maxPayout()) + .append(" LC (ур. ") + .append(job.requiredLevel()) + .append(")\n"); + } + return EmbedFactory.create() + .withTitle("📋 Доступные работы") + .withDescription(desc.toString()) + .withColor(COLOR) + .withFooter("!job join <название> — устроиться на работу") + .build(); + } + + public static JobService.JobInfo findJob(final List jobs, final String name) { + for (var job : jobs) { + if (job.name().equalsIgnoreCase(name) || job.displayName().equalsIgnoreCase(name)) { + return job; + } + } + return null; + } +} diff --git a/src/main/java/creator/loki/feature/economy/command/minigame/BlackjackCommand.java b/src/main/java/creator/loki/feature/economy/command/minigame/BlackjackCommand.java index 594e487..0d34f16 100644 --- a/src/main/java/creator/loki/feature/economy/command/minigame/BlackjackCommand.java +++ b/src/main/java/creator/loki/feature/economy/command/minigame/BlackjackCommand.java @@ -1,17 +1,14 @@ package creator.loki.feature.economy.command.minigame; -import java.awt.Color; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import creator.loki.command.Command; import creator.loki.command.CommandCategories; import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; import creator.loki.embed.EmbedTemplates; import creator.loki.feature.economy.service.EconomyService; import creator.loki.feature.economy.service.game.BlackjackService; -import net.dv8tion.jda.api.entities.MessageEmbed; public class BlackjackCommand implements Command { @@ -111,7 +108,7 @@ public class BlackjackCommand implements Command { if (!state.isOver()) { activeGames.add(userId); } - context.getChannel().sendMessageEmbeds(buildGameEmbed(state)).queue(); + context.getChannel().sendMessageEmbeds(BlackjackEmbeds.game(state)).queue(); } private void handleAction( @@ -129,50 +126,6 @@ public class BlackjackCommand implements Command { if (state.isOver()) { activeGames.remove(userId); } - context.getChannel().sendMessageEmbeds(buildGameEmbed(state)).queue(); - } - - private MessageEmbed buildGameEmbed(final BlackjackService.BlackjackState state) { - var embed = - EmbedFactory.create() - .withTitle("🃏 Блэкджек") - .withColor(new Color(34, 139, 34)) - .addField("Твои карты", formatCards(state.playerCards(), state.playerSum()), true); - if (state.isOver()) { - embed - .addField("Карты дилера", formatCards(state.dealerCards(), state.dealerSum()), true) - .addField("Результат", formatResult(state), false); - } else { - embed - .addField("Карты дилера", formatHiddenDealerCards(state.dealerCards()), true) - .withFooter("!bj hit — взять карту | !bj stand — остановиться"); - } - return embed.addField("Ставка", state.bet() + " LC", false).build(); - } - - private String formatCards(final String[] cards, final int sum) { - return String.join(" + ", cards) + " = " + sum; - } - - private String formatHiddenDealerCards(final String[] cards) { - var sb = new StringBuilder("?"); - for (int i = 1; i < cards.length; i++) { - sb.append(" + ").append(cards[i]); - } - return sb.toString(); - } - - private String formatResult(final BlackjackService.BlackjackState state) { - long payout = state.payout(); - if (payout > state.bet()) { - return "🎉 Блэкджек! +" + payout + " LC"; - } - if (payout > 0) { - return "✅ Ты выиграл +" + payout + " LC"; - } - if (payout == state.bet()) { - return "🤝 Ничья. Возврат " + payout + " LC"; - } - return "💔 Ты проиграл " + state.bet() + " LC"; + context.getChannel().sendMessageEmbeds(BlackjackEmbeds.game(state)).queue(); } } diff --git a/src/main/java/creator/loki/feature/economy/command/minigame/BlackjackEmbeds.java b/src/main/java/creator/loki/feature/economy/command/minigame/BlackjackEmbeds.java new file mode 100644 index 0000000..4ab8193 --- /dev/null +++ b/src/main/java/creator/loki/feature/economy/command/minigame/BlackjackEmbeds.java @@ -0,0 +1,56 @@ +package creator.loki.feature.economy.command.minigame; + +import java.awt.Color; + +import creator.loki.embed.EmbedFactory; +import creator.loki.feature.economy.service.game.BlackjackService; +import net.dv8tion.jda.api.entities.MessageEmbed; + +/** Построение embed-сообщений для игры в блэкджек. */ +public final class BlackjackEmbeds { + private BlackjackEmbeds() {} + + public static MessageEmbed game(final BlackjackService.BlackjackState state) { + var embed = + EmbedFactory.create() + .withTitle("🃏 Блэкджек") + .withColor(new Color(34, 139, 34)) + .addField("Твои карты", formatCards(state.playerCards(), state.playerSum()), true); + if (state.isOver()) { + embed + .addField("Карты дилера", formatCards(state.dealerCards(), state.dealerSum()), true) + .addField("Результат", formatResult(state), false); + } else { + embed + .addField("Карты дилера", formatHiddenDealerCards(state.dealerCards()), true) + .withFooter("!bj hit — взять карту | !bj stand — остановиться"); + } + return embed.addField("Ставка", state.bet() + " LC", false).build(); + } + + private static String formatCards(final String[] cards, final int sum) { + return String.join(" + ", cards) + " = " + sum; + } + + private static String formatHiddenDealerCards(final String[] cards) { + var sb = new StringBuilder("?"); + for (int i = 1; i < cards.length; i++) { + sb.append(" + ").append(cards[i]); + } + return sb.toString(); + } + + private static String formatResult(final BlackjackService.BlackjackState state) { + long payout = state.payout(); + if (payout > state.bet()) { + return "🎉 Блэкджек! +" + payout + " LC"; + } + if (payout > 0) { + return "✅ Ты выиграл +" + payout + " LC"; + } + if (payout == state.bet()) { + return "🤝 Ничья. Возврат " + payout + " LC"; + } + return "💔 Ты проиграл " + state.bet() + " LC"; + } +} diff --git a/src/main/java/creator/loki/feature/economy/repository/EconomyRowMapper.java b/src/main/java/creator/loki/feature/economy/repository/EconomyRowMapper.java new file mode 100644 index 0000000..d6eab6d --- /dev/null +++ b/src/main/java/creator/loki/feature/economy/repository/EconomyRowMapper.java @@ -0,0 +1,29 @@ +package creator.loki.feature.economy.repository; + +import java.sql.ResultSet; +import java.sql.SQLException; + +import creator.loki.feature.economy.domain.EconomyAccount; +import creator.loki.feature.economy.domain.EconomyAccountBuilder; + +/** Маппинг строки economy-таблицы в {@link EconomyAccount}. */ +public final class EconomyRowMapper { + private EconomyRowMapper() {} + + public static EconomyAccount map(final ResultSet rs) throws SQLException { + return new EconomyAccountBuilder() + .userId(rs.getString("user_id")) + .cash(rs.getLong("cash")) + .bank(rs.getLong("bank")) + .totalEarned(rs.getLong("total_earned")) + .totalSpent(rs.getLong("total_spent")) + .lastDaily(rs.getString("last_daily")) + .dailyStreak(rs.getInt("daily_streak")) + .fishingLuck(rs.getInt("fishing_luck")) + .efficiency(rs.getInt("efficiency")) + .lastFish(rs.getString("last_fish")) + .job(rs.getString("job")) + .lastWork(rs.getString("last_work")) + .build(); + } +} diff --git a/src/main/java/creator/loki/feature/economy/repository/EconomySql.java b/src/main/java/creator/loki/feature/economy/repository/EconomySql.java new file mode 100644 index 0000000..0d030a6 --- /dev/null +++ b/src/main/java/creator/loki/feature/economy/repository/EconomySql.java @@ -0,0 +1,55 @@ +package creator.loki.feature.economy.repository; + +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.sql.Types; + +/** SQL-запросы и низкоуровневые помощники для economy-таблицы. */ +public final class EconomySql { + public static final int MAX_TOP = 100; + + public static final String COLUMNS = + "user_id, cash, bank, total_earned, total_spent," + + " last_daily, daily_streak, fishing_luck," + + " efficiency, last_fish, job, last_work"; + + private EconomySql() {} + + public static String selectSql() { + return "SELECT " + COLUMNS + " FROM economy WHERE user_id = ?"; + } + + public static String upsertSql() { + return "INSERT INTO economy (" + + COLUMNS + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT(user_id) DO UPDATE SET " + + "cash = excluded.cash, bank = excluded.bank, total_earned = excluded.total_earned, " + + "total_spent = excluded.total_spent, last_daily = excluded.last_daily, " + + "daily_streak = excluded.daily_streak, fishing_luck = excluded.fishing_luck, " + + "efficiency = excluded.efficiency, last_fish = excluded.last_fish, " + + "job = excluded.job, last_work = excluded.last_work"; + } + + public static String topByBalanceSql() { + return "SELECT " + + COLUMNS + + " FROM economy ORDER BY (cash + bank) DESC, cash DESC, user_id ASC LIMIT ?"; + } + + public static void setNullableString( + final PreparedStatement stmt, final int index, final String value) throws SQLException { + if (value != null) { + stmt.setString(index, value); + } else { + stmt.setNull(index, Types.VARCHAR); + } + } + + public static int clampLimit(final int limit) { + if (limit <= 0) { + return 1; + } + return Math.min(limit, MAX_TOP); + } +} diff --git a/src/main/java/creator/loki/feature/economy/repository/SqlEconomyRepository.java b/src/main/java/creator/loki/feature/economy/repository/SqlEconomyRepository.java index 1e29520..b8154ed 100644 --- a/src/main/java/creator/loki/feature/economy/repository/SqlEconomyRepository.java +++ b/src/main/java/creator/loki/feature/economy/repository/SqlEconomyRepository.java @@ -1,9 +1,6 @@ package creator.loki.feature.economy.repository; -import java.sql.PreparedStatement; -import java.sql.ResultSet; import java.sql.SQLException; -import java.sql.Types; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -11,57 +8,25 @@ import java.util.Optional; import creator.loki.database.Database; import creator.loki.feature.economy.domain.EconomyAccount; -import creator.loki.feature.economy.domain.EconomyAccountBuilder; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class SqlEconomyRepository implements EconomyRepository { private static final Logger logger = LoggerFactory.getLogger(SqlEconomyRepository.class); - private static final int MAX_TOP = 100; - private final Database database; - private static final String COLUMNS = - "user_id, cash, bank, total_earned, total_spent," - + " last_daily, daily_streak, fishing_luck," - + " efficiency, last_fish, job, last_work"; + private final Database database; public SqlEconomyRepository(Database database) { this.database = database; } - private static String selectSql() { - return "SELECT " + COLUMNS + " FROM economy WHERE user_id = ?"; - } - - private static String upsertSql() { - return "INSERT INTO economy (" - + COLUMNS - + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " - + "ON CONFLICT(user_id) DO UPDATE SET " - + "cash = excluded.cash, bank = excluded.bank, total_earned = excluded.total_earned, " - + "total_spent = excluded.total_spent, last_daily = excluded.last_daily, " - + "daily_streak = excluded.daily_streak, fishing_luck = excluded.fishing_luck, " - + "efficiency = excluded.efficiency, last_fish = excluded.last_fish, " - + "job = excluded.job, last_work = excluded.last_work"; - } - - private static void setNullableString(PreparedStatement stmt, final int index, final String value) - throws SQLException { - if (value != null) { - stmt.setString(index, value); - } else { - stmt.setNull(index, Types.VARCHAR); - } - } - @Override public Optional findById(final String userId) { - var sql = selectSql(); - try (var stmt = database.prepare(sql)) { + try (var stmt = database.prepare(EconomySql.selectSql())) { stmt.setString(1, userId); try (var rs = stmt.executeQuery()) { if (rs.next()) { - return Optional.of(mapResultSetToAccount(rs)); + return Optional.of(EconomyRowMapper.map(rs)); } } } catch (SQLException e) { @@ -72,20 +37,19 @@ public class SqlEconomyRepository implements EconomyRepository { @Override public void save(final EconomyAccount account) { - var sql = upsertSql(); - try (var stmt = database.prepare(sql)) { + try (var stmt = database.prepare(EconomySql.upsertSql())) { stmt.setString(1, account.getUserId()); stmt.setLong(2, account.getCash()); stmt.setLong(3, account.getBank()); stmt.setLong(4, account.getTotalEarned()); stmt.setLong(5, account.getTotalSpent()); - setNullableString(stmt, 6, account.getLastDaily()); + EconomySql.setNullableString(stmt, 6, account.getLastDaily()); stmt.setInt(7, account.getDailyStreak()); stmt.setInt(8, account.getFishingLuck()); stmt.setInt(9, account.getEfficiency()); - setNullableString(stmt, 10, account.getLastFish()); - setNullableString(stmt, 11, account.getJob()); - setNullableString(stmt, 12, account.getLastWork()); + EconomySql.setNullableString(stmt, 10, account.getLastFish()); + EconomySql.setNullableString(stmt, 11, account.getJob()); + EconomySql.setNullableString(stmt, 12, account.getLastWork()); stmt.executeUpdate(); } catch (SQLException e) { logger.error("Error saving economy account for {}", account.getUserId(), e); @@ -94,8 +58,7 @@ public class SqlEconomyRepository implements EconomyRepository { @Override public boolean exists(final String userId) { - var sql = "SELECT 1 FROM economy WHERE user_id = ?"; - try (var stmt = database.prepare(sql)) { + try (var stmt = database.prepare("SELECT 1 FROM economy WHERE user_id = ?")) { stmt.setString(1, userId); try (var rs = stmt.executeQuery()) { return rs.next(); @@ -108,17 +71,13 @@ public class SqlEconomyRepository implements EconomyRepository { @Override public List findTopByBalance(final int limit) { - var lim = clampLimit(limit); - var sql = - "SELECT " - + COLUMNS - + " FROM economy ORDER BY (cash + bank) DESC, cash DESC, user_id ASC LIMIT ?"; + var lim = EconomySql.clampLimit(limit); var list = new ArrayList(); - try (var stmt = database.prepare(sql)) { + try (var stmt = database.prepare(EconomySql.topByBalanceSql())) { stmt.setInt(1, lim); try (var rs = stmt.executeQuery()) { while (rs.next()) { - list.add(mapResultSetToAccount(rs)); + list.add(EconomyRowMapper.map(rs)); } } } catch (SQLException e) { @@ -126,28 +85,4 @@ public class SqlEconomyRepository implements EconomyRepository { } return Collections.unmodifiableList(list); } - - private static int clampLimit(final int limit) { - if (limit <= 0) { - return 1; - } - return Math.min(limit, MAX_TOP); - } - - private EconomyAccount mapResultSetToAccount(final ResultSet rs) throws SQLException { - return new EconomyAccountBuilder() - .userId(rs.getString("user_id")) - .cash(rs.getLong("cash")) - .bank(rs.getLong("bank")) - .totalEarned(rs.getLong("total_earned")) - .totalSpent(rs.getLong("total_spent")) - .lastDaily(rs.getString("last_daily")) - .dailyStreak(rs.getInt("daily_streak")) - .fishingLuck(rs.getInt("fishing_luck")) - .efficiency(rs.getInt("efficiency")) - .lastFish(rs.getString("last_fish")) - .job(rs.getString("job")) - .lastWork(rs.getString("last_work")) - .build(); - } } diff --git a/src/main/java/creator/loki/feature/economy/service/FishingBoard.java b/src/main/java/creator/loki/feature/economy/service/FishingBoard.java new file mode 100644 index 0000000..3a378a9 --- /dev/null +++ b/src/main/java/creator/loki/feature/economy/service/FishingBoard.java @@ -0,0 +1,60 @@ +package creator.loki.feature.economy.service; + +import java.util.Arrays; +import java.util.concurrent.ThreadLocalRandom; + +/** Игровое поле рыбалки: сетка, целевая рыба и количество совпадений. */ +public class FishingBoard { + private static final String[] FISH = { + "\uD83D\uDC1F", "\uD83D\uDC20", "\uD83D\uDC21", "\uD83D\uDC19" + }; + private static final String WAVE = "\uD83C\uDF0A"; + + private final String[] rows; + private final String targetFish; + private final int correctCount; + + public FishingBoard(int size, int fishTypes) { + String[] available = Arrays.copyOf(FISH, fishTypes); + String[][] grid = new String[size][size]; + int[] counts = new int[fishTypes]; + var rng = ThreadLocalRandom.current(); + + for (int r = 0; r < size; r++) { + for (int c = 0; c < size; c++) { + if (rng.nextInt(100) < 40) { + grid[r][c] = WAVE; + } else { + int fi = rng.nextInt(fishTypes); + grid[r][c] = available[fi]; + counts[fi]++; + } + } + } + + int targetIdx; + do { + targetIdx = rng.nextInt(fishTypes); + } while (counts[targetIdx] == 0); + + String[] rowLines = new String[size]; + for (int r = 0; r < size; r++) { + rowLines[r] = String.join(" ", grid[r]); + } + this.rows = rowLines; + this.targetFish = available[targetIdx]; + this.correctCount = counts[targetIdx]; + } + + public String[] getRows() { + return rows; + } + + public String getTargetFish() { + return targetFish; + } + + public int getCorrectCount() { + return correctCount; + } +} diff --git a/src/main/java/creator/loki/feature/economy/service/FishingDifficulty.java b/src/main/java/creator/loki/feature/economy/service/FishingDifficulty.java new file mode 100644 index 0000000..25825db --- /dev/null +++ b/src/main/java/creator/loki/feature/economy/service/FishingDifficulty.java @@ -0,0 +1,51 @@ +package creator.loki.feature.economy.service; + +/** Правила сложности рыбалки. */ +public enum FishingDifficulty { + EASY(5, 2, 15, 30000), + NORMAL(7, 3, 30, 45000), + HARD(9, 4, 50, 60000); + + private final int size; + private final int fishTypes; + private final int baseReward; + private final long timeLimitMs; + + FishingDifficulty(int size, int fishTypes, int baseReward, long timeLimitMs) { + this.size = size; + this.fishTypes = fishTypes; + this.baseReward = baseReward; + this.timeLimitMs = timeLimitMs; + } + + public int getSize() { + return size; + } + + public int getFishTypes() { + return fishTypes; + } + + public long getTimeLimitMs() { + return timeLimitMs; + } + + public long computeReward(final long elapsed, final int luck) { + double ratio = (double) elapsed / timeLimitMs; + double speedMult = ratio < 0.3 ? 2.0 : ratio < 0.5 ? 1.5 : 1.0; + return (long) (baseReward * speedMult * (1 + luck * 0.01)); + } + + public static FishingDifficulty from(final String difficulty) { + switch (difficulty.toLowerCase()) { + case "easy": + return EASY; + case "normal": + return NORMAL; + case "hard": + return HARD; + default: + return null; + } + } +} diff --git a/src/main/java/creator/loki/feature/economy/service/FishingService.java b/src/main/java/creator/loki/feature/economy/service/FishingService.java index 931115d..8c32562 100644 --- a/src/main/java/creator/loki/feature/economy/service/FishingService.java +++ b/src/main/java/creator/loki/feature/economy/service/FishingService.java @@ -1,23 +1,13 @@ package creator.loki.feature.economy.service; -import java.util.Arrays; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ThreadLocalRandom; import creator.loki.feature.economy.domain.EconomyAccount; import creator.loki.feature.economy.domain.EconomyAccountBuilder; import creator.loki.feature.economy.repository.EconomyRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; public class FishingService { - private static final Logger logger = LoggerFactory.getLogger(FishingService.class); - - private static final String[] FISH = { - "\uD83D\uDC1F", "\uD83D\uDC20", "\uD83D\uDC21", "\uD83D\uDC19" - }; - private static final String WAVE = "\uD83C\uDF0A"; private static final long COOLDOWN_MS = 7200000; private final EconomyRepository repository; @@ -39,65 +29,23 @@ public class FishingService { } } - int size; - int fishTypes; - int baseReward; - switch (difficulty.toLowerCase()) { - case "easy" -> { - size = 5; - fishTypes = 2; - baseReward = 15; - } - case "normal" -> { - size = 7; - fishTypes = 3; - baseReward = 30; - } - case "hard" -> { - size = 9; - fishTypes = 4; - baseReward = 50; - } - default -> { - return null; - } - } - - String[] available = Arrays.copyOf(FISH, fishTypes); - String[][] grid = new String[size][size]; - int[] counts = new int[fishTypes]; - var rng = ThreadLocalRandom.current(); - - for (int r = 0; r < size; r++) { - for (int c = 0; c < size; c++) { - if (rng.nextInt(100) < 40) { - grid[r][c] = WAVE; - } else { - int fi = rng.nextInt(fishTypes); - grid[r][c] = available[fi]; - counts[fi]++; - } - } - } - - int targetIdx; - do { - targetIdx = rng.nextInt(fishTypes); - } while (counts[targetIdx] == 0); - - String targetFish = available[targetIdx]; - int correctCount = counts[targetIdx]; - - String[] rows = new String[size]; - for (int r = 0; r < size; r++) { - rows[r] = String.join(" ", grid[r]); + FishingDifficulty diff = FishingDifficulty.from(difficulty); + if (diff == null) { + return null; } + FishingBoard board = new FishingBoard(diff.getSize(), diff.getFishTypes()); ActiveFish session = - new ActiveFish(targetFish, correctCount, System.currentTimeMillis(), difficulty, rows); + new ActiveFish( + board.getTargetFish(), + board.getCorrectCount(), + System.currentTimeMillis(), + difficulty, + board.getRows()); activeFishing.put(userId, session); - return new FishResult(rows, targetFish, correctCount, 0, account.getCash()); + return new FishResult( + board.getRows(), board.getTargetFish(), board.getCorrectCount(), 0, account.getCash()); } public FishResult checkAnswer(final String userId, int answer) { @@ -118,34 +66,17 @@ public class FishingService { return null; } + FishingDifficulty diff = FishingDifficulty.from(difficulty); + if (diff == null) { + return null; + } + EconomyAccount account = repository.getAccount(userId); long elapsed = System.currentTimeMillis() - session.startTime(); - int baseReward; - long timeLimit; - switch (difficulty.toLowerCase()) { - case "easy" -> { - baseReward = 15; - timeLimit = 30000; - } - case "normal" -> { - baseReward = 30; - timeLimit = 45000; - } - case "hard" -> { - baseReward = 50; - timeLimit = 60000; - } - default -> { - return null; - } - } - long reward = 0; - if (answer == session.correctCount() && elapsed <= timeLimit) { - double ratio = (double) elapsed / timeLimit; - double speedMult = ratio < 0.3 ? 2.0 : ratio < 0.5 ? 1.5 : 1.0; - reward = (long) (baseReward * speedMult * (1 + account.getFishingLuck() * 0.01)); + if (answer == session.correctCount() && elapsed <= diff.getTimeLimitMs()) { + reward = diff.computeReward(elapsed, account.getFishingLuck()); } long newCash = account.getCash() + reward; diff --git a/src/main/java/creator/loki/feature/entertainment/commands/GlazbobaCommand.java b/src/main/java/creator/loki/feature/entertainment/commands/GlazbobaCommand.java index f3f6300..5e84d57 100644 --- a/src/main/java/creator/loki/feature/entertainment/commands/GlazbobaCommand.java +++ b/src/main/java/creator/loki/feature/entertainment/commands/GlazbobaCommand.java @@ -54,9 +54,7 @@ public class GlazbobaCommand implements Command { Thread.sleep(1000); loadingMsg.editMessage("🔐 Взламываю Discord...").queue(); Thread.sleep(1000); - loadingMsg - .editMessageEmbeds(buildProfileEmbed(target, context).build()) - .queue(); + loadingMsg.editMessageEmbeds(buildProfileEmbed(target, context).build()).queue(); } catch (InterruptedException e) { loadingMsg.editMessage("❌ Ошибка загрузки").queue(); } @@ -90,9 +88,7 @@ public class GlazbobaCommand implements Command { .addField("💎 Бустер", isBoosting ? "Да" : "Нет", true) .addField("🎭 Роли", roles, false) .withColor( - target.getColors().getPrimary() != null - ? target.getColors().getPrimary() - : Color.BLUE) + target.getColors().getPrimary() != null ? target.getColors().getPrimary() : Color.BLUE) .withFooter("Запросил: " + context.getAuthor().getUser().getName()); } diff --git a/src/main/java/creator/loki/feature/help/HelpCategoryEmbed.java b/src/main/java/creator/loki/feature/help/HelpCategoryEmbed.java new file mode 100644 index 0000000..401d8a2 --- /dev/null +++ b/src/main/java/creator/loki/feature/help/HelpCategoryEmbed.java @@ -0,0 +1,59 @@ +package creator.loki.feature.help; + +import java.util.List; + +import creator.loki.command.Command; +import creator.loki.config.BotConfig; +import creator.loki.embed.EmbedFactory; +import creator.loki.embed.HelpCategories; +import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent; + +/** Embed для списка команд выбранной категории. */ +public final class HelpCategoryEmbed { + private HelpCategoryEmbed() {} + + public static EmbedFactory build( + final String category, + final List commands, + final StringSelectInteractionEvent event, + final BotConfig config) { + String emoji = HelpCategories.getEmoji(category); + String prefix = config.getPrefix(); + + EmbedFactory embed = + EmbedFactory.create() + .withTitle(emoji + " " + category) + .withDescription( + "Список команд категории **" + category + "**\n" + "Префикс: `" + prefix + "`") + .withColor(HelpCategories.getColor(category)); + + StringBuilder commandList = new StringBuilder(); + int count = 0; + for (Command cmd : commands) { + commandList + .append("`") + .append(prefix) + .append(cmd.getUsage()) + .append("`\n") + .append(cmd.getDescription()) + .append("\n\n"); + count++; + if (count % 8 == 0 || commandList.length() > 900) { + embed.addField("", commandList.toString().trim(), false); + commandList = new StringBuilder(); + } + } + if (commandList.length() > 0) { + embed.addField("", commandList.toString().trim(), false); + } + + embed.addField( + "💡 Подсказка", "Используй селектор ниже для переключения между категориями", false); + + embed.withFooter( + "Запросил: " + event.getUser().getName() + " • Всего команд: " + commands.size(), + event.getUser().getAvatarUrl()); + + return embed; + } +} diff --git a/src/main/java/creator/loki/feature/help/HelpMainPage.java b/src/main/java/creator/loki/feature/help/HelpMainPage.java new file mode 100644 index 0000000..3f6fca3 --- /dev/null +++ b/src/main/java/creator/loki/feature/help/HelpMainPage.java @@ -0,0 +1,62 @@ +package creator.loki.feature.help; + +import java.awt.Color; +import java.util.List; +import java.util.Map; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.embed.EmbedFactory; + +/** Главная страница справки. */ +public class HelpMainPage { + private HelpMainPage() {} + + public static EmbedFactory build( + final CommandContext context, + final Map> commandsByCategory, + final HelpMenuBuilder menuBuilder, + final boolean isAdmin, + final boolean isSuper, + final boolean isNsfwChannel) { + int totalCommands = + menuBuilder.countVisibleCommands(commandsByCategory, isAdmin, isSuper, isNsfwChannel); + String categoriesText = + menuBuilder.buildCategoriesText(commandsByCategory, isAdmin, isSuper, isNsfwChannel); + String prefix = menuBuilder.getPrefix(); + + return EmbedFactory.create() + .withTitle("📚 Справка по командам Loki Bot") + .withDescription( + "Привет! Я многофункциональный Discord бот с системой уровней, " + + "личными голосовыми каналами и множеством развлекательных команд.\n\n" + + "**Префикс команд:** `" + + prefix + + "`\n" + + "**Всего команд:** " + + totalCommands + + "\n\n" + + "**📂 Категории команд:**\n" + + categoriesText + + "\n" + + "Используй селектор ниже для просмотра команд категории.") + .withColor(new Color(88, 101, 242)) + .withThumbnail(context.getGuild() != null ? context.getGuild().getIconUrl() : null) + .addField( + "💡 Как использовать", + "Все команды - с префиксом `" + + prefix + + "`\n" + + "Примеры: `" + + prefix + + "rank`, `" + + prefix + + "ship @user1 @user2`, `" + + prefix + + "voice-setup info`", + false) + .withFooter( + "Запросил: " + context.getAuthor().getUser().getName(), + context.getAuthor().getUser().getAvatarUrl()); + } +} diff --git a/src/main/java/creator/loki/feature/help/HelpMenuBuilder.java b/src/main/java/creator/loki/feature/help/HelpMenuBuilder.java new file mode 100644 index 0000000..e208f4f --- /dev/null +++ b/src/main/java/creator/loki/feature/help/HelpMenuBuilder.java @@ -0,0 +1,91 @@ +package creator.loki.feature.help; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import creator.loki.command.Command; +import creator.loki.config.BotConfig; +import creator.loki.core.registry.CommandRegistry; +import creator.loki.embed.HelpCategories; +import net.dv8tion.jda.api.components.selections.StringSelectMenu; +import net.dv8tion.jda.api.entities.emoji.Emoji; + +/** Построение селектора категорий и группировка команд для справки. */ +public class HelpMenuBuilder { + private final CommandRegistry registry; + private final BotConfig config; + + public HelpMenuBuilder(CommandRegistry registry, BotConfig config) { + this.registry = registry; + this.config = config; + } + + public Map> groupByCategory() { + return registry.getAllCommands().stream().collect(Collectors.groupingBy(Command::getCategory)); + } + + public StringSelectMenu buildSelector( + final Map> commandsByCategory, + final boolean isAdmin, + final boolean isSuper, + final boolean isNsfwChannel) { + StringSelectMenu.Builder menuBuilder = + StringSelectMenu.create("help:category").setPlaceholder("📂 Выберите категорию"); + commandsByCategory.keySet().stream() + .sorted() + .forEach( + category -> { + if (!HelpCategories.isCategoryVisible(category, isAdmin, isSuper, isNsfwChannel)) { + return; + } + String emoji = HelpCategories.getEmoji(category); + String description = HelpCategories.getDescription(category); + int count = commandsByCategory.get(category).size(); + menuBuilder.addOption( + category, + "category:" + category, + description + " (" + count + " команд)", + Emoji.fromUnicode(emoji)); + }); + return menuBuilder.build(); + } + + public int countVisibleCommands( + final Map> commandsByCategory, + final boolean isAdmin, + final boolean isSuper, + final boolean isNsfwChannel) { + return commandsByCategory.entrySet().stream() + .filter(e -> HelpCategories.isCategoryVisible(e.getKey(), isAdmin, isSuper, isNsfwChannel)) + .mapToInt(e -> e.getValue().size()) + .sum(); + } + + public String buildCategoriesText( + final Map> commandsByCategory, + final boolean isAdmin, + final boolean isSuper, + final boolean isNsfwChannel) { + StringBuilder text = new StringBuilder(); + commandsByCategory.keySet().stream() + .sorted() + .forEach( + category -> { + if (!HelpCategories.isCategoryVisible(category, isAdmin, isSuper, isNsfwChannel)) { + return; + } + text.append(HelpCategories.getEmoji(category)) + .append(" **") + .append(category) + .append("** - ") + .append(commandsByCategory.get(category).size()) + .append(" команд\n"); + }); + return text.toString(); + } + + public String getPrefix() { + return config.getPrefix(); + } +} diff --git a/src/main/java/creator/loki/feature/help/HelpMenuHandler.java b/src/main/java/creator/loki/feature/help/HelpMenuHandler.java index 92c332c..360505b 100644 --- a/src/main/java/creator/loki/feature/help/HelpMenuHandler.java +++ b/src/main/java/creator/loki/feature/help/HelpMenuHandler.java @@ -1,32 +1,26 @@ package creator.loki.feature.help; -import java.awt.Color; import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; import com.google.inject.Inject; import com.google.inject.Singleton; import creator.loki.command.Command; import creator.loki.config.BotConfig; import creator.loki.core.registry.CommandRegistry; -import creator.loki.embed.EmbedFactory; -import creator.loki.embed.HelpCategories; import net.dv8tion.jda.api.Permission; import net.dv8tion.jda.api.components.actionrow.ActionRow; -import net.dv8tion.jda.api.components.selections.StringSelectMenu; import net.dv8tion.jda.api.entities.channel.attribute.IAgeRestrictedChannel; import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; @Singleton public class HelpMenuHandler extends ListenerAdapter { - private final CommandRegistry registry; + private final HelpMenuBuilder menuBuilder; private final BotConfig config; @Inject public HelpMenuHandler(CommandRegistry registry, BotConfig config) { - this.registry = registry; + this.menuBuilder = new HelpMenuBuilder(registry, config); this.config = config; } @@ -43,116 +37,44 @@ public class HelpMenuHandler extends ListenerAdapter { String category = selectedValue.substring("category:".length()); - // Проверка прав для категории "Настройка" - if ("Настройка".equals(category) - && event.getMember() != null - && !event.getMember().hasPermission(Permission.ADMINISTRATOR)) { - event.reply("❌ Эта категория доступна только администраторам!").setEphemeral(true).queue(); + if (!canViewCategory(event, category)) { return; } - if ("Супер-Админ".equals(category) - && !config.getSuperAdminId().equals(event.getUser().getId())) { - event.reply("❌ Эта категория доступна только владельцу бота.").setEphemeral(true).queue(); - return; - } - - var commands = registry.getAllCommands(); - Map> commandsByCategory = - commands.stream().collect(Collectors.groupingBy(Command::getCategory)); - + var commandsByCategory = menuBuilder.groupByCategory(); List categoryCommands = commandsByCategory.get(category); if (categoryCommands == null || categoryCommands.isEmpty()) { event.reply("❌ В этой категории нет команд!").setEphemeral(true).queue(); return; } - // Создаём embed для категории - EmbedFactory embed = createCategoryEmbed(category, categoryCommands, event); - - // Пересоздаём селектор - StringSelectMenu.Builder menuBuilder = - StringSelectMenu.create("help:category").setPlaceholder("📂 Выберите категорию"); - boolean isAdmin = event.getMember() != null && event.getMember().hasPermission(Permission.ADMINISTRATOR); boolean isSuper = config.getSuperAdminId().equals(event.getUser().getId()); boolean isNsfwChannel = isNsfw(event); - commandsByCategory.keySet().stream() - .sorted() - .forEach( - cat -> { - if (!HelpCategories.isCategoryVisible(cat, isAdmin, isSuper, isNsfwChannel)) { - return; - } - - String emoji = HelpCategories.getEmoji(cat); - int count = commandsByCategory.get(cat).size(); - - menuBuilder.addOption( - cat, - "category:" + cat, - count + " команд", - net.dv8tion.jda.api.entities.emoji.Emoji.fromUnicode(emoji)); - }); - - event.editMessageEmbeds(embed.build()).setComponents(ActionRow.of(menuBuilder.build())).queue(); + event + .editMessageEmbeds( + HelpCategoryEmbed.build(category, categoryCommands, event, config).build()) + .setComponents( + ActionRow.of( + menuBuilder.buildSelector(commandsByCategory, isAdmin, isSuper, isNsfwChannel))) + .queue(); } - private EmbedFactory createCategoryEmbed( - final String category, - final List commands, - final StringSelectInteractionEvent event) { - String emoji = HelpCategories.getEmoji(category); - Color color = HelpCategories.getColor(category); - - EmbedFactory embed = - EmbedFactory.create() - .withTitle(emoji + " " + category) - .withDescription( - "Список команд категории **" - + category - + "**\n" - + "Префикс: `" - + config.getPrefix() - + "`") - .withColor(color); - - // Группируем команды по 8 в поле - StringBuilder commandList = new StringBuilder(); - int count = 0; - - for (Command cmd : commands) { - commandList - .append("`") - .append(config.getPrefix()) - .append(cmd.getUsage()) - .append("`\n") - .append(cmd.getDescription()) - .append("\n\n"); - count++; - - // Если достигли 8 команд или лимита символов, создаём новое поле - if (count % 8 == 0 || commandList.length() > 900) { - embed.addField("", commandList.toString().trim(), false); - commandList = new StringBuilder(); - } + private boolean canViewCategory(final StringSelectInteractionEvent event, final String category) { + if ("Настройка".equals(category) + && event.getMember() != null + && !event.getMember().hasPermission(Permission.ADMINISTRATOR)) { + event.reply("❌ Эта категория доступна только администраторам!").setEphemeral(true).queue(); + return false; } - - // Добавляем оставшиеся команды - if (commandList.length() > 0) { - embed.addField("", commandList.toString().trim(), false); + if ("Супер-Админ".equals(category) + && !config.getSuperAdminId().equals(event.getUser().getId())) { + event.reply("❌ Эта категория доступна только владельцу бота.").setEphemeral(true).queue(); + return false; } - - embed.addField( - "💡 Подсказка", "Используй селектор ниже для переключения между категориями", false); - - embed.withFooter( - "Запросил: " + event.getUser().getName() + " • Всего команд: " + commands.size(), - event.getUser().getAvatarUrl()); - - return embed; + return true; } private static boolean isNsfw(final StringSelectInteractionEvent event) { diff --git a/src/main/java/creator/loki/feature/help/commands/HelpCommand.java b/src/main/java/creator/loki/feature/help/commands/HelpCommand.java index c5fcf0a..06ac420 100644 --- a/src/main/java/creator/loki/feature/help/commands/HelpCommand.java +++ b/src/main/java/creator/loki/feature/help/commands/HelpCommand.java @@ -1,29 +1,23 @@ package creator.loki.feature.help.commands; -import java.awt.Color; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - import creator.loki.command.Command; import creator.loki.command.CommandCategories; import creator.loki.command.CommandContext; import creator.loki.config.BotConfig; import creator.loki.core.registry.CommandRegistry; -import creator.loki.embed.EmbedFactory; -import creator.loki.embed.HelpCategories; +import creator.loki.feature.help.HelpMainPage; +import creator.loki.feature.help.HelpMenuBuilder; import net.dv8tion.jda.api.Permission; import net.dv8tion.jda.api.components.actionrow.ActionRow; -import net.dv8tion.jda.api.components.selections.StringSelectMenu; import net.dv8tion.jda.api.entities.User; import net.dv8tion.jda.api.entities.channel.attribute.IAgeRestrictedChannel; public class HelpCommand implements Command { - private final CommandRegistry registry; + private final HelpMenuBuilder menuBuilder; private final BotConfig config; public HelpCommand(CommandRegistry registry, BotConfig config) { - this.registry = registry; + this.menuBuilder = new HelpMenuBuilder(registry, config); this.config = config; } @@ -39,9 +33,7 @@ public class HelpCommand implements Command { @Override public void execute(final CommandContext context) { - var commands = registry.getAllCommands(); - Map> commandsByCategory = - commands.stream().collect(Collectors.groupingBy(Command::getCategory)); + var commandsByCategory = menuBuilder.groupByCategory(); User viewer = context.getAuthor() != null @@ -52,37 +44,15 @@ public class HelpCommand implements Command { boolean isSuper = config.getSuperAdminId().equals(viewer.getId()); boolean isNsfwChannel = isNsfw(context); - // Главная страница - EmbedFactory embed = - createMainPage(context, commandsByCategory, isAdmin, isSuper, isNsfwChannel); - - // Создаём селектор категорий - StringSelectMenu.Builder menuBuilder = - StringSelectMenu.create("help:category").setPlaceholder("📂 Выберите категорию"); - - commandsByCategory.keySet().stream() - .sorted() - .forEach( - category -> { - if (!HelpCategories.isCategoryVisible(category, isAdmin, isSuper, isNsfwChannel)) { - return; - } - - String emoji = HelpCategories.getEmoji(category); - String description = HelpCategories.getDescription(category); - int count = commandsByCategory.get(category).size(); - - menuBuilder.addOption( - category, - "category:" + category, - description + " (" + count + " команд)", - net.dv8tion.jda.api.entities.emoji.Emoji.fromUnicode(emoji)); - }); - context .getChannel() - .sendMessageEmbeds(embed.build()) - .addComponents(ActionRow.of(menuBuilder.build())) + .sendMessageEmbeds( + HelpMainPage.build( + context, commandsByCategory, menuBuilder, isAdmin, isSuper, isNsfwChannel) + .build()) + .addComponents( + ActionRow.of( + menuBuilder.buildSelector(commandsByCategory, isAdmin, isSuper, isNsfwChannel))) .queue(); } @@ -90,74 +60,6 @@ public class HelpCommand implements Command { return context.getChannel() instanceof IAgeRestrictedChannel arc && arc.isNSFW(); } - private EmbedFactory createMainPage( - final CommandContext context, - final Map> commandsByCategory, - final boolean isAdmin, - final boolean isSuper, - final boolean isNsfwChannel) { - int totalCommands = - commandsByCategory.entrySet().stream() - .filter( - e -> HelpCategories.isCategoryVisible(e.getKey(), isAdmin, isSuper, isNsfwChannel)) - .mapToInt(e -> e.getValue().size()) - .sum(); - - StringBuilder categoriesText = new StringBuilder(); - commandsByCategory.keySet().stream() - .sorted() - .forEach( - category -> { - if (!HelpCategories.isCategoryVisible(category, isAdmin, isSuper, isNsfwChannel)) { - return; - } - - String emoji = HelpCategories.getEmoji(category); - int count = commandsByCategory.get(category).size(); - categoriesText - .append(emoji) - .append(" **") - .append(category) - .append("** - ") - .append(count) - .append(" команд\n"); - }); - - return EmbedFactory.create() - .withTitle("📚 Справка по командам Loki Bot") - .withDescription( - "Привет! Я многофункциональный Discord бот с системой уровней, " - + "личными голосовыми каналами и множеством развлекательных команд.\n\n" - + "**Префикс команд:** `" - + config.getPrefix() - + "`\n" - + "**Всего команд:** " - + totalCommands - + "\n\n" - + "**📂 Категории команд:**\n" - + categoriesText.toString() - + "\n" - + "Используй селектор ниже для просмотра команд категории.") - .withColor(new Color(88, 101, 242)) - .withThumbnail(context.getGuild() != null ? context.getGuild().getIconUrl() : null) - .addField( - "💡 Как использовать", - "Все команды - с префиксом `" - + config.getPrefix() - + "`\n" - + "Примеры: `" - + config.getPrefix() - + "rank`, `" - + config.getPrefix() - + "ship @user1 @user2`, `" - + config.getPrefix() - + "voice-setup info`", - false) - .withFooter( - "Запросил: " + context.getAuthor().getUser().getName(), - context.getAuthor().getUser().getAvatarUrl()); - } - @Override public String getCategory() { return CommandCategories.UTILITY; diff --git a/src/main/java/creator/loki/feature/info/ServerStatsEmbedBuilder.java b/src/main/java/creator/loki/feature/info/ServerStatsEmbedBuilder.java new file mode 100644 index 0000000..f4810de --- /dev/null +++ b/src/main/java/creator/loki/feature/info/ServerStatsEmbedBuilder.java @@ -0,0 +1,88 @@ +package creator.loki.feature.info; + +import java.awt.Color; +import java.util.List; + +import creator.loki.embed.EmbedFactory; +import net.dv8tion.jda.api.OnlineStatus; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.MessageEmbed; + +public final class ServerStatsEmbedBuilder { + private ServerStatsEmbedBuilder() {} + + public record MemberStats(long total, long bots, long humans, long online) {} + + public record ChannelCounts( + long textChannels, long voiceChannels, long categories, long totalChannels) {} + + public record BoostInfo(int boostCount, int boostTier) {} + + public record GuildMeta(int roles, int emojis, String created, String ownerName) {} + + public static MemberStats countMembers(final List members) { + long total = members.size(); + long bots = members.stream().filter(m -> m.getUser().isBot()).count(); + long humans = total - bots; + long online = + members.stream() + .filter( + m -> + m.getOnlineStatus() != null + && m.getOnlineStatus() != OnlineStatus.OFFLINE + && m.getOnlineStatus() != OnlineStatus.UNKNOWN) + .count(); + return new MemberStats(total, bots, humans, online); + } + + public static MessageEmbed build( + final Guild guild, + final MemberStats memberStats, + final ChannelCounts channels, + final BoostInfo boost, + final GuildMeta meta) { + return EmbedFactory.create() + .withTitle("📊 Статистика сервера") + .withThumbnail(guild.getIconUrl()) + .withColor(new Color(88, 101, 242)) + .addField( + "👤 Участники", + "Всего: **" + + memberStats.total() + + "**\n👥 Людей: **" + + memberStats.humans() + + "**\n🤖 Ботов: **" + + memberStats.bots() + + "**\n🟢 Онлайн: **" + + memberStats.online() + + "**", + true) + .addField( + "💬 Каналы", + "Текстовых: **" + + channels.textChannels() + + "**\n🔊 Голосовых: **" + + channels.voiceChannels() + + "**\n📁 Категорий: **" + + channels.categories() + + "**\nВсего: **" + + channels.totalChannels() + + "**", + true) + .addField( + "⚙️ Прочее", + "Ролей: **" + + meta.roles() + + "**\n😀 Эмодзи: **" + + meta.emojis() + + "**\n🚀 Бустов: **" + + boost.boostCount() + + "** (ур. " + + boost.boostTier() + + ")", + true) + .withFooter("Создан: " + meta.created() + " | Владелец: " + meta.ownerName()) + .build(); + } +} diff --git a/src/main/java/creator/loki/feature/info/commands/ServerStatsCommand.java b/src/main/java/creator/loki/feature/info/commands/ServerStatsCommand.java index d9e0b21..eb40175 100644 --- a/src/main/java/creator/loki/feature/info/commands/ServerStatsCommand.java +++ b/src/main/java/creator/loki/feature/info/commands/ServerStatsCommand.java @@ -1,14 +1,19 @@ package creator.loki.feature.info.commands; -import java.awt.Color; import java.time.format.DateTimeFormatter; +import java.util.List; import creator.loki.command.Command; import creator.loki.command.CommandCategories; import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; +import creator.loki.feature.info.ServerStatsEmbedBuilder; +import creator.loki.feature.info.ServerStatsEmbedBuilder.BoostInfo; +import creator.loki.feature.info.ServerStatsEmbedBuilder.ChannelCounts; +import creator.loki.feature.info.ServerStatsEmbedBuilder.GuildMeta; +import creator.loki.feature.info.ServerStatsEmbedBuilder.MemberStats; import creator.loki.feature.serverstats.ServerStatsService; import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.utils.FileUpload; public class ServerStatsCommand implements Command { @@ -47,7 +52,6 @@ public class ServerStatsCommand implements Command { } String arg = context.getArgs().isEmpty() ? "" : context.getArgs().get(0).toLowerCase(); - switch (arg) { case "csv" -> handleCsv(context, guild); case "chart" -> handleChart(context, guild); @@ -55,93 +59,33 @@ public class ServerStatsCommand implements Command { } } - private record MemberStats( - long total, long bots, long humans, long online) {} - - private static MemberStats countMembers( - java.util.List members) { - long total = members.size(); - long bots = members.stream().filter(m -> m.getUser().isBot()).count(); - long humans = total - bots; - long online = - members.stream() - .filter( - m -> - m.getOnlineStatus() != null - && m.getOnlineStatus() - != net.dv8tion.jda.api.OnlineStatus.OFFLINE - && m.getOnlineStatus() - != net.dv8tion.jda.api.OnlineStatus.UNKNOWN) - .count(); - return new MemberStats(total, bots, humans, online); - } - private void handleEmbed(final CommandContext context, final Guild guild) { guild.loadMembers().onSuccess(members -> sendStatsEmbed(context, guild, members)); } - private record ChannelCounts( - long textChannels, long voiceChannels, long categories, long totalChannels) {} - - private record BoostInfo(int boostCount, int boostTier) {} - - private record GuildMeta(int roles, int emojis, String created, String ownerName) {} - private void sendStatsEmbed( - final CommandContext context, - final Guild guild, - java.util.List members) { - var s = countMembers(members); + final CommandContext context, final Guild guild, final List members) { + MemberStats stats = ServerStatsEmbedBuilder.countMembers(members); long textChannels = guild.getTextChannels().size(); long voiceChannels = guild.getVoiceChannels().size(); long categories = guild.getCategories().size(); long totalChannels = textChannels + voiceChannels + categories; - int roles = guild.getRoles().size(); - int emojis = guild.getEmojis().size(); - int boostTier = guild.getBoostTier().getKey(); - int boostCount = guild.getBoostCount(); - String created = - guild.getTimeCreated().format(DateTimeFormatter.ofPattern("dd.MM.yyyy")); + String created = guild.getTimeCreated().format(DateTimeFormatter.ofPattern("dd.MM.yyyy")); String ownerName = - guild.getOwner() != null - ? guild.getOwner().getUser().getGlobalName() - : "неизвестен"; + guild.getOwner() != null ? guild.getOwner().getUser().getGlobalName() : "неизвестен"; context .getChannel() - .sendMessageEmbeds(buildStatsEmbed( - guild, s, - new ChannelCounts(textChannels, voiceChannels, categories, totalChannels), - new BoostInfo(boostCount, boostTier), - new GuildMeta(roles, emojis, created, ownerName))) + .sendMessageEmbeds( + ServerStatsEmbedBuilder.build( + guild, + stats, + new ChannelCounts(textChannels, voiceChannels, categories, totalChannels), + new BoostInfo(guild.getBoostCount(), guild.getBoostTier().getKey()), + new GuildMeta( + guild.getRoles().size(), guild.getEmojis().size(), created, ownerName))) .queue(); } - private net.dv8tion.jda.api.entities.MessageEmbed buildStatsEmbed( - final Guild guild, - final MemberStats memberStats, - final ChannelCounts channels, - final BoostInfo boost, - final GuildMeta meta) { - return EmbedFactory.create() - .withTitle("📊 Статистика сервера") - .withThumbnail(guild.getIconUrl()) - .withColor(new Color(88, 101, 242)) - .addField("👤 Участники", - "Всего: **" + memberStats.total() + "**\n👥 Людей: **" + memberStats.humans() - + "**\n🤖 Ботов: **" + memberStats.bots() + "**\n🟢 Онлайн: **" + memberStats.online() + "**", - true) - .addField("💬 Каналы", - "Текстовых: **" + channels.textChannels() + "**\n🔊 Голосовых: **" + channels.voiceChannels() - + "**\n📁 Категорий: **" + channels.categories() + "**\nВсего: **" + channels.totalChannels() + "**", - true) - .addField("⚙️ Прочее", - "Ролей: **" + meta.roles() + "**\n😀 Эмодзи: **" + meta.emojis() - + "**\n🚀 Бустов: **" + boost.boostCount() + "** (ур. " + boost.boostTier() + ")", - true) - .withFooter("Создан: " + meta.created() + " | Владелец: " + meta.ownerName()) - .build(); - } - private void handleCsv(final CommandContext context, final Guild guild) { context .getChannel() diff --git a/src/main/java/creator/loki/feature/level/commands/LevelRoleCommand.java b/src/main/java/creator/loki/feature/level/commands/LevelRoleCommand.java index b7bbc3d..7b7d360 100644 --- a/src/main/java/creator/loki/feature/level/commands/LevelRoleCommand.java +++ b/src/main/java/creator/loki/feature/level/commands/LevelRoleCommand.java @@ -1,24 +1,20 @@ package creator.loki.feature.level.commands; import java.util.List; -import java.util.Map; import creator.loki.command.Command; import creator.loki.command.CommandCategories; import creator.loki.command.CommandContext; import creator.loki.embed.EmbedTemplates; import creator.loki.feature.config.repository.ConfigRepository; -import creator.loki.feature.level.commands.util.LevelRoleFormatter; import creator.loki.feature.level.service.LevelRoleService; import net.dv8tion.jda.api.Permission; public class LevelRoleCommand implements Command { - private final LevelRoleService levelRoleService; - private final ConfigRepository configRepository; + private final LevelRoleSubcommands subcommands; public LevelRoleCommand(LevelRoleService levelRoleService, ConfigRepository configRepository) { - this.levelRoleService = levelRoleService; - this.configRepository = configRepository; + this.subcommands = new LevelRoleSubcommands(levelRoleService, configRepository); } @Override @@ -56,125 +52,15 @@ public class LevelRoleCommand implements Command { List args = context.getArgs(); if (args.isEmpty()) { - context - .getChannel() - .sendMessageEmbeds( - LevelRoleFormatter.buildMappingsEmbed(configRepository, levelRoleService).build()) - .queue(); + subcommands.showMappings(context); return; } switch (args.get(0).toLowerCase()) { - case "set" -> handleSet(context, args); - case "remove" -> handleRemove(context, args); - case "clear" -> handleClear(context); - default -> - context - .getChannel() - .sendMessageEmbeds( - LevelRoleFormatter.buildMappingsEmbed(configRepository, levelRoleService).build()) - .queue(); + case "set" -> subcommands.set(context, args); + case "remove" -> subcommands.remove(context, args); + case "clear" -> subcommands.clear(context); + default -> subcommands.showMappings(context); } } - - private void handleSet(final CommandContext context, final List args) { - if (args.size() < 3) { - context - .getChannel() - .sendMessageEmbeds( - EmbedTemplates.error( - "Ошибка", "Использование: `!levelrole set <уровень> <название роли>`")) - .queue(); - return; - } - - int level; - try { - level = Integer.parseInt(args.get(1)); - } catch (NumberFormatException e) { - context - .getChannel() - .sendMessageEmbeds(EmbedTemplates.error("Ошибка", "Уровень должен быть числом.")) - .queue(); - return; - } - - if (level < 1) { - context - .getChannel() - .sendMessageEmbeds( - EmbedTemplates.error("Ошибка", "Уровень должен быть положительным числом.")) - .queue(); - return; - } - - String roleName = String.join(" ", args.subList(2, args.size())); - if (roleName.isEmpty()) { - context - .getChannel() - .sendMessageEmbeds(EmbedTemplates.error("Ошибка", "Название роли не может быть пустым.")) - .queue(); - return; - } - - levelRoleService.setRoleForLevel(level, roleName); - context - .getChannel() - .sendMessageEmbeds( - EmbedTemplates.success( - "Роль назначена", - "Роль `" + roleName + "` будет выдаваться за уровень **" + level + "**.")) - .queue(); - } - - private void handleRemove(final CommandContext context, final List args) { - if (args.size() < 2) { - context - .getChannel() - .sendMessageEmbeds( - EmbedTemplates.error("Ошибка", "Использование: `!levelrole remove <уровень>`")) - .queue(); - return; - } - - int level; - try { - level = Integer.parseInt(args.get(1)); - } catch (NumberFormatException e) { - context - .getChannel() - .sendMessageEmbeds(EmbedTemplates.error("Ошибка", "Уровень должен быть числом.")) - .queue(); - return; - } - - levelRoleService.removeRoleForLevel(level); - context - .getChannel() - .sendMessageEmbeds( - EmbedTemplates.success( - "Роль сброшена", - "Для уровня **" + level + "** будет использоваться роль по умолчанию.")) - .queue(); - } - - private void handleClear(final CommandContext context) { - Map allConfig = configRepository.getAll(); - int removed = 0; - for (String key : allConfig.keySet()) { - if (key.startsWith("level_role_")) { - configRepository.delete(key); - removed++; - } - } - context - .getChannel() - .sendMessageEmbeds( - EmbedTemplates.success( - "Настройки сброшены", - "Удалено пользовательских ролей: **" - + removed - + "**. Будут использоваться роли по умолчанию.")) - .queue(); - } } diff --git a/src/main/java/creator/loki/feature/level/commands/LevelRoleSubcommands.java b/src/main/java/creator/loki/feature/level/commands/LevelRoleSubcommands.java new file mode 100644 index 0000000..6d7a6af --- /dev/null +++ b/src/main/java/creator/loki/feature/level/commands/LevelRoleSubcommands.java @@ -0,0 +1,131 @@ +package creator.loki.feature.level.commands; + +import java.util.List; +import java.util.Map; + +import creator.loki.command.CommandContext; +import creator.loki.embed.EmbedTemplates; +import creator.loki.feature.config.repository.ConfigRepository; +import creator.loki.feature.level.commands.util.LevelRoleFormatter; +import creator.loki.feature.level.service.LevelRoleService; + +/** Обработчики подкоманд настройки ролей за уровни. */ +public class LevelRoleSubcommands { + private final LevelRoleService levelRoleService; + private final ConfigRepository configRepository; + + public LevelRoleSubcommands( + LevelRoleService levelRoleService, ConfigRepository configRepository) { + this.levelRoleService = levelRoleService; + this.configRepository = configRepository; + } + + public void showMappings(final CommandContext context) { + context + .getChannel() + .sendMessageEmbeds( + LevelRoleFormatter.buildMappingsEmbed(configRepository, levelRoleService).build()) + .queue(); + } + + public void set(final CommandContext context, final List args) { + if (args.size() < 3) { + context + .getChannel() + .sendMessageEmbeds( + EmbedTemplates.error( + "Ошибка", "Использование: `!levelrole set <уровень> <название роли>`")) + .queue(); + return; + } + + int level; + try { + level = Integer.parseInt(args.get(1)); + } catch (NumberFormatException e) { + context + .getChannel() + .sendMessageEmbeds(EmbedTemplates.error("Ошибка", "Уровень должен быть числом.")) + .queue(); + return; + } + + if (level < 1) { + context + .getChannel() + .sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "Уровень должен быть положительным числом.")) + .queue(); + return; + } + + String roleName = String.join(" ", args.subList(2, args.size())); + if (roleName.isEmpty()) { + context + .getChannel() + .sendMessageEmbeds(EmbedTemplates.error("Ошибка", "Название роли не может быть пустым.")) + .queue(); + return; + } + + levelRoleService.setRoleForLevel(level, roleName); + context + .getChannel() + .sendMessageEmbeds( + EmbedTemplates.success( + "Роль назначена", + "Роль `" + roleName + "` будет выдаваться за уровень **" + level + "**.")) + .queue(); + } + + public void remove(final CommandContext context, final List args) { + if (args.size() < 2) { + context + .getChannel() + .sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "Использование: `!levelrole remove <уровень>`")) + .queue(); + return; + } + + int level; + try { + level = Integer.parseInt(args.get(1)); + } catch (NumberFormatException e) { + context + .getChannel() + .sendMessageEmbeds(EmbedTemplates.error("Ошибка", "Уровень должен быть числом.")) + .queue(); + return; + } + + levelRoleService.removeRoleForLevel(level); + context + .getChannel() + .sendMessageEmbeds( + EmbedTemplates.success( + "Роль сброшена", + "Для уровня **" + level + "** будет использоваться роль по умолчанию.")) + .queue(); + } + + public void clear(final CommandContext context) { + Map allConfig = configRepository.getAll(); + int removed = 0; + for (String key : allConfig.keySet()) { + if (key.startsWith("level_role_")) { + configRepository.delete(key); + removed++; + } + } + context + .getChannel() + .sendMessageEmbeds( + EmbedTemplates.success( + "Настройки сброшены", + "Удалено пользовательских ролей: **" + + removed + + "**. Будут использоваться роли по умолчанию.")) + .queue(); + } +} diff --git a/src/main/java/creator/loki/feature/level/commands/admin/ResetLevelsCommand.java b/src/main/java/creator/loki/feature/level/commands/admin/ResetLevelsCommand.java index 425f248..3d8bbce 100644 --- a/src/main/java/creator/loki/feature/level/commands/admin/ResetLevelsCommand.java +++ b/src/main/java/creator/loki/feature/level/commands/admin/ResetLevelsCommand.java @@ -1,35 +1,24 @@ package creator.loki.feature.level.commands.admin; -import java.awt.Color; - import creator.loki.command.Command; import creator.loki.command.CommandCategories; import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; import creator.loki.feature.config.repository.GuildConfigRepository; -import creator.loki.feature.level.domain.UserStats; import creator.loki.feature.level.repository.UserStatsRepository; +import creator.loki.feature.level.service.LevelResetService; import creator.loki.util.LoggerDecorator; import net.dv8tion.jda.api.Permission; -import net.dv8tion.jda.api.components.actionrow.ActionRow; -import net.dv8tion.jda.api.components.buttons.Button; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -/** - * Одноразовая команда для сброса уровней и XP всех пользователей Сохраняет время в войсе и - * количество сообщений - */ +/** Одноразовая команда для сброса уровней и XP всех пользователей. */ public class ResetLevelsCommand implements Command { private static final Logger logger = LoggerFactory.getLogger(ResetLevelsCommand.class); - private static final String RESET_KEY = "levels_reset_executed"; - private final UserStatsRepository repository; - private final GuildConfigRepository guildConfig; + private final LevelResetService resetService; public ResetLevelsCommand(UserStatsRepository repository, GuildConfigRepository guildConfig) { - this.repository = repository; - this.guildConfig = guildConfig; + this.resetService = new LevelResetService(repository, guildConfig); } @Override @@ -54,104 +43,29 @@ public class ResetLevelsCommand implements Command { @Override public void execute(final CommandContext context) { - // Проверка прав администратора if (context.getAuthor() == null || !context.getAuthor().hasPermission(Permission.ADMINISTRATOR)) { - context - .getChannel() - .sendMessageEmbeds( - EmbedFactory.create() - .withTitle("❌ Недостаточно прав") - .withDescription("Эта команда доступна только администраторам.") - .withColor(Color.RED) - .build()) - .queue(); + context.getChannel().sendMessageEmbeds(ResetLevelsEmbeds.noPermission()).queue(); return; } - // Проверка, была ли команда уже выполнена String guildId = context.getGuild().getId(); - String resetValue = guildConfig.get(guildId, RESET_KEY); - boolean alreadyReset = "true".equals(resetValue); - - if (alreadyReset) { - context - .getChannel() - .sendMessageEmbeds( - EmbedFactory.create() - .withTitle("⚠️ Команда уже выполнена") - .withDescription( - "Сброс уровней уже был выполнен ранее." - + "\nЭта команда одноразовая и не может быть использована повторно.") - .withColor(Color.ORANGE) - .addField( - "💡 Информация", - "Если нужно сбросить уровни снова, обратитесь к разработчику бота.", - false) - .build()) - .queue(); + if (resetService.isAlreadyReset(guildId)) { + context.getChannel().sendMessageEmbeds(ResetLevelsEmbeds.alreadyReset()).queue(); return; } - // Показываем предупреждение с кнопками подтверждения context .getChannel() - .sendMessageEmbeds( - EmbedFactory.create() - .withTitle("⚠️ ВНИМАНИЕ: Сброс уровней") - .withDescription( - "Вы собираетесь сбросить **уровни и XP** всех пользователей на сервере.\n\n" - + "**Что будет сброшено:**\n" - + "• Уровень → 0\n" - + "• XP → 0\n\n" - + "**Что НЕ будет затронуто:**\n" - + "• Время в голосовых каналах\n" - + "• Количество сообщений\n" - + "• Репутация\n\n" - + "**⚠️ Это действие НЕОБРАТИМО и может быть выполнено только ОДИН РАЗ!**") - .withColor(Color.RED) - .addField( - "📊 Новая система уровней", - "После сброса будет использоваться новая формула:\n" - + "• XP для уровня N = 200 × N\n" - + "• Пример: для 15 уровня нужно 22,800 XP", - false) - .withFooter("Подтвердите действие нажатием кнопки ниже") - .build()) - .addComponents( - ActionRow.of( - Button.success( - "reset_levels_confirm:" + context.getAuthor().getId(), "✅ Подтвердить сброс"), - Button.danger("reset_levels_cancel:" + context.getAuthor().getId(), "❌ Отмена"))) + .sendMessageEmbeds(ResetLevelsEmbeds.confirmation()) + .addComponents(ResetLevelsEmbeds.confirmationButtons(context.getAuthor().getId())) .queue(); } - /** Выполняет сброс уровней (вызывается из ButtonHandler) */ + /** Выполняет сброс уровней (вызывается из ButtonHandler). */ public void executeReset(final String guildId, final String userId) { try { - LoggerDecorator.warn(logger, "Начало сброса уровней для гильдии: " + guildId); - LoggerDecorator.warn(logger, "Инициатор: " + userId); - - // Получаем всех пользователей (без guildId - метод возвращает всех) - var allStats = repository.getAllStats(); - int resetCount = 0; - - // Фильтруем по guildId и сбрасываем только XP и уровень - for (var entry : allStats.entrySet()) { - String key = entry.getKey(); - // Ключ формата "guildId:userId" - if (key.startsWith(guildId + ":")) { - var stats = new UserStats.Builder(entry.getValue()).totalXP(0).level(0).build(); - repository.save(stats); - resetCount++; - } - } - - // Помечаем что команда выполнена - guildConfig.set(guildId, RESET_KEY, "true"); - - LoggerDecorator.success(logger, "Сброс завершён. Обработано пользователей: " + resetCount); - + resetService.resetLevels(guildId, userId); } catch (Exception e) { LoggerDecorator.error(logger, "Ошибка при сбросе уровней", e); throw new RuntimeException("Ошибка при сбросе уровней: " + e.getMessage(), e); diff --git a/src/main/java/creator/loki/feature/level/commands/admin/ResetLevelsEmbeds.java b/src/main/java/creator/loki/feature/level/commands/admin/ResetLevelsEmbeds.java new file mode 100644 index 0000000..a39e035 --- /dev/null +++ b/src/main/java/creator/loki/feature/level/commands/admin/ResetLevelsEmbeds.java @@ -0,0 +1,65 @@ +package creator.loki.feature.level.commands.admin; + +import java.awt.Color; + +import creator.loki.embed.EmbedFactory; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.buttons.Button; +import net.dv8tion.jda.api.entities.MessageEmbed; + +/** Построение embed-сообщений для команды сброса уровней. */ +public final class ResetLevelsEmbeds { + private ResetLevelsEmbeds() {} + + public static MessageEmbed noPermission() { + return EmbedFactory.create() + .withTitle("❌ Недостаточно прав") + .withDescription("Эта команда доступна только администраторам.") + .withColor(Color.RED) + .build(); + } + + public static MessageEmbed alreadyReset() { + return EmbedFactory.create() + .withTitle("⚠️ Команда уже выполнена") + .withDescription( + "Сброс уровней уже был выполнен ранее." + + "\nЭта команда одноразовая и не может быть использована повторно.") + .withColor(Color.ORANGE) + .addField( + "💡 Информация", + "Если нужно сбросить уровни снова, обратитесь к разработчику бота.", + false) + .build(); + } + + public static MessageEmbed confirmation() { + return EmbedFactory.create() + .withTitle("⚠️ ВНИМАНИЕ: Сброс уровней") + .withDescription( + "Вы собираетесь сбросить **уровни и XP** всех пользователей на сервере.\n\n" + + "**Что будет сброшено:**\n" + + "• Уровень → 0\n" + + "• XP → 0\n\n" + + "**Что НЕ будет затронуто:**\n" + + "• Время в голосовых каналах\n" + + "• Количество сообщений\n" + + "• Репутация\n\n" + + "**⚠️ Это действие НЕОБРАТИМО и может быть выполнено только ОДИН РАЗ!**") + .withColor(Color.RED) + .addField( + "📊 Новая система уровней", + "После сброса будет использоваться новая формула:\n" + + "• XP для уровня N = 200 × N\n" + + "• Пример: для 15 уровня нужно 22,800 XP", + false) + .withFooter("Подтвердите действие нажатием кнопки ниже") + .build(); + } + + public static ActionRow confirmationButtons(final String authorId) { + return ActionRow.of( + Button.success("reset_levels_confirm:" + authorId, "✅ Подтвердить сброс"), + Button.danger("reset_levels_cancel:" + authorId, "❌ Отмена")); + } +} diff --git a/src/main/java/creator/loki/feature/level/domain/UserStats.java b/src/main/java/creator/loki/feature/level/domain/UserStats.java index 3422437..54e497b 100644 --- a/src/main/java/creator/loki/feature/level/domain/UserStats.java +++ b/src/main/java/creator/loki/feature/level/domain/UserStats.java @@ -21,7 +21,7 @@ public class UserStats { this.lastVoiceJoin = 0; } - private UserStats(final Builder builder) { + UserStats(final UserStatsBuilder builder) { this.userId = builder.userId; this.level = builder.level; this.totalXP = builder.totalXP; @@ -99,62 +99,14 @@ public class UserStats { userId, level, totalXP, messageCount, voiceMinutes); } - public static class Builder { - private String userId; - private int level; - private int totalXP; - private int messageCount; - private long voiceMinutes; - private long lastMessageTime; - private long lastVoiceJoin; - + public static class Builder extends UserStatsBuilder { public Builder() {} public Builder(final UserStats existing) { - this.userId = existing.userId; - this.level = existing.level; - this.totalXP = existing.totalXP; - this.messageCount = existing.messageCount; - this.voiceMinutes = existing.voiceMinutes; - this.lastMessageTime = existing.lastMessageTime; - this.lastVoiceJoin = existing.lastVoiceJoin; - } - - public Builder userId(String userId) { - this.userId = userId; - return this; - } - - public Builder level(int level) { - this.level = level; - return this; - } - - public Builder totalXP(int totalXP) { - this.totalXP = totalXP; - return this; - } - - public Builder messageCount(int messageCount) { - this.messageCount = messageCount; - return this; - } - - public Builder voiceMinutes(long voiceMinutes) { - this.voiceMinutes = voiceMinutes; - return this; - } - - public Builder lastMessageTime(long lastMessageTime) { - this.lastMessageTime = lastMessageTime; - return this; - } - - public Builder lastVoiceJoin(long lastVoiceJoin) { - this.lastVoiceJoin = lastVoiceJoin; - return this; + super(existing); } + @Override public UserStats build() { return new UserStats(this); } diff --git a/src/main/java/creator/loki/feature/level/domain/UserStatsBuilder.java b/src/main/java/creator/loki/feature/level/domain/UserStatsBuilder.java new file mode 100644 index 0000000..5ea2e89 --- /dev/null +++ b/src/main/java/creator/loki/feature/level/domain/UserStatsBuilder.java @@ -0,0 +1,63 @@ +package creator.loki.feature.level.domain; + +/** Флюент-билдер для {@link UserStats}. */ +public class UserStatsBuilder { + protected String userId; + protected int level; + protected int totalXP; + protected int messageCount; + protected long voiceMinutes; + protected long lastMessageTime; + protected long lastVoiceJoin; + + public UserStatsBuilder() {} + + protected UserStatsBuilder(final UserStats existing) { + this.userId = existing.getUserId(); + this.level = existing.getLevel(); + this.totalXP = existing.getTotalXP(); + this.messageCount = existing.getMessageCount(); + this.voiceMinutes = existing.getVoiceMinutes(); + this.lastMessageTime = existing.getLastMessageTime(); + this.lastVoiceJoin = existing.getLastVoiceJoin(); + } + + public UserStatsBuilder userId(String userId) { + this.userId = userId; + return this; + } + + public UserStatsBuilder level(int level) { + this.level = level; + return this; + } + + public UserStatsBuilder totalXP(int totalXP) { + this.totalXP = totalXP; + return this; + } + + public UserStatsBuilder messageCount(int messageCount) { + this.messageCount = messageCount; + return this; + } + + public UserStatsBuilder voiceMinutes(long voiceMinutes) { + this.voiceMinutes = voiceMinutes; + return this; + } + + public UserStatsBuilder lastMessageTime(long lastMessageTime) { + this.lastMessageTime = lastMessageTime; + return this; + } + + public UserStatsBuilder lastVoiceJoin(long lastVoiceJoin) { + this.lastVoiceJoin = lastVoiceJoin; + return this; + } + + public UserStats build() { + return new UserStats(this); + } +} diff --git a/src/main/java/creator/loki/feature/level/service/LevelResetService.java b/src/main/java/creator/loki/feature/level/service/LevelResetService.java new file mode 100644 index 0000000..7b7da18 --- /dev/null +++ b/src/main/java/creator/loki/feature/level/service/LevelResetService.java @@ -0,0 +1,47 @@ +package creator.loki.feature.level.service; + +import creator.loki.feature.config.repository.GuildConfigRepository; +import creator.loki.feature.level.domain.UserStats; +import creator.loki.feature.level.repository.UserStatsRepository; +import creator.loki.util.LoggerDecorator; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Выполнение одноразового сброса уровней и XP всех пользователей. */ +public class LevelResetService { + private static final Logger logger = LoggerFactory.getLogger(LevelResetService.class); + private static final String RESET_KEY = "levels_reset_executed"; + + private final UserStatsRepository repository; + private final GuildConfigRepository guildConfig; + + public LevelResetService(UserStatsRepository repository, GuildConfigRepository guildConfig) { + this.repository = repository; + this.guildConfig = guildConfig; + } + + public boolean isAlreadyReset(final String guildId) { + return "true".equals(guildConfig.get(guildId, RESET_KEY)); + } + + public int resetLevels(final String guildId, final String userId) { + LoggerDecorator.warn(logger, "Начало сброса уровней для гильдии: " + guildId); + LoggerDecorator.warn(logger, "Инициатор: " + userId); + + var allStats = repository.getAllStats(); + int resetCount = 0; + + for (var entry : allStats.entrySet()) { + String key = entry.getKey(); + if (key.startsWith(guildId + ":")) { + var stats = new UserStats.Builder(entry.getValue()).totalXP(0).level(0).build(); + repository.save(stats); + resetCount++; + } + } + + guildConfig.set(guildId, RESET_KEY, "true"); + LoggerDecorator.success(logger, "Сброс завершён. Обработано пользователей: " + resetCount); + return resetCount; + } +} diff --git a/src/main/java/creator/loki/feature/logging/LogSetupEmbeds.java b/src/main/java/creator/loki/feature/logging/LogSetupEmbeds.java new file mode 100644 index 0000000..a11d6da --- /dev/null +++ b/src/main/java/creator/loki/feature/logging/LogSetupEmbeds.java @@ -0,0 +1,71 @@ +package creator.loki.feature.logging; + +import java.awt.Color; + +import creator.loki.embed.EmbedFactory; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; + +final class LogSetupEmbeds { + private LogSetupEmbeds() {} + + static MessageEmbed moderationChannelSet(final TextChannel channel) { + return EmbedFactory.create() + .withTitle("✅ Канал логов модерации установлен") + .withDescription("Логи модерации будут отправляться в " + channel.getAsMention()) + .addField( + "Что логируется", + "• Удаление сообщений\n" + + "• Баны/разбаны\n" + + "• Кики\n" + + "• Муты/размуты\n" + + "• Отключение микрофона/звука", + false) + .withColor(Color.GREEN) + .build(); + } + + static MessageEmbed levelUpChannelSet(final TextChannel channel) { + return EmbedFactory.create() + .withTitle("✅ Канал уведомлений о level up установлен") + .withDescription( + "Уведомления о повышении уровня будут отправляться в " + channel.getAsMention()) + .withColor(Color.GREEN) + .build(); + } + + static MessageEmbed info(final String moderationChannelId, final String levelupChannelId) { + var embed = + EmbedFactory.create().withTitle("📋 Конфигурация логирования").withColor(Color.BLUE); + + if (moderationChannelId != null) { + embed.addField("Логи модерации", "<#" + moderationChannelId + ">", false); + } else { + embed.addField("Логи модерации", "❌ Не установлен", false); + } + + if (levelupChannelId != null) { + embed.addField("Уведомления level up", "<#" + levelupChannelId + ">", false); + } else { + embed.addField("Уведомления level up", "❌ Не установлен", false); + } + + return embed.build(); + } + + static MessageEmbed help() { + return EmbedFactory.create() + .withTitle("📋 Настройка логирования") + .withDescription( + """ + **Команды:** + • `!log-setup moderation` - `#канал` или числовой id + • `!log-setup levelup` - `#канал` или id + • `!log-setup info` - текущая конфигурация + + Только для администраторов. + """) + .withColor(Color.BLUE) + .build(); + } +} diff --git a/src/main/java/creator/loki/feature/logging/LogSetupPrefixCommand.java b/src/main/java/creator/loki/feature/logging/LogSetupPrefixCommand.java index bf8f20c..b63eda1 100644 --- a/src/main/java/creator/loki/feature/logging/LogSetupPrefixCommand.java +++ b/src/main/java/creator/loki/feature/logging/LogSetupPrefixCommand.java @@ -1,12 +1,10 @@ package creator.loki.feature.logging; -import java.awt.Color; import java.util.List; import creator.loki.command.Command; import creator.loki.command.CommandCategories; import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; import creator.loki.feature.config.repository.ConfigRepository; import creator.loki.util.GuildChannelResolve; import net.dv8tion.jda.api.Permission; @@ -62,13 +60,44 @@ public class LogSetupPrefixCommand implements Command { } private void handleModeration(final CommandContext context, final List args) { + TextChannel channel = requireChannel(context, args); + if (channel == null) { + return; + } + configRepo.set("moderation.log_channel_id", channel.getId()); + context.getChannel().sendMessageEmbeds(LogSetupEmbeds.moderationChannelSet(channel)).queue(); + } + + private void handleLevelUp(final CommandContext context, final List args) { + TextChannel channel = requireChannel(context, args); + if (channel == null) { + return; + } + configRepo.set("levelup.notification_channel_id", channel.getId()); + context.getChannel().sendMessageEmbeds(LogSetupEmbeds.levelUpChannelSet(channel)).queue(); + } + + private void handleInfo(final CommandContext context) { + String moderationChannelId = configRepo.get("moderation.log_channel_id"); + String levelupChannelId = configRepo.get("levelup.notification_channel_id"); + context + .getChannel() + .sendMessageEmbeds(LogSetupEmbeds.info(moderationChannelId, levelupChannelId)) + .queue(); + } + + private void sendHelp(final CommandContext context) { + context.getChannel().sendMessageEmbeds(LogSetupEmbeds.help()).queue(); + } + + private TextChannel requireChannel(final CommandContext context, final List args) { if (args.size() < 2) { context .getChannel() .sendMessage( "❌ Укажите текстовый канал: `#канал`, `<#id>` или id. Пример: `!log-setup moderation 123456789012345678`") .queue(); - return; + return null; } TextChannel channel = GuildChannelResolve.text(context.getGuild(), context.getMessage(), args, 1); @@ -78,101 +107,7 @@ public class LogSetupPrefixCommand implements Command { .sendMessage( "❌ Не найден текстовый канал. Укажите `#канал`, `<#id>` или id из 17-20 цифр.") .queue(); - return; } - configRepo.set("moderation.log_channel_id", channel.getId()); - - context - .getChannel() - .sendMessageEmbeds( - EmbedFactory.create() - .withTitle("✅ Канал логов модерации установлен") - .withDescription("Логи модерации будут отправляться в " + channel.getAsMention()) - .addField( - "Что логируется", - "• Удаление сообщений\n" - + "• Баны/разбаны\n" - + "• Кики\n" - + "• Муты/размуты\n" - + "• Отключение микрофона/звука", - false) - .withColor(Color.GREEN) - .build()) - .queue(); - } - - private void handleLevelUp(final CommandContext context, final List args) { - if (args.size() < 2) { - context - .getChannel() - .sendMessage( - "❌ Укажите текстовый канал: `#канал`, `<#id>` или id. Пример: `!log-setup levelup 123456789012345678`") - .queue(); - return; - } - TextChannel channel = - GuildChannelResolve.text(context.getGuild(), context.getMessage(), args, 1); - if (channel == null) { - context - .getChannel() - .sendMessage( - "❌ Не найден текстовый канал. Укажите `#канал`, `<#id>` или id из 17-20 цифр.") - .queue(); - return; - } - configRepo.set("levelup.notification_channel_id", channel.getId()); - - context - .getChannel() - .sendMessageEmbeds( - EmbedFactory.create() - .withTitle("✅ Канал уведомлений о level up установлен") - .withDescription( - "Уведомления о повышении уровня будут отправляться в " + channel.getAsMention()) - .withColor(Color.GREEN) - .build()) - .queue(); - } - - private void handleInfo(final CommandContext context) { - String moderationChannelId = configRepo.get("moderation.log_channel_id"); - String levelupChannelId = configRepo.get("levelup.notification_channel_id"); - - var embed = - EmbedFactory.create().withTitle("📋 Конфигурация логирования").withColor(Color.BLUE); - - if (moderationChannelId != null) { - embed.addField("Логи модерации", "<#" + moderationChannelId + ">", false); - } else { - embed.addField("Логи модерации", "❌ Не установлен", false); - } - - if (levelupChannelId != null) { - embed.addField("Уведомления level up", "<#" + levelupChannelId + ">", false); - } else { - embed.addField("Уведомления level up", "❌ Не установлен", false); - } - - context.getChannel().sendMessageEmbeds(embed.build()).queue(); - } - - private void sendHelp(final CommandContext context) { - context - .getChannel() - .sendMessageEmbeds( - EmbedFactory.create() - .withTitle("📋 Настройка логирования") - .withDescription( - """ - **Команды:** - • `!log-setup moderation` - `#канал` или числовой id - • `!log-setup levelup` - `#канал` или id - • `!log-setup info` - текущая конфигурация - - Только для администраторов. - """) - .withColor(Color.BLUE) - .build()) - .queue(); + return channel; } } diff --git a/src/main/java/creator/loki/feature/metrics/HealthHttpServer.java b/src/main/java/creator/loki/feature/metrics/HealthHttpServer.java index 5187d51..007b03d 100644 --- a/src/main/java/creator/loki/feature/metrics/HealthHttpServer.java +++ b/src/main/java/creator/loki/feature/metrics/HealthHttpServer.java @@ -1,21 +1,14 @@ package creator.loki.feature.metrics; -import java.io.InputStream; -import java.io.OutputStream; import java.net.BindException; import java.net.InetSocketAddress; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; import java.util.concurrent.Executors; import com.google.inject.Inject; import com.google.inject.Singleton; -import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; import creator.loki.config.BotConfig; import creator.loki.core.Lifecycle; -import creator.loki.feature.tiktok.TempFile; import creator.loki.feature.tiktok.TempFileManager; import creator.loki.util.LoggerDecorator; import org.slf4j.Logger; @@ -26,16 +19,14 @@ public class HealthHttpServer implements Lifecycle { private static final Logger logger = LoggerFactory.getLogger(HealthHttpServer.class); private final BotConfig config; - private final MetricsCollector metrics; - private final TempFileManager tempFileManager; + private final HttpEndpoints endpoints; private HttpServer server; @Inject public HealthHttpServer( BotConfig config, MetricsCollector metrics, TempFileManager tempFileManager) { this.config = config; - this.metrics = metrics; - this.tempFileManager = tempFileManager; + this.endpoints = new HttpEndpoints(metrics, tempFileManager, logger); } public synchronized void start() { @@ -49,8 +40,8 @@ public class HealthHttpServer implements Lifecycle { } try { server = bindHealthServer(port); - server.createContext("/health", this::handleHealth); - server.createContext("/d/", this::handleDownload); + server.createContext("/health", endpoints::handleHealth); + server.createContext("/d/", endpoints::handleDownload); server.setExecutor(Executors.newVirtualThreadPerTaskExecutor()); server.start(); int bound = server.getAddress().getPort(); @@ -82,85 +73,6 @@ public class HealthHttpServer implements Lifecycle { } } - private void handleHealth(final HttpExchange exchange) { - try { - if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { - exchange.sendResponseHeaders(405, -1); - return; - } - String path = exchange.getRequestURI().getPath(); - if (!"/health".equals(path)) { - exchange.sendResponseHeaders(404, -1); - return; - } - String body = metrics.buildHealthJson(); - byte[] bytes = body.getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().add("Content-Type", "application/json; charset=utf-8"); - exchange.sendResponseHeaders(200, bytes.length); - try (OutputStream os = exchange.getResponseBody()) { - os.write(bytes); - } - } catch (Exception e) { - LoggerDecorator.error(logger, "Ошибка ответа /health", e); - try { - exchange.sendResponseHeaders(500, -1); - } catch (Exception ignored) { - } - } finally { - exchange.close(); - } - } - - private void handleDownload(final HttpExchange exchange) { - try { - if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { - exchange.sendResponseHeaders(405, -1); - return; - } - String path = exchange.getRequestURI().getPath(); - String key = path.substring("/d/".length()); - if (key.isEmpty()) { - exchange.sendResponseHeaders(400, -1); - return; - } - TempFile entry = tempFileManager.getEntry(key); - if (entry == null) { - String body = "File not found or expired"; - byte[] bytes = body.getBytes(StandardCharsets.UTF_8); - exchange.getResponseHeaders().add("Content-Type", "text/plain; charset=utf-8"); - exchange.sendResponseHeaders(404, bytes.length); - try (OutputStream os = exchange.getResponseBody()) { - os.write(bytes); - } - return; - } - Path filePath = tempFileManager.getFilePath(entry); - if (!Files.exists(filePath)) { - tempFileManager.getEntry(key); - exchange.sendResponseHeaders(404, -1); - return; - } - long fileSize = Files.size(filePath); - exchange.getResponseHeaders().add("Content-Type", "video/mp4"); - exchange - .getResponseHeaders() - .add("Content-Disposition", "attachment; filename=\"" + entry.filename() + "\""); - exchange.sendResponseHeaders(200, fileSize); - try (InputStream is = Files.newInputStream(filePath); - OutputStream os = exchange.getResponseBody()) { - is.transferTo(os); - } - } catch (Exception e) { - LoggerDecorator.error(logger, "Ошибка ответа /d/", e); - try { - exchange.sendResponseHeaders(500, -1); - } catch (Exception ignored) { - } - } finally { - exchange.close(); - } - } - @Override public synchronized void shutdown() { if (server != null) { diff --git a/src/main/java/creator/loki/feature/metrics/HttpEndpoints.java b/src/main/java/creator/loki/feature/metrics/HttpEndpoints.java new file mode 100644 index 0000000..581288c --- /dev/null +++ b/src/main/java/creator/loki/feature/metrics/HttpEndpoints.java @@ -0,0 +1,111 @@ +package creator.loki.feature.metrics; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import com.sun.net.httpserver.HttpExchange; +import creator.loki.feature.tiktok.TempFile; +import creator.loki.feature.tiktok.TempFileManager; +import creator.loki.util.LoggerDecorator; +import org.slf4j.Logger; + +/** HTTP-обработчики для /health и /d/ эндпоинтов. */ +public class HttpEndpoints { + private final MetricsCollector metrics; + private final TempFileManager tempFileManager; + private final Logger logger; + + public HttpEndpoints(MetricsCollector metrics, TempFileManager tempFileManager, Logger logger) { + this.metrics = metrics; + this.tempFileManager = tempFileManager; + this.logger = logger; + } + + public void handleHealth(final HttpExchange exchange) { + try { + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + exchange.sendResponseHeaders(405, -1); + return; + } + if (!"/health".equals(exchange.getRequestURI().getPath())) { + exchange.sendResponseHeaders(404, -1); + return; + } + sendBytes( + exchange, + 200, + metrics.buildHealthJson().getBytes(StandardCharsets.UTF_8), + "application/json; charset=utf-8"); + } catch (Exception e) { + respondWithError(exchange, "Ошибка ответа /health", e); + } finally { + exchange.close(); + } + } + + public void handleDownload(final HttpExchange exchange) { + try { + if (!"GET".equalsIgnoreCase(exchange.getRequestMethod())) { + exchange.sendResponseHeaders(405, -1); + return; + } + String path = exchange.getRequestURI().getPath(); + String key = path.substring("/d/".length()); + if (key.isEmpty()) { + exchange.sendResponseHeaders(400, -1); + return; + } + TempFile entry = tempFileManager.getEntry(key); + if (entry == null) { + sendBytes( + exchange, + 404, + "File not found or expired".getBytes(StandardCharsets.UTF_8), + "text/plain; charset=utf-8"); + return; + } + Path filePath = tempFileManager.getFilePath(entry); + if (!Files.exists(filePath)) { + exchange.sendResponseHeaders(404, -1); + return; + } + long fileSize = Files.size(filePath); + exchange.getResponseHeaders().add("Content-Type", "video/mp4"); + exchange + .getResponseHeaders() + .add("Content-Disposition", "attachment; filename=\"" + entry.filename() + "\""); + exchange.sendResponseHeaders(200, fileSize); + try (InputStream is = Files.newInputStream(filePath); + OutputStream os = exchange.getResponseBody()) { + is.transferTo(os); + } + } catch (Exception e) { + respondWithError(exchange, "Ошибка ответа /d/", e); + } finally { + exchange.close(); + } + } + + private void sendBytes( + final HttpExchange exchange, final int status, final byte[] bytes, final String contentType) + throws IOException { + exchange.getResponseHeaders().add("Content-Type", contentType); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(bytes); + } + } + + private void respondWithError( + final HttpExchange exchange, final String message, final Exception e) { + LoggerDecorator.error(logger, message, e); + try { + exchange.sendResponseHeaders(500, -1); + } catch (Exception ignored) { + } + } +} diff --git a/src/main/java/creator/loki/feature/moderation/commands/punish/MuteArgs.java b/src/main/java/creator/loki/feature/moderation/commands/punish/MuteArgs.java new file mode 100644 index 0000000..94f6731 --- /dev/null +++ b/src/main/java/creator/loki/feature/moderation/commands/punish/MuteArgs.java @@ -0,0 +1,55 @@ +package creator.loki.feature.moderation.commands.punish; + +import java.util.List; + +import creator.loki.util.MuteDurationParser; + +final class MuteArgs { + private MuteArgs() {} + + record Parsed(MuteDurationParser.MuteDuration dur, String reason) {} + + static Parsed parse(final List args) { + String durToken = findDurationToken(args); + if (durToken == null) { + return null; + } + return new Parsed(MuteDurationParser.parse(durToken), extractReason(args, durToken)); + } + + private static String findDurationToken(final List args) { + for (String a : args) { + if (a.startsWith("<@")) { + continue; + } + try { + MuteDurationParser.parse(a); + return a; + } catch (IllegalArgumentException ignored) { + } + } + return null; + } + + private static String extractReason(final List args, final String durToken) { + boolean past = false; + var sb = new StringBuilder(); + for (String a : args) { + if (a.startsWith("<@")) { + continue; + } + if (!past) { + if (a.equals(durToken)) { + past = true; + } + continue; + } + if (!sb.isEmpty()) { + sb.append(' '); + } + sb.append(a); + } + String tmp = sb.toString().trim(); + return tmp.isEmpty() ? "Без причины" : tmp; + } +} diff --git a/src/main/java/creator/loki/feature/moderation/commands/punish/MuteCommand.java b/src/main/java/creator/loki/feature/moderation/commands/punish/MuteCommand.java index 398dbb5..ce68259 100644 --- a/src/main/java/creator/loki/feature/moderation/commands/punish/MuteCommand.java +++ b/src/main/java/creator/loki/feature/moderation/commands/punish/MuteCommand.java @@ -17,7 +17,6 @@ import net.dv8tion.jda.api.entities.Role; public class MuteCommand implements Command { private final MuteNotifier notifier; - private final ConfigRepository configRepo; private final MuteRoleService muteRoleService; private final MuteScheduler muteScheduler; @@ -28,7 +27,6 @@ public class MuteCommand implements Command { final MuteRoleService mrs, final MuteScheduler ms) { this.notifier = new MuteNotifier(c, a, cr, ms); - this.configRepo = cr; this.muteRoleService = mrs; this.muteScheduler = ms; } @@ -42,10 +40,13 @@ public class MuteCommand implements Command { public String getDescription() { return "Выдать мут на время или навсегда"; } + @Override public String getUsage() { return "mute <@user> <5m|1h|1d|perm> [причина]"; } + + @Override public String getCategory() { return CommandCategories.MODERATION; } @@ -71,8 +72,9 @@ public class MuteCommand implements Command { if (!validateMute(ctx, target, mod, g)) { return; } - var parsed = parseDurationAndReason(ctx); + var parsed = MuteArgs.parse(ctx.getArgs()); if (parsed == null) { + err(ctx, "Укажи длительность: `30s`, `5m`, `2h`, `1d` или `perm`"); return; } Role muteRole; @@ -86,20 +88,18 @@ public class MuteCommand implements Command { err(ctx, "Подними роль бота выше роли **Мут**"); return; } - long caseId = notifier.createMuteCase(g, target, mod, parsed.dur, parsed.reason); + long caseId = notifier.createMuteCase(g, target, mod, parsed.dur(), parsed.reason()); if (caseId < 0) { err(ctx, "Ошибка записи кейса"); return; } applyMuteRole( new MuteContext(ctx, g, target, muteRole, caseId), - new MuteDetails(parsed.dur, parsed.reason, mod)); + new MuteDetails(parsed.dur(), parsed.reason(), mod)); } + private boolean validateMute( - final CommandContext ctx, - final Member target, - final Member mod, - final Guild g) { + final CommandContext ctx, final Member target, final Member mod, final Guild g) { if (target.getId().equals(mod.getId())) { err(ctx, "Нельзя замутить себя"); return false; @@ -115,54 +115,6 @@ public class MuteCommand implements Command { return true; } - private record ParsedDuration(MuteDurationParser.MuteDuration dur, String reason) {} - private ParsedDuration parseDurationAndReason(final CommandContext ctx) { - String durToken = findDurationToken(ctx.getArgs()); - if (durToken == null) { - err(ctx, "Укажи длительность: `30s`, `5m`, `2h`, `1d` или `perm`"); - return null; - } - MuteDurationParser.MuteDuration dur = MuteDurationParser.parse(durToken); - String reason = extractReason(ctx.getArgs(), durToken); - return new ParsedDuration(dur, reason); - } - - private String findDurationToken(final java.util.List args) { - for (String a : args) { - if (a.startsWith("<@")) { - continue; - } - try { - MuteDurationParser.parse(a); - return a; - } catch (IllegalArgumentException ignored) { - } - } - return null; - } - - private String extractReason(final java.util.List args, final String durToken) { - boolean past = false; - var sb = new StringBuilder(); - for (String a : args) { - if (a.startsWith("<@")) { - continue; - } - if (!past) { - if (a.equals(durToken)) { - past = true; - } - continue; - } - if (!sb.isEmpty()) { - sb.append(' '); - } - sb.append(a); - } - String tmp = sb.toString().trim(); - return tmp.isEmpty() ? "Без причины" : tmp; - } - private record MuteContext( CommandContext ctx, Guild g, Member target, Role muteRole, long caseId) {} @@ -178,8 +130,7 @@ public class MuteCommand implements Command { var mod = md.mod(); var ctx = mc.ctx(); boolean permanent = dur.permanent(); - long secs = dur.secondsTotal(); - long untilMs = permanent ? 0L : System.currentTimeMillis() + secs * 1000; + long untilMs = permanent ? 0L : System.currentTimeMillis() + dur.secondsTotal() * 1000; g.addRoleToMember(target, muteRole) .queue( ok -> { @@ -190,10 +141,9 @@ public class MuteCommand implements Command { .queue(); notifier.sendMuteLogEmbed(g, target, mod, caseId, dur, reason, untilMs); }, - err2 -> { - err(ctx, "Ошибка выдачи роли"); - }); + err2 -> err(ctx, "Ошибка выдачи роли")); } + private static void err(final CommandContext ctx, final String msg) { ModerationHelper.error(ctx, msg); } diff --git a/src/main/java/creator/loki/feature/moderation/commands/punish/MuteNotifier.java b/src/main/java/creator/loki/feature/moderation/commands/punish/MuteNotifier.java index 8d1ef16..117ebfa 100644 --- a/src/main/java/creator/loki/feature/moderation/commands/punish/MuteNotifier.java +++ b/src/main/java/creator/loki/feature/moderation/commands/punish/MuteNotifier.java @@ -35,11 +35,7 @@ class MuteNotifier { } long createMuteCase( - Guild g, - Member target, - Member mod, - MuteDurationParser.MuteDuration dur, - String reason) { + Guild g, Member target, Member mod, MuteDurationParser.MuteDuration dur, String reason) { boolean permanent = dur.permanent(); long secs = dur.secondsTotal(); long untilMs = permanent ? 0L : System.currentTimeMillis() + secs * 1000; @@ -105,18 +101,11 @@ class MuteNotifier { new EmbedBuilder() .setTitle("🔇 Мут") .setColor(Color.DARK_GRAY) - .addField( - "Участник", target.getAsMention() + " (`" + target.getId() + "`)", false) + .addField("Участник", target.getAsMention() + " (`" + target.getId() + "`)", false) .addField("Модератор", mod.getAsMention(), false) .addField("Кейс", "`" + caseId + "`", true) - .addField( - "Срок", - permanent ? "Навсегда" : "", - true) - .addField( - "Причина", - ModerationHelper.trunc(reason, MAX_LOG_REASON_LENGTH), - false) + .addField("Срок", permanent ? "Навсегда" : "", true) + .addField("Причина", ModerationHelper.trunc(reason, MAX_LOG_REASON_LENGTH), false) .setTimestamp(OffsetDateTime.now()) .build()) .queue(); diff --git a/src/main/java/creator/loki/feature/moderation/commands/punish/UnmuteCommand.java b/src/main/java/creator/loki/feature/moderation/commands/punish/UnmuteCommand.java index 79c441b..6b37a6e 100644 --- a/src/main/java/creator/loki/feature/moderation/commands/punish/UnmuteCommand.java +++ b/src/main/java/creator/loki/feature/moderation/commands/punish/UnmuteCommand.java @@ -1,12 +1,8 @@ package creator.loki.feature.moderation.commands.punish; -import java.awt.Color; -import java.time.OffsetDateTime; - import creator.loki.command.Command; import creator.loki.command.CommandCategories; import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; import creator.loki.embed.EmbedTemplates; import creator.loki.feature.config.repository.ConfigRepository; import creator.loki.feature.config.repository.GuildConfigRepository; @@ -15,15 +11,14 @@ import creator.loki.feature.moderation.MuteScheduler; import creator.loki.feature.moderation.repository.ActiveMuteRepository; import creator.loki.feature.moderation.repository.ModerationCaseRepository; import creator.loki.feature.moderation.util.ModerationHelper; -import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.Permission; public class UnmuteCommand implements Command { private final ModerationCaseRepository cases; private final ActiveMuteRepository activeMutes; - private final ConfigRepository configRepo; private final GuildConfigRepository guildConfig; private final MuteScheduler muteScheduler; + private final UnmuteNotifier notifier; public UnmuteCommand( final ModerationCaseRepository c, @@ -33,9 +28,9 @@ public class UnmuteCommand implements Command { final MuteScheduler ms) { cases = c; activeMutes = a; - configRepo = cr; guildConfig = gc; muteScheduler = ms; + notifier = new UnmuteNotifier(cr); } @Override @@ -105,65 +100,10 @@ public class UnmuteCommand implements Command { long caseId = cases.insert(g.getId(), "UNMUTE", t.getId(), mod.getId(), reason, null, null); g.removeRoleFromMember(t, muteRole) .queue( - ok -> onMuteRemoved(ctx, g, t, mod, caseId, reason), + ok -> notifier.notifyMuteRemoved(ctx, g, t, mod, caseId, reason), err2 -> err(ctx, "Ошибка снятия роли")); } - private void onMuteRemoved( - final CommandContext ctx, - final net.dv8tion.jda.api.entities.Guild g, - final net.dv8tion.jda.api.entities.Member t, - final net.dv8tion.jda.api.entities.Member mod, - final long caseId, - final String reason) { - ctx.getChannel() - .sendMessageEmbeds( - EmbedFactory.create() - .withTitle("✅ Мут снят") - .withColor(Color.GREEN) - .withDescription( - "**С кого:** " - + t.getAsMention() - + (caseId > 0 ? "\n**Кейс:** `" + caseId + "`" : "") - + "\n**Причина:** " - + trunc(reason, 500)) - .build()) - .queue(); - sendModLog(g, t, mod, caseId, reason); - } - - private void sendModLog( - final net.dv8tion.jda.api.entities.Guild g, - final net.dv8tion.jda.api.entities.Member t, - final net.dv8tion.jda.api.entities.Member mod, - final long caseId, - final String reason) { - var logId = configRepo.get("moderation.log_channel_id"); - if (logId == null) { - return; - } - var log = g.getTextChannelById(logId); - if (log == null) { - return; - } - var eb = - new EmbedBuilder() - .setTitle("🔊 Размут") - .setColor(Color.GREEN) - .addField("Участник", t.getAsMention() + " (`" + t.getId() + "`)", false) - .addField("Модератор", mod.getAsMention(), false); - if (caseId > 0) { - eb.addField("Кейс", "`" + caseId + "`", true); - } - eb.addField("Причина", trunc(reason, 1000), false) - .setTimestamp(OffsetDateTime.now()); - log.sendMessageEmbeds(eb.build()).queue(s -> {}, e -> {}); - } - - private static String trunc(final String s, final int max) { - return ModerationHelper.trunc(s, max); - } - private static void err(final CommandContext ctx, final String msg) { ModerationHelper.error(ctx, msg); } diff --git a/src/main/java/creator/loki/feature/moderation/commands/punish/UnmuteNotifier.java b/src/main/java/creator/loki/feature/moderation/commands/punish/UnmuteNotifier.java new file mode 100644 index 0000000..68cadf7 --- /dev/null +++ b/src/main/java/creator/loki/feature/moderation/commands/punish/UnmuteNotifier.java @@ -0,0 +1,72 @@ +package creator.loki.feature.moderation.commands.punish; + +import java.awt.Color; +import java.time.OffsetDateTime; + +import creator.loki.command.CommandContext; +import creator.loki.embed.EmbedFactory; +import creator.loki.feature.config.repository.ConfigRepository; +import creator.loki.feature.moderation.util.ModerationHelper; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.MessageEmbed; + +class UnmuteNotifier { + private static final int MAX_REASON_LENGTH = 500; + private static final int MAX_LOG_REASON_LENGTH = 1000; + + private final ConfigRepository configRepo; + + UnmuteNotifier(ConfigRepository configRepo) { + this.configRepo = configRepo; + } + + void notifyMuteRemoved( + final CommandContext ctx, + final Guild g, + final Member t, + final Member mod, + final long caseId, + final String reason) { + ctx.getChannel().sendMessageEmbeds(buildConfirmEmbed(t, caseId, reason)).queue(); + sendModLog(g, t, mod, caseId, reason); + } + + private MessageEmbed buildConfirmEmbed(final Member t, final long caseId, final String reason) { + return EmbedFactory.create() + .withTitle("✅ Мут снят") + .withColor(Color.GREEN) + .withDescription( + "**С кого:** " + + t.getAsMention() + + (caseId > 0 ? "\n**Кейс:** `" + caseId + "`" : "") + + "\n**Причина:** " + + ModerationHelper.trunc(reason, MAX_REASON_LENGTH)) + .build(); + } + + private void sendModLog( + final Guild g, final Member t, final Member mod, final long caseId, final String reason) { + var logId = configRepo.get("moderation.log_channel_id"); + if (logId == null) { + return; + } + var log = g.getTextChannelById(logId); + if (log == null) { + return; + } + var eb = + new EmbedBuilder() + .setTitle("🔊 Размут") + .setColor(Color.GREEN) + .addField("Участник", t.getAsMention() + " (`" + t.getId() + "`)", false) + .addField("Модератор", mod.getAsMention(), false); + if (caseId > 0) { + eb.addField("Кейс", "`" + caseId + "`", true); + } + eb.addField("Причина", ModerationHelper.trunc(reason, MAX_LOG_REASON_LENGTH), false) + .setTimestamp(OffsetDateTime.now()); + log.sendMessageEmbeds(eb.build()).queue(s -> {}, e -> {}); + } +} diff --git a/src/main/java/creator/loki/feature/moderation/commands/warn/WarnAutoMute.java b/src/main/java/creator/loki/feature/moderation/commands/warn/WarnAutoMute.java index f709277..1ca3fac 100644 --- a/src/main/java/creator/loki/feature/moderation/commands/warn/WarnAutoMute.java +++ b/src/main/java/creator/loki/feature/moderation/commands/warn/WarnAutoMute.java @@ -52,8 +52,7 @@ class WarnAutoMute { String reason = "Авто-мут: 3+ варна"; long untilMs = System.currentTimeMillis() + AUTO_MUTE_HOURS * 3600 * 1000; long durSec = AUTO_MUTE_HOURS * 3600; - long caseId = - cases.insert(g.getId(), "MUTE", t.getId(), mod.getId(), reason, durSec, untilMs); + long caseId = cases.insert(g.getId(), "MUTE", t.getId(), mod.getId(), reason, durSec, untilMs); if (caseId < 0) { return; } diff --git a/src/main/java/creator/loki/feature/moderation/log/AuditLogs.java b/src/main/java/creator/loki/feature/moderation/log/AuditLogs.java new file mode 100644 index 0000000..9f2253c --- /dev/null +++ b/src/main/java/creator/loki/feature/moderation/log/AuditLogs.java @@ -0,0 +1,62 @@ +package creator.loki.feature.moderation.log; + +import java.util.List; +import java.util.function.BiConsumer; +import java.util.function.Function; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.audit.ActionType; +import net.dv8tion.jda.api.audit.AuditLogEntry; +import net.dv8tion.jda.api.entities.Guild; + +final class AuditLogs { + private AuditLogs() {} + + static AuditLogEntry findEntry(List l, String targetId, String... key) { + var s = l.stream().filter(e -> targetId.equals(e.getTargetId())); + if (key.length > 0) { + s = s.filter(e -> e.getChangeByKey(key[0]) != null); + } + return s.findFirst().orElse(null); + } + + static String mod(AuditLogEntry e) { + return e.getUser() != null ? e.getUser().getAsMention() : "Неизвестно"; + } + + static void sendForTarget( + Guild g, + ActionType type, + int limit, + String targetId, + Function embed, + BiConsumer send) { + g.retrieveAuditLogs() + .type(type) + .limit(limit) + .queue( + logs -> { + var entry = findEntry(logs, targetId); + if (entry == null) { + return; + } + send.accept(g, embed.apply(entry)); + }); + } + + static void sendFirst( + Guild g, + ActionType type, + int limit, + Function embed, + BiConsumer send) { + g.retrieveAuditLogs() + .type(type) + .limit(limit) + .queue( + logs -> { + var entry = logs.stream().findFirst().orElse(null); + send.accept(g, embed.apply(entry)); + }); + } +} diff --git a/src/main/java/creator/loki/feature/moderation/log/ModerationLogger.java b/src/main/java/creator/loki/feature/moderation/log/ModerationLogger.java index 46522a0..49c74be 100644 --- a/src/main/java/creator/loki/feature/moderation/log/ModerationLogger.java +++ b/src/main/java/creator/loki/feature/moderation/log/ModerationLogger.java @@ -1,7 +1,6 @@ package creator.loki.feature.moderation.log; import java.time.OffsetDateTime; -import java.util.List; import com.google.inject.Inject; import com.google.inject.Singleton; @@ -9,7 +8,6 @@ import creator.loki.core.MessageCache; import creator.loki.feature.config.repository.ConfigRepository; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.audit.ActionType; -import net.dv8tion.jda.api.audit.AuditLogEntry; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.events.guild.GuildBanEvent; import net.dv8tion.jda.api.events.guild.GuildUnbanEvent; @@ -52,120 +50,87 @@ public class ModerationLogger extends ListenerAdapter { @Override public void onMessageDelete(MessageDeleteEvent e) { var content = messageCache.getContent(e.getMessageId()); - var g = e.getGuild(); - g.retrieveAuditLogs() - .type(ActionType.MESSAGE_DELETE) - .limit(5) - .queue( - logs -> { - var entry = - logs.stream() - .filter(log -> e.getMessageId().equals(log.getTargetId())) - .findFirst() - .orElse(null); - send(g, LogEmbeds.messageDelete(entry, e.getChannel().getAsMention(), content)); - }); + AuditLogs.sendForTarget( + e.getGuild(), + ActionType.MESSAGE_DELETE, + 5, + e.getMessageId(), + entry -> LogEmbeds.messageDelete(entry, e.getChannel().getAsMention(), content), + this::send); } @Override public void onMessageBulkDelete(MessageBulkDeleteEvent e) { - var g = e.getGuild(); var size = e.getMessageIds().size(); - g.retrieveAuditLogs() - .type(ActionType.MESSAGE_BULK_DELETE) - .limit(3) - .queue( - logs -> { - var entry = logs.stream().findFirst().orElse(null); - send(g, LogEmbeds.messageBulkDelete(entry, e.getChannel().getAsMention(), size)); - }); + AuditLogs.sendFirst( + e.getGuild(), + ActionType.MESSAGE_BULK_DELETE, + 3, + entry -> LogEmbeds.messageBulkDelete(entry, e.getChannel().getAsMention(), size), + this::send); } @Override public void onGuildBan(GuildBanEvent ev) { - ev.getGuild() - .retrieveAuditLogs() - .type(ActionType.BAN) - .limit(5) - .queue( - l -> { - var entry = findEntry(l, ev.getUser().getId()); - if (entry == null) { - return; - } - send( - ev.getGuild(), - LogEmbeds.ban( - ev.getUser().getAsMention(), - mod(entry), - entry.getReason(), - ev.getUser().getAvatarUrl())); - }); + AuditLogs.sendForTarget( + ev.getGuild(), + ActionType.BAN, + 5, + ev.getUser().getId(), + entry -> + LogEmbeds.ban( + ev.getUser().getAsMention(), + AuditLogs.mod(entry), + entry.getReason(), + ev.getUser().getAvatarUrl()), + this::send); } @Override public void onGuildUnban(GuildUnbanEvent ev) { - ev.getGuild() - .retrieveAuditLogs() - .type(ActionType.UNBAN) - .limit(5) - .queue( - l -> { - var entry = findEntry(l, ev.getUser().getId()); - if (entry == null) { - return; - } - send( - ev.getGuild(), - LogEmbeds.unban( - ev.getUser().getAsMention(), mod(entry), ev.getUser().getAvatarUrl())); - }); + AuditLogs.sendForTarget( + ev.getGuild(), + ActionType.UNBAN, + 5, + ev.getUser().getId(), + entry -> + LogEmbeds.unban( + ev.getUser().getAsMention(), AuditLogs.mod(entry), ev.getUser().getAvatarUrl()), + this::send); } @Override public void onGuildMemberRemove(GuildMemberRemoveEvent ev) { - ev.getGuild() - .retrieveAuditLogs() - .type(ActionType.KICK) - .limit(5) - .queue( - l -> { - var entry = findEntry(l, ev.getUser().getId()); - if (entry == null) { - return; - } - send( - ev.getGuild(), - LogEmbeds.kick( - ev.getUser().getAsMention(), - mod(entry), - entry.getReason(), - ev.getUser().getAvatarUrl())); - }); + AuditLogs.sendForTarget( + ev.getGuild(), + ActionType.KICK, + 5, + ev.getUser().getId(), + entry -> + LogEmbeds.kick( + ev.getUser().getAsMention(), + AuditLogs.mod(entry), + entry.getReason(), + ev.getUser().getAvatarUrl()), + this::send); } @Override public void onGuildMemberUpdateTimeOut(GuildMemberUpdateTimeOutEvent e) { var muted = e.getNewTimeOutEnd() != null; - e.getGuild() - .retrieveAuditLogs() - .type(ActionType.MEMBER_UPDATE) - .limit(5) - .queue( - l -> { - var entry = findEntry(l, e.getUser().getId()); - if (entry == null) { - return; - } - send( - e.getGuild(), - LogEmbeds.timeout( - e.getUser().getAsMention(), - mod(entry), - muted, - muted ? e.getNewTimeOutEnd().toEpochSecond() : null, - entry.getReason())); - }); + AuditLogs.sendForTarget( + e.getGuild(), + ActionType.MEMBER_UPDATE, + 5, + e.getUser().getId(), + entry -> + LogEmbeds.timeout( + e.getUser().getAsMention(), + AuditLogs.mod(entry), + muted, + muted ? e.getNewTimeOutEnd().toEpochSecond() : null, + entry.getReason()), + this::send); } @Override @@ -177,16 +142,4 @@ public class ModerationLogger extends ListenerAdapter { public void onGuildVoiceGuildDeafen(GuildVoiceGuildDeafenEvent e) { voiceLogger.onGuildVoiceGuildDeafen(e); } - - static AuditLogEntry findEntry(List l, String targetId, String... key) { - var s = l.stream().filter(e -> targetId.equals(e.getTargetId())); - if (key.length > 0) { - s = s.filter(e -> e.getChangeByKey(key[0]) != null); - } - return s.findFirst().orElse(null); - } - - static String mod(AuditLogEntry e) { - return e.getUser() != null ? e.getUser().getAsMention() : "Неизвестно"; - } } diff --git a/src/main/java/creator/loki/feature/moderation/log/VoiceAuditLogger.java b/src/main/java/creator/loki/feature/moderation/log/VoiceAuditLogger.java index c37a8c8..c2c24aa 100644 --- a/src/main/java/creator/loki/feature/moderation/log/VoiceAuditLogger.java +++ b/src/main/java/creator/loki/feature/moderation/log/VoiceAuditLogger.java @@ -30,23 +30,23 @@ class VoiceAuditLogger { private EmbedBuilder voiceMute(GuildVoiceGuildMuteEvent e, boolean muted, List l) { var member = e.getMember(); - var entry = ModerationLogger.findEntry(l, member.getId(), "mute"); + var entry = AuditLogs.findEntry(l, member.getId(), "mute"); return LogEmbeds.voiceMute( member.getAsMention(), e.getVoiceState().getChannel().getAsMention(), muted, - entry != null ? ModerationLogger.mod(entry) : null); + entry != null ? AuditLogs.mod(entry) : null); } private EmbedBuilder voiceDeafen( GuildVoiceGuildDeafenEvent e, boolean deaf, List l) { var member = e.getMember(); - var entry = ModerationLogger.findEntry(l, member.getId(), "deaf"); + var entry = AuditLogs.findEntry(l, member.getId(), "deaf"); return LogEmbeds.voiceDeafen( member.getAsMention(), e.getVoiceState().getChannel().getAsMention(), deaf, - entry != null ? ModerationLogger.mod(entry) : null); + entry != null ? AuditLogs.mod(entry) : null); } private void handle( diff --git a/src/main/java/creator/loki/feature/music/MusicSlashCommands.java b/src/main/java/creator/loki/feature/music/MusicSlashCommands.java index ba682ab..d8f5fd9 100644 --- a/src/main/java/creator/loki/feature/music/MusicSlashCommands.java +++ b/src/main/java/creator/loki/feature/music/MusicSlashCommands.java @@ -30,22 +30,23 @@ public class MusicSlashCommands extends ListenerAdapter { this.playHandler = new PlaySlashHandler(playerManager); this.queueHandler = new QueueSlashHandler(playerManager); this.filterHandler = new FilterSlashHandler(playerManager); - this.commandMap = Map.ofEntries( - Map.entry("play", playHandler::handle), - Map.entry("stop", queueHandler::handleStop), - Map.entry("skip", queueHandler::handleSkip), - Map.entry("queue", queueHandler::handleQueue), - Map.entry("nowplaying", queueHandler::handleNowPlaying), - Map.entry("volume", queueHandler::handleVolume), - Map.entry("pause", queueHandler::handlePause), - Map.entry("resume", queueHandler::handleResume), - Map.entry("shuffle", queueHandler::handleShuffle), - Map.entry("repeat", queueHandler::handleRepeat), - Map.entry("bassboost", filterHandler::handleBassboost), - Map.entry("nightcore", filterHandler::handleNightcore), - Map.entry("vaporwave", filterHandler::handleVaporwave), - Map.entry("equalizer", filterHandler::handleEqualizer), - Map.entry("reset", filterHandler::handleReset)); + this.commandMap = + Map.ofEntries( + Map.entry("play", playHandler::handle), + Map.entry("stop", queueHandler::handleStop), + Map.entry("skip", queueHandler::handleSkip), + Map.entry("queue", queueHandler::handleQueue), + Map.entry("nowplaying", queueHandler::handleNowPlaying), + Map.entry("volume", queueHandler::handleVolume), + Map.entry("pause", queueHandler::handlePause), + Map.entry("resume", queueHandler::handleResume), + Map.entry("shuffle", queueHandler::handleShuffle), + Map.entry("repeat", queueHandler::handleRepeat), + Map.entry("bassboost", filterHandler::handleBassboost), + Map.entry("nightcore", filterHandler::handleNightcore), + Map.entry("vaporwave", filterHandler::handleVaporwave), + Map.entry("equalizer", filterHandler::handleEqualizer), + Map.entry("reset", filterHandler::handleReset)); } @Override diff --git a/src/main/java/creator/loki/feature/music/commands/BandCommand.java b/src/main/java/creator/loki/feature/music/commands/BandCommand.java new file mode 100644 index 0000000..dd57b29 --- /dev/null +++ b/src/main/java/creator/loki/feature/music/commands/BandCommand.java @@ -0,0 +1,96 @@ +package creator.loki.feature.music.commands; + +import creator.loki.command.Command; +import creator.loki.command.CommandCategories; +import creator.loki.command.CommandContext; +import creator.loki.embed.EmbedTemplates; +import creator.loki.feature.music.core.PlayerManager; + +final class BandCommand implements Command { + private final PlayerManager pm; + private final int mode; + + BandCommand(PlayerManager pm, int mode) { + this.pm = pm; + this.mode = mode; + } + + @Override + public String getName() { + return mode == 0 ? "bassboost" : mode == 1 ? "equalizer" : "reset"; + } + + @Override + public String getDescription() { + return mode == 0 + ? "Bassboost (-25 to 25)" + : mode == 1 ? "Set equalizer band (0-14, gain -0.25 to 1.0)" : "Reset all filters"; + } + + @Override + public String getUsage() { + return mode == 0 ? "bassboost " : mode == 1 ? "equalizer " : "reset"; + } + + @Override + public String getCategory() { + return CommandCategories.MUSIC; + } + + @Override + public void execute(final CommandContext ctx) { + var m = FilterCommands.mgr(pm, ctx); + if (!FilterCommands.checkChannel(m, ctx)) { + return; + } + if (mode == 2) { + m.getScheduler().resetFilters(); + ctx.getChannel() + .sendMessageEmbeds(EmbedTemplates.info("Фильтры", "Все фильтры сброшены.")) + .queue(); + return; + } + if (mode == 1 ? ctx.getArgs().size() < 2 : ctx.getArgs().isEmpty()) { + ctx.getChannel() + .sendMessageEmbeds( + mode == 1 + ? (ctx.getArgs().isEmpty() + ? EmbedTemplates.info( + "Equalizer", + "Использование: `" + + getUsage() + + "`\nБанды: 0-14, усиление: -0.25 до 1.0") + : EmbedTemplates.error("Аргументы", "Укажи band и gain")) + : EmbedTemplates.error("Аргументы", "Укажи уровень от -25 до 25")) + .queue(); + return; + } + try { + if (mode == 1) { + int band = Math.max(0, Math.min(14, Integer.parseInt(ctx.getArgs().get(0)))); + float gain = + (float) Math.max(-0.25, Math.min(1.0, Double.parseDouble(ctx.getArgs().get(1)))); + m.getScheduler().setEqualizerBand(band, gain); + ctx.getChannel() + .sendMessageEmbeds( + EmbedTemplates.info("Equalizer", "Band **" + band + "** → **" + gain + "**")) + .queue(); + } else { + int v = Math.max(-25, Math.min(25, Integer.parseInt(ctx.getArgs().get(0)))); + m.getScheduler().setBassboost(v); + ctx.getChannel() + .sendMessageEmbeds(EmbedTemplates.info("Bassboost", "Установлен **" + v + "**")) + .queue(); + } + } catch (NumberFormatException e) { + ctx.getChannel() + .sendMessageEmbeds( + EmbedTemplates.error( + "Аргументы", + mode == 1 + ? "Нужны числа: band (0-14) и gain (-0.25 до 1.0)." + : "Нужно число от -25 до 25.")) + .queue(); + } + } +} diff --git a/src/main/java/creator/loki/feature/music/commands/FilterCommands.java b/src/main/java/creator/loki/feature/music/commands/FilterCommands.java index 074d32a..f03643c 100644 --- a/src/main/java/creator/loki/feature/music/commands/FilterCommands.java +++ b/src/main/java/creator/loki/feature/music/commands/FilterCommands.java @@ -1,7 +1,6 @@ package creator.loki.feature.music.commands; import creator.loki.command.Command; -import creator.loki.command.CommandCategories; import creator.loki.command.CommandContext; import creator.loki.embed.EmbedTemplates; import creator.loki.feature.music.core.PlayerManager; @@ -30,11 +29,11 @@ public class FilterCommands { return new BandCommand(pm, 2); } - private static GuildMusicManager mgr(PlayerManager pm, final CommandContext ctx) { + static GuildMusicManager mgr(PlayerManager pm, final CommandContext ctx) { return pm.getMusicManager(ctx.getGuild().getIdLong()); } - private static boolean checkChannel(GuildMusicManager m, final CommandContext ctx) { + static boolean checkChannel(GuildMusicManager m, final CommandContext ctx) { if (!m.getLink().isEmpty()) { return true; } @@ -43,145 +42,4 @@ public class FilterCommands { .queue(); return false; } - - private static final class BandCommand implements Command { - private final PlayerManager pm; - private final int mode; - - BandCommand(PlayerManager pm, int mode) { - this.pm = pm; - this.mode = mode; - } - - @Override - public String getName() { - return mode == 0 ? "bassboost" : mode == 1 ? "equalizer" : "reset"; - } - - @Override - public String getDescription() { - return mode == 0 - ? "Bassboost (-25 to 25)" - : mode == 1 ? "Set equalizer band (0-14, gain -0.25 to 1.0)" : "Reset all filters"; - } - - @Override - public String getUsage() { - return mode == 0 ? "bassboost " : mode == 1 ? "equalizer " : "reset"; - } - - @Override - public String getCategory() { - return CommandCategories.MUSIC; - } - - @Override - public void execute(final CommandContext ctx) { - var m = mgr(pm, ctx); - if (!checkChannel(m, ctx)) { - return; - } - if (mode == 2) { - m.getScheduler().resetFilters(); - ctx.getChannel() - .sendMessageEmbeds(EmbedTemplates.info("Фильтры", "Все фильтры сброшены.")) - .queue(); - return; - } - if (mode == 1 ? ctx.getArgs().size() < 2 : ctx.getArgs().isEmpty()) { - ctx.getChannel() - .sendMessageEmbeds( - mode == 1 - ? (ctx.getArgs().isEmpty() - ? EmbedTemplates.info( - "Equalizer", - "Использование: `" - + getUsage() - + "`\nБанды: 0-14, усиление: -0.25 до 1.0") - : EmbedTemplates.error("Аргументы", "Укажи band и gain")) - : EmbedTemplates.error("Аргументы", "Укажи уровень от -25 до 25")) - .queue(); - return; - } - try { - if (mode == 1) { - int band = Math.max(0, Math.min(14, Integer.parseInt(ctx.getArgs().get(0)))); - float gain = - (float) Math.max(-0.25, Math.min(1.0, Double.parseDouble(ctx.getArgs().get(1)))); - m.getScheduler().setEqualizerBand(band, gain); - ctx.getChannel() - .sendMessageEmbeds( - EmbedTemplates.info("Equalizer", "Band **" + band + "** → **" + gain + "**")) - .queue(); - } else { - int v = Math.max(-25, Math.min(25, Integer.parseInt(ctx.getArgs().get(0)))); - m.getScheduler().setBassboost(v); - ctx.getChannel() - .sendMessageEmbeds(EmbedTemplates.info("Bassboost", "Установлен **" + v + "**")) - .queue(); - } - } catch (NumberFormatException e) { - ctx.getChannel() - .sendMessageEmbeds( - EmbedTemplates.error( - "Аргументы", - mode == 1 - ? "Нужны числа: band (0-14) и gain (-0.25 до 1.0)." - : "Нужно число от -25 до 25.")) - .queue(); - } - } - } - - private static final class ToggleCommand implements Command { - private final PlayerManager pm; - private final boolean isNight; - - ToggleCommand(PlayerManager pm, boolean isNight) { - this.pm = pm; - this.isNight = isNight; - } - - @Override - public String getName() { - return isNight ? "nightcore" : "vaporwave"; - } - - @Override - public String getDescription() { - return isNight ? "Toggle nightcore effect" : "Toggle vaporwave effect"; - } - - @Override - public String getUsage() { - return isNight ? "nightcore" : "vaporwave"; - } - - @Override - public String getCategory() { - return CommandCategories.MUSIC; - } - - @Override - public void execute(final CommandContext ctx) { - var m = mgr(pm, ctx); - if (!checkChannel(m, ctx)) { - return; - } - boolean enabled = - isNight - ? !m.getScheduler().filterState.isNightcore() - : !m.getScheduler().filterState.isVaporwave(); - if (isNight) { - m.getScheduler().setNightcore(enabled); - } else { - m.getScheduler().setVaporwave(enabled); - } - ctx.getChannel() - .sendMessageEmbeds( - EmbedTemplates.info( - isNight ? "Nightcore" : "Vaporwave", enabled ? "✅ Включён" : "❌ Выключен")) - .queue(); - } - } } diff --git a/src/main/java/creator/loki/feature/music/commands/LeaveCommand.java b/src/main/java/creator/loki/feature/music/commands/LeaveCommand.java new file mode 100644 index 0000000..15b8eb4 --- /dev/null +++ b/src/main/java/creator/loki/feature/music/commands/LeaveCommand.java @@ -0,0 +1,46 @@ +package creator.loki.feature.music.commands; + +import creator.loki.command.Command; +import creator.loki.command.CommandCategories; +import creator.loki.command.CommandContext; +import creator.loki.embed.EmbedTemplates; +import creator.loki.feature.music.core.PlayerManager; +import creator.loki.feature.music.util.GuildAudioActions; + +final class LeaveCommand implements Command { + private final PlayerManager playerManager; + + LeaveCommand(PlayerManager playerManager) { + this.playerManager = playerManager; + } + + @Override + public String getName() { + return "leave"; + } + + @Override + public String getDescription() { + return "Отключить бота от голосового канала"; + } + + @Override + public String getUsage() { + return "leave"; + } + + @Override + public String getCategory() { + return CommandCategories.MUSIC; + } + + @Override + public void execute(final CommandContext context) { + var guild = context.getGuild(); + GuildAudioActions.stopAndDisconnect(playerManager.getMusicManager(guild.getIdLong()), guild); + context + .getChannel() + .sendMessageEmbeds(EmbedTemplates.info("Отключён", "Бот отключён от голосового канала.")) + .queue(); + } +} diff --git a/src/main/java/creator/loki/feature/music/commands/PlayerCommands.java b/src/main/java/creator/loki/feature/music/commands/PlayerCommands.java index 8ad5ff3..84e1c7f 100644 --- a/src/main/java/creator/loki/feature/music/commands/PlayerCommands.java +++ b/src/main/java/creator/loki/feature/music/commands/PlayerCommands.java @@ -1,14 +1,9 @@ package creator.loki.feature.music.commands; import creator.loki.command.Command; -import creator.loki.command.CommandCategories; -import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedTemplates; import creator.loki.feature.music.core.PlayerManager; -import creator.loki.feature.music.manage.GuildMusicManager; public class PlayerCommands { - private PlayerCommands() {} public static Command skip(PlayerManager playerManager) { @@ -22,145 +17,4 @@ public class PlayerCommands { public static Command leave(PlayerManager playerManager) { return new LeaveCommand(playerManager); } - - private static final class SkipCommand implements Command { - private final PlayerManager playerManager; - - SkipCommand(PlayerManager playerManager) { - this.playerManager = playerManager; - } - - @Override - public String getName() { - return "skip"; - } - - @Override - public String getDescription() { - return "Пропустить текущий трек"; - } - - @Override - public String getUsage() { - return "skip [количество]"; - } - - @Override - public String getCategory() { - return CommandCategories.MUSIC; - } - - @Override - public void execute(final CommandContext context) { - var manager = getManager(context); - if (manager.getScheduler().getCurrentTrack() == null) { - context - .getChannel() - .sendMessageEmbeds(EmbedTemplates.error("Очередь пуста", "Сейчас ничего не играет.")) - .queue(); - return; - } - int amount = 1; - if (!context.getArgs().isEmpty()) { - try { - amount = Integer.parseInt(context.getArgs().get(0)); - } catch (NumberFormatException ignored) { - } - } - String skipped = manager.getScheduler().getCurrentTrack().getInfo().getTitle(); - for (int i = 0; i < amount - 1; i++) { - manager.getScheduler().nextTrack(); - } - manager.getScheduler().skip(); - context - .getChannel() - .sendMessageEmbeds(EmbedTemplates.info("Пропущено", "**" + skipped + "**")) - .queue(); - } - - private GuildMusicManager getManager(final CommandContext context) { - return playerManager.getMusicManager(context.getGuild().getIdLong()); - } - } - - private static final class StopCommand implements Command { - private final PlayerManager playerManager; - - StopCommand(PlayerManager playerManager) { - this.playerManager = playerManager; - } - - @Override - public String getName() { - return "stop"; - } - - @Override - public String getDescription() { - return "Остановить музыку и очистить очередь"; - } - - @Override - public String getUsage() { - return "stop"; - } - - @Override - public String getCategory() { - return CommandCategories.MUSIC; - } - - @Override - public void execute(final CommandContext context) { - var guild = context.getGuild(); - var manager = playerManager.getMusicManager(guild.getIdLong()); - manager.stop(); - guild.getJDA().getDirectAudioController().disconnect(guild); - context - .getChannel() - .sendMessageEmbeds( - EmbedTemplates.info("Остановлено", "Музыка остановлена, очередь очищена.")) - .queue(); - } - } - - private static final class LeaveCommand implements Command { - private final PlayerManager playerManager; - - LeaveCommand(PlayerManager playerManager) { - this.playerManager = playerManager; - } - - @Override - public String getName() { - return "leave"; - } - - @Override - public String getDescription() { - return "Отключить бота от голосового канала"; - } - - @Override - public String getUsage() { - return "leave"; - } - - @Override - public String getCategory() { - return CommandCategories.MUSIC; - } - - @Override - public void execute(final CommandContext context) { - var guild = context.getGuild(); - var manager = playerManager.getMusicManager(guild.getIdLong()); - manager.stop(); - guild.getJDA().getDirectAudioController().disconnect(guild); - context - .getChannel() - .sendMessageEmbeds(EmbedTemplates.info("Отключён", "Бот отключён от голосового канала.")) - .queue(); - } - } } diff --git a/src/main/java/creator/loki/feature/music/commands/QueueCommands.java b/src/main/java/creator/loki/feature/music/commands/QueueCommands.java index 2929af7..34c64c9 100644 --- a/src/main/java/creator/loki/feature/music/commands/QueueCommands.java +++ b/src/main/java/creator/loki/feature/music/commands/QueueCommands.java @@ -3,151 +3,75 @@ package creator.loki.feature.music.commands; import static creator.loki.embed.EmbedTemplates.error; import static creator.loki.embed.EmbedTemplates.info; -import java.awt.Color; -import java.util.function.BiConsumer; - import creator.loki.command.Command; import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; import creator.loki.feature.music.core.PlayerManager; +import creator.loki.feature.music.util.QueueView; +import creator.loki.feature.music.util.VolumeControl; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class QueueCommands { - private static final Color C = new Color(88, 101, 242); private static final Logger LOG = LoggerFactory.getLogger(QueueCommands.class); private QueueCommands() {} public static Command queue(final PlayerManager pm) { - return new Mc(pm, "queue", "Показать очередь треков", null, null, QueueCommands::executeQueue); + return new SimpleCommand( + pm, "queue", "Показать очередь треков", null, null, QueueCommands::executeQueue); } public static Command nowplaying(final PlayerManager pm) { - BiConsumer exec = QueueCommands::executeNowPlaying; - return new Mc(pm, "nowplaying", "Показать текущий трек", "nowplaying", "Музыка", exec); + return new SimpleCommand( + pm, + "nowplaying", + "Показать текущий трек", + "nowplaying", + "Музыка", + QueueCommands::executeNowPlaying); } public static Command volume(final PlayerManager pm) { - BiConsumer exec = QueueCommands::executeVolume; - return new Mc(pm, "volume", "Регулировка громкости (0-100)", "volume [0-100]", "Музыка", exec); - } - - private static final class Mc implements Command { - private final PlayerManager p; - private final String n; - private final String d; - private final String u; - private final String c; - private final BiConsumer e; - - Mc( - PlayerManager p, - String n, - String d, - String u, - String c, - BiConsumer e) { - this.p = p; - this.n = n; - this.d = d; - this.u = u; - this.c = c; - this.e = e; - } - - public String getName() { - return n; - } - - public String getDescription() { - return d; - } - - public String getUsage() { - return u != null ? u : n; - } - - public String getCategory() { - return c != null ? c : "Общие"; - } - - public void execute(final CommandContext ctx) { - e.accept(p, ctx); - } + return new SimpleCommand( + pm, + "volume", + "Регулировка громкости (0-100)", + "volume [0-100]", + "Музыка", + QueueCommands::executeVolume); } private static void executeQueue(final PlayerManager pm, final CommandContext ctx) { - var m = pm.getMusicManager(ctx.getGuild().getIdLong()); - var cur = m.getScheduler().getCurrentTrack(); - var q = m.getScheduler().getQueue(); - if (cur == null && q.isEmpty()) { + var embed = QueueView.queue(pm.getMusicManager(ctx.getGuild().getIdLong()).getScheduler()); + if (embed == null) { ctx.getChannel().sendMessage("Очередь пуста.").queue(); return; } - var embed = EmbedFactory.create().withTitle("Очередь треков").withColor(C); - if (cur != null) { - var i = cur.getInfo(); - embed.addField("Сейчас играет", "**" + i.getTitle() + "**\n" + i.getAuthor(), false); - } - if (!q.isEmpty()) { - var sb = new StringBuilder(); - var show = Math.min(q.size(), 10); - for (var i = 0; i < show; i++) { - sb.append(String.format("`%d.` **%s**\n", i + 1, q.get(i).getInfo().getTitle())); - } - if (q.size() > show) { - sb.append("... и ещё **").append(q.size() - show).append("** треков"); - } - embed.addField("В очереди (" + q.size() + ")", sb.toString(), false); - } - ctx.getChannel().sendMessageEmbeds(embed.build()).queue(); + ctx.getChannel().sendMessageEmbeds(embed).queue(); } private static void executeNowPlaying(final PlayerManager pm, final CommandContext ctx) { - var lm = pm.getLavalinkManager(); - var track = lm.getMusicManager(ctx.getGuild().getIdLong()).getScheduler().getCurrentTrack(); - if (track == null) { + var guildId = ctx.getGuild().getIdLong(); + var link = pm.getLavalinkManager().getClient().getLinkIfCached(guildId); + var embed = QueueView.nowPlaying(pm.getMusicManager(guildId).getScheduler(), link); + if (embed == null) { ctx.getChannel().sendMessage("Сейчас ничего не играет.").queue(); return; } - var l = lm.getClient().getLinkIfCached(ctx.getGuild().getIdLong()); - var pos = l != null && l.getCachedPlayer() != null ? l.getCachedPlayer().getPosition() : 0L; - var dur = track.getInfo().getLength(); - var ds = dur / 1000 <= 0 ? 1 : dur / 1000; - var filled = (int) ((double) pos / dur * 20); - var bar = new StringBuilder("\u2503"); - for (var i = 0; i < 20; i++) { - bar.append(i < filled ? "\u2588" : "\u2591"); - } - var t0 = String.format("%d:%02d", pos / 1000 / 60, pos / 1000 % 60); - var t1 = String.format("%d:%02d", ds / 60, ds % 60); - var pbar = bar + "\u2503 " + t0 + " / " + t1; - var msg = - EmbedFactory.create() - .withTitle("Сейчас играет") - .withColor(C) - .addField("Название", "**" + track.getInfo().getTitle() + "**", false) - .addField("Автор", track.getInfo().getAuthor(), true) - .addField("Длительность", pbar, true) - .build(); - ctx.getChannel().sendMessageEmbeds(msg).queue(); + ctx.getChannel().sendMessageEmbeds(embed).queue(); } private static void executeVolume(final PlayerManager pm, final CommandContext ctx) { - var l = pm.getLavalinkManager().getClient().getLinkIfCached(ctx.getGuild().getIdLong()); + var link = pm.getLavalinkManager().getClient().getLinkIfCached(ctx.getGuild().getIdLong()); if (ctx.getArgs().isEmpty()) { - var v = l != null && l.getCachedPlayer() != null ? l.getCachedPlayer().getVolume() : 100; - ctx.getChannel().sendMessageEmbeds(info("Громкость", "Текущая: **" + v + "%**")).queue(); + ctx.getChannel() + .sendMessageEmbeds(info("Громкость", "Текущая: **" + VolumeControl.current(link) + "%**")) + .queue(); return; } try { - var vol = Math.max(0, Math.min(100, Integer.parseInt(ctx.getArgs().get(0)))); - if (l != null && l.getCachedPlayer() != null) { - l.createOrUpdatePlayer() - .setVolume(vol) - .subscribe(null, err -> LOG.warn("Lavalink: {}", err.getMessage())); - } + var vol = VolumeControl.clamp(Integer.parseInt(ctx.getArgs().get(0))); + VolumeControl.set(link, vol, err -> LOG.warn("Lavalink: {}", err.getMessage())); ctx.getChannel() .sendMessageEmbeds(info("Громкость", "Установлена на **" + vol + "%**")) .queue(); diff --git a/src/main/java/creator/loki/feature/music/commands/SimpleCommand.java b/src/main/java/creator/loki/feature/music/commands/SimpleCommand.java new file mode 100644 index 0000000..2108043 --- /dev/null +++ b/src/main/java/creator/loki/feature/music/commands/SimpleCommand.java @@ -0,0 +1,56 @@ +package creator.loki.feature.music.commands; + +import java.util.function.BiConsumer; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.feature.music.core.PlayerManager; + +final class SimpleCommand implements Command { + private final PlayerManager p; + private final String n; + private final String d; + private final String u; + private final String c; + private final BiConsumer e; + + SimpleCommand( + PlayerManager p, + String n, + String d, + String u, + String c, + BiConsumer e) { + this.p = p; + this.n = n; + this.d = d; + this.u = u; + this.c = c; + this.e = e; + } + + @Override + public String getName() { + return n; + } + + @Override + public String getDescription() { + return d; + } + + @Override + public String getUsage() { + return u != null ? u : n; + } + + @Override + public String getCategory() { + return c != null ? c : "Общие"; + } + + @Override + public void execute(final CommandContext ctx) { + e.accept(p, ctx); + } +} diff --git a/src/main/java/creator/loki/feature/music/commands/SkipCommand.java b/src/main/java/creator/loki/feature/music/commands/SkipCommand.java new file mode 100644 index 0000000..324a1d5 --- /dev/null +++ b/src/main/java/creator/loki/feature/music/commands/SkipCommand.java @@ -0,0 +1,68 @@ +package creator.loki.feature.music.commands; + +import creator.loki.command.Command; +import creator.loki.command.CommandCategories; +import creator.loki.command.CommandContext; +import creator.loki.embed.EmbedTemplates; +import creator.loki.feature.music.core.PlayerManager; +import creator.loki.feature.music.manage.GuildMusicManager; + +final class SkipCommand implements Command { + private final PlayerManager playerManager; + + SkipCommand(PlayerManager playerManager) { + this.playerManager = playerManager; + } + + @Override + public String getName() { + return "skip"; + } + + @Override + public String getDescription() { + return "Пропустить текущий трек"; + } + + @Override + public String getUsage() { + return "skip [количество]"; + } + + @Override + public String getCategory() { + return CommandCategories.MUSIC; + } + + @Override + public void execute(final CommandContext context) { + var manager = getManager(context); + if (manager.getScheduler().getCurrentTrack() == null) { + context + .getChannel() + .sendMessageEmbeds(EmbedTemplates.error("Очередь пуста", "Сейчас ничего не играет.")) + .queue(); + return; + } + int amount = 1; + if (!context.getArgs().isEmpty()) { + try { + amount = Integer.parseInt(context.getArgs().get(0)); + } catch (NumberFormatException ignored) { + } + } + String skipped = manager.getScheduler().getCurrentTrack().getInfo().getTitle(); + for (int i = 0; i < amount - 1; i++) { + manager.getScheduler().nextTrack(); + } + manager.getScheduler().skip(); + context + .getChannel() + .sendMessageEmbeds(EmbedTemplates.info("Пропущено", "**" + skipped + "**")) + .queue(); + } + + private GuildMusicManager getManager(final CommandContext context) { + return playerManager.getMusicManager(context.getGuild().getIdLong()); + } +} diff --git a/src/main/java/creator/loki/feature/music/commands/StopCommand.java b/src/main/java/creator/loki/feature/music/commands/StopCommand.java new file mode 100644 index 0000000..4191212 --- /dev/null +++ b/src/main/java/creator/loki/feature/music/commands/StopCommand.java @@ -0,0 +1,47 @@ +package creator.loki.feature.music.commands; + +import creator.loki.command.Command; +import creator.loki.command.CommandCategories; +import creator.loki.command.CommandContext; +import creator.loki.embed.EmbedTemplates; +import creator.loki.feature.music.core.PlayerManager; +import creator.loki.feature.music.util.GuildAudioActions; + +final class StopCommand implements Command { + private final PlayerManager playerManager; + + StopCommand(PlayerManager playerManager) { + this.playerManager = playerManager; + } + + @Override + public String getName() { + return "stop"; + } + + @Override + public String getDescription() { + return "Остановить музыку и очистить очередь"; + } + + @Override + public String getUsage() { + return "stop"; + } + + @Override + public String getCategory() { + return CommandCategories.MUSIC; + } + + @Override + public void execute(final CommandContext context) { + var guild = context.getGuild(); + GuildAudioActions.stopAndDisconnect(playerManager.getMusicManager(guild.getIdLong()), guild); + context + .getChannel() + .sendMessageEmbeds( + EmbedTemplates.info("Остановлено", "Музыка остановлена, очередь очищена.")) + .queue(); + } +} diff --git a/src/main/java/creator/loki/feature/music/commands/ToggleCommand.java b/src/main/java/creator/loki/feature/music/commands/ToggleCommand.java new file mode 100644 index 0000000..be6d30e --- /dev/null +++ b/src/main/java/creator/loki/feature/music/commands/ToggleCommand.java @@ -0,0 +1,59 @@ +package creator.loki.feature.music.commands; + +import creator.loki.command.Command; +import creator.loki.command.CommandCategories; +import creator.loki.command.CommandContext; +import creator.loki.embed.EmbedTemplates; +import creator.loki.feature.music.core.PlayerManager; + +final class ToggleCommand implements Command { + private final PlayerManager pm; + private final boolean isNight; + + ToggleCommand(PlayerManager pm, boolean isNight) { + this.pm = pm; + this.isNight = isNight; + } + + @Override + public String getName() { + return isNight ? "nightcore" : "vaporwave"; + } + + @Override + public String getDescription() { + return isNight ? "Toggle nightcore effect" : "Toggle vaporwave effect"; + } + + @Override + public String getUsage() { + return isNight ? "nightcore" : "vaporwave"; + } + + @Override + public String getCategory() { + return CommandCategories.MUSIC; + } + + @Override + public void execute(final CommandContext ctx) { + var m = FilterCommands.mgr(pm, ctx); + if (!FilterCommands.checkChannel(m, ctx)) { + return; + } + boolean enabled = + isNight + ? !m.getScheduler().filterState.isNightcore() + : !m.getScheduler().filterState.isVaporwave(); + if (isNight) { + m.getScheduler().setNightcore(enabled); + } else { + m.getScheduler().setVaporwave(enabled); + } + ctx.getChannel() + .sendMessageEmbeds( + EmbedTemplates.info( + isNight ? "Nightcore" : "Vaporwave", enabled ? "✅ Включён" : "❌ Выключен")) + .queue(); + } +} diff --git a/src/main/java/creator/loki/feature/music/core/LavalinkEventRouter.java b/src/main/java/creator/loki/feature/music/core/LavalinkEventRouter.java new file mode 100644 index 0000000..a2b545d --- /dev/null +++ b/src/main/java/creator/loki/feature/music/core/LavalinkEventRouter.java @@ -0,0 +1,68 @@ +package creator.loki.feature.music.core; + +import java.util.Map; + +import creator.loki.feature.music.manage.GuildMusicManager; +import creator.loki.util.LoggerDecorator; +import dev.arbjerg.lavalink.client.LavalinkClient; +import dev.arbjerg.lavalink.client.event.ReadyEvent; +import dev.arbjerg.lavalink.client.event.StatsEvent; +import dev.arbjerg.lavalink.client.event.TrackEndEvent; +import dev.arbjerg.lavalink.client.event.TrackStartEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class LavalinkEventRouter { + private static final Logger logger = LoggerFactory.getLogger(LavalinkEventRouter.class); + + private final LavalinkClient client; + private final Map musicManagers; + + LavalinkEventRouter(LavalinkClient client, Map musicManagers) { + this.client = client; + this.musicManagers = musicManagers; + } + + void register() { + client + .on(ReadyEvent.class) + .subscribe( + event -> + LoggerDecorator.music( + logger, + "Нода '" + + event.getNode().getName() + + "' готова, sessionId: " + + event.getSessionId())); + + client + .on(StatsEvent.class) + .subscribe( + event -> + logger.trace( + "Нода '{}' статистика: игроков {}/{}", + event.getNode().getName(), + event.getPlayingPlayers(), + event.getPlayers())); + + client + .on(TrackStartEvent.class) + .subscribe( + event -> { + GuildMusicManager manager = musicManagers.get(event.getGuildId()); + if (manager != null) { + manager.cancelAutoLeaveTimer(); + } + }); + + client + .on(TrackEndEvent.class) + .subscribe( + event -> { + GuildMusicManager manager = musicManagers.get(event.getGuildId()); + if (manager != null) { + manager.getScheduler().onTrackEnd(event.getTrack(), event.getEndReason()); + } + }); + } +} diff --git a/src/main/java/creator/loki/feature/music/core/LavalinkManager.java b/src/main/java/creator/loki/feature/music/core/LavalinkManager.java index 2438650..232f578 100644 --- a/src/main/java/creator/loki/feature/music/core/LavalinkManager.java +++ b/src/main/java/creator/loki/feature/music/core/LavalinkManager.java @@ -15,10 +15,6 @@ import dev.arbjerg.lavalink.client.Helpers; import dev.arbjerg.lavalink.client.LavalinkClient; import dev.arbjerg.lavalink.client.Link; import dev.arbjerg.lavalink.client.NodeOptions; -import dev.arbjerg.lavalink.client.event.ReadyEvent; -import dev.arbjerg.lavalink.client.event.StatsEvent; -import dev.arbjerg.lavalink.client.event.TrackEndEvent; -import dev.arbjerg.lavalink.client.event.TrackStartEvent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -58,57 +54,12 @@ public class LavalinkManager { .setHttpTimeout(30000L) .build()); - registerListeners(); + new LavalinkEventRouter(client, musicManagers).register(); LoggerDecorator.music( logger, "LavalinkManager инициализирован, нода: " + lavalinkConfig.getWsUri()); } - private void registerListeners() { - client - .on(ReadyEvent.class) - .subscribe( - event -> { - LoggerDecorator.music( - logger, - "Нода '" - + event.getNode().getName() - + "' готова, sessionId: " - + event.getSessionId()); - }); - - client - .on(StatsEvent.class) - .subscribe( - event -> { - logger.trace( - "Нода '{}' статистика: игроков {}/{}", - event.getNode().getName(), - event.getPlayingPlayers(), - event.getPlayers()); - }); - - client - .on(TrackStartEvent.class) - .subscribe( - event -> { - GuildMusicManager manager = musicManagers.get(event.getGuildId()); - if (manager != null) { - manager.cancelAutoLeaveTimer(); - } - }); - - client - .on(TrackEndEvent.class) - .subscribe( - event -> { - GuildMusicManager manager = musicManagers.get(event.getGuildId()); - if (manager != null) { - manager.getScheduler().onTrackEnd(event.getTrack(), event.getEndReason()); - } - }); - } - public LavalinkClient getClient() { return client; } diff --git a/src/main/java/creator/loki/feature/music/listener/MessagePlayResponder.java b/src/main/java/creator/loki/feature/music/listener/MessagePlayResponder.java new file mode 100644 index 0000000..3087b85 --- /dev/null +++ b/src/main/java/creator/loki/feature/music/listener/MessagePlayResponder.java @@ -0,0 +1,88 @@ +package creator.loki.feature.music.listener; + +import java.util.List; + +import creator.loki.feature.music.manage.GuildMusicManager; +import creator.loki.feature.music.play.TrackLoader; +import creator.loki.feature.music.util.MusicEmbeds; +import creator.loki.feature.music.util.TrackFormatter; +import dev.arbjerg.lavalink.client.player.LoadFailed; +import dev.arbjerg.lavalink.client.player.PlaylistLoaded; +import dev.arbjerg.lavalink.client.player.Track; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; + +final class MessagePlayResponder implements TrackLoader.LoadCallbacks { + private static final int GREEN = 0x00FF00; + + private final MessageChannel ch; + private final GuildMusicManager manager; + private final String query; + + MessagePlayResponder(MessageChannel ch, GuildMusicManager manager, String query) { + this.ch = ch; + this.manager = manager; + this.query = query; + } + + @Override + public void onTrackLoaded(final Track track) { + manager.getScheduler().enqueue(track); + send(trackAdded(track)); + } + + @Override + public void onPlaylistLoaded(final PlaylistLoaded r) { + for (Track t : r.getTracks()) { + manager.getScheduler().enqueue(t); + } + send(playlistAdded(r)); + } + + @Override + public void onSearchResults(final List tracks) { + if (tracks.isEmpty()) { + onNoMatches(); + return; + } + Track t = tracks.get(0); + manager.getScheduler().enqueue(t); + send(trackAdded(t)); + } + + @Override + public void onNoMatches() { + send(MusicEmbeds.err("Ничего не найдено: " + query)); + } + + @Override + public void onLoadFailed(final LoadFailed e) { + send( + MusicEmbeds.err( + "Ошибка: " + + (e.getException().getMessage() != null + ? e.getException().getMessage() + : "неизвестная"))); + } + + private EmbedBuilder trackAdded(final Track t) { + var i = t.getInfo(); + return MusicEmbeds.ok("✅ Добавлено в очередь", "**[" + i.getTitle() + "](" + i.getUri() + ")**") + .addField("Исполнитель", i.getAuthor(), true) + .addField("Длительность", TrackFormatter.formatDuration(i.getLength()), true); + } + + private EmbedBuilder playlistAdded(final PlaylistLoaded pl) { + long total = pl.getTracks().stream().mapToLong(t -> t.getInfo().getLength()).sum(); + return new EmbedBuilder() + .setColor(GREEN) + .setTitle("📀 Плейлист добавлен") + .setDescription("**" + pl.getInfo().getName() + "**") + .addField("Треков", String.valueOf(pl.getTracks().size()), true) + .addField("Длительность", TrackFormatter.formatDuration(total), true); + } + + private void send(final EmbedBuilder eb) { + ch.sendMessageEmbeds(eb.build()).queue(); + } +} diff --git a/src/main/java/creator/loki/feature/music/listener/MusicBotControlHandler.java b/src/main/java/creator/loki/feature/music/listener/MusicBotControlHandler.java index a7e6c35..639c59d 100644 --- a/src/main/java/creator/loki/feature/music/listener/MusicBotControlHandler.java +++ b/src/main/java/creator/loki/feature/music/listener/MusicBotControlHandler.java @@ -1,6 +1,9 @@ package creator.loki.feature.music.listener; import creator.loki.feature.music.core.LavalinkManager; +import creator.loki.feature.music.util.GuildAudioActions; +import creator.loki.feature.music.util.MusicEmbeds; +import creator.loki.feature.music.util.VoiceCheck; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Member; @@ -22,26 +25,25 @@ public class MusicBotControlHandler { } public void handleSkip(final MessageChannel ch, final Guild guild, final Member member) { - if (reqVoice(ch, member) == null) { + if (VoiceCheck.requireChannel(ch, member) == null) { return; } var s = lavalinkManager.getMusicManager(guild.getIdLong()).getScheduler(); if (s.getCurrentTrack() == null) { - send(ch, err("Нечего пропускать.")); + send(ch, MusicEmbeds.err("Нечего пропускать.")); return; } var t = s.getCurrentTrack().getInfo().getTitle(); s.skip(); - send(ch, ok("⏭ Пропущено", "**" + t + "**")); + send(ch, MusicEmbeds.ok("⏭ Пропущено", "**" + t + "**")); } public void handleStop(final MessageChannel ch, final Guild guild, final Member member) { - if (reqVoice(ch, member) == null) { + if (VoiceCheck.requireChannel(ch, member) == null) { return; } - lavalinkManager.getMusicManager(guild.getIdLong()).stop(); - guild.getJDA().getDirectAudioController().disconnect(guild); - send(ch, ok(ERR, "⏹ Остановлено", "Музыка остановлена, очередь очищена.")); + GuildAudioActions.stopAndDisconnect(lavalinkManager.getMusicManager(guild.getIdLong()), guild); + send(ch, MusicEmbeds.ok(ERR, "⏹ Остановлено", "Музыка остановлена, очередь очищена.")); } public void handleQueue(final MessageChannel ch, final Guild guild) { @@ -49,7 +51,7 @@ public class MusicBotControlHandler { var cur = s.getCurrentTrack(); var q = s.getQueue(); if (cur == null && q.isEmpty()) { - send(ch, err("Очередь пуста.")); + send(ch, MusicEmbeds.err("Очередь пуста.")); return; } var sb = new StringBuilder(); @@ -69,31 +71,31 @@ public class MusicBotControlHandler { sb.append("... и ещё **").append(q.size() - show).append("** треков"); } } - send(ch, ok("📜 Очередь", sb.toString())); + send(ch, MusicEmbeds.ok("📜 Очередь", sb.toString())); } public void handleNowPlaying(final MessageChannel ch, final Guild guild) { var t = lavalinkManager.getMusicManager(guild.getIdLong()).getScheduler().getCurrentTrack(); if (t == null) { - send(ch, err("Сейчас ничего не играет.")); + send(ch, MusicEmbeds.err("Сейчас ничего не играет.")); return; } send( ch, - ok("🎵 Сейчас играет", "**" + t.getInfo().getTitle() + "**\n" + t.getInfo().getAuthor())); + MusicEmbeds.ok( + "🎵 Сейчас играет", "**" + t.getInfo().getTitle() + "**\n" + t.getInfo().getAuthor())); } public void handleLeave(final MessageChannel ch, final Guild guild, final Member member) { - if (reqVoice(ch, member) == null) { + if (VoiceCheck.requireChannel(ch, member) == null) { return; } - lavalinkManager.getMusicManager(guild.getIdLong()).stop(); - guild.getJDA().getDirectAudioController().disconnect(guild); - send(ch, ok(ERR, "👋 Отключился", "Вышел из голосового канала.")); + GuildAudioActions.stopAndDisconnect(lavalinkManager.getMusicManager(guild.getIdLong()), guild); + send(ch, MusicEmbeds.ok(ERR, "👋 Отключился", "Вышел из голосового канала.")); } public void handleRepeat(final MessageChannel ch, final Guild guild, final Member member) { - if (reqVoice(ch, member) == null) { + if (VoiceCheck.requireChannel(ch, member) == null) { return; } var s = lavalinkManager.getMusicManager(guild.getIdLong()).getScheduler(); @@ -101,45 +103,25 @@ public class MusicBotControlHandler { s.setRepeating(now); send( ch, - ok( + MusicEmbeds.ok( now ? "🔁 Повтор включён" : "➡ Повтор выключен", now ? "Очередь будет повторяться." : "Повтор отключён.")); } public void handleShuffle(final MessageChannel ch, final Guild guild, final Member member) { - if (reqVoice(ch, member) == null) { + if (VoiceCheck.requireChannel(ch, member) == null) { return; } var s = lavalinkManager.getMusicManager(guild.getIdLong()).getScheduler(); if (s.getQueue().isEmpty()) { - send(ch, err("Очередь пуста.")); + send(ch, MusicEmbeds.err("Очередь пуста.")); return; } s.shuffle(); - send(ch, ok("🔀 Перемешано", "Очередь перемешана.")); - } - - private Member reqVoice(final MessageChannel ch, final Member member) { - if (member != null && member.getVoiceState().inAudioChannel()) { - return member; - } - send(ch, err("Зайди в голосовой канал сначала.")); - return null; + send(ch, MusicEmbeds.ok("🔀 Перемешано", "Очередь перемешана.")); } private void send(final MessageChannel ch, final EmbedBuilder eb) { ch.sendMessageEmbeds(eb.build()).queue(); } - - private static EmbedBuilder ok(final String title, final String desc) { - return new EmbedBuilder().setColor(0x00AAFF).setTitle(title).setDescription(desc); - } - - private static EmbedBuilder ok(final int color, final String title, final String desc) { - return new EmbedBuilder().setColor(color).setTitle(title).setDescription(desc); - } - - private static EmbedBuilder err(final String msg) { - return new EmbedBuilder().setColor(ERR).setDescription("❌ " + msg); - } } diff --git a/src/main/java/creator/loki/feature/music/listener/MusicBotListener.java b/src/main/java/creator/loki/feature/music/listener/MusicBotListener.java index 4d8b662..9d327bd 100644 --- a/src/main/java/creator/loki/feature/music/listener/MusicBotListener.java +++ b/src/main/java/creator/loki/feature/music/listener/MusicBotListener.java @@ -1,15 +1,11 @@ package creator.loki.feature.music.listener; -import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.function.Consumer; import creator.loki.feature.music.core.LavalinkManager; +import creator.loki.feature.music.core.PlayerManager; import creator.loki.feature.music.repository.QueueRepository; -import net.dv8tion.jda.api.EmbedBuilder; -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; import org.jetbrains.annotations.NotNull; @@ -17,58 +13,15 @@ import org.jetbrains.annotations.NotNull; public class MusicBotListener extends ListenerAdapter { private static final String PREFIX = "m!"; - private final MusicBotPlayHandler playHandler; - private final MusicBotControlHandler controlHandler; private final Map> commandMap; public MusicBotListener( final LavalinkManager lavalinkManager, final QueueRepository queueRepository, - final creator.loki.feature.music.core.PlayerManager playerManager) { - this.playHandler = new MusicBotPlayHandler(lavalinkManager); - this.controlHandler = new MusicBotControlHandler(lavalinkManager); - this.commandMap = buildCommandMap(); - } - - private Map> buildCommandMap() { - var map = new HashMap>(); - map.put("play", ev -> playHandler.handlePlay(ev.getChannel(), ev.getGuild(), ev.getMember(), parseArgs(ev))); - map.put("skip", ev -> controlHandler.handleSkip(ev.getChannel(), ev.getGuild(), ev.getMember())); - map.put("stop", ev -> controlHandler.handleStop(ev.getChannel(), ev.getGuild(), ev.getMember())); - map.put("queue", ev -> controlHandler.handleQueue(ev.getChannel(), ev.getGuild())); - map.put("nowplaying", ev -> controlHandler.handleNowPlaying(ev.getChannel(), ev.getGuild())); - map.put("np", ev -> controlHandler.handleNowPlaying(ev.getChannel(), ev.getGuild())); - map.put("volume", ev -> controlHandler.getFilterHandler() - .handleVolume(ev.getChannel(), ev.getGuild(), ev.getMember(), parseArgs(ev))); - map.put("leave", ev -> controlHandler - .handleLeave(ev.getChannel(), ev.getGuild(), ev.getMember())); - map.put("bassboost", ev -> controlHandler.getFilterHandler() - .handleBassboost(ev.getChannel(), ev.getGuild(), ev.getMember(), parseArgs(ev))); - map.put("nightcore", ev -> controlHandler.getFilterHandler() - .handleNightcore(ev.getChannel(), ev.getGuild(), ev.getMember())); - map.put("vaporwave", ev -> controlHandler.getFilterHandler() - .handleVaporwave(ev.getChannel(), ev.getGuild(), ev.getMember())); - map.put("reset", ev -> controlHandler.getFilterHandler() - .handleReset(ev.getChannel(), ev.getGuild(), ev.getMember())); - map.put("equalizer", ev -> controlHandler.getFilterHandler() - .handleEqualizer(ev.getChannel(), ev.getGuild(), ev.getMember(), parseArgs(ev))); - map.put("repeat", ev -> controlHandler - .handleRepeat(ev.getChannel(), ev.getGuild(), ev.getMember())); - map.put("loop", ev -> controlHandler - .handleRepeat(ev.getChannel(), ev.getGuild(), ev.getMember())); - map.put("shuffle", ev -> controlHandler - .handleShuffle(ev.getChannel(), ev.getGuild(), ev.getMember())); - map.put("help", ev -> help(ev.getChannel())); - map.put("commands", ev -> help(ev.getChannel())); - return map; - } - - private static List parseArgs(MessageReceivedEvent event) { - String content = event.getMessage().getContentRaw(); - String[] parts = content.substring(PREFIX.length()).trim().split("\\s+"); - return parts.length > 1 - ? List.of(java.util.Arrays.copyOfRange(parts, 1, parts.length)) - : List.of(); + final PlayerManager playerManager) { + var playHandler = new MusicBotPlayHandler(lavalinkManager); + var controlHandler = new MusicBotControlHandler(lavalinkManager); + this.commandMap = MusicCommandMap.build(playHandler, controlHandler); } @Override @@ -81,43 +34,13 @@ public class MusicBotListener extends ListenerAdapter { return; } String[] parts = content.substring(PREFIX.length()).trim().split("\\s+"); - if (parts.length == 0) { + if (parts.length == 0 || event.getGuild() == null) { return; } String cmd = parts[0].toLowerCase(); - Guild guild = event.getGuild(); - if (guild == null) { - return; - } Consumer handler = commandMap.get(cmd); if (handler != null) { handler.accept(event); } } - - private void help(final MessageChannel ch) { - ch.sendMessageEmbeds( - new EmbedBuilder() - .setColor(0x00AAFF) - .setTitle("🎵 Music Bot") - .setDescription( - """ - `m!play <запрос/URL>` — Искать и играть - `m!skip` — Пропустить - `m!stop` — Остановить - `m!queue` — Очередь - `m!nowplaying` — Что играет - `m!volume [0-100]` — Громкость - `m!shuffle` — Перемешать - `m!repeat` — Повтор - `m!bassboost [-25..25]` — Басс - `m!nightcore` — Nightcore ⚡ - `m!vaporwave` — Vaporwave 🌊 - `m!equalizer ` — Эквалайзер - `m!reset` — Сбросить фильтры - """) - .setFooter("Jockie Music style", null) - .build()) - .queue(); - } } diff --git a/src/main/java/creator/loki/feature/music/listener/MusicBotPlayHandler.java b/src/main/java/creator/loki/feature/music/listener/MusicBotPlayHandler.java index 6864d9f..c9297d1 100644 --- a/src/main/java/creator/loki/feature/music/listener/MusicBotPlayHandler.java +++ b/src/main/java/creator/loki/feature/music/listener/MusicBotPlayHandler.java @@ -5,9 +5,10 @@ import java.util.List; import creator.loki.feature.music.core.LavalinkManager; import creator.loki.feature.music.manage.GuildMusicManager; import creator.loki.feature.music.play.TrackLoader; -import dev.arbjerg.lavalink.client.Link; -import dev.arbjerg.lavalink.client.player.PlaylistLoaded; -import dev.arbjerg.lavalink.client.player.Track; +import creator.loki.feature.music.util.MusicEmbeds; +import creator.loki.feature.music.util.TrackQuery; +import creator.loki.feature.music.util.VoiceCheck; +import creator.loki.feature.utility.VoiceHelper; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Member; @@ -24,158 +25,21 @@ public class MusicBotPlayHandler { public void handlePlay( final MessageChannel ch, final Guild guild, Member member, final List args) { - if (reqVoice(ch, member) == null) { + if (VoiceCheck.requireChannel(ch, member) == null) { return; } if (args.isEmpty()) { - send(ch, err("Укажи URL или запрос: `m!play <название>`")); - return; - } - try { - Thread.startVirtualThread(() -> connect(guild, member, lavalinkManager)).join(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); + send(ch, MusicEmbeds.err("Укажи URL или запрос: `m!play <название>`")); return; } String query = String.join(" ", args); - GuildMusicManager m = mgr(guild); - String id = resolveIdentifier(query); - - trackLoader.loadItem( - guild.getIdLong(), - id, - new TrackLoader.LoadCallbacks() { - @Override - public void onTrackLoaded(final Track track) { - m.getScheduler().enqueue(track); - send(ch, trackAdded(track)); - } - - @Override - public void onPlaylistLoaded(final PlaylistLoaded r) { - for (Track t : r.getTracks()) { - m.getScheduler().enqueue(t); - } - send(ch, playlistAdded(r)); - } - - @Override - public void onSearchResults(final List tracks) { - if (tracks.isEmpty()) { - onNoMatches(); - return; - } - Track t = tracks.get(0); - m.getScheduler().enqueue(t); - send(ch, trackAdded(t)); - } - - @Override - public void onNoMatches() { - send(ch, err("Ничего не найдено: " + query)); - } - - @Override - public void onLoadFailed(final dev.arbjerg.lavalink.client.player.LoadFailed e) { - send( - ch, - err( - "Ошибка: " - + (e.getException().getMessage() != null - ? e.getException().getMessage() - : "неизвестная"))); - } - }); - } - - private Member reqVoice(final MessageChannel ch, Member member) { - if (member == null || !member.getVoiceState().inAudioChannel()) { - send(ch, err("Зайди в голосовой канал сначала.")); - return null; - } - return member; - } - - private GuildMusicManager mgr(final Guild g) { - return lavalinkManager.getMusicManager(g.getIdLong()); - } - - private static String resolveIdentifier(final String query) { - if (query.startsWith("http://") || query.startsWith("https://")) { - return query; - } - return "ytsearch:" + query; - } - - private void connect(final Guild g, final Member m, final LavalinkManager lm) { - if (g.getMember(g.getJDA().getSelfUser()).getVoiceState().inAudioChannel()) { - return; - } - var vc = m.getVoiceState().getChannel().asVoiceChannel(); - g.getJDA().getDirectAudioController().connect(vc); - - if (!g.getAudioManager().isConnected()) { - g.getAudioManager().openAudioConnection(vc); - } - - for (int i = 0; i < 10; i++) { - if (g.getMember(g.getJDA().getSelfUser()).getVoiceState().inAudioChannel()) { - break; - } - try { - Thread.sleep(200); - } catch (InterruptedException e) { - return; - } - } - - Link link = lm.getOrCreateLink(g.getIdLong()); - for (int i = 0; i < 10; i++) { - if (link.getCachedPlayer() != null) { - return; - } - try { - Thread.sleep(200); - } catch (InterruptedException e) { - return; - } - } + VoiceHelper.connectForPlayback(guild, member, lavalinkManager); + GuildMusicManager m = lavalinkManager.getMusicManager(guild.getIdLong()); + String id = TrackQuery.build(null, query); + trackLoader.loadItem(guild.getIdLong(), id, new MessagePlayResponder(ch, m, query)); } private void send(final MessageChannel ch, final EmbedBuilder eb) { ch.sendMessageEmbeds(eb.build()).queue(); } - - private EmbedBuilder trackAdded(final Track t) { - var i = t.getInfo(); - return ok("✅ Добавлено в очередь", "**[" + i.getTitle() + "](" + i.getUri() + ")**") - .addField("Исполнитель", i.getAuthor(), true) - .addField("Длительность", fmt(i.getLength()), true); - } - - private EmbedBuilder playlistAdded(final PlaylistLoaded pl) { - long total = pl.getTracks().stream().mapToLong(t -> t.getInfo().getLength()).sum(); - return new EmbedBuilder() - .setColor(0x00FF00) - .setTitle("📀 Плейлист добавлен") - .setDescription("**" + pl.getInfo().getName() + "**") - .addField("Треков", String.valueOf(pl.getTracks().size()), true) - .addField("Длительность", fmt(total), true); - } - - private static EmbedBuilder ok(final String title, final String desc) { - return new EmbedBuilder().setColor(0x00AAFF).setTitle(title).setDescription(desc); - } - - private static EmbedBuilder err(final String msg) { - return new EmbedBuilder().setColor(0xFF4444).setDescription("❌ " + msg); - } - - static String fmt(final long ms) { - long s = ms / 1000; - if (s >= 3600) { - return String.format("%d:%02d:%02d", s / 3600, (s % 3600) / 60, s % 60); - } - return String.format("%d:%02d", s / 60, s % 60); - } } diff --git a/src/main/java/creator/loki/feature/music/listener/MusicCommandMap.java b/src/main/java/creator/loki/feature/music/listener/MusicCommandMap.java new file mode 100644 index 0000000..729c354 --- /dev/null +++ b/src/main/java/creator/loki/feature/music/listener/MusicCommandMap.java @@ -0,0 +1,96 @@ +package creator.loki.feature.music.listener; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import net.dv8tion.jda.api.events.message.MessageReceivedEvent; + +final class MusicCommandMap { + private static final String PREFIX = "m!"; + + private MusicCommandMap() {} + + static Map> build( + final MusicBotPlayHandler playHandler, final MusicBotControlHandler controlHandler) { + var filters = controlHandler.getFilterHandler(); + var map = new HashMap>(); + map.put( + "play", + ev -> + playHandler.handlePlay(ev.getChannel(), ev.getGuild(), ev.getMember(), parseArgs(ev))); + map.put( + "skip", ev -> controlHandler.handleSkip(ev.getChannel(), ev.getGuild(), ev.getMember())); + map.put( + "stop", ev -> controlHandler.handleStop(ev.getChannel(), ev.getGuild(), ev.getMember())); + map.put("queue", ev -> controlHandler.handleQueue(ev.getChannel(), ev.getGuild())); + map.put("nowplaying", ev -> controlHandler.handleNowPlaying(ev.getChannel(), ev.getGuild())); + map.put("np", ev -> controlHandler.handleNowPlaying(ev.getChannel(), ev.getGuild())); + map.put( + "volume", + ev -> filters.handleVolume(ev.getChannel(), ev.getGuild(), ev.getMember(), parseArgs(ev))); + map.put( + "leave", ev -> controlHandler.handleLeave(ev.getChannel(), ev.getGuild(), ev.getMember())); + map.put( + "bassboost", + ev -> + filters.handleBassboost(ev.getChannel(), ev.getGuild(), ev.getMember(), parseArgs(ev))); + map.put( + "nightcore", ev -> filters.handleNightcore(ev.getChannel(), ev.getGuild(), ev.getMember())); + map.put( + "vaporwave", ev -> filters.handleVaporwave(ev.getChannel(), ev.getGuild(), ev.getMember())); + map.put("reset", ev -> filters.handleReset(ev.getChannel(), ev.getGuild(), ev.getMember())); + map.put( + "equalizer", + ev -> + filters.handleEqualizer(ev.getChannel(), ev.getGuild(), ev.getMember(), parseArgs(ev))); + map.put( + "repeat", + ev -> controlHandler.handleRepeat(ev.getChannel(), ev.getGuild(), ev.getMember())); + map.put( + "loop", ev -> controlHandler.handleRepeat(ev.getChannel(), ev.getGuild(), ev.getMember())); + map.put( + "shuffle", + ev -> controlHandler.handleShuffle(ev.getChannel(), ev.getGuild(), ev.getMember())); + map.put("help", ev -> help(ev.getChannel())); + map.put("commands", ev -> help(ev.getChannel())); + return map; + } + + static List parseArgs(final MessageReceivedEvent event) { + String content = event.getMessage().getContentRaw(); + String[] parts = content.substring(PREFIX.length()).trim().split("\\s+"); + return parts.length > 1 + ? List.of(java.util.Arrays.copyOfRange(parts, 1, parts.length)) + : List.of(); + } + + private static void help(final MessageChannel ch) { + ch.sendMessageEmbeds( + new EmbedBuilder() + .setColor(0x00AAFF) + .setTitle("🎵 Music Bot") + .setDescription( + """ + `m!play <запрос/URL>` — Искать и играть + `m!skip` — Пропустить + `m!stop` — Остановить + `m!queue` — Очередь + `m!nowplaying` — Что играет + `m!volume [0-100]` — Громкость + `m!shuffle` — Перемешать + `m!repeat` — Повтор + `m!bassboost [-25..25]` — Басс + `m!nightcore` — Nightcore ⚡ + `m!vaporwave` — Vaporwave 🌊 + `m!equalizer ` — Эквалайзер + `m!reset` — Сбросить фильтры + """) + .setFooter("Jockie Music style", null) + .build()) + .queue(); + } +} diff --git a/src/main/java/creator/loki/feature/music/listener/MusicFilterHandler.java b/src/main/java/creator/loki/feature/music/listener/MusicFilterHandler.java index 3e47e02..c5f01f8 100644 --- a/src/main/java/creator/loki/feature/music/listener/MusicFilterHandler.java +++ b/src/main/java/creator/loki/feature/music/listener/MusicFilterHandler.java @@ -3,6 +3,9 @@ package creator.loki.feature.music.listener; import java.util.List; import creator.loki.feature.music.core.LavalinkManager; +import creator.loki.feature.music.util.MusicEmbeds; +import creator.loki.feature.music.util.VoiceCheck; +import creator.loki.feature.music.util.VolumeControl; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Member; @@ -12,7 +15,6 @@ import org.slf4j.LoggerFactory; class MusicFilterHandler { private static final Logger log = LoggerFactory.getLogger(MusicFilterHandler.class); - private static final int ERR = 0xFF4444; private final LavalinkManager lavalinkManager; @@ -21,37 +23,25 @@ class MusicFilterHandler { } void handleVolume(MessageChannel ch, Guild guild, Member member, List args) { - if (reqVoice(ch, member) == null) { + if (VoiceCheck.requireChannel(ch, member) == null) { return; } var link = lavalinkManager.getClient().getLinkIfCached(guild.getIdLong()); if (args.isEmpty()) { - send( - ch, - ok( - "🔊 Громкость", - "Текущая: **" - + (link != null && link.getCachedPlayer() != null - ? link.getCachedPlayer().getVolume() - : 100) - + "%**")); + send(ch, MusicEmbeds.ok("🔊 Громкость", "Текущая: **" + VolumeControl.current(link) + "%**")); return; } try { - var vol = Math.max(0, Math.min(100, Integer.parseInt(args.get(0)))); - if (link != null) { - link.createOrUpdatePlayer() - .setVolume(vol) - .subscribe(null, err -> log.warn("Lavalink: {}", err.getMessage())); - } - send(ch, ok("🔊 Громкость", "Установлена **" + vol + "%**")); + var vol = VolumeControl.clamp(Integer.parseInt(args.get(0))); + VolumeControl.set(link, vol, err -> log.warn("Lavalink: {}", err.getMessage())); + send(ch, MusicEmbeds.ok("🔊 Громкость", "Установлена **" + vol + "%**")); } catch (NumberFormatException e) { - send(ch, err("Укажи число от 0 до 100.")); + send(ch, MusicEmbeds.err("Укажи число от 0 до 100.")); } } void handleBassboost(MessageChannel ch, Guild guild, Member member, List args) { - if (reqVoice(ch, member) == null) { + if (VoiceCheck.requireChannel(ch, member) == null) { return; } var level = 10; @@ -62,43 +52,43 @@ class MusicFilterHandler { } } lavalinkManager.getMusicManager(guild.getIdLong()).getScheduler().setBassboost(level); - send(ch, ok("🔊 Bassboost", "**" + level + "**")); + send(ch, MusicEmbeds.ok("🔊 Bassboost", "**" + level + "**")); } void handleNightcore(MessageChannel ch, Guild guild, Member member) { - if (reqVoice(ch, member) == null) { + if (VoiceCheck.requireChannel(ch, member) == null) { return; } var s = lavalinkManager.getMusicManager(guild.getIdLong()).getScheduler(); var now = !s.filterState.isNightcore(); s.setNightcore(now); - send(ch, ok("🎵 Nightcore", now ? "Включён ⚡" : "Выключен")); + send(ch, MusicEmbeds.ok("🎵 Nightcore", now ? "Включён ⚡" : "Выключен")); } void handleVaporwave(MessageChannel ch, Guild guild, Member member) { - if (reqVoice(ch, member) == null) { + if (VoiceCheck.requireChannel(ch, member) == null) { return; } var s = lavalinkManager.getMusicManager(guild.getIdLong()).getScheduler(); var now = !s.filterState.isVaporwave(); s.setVaporwave(now); - send(ch, ok("🎵 Vaporwave", now ? "Включён 🌊" : "Выключен")); + send(ch, MusicEmbeds.ok("🎵 Vaporwave", now ? "Включён 🌊" : "Выключен")); } void handleReset(MessageChannel ch, Guild guild, Member member) { - if (reqVoice(ch, member) == null) { + if (VoiceCheck.requireChannel(ch, member) == null) { return; } lavalinkManager.getMusicManager(guild.getIdLong()).getScheduler().resetFilters(); - send(ch, ok("🔄 Сброс", "Фильтры сброшены.")); + send(ch, MusicEmbeds.ok("🔄 Сброс", "Фильтры сброшены.")); } void handleEqualizer(MessageChannel ch, Guild guild, Member member, List args) { - if (reqVoice(ch, member) == null) { + if (VoiceCheck.requireChannel(ch, member) == null) { return; } if (args.size() < 2) { - send(ch, err("Укажи band 0-14 и gain -0.25 до 1.0")); + send(ch, MusicEmbeds.err("Укажи band 0-14 и gain -0.25 до 1.0")); return; } try { @@ -110,31 +100,14 @@ class MusicFilterHandler { .setEqualizerBand(band, gain); send( ch, - ok( - "🎛 Эквалайзер", - "Band **" + band + "** → **" + String.format("%.2f", gain) + "**")); + MusicEmbeds.ok( + "🎛 Эквалайзер", "Band **" + band + "** → **" + String.format("%.2f", gain) + "**")); } catch (NumberFormatException e) { - send(ch, err("Неверный формат чисел.")); + send(ch, MusicEmbeds.err("Неверный формат чисел.")); } } - private Member reqVoice(final MessageChannel ch, final Member member) { - if (member != null && member.getVoiceState().inAudioChannel()) { - return member; - } - send(ch, err("Зайди в голосовой канал сначала.")); - return null; - } - private void send(final MessageChannel ch, final EmbedBuilder eb) { ch.sendMessageEmbeds(eb.build()).queue(); } - - private static EmbedBuilder ok(final String title, final String desc) { - return new EmbedBuilder().setColor(0x00AAFF).setTitle(title).setDescription(desc); - } - - private static EmbedBuilder err(final String msg) { - return new EmbedBuilder().setColor(ERR).setDescription("❌ " + msg); - } } diff --git a/src/main/java/creator/loki/feature/music/manage/AutoLeaveController.java b/src/main/java/creator/loki/feature/music/manage/AutoLeaveController.java new file mode 100644 index 0000000..9c1bda2 --- /dev/null +++ b/src/main/java/creator/loki/feature/music/manage/AutoLeaveController.java @@ -0,0 +1,49 @@ +package creator.loki.feature.music.manage; + +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.LongConsumer; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class AutoLeaveController { + private static final Logger logger = LoggerFactory.getLogger(AutoLeaveController.class); + private static final long AUTO_LEAVE_DELAY_MINUTES = 3; + + private final GuildMusicManager manager; + private final LongConsumer voiceDisconnectHandler; + private ScheduledFuture autoLeaveFuture; + + AutoLeaveController(GuildMusicManager manager, LongConsumer voiceDisconnectHandler) { + this.manager = manager; + this.voiceDisconnectHandler = voiceDisconnectHandler; + } + + void start() { + cancel(); + autoLeaveFuture = + manager.getExecutor().schedule(this::autoLeave, AUTO_LEAVE_DELAY_MINUTES, TimeUnit.MINUTES); + } + + void cancel() { + if (autoLeaveFuture != null && !autoLeaveFuture.isDone()) { + autoLeaveFuture.cancel(false); + autoLeaveFuture = null; + } + } + + private void autoLeave() { + manager.stop(); + manager + .getLink() + .ifPresent( + link -> + link.destroy() + .subscribe(null, err -> logger.warn("Lavalink: {}", err.getMessage()))); + if (voiceDisconnectHandler != null) { + voiceDisconnectHandler.accept(manager.getGuildId()); + } + manager.getLavalinkManager().removeMusicManager(manager.getGuildId()); + } +} diff --git a/src/main/java/creator/loki/feature/music/manage/GuildMusicManager.java b/src/main/java/creator/loki/feature/music/manage/GuildMusicManager.java index e8198e6..b9c1488 100644 --- a/src/main/java/creator/loki/feature/music/manage/GuildMusicManager.java +++ b/src/main/java/creator/loki/feature/music/manage/GuildMusicManager.java @@ -1,34 +1,23 @@ package creator.loki.feature.music.manage; -import java.util.ArrayList; -import java.util.List; import java.util.Optional; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import java.util.function.LongConsumer; import creator.loki.feature.music.core.LavalinkManager; import creator.loki.feature.music.repository.QueueRepository; -import creator.loki.feature.music.repository.QueueRepository.QueueTrack; -import creator.loki.util.LoggerDecorator; -import dev.arbjerg.lavalink.client.LavalinkNode; import dev.arbjerg.lavalink.client.Link; import dev.arbjerg.lavalink.client.player.LavalinkPlayer; -import dev.arbjerg.lavalink.client.player.Track; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; public class GuildMusicManager { - private static final Logger logger = LoggerFactory.getLogger(GuildMusicManager.class); private final long guildId; private final LavalinkManager lavalinkManager; - private TrackScheduler scheduler; - private final QueueRepository queueRepository; private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); - private ScheduledFuture autoLeaveFuture; - private final LongConsumer voiceDisconnectHandler; + private final AutoLeaveController autoLeave; + private final QueueStore queueStore; + private TrackScheduler scheduler; public GuildMusicManager( long guildId, LavalinkManager lavalinkManager, QueueRepository queueRepository) { @@ -42,14 +31,14 @@ public class GuildMusicManager { LongConsumer voiceDisconnectHandler) { this.guildId = guildId; this.lavalinkManager = lavalinkManager; - this.queueRepository = queueRepository; - this.voiceDisconnectHandler = voiceDisconnectHandler; + this.autoLeave = new AutoLeaveController(this, voiceDisconnectHandler); + this.queueStore = new QueueStore(this, lavalinkManager, queueRepository); this.scheduler = null; } public void init() { this.scheduler = new TrackScheduler(this); - loadQueueAsync(); + queueStore.loadAsync(); } public TrackScheduler getScheduler() { @@ -65,7 +54,7 @@ public class GuildMusicManager { } public void destroy() { - cancelAutoLeaveTimer(); + autoLeave.cancel(); scheduler.stop(); executor.shutdown(); try { @@ -78,28 +67,11 @@ public class GuildMusicManager { } public void startAutoLeaveTimer() { - cancelAutoLeaveTimer(); - autoLeaveFuture = executor.schedule(this::autoLeave, 3, TimeUnit.MINUTES); - } - - private void autoLeave() { - stop(); - getLink() - .ifPresent( - link -> - link.destroy() - .subscribe(null, err -> logger.warn("Lavalink: {}", err.getMessage()))); - if (voiceDisconnectHandler != null) { - voiceDisconnectHandler.accept(guildId); - } - lavalinkManager.removeMusicManager(guildId); + autoLeave.start(); } public void cancelAutoLeaveTimer() { - if (autoLeaveFuture != null && !autoLeaveFuture.isDone()) { - autoLeaveFuture.cancel(false); - autoLeaveFuture = null; - } + autoLeave.cancel(); } public Optional getLink() { @@ -118,77 +90,11 @@ public class GuildMusicManager { return guildId; } - @SuppressWarnings("resource") - private void loadQueueAsync() { - if (queueRepository == null) { - return; - } - var saved = queueRepository.loadQueue(String.valueOf(guildId)); - if (saved.isEmpty()) { - return; - } - var client = lavalinkManager.getClient(); // NOPMD: shared client, not owned by this method - if (client == null || client.getNodes().isEmpty()) { - return; - } - executor.execute(() -> restoreTracks(saved, client.getNodes().get(0))); - } - - private void restoreTracks(final List saved, final LavalinkNode node) { - var decoded = new ArrayList(); - for (var qt : saved) { - try { - var track = node.decodeTrack(qt.encoded()).block(); - if (track != null) { - decoded.add(track); - } - } catch (Exception e) { - LoggerDecorator.warn(logger, "Не удалось декодировать: " + qt.title()); - } - } - if (decoded.isEmpty()) { - return; - } - var q = scheduler.queue; - q.addAll(decoded.subList(1, decoded.size())); - var first = decoded.get(0); - var player = getPlayer().orElse(null); - if (player != null && player.getTrack() == null) { - scheduler.startTrack(first); - } else { - q.offer(first); - } - LoggerDecorator.music(logger, "Восстановлено " + decoded.size() + " треков"); - queueRepository.deleteQueue(String.valueOf(guildId)); - } - - private ScheduledFuture saveDebounced; - void saveQueueDebounced() { - if (saveDebounced != null && !saveDebounced.isDone()) { - saveDebounced.cancel(false); - } - saveDebounced = executor.schedule(this::saveQueue, 3, TimeUnit.SECONDS); + queueStore.saveDebounced(); } void saveQueue() { - if (queueRepository == null) { - return; - } - var current = scheduler.getCurrentTrack(); - var tracks = new ArrayList<>(scheduler.getQueue()); - var queueTracks = new ArrayList(); - if (current != null) { - queueTracks.add(toQueueTrack(current)); - } - for (var t : tracks) { - queueTracks.add(toQueueTrack(t)); - } - queueRepository.saveQueue(String.valueOf(guildId), queueTracks); - } - - private static QueueTrack toQueueTrack(final Track t) { - var i = t.getInfo(); - return new QueueTrack(t.getEncoded(), i.getTitle(), i.getAuthor(), i.getUri(), i.getLength()); + queueStore.save(); } } diff --git a/src/main/java/creator/loki/feature/music/manage/QueueStore.java b/src/main/java/creator/loki/feature/music/manage/QueueStore.java new file mode 100644 index 0000000..2b99cbb --- /dev/null +++ b/src/main/java/creator/loki/feature/music/manage/QueueStore.java @@ -0,0 +1,105 @@ +package creator.loki.feature.music.manage; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import creator.loki.feature.music.core.LavalinkManager; +import creator.loki.feature.music.repository.QueueRepository; +import creator.loki.feature.music.repository.QueueRepository.QueueTrack; +import creator.loki.util.LoggerDecorator; +import dev.arbjerg.lavalink.client.LavalinkNode; +import dev.arbjerg.lavalink.client.player.Track; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class QueueStore { + private static final Logger logger = LoggerFactory.getLogger(QueueStore.class); + private static final long SAVE_DELAY_SECONDS = 3; + + private final GuildMusicManager manager; + private final LavalinkManager lavalinkManager; + private final QueueRepository queueRepository; + private ScheduledFuture saveDebounced; + + QueueStore( + GuildMusicManager manager, LavalinkManager lavalinkManager, QueueRepository queueRepository) { + this.manager = manager; + this.lavalinkManager = lavalinkManager; + this.queueRepository = queueRepository; + } + + @SuppressWarnings("resource") + void loadAsync() { + if (queueRepository == null) { + return; + } + var saved = queueRepository.loadQueue(String.valueOf(manager.getGuildId())); + if (saved.isEmpty()) { + return; + } + var client = lavalinkManager.getClient(); // NOPMD: shared client, not owned by this method + if (client == null || client.getNodes().isEmpty()) { + return; + } + manager.getExecutor().execute(() -> restoreTracks(saved, client.getNodes().get(0))); + } + + private void restoreTracks(final List saved, final LavalinkNode node) { + var decoded = new ArrayList(); + for (var qt : saved) { + try { + var track = node.decodeTrack(qt.encoded()).block(); + if (track != null) { + decoded.add(track); + } + } catch (Exception e) { + LoggerDecorator.warn(logger, "Не удалось декодировать: " + qt.title()); + } + } + if (decoded.isEmpty()) { + return; + } + var q = manager.getScheduler().queue; + q.addAll(decoded.subList(1, decoded.size())); + var first = decoded.get(0); + var player = manager.getPlayer().orElse(null); + if (player != null && player.getTrack() == null) { + manager.getScheduler().startTrack(first); + } else { + q.offer(first); + } + LoggerDecorator.music(logger, "Восстановлено " + decoded.size() + " треков"); + queueRepository.deleteQueue(String.valueOf(manager.getGuildId())); + } + + void saveDebounced() { + if (saveDebounced != null && !saveDebounced.isDone()) { + saveDebounced.cancel(false); + } + saveDebounced = + manager.getExecutor().schedule(this::save, SAVE_DELAY_SECONDS, TimeUnit.SECONDS); + } + + void save() { + if (queueRepository == null) { + return; + } + var current = manager.getScheduler().getCurrentTrack(); + var tracks = new ArrayList<>(manager.getScheduler().getQueue()); + var queueTracks = new ArrayList(); + if (current != null) { + queueTracks.add(toQueueTrack(current)); + } + for (var t : tracks) { + queueTracks.add(toQueueTrack(t)); + } + queueRepository.saveQueue(String.valueOf(manager.getGuildId()), queueTracks); + } + + private static QueueTrack toQueueTrack(final Track t) { + var i = t.getInfo(); + return new QueueTrack(t.getEncoded(), i.getTitle(), i.getAuthor(), i.getUri(), i.getLength()); + } +} diff --git a/src/main/java/creator/loki/feature/music/manage/TrackEndHandler.java b/src/main/java/creator/loki/feature/music/manage/TrackEndHandler.java new file mode 100644 index 0000000..7bf6539 --- /dev/null +++ b/src/main/java/creator/loki/feature/music/manage/TrackEndHandler.java @@ -0,0 +1,55 @@ +package creator.loki.feature.music.manage; + +import creator.loki.util.LoggerDecorator; +import dev.arbjerg.lavalink.client.player.Track; +import dev.arbjerg.lavalink.protocol.v4.Message.EmittedEvent.TrackEndEvent.AudioTrackEndReason; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +class TrackEndHandler { + private static final Logger log = LoggerFactory.getLogger(TrackEndHandler.class); + + private final TrackScheduler scheduler; + private final GuildMusicManager manager; + + TrackEndHandler(TrackScheduler scheduler, GuildMusicManager manager) { + this.scheduler = scheduler; + this.manager = manager; + } + + void onTrackEnd(final Track lastTrack, final AudioTrackEndReason endReason) { + if (endReason == AudioTrackEndReason.LOAD_FAILED) { + handleLoadFailed(); + } else if (!endReason.getMayStartNext() && endReason != AudioTrackEndReason.FINISHED) { + handleUnexpectedEnd(endReason); + } else if (scheduler.isRepeating() && endReason == AudioTrackEndReason.FINISHED) { + handleFinished(lastTrack); + } else { + nextOrAutoLeave(); + } + } + + private void handleLoadFailed() { + LoggerDecorator.warn(log, "Load failed, skipping to next"); + scheduler.notifyLoadFailed(); + nextOrAutoLeave(); + } + + private void handleUnexpectedEnd(final AudioTrackEndReason endReason) { + LoggerDecorator.warn(log, "Track ended unexpectedly: " + endReason); + manager.startAutoLeaveTimer(); + } + + private void handleFinished(final Track lastTrack) { + scheduler.startTrack(lastTrack.makeClone()); + } + + private void nextOrAutoLeave() { + var next = scheduler.pollNext(); + if (next != null) { + scheduler.startTrack(next); + } else { + manager.startAutoLeaveTimer(); + } + } +} diff --git a/src/main/java/creator/loki/feature/music/manage/TrackScheduler.java b/src/main/java/creator/loki/feature/music/manage/TrackScheduler.java index d82427a..bb78da1 100644 --- a/src/main/java/creator/loki/feature/music/manage/TrackScheduler.java +++ b/src/main/java/creator/loki/feature/music/manage/TrackScheduler.java @@ -7,7 +7,6 @@ import java.util.List; import java.util.Queue; import java.util.function.Consumer; -import creator.loki.util.LoggerDecorator; import dev.arbjerg.lavalink.client.player.LavalinkPlayer; import dev.arbjerg.lavalink.client.player.Track; import dev.arbjerg.lavalink.protocol.v4.Message.EmittedEvent.TrackEndEvent.AudioTrackEndReason; @@ -17,8 +16,10 @@ import org.slf4j.LoggerFactory; public class TrackScheduler { private static final Logger log = LoggerFactory.getLogger(TrackScheduler.class); private static final Consumer LOG_LAV = t -> log.warn("Lav: {}", t.getMessage()); + private final GuildMusicManager manager; private final TrackStarter trackStarter; + private final TrackEndHandler trackEndHandler; final Queue queue = new LinkedList<>(); private boolean repeating; public final FilterState filterState = new FilterState(); @@ -27,6 +28,7 @@ public class TrackScheduler { public TrackScheduler(GuildMusicManager manager) { this.manager = manager; this.trackStarter = new TrackStarter(manager.getExecutor()); + this.trackEndHandler = new TrackEndHandler(this, manager); } public void setOnLoadFailedNotify(Consumer onLoadFailedNotify) { @@ -109,15 +111,7 @@ public class TrackScheduler { } public void onTrackEnd(final Track lastTrack, AudioTrackEndReason endReason) { - if (endReason == AudioTrackEndReason.LOAD_FAILED) { - handleLoadFailed(); - } else if (!endReason.getMayStartNext() && endReason != AudioTrackEndReason.FINISHED) { - handleUnexpectedEnd(endReason); - } else if (repeating && endReason == AudioTrackEndReason.FINISHED) { - handleFinished(lastTrack); - } else { - nextOrAutoLeave(); - } + trackEndHandler.onTrackEnd(lastTrack, endReason); } void startTrack(final Track track) { @@ -126,29 +120,13 @@ public class TrackScheduler { trackStarter.startWithRetry(link, track); } - private void handleLoadFailed() { - LoggerDecorator.warn(log, "Load failed, skipping to next"); + void notifyLoadFailed() { if (onLoadFailedNotify != null) { onLoadFailedNotify.accept("⚠️ Ошибка загрузки трека, пропускаю..."); } - nextOrAutoLeave(); } - private void handleUnexpectedEnd(final AudioTrackEndReason endReason) { - LoggerDecorator.warn(log, "Track ended unexpectedly: " + endReason); - manager.startAutoLeaveTimer(); - } - - private void handleFinished(final Track lastTrack) { - startTrack(lastTrack.makeClone()); - } - - private void nextOrAutoLeave() { - var next = queue.poll(); - if (next != null) { - startTrack(next); - } else { - manager.startAutoLeaveTimer(); - } + Track pollNext() { + return queue.poll(); } } diff --git a/src/main/java/creator/loki/feature/music/play/PlayCommand.java b/src/main/java/creator/loki/feature/music/play/PlayCommand.java index cf1e769..db76959 100644 --- a/src/main/java/creator/loki/feature/music/play/PlayCommand.java +++ b/src/main/java/creator/loki/feature/music/play/PlayCommand.java @@ -11,6 +11,8 @@ 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.feature.music.util.QueueNotifier; +import creator.loki.feature.music.util.TrackQuery; import creator.loki.feature.utility.VoiceHelper; import dev.arbjerg.lavalink.client.player.Track; import net.dv8tion.jda.api.entities.Guild; @@ -18,6 +20,7 @@ import net.dv8tion.jda.api.entities.Member; public class PlayCommand implements Command { private static final Set SOURCES = Set.of("yt", "sc", "list"); + private final PlayerManager playerManager; private final SearchHandler searchHandler; @@ -67,23 +70,12 @@ public class PlayCommand implements Command { handlePickResult(ctx, first); return; } - if (!resolveVoiceConnection(ctx)) { - return; - } - var lm = playerManager.getLavalinkManager(); - var mm = lm.getMusicManager(guild.getIdLong()); - handlePlayQuery(ctx, args, first, lm, mm); - } - - private boolean resolveVoiceConnection(final CommandContext ctx) { - var guild = ctx.getGuild(); - var member = ctx.getAuthor(); var lm = playerManager.getLavalinkManager(); if (!VoiceHelper.connectForPlayback(guild, member, lm)) { sendErr(ctx, "Не удалось подключиться к голосовому каналу."); - return false; + return; } - return true; + handlePlayQuery(ctx, args, first, lm, lm.getMusicManager(guild.getIdLong())); } private void handlePickResult(final CommandContext ctx, final String first) { @@ -93,10 +85,15 @@ public class PlayCommand implements Command { sendErr(ctx, "Поиск устарел. Сделай новый: `!play <запрос>`"); return; } - if (!resolveVoiceConnection(ctx)) { + if (!VoiceHelper.connectForPlayback( + guild, ctx.getAuthor(), playerManager.getLavalinkManager())) { + sendErr(ctx, "Не удалось подключиться к голосовому каналу."); return; } - enqueueAndNotify(ctx, t); + QueueNotifier.enqueueAndNotify( + playerManager.getLavalinkManager().getMusicManager(guild.getIdLong()).getScheduler(), + ctx.getChannel(), + t); } private void handlePlayQuery( @@ -111,53 +108,11 @@ public class PlayCommand implements Command { sendErr(ctx, "Укажи запрос после источника."); return; } - boolean isUrl = raw.startsWith("http://") || raw.startsWith("https://"); - String query = isUrl ? null : raw; - String id = buildQuery(first, raw); - loadAndHandle( - new PlayContext(ctx, lm, ctx.getGuild().getIdLong(), mm), - new PlayQuery(id, isUrl, query, raw)); - } - - private record PlayContext( - CommandContext ctx, LavalinkManager lm, long guildId, GuildMusicManager mm) {} - - private record PlayQuery(String id, boolean isUrl, String query, String raw) {} - - private void loadAndHandle(final PlayContext pc, final PlayQuery pq) { - pc.lm() - .getOrCreateLink(pc.guildId()) - .loadItem(pq.id()) - .subscribe(new PlayLoadHandler(pc.ctx(), searchHandler, pc.mm(), pq.isUrl(), pq.query(), pq.raw())); - } - - private static String buildQuery(final String first, final 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(final CommandContext ctx, final Track track) { - var m = playerManager.getLavalinkManager().getMusicManager(ctx.getGuild().getIdLong()); - m.getScheduler().enqueue(track); - ctx.getChannel() - .sendMessageEmbeds( - EmbedTemplates.info( - "Добавлено в очередь", - "**" - + track.getInfo().getTitle() - + "**\n" - + SearchHandler.formatDuration(track.getInfo().getLength()))) - .queue(); + boolean isUrl = TrackQuery.isUrl(raw); + String id = TrackQuery.build(first, raw); + lm.getOrCreateLink(ctx.getGuild().getIdLong()) + .loadItem(id) + .subscribe(new PlayLoadHandler(ctx, searchHandler, mm, isUrl, isUrl ? null : raw, raw)); } private static void sendErr(final CommandContext ctx, final String msg) { diff --git a/src/main/java/creator/loki/feature/music/play/SearchHandler.java b/src/main/java/creator/loki/feature/music/play/SearchHandler.java index 7e2d1b1..6f3dfe0 100644 --- a/src/main/java/creator/loki/feature/music/play/SearchHandler.java +++ b/src/main/java/creator/loki/feature/music/play/SearchHandler.java @@ -3,6 +3,7 @@ package creator.loki.feature.music.play; import java.util.List; import creator.loki.feature.music.core.SearchManager; +import creator.loki.feature.music.util.TrackFormatter; import dev.arbjerg.lavalink.client.player.Track; import net.dv8tion.jda.api.EmbedBuilder; @@ -68,13 +69,6 @@ public class SearchHandler { } public static String formatDuration(final long millis) { - long totalSec = millis / 1000; - long hours = totalSec / 3600; - long minutes = (totalSec % 3600) / 60; - long secs = totalSec % 60; - if (hours > 0) { - return String.format("%d:%02d:%02d", hours, minutes, secs); - } - return String.format("%d:%02d", minutes, secs); + return TrackFormatter.formatDuration(millis); } } diff --git a/src/main/java/creator/loki/feature/music/slash/PlaySlashHandler.java b/src/main/java/creator/loki/feature/music/slash/PlaySlashHandler.java index d9f7574..20919a5 100644 --- a/src/main/java/creator/loki/feature/music/slash/PlaySlashHandler.java +++ b/src/main/java/creator/loki/feature/music/slash/PlaySlashHandler.java @@ -1,16 +1,11 @@ package creator.loki.feature.music.slash; -import java.util.List; - -import creator.loki.embed.EmbedTemplates; import creator.loki.feature.music.core.PlayerManager; import creator.loki.feature.music.manage.GuildMusicManager; import creator.loki.feature.music.play.TrackLoader; -import dev.arbjerg.lavalink.client.player.LoadFailed; -import dev.arbjerg.lavalink.client.player.PlaylistLoaded; -import dev.arbjerg.lavalink.client.player.Track; +import creator.loki.feature.music.util.TrackQuery; +import creator.loki.feature.music.util.VoiceCheck; import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; public class PlaySlashHandler { @@ -23,138 +18,24 @@ public class PlaySlashHandler { } public void handle(final SlashCommandInteractionEvent event) { - Member member = requireVoiceChannel(event); - if (member == null) { + if (VoiceCheck.requireChannel(event) == null) { return; } String query = event.getOption("query").getAsString(); Guild guild = event.getGuild(); event.deferReply().queue(); - if (!guild.getMember(guild.getJDA().getSelfUser()).getVoiceState().inAudioChannel()) { + if (!guild.getSelfMember().getVoiceState().inAudioChannel()) { guild .getJDA() .getDirectAudioController() - .connect(member.getVoiceState().getChannel().asVoiceChannel()); + .connect(event.getMember().getVoiceState().getChannel().asVoiceChannel()); } GuildMusicManager m = playerManager.getMusicManager(guild.getIdLong()); - if (query.startsWith("http://") || query.startsWith("https://")) { - loadUrl(event, query, m, guild); - } else { - search(event, query, m, guild); - } - } - - private void loadUrl( - final SlashCommandInteractionEvent event, - final String url, - final GuildMusicManager m, - final Guild guild) { - trackLoader.loadItem(m.getGuildId(), url, loader(event, m, guild, null)); - } - - private void search( - final SlashCommandInteractionEvent event, - final String query, - final GuildMusicManager m, - final Guild guild) { - trackLoader.loadItem(m.getGuildId(), "ytsearch:" + query, loader(event, m, guild, query)); - } - - private TrackLoader.LoadCallbacks loader( - final SlashCommandInteractionEvent event, - final GuildMusicManager m, - final Guild guild, - final String searchQuery) { - return new TrackLoader.LoadCallbacks() { - @Override - public void onTrackLoaded(final Track t) { - addAndReply(event, m, t); - } - - @Override - public void onPlaylistLoaded(final PlaylistLoaded r) { - int c = 0; - for (Track t : r.getTracks()) { - m.getScheduler().enqueue(t); - c++; - } - event - .getHook() - .editOriginalEmbeds( - EmbedTemplates.info( - "Плейлист добавлен", "**" + r.getInfo().getName() + "** — " + c + " треков")) - .queue(); - } - - @Override - public void onSearchResults(final List ts) { - if (ts.isEmpty()) { - onNoMatches(); - return; - } - addAndReply(event, m, ts.get(0)); - } - - @Override - public void onNoMatches() { - event - .getHook() - .editOriginalEmbeds( - EmbedTemplates.error( - "Не найдено", - searchQuery != null - ? "Ничего не найдено по запросу: " + searchQuery - : "Ничего не найдено по ссылке.")) - .queue(); - } - - @Override - public void onLoadFailed(final LoadFailed e) { - event - .getHook() - .editOriginalEmbeds( - EmbedTemplates.error( - "Ошибка загрузки", - e.getException().getMessage() != null - ? e.getException().getMessage() - : "Неизвестная ошибка")) - .queue(); - } - }; - } - - private void addAndReply( - final SlashCommandInteractionEvent event, final GuildMusicManager m, final Track t) { - m.getScheduler().enqueue(t); - event - .getHook() - .editOriginalEmbeds( - EmbedTemplates.info( - "Добавлено в очередь", - "**" + t.getInfo().getTitle() + "**\n" + formatDuration(t.getInfo().getLength()))) - .queue(); - } - - private static Member requireVoiceChannel(final SlashCommandInteractionEvent event) { - Member member = event.getMember(); - if (member != null && member.getVoiceState().inAudioChannel()) { - return member; - } - event - .replyEmbeds(EmbedTemplates.error("Голосовой канал", "Зайди в голосовой канал сначала.")) - .setEphemeral(true) - .queue(); - return null; - } - - static String formatDuration(final long millis) { - long totalSec = millis / 1000; - long hours = totalSec / 3600; - long minutes = (totalSec % 3600) / 60; - long secs = totalSec % 60; - if (hours > 0) { - return String.format("%d:%02d:%02d", hours, minutes, secs); - } - return String.format("%d:%02d", minutes, secs); + boolean isUrl = TrackQuery.isUrl(query); + String searchQuery = isUrl ? null : query; + trackLoader.loadItem( + m.getGuildId(), + TrackQuery.build(null, query), + new SlashPlayResponder(event, m, searchQuery)); } } diff --git a/src/main/java/creator/loki/feature/music/slash/QueueSlashHandler.java b/src/main/java/creator/loki/feature/music/slash/QueueSlashHandler.java index a5c342e..02b619a 100644 --- a/src/main/java/creator/loki/feature/music/slash/QueueSlashHandler.java +++ b/src/main/java/creator/loki/feature/music/slash/QueueSlashHandler.java @@ -1,14 +1,13 @@ package creator.loki.feature.music.slash; -import java.awt.Color; -import java.util.List; import java.util.function.Consumer; -import creator.loki.embed.EmbedFactory; import creator.loki.embed.EmbedTemplates; import creator.loki.feature.music.core.PlayerManager; -import dev.arbjerg.lavalink.client.player.Track; -import net.dv8tion.jda.api.entities.Member; +import creator.loki.feature.music.util.GuildAudioActions; +import creator.loki.feature.music.util.QueueView; +import creator.loki.feature.music.util.VoiceCheck; +import creator.loki.feature.music.util.VolumeControl; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import org.slf4j.LoggerFactory; @@ -20,17 +19,17 @@ public class QueueSlashHandler { } public void handleStop(final SlashCommandInteractionEvent ev) { - if (reqVoice(ev) == null) { + if (VoiceCheck.requireChannel(ev) == null) { return; } - pm.getMusicManager(ev.getGuild().getIdLong()).stop(); - ev.getGuild().getJDA().getDirectAudioController().disconnect(ev.getGuild()); + GuildAudioActions.stopAndDisconnect( + pm.getMusicManager(ev.getGuild().getIdLong()), ev.getGuild()); ev.replyEmbeds(EmbedTemplates.info("Остановлено", "Музыка остановлена, очередь очищена.")) .queue(); } public void handleSkip(final SlashCommandInteractionEvent ev) { - if (reqVoice(ev) == null) { + if (VoiceCheck.requireChannel(ev) == null) { return; } var s = pm.getMusicManager(ev.getGuild().getIdLong()).getScheduler(); @@ -45,77 +44,44 @@ public class QueueSlashHandler { public void handleQueue(final SlashCommandInteractionEvent ev) { var s = pm.getMusicManager(ev.getGuild().getIdLong()).getScheduler(); - var cur = s.getCurrentTrack(); - var q = s.getQueue(); - if (cur == null && q.isEmpty()) { + var embed = QueueView.queue(s); + if (embed == null) { ev.reply("Очередь пуста.").queue(); return; } - var eb = EmbedFactory.create().withTitle("Очередь треков").withColor(new Color(88, 101, 242)); - if (cur != null) { - var ci = cur.getInfo(); - eb.addField("Сейчас играет", "**" + ci.getTitle() + "**\n" + ci.getAuthor(), false); - } - if (!q.isEmpty()) { - eb.addField("В очереди (" + q.size() + ")", buildQueueList(q), false); - } - ev.replyEmbeds(eb.build()).queue(); - } - - private String buildQueueList(final List q) { - var sb = new StringBuilder(); - var show = Math.min(q.size(), 10); - for (int i = 0; i < show; i++) { - var t = q.get(i); - sb.append("`").append(i + 1).append(".` **").append(t.getInfo().getTitle()).append("**\n"); - } - if (q.size() > show) { - sb.append("... и ещё **").append(q.size() - show).append("** треков"); - } - return sb.toString(); + ev.replyEmbeds(embed).queue(); } public void handleNowPlaying(final SlashCommandInteractionEvent ev) { - var track = pm.getMusicManager(ev.getGuild().getIdLong()).getScheduler().getCurrentTrack(); - if (track == null) { + var guildId = ev.getGuild().getIdLong(); + var link = pm.getLavalinkManager().getClient().getLinkIfCached(guildId); + var embed = QueueView.nowPlaying(pm.getMusicManager(guildId).getScheduler(), link); + if (embed == null) { ev.reply("Сейчас ничего не играет.").queue(); return; } - var link = pm.getLavalinkManager().getClient().getLinkIfCached(ev.getGuild().getIdLong()); - var pos = - link != null && link.getCachedPlayer() != null ? link.getCachedPlayer().getPosition() : 0L; - var eb = - EmbedFactory.create() - .withTitle("Сейчас играет") - .withColor(new Color(88, 101, 242)) - .addField("Название", "**" + track.getInfo().getTitle() + "**", false) - .addField("Автор", track.getInfo().getAuthor(), true) - .addField("Длительность", progressBar(pos, track.getInfo().getLength()), true) - .build(); - ev.replyEmbeds(eb).queue(); + ev.replyEmbeds(embed).queue(); } public void handleVolume(final SlashCommandInteractionEvent ev) { - if (reqVoice(ev) == null) { + if (VoiceCheck.requireChannel(ev) == null) { return; } var link = pm.getLavalinkManager().getClient().getLinkIfCached(ev.getGuild().getIdLong()); var opt = ev.getOption("percent"); if (opt == null) { - var cur = - link != null && link.getCachedPlayer() != null ? link.getCachedPlayer().getVolume() : 100; - ev.replyEmbeds(EmbedTemplates.info("Громкость", "Текущая: **" + cur + "%**")).queue(); + ev.replyEmbeds( + EmbedTemplates.info("Громкость", "Текущая: **" + VolumeControl.current(link) + "%**")) + .queue(); return; } - var vol = Math.max(0, Math.min(100, opt.getAsInt())); - if (link != null && link.getCachedPlayer() != null) { - link.createOrUpdatePlayer().setVolume(vol).subscribe(null, LOG); - } + var vol = VolumeControl.clamp(opt.getAsInt()); + VolumeControl.set(link, vol, LOG); ev.replyEmbeds(EmbedTemplates.info("Громкость", "Установлена на **" + vol + "%**")).queue(); } public void handlePause(final SlashCommandInteractionEvent ev) { - if (reqVoice(ev) == null) { + if (VoiceCheck.requireChannel(ev) == null) { return; } pm.getMusicManager(ev.getGuild().getIdLong()) @@ -125,7 +91,7 @@ public class QueueSlashHandler { } public void handleResume(final SlashCommandInteractionEvent ev) { - if (reqVoice(ev) == null) { + if (VoiceCheck.requireChannel(ev) == null) { return; } pm.getMusicManager(ev.getGuild().getIdLong()) @@ -135,7 +101,7 @@ public class QueueSlashHandler { } public void handleShuffle(final SlashCommandInteractionEvent ev) { - if (reqVoice(ev) == null) { + if (VoiceCheck.requireChannel(ev) == null) { return; } pm.getMusicManager(ev.getGuild().getIdLong()).getScheduler().shuffle(); @@ -143,7 +109,7 @@ public class QueueSlashHandler { } public void handleRepeat(final SlashCommandInteractionEvent ev) { - if (reqVoice(ev) == null) { + if (VoiceCheck.requireChannel(ev) == null) { return; } var s = pm.getMusicManager(ev.getGuild().getIdLong()).getScheduler(); @@ -153,29 +119,6 @@ public class QueueSlashHandler { ev.replyEmbeds(EmbedTemplates.info("Повтор", d)).queue(); } - private Member reqVoice(final SlashCommandInteractionEvent ev) { - var m = ev.getMember(); - if (m != null && m.getVoiceState().inAudioChannel()) { - return m; - } - var em = EmbedTemplates.error("Голосовой канал", "Зайди в голосовой канал сначала."); - ev.replyEmbeds(em).setEphemeral(true).queue(); - return null; - } - - static String progressBar(final long pos, final long duration) { - long dur = duration <= 0 ? 1 : duration; - int total = 20; - int filled = (int) ((double) pos / dur * total); - var bar = new StringBuilder("\u2503"); - for (int i = 0; i < total; i++) { - bar.append(i < filled ? "\u2588" : "\u2591"); - } - var p = String.format("%d:%02d", pos / 60000, (pos / 1000) % 60); - var d = String.format("%d:%02d", dur / 60000, (dur / 1000) % 60); - return bar + "\u2503 " + p + " / " + d; - } - private static final Consumer LOG = err -> LoggerFactory.getLogger(QueueSlashHandler.class).warn("Lavalink: {}", err.getMessage()); diff --git a/src/main/java/creator/loki/feature/music/slash/SlashPlayResponder.java b/src/main/java/creator/loki/feature/music/slash/SlashPlayResponder.java new file mode 100644 index 0000000..9bb4971 --- /dev/null +++ b/src/main/java/creator/loki/feature/music/slash/SlashPlayResponder.java @@ -0,0 +1,94 @@ +package creator.loki.feature.music.slash; + +import java.util.List; + +import creator.loki.embed.EmbedTemplates; +import creator.loki.feature.music.manage.GuildMusicManager; +import creator.loki.feature.music.play.TrackLoader; +import creator.loki.feature.music.util.TrackFormatter; +import dev.arbjerg.lavalink.client.player.LoadFailed; +import dev.arbjerg.lavalink.client.player.PlaylistLoaded; +import dev.arbjerg.lavalink.client.player.Track; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; + +final class SlashPlayResponder implements TrackLoader.LoadCallbacks { + private final SlashCommandInteractionEvent event; + private final GuildMusicManager manager; + private final String searchQuery; + + SlashPlayResponder( + SlashCommandInteractionEvent event, GuildMusicManager manager, String searchQuery) { + this.event = event; + this.manager = manager; + this.searchQuery = searchQuery; + } + + @Override + public void onTrackLoaded(final Track t) { + addAndReply(t); + } + + @Override + public void onPlaylistLoaded(final PlaylistLoaded r) { + int count = 0; + for (Track t : r.getTracks()) { + manager.getScheduler().enqueue(t); + count++; + } + event + .getHook() + .editOriginalEmbeds( + EmbedTemplates.info( + "Плейлист добавлен", "**" + r.getInfo().getName() + "** — " + count + " треков")) + .queue(); + } + + @Override + public void onSearchResults(final List ts) { + if (ts.isEmpty()) { + onNoMatches(); + return; + } + addAndReply(ts.get(0)); + } + + @Override + public void onNoMatches() { + event + .getHook() + .editOriginalEmbeds( + EmbedTemplates.error( + "Не найдено", + searchQuery != null + ? "Ничего не найдено по запросу: " + searchQuery + : "Ничего не найдено по ссылке.")) + .queue(); + } + + @Override + public void onLoadFailed(final LoadFailed e) { + event + .getHook() + .editOriginalEmbeds( + EmbedTemplates.error( + "Ошибка загрузки", + e.getException().getMessage() != null + ? e.getException().getMessage() + : "Неизвестная ошибка")) + .queue(); + } + + private void addAndReply(final Track t) { + manager.getScheduler().enqueue(t); + event + .getHook() + .editOriginalEmbeds( + EmbedTemplates.info( + "Добавлено в очередь", + "**" + + t.getInfo().getTitle() + + "**\n" + + TrackFormatter.formatDuration(t.getInfo().getLength()))) + .queue(); + } +} diff --git a/src/main/java/creator/loki/feature/music/util/GuildAudioActions.java b/src/main/java/creator/loki/feature/music/util/GuildAudioActions.java new file mode 100644 index 0000000..6f53010 --- /dev/null +++ b/src/main/java/creator/loki/feature/music/util/GuildAudioActions.java @@ -0,0 +1,13 @@ +package creator.loki.feature.music.util; + +import creator.loki.feature.music.manage.GuildMusicManager; +import net.dv8tion.jda.api.entities.Guild; + +public final class GuildAudioActions { + private GuildAudioActions() {} + + public static void stopAndDisconnect(final GuildMusicManager manager, final Guild guild) { + manager.stop(); + guild.getJDA().getDirectAudioController().disconnect(guild); + } +} diff --git a/src/main/java/creator/loki/feature/music/util/MusicEmbeds.java b/src/main/java/creator/loki/feature/music/util/MusicEmbeds.java new file mode 100644 index 0000000..2e05a19 --- /dev/null +++ b/src/main/java/creator/loki/feature/music/util/MusicEmbeds.java @@ -0,0 +1,22 @@ +package creator.loki.feature.music.util; + +import net.dv8tion.jda.api.EmbedBuilder; + +public final class MusicEmbeds { + private static final int ACCENT = 0x00AAFF; + private static final int ERROR = 0xFF4444; + + private MusicEmbeds() {} + + public static EmbedBuilder ok(final String title, final String desc) { + return new EmbedBuilder().setColor(ACCENT).setTitle(title).setDescription(desc); + } + + public static EmbedBuilder ok(final int color, final String title, final String desc) { + return new EmbedBuilder().setColor(color).setTitle(title).setDescription(desc); + } + + public static EmbedBuilder err(final String msg) { + return new EmbedBuilder().setColor(ERROR).setDescription("❌ " + msg); + } +} diff --git a/src/main/java/creator/loki/feature/music/util/QueueNotifier.java b/src/main/java/creator/loki/feature/music/util/QueueNotifier.java new file mode 100644 index 0000000..c25a8ec --- /dev/null +++ b/src/main/java/creator/loki/feature/music/util/QueueNotifier.java @@ -0,0 +1,23 @@ +package creator.loki.feature.music.util; + +import creator.loki.embed.EmbedTemplates; +import creator.loki.feature.music.manage.TrackScheduler; +import dev.arbjerg.lavalink.client.player.Track; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; + +public final class QueueNotifier { + private QueueNotifier() {} + + public static void enqueueAndNotify( + final TrackScheduler scheduler, final MessageChannel ch, final Track t) { + scheduler.enqueue(t); + ch.sendMessageEmbeds( + EmbedTemplates.info( + "Добавлено в очередь", + "**" + + t.getInfo().getTitle() + + "**\n" + + TrackFormatter.formatDuration(t.getInfo().getLength()))) + .queue(); + } +} diff --git a/src/main/java/creator/loki/feature/music/util/QueueView.java b/src/main/java/creator/loki/feature/music/util/QueueView.java new file mode 100644 index 0000000..75ceda7 --- /dev/null +++ b/src/main/java/creator/loki/feature/music/util/QueueView.java @@ -0,0 +1,66 @@ +package creator.loki.feature.music.util; + +import java.awt.Color; +import java.util.List; + +import creator.loki.embed.EmbedFactory; +import creator.loki.feature.music.manage.TrackScheduler; +import dev.arbjerg.lavalink.client.Link; +import dev.arbjerg.lavalink.client.player.Track; +import net.dv8tion.jda.api.entities.MessageEmbed; + +public final class QueueView { + private static final Color BLURPLE = new Color(88, 101, 242); + + private QueueView() {} + + public static MessageEmbed queue(final TrackScheduler scheduler) { + var cur = scheduler.getCurrentTrack(); + var q = scheduler.getQueue(); + if (cur == null && q.isEmpty()) { + return null; + } + var eb = EmbedFactory.create().withTitle("Очередь треков").withColor(BLURPLE); + if (cur != null) { + var ci = cur.getInfo(); + eb.addField("Сейчас играет", "**" + ci.getTitle() + "**\n" + ci.getAuthor(), false); + } + if (!q.isEmpty()) { + eb.addField("В очереди (" + q.size() + ")", queueList(q), false); + } + return eb.build(); + } + + public static MessageEmbed nowPlaying(final TrackScheduler scheduler, final Link link) { + var track = scheduler.getCurrentTrack(); + if (track == null) { + return null; + } + var pos = + link != null && link.getCachedPlayer() != null ? link.getCachedPlayer().getPosition() : 0L; + return EmbedFactory.create() + .withTitle("Сейчас играет") + .withColor(BLURPLE) + .addField("Название", "**" + track.getInfo().getTitle() + "**", false) + .addField("Автор", track.getInfo().getAuthor(), true) + .addField( + "Длительность", TrackFormatter.progressBar(pos, track.getInfo().getLength()), true) + .build(); + } + + private static String queueList(final List q) { + var sb = new StringBuilder(); + var show = Math.min(q.size(), 10); + for (int i = 0; i < show; i++) { + sb.append("`") + .append(i + 1) + .append(".` **") + .append(q.get(i).getInfo().getTitle()) + .append("**\n"); + } + if (q.size() > show) { + sb.append("... и ещё **").append(q.size() - show).append("** треков"); + } + return sb.toString(); + } +} diff --git a/src/main/java/creator/loki/feature/music/util/TrackFormatter.java b/src/main/java/creator/loki/feature/music/util/TrackFormatter.java new file mode 100644 index 0000000..7f7e27a --- /dev/null +++ b/src/main/java/creator/loki/feature/music/util/TrackFormatter.java @@ -0,0 +1,29 @@ +package creator.loki.feature.music.util; + +public final class TrackFormatter { + private TrackFormatter() {} + + public static String formatDuration(final long millis) { + long totalSec = millis / 1000; + long hours = totalSec / 3600; + long minutes = (totalSec % 3600) / 60; + long secs = totalSec % 60; + if (hours > 0) { + return String.format("%d:%02d:%02d", hours, minutes, secs); + } + return String.format("%d:%02d", minutes, secs); + } + + public static String progressBar(final long pos, final long duration) { + long dur = duration <= 0 ? 1 : duration; + int total = 20; + int filled = (int) ((double) pos / dur * total); + var bar = new StringBuilder("\u2503"); + for (int i = 0; i < total; i++) { + bar.append(i < filled ? "\u2588" : "\u2591"); + } + var p = String.format("%d:%02d", pos / 60000, (pos / 1000) % 60); + var d = String.format("%d:%02d", dur / 60000, (dur / 1000) % 60); + return bar + "\u2503 " + p + " / " + d; + } +} diff --git a/src/main/java/creator/loki/feature/music/util/TrackQuery.java b/src/main/java/creator/loki/feature/music/util/TrackQuery.java new file mode 100644 index 0000000..4ef57ba --- /dev/null +++ b/src/main/java/creator/loki/feature/music/util/TrackQuery.java @@ -0,0 +1,24 @@ +package creator.loki.feature.music.util; + +public final class TrackQuery { + private TrackQuery() {} + + public static boolean isUrl(final String raw) { + return raw.startsWith("http://") || raw.startsWith("https://"); + } + + public static String build(final String source, final String raw) { + if (isUrl(raw)) { + return raw; + } + if (raw.startsWith("ytsearch:") + || raw.startsWith("ytmsearch:") + || raw.startsWith("scsearch:")) { + return raw; + } + if ("sc".equals(source)) { + return "scsearch:" + raw; + } + return "ytsearch:" + raw; + } +} diff --git a/src/main/java/creator/loki/feature/music/util/VoiceCheck.java b/src/main/java/creator/loki/feature/music/util/VoiceCheck.java new file mode 100644 index 0000000..0e19626 --- /dev/null +++ b/src/main/java/creator/loki/feature/music/util/VoiceCheck.java @@ -0,0 +1,29 @@ +package creator.loki.feature.music.util; + +import creator.loki.embed.EmbedTemplates; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; + +public final class VoiceCheck { + private VoiceCheck() {} + + public static Member requireChannel(final SlashCommandInteractionEvent ev) { + var member = ev.getMember(); + if (member != null && member.getVoiceState().inAudioChannel()) { + return member; + } + ev.replyEmbeds(EmbedTemplates.error("Голосовой канал", "Зайди в голосовой канал сначала.")) + .setEphemeral(true) + .queue(); + return null; + } + + public static Member requireChannel(final MessageChannel ch, final Member member) { + if (member != null && member.getVoiceState().inAudioChannel()) { + return member; + } + ch.sendMessageEmbeds(MusicEmbeds.err("Зайди в голосовой канал сначала.").build()).queue(); + return null; + } +} diff --git a/src/main/java/creator/loki/feature/music/util/VolumeControl.java b/src/main/java/creator/loki/feature/music/util/VolumeControl.java new file mode 100644 index 0000000..6a8fc87 --- /dev/null +++ b/src/main/java/creator/loki/feature/music/util/VolumeControl.java @@ -0,0 +1,25 @@ +package creator.loki.feature.music.util; + +import java.util.function.Consumer; + +import dev.arbjerg.lavalink.client.Link; + +public final class VolumeControl { + private VolumeControl() {} + + public static int current(final Link link) { + return link != null && link.getCachedPlayer() != null + ? link.getCachedPlayer().getVolume() + : 100; + } + + public static int clamp(final int vol) { + return Math.max(0, Math.min(100, vol)); + } + + public static void set(final Link link, final int vol, final Consumer onError) { + if (link != null && link.getCachedPlayer() != null) { + link.createOrUpdatePlayer().setVolume(vol).subscribe(null, onError); + } + } +} diff --git a/src/main/java/creator/loki/feature/profile/render/RankCardGenerator.java b/src/main/java/creator/loki/feature/profile/render/RankCardGenerator.java index 576c9aa..76cf61b 100644 --- a/src/main/java/creator/loki/feature/profile/render/RankCardGenerator.java +++ b/src/main/java/creator/loki/feature/profile/render/RankCardGenerator.java @@ -11,9 +11,9 @@ import static creator.loki.feature.profile.render.RankCardConstants.GREY; import static creator.loki.feature.profile.render.RankCardConstants.H; import static creator.loki.feature.profile.render.RankCardConstants.OVERLAY; import static creator.loki.feature.profile.render.RankCardConstants.PAD; +import static creator.loki.feature.profile.render.RankCardConstants.STATS_Y; import static creator.loki.feature.profile.render.RankCardConstants.STAT_GAP; import static creator.loki.feature.profile.render.RankCardConstants.STAT_H; -import static creator.loki.feature.profile.render.RankCardConstants.STATS_Y; import static creator.loki.feature.profile.render.RankCardConstants.W; import static creator.loki.feature.profile.render.RankCardConstants.WHITE; diff --git a/src/main/java/creator/loki/feature/radio/commands/RadioCommand.java b/src/main/java/creator/loki/feature/radio/commands/RadioCommand.java index 5714b39..4972698 100644 --- a/src/main/java/creator/loki/feature/radio/commands/RadioCommand.java +++ b/src/main/java/creator/loki/feature/radio/commands/RadioCommand.java @@ -8,15 +8,13 @@ import creator.loki.command.CommandCategories; import creator.loki.command.CommandContext; import creator.loki.embed.EmbedTemplates; import creator.loki.feature.radio.service.RadioManager; -import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.entities.Member; public class RadioCommand implements Command { - private final RadioManager radioManager; + private final RadioSubcommands subcommands; @Inject public RadioCommand(RadioManager radioManager) { - this.radioManager = radioManager; + this.subcommands = new RadioSubcommands(radioManager); } @Override @@ -41,146 +39,29 @@ public class RadioCommand implements Command { @Override public void execute(final CommandContext context) { - Guild guild = context.getGuild(); - if (guild == null) { + if (context.getGuild() == null) { return; } List args = context.getArgs(); if (args.isEmpty()) { - context - .getChannel() - .sendMessageEmbeds(EmbedTemplates.error("Радио", "Использование: `" + getUsage() + "`")) - .queue(); + sendUsage(context); return; } - String sub = args.get(0).toLowerCase(); - switch (sub) { - case "set": - handleSet(context, args); - break; - case "on": - handleOn(context); - break; - case "off": - handleOff(context); - break; - case "volume": - handleVolume(context, args); - break; - default: - context - .getChannel() - .sendMessageEmbeds( - EmbedTemplates.error( - "Радио", "Неизвестная подкоманда. Использование: `" + getUsage() + "`")) - .queue(); - break; + switch (args.get(0).toLowerCase()) { + case "set" -> subcommands.set(context, args); + case "on" -> subcommands.on(context); + case "off" -> subcommands.off(context); + case "volume" -> subcommands.volume(context, args); + default -> sendUsage(context); } } - private void handleSet(final CommandContext context, final List args) { - if (args.size() < 2) { - context - .getChannel() - .sendMessageEmbeds(EmbedTemplates.error("Радио", "Укажи URL: `!radio set `")) - .queue(); - return; - } - String url = args.get(1); - if (!url.startsWith("http://") && !url.startsWith("https://")) { - context - .getChannel() - .sendMessageEmbeds( - EmbedTemplates.error("Радио", "URL должен начинаться с http:// или https://")) - .queue(); - return; - } - radioManager.setRadio(context.getGuild().getIdLong(), url); + private void sendUsage(final CommandContext context) { context .getChannel() - .sendMessageEmbeds(EmbedTemplates.success("Радио", "URL радио потока установлен.")) + .sendMessageEmbeds(EmbedTemplates.error("Радио", "Использование: `" + getUsage() + "`")) .queue(); } - - private void handleOn(final CommandContext context) { - if (!radioManager.hasRadioBot()) { - context - .getChannel() - .sendMessageEmbeds(EmbedTemplates.error("Радио", "Radio бот не настроен.")) - .queue(); - return; - } - - Member member = context.getAuthor(); - if (member == null || !member.getVoiceState().inAudioChannel()) { - context - .getChannel() - .sendMessageEmbeds( - EmbedTemplates.error("Голосовой канал", "Зайди в голосовой канал сначала.")) - .queue(); - return; - } - - long guildId = context.getGuild().getIdLong(); - String url = radioManager.getRadioUrl(guildId); - if (url == null) { - context - .getChannel() - .sendMessageEmbeds( - EmbedTemplates.error("Радио", "Сначала установи URL: `!radio set `")) - .queue(); - return; - } - - long channelId = member.getVoiceState().getChannel().getIdLong(); - Thread.startVirtualThread(() -> radioManager.startRadio(guildId, channelId)); - context - .getChannel() - .sendMessageEmbeds( - EmbedTemplates.success("Радио", "Запущено радио в твоём голосовом канале.")) - .queue(); - } - - private void handleOff(final CommandContext context) { - if (!radioManager.hasRadioBot()) { - context - .getChannel() - .sendMessageEmbeds(EmbedTemplates.error("Радио", "Radio бот не настроен.")) - .queue(); - return; - } - radioManager.stopRadio(context.getGuild().getIdLong()); - context - .getChannel() - .sendMessageEmbeds(EmbedTemplates.success("Радио", "Радио остановлено.")) - .queue(); - } - - private void handleVolume(final CommandContext context, final List args) { - long guildId = context.getGuild().getIdLong(); - if (args.size() < 2) { - int vol = radioManager.getVolume(guildId); - context - .getChannel() - .sendMessageEmbeds(EmbedTemplates.info("Радио", "Текущая громкость: **" + vol + "%**")) - .queue(); - return; - } - try { - int vol = Integer.parseInt(args.get(1)); - radioManager.setVolume(guildId, vol); - context - .getChannel() - .sendMessageEmbeds( - EmbedTemplates.success("Радио", "Громкость установлена на **" + vol + "%**")) - .queue(); - } catch (NumberFormatException e) { - context - .getChannel() - .sendMessageEmbeds(EmbedTemplates.error("Радио", "Укажи число от 0 до 100.")) - .queue(); - } - } } diff --git a/src/main/java/creator/loki/feature/radio/commands/RadioSubcommands.java b/src/main/java/creator/loki/feature/radio/commands/RadioSubcommands.java new file mode 100644 index 0000000..0642177 --- /dev/null +++ b/src/main/java/creator/loki/feature/radio/commands/RadioSubcommands.java @@ -0,0 +1,121 @@ +package creator.loki.feature.radio.commands; + +import java.util.List; + +import creator.loki.command.CommandContext; +import creator.loki.embed.EmbedTemplates; +import creator.loki.feature.radio.service.RadioManager; +import net.dv8tion.jda.api.entities.Member; + +/** Обработчики подкоманд префиксной команды радио. */ +public class RadioSubcommands { + private final RadioManager radioManager; + + public RadioSubcommands(RadioManager radioManager) { + this.radioManager = radioManager; + } + + public void set(final CommandContext context, final List args) { + if (args.size() < 2) { + context + .getChannel() + .sendMessageEmbeds(EmbedTemplates.error("Радио", "Укажи URL: `!radio set `")) + .queue(); + return; + } + String url = args.get(1); + if (!url.startsWith("http://") && !url.startsWith("https://")) { + context + .getChannel() + .sendMessageEmbeds( + EmbedTemplates.error("Радио", "URL должен начинаться с http:// или https://")) + .queue(); + return; + } + radioManager.setRadio(context.getGuild().getIdLong(), url); + context + .getChannel() + .sendMessageEmbeds(EmbedTemplates.success("Радио", "URL радио потока установлен.")) + .queue(); + } + + public void on(final CommandContext context) { + if (!radioManager.hasRadioBot()) { + context + .getChannel() + .sendMessageEmbeds(EmbedTemplates.error("Радио", "Radio бот не настроен.")) + .queue(); + return; + } + + Member member = context.getAuthor(); + if (member == null || !member.getVoiceState().inAudioChannel()) { + context + .getChannel() + .sendMessageEmbeds( + EmbedTemplates.error("Голосовой канал", "Зайди в голосовой канал сначала.")) + .queue(); + return; + } + + long guildId = context.getGuild().getIdLong(); + String url = radioManager.getRadioUrl(guildId); + if (url == null) { + context + .getChannel() + .sendMessageEmbeds( + EmbedTemplates.error("Радио", "Сначала установи URL: `!radio set `")) + .queue(); + return; + } + + long channelId = member.getVoiceState().getChannel().getIdLong(); + Thread.startVirtualThread(() -> radioManager.startRadio(guildId, channelId)); + context + .getChannel() + .sendMessageEmbeds( + EmbedTemplates.success("Радио", "Запущено радио в твоём голосовом канале.")) + .queue(); + } + + public void off(final CommandContext context) { + if (!radioManager.hasRadioBot()) { + context + .getChannel() + .sendMessageEmbeds(EmbedTemplates.error("Радио", "Radio бот не настроен.")) + .queue(); + return; + } + radioManager.stopRadio(context.getGuild().getIdLong()); + context + .getChannel() + .sendMessageEmbeds(EmbedTemplates.success("Радио", "Радио остановлено.")) + .queue(); + } + + public void volume(final CommandContext context, final List args) { + long guildId = context.getGuild().getIdLong(); + if (args.size() < 2) { + int vol = radioManager.getVolume(guildId); + context + .getChannel() + .sendMessageEmbeds(EmbedTemplates.info("Радио", "Текущая громкость: **" + vol + "%**")) + .queue(); + return; + } + try { + int vol = Integer.parseInt(args.get(1)); + radioManager.setVolume(guildId, vol); + context + .getChannel() + .sendMessageEmbeds( + EmbedTemplates.success("Радио", "Громкость установлена на **" + vol + "%**")) + .queue(); + } catch (NumberFormatException e) { + context + .getChannel() + .sendMessageEmbeds(EmbedTemplates.error("Радио", "Укажи число от 0 до 100.")) + .queue(); + } + } +} diff --git a/src/main/java/creator/loki/feature/radio/listener/RadioBotCommands.java b/src/main/java/creator/loki/feature/radio/listener/RadioBotCommands.java new file mode 100644 index 0000000..05031a4 --- /dev/null +++ b/src/main/java/creator/loki/feature/radio/listener/RadioBotCommands.java @@ -0,0 +1,109 @@ +package creator.loki.feature.radio.listener; + +import java.util.List; + +import creator.loki.feature.radio.service.RadioManager; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; +import net.dv8tion.jda.api.events.message.MessageReceivedEvent; + +/** Обработчики префиксных команд радио-бота. */ +public class RadioBotCommands { + private final RadioManager radioManager; + + public RadioBotCommands(RadioManager radioManager) { + this.radioManager = radioManager; + } + + public void play(final MessageReceivedEvent event, final List args) { + Member member = requireVoiceChannel(event); + if (member == null) { + return; + } + + if (args.isEmpty()) { + event.getChannel().sendMessage("❌ Укажи URL радиостанции.").queue(); + return; + } + + String url = args.get(0); + if (!url.startsWith("http://") && !url.startsWith("https://")) { + event.getChannel().sendMessage("❌ URL должен начинаться с http:// или https://").queue(); + return; + } + + Guild guild = event.getGuild(); + radioManager.setRadio(guild.getIdLong(), url); + + VoiceChannel vc = member.getVoiceState().getChannel().asVoiceChannel(); + Thread.startVirtualThread(() -> radioManager.startRadio(guild.getIdLong(), vc.getIdLong())); + + event.getChannel().sendMessage("📻 Радио запущено: **" + url + "**").queue(); + } + + public void stop(final MessageReceivedEvent event) { + Member member = requireVoiceChannel(event); + if (member == null) { + return; + } + + radioManager.stopRadio(event.getGuild().getIdLong()); + event.getChannel().sendMessage("⏹️ Радио остановлено.").queue(); + } + + public void volume(final MessageReceivedEvent event, final List args) { + long guildId = event.getGuild().getIdLong(); + if (args.isEmpty()) { + int vol = radioManager.getVolume(guildId); + event.getChannel().sendMessage("🔊 Текущая громкость: **" + vol + "%**").queue(); + return; + } + try { + int vol = Math.max(0, Math.min(100, Integer.parseInt(args.get(0)))); + radioManager.setVolume(guildId, vol); + event.getChannel().sendMessage("🔊 Громкость установлена на **" + vol + "%**").queue(); + } catch (NumberFormatException e) { + event.getChannel().sendMessage("❌ Укажи число от 0 до 100.").queue(); + } + } + + public void nowPlaying(final MessageReceivedEvent event) { + long guildId = event.getGuild().getIdLong(); + String url = radioManager.getRadioUrl(guildId); + if (url == null || !radioManager.isRadioActive(guildId)) { + event.getChannel().sendMessage("📭 Радио сейчас не играет.").queue(); + return; + } + event.getChannel().sendMessage("📻 **Сейчас играет радио:** " + url).queue(); + } + + public void help(final MessageReceivedEvent event) { + event + .getChannel() + .sendMessageEmbeds( + new EmbedBuilder() + .setColor(0x00AAFF) + .setTitle("📻 Radio Bot Commands") + .setDescription( + """ + `r!play ` — Запустить радио + `r!stop` — Остановить радио + `r!volume [0-100]` — Громкость + `r!np` — Что играет + `r!help` — Показать это сообщение + """) + .build()) + .queue(); + } + + private Member requireVoiceChannel(final MessageReceivedEvent event) { + Member member = event.getMember(); + if (member == null || !member.getVoiceState().inAudioChannel()) { + event.getChannel().sendMessage("❌ Зайди в голосовой канал сначала.").queue(); + return null; + } + return member; + } +} diff --git a/src/main/java/creator/loki/feature/radio/listener/RadioBotListener.java b/src/main/java/creator/loki/feature/radio/listener/RadioBotListener.java index 40afa1f..abae4a3 100644 --- a/src/main/java/creator/loki/feature/radio/listener/RadioBotListener.java +++ b/src/main/java/creator/loki/feature/radio/listener/RadioBotListener.java @@ -3,24 +3,18 @@ package creator.loki.feature.radio.listener; import java.util.List; import creator.loki.feature.radio.service.RadioManager; -import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.entities.Guild; -import net.dv8tion.jda.api.entities.Member; -import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; import org.jetbrains.annotations.NotNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; public class RadioBotListener extends ListenerAdapter { - private static final Logger logger = LoggerFactory.getLogger(RadioBotListener.class); private static final String PREFIX = "r!"; - private final RadioManager radioManager; + private final RadioBotCommands commands; public RadioBotListener(RadioManager radioManager) { - this.radioManager = radioManager; + this.commands = new RadioBotCommands(radioManager); } @Override @@ -50,115 +44,14 @@ public class RadioBotListener extends ListenerAdapter { } switch (cmd) { - case "play": - handlePlay(event, args); - break; - case "stop": - handleStop(event); - break; - case "volume": - handleVolume(event, args); - break; - case "np": - case "nowplaying": - handleNowPlaying(event); - break; - case "help": - case "commands": - handleHelp(event); - break; - default: - break; + case "play" -> commands.play(event, args); + case "stop" -> commands.stop(event); + case "volume" -> commands.volume(event, args); + case "np", "nowplaying" -> commands.nowPlaying(event); + case "help", "commands" -> commands.help(event); + default -> { + /* ignore */ + } } } - - private Member requireVoiceChannel(final MessageReceivedEvent event) { - Member member = event.getMember(); - if (member == null || !member.getVoiceState().inAudioChannel()) { - event.getChannel().sendMessage("❌ Зайди в голосовой канал сначала.").queue(); - return null; - } - return member; - } - - private void handlePlay(final MessageReceivedEvent event, final List args) { - Member member = requireVoiceChannel(event); - if (member == null) { - return; - } - - if (args.isEmpty()) { - event.getChannel().sendMessage("❌ Укажи URL радиостанции.").queue(); - return; - } - - String url = args.get(0); - if (!url.startsWith("http://") && !url.startsWith("https://")) { - event.getChannel().sendMessage("❌ URL должен начинаться с http:// или https://").queue(); - return; - } - - Guild guild = event.getGuild(); - radioManager.setRadio(guild.getIdLong(), url); - - VoiceChannel vc = member.getVoiceState().getChannel().asVoiceChannel(); - Thread.startVirtualThread(() -> radioManager.startRadio(guild.getIdLong(), vc.getIdLong())); - - event.getChannel().sendMessage("📻 Радио запущено: **" + url + "**").queue(); - } - - private void handleStop(final MessageReceivedEvent event) { - Member member = requireVoiceChannel(event); - if (member == null) { - return; - } - - radioManager.stopRadio(event.getGuild().getIdLong()); - event.getChannel().sendMessage("⏹️ Радио остановлено.").queue(); - } - - private void handleVolume(final MessageReceivedEvent event, final List args) { - long guildId = event.getGuild().getIdLong(); - if (args.isEmpty()) { - int vol = radioManager.getVolume(guildId); - event.getChannel().sendMessage("🔊 Текущая громкость: **" + vol + "%**").queue(); - return; - } - try { - int vol = Math.max(0, Math.min(100, Integer.parseInt(args.get(0)))); - radioManager.setVolume(guildId, vol); - event.getChannel().sendMessage("🔊 Громкость установлена на **" + vol + "%**").queue(); - } catch (NumberFormatException e) { - event.getChannel().sendMessage("❌ Укажи число от 0 до 100.").queue(); - } - } - - private void handleNowPlaying(final MessageReceivedEvent event) { - long guildId = event.getGuild().getIdLong(); - String url = radioManager.getRadioUrl(guildId); - if (url == null || !radioManager.isRadioActive(guildId)) { - event.getChannel().sendMessage("📭 Радио сейчас не играет.").queue(); - return; - } - event.getChannel().sendMessage("📻 **Сейчас играет радио:** " + url).queue(); - } - - private void handleHelp(final MessageReceivedEvent event) { - event - .getChannel() - .sendMessageEmbeds( - new EmbedBuilder() - .setColor(0x00AAFF) - .setTitle("📻 Radio Bot Commands") - .setDescription( - """ - `r!play ` — Запустить радио - `r!stop` — Остановить радио - `r!volume [0-100]` — Громкость - `r!np` — Что играет - `r!help` — Показать это сообщение - """) - .build()) - .queue(); - } } diff --git a/src/main/java/creator/loki/feature/radio/service/RadioManager.java b/src/main/java/creator/loki/feature/radio/service/RadioManager.java index 59ad763..dec460b 100644 --- a/src/main/java/creator/loki/feature/radio/service/RadioManager.java +++ b/src/main/java/creator/loki/feature/radio/service/RadioManager.java @@ -6,12 +6,9 @@ import java.util.concurrent.ConcurrentHashMap; import com.google.inject.Inject; import com.google.inject.Singleton; import creator.loki.feature.music.core.LavalinkManager; -import creator.loki.feature.music.manage.GuildMusicManager; import creator.loki.feature.music.repository.QueueRepository; import creator.loki.util.LoggerDecorator; -import dev.arbjerg.lavalink.client.Link; import net.dv8tion.jda.api.JDA; -import net.dv8tion.jda.api.entities.Guild; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -19,10 +16,11 @@ import org.slf4j.LoggerFactory; public class RadioManager { private static final Logger logger = LoggerFactory.getLogger(RadioManager.class); - private final Map radioStates = new ConcurrentHashMap<>(); + private final Map radioSessions = new ConcurrentHashMap<>(); private final QueueRepository queueRepository; private LavalinkManager lavalinkManager; private JDA radioJda; + private RadioPlayer player; @Inject public RadioManager(QueueRepository queueRepository) { @@ -31,18 +29,26 @@ public class RadioManager { public void setLavalinkManager(final LavalinkManager lm) { this.lavalinkManager = lm; + rebuildPlayer(); } public void setRadioJda(JDA radioJda) { this.radioJda = radioJda; + rebuildPlayer(); + } + + private void rebuildPlayer() { + if (radioJda != null && lavalinkManager != null) { + this.player = new RadioPlayer(radioJda, lavalinkManager, logger); + } } public void setRadio(final long guildId, String streamUrl) { - radioStates.compute( + radioSessions.compute( guildId, (id, state) -> { if (state == null) { - state = new GuildRadioState(); + state = new RadioSession(); } state.streamUrl = streamUrl; return state; @@ -51,16 +57,16 @@ public class RadioManager { } public String getRadioUrl(final long guildId) { - GuildRadioState state = radioStates.get(guildId); + RadioSession state = radioSessions.get(guildId); return state != null ? state.streamUrl : null; } public void startRadio(final long guildId, final long channelId) { - GuildRadioState state = radioStates.get(guildId); + RadioSession state = radioSessions.get(guildId); if (state == null || state.streamUrl == null || state.streamUrl.isBlank()) { return; } - if (radioJda == null || lavalinkManager == null) { + if (player == null) { return; } @@ -70,44 +76,14 @@ public class RadioManager { stopRadio(guildId); } - GuildRadioState finalState = radioStates.computeIfAbsent(guildId, id -> new GuildRadioState()); + RadioSession finalState = radioSessions.computeIfAbsent(guildId, id -> new RadioSession()); finalState.streamUrl = url; finalState.volume = vol; - - Guild guild = radioJda.getGuildById(guildId); - if (guild == null) { - logger.warn("Radio: guild {} not found in radio bot cache", guildId); - return; - } - var vc = guild.getVoiceChannelById(channelId); - if (vc == null) { - logger.warn("Radio: voice channel {} not found in guild {}", channelId, guildId); - return; - } - var self = guild.getMember(guild.getJDA().getSelfUser()); - if (self == null) { - logger.warn("Radio: self member is null for guild {}", guildId); - return; - } - if (!self.hasPermission(vc, net.dv8tion.jda.api.Permission.VOICE_CONNECT)) { - logger.warn("Radio: no CONNECT permission in channel {}/{}", channelId, guildId); - return; - } - - var link = lavalinkManager.getOrCreateLink(guildId); - guild.getJDA().getDirectAudioController().connect(vc); - - var mm = new GuildMusicManager(guildId, lavalinkManager, null); - mm.init(); - finalState.musicManager = mm; - lavalinkManager - .getOrCreateLink(guildId) - .loadItem(url) - .subscribe(new RadioStreamLoader(link, mm, guildId, vol, url, logger)); + finalState.musicManager = player.connect(guildId, channelId, vol, url); } public void stopRadio(final long guildId) { - GuildRadioState state = radioStates.remove(guildId); + RadioSession state = radioSessions.remove(guildId); if (state == null) { return; } @@ -115,23 +91,14 @@ public class RadioManager { state.musicManager.stop(); state.musicManager.destroy(); } - if (lavalinkManager != null) { - Link link = lavalinkManager.getClient().getLinkIfCached(guildId); - if (link != null) { - link.destroy().subscribe(); - } - } - if (radioJda != null) { - Guild guild = radioJda.getGuildById(guildId); - if (guild != null) { - guild.getAudioManager().closeAudioConnection(); - } + if (player != null) { + player.disconnect(guildId); } LoggerDecorator.music(logger, "Radio stopped for guild " + guildId); } public boolean isRadioActive(final long guildId) { - GuildRadioState state = radioStates.get(guildId); + RadioSession state = radioSessions.get(guildId); return state != null && state.streamUrl != null; } @@ -144,12 +111,12 @@ public class RadioManager { } public int getVolume(final long guildId) { - GuildRadioState state = radioStates.get(guildId); + RadioSession state = radioSessions.get(guildId); return state != null ? state.volume : 100; } public void setVolume(final long guildId, int volume) { - GuildRadioState state = radioStates.get(guildId); + RadioSession state = radioSessions.get(guildId); if (state == null) { return; } @@ -163,10 +130,4 @@ public class RadioManager { } } } - - private static class GuildRadioState { - String streamUrl; - GuildMusicManager musicManager; - int volume = 100; - } } diff --git a/src/main/java/creator/loki/feature/radio/service/RadioPlayer.java b/src/main/java/creator/loki/feature/radio/service/RadioPlayer.java new file mode 100644 index 0000000..bd0725b --- /dev/null +++ b/src/main/java/creator/loki/feature/radio/service/RadioPlayer.java @@ -0,0 +1,67 @@ +package creator.loki.feature.radio.service; + +import creator.loki.feature.music.core.LavalinkManager; +import creator.loki.feature.music.manage.GuildMusicManager; +import dev.arbjerg.lavalink.client.Link; +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.Guild; +import org.slf4j.Logger; + +/** Подключение и отключение радио-бота в голосовых каналах. */ +public class RadioPlayer { + private final JDA radioJda; + private final LavalinkManager lavalinkManager; + private final Logger logger; + + public RadioPlayer(JDA radioJda, LavalinkManager lavalinkManager, Logger logger) { + this.radioJda = radioJda; + this.lavalinkManager = lavalinkManager; + this.logger = logger; + } + + public GuildMusicManager connect( + final long guildId, final long channelId, final int volume, final String url) { + Guild guild = radioJda.getGuildById(guildId); + if (guild == null) { + logger.warn("Radio: guild {} not found in radio bot cache", guildId); + return null; + } + var vc = guild.getVoiceChannelById(channelId); + if (vc == null) { + logger.warn("Radio: voice channel {} not found in guild {}", channelId, guildId); + return null; + } + var self = guild.getMember(guild.getJDA().getSelfUser()); + if (self == null) { + logger.warn("Radio: self member is null for guild {}", guildId); + return null; + } + if (!self.hasPermission(vc, Permission.VOICE_CONNECT)) { + logger.warn("Radio: no CONNECT permission in channel {}/{}", channelId, guildId); + return null; + } + + var link = lavalinkManager.getOrCreateLink(guildId); + guild.getJDA().getDirectAudioController().connect(vc); + + var mm = new GuildMusicManager(guildId, lavalinkManager, null); + mm.init(); + lavalinkManager + .getOrCreateLink(guildId) + .loadItem(url) + .subscribe(new RadioStreamLoader(link, mm, guildId, volume, url, logger)); + return mm; + } + + public void disconnect(final long guildId) { + Link link = lavalinkManager.getClient().getLinkIfCached(guildId); + if (link != null) { + link.destroy().subscribe(); + } + Guild guild = radioJda.getGuildById(guildId); + if (guild != null) { + guild.getAudioManager().closeAudioConnection(); + } + } +} diff --git a/src/main/java/creator/loki/feature/radio/service/RadioSession.java b/src/main/java/creator/loki/feature/radio/service/RadioSession.java new file mode 100644 index 0000000..0460dc1 --- /dev/null +++ b/src/main/java/creator/loki/feature/radio/service/RadioSession.java @@ -0,0 +1,10 @@ +package creator.loki.feature.radio.service; + +import creator.loki.feature.music.manage.GuildMusicManager; + +/** Состояние радио для одной гильдии. */ +class RadioSession { + String streamUrl; + GuildMusicManager musicManager; + int volume = 100; +} diff --git a/src/main/java/creator/loki/feature/radio/service/RadioStreamLoader.java b/src/main/java/creator/loki/feature/radio/service/RadioStreamLoader.java index 291361b..58296db 100644 --- a/src/main/java/creator/loki/feature/radio/service/RadioStreamLoader.java +++ b/src/main/java/creator/loki/feature/radio/service/RadioStreamLoader.java @@ -19,12 +19,7 @@ class RadioStreamLoader extends AbstractAudioLoadResultHandler { private final Logger logger; RadioStreamLoader( - Link link, - GuildMusicManager mm, - long guildId, - int vol, - String url, - Logger logger) { + Link link, GuildMusicManager mm, long guildId, int vol, String url, Logger logger) { this.link = link; this.mm = mm; this.guildId = guildId; @@ -74,8 +69,6 @@ class RadioStreamLoader extends AbstractAudioLoadResultHandler { LoggerDecorator.warn( logger, "Failed to load radio: " - + (e.getException().getMessage() != null - ? e.getException().getMessage() - : "unknown")); + + (e.getException().getMessage() != null ? e.getException().getMessage() : "unknown")); } } diff --git a/src/main/java/creator/loki/feature/rating/config/RatingConfig.java b/src/main/java/creator/loki/feature/rating/config/RatingConfig.java index df05e48..777285d 100644 --- a/src/main/java/creator/loki/feature/rating/config/RatingConfig.java +++ b/src/main/java/creator/loki/feature/rating/config/RatingConfig.java @@ -1,125 +1,17 @@ package creator.loki.feature.rating.config; -import java.awt.Color; -import java.util.Collections; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; +/** Единая точка доступа к уровням всех рейтингов. */ public class RatingConfig { private RatingConfig() {} - private static final Map> LEVELS; - - static { - var m = new LinkedHashMap>(); - m.put( - "mustache", - List.of( - new RatingLevel(1000, "🎩 ЛЕГЕНДАРНЫЕ УСИЩИ!", new Color(255, 215, 0)), - new RatingLevel(80, "🧔 Роскошные усы!", new Color(139, 69, 19)), - new RatingLevel(60, "👨 Хорошие усы", new Color(160, 82, 45)), - new RatingLevel(40, "😊 Усики растут", new Color(205, 133, 63)), - new RatingLevel(20, "🙂 Лёгкая щетина", new Color(210, 180, 140)), - new RatingLevel(1, "😐 Почти нет усов", new Color(128, 128, 128)), - new RatingLevel(0, "🪒 Гладко выбрит", new Color(200, 200, 200)))); - m.put( - "iq", - List.of( - new RatingLevel(140, "🧠 Гений!", new Color(255, 215, 0)), - new RatingLevel(120, "🎓 Очень умный", new Color(100, 255, 150)), - new RatingLevel(100, "📚 Средний", new Color(52, 152, 219)), - new RatingLevel(80, "📖 Ниже среднего", new Color(255, 165, 0)), - new RatingLevel(0, "🤔 Низкий", new Color(231, 76, 60)))); - m.put( - "fat", - List.of( - new RatingLevel(90, "🐷 СВИНОТА!", new Color(255, 0, 0)), - new RatingLevel(70, "🍔 Очень жирный", new Color(255, 100, 0)), - new RatingLevel(50, "🍕 Жирноват", new Color(255, 165, 0)), - new RatingLevel(30, "👍 Нормальный вес", new Color(0, 200, 0)), - new RatingLevel(10, "🥗 Худой", new Color(100, 200, 255)), - new RatingLevel(0, "💀 Очень худой", new Color(150, 150, 150)))); - m.put( - "pp", - List.of( - new RatingLevel(25, "😳 НЕВЕРОЯТНО!", new Color(255, 215, 0)), - new RatingLevel(20, "😮 Впечатляет", new Color(100, 255, 150)), - new RatingLevel(15, "👍 Выше среднего", new Color(52, 152, 219)), - new RatingLevel(10, "🙂 Средний", new Color(128, 128, 128)), - new RatingLevel(5, "😐 Ниже среднего", new Color(255, 165, 0)), - new RatingLevel(0, "💀 Микро", new Color(231, 76, 60)))); - m.put( - "boykisser", - List.of( - new RatingLevel(90, "💋 ЛЕГЕНДАРНЫЙ BOYKISSER!", new Color(255, 105, 180)), - new RatingLevel(70, "😘 Активный boykisser", new Color(255, 182, 193)), - new RatingLevel(50, "😊 Любит целоваться", new Color(255, 192, 203)), - new RatingLevel(30, "🙂 Иногда целуется", new Color(255, 218, 185)), - new RatingLevel(10, "😐 Редко целуется", new Color(176, 196, 222)), - new RatingLevel(0, "😶 Не целуется", new Color(128, 128, 128)))); - m.put( - "rizz", - List.of( - new RatingLevel(90, "🗿 ЛЕГЕНДАРНЫЙ РИЗЗ!", new Color(255, 215, 0)), - new RatingLevel(70, "😎 Обаятельный", new Color(100, 255, 150)), - new RatingLevel(50, "🙂 Есть потенциал", new Color(52, 152, 219)), - new RatingLevel(30, "😐 Средний", new Color(128, 128, 128)), - new RatingLevel(10, "😬 Неловко", new Color(255, 165, 0)), - new RatingLevel(0, "💀 Кринж", new Color(231, 76, 60)))); - m.put( - "luck", - List.of( - new RatingLevel(90, "🍀 ИЗБРАННИК УДАЧИ!", new Color(255, 215, 0)), - new RatingLevel(70, "🍀 Везёт", new Color(100, 255, 150)), - new RatingLevel(50, "🙂 Бывает хуже", new Color(52, 152, 219)), - new RatingLevel(30, "😐 Ни туда ни сюда", new Color(128, 128, 128)), - new RatingLevel(10, "😬 Невезучий", new Color(255, 165, 0)), - new RatingLevel(0, "💀 Проклят", new Color(231, 76, 60)))); - m.put( - "simp", - List.of( - new RatingLevel(90, "👑 КОРОЛЬ СИМПОВ!", new Color(255, 105, 180)), - new RatingLevel(70, "😍 Задрот", new Color(255, 182, 193)), - new RatingLevel(50, "🙂 Нормальный", new Color(52, 152, 219)), - new RatingLevel(30, "😐 Холодный", new Color(128, 128, 128)), - new RatingLevel(10, "💪 Альфа", new Color(100, 255, 150)), - new RatingLevel(0, "🦍 ГОРИЛЛА", new Color(150, 100, 50)))); - m.put( - "vibe", - List.of( - new RatingLevel(90, "🎶 ЛЕГЕНДАРНЫЙ ВАЙБ!", new Color(255, 215, 0)), - new RatingLevel(70, "🔥 Крутой вайб", new Color(255, 100, 0)), - new RatingLevel(50, "🙂 Норм вайб", new Color(52, 152, 219)), - new RatingLevel(30, "😐 Так себе", new Color(128, 128, 128)), - new RatingLevel(10, "📉 Вайб упал", new Color(255, 165, 0)), - new RatingLevel(0, "💀 Вайба нет", new Color(231, 76, 60)))); - m.put( - "glaze", - List.of( - new RatingLevel(90, "🍩 НЕВЕРОЯТНО ГЛАЗИРОВАН!", new Color(255, 215, 0)), - new RatingLevel(70, "✨ Сияет", new Color(255, 200, 100)), - new RatingLevel(50, "🙂 Средне", new Color(52, 152, 219)), - new RatingLevel(30, "😐 Матовый", new Color(128, 128, 128)), - new RatingLevel(10, "🧊 Подтаивает", new Color(200, 200, 255)), - new RatingLevel(0, "💀 Сухой", new Color(150, 150, 150)))); - LEVELS = Collections.unmodifiableMap(m); - } - - /** Data-driven accessor — единая точка доступа для всех рейтингов. */ public static List getLevels(final String ratingName) { - var levels = LEVELS.get(ratingName); - if (levels == null) { - throw new IllegalArgumentException("Unknown rating: " + ratingName); - } - return levels; + return RatingLevelsCatalog.getLevels(ratingName); } - /** - * Data-driven поиск уровня: `findLevel(value, "iq")` вместо `findLevel(value, getIqLevels())`. - */ public static RatingLevel findLevel(final int value, final String ratingName) { - return RatingLevel.findLevel(value, getLevels(ratingName)); + return RatingLevelsCatalog.findLevel(value, ratingName); } public static List getMustacheLevels() { diff --git a/src/main/java/creator/loki/feature/rating/config/RatingLevelsCatalog.java b/src/main/java/creator/loki/feature/rating/config/RatingLevelsCatalog.java new file mode 100644 index 0000000..db0b50f --- /dev/null +++ b/src/main/java/creator/loki/feature/rating/config/RatingLevelsCatalog.java @@ -0,0 +1,121 @@ +package creator.loki.feature.rating.config; + +import java.awt.Color; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Каталог уровней всех рейтингов. */ +final class RatingLevelsCatalog { + private RatingLevelsCatalog() {} + + private static final Map> LEVELS; + + static { + var m = new LinkedHashMap>(); + m.put( + "mustache", + List.of( + new RatingLevel(1000, "🎩 ЛЕГЕНДАРНЫЕ УСИЩИ!", new Color(255, 215, 0)), + new RatingLevel(80, "🧔 Роскошные усы!", new Color(139, 69, 19)), + new RatingLevel(60, "👨 Хорошие усы", new Color(160, 82, 45)), + new RatingLevel(40, "😊 Усики растут", new Color(205, 133, 63)), + new RatingLevel(20, "🙂 Лёгкая щетина", new Color(210, 180, 140)), + new RatingLevel(1, "😐 Почти нет усов", new Color(128, 128, 128)), + new RatingLevel(0, "🪒 Гладко выбрит", new Color(200, 200, 200)))); + m.put( + "iq", + List.of( + new RatingLevel(140, "🧠 Гений!", new Color(255, 215, 0)), + new RatingLevel(120, "🎓 Очень умный", new Color(100, 255, 150)), + new RatingLevel(100, "📚 Средний", new Color(52, 152, 219)), + new RatingLevel(80, "📖 Ниже среднего", new Color(255, 165, 0)), + new RatingLevel(0, "🤔 Низкий", new Color(231, 76, 60)))); + m.put( + "fat", + List.of( + new RatingLevel(90, "🐷 СВИНОТА!", new Color(255, 0, 0)), + new RatingLevel(70, "🍔 Очень жирный", new Color(255, 100, 0)), + new RatingLevel(50, "🍕 Жирноват", new Color(255, 165, 0)), + new RatingLevel(30, "👍 Нормальный вес", new Color(0, 200, 0)), + new RatingLevel(10, "🥗 Худой", new Color(100, 200, 255)), + new RatingLevel(0, "💀 Очень худой", new Color(150, 150, 150)))); + m.put( + "pp", + List.of( + new RatingLevel(25, "😳 НЕВЕРОЯТНО!", new Color(255, 215, 0)), + new RatingLevel(20, "😮 Впечатляет", new Color(100, 255, 150)), + new RatingLevel(15, "👍 Выше среднего", new Color(52, 152, 219)), + new RatingLevel(10, "🙂 Средний", new Color(128, 128, 128)), + new RatingLevel(5, "😐 Ниже среднего", new Color(255, 165, 0)), + new RatingLevel(0, "💀 Микро", new Color(231, 76, 60)))); + m.put( + "boykisser", + List.of( + new RatingLevel(90, "💋 ЛЕГЕНДАРНЫЙ BOYKISSER!", new Color(255, 105, 180)), + new RatingLevel(70, "😘 Активный boykisser", new Color(255, 182, 193)), + new RatingLevel(50, "😊 Любит целоваться", new Color(255, 192, 203)), + new RatingLevel(30, "🙂 Иногда целуется", new Color(255, 218, 185)), + new RatingLevel(10, "😐 Редко целуется", new Color(176, 196, 222)), + new RatingLevel(0, "😶 Не целуется", new Color(128, 128, 128)))); + m.put( + "rizz", + List.of( + new RatingLevel(90, "🗿 ЛЕГЕНДАРНЫЙ РИЗЗ!", new Color(255, 215, 0)), + new RatingLevel(70, "😎 Обаятельный", new Color(100, 255, 150)), + new RatingLevel(50, "🙂 Есть потенциал", new Color(52, 152, 219)), + new RatingLevel(30, "😐 Средний", new Color(128, 128, 128)), + new RatingLevel(10, "😬 Неловко", new Color(255, 165, 0)), + new RatingLevel(0, "💀 Кринж", new Color(231, 76, 60)))); + m.put( + "luck", + List.of( + new RatingLevel(90, "🍀 ИЗБРАННИК УДАЧИ!", new Color(255, 215, 0)), + new RatingLevel(70, "🍀 Везёт", new Color(100, 255, 150)), + new RatingLevel(50, "🙂 Бывает хуже", new Color(52, 152, 219)), + new RatingLevel(30, "😐 Ни туда ни сюда", new Color(128, 128, 128)), + new RatingLevel(10, "😬 Невезучий", new Color(255, 165, 0)), + new RatingLevel(0, "💀 Проклят", new Color(231, 76, 60)))); + m.put( + "simp", + List.of( + new RatingLevel(90, "👑 КОРОЛЬ СИМПОВ!", new Color(255, 105, 180)), + new RatingLevel(70, "😍 Задрот", new Color(255, 182, 193)), + new RatingLevel(50, "🙂 Нормальный", new Color(52, 152, 219)), + new RatingLevel(30, "😐 Холодный", new Color(128, 128, 128)), + new RatingLevel(10, "💪 Альфа", new Color(100, 255, 150)), + new RatingLevel(0, "🦍 ГОРИЛЛА", new Color(150, 100, 50)))); + m.put( + "vibe", + List.of( + new RatingLevel(90, "🎶 ЛЕГЕНДАРНЫЙ ВАЙБ!", new Color(255, 215, 0)), + new RatingLevel(70, "🔥 Крутой вайб", new Color(255, 100, 0)), + new RatingLevel(50, "🙂 Норм вайб", new Color(52, 152, 219)), + new RatingLevel(30, "😐 Так себе", new Color(128, 128, 128)), + new RatingLevel(10, "📉 Вайб упал", new Color(255, 165, 0)), + new RatingLevel(0, "💀 Вайба нет", new Color(231, 76, 60)))); + m.put( + "glaze", + List.of( + new RatingLevel(90, "🍩 НЕВЕРОЯТНО ГЛАЗИРОВАН!", new Color(255, 215, 0)), + new RatingLevel(70, "✨ Сияет", new Color(255, 200, 100)), + new RatingLevel(50, "🙂 Средне", new Color(52, 152, 219)), + new RatingLevel(30, "😐 Матовый", new Color(128, 128, 128)), + new RatingLevel(10, "🧊 Подтаивает", new Color(200, 200, 255)), + new RatingLevel(0, "💀 Сухой", new Color(150, 150, 150)))); + LEVELS = Collections.unmodifiableMap(m); + } + + static List getLevels(final String ratingName) { + var levels = LEVELS.get(ratingName); + if (levels == null) { + throw new IllegalArgumentException("Unknown rating: " + ratingName); + } + return levels; + } + + static RatingLevel findLevel(final int value, final String ratingName) { + return RatingLevel.findLevel(value, getLevels(ratingName)); + } +} diff --git a/src/main/java/creator/loki/feature/reactroles/commands/ReactionRoleCommand.java b/src/main/java/creator/loki/feature/reactroles/commands/ReactionRoleCommand.java index 6176ae4..d3d46ec 100644 --- a/src/main/java/creator/loki/feature/reactroles/commands/ReactionRoleCommand.java +++ b/src/main/java/creator/loki/feature/reactroles/commands/ReactionRoleCommand.java @@ -1,7 +1,5 @@ package creator.loki.feature.reactroles.commands; -import java.util.List; - import com.google.inject.Inject; import creator.loki.command.Command; import creator.loki.command.CommandCategories; @@ -11,11 +9,11 @@ import creator.loki.feature.reactroles.repository.ReactionRoleRepository; import net.dv8tion.jda.api.Permission; public class ReactionRoleCommand implements Command { - private final ReactionRoleRepository repository; + private final ReactionRoleSubcommands subcommands; @Inject public ReactionRoleCommand(ReactionRoleRepository repository) { - this.repository = repository; + this.subcommands = new ReactionRoleSubcommands(repository); } @Override @@ -59,133 +57,15 @@ public class ReactionRoleCommand implements Command { var args = context.getArgs(); if (args.isEmpty()) { - sendHelp(context); + subcommands.help(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); + switch (args.get(0).toLowerCase()) { + case "setup" -> subcommands.setup(context, guild, args); + case "remove" -> subcommands.remove(context, args); + case "list" -> subcommands.list(context, guild, args); + default -> subcommands.help(context); } } - - private void handleSetup( - final CommandContext context, - final net.dv8tion.jda.api.entities.Guild guild, - final List args) { - if (args.size() < 4) { - context - .getChannel() - .sendMessageEmbeds( - EmbedTemplates.error( - "Неверное использование", - "Используйте: `reactionrole setup `")) - .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(final CommandContext context, final List args) { - if (args.size() < 3) { - context - .getChannel() - .sendMessageEmbeds( - EmbedTemplates.error( - "Неверное использование", - "Используйте: `reactionrole remove `")) - .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( - final CommandContext context, - final net.dv8tion.jda.api.entities.Guild guild, - final List args) { - List 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(final CommandContext context) { - context - .getChannel() - .sendMessageEmbeds( - EmbedTemplates.info( - "Reaction Role", - "`reactionrole setup ` — добавить связь\n" - + "`reactionrole remove ` — удалить связь\n" - + "`reactionrole list [messageId]` — список связей")) - .queue(); - } } diff --git a/src/main/java/creator/loki/feature/reactroles/commands/ReactionRoleSubcommands.java b/src/main/java/creator/loki/feature/reactroles/commands/ReactionRoleSubcommands.java new file mode 100644 index 0000000..43f88a7 --- /dev/null +++ b/src/main/java/creator/loki/feature/reactroles/commands/ReactionRoleSubcommands.java @@ -0,0 +1,125 @@ +package creator.loki.feature.reactroles.commands; + +import java.util.List; + +import creator.loki.command.CommandContext; +import creator.loki.embed.EmbedTemplates; +import creator.loki.feature.reactroles.repository.ReactionRoleRepository; +import net.dv8tion.jda.api.entities.Guild; + +final class ReactionRoleSubcommands { + private final ReactionRoleRepository repository; + + ReactionRoleSubcommands(ReactionRoleRepository repository) { + this.repository = repository; + } + + void setup(final CommandContext context, final Guild guild, final List args) { + if (args.size() < 4) { + context + .getChannel() + .sendMessageEmbeds( + EmbedTemplates.error( + "Неверное использование", + "Используйте: `reactionrole setup `")) + .queue(); + return; + } + + String messageId = args.get(1); + String emoji = args.get(2); + String roleId = args.get(3).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(); + } + + void remove(final CommandContext context, final List args) { + if (args.size() < 3) { + context + .getChannel() + .sendMessageEmbeds( + EmbedTemplates.error( + "Неверное использование", + "Используйте: `reactionrole remove `")) + .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(); + } + + void list(final CommandContext context, final Guild guild, final List args) { + List 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(); + } + + void help(final CommandContext context) { + context + .getChannel() + .sendMessageEmbeds( + EmbedTemplates.info( + "Reaction Role", + "`reactionrole setup ` — добавить связь\n" + + "`reactionrole remove ` — удалить связь\n" + + "`reactionrole list [messageId]` — список связей")) + .queue(); + } +} diff --git a/src/main/java/creator/loki/feature/role/command/DeleteRoleCommand.java b/src/main/java/creator/loki/feature/role/command/DeleteRoleCommand.java index 3e3bad7..74483d6 100644 --- a/src/main/java/creator/loki/feature/role/command/DeleteRoleCommand.java +++ b/src/main/java/creator/loki/feature/role/command/DeleteRoleCommand.java @@ -44,34 +44,36 @@ public class DeleteRoleCommand implements Command { if (!sendErrorIfNull(context, guild, "Ошибка", "Команда доступна только на сервере")) { return; } - if (!guild.getMember(guild.getJDA().getSelfUser()) - .hasPermission(Permission.MANAGE_ROLES)) { + if (!guild.getMember(guild.getJDA().getSelfUser()).hasPermission(Permission.MANAGE_ROLES)) { sendError(context, "Недостаточно прав", "У бота нет прав на управление ролями"); return; } if (context.getArgs().isEmpty()) { - sendError(context, "Неверное использование", - "Используй: `!deleterole <название>`"); + sendError(context, "Неверное использование", "Используй: `!deleterole <название>`"); return; } String roleName = String.join(" ", context.getArgs()); List roles = guild.getRolesByName(roleName, true); if (roles.isEmpty()) { - sendError(context, "Роль не найдена", - "Роль с названием `" + roleName + "` не найдена на сервере"); + sendError( + context, "Роль не найдена", "Роль с названием `" + roleName + "` не найдена на сервере"); return; } Role role = roles.get(0); String userId = context.getAuthor().getId(); String guildId = guild.getId(); if (!roleManager.isOwnedBy(guildId, userId, role.getId())) { - sendError(context, "Нет прав", + sendError( + context, + "Нет прав", "Вы можете удалять только свои кастомные роли\n" + "Используй `!myroles` чтобы посмотреть список своих ролей"); return; } if (!guild.getMember(guild.getJDA().getSelfUser()).canInteract(role)) { - sendError(context, "Невозможно удалить", + sendError( + context, + "Невозможно удалить", "Роль находится выше роли бота в иерархии. " + "Переместите роль бота выше в настройках сервера."); return; @@ -94,12 +96,18 @@ public class DeleteRoleCommand implements Command { .sendMessageEmbeds( EmbedTemplates.success( "Роль удалена", - "Роль " + mention + " успешно удалена\n" - + "Ваши кастомные роли: " + remaining + "/" + max)) + "Роль " + + mention + + " успешно удалена\n" + + "Ваши кастомные роли: " + + remaining + + "/" + + max)) .queue(); }, - error -> sendError(context, "Ошибка удаления", - "Не удалось удалить роль: " + error.getMessage())); + error -> + sendError( + context, "Ошибка удаления", "Не удалось удалить роль: " + error.getMessage())); } private static boolean sendErrorIfNull( @@ -112,9 +120,6 @@ public class DeleteRoleCommand implements Command { } private static void sendError(CommandContext context, String title, String desc) { - context - .getChannel() - .sendMessageEmbeds(EmbedTemplates.error(title, desc)) - .queue(); + context.getChannel().sendMessageEmbeds(EmbedTemplates.error(title, desc)).queue(); } } diff --git a/src/main/java/creator/loki/feature/role/command/EditRoleCommand.java b/src/main/java/creator/loki/feature/role/command/EditRoleCommand.java index 2839f32..d0efc65 100644 --- a/src/main/java/creator/loki/feature/role/command/EditRoleCommand.java +++ b/src/main/java/creator/loki/feature/role/command/EditRoleCommand.java @@ -47,22 +47,18 @@ public class EditRoleCommand implements Command { if (!sendErrorIfNull(context, guild, "Ошибка", "Команда доступна только на сервере")) { return; } - if (!guild.getMember(guild.getJDA().getSelfUser()) - .hasPermission(Permission.MANAGE_ROLES)) { + if (!guild.getMember(guild.getJDA().getSelfUser()).hasPermission(Permission.MANAGE_ROLES)) { sendError(context, "Недостаточно прав", "У бота нет прав на управление ролями"); return; } if (context.getArgs().size() < 2) { - sendError(context, "Неверное использование", - "Используй: `!editrole <название> <#цвет>`"); + sendError(context, "Неверное использование", "Используй: `!editrole <название> <#цвет>`"); return; } String colorArg = context.getArgs().get(context.getArgs().size() - 1); - String roleName = String.join(" ", - context.getArgs().subList(0, context.getArgs().size() - 1)); + String roleName = String.join(" ", context.getArgs().subList(0, context.getArgs().size() - 1)); if (!HEX_PATTERN.matcher(colorArg).matches()) { - sendError(context, "Неверный цвет", - "Укажите цвет в HEX формате (например: #FF5733)"); + sendError(context, "Неверный цвет", "Укажите цвет в HEX формате (например: #FF5733)"); return; } Color color; @@ -70,27 +66,23 @@ public class EditRoleCommand implements Command { String hex = colorArg.startsWith("#") ? colorArg : "#" + colorArg; color = Color.decode(hex); } catch (NumberFormatException e) { - sendError(context, "Неверный цвет", - "Не удалось распознать цвет: " + colorArg); + sendError(context, "Неверный цвет", "Не удалось распознать цвет: " + colorArg); return; } List roles = guild.getRolesByName(roleName, true); if (roles.isEmpty()) { - sendError(context, "Роль не найдена", - "Роль с названием `" + roleName + "` не найдена"); + sendError(context, "Роль не найдена", "Роль с названием `" + roleName + "` не найдена"); return; } Role role = roles.get(0); String userId = context.getAuthor().getId(); String guildId = guild.getId(); if (!roleManager.isOwnedBy(guildId, userId, role.getId())) { - sendError(context, "Нет прав", - "Вы можете изменять только свои кастомные роли"); + sendError(context, "Нет прав", "Вы можете изменять только свои кастомные роли"); return; } if (!guild.getMember(guild.getJDA().getSelfUser()).canInteract(role)) { - sendError(context, "Невозможно изменить", - "Роль находится выше роли бота в иерархии"); + sendError(context, "Невозможно изменить", "Роль находится выше роли бота в иерархии"); return; } role.getManager() @@ -106,8 +98,8 @@ public class EditRoleCommand implements Command { "Цвет роли " + role.getAsMention() + " изменён на " + hex)) .queue(); }, - error -> sendError(context, "Ошибка", - "Не удалось изменить цвет: " + error.getMessage())); + error -> + sendError(context, "Ошибка", "Не удалось изменить цвет: " + error.getMessage())); } private static boolean sendErrorIfNull( @@ -120,9 +112,6 @@ public class EditRoleCommand implements Command { } private static void sendError(CommandContext context, String title, String desc) { - context - .getChannel() - .sendMessageEmbeds(EmbedTemplates.error(title, desc)) - .queue(); + context.getChannel().sendMessageEmbeds(EmbedTemplates.error(title, desc)).queue(); } } diff --git a/src/main/java/creator/loki/feature/ticket/listener/TicketCreator.java b/src/main/java/creator/loki/feature/ticket/listener/TicketCreator.java new file mode 100644 index 0000000..083f9d8 --- /dev/null +++ b/src/main/java/creator/loki/feature/ticket/listener/TicketCreator.java @@ -0,0 +1,108 @@ +package creator.loki.feature.ticket.listener; + +import java.awt.Color; +import java.util.EnumSet; + +import creator.loki.feature.ticket.repository.TicketRepository; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.buttons.Button; +import net.dv8tion.jda.api.entities.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.managers.channel.concrete.TextChannelManager; +import net.dv8tion.jda.api.requests.restaction.ChannelAction; + +final class TicketCreator { + private final TicketRepository ticketRepository; + + TicketCreator(TicketRepository ticketRepository) { + this.ticketRepository = ticketRepository; + } + + void create(final ButtonInteractionEvent event) { + var guild = event.getGuild(); + if (guild == null) { + return; + } + + var member = event.getMember(); + if (member == null) { + return; + } + + String channelName = "ticket-" + member.getUser().getName(); + + ChannelAction action = guild.createTextChannel(channelName); + + Category category = findTicketCategory(guild); + if (category != null) { + action = action.setParent(category); + } + + action.queue( + channel -> { + setupChannel(channel, guild, member); + sendWelcome(channel); + ticketRepository.createTicket(channel.getId(), guild.getId(), member.getId()); + event.reply("🎫 Создан тикет: " + channel.getAsMention()).setEphemeral(true).queue(); + }); + } + + private void setupChannel( + final TextChannel channel, + final net.dv8tion.jda.api.entities.Guild guild, + final net.dv8tion.jda.api.entities.Member member) { + 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(); + } + + private void sendWelcome(final TextChannel channel) { + var embed = + new EmbedBuilder() + .setColor(Color.CYAN) + .setTitle("🎫 Поддержка") + .setDescription("Опишите вашу проблему, администраторы скоро ответят.") + .build(); + + channel + .sendMessageEmbeds(embed) + .addComponents(ActionRow.of(Button.danger("ticket_close", "🔒 Закрыть"))) + .queue(); + } + + private Category findTicketCategory(final net.dv8tion.jda.api.entities.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; + } +} diff --git a/src/main/java/creator/loki/feature/ticket/listener/TicketListener.java b/src/main/java/creator/loki/feature/ticket/listener/TicketListener.java index 16a8733..af1203d 100644 --- a/src/main/java/creator/loki/feature/ticket/listener/TicketListener.java +++ b/src/main/java/creator/loki/feature/ticket/listener/TicketListener.java @@ -1,8 +1,5 @@ package creator.loki.feature.ticket.listener; -import java.awt.Color; -import java.util.EnumSet; - import com.google.inject.Inject; import com.google.inject.Singleton; import creator.loki.feature.ticket.repository.TicketRepository; @@ -13,20 +10,19 @@ import net.dv8tion.jda.api.components.actionrow.ActionRow; import net.dv8tion.jda.api.components.buttons.Button; import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Member; -import net.dv8tion.jda.api.entities.channel.concrete.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; @Singleton public class TicketListener extends ListenerAdapter { private final TicketRepository ticketRepository; + private final TicketCreator creator; @Inject public TicketListener(TicketRepository ticketRepository) { this.ticketRepository = ticketRepository; + this.creator = new TicketCreator(ticketRepository); } @Override @@ -37,7 +33,7 @@ public class TicketListener extends ListenerAdapter { } switch (buttonId) { - case "ticket_create" -> handleCreate(event); + case "ticket_create" -> creator.create(event); case "ticket_close" -> handleClose(event); case "ticket_delete" -> handleDelete(event); default -> { @@ -46,74 +42,6 @@ public class TicketListener extends ListenerAdapter { } } - private void handleCreate(final 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 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(final ButtonInteractionEvent event) { Guild guild = event.getGuild(); if (guild == null) { @@ -152,7 +80,7 @@ public class TicketListener extends ListenerAdapter { .getMessage() .editMessageEmbeds( new EmbedBuilder() - .setColor(Color.GRAY) + .setColor(java.awt.Color.GRAY) .setTitle("✅ Тикет закрыт") .setDescription("Тикет закрыт пользователем " + member.getAsMention()) .build()) @@ -175,16 +103,4 @@ public class TicketListener extends ListenerAdapter { event.reply("🗑 Канал будет удалён.").setEphemeral(true).queue(); } - - private Category findTicketCategory(final 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; - } } diff --git a/src/main/java/creator/loki/feature/tiktok/TikTokCommand.java b/src/main/java/creator/loki/feature/tiktok/TikTokCommand.java index 39b3119..9b5e6c5 100644 --- a/src/main/java/creator/loki/feature/tiktok/TikTokCommand.java +++ b/src/main/java/creator/loki/feature/tiktok/TikTokCommand.java @@ -1,30 +1,23 @@ package creator.loki.feature.tiktok; -import java.awt.Color; import java.io.IOException; -import java.io.InputStream; -import java.util.concurrent.TimeUnit; import com.google.inject.Inject; import creator.loki.command.Command; import creator.loki.command.CommandCategories; import creator.loki.command.CommandContext; -import creator.loki.embed.EmbedFactory; import creator.loki.embed.EmbedTemplates; +import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.MessageEmbed; import net.dv8tion.jda.api.utils.FileUpload; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class TikTokCommand implements Command { private static final Logger logger = LoggerFactory.getLogger(TikTokCommand.class); - private static final long MAX_FILE_SIZE = 8 * 1024 * 1024; private final TikTokApi tikTokApi; - private final OkHttpClient downloadClient; + private final TikTokVideoDownloader downloader; private final TempFileManager tempFileManager; private final String externalUrl; @@ -33,11 +26,7 @@ public class TikTokCommand implements Command { this.tikTokApi = tikTokApi; this.tempFileManager = tempFileManager; this.externalUrl = externalUrl; - this.downloadClient = - new OkHttpClient.Builder() - .connectTimeout(15, TimeUnit.SECONDS) - .readTimeout(60, TimeUnit.SECONDS) - .build(); + this.downloader = new TikTokVideoDownloader(); } @Override @@ -87,28 +76,24 @@ public class TikTokCommand implements Command { } private void processVideoAsync( - CommandContext context, String url, - net.dv8tion.jda.api.entities.Message loadingMsg) { + final CommandContext context, final String url, final Message loadingMsg) { Thread.startVirtualThread( () -> { try { TikTokApi.TikTokResult result = tikTokApi.fetchVideo(url); - byte[] videoBytes = downloadVideo(result.getVideoUrl()); - String durationStr = formatDuration(result.getDuration()); - if (videoBytes.length <= MAX_FILE_SIZE) { - sendDirectVideo(context, loadingMsg, result, videoBytes, durationStr); - } else { + byte[] videoBytes = downloader.download(result.getVideoUrl()); + String durationStr = TikTokEmbeds.formatDuration(result.getDuration()); + if (TikTokVideoDownloader.exceedsDiscordLimit(videoBytes)) { sendTempLink(context, loadingMsg, result, videoBytes, durationStr); + } else { + sendDirectVideo(context, loadingMsg, result, videoBytes, durationStr); } } catch (TikTokApi.TikTokApiException e) { - loadingMsg - .editMessageEmbeds(EmbedTemplates.error("Ошибка", e.getMessage())) - .queue(); + loadingMsg.editMessageEmbeds(EmbedTemplates.error("Ошибка", e.getMessage())).queue(); } catch (IOException e) { logger.error("Failed to download TikTok video", e); loadingMsg - .editMessageEmbeds( - EmbedTemplates.error("Ошибка", "Ошибка загрузки видео")) + .editMessageEmbeds(EmbedTemplates.error("Ошибка", "Ошибка загрузки видео")) .queue(); } catch (Exception e) { logger.error("Unexpected error in TikTokCommand", e); @@ -120,19 +105,12 @@ public class TikTokCommand implements Command { } private void sendDirectVideo( - CommandContext context, - net.dv8tion.jda.api.entities.Message loadingMsg, - TikTokApi.TikTokResult result, - byte[] videoBytes, - String durationStr) { - MessageEmbed embed = - EmbedFactory.create() - .withTitle(truncate(result.getTitle(), 256)) - .withAuthor("@" + result.getAuthor()) - .withColor(new Color(0, 0, 0)) - .addField("Длительность", durationStr, true) - .withThumbnail(result.getCover()) - .build(); + final CommandContext context, + final Message loadingMsg, + final TikTokApi.TikTokResult result, + final byte[] videoBytes, + final String durationStr) { + MessageEmbed embed = TikTokEmbeds.direct(result, durationStr); context .getChannel() .sendFiles(FileUpload.fromData(videoBytes, "tiktok.mp4")) @@ -141,60 +119,28 @@ public class TikTokCommand implements Command { success -> loadingMsg.delete().queue(), failure -> loadingMsg - .editMessageEmbeds( - EmbedTemplates.error("Ошибка", "Не удалось отправить видео")) + .editMessageEmbeds(EmbedTemplates.error("Ошибка", "Не удалось отправить видео")) .queue()); } private void sendTempLink( - CommandContext context, - net.dv8tion.jda.api.entities.Message loadingMsg, - TikTokApi.TikTokResult result, - byte[] videoBytes, - String durationStr) { + final CommandContext context, + final Message loadingMsg, + final TikTokApi.TikTokResult result, + final byte[] videoBytes, + final String durationStr) { try { String key = tempFileManager.save(videoBytes, "tiktok.mp4"); String downloadUrl = externalUrl + "/d/" + key; - double sizeMb = videoBytes.length / (1024.0 * 1024.0); - MessageEmbed embed = - EmbedFactory.create() - .withTitle(truncate(result.getTitle(), 256)) - .withAuthor("@" + result.getAuthor()) - .withColor(new Color(0, 0, 0)) - .addField("Длительность", durationStr, true) - .addField("Размер", String.format("%.1f MB", sizeMb), true) - .addField("Скачать", "[Нажми чтобы скачать](" + downloadUrl + ")", false) - .withFooter("Ссылка действительна 24 часа") - .withThumbnail(result.getCover()) - .build(); - loadingMsg.editMessageEmbeds(embed).queue(); + loadingMsg + .editMessageEmbeds( + TikTokEmbeds.tempLink(result, durationStr, downloadUrl, videoBytes.length)) + .queue(); } catch (IOException e) { logger.error("Failed to save TikTok video", e); - loadingMsg.editMessageEmbeds(EmbedTemplates.error("Ошибка", "Не удалось сохранить видео")).queue(); + loadingMsg + .editMessageEmbeds(EmbedTemplates.error("Ошибка", "Не удалось сохранить видео")) + .queue(); } } - private byte[] downloadVideo(final String videoUrl) throws IOException { - Request request = new Request.Builder().url(videoUrl).build(); - try (Response response = downloadClient.newCall(request).execute()) { - if (!response.isSuccessful()) { - throw new IOException("Download failed: HTTP " + response.code()); - } - try (InputStream is = response.body().byteStream()) { - return is.readAllBytes(); - } - } - } - - private String formatDuration(final int seconds) { - int min = seconds / 60; - int sec = seconds % 60; - return String.format("%d:%02d", min, sec); - } - - private String truncate(String str, final int maxLen) { - if (str == null || str.isBlank()) { - return "TikTok Video"; - } - return str.length() > maxLen ? str.substring(0, maxLen - 3) + "..." : str; - } } diff --git a/src/main/java/creator/loki/feature/tiktok/TikTokEmbeds.java b/src/main/java/creator/loki/feature/tiktok/TikTokEmbeds.java new file mode 100644 index 0000000..089d77d --- /dev/null +++ b/src/main/java/creator/loki/feature/tiktok/TikTokEmbeds.java @@ -0,0 +1,49 @@ +package creator.loki.feature.tiktok; + +import java.awt.Color; + +import creator.loki.embed.EmbedFactory; +import net.dv8tion.jda.api.entities.MessageEmbed; + +/** Построение embed-сообщений для результатов поиска TikTok. */ +public final class TikTokEmbeds { + private TikTokEmbeds() {} + + public static MessageEmbed direct(final TikTokApi.TikTokResult result, final String durationStr) { + return base(result).addField("Длительность", durationStr, true).build(); + } + + public static MessageEmbed tempLink( + final TikTokApi.TikTokResult result, + final String durationStr, + final String downloadUrl, + final long videoBytes) { + return base(result) + .addField("Длительность", durationStr, true) + .addField("Размер", String.format("%.1f MB", videoBytes / (1024.0 * 1024.0)), true) + .addField("Скачать", "[Нажми чтобы скачать](" + downloadUrl + ")", false) + .withFooter("Ссылка действительна 24 часа") + .build(); + } + + private static EmbedFactory base(final TikTokApi.TikTokResult result) { + return EmbedFactory.create() + .withTitle(truncate(result.getTitle(), 256)) + .withAuthor("@" + result.getAuthor()) + .withColor(new Color(0, 0, 0)) + .withThumbnail(result.getCover()); + } + + public static String formatDuration(final int seconds) { + int min = seconds / 60; + int sec = seconds % 60; + return String.format("%d:%02d", min, sec); + } + + public static String truncate(String str, final int maxLen) { + if (str == null || str.isBlank()) { + return "TikTok Video"; + } + return str.length() > maxLen ? str.substring(0, maxLen - 3) + "..." : str; + } +} diff --git a/src/main/java/creator/loki/feature/tiktok/TikTokVideoDownloader.java b/src/main/java/creator/loki/feature/tiktok/TikTokVideoDownloader.java new file mode 100644 index 0000000..509fe03 --- /dev/null +++ b/src/main/java/creator/loki/feature/tiktok/TikTokVideoDownloader.java @@ -0,0 +1,40 @@ +package creator.loki.feature.tiktok; + +import java.io.IOException; +import java.io.InputStream; +import java.util.concurrent.TimeUnit; + +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; + +/** Загрузка видео TikTok во временное хранилище. */ +public class TikTokVideoDownloader { + public static final long MAX_FILE_SIZE = 8 * 1024 * 1024; + + private final OkHttpClient client; + + public TikTokVideoDownloader() { + this.client = + new OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .build(); + } + + public byte[] download(final String videoUrl) throws IOException { + Request request = new Request.Builder().url(videoUrl).build(); + try (Response response = client.newCall(request).execute()) { + if (!response.isSuccessful()) { + throw new IOException("Download failed: HTTP " + response.code()); + } + try (InputStream is = response.body().byteStream()) { + return is.readAllBytes(); + } + } + } + + public static boolean exceedsDiscordLimit(final byte[] videoBytes) { + return videoBytes.length > MAX_FILE_SIZE; + } +} diff --git a/src/main/java/creator/loki/integration/nekos/NekosLifeApi.java b/src/main/java/creator/loki/integration/nekos/NekosLifeApi.java index a0f1924..92ea174 100644 --- a/src/main/java/creator/loki/integration/nekos/NekosLifeApi.java +++ b/src/main/java/creator/loki/integration/nekos/NekosLifeApi.java @@ -78,8 +78,7 @@ public class NekosLifeApi { JsonObject json = JsonParser.parseString(response.body().string()).getAsJsonObject(); return json.get("url").getAsString(); } catch (IOException e) { - logger.error( - "Error fetching image from nekos.life (attempt {}/{})", attempt, maxRetries, e); + logger.error("Error fetching image from nekos.life (attempt {}/{})", attempt, maxRetries, e); return null; } } diff --git a/src/test/java/creator/loki/core/registry/SmokeCommandRegistrationTest.java b/src/test/java/creator/loki/core/registry/SmokeCommandRegistrationTest.java index 48b02e6..74620cb 100644 --- a/src/test/java/creator/loki/core/registry/SmokeCommandRegistrationTest.java +++ b/src/test/java/creator/loki/core/registry/SmokeCommandRegistrationTest.java @@ -35,8 +35,6 @@ import creator.loki.feature.music.core.SearchManager; import creator.loki.feature.profile.service.ProfileService; import creator.loki.feature.rating.repository.RatingsRepository; import creator.loki.feature.reputation.repository.ReputationRepository; -import creator.loki.feature.roast.AndrertListener; -import creator.loki.feature.roast.RoastService; import creator.loki.feature.role.CustomRoleManager; import creator.loki.feature.ship.repository.ShipPairRepository; import creator.loki.feature.tiktok.TikTokCommand; @@ -97,14 +95,13 @@ class SmokeCommandRegistrationTest { var moderationRegistrar = new ModerationCommandRegistrar( mock(CustomRoleManager.class), - new ModerationCommandRegistrar.Repositories( - mock(WarningRepository.class), - mock(ConfigRepository.class), - mock(ModerationCaseRepository.class), - mock(ActiveMuteRepository.class), - mock(GuildConfigRepository.class)), + mock(WarningRepository.class), + mock(ConfigRepository.class), + mock(ModerationCaseRepository.class), + mock(ActiveMuteRepository.class), mock(MuteScheduler.class), - mock(MuteRoleService.class)); + mock(MuteRoleService.class), + mock(GuildConfigRepository.class)); moderationRegistrar.register(registry); var tikTokCommand = mock(TikTokCommand.class); @@ -124,20 +121,16 @@ class SmokeCommandRegistrationTest { var utilityRegistrar = new UtilityCommandRegistrar( - new UtilityCommandRegistrar.CoreServices( - registry, - mock(BotConfig.class), - mock(MetricsCollector.class), - mock(ConfigRepository.class), - mock(VoiceChannelManager.class), - mock(CounterManager.class)), - new UtilityCommandRegistrar.RepoServices( - mock(RatingsRepository.class), - mock(ReputationRepository.class), - mock(CooldownManager.class), - mock(CounterStatsRepository.class), - mock(RoastService.class), - mock(AndrertListener.class))); + registry, + mock(BotConfig.class), + mock(MetricsCollector.class), + mock(ConfigRepository.class), + mock(VoiceChannelManager.class), + mock(CounterManager.class), + mock(RatingsRepository.class), + mock(ReputationRepository.class), + mock(CooldownManager.class), + mock(CounterStatsRepository.class)); utilityRegistrar.register(registry); var musicRegistrar = diff --git a/src/test/java/creator/loki/feature/economy/service/BlackjackServiceTest.java b/src/test/java/creator/loki/feature/economy/service/BlackjackServiceTest.java index 5dfda4f..992e2c0 100644 --- a/src/test/java/creator/loki/feature/economy/service/BlackjackServiceTest.java +++ b/src/test/java/creator/loki/feature/economy/service/BlackjackServiceTest.java @@ -56,8 +56,8 @@ class BlackjackServiceTest { service.startGame("user1", 200); - verify(repository).save(accountCaptor.capture()); - assertEquals(800, accountCaptor.getValue().getCash()); + verify(repository, atLeast(1)).save(accountCaptor.capture()); + assertEquals(800, accountCaptor.getAllValues().get(0).getCash()); } } diff --git a/src/test/java/creator/loki/feature/profile/RankCardGeneratorTest.java b/src/test/java/creator/loki/feature/profile/RankCardGeneratorTest.java index 4cb4364..dba6da1 100644 --- a/src/test/java/creator/loki/feature/profile/RankCardGeneratorTest.java +++ b/src/test/java/creator/loki/feature/profile/RankCardGeneratorTest.java @@ -1,10 +1,8 @@ package creator.loki.feature.profile; import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; import java.awt.image.BufferedImage; -import java.io.File; import creator.loki.feature.profile.model.ProfileData; import creator.loki.feature.profile.render.RankCardGenerator; @@ -14,7 +12,7 @@ class RankCardGeneratorTest { @Test void generatesValidRankCard() throws Exception { - var data = mockProfile(10, 500, 1000, "test_bg.png"); + var data = profile(10, 500, 1000, "test_bg.png"); BufferedImage image = new RankCardGenerator().generate(data); assertNotNull(image); assertEquals(1400, image.getWidth()); @@ -23,42 +21,29 @@ class RankCardGeneratorTest { @Test void handlesZeroXp() throws Exception { - var data = mockProfile(1, 0, 100, "test_bg.png"); + var data = profile(1, 0, 100, "test_bg.png"); assertNotNull(new RankCardGenerator().generate(data)); } @Test void handlesMaxValues() throws Exception { - var data = mockProfile(9999, 999999, 999999, "test_bg.png"); + var data = profile(9999, 999999, 999999, "test_bg.png"); assertNotNull(new RankCardGenerator().generate(data)); } @Test void backgroundNotFoundReturnsFallback() throws Exception { - var data = mockProfile(1, 0, 100, "nonexistent.png"); + var data = profile(1, 0, 100, "nonexistent.png"); assertNotNull(new RankCardGenerator().generate(data)); } - private static ProfileData mockProfile(int level, int xp, int maxXp, String background) { - try { - var data = mock(ProfileData.class); - var avatarUrl = - new File("src/test/resources/assets/test_avatar.png").toURI().toURL().toString(); - when(data.getAvatarUrl()).thenReturn(avatarUrl); - when(data.getUsername()).thenReturn("User"); - when(data.getTag()).thenReturn("#0000"); - when(data.getSubtitle()).thenReturn(""); - when(data.getLevel()).thenReturn(level); - when(data.getXp()).thenReturn(xp); - when(data.getMaxXp()).thenReturn(maxXp); - when(data.getRank()).thenReturn(0); - when(data.getMessages()).thenReturn(0); - when(data.getReputation()).thenReturn(0); - when(data.getActivity()).thenReturn(""); - when(data.getBackground()).thenReturn(background); - return data; - } catch (Exception e) { - throw new RuntimeException(e); - } + private static ProfileData profile(int level, int xp, int maxXp, String background) { + String avatarUrl = + RankCardGeneratorTest.class + .getClassLoader() + .getResource("assets/test_avatar.png") + .toExternalForm(); + return new ProfileData( + "User", avatarUrl, "", "#0000", level, xp, maxXp, 0, 0, 0, "", background); } }