From d31728ac6e0d200501b5af495121e69648f9f5da Mon Sep 17 00:00:00 2001 From: Firas Regaieg Date: Tue, 27 Jan 2026 10:09:38 +0100 Subject: [PATCH 1/4] Added /rewrite for improving a message using AI (#1378) * Feature: Implement /rewrite command for message improvement using ChatGPT * feature "rewrite-msg command": applies changes due to Zabu's first review; * feature "rewrite-msg command": applies changes due to Wazei's first review - Part 1; * feature "rewrite-msg command": applies changes due to Wazei's first review - Part 2; * feature "rewrite-msg command": applies changes due to Wazei's first review - Part 3; * feature "rewrite-msg command": removing Optional and other adjustments * feature "rewrite-msg command" - reply to taz 2nd review; 1. ChatGptService: MAX_TOKENS value updated to 500; 2. RewriteCommand: Adding more precision in Ai Prompt message; * ChatGptService: MAX_TOKENS value updated to 1000; --- .../togetherjava/tjbot/features/Features.java | 2 + .../features/chatgpt/ChatGptService.java | 68 +++++-- .../features/messages/RewriteCommand.java | 186 ++++++++++++++++++ 3 files changed, 239 insertions(+), 17 deletions(-) create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/messages/RewriteCommand.java diff --git a/application/src/main/java/org/togetherjava/tjbot/features/Features.java b/application/src/main/java/org/togetherjava/tjbot/features/Features.java index 9c4f326cc5..6febd433b6 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -41,6 +41,7 @@ import org.togetherjava.tjbot.features.mathcommands.wolframalpha.WolframAlphaCommand; import org.togetherjava.tjbot.features.mediaonly.MediaOnlyChannelListener; import org.togetherjava.tjbot.features.messages.MessageCommand; +import org.togetherjava.tjbot.features.messages.RewriteCommand; import org.togetherjava.tjbot.features.moderation.BanCommand; import org.togetherjava.tjbot.features.moderation.KickCommand; import org.togetherjava.tjbot.features.moderation.ModerationActionsStore; @@ -211,6 +212,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new ChatGptCommand(chatGptService, helpSystemHelper)); features.add(new JShellCommand(jshellEval)); features.add(new MessageCommand()); + features.add(new RewriteCommand(chatGptService)); FeatureBlacklist> blacklist = blacklistConfig.normal(); return blacklist.filterStream(features.stream(), Object::getClass).toList(); 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 02e32cde6e..08ddbee729 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 @@ -24,7 +24,7 @@ public class ChatGptService { private static final Duration TIMEOUT = Duration.ofSeconds(90); /** The maximum number of tokens allowed for the generated answer. */ - private static final int MAX_TOKENS = 3_000; + private static final int MAX_TOKENS = 1000; private boolean isDisabled = false; private OpenAIClient openAIClient; @@ -39,9 +39,11 @@ public ChatGptService(Config config) { boolean keyIsDefaultDescription = apiKey.startsWith("<") && apiKey.endsWith(">"); if (apiKey.isBlank() || keyIsDefaultDescription) { isDisabled = true; + logger.warn("ChatGPT service is disabled: API key is not configured"); return; } openAIClient = OpenAIOkHttpClient.builder().apiKey(apiKey).timeout(TIMEOUT).build(); + logger.info("ChatGPT service initialized successfully"); } /** @@ -56,10 +58,6 @@ public ChatGptService(Config config) { * Tokens. */ public Optional ask(String question, @Nullable String context, ChatGptModel chatModel) { - if (isDisabled) { - return Optional.empty(); - } - String contextText = context == null ? "" : ", Context: %s.".formatted(context); String inputPrompt = """ For code supplied for review, refer to the old code supplied rather than @@ -71,35 +69,71 @@ public Optional ask(String question, @Nullable String context, ChatGptMo Question: %s """.formatted(contextText, question); - logger.debug("ChatGpt request: {}", inputPrompt); + return sendPrompt(inputPrompt, chatModel); + } + + /** + * Prompt ChatGPT with a raw prompt and receive a response without any prefix wrapping. + *

+ * Use this method when you need full control over the prompt structure without the service's + * opinionated formatting (e.g., for iterative refinement or specialized use cases). + * + * @param inputPrompt The raw prompt to send to ChatGPT. Max is {@value MAX_TOKENS} tokens. + * @param chatModel The AI model to use for this request. + * @return response from ChatGPT as a String. + * @see ChatGPT + * Tokens. + */ + public Optional askRaw(String inputPrompt, ChatGptModel chatModel) { + return sendPrompt(inputPrompt, chatModel); + } + + /** + * Sends a prompt to the ChatGPT API and returns the response. + * + * @param prompt The prompt to send to ChatGPT. + * @param chatModel The AI model to use for this request. + * @return response from ChatGPT as a String. + */ + private Optional sendPrompt(String prompt, ChatGptModel chatModel) { + if (isDisabled) { + logger.warn("ChatGPT request attempted but service is disabled"); + return Optional.empty(); + } + + logger.debug("ChatGpt request: {}", prompt); - String response = null; try { ResponseCreateParams params = ResponseCreateParams.builder() .model(chatModel.toChatModel()) - .input(inputPrompt) + .input(prompt) .maxOutputTokens(MAX_TOKENS) .build(); Response chatGptResponse = openAIClient.responses().create(params); - response = chatGptResponse.output() + String response = chatGptResponse.output() .stream() .flatMap(item -> item.message().stream()) .flatMap(message -> message.content().stream()) .flatMap(content -> content.outputText().stream()) .map(ResponseOutputText::text) .collect(Collectors.joining("\n")); - } catch (RuntimeException runtimeException) { - logger.warn("There was an error using the OpenAI API: {}", - runtimeException.getMessage()); - } - logger.debug("ChatGpt Response: {}", response); - if (response == null) { + logger.debug("ChatGpt Response: {}", response); + + if (response.isBlank()) { + logger.warn("ChatGPT returned an empty response"); + return Optional.empty(); + } + + logger.debug("ChatGpt response received successfully, length: {} characters", + response.length()); + return Optional.of(response); + } catch (RuntimeException runtimeException) { + logger.error("Error communicating with OpenAI API: {}", runtimeException.getMessage(), + runtimeException); return Optional.empty(); } - - return Optional.of(response); } } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/messages/RewriteCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/messages/RewriteCommand.java new file mode 100644 index 0000000000..44b143558b --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/messages/RewriteCommand.java @@ -0,0 +1,186 @@ +package org.togetherjava.tjbot.features.messages; + +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.OptionData; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.features.CommandVisibility; +import org.togetherjava.tjbot.features.SlashCommandAdapter; +import org.togetherjava.tjbot.features.chatgpt.ChatGptModel; +import org.togetherjava.tjbot.features.chatgpt.ChatGptService; + +import java.util.Arrays; + +/** + * The implemented command is {@code /rewrite}, which allows users to have their message rewritten + * in a clearer, more professional, or better structured form using AI. + *

+ * The rewritten message is shown as an ephemeral message visible only to the user who triggered the + * command. + *

+ * Users can optionally specify a tone/style for the rewrite. + */ +public final class RewriteCommand extends SlashCommandAdapter { + private static final Logger logger = LoggerFactory.getLogger(RewriteCommand.class); + private static final String COMMAND_NAME = "rewrite"; + private static final String MESSAGE_OPTION = "message"; + private static final String TONE_OPTION = "tone"; + + private static final int MAX_MESSAGE_LENGTH = Message.MAX_CONTENT_LENGTH; + private static final int MIN_MESSAGE_LENGTH = 3; + + private static final String AI_REWRITE_PROMPT_TEMPLATE = """ + You are rewriting a Discord text chat message for clarity and professionalism. + Keep it conversational and casual, not email or formal document format. + + Tone: %s + + Rewrite the message to: + - Improve clarity and structure + - Maintain the original meaning + - Avoid em-dashes (—) + - Stay under %d characters (strict limit) + + If the message is already well-written, make only minor improvements. + + Reply with ONLY the rewritten message, nothing else (greetings, preamble, etc). + + Message to rewrite: + %s + """.stripIndent(); + + private final ChatGptService chatGptService; + + /** + * Creates the slash command definition and configures available options for rewriting messages. + * + * @param chatGptService service for interacting with ChatGPT + */ + public RewriteCommand(ChatGptService chatGptService) { + super(COMMAND_NAME, "Let AI rephrase and improve your message", CommandVisibility.GUILD); + + this.chatGptService = chatGptService; + + OptionData messageOption = + new OptionData(OptionType.STRING, MESSAGE_OPTION, "The message you want to rewrite", + true) + .setMinLength(MIN_MESSAGE_LENGTH) + .setMaxLength(MAX_MESSAGE_LENGTH); + + OptionData toneOption = new OptionData(OptionType.STRING, TONE_OPTION, + "The tone/style for the rewritten message (default: " + + MessageTone.CLEAR.displayName + ")", + false); + + Arrays.stream(MessageTone.values()) + .forEach(tone -> toneOption.addChoice(tone.displayName, tone.name())); + + getData().addOptions(messageOption, toneOption); + } + + @Override + public void onSlashCommand(SlashCommandInteractionEvent event) { + + OptionMapping messageOption = event.getOption(MESSAGE_OPTION); + + if (messageOption == null) { + throw new IllegalArgumentException( + "Required option '" + MESSAGE_OPTION + "' is missing"); + } + + String userMessage = messageOption.getAsString(); + + MessageTone tone = parseTone(event.getOption(TONE_OPTION)); + + event.deferReply(true).queue(); + + String rewrittenMessage = rewrite(userMessage, tone); + + if (rewrittenMessage.isEmpty()) { + logger.debug("Failed to obtain a response for /{}, original message: '{}'", + COMMAND_NAME, userMessage); + + event.getHook() + .editOriginal( + "An error occurred while processing your request. Please try again later.") + .queue(); + + return; + } + + logger.debug("Rewrite successful; rewritten message length: {}", rewrittenMessage.length()); + + event.getHook().sendMessage(rewrittenMessage).setEphemeral(true).queue(); + } + + private MessageTone parseTone(@Nullable OptionMapping toneOption) + throws IllegalArgumentException { + + if (toneOption == null) { + logger.debug("Tone option not provided, using default '{}'", MessageTone.CLEAR.name()); + return MessageTone.CLEAR; + } + + return MessageTone.valueOf(toneOption.getAsString()); + } + + private String rewrite(String userMessage, MessageTone tone) { + + String rewritePrompt = createAiPrompt(userMessage, tone); + + ChatGptModel aiModel = tone.model; + + String attempt = askAi(rewritePrompt, aiModel); + + if (attempt.length() <= MAX_MESSAGE_LENGTH) { + return attempt; + } + + logger.debug("Rewritten message exceeded {} characters; retrying with stricter constraint", + MAX_MESSAGE_LENGTH); + + String shortenPrompt = + """ + %s + + Constraint reminder: Your previous rewrite exceeded %d characters. + Provide a revised rewrite strictly under %d characters while preserving meaning and tone. + """ + .formatted(rewritePrompt, MAX_MESSAGE_LENGTH, MAX_MESSAGE_LENGTH); + + return askAi(shortenPrompt, aiModel); + } + + private String askAi(String shortenPrompt, ChatGptModel aiModel) { + return chatGptService.askRaw(shortenPrompt, aiModel).orElse(""); + } + + private static String createAiPrompt(String userMessage, MessageTone tone) { + return AI_REWRITE_PROMPT_TEMPLATE.formatted(tone.description, MAX_MESSAGE_LENGTH, + userMessage); + } + + private enum MessageTone { + CLEAR("Clear", "Make it clear and easy to understand.", ChatGptModel.FASTEST), + PROFESSIONAL("Professional", "Use a professional and polished tone.", ChatGptModel.FASTEST), + DETAILED("Detailed", "Expand with more detail and explanation.", ChatGptModel.HIGH_QUALITY), + TECHNICAL("Technical", "Use technical and specialized language where appropriate.", + ChatGptModel.HIGH_QUALITY); + + private final String displayName; + private final String description; + private final ChatGptModel model; + + MessageTone(String displayName, String description, ChatGptModel model) { + this.displayName = displayName; + this.description = description; + this.model = model; + } + } + +} From 59de5ee70726c39446d448000bae2baf147e05d0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Jan 2026 09:05:46 +0100 Subject: [PATCH 2/4] build(deps): bump org.flywaydb:flyway-core from 11.20.0 to 12.0.0 (#1401) Bumps [org.flywaydb:flyway-core](https://github.com/flyway/flyway) from 11.20.0 to 12.0.0. - [Release notes](https://github.com/flyway/flyway/releases) - [Commits](https://github.com/flyway/flyway/compare/flyway-11.20.0...flyway-12.0.0) --- updated-dependencies: - dependency-name: org.flywaydb:flyway-core dependency-version: 12.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- database/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/build.gradle b/database/build.gradle index 20f1804a49..8ef3ef97cd 100644 --- a/database/build.gradle +++ b/database/build.gradle @@ -7,7 +7,7 @@ var sqliteVersion = "3.51.0.0" dependencies { implementation 'com.google.code.findbugs:jsr305:3.0.2' implementation "org.xerial:sqlite-jdbc:${sqliteVersion}" - implementation 'org.flywaydb:flyway-core:11.20.0' + implementation 'org.flywaydb:flyway-core:12.0.0' implementation "org.jooq:jooq:$jooqVersion" implementation project(':utils') From db01f72a715fd6f52cbe11f71494a1907a64e1f9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Feb 2026 08:35:52 +0100 Subject: [PATCH 3/4] build(deps): bump com.openai:openai-java from 4.16.0 to 4.17.0 (#1405) Bumps [com.openai:openai-java](https://github.com/openai/openai-java) from 4.16.0 to 4.17.0. - [Release notes](https://github.com/openai/openai-java/releases) - [Changelog](https://github.com/openai/openai-java/blob/main/CHANGELOG.md) - [Commits](https://github.com/openai/openai-java/compare/v4.16.0...v4.17.0) --- updated-dependencies: - dependency-name: com.openai:openai-java dependency-version: 4.17.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index de2caad6c0..1a810fb9af 100644 --- a/build.gradle +++ b/build.gradle @@ -14,7 +14,7 @@ version '1.0-SNAPSHOT' ext { jooqVersion = '3.20.5' jacksonVersion = '2.19.1' - chatGPTVersion = '4.16.0' + chatGPTVersion = '4.17.0' junitVersion = '6.0.0' } From 996431fdde12fdd3efee0157739541b5df9c6e0d Mon Sep 17 00:00:00 2001 From: alphaBEE Date: Tue, 3 Feb 2026 13:26:55 +0530 Subject: [PATCH 4/4] fix: AI message dismiss button (#1403) * fix: AI message dismiss button when message that created thread was deleted, retrieveStartMessage() threw an error UNKNOWN_MESSAGE leading to non-functional dismiss button. this adds a handler, specifically for parent message deleted error by using getIterableHistoryInstead. * refactor: HelpThreadCreatedListener * move AI dismiss fallback consumer to a seperate function for clarity * move variable FIRST_MESSAGE_ONLY to function scope as noOfMessage for clarity * improve error log message for clarity --- .../help/HelpThreadCreatedListener.java | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) 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 cd453eab63..bbf8490a2c 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 @@ -12,8 +12,12 @@ import net.dv8tion.jda.api.entities.channel.forums.ForumTag; import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent; import net.dv8tion.jda.api.events.message.MessageReceivedEvent; +import net.dv8tion.jda.api.exceptions.ErrorResponseException; import net.dv8tion.jda.api.hooks.ListenerAdapter; +import net.dv8tion.jda.api.requests.ErrorResponse; import net.dv8tion.jda.api.requests.RestAction; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.togetherjava.tjbot.features.EventReceiver; import org.togetherjava.tjbot.features.UserInteractionType; @@ -28,8 +32,10 @@ import java.util.List; import java.util.Objects; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; import java.util.stream.Collectors; + /** * Listens for new help threads being created. That is, a user posted a question in the help forum. *

@@ -38,6 +44,7 @@ */ public final class HelpThreadCreatedListener extends ListenerAdapter implements EventReceiver, UserInteractor { + private static final Logger log = LoggerFactory.getLogger(HelpThreadCreatedListener.class); private final HelpSystemHelper helper; private final Cache threadIdToCreatedAtCache = Caffeine.newBuilder() @@ -159,6 +166,25 @@ public void acceptComponentIdGenerator(ComponentIdGenerator generator) { componentIdInteractor.acceptComponentIdGenerator(generator); } + private Consumer handleParentMessageDeleted(Member user, ThreadChannel channel, + ButtonInteractionEvent event, List args) { + int noOfMessages = 1; // we only care about first message from channel history + return error -> { + if (error instanceof ErrorResponseException ere + && ere.getErrorResponse() == ErrorResponse.UNKNOWN_MESSAGE) { + channel.getIterableHistory().reverse().limit(noOfMessages).queue(messages -> { + if (!messages.isEmpty()) { + handleDismiss(user, channel, messages.getFirst(), event, args); + } + }); + } else { + log.error( + "Trying to dismiss AI help message for thread: {}, unable to find original message.", + channel.getId(), error); + } + }; + } + @Override public void onButtonClick(ButtonInteractionEvent event, List args) { // This method handles chatgpt's automatic response "dismiss" button @@ -169,7 +195,8 @@ public void onButtonClick(ButtonInteractionEvent event, List args) { channel.retrieveStartMessage() .queue(forumPostMessage -> handleDismiss(interactionUser, channel, forumPostMessage, - event, args)); + event, args), + handleParentMessageDeleted(interactionUser, channel, event, args)); }