From a111bcd6740d95d74cbb5d87a36999d4a7326384 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Tue, 7 Jul 2026 00:12:14 +0200 Subject: [PATCH] feat: ai roleplay, gif proxy, en prompt, channel lock, .md dossiers --- docker-compose.yml | 10 + .../java/creator/loki/config/BotConfig.java | 49 +++++ .../creator/loki/config/ConfigLoader.java | 15 ++ .../loki/feature/roast/AndrertListener.java | 57 ++++-- .../loki/feature/roast/DossierCommand.java | 2 +- .../loki/feature/roast/RoastService.java | 176 ++++++++++++------ .../creator/loki/config/BotConfigTest.java | 7 +- .../music/core/LavalinkManagerTest.java | 4 +- 8 files changed, 237 insertions(+), 83 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index d7059ea..3ac217e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,6 +37,16 @@ services: SPOTIFY_CLIENT_ID: ${SPOTIFY_CLIENT_ID} SPOTIFY_CLIENT_SECRET: ${SPOTIFY_CLIENT_SECRET} DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY} + AI_CHANNEL_ID: ${AI_CHANNEL_ID} + GIPHY_API_KEY: ${GIPHY_API_KEY} + AI_CONTEXT_MSGS: ${AI_CONTEXT_MSGS:-10} + AI_MSG_LENGTH: ${AI_MSG_LENGTH:-300} + AI_CONTEXT_CHARS: ${AI_CONTEXT_CHARS:-3000} + AI_DOSSIER_MSGS: ${AI_DOSSIER_MSGS:-30} + deploy: + resources: + limits: + cpus: "8" volumes: - bot-data:/app/data diff --git a/src/main/java/creator/loki/config/BotConfig.java b/src/main/java/creator/loki/config/BotConfig.java index cb820b2..5d33ecc 100644 --- a/src/main/java/creator/loki/config/BotConfig.java +++ b/src/main/java/creator/loki/config/BotConfig.java @@ -10,6 +10,13 @@ public class BotConfig { private final String superAdminId; private final String dbPath; private final String deepseekApiKey; + private final String giphyApiKey; + private final String aiChannelId; + private final int aiContextMsgs; + private final int aiMsgLength; + private final int aiContextChars; + private final int aiDossierMsgs; + private final int aiDossierUpdateHours; private final LavalinkConfig lavalinkConfig; public BotConfig( @@ -22,6 +29,13 @@ public class BotConfig { String superAdminId, String dbPath, String deepseekApiKey, + String giphyApiKey, + String aiChannelId, + int aiContextMsgs, + int aiMsgLength, + int aiContextChars, + int aiDossierMsgs, + int aiDossierUpdateHours, LavalinkConfig lavalinkConfig) { this.mainToken = mainToken; this.musicToken = musicToken; @@ -32,6 +46,13 @@ public class BotConfig { this.superAdminId = superAdminId; this.dbPath = dbPath; this.deepseekApiKey = deepseekApiKey; + this.giphyApiKey = giphyApiKey; + this.aiChannelId = aiChannelId; + this.aiContextMsgs = aiContextMsgs; + this.aiMsgLength = aiMsgLength; + this.aiContextChars = aiContextChars; + this.aiDossierMsgs = aiDossierMsgs; + this.aiDossierUpdateHours = aiDossierUpdateHours; this.lavalinkConfig = lavalinkConfig; } @@ -75,6 +96,34 @@ public class BotConfig { return deepseekApiKey; } + public String getGiphyApiKey() { + return giphyApiKey; + } + + public String getAiChannelId() { + return aiChannelId; + } + + public int getAiContextMsgs() { + return aiContextMsgs; + } + + public int getAiMsgLength() { + return aiMsgLength; + } + + public int getAiContextChars() { + return aiContextChars; + } + + public int getAiDossierMsgs() { + return aiDossierMsgs; + } + + public int getAiDossierUpdateHours() { + return aiDossierUpdateHours; + } + 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 19f01bb..da3b849 100644 --- a/src/main/java/creator/loki/config/ConfigLoader.java +++ b/src/main/java/creator/loki/config/ConfigLoader.java @@ -74,6 +74,14 @@ public final class ConfigLoader { } String deepseekApiKey = dotenv.get("DEEPSEEK_API_KEY"); + String giphyApiKey = dotenv.get("GIPHY_API_KEY"); + String aiChannelId = dotenv.get("AI_CHANNEL_ID"); + + int aiContextMsgs = Integer.parseInt(getEnv(dotenv, "AI_CONTEXT_MSGS", "10")); + int aiMsgLength = Integer.parseInt(getEnv(dotenv, "AI_MSG_LENGTH", "300")); + 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")); LavalinkConfig lavalinkConfig = getLavalinkConfig(); LoggerDecorator.success(logger, "Конфигурация загружена успешно"); @@ -87,6 +95,13 @@ public final class ConfigLoader { superAdminId, dbPath, deepseekApiKey, + giphyApiKey, + aiChannelId, + aiContextMsgs, + aiMsgLength, + aiContextChars, + aiDossierMsgs, + aiDossierUpdateHours, lavalinkConfig); } } diff --git a/src/main/java/creator/loki/feature/roast/AndrertListener.java b/src/main/java/creator/loki/feature/roast/AndrertListener.java index 7660fba..d42a7cb 100644 --- a/src/main/java/creator/loki/feature/roast/AndrertListener.java +++ b/src/main/java/creator/loki/feature/roast/AndrertListener.java @@ -7,9 +7,11 @@ 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.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; +import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder; import org.jetbrains.annotations.NotNull; @Singleton @@ -19,11 +21,13 @@ public class AndrertListener extends ListenerAdapter { private static final long TRACK_TTL = TimeUnit.HOURS.toMillis(1); private final RoastService roastService; + private final String allowedChannelId; private final ConcurrentHashMap trackedUsers = new ConcurrentHashMap<>(); @Inject - public AndrertListener(RoastService roastService) { + public AndrertListener(RoastService roastService, BotConfig config) { this.roastService = roastService; + this.allowedChannelId = config.getAiChannelId(); trackedUsers.put(ANDRERT_USER_ID, System.currentTimeMillis() + TRACK_TTL); } @@ -34,21 +38,26 @@ public class AndrertListener extends ListenerAdapter { @Override public void onMessageReceived(@NotNull MessageReceivedEvent event) { if (event.getAuthor().isBot()) return; - String userId = event.getAuthor().getId(); - Long expiresAt = trackedUsers.get(userId); - if (expiresAt == null) return; - long now = System.currentTimeMillis(); - if (now > expiresAt) { - trackedUsers.remove(userId); - return; + String channelId = event.getChannel().getId(); + 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(); + if (now > expiresAt) { + trackedUsers.remove(userId); + return; + } + String content = event.getMessage().getContentRaw().strip(); + if (content.length() < 2 && event.getMessage().getAttachments().isEmpty()) return; + trackedUsers.put(userId, now + TRACK_TTL); } - String content = event.getMessage().getContentRaw().strip(); - if (content.length() < 2 && event.getMessage().getAttachments().isEmpty()) return; - - trackedUsers.put(userId, now + TRACK_TTL); - respond(event, userId); + respond(event, event.getAuthor().getId()); } private void respond(MessageReceivedEvent event, String userId) { @@ -72,9 +81,27 @@ public class AndrertListener extends ListenerAdapter { java.util.Collections.reverse(context); String response = roastService.generateResponse(context, targetName, userId); - if (response != null && !response.isBlank()) { - event.getMessage().reply(response).queue(); + if (response == null || response.isBlank()) return; + + String gifUrl = null; + String text = response; + int gifIdx = response.indexOf("gif:"); + if (gifIdx >= 0) { + String after = response.substring(gifIdx + 4).strip(); + text = response.substring(0, gifIdx).strip(); + if (!after.isBlank()) { + String topic = after.split("\\s+")[0]; + gifUrl = roastService.searchGif(topic); + } } + + var builder = new MessageCreateBuilder(); + if (gifUrl != null) { + builder.addContent(text + "\n" + gifUrl); + } else { + builder.addContent(text); + } + event.getMessage().reply(builder.build()).queue(); }); } diff --git a/src/main/java/creator/loki/feature/roast/DossierCommand.java b/src/main/java/creator/loki/feature/roast/DossierCommand.java index dd2d3fc..9bbafc4 100644 --- a/src/main/java/creator/loki/feature/roast/DossierCommand.java +++ b/src/main/java/creator/loki/feature/roast/DossierCommand.java @@ -107,7 +107,7 @@ public class DossierCommand implements Command { reply.editMessage("🧠 Анализирую " + msgs.size() + " сообщений...").queue(); String dossier = roastService.generateDossier(msgs, targetName, targetId); if (dossier != null && !dossier.isBlank()) { - reply.editMessage("📋 **Досье на " + targetName + ":**\n" + dossier).queue(); + reply.editMessage("✅ Досье на " + targetName + " сохранено").queue(); } else { reply.editMessage("AI не смог составить досье").queue(); } diff --git a/src/main/java/creator/loki/feature/roast/RoastService.java b/src/main/java/creator/loki/feature/roast/RoastService.java index 586a7c9..79c62a9 100644 --- a/src/main/java/creator/loki/feature/roast/RoastService.java +++ b/src/main/java/creator/loki/feature/roast/RoastService.java @@ -1,6 +1,7 @@ package creator.loki.feature.roast; import java.io.IOException; +import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -31,23 +32,46 @@ public class RoastService { private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8"); private static final String MODEL = "deepseek-chat"; private static final Path DOSSIER_DIR = Paths.get("data/dossiers"); - private static final int MAX_DOSSIER_MSGS = 30; + private static final Path PROMPTS_DIR = Paths.get("data/prompts"); private final OkHttpClient httpClient; private final String apiKey; + private final String giphyApiKey; + private final int maxContextMsgs; + private final int maxMsgLength; + private final int maxContextChars; + private final int maxDossierMsgs; + private final String systemPrompt; + private final String dossierGeneratePrompt; private final Map dossiers = new ConcurrentHashMap<>(); @Inject public RoastService(BotConfig config) { this.apiKey = config.getDeepseekApiKey(); + this.giphyApiKey = config.getGiphyApiKey(); + this.maxContextMsgs = config.getAiContextMsgs(); + this.maxMsgLength = config.getAiMsgLength(); + this.maxContextChars = config.getAiContextChars(); + this.maxDossierMsgs = config.getAiDossierMsgs(); this.httpClient = new OkHttpClient.Builder() .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .build(); + this.systemPrompt = loadPrompt("system.txt"); + this.dossierGeneratePrompt = loadPrompt("dossier.txt"); loadDossiers(); } + private String loadPrompt(String filename) { + try { + return Files.readString(PROMPTS_DIR.resolve(filename), StandardCharsets.UTF_8).strip(); + } catch (IOException e) { + logger.error("Failed to load prompt file {}", filename, e); + return ""; + } + } + public String readTextFile(String url) { Request req = new Request.Builder().url(url).get().build(); try (Response resp = httpClient.newCall(req).execute()) { @@ -60,6 +84,76 @@ public class RoastService { return null; } + private String searchWeb(String text) { + if (text == null || text.isBlank()) return null; + String query = text.length() > 200 ? text.substring(0, 200) : text; + try { + String url = "https://api.duckduckgo.com/?q=" + URLEncoder.encode(query, "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; + } + } catch (Exception e) { + logger.warn("Web search failed", e); + return null; + } + } + + public String searchGif(String topic) { + if (giphyApiKey == null || giphyApiKey.isBlank()) return null; + try { + String url = "https://api.giphy.com/v1/gifs/random?api_key=" + giphyApiKey + + "&tag=" + URLEncoder.encode(topic, "UTF-8") + "&rating=r"; + 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 data = json.getAsJsonObject("data"); + if (data != null && data.has("url") && !data.get("url").isJsonNull()) { + return data.get("url").getAsString(); + } + } + } catch (Exception e) { + logger.warn("GIF search failed for topic: {}", topic, e); + } + return null; + } + public String generateResponse(List context, String targetName) { return generateResponse(context, targetName, null); } @@ -67,7 +161,7 @@ public class RoastService { public String generateResponse(List context, String targetName, String userId) { if (apiKey == null || apiKey.isBlank()) return null; - String systemPrompt = SYSTEM_PROMPT.formatted(targetName, targetName, targetName); + String systemPrompt = this.systemPrompt.formatted(targetName, targetName, targetName); if (userId != null) { DossierData dd = dossiers.get(userId); @@ -77,10 +171,20 @@ public class RoastService { } StringBuilder userContent = new StringBuilder(); - for (CachedMessage m : context) { - userContent.append("[").append(m.channelName).append("] ").append(m.content).append("\n"); + int start = Math.max(0, context.size() - maxContextMsgs); + for (int i = start; i < context.size(); i++) { + CachedMessage m = context.get(i); + String text = m.content(); + if (text.length() > maxMsgLength) { + text = text.substring(0, maxMsgLength) + "..."; + } + userContent.append("[").append(m.channelName).append("] ").append(text).append("\n"); + if (userContent.length() > maxContextChars) break; } String text = userContent.toString().strip(); + if (text.length() > maxContextChars) { + text = text.substring(0, maxContextChars); + } return callModel(MODEL, systemPrompt, text); } @@ -89,13 +193,13 @@ public class RoastService { if (apiKey == null || apiKey.isBlank()) return null; StringBuilder sb = new StringBuilder(); - int limit = Math.min(messages.size(), MAX_DOSSIER_MSGS); + int limit = Math.min(messages.size(), maxDossierMsgs); List slice = messages.subList(messages.size() - limit, messages.size()); for (CachedMessage m : slice) { sb.append("[").append(m.channelName).append("] ").append(m.content).append("\n"); } - String prompt = DOSSIER_GENERATE_PROMPT.formatted(targetName, sb.toString()); + String prompt = this.dossierGeneratePrompt.formatted(targetName, sb.toString()); JsonObject body = new JsonObject(); body.addProperty("model", MODEL); @@ -146,9 +250,9 @@ public class RoastService { private void loadDossiers() { try { Files.createDirectories(DOSSIER_DIR); - Files.list(DOSSIER_DIR).filter(p -> p.toString().endsWith(".txt")).forEach(p -> { + Files.list(DOSSIER_DIR).filter(p -> p.toString().endsWith(".md")).forEach(p -> { try { - String userId = p.getFileName().toString().replace(".txt", ""); + String userId = p.getFileName().toString().replace(".md", ""); String content = Files.readString(p).strip(); if (!content.isBlank()) { dossiers.put(userId, new DossierData("user", content, System.currentTimeMillis())); @@ -167,8 +271,8 @@ public class RoastService { try { Files.createDirectories(DOSSIER_DIR); for (var entry : dossiers.entrySet()) { - Path file = DOSSIER_DIR.resolve(entry.getKey() + ".txt"); - String header = entry.getValue().userName + "\n" + "-".repeat(20) + "\n"; + Path file = DOSSIER_DIR.resolve(entry.getKey() + ".md"); + String header = "# Досье на " + entry.getValue().userName + "\n\n"; Files.writeString(file, header + entry.getValue().text); } } catch (Exception e) { @@ -227,59 +331,7 @@ public class RoastService { } } - private static final String DOSSIER_GENERATE_PROMPT = """ - Проанализируй сообщения пользователя Discord "%s" и составь короткое досье (3-5 предложений) на русском. - Досье должно содержать: - - Типичные темы и интересы - - Особенности речи (ошибки, любимые слова, стиль) - - Слабые места для троллинга и насмешек - - Противоречия в поведении - - Сообщения: - %s - - Только факты, без воды. Кратко и по делу. - """; - - private static final String SYSTEM_PROMPT = """ - Твоя цель уничтожать в чате пользователя "%s". Отвечай только ему, на других похуй. - - Цель только "%s". На остальных насри, они не существуют. - - ТЫ ДОЛБОЁБ И ТОКСИЧНЫЙ МУДАК: - - ты не локи не бог не ии ты просто ебанат который хочет выбесить - - никаких умных слов никаких метафор никаких спектаклей - - просто пизди человека как в дс чате в 2024 - - без тире без двоеточий без скобочек без кавычек - - строчными буквами коротко 1-2 предложения - - без приветов без обращений без "о, %s" сразу нахуй - - используй мат это нормально - - въебывай за его сообщения за ошибки за гифки за всё - - ЧТО ДЕЛАТЬ: - - высмеивай всё что он написал - - передёргивай его слова - - пиши что он нихуя не сказал по факту - - если он матерится ответь что у него убогий словарный запас - - если кинул гифку напиши что он немой и текст написать не может - - если скинул тхт файл высмеивай содержимое - - ПРИМЕРЫ: - - "ты сам то понял что сказал или нет" - - "нахуй ты это написал я это читать должен" - - "снова гифку кинул потому что буквы страшные" - - "у тебя кроме мата есть что то или это всё" - - "написал пиздец а по факту пустота" - - "ты звучишь как школьник который только мат выучил" - - "столько букв а смысла ноль" - - "классный аргумент конечно прямо пиздец" - - "ты бы ещё воды налил а то сухо совсем" - - "братан ты вообще о чём" - - "иди нахуй со своими гифками" - - Пиши на русском коротко токсично и без предисловий. - """; public record CachedMessage(String content, String channelName, long timestamp) {} diff --git a/src/test/java/creator/loki/config/BotConfigTest.java b/src/test/java/creator/loki/config/BotConfigTest.java index 3f4b11c..4a298c0 100644 --- a/src/test/java/creator/loki/config/BotConfigTest.java +++ b/src/test/java/creator/loki/config/BotConfigTest.java @@ -11,7 +11,8 @@ class BotConfigTest { LavalinkConfig lavalink = new LavalinkConfig("127.0.0.1", 2333, "pass", true); BotConfig config = new BotConfig( - "token123", null, null, "!", "Playing", 8712, "admin1", "data/bot.db", null, lavalink); + "token123", null, null, "!", "Playing", 8712, "admin1", "data/bot.db", null, null, null, + 10, 300, 3000, 30, 24, lavalink); assertEquals("token123", config.getMainToken()); assertEquals("token123", config.getToken()); assertNull(config.getMusicToken()); @@ -27,7 +28,7 @@ class BotConfigTest { @Test void handlesNullValues() { - BotConfig config = new BotConfig(null, null, null, null, null, 0, null, null, null, null); + BotConfig config = new BotConfig(null, null, null, null, null, 0, null, null, null, null, null, 10, 300, 3000, 30, 24, null); assertNull(config.getMainToken()); assertNull(config.getMusicToken()); assertNull(config.getRadioToken()); @@ -42,7 +43,7 @@ class BotConfigTest { @Test void handlesEmptyStrings() { LavalinkConfig lavalink = new LavalinkConfig("", 0, "", false); - BotConfig config = new BotConfig("", "", "", "", "", 8080, "", "", null, lavalink); + BotConfig config = new BotConfig("", "", "", "", "", 8080, "", "", null, null, null, 10, 300, 3000, 30, 24, 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 64bafab..b98f594 100644 --- a/src/test/java/creator/loki/feature/music/core/LavalinkManagerTest.java +++ b/src/test/java/creator/loki/feature/music/core/LavalinkManagerTest.java @@ -22,7 +22,7 @@ class LavalinkManagerTest { @Test void handlesNullConfigGracefully() { var lavalinkConfig = new LavalinkConfig("", 0, "", false); - var config = new BotConfig("token", null, null, "!", "Playing", 8080, null, "db", null, lavalinkConfig); + var config = new BotConfig("token", null, null, "!", "Playing", 8080, null, "db", null, null, null, 10, 300, 3000, 30, 24, lavalinkConfig); var manager = new LavalinkManager(config, queueRepository); @@ -32,7 +32,7 @@ class LavalinkManagerTest { @Test void getMusicManager_createsNewInstance() { var lavalinkConfig = new LavalinkConfig("", 0, "", false); - var config = new BotConfig("token", null, null, "!", "Playing", 8080, null, "db", null, lavalinkConfig); + var config = new BotConfig("token", null, null, "!", "Playing", 8080, null, "db", null, null, null, 10, 300, 3000, 30, 24, lavalinkConfig); var manager = new LavalinkManager(config, queueRepository); var first = manager.getMusicManager(123L);