From 49adad7f585a22db8a179099cda89e9c413881c5 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Sun, 12 Jul 2026 22:24:43 +0200 Subject: [PATCH] feat: image navigation, yandere/e621random commands, ai session manager, deploy script --- .env.example | 32 +++-- deploy.sh | 40 ++++++ .../creator/loki/core/ListenerAggregator.java | 7 +- .../loki/feature/help/HelpMenuHandler.java | 11 +- .../feature/help/commands/HelpCommand.java | 11 +- .../feature/image/ImageCommandRegistrar.java | 14 +- .../loki/feature/image/ImageEmbedHelper.java | 91 ++++++++++++ .../loki/feature/image/ImageModule.java | 5 +- .../feature/image/ImageNavigationHandler.java | 119 +++++++++++++++ .../loki/feature/image/ImageService.java | 39 ++++- .../loki/feature/image/ImageSession.java | 90 ++++++++++++ .../feature/image/ImageSessionManager.java | 50 +++++++ .../image/commands/AbstractImageCommand.java | 107 ++++++++++++++ .../feature/image/commands/E621Command.java | 136 +++--------------- .../image/commands/E621RandomCommand.java | 78 ++++++++++ .../image/commands/GelbooruCommand.java | 136 +++--------------- .../feature/image/commands/R34Command.java | 136 +++--------------- .../image/commands/YandereCommand.java | 29 ++++ .../loki/feature/roast/AgrCommand.java | 8 +- .../loki/feature/roast/AgrSlashCommands.java | 15 +- .../loki/feature/roast/AiSessionManager.java | 93 ++++++++++++ .../loki/feature/roast/AndrertListener.java | 102 +++++-------- .../loki/feature/roast/RoastModule.java | 1 + 23 files changed, 880 insertions(+), 470 deletions(-) create mode 100755 deploy.sh create mode 100644 src/main/java/creator/loki/feature/image/ImageEmbedHelper.java create mode 100644 src/main/java/creator/loki/feature/image/ImageNavigationHandler.java create mode 100644 src/main/java/creator/loki/feature/image/ImageSession.java create mode 100644 src/main/java/creator/loki/feature/image/ImageSessionManager.java create mode 100644 src/main/java/creator/loki/feature/image/commands/AbstractImageCommand.java create mode 100644 src/main/java/creator/loki/feature/image/commands/E621RandomCommand.java create mode 100644 src/main/java/creator/loki/feature/image/commands/YandereCommand.java create mode 100644 src/main/java/creator/loki/feature/roast/AiSessionManager.java diff --git a/.env.example b/.env.example index 202b025..98441a1 100644 --- a/.env.example +++ b/.env.example @@ -1,27 +1,37 @@ -# Main bot token (all features) +# === Discord === DISCORD_BOT_TOKEN=your_token_here -# Music bot token (only music via Lavalink) - OPTIONAL DISCORD_BOT_TOKEN_MUSIC=your_music_token -# Radio bot token (only radio) - OPTIONAL DISCORD_BOT_TOKEN_RADIO=your_radio_token + BOT_PREFIX=! BOT_ACTIVITY=Используй !help -HEALTH_HTTP_PORT=8712 SUPER_ADMIN_ID=your_discord_user_id + +# === HTTP Health === +HEALTH_HTTP_PORT=8712 + +# === Database === DB_PATH=data/bot.db -# DeepSeek AI API (для roast) -DEEPSEEK_API_KEY=your_deepseek_key - -# Debate channel ID для мульти-AI диспутов +# === AI / DeepSeek === +DEEPSEEK_API_KEY=your_deepseek_api_key +AI_CHANNEL_ID=your_ai_channel_id +AI_CONTEXT_MSGS=10 +AI_MSG_LENGTH=300 +AI_CONTEXT_CHARS=3000 +AI_DOSSIER_MSGS=30 +AI_DOSSIER_UPDATE_HOURS=24 AI_DEBATE_CHANNEL_ID=your_debate_channel_id +# === GIPHY (для GIF прокси) === +GIPHY_API_KEY= -# Lavalink +# === Lavalink === LAVALINK_HOST=127.0.0.1 LAVALINK_PORT=2333 -LAVALINK_PASSWORD=youshallnotpass +LAVALINK_PASSWORD=Hch4HiVCxkKs9JPiSnrt4Q +LAVALINK_ENABLED=true -# Spotify API (для LavaSrc) +# === Spotify (для LavaSrc) === SPOTIFY_CLIENT_ID=your_spotify_client_id SPOTIFY_CLIENT_SECRET=your_spotify_client_secret diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..4d72b2b --- /dev/null +++ b/deploy.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ========================================= +# Loki Discord Bot — VPS Deploy Script +# Запускать на VPS после git clone +# ========================================= + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$SCRIPT_DIR" + +echo "=== 1. Проверка Docker ===" +command -v docker >/dev/null 2>&1 || { echo "Docker не установлен"; exit 1; } +command -v docker compose >/dev/null 2>&1 || { echo "docker compose не найден"; exit 1; } + +echo "=== 2. Создание .env из .env.example ===" +if [ ! -f .env ]; then + cp .env.example .env + echo "Файл .env создан. ОТРЕДАКТИРУЙ ЕГО ПЕРЕД ЗАПУСКОМ!" + echo "Заполни токены в .env и запусти скрипт снова." + exit 0 +fi + +echo "=== 3. Создание необходимых директорий ===" +mkdir -p data/prompts data/knowledge lavalink/plugins logs + +echo "=== 4. Проверка Lavalink плагинов ===" +if [ -z "$(ls -A lavalink/plugins 2>/dev/null)" ]; then + echo "ВНИМАНИЕ: lavalink/plugins пуста." + echo "Скачай плагины вручную если нужно (youtube-plugin, lavasrc-plugin)." +fi + +echo "=== 5. Сборка и запуск ===" +docker compose up --build -d + +echo "" +echo "=== Готово! ===" +echo " docker compose logs -f — логи" +echo " docker compose down — остановить" +echo " docker compose restart — перезапустить" diff --git a/src/main/java/creator/loki/core/ListenerAggregator.java b/src/main/java/creator/loki/core/ListenerAggregator.java index 5bddf7f..31775cb 100644 --- a/src/main/java/creator/loki/core/ListenerAggregator.java +++ b/src/main/java/creator/loki/core/ListenerAggregator.java @@ -6,6 +6,7 @@ import creator.loki.bootstrap.ReadyListener; import creator.loki.feature.counter.service.MemberCounterListener; import creator.loki.feature.economy.FishMessageListener; import creator.loki.feature.help.HelpMenuHandler; +import creator.loki.feature.image.ImageNavigationHandler; import creator.loki.feature.leaderboard.LeaderboardButtonListener; import creator.loki.feature.level.listener.MessageListener; import creator.loki.feature.level.listener.ResetLevelsButtonHandler; @@ -45,7 +46,8 @@ public class ListenerAggregator { FishMessageListener fishMessageListener, AndrertListener andrertListener, ClearSlashCommands clearSlashCommands, - HelperListener helperListener) { + HelperListener helperListener, + ImageNavigationHandler imageNavigationHandler) { this.listeners = new Object[] { readyListener, @@ -66,7 +68,8 @@ public class ListenerAggregator { fishMessageListener, andrertListener, clearSlashCommands, - helperListener + helperListener, + imageNavigationHandler }; } diff --git a/src/main/java/creator/loki/feature/help/HelpMenuHandler.java b/src/main/java/creator/loki/feature/help/HelpMenuHandler.java index 40f32d5..0bf53c4 100644 --- a/src/main/java/creator/loki/feature/help/HelpMenuHandler.java +++ b/src/main/java/creator/loki/feature/help/HelpMenuHandler.java @@ -15,7 +15,7 @@ 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.ChannelType; +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; @@ -77,7 +77,7 @@ public class HelpMenuHandler extends ListenerAdapter { boolean isAdmin = event.getMember() != null && event.getMember().hasPermission(Permission.ADMINISTRATOR); boolean isSuper = config.getSuperAdminId().equals(event.getUser().getId()); - boolean isNsfwChannel = isNsfw(event.getChannel().getType()); + boolean isNsfwChannel = isNsfw(event); commandsByCategory.keySet().stream() .sorted() @@ -153,10 +153,7 @@ public class HelpMenuHandler extends ListenerAdapter { return embed; } - private static boolean isNsfw(ChannelType type) { - return type == ChannelType.TEXT - || type == ChannelType.NEWS - || type == ChannelType.FORUM - || type == ChannelType.MEDIA; + private static boolean isNsfw(StringSelectInteractionEvent event) { + return event.getChannel() instanceof IAgeRestrictedChannel arc && arc.isNSFW(); } } diff --git a/src/main/java/creator/loki/feature/help/commands/HelpCommand.java b/src/main/java/creator/loki/feature/help/commands/HelpCommand.java index b8c02ba..a482e26 100644 --- a/src/main/java/creator/loki/feature/help/commands/HelpCommand.java +++ b/src/main/java/creator/loki/feature/help/commands/HelpCommand.java @@ -15,7 +15,7 @@ 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.ChannelType; +import net.dv8tion.jda.api.entities.channel.attribute.IAgeRestrictedChannel; public class HelpCommand implements Command { private final CommandRegistry registry; @@ -49,7 +49,7 @@ public class HelpCommand implements Command { boolean isAdmin = context.getAuthor() != null && context.getAuthor().hasPermission(Permission.ADMINISTRATOR); boolean isSuper = config.getSuperAdminId().equals(viewer.getId()); - boolean isNsfwChannel = isNsfw(context.getChannel().getType()); + boolean isNsfwChannel = isNsfw(context); // Главная страница EmbedFactory embed = @@ -85,11 +85,8 @@ public class HelpCommand implements Command { .queue(); } - private static boolean isNsfw(ChannelType type) { - return type == ChannelType.TEXT - || type == ChannelType.NEWS - || type == ChannelType.FORUM - || type == ChannelType.MEDIA; + private static boolean isNsfw(CommandContext context) { + return context.getChannel() instanceof IAgeRestrictedChannel arc && arc.isNSFW(); } private EmbedFactory createMainPage( diff --git a/src/main/java/creator/loki/feature/image/ImageCommandRegistrar.java b/src/main/java/creator/loki/feature/image/ImageCommandRegistrar.java index babd4a2..2b2851a 100644 --- a/src/main/java/creator/loki/feature/image/ImageCommandRegistrar.java +++ b/src/main/java/creator/loki/feature/image/ImageCommandRegistrar.java @@ -4,21 +4,27 @@ import com.google.inject.Inject; import creator.loki.core.registry.CommandRegistrar; import creator.loki.core.registry.CommandRegistry; import creator.loki.feature.image.commands.E621Command; +import creator.loki.feature.image.commands.E621RandomCommand; import creator.loki.feature.image.commands.GelbooruCommand; import creator.loki.feature.image.commands.R34Command; +import creator.loki.feature.image.commands.YandereCommand; public class ImageCommandRegistrar implements CommandRegistrar { private final ImageService imageService; + private final ImageSessionManager sessionManager; @Inject - public ImageCommandRegistrar(ImageService imageService) { + public ImageCommandRegistrar(ImageService imageService, ImageSessionManager sessionManager) { this.imageService = imageService; + this.sessionManager = sessionManager; } @Override public void register(CommandRegistry registry) { - registry.register(new E621Command(imageService)); - registry.register(new R34Command(imageService)); - registry.register(new GelbooruCommand(imageService)); + registry.register(new E621Command(imageService, sessionManager)); + registry.register(new E621RandomCommand(imageService, sessionManager)); + registry.register(new R34Command(imageService, sessionManager)); + registry.register(new GelbooruCommand(imageService, sessionManager)); + registry.register(new YandereCommand(imageService, sessionManager)); } } diff --git a/src/main/java/creator/loki/feature/image/ImageEmbedHelper.java b/src/main/java/creator/loki/feature/image/ImageEmbedHelper.java new file mode 100644 index 0000000..1119208 --- /dev/null +++ b/src/main/java/creator/loki/feature/image/ImageEmbedHelper.java @@ -0,0 +1,91 @@ +package creator.loki.feature.image; + +import creator.loki.embed.EmbedFactory; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.components.actionrow.ActionRow; +import net.dv8tion.jda.api.components.buttons.Button; + +import java.util.ArrayList; +import java.util.List; + +public final class ImageEmbedHelper { + + private static final String NAV_PREFIX = "img_nav:"; + + private ImageEmbedHelper() {} + + public static MessageEmbed[] buildEmbeds(ImageSession session) { + String tagsStr = formatTags(session.tags()); + List embeds = new ArrayList<>(); + List pageUrls = session.currentPageUrls(); + boolean descUsed = false; + + for (String url : pageUrls) { + var builder = EmbedFactory.create() + .withTitle("🔞 " + session.site()) + .withImage(url) + .withColor(session.color()); + if (!descUsed && !tagsStr.isBlank()) { + builder.withDescription(tagsStr); + descUsed = true; + } + embeds.add(builder.build()); + } + + if (!embeds.isEmpty()) { + String footer = "Стр. " + (session.page() + 1) + " / " + session.totalPages(); + if (!session.tags().isBlank()) { + footer += " · " + session.tags(); + } + // Последний эмбед получает футер + int last = embeds.size() - 1; + var lastBuilder = EmbedFactory.create() + .withTitle("🔞 " + session.site()) + .withImage(pageUrls.get(last)) + .withColor(session.color()) + .withFooter(footer); + // Если description ещё не был поставлен (1 эмбед на странице) + if (!descUsed && !tagsStr.isBlank()) { + lastBuilder.withDescription(tagsStr); + } + embeds.set(last, lastBuilder.build()); + } + + return embeds.toArray(new MessageEmbed[0]); + } + + /** + * Кнопка «Вперёд» отключается только если: + * - нет следующей страницы В сессии и уже последняя API-страница (isEmpty флаг). + * Поскольку мы не знаем заранее, есть ли ещё страницы на сервере, + * кнопка остаётся активной пока не придёт пустой ответ от API. + * Флаг session.exhausted() сигнализирует об этом. + */ + public static ActionRow buildActionRow(ImageSession session) { + boolean noPrev = !session.hasPrev(); + boolean noNext = !session.hasNext() && session.exhausted(); + return ActionRow.of( + Button.primary(NAV_PREFIX + "prev", "◀ Назад").withDisabled(noPrev), + Button.primary(NAV_PREFIX + "next", "Вперёд ▶").withDisabled(noNext) + ); + } + + public static String formatTags(String tags) { + if (tags == null || tags.isBlank()) return ""; + String[] parts = tags.split(" "); + int count = Math.min(parts.length, 10); + StringBuilder sb = new StringBuilder("**Теги:** "); + for (int i = 0; i < count; i++) { + if (i > 0) sb.append(" "); + sb.append("`").append(parts[i]).append("`"); + } + if (parts.length > 10) { + sb.append(" **+").append(parts.length - 10).append("**"); + } + return sb.toString(); + } + + public static String tagsDesc(String tags) { + return (tags == null || tags.isBlank()) ? "" : " `" + tags + "`"; + } +} diff --git a/src/main/java/creator/loki/feature/image/ImageModule.java b/src/main/java/creator/loki/feature/image/ImageModule.java index 0bd01f6..f94e45a 100644 --- a/src/main/java/creator/loki/feature/image/ImageModule.java +++ b/src/main/java/creator/loki/feature/image/ImageModule.java @@ -7,7 +7,10 @@ import com.google.inject.Singleton; public class ImageModule extends AbstractModule { @Override - protected void configure() {} + protected void configure() { + bind(ImageSessionManager.class).in(Singleton.class); + bind(ImageNavigationHandler.class).in(Singleton.class); + } @Provides @Singleton diff --git a/src/main/java/creator/loki/feature/image/ImageNavigationHandler.java b/src/main/java/creator/loki/feature/image/ImageNavigationHandler.java new file mode 100644 index 0000000..6b9c443 --- /dev/null +++ b/src/main/java/creator/loki/feature/image/ImageNavigationHandler.java @@ -0,0 +1,119 @@ +package creator.loki.feature.image; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +@Singleton +public class ImageNavigationHandler extends ListenerAdapter { + + private static final Logger logger = LoggerFactory.getLogger(ImageNavigationHandler.class); + private static final String PREFIX = "img_nav:"; + private static final int PAGE_FETCH = 10; + + private final ImageSessionManager sessionManager; + private final ImageService imageService; + + @Inject + public ImageNavigationHandler(ImageSessionManager sessionManager, ImageService imageService) { + this.sessionManager = sessionManager; + this.imageService = imageService; + } + + @Override + public void onButtonInteraction(ButtonInteractionEvent event) { + String id = event.getComponentId(); + if (!id.startsWith(PREFIX)) return; + + String direction = id.substring(PREFIX.length()); + String messageId = event.getMessageId(); + logger.debug("nav: {} dir={}", messageId, direction); + + ImageSession session = sessionManager.get(messageId); + if (session == null) { + event.reply("⏰ Сессия истекла. Используй команду заново.").setEphemeral(true).queue(); + return; + } + + if (event.getUser().getIdLong() != session.userId()) { + event.reply("❌ Только автор запроса может листать.").setEphemeral(true).queue(); + return; + } + + switch (direction) { + case "next" -> handleNext(event, session, messageId); + case "prev" -> handlePrev(event, session, messageId); + } + } + + // ── Вперёд ─────────────────────────────────────────────────────────────────── + + private void handleNext(ButtonInteractionEvent event, ImageSession session, String messageId) { + if (session.hasNext()) { + // Есть готовая следующая страница в кеше + logger.debug("nav: next in cache page={}/{}", session.page() + 1, session.totalPages()); + ImageSession updated = session.withPage(session.page() + 1); + sessionManager.update(messageId, updated); + editMessage(event, updated); + } else if (session.exhausted()) { + // API уже закончился, листать некуда + event.reply("📭 Больше изображений нет.").setEphemeral(true).queue(); + } else { + // Нужно подгрузить следующую API-страницу + logger.debug("nav: loadMore apiPage={}", session.apiPage() + 1); + event.deferEdit().queue(hook -> loadMore(event, session, messageId)); + } + } + + // ── Назад ──────────────────────────────────────────────────────────────────── + + private void handlePrev(ButtonInteractionEvent event, ImageSession session, String messageId) { + if (!session.hasPrev()) return; + logger.debug("nav: prev page={}/{}", session.page() - 1, session.totalPages()); + ImageSession updated = session.withPage(session.page() - 1); + sessionManager.update(messageId, updated); + editMessage(event, updated); + } + + // ── Подгрузка с API ────────────────────────────────────────────────────────── + + private void loadMore(ButtonInteractionEvent event, ImageSession session, String messageId) { + int nextApiPage = session.apiPage() + 1; + logger.debug("loadMore: site={} tags='{}' apiPage={}", session.site(), session.tags(), nextApiPage); + + List newUrls = fetchPage(session.site(), session.tags(), nextApiPage); + logger.debug("loadMore: got {} urls", newUrls.size()); + + // withMoreUrls сам обработает пустой список → exhausted + ImageSession updated = session.withMoreUrls(newUrls); + sessionManager.update(messageId, updated); + + event.getHook() + .editOriginalEmbeds(ImageEmbedHelper.buildEmbeds(updated)) + .setComponents(ImageEmbedHelper.buildActionRow(updated)) + .queue(); + } + + private List fetchPage(String site, String tags, int apiPage) { + return switch (site) { + case "e621.net" -> imageService.fetchE621(tags, PAGE_FETCH, apiPage); + case "rule34.xxx" -> imageService.fetchR34(tags, PAGE_FETCH, apiPage); + case "gelbooru.com" -> imageService.fetchGelbooru(tags, PAGE_FETCH, apiPage); + case "yande.re" -> imageService.fetchYandere(tags, PAGE_FETCH, apiPage); + default -> List.of(); + }; + } + + // ── Вспомогательное ────────────────────────────────────────────────────────── + + private static void editMessage(ButtonInteractionEvent event, ImageSession session) { + event.editMessageEmbeds(ImageEmbedHelper.buildEmbeds(session)) + .setComponents(ImageEmbedHelper.buildActionRow(session)) + .queue(); + } +} diff --git a/src/main/java/creator/loki/feature/image/ImageService.java b/src/main/java/creator/loki/feature/image/ImageService.java index 9c4de24..55b3b2b 100644 --- a/src/main/java/creator/loki/feature/image/ImageService.java +++ b/src/main/java/creator/loki/feature/image/ImageService.java @@ -36,7 +36,12 @@ public class ImageService { } public List fetchE621(String tags, int limit) { - String url = E621_API + "?tags=" + encodeTags(tags) + "&limit=" + Math.min(limit, 10); + return fetchE621(tags, limit, 1); + } + + public List fetchE621(String tags, int limit, int page) { + int capped = Math.min(limit, 100); + String url = E621_API + "?tags=" + encodeTags(tags) + "&limit=" + capped + "&page=" + page; Request request = new Request.Builder() .url(url) @@ -76,17 +81,45 @@ public class ImageService { } public List fetchR34(String tags, int limit) { + return fetchR34(tags, limit, 1); + } + + public List fetchR34(String tags, int limit, int page) { + int capped = Math.min(limit, 100); String url = - R34_API + "&tags=" + encodeTags(tags) + "&limit=" + Math.min(limit, 10); + R34_API + "&tags=" + encodeTags(tags) + "&limit=" + capped + "&pid=" + (page - 1); return fetchBooruJson(url, limit); } public List fetchGelbooru(String tags, int limit) { + return fetchGelbooru(tags, limit, 1); + } + + public List fetchGelbooru(String tags, int limit, int page) { + int capped = Math.min(limit, 100); String url = "https://gelbooru.com/index.php?page=dapi&s=post&q=index&json=1&tags=" + encodeTags(tags) + "&limit=" - + Math.min(limit, 10); + + capped + + "&pid=" + + (page - 1); + return fetchBooruJson(url, limit); + } + + public List fetchYandere(String tags, int limit) { + return fetchYandere(tags, limit, 1); + } + + public List fetchYandere(String tags, int limit, int page) { + int capped = Math.min(limit, 100); + String url = + "https://yande.re/post.json?tags=" + + encodeTags(tags) + + "&limit=" + + capped + + "&page=" + + page; return fetchBooruJson(url, limit); } diff --git a/src/main/java/creator/loki/feature/image/ImageSession.java b/src/main/java/creator/loki/feature/image/ImageSession.java new file mode 100644 index 0000000..43faf44 --- /dev/null +++ b/src/main/java/creator/loki/feature/image/ImageSession.java @@ -0,0 +1,90 @@ +package creator.loki.feature.image; + +import java.util.ArrayList; +import java.util.List; + +/** + * Иммутабельная сессия навигации по изображениям. + * + *

exhausted = true означает, что API вернул пустой ответ на последнем запросе, + * т.е. новых страниц больше нет — тогда кнопка «Вперёд» блокируется. + */ +public record ImageSession( + List urls, + int page, + int pageSize, + String site, + java.awt.Color color, + String tags, + long userId, + long guildId, + long createdAt, + int apiPage, + boolean exhausted) { + + public ImageSession { + urls = List.copyOf(urls); + } + + // ── Фабричный метод ────────────────────────────────────────────────────────── + + public static ImageSession create( + List urls, + int pageSize, + String site, + java.awt.Color color, + String tags, + long userId, + long guildId) { + return new ImageSession( + urls, 0, pageSize, site, color, tags, userId, guildId, + System.currentTimeMillis(), 1, false); + } + + // ── Навигация ──────────────────────────────────────────────────────────────── + + public int totalPages() { + return Math.max(1, (int) Math.ceil((double) urls.size() / pageSize)); + } + + public boolean hasPrev() { + return page > 0; + } + + /** Есть ли следующая страница прямо сейчас (без подгрузки). */ + public boolean hasNext() { + return page < totalPages() - 1; + } + + public List currentPageUrls() { + int from = page * pageSize; + int to = Math.min(from + pageSize, urls.size()); + return urls.subList(from, to); + } + + // ── Мутации (возвращают новый экземпляр) ──────────────────────────────────── + + public ImageSession withPage(int newPage) { + return new ImageSession( + urls, newPage, pageSize, site, color, tags, userId, guildId, + System.currentTimeMillis(), apiPage, exhausted); + } + + /** + * Добавляем новые URL из следующей API-страницы и переходим на следующую + * внутреннюю страницу. Если newUrls пустой — помечаем сессию как exhausted. + */ + public ImageSession withMoreUrls(List newUrls) { + if (newUrls.isEmpty()) { + // API больше ничего не отдал — остаёмся на месте, ставим флаг + return new ImageSession( + urls, page, pageSize, site, color, tags, userId, guildId, + System.currentTimeMillis(), apiPage, true); + } + List combined = new ArrayList<>(urls); + combined.addAll(newUrls); + return new ImageSession( + combined, page + 1, pageSize, site, color, tags, userId, guildId, + System.currentTimeMillis(), apiPage + 1, false); + } +} diff --git a/src/main/java/creator/loki/feature/image/ImageSessionManager.java b/src/main/java/creator/loki/feature/image/ImageSessionManager.java new file mode 100644 index 0000000..bcabf70 --- /dev/null +++ b/src/main/java/creator/loki/feature/image/ImageSessionManager.java @@ -0,0 +1,50 @@ +package creator.loki.feature.image; + +import java.awt.Color; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import com.google.inject.Singleton; + +@Singleton +public class ImageSessionManager { + private static final long SESSION_TTL = 5 * 60 * 1000; + private final Map sessions = new ConcurrentHashMap<>(); + + public void put(String messageId, ImageSession session) { + cleanup(); + sessions.put(messageId, session); + } + + public ImageSession get(String messageId) { + ImageSession session = sessions.get(messageId); + if (session == null) return null; + if (System.currentTimeMillis() - session.createdAt() > SESSION_TTL) { + sessions.remove(messageId); + return null; + } + return session; + } + + public void update(String messageId, ImageSession session) { + sessions.put(messageId, session); + } + + public void remove(String messageId) { + sessions.remove(messageId); + } + + private void cleanup() { + long now = System.currentTimeMillis(); + sessions.values().removeIf(s -> now - s.createdAt() > SESSION_TTL); + } + + public static Color parseColor(String hex) { + try { + return Color.decode(hex); + } catch (NumberFormatException e) { + return new Color(88, 101, 242); + } + } +} diff --git a/src/main/java/creator/loki/feature/image/commands/AbstractImageCommand.java b/src/main/java/creator/loki/feature/image/commands/AbstractImageCommand.java new file mode 100644 index 0000000..9e709ff --- /dev/null +++ b/src/main/java/creator/loki/feature/image/commands/AbstractImageCommand.java @@ -0,0 +1,107 @@ +package creator.loki.feature.image.commands; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.embed.EmbedTemplates; +import creator.loki.feature.image.ImageEmbedHelper; +import creator.loki.feature.image.ImageService; +import creator.loki.feature.image.ImageSession; +import creator.loki.feature.image.ImageSessionManager; +import net.dv8tion.jda.api.entities.channel.attribute.IAgeRestrictedChannel; + +import java.awt.Color; +import java.util.List; + +/** + * Общая база для команд поиска изображений. + * Убирает дублирование: NSFW-проверка, parseArgs, buildEmbeds, buildButtons. + */ +public abstract class AbstractImageCommand implements Command { + + protected static final int PAGE_SIZE = 10; + protected static final int DEFAULT_FETCH = 30; + protected static final int MAX_FETCH = 50; + + protected final ImageService imageService; + protected final ImageSessionManager sessionManager; + + protected AbstractImageCommand(ImageService imageService, ImageSessionManager sessionManager) { + this.imageService = imageService; + this.sessionManager = sessionManager; + } + + // ── Подклассы реализуют ────────────────────────────────────────────────────── + + /** Имя сайта, хранимое в сессии (e621.net, rule34.xxx, …). */ + protected abstract String siteName(); + + /** Цвет эмбеда. */ + protected abstract Color siteColor(); + + /** Имя сайта для отображения в сообщении «Ищу…». */ + protected abstract String siteDisplayName(); + + /** Выполняет запрос к API и возвращает список URL. */ + protected abstract List fetchUrls(String tags, int limit); + + // ── Общая логика execute ───────────────────────────────────────────────────── + + @Override + public void execute(CommandContext context) { + if (!isNsfwChannel(context)) { + context.getChannel() + .sendMessageEmbeds(EmbedTemplates.error( + "🔞 NSFW", "Эта команда доступна только в каналах с возрастным ограничением.")) + .queue(); + return; + } + + ParseResult parsed = parseArgs(context.getArgs()); + String searchingMsg = "Ищу изображения на " + siteDisplayName() + + ImageEmbedHelper.tagsDesc(parsed.tags()) + "..."; + + context.getChannel() + .sendMessageEmbeds(EmbedTemplates.info("🔍 Поиск", searchingMsg)) + .queue(msg -> { + List urls = fetchUrls(parsed.tags(), parsed.limit()); + if (urls.isEmpty()) { + msg.editMessageEmbeds(EmbedTemplates.warning( + "😔 Ничего не найдено", + "По запросу" + ImageEmbedHelper.tagsDesc(parsed.tags()) + " ничего не найдено.")) + .queue(); + return; + } + + ImageSession session = ImageSession.create( + urls, PAGE_SIZE, siteName(), siteColor(), + parsed.tags(), + context.getAuthor().getIdLong(), + context.getGuild().getIdLong()); + sessionManager.put(msg.getId(), session); + + msg.editMessageEmbeds(ImageEmbedHelper.buildEmbeds(session)) + .setComponents(ImageEmbedHelper.buildActionRow(session)) + .queue(); + }); + } + + // ── Вспомогательное ────────────────────────────────────────────────────────── + + protected static ParseResult parseArgs(List args) { + if (args.isEmpty()) return new ParseResult(DEFAULT_FETCH, ""); + try { + int count = Integer.parseInt(args.get(0)); + count = Math.max(1, Math.min(count, MAX_FETCH)); + String tags = args.size() > 1 ? String.join(" ", args.subList(1, args.size())) : ""; + return new ParseResult(count, tags); + } catch (NumberFormatException e) { + return new ParseResult(DEFAULT_FETCH, String.join(" ", args)); + } + } + + protected boolean isNsfwChannel(CommandContext context) { + return context.getChannel() instanceof IAgeRestrictedChannel arc && arc.isNSFW(); + } + + protected record ParseResult(int limit, String tags) {} +} diff --git a/src/main/java/creator/loki/feature/image/commands/E621Command.java b/src/main/java/creator/loki/feature/image/commands/E621Command.java index 30e14b5..1d16c6f 100644 --- a/src/main/java/creator/loki/feature/image/commands/E621Command.java +++ b/src/main/java/creator/loki/feature/image/commands/E621Command.java @@ -1,129 +1,29 @@ package creator.loki.feature.image.commands; +import creator.loki.command.CommandCategories; +import creator.loki.feature.image.ImageService; +import creator.loki.feature.image.ImageSessionManager; + import java.awt.Color; -import java.util.ArrayList; 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.embed.EmbedTemplates; -import creator.loki.feature.image.ImageService; -import net.dv8tion.jda.api.entities.MessageEmbed; -import net.dv8tion.jda.api.entities.channel.ChannelType; +public class E621Command extends AbstractImageCommand { -public class E621Command implements Command { - private final ImageService imageService; - - public E621Command(ImageService imageService) { - this.imageService = imageService; + public E621Command(ImageService imageService, ImageSessionManager sessionManager) { + super(imageService, sessionManager); } + @Override public String getName() { return "e621"; } + @Override public String getDescription() { return "Поиск изображений на e621.net"; } + @Override public String getUsage() { return "e621 <кол-во до 50> <теги>"; } + @Override public String getCategory() { return CommandCategories.NSFW_IMAGES; } + + @Override protected String siteName() { return "e621.net"; } + @Override protected String siteDisplayName() { return "e621"; } + @Override protected Color siteColor() { return new Color(102, 45, 145); } + @Override - public String getName() { - return "e621"; + protected List fetchUrls(String tags, int limit) { + return imageService.fetchE621(tags, limit); } - - @Override - public String getDescription() { - return "Поиск изображений на e621.net"; - } - - @Override - public String getUsage() { - return "e621 <количество до 10> <теги>"; - } - - @Override - public String getCategory() { - return CommandCategories.NSFW_IMAGES; - } - - @Override - public void execute(CommandContext context) { - if (!isNsfwChannel(context)) { - context - .getChannel() - .sendMessageEmbeds( - EmbedTemplates.error( - "🔞 NSFW", "Эта команда доступна только в каналах с возрастным ограничением.")) - .queue(); - return; - } - - ParseResult parsed = parseArgs(context.getArgs()); - sendResults(context, parsed.limit, parsed.tags); - } - - private void sendResults(CommandContext context, int limit, String tags) { - context - .getChannel() - .sendMessageEmbeds( - EmbedTemplates.info("🔍 Поиск", "Ищу изображения на e621" + tagsDesc(tags) + "...")) - .queue( - msg -> { - List urls = imageService.fetchE621(tags, limit); - if (urls.isEmpty()) { - msg.editMessageEmbeds( - EmbedTemplates.warning( - "😔 Ничего не найдено", - "По запросу" + tagsDesc(tags) + " ничего не найдено.")) - .queue(); - return; - } - - msg.delete().queue(); - List embeds = buildEmbeds(urls); - for (int i = 0; i < embeds.size(); i += 10) { - context - .getChannel() - .sendMessageEmbeds( - embeds.subList(i, Math.min(i + 10, embeds.size()))) - .queue(); - } - }); - } - - private ParseResult parseArgs(List args) { - if (args.isEmpty()) { - return new ParseResult(1, ""); - } - try { - int count = Integer.parseInt(args.get(0)); - if (count < 1) count = 1; - if (count > 10) count = 10; - String tagStr = args.size() > 1 ? String.join(" ", args.subList(1, args.size())) : ""; - return new ParseResult(count, tagStr); - } catch (NumberFormatException e) { - return new ParseResult(1, String.join(" ", args)); - } - } - - private List buildEmbeds(List urls) { - List embeds = new ArrayList<>(); - for (String url : urls) { - embeds.add( - EmbedFactory.create() - .withTitle("🔞 e621.net") - .withImage(url) - .withColor(new Color(102, 45, 145)) - .build()); - } - return embeds; - } - - private boolean isNsfwChannel(CommandContext context) { - ChannelType type = context.getChannel().getType(); - return type == ChannelType.TEXT - || type == ChannelType.NEWS - || type == ChannelType.FORUM - || type == ChannelType.MEDIA; - } - - private static String tagsDesc(String tags) { - return tags.isBlank() ? "" : " `" + tags + "`"; - } - - private record ParseResult(int limit, String tags) {} } diff --git a/src/main/java/creator/loki/feature/image/commands/E621RandomCommand.java b/src/main/java/creator/loki/feature/image/commands/E621RandomCommand.java new file mode 100644 index 0000000..e22bcf9 --- /dev/null +++ b/src/main/java/creator/loki/feature/image/commands/E621RandomCommand.java @@ -0,0 +1,78 @@ +package creator.loki.feature.image.commands; + +import creator.loki.command.CommandCategories; +import creator.loki.command.CommandContext; +import creator.loki.embed.EmbedTemplates; +import creator.loki.feature.image.ImageEmbedHelper; +import creator.loki.feature.image.ImageService; +import creator.loki.feature.image.ImageSession; +import creator.loki.feature.image.ImageSessionManager; + +import java.awt.Color; +import java.util.List; + +public class E621RandomCommand extends AbstractImageCommand { + + public E621RandomCommand(ImageService imageService, ImageSessionManager sessionManager) { + super(imageService, sessionManager); + } + + @Override public String getName() { return "e621-random"; } + @Override public String getDescription() { return "Случайные изображения с e621.net"; } + @Override public String getUsage() { return "e621-random <кол-во до 50>"; } + @Override public String getCategory() { return CommandCategories.NSFW_IMAGES; } + + @Override protected String siteName() { return "e621.net"; } + @Override protected String siteDisplayName() { return "e621 (случайные)"; } + @Override protected Color siteColor() { return new Color(102, 45, 145); } + + @Override + protected List fetchUrls(String tags, int limit) { + return imageService.fetchE621("", limit); // теги игнорируются — всегда случайные + } + + /** Переопределяем execute: теги не нужны, лимит берём из первого аргумента. */ + @Override + public void execute(CommandContext context) { + if (!isNsfwChannel(context)) { + context.getChannel() + .sendMessageEmbeds(EmbedTemplates.error( + "🔞 NSFW", "Эта команда доступна только в каналах с возрастным ограничением.")) + .queue(); + return; + } + + int limit = DEFAULT_FETCH; + var args = context.getArgs(); + if (!args.isEmpty()) { + try { + limit = Integer.parseInt(args.get(0)); + limit = Math.max(1, Math.min(limit, MAX_FETCH)); + } catch (NumberFormatException ignored) {} + } + + final int finalLimit = limit; + context.getChannel() + .sendMessageEmbeds(EmbedTemplates.info("🔍 Случайные", "Ищу случайные изображения на e621...")) + .queue(msg -> { + List urls = imageService.fetchE621("", finalLimit); + if (urls.isEmpty()) { + msg.editMessageEmbeds(EmbedTemplates.warning( + "😔 Ничего не найдено", "Не удалось найти изображения.")) + .queue(); + return; + } + + ImageSession session = ImageSession.create( + urls, PAGE_SIZE, siteName(), siteColor(), + "", + context.getAuthor().getIdLong(), + context.getGuild().getIdLong()); + sessionManager.put(msg.getId(), session); + + msg.editMessageEmbeds(ImageEmbedHelper.buildEmbeds(session)) + .setComponents(ImageEmbedHelper.buildActionRow(session)) + .queue(); + }); + } +} diff --git a/src/main/java/creator/loki/feature/image/commands/GelbooruCommand.java b/src/main/java/creator/loki/feature/image/commands/GelbooruCommand.java index 004130c..b86c78a 100644 --- a/src/main/java/creator/loki/feature/image/commands/GelbooruCommand.java +++ b/src/main/java/creator/loki/feature/image/commands/GelbooruCommand.java @@ -1,129 +1,29 @@ package creator.loki.feature.image.commands; +import creator.loki.command.CommandCategories; +import creator.loki.feature.image.ImageService; +import creator.loki.feature.image.ImageSessionManager; + import java.awt.Color; -import java.util.ArrayList; 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.embed.EmbedTemplates; -import creator.loki.feature.image.ImageService; -import net.dv8tion.jda.api.entities.MessageEmbed; -import net.dv8tion.jda.api.entities.channel.ChannelType; +public class GelbooruCommand extends AbstractImageCommand { -public class GelbooruCommand implements Command { - private final ImageService imageService; - - public GelbooruCommand(ImageService imageService) { - this.imageService = imageService; + public GelbooruCommand(ImageService imageService, ImageSessionManager sessionManager) { + super(imageService, sessionManager); } + @Override public String getName() { return "gelbooru"; } + @Override public String getDescription() { return "Поиск изображений на Gelbooru"; } + @Override public String getUsage() { return "gelbooru <кол-во до 50> <теги>"; } + @Override public String getCategory() { return CommandCategories.NSFW_IMAGES; } + + @Override protected String siteName() { return "gelbooru.com"; } + @Override protected String siteDisplayName() { return "Gelbooru"; } + @Override protected Color siteColor() { return new Color(50, 100, 180); } + @Override - public String getName() { - return "gelbooru"; + protected List fetchUrls(String tags, int limit) { + return imageService.fetchGelbooru(tags, limit); } - - @Override - public String getDescription() { - return "Поиск изображений на Gelbooru"; - } - - @Override - public String getUsage() { - return "gelbooru <количество до 10> <теги>"; - } - - @Override - public String getCategory() { - return CommandCategories.NSFW_IMAGES; - } - - @Override - public void execute(CommandContext context) { - if (!isNsfwChannel(context)) { - context - .getChannel() - .sendMessageEmbeds( - EmbedTemplates.error( - "🔞 NSFW", "Эта команда доступна только в каналах с возрастным ограничением.")) - .queue(); - return; - } - - ParseResult parsed = parseArgs(context.getArgs()); - sendResults(context, parsed.limit, parsed.tags); - } - - private void sendResults(CommandContext context, int limit, String tags) { - context - .getChannel() - .sendMessageEmbeds( - EmbedTemplates.info("🔍 Поиск", "Ищу изображения на Gelbooru" + tagsDesc(tags) + "...")) - .queue( - msg -> { - List urls = imageService.fetchGelbooru(tags, limit); - if (urls.isEmpty()) { - msg.editMessageEmbeds( - EmbedTemplates.warning( - "😔 Ничего не найдено", - "По запросу" + tagsDesc(tags) + " ничего не найдено.")) - .queue(); - return; - } - - msg.delete().queue(); - List embeds = buildEmbeds(urls); - for (int i = 0; i < embeds.size(); i += 10) { - context - .getChannel() - .sendMessageEmbeds( - embeds.subList(i, Math.min(i + 10, embeds.size()))) - .queue(); - } - }); - } - - private ParseResult parseArgs(List args) { - if (args.isEmpty()) { - return new ParseResult(1, ""); - } - try { - int count = Integer.parseInt(args.get(0)); - if (count < 1) count = 1; - if (count > 10) count = 10; - String tagStr = args.size() > 1 ? String.join(" ", args.subList(1, args.size())) : ""; - return new ParseResult(count, tagStr); - } catch (NumberFormatException e) { - return new ParseResult(1, String.join(" ", args)); - } - } - - private List buildEmbeds(List urls) { - List embeds = new ArrayList<>(); - for (String url : urls) { - embeds.add( - EmbedFactory.create() - .withTitle("🔞 gelbooru.com") - .withImage(url) - .withColor(new Color(50, 100, 180)) - .build()); - } - return embeds; - } - - private boolean isNsfwChannel(CommandContext context) { - ChannelType type = context.getChannel().getType(); - return type == ChannelType.TEXT - || type == ChannelType.NEWS - || type == ChannelType.FORUM - || type == ChannelType.MEDIA; - } - - private static String tagsDesc(String tags) { - return tags.isBlank() ? "" : " `" + tags + "`"; - } - - private record ParseResult(int limit, String tags) {} } diff --git a/src/main/java/creator/loki/feature/image/commands/R34Command.java b/src/main/java/creator/loki/feature/image/commands/R34Command.java index 50b3a2f..acc78cb 100644 --- a/src/main/java/creator/loki/feature/image/commands/R34Command.java +++ b/src/main/java/creator/loki/feature/image/commands/R34Command.java @@ -1,129 +1,29 @@ package creator.loki.feature.image.commands; +import creator.loki.command.CommandCategories; +import creator.loki.feature.image.ImageService; +import creator.loki.feature.image.ImageSessionManager; + import java.awt.Color; -import java.util.ArrayList; 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.embed.EmbedTemplates; -import creator.loki.feature.image.ImageService; -import net.dv8tion.jda.api.entities.MessageEmbed; -import net.dv8tion.jda.api.entities.channel.ChannelType; +public class R34Command extends AbstractImageCommand { -public class R34Command implements Command { - private final ImageService imageService; - - public R34Command(ImageService imageService) { - this.imageService = imageService; + public R34Command(ImageService imageService, ImageSessionManager sessionManager) { + super(imageService, sessionManager); } + @Override public String getName() { return "r34"; } + @Override public String getDescription() { return "Поиск изображений на Rule34"; } + @Override public String getUsage() { return "r34 <кол-во до 50> <теги>"; } + @Override public String getCategory() { return CommandCategories.NSFW_IMAGES; } + + @Override protected String siteName() { return "rule34.xxx"; } + @Override protected String siteDisplayName() { return "Rule34"; } + @Override protected Color siteColor() { return new Color(200, 100, 0); } + @Override - public String getName() { - return "r34"; + protected List fetchUrls(String tags, int limit) { + return imageService.fetchR34(tags, limit); } - - @Override - public String getDescription() { - return "Поиск изображений на Rule34"; - } - - @Override - public String getUsage() { - return "r34 <количество до 10> <теги>"; - } - - @Override - public String getCategory() { - return CommandCategories.NSFW_IMAGES; - } - - @Override - public void execute(CommandContext context) { - if (!isNsfwChannel(context)) { - context - .getChannel() - .sendMessageEmbeds( - EmbedTemplates.error( - "🔞 NSFW", "Эта команда доступна только в каналах с возрастным ограничением.")) - .queue(); - return; - } - - ParseResult parsed = parseArgs(context.getArgs()); - sendResults(context, parsed.limit, parsed.tags); - } - - private void sendResults(CommandContext context, int limit, String tags) { - context - .getChannel() - .sendMessageEmbeds( - EmbedTemplates.info("🔍 Поиск", "Ищу изображения на Rule34" + tagsDesc(tags) + "...")) - .queue( - msg -> { - List urls = imageService.fetchR34(tags, limit); - if (urls.isEmpty()) { - msg.editMessageEmbeds( - EmbedTemplates.warning( - "😔 Ничего не найдено", - "По запросу" + tagsDesc(tags) + " ничего не найдено.")) - .queue(); - return; - } - - msg.delete().queue(); - List embeds = buildEmbeds(urls); - for (int i = 0; i < embeds.size(); i += 10) { - context - .getChannel() - .sendMessageEmbeds( - embeds.subList(i, Math.min(i + 10, embeds.size()))) - .queue(); - } - }); - } - - private ParseResult parseArgs(List args) { - if (args.isEmpty()) { - return new ParseResult(1, ""); - } - try { - int count = Integer.parseInt(args.get(0)); - if (count < 1) count = 1; - if (count > 10) count = 10; - String tagStr = args.size() > 1 ? String.join(" ", args.subList(1, args.size())) : ""; - return new ParseResult(count, tagStr); - } catch (NumberFormatException e) { - return new ParseResult(1, String.join(" ", args)); - } - } - - private List buildEmbeds(List urls) { - List embeds = new ArrayList<>(); - for (String url : urls) { - embeds.add( - EmbedFactory.create() - .withTitle("🔞 rule34.xxx") - .withImage(url) - .withColor(new Color(200, 100, 0)) - .build()); - } - return embeds; - } - - private boolean isNsfwChannel(CommandContext context) { - ChannelType type = context.getChannel().getType(); - return type == ChannelType.TEXT - || type == ChannelType.NEWS - || type == ChannelType.FORUM - || type == ChannelType.MEDIA; - } - - private static String tagsDesc(String tags) { - return tags.isBlank() ? "" : " `" + tags + "`"; - } - - private record ParseResult(int limit, String tags) {} } diff --git a/src/main/java/creator/loki/feature/image/commands/YandereCommand.java b/src/main/java/creator/loki/feature/image/commands/YandereCommand.java new file mode 100644 index 0000000..77bfdba --- /dev/null +++ b/src/main/java/creator/loki/feature/image/commands/YandereCommand.java @@ -0,0 +1,29 @@ +package creator.loki.feature.image.commands; + +import creator.loki.command.CommandCategories; +import creator.loki.feature.image.ImageService; +import creator.loki.feature.image.ImageSessionManager; + +import java.awt.Color; +import java.util.List; + +public class YandereCommand extends AbstractImageCommand { + + public YandereCommand(ImageService imageService, ImageSessionManager sessionManager) { + super(imageService, sessionManager); + } + + @Override public String getName() { return "yandere"; } + @Override public String getDescription() { return "Поиск изображений на yande.re"; } + @Override public String getUsage() { return "yandere <кол-во до 50> <теги>"; } + @Override public String getCategory() { return CommandCategories.NSFW_IMAGES; } + + @Override protected String siteName() { return "yande.re"; } + @Override protected String siteDisplayName() { return "yande.re"; } + @Override protected Color siteColor() { return new Color(220, 50, 120); } + + @Override + protected List fetchUrls(String tags, int limit) { + return imageService.fetchYandere(tags, limit); + } +} diff --git a/src/main/java/creator/loki/feature/roast/AgrCommand.java b/src/main/java/creator/loki/feature/roast/AgrCommand.java index ad2c948..b36de69 100644 --- a/src/main/java/creator/loki/feature/roast/AgrCommand.java +++ b/src/main/java/creator/loki/feature/roast/AgrCommand.java @@ -6,8 +6,6 @@ import creator.loki.config.BotConfig; import creator.loki.embed.EmbedTemplates; public class AgrCommand implements Command { - private static final String ANDRERT_USER_ID = "1405129454165954630"; - private final BotConfig config; private final RoastService roastService; private final AndrertListener autoResponder; @@ -27,7 +25,7 @@ public class AgrCommand implements Command { @Override public String getDescription() { - return "Наслать AI на пользователя (по умолчанию Andrert_). Только для админов"; + return "Наслать AI на пользователя. Только для админов"; } @Override @@ -55,8 +53,8 @@ public class AgrCommand implements Command { } if (context.getArgs().isEmpty()) { - autoResponder.trackUser(ANDRERT_USER_ID); - doScan(context, ANDRERT_USER_ID, "Andrert_"); + context.getChannel().sendMessage("Укажи @упоминание или ID пользователя").queue(); + return; } else { String arg = context.getArgs().get(0); String targetId = arg.replaceAll("<@!?(\\d+)>", "$1"); diff --git a/src/main/java/creator/loki/feature/roast/AgrSlashCommands.java b/src/main/java/creator/loki/feature/roast/AgrSlashCommands.java index f751606..deb5c99 100644 --- a/src/main/java/creator/loki/feature/roast/AgrSlashCommands.java +++ b/src/main/java/creator/loki/feature/roast/AgrSlashCommands.java @@ -10,8 +10,6 @@ import org.jetbrains.annotations.NotNull; @Singleton public class AgrSlashCommands extends ListenerAdapter { - private static final String ANDRERT_USER_ID = "1405129454165954630"; - private final BotConfig config; private final RoastService roastService; private final MessageScanner messageScanner; @@ -45,14 +43,13 @@ public class AgrSlashCommands extends ListenerAdapter { String targetName; var userOption = event.getOption("user"); - if (userOption != null) { - var user = userOption.getAsUser(); - targetId = user.getId(); - targetName = user.getName(); - } else { - targetId = ANDRERT_USER_ID; - targetName = "Andrert_"; + if (userOption == null) { + event.getHook().editOriginal("Укажи пользователя").queue(); + return; } + var user = userOption.getAsUser(); + targetId = user.getId(); + targetName = user.getName(); String finalTargetName = targetName; String finalTargetId = targetId; diff --git a/src/main/java/creator/loki/feature/roast/AiSessionManager.java b/src/main/java/creator/loki/feature/roast/AiSessionManager.java new file mode 100644 index 0000000..6b65467 --- /dev/null +++ b/src/main/java/creator/loki/feature/roast/AiSessionManager.java @@ -0,0 +1,93 @@ +package creator.loki.feature.roast; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import com.google.inject.Singleton; + +@Singleton +public class AiSessionManager { + private static final long SESSION_TTL_MS = TimeUnit.MINUTES.toMillis(10); + private static final int MAX_SESSION_MSGS = 100; + private static final long CLEANUP_INTERVAL_MS = TimeUnit.MINUTES.toMillis(2); + + private final Map sessions = new ConcurrentHashMap<>(); + private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + + public AiSessionManager() { + scheduler.scheduleAtFixedRate( + this::cleanup, CLEANUP_INTERVAL_MS, CLEANUP_INTERVAL_MS, TimeUnit.MILLISECONDS); + } + + public void addMessage( + String channelId, String authorName, String authorId, String content, long timestamp) { + AiSession session = + sessions.computeIfAbsent(channelId, k -> new AiSession()); + session.addMessage(authorName, authorId, content, timestamp); + } + + public List getContext(String channelId, int maxMsgs) { + AiSession session = sessions.get(channelId); + if (session == null) return List.of(); + return session.getContext(maxMsgs); + } + + public void clear(String channelId) { + sessions.remove(channelId); + } + + public void clearAll() { + sessions.clear(); + } + + private void cleanup() { + long now = System.currentTimeMillis(); + Iterator> it = sessions.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry entry = it.next(); + if (now - entry.getValue().lastActivity > SESSION_TTL_MS) { + it.remove(); + } + } + } + + private static class AiSession { + private final List messages = new ArrayList<>(); + private volatile long lastActivity = System.currentTimeMillis(); + + void addMessage(String authorName, String authorId, String content, long timestamp) { + lastActivity = System.currentTimeMillis(); + messages.add(new SessionMessage(authorName, authorId, content, timestamp)); + if (messages.size() > MAX_SESSION_MSGS) { + messages.remove(0); + } + } + + List getContext(int maxMsgs) { + List result = new ArrayList<>(); + int start = Math.max(0, messages.size() - maxMsgs); + for (int i = start; i < messages.size(); i++) { + SessionMessage m = messages.get(i); + String label = (i == messages.size() - 1) ? "[now]" : "[history]"; + String formatted = + label + + " " + + m.authorName + + "(" + + m.authorId + + "): " + + m.content; + result.add(new CachedMessage(formatted, "", m.timestamp)); + } + return result; + } + } + + private record SessionMessage(String authorName, String authorId, String content, long timestamp) {} +} diff --git a/src/main/java/creator/loki/feature/roast/AndrertListener.java b/src/main/java/creator/loki/feature/roast/AndrertListener.java index cfcc85d..a567979 100644 --- a/src/main/java/creator/loki/feature/roast/AndrertListener.java +++ b/src/main/java/creator/loki/feature/roast/AndrertListener.java @@ -19,12 +19,12 @@ import org.jetbrains.annotations.NotNull; @Singleton public class AndrertListener extends ListenerAdapter { - private static final String ANDRERT_USER_ID = "1405129454165954630"; private static final int CHANNEL_CONTEXT = 15; private static final long TRACK_TTL = TimeUnit.HOURS.toMillis(1); private static final long AUTO_PING_DELAY = TimeUnit.MINUTES.toMillis(5); private final RoastService roastService; + private final AiSessionManager sessionManager; private final String allowedChannelId; private final ConcurrentHashMap trackedUsers = new ConcurrentHashMap<>(); private final ConcurrentHashMap lastAiReplyTime = new ConcurrentHashMap<>(); @@ -32,10 +32,10 @@ public class AndrertListener extends ListenerAdapter { private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); @Inject - public AndrertListener(RoastService roastService, BotConfig config) { + public AndrertListener(RoastService roastService, AiSessionManager sessionManager, BotConfig config) { this.roastService = roastService; + this.sessionManager = sessionManager; this.allowedChannelId = config.getAiChannelId(); - trackedUsers.put(ANDRERT_USER_ID, System.currentTimeMillis() + TRACK_TTL); } public void trackUser(String userId) { @@ -92,47 +92,23 @@ public class AndrertListener extends ListenerAdapter { } private void respondAi(MessageReceivedEvent event, String userId) { - event - .getChannel() - .getHistory() - .retrievePast(CHANNEL_CONTEXT) - .queue( - messages -> { - String targetName = event.getAuthor().getEffectiveName(); - List context = new ArrayList<>(); - for (int idx = 0; idx < messages.size(); idx++) { - Message msg = messages.get(idx); - boolean isSelf = - msg.getAuthor().getId().equals(event.getJDA().getSelfUser().getId()); - String author = isSelf ? "ты" : msg.getAuthor().getEffectiveName(); - String authorId = msg.getAuthor().getId(); + Message msg = event.getMessage(); + String fullContent = msg.getContentRaw() + replyInfo(msg) + attachmentInfo(msg); + sessionManager.addMessage( + event.getChannel().getId(), + event.getAuthor().getEffectiveName(), + event.getAuthor().getId(), + fullContent, + System.currentTimeMillis()); - String label = (idx == 0) ? "[now]" : "[history]"; - StringBuilder line = new StringBuilder(); - line.append(label) - .append(" ") - .append(author) - .append("(") - .append(authorId) - .append("): ") - .append(msg.getContentRaw()); - line.append(replyInfo(msg)); - line.append(attachmentInfo(msg)); + String targetName = event.getAuthor().getEffectiveName(); + List context = sessionManager.getContext(event.getChannel().getId(), CHANNEL_CONTEXT); - context.add( - new CachedMessage( - line.toString(), - event.getChannel().getName(), - msg.getTimeCreated().toEpochSecond() * 1000)); - } - java.util.Collections.reverse(context); + String response = roastService.generateResponse(context, targetName, userId); + if (response == null || response.isBlank()) return; - String response = roastService.generateResponse(context, targetName, userId); - if (response == null || response.isBlank()) return; - - sendInParts(event, response); - scheduleAutoPing(event, userId); - }); + sendInParts(event, response); + scheduleAutoPing(event, userId); } private void scheduleAutoPing(MessageReceivedEvent event, String userId) { @@ -173,7 +149,7 @@ public class AndrertListener extends ListenerAdapter { "тишина в эфире " + targetMention + ". всё, признал поражение?", targetMention + " слился. ожидаемо", }; - String msg = + String msgText = lines[(int) (System.currentTimeMillis() % lines.length)]; channel @@ -181,37 +157,29 @@ public class AndrertListener extends ListenerAdapter { .retrievePast(CHANNEL_CONTEXT) .queue( hist -> { - String targetName = "пользователь"; - List ctx = new ArrayList<>(); - for (int i = 0; i < hist.size(); i++) { + sessionManager.clear(channelId); + for (int i = hist.size() - 1; i >= 0; i--) { Message m = hist.get(i); - String a = - m.getAuthor() - .getId() - .equals( - event.getJDA().getSelfUser().getId()) - ? "ты" - : m.getAuthor().getEffectiveName(); - String label = (i == 0) ? "[now]" : "[history]"; - ctx.add( - new CachedMessage( - label - + " " - + a - + "(" - + m.getAuthor().getId() - + "): " - + m.getContentRaw(), - channel.getName(), - m.getTimeCreated().toEpochSecond() * 1000)); + if (m.getAuthor().isBot()) continue; + String a = m.getAuthor().getEffectiveName(); + String c = + m.getContentRaw() + + replyInfo(m) + + attachmentInfo(m); + sessionManager.addMessage( + channelId, a, m.getAuthor().getId(), c, + m.getTimeCreated().toEpochSecond() * 1000); } - java.util.Collections.reverse(ctx); + + String targetName = "пользователь"; + List ctx = + sessionManager.getContext(channelId, CHANNEL_CONTEXT); String resp = roastService.generateResponse(ctx, targetName, userId); if (resp != null && !resp.isBlank()) { - channel.sendMessage(msg + "\n" + resp).queue(); + channel.sendMessage(msgText + "\n" + resp).queue(); } else { - channel.sendMessage(msg).queue(); + channel.sendMessage(msgText).queue(); } }); }); diff --git a/src/main/java/creator/loki/feature/roast/RoastModule.java b/src/main/java/creator/loki/feature/roast/RoastModule.java index d465273..87f2003 100644 --- a/src/main/java/creator/loki/feature/roast/RoastModule.java +++ b/src/main/java/creator/loki/feature/roast/RoastModule.java @@ -11,6 +11,7 @@ public class RoastModule extends AbstractModule { bind(ContentFetcher.class).in(Singleton.class); bind(WikipediaService.class).in(Singleton.class); bind(RoastService.class).in(Singleton.class); + bind(AiSessionManager.class).in(Singleton.class); bind(AndrertListener.class).in(Singleton.class); bind(WebSearchService.class).in(Singleton.class); }