feat: image navigation, yandere/e621random commands, ai session manager, deploy script

This commit is contained in:
loki5512344 2026-07-12 22:24:43 +02:00
parent deb875fd3d
commit 49adad7f58
Signed by: boba
GPG key ID: 253067914055423B
23 changed files with 880 additions and 470 deletions

View file

@ -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
View 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 — перезапустить"

View file

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

View file

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

View file

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

View file

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

View file

@ -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 + "`";
}
}

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) {
var user = userOption.getAsUser(); event.getHook().editOriginal("Укажи пользователя").queue();
targetId = user.getId(); return;
targetName = user.getName();
} else {
targetId = ANDRERT_USER_ID;
targetName = "Andrert_";
} }
var user = userOption.getAsUser();
targetId = user.getId();
targetName = user.getName();
String finalTargetName = targetName; String finalTargetName = targetName;
String finalTargetId = targetId; String finalTargetId = targetId;

View file

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

View file

@ -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(),
String targetName = event.getAuthor().getEffectiveName(); fullContent,
List<CachedMessage> context = new ArrayList<>(); System.currentTimeMillis());
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]"; String targetName = event.getAuthor().getEffectiveName();
StringBuilder line = new StringBuilder(); List<CachedMessage> context = sessionManager.getContext(event.getChannel().getId(), CHANNEL_CONTEXT);
line.append(label)
.append(" ")
.append(author)
.append("(")
.append(authorId)
.append("): ")
.append(msg.getContentRaw());
line.append(replyInfo(msg));
line.append(attachmentInfo(msg));
context.add( String response = roastService.generateResponse(context, targetName, userId);
new CachedMessage( if (response == null || response.isBlank()) return;
line.toString(),
event.getChannel().getName(),
msg.getTimeCreated().toEpochSecond() * 1000));
}
java.util.Collections.reverse(context);
String response = roastService.generateResponse(context, targetName, userId); sendInParts(event, response);
if (response == null || response.isBlank()) return; scheduleAutoPing(event, userId);
sendInParts(event, response);
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();
} }
}); });
}); });

View file

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