diff --git a/application/src/main/java/org/togetherjava/tjbot/features/Features.java b/application/src/main/java/org/togetherjava/tjbot/features/Features.java index 0485904ab3..b1acf61111 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -4,10 +4,7 @@ import org.togetherjava.tjbot.config.Config; import org.togetherjava.tjbot.db.Database; -import org.togetherjava.tjbot.features.basic.PingCommand; -import org.togetherjava.tjbot.features.basic.RoleSelectCommand; -import org.togetherjava.tjbot.features.basic.SuggestionsUpDownVoter; -import org.togetherjava.tjbot.features.basic.VcActivityCommand; +import org.togetherjava.tjbot.features.basic.*; import org.togetherjava.tjbot.features.bookmarks.*; import org.togetherjava.tjbot.features.code.CodeMessageAutoDetection; import org.togetherjava.tjbot.features.code.CodeMessageHandler; @@ -104,6 +101,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(codeMessageHandler); features.add(new CodeMessageAutoDetection(config, codeMessageHandler)); features.add(new CodeMessageManualDetection(codeMessageHandler)); + features.add(new SlashCommandEducator()); // Event receivers features.add(new RejoinModerationRoleListener(actionsStore, config)); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/MessageReceiverAdapter.java b/application/src/main/java/org/togetherjava/tjbot/features/MessageReceiverAdapter.java index 350a78d8a0..05280c97ab 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/MessageReceiverAdapter.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/MessageReceiverAdapter.java @@ -18,6 +18,13 @@ public abstract class MessageReceiverAdapter implements MessageReceiver { private final Pattern channelNamePattern; + /** + * Creates an instance of a message receiver, listening to messages of all channels. + */ + protected MessageReceiverAdapter() { + this(Pattern.compile(".*")); + } + /** * Creates an instance of a message receiver with the given pattern. * diff --git a/application/src/main/java/org/togetherjava/tjbot/features/basic/SlashCommandEducator.java b/application/src/main/java/org/togetherjava/tjbot/features/basic/SlashCommandEducator.java new file mode 100644 index 0000000000..a9d8056e1a --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/basic/SlashCommandEducator.java @@ -0,0 +1,71 @@ +package org.togetherjava.tjbot.features.basic; + +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.events.message.MessageReceivedEvent; +import net.dv8tion.jda.api.requests.restaction.MessageCreateAction; +import net.dv8tion.jda.api.utils.FileUpload; + +import org.togetherjava.tjbot.features.MessageReceiverAdapter; +import org.togetherjava.tjbot.features.help.HelpSystemHelper; + +import java.io.InputStream; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +/** + * Listens to messages that are likely supposed to be message commands, such as {@code !foo} and + * then educates the user about using slash commands, such as {@code /foo} instead. + */ +public final class SlashCommandEducator extends MessageReceiverAdapter { + private static final String SLASH_COMMAND_POPUP_ADVICE_PATH = "slashCommandPopupAdvice.png"; + private static final Predicate IS_MESSAGE_COMMAND = Pattern.compile(""" + [.!?] #Start of message command + [a-zA-Z]{2,15} #Name of message command, e.g. 'close' + .* #Rest of the message + """, Pattern.COMMENTS).asMatchPredicate(); + + @Override + public void onMessageReceived(MessageReceivedEvent event) { + if (event.getAuthor().isBot() || event.isWebhookMessage()) { + return; + } + + String content = event.getMessage().getContentRaw(); + if (IS_MESSAGE_COMMAND.test(content)) { + sendAdvice(event.getMessage()); + } + } + + private void sendAdvice(Message message) { + String content = + """ + Looks like you attempted to use a command? Please note that we only use **slash-commands** on this server 🙂 + + Try starting your message with a forward-slash `/` and Discord should open a popup showing you all available commands. + A command might then look like `/foo` 👍"""; + + createReply(message, content, SLASH_COMMAND_POPUP_ADVICE_PATH).queue(); + } + + private static MessageCreateAction createReply(Message messageToReplyTo, String content, + String imagePath) { + boolean useImage = true; + InputStream imageData = HelpSystemHelper.class.getResourceAsStream("/" + imagePath); + if (imageData == null) { + useImage = false; + } + + MessageEmbed embed = new EmbedBuilder().setDescription(content) + .setImage(useImage ? "attachment://" + imagePath : null) + .build(); + + MessageCreateAction action = messageToReplyTo.replyEmbeds(embed); + if (useImage) { + action = action.addFiles(FileUpload.fromData(imageData, imagePath)); + } + + return action; + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/code/CodeMessageAutoDetection.java b/application/src/main/java/org/togetherjava/tjbot/features/code/CodeMessageAutoDetection.java index 9e53b0321a..2a95879449 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/code/CodeMessageAutoDetection.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/code/CodeMessageAutoDetection.java @@ -34,8 +34,6 @@ public final class CodeMessageAutoDetection extends MessageReceiverAdapter { * @param codeMessageHandler to register detected code messages at for further handling */ public CodeMessageAutoDetection(Config config, CodeMessageHandler codeMessageHandler) { - super(Pattern.compile(".*")); - this.codeMessageHandler = codeMessageHandler; isHelpForumName = diff --git a/application/src/main/java/org/togetherjava/tjbot/features/code/CodeMessageHandler.java b/application/src/main/java/org/togetherjava/tjbot/features/code/CodeMessageHandler.java index 1d809e66cf..21ddf289df 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/code/CodeMessageHandler.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/code/CodeMessageHandler.java @@ -29,7 +29,6 @@ import java.awt.Color; import java.util.*; import java.util.function.Function; -import java.util.regex.Pattern; import java.util.stream.Collectors; /** @@ -66,8 +65,6 @@ public final class CodeMessageHandler extends MessageReceiverAdapter implements * Creates a new instance. */ public CodeMessageHandler() { - super(Pattern.compile(".*")); - componentIdInteractor = new ComponentIdInteractor(getInteractionType(), getName()); List codeActions = List.of(new FormatCodeCommand()); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/filesharing/FileSharingMessageListener.java b/application/src/main/java/org/togetherjava/tjbot/features/filesharing/FileSharingMessageListener.java index 5ef727b40d..01ae063b97 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/filesharing/FileSharingMessageListener.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/filesharing/FileSharingMessageListener.java @@ -73,8 +73,6 @@ public class FileSharingMessageListener extends MessageReceiverAdapter implement * @see org.togetherjava.tjbot.features.Features */ public FileSharingMessageListener(Config config) { - super(Pattern.compile(".*")); - gistApiKey = config.getGistApiKey(); isHelpForumName = Pattern.compile(config.getHelpSystem().getHelpForumPattern()).asMatchPredicate(); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/moderation/attachment/BlacklistedAttachmentListener.java b/application/src/main/java/org/togetherjava/tjbot/features/moderation/attachment/BlacklistedAttachmentListener.java index 1c5d9df5d8..db1060aefb 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/moderation/attachment/BlacklistedAttachmentListener.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/attachment/BlacklistedAttachmentListener.java @@ -15,11 +15,10 @@ import org.togetherjava.tjbot.features.moderation.modmail.ModMailCommand; import org.togetherjava.tjbot.features.utils.MessageUtils; -import java.awt.*; +import java.awt.Color; import java.util.List; import java.util.Locale; import java.util.function.UnaryOperator; -import java.util.regex.Pattern; /** * Reacts to blacklisted attachments being posted, upon which they are deleted. @@ -35,7 +34,6 @@ public final class BlacklistedAttachmentListener extends MessageReceiverAdapter * @param modAuditLogWriter to inform the mods about the suspicious attachment */ public BlacklistedAttachmentListener(Config config, ModAuditLogWriter modAuditLogWriter) { - super(Pattern.compile(".*")); this.modAuditLogWriter = modAuditLogWriter; blacklistedFileExtensions = config.getBlacklistedFileExtensions(); } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamBlocker.java b/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamBlocker.java index af69d40dac..c7beafb704 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamBlocker.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/scam/ScamBlocker.java @@ -74,8 +74,6 @@ public final class ScamBlocker extends MessageReceiverAdapter implements UserInt */ public ScamBlocker(ModerationActionsStore actionsStore, ScamHistoryStore scamHistoryStore, Config config) { - super(Pattern.compile(".*")); - this.actionsStore = actionsStore; this.scamHistoryStore = scamHistoryStore; this.config = config; diff --git a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersMessageListener.java b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersMessageListener.java index f32086e880..9d98014aa4 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersMessageListener.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersMessageListener.java @@ -38,8 +38,6 @@ public final class TopHelpersMessageListener extends MessageReceiverAdapter { * @param config the config to use for this */ public TopHelpersMessageListener(Database database, Config config) { - super(Pattern.compile(".*")); - this.database = database; isHelpForumName = diff --git a/application/src/main/resources/slashCommandPopupAdvice.png b/application/src/main/resources/slashCommandPopupAdvice.png new file mode 100644 index 0000000000..6284b8566e Binary files /dev/null and b/application/src/main/resources/slashCommandPopupAdvice.png differ diff --git a/application/src/test/java/org/togetherjava/tjbot/features/basic/SlashCommandEducatorTest.java b/application/src/test/java/org/togetherjava/tjbot/features/basic/SlashCommandEducatorTest.java new file mode 100644 index 0000000000..6e118a5c76 --- /dev/null +++ b/application/src/test/java/org/togetherjava/tjbot/features/basic/SlashCommandEducatorTest.java @@ -0,0 +1,70 @@ +package org.togetherjava.tjbot.features.basic; + +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; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import org.togetherjava.tjbot.features.MessageReceiver; +import org.togetherjava.tjbot.jda.JdaTester; + +import java.util.List; +import java.util.stream.Stream; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +final class SlashCommandEducatorTest { + private JdaTester jdaTester; + private MessageReceiver messageReceiver; + + @BeforeEach + void setUp() { + jdaTester = new JdaTester(); + messageReceiver = new SlashCommandEducator(); + } + + private MessageReceivedEvent sendMessage(String content) { + MessageCreateData message = new MessageCreateBuilder().setContent(content).build(); + MessageReceivedEvent event = + jdaTester.createMessageReceiveEvent(message, List.of(), ChannelType.TEXT); + + messageReceiver.onMessageReceived(event); + + return event; + } + + @ParameterizedTest + @MethodSource("provideMessageCommands") + void sendsAdviceOnMessageCommand(String message) { + // GIVEN a message containing a message command + // WHEN the message is sent + MessageReceivedEvent event = sendMessage(message); + + // THEN the system replies to it with an advice + verify(event.getMessage(), times(1)).replyEmbeds(any(MessageEmbed.class)); + } + + @ParameterizedTest + @MethodSource("provideOtherMessages") + void ignoresOtherMessages(String message) { + // GIVEN a message that is not a message command + // WHEN the message is sent + MessageReceivedEvent event = sendMessage(message); + + // THEN the system ignores the message and does not reply to it + verify(event.getMessage(), never()).replyEmbeds(any(MessageEmbed.class)); + } + + private static Stream provideMessageCommands() { + return Stream.of("!foo", ".foo", "?foo", ".test", "!whatever", "!this is a test"); + } + + private static Stream provideOtherMessages() { + return Stream.of(" a ", "foo", "#foo", "/foo", "!!!", "?!?!?", "?", ".,-", "!f", "! foo"); + } +} diff --git a/application/src/test/java/org/togetherjava/tjbot/features/reminder/RemindRoutineTest.java b/application/src/test/java/org/togetherjava/tjbot/features/reminder/RemindRoutineTest.java index f11f4c398f..f4469c3e89 100644 --- a/application/src/test/java/org/togetherjava/tjbot/features/reminder/RemindRoutineTest.java +++ b/application/src/test/java/org/togetherjava/tjbot/features/reminder/RemindRoutineTest.java @@ -184,6 +184,6 @@ void reminderIsNotSendIfNotPending() { private static void assertSimilar(Instant expected, Instant actual) { // NOTE For some reason, the instant ends up in the database slightly wrong already (about // half a second), seems to be an issue with jOOQ - assertEquals(expected.toEpochMilli(), actual.toEpochMilli(), TimeUnit.SECONDS.toMillis(1)); + assertEquals(expected.toEpochMilli(), actual.toEpochMilli(), TimeUnit.SECONDS.toMillis(2)); } } 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 b5d68f6ea2..a634c93165 100644 --- a/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java +++ b/application/src/test/java/org/togetherjava/tjbot/jda/JdaTester.java @@ -216,6 +216,8 @@ public JdaTester() { doNothing().when(messageCreateAction).queue(); when(messageCreateAction.setContent(any())).thenReturn(messageCreateAction); when(messageCreateAction.addContent(any())).thenReturn(messageCreateAction); + when(messageCreateAction.addFiles(any(FileUpload.class))).thenReturn(messageCreateAction); + when(messageCreateAction.addFiles(anyCollection())).thenReturn(messageCreateAction); CacheRestAction privateChannelAction = createSucceededActionMock(privateChannel, CacheRestAction.class);