diff --git a/README.md b/README.md index b1e65d81e4..39eb7edde9 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,9 @@ [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=Together-Java_TJ-Bot&metric=reliability_rating)](https://sonarcloud.io/dashboard?id=Together-Java_TJ-Bot) [![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=Together-Java_TJ-Bot&metric=security_rating)](https://sonarcloud.io/dashboard?id=Together-Java_TJ-Bot) +[![Pipeline](https://woodpecker.togetherjava.org/api/badges/1/status.svg)](https://woodpecker.togetherjava.org/repos/1) + + TJ-Bot is a Discord Bot used on the [Together Java](https://discord.com/invite/XXFUXzK) server. It is maintained by the community, anyone can contribute! ![bot says hello](https://i.imgur.com/FE1MJTV.png) diff --git a/application/build.gradle b/application/build.gradle index 280d482687..a387e78a87 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -40,7 +40,7 @@ shadowJar { dependencies { implementation 'com.google.code.findbugs:jsr305:3.0.2' - implementation 'org.jetbrains:annotations:26.0.1' + implementation 'org.jetbrains:annotations:26.1.0' implementation project(':database') implementation project(':utils') @@ -80,7 +80,7 @@ dependencies { implementation 'org.apache.commons:commons-text:1.15.0' implementation 'com.apptasticsoftware:rssreader:3.12.0' - testImplementation 'org.mockito:mockito-core:5.21.0' + testImplementation 'org.mockito:mockito-core:5.23.0' testImplementation "org.junit.jupiter:junit-jupiter-api:$junitVersion" testImplementation "org.junit.jupiter:junit-jupiter-params:$junitVersion" testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/application/src/main/java/org/togetherjava/tjbot/Application.java b/application/src/main/java/org/togetherjava/tjbot/Application.java index 4c228cb02a..ec5e92a2f3 100644 --- a/application/src/main/java/org/togetherjava/tjbot/Application.java +++ b/application/src/main/java/org/togetherjava/tjbot/Application.java @@ -12,6 +12,7 @@ import org.togetherjava.tjbot.db.Database; import org.togetherjava.tjbot.features.Features; import org.togetherjava.tjbot.features.SlashCommandAdapter; +import org.togetherjava.tjbot.features.analytics.Metrics; import org.togetherjava.tjbot.features.system.BotCore; import org.togetherjava.tjbot.logging.LogMarkers; import org.togetherjava.tjbot.logging.discord.DiscordLogging; @@ -82,13 +83,15 @@ public static void runBot(Config config) { } Database database = new Database("jdbc:sqlite:" + databasePath.toAbsolutePath()); + Metrics metrics = new Metrics(database); + JDA jda = JDABuilder.createDefault(config.getToken()) .enableIntents(GatewayIntent.GUILD_MEMBERS, GatewayIntent.MESSAGE_CONTENT) .build(); jda.awaitReady(); - BotCore core = new BotCore(jda, database, config); + BotCore core = new BotCore(jda, database, config, metrics); CommandReloading.reloadCommands(jda, core); core.scheduleRoutines(jda); 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 6febd433b6..1f6acbef2e 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -6,11 +6,11 @@ import org.togetherjava.tjbot.config.FeatureBlacklist; import org.togetherjava.tjbot.config.FeatureBlacklistConfig; import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.features.analytics.Metrics; import org.togetherjava.tjbot.features.basic.MemberCountDisplayRoutine; import org.togetherjava.tjbot.features.basic.PingCommand; import org.togetherjava.tjbot.features.basic.QuoteBoardForwarder; import org.togetherjava.tjbot.features.basic.RoleSelectCommand; -import org.togetherjava.tjbot.features.basic.SlashCommandEducator; import org.togetherjava.tjbot.features.basic.SuggestionsUpDownVoter; import org.togetherjava.tjbot.features.bookmarks.BookmarksCommand; import org.togetherjava.tjbot.features.bookmarks.BookmarksSystem; @@ -91,7 +91,7 @@ * it with the system. *

* To add a new slash command, extend the commands returned by - * {@link #createFeatures(JDA, Database, Config)}. + * {@link #createFeatures(JDA, Database, Config, Metrics)}. */ public class Features { private Features() { @@ -107,9 +107,12 @@ private Features() { * @param jda the JDA instance commands will be registered at * @param database the database of the application, which features can use to persist data * @param config the configuration features should use + * @param metrics the metrics service for tracking analytics * @return a collection of all features */ - public static Collection createFeatures(JDA jda, Database database, Config config) { + @SuppressWarnings("unused") + public static Collection createFeatures(JDA jda, Database database, Config config, + Metrics metrics) { FeatureBlacklistConfig blacklistConfig = config.getFeatureBlacklistConfig(); JShellEval jshellEval = new JShellEval(config.getJshell(), config.getGitHubApiKey()); @@ -118,16 +121,18 @@ public static Collection createFeatures(JDA jda, Database database, Con ModerationActionsStore actionsStore = new ModerationActionsStore(database); ModAuditLogWriter modAuditLogWriter = new ModAuditLogWriter(config); ScamHistoryStore scamHistoryStore = new ScamHistoryStore(database); - GitHubReference githubReference = new GitHubReference(config); + GitHubReference githubReference = new GitHubReference(config, metrics); CodeMessageHandler codeMessageHandler = - new CodeMessageHandler(blacklistConfig.special(), jshellEval); - ChatGptService chatGptService = new ChatGptService(config); + new CodeMessageHandler(blacklistConfig.special(), jshellEval, metrics); + ChatGptService chatGptService = new ChatGptService(config, metrics); HelpSystemHelper helpSystemHelper = new HelpSystemHelper(config, database, chatGptService); HelpThreadLifecycleListener helpThreadLifecycleListener = new HelpThreadLifecycleListener(helpSystemHelper, database); + HelpThreadCreatedListener helpThreadCreatedListener = + new HelpThreadCreatedListener(helpSystemHelper, metrics); TopHelpersService topHelpersService = new TopHelpersService(database); TopHelpersAssignmentRoutine topHelpersAssignmentRoutine = - new TopHelpersAssignmentRoutine(config, topHelpersService); + new TopHelpersAssignmentRoutine(config, topHelpersService, metrics); // NOTE The system can add special system relevant commands also by itself, // hence this list may not necessarily represent the full list of all commands actually @@ -142,38 +147,37 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new ScamHistoryPurgeRoutine(scamHistoryStore)); features.add(new HelpThreadMetadataPurger(database)); features.add(new HelpThreadActivityUpdater(helpSystemHelper)); - features - .add(new AutoPruneHelperRoutine(config, helpSystemHelper, modAuditLogWriter, database)); + features.add(new AutoPruneHelperRoutine(config, helpSystemHelper, modAuditLogWriter, + database, metrics)); features.add(new HelpThreadAutoArchiver(helpSystemHelper)); features.add(new LeftoverBookmarksCleanupRoutine(bookmarksSystem)); features.add(new MarkHelpThreadCloseInDBRoutine(database, helpThreadLifecycleListener)); features.add(new MemberCountDisplayRoutine(config)); - features.add(new RSSHandlerRoutine(config, database)); + features.add(new RSSHandlerRoutine(config, database, metrics)); features.add(topHelpersAssignmentRoutine); // Message receivers features.add(new TopHelpersMessageListener(database, config)); - features.add(new SuggestionsUpDownVoter(config)); - features.add(new ScamBlocker(actionsStore, scamHistoryStore, config)); - features.add(new MediaOnlyChannelListener(config)); - features.add(new FileSharingMessageListener(config)); - features.add(new BlacklistedAttachmentListener(config, modAuditLogWriter)); + features.add(new SuggestionsUpDownVoter(config, metrics)); + features.add(new ScamBlocker(actionsStore, scamHistoryStore, config, metrics)); + features.add(new MediaOnlyChannelListener(config, metrics)); + features.add(new FileSharingMessageListener(config, metrics)); + features.add(new BlacklistedAttachmentListener(config, modAuditLogWriter, metrics)); features.add(githubReference); features.add(codeMessageHandler); features.add(new CodeMessageAutoDetection(config, codeMessageHandler)); features.add(new CodeMessageManualDetection(codeMessageHandler)); - features.add(new SlashCommandEducator()); features.add(new PinnedNotificationRemover(config)); features.add(new QuoteBoardForwarder(config)); // Voice receivers - features.add(new DynamicVoiceChat(config)); + features.add(new DynamicVoiceChat(config, metrics)); // Event receivers - features.add(new RejoinModerationRoleListener(actionsStore, config)); - features.add(new GuildLeaveCloseThreadListener(config)); + features.add(new RejoinModerationRoleListener(actionsStore, config, metrics)); + features.add(new GuildLeaveCloseThreadListener(config, metrics)); features.add(new LeftoverBookmarksListener(bookmarksSystem)); - features.add(new HelpThreadCreatedListener(helpSystemHelper)); + features.add(helpThreadCreatedListener); features.add(new HelpThreadLifecycleListener(helpSystemHelper, database)); features.add(new ProjectsThreadCreatedListener(config)); @@ -186,7 +190,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new LogLevelCommand()); features.add(new PingCommand()); features.add(new TeXCommand()); - features.add(new TagCommand(tagSystem)); + features.add(new TagCommand(tagSystem, metrics)); features.add(new TagManageCommand(tagSystem, modAuditLogWriter)); features.add(new TagsCommand(tagSystem)); features.add(new WarnCommand(actionsStore)); @@ -206,7 +210,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new WolframAlphaCommand(config)); features.add(new GitHubCommand(githubReference)); features.add(new ModMailCommand(jda, config)); - features.add(new HelpThreadCommand(config, helpSystemHelper)); + features.add(new HelpThreadCommand(config, helpSystemHelper, metrics)); features.add(new ReportCommand(config)); features.add(new BookmarksCommand(bookmarksSystem)); features.add(new ChatGptCommand(chatGptService, helpSystemHelper)); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/analytics/Metrics.java b/application/src/main/java/org/togetherjava/tjbot/features/analytics/Metrics.java new file mode 100644 index 0000000000..9aa0c797fe --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/analytics/Metrics.java @@ -0,0 +1,56 @@ +package org.togetherjava.tjbot.features.analytics; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.db.Database; +import org.togetherjava.tjbot.db.generated.tables.MetricEvents; + +import java.time.Instant; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +/** + * Service for tracking and recording events for analytics purposes. + */ +public final class Metrics { + private static final Logger logger = LoggerFactory.getLogger(Metrics.class); + + private final Database database; + + private final ExecutorService service = Executors.newSingleThreadExecutor(); + + /** + * Creates a new instance. + * + * @param database the database to use for storing and retrieving analytics data + */ + public Metrics(Database database) { + this.database = database; + } + + /** + * Track an event execution. + * + * @param event the event to save + */ + public void count(String event) { + logger.debug("Counting new record for event: {}", event); + Instant moment = Instant.now(); + service.submit(() -> processEvent(event, moment)); + + } + + /** + * + * @param event the event to save + * @param happenedAt the moment when the event is dispatched + */ + private void processEvent(String event, Instant happenedAt) { + database.write(context -> context.newRecord(MetricEvents.METRIC_EVENTS) + .setEvent(event) + .setHappenedAt(happenedAt) + .insert()); + } + +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/analytics/package-info.java b/application/src/main/java/org/togetherjava/tjbot/features/analytics/package-info.java new file mode 100644 index 0000000000..d06d76f93d --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/analytics/package-info.java @@ -0,0 +1,13 @@ +/** + * Analytics system for collecting and persisting bot activity metrics. + *

+ * This package provides services and components that record events for later analysis and reporting + * across multiple feature areas. + */ +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package org.togetherjava.tjbot.features.analytics; + +import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; 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 deleted file mode 100644 index f2c0d7e24b..0000000000 --- a/application/src/main/java/org/togetherjava/tjbot/features/basic/SlashCommandEducator.java +++ /dev/null @@ -1,74 +0,0 @@ -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 int MAX_COMMAND_LENGTH = 30; - 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 (don't end with code stuff) - """, 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) && content.length() < MAX_COMMAND_LENGTH) { - 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).queue(); - } - - private static MessageCreateAction createReply(Message messageToReplyTo, String content) { - boolean useImage = true; - InputStream imageData = - HelpSystemHelper.class.getResourceAsStream("/" + SLASH_COMMAND_POPUP_ADVICE_PATH); - if (imageData == null) { - useImage = false; - } - - MessageEmbed embed = new EmbedBuilder().setDescription(content) - .setImage(useImage ? "attachment://" + SLASH_COMMAND_POPUP_ADVICE_PATH : null) - .build(); - - MessageCreateAction action = messageToReplyTo.replyEmbeds(embed); - if (useImage) { - action = action - .addFiles(FileUpload.fromData(imageData, SLASH_COMMAND_POPUP_ADVICE_PATH)); - } - - return action; - } -} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/basic/SuggestionsUpDownVoter.java b/application/src/main/java/org/togetherjava/tjbot/features/basic/SuggestionsUpDownVoter.java index 5dbbdbf8b2..861454b0a6 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/basic/SuggestionsUpDownVoter.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/basic/SuggestionsUpDownVoter.java @@ -13,6 +13,7 @@ import org.togetherjava.tjbot.config.Config; import org.togetherjava.tjbot.config.SuggestionsConfig; import org.togetherjava.tjbot.features.MessageReceiverAdapter; +import org.togetherjava.tjbot.features.analytics.Metrics; import java.util.Optional; import java.util.regex.Pattern; @@ -28,16 +29,19 @@ public final class SuggestionsUpDownVoter extends MessageReceiverAdapter { private static final int THREAD_TITLE_MAX_LENGTH = 60; private final SuggestionsConfig config; + private final Metrics metrics; /** * Creates a new listener to receive all message sent in suggestion channels. * * @param config the config to use for this + * @param metrics to track events */ - public SuggestionsUpDownVoter(Config config) { + public SuggestionsUpDownVoter(Config config, Metrics metrics) { super(Pattern.compile(config.getSuggestions().getChannelPattern())); this.config = config.getSuggestions(); + this.metrics = metrics; } @Override @@ -49,14 +53,24 @@ public void onMessageReceived(MessageReceivedEvent event) { Guild guild = event.getGuild(); Message message = event.getMessage(); + metrics.count("suggestion"); createThread(message); + reactWith(config.getUpVoteEmoteName(), FALLBACK_UP_VOTE, guild, message); reactWith(config.getDownVoteEmoteName(), FALLBACK_DOWN_VOTE, guild, message); } private static void createThread(Message message) { String threadTitle = generateThreadTitle(message); - message.createThreadChannel(threadTitle).queue(); + message.createThreadChannel(threadTitle).queue(_ -> { + }, exception -> { + if (exception instanceof ErrorResponseException responseException + && responseException.getErrorResponse() == ErrorResponse.UNKNOWN_MESSAGE) { + logger.info("Failed to start suggestion thread: source message deleted"); + return; + + } + }); } /** @@ -91,19 +105,25 @@ private static void reactWith(String emojiName, Emoji fallbackEmoji, Guild guild "Unable to vote on a suggestion with the configured emoji ('{}'), using fallback instead.", emojiName); return message.addReaction(fallbackEmoji); - }).queue(ignored -> { - }, exception -> { - if (exception instanceof ErrorResponseException responseException - && responseException.getErrorResponse() == ErrorResponse.REACTION_BLOCKED) { + }).queue(_ -> { + }, SuggestionsUpDownVoter::handleReactionFailure); + } + + private static void handleReactionFailure(Throwable exception) { + if (exception instanceof ErrorResponseException responseException) { + if (responseException.getErrorResponse() == ErrorResponse.REACTION_BLOCKED) { // User blocked the bot, hence the bot can not add reactions to their messages. - // Nothing we can do here. return; } - - logger.error("Attempted to react to a suggestion, but failed", exception); - }); + if (responseException.getErrorResponse() == ErrorResponse.UNKNOWN_MESSAGE) { + logger.info("Failed to react to suggestion: source message deleted"); + return; + } + } + logger.error("Attempted to react to a suggestion, but failed", exception); } + private static Optional getEmojiByName(String name, Guild guild) { return guild.getEmojisByName(name, false).stream().findAny(); } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptService.java b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptService.java index 08ddbee729..3b2e78af73 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptService.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptService.java @@ -9,6 +9,7 @@ import org.slf4j.LoggerFactory; import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.features.analytics.Metrics; import javax.annotation.Nullable; @@ -28,14 +29,18 @@ public class ChatGptService { private boolean isDisabled = false; private OpenAIClient openAIClient; + private Metrics metrics; /** * Creates instance of ChatGPTService * * @param config needed for token to OpenAI API. + * @param metrics to track events */ - public ChatGptService(Config config) { + public ChatGptService(Config config, Metrics metrics) { String apiKey = config.getOpenaiApiKey(); + this.metrics = metrics; + boolean keyIsDefaultDescription = apiKey.startsWith("<") && apiKey.endsWith(">"); if (apiKey.isBlank() || keyIsDefaultDescription) { isDisabled = true; @@ -111,6 +116,7 @@ private Optional sendPrompt(String prompt, ChatGptModel chatModel) { .build(); Response chatGptResponse = openAIClient.responses().create(params); + metrics.count("chatgpt-prompted"); String response = chatGptResponse.output() .stream() 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 601f91663f..38b2c44164 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 @@ -17,6 +17,7 @@ import org.togetherjava.tjbot.features.MessageReceiverAdapter; import org.togetherjava.tjbot.features.UserInteractionType; import org.togetherjava.tjbot.features.UserInteractor; +import org.togetherjava.tjbot.features.analytics.Metrics; import org.togetherjava.tjbot.features.componentids.ComponentIdGenerator; import org.togetherjava.tjbot.features.componentids.ComponentIdInteractor; import org.togetherjava.tjbot.features.jshell.JShellEval; @@ -52,6 +53,7 @@ public final class CodeMessageHandler extends MessageReceiverAdapter implements static final Color AMBIENT_COLOR = Color.decode("#FDFD96"); private final ComponentIdInteractor componentIdInteractor; + private final Metrics metrics; private final Map labelToCodeAction; /** @@ -71,9 +73,12 @@ public final class CodeMessageHandler extends MessageReceiverAdapter implements * @param blacklist the feature blacklist, used to test if certain code actions should be * disabled * @param jshellEval used to execute java code and build visual result + * @param metrics to track events */ - public CodeMessageHandler(FeatureBlacklist blacklist, JShellEval jshellEval) { + public CodeMessageHandler(FeatureBlacklist blacklist, JShellEval jshellEval, + Metrics metrics) { componentIdInteractor = new ComponentIdInteractor(getInteractionType(), getName()); + this.metrics = metrics; List codeActions = blacklist .filterStream(Stream.of(new FormatCodeCommand(), new EvalCodeCommand(jshellEval)), @@ -183,6 +188,7 @@ public void onButtonClick(ButtonInteractionEvent event, List args) { CodeFence code = extractCodeOrFallback(originalMessage.get().getContentRaw()); // Apply the selected action + metrics.count("code_action-" + codeAction.getLabel()); return event.getHook() .editOriginalEmbeds(codeAction.apply(code)) .setActionRow(createButtons(originalMessageId, codeAction)); 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 c040eaf065..bdc4f13c38 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 @@ -18,6 +18,7 @@ import org.togetherjava.tjbot.features.MessageReceiverAdapter; import org.togetherjava.tjbot.features.UserInteractionType; import org.togetherjava.tjbot.features.UserInteractor; +import org.togetherjava.tjbot.features.analytics.Metrics; import org.togetherjava.tjbot.features.componentids.ComponentIdGenerator; import org.togetherjava.tjbot.features.componentids.ComponentIdInteractor; import org.togetherjava.tjbot.features.utils.Guilds; @@ -46,6 +47,7 @@ public final class FileSharingMessageListener extends MessageReceiverAdapter new ComponentIdInteractor(getInteractionType(), getName()); private final String githubApiKey; + private final Metrics metrics; private final Set extensionFilter = Set.of("txt", "java", "gradle", "xml", "kt", "json", "fxml", "css", "c", "h", "cpp", "py", "yml"); @@ -56,11 +58,13 @@ public final class FileSharingMessageListener extends MessageReceiverAdapter * Creates a new instance. * * @param config used to get api key and channel names. + * @param metrics to track events * @see org.togetherjava.tjbot.features.Features */ - public FileSharingMessageListener(Config config) { + public FileSharingMessageListener(Config config, Metrics metrics) { super(Pattern.compile(".*")); githubApiKey = config.getGitHubApiKey(); + this.metrics = metrics; isHelpForumName = Pattern.compile(config.getHelpSystem().getHelpForumPattern()).asMatchPredicate(); isSoftModRole = Pattern.compile(config.getSoftModerationRolePattern()).asMatchPredicate(); @@ -112,6 +116,7 @@ public void onButtonClick(ButtonInteractionEvent event, List args) { new GitHubBuilder().withOAuthToken(githubApiKey).build().getGist(gistId).delete(); event.deferEdit().queue(); event.getHook().deleteOriginal().queue(); + metrics.count("file_sharing-deleted"); } catch (IOException e) { logger.warn("Failed to delete gist with id {}", gistId, e); } @@ -190,6 +195,7 @@ private void sendResponse(MessageReceivedEvent event, String url, String gistId) componentIdInteractor.generateComponentId(message.getAuthor().getId(), gistId), "Delete"); + metrics.count("file_sharing-uploaded"); message.reply(messageContent).setActionRow(gist, delete).queue(); } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/github/GitHubCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/github/GitHubCommand.java index b52e79550d..4091912755 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/github/GitHubCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/github/GitHubCommand.java @@ -2,6 +2,7 @@ import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.InteractionHook; import net.dv8tion.jda.api.interactions.commands.OptionType; import org.kohsuke.github.GHIssue; import org.slf4j.Logger; @@ -44,6 +45,7 @@ public final class GitHubCommand extends SlashCommandAdapter { private static final String TITLE_OPTION = "title"; private static final Logger logger = LoggerFactory.getLogger(GitHubCommand.class); + private static final int MAX_SUGGESTED_CHOICES = 25; private final GitHubReference reference; @@ -98,10 +100,13 @@ public void onSlashCommand(SlashCommandInteractionEvent event) { String[] issueData = titleOption.split(" ", 2); String targetIssueTitle = issueData[1]; + event.deferReply().queue(); + InteractionHook hook = event.getHook(); + reference.findIssue(issueId, targetIssueTitle) - .ifPresentOrElse(issue -> event.replyEmbeds(reference.generateReply(issue)).queue(), - () -> event.reply("Could not find the issue you are looking for.") - .setEphemeral(true) + .ifPresentOrElse( + issue -> hook.editOriginalEmbeds(reference.generateReply(issue)).queue(), + () -> hook.editOriginal("Could not find the issue you are looking for.") .queue()); } @@ -109,17 +114,21 @@ public void onSlashCommand(SlashCommandInteractionEvent event) { public void onAutoComplete(CommandAutoCompleteInteractionEvent event) { String title = event.getOption(TITLE_OPTION).getAsString(); + List choices; if (title.isEmpty()) { - event.replyChoiceStrings(autocompleteGHIssueCache.stream().limit(25).toList()).queue(); + choices = autocompleteGHIssueCache.stream().limit(MAX_SUGGESTED_CHOICES).toList(); } else { Queue closestSuggestions = new PriorityQueue<>(Comparator.comparingInt(suggestionScorer(title))); - closestSuggestions.addAll(autocompleteGHIssueCache); + choices = + Stream.generate(closestSuggestions::poll).limit(MAX_SUGGESTED_CHOICES).toList(); + } - List choices = Stream.generate(closestSuggestions::poll).limit(25).toList(); - event.replyChoiceStrings(choices).queue(); + if (choices.isEmpty()) { + choices = List.of("No issues found"); } + event.replyChoiceStrings(choices).queue(); if (isCacheExpired()) { updateCache(); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/github/GitHubReference.java b/application/src/main/java/org/togetherjava/tjbot/features/github/GitHubReference.java index 960587e8a4..0b48799806 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/github/GitHubReference.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/github/GitHubReference.java @@ -21,6 +21,7 @@ import org.togetherjava.tjbot.config.Config; import org.togetherjava.tjbot.features.MessageReceiverAdapter; +import org.togetherjava.tjbot.features.analytics.Metrics; import java.awt.Color; import java.io.FileNotFoundException; @@ -67,6 +68,7 @@ public final class GitHubReference extends MessageReceiverAdapter { DateTimeFormatter.ofPattern("dd MMM, yyyy").withZone(ZoneOffset.UTC); private final Predicate hasGithubIssueReferenceEnabled; private final Config config; + private final Metrics metrics; /** * The repositories that are searched when looking for an issue. @@ -80,9 +82,11 @@ public final class GitHubReference extends MessageReceiverAdapter { * a predicate for matching allowed channels for feature and acquires repositories. * * @param config The Config to get allowed channel pattern for feature. + * @param metrics to track events */ - public GitHubReference(Config config) { + public GitHubReference(Config config, Metrics metrics) { this.config = config; + this.metrics = metrics; this.hasGithubIssueReferenceEnabled = Pattern.compile(config.getGitHubReferencingEnabledChannelPattern()) .asMatchPredicate(); @@ -142,6 +146,7 @@ private void replyBatchEmbeds(List embeds, Message message, ? message.getChannel().asThreadChannel() : message.getChannel().asTextChannel(); + metrics.count("gh_reference"); for (List messageEmbeds : partition) { if (isFirstBatch) { message.replyEmbeds(messageEmbeds).mentionRepliedUser(mentionRepliedUser).queue(); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/AutoPruneHelperRoutine.java b/application/src/main/java/org/togetherjava/tjbot/features/help/AutoPruneHelperRoutine.java index 6ed381a36e..34ac5140ba 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/help/AutoPruneHelperRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/help/AutoPruneHelperRoutine.java @@ -12,6 +12,7 @@ import org.togetherjava.tjbot.config.HelperPruneConfig; import org.togetherjava.tjbot.db.Database; import org.togetherjava.tjbot.features.Routine; +import org.togetherjava.tjbot.features.analytics.Metrics; import org.togetherjava.tjbot.features.moderation.audit.ModAuditLogWriter; import javax.annotation.Nullable; @@ -45,6 +46,7 @@ public final class AutoPruneHelperRoutine implements Routine { private final HelpSystemHelper helper; private final ModAuditLogWriter modAuditLogWriter; private final Database database; + private final Metrics metrics; private final List allCategories; private final Predicate selectYourRolesChannelNamePredicate; @@ -55,13 +57,15 @@ public final class AutoPruneHelperRoutine implements Routine { * @param helper the helper to use * @param modAuditLogWriter to inform mods when manual pruning becomes necessary * @param database to determine whether a user is inactive + * @param metrics to track events */ public AutoPruneHelperRoutine(Config config, HelpSystemHelper helper, - ModAuditLogWriter modAuditLogWriter, Database database) { + ModAuditLogWriter modAuditLogWriter, Database database, Metrics metrics) { allCategories = config.getHelpSystem().getCategories(); this.helper = helper; this.modAuditLogWriter = modAuditLogWriter; this.database = database; + this.metrics = metrics; HelperPruneConfig helperPruneConfig = config.getHelperPruneConfig(); roleFullLimit = helperPruneConfig.roleFullLimit(); @@ -108,6 +112,7 @@ private void pruneRoleIfFull(List members, Role targetRole, if (isRoleFull(withRole)) { logger.debug("Helper role {} is full, starting to prune.", targetRole.getName()); + metrics.count("autoprune_helper-" + targetRole.getName()); pruneRole(targetRole, withRole, selectRoleChannel, when); } } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/GuildLeaveCloseThreadListener.java b/application/src/main/java/org/togetherjava/tjbot/features/help/GuildLeaveCloseThreadListener.java index 5d1a4d7260..a0a7facd88 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/help/GuildLeaveCloseThreadListener.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/help/GuildLeaveCloseThreadListener.java @@ -7,20 +7,24 @@ import org.togetherjava.tjbot.config.Config; import org.togetherjava.tjbot.features.EventReceiver; +import org.togetherjava.tjbot.features.analytics.Metrics; /** * Remove all thread channels associated to a user when they leave the guild. */ public final class GuildLeaveCloseThreadListener extends ListenerAdapter implements EventReceiver { private final String helpForumPattern; + private final Metrics metrics; /** * Creates a new instance. * * @param config the config to get help forum channel pattern from + * @param metrics to track events */ - public GuildLeaveCloseThreadListener(Config config) { + public GuildLeaveCloseThreadListener(Config config, Metrics metrics) { this.helpForumPattern = config.getHelpSystem().getHelpForumPattern(); + this.metrics = metrics; } @Override @@ -35,8 +39,11 @@ public void onGuildMemberRemove(GuildMemberRemoveEvent event) { .queue(threads -> threads.stream() .filter(thread -> thread.getOwnerIdLong() == event.getUser().getIdLong()) .filter(thread -> thread.getParentChannel().getName().matches(helpForumPattern)) - .forEach(thread -> thread.sendMessageEmbeds(embed) - .flatMap(_ -> thread.getManager().setArchived(true)) - .queue())); + .forEach(thread -> { + metrics.count("op_left_thread"); + thread.sendMessageEmbeds(embed) + .flatMap(_ -> thread.getManager().setArchived(true)) + .queue(); + })); } } 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 edf217f1ea..5f88ff1019 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 @@ -236,7 +236,6 @@ private RestAction useChatGptFallbackMessage(ThreadChannel threadChanne } void writeHelpThreadToDatabase(long authorId, ThreadChannel threadChannel) { - Instant createdAt = threadChannel.getTimeCreated().toInstant(); String appliedTags = threadChannel.getAppliedTags() diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadAutoArchiver.java b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadAutoArchiver.java index 41792957ff..d4537cdab5 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadAutoArchiver.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadAutoArchiver.java @@ -8,12 +8,18 @@ import net.dv8tion.jda.api.entities.MessageEmbed; import net.dv8tion.jda.api.entities.channel.concrete.ForumChannel; import net.dv8tion.jda.api.entities.channel.concrete.ThreadChannel; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.interactions.components.buttons.Button; import net.dv8tion.jda.api.requests.RestAction; import net.dv8tion.jda.api.utils.TimeUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.togetherjava.tjbot.features.Routine; +import org.togetherjava.tjbot.features.UserInteractionType; +import org.togetherjava.tjbot.features.UserInteractor; +import org.togetherjava.tjbot.features.componentids.ComponentIdGenerator; +import org.togetherjava.tjbot.features.componentids.ComponentIdInteractor; import java.time.Duration; import java.time.Instant; @@ -27,12 +33,16 @@ * Routine, which periodically checks all help threads and archives them if there has not been any * recent activity. */ -public final class HelpThreadAutoArchiver implements Routine { +public final class HelpThreadAutoArchiver implements Routine, UserInteractor { private static final Logger logger = LoggerFactory.getLogger(HelpThreadAutoArchiver.class); private static final int SCHEDULE_MINUTES = 60; private static final Duration ARCHIVE_AFTER_INACTIVITY_OF = Duration.ofHours(12); + private static final String MARK_ACTIVE_LABEL = "Mark Active"; + private static final String MARK_ACTIVE_ID = "mark-active"; private final HelpSystemHelper helper; + private final ComponentIdInteractor inactivityInteractor = + new ComponentIdInteractor(getInteractionType(), getName()); /** * Creates a new instance. @@ -43,6 +53,41 @@ public HelpThreadAutoArchiver(HelpSystemHelper helper) { this.helper = helper; } + @Override + public String getName() { + return "help-thread-auto-archiver"; + } + + @Override + public UserInteractionType getInteractionType() { + return UserInteractionType.OTHER; + } + + @Override + public void acceptComponentIdGenerator(ComponentIdGenerator generator) { + inactivityInteractor.acceptComponentIdGenerator(generator); + } + + @Override + public void onButtonClick(ButtonInteractionEvent event, List args) { + onMarkActiveButton(event); + } + + private void onMarkActiveButton(ButtonInteractionEvent event) { + event.reply("You have marked the thread as active.").setEphemeral(true).queue(); + + ThreadChannel thread = event.getChannel().asThreadChannel(); + Message botClosedThreadMessage = event.getMessage(); + + thread.getManager() + .setArchived(false) + .flatMap(_ -> botClosedThreadMessage.delete()) + .queue(); + + logger.debug("Thread {} was manually reactivated via button by user {}", thread.getId(), + event.getUser().getId()); + } + @Override public Schedule createSchedule() { return new Schedule(ScheduleMode.FIXED_RATE, 0, SCHEDULE_MINUTES, TimeUnit.MINUTES); @@ -88,8 +133,8 @@ private void autoArchiveForThread(ThreadChannel threadChannel) { """ Your question has been closed due to inactivity. - If it was not resolved yet, feel free to just post a message below - to reopen it, or create a new thread. + If it was not resolved yet, **click the button below** to keep it + open, or feel free to create a new thread. Note that usually the reason for nobody calling back is that your question may have been not well asked and hence no one felt confident @@ -131,11 +176,16 @@ private void handleArchiveFlow(ThreadChannel threadChannel, MessageEmbed embed) private void triggerArchiveFlow(ThreadChannel threadChannel, long authorId, MessageEmbed embed) { + String markActiveId = inactivityInteractor.generateComponentId(MARK_ACTIVE_ID); + Function> sendEmbedWithMention = - member -> threadChannel.sendMessage(member.getAsMention()).addEmbeds(embed); + member -> threadChannel.sendMessage(member.getAsMention()) + .addEmbeds(embed) + .addActionRow(Button.primary(markActiveId, MARK_ACTIVE_LABEL)); Supplier> sendEmbedWithoutMention = - () -> threadChannel.sendMessageEmbeds(embed); + () -> threadChannel.sendMessageEmbeds(embed) + .addActionRow(Button.primary(markActiveId, MARK_ACTIVE_LABEL)); threadChannel.getGuild() .retrieveMemberById(authorId) @@ -161,8 +211,12 @@ private void triggerAuthorIdNotFoundArchiveFlow(ThreadChannel threadChannel, logger.info( "Was unable to find a matching thread for id: {} in DB, archiving thread without mentioning OP", threadChannel.getId()); + + String markActiveId = inactivityInteractor.generateComponentId(MARK_ACTIVE_ID); + threadChannel.sendMessageEmbeds(embed) - .flatMap(sentEmbed -> threadChannel.getManager().setArchived(true)) + .addActionRow(Button.primary(markActiveId, MARK_ACTIVE_LABEL)) + .flatMap(_ -> threadChannel.getManager().setArchived(true)) .queue(); } } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadCommand.java index fd6b264e0c..459fc5a904 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadCommand.java @@ -20,6 +20,7 @@ import org.togetherjava.tjbot.config.Config; import org.togetherjava.tjbot.features.CommandVisibility; import org.togetherjava.tjbot.features.SlashCommandAdapter; +import org.togetherjava.tjbot.features.analytics.Metrics; import java.time.Instant; import java.time.temporal.ChronoUnit; @@ -56,6 +57,7 @@ public final class HelpThreadCommand extends SlashCommandAdapter { public static final String COMMAND_NAME = "help-thread"; private final HelpSystemHelper helper; + private final Metrics metrics; private final Map nameToSubcommand; private final Map> subcommandToCooldownCache; private final Map> subcommandToEventHandler; @@ -65,8 +67,9 @@ public final class HelpThreadCommand extends SlashCommandAdapter { * * @param config the config to use * @param helper the helper to use + * @param metrics to track events */ - public HelpThreadCommand(Config config, HelpSystemHelper helper) { + public HelpThreadCommand(Config config, HelpSystemHelper helper, Metrics metrics) { super(COMMAND_NAME, "Help thread specific commands", CommandVisibility.GUILD); OptionData categoryChoices = @@ -93,6 +96,7 @@ public HelpThreadCommand(Config config, HelpSystemHelper helper) { getData().addSubcommands(Subcommand.RESET_ACTIVITY.toSubcommandData()); this.helper = helper; + this.metrics = metrics; Supplier> createCooldownCache = () -> Caffeine.newBuilder() .maximumSize(1_000) @@ -158,6 +162,7 @@ private void changeCategory(SlashCommandInteractionEvent event, ThreadChannel he event.deferReply().queue(); refreshCooldownFor(Subcommand.CHANGE_CATEGORY, helpThread); + metrics.count("help-category-" + category); helper.changeChannelCategory(helpThread, category) .flatMap(_ -> sendCategoryChangedMessage(helpThread.getGuild(), event.getHook(), helpThread, category)) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadCreatedListener.java b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadCreatedListener.java index bbf8490a2c..c0f01b8245 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadCreatedListener.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadCreatedListener.java @@ -22,6 +22,7 @@ import org.togetherjava.tjbot.features.EventReceiver; import org.togetherjava.tjbot.features.UserInteractionType; import org.togetherjava.tjbot.features.UserInteractor; +import org.togetherjava.tjbot.features.analytics.Metrics; import org.togetherjava.tjbot.features.componentids.ComponentIdGenerator; import org.togetherjava.tjbot.features.componentids.ComponentIdInteractor; import org.togetherjava.tjbot.features.utils.LinkDetection; @@ -31,6 +32,7 @@ import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -46,6 +48,7 @@ public final class HelpThreadCreatedListener extends ListenerAdapter implements EventReceiver, UserInteractor { private static final Logger log = LoggerFactory.getLogger(HelpThreadCreatedListener.class); private final HelpSystemHelper helper; + private final Metrics metrics; private final Cache threadIdToCreatedAtCache = Caffeine.newBuilder() .maximumSize(1_000) @@ -58,9 +61,11 @@ public final class HelpThreadCreatedListener extends ListenerAdapter * Creates a new instance. * * @param helper to work with the help threads + * @param metrics to track events */ - public HelpThreadCreatedListener(HelpSystemHelper helper) { + public HelpThreadCreatedListener(HelpSystemHelper helper, Metrics metrics) { this.helper = helper; + this.metrics = metrics; } @Override @@ -88,6 +93,7 @@ private boolean wasThreadAlreadyHandled(long threadChannelId) { } private void handleHelpThreadCreated(ThreadChannel threadChannel) { + metrics.count("help-question_posted"); threadChannel.retrieveStartMessage().flatMap(message -> { registerThreadDataInDB(message, threadChannel); return sendHelperHeadsUp(threadChannel) @@ -121,10 +127,11 @@ private RestAction pinOriginalQuestion(Message message) { private RestAction sendHelperHeadsUp(ThreadChannel threadChannel) { String alternativeMention = "Helper"; - String helperMention = helper.getCategoryTagOfChannel(threadChannel) - .map(ForumTag::getName) - .flatMap(category -> helper.handleFindRoleForCategory(category, - threadChannel.getGuild())) + Optional forumTagName = + helper.getCategoryTagOfChannel(threadChannel).map(ForumTag::getName); + forumTagName.ifPresent(name -> metrics.count("help-category-" + name)); + String helperMention = forumTagName.flatMap( + category -> helper.handleFindRoleForCategory(category, threadChannel.getGuild())) .map(Role::getAsMention) .orElse(alternativeMention); @@ -187,7 +194,10 @@ private Consumer handleParentMessageDeleted(Member user, ThreadChanne @Override public void onButtonClick(ButtonInteractionEvent event, List args) { - // This method handles chatgpt's automatic response "dismiss" button + onAiHelpDismissButton(event, args); + } + + private void onAiHelpDismissButton(ButtonInteractionEvent event, List args) { event.deferEdit().queue(); ThreadChannel channel = event.getChannel().asThreadChannel(); @@ -197,7 +207,6 @@ public void onButtonClick(ButtonInteractionEvent event, List args) { .queue(forumPostMessage -> handleDismiss(interactionUser, channel, forumPostMessage, event, args), handleParentMessageDeleted(interactionUser, channel, event, args)); - } private boolean isPostAuthor(Member interactionUser, Message message) { @@ -231,6 +240,7 @@ private void handleDismiss(Member interactionUser, ThreadChannel channel, return; } + metrics.count("help-ai_dismiss"); RestAction deleteMessages = event.getMessage().delete(); for (String id : args) { deleteMessages = deleteMessages.and(channel.deleteMessageById(id)); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/mediaonly/MediaOnlyChannelListener.java b/application/src/main/java/org/togetherjava/tjbot/features/mediaonly/MediaOnlyChannelListener.java index 11f666beaa..ab9eedcc7b 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/mediaonly/MediaOnlyChannelListener.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/mediaonly/MediaOnlyChannelListener.java @@ -11,8 +11,10 @@ import org.togetherjava.tjbot.config.Config; import org.togetherjava.tjbot.features.MessageReceiverAdapter; +import org.togetherjava.tjbot.features.analytics.Metrics; import java.awt.Color; +import java.util.List; import java.util.concurrent.TimeUnit; import java.util.regex.Pattern; @@ -25,13 +27,18 @@ */ public final class MediaOnlyChannelListener extends MessageReceiverAdapter { + private final Metrics metrics; + /** * Creates a MediaOnlyChannelListener to receive all message sent in MediaOnly channel. * * @param config to find MediaOnly channels + * @param metrics metrics */ - public MediaOnlyChannelListener(Config config) { + public MediaOnlyChannelListener(Config config, Metrics metrics) { super(Pattern.compile(config.getMediaOnlyChannelPattern())); + + this.metrics = metrics; } @Override @@ -46,28 +53,45 @@ public void onMessageReceived(MessageReceivedEvent event) { } if (messageHasNoMediaAttached(message)) { + metrics.count("media_only_channel-msg_deleted"); message.delete().flatMap(_ -> dmUser(message)).queue(_ -> { }, failure -> tempNotifyUserInChannel(message)); } } + /** + * Checks whether the given message has no media attached. + *

+ * A message is considered to have media if it contains attachments, embeds, or a URL in its + * text content. For forwarded messages, the snapshots are also checked for media. + * + * @param message the message to check + * @return {@code true} if the message has no media, {@code false} otherwise + */ private boolean messageHasNoMediaAttached(Message message) { - return message.getAttachments().isEmpty() && message.getEmbeds().isEmpty() - && !message.getContentRaw().contains("http"); - } - - private MessageCreateData createNotificationMessage(Message message) { - String originalMessageContent = message.getContentRaw(); + if (hasMedia(message.getAttachments(), message.getEmbeds(), message.getContentRaw())) { + return false; + } - MessageEmbed originalMessageEmbed = - new EmbedBuilder().setDescription(originalMessageContent) - .setColor(Color.ORANGE) - .build(); + return message.getMessageSnapshots() + .stream() + .noneMatch(snapshot -> hasMedia(snapshot.getAttachments(), snapshot.getEmbeds(), + snapshot.getContentRaw())); + } - return new MessageCreateBuilder().setContent(message.getAuthor().getAsMention() - + " Hey there, you posted a message without media (image, video, link) in a media-only channel. Please see the description of the channel for details and then repost with media attached, thanks 😀") - .setEmbeds(originalMessageEmbed) - .build(); + /** + * Checks whether the given content contains any media. + *

+ * Media is considered present if there are attachments, embeds, or a URL (identified by + * {@code "http"}) in the text content. + * + * @param attachments the attachments of the message or snapshot + * @param embeds the embeds of the message or snapshot + * @param content the raw text content of the message or snapshot + */ + private boolean hasMedia(List attachments, List embeds, + String content) { + return !attachments.isEmpty() || !embeds.isEmpty() || content.contains("http"); } private RestAction dmUser(Message message) { @@ -82,4 +106,18 @@ private void tempNotifyUserInChannel(Message message) { .queue(notificationMessage -> notificationMessage.delete() .queueAfter(1, TimeUnit.MINUTES)); } + + private MessageCreateData createNotificationMessage(Message message) { + String originalMessageContent = message.getContentRaw(); + + MessageEmbed originalMessageEmbed = + new EmbedBuilder().setDescription(originalMessageContent) + .setColor(Color.ORANGE) + .build(); + + return new MessageCreateBuilder().setContent(message.getAuthor().getAsMention() + + " Hey there, you posted a message without media (image, video, link) in a media-only channel. Please see the description of the channel for details and then repost with media attached, thanks 😀") + .setEmbeds(originalMessageEmbed) + .build(); + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/moderation/RejoinModerationRoleListener.java b/application/src/main/java/org/togetherjava/tjbot/features/moderation/RejoinModerationRoleListener.java index 7312a9104a..5f0f7c740f 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/moderation/RejoinModerationRoleListener.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/RejoinModerationRoleListener.java @@ -11,6 +11,7 @@ import org.togetherjava.tjbot.config.Config; import org.togetherjava.tjbot.features.EventReceiver; +import org.togetherjava.tjbot.features.analytics.Metrics; import org.togetherjava.tjbot.logging.LogMarkers; import java.util.List; @@ -32,6 +33,7 @@ public final class RejoinModerationRoleListener implements EventReceiver { private final ModerationActionsStore actionsStore; private final List moderationRoles; + private final Metrics metrics; /** * Constructs an instance. @@ -39,9 +41,12 @@ public final class RejoinModerationRoleListener implements EventReceiver { * @param actionsStore used to store actions issued by this command and to retrieve whether a * user should be e.g. muted * @param config the config to use for this + * @param metrics to track events */ - public RejoinModerationRoleListener(ModerationActionsStore actionsStore, Config config) { + public RejoinModerationRoleListener(ModerationActionsStore actionsStore, Config config, + Metrics metrics) { this.actionsStore = actionsStore; + this.metrics = metrics; moderationRoles = List.of( new ModerationRole("mute", ModerationAction.MUTE, ModerationAction.UNMUTE, @@ -93,8 +98,10 @@ private boolean shouldApplyModerationRole(ModerationRole moderationRole, return false; } - private static void applyModerationRole(ModerationRole moderationRole, Member member) { + private void applyModerationRole(ModerationRole moderationRole, Member member) { Guild guild = member.getGuild(); + + metrics.count("mod-user_rejoined_reapplied_role"); logger.info(LogMarkers.SENSITIVE, "Reapplied existing {} to user '{}' ({}) in guild '{}' after rejoining.", moderationRole.actionName, member.getUser().getName(), member.getId(), 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 c7cd224f18..b043152e5e 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 @@ -11,6 +11,7 @@ import org.togetherjava.tjbot.config.Config; import org.togetherjava.tjbot.features.MessageReceiverAdapter; +import org.togetherjava.tjbot.features.analytics.Metrics; import org.togetherjava.tjbot.features.moderation.audit.ModAuditLogWriter; import org.togetherjava.tjbot.features.moderation.modmail.ModMailCommand; import org.togetherjava.tjbot.features.utils.MessageUtils; @@ -25,6 +26,7 @@ */ public final class BlacklistedAttachmentListener extends MessageReceiverAdapter { private final ModAuditLogWriter modAuditLogWriter; + private final Metrics metrics; private final List blacklistedFileExtensions; /** @@ -32,9 +34,12 @@ public final class BlacklistedAttachmentListener extends MessageReceiverAdapter * * @param config to find the blacklisted media attachments * @param modAuditLogWriter to inform the mods about the suspicious attachment + * @param metrics to track events */ - public BlacklistedAttachmentListener(Config config, ModAuditLogWriter modAuditLogWriter) { + public BlacklistedAttachmentListener(Config config, ModAuditLogWriter modAuditLogWriter, + Metrics metrics) { this.modAuditLogWriter = modAuditLogWriter; + this.metrics = metrics; blacklistedFileExtensions = config.getBlacklistedFileExtensions(); } @@ -49,6 +54,7 @@ public void onMessageReceived(MessageReceivedEvent event) { } private void handleBadMessage(Message message) { + metrics.count("blacklisted_attachment-deleted"); message.delete().flatMap(_ -> dmUser(message)).queue(_ -> warnMods(message)); } 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 094d05b5ce..a1314c5eb5 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 @@ -27,6 +27,7 @@ import org.togetherjava.tjbot.features.MessageReceiverAdapter; import org.togetherjava.tjbot.features.UserInteractionType; import org.togetherjava.tjbot.features.UserInteractor; +import org.togetherjava.tjbot.features.analytics.Metrics; import org.togetherjava.tjbot.features.componentids.ComponentIdGenerator; import org.togetherjava.tjbot.features.componentids.ComponentIdInteractor; import org.togetherjava.tjbot.features.moderation.ModerationAction; @@ -75,6 +76,7 @@ public final class ScamBlocker extends MessageReceiverAdapter implements UserInt private final ScamHistoryStore scamHistoryStore; private final Predicate isRequiredRole; + private final Metrics metrics; private final ComponentIdInteractor componentIdInteractor; /** @@ -83,9 +85,10 @@ public final class ScamBlocker extends MessageReceiverAdapter implements UserInt * @param actionsStore to store quarantine actions in * @param scamHistoryStore to store and retrieve scam history from * @param config the config to use for this + * @param metrics to track events */ public ScamBlocker(ModerationActionsStore actionsStore, ScamHistoryStore scamHistoryStore, - Config config) { + Config config, Metrics metrics) { this.actionsStore = actionsStore; this.scamHistoryStore = scamHistoryStore; this.config = config; @@ -102,6 +105,7 @@ public ScamBlocker(ModerationActionsStore actionsStore, ScamHistoryStore scamHis isRequiredRole = Pattern.compile(config.getSoftModerationRolePattern()).asMatchPredicate(); + this.metrics = metrics; componentIdInteractor = new ComponentIdInteractor(getInteractionType(), getName()); } @@ -164,6 +168,7 @@ private void takeActionWasAlreadyReported(MessageReceivedEvent event) { } private void takeAction(MessageReceivedEvent event) { + metrics.count("scam-detected"); switch (mode) { case OFF -> throw new AssertionError( "The OFF-mode should be detected earlier already to prevent expensive computation"); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/rss/RSSHandlerRoutine.java b/application/src/main/java/org/togetherjava/tjbot/features/rss/RSSHandlerRoutine.java index 1d89896038..d00bb54af7 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/rss/RSSHandlerRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/rss/RSSHandlerRoutine.java @@ -24,6 +24,7 @@ import org.togetherjava.tjbot.db.Database; import org.togetherjava.tjbot.db.generated.tables.records.RssFeedRecord; import org.togetherjava.tjbot.features.Routine; +import org.togetherjava.tjbot.features.analytics.Metrics; import javax.annotation.Nonnull; @@ -85,6 +86,7 @@ public final class RSSHandlerRoutine implements Routine { private final Map> targetChannelPatterns; private final int interval; private final Database database; + private final Metrics metrics; private final Cache circuitBreaker = Caffeine.newBuilder().expireAfterWrite(7, TimeUnit.DAYS).maximumSize(500).build(); @@ -99,11 +101,14 @@ public final class RSSHandlerRoutine implements Routine { * * @param config The configuration containing RSS feed details. * @param database The database for storing RSS feed data. + * @param metrics to track events */ - public RSSHandlerRoutine(Config config, Database database) { + public RSSHandlerRoutine(Config config, Database database, Metrics metrics) { this.config = config.getRSSFeedsConfig(); this.interval = this.config.pollIntervalInMinutes(); this.database = database; + this.metrics = metrics; + this.fallbackChannelPattern = Pattern.compile(this.config.fallbackChannelPattern()).asMatchPredicate(); isVideoLink = Pattern.compile(this.config.videoLinkPattern()).asMatchPredicate(); @@ -260,6 +265,7 @@ private Optional getLatestPostDateFromItems(List items, * @param feedConfig the RSS feed configuration */ private void postItem(List textChannels, Item rssItem, RSSFeed feedConfig) { + metrics.count("rss-item_posted"); MessageCreateData message = constructMessage(rssItem, feedConfig); textChannels.forEach(channel -> channel.sendMessage(message).queue()); } @@ -432,7 +438,7 @@ private List fetchRSSItemsFromURL(String rssUrl) { long blacklistedHours = calculateWaitHours(newCount); - logger.warn( + logger.debug( "RSS fetch failed for {} (Attempt #{}). Backing off for {} hours. Reason: {}", rssUrl, newCount, blacklistedHours, e.getMessage(), e); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/system/BotCore.java b/application/src/main/java/org/togetherjava/tjbot/features/system/BotCore.java index e9d99bc4d1..5dcdc0a81a 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/system/BotCore.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/system/BotCore.java @@ -23,7 +23,6 @@ import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; import net.dv8tion.jda.api.interactions.components.ComponentInteraction; -import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Unmodifiable; import org.slf4j.Logger; @@ -42,6 +41,7 @@ import org.togetherjava.tjbot.features.UserInteractionType; import org.togetherjava.tjbot.features.UserInteractor; import org.togetherjava.tjbot.features.VoiceReceiver; +import org.togetherjava.tjbot.features.analytics.Metrics; import org.togetherjava.tjbot.features.componentids.ComponentId; import org.togetherjava.tjbot.features.componentids.ComponentIdParser; import org.togetherjava.tjbot.features.componentids.ComponentIdStore; @@ -79,13 +79,13 @@ public final class BotCore extends ListenerAdapter implements CommandProvider { private static final ExecutorService COMMAND_SERVICE = Executors.newCachedThreadPool(); private static final ScheduledExecutorService ROUTINE_SERVICE = Executors.newScheduledThreadPool(5); - private final Config config; private final Map prefixedNameToInteractor; private final List routines; private final ComponentIdParser componentIdParser; private final ComponentIdStore componentIdStore; private final Map channelNameToMessageReceiver = new HashMap<>(); private final Map channelNameToVoiceReceiver = new HashMap<>(); + private final Metrics metrics; /** * Creates a new command system which uses the given database to allow commands to persist data. @@ -95,10 +95,11 @@ public final class BotCore extends ListenerAdapter implements CommandProvider { * @param jda the JDA instance that this command system will be used with * @param database the database that commands may use to persist data * @param config the configuration to use for this system + * @param metrics the metrics service for tracking analytics */ - public BotCore(JDA jda, Database database, Config config) { - this.config = config; - Collection features = Features.createFeatures(jda, database, config); + public BotCore(JDA jda, Database database, Config config, Metrics metrics) { + this.metrics = metrics; + Collection features = Features.createFeatures(jda, database, config, metrics); // Message receivers features.stream() @@ -300,14 +301,14 @@ private Optional selectPreferredAudioChannel(@Nullable AudioChannelUnio } @Override - public void onGuildVoiceUpdate(@NotNull GuildVoiceUpdateEvent event) { + public void onGuildVoiceUpdate(GuildVoiceUpdateEvent event) { selectPreferredAudioChannel(event.getChannelJoined(), event.getChannelLeft()) .ifPresent(channel -> getVoiceReceiversSubscribedTo(channel) .forEach(voiceReceiver -> voiceReceiver.onVoiceUpdate(event))); } @Override - public void onGuildVoiceVideo(@NotNull GuildVoiceVideoEvent event) { + public void onGuildVoiceVideo(GuildVoiceVideoEvent event) { AudioChannelUnion channel = event.getVoiceState().getChannel(); if (channel == null) { @@ -319,7 +320,7 @@ public void onGuildVoiceVideo(@NotNull GuildVoiceVideoEvent event) { } @Override - public void onGuildVoiceStream(@NotNull GuildVoiceStreamEvent event) { + public void onGuildVoiceStream(GuildVoiceStreamEvent event) { AudioChannelUnion channel = event.getVoiceState().getChannel(); if (channel == null) { @@ -331,7 +332,7 @@ public void onGuildVoiceStream(@NotNull GuildVoiceStreamEvent event) { } @Override - public void onGuildVoiceMute(@NotNull GuildVoiceMuteEvent event) { + public void onGuildVoiceMute(GuildVoiceMuteEvent event) { AudioChannelUnion channel = event.getVoiceState().getChannel(); if (channel == null) { @@ -343,7 +344,7 @@ public void onGuildVoiceMute(@NotNull GuildVoiceMuteEvent event) { } @Override - public void onGuildVoiceDeafen(@NotNull GuildVoiceDeafenEvent event) { + public void onGuildVoiceDeafen(GuildVoiceDeafenEvent event) { AudioChannelUnion channel = event.getVoiceState().getChannel(); if (channel == null) { @@ -380,10 +381,18 @@ public void onSlashCommandInteraction(SlashCommandInteractionEvent event) { logger.debug("Received slash command '{}' (#{}) on guild '{}'", name, event.getId(), event.getGuild()); - COMMAND_SERVICE.execute( - () -> requireUserInteractor(UserInteractionType.SLASH_COMMAND.getPrefixedName(name), - SlashCommand.class) - .onSlashCommand(event)); + COMMAND_SERVICE.execute(() -> { + SlashCommand interactor = requireUserInteractor( + UserInteractionType.SLASH_COMMAND.getPrefixedName(name), SlashCommand.class); + + String eventName = "slash-" + name; + if (event.getSubcommandName() != null) { + eventName += "_" + event.getSubcommandName(); + } + metrics.count(eventName); + + interactor.onSlashCommand(event); + }); } @Override @@ -450,10 +459,13 @@ public void onMessageContextInteraction(final MessageContextInteractionEvent eve logger.debug("Received message context command '{}' (#{}) on guild '{}'", name, event.getId(), event.getGuild()); - COMMAND_SERVICE.execute(() -> requireUserInteractor( - UserInteractionType.MESSAGE_CONTEXT_COMMAND.getPrefixedName(name), - MessageContextCommand.class) - .onMessageContext(event)); + COMMAND_SERVICE.execute(() -> { + MessageContextCommand userInteractor = requireUserInteractor( + UserInteractionType.MESSAGE_CONTEXT_COMMAND.getPrefixedName(name), + MessageContextCommand.class); + metrics.count("msg_ctx-" + name); + userInteractor.onMessageContext(event); + }); } @Override @@ -462,10 +474,13 @@ public void onUserContextInteraction(final UserContextInteractionEvent event) { logger.debug("Received user context command '{}' (#{}) on guild '{}'", name, event.getId(), event.getGuild()); - COMMAND_SERVICE.execute(() -> requireUserInteractor( - UserInteractionType.USER_CONTEXT_COMMAND.getPrefixedName(name), - UserContextCommand.class) - .onUserContext(event)); + COMMAND_SERVICE.execute(() -> { + UserContextCommand userInteractor = requireUserInteractor( + UserInteractionType.USER_CONTEXT_COMMAND.getPrefixedName(name), + UserContextCommand.class); + metrics.count("user_ctx-" + name); + userInteractor.onUserContext(event); + }); } /** diff --git a/application/src/main/java/org/togetherjava/tjbot/features/tags/TagCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/tags/TagCommand.java index 1bf13983bf..7581c5c750 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/tags/TagCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/tags/TagCommand.java @@ -18,6 +18,7 @@ import org.togetherjava.tjbot.features.CommandVisibility; import org.togetherjava.tjbot.features.SlashCommandAdapter; +import org.togetherjava.tjbot.features.analytics.Metrics; import org.togetherjava.tjbot.features.utils.LinkDetection; import org.togetherjava.tjbot.features.utils.LinkPreview; import org.togetherjava.tjbot.features.utils.LinkPreviews; @@ -40,6 +41,7 @@ */ public final class TagCommand extends SlashCommandAdapter { private final TagSystem tagSystem; + private final Metrics metrics; private static final int MAX_SUGGESTIONS = 5; static final String ID_OPTION = "id"; static final String REPLY_TO_USER_OPTION = "reply-to"; @@ -48,11 +50,13 @@ public final class TagCommand extends SlashCommandAdapter { * Creates a new instance, using the given tag system as base. * * @param tagSystem the system providing the actual tag data + * @param metrics to track events */ - public TagCommand(TagSystem tagSystem) { + public TagCommand(TagSystem tagSystem, Metrics metrics) { super("tag", "Display a tags content", CommandVisibility.GUILD); this.tagSystem = tagSystem; + this.metrics = metrics; getData().addOptions( new OptionData(OptionType.STRING, ID_OPTION, "The id of the tag to display", true, @@ -87,6 +91,7 @@ public void onSlashCommand(SlashCommandInteractionEvent event) { if (tagSystem.handleIsUnknownTag(id, event)) { return; } + metrics.count("tag-" + id); String tagContent = tagSystem.getTag(id).orElseThrow(); MessageEmbed contentEmbed = new EmbedBuilder().setDescription(tagContent) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java index 76434af7e8..cb6449a24f 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/tophelper/TopHelpersAssignmentRoutine.java @@ -23,6 +23,7 @@ import org.togetherjava.tjbot.features.Routine; import org.togetherjava.tjbot.features.UserInteractionType; import org.togetherjava.tjbot.features.UserInteractor; +import org.togetherjava.tjbot.features.analytics.Metrics; import org.togetherjava.tjbot.features.componentids.ComponentIdGenerator; import org.togetherjava.tjbot.features.componentids.ComponentIdInteractor; import org.togetherjava.tjbot.features.utils.Guilds; @@ -65,6 +66,7 @@ public final class TopHelpersAssignmentRoutine implements Routine, UserInteracto private final TopHelpersConfig config; private final TopHelpersService service; + private final Metrics metrics; private final Predicate roleNamePredicate; private final Predicate assignmentChannelNamePredicate; private final Predicate announcementChannelNamePredicate; @@ -75,10 +77,12 @@ public final class TopHelpersAssignmentRoutine implements Routine, UserInteracto * * @param config the config to use * @param service the service to use to compute Top Helpers + * @param metrics to track events */ - public TopHelpersAssignmentRoutine(Config config, TopHelpersService service) { + public TopHelpersAssignmentRoutine(Config config, TopHelpersService service, Metrics metrics) { this.config = config.getTopHelpers(); this.service = service; + this.metrics = metrics; roleNamePredicate = Pattern.compile(this.config.getRolePattern()).asMatchPredicate(); assignmentChannelNamePredicate = @@ -255,6 +259,9 @@ private void manageTopHelperRole(Collection currentTopHelpers, guild.addRoleToMember(UserSnowflake.fromId(userToAddRoleTo), topHelperRole).queue(); } + for (long topHelperUserId : selectedTopHelperIds) { + metrics.count("top_helper-" + topHelperUserId); + } reportRoleManageSuccess(event); } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/utils/LinkDetection.java b/application/src/main/java/org/togetherjava/tjbot/features/utils/LinkDetection.java index 3b6dc18112..8b66c0b7cd 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/utils/LinkDetection.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/utils/LinkDetection.java @@ -4,27 +4,61 @@ import com.linkedin.urls.detection.UrlDetector; import com.linkedin.urls.detection.UrlDetectorOptions; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.concurrent.CompletableFuture; /** - * Utility class to detect links. + * Utility methods for working with links inside arbitrary text. + * + *

+ * This class can: + *

+ * + *

+ * It is intentionally stateless and uses asynchronous HTTP requests to avoid blocking calling + * threads. */ public class LinkDetection { + private static final HttpClient HTTP_CLIENT = HttpClient.newHttpClient(); /** - * Possible ways to filter a link. + * Default filters applied when extracting links from text. * - * @see LinkDetection + *

+ * Links to intentionally ignore in order to reduce false positives when scanning chat messages + * or source-code snippets. + */ + private static final Set DEFAULT_FILTERS = + Set.of(LinkFilter.SUPPRESSED, LinkFilter.NON_HTTP_SCHEME); + + /** + * Filters that control which detected URLs are returned by {@link #extractLinks}. */ public enum LinkFilter { /** - * Filters links suppressed with {@literal }. + * Ignores URLs that are wrapped in angle brackets, e.g. {@code }. + * + *

+ * Such links are often intentionally suppressed in chat platforms. */ SUPPRESSED, /** - * Filters links that are not using http scheme. + * Ignores URLs that do not use the HTTP or HTTPS scheme. + * + *

+ * This helps avoid false positives such as {@code ftp://}, {@code file://}, or scheme-less + * matches. */ NON_HTTP_SCHEME } @@ -34,11 +68,24 @@ private LinkDetection() { } /** - * Extracts all links from the given content. + * Extracts links from the given text. + * + *

+ * The text is scanned using a URL detector, then filtered and normalized according to the + * provided {@link LinkFilter}s. + * + *

+ * Example: + * + *

{@code
+     * Set filters = Set.of(LinkFilter.SUPPRESSED, LinkFilter.NON_HTTP_SCHEME);
+     * extractLinks("Visit https://example.com and ", filters)
+     * // returns ["https://example.com"]
+     * }
* - * @param content the content to search through - * @param filter the filters applied to the urls - * @return a list of all found links, can be empty + * @param content the text to scan for links + * @param filter a set of filters controlling which detected links are returned + * @return a list of extracted links in the order they appear in the text */ public static List extractLinks(String content, Set filter) { return new UrlDetector(content, UrlDetectorOptions.BRACKET_MATCH).detect() @@ -49,22 +96,166 @@ public static List extractLinks(String content, Set filter) } /** - * Checks whether the given content contains a link. + * Extracts links from the given text using default filters. * - * @param content the content to search through - * @return true if the content contains at least one link + *

+ * This is a convenience method that uses {@link #DEFAULT_FILTERS}. + * + * @param content the text to scan for links + * @return a list of extracted links in the order they appear in the text + * @see #extractLinks(String, Set) + */ + public static List extractLinks(String content) { + return extractLinks(content, DEFAULT_FILTERS); + } + + /** + * Checks whether the given text contains at least one detectable URL. + * + *

+ * This method performs a lightweight detection only and does not apply any {@link LinkFilter}s. + * + * @param content the text to scan + * @return {@code true} if at least one URL-like pattern is detected */ public static boolean containsLink(String content) { return !(new UrlDetector(content, UrlDetectorOptions.BRACKET_MATCH).detect().isEmpty()); } + /** + * Asynchronously checks whether a URL is considered broken. + * + *

+ * A link is considered broken if: + *

    + *
  • The URL is malformed or unreachable
  • + *
  • The HTTP request fails with an exception
  • + *
  • The response status code is 4xx (client error) or 5xx (server error)
  • + *
+ * + *

+ * Successful responses (2xx) and redirects (3xx) are considered valid links. The response body + * is never inspected. + * + * @param url the URL to check + * @return a {@code CompletableFuture} completing with {@code true} if the link is broken, + * {@code false} otherwise + */ + public static CompletableFuture isLinkBroken(String url) { + // Try HEAD request first (cheap and fast) + HttpRequest headRequest = HttpRequest.newBuilder(URI.create(url)) + .method("HEAD", HttpRequest.BodyPublishers.noBody()) + .build(); + + return HTTP_CLIENT.sendAsync(headRequest, HttpResponse.BodyHandlers.discarding()) + .thenApply(response -> { + int status = response.statusCode(); + // 2xx and 3xx are success, 4xx and 5xx are errors + return status >= 400; + }) + .exceptionally(_ -> true) + .thenCompose(result -> { + if (!Boolean.TRUE.equals(result)) { + return CompletableFuture.completedFuture(false); + } + // If HEAD fails, fall back to GET request (some servers don't support HEAD) + HttpRequest fallbackGetRequest = + HttpRequest.newBuilder(URI.create(url)).GET().build(); + return HTTP_CLIENT + .sendAsync(fallbackGetRequest, HttpResponse.BodyHandlers.discarding()) + .thenApply(resp -> resp.statusCode() >= 400) + .exceptionally(_ -> true); + }); + } + + /** + * Replaces all broken links in the given text. + * + *

+ * Each detected link is checked asynchronously using {@link #isLinkBroken(String)}. Only links + * confirmed as broken are replaced. Duplicate URLs are checked only once and all occurrences + * are replaced if found to be broken. + * + *

+ * This method does not block - all link checks are performed asynchronously and combined into a + * single {@code CompletableFuture}. + * + *

+ * Example: + * + *

{@code
+     * replaceBrokenLinks("""
+     *           Test
+     *           http://deadlink/1
+     *           http://workinglink/1
+     *         """, "(broken link)")
+     * }
+ * + *

+ * Results in: + * + *

{@code
+     * Test
+     * (broken link)
+     * http://workinglink/1
+     * }
+ * + * @param text the input text containing URLs + * @param replacement the string used to replace broken links + * @return a {@code CompletableFuture} that completes with the modified text, or the original + * text if no broken links were found + */ + public static CompletableFuture replaceBrokenLinks(String text, String replacement) { + List links = extractLinks(text, DEFAULT_FILTERS); + + if (links.isEmpty()) { + return CompletableFuture.completedFuture(text); + } + + // Can't filter yet - we won't know which links are broken until the futures complete + List> brokenLinkFutures = links.stream() + .distinct() + .map(link -> isLinkBroken(link) + .thenApply(isBroken -> Boolean.TRUE.equals(isBroken) ? link : null)) + .toList(); + + return CompletableFuture.allOf(brokenLinkFutures.toArray(CompletableFuture[]::new)) + .thenApply(_ -> brokenLinkFutures.stream() + .map(CompletableFuture::join) + .filter(Objects::nonNull) + .toList()) + .thenApply(brokenLinks -> replaceLinks(brokenLinks, text, replacement)); + } + + private static String replaceLinks(List linksToReplace, String text, + String replacement) { + String result = text; + for (String link : linksToReplace) { + result = result.replace(link, replacement); + } + return result; + } + + /** + * Converts a detected {@link Url} into a normalized link string. + * + *

+ * Applies the provided {@link LinkFilter}s. Additionally removes trailing punctuation such as + * commas or periods from the detected URL. + * + * @param url the detected URL + * @param filter active link filters to apply + * @return an {@link Optional} containing the normalized link, or {@code Optional.empty()} if + * the link should be filtered out + */ private static Optional toLink(Url url, Set filter) { String raw = url.getOriginalUrl(); if (filter.contains(LinkFilter.SUPPRESSED) && raw.contains(">")) { // URL escapes, such as "" should be skipped return Optional.empty(); } - // Not interested in other schemes, also to filter out matches without scheme. + // Not interested in other schemes, also to filter out matches without scheme (Skip non-HTTP + // schemes) // It detects a lot of such false-positives in Java snippets if (filter.contains(LinkFilter.NON_HTTP_SCHEME) && !raw.startsWith("http")) { return Optional.empty(); @@ -76,8 +267,6 @@ private static Optional toLink(Url url, Set filter) { // Remove trailing punctuation link = link.substring(0, link.length() - 1); } - return Optional.of(link); } - } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java index 9294d6dcd0..03a001a604 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/voicechat/DynamicVoiceChat.java @@ -20,6 +20,7 @@ import org.togetherjava.tjbot.config.Config; import org.togetherjava.tjbot.config.DynamicVoiceChatConfig; import org.togetherjava.tjbot.features.VoiceReceiverAdapter; +import org.togetherjava.tjbot.features.analytics.Metrics; import java.util.Optional; import java.util.concurrent.TimeUnit; @@ -36,6 +37,7 @@ public final class DynamicVoiceChat extends VoiceReceiverAdapter { private final VoiceChatCleanupStrategy voiceChatCleanupStrategy; private final DynamicVoiceChatConfig dynamicVoiceChannelConfig; + private final Metrics metrics; private final Cache deletedChannels = Caffeine.newBuilder().expireAfterWrite(5, TimeUnit.MINUTES).build(); @@ -45,9 +47,11 @@ public final class DynamicVoiceChat extends VoiceReceiverAdapter { * * @param config the configurations needed for this feature. See: * {@link org.togetherjava.tjbot.config.DynamicVoiceChatConfig} + * @param metrics to track events */ - public DynamicVoiceChat(Config config) { + public DynamicVoiceChat(Config config, Metrics metrics) { this.dynamicVoiceChannelConfig = config.getDynamicVoiceChatConfig(); + this.metrics = metrics; this.voiceChatCleanupStrategy = new OldestVoiceChatCleanup(dynamicVoiceChannelConfig.cleanChannelsAmount(), @@ -128,9 +132,10 @@ private void createDynamicVoiceChannel(GuildVoiceUpdateEvent event, VoiceChannel moveMember(guild, member, newChannel); sendWarningEmbed(newChannel); }) - .queue(newChannel -> logger.trace("Successfully created {} voice channel.", - newChannel.getName()), - error -> logger.error("Failed to create dynamic voice channel", error)); + .queue(newChannel -> { + logger.trace("Successfully created {} voice channel.", newChannel.getName()); + metrics.count("dynamic_voice_channel-created"); + }, error -> logger.error("Failed to create dynamic voice channel", error)); } private void moveMember(Guild guild, Member member, AudioChannel channel) { diff --git a/application/src/main/resources/db/V16__Add_Analytics_System.sql b/application/src/main/resources/db/V16__Add_Analytics_System.sql new file mode 100644 index 0000000000..a29a62e513 --- /dev/null +++ b/application/src/main/resources/db/V16__Add_Analytics_System.sql @@ -0,0 +1,6 @@ +CREATE TABLE metric_events +( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event TEXT NOT NULL, + happened_at TIMESTAMP NOT NULL +); diff --git a/application/src/main/resources/slashCommandPopupAdvice.png b/application/src/main/resources/slashCommandPopupAdvice.png deleted file mode 100644 index 6284b8566e..0000000000 Binary files a/application/src/main/resources/slashCommandPopupAdvice.png and /dev/null 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 deleted file mode 100644 index 03d07c1a2c..0000000000 --- a/application/src/test/java/org/togetherjava/tjbot/features/basic/SlashCommandEducatorTest.java +++ /dev/null @@ -1,75 +0,0 @@ -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.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; - -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", - "thisIsAWordWhichLengthIsMoreThanThirtyLetterSoItShouldNotReply", - ".isLetter and .isNumber are available", ".toString()", ".toString();", - "this is a test;"); - } -} diff --git a/application/src/test/java/org/togetherjava/tjbot/features/mediaonly/MediaOnlyChannelListenerTest.java b/application/src/test/java/org/togetherjava/tjbot/features/mediaonly/MediaOnlyChannelListenerTest.java index c42bba88bf..b814f0dab8 100644 --- a/application/src/test/java/org/togetherjava/tjbot/features/mediaonly/MediaOnlyChannelListenerTest.java +++ b/application/src/test/java/org/togetherjava/tjbot/features/mediaonly/MediaOnlyChannelListenerTest.java @@ -4,6 +4,7 @@ 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.entities.messages.MessageSnapshot; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder; import net.dv8tion.jda.api.utils.messages.MessageCreateData; @@ -11,6 +12,7 @@ import org.junit.jupiter.api.Test; import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.features.analytics.Metrics; import org.togetherjava.tjbot.jda.JdaTester; import java.util.List; @@ -33,7 +35,7 @@ void setUp() { Config config = mock(Config.class); when(config.getMediaOnlyChannelPattern()).thenReturn("any"); - mediaOnlyChannelListener = new MediaOnlyChannelListener(config); + mediaOnlyChannelListener = new MediaOnlyChannelListener(config, mock(Metrics.class)); } @Test @@ -112,4 +114,48 @@ private MessageReceivedEvent sendMessage(MessageCreateData message, mediaOnlyChannelListener.onMessageReceived(event); return event; } + + + @Test + void keepsForwardedMessageWithAttachment() { + // GIVEN a forwarded message that contains an attachment inside the snapshot + MessageCreateData message = new MessageCreateBuilder().setContent("any").build(); + + MessageSnapshot snapshot = mock(MessageSnapshot.class); + when(snapshot.getAttachments()).thenReturn(List.of(mock(Message.Attachment.class))); + when(snapshot.getEmbeds()).thenReturn(List.of()); + when(snapshot.getContentRaw()).thenReturn(""); + + // WHEN sending the forwarded message + MessageReceivedEvent event = sendMessageWithSnapshots(message, List.of(snapshot)); + + // THEN it does not get deleted + verify(event.getMessage(), never()).delete(); + } + + @Test + void deletesForwardedMessageWithoutMedia() { + // GIVEN a forwarded message that contains no media inside the snapshot + MessageCreateData message = new MessageCreateBuilder().setContent("any").build(); + + MessageSnapshot snapshot = mock(MessageSnapshot.class); + when(snapshot.getAttachments()).thenReturn(List.of()); + when(snapshot.getEmbeds()).thenReturn(List.of()); + when(snapshot.getContentRaw()).thenReturn("just some text, no media"); + + // WHEN sending the forwarded message + MessageReceivedEvent event = sendMessageWithSnapshots(message, List.of(snapshot)); + + // THEN it gets deleted + verify(event.getMessage()).delete(); + } + + private MessageReceivedEvent sendMessageWithSnapshots(MessageCreateData message, + List snapshots) { + MessageReceivedEvent event = + jdaTester.createMessageReceiveEvent(message, List.of(), ChannelType.TEXT); + when(event.getMessage().getMessageSnapshots()).thenReturn(snapshots); + mediaOnlyChannelListener.onMessageReceived(event); + return event; + } } diff --git a/application/src/test/java/org/togetherjava/tjbot/features/tags/TagCommandTest.java b/application/src/test/java/org/togetherjava/tjbot/features/tags/TagCommandTest.java index bb8f3cea5b..738c9177e8 100644 --- a/application/src/test/java/org/togetherjava/tjbot/features/tags/TagCommandTest.java +++ b/application/src/test/java/org/togetherjava/tjbot/features/tags/TagCommandTest.java @@ -10,14 +10,14 @@ import org.togetherjava.tjbot.db.Database; import org.togetherjava.tjbot.db.generated.tables.Tags; import org.togetherjava.tjbot.features.SlashCommand; +import org.togetherjava.tjbot.features.analytics.Metrics; import org.togetherjava.tjbot.jda.JdaTester; import org.togetherjava.tjbot.jda.SlashCommandInteractionEventBuilder; import javax.annotation.Nullable; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.*; final class TagCommandTest { private TagSystem system; @@ -29,7 +29,7 @@ void setUp() { Database database = Database.createMemoryDatabase(Tags.TAGS); system = spy(new TagSystem(database)); jdaTester = new JdaTester(); - command = new TagCommand(system); + command = new TagCommand(system, mock(Metrics.class)); } private SlashCommandInteractionEvent triggerSlashCommand(String id, diff --git a/application/src/test/java/org/togetherjava/tjbot/features/tags/TagSystemTest.java b/application/src/test/java/org/togetherjava/tjbot/features/tags/TagSystemTest.java index 97b1b2ab3f..2f83607a3b 100644 --- a/application/src/test/java/org/togetherjava/tjbot/features/tags/TagSystemTest.java +++ b/application/src/test/java/org/togetherjava/tjbot/features/tags/TagSystemTest.java @@ -6,6 +6,7 @@ import org.togetherjava.tjbot.db.Database; import org.togetherjava.tjbot.db.generated.tables.Tags; +import org.togetherjava.tjbot.features.analytics.Metrics; import org.togetherjava.tjbot.jda.JdaTester; import java.util.Optional; @@ -16,9 +17,7 @@ import static org.junit.jupiter.api.Assertions.assertThrowsExactly; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.*; final class TagSystemTest { private TagSystem system; @@ -56,8 +55,9 @@ void createDeleteButton() { @Test void handleIsUnknownTag() { insertTagRaw("known", "foo"); - SlashCommandInteractionEvent event = - jdaTester.createSlashCommandInteractionEvent(new TagCommand(system)).build(); + SlashCommandInteractionEvent event = jdaTester + .createSlashCommandInteractionEvent(new TagCommand(system, mock(Metrics.class))) + .build(); assertFalse(system.handleIsUnknownTag("known", event)); verify(event, never()).reply(anyString()); diff --git a/build.gradle b/build.gradle index 19cdc35324..a60efc7e75 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id "com.diffplug.spotless" version "8.2.0" + id "com.diffplug.spotless" version "8.3.0" id "org.sonarqube" version "7.2.0.6526" id "name.remal.sonarlint" version "7.0.0" } @@ -14,7 +14,7 @@ version '1.0-SNAPSHOT' ext { jooqVersion = '3.20.5' jacksonVersion = '2.19.1' - chatGPTVersion = '4.18.0' + chatGPTVersion = '4.26.0' junitVersion = '6.0.0' } diff --git a/database/build.gradle b/database/build.gradle index 8ef3ef97cd..2d0e9fbbcf 100644 --- a/database/build.gradle +++ b/database/build.gradle @@ -7,7 +7,7 @@ var sqliteVersion = "3.51.0.0" dependencies { implementation 'com.google.code.findbugs:jsr305:3.0.2' implementation "org.xerial:sqlite-jdbc:${sqliteVersion}" - implementation 'org.flywaydb:flyway-core:12.0.0' + implementation 'org.flywaydb:flyway-core:12.1.0' implementation "org.jooq:jooq:$jooqVersion" implementation project(':utils') diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index f8e1ee3125..d997cfc60f 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index bad7c2462f..dbc3ce4a04 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index adff685a03..0262dcbd52 100755 --- a/gradlew +++ b/gradlew @@ -57,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/b631911858264c0b6e4d6603d677ff5218766cee/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/.