From e3f954a4879529609c0bb6fc998d6ba9b4fd8ad2 Mon Sep 17 00:00:00 2001 From: Duncan Casteleyn <10881109+DuncanCasteleyn@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:21:56 +0200 Subject: [PATCH 1/5] feat(reddit): mirror subreddit posts to Discord --- .../discordmodbot/reddit/ModuleMetadata.kt | 8 + .../reddit/RedditConfigCommand.kt | 187 +++++++++++++++ .../reddit/RedditPollingService.kt | 217 +++++++++++++++++ .../discordmodbot/reddit/RedditProperties.kt | 23 ++ .../discordmodbot/reddit/RedditRssClient.kt | 68 ++++++ .../reddit/RedditRssClientConfig.kt | 28 +++ .../reddit/persistence/RedditAlertSettings.kt | 22 ++ .../RedditAlertSettingsRepository.kt | 7 + .../reddit/persistence/RedditPendingPost.kt | 14 ++ .../RedditPendingPostRepository.kt | 7 + .../reddit/persistence/RedditPostMirror.kt | 22 ++ .../persistence/RedditPostMirrorRepository.kt | 7 + .../V17__add_reddit_alert_settings.sql | 7 + .../discordmodbot/ApplicationModulesTest.kt | 1 + .../reddit/RedditConfigCommandTest.kt | 220 ++++++++++++++++++ .../reddit/RedditPollingServiceTest.kt | 184 +++++++++++++++ .../reddit/RedditRssClientTest.kt | 40 ++++ 17 files changed, 1062 insertions(+) create mode 100644 src/main/kotlin/be/duncanc/discordmodbot/reddit/ModuleMetadata.kt create mode 100644 src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommand.kt create mode 100644 src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingService.kt create mode 100644 src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditProperties.kt create mode 100644 src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClient.kt create mode 100644 src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClientConfig.kt create mode 100644 src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditAlertSettings.kt create mode 100644 src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditAlertSettingsRepository.kt create mode 100644 src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPendingPost.kt create mode 100644 src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPendingPostRepository.kt create mode 100644 src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPostMirror.kt create mode 100644 src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPostMirrorRepository.kt create mode 100644 src/main/resources/db/migration/V17__add_reddit_alert_settings.sql create mode 100644 src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommandTest.kt create mode 100644 src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingServiceTest.kt create mode 100644 src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClientTest.kt diff --git a/src/main/kotlin/be/duncanc/discordmodbot/reddit/ModuleMetadata.kt b/src/main/kotlin/be/duncanc/discordmodbot/reddit/ModuleMetadata.kt new file mode 100644 index 00000000..fb24fe81 --- /dev/null +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/ModuleMetadata.kt @@ -0,0 +1,8 @@ +package be.duncanc.discordmodbot.reddit + +import org.springframework.modulith.ApplicationModule +import org.springframework.modulith.PackageInfo + +@PackageInfo +@ApplicationModule(allowedDependencies = ["discord"]) +class ModuleMetadata diff --git a/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommand.kt b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommand.kt new file mode 100644 index 00000000..2c08ac4b --- /dev/null +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommand.kt @@ -0,0 +1,187 @@ +package be.duncanc.discordmodbot.reddit + +import be.duncanc.discordmodbot.discord.SlashCommand +import be.duncanc.discordmodbot.reddit.persistence.RedditAlertSettings +import be.duncanc.discordmodbot.reddit.persistence.RedditAlertSettingsRepository +import be.duncanc.discordmodbot.reddit.persistence.RedditPendingPostRepository +import be.duncanc.discordmodbot.reddit.persistence.RedditPostMirrorRepository +import net.dv8tion.jda.api.Permission +import net.dv8tion.jda.api.entities.Guild +import net.dv8tion.jda.api.entities.channel.ChannelType +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel +import net.dv8tion.jda.api.events.guild.GuildLeaveEvent +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent +import net.dv8tion.jda.api.hooks.ListenerAdapter +import net.dv8tion.jda.api.interactions.InteractionContextType +import net.dv8tion.jda.api.interactions.commands.DefaultMemberPermissions +import net.dv8tion.jda.api.interactions.commands.OptionType +import net.dv8tion.jda.api.interactions.commands.build.Commands +import net.dv8tion.jda.api.interactions.commands.build.OptionData +import net.dv8tion.jda.api.interactions.commands.build.SlashCommandData +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData +import org.springframework.stereotype.Component + +@Component +class RedditConfigCommand( + private val redditAlertSettingsRepository: RedditAlertSettingsRepository, + private val redditPostMirrorRepository: RedditPostMirrorRepository, + private val redditPendingPostRepository: RedditPendingPostRepository, + private val redditPollingService: RedditPollingService, + private val redditProperties: RedditProperties +) : ListenerAdapter(), SlashCommand { + companion object { + private const val COMMAND = "reddit" + private const val DESCRIPTION = "Configure Reddit post mirroring for this server." + private const val OPTION_CHANNEL = "channel" + private const val OPTION_SUBREDDIT = "subreddit" + private const val SUBCOMMAND_SHOW = "show" + private const val SUBCOMMAND_SET_CHANNEL = "set-channel" + private const val SUBCOMMAND_SET_SUBREDDIT = "set-subreddit" + private const val SUBCOMMAND_DISABLE = "disable" + private val SUBREDDIT_REGEX = Regex("[A-Za-z0-9_]{2,21}") + } + + override fun onGuildLeave(event: GuildLeaveEvent) { + clearAlertConfiguration(event.guild.idLong) + } + + override fun onSlashCommandInteraction(event: SlashCommandInteractionEvent) { + if (event.name != COMMAND) { + return + } + + val guild = event.guild + val member = event.member + if (guild == null || member == null) { + event.reply("This command only works in a guild.").setEphemeral(true).queue() + return + } + + if (!member.hasPermission(Permission.MANAGE_CHANNEL)) { + event.reply("You need manage channel permission to use this command.").setEphemeral(true).queue() + return + } + + when (event.subcommandName) { + null, SUBCOMMAND_SHOW -> showCurrentSettings(event, guild) + SUBCOMMAND_SET_CHANNEL -> { + val channel = getRequiredTextChannel(event) ?: return + val settings = redditAlertSettingsRepository.findById(guild.idLong).orElseGet { + RedditAlertSettings( + guildId = guild.idLong, + channelId = channel.idLong, + subreddit = redditProperties.subreddit + ) + } + settings.channelId = channel.idLong + redditAlertSettingsRepository.save(settings) + redditPollingService.baselineCurrentPosts(guild.idLong, settings.subreddit) + event.reply("Reddit posts from r/${settings.subreddit} will be mirrored to ${channel.asMention}.") + .setEphemeral(true) + .queue() + } + + SUBCOMMAND_SET_SUBREDDIT -> { + val subreddit = getRequiredSubreddit(event) ?: return + val settings = redditAlertSettingsRepository.findById(guild.idLong).orElseGet { + RedditAlertSettings(guildId = guild.idLong, subreddit = subreddit) + } + settings.subreddit = subreddit + clearTrackedPosts(guild.idLong) + redditAlertSettingsRepository.save(settings) + redditPollingService.baselineCurrentPosts(guild.idLong, settings.subreddit) + event.reply("Reddit post mirroring now watches r/$subreddit.").setEphemeral(true).queue() + } + + SUBCOMMAND_DISABLE -> { + clearAlertConfiguration(guild.idLong) + event.reply("Reddit post mirroring disabled.").setEphemeral(true).queue() + } + + else -> event.reply("Please choose a valid /reddit subcommand.").setEphemeral(true).queue() + } + } + + override fun getCommandsData(): List { + return listOf( + Commands.slash(COMMAND, DESCRIPTION) + .setContexts(InteractionContextType.GUILD) + .setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_CHANNEL)) + .addSubcommands( + SubcommandData(SUBCOMMAND_SHOW, "Show the current Reddit mirror settings"), + SubcommandData(SUBCOMMAND_SET_CHANNEL, "Set the channel that receives Reddit posts") + .addOptions(textChannelOption("The channel used for Reddit post mirrors")), + SubcommandData(SUBCOMMAND_SET_SUBREDDIT, "Set the subreddit to mirror") + .addOptions(subredditOption()), + SubcommandData(SUBCOMMAND_DISABLE, "Disable Reddit post mirroring for this server") + ) + ) + } + + internal fun getRequiredTextChannel(event: SlashCommandInteractionEvent): TextChannel? { + val channel = event.getOption(OPTION_CHANNEL)?.asChannel?.asTextChannel() + if (channel == null) { + event.reply("Please choose a text channel.").setEphemeral(true).queue() + return null + } + + return channel + } + + internal fun getRequiredSubreddit(event: SlashCommandInteractionEvent): String? { + val subreddit = event.getOption(OPTION_SUBREDDIT)?.asString + ?.trim() + ?.removePrefix("r/") + ?.removePrefix("/r/") + if (subreddit.isNullOrBlank() || !SUBREDDIT_REGEX.matches(subreddit)) { + event.reply("Please provide a valid subreddit name.").setEphemeral(true).queue() + return null + } + + return subreddit + } + + private fun showCurrentSettings(event: SlashCommandInteractionEvent, guild: Guild) { + val settings = redditAlertSettingsRepository.findById(guild.idLong).orElse(null) + val message = buildString { + appendLine("Reddit mirror settings for ${guild.name}") + appendLine() + appendLine("- Subreddit: r/${settings?.subreddit ?: redditProperties.subreddit}") + appendLine("- Mirror channel: ${formatChannel(guild, settings?.channelId)}") + appendLine("- Mentions: Disabled") + } + + event.reply(message).setEphemeral(true).queue() + } + + private fun formatChannel(guild: Guild, channelId: Long?): String { + if (channelId == null) { + return "Disabled" + } + + return guild.getTextChannelById(channelId)?.asMention ?: "Channel not found (ID: $channelId)" + } + + private fun clearAlertConfiguration(guildId: Long) { + redditAlertSettingsRepository.deleteById(guildId) + clearTrackedPosts(guildId) + } + + private fun clearTrackedPosts(guildId: Long) { + redditPostMirrorRepository.findAll() + .filter { it.guildId == guildId } + .forEach { redditPostMirrorRepository.delete(it) } + redditPendingPostRepository.findAll() + .filter { it.id.startsWith("$guildId:") } + .forEach { redditPendingPostRepository.delete(it) } + } + + private fun textChannelOption(description: String): OptionData { + return OptionData(OptionType.CHANNEL, OPTION_CHANNEL, description, true) + .setChannelTypes(ChannelType.TEXT) + } + + private fun subredditOption(): OptionData { + return OptionData(OptionType.STRING, OPTION_SUBREDDIT, "Subreddit name, for example Re_Zero", true) + } +} diff --git a/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingService.kt b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingService.kt new file mode 100644 index 00000000..08e1baed --- /dev/null +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingService.kt @@ -0,0 +1,217 @@ +package be.duncanc.discordmodbot.reddit + +import be.duncanc.discordmodbot.reddit.persistence.RedditAlertSettings +import be.duncanc.discordmodbot.reddit.persistence.RedditAlertSettingsRepository +import be.duncanc.discordmodbot.reddit.persistence.RedditPendingPost +import be.duncanc.discordmodbot.reddit.persistence.RedditPendingPostRepository +import be.duncanc.discordmodbot.reddit.persistence.RedditPostMirror +import be.duncanc.discordmodbot.reddit.persistence.RedditPostMirrorRepository +import net.dv8tion.jda.api.EmbedBuilder +import net.dv8tion.jda.api.JDA +import net.dv8tion.jda.api.entities.Message +import net.dv8tion.jda.api.entities.MessageEmbed +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel +import net.dv8tion.jda.api.exceptions.ErrorResponseException +import net.dv8tion.jda.api.requests.ErrorResponse +import org.slf4j.LoggerFactory +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Service +import java.awt.Color +import java.time.Instant + +@Service +class RedditPollingService( + private val redditRssClient: RedditRssClient, + private val redditAlertSettingsRepository: RedditAlertSettingsRepository, + private val redditPostMirrorRepository: RedditPostMirrorRepository, + private val redditPendingPostRepository: RedditPendingPostRepository, + private val jda: JDA +) { + companion object { + private val LOG = LoggerFactory.getLogger(RedditPollingService::class.java) + } + + @Scheduled(cron = $$"${discord-mod-bot.reddit.poll-cron:0 */2 * * * *}") + fun pollSubreddit() { + val settings = redditAlertSettingsRepository.findAll().filter { it.channelId != null } + if (settings.isEmpty()) { + return + } + + settings.groupBy { it.subreddit }.forEach { (subreddit, subredditSettings) -> + val posts = try { + redditRssClient.fetchNewestPosts(subreddit) + } catch (exception: Exception) { + LOG.warn("Failed to poll Reddit RSS for r/{}", subreddit, exception) + return@forEach + } + if (posts.isEmpty()) { + return@forEach + } + + subredditSettings.forEach { processGuild(it, posts) } + } + } + + fun baselineCurrentPosts(guildId: Long, subreddit: String) { + val posts = try { + redditRssClient.fetchNewestPosts(subreddit) + } catch (exception: Exception) { + LOG.warn("Failed to baseline Reddit RSS posts for r/{} in guild {}", subreddit, guildId, exception) + return + } + + posts.forEach { post -> + val mirrorId = RedditPostMirror.id(guildId, post.id) + val existingMirror = redditPostMirrorRepository.findById(mirrorId).orElse(null) + if (existingMirror == null) { + redditPostMirrorRepository.save( + RedditPostMirror( + id = mirrorId, + guildId = guildId, + redditPostId = post.id, + discordChannelId = null, + discordMessageId = null, + publishedAt = post.publishedAt, + permalink = post.permalink + ) + ) + } else { + redditPostMirrorRepository.save(existingMirror) + } + } + } + + private fun processGuild(settings: RedditAlertSettings, posts: List) { + val channelId = settings.channelId ?: return + val channel = jda.getTextChannelById(channelId) + if (channel == null) { + disableMissingChannel(settings, channelId) + return + } + + cleanupRemovedPosts(settings.guildId, posts) + posts.sortedBy { it.publishedAt }.forEach { post -> mirrorPost(settings.guildId, settings.subreddit, channel, post) } + } + + private fun mirrorPost(guildId: Long, subreddit: String, channel: TextChannel, post: RedditPost) { + val mirrorId = RedditPostMirror.id(guildId, post.id) + val existingMirror = redditPostMirrorRepository.findById(mirrorId).orElse(null) + if (existingMirror != null) { + redditPostMirrorRepository.save(existingMirror) + return + } + val pendingId = RedditPendingPost.id(guildId, post.id) + if (redditPendingPostRepository.existsById(pendingId)) { + return + } + + redditPendingPostRepository.save(RedditPendingPost(pendingId)) + sendPostMessage( + channel = channel, + embed = buildPostEmbed(subreddit, post), + onSuccess = { message -> + try { + redditPostMirrorRepository.save( + RedditPostMirror( + id = mirrorId, + guildId = guildId, + redditPostId = post.id, + discordChannelId = channel.idLong, + discordMessageId = message.idLong, + publishedAt = post.publishedAt, + permalink = post.permalink + ) + ) + } finally { + redditPendingPostRepository.deleteById(pendingId) + } + }, + onFailure = { exception -> + redditPendingPostRepository.deleteById(pendingId) + LOG.warn("Failed to mirror Reddit post {} for guild {}", post.id, guildId, exception) + } + ) + } + + private fun cleanupRemovedPosts(guildId: Long, posts: List) { + val currentPostIds = posts.mapTo(mutableSetOf()) { it.id } + val oldestFeedPost = posts.minByOrNull { it.publishedAt } ?: return + redditPostMirrorRepository.findAll() + .filter { it.guildId == guildId && !it.deleted && it.discordChannelId != null && it.discordMessageId != null } + .filter { it.redditPostId !in currentPostIds && !it.publishedAt.isBefore(oldestFeedPost.publishedAt) } + .forEach { mirror -> deleteMirroredMessage(mirror) } + } + + private fun deleteMirroredMessage(mirror: RedditPostMirror) { + val channelId = mirror.discordChannelId ?: return + val messageId = mirror.discordMessageId ?: return + val channel = jda.getTextChannelById(channelId) ?: return + channel.deleteMessageById(messageId).queue( + { + mirror.deleted = true + redditPostMirrorRepository.save(mirror) + }, + { exception -> + if (isTerminalMessageFailure(exception)) { + mirror.deleted = true + redditPostMirrorRepository.save(mirror) + return@queue + } + LOG.warn("Failed to delete mirrored Reddit post message {}", messageId, exception) + } + ) + } + + private fun disableMissingChannel(settings: RedditAlertSettings, channelId: Long) { + settings.channelId = null + redditAlertSettingsRepository.save(settings) + LOG.warn("Disabled Reddit alerts for guild {} because channel {} no longer exists", settings.guildId, channelId) + } + + internal fun sendPostMessage( + channel: TextChannel, + embed: MessageEmbed, + onSuccess: (Message) -> Unit, + onFailure: (Throwable) -> Unit + ) { + channel.sendMessageEmbeds(embed).queue(onSuccess, onFailure) + } + + internal fun buildPostEmbed(subreddit: String, post: RedditPost): MessageEmbed { + val embed = EmbedBuilder() + .setColor(Color(255, 69, 0)) + .setTitle(truncate(post.title, MessageEmbed.TITLE_MAX_LENGTH), post.permalink) + .setDescription("New post on r/$subreddit") + .setTimestamp(post.publishedAt) + .addField("Author", post.author?.let { "u/$it" } ?: "Unknown", true) + .addField("Reddit", truncate(post.permalink, MessageEmbed.VALUE_MAX_LENGTH), false) + + val thumbnailUrl = post.thumbnailUrl + if (thumbnailUrl != null) { + embed.setImage(thumbnailUrl) + } + + return embed.build() + } + + internal fun isTerminalMessageFailure(exception: Throwable): Boolean { + val errorResponseException = exception as? ErrorResponseException ?: return false + return when (errorResponseException.errorResponse) { + ErrorResponse.MISSING_PERMISSIONS, + ErrorResponse.MISSING_ACCESS, + ErrorResponse.UNKNOWN_CHANNEL, + ErrorResponse.UNKNOWN_MESSAGE -> true + + else -> false + } + } + + private fun truncate(value: String, maxLength: Int): String { + if (value.length <= maxLength) { + return value + } + + return value.take(maxLength - 3) + "..." + } +} diff --git a/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditProperties.kt b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditProperties.kt new file mode 100644 index 00000000..f331c0c2 --- /dev/null +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditProperties.kt @@ -0,0 +1,23 @@ +package be.duncanc.discordmodbot.reddit + +import jakarta.validation.constraints.NotEmpty +import org.springframework.boot.context.properties.ConfigurationProperties +import org.springframework.boot.context.properties.bind.DefaultValue +import org.springframework.validation.annotation.Validated +import java.time.Duration + +@Validated +@ConfigurationProperties("discord-mod-bot.reddit") +data class RedditProperties( + @NotEmpty + @DefaultValue("Re_Zero") + val subreddit: String, + @NotEmpty + @DefaultValue("0 */2 * * * *") + val pollCron: String, + @DefaultValue("10s") + val readTimeout: Duration, + @NotEmpty + @DefaultValue("DiscordModBot reddit RSS mirror") + val userAgent: String +) diff --git a/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClient.kt b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClient.kt new file mode 100644 index 00000000..da52c3da --- /dev/null +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClient.kt @@ -0,0 +1,68 @@ +package be.duncanc.discordmodbot.reddit + +import org.springframework.stereotype.Component +import org.springframework.web.client.RestClient +import org.w3c.dom.Element +import java.io.ByteArrayInputStream +import java.time.Instant +import javax.xml.parsers.DocumentBuilderFactory + +@Component +class RedditRssClient( + private val redditRestClient: RestClient +) { + fun fetchNewestPosts(subreddit: String): List { + val feed = redditRestClient.get() + .uri("/r/{subreddit}/new/.rss", subreddit) + .retrieve() + .body(String::class.java) + ?: throw IllegalStateException("Reddit RSS response was empty") + + return parse(feed) + } + + internal fun parse(feed: String): List { + val factory = DocumentBuilderFactory.newInstance() + factory.isNamespaceAware = true + val document = factory.newDocumentBuilder().parse(ByteArrayInputStream(feed.toByteArray(Charsets.UTF_8))) + val entries = document.getElementsByTagNameNS(ATOM_NAMESPACE, "entry") + return (0 until entries.length).map { index -> + val entry = entries.item(index) as Element + RedditPost( + id = entry.text("id"), + title = entry.text("title"), + author = entry.child("author")?.text("name")?.removePrefix("/u/"), + permalink = entry.child("link")?.getAttribute("href") ?: "", + publishedAt = Instant.parse(entry.text("published")), + thumbnailUrl = entry.child(MEDIA_NAMESPACE, "thumbnail")?.getAttribute("url")?.takeIf { it.isNotBlank() } + ) + }.filter { it.id.isNotBlank() && it.permalink.isNotBlank() } + } + + private fun Element.text(localName: String): String { + return child(localName)?.textContent?.trim().orEmpty() + } + + private fun Element.child(localName: String): Element? { + return child(ATOM_NAMESPACE, localName) + } + + private fun Element.child(namespace: String, localName: String): Element? { + val children = getElementsByTagNameNS(namespace, localName) + return children.item(0) as? Element + } + + companion object { + private const val ATOM_NAMESPACE = "http://www.w3.org/2005/Atom" + private const val MEDIA_NAMESPACE = "http://search.yahoo.com/mrss/" + } +} + +data class RedditPost( + val id: String, + val title: String, + val author: String?, + val permalink: String, + val publishedAt: Instant, + val thumbnailUrl: String? +) diff --git a/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClientConfig.kt b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClientConfig.kt new file mode 100644 index 00000000..610ec67d --- /dev/null +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClientConfig.kt @@ -0,0 +1,28 @@ +package be.duncanc.discordmodbot.reddit + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.client.JdkClientHttpRequestFactory +import org.springframework.web.client.RestClient + +@Configuration +class RedditRssClientConfig { + companion object { + private const val BASE_URL = "https://www.reddit.com" + } + + @Bean + fun redditRestClient( + redditProperties: RedditProperties, + restClientBuilder: RestClient.Builder + ): RestClient { + val requestFactory = JdkClientHttpRequestFactory() + requestFactory.setReadTimeout(redditProperties.readTimeout) + + return restClientBuilder + .baseUrl(BASE_URL) + .defaultHeader("User-Agent", redditProperties.userAgent) + .requestFactory(requestFactory) + .build() + } +} diff --git a/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditAlertSettings.kt b/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditAlertSettings.kt new file mode 100644 index 00000000..bfa09dd6 --- /dev/null +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditAlertSettings.kt @@ -0,0 +1,22 @@ +package be.duncanc.discordmodbot.reddit.persistence + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table + +@Entity +@Table(name = "reddit_alert_settings") +data class RedditAlertSettings( + @Id + @Column(updatable = false) + val guildId: Long, + @Column(nullable = true) + var channelId: Long? = null, + @Column(nullable = false, length = 100) + var subreddit: String = DEFAULT_SUBREDDIT +) { + companion object { + const val DEFAULT_SUBREDDIT = "Re_Zero" + } +} diff --git a/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditAlertSettingsRepository.kt b/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditAlertSettingsRepository.kt new file mode 100644 index 00000000..ce19269f --- /dev/null +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditAlertSettingsRepository.kt @@ -0,0 +1,7 @@ +package be.duncanc.discordmodbot.reddit.persistence + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository + +@Repository +interface RedditAlertSettingsRepository : JpaRepository diff --git a/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPendingPost.kt b/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPendingPost.kt new file mode 100644 index 00000000..2a0bdcb6 --- /dev/null +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPendingPost.kt @@ -0,0 +1,14 @@ +package be.duncanc.discordmodbot.reddit.persistence + +import org.springframework.data.annotation.Id +import org.springframework.data.redis.core.RedisHash + +@RedisHash("redditPendingPost", timeToLive = 900) +data class RedditPendingPost( + @Id + val id: String +) { + companion object { + fun id(guildId: Long, redditPostId: String): String = "$guildId:$redditPostId" + } +} diff --git a/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPendingPostRepository.kt b/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPendingPostRepository.kt new file mode 100644 index 00000000..318b1ec8 --- /dev/null +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPendingPostRepository.kt @@ -0,0 +1,7 @@ +package be.duncanc.discordmodbot.reddit.persistence + +import org.springframework.data.keyvalue.repository.KeyValueRepository +import org.springframework.stereotype.Repository + +@Repository +interface RedditPendingPostRepository : KeyValueRepository diff --git a/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPostMirror.kt b/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPostMirror.kt new file mode 100644 index 00000000..4e7223aa --- /dev/null +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPostMirror.kt @@ -0,0 +1,22 @@ +package be.duncanc.discordmodbot.reddit.persistence + +import org.springframework.data.annotation.Id +import org.springframework.data.redis.core.RedisHash +import java.time.Instant + +@RedisHash("redditPostMirror", timeToLive = 86400) +data class RedditPostMirror( + @Id + val id: String, + val guildId: Long, + val redditPostId: String, + val discordChannelId: Long?, + val discordMessageId: Long?, + val publishedAt: Instant, + val permalink: String, + var deleted: Boolean = false +) { + companion object { + fun id(guildId: Long, redditPostId: String): String = "$guildId:$redditPostId" + } +} diff --git a/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPostMirrorRepository.kt b/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPostMirrorRepository.kt new file mode 100644 index 00000000..e9ae49e8 --- /dev/null +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPostMirrorRepository.kt @@ -0,0 +1,7 @@ +package be.duncanc.discordmodbot.reddit.persistence + +import org.springframework.data.keyvalue.repository.KeyValueRepository +import org.springframework.stereotype.Repository + +@Repository +interface RedditPostMirrorRepository : KeyValueRepository diff --git a/src/main/resources/db/migration/V17__add_reddit_alert_settings.sql b/src/main/resources/db/migration/V17__add_reddit_alert_settings.sql new file mode 100644 index 00000000..5159048b --- /dev/null +++ b/src/main/resources/db/migration/V17__add_reddit_alert_settings.sql @@ -0,0 +1,7 @@ +create table reddit_alert_settings +( + guild_id bigint not null, + channel_id bigint, + subreddit varchar(100) not null, + primary key (guild_id) +); diff --git a/src/test/kotlin/be/duncanc/discordmodbot/ApplicationModulesTest.kt b/src/test/kotlin/be/duncanc/discordmodbot/ApplicationModulesTest.kt index acebffb4..3ae1227a 100644 --- a/src/test/kotlin/be/duncanc/discordmodbot/ApplicationModulesTest.kt +++ b/src/test/kotlin/be/duncanc/discordmodbot/ApplicationModulesTest.kt @@ -18,6 +18,7 @@ class ApplicationModulesTest { "member.gate", "moderation", "narou.novel.api", + "reddit", "reporting", "roles", "server.config", diff --git a/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommandTest.kt b/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommandTest.kt new file mode 100644 index 00000000..f3b9c608 --- /dev/null +++ b/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommandTest.kt @@ -0,0 +1,220 @@ +package be.duncanc.discordmodbot.reddit + +import be.duncanc.discordmodbot.reddit.persistence.RedditAlertSettings +import be.duncanc.discordmodbot.reddit.persistence.RedditAlertSettingsRepository +import be.duncanc.discordmodbot.reddit.persistence.RedditPendingPost +import be.duncanc.discordmodbot.reddit.persistence.RedditPendingPostRepository +import be.duncanc.discordmodbot.reddit.persistence.RedditPostMirror +import be.duncanc.discordmodbot.reddit.persistence.RedditPostMirrorRepository +import net.dv8tion.jda.api.Permission +import net.dv8tion.jda.api.entities.Guild +import net.dv8tion.jda.api.entities.Member +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent +import net.dv8tion.jda.api.interactions.InteractionContextType +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData +import net.dv8tion.jda.api.requests.restaction.interactions.ReplyCallbackAction +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.time.Duration +import java.time.Instant +import java.util.Optional + +@ExtendWith(MockitoExtension::class) +class RedditConfigCommandTest { + @Mock + private lateinit var redditAlertSettingsRepository: RedditAlertSettingsRepository + + @Mock + private lateinit var redditPostMirrorRepository: RedditPostMirrorRepository + + @Mock + private lateinit var redditPendingPostRepository: RedditPendingPostRepository + + @Mock + private lateinit var redditPollingService: RedditPollingService + + @Mock + private lateinit var slashEvent: SlashCommandInteractionEvent + + @Mock + private lateinit var guild: Guild + + @Mock + private lateinit var member: Member + + @Mock + private lateinit var textChannel: TextChannel + + @Mock + private lateinit var replyAction: ReplyCallbackAction + + private lateinit var command: TestRedditConfigCommand + + @BeforeEach + fun setUp() { + command = TestRedditConfigCommand( + redditAlertSettingsRepository = redditAlertSettingsRepository, + redditPostMirrorRepository = redditPostMirrorRepository, + redditPendingPostRepository = redditPendingPostRepository, + redditPollingService = redditPollingService, + redditProperties = RedditProperties( + subreddit = "Re_Zero", + pollCron = "0 */2 * * * *", + readTimeout = Duration.ofSeconds(10), + userAgent = "test" + ) + ) + } + + @Test + fun `set channel stores selected channel and baselines configured subreddit`() { + stubAuthorizedSlashCommand("set-channel") + whenever(textChannel.idLong).thenReturn(11L) + whenever(textChannel.asMention).thenReturn("<#11>") + whenever(redditAlertSettingsRepository.findById(1L)).thenReturn( + Optional.of(RedditAlertSettings(guildId = 1L, subreddit = "Anime")) + ) + command.selectedChannel = textChannel + + command.onSlashCommandInteraction(slashEvent) + + val settingsCaptor = argumentCaptor() + verify(redditAlertSettingsRepository).save(settingsCaptor.capture()) + assertEquals(11L, settingsCaptor.firstValue.channelId) + assertEquals("Anime", settingsCaptor.firstValue.subreddit) + verify(redditPollingService).baselineCurrentPosts(1L, "Anime") + verify(slashEvent).reply("Reddit posts from r/Anime will be mirrored to <#11>.") + } + + @Test + fun `set subreddit stores subreddit clears tracked posts and baselines`() { + stubAuthorizedSlashCommand("set-subreddit") + val mirror = RedditPostMirror( + id = "1:t3_old", + guildId = 1L, + redditPostId = "t3_old", + discordChannelId = 11L, + discordMessageId = 101L, + publishedAt = Instant.parse("2026-07-02T12:00:00Z"), + permalink = "https://www.reddit.com/r/Re_Zero/comments/old/title/" + ) + val pending = RedditPendingPost("1:t3_pending") + whenever(redditAlertSettingsRepository.findById(1L)).thenReturn( + Optional.of(RedditAlertSettings(guildId = 1L, channelId = 11L, subreddit = "Re_Zero")) + ) + whenever(redditPostMirrorRepository.findAll()).thenReturn(listOf(mirror)) + whenever(redditPendingPostRepository.findAll()).thenReturn(listOf(pending)) + command.subreddit = "Anime" + + command.onSlashCommandInteraction(slashEvent) + + val settingsCaptor = argumentCaptor() + verify(redditAlertSettingsRepository).save(settingsCaptor.capture()) + assertEquals("Anime", settingsCaptor.firstValue.subreddit) + verify(redditPostMirrorRepository).delete(mirror) + verify(redditPendingPostRepository).delete(pending) + verify(redditPollingService).baselineCurrentPosts(1L, "Anime") + verify(slashEvent).reply("Reddit post mirroring now watches r/Anime.") + } + + @Test + fun `disable wipes settings and tracked posts`() { + stubAuthorizedSlashCommand("disable") + val mirror = RedditPostMirror( + id = "1:t3_old", + guildId = 1L, + redditPostId = "t3_old", + discordChannelId = 11L, + discordMessageId = 101L, + publishedAt = Instant.parse("2026-07-02T12:00:00Z"), + permalink = "https://www.reddit.com/r/Re_Zero/comments/old/title/" + ) + whenever(redditPostMirrorRepository.findAll()).thenReturn(listOf(mirror)) + whenever(redditPendingPostRepository.findAll()).thenReturn(emptyList()) + + command.onSlashCommandInteraction(slashEvent) + + verify(redditAlertSettingsRepository).deleteById(1L) + verify(redditPostMirrorRepository).delete(mirror) + verify(slashEvent).reply("Reddit post mirroring disabled.") + } + + @Test + fun `show displays configured subreddit`() { + stubAuthorizedSlashCommand("show") + whenever(guild.name).thenReturn("Test Guild") + whenever(guild.getTextChannelById(11L)).thenReturn(textChannel) + whenever(textChannel.asMention).thenReturn("<#11>") + whenever(redditAlertSettingsRepository.findById(1L)).thenReturn( + Optional.of(RedditAlertSettings(guildId = 1L, channelId = 11L, subreddit = "Anime")) + ) + + command.onSlashCommandInteraction(slashEvent) + + val replyCaptor = argumentCaptor() + verify(slashEvent).reply(replyCaptor.capture()) + assertEquals(true, replyCaptor.firstValue.contains("- Subreddit: r/Anime")) + assertEquals(true, replyCaptor.firstValue.contains("- Mirror channel: <#11>")) + } + + @Test + fun `command data exposes expected subcommands`() { + val commandData = command.getCommandsData().single() + + assertEquals("reddit", commandData.name) + assertEquals(setOf(InteractionContextType.GUILD), commandData.contexts) + assertEquals( + listOf("show", "set-channel", "set-subreddit", "disable"), + commandData.subcommands.map(SubcommandData::getName) + ) + } + + private fun stubSlashCommandContext() { + whenever(slashEvent.name).thenReturn("reddit") + whenever(slashEvent.guild).thenReturn(guild) + whenever(slashEvent.member).thenReturn(member) + whenever(slashEvent.reply(any())).thenReturn(replyAction) + whenever(replyAction.setEphemeral(true)).thenReturn(replyAction) + } + + private fun stubAuthorizedSlashCommand(subcommandName: String) { + stubSlashCommandContext() + whenever(guild.idLong).thenReturn(1L) + whenever(member.hasPermission(Permission.MANAGE_CHANNEL)).thenReturn(true) + whenever(slashEvent.subcommandName).thenReturn(subcommandName) + } + + private class TestRedditConfigCommand( + redditAlertSettingsRepository: RedditAlertSettingsRepository, + redditPostMirrorRepository: RedditPostMirrorRepository, + redditPendingPostRepository: RedditPendingPostRepository, + redditPollingService: RedditPollingService, + redditProperties: RedditProperties + ) : RedditConfigCommand( + redditAlertSettingsRepository, + redditPostMirrorRepository, + redditPendingPostRepository, + redditPollingService, + redditProperties + ) { + var selectedChannel: TextChannel? = null + var subreddit: String? = null + + override fun getRequiredTextChannel(event: SlashCommandInteractionEvent): TextChannel? { + return selectedChannel ?: super.getRequiredTextChannel(event) + } + + override fun getRequiredSubreddit(event: SlashCommandInteractionEvent): String? { + return subreddit ?: super.getRequiredSubreddit(event) + } + } +} diff --git a/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingServiceTest.kt b/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingServiceTest.kt new file mode 100644 index 00000000..6cba8484 --- /dev/null +++ b/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingServiceTest.kt @@ -0,0 +1,184 @@ +package be.duncanc.discordmodbot.reddit + +import be.duncanc.discordmodbot.reddit.persistence.RedditAlertSettings +import be.duncanc.discordmodbot.reddit.persistence.RedditAlertSettingsRepository +import be.duncanc.discordmodbot.reddit.persistence.RedditPendingPost +import be.duncanc.discordmodbot.reddit.persistence.RedditPendingPostRepository +import be.duncanc.discordmodbot.reddit.persistence.RedditPostMirror +import be.duncanc.discordmodbot.reddit.persistence.RedditPostMirrorRepository +import net.dv8tion.jda.api.JDA +import net.dv8tion.jda.api.entities.Message +import net.dv8tion.jda.api.entities.MessageEmbed +import net.dv8tion.jda.api.entities.channel.concrete.TextChannel +import net.dv8tion.jda.api.requests.restaction.AuditableRestAction +import net.dv8tion.jda.api.requests.restaction.MessageCreateAction +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.Mock +import org.mockito.junit.jupiter.MockitoExtension +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.never +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import java.time.Instant +import java.util.Optional +import java.util.function.Consumer + +@ExtendWith(MockitoExtension::class) +class RedditPollingServiceTest { + @Mock + private lateinit var redditRssClient: RedditRssClient + + @Mock + private lateinit var redditAlertSettingsRepository: RedditAlertSettingsRepository + + @Mock + private lateinit var redditPostMirrorRepository: RedditPostMirrorRepository + + @Mock + private lateinit var redditPendingPostRepository: RedditPendingPostRepository + + @Mock + private lateinit var jda: JDA + + @Mock + private lateinit var textChannel: TextChannel + + @Mock + private lateinit var messageCreateAction: MessageCreateAction + + @Mock + private lateinit var deleteAction: AuditableRestAction + + @Mock + private lateinit var message: Message + + private lateinit var service: RedditPollingService + + + @BeforeEach + fun setUp() { + service = RedditPollingService( + redditRssClient = redditRssClient, + redditAlertSettingsRepository = redditAlertSettingsRepository, + redditPostMirrorRepository = redditPostMirrorRepository, + redditPendingPostRepository = redditPendingPostRepository, + jda = jda + ) + whenever(redditPostMirrorRepository.save(any())).thenAnswer { it.arguments[0] } + } + + @Test + fun `baseline stores current rss posts without discord message ids`() { + whenever(redditRssClient.fetchNewestPosts("Re_Zero")).thenReturn(listOf(post("t3_first"))) + whenever(redditPostMirrorRepository.findById("1:t3_first")).thenReturn(Optional.empty()) + + service.baselineCurrentPosts(1L, "Re_Zero") + + val mirrorCaptor = argumentCaptor() + verify(redditPostMirrorRepository).save(mirrorCaptor.capture()) + assertEquals("1:t3_first", mirrorCaptor.firstValue.id) + assertEquals(null, mirrorCaptor.firstValue.discordChannelId) + assertEquals(null, mirrorCaptor.firstValue.discordMessageId) + } + + @Test + fun `poll mirrors new rss post and stores discord message id`() { + whenever(redditRssClient.fetchNewestPosts("Re_Zero")).thenReturn(listOf(post("t3_new"))) + whenever(redditAlertSettingsRepository.findAll()).thenReturn(listOf(RedditAlertSettings(1L, 11L))) + whenever(jda.getTextChannelById(11L)).thenReturn(textChannel) + whenever(redditPostMirrorRepository.findAll()).thenReturn(emptyList()) + whenever(redditPostMirrorRepository.findById("1:t3_new")).thenReturn(Optional.empty()) + whenever(redditPendingPostRepository.existsById("1:t3_new")).thenReturn(false) + whenever(redditPendingPostRepository.save(any())).thenAnswer { it.arguments[0] } + whenever(textChannel.idLong).thenReturn(11L) + whenever(textChannel.sendMessageEmbeds(any())).thenReturn(messageCreateAction) + whenever(message.idLong).thenReturn(101L) + doAnswer { invocation -> + invocation.component1>().accept(message) + null + }.whenever(messageCreateAction).queue(any(), any()) + + service.pollSubreddit() + + val mirrorCaptor = argumentCaptor() + verify(redditPostMirrorRepository).save(mirrorCaptor.capture()) + assertEquals("1:t3_new", mirrorCaptor.lastValue.id) + assertEquals(11L, mirrorCaptor.lastValue.discordChannelId) + assertEquals(101L, mirrorCaptor.lastValue.discordMessageId) + verify(redditPendingPostRepository).deleteById("1:t3_new") + } + + @Test + fun `poll deletes mirrored message when tracked post is missing inside rss window`() { + val trackedMirror = RedditPostMirror( + id = "1:t3_removed", + guildId = 1L, + redditPostId = "t3_removed", + discordChannelId = 11L, + discordMessageId = 101L, + publishedAt = Instant.parse("2026-07-02T12:00:00Z"), + permalink = "https://www.reddit.com/r/Re_Zero/comments/removed/title/" + ) + whenever(redditRssClient.fetchNewestPosts("Re_Zero")).thenReturn( + listOf( + post("t3_newer", publishedAt = Instant.parse("2026-07-02T13:00:00Z")), + post("t3_older", publishedAt = Instant.parse("2026-07-02T11:00:00Z")) + ) + ) + whenever(redditAlertSettingsRepository.findAll()).thenReturn(listOf(RedditAlertSettings(1L, 11L))) + whenever(jda.getTextChannelById(11L)).thenReturn(textChannel) + whenever(redditPostMirrorRepository.findAll()).thenReturn(listOf(trackedMirror)) + whenever(redditPostMirrorRepository.findById("1:t3_older")).thenReturn( + Optional.of( + trackedMirror.copy( + id = "1:t3_older", + redditPostId = "t3_older", + discordMessageId = 102L, + publishedAt = Instant.parse("2026-07-02T11:00:00Z") + ) + ) + ) + whenever(redditPostMirrorRepository.findById("1:t3_newer")).thenReturn( + Optional.of( + trackedMirror.copy( + id = "1:t3_newer", + redditPostId = "t3_newer", + discordMessageId = 103L, + publishedAt = Instant.parse("2026-07-02T13:00:00Z") + ) + ) + ) + whenever(textChannel.deleteMessageById(101L)).thenReturn(deleteAction) + doAnswer { invocation -> + invocation.component1>().accept(null) + null + }.whenever(deleteAction).queue(any(), any()) + + service.pollSubreddit() + + val mirrorCaptor = argumentCaptor() + verify(redditPostMirrorRepository, times(3)).save(mirrorCaptor.capture()) + assertEquals(true, mirrorCaptor.allValues.first { it.redditPostId == "t3_removed" }.deleted) + verify(textChannel, never()).sendMessageEmbeds(any()) + } + + private fun post( + id: String, + publishedAt: Instant = Instant.parse("2026-07-02T12:00:00Z") + ): RedditPost { + return RedditPost( + id = id, + title = "Test post $id", + author = "Subaru", + permalink = "https://www.reddit.com/r/Re_Zero/comments/$id/title/", + publishedAt = publishedAt, + thumbnailUrl = null + ) + } +} diff --git a/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClientTest.kt b/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClientTest.kt new file mode 100644 index 00000000..6d96ad1b --- /dev/null +++ b/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClientTest.kt @@ -0,0 +1,40 @@ +package be.duncanc.discordmodbot.reddit + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.kotlin.mock +import org.springframework.web.client.RestClient +import java.time.Instant + +class RedditRssClientTest { + @Test + fun `parse reads reddit atom entries`() { + val client = RedditRssClient( + redditRestClient = mock() + ) + + val posts = client.parse( + """ + + + + /u/Subaru + t3_abc123 + + + 2026-07-02T21:29:12+00:00 + [media] Test post + + + """.trimIndent() + ) + + assertEquals(1, posts.size) + assertEquals("t3_abc123", posts.first().id) + assertEquals("[media] Test post", posts.first().title) + assertEquals("Subaru", posts.first().author) + assertEquals("https://www.reddit.com/r/Re_Zero/comments/abc123/title/", posts.first().permalink) + assertEquals(Instant.parse("2026-07-02T21:29:12Z"), posts.first().publishedAt) + assertEquals("https://preview.redd.it/image.jpeg", posts.first().thumbnailUrl) + } +} From b34eef7b40977d3902c0bd1c1522f7e017cf11a6 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 3 Jul 2026 00:26:09 +0000 Subject: [PATCH 2/5] Fixed Reddit PR #722 review issues. Co-authored-by: DuncanCasteleyn --- .../reddit/RedditConfigCommand.kt | 29 +++++++++-- .../reddit/RedditPollingService.kt | 25 +++++++--- .../reddit/RedditConfigCommandTest.kt | 49 +++++++++++++++++++ .../reddit/RedditPollingServiceTest.kt | 43 +++++++++++++++- 4 files changed, 134 insertions(+), 12 deletions(-) diff --git a/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommand.kt b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommand.kt index 2c08ac4b..e8214868 100644 --- a/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommand.kt +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommand.kt @@ -73,10 +73,18 @@ class RedditConfigCommand( subreddit = redditProperties.subreddit ) } + val subreddit = settings.subreddit + try { + redditPollingService.baselineCurrentPosts(guild.idLong, subreddit) + } catch (exception: IllegalStateException) { + event.reply(exception.message ?: "Failed to enable Reddit alerts. Please try again later.") + .setEphemeral(true) + .queue() + return + } settings.channelId = channel.idLong redditAlertSettingsRepository.save(settings) - redditPollingService.baselineCurrentPosts(guild.idLong, settings.subreddit) - event.reply("Reddit posts from r/${settings.subreddit} will be mirrored to ${channel.asMention}.") + event.reply("Reddit posts from r/$subreddit will be mirrored to ${channel.asMention}.") .setEphemeral(true) .queue() } @@ -86,10 +94,17 @@ class RedditConfigCommand( val settings = redditAlertSettingsRepository.findById(guild.idLong).orElseGet { RedditAlertSettings(guildId = guild.idLong, subreddit = subreddit) } + val posts = try { + redditPollingService.baselineCurrentPosts(guild.idLong, subreddit) + } catch (exception: IllegalStateException) { + event.reply(exception.message ?: "Failed to enable Reddit alerts. Please try again later.") + .setEphemeral(true) + .queue() + return + } settings.subreddit = subreddit - clearTrackedPosts(guild.idLong) + clearTrackedPostsExcept(guild.idLong, posts.map { it.id }.toSet()) redditAlertSettingsRepository.save(settings) - redditPollingService.baselineCurrentPosts(guild.idLong, settings.subreddit) event.reply("Reddit post mirroring now watches r/$subreddit.").setEphemeral(true).queue() } @@ -168,8 +183,12 @@ class RedditConfigCommand( } private fun clearTrackedPosts(guildId: Long) { + clearTrackedPostsExcept(guildId, emptySet()) + } + + private fun clearTrackedPostsExcept(guildId: Long, keepPostIds: Set) { redditPostMirrorRepository.findAll() - .filter { it.guildId == guildId } + .filter { it.guildId == guildId && it.redditPostId !in keepPostIds } .forEach { redditPostMirrorRepository.delete(it) } redditPendingPostRepository.findAll() .filter { it.id.startsWith("$guildId:") } diff --git a/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingService.kt b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingService.kt index 08e1baed..10a9ab27 100644 --- a/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingService.kt +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingService.kt @@ -53,12 +53,14 @@ class RedditPollingService( } } - fun baselineCurrentPosts(guildId: Long, subreddit: String) { + fun baselineCurrentPosts(guildId: Long, subreddit: String): List { val posts = try { redditRssClient.fetchNewestPosts(subreddit) } catch (exception: Exception) { LOG.warn("Failed to baseline Reddit RSS posts for r/{} in guild {}", subreddit, guildId, exception) - return + throw IllegalStateException( + "Failed to reach Reddit while enabling alerts for r/$subreddit. Please try again later." + ) } posts.forEach { post -> @@ -80,6 +82,7 @@ class RedditPollingService( redditPostMirrorRepository.save(existingMirror) } } + return posts } private fun processGuild(settings: RedditAlertSettings, posts: List) { @@ -91,10 +94,12 @@ class RedditPollingService( } cleanupRemovedPosts(settings.guildId, posts) - posts.sortedBy { it.publishedAt }.forEach { post -> mirrorPost(settings.guildId, settings.subreddit, channel, post) } + posts.sortedBy { it.publishedAt }.forEach { post -> mirrorPost(settings, channel, post) } } - private fun mirrorPost(guildId: Long, subreddit: String, channel: TextChannel, post: RedditPost) { + private fun mirrorPost(settings: RedditAlertSettings, channel: TextChannel, post: RedditPost) { + val guildId = settings.guildId + val subreddit = settings.subreddit val mirrorId = RedditPostMirror.id(guildId, post.id) val existingMirror = redditPostMirrorRepository.findById(mirrorId).orElse(null) if (existingMirror != null) { @@ -127,8 +132,12 @@ class RedditPollingService( redditPendingPostRepository.deleteById(pendingId) } }, - onFailure = { exception -> + onFailure = onFailure@{ exception -> redditPendingPostRepository.deleteById(pendingId) + if (isTerminalMessageFailure(exception)) { + disableChannel(settings, "channel ${channel.idLong} is inaccessible") + return@onFailure + } LOG.warn("Failed to mirror Reddit post {} for guild {}", post.id, guildId, exception) } ) @@ -164,9 +173,13 @@ class RedditPollingService( } private fun disableMissingChannel(settings: RedditAlertSettings, channelId: Long) { + disableChannel(settings, "channel $channelId no longer exists") + } + + private fun disableChannel(settings: RedditAlertSettings, reason: String) { settings.channelId = null redditAlertSettingsRepository.save(settings) - LOG.warn("Disabled Reddit alerts for guild {} because channel {} no longer exists", settings.guildId, channelId) + LOG.warn("Disabled Reddit alerts for guild {}: {}", settings.guildId, reason) } internal fun sendPostMessage( diff --git a/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommandTest.kt b/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommandTest.kt index f3b9c608..a686e762 100644 --- a/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommandTest.kt +++ b/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommandTest.kt @@ -22,6 +22,7 @@ import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import java.time.Duration @@ -113,6 +114,18 @@ class RedditConfigCommandTest { ) whenever(redditPostMirrorRepository.findAll()).thenReturn(listOf(mirror)) whenever(redditPendingPostRepository.findAll()).thenReturn(listOf(pending)) + whenever(redditPollingService.baselineCurrentPosts(1L, "Anime")).thenReturn( + listOf( + RedditPost( + id = "t3_new", + title = "New", + author = "Subaru", + permalink = "https://www.reddit.com/r/Anime/comments/t3_new/title/", + publishedAt = Instant.parse("2026-07-02T12:00:00Z"), + thumbnailUrl = null + ) + ) + ) command.subreddit = "Anime" command.onSlashCommandInteraction(slashEvent) @@ -126,6 +139,42 @@ class RedditConfigCommandTest { verify(slashEvent).reply("Reddit post mirroring now watches r/Anime.") } + @Test + fun `set channel does not save settings when baseline fails`() { + stubAuthorizedSlashCommand("set-channel") + whenever(redditAlertSettingsRepository.findById(1L)).thenReturn( + Optional.of(RedditAlertSettings(guildId = 1L, subreddit = "Anime")) + ) + whenever(redditPollingService.baselineCurrentPosts(1L, "Anime")).thenThrow( + IllegalStateException("Reddit unavailable") + ) + command.selectedChannel = textChannel + + command.onSlashCommandInteraction(slashEvent) + + verify(redditAlertSettingsRepository, never()).save(any()) + verify(slashEvent).reply("Reddit unavailable") + } + + @Test + fun `set subreddit does not save settings or clear tracked posts when baseline fails`() { + stubAuthorizedSlashCommand("set-subreddit") + whenever(redditAlertSettingsRepository.findById(1L)).thenReturn( + Optional.of(RedditAlertSettings(guildId = 1L, channelId = 11L, subreddit = "Re_Zero")) + ) + whenever(redditPollingService.baselineCurrentPosts(1L, "Anime")).thenThrow( + IllegalStateException("Reddit unavailable") + ) + command.subreddit = "Anime" + + command.onSlashCommandInteraction(slashEvent) + + verify(redditAlertSettingsRepository, never()).save(any()) + verify(redditPostMirrorRepository, never()).delete(any()) + verify(redditPendingPostRepository, never()).delete(any()) + verify(slashEvent).reply("Reddit unavailable") + } + @Test fun `disable wipes settings and tracked posts`() { stubAuthorizedSlashCommand("disable") diff --git a/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingServiceTest.kt b/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingServiceTest.kt index 6cba8484..44c10ea2 100644 --- a/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingServiceTest.kt +++ b/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingServiceTest.kt @@ -10,17 +10,21 @@ import net.dv8tion.jda.api.JDA import net.dv8tion.jda.api.entities.Message import net.dv8tion.jda.api.entities.MessageEmbed import net.dv8tion.jda.api.entities.channel.concrete.TextChannel +import net.dv8tion.jda.api.exceptions.ErrorResponseException +import net.dv8tion.jda.api.requests.ErrorResponse import net.dv8tion.jda.api.requests.restaction.AuditableRestAction import net.dv8tion.jda.api.requests.restaction.MessageCreateAction import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.extension.ExtendWith import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.times import org.mockito.kotlin.verify @@ -70,13 +74,13 @@ class RedditPollingServiceTest { redditPendingPostRepository = redditPendingPostRepository, jda = jda ) - whenever(redditPostMirrorRepository.save(any())).thenAnswer { it.arguments[0] } } @Test fun `baseline stores current rss posts without discord message ids`() { whenever(redditRssClient.fetchNewestPosts("Re_Zero")).thenReturn(listOf(post("t3_first"))) whenever(redditPostMirrorRepository.findById("1:t3_first")).thenReturn(Optional.empty()) + whenever(redditPostMirrorRepository.save(any())).thenAnswer { it.arguments[0] } service.baselineCurrentPosts(1L, "Re_Zero") @@ -96,6 +100,7 @@ class RedditPollingServiceTest { whenever(redditPostMirrorRepository.findById("1:t3_new")).thenReturn(Optional.empty()) whenever(redditPendingPostRepository.existsById("1:t3_new")).thenReturn(false) whenever(redditPendingPostRepository.save(any())).thenAnswer { it.arguments[0] } + whenever(redditPostMirrorRepository.save(any())).thenAnswer { it.arguments[0] } whenever(textChannel.idLong).thenReturn(11L) whenever(textChannel.sendMessageEmbeds(any())).thenReturn(messageCreateAction) whenever(message.idLong).thenReturn(101L) @@ -114,6 +119,41 @@ class RedditPollingServiceTest { verify(redditPendingPostRepository).deleteById("1:t3_new") } + @Test + fun `baseline throws when rss client fails`() { + whenever(redditRssClient.fetchNewestPosts("Re_Zero")).thenThrow(RuntimeException("network error")) + + assertThrows { service.baselineCurrentPosts(1L, "Re_Zero") } + } + + @Test + fun `poll disables channel on terminal send failure`() { + val settings = RedditAlertSettings(1L, 11L) + whenever(redditRssClient.fetchNewestPosts("Re_Zero")).thenReturn(listOf(post("t3_new"))) + whenever(redditAlertSettingsRepository.findAll()).thenReturn(listOf(settings)) + whenever(jda.getTextChannelById(11L)).thenReturn(textChannel) + whenever(redditPostMirrorRepository.findAll()).thenReturn(emptyList()) + whenever(redditPostMirrorRepository.findById("1:t3_new")).thenReturn(Optional.empty()) + whenever(redditPendingPostRepository.existsById("1:t3_new")).thenReturn(false) + whenever(redditPendingPostRepository.save(any())).thenAnswer { it.arguments[0] } + whenever(textChannel.idLong).thenReturn(11L) + whenever(textChannel.sendMessageEmbeds(any())).thenReturn(messageCreateAction) + val exception = mock() + whenever(exception.errorResponse).thenReturn(ErrorResponse.MISSING_PERMISSIONS) + doAnswer { invocation -> + invocation.component2>().accept(exception) + null + }.whenever(messageCreateAction).queue(any(), any()) + + service.pollSubreddit() + + val settingsCaptor = argumentCaptor() + verify(redditAlertSettingsRepository).save(settingsCaptor.capture()) + assertEquals(null, settingsCaptor.firstValue.channelId) + verify(redditPostMirrorRepository, never()).save(any()) + verify(redditPendingPostRepository).deleteById("1:t3_new") + } + @Test fun `poll deletes mirrored message when tracked post is missing inside rss window`() { val trackedMirror = RedditPostMirror( @@ -154,6 +194,7 @@ class RedditPollingServiceTest { ) ) ) + whenever(redditPostMirrorRepository.save(any())).thenAnswer { it.arguments[0] } whenever(textChannel.deleteMessageById(101L)).thenReturn(deleteAction) doAnswer { invocation -> invocation.component1>().accept(null) From 9968c99364f894d1cbb0f996a09a053477b43f02 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 4 Jul 2026 19:45:55 +0000 Subject: [PATCH 3/5] Fixed Reddit items 2-4,6,8-10. Co-authored-by: DuncanCasteleyn --- .../reddit/RedditConfigCommand.kt | 7 ++-- .../reddit/RedditPollingService.kt | 13 ++++-- .../discordmodbot/reddit/RedditRssClient.kt | 1 + .../reddit/persistence/RedditAlertSettings.kt | 8 +--- .../persistence/RedditPostMirrorRepository.kt | 4 +- .../reddit/RedditConfigCommandTest.kt | 21 +++++++++- .../reddit/RedditPollingServiceTest.kt | 42 ++++++++++++++++--- .../reddit/RedditRssClientTest.kt | 26 ++++++++++++ 8 files changed, 100 insertions(+), 22 deletions(-) diff --git a/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommand.kt b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommand.kt index e8214868..934d097b 100644 --- a/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommand.kt +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommand.kt @@ -38,7 +38,7 @@ class RedditConfigCommand( private const val SUBCOMMAND_SET_CHANNEL = "set-channel" private const val SUBCOMMAND_SET_SUBREDDIT = "set-subreddit" private const val SUBCOMMAND_DISABLE = "disable" - private val SUBREDDIT_REGEX = Regex("[A-Za-z0-9_]{2,21}") + private val SUBREDDIT_REGEX = Regex("^(?=.*[A-Za-z0-9])[A-Za-z0-9_]{3,21}$") } override fun onGuildLeave(event: GuildLeaveEvent) { @@ -163,7 +163,6 @@ class RedditConfigCommand( appendLine() appendLine("- Subreddit: r/${settings?.subreddit ?: redditProperties.subreddit}") appendLine("- Mirror channel: ${formatChannel(guild, settings?.channelId)}") - appendLine("- Mentions: Disabled") } event.reply(message).setEphemeral(true).queue() @@ -187,8 +186,8 @@ class RedditConfigCommand( } private fun clearTrackedPostsExcept(guildId: Long, keepPostIds: Set) { - redditPostMirrorRepository.findAll() - .filter { it.guildId == guildId && it.redditPostId !in keepPostIds } + redditPostMirrorRepository.findAllByGuildId(guildId) + .filter { it.redditPostId !in keepPostIds } .forEach { redditPostMirrorRepository.delete(it) } redditPendingPostRepository.findAll() .filter { it.id.startsWith("$guildId:") } diff --git a/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingService.kt b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingService.kt index 10a9ab27..9d1cb57f 100644 --- a/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingService.kt +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingService.kt @@ -16,6 +16,7 @@ import net.dv8tion.jda.api.requests.ErrorResponse import org.slf4j.LoggerFactory import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional import java.awt.Color import java.time.Instant @@ -32,6 +33,7 @@ class RedditPollingService( } @Scheduled(cron = $$"${discord-mod-bot.reddit.poll-cron:0 */2 * * * *}") + @Transactional fun pollSubreddit() { val settings = redditAlertSettingsRepository.findAll().filter { it.channelId != null } if (settings.isEmpty()) { @@ -94,7 +96,12 @@ class RedditPollingService( } cleanupRemovedPosts(settings.guildId, posts) - posts.sortedBy { it.publishedAt }.forEach { post -> mirrorPost(settings, channel, post) } + posts.sortedBy { it.publishedAt }.forEach { post -> + if (settings.channelId == null) { + return + } + mirrorPost(settings, channel, post) + } } private fun mirrorPost(settings: RedditAlertSettings, channel: TextChannel, post: RedditPost) { @@ -146,8 +153,8 @@ class RedditPollingService( private fun cleanupRemovedPosts(guildId: Long, posts: List) { val currentPostIds = posts.mapTo(mutableSetOf()) { it.id } val oldestFeedPost = posts.minByOrNull { it.publishedAt } ?: return - redditPostMirrorRepository.findAll() - .filter { it.guildId == guildId && !it.deleted && it.discordChannelId != null && it.discordMessageId != null } + redditPostMirrorRepository.findAllByGuildId(guildId) + .filter { !it.deleted && it.discordChannelId != null && it.discordMessageId != null } .filter { it.redditPostId !in currentPostIds && !it.publishedAt.isBefore(oldestFeedPost.publishedAt) } .forEach { mirror -> deleteMirroredMessage(mirror) } } diff --git a/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClient.kt b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClient.kt index da52c3da..577836a9 100644 --- a/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClient.kt +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClient.kt @@ -24,6 +24,7 @@ class RedditRssClient( internal fun parse(feed: String): List { val factory = DocumentBuilderFactory.newInstance() factory.isNamespaceAware = true + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) val document = factory.newDocumentBuilder().parse(ByteArrayInputStream(feed.toByteArray(Charsets.UTF_8))) val entries = document.getElementsByTagNameNS(ATOM_NAMESPACE, "entry") return (0 until entries.length).map { index -> diff --git a/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditAlertSettings.kt b/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditAlertSettings.kt index bfa09dd6..47d3a14b 100644 --- a/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditAlertSettings.kt +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditAlertSettings.kt @@ -14,9 +14,5 @@ data class RedditAlertSettings( @Column(nullable = true) var channelId: Long? = null, @Column(nullable = false, length = 100) - var subreddit: String = DEFAULT_SUBREDDIT -) { - companion object { - const val DEFAULT_SUBREDDIT = "Re_Zero" - } -} + var subreddit: String +) diff --git a/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPostMirrorRepository.kt b/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPostMirrorRepository.kt index e9ae49e8..192e3f77 100644 --- a/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPostMirrorRepository.kt +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPostMirrorRepository.kt @@ -4,4 +4,6 @@ import org.springframework.data.keyvalue.repository.KeyValueRepository import org.springframework.stereotype.Repository @Repository -interface RedditPostMirrorRepository : KeyValueRepository +interface RedditPostMirrorRepository : KeyValueRepository { + fun findAllByGuildId(guildId: Long): List +} diff --git a/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommandTest.kt b/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommandTest.kt index a686e762..b33e990b 100644 --- a/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommandTest.kt +++ b/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommandTest.kt @@ -22,6 +22,7 @@ import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mockito.kotlin.any import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever @@ -112,7 +113,7 @@ class RedditConfigCommandTest { whenever(redditAlertSettingsRepository.findById(1L)).thenReturn( Optional.of(RedditAlertSettings(guildId = 1L, channelId = 11L, subreddit = "Re_Zero")) ) - whenever(redditPostMirrorRepository.findAll()).thenReturn(listOf(mirror)) + whenever(redditPostMirrorRepository.findAllByGuildId(1L)).thenReturn(listOf(mirror)) whenever(redditPendingPostRepository.findAll()).thenReturn(listOf(pending)) whenever(redditPollingService.baselineCurrentPosts(1L, "Anime")).thenReturn( listOf( @@ -187,7 +188,7 @@ class RedditConfigCommandTest { publishedAt = Instant.parse("2026-07-02T12:00:00Z"), permalink = "https://www.reddit.com/r/Re_Zero/comments/old/title/" ) - whenever(redditPostMirrorRepository.findAll()).thenReturn(listOf(mirror)) + whenever(redditPostMirrorRepository.findAllByGuildId(1L)).thenReturn(listOf(mirror)) whenever(redditPendingPostRepository.findAll()).thenReturn(emptyList()) command.onSlashCommandInteraction(slashEvent) @@ -215,6 +216,22 @@ class RedditConfigCommandTest { assertEquals(true, replyCaptor.firstValue.contains("- Mirror channel: <#11>")) } + @Test + fun `set subreddit rejects invalid subreddit names`() { + stubSlashCommandContext() + whenever(member.hasPermission(Permission.MANAGE_CHANNEL)).thenReturn(true) + whenever(slashEvent.subcommandName).thenReturn("set-subreddit") + val optionMapping = mock() + whenever(slashEvent.getOption("subreddit")).thenReturn(optionMapping) + whenever(optionMapping.asString).thenReturn("__") + command.subreddit = null + + command.onSlashCommandInteraction(slashEvent) + + verify(redditAlertSettingsRepository, never()).findById(any()) + verify(slashEvent).reply("Please provide a valid subreddit name.") + } + @Test fun `command data exposes expected subcommands`() { val commandData = command.getCommandsData().single() diff --git a/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingServiceTest.kt b/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingServiceTest.kt index 44c10ea2..25138093 100644 --- a/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingServiceTest.kt +++ b/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingServiceTest.kt @@ -94,9 +94,9 @@ class RedditPollingServiceTest { @Test fun `poll mirrors new rss post and stores discord message id`() { whenever(redditRssClient.fetchNewestPosts("Re_Zero")).thenReturn(listOf(post("t3_new"))) - whenever(redditAlertSettingsRepository.findAll()).thenReturn(listOf(RedditAlertSettings(1L, 11L))) + whenever(redditAlertSettingsRepository.findAll()).thenReturn(listOf(RedditAlertSettings(1L, 11L, "Re_Zero"))) whenever(jda.getTextChannelById(11L)).thenReturn(textChannel) - whenever(redditPostMirrorRepository.findAll()).thenReturn(emptyList()) + whenever(redditPostMirrorRepository.findAllByGuildId(1L)).thenReturn(emptyList()) whenever(redditPostMirrorRepository.findById("1:t3_new")).thenReturn(Optional.empty()) whenever(redditPendingPostRepository.existsById("1:t3_new")).thenReturn(false) whenever(redditPendingPostRepository.save(any())).thenAnswer { it.arguments[0] } @@ -128,11 +128,11 @@ class RedditPollingServiceTest { @Test fun `poll disables channel on terminal send failure`() { - val settings = RedditAlertSettings(1L, 11L) + val settings = RedditAlertSettings(1L, 11L, "Re_Zero") whenever(redditRssClient.fetchNewestPosts("Re_Zero")).thenReturn(listOf(post("t3_new"))) whenever(redditAlertSettingsRepository.findAll()).thenReturn(listOf(settings)) whenever(jda.getTextChannelById(11L)).thenReturn(textChannel) - whenever(redditPostMirrorRepository.findAll()).thenReturn(emptyList()) + whenever(redditPostMirrorRepository.findAllByGuildId(1L)).thenReturn(emptyList()) whenever(redditPostMirrorRepository.findById("1:t3_new")).thenReturn(Optional.empty()) whenever(redditPendingPostRepository.existsById("1:t3_new")).thenReturn(false) whenever(redditPendingPostRepository.save(any())).thenAnswer { it.arguments[0] } @@ -154,6 +154,36 @@ class RedditPollingServiceTest { verify(redditPendingPostRepository).deleteById("1:t3_new") } + @Test + fun `poll skips remaining posts after terminal send failure disables channel`() { + val settings = RedditAlertSettings(1L, 11L, "Re_Zero") + whenever(redditRssClient.fetchNewestPosts("Re_Zero")).thenReturn( + listOf( + post("t3_first"), + post("t3_second") + ) + ) + whenever(redditAlertSettingsRepository.findAll()).thenReturn(listOf(settings)) + whenever(jda.getTextChannelById(11L)).thenReturn(textChannel) + whenever(redditPostMirrorRepository.findAllByGuildId(1L)).thenReturn(emptyList()) + whenever(redditPostMirrorRepository.findById("1:t3_first")).thenReturn(Optional.empty()) + whenever(redditPendingPostRepository.existsById("1:t3_first")).thenReturn(false) + whenever(redditPendingPostRepository.save(any())).thenAnswer { it.arguments[0] } + whenever(textChannel.idLong).thenReturn(11L) + whenever(textChannel.sendMessageEmbeds(any())).thenReturn(messageCreateAction) + val exception = mock() + whenever(exception.errorResponse).thenReturn(ErrorResponse.MISSING_PERMISSIONS) + doAnswer { invocation -> + invocation.component2>().accept(exception) + null + }.whenever(messageCreateAction).queue(any(), any()) + + service.pollSubreddit() + + verify(textChannel, times(1)).sendMessageEmbeds(any()) + verify(redditAlertSettingsRepository).save(settings) + } + @Test fun `poll deletes mirrored message when tracked post is missing inside rss window`() { val trackedMirror = RedditPostMirror( @@ -171,9 +201,9 @@ class RedditPollingServiceTest { post("t3_older", publishedAt = Instant.parse("2026-07-02T11:00:00Z")) ) ) - whenever(redditAlertSettingsRepository.findAll()).thenReturn(listOf(RedditAlertSettings(1L, 11L))) + whenever(redditAlertSettingsRepository.findAll()).thenReturn(listOf(RedditAlertSettings(1L, 11L, "Re_Zero"))) whenever(jda.getTextChannelById(11L)).thenReturn(textChannel) - whenever(redditPostMirrorRepository.findAll()).thenReturn(listOf(trackedMirror)) + whenever(redditPostMirrorRepository.findAllByGuildId(1L)).thenReturn(listOf(trackedMirror)) whenever(redditPostMirrorRepository.findById("1:t3_older")).thenReturn( Optional.of( trackedMirror.copy( diff --git a/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClientTest.kt b/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClientTest.kt index 6d96ad1b..04c4ba3c 100644 --- a/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClientTest.kt +++ b/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClientTest.kt @@ -1,12 +1,38 @@ package be.duncanc.discordmodbot.reddit import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows import org.junit.jupiter.api.Test import org.mockito.kotlin.mock import org.springframework.web.client.RestClient import java.time.Instant class RedditRssClientTest { + @Test + fun `parse rejects feeds with doctype declarations`() { + val client = RedditRssClient( + redditRestClient = mock() + ) + + assertThrows(Exception::class.java) { + client.parse( + """ + + + ]> + + + t3_xxe + XXE + 2026-07-02T21:29:12+00:00 + + + """.trimIndent() + ) + } + } + @Test fun `parse reads reddit atom entries`() { val client = RedditRssClient( From 1215df0b16bbc7500b837152879bf9d6113a0ab1 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 28 Jul 2026 15:11:15 +0000 Subject: [PATCH 4/5] All Reddit review fixes validated. Co-authored-by: DuncanCasteleyn --- .../reddit/RedditConfigCommand.kt | 3 +- .../reddit/RedditPollingService.kt | 27 +++++++++++-- .../discordmodbot/reddit/RedditProperties.kt | 2 + .../discordmodbot/reddit/RedditRssClient.kt | 12 +++++- .../reddit/RedditRssClientConfig.kt | 6 ++- .../reddit/persistence/RedditAlertSettings.kt | 19 ++++++++- .../reddit/persistence/RedditPendingPost.kt | 5 ++- .../RedditPendingPostRepository.kt | 4 +- .../reddit/persistence/RedditPostMirror.kt | 2 + ...sql => V18__add_reddit_alert_settings.sql} | 0 .../reddit/RedditConfigCommandTest.kt | 7 ++-- .../reddit/RedditPollingServiceTest.kt | 39 +++++++++++++++++++ .../reddit/RedditRssClientTest.kt | 32 +++++++++++++++ 13 files changed, 142 insertions(+), 16 deletions(-) rename src/main/resources/db/migration/{V17__add_reddit_alert_settings.sql => V18__add_reddit_alert_settings.sql} (100%) diff --git a/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommand.kt b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommand.kt index 934d097b..1f9cda26 100644 --- a/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommand.kt +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommand.kt @@ -189,8 +189,7 @@ class RedditConfigCommand( redditPostMirrorRepository.findAllByGuildId(guildId) .filter { it.redditPostId !in keepPostIds } .forEach { redditPostMirrorRepository.delete(it) } - redditPendingPostRepository.findAll() - .filter { it.id.startsWith("$guildId:") } + redditPendingPostRepository.findAllByGuildId(guildId) .forEach { redditPendingPostRepository.delete(it) } } diff --git a/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingService.kt b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingService.kt index 9d1cb57f..c9a1306d 100644 --- a/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingService.kt +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingService.kt @@ -18,6 +18,7 @@ import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional import java.awt.Color +import java.net.URI import java.time.Instant @Service @@ -100,7 +101,11 @@ class RedditPollingService( if (settings.channelId == null) { return } - mirrorPost(settings, channel, post) + try { + mirrorPost(settings, channel, post) + } catch (exception: Exception) { + LOG.warn("Failed to process Reddit post {} for guild {}", post.id, settings.guildId, exception) + } } } @@ -118,7 +123,7 @@ class RedditPollingService( return } - redditPendingPostRepository.save(RedditPendingPost(pendingId)) + redditPendingPostRepository.save(RedditPendingPost(pendingId, guildId)) sendPostMessage( channel = channel, embed = buildPostEmbed(subreddit, post), @@ -162,7 +167,12 @@ class RedditPollingService( private fun deleteMirroredMessage(mirror: RedditPostMirror) { val channelId = mirror.discordChannelId ?: return val messageId = mirror.discordMessageId ?: return - val channel = jda.getTextChannelById(channelId) ?: return + val channel = jda.getTextChannelById(channelId) + if (channel == null) { + mirror.deleted = true + redditPostMirrorRepository.save(mirror) + return + } channel.deleteMessageById(messageId).queue( { mirror.deleted = true @@ -208,13 +218,22 @@ class RedditPollingService( .addField("Reddit", truncate(post.permalink, MessageEmbed.VALUE_MAX_LENGTH), false) val thumbnailUrl = post.thumbnailUrl - if (thumbnailUrl != null) { + if (thumbnailUrl != null && isValidThumbnailUrl(thumbnailUrl)) { embed.setImage(thumbnailUrl) } return embed.build() } + private fun isValidThumbnailUrl(thumbnailUrl: String): Boolean { + return try { + val url = URI(thumbnailUrl).toURL() + (url.protocol == "http" || url.protocol == "https") && url.host.isNotBlank() + } catch (exception: Exception) { + false + } + } + internal fun isTerminalMessageFailure(exception: Throwable): Boolean { val errorResponseException = exception as? ErrorResponseException ?: return false return when (errorResponseException.errorResponse) { diff --git a/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditProperties.kt b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditProperties.kt index f331c0c2..fc4e512a 100644 --- a/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditProperties.kt +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditProperties.kt @@ -17,6 +17,8 @@ data class RedditProperties( val pollCron: String, @DefaultValue("10s") val readTimeout: Duration, + @DefaultValue("10s") + val connectTimeout: Duration, @NotEmpty @DefaultValue("DiscordModBot reddit RSS mirror") val userAgent: String diff --git a/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClient.kt b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClient.kt index 577836a9..f42b93e1 100644 --- a/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClient.kt +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClient.kt @@ -27,14 +27,22 @@ class RedditRssClient( factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true) val document = factory.newDocumentBuilder().parse(ByteArrayInputStream(feed.toByteArray(Charsets.UTF_8))) val entries = document.getElementsByTagNameNS(ATOM_NAMESPACE, "entry") - return (0 until entries.length).map { index -> + return (0 until entries.length).mapNotNull { index -> val entry = entries.item(index) as Element + val publishedAt = try { + Instant.parse(entry.text("published")) + } catch (exception: Exception) { + null + } + if (publishedAt == null) { + return@mapNotNull null + } RedditPost( id = entry.text("id"), title = entry.text("title"), author = entry.child("author")?.text("name")?.removePrefix("/u/"), permalink = entry.child("link")?.getAttribute("href") ?: "", - publishedAt = Instant.parse(entry.text("published")), + publishedAt = publishedAt, thumbnailUrl = entry.child(MEDIA_NAMESPACE, "thumbnail")?.getAttribute("url")?.takeIf { it.isNotBlank() } ) }.filter { it.id.isNotBlank() && it.permalink.isNotBlank() } diff --git a/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClientConfig.kt b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClientConfig.kt index 610ec67d..d4b3e499 100644 --- a/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClientConfig.kt +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClientConfig.kt @@ -4,6 +4,7 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.http.client.JdkClientHttpRequestFactory import org.springframework.web.client.RestClient +import java.net.http.HttpClient @Configuration class RedditRssClientConfig { @@ -16,7 +17,10 @@ class RedditRssClientConfig { redditProperties: RedditProperties, restClientBuilder: RestClient.Builder ): RestClient { - val requestFactory = JdkClientHttpRequestFactory() + val httpClient = HttpClient.newBuilder() + .connectTimeout(redditProperties.connectTimeout) + .build() + val requestFactory = JdkClientHttpRequestFactory(httpClient) requestFactory.setReadTimeout(redditProperties.readTimeout) return restClientBuilder diff --git a/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditAlertSettings.kt b/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditAlertSettings.kt index 47d3a14b..f8b42b12 100644 --- a/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditAlertSettings.kt +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditAlertSettings.kt @@ -7,7 +7,7 @@ import jakarta.persistence.Table @Entity @Table(name = "reddit_alert_settings") -data class RedditAlertSettings( +class RedditAlertSettings( @Id @Column(updatable = false) val guildId: Long, @@ -15,4 +15,19 @@ data class RedditAlertSettings( var channelId: Long? = null, @Column(nullable = false, length = 100) var subreddit: String -) +) { + constructor() : this(0L, null, "") + + override fun equals(other: Any?): Boolean { + if (this === other) { + return true + } + if (javaClass != other?.javaClass) { + return false + } + other as RedditAlertSettings + return guildId == other.guildId + } + + override fun hashCode(): Int = guildId.hashCode() +} diff --git a/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPendingPost.kt b/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPendingPost.kt index 2a0bdcb6..253f8adf 100644 --- a/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPendingPost.kt +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPendingPost.kt @@ -2,11 +2,14 @@ package be.duncanc.discordmodbot.reddit.persistence import org.springframework.data.annotation.Id import org.springframework.data.redis.core.RedisHash +import org.springframework.data.redis.core.index.Indexed @RedisHash("redditPendingPost", timeToLive = 900) data class RedditPendingPost( @Id - val id: String + val id: String, + @Indexed + val guildId: Long ) { companion object { fun id(guildId: Long, redditPostId: String): String = "$guildId:$redditPostId" diff --git a/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPendingPostRepository.kt b/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPendingPostRepository.kt index 318b1ec8..874ad3be 100644 --- a/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPendingPostRepository.kt +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPendingPostRepository.kt @@ -4,4 +4,6 @@ import org.springframework.data.keyvalue.repository.KeyValueRepository import org.springframework.stereotype.Repository @Repository -interface RedditPendingPostRepository : KeyValueRepository +interface RedditPendingPostRepository : KeyValueRepository { + fun findAllByGuildId(guildId: Long): List +} diff --git a/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPostMirror.kt b/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPostMirror.kt index 4e7223aa..95b0fc5f 100644 --- a/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPostMirror.kt +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPostMirror.kt @@ -2,12 +2,14 @@ package be.duncanc.discordmodbot.reddit.persistence import org.springframework.data.annotation.Id import org.springframework.data.redis.core.RedisHash +import org.springframework.data.redis.core.index.Indexed import java.time.Instant @RedisHash("redditPostMirror", timeToLive = 86400) data class RedditPostMirror( @Id val id: String, + @Indexed val guildId: Long, val redditPostId: String, val discordChannelId: Long?, diff --git a/src/main/resources/db/migration/V17__add_reddit_alert_settings.sql b/src/main/resources/db/migration/V18__add_reddit_alert_settings.sql similarity index 100% rename from src/main/resources/db/migration/V17__add_reddit_alert_settings.sql rename to src/main/resources/db/migration/V18__add_reddit_alert_settings.sql diff --git a/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommandTest.kt b/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommandTest.kt index b33e990b..86a075e4 100644 --- a/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommandTest.kt +++ b/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommandTest.kt @@ -72,6 +72,7 @@ class RedditConfigCommandTest { subreddit = "Re_Zero", pollCron = "0 */2 * * * *", readTimeout = Duration.ofSeconds(10), + connectTimeout = Duration.ofSeconds(10), userAgent = "test" ) ) @@ -109,12 +110,12 @@ class RedditConfigCommandTest { publishedAt = Instant.parse("2026-07-02T12:00:00Z"), permalink = "https://www.reddit.com/r/Re_Zero/comments/old/title/" ) - val pending = RedditPendingPost("1:t3_pending") + val pending = RedditPendingPost("1:t3_pending", 1L) whenever(redditAlertSettingsRepository.findById(1L)).thenReturn( Optional.of(RedditAlertSettings(guildId = 1L, channelId = 11L, subreddit = "Re_Zero")) ) whenever(redditPostMirrorRepository.findAllByGuildId(1L)).thenReturn(listOf(mirror)) - whenever(redditPendingPostRepository.findAll()).thenReturn(listOf(pending)) + whenever(redditPendingPostRepository.findAllByGuildId(1L)).thenReturn(listOf(pending)) whenever(redditPollingService.baselineCurrentPosts(1L, "Anime")).thenReturn( listOf( RedditPost( @@ -189,7 +190,7 @@ class RedditConfigCommandTest { permalink = "https://www.reddit.com/r/Re_Zero/comments/old/title/" ) whenever(redditPostMirrorRepository.findAllByGuildId(1L)).thenReturn(listOf(mirror)) - whenever(redditPendingPostRepository.findAll()).thenReturn(emptyList()) + whenever(redditPendingPostRepository.findAllByGuildId(1L)).thenReturn(emptyList()) command.onSlashCommandInteraction(slashEvent) diff --git a/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingServiceTest.kt b/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingServiceTest.kt index 25138093..df88c2a4 100644 --- a/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingServiceTest.kt +++ b/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingServiceTest.kt @@ -184,6 +184,45 @@ class RedditPollingServiceTest { verify(redditAlertSettingsRepository).save(settings) } + @Test + fun `poll marks tracked mirror deleted when mirrored channel is unavailable`() { + val trackedMirror = RedditPostMirror( + id = "1:t3_removed", + guildId = 1L, + redditPostId = "t3_removed", + discordChannelId = 12L, + discordMessageId = 101L, + publishedAt = Instant.parse("2026-07-02T12:00:00Z"), + permalink = "https://www.reddit.com/r/Re_Zero/comments/removed/title/" + ) + whenever(redditRssClient.fetchNewestPosts("Re_Zero")).thenReturn( + listOf( + post("t3_newer", publishedAt = Instant.parse("2026-07-02T11:00:00Z")) + ) + ) + whenever(redditAlertSettingsRepository.findAll()).thenReturn(listOf(RedditAlertSettings(1L, 11L, "Re_Zero"))) + whenever(jda.getTextChannelById(11L)).thenReturn(textChannel) + whenever(redditPostMirrorRepository.findAllByGuildId(1L)).thenReturn(listOf(trackedMirror)) + whenever(redditPostMirrorRepository.save(any())).thenAnswer { it.arguments[0] } + whenever(jda.getTextChannelById(12L)).thenReturn(null) + + service.pollSubreddit() + + val mirrorCaptor = argumentCaptor() + verify(redditPostMirrorRepository).save(mirrorCaptor.capture()) + assertEquals(true, mirrorCaptor.firstValue.deleted) + } + + @Test + fun `buildPostEmbed ignores invalid thumbnail urls`() { + val embed = service.buildPostEmbed( + "Re_Zero", + post("t3_first").copy(thumbnailUrl = "not-a-valid-url") + ) + + assertEquals(null, embed.image) + } + @Test fun `poll deletes mirrored message when tracked post is missing inside rss window`() { val trackedMirror = RedditPostMirror( diff --git a/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClientTest.kt b/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClientTest.kt index 04c4ba3c..7d673e65 100644 --- a/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClientTest.kt +++ b/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClientTest.kt @@ -33,6 +33,38 @@ class RedditRssClientTest { } } + @Test + fun `parse skips entries with malformed published timestamp`() { + val client = RedditRssClient( + redditRestClient = mock() + ) + + val posts = client.parse( + """ + + + + /u/Subaru + t3_invalid + + not-a-timestamp + Invalid timestamp + + + /u/Rem + t3_valid + + 2026-07-02T21:29:12+00:00 + Valid timestamp + + + """.trimIndent() + ) + + assertEquals(1, posts.size) + assertEquals("t3_valid", posts.first().id) + } + @Test fun `parse reads reddit atom entries`() { val client = RedditRssClient( From 0f17b441dd322146e5d57087d5970ee0b539fcd3 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 7 Aug 2026 21:58:02 +0000 Subject: [PATCH 5/5] Fixed Reddit polling transactions, tests pass. Co-authored-by: DuncanCasteleyn --- .../discordmodbot/reddit/RedditPollingService.kt | 15 +++++++++++++-- .../reddit/RedditPollingServiceTest.kt | 1 + 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingService.kt b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingService.kt index c9a1306d..4b70bc8f 100644 --- a/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingService.kt +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingService.kt @@ -14,6 +14,8 @@ import net.dv8tion.jda.api.entities.channel.concrete.TextChannel import net.dv8tion.jda.api.exceptions.ErrorResponseException import net.dv8tion.jda.api.requests.ErrorResponse import org.slf4j.LoggerFactory +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Lazy import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -33,8 +35,11 @@ class RedditPollingService( private val LOG = LoggerFactory.getLogger(RedditPollingService::class.java) } + @set:Autowired + @set:Lazy + lateinit var self: RedditPollingService + @Scheduled(cron = $$"${discord-mod-bot.reddit.poll-cron:0 */2 * * * *}") - @Transactional fun pollSubreddit() { val settings = redditAlertSettingsRepository.findAll().filter { it.channelId != null } if (settings.isEmpty()) { @@ -52,10 +57,11 @@ class RedditPollingService( return@forEach } - subredditSettings.forEach { processGuild(it, posts) } + subredditSettings.forEach { self.processGuildTransactional(it, posts) } } } + @Transactional fun baselineCurrentPosts(guildId: Long, subreddit: String): List { val posts = try { redditRssClient.fetchNewestPosts(subreddit) @@ -88,6 +94,11 @@ class RedditPollingService( return posts } + @Transactional + fun processGuildTransactional(settings: RedditAlertSettings, posts: List) { + processGuild(settings, posts) + } + private fun processGuild(settings: RedditAlertSettings, posts: List) { val channelId = settings.channelId ?: return val channel = jda.getTextChannelById(channelId) diff --git a/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingServiceTest.kt b/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingServiceTest.kt index df88c2a4..c3bf9db1 100644 --- a/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingServiceTest.kt +++ b/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingServiceTest.kt @@ -74,6 +74,7 @@ class RedditPollingServiceTest { redditPendingPostRepository = redditPendingPostRepository, jda = jda ) + service.self = service } @Test