diff --git a/TODO.md b/TODO.md index 15c30f8..573f04e 100644 --- a/TODO.md +++ b/TODO.md @@ -253,7 +253,7 @@ feature/economy/ - [x] Красивые level-up embed с прогресс-баром ### Приоритет 3 -- [ ] Статистика сервера — графики, CSV export +- [x] Статистика сервера — графики, CSV export — ✅ `!serverstats [csv|chart]` --- @@ -263,15 +263,15 @@ feature/economy/ - [x] `!catperson` / `!dogperson` / `!foxperson` — «Какой ты кот/пёс/лис»: % + роль + картинка (детерминировано ID) - [x] `!shibe` / `!birb` — картинки шиба-ину и птичек - [x] `!neko` / `!waifu` / `!catboy` — аниме-картинки (неко, вайфу, кошко-мальчик) -- [ ] Факты: `!catfact` / `!dogfact` — факт + картинка (catfact.ninja, dog facts API) -- [ ] `!joke` — анекдоты (jokeapi.dev, категории Any/Programming/Misc) -- [ ] `!chuck` — цитаты Чака Норриса (api.chucknorris.io) -- [ ] `!inspire` — мотивационные цитаты (zenquotes.io) -- [ ] `!apod` — астрономическая картинка дня NASA (api.nasa.gov, бесплатный ключ) -- [ ] `!iss` — где сейчас МКС (api.wheretheiss.at) -- [ ] `!space` — кто сейчас в космосе (open-notify.org) -- [ ] `!xkcd` — свежий стрип (xkcd.com/api) -- [ ] `!meme ` — расширить мем по сабреддиту +- [x] Факты: `!catfact` / `!dogfact` — факт + картинка (catfact.ninja, dog facts API) +- [x] `!joke` — анекдоты (jokeapi.dev, категории Any/Programming/Misc) +- [x] `!chuck` — цитаты Чака Норриса (api.chucknorris.io) +- [x] `!inspire` — мотивационные цитаты (zenquotes.io) +- [x] `!apod` — астрономическая картинка дня NASA (api.nasa.gov, DEMO_KEY) +- [x] `!iss` — где сейчас МКС (api.wheretheiss.at) +- [x] `!space` — кто сейчас в космосе (open-notify.org) +- [x] `!xkcd` — свежий стрип (xkcd.com/api) +- [x] `!meme ` — расширить мем по сабреддиту --- @@ -296,6 +296,7 @@ feature/economy/ ### Оптимизации (2026-08-19) - [x] `AnimalApi` — Caffeine-кэш успешных URL (TTL 30 с): повторные `!neko`/`!catboy`/зоо-викторины отвечают мгновенно +- [x] `AnimalApi` — кэш полностью убран (2026-08-19): из-за него `!neko` 30с отдавал одну и ту же фотку - [x] `MemeCommand` — HttpURLConnection (без таймаутов, мог висеть) → OkHttp 6с/8с - [ ] `Database` — включить `PRAGMA journal_mode = WAL` (сейчас нет — блокировки при конкурентной записи) - [ ] NekosLifeApi — таймауты/ретраи вынести в конфиг (10с → 8с, retry 3 → 2) @@ -462,15 +463,16 @@ feature/economy/ - [ ] F14: MetricsCollector.java — разбить по типам метрик - [ ] F15: WarnCommand.java — вынести auto-mute в отдельный сервис - [x] F16: BotInitializer.java — вынести фабрики ботов — ✅ 162→135 строк +- [x] F02-04: QueueSlashHandler, MusicBotControlHandler, QueueCommands — вынести общие части — ✅ вынесены при реорге music - [ ] Остальные: разбить по SOLID ### Этап 11: ≤6 файлов в папке -- [ ] P1: Entertainment команды — группировать по смыслу (coin/dice → `random/`, gay/iq/pp → `rating/`) -- [ ] P2: Social команды — объединить в `SocialCommand` с параметром -- [ ] P4: Level — разбить на commands/, service/, repository/ -- [ ] P5: Moderation — разбить по поддоменам (commands/punish, commands/utils, warn) -- [ ] P6: Music — разбить на commands/, player/, lavalink/ +- [x] P1: Entertainment команды — уже сгруппированы (choice/, random/; rating-команды в feature/rating/) +- [x] P2: Social команды — уже объединены (SocialCommand + 3 отдельных), 4 файла +- [x] P4: Level — уже разбит на commands/, service/, repository/, domain/ +- [x] P5: Moderation — уже разбит по поддоменам (punish, utils, warn, caselookup, log, repository) +- [x] P6: Music — ✅ commands/filter|control|queue, listener/bot|select, manage/player|filter, util/embed|voice|query (все папки ≤6) ### Этап 12: Тесты diff --git a/src/main/java/creator/loki/feature/animals/AnimalApi.java b/src/main/java/creator/loki/feature/animals/AnimalApi.java index ebf6aea..d35860c 100644 --- a/src/main/java/creator/loki/feature/animals/AnimalApi.java +++ b/src/main/java/creator/loki/feature/animals/AnimalApi.java @@ -4,8 +4,6 @@ import java.io.IOException; import java.util.List; import java.util.concurrent.TimeUnit; -import com.github.benmanes.caffeine.cache.Cache; -import com.github.benmanes.caffeine.cache.Caffeine; import com.google.gson.JsonElement; import com.google.gson.JsonParser; import com.google.inject.Inject; @@ -20,18 +18,15 @@ import org.slf4j.LoggerFactory; /** * Клиент к публичным API животных и аниме-картинок. Для каждого вида пробует эндпоинты по порядку и * возвращает первый успешный URL картинки. Все запросы проходят через {@link ApiRequestQueue} — - * ответы изолированы и не могут подмешаться друг к другу. Успешные URL кэшируются на короткое - * время, чтобы повторные команды отвечали мгновенно и не перегружали внешние API. + * ответы изолированы и не могут подмешаться друг к другу. */ @Singleton public class AnimalApi { private static final Logger logger = LoggerFactory.getLogger(AnimalApi.class); private static final String NEKOS_BASE = "https://nekos.life/api/v2/img/"; - private static final long CACHE_TTL_SECONDS = 30; private final ApiRequestQueue queue; private final OkHttpClient client; - private final Cache cache; @Inject public AnimalApi(final ApiRequestQueue queue) { @@ -41,24 +36,11 @@ public class AnimalApi { AnimalApi(final ApiRequestQueue queue, final OkHttpClient client) { this.queue = queue; this.client = client; - this.cache = - Caffeine.newBuilder() - .maximumSize(64) - .expireAfterWrite(CACHE_TTL_SECONDS, TimeUnit.SECONDS) - .build(); } /** Возвращает URL картинки для вида или null, если все эндпоинты недоступны. */ public String fetchImage(final AnimalKind kind) { - String cached = cache.getIfPresent(kind.name()); - if (cached != null) { - return cached; - } - String url = queue.submit(() -> fetchImageBlocking(kind)); - if (url != null) { - cache.put(kind.name(), url); - } - return url; + return queue.submit(() -> fetchImageBlocking(kind)); } private String fetchImageBlocking(final AnimalKind kind) { diff --git a/src/main/java/creator/loki/feature/rating/commands/board/RatingOverviewCommand.java b/src/main/java/creator/loki/feature/rating/commands/board/RatingOverviewCommand.java new file mode 100644 index 0000000..74a8b60 --- /dev/null +++ b/src/main/java/creator/loki/feature/rating/commands/board/RatingOverviewCommand.java @@ -0,0 +1,114 @@ +package creator.loki.feature.rating.commands.board; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import creator.loki.command.Command; +import creator.loki.command.CommandCategories; +import creator.loki.command.CommandContext; +import creator.loki.config.BotConfig; +import creator.loki.embed.EmbedFactory; +import creator.loki.feature.rating.repository.RatingsRepository; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; + +/** Максимум и минимум по каждой категории рейтинга (только для супер-админа, без ботов). */ +public class RatingOverviewCommand implements Command { + + private static final List TYPES = + List.of("ship", "gay", "iq", "pp", "mustache", "fat", "boykisser"); + + private final RatingsRepository ratingsRepository; + private final BotConfig config; + + public RatingOverviewCommand(RatingsRepository ratingsRepository, BotConfig config) { + this.ratingsRepository = ratingsRepository; + this.config = config; + } + + @Override + public String getName() { + return "ratingoverview"; + } + + @Override + public String getDescription() { + return "Максимумы и минимумы рейтингов по всем категориям (только для супер-админа)"; + } + + @Override + public String getUsage() { + return "ratingoverview"; + } + + @Override + public String getCategory() { + return CommandCategories.SUPER_ADMIN; + } + + @Override + public void execute(final CommandContext context) { + if (!config.getSuperAdminId().equals(context.getAuthor().getUser().getId())) { + context + .getChannel() + .sendMessage("❌ Команда доступна только супер-администратору бота.") + .queue(); + return; + } + Guild guild = context.getGuild(); + if (guild == null) { + context.getChannel().sendMessage("❌ Команда работает только на сервере.").queue(); + return; + } + + Set excluded = new HashSet<>(); + excluded.add(config.getSuperAdminId()); + for (Member member : guild.getMembers()) { + if (member.getUser().isBot()) { + excluded.add(member.getUser().getId()); + } + } + + StringBuilder description = new StringBuilder(); + for (String type : TYPES) { + RatingsRepository.RatingEntry max = + ratingsRepository.getExtremeRating(guild.getId(), type, excluded, true); + RatingsRepository.RatingEntry min = + ratingsRepository.getExtremeRating(guild.getId(), type, excluded, false); + if (max == null || min == null) { + continue; + } + description + .append("**") + .append(type) + .append("**\n🏆 <@") + .append(max.userId()) + .append("> — **") + .append(max.value()) + .append("**\n📉 <@") + .append(min.userId()) + .append("> — **") + .append(min.value()) + .append("**\n\n"); + } + + if (description.isEmpty()) { + context + .getChannel() + .sendMessage("❌ Пока никто не имеет рейтингов. Сначала `!generateratings`.") + .queue(); + return; + } + + context + .getChannel() + .sendMessageEmbeds( + EmbedFactory.create() + .withTitle("📊 Сводка рейтингов") + .withDescription(description.toString()) + .withFooter("Без учёта ботов и супер-админа") + .build()) + .queue(); + } +} diff --git a/src/main/java/creator/loki/feature/rating/repository/RatingsRepository.java b/src/main/java/creator/loki/feature/rating/repository/RatingsRepository.java index 4c27e45..af1d06d 100644 --- a/src/main/java/creator/loki/feature/rating/repository/RatingsRepository.java +++ b/src/main/java/creator/loki/feature/rating/repository/RatingsRepository.java @@ -4,6 +4,8 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -152,6 +154,40 @@ public class RatingsRepository { return getRatingBoard(guildId, ratingType, limit, false); } + /** Крайнее значение рейтинга (максимум или минимум) без учёта исключённых пользователей. */ + public RatingEntry getExtremeRating( + final String guildId, + final String ratingType, + final Collection excludedUserIds, + final boolean highest) { + StringBuilder sql = + new StringBuilder( + "SELECT user_id, value FROM ratings WHERE guild_id = ? AND rating_type = ?"); + List params = new ArrayList<>(List.of(guildId, ratingType)); + if (excludedUserIds != null && !excludedUserIds.isEmpty()) { + sql.append(" AND user_id NOT IN (") + .append(String.join(",", Collections.nCopies(excludedUserIds.size(), "?"))) + .append(")"); + params.addAll(excludedUserIds); + } + sql.append(" ORDER BY value ").append(highest ? "DESC" : "ASC").append(" LIMIT 1"); + + try (PreparedStatement stmt = database.prepare(sql.toString())) { + for (int i = 0; i < params.size(); i++) { + stmt.setString(i + 1, params.get(i)); + } + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return new RatingEntry(rs.getString("user_id"), rs.getInt("value")); + } + } + } catch (SQLException e) { + logger.error("Ошибка получения крайнего рейтинга {} для сервера {}", ratingType, guildId, e); + } + + return null; + } + private List getRatingBoard( final String guildId, final String ratingType, final int limit, final boolean descending) { String sql = diff --git a/src/main/java/creator/loki/feature/utility/UtilityCommandRegistrar.java b/src/main/java/creator/loki/feature/utility/UtilityCommandRegistrar.java index d98c0fa..a877df4 100644 --- a/src/main/java/creator/loki/feature/utility/UtilityCommandRegistrar.java +++ b/src/main/java/creator/loki/feature/utility/UtilityCommandRegistrar.java @@ -18,6 +18,7 @@ import creator.loki.feature.info.commands.ServerStatsCommand; import creator.loki.feature.metrics.MetricsCollector; import creator.loki.feature.metrics.commands.MetricsCommand; import creator.loki.feature.rating.commands.board.RatingBoardCommand; +import creator.loki.feature.rating.commands.board.RatingOverviewCommand; import creator.loki.feature.rating.repository.RatingsRepository; import creator.loki.feature.reputation.MinusRepCommand; import creator.loki.feature.reputation.RepCommand; @@ -81,6 +82,7 @@ public class UtilityCommandRegistrar implements CommandRegistrar { registry.register(new GenerateRatingsCommand(ratingsRepository, config)); registry.register(new RatingBoardCommand(RatingBoardCommand.Flavor.TOP, ratingsRepository)); registry.register(new RatingBoardCommand(RatingBoardCommand.Flavor.BOTTOM, ratingsRepository)); + registry.register(new RatingOverviewCommand(ratingsRepository, config)); registry.register(new RepCommand(reputationRepository, cooldownManager)); registry.register(new MinusRepCommand(reputationRepository, cooldownManager)); diff --git a/src/test/java/creator/loki/feature/rating/repository/RatingsRepositoryTest.java b/src/test/java/creator/loki/feature/rating/repository/RatingsRepositoryTest.java index 3a2aaba..e36d14c 100644 --- a/src/test/java/creator/loki/feature/rating/repository/RatingsRepositoryTest.java +++ b/src/test/java/creator/loki/feature/rating/repository/RatingsRepositoryTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.*; import java.io.IOException; import java.nio.file.Files; +import java.util.List; import java.util.Map; import creator.loki.database.Database; @@ -259,4 +260,74 @@ class RatingsRepositoryTest { assertTrue(repository.getBottomRatings("guild1", "iq", 10).isEmpty()); } } + + @Nested + class Extreme { + @Test + void highestWithoutExclusions() { + repository.setRating("guild1", "user1", "iq", 100); + repository.setRating("guild1", "user2", "iq", 150); + + var max = repository.getExtremeRating("guild1", "iq", List.of(), true); + assertEquals("user2", max.userId()); + assertEquals(150, max.value()); + } + + @Test + void lowestWithoutExclusions() { + repository.setRating("guild1", "user1", "iq", 100); + repository.setRating("guild1", "user2", "iq", 50); + + var min = repository.getExtremeRating("guild1", "iq", List.of(), false); + assertEquals("user2", min.userId()); + assertEquals(50, min.value()); + } + + @Test + void highestSkipsExcludedUsers() { + repository.setRating("guild1", "user1", "iq", 100); + repository.setRating("guild1", "bot1", "iq", 200); + repository.setRating("guild1", "admin", "iq", 180); + + var max = repository.getExtremeRating("guild1", "iq", List.of("bot1", "admin"), true); + assertEquals("user1", max.userId()); + assertEquals(100, max.value()); + } + + @Test + void lowestSkipsExcludedUsers() { + repository.setRating("guild1", "user1", "iq", 5); + repository.setRating("guild1", "bot1", "iq", 1); + repository.setRating("guild1", "admin", "iq", 3); + + var min = repository.getExtremeRating("guild1", "iq", List.of("bot1", "admin"), false); + assertEquals("user1", min.userId()); + assertEquals(5, min.value()); + } + + @Test + void returnsNullWhenAllExcluded() { + repository.setRating("guild1", "user1", "iq", 100); + + var max = repository.getExtremeRating("guild1", "iq", List.of("user1"), true); + assertNull(max); + } + + @Test + void returnsNullWhenNoRatings() { + assertNull(repository.getExtremeRating("guild1", "iq", List.of(), true)); + assertNull(repository.getExtremeRating("guild1", "iq", List.of("user1"), false)); + } + + @Test + void filtersByGuildAndType() { + repository.setRating("guild1", "user1", "iq", 100); + repository.setRating("guild2", "user1", "iq", 200); + repository.setRating("guild1", "user1", "gay", 90); + + assertEquals(100, repository.getExtremeRating("guild1", "iq", List.of(), true).value()); + assertEquals(200, repository.getExtremeRating("guild2", "iq", List.of(), true).value()); + assertEquals(90, repository.getExtremeRating("guild1", "gay", List.of(), true).value()); + } + } }