diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersMessageListener.java b/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersMessageListener.java index f7b7611fc4..e1019ae39d 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersMessageListener.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/tophelper/TopHelpersMessageListener.java @@ -2,6 +2,7 @@ import net.dv8tion.jda.api.entities.channel.ChannelType; import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; +import net.dv8tion.jda.api.entities.channel.unions.MessageChannelUnion; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import org.togetherjava.tjbot.commands.MessageReceiverAdapter; @@ -20,11 +21,11 @@ public final class TopHelpersMessageListener extends MessageReceiverAdapter { /** * Matches invisible control characters and unused code points - * + * * @see Unicode * Categories */ - private static final Pattern UNCOUNTED_CHARS = Pattern.compile("\\p{C}"); + private static final Pattern INVALID_CHARACTERS = Pattern.compile("\\p{C}"); private final Database database; @@ -50,31 +51,15 @@ public TopHelpersMessageListener(Database database, Config config) { @Override public void onMessageReceived(MessageReceivedEvent event) { - if (event.getAuthor().isBot() || event.isWebhookMessage()) { - return; - } - - if (!isHelpThread(event)) { + if (shouldIgnoreMessage(event)) { return; } addMessageRecord(event); } - private boolean isHelpThread(MessageReceivedEvent event) { - if (event.getChannelType() != ChannelType.GUILD_PUBLIC_THREAD) { - return false; - } - - ThreadChannel thread = event.getChannel().asThreadChannel(); - String rootChannelName = thread.getParentChannel().getName(); - return isStagingChannelName.test(rootChannelName) - || isOverviewChannelName.test(rootChannelName); - } - private void addMessageRecord(MessageReceivedEvent event) { - String messageContent = event.getMessage().getContentRaw(); - long messageLength = UNCOUNTED_CHARS.matcher(messageContent).replaceAll("").length(); + long messageLength = countValidCharacters(event.getMessage().getContentRaw()); database.write(context -> context.newRecord(HELP_CHANNEL_MESSAGES) .setMessageId(event.getMessage().getIdLong()) @@ -85,4 +70,25 @@ private void addMessageRecord(MessageReceivedEvent event) { .setMessageLength(messageLength) .insert()); } + + boolean shouldIgnoreMessage(MessageReceivedEvent event) { + return event.getAuthor().isBot() || event.isWebhookMessage() + || !isHelpThread(event.getChannel()); + } + + boolean isHelpThread(MessageChannelUnion channel) { + if (channel.getType() != ChannelType.GUILD_PUBLIC_THREAD) { + return false; + } + + ThreadChannel thread = channel.asThreadChannel(); + String rootChannelName = thread.getParentChannel().getName(); + return isStagingChannelName.test(rootChannelName) + || isOverviewChannelName.test(rootChannelName); + } + + static long countValidCharacters(String messageContent) { + return INVALID_CHARACTERS.matcher(messageContent).replaceAll("").length(); + } + } diff --git a/application/src/test/java/org/togetherjava/tjbot/commands/mediaonly/MediaOnlyChannelListenerTest.java b/application/src/test/java/org/togetherjava/tjbot/commands/mediaonly/MediaOnlyChannelListenerTest.java index 63f6ef9520..fa5f3476ea 100644 --- a/application/src/test/java/org/togetherjava/tjbot/commands/mediaonly/MediaOnlyChannelListenerTest.java +++ b/application/src/test/java/org/togetherjava/tjbot/commands/mediaonly/MediaOnlyChannelListenerTest.java @@ -3,6 +3,7 @@ import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.entities.Message; import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.channel.ChannelType; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder; import net.dv8tion.jda.api.utils.messages.MessageCreateData; @@ -102,7 +103,8 @@ private MessageReceivedEvent sendMessage(MessageCreateData message) { private MessageReceivedEvent sendMessage(MessageCreateData message, List attachments) { - MessageReceivedEvent event = jdaTester.createMessageReceiveEvent(message, attachments); + MessageReceivedEvent event = + jdaTester.createMessageReceiveEvent(message, attachments, ChannelType.TEXT); mediaOnlyChannelListener.onMessageReceived(event); return event; } diff --git a/application/src/test/java/org/togetherjava/tjbot/commands/tophelper/TopHelperMessageListenerTest.java b/application/src/test/java/org/togetherjava/tjbot/commands/tophelper/TopHelperMessageListenerTest.java new file mode 100644 index 0000000000..f58fbb20a4 --- /dev/null +++ b/application/src/test/java/org/togetherjava/tjbot/commands/tophelper/TopHelperMessageListenerTest.java @@ -0,0 +1,177 @@ +package org.togetherjava.tjbot.commands.tophelper; + +import net.dv8tion.jda.api.entities.channel.ChannelType; +import net.dv8tion.jda.api.events.message.MessageReceivedEvent; +import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder; +import net.dv8tion.jda.api.utils.messages.MessageCreateData; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.config.HelpSystemConfig; +import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.jda.JdaTester; + +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.togetherjava.tjbot.db.generated.tables.HelpChannelMessages.HELP_CHANNEL_MESSAGES; + +final class TopHelperMessageListenerTest { + + private static final String STAGING_CHANNEL_PATTERN = "ask_here"; + private static final String OVERVIEW_CHANNEL_PATTERN = "active_questions"; + + private static JdaTester jdaTester; + private static TopHelpersMessageListener topHelpersListener; + + @BeforeAll + static void setUp() { + Database database = Database.createMemoryDatabase(HELP_CHANNEL_MESSAGES); + Config config = mock(Config.class); + HelpSystemConfig helpSystemConfig = mock(HelpSystemConfig.class); + + when(helpSystemConfig.getStagingChannelPattern()).thenReturn(STAGING_CHANNEL_PATTERN); + when(helpSystemConfig.getOverviewChannelPattern()).thenReturn(OVERVIEW_CHANNEL_PATTERN); + + when(config.getHelpSystem()).thenReturn(helpSystemConfig); + + jdaTester = new JdaTester(); + topHelpersListener = new TopHelpersMessageListener(database, config); + } + + @Test + void recognizesValidMessages() { + // GIVEN a message by a human in a help channel + MessageReceivedEvent event = + createMessageReceivedEvent(false, false, true, OVERVIEW_CHANNEL_PATTERN); + + // WHEN checking if the message should be ignored + boolean shouldBeIgnored = topHelpersListener.shouldIgnoreMessage(event); + + // THEN the message is not ignored + assertFalse(shouldBeIgnored); + } + + @Test + void ignoresBots() { + // GIVEN a message from a bot + MessageReceivedEvent event = + createMessageReceivedEvent(true, false, true, OVERVIEW_CHANNEL_PATTERN); + + // WHEN checking if the message should be ignored + boolean shouldBeIgnored = topHelpersListener.shouldIgnoreMessage(event); + + // THEN the message is ignored + assertTrue(shouldBeIgnored); + } + + @Test + void ignoresWebhooks() { + // GIVEN a message from a webhook + MessageReceivedEvent event = + createMessageReceivedEvent(false, true, true, OVERVIEW_CHANNEL_PATTERN); + + // WHEN checking if the message should be ignored + boolean shouldBeIgnored = topHelpersListener.shouldIgnoreMessage(event); + + // THEN the message is ignored + assertTrue(shouldBeIgnored); + } + + @Test + void ignoresWrongChannels() { + // GIVEN a message outside a help thread + MessageReceivedEvent eventNotAThread = + createMessageReceivedEvent(false, false, false, OVERVIEW_CHANNEL_PATTERN); + MessageReceivedEvent eventWrongParentName = + createMessageReceivedEvent(false, false, true, "memes"); + + // WHEN checking if the message should be ignored + boolean ignoresNonThreadChannels = topHelpersListener.shouldIgnoreMessage(eventNotAThread); + boolean ignoresWrongParentNames = + topHelpersListener.shouldIgnoreMessage(eventWrongParentName); + + // THEN the message is ignored + assertTrue(ignoresNonThreadChannels, "Failed to ignore non-thread channels"); + assertTrue(ignoresWrongParentNames, "Failed to ignore wrong parent channel names"); + } + + + MessageReceivedEvent createMessageReceivedEvent(boolean isBot, boolean isWebhook, + boolean isThread, String parentChannelName) { + try (MessageCreateData message = new MessageCreateBuilder().setContent("Any").build()) { + MessageReceivedEvent event = jdaTester.createMessageReceiveEvent(message, List.of(), + isThread ? ChannelType.GUILD_PUBLIC_THREAD : ChannelType.TEXT); + + when(jdaTester.getMemberSpy().getUser().isBot()).thenReturn(isBot); + when(event.getMessage().isWebhookMessage()).thenReturn(isWebhook); + when(jdaTester.getTextChannelSpy().getName()).thenReturn(parentChannelName); + + return event; + } + } + + + @ParameterizedTest + @MethodSource("provideInvalidCharactersWithDescription") + void excludesInvalidCharacters(String invalidChars, String description) { + // GIVEN a string of invalid characters + + // WHEN counting the amount of valid characters + long validCharacterCount = TopHelpersMessageListener.countValidCharacters(invalidChars); + + // THEN no characters are counted + assertEquals(0, validCharacterCount, + "Characters [%s] were not fully ignored".formatted(description)); + } + + + @ParameterizedTest + @MethodSource("provideValidCharacters") + void countsValidCharacters(String validChars) { + // GIVEN a string of valid characters + + // WHEN counting the amount of valid characters + long validCharCount = TopHelpersMessageListener.countValidCharacters(validChars); + + // THEN all characters are counted + assertEquals(validChars.length(), validCharCount, + "Characters [%s] were not fully ignored".formatted(validChars)); + } + + + private static Stream provideInvalidCharactersWithDescription() { + return Stream.of( // Invalid characters + Arguments.of("\u061C", "Arabic Letter Mark"), + Arguments.of("\u0600", "Arabic Number Sign"), + Arguments.of("\u180E", "Mongolian Vowel Separator"), + Arguments.of("\u200B", "Zero Width Space"), + Arguments.of("\u200C", "Zero Width Non-Joiner"), + Arguments.of("\u200D", "Zero Width Joiner"), + Arguments.of("\u200E", "Left-to-Right Mark"), + Arguments.of("\u200F", "Right-to-Left Mark")); + } + + + private static List provideValidCharacters() { + return List.of( // Valid characters + "a", "A", "b", "B", "c", "C", "x", "X,", "y", "Y", "z", "Z", // Latin alphabet + "1", "2", "3", "4", "5", "6", "7", "8", "9", "0", // Numbers + "°", "!", "§", "§", "$", "%", "&", "/", "(", ")", "{", "}", "[", "]", "=", // Other + "+", "*", "~", "-", "_", ".", ",", "?", ":", ";", "|", "<", ">", "@", "€", "µ", // Other + "α", "Α", "β", "Β", "γ", "Γ", "χ", "Χ", "ψ", "Ψ", "ω", "Ω", // Greek alphabet + "ä", "ö", "ü", "ß", // German + "á", "è", "î", // French + "天", "四", "永", // Chinese + "😀", "😛", "❤️", "💚", "⛔" // Emojis + ); + } + +} diff --git a/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java b/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java index 3ab89df2f2..66734c2c64 100644 --- a/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java +++ b/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java @@ -4,10 +4,13 @@ import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.Permission; import net.dv8tion.jda.api.entities.*; +import net.dv8tion.jda.api.entities.channel.ChannelType; import net.dv8tion.jda.api.entities.channel.concrete.PrivateChannel; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; import net.dv8tion.jda.api.entities.channel.middleman.GuildChannel; import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import net.dv8tion.jda.api.entities.channel.unions.MessageChannelUnion; import net.dv8tion.jda.api.entities.emoji.Emoji; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; @@ -32,6 +35,7 @@ import net.dv8tion.jda.internal.entities.*; import net.dv8tion.jda.internal.entities.channel.concrete.PrivateChannelImpl; import net.dv8tion.jda.internal.entities.channel.concrete.TextChannelImpl; +import net.dv8tion.jda.internal.entities.channel.concrete.ThreadChannelImpl; import net.dv8tion.jda.internal.requests.Requester; import net.dv8tion.jda.internal.requests.restaction.AuditableRestActionImpl; import net.dv8tion.jda.internal.requests.restaction.MessageCreateActionImpl; @@ -98,6 +102,7 @@ public final class JdaTester { private static final long PRIVATE_CHANNEL_ID = 1; private static final long GUILD_ID = 1; private static final long TEXT_CHANNEL_ID = 1; + private static final long THREAD_CHANNEL_ID = 2; private final JDAImpl jda; private final MemberImpl member; private final GuildImpl guild; @@ -106,6 +111,7 @@ public final class JdaTester { private final MessageCreateActionImpl messageCreateAction; private final WebhookMessageEditActionImpl webhookMessageEditAction; private final TextChannelImpl textChannel; + private final ThreadChannelImpl threadChannel; private final PrivateChannelImpl privateChannel; private final InteractionHook interactionHook; private final ReplyCallbackAction replyCallbackAction; @@ -131,6 +137,8 @@ public JdaTester() { Member selfMember = spy(new MemberImpl(guild, selfUser)); member = spy(new MemberImpl(guild, user)); textChannel = spy(new TextChannelImpl(TEXT_CHANNEL_ID, guild)); + threadChannel = spy( + new ThreadChannelImpl(THREAD_CHANNEL_ID, guild, ChannelType.GUILD_PUBLIC_THREAD)); privateChannel = spy(new PrivateChannelImpl(jda, PRIVATE_CHANNEL_ID, user)); messageCreateAction = mock(MessageCreateActionImpl.class); webhookMessageEditAction = mock(WebhookMessageEditActionImpl.class); @@ -151,6 +159,7 @@ public JdaTester() { doReturn(selfUser).when(jda).getSelfUser(); when(jda.getGuildChannelById(anyLong())).thenReturn(textChannel); when(jda.getTextChannelById(anyLong())).thenReturn(textChannel); + when(jda.getThreadChannelById(anyLong())).thenReturn(threadChannel); when(jda.getChannelById(ArgumentMatchers.>any(), anyLong())) .thenReturn(textChannel); when(jda.getPrivateChannelById(anyLong())).thenReturn(privateChannel); @@ -188,6 +197,7 @@ public JdaTester() { when(jda.retrieveUserById(anyLong())).thenReturn(userAction); doReturn(null).when(textChannel).retrieveMessageById(any()); + doReturn(null).when(threadChannel).retrieveMessageById(any()); interactionHook = mock(InteractionHook.class); when(interactionHook.editOriginal(anyString())).thenReturn(webhookMessageEditAction); @@ -197,8 +207,11 @@ public JdaTester() { .thenReturn(webhookMessageEditAction); doReturn(messageCreateAction).when(textChannel).sendMessageEmbeds(any(), any()); + doReturn(messageCreateAction).when(threadChannel).sendMessageEmbeds(any(), any()); doReturn(messageCreateAction).when(textChannel).sendMessageEmbeds(any()); + doReturn(messageCreateAction).when(threadChannel).sendMessageEmbeds(any()); doReturn(privateChannel).when(textChannel).asPrivateChannel(); + doReturn(textChannel).when(threadChannel).getParentChannel(); doNothing().when(messageCreateAction).queue(); when(messageCreateAction.setContent(any())).thenReturn(messageCreateAction); @@ -265,7 +278,7 @@ public ButtonClickEventBuilder createButtonInteractionEvent() { Message message = mockingDetails.isMock() || mockingDetails.isSpy() ? event : spy(event); - mockMessage(message); + mockMessage(message, ChannelType.TEXT); return message; }; @@ -354,6 +367,20 @@ public TextChannel getTextChannelSpy() { return textChannel; } + /** + * Gets the thread channel spy used as universal thread channel by all mocks created by this + * tester instance. + *

+ * For example the events created by + * {@link #createMessageReceiveEvent(MessageCreateData, List, ChannelType)} can return this + * channel spy + * + * @return the thread channel spy used by this tester + */ + public ThreadChannel getThreadChannelSpy() { + return threadChannel; + } + /** * Gets the private channel spy used as universal private channel by all mocks created by this * tester instance. @@ -554,12 +581,14 @@ public ErrorResponseException createErrorResponseException(ErrorResponse reason) * * @param message the message that has been received * @param attachments attachments of the message, empty if none + * @param channelType the type of the channel the message was sent in. See + * {@link #mockMessage(Message, ChannelType)} for supported channel types * @return the event of receiving the given message */ public MessageReceivedEvent createMessageReceiveEvent(MessageCreateData message, - List attachments) { + List attachments, ChannelType channelType) { Message receivedMessage = clientMessageToReceivedMessageMock(message); - mockMessage(receivedMessage); + mockMessage(receivedMessage, channelType); doReturn(attachments).when(receivedMessage).getAttachments(); return new MessageReceivedEvent(jda, responseNumber.getAndIncrement(), receivedMessage); @@ -638,7 +667,14 @@ private void mockButtonClickEvent(ButtonInteractionEvent event) { doReturn(replyAction).when(event).editButton(any()); } - private void mockMessage(Message message) { + private void mockMessage(Message message, ChannelType channelType) { + MessageChannelUnion channel = switch (channelType) { + case TEXT -> textChannel; + case GUILD_PUBLIC_THREAD -> threadChannel; + default -> throw new IllegalArgumentException( + "Unsupported channel type: " + channelType); + }; + doReturn(messageCreateAction).when(message).reply(anyString()); doReturn(messageCreateAction).when(message) .replyEmbeds(ArgumentMatchers.any()); @@ -651,7 +687,7 @@ private void mockMessage(Message message) { doReturn(member).when(message).getMember(); doReturn(member.getUser()).when(message).getAuthor(); - doReturn(textChannel).when(message).getChannel(); + doReturn(channel).when(message).getChannel(); doReturn(1L).when(message).getIdLong(); doReturn(false).when(message).isWebhookMessage();