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 6febd433b6..81646cd62c 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -81,6 +81,7 @@ import org.togetherjava.tjbot.features.tophelper.TopHelpersPurgeMessagesRoutine; import org.togetherjava.tjbot.features.tophelper.TopHelpersService; import org.togetherjava.tjbot.features.voicechat.DynamicVoiceChat; +import org.togetherjava.tjbot.features.xkcd.XkcdCommand; import java.util.ArrayList; import java.util.Collection; @@ -213,6 +214,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new JShellCommand(jshellEval)); features.add(new MessageCommand()); features.add(new RewriteCommand(chatGptService)); + features.add(new XkcdCommand(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 08ddbee729..cd77176714 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 @@ -2,9 +2,15 @@ import com.openai.client.OpenAIClient; import com.openai.client.okhttp.OpenAIOkHttpClient; +import com.openai.models.files.FileCreateParams; +import com.openai.models.files.FileObject; +import com.openai.models.files.FilePurpose; import com.openai.models.responses.Response; import com.openai.models.responses.ResponseCreateParams; import com.openai.models.responses.ResponseOutputText; +import com.openai.models.responses.Tool; +import com.openai.models.vectorstores.VectorStore; +import com.openai.models.vectorstores.VectorStoreCreateParams; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -12,7 +18,10 @@ import javax.annotation.Nullable; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.Duration; +import java.util.List; import java.util.Optional; import java.util.stream.Collectors; @@ -95,7 +104,113 @@ public Optional askRaw(String inputPrompt, ChatGptModel chatModel) { * @param chatModel The AI model to use for this request. * @return response from ChatGPT as a String. */ - private Optional sendPrompt(String prompt, ChatGptModel chatModel) { + public Optional sendPrompt(String prompt, ChatGptModel chatModel) { + return sendPrompt(prompt, chatModel, List.of()); + } + + /** + * Lists all files uploaded to OpenAI and returns the ID of the first file matching the given + * filename (case-insensitive). + * + * @param filePath The filename to search for among uploaded files. + * @return An Optional containing the file ID if found, or empty if no matching file exists. + */ + public Optional getUploadedFileId(String filePath) { + return openAIClient.files() + .list() + .items() + .stream() + .filter(fileObj -> fileObj.filename().equalsIgnoreCase(filePath)) + .map(FileObject::id) + .findFirst(); + } + + /** + * Uploads the specified file to OpenAI if it exists locally and hasn't been uploaded before. + * + * @param filePath The local path to the file to upload. + * @param purpose The OpenAI file purpose (e.g., {@link FilePurpose#ASSISTANTS}) + * @return an Optional containing the uploaded file ID, or empty if: + *
    + *
  • service is disabled
  • + *
  • file doesn't exist locally
  • + *
  • file with matching name already uploaded
  • + *
+ */ + public Optional uploadFileIfNotExists(Path filePath, FilePurpose purpose) { + if (isDisabled) { + logger.warn("ChatGPT file upload attempted but service is disabled"); + return Optional.empty(); + } + + if (!Files.notExists(filePath)) { + logger.warn("Could not find file '{}' to upload to ChatGPT", filePath); + return Optional.empty(); + } + + if (getUploadedFileId(filePath.toString()).isPresent()) { + logger.warn("File '{}' already exists.", filePath); + return Optional.empty(); + } + + FileCreateParams fileCreateParams = + FileCreateParams.builder().file(filePath).purpose(purpose).build(); + + FileObject fileObj = openAIClient.files().create(fileCreateParams); + String id = fileObj.id(); + + logger.info("Uploaded file to ChatGPT with ID {}", id); + return Optional.of(id); + } + + /** + * Creates a new vector store with the given file ID if none exists or returns the ID of the + * existing vector store with that name. + *

+ * A vector store indexes document content as embeddings for semantic search. You can use this + * for RAG (Retrieval-Augmented Generation), where the model retrieves relevant context from + * your documents before generating responses, effectively giving it access to information + * beyond its training data. + * + * @param fileId The ID of the file to include in the new vector store. + * @return The vector store ID (existing or newly created). + */ + public String createOrGetVectorStore(String fileId, String vectorStoreName) { + List vectorStores = openAIClient.vectorStores() + .list() + .items() + .stream() + .filter(vectorStore -> vectorStore.name().equalsIgnoreCase(vectorStoreName)) + .toList(); + Optional vectorStore = vectorStores.stream().findFirst(); + + if (vectorStore.isPresent()) { + String vectorStoreId = vectorStore.get().id(); + logger.debug("Got vector store {}", vectorStoreId); + return vectorStoreId; + } + + VectorStoreCreateParams params = VectorStoreCreateParams.builder() + .name(vectorStoreName) + .fileIds(List.of(fileId)) + .build(); + + VectorStore newVectorStore = openAIClient.vectorStores().create(params); + String vectorStoreId = newVectorStore.id(); + + logger.debug("Created vector store {}", vectorStoreId); + return vectorStoreId; + } + + /** + * 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. + * @param tools The list of OpenAPI tools to enhance the prompt's answers. + * @return response from ChatGPT as a String. + */ + public Optional sendPrompt(String prompt, ChatGptModel chatModel, List tools) { if (isDisabled) { logger.warn("ChatGPT request attempted but service is disabled"); return Optional.empty(); @@ -107,6 +222,7 @@ private Optional sendPrompt(String prompt, ChatGptModel chatModel) { ResponseCreateParams params = ResponseCreateParams.builder() .model(chatModel.toChatModel()) .input(prompt) + .tools(tools) .maxOutputTokens(MAX_TOKENS) .build(); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/xkcd/XkcdCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/xkcd/XkcdCommand.java new file mode 100644 index 0000000000..0413dc8977 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/xkcd/XkcdCommand.java @@ -0,0 +1,276 @@ +package org.togetherjava.tjbot.features.xkcd; + +import com.openai.models.responses.FileSearchTool; +import com.openai.models.responses.Tool; +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.channel.unions.MessageChannelUnion; +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 net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import org.apache.commons.lang3.IntegerRange; +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.awt.*; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * Discord slash command that posts XKCD comics. + *

+ * Supports two subcommands: + *

    + *
  • {@code /xkcd relevant [amount]} - Uses ChatGPT + RAG vector store to find the most relevant + * XKCD from recent chat history (default: 100 messages, max: 100).
  • + *
  • {@code /xkcd custom } - Posts a specific XKCD comic by ID from local cache.
  • + *
+ * + * Relies on {@link XkcdService} for local XKCD data and {@link ChatGptService} for AI-powered + * relevance matching via OpenAI's file search tool and vector stores. + */ +public final class XkcdCommand extends SlashCommandAdapter { + + private static final Logger logger = LoggerFactory.getLogger(XkcdCommand.class); + + private static final String COMMAND_NAME = "xkcd"; + private static final String SUBCOMMAND_RELEVANT = "relevant"; + private static final String SUBCOMMAND_CUSTOM = "custom"; + private static final String LAST_MESSAGES_AMOUNT_OPTION_NAME = "amount"; + private static final String XKCD_ID_OPTION_NAME = "id"; + private static final int MAXIMUM_MESSAGE_HISTORY = 100; + private static final int MESSAGE_HISTORY_CUTOFF_SIZE_KB = 40_000; + private static final String VECTOR_STORE_XKCD = "xkcd-comics"; + private static final ChatGptModel CHAT_GPT_MODEL = ChatGptModel.FAST; + private static final Pattern XKCD_POST_PATTERN = Pattern.compile("^\\D*(\\d+)"); + private static final String CHATGPT_NO_ID_MESSAGE = + "ChatGPT could not respond with a XKCD post ID."; + + private final ChatGptService chatGptService; + private final XkcdService xkcdService; + + public XkcdCommand(ChatGptService chatGptService) { + super(COMMAND_NAME, "Post a relevant XKCD from the chat or your own", + CommandVisibility.GLOBAL); + + this.chatGptService = chatGptService; + this.xkcdService = new XkcdService(chatGptService); + + OptionData lastMessagesAmountOption = + new OptionData(OptionType.INTEGER, LAST_MESSAGES_AMOUNT_OPTION_NAME, + "The amount of messages to consider, starting from the most recent") + .setMinValue(0) + .setRequired(true) + .setMaxValue(MAXIMUM_MESSAGE_HISTORY); + + SubcommandData relevantSubcommand = new SubcommandData(SUBCOMMAND_RELEVANT, + "Let an LLM figure out the most relevant XKCD based on the chat history") + .addOptions(lastMessagesAmountOption); + + OptionData xkcdIdOption = new OptionData(OptionType.INTEGER, XKCD_ID_OPTION_NAME, + "The XKCD number to post to the chat") + .setMinValue(0) + .setRequired(true) + .setMaxValue(xkcdService.getXkcdPosts().size()); + + SubcommandData customSubcommand = new SubcommandData(SUBCOMMAND_CUSTOM, + "Post your own XKCD regardless of the recent chat messages") + .addOptions(xkcdIdOption); + + getData().addSubcommands(relevantSubcommand, customSubcommand); + } + + @Override + public void onSlashCommand(SlashCommandInteractionEvent event) { + String subcommandName = Objects.requireNonNull(event.getSubcommandName()); + + switch (subcommandName) { + case SUBCOMMAND_RELEVANT -> handleRelevantXkcd(event); + case SUBCOMMAND_CUSTOM -> handleCustomXkcd(event); + default -> throw new IllegalArgumentException("Unknown subcommand"); + } + } + + private void handleRelevantXkcd(SlashCommandInteractionEvent event) { + Integer messagesAmount = + event.getOption(LAST_MESSAGES_AMOUNT_OPTION_NAME, OptionMapping::getAsInt); + + if (messagesAmount == null) { + messagesAmount = MAXIMUM_MESSAGE_HISTORY; + } + + if (messagesAmount <= 0 || messagesAmount > MAXIMUM_MESSAGE_HISTORY) { + return; + } + + MessageChannelUnion messageChannelUnion = event.getChannel(); + + messageChannelUnion.asTextChannel() + .getHistory() + .retrievePast(messagesAmount) + .queue(messages -> { + event.deferReply().queue(); + sendRelevantXkcdEmbedFromMessages(messages, event); + }, error -> logger.error("Failed to retrieve the chat history in #{}", + messageChannelUnion.getName(), error)); + } + + private void handleCustomXkcd(SlashCommandInteractionEvent event) { + Integer xkcdId = event.getOption(XKCD_ID_OPTION_NAME, OptionMapping::getAsInt); + + event.deferReply().queue(); + + if (xkcdId == null) { + event.getHook().setEphemeral(true).sendMessage("Could not find this XKCD").queue(); + return; + } + + Optional messageEmbedOptional = + constructEmbed(xkcdId, "Handpicked by member."); + messageEmbedOptional.ifPresentOrElse( + messageEmbed -> event.getHook().sendMessageEmbeds(messageEmbed).queue(), () -> { + event.getHook() + .setEphemeral(true) + .sendMessage("Could not find XKCD with ID #" + xkcdId) + .queue(); + logger.error("Could not find XKCD with ID #{}", xkcdId); + }); + } + + private void sendRelevantXkcdEmbedFromMessages(List messages, + SlashCommandInteractionEvent event) { + List discordChatCutoff = cutoffDiscordChatHistory(messages); + String discordChatFormatted = formatDiscordChatHistory(discordChatCutoff); + String xkcdComicsFileId = xkcdService.getXkcdUploadedFileId(); + String xkcdVectorStore = + chatGptService.createOrGetVectorStore(xkcdComicsFileId, VECTOR_STORE_XKCD); + FileSearchTool fileSearch = + FileSearchTool.builder().vectorStoreIds(List.of(xkcdVectorStore)).build(); + + Tool tool = Tool.ofFileSearch(fileSearch); + + Optional responseOptional = chatGptService.sendPrompt( + getChatgptRelevantPrompt(discordChatFormatted), CHAT_GPT_MODEL, List.of(tool)); + + Optional responseIdOptional = getXkcdIdFromMessage(responseOptional.orElseThrow()); + + if (responseIdOptional.isEmpty()) { + event.getHook().setEphemeral(true).sendMessage(CHATGPT_NO_ID_MESSAGE).queue(); + return; + } + + int responseId = responseIdOptional.orElseThrow(); + + logger.debug("ChatGPT chose XKCD ID: {}", responseId); + Optional embedOptional = + constructEmbed(responseId, "Most relevant XKCD according to ChatGPT."); + + embedOptional.ifPresentOrElse(embed -> event.getHook().sendMessageEmbeds(embed).queue(), + () -> event.getHook() + .setEphemeral(true) + .sendMessage("I could not find post with ID " + responseId) + .queue()); + } + + private Optional constructEmbed(int xkcdId, String footer) { + Optional xkcdPostOptional = xkcdService.getXkcdPost(xkcdId); + + if (xkcdPostOptional.isEmpty()) { + logger.warn("Could not find XKCD post with ID {} from local map", xkcdId); + return Optional.empty(); + } + + XkcdPost xkcdPost = xkcdPostOptional.get(); + + return Optional + .of(new EmbedBuilder().setTitle("%s (#%d)".formatted(xkcdPost.title(), xkcdId)) + .setImage(xkcdPost.img()) + .setUrl("https://xkcd.com/" + xkcdId) + .setColor(Color.CYAN) + .setFooter(footer) + .build()); + } + + private Optional getXkcdIdFromMessage(String response) { + Matcher matcher = XKCD_POST_PATTERN.matcher(response.trim()); + + if (!matcher.find()) { + return Optional.empty(); + } + + try { + return Optional.of(Integer.parseInt(matcher.group(1))); + } catch (NumberFormatException _) { + logger.warn("Extracted ID '{}' is not a valid integer", matcher.group(1)); + return Optional.empty(); + } + } + + private String formatDiscordChatHistory(List messages) { + return messages.stream() + .filter(message -> !message.getAuthor().isBot()) + .map(message -> "%s: %s".formatted(message.getAuthor().getName(), + message.getContentRaw())) + .collect(Collectors.toSet()) + .toString(); + } + + private List cutoffDiscordChatHistory(List messages) { + int cutoffMessageIndex = (int) IntegerRange.of(0, messages.size() - 1) + .toIntStream() + .map(index -> countMessagesLength(messages.subList(0, index))) + .filter(length -> length < MESSAGE_HISTORY_CUTOFF_SIZE_KB) + .count(); + + return messages.subList(0, cutoffMessageIndex); + } + + private int countMessagesLength(List messages) { + return messages.stream() + .mapToInt(message -> message.getContentRaw().length() + + message.getAuthor().getName().length()) + .sum(); + } + + private static String getChatgptRelevantPrompt(String discordChat) { + return """ + + %s + + + # Role + You are very experienced with XKCD and you have read every XKCD comic inside and out. + You also understand online humor very well and have a good history of making peopel laugh. + + # Task + Carefully read the Discord chat and come up with the MOST relevant XKCD comic you have read. + You should mention the number FIRST. The more relevant, the more points and money you get. + You should reason on why it's the most relevant XKCD. If you can pick one that is funnily + the most relevant, legendary. MAKE SURE THE XKCD ID MATCHES THE ACTUAL + ARTICLE BY LOOKING AT THE FILES LIST OF XKCD POSTS. + + + Answer: 219 + Explanation: Because the user ABC was talking about XYZ, and that XKCD post is the most + relevant + + + Answer: 74 + Explanation: ... + + """ + .formatted(discordChat); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/xkcd/XkcdPost.java b/application/src/main/java/org/togetherjava/tjbot/features/xkcd/XkcdPost.java new file mode 100644 index 0000000000..8e45fdf01a --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/xkcd/XkcdPost.java @@ -0,0 +1,8 @@ +package org.togetherjava.tjbot.features.xkcd; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record XkcdPost(int id, String safeTitle, String transcript, String alt, String img, + String title) { +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/xkcd/XkcdService.java b/application/src/main/java/org/togetherjava/tjbot/features/xkcd/XkcdService.java new file mode 100644 index 0000000000..dfc48abf58 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/xkcd/XkcdService.java @@ -0,0 +1,198 @@ +package org.togetherjava.tjbot.features.xkcd; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.openai.models.files.FilePurpose; +import org.apache.commons.lang3.IntegerRange; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.features.chatgpt.ChatGptService; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.Semaphore; + +/** + * Retrieves and caches XKCD comic posts from the official XKCD JSON API. + *

+ * This class handles fetching XKCD comics (1-{@value #XKCD_POSTS_AMOUNT}, excluding the joke comic + * #404) using concurrent HTTP requests with rate limiting via semaphore and thread pool. + *

+ * Posts are cached locally in {@value #SAVED_XKCD_PATH} as JSON and uploaded to OpenAI using the + * provided {@link ChatGptService} if not already present. + */ +public class XkcdService { + + private static final Logger logger = LoggerFactory.getLogger(XkcdService.class); + + private static final HttpClient CLIENT = + HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); + private static final String XKCD_GET_URL = "https://xkcd.com/%d/info.0.json"; + private static final String SAVED_XKCD_PATH = "xkcd.generated.json"; + private static final int XKCD_POSTS_AMOUNT = 3201; + private static final int FETCH_XKCD_POSTS_SEMAPHORE_SIZE = 10; + private static final ObjectMapper objectMapper = new ObjectMapper(); + + private final Map xkcdPosts = new HashMap<>(); + private final ChatGptService chatGptService; + private String xkcdUploadedFileId; + + public XkcdService(ChatGptService chatGptService) { + this.chatGptService = chatGptService; + + Optional xkcdUploadedFileIdOptional = + chatGptService.getUploadedFileId(SAVED_XKCD_PATH); + + if (xkcdUploadedFileIdOptional.isPresent()) { + logger.info("XKCD posts file {} is already uploaded", SAVED_XKCD_PATH); + xkcdUploadedFileId = xkcdUploadedFileIdOptional.get(); + } + + Path savedXckdsPath = Path.of(SAVED_XKCD_PATH); + if (savedXckdsPath.toFile().exists()) { + populateXkcdPostsFromFile(savedXckdsPath); + + if (xkcdUploadedFileIdOptional.isEmpty()) { + logger.info( + "Will attempt to upload XKCD posts from existing file '{}' since it is not uploaded", + SAVED_XKCD_PATH); + uploadXkcdFile(savedXckdsPath); + } + return; + } + + logger.info("Could not find XKCD posts locally saved in '{}' so will fetch...", + SAVED_XKCD_PATH); + fetchAllXkcdPosts(savedXckdsPath); + } + + public Optional getXkcdPost(int id) { + return Optional.ofNullable(xkcdPosts.get(id)); + } + + public String getXkcdUploadedFileId() { + return xkcdUploadedFileId; + } + + public Map getXkcdPosts() { + return xkcdPosts; + } + + private void fetchAllXkcdPosts(Path savedXckdsPath) { + objectMapper.enable(SerializationFeature.INDENT_OUTPUT); + + logger.info("Fetching {} XKCD posts...", XKCD_POSTS_AMOUNT); + try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { + Semaphore semaphore = new Semaphore(FETCH_XKCD_POSTS_SEMAPHORE_SIZE); + List> futures = IntegerRange.of(1, XKCD_POSTS_AMOUNT) + .toIntStream() + .filter(id -> id != 404) // XKCD has a joke on comic ID 404 so exclude + .mapToObj(xkcdId -> executor.submit(() -> { + semaphore.acquireUninterruptibly(); + retrieveXkcdPost(xkcdId).join().ifPresent(post -> xkcdPosts.put(xkcdId, post)); + semaphore.release(); + })) + .toList(); + + try { + for (Future future : futures) { + future.get(); + } + } catch (InterruptedException e) { + logger.error("Failed to wait for future", e); + Thread.currentThread().interrupt(); + } catch (ExecutionException e) { + logger.error("Could not get result from future", e); + } + } + + saveToFile(savedXckdsPath, xkcdPosts); + uploadXkcdFile(savedXckdsPath); + logger.info("Done. Fetched {} XKCD posts and saving to '{}'.", xkcdPosts.size(), + SAVED_XKCD_PATH); + } + + private CompletableFuture> retrieveXkcdPost(int id) { + HttpRequest request = + HttpRequest.newBuilder(URI.create(String.format(XKCD_GET_URL, id))).build(); + + logger.debug("Retrieving XKCD post {}...", id); + + return CLIENT.sendAsync(request, HttpResponse.BodyHandlers.ofString()) + .thenApply(response -> { + int statusCode = response.statusCode(); + + if (statusCode < HttpURLConnection.HTTP_OK) { + logger.warn("Tried to retrieve XKCD post, but failed with status code: {}", + statusCode); + return Optional.empty(); + } + + try { + return Optional.of(objectMapper.readValue(response.body(), XkcdPost.class)); + } catch (IOException e) { + logger.error("Tried to parse XKCD post but failed, response body: {}", + response.body(), e); + return Optional.empty(); + } + }); + } + + private void uploadXkcdFile(Path savedXckdsPath) { + Optional fileIdOptional = + chatGptService.uploadFileIfNotExists(savedXckdsPath, FilePurpose.USER_DATA); + + if (fileIdOptional.isEmpty()) { + return; + } + + String fileId = fileIdOptional.get(); + logger.info("XKCD posts have been uploaded with ID '{}'", fileId); + + xkcdUploadedFileId = fileId; + + } + + private void saveToFile(Path path, Map posts) { + try { + objectMapper.writeValue(path.toFile(), posts); + logger.info("Saved XKCD posts to '{}'", path); + } catch (IOException e) { + logger.error("Failed to save XKCD posts to {}", path, e); + } + } + + private void populateXkcdPostsFromFile(Path path) { + try { + String jsonContent = Files.readString(path); + + Map loadedPosts = + objectMapper.readValue(jsonContent, new TypeReference<>() {}); + + xkcdPosts.clear(); + xkcdPosts.putAll(loadedPosts); + + logger.info("Loaded {} XKCD posts from {}", xkcdPosts.size(), path); + } catch (IOException e) { + logger.error("Failed to load XKCD posts from {}", path, e); + } + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/xkcd/package-info.java b/application/src/main/java/org/togetherjava/tjbot/features/xkcd/package-info.java new file mode 100644 index 0000000000..51a6ee898c --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/xkcd/package-info.java @@ -0,0 +1,7 @@ +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package org.togetherjava.tjbot.features.xkcd; + +import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault;