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 64bf86c166..54a46f5afd 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; @@ -207,6 +208,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..21a3489880 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 @@ -5,6 +5,7 @@ import com.openai.models.responses.Response; import com.openai.models.responses.ResponseCreateParams; import com.openai.models.responses.ResponseOutputText; +import net.dv8tion.jda.api.entities.Message; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,7 +25,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 = Message.MAX_CONTENT_LENGTH; private boolean isDisabled = false; private OpenAIClient openAIClient; @@ -39,9 +40,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 +59,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 +70,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..34b2aaf9dd --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/messages/RewriteCommand.java @@ -0,0 +1,184 @@ +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. + + 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; + } + } + +}