diff --git a/.env.example b/.env.example index 58c3562..202b025 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,9 @@ DB_PATH=data/bot.db # DeepSeek AI API (для roast) DEEPSEEK_API_KEY=your_deepseek_key +# Debate channel ID для мульти-AI диспутов +AI_DEBATE_CHANNEL_ID=your_debate_channel_id + # Lavalink LAVALINK_HOST=127.0.0.1 diff --git a/Dockerfile b/Dockerfile index e79e8c5..442d3bf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,7 @@ RUN addgroup -S bot && adduser -S bot -G bot WORKDIR /app COPY --from=builder /build/build/libs/discord-bot-1.0.0.jar app.jar COPY data/prompts /app/prompts +COPY data/knowledge /app/knowledge RUN mkdir -p /app/data /app/logs && chown -R bot:bot /app USER bot VOLUME /app/data diff --git a/docker-compose.yml b/docker-compose.yml index a6d3d89..95da015 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -42,12 +42,15 @@ services: AI_MSG_LENGTH: ${AI_MSG_LENGTH:-300} AI_CONTEXT_CHARS: ${AI_CONTEXT_CHARS:-3000} AI_DOSSIER_MSGS: ${AI_DOSSIER_MSGS:-30} + AI_DEBATE_CHANNEL_ID: ${AI_DEBATE_CHANNEL_ID} deploy: resources: limits: cpus: "8" volumes: - bot-data:/app/data + - ./data/prompts:/app/prompts:ro + - ./data/knowledge:/app/knowledge:ro volumes: bot-data: diff --git a/src/main/java/creator/loki/command/CommandCategories.java b/src/main/java/creator/loki/command/CommandCategories.java index b4e3cdc..2da17d1 100644 --- a/src/main/java/creator/loki/command/CommandCategories.java +++ b/src/main/java/creator/loki/command/CommandCategories.java @@ -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() {} } diff --git a/src/main/java/creator/loki/config/BotConfig.java b/src/main/java/creator/loki/config/BotConfig.java index 84a9594..ef37a09 100644 --- a/src/main/java/creator/loki/config/BotConfig.java +++ b/src/main/java/creator/loki/config/BotConfig.java @@ -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; } diff --git a/src/main/java/creator/loki/config/ConfigLoader.java b/src/main/java/creator/loki/config/ConfigLoader.java index 6f7ee78..e9d76a8 100644 --- a/src/main/java/creator/loki/config/ConfigLoader.java +++ b/src/main/java/creator/loki/config/ConfigLoader.java @@ -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); } } diff --git a/src/main/java/creator/loki/core/BotInitializer.java b/src/main/java/creator/loki/core/BotInitializer.java index 6913918..c0b92f3 100644 --- a/src/main/java/creator/loki/core/BotInitializer.java +++ b/src/main/java/creator/loki/core/BotInitializer.java @@ -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(); diff --git a/src/main/java/creator/loki/core/BotJdaRegistry.java b/src/main/java/creator/loki/core/BotJdaRegistry.java new file mode 100644 index 0000000..34b49aa --- /dev/null +++ b/src/main/java/creator/loki/core/BotJdaRegistry.java @@ -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; + }; + } +} diff --git a/src/main/java/creator/loki/core/ListenerAggregator.java b/src/main/java/creator/loki/core/ListenerAggregator.java index 695c551..5bddf7f 100644 --- a/src/main/java/creator/loki/core/ListenerAggregator.java +++ b/src/main/java/creator/loki/core/ListenerAggregator.java @@ -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 }; } diff --git a/src/main/java/creator/loki/di/BotModule.java b/src/main/java/creator/loki/di/BotModule.java index c89fe96..e8b151b 100644 --- a/src/main/java/creator/loki/di/BotModule.java +++ b/src/main/java/creator/loki/di/BotModule.java @@ -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 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 diff --git a/src/main/java/creator/loki/embed/HelpCategories.java b/src/main/java/creator/loki/embed/HelpCategories.java index 6858a0d..2f2c409 100644 --- a/src/main/java/creator/loki/embed/HelpCategories.java +++ b/src/main/java/creator/loki/embed/HelpCategories.java @@ -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; } } diff --git a/src/main/java/creator/loki/feature/help/HelpMenuHandler.java b/src/main/java/creator/loki/feature/help/HelpMenuHandler.java index fff3579..40f32d5 100644 --- a/src/main/java/creator/loki/feature/help/HelpMenuHandler.java +++ b/src/main/java/creator/loki/feature/help/HelpMenuHandler.java @@ -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; + } } diff --git a/src/main/java/creator/loki/feature/help/commands/HelpCommand.java b/src/main/java/creator/loki/feature/help/commands/HelpCommand.java index eee9237..b8c02ba 100644 --- a/src/main/java/creator/loki/feature/help/commands/HelpCommand.java +++ b/src/main/java/creator/loki/feature/help/commands/HelpCommand.java @@ -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> 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; } diff --git a/src/main/java/creator/loki/feature/image/ImageCommandRegistrar.java b/src/main/java/creator/loki/feature/image/ImageCommandRegistrar.java new file mode 100644 index 0000000..babd4a2 --- /dev/null +++ b/src/main/java/creator/loki/feature/image/ImageCommandRegistrar.java @@ -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)); + } +} diff --git a/src/main/java/creator/loki/feature/image/ImageModule.java b/src/main/java/creator/loki/feature/image/ImageModule.java new file mode 100644 index 0000000..0bd01f6 --- /dev/null +++ b/src/main/java/creator/loki/feature/image/ImageModule.java @@ -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(); + } +} diff --git a/src/main/java/creator/loki/feature/image/ImageService.java b/src/main/java/creator/loki/feature/image/ImageService.java new file mode 100644 index 0000000..9c4de24 --- /dev/null +++ b/src/main/java/creator/loki/feature/image/ImageService.java @@ -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 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 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 fetchR34(String tags, int limit) { + String url = + R34_API + "&tags=" + encodeTags(tags) + "&limit=" + Math.min(limit, 10); + return fetchBooruJson(url, limit); + } + + public List 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 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 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"); + } +} diff --git a/src/main/java/creator/loki/feature/image/commands/E621Command.java b/src/main/java/creator/loki/feature/image/commands/E621Command.java new file mode 100644 index 0000000..30e14b5 --- /dev/null +++ b/src/main/java/creator/loki/feature/image/commands/E621Command.java @@ -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 urls = imageService.fetchE621(tags, limit); + if (urls.isEmpty()) { + msg.editMessageEmbeds( + EmbedTemplates.warning( + "😔 Ничего не найдено", + "По запросу" + tagsDesc(tags) + " ничего не найдено.")) + .queue(); + return; + } + + msg.delete().queue(); + List embeds = buildEmbeds(urls); + for (int i = 0; i < embeds.size(); i += 10) { + context + .getChannel() + .sendMessageEmbeds( + embeds.subList(i, Math.min(i + 10, embeds.size()))) + .queue(); + } + }); + } + + private ParseResult parseArgs(List args) { + if (args.isEmpty()) { + return new ParseResult(1, ""); + } + try { + int count = Integer.parseInt(args.get(0)); + if (count < 1) count = 1; + if (count > 10) count = 10; + String tagStr = args.size() > 1 ? String.join(" ", args.subList(1, args.size())) : ""; + return new ParseResult(count, tagStr); + } catch (NumberFormatException e) { + return new ParseResult(1, String.join(" ", args)); + } + } + + private List buildEmbeds(List urls) { + List embeds = new ArrayList<>(); + for (String url : urls) { + embeds.add( + EmbedFactory.create() + .withTitle("🔞 e621.net") + .withImage(url) + .withColor(new Color(102, 45, 145)) + .build()); + } + return embeds; + } + + private boolean isNsfwChannel(CommandContext context) { + ChannelType type = context.getChannel().getType(); + return type == ChannelType.TEXT + || type == ChannelType.NEWS + || type == ChannelType.FORUM + || type == ChannelType.MEDIA; + } + + private static String tagsDesc(String tags) { + return tags.isBlank() ? "" : " `" + tags + "`"; + } + + private record ParseResult(int limit, String tags) {} +} diff --git a/src/main/java/creator/loki/feature/image/commands/GelbooruCommand.java b/src/main/java/creator/loki/feature/image/commands/GelbooruCommand.java new file mode 100644 index 0000000..004130c --- /dev/null +++ b/src/main/java/creator/loki/feature/image/commands/GelbooruCommand.java @@ -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 urls = imageService.fetchGelbooru(tags, limit); + if (urls.isEmpty()) { + msg.editMessageEmbeds( + EmbedTemplates.warning( + "😔 Ничего не найдено", + "По запросу" + tagsDesc(tags) + " ничего не найдено.")) + .queue(); + return; + } + + msg.delete().queue(); + List embeds = buildEmbeds(urls); + for (int i = 0; i < embeds.size(); i += 10) { + context + .getChannel() + .sendMessageEmbeds( + embeds.subList(i, Math.min(i + 10, embeds.size()))) + .queue(); + } + }); + } + + private ParseResult parseArgs(List args) { + if (args.isEmpty()) { + return new ParseResult(1, ""); + } + try { + int count = Integer.parseInt(args.get(0)); + if (count < 1) count = 1; + if (count > 10) count = 10; + String tagStr = args.size() > 1 ? String.join(" ", args.subList(1, args.size())) : ""; + return new ParseResult(count, tagStr); + } catch (NumberFormatException e) { + return new ParseResult(1, String.join(" ", args)); + } + } + + private List buildEmbeds(List urls) { + List embeds = new ArrayList<>(); + for (String url : urls) { + embeds.add( + EmbedFactory.create() + .withTitle("🔞 gelbooru.com") + .withImage(url) + .withColor(new Color(50, 100, 180)) + .build()); + } + return embeds; + } + + private boolean isNsfwChannel(CommandContext context) { + ChannelType type = context.getChannel().getType(); + return type == ChannelType.TEXT + || type == ChannelType.NEWS + || type == ChannelType.FORUM + || type == ChannelType.MEDIA; + } + + private static String tagsDesc(String tags) { + return tags.isBlank() ? "" : " `" + tags + "`"; + } + + private record ParseResult(int limit, String tags) {} +} diff --git a/src/main/java/creator/loki/feature/image/commands/R34Command.java b/src/main/java/creator/loki/feature/image/commands/R34Command.java new file mode 100644 index 0000000..50b3a2f --- /dev/null +++ b/src/main/java/creator/loki/feature/image/commands/R34Command.java @@ -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 urls = imageService.fetchR34(tags, limit); + if (urls.isEmpty()) { + msg.editMessageEmbeds( + EmbedTemplates.warning( + "😔 Ничего не найдено", + "По запросу" + tagsDesc(tags) + " ничего не найдено.")) + .queue(); + return; + } + + msg.delete().queue(); + List embeds = buildEmbeds(urls); + for (int i = 0; i < embeds.size(); i += 10) { + context + .getChannel() + .sendMessageEmbeds( + embeds.subList(i, Math.min(i + 10, embeds.size()))) + .queue(); + } + }); + } + + private ParseResult parseArgs(List args) { + if (args.isEmpty()) { + return new ParseResult(1, ""); + } + try { + int count = Integer.parseInt(args.get(0)); + if (count < 1) count = 1; + if (count > 10) count = 10; + String tagStr = args.size() > 1 ? String.join(" ", args.subList(1, args.size())) : ""; + return new ParseResult(count, tagStr); + } catch (NumberFormatException e) { + return new ParseResult(1, String.join(" ", args)); + } + } + + private List buildEmbeds(List urls) { + List embeds = new ArrayList<>(); + for (String url : urls) { + embeds.add( + EmbedFactory.create() + .withTitle("🔞 rule34.xxx") + .withImage(url) + .withColor(new Color(200, 100, 0)) + .build()); + } + return embeds; + } + + private boolean isNsfwChannel(CommandContext context) { + ChannelType type = context.getChannel().getType(); + return type == ChannelType.TEXT + || type == ChannelType.NEWS + || type == ChannelType.FORUM + || type == ChannelType.MEDIA; + } + + private static String tagsDesc(String tags) { + return tags.isBlank() ? "" : " `" + tags + "`"; + } + + private record ParseResult(int limit, String tags) {} +} diff --git a/src/main/java/creator/loki/feature/moderation/commands/utils/ClearSlashCommands.java b/src/main/java/creator/loki/feature/moderation/commands/utils/ClearSlashCommands.java index fb7447e..d5aba6e 100644 --- a/src/main/java/creator/loki/feature/moderation/commands/utils/ClearSlashCommands.java +++ b/src/main/java/creator/loki/feature/moderation/commands/utils/ClearSlashCommands.java @@ -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; diff --git a/src/main/java/creator/loki/feature/music/MusicSlashCommands.java b/src/main/java/creator/loki/feature/music/MusicSlashCommands.java index 64aed74..a249d64 100644 --- a/src/main/java/creator/loki/feature/music/MusicSlashCommands.java +++ b/src/main/java/creator/loki/feature/music/MusicSlashCommands.java @@ -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(); } diff --git a/src/main/java/creator/loki/feature/roast/AndrertListener.java b/src/main/java/creator/loki/feature/roast/AndrertListener.java index 0d07477..cfcc85d 100644 --- a/src/main/java/creator/loki/feature/roast/AndrertListener.java +++ b/src/main/java/creator/loki/feature/roast/AndrertListener.java @@ -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 trackedUsers = new ConcurrentHashMap<>(); + private final ConcurrentHashMap lastAiReplyTime = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> 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 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(); diff --git a/src/main/java/creator/loki/feature/roast/ContentFetcher.java b/src/main/java/creator/loki/feature/roast/ContentFetcher.java index efaed25..bec3342 100644 --- a/src/main/java/creator/loki/feature/roast/ContentFetcher.java +++ b/src/main/java/creator/loki/feature/roast/ContentFetcher.java @@ -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; diff --git a/src/main/java/creator/loki/feature/roast/DebateChannelListener.java b/src/main/java/creator/loki/feature/roast/DebateChannelListener.java new file mode 100644 index 0000000..bbee23d --- /dev/null +++ b/src/main/java/creator/loki/feature/roast/DebateChannelListener.java @@ -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 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); + } +} diff --git a/src/main/java/creator/loki/feature/roast/DebateService.java b/src/main/java/creator/loki/feature/roast/DebateService.java new file mode 100644 index 0000000..15efdc8 --- /dev/null +++ b/src/main/java/creator/loki/feature/roast/DebateService.java @@ -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 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 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 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 buildContext(List messages) { + String archId = getBotId("arch"); + String winId = getBotId("windows"); + String gentooId = getBotId("gentoo"); + + List 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; + } + } + } +} diff --git a/src/main/java/creator/loki/feature/roast/DeepSeekClient.java b/src/main/java/creator/loki/feature/roast/DeepSeekClient.java index 07c49ff..e0ad027 100644 --- a/src/main/java/creator/loki/feature/roast/DeepSeekClient.java +++ b/src/main/java/creator/loki/feature/roast/DeepSeekClient.java @@ -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 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); diff --git a/src/main/java/creator/loki/feature/roast/HelperListener.java b/src/main/java/creator/loki/feature/roast/HelperListener.java new file mode 100644 index 0000000..a9c2953 --- /dev/null +++ b/src/main/java/creator/loki/feature/roast/HelperListener.java @@ -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 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 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(); + } +} diff --git a/src/main/java/creator/loki/feature/roast/KnowledgeService.java b/src/main/java/creator/loki/feature/roast/KnowledgeService.java new file mode 100644 index 0000000..f37a756 --- /dev/null +++ b/src/main/java/creator/loki/feature/roast/KnowledgeService.java @@ -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 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 files = new ArrayList<>(); + + public KnowledgeService() { + loadFiles(); + } + + private void loadFiles() { + try { + Files.createDirectories(KNOWLEDGE_DIR); + List 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 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; + } + } +} diff --git a/src/main/java/creator/loki/feature/roast/RoastModule.java b/src/main/java/creator/loki/feature/roast/RoastModule.java index 044938f..d465273 100644 --- a/src/main/java/creator/loki/feature/roast/RoastModule.java +++ b/src/main/java/creator/loki/feature/roast/RoastModule.java @@ -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); } } diff --git a/src/main/java/creator/loki/feature/roast/RoastService.java b/src/main/java/creator/loki/feature/roast/RoastService.java index 1149e33..c466d52 100644 --- a/src/main/java/creator/loki/feature/roast/RoastService.java +++ b/src/main/java/creator/loki/feature/roast/RoastService.java @@ -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 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 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); diff --git a/src/main/java/creator/loki/feature/roast/WebSearchService.java b/src/main/java/creator/loki/feature/roast/WebSearchService.java index 15b6658..8fe3a26 100644 --- a/src/main/java/creator/loki/feature/roast/WebSearchService.java +++ b/src/main/java/creator/loki/feature/roast/WebSearchService.java @@ -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 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 searchDdg(String query) { + List 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) {} } diff --git a/src/main/java/creator/loki/feature/roast/WikipediaService.java b/src/main/java/creator/loki/feature/roast/WikipediaService.java new file mode 100644 index 0000000..f29a083 --- /dev/null +++ b/src/main/java/creator/loki/feature/roast/WikipediaService.java @@ -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; + } +} diff --git a/src/test/java/creator/loki/config/BotConfigTest.java b/src/test/java/creator/loki/config/BotConfigTest.java index 2da80b8..081a952 100644 --- a/src/test/java/creator/loki/config/BotConfigTest.java +++ b/src/test/java/creator/loki/config/BotConfigTest.java @@ -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()); diff --git a/src/test/java/creator/loki/feature/music/core/LavalinkManagerTest.java b/src/test/java/creator/loki/feature/music/core/LavalinkManagerTest.java index 7e5c0c6..11fa051 100644 --- a/src/test/java/creator/loki/feature/music/core/LavalinkManagerTest.java +++ b/src/test/java/creator/loki/feature/music/core/LavalinkManagerTest.java @@ -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);