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..1f9cda26 --- /dev/null +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommand.kt @@ -0,0 +1,204 @@ +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])[A-Za-z0-9_]{3,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 + ) + } + 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) + event.reply("Reddit posts from r/$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) + } + 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 + clearTrackedPostsExcept(guild.idLong, posts.map { it.id }.toSet()) + redditAlertSettingsRepository.save(settings) + 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)}") + } + + 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) { + clearTrackedPostsExcept(guildId, emptySet()) + } + + private fun clearTrackedPostsExcept(guildId: Long, keepPostIds: Set) { + redditPostMirrorRepository.findAllByGuildId(guildId) + .filter { it.redditPostId !in keepPostIds } + .forEach { redditPostMirrorRepository.delete(it) } + redditPendingPostRepository.findAllByGuildId(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..4b70bc8f --- /dev/null +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingService.kt @@ -0,0 +1,267 @@ +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.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 +import java.awt.Color +import java.net.URI +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) + } + + @set:Autowired + @set:Lazy + lateinit var self: RedditPollingService + + @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 { self.processGuildTransactional(it, posts) } + } + } + + @Transactional + 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) + throw IllegalStateException( + "Failed to reach Reddit while enabling alerts for r/$subreddit. Please try again later." + ) + } + + 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) + } + } + 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) + if (channel == null) { + disableMissingChannel(settings, channelId) + return + } + + cleanupRemovedPosts(settings.guildId, posts) + posts.sortedBy { it.publishedAt }.forEach { post -> + if (settings.channelId == null) { + return + } + try { + mirrorPost(settings, channel, post) + } catch (exception: Exception) { + LOG.warn("Failed to process Reddit post {} for guild {}", post.id, settings.guildId, exception) + } + } + } + + 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) { + redditPostMirrorRepository.save(existingMirror) + return + } + val pendingId = RedditPendingPost.id(guildId, post.id) + if (redditPendingPostRepository.existsById(pendingId)) { + return + } + + redditPendingPostRepository.save(RedditPendingPost(pendingId, guildId)) + 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 = 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) + } + ) + } + + private fun cleanupRemovedPosts(guildId: Long, posts: List) { + val currentPostIds = posts.mapTo(mutableSetOf()) { it.id } + val oldestFeedPost = posts.minByOrNull { it.publishedAt } ?: return + 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) } + } + + private fun deleteMirroredMessage(mirror: RedditPostMirror) { + val channelId = mirror.discordChannelId ?: return + val messageId = mirror.discordMessageId ?: return + val channel = jda.getTextChannelById(channelId) + if (channel == null) { + mirror.deleted = true + redditPostMirrorRepository.save(mirror) + 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) { + 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 {}: {}", settings.guildId, reason) + } + + 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 && 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) { + 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..fc4e512a --- /dev/null +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditProperties.kt @@ -0,0 +1,25 @@ +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, + @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 new file mode 100644 index 00000000..f42b93e1 --- /dev/null +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClient.kt @@ -0,0 +1,77 @@ +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 + 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).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 = publishedAt, + 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..d4b3e499 --- /dev/null +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClientConfig.kt @@ -0,0 +1,32 @@ +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 +import java.net.http.HttpClient + +@Configuration +class RedditRssClientConfig { + companion object { + private const val BASE_URL = "https://www.reddit.com" + } + + @Bean + fun redditRestClient( + redditProperties: RedditProperties, + restClientBuilder: RestClient.Builder + ): RestClient { + val httpClient = HttpClient.newBuilder() + .connectTimeout(redditProperties.connectTimeout) + .build() + val requestFactory = JdkClientHttpRequestFactory(httpClient) + 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..f8b42b12 --- /dev/null +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditAlertSettings.kt @@ -0,0 +1,33 @@ +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") +class RedditAlertSettings( + @Id + @Column(updatable = false) + val guildId: Long, + @Column(nullable = true) + 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/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..253f8adf --- /dev/null +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPendingPost.kt @@ -0,0 +1,17 @@ +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, + @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 new file mode 100644 index 00000000..874ad3be --- /dev/null +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPendingPostRepository.kt @@ -0,0 +1,9 @@ +package be.duncanc.discordmodbot.reddit.persistence + +import org.springframework.data.keyvalue.repository.KeyValueRepository +import org.springframework.stereotype.Repository + +@Repository +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 new file mode 100644 index 00000000..95b0fc5f --- /dev/null +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPostMirror.kt @@ -0,0 +1,24 @@ +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?, + 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..192e3f77 --- /dev/null +++ b/src/main/kotlin/be/duncanc/discordmodbot/reddit/persistence/RedditPostMirrorRepository.kt @@ -0,0 +1,9 @@ +package be.duncanc.discordmodbot.reddit.persistence + +import org.springframework.data.keyvalue.repository.KeyValueRepository +import org.springframework.stereotype.Repository + +@Repository +interface RedditPostMirrorRepository : KeyValueRepository { + fun findAllByGuildId(guildId: Long): List +} diff --git a/src/main/resources/db/migration/V18__add_reddit_alert_settings.sql b/src/main/resources/db/migration/V18__add_reddit_alert_settings.sql new file mode 100644 index 00000000..5159048b --- /dev/null +++ b/src/main/resources/db/migration/V18__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..86a075e4 --- /dev/null +++ b/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditConfigCommandTest.kt @@ -0,0 +1,287 @@ +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.mock +import org.mockito.kotlin.never +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), + connectTimeout = 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", 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.findAllByGuildId(1L)).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) + + 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 `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") + 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.findAllByGuildId(1L)).thenReturn(listOf(mirror)) + whenever(redditPendingPostRepository.findAllByGuildId(1L)).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 `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() + + 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..c3bf9db1 --- /dev/null +++ b/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditPollingServiceTest.kt @@ -0,0 +1,295 @@ +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.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 +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 + ) + service.self = service + } + + @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") + + 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, "Re_Zero"))) + whenever(jda.getTextChannelById(11L)).thenReturn(textChannel) + 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] } + whenever(redditPostMirrorRepository.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 `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, "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.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] } + 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 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 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( + 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, "Re_Zero"))) + whenever(jda.getTextChannelById(11L)).thenReturn(textChannel) + whenever(redditPostMirrorRepository.findAllByGuildId(1L)).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(redditPostMirrorRepository.save(any())).thenAnswer { it.arguments[0] } + 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..7d673e65 --- /dev/null +++ b/src/test/kotlin/be/duncanc/discordmodbot/reddit/RedditRssClientTest.kt @@ -0,0 +1,98 @@ +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 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( + 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) + } +}