From 2990f019585af2249e5078efb8e5845b0ba0959f Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Mon, 6 Jul 2026 22:29:06 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20deepseek=20ai,=20=D0=B4=D0=BE=D1=81?= =?UTF-8?q?=D1=8C=D0=B5,=20=D0=BF=D0=B0=D1=80=D1=81=D0=B5=D1=80=20.txt,=20?= =?UTF-8?q?=D1=80=D0=BE=D0=BB=D0=B5=D0=BF=D0=BB=D0=B5=D0=B9=20roast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 + docker-compose.yml | 1 + .../java/creator/loki/config/BotConfig.java | 7 + .../creator/loki/config/ConfigLoader.java | 3 + .../creator/loki/core/BotInitializer.java | 7 +- .../creator/loki/core/ListenerAggregator.java | 8 +- src/main/java/creator/loki/di/BotModule.java | 2 + .../loki/feature/roast/AgrCommand.java | 163 ++++++++++ .../loki/feature/roast/AgrSlashCommands.java | 108 +++++++ .../loki/feature/roast/AndrertListener.java | 113 +++++++ .../loki/feature/roast/DossierCommand.java | 121 +++++++ .../loki/feature/roast/RoastModule.java | 12 + .../loki/feature/roast/RoastService.java | 300 ++++++++++++++++++ .../utility/UtilityCommandRegistrar.java | 14 +- .../creator/loki/config/BotConfigTest.java | 6 +- .../SmokeCommandRegistrationTest.java | 6 +- .../music/core/LavalinkManagerTest.java | 4 +- 17 files changed, 868 insertions(+), 10 deletions(-) create mode 100644 src/main/java/creator/loki/feature/roast/AgrCommand.java create mode 100644 src/main/java/creator/loki/feature/roast/AgrSlashCommands.java create mode 100644 src/main/java/creator/loki/feature/roast/AndrertListener.java create mode 100644 src/main/java/creator/loki/feature/roast/DossierCommand.java create mode 100644 src/main/java/creator/loki/feature/roast/RoastModule.java create mode 100644 src/main/java/creator/loki/feature/roast/RoastService.java diff --git a/.env.example b/.env.example index 05f30b3..69abb8a 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,9 @@ HEALTH_HTTP_PORT=8712 SUPER_ADMIN_ID=your_discord_user_id DB_PATH=data/bot.db +# DeepSeek AI API (для roast) +DEEPSEEK_API_KEY=your_deepseek_key + # Lavalink LAVALINK_HOST=127.0.0.1 LAVALINK_PORT=2333 diff --git a/docker-compose.yml b/docker-compose.yml index 748410a..d7059ea 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,6 +36,7 @@ services: LAVALINK_PASSWORD: Hch4HiVCxkKs9JPiSnrt4Q SPOTIFY_CLIENT_ID: ${SPOTIFY_CLIENT_ID} SPOTIFY_CLIENT_SECRET: ${SPOTIFY_CLIENT_SECRET} + DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY} 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 364814e..cb820b2 100644 --- a/src/main/java/creator/loki/config/BotConfig.java +++ b/src/main/java/creator/loki/config/BotConfig.java @@ -9,6 +9,7 @@ public class BotConfig { private final int healthHttpPort; private final String superAdminId; private final String dbPath; + private final String deepseekApiKey; private final LavalinkConfig lavalinkConfig; public BotConfig( @@ -20,6 +21,7 @@ public class BotConfig { int healthHttpPort, String superAdminId, String dbPath, + String deepseekApiKey, LavalinkConfig lavalinkConfig) { this.mainToken = mainToken; this.musicToken = musicToken; @@ -29,6 +31,7 @@ public class BotConfig { this.healthHttpPort = healthHttpPort; this.superAdminId = superAdminId; this.dbPath = dbPath; + this.deepseekApiKey = deepseekApiKey; this.lavalinkConfig = lavalinkConfig; } @@ -68,6 +71,10 @@ public class BotConfig { return dbPath; } + public String getDeepseekApiKey() { + return deepseekApiKey; + } + 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 ce7eee3..19f01bb 100644 --- a/src/main/java/creator/loki/config/ConfigLoader.java +++ b/src/main/java/creator/loki/config/ConfigLoader.java @@ -73,6 +73,8 @@ public final class ConfigLoader { dbPath = "data/bot.db"; } + String deepseekApiKey = dotenv.get("DEEPSEEK_API_KEY"); + LavalinkConfig lavalinkConfig = getLavalinkConfig(); LoggerDecorator.success(logger, "Конфигурация загружена успешно"); return new BotConfig( @@ -84,6 +86,7 @@ public final class ConfigLoader { healthHttpPort, superAdminId, dbPath, + deepseekApiKey, lavalinkConfig); } } diff --git a/src/main/java/creator/loki/core/BotInitializer.java b/src/main/java/creator/loki/core/BotInitializer.java index a050a17..b26ff0d 100644 --- a/src/main/java/creator/loki/core/BotInitializer.java +++ b/src/main/java/creator/loki/core/BotInitializer.java @@ -19,6 +19,8 @@ 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; @@ -66,7 +68,10 @@ public class BotInitializer { LoggerDecorator.startup(logger, "Инициализация main бота..."); registrar.registerAll(); JDA mainJda = buildMainJDA(); - mainJda.updateCommands().queue(); + mainJda.upsertCommand( + Commands.slash("agr", "Наслать AI на пользователя (только для админов)") + .addOption(OptionType.USER, "user", "Цель (по умолчанию Andrert_)", false) + ).queue(); muteScheduler.restorePending(mainJda); mainJda.getGuilds().forEach(g -> memberCounterListener.startScheduler(g)); healthHttpServer.start(); diff --git a/src/main/java/creator/loki/core/ListenerAggregator.java b/src/main/java/creator/loki/core/ListenerAggregator.java index 5ce334b..74665a3 100644 --- a/src/main/java/creator/loki/core/ListenerAggregator.java +++ b/src/main/java/creator/loki/core/ListenerAggregator.java @@ -6,6 +6,8 @@ import creator.loki.bootstrap.ReadyListener; import creator.loki.feature.counter.service.MemberCounterListener; import creator.loki.feature.economy.FishMessageListener; import creator.loki.feature.help.HelpMenuHandler; +import creator.loki.feature.roast.AgrSlashCommands; +import creator.loki.feature.roast.AndrertListener; import creator.loki.feature.leaderboard.LeaderboardButtonListener; import creator.loki.feature.level.listener.MessageListener; import creator.loki.feature.level.listener.ResetLevelsButtonHandler; @@ -39,7 +41,8 @@ public class ListenerAggregator { LeaderboardButtonListener leaderboardButtonListener, MessageCache messageCache, MusicSlashCommands musicSlashCommands, - FishMessageListener fishMessageListener) { + FishMessageListener fishMessageListener, + AndrertListener andrertListener) { this.listeners = new Object[] { readyListener, @@ -57,7 +60,8 @@ public class ListenerAggregator { leaderboardButtonListener, messageCache, musicSlashCommands, - fishMessageListener + fishMessageListener, + andrertListener }; } diff --git a/src/main/java/creator/loki/di/BotModule.java b/src/main/java/creator/loki/di/BotModule.java index 7ba95aa..45b2e9a 100644 --- a/src/main/java/creator/loki/di/BotModule.java +++ b/src/main/java/creator/loki/di/BotModule.java @@ -32,6 +32,7 @@ import creator.loki.feature.music.MusicModule; import creator.loki.feature.profile.ProfileModule; import creator.loki.feature.radio.RadioCommandRegistrar; import creator.loki.feature.radio.RadioModule; +import creator.loki.feature.roast.RoastModule; import creator.loki.feature.role.RoleModule; import creator.loki.feature.utility.UtilityCommandRegistrar; import creator.loki.feature.voice.VoiceModule; @@ -62,6 +63,7 @@ public class BotModule extends AbstractModule { install(new WelcomeModule()); install(new EconomyModule()); install(new RadioModule()); + install(new RoastModule()); Multibinder commandBinder = Multibinder.newSetBinder(binder(), CommandRegistrar.class); diff --git a/src/main/java/creator/loki/feature/roast/AgrCommand.java b/src/main/java/creator/loki/feature/roast/AgrCommand.java new file mode 100644 index 0000000..9e14e50 --- /dev/null +++ b/src/main/java/creator/loki/feature/roast/AgrCommand.java @@ -0,0 +1,163 @@ +package creator.loki.feature.roast; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.config.BotConfig; +import creator.loki.embed.EmbedTemplates; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; + +public class AgrCommand implements Command { + private static final String ANDRERT_USER_ID = "1405129454165954630"; + private static final int MESSAGES_PER_CHANNEL = 100; + + private final BotConfig config; + private final RoastService roastService; + private final AndrertListener autoResponder; + + public AgrCommand(BotConfig config, RoastService roastService, AndrertListener autoResponder) { + this.config = config; + this.roastService = roastService; + this.autoResponder = autoResponder; + } + + @Override + public String getName() { + return "agr"; + } + + @Override + public String getDescription() { + return "Наслать AI на пользователя (по умолчанию Andrert_). Только для админов"; + } + + @Override + public String getUsage() { + return "agr [@user]"; + } + + @Override + public String getCategory() { + return "Супер-Админ"; + } + + @Override + public void execute(CommandContext context) { + if (!config.getSuperAdminId().equals(context.getAuthor().getId())) { + context.getChannel().sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "Только супер-администратор")).queue(); + return; + } + if (context.getGuild() == null) { + context.getChannel().sendMessage("Команда работает только на сервере").queue(); + return; + } + + if (context.getArgs().isEmpty()) { + autoResponder.trackUser(ANDRERT_USER_ID); + doScan(context, ANDRERT_USER_ID, "Andrert_"); + } else { + String arg = context.getArgs().get(0); + String targetId = arg.replaceAll("<@!?(\\d+)>", "$1"); + if (targetId.equals(arg) && !arg.matches("\\d+")) { + context.getChannel().sendMessage("Укажи @упоминание или ID пользователя").queue(); + return; + } + String finalId = targetId; + context.getGuild().retrieveMemberById(targetId).queue( + member -> { + autoResponder.trackUser(finalId); + doScan(context, finalId, member.getEffectiveName()); + }, + error -> context.getChannel().sendMessage("Пользователь не найден на сервере").queue()); + } + } + + private void doScan(CommandContext context, String targetId, String targetName) { + context.getMessage().reply("🔍 Сканирую сообщения " + targetName + "...") + .queue(reply -> { + List textChannels = context.getGuild().getTextChannels(); + List>> futures = new ArrayList<>(); + + for (TextChannel ch : textChannels) { + CompletableFuture> future = new CompletableFuture<>(); + futures.add(future); + ch.getHistory().retrievePast(MESSAGES_PER_CHANNEL).queue(future::complete, e -> future.complete(List.of())); + } + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .thenApply(v -> { + List allMessages = new ArrayList<>(); + for (int i = 0; i < textChannels.size(); i++) { + List msgs = futures.get(i).join(); + String chName = textChannels.get(i).getName(); + for (Message msg : msgs) { + if (msg.getAuthor().getId().equals(targetId)) { + String text = msg.getContentRaw() + replyInfo(msg) + attachmentInfo(msg); + allMessages.add(new RoastService.CachedMessage( + text, chName, msg.getTimeCreated().toEpochSecond() * 1000)); + } + } + } + allMessages.sort((a, b) -> Long.compare(a.timestamp(), b.timestamp())); + if (allMessages.size() > 10) { + allMessages = allMessages.subList(allMessages.size() - 10, allMessages.size()); + } + return allMessages; + }) + .thenAccept(msgs -> { + if (msgs.isEmpty()) { + reply.editMessage("Нет сообщений от " + targetName).queue(); + return; + } + String response = roastService.generateResponse(msgs, targetName, targetId); + if (response == null || response.isBlank()) { + reply.editMessage("AI не ответил").queue(); + return; + } + reply.editMessage(response).queue(); + }) + .exceptionally(e -> { + reply.editMessage("Ошибка: " + e.getMessage()).queue(); + return null; + }); + }); + } + + private String replyInfo(Message msg) { + Message ref = msg.getReferencedMessage(); + if (ref == null) return ""; + String refAuthor = ref.getAuthor().isBot() ? "бот" : ref.getAuthor().getEffectiveName(); + String refText = ref.getContentRaw().replace("\n", " ").strip(); + if (refText.length() > 80) refText = refText.substring(0, 80) + "..."; + return " (в ответ на @" + refAuthor + ": \"" + refText + "\")"; + } + + 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.readTextFile(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/AgrSlashCommands.java b/src/main/java/creator/loki/feature/roast/AgrSlashCommands.java new file mode 100644 index 0000000..3c08863 --- /dev/null +++ b/src/main/java/creator/loki/feature/roast/AgrSlashCommands.java @@ -0,0 +1,108 @@ +package creator.loki.feature.roast; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import creator.loki.config.BotConfig; +import creator.loki.embed.EmbedTemplates; +import org.jetbrains.annotations.NotNull; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; + + +@Singleton +public class AgrSlashCommands extends ListenerAdapter { + private static final String ANDRERT_USER_ID = "1405129454165954630"; + private static final int MESSAGES_PER_CHANNEL = 100; + + private final BotConfig config; + private final RoastService roastService; + @Inject + public AgrSlashCommands(BotConfig config, RoastService roastService) { + this.config = config; + this.roastService = roastService; + } + + @Override + public void onSlashCommandInteraction(@NotNull SlashCommandInteractionEvent event) { + if (!event.getName().equals("agr")) return; + + if (!config.getSuperAdminId().equals(event.getUser().getId())) { + event.replyEmbeds(EmbedTemplates.error("Ошибка", "Только супер-администратор")) + .setEphemeral(true).queue(); + return; + } + if (event.getGuild() == null) { + event.reply("Команда работает только на сервере").setEphemeral(true).queue(); + return; + } + + event.deferReply().queue(); + + String targetId; + String targetName; + + var userOption = event.getOption("user"); + if (userOption != null) { + var user = userOption.getAsUser(); + targetId = user.getId(); + targetName = user.getName(); + } else { + targetId = ANDRERT_USER_ID; + targetName = "Andrert_"; + } + + String finalTargetName = targetName; + String finalTargetId = targetId; + List textChannels = event.getGuild().getTextChannels(); + List>> futures = new ArrayList<>(); + + for (TextChannel ch : textChannels) { + CompletableFuture> future = new CompletableFuture<>(); + futures.add(future); + ch.getHistory().retrievePast(MESSAGES_PER_CHANNEL).queue(future::complete, e -> future.complete(List.of())); + } + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .thenApply(v -> { + List allMessages = new ArrayList<>(); + for (int i = 0; i < textChannels.size(); i++) { + List msgs = futures.get(i).join(); + String chName = textChannels.get(i).getName(); + for (Message msg : msgs) { + if (msg.getAuthor().getId().equals(finalTargetId)) { + allMessages.add(new RoastService.CachedMessage( + msg.getContentRaw(), chName, msg.getTimeCreated().toEpochSecond() * 1000)); + } + } + } + allMessages.sort((a, b) -> Long.compare(a.timestamp(), b.timestamp())); + if (allMessages.size() > 10) { + allMessages = allMessages.subList(allMessages.size() - 10, allMessages.size()); + } + return allMessages; + }) + .thenCompose(msgs -> { + if (msgs.isEmpty()) { + event.getHook().editOriginal("Нет сообщений от " + finalTargetName).queue(); + return CompletableFuture.completedFuture(null); + } + String response = roastService.generateResponse(msgs, finalTargetName, finalTargetId); + if (response != null && !response.isBlank()) { + event.getHook().editOriginal(response).queue(); + } else { + event.getHook().editOriginal("AI не ответил").queue(); + } + return CompletableFuture.completedFuture(null); + }) + .exceptionally(e -> { + event.getHook().editOriginal("Ошибка: " + e.getMessage()).queue(); + return null; + }); + } +} diff --git a/src/main/java/creator/loki/feature/roast/AndrertListener.java b/src/main/java/creator/loki/feature/roast/AndrertListener.java new file mode 100644 index 0000000..7660fba --- /dev/null +++ b/src/main/java/creator/loki/feature/roast/AndrertListener.java @@ -0,0 +1,113 @@ +package creator.loki.feature.roast; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +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; + +@Singleton +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 final RoastService roastService; + private final ConcurrentHashMap trackedUsers = new ConcurrentHashMap<>(); + + @Inject + public AndrertListener(RoastService roastService) { + this.roastService = roastService; + trackedUsers.put(ANDRERT_USER_ID, System.currentTimeMillis() + TRACK_TTL); + } + + public void trackUser(String userId) { + trackedUsers.put(userId, System.currentTimeMillis() + TRACK_TTL); + } + + @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 content = event.getMessage().getContentRaw().strip(); + if (content.length() < 2 && event.getMessage().getAttachments().isEmpty()) return; + + trackedUsers.put(userId, now + TRACK_TTL); + respond(event, userId); + } + + private void respond(MessageReceivedEvent event, String userId) { + event.getChannel().getHistory().retrievePast(CHANNEL_CONTEXT) + .queue(messages -> { + String targetName = event.getAuthor().getEffectiveName(); + List context = new ArrayList<>(); + for (Message msg : messages) { + String author = msg.getAuthor().isBot() ? "бот" : msg.getAuthor().getEffectiveName(); + + StringBuilder line = new StringBuilder(); + line.append(author).append(": ").append(msg.getContentRaw()); + line.append(replyInfo(msg)); + line.append(attachmentInfo(msg)); + + context.add(new RoastService.CachedMessage( + line.toString(), + event.getChannel().getName(), + msg.getTimeCreated().toEpochSecond() * 1000)); + } + java.util.Collections.reverse(context); + + String response = roastService.generateResponse(context, targetName, userId); + if (response != null && !response.isBlank()) { + event.getMessage().reply(response).queue(); + } + }); + } + + private String replyInfo(Message msg) { + Message ref = msg.getReferencedMessage(); + if (ref == null) return ""; + String refAuthor = ref.getAuthor().isBot() ? "бот" : ref.getAuthor().getEffectiveName(); + String refText = ref.getContentRaw().replace("\n", " ").strip(); + if (refText.length() > 80) refText = refText.substring(0, 80) + "..."; + return " (в ответ на @" + refAuthor + ": \"" + refText + "\")"; + } + + 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.readTextFile(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/DossierCommand.java b/src/main/java/creator/loki/feature/roast/DossierCommand.java new file mode 100644 index 0000000..dd2d3fc --- /dev/null +++ b/src/main/java/creator/loki/feature/roast/DossierCommand.java @@ -0,0 +1,121 @@ +package creator.loki.feature.roast; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import creator.loki.command.Command; +import creator.loki.command.CommandContext; +import creator.loki.config.BotConfig; +import creator.loki.embed.EmbedTemplates; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; + +public class DossierCommand implements Command { + private static final int MESSAGES_PER_CHANNEL = 100; + + private final BotConfig config; + private final RoastService roastService; + + public DossierCommand(BotConfig config, RoastService roastService) { + this.config = config; + this.roastService = roastService; + } + + @Override + public String getName() { + return "dossier"; + } + + @Override + public String getDescription() { + return "Составить AI-досье на пользователя по его сообщениям"; + } + + @Override + public String getUsage() { + return "dossier @user"; + } + + @Override + public String getCategory() { + return "Супер-Админ"; + } + + @Override + public void execute(CommandContext context) { + if (!config.getSuperAdminId().equals(context.getAuthor().getId())) { + context.getChannel().sendMessageEmbeds( + EmbedTemplates.error("Ошибка", "Только супер-администратор")).queue(); + return; + } + if (context.getGuild() == null) { + context.getChannel().sendMessage("Команда работает только на сервере").queue(); + return; + } + if (context.getArgs().isEmpty()) { + context.getChannel().sendMessage("Укажи @упоминание пользователя").queue(); + return; + } + + String arg = context.getArgs().get(0); + String targetId = arg.replaceAll("<@!?(\\d+)>", "$1"); + if (targetId.equals(arg) && !arg.matches("\\d+")) { + context.getChannel().sendMessage("Укажи @упоминание или ID пользователя").queue(); + return; + } + + String finalId = targetId; + context.getGuild().retrieveMemberById(targetId).queue( + member -> startDossier(context, finalId, member.getEffectiveName()), + error -> context.getChannel().sendMessage("Пользователь не найден на сервере").queue()); + } + + private void startDossier(CommandContext context, String targetId, String targetName) { + context.getMessage().reply("🔍 Сканирую сообщения " + targetName + "...") + .queue(reply -> { + List textChannels = context.getGuild().getTextChannels(); + List>> futures = new ArrayList<>(); + + for (TextChannel ch : textChannels) { + CompletableFuture> future = new CompletableFuture<>(); + futures.add(future); + ch.getHistory().retrievePast(MESSAGES_PER_CHANNEL).queue(future::complete, e -> future.complete(List.of())); + } + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .thenApply(v -> { + List allMessages = new ArrayList<>(); + for (int i = 0; i < textChannels.size(); i++) { + List msgs = futures.get(i).join(); + String chName = textChannels.get(i).getName(); + for (Message msg : msgs) { + if (msg.getAuthor().getId().equals(targetId)) { + allMessages.add(new RoastService.CachedMessage( + msg.getContentRaw(), chName, msg.getTimeCreated().toEpochSecond() * 1000)); + } + } + } + allMessages.sort((a, b) -> Long.compare(a.timestamp(), b.timestamp())); + return allMessages; + }) + .thenAccept(msgs -> { + if (msgs.isEmpty()) { + reply.editMessage("Нет сообщений от " + targetName).queue(); + return; + } + reply.editMessage("🧠 Анализирую " + msgs.size() + " сообщений...").queue(); + String dossier = roastService.generateDossier(msgs, targetName, targetId); + if (dossier != null && !dossier.isBlank()) { + reply.editMessage("📋 **Досье на " + targetName + ":**\n" + dossier).queue(); + } else { + reply.editMessage("AI не смог составить досье").queue(); + } + }) + .exceptionally(e -> { + reply.editMessage("Ошибка: " + e.getMessage()).queue(); + return null; + }); + }); + } +} diff --git a/src/main/java/creator/loki/feature/roast/RoastModule.java b/src/main/java/creator/loki/feature/roast/RoastModule.java new file mode 100644 index 0000000..0b31fe0 --- /dev/null +++ b/src/main/java/creator/loki/feature/roast/RoastModule.java @@ -0,0 +1,12 @@ +package creator.loki.feature.roast; + +import com.google.inject.AbstractModule; +import com.google.inject.Singleton; + +public class RoastModule extends AbstractModule { + @Override + protected void configure() { + bind(RoastService.class).in(Singleton.class); + bind(AndrertListener.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 new file mode 100644 index 0000000..586a7c9 --- /dev/null +++ b/src/main/java/creator/loki/feature/roast/RoastService.java @@ -0,0 +1,300 @@ +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.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import creator.loki.config.BotConfig; +import okhttp3.MediaType; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.RequestBody; +import okhttp3.Response; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Singleton +public class RoastService { + private static final Logger logger = LoggerFactory.getLogger(RoastService.class); + private static final String API_URL = "https://api.deepseek.com/v1/chat/completions"; + 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 final OkHttpClient httpClient; + private final String apiKey; + private final Map dossiers = new ConcurrentHashMap<>(); + + @Inject + public RoastService(BotConfig config) { + this.apiKey = config.getDeepseekApiKey(); + this.httpClient = new OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build(); + loadDossiers(); + } + + public String readTextFile(String url) { + Request req = new Request.Builder().url(url).get().build(); + try (Response resp = httpClient.newCall(req).execute()) { + if (resp.isSuccessful() && resp.body() != null) { + return resp.body().string(); + } + } catch (IOException e) { + logger.warn("Failed to read text file {}", url, e); + } + return null; + } + + public String generateResponse(List context, String targetName) { + return generateResponse(context, targetName, null); + } + + public String generateResponse(List context, String targetName, String userId) { + if (apiKey == null || apiKey.isBlank()) return null; + + String systemPrompt = SYSTEM_PROMPT.formatted(targetName, targetName, targetName); + + if (userId != null) { + DossierData dd = dossiers.get(userId); + if (dd != null && !dd.text.isBlank()) { + systemPrompt += "\n\nДОСЬЕ НА ПОЛЬЗОВАТЕЛЯ " + targetName + ":\n" + dd.text; + } + } + + StringBuilder userContent = new StringBuilder(); + for (CachedMessage m : context) { + userContent.append("[").append(m.channelName).append("] ").append(m.content).append("\n"); + } + String text = userContent.toString().strip(); + + return callModel(MODEL, systemPrompt, text); + } + + public String generateDossier(List messages, String targetName, String targetId) { + if (apiKey == null || apiKey.isBlank()) return null; + + StringBuilder sb = new StringBuilder(); + int limit = Math.min(messages.size(), MAX_DOSSIER_MSGS); + 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()); + + JsonObject body = new JsonObject(); + body.addProperty("model", MODEL); + + JsonArray msgs = new JsonArray(); + JsonObject userMsg = new JsonObject(); + userMsg.addProperty("role", "user"); + userMsg.addProperty("content", prompt); + msgs.add(userMsg); + body.add("messages", msgs); + body.addProperty("temperature", 0.7); + + Request req = new Request.Builder() + .url(API_URL) + .header("Authorization", "Bearer " + apiKey) + .header("Content-Type", "application/json") + .post(RequestBody.create(body.toString(), JSON)) + .build(); + + try (Response resp = httpClient.newCall(req).execute()) { + if (!resp.isSuccessful() || resp.body() == null) return null; + String respBody = resp.body().string(); + JsonObject jsonResp = JsonParser.parseString(respBody).getAsJsonObject(); + var choice = jsonResp.getAsJsonArray("choices").get(0).getAsJsonObject(); + var msg = choice.getAsJsonObject("message"); + String content = null; + if (msg.get("content") != null && !msg.get("content").isJsonNull()) { + content = msg.get("content").getAsString().strip(); + } else if (msg.get("reasoning") != null && !msg.get("reasoning").isJsonNull()) { + content = msg.get("reasoning").getAsString().strip(); + } + if (content != null && !content.isBlank()) { + dossiers.put(targetId, new DossierData(targetName, content, System.currentTimeMillis())); + saveDossiers(); + } + return content; + } catch (IOException e) { + logger.error("Dossier generation failed", e); + return null; + } + } + + public String getDossier(String userId) { + DossierData dd = dossiers.get(userId); + return dd != null ? dd.text : null; + } + + private void loadDossiers() { + try { + Files.createDirectories(DOSSIER_DIR); + Files.list(DOSSIER_DIR).filter(p -> p.toString().endsWith(".txt")).forEach(p -> { + try { + String userId = p.getFileName().toString().replace(".txt", ""); + String content = Files.readString(p).strip(); + if (!content.isBlank()) { + dossiers.put(userId, new DossierData("user", content, System.currentTimeMillis())); + } + } catch (IOException e) { + logger.warn("Failed to load dossier {}", p, e); + } + }); + if (!dossiers.isEmpty()) logger.info("Loaded {} dossiers", dossiers.size()); + } catch (IOException e) { + logger.warn("Failed to list dossiers dir", e); + } + } + + private void saveDossiers() { + 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"; + Files.writeString(file, header + entry.getValue().text); + } + } catch (Exception e) { + logger.warn("Failed to save dossiers", e); + } + } + + private String callModel(String model, String systemPrompt, String userContent) { + JsonObject body = new JsonObject(); + body.addProperty("model", model); + + JsonArray messages = new JsonArray(); + JsonObject systemMsg = new JsonObject(); + systemMsg.addProperty("role", "system"); + systemMsg.addProperty("content", systemPrompt); + messages.add(systemMsg); + + JsonObject userMsg = new JsonObject(); + userMsg.addProperty("role", "user"); + userMsg.addProperty("content", userContent); + messages.add(userMsg); + + body.add("messages", messages); + body.addProperty("temperature", 0.9); + + Request request = new Request.Builder() + .url(API_URL) + .header("Authorization", "Bearer " + apiKey) + .header("Content-Type", "application/json") + .post(RequestBody.create(body.toString(), JSON)) + .build(); + + try (Response resp = httpClient.newCall(request).execute()) { + if (!resp.isSuccessful()) { + if (resp.body() != null) { + logger.warn("DeepSeek {} error {}: {}", model, resp.code(), resp.body().string()); + } + return null; + } + if (resp.body() == null) return null; + String respBody = resp.body().string(); + JsonObject jsonResp = JsonParser.parseString(respBody).getAsJsonObject(); + JsonObject msg = jsonResp.getAsJsonArray("choices") + .get(0).getAsJsonObject() + .getAsJsonObject("message"); + if (msg.get("content") != null && !msg.get("content").isJsonNull()) { + return msg.get("content").getAsString().strip(); + } + if (msg.get("reasoning") != null && !msg.get("reasoning").isJsonNull()) { + return msg.get("reasoning").getAsString().strip(); + } + return null; + } catch (IOException e) { + logger.error("DeepSeek call failed for model {}", model, e); + return null; + } + } + + 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) {} + + private static class DossierData { + String userName; + String text; + long updatedAt; + + DossierData() {} + + DossierData(String userName, String text, long updatedAt) { + this.userName = userName; + this.text = text; + this.updatedAt = updatedAt; + } + } + +} diff --git a/src/main/java/creator/loki/feature/utility/UtilityCommandRegistrar.java b/src/main/java/creator/loki/feature/utility/UtilityCommandRegistrar.java index 8f17591..3611c10 100644 --- a/src/main/java/creator/loki/feature/utility/UtilityCommandRegistrar.java +++ b/src/main/java/creator/loki/feature/utility/UtilityCommandRegistrar.java @@ -8,6 +8,10 @@ import creator.loki.core.security.CooldownManager; import creator.loki.feature.admin.commands.GenerateRatingsCommand; import creator.loki.feature.config.repository.ConfigRepository; import creator.loki.feature.counter.service.CounterManager; +import creator.loki.feature.roast.AgrCommand; +import creator.loki.feature.roast.AndrertListener; +import creator.loki.feature.roast.DossierCommand; +import creator.loki.feature.roast.RoastService; import creator.loki.feature.counter.service.CounterSetupPrefixCommand; import creator.loki.feature.counter.repository.CounterStatsRepository; import creator.loki.feature.help.commands.HelpCommand; @@ -39,6 +43,8 @@ public class UtilityCommandRegistrar implements CommandRegistrar { private final ReputationRepository reputationRepository; private final CooldownManager cooldownManager; private final ServerStatsService serverStatsService; + private final RoastService roastService; + private final AndrertListener andrertListener; @Inject public UtilityCommandRegistrar( @@ -51,7 +57,9 @@ public class UtilityCommandRegistrar implements CommandRegistrar { RatingsRepository ratingsRepository, ReputationRepository reputationRepository, CooldownManager cooldownManager, - CounterStatsRepository counterStatsRepository) { + CounterStatsRepository counterStatsRepository, + RoastService roastService, + AndrertListener andrertListener) { this.commandRegistry = commandRegistry; this.config = config; this.metrics = metrics; @@ -62,6 +70,8 @@ public class UtilityCommandRegistrar implements CommandRegistrar { this.reputationRepository = reputationRepository; this.cooldownManager = cooldownManager; this.serverStatsService = new ServerStatsService(counterStatsRepository); + this.roastService = roastService; + this.andrertListener = andrertListener; } @Override @@ -79,6 +89,8 @@ public class UtilityCommandRegistrar implements CommandRegistrar { registry.register(new CounterSetupPrefixCommand(counterManager, configRepository)); registry.register(new GenerateRatingsCommand(ratingsRepository, config)); + registry.register(new AgrCommand(config, roastService, andrertListener)); + registry.register(new DossierCommand(config, roastService)); registry.register(new RepCommand(reputationRepository, cooldownManager)); registry.register(new MinusRepCommand(reputationRepository, cooldownManager)); diff --git a/src/test/java/creator/loki/config/BotConfigTest.java b/src/test/java/creator/loki/config/BotConfigTest.java index 8917911..3f4b11c 100644 --- a/src/test/java/creator/loki/config/BotConfigTest.java +++ b/src/test/java/creator/loki/config/BotConfigTest.java @@ -11,7 +11,7 @@ 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", lavalink); + "token123", null, null, "!", "Playing", 8712, "admin1", "data/bot.db", null, lavalink); assertEquals("token123", config.getMainToken()); assertEquals("token123", config.getToken()); assertNull(config.getMusicToken()); @@ -27,7 +27,7 @@ class BotConfigTest { @Test void handlesNullValues() { - BotConfig config = new BotConfig(null, null, null, null, null, 0, null, null, null); + BotConfig config = new BotConfig(null, null, null, null, null, 0, null, null, null, null); assertNull(config.getMainToken()); assertNull(config.getMusicToken()); assertNull(config.getRadioToken()); @@ -42,7 +42,7 @@ class BotConfigTest { @Test void handlesEmptyStrings() { LavalinkConfig lavalink = new LavalinkConfig("", 0, "", false); - BotConfig config = new BotConfig("", "", "", "", "", 8080, "", "", lavalink); + BotConfig config = new BotConfig("", "", "", "", "", 8080, "", "", null, lavalink); assertEquals("", config.getMainToken()); assertEquals("", config.getMusicToken()); assertEquals("", config.getRadioToken()); diff --git a/src/test/java/creator/loki/core/registry/SmokeCommandRegistrationTest.java b/src/test/java/creator/loki/core/registry/SmokeCommandRegistrationTest.java index c0f850a..1552c89 100644 --- a/src/test/java/creator/loki/core/registry/SmokeCommandRegistrationTest.java +++ b/src/test/java/creator/loki/core/registry/SmokeCommandRegistrationTest.java @@ -32,6 +32,8 @@ import creator.loki.feature.music.core.SearchManager; import creator.loki.feature.profile.service.ProfileService; import creator.loki.feature.rating.repository.RatingsRepository; import creator.loki.feature.reputation.repository.ReputationRepository; +import creator.loki.feature.roast.AndrertListener; +import creator.loki.feature.roast.RoastService; import creator.loki.feature.role.CustomRoleManager; import creator.loki.feature.ship.repository.ShipPairRepository; import creator.loki.feature.utility.UtilityCommandRegistrar; @@ -119,7 +121,9 @@ class SmokeCommandRegistrationTest { mock(RatingsRepository.class), mock(ReputationRepository.class), mock(CooldownManager.class), - mock(CounterStatsRepository.class)); + mock(CounterStatsRepository.class), + mock(RoastService.class), + mock(AndrertListener.class)); utilityRegistrar.register(registry); var musicRegistrar = 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 d51bced..64bafab 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", lavalinkConfig); + var config = new BotConfig("token", null, null, "!", "Playing", 8080, null, "db", null, 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", lavalinkConfig); + var config = new BotConfig("token", null, null, "!", "Playing", 8080, null, "db", null, lavalinkConfig); var manager = new LavalinkManager(config, queueRepository); var first = manager.getMusicManager(123L);