Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -207,6 +208,7 @@ public static Collection<Feature> 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<Class<?>> blacklist = blacklistConfig.normal();
return blacklist.filterStream(features.stream(), Object::getClass).toList();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand All @@ -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");
}

/**
Expand All @@ -56,10 +59,6 @@ public ChatGptService(Config config) {
* Tokens</a>.
*/
public Optional<String> 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
Expand All @@ -71,35 +70,71 @@ public Optional<String> 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.
* <p>
* 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 <a href="https://platform.openai.com/docs/guides/chat/managing-tokens">ChatGPT
* Tokens</a>.
*/
public Optional<String> 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<String> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* The rewritten message is shown as an ephemeral message visible only to the user who triggered the
* command.
* <p>
* 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;
}
}

}
Loading