feat: image navigation, yandere/e621random commands, ai session manager, deploy script
This commit is contained in:
parent
deb875fd3d
commit
49adad7f58
23 changed files with 880 additions and 470 deletions
32
.env.example
32
.env.example
|
|
@ -1,27 +1,37 @@
|
||||||
# Main bot token (all features)
|
# === Discord ===
|
||||||
DISCORD_BOT_TOKEN=your_token_here
|
DISCORD_BOT_TOKEN=your_token_here
|
||||||
# Music bot token (only music via Lavalink) - OPTIONAL
|
|
||||||
DISCORD_BOT_TOKEN_MUSIC=your_music_token
|
DISCORD_BOT_TOKEN_MUSIC=your_music_token
|
||||||
# Radio bot token (only radio) - OPTIONAL
|
|
||||||
DISCORD_BOT_TOKEN_RADIO=your_radio_token
|
DISCORD_BOT_TOKEN_RADIO=your_radio_token
|
||||||
|
|
||||||
BOT_PREFIX=!
|
BOT_PREFIX=!
|
||||||
BOT_ACTIVITY=Используй !help
|
BOT_ACTIVITY=Используй !help
|
||||||
HEALTH_HTTP_PORT=8712
|
|
||||||
SUPER_ADMIN_ID=your_discord_user_id
|
SUPER_ADMIN_ID=your_discord_user_id
|
||||||
|
|
||||||
|
# === HTTP Health ===
|
||||||
|
HEALTH_HTTP_PORT=8712
|
||||||
|
|
||||||
|
# === Database ===
|
||||||
DB_PATH=data/bot.db
|
DB_PATH=data/bot.db
|
||||||
|
|
||||||
# DeepSeek AI API (для roast)
|
# === AI / DeepSeek ===
|
||||||
DEEPSEEK_API_KEY=your_deepseek_key
|
DEEPSEEK_API_KEY=your_deepseek_api_key
|
||||||
|
AI_CHANNEL_ID=your_ai_channel_id
|
||||||
# Debate channel ID для мульти-AI диспутов
|
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
|
AI_DEBATE_CHANNEL_ID=your_debate_channel_id
|
||||||
|
|
||||||
|
# === GIPHY (для GIF прокси) ===
|
||||||
|
GIPHY_API_KEY=
|
||||||
|
|
||||||
# Lavalink
|
# === Lavalink ===
|
||||||
LAVALINK_HOST=127.0.0.1
|
LAVALINK_HOST=127.0.0.1
|
||||||
LAVALINK_PORT=2333
|
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_ID=your_spotify_client_id
|
||||||
SPOTIFY_CLIENT_SECRET=your_spotify_client_secret
|
SPOTIFY_CLIENT_SECRET=your_spotify_client_secret
|
||||||
|
|
|
||||||
40
deploy.sh
Executable file
40
deploy.sh
Executable file
|
|
@ -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 — перезапустить"
|
||||||
|
|
@ -6,6 +6,7 @@ import creator.loki.bootstrap.ReadyListener;
|
||||||
import creator.loki.feature.counter.service.MemberCounterListener;
|
import creator.loki.feature.counter.service.MemberCounterListener;
|
||||||
import creator.loki.feature.economy.FishMessageListener;
|
import creator.loki.feature.economy.FishMessageListener;
|
||||||
import creator.loki.feature.help.HelpMenuHandler;
|
import creator.loki.feature.help.HelpMenuHandler;
|
||||||
|
import creator.loki.feature.image.ImageNavigationHandler;
|
||||||
import creator.loki.feature.leaderboard.LeaderboardButtonListener;
|
import creator.loki.feature.leaderboard.LeaderboardButtonListener;
|
||||||
import creator.loki.feature.level.listener.MessageListener;
|
import creator.loki.feature.level.listener.MessageListener;
|
||||||
import creator.loki.feature.level.listener.ResetLevelsButtonHandler;
|
import creator.loki.feature.level.listener.ResetLevelsButtonHandler;
|
||||||
|
|
@ -45,7 +46,8 @@ public class ListenerAggregator {
|
||||||
FishMessageListener fishMessageListener,
|
FishMessageListener fishMessageListener,
|
||||||
AndrertListener andrertListener,
|
AndrertListener andrertListener,
|
||||||
ClearSlashCommands clearSlashCommands,
|
ClearSlashCommands clearSlashCommands,
|
||||||
HelperListener helperListener) {
|
HelperListener helperListener,
|
||||||
|
ImageNavigationHandler imageNavigationHandler) {
|
||||||
this.listeners =
|
this.listeners =
|
||||||
new Object[] {
|
new Object[] {
|
||||||
readyListener,
|
readyListener,
|
||||||
|
|
@ -66,7 +68,8 @@ public class ListenerAggregator {
|
||||||
fishMessageListener,
|
fishMessageListener,
|
||||||
andrertListener,
|
andrertListener,
|
||||||
clearSlashCommands,
|
clearSlashCommands,
|
||||||
helperListener
|
helperListener,
|
||||||
|
imageNavigationHandler
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import creator.loki.embed.HelpCategories;
|
||||||
import net.dv8tion.jda.api.Permission;
|
import net.dv8tion.jda.api.Permission;
|
||||||
import net.dv8tion.jda.api.components.actionrow.ActionRow;
|
import net.dv8tion.jda.api.components.actionrow.ActionRow;
|
||||||
import net.dv8tion.jda.api.components.selections.StringSelectMenu;
|
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.events.interaction.component.StringSelectInteractionEvent;
|
||||||
import net.dv8tion.jda.api.hooks.ListenerAdapter;
|
import net.dv8tion.jda.api.hooks.ListenerAdapter;
|
||||||
|
|
||||||
|
|
@ -77,7 +77,7 @@ public class HelpMenuHandler extends ListenerAdapter {
|
||||||
boolean isAdmin =
|
boolean isAdmin =
|
||||||
event.getMember() != null && event.getMember().hasPermission(Permission.ADMINISTRATOR);
|
event.getMember() != null && event.getMember().hasPermission(Permission.ADMINISTRATOR);
|
||||||
boolean isSuper = config.getSuperAdminId().equals(event.getUser().getId());
|
boolean isSuper = config.getSuperAdminId().equals(event.getUser().getId());
|
||||||
boolean isNsfwChannel = isNsfw(event.getChannel().getType());
|
boolean isNsfwChannel = isNsfw(event);
|
||||||
|
|
||||||
commandsByCategory.keySet().stream()
|
commandsByCategory.keySet().stream()
|
||||||
.sorted()
|
.sorted()
|
||||||
|
|
@ -153,10 +153,7 @@ public class HelpMenuHandler extends ListenerAdapter {
|
||||||
return embed;
|
return embed;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isNsfw(ChannelType type) {
|
private static boolean isNsfw(StringSelectInteractionEvent event) {
|
||||||
return type == ChannelType.TEXT
|
return event.getChannel() instanceof IAgeRestrictedChannel arc && arc.isNSFW();
|
||||||
|| type == ChannelType.NEWS
|
|
||||||
|| type == ChannelType.FORUM
|
|
||||||
|| type == ChannelType.MEDIA;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import net.dv8tion.jda.api.Permission;
|
||||||
import net.dv8tion.jda.api.components.actionrow.ActionRow;
|
import net.dv8tion.jda.api.components.actionrow.ActionRow;
|
||||||
import net.dv8tion.jda.api.components.selections.StringSelectMenu;
|
import net.dv8tion.jda.api.components.selections.StringSelectMenu;
|
||||||
import net.dv8tion.jda.api.entities.User;
|
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 {
|
public class HelpCommand implements Command {
|
||||||
private final CommandRegistry registry;
|
private final CommandRegistry registry;
|
||||||
|
|
@ -49,7 +49,7 @@ public class HelpCommand implements Command {
|
||||||
boolean isAdmin =
|
boolean isAdmin =
|
||||||
context.getAuthor() != null && context.getAuthor().hasPermission(Permission.ADMINISTRATOR);
|
context.getAuthor() != null && context.getAuthor().hasPermission(Permission.ADMINISTRATOR);
|
||||||
boolean isSuper = config.getSuperAdminId().equals(viewer.getId());
|
boolean isSuper = config.getSuperAdminId().equals(viewer.getId());
|
||||||
boolean isNsfwChannel = isNsfw(context.getChannel().getType());
|
boolean isNsfwChannel = isNsfw(context);
|
||||||
|
|
||||||
// Главная страница
|
// Главная страница
|
||||||
EmbedFactory embed =
|
EmbedFactory embed =
|
||||||
|
|
@ -85,11 +85,8 @@ public class HelpCommand implements Command {
|
||||||
.queue();
|
.queue();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean isNsfw(ChannelType type) {
|
private static boolean isNsfw(CommandContext context) {
|
||||||
return type == ChannelType.TEXT
|
return context.getChannel() instanceof IAgeRestrictedChannel arc && arc.isNSFW();
|
||||||
|| type == ChannelType.NEWS
|
|
||||||
|| type == ChannelType.FORUM
|
|
||||||
|| type == ChannelType.MEDIA;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private EmbedFactory createMainPage(
|
private EmbedFactory createMainPage(
|
||||||
|
|
|
||||||
|
|
@ -4,21 +4,27 @@ import com.google.inject.Inject;
|
||||||
import creator.loki.core.registry.CommandRegistrar;
|
import creator.loki.core.registry.CommandRegistrar;
|
||||||
import creator.loki.core.registry.CommandRegistry;
|
import creator.loki.core.registry.CommandRegistry;
|
||||||
import creator.loki.feature.image.commands.E621Command;
|
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.GelbooruCommand;
|
||||||
import creator.loki.feature.image.commands.R34Command;
|
import creator.loki.feature.image.commands.R34Command;
|
||||||
|
import creator.loki.feature.image.commands.YandereCommand;
|
||||||
|
|
||||||
public class ImageCommandRegistrar implements CommandRegistrar {
|
public class ImageCommandRegistrar implements CommandRegistrar {
|
||||||
private final ImageService imageService;
|
private final ImageService imageService;
|
||||||
|
private final ImageSessionManager sessionManager;
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
public ImageCommandRegistrar(ImageService imageService) {
|
public ImageCommandRegistrar(ImageService imageService, ImageSessionManager sessionManager) {
|
||||||
this.imageService = imageService;
|
this.imageService = imageService;
|
||||||
|
this.sessionManager = sessionManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void register(CommandRegistry registry) {
|
public void register(CommandRegistry registry) {
|
||||||
registry.register(new E621Command(imageService));
|
registry.register(new E621Command(imageService, sessionManager));
|
||||||
registry.register(new R34Command(imageService));
|
registry.register(new E621RandomCommand(imageService, sessionManager));
|
||||||
registry.register(new GelbooruCommand(imageService));
|
registry.register(new R34Command(imageService, sessionManager));
|
||||||
|
registry.register(new GelbooruCommand(imageService, sessionManager));
|
||||||
|
registry.register(new YandereCommand(imageService, sessionManager));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<MessageEmbed> embeds = new ArrayList<>();
|
||||||
|
List<String> 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 + "`";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,10 @@ import com.google.inject.Singleton;
|
||||||
public class ImageModule extends AbstractModule {
|
public class ImageModule extends AbstractModule {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void configure() {}
|
protected void configure() {
|
||||||
|
bind(ImageSessionManager.class).in(Singleton.class);
|
||||||
|
bind(ImageNavigationHandler.class).in(Singleton.class);
|
||||||
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
|
|
|
||||||
|
|
@ -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<String> 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<String> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -36,7 +36,12 @@ public class ImageService {
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<String> fetchE621(String tags, int limit) {
|
public List<String> fetchE621(String tags, int limit) {
|
||||||
String url = E621_API + "?tags=" + encodeTags(tags) + "&limit=" + Math.min(limit, 10);
|
return fetchE621(tags, limit, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> 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 =
|
Request request =
|
||||||
new Request.Builder()
|
new Request.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
|
|
@ -76,17 +81,45 @@ public class ImageService {
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<String> fetchR34(String tags, int limit) {
|
public List<String> fetchR34(String tags, int limit) {
|
||||||
|
return fetchR34(tags, limit, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> fetchR34(String tags, int limit, int page) {
|
||||||
|
int capped = Math.min(limit, 100);
|
||||||
String url =
|
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);
|
return fetchBooruJson(url, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<String> fetchGelbooru(String tags, int limit) {
|
public List<String> fetchGelbooru(String tags, int limit) {
|
||||||
|
return fetchGelbooru(tags, limit, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> fetchGelbooru(String tags, int limit, int page) {
|
||||||
|
int capped = Math.min(limit, 100);
|
||||||
String url =
|
String url =
|
||||||
"https://gelbooru.com/index.php?page=dapi&s=post&q=index&json=1&tags="
|
"https://gelbooru.com/index.php?page=dapi&s=post&q=index&json=1&tags="
|
||||||
+ encodeTags(tags)
|
+ encodeTags(tags)
|
||||||
+ "&limit="
|
+ "&limit="
|
||||||
+ Math.min(limit, 10);
|
+ capped
|
||||||
|
+ "&pid="
|
||||||
|
+ (page - 1);
|
||||||
|
return fetchBooruJson(url, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> fetchYandere(String tags, int limit) {
|
||||||
|
return fetchYandere(tags, limit, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<String> 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);
|
return fetchBooruJson(url, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
90
src/main/java/creator/loki/feature/image/ImageSession.java
Normal file
90
src/main/java/creator/loki/feature/image/ImageSession.java
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
package creator.loki.feature.image;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Иммутабельная сессия навигации по изображениям.
|
||||||
|
*
|
||||||
|
* <p>exhausted = true означает, что API вернул пустой ответ на последнем запросе,
|
||||||
|
* т.е. новых страниц больше нет — тогда кнопка «Вперёд» блокируется.
|
||||||
|
*/
|
||||||
|
public record ImageSession(
|
||||||
|
List<String> 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<String> 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<String> 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<String> newUrls) {
|
||||||
|
if (newUrls.isEmpty()) {
|
||||||
|
// API больше ничего не отдал — остаёмся на месте, ставим флаг
|
||||||
|
return new ImageSession(
|
||||||
|
urls, page, pageSize, site, color, tags, userId, guildId,
|
||||||
|
System.currentTimeMillis(), apiPage, true);
|
||||||
|
}
|
||||||
|
List<String> combined = new ArrayList<>(urls);
|
||||||
|
combined.addAll(newUrls);
|
||||||
|
return new ImageSession(
|
||||||
|
combined, page + 1, pageSize, site, color, tags, userId, guildId,
|
||||||
|
System.currentTimeMillis(), apiPage + 1, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String, ImageSession> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<String> 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<String> 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<String> 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) {}
|
||||||
|
}
|
||||||
|
|
@ -1,129 +1,29 @@
|
||||||
package creator.loki.feature.image.commands;
|
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.awt.Color;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import creator.loki.command.Command;
|
public class E621Command extends AbstractImageCommand {
|
||||||
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 implements Command {
|
public E621Command(ImageService imageService, ImageSessionManager sessionManager) {
|
||||||
private final ImageService imageService;
|
super(imageService, sessionManager);
|
||||||
|
|
||||||
public E621Command(ImageService imageService) {
|
|
||||||
this.imageService = imageService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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
|
@Override
|
||||||
public String getName() {
|
protected List<String> fetchUrls(String tags, int limit) {
|
||||||
return "e621";
|
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<String> urls = imageService.fetchE621(tags, limit);
|
|
||||||
if (urls.isEmpty()) {
|
|
||||||
msg.editMessageEmbeds(
|
|
||||||
EmbedTemplates.warning(
|
|
||||||
"😔 Ничего не найдено",
|
|
||||||
"По запросу" + tagsDesc(tags) + " ничего не найдено."))
|
|
||||||
.queue();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
msg.delete().queue();
|
|
||||||
List<MessageEmbed> 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<String> 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<MessageEmbed> buildEmbeds(List<String> urls) {
|
|
||||||
List<MessageEmbed> 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) {}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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<String> 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<String> 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,129 +1,29 @@
|
||||||
package creator.loki.feature.image.commands;
|
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.awt.Color;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import creator.loki.command.Command;
|
public class GelbooruCommand extends AbstractImageCommand {
|
||||||
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 implements Command {
|
public GelbooruCommand(ImageService imageService, ImageSessionManager sessionManager) {
|
||||||
private final ImageService imageService;
|
super(imageService, sessionManager);
|
||||||
|
|
||||||
public GelbooruCommand(ImageService imageService) {
|
|
||||||
this.imageService = imageService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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
|
@Override
|
||||||
public String getName() {
|
protected List<String> fetchUrls(String tags, int limit) {
|
||||||
return "gelbooru";
|
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<String> urls = imageService.fetchGelbooru(tags, limit);
|
|
||||||
if (urls.isEmpty()) {
|
|
||||||
msg.editMessageEmbeds(
|
|
||||||
EmbedTemplates.warning(
|
|
||||||
"😔 Ничего не найдено",
|
|
||||||
"По запросу" + tagsDesc(tags) + " ничего не найдено."))
|
|
||||||
.queue();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
msg.delete().queue();
|
|
||||||
List<MessageEmbed> 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<String> 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<MessageEmbed> buildEmbeds(List<String> urls) {
|
|
||||||
List<MessageEmbed> 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) {}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,129 +1,29 @@
|
||||||
package creator.loki.feature.image.commands;
|
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.awt.Color;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import creator.loki.command.Command;
|
public class R34Command extends AbstractImageCommand {
|
||||||
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 implements Command {
|
public R34Command(ImageService imageService, ImageSessionManager sessionManager) {
|
||||||
private final ImageService imageService;
|
super(imageService, sessionManager);
|
||||||
|
|
||||||
public R34Command(ImageService imageService) {
|
|
||||||
this.imageService = imageService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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
|
@Override
|
||||||
public String getName() {
|
protected List<String> fetchUrls(String tags, int limit) {
|
||||||
return "r34";
|
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<String> urls = imageService.fetchR34(tags, limit);
|
|
||||||
if (urls.isEmpty()) {
|
|
||||||
msg.editMessageEmbeds(
|
|
||||||
EmbedTemplates.warning(
|
|
||||||
"😔 Ничего не найдено",
|
|
||||||
"По запросу" + tagsDesc(tags) + " ничего не найдено."))
|
|
||||||
.queue();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
msg.delete().queue();
|
|
||||||
List<MessageEmbed> 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<String> 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<MessageEmbed> buildEmbeds(List<String> urls) {
|
|
||||||
List<MessageEmbed> 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) {}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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<String> fetchUrls(String tags, int limit) {
|
||||||
|
return imageService.fetchYandere(tags, limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,8 +6,6 @@ import creator.loki.config.BotConfig;
|
||||||
import creator.loki.embed.EmbedTemplates;
|
import creator.loki.embed.EmbedTemplates;
|
||||||
|
|
||||||
public class AgrCommand implements Command {
|
public class AgrCommand implements Command {
|
||||||
private static final String ANDRERT_USER_ID = "1405129454165954630";
|
|
||||||
|
|
||||||
private final BotConfig config;
|
private final BotConfig config;
|
||||||
private final RoastService roastService;
|
private final RoastService roastService;
|
||||||
private final AndrertListener autoResponder;
|
private final AndrertListener autoResponder;
|
||||||
|
|
@ -27,7 +25,7 @@ public class AgrCommand implements Command {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getDescription() {
|
public String getDescription() {
|
||||||
return "Наслать AI на пользователя (по умолчанию Andrert_). Только для админов";
|
return "Наслать AI на пользователя. Только для админов";
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -55,8 +53,8 @@ public class AgrCommand implements Command {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.getArgs().isEmpty()) {
|
if (context.getArgs().isEmpty()) {
|
||||||
autoResponder.trackUser(ANDRERT_USER_ID);
|
context.getChannel().sendMessage("Укажи @упоминание или ID пользователя").queue();
|
||||||
doScan(context, ANDRERT_USER_ID, "Andrert_");
|
return;
|
||||||
} else {
|
} else {
|
||||||
String arg = context.getArgs().get(0);
|
String arg = context.getArgs().get(0);
|
||||||
String targetId = arg.replaceAll("<@!?(\\d+)>", "$1");
|
String targetId = arg.replaceAll("<@!?(\\d+)>", "$1");
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,6 @@ import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
public class AgrSlashCommands extends ListenerAdapter {
|
public class AgrSlashCommands extends ListenerAdapter {
|
||||||
private static final String ANDRERT_USER_ID = "1405129454165954630";
|
|
||||||
|
|
||||||
private final BotConfig config;
|
private final BotConfig config;
|
||||||
private final RoastService roastService;
|
private final RoastService roastService;
|
||||||
private final MessageScanner messageScanner;
|
private final MessageScanner messageScanner;
|
||||||
|
|
@ -45,14 +43,13 @@ public class AgrSlashCommands extends ListenerAdapter {
|
||||||
String targetName;
|
String targetName;
|
||||||
|
|
||||||
var userOption = event.getOption("user");
|
var userOption = event.getOption("user");
|
||||||
if (userOption != null) {
|
if (userOption == null) {
|
||||||
|
event.getHook().editOriginal("Укажи пользователя").queue();
|
||||||
|
return;
|
||||||
|
}
|
||||||
var user = userOption.getAsUser();
|
var user = userOption.getAsUser();
|
||||||
targetId = user.getId();
|
targetId = user.getId();
|
||||||
targetName = user.getName();
|
targetName = user.getName();
|
||||||
} else {
|
|
||||||
targetId = ANDRERT_USER_ID;
|
|
||||||
targetName = "Andrert_";
|
|
||||||
}
|
|
||||||
|
|
||||||
String finalTargetName = targetName;
|
String finalTargetName = targetName;
|
||||||
String finalTargetId = targetId;
|
String finalTargetId = targetId;
|
||||||
|
|
|
||||||
|
|
@ -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<String, AiSession> 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<CachedMessage> 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<Map.Entry<String, AiSession>> it = sessions.entrySet().iterator();
|
||||||
|
while (it.hasNext()) {
|
||||||
|
Map.Entry<String, AiSession> entry = it.next();
|
||||||
|
if (now - entry.getValue().lastActivity > SESSION_TTL_MS) {
|
||||||
|
it.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class AiSession {
|
||||||
|
private final List<SessionMessage> 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<CachedMessage> getContext(int maxMsgs) {
|
||||||
|
List<CachedMessage> 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) {}
|
||||||
|
}
|
||||||
|
|
@ -19,12 +19,12 @@ import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
public class AndrertListener extends ListenerAdapter {
|
public class AndrertListener extends ListenerAdapter {
|
||||||
private static final String ANDRERT_USER_ID = "1405129454165954630";
|
|
||||||
private static final int CHANNEL_CONTEXT = 15;
|
private static final int CHANNEL_CONTEXT = 15;
|
||||||
private static final long TRACK_TTL = TimeUnit.HOURS.toMillis(1);
|
private static final long TRACK_TTL = TimeUnit.HOURS.toMillis(1);
|
||||||
private static final long AUTO_PING_DELAY = TimeUnit.MINUTES.toMillis(5);
|
private static final long AUTO_PING_DELAY = TimeUnit.MINUTES.toMillis(5);
|
||||||
|
|
||||||
private final RoastService roastService;
|
private final RoastService roastService;
|
||||||
|
private final AiSessionManager sessionManager;
|
||||||
private final String allowedChannelId;
|
private final String allowedChannelId;
|
||||||
private final ConcurrentHashMap<String, Long> trackedUsers = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<String, Long> trackedUsers = new ConcurrentHashMap<>();
|
||||||
private final ConcurrentHashMap<String, Long> lastAiReplyTime = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<String, Long> lastAiReplyTime = new ConcurrentHashMap<>();
|
||||||
|
|
@ -32,10 +32,10 @@ public class AndrertListener extends ListenerAdapter {
|
||||||
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
|
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
|
||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
public AndrertListener(RoastService roastService, BotConfig config) {
|
public AndrertListener(RoastService roastService, AiSessionManager sessionManager, BotConfig config) {
|
||||||
this.roastService = roastService;
|
this.roastService = roastService;
|
||||||
|
this.sessionManager = sessionManager;
|
||||||
this.allowedChannelId = config.getAiChannelId();
|
this.allowedChannelId = config.getAiChannelId();
|
||||||
trackedUsers.put(ANDRERT_USER_ID, System.currentTimeMillis() + TRACK_TTL);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void trackUser(String userId) {
|
public void trackUser(String userId) {
|
||||||
|
|
@ -92,47 +92,23 @@ public class AndrertListener extends ListenerAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
private void respondAi(MessageReceivedEvent event, String userId) {
|
private void respondAi(MessageReceivedEvent event, String userId) {
|
||||||
event
|
Message msg = event.getMessage();
|
||||||
.getChannel()
|
String fullContent = msg.getContentRaw() + replyInfo(msg) + attachmentInfo(msg);
|
||||||
.getHistory()
|
sessionManager.addMessage(
|
||||||
.retrievePast(CHANNEL_CONTEXT)
|
event.getChannel().getId(),
|
||||||
.queue(
|
event.getAuthor().getEffectiveName(),
|
||||||
messages -> {
|
event.getAuthor().getId(),
|
||||||
|
fullContent,
|
||||||
|
System.currentTimeMillis());
|
||||||
|
|
||||||
String targetName = event.getAuthor().getEffectiveName();
|
String targetName = event.getAuthor().getEffectiveName();
|
||||||
List<CachedMessage> context = new ArrayList<>();
|
List<CachedMessage> context = sessionManager.getContext(event.getChannel().getId(), CHANNEL_CONTEXT);
|
||||||
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();
|
|
||||||
|
|
||||||
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));
|
|
||||||
|
|
||||||
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);
|
String response = roastService.generateResponse(context, targetName, userId);
|
||||||
if (response == null || response.isBlank()) return;
|
if (response == null || response.isBlank()) return;
|
||||||
|
|
||||||
sendInParts(event, response);
|
sendInParts(event, response);
|
||||||
scheduleAutoPing(event, userId);
|
scheduleAutoPing(event, userId);
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void scheduleAutoPing(MessageReceivedEvent event, String userId) {
|
private void scheduleAutoPing(MessageReceivedEvent event, String userId) {
|
||||||
|
|
@ -173,7 +149,7 @@ public class AndrertListener extends ListenerAdapter {
|
||||||
"тишина в эфире " + targetMention + ". всё, признал поражение?",
|
"тишина в эфире " + targetMention + ". всё, признал поражение?",
|
||||||
targetMention + " слился. ожидаемо",
|
targetMention + " слился. ожидаемо",
|
||||||
};
|
};
|
||||||
String msg =
|
String msgText =
|
||||||
lines[(int) (System.currentTimeMillis() % lines.length)];
|
lines[(int) (System.currentTimeMillis() % lines.length)];
|
||||||
|
|
||||||
channel
|
channel
|
||||||
|
|
@ -181,37 +157,29 @@ public class AndrertListener extends ListenerAdapter {
|
||||||
.retrievePast(CHANNEL_CONTEXT)
|
.retrievePast(CHANNEL_CONTEXT)
|
||||||
.queue(
|
.queue(
|
||||||
hist -> {
|
hist -> {
|
||||||
String targetName = "пользователь";
|
sessionManager.clear(channelId);
|
||||||
List<CachedMessage> ctx = new ArrayList<>();
|
for (int i = hist.size() - 1; i >= 0; i--) {
|
||||||
for (int i = 0; i < hist.size(); i++) {
|
|
||||||
Message m = hist.get(i);
|
Message m = hist.get(i);
|
||||||
String a =
|
if (m.getAuthor().isBot()) continue;
|
||||||
m.getAuthor()
|
String a = m.getAuthor().getEffectiveName();
|
||||||
.getId()
|
String c =
|
||||||
.equals(
|
m.getContentRaw()
|
||||||
event.getJDA().getSelfUser().getId())
|
+ replyInfo(m)
|
||||||
? "ты"
|
+ attachmentInfo(m);
|
||||||
: m.getAuthor().getEffectiveName();
|
sessionManager.addMessage(
|
||||||
String label = (i == 0) ? "[now]" : "[history]";
|
channelId, a, m.getAuthor().getId(), c,
|
||||||
ctx.add(
|
m.getTimeCreated().toEpochSecond() * 1000);
|
||||||
new CachedMessage(
|
|
||||||
label
|
|
||||||
+ " "
|
|
||||||
+ a
|
|
||||||
+ "("
|
|
||||||
+ m.getAuthor().getId()
|
|
||||||
+ "): "
|
|
||||||
+ m.getContentRaw(),
|
|
||||||
channel.getName(),
|
|
||||||
m.getTimeCreated().toEpochSecond() * 1000));
|
|
||||||
}
|
}
|
||||||
java.util.Collections.reverse(ctx);
|
|
||||||
|
String targetName = "пользователь";
|
||||||
|
List<CachedMessage> ctx =
|
||||||
|
sessionManager.getContext(channelId, CHANNEL_CONTEXT);
|
||||||
String resp =
|
String resp =
|
||||||
roastService.generateResponse(ctx, targetName, userId);
|
roastService.generateResponse(ctx, targetName, userId);
|
||||||
if (resp != null && !resp.isBlank()) {
|
if (resp != null && !resp.isBlank()) {
|
||||||
channel.sendMessage(msg + "\n" + resp).queue();
|
channel.sendMessage(msgText + "\n" + resp).queue();
|
||||||
} else {
|
} else {
|
||||||
channel.sendMessage(msg).queue();
|
channel.sendMessage(msgText).queue();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ public class RoastModule extends AbstractModule {
|
||||||
bind(ContentFetcher.class).in(Singleton.class);
|
bind(ContentFetcher.class).in(Singleton.class);
|
||||||
bind(WikipediaService.class).in(Singleton.class);
|
bind(WikipediaService.class).in(Singleton.class);
|
||||||
bind(RoastService.class).in(Singleton.class);
|
bind(RoastService.class).in(Singleton.class);
|
||||||
|
bind(AiSessionManager.class).in(Singleton.class);
|
||||||
bind(AndrertListener.class).in(Singleton.class);
|
bind(AndrertListener.class).in(Singleton.class);
|
||||||
bind(WebSearchService.class).in(Singleton.class);
|
bind(WebSearchService.class).in(Singleton.class);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue