diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java index 5f88ff1019..6f74142dce 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java @@ -29,6 +29,7 @@ import org.togetherjava.tjbot.features.chatgpt.ChatGptService; import org.togetherjava.tjbot.features.componentids.ComponentIdInteractor; import org.togetherjava.tjbot.features.utils.Guilds; +import org.togetherjava.tjbot.features.utils.LinkDetection; import java.awt.Color; import java.time.Instant; @@ -41,6 +42,7 @@ import java.util.Optional; import java.util.Set; import java.util.StringJoiner; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import java.util.function.Function; @@ -57,6 +59,7 @@ public final class HelpSystemHelper { private static final Logger logger = LoggerFactory.getLogger(HelpSystemHelper.class); private static final ChatGptModel CHAT_GPT_MODEL = ChatGptModel.FAST; + private static final String BROKEN_LINK_REPLACEMENT = "(broken link removed)"; static final Color AMBIENT_COLOR = new Color(255, 255, 165); @@ -74,6 +77,7 @@ public final class HelpSystemHelper { private final Database database; private final ChatGptService chatGptService; + private final Function> brokenLinkReplacer; private static final int MAX_QUESTION_LENGTH = 200; private static final int MIN_QUESTION_LENGTH = 10; private static final String CHATGPT_FAILURE_MESSAGE = @@ -87,9 +91,16 @@ public final class HelpSystemHelper { * @param chatGptService the service used to ask ChatGPT questions via the API. */ public HelpSystemHelper(Config config, Database database, ChatGptService chatGptService) { + this(config, database, chatGptService, + answer -> LinkDetection.replaceBrokenLinks(answer, BROKEN_LINK_REPLACEMENT)); + } + + HelpSystemHelper(Config config, Database database, ChatGptService chatGptService, + Function> brokenLinkReplacer) { HelpSystemConfig helpConfig = config.getHelpSystem(); this.database = database; this.chatGptService = chatGptService; + this.brokenLinkReplacer = brokenLinkReplacer; isTagManageRole = Pattern.compile(config.getTagManageRolePattern()).asMatchPredicate(); helpForumPattern = helpConfig.getHelpForumPattern(); @@ -188,6 +199,7 @@ RestAction constructChatGptAttempt(ThreadChannel threadChannel, public MessageEmbed generateGptResponseEmbed(String answer, SelfUser selfUser, String title, ChatGptModel model) { String responseByGptFooter = "- AI generated response using %s model".formatted(model); + String sanitizedAnswer = sanitizeBrokenLinks(answer); int embedTitleLimit = MessageEmbed.TITLE_MAX_LENGTH; String capitalizedTitle = Character.toUpperCase(title.charAt(0)) + title.substring(1); @@ -199,12 +211,21 @@ public MessageEmbed generateGptResponseEmbed(String answer, SelfUser selfUser, S return new EmbedBuilder() .setAuthor(selfUser.getName(), null, selfUser.getEffectiveAvatarUrl()) .setTitle(titleForEmbed) - .setDescription(answer) + .setDescription(sanitizedAnswer) .setColor(Color.pink) .setFooter(responseByGptFooter) .build(); } + private String sanitizeBrokenLinks(String answer) { + try { + return brokenLinkReplacer.apply(answer).join(); + } catch (RuntimeException runtimeException) { + logger.debug("Failed to replace broken links in ChatGPT response", runtimeException); + return answer; + } + } + private Button generateDismissButton(ComponentIdInteractor componentIdInteractor, String id) { String buttonId = componentIdInteractor.generateComponentId(id); return Button.danger(buttonId, "Dismiss"); diff --git a/application/src/test/java/org/togetherjava/tjbot/features/help/HelpSystemHelperTest.java b/application/src/test/java/org/togetherjava/tjbot/features/help/HelpSystemHelperTest.java new file mode 100644 index 0000000000..f130c391fe --- /dev/null +++ b/application/src/test/java/org/togetherjava/tjbot/features/help/HelpSystemHelperTest.java @@ -0,0 +1,87 @@ +package org.togetherjava.tjbot.features.help; + +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.SelfUser; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.config.HelpSystemConfig; +import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.db.generated.tables.HelpThreads; +import org.togetherjava.tjbot.features.chatgpt.ChatGptModel; +import org.togetherjava.tjbot.features.chatgpt.ChatGptService; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +final class HelpSystemHelperTest { + private static final String ORIGINAL_ANSWER = """ + Useful links: + - https://broken.example + - https://working.example + """; + private static final String SANITIZED_ANSWER = """ + Useful links: + - (broken link removed) + - https://working.example + """; + private HelpSystemHelper helper; + private SelfUser selfUser; + + @BeforeEach + void setUp() { + Config config = mock(Config.class); + HelpSystemConfig helpSystemConfig = mock(HelpSystemConfig.class); + when(config.getHelpSystem()).thenReturn(helpSystemConfig); + when(config.getTagManageRolePattern()).thenReturn("tag-manage"); + when(helpSystemConfig.getHelpForumPattern()).thenReturn("questions"); + when(helpSystemConfig.getCategories()).thenReturn(List.of("java")); + when(helpSystemConfig.getCategoryRoleSuffix()).thenReturn(" helper"); + + Database database = Database.createMemoryDatabase(HelpThreads.HELP_THREADS); + ChatGptService chatGptService = mock(ChatGptService.class); + helper = new HelpSystemHelper(config, database, chatGptService, answer -> CompletableFuture + .completedFuture(answer.replace("https://broken.example", "(broken link removed)"))); + + selfUser = mock(SelfUser.class); + when(selfUser.getName()).thenReturn("TJ-Bot"); + when(selfUser.getEffectiveAvatarUrl()).thenReturn("https://example.com/avatar.png"); + } + + @Test + @DisplayName("Replaces broken links before building AI response embed") + void replacesBrokenLinksInGeneratedEmbed() { + MessageEmbed embed = helper.generateGptResponseEmbed(ORIGINAL_ANSWER, selfUser, + "example question", ChatGptModel.FAST); + + assertEquals(SANITIZED_ANSWER, embed.getDescription()); + } + + @Test + @DisplayName("Keeps original answer if broken-link replacement fails") + void keepsOriginalAnswerWhenReplacementFails() { + Config config = mock(Config.class); + HelpSystemConfig helpSystemConfig = mock(HelpSystemConfig.class); + when(config.getHelpSystem()).thenReturn(helpSystemConfig); + when(config.getTagManageRolePattern()).thenReturn("tag-manage"); + when(helpSystemConfig.getHelpForumPattern()).thenReturn("questions"); + when(helpSystemConfig.getCategories()).thenReturn(List.of("java")); + when(helpSystemConfig.getCategoryRoleSuffix()).thenReturn(" helper"); + + Database database = Database.createMemoryDatabase(HelpThreads.HELP_THREADS); + ChatGptService chatGptService = mock(ChatGptService.class); + HelpSystemHelper failingHelper = new HelpSystemHelper(config, database, chatGptService, + _ -> CompletableFuture.failedFuture(new IllegalStateException("boom"))); + + MessageEmbed embed = failingHelper.generateGptResponseEmbed(ORIGINAL_ANSWER, selfUser, + "example question", ChatGptModel.FAST); + + assertEquals(ORIGINAL_ANSWER, embed.getDescription()); + } +}