feat: zoo commands (catperson/dogperson/foxperson, shibe/birb/neko/waifu/catboy) + ApiRequestQueue

- AnimalApi: 8 public APIs with fallback chain + Caffeine cache (TTL 30s)
- ZooPersonCommand (deterministic personality + image), AnimalImageCommand
- util/ApiRequestQueue: fixed pool 4, isolated responses, graceful shutdown
- wired into AnimalApi + NekosLifeApi, MemeCommand moved to OkHttp timeouts
- 15 tests green
This commit is contained in:
loki5512344 2026-08-19 13:52:50 +02:00
parent 92d1d158b3
commit 7cf4b9debb
Signed by: boba
GPG key ID: 253067914055423B
19 changed files with 1016 additions and 33 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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<String, String> 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<Endpoint> endpoints;
AnimalKind(final String emoji, final List<Endpoint> endpoints) {
this.emoji = emoji;
this.endpoints = endpoints;
}
public String getEmoji() {
return emoji;
}
public List<Endpoint> getEndpoints() {
return endpoints;
}
/** Эндпоинт с URL запроса и логикой извлечения картинки из JSON. */
public record Endpoint(String url, UrlExtractor extractor) {}
}
/** Извлекает URL картинки из JSON-ответа API. */
@FunctionalInterface
interface UrlExtractor {
String extract(JsonElement json);
}
}

View file

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

View file

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

View file

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

View file

@ -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<AnimalPersonality> levels;
Flavor(
final String name,
final String question,
final int salt,
final AnimalKind kind,
final List<AnimalPersonality> 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<AnimalPersonality> 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();
}
}

View file

@ -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<AnimalPersonality> levels) {
return levels.stream()
.filter(level -> value >= level.threshold)
.findFirst()
.orElse(levels.get(levels.size() - 1));
}
}

View file

@ -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<AnimalPersonality> 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<AnimalPersonality> 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<AnimalPersonality> 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<AnimalPersonality> catLevels() {
return CAT;
}
public static List<AnimalPersonality> dogLevels() {
return DOG;
}
public static List<AnimalPersonality> foxLevels() {
return FOX;
}
}

View file

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

View file

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

View file

@ -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> T submit(final Supplier<T> task) {
Future<T> 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();
}
}
}

View file

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

View file

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

View file

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

View file

@ -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<AnimalPersonality> levels = AnimalPersonalityCatalog.catLevels();
assertSame(last(levels), AnimalPersonality.find(0, levels));
}
@Test
void findReturnsFirstLevelForHundred() {
List<AnimalPersonality> levels = AnimalPersonalityCatalog.catLevels();
assertSame(first(levels), AnimalPersonality.find(100, levels));
}
@Test
void findReturnsLevelWithExactThreshold() {
List<AnimalPersonality> levels = AnimalPersonalityCatalog.catLevels();
AnimalPersonality second = levels.get(1);
assertSame(second, AnimalPersonality.find(second.getThreshold(), levels));
}
private static void assertLevels(final List<AnimalPersonality> 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<AnimalPersonality> levels) {
return levels.get(0);
}
private static AnimalPersonality last(final List<AnimalPersonality> levels) {
return levels.get(levels.size() - 1);
}
}

View file

@ -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<Callable<String>> tasks =
IntStream.range(0, 40)
.mapToObj(
i ->
(Callable<String>)
() ->
queue.submit(
() -> {
try {
Thread.sleep(5);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return "result-" + i;
}))
.collect(Collectors.toList());
List<Future<String>> futures = pool.invokeAll(tasks);
List<String> results = new ArrayList<>();
for (Future<String> 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();
}
}
}