diff --git a/src/main/java/creator/loki/command/CommandCategories.java b/src/main/java/creator/loki/command/CommandCategories.java index 613e885..5c82d00 100644 --- a/src/main/java/creator/loki/command/CommandCategories.java +++ b/src/main/java/creator/loki/command/CommandCategories.java @@ -18,6 +18,7 @@ public final class CommandCategories { public static final String GIF_INTERACTIVE = "GIF-интерактив"; public static final String NSFW_IMAGES = "NSFW"; public static final String MEASUREMENTS = "Измерения"; + public static final String ANIMALS = "Животные"; private CommandCategories() {} } diff --git a/src/main/java/creator/loki/di/BotModule.java b/src/main/java/creator/loki/di/BotModule.java index 0084d39..e36e88d 100644 --- a/src/main/java/creator/loki/di/BotModule.java +++ b/src/main/java/creator/loki/di/BotModule.java @@ -14,6 +14,7 @@ import creator.loki.core.registry.PrefixCommandRegistrar; import creator.loki.core.security.CooldownManager; import creator.loki.core.security.RateLimiter; import creator.loki.database.DatabaseModule; +import creator.loki.feature.animals.AnimalModule; import creator.loki.feature.config.ConfigModule; import creator.loki.feature.counter.CounterModule; import creator.loki.feature.economy.EconomyModule; @@ -34,6 +35,7 @@ import creator.loki.feature.tiktok.TikTokModule; import creator.loki.feature.utility.UtilityModule; import creator.loki.feature.voice.VoiceModule; import creator.loki.feature.welcome.WelcomeModule; +import creator.loki.util.ApiRequestQueue; public class BotModule extends AbstractModule { @Override @@ -64,6 +66,7 @@ public class BotModule extends AbstractModule { install(new ImageModule()); install(new TikTokModule()); install(new EntertainmentModule()); + install(new AnimalModule()); install(new TicketModule()); install(new UtilityModule()); install(new ReactionRolesModule()); @@ -76,6 +79,12 @@ public class BotModule extends AbstractModule { return ConfigLoader.load(); } + @Provides + @Singleton + ApiRequestQueue provideApiRequestQueue() { + return new ApiRequestQueue(); + } + @Provides @Singleton EventDispatcher provideEventDispatcher( diff --git a/src/main/java/creator/loki/di/LifecycleModule.java b/src/main/java/creator/loki/di/LifecycleModule.java index 5083cdb..ea3e15f 100644 --- a/src/main/java/creator/loki/di/LifecycleModule.java +++ b/src/main/java/creator/loki/di/LifecycleModule.java @@ -11,6 +11,7 @@ import creator.loki.feature.counter.service.listener.MemberCounterListener; import creator.loki.feature.level.service.tracker.VoiceTracker; import creator.loki.feature.metrics.HealthHttpServer; import creator.loki.feature.moderation.MuteScheduler; +import creator.loki.util.ApiRequestQueue; public class LifecycleModule extends AbstractModule { @Override @@ -24,5 +25,6 @@ public class LifecycleModule extends AbstractModule { binder.addBinding().to(MuteScheduler.class); binder.addBinding().to(HealthHttpServer.class); binder.addBinding().to(Database.class); + binder.addBinding().to(ApiRequestQueue.class); } } diff --git a/src/main/java/creator/loki/embed/HelpCategories.java b/src/main/java/creator/loki/embed/HelpCategories.java index 28d2a20..daf7b9e 100644 --- a/src/main/java/creator/loki/embed/HelpCategories.java +++ b/src/main/java/creator/loki/embed/HelpCategories.java @@ -27,7 +27,8 @@ public final class HelpCategories { Map.entry(CommandCategories.ROLES, "🎭"), Map.entry(CommandCategories.GIF_INTERACTIVE, "💞"), Map.entry(CommandCategories.NSFW_IMAGES, "🔞"), - Map.entry(CommandCategories.MEASUREMENTS, "📏")); + Map.entry(CommandCategories.MEASUREMENTS, "📏"), + Map.entry(CommandCategories.ANIMALS, "🐾")); COLORS = Map.ofEntries( @@ -45,7 +46,8 @@ public final class HelpCategories { Map.entry(CommandCategories.ROLES, new Color(155, 89, 182)), Map.entry(CommandCategories.GIF_INTERACTIVE, new Color(233, 30, 99)), Map.entry(CommandCategories.NSFW_IMAGES, new Color(180, 0, 0)), - Map.entry(CommandCategories.MEASUREMENTS, new Color(255, 159, 67))); + Map.entry(CommandCategories.MEASUREMENTS, new Color(255, 159, 67)), + Map.entry(CommandCategories.ANIMALS, new Color(46, 204, 113))); DESCRIPTIONS = Map.ofEntries( @@ -64,7 +66,8 @@ public final class HelpCategories { Map.entry( CommandCategories.GIF_INTERACTIVE, "Действия с участником в GIF (nekos.life)"), Map.entry(CommandCategories.NSFW_IMAGES, "Поиск изображений (только в NSFW каналах)"), - Map.entry(CommandCategories.MEASUREMENTS, "Забавные измерительные команды")); + Map.entry(CommandCategories.MEASUREMENTS, "Забавные измерительные команды"), + Map.entry(CommandCategories.ANIMALS, "Зоо-викторины и картинки животных")); } private HelpCategories() {} diff --git a/src/main/java/creator/loki/feature/animals/AnimalApi.java b/src/main/java/creator/loki/feature/animals/AnimalApi.java new file mode 100644 index 0000000..ebf6aea --- /dev/null +++ b/src/main/java/creator/loki/feature/animals/AnimalApi.java @@ -0,0 +1,164 @@ +package creator.loki.feature.animals; + +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; +import com.google.inject.Singleton; +import creator.loki.util.ApiRequestQueue; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import org.slf4j.Logger; +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) { + this(queue, buildClient()); + } + + 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; + } + + private String fetchImageBlocking(final AnimalKind kind) { + for (AnimalKind.Endpoint endpoint : kind.getEndpoints()) { + String url = tryFetch(endpoint); + if (url != null) { + return url; + } + } + return null; + } + + private String tryFetch(final AnimalKind.Endpoint endpoint) { + Request request = new Request.Builder().url(endpoint.url()).get().build(); + try (Response response = client.newCall(request).execute()) { + if (!response.isSuccessful() || response.body() == null) { + logger.warn("Animal API error: {} for {}", response.code(), endpoint.url()); + return null; + } + JsonElement json = JsonParser.parseString(response.body().string()); + return endpoint.extractor().extract(json); + } catch (IOException | IllegalStateException e) { + logger.warn("Failed to fetch {}", endpoint.url(), e); + return null; + } + } + + private static OkHttpClient buildClient() { + return new OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .build(); + } + + static UrlExtractor objectKey(final String key) { + return json -> json.getAsJsonObject().get(key).getAsString(); + } + + static UrlExtractor firstArrayString() { + return json -> json.getAsJsonArray().get(0).getAsString(); + } + + static UrlExtractor firstArrayObjectKey(final String key) { + return json -> json.getAsJsonArray().get(0).getAsJsonObject().get(key).getAsString(); + } + + /** Вид животного/аниме-категории с цепочкой эндпоинтов (primary + fallback). */ + public enum AnimalKind { + CAT( + "🐱", + List.of( + new Endpoint( + "https://api.thecatapi.com/v1/images/search", firstArrayObjectKey("url")))), + DOG( + "🐶", + List.of(new Endpoint("https://dog.ceo/api/breeds/image/random", objectKey("message")))), + FOX("🦊", List.of(new Endpoint("https://randomfox.ca/floof/", objectKey("image")))), + SHIBE( + "🐕", + List.of( + new Endpoint( + "https://shibe.online/api/shibes?count=1&urls=true&httpsUrls=true", + firstArrayString()), + new Endpoint("https://dog.ceo/api/breed/shiba/images/random", objectKey("message")))), + BIRB("🐦", List.of(new Endpoint("https://some-random-api.com/img/birb", objectKey("link")))), + NEKO("😺", List.of(new Endpoint(NEKOS_BASE + "neko", objectKey("url")))), + WAIFU( + "🌸", + List.of( + new Endpoint("https://api.waifu.pics/sfw/waifu", objectKey("url")), + new Endpoint(NEKOS_BASE + "waifu", objectKey("url")))), + CATBOY( + "😼", + List.of( + new Endpoint("https://api.catboys.com/img", objectKey("url")), + new Endpoint(NEKOS_BASE + "neko", objectKey("url")))); + + private final String emoji; + private final List endpoints; + + AnimalKind(final String emoji, final List endpoints) { + this.emoji = emoji; + this.endpoints = endpoints; + } + + public String getEmoji() { + return emoji; + } + + public List getEndpoints() { + return endpoints; + } + + /** Эндпоинт с URL запроса и логикой извлечения картинки из JSON. */ + public record Endpoint(String url, UrlExtractor extractor) {} + } + + /** Извлекает URL картинки из JSON-ответа API. */ + @FunctionalInterface + interface UrlExtractor { + String extract(JsonElement json); + } +} diff --git a/src/main/java/creator/loki/feature/animals/AnimalCommandRegistrar.java b/src/main/java/creator/loki/feature/animals/AnimalCommandRegistrar.java new file mode 100644 index 0000000..b6a5088 --- /dev/null +++ b/src/main/java/creator/loki/feature/animals/AnimalCommandRegistrar.java @@ -0,0 +1,29 @@ +package creator.loki.feature.animals; + +import com.google.inject.Inject; +import creator.loki.core.registry.CommandRegistrar; +import creator.loki.core.registry.CommandRegistry; +import creator.loki.feature.animals.command.AnimalImageCommand; +import creator.loki.feature.animals.command.ZooPersonCommand; +import creator.loki.feature.entertainment.service.RatingService; + +public class AnimalCommandRegistrar implements CommandRegistrar { + private final RatingService ratingService; + private final AnimalApi animalApi; + + @Inject + public AnimalCommandRegistrar(final RatingService ratingService, final AnimalApi animalApi) { + this.ratingService = ratingService; + this.animalApi = animalApi; + } + + @Override + public void register(final CommandRegistry registry) { + for (ZooPersonCommand.Flavor flavor : ZooPersonCommand.Flavor.values()) { + registry.register(new ZooPersonCommand(flavor, ratingService, animalApi)); + } + for (AnimalImageCommand.Flavor flavor : AnimalImageCommand.Flavor.values()) { + registry.register(new AnimalImageCommand(flavor, animalApi)); + } + } +} diff --git a/src/main/java/creator/loki/feature/animals/AnimalModule.java b/src/main/java/creator/loki/feature/animals/AnimalModule.java new file mode 100644 index 0000000..0611227 --- /dev/null +++ b/src/main/java/creator/loki/feature/animals/AnimalModule.java @@ -0,0 +1,23 @@ +package creator.loki.feature.animals; + +import com.google.inject.AbstractModule; +import com.google.inject.Provides; +import com.google.inject.Singleton; +import com.google.inject.multibindings.Multibinder; +import creator.loki.core.registry.CommandRegistrar; +import creator.loki.util.ApiRequestQueue; + +public class AnimalModule extends AbstractModule { + @Override + protected void configure() { + Multibinder.newSetBinder(binder(), CommandRegistrar.class) + .addBinding() + .to(AnimalCommandRegistrar.class); + } + + @Provides + @Singleton + AnimalApi provideAnimalApi(final ApiRequestQueue queue) { + return new AnimalApi(queue); + } +} diff --git a/src/main/java/creator/loki/feature/animals/command/AnimalImageCommand.java b/src/main/java/creator/loki/feature/animals/command/AnimalImageCommand.java new file mode 100644 index 0000000..556d775 --- /dev/null +++ b/src/main/java/creator/loki/feature/animals/command/AnimalImageCommand.java @@ -0,0 +1,90 @@ +package creator.loki.feature.animals.command; + +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.animals.AnimalApi; +import creator.loki.feature.animals.AnimalApi.AnimalKind; + +/** Команда-картинка: случайное животное или аниме-категория. */ +public class AnimalImageCommand implements Command { + + public enum Flavor { + SHIBE("shibe", "Случайная шиба-ину", AnimalKind.SHIBE), + BIRB("birb", "Случайная птичка", AnimalKind.BIRB), + NEKO("neko", "Случайная неко", AnimalKind.NEKO), + WAIFU("waifu", "Случайная вайфу", AnimalKind.WAIFU), + CATBOY("catboy", "Случайный кошко-мальчик", AnimalKind.CATBOY); + + private final String name; + private final String title; + private final AnimalKind kind; + + Flavor(final String name, final String title, final AnimalKind kind) { + this.name = name; + this.title = title; + this.kind = kind; + } + + public String getName() { + return name; + } + + public AnimalKind getKind() { + return kind; + } + } + + private static final Color EMBED_COLOR = new Color(88, 101, 242); + + private final Flavor flavor; + private final AnimalApi animalApi; + + public AnimalImageCommand(final Flavor flavor, final AnimalApi animalApi) { + this.flavor = flavor; + this.animalApi = animalApi; + } + + @Override + public String getName() { + return flavor.getName(); + } + + @Override + public String getDescription() { + return flavor.title; + } + + @Override + public String getCategory() { + return CommandCategories.ANIMALS; + } + + @Override + public void execute(final CommandContext context) { + String imageUrl = animalApi.fetchImage(flavor.getKind()); + if (imageUrl == null) { + context + .getChannel() + .sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "Не удалось загрузить картинку, попробуй позже.")) + .queue(); + return; + } + + context + .getChannel() + .sendMessageEmbeds( + EmbedFactory.create() + .withTitle(flavor.getKind().getEmoji() + " " + flavor.title) + .withImage(imageUrl) + .withColor(EMBED_COLOR) + .withFooter("Источник: " + flavor.getKind().name()) + .build()) + .queue(); + } +} diff --git a/src/main/java/creator/loki/feature/animals/command/ZooPersonCommand.java b/src/main/java/creator/loki/feature/animals/command/ZooPersonCommand.java new file mode 100644 index 0000000..bdf85c8 --- /dev/null +++ b/src/main/java/creator/loki/feature/animals/command/ZooPersonCommand.java @@ -0,0 +1,125 @@ +package creator.loki.feature.animals.command; + +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.animals.AnimalApi; +import creator.loki.feature.animals.AnimalApi.AnimalKind; +import creator.loki.feature.animals.config.AnimalPersonality; +import creator.loki.feature.animals.config.AnimalPersonalityCatalog; +import creator.loki.feature.entertainment.service.RatingService; +import net.dv8tion.jda.api.entities.Member; + +/** Зоо-викторина «Какой ты кот/пёс/лис»: процент + роль + картинка. */ +public class ZooPersonCommand implements Command { + + public enum Flavor { + CAT("catperson", "Какой ты кот", 1001, AnimalKind.CAT, AnimalPersonalityCatalog.catLevels()), + DOG("dogperson", "Какой ты пёс", 1002, AnimalKind.DOG, AnimalPersonalityCatalog.dogLevels()), + FOX("foxperson", "Какой ты лис", 1003, AnimalKind.FOX, AnimalPersonalityCatalog.foxLevels()); + + private final String name; + private final String question; + private final int salt; + private final AnimalKind kind; + private final List levels; + + Flavor( + final String name, + final String question, + final int salt, + final AnimalKind kind, + final List levels) { + this.name = name; + this.question = question; + this.salt = salt; + this.kind = kind; + this.levels = levels; + } + + public String getName() { + return name; + } + + public String getQuestion() { + return question; + } + + public AnimalKind getKind() { + return kind; + } + + public List getLevels() { + return levels; + } + + int getSalt() { + return salt; + } + } + + private final Flavor flavor; + private final RatingService ratingService; + private final AnimalApi animalApi; + + public ZooPersonCommand( + final Flavor flavor, final RatingService ratingService, final AnimalApi animalApi) { + this.flavor = flavor; + this.ratingService = ratingService; + this.animalApi = animalApi; + } + + @Override + public String getName() { + return flavor.getName(); + } + + @Override + public String getDescription() { + return flavor.getQuestion(); + } + + @Override + public String getUsage() { + return getName() + " [@user]"; + } + + @Override + public String getCategory() { + return CommandCategories.ANIMALS; + } + + @Override + public void execute(final CommandContext context) { + if (!ratingService.checkAndApplyCooldown(context, getName())) { + return; + } + + Member target = ratingService.resolveTarget(context); + int percent = ratingService.getDeterministicValue(target.getId(), flavor.getSalt(), 0, 100); + AnimalPersonality personality = AnimalPersonality.find(percent, flavor.getLevels()); + String imageUrl = animalApi.fetchImage(flavor.getKind()); + + EmbedFactory embed = + EmbedFactory.create() + .withTitle(flavor.getKind().getEmoji() + " " + flavor.getQuestion()) + .withDescription( + "**" + + personality.getTitle() + + "** — " + + percent + + "%\n\n" + + personality.getDescription()) + .withThumbnail(target.getUser().getAvatarUrl()) + .withColor(personality.getColor()) + .withFooter("💡 Детерминировано вашим Discord ID"); + if (imageUrl != null) { + embed.withImage(imageUrl); + } + + context.getChannel().sendMessageEmbeds(embed.build()).queue(); + } +} diff --git a/src/main/java/creator/loki/feature/animals/config/AnimalPersonality.java b/src/main/java/creator/loki/feature/animals/config/AnimalPersonality.java new file mode 100644 index 0000000..14d6c08 --- /dev/null +++ b/src/main/java/creator/loki/feature/animals/config/AnimalPersonality.java @@ -0,0 +1,50 @@ +package creator.loki.feature.animals.config; + +import java.awt.Color; +import java.util.List; + +/** Роль животного-персонажа с порогом, описанием и цветом. */ +public class AnimalPersonality { + private final int threshold; + private final String title; + private final String description; + private final Color color; + + public AnimalPersonality( + final int threshold, final String title, final String description, final Color color) { + this.threshold = threshold; + this.title = title; + this.description = description; + this.color = color; + } + + public int getThreshold() { + return threshold; + } + + public String getTitle() { + return title; + } + + public String getDescription() { + return description; + } + + public Color getColor() { + return color; + } + + /** + * Находит роль для значения (список должен быть отсортирован по убыванию threshold). + * + * @param value значение процента 0-100 + * @param levels список ролей + * @return подходящая роль + */ + public static AnimalPersonality find(final int value, final List levels) { + return levels.stream() + .filter(level -> value >= level.threshold) + .findFirst() + .orElse(levels.get(levels.size() - 1)); + } +} diff --git a/src/main/java/creator/loki/feature/animals/config/AnimalPersonalityCatalog.java b/src/main/java/creator/loki/feature/animals/config/AnimalPersonalityCatalog.java new file mode 100644 index 0000000..25ef4b5 --- /dev/null +++ b/src/main/java/creator/loki/feature/animals/config/AnimalPersonalityCatalog.java @@ -0,0 +1,105 @@ +package creator.loki.feature.animals.config; + +import java.awt.Color; +import java.util.List; + +/** Каталог ролей животных для зоо-викторин. */ +public final class AnimalPersonalityCatalog { + private AnimalPersonalityCatalog() {} + + private static final List CAT = + List.of( + new AnimalPersonality( + 90, + "Король котов", + "Грациозный хищник, весь дом — твой личный трон.", + new Color(255, 215, 0)), + new AnimalPersonality( + 70, + "Британский вислоухий", + "Спокоен, пока не надо кормить. Уважаешь порядок и диван.", + new Color(139, 69, 19)), + new AnimalPersonality( + 50, + "Дворовый кот", + "Любишь свободу и вкусняшки. Мурчишь только тем, кто заслужил.", + new Color(255, 165, 0)), + new AnimalPersonality( + 30, + "Котёнок", + "Милый и игривый, но наглый, когда чего-то хочешь.", + new Color(100, 200, 255)), + new AnimalPersonality( + 0, + "Дикий кот", + "Не даёшь себя гладить, но внимательно следишь за всеми.", + new Color(128, 128, 128))); + + private static final List DOG = + List.of( + new AnimalPersonality( + 90, + "Альфа-волк", + "Вожак стаи. Защищаешь своих и метишь территорию.", + new Color(255, 215, 0)), + new AnimalPersonality( + 70, + "Немецкая овчарка", + "Преданный и умный, всегда на страже порядка.", + new Color(139, 69, 19)), + new AnimalPersonality( + 50, + "Золотистый ретривер", + "Друг для всех. Хвост виляет сам, даже когда спишь.", + new Color(255, 165, 0)), + new AnimalPersonality( + 30, + "Мопс", + "Ленивый, но невероятно обаятельный. Храпишь с достоинством.", + new Color(160, 82, 45)), + new AnimalPersonality( + 0, + "Бродяга", + "Свободная душа. Ищешь свой дом, но уходишь, если скучно.", + new Color(100, 100, 100))); + + private static final List FOX = + List.of( + new AnimalPersonality( + 90, + "Мифический девятихвостый", + "Хитрость и мудрость на высшем уровне. Все к тебе приходят за советом.", + new Color(255, 215, 0)), + new AnimalPersonality( + 70, + "Огненный лис", + "Яркий и заметный. На тебя оборачиваются, а ты лишь ухмыляешься.", + new Color(255, 69, 0)), + new AnimalPersonality( + 50, + "Обычный лис", + "Хитрожопый, но честный, когда это выгодно.", + new Color(255, 140, 0)), + new AnimalPersonality( + 30, + "Лисёнок", + "Любопытный и милый, всё нюхаешь и лезешь куда не просят.", + new Color(255, 200, 150)), + new AnimalPersonality( + 0, + "Полярный лис", + "Холодный и загадочный. Друзей мало, но зато настоящие.", + new Color(200, 230, 255))); + + public static List catLevels() { + return CAT; + } + + public static List dogLevels() { + return DOG; + } + + public static List foxLevels() { + return FOX; + } +} diff --git a/src/main/java/creator/loki/feature/entertainment/commands/MemeCommand.java b/src/main/java/creator/loki/feature/entertainment/commands/MemeCommand.java index c8722d1..3d2eab2 100644 --- a/src/main/java/creator/loki/feature/entertainment/commands/MemeCommand.java +++ b/src/main/java/creator/loki/feature/entertainment/commands/MemeCommand.java @@ -1,11 +1,8 @@ package creator.loki.feature.entertainment.commands; import java.awt.Color; -import java.io.BufferedReader; -import java.io.InputStreamReader; -import java.net.HttpURLConnection; -import java.net.URI; -import java.nio.charset.StandardCharsets; +import java.io.IOException; +import java.util.concurrent.TimeUnit; import com.google.gson.JsonObject; import com.google.gson.JsonParser; @@ -13,8 +10,22 @@ import creator.loki.command.Command; import creator.loki.command.CommandCategories; import creator.loki.command.CommandContext; import creator.loki.embed.EmbedFactory; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; public class MemeCommand implements Command { + private static final String MEME_API = "https://meme-api.com/gimme"; + + private final OkHttpClient client; + + public MemeCommand() { + this.client = + new OkHttpClient.Builder() + .connectTimeout(6, TimeUnit.SECONDS) + .readTimeout(8, TimeUnit.SECONDS) + .build(); + } @Override public String getName() { @@ -44,22 +55,7 @@ public class MemeCommand implements Command { .queue( loadingMsg -> { try { - var url = new URI("https://meme-api.com/gimme").toURL(); - HttpURLConnection conn = (HttpURLConnection) url.openConnection(); - conn.setRequestMethod("GET"); - conn.setRequestProperty("User-Agent", "Mozilla/5.0"); - - BufferedReader in = - new BufferedReader( - new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8)); - StringBuilder response = new StringBuilder(); - String line; - while ((line = in.readLine()) != null) { - response.append(line); - } - in.close(); - - JsonObject json = JsonParser.parseString(response.toString()).getAsJsonObject(); + JsonObject json = fetchMeme(); String memeUrl = json.get("url").getAsString(); String title = json.get("title").getAsString(); String subreddit = json.get("subreddit").getAsString(); @@ -74,9 +70,19 @@ public class MemeCommand implements Command { .build()) .queue(); - } catch (Exception e) { + } catch (IOException | IllegalStateException e) { loadingMsg.editMessage("❌ Не удалось загрузить мем: " + e.getMessage()).queue(); } }); } + + private JsonObject fetchMeme() throws IOException { + Request request = new Request.Builder().url(MEME_API).get().build(); + try (Response response = client.newCall(request).execute()) { + if (!response.isSuccessful() || response.body() == null) { + throw new IOException("meme-api вернул " + response.code()); + } + return JsonParser.parseString(response.body().string()).getAsJsonObject(); + } + } } diff --git a/src/main/java/creator/loki/integration/nekos/NekosLifeApi.java b/src/main/java/creator/loki/integration/nekos/NekosLifeApi.java index 92ea174..e15ba37 100644 --- a/src/main/java/creator/loki/integration/nekos/NekosLifeApi.java +++ b/src/main/java/creator/loki/integration/nekos/NekosLifeApi.java @@ -7,6 +7,7 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.google.inject.Inject; import com.google.inject.Singleton; +import creator.loki.util.ApiRequestQueue; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; @@ -20,24 +21,29 @@ public class NekosLifeApi { private static NekosLifeApi instance; + private final ApiRequestQueue queue; private final OkHttpClient client; @Inject - public NekosLifeApi() { - this.client = - new OkHttpClient.Builder() - .connectTimeout(10, TimeUnit.SECONDS) - .readTimeout(10, TimeUnit.SECONDS) - .build(); - NekosLifeApi.instance = this; + public NekosLifeApi(final ApiRequestQueue queue) { + this(queue, buildClient()); } - NekosLifeApi(OkHttpClient client) { + public NekosLifeApi() { + this(new ApiRequestQueue(), buildClient()); + } + + NekosLifeApi(final ApiRequestQueue queue, final OkHttpClient client) { + this.queue = queue; this.client = client; NekosLifeApi.instance = this; } public String getImage(final String category) { + return queue.submit(() -> getImageBlocking(category)); + } + + private String getImageBlocking(final String category) { int maxRetries = 3; long retryDelay = 1000; @@ -93,6 +99,13 @@ public class NekosLifeApi { } } + private static OkHttpClient buildClient() { + return new OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .build(); + } + public static String fetchImage(final String category) { if (instance == null) { instance = new NekosLifeApi(); diff --git a/src/main/java/creator/loki/util/ApiRequestQueue.java b/src/main/java/creator/loki/util/ApiRequestQueue.java new file mode 100644 index 0000000..0befb76 --- /dev/null +++ b/src/main/java/creator/loki/util/ApiRequestQueue.java @@ -0,0 +1,59 @@ +package creator.loki.util; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +import creator.loki.core.Lifecycle; + +/** + * Очередь исходящих API-запросов. Каждый запрос выполняется изолированно в своём потоке (пул + * фиксированного размера), поэтому ответ всегда привязан к своему запросу и не может «подмениться» + * под соседний, а одновременные запросы не перегружают внешние API. + */ +public class ApiRequestQueue implements Lifecycle { + private static final int DEFAULT_CONCURRENCY = 4; + + private final ExecutorService executor; + + public ApiRequestQueue() { + this(DEFAULT_CONCURRENCY); + } + + public ApiRequestQueue(final int maxConcurrent) { + this.executor = Executors.newFixedThreadPool(maxConcurrent); + } + + ApiRequestQueue(final ExecutorService executor) { + this.executor = executor; + } + + /** Выполняет задачу через очередь и блокирует до получения результата. */ + public T submit(final Supplier task) { + Future future = executor.submit(task::get); + try { + return future.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return null; + } catch (ExecutionException e) { + throw new IllegalStateException("API request failed", e.getCause()); + } + } + + @Override + public void shutdown() { + executor.shutdown(); + try { + if (!executor.awaitTermination(5, TimeUnit.SECONDS)) { + executor.shutdownNow(); + } + } catch (InterruptedException e) { + executor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/test/java/creator/loki/feature/animals/AnimalApiTest.java b/src/test/java/creator/loki/feature/animals/AnimalApiTest.java new file mode 100644 index 0000000..557d9f6 --- /dev/null +++ b/src/test/java/creator/loki/feature/animals/AnimalApiTest.java @@ -0,0 +1,37 @@ +package creator.loki.feature.animals; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.google.gson.JsonParser; +import creator.loki.feature.animals.AnimalApi.UrlExtractor; +import org.junit.jupiter.api.Test; + +class AnimalApiTest { + + @Test + void objectKeyExtractsValueByKey() { + UrlExtractor extractor = AnimalApi.objectKey("url"); + + String url = extractor.extract(JsonParser.parseString("{\"url\":\"https://x/y.jpg\"}")); + + assertEquals("https://x/y.jpg", url); + } + + @Test + void firstArrayStringExtractsFirstElement() { + UrlExtractor extractor = AnimalApi.firstArrayString(); + + String url = extractor.extract(JsonParser.parseString("[\"https://x/y.jpg\"]")); + + assertEquals("https://x/y.jpg", url); + } + + @Test + void firstArrayObjectKeyExtractsKeyFromFirstObject() { + UrlExtractor extractor = AnimalApi.firstArrayObjectKey("url"); + + String url = extractor.extract(JsonParser.parseString("[{\"url\":\"https://x/y.jpg\"}]")); + + assertEquals("https://x/y.jpg", url); + } +} diff --git a/src/test/java/creator/loki/feature/animals/command/AnimalImageCommandTest.java b/src/test/java/creator/loki/feature/animals/command/AnimalImageCommandTest.java new file mode 100644 index 0000000..5e21d50 --- /dev/null +++ b/src/test/java/creator/loki/feature/animals/command/AnimalImageCommandTest.java @@ -0,0 +1,62 @@ +package creator.loki.feature.animals.command; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; + +import creator.loki.command.CommandContext; +import creator.loki.feature.animals.AnimalApi; +import creator.loki.feature.animals.AnimalApi.AnimalKind; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import net.dv8tion.jda.api.requests.restaction.MessageCreateAction; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AnimalImageCommandTest { + + @Mock AnimalApi animalApi; + @Mock MessageChannel channel; + @Mock Message message; + @Mock MessageCreateAction sendAction; + + @Captor ArgumentCaptor embedCaptor; + + @Test + void sendsImage() { + when(animalApi.fetchImage(any(AnimalKind.class))).thenReturn("https://shibe.jpg"); + when(channel.sendMessageEmbeds(any(MessageEmbed.class))).thenReturn(sendAction); + + new AnimalImageCommand(AnimalImageCommand.Flavor.SHIBE, animalApi) + .execute(new CommandContext(message, List.of(), channel, null, null)); + + verify(channel).sendMessageEmbeds(embedCaptor.capture()); + MessageEmbed embed = embedCaptor.getValue(); + assertTrue(embed.getTitle().contains("шиба")); + assertNotNull(embed.getImage()); + assertEquals("https://shibe.jpg", embed.getImage().getUrl()); + } + + @Test + void imageNullShowsError() { + when(animalApi.fetchImage(any(AnimalKind.class))).thenReturn(null); + when(channel.sendMessageEmbeds(any(MessageEmbed.class))).thenReturn(sendAction); + + new AnimalImageCommand(AnimalImageCommand.Flavor.SHIBE, animalApi) + .execute(new CommandContext(message, List.of(), channel, null, null)); + + verify(channel).sendMessageEmbeds(embedCaptor.capture()); + assertTrue(embedCaptor.getValue().getTitle().startsWith("❌")); + } +} diff --git a/src/test/java/creator/loki/feature/animals/command/ZooPersonCommandTest.java b/src/test/java/creator/loki/feature/animals/command/ZooPersonCommandTest.java new file mode 100644 index 0000000..58b69d1 --- /dev/null +++ b/src/test/java/creator/loki/feature/animals/command/ZooPersonCommandTest.java @@ -0,0 +1,77 @@ +package creator.loki.feature.animals.command; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; + +import creator.loki.command.CommandContext; +import creator.loki.feature.animals.AnimalApi; +import creator.loki.feature.animals.AnimalApi.AnimalKind; +import creator.loki.feature.entertainment.service.RatingService; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import net.dv8tion.jda.api.requests.restaction.MessageCreateAction; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ZooPersonCommandTest { + + @Mock RatingService ratingService; + @Mock AnimalApi animalApi; + @Mock MessageChannel channel; + @Mock Message message; + @Mock Member member; + @Mock User user; + @Mock MessageCreateAction sendAction; + + @Captor ArgumentCaptor embedCaptor; + + @Test + void cooldownBlocksMessageSend() { + when(ratingService.checkAndApplyCooldown(any(), anyString())).thenReturn(false); + + new ZooPersonCommand(ZooPersonCommand.Flavor.CAT, ratingService, animalApi) + .execute(new CommandContext(message, List.of(), channel, member, null)); + + verify(channel, never()).sendMessageEmbeds(any(MessageEmbed.class)); + } + + @Test + void sendsQuizEmbed() { + when(ratingService.checkAndApplyCooldown(any(), anyString())).thenReturn(true); + when(ratingService.resolveTarget(any())).thenReturn(member); + when(member.getId()).thenReturn("123"); + when(member.getUser()).thenReturn(user); + when(user.getAvatarUrl()).thenReturn("https://avatar.png"); + when(ratingService.getDeterministicValue(anyString(), anyInt(), anyInt(), anyInt())) + .thenReturn(73); + when(animalApi.fetchImage(any(AnimalKind.class))).thenReturn("https://cat.jpg"); + when(channel.sendMessageEmbeds(any(MessageEmbed.class))).thenReturn(sendAction); + + new ZooPersonCommand(ZooPersonCommand.Flavor.CAT, ratingService, animalApi) + .execute(new CommandContext(message, List.of(), channel, member, null)); + + verify(channel).sendMessageEmbeds(embedCaptor.capture()); + MessageEmbed embed = embedCaptor.getValue(); + assertTrue(embed.getTitle().contains("Какой ты кот")); + assertTrue(embed.getDescription().contains("73%")); + assertNotNull(embed.getImage()); + assertEquals("https://cat.jpg", embed.getImage().getUrl()); + } +} diff --git a/src/test/java/creator/loki/feature/animals/config/AnimalPersonalityCatalogTest.java b/src/test/java/creator/loki/feature/animals/config/AnimalPersonalityCatalogTest.java new file mode 100644 index 0000000..ee81c0f --- /dev/null +++ b/src/test/java/creator/loki/feature/animals/config/AnimalPersonalityCatalogTest.java @@ -0,0 +1,67 @@ +package creator.loki.feature.animals.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +class AnimalPersonalityCatalogTest { + + @Test + void catLevelsAreSortedDescendingAndBounded() { + assertLevels(AnimalPersonalityCatalog.catLevels()); + } + + @Test + void dogLevelsAreSortedDescendingAndBounded() { + assertLevels(AnimalPersonalityCatalog.dogLevels()); + } + + @Test + void foxLevelsAreSortedDescendingAndBounded() { + assertLevels(AnimalPersonalityCatalog.foxLevels()); + } + + @Test + void findReturnsLastLevelForZero() { + List levels = AnimalPersonalityCatalog.catLevels(); + + assertSame(last(levels), AnimalPersonality.find(0, levels)); + } + + @Test + void findReturnsFirstLevelForHundred() { + List levels = AnimalPersonalityCatalog.catLevels(); + + assertSame(first(levels), AnimalPersonality.find(100, levels)); + } + + @Test + void findReturnsLevelWithExactThreshold() { + List levels = AnimalPersonalityCatalog.catLevels(); + AnimalPersonality second = levels.get(1); + + assertSame(second, AnimalPersonality.find(second.getThreshold(), levels)); + } + + private static void assertLevels(final List levels) { + assertFalse(levels.isEmpty()); + assertEquals(0, last(levels).getThreshold()); + assertTrue(first(levels).getThreshold() > 0); + for (int i = 0; i < levels.size() - 1; i++) { + assertTrue(levels.get(i).getThreshold() > levels.get(i + 1).getThreshold()); + } + } + + private static AnimalPersonality first(final List levels) { + return levels.get(0); + } + + private static AnimalPersonality last(final List levels) { + return levels.get(levels.size() - 1); + } +} diff --git a/src/test/java/creator/loki/util/ApiRequestQueueTest.java b/src/test/java/creator/loki/util/ApiRequestQueueTest.java new file mode 100644 index 0000000..ba6608b --- /dev/null +++ b/src/test/java/creator/loki/util/ApiRequestQueueTest.java @@ -0,0 +1,61 @@ +package creator.loki.util; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Callable; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import org.junit.jupiter.api.Test; + +class ApiRequestQueueTest { + + @Test + void submitReturnsTaskResult() { + var queue = new ApiRequestQueue(1); + try { + assertEquals("ok", queue.submit(() -> "ok")); + } finally { + queue.shutdown(); + } + } + + @Test + void concurrentSubmissionsGetOwnResults() throws Exception { + var queue = new ApiRequestQueue(4); + try { + try (var pool = Executors.newFixedThreadPool(8)) { + List> tasks = + IntStream.range(0, 40) + .mapToObj( + i -> + (Callable) + () -> + queue.submit( + () -> { + try { + Thread.sleep(5); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return "result-" + i; + })) + .collect(Collectors.toList()); + List> futures = pool.invokeAll(tasks); + List results = new ArrayList<>(); + for (Future future : futures) { + results.add(future.get()); + } + for (int i = 0; i < 40; i++) { + assertEquals("result-" + i, results.get(i), "response must match its request"); + } + } + } finally { + queue.shutdown(); + } + } +}