diff --git a/src/modules/suggest/suggest.listener.ts b/src/modules/suggest/suggest.listener.ts index cb427672..7bb1308b 100644 --- a/src/modules/suggest/suggest.listener.ts +++ b/src/modules/suggest/suggest.listener.ts @@ -6,6 +6,7 @@ import { type ModalSubmitInteraction, } from "discord.js"; import { config } from "../../Config.js"; +import { logger } from "../../logging.js"; import { SuggestionStatus } from "../../store/models/Suggestion.js"; import type { EventListener } from "../module.js"; @@ -15,6 +16,7 @@ import { createVotesEmbed, generateVoteMessage, getSuggestionByMessageIdOrRecoverFromMessage, + removeSuggestionVotesForMember, respondToSuggestionInteraction, SUGGESTION_MANAGE_APPROVE_ID, SUGGESTION_MANAGE_APPROVE_MODAL_ID, @@ -256,4 +258,25 @@ export const SuggestionButtonListener: EventListener = { } } }, + async guildBanAdd(client, ban) { + try { + const { removedVotes, updatedSuggestions } = + await removeSuggestionVotesForMember(client, BigInt(ban.user.id)); + + if (removedVotes > 0) { + logger.info( + "Removed %d suggestion votes from banned user %s and refreshed %d suggestion messages", + removedVotes, + ban.user.id, + updatedSuggestions, + ); + } + } catch (error) { + logger.error( + "Failed to remove suggestion votes for banned user %s", + ban.user.id, + error, + ); + } + }, }; diff --git a/src/modules/suggest/suggest.module.ts b/src/modules/suggest/suggest.module.ts index 718db8ed..d08cf281 100644 --- a/src/modules/suggest/suggest.module.ts +++ b/src/modules/suggest/suggest.module.ts @@ -2,10 +2,15 @@ import type Module from "../module.js"; import { ManageSuggestionCommand } from "./manageSuggestion.command.js"; import { SuggestCommand } from "./suggest.command.js"; import { SuggestionButtonListener } from "./suggest.listener.js"; +import { SyncSuggestionVotesCommand } from "./syncSuggestionVotes.command.js"; export const SuggestModule: Module = { name: "suggest", - commands: [SuggestCommand, ManageSuggestionCommand], + commands: [ + SuggestCommand, + ManageSuggestionCommand, + SyncSuggestionVotesCommand, + ], listeners: [SuggestionButtonListener], }; diff --git a/src/modules/suggest/suggest.test.ts b/src/modules/suggest/suggest.test.ts new file mode 100644 index 00000000..8fa49293 --- /dev/null +++ b/src/modules/suggest/suggest.test.ts @@ -0,0 +1,523 @@ +import { afterEach, describe, expect, mock, test } from "bun:test"; +import type { Client, Message, TextBasedChannel, User } from "discord.js"; +import { PermissionFlagsBits } from "discord.js"; +import { config } from "../../Config.js"; +import { Suggestion, SuggestionStatus } from "../../store/models/Suggestion.js"; +import { SuggestionVote } from "../../store/models/SuggestionVote.js"; +import { getSequelizeInstance } from "../../store/storage.js"; +import { createMockUser } from "../../tests/mocks/discord.js"; +import { + removeSuggestionVotesForMember, + removeSuggestionVotesForMembers, +} from "./suggest.js"; +import { SyncSuggestionVotesCommand } from "./syncSuggestionVotes.command.js"; + +afterEach(async () => { + await getSequelizeInstance().destroyAll(); +}); + +function createTestUser(id: string): User { + return { + ...createMockUser({ id }), + avatarURL: () => null, + } as unknown as User; +} + +function getFieldValue( + editPayload: { + embeds: Array<{ + data?: { + fields?: Array<{ + name?: string; + value?: string; + }>; + }; + fields?: Array<{ + name?: string; + value?: string; + }>; + toJSON?: () => { + fields?: Array<{ + name?: string; + value?: string; + }>; + }; + }>; + }, + fieldName: string, +): string { + const embed = editPayload.embeds[0]; + const fields = + embed?.toJSON?.().fields ?? embed?.data?.fields ?? embed?.fields; + const field = fields?.find((candidate) => candidate.name === fieldName); + expect(field).toBeDefined(); + return field?.value ?? ""; +} + +describe("removeSuggestionVotesForMember", () => { + test("removes banned user votes and refreshes live and archived suggestions", async () => { + const bannedUserId = 999n; + const liveEditPayloads: Array<{ + embeds: Array<{ + fields?: Array<{ + name?: string; + value?: string; + }>; + }>; + }> = []; + const archivedEditPayloads: Array<{ + embeds: Array<{ + fields?: Array<{ + name?: string; + value?: string; + }>; + }>; + }> = []; + const liveEdit = mock( + async (payload: { + embeds: Array<{ + data?: { + fields?: Array<{ + name?: string; + value?: string; + }>; + }; + toJSON?: () => { + fields?: Array<{ + name?: string; + value?: string; + }>; + }; + }>; + }) => { + const embed = payload.embeds[0]; + liveEditPayloads.push({ + embeds: [embed?.toJSON?.() ?? embed?.data ?? embed ?? {}], + }); + return {}; + }, + ); + const archivedEdit = mock( + async (payload: { + embeds: Array<{ + data?: { + fields?: Array<{ + name?: string; + value?: string; + }>; + }; + toJSON?: () => { + fields?: Array<{ + name?: string; + value?: string; + }>; + }; + }>; + }) => { + const embed = payload.embeds[0]; + archivedEditPayloads.push({ + embeds: [embed?.toJSON?.() ?? embed?.data ?? embed ?? {}], + }); + return {}; + }, + ); + + const liveMessage = { + id: "2001", + editable: true, + edit: liveEdit, + } as unknown as Message; + const archivedMessage = { + id: "2002", + editable: true, + edit: archivedEdit, + } as unknown as Message; + + const liveChannel = { + id: config.suggest.suggestionsChannel, + isTextBased: () => true, + messages: { + fetch: mock(async (messageId: string) => { + if (messageId === liveMessage.id) { + return liveMessage; + } + throw new Error("Message not found"); + }), + }, + } as unknown as TextBasedChannel; + const archiveChannel = { + id: config.suggest.archiveChannel, + isTextBased: () => true, + messages: { + fetch: mock(async (messageId: string) => { + if (messageId === archivedMessage.id) { + return archivedMessage; + } + throw new Error("Message not found"); + }), + }, + } as unknown as TextBasedChannel; + + const client = { + channels: { + fetch: mock(async (channelId: string) => { + if (channelId === config.suggest.suggestionsChannel) { + return liveChannel; + } + if (channelId === config.suggest.archiveChannel) { + return archiveChannel; + } + return null; + }), + }, + users: { + fetch: mock(async (userResolvable: string | { id: string }) => { + const id = + typeof userResolvable === "string" + ? userResolvable + : userResolvable.id; + return createTestUser(id); + }), + }, + } as unknown as Client; + + await Suggestion.create({ + id: 1n, + memberId: 101n, + suggestionText: "Live suggestion", + messageId: BigInt(liveMessage.id), + status: SuggestionStatus.PENDING, + }); + await Suggestion.create({ + id: 2n, + memberId: 202n, + suggestionText: "Archived suggestion", + messageId: BigInt(archivedMessage.id), + status: SuggestionStatus.APPROVED, + moderatorId: 303n, + }); + + await SuggestionVote.bulkCreate([ + { + suggestionId: 1n, + memberId: bannedUserId, + vote: 1, + }, + { + suggestionId: 1n, + memberId: 111n, + vote: -1, + }, + { + suggestionId: 2n, + memberId: bannedUserId, + vote: -1, + }, + { + suggestionId: 2n, + memberId: 222n, + vote: 1, + }, + ]); + + const result = await removeSuggestionVotesForMember(client, bannedUserId); + + expect(result).toEqual({ + removedVotes: 2, + updatedSuggestions: 2, + }); + expect( + await SuggestionVote.count({ + where: { + memberId: bannedUserId, + }, + }), + ).toBe(0); + expect( + await SuggestionVote.count({ + where: { + suggestionId: 1n, + vote: 1, + }, + }), + ).toBe(0); + expect( + await SuggestionVote.count({ + where: { + suggestionId: 2n, + vote: -1, + }, + }), + ).toBe(0); + + expect(liveEdit).toHaveBeenCalledTimes(1); + expect(archivedEdit).toHaveBeenCalledTimes(1); + + const liveFieldValue = getFieldValue( + liveEditPayloads[0] as { + embeds: Array<{ + data: { + fields?: Array<{ + name?: string; + value?: string; + }>; + }; + }>; + }, + "Results", + ); + expect(liveFieldValue).toMatch(/:white_check_mark:: \*\*0\*\*/); + expect(liveFieldValue).toMatch(/:x:: \*\*1\*\*/); + + const archivedFieldValue = getFieldValue( + archivedEditPayloads[0] as { + embeds: Array<{ + data: { + fields?: Array<{ + name?: string; + value?: string; + }>; + }; + }>; + }, + "Results", + ); + expect(archivedFieldValue).toMatch(/:white_check_mark:: \*\*1\*\*/); + expect(archivedFieldValue).toMatch(/:x:: \*\*0\*\*/); + }); +}); + +describe("removeSuggestionVotesForMembers", () => { + test("removes votes for multiple banned users in one pass", async () => { + const messageEdit = mock(async (_payload: unknown) => ({})); + const message = { + id: "3001", + editable: true, + edit: messageEdit, + } as unknown as Message; + const suggestionChannel = { + id: config.suggest.suggestionsChannel, + isTextBased: () => true, + messages: { + fetch: mock(async (messageId: string) => { + if (messageId === message.id) { + return message; + } + throw new Error("Message not found"); + }), + }, + } as unknown as TextBasedChannel; + const archiveChannel = { + id: config.suggest.archiveChannel, + isTextBased: () => true, + messages: { + fetch: mock(async () => { + throw new Error("Message not found"); + }), + }, + } as unknown as TextBasedChannel; + + const client = { + channels: { + fetch: mock(async (channelId: string) => { + if (channelId === config.suggest.suggestionsChannel) { + return suggestionChannel; + } + if (channelId === config.suggest.archiveChannel) { + return archiveChannel; + } + return null; + }), + }, + users: { + fetch: mock(async (userResolvable: string | { id: string }) => { + const id = + typeof userResolvable === "string" + ? userResolvable + : userResolvable.id; + return createTestUser(id); + }), + }, + } as unknown as Client; + + await Suggestion.create({ + id: 3n, + memberId: 303n, + suggestionText: "Shared suggestion", + messageId: BigInt(message.id), + status: SuggestionStatus.PENDING, + }); + + await SuggestionVote.bulkCreate([ + { + suggestionId: 3n, + memberId: 900n, + vote: 1, + }, + { + suggestionId: 3n, + memberId: 901n, + vote: -1, + }, + { + suggestionId: 3n, + memberId: 902n, + vote: 1, + }, + ]); + + const result = await removeSuggestionVotesForMembers(client, [900n, 901n]); + + expect(result).toEqual({ + removedVotes: 2, + updatedSuggestions: 1, + affectedMembers: 2, + }); + expect( + await SuggestionVote.count({ + where: { + suggestionId: 3n, + }, + }), + ).toBe(1); + expect(messageEdit).toHaveBeenCalledTimes(1); + }); +}); + +describe("SyncSuggestionVotesCommand", () => { + test("rejects non-admin users", async () => { + const reply = mock(async (_payload: unknown) => ({})); + + await SyncSuggestionVotesCommand.handle({ + isChatInputCommand: () => true, + inGuild: () => true, + guild: { + bans: { + fetch: mock(async () => new Map()), + }, + }, + member: { + permissions: { + has: () => false, + }, + }, + reply, + } as never); + + expect(reply).toHaveBeenCalledTimes(1); + expect((reply.mock.calls[0][0] as { content: string }).content).toContain( + "don't have permission", + ); + }); + + test("sweeps existing bans and reports the cleanup summary", async () => { + const bannedUserId = 777n; + const suggestionEdit = mock(async (_payload: unknown) => ({})); + const suggestionMessage = { + id: "4001", + editable: true, + edit: suggestionEdit, + } as unknown as Message; + const suggestionChannel = { + id: config.suggest.suggestionsChannel, + isTextBased: () => true, + messages: { + fetch: mock(async (messageId: string) => { + if (messageId === suggestionMessage.id) { + return suggestionMessage; + } + throw new Error("Message not found"); + }), + }, + } as unknown as TextBasedChannel; + const archiveChannel = { + id: config.suggest.archiveChannel, + isTextBased: () => true, + messages: { + fetch: mock(async () => { + throw new Error("Message not found"); + }), + }, + } as unknown as TextBasedChannel; + const client = { + channels: { + fetch: mock(async (channelId: string) => { + if (channelId === config.suggest.suggestionsChannel) { + return suggestionChannel; + } + if (channelId === config.suggest.archiveChannel) { + return archiveChannel; + } + return null; + }), + }, + users: { + fetch: mock(async (userResolvable: string | { id: string }) => { + const id = + typeof userResolvable === "string" + ? userResolvable + : userResolvable.id; + return createTestUser(id); + }), + }, + } as unknown as Client; + + await Suggestion.create({ + id: 4n, + memberId: 404n, + suggestionText: "Banned user cleanup", + messageId: BigInt(suggestionMessage.id), + status: SuggestionStatus.PENDING, + }); + await SuggestionVote.bulkCreate([ + { + suggestionId: 4n, + memberId: bannedUserId, + vote: 1, + }, + { + suggestionId: 4n, + memberId: 778n, + vote: -1, + }, + ]); + + const deferReply = mock(async (_payload: unknown) => ({})); + const editReply = mock(async (_payload: unknown) => ({})); + + await SyncSuggestionVotesCommand.handle({ + isChatInputCommand: () => true, + inGuild: () => true, + guild: { + bans: { + fetch: mock(async () => new Map([[bannedUserId.toString(), {}]])), + }, + }, + member: { + permissions: { + has: (permission: bigint) => + permission === PermissionFlagsBits.Administrator, + }, + }, + client, + deferReply, + editReply, + } as never); + + expect(deferReply).toHaveBeenCalledTimes(1); + expect(editReply).toHaveBeenCalledTimes(1); + expect( + (editReply.mock.calls[0][0] as { content: string }).content, + ).toContain( + "Scanned 1 banned user(s). Removed 1 vote(s) from 1 user(s). Refreshed 1 suggestion message(s).", + ); + expect( + await SuggestionVote.count({ + where: { + memberId: bannedUserId, + }, + }), + ).toBe(0); + expect(suggestionEdit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/modules/suggest/suggest.ts b/src/modules/suggest/suggest.ts index 38b67788..34a4574f 100644 --- a/src/modules/suggest/suggest.ts +++ b/src/modules/suggest/suggest.ts @@ -1,3 +1,4 @@ +import { Op } from "@sequelize/core"; import { ActionRowBuilder, ButtonBuilder, @@ -12,6 +13,7 @@ import { type ModalSubmitInteraction, type OmitPartialGroupDMChannel, type SendableChannels, + type TextBasedChannel, TextInputBuilder, TextInputStyle, type UserResolvable, @@ -159,6 +161,188 @@ export const getSuggestionByMessageId: ( }); }; +async function getSuggestionChannels( + client: Client, +): Promise { + const channels = await Promise.all( + [config.suggest.suggestionsChannel, config.suggest.archiveChannel].map( + async (channelId) => { + try { + const channel = await client.channels.fetch(channelId); + if (!channel?.isTextBased()) { + return null; + } + return channel; + } catch (error) { + logger.error( + "Failed to fetch suggestion channel %s", + channelId, + error, + ); + return null; + } + }, + ), + ); + + return channels.filter( + (channel): channel is TextBasedChannel => channel !== null, + ); +} + +async function getSuggestionMessage( + channels: TextBasedChannel[], + messageId: bigint, +): Promise { + for (const channel of channels) { + try { + return await channel.messages.fetch(messageId.toString()); + } catch { + // Try the next suggestion channel. + } + } + + return null; +} + +export async function refreshSuggestionMessage( + client: Client, + suggestion: Suggestion, + suggestionChannels?: TextBasedChannel[], +): Promise { + const channels = suggestionChannels ?? (await getSuggestionChannels(client)); + const message = await getSuggestionMessage(channels, suggestion.messageId); + + if (!message) { + logger.warn( + "Could not find suggestion message %s for suggestion %s", + suggestion.messageId, + suggestion.id, + ); + return false; + } + + if (!message.editable) { + logger.warn( + "Suggestion message %s for suggestion %s is not editable", + suggestion.messageId, + suggestion.id, + ); + return false; + } + + await message.edit({ + embeds: [await createSuggestionEmbedFromEntity(client, suggestion)], + }); + return true; +} + +export interface RemovedSuggestionVotesResult { + removedVotes: number; + updatedSuggestions: number; +} + +export interface RemovedSuggestionVotesForMembersResult + extends RemovedSuggestionVotesResult { + affectedMembers: number; +} + +export async function removeSuggestionVotesForMembers( + client: Client, + memberIds: Iterable, +): Promise { + const uniqueMemberIds = Array.from( + new Set(Array.from(memberIds, (memberId) => memberId.toString())), + ).map((memberId) => BigInt(memberId)); + + if (uniqueMemberIds.length === 0) { + return { + removedVotes: 0, + updatedSuggestions: 0, + affectedMembers: 0, + }; + } + + const votes = await SuggestionVote.findAll({ + where: { + memberId: { + [Op.in]: uniqueMemberIds, + }, + }, + }); + + if (votes.length === 0) { + return { + removedVotes: 0, + updatedSuggestions: 0, + affectedMembers: 0, + }; + } + + const affectedMemberIds = new Set( + votes.map((vote) => vote.memberId.toString()), + ); + const suggestionIds = Array.from( + new Set(votes.map((vote) => vote.suggestionId.toString())), + ).map((suggestionId) => BigInt(suggestionId)); + + await SuggestionVote.destroy({ + where: { + memberId: { + [Op.in]: uniqueMemberIds, + }, + }, + }); + + const suggestionChannels = await getSuggestionChannels(client); + let updatedSuggestions = 0; + + for (const suggestionId of suggestionIds) { + const suggestion = await Suggestion.findOne({ + where: { + id: suggestionId, + }, + include: [SuggestionVote], + }); + + if (!suggestion) { + continue; + } + + try { + if ( + await refreshSuggestionMessage(client, suggestion, suggestionChannels) + ) { + updatedSuggestions++; + } + } catch (error) { + logger.error( + "Failed to refresh suggestion %s after removing votes from banned users", + suggestionId, + error, + ); + } + } + + return { + removedVotes: votes.length, + updatedSuggestions, + affectedMembers: affectedMemberIds.size, + }; +} + +export async function removeSuggestionVotesForMember( + client: Client, + memberId: bigint, +): Promise { + const { removedVotes, updatedSuggestions } = + await removeSuggestionVotesForMembers(client, [memberId]); + return { + removedVotes, + updatedSuggestions, + }; +} + export async function getSuggestionByMessageIdOrRecoverFromMessage( embedMessage: Message, ): Promise { diff --git a/src/modules/suggest/syncSuggestionVotes.command.ts b/src/modules/suggest/syncSuggestionVotes.command.ts new file mode 100644 index 00000000..9895f184 --- /dev/null +++ b/src/modules/suggest/syncSuggestionVotes.command.ts @@ -0,0 +1,71 @@ +import { + ApplicationCommandType, + type GuildMember, + MessageFlags, + PermissionFlagsBits, +} from "discord.js"; +import type { Command } from "djs-slash-helper"; +import { logger } from "../../logging.js"; +import { removeSuggestionVotesForMembers } from "./suggest.js"; + +export const SyncSuggestionVotesCommand: Command = + { + name: "syncsuggestionvotes", + description: "Remove banned users from suggestion votes and refresh totals", + type: ApplicationCommandType.ChatInput, + default_permission: false, + options: [], + handle: async (interaction) => { + if ( + !interaction.isChatInputCommand() || + !interaction.inGuild() || + interaction.guild === null + ) { + return; + } + + const member = interaction.member as GuildMember; + if (!member.permissions.has(PermissionFlagsBits.Administrator)) { + await interaction.reply({ + content: "You don't have permission to run this command.", + flags: MessageFlags.Ephemeral, + }); + return; + } + + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + try { + const bans = await interaction.guild.bans.fetch(); + if (bans.size === 0) { + await interaction.editReply({ + content: "No banned users found.", + }); + return; + } + + const bannedUserIds = Array.from(bans.keys(), (userId) => + BigInt(userId), + ); + const result = await removeSuggestionVotesForMembers( + interaction.client, + bannedUserIds, + ); + + await interaction.editReply({ + content: + `Scanned ${bans.size} banned user(s). ` + + `Removed ${result.removedVotes} vote(s) from ${result.affectedMembers} user(s). ` + + `Refreshed ${result.updatedSuggestions} suggestion message(s).`, + }); + } catch (error) { + logger.error( + "Failed to reconcile suggestion votes for banned users", + error, + ); + await interaction.editReply({ + content: "Failed to reconcile suggestion votes.", + }); + } + }, + };