From 68169fcc6a2eae92bcca1da7ae450b93ff01c5c2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 20 Nov 2025 09:09:26 +0100 Subject: [PATCH 01/27] Bump com.diffplug.spotless from 8.0.0 to 8.1.0 (#1348) Bumps com.diffplug.spotless from 8.0.0 to 8.1.0. --- updated-dependencies: - dependency-name: com.diffplug.spotless dependency-version: 8.1.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index f3562829ed..0dd9637cac 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id "com.diffplug.spotless" version "8.0.0" + id "com.diffplug.spotless" version "8.1.0" id "org.sonarqube" version "7.0.1.6134" id "name.remal.sonarlint" version "6.0.0" } From d2fc29f0722b92837902b32be4a5308376f5f78b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Nov 2025 08:24:53 +0100 Subject: [PATCH 02/27] Bump org.sonarqube from 7.0.1.6134 to 7.1.0.6387 (#1349) Bumps org.sonarqube from 7.0.1.6134 to 7.1.0.6387. --- updated-dependencies: - dependency-name: org.sonarqube dependency-version: 7.1.0.6387 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 0dd9637cac..74205a4648 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ plugins { id 'java' id "com.diffplug.spotless" version "8.1.0" - id "org.sonarqube" version "7.0.1.6134" + id "org.sonarqube" version "7.1.0.6387" id "name.remal.sonarlint" version "6.0.0" } repositories { From 9c552b6b29ddbf63a2f35916125cb68d7c647415 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Nov 2025 09:26:00 +0100 Subject: [PATCH 03/27] Bump name.remal.sonarlint from 6.0.0 to 7.0.0 (#1351) Bumps [name.remal.sonarlint](https://github.com/remal-gradle-plugins/sonarlint) from 6.0.0 to 7.0.0. - [Release notes](https://github.com/remal-gradle-plugins/sonarlint/releases) - [Commits](https://github.com/remal-gradle-plugins/sonarlint/commits) --- updated-dependencies: - dependency-name: name.remal.sonarlint dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 74205a4648..a22635a0e7 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ plugins { id 'java' id "com.diffplug.spotless" version "8.1.0" id "org.sonarqube" version "7.1.0.6387" - id "name.remal.sonarlint" version "6.0.0" + id "name.remal.sonarlint" version "7.0.0" } repositories { mavenCentral() From 44971615df8d88f210c4bc9076298b944fabce74 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 28 Nov 2025 08:24:52 +0100 Subject: [PATCH 04/27] Bump org.flywaydb:flyway-core from 11.17.0 to 11.18.0 (#1352) Bumps [org.flywaydb:flyway-core](https://github.com/flyway/flyway) from 11.17.0 to 11.18.0. - [Release notes](https://github.com/flyway/flyway/releases) - [Commits](https://github.com/flyway/flyway/compare/flyway-11.17.0...flyway-11.18.0) --- updated-dependencies: - dependency-name: org.flywaydb:flyway-core dependency-version: 11.18.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- database/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/build.gradle b/database/build.gradle index 8b9f5fd380..7f62b722ef 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:11.17.0' + implementation 'org.flywaydb:flyway-core:11.18.0' implementation "org.jooq:jooq:$jooqVersion" implementation project(':utils') From 13d706e36e4198891734058a0ee0a308b9143d1e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 08:27:29 +0100 Subject: [PATCH 05/27] Bump com.apptasticsoftware:rssreader from 3.11.0 to 3.12.0 (#1354) Bumps [com.apptasticsoftware:rssreader](https://github.com/w3stling/rssreader) from 3.11.0 to 3.12.0. - [Release notes](https://github.com/w3stling/rssreader/releases) - [Commits](https://github.com/w3stling/rssreader/compare/v3.11.0...v3.12.0) --- updated-dependencies: - dependency-name: com.apptasticsoftware:rssreader dependency-version: 3.12.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- application/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/build.gradle b/application/build.gradle index f701c01246..074b94051a 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -78,7 +78,7 @@ dependencies { implementation 'org.kohsuke:github-api:1.329' implementation 'org.apache.commons:commons-text:1.14.0' - implementation 'com.apptasticsoftware:rssreader:3.11.0' + implementation 'com.apptasticsoftware:rssreader:3.12.0' testImplementation 'org.mockito:mockito-core:5.20.0' testImplementation "org.junit.jupiter:junit-jupiter-api:$junitVersion" From bcb192d63b202b4f842f6f7070661f45601dd5e6 Mon Sep 17 00:00:00 2001 From: Zach Date: Tue, 2 Dec 2025 02:28:27 -0500 Subject: [PATCH 06/27] Fixes "/reminder list" fails if no reminders (#1353) --- .../togetherjava/tjbot/features/reminder/ReminderCommand.java | 1 + 1 file changed, 1 insertion(+) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/reminder/ReminderCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/reminder/ReminderCommand.java index 88bcef8607..03c9eaf9e6 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/reminder/ReminderCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/reminder/ReminderCommand.java @@ -219,6 +219,7 @@ private MessageCreateData createPendingRemindersPage( List pendingReminders, int pageToShow) { // 12 reminders, 10 per page, ceil(12 / 10) = 2 int totalPages = Math.ceilDiv(pendingReminders.size(), REMINDERS_PER_PAGE); + totalPages = Math.max(1, totalPages); pageToShow = Math.clamp(pageToShow, 1, totalPages); From b2eacf6af7b377eeb83ab71f920d2f74a47f6a07 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Dec 2025 09:19:14 +0100 Subject: [PATCH 07/27] Bump org.sonarqube from 7.1.0.6387 to 7.2.0.6526 (#1355) Bumps org.sonarqube from 7.1.0.6387 to 7.2.0.6526. --- updated-dependencies: - dependency-name: org.sonarqube dependency-version: 7.2.0.6526 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index a22635a0e7..548b509dd6 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ plugins { id 'java' id "com.diffplug.spotless" version "8.1.0" - id "org.sonarqube" version "7.1.0.6387" + id "org.sonarqube" version "7.2.0.6526" id "name.remal.sonarlint" version "7.0.0" } repositories { From eeb7f095767a785233972cc711c661c9a9e15a36 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 08:44:33 +0100 Subject: [PATCH 08/27] Bump com.gradleup.shadow from 9.2.2 to 9.3.0 (#1357) Bumps [com.gradleup.shadow](https://github.com/GradleUp/shadow) from 9.2.2 to 9.3.0. - [Release notes](https://github.com/GradleUp/shadow/releases) - [Commits](https://github.com/GradleUp/shadow/compare/9.2.2...9.3.0) --- updated-dependencies: - dependency-name: com.gradleup.shadow dependency-version: 9.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- application/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/build.gradle b/application/build.gradle index 074b94051a..f3e7f36043 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -7,7 +7,7 @@ buildscript { plugins { id 'application' id 'com.google.cloud.tools.jib' version '3.5.0' - id 'com.gradleup.shadow' version '9.2.2' + id 'com.gradleup.shadow' version '9.3.0' id 'database-settings' } From c03731a9a8b85b3b9fad189d9fcc33776fb840a4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 08:44:51 +0100 Subject: [PATCH 09/27] Bump org.apache.commons:commons-text from 1.14.0 to 1.15.0 (#1356) Bumps [org.apache.commons:commons-text](https://github.com/apache/commons-text) from 1.14.0 to 1.15.0. - [Changelog](https://github.com/apache/commons-text/blob/master/RELEASE-NOTES.txt) - [Commits](https://github.com/apache/commons-text/compare/rel/commons-text-1.14.0...rel/commons-text-1.15.0) --- updated-dependencies: - dependency-name: org.apache.commons:commons-text dependency-version: 1.15.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- application/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/build.gradle b/application/build.gradle index f3e7f36043..aaa48828c7 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -77,7 +77,7 @@ dependencies { implementation 'org.kohsuke:github-api:1.329' - implementation 'org.apache.commons:commons-text:1.14.0' + implementation 'org.apache.commons:commons-text:1.15.0' implementation 'com.apptasticsoftware:rssreader:3.12.0' testImplementation 'org.mockito:mockito-core:5.20.0' From 1f70efafbb04bae8edd5515d7608aaa33aa828ee Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 Dec 2025 10:05:28 +0100 Subject: [PATCH 10/27] Bump org.mockito:mockito-core from 5.20.0 to 5.21.0 (#1359) Bumps [org.mockito:mockito-core](https://github.com/mockito/mockito) from 5.20.0 to 5.21.0. - [Release notes](https://github.com/mockito/mockito/releases) - [Commits](https://github.com/mockito/mockito/compare/v5.20.0...v5.21.0) --- updated-dependencies: - dependency-name: org.mockito:mockito-core dependency-version: 5.21.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- application/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/build.gradle b/application/build.gradle index aaa48828c7..4f76824186 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -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.20.0' + testImplementation 'org.mockito:mockito-core:5.21.0' testImplementation "org.junit.jupiter:junit-jupiter-api:$junitVersion" testImplementation "org.junit.jupiter:junit-jupiter-params:$junitVersion" testRuntimeOnly 'org.junit.platform:junit-platform-launcher' From 72cc22fb9abf5354665f69399a3cb3184786b830 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 Dec 2025 10:05:45 +0100 Subject: [PATCH 11/27] Bump org.flywaydb:flyway-core from 11.18.0 to 11.20.0 (#1363) Bumps [org.flywaydb:flyway-core](https://github.com/flyway/flyway) from 11.18.0 to 11.20.0. - [Release notes](https://github.com/flyway/flyway/releases) - [Commits](https://github.com/flyway/flyway/compare/flyway-11.18.0...flyway-11.20.0) --- updated-dependencies: - dependency-name: org.flywaydb:flyway-core dependency-version: 11.20.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- database/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/build.gradle b/database/build.gradle index 7f62b722ef..20f1804a49 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:11.18.0' + implementation 'org.flywaydb:flyway-core:11.20.0' implementation "org.jooq:jooq:$jooqVersion" implementation project(':utils') From 875b8da03cb52c69c246fa8df6ef25d70b960053 Mon Sep 17 00:00:00 2001 From: alphaBEE Date: Mon, 29 Dec 2025 12:49:31 +0530 Subject: [PATCH 12/27] fix: rss failures with exponential blacklist (#1362) * fix: rss failures with exponential blacklist failed rss feed urls are marked as blacklisted upto 24 hours, if it's a dead url it notifies with error log for admin to remove it. * refactor: helper and magic numbers moves numbers used for backing off hours to constants and adds a helper to calculate backing off hours duration * chore: log level for skips to debug * refactor: move failure state record to seperate file --- .../tjbot/features/rss/FailureState.java | 6 ++ .../tjbot/features/rss/RSSHandlerRoutine.java | 61 +++++++++++++++++-- 2 files changed, 61 insertions(+), 6 deletions(-) create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/rss/FailureState.java diff --git a/application/src/main/java/org/togetherjava/tjbot/features/rss/FailureState.java b/application/src/main/java/org/togetherjava/tjbot/features/rss/FailureState.java new file mode 100644 index 0000000000..a5f8d41f40 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/rss/FailureState.java @@ -0,0 +1,6 @@ +package org.togetherjava.tjbot.features.rss; + +import java.time.ZonedDateTime; + +record FailureState(int count, ZonedDateTime lastFailure) { +} 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 56aea37b74..e3d96af021 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 @@ -2,6 +2,8 @@ import com.apptasticsoftware.rssreader.Item; import com.apptasticsoftware.rssreader.RssReader; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; @@ -48,7 +50,7 @@ *

* To include a new RSS feed, simply define an {@link RSSFeed} entry in the {@code "rssFeeds"} array * within the configuration file, adhering to the format shown below: - * + * *

  * {@code
  * {
@@ -58,7 +60,7 @@
  * }
  * }
  * 
- * + *

* Where: *

    *
  • {@code url} represents the URL of the RSS feed.
  • @@ -84,6 +86,14 @@ public final class RSSHandlerRoutine implements Routine { private final int interval; private final Database database; + private final Cache circuitBreaker = + Caffeine.newBuilder().expireAfterWrite(7, TimeUnit.DAYS).maximumSize(500).build(); + + private static final int DEAD_RSS_FEED_FAILURE_THRESHOLD = 15; + private static final double BACKOFF_BASE = 2.0; + private static final double BACKOFF_EXPONENT_OFFSET = 1.0; + private static final double MAX_BACKOFF_HOURS = 24.0; + /** * Constructs an RSSHandlerRoutine with the provided configuration and database. * @@ -117,7 +127,14 @@ public Schedule createSchedule() { @Override public void runRoutine(@Nonnull JDA jda) { - this.config.feeds().forEach(feed -> sendRSS(jda, feed)); + this.config.feeds().forEach(feed -> { + if (isBackingOff(feed.url())) { + logger.debug("Skipping RSS feed (Backing off): {}", feed.url()); + return; + } + + sendRSS(jda, feed); + }); } /** @@ -257,7 +274,6 @@ private void postItem(List textChannels, Item rssItem, RSSFeed feed * @param rssFeedRecord the record representing the RSS feed, can be null if not found in the * database * @param lastPostedDate the last posted date to be updated - * * @throws DateTimeParseException if the date cannot be parsed */ private void updateLastDateToDatabase(RSSFeed feedConfig, @Nullable RssFeedRecord rssFeedRecord, @@ -400,9 +416,26 @@ private MessageCreateData constructMessage(Item item, RSSFeed feedConfig) { */ private List fetchRSSItemsFromURL(String rssUrl) { try { - return rssReader.read(rssUrl).toList(); + List items = rssReader.read(rssUrl).toList(); + circuitBreaker.invalidate(rssUrl); + return items; } catch (IOException e) { - logger.error("Could not fetch RSS from URL ({})", rssUrl, e); + FailureState oldState = circuitBreaker.getIfPresent(rssUrl); + int newCount = (oldState == null) ? 1 : oldState.count() + 1; + + if (newCount >= DEAD_RSS_FEED_FAILURE_THRESHOLD) { + logger.error( + "Possibly dead RSS feed URL: {} - Failed {} times. Please remove it from config.", + rssUrl, newCount); + } + circuitBreaker.put(rssUrl, new FailureState(newCount, ZonedDateTime.now())); + + long blacklistedHours = calculateWaitHours(newCount); + + logger.warn( + "RSS fetch failed for {} (Attempt #{}). Backing off for {} hours. Reason: {}", + rssUrl, newCount, blacklistedHours, e.getMessage(), e); + return List.of(); } } @@ -424,4 +457,20 @@ private static ZonedDateTime getZonedDateTime(@Nullable String date, String form return ZonedDateTime.parse(date, DateTimeFormatter.ofPattern(format)); } + + private long calculateWaitHours(int failureCount) { + return (long) Math.min(Math.pow(BACKOFF_BASE, failureCount - BACKOFF_EXPONENT_OFFSET), + MAX_BACKOFF_HOURS); + } + + private boolean isBackingOff(String url) { + FailureState state = circuitBreaker.getIfPresent(url); + if (state == null) + return false; + + long waitHours = calculateWaitHours(state.count()); + ZonedDateTime retryAt = state.lastFailure().plusHours(waitHours); + + return ZonedDateTime.now().isBefore(retryAt); + } } From 04b94dff0057029ded14fdfb98af11b7d2675c45 Mon Sep 17 00:00:00 2001 From: Daniel Tischner Date: Mon, 29 Dec 2025 12:00:58 +0100 Subject: [PATCH 13/27] (removed explicit name from CLA, not needed) --- CLA.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/CLA.md b/CLA.md index c7a4c71973..e23df7e967 100644 --- a/CLA.md +++ b/CLA.md @@ -115,8 +115,6 @@ IF THE DISCLAIMER AND DAMAGE WAIVER MENTIONED IN SECTION 5. AND SECTION 6. CANNO ### Us -Name: Daniel Tischner (aka Zabuzard, acting on behalf of Together Java) - Organization: https://github.com/Together-Java Contact: https://discord.com/invite/XXFUXzK From 4e1c1324911f456fcf15aacb8cb11902e0f222ff Mon Sep 17 00:00:00 2001 From: Chris Sdogkos Date: Mon, 29 Dec 2025 15:21:43 +0300 Subject: [PATCH 14/27] hotfix(RSSHandlerRoutine): resolve java:S121 (#1364) While analyzing the code, SonarQube is rightfully complaining about: Control structures should use curly braces. Fix this by simply adding curly braces to the if statement. Signed-off-by: Chris Sdogkos --- .../org/togetherjava/tjbot/features/rss/RSSHandlerRoutine.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 e3d96af021..1d89896038 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 @@ -465,8 +465,9 @@ private long calculateWaitHours(int failureCount) { private boolean isBackingOff(String url) { FailureState state = circuitBreaker.getIfPresent(url); - if (state == null) + if (state == null) { return false; + } long waitHours = calculateWaitHours(state.count()); ZonedDateTime retryAt = state.lastFailure().plusHours(waitHours); From aa12c1381ffa0c3f2012b5bb0d75b1d1f60900d1 Mon Sep 17 00:00:00 2001 From: Chris Sdogkos Date: Fri, 2 Jan 2026 18:49:16 +0300 Subject: [PATCH 15/27] pre-commit: run sonarlintMain task (#1368) Recently thanks to a surge in new contributors, it has been observed that many of their commits contain code that does not pass Sonarlint checks. This results in the CI checks failing, and contributors have to push new commits to address the Sonarlint complaints. Introduce a new pre-commit hook procedure that also runs Sonarlint right before a commit gets created, which should exit with a non-zero status code so that the commit does not get made if it does not pass Sonarlint checks. Running Sonarlint checks is a passive task - in other words, it does not take the initiative of editing the code in order to make it pass the tests, so it's safe to call the Gradle task by just calling ./gradlew sonarlintMain Hopefully with this we get less unexpected failures on our CI pipeline and more motivated contributors. Signed-off-by: Chris Sdogkos --- scripts/pre-commit | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/pre-commit b/scripts/pre-commit index 87a8fdc42c..b1da09e748 100644 --- a/scripts/pre-commit +++ b/scripts/pre-commit @@ -44,6 +44,14 @@ if [ "$spotlessExitCode" -ne 0 ]; then exit "$spotlessExitCode" fi +echo "**Running Sonarlint checks**" +./gradlew sonarlintMain +sonarlintExitCode=$? +if [ "$sonarlintExitCode" -ne 0 ]; then + pop_stash + exit "$sonarlintMain" +fi + # Spotless possibly found changes, apply them, excluding untracked files git add -u From 5bc588e6fe576d7bea215004833123e3e6e61637 Mon Sep 17 00:00:00 2001 From: Chris Sdogkos Date: Sun, 4 Jan 2026 00:47:57 +0200 Subject: [PATCH 16/27] Add VoiceReceiver logic (#1369) Signed-off-by: Chris Sdogkos --- .../tjbot/features/VoiceReceiver.java | 69 +++++++++++ .../tjbot/features/VoiceReceiverAdapter.java | 52 ++++++++ .../tjbot/features/system/BotCore.java | 117 ++++++++++++++++++ 3 files changed, 238 insertions(+) create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiver.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiverAdapter.java diff --git a/application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiver.java b/application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiver.java new file mode 100644 index 0000000000..170dabd4fe --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiver.java @@ -0,0 +1,69 @@ +package org.togetherjava.tjbot.features; + +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceDeafenEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceMuteEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceStreamEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceVideoEvent; + +import java.util.regex.Pattern; + +/** + * Receives incoming Discord guild events from voice channels matching a given pattern. + *

    + * All voice receivers have to implement this interface. For convenience, there is a + * {@link VoiceReceiverAdapter} available that implemented most methods already. A new receiver can + * then be registered by adding it to {@link Features}. + *

    + *

    + * After registration, the system will notify a receiver whenever a new event was sent or an + * existing event was updated in any channel matching the {@link #getChannelNamePattern()} the bot + * is added to. + */ +public interface VoiceReceiver extends Feature { + /** + * Retrieves the pattern matching the names of channels of which this receiver is interested in + * receiving events from. Called by the core system once during the startup in order to register + * the receiver accordingly. + *

    + * Changes on the pattern returned by this method afterwards will not be picked up. + * + * @return the pattern matching the names of relevant channels + */ + Pattern getChannelNamePattern(); + + /** + * Triggered by the core system whenever a member joined, left or moved voice channels. + * + * @param event the event that triggered this + */ + void onVoiceUpdate(GuildVoiceUpdateEvent event); + + /** + * Triggered by the core system whenever a member toggled their camera in a voice channel. + * + * @param event the event that triggered this + */ + void onVideoToggle(GuildVoiceVideoEvent event); + + /** + * Triggered by the core system whenever a member started or stopped a stream. + * + * @param event the event that triggered this + */ + void onStreamToggle(GuildVoiceStreamEvent event); + + /** + * Triggered by the core system whenever a member toggled their mute status. + * + * @param event the event that triggered this + */ + void onMuteToggle(GuildVoiceMuteEvent event); + + /** + * Triggered by the core system whenever a member toggled their deafened status. + * + * @param event the event that triggered this + */ + void onDeafenToggle(GuildVoiceDeafenEvent event); +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiverAdapter.java b/application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiverAdapter.java new file mode 100644 index 0000000000..c92fbb339a --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiverAdapter.java @@ -0,0 +1,52 @@ +package org.togetherjava.tjbot.features; + +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceDeafenEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceMuteEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceStreamEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceVideoEvent; + +import java.util.regex.Pattern; + +public class VoiceReceiverAdapter implements VoiceReceiver { + + private final Pattern channelNamePattern; + + protected VoiceReceiverAdapter() { + this(Pattern.compile(".*")); + } + + protected VoiceReceiverAdapter(Pattern channelNamePattern) { + this.channelNamePattern = channelNamePattern; + } + + @Override + public Pattern getChannelNamePattern() { + return channelNamePattern; + } + + @Override + public void onVoiceUpdate(GuildVoiceUpdateEvent event) { + // Adapter does not react by default, subclasses may change this behavior + } + + @Override + public void onVideoToggle(GuildVoiceVideoEvent event) { + // Adapter does not react by default, subclasses may change this behavior + } + + @Override + public void onStreamToggle(GuildVoiceStreamEvent event) { + // Adapter does not react by default, subclasses may change this behavior + } + + @Override + public void onMuteToggle(GuildVoiceMuteEvent event) { + // Adapter does not react by default, subclasses may change this behavior + } + + @Override + public void onDeafenToggle(GuildVoiceDeafenEvent event) { + // Adapter does not react by default, subclasses may change this behavior + } +} 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 869e978a17..7c337e2efb 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 @@ -2,6 +2,12 @@ import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.entities.channel.Channel; +import net.dv8tion.jda.api.entities.channel.unions.AudioChannelUnion; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceDeafenEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceMuteEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceStreamEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent; +import net.dv8tion.jda.api.events.guild.voice.GuildVoiceVideoEvent; import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; import net.dv8tion.jda.api.events.interaction.command.CommandAutoCompleteInteractionEvent; import net.dv8tion.jda.api.events.interaction.command.MessageContextInteractionEvent; @@ -16,6 +22,8 @@ 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; import org.slf4j.LoggerFactory; @@ -32,6 +40,7 @@ import org.togetherjava.tjbot.features.UserContextCommand; import org.togetherjava.tjbot.features.UserInteractionType; import org.togetherjava.tjbot.features.UserInteractor; +import org.togetherjava.tjbot.features.VoiceReceiver; import org.togetherjava.tjbot.features.componentids.ComponentId; import org.togetherjava.tjbot.features.componentids.ComponentIdParser; import org.togetherjava.tjbot.features.componentids.ComponentIdStore; @@ -75,6 +84,7 @@ public final class BotCore extends ListenerAdapter implements CommandProvider { private final ComponentIdParser componentIdParser; private final ComponentIdStore componentIdStore; private final Map channelNameToMessageReceiver = new HashMap<>(); + private final Map channelNameToVoiceReceiver = new HashMap<>(); /** * Creates a new command system which uses the given database to allow commands to persist data. @@ -96,6 +106,13 @@ public BotCore(JDA jda, Database database, Config config) { .forEach(messageReceiver -> channelNameToMessageReceiver .put(messageReceiver.getChannelNamePattern(), messageReceiver)); + // Voice receivers + features.stream() + .filter(VoiceReceiver.class::isInstance) + .map(VoiceReceiver.class::cast) + .forEach(voiceReceiver -> channelNameToVoiceReceiver + .put(voiceReceiver.getChannelNamePattern(), voiceReceiver)); + // Event receivers features.stream() .filter(EventReceiver.class::isInstance) @@ -238,6 +255,96 @@ public void onMessageDelete(final MessageDeleteEvent event) { } } + /** + * Calculates the correct voice channel to act upon. + * + *

    + * If there is a channelJoined and a channelLeft, then the + * channelJoined is prioritized and returned. Otherwise, it returns + * channelLeft. + * + *

    + * This is an essential method due to the need of updating both channel categories that a member + * utilizes. For example, take the scenario of a user browsing through voice channels: + * + *

    +     *     - User joins General -> channelJoined = General | channelLeft = null
    +     *     - User switches to Gaming -> channelJoined = Gaming | channelLeft = General
    +     *     - User leaves Discord -> channelJoined = null | channelLeft = Gaming
    +     * 
    + * + *

    + * This way, we make sure that all relevant voice channels are updated. + * + * @param channelJoined the channel that the member has connected to, if any + * @param channelLeft the channel that the member left from, if any + * @return the join channel if not null, otherwise the leave channel, otherwise an empty + * optional + */ + private Optional selectPreferredAudioChannel(@Nullable AudioChannelUnion channelJoined, + @Nullable AudioChannelUnion channelLeft) { + if (channelJoined != null) { + return Optional.of(channelJoined); + } + + return Optional.ofNullable(channelLeft); + } + + @Override + public void onGuildVoiceUpdate(@NotNull GuildVoiceUpdateEvent event) { + selectPreferredAudioChannel(event.getChannelJoined(), event.getChannelLeft()) + .ifPresent(channel -> getVoiceReceiversSubscribedTo(channel) + .forEach(voiceReceiver -> voiceReceiver.onVoiceUpdate(event))); + } + + @Override + public void onGuildVoiceVideo(@NotNull GuildVoiceVideoEvent event) { + AudioChannelUnion channel = event.getVoiceState().getChannel(); + + if (channel == null) { + return; + } + + getVoiceReceiversSubscribedTo(channel) + .forEach(voiceReceiver -> voiceReceiver.onVideoToggle(event)); + } + + @Override + public void onGuildVoiceStream(@NotNull GuildVoiceStreamEvent event) { + AudioChannelUnion channel = event.getVoiceState().getChannel(); + + if (channel == null) { + return; + } + + getVoiceReceiversSubscribedTo(channel) + .forEach(voiceReceiver -> voiceReceiver.onStreamToggle(event)); + } + + @Override + public void onGuildVoiceMute(@NotNull GuildVoiceMuteEvent event) { + AudioChannelUnion channel = event.getVoiceState().getChannel(); + + if (channel == null) { + return; + } + + getVoiceReceiversSubscribedTo(channel) + .forEach(voiceReceiver -> voiceReceiver.onMuteToggle(event)); + } + + @Override + public void onGuildVoiceDeafen(@NotNull GuildVoiceDeafenEvent event) { + AudioChannelUnion channel = event.getVoiceState().getChannel(); + + if (channel == null) { + return; + } + + getVoiceReceiversSubscribedTo(channel) + .forEach(voiceReceiver -> voiceReceiver.onDeafenToggle(event)); + } + private Stream getMessageReceiversSubscribedTo(Channel channel) { String channelName = channel.getName(); return channelNameToMessageReceiver.entrySet() @@ -248,6 +355,16 @@ private Stream getMessageReceiversSubscribedTo(Channel channel) .map(Map.Entry::getValue); } + private Stream getVoiceReceiversSubscribedTo(Channel channel) { + String channelName = channel.getName(); + return channelNameToVoiceReceiver.entrySet() + .stream() + .filter(patternAndReceiver -> patternAndReceiver.getKey() + .matcher(channelName) + .matches()) + .map(Map.Entry::getValue); + } + @Override public void onSlashCommandInteraction(SlashCommandInteractionEvent event) { String name = event.getName(); From fb6cbf78d8acd30eea2b1af9204c25ca4ce35346 Mon Sep 17 00:00:00 2001 From: Chris Sdogkos Date: Sun, 4 Jan 2026 21:03:44 +0200 Subject: [PATCH 17/27] VoiceReceiverAdapter.java: add default missing doc (#1371) * VoiceReceiverAdapter.java: add default missing doc Signed-off-by: Chris Sdogkos * docs(VoiceReceiverAdapter): improve message Write an improved, more specific JavaDoc for VoiceReceiverAdapter. Signed-off-by: Chris Sdogkos --------- Signed-off-by: Chris Sdogkos --- .../togetherjava/tjbot/features/VoiceReceiverAdapter.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiverAdapter.java b/application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiverAdapter.java index c92fbb339a..f4f86aa262 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiverAdapter.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/VoiceReceiverAdapter.java @@ -8,6 +8,13 @@ import java.util.regex.Pattern; +/** + * Adapter implementation of a {@link VoiceReceiver}. A new receiver can then be registered by + * adding it to {@link Features}. + *

    + * {@link #onVoiceUpdate(GuildVoiceUpdateEvent)} like the other provided methods can be overridden + * if desired. The default implementation is empty, the adapter will not react to such events. + */ public class VoiceReceiverAdapter implements VoiceReceiver { private final Pattern channelNamePattern; From ffb165074fceafbe3179559c9388c1f6a6f3e63c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 18:29:47 +0000 Subject: [PATCH 18/27] Bump org.jsoup:jsoup from 1.21.1 to 1.22.1 (#1367) Bumps [org.jsoup:jsoup](https://github.com/jhy/jsoup) from 1.21.1 to 1.22.1. - [Release notes](https://github.com/jhy/jsoup/releases) - [Changelog](https://github.com/jhy/jsoup/blob/master/CHANGES.md) - [Commits](https://github.com/jhy/jsoup/compare/jsoup-1.21.1...jsoup-1.22.1) --- updated-dependencies: - dependency-name: org.jsoup:jsoup dependency-version: 1.22.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- application/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/build.gradle b/application/build.gradle index 4f76824186..cfcd5adcc1 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -57,7 +57,7 @@ dependencies { implementation 'io.mikael:urlbuilder:2.0.9' - implementation 'org.jsoup:jsoup:1.21.1' + implementation 'org.jsoup:jsoup:1.22.1' implementation 'org.scilab.forge:jlatexmath:1.0.7' implementation 'org.scilab.forge:jlatexmath-font-greek:1.0.7' From 03baab74bcca393890923a3614d8a8e02ff14f64 Mon Sep 17 00:00:00 2001 From: Daniel Tischner Date: Tue, 6 Jan 2026 23:22:44 +0100 Subject: [PATCH 19/27] Added /message commands (#1372) * Added /message command family - raw - post (and post-with-message) - edit (and edit-with-message) * CR simplified subcommand setup * CR renamed confusing method names --- .../togetherjava/tjbot/features/Features.java | 2 + .../features/messages/MessageCommand.java | 392 ++++++++++++++++++ .../tjbot/features/messages/package-info.java | 11 + 3 files changed, 405 insertions(+) create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/messages/MessageCommand.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/messages/package-info.java 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 463c3b5248..8ed07eff6c 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -39,6 +39,7 @@ import org.togetherjava.tjbot.features.mathcommands.TeXCommand; import org.togetherjava.tjbot.features.mathcommands.wolframalpha.WolframAlphaCommand; import org.togetherjava.tjbot.features.mediaonly.MediaOnlyChannelListener; +import org.togetherjava.tjbot.features.messages.MessageCommand; import org.togetherjava.tjbot.features.moderation.BanCommand; import org.togetherjava.tjbot.features.moderation.KickCommand; import org.togetherjava.tjbot.features.moderation.ModerationActionsStore; @@ -203,6 +204,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new BookmarksCommand(bookmarksSystem)); features.add(new ChatGptCommand(chatGptService, helpSystemHelper)); features.add(new JShellCommand(jshellEval)); + features.add(new MessageCommand()); FeatureBlacklist> blacklist = blacklistConfig.normal(); return blacklist.filterStream(features.stream(), Object::getClass).toList(); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/messages/MessageCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/messages/MessageCommand.java new file mode 100644 index 0000000000..ce36653b36 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/messages/MessageCommand.java @@ -0,0 +1,392 @@ +package org.togetherjava.tjbot.features.messages; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.channel.ChannelType; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.entities.channel.unions.GuildChannelUnion; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.exceptions.ErrorResponseException; +import net.dv8tion.jda.api.interactions.callbacks.IDeferrableCallback; +import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; +import net.dv8tion.jda.api.interactions.commands.CommandInteraction; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import net.dv8tion.jda.api.requests.ErrorResponse; +import net.dv8tion.jda.api.utils.FileUpload; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.features.CommandVisibility; +import org.togetherjava.tjbot.features.SlashCommandAdapter; + +import java.awt.Color; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.OptionalLong; + +/** + * Implements the {@code /message} command, which offers utility dealing with messages. Available + * subcommands are: + *

      + *
    • {@code raw}
    • + *
    • {@code post}
    • + *
    • {@code post-with-message}
    • + *
    • {@code edit}
    • + *
    • {@code edit-with-message}
    • + *
    + */ +public final class MessageCommand extends SlashCommandAdapter { + private static final Logger logger = LoggerFactory.getLogger(MessageCommand.class); + static final String CONTENT_MESSAGE_ID_OPTION = "content-message-id"; + private static final String CONTENT_MESSAGE_ID_DESCRIPTION = + "the id of the message to read content from, must be in the channel this command is invoked"; + static final String SRC_CHANNEL_OPTION = "source"; + private static final String EDIT_SRC_CHANNEL_DESCRIPTION = "where to find the message to edit"; + static final String DEST_CHANNEL_OPTION = "destination"; + private static final String DEST_CHANNEL_DESCRIPTION = "where to post the message"; + static final String CONTENT_OPTION = "content"; + private static final String CONTENT_DESCRIPTION = "the content of the message"; + static final String EDIT_MESSAGE_ID_OPTION = "edit-message-id"; + private static final String EDIT_MESSAGE_ID_DESCRIPTION = "the id of the message to edit"; + + private static final Color AMBIENT_COLOR = new Color(24, 109, 221, 255); + + private static final String CONTENT_FILE_NAME = "content.md"; + + /** + * Creates a new instance. + */ + public MessageCommand() { + super("message", "Provides commands to work with messages", CommandVisibility.GUILD); + + SubcommandData raw = new SubcommandData(Subcommand.RAW.name, + "View the raw content of a message, without Discord interpreting any of its content") + .addOption(OptionType.CHANNEL, SRC_CHANNEL_OPTION, + "where to find the message to retrieve content from", true) + .addOption(OptionType.STRING, CONTENT_MESSAGE_ID_OPTION, + "the id of the message to read content from", true); + + SubcommandData post = + new SubcommandData(Subcommand.POST.name, "Let this bot post a message") + .addOption(OptionType.CHANNEL, DEST_CHANNEL_OPTION, DEST_CHANNEL_DESCRIPTION, + true) + .addOption(OptionType.STRING, CONTENT_OPTION, CONTENT_DESCRIPTION, true); + SubcommandData postWithMessage = new SubcommandData(Subcommand.POST_WITH_MESSAGE.name, + "Let this bot post a message. Content is retrieved from the given message.") + .addOption(OptionType.CHANNEL, DEST_CHANNEL_OPTION, DEST_CHANNEL_DESCRIPTION, true) + .addOption(OptionType.STRING, CONTENT_MESSAGE_ID_OPTION, CONTENT_MESSAGE_ID_DESCRIPTION, + true); + + SubcommandData edit = new SubcommandData(Subcommand.EDIT.name, + "Edits a message posted by this bot, the old content is replaced") + .addOption(OptionType.CHANNEL, SRC_CHANNEL_OPTION, EDIT_SRC_CHANNEL_DESCRIPTION, true) + .addOption(OptionType.STRING, EDIT_MESSAGE_ID_OPTION, EDIT_MESSAGE_ID_DESCRIPTION, true) + .addOption(OptionType.STRING, CONTENT_OPTION, CONTENT_DESCRIPTION, true); + SubcommandData editWithMessage = new SubcommandData(Subcommand.EDIT_WITH_MESSAGE.name, + "Edits a message posted by this bot. Content is retrieved from the given message.") + .addOption(OptionType.CHANNEL, SRC_CHANNEL_OPTION, EDIT_SRC_CHANNEL_DESCRIPTION, true) + .addOption(OptionType.STRING, EDIT_MESSAGE_ID_OPTION, EDIT_MESSAGE_ID_DESCRIPTION, true) + .addOption(OptionType.STRING, CONTENT_MESSAGE_ID_OPTION, CONTENT_MESSAGE_ID_DESCRIPTION, + true); + + getData().addSubcommands(raw, post, postWithMessage, edit, editWithMessage); + } + + /** + * Attempts to convert the given channel into a {@link TextChannel}. + *

    + * If the channel is not a text channel, an error message is send to the user. + * + * @param channel the channel to convert + * @param event the event to send messages with + * @return the channel as text channel, if successful + */ + private static Optional handleExpectMessageChannel(GuildChannelUnion channel, + IReplyCallback event) { + if (channel.getType() != ChannelType.TEXT) { + event + .reply("The given channel ('%s') is not a text-channel." + .formatted(channel.getName())) + .setEphemeral(true) + .queue(); + return Optional.empty(); + } + return Optional.of(channel.asTextChannel()); + } + + /** + * Attempts to parse the given message id. + *

    + * If the message id could not be parsed, because it is invalid, an error message is send to the + * user. + * + * @param messageId the message id to parse + * @param event the event to send messages with + * @return the parsed message id, if successful + */ + private static OptionalLong parseMessageIdAndHandle(String messageId, IReplyCallback event) { + try { + return OptionalLong.of(Long.parseLong(messageId)); + } catch (NumberFormatException _) { + event + .reply("The given message id '%s' is invalid, expected a number." + .formatted(messageId)) + .setEphemeral(true) + .queue(); + return OptionalLong.empty(); + } + } + + private static void handleMessageRetrieveFailed(Throwable failure, IDeferrableCallback event, + long messageId) { + handleMessageRetrieveFailed(failure, event, List.of(messageId)); + } + + private static void handleMessageRetrieveFailed(Throwable failure, IDeferrableCallback event, + List messageIds) { + if (failure instanceof ErrorResponseException ex + && ex.getErrorResponse() == ErrorResponse.UNKNOWN_MESSAGE) { + event.getHook() + .editOriginal("The messages with ids '%s' do not exist.".formatted(messageIds)) + .queue(); + return; + } + + logger.warn("Unable to retrieve the messages with ids '{}' for an unknown reason.", + messageIds, failure); + event.getHook() + .editOriginal( + "Something unexpected went wrong trying to locate the messages with ids '%s'." + .formatted(messageIds)) + .queue(); + } + + private static boolean handleIsMessageFromOtherUser(Message message, + IDeferrableCallback event) { + if (message.getAuthor().equals(message.getJDA().getSelfUser())) { + return false; + } + event.getHook() + .editOriginal( + "The message to edit must be from this bot but was posted by another user.") + .queue(); + return true; + } + + private static void sendSuccessMessage(IDeferrableCallback event, Subcommand action) { + event.getHook() + .editOriginalEmbeds(new EmbedBuilder().setTitle("Success") + .setDescription("Successfully %s message.".formatted(action.getActionVerbPast())) + .setColor(MessageCommand.AMBIENT_COLOR) + .build()) + .queue(); + } + + private static void handleActionFailed(Throwable failure, IDeferrableCallback event, + Subcommand action) { + String verb = action.getActionVerb(); + logger.warn("Unable to {} message for an unknown reason.", verb, failure); + event.getHook() + .editOriginal( + "Something unexpected went wrong trying to '%s' the message.".formatted(verb)) + .queue(); + } + + @Override + public void onSlashCommand(SlashCommandInteractionEvent event) { + switch (Subcommand.fromName(event.getSubcommandName())) { + case RAW -> rawMessage(event); + case POST -> postMessage(event); + case POST_WITH_MESSAGE -> postMessageUsingMessageContent(event); + case EDIT -> editMessage(event); + case EDIT_WITH_MESSAGE -> editMessageUsingMessageContent(event); + default -> throw new AssertionError( + "Unexpected subcommand '%s'".formatted(event.getSubcommandName())); + } + } + + private void rawMessage(SlashCommandInteractionEvent event) { + Optional srcChannelOpt = handleExpectMessageChannel( + Objects.requireNonNull(event.getOption(SRC_CHANNEL_OPTION)).getAsChannel(), event); + if (srcChannelOpt.isEmpty()) { + return; + } + TextChannel srcChannel = srcChannelOpt.orElseThrow(); + + OptionalLong contentMessageIdOpt = parseMessageIdAndHandle( + Objects.requireNonNull(event.getOption(CONTENT_MESSAGE_ID_OPTION)).getAsString(), + event); + if (contentMessageIdOpt.isEmpty()) { + return; + } + long contentMessageId = contentMessageIdOpt.orElseThrow(); + + event.deferReply().queue(); + srcChannel.retrieveMessageById(contentMessageId).queue(contentMessage -> { + String content = contentMessage.getContentRaw(); + event.getHook() + .editOriginal("") + .setFiles(FileUpload.fromData(content.getBytes(StandardCharsets.UTF_8), + CONTENT_FILE_NAME)) + .queue(); + }, failure -> handleMessageRetrieveFailed(failure, event, contentMessageId)); + } + + private void postMessage(CommandInteraction event) { + Subcommand action = Subcommand.POST; + Optional destChannelOpt = handleExpectMessageChannel( + Objects.requireNonNull(event.getOption(DEST_CHANNEL_OPTION)).getAsChannel(), event); + if (destChannelOpt.isEmpty()) { + return; + } + TextChannel destChannel = destChannelOpt.orElseThrow(); + + String content = Objects.requireNonNull(event.getOption(CONTENT_OPTION)).getAsString(); + + event.deferReply().queue(); + destChannel.sendMessage(content) + .queue(_ -> sendSuccessMessage(event, action), + failure -> handleActionFailed(failure, event, action)); + } + + private void postMessageUsingMessageContent(CommandInteraction event) { + Subcommand action = Subcommand.POST_WITH_MESSAGE; + Optional destChannelOpt = handleExpectMessageChannel( + Objects.requireNonNull(event.getOption(DEST_CHANNEL_OPTION)).getAsChannel(), event); + if (destChannelOpt.isEmpty()) { + return; + } + TextChannel destChannel = destChannelOpt.orElseThrow(); + + OptionalLong contentMessageIdOpt = parseMessageIdAndHandle( + Objects.requireNonNull(event.getOption(CONTENT_MESSAGE_ID_OPTION)).getAsString(), + event); + if (contentMessageIdOpt.isEmpty()) { + return; + } + long contentMessageId = contentMessageIdOpt.orElseThrow(); + + event.deferReply().queue(); + event.getMessageChannel().retrieveMessageById(contentMessageId).queue(contentMessage -> { + String content = contentMessage.getContentRaw(); + destChannel.sendMessage(content) + .queue(_ -> sendSuccessMessage(event, action), + failure -> handleActionFailed(failure, event, action)); + }, failure -> handleMessageRetrieveFailed(failure, event, contentMessageId)); + } + + private void editMessage(CommandInteraction event) { + Subcommand action = Subcommand.EDIT; + Optional srcChannelOpt = handleExpectMessageChannel( + Objects.requireNonNull(event.getOption(SRC_CHANNEL_OPTION)).getAsChannel(), event); + if (srcChannelOpt.isEmpty()) { + return; + } + TextChannel srcChannel = srcChannelOpt.orElseThrow(); + + OptionalLong editingMessageIdOpt = parseMessageIdAndHandle( + Objects.requireNonNull(event.getOption(EDIT_MESSAGE_ID_OPTION)).getAsString(), + event); + if (editingMessageIdOpt.isEmpty()) { + return; + } + long editingMessageId = editingMessageIdOpt.orElseThrow(); + String content = Objects.requireNonNull(event.getOption(CONTENT_OPTION)).getAsString(); + + event.deferReply().queue(); + srcChannel.retrieveMessageById(editingMessageId).queue(editingMessage -> { + if (handleIsMessageFromOtherUser(editingMessage, event)) { + return; + } + editingMessage.editMessage(content) + .queue(_ -> sendSuccessMessage(event, action), + failure -> handleActionFailed(failure, event, action)); + }, failure -> handleMessageRetrieveFailed(failure, event, editingMessageId)); + } + + private void editMessageUsingMessageContent(CommandInteraction event) { + Subcommand action = Subcommand.EDIT_WITH_MESSAGE; + Optional srcChannelOpt = handleExpectMessageChannel( + Objects.requireNonNull(event.getOption(SRC_CHANNEL_OPTION)).getAsChannel(), event); + if (srcChannelOpt.isEmpty()) { + return; + } + TextChannel srcChannel = srcChannelOpt.orElseThrow(); + + OptionalLong editingMessageIdOpt = parseMessageIdAndHandle( + Objects.requireNonNull(event.getOption(EDIT_MESSAGE_ID_OPTION)).getAsString(), + event); + if (editingMessageIdOpt.isEmpty()) { + return; + } + long editingMessageId = editingMessageIdOpt.orElseThrow(); + + OptionalLong contentMessageIdOpt = parseMessageIdAndHandle( + Objects.requireNonNull(event.getOption(CONTENT_MESSAGE_ID_OPTION)).getAsString(), + event); + if (contentMessageIdOpt.isEmpty()) { + return; + } + long contentMessageId = contentMessageIdOpt.orElseThrow(); + + event.deferReply().queue(); + record Messages(Message editingMessage, Message contentMessage) { + } + srcChannel.retrieveMessageById(editingMessageId) + .and(event.getMessageChannel().retrieveMessageById(contentMessageId), Messages::new) + .queue(messages -> { + if (handleIsMessageFromOtherUser(messages.editingMessage, event)) { + return; + } + + String content = messages.contentMessage.getContentRaw(); + messages.editingMessage.editMessage(content) + .queue(_ -> sendSuccessMessage(event, action), + failure -> handleActionFailed(failure, event, action)); + }, failure -> handleMessageRetrieveFailed(failure, event, + List.of(editingMessageId, contentMessageId))); + } + + enum Subcommand { + RAW("raw", "", ""), + POST("post", "post", "posted"), + POST_WITH_MESSAGE("post-with-message", "post", "posted"), + EDIT("edit", "edit", "edited"), + EDIT_WITH_MESSAGE("edit-with-message", "edit", "edited"); + + private final String name; + private final String actionVerb; + private final String actionVerbPast; + + Subcommand(String name, String actionVerb, String actionVerbPast) { + this.name = name; + this.actionVerb = actionVerb; + this.actionVerbPast = actionVerbPast; + } + + String getName() { + return name; + } + + String getActionVerb() { + return actionVerb; + } + + String getActionVerbPast() { + return actionVerbPast; + } + + static Subcommand fromName(String name) { + for (Subcommand subcommand : Subcommand.values()) { + if (subcommand.name.equals(name)) { + return subcommand; + } + } + throw new IllegalArgumentException( + "Subcommand with name '%s' is unknown".formatted(name)); + } + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/messages/package-info.java b/application/src/main/java/org/togetherjava/tjbot/features/messages/package-info.java new file mode 100644 index 0000000000..e816d6be6f --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/messages/package-info.java @@ -0,0 +1,11 @@ +/** + * This package offers commands dealing with messages in general. See + * {@link org.togetherjava.tjbot.features.messages.MessageCommand} as main command being offered. + */ +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package org.togetherjava.tjbot.features.messages; + +import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; From 954be5bd70b33e305df0c81fc512068cd417575c Mon Sep 17 00:00:00 2001 From: tj-wazei <158822022+tj-wazei@users.noreply.github.com> Date: Wed, 7 Jan 2026 13:46:11 +0000 Subject: [PATCH 20/27] Update ChatGPT service to use `gpt-5-nano` model (#1373) * chore: remove use `openai-gpt3-java` and use official `openai-java dependency` * feat: use new gpt-5-nano model * chore: run spotless --------- Co-authored-by: Suraj Kumar --- application/build.gradle | 3 +- .../features/chatgpt/ChatGptService.java | 104 ++++++------------ build.gradle | 2 +- 3 files changed, 37 insertions(+), 72 deletions(-) diff --git a/application/build.gradle b/application/build.gradle index cfcd5adcc1..20838cd4bb 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -86,8 +86,7 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testRuntimeOnly "org.junit.jupiter:junit-jupiter-engine:$junitVersion" - implementation "com.theokanning.openai-gpt3-java:api:$chatGPTVersion" - implementation "com.theokanning.openai-gpt3-java:service:$chatGPTVersion" + implementation "com.openai:openai-java:$chatGPTVersion" } application { 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 a6fdcbcb9d..f5790af185 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 @@ -1,10 +1,11 @@ package org.togetherjava.tjbot.features.chatgpt; -import com.theokanning.openai.OpenAiHttpException; -import com.theokanning.openai.completion.chat.ChatCompletionRequest; -import com.theokanning.openai.completion.chat.ChatMessage; -import com.theokanning.openai.completion.chat.ChatMessageRole; -import com.theokanning.openai.service.OpenAiService; +import com.openai.client.OpenAIClient; +import com.openai.client.okhttp.OpenAIOkHttpClient; +import com.openai.models.ChatModel; +import com.openai.models.responses.Response; +import com.openai.models.responses.ResponseCreateParams; +import com.openai.models.responses.ResponseOutputText; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -13,8 +14,8 @@ import javax.annotation.Nullable; import java.time.Duration; -import java.util.List; import java.util.Optional; +import java.util.stream.Collectors; /** * Service used to communicate to OpenAI API to generate responses. @@ -26,30 +27,8 @@ public class ChatGptService { /** The maximum number of tokens allowed for the generated answer. */ private static final int MAX_TOKENS = 3_000; - /** - * This parameter reduces the likelihood of the AI repeating itself. A higher frequency penalty - * makes the model less likely to repeat the same lines verbatim. It helps in generating more - * diverse and varied responses. - */ - private static final double FREQUENCY_PENALTY = 0.5; - - /** - * This parameter controls the randomness of the AI's responses. A higher temperature results in - * more varied, unpredictable, and creative responses. Conversely, a lower temperature makes the - * model's responses more deterministic and conservative. - */ - private static final double TEMPERATURE = 0.8; - - /** - * n: This parameter specifies the number of responses to generate for each prompt. If n is more - * than 1, the AI will generate multiple different responses to the same prompt, each one being - * a separate iteration based on the input. - */ - private static final int MAX_NUMBER_OF_RESPONSES = 1; - private static final String AI_MODEL = "gpt-3.5-turbo"; - private boolean isDisabled = false; - private OpenAiService openAiService; + private OpenAIClient openAIClient; /** * Creates instance of ChatGPTService @@ -63,23 +42,7 @@ public ChatGptService(Config config) { isDisabled = true; return; } - - openAiService = new OpenAiService(apiKey, TIMEOUT); - - ChatMessage setupMessage = new ChatMessage(ChatMessageRole.SYSTEM.value(), """ - For code supplied for review, refer to the old code supplied rather than - rewriting the code. DON'T supply a corrected version of the code.\s"""); - ChatCompletionRequest systemSetupRequest = ChatCompletionRequest.builder() - .model(AI_MODEL) - .messages(List.of(setupMessage)) - .frequencyPenalty(FREQUENCY_PENALTY) - .temperature(TEMPERATURE) - .maxTokens(50) - .n(MAX_NUMBER_OF_RESPONSES) - .build(); - - // Sending the system setup message to ChatGPT. - openAiService.createChatCompletion(systemSetupRequest); + openAIClient = OpenAIOkHttpClient.builder().apiKey(apiKey).timeout(TIMEOUT).build(); } /** @@ -98,32 +61,35 @@ public Optional ask(String question, @Nullable String context) { } String contextText = context == null ? "" : ", Context: %s.".formatted(context); - String fullQuestion = "(KEEP IT CONCISE, NOT MORE THAN 280 WORDS%s) - %s" - .formatted(contextText, question); - - ChatMessage chatMessage = new ChatMessage(ChatMessageRole.USER.value(), fullQuestion); - ChatCompletionRequest chatCompletionRequest = ChatCompletionRequest.builder() - .model(AI_MODEL) - .messages(List.of(chatMessage)) - .frequencyPenalty(FREQUENCY_PENALTY) - .temperature(TEMPERATURE) - .maxTokens(MAX_TOKENS) - .n(MAX_NUMBER_OF_RESPONSES) - .build(); - logger.debug("ChatGpt Request: {}", fullQuestion); + String inputPrompt = """ + For code supplied for review, refer to the old code supplied rather than + rewriting the code. DON'T supply a corrected version of the code. + + KEEP IT CONCISE, NOT MORE THAN 280 WORDS + + %s + Question: %s + """.formatted(contextText, question); + + logger.debug("ChatGpt request: {}", inputPrompt); String response = null; try { - response = openAiService.createChatCompletion(chatCompletionRequest) - .getChoices() - .getFirst() - .getMessage() - .getContent(); - } catch (OpenAiHttpException openAiHttpException) { - logger.warn( - "There was an error using the OpenAI API: {} Code: {} Type: {} Status Code: {}", - openAiHttpException.getMessage(), openAiHttpException.code, - openAiHttpException.type, openAiHttpException.statusCode); + ResponseCreateParams params = ResponseCreateParams.builder() + .model(ChatModel.GPT_5_NANO) + .input(inputPrompt) + .maxOutputTokens(MAX_TOKENS) + .build(); + + Response chatGptResponse = openAIClient.responses().create(params); + + response = chatGptResponse.output() + .stream() + .flatMap(item -> item.message().stream()) + .flatMap(message -> message.content().stream()) + .flatMap(content -> content.outputText().stream()) + .map(ResponseOutputText::text) + .collect(Collectors.joining("\n")); } catch (RuntimeException runtimeException) { logger.warn("There was an error using the OpenAI API: {}", runtimeException.getMessage()); diff --git a/build.gradle b/build.gradle index 548b509dd6..fd7faa6a4a 100644 --- a/build.gradle +++ b/build.gradle @@ -14,7 +14,7 @@ version '1.0-SNAPSHOT' ext { jooqVersion = '3.20.5' jacksonVersion = '2.19.1' - chatGPTVersion = '0.18.2' + chatGPTVersion = '4.13.0' junitVersion = '6.0.0' } From 938c6c12f5be3f5006c1785791dac9eb2210d4aa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 8 Jan 2026 09:01:43 +0100 Subject: [PATCH 21/27] Bump com.openai:openai-java from 4.13.0 to 4.14.0 (#1375) Bumps [com.openai:openai-java](https://github.com/openai/openai-java) from 4.13.0 to 4.14.0. - [Release notes](https://github.com/openai/openai-java/releases) - [Changelog](https://github.com/openai/openai-java/blob/main/CHANGELOG.md) - [Commits](https://github.com/openai/openai-java/compare/v4.13.0...v4.14.0) --- updated-dependencies: - dependency-name: com.openai:openai-java dependency-version: 4.14.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index fd7faa6a4a..d901277666 100644 --- a/build.gradle +++ b/build.gradle @@ -14,7 +14,7 @@ version '1.0-SNAPSHOT' ext { jooqVersion = '3.20.5' jacksonVersion = '2.19.1' - chatGPTVersion = '4.13.0' + chatGPTVersion = '4.14.0' junitVersion = '6.0.0' } From 5009653d1b6bc4b69888ecd9678813029bdf2712 Mon Sep 17 00:00:00 2001 From: tj-wazei <158822022+tj-wazei@users.noreply.github.com> Date: Sat, 10 Jan 2026 12:24:56 +0000 Subject: [PATCH 22/27] Update ChatGPT model to accept ChatModel & use 4.1-mini for questions (#1376) * Update ChatGPT model to GPT_5_MINI * feat: add a chat model parameter to allow choice between models (speed vs quality) * feat: create an enum to house the AI models * update AI response footer to include model * docs: add java docs --------- Co-authored-by: Suraj Kumar --- .../features/chatgpt/ChatGptCommand.java | 8 ++-- .../tjbot/features/chatgpt/ChatGptModel.java | 45 +++++++++++++++++++ .../features/chatgpt/ChatGptService.java | 6 +-- .../tjbot/features/help/HelpSystemHelper.java | 15 ++++--- .../moderation/TransferQuestionCommand.java | 4 +- 5 files changed, 66 insertions(+), 12 deletions(-) create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptModel.java diff --git a/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptCommand.java index 163220d8a5..1f9b3208fb 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptCommand.java @@ -25,6 +25,7 @@ * which it will respond with an AI generated answer. */ public final class ChatGptCommand extends SlashCommandAdapter { + private static final ChatGptModel CHAT_GPT_MODEL = ChatGptModel.HIGH_QUALITY; public static final String COMMAND_NAME = "chatgpt"; private static final String QUESTION_INPUT = "question"; private static final int MAX_MESSAGE_INPUT_LENGTH = 200; @@ -82,8 +83,8 @@ public void onModalSubmitted(ModalInteractionEvent event, List args) { String question = event.getValue(QUESTION_INPUT).getAsString(); - Optional chatgptResponse = - chatGptService.ask(question, "You may use markdown syntax for the response"); + Optional chatgptResponse = chatGptService.ask(question, + "You may use markdown syntax for the response", CHAT_GPT_MODEL); if (chatgptResponse.isPresent()) { userIdToAskedAtCache.put(event.getMember().getId(), Instant.now()); } @@ -96,7 +97,8 @@ public void onModalSubmitted(ModalInteractionEvent event, List args) { String response = chatgptResponse.orElse(errorResponse); SelfUser selfUser = event.getJDA().getSelfUser(); - MessageEmbed responseEmbed = helper.generateGptResponseEmbed(response, selfUser, question); + MessageEmbed responseEmbed = + helper.generateGptResponseEmbed(response, selfUser, question, CHAT_GPT_MODEL); event.getHook().sendMessageEmbeds(responseEmbed).queue(); } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptModel.java b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptModel.java new file mode 100644 index 0000000000..e08951f4b3 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptModel.java @@ -0,0 +1,45 @@ +package org.togetherjava.tjbot.features.chatgpt; + +import com.openai.models.ChatModel; + +/** + * Logical abstraction over OpenAI chat models. + *

    + * This enum allows the application to select models based on performance/quality intent rather than + * hard-coding specific OpenAI model versions throughout the codebase. + * + */ +public enum ChatGptModel { + /** + * Fastest response time with the lowest computational cost. + */ + FASTEST(ChatModel.GPT_3_5_TURBO), + + /** + * Balanced option between speed and quality. + */ + FAST(ChatModel.GPT_4_1_MINI), + + /** + * Highest quality responses with increased reasoning capability. + */ + HIGH_QUALITY(ChatModel.GPT_5_MINI); + + private final ChatModel chatModel; + + ChatGptModel(ChatModel chatModel) { + this.chatModel = chatModel; + } + + /** + * @return the underlying OpenAI model used by this enum. + */ + public ChatModel toChatModel() { + return chatModel; + } + + @Override + public String toString() { + return chatModel.toString(); + } +} 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 f5790af185..02e32cde6e 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 @@ -2,7 +2,6 @@ import com.openai.client.OpenAIClient; import com.openai.client.okhttp.OpenAIOkHttpClient; -import com.openai.models.ChatModel; import com.openai.models.responses.Response; import com.openai.models.responses.ResponseCreateParams; import com.openai.models.responses.ResponseOutputText; @@ -51,11 +50,12 @@ public ChatGptService(Config config) { * @param question The question being asked of ChatGPT. Max is {@value MAX_TOKENS} tokens. * @param context The category of asked question, to set the context(eg. Java, Database, Other * etc). + * @param chatModel The AI model to use for this request. * @return response from ChatGPT as a String. * @see ChatGPT * Tokens. */ - public Optional ask(String question, @Nullable String context) { + public Optional ask(String question, @Nullable String context, ChatGptModel chatModel) { if (isDisabled) { return Optional.empty(); } @@ -76,7 +76,7 @@ public Optional ask(String question, @Nullable String context) { String response = null; try { ResponseCreateParams params = ResponseCreateParams.builder() - .model(ChatModel.GPT_5_NANO) + .model(chatModel.toChatModel()) .input(inputPrompt) .maxOutputTokens(MAX_TOKENS) .build(); 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 dbb6ed55e2..edf217f1ea 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 @@ -25,6 +25,7 @@ import org.togetherjava.tjbot.db.generated.tables.HelpThreads; import org.togetherjava.tjbot.db.generated.tables.records.HelpThreadsRecord; import org.togetherjava.tjbot.features.chatgpt.ChatGptCommand; +import org.togetherjava.tjbot.features.chatgpt.ChatGptModel; import org.togetherjava.tjbot.features.chatgpt.ChatGptService; import org.togetherjava.tjbot.features.componentids.ComponentIdInteractor; import org.togetherjava.tjbot.features.utils.Guilds; @@ -55,6 +56,7 @@ */ public final class HelpSystemHelper { private static final Logger logger = LoggerFactory.getLogger(HelpSystemHelper.class); + private static final ChatGptModel CHAT_GPT_MODEL = ChatGptModel.FAST; static final Color AMBIENT_COLOR = new Color(255, 255, 165); @@ -143,7 +145,7 @@ RestAction constructChatGptAttempt(ThreadChannel threadChannel, String context = "Category %s on a Java Q&A discord server. You may use markdown syntax for the response" .formatted(matchingTag.getName()); - chatGptAnswer = chatGptService.ask(question, context); + chatGptAnswer = chatGptService.ask(question, context, CHAT_GPT_MODEL); if (chatGptAnswer.isEmpty()) { return useChatGptFallbackMessage(threadChannel); @@ -168,7 +170,8 @@ RestAction constructChatGptAttempt(ThreadChannel threadChannel, answer = answer.substring(0, responseCharLimit); } - MessageEmbed responseEmbed = generateGptResponseEmbed(answer, selfUser, originalQuestion); + MessageEmbed responseEmbed = + generateGptResponseEmbed(answer, selfUser, originalQuestion, CHAT_GPT_MODEL); return post.flatMap(_ -> threadChannel.sendMessageEmbeds(responseEmbed) .addActionRow(generateDismissButton(componentIdInteractor, messageId.get()))); } @@ -178,11 +181,13 @@ RestAction constructChatGptAttempt(ThreadChannel threadChannel, * * @param answer The response text generated by AI. * @param selfUser The SelfUser representing the bot. - * @param title The title for the MessageEmbed. + * @param title The title for the MessageEmbed + * @param model The AI model that was used for the foot notes * @return A MessageEmbed that contains response generated by AI. */ - public MessageEmbed generateGptResponseEmbed(String answer, SelfUser selfUser, String title) { - String responseByGptFooter = "- AI generated response"; + public MessageEmbed generateGptResponseEmbed(String answer, SelfUser selfUser, String title, + ChatGptModel model) { + String responseByGptFooter = "- AI generated response using %s model".formatted(model); int embedTitleLimit = MessageEmbed.TITLE_MAX_LENGTH; String capitalizedTitle = Character.toUpperCase(title.charAt(0)) + title.substring(1); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/moderation/TransferQuestionCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/moderation/TransferQuestionCommand.java index 9751397137..8fe47ce1f4 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/moderation/TransferQuestionCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/moderation/TransferQuestionCommand.java @@ -31,6 +31,7 @@ import org.togetherjava.tjbot.features.BotCommandAdapter; import org.togetherjava.tjbot.features.CommandVisibility; import org.togetherjava.tjbot.features.MessageContextCommand; +import org.togetherjava.tjbot.features.chatgpt.ChatGptModel; import org.togetherjava.tjbot.features.chatgpt.ChatGptService; import org.togetherjava.tjbot.features.utils.StringDistances; @@ -98,7 +99,8 @@ public void onMessageContext(MessageContextInteractionEvent event) { String chatGptTitleRequest = "Summarize the following question into a concise title or heading not more than 5 words, remove quotations if any: %s" .formatted(originalMessage); - Optional chatGptTitle = chatGptService.ask(chatGptTitleRequest, null); + Optional chatGptTitle = + chatGptService.ask(chatGptTitleRequest, null, ChatGptModel.FASTEST); String title = chatGptTitle.orElse(createTitle(originalMessage)); if (title.startsWith("\"") && title.endsWith("\"")) { title = title.substring(1, title.length() - 1); From 44bb86bf2c37fd0c3e82f750dddfc2530bb6edc0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 09:12:34 +0100 Subject: [PATCH 23/27] Bump com.openai:openai-java from 4.14.0 to 4.15.0 (#1377) Bumps [com.openai:openai-java](https://github.com/openai/openai-java) from 4.14.0 to 4.15.0. - [Release notes](https://github.com/openai/openai-java/releases) - [Changelog](https://github.com/openai/openai-java/blob/main/CHANGELOG.md) - [Commits](https://github.com/openai/openai-java/compare/v4.14.0...v4.15.0) --- updated-dependencies: - dependency-name: com.openai:openai-java dependency-version: 4.15.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index d901277666..a1257bb686 100644 --- a/build.gradle +++ b/build.gradle @@ -14,7 +14,7 @@ version '1.0-SNAPSHOT' ext { jooqVersion = '3.20.5' jacksonVersion = '2.19.1' - chatGPTVersion = '4.14.0' + chatGPTVersion = '4.15.0' junitVersion = '6.0.0' } From ef6fa5a9663a54a37fb71388f6b4adce8ac8e5ba Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 15 Jan 2026 07:52:46 +0100 Subject: [PATCH 24/27] Bump com.github.freva:ascii-table from 1.8.0 to 1.9.0 (#1379) Bumps [com.github.freva:ascii-table](https://github.com/freva/ascii-table) from 1.8.0 to 1.9.0. - [Release notes](https://github.com/freva/ascii-table/releases) - [Commits](https://github.com/freva/ascii-table/commits) --- updated-dependencies: - dependency-name: com.github.freva:ascii-table dependency-version: 1.9.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- application/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/build.gradle b/application/build.gradle index 20838cd4bb..280d482687 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -69,7 +69,7 @@ dependencies { implementation "com.fasterxml.jackson.core:jackson-databind:$jacksonVersion" implementation "com.sigpwned:jackson-modules-java17-sealed-classes:2.19.0.0" - implementation 'com.github.freva:ascii-table:1.8.0' + implementation 'com.github.freva:ascii-table:1.9.0' implementation 'io.github.url-detector:url-detector:0.1.23' From e43f7c355c230635a71a784fa689b7a21b42868c Mon Sep 17 00:00:00 2001 From: Chris Sdogkos Date: Thu, 15 Jan 2026 21:37:25 +0200 Subject: [PATCH 25/27] Implement Quotes Board (#1029) * feat(cool-messages): add configuration files * feat(cool-messages): add primary logic * feat(cool-message): forward messages instead of using embed For this feature, the version of JDA had to be bumped to 5.1.2 * refactor: code review addressed by Zabuzard * minimumReactions-5, star symbol instead of encoding * requests changes by zabuzard except for moving getBoardChannel down and markMessageAsProcessed * Following JavaDocs guidelines of making the first letter capital * code refactoring * refactor: use correct method for reactionsCount It turns out that for each event fired, every *single* damn time, messageReaction.hasCount() would always return false. No matter what. Terrible documentation from JDA's side. As a result, because of the ternary operator: messageReaction.hasCount() ? messageReaction.getCount() + 1 : 1 the result of `reactionsCount` would always end up holding the value of one. In the following changes, we use `messageReaction.retrieveUsers()` to get a list of the people reacted, get a `Stream` from that and get its count. Much more reliable this way and it also happens to be more readable. Signed-off-by: Chris Sdogkos Co-authored-by: Chris Sdogkos Co-authored-by: Surya Tejess <74978874+suryatejess@users.noreply.github.com> * doc(QuoteBoardForwarder.java): improve JavaDoc Since 1ade40902b4 (refactor: code review addressed by Zabuzard, 2025-06-28) primarily contains a generaly vague JavaDoc describing what the `QuoteBoardForwarder.java` class is doing, a more descriptive one replaces it. Signed-off-by: Chris Sdogkos * refactor: code review addressed by zabuzard * rename coolMessagesConfig to quoteMessagesConfig * removed backticks for QuoteBoardForwarder and added a qualifier statement for QuoteBoardForwarder * param check for reactionEmoji * straight quotes instead of smart quotes * rename isCoolEmoji to isTriggerEmoji * early return when reactionsCount < config.minimumReactions() * early return for isCoolEmoji * fix: more renaming to quoteMessageConfig Due to some hastiness in resolving the recent merge conflicts, some parts with "coolMessagesConfig" were not renamed to "quoteMessagesConfig". Take care of that. Signed-off-by: Chris Sdogkos * fixes a single compilation error: "getCoolMessageConfig" is an old name * applies FirasRG first review Comments: 1. improved settings 2. using meaningful names 3. validations 4. some debug logs * [smallFix] FirasRG first review: replace var with explicit type --------- Signed-off-by: Chris Sdogkos Co-authored-by: Surya Tejess Co-authored-by: Surya Tejess <74978874+suryatejess@users.noreply.github.com> Co-authored-by: Firas Regaieg --- application/config.json.template | 5 + .../org/togetherjava/tjbot/config/Config.java | 16 ++ .../tjbot/config/QuoteBoardConfig.java | 43 +++++ .../togetherjava/tjbot/features/Features.java | 2 + .../tjbot/features/MessageReceiver.java | 10 ++ .../features/MessageReceiverAdapter.java | 7 + .../features/basic/QuoteBoardForwarder.java | 151 ++++++++++++++++++ .../tjbot/features/system/BotCore.java | 9 ++ 8 files changed, 243 insertions(+) create mode 100644 application/src/main/java/org/togetherjava/tjbot/config/QuoteBoardConfig.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/basic/QuoteBoardForwarder.java diff --git a/application/config.json.template b/application/config.json.template index 5cfe9ac38e..a950170fb4 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -194,6 +194,11 @@ "videoLinkPattern": "http(s)?://www\\.youtube.com.*", "pollIntervalInMinutes": 10 }, + "quoteBoardConfig": { + "minimumReactionsToTrigger": 5, + "channel": "quotes", + "reactionEmoji": "⭐" + }, "memberCountCategoryPattern": "Info", "topHelpers": { "rolePattern": "Top Helper.*", diff --git a/application/src/main/java/org/togetherjava/tjbot/config/Config.java b/application/src/main/java/org/togetherjava/tjbot/config/Config.java index 60e6622cbc..a1ee80363d 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -48,6 +48,7 @@ public final class Config { private final RSSFeedsConfig rssFeedsConfig; private final String selectRolesChannelPattern; private final String memberCountCategoryPattern; + private final QuoteBoardConfig quoteBoardConfig; private final TopHelpersConfig topHelpers; @SuppressWarnings("ConstructorWithTooManyParameters") @@ -102,6 +103,8 @@ private Config(@JsonProperty(value = "token", required = true) String token, @JsonProperty(value = "rssConfig", required = true) RSSFeedsConfig rssFeedsConfig, @JsonProperty(value = "selectRolesChannelPattern", required = true) String selectRolesChannelPattern, + @JsonProperty(value = "quoteBoardConfig", + required = true) QuoteBoardConfig quoteBoardConfig, @JsonProperty(value = "topHelpers", required = true) TopHelpersConfig topHelpers) { this.token = Objects.requireNonNull(token); this.githubApiKey = Objects.requireNonNull(githubApiKey); @@ -137,6 +140,7 @@ private Config(@JsonProperty(value = "token", required = true) String token, this.featureBlacklistConfig = Objects.requireNonNull(featureBlacklistConfig); this.rssFeedsConfig = Objects.requireNonNull(rssFeedsConfig); this.selectRolesChannelPattern = Objects.requireNonNull(selectRolesChannelPattern); + this.quoteBoardConfig = Objects.requireNonNull(quoteBoardConfig); this.topHelpers = Objects.requireNonNull(topHelpers); } @@ -431,6 +435,18 @@ public String getSelectRolesChannelPattern() { return selectRolesChannelPattern; } + /** + * The configuration of the quote messages config. + * + *

    + * >The configuration of the quote board feature. Quotes user selected messages. + * + * @return configuration of quote messages config + */ + public QuoteBoardConfig getQuoteBoardConfig() { + return quoteBoardConfig; + } + /** * Gets the pattern matching the category that is used to display the total member count. * diff --git a/application/src/main/java/org/togetherjava/tjbot/config/QuoteBoardConfig.java b/application/src/main/java/org/togetherjava/tjbot/config/QuoteBoardConfig.java new file mode 100644 index 0000000000..faf756b4a8 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/config/QuoteBoardConfig.java @@ -0,0 +1,43 @@ +package org.togetherjava.tjbot.config; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonRootName; +import org.apache.logging.log4j.LogManager; + +import org.togetherjava.tjbot.features.basic.QuoteBoardForwarder; + +import java.util.Objects; + +/** + * Configuration for the quote board feature, see {@link QuoteBoardForwarder}. + */ +@JsonRootName("quoteBoardConfig") +public record QuoteBoardConfig( + @JsonProperty(value = "minimumReactionsToTrigger", required = true) int minimumReactions, + @JsonProperty(required = true) String channel, + @JsonProperty(value = "reactionEmoji", required = true) String reactionEmoji) { + + /** + * Creates a QuoteBoardConfig. + * + * @param minimumReactions the minimum amount of reactions + * @param channel the pattern for the board channel + * @param reactionEmoji the emoji with which users should react to + */ + public QuoteBoardConfig { + if (minimumReactions <= 0) { + throw new IllegalArgumentException("minimumReactions must be greater than zero"); + } + Objects.requireNonNull(channel); + if (channel.isBlank()) { + throw new IllegalArgumentException("channel must not be empty or blank"); + } + Objects.requireNonNull(reactionEmoji); + if (reactionEmoji.isBlank()) { + throw new IllegalArgumentException("reactionEmoji must not be empty or blank"); + } + LogManager.getLogger(QuoteBoardConfig.class) + .debug("Quote-Board configs loaded: minimumReactions={}, channel='{}', reactionEmoji='{}'", + minimumReactions, channel, reactionEmoji); + } +} 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 8ed07eff6c..64bf86c166 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -8,6 +8,7 @@ import org.togetherjava.tjbot.db.Database; 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; @@ -161,6 +162,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new CodeMessageManualDetection(codeMessageHandler)); features.add(new SlashCommandEducator()); features.add(new PinnedNotificationRemover(config)); + features.add(new QuoteBoardForwarder(config)); // Event receivers features.add(new RejoinModerationRoleListener(actionsStore, config)); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/MessageReceiver.java b/application/src/main/java/org/togetherjava/tjbot/features/MessageReceiver.java index c5b6358434..18a1adb023 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/MessageReceiver.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/MessageReceiver.java @@ -3,6 +3,7 @@ import net.dv8tion.jda.api.events.message.MessageDeleteEvent; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.events.message.MessageUpdateEvent; +import net.dv8tion.jda.api.events.message.react.MessageReactionAddEvent; import java.util.regex.Pattern; @@ -56,4 +57,13 @@ public interface MessageReceiver extends Feature { * message that was deleted */ void onMessageDeleted(MessageDeleteEvent event); + + /** + * Triggered by the core system whenever a new reaction was added to a message in a text channel + * of a guild the bot has been added to. + * + * @param event the event that triggered this, containing information about the corresponding + * reaction that was added + */ + void onMessageReactionAdd(MessageReactionAddEvent event); } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/MessageReceiverAdapter.java b/application/src/main/java/org/togetherjava/tjbot/features/MessageReceiverAdapter.java index 05280c97ab..6ceee951b9 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/MessageReceiverAdapter.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/MessageReceiverAdapter.java @@ -3,6 +3,7 @@ import net.dv8tion.jda.api.events.message.MessageDeleteEvent; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.events.message.MessageUpdateEvent; +import net.dv8tion.jda.api.events.message.react.MessageReactionAddEvent; import java.util.regex.Pattern; @@ -57,4 +58,10 @@ public void onMessageUpdated(MessageUpdateEvent event) { public void onMessageDeleted(MessageDeleteEvent event) { // Adapter does not react by default, subclasses may change this behavior } + + @SuppressWarnings("NoopMethodInAbstractClass") + @Override + public void onMessageReactionAdd(MessageReactionAddEvent event) { + // Adapter does not react by default, subclasses may change this behavior + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/basic/QuoteBoardForwarder.java b/application/src/main/java/org/togetherjava/tjbot/features/basic/QuoteBoardForwarder.java new file mode 100644 index 0000000000..22ce51f43f --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/basic/QuoteBoardForwarder.java @@ -0,0 +1,151 @@ +package org.togetherjava.tjbot.features.basic; + +import net.dv8tion.jda.api.JDA; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.MessageReaction; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.entities.emoji.Emoji; +import net.dv8tion.jda.api.events.message.react.MessageReactionAddEvent; +import net.dv8tion.jda.api.requests.RestAction; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.config.QuoteBoardConfig; +import org.togetherjava.tjbot.features.MessageReceiverAdapter; + +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +/** + * Listens for reaction-add events and turns popular messages into "quotes". + *

    + * When someone reacts to a message with the configured emoji, the listener counts how many users + * have used that same emoji. If the total meets or exceeds the minimum threshold and the bot has + * not processed the message before, it copies (forwards) the message to the first text channel + * whose name matches the configured quote-board pattern, then reacts to the original message itself + * to mark it as handled (and to not let people spam react a message and give a way to the bot to + * know that a message has been quoted before). + *

    + * Key points: - Trigger emoji, minimum vote count and quote-board channel pattern are supplied via + * {@code QuoteBoardConfig}. + */ +public final class QuoteBoardForwarder extends MessageReceiverAdapter { + + private static final Logger logger = LoggerFactory.getLogger(QuoteBoardForwarder.class); + private final Emoji triggerReaction; + private final Predicate isQuoteBoardChannelName; + private final QuoteBoardConfig config; + + /** + * Constructs a new instance of QuoteBoardForwarder. + * + * @param config the configuration containing settings specific to the cool messages board, + * including the reaction emoji and the pattern to match board channel names + */ + public QuoteBoardForwarder(Config config) { + this.config = config.getQuoteBoardConfig(); + this.triggerReaction = Emoji.fromUnicode(this.config.reactionEmoji()); + + this.isQuoteBoardChannelName = Pattern.compile(this.config.channel()).asMatchPredicate(); + } + + @Override + public void onMessageReactionAdd(MessageReactionAddEvent event) { + logger.debug("Received MessageReactionAddEvent: messageId={}, channelId={}, userId={}", + event.getMessageId(), event.getChannel().getId(), event.getUserId()); + + final MessageReaction messageReaction = event.getReaction(); + + if (!messageReaction.getEmoji().equals(triggerReaction)) { + logger.debug("Reaction emoji '{}' does not match trigger emoji '{}'. Ignoring.", + messageReaction.getEmoji(), triggerReaction); + return; + } + + if (hasAlreadyForwardedMessage(event.getJDA(), messageReaction)) { + logger.debug("Message has already been forwarded by the bot. Skipping."); + return; + } + + long reactionCount = messageReaction.retrieveUsers().stream().count(); + if (reactionCount < config.minimumReactions()) { + logger.debug("Reaction count {} is less than minimum required {}. Skipping.", + reactionCount, config.minimumReactions()); + return; + } + + final long guildId = event.getGuild().getIdLong(); + + Optional boardChannel = findQuoteBoardChannel(event.getJDA(), guildId); + + if (boardChannel.isEmpty()) { + logger.warn( + "Could not find board channel with pattern '{}' in server with ID '{}'. Skipping reaction handling...", + this.config.channel(), guildId); + return; + } + + logger.debug("Forwarding message to quote board channel: {}", boardChannel.get().getName()); + + event.retrieveMessage() + .queue(message -> markAsProcessed(message) + .flatMap(v -> message.forwardTo(boardChannel.orElseThrow())) + .queue(_ -> logger.debug("Message forwarded to quote board channel: {}", + boardChannel.get().getName())), + + e -> logger.warn( + "Unknown error while attempting to retrieve and forward message for quote-board, message is ignored.", + e)); + + } + + private RestAction markAsProcessed(Message message) { + return message.addReaction(triggerReaction); + } + + /** + * Gets the board text channel where the quotes go to, wrapped in an optional. + * + * @param jda the JDA + * @param guildId the guild ID + * @return the board text channel + */ + private Optional findQuoteBoardChannel(JDA jda, long guildId) { + Guild guild = jda.getGuildById(guildId); + + if (guild == null) { + throw new IllegalStateException( + String.format("Guild with ID '%d' not found.", guildId)); + } + + List matchingChannels = guild.getTextChannelCache() + .stream() + .filter(channel -> isQuoteBoardChannelName.test(channel.getName())) + .toList(); + + if (matchingChannels.size() > 1) { + logger.warn( + "Multiple quote board channels found matching pattern '{}' in guild with ID '{}'. Selecting the first one anyway.", + this.config.channel(), guildId); + } + + return matchingChannels.stream().findFirst(); + } + + /** + * Checks a {@link MessageReaction} to see if the bot has reacted to it. + */ + private boolean hasAlreadyForwardedMessage(JDA jda, MessageReaction messageReaction) { + if (!triggerReaction.equals(messageReaction.getEmoji())) { + return false; + } + + return messageReaction.retrieveUsers() + .parallelStream() + .anyMatch(user -> jda.getSelfUser().getIdLong() == user.getIdLong()); + } +} 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 7c337e2efb..e9d99bc4d1 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 @@ -19,6 +19,7 @@ import net.dv8tion.jda.api.events.message.MessageDeleteEvent; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; import net.dv8tion.jda.api.events.message.MessageUpdateEvent; +import net.dv8tion.jda.api.events.message.react.MessageReactionAddEvent; import net.dv8tion.jda.api.hooks.ListenerAdapter; import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; import net.dv8tion.jda.api.interactions.components.ComponentInteraction; @@ -255,6 +256,14 @@ public void onMessageDelete(final MessageDeleteEvent event) { } } + @Override + public void onMessageReactionAdd(final MessageReactionAddEvent event) { + if (event.isFromGuild()) { + getMessageReceiversSubscribedTo(event.getChannel()) + .forEach(messageReceiver -> messageReceiver.onMessageReactionAdd(event)); + } + } + /** * Calculates the correct voice channel to act upon. * From e1f3022057c4596d264e8fedfafc481a241d2b81 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 22 Jan 2026 08:39:59 +0100 Subject: [PATCH 26/27] Bump com.openai:openai-java from 4.15.0 to 4.16.0 (#1381) Bumps [com.openai:openai-java](https://github.com/openai/openai-java) from 4.15.0 to 4.16.0. - [Release notes](https://github.com/openai/openai-java/releases) - [Changelog](https://github.com/openai/openai-java/blob/main/CHANGELOG.md) - [Commits](https://github.com/openai/openai-java/compare/v4.15.0...v4.16.0) --- updated-dependencies: - dependency-name: com.openai:openai-java dependency-version: 4.16.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index a1257bb686..8a1c011a63 100644 --- a/build.gradle +++ b/build.gradle @@ -14,7 +14,7 @@ version '1.0-SNAPSHOT' ext { jooqVersion = '3.20.5' jacksonVersion = '2.19.1' - chatGPTVersion = '4.15.0' + chatGPTVersion = '4.16.0' junitVersion = '6.0.0' } From c8003e71f41aa80a4093c23a63e30c1ecb46dde1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 08:46:23 +0100 Subject: [PATCH 27/27] Bump com.diffplug.spotless from 8.1.0 to 8.2.0 (#1382) Bumps com.diffplug.spotless from 8.1.0 to 8.2.0. --- updated-dependencies: - dependency-name: com.diffplug.spotless dependency-version: 8.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 8a1c011a63..de2caad6c0 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id "com.diffplug.spotless" version "8.1.0" + id "com.diffplug.spotless" version "8.2.0" id "org.sonarqube" version "7.2.0.6526" id "name.remal.sonarlint" version "7.0.0" }