fix: repair broken test compilation, flaky BlackjackServiceTest, and split large files <=150 lines

- RankCardGeneratorTest: mock record ProfileData via direct constructor instead of non-existent getters
- SmokeCommandRegistrationTest: drop removed feature.roast refs, update registrar signatures
- BlackjackServiceTest.deductsBetFromCash: atLeast(1) + first captured account (flaky on blackjack)
- Split >150-line files across music, moderation, config, logging, reactroles, ticket, database, economy, voice, help, radio, tiktok, level domains (KISS/DRY/SOLID, files <=150 lines)
This commit is contained in:
loki5512344 2026-08-19 11:52:36 +02:00
parent c5bac60ebf
commit 1483c5cee0
Signed by: boba
GPG key ID: 253067914055423B
116 changed files with 4369 additions and 3768 deletions

View file

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

View file

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

View file

@ -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<String, Object> data) {
var userStats = (Map<String, Object>) 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<String, Object>) 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<String, Object> data) {
var reputation = (Map<String, Object>) 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<String, Object> data) {
var shipPairs = (Map<String, Object>) 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<String, Object> data) {
var customRoles = (Map<String, Object>) 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<String, Object>) 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);
}
}
}

View file

@ -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<String, Object> data) {
var userStats = (Map<String, Object>) 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<String, Object>) 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<String, Object> data) {
var reputation = (Map<String, Object>) 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<String, Object> data) {
var shipPairs = (Map<String, Object>) 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<String, Object> data) {
var customRoles = (Map<String, Object>) 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<String, Object>) 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);
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Integer, String> byLevel(final Map<String, String> all) {
Map<Integer, String> 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;
}
}

View file

@ -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<String, String> 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<String, String> allConfig = configRepo.getAll();
var options =
allConfig.entrySet().stream()
.filter(e -> e.getKey().startsWith("level_role_"))
.map(
e -> {
String level = e.getKey().substring("level_role_".length());
return "Уровень " + level + " — " + e.getValue();
})
.toList();
if (options.isEmpty()) {
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<Integer, String> levelRoles = LevelRoleConfig.byLevel(configRepo.getAll());
embed.addField("🏆 Роли за уровни", "Настроено ролей: **" + levelRoles.size() + "**", false);
}
}

View file

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

View file

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

View file

@ -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<String, String> allConfig = configRepo.getAll();
var levelRoles =
allConfig.entrySet().stream()
.filter(e -> e.getKey().startsWith("level_role_"))
.collect(
Collectors.toMap(
e -> Integer.parseInt(e.getKey().substring("level_role_".length())),
Map.Entry::getValue));
Map<Integer, String> 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());
}

View file

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

View file

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

View file

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

View file

@ -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<JobService.JobInfo> 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<JobService.JobInfo> jobs, final String name) {
for (var job : jobs) {
if (job.name().equalsIgnoreCase(name) || job.displayName().equalsIgnoreCase(name)) {
return job;
}
}
return null;
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<String, List<Command>> 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());
}
}

View file

@ -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<String, List<Command>> groupByCategory() {
return registry.getAllCommands().stream().collect(Collectors.groupingBy(Command::getCategory));
}
public StringSelectMenu buildSelector(
final Map<String, List<Command>> 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<String, List<Command>> 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<String, List<Command>> 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();
}
}

View file

@ -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<String, List<Command>> commandsByCategory =
commands.stream().collect(Collectors.groupingBy(Command::getCategory));
var commandsByCategory = menuBuilder.groupByCategory();
List<Command> 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<Command> 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) {

View file

@ -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<String, List<Command>> 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<String, List<Command>> 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;

View file

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

View file

@ -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<net.dv8tion.jda.api.entities.Member> 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<net.dv8tion.jda.api.entities.Member> members) {
var s = countMembers(members);
final CommandContext context, final Guild guild, final List<Member> 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()

View file

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

View file

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

View file

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

View file

@ -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, "❌ Отмена"));
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) {

View file

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

View file

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

View file

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

View file

@ -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 ? "Навсегда" : "<t:" + (untilMs / 1000) + ":F>",
true)
.addField(
"Причина",
ModerationHelper.trunc(reason, MAX_LOG_REASON_LENGTH),
false)
.addField("Срок", permanent ? "Навсегда" : "<t:" + (untilMs / 1000) + ":F>", true)
.addField("Причина", ModerationHelper.trunc(reason, MAX_LOG_REASON_LENGTH), false)
.setTimestamp(OffsetDateTime.now())
.build())
.queue();

View file

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

View file

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

View file

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

View file

@ -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<AuditLogEntry> l, String targetId, String... key) {
var s = l.stream().filter(e -> targetId.equals(e.getTargetId()));
if (key.length > 0) {
s = s.filter(e -> e.getChangeByKey(key[0]) != null);
}
return s.findFirst().orElse(null);
}
static String mod(AuditLogEntry e) {
return e.getUser() != null ? e.getUser().getAsMention() : "Неизвестно";
}
static void sendForTarget(
Guild g,
ActionType type,
int limit,
String targetId,
Function<AuditLogEntry, EmbedBuilder> embed,
BiConsumer<Guild, EmbedBuilder> 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<AuditLogEntry, EmbedBuilder> embed,
BiConsumer<Guild, EmbedBuilder> send) {
g.retrieveAuditLogs()
.type(type)
.limit(limit)
.queue(
logs -> {
var entry = logs.stream().findFirst().orElse(null);
send.accept(g, embed.apply(entry));
});
}
}

View file

@ -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<AuditLogEntry> l, String targetId, String... key) {
var s = l.stream().filter(e -> targetId.equals(e.getTargetId()));
if (key.length > 0) {
s = s.filter(e -> e.getChangeByKey(key[0]) != null);
}
return s.findFirst().orElse(null);
}
static String mod(AuditLogEntry e) {
return e.getUser() != null ? e.getUser().getAsMention() : "Неизвестно";
}
}

View file

@ -30,23 +30,23 @@ class VoiceAuditLogger {
private EmbedBuilder voiceMute(GuildVoiceGuildMuteEvent e, boolean muted, List<AuditLogEntry> 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<AuditLogEntry> 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(

View file

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

View file

@ -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 <level>" : mode == 1 ? "equalizer <band> <gain>" : "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();
}
}
}

View file

@ -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 <level>" : mode == 1 ? "equalizer <band> <gain>" : "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();
}
}
}

View file

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

View file

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

View file

@ -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<PlayerManager, CommandContext> 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<PlayerManager, CommandContext> 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<PlayerManager, CommandContext> e;
Mc(
PlayerManager p,
String n,
String d,
String u,
String c,
BiConsumer<PlayerManager, CommandContext> e) {
this.p = p;
this.n = n;
this.d = d;
this.u = u;
this.c = c;
this.e = e;
}
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();

View file

@ -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<PlayerManager, CommandContext> e;
SimpleCommand(
PlayerManager p,
String n,
String d,
String u,
String c,
BiConsumer<PlayerManager, CommandContext> e) {
this.p = p;
this.n = n;
this.d = d;
this.u = u;
this.c = c;
this.e = e;
}
@Override
public String getName() {
return 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);
}
}

View file

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

View file

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

View file

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

View file

@ -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<Long, GuildMusicManager> musicManagers;
LavalinkEventRouter(LavalinkClient client, Map<Long, GuildMusicManager> 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());
}
});
}
}

View file

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

View file

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

View file

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

View file

@ -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<String, Consumer<MessageReceivedEvent>> 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<String, Consumer<MessageReceivedEvent>> buildCommandMap() {
var map = new HashMap<String, Consumer<MessageReceivedEvent>>();
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<String> 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<MessageReceivedEvent> 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 <band> <gain>` — Эквалайзер
`m!reset` — Сбросить фильтры
""")
.setFooter("Jockie Music style", null)
.build())
.queue();
}
}

View file

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

View file

@ -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<String, Consumer<MessageReceivedEvent>> build(
final MusicBotPlayHandler playHandler, final MusicBotControlHandler controlHandler) {
var filters = controlHandler.getFilterHandler();
var map = new HashMap<String, Consumer<MessageReceivedEvent>>();
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<String> 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 <band> <gain>` — Эквалайзер
`m!reset` — Сбросить фильтры
""")
.setFooter("Jockie Music style", null)
.build())
.queue();
}
}

View file

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

View file

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

View file

@ -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<Link> 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<QueueTrack> saved, final LavalinkNode node) {
var decoded = new ArrayList<Track>();
for (var qt : saved) {
try {
var track = node.decodeTrack(qt.encoded()).block();
if (track != null) {
decoded.add(track);
}
} catch (Exception e) {
LoggerDecorator.warn(logger, "Не удалось декодировать: " + qt.title());
}
}
if (decoded.isEmpty()) {
return;
}
var q = scheduler.queue;
q.addAll(decoded.subList(1, decoded.size()));
var first = decoded.get(0);
var player = getPlayer().orElse(null);
if (player != null && player.getTrack() == null) {
scheduler.startTrack(first);
} else {
q.offer(first);
}
LoggerDecorator.music(logger, "Восстановлено " + decoded.size() + " треков");
queueRepository.deleteQueue(String.valueOf(guildId));
}
private ScheduledFuture<?> saveDebounced;
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<QueueTrack>();
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();
}
}

View file

@ -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<QueueTrack> saved, final LavalinkNode node) {
var decoded = new ArrayList<Track>();
for (var qt : saved) {
try {
var track = node.decodeTrack(qt.encoded()).block();
if (track != null) {
decoded.add(track);
}
} catch (Exception e) {
LoggerDecorator.warn(logger, "Не удалось декодировать: " + qt.title());
}
}
if (decoded.isEmpty()) {
return;
}
var q = 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<QueueTrack>();
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());
}
}

View file

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

View file

@ -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<Throwable> LOG_LAV = t -> log.warn("Lav: {}", t.getMessage());
private final GuildMusicManager manager;
private final TrackStarter trackStarter;
private final TrackEndHandler trackEndHandler;
final Queue<Track> 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<String> 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();
}
}

View file

@ -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<String> 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) {

View file

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

View file

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

View file

@ -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<Track> q) {
var sb = new StringBuilder();
var show = Math.min(q.size(), 10);
for (int i = 0; i < show; i++) {
var t = q.get(i);
sb.append("`").append(i + 1).append(".` **").append(t.getInfo().getTitle()).append("**\n");
}
if (q.size() > show) {
sb.append("... и ещё **").append(q.size() - show).append("** треков");
}
return sb.toString();
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<Throwable> LOG =
err ->
LoggerFactory.getLogger(QueueSlashHandler.class).warn("Lavalink: {}", err.getMessage());

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Throwable> onError) {
if (link != null && link.getCachedPlayer() != null) {
link.createOrUpdatePlayer().setVolume(vol).subscribe(null, onError);
}
}
}

View file

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

View file

@ -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<String> 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<String> args) {
if (args.size() < 2) {
context
.getChannel()
.sendMessageEmbeds(EmbedTemplates.error("Радио", "Укажи URL: `!radio set <url>`"))
.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 <url>`"))
.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<String> 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();
}
}
}

View file

@ -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<String> args) {
if (args.size() < 2) {
context
.getChannel()
.sendMessageEmbeds(EmbedTemplates.error("Радио", "Укажи URL: `!radio set <url>`"))
.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 <url>`"))
.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<String> 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();
}
}
}

View file

@ -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<String> 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<String> 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 <URL>` — Запустить радио
`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;
}
}

View file

@ -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<String> 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<String> 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 <URL>` — Запустить радио
`r!stop` — Остановить радио
`r!volume [0-100]` — Громкость
`r!np` — Что играет
`r!help` — Показать это сообщение
""")
.build())
.queue();
}
}

View file

@ -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<Long, GuildRadioState> radioStates = new ConcurrentHashMap<>();
private final Map<Long, RadioSession> 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;
}
}

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more