From bb6ef839af3f81559ae9b2fc3f8c3f8c96c555e5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 May 2023 08:20:56 +0200 Subject: [PATCH 01/33] Bump org.xerial:sqlite-jdbc from 3.41.0.0 to 3.42.0.0 (#838) Bumps [org.xerial:sqlite-jdbc](https://github.com/xerial/sqlite-jdbc) from 3.41.0.0 to 3.42.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.41.0.0...3.42.0.0) --- updated-dependencies: - dependency-name: org.xerial:sqlite-jdbc 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 652906f6df..60b2971ff0 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -1,6 +1,6 @@ buildscript { dependencies { - classpath 'org.xerial:sqlite-jdbc:3.41.0.0' + classpath 'org.xerial:sqlite-jdbc:3.42.0.0' } } diff --git a/database/build.gradle b/database/build.gradle index 8a43e31b5a..9b69b32197 100644 --- a/database/build.gradle +++ b/database/build.gradle @@ -2,7 +2,7 @@ plugins { id 'java' } -var sqliteVersion = "3.41.0.0" +var sqliteVersion = "3.42.0.0" dependencies { implementation 'com.google.code.findbugs:jsr305:3.0.2' From a37d1cc3781015e612ee5d30f241d26f86812f45 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 24 May 2023 07:33:29 +0200 Subject: [PATCH 02/33] Bump org.flywaydb:flyway-core from 9.18.0 to 9.19.0 (#841) Bumps [org.flywaydb:flyway-core](https://github.com/flyway/flyway) from 9.18.0 to 9.19.0. - [Release notes](https://github.com/flyway/flyway/releases) - [Commits](https://github.com/flyway/flyway/commits) --- updated-dependencies: - dependency-name: org.flywaydb:flyway-core 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 9b69b32197..d1ba22889f 100644 --- a/database/build.gradle +++ b/database/build.gradle @@ -7,7 +7,7 @@ var sqliteVersion = "3.42.0.0" dependencies { implementation 'com.google.code.findbugs:jsr305:3.0.2' implementation "org.xerial:sqlite-jdbc:${sqliteVersion}" - implementation 'org.flywaydb:flyway-core:9.18.0' + implementation 'org.flywaydb:flyway-core:9.19.0' implementation "org.jooq:jooq:$jooqVersion" implementation project(':utils') From fc82bbfc669ee76fe150c294e6445b4d591afdc2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 24 May 2023 08:01:36 +0200 Subject: [PATCH 03/33] Bump gradle.plugin.org.flywaydb:gradle-plugin-publishing (#842) Bumps gradle.plugin.org.flywaydb:gradle-plugin-publishing from 9.18.0 to 9.19.0. --- updated-dependencies: - dependency-name: gradle.plugin.org.flywaydb:gradle-plugin-publishing 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> --- buildSrc/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index c6a5c6c4c8..1af84d1dfe 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -7,6 +7,6 @@ repositories { } dependencies { - implementation "gradle.plugin.org.flywaydb:gradle-plugin-publishing:9.18.0" + implementation "gradle.plugin.org.flywaydb:gradle-plugin-publishing:9.19.0" implementation 'nu.studer:gradle-jooq-plugin:8.2' } From 98f090ecdb163bda3a35e51a0f4b60c682735254 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 May 2023 08:59:21 +0200 Subject: [PATCH 04/33] Bump com.diffplug.spotless from 6.18.0 to 6.19.0 (#843) Bumps com.diffplug.spotless from 6.18.0 to 6.19.0. --- updated-dependencies: - dependency-name: com.diffplug.spotless 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 fbf8bcde66..50845b2bde 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id "com.diffplug.spotless" version "6.18.0" + id "com.diffplug.spotless" version "6.19.0" id "org.sonarqube" version "4.0.0.2929" id "name.remal.sonarlint" version "3.2.0" } From d6694f3af1fe050f24cb4ce9d36251a7c16a8ebd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 May 2023 08:13:58 +0200 Subject: [PATCH 05/33] Bump org.sonarqube from 4.0.0.2929 to 4.1.0.3113 (#844) Bumps org.sonarqube from 4.0.0.2929 to 4.1.0.3113. --- updated-dependencies: - dependency-name: org.sonarqube 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 50845b2bde..da4d752371 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ plugins { id 'java' id "com.diffplug.spotless" version "6.19.0" - id "org.sonarqube" version "4.0.0.2929" + id "org.sonarqube" version "4.1.0.3113" id "name.remal.sonarlint" version "3.2.0" } From 0ac8fe022e31e15e1527a8ce8d7bc98d02e88079 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 1 Jun 2023 08:51:57 +0200 Subject: [PATCH 06/33] Bump org.sonarqube from 4.1.0.3113 to 4.2.0.3129 (#845) Bumps org.sonarqube from 4.1.0.3113 to 4.2.0.3129. --- updated-dependencies: - dependency-name: org.sonarqube 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 da4d752371..776cd3dde7 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ plugins { id 'java' id "com.diffplug.spotless" version "6.19.0" - id "org.sonarqube" version "4.1.0.3113" + id "org.sonarqube" version "4.2.0.3129" id "name.remal.sonarlint" version "3.2.0" } From 7086528c162109132726b09eca09ec4c43cded7e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 2 Jun 2023 08:25:32 +0200 Subject: [PATCH 07/33] Bump org.kohsuke:github-api from 1.314 to 1.315 (#846) Bumps [org.kohsuke:github-api](https://github.com/hub4j/github-api) from 1.314 to 1.315. - [Release notes](https://github.com/hub4j/github-api/releases) - [Changelog](https://github.com/hub4j/github-api/blob/main/CHANGELOG.md) - [Commits](https://github.com/hub4j/github-api/compare/github-api-1.314...github-api-1.315) --- updated-dependencies: - dependency-name: org.kohsuke:github-api dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- application/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/build.gradle b/application/build.gradle index 60b2971ff0..9ae5b718c3 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -74,7 +74,7 @@ dependencies { implementation 'com.github.ben-manes.caffeine:caffeine:3.1.1' - implementation 'org.kohsuke:github-api:1.314' + implementation 'org.kohsuke:github-api:1.315' testImplementation 'org.mockito:mockito-core:5.3.1' testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.0' From a777157f06029273aea874fbb6436fdc96e2c706 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Jun 2023 07:52:45 +0200 Subject: [PATCH 08/33] Bump chatGPTVersion from 0.12.0 to 0.13.0 (#848) Bumps `chatGPTVersion` from 0.12.0 to 0.13.0. Updates `com.theokanning.openai-gpt3-java:api` from 0.12.0 to 0.13.0 - [Release notes](https://github.com/theokanning/openai-java/releases) - [Commits](https://github.com/theokanning/openai-java/compare/0.12.0...0.13.0) Updates `com.theokanning.openai-gpt3-java:service` from 0.12.0 to 0.13.0 - [Release notes](https://github.com/theokanning/openai-java/releases) - [Commits](https://github.com/theokanning/openai-java/compare/0.12.0...0.13.0) --- updated-dependencies: - dependency-name: com.theokanning.openai-gpt3-java:api dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: com.theokanning.openai-gpt3-java:service 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 776cd3dde7..2eb251b2d7 100644 --- a/build.gradle +++ b/build.gradle @@ -11,7 +11,7 @@ version '1.0-SNAPSHOT' ext { jooqVersion = '3.18.0' jacksonVersion = '2.15.0' - chatGPTVersion = '0.12.0' + chatGPTVersion = '0.13.0' } // Skips sonarlint during the build, useful for testing purposes. From f41f6a9198682da8c5d76a98d1eb41f5c1f55672 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 21 Jun 2023 08:01:29 +0200 Subject: [PATCH 09/33] Bump gradle.plugin.org.flywaydb:gradle-plugin-publishing (#849) Bumps gradle.plugin.org.flywaydb:gradle-plugin-publishing from 9.19.0 to 9.20.0. --- updated-dependencies: - dependency-name: gradle.plugin.org.flywaydb:gradle-plugin-publishing 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> --- buildSrc/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index 1af84d1dfe..3f3186990a 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -7,6 +7,6 @@ repositories { } dependencies { - implementation "gradle.plugin.org.flywaydb:gradle-plugin-publishing:9.19.0" + implementation "gradle.plugin.org.flywaydb:gradle-plugin-publishing:9.20.0" implementation 'nu.studer:gradle-jooq-plugin:8.2' } From a153298b4f7373a13ac72e10f8d6855c19a3bd7d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 21 Jun 2023 08:01:39 +0200 Subject: [PATCH 10/33] Bump org.flywaydb:flyway-core from 9.19.0 to 9.20.0 (#850) Bumps [org.flywaydb:flyway-core](https://github.com/flyway/flyway) from 9.19.0 to 9.20.0. - [Release notes](https://github.com/flyway/flyway/releases) - [Commits](https://github.com/flyway/flyway/compare/flyway-9.19.0...flyway-9.20.0) --- updated-dependencies: - dependency-name: org.flywaydb:flyway-core 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 d1ba22889f..dd87dc7a59 100644 --- a/database/build.gradle +++ b/database/build.gradle @@ -7,7 +7,7 @@ var sqliteVersion = "3.42.0.0" dependencies { implementation 'com.google.code.findbugs:jsr305:3.0.2' implementation "org.xerial:sqlite-jdbc:${sqliteVersion}" - implementation 'org.flywaydb:flyway-core:9.19.0' + implementation 'org.flywaydb:flyway-core:9.20.0' implementation "org.jooq:jooq:$jooqVersion" implementation project(':utils') From d2d648711cf88951f14f0b505b52caaba912018e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 23 Jun 2023 08:30:40 +0200 Subject: [PATCH 11/33] Bump chatGPTVersion from 0.13.0 to 0.14.0 (#851) Bumps `chatGPTVersion` from 0.13.0 to 0.14.0. Updates `com.theokanning.openai-gpt3-java:api` from 0.13.0 to 0.14.0 - [Release notes](https://github.com/theokanning/openai-java/releases) - [Commits](https://github.com/theokanning/openai-java/compare/0.13.0...0.14.0) Updates `com.theokanning.openai-gpt3-java:service` from 0.13.0 to 0.14.0 - [Release notes](https://github.com/theokanning/openai-java/releases) - [Commits](https://github.com/theokanning/openai-java/compare/0.13.0...0.14.0) --- updated-dependencies: - dependency-name: com.theokanning.openai-gpt3-java:api dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: com.theokanning.openai-gpt3-java:service 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 2eb251b2d7..77dd9459f6 100644 --- a/build.gradle +++ b/build.gradle @@ -11,7 +11,7 @@ version '1.0-SNAPSHOT' ext { jooqVersion = '3.18.0' jacksonVersion = '2.15.0' - chatGPTVersion = '0.13.0' + chatGPTVersion = '0.14.0' } // Skips sonarlint during the build, useful for testing purposes. From 6454646bc6d72d4c7a1fd220f6973271e48cef6a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 14 Jul 2023 08:11:42 +0200 Subject: [PATCH 12/33] Bump name.remal.sonarlint from 3.2.0 to 3.3.0 (#855) Bumps name.remal.sonarlint from 3.2.0 to 3.3.0. --- updated-dependencies: - dependency-name: name.remal.sonarlint 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 77dd9459f6..3716d90179 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,7 @@ plugins { id 'java' id "com.diffplug.spotless" version "6.19.0" id "org.sonarqube" version "4.2.0.3129" - id "name.remal.sonarlint" version "3.2.0" + id "name.remal.sonarlint" version "3.3.0" } group 'org.togetherjava' From a26588ce6cdae9ed55d418946211a795c73411cd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Jul 2023 10:13:08 +0200 Subject: [PATCH 13/33] Bump com.diffplug.spotless from 6.19.0 to 6.20.0 (#859) Bumps com.diffplug.spotless from 6.19.0 to 6.20.0. --- updated-dependencies: - dependency-name: com.diffplug.spotless 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 3716d90179..02276db82f 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id "com.diffplug.spotless" version "6.19.0" + id "com.diffplug.spotless" version "6.20.0" id "org.sonarqube" version "4.2.0.3129" id "name.remal.sonarlint" version "3.3.0" } From 27b97d317abedce914fe9d936f5c39f1244f9b4e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Jul 2023 15:57:35 +0200 Subject: [PATCH 14/33] Bump org.sonarqube from 4.2.0.3129 to 4.3.0.3225 (#858) Bumps org.sonarqube from 4.2.0.3129 to 4.3.0.3225. --- updated-dependencies: - dependency-name: org.sonarqube 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 02276db82f..e511f8be55 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ plugins { id 'java' id "com.diffplug.spotless" version "6.20.0" - id "org.sonarqube" version "4.2.0.3129" + id "org.sonarqube" version "4.3.0.3225" id "name.remal.sonarlint" version "3.3.0" } From 2279bf2bd3f390f8e127a551b8c0dcd67b7df4f0 Mon Sep 17 00:00:00 2001 From: Connor Schweighoefer <88111627+SquidXTV@users.noreply.github.com> Date: Wed, 19 Jul 2023 23:50:46 +0200 Subject: [PATCH 15/33] Logging - links logs to source (#857) * Added sourceCodeBaseUrl entry in config * Added linkable title functionality * Requested changes --- application/config.json.template | 5 +-- .../org/togetherjava/tjbot/config/Config.java | 16 ++++++++- .../logging/discord/DiscordLogAppender.java | 14 ++++++-- .../logging/discord/DiscordLogForwarder.java | 35 ++++++++++++++++--- .../tjbot/logging/discord/DiscordLogging.java | 7 ++-- 5 files changed, 63 insertions(+), 14 deletions(-) diff --git a/application/config.json.template b/application/config.json.template index 84ac087cb0..3f9262c32b 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -87,6 +87,7 @@ "wsh" ], "logInfoChannelWebhook": "", - "logErrorChannelWebhook": "" - "openaiApiKey": "" + "logErrorChannelWebhook": "", + "openaiApiKey": "", + "sourceCodeBaseUrl": "//blob/master/application/src/main/java/>" } 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 e5933ca262..f314b129b3 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -37,6 +37,7 @@ public final class Config { private final String logInfoChannelWebhook; private final String logErrorChannelWebhook; private final String openaiApiKey; + private final String sourceCodeBaseUrl; @SuppressWarnings("ConstructorWithTooManyParameters") @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) @@ -72,7 +73,8 @@ private Config(@JsonProperty(value = "token", required = true) String token, required = true) String logInfoChannelWebhook, @JsonProperty(value = "logErrorChannelWebhook", required = true) String logErrorChannelWebhook, - @JsonProperty(value = "openaiApiKey", required = true) String openaiApiKey) { + @JsonProperty(value = "openaiApiKey", required = true) String openaiApiKey, + @JsonProperty(value = "sourceCodeBaseUrl", required = true) String sourceCodeBaseUrl) { this.token = Objects.requireNonNull(token); this.gistApiKey = Objects.requireNonNull(gistApiKey); this.databasePath = Objects.requireNonNull(databasePath); @@ -96,6 +98,7 @@ private Config(@JsonProperty(value = "token", required = true) String token, this.logInfoChannelWebhook = Objects.requireNonNull(logInfoChannelWebhook); this.logErrorChannelWebhook = Objects.requireNonNull(logErrorChannelWebhook); this.openaiApiKey = Objects.requireNonNull(openaiApiKey); + this.sourceCodeBaseUrl = Objects.requireNonNull(sourceCodeBaseUrl); } /** @@ -316,4 +319,15 @@ public String getLogErrorChannelWebhook() { public String getOpenaiApiKey() { return openaiApiKey; } + + /** + * The base URL of the source code of this bot. E.g. + * {@code getSourceCodeBaseUrl() + "/org/togetherjava/tjbot/config/Config.java"} would point to + * this file. + * + * @return the base url of the source code of this bot + */ + public String getSourceCodeBaseUrl() { + return sourceCodeBaseUrl; + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/logging/discord/DiscordLogAppender.java b/application/src/main/java/org/togetherjava/tjbot/logging/discord/DiscordLogAppender.java index c826e5066e..fbf6e97a83 100644 --- a/application/src/main/java/org/togetherjava/tjbot/logging/discord/DiscordLogAppender.java +++ b/application/src/main/java/org/togetherjava/tjbot/logging/discord/DiscordLogAppender.java @@ -19,10 +19,10 @@ final class DiscordLogAppender extends AbstractAppender { private final DiscordLogForwarder logForwarder; private DiscordLogAppender(String name, Filter filter, StringLayout layout, - boolean ignoreExceptions, URI webhook) { + boolean ignoreExceptions, URI webhook, String sourceCodeBaseUrl) { super(name, filter, layout, ignoreExceptions, NO_PROPERTIES); - logForwarder = new DiscordLogForwarder(webhook); + logForwarder = new DiscordLogForwarder(webhook, sourceCodeBaseUrl); } @Override @@ -43,11 +43,19 @@ static final class DiscordLogAppenderBuilder @Required private URI webhook; + @Required + private String sourceCodeBaseUrl; + public DiscordLogAppenderBuilder setWebhook(URI webhook) { this.webhook = webhook; return asBuilder(); } + public DiscordLogAppenderBuilder setSourceCodeBaseUrl(String sourceCodeBaseUrl) { + this.sourceCodeBaseUrl = sourceCodeBaseUrl; + return asBuilder(); + } + @Override public DiscordLogAppender build() { Layout layout = getOrCreateLayout(); @@ -58,7 +66,7 @@ public DiscordLogAppender build() { String name = Objects.requireNonNull(getName()); return new DiscordLogAppender(name, getFilter(), (StringLayout) layout, - isIgnoreExceptions(), webhook); + isIgnoreExceptions(), webhook, sourceCodeBaseUrl); } } } diff --git a/application/src/main/java/org/togetherjava/tjbot/logging/discord/DiscordLogForwarder.java b/application/src/main/java/org/togetherjava/tjbot/logging/discord/DiscordLogForwarder.java index ece1a49a5a..b86a6eec16 100644 --- a/application/src/main/java/org/togetherjava/tjbot/logging/discord/DiscordLogForwarder.java +++ b/application/src/main/java/org/togetherjava/tjbot/logging/discord/DiscordLogForwarder.java @@ -7,6 +7,7 @@ import org.apache.logging.log4j.Level; import org.apache.logging.log4j.core.LogEvent; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -70,6 +71,7 @@ final class DiscordLogForwarder { 0xDFDF00, Level.ERROR, 0xBF2200, Level.FATAL, 0xFF8484); private final WebhookClient webhookClient; + private final String sourceCodeBaseUrl; /** * Internal buffer of logs that still have to be forwarded to Discord. Actions are synchronized * using {@link #pendingLogsLock} to ensure thread safety. @@ -77,9 +79,15 @@ final class DiscordLogForwarder { private final Queue pendingLogs = new PriorityQueue<>(); private final Object pendingLogsLock = new Object(); - DiscordLogForwarder(URI webhook) { + DiscordLogForwarder(URI webhook, String sourceCodeBaseUrl) { webhookClient = WebhookClient.withUrl(webhook.toString()); + if (!sourceCodeBaseUrl.endsWith("/")) { + this.sourceCodeBaseUrl = sourceCodeBaseUrl + "/"; + } else { + this.sourceCodeBaseUrl = sourceCodeBaseUrl; + } + SERVICE.scheduleWithFixedDelay(this::processPendingLogs, 5, 5, TimeUnit.SECONDS); } @@ -110,7 +118,7 @@ void forwardLogEvent(LogEvent event) { """); } - LogMessage log = LogMessage.ofEvent(event); + LogMessage log = LogMessage.ofEvent(event, sourceCodeBaseUrl); synchronized (pendingLogsLock) { pendingLogs.add(log); @@ -160,8 +168,11 @@ private List validateBatch(List logBatch) { private record LogMessage(WebhookEmbed embed, Instant timestamp) implements Comparable { - private static LogMessage ofEvent(LogEvent event) { + private static final String BASE_PACKAGE = "org.togetherjava.tjbot."; + + private static LogMessage ofEvent(LogEvent event, String sourceCodeBaseUrl) { String authorName = event.getLoggerName(); + String authorUrl = linkToSource(event.getSource(), sourceCodeBaseUrl).orElse(null); String title = event.getLevel().name(); int colorDecimal = Objects.requireNonNull(LEVEL_TO_AMBIENT_COLOR.get(event.getLevel())); String description = @@ -169,13 +180,12 @@ private static LogMessage ofEvent(LogEvent event) { Instant timestamp = Instant.ofEpochMilli(event.getInstant().getEpochMillisecond()); WebhookEmbed embed = new WebhookEmbedBuilder() - .setAuthor(new WebhookEmbed.EmbedAuthor(authorName, null, null)) + .setAuthor(new WebhookEmbed.EmbedAuthor(authorName, null, authorUrl)) .setTitle(new WebhookEmbed.EmbedTitle(title, null)) .setDescription(description) .setColor(colorDecimal) .setTimestamp(timestamp) .build(); - return new LogMessage(embed, timestamp); } @@ -193,6 +203,21 @@ private static String describeLogEvent(LogEvent event) { return logMessage + "\n" + exceptionWriter.toString().replace("\t", "> "); } + private static Optional linkToSource(@Nullable StackTraceElement sourceElement, + String sourceCodeBaseUrl) { + if (sourceElement == null) { + return Optional.empty(); + } + + String source = sourceElement.getClassName(); + if (!source.startsWith(BASE_PACKAGE)) { + return Optional.empty(); + } + + String link = "%s%s.java".formatted(sourceCodeBaseUrl, source.replace('.', '/')); + return Optional.of(link); + } + private LogMessage shortened() { String shortDescription = MessageUtils.abbreviate( Objects.requireNonNull(embed.getDescription()), MAX_EMBED_DESCRIPTION_SHORT); diff --git a/application/src/main/java/org/togetherjava/tjbot/logging/discord/DiscordLogging.java b/application/src/main/java/org/togetherjava/tjbot/logging/discord/DiscordLogging.java index da53282cf7..99d2de7970 100644 --- a/application/src/main/java/org/togetherjava/tjbot/logging/discord/DiscordLogging.java +++ b/application/src/main/java/org/togetherjava/tjbot/logging/discord/DiscordLogging.java @@ -47,11 +47,11 @@ public static void startDiscordLogging(Config botConfig) { private static void addAppenders(Configuration logConfig, Config botConfig) { parseWebhookUri(botConfig.getLogInfoChannelWebhook()) .ifPresent(webhookUri -> addDiscordLogAppender("DiscordInfo", createInfoRangeFilter(), - webhookUri, logConfig)); + webhookUri, botConfig.getSourceCodeBaseUrl(), logConfig)); parseWebhookUri(botConfig.getLogErrorChannelWebhook()) .ifPresent(webhookUri -> addDiscordLogAppender("DiscordError", createErrorRangeFilter(), - webhookUri, logConfig)); + webhookUri, botConfig.getSourceCodeBaseUrl(), logConfig)); } private static Optional parseWebhookUri(String webhookUri) { @@ -71,7 +71,7 @@ private static Optional parseWebhookUri(String webhookUri) { // to the config. @SuppressWarnings("squid:S4792") private static void addDiscordLogAppender(String name, Filter filter, URI webhookUri, - Configuration logConfig) { + String sourceCodeBaseUrl, Configuration logConfig) { // NOTE The whole setup is done programmatically in order to allow the webhooks // to be read from the config file Filter[] filters = {filter, createDenyMarkerFilter(LogMarkers.NO_DISCORD.getName()), @@ -80,6 +80,7 @@ private static void addDiscordLogAppender(String name, Filter filter, URI webhoo Appender appender = DiscordLogAppender.newBuilder() .setName(name) .setWebhook(webhookUri) + .setSourceCodeBaseUrl(sourceCodeBaseUrl) .setFilter(CompositeFilter.createFilters(filters)) .build(); From 2fceec56005eb06c5353a30b086d24788f57fdc2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Jul 2023 09:24:38 +0200 Subject: [PATCH 16/33] Bump org.flywaydb:flyway-core from 9.20.0 to 9.21.0 (#861) Bumps [org.flywaydb:flyway-core](https://github.com/flyway/flyway) from 9.20.0 to 9.21.0. - [Release notes](https://github.com/flyway/flyway/releases) - [Commits](https://github.com/flyway/flyway/compare/flyway-9.20.0...flyway-9.21.0) --- updated-dependencies: - dependency-name: org.flywaydb:flyway-core 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 dd87dc7a59..0a7af61b10 100644 --- a/database/build.gradle +++ b/database/build.gradle @@ -7,7 +7,7 @@ var sqliteVersion = "3.42.0.0" dependencies { implementation 'com.google.code.findbugs:jsr305:3.0.2' implementation "org.xerial:sqlite-jdbc:${sqliteVersion}" - implementation 'org.flywaydb:flyway-core:9.20.0' + implementation 'org.flywaydb:flyway-core:9.21.0' implementation "org.jooq:jooq:$jooqVersion" implementation project(':utils') From c83c515443ac5a99d85b754f5cd1a37535eb8c44 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 21 Jul 2023 09:24:48 +0200 Subject: [PATCH 17/33] Bump gradle.plugin.org.flywaydb:gradle-plugin-publishing (#860) Bumps gradle.plugin.org.flywaydb:gradle-plugin-publishing from 9.20.0 to 9.21.0. --- updated-dependencies: - dependency-name: gradle.plugin.org.flywaydb:gradle-plugin-publishing 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> --- buildSrc/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index 3f3186990a..43a10c37a6 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -7,6 +7,6 @@ repositories { } dependencies { - implementation "gradle.plugin.org.flywaydb:gradle-plugin-publishing:9.20.0" + implementation "gradle.plugin.org.flywaydb:gradle-plugin-publishing:9.21.0" implementation 'nu.studer:gradle-jooq-plugin:8.2' } From 3e86ef0a3d1316c367035f9925d632eb0d7f9aeb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Jul 2023 08:08:04 +0200 Subject: [PATCH 18/33] Bump org.junit.jupiter:junit-jupiter-api from 5.9.0 to 5.10.0 (#864) Bumps [org.junit.jupiter:junit-jupiter-api](https://github.com/junit-team/junit5) from 5.9.0 to 5.10.0. - [Release notes](https://github.com/junit-team/junit5/releases) - [Commits](https://github.com/junit-team/junit5/compare/r5.9.0...r5.10.0) --- updated-dependencies: - dependency-name: org.junit.jupiter:junit-jupiter-api 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 +- formatter/build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/application/build.gradle b/application/build.gradle index 9ae5b718c3..c6636a5c45 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -77,7 +77,7 @@ dependencies { implementation 'org.kohsuke:github-api:1.315' testImplementation 'org.mockito:mockito-core:5.3.1' - testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.0' + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.0' testImplementation 'org.junit.jupiter:junit-jupiter-params:5.9.0' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.0' diff --git a/formatter/build.gradle b/formatter/build.gradle index e05ddb18dd..9ee6a3d837 100644 --- a/formatter/build.gradle +++ b/formatter/build.gradle @@ -6,7 +6,7 @@ dependencies { implementation 'com.google.code.findbugs:jsr305:3.0.2' implementation project(':utils') - testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.0' + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.0' testImplementation 'org.junit.jupiter:junit-jupiter-params:5.9.0' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.0' } From 3e2aaaab94e783fbc77c1ab8f87b85a1104af6a9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Jul 2023 08:08:12 +0200 Subject: [PATCH 19/33] Bump org.junit.jupiter:junit-jupiter-engine from 5.9.0 to 5.10.0 (#863) Bumps [org.junit.jupiter:junit-jupiter-engine](https://github.com/junit-team/junit5) from 5.9.0 to 5.10.0. - [Release notes](https://github.com/junit-team/junit5/releases) - [Commits](https://github.com/junit-team/junit5/compare/r5.9.0...r5.10.0) --- updated-dependencies: - dependency-name: org.junit.jupiter:junit-jupiter-engine 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 +- formatter/build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/application/build.gradle b/application/build.gradle index c6636a5c45..ceafe2864b 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -79,7 +79,7 @@ dependencies { testImplementation 'org.mockito:mockito-core:5.3.1' testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.0' testImplementation 'org.junit.jupiter:junit-jupiter-params:5.9.0' - testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.0' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.0' implementation "com.theokanning.openai-gpt3-java:api:$chatGPTVersion" implementation "com.theokanning.openai-gpt3-java:service:$chatGPTVersion" diff --git a/formatter/build.gradle b/formatter/build.gradle index 9ee6a3d837..1737385589 100644 --- a/formatter/build.gradle +++ b/formatter/build.gradle @@ -8,6 +8,6 @@ dependencies { testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.0' testImplementation 'org.junit.jupiter:junit-jupiter-params:5.9.0' - testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.0' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.0' } From 8195a30a190aa363cc73e4b7481f91a8b5a6087e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Jul 2023 10:45:55 +0200 Subject: [PATCH 20/33] Bump org.junit.jupiter:junit-jupiter-params from 5.9.0 to 5.10.0 (#862) Bumps [org.junit.jupiter:junit-jupiter-params](https://github.com/junit-team/junit5) from 5.9.0 to 5.10.0. - [Release notes](https://github.com/junit-team/junit5/releases) - [Commits](https://github.com/junit-team/junit5/compare/r5.9.0...r5.10.0) --- updated-dependencies: - dependency-name: org.junit.jupiter:junit-jupiter-params 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 +- formatter/build.gradle | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/application/build.gradle b/application/build.gradle index ceafe2864b..5050f0852a 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -78,7 +78,7 @@ dependencies { testImplementation 'org.mockito:mockito-core:5.3.1' testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.0' - testImplementation 'org.junit.jupiter:junit-jupiter-params:5.9.0' + testImplementation 'org.junit.jupiter:junit-jupiter-params:5.10.0' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.0' implementation "com.theokanning.openai-gpt3-java:api:$chatGPTVersion" diff --git a/formatter/build.gradle b/formatter/build.gradle index 1737385589..d86473ff90 100644 --- a/formatter/build.gradle +++ b/formatter/build.gradle @@ -7,7 +7,7 @@ dependencies { implementation project(':utils') testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.0' - testImplementation 'org.junit.jupiter:junit-jupiter-params:5.9.0' + testImplementation 'org.junit.jupiter:junit-jupiter-params:5.10.0' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.10.0' } From 0de9992c0b9c0f7fc14c87d1d30b2b4bff3d661e Mon Sep 17 00:00:00 2001 From: TheRealJoeFriel <32469171+TheRealJoeFriel@users.noreply.github.com> Date: Wed, 26 Jul 2023 04:03:08 -0500 Subject: [PATCH 21/33] Increase default ChatGPT response timeout from 10s to 90s (#852) --- .../togetherjava/tjbot/features/chaptgpt/ChatGptService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/chaptgpt/ChatGptService.java b/application/src/main/java/org/togetherjava/tjbot/features/chaptgpt/ChatGptService.java index 08fbe7c134..11d7055a28 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/chaptgpt/ChatGptService.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/chaptgpt/ChatGptService.java @@ -20,7 +20,7 @@ */ public class ChatGptService { private static final Logger logger = LoggerFactory.getLogger(ChatGptService.class); - private static final Duration TIMEOUT = Duration.ofSeconds(10); + private static final Duration TIMEOUT = Duration.ofSeconds(90); private static final int MAX_TOKENS = 3_000; private boolean isDisabled = false; private final OpenAiService openAiService; From 03b1112b05a94158b8369af29ff3dcb5176f4e59 Mon Sep 17 00:00:00 2001 From: Alathreon <45936420+Alathreon@users.noreply.github.com> Date: Fri, 11 Aug 2023 13:56:54 +0200 Subject: [PATCH 22/33] Feature/jshell (#870) * Feature/jshell from fork (#869) * JShell feature added * Added option to get the snippets of another user * Refactored JShell eval into its own class * Changed RateLimiter so it's a global limiter and not a per user limiter + merged user and oneOffSession together * Added context action for running java code * Fixing sonar wranings for jshell * Added startupt scripts for jshell * Formating jshell classes * JShell javadoc and many minor code improvements * JShell more minor code improvements * Other minor code changes * JShell now allows wrong uri and dead server, will give correct response to the user and will log them as warns * Forgot one exception javadoc for jshell feature * JShell refactored snippets send conditions --- application/config.json.template | 5 + .../org/togetherjava/tjbot/config/Config.java | 14 +- .../tjbot/config/JShellConfig.java | 39 +++ .../togetherjava/tjbot/features/Features.java | 7 +- .../features/code/CodeMessageHandler.java | 8 +- .../tjbot/features/code/EvalCodeCommand.java | 45 +++ .../tjbot/features/jshell/JShellCommand.java | 319 ++++++++++++++++++ .../tjbot/features/jshell/JShellEval.java | 96 ++++++ .../tjbot/features/jshell/ResultRenderer.java | 89 +++++ .../features/jshell/backend/JShellApi.java | 189 +++++++++++ .../backend/dto/JShellExceptionResult.java | 10 + .../jshell/backend/dto/JShellResult.java | 43 +++ .../backend/dto/JShellResultWithId.java | 10 + .../jshell/backend/dto/SnippetList.java | 23 ++ .../jshell/backend/dto/SnippetStatus.java | 28 ++ .../jshell/backend/dto/SnippetType.java | 9 + .../jshell/backend/dto/package-info.java | 10 + .../features/jshell/backend/package-info.java | 11 + .../tjbot/features/jshell/package-info.java | 11 + .../tjbot/features/utils/Colors.java | 17 + .../utils/ConnectionFailedException.java | 34 ++ .../tjbot/features/utils/MessageUtils.java | 3 +- .../tjbot/features/utils/RateLimiter.java | 83 +++++ .../utils/RequestFailedException.java | 39 +++ .../tjbot/features/utils/ResponseUtils.java | 54 +++ .../UncheckedRequestFailedException.java | 38 +++ 26 files changed, 1229 insertions(+), 5 deletions(-) create mode 100644 application/src/main/java/org/togetherjava/tjbot/config/JShellConfig.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/code/EvalCodeCommand.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellCommand.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellEval.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/jshell/ResultRenderer.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/JShellApi.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/JShellExceptionResult.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/JShellResult.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/JShellResultWithId.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/SnippetList.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/SnippetStatus.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/SnippetType.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/package-info.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/package-info.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/jshell/package-info.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/utils/Colors.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/utils/ConnectionFailedException.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/utils/RateLimiter.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/utils/RequestFailedException.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/utils/ResponseUtils.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/utils/UncheckedRequestFailedException.java diff --git a/application/config.json.template b/application/config.json.template index 3f9262c32b..02c6027d9e 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -90,4 +90,9 @@ "logErrorChannelWebhook": "", "openaiApiKey": "", "sourceCodeBaseUrl": "//blob/master/application/src/main/java/>" + "jshell": { + "baseUrl": "", + "rateLimitWindowSeconds": 10, + "rateLimitRequestsInWindow": 3 + } } 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 f314b129b3..f16aed61b4 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -38,6 +38,7 @@ public final class Config { private final String logErrorChannelWebhook; private final String openaiApiKey; private final String sourceCodeBaseUrl; + private final JShellConfig jshell; @SuppressWarnings("ConstructorWithTooManyParameters") @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) @@ -74,7 +75,8 @@ private Config(@JsonProperty(value = "token", required = true) String token, @JsonProperty(value = "logErrorChannelWebhook", required = true) String logErrorChannelWebhook, @JsonProperty(value = "openaiApiKey", required = true) String openaiApiKey, - @JsonProperty(value = "sourceCodeBaseUrl", required = true) String sourceCodeBaseUrl) { + @JsonProperty(value = "sourceCodeBaseUrl", required = true) String sourceCodeBaseUrl, + @JsonProperty(value = "jshell", required = true) JShellConfig jshell) { this.token = Objects.requireNonNull(token); this.gistApiKey = Objects.requireNonNull(gistApiKey); this.databasePath = Objects.requireNonNull(databasePath); @@ -99,6 +101,7 @@ private Config(@JsonProperty(value = "token", required = true) String token, this.logErrorChannelWebhook = Objects.requireNonNull(logErrorChannelWebhook); this.openaiApiKey = Objects.requireNonNull(openaiApiKey); this.sourceCodeBaseUrl = Objects.requireNonNull(sourceCodeBaseUrl); + this.jshell = Objects.requireNonNull(jshell); } /** @@ -330,4 +333,13 @@ public String getOpenaiApiKey() { public String getSourceCodeBaseUrl() { return sourceCodeBaseUrl; } + + /** + * The configuration about jshell REST API and command/code action settings. + * + * @return the jshell configuration + */ + public JShellConfig getJshell() { + return jshell; + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/config/JShellConfig.java b/application/src/main/java/org/togetherjava/tjbot/config/JShellConfig.java new file mode 100644 index 0000000000..91b85eb2d8 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/config/JShellConfig.java @@ -0,0 +1,39 @@ +package org.togetherjava.tjbot.config; + + +import org.togetherjava.tjbot.features.utils.RateLimiter; + +import java.util.Objects; + +/** + * JShell config. + * + * @param baseUrl the base url of the JShell REST API + * @param rateLimitWindowSeconds the number of seconds of the {@link RateLimiter rate limiter} for + * jshell commands and code actions + * @param rateLimitRequestsInWindow the number of requests of the {@link RateLimiter rate limiter} + * for jshell commands and code actions + */ +public record JShellConfig(String baseUrl, int rateLimitWindowSeconds, + int rateLimitRequestsInWindow) { + /** + * Creates a JShell config. + * + * @param baseUrl the base url of the JShell REST API, must be not null + * @param rateLimitWindowSeconds the number of seconds of the {@link RateLimiter rate limiter} + * for jshell commands and code actions, must be higher than 0 + * @param rateLimitRequestsInWindow the number of requests of the {@link RateLimiter rate + * limiter} for jshell commands and code actions, must be higher than 0 + */ + public JShellConfig { + Objects.requireNonNull(baseUrl); + if (rateLimitWindowSeconds < 0) { + throw new IllegalArgumentException( + "Illegal rateLimitWindowSeconds : " + rateLimitWindowSeconds); + } + if (rateLimitRequestsInWindow < 0) { + throw new IllegalArgumentException( + "Illegal rateLimitRequestsInWindow : " + rateLimitRequestsInWindow); + } + } +} 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 7f1f3af046..3210269955 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -13,6 +13,8 @@ import org.togetherjava.tjbot.features.code.CodeMessageManualDetection; import org.togetherjava.tjbot.features.filesharing.FileSharingMessageListener; import org.togetherjava.tjbot.features.help.*; +import org.togetherjava.tjbot.features.jshell.JShellCommand; +import org.togetherjava.tjbot.features.jshell.JShellEval; import org.togetherjava.tjbot.features.mathcommands.TeXCommand; import org.togetherjava.tjbot.features.mathcommands.wolframalpha.WolframAlphaCommand; import org.togetherjava.tjbot.features.mediaonly.MediaOnlyChannelListener; @@ -67,13 +69,15 @@ private Features() { * @return a collection of all features */ public static Collection createFeatures(JDA jda, Database database, Config config) { + JShellEval jshellEval = new JShellEval(config.getJshell()); + TagSystem tagSystem = new TagSystem(database); BookmarksSystem bookmarksSystem = new BookmarksSystem(config, database); ModerationActionsStore actionsStore = new ModerationActionsStore(database); ModAuditLogWriter modAuditLogWriter = new ModAuditLogWriter(config); ScamHistoryStore scamHistoryStore = new ScamHistoryStore(database); HelpSystemHelper helpSystemHelper = new HelpSystemHelper(config, database); - CodeMessageHandler codeMessageHandler = new CodeMessageHandler(); + CodeMessageHandler codeMessageHandler = new CodeMessageHandler(jshellEval); ChatGptService chatGptService = new ChatGptService(config); // NOTE The system can add special system relevant commands also by itself, @@ -143,6 +147,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new ReportCommand(config)); features.add(new BookmarksCommand(bookmarksSystem)); features.add(new ChatGptCommand(chatGptService)); + features.add(new JShellCommand(jshellEval)); return features; } } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/code/CodeMessageHandler.java b/application/src/main/java/org/togetherjava/tjbot/features/code/CodeMessageHandler.java index 3073d665ee..a26cde9f1c 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/code/CodeMessageHandler.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/code/CodeMessageHandler.java @@ -20,6 +20,7 @@ import org.togetherjava.tjbot.features.UserInteractor; import org.togetherjava.tjbot.features.componentids.ComponentIdGenerator; import org.togetherjava.tjbot.features.componentids.ComponentIdInteractor; +import org.togetherjava.tjbot.features.jshell.JShellEval; import org.togetherjava.tjbot.features.utils.CodeFence; import org.togetherjava.tjbot.features.utils.MessageUtils; @@ -62,11 +63,14 @@ public final class CodeMessageHandler extends MessageReceiverAdapter implements /** * Creates a new instance. + * + * @param jshellEval used to execute java code and build visual result */ - public CodeMessageHandler() { + public CodeMessageHandler(JShellEval jshellEval) { componentIdInteractor = new ComponentIdInteractor(getInteractionType(), getName()); - List codeActions = List.of(new FormatCodeCommand()); + List codeActions = + List.of(new FormatCodeCommand(), new EvalCodeCommand(jshellEval)); labelToCodeAction = codeActions.stream() .collect(Collectors.toMap(CodeAction::getLabel, Function.identity(), (x, y) -> y, diff --git a/application/src/main/java/org/togetherjava/tjbot/features/code/EvalCodeCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/code/EvalCodeCommand.java new file mode 100644 index 0000000000..ad6cc91a3c --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/code/EvalCodeCommand.java @@ -0,0 +1,45 @@ +package org.togetherjava.tjbot.features.code; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.MessageEmbed; + +import org.togetherjava.tjbot.features.jshell.JShellEval; +import org.togetherjava.tjbot.features.utils.CodeFence; +import org.togetherjava.tjbot.features.utils.Colors; +import org.togetherjava.tjbot.features.utils.ConnectionFailedException; +import org.togetherjava.tjbot.features.utils.RequestFailedException; + +/** + * Evaluates the given code with jshell. + *

+ * It will not work of the code isn't valid java or jshell compatible code. + */ +final class EvalCodeCommand implements CodeAction { + private final JShellEval jshellEval; + + EvalCodeCommand(JShellEval jshellEval) { + this.jshellEval = jshellEval; + } + + @Override + public String getLabel() { + return "Run code"; + } + + @Override + public MessageEmbed apply(CodeFence codeFence) { + if (codeFence.code().isEmpty()) { + return new EmbedBuilder().setColor(Colors.ERROR_COLOR) + .setDescription("There is nothing to evaluate") + .build(); + } + try { + return jshellEval.evaluateAndRespond(null, codeFence.code(), false, false); + } catch (RequestFailedException | ConnectionFailedException e) { + return new EmbedBuilder().setColor(Colors.ERROR_COLOR) + .setDescription("Request failed: " + e.getMessage()) + .build(); + } + } + +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellCommand.java new file mode 100644 index 0000000000..44bb6ba37f --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellCommand.java @@ -0,0 +1,319 @@ +package org.togetherjava.tjbot.features.jshell; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.InteractionHook; +import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import net.dv8tion.jda.api.interactions.components.Modal; +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.ModalMapping; +import net.dv8tion.jda.api.utils.FileUpload; + +import org.togetherjava.tjbot.features.CommandVisibility; +import org.togetherjava.tjbot.features.SlashCommandAdapter; +import org.togetherjava.tjbot.features.jshell.backend.JShellApi; +import org.togetherjava.tjbot.features.utils.Colors; +import org.togetherjava.tjbot.features.utils.ConnectionFailedException; +import org.togetherjava.tjbot.features.utils.MessageUtils; +import org.togetherjava.tjbot.features.utils.RequestFailedException; + +import javax.annotation.Nullable; + +import java.util.List; +import java.util.Objects; + +/** + * The JShell command AKA {@code /jshell}, provide functionalities to create JShell sessions, + * evaluate code, etc. + *

+ * Example: {@code /jshell eval code:2+2} + */ +public class JShellCommand extends SlashCommandAdapter { + private static final String TEXT_INPUT_PART_ID = "jshell"; + private static final String JSHELL_COMMAND = "jshell"; + private static final String VERSION_SUBCOMMAND = "version"; + private static final String EVAL_SUBCOMMAND = "eval"; + private static final String SNIPPETS_SUBCOMMAND = "snippets"; + private static final String CLOSE_SUBCOMMAND = "shutdown"; + private static final String STARTUP_SCRIPT_SUBCOMMAND = "startup-script"; + private static final String CODE_PARAMETER = "code"; + private static final String STARTUP_SCRIPT_PARAMETER = "startup-script"; + private static final String USER_PARAMETER = "user"; + private static final String INCLUDE_STARTUP_SCRIPT_PARAMETER = "include-startup-script"; + + private static final int MIN_MESSAGE_INPUT_LENGTH = 0; + private static final int MAX_MESSAGE_INPUT_LENGTH = TextInput.MAX_VALUE_LENGTH; + + private final JShellEval jshellEval; + + /** + * Creates an instance of the command. + * + * @param jshellEval used to execute java code and build visual result + */ + public JShellCommand(JShellEval jshellEval) { + super(JSHELL_COMMAND, "Execute Java code in Discord!", CommandVisibility.GUILD); + + this.jshellEval = jshellEval; + + getData().addSubcommands( + new SubcommandData(VERSION_SUBCOMMAND, "Get the version of JShell"), + new SubcommandData(EVAL_SUBCOMMAND, + "Evaluate java code in JShell, submit the command without code for inputting longer, multi-line code.") + .addOption(OptionType.STRING, CODE_PARAMETER, + "Code to evaluate. Leave empty to input longer, multi-line code.") + .addOption(OptionType.BOOLEAN, STARTUP_SCRIPT_PARAMETER, + "If the startup script should be loaded, true by default."), + new SubcommandData(SNIPPETS_SUBCOMMAND, + "Display your snippets, or the snippets of the specified user if any.") + .addOption(OptionType.USER, USER_PARAMETER, + "User to get the snippets from. If null, get your snippets.") + .addOption(OptionType.BOOLEAN, INCLUDE_STARTUP_SCRIPT_PARAMETER, + "if the startup script should be included, false by default."), + new SubcommandData(CLOSE_SUBCOMMAND, "Close your session."), + new SubcommandData(STARTUP_SCRIPT_SUBCOMMAND, "Display the startup script.")); + } + + @Override + public void onSlashCommand(SlashCommandInteractionEvent event) { + switch (Objects.requireNonNull(event.getSubcommandName())) { + case VERSION_SUBCOMMAND -> handleVersionCommand(event); + case EVAL_SUBCOMMAND -> handleEvalCommand(event); + case SNIPPETS_SUBCOMMAND -> handleSnippetsCommand(event); + case CLOSE_SUBCOMMAND -> handleCloseCommand(event); + case STARTUP_SCRIPT_SUBCOMMAND -> handleStartupScriptCommand(event); + default -> throw new AssertionError( + "Unexpected Subcommand: " + event.getSubcommandName()); + } + } + + @Override + public void onModalSubmitted(ModalInteractionEvent event, List args) { + ModalMapping mapping = event.getValue(TEXT_INPUT_PART_ID + "|" + STARTUP_SCRIPT_PARAMETER); + boolean startupScript = mapping != null; + if (mapping == null) { + mapping = event.getValue(TEXT_INPUT_PART_ID); + } + if (mapping != null) { + handleEval(event, event.getUser(), true, mapping.getAsString(), startupScript); + } + } + + private void handleVersionCommand(SlashCommandInteractionEvent event) { + String code = """ + System.out.println("```"); + System.out.println("Version: " + Runtime.version()); + System.out.println("Vendor: " + System.getProperty("java.vendor")); + System.out.println("OS: " + System.getProperty("os.name")); + System.out.println("Arch: " + System.getProperty("os.arch")); + System.out.println("```");"""; + handleEval(event, null, false, code, false); + } + + private void handleEvalCommand(SlashCommandInteractionEvent event) { + OptionMapping code = event.getOption(CODE_PARAMETER); + boolean startupScript = event.getOption(STARTUP_SCRIPT_PARAMETER) == null + || Objects.requireNonNull(event.getOption(STARTUP_SCRIPT_PARAMETER)).getAsBoolean(); + if (code == null) { + sendEvalModal(event, startupScript); + } else { + handleEval(event, event.getUser(), true, code.getAsString(), startupScript); + } + } + + private void sendEvalModal(SlashCommandInteractionEvent event, boolean startupScript) { + TextInput body = TextInput + .create(TEXT_INPUT_PART_ID + (startupScript ? "|" + STARTUP_SCRIPT_PARAMETER : ""), + "Enter code to evaluate.", TextInputStyle.PARAGRAPH) + .setPlaceholder("Put your code here.") + .setRequiredRange(MIN_MESSAGE_INPUT_LENGTH, MAX_MESSAGE_INPUT_LENGTH) + .build(); + + Modal modal = Modal.create(generateComponentId(), "JShell").addActionRow(body).build(); + event.replyModal(modal).queue(); + } + + /** + * Handle evaluation of code. + * + * @param replyCallback the callback to reply to + * @param user the user, if null, will create a single use session + * @param showCode if the embed should contain the original code + * @param startupScript if the startup script should be used or not + * @param code the code + */ + private void handleEval(IReplyCallback replyCallback, @Nullable User user, boolean showCode, + String code, boolean startupScript) { + replyCallback.deferReply().queue(interactionHook -> { + try { + interactionHook + .editOriginalEmbeds( + jshellEval.evaluateAndRespond(user, code, showCode, startupScript)) + .queue(); + } catch (RequestFailedException | ConnectionFailedException e) { + interactionHook.editOriginalEmbeds(createUnexpectedErrorEmbed(user, e)).queue(); + } + }); + } + + private void handleSnippetsCommand(SlashCommandInteractionEvent event) { + event.deferReply().queue(interactionHook -> { + OptionMapping userOption = event.getOption(USER_PARAMETER); + User user = userOption == null ? event.getUser() : userOption.getAsUser(); + OptionMapping includeStartupScriptOption = + event.getOption(INCLUDE_STARTUP_SCRIPT_PARAMETER); + boolean includeStartupScript = + includeStartupScriptOption != null && includeStartupScriptOption.getAsBoolean(); + List snippets; + try { + snippets = jshellEval.getApi() + .snippetsSession(user.getId(), includeStartupScript) + .snippets(); + } catch (RequestFailedException e) { + if (e.getStatus() == JShellApi.SESSION_NOT_FOUND) { + interactionHook.editOriginalEmbeds(createSessionNotFoundErrorEmbed(user)) + .queue(); + } else { + interactionHook.editOriginalEmbeds(createUnexpectedErrorEmbed(user, e)).queue(); + } + return; + } catch (ConnectionFailedException e) { + interactionHook.editOriginalEmbeds(createUnexpectedErrorEmbed(user, e)).queue(); + return; + } + + sendSnippets(interactionHook, user, snippets); + }); + } + + private void sendSnippets(InteractionHook interactionHook, User user, List snippets) { + if (canBeSentAsEmbed(snippets)) { + sendSnippetsAsEmbed(interactionHook, user, snippets); + } else if (canBeSentAsFile(snippets)) { + sendSnippetsAsFile(interactionHook, user, snippets); + } else { + sendSnippetsTooLong(interactionHook, user); + } + } + + private boolean canBeSentAsEmbed(List snippets) { + return snippets.stream().noneMatch(s -> s.length() >= MessageEmbed.VALUE_MAX_LENGTH) + && snippets.stream() + .mapToInt(s -> (s + "Snippet 10```java\n```").length()) + .sum() < MessageEmbed.EMBED_MAX_LENGTH_BOT - 100 + && snippets.size() <= MessageUtils.MAXIMUM_VISIBLE_EMBEDS; + } + + private void sendSnippetsAsEmbed(InteractionHook interactionHook, User user, + List snippets) { + EmbedBuilder builder = new EmbedBuilder().setColor(Colors.SUCCESS_COLOR) + .setAuthor(user.getName()) + .setTitle(snippetsTitle(user)); + int i = 1; + for (String snippet : snippets) { + builder.addField("Snippet " + i, "```java\n" + snippet + "```", false); + i++; + } + interactionHook.editOriginalEmbeds(builder.build()).queue(); + } + + private boolean canBeSentAsFile(List snippets) { + return snippets.stream() + .mapToInt(s -> (s + "// Snippet 10").getBytes().length) + .sum() < Message.MAX_FILE_SIZE; + } + + private void sendSnippetsAsFile(InteractionHook interactionHook, User user, + List snippets) { + StringBuilder sb = new StringBuilder(); + int i = 1; + for (String snippet : snippets) { + sb.append("// Snippet ").append(i).append("\n").append(snippet); + i++; + } + interactionHook + .editOriginalEmbeds(new EmbedBuilder().setColor(Colors.SUCCESS_COLOR) + .setAuthor(user.getName()) + .setTitle(snippetsTitle(user)) + .build()) + .setFiles(FileUpload.fromData(sb.toString().getBytes(), snippetsTitle(user))) + .queue(); + } + + private String snippetsTitle(User user) { + return user.getName() + "'s snippets"; + } + + private void sendSnippetsTooLong(InteractionHook interactionHook, User user) { + interactionHook + .editOriginalEmbeds(new EmbedBuilder().setColor(Colors.ERROR_COLOR) + .setAuthor(user.getName()) + .setTitle("Too much code to send...") + .build()) + .queue(); + } + + private void handleCloseCommand(SlashCommandInteractionEvent event) { + try { + jshellEval.getApi().closeSession(event.getUser().getId()); + } catch (RequestFailedException e) { + if (e.getStatus() == JShellApi.SESSION_NOT_FOUND) { + event.replyEmbeds(createSessionNotFoundErrorEmbed(event.getUser())).queue(); + } else { + event.replyEmbeds(createUnexpectedErrorEmbed(event.getUser(), e)).queue(); + } + return; + } catch (ConnectionFailedException e) { + event.replyEmbeds(createUnexpectedErrorEmbed(event.getUser(), e)).queue(); + return; + } + + event + .replyEmbeds(new EmbedBuilder().setColor(Colors.SUCCESS_COLOR) + .setAuthor(event.getUser().getName()) + .setTitle("Session closed") + .build()) + .queue(); + } + + private void handleStartupScriptCommand(SlashCommandInteractionEvent event) { + event.deferReply().queue(interactionHook -> { + try { + String startupScript = jshellEval.getApi().startupScript(); + interactionHook + .editOriginalEmbeds(new EmbedBuilder().setColor(Colors.SUCCESS_COLOR) + .setAuthor(event.getUser().getName()) + .setTitle("Startup script") + .setDescription("```java\n" + startupScript + "```") + .build()) + .queue(); + } catch (RequestFailedException | ConnectionFailedException e) { + event.replyEmbeds(createUnexpectedErrorEmbed(event.getUser(), e)).queue(); + } + }); + } + + private MessageEmbed createSessionNotFoundErrorEmbed(User user) { + return new EmbedBuilder().setAuthor(user.getName() + "'s result") + .setColor(Colors.ERROR_COLOR) + .setDescription("Could not find session for user " + user.getName()) + .build(); + } + + private MessageEmbed createUnexpectedErrorEmbed(@Nullable User user, Exception e) { + EmbedBuilder embedBuilder = new EmbedBuilder().setColor(Colors.ERROR_COLOR) + .setDescription("Request failed: " + e.getMessage()); + if (user != null) { + embedBuilder.setAuthor(user.getName() + "'s result"); + } + return embedBuilder.build(); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellEval.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellEval.java new file mode 100644 index 0000000000..381c787cd0 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellEval.java @@ -0,0 +1,96 @@ +package org.togetherjava.tjbot.features.jshell; + +import com.fasterxml.jackson.databind.ObjectMapper; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.utils.TimeFormat; + +import org.togetherjava.tjbot.config.JShellConfig; +import org.togetherjava.tjbot.features.jshell.backend.JShellApi; +import org.togetherjava.tjbot.features.jshell.backend.dto.JShellResult; +import org.togetherjava.tjbot.features.utils.Colors; +import org.togetherjava.tjbot.features.utils.ConnectionFailedException; +import org.togetherjava.tjbot.features.utils.RateLimiter; +import org.togetherjava.tjbot.features.utils.RequestFailedException; + +import javax.annotation.Nullable; + +import java.time.Duration; +import java.time.Instant; + +/** + * Provides a mid-ground between JDA and JShell API which can be used from many places in the bot, + * including JShell commands and JShell code actions. + */ +public class JShellEval { + private final JShellApi api; + + private final ResultRenderer renderer; + private final RateLimiter rateLimiter; + + /** + * Creates a JShell evaluation instance + * + * @param config the JShell configuration to use + */ + public JShellEval(JShellConfig config) { + this.api = new JShellApi(new ObjectMapper(), config.baseUrl()); + this.renderer = new ResultRenderer(); + + this.rateLimiter = new RateLimiter(Duration.ofSeconds(config.rateLimitWindowSeconds()), + config.rateLimitRequestsInWindow()); + } + + public JShellApi getApi() { + return api; + } + + /** + * Evaluate code and return a message containing the response. + * + * @param user the user, if null, will create a single use session + * @param code the code + * @param showCode if the original code should be displayed + * @param startupScript if the startup script should be used or not + * @return the response + * @throws RequestFailedException if a http error happens + * @throws ConnectionFailedException if the connection to the API couldn't be made at the first + * place + */ + public MessageEmbed evaluateAndRespond(@Nullable User user, String code, boolean showCode, + boolean startupScript) throws RequestFailedException, ConnectionFailedException { + MessageEmbed rateLimitedMessage = wasRateLimited(user, Instant.now()); + if (rateLimitedMessage != null) { + return rateLimitedMessage; + } + JShellResult result; + if (user == null) { + result = api.evalOnce(code, startupScript); + } else { + result = api.evalSession(code, user.getId(), startupScript); + } + + return renderer + .renderToEmbed(user, showCode ? code : null, user != null, result, new EmbedBuilder()) + .build(); + } + + @Nullable + private MessageEmbed wasRateLimited(@Nullable User user, Instant checkTime) { + if (rateLimiter.allowRequest(checkTime)) { + return null; + } + + String nextAllowedTime = + TimeFormat.RELATIVE.format(rateLimiter.nextAllowedRequestTime(checkTime)); + EmbedBuilder embedBuilder = new EmbedBuilder() + .setDescription( + "You are currently rate-limited. Please try again " + nextAllowedTime + ".") + .setColor(Colors.ERROR_COLOR); + if (user != null) { + embedBuilder.setAuthor(user.getName() + "'s result"); + } + return embedBuilder.build(); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/ResultRenderer.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/ResultRenderer.java new file mode 100644 index 0000000000..9d930249d9 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/ResultRenderer.java @@ -0,0 +1,89 @@ +package org.togetherjava.tjbot.features.jshell; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.User; + +import org.togetherjava.tjbot.features.jshell.backend.dto.JShellResult; +import org.togetherjava.tjbot.features.jshell.backend.dto.SnippetStatus; +import org.togetherjava.tjbot.features.utils.MessageUtils; + +import javax.annotation.Nullable; + +import java.awt.Color; + +import static org.togetherjava.tjbot.features.utils.Colors.*; + +/** + * Allows to render JShell results. + */ +class ResultRenderer { + + /** + * Renders a JShell result to an embed. + * + * @param originator the user from who to display snippet ownership, won't be displayed if null + * @param originalCode the original code to display, won't be displayed if null + * @param partOfSession if it was part of a regular session, or a one time session + * @param result the JShell result + * @param builder the embed builder + * @return the ember builder, for chaining + */ + public EmbedBuilder renderToEmbed(@Nullable User originator, @Nullable String originalCode, + boolean partOfSession, JShellResult result, EmbedBuilder builder) { + if (originator != null) { + builder.setAuthor(originator.getName() + "'s result"); + } + builder.setColor(color(result.status())); + + if (originalCode != null + && originalCode.length() + "```\n```".length() < MessageEmbed.VALUE_MAX_LENGTH) { + builder.setDescription("```java\n" + originalCode + "```"); + builder.addField( + originator == null ? "Original code" : (originator.getName() + "'s code"), + "```java\n" + originalCode + "```", false); + } + + if (result.result() != null && !result.result().isBlank()) { + builder.addField("Snippet result", result.result(), false); + } + if (result.status() == SnippetStatus.ABORTED) { + builder.setTitle("Request timed out"); + } + + String description = getDescriptionFromResult(result); + description = MessageUtils.abbreviate(description, MessageEmbed.DESCRIPTION_MAX_LENGTH); + if (result.stdoutOverflow() && !description.endsWith(MessageUtils.ABBREVIATION)) { + description += MessageUtils.ABBREVIATION; + } + builder.setDescription(description); + + if (partOfSession) { + builder.setFooter("Snippet " + result.id() + " of current session"); + } else { + builder.setFooter("This result is not part of a session"); + } + + return builder; + } + + private String getDescriptionFromResult(JShellResult result) { + if (result.exception() != null) { + return result.exception().exceptionClass() + ":" + + result.exception().exceptionMessage(); + } + if (!result.errors().isEmpty()) { + return String.join(", ", result.errors()); + } + return result.stdout(); + } + + private Color color(SnippetStatus status) { + return switch (status) { + case VALID -> SUCCESS_COLOR; + case RECOVERABLE_DEFINED, RECOVERABLE_NOT_DEFINED -> WARNING_COLOR; + case REJECTED, ABORTED -> ERROR_COLOR; + }; + } + +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/JShellApi.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/JShellApi.java new file mode 100644 index 0000000000..cd961a00c6 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/JShellApi.java @@ -0,0 +1,189 @@ +package org.togetherjava.tjbot.features.jshell.backend; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.features.jshell.backend.dto.JShellResult; +import org.togetherjava.tjbot.features.jshell.backend.dto.SnippetList; +import org.togetherjava.tjbot.features.utils.ConnectionFailedException; +import org.togetherjava.tjbot.features.utils.RequestFailedException; +import org.togetherjava.tjbot.features.utils.ResponseUtils; +import org.togetherjava.tjbot.features.utils.UncheckedRequestFailedException; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.ConnectException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublishers; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandler; +import java.net.http.HttpResponse.BodyHandlers; + +/** + * Allows to interact with the + * Together-Java's JShell REST + * API project. + *

+ * Each method may do a blocking HTTP request and may throw a RequestFailedException if status code + * isn't 200 or 204. + *

+ * When startup script boolean argument is asked, true means {@link JShellApi#STARTUP_SCRIPT_ID} and + * false means Together-Java JShell backend's default startup script. + *

+ * For more information, check the Together-Java JShell backend project. + */ +public class JShellApi { + private static final Logger logger = LoggerFactory.getLogger(JShellApi.class); + public static final int SESSION_NOT_FOUND = 404; + /** + * The startup script to use when startup script boolean argument is true. + */ + private static final String STARTUP_SCRIPT_ID = "CUSTOM_DEFAULT"; + + private final ObjectMapper objectMapper; + private final HttpClient httpClient; + private final String baseUrl; + + /** + * Creates a JShellAPI + * + * @param objectMapper the json mapper to use + * @param baseUrl the base url of the JShell REST API + */ + public JShellApi(ObjectMapper objectMapper, String baseUrl) { + this.objectMapper = objectMapper; + this.baseUrl = baseUrl; + + this.httpClient = HttpClient.newBuilder().build(); + } + + /** + * Evaluates the code in a one time only session, will block until the request is over. + * + * @param code the code to evaluate + * @param startupScript if the {@link JShellApi#STARTUP_SCRIPT_ID startup script} should be + * executed at the start of the session + * @return the result of the evaluation + * @throws RequestFailedException if the status code is not 200 or 204 + * @throws ConnectionFailedException if the connection to the API couldn't be made at the first + * place + */ + public JShellResult evalOnce(String code, boolean startupScript) + throws RequestFailedException, ConnectionFailedException { + return send( + baseUrl + "single-eval" + + (startupScript ? "?startupScriptId=" + STARTUP_SCRIPT_ID : ""), + HttpRequest.newBuilder().POST(BodyPublishers.ofString(code)), + ResponseUtils.ofJson(JShellResult.class, objectMapper)).body(); + } + + /** + * Evaluates the code in a regular session, will block until the request is over. + * + * @param code the code to evaluate + * @param sessionId the id of the session to get the snippets from + * @param startupScript if the {@link JShellApi#STARTUP_SCRIPT_ID startup script} should be + * executed at the start of the session + * @return the result of the evaluation + * @throws RequestFailedException if the status code is not 200 or 204 + * @throws ConnectionFailedException if the connection to the API couldn't be made at the first + * place + */ + public JShellResult evalSession(String code, String sessionId, boolean startupScript) + throws RequestFailedException, ConnectionFailedException { + return send( + baseUrl + "eval/" + sessionId + + (startupScript ? "?startupScriptId=" + STARTUP_SCRIPT_ID : ""), + HttpRequest.newBuilder().POST(BodyPublishers.ofString(code)), + ResponseUtils.ofJson(JShellResult.class, objectMapper)).body(); + } + + /** + * Gets and return the snippets for the given session id, will block until the request is over. + * + * @param sessionId the id of the session to get the snippets from + * @param includeStartupScript if the startup script should be included in the returned snippets + * @return the snippets of the session + * @throws RequestFailedException if the status code is not 200 or 204 + * @throws ConnectionFailedException if the connection to the API couldn't be made at the first + * place + */ + public SnippetList snippetsSession(String sessionId, boolean includeStartupScript) + throws RequestFailedException, ConnectionFailedException { + return send( + baseUrl + "snippets/" + sessionId + "?includeStartupScript=" + includeStartupScript, + HttpRequest.newBuilder().GET(), + ResponseUtils.ofJson(SnippetList.class, objectMapper)).body(); + } + + /** + * Closes the given session. + * + * @param sessionId the id of the session to close + * @throws RequestFailedException if the status code is not 200 or 204 + * @throws ConnectionFailedException if the connection to the API couldn't be made at the first + * place + */ + public void closeSession(String sessionId) + throws RequestFailedException, ConnectionFailedException { + send(baseUrl + sessionId, HttpRequest.newBuilder().DELETE(), BodyHandlers.discarding()) + .body(); + } + + /** + * Gets and return the {@link JShellApi#STARTUP_SCRIPT_ID startup script}, will block until the + * request is over. + * + * @return the startup script + * @throws RequestFailedException if the status code is not 200 or 204 + * @throws ConnectionFailedException if the connection to the API couldn't be made at the first + * place + */ + public String startupScript() throws RequestFailedException, ConnectionFailedException { + return send(baseUrl + "startup_script/" + STARTUP_SCRIPT_ID, HttpRequest.newBuilder().GET(), + BodyHandlers.ofString()).body(); + } + + private HttpResponse send(String url, HttpRequest.Builder builder, BodyHandler body) + throws RequestFailedException, ConnectionFailedException { + try { + HttpRequest request = buildRequestWithURI(builder, url); + HttpResponse response = httpClient.send(request, body); + if (response.statusCode() == 200 || response.statusCode() == 204) { + return response; + } + throw warn("JShell request failed.", new RequestFailedException( + "Request failed with status: " + response.statusCode(), response.statusCode())); + } catch (IOException e) { + if (e.getCause() instanceof UncheckedRequestFailedException r) { + throw warn("JShell request failed.", r.toChecked()); + } + if (e.getCause() instanceof ConnectException ce) { + throw warn("JShell Connection failed.", + new ConnectionFailedException("Couldn't connect to JShell server.", ce)); + } + throw new UncheckedIOException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + + private HttpRequest buildRequestWithURI(HttpRequest.Builder builder, String url) + throws ConnectionFailedException { + try { + return builder.uri(URI.create(url)).build(); + } catch (IllegalArgumentException ex) { + throw warn("Invalid JShell URI.", + new ConnectionFailedException("Couldn't parse JShell URI.", ex)); + } + } + + private T warn(String message, T exception) { + logger.warn(message, exception); + return exception; + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/JShellExceptionResult.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/JShellExceptionResult.java new file mode 100644 index 0000000000..dbffefda69 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/JShellExceptionResult.java @@ -0,0 +1,10 @@ +package org.togetherjava.tjbot.features.jshell.backend.dto; + +/** + * The thrown exception. + * + * @param exceptionClass the class of the exception + * @param exceptionMessage the message of the exception + */ +public record JShellExceptionResult(String exceptionClass, String exceptionMessage) { +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/JShellResult.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/JShellResult.java new file mode 100644 index 0000000000..11be982c91 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/JShellResult.java @@ -0,0 +1,43 @@ +package org.togetherjava.tjbot.features.jshell.backend.dto; + +import jdk.jshell.SnippetEvent; + +import javax.annotation.Nullable; + +import java.util.List; + +/** + * Result of a JShell eval. + * + * @param status {@link SnippetStatus status} of the snippet + * @param type {@link SnippetType type} of the snippet + * @param id {@link jdk.jshell.Snippet#id() id} of the snippet + * @param source source code of the snippet + * @param result {@link SnippetEvent#value() result} of the snippet, usually null if the source code + * wasn't executed or if an exception happened during the execution, see related doc + * @param exception exception thrown by the executed code, null if no exception was thrown + * @param stdoutOverflow if stdout has overflowed and was truncated + * @param stdout what was printed by the snippet + * @param errors the compilations errors of the snippet + */ +public record JShellResult(SnippetStatus status, SnippetType type, String id, String source, + @Nullable String result, @Nullable JShellExceptionResult exception, boolean stdoutOverflow, + String stdout, List errors) { + + /** + * The JShell result. + * + * @param status status of the snippet + * @param type type of the snippet + * @param id id of the snippet + * @param source source code of the snippet + * @param result result of the snippet, nullable + * @param exception thrown exception, nullable + * @param stdoutOverflow if stdout has overflowed and was truncated + * @param stdout what was printed by the snippet + * @param errors the compilations errors of the snippet + */ + public JShellResult { + errors = List.copyOf(errors); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/JShellResultWithId.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/JShellResultWithId.java new file mode 100644 index 0000000000..2cd862e9b7 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/JShellResultWithId.java @@ -0,0 +1,10 @@ +package org.togetherjava.tjbot.features.jshell.backend.dto; + +/** + * Result of a JShell eval plus the session id. + * + * @param id the session of the id + * @param result the JShell eval result + */ +public record JShellResultWithId(String id, JShellResult result) { +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/SnippetList.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/SnippetList.java new file mode 100644 index 0000000000..dbed64f37d --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/SnippetList.java @@ -0,0 +1,23 @@ +package org.togetherjava.tjbot.features.jshell.backend.dto; + +import com.fasterxml.jackson.annotation.JsonCreator; + +import java.util.List; +import java.util.Objects; + +/** + * List of snippets returned by snippets endpoint. + * + * @param snippets the list of snippets + */ +public record SnippetList(List snippets) { + /** + * List of snippets returned by snippets endpoint. + * + * @param snippets the list of snippets + */ + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public SnippetList { + Objects.requireNonNull(snippets); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/SnippetStatus.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/SnippetStatus.java new file mode 100644 index 0000000000..63306ab0b2 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/SnippetStatus.java @@ -0,0 +1,28 @@ +package org.togetherjava.tjbot.features.jshell.backend.dto; + +/** + * The status of the snippet, see {@link jdk.jshell.Snippet.Status} for most of them, and evaluation + * timeout of the JShell REST API for {@link SnippetStatus#ABORTED ABORTED}. + */ +public enum SnippetStatus { + /** + * See {@link jdk.jshell.Snippet.Status#VALID}. + */ + VALID, + /** + * See {@link jdk.jshell.Snippet.Status#RECOVERABLE_DEFINED}. + */ + RECOVERABLE_DEFINED, + /** + * See {@link jdk.jshell.Snippet.Status#RECOVERABLE_NOT_DEFINED}. + */ + RECOVERABLE_NOT_DEFINED, + /** + * See {@link jdk.jshell.Snippet.Status#REJECTED}. + */ + REJECTED, + /** + * Used when the timeout of an evaluation is reached. + */ + ABORTED +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/SnippetType.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/SnippetType.java new file mode 100644 index 0000000000..54c737dca5 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/SnippetType.java @@ -0,0 +1,9 @@ +package org.togetherjava.tjbot.features.jshell.backend.dto; + +/** + * Type of the snippet, if it was added or modified. + */ +public enum SnippetType { + ADDITION, + MODIFICATION +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/package-info.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/package-info.java new file mode 100644 index 0000000000..c506b34feb --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/package-info.java @@ -0,0 +1,10 @@ +/** + * This packages offers value classes to encapsulate information of JShell REST API. + */ +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package org.togetherjava.tjbot.features.jshell.backend.dto; + +import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/package-info.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/package-info.java new file mode 100644 index 0000000000..67a345f285 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/package-info.java @@ -0,0 +1,11 @@ +/** + * This packages offers a class to interact with JShell REST API. The core class is + * {@link org.togetherjava.tjbot.features.jshell.backend.JShellApi}. + */ +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package org.togetherjava.tjbot.features.jshell.backend; + +import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/package-info.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/package-info.java new file mode 100644 index 0000000000..1f1d03d1e8 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/package-info.java @@ -0,0 +1,11 @@ +/** + * This packages offers all the functionality for jshell. The core class is + * {@link org.togetherjava.tjbot.features.jshell.JShellCommand}. + */ +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package org.togetherjava.tjbot.features.jshell; + +import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/application/src/main/java/org/togetherjava/tjbot/features/utils/Colors.java b/application/src/main/java/org/togetherjava/tjbot/features/utils/Colors.java new file mode 100644 index 0000000000..8222bf3c52 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/utils/Colors.java @@ -0,0 +1,17 @@ +package org.togetherjava.tjbot.features.utils; + +import java.awt.Color; + +/** + * Provides the color of different things. + */ +public class Colors { + private Colors() { + throw new UnsupportedOperationException(); + } + + public static final Color ERROR_COLOR = new Color(255, 99, 71); + public static final Color SUCCESS_COLOR = new Color(118, 255, 0); + public static final Color WARNING_COLOR = new Color(255, 181, 71); + +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/utils/ConnectionFailedException.java b/application/src/main/java/org/togetherjava/tjbot/features/utils/ConnectionFailedException.java new file mode 100644 index 0000000000..7325cc97eb --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/utils/ConnectionFailedException.java @@ -0,0 +1,34 @@ +package org.togetherjava.tjbot.features.utils; + +/** + * Happens when a connection has failed, or the URL was invalid. + */ +public class ConnectionFailedException extends Exception { + + /** + * Constructs a new exception with the specified detail message. The cause is not initialized, + * and may subsequently be initialized by a call to {@link #initCause}. + * + * @param message the detail message. The detail message is saved for later retrieval by the + * {@link #getMessage()} method. + */ + public ConnectionFailedException(String message) { + super(message); + } + + /** + * Constructs a new exception with the specified detail message and cause. + *

+ * Note that the detail message associated with {@code cause} is not automatically + * incorporated in this exception's detail message. + * + * @param message the detail message (which is saved for later retrieval by the + * {@link #getMessage()} method). + * @param cause the cause (which is saved for later retrieval by the {@link #getCause()} + * method). (A {@code null} value is permitted, and indicates that the cause is + * nonexistent or unknown.) + */ + public ConnectionFailedException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/utils/MessageUtils.java b/application/src/main/java/org/togetherjava/tjbot/features/utils/MessageUtils.java index 3788b8465f..661f5acd64 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/utils/MessageUtils.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/utils/MessageUtils.java @@ -20,7 +20,8 @@ * other commands to avoid similar methods appearing everywhere. */ public class MessageUtils { - private static final String ABBREVIATION = "..."; + public static final int MAXIMUM_VISIBLE_EMBEDS = 25; + public static final String ABBREVIATION = "..."; private static final String CODE_FENCE_SYMBOL = "```"; private MessageUtils() { diff --git a/application/src/main/java/org/togetherjava/tjbot/features/utils/RateLimiter.java b/application/src/main/java/org/togetherjava/tjbot/features/utils/RateLimiter.java new file mode 100644 index 0000000000..8dda5c8c91 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/utils/RateLimiter.java @@ -0,0 +1,83 @@ +package org.togetherjava.tjbot.features.utils; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Rate limiter, register when requests are done and tells if a request can be done or need to be + * canceled. + */ +public class RateLimiter { + + private List lastUses; + + private final Duration duration; + private final int allowedRequests; + + /** + * Creates a rate limiter. + *

+ * Defines a window and a number of request, for example, if 10 requests should be allowed per 5 + * seconds, so 10/5s, the following should be called: {@snippet java: new + * RateLimit(Duration.of(5, TimeUnit.SECONDS), 10) } + * + * @param duration the duration of window + * @param allowedRequests the number of requests to allow in the window + */ + public RateLimiter(Duration duration, int allowedRequests) { + this.duration = duration; + this.allowedRequests = allowedRequests; + + this.lastUses = List.of(); + } + + /** + * Tries to allow the request. If it is allowed, the time is registered. + * + * @param time the time of the request + * @return if the request was allowed + */ + public boolean allowRequest(Instant time) { + synchronized (this) { + List usesInWindow = getEffectiveUses(time); + + if (usesInWindow.size() >= allowedRequests) { + return false; + } + usesInWindow.add(time); + + lastUses = usesInWindow; + + return true; + } + } + + private List getEffectiveUses(Instant time) { + return lastUses.stream() + .filter(it -> Duration.between(it, time).compareTo(duration) <= 0) + .collect(Collectors.toCollection(ArrayList::new)); + } + + /** + * Returns next time a request can be allowed. + * + * @param time the time of the request + * @return when the next request will be allowed + */ + public Instant nextAllowedRequestTime(Instant time) { + synchronized (this) { + List currentUses = getEffectiveUses(time); + currentUses.sort(Instant::compareTo); + + if (currentUses.size() < allowedRequests) { + return Instant.now(); + } + + return currentUses.get(0).plus(duration); + } + } + +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/utils/RequestFailedException.java b/application/src/main/java/org/togetherjava/tjbot/features/utils/RequestFailedException.java new file mode 100644 index 0000000000..7649135447 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/utils/RequestFailedException.java @@ -0,0 +1,39 @@ +package org.togetherjava.tjbot.features.utils; + +/** + * Happens when an HTTP request has failed, contains an HTTP status code. Is the checked version of + * {@link UncheckedRequestFailedException}. + */ +public class RequestFailedException extends Exception { + private final int status; + + /** + * Creates a RequestFailedException from an unchecked one. + * + * @param ex the UncheckedRequestFailedException + */ + public RequestFailedException(UncheckedRequestFailedException ex) { + super(ex.getMessage()); + this.status = ex.getStatus(); + } + + /** + * Creates a RequestFailedException from a message and a HTTP status + * + * @param message the message + * @param status the http status + */ + public RequestFailedException(String message, int status) { + super(message); + this.status = status; + } + + /** + * Returns the HTTP status. + * + * @return the HTTP status + */ + public int getStatus() { + return status; + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/utils/ResponseUtils.java b/application/src/main/java/org/togetherjava/tjbot/features/utils/ResponseUtils.java new file mode 100644 index 0000000000..c2ab8675fb --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/utils/ResponseUtils.java @@ -0,0 +1,54 @@ +package org.togetherjava.tjbot.features.utils; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.http.HttpResponse.BodyHandler; +import java.net.http.HttpResponse.BodySubscribers; +import java.util.Optional; + +/** + * Handle the parsing of json in a http request. + */ +public class ResponseUtils { + private ResponseUtils() {} + + /** + * Creates a body handler which will parse the body of the request. If the parsing fails, an + * IOException is thrown. if the request status code is not 200 or 204, a + * UncheckedRequestFailedException is thrown wrapped in an IOException. + * + * @param type the class to parse the json into + * @param mapper the json mapper + * @return the body handler + * @param the type of the class to parse the json into + */ + public static BodyHandler ofJson(Class type, ObjectMapper mapper) { + return responseInfo -> BodySubscribers.mapping(BodySubscribers.ofByteArray(), bytes -> { + if (responseInfo.statusCode() == 200 || responseInfo.statusCode() == 204) { + return uncheckedParseJson(type, mapper, bytes); + } + String errorMessage = tryParseError(bytes, mapper) + .orElse("Request failed with status: " + responseInfo.statusCode()); + throw new UncheckedRequestFailedException(errorMessage, responseInfo.statusCode()); + }); + } + + private static T uncheckedParseJson(Class type, ObjectMapper mapper, byte[] value) { + try { + return mapper.readValue(value, type); + } catch (IOException e) { + throw new UncheckedIOException("Error parsing json", e); + } + } + + private static Optional tryParseError(byte[] bytes, ObjectMapper mapper) { + try { + return Optional.ofNullable(mapper.readTree(bytes).get("error").asText()); + } catch (Exception e) { + return Optional.empty(); + } + } + +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/utils/UncheckedRequestFailedException.java b/application/src/main/java/org/togetherjava/tjbot/features/utils/UncheckedRequestFailedException.java new file mode 100644 index 0000000000..e35b140d34 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/utils/UncheckedRequestFailedException.java @@ -0,0 +1,38 @@ +package org.togetherjava.tjbot.features.utils; + +/** + * Internal exception when an HTTP request has failed, contains an HTTP status code. Is the + * unchecked version of {@link RequestFailedException}. + */ +public class UncheckedRequestFailedException extends RuntimeException { + private final int status; + + /** + * Creates an UncheckedRequestFailedException from a message and a HTTP status + * + * @param message the message + * @param status the http status + */ + public UncheckedRequestFailedException(String message, int status) { + super(message); + this.status = status; + } + + /** + * Returns the HTTP status. + * + * @return the HTTP status + */ + public int getStatus() { + return status; + } + + /** + * Creates a checked RequestFailedException from this UncheckedRequestFailedException. + * + * @return a checked RequestFailedException + */ + public RequestFailedException toChecked() { + return new RequestFailedException(this); + } +} From ae006d2a0d460b6d721bdf50c1093da3705c22c0 Mon Sep 17 00:00:00 2001 From: Loimz <83086076+tmcdonnell2@users.noreply.github.com> Date: Sat, 26 Aug 2023 17:15:29 -0700 Subject: [PATCH 23/33] Chat gpt attempt questions (#873) * * Refactor folder name * Add ChatGPT command/service code and utils. * * Fix Features.java import statements and add ChatGPTService being passed in to helper. * Add parsing of question to helper. * * Add AI Help message to when user asks question. (Either direction to the command if the service fails or an explanation that the next response is AI generated) * * Add test files and test suite for AIResponseParser.java * Add logger.debug() to catch responses before being parsed in case of failing to parse correctly to generate new tests [AIResponseParser.java]. * * Refactor how AIResponseParserTest parameterizes file names (ints instead of full file names). * Remove ChatGPTServiceTest.java as it was an older way to generate long responses from ChatGPT for testing. * Add another test file and include in parameters for AIResponseParserTest.java --- .../togetherjava/tjbot/features/Features.java | 38 ++++-- .../features/chatgpt/AIResponseParser.java | 82 ++++++++++++ .../{chaptgpt => chatgpt}/ChatGptCommand.java | 19 ++- .../{chaptgpt => chatgpt}/ChatGptService.java | 43 +++++-- .../{chaptgpt => chatgpt}/package-info.java | 2 +- .../tjbot/features/help/HelpSystemHelper.java | 119 ++++++++++++++++-- .../help/HelpThreadCreatedListener.java | 8 ++ .../chatgpt/AIResponseParserTest.java | 46 +++++++ .../test/resources/AITestResponses/test1.txt | 71 +++++++++++ .../test/resources/AITestResponses/test2.txt | 63 ++++++++++ .../test/resources/AITestResponses/test3.txt | 80 ++++++++++++ .../test/resources/AITestResponses/test4.txt | 27 ++++ 12 files changed, 566 insertions(+), 32 deletions(-) create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/chatgpt/AIResponseParser.java rename application/src/main/java/org/togetherjava/tjbot/features/{chaptgpt => chatgpt}/ChatGptCommand.java (84%) rename application/src/main/java/org/togetherjava/tjbot/features/{chaptgpt => chatgpt}/ChatGptService.java (65%) rename application/src/main/java/org/togetherjava/tjbot/features/{chaptgpt => chatgpt}/package-info.java (84%) create mode 100644 application/src/test/java/org/togetherjava/tjbot/features/chatgpt/AIResponseParserTest.java create mode 100644 application/src/test/resources/AITestResponses/test1.txt create mode 100644 application/src/test/resources/AITestResponses/test2.txt create mode 100644 application/src/test/resources/AITestResponses/test3.txt create mode 100644 application/src/test/resources/AITestResponses/test4.txt 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 3210269955..58c34ca949 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -4,22 +4,46 @@ import org.togetherjava.tjbot.config.Config; import org.togetherjava.tjbot.db.Database; -import org.togetherjava.tjbot.features.basic.*; -import org.togetherjava.tjbot.features.bookmarks.*; -import org.togetherjava.tjbot.features.chaptgpt.ChatGptCommand; -import org.togetherjava.tjbot.features.chaptgpt.ChatGptService; +import org.togetherjava.tjbot.features.basic.PingCommand; +import org.togetherjava.tjbot.features.basic.RoleSelectCommand; +import org.togetherjava.tjbot.features.basic.SlashCommandEducator; +import org.togetherjava.tjbot.features.basic.SuggestionsUpDownVoter; +import org.togetherjava.tjbot.features.bookmarks.BookmarksCommand; +import org.togetherjava.tjbot.features.bookmarks.BookmarksSystem; +import org.togetherjava.tjbot.features.bookmarks.LeftoverBookmarksCleanupRoutine; +import org.togetherjava.tjbot.features.bookmarks.LeftoverBookmarksListener; +import org.togetherjava.tjbot.features.chatgpt.ChatGptCommand; +import org.togetherjava.tjbot.features.chatgpt.ChatGptService; import org.togetherjava.tjbot.features.code.CodeMessageAutoDetection; import org.togetherjava.tjbot.features.code.CodeMessageHandler; import org.togetherjava.tjbot.features.code.CodeMessageManualDetection; import org.togetherjava.tjbot.features.filesharing.FileSharingMessageListener; -import org.togetherjava.tjbot.features.help.*; +import org.togetherjava.tjbot.features.help.AutoPruneHelperRoutine; +import org.togetherjava.tjbot.features.help.GuildLeaveCloseThreadListener; +import org.togetherjava.tjbot.features.help.HelpSystemHelper; +import org.togetherjava.tjbot.features.help.HelpThreadActivityUpdater; +import org.togetherjava.tjbot.features.help.HelpThreadAutoArchiver; +import org.togetherjava.tjbot.features.help.HelpThreadCommand; +import org.togetherjava.tjbot.features.help.HelpThreadCreatedListener; +import org.togetherjava.tjbot.features.help.HelpThreadMetadataPurger; import org.togetherjava.tjbot.features.jshell.JShellCommand; import org.togetherjava.tjbot.features.jshell.JShellEval; import org.togetherjava.tjbot.features.mathcommands.TeXCommand; import org.togetherjava.tjbot.features.mathcommands.wolframalpha.WolframAlphaCommand; import org.togetherjava.tjbot.features.mediaonly.MediaOnlyChannelListener; -import org.togetherjava.tjbot.features.moderation.*; +import org.togetherjava.tjbot.features.moderation.BanCommand; +import org.togetherjava.tjbot.features.moderation.KickCommand; +import org.togetherjava.tjbot.features.moderation.ModerationActionsStore; +import org.togetherjava.tjbot.features.moderation.MuteCommand; +import org.togetherjava.tjbot.features.moderation.NoteCommand; +import org.togetherjava.tjbot.features.moderation.QuarantineCommand; +import org.togetherjava.tjbot.features.moderation.RejoinModerationRoleListener; import org.togetherjava.tjbot.features.moderation.ReportCommand; +import org.togetherjava.tjbot.features.moderation.UnbanCommand; +import org.togetherjava.tjbot.features.moderation.UnmuteCommand; +import org.togetherjava.tjbot.features.moderation.UnquarantineCommand; +import org.togetherjava.tjbot.features.moderation.WarnCommand; +import org.togetherjava.tjbot.features.moderation.WhoIsCommand; import org.togetherjava.tjbot.features.moderation.attachment.BlacklistedAttachmentListener; import org.togetherjava.tjbot.features.moderation.audit.AuditCommand; import org.togetherjava.tjbot.features.moderation.audit.ModAuditLogRoutine; @@ -76,9 +100,9 @@ public static Collection createFeatures(JDA jda, Database database, Con ModerationActionsStore actionsStore = new ModerationActionsStore(database); ModAuditLogWriter modAuditLogWriter = new ModAuditLogWriter(config); ScamHistoryStore scamHistoryStore = new ScamHistoryStore(database); - HelpSystemHelper helpSystemHelper = new HelpSystemHelper(config, database); CodeMessageHandler codeMessageHandler = new CodeMessageHandler(jshellEval); ChatGptService chatGptService = new ChatGptService(config); + HelpSystemHelper helpSystemHelper = new HelpSystemHelper(config, database, chatGptService); // NOTE The system can add special system relevant commands also by itself, // hence this list may not necessarily represent the full list of all commands actually diff --git a/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/AIResponseParser.java b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/AIResponseParser.java new file mode 100644 index 0000000000..9dce43ff1c --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/AIResponseParser.java @@ -0,0 +1,82 @@ +package org.togetherjava.tjbot.features.chatgpt; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Represents a class to partition long text blocks into smaller blocks which work with Discord's + * API. Initially constructed to partition text from AI text generation APIs. + */ +public class AIResponseParser { + private AIResponseParser() { + throw new UnsupportedOperationException("Utility class, construction not supported"); + } + + private static final Logger logger = LoggerFactory.getLogger(AIResponseParser.class); + private static final int RESPONSE_LENGTH_LIMIT = 2_000; + + /** + * Parses the response generated by AI. If response is longer than + * {@value RESPONSE_LENGTH_LIMIT}, then breaks apart the response into suitable lengths for + * Discords API. + * + * @param response The response from the AI which we want to send over Discord. + * @return An array potentially holding the original response split up into shorter than + * {@value RESPONSE_LENGTH_LIMIT} length pieces. + */ + public static String[] parse(String response) { + String[] partedResponse = new String[] {response}; + if (response.length() > RESPONSE_LENGTH_LIMIT) { + logger.debug("Response to parse:\n{}", response); + partedResponse = partitionAiResponse(response); + } + + return partedResponse; + } + + private static String[] partitionAiResponse(String response) { + List responseChunks = new ArrayList<>(); + String[] splitResponseOnMarks = response.split("```"); + + for (int i = 0; i < splitResponseOnMarks.length; i++) { + String split = splitResponseOnMarks[i]; + List chunks = new ArrayList<>(); + chunks.add(split); + + // Check each chunk for correct length. If over the length, split in two and check + // again. + while (!chunks.stream().allMatch(s -> s.length() < RESPONSE_LENGTH_LIMIT)) { + for (int j = 0; j < chunks.size(); j++) { + String chunk = chunks.get(j); + if (chunk.length() > RESPONSE_LENGTH_LIMIT) { + int midpointNewline = chunk.lastIndexOf("\n", chunk.length() / 2); + chunks.set(j, chunk.substring(0, midpointNewline)); + chunks.add(j + 1, chunk.substring(midpointNewline)); + } + } + } + + // Given the splitting on ```, the odd numbered entries need to have code marks + // restored. + if (i % 2 != 0) { + // We assume that everything after the ``` on the same line is the language + // declaration. Could be empty. + String lang = split.substring(0, split.indexOf(System.lineSeparator())); + chunks = chunks.stream() + .map(s -> ("```" + lang).concat(s).concat("```")) + // Handle case of doubling language declaration + .map(s -> s.replaceFirst("```" + lang + lang, "```" + lang)) + .collect(Collectors.toList()); + } + + List list = chunks.stream().filter(string -> !string.equals("")).toList(); + responseChunks.addAll(list); + } // end of for loop. + + return responseChunks.toArray(new String[0]); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/chaptgpt/ChatGptCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptCommand.java similarity index 84% rename from application/src/main/java/org/togetherjava/tjbot/features/chaptgpt/ChatGptCommand.java rename to application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptCommand.java index e53a34391d..2f06384d90 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/chaptgpt/ChatGptCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptCommand.java @@ -1,4 +1,4 @@ -package org.togetherjava.tjbot.features.chaptgpt; +package org.togetherjava.tjbot.features.chatgpt; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; @@ -22,6 +22,7 @@ * which it will respond with an AI generated answer. */ public final class ChatGptCommand extends SlashCommandAdapter { + public static final String COMMAND_NAME = "chatgpt"; private static final String QUESTION_INPUT = "question"; private static final int MAX_MESSAGE_INPUT_LENGTH = 200; private static final int MIN_MESSAGE_INPUT_LENGTH = 4; @@ -37,7 +38,7 @@ public final class ChatGptCommand extends SlashCommandAdapter { * @param chatGptService ChatGptService - Needed to make calls to ChatGPT API */ public ChatGptCommand(ChatGptService chatGptService) { - super("chatgpt", "Ask the ChatGPT AI a question!", CommandVisibility.GUILD); + super(COMMAND_NAME, "Ask the ChatGPT AI a question!", CommandVisibility.GUILD); this.chatGptService = chatGptService; } @@ -73,14 +74,20 @@ public void onSlashCommand(SlashCommandInteractionEvent event) { public void onModalSubmitted(ModalInteractionEvent event, List args) { event.deferReply().queue(); - Optional optional = + Optional optional = chatGptService.ask(event.getValue(QUESTION_INPUT).getAsString()); if (optional.isPresent()) { userIdToAskedAtCache.put(event.getMember().getId(), Instant.now()); } - String response = optional.orElse( - "An error has occurred while trying to communicate with ChatGPT. Please try again later"); - event.getHook().sendMessage(response).queue(); + String[] errorResponse = {""" + An error has occurred while trying to communicate with ChatGPT. + Please try again later. + """}; + + String[] response = optional.orElse(errorResponse); + for (String message : response) { + event.getHook().sendMessage(message).queue(); + } } } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/chaptgpt/ChatGptService.java b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptService.java similarity index 65% rename from application/src/main/java/org/togetherjava/tjbot/features/chaptgpt/ChatGptService.java rename to application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptService.java index 11d7055a28..11f057e296 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/chaptgpt/ChatGptService.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/ChatGptService.java @@ -1,4 +1,4 @@ -package org.togetherjava.tjbot.features.chaptgpt; +package org.togetherjava.tjbot.features.chatgpt; import com.theokanning.openai.OpenAiHttpException; import com.theokanning.openai.completion.chat.ChatCompletionRequest; @@ -22,12 +22,13 @@ public class ChatGptService { private static final Logger logger = LoggerFactory.getLogger(ChatGptService.class); private static final Duration TIMEOUT = Duration.ofSeconds(90); private static final int MAX_TOKENS = 3_000; + private static final String AI_MODEL = "gpt-3.5-turbo"; private boolean isDisabled = false; private final OpenAiService openAiService; /** * Creates instance of ChatGPTService - * + * * @param config needed for token to OpenAI API. */ public ChatGptService(Config config) { @@ -37,17 +38,34 @@ public ChatGptService(Config config) { } openAiService = new OpenAiService(apiKey, TIMEOUT); + + ChatMessage setupMessage = new ChatMessage(ChatMessageRole.SYSTEM.value(), + """ + Please answer questions in 1500 characters or less. Remember to count spaces in the + character limit. For code supplied for review, refer to the old code supplied rather than + rewriting the code. Don't supply a corrected version of the code.\s"""); + ChatCompletionRequest systemSetupRequest = ChatCompletionRequest.builder() + .model(AI_MODEL) + .messages(List.of(setupMessage)) + .frequencyPenalty(0.5) + .temperature(0.3) + .maxTokens(50) + .n(1) + .build(); + + // Sending the system setup message to ChatGPT. + openAiService.createChatCompletion(systemSetupRequest); } /** * Prompt ChatGPT with a question and receive a response. - * + * * @param question The question being asked of ChatGPT. Max is {@value MAX_TOKENS} tokens. + * @return partitioned response from ChatGPT as a String array. * @see ChatGPT * Tokens. - * @return response from ChatGPT as a String. */ - public Optional ask(String question) { + public Optional ask(String question) { if (isDisabled) { return Optional.empty(); } @@ -56,18 +74,25 @@ public Optional ask(String question) { ChatMessage chatMessage = new ChatMessage(ChatMessageRole.USER.value(), Objects.requireNonNull(question)); ChatCompletionRequest chatCompletionRequest = ChatCompletionRequest.builder() - .model("gpt-3.5-turbo") + .model(AI_MODEL) .messages(List.of(chatMessage)) .frequencyPenalty(0.5) - .temperature(0.7) + .temperature(0.3) .maxTokens(MAX_TOKENS) .n(1) .build(); - return Optional.ofNullable(openAiService.createChatCompletion(chatCompletionRequest) + + String response = openAiService.createChatCompletion(chatCompletionRequest) .getChoices() .get(0) .getMessage() - .getContent()); + .getContent(); + + if (response == null) { + return Optional.empty(); + } + + return Optional.of(AIResponseParser.parse(response)); } catch (OpenAiHttpException openAiHttpException) { logger.warn( "There was an error using the OpenAI API: {} Code: {} Type: {} Status Code: {}", diff --git a/application/src/main/java/org/togetherjava/tjbot/features/chaptgpt/package-info.java b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/package-info.java similarity index 84% rename from application/src/main/java/org/togetherjava/tjbot/features/chaptgpt/package-info.java rename to application/src/main/java/org/togetherjava/tjbot/features/chatgpt/package-info.java index 76c1d28638..37d1d88d99 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/chaptgpt/package-info.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/chatgpt/package-info.java @@ -3,7 +3,7 @@ */ @MethodsReturnNonnullByDefault @ParametersAreNonnullByDefault -package org.togetherjava.tjbot.features.chaptgpt; +package org.togetherjava.tjbot.features.chatgpt; import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault; diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java index 1bae9c6b1d..1fa5dcaf7c 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java @@ -24,13 +24,22 @@ import org.togetherjava.tjbot.db.Database; import org.togetherjava.tjbot.db.generated.tables.HelpThreads; import org.togetherjava.tjbot.db.generated.tables.records.HelpThreadsRecord; -import org.togetherjava.tjbot.features.utils.MessageUtils; +import org.togetherjava.tjbot.features.chatgpt.ChatGptCommand; +import org.togetherjava.tjbot.features.chatgpt.ChatGptService; import javax.annotation.Nullable; -import java.awt.Color; +import java.awt.*; import java.io.InputStream; -import java.util.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Predicate; @@ -38,6 +47,8 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; +import static org.togetherjava.tjbot.features.utils.MessageUtils.mentionGuildSlashCommand; + /** * Helper class offering certain methods used by the help system. */ @@ -59,16 +70,23 @@ public final class HelpSystemHelper { private final Set threadActivityTagNames; private final String categoryRoleSuffix; private final Database database; + private final ChatGptService chatGptService; + private static final int MAX_QUESTION_LENGTH = 200; + private static final int MIN_QUESTION_LENGTH = 10; + private static final String CHATGPT_FAILURE_MESSAGE = + "You can use %s to ask ChatGPT about your question while you wait for a human to respond."; /** * Creates a new instance. * * @param config the config to use * @param database the database to store help thread metadata in + * @param chatGptService the service used to ask ChatGPT questions via the API. */ - public HelpSystemHelper(Config config, Database database) { + public HelpSystemHelper(Config config, Database database, ChatGptService chatGptService) { HelpSystemConfig helpConfig = config.getHelpSystem(); this.database = database; + this.chatGptService = chatGptService; helpForumPattern = helpConfig.getHelpForumPattern(); isHelpForumName = Pattern.compile(helpForumPattern).asMatchPredicate(); @@ -91,11 +109,10 @@ public HelpSystemHelper(Config config, Database database) { } RestAction sendExplanationMessage(GuildMessageChannel threadChannel) { - return MessageUtils - .mentionGuildSlashCommand(threadChannel.getGuild(), HelpThreadCommand.COMMAND_NAME, - HelpThreadCommand.Subcommand.CLOSE.getCommandName()) - .flatMap(closeCommandMention -> sendExplanationMessage(threadChannel, - closeCommandMention)); + return mentionGuildSlashCommand(threadChannel.getGuild(), HelpThreadCommand.COMMAND_NAME, + HelpThreadCommand.Subcommand.CLOSE.getCommandName()) + .flatMap(closeCommandMention -> sendExplanationMessage(threadChannel, + closeCommandMention)); } private RestAction sendExplanationMessage(GuildMessageChannel threadChannel, @@ -131,6 +148,90 @@ private RestAction sendExplanationMessage(GuildMessageChannel threadCha return action.setEmbeds(embeds); } + /** + * Determine between the title of the thread and the first message which to send to the AI. It + * uses a simple heuristic of length to determine if enough context exists in a question. If the + * title is used, it must also include a question mark since the title is often used more as an + * indicator of topic versus a question. + * + * @param originalQuestion The first message of the thread which originates from the question + * asker. + * @param threadChannel The thread in which the question was asked. + * @return An answer for the user from the AI service or a message indicating either an error or + * why the message wasn't used. + */ + RestAction constructChatGptAttempt(ThreadChannel threadChannel, + String originalQuestion) { + Optional questionOptional = prepareChatGptQuestion(threadChannel, originalQuestion); + Optional chatGPTAnswer; + + if (questionOptional.isEmpty()) { + return useChatGptFallbackMessage(threadChannel); + } + String question = questionOptional.get(); + logger.debug("The final question sent to chatGPT: {}", question); + logger.info("The final question sent to chatGPT: {}", question); + + chatGPTAnswer = chatGptService.ask(question); + if (chatGPTAnswer.isEmpty()) { + return useChatGptFallbackMessage(threadChannel); + } + + RestAction message = + mentionGuildSlashCommand(threadChannel.getGuild(), ChatGptCommand.COMMAND_NAME) + .map(""" + Here is an AI assisted attempt to answer your question 🤖. Maybe it helps! \ + In any case, a human is on the way 👍. To continue talking to the AI, you can use \ + %s. + """::formatted) + .flatMap(threadChannel::sendMessage); + + for (String aiResponse : chatGPTAnswer.get()) { + message = message.map(aiResponse::formatted).flatMap(threadChannel::sendMessage); + } + + return message; + } + + private Optional prepareChatGptQuestion(ThreadChannel threadChannel, + String originalQuestion) { + String questionTitle = threadChannel.getName(); + StringBuilder questionBuilder = new StringBuilder(MAX_QUESTION_LENGTH); + + if (originalQuestion.length() < MIN_QUESTION_LENGTH + && questionTitle.length() < MIN_QUESTION_LENGTH) { + return Optional.empty(); + } + + questionBuilder.append(questionTitle).append(" "); + originalQuestion = originalQuestion.substring(0, Math + .min(MAX_QUESTION_LENGTH - questionBuilder.length(), originalQuestion.length())); + + questionBuilder.append(originalQuestion); + + StringBuilder tagBuilder = new StringBuilder(); + int stringLength = questionBuilder.length(); + for (ForumTag tag : threadChannel.getAppliedTags()) { + String tagName = tag.getName(); + stringLength += tagName.length(); + if (stringLength > MAX_QUESTION_LENGTH) { + break; + } + tagBuilder.append(String.format("%s ", tagName)); + } + + questionBuilder.insert(0, tagBuilder); + + return Optional.of(questionBuilder.toString()); + } + + private RestAction useChatGptFallbackMessage(ThreadChannel threadChannel) { + logger.warn("Something went wrong while trying to communicate with the ChatGpt API"); + return mentionGuildSlashCommand(threadChannel.getGuild(), ChatGptCommand.COMMAND_NAME) + .map(CHATGPT_FAILURE_MESSAGE::formatted) + .flatMap(threadChannel::sendMessage); + } + void writeHelpThreadToDatabase(long authorId, ThreadChannel threadChannel) { database.write(content -> { HelpThreadsRecord helpThreadsRecord = content.newRecord(HelpThreads.HELP_THREADS) diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadCreatedListener.java b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadCreatedListener.java index f707ad5821..b38d35fcc1 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadCreatedListener.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadCreatedListener.java @@ -77,6 +77,7 @@ private void handleHelpThreadCreated(ThreadChannel threadChannel) { Runnable createMessages = () -> { try { createMessages(threadChannel).queue(); + createAIResponse(threadChannel).queue(); } catch (Exception e) { logger.error( "Unknown error while creating messages after help-thread ({}) creation", @@ -90,6 +91,13 @@ private void handleHelpThreadCreated(ThreadChannel threadChannel) { SERVICE.schedule(createMessages, 5, TimeUnit.SECONDS); } + private RestAction createAIResponse(ThreadChannel threadChannel) { + RestAction originalQuestion = + threadChannel.retrieveMessageById(threadChannel.getIdLong()); + return originalQuestion.flatMap( + message -> helper.constructChatGptAttempt(threadChannel, message.getContentRaw())); + } + private RestAction createMessages(ThreadChannel threadChannel) { return sendHelperHeadsUp(threadChannel).flatMap(Message::pin) .flatMap(any -> helper.sendExplanationMessage(threadChannel)); diff --git a/application/src/test/java/org/togetherjava/tjbot/features/chatgpt/AIResponseParserTest.java b/application/src/test/java/org/togetherjava/tjbot/features/chatgpt/AIResponseParserTest.java new file mode 100644 index 0000000000..715dc14f0c --- /dev/null +++ b/application/src/test/java/org/togetherjava/tjbot/features/chatgpt/AIResponseParserTest.java @@ -0,0 +1,46 @@ +package org.togetherjava.tjbot.features.chatgpt; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Objects; + +class AIResponseParserTest { + private static final Logger logger = LoggerFactory.getLogger(AIResponseParserTest.class); + + @ParameterizedTest + @ValueSource(ints = {1, 2, 3, 4}) + void correctResponseLength(int fileNumber) { + try (InputStream in = getClass().getClassLoader() + .getResourceAsStream("AITestResponses/test" + fileNumber + ".txt")) { + String response = new String(Objects.requireNonNull(in).readAllBytes()); + String[] aiResponse = AIResponseParser.parse(response); + + testResponseLength(aiResponse); + toLog(aiResponse); + } catch (IOException | NullPointerException ex) { + logger.error("{}", ex.getMessage()); + Assertions.fail(); + } + } + + private void testResponseLength(String[] responses) { + int AI_RESPONSE_CHARACTER_LIMIT = 2000; + for (String response : responses) { + Assertions.assertTrue(response.length() <= AI_RESPONSE_CHARACTER_LIMIT, + "Response length is NOT within character limit: " + response.length()); + logger.warn("Response length was: {}", response.length()); + } + } + + private void toLog(String[] responses) { + for (String response : responses) { + logger.info(response); + } + } +} diff --git a/application/src/test/resources/AITestResponses/test1.txt b/application/src/test/resources/AITestResponses/test1.txt new file mode 100644 index 0000000000..beda6295c5 --- /dev/null +++ b/application/src/test/resources/AITestResponses/test1.txt @@ -0,0 +1,71 @@ +Jackson is a popular Java library for JSON processing. It provides a simple and efficient way to convert Java objects to JSON and vice versa. In this tutorial, we will learn how to set up Jackson in a Java project and use it to serialize and deserialize JSON data. + +Step 1: Add Jackson Dependency + +The first step is to add the Jackson dependency to your project. You can do this by adding the following code to your build.gradle file: + +``` +dependencies { + implementation 'com.fasterxml.jackson.core:jackson-databind:2.12.3' +} +``` + +Step 2: Create Java Objects + +Next, we need to create some Java objects that we want to serialize and deserialize as JSON. For example, let's create a simple Person class: + +``` +public class Person { + private String name; + private int age; + + public Person(String name, int age) { + this.name = name; + this.age = age; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public int getAge() { + return age; + } + + public void setAge(int age) { + this.age = age; + } +} +``` + +Step 3: Serialize Java Object to JSON + +To serialize a Java object as JSON, we need to create an ObjectMapper object from the com.fasterxml.jackson.databind.ObjectMapper class and call its writeValueAsString() method. + +``` +ObjectMapper objectMapper = new ObjectMapper(); +Person person = new Person("John Doe", 30); +String json = objectMapper.writeValueAsString(person); +System.out.println(json); // {"name":"John Doe","age":30} +``` + +Step 4: Deserialize JSON to Java Object + +To deserialize JSON data into a Java object, we need to call the readValue() method of the ObjectMapper class. + +``` +String json = "{\"name\":\"John Doe\",\"age\":30}"; +Person person = objectMapper.readValue(json, Person.class); +System.out.println(person.getName()); // John Doe +System.out.println(person.getAge()); // 30 +``` + +Conclusion + +In this tutorial, we learned how to set up Jackson in a Java project and use it to serialize and deserialize JSON data. +Jackson is a powerful library that provides many features for working with JSON data. +With its simple API and efficient performance, it is a great choice for any Java project that needs to work with JSON data. \ No newline at end of file diff --git a/application/src/test/resources/AITestResponses/test2.txt b/application/src/test/resources/AITestResponses/test2.txt new file mode 100644 index 0000000000..62c70602e5 --- /dev/null +++ b/application/src/test/resources/AITestResponses/test2.txt @@ -0,0 +1,63 @@ +Sure, I can help you with setting up a Docker reverse proxy with Nginx. Here's a step-by-step guide: + +Step 1: Install Docker and Docker Compose +Make sure you have Docker and Docker Compose installed on your system. You can follow the official documentation for installation instructions. + +Step 2: Create a new directory +Create a new directory where you will store your Nginx configuration files. For example, create a directory called "nginx-proxy" in your home directory. + +Step 3: Create the Nginx configuration file +Inside the "nginx-proxy" directory, create a file called "default.conf" and open it in a text editor. This file will contain the reverse proxy configuration. + +Here's an example of a basic Nginx reverse proxy configuration: + +``` +server { + listen 80; + server_name yourdomain.com; + + location / { + proxy_pass http://your-app-container:port; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } +} +``` + +Replace "yourdomain.com" with your actual domain name or IP address. Replace "your-app-container" with the name of your Docker container running the application you want to proxy. Replace "port" with the port number on which your application is running inside the container. + +Save and close the file. + +Step 4: Create a Docker Compose file +Inside the "nginx-proxy" directory, create another file called "docker-compose.yml" and open it in a text editor. This file will define the services required for running Nginx as a reverse proxy. + +Here's an example of a basic Docker Compose configuration: + +``` +version: '3' +services: + nginx: + image: nginx + ports: + - 80:80 + volumes: + - ./default.conf:/etc/nginx/conf.d/default.conf + restart: always +``` + +Save and close the file. + +Step 5: Start the Docker containers +Open a terminal or command prompt, navigate to the "nginx-proxy" directory, and run the following command to start the Docker containers: + +``` +docker-compose up -d +``` + +This will start the Nginx +container as a reverse proxy using the configuration specified in the "default.conf" file. + +Step 6: Test the reverse proxy +Assuming your DNS or hosts file is properly configured, you should now be able to access your application through the reverse proxy. Open a web browser and enter your domain name or IP address. The request will be forwarded to your application running inside the Docker container. + +That's it! You have successfully set up a Docker reverse proxy with Nginx. You can add more server blocks in the "default.conf" file to configure additional reverse proxies for different applications if needed \ No newline at end of file diff --git a/application/src/test/resources/AITestResponses/test3.txt b/application/src/test/resources/AITestResponses/test3.txt new file mode 100644 index 0000000000..8c58a44f50 --- /dev/null +++ b/application/src/test/resources/AITestResponses/test3.txt @@ -0,0 +1,80 @@ +```java +import javax.swing.*; +import java.awt.*; +import java.awt.event.KeyEvent; +import java.awt.event.KeyListener; + +public class Game extends JFrame implements KeyListener { + private int characterX = 50; + private int characterY = 200; + private int obstacleX = 600; + private int obstacleY = 200; + + public Game() { + setTitle("Game"); + setSize(800, 400); + setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); + setResizable(false); + setLocationRelativeTo(null); + + addKeyListener(this); + + setVisible(true); + } + + public void paint(Graphics g) { + super.paint(g); + + g.setColor(Color.BLACK); + g.fillRect(characterX, characterY, 50, 50); // Draw character + + g.setColor(Color.RED); + g.fillRect(obstacleX, obstacleY, 20, 100); // Draw obstacle + } + + public void update() { + characterX += 5; // Move character horizontally + + if (characterX >= obstacleX && characterX <= obstacleX + 20 && characterY >= obstacleY && characterY <= obstacleY + 100) { + System.out.println("Game Over!"); + System.exit(0); + } + + if (characterX > getWidth()) { + characterX = -50; // Reset character position + obstacleY = (int) (Math.random() * getHeight()); // Randomize obstacle position + } + + repaint(); + } + + public static void main(String[] args) { + Game game = new Game(); + + while (true) { + game.update(); + + try { + Thread.sleep(10); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + } + + @Override + public void keyTyped(KeyEvent e) {} + + @Override + public void keyPressed(KeyEvent e) { + if (e.getKeyCode() == KeyEvent.VK_UP && characterY > 0) { + characterY -= 10; // Move character up + } else if (e.getKeyCode() == KeyEvent.VK_DOWN && characterY < getHeight() - 50) { + characterY += 10; // Move character down + } + } + + @Override + public void keyReleased(KeyEvent e) {} +} +``` \ No newline at end of file diff --git a/application/src/test/resources/AITestResponses/test4.txt b/application/src/test/resources/AITestResponses/test4.txt new file mode 100644 index 0000000000..5373c0ea84 --- /dev/null +++ b/application/src/test/resources/AITestResponses/test4.txt @@ -0,0 +1,27 @@ +Software engineering is a multidisciplinary field that encompasses various aspects of developing, designing, testing, and maintaining software systems. It involves the application of engineering principles and practices to create reliable, efficient, and high-quality software solutions. Let's delve into the different aspects of software engineering in detail: + +1. Software Development Life Cycle (SDLC): The SDLC is a systematic approach to software development that consists of several phases including requirements gathering, system design, coding, testing, deployment, and maintenance. Each phase has its own set of activities and deliverables to ensure the successful development of software. + +2. Requirements Engineering: This phase involves understanding and documenting the needs and expectations of stakeholders. It includes gathering functional and non-functional requirements, analyzing them for feasibility, prioritizing them, and creating a comprehensive requirements specification document. + +3. System Design: In this phase, the overall architecture and structure of the software system are defined. It includes creating high-level designs that outline the system components, their interactions, data flow diagrams, database schemas, user interfaces, etc. + +4. Coding: The coding phase involves writing the actual source code based on the design specifications. It requires expertise in programming languages such as Java, C++, Python or JavaScript to implement algorithms and logic to achieve desired functionality. + +5. Testing: This aspect ensures that the developed software meets quality standards by identifying defects or bugs through various testing techniques such as unit testing (testing individual components), integration testing (testing interactions between components), system testing (testing entire system functionality), performance testing (evaluating system performance under load), etc. + +6. Software Configuration Management: This aspect deals with managing changes to software artifacts throughout their lifecycle. It includes version control systems (e.g., Git), build automation tools (e.g., Jenkins), release management processes to ensure proper tracking and control over changes made during development. + +7. Software Maintenance: After deployment, software requires ongoing maintenance to fix bugs or issues, enhance functionality, and adapt to changing requirements. This includes activities like bug fixing, patching, performance optimization, and software updates. + +8. Software Quality Assurance: This aspect focuses on ensuring that the software meets specified quality standards. It involves defining quality metrics, conducting code reviews, performing audits, and implementing quality control processes to identify and rectify defects. + +9. Software Project Management: Effective project management is crucial for successful software development. It involves planning, organizing, coordinating resources (human, financial), setting milestones and deadlines, managing risks, and ensuring timely delivery of high-quality software. + +10. Software Documentation: Comprehensive documentation is essential for understanding the software system's design, functionality, and usage. It includes requirements documents, design specifications, user manuals, API documentation, and other technical documents to aid developers and users. + +11. Software Engineering Ethics: Ethical considerations are important in software engineering to ensure responsible behavior towards stakeholders. This includes protecting user privacy and data security, adhering to legal regulations (e.g., GDPR), avoiding conflicts of interest or bias in decision-making processes. + +12. Software Engineering Tools: Various tools support different aspects of software engineering such as Integrated Development Environments (IDEs) like Visual Studio or Eclipse for coding; project management tools like Jira or Trello for tracking tasks; testing frameworks like JUnit or Selenium for automated testing; collaboration tools like Slack or Microsoft Teams for communication among team members. + +These aspects collectively contribute to the successful development of reliable software systems that meet user requirements while adhering to industry best practices and ethical standards. \ No newline at end of file From b5730bcc0b261392df3fbfeaf0152a1403d26de4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Sep 2023 10:45:05 +0200 Subject: [PATCH 24/33] Bump org.flywaydb:flyway-core from 9.21.0 to 9.22.0 (#880) Bumps [org.flywaydb:flyway-core](https://github.com/flyway/flyway) from 9.21.0 to 9.22.0. - [Release notes](https://github.com/flyway/flyway/releases) - [Commits](https://github.com/flyway/flyway/compare/flyway-9.21.0...flyway-9.22.0) --- updated-dependencies: - dependency-name: org.flywaydb:flyway-core 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 0a7af61b10..7efbb4583d 100644 --- a/database/build.gradle +++ b/database/build.gradle @@ -7,7 +7,7 @@ var sqliteVersion = "3.42.0.0" dependencies { implementation 'com.google.code.findbugs:jsr305:3.0.2' implementation "org.xerial:sqlite-jdbc:${sqliteVersion}" - implementation 'org.flywaydb:flyway-core:9.21.0' + implementation 'org.flywaydb:flyway-core:9.22.0' implementation "org.jooq:jooq:$jooqVersion" implementation project(':utils') From 01ffaaeb6514cc9c54c04a80cc989bb09a5dbaf0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Sep 2023 10:45:18 +0200 Subject: [PATCH 25/33] Bump gradle.plugin.org.flywaydb:gradle-plugin-publishing (#879) Bumps gradle.plugin.org.flywaydb:gradle-plugin-publishing from 9.21.0 to 9.22.0. --- updated-dependencies: - dependency-name: gradle.plugin.org.flywaydb:gradle-plugin-publishing 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> --- buildSrc/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/buildSrc/build.gradle b/buildSrc/build.gradle index 43a10c37a6..fd7f55577c 100644 --- a/buildSrc/build.gradle +++ b/buildSrc/build.gradle @@ -7,6 +7,6 @@ repositories { } dependencies { - implementation "gradle.plugin.org.flywaydb:gradle-plugin-publishing:9.21.0" + implementation "gradle.plugin.org.flywaydb:gradle-plugin-publishing:9.22.0" implementation 'nu.studer:gradle-jooq-plugin:8.2' } From 5874437451a44ff18182905699d0def82068bb4c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Sep 2023 10:45:31 +0200 Subject: [PATCH 26/33] Bump org.xerial:sqlite-jdbc from 3.42.0.0 to 3.43.0.0 (#878) Bumps [org.xerial:sqlite-jdbc](https://github.com/xerial/sqlite-jdbc) from 3.42.0.0 to 3.43.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.42.0.0...3.43.0.0) --- updated-dependencies: - dependency-name: org.xerial:sqlite-jdbc 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 5050f0852a..dda761af2e 100644 --- a/application/build.gradle +++ b/application/build.gradle @@ -1,6 +1,6 @@ buildscript { dependencies { - classpath 'org.xerial:sqlite-jdbc:3.42.0.0' + classpath 'org.xerial:sqlite-jdbc:3.43.0.0' } } diff --git a/database/build.gradle b/database/build.gradle index 7efbb4583d..55c940a648 100644 --- a/database/build.gradle +++ b/database/build.gradle @@ -2,7 +2,7 @@ plugins { id 'java' } -var sqliteVersion = "3.42.0.0" +var sqliteVersion = "3.43.0.0" dependencies { implementation 'com.google.code.findbugs:jsr305:3.0.2' From 07292e60eef3cf0f0b4cc1e036fa6a0dba73e0be Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Sep 2023 10:46:04 +0200 Subject: [PATCH 27/33] Bump com.diffplug.spotless from 6.20.0 to 6.21.0 (#877) Bumps com.diffplug.spotless from 6.20.0 to 6.21.0. --- updated-dependencies: - dependency-name: com.diffplug.spotless 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 e511f8be55..d30d7a522e 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id "com.diffplug.spotless" version "6.20.0" + id "com.diffplug.spotless" version "6.21.0" id "org.sonarqube" version "4.3.0.3225" id "name.remal.sonarlint" version "3.3.0" } From b90fce6ba451805f5af4caa87739febbc05673d8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 Sep 2023 10:46:23 +0200 Subject: [PATCH 28/33] Bump chatGPTVersion from 0.14.0 to 0.16.0 (#876) Bumps `chatGPTVersion` from 0.14.0 to 0.16.0. Updates `com.theokanning.openai-gpt3-java:api` from 0.14.0 to 0.16.0 - [Release notes](https://github.com/theokanning/openai-java/releases) - [Commits](https://github.com/theokanning/openai-java/compare/0.14.0...0.16.0) Updates `com.theokanning.openai-gpt3-java:service` from 0.14.0 to 0.16.0 - [Release notes](https://github.com/theokanning/openai-java/releases) - [Commits](https://github.com/theokanning/openai-java/compare/0.14.0...0.16.0) --- updated-dependencies: - dependency-name: com.theokanning.openai-gpt3-java:api dependency-type: direct:production update-type: version-update:semver-minor - dependency-name: com.theokanning.openai-gpt3-java:service 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 d30d7a522e..1e648c367b 100644 --- a/build.gradle +++ b/build.gradle @@ -11,7 +11,7 @@ version '1.0-SNAPSHOT' ext { jooqVersion = '3.18.0' jacksonVersion = '2.15.0' - chatGPTVersion = '0.14.0' + chatGPTVersion = '0.16.0' } // Skips sonarlint during the build, useful for testing purposes. From d1da70884cb46ac8b63dcf38a30ea46938c9e2d2 Mon Sep 17 00:00:00 2001 From: Tanish Date: Mon, 11 Sep 2023 23:48:58 +0530 Subject: [PATCH 29/33] use - in channel names (#853) --- application/config.json.template | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/config.json.template b/application/config.json.template index 02c6027d9e..f41d93472e 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -4,7 +4,7 @@ "databasePath": "local-database.db", "projectWebsite": "https://github.com/Together-Java/TJ-Bot", "discordGuildInvite": "https://discord.com/invite/XXFUXzK", - "modAuditLogChannelPattern": "mod_audit_log", + "modAuditLogChannelPattern": "mod-audit-log", "modMailChannelPattern": "modmail", "mutedRolePattern": "Muted", "heavyModerationRolePattern": "Moderator", @@ -12,7 +12,7 @@ "tagManageRolePattern": "Moderator|Community Ambassador|Top Helpers .+", "excludeCodeAutoDetectionRolePattern": "Top Helpers .+|Moderator|Community Ambassador|Expert", "suggestions": { - "channelPattern": "tj_suggestions", + "channelPattern": "tj-suggestions", "upVoteEmoteName": "peepo_yes", "downVoteEmoteName": "peepo_no" }, From 92e36aa67e8341cb9298c07c4297aa9ab746d65e Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Mon, 18 Sep 2023 09:43:33 +0200 Subject: [PATCH 30/33] typo in config template (missing comma) --- application/config.json.template | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/config.json.template b/application/config.json.template index f41d93472e..0ce8d2c59d 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -89,7 +89,7 @@ "logInfoChannelWebhook": "", "logErrorChannelWebhook": "", "openaiApiKey": "", - "sourceCodeBaseUrl": "//blob/master/application/src/main/java/>" + "sourceCodeBaseUrl": "//blob/master/application/src/main/java/>", "jshell": { "baseUrl": "", "rateLimitWindowSeconds": 10, From e9a7f32c49e8220458d9a0f5ff4866dcc5dd43ba Mon Sep 17 00:00:00 2001 From: Daniel Tischner Date: Mon, 18 Sep 2023 10:35:22 +0200 Subject: [PATCH 31/33] Fixed some ChatGPT issues (#888) * url must not be fake * fixed chatgpt disabling * chatgpt logs too much * answer-attempt should be after explanation messages --- application/config.json.template | 2 +- .../tjbot/features/chatgpt/ChatGptService.java | 7 +++++-- .../togetherjava/tjbot/features/help/HelpSystemHelper.java | 2 -- .../tjbot/features/help/HelpThreadCreatedListener.java | 4 ++-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/application/config.json.template b/application/config.json.template index 0ce8d2c59d..80585e39b4 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -89,7 +89,7 @@ "logInfoChannelWebhook": "", "logErrorChannelWebhook": "", "openaiApiKey": "", - "sourceCodeBaseUrl": "//blob/master/application/src/main/java/>", + "sourceCodeBaseUrl": "https://github.com/Together-Java/TJ-Bot/blob/master/application/src/main/java/", "jshell": { "baseUrl": "", "rateLimitWindowSeconds": 10, 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 11f057e296..24cf088852 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 @@ -23,8 +23,9 @@ public class ChatGptService { private static final Duration TIMEOUT = Duration.ofSeconds(90); private static final int MAX_TOKENS = 3_000; private static final String AI_MODEL = "gpt-3.5-turbo"; + private boolean isDisabled = false; - private final OpenAiService openAiService; + private OpenAiService openAiService; /** * Creates instance of ChatGPTService @@ -33,8 +34,10 @@ public class ChatGptService { */ public ChatGptService(Config config) { String apiKey = config.getOpenaiApiKey(); - if (apiKey.isBlank()) { + boolean keyIsDefaultDescription = apiKey.startsWith("<") && apiKey.endsWith(">"); + if (apiKey.isBlank() || keyIsDefaultDescription) { isDisabled = true; + return; } openAiService = new OpenAiService(apiKey, TIMEOUT); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java index 1fa5dcaf7c..8604ac631b 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpSystemHelper.java @@ -170,7 +170,6 @@ RestAction constructChatGptAttempt(ThreadChannel threadChannel, } String question = questionOptional.get(); logger.debug("The final question sent to chatGPT: {}", question); - logger.info("The final question sent to chatGPT: {}", question); chatGPTAnswer = chatGptService.ask(question); if (chatGPTAnswer.isEmpty()) { @@ -226,7 +225,6 @@ private Optional prepareChatGptQuestion(ThreadChannel threadChannel, } private RestAction useChatGptFallbackMessage(ThreadChannel threadChannel) { - logger.warn("Something went wrong while trying to communicate with the ChatGpt API"); return mentionGuildSlashCommand(threadChannel.getGuild(), ChatGptCommand.COMMAND_NAME) .map(CHATGPT_FAILURE_MESSAGE::formatted) .flatMap(threadChannel::sendMessage); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadCreatedListener.java b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadCreatedListener.java index b38d35fcc1..6bbf80eff7 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadCreatedListener.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/help/HelpThreadCreatedListener.java @@ -77,7 +77,6 @@ private void handleHelpThreadCreated(ThreadChannel threadChannel) { Runnable createMessages = () -> { try { createMessages(threadChannel).queue(); - createAIResponse(threadChannel).queue(); } catch (Exception e) { logger.error( "Unknown error while creating messages after help-thread ({}) creation", @@ -100,7 +99,8 @@ private RestAction createAIResponse(ThreadChannel threadChannel) { private RestAction createMessages(ThreadChannel threadChannel) { return sendHelperHeadsUp(threadChannel).flatMap(Message::pin) - .flatMap(any -> helper.sendExplanationMessage(threadChannel)); + .flatMap(any -> helper.sendExplanationMessage(threadChannel)) + .flatMap(any -> createAIResponse(threadChannel)); } private RestAction sendHelperHeadsUp(ThreadChannel threadChannel) { From 69d4f785e670fd1f0d49ccd5337c1e34d8bad0c8 Mon Sep 17 00:00:00 2001 From: Daniel Tischner Date: Mon, 18 Sep 2023 11:18:57 +0200 Subject: [PATCH 32/33] added helper prune config (#889) --- application/config.json.template | 7 ++++ .../org/togetherjava/tjbot/config/Config.java | 15 ++++++++- .../tjbot/config/HelperPruneConfig.java | 19 +++++++++++ .../features/help/AutoPruneHelperRoutine.java | 32 ++++++++++++------- 4 files changed, 60 insertions(+), 13 deletions(-) create mode 100644 application/src/main/java/org/togetherjava/tjbot/config/HelperPruneConfig.java diff --git a/application/config.json.template b/application/config.json.template index 80585e39b4..5391037110 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -94,5 +94,12 @@ "baseUrl": "", "rateLimitWindowSeconds": 10, "rateLimitRequestsInWindow": 3 + }, + "helperPruneConfig": { + "roleFullLimit": 100, + "roleFullThreshold": 95, + "pruneMemberAmount": 7, + "inactivateAfterDays": 90, + "recentlyJoinedDays": 4 } } 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 f16aed61b4..4697573b06 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -39,6 +39,7 @@ public final class Config { private final String openaiApiKey; private final String sourceCodeBaseUrl; private final JShellConfig jshell; + private final HelperPruneConfig helperPruneConfig; @SuppressWarnings("ConstructorWithTooManyParameters") @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) @@ -76,7 +77,9 @@ private Config(@JsonProperty(value = "token", required = true) String token, required = true) String logErrorChannelWebhook, @JsonProperty(value = "openaiApiKey", required = true) String openaiApiKey, @JsonProperty(value = "sourceCodeBaseUrl", required = true) String sourceCodeBaseUrl, - @JsonProperty(value = "jshell", required = true) JShellConfig jshell) { + @JsonProperty(value = "jshell", required = true) JShellConfig jshell, + @JsonProperty(value = "helperPruneConfig", + required = true) HelperPruneConfig helperPruneConfig) { this.token = Objects.requireNonNull(token); this.gistApiKey = Objects.requireNonNull(gistApiKey); this.databasePath = Objects.requireNonNull(databasePath); @@ -102,6 +105,7 @@ private Config(@JsonProperty(value = "token", required = true) String token, this.openaiApiKey = Objects.requireNonNull(openaiApiKey); this.sourceCodeBaseUrl = Objects.requireNonNull(sourceCodeBaseUrl); this.jshell = Objects.requireNonNull(jshell); + this.helperPruneConfig = Objects.requireNonNull(helperPruneConfig); } /** @@ -342,4 +346,13 @@ public String getSourceCodeBaseUrl() { public JShellConfig getJshell() { return jshell; } + + /** + * Gets the config for automatic pruning of helper roles. + * + * @return the configuration + */ + public HelperPruneConfig getHelperPruneConfig() { + return helperPruneConfig; + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/config/HelperPruneConfig.java b/application/src/main/java/org/togetherjava/tjbot/config/HelperPruneConfig.java new file mode 100644 index 0000000000..6f451b491f --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/config/HelperPruneConfig.java @@ -0,0 +1,19 @@ +package org.togetherjava.tjbot.config; + + +/** + * Config for automatic pruning of helper roles, see + * {@link org.togetherjava.tjbot.features.help.AutoPruneHelperRoutine}. + * + * @param roleFullLimit if a helper role contains that many users, it is considered full and pruning + * must occur + * @param roleFullThreshold if a helper role contains that many users, pruning will start to occur + * to prevent reaching the limit + * @param pruneMemberAmount amount of users to remove from helper roles during a prune + * @param inactivateAfterDays after how many days of inactivity a user is eligible for pruning + * @param recentlyJoinedDays if a user is with the server for just this amount of days, they are + * protected from pruning + */ +public record HelperPruneConfig(int roleFullLimit, int roleFullThreshold, int pruneMemberAmount, + int inactivateAfterDays, int recentlyJoinedDays) { +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/help/AutoPruneHelperRoutine.java b/application/src/main/java/org/togetherjava/tjbot/features/help/AutoPruneHelperRoutine.java index 183dd2ab79..9fcd05e4ef 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/help/AutoPruneHelperRoutine.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/help/AutoPruneHelperRoutine.java @@ -9,6 +9,7 @@ import org.slf4j.LoggerFactory; import org.togetherjava.tjbot.config.Config; +import org.togetherjava.tjbot.config.HelperPruneConfig; import org.togetherjava.tjbot.db.Database; import org.togetherjava.tjbot.features.Routine; import org.togetherjava.tjbot.features.moderation.audit.ModAuditLogWriter; @@ -31,11 +32,11 @@ public final class AutoPruneHelperRoutine implements Routine { private static final Logger logger = LoggerFactory.getLogger(AutoPruneHelperRoutine.class); - private static final int ROLE_FULL_LIMIT = 100; - private static final int ROLE_FULL_THRESHOLD = 95; - private static final int PRUNE_MEMBER_AMOUNT = 7; - private static final Period INACTIVE_AFTER = Period.ofDays(90); - private static final int RECENTLY_JOINED_DAYS = 4; + private final int roleFullLimit; + private final int roleFullThreshold; + private final int pruneMemberAmount; + private final Period inactiveAfter; + private final int recentlyJoinedDays; private final HelpSystemHelper helper; private final ModAuditLogWriter modAuditLogWriter; @@ -56,6 +57,13 @@ public AutoPruneHelperRoutine(Config config, HelpSystemHelper helper, this.helper = helper; this.modAuditLogWriter = modAuditLogWriter; this.database = database; + + HelperPruneConfig helperPruneConfig = config.getHelperPruneConfig(); + roleFullLimit = helperPruneConfig.roleFullLimit(); + roleFullThreshold = helperPruneConfig.roleFullThreshold(); + pruneMemberAmount = helperPruneConfig.pruneMemberAmount(); + inactiveAfter = Period.ofDays(helperPruneConfig.inactivateAfterDays()); + recentlyJoinedDays = helperPruneConfig.recentlyJoinedDays(); } @Override @@ -93,7 +101,7 @@ private void pruneRoleIfFull(Role role, ForumChannel helpForum, Instant when) { } private boolean isRoleFull(Collection members) { - return members.size() >= ROLE_FULL_THRESHOLD; + return members.size() >= roleFullThreshold; } private void pruneRole(Role role, List members, ForumChannel helpForum, @@ -103,18 +111,18 @@ private void pruneRole(Role role, List members, ForumChannel h List membersToPrune = membersShuffled.stream() .filter(member -> isMemberInactive(member, when)) - .limit(PRUNE_MEMBER_AMOUNT) + .limit(pruneMemberAmount) .toList(); - if (membersToPrune.size() < PRUNE_MEMBER_AMOUNT) { + if (membersToPrune.size() < pruneMemberAmount) { warnModsAbout( "Attempting to prune helpers from role **%s** (%d members), but only found %d inactive users. That is less than expected, the category might eventually grow beyond the limit." .formatted(role.getName(), members.size(), membersToPrune.size()), role.getGuild()); } - if (members.size() - membersToPrune.size() >= ROLE_FULL_LIMIT) { + if (members.size() - membersToPrune.size() >= roleFullLimit) { warnModsAbout( "The helper role **%s** went beyond its member limit (%d), despite automatic pruning. It will not function correctly anymore. Please manually prune some users." - .formatted(role.getName(), ROLE_FULL_LIMIT), + .formatted(role.getName(), roleFullLimit), role.getGuild()); } @@ -126,14 +134,14 @@ private void pruneRole(Role role, List members, ForumChannel h private boolean isMemberInactive(Member member, Instant when) { if (member.hasTimeJoined()) { Instant memberJoined = member.getTimeJoined().toInstant(); - if (Duration.between(memberJoined, when).toDays() <= RECENTLY_JOINED_DAYS) { + if (Duration.between(memberJoined, when).toDays() <= recentlyJoinedDays) { // New users are protected from purging to not immediately kick them out of the role // again return false; } } - Instant latestActiveMoment = when.minus(INACTIVE_AFTER); + Instant latestActiveMoment = when.minus(inactiveAfter); // Has no recent help message return database.read(context -> context.fetchCount(HELP_CHANNEL_MESSAGES, From 48230006ec36f9a4afdd386e930d4b0b21f7c8b8 Mon Sep 17 00:00:00 2001 From: Zabuzard Date: Mon, 18 Sep 2023 11:23:45 +0200 Subject: [PATCH 33/33] temporarily disable jshell --- .../togetherjava/tjbot/features/Features.java | 26 +++---------------- .../features/code/CodeMessageHandler.java | 2 +- 2 files changed, 4 insertions(+), 24 deletions(-) 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 58c34ca949..e5186ad087 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -18,32 +18,12 @@ import org.togetherjava.tjbot.features.code.CodeMessageHandler; import org.togetherjava.tjbot.features.code.CodeMessageManualDetection; import org.togetherjava.tjbot.features.filesharing.FileSharingMessageListener; -import org.togetherjava.tjbot.features.help.AutoPruneHelperRoutine; -import org.togetherjava.tjbot.features.help.GuildLeaveCloseThreadListener; -import org.togetherjava.tjbot.features.help.HelpSystemHelper; -import org.togetherjava.tjbot.features.help.HelpThreadActivityUpdater; -import org.togetherjava.tjbot.features.help.HelpThreadAutoArchiver; -import org.togetherjava.tjbot.features.help.HelpThreadCommand; -import org.togetherjava.tjbot.features.help.HelpThreadCreatedListener; -import org.togetherjava.tjbot.features.help.HelpThreadMetadataPurger; -import org.togetherjava.tjbot.features.jshell.JShellCommand; +import org.togetherjava.tjbot.features.help.*; import org.togetherjava.tjbot.features.jshell.JShellEval; import org.togetherjava.tjbot.features.mathcommands.TeXCommand; import org.togetherjava.tjbot.features.mathcommands.wolframalpha.WolframAlphaCommand; import org.togetherjava.tjbot.features.mediaonly.MediaOnlyChannelListener; -import org.togetherjava.tjbot.features.moderation.BanCommand; -import org.togetherjava.tjbot.features.moderation.KickCommand; -import org.togetherjava.tjbot.features.moderation.ModerationActionsStore; -import org.togetherjava.tjbot.features.moderation.MuteCommand; -import org.togetherjava.tjbot.features.moderation.NoteCommand; -import org.togetherjava.tjbot.features.moderation.QuarantineCommand; -import org.togetherjava.tjbot.features.moderation.RejoinModerationRoleListener; -import org.togetherjava.tjbot.features.moderation.ReportCommand; -import org.togetherjava.tjbot.features.moderation.UnbanCommand; -import org.togetherjava.tjbot.features.moderation.UnmuteCommand; -import org.togetherjava.tjbot.features.moderation.UnquarantineCommand; -import org.togetherjava.tjbot.features.moderation.WarnCommand; -import org.togetherjava.tjbot.features.moderation.WhoIsCommand; +import org.togetherjava.tjbot.features.moderation.*; import org.togetherjava.tjbot.features.moderation.attachment.BlacklistedAttachmentListener; import org.togetherjava.tjbot.features.moderation.audit.AuditCommand; import org.togetherjava.tjbot.features.moderation.audit.ModAuditLogRoutine; @@ -171,7 +151,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new ReportCommand(config)); features.add(new BookmarksCommand(bookmarksSystem)); features.add(new ChatGptCommand(chatGptService)); - features.add(new JShellCommand(jshellEval)); + // features.add(new JShellCommand(jshellEval)); return features; } } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/code/CodeMessageHandler.java b/application/src/main/java/org/togetherjava/tjbot/features/code/CodeMessageHandler.java index a26cde9f1c..f4d27e85d5 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/code/CodeMessageHandler.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/code/CodeMessageHandler.java @@ -70,7 +70,7 @@ public CodeMessageHandler(JShellEval jshellEval) { componentIdInteractor = new ComponentIdInteractor(getInteractionType(), getName()); List codeActions = - List.of(new FormatCodeCommand(), new EvalCodeCommand(jshellEval)); + List.of(new FormatCodeCommand()/* , new EvalCodeCommand(jshellEval) */); labelToCodeAction = codeActions.stream() .collect(Collectors.toMap(CodeAction::getLabel, Function.identity(), (x, y) -> y,