diff --git a/.gitignore b/.gitignore index e57d72b6..db181e4a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.DS_Store .vscode .idea node_modules diff --git a/src/modules/tccpp/components/permissions-manager.ts b/src/modules/tccpp/components/permissions-manager.ts index ea27b4c8..e8e8b75c 100644 --- a/src/modules/tccpp/components/permissions-manager.ts +++ b/src/modules/tccpp/components/permissions-manager.ts @@ -15,6 +15,7 @@ import SkillRoles from "./skill-roles.js"; import { named_id } from "../../../channel-map.js"; import { unwrap } from "../../../utils/misc.js"; import { CommandSetBuilder } from "../../../command-abstractions/command-set-builder.js"; +import { channel_has_member_with_role } from "../../../utils/discord.js"; import { set_timeout } from "../../../utils/node.js"; const categories_map = { @@ -61,6 +62,7 @@ export default class PermissionManager extends BotComponent { wheatley_roles.server_booster, wheatley_roles.no_voice, wheatley_roles.voice_moderator, + wheatley_roles.voice_muted, wheatley_roles.no_suggestions, wheatley_roles.no_suggestions_at_all, wheatley_roles.jedi_council, @@ -221,6 +223,9 @@ export default class PermissionManager extends BotComponent { [this.roles.voice_moderator.id]: { allow: [...acive_voice_permissions, SET_VOICE_STATUS_PERMISSION_BIT], }, + [this.roles.voice_muted.id]: { + deny: acive_voice_permissions, + }, }; const mod_only_channel: permission_overwrites = { [this.wheatley.guild.roles.everyone.id]: { @@ -487,13 +492,12 @@ export default class PermissionManager extends BotComponent { await category.permissionOverwrites.set( Object.entries(permissions).map(([id, permissions]) => ({ id, ...permissions })), ); - const channels = category.children.cache.map(channel => channel); - for (const channel of channels) { + for (const channel of category.children.cache.values()) { await this.sync_channel_permissions(channel); if (channel.isVoiceBased()) { for (const [id, member] of channel.members) { if (await this.wheatley.check_permissions(member, Discord.PermissionFlagsBits.MuteMembers)) { - await this.mod_has_entered_the_building(channel); + await this.mod_has_entered_the_building(channel, member.id); break; } } @@ -518,15 +522,31 @@ export default class PermissionManager extends BotComponent { }, HOUR); } - private async mod_has_entered_the_building(channel: Discord.Channel) { - assert(channel.isVoiceBased()); + private get_base_permissions( + channel: Discord.VoiceChannel | Discord.StageChannel, + ): permission_overwrites | undefined { + if (this.channel_overwrites[channel.id] != null) { + return this.channel_overwrites[channel.id]; + } + if (channel.parent != null) { + return this.category_permissions[channel.parent.id]; + } + return undefined; + } + + private has_moderator_in_channel(channel: Discord.VoiceChannel | Discord.StageChannel): boolean { + return channel.members.some(member => member.permissions.has(Discord.PermissionFlagsBits.MuteMembers)); + } + + private async mod_has_entered_the_building( + channel: Discord.VoiceChannel | Discord.StageChannel, + entering_moderator_id: string, + ) { if (channel.id in this.dynamic_channel_overwrites || channel.id == this.wheatley.guild.afkChannelId) { return; } const everyone = this.wheatley.guild.roles.everyone.id; - const base_perms = - this.channel_overwrites[channel.id] ?? - (channel.parent ? this.category_permissions[channel.parent.id] : undefined); + const base_perms = this.get_base_permissions(channel); if ( !base_perms || !base_perms[everyone] || @@ -536,6 +556,9 @@ export default class PermissionManager extends BotComponent { ) { return; } + if (channel_has_member_with_role(channel, this.roles.voice_moderator.id, entering_moderator_id)) { + return; + } const perms = Object.assign({}, base_perms); perms[everyone] = { allow: [ @@ -549,15 +572,12 @@ export default class PermissionManager extends BotComponent { await this.sync_channel_permissions(channel); } - private async mod_has_left_the_building(channel: Discord.Channel) { - assert(channel.isVoiceBased()); + private async mod_has_left_the_building(channel: Discord.VoiceChannel | Discord.StageChannel) { if (!(channel.id in this.dynamic_channel_overwrites)) { return; } - for (const [id, member] of channel.members) { - if (await this.wheatley.check_permissions(member, Discord.PermissionFlagsBits.MuteMembers)) { - return; - } + if (this.has_moderator_in_channel(channel)) { + return; } delete this.dynamic_channel_overwrites[channel.id]; await this.sync_channel_permissions(channel); @@ -576,7 +596,7 @@ export default class PermissionManager extends BotComponent { await this.mod_has_left_the_building(old_state.channel); } if (new_state.channel) { - await this.mod_has_entered_the_building(new_state.channel); + await this.mod_has_entered_the_building(new_state.channel, new_state.member.id); } } } diff --git a/src/modules/wheatley/components/moderation/voice-mute.ts b/src/modules/wheatley/components/moderation/voice-mute.ts index a2c6c599..f33599ca 100644 --- a/src/modules/wheatley/components/moderation/voice-mute.ts +++ b/src/modules/wheatley/components/moderation/voice-mute.ts @@ -4,7 +4,9 @@ import * as mongo from "mongodb"; import { strict as assert } from "assert"; import { M } from "../../../../utils/debugging-and-logging.js"; -import { ModerationComponent } from "./moderation-common.js"; +import { ModerationComponent, duration_regex } from "./moderation-common.js"; +import { role_map } from "../../../../role-map.js"; +import { wheatley_roles } from "../../roles.js"; import { CommandSetBuilder } from "../../../../command-abstractions/command-set-builder.js"; import { EarlyReplyMode, @@ -14,6 +16,8 @@ import { TextBasedCommand } from "../../../../command-abstractions/text-based-co import { moderation_entry, basic_moderation_with_user } from "./schemata.js"; export default class VoiceMute extends ModerationComponent { + private readonly roles = role_map(this.wheatley, wheatley_roles.voice_muted); + get type() { return "voice_mute" as const; } @@ -22,8 +26,13 @@ export default class VoiceMute extends ModerationComponent { return "voice muted"; } + override get persist_moderation() { + return true; + } + override async setup(commands: CommandSetBuilder) { await super.setup(commands); + this.roles.resolve(); commands.add( new TextBasedCommandBuilder("voice", EarlyReplyMode.ephemeral) @@ -31,24 +40,35 @@ export default class VoiceMute extends ModerationComponent { .set_permissions(Discord.PermissionFlagsBits.MuteMembers) .add_subcommand( new TextBasedCommandBuilder("mute", EarlyReplyMode.ephemeral) - .set_description("Server mute a user") + .set_description("Voice mute a user") .add_user_option({ title: "user", description: "User to mute", required: true, }) + .add_string_option({ + title: "duration", + description: "Duration", + regex: duration_regex, + required: false, + }) .add_string_option({ title: "reason", description: "Reason", required: false, }) - .set_handler((command: TextBasedCommand, user: Discord.User, reason: string | null) => - this.moderation_issue_handler(command, user, null, reason, { type: this.type }), + .set_handler( + ( + command: TextBasedCommand, + user: Discord.User, + duration: string | null, + reason: string | null, + ) => this.moderation_issue_handler(command, user, duration, reason, { type: this.type }), ), ) .add_subcommand( new TextBasedCommandBuilder("unmute", EarlyReplyMode.ephemeral) - .set_description("Server unmute a user") + .set_description("Voice unmute a user") .add_user_option({ title: "user", description: "User to unmute", @@ -64,25 +84,41 @@ export default class VoiceMute extends ModerationComponent { ); } + private async refresh_voice_permissions(member: Discord.GuildMember) { + try { + await this.wheatley.force_voice_permissions_update(member); + } catch (e) { + M.error(`Failed to refresh voice permissions for ${member.user.tag}`, e); + } + } + async apply_moderation(entry: moderation_entry) { M.info(`Applying voice mute to ${entry.user_name}`); + if (this.dummy_rounds) { + return; + } const member = await this.wheatley.try_fetch_guild_member(entry.user); - if (member?.voice.channel) { - await member.voice.setMute(true); + if (member) { + await member.roles.add(this.roles.voice_muted); + await this.refresh_voice_permissions(member); } } async remove_moderation(entry: mongo.WithId) { M.info(`Removing voice mute from ${entry.user_name}`); + if (this.dummy_rounds) { + return; + } const member = await this.wheatley.try_fetch_guild_member(entry.user); - if (member?.voice.channel) { - await member.voice.setMute(false); + if (member) { + await member.roles.remove(this.roles.voice_muted); + await this.refresh_voice_permissions(member); } } async is_moderation_applied_in_discord(moderation: basic_moderation_with_user) { assert(moderation.type == this.type); const member = await this.wheatley.try_fetch_guild_member(moderation.user); - return member?.voice.serverMute ?? false; + return member?.roles.cache.has(this.roles.voice_muted.id) ?? false; } } diff --git a/src/modules/wheatley/components/moderation/voice-take.ts b/src/modules/wheatley/components/moderation/voice-take.ts index 77d4e432..967bf1ee 100644 --- a/src/modules/wheatley/components/moderation/voice-take.ts +++ b/src/modules/wheatley/components/moderation/voice-take.ts @@ -14,9 +14,10 @@ import { } from "../../../../command-abstractions/text-based-command-builder.js"; import { TextBasedCommand } from "../../../../command-abstractions/text-based-command.js"; import { moderation_entry, basic_moderation_with_user } from "./schemata.js"; +import { channel_has_member_with_role } from "../../../../utils/discord.js"; export default class VoiceTake extends ModerationComponent { - private roles = role_map(this.wheatley, wheatley_roles.voice); + private readonly roles = role_map(this.wheatley, wheatley_roles.voice, wheatley_roles.voice_moderator); get type() { return "voice_take" as const; @@ -97,24 +98,45 @@ export default class VoiceTake extends ModerationComponent { await this.moderation_revoke_handler(command, user, null, {}, { allow_no_entry: true }); } + private async refresh_voice_permissions(member: Discord.GuildMember) { + const channel = member.voice.channel; + if (!channel || channel_has_member_with_role(channel, this.roles.voice_moderator.id)) { + return; + } + try { + await this.wheatley.force_voice_permissions_update(member); + } catch (e) { + M.error(`Failed to refresh voice permissions for ${member.user.tag}`, e); + } + } + async apply_moderation(entry: moderation_entry) { M.info(`Applying voice take to ${entry.user_name}`); + if (this.dummy_rounds) { + return; + } const member = await this.wheatley.try_fetch_guild_member(entry.user); if (member) { await member.roles.remove(this.roles.voice); + await this.refresh_voice_permissions(member); } } async remove_moderation(entry: mongo.WithId) { M.info(`Removing voice take from ${entry.user_name}`); + if (this.dummy_rounds) { + return; + } const member = await this.wheatley.try_fetch_guild_member(entry.user); if (member) { await member.roles.add(this.roles.voice); + await this.refresh_voice_permissions(member); } } override async apply_revoke_to_discord(member: Discord.GuildMember): Promise { await member.roles.add(this.roles.voice); + await this.refresh_voice_permissions(member); } async is_moderation_applied_in_discord(moderation: basic_moderation_with_user) { diff --git a/src/modules/wheatley/components/moderation/voice-update.ts b/src/modules/wheatley/components/moderation/voice-update.ts new file mode 100644 index 00000000..0a0ff3cf --- /dev/null +++ b/src/modules/wheatley/components/moderation/voice-update.ts @@ -0,0 +1,96 @@ +import * as Discord from "discord.js"; + +import { BotComponent } from "../../../../bot-component.js"; +import { CommandSetBuilder } from "../../../../command-abstractions/command-set-builder.js"; +import { + EarlyReplyMode, + TextBasedCommandBuilder, +} from "../../../../command-abstractions/text-based-command-builder.js"; +import { TextBasedCommand } from "../../../../command-abstractions/text-based-command.js"; +import { wheatley_roles } from "../../roles.js"; +import { create_error_reply } from "../../../../wheatley.js"; +import { + perform_voice_update, + select_everyone, + exclude_bots, + select_without_role, + type VoiceUpdateContext, +} from "../../../../utils/voice-update.js"; + +export default class VoiceUpdate extends BotComponent { + static override get is_freestanding() { + return true; + } + + override async setup(commands: CommandSetBuilder) { + commands.add( + new TextBasedCommandBuilder("voice", EarlyReplyMode.ephemeral) + .set_description("Voice moderation") + .set_permissions(Discord.PermissionFlagsBits.MuteMembers) + .add_subcommand( + new TextBasedCommandBuilder("update", EarlyReplyMode.ephemeral) + .set_description("Force-refresh voice permissions in your current channel") + .add_boolean_option({ + title: "all", + description: "Refresh everyone rather than only those without the voice role", + required: false, + }) + .set_handler(this.handle_update.bind(this)), + ), + ); + } + + private async handle_update(command: TextBasedCommand, all: boolean | null) { + const member = await command.get_member(); + if (!member.permissions.has(Discord.PermissionFlagsBits.MoveMembers)) { + await command.reply(create_error_reply("You need the Move Members permission to use this command.")); + return; + } + + const channel = member.voice.channel; + if (!channel?.isVoiceBased()) { + await command.reply(create_error_reply("You must be in a voice channel to use this command.")); + return; + } + + let selector = exclude_bots(select_everyone); + if (!all) { + let voice_role: Discord.Role; + try { + voice_role = this.utilities.resolve_role(wheatley_roles.voice); + } catch { + await command.reply( + create_error_reply( + "Could not resolve the `voice` role needed for affected-user refresh. " + + "Create that role in this guild or run with `all: true`.", + ), + ); + return; + } + selector = exclude_bots(select_without_role(voice_role.id)); + } + + const context: VoiceUpdateContext = { + caller: member, + channel, + wheatley: this.wheatley, + }; + const result = await perform_voice_update(context, selector); + if (result.afk_missing) { + await command.reply( + create_error_reply("No AFK channel is configured for this guild, so voice refresh cannot run."), + ); + return; + } + + const scope = all ? "" : " affected"; + const skipped_suffix = result.skipped > 0 ? ` (${result.skipped} skipped)` : ""; + await command.reply({ + content: + `Refreshed voice permissions for ${result.succeeded}${scope} member(s) in ${channel.name}.` + + skipped_suffix + + (result.failed > 0 ? ` (${result.failed} failed)` : ""), + should_text_reply: true, + }); + } +} diff --git a/src/modules/wheatley/roles.ts b/src/modules/wheatley/roles.ts index 95fb19fc..9d258e74 100644 --- a/src/modules/wheatley/roles.ts +++ b/src/modules/wheatley/roles.ts @@ -24,4 +24,5 @@ export const wheatley_roles = define_roles({ herald: { id: "1095555811536797787", name: "Herald" }, linked_github: { id: "1080596526478397471", name: "Linked GitHub" }, voice: { id: "1368073548983308328", name: "voice" }, + voice_muted: { id: "1479764785917595822", name: "Voice Muted" }, }); diff --git a/src/utils/discord.ts b/src/utils/discord.ts index f1aa2bda..e786ee75 100644 --- a/src/utils/discord.ts +++ b/src/utils/discord.ts @@ -81,6 +81,14 @@ export function textchannelify(x: Discord.Channel): Discord.TextBasedChannel { return x; } +export function channel_has_member_with_role( + channel: Discord.VoiceChannel | Discord.StageChannel, + role_id: string, + excluded_member_id?: string, +): boolean { + return channel.members.some(member => member.id !== excluded_member_id && member.roles.cache.has(role_id)); +} + export function get_tag(channel: Discord.ForumChannel, name: string) { const candidates = channel.availableTags.filter(tag => tag.name == name); assert( diff --git a/src/utils/voice-update.ts b/src/utils/voice-update.ts new file mode 100644 index 00000000..df00765b --- /dev/null +++ b/src/utils/voice-update.ts @@ -0,0 +1,66 @@ +import * as Discord from "discord.js"; + +import { Wheatley } from "../wheatley.js"; + +export type VoiceUpdateContext = { + caller: Discord.GuildMember; + channel: Discord.VoiceChannel | Discord.StageChannel; + wheatley: Wheatley; +}; + +/** Predicate that determines whether a member should be included in the refresh set. */ +export type VoiceUpdateSelector = (member: Discord.GuildMember, context: VoiceUpdateContext) => boolean; + +export type VoiceUpdateResult = { + succeeded: number; + failed: number; + skipped: number; + total: number; + afk_missing: boolean; +}; + +function is_hard_excluded(member: Discord.GuildMember, context: VoiceUpdateContext): boolean { + if (member.id === context.caller.id) { + return true; + } + return member.permissions.has(Discord.PermissionFlagsBits.MoveMembers); +} + +export const select_everyone: VoiceUpdateSelector = () => true; + +export function exclude_bots(selector: VoiceUpdateSelector): VoiceUpdateSelector { + return (member, context) => { + if (member.user.bot) { + return false; + } + return selector(member, context); + }; +} + +export function select_without_role(role_id: string): VoiceUpdateSelector { + return member => !member.roles.cache.has(role_id); +} + +export async function perform_voice_update( + context: VoiceUpdateContext, + selector: VoiceUpdateSelector, +): Promise { + if (!context.wheatley.guild.afkChannel) { + return { succeeded: 0, failed: 0, skipped: 0, total: 0, afk_missing: true }; + } + + const members = [...context.channel.members.values()].filter(m => { + if (is_hard_excluded(m, context)) { + return false; + } + return selector(m, context); + }); + + const results = await Promise.allSettled(members.map(m => context.wheatley.force_voice_permissions_update(m))); + + const succeeded = results.filter(r => r.status === "fulfilled" && r.value).length; + const skipped = results.filter(r => r.status === "fulfilled" && !r.value).length; + const failed = results.filter(r => r.status === "rejected").length; + + return { succeeded, failed, skipped, total: members.length, afk_missing: false }; +} diff --git a/src/wheatley.ts b/src/wheatley.ts index 48d6af69..ef9670d1 100644 --- a/src/wheatley.ts +++ b/src/wheatley.ts @@ -16,7 +16,7 @@ import { BotComponent } from "./bot-component.js"; import { CommandAbstractionReplyOptions } from "./command-abstractions/text-based-command.js"; import { WheatleyDatabase } from "./infra/database-interface.js"; -import { forge_snowflake, send_long_message_markdown_aware } from "./utils/discord.js"; +import { api_wrap, forge_snowflake, send_long_message_markdown_aware } from "./utils/discord.js"; import { TypedEventEmitter } from "./utils/event-emitter.js"; import { setup_metrics_server } from "./infra/prometheus.js"; import { moderation_entry } from "./modules/wheatley/components/moderation/schemata.js"; @@ -450,6 +450,21 @@ export class Wheatley { return !!member?.permissions.has(permissions); } + /** Moving a user to a new voice channel forces Discord to re-evaluate permissions on them. */ + async force_voice_permissions_update(member: Discord.GuildMember): Promise { + const afk_channel = this.guild.afkChannel; + const original_channel = member.voice.channel; + if (!afk_channel || !original_channel || original_channel.id === afk_channel.id) { + return false; + } + // The member may disconnect mid-bounce which is not an error worth propagating + const disconnected = [Discord.RESTJSONErrorCodes.TargetUserIsNotConnectedToVoice]; + if ((await api_wrap(() => member.voice.setChannel(afk_channel), disconnected)) === null) { + return false; + } + return (await api_wrap(() => member.voice.setChannel(original_channel), disconnected)) !== null; + } + async is_established_member( options: Discord.GuildMember | Discord.User | Discord.UserResolvable | Discord.FetchMemberOptions, ) { diff --git a/test/voice-mute.ts b/test/voice-mute.ts new file mode 100644 index 00000000..b73bb9ca --- /dev/null +++ b/test/voice-mute.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from "vitest"; + +import VoiceMute from "../src/modules/wheatley/components/moderation/voice-mute.js"; + +function create_member(id: string) { + return { + id, + user: { tag: `${id}#0000` }, + roles: { + remove: vi.fn().mockResolvedValue(undefined), + add: vi.fn().mockResolvedValue(undefined), + }, + voice: { channel: {} }, + }; +} + +function create_voice_mute(member: any, force_voice_permissions_update: any) { + return Object.assign(Object.create(VoiceMute.prototype), { + wheatley: { + try_fetch_guild_member: vi.fn().mockResolvedValue(member), + force_voice_permissions_update, + }, + roles: { + voice_muted: { id: "voice-muted-role" }, + }, + }); +} + +describe("voice mute refreshes", () => { + it("still applies the moderation when the refresh fails", async () => { + const member = create_member("target"); + + const force_voice_permissions_update = vi.fn().mockRejectedValue(new Error("disconnected")); + const component = create_voice_mute(member, force_voice_permissions_update); + + await expect(component.apply_moderation({ user: member.id, user_name: "Target" })).resolves.toBeUndefined(); + + expect(member.roles.add).toHaveBeenCalledWith(expect.objectContaining({ id: "voice-muted-role" })); + expect(force_voice_permissions_update).toHaveBeenCalledWith(member); + }); + + it("still removes the moderation when the refresh fails", async () => { + const member = create_member("target"); + + const force_voice_permissions_update = vi.fn().mockRejectedValue(new Error("disconnected")); + const component = create_voice_mute(member, force_voice_permissions_update); + + await expect(component.remove_moderation({ user: member.id, user_name: "Target" })).resolves.toBeUndefined(); + + expect(member.roles.remove).toHaveBeenCalledWith(expect.objectContaining({ id: "voice-muted-role" })); + expect(force_voice_permissions_update).toHaveBeenCalledWith(member); + }); +}); diff --git a/test/voice-take.ts b/test/voice-take.ts new file mode 100644 index 00000000..a8a235aa --- /dev/null +++ b/test/voice-take.ts @@ -0,0 +1,105 @@ +import * as Discord from "discord.js"; + +import { describe, expect, it, vi } from "vitest"; + +import VoiceTake from "../src/modules/wheatley/components/moderation/voice-take.js"; + +function createMember({ id, roleIds = [], channel = null }: { id: string; roleIds?: string[]; channel?: any }) { + const roles = new Set(roleIds); + + return { + id, + user: { bot: false }, + roles: { + cache: { + has: (roleId: string) => roles.has(roleId), + }, + remove: vi.fn().mockResolvedValue(undefined), + add: vi.fn().mockResolvedValue(undefined), + }, + permissions: { + has: () => false, + }, + voice: { channel }, + }; +} + +function createVoiceChannel(members: any[]) { + return { + members: new Discord.Collection(members.map(member => [member.id, member])), + }; +} + +function createVoiceTake(member: any, force_voice_permissions_update = vi.fn().mockResolvedValue(true)) { + return Object.assign(Object.create(VoiceTake.prototype), { + wheatley: { + try_fetch_guild_member: vi.fn().mockResolvedValue(member), + force_voice_permissions_update, + }, + roles: { + voice: { id: "voice-role" }, + voice_moderator: { id: "voice-moderator-role" }, + }, + }); +} + +describe("voice take refreshes", () => { + it("forces a refresh when applying a voice take in a channel without a voice moderator", async () => { + const member = createMember({ id: "target" }); + const channel = createVoiceChannel([member]); + member.voice.channel = channel; + + const force_voice_permissions_update = vi.fn().mockResolvedValue(true); + const component = createVoiceTake(member, force_voice_permissions_update); + + await component.apply_moderation({ user: member.id, user_name: "Target" }); + + expect(member.roles.remove).toHaveBeenCalledWith(expect.objectContaining({ id: "voice-role" })); + expect(force_voice_permissions_update).toHaveBeenCalledWith(member); + }); + + it("does not force a refresh when a voice moderator is already present", async () => { + const voice_moderator = createMember({ + id: "voice-moderator", + roleIds: ["voice-moderator-role"], + }); + const member = createMember({ id: "target" }); + const channel = createVoiceChannel([member, voice_moderator]); + member.voice.channel = channel; + + const force_voice_permissions_update = vi.fn().mockResolvedValue(true); + const component = createVoiceTake(member, force_voice_permissions_update); + + await component.apply_moderation({ user: member.id, user_name: "Target" }); + + expect(member.roles.remove).toHaveBeenCalledWith(expect.objectContaining({ id: "voice-role" })); + expect(force_voice_permissions_update).not.toHaveBeenCalled(); + }); + + it("still applies the moderation when the refresh fails", async () => { + const member = createMember({ id: "target" }); + member.voice.channel = createVoiceChannel([member]); + + const force_voice_permissions_update = vi.fn().mockRejectedValue(new Error("disconnected")); + const component = createVoiceTake(member, force_voice_permissions_update); + + await expect(component.apply_moderation({ user: member.id, user_name: "Target" })).resolves.toBeUndefined(); + + expect(member.roles.remove).toHaveBeenCalledWith(expect.objectContaining({ id: "voice-role" })); + expect(force_voice_permissions_update).toHaveBeenCalledWith(member); + }); + + it("forces a refresh again when removing a voice take", async () => { + const member = createMember({ id: "target" }); + const channel = createVoiceChannel([member]); + member.voice.channel = channel; + + const force_voice_permissions_update = vi.fn().mockResolvedValue(true); + const component = createVoiceTake(member, force_voice_permissions_update); + + await component.remove_moderation({ user: member.id, user_name: "Target" }); + + expect(member.roles.add).toHaveBeenCalledWith(expect.objectContaining({ id: "voice-role" })); + expect(force_voice_permissions_update).toHaveBeenCalledWith(member); + }); +}); diff --git a/test/voice-update.ts b/test/voice-update.ts new file mode 100644 index 00000000..077ec080 --- /dev/null +++ b/test/voice-update.ts @@ -0,0 +1,169 @@ +import * as Discord from "discord.js"; + +import { describe, expect, it, vi } from "vitest"; + +import VoiceUpdate from "../src/modules/wheatley/components/moderation/voice-update.js"; +import { exclude_bots, perform_voice_update, select_everyone } from "../src/utils/voice-update.js"; + +function createMember({ + id, + bot = false, + roleIds = [], + permissionBits = [], + channel = null, +}: { + id: string; + bot?: boolean; + roleIds?: string[]; + permissionBits?: bigint[]; + channel?: any; +}) { + const roles = new Set(roleIds); + const permissions = new Set(permissionBits); + + return { + id, + user: { bot }, + roles: { + cache: { + has: (roleId: string) => roles.has(roleId), + }, + }, + permissions: { + has: (permission: bigint) => permissions.has(permission), + }, + voice: { channel }, + }; +} + +function createVoiceChannel(members: any[], name = "Office Hours") { + return { + name, + isVoiceBased: () => true, + members: new Discord.Collection(members.map(member => [member.id, member])), + }; +} + +describe("voice update", () => { + it("refreshes only affected non-bot members in affected-user mode", async () => { + const force_voice_permissions_update = vi.fn().mockResolvedValue(true); + const wheatley = { + guild: { afkChannel: { id: "afk" } }, + force_voice_permissions_update, + }; + + const channel = createVoiceChannel([]); + const caller = createMember({ + id: "caller", + permissionBits: [Discord.PermissionFlagsBits.MoveMembers], + channel, + }); + const missing_voice = createMember({ id: "missing-voice", channel }); + const has_voice = createMember({ id: "has-voice", roleIds: ["voice-role"], channel }); + const bot_member = createMember({ id: "bot", bot: true, channel }); + channel.members = new Discord.Collection([ + [caller.id, caller], + [missing_voice.id, missing_voice], + [has_voice.id, has_voice], + [bot_member.id, bot_member], + ]); + + const reply = vi.fn().mockResolvedValue(undefined); + const command = { + get_member: vi.fn().mockResolvedValue(caller), + reply, + }; + + const component = Object.assign(Object.create(VoiceUpdate.prototype), { + wheatley, + utilities: { + resolve_role: vi.fn().mockReturnValue({ id: "voice-role" }), + }, + }); + + await component["handle_update"](command, null); + + expect(force_voice_permissions_update).toHaveBeenCalledTimes(1); + expect(force_voice_permissions_update).toHaveBeenCalledWith(missing_voice); + expect(reply).toHaveBeenCalledWith({ + content: "Refreshed voice permissions for 1 affected member(s) in Office Hours.", + should_text_reply: true, + }); + }); + + it("reports an error when the voice role cannot be resolved in affected-user mode", async () => { + const reply = vi.fn().mockResolvedValue(undefined); + const caller = createMember({ + id: "caller", + permissionBits: [Discord.PermissionFlagsBits.MoveMembers], + channel: createVoiceChannel([]), + }); + const component = Object.assign(Object.create(VoiceUpdate.prototype), { + utilities: { + resolve_role: vi.fn(() => { + throw new Error("unresolved"); + }), + }, + }); + + await component["handle_update"]( + { + get_member: vi.fn().mockResolvedValue(caller), + reply, + }, + null, + ); + + expect(reply).toHaveBeenCalledOnce(); + expect(reply.mock.calls[0][0].embeds[0].data.description).toContain("Could not resolve the `voice` role"); + }); +}); + +describe("perform_voice_update", () => { + it("reports succeeded, skipped, and failed refreshes after exclusions", async () => { + const caller = createMember({ + id: "caller", + permissionBits: [Discord.PermissionFlagsBits.MoveMembers], + }); + const succeeds = createMember({ id: "succeeds" }); + const skips = createMember({ id: "skips" }); + const fails = createMember({ id: "fails" }); + const bot_member = createMember({ id: "bot", bot: true }); + const channel_moderator = createMember({ + id: "channel-moderator", + permissionBits: [Discord.PermissionFlagsBits.MoveMembers], + }); + const channel = createVoiceChannel([caller, succeeds, skips, fails, bot_member, channel_moderator]); + + const force_voice_permissions_update = vi.fn(async (member: { id: string }) => { + if (member.id === "succeeds") { + return true; + } + if (member.id === "skips") { + return false; + } + throw new Error("refresh failed"); + }); + + const result = await perform_voice_update( + { + caller: { ...caller, voice: { channel } } as any, + channel: channel as any, + wheatley: { + guild: { afkChannel: { id: "afk" } }, + force_voice_permissions_update, + } as any, + }, + exclude_bots(select_everyone), + ); + + expect(force_voice_permissions_update).toHaveBeenCalledTimes(3); + expect(result).toEqual({ + succeeded: 1, + failed: 1, + skipped: 1, + total: 3, + afk_missing: false, + }); + }); +});