Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ Repository guidance for coding agents working in `DiscordModBot`.
SPRING_DATASOURCE_DRIVERCLASSNAME=org.mariadb.jdbc.Driver \
./gradlew check
```
- On some machines Docker is Podman-backed: the `docker compose` v2 plugin is unavailable; use the standalone
`docker-compose` binary instead (e.g. `docker-compose up -d`).
- Environment variables do not invalidate Gradle's up-to-date checks, so a `check` run right after a plain
`./gradlew check` will skip `test` as UP-TO-DATE. Force re-execution with `./gradlew check --rerun-tasks` to
actually run the tests against MariaDB.
- Prefer H2 unless changing DB-specific behavior.
- Some tests (e.g. `GuildWarnPointRepositoryTest`) are `@Disabled` due to known Hibernate issues; leave them unless
fixing the root cause.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ import net.dv8tion.jda.api.events.interaction.component.ButtonInteractionEvent
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.utils.MarkdownUtil
import net.dv8tion.jda.api.utils.TimeFormat
Expand All @@ -37,6 +39,7 @@ class ReviewCommand(
) : ListenerAdapter(), SlashCommand {
companion object {
private const val COMMAND = "review"
private const val OPTION_MAX_MEMBERS = "max-members"
private const val BUTTON_PREFIX = "member-gate-review:"
private const val INTERRUPT_CONFIRM_PREFIX = "member-gate-review-interrupt:"
private const val APPROVE_ACTION = "approve"
Expand All @@ -62,6 +65,10 @@ class ReviewCommand(
Commands.slash(COMMAND, "Review pending member gate applications")
.setContexts(InteractionContextType.GUILD)
.setDefaultPermissions(DefaultMemberPermissions.enabledFor(Permission.MANAGE_ROLES))
.addOptions(
OptionData(OptionType.INTEGER, OPTION_MAX_MEMBERS, "Maximum number of members to review")
.setMinValue(1)
)
)
}

Expand All @@ -81,7 +88,9 @@ class ReviewCommand(
return
}

val session = reviewManager.createSession(guild.idLong)
reviewManager.pruneStaleApplicants(guild, event.jda)
val maxMembers = event.getOption(OPTION_MAX_MEMBERS)?.asInt
val session = reviewManager.createSession(guild.idLong, maxMembers)
if (session == null) {
event.reply("Nobody is currently waiting for approval.").setEphemeral(true).queue()
return
Expand All @@ -96,7 +105,7 @@ class ReviewCommand(

val otherSessions = reviewSessionRegistry.getOtherSessions(guild.idLong, event.user.idLong)
if (otherSessions.isNotEmpty()) {
val token = rememberInterruptConfirmation(guild.idLong, event.user.idLong, otherSessions)
val token = rememberInterruptConfirmation(guild.idLong, event.user.idLong, otherSessions, maxMembers)
event.reply(buildInterruptPrompt(guild, otherSessions))
.setEphemeral(true)
.addComponents(ActionRow.of(buildInterruptButtons(token)))
Expand Down Expand Up @@ -230,7 +239,9 @@ class ReviewCommand(
if (pendingQuestion == null) {
logReviewCompleted(guild, event.member!!, session)
reviewSessionRegistry.forget(guild.idLong, event.user.idLong)
event.editMessage(buildCompletionMessage(feedback)).setComponents(emptyList()).queue()
event.editMessage(buildCompletionMessage(feedback, reviewManager.hasPendingApplicants(guild.idLong)))
.setComponents(emptyList())
.queue()
return
}

Expand All @@ -247,7 +258,9 @@ class ReviewCommand(
if (pendingQuestion == null) {
logReviewCompleted(guild, event.member!!, session)
reviewSessionRegistry.forget(guild.idLong, event.user.idLong)
event.editMessage(buildCompletionMessage(feedback)).setComponents(emptyList()).queue()
event.editMessage(buildCompletionMessage(feedback, reviewManager.hasPendingApplicants(guild.idLong)))
.setComponents(emptyList())
.queue()
return
}

Expand Down Expand Up @@ -315,7 +328,8 @@ class ReviewCommand(
return
}

val session = reviewManager.createSession(guild.idLong)
reviewManager.pruneStaleApplicants(guild, event.jda)
val session = reviewManager.createSession(guild.idLong, confirmation.maxMembers)
if (session == null) {
event.editMessage("Nobody is currently waiting for approval.").setComponents(emptyList()).queue()
return
Expand Down Expand Up @@ -355,15 +369,17 @@ class ReviewCommand(
private fun rememberInterruptConfirmation(
guildId: Long,
reviewerId: Long,
sessions: List<ReviewSessionRegistry.StoredReviewSession>
sessions: List<ReviewSessionRegistry.StoredReviewSession>,
maxMembers: Int? = null
): String {
val token = UUID.randomUUID().toString().replace("-", "").take(12)
reviewInterruptConfirmationRepository.save(
ReviewInterruptConfirmation(
id = token,
guildId = guildId,
reviewerId = reviewerId,
targetSessionIds = sessions.associate { it.reviewerId to it.sessionId }
targetSessionIds = sessions.associate { it.reviewerId to it.sessionId },
maxMembers = maxMembers
)
)
return token
Expand Down Expand Up @@ -407,8 +423,12 @@ class ReviewCommand(
"\nChoose `Approve`, `Reject`, or `Manual action`."
}

private fun buildCompletionMessage(feedback: String): String {
return "$feedback\n\nThere are no more pending applicants in the queue."
private fun buildCompletionMessage(feedback: String, hasMoreApplicants: Boolean): String {
return if (hasMoreApplicants) {
"$feedback\n\nThere are still applicants waiting for approval. Run `/review` again to continue."
} else {
"$feedback\n\nThere are no more pending applicants in the queue."
}
}

private fun buildInterruptPrompt(
Expand Down Expand Up @@ -436,11 +456,17 @@ class ReviewCommand(
}

private fun logReviewStarted(guild: Guild, moderator: Member, session: ReviewSession) {
val sessionSize = session.toPendingUserIds().size
val totalPending = reviewManager.countPendingApplicants(guild.idLong)
val logEmbed = EmbedBuilder()
.setColor(Color.GREEN)
.setTitle("Member gate review started")
.addField("Moderator", moderator.nicknameAndUsername, false)
.addField("Pending applicants", session.toPendingUserIds().size.toString(), true)
.addField("Pending applicants", totalPending.toString(), true)

if (sessionSize != totalPending) {
logEmbed.addField("In this session", sessionSize.toString(), true)
}

guildLogger.log(logEmbed, moderator.user, guild, null, GuildLogger.LogTypeAction.MODERATOR)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import be.duncanc.discordmodbot.member.gate.persistence.MemberGateQuestionReposi
import net.dv8tion.jda.api.JDA
import net.dv8tion.jda.api.entities.Guild
import net.dv8tion.jda.api.entities.Member
import net.dv8tion.jda.api.exceptions.ErrorResponseException
import net.dv8tion.jda.api.requests.ErrorResponse
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
import java.util.concurrent.TimeUnit
Expand All @@ -15,8 +18,12 @@ class ReviewManager(
private val memberGateService: MemberGateService,
private val promptRegistry: ReviewPromptRegistry
) {
companion object {
private val LOG = LoggerFactory.getLogger(ReviewManager::class.java)
}

@Transactional(readOnly = true)
fun createSession(guildId: Long): ReviewSession? {
fun createSession(guildId: Long, maxMembers: Int? = null): ReviewSession? {
val storedQuestions = memberGateQuestionRepository.findAll()

val pendingUserIds = storedQuestions
Expand All @@ -25,11 +32,50 @@ class ReviewManager(
.filter { it.guildId == guildId && it.userId.toULong() > 0uL }
.sortedBy { it.queuedAt }
.map { it.userId }
.take(maxMembers ?: Int.MAX_VALUE)
.toList()

return pendingUserIds.takeIf { it.isNotEmpty() }?.let(::ReviewSession)
}

@Transactional
fun pruneStaleApplicants(guild: Guild, jda: JDA) {
memberGateQuestionRepository.findAll()
.filterNotNull()
.filter { it.guildId == guild.idLong && it.userId.toULong() > 0uL }
.forEach { question ->
guild.retrieveMemberById(question.userId).queue(
{ },
{ throwable ->
if ((throwable as? ErrorResponseException)?.errorResponse == ErrorResponse.UNKNOWN_MEMBER) {
clearPendingQuestion(guild.idLong, jda, question.userId)
} else {
LOG.warn(
"Failed to check membership of {} in guild {}; keeping the pending question.",
question.userId,
guild.idLong,
throwable
)
}
}
)
}
}

@Transactional(readOnly = true)
fun hasPendingApplicants(guildId: Long): Boolean {
return memberGateQuestionRepository.findAll()
.filterNotNull()
.any { it.guildId == guildId && it.userId.toULong() > 0uL }
}

@Transactional(readOnly = true)
fun countPendingApplicants(guildId: Long): Int {
return memberGateQuestionRepository.findAll()
.filterNotNull()
.count { it.guildId == guildId && it.userId.toULong() > 0uL }
}

@Transactional(readOnly = true)
fun getPendingQuestion(guildId: Long, userId: Long): MemberGateQuestion? {
return memberGateQuestionRepository.findById(MemberGateQuestion.createId(guildId, userId)).orElse(null)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ data class ReviewInterruptConfirmation(
val id: String,
val guildId: Long,
val reviewerId: Long,
val targetSessionIds: Map<Long, String>
val targetSessionIds: Map<Long, String>,
val maxMembers: Int? = null
)
Loading