diff --git a/README.md b/README.md
index b1e65d81e4..39eb7edde9 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,9 @@
[](https://sonarcloud.io/dashboard?id=Together-Java_TJ-Bot)
[](https://sonarcloud.io/dashboard?id=Together-Java_TJ-Bot)
+[](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!

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 extends Member> 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:
+ *
+ * - Extract HTTP(S) links from text
+ * - Check whether a link is reachable via HTTP
+ * - Replace broken links asynchronously
+ *
+ *
+ *
+ * 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/.