From fd35376ceb646945f52b6bb40de4dfb077f70eee Mon Sep 17 00:00:00 2001 From: Suraj Kumar Date: Fri, 27 Mar 2026 07:04:40 +0000 Subject: [PATCH 01/23] feat: include emoji name in metric --- .../tjbot/features/analytics/EmojiTrackerListener.java | 10 ++++++---- .../db/V18__Remove_Emojo_From_Events_Table.sql | 1 + 2 files changed, 7 insertions(+), 4 deletions(-) create mode 100644 application/src/main/resources/db/V18__Remove_Emojo_From_Events_Table.sql diff --git a/application/src/main/java/org/togetherjava/tjbot/features/analytics/EmojiTrackerListener.java b/application/src/main/java/org/togetherjava/tjbot/features/analytics/EmojiTrackerListener.java index a5c054bee9..3b64622fc9 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/analytics/EmojiTrackerListener.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/analytics/EmojiTrackerListener.java @@ -44,7 +44,7 @@ public void onMessageReceived(MessageReceivedEvent event) { .getMentions() .getCustomEmojis() .forEach(customEmoji -> trackCustomEmoji("message", customEmoji.getIdLong(), - customEmoji.isAnimated())); + customEmoji.isAnimated(), customEmoji.getName())); } @Override @@ -56,10 +56,12 @@ public void onMessageReactionAdd(MessageReactionAddEvent event) { CustomEmoji customEmoji = emoji.asCustom(); - trackCustomEmoji("reaction", customEmoji.getIdLong(), customEmoji.isAnimated()); + trackCustomEmoji("reaction", customEmoji.getIdLong(), customEmoji.isAnimated(), + customEmoji.getName()); } - private void trackCustomEmoji(String type, long id, boolean isAnimated) { - metrics.count(METRIC_NAME, Map.of("type", type, "id", id, "animated", isAnimated)); + private void trackCustomEmoji(String type, long id, boolean isAnimated, String name) { + metrics.count(METRIC_NAME, + Map.of("type", type, "id", id, "animated", isAnimated, "name", name)); } } diff --git a/application/src/main/resources/db/V18__Remove_Emojo_From_Events_Table.sql b/application/src/main/resources/db/V18__Remove_Emojo_From_Events_Table.sql new file mode 100644 index 0000000000..a5e9bf6778 --- /dev/null +++ b/application/src/main/resources/db/V18__Remove_Emojo_From_Events_Table.sql @@ -0,0 +1 @@ +DELETE FROM metric_events WHERE event = 'emoji'; From 0ab8fc9e4e9b8a86b23cc292b3367dd6218ac4d8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 08:11:53 +0200 Subject: [PATCH 02/23] build(deps): bump org.flywaydb:flyway-core from 12.1.0 to 12.4.0 (#1461) Bumps org.flywaydb:flyway-core from 12.1.0 to 12.4.0. --- updated-dependencies: - dependency-name: org.flywaydb:flyway-core dependency-version: 12.4.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 2d0e9fbbcf..004a471419 100644 --- a/database/build.gradle +++ b/database/build.gradle @@ -7,7 +7,7 @@ var sqliteVersion = "3.51.0.0" dependencies { implementation 'com.google.code.findbugs:jsr305:3.0.2' implementation "org.xerial:sqlite-jdbc:${sqliteVersion}" - implementation 'org.flywaydb:flyway-core:12.1.0' + implementation 'org.flywaydb:flyway-core:12.4.0' implementation "org.jooq:jooq:$jooqVersion" implementation project(':utils') From 8e51fb1aa9119371c18fa5488c491b010bc73928 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 08:12:07 +0200 Subject: [PATCH 03/23] build(deps): bump org.xerial:sqlite-jdbc from 3.51.0.0 to 3.53.0.0 (#1460) Bumps [org.xerial:sqlite-jdbc](https://github.com/xerial/sqlite-jdbc) from 3.51.0.0 to 3.53.0.0. - [Release notes](https://github.com/xerial/sqlite-jdbc/releases) - [Changelog](https://github.com/xerial/sqlite-jdbc/blob/master/CHANGELOG) - [Commits](https://github.com/xerial/sqlite-jdbc/compare/3.51.0.0...3.53.0.0) --- updated-dependencies: - dependency-name: org.xerial:sqlite-jdbc dependency-version: 3.53.0.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 +- database/build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/application/build.gradle b/application/build.gradle index 1dec1bca50..0770a382fb 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -1,6 +1,6 @@ buildscript { dependencies { - classpath 'org.xerial:sqlite-jdbc:3.51.0.0' + classpath 'org.xerial:sqlite-jdbc:3.53.0.0' } } diff --git a/database/build.gradle b/database/build.gradle index 004a471419..907757816c 100644 --- a/database/build.gradle +++ b/database/build.gradle @@ -2,7 +2,7 @@ plugins { id 'java' } -var sqliteVersion = "3.51.0.0" +var sqliteVersion = "3.53.0.0" dependencies { implementation 'com.google.code.findbugs:jsr305:3.0.2' From 541d02a96d78d7f744c1dcb1e6b1fce0ef05c628 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 08:15:50 +0200 Subject: [PATCH 04/23] build(deps): bump org.jooq:jooq from 3.20.5 to 3.21.2 (#1462) Bumps org.jooq:jooq from 3.20.5 to 3.21.2. --- updated-dependencies: - dependency-name: org.jooq:jooq dependency-version: 3.21.2 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 c6a4e21040..cf665b6c4a 100644 --- a/build.gradle +++ b/build.gradle @@ -12,7 +12,7 @@ group 'org.togetherjava' version '1.0-SNAPSHOT' ext { - jooqVersion = '3.20.5' + jooqVersion = '3.21.2' jacksonVersion = '2.19.1' chatGPTVersion = '4.29.0' junitVersion = '6.0.0' From 5faf62c583f981f1f0ffd4d0623dd60022dcd90c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 08:45:53 +0200 Subject: [PATCH 05/23] build(deps): bump org.flywaydb:flyway-core from 12.4.0 to 12.5.0 (#1466) Bumps org.flywaydb:flyway-core from 12.4.0 to 12.5.0. --- updated-dependencies: - dependency-name: org.flywaydb:flyway-core dependency-version: 12.5.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 907757816c..4716c86a4f 100644 --- a/database/build.gradle +++ b/database/build.gradle @@ -7,7 +7,7 @@ var sqliteVersion = "3.53.0.0" dependencies { implementation 'com.google.code.findbugs:jsr305:3.0.2' implementation "org.xerial:sqlite-jdbc:${sqliteVersion}" - implementation 'org.flywaydb:flyway-core:12.4.0' + implementation 'org.flywaydb:flyway-core:12.5.0' implementation "org.jooq:jooq:$jooqVersion" implementation project(':utils') From 0c6f3b315acd328dbc1fa513dee701dcb81b0dca Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:33:35 +0200 Subject: [PATCH 06/23] build(deps): bump com.openai:openai-java from 4.29.0 to 4.33.0 (#1470) Bumps [com.openai:openai-java](https://github.com/openai/openai-java) from 4.29.0 to 4.33.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.29.0...v4.33.0) --- updated-dependencies: - dependency-name: com.openai:openai-java dependency-version: 4.33.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 cf665b6c4a..ca6ab7f905 100644 --- a/build.gradle +++ b/build.gradle @@ -14,7 +14,7 @@ version '1.0-SNAPSHOT' ext { jooqVersion = '3.21.2' jacksonVersion = '2.19.1' - chatGPTVersion = '4.29.0' + chatGPTVersion = '4.33.0' junitVersion = '6.0.0' } From ba80d0396294cca494408c2c7372deff3030507c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 08:33:45 +0200 Subject: [PATCH 07/23] build(deps): bump name.remal.sonarlint from 7.0.0 to 7.1.0 (#1469) Bumps [name.remal.sonarlint](https://github.com/remal-gradle-plugins/sonarlint) from 7.0.0 to 7.1.0. - [Release notes](https://github.com/remal-gradle-plugins/sonarlint/releases) - [Commits](https://github.com/remal-gradle-plugins/sonarlint/compare/v7.0.0...v7.1.0) --- updated-dependencies: - dependency-name: name.remal.sonarlint dependency-version: 7.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 ca6ab7f905..f23df02e2f 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ plugins { id 'java' id "com.diffplug.spotless" version "8.4.0" id "org.sonarqube" version "7.2.0.6526" - id "name.remal.sonarlint" version "7.0.0" + id "name.remal.sonarlint" version "7.1.0" } repositories { mavenCentral() From 0506b1a520ed4dad704e1f32f44908b83483ee16 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 11:01:39 +0200 Subject: [PATCH 08/23] build(deps): bump org.sonarqube from 7.2.0.6526 to 7.3.0.8198 (#1472) Bumps org.sonarqube from 7.2.0.6526 to 7.3.0.8198. --- updated-dependencies: - dependency-name: org.sonarqube dependency-version: 7.3.0.8198 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 f23df02e2f..95f8689597 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ plugins { id 'java' id "com.diffplug.spotless" version "8.4.0" - id "org.sonarqube" version "7.2.0.6526" + id "org.sonarqube" version "7.3.0.8198" id "name.remal.sonarlint" version "7.1.0" } repositories { From 7f5ae2a07c7b3598c5e564770ede655d9eb1f24a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 08:28:04 +0200 Subject: [PATCH 09/23] build(deps): bump com.openai:openai-java from 4.33.0 to 4.34.0 (#1473) Bumps [com.openai:openai-java](https://github.com/openai/openai-java) from 4.33.0 to 4.34.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.33.0...v4.34.0) --- updated-dependencies: - dependency-name: com.openai:openai-java dependency-version: 4.34.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 95f8689597..bf2973e7d2 100644 --- a/build.gradle +++ b/build.gradle @@ -14,7 +14,7 @@ version '1.0-SNAPSHOT' ext { jooqVersion = '3.21.2' jacksonVersion = '2.19.1' - chatGPTVersion = '4.33.0' + chatGPTVersion = '4.34.0' junitVersion = '6.0.0' } From de2cacb4b19b10866ef2e717e272681a20d26ad6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 08:36:40 +0200 Subject: [PATCH 10/23] build(deps): bump org.apache.logging.log4j:log4j-slf4j2-impl (#1474) Bumps org.apache.logging.log4j:log4j-slf4j2-impl from 2.25.0 to 2.26.0. --- updated-dependencies: - dependency-name: org.apache.logging.log4j:log4j-slf4j2-impl dependency-version: 2.26.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 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/build.gradle b/application/build.gradle index 0770a382fb..779306418a 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -48,8 +48,8 @@ dependencies { implementation 'net.dv8tion:JDA:5.6.1' - implementation 'org.apache.logging.log4j:log4j-core:2.25.0' - runtimeOnly 'org.apache.logging.log4j:log4j-slf4j2-impl:2.25.0' + implementation 'org.apache.logging.log4j:log4j-core:2.26.0' + runtimeOnly 'org.apache.logging.log4j:log4j-slf4j2-impl:2.26.0' implementation 'club.minnced:discord-webhooks:0.8.4' From bd416e9a7d0fc829312ed73563caf6aa8b543b32 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 08:36:51 +0200 Subject: [PATCH 11/23] build(deps): bump com.openai:openai-java from 4.34.0 to 4.35.0 (#1475) Bumps [com.openai:openai-java](https://github.com/openai/openai-java) from 4.34.0 to 4.35.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.34.0...v4.35.0) --- updated-dependencies: - dependency-name: com.openai:openai-java dependency-version: 4.35.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 bf2973e7d2..6b53af719f 100644 --- a/build.gradle +++ b/build.gradle @@ -14,7 +14,7 @@ version '1.0-SNAPSHOT' ext { jooqVersion = '3.21.2' jacksonVersion = '2.19.1' - chatGPTVersion = '4.34.0' + chatGPTVersion = '4.35.0' junitVersion = '6.0.0' } From fa81fbc3843d3c7476e30aabda8c0c0b0c89593d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 08:37:02 +0200 Subject: [PATCH 12/23] build(deps): bump org.apache.logging.log4j:log4j-core (#1476) Bumps org.apache.logging.log4j:log4j-core from 2.25.0 to 2.26.0. --- updated-dependencies: - dependency-name: org.apache.logging.log4j:log4j-core dependency-version: 2.26.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> From 6c0ebc0bf5ac577afdde10a355cb575854e64191 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 11 May 2026 08:39:35 +0200 Subject: [PATCH 13/23] build(deps): bump org.flywaydb:flyway-core from 12.5.0 to 12.6.0 (#1477) Bumps org.flywaydb:flyway-core from 12.5.0 to 12.6.0. --- updated-dependencies: - dependency-name: org.flywaydb:flyway-core dependency-version: 12.6.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 4716c86a4f..bd87257a71 100644 --- a/database/build.gradle +++ b/database/build.gradle @@ -7,7 +7,7 @@ var sqliteVersion = "3.53.0.0" dependencies { implementation 'com.google.code.findbugs:jsr305:3.0.2' implementation "org.xerial:sqlite-jdbc:${sqliteVersion}" - implementation 'org.flywaydb:flyway-core:12.5.0' + implementation 'org.flywaydb:flyway-core:12.6.0' implementation "org.jooq:jooq:$jooqVersion" implementation project(':utils') From c941a5a01a6380095241ea0f88e3a8578ead2208 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 08:49:00 +0200 Subject: [PATCH 14/23] build(deps): bump com.diffplug.spotless from 8.4.0 to 8.5.1 (#1481) Bumps com.diffplug.spotless from 8.4.0 to 8.5.1. --- updated-dependencies: - dependency-name: com.diffplug.spotless dependency-version: 8.5.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> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 6b53af719f..3010f88a43 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id "com.diffplug.spotless" version "8.4.0" + id "com.diffplug.spotless" version "8.5.1" id "org.sonarqube" version "7.3.0.8198" id "name.remal.sonarlint" version "7.1.0" } From bdfc278b5206d0251bee74a4e62c9e46275ac88c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 08:49:09 +0200 Subject: [PATCH 15/23] build(deps): bump com.openai:openai-java from 4.35.0 to 4.36.0 (#1480) Bumps [com.openai:openai-java](https://github.com/openai/openai-java) from 4.35.0 to 4.36.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.35.0...v4.36.0) --- updated-dependencies: - dependency-name: com.openai:openai-java dependency-version: 4.36.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 3010f88a43..c02950be9f 100644 --- a/build.gradle +++ b/build.gradle @@ -14,7 +14,7 @@ version '1.0-SNAPSHOT' ext { jooqVersion = '3.21.2' jacksonVersion = '2.19.1' - chatGPTVersion = '4.35.0' + chatGPTVersion = '4.36.0' junitVersion = '6.0.0' } From f5d4d45ce0ce90ac26d6b23990474351310d3a7c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 08:09:28 +0200 Subject: [PATCH 16/23] build(deps): bump junitVersion from 6.0.0 to 6.1.0 (#1482) Bumps `junitVersion` from 6.0.0 to 6.1.0. Updates `org.junit.jupiter:junit-jupiter-api` from 6.0.0 to 6.1.0 - [Release notes](https://github.com/junit-team/junit-framework/releases) - [Commits](https://github.com/junit-team/junit-framework/compare/r6.0.0...r6.1.0) Updates `org.junit.jupiter:junit-jupiter-params` from 6.0.0 to 6.1.0 - [Release notes](https://github.com/junit-team/junit-framework/releases) - [Commits](https://github.com/junit-team/junit-framework/compare/r6.0.0...r6.1.0) Updates `org.junit.jupiter:junit-jupiter-engine` from 6.0.0 to 6.1.0 - [Release notes](https://github.com/junit-team/junit-framework/releases) - [Commits](https://github.com/junit-team/junit-framework/compare/r6.0.0...r6.1.0) --- updated-dependencies: - dependency-name: org.junit.jupiter:junit-jupiter-api dependency-version: 6.1.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: org.junit.jupiter:junit-jupiter-params dependency-version: 6.1.0 dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: org.junit.jupiter:junit-jupiter-engine dependency-version: 6.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 c02950be9f..4ffce3a9fc 100644 --- a/build.gradle +++ b/build.gradle @@ -15,7 +15,7 @@ ext { jooqVersion = '3.21.2' jacksonVersion = '2.19.1' chatGPTVersion = '4.36.0' - junitVersion = '6.0.0' + junitVersion = '6.1.0' } // Skips sonarlint during the build, useful for testing purposes. From 29bc88aaddf0d0448174398da93cea7303f45e51 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 22 May 2026 07:51:54 +0200 Subject: [PATCH 17/23] build(deps): bump com.openai:openai-java from 4.36.0 to 4.37.0 (#1483) Bumps [com.openai:openai-java](https://github.com/openai/openai-java) from 4.36.0 to 4.37.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.36.0...v4.37.0) --- updated-dependencies: - dependency-name: com.openai:openai-java dependency-version: 4.37.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 4ffce3a9fc..508cf2eafb 100644 --- a/build.gradle +++ b/build.gradle @@ -14,7 +14,7 @@ version '1.0-SNAPSHOT' ext { jooqVersion = '3.21.2' jacksonVersion = '2.19.1' - chatGPTVersion = '4.36.0' + chatGPTVersion = '4.37.0' junitVersion = '6.1.0' } From 90799e84a8fbfb75ee0dde11319fa3ab2db7c242 Mon Sep 17 00:00:00 2001 From: Suraj Kumar Date: Sat, 23 May 2026 20:23:57 +0100 Subject: [PATCH 18/23] feat: add support for specifying response schema to AI response (#1485) * feat: add support for specifying response schema * feat: remove toString and use record for ResponseSchema * feat: skip the modal and transfer question directly --- .../features/chatgpt/ChatGptService.java | 65 ++++++- .../features/chatgpt/schema/Property.java | 117 ++++++++++++ .../chatgpt/schema/ResponseSchema.java | 38 ++++ .../tjbot/features/chatgpt/schema/Type.java | 29 +++ .../features/chatgpt/schema/package-info.java | 7 + .../moderation/TransferQuestionCommand.java | 177 ++++++++---------- 6 files changed, 320 insertions(+), 113 deletions(-) create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/chatgpt/schema/Property.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/chatgpt/schema/ResponseSchema.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/chatgpt/schema/Type.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/chatgpt/schema/package-info.java 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 3b2e78af73..f0d83bd23d 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,19 +1,22 @@ package org.togetherjava.tjbot.features.chatgpt; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import com.openai.client.OpenAIClient; import com.openai.client.okhttp.OpenAIOkHttpClient; -import com.openai.models.responses.Response; -import com.openai.models.responses.ResponseCreateParams; -import com.openai.models.responses.ResponseOutputText; +import com.openai.core.JsonValue; +import com.openai.models.responses.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.togetherjava.tjbot.config.Config; import org.togetherjava.tjbot.features.analytics.Metrics; +import org.togetherjava.tjbot.features.chatgpt.schema.ResponseSchema; import javax.annotation.Nullable; import java.time.Duration; +import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; @@ -23,6 +26,8 @@ public class ChatGptService { private static final Logger logger = LoggerFactory.getLogger(ChatGptService.class); private static final Duration TIMEOUT = Duration.ofSeconds(90); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final String DEFAULT_SCHEMA_NAME = "response"; /** The maximum number of tokens allowed for the generated answer. */ private static final int MAX_TOKENS = 1000; @@ -74,7 +79,7 @@ public Optional ask(String question, @Nullable String context, ChatGptMo Question: %s """.formatted(contextText, question); - return sendPrompt(inputPrompt, chatModel); + return sendPrompt(inputPrompt, chatModel, null); } /** @@ -90,7 +95,23 @@ public Optional ask(String question, @Nullable String context, ChatGptMo * Tokens. */ public Optional askRaw(String inputPrompt, ChatGptModel chatModel) { - return sendPrompt(inputPrompt, chatModel); + return sendPrompt(inputPrompt, chatModel, null); + } + + /** + * Prompt ChatGPT with a raw prompt and receive a JSON response conforming to the given schema. + *

+ * Uses OpenAI's structured outputs feature so the model is constrained to return JSON matching + * the supplied schema. + * + * @param inputPrompt The raw prompt to send to ChatGPT. Max is {@value MAX_TOKENS} tokens. + * @param chatModel The AI model to use for this request. + * @param schema The JSON schema the response must conform to. + * @return response from ChatGPT as a JSON string conforming to {@code schema}. + */ + public Optional askRaw(String inputPrompt, ChatGptModel chatModel, + ResponseSchema schema) { + return sendPrompt(inputPrompt, chatModel, schema); } /** @@ -98,9 +119,11 @@ public Optional askRaw(String inputPrompt, ChatGptModel chatModel) { * * @param prompt The prompt to send to ChatGPT. * @param chatModel The AI model to use for this request. + * @param schema Optional JSON schema constraining the model output; {@code null} for free-form. * @return response from ChatGPT as a String. */ - private Optional sendPrompt(String prompt, ChatGptModel chatModel) { + private Optional sendPrompt(String prompt, ChatGptModel chatModel, + @Nullable ResponseSchema schema) { if (isDisabled) { logger.warn("ChatGPT request attempted but service is disabled"); return Optional.empty(); @@ -109,11 +132,16 @@ private Optional sendPrompt(String prompt, ChatGptModel chatModel) { logger.debug("ChatGpt request: {}", prompt); try { - ResponseCreateParams params = ResponseCreateParams.builder() + ResponseCreateParams.Builder paramsBuilder = ResponseCreateParams.builder() .model(chatModel.toChatModel()) .input(prompt) - .maxOutputTokens(MAX_TOKENS) - .build(); + .maxOutputTokens(MAX_TOKENS); + + if (schema != null) { + paramsBuilder.text(buildTextConfig(schema)); + } + + ResponseCreateParams params = paramsBuilder.build(); Response chatGptResponse = openAIClient.responses().create(params); metrics.count("chatgpt-prompted"); @@ -142,4 +170,23 @@ private Optional sendPrompt(String prompt, ChatGptModel chatModel) { return Optional.empty(); } } + + private static ResponseTextConfig buildTextConfig(ResponseSchema schema) { + Map schemaMap = + OBJECT_MAPPER.convertValue(schema, new TypeReference<>() {}); + + ResponseFormatTextJsonSchemaConfig.Schema.Builder schemaBuilder = + ResponseFormatTextJsonSchemaConfig.Schema.builder(); + schemaMap.forEach( + (key, value) -> schemaBuilder.putAdditionalProperty(key, JsonValue.from(value))); + + ResponseFormatTextJsonSchemaConfig jsonSchemaConfig = + ResponseFormatTextJsonSchemaConfig.builder() + .name(DEFAULT_SCHEMA_NAME) + .strict(true) + .schema(schemaBuilder.build()) + .build(); + + return ResponseTextConfig.builder().format(jsonSchemaConfig).build(); + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/schema/Property.java b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/schema/Property.java new file mode 100644 index 0000000000..184c2c74f7 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/schema/Property.java @@ -0,0 +1,117 @@ +package org.togetherjava.tjbot.features.chatgpt.schema; + +import java.util.List; +import java.util.Map; + +/** + * Represents a single property in an OpenAI JSON schema, used to describe the shape of one field + * inside a {@link ResponseSchema}. + *

+ * The hierarchy is sealed and mirrors the JSON Schema specification supported by OpenAI's + * structured outputs: + *

    + *
  • {@link Primitive} โ€“ scalar values ({@code string}, {@code number}, {@code integer}, + * {@code boolean}, {@code null}).
  • + *
  • {@link ArrayProperty} - an array whose elements conform to a nested {@link Property}.
  • + *
  • {@link ObjectProperty} - a nested object with its own {@code properties} and {@code required} + * list.
  • + *
+ * Prefer the static factory methods ({@link #of}, {@link #array}, {@link #object}) for readable + * construction. + * + * @see ResponseSchema + * @see OpenAI Structured + * Outputs + */ +public sealed interface Property + permits Property.Primitive, Property.ArrayProperty, Property.ObjectProperty { + + /** + * The JSON schema {@link Type} of this property. + * + * @return the type this property declares + */ + Type type(); + + /** + * Creates a primitive property of the given scalar type. + * + * @param type a scalar type such as {@link Type#STRING} or {@link Type#INTEGER} + * @return a new {@link Primitive} property + */ + static Primitive of(Type type) { + return new Primitive(type); + } + + /** + * Creates an array property whose elements conform to the given item schema. + * + * @param items the schema each element must satisfy + * @return a new {@link ArrayProperty} of type {@link Type#ARRAY} + */ + static ArrayProperty array(Property items) { + return new ArrayProperty(items); + } + + /** + * Creates a nested object property with {@code additionalProperties} disabled, matching + * OpenAI's strict-mode requirement. + * + * @param properties the fields of the nested object, keyed by field name + * @param required the names of fields that must be present + * @return a new {@link ObjectProperty} of type {@link Type#OBJECT} + */ + static ObjectProperty object(Map properties, List required) { + return new ObjectProperty(properties, required, false); + } + + /** + * A scalar property - anything that isn't an object or array. + * + * @param type the scalar JSON type + */ + record Primitive(Type type) implements Property { + } + + /** + * An array property describing a list of elements that all match {@link #items}. + * + * @param type always {@link Type#ARRAY} + * @param items the schema each element must satisfy + */ + record ArrayProperty(Type type, Property items) implements Property { + /** + * Convenience constructor that fixes {@link #type} to {@link Type#ARRAY}. + * + * @param items the schema each element must satisfy + */ + public ArrayProperty(Property items) { + this(Type.ARRAY, items); + } + } + + /** + * A nested object property with its own field definitions. Mirrors the top-level + * {@link ResponseSchema} structure, allowing arbitrarily deep nesting. + * + * @param type always {@link Type#OBJECT} + * @param properties the fields of the nested object, keyed by field name + * @param required the names of fields that must be present + * @param additionalProperties whether fields beyond those declared in {@code properties} are + * allowed; OpenAI's strict mode requires {@code false} + */ + record ObjectProperty(Type type, Map properties, List required, + boolean additionalProperties) implements Property { + /** + * Convenience constructor that fixes {@link #type} to {@link Type#OBJECT}. + * + * @param properties the fields of the nested object + * @param required the names of fields that must be present + * @param additionalProperties whether undeclared fields are allowed + */ + public ObjectProperty(Map properties, List required, + boolean additionalProperties) { + this(Type.OBJECT, properties, required, additionalProperties); + } + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/schema/ResponseSchema.java b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/schema/ResponseSchema.java new file mode 100644 index 0000000000..ea8cfcd48f --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/schema/ResponseSchema.java @@ -0,0 +1,38 @@ +package org.togetherjava.tjbot.features.chatgpt.schema; + +import java.util.List; +import java.util.Map; + +/** + * Top-level JSON schema describing the shape of a structured response from the OpenAI API. + *

+ * Mirrors the {@code json_schema.schema} object that OpenAI's structured-outputs feature expects: + * an object schema with declared {@code properties}, a {@code required} list, and the + * {@code additionalProperties} flag (which must be {@code false} in strict mode). + *

+ * Use {@link Property} (and its static factories) to build the {@code properties} map. Example: + * + *

{@code
+ * ResponseSchema schema = new ResponseSchema(Map.of("answer", Property.of(Type.STRING), "tags",
+ *         Property.array(Property.of(Type.STRING))), List.of("answer", "tags"));
+ * }
+ * + * @param type the JSON type โ€” must be {@link Type#OBJECT} for a top-level schema + * @param properties the fields of the response object, keyed by field name + * @param required the names of fields the model must always include + * @param additionalProperties whether undeclared fields are allowed; strict mode requires + * {@code false} + */ +public record ResponseSchema(Type type, Map properties, List required, + boolean additionalProperties) { + + /** + * Creates a strict-mode object schema: {@code type=object}, {@code additionalProperties=false}. + * + * @param properties the fields of the response object, keyed by field name + * @param required the names of fields the model must always include + */ + public ResponseSchema(Map properties, List required) { + this(Type.OBJECT, properties, required, false); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/schema/Type.java b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/schema/Type.java new file mode 100644 index 0000000000..0923f2a091 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/schema/Type.java @@ -0,0 +1,29 @@ +package org.togetherjava.tjbot.features.chatgpt.schema; + +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * The JSON Schema primitive types supported by OpenAI's structured outputs. Each constant + * serializes to its lowercase form (e.g. {@code STRING} โ†’ {@code "string"}) via {@link #jsonValue}, + * matching the JSON Schema specification. + */ +public enum Type { + STRING, + NUMBER, + INTEGER, + BOOLEAN, + OBJECT, + ARRAY, + NULL; + + /** + * Returns the lowercase JSON representation of this type. Used by Jackson via + * {@link JsonValue}, so the enum serializes to {@code "string"} rather than {@code "STRING"}. + * + * @return the JSON Schema type name in lowercase + */ + @JsonValue + public String jsonValue() { + return name().toLowerCase(); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/schema/package-info.java b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/schema/package-info.java new file mode 100644 index 0000000000..913df7e98f --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/schema/package-info.java @@ -0,0 +1,7 @@ +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package org.togetherjava.tjbot.features.chatgpt.schema; + +import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; 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 8fe47ce1f4..b8430b9480 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 @@ -1,5 +1,6 @@ package org.togetherjava.tjbot.features.moderation; +import com.fasterxml.jackson.databind.ObjectMapper; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.entities.Guild; @@ -12,13 +13,9 @@ import net.dv8tion.jda.api.entities.channel.forums.ForumTagSnowflake; import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; import net.dv8tion.jda.api.entities.channel.unions.MessageChannelUnion; -import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; import net.dv8tion.jda.api.events.interaction.command.MessageContextInteractionEvent; import net.dv8tion.jda.api.exceptions.ErrorResponseException; import net.dv8tion.jda.api.interactions.commands.build.Commands; -import net.dv8tion.jda.api.interactions.components.text.TextInput; -import net.dv8tion.jda.api.interactions.components.text.TextInputStyle; -import net.dv8tion.jda.api.interactions.modals.Modal; import net.dv8tion.jda.api.requests.ErrorResponse; import net.dv8tion.jda.api.requests.RestAction; import net.dv8tion.jda.api.requests.restaction.WebhookMessageCreateAction; @@ -33,10 +30,14 @@ 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.chatgpt.schema.Property; +import org.togetherjava.tjbot.features.chatgpt.schema.ResponseSchema; +import org.togetherjava.tjbot.features.chatgpt.schema.Type; import org.togetherjava.tjbot.features.utils.StringDistances; import java.awt.Color; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.function.Consumer; import java.util.function.Function; @@ -45,28 +46,25 @@ import java.util.regex.Pattern; /** - * This command can be used to transfer questions asked in any channel to the helper forum. The user - * is given the chance to edit details of the question and upon submitting, the original message - * will be deleted and recreated in the helper forum. The original author is notified and redirected - * to the new post. + * This command transfers a question asked in any channel to the helper forum. The AI generates the + * post title and selects the most fitting tag; the original message body is reused verbatim. On + * success the original message is deleted and its author is notified and redirected to the new + * post. */ public final class TransferQuestionCommand extends BotCommandAdapter implements MessageContextCommand { private static final Logger logger = LoggerFactory.getLogger(TransferQuestionCommand.class); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); private static final String COMMAND_NAME = "transfer-question"; - private static final String MODAL_TITLE_ID = "transferID"; - private static final String MODAL_INPUT_ID = "transferQuestion"; - private static final String MODAL_TAG = "tags"; private static final int TITLE_MAX_LENGTH = 50; private static final Pattern TITLE_GUESS_COMPACT_REMOVAL_PATTERN = Pattern.compile("\\W"); private static final int TITLE_MIN_LENGTH = 3; private static final Color EMBED_COLOR = new Color(50, 164, 168); - private static final int INPUT_MAX_LENGTH = Message.MAX_CONTENT_LENGTH; - private static final int INPUT_MIN_LENGTH = 3; + private final Predicate isHelpForumName; private final List tags; private final ChatGptService chatGptService; - + private final ResponseSchema transferSchema; /** * Creates a new instance. @@ -82,6 +80,9 @@ public TransferQuestionCommand(Config config, ChatGptService chatGptService) { tags = config.getHelpSystem().getCategories(); this.chatGptService = chatGptService; + this.transferSchema = new ResponseSchema( + Map.of("title", Property.of(Type.STRING), "tag", Property.of(Type.STRING)), + List.of("title", "tag")); } @Override @@ -90,88 +91,68 @@ public void onMessageContext(MessageContextInteractionEvent event) { return; } - String originalMessage = event.getTarget().getContentRaw(); - String originalMessageId = event.getTarget().getId(); - String originalChannelId = event.getTarget().getChannel().getId(); - String authorId = event.getTarget().getAuthor().getId(); - String mostCommonTag = tags.getFirst(); - - 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, ChatGptModel.FASTEST); - String title = chatGptTitle.orElse(createTitle(originalMessage)); - if (title.startsWith("\"") && title.endsWith("\"")) { - title = title.substring(1, title.length() - 1); - } - - if (title.length() > TITLE_MAX_LENGTH) { - title = title.substring(0, TITLE_MAX_LENGTH); - } - - TextInput modalTitle = TextInput.create(MODAL_TITLE_ID, "Title", TextInputStyle.SHORT) - .setMaxLength(TITLE_MAX_LENGTH) - .setMinLength(TITLE_MIN_LENGTH) - .setPlaceholder("Describe the question in short") - .setValue(title) - .build(); - - TextInput.Builder modalInputBuilder = - TextInput.create(MODAL_INPUT_ID, "Question", TextInputStyle.PARAGRAPH) - .setRequiredRange(INPUT_MIN_LENGTH, INPUT_MAX_LENGTH) - .setPlaceholder("Contents of the question"); - - if (!isQuestionTooShort(originalMessage)) { - String trimmedMessage = getMessageUptoMaxLimit(originalMessage); - modalInputBuilder.setValue(trimmedMessage); - } - - TextInput modalTag = TextInput.create(MODAL_TAG, "Most fitting tag", TextInputStyle.SHORT) - .setValue(mostCommonTag) - .setPlaceholder("Suitable tag for the question") - .build(); - - String modalComponentId = - generateComponentId(authorId, originalMessageId, originalChannelId); - Modal transferModal = Modal.create(modalComponentId, "Transfer this question") - .addActionRow(modalTitle) - .addActionRow(modalInputBuilder.build()) - .addActionRow(modalTag) - .build(); - - event.replyModal(transferModal).queue(); - } - - @Override - public void onModalSubmitted(ModalInteractionEvent event, List args) { event.deferReply(true).queue(); - String authorId = args.getFirst(); - String messageId = args.get(1); - String channelId = args.get(2); - ForumChannel helperForum = getHelperForum(event.getJDA()); + Message message = event.getTarget(); + String authorId = message.getAuthor().getId(); + String messageId = message.getId(); + String channelId = message.getChannelId(); + String content = message.getContentRaw(); + + AiTransferResult ai = askAi(content) + .orElseGet(() -> new AiTransferResult(createTitle(content), tags.getFirst())); + String title = sanitizeTitle(ai.title()); + String tag = ai.tag(); - // Has been handled if original message was deleted by now. - // Deleted messages cause retrieveMessageById to fail. Consumer notHandledAction = - _ -> transferFlow(event, channelId, authorId, messageId); + _ -> transferFlow(event, channelId, authorId, messageId, title, tag, content); Consumer handledAction = failure -> { if (failure instanceof ErrorResponseException errorResponseException && errorResponseException.getErrorResponse() == ErrorResponse.UNKNOWN_MESSAGE) { - alreadyHandled(event, helperForum); + alreadyHandled(event); return; } - logger.warn("Unknown error occurred on modal submission during question transfer.", - failure); + logger.warn("Unknown error occurred during question transfer.", failure); }; event.getChannel().retrieveMessageById(messageId).queue(notHandledAction, handledAction); } - private void transferFlow(ModalInteractionEvent event, String channelId, String authorId, - String messageId) { + private Optional askAi(String content) { + String prompt = """ + Summarize the following question into a concise title (max 5 words, no quotes) \ + and pick the single most fitting tag from this list: %s. + + Question: %s + """.formatted(tags, content); + + return chatGptService.askRaw(prompt, ChatGptModel.FAST, transferSchema) + .flatMap(this::parseAi); + } + + private Optional parseAi(String json) { + try { + return Optional.of(OBJECT_MAPPER.readValue(json, AiTransferResult.class)); + } catch (Exception e) { + logger.warn("Failed to parse AI transfer response: {}", json, e); + return Optional.empty(); + } + } + + private static String sanitizeTitle(String raw) { + String title = raw; + if (title.startsWith("\"") && title.endsWith("\"")) { + title = title.substring(1, title.length() - 1); + } + if (title.length() > TITLE_MAX_LENGTH) { + title = title.substring(0, TITLE_MAX_LENGTH); + } + return title; + } + + private void transferFlow(MessageContextInteractionEvent event, String channelId, + String authorId, String messageId, String title, String tag, String content) { Function> sendMessageToTransferrer = post -> event.getHook() .sendMessage("Transferred to %s" @@ -179,7 +160,7 @@ private void transferFlow(ModalInteractionEvent event, String channelId, String event.getJDA() .retrieveUserById(authorId) - .flatMap(fetchedUser -> createForumPost(event, fetchedUser)) + .flatMap(fetchedUser -> createForumPost(event, fetchedUser, title, tag, content)) .flatMap(createdForumPost -> dmUser(event.getChannel(), createdForumPost, event.getGuild()) .and(sendMessageToTransferrer.apply(createdForumPost))) @@ -187,7 +168,8 @@ private void transferFlow(ModalInteractionEvent event, String channelId, String .queue(); } - private void alreadyHandled(ModalInteractionEvent event, ForumChannel helperForum) { + private void alreadyHandled(MessageContextInteractionEvent event) { + ForumChannel helperForum = getHelperForum(event.getJDA()); event.getHook() .sendMessage( "It appears that someone else has already transferred this question. Kindly see %s for details." @@ -216,30 +198,24 @@ private static boolean isTitleValid(CharSequence title) { && titleCompact.length() <= TITLE_MAX_LENGTH; } - private RestAction createForumPost(ModalInteractionEvent event, - User originalUser) { - String originalMessage = event.getValue(MODAL_INPUT_ID).getAsString(); - - MessageEmbed embedForPost = makeEmbedForPost(originalUser, originalMessage); + private RestAction createForumPost(MessageContextInteractionEvent event, + User originalUser, String title, String tagQuery, String content) { + MessageEmbed embedForPost = makeEmbedForPost(originalUser, content); MessageCreateData forumMessage = new MessageCreateBuilder() .addContent("%s has a question:".formatted(originalUser.getAsMention())) .setEmbeds(embedForPost) .build(); - String forumTitle = event.getValue(MODAL_TITLE_ID).getAsString(); - String transferQuestionTag = event.getValue(MODAL_TAG).getAsString(); - ForumChannel questionsForum = getHelperForum(event.getJDA()); String mostCommonTag = tags.getFirst(); - String queryTag = - StringDistances.closestMatch(transferQuestionTag, tags).orElse(mostCommonTag); + String queryTag = StringDistances.closestMatch(tagQuery, tags).orElse(mostCommonTag); ForumTag tag = getTagOrDefault(questionsForum.getAvailableTagsByName(queryTag, true), () -> questionsForum.getAvailableTagsByName(mostCommonTag, true).getFirst()); - return questionsForum.createForumPost(forumTitle, forumMessage) + return questionsForum.createForumPost(title, forumMessage) .setTags(ForumTagSnowflake.fromId(tag.getId())) .map(createdPost -> new ForumPostData(createdPost, originalUser)); } @@ -299,6 +275,9 @@ private MessageEmbed makeEmbedForPost(User originalUser, String originalMessage) private record ForumPostData(ForumPost forumPost, User author) { } + private record AiTransferResult(String title, String tag) { + } + private boolean isBotMessageTransfer(User author) { return author.isBot(); } @@ -307,10 +286,6 @@ private void handleBotMessageTransfer(MessageContextInteractionEvent event) { event.reply("Cannot transfer messages from a bot.").setEphemeral(true).queue(); } - private boolean isQuestionTooShort(String question) { - return question.length() < INPUT_MIN_LENGTH; - } - private boolean isInvalidForTransfer(MessageContextInteractionEvent event) { User author = event.getTarget().getAuthor(); @@ -320,10 +295,4 @@ private boolean isInvalidForTransfer(MessageContextInteractionEvent event) { } return false; } - - private String getMessageUptoMaxLimit(String originalMessage) { - return originalMessage.length() > INPUT_MAX_LENGTH - ? originalMessage.substring(0, INPUT_MAX_LENGTH) - : originalMessage; - } } From d38139941fd62fb3192e699475fd969487d09654 Mon Sep 17 00:00:00 2001 From: Chris Sdogkos Date: Sat, 23 May 2026 23:51:21 +0400 Subject: [PATCH 19/23] feat: create role application system (#1049) Signed-off-by: Chris Sdogkos --- application/config.json.template | 4 + .../org/togetherjava/tjbot/config/Config.java | 13 + .../config/RoleApplicationSystemConfig.java | 38 +++ .../togetherjava/tjbot/features/Features.java | 2 + .../CreateRoleApplicationCommand.java | 286 ++++++++++++++++++ .../RoleApplicationHandler.java | 162 ++++++++++ .../roleapplication/package-info.java | 12 + 7 files changed, 517 insertions(+) create mode 100644 application/src/main/java/org/togetherjava/tjbot/config/RoleApplicationSystemConfig.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/roleapplication/CreateRoleApplicationCommand.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/roleapplication/RoleApplicationHandler.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/roleapplication/package-info.java diff --git a/application/config.json.template b/application/config.json.template index e2e1963c80..105db688da 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -199,6 +199,10 @@ "channel": "quotes", "reactionEmoji": "โญ" }, + "roleApplicationSystem": { + "submissionsChannelPattern": "staff-applications", + "defaultQuestion": "What makes you a good addition to the team?" + }, "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 33362afcb0..8032c465ba 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 RoleApplicationSystemConfig roleApplicationSystemConfig; private final QuoteBoardConfig quoteBoardConfig; private final TopHelpersConfig topHelpers; private final DynamicVoiceChatConfig dynamicVoiceChatConfig; @@ -106,6 +107,8 @@ private Config(@JsonProperty(value = "token", required = true) String token, required = true) String selectRolesChannelPattern, @JsonProperty(value = "quoteBoardConfig", required = true) QuoteBoardConfig quoteBoardConfig, + @JsonProperty(value = "roleApplicationSystem", + required = true) RoleApplicationSystemConfig roleApplicationSystemConfig, @JsonProperty(value = "topHelpers", required = true) TopHelpersConfig topHelpers, @JsonProperty(value = "dynamicVoiceChatConfig", required = true) DynamicVoiceChatConfig dynamicVoiceChatConfig) { @@ -144,6 +147,7 @@ private Config(@JsonProperty(value = "token", required = true) String token, this.rssFeedsConfig = Objects.requireNonNull(rssFeedsConfig); this.selectRolesChannelPattern = Objects.requireNonNull(selectRolesChannelPattern); this.quoteBoardConfig = Objects.requireNonNull(quoteBoardConfig); + this.roleApplicationSystemConfig = roleApplicationSystemConfig; this.topHelpers = Objects.requireNonNull(topHelpers); this.dynamicVoiceChatConfig = Objects.requireNonNull(dynamicVoiceChatConfig); } @@ -460,6 +464,15 @@ public String getMemberCountCategoryPattern() { return memberCountCategoryPattern; } + /** + * The configuration related to the application form. + * + * @return the application form config + */ + public RoleApplicationSystemConfig getRoleApplicationSystemConfig() { + return roleApplicationSystemConfig; + } + /** * Gets the RSS feeds configuration. * diff --git a/application/src/main/java/org/togetherjava/tjbot/config/RoleApplicationSystemConfig.java b/application/src/main/java/org/togetherjava/tjbot/config/RoleApplicationSystemConfig.java new file mode 100644 index 0000000000..0979556b58 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/config/RoleApplicationSystemConfig.java @@ -0,0 +1,38 @@ +package org.togetherjava.tjbot.config; + +import com.fasterxml.jackson.annotation.JsonProperty; +import net.dv8tion.jda.api.interactions.components.text.TextInput; + +import java.util.Objects; + +/** + * Represents the configuration for an application form, including roles and application channel + * pattern. + * + * @param submissionsChannelPattern the pattern used to identify the submissions channel where + * applications are sent + * @param defaultQuestion the default question that will be asked in the role application form + */ +public record RoleApplicationSystemConfig( + @JsonProperty(value = "submissionsChannelPattern", + required = true) String submissionsChannelPattern, + @JsonProperty(value = "defaultQuestion", required = true) String defaultQuestion) { + + /** + * Constructs an instance of {@link RoleApplicationSystemConfig} with the provided parameters. + *

+ * This constructor ensures that {@code submissionsChannelPattern} and {@code defaultQuestion} + * are not null and that the length of the {@code defaultQuestion} does not exceed the maximum + * allowed length. + */ + public RoleApplicationSystemConfig { + Objects.requireNonNull(submissionsChannelPattern); + Objects.requireNonNull(defaultQuestion); + + if (defaultQuestion.length() > TextInput.MAX_LABEL_LENGTH) { + throw new IllegalArgumentException( + "defaultQuestion length is too long! Cannot be greater than %d" + .formatted(TextInput.MAX_LABEL_LENGTH)); + } + } +} 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 c360bacdd1..4ca201fb9d 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -69,6 +69,7 @@ import org.togetherjava.tjbot.features.projects.ProjectsThreadCreatedListener; import org.togetherjava.tjbot.features.reminder.RemindRoutine; import org.togetherjava.tjbot.features.reminder.ReminderCommand; +import org.togetherjava.tjbot.features.roleapplication.CreateRoleApplicationCommand; import org.togetherjava.tjbot.features.rss.RSSHandlerRoutine; import org.togetherjava.tjbot.features.system.BotCore; import org.togetherjava.tjbot.features.system.LogLevelCommand; @@ -219,6 +220,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new JShellCommand(jshellEval)); features.add(new MessageCommand()); features.add(new RewriteCommand(chatGptService)); + features.add(new CreateRoleApplicationCommand(config)); FeatureBlacklist> blacklist = blacklistConfig.normal(); return blacklist.filterStream(features.stream(), Object::getClass).toList(); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/roleapplication/CreateRoleApplicationCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/roleapplication/CreateRoleApplicationCommand.java new file mode 100644 index 0000000000..984e3b4ee6 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/roleapplication/CreateRoleApplicationCommand.java @@ -0,0 +1,286 @@ +package org.togetherjava.tjbot.features.roleapplication; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.emoji.Emoji; +import net.dv8tion.jda.api.entities.emoji.EmojiUnion; +import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.StringSelectInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.CommandInteraction; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData; +import net.dv8tion.jda.api.interactions.components.ActionRow; +import net.dv8tion.jda.api.interactions.components.selections.SelectOption; +import net.dv8tion.jda.api.interactions.components.selections.StringSelectMenu; +import net.dv8tion.jda.api.interactions.components.text.TextInput; +import net.dv8tion.jda.api.interactions.components.text.TextInputStyle; +import net.dv8tion.jda.api.interactions.modals.Modal; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.config.RoleApplicationSystemConfig; +import org.togetherjava.tjbot.features.CommandVisibility; +import org.togetherjava.tjbot.features.SlashCommandAdapter; +import org.togetherjava.tjbot.features.componentids.Lifespan; + +import javax.annotation.Nullable; + +import java.awt.Color; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.IntStream; + +/** + * Represents a command to create an application form for members to apply for roles. + *

+ * This command is designed to generate an application form for members to apply for roles within a + * guild. + */ +public class CreateRoleApplicationCommand extends SlashCommandAdapter { + protected static final Color AMBIENT_COLOR = new Color(24, 221, 136, 255); + private static final int OPTIONAL_ROLES_AMOUNT = 5; + private static final String ROLE_COMPONENT_ID_HEADER = "application-create"; + private static final String OPTION_PARAM_ID_DELIMITER = "_"; + private static final int OPTIONS_PER_ROLE = 3; + private static final int MINIMUM_ANSWER_LENGTH = 50; + private static final int MAXIMUM_ANSWER_LENGTH = 500; + + private final RoleApplicationHandler handler; + private final RoleApplicationSystemConfig config; + + private static final Logger logger = + LoggerFactory.getLogger(CreateRoleApplicationCommand.class); + + /** + * Constructs a new {@link CreateRoleApplicationCommand} with the specified configuration. + *

+ * This command is designed to generate an application form for members to apply for roles. + * + * @param config the configuration containing the settings for the application form + */ + public CreateRoleApplicationCommand(Config config) { + super("application-form", "Generates an application form for members to apply for roles.", + CommandVisibility.GUILD); + + this.config = config.getRoleApplicationSystemConfig(); + + generateRoleOptions(); + handler = new RoleApplicationHandler(this.config); + } + + /** + * Populates this command's instance {@link SlashCommandData} object with the proper arguments + * for this command. + */ + private void generateRoleOptions() { + final SlashCommandData data = getData(); + + IntStream.range(1, OPTIONAL_ROLES_AMOUNT + 1).forEach(index -> { + data.addOption(OptionType.STRING, generateOptionId("title", index), + "The title of the role"); + data.addOption(OptionType.STRING, generateOptionId("description", index), + "The description of the role"); + data.addOption(OptionType.STRING, generateOptionId("emoji", index), + "The emoji of the role"); + }); + } + + private static String generateOptionId(String name, int id) { + return "%s%s%d".formatted(name, OPTION_PARAM_ID_DELIMITER, id); + } + + @Override + public void onSlashCommand(SlashCommandInteractionEvent event) { + if (!handleHasPermissions(event)) { + return; + } + + final List optionMappings = event.getInteraction().getOptions(); + if (optionMappings.isEmpty()) { + event.reply("You have to select at least one role.").setEphemeral(true).queue(); + return; + } + + long incorrectArgsCount = getIncorrectRoleArgsCount(optionMappings); + if (incorrectArgsCount > 0) { + event.reply("Missing information for %d roles.".formatted(incorrectArgsCount)) + .setEphemeral(true) + .queue(); + return; + } + + sendMenu(event); + } + + @Override + public void onStringSelectSelection(StringSelectInteractionEvent event, List args) { + SelectOption selectOption = event.getSelectedOptions().getFirst(); + Member member = event.getMember(); + + if (member == null) { + logger.error("Member was null during onStringSelectSelection()"); + return; + } + + if (selectOption == null) { + logger.error("selectOption was null during onStringSelectSelection()"); + return; + } + + long remainingMinutes = handler.getMemberCooldownMinutes(member); + if (remainingMinutes > 0) { + String correctMinutesWord = remainingMinutes == 1 ? "minute" : "minutes"; + + event + .reply("Please wait %d %s before sending a new application form." + .formatted(remainingMinutes, correctMinutesWord)) + .setEphemeral(true) + .queue(); + return; + } + + TextInput body = TextInput + .create(generateComponentId(event.getUser().getId()), config.defaultQuestion(), + TextInputStyle.PARAGRAPH) + .setRequired(true) + .setRequiredRange(MINIMUM_ANSWER_LENGTH, MAXIMUM_ANSWER_LENGTH) + .setPlaceholder("Enter your answer here") + .build(); + + EmojiUnion emoji = selectOption.getEmoji(); + String roleDisplayName; + + if (emoji == null) { + roleDisplayName = selectOption.getLabel(); + } else { + roleDisplayName = "%s %s".formatted(emoji.getFormatted(), selectOption.getLabel()); + } + + Modal modal = Modal + .create(generateComponentId(event.getUser().getId(), roleDisplayName), + String.format("Application form - %s", selectOption.getLabel())) + .addActionRow(ActionRow.of(body).getComponents()) + .build(); + + event.replyModal(modal).queue(); + } + + /** + * Checks a given list of passed arguments (from a user) and calculates how many roles have + * missing data. + * + * @param args the list of passed arguments + * @return the amount of roles with missing data + */ + private static long getIncorrectRoleArgsCount(final List args) { + final Map frequencyMap = new HashMap<>(); + + args.stream() + .map(OptionMapping::getName) + .map(name -> name.split(OPTION_PARAM_ID_DELIMITER)[1]) + .forEach(number -> frequencyMap.merge(number, 1, Integer::sum)); + + return frequencyMap.values().stream().filter(value -> value != OPTIONS_PER_ROLE).count(); + } + + /** + * Populates a {@link StringSelectMenu.Builder} with application roles. + * + * @param menuBuilder the menu builder to populate + * @param args the arguments which contain data about the roles + */ + private void addRolesToMenu(StringSelectMenu.Builder menuBuilder, + final List args) { + final Map roles = new HashMap<>(); + + for (int i = 0; i < args.size(); i += OPTIONS_PER_ROLE) { + OptionMapping optionTitle = args.get(i); + OptionMapping optionDescription = args.get(i + 1); + OptionMapping optionEmoji = args.get(i + 2); + + roles.put(i, + new MenuRole(optionTitle.getAsString(), + generateComponentId(ROLE_COMPONENT_ID_HEADER, + optionTitle.getAsString()), + optionDescription.getAsString(), + Emoji.fromFormatted(optionEmoji.getAsString()))); + } + + roles.values() + .forEach(role -> menuBuilder.addOption(role.title(), role.value(), role.description(), + role.emoji())); + } + + private boolean handleHasPermissions(SlashCommandInteractionEvent event) { + Member member = event.getMember(); + Guild guild = event.getGuild(); + + if (member == null || guild == null) { + return false; + } + + if (!member.hasPermission(Permission.MANAGE_ROLES)) { + event.reply("You do not have the required manage role permission to use this command") + .setEphemeral(true) + .queue(); + return false; + } + + return true; + } + + /** + * Sends the initial embed and a button which displays role openings. + * + * @param event the command interaction event triggering the menu + */ + private void sendMenu(final CommandInteraction event) { + MessageEmbed embed = createApplicationEmbed(); + + StringSelectMenu.Builder menuBuilder = StringSelectMenu + .create(generateComponentId(Lifespan.PERMANENT, event.getUser().getId())) + .setPlaceholder("Select role to apply for") + .setRequiredRange(1, 1); + + addRolesToMenu(menuBuilder, event.getOptions()); + + event.replyEmbeds(embed).addActionRow(menuBuilder.build()).queue(); + } + + private static MessageEmbed createApplicationEmbed() { + return new EmbedBuilder().setTitle("Apply for roles") + .setDescription( + """ + We are always looking for community members that want to contribute to our community \ + and take charge. If you are interested, you can apply for various positions here! ๐Ÿ˜Ž""") + .setColor(AMBIENT_COLOR) + .build(); + } + + public RoleApplicationHandler getApplicationApplyHandler() { + return handler; + } + + @Override + public void onModalSubmitted(ModalInteractionEvent event, List args) { + getApplicationApplyHandler().submitApplicationFromModalInteraction(event, args); + } + + /** + * Wrapper class which represents a menu role for the application create command. + *

+ * The reason this exists is due to the fact that {@link StringSelectMenu.Builder} does not have + * a method which takes emojis as input as of writing this, so we have to elegantly pass in + * custom data from this POJO. + */ + private record MenuRole(String title, String value, String description, @Nullable Emoji emoji) { + + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/roleapplication/RoleApplicationHandler.java b/application/src/main/java/org/togetherjava/tjbot/features/roleapplication/RoleApplicationHandler.java new file mode 100644 index 0000000000..4e8e45bc08 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/roleapplication/RoleApplicationHandler.java @@ -0,0 +1,162 @@ +package org.togetherjava.tjbot.features.roleapplication; + +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.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; +import net.dv8tion.jda.api.interactions.modals.ModalMapping; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.config.RoleApplicationSystemConfig; + +import java.time.Duration; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +/** + * Handles the actual process of submitting role applications. + *

+ * This class is responsible for managing application submissions via modal interactions, ensuring + * that submissions are sent to the appropriate application channel, and enforcing cooldowns for + * users to prevent spamming. + */ +public final class RoleApplicationHandler { + private static final Logger logger = LoggerFactory.getLogger(RoleApplicationHandler.class); + + private static final int APPLICATION_SUBMIT_COOLDOWN_MINUTES = 5; + private final Cache applicationSubmitCooldown; + private final Predicate applicationChannelPattern; + private final RoleApplicationSystemConfig roleApplicationSystemConfig; + + /** + * Constructs a new {@code RoleApplicationHandler} instance. + * + * @param roleApplicationSystemConfig the configuration that contains the details for the + * application form including the cooldown duration and channel pattern. + */ + public RoleApplicationHandler(RoleApplicationSystemConfig roleApplicationSystemConfig) { + this.roleApplicationSystemConfig = roleApplicationSystemConfig; + this.applicationChannelPattern = + Pattern.compile(roleApplicationSystemConfig.submissionsChannelPattern()) + .asMatchPredicate(); + + final Duration applicationSubmitCooldownDuration = + Duration.ofMinutes(APPLICATION_SUBMIT_COOLDOWN_MINUTES); + applicationSubmitCooldown = + Caffeine.newBuilder().expireAfterWrite(applicationSubmitCooldownDuration).build(); + } + + /** + * Sends the result of an application submission to the designated application channel in the + * guild. + *

+ * The {@code args} parameter should contain the applicant's name and the role they are applying + * for. + * + * @param event the modal interaction event triggering the application submission + * @param args the arguments provided in the application submission + * @param answer the answer provided by the applicant to the default question + */ + private void sendApplicationResult(final ModalInteractionEvent event, List args, + String answer) throws IllegalArgumentException { + Guild guild = event.getGuild(); + + if (guild == null) { + throw new IllegalArgumentException( + "sendApplicationResult() got fired in a non-guild environment."); + } + + if (args.size() != 2) { + throw new IllegalArgumentException( + "Received application result after user submitted one, and did not receive 2 arguments. Args: " + + args); + } + + String roleString = args.get(1); + + Optional applicationChannel = getApplicationChannel(guild); + if (applicationChannel.isEmpty()) { + throw new IllegalArgumentException("Application channel %s could not be found." + .formatted(roleApplicationSystemConfig.submissionsChannelPattern())); + } + + User applicant = event.getUser(); + EmbedBuilder embed = + new EmbedBuilder().setAuthor(applicant.getName(), null, applicant.getAvatarUrl()) + .setColor(CreateRoleApplicationCommand.AMBIENT_COLOR) + .setFooter("Submitted at") + .setTimestamp(Instant.now()); + + MessageEmbed.Field roleField = new MessageEmbed.Field("Role", roleString, false); + embed.addField(roleField); + + MessageEmbed.Field answerField = new MessageEmbed.Field( + roleApplicationSystemConfig.defaultQuestion(), answer, false); + embed.addField(answerField); + + applicationChannel.get().sendMessageEmbeds(embed.build()).queue(); + } + + /** + * Retrieves the application channel from the given {@link Guild}. + * + * @param guild the guild from which to retrieve the application channel + * @return an {@link Optional} containing the {@link TextChannel} representing the application + * channel, or an empty {@link Optional} if no such channel is found + */ + private Optional getApplicationChannel(Guild guild) { + return guild.getChannels() + .stream() + .filter(channel -> applicationChannelPattern.test(channel.getName())) + .filter(channel -> channel.getType().isMessage()) + .map(TextChannel.class::cast) + .findFirst(); + } + + public Cache getApplicationSubmitCooldown() { + return applicationSubmitCooldown; + } + + void submitApplicationFromModalInteraction(ModalInteractionEvent event, List args) { + Guild guild = event.getGuild(); + + if (guild == null) { + return; + } + + ModalMapping modalAnswer = event.getValues().getFirst(); + + try { + sendApplicationResult(event, args, modalAnswer.getAsString()); + event.reply("Your application has been submitted. Thank you for applying! ๐Ÿ˜Ž") + .setEphemeral(true) + .queue(); + } catch (IllegalArgumentException e) { + logger.error("A role application could not be submitted. ", e); + event.reply("Your application could not be submitted. Please contact the staff team.") + .setEphemeral(true) + .queue(); + } + + applicationSubmitCooldown.put(event.getMember(), OffsetDateTime.now()); + } + + long getMemberCooldownMinutes(Member member) { + OffsetDateTime timeSentCache = getApplicationSubmitCooldown().getIfPresent(member); + if (timeSentCache != null) { + Duration duration = Duration.between(timeSentCache, OffsetDateTime.now()); + return APPLICATION_SUBMIT_COOLDOWN_MINUTES - duration.toMinutes(); + } + return 0L; + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/roleapplication/package-info.java b/application/src/main/java/org/togetherjava/tjbot/features/roleapplication/package-info.java new file mode 100644 index 0000000000..cc527ac009 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/roleapplication/package-info.java @@ -0,0 +1,12 @@ +/** + * This packages offers all the functionality for the application-create command as well as the + * application system. The core class is + * {@link org.togetherjava.tjbot.features.roleapplication.CreateRoleApplicationCommand}. + */ +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package org.togetherjava.tjbot.features.roleapplication; + +import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; From b5f2c14f977cf7e7fe4124a5aee9cd139dfe71d6 Mon Sep 17 00:00:00 2001 From: Chris Sdogkos Date: Sun, 24 May 2026 00:19:57 +0400 Subject: [PATCH 20/23] Add member id at the end of the role application embed (#1486) Signed-off-by: Chris Sdogkos --- .../tjbot/features/roleapplication/RoleApplicationHandler.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/roleapplication/RoleApplicationHandler.java b/application/src/main/java/org/togetherjava/tjbot/features/roleapplication/RoleApplicationHandler.java index 4e8e45bc08..f01f3f043f 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/roleapplication/RoleApplicationHandler.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/roleapplication/RoleApplicationHandler.java @@ -91,8 +91,9 @@ private void sendApplicationResult(final ModalInteractionEvent event, List Date: Sun, 24 May 2026 00:43:06 +0400 Subject: [PATCH 21/23] Quotes board: Allow reactions with any emoji (#1389) * quotes-board: allow reactions with any emoji Recently from the server suggestions, many people have been slightly upset that the only reaction emoji considered for adding to the quotes board is the default star emoji. Some members have suggested specific additional emojis to be considered, others suggested that the star emoji should have a weight of 1.0 while the rest of the emojis should have a weight of 0.5. While both solutions can work, all emojis can have a custom weight for the purpose of customizability. Introduce a scoring concept for each emoji, configurable for each particular one, and provide the ability to set a default value if an emoji is not defined in the configuration file. For those who are wondering, _any_ kind of emoji that Discord can handle is able to be added in the configuration, including custom emojis in the server. JDA can keep track of those. For adding a unicode emoji, the actual unicode value has to be provided, like it has been done in "config.json.template". For adding a guild emoji, a "code" for the emoji has to be provided, for instance: youtube:1464573182206804010 Which stands for the friendly name of the emoji, a colon right after, and finally the ID of the custom emoji. The "config.json.template" is _NOT_ exhaustive, more emojis have to be added and some others removed according to preference. Signed-off-by: Chris Sdogkos * doc(QuoteBoardConfig.java): update to reflect new configuration --------- Signed-off-by: Chris Sdogkos --- application/config.json.template | 29 +++++++++- .../tjbot/config/QuoteBoardConfig.java | 26 +++++---- .../features/basic/QuoteBoardForwarder.java | 57 +++++++++++-------- 3 files changed, 75 insertions(+), 37 deletions(-) diff --git a/application/config.json.template b/application/config.json.template index 105db688da..67c397f023 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -195,9 +195,34 @@ "pollIntervalInMinutes": 10 }, "quoteBoardConfig": { - "minimumReactionsToTrigger": 5, + "minimumScoreToTrigger": 5.0, "channel": "quotes", - "reactionEmoji": "โญ" + "botEmoji": "โญ", + "defaultEmojiScore": 0.5, + "emojiScores": { + "๐Ÿ˜ฌ": -0.5, + "๐Ÿ’”": -0.5, + "๐Ÿ˜": -0.5, + "๐Ÿ˜Š": -0.5, + "๐Ÿ–•": -0.5, + "๐Ÿ‘Ž": -0.5, + "๐Ÿ’ฉ": -0.5, + "๐Ÿคข": -0.5, + "๐Ÿคฎ": -0.5, + "๐Ÿคฌ": -0.5, + "๐Ÿ˜ก": -0.5, + "๐Ÿ˜’": -0.5, + "๐Ÿคจ": -0.5, + + "๐Ÿ‡ท๐Ÿ‡บ": 0.0, + "๐Ÿ‡ต๐Ÿ‡ธ": 0.0, + "๐Ÿ‡ฎ๐Ÿ‡ฑ": 0.0, + "๐Ÿณ๏ธโ€๐ŸŒˆ": 0.0, + + "โญ": 1.0, + + "youtube:1464573182206804010": 0.0 + } }, "roleApplicationSystem": { "submissionsChannelPattern": "staff-applications", diff --git a/application/src/main/java/org/togetherjava/tjbot/config/QuoteBoardConfig.java b/application/src/main/java/org/togetherjava/tjbot/config/QuoteBoardConfig.java index faf756b4a8..767af2c16d 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/QuoteBoardConfig.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/QuoteBoardConfig.java @@ -6,6 +6,7 @@ import org.togetherjava.tjbot.features.basic.QuoteBoardForwarder; +import java.util.Map; import java.util.Objects; /** @@ -13,31 +14,36 @@ */ @JsonRootName("quoteBoardConfig") public record QuoteBoardConfig( - @JsonProperty(value = "minimumReactionsToTrigger", required = true) int minimumReactions, - @JsonProperty(required = true) String channel, - @JsonProperty(value = "reactionEmoji", required = true) String reactionEmoji) { + @JsonProperty(value = "minimumScoreToTrigger", required = true) float minimumScoreToTrigger, + @JsonProperty(value = "channel", required = true) String channel, + @JsonProperty(value = "botEmoji", required = true) String botEmoji, + @JsonProperty(value = "defaultEmojiScore", required = true) float defaultEmojiScore, + @JsonProperty(value = "emojiScores", required = true) Map emojiScores) { /** * Creates a QuoteBoardConfig. * - * @param minimumReactions the minimum amount of reactions + * @param minimumScoreToTrigger the minimum amount of reaction score for a message to be quoted * @param channel the pattern for the board channel - * @param reactionEmoji the emoji with which users should react to + * @param botEmoji the emoji with which the bot will mark quoted messages + * @param defaultEmojiScore the default score of an emoji if it's not in the emojiScores map + * @param emojiScores a map of each emoji's custom score */ public QuoteBoardConfig { - if (minimumReactions <= 0) { - throw new IllegalArgumentException("minimumReactions must be greater than zero"); + if (minimumScoreToTrigger <= 0) { + throw new IllegalArgumentException("minimumScoreToTrigger 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()) { + Objects.requireNonNull(botEmoji); + if (botEmoji.isBlank()) { throw new IllegalArgumentException("reactionEmoji must not be empty or blank"); } + Objects.requireNonNull(emojiScores); LogManager.getLogger(QuoteBoardConfig.class) .debug("Quote-Board configs loaded: minimumReactions={}, channel='{}', reactionEmoji='{}'", - minimumReactions, channel, reactionEmoji); + minimumScoreToTrigger, channel, botEmoji); } } 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 index a6d067e16e..8121ccf037 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/basic/QuoteBoardForwarder.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/basic/QuoteBoardForwarder.java @@ -6,6 +6,7 @@ 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.entities.emoji.EmojiUnion; import net.dv8tion.jda.api.events.message.react.MessageReactionAddEvent; import net.dv8tion.jda.api.requests.RestAction; import org.slf4j.Logger; @@ -36,7 +37,7 @@ public final class QuoteBoardForwarder extends MessageReceiverAdapter { private static final Logger logger = LoggerFactory.getLogger(QuoteBoardForwarder.class); - private final Emoji triggerReaction; + private final Emoji botEmoji; private final Predicate isQuoteBoardChannelName; private final QuoteBoardConfig config; @@ -48,7 +49,7 @@ public final class QuoteBoardForwarder extends MessageReceiverAdapter { */ public QuoteBoardForwarder(Config config) { this.config = config.getQuoteBoardConfig(); - this.triggerReaction = Emoji.fromUnicode(this.config.reactionEmoji()); + this.botEmoji = Emoji.fromUnicode(this.config.botEmoji()); this.isQuoteBoardChannelName = Pattern.compile(this.config.channel()).asMatchPredicate(); } @@ -60,24 +61,11 @@ public void onMessageReactionAdd(MessageReactionAddEvent event) { 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 boardChannelOptional = findQuoteBoardChannel(event.getJDA(), guildId); @@ -96,21 +84,27 @@ public void onMessageReactionAdd(MessageReactionAddEvent event) { return; } - logger.debug("Forwarding message to quote board channel: {}", boardChannel.getName()); + event.retrieveMessage().queue(message -> { + float emojiScore = calculateMessageScore(message.getReactions()); - event.retrieveMessage() - .queue(message -> markAsProcessed(message).flatMap(v -> message.forwardTo(boardChannel)) - .queue(_ -> logger.debug("Message forwarded to quote board channel: {}", - boardChannel.getName())), + if (emojiScore < config.minimumScoreToTrigger()) { + return; + } - e -> logger.warn( - "Unknown error while attempting to retrieve and forward message for quote-board, message is ignored.", - e)); + logger.debug("Attempting to forward message to quote board channel: {}", + boardChannel.getName()); + markAsProcessed(message).flatMap(_ -> message.forwardTo(boardChannel)) + .queue(_ -> logger.debug("Message forwarded to quote board channel: {}", + boardChannel.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); + return message.addReaction(botEmoji); } /** @@ -146,7 +140,7 @@ private Optional findQuoteBoardChannel(JDA jda, long guildId) { * 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())) { + if (!botEmoji.equals(messageReaction.getEmoji())) { return false; } @@ -154,4 +148,17 @@ private boolean hasAlreadyForwardedMessage(JDA jda, MessageReaction messageReact .parallelStream() .anyMatch(user -> jda.getSelfUser().getIdLong() == user.getIdLong()); } + + private float calculateMessageScore(List reactions) { + return (float) reactions.stream() + .mapToDouble(reaction -> reaction.getCount() * getEmojiScore(reaction.getEmoji())) + .sum(); + } + + private float getEmojiScore(EmojiUnion emoji) { + float defaultScore = config.defaultEmojiScore(); + String reactionCode = emoji.getAsReactionCode(); + + return config.emojiScores().getOrDefault(reactionCode, defaultScore); + } } From b95ec256a4e8bdd01e480546837518289cfe6f1c Mon Sep 17 00:00:00 2001 From: Suraj Kumar Date: Sun, 24 May 2026 10:04:07 +0100 Subject: [PATCH 22/23] feat: purge command (#1484) --- .../togetherjava/tjbot/features/Features.java | 4 + .../tjbot/features/purge/PurgeCommand.java | 187 ++++++++++++ .../tjbot/features/purge/PurgeHelper.java | 101 +++++++ .../purge/PurgeMessagesByUserCommand.java | 271 ++++++++++++++++++ .../tjbot/features/purge/PurgeResult.java | 16 ++ .../tjbot/features/purge/package-info.java | 7 + 6 files changed, 586 insertions(+) create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/purge/PurgeCommand.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/purge/PurgeHelper.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/purge/PurgeMessagesByUserCommand.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/purge/PurgeResult.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/purge/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 4ca201fb9d..4f04f74995 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -67,6 +67,8 @@ import org.togetherjava.tjbot.features.moderation.scam.ScamHistoryStore; import org.togetherjava.tjbot.features.moderation.temp.TemporaryModerationRoutine; import org.togetherjava.tjbot.features.projects.ProjectsThreadCreatedListener; +import org.togetherjava.tjbot.features.purge.PurgeCommand; +import org.togetherjava.tjbot.features.purge.PurgeMessagesByUserCommand; import org.togetherjava.tjbot.features.reminder.RemindRoutine; import org.togetherjava.tjbot.features.reminder.ReminderCommand; import org.togetherjava.tjbot.features.roleapplication.CreateRoleApplicationCommand; @@ -221,6 +223,8 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new MessageCommand()); features.add(new RewriteCommand(chatGptService)); features.add(new CreateRoleApplicationCommand(config)); + features.add(new PurgeCommand(modAuditLogWriter)); + features.add(new PurgeMessagesByUserCommand(modAuditLogWriter)); FeatureBlacklist> blacklist = blacklistConfig.normal(); return blacklist.filterStream(features.stream(), Object::getClass).toList(); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/purge/PurgeCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/purge/PurgeCommand.java new file mode 100644 index 0000000000..66398f8e1a --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/purge/PurgeCommand.java @@ -0,0 +1,187 @@ +package org.togetherjava.tjbot.features.purge; + +import net.dv8tion.jda.api.entities.channel.ChannelType; +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.utils.TimeUtil; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.features.CommandVisibility; +import org.togetherjava.tjbot.features.SlashCommandAdapter; +import org.togetherjava.tjbot.features.moderation.audit.ModAuditLogWriter; + +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +/** + * Slash command that bulk-deletes messages in a text channel posted after a given anchor message. + *

+ * The anchor message itself is preserved (deletion starts exclusively from messages newer than it). + * An optional {@code amount} option caps the number of messages deleted; when omitted, the command + * keeps deleting until no newer messages remain. + *

+ * Because this command is destructive, it presents an ephemeral confirmation dialog before any + * deletion runs, and rejects anchors older than {@link #MAX_ANCHOR_AGE} to avoid accidentally + * purging large swathes of channel history. Completed purges are written to the moderation audit + * log. + */ +public class PurgeCommand extends SlashCommandAdapter { + private static final Logger logger = LoggerFactory.getLogger(PurgeCommand.class); + private static final String CHANNEL_OPTION = "channel"; + private static final String MESSAGE_OPTION = "message-id"; + private static final String MINUTES_OPTION = "minutes"; + private static final String AMOUNT_OPTION = "amount"; + private static final Duration MAX_ANCHOR_AGE = Duration.ofDays(2); + + private final ModAuditLogWriter modAuditLogWriter; + + /** + * Constructs the command and registers its options ({@code channel}, optional + * {@code message-id}, optional {@code minutes}, optional {@code amount}). + * + * @param modAuditLogWriter used to record completed purges for moderator review + */ + public PurgeCommand(ModAuditLogWriter modAuditLogWriter) { + super("purge", "Deletes all messages in a channel after the given message id", + CommandVisibility.GUILD); + + this.modAuditLogWriter = modAuditLogWriter; + + getData() + .addOptions( + new OptionData(OptionType.CHANNEL, CHANNEL_OPTION, "The channel to purge", true) + .setChannelTypes(ChannelType.TEXT)) + .addOptions(new OptionData(OptionType.STRING, MESSAGE_OPTION, + "The message id to start purging from (exclusive)", false)) + .addOptions(new OptionData(OptionType.INTEGER, MINUTES_OPTION, + "Purge messages sent in the last N minutes (alternative to message-id)", false) + .setMinValue(1) + .setMaxValue(MAX_ANCHOR_AGE.toMinutes())) + .addOptions(new OptionData(OptionType.INTEGER, AMOUNT_OPTION, + "The amount of messages to delete (default: all)", false) + .setMinValue(1)); + } + + @Override + public void onSlashCommand(SlashCommandInteractionEvent event) { + TextChannel channel = Objects.requireNonNull(event.getOption(CHANNEL_OPTION)) + .getAsChannel() + .asTextChannel(); + + OptionMapping messageOption = event.getOption(MESSAGE_OPTION); + OptionMapping minutesOption = event.getOption(MINUTES_OPTION); + + if ((messageOption == null) == (minutesOption == null)) { + event.reply("Provide exactly one of `message-id` or `minutes`.") + .setEphemeral(true) + .queue(); + return; + } + + ResolvedAnchor anchor = messageOption != null ? resolveMessageAnchor(event, messageOption) + : resolveMinutesAnchor(minutesOption); + + OptionMapping amountOption = event.getOption(AMOUNT_OPTION); + int amount = amountOption == null ? Integer.MAX_VALUE : amountOption.getAsInt(); + String amountLabel = amount == Integer.MAX_VALUE ? "all" : Integer.toString(amount); + + String description = "About to delete up to **%s** messages from %s, %s.".formatted( + amountLabel, channel.getAsMention(), Objects.requireNonNull(anchor).description()); + + String confirmId = generateComponentId(PurgeHelper.CONFIRM_ACTION, channel.getId(), + anchor.snowflake(), Integer.toString(amount)); + String cancelId = generateComponentId(PurgeHelper.CANCEL_ACTION); + + PurgeHelper.sendConfirmationDialog(event, "Confirm purge", description, confirmId, + cancelId); + } + + private @Nullable ResolvedAnchor resolveMessageAnchor(SlashCommandInteractionEvent event, + OptionMapping messageOption) { + String messageId = messageOption.getAsString(); + long anchorIdLong; + try { + anchorIdLong = Long.parseLong(messageId); + } catch (NumberFormatException _) { + event.reply("The provided message id is not a valid snowflake.") + .setEphemeral(true) + .queue(); + return null; + } + + Instant anchorCreatedAt = TimeUtil.getTimeCreated(anchorIdLong).toInstant(); + Duration anchorAge = Duration.between(anchorCreatedAt, Instant.now()); + if (anchorAge.compareTo(MAX_ANCHOR_AGE) > 0) { + event.reply( + "Refusing to purge: anchor message is older than %d days. Pick a more recent anchor." + .formatted(MAX_ANCHOR_AGE.toDays())) + .setEphemeral(true) + .queue(); + return null; + } + + return new ResolvedAnchor(messageId, "starting after message `%s` (sent )" + .formatted(messageId, anchorCreatedAt.getEpochSecond())); + } + + private ResolvedAnchor resolveMinutesAnchor(OptionMapping minutesOption) { + int minutes = minutesOption.getAsInt(); + Instant anchorCreatedAt = Instant.now().minus(Duration.ofMinutes(minutes)); + String snowflake = + Long.toUnsignedString(TimeUtil.getDiscordTimestamp(anchorCreatedAt.toEpochMilli())); + return new ResolvedAnchor(snowflake, + "sent in the last **%d** minute%s".formatted(minutes, minutes == 1 ? "" : "s")); + } + + private record ResolvedAnchor(String snowflake, String description) { + } + + @Override + public void onButtonClick(ButtonInteractionEvent event, List args) { + String action = args.getFirst(); + + if (PurgeHelper.CANCEL_ACTION.equals(action)) { + PurgeHelper.handleCancel(event); + return; + } + + if (!PurgeHelper.CONFIRM_ACTION.equals(action)) { + return; + } + + String channelId = args.get(1); + String messageId = args.get(2); + int amount = Integer.parseInt(args.get(3)); + + TextChannel channel = + Objects.requireNonNull(event.getGuild()).getTextChannelById(channelId); + if (channel == null) { + event.editMessage("That channel no longer exists.").setEmbeds().setComponents().queue(); + return; + } + + event.editMessage("Purging... this may take a while.").setEmbeds().setComponents().queue(); + + logger.info("Purge initiated by {} in channel {} starting from messageId {} (amount: {})", + event.getUser().getId(), channel.getName(), messageId, amount); + + PurgeHelper.purgeChannelMessages(channel, messageId, amount, 0, _ -> true, total -> { + event.getHook() + .editOriginal("Purge complete: deleted %d messages from %s.".formatted(total, + channel.getAsMention())) + .queue(); + + modAuditLogWriter.write("/purge", + "Deleted %d messages from %s".formatted(total, channel.getAsMention()), + event.getUser(), Instant.now(), Objects.requireNonNull(event.getGuild())); + }); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/purge/PurgeHelper.java b/application/src/main/java/org/togetherjava/tjbot/features/purge/PurgeHelper.java new file mode 100644 index 0000000000..5399ccfd70 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/purge/PurgeHelper.java @@ -0,0 +1,101 @@ +package org.togetherjava.tjbot.features.purge; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.channel.middleman.MessageChannel; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; +import net.dv8tion.jda.api.interactions.components.buttons.Button; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.awt.Color; +import java.util.List; +import java.util.function.Predicate; + +/** + * Shared helpers used by the {@link PurgeCommand} family. Centralises the confirmation dialog and + * the recursive history-pagination + bulk-delete loop so each variant only owns its own input + * handling. + */ +final class PurgeHelper { + private static final Logger logger = LoggerFactory.getLogger(PurgeHelper.class); + + static final int BATCH_SIZE = 100; + static final String CONFIRM_ACTION = "confirm"; + static final String CANCEL_ACTION = "cancel"; + + private PurgeHelper() {} + + /** + * Replies to {@code event} with an ephemeral confirmation embed and a Confirm (danger) / Cancel + * (secondary) button row. + */ + static void sendConfirmationDialog(IReplyCallback event, String title, String description, + String confirmComponentId, String cancelComponentId) { + EmbedBuilder embed = + new EmbedBuilder().setTitle(title).setDescription(description).setColor(Color.RED); + + event.replyEmbeds(embed.build()) + .setEphemeral(true) + .addActionRow(Button.danger(confirmComponentId, "Confirm purge"), + Button.secondary(cancelComponentId, "Cancel")) + .queue(); + } + + /** + * Edits the originating message to indicate the purge was cancelled and removes the buttons. + */ + static void handleCancel(ButtonInteractionEvent event) { + event.editMessage("Purge cancelled.").setEmbeds().setComponents().queue(); + } + + /** + * Recursively deletes messages newer than {@code messageId} in {@code channel} that satisfy + * {@code filter}, up to {@code remaining} matches. + *

+ * Each call fetches a full {@link #BATCH_SIZE} batch via + * {@link MessageChannel#getHistoryAfter(String, int)}, filters it, deletes the matches with + * {@link MessageChannel#purgeMessages(List)}, then recurses using the newest message of the + * fetched batch as the next anchor. {@code onComplete} fires exactly once with the cumulative + * count of matches submitted for deletion. Fetch failures are logged and treated as the end of + * the channel. + * + * @param channel the channel to scan and purge + * @param messageId snowflake id of the anchor (exclusive lower bound) + * @param remaining maximum further matches that may still be deleted + * @param totalDeleted matches already deleted by prior recursive calls in this chain + * @param filter predicate selecting which fetched messages to delete + * @param onComplete callback invoked with the final cumulative count for this channel + */ + static void purgeChannelMessages(MessageChannel channel, String messageId, int remaining, + int totalDeleted, Predicate filter, PurgeResult onComplete) { + channel.getHistoryAfter(messageId, BATCH_SIZE).queue(history -> { + List fetched = history.getRetrievedHistory(); + if (fetched.isEmpty()) { + onComplete.run(totalDeleted); + return; + } + + List matches = fetched.stream().filter(filter).limit(remaining).toList(); + + if (!matches.isEmpty()) { + channel.purgeMessages(matches); + } + + int newTotal = totalDeleted + matches.size(); + int newRemaining = remaining - matches.size(); + + if (fetched.size() == BATCH_SIZE && newRemaining > 0) { + purgeChannelMessages(channel, fetched.getFirst().getId(), newRemaining, newTotal, + filter, onComplete); + } else { + onComplete.run(newTotal); + } + }, failure -> { + logger.warn("Failed to fetch history in channel {}: {}", channel.getName(), + failure.getMessage()); + onComplete.run(totalDeleted); + }); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/purge/PurgeMessagesByUserCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/purge/PurgeMessagesByUserCommand.java new file mode 100644 index 0000000000..6301423e0d --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/purge/PurgeMessagesByUserCommand.java @@ -0,0 +1,271 @@ +package org.togetherjava.tjbot.features.purge; + +import net.dv8tion.jda.api.Permission; +import net.dv8tion.jda.api.entities.Guild; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.entities.channel.middleman.GuildMessageChannel; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import net.dv8tion.jda.api.utils.TimeUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.features.CommandVisibility; +import org.togetherjava.tjbot.features.SlashCommandAdapter; +import org.togetherjava.tjbot.features.moderation.audit.ModAuditLogWriter; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Slash command that deletes messages authored by a given user across every channel they have view + * access to within a recent time window. + *

+ * Because Discord exposes no "messages by user" endpoint, this command iterates each candidate + * channel and paginates its recent history, filtering by author. It is therefore noticeably slower + * than the single-channel {@link PurgeCommand} and intentionally caps its lookback at + * {@link #MAX_WINDOW}. + *

+ * Like {@link PurgeCommand} it shows a confirmation dialog before running and records completed + * runs in the moderation audit log. Active threads are scanned; archived threads are skipped. + */ +public class PurgeMessagesByUserCommand extends SlashCommandAdapter { + private static final Logger logger = LoggerFactory.getLogger(PurgeMessagesByUserCommand.class); + + private static final String USER_OPTION = "user"; + private static final String MINUTES_OPTION = "minutes"; + private static final String AMOUNT_OPTION = "amount"; + + private static final Duration MAX_WINDOW = Duration.ofHours(6); + private static final int PROGRESS_EDIT_EVERY_N_CHANNELS = 10; + + private final ModAuditLogWriter modAuditLogWriter; + + /** + * Constructs the command and registers its options ({@code user}, {@code minutes}, optional + * {@code amount}). + * + * @param modAuditLogWriter used to record completed purges for moderator review + */ + public PurgeMessagesByUserCommand(ModAuditLogWriter modAuditLogWriter) { + super("purge-messages-by-user", + "Deletes a user's recent messages across all channels they can access", + CommandVisibility.GUILD); + + this.modAuditLogWriter = modAuditLogWriter; + + getData() + .addOptions(new OptionData(OptionType.USER, USER_OPTION, + "The user whose messages to purge", true)) + .addOptions(new OptionData(OptionType.INTEGER, MINUTES_OPTION, + "Purge messages sent in the last N minutes", true) + .setMinValue(1) + .setMaxValue(MAX_WINDOW.toMinutes())) + .addOptions(new OptionData(OptionType.INTEGER, AMOUNT_OPTION, + "Global cap on the number of messages to delete (default: all)", false) + .setMinValue(1)); + } + + @Override + public void onSlashCommand(SlashCommandInteractionEvent event) { + Guild guild = Objects.requireNonNull(event.getGuild()); + User targetUser = Objects.requireNonNull(event.getOption(USER_OPTION)).getAsUser(); + int minutes = Objects.requireNonNull(event.getOption(MINUTES_OPTION)).getAsInt(); + OptionMapping amountOption = event.getOption(AMOUNT_OPTION); + int amount = amountOption == null ? Integer.MAX_VALUE : amountOption.getAsInt(); + + Member targetMember = guild.getMember(targetUser); + if (targetMember == null) { + event.reply("That user is not a member of this guild.").setEphemeral(true).queue(); + return; + } + + List candidates = collectCandidateChannels(guild, targetMember); + if (candidates.isEmpty()) { + event + .reply("Found no channels where %s has access and the bot can manage messages." + .formatted(targetUser.getAsMention())) + .setEphemeral(true) + .queue(); + return; + } + + Instant anchorCreatedAt = Instant.now().minus(Duration.ofMinutes(minutes)); + String anchorSnowflake = + Long.toUnsignedString(TimeUtil.getDiscordTimestamp(anchorCreatedAt.toEpochMilli())); + + String amountLabel = amount == Integer.MAX_VALUE ? "all" : Integer.toString(amount); + String description = + "About to delete up to **%s** messages by %s sent in the last **%d** minute%s, across **%d** channel%s. This may take several minutes." + .formatted(amountLabel, targetUser.getAsMention(), minutes, + minutes == 1 ? "" : "s", candidates.size(), + candidates.size() == 1 ? "" : "s"); + + String confirmId = generateComponentId(PurgeHelper.CONFIRM_ACTION, targetUser.getId(), + anchorSnowflake, Integer.toString(amount)); + String cancelId = generateComponentId(PurgeHelper.CANCEL_ACTION); + + PurgeHelper.sendConfirmationDialog(event, "Confirm user purge", description, confirmId, + cancelId); + } + + @Override + public void onButtonClick(ButtonInteractionEvent event, List args) { + String action = args.getFirst(); + + if (PurgeHelper.CANCEL_ACTION.equals(action)) { + PurgeHelper.handleCancel(event); + return; + } + + if (!PurgeHelper.CONFIRM_ACTION.equals(action)) { + return; + } + + long targetUserId = Long.parseLong(args.get(1)); + String anchorSnowflake = args.get(2); + int amount = Integer.parseInt(args.get(3)); + + Guild guild = Objects.requireNonNull(event.getGuild()); + Member targetMember = guild.getMemberById(targetUserId); + if (targetMember == null) { + event.editMessage("That user is no longer a member of this guild.") + .setEmbeds() + .setComponents() + .queue(); + return; + } + + List candidates = collectCandidateChannels(guild, targetMember); + if (candidates.isEmpty()) { + event.editMessage("No accessible channels remain to scan.") + .setEmbeds() + .setComponents() + .queue(); + return; + } + + event + .editMessage("Purging across %d channels... this may take a while." + .formatted(candidates.size())) + .setEmbeds() + .setComponents() + .queue(); + + logger.info( + "User-purge initiated by {} targeting user {} ({} channels, amount: {}, anchor: {})", + event.getUser().getId(), targetUserId, candidates.size(), amount, anchorSnowflake); + + Map perChannel = new LinkedHashMap<>(); + PurgeContext ctx = + new PurgeContext(targetUserId, anchorSnowflake, perChannel, event, totalDeleted -> { + User targetUser = targetMember.getUser(); + event.getHook() + .editOriginal( + "Purge complete: deleted %d message%s from %s across %d channel%s." + .formatted(totalDeleted, totalDeleted == 1 ? "" : "s", + targetUser.getAsMention(), perChannel.size(), + perChannel.size() == 1 ? "" : "s")) + .queue(); + + modAuditLogWriter.write("/purge-messages-by-user", + buildAuditDescription(targetUser, totalDeleted, perChannel), + event.getUser(), Instant.now(), guild); + }); + + purgeAcrossChannels(ctx, candidates.iterator(), amount, 0, 0); + } + + private record PurgeContext(long targetUserId, String anchorSnowflake, + Map perChannel, ButtonInteractionEvent event, PurgeResult onComplete) { + } + + private List collectCandidateChannels(Guild guild, Member target) { + Member bot = guild.getSelfMember(); + List result = new ArrayList<>(); + + guild.getTextChannelCache().forEach(channel -> { + if (target.hasAccess(channel) + && bot.hasPermission(channel, Permission.MESSAGE_MANAGE)) { + result.add(channel); + } + }); + guild.getNewsChannelCache().forEach(channel -> { + if (target.hasAccess(channel) + && bot.hasPermission(channel, Permission.MESSAGE_MANAGE)) { + result.add(channel); + } + }); + guild.getThreadChannelCache().forEach(thread -> { + if (thread.isArchived()) { + return; + } + if (target.hasAccess(thread) && bot.hasPermission(thread, Permission.MESSAGE_MANAGE)) { + result.add(thread); + } + }); + + return result; + } + + private void purgeAcrossChannels(PurgeContext ctx, Iterator channels, + int remaining, int totalDeleted, int channelsProcessed) { + if (!channels.hasNext() || remaining <= 0) { + ctx.onComplete().run(totalDeleted); + return; + } + + GuildMessageChannel channel = channels.next(); + long targetUserId = ctx.targetUserId(); + + PurgeHelper.purgeChannelMessages(channel, ctx.anchorSnowflake(), remaining, 0, + m -> m.getAuthor().getIdLong() == targetUserId, channelDeleted -> { + if (channelDeleted > 0) { + ctx.perChannel().put(channel.getName(), channelDeleted); + } + + int newTotal = totalDeleted + channelDeleted; + int newRemaining = remaining - channelDeleted; + int newProcessed = channelsProcessed + 1; + + if (newProcessed % PROGRESS_EDIT_EVERY_N_CHANNELS == 0) { + ctx.event() + .getHook() + .editOriginal( + "Purging... %d messages deleted so far.".formatted(newTotal)) + .queue(); + } + + purgeAcrossChannels(ctx, channels, newRemaining, newTotal, newProcessed); + }); + } + + private static String buildAuditDescription(User target, int total, + Map perChannel) { + StringBuilder sb = new StringBuilder(); + sb.append("Deleted ") + .append(total) + .append(" messages by ") + .append(target.getAsMention()) + .append(" (`") + .append(target.getId()) + .append("`)."); + + if (!perChannel.isEmpty()) { + sb.append("\nBy channel:"); + perChannel.forEach( + (name, count) -> sb.append("\nโ€ข #").append(name).append(": ").append(count)); + } + return sb.toString(); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/purge/PurgeResult.java b/application/src/main/java/org/togetherjava/tjbot/features/purge/PurgeResult.java new file mode 100644 index 0000000000..b4a782388a --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/purge/PurgeResult.java @@ -0,0 +1,16 @@ +package org.togetherjava.tjbot.features.purge; + +/** + * Callback invoked by {@link PurgeCommand} once a purge operation finishes, carrying the total + * number of messages that were submitted for deletion. + */ +@FunctionalInterface +public interface PurgeResult { + /** + * Called when the purge completes. + * + * @param totalDeleted the cumulative number of messages submitted for deletion across all + * batches of the purge + */ + void run(int totalDeleted); +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/purge/package-info.java b/application/src/main/java/org/togetherjava/tjbot/features/purge/package-info.java new file mode 100644 index 0000000000..cc5c95ab4c --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/purge/package-info.java @@ -0,0 +1,7 @@ +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package org.togetherjava.tjbot.features.purge; + +import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; From 9c234d760cffae8878af1d6d06b4b3d3d14ad728 Mon Sep 17 00:00:00 2001 From: Suraj Kumar Date: Sun, 24 May 2026 10:26:41 +0100 Subject: [PATCH 23/23] fix: stop re-forwarding quoted messages when new emojis are added (#1487) --- .../features/basic/QuoteBoardForwarder.java | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) 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 index 8121ccf037..39dbd7e699 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/basic/QuoteBoardForwarder.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/basic/QuoteBoardForwarder.java @@ -59,13 +59,6 @@ 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 (hasAlreadyForwardedMessage(event.getJDA(), messageReaction)) { - logger.debug("Message has already been forwarded by the bot. Skipping."); - return; - } - final long guildId = event.getGuild().getIdLong(); Optional boardChannelOptional = findQuoteBoardChannel(event.getJDA(), guildId); @@ -85,6 +78,11 @@ public void onMessageReactionAdd(MessageReactionAddEvent event) { } event.retrieveMessage().queue(message -> { + if (hasAlreadyForwardedMessage(message)) { + logger.debug("Message has already been forwarded by the bot. Skipping."); + return; + } + float emojiScore = calculateMessageScore(message.getReactions()); if (emojiScore < config.minimumScoreToTrigger()) { @@ -137,16 +135,13 @@ private Optional findQuoteBoardChannel(JDA jda, long guildId) { } /** - * Checks a {@link MessageReaction} to see if the bot has reacted to it. + * Checks whether the bot has already reacted to the given message with its marker emoji. */ - private boolean hasAlreadyForwardedMessage(JDA jda, MessageReaction messageReaction) { - if (!botEmoji.equals(messageReaction.getEmoji())) { - return false; - } - - return messageReaction.retrieveUsers() - .parallelStream() - .anyMatch(user -> jda.getSelfUser().getIdLong() == user.getIdLong()); + private boolean hasAlreadyForwardedMessage(Message message) { + return message.getReactions() + .stream() + .filter(reaction -> botEmoji.equals(reaction.getEmoji())) + .anyMatch(MessageReaction::isSelf); } private float calculateMessageScore(List reactions) {