feat: nsfw image commands (!e621, !r34, !gelbooru) + help filtering

This commit is contained in:
loki5512344 2026-07-07 16:03:03 +02:00
parent cc046d7ee6
commit deb875fd3d
34 changed files with 1765 additions and 132 deletions

View file

@ -6,5 +6,8 @@ public final class CommandCategories {
/** Команды с GIF (nekos.life): hug, slap, poke и т.п. */
public static final String GIF_INTERACTIVE = "GIF-интерактив";
/** Команды для поиска изображений (e621, r34) — только в NSFW-каналах. */
public static final String NSFW_IMAGES = "NSFW";
private CommandCategories() {}
}

View file

@ -16,6 +16,7 @@ public class BotConfig {
private final int aiContextChars;
private final int aiDossierMsgs;
private final int aiDossierUpdateHours;
private final String aiDebateChannelId;
private final LavalinkConfig lavalinkConfig;
public BotConfig(
@ -34,6 +35,7 @@ public class BotConfig {
int aiContextChars,
int aiDossierMsgs,
int aiDossierUpdateHours,
String aiDebateChannelId,
LavalinkConfig lavalinkConfig) {
this.mainToken = mainToken;
this.musicToken = musicToken;
@ -50,6 +52,7 @@ public class BotConfig {
this.aiContextChars = aiContextChars;
this.aiDossierMsgs = aiDossierMsgs;
this.aiDossierUpdateHours = aiDossierUpdateHours;
this.aiDebateChannelId = aiDebateChannelId;
this.lavalinkConfig = lavalinkConfig;
}
@ -117,6 +120,10 @@ public class BotConfig {
return aiDossierUpdateHours;
}
public String getAiDebateChannelId() {
return aiDebateChannelId;
}
public LavalinkConfig getLavalinkConfig() {
return lavalinkConfig;
}

View file

@ -81,6 +81,7 @@ public final class ConfigLoader {
int aiContextChars = Integer.parseInt(getEnv(dotenv, "AI_CONTEXT_CHARS", "3000"));
int aiDossierMsgs = Integer.parseInt(getEnv(dotenv, "AI_DOSSIER_MSGS", "30"));
int aiDossierUpdateHours = Integer.parseInt(getEnv(dotenv, "AI_DOSSIER_UPDATE_HOURS", "24"));
String aiDebateChannelId = dotenv.get("AI_DEBATE_CHANNEL_ID");
LavalinkConfig lavalinkConfig = getLavalinkConfig();
LoggerDecorator.success(logger, "Конфигурация загружена успешно");
@ -100,6 +101,7 @@ public final class ConfigLoader {
aiContextChars,
aiDossierMsgs,
aiDossierUpdateHours,
aiDebateChannelId,
lavalinkConfig);
}
}

View file

@ -19,8 +19,7 @@ import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.OnlineStatus;
import net.dv8tion.jda.api.entities.Activity;
import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.Commands;
import net.dv8tion.jda.api.requests.GatewayIntent;
import net.dv8tion.jda.api.utils.cache.CacheFlag;
import org.slf4j.Logger;
@ -68,18 +67,6 @@ public class BotInitializer {
LoggerDecorator.startup(logger, "Инициализация main бота...");
registrar.registerAll();
JDA mainJda = buildMainJDA();
mainJda
.upsertCommand(
Commands.slash("agr", "Наслать AI на пользователя (только для админов)")
.addOption(OptionType.USER, "user", "Цель (по умолчанию Andrert_)", false))
.queue();
mainJda
.upsertCommand(
Commands.slash("clear", "Удалить сообщения")
.addOption(OptionType.INTEGER, "amount", "Количество (1-100)", true)
.addOption(
OptionType.USER, "user", "Оставить только сообщения пользователя", false))
.queue();
muteScheduler.restorePending(mainJda);
mainJda.getGuilds().forEach(g -> memberCounterListener.startScheduler(g));
healthHttpServer.start();

View file

@ -0,0 +1,23 @@
package creator.loki.core;
import com.google.inject.Singleton;
import net.dv8tion.jda.api.JDA;
@Singleton
public class BotJdaRegistry {
private JDA main;
private JDA music;
private JDA radio;
public void setMain(JDA jda) { this.main = jda; }
public void setMusic(JDA jda) { this.music = jda; }
public void setRadio(JDA jda) { this.radio = jda; }
public JDA getByPersonality(String personality) {
return switch (personality) {
case "windows" -> music != null ? music : main;
case "gentoo" -> radio != null ? radio : main;
default -> main;
};
}
}

View file

@ -14,6 +14,7 @@ import creator.loki.feature.moderation.commands.utils.ClearSlashCommands;
import creator.loki.feature.moderation.log.ModerationLogger;
import creator.loki.feature.music.MusicSlashCommands;
import creator.loki.feature.roast.AndrertListener;
import creator.loki.feature.roast.HelperListener;
import creator.loki.feature.voice.handler.VoiceButtonHandler;
import creator.loki.feature.voice.handler.VoiceModalHandler;
import creator.loki.feature.voice.listener.VoiceChannelListener;
@ -43,7 +44,8 @@ public class ListenerAggregator {
MusicSlashCommands musicSlashCommands,
FishMessageListener fishMessageListener,
AndrertListener andrertListener,
ClearSlashCommands clearSlashCommands) {
ClearSlashCommands clearSlashCommands,
HelperListener helperListener) {
this.listeners =
new Object[] {
readyListener,
@ -63,7 +65,8 @@ public class ListenerAggregator {
musicSlashCommands,
fishMessageListener,
andrertListener,
clearSlashCommands
clearSlashCommands,
helperListener
};
}

View file

@ -20,6 +20,8 @@ import creator.loki.feature.counter.CounterModule;
import creator.loki.feature.economy.EconomyCommandRegistrar;
import creator.loki.feature.economy.EconomyModule;
import creator.loki.feature.entertainment.EntertainmentCommandRegistrar;
import creator.loki.feature.image.ImageCommandRegistrar;
import creator.loki.feature.image.ImageModule;
import creator.loki.feature.level.LevelModule;
import creator.loki.feature.level.commands.LevelCommandRegistrar;
import creator.loki.feature.metrics.HealthHttpServer;
@ -64,6 +66,7 @@ public class BotModule extends AbstractModule {
install(new EconomyModule());
install(new RadioModule());
install(new RoastModule());
install(new ImageModule());
Multibinder<CommandRegistrar> commandBinder =
Multibinder.newSetBinder(binder(), CommandRegistrar.class);
@ -74,6 +77,7 @@ public class BotModule extends AbstractModule {
commandBinder.addBinding().to(MusicCommandRegistrar.class);
commandBinder.addBinding().to(EconomyCommandRegistrar.class);
commandBinder.addBinding().to(RadioCommandRegistrar.class);
commandBinder.addBinding().to(ImageCommandRegistrar.class);
}
@Provides

View file

@ -22,7 +22,8 @@ public final class HelpCategories {
Map.entry("Утилиты", "🔧"),
Map.entry("Система", "💠"),
Map.entry("Настройка", "⚙️"),
Map.entry("Супер-Админ", "🔐"));
Map.entry("Супер-Админ", "🔐"),
Map.entry(CommandCategories.NSFW_IMAGES, "🔞"));
COLORS =
Map.ofEntries(
@ -35,7 +36,8 @@ public final class HelpCategories {
Map.entry("Утилиты", new Color(52, 152, 219)),
Map.entry("Система", new Color(52, 73, 94)),
Map.entry("Настройка", new Color(149, 165, 166)),
Map.entry("Супер-Админ", new Color(113, 54, 138)));
Map.entry("Супер-Админ", new Color(113, 54, 138)),
Map.entry(CommandCategories.NSFW_IMAGES, new Color(180, 0, 0)));
DESCRIPTIONS =
Map.ofEntries(
@ -49,7 +51,9 @@ public final class HelpCategories {
Map.entry("Утилиты", "Полезные инструменты"),
Map.entry("Система", "Метрики и служебная информация для админов"),
Map.entry("Настройка", "Настройка систем бота (только админы)"),
Map.entry("Супер-Админ", "Сервисные команды владельца бота"));
Map.entry("Супер-Админ", "Сервисные команды владельца бота"),
Map.entry(
CommandCategories.NSFW_IMAGES, "Поиск изображений (только в NSFW каналах)"));
}
private HelpCategories() {}
@ -66,10 +70,17 @@ public final class HelpCategories {
return DESCRIPTIONS.getOrDefault(category, "Команды категории");
}
public static boolean isCategoryVisible(String category, boolean isAdmin, boolean isSuper) {
public static boolean isCategoryVisible(
String category, boolean isAdmin, boolean isSuper, boolean isNsfwChannel) {
if ("Настройка".equals(category) && !isAdmin) {
return false;
}
return !"Супер-Админ".equals(category) || isSuper;
if ("Супер-Админ".equals(category) && !isSuper) {
return false;
}
if (CommandCategories.NSFW_IMAGES.equals(category) && !isNsfwChannel) {
return false;
}
return true;
}
}

View file

@ -15,6 +15,7 @@ import creator.loki.embed.HelpCategories;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.components.actionrow.ActionRow;
import net.dv8tion.jda.api.components.selections.StringSelectMenu;
import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
@ -76,12 +77,13 @@ public class HelpMenuHandler extends ListenerAdapter {
boolean isAdmin =
event.getMember() != null && event.getMember().hasPermission(Permission.ADMINISTRATOR);
boolean isSuper = config.getSuperAdminId().equals(event.getUser().getId());
boolean isNsfwChannel = isNsfw(event.getChannel().getType());
commandsByCategory.keySet().stream()
.sorted()
.forEach(
cat -> {
if (!HelpCategories.isCategoryVisible(cat, isAdmin, isSuper)) {
if (!HelpCategories.isCategoryVisible(cat, isAdmin, isSuper, isNsfwChannel)) {
return;
}
@ -150,4 +152,11 @@ public class HelpMenuHandler extends ListenerAdapter {
return embed;
}
private static boolean isNsfw(ChannelType type) {
return type == ChannelType.TEXT
|| type == ChannelType.NEWS
|| type == ChannelType.FORUM
|| type == ChannelType.MEDIA;
}
}

View file

@ -15,6 +15,7 @@ import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.components.actionrow.ActionRow;
import net.dv8tion.jda.api.components.selections.StringSelectMenu;
import net.dv8tion.jda.api.entities.User;
import net.dv8tion.jda.api.entities.channel.ChannelType;
public class HelpCommand implements Command {
private final CommandRegistry registry;
@ -48,9 +49,11 @@ public class HelpCommand implements Command {
boolean isAdmin =
context.getAuthor() != null && context.getAuthor().hasPermission(Permission.ADMINISTRATOR);
boolean isSuper = config.getSuperAdminId().equals(viewer.getId());
boolean isNsfwChannel = isNsfw(context.getChannel().getType());
// Главная страница
EmbedFactory embed = createMainPage(context, commandsByCategory, isAdmin, isSuper);
EmbedFactory embed =
createMainPage(context, commandsByCategory, isAdmin, isSuper, isNsfwChannel);
// Создаём селектор категорий
StringSelectMenu.Builder menuBuilder =
@ -60,7 +63,7 @@ public class HelpCommand implements Command {
.sorted()
.forEach(
category -> {
if (!HelpCategories.isCategoryVisible(category, isAdmin, isSuper)) {
if (!HelpCategories.isCategoryVisible(category, isAdmin, isSuper, isNsfwChannel)) {
return;
}
@ -82,14 +85,22 @@ public class HelpCommand implements Command {
.queue();
}
private static boolean isNsfw(ChannelType type) {
return type == ChannelType.TEXT
|| type == ChannelType.NEWS
|| type == ChannelType.FORUM
|| type == ChannelType.MEDIA;
}
private EmbedFactory createMainPage(
CommandContext context,
Map<String, List<Command>> commandsByCategory,
boolean isAdmin,
boolean isSuper) {
boolean isSuper,
boolean isNsfwChannel) {
int totalCommands =
commandsByCategory.entrySet().stream()
.filter(e -> HelpCategories.isCategoryVisible(e.getKey(), isAdmin, isSuper))
.filter(e -> HelpCategories.isCategoryVisible(e.getKey(), isAdmin, isSuper, isNsfwChannel))
.mapToInt(e -> e.getValue().size())
.sum();
@ -98,7 +109,8 @@ public class HelpCommand implements Command {
.sorted()
.forEach(
category -> {
if (!HelpCategories.isCategoryVisible(category, isAdmin, isSuper)) {
if (!HelpCategories.isCategoryVisible(
category, isAdmin, isSuper, isNsfwChannel)) {
return;
}

View file

@ -0,0 +1,24 @@
package creator.loki.feature.image;
import com.google.inject.Inject;
import creator.loki.core.registry.CommandRegistrar;
import creator.loki.core.registry.CommandRegistry;
import creator.loki.feature.image.commands.E621Command;
import creator.loki.feature.image.commands.GelbooruCommand;
import creator.loki.feature.image.commands.R34Command;
public class ImageCommandRegistrar implements CommandRegistrar {
private final ImageService imageService;
@Inject
public ImageCommandRegistrar(ImageService imageService) {
this.imageService = imageService;
}
@Override
public void register(CommandRegistry registry) {
registry.register(new E621Command(imageService));
registry.register(new R34Command(imageService));
registry.register(new GelbooruCommand(imageService));
}
}

View file

@ -0,0 +1,17 @@
package creator.loki.feature.image;
import com.google.inject.AbstractModule;
import com.google.inject.Provides;
import com.google.inject.Singleton;
public class ImageModule extends AbstractModule {
@Override
protected void configure() {}
@Provides
@Singleton
ImageService provideImageService() {
return new ImageService();
}
}

View file

@ -0,0 +1,129 @@
package creator.loki.feature.image;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
@Singleton
public class ImageService {
private static final Logger logger = LoggerFactory.getLogger(ImageService.class);
private static final String E621_API = "https://e621.net/posts.json";
private static final String R34_API =
"https://api.rule34.xxx/index.php?page=dapi&s=post&q=index&json=1";
private final OkHttpClient client;
@Inject
public ImageService() {
this.client =
new OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build();
}
public List<String> fetchE621(String tags, int limit) {
String url = E621_API + "?tags=" + encodeTags(tags) + "&limit=" + Math.min(limit, 10);
Request request =
new Request.Builder()
.url(url)
.header("User-Agent", "LokiBot/1.0 (by loki)")
.get()
.build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful() || response.body() == null) {
logger.error("e621 API error: {} {}", response.code(), response.message());
return List.of();
}
String body = response.body().string();
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
JsonArray posts = json.getAsJsonArray("posts");
if (posts == null || posts.isEmpty()) {
return List.of();
}
List<String> urls = new ArrayList<>();
for (JsonElement el : posts) {
JsonObject post = el.getAsJsonObject();
JsonObject file = post.getAsJsonObject("file");
if (file != null && file.has("url") && !file.get("url").isJsonNull()) {
String imageUrl = file.get("url").getAsString();
if (!imageUrl.isEmpty()) {
urls.add(imageUrl);
}
}
if (urls.size() >= limit) break;
}
return urls;
} catch (IOException e) {
logger.error("Error fetching from e621", e);
return List.of();
}
}
public List<String> fetchR34(String tags, int limit) {
String url =
R34_API + "&tags=" + encodeTags(tags) + "&limit=" + Math.min(limit, 10);
return fetchBooruJson(url, limit);
}
public List<String> fetchGelbooru(String tags, int limit) {
String url =
"https://gelbooru.com/index.php?page=dapi&s=post&q=index&json=1&tags="
+ encodeTags(tags)
+ "&limit="
+ Math.min(limit, 10);
return fetchBooruJson(url, limit);
}
private List<String> fetchBooruJson(String url, int limit) {
Request request = new Request.Builder().url(url).get().build();
try (Response response = client.newCall(request).execute()) {
if (!response.isSuccessful() || response.body() == null) {
logger.error("Booru API error: {} {}", response.code(), response.message());
return List.of();
}
String body = response.body().string();
if (body.isBlank() || !body.startsWith("[")) {
return List.of();
}
JsonArray posts = JsonParser.parseString(body).getAsJsonArray();
List<String> urls = new ArrayList<>();
for (JsonElement el : posts) {
JsonObject post = el.getAsJsonObject();
String fileUrl = post.get("file_url").getAsString();
if (fileUrl != null && !fileUrl.isEmpty()) {
if (fileUrl.startsWith("//")) {
fileUrl = "https:" + fileUrl;
}
urls.add(fileUrl);
}
if (urls.size() >= limit) break;
}
return urls;
} catch (IOException e) {
logger.error("Error fetching from booru", e);
return List.of();
}
}
private String encodeTags(String tags) {
if (tags == null || tags.isBlank()) return "";
return tags.trim().replace(" ", "+").replace("#", "%23");
}
}

View file

@ -0,0 +1,129 @@
package creator.loki.feature.image.commands;
import java.awt.Color;
import java.util.ArrayList;
import java.util.List;
import creator.loki.command.Command;
import creator.loki.command.CommandCategories;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedFactory;
import creator.loki.embed.EmbedTemplates;
import creator.loki.feature.image.ImageService;
import net.dv8tion.jda.api.entities.MessageEmbed;
import net.dv8tion.jda.api.entities.channel.ChannelType;
public class E621Command implements Command {
private final ImageService imageService;
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 <количество до 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,129 @@
package creator.loki.feature.image.commands;
import java.awt.Color;
import java.util.ArrayList;
import java.util.List;
import creator.loki.command.Command;
import creator.loki.command.CommandCategories;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedFactory;
import creator.loki.embed.EmbedTemplates;
import creator.loki.feature.image.ImageService;
import net.dv8tion.jda.api.entities.MessageEmbed;
import net.dv8tion.jda.api.entities.channel.ChannelType;
public class GelbooruCommand implements Command {
private final ImageService imageService;
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 <количество до 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

@ -0,0 +1,129 @@
package creator.loki.feature.image.commands;
import java.awt.Color;
import java.util.ArrayList;
import java.util.List;
import creator.loki.command.Command;
import creator.loki.command.CommandCategories;
import creator.loki.command.CommandContext;
import creator.loki.embed.EmbedFactory;
import creator.loki.embed.EmbedTemplates;
import creator.loki.feature.image.ImageService;
import net.dv8tion.jda.api.entities.MessageEmbed;
import net.dv8tion.jda.api.entities.channel.ChannelType;
public class R34Command implements Command {
private final ImageService imageService;
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 <количество до 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

@ -8,29 +8,13 @@ import creator.loki.embed.EmbedTemplates;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.events.guild.GuildReadyEvent;
import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import net.dv8tion.jda.api.interactions.commands.OptionType;
import net.dv8tion.jda.api.interactions.commands.build.Commands;
import org.jetbrains.annotations.NotNull;
@Singleton
public class ClearSlashCommands extends ListenerAdapter {
@Override
public void onGuildReady(@NotNull GuildReadyEvent event) {
event
.getJDA()
.updateCommands()
.addCommands(
Commands.slash("clear", "Удалить сообщения")
.addOption(OptionType.INTEGER, "amount", "Количество сообщений (1-100)", true)
.addOption(
OptionType.USER, "user", "Оставить только сообщения пользователя", false))
.queue();
}
@Override
public void onSlashCommandInteraction(@NotNull SlashCommandInteractionEvent event) {
if (!event.getName().equals("clear")) return;

View file

@ -55,7 +55,12 @@ public class MusicSlashCommands extends ListenerAdapter {
Commands.slash("equalizer", "Эквалайзер")
.addOption(OptionType.INTEGER, "band", "0-14", false)
.addOption(OptionType.NUMBER, "gain", "-0.25 до 1.0", false),
Commands.slash("reset", "Сбросить фильтры"))
Commands.slash("reset", "Сбросить фильтры"),
Commands.slash("agr", "Наслать AI на пользователя (только для админов)")
.addOption(OptionType.USER, "user", "Цель", false),
Commands.slash("clear", "Удалить сообщения")
.addOption(OptionType.INTEGER, "amount", "Количество (1-100)", true)
.addOption(OptionType.USER, "user", "Оставить только сообщения пользователя", false))
.queue();
}

View file

@ -3,12 +3,16 @@ package creator.loki.feature.roast;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import creator.loki.config.BotConfig;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import org.jetbrains.annotations.NotNull;
@ -18,10 +22,14 @@ public class AndrertListener extends ListenerAdapter {
private static final String ANDRERT_USER_ID = "1405129454165954630";
private static final int CHANNEL_CONTEXT = 15;
private static final long TRACK_TTL = TimeUnit.HOURS.toMillis(1);
private static final long AUTO_PING_DELAY = TimeUnit.MINUTES.toMillis(5);
private final RoastService roastService;
private final String allowedChannelId;
private final ConcurrentHashMap<String, Long> trackedUsers = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, Long> lastAiReplyTime = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, ScheduledFuture<?>> pendingPings = new ConcurrentHashMap<>();
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
@Inject
public AndrertListener(RoastService roastService, BotConfig config) {
@ -38,14 +46,23 @@ public class AndrertListener extends ListenerAdapter {
public void onMessageReceived(@NotNull MessageReceivedEvent event) {
if (event.getAuthor().isBot()) return;
String userId = event.getAuthor().getId();
String channelId = event.getChannel().getId();
String key = userId + ":" + channelId;
Long lastReply = lastAiReplyTime.get(key);
if (lastReply != null) {
lastAiReplyTime.put(key, -1L);
ScheduledFuture<?> existing = pendingPings.remove(key);
if (existing != null) existing.cancel(false);
}
boolean isAllowedChannel =
allowedChannelId != null
&& !allowedChannelId.isBlank()
&& channelId.equals(allowedChannelId);
if (!isAllowedChannel) {
String userId = event.getAuthor().getId();
Long expiresAt = trackedUsers.get(userId);
if (expiresAt == null) return;
long now = System.currentTimeMillis();
@ -58,7 +75,7 @@ public class AndrertListener extends ListenerAdapter {
trackedUsers.put(userId, now + TRACK_TTL);
}
respond(event, event.getAuthor().getId());
respond(event, userId);
}
private void respond(MessageReceivedEvent event, String userId) {
@ -89,9 +106,8 @@ public class AndrertListener extends ListenerAdapter {
msg.getAuthor().getId().equals(event.getJDA().getSelfUser().getId());
String author = isSelf ? "ты" : msg.getAuthor().getEffectiveName();
String authorId = msg.getAuthor().getId();
boolean isCurrent = (idx == 0);
String label = isCurrent ? "[now]" : "[history]";
String label = (idx == 0) ? "[now]" : "[history]";
StringBuilder line = new StringBuilder();
line.append(label)
.append(" ")
@ -115,9 +131,98 @@ public class AndrertListener extends ListenerAdapter {
if (response == null || response.isBlank()) return;
sendInParts(event, response);
scheduleAutoPing(event, userId);
});
}
private void scheduleAutoPing(MessageReceivedEvent event, String userId) {
String channelId = event.getChannel().getId();
String key = userId + ":" + channelId;
long now = System.currentTimeMillis();
lastAiReplyTime.put(key, now);
ScheduledFuture<?> existing = pendingPings.remove(key);
if (existing != null) existing.cancel(false);
TextChannel channel = event.getChannel().asTextChannel();
String targetMention = "<@" + userId + ">";
ScheduledFuture<?> future =
scheduler.schedule(
() -> {
Long lastReply = lastAiReplyTime.get(key);
if (lastReply == null || lastReply != now) return;
if (channel.canTalk()) {
channel
.getHistory()
.retrievePast(5)
.queue(
recent -> {
boolean userReplied =
recent.stream()
.anyMatch(
m ->
m.getAuthor().getId().equals(userId)
&& m.getTimeCreated().toEpochSecond() * 1000 > now);
if (userReplied) return;
String[] lines = {
"эй " + targetMention + ", ты чё замолчал? я не закончил",
"куда пропал " + targetMention + "? аргументы закончились?",
"ну " + targetMention + ", я жду. или скажешь что тебе нечего ответить?",
"тишина в эфире " + targetMention + ". всё, признал поражение?",
targetMention + " слился. ожидаемо",
};
String msg =
lines[(int) (System.currentTimeMillis() % lines.length)];
channel
.getHistory()
.retrievePast(CHANNEL_CONTEXT)
.queue(
hist -> {
String targetName = "пользователь";
List<CachedMessage> ctx = new ArrayList<>();
for (int i = 0; i < hist.size(); i++) {
Message m = hist.get(i);
String a =
m.getAuthor()
.getId()
.equals(
event.getJDA().getSelfUser().getId())
? "ты"
: m.getAuthor().getEffectiveName();
String label = (i == 0) ? "[now]" : "[history]";
ctx.add(
new CachedMessage(
label
+ " "
+ a
+ "("
+ m.getAuthor().getId()
+ "): "
+ m.getContentRaw(),
channel.getName(),
m.getTimeCreated().toEpochSecond() * 1000));
}
java.util.Collections.reverse(ctx);
String resp =
roastService.generateResponse(ctx, targetName, userId);
if (resp != null && !resp.isBlank()) {
channel.sendMessage(msg + "\n" + resp).queue();
} else {
channel.sendMessage(msg).queue();
}
});
});
}
pendingPings.remove(key);
},
AUTO_PING_DELAY,
TimeUnit.MILLISECONDS);
pendingPings.put(key, future);
}
private void sendInParts(MessageReceivedEvent event, String text) {
if (text.length() <= 2000) {
event.getMessage().reply(text).queue();

View file

@ -20,7 +20,7 @@ import org.slf4j.LoggerFactory;
@Singleton
public class ContentFetcher {
private static final Logger logger = LoggerFactory.getLogger(ContentFetcher.class);
private static final Pattern URL_PATTERN = Pattern.compile("https?://[^\\s]+");
private static final Pattern URL_PATTERN = Pattern.compile("https?://[^\\s.,!?;:)\\]>]+");
private static final int MAX_FETCH_CHARS = 3000;
private static final int MAX_PREVIEW_CHARS = 1000;

View file

@ -0,0 +1,39 @@
package creator.loki.feature.roast;
import java.util.concurrent.ConcurrentHashMap;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Singleton
public class DebateChannelListener extends ListenerAdapter {
private static final Logger logger = LoggerFactory.getLogger(DebateChannelListener.class);
private final ConcurrentHashMap<String, Long> seenMessages = new ConcurrentHashMap<>();
private final DebateService debateService;
@Inject
public DebateChannelListener(DebateService debateService) {
this.debateService = debateService;
}
@Override
public void onMessageReceived(@NotNull MessageReceivedEvent event) {
if (event.getAuthor().isBot()) return;
String msgId = event.getMessage().getId();
long now = System.currentTimeMillis();
Long prev = seenMessages.put(msgId, now);
if (prev != null) return;
seenMessages.entrySet().removeIf(e -> now - e.getValue() > 5000);
logger.info("debate msg ch={} a={} text={}",
event.getChannel().getId(), event.getAuthor().getId(),
event.getMessage().getContentRaw().replace("\n", " "));
debateService.onHumanMessage(event);
}
}

View file

@ -0,0 +1,257 @@
package creator.loki.feature.roast;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import creator.loki.config.BotConfig;
import creator.loki.core.BotJdaRegistry;
import creator.loki.feature.metrics.MetricsCollector;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Singleton
public class DebateService {
private static final Logger logger = LoggerFactory.getLogger(DebateService.class);
private static final Path PROMPTS_DIR =
Files.isDirectory(Paths.get("/app/prompts"))
? Paths.get("/app/prompts")
: Paths.get("data/prompts");
private static final DateTimeFormatter DT_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final long BOT_START_TIME = System.currentTimeMillis();
private static final int CHANNEL_CONTEXT = 20;
private static final String[] PERSONALITIES = {"arch", "windows", "gentoo"};
private static final int MAX_ROUNDS = 15;
private final BotConfig config;
private final DeepSeekClient deepSeekClient;
private final WikipediaService wikipediaService;
private final KnowledgeService knowledgeService;
private final MetricsCollector metricsCollector;
private final BotJdaRegistry jdaRegistry;
private final String archPrompt;
private final String windowsPrompt;
private final String gentooPrompt;
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
private final ConcurrentMap<String, ChannelState> channels = new ConcurrentHashMap<>();
@Inject
public DebateService(
BotConfig config,
DeepSeekClient deepSeekClient,
WikipediaService wikipediaService,
KnowledgeService knowledgeService,
MetricsCollector metricsCollector,
BotJdaRegistry jdaRegistry) {
this.config = config;
this.deepSeekClient = deepSeekClient;
this.wikipediaService = wikipediaService;
this.knowledgeService = knowledgeService;
this.metricsCollector = metricsCollector;
this.jdaRegistry = jdaRegistry;
this.archPrompt = loadPrompt("system-arch.txt");
this.windowsPrompt = loadPrompt("system-windows.txt");
this.gentooPrompt = loadPrompt("system-gentoo.txt");
}
private String loadPrompt(String filename) {
try {
Path p = PROMPTS_DIR.resolve(filename);
return Files.exists(p) ? Files.readString(p, StandardCharsets.UTF_8).strip() : "";
} catch (IOException e) {
logger.error("Failed to load prompt {}", filename, e);
return "";
}
}
public void onHumanMessage(MessageReceivedEvent event) {
String debateId = config.getAiDebateChannelId();
if (debateId == null || debateId.isBlank()) return;
if (!event.getChannel().getId().equals(debateId)) return;
if (event.getAuthor().isBot()) return;
if (event.getMessage().getContentRaw().isBlank() && event.getMessage().getAttachments().isEmpty()) return;
TextChannel channel = event.getChannel().asTextChannel();
ChannelState state = channels.computeIfAbsent(debateId, k -> new ChannelState());
state.cancel();
state.lastHumanTime = System.currentTimeMillis();
state.lastPersonality = null;
state.roundCount = 0;
state.lastMessageId = event.getMessage().getId();
state.currentTask = scheduler.schedule(() ->
doRound(channel, state), 0, TimeUnit.SECONDS);
}
private void doRound(TextChannel channel, ChannelState state) {
String debateId = channel.getId();
long now = System.currentTimeMillis();
if (now - state.lastHumanTime > 300_000 || state.roundDone) return;
state.cancel();
state.roundDone = true;
channel.getHistory().retrievePast(CHANNEL_CONTEXT).queue(messages -> {
boolean hasNewHuman = messages.stream().anyMatch(m ->
!m.getAuthor().isBot() && m.getTimeCreated().toEpochSecond() * 1000 > state.lastHumanTime);
if (hasNewHuman) {
state.roundDone = false;
state.lastHumanTime = now;
state.lastPersonality = null;
state.currentTask = scheduler.schedule(() ->
doRound(channel, state), 0, TimeUnit.SECONDS);
return;
}
List<CachedMessage> ctx = buildContext(messages);
if (state.roundCount >= MAX_ROUNDS) return;
state.roundCount++;
String personality = pickPersonality(state.lastPersonality);
if (personality == null) return;
state.lastPersonality = personality;
String response = generateResponse(personality, ctx);
if (response == null || response.isBlank()) return;
JDA jda = jdaRegistry.getByPersonality(personality);
TextChannel target = jda.getTextChannelById(debateId);
if (target == null) return;
String replyToId = state.lastMessageId;
target.sendMessage(response)
.setMessageReference(replyToId)
.queue(m -> state.lastMessageId = m.getId());
state.currentTask = scheduler.schedule(() -> {
state.roundDone = false;
doRound(channel, state);
}, 0, TimeUnit.SECONDS);
});
}
private String pickPersonality(String exclude) {
if (exclude == null) return PERSONALITIES[0];
for (int i = 0; i < PERSONALITIES.length; i++) {
if (PERSONALITIES[i].equals(exclude)) {
return PERSONALITIES[(i + 1) % PERSONALITIES.length];
}
}
return PERSONALITIES[0];
}
private String generateResponse(String personality, List<CachedMessage> ctx) {
String prompt;
String selfName;
String otherNames;
switch (personality) {
case "windows" -> {
prompt = windowsPrompt;
selfName = "шинд";
otherNames = "арч и генту";
}
case "gentoo" -> {
prompt = gentooPrompt;
selfName = "генту";
otherNames = "арч и шинд";
}
default -> {
prompt = archPrompt;
selfName = "арч";
otherNames = "шинд и генту";
}
}
if (prompt.isBlank()) return null;
prompt += "\n\nты " + selfName + ". два других ai в чате это " + otherNames;
long uptimeSec = (System.currentTimeMillis() - BOT_START_TIME) / 1000;
String now = LocalDateTime.now().format(DT_FMT);
prompt += "\nтекущее время " + now + " uptime бота " + (uptimeSec / 3600) + "ч "
+ ((uptimeSec % 3600) / 60) + "м " + (uptimeSec % 60) + "с";
StringBuilder userContent = new StringBuilder();
for (CachedMessage m : ctx) {
String t = m.content();
if (t.length() > config.getAiMsgLength()) t = t.substring(0, config.getAiMsgLength()) + "...";
userContent.append("[").append(m.channelName()).append("] ").append(t).append("\n");
if (userContent.length() > config.getAiContextChars()) break;
}
String text = userContent.toString().strip();
if (text.length() > config.getAiContextChars()) text = text.substring(0, config.getAiContextChars());
String wiki = wikipediaService.search(text);
if (wiki != null) text += "\n\n[википедия]:\n" + wiki;
String knowledge = knowledgeService.search(text);
if (knowledge != null) text += "\n\n[знания]:\n" + knowledge;
return deepSeekClient.callModel(prompt, text, 0.9);
}
private List<CachedMessage> buildContext(List<Message> messages) {
String archId = getBotId("arch");
String winId = getBotId("windows");
String gentooId = getBotId("gentoo");
List<CachedMessage> result = new ArrayList<>();
for (int i = messages.size() - 1; i >= 0; i--) {
Message msg = messages.get(i);
String name;
if (msg.getAuthor().isBot()) {
String id = msg.getAuthor().getId();
if (id.equals(archId)) name = "арч";
else if (id.equals(winId)) name = "шинд";
else if (id.equals(gentooId)) name = "генту";
else name = "бот";
} else {
name = msg.getAuthor().getEffectiveName();
}
String label = (i == messages.size() - 1) ? "[now]" : "[history]";
result.add(new CachedMessage(
label + " " + name + "(" + msg.getAuthor().getId() + "): " + msg.getContentRaw(),
msg.getChannel().getName(),
msg.getTimeCreated().toEpochSecond() * 1000));
}
return result;
}
private String getBotId(String personality) {
JDA jda = jdaRegistry.getByPersonality(personality);
return jda != null ? jda.getSelfUser().getId() : "";
}
private static class ChannelState {
long lastHumanTime;
boolean roundDone;
int roundCount;
String lastPersonality;
String lastMessageId;
ScheduledFuture<?> currentTask;
void cancel() {
if (currentTask != null) {
currentTask.cancel(false);
currentTask = null;
}
}
}
}

View file

@ -3,8 +3,6 @@ package creator.loki.feature.roast;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
@ -28,7 +26,6 @@ public class DeepSeekClient {
private final OkHttpClient httpClient;
private final String apiKey;
private final Cache<String, String> responseCache;
@Inject
public DeepSeekClient(BotConfig config) {
@ -39,8 +36,6 @@ public class DeepSeekClient {
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build();
this.responseCache =
Caffeine.newBuilder().maximumSize(200).expireAfterWrite(1, TimeUnit.HOURS).build();
}
public String callModel(String systemPrompt, String userContent) {
@ -50,10 +45,6 @@ public class DeepSeekClient {
public String callModel(String systemPrompt, String userContent, double temperature) {
if (apiKey == null || apiKey.isBlank()) return null;
String cacheKey = systemPrompt.hashCode() + ":" + userContent.hashCode();
String cached = responseCache.getIfPresent(cacheKey);
if (cached != null) return cached;
JsonObject body = new JsonObject();
body.addProperty("model", MODEL);
@ -98,9 +89,6 @@ public class DeepSeekClient {
} else if (msg.get("reasoning") != null && !msg.get("reasoning").isJsonNull()) {
content = msg.get("reasoning").getAsString().strip();
}
if (content != null && !content.isBlank()) {
responseCache.put(cacheKey, content);
}
return content;
} catch (IOException e) {
logger.error("DeepSeek call failed", e);

View file

@ -0,0 +1,178 @@
package creator.loki.feature.roast;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import creator.loki.config.BotConfig;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Singleton
public class HelperListener extends ListenerAdapter {
private static final Logger logger = LoggerFactory.getLogger(HelperListener.class);
private static final Path PROMPTS_DIR =
Files.isDirectory(Paths.get("/app/prompts"))
? Paths.get("/app/prompts")
: Paths.get("data/prompts");
private static final int CHANNEL_CONTEXT = 20;
private final BotConfig config;
private final RoastService roastService;
private final String helperPrompt;
@Inject
public HelperListener(BotConfig config, RoastService roastService) {
this.config = config;
this.roastService = roastService;
this.helperPrompt = loadPrompt();
}
private String loadPrompt() {
try {
Path p = PROMPTS_DIR.resolve("system-helper.txt");
return Files.exists(p) ? Files.readString(p, StandardCharsets.UTF_8).strip() : "";
} catch (IOException e) {
logger.error("Failed to load helper prompt", e);
return "";
}
}
@Override
public void onMessageReceived(@NotNull MessageReceivedEvent event) {
String channelId = config.getAiDebateChannelId();
if (channelId == null || channelId.isBlank()) return;
if (!event.getChannel().getId().equals(channelId)) return;
if (event.getAuthor().isBot()) return;
if (event.getMessage().getContentRaw().isBlank() && event.getMessage().getAttachments().isEmpty()) return;
String raw = event.getMessage().getContentRaw();
if (!raw.isBlank() && !LanguageFilter.isAllowed(raw)) return;
respond(event);
}
private void respond(MessageReceivedEvent event) {
event.getChannel().getHistory().retrievePast(CHANNEL_CONTEXT).queue(messages -> {
String targetName = event.getAuthor().getEffectiveName();
List<CachedMessage> context = new ArrayList<>();
for (int idx = 0; idx < messages.size(); idx++) {
Message msg = messages.get(idx);
boolean isSelf = msg.getAuthor().getId().equals(event.getJDA().getSelfUser().getId());
String author = isSelf ? "ты" : msg.getAuthor().getEffectiveName();
String authorId = msg.getAuthor().getId();
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(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, null, helperPrompt);
if (response == null || response.isBlank()) return;
sendReply(event, response);
});
}
private void sendReply(MessageReceivedEvent event, String text) {
java.util.regex.Matcher m = java.util.regex.Pattern.compile(
"\\[file:([^\\]]+)\\](.*?)\\[/file\\]", java.util.regex.Pattern.DOTALL).matcher(text);
if (!m.find()) {
sendText(event, text);
return;
}
StringBuffer remaining = new StringBuffer();
boolean first = true;
do {
String filename = m.group(1).strip();
String content = m.group(2);
m.appendReplacement(remaining, "");
var data = net.dv8tion.jda.api.utils.FileUpload.fromData(
content.getBytes(java.nio.charset.StandardCharsets.UTF_8), filename);
if (first) {
String msg = remaining.toString().strip();
event.getMessage().reply(msg == null ? "" : msg)
.addFiles(data).queue();
first = false;
} else {
event.getChannel().sendMessage(" ")
.addFiles(data).queue();
}
} while (m.find());
m.appendTail(remaining);
String rest = remaining.toString().strip();
if (!rest.isEmpty()) {
event.getChannel().sendMessage(rest).queue();
}
}
private void sendText(MessageReceivedEvent event, String text) {
if (text.length() <= 2000) {
event.getMessage().reply(text).queue();
return;
}
List<String> parts = new ArrayList<>();
int start = 0;
while (start < text.length() && parts.size() < 3) {
int end = Math.min(start + 1990, text.length());
if (end < text.length()) {
int nl = text.lastIndexOf('\n', end);
if (nl > start) end = nl;
}
parts.add(text.substring(start, end).strip());
start = end;
}
for (int i = 0; i < parts.size(); i++) {
if (i == 0) {
event.getMessage().reply(parts.get(i)).queue();
} else {
event.getChannel().sendMessage(parts.get(i)).queue();
}
}
}
private String attachmentInfo(Message msg) {
StringBuilder sb = new StringBuilder();
for (var att : msg.getAttachments()) {
if (att.isImage()) {
sb.append(" [изображение: ").append(att.getFileName()).append("]");
} else if (att.isVideo()) {
sb.append(" [видео: ").append(att.getFileName()).append("]");
} else if (att.getFileName().endsWith(".txt")) {
String txt = roastService.readTextFilePreview(att.getUrl());
if (txt != null) {
sb.append("\n[содержимое txt файла ")
.append(att.getFileName())
.append("]:\n")
.append(txt);
} else {
sb.append(" [txt файл: ").append(att.getFileName()).append(" (не удалось прочитать)]");
}
} else {
sb.append(" [файл: ").append(att.getFileName()).append("]");
}
}
if (!msg.getStickers().isEmpty()) {
sb.append(" [стикер]");
}
return sb.toString();
}
}

View file

@ -0,0 +1,211 @@
package creator.loki.feature.roast;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import com.google.inject.Singleton;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Singleton
public class KnowledgeService {
private static final Logger logger = LoggerFactory.getLogger(KnowledgeService.class);
private static final Path KNOWLEDGE_DIR =
Files.isDirectory(Paths.get("/app/knowledge"))
? Paths.get("/app/knowledge")
: Paths.get("data/knowledge");
private static final int MAX_CHARS = 2000;
private static final int RESULT_LIMIT = 3;
private static final Set<String> STOP_WORDS =
Set.of(
"это", "как", "так", "что", "он", "она", "они", "мы", "вы", "ты", "я",
"не", "да", "нет", "на", "в", "по", "для", "с", "у", "о", "от", "к",
"из", "за", "над", "под", "об", "же", "ли", "бы", "а", "и", "но",
"или", "то", "все", "его", "ее", "их", "еще", "уже", "когда",
"the", "a", "an", "is", "are", "was", "were", "be", "been",
"have", "has", "had", "do", "does", "did", "will", "would",
"can", "could", "may", "might", "shall", "should", "to", "of",
"in", "for", "with", "on", "at", "by", "from", "as", "into",
"it", "its", "this", "that", "these", "those", "i", "you",
"he", "she", "we", "they", "me", "him", "her", "us", "them",
"and", "but", "or", "not", "no", "if", "so", "about");
private final List<KnowledgeFile> files = new ArrayList<>();
public KnowledgeService() {
loadFiles();
}
private void loadFiles() {
try {
Files.createDirectories(KNOWLEDGE_DIR);
List<Path> paths;
try (var stream = Files.walk(KNOWLEDGE_DIR, 3)) {
paths =
stream
.filter(p -> p.toString().endsWith(".txt") || p.toString().endsWith(".md"))
.collect(Collectors.toList());
}
for (Path p : paths) {
try {
String content = Files.readString(p, StandardCharsets.UTF_8);
String name = KNOWLEDGE_DIR.relativize(p).toString();
files.add(new KnowledgeFile(name, content));
logger.info("Loaded knowledge file: {} ({} chars)", name, content.length());
} catch (IOException e) {
logger.warn("Failed to load knowledge file {}", p, e);
}
}
logger.info("Loaded {} knowledge files", files.size());
} catch (IOException e) {
logger.warn("Failed to list knowledge directory", e);
}
}
public String search(String text) {
if (text == null || text.isBlank() || files.isEmpty()) return null;
String[] words = extractKeywords(text);
if (words.length == 0) return null;
List<ScoredResult> results = new ArrayList<>();
for (KnowledgeFile file : files) {
int score = scoreFile(file, words);
if (score > 0) {
String snippet = findBestSnippet(file, words);
results.add(new ScoredResult(file.name, snippet, score));
}
}
results.sort((a, b) -> Integer.compare(b.score, a.score));
if (results.isEmpty()) return null;
StringBuilder sb = new StringBuilder();
int count = 0;
for (ScoredResult r : results) {
if (count >= RESULT_LIMIT) break;
if (sb.length() + r.snippet.length() > MAX_CHARS) break;
if (count > 0) sb.append("\n---\n");
sb.append("[").append(r.fileName).append("] ").append(r.snippet);
count++;
}
String result = sb.toString().strip();
return result.isBlank() ? null : result;
}
private String[] extractKeywords(String text) {
String cleaned = text.toLowerCase()
.replaceAll("[^a-zа-яё0-9\\s]", " ")
.replaceAll("\\s+", " ")
.strip();
String[] all = cleaned.split(" ");
return Arrays.stream(all)
.filter(w -> w.length() > 2)
.filter(w -> !STOP_WORDS.contains(w))
.distinct()
.limit(30)
.toArray(String[]::new);
}
private int scoreFile(KnowledgeFile file, String[] keywords) {
String lower = file.contentLower;
int score = 0;
for (String kw : keywords) {
int idx = 0;
int count = 0;
while ((idx = lower.indexOf(kw, idx)) != -1) {
count++;
idx += kw.length();
}
score += count * 10;
}
return score;
}
private String findBestSnippet(KnowledgeFile file, String[] keywords) {
String lower = file.contentLower;
int bestPos = -1;
int bestCount = 0;
for (String kw : keywords) {
int idx = lower.indexOf(kw);
if (idx == -1) continue;
int count = countInWindow(lower, idx, 500, keywords);
if (count > bestCount) {
bestCount = count;
bestPos = idx;
}
}
if (bestPos == -1) {
bestPos = Math.min(lower.length() / 2, 100);
}
int start = Math.max(0, bestPos - 200);
int end = Math.min(file.content.length(), bestPos + 600);
if (start > 0) {
int nl = file.content.indexOf('\n', start);
start = (nl != -1 && nl < bestPos) ? nl + 1 : start;
}
if (end < file.content.length()) {
int nl = file.content.lastIndexOf('\n', end);
end = (nl != -1 && nl > bestPos) ? nl : end;
}
String snippet = file.content.substring(start, end).strip();
if (snippet.length() > 800) {
snippet = snippet.substring(0, 797) + "...";
}
return snippet;
}
private int countInWindow(String text, int center, int windowSize, String[] keywords) {
int start = Math.max(0, center - windowSize);
int end = Math.min(text.length(), center + windowSize);
String window = text.substring(start, end);
int count = 0;
for (String kw : keywords) {
int idx = 0;
while ((idx = window.indexOf(kw, idx)) != -1) {
count++;
idx += kw.length();
}
}
return count;
}
private static class KnowledgeFile {
final String name;
final String content;
final String contentLower;
KnowledgeFile(String name, String content) {
this.name = name;
this.content = content;
this.contentLower = content.toLowerCase();
}
}
private static class ScoredResult {
final String fileName;
final String snippet;
final int score;
ScoredResult(String fileName, String snippet, int score) {
this.fileName = fileName;
this.snippet = snippet;
this.score = score;
}
}
}

View file

@ -9,8 +9,9 @@ public class RoastModule extends AbstractModule {
bind(DeepSeekClient.class).in(Singleton.class);
bind(DossierManager.class).in(Singleton.class);
bind(ContentFetcher.class).in(Singleton.class);
bind(WebSearchService.class).in(Singleton.class);
bind(WikipediaService.class).in(Singleton.class);
bind(RoastService.class).in(Singleton.class);
bind(AndrertListener.class).in(Singleton.class);
bind(WebSearchService.class).in(Singleton.class);
}
}

View file

@ -5,6 +5,8 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
@ -12,6 +14,7 @@ import java.util.regex.Pattern;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import creator.loki.config.BotConfig;
import creator.loki.feature.metrics.MetricsCollector;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -22,12 +25,17 @@ public class RoastService {
Files.isDirectory(Paths.get("/app/prompts"))
? Paths.get("/app/prompts")
: Paths.get("data/prompts");
private static final DateTimeFormatter DT_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final long BOT_START_TIME = System.currentTimeMillis();
private final BotConfig config;
private final DeepSeekClient deepSeekClient;
private final DossierManager dossierManager;
private final ContentFetcher contentFetcher;
private final WikipediaService wikipediaService;
private final KnowledgeService knowledgeService;
private final WebSearchService webSearchService;
private final MetricsCollector metricsCollector;
private final String systemPrompt;
private final String dossierGeneratePrompt;
@ -37,16 +45,31 @@ public class RoastService {
DeepSeekClient deepSeekClient,
DossierManager dossierManager,
ContentFetcher contentFetcher,
WebSearchService webSearchService) {
WikipediaService wikipediaService,
KnowledgeService knowledgeService,
WebSearchService webSearchService,
MetricsCollector metricsCollector) {
this.config = config;
this.deepSeekClient = deepSeekClient;
this.dossierManager = dossierManager;
this.contentFetcher = contentFetcher;
this.wikipediaService = wikipediaService;
this.knowledgeService = knowledgeService;
this.webSearchService = webSearchService;
this.metricsCollector = metricsCollector;
this.systemPrompt = loadPrompt("system.txt");
this.dossierGeneratePrompt = loadPrompt("dossier.txt");
}
private String currentTimeInfo() {
long uptimeSec = (System.currentTimeMillis() - BOT_START_TIME) / 1000;
long hours = uptimeSec / 3600;
long mins = (uptimeSec % 3600) / 60;
long secs = uptimeSec % 60;
String now = LocalDateTime.now().format(DT_FMT);
return String.format("текущее время %s uptime бота %dч %dм %dс всего команд выполнено %d", now, hours, mins, secs, metricsCollector.getTotalCommands());
}
private String loadPrompt(String filename) {
try {
return Files.readString(PROMPTS_DIR.resolve(filename), StandardCharsets.UTF_8).strip();
@ -123,9 +146,57 @@ public class RoastService {
if (text.length() > config.getAiMsgLength()) {
text = text.substring(0, config.getAiMsgLength()) + "...";
}
String parsed = contentFetcher.fetchUrlContent(text);
if (parsed != null) {
text += "\n[содержимое по ссылке]:\n" + parsed;
userContent.append("[").append(m.channelName()).append("] ").append(text).append("\n");
if (userContent.length() > config.getAiContextChars()) break;
}
String text = userContent.toString().strip();
if (text.length() > config.getAiContextChars()) {
text = text.substring(0, config.getAiContextChars());
}
systemPrompt += "\n\n" + currentTimeInfo();
text = currentTimeInfo() + "\n\n" + text;
String wikiContext = wikipediaService.search(text);
if (wikiContext != null) {
text += "\n\n[википедия]:\n" + wikiContext;
}
String knowledge = knowledgeService.search(text);
if (knowledge != null) {
text += "\n\n[знания]:\n" + knowledge;
}
return deepSeekClient.callModel(systemPrompt, text, 0.9);
}
public String generateResponse(List<CachedMessage> context, String targetName, String userId, String customPrompt) {
String escapedName = escapeFormat(targetName != null ? targetName : "");
String systemPrompt = customPrompt.formatted(escapedName, escapedName, escapedName);
if (userId != null) {
String dossier = dossierManager.getDossier(userId);
if (dossier != null && !dossier.isBlank()) {
systemPrompt +=
"\n\nДОСЬЕ НА ПОЛЬЗОВАТЕЛЯ " + escapedName.replace("%%", "%") + ":\n" + dossier;
}
}
List<CachedMessage> filtered = new ArrayList<>();
for (CachedMessage m : context) {
if (stripJailbreaks(m.content()) != null) {
filtered.add(m);
}
}
StringBuilder userContent = new StringBuilder();
int start = Math.max(0, filtered.size() - config.getAiContextMsgs());
for (int i = start; i < filtered.size(); i++) {
CachedMessage m = filtered.get(i);
String text = m.content();
if (text.length() > config.getAiMsgLength()) {
text = text.substring(0, config.getAiMsgLength()) + "...";
}
userContent.append("[").append(m.channelName()).append("] ").append(text).append("\n");
if (userContent.length() > config.getAiContextChars()) break;
@ -135,9 +206,23 @@ public class RoastService {
text = text.substring(0, config.getAiContextChars());
}
String webContext = webSearchService.search(text);
if (webContext != null) {
text += "\n\n[поиск в интернете]:\n" + webContext;
systemPrompt += "\n\n" + currentTimeInfo();
text = currentTimeInfo() + "\n\n" + text;
String wikiContext = wikipediaService.search(text);
if (wikiContext != null) {
text += "\n\n[википедия]:\n" + wikiContext;
}
String knowledge = knowledgeService.search(text);
if (knowledge != null) {
text += "\n\n[знания]:\n" + knowledge;
}
String web = webSearchService.search(text);
if (web != null) {
text += "\n\n[web]:\n" + web;
}
return deepSeekClient.callModel(systemPrompt, text, 0.9);

View file

@ -1,90 +1,147 @@
package creator.loki.feature.roast;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.inject.Singleton;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Singleton
public class WebSearchService {
private static final Logger logger = LoggerFactory.getLogger(WebSearchService.class);
private static final String DDG_URL = "https://api.duckduckgo.com/";
private static final int MAX_RESULT_CHARS = 3000;
private final OkHttpClient httpClient;
public WebSearchService() {
this.httpClient =
new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build();
this.httpClient = new OkHttpClient.Builder()
.connectTimeout(8, TimeUnit.SECONDS)
.readTimeout(12, TimeUnit.SECONDS)
.followRedirects(true)
.build();
}
public String search(String text) {
if (text == null || text.isBlank()) return null;
String query = text.length() > 200 ? text.substring(0, 200) : text;
public String search(String query) {
if (query == null || query.isBlank()) return null;
try {
String url =
DDG_URL
+ "?q="
+ URLEncoder.encode(query, StandardCharsets.UTF_8)
+ "&format=json&no_html=1&skip_disambig=1";
Request req = new Request.Builder().url(url).get().build();
try (Response resp = httpClient.newCall(req).execute()) {
if (!resp.isSuccessful() || resp.body() == null) return null;
JsonObject json = JsonParser.parseString(resp.body().string()).getAsJsonObject();
StringBuilder sb = new StringBuilder();
if (json.has("AbstractText") && !json.get("AbstractText").isJsonNull()) {
String abs = json.get("AbstractText").getAsString().strip();
if (!abs.isBlank()) sb.append(abs).append("\n");
}
JsonArray topics = json.getAsJsonArray("RelatedTopics");
int count = 0;
if (topics != null) {
for (var el : topics) {
if (count >= 5) break;
if (el.isJsonObject()) {
JsonObject t = el.getAsJsonObject();
if (t.has("Text") && !t.get("Text").isJsonNull()) {
String s = t.get("Text").getAsString().strip();
if (!s.isBlank()) {
sb.append("- ").append(s).append("\n");
count++;
}
}
if (t.has("Topics") && !t.get("Topics").isJsonNull()) {
for (var sub : t.getAsJsonArray("Topics")) {
if (count >= 5) break;
if (sub.isJsonObject()) {
JsonObject st = sub.getAsJsonObject();
if (st.has("Text") && !st.get("Text").isJsonNull()) {
String s = st.get("Text").getAsString().strip();
if (!s.isBlank()) {
sb.append("- ").append(s).append("\n");
count++;
}
}
}
}
}
}
}
}
String result = sb.toString().strip();
return result.isBlank() ? null : result;
List<SearchResult> results = searchDdg(query);
if (results.isEmpty()) return null;
StringBuilder sb = new StringBuilder();
for (int i = 0; i < Math.min(results.size(), 3); i++) {
SearchResult r = results.get(i);
sb.append(i + 1).append(". ").append(r.title).append("\n");
sb.append(r.snippet).append("\n");
sb.append(r.url).append("\n\n");
}
SearchResult top = results.get(0);
String pageContent = fetchPageText(top.url);
if (pageContent != null) {
sb.append("--- страница ").append(top.title).append(" ---\n");
sb.append(pageContent).append("\n");
}
if (sb.length() > MAX_RESULT_CHARS) {
sb.setLength(MAX_RESULT_CHARS);
sb.append("...");
}
logger.info("Web search ok q={} results={} chars={}", query, results.size(), sb.length());
return sb.toString().strip();
} catch (Exception e) {
logger.warn("Web search failed", e);
logger.warn("Web search failed for: {}", query, e);
return null;
}
}
private List<SearchResult> searchDdg(String query) {
List<SearchResult> results = new ArrayList<>();
try {
String url = "https://html.duckduckgo.com/html/?q="
+ URLEncoder.encode(query, StandardCharsets.UTF_8);
Request req = new Request.Builder()
.url(url)
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
.get()
.build();
try (Response resp = httpClient.newCall(req).execute()) {
if (!resp.isSuccessful() || resp.body() == null) return results;
String html = resp.body().string();
Document doc = Jsoup.parse(html);
for (Element a : doc.select("a.result__a")) {
if (results.size() >= 5) break;
String title = a.text().strip();
String href = a.attr("href");
String realUrl = extractUrl(href);
Element parent = a.parent();
Element snippet = parent != null ? parent.selectFirst(".result__snippet") : null;
String snip = snippet != null ? snippet.text().strip() : "";
if (!title.isEmpty() && !realUrl.isEmpty()) {
results.add(new SearchResult(title, realUrl, snip));
}
}
}
} catch (Exception e) {
logger.warn("DDG search failed for: {}", query, e);
}
return results;
}
private String extractUrl(String ddgUrl) {
if (ddgUrl == null || ddgUrl.isBlank()) return "";
if (ddgUrl.startsWith("//")) ddgUrl = "https:" + ddgUrl;
try {
if (ddgUrl.contains("uddg=")) {
String enc = ddgUrl.substring(ddgUrl.indexOf("uddg=") + 5);
int amp = enc.indexOf('&');
if (amp > 0) enc = enc.substring(0, amp);
return URLDecoder.decode(enc, StandardCharsets.UTF_8);
}
} catch (Exception ignored) {}
return ddgUrl;
}
private String fetchPageText(String url) {
try {
Request req = new Request.Builder()
.url(url)
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
.header("Accept", "text/html,application/xhtml+xml")
.header("Accept-Language", "en,ru;q=0.9")
.get()
.build();
try (Response resp = httpClient.newCall(req).execute()) {
if (!resp.isSuccessful() || resp.body() == null) return null;
String html = resp.body().string();
Document doc = Jsoup.parse(html);
Element main = doc.selectFirst("main, article, .post-content, .entry-content, .readme, .markdown-body, #readme, .container-lg");
String text;
if (main != null) {
main.select("script, style, nav, footer, header, .sidebar, aside").remove();
text = main.text().strip();
} else {
doc.select("script, style, nav, footer, header, .sidebar, aside").remove();
text = doc.body().text().strip();
}
if (text.length() > 2000) text = text.substring(0, 2000) + "...";
return text.isEmpty() ? null : text;
}
} catch (Exception e) {
logger.debug("Failed to fetch page: {}", url, e);
return null;
}
}
private record SearchResult(String title, String url, String snippet) {}
}

View file

@ -0,0 +1,95 @@
package creator.loki.feature.roast;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.inject.Singleton;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Singleton
public class WikipediaService {
private static final Logger logger = LoggerFactory.getLogger(WikipediaService.class);
private static final String SEARCH_URL = "https://ru.wikipedia.org/w/api.php?action=query&list=search&srsearch=%s&format=json&srlimit=3&srprop=snippet";
private static final String EXTRACT_URL = "https://ru.wikipedia.org/w/api.php?action=query&prop=extracts&exintro&explaintext&titles=%s&format=json&exlimit=1";
private static final int MAX_RESULT_CHARS = 1500;
private final OkHttpClient httpClient;
public WikipediaService() {
this.httpClient = new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build();
}
public String search(String query) {
if (query == null || query.isBlank()) return null;
try {
String url = String.format(SEARCH_URL, URLEncoder.encode(query, StandardCharsets.UTF_8));
Request req = new Request.Builder().url(url).get().build();
try (Response resp = httpClient.newCall(req).execute()) {
if (!resp.isSuccessful() || resp.body() == null) return null;
JsonObject json = JsonParser.parseString(resp.body().string()).getAsJsonObject();
JsonArray results = json.getAsJsonObject("query").getAsJsonArray("search");
if (results == null || results.size() == 0) return null;
StringBuilder sb = new StringBuilder();
for (int i = 0; i < Math.min(results.size(), 3); i++) {
JsonObject r = results.get(i).getAsJsonObject();
String title = r.get("title").getAsString();
String snippet = r.get("snippet").getAsString()
.replaceAll("<[^>]+>", "")
.replaceAll("&[^;]+;", " ");
String pageContent = getExtract(title);
sb.append("=== ").append(title).append(" ===\n");
if (pageContent != null) {
sb.append(pageContent);
} else {
sb.append(snippet);
}
sb.append("\n\n");
if (sb.length() > MAX_RESULT_CHARS) {
sb.setLength(MAX_RESULT_CHARS);
sb.append("...");
break;
}
}
return sb.toString().strip();
}
} catch (Exception e) {
logger.warn("Wikipedia search failed for: {}", query, e);
return null;
}
}
private String getExtract(String title) {
try {
String url = String.format(EXTRACT_URL, URLEncoder.encode(title, StandardCharsets.UTF_8));
Request req = new Request.Builder().url(url).get().build();
try (Response resp = httpClient.newCall(req).execute()) {
if (!resp.isSuccessful() || resp.body() == null) return null;
JsonObject json = JsonParser.parseString(resp.body().string()).getAsJsonObject();
JsonObject pages = json.getAsJsonObject("query").getAsJsonObject("pages");
for (var key : pages.keySet()) {
JsonObject page = pages.getAsJsonObject(key);
if (page.has("extract") && !page.get("extract").isJsonNull()) {
String ext = page.get("extract").getAsString().strip();
if (ext.length() > 800) ext = ext.substring(0, 800) + "...";
return ext;
}
}
}
} catch (Exception e) {
logger.warn("Failed to get extract for: {}", title, e);
}
return null;
}
}

View file

@ -26,6 +26,7 @@ class BotConfigTest {
3000,
30,
24,
null,
lavalink);
assertEquals("token123", config.getMainToken());
assertEquals("token123", config.getToken());
@ -44,7 +45,7 @@ class BotConfigTest {
void handlesNullValues() {
BotConfig config =
new BotConfig(
null, null, null, null, null, 0, null, null, null, null, 10, 300, 3000, 30, 24, null);
null, null, null, null, null, 0, null, null, null, null, 10, 300, 3000, 30, 24, null, null);
assertNull(config.getMainToken());
assertNull(config.getMusicToken());
assertNull(config.getRadioToken());
@ -61,7 +62,7 @@ class BotConfigTest {
LavalinkConfig lavalink = new LavalinkConfig("", 0, "", false);
BotConfig config =
new BotConfig(
"", "", "", "", "", 8080, "", "", null, null, 10, 300, 3000, 30, 24, lavalink);
"", "", "", "", "", 8080, "", "", null, null, 10, 300, 3000, 30, 24, null, lavalink);
assertEquals("", config.getMainToken());
assertEquals("", config.getMusicToken());
assertEquals("", config.getRadioToken());

View file

@ -39,6 +39,7 @@ class LavalinkManagerTest {
3000,
30,
24,
null,
lavalinkConfig);
var manager = new LavalinkManager(config, queueRepository);
@ -66,6 +67,7 @@ class LavalinkManagerTest {
3000,
30,
24,
null,
lavalinkConfig);
var manager = new LavalinkManager(config, queueRepository);