From 4e42d91a67437578cc08f0b5aee60c3290804948 Mon Sep 17 00:00:00 2001 From: ianpike Date: Sat, 28 Feb 2026 21:02:56 -0500 Subject: [PATCH 01/16] Add support for forcing a permissions update on a user in a voice channel --- src/wheatley.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/wheatley.ts b/src/wheatley.ts index 9070e755..5e6b550a 100644 --- a/src/wheatley.ts +++ b/src/wheatley.ts @@ -449,6 +449,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; + if (!afk_channel) { + return false; + } + const original_channel = member.voice.channel; + if (!original_channel || original_channel.id === afk_channel.id) { + return false; + } + await member.voice.setChannel(afk_channel); + await member.voice.setChannel(original_channel); + return true; + } + async is_established_member( options: Discord.GuildMember | Discord.User | Discord.UserResolvable | Discord.FetchMemberOptions, ) { From e53c396167dee9df21ad661013cc906e33fce051 Mon Sep 17 00:00:00 2001 From: ianpike Date: Fri, 6 Mar 2026 23:33:02 -0500 Subject: [PATCH 02/16] Add DS_Store to ignore to prevent macOS artifacts from appearing --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e57d72b6..db181e4a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.DS_Store .vscode .idea node_modules From 9ab09eab8167f61ee7cdb49680e5696de4ad705c Mon Sep 17 00:00:00 2001 From: ianpike Date: Fri, 6 Mar 2026 23:34:16 -0500 Subject: [PATCH 03/16] Add role-based voice mute with duration --- .../tccpp/components/permissions-manager.ts | 4 ++ .../components/moderation/voice-mute.ts | 52 +++++++++++++++---- src/modules/wheatley/roles.ts | 1 + 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/modules/tccpp/components/permissions-manager.ts b/src/modules/tccpp/components/permissions-manager.ts index ea27b4c8..622af255 100644 --- a/src/modules/tccpp/components/permissions-manager.ts +++ b/src/modules/tccpp/components/permissions-manager.ts @@ -61,6 +61,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 +222,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]: { diff --git a/src/modules/wheatley/components/moderation/voice-mute.ts b/src/modules/wheatley/components/moderation/voice-mute.ts index a2c6c599..b85401b2 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 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", @@ -66,23 +86,35 @@ export default class VoiceMute extends ModerationComponent { 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); + if (member.voice.channel) { + await this.wheatley.force_voice_permissions_update(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); + if (member.voice.channel) { + await this.wheatley.force_voice_permissions_update(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/roles.ts b/src/modules/wheatley/roles.ts index 95fb19fc..7d904aed 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: "0", name: "voice mute" }, // TODO: Replace with actual role ID from Discord }); From 9954cb74d5ec9e3ed965093212e67153e74be6e9 Mon Sep 17 00:00:00 2001 From: ianpike Date: Fri, 6 Mar 2026 23:54:11 -0500 Subject: [PATCH 04/16] Update voice take to also use the force update permissions if a vc mod is not present when issued --- .../components/moderation/voice-take.ts | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/modules/wheatley/components/moderation/voice-take.ts b/src/modules/wheatley/components/moderation/voice-take.ts index 77d4e432..b20321a9 100644 --- a/src/modules/wheatley/components/moderation/voice-take.ts +++ b/src/modules/wheatley/components/moderation/voice-take.ts @@ -16,7 +16,7 @@ import { TextBasedCommand } from "../../../../command-abstractions/text-based-co import { moderation_entry, basic_moderation_with_user } from "./schemata.js"; export default class VoiceTake extends ModerationComponent { - private roles = role_map(this.wheatley, wheatley_roles.voice); + private roles = role_map(this.wheatley, wheatley_roles.voice, wheatley_roles.voice_moderator); get type() { return "voice_take" as const; @@ -97,24 +97,46 @@ export default class VoiceTake extends ModerationComponent { await this.moderation_revoke_handler(command, user, null, {}, { allow_no_entry: true }); } + private has_voice_moderator_in_channel(channel: Discord.VoiceChannel | Discord.StageChannel): boolean { + return channel.members.some(m => m.roles.cache.has(this.roles.voice_moderator.id)); + } + 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); + const channel = member.voice.channel; + if (channel && !this.has_voice_moderator_in_channel(channel)) { + await this.wheatley.force_voice_permissions_update(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); + const channel = member.voice.channel; + if (channel && !this.has_voice_moderator_in_channel(channel)) { + await this.wheatley.force_voice_permissions_update(member); + } } } override async apply_revoke_to_discord(member: Discord.GuildMember): Promise { await member.roles.add(this.roles.voice); + const channel = member.voice.channel; + if (channel && !this.has_voice_moderator_in_channel(channel)) { + await this.wheatley.force_voice_permissions_update(member); + } } async is_moderation_applied_in_discord(moderation: basic_moderation_with_user) { From 011433ed6ff1f2bab1f8a6c93860a125aff1fafa Mon Sep 17 00:00:00 2001 From: ianpike Date: Sat, 7 Mar 2026 01:11:32 -0500 Subject: [PATCH 05/16] Force voice permission updates for suppressed users when a voice mod joins to address specific edge cases with voice give Made-with: Cursor --- src/modules/tccpp/components/permissions-manager.ts | 5 +++++ src/modules/wheatley/components/moderation/voice-mute.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/modules/tccpp/components/permissions-manager.ts b/src/modules/tccpp/components/permissions-manager.ts index 622af255..1652121a 100644 --- a/src/modules/tccpp/components/permissions-manager.ts +++ b/src/modules/tccpp/components/permissions-manager.ts @@ -551,6 +551,11 @@ export default class PermissionManager extends BotComponent { }; this.dynamic_channel_overwrites[channel.id] = perms; await this.sync_channel_permissions(channel); + await Promise.all( + [...channel.members.values()] + .filter(member => !member.roles.cache.has(this.roles.voice.id)) + .map(member => this.wheatley.force_voice_permissions_update(member)), + ); } private async mod_has_left_the_building(channel: Discord.Channel) { diff --git a/src/modules/wheatley/components/moderation/voice-mute.ts b/src/modules/wheatley/components/moderation/voice-mute.ts index b85401b2..99d2d9cb 100644 --- a/src/modules/wheatley/components/moderation/voice-mute.ts +++ b/src/modules/wheatley/components/moderation/voice-mute.ts @@ -16,7 +16,7 @@ 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 roles = role_map(this.wheatley, wheatley_roles.voice_muted); + private readonly roles = role_map(this.wheatley, wheatley_roles.voice_muted); get type() { return "voice_mute" as const; From 51e6b3436ae8dbccf1b1e1857017f7d7969f7180 Mon Sep 17 00:00:00 2001 From: ianpike Date: Sat, 7 Mar 2026 17:03:18 -0500 Subject: [PATCH 06/16] General improvement to entire voice moderation component --- .../tccpp/components/permissions-manager.ts | 96 +++++++++++++++---- .../components/moderation/voice-take.ts | 11 +-- src/utils/discord.ts | 8 ++ 3 files changed, 91 insertions(+), 24 deletions(-) diff --git a/src/modules/tccpp/components/permissions-manager.ts b/src/modules/tccpp/components/permissions-manager.ts index 1652121a..2b9123ea 100644 --- a/src/modules/tccpp/components/permissions-manager.ts +++ b/src/modules/tccpp/components/permissions-manager.ts @@ -16,6 +16,7 @@ import { named_id } from "../../../channel-map.js"; import { unwrap } from "../../../utils/misc.js"; import { CommandSetBuilder } from "../../../command-abstractions/command-set-builder.js"; import { set_timeout } from "../../../utils/node.js"; +import { channel_has_member_with_role } from "../../../utils/discord.js"; const categories_map = { staff_logs: { id: "1135927261472755712", name: "Staff Logs" }, @@ -497,7 +498,7 @@ export default class PermissionManager extends BotComponent { 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; } } @@ -522,15 +523,56 @@ 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 member_has_voice_permissions( + channel: Discord.VoiceChannel | Discord.StageChannel, + member: Discord.GuildMember, + ): boolean { + const permissions = channel.permissionsFor(member); + return ( + permissions.has(Discord.PermissionsBitField.Flags.Speak) && + permissions.has(Discord.PermissionsBitField.Flags.Stream) + ); + } + + private should_force_voice_permissions_update( + channel: Discord.VoiceChannel | Discord.StageChannel, + member: Discord.GuildMember, + entering_moderator_id: string, + ): boolean { + return ( + member.id !== entering_moderator_id && + member.voice.channelId === channel.id && + !member.user.bot && + !member.permissions.has(Discord.PermissionFlagsBits.MuteMembers) && + !this.member_has_voice_permissions(channel, member) + ); + } + + 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] || @@ -540,6 +582,19 @@ export default class PermissionManager extends BotComponent { ) { return; } + if (channel_has_member_with_role(channel, this.roles.voice_moderator.id, entering_moderator_id)) { + return; + } + // Capture who currently lacks voice before applying the temporary overwrite. + const members_to_update = [ + ...new Set( + [...channel.members.values()] + .filter(member => + this.should_force_voice_permissions_update(channel, member, entering_moderator_id), + ) + .map(member => member.id), + ), + ]; const perms = Object.assign({}, base_perms); perms[everyone] = { allow: [ @@ -551,22 +606,29 @@ export default class PermissionManager extends BotComponent { }; this.dynamic_channel_overwrites[channel.id] = perms; await this.sync_channel_permissions(channel); - await Promise.all( - [...channel.members.values()] - .filter(member => !member.roles.cache.has(this.roles.voice.id)) - .map(member => this.wheatley.force_voice_permissions_update(member)), + const members = members_to_update + .map(id => channel.members.get(id)) + .filter((member): member is Discord.GuildMember => member != null); + const results = await Promise.allSettled( + members.map(member => this.wheatley.force_voice_permissions_update(member)), ); + for (const [index, result] of results.entries()) { + if (result.status === "rejected") { + const member = members[index]; + M.warn( + `Failed to force voice permissions update for ${member.user.tag} (${member.id}) in ${channel.name}`, + result.reason, + ); + } + } } - 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); @@ -585,7 +647,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-take.ts b/src/modules/wheatley/components/moderation/voice-take.ts index b20321a9..d5d102f3 100644 --- a/src/modules/wheatley/components/moderation/voice-take.ts +++ b/src/modules/wheatley/components/moderation/voice-take.ts @@ -14,6 +14,7 @@ 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, wheatley_roles.voice_moderator); @@ -97,10 +98,6 @@ export default class VoiceTake extends ModerationComponent { await this.moderation_revoke_handler(command, user, null, {}, { allow_no_entry: true }); } - private has_voice_moderator_in_channel(channel: Discord.VoiceChannel | Discord.StageChannel): boolean { - return channel.members.some(m => m.roles.cache.has(this.roles.voice_moderator.id)); - } - async apply_moderation(entry: moderation_entry) { M.info(`Applying voice take to ${entry.user_name}`); if (this.dummy_rounds) { @@ -110,7 +107,7 @@ export default class VoiceTake extends ModerationComponent { if (member) { await member.roles.remove(this.roles.voice); const channel = member.voice.channel; - if (channel && !this.has_voice_moderator_in_channel(channel)) { + if (channel && !channel_has_member_with_role(channel, this.roles.voice_moderator.id)) { await this.wheatley.force_voice_permissions_update(member); } } @@ -125,7 +122,7 @@ export default class VoiceTake extends ModerationComponent { if (member) { await member.roles.add(this.roles.voice); const channel = member.voice.channel; - if (channel && !this.has_voice_moderator_in_channel(channel)) { + if (channel && !channel_has_member_with_role(channel, this.roles.voice_moderator.id)) { await this.wheatley.force_voice_permissions_update(member); } } @@ -134,7 +131,7 @@ export default class VoiceTake extends ModerationComponent { override async apply_revoke_to_discord(member: Discord.GuildMember): Promise { await member.roles.add(this.roles.voice); const channel = member.voice.channel; - if (channel && !this.has_voice_moderator_in_channel(channel)) { + if (channel && !channel_has_member_with_role(channel, this.roles.voice_moderator.id)) { await this.wheatley.force_voice_permissions_update(member); } } 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( From dff0896bae5900453473aba09be8b42c4d260cd4 Mon Sep 17 00:00:00 2001 From: Ian Pike Date: Sat, 7 Mar 2026 17:07:55 -0500 Subject: [PATCH 07/16] Update voice_muted role ID and name to correct ID --- src/modules/wheatley/roles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/wheatley/roles.ts b/src/modules/wheatley/roles.ts index 7d904aed..9d258e74 100644 --- a/src/modules/wheatley/roles.ts +++ b/src/modules/wheatley/roles.ts @@ -24,5 +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: "0", name: "voice mute" }, // TODO: Replace with actual role ID from Discord + voice_muted: { id: "1479764785917595822", name: "Voice Muted" }, }); From 6ea8cd804996557f84d3f6a94471dae0a5dfef57 Mon Sep 17 00:00:00 2001 From: ianpike Date: Sat, 7 Mar 2026 17:18:47 -0500 Subject: [PATCH 08/16] Make role map readonly in voice take --- src/modules/wheatley/components/moderation/voice-take.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/wheatley/components/moderation/voice-take.ts b/src/modules/wheatley/components/moderation/voice-take.ts index d5d102f3..6032998c 100644 --- a/src/modules/wheatley/components/moderation/voice-take.ts +++ b/src/modules/wheatley/components/moderation/voice-take.ts @@ -17,7 +17,7 @@ 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, wheatley_roles.voice_moderator); + private readonly roles = role_map(this.wheatley, wheatley_roles.voice, wheatley_roles.voice_moderator); get type() { return "voice_take" as const; From ae647f893ae2e60535f30872de3bbf5331556a67 Mon Sep 17 00:00:00 2001 From: ianpike Date: Sat, 7 Mar 2026 17:19:37 -0500 Subject: [PATCH 09/16] Add a cooldown to permission updates when a mod joins a vc channel --- src/modules/tccpp/components/permissions-manager.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/modules/tccpp/components/permissions-manager.ts b/src/modules/tccpp/components/permissions-manager.ts index 2b9123ea..f821bf89 100644 --- a/src/modules/tccpp/components/permissions-manager.ts +++ b/src/modules/tccpp/components/permissions-manager.ts @@ -3,7 +3,7 @@ import * as Discord from "discord.js"; import { strict as assert } from "assert"; import { M } from "../../../utils/debugging-and-logging.js"; -import { HOUR } from "../../../common.js"; +import { HOUR, MINUTE } from "../../../common.js"; import { BotComponent } from "../../../bot-component.js"; import { channel_map } from "../../../channel-map.js"; import { role_map } from "../../../role-map.js"; @@ -106,6 +106,7 @@ export default class PermissionManager extends BotComponent { category_permissions: Partial> = {}; channel_overwrites: Partial> = {}; dynamic_channel_overwrites: Partial> = {}; + private last_member_update_by_channel = new Map(); override async setup(commands: CommandSetBuilder) { await this.channels.resolve(); @@ -606,6 +607,12 @@ export default class PermissionManager extends BotComponent { }; this.dynamic_channel_overwrites[channel.id] = perms; await this.sync_channel_permissions(channel); + const now = Date.now(); + const last_update = this.last_member_update_by_channel.get(channel.id) ?? 0; + if (now - last_update < 5 * MINUTE) { + return; // Avoid repeatedly moving everyone when mods join/leave frequently. + } + this.last_member_update_by_channel.set(channel.id, now); const members = members_to_update .map(id => channel.members.get(id)) .filter((member): member is Discord.GuildMember => member != null); @@ -631,6 +638,7 @@ export default class PermissionManager extends BotComponent { return; } delete this.dynamic_channel_overwrites[channel.id]; + this.last_member_update_by_channel.delete(channel.id); await this.sync_channel_permissions(channel); } From fe49045137e9e13043132e877bbde53087ad56e7 Mon Sep 17 00:00:00 2001 From: ianpike Date: Mon, 9 Mar 2026 14:52:07 -0400 Subject: [PATCH 10/16] Add shared voice-update utility --- .../tccpp/components/permissions-manager.ts | 157 +----------------- .../components/moderation/voice-take.ts | 63 +++++++ src/utils/voice-update.ts | 135 +++++++++++++++ 3 files changed, 200 insertions(+), 155 deletions(-) create mode 100644 src/utils/voice-update.ts diff --git a/src/modules/tccpp/components/permissions-manager.ts b/src/modules/tccpp/components/permissions-manager.ts index f821bf89..3fde6703 100644 --- a/src/modules/tccpp/components/permissions-manager.ts +++ b/src/modules/tccpp/components/permissions-manager.ts @@ -3,7 +3,7 @@ import * as Discord from "discord.js"; import { strict as assert } from "assert"; import { M } from "../../../utils/debugging-and-logging.js"; -import { HOUR, MINUTE } from "../../../common.js"; +import { HOUR } from "../../../common.js"; import { BotComponent } from "../../../bot-component.js"; import { channel_map } from "../../../channel-map.js"; import { role_map } from "../../../role-map.js"; @@ -16,7 +16,6 @@ import { named_id } from "../../../channel-map.js"; import { unwrap } from "../../../utils/misc.js"; import { CommandSetBuilder } from "../../../command-abstractions/command-set-builder.js"; import { set_timeout } from "../../../utils/node.js"; -import { channel_has_member_with_role } from "../../../utils/discord.js"; const categories_map = { staff_logs: { id: "1135927261472755712", name: "Staff Logs" }, @@ -105,8 +104,6 @@ export default class PermissionManager extends BotComponent { category_permissions: Partial> = {}; channel_overwrites: Partial> = {}; - dynamic_channel_overwrites: Partial> = {}; - private last_member_update_by_channel = new Map(); override async setup(commands: CommandSetBuilder) { await this.channels.resolve(); @@ -474,11 +471,6 @@ export default class PermissionManager extends BotComponent { } async sync_channel_permissions(channel: Discord.CategoryChildChannel) { - if (channel.id in this.dynamic_channel_overwrites) { - M.log(`Setting dynamic permissions for channel ${channel.id} ${channel.name}`); - await this.set_channel_permissions(channel, unwrap(this.dynamic_channel_overwrites[channel.id])); - return; - } if (channel.id in this.channel_overwrites) { M.log(`Setting permissions for channel ${channel.id} ${channel.name}`); await this.set_channel_permissions(channel, unwrap(this.channel_overwrites[channel.id])); @@ -493,17 +485,8 @@ 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, member.id); - break; - } - } - } } } @@ -523,140 +506,4 @@ export default class PermissionManager extends BotComponent { this.sync_permissions().catch(this.wheatley.critical_error.bind(this.wheatley)); }, HOUR); } - - 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 member_has_voice_permissions( - channel: Discord.VoiceChannel | Discord.StageChannel, - member: Discord.GuildMember, - ): boolean { - const permissions = channel.permissionsFor(member); - return ( - permissions.has(Discord.PermissionsBitField.Flags.Speak) && - permissions.has(Discord.PermissionsBitField.Flags.Stream) - ); - } - - private should_force_voice_permissions_update( - channel: Discord.VoiceChannel | Discord.StageChannel, - member: Discord.GuildMember, - entering_moderator_id: string, - ): boolean { - return ( - member.id !== entering_moderator_id && - member.voice.channelId === channel.id && - !member.user.bot && - !member.permissions.has(Discord.PermissionFlagsBits.MuteMembers) && - !this.member_has_voice_permissions(channel, member) - ); - } - - 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.get_base_permissions(channel); - if ( - !base_perms || - !base_perms[everyone] || - !base_perms[everyone].deny || - !base_perms[everyone].deny.includes(Discord.PermissionsBitField.Flags.Speak) || - !base_perms[everyone].deny.includes(Discord.PermissionsBitField.Flags.Stream) - ) { - return; - } - if (channel_has_member_with_role(channel, this.roles.voice_moderator.id, entering_moderator_id)) { - return; - } - // Capture who currently lacks voice before applying the temporary overwrite. - const members_to_update = [ - ...new Set( - [...channel.members.values()] - .filter(member => - this.should_force_voice_permissions_update(channel, member, entering_moderator_id), - ) - .map(member => member.id), - ), - ]; - const perms = Object.assign({}, base_perms); - perms[everyone] = { - allow: [ - Discord.PermissionsBitField.Flags.Speak, - Discord.PermissionsBitField.Flags.Stream, - ...(perms[everyone]?.allow ?? []), - ], - deny: perms[everyone]?.deny, - }; - this.dynamic_channel_overwrites[channel.id] = perms; - await this.sync_channel_permissions(channel); - const now = Date.now(); - const last_update = this.last_member_update_by_channel.get(channel.id) ?? 0; - if (now - last_update < 5 * MINUTE) { - return; // Avoid repeatedly moving everyone when mods join/leave frequently. - } - this.last_member_update_by_channel.set(channel.id, now); - const members = members_to_update - .map(id => channel.members.get(id)) - .filter((member): member is Discord.GuildMember => member != null); - const results = await Promise.allSettled( - members.map(member => this.wheatley.force_voice_permissions_update(member)), - ); - for (const [index, result] of results.entries()) { - if (result.status === "rejected") { - const member = members[index]; - M.warn( - `Failed to force voice permissions update for ${member.user.tag} (${member.id}) in ${channel.name}`, - result.reason, - ); - } - } - } - - private async mod_has_left_the_building(channel: Discord.VoiceChannel | Discord.StageChannel) { - if (!(channel.id in this.dynamic_channel_overwrites)) { - return; - } - if (this.has_moderator_in_channel(channel)) { - return; - } - delete this.dynamic_channel_overwrites[channel.id]; - this.last_member_update_by_channel.delete(channel.id); - await this.sync_channel_permissions(channel); - } - - override async on_voice_state_update(old_state: Discord.VoiceState, new_state: Discord.VoiceState) { - if (new_state.guild.id !== this.wheatley.guild.id) { - return; - } - if ( - new_state.member && - new_state.member.permissions.has(Discord.PermissionFlagsBits.MuteMembers) && - new_state.channelId != old_state.channelId - ) { - if (old_state.channel) { - await this.mod_has_left_the_building(old_state.channel); - } - if (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-take.ts b/src/modules/wheatley/components/moderation/voice-take.ts index 6032998c..50c0b993 100644 --- a/src/modules/wheatley/components/moderation/voice-take.ts +++ b/src/modules/wheatley/components/moderation/voice-take.ts @@ -15,6 +15,13 @@ import { 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"; +import { + perform_voice_update, + select_everyone, + exclude_bots, + select_without_role, + type VoiceUpdateContext, +} from "../../../../utils/voice-update.js"; export default class VoiceTake extends ModerationComponent { private readonly roles = role_map(this.wheatley, wheatley_roles.voice, wheatley_roles.voice_moderator); @@ -63,6 +70,17 @@ export default class VoiceTake extends ModerationComponent { required: true, }) .set_handler(this.handle_give.bind(this)), + ) + .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 (required on non-TCCPP). Omit on TCCPP for affected users only.", + required: false, + }) + .set_handler(this.handle_update.bind(this)), ), ); } @@ -98,6 +116,51 @@ export default class VoiceTake extends ModerationComponent { await this.moderation_revoke_handler(command, user, null, {}, { allow_no_entry: true }); } + private async handle_update(command: TextBasedCommand, all: boolean | null) { + const member = await command.get_member(); + if (!member.permissions.has(Discord.PermissionFlagsBits.MoveMembers)) { + await this.reply_with_error(command, "You need the Move Members permission to use this command."); + return; + } + const channel = member.voice.channel; + if (!channel || !channel.isVoiceBased()) { + await this.reply_with_error(command, "You must be in a voice channel to use this command."); + return; + } + const on_tccpp = this.wheatley.components.has("PermissionManager"); + if (!all && !on_tccpp) { + await this.reply_with_error( + command, + "Specify `all: true` to refresh everyone. (The affected-user mode is only available on TCCPP.)", + ); + return; + } + const context: VoiceUpdateContext = { + guild: this.wheatley.guild, + caller: member, + channel, + wheatley: this.wheatley, + }; + const selector = all ? exclude_bots(select_everyone) : exclude_bots(select_without_role(this.roles.voice.id)); + const result = await perform_voice_update(context, selector); + if (result.afk_missing) { + await this.reply_with_error( + command, + "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, + }); + } + async apply_moderation(entry: moderation_entry) { M.info(`Applying voice take to ${entry.user_name}`); if (this.dummy_rounds) { diff --git a/src/utils/voice-update.ts b/src/utils/voice-update.ts new file mode 100644 index 00000000..6715a1a4 --- /dev/null +++ b/src/utils/voice-update.ts @@ -0,0 +1,135 @@ +import * as Discord from "discord.js"; + +import { Wheatley } from "../wheatley.js"; + +export type VoiceUpdateContext = { + guild: Discord.Guild; + caller: Discord.GuildMember; + channel: Discord.VoiceChannel | Discord.StageChannel; + wheatley: Wheatley; + excludeUserIds?: string[]; +}; + +/** 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; +}; + +const MOVE_MEMBERS = Discord.PermissionFlagsBits.MoveMembers; + +function is_hard_excluded(member: Discord.GuildMember, context: VoiceUpdateContext): boolean { + if (member.id === context.caller.id) { + return true; + } + if (member.permissions.has(MOVE_MEMBERS)) { + return true; + } + if (context.excludeUserIds?.includes(member.id)) { + return true; + } + return false; +} + +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 require_roles(roleIds: string[], selector: VoiceUpdateSelector): VoiceUpdateSelector { + return (member, context) => { + if (!selector(member, context)) { + return false; + } + return roleIds.some(id => member.roles.cache.has(id)); + }; +} + +export function forbid_roles(roleIds: string[], selector: VoiceUpdateSelector): VoiceUpdateSelector { + return (member, context) => { + if (!selector(member, context)) { + return false; + } + return !roleIds.some(id => member.roles.cache.has(id)); + }; +} + +export function require_permissions( + permissions: Discord.PermissionResolvable, + selector: VoiceUpdateSelector, +): VoiceUpdateSelector { + const bits = BigInt(Discord.PermissionsBitField.resolve(permissions)); + return (member, context) => { + if (!selector(member, context)) { + return false; + } + return member.permissions.has(bits); + }; +} + +export function forbid_permissions( + permissions: Discord.PermissionResolvable, + selector: VoiceUpdateSelector, +): VoiceUpdateSelector { + const bits = BigInt(Discord.PermissionsBitField.resolve(permissions)); + return (member, context) => { + if (!selector(member, context)) { + return false; + } + return !member.permissions.has(bits); + }; +} + +export function include_users(userIds: string[], selector: VoiceUpdateSelector): VoiceUpdateSelector { + const set = new Set(userIds); + return (member, context) => { + if (!selector(member, context)) { + return false; + } + return set.has(member.id); + }; +} + +export function or_include_users(userIds: string[], selector: VoiceUpdateSelector): VoiceUpdateSelector { + const set = new Set(userIds); + return (member, context) => selector(member, context) || set.has(member.id); +} + +export function select_without_role(roleId: string, base: VoiceUpdateSelector = select_everyone): VoiceUpdateSelector { + return forbid_roles([roleId], base); +} + +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 }; +} From 9b1823590d863a4334a5cc63bcc0394d0086f2c4 Mon Sep 17 00:00:00 2001 From: ianpike Date: Mon, 9 Mar 2026 19:13:52 -0400 Subject: [PATCH 11/16] Re-add automatic dynamic permission overwrite when mod joins voice channel --- .../tccpp/components/permissions-manager.ts | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/src/modules/tccpp/components/permissions-manager.ts b/src/modules/tccpp/components/permissions-manager.ts index 3fde6703..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 = { @@ -104,6 +105,7 @@ export default class PermissionManager extends BotComponent { category_permissions: Partial> = {}; channel_overwrites: Partial> = {}; + dynamic_channel_overwrites: Partial> = {}; override async setup(commands: CommandSetBuilder) { await this.channels.resolve(); @@ -471,6 +473,11 @@ export default class PermissionManager extends BotComponent { } async sync_channel_permissions(channel: Discord.CategoryChildChannel) { + if (channel.id in this.dynamic_channel_overwrites) { + M.log(`Setting dynamic permissions for channel ${channel.id} ${channel.name}`); + await this.set_channel_permissions(channel, unwrap(this.dynamic_channel_overwrites[channel.id])); + return; + } if (channel.id in this.channel_overwrites) { M.log(`Setting permissions for channel ${channel.id} ${channel.name}`); await this.set_channel_permissions(channel, unwrap(this.channel_overwrites[channel.id])); @@ -487,6 +494,14 @@ export default class PermissionManager extends BotComponent { ); 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, member.id); + break; + } + } + } } } @@ -506,4 +521,83 @@ export default class PermissionManager extends BotComponent { this.sync_permissions().catch(this.wheatley.critical_error.bind(this.wheatley)); }, HOUR); } + + 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.get_base_permissions(channel); + if ( + !base_perms || + !base_perms[everyone] || + !base_perms[everyone].deny || + !base_perms[everyone].deny.includes(Discord.PermissionsBitField.Flags.Speak) || + !base_perms[everyone].deny.includes(Discord.PermissionsBitField.Flags.Stream) + ) { + 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: [ + Discord.PermissionsBitField.Flags.Speak, + Discord.PermissionsBitField.Flags.Stream, + ...(perms[everyone]?.allow ?? []), + ], + deny: perms[everyone]?.deny, + }; + this.dynamic_channel_overwrites[channel.id] = perms; + await this.sync_channel_permissions(channel); + } + + private async mod_has_left_the_building(channel: Discord.VoiceChannel | Discord.StageChannel) { + if (!(channel.id in this.dynamic_channel_overwrites)) { + return; + } + if (this.has_moderator_in_channel(channel)) { + return; + } + delete this.dynamic_channel_overwrites[channel.id]; + await this.sync_channel_permissions(channel); + } + + override async on_voice_state_update(old_state: Discord.VoiceState, new_state: Discord.VoiceState) { + if (new_state.guild.id !== this.wheatley.guild.id) { + return; + } + if ( + new_state.member && + new_state.member.permissions.has(Discord.PermissionFlagsBits.MuteMembers) && + new_state.channelId != old_state.channelId + ) { + if (old_state.channel) { + await this.mod_has_left_the_building(old_state.channel); + } + if (new_state.channel) { + await this.mod_has_entered_the_building(new_state.channel, new_state.member.id); + } + } + } } From ee8a0163561aab6efa712cc238fa9ff2fad2b8b4 Mon Sep 17 00:00:00 2001 From: Ian Pike Date: Mon, 9 Mar 2026 19:42:55 -0400 Subject: [PATCH 12/16] Use optional chaining for voice channel check --- src/modules/wheatley/components/moderation/voice-take.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/wheatley/components/moderation/voice-take.ts b/src/modules/wheatley/components/moderation/voice-take.ts index 50c0b993..05997511 100644 --- a/src/modules/wheatley/components/moderation/voice-take.ts +++ b/src/modules/wheatley/components/moderation/voice-take.ts @@ -123,7 +123,7 @@ export default class VoiceTake extends ModerationComponent { return; } const channel = member.voice.channel; - if (!channel || !channel.isVoiceBased()) { + if (!channel?.isVoiceBased()) { await this.reply_with_error(command, "You must be in a voice channel to use this command."); return; } From 85e364b769d197ab79776e99749edaaf4452177e Mon Sep 17 00:00:00 2001 From: ianpike Date: Tue, 17 Mar 2026 01:42:26 -0400 Subject: [PATCH 13/16] Move voice take into its own module --- .../components/moderation/voice-take.ts | 63 ---------- .../components/moderation/voice-update.ts | 108 ++++++++++++++++++ 2 files changed, 108 insertions(+), 63 deletions(-) create mode 100644 src/modules/wheatley/components/moderation/voice-update.ts diff --git a/src/modules/wheatley/components/moderation/voice-take.ts b/src/modules/wheatley/components/moderation/voice-take.ts index 05997511..6032998c 100644 --- a/src/modules/wheatley/components/moderation/voice-take.ts +++ b/src/modules/wheatley/components/moderation/voice-take.ts @@ -15,13 +15,6 @@ import { 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"; -import { - perform_voice_update, - select_everyone, - exclude_bots, - select_without_role, - type VoiceUpdateContext, -} from "../../../../utils/voice-update.js"; export default class VoiceTake extends ModerationComponent { private readonly roles = role_map(this.wheatley, wheatley_roles.voice, wheatley_roles.voice_moderator); @@ -70,17 +63,6 @@ export default class VoiceTake extends ModerationComponent { required: true, }) .set_handler(this.handle_give.bind(this)), - ) - .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 (required on non-TCCPP). Omit on TCCPP for affected users only.", - required: false, - }) - .set_handler(this.handle_update.bind(this)), ), ); } @@ -116,51 +98,6 @@ export default class VoiceTake extends ModerationComponent { await this.moderation_revoke_handler(command, user, null, {}, { allow_no_entry: true }); } - private async handle_update(command: TextBasedCommand, all: boolean | null) { - const member = await command.get_member(); - if (!member.permissions.has(Discord.PermissionFlagsBits.MoveMembers)) { - await this.reply_with_error(command, "You need the Move Members permission to use this command."); - return; - } - const channel = member.voice.channel; - if (!channel?.isVoiceBased()) { - await this.reply_with_error(command, "You must be in a voice channel to use this command."); - return; - } - const on_tccpp = this.wheatley.components.has("PermissionManager"); - if (!all && !on_tccpp) { - await this.reply_with_error( - command, - "Specify `all: true` to refresh everyone. (The affected-user mode is only available on TCCPP.)", - ); - return; - } - const context: VoiceUpdateContext = { - guild: this.wheatley.guild, - caller: member, - channel, - wheatley: this.wheatley, - }; - const selector = all ? exclude_bots(select_everyone) : exclude_bots(select_without_role(this.roles.voice.id)); - const result = await perform_voice_update(context, selector); - if (result.afk_missing) { - await this.reply_with_error( - command, - "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, - }); - } - async apply_moderation(entry: moderation_entry) { M.info(`Applying voice take to ${entry.user_name}`); if (this.dummy_rounds) { 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..9bcb23a9 --- /dev/null +++ b/src/modules/wheatley/components/moderation/voice-update.ts @@ -0,0 +1,108 @@ +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 (required on non-TCCPP). Omit on TCCPP for affected users only.", + 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; + } + + const on_tccpp = this.wheatley.components.has("PermissionManager"); + if (!all && !on_tccpp) { + await command.reply( + create_error_reply( + "Specify `all: true` to refresh everyone. (The affected-user mode is only available on TCCPP.)", + ), + ); + 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 the dev guild or run with `all: true`.", + ), + ); + return; + } + selector = exclude_bots(select_without_role(voice_role.id)); + } + + const context: VoiceUpdateContext = { + guild: this.wheatley.guild, + 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, + }); + } +} From 02aac1ba1d62b1ad6c47acb795a9d85feb239690 Mon Sep 17 00:00:00 2001 From: ianpike Date: Tue, 17 Mar 2026 01:43:12 -0400 Subject: [PATCH 14/16] Add tests for voice take and voice update --- test/voice-take.ts | 92 +++++++++++++++++++++++ test/voice-update.ts | 169 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 test/voice-take.ts create mode 100644 test/voice-update.ts diff --git a/test/voice-take.ts b/test/voice-take.ts new file mode 100644 index 00000000..1dc4e173 --- /dev/null +++ b/test/voice-take.ts @@ -0,0 +1,92 @@ +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("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..d9c49706 --- /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" } }, + components: { has: (name: string) => name === "PermissionManager" }, + 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("requires all outside TCCPP when affected-user mode is unavailable", 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), { + wheatley: { + components: { has: () => false }, + }, + }); + + 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("Specify `all: true` to refresh everyone."); + }); +}); + +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( + { + guild: {} as any, + 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, + }); + }); +}); From bcc1fef9466805b9d0f65aa465668645523b5b4e Mon Sep 17 00:00:00 2001 From: ianpike Date: Tue, 17 Mar 2026 02:02:53 -0400 Subject: [PATCH 15/16] Move is_tccpp into its own helper out of voice update module --- .../wheatley/components/moderation/voice-update.ts | 3 +-- src/wheatley.ts | 12 ++++++++++++ test/voice-update.ts | 3 ++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/modules/wheatley/components/moderation/voice-update.ts b/src/modules/wheatley/components/moderation/voice-update.ts index 9bcb23a9..1fd33ccd 100644 --- a/src/modules/wheatley/components/moderation/voice-update.ts +++ b/src/modules/wheatley/components/moderation/voice-update.ts @@ -54,8 +54,7 @@ export default class VoiceUpdate extends BotComponent { return; } - const on_tccpp = this.wheatley.components.has("PermissionManager"); - if (!all && !on_tccpp) { + if (!all && !this.wheatley.is_tccpp_like()) { await command.reply( create_error_reply( "Specify `all: true` to refresh everyone. (The affected-user mode is only available on TCCPP.)", diff --git a/src/wheatley.ts b/src/wheatley.ts index 7de0c85a..c2bd88d6 100644 --- a/src/wheatley.ts +++ b/src/wheatley.ts @@ -25,6 +25,8 @@ import { CommandHandler } from "./command-handler.js"; import { CommandSetBuilder } from "./command-abstractions/command-set-builder.js"; import { message_database_entry } from "./modules/wheatley/components/moderation/purge.js"; +const OFFICIAL_TCCPP_GUILD_ID = "331718482485837825"; + export function create_basic_embed(title: string | undefined, color: number, content: string) { const embed = new Discord.EmbedBuilder().setColor(color).setDescription(content); if (title) { @@ -130,6 +132,7 @@ export class Wheatley { }); private mom_ping: string; + private readonly configured_guild_id: string; readonly config: { [key: string]: any; @@ -144,6 +147,7 @@ export class Wheatley { config: wheatley_config, ) { this.freestanding = config.freestanding ?? false; + this.configured_guild_id = config.guild; this.mom_ping = config.mom ? ` <@${config.mom}>` : ""; @@ -162,6 +166,14 @@ export class Wheatley { this.setup(config).catch(this.critical_error.bind(this)); } + is_tccpp() { + return this.configured_guild_id === OFFICIAL_TCCPP_GUILD_ID; + } + + is_tccpp_like() { + return this.is_tccpp() || this.components.has("PermissionManager"); + } + private *locate_components(config: core_config) { const visited = new Set(); diff --git a/test/voice-update.ts b/test/voice-update.ts index d9c49706..d81bcffb 100644 --- a/test/voice-update.ts +++ b/test/voice-update.ts @@ -51,6 +51,7 @@ describe("voice update", () => { guild: { afkChannel: { id: "afk" } }, components: { has: (name: string) => name === "PermissionManager" }, force_voice_permissions_update, + is_tccpp_like: () => true, }; const channel = createVoiceChannel([]); @@ -101,7 +102,7 @@ describe("voice update", () => { }); const component = Object.assign(Object.create(VoiceUpdate.prototype), { wheatley: { - components: { has: () => false }, + is_tccpp_like: () => false, }, }); From 8be5ced550677eea10bec69d5172df2b5c53e314 Mon Sep 17 00:00:00 2001 From: ianpike Date: Mon, 20 Jul 2026 14:14:56 -0400 Subject: [PATCH 16/16] Clean up voice update and handle disconnects during permission refresh --- .../components/moderation/voice-mute.ts | 16 ++-- .../components/moderation/voice-take.ts | 27 ++++--- .../components/moderation/voice-update.ts | 15 +--- src/utils/voice-update.ts | 75 +------------------ src/wheatley.ts | 26 ++----- test/voice-mute.ts | 53 +++++++++++++ test/voice-take.ts | 13 ++++ test/voice-update.ts | 13 ++-- 8 files changed, 109 insertions(+), 129 deletions(-) create mode 100644 test/voice-mute.ts diff --git a/src/modules/wheatley/components/moderation/voice-mute.ts b/src/modules/wheatley/components/moderation/voice-mute.ts index 99d2d9cb..f33599ca 100644 --- a/src/modules/wheatley/components/moderation/voice-mute.ts +++ b/src/modules/wheatley/components/moderation/voice-mute.ts @@ -84,6 +84,14 @@ 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) { @@ -92,9 +100,7 @@ export default class VoiceMute extends ModerationComponent { const member = await this.wheatley.try_fetch_guild_member(entry.user); if (member) { await member.roles.add(this.roles.voice_muted); - if (member.voice.channel) { - await this.wheatley.force_voice_permissions_update(member); - } + await this.refresh_voice_permissions(member); } } @@ -106,9 +112,7 @@ export default class VoiceMute extends ModerationComponent { const member = await this.wheatley.try_fetch_guild_member(entry.user); if (member) { await member.roles.remove(this.roles.voice_muted); - if (member.voice.channel) { - await this.wheatley.force_voice_permissions_update(member); - } + await this.refresh_voice_permissions(member); } } diff --git a/src/modules/wheatley/components/moderation/voice-take.ts b/src/modules/wheatley/components/moderation/voice-take.ts index 6032998c..967bf1ee 100644 --- a/src/modules/wheatley/components/moderation/voice-take.ts +++ b/src/modules/wheatley/components/moderation/voice-take.ts @@ -98,6 +98,18 @@ 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) { @@ -106,10 +118,7 @@ export default class VoiceTake extends ModerationComponent { const member = await this.wheatley.try_fetch_guild_member(entry.user); if (member) { await member.roles.remove(this.roles.voice); - const channel = member.voice.channel; - if (channel && !channel_has_member_with_role(channel, this.roles.voice_moderator.id)) { - await this.wheatley.force_voice_permissions_update(member); - } + await this.refresh_voice_permissions(member); } } @@ -121,19 +130,13 @@ export default class VoiceTake extends ModerationComponent { const member = await this.wheatley.try_fetch_guild_member(entry.user); if (member) { await member.roles.add(this.roles.voice); - const channel = member.voice.channel; - if (channel && !channel_has_member_with_role(channel, this.roles.voice_moderator.id)) { - await this.wheatley.force_voice_permissions_update(member); - } + await this.refresh_voice_permissions(member); } } override async apply_revoke_to_discord(member: Discord.GuildMember): Promise { await member.roles.add(this.roles.voice); - const channel = member.voice.channel; - if (channel && !channel_has_member_with_role(channel, this.roles.voice_moderator.id)) { - await this.wheatley.force_voice_permissions_update(member); - } + 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 index 1fd33ccd..0a0ff3cf 100644 --- a/src/modules/wheatley/components/moderation/voice-update.ts +++ b/src/modules/wheatley/components/moderation/voice-update.ts @@ -32,8 +32,7 @@ export default class VoiceUpdate extends BotComponent { .set_description("Force-refresh voice permissions in your current channel") .add_boolean_option({ title: "all", - description: - "Refresh everyone (required on non-TCCPP). Omit on TCCPP for affected users only.", + description: "Refresh everyone rather than only those without the voice role", required: false, }) .set_handler(this.handle_update.bind(this)), @@ -54,15 +53,6 @@ export default class VoiceUpdate extends BotComponent { return; } - if (!all && !this.wheatley.is_tccpp_like()) { - await command.reply( - create_error_reply( - "Specify `all: true` to refresh everyone. (The affected-user mode is only available on TCCPP.)", - ), - ); - return; - } - let selector = exclude_bots(select_everyone); if (!all) { let voice_role: Discord.Role; @@ -72,7 +62,7 @@ export default class VoiceUpdate extends BotComponent { await command.reply( create_error_reply( "Could not resolve the `voice` role needed for affected-user refresh. " + - "Create that role in the dev guild or run with `all: true`.", + "Create that role in this guild or run with `all: true`.", ), ); return; @@ -81,7 +71,6 @@ export default class VoiceUpdate extends BotComponent { } const context: VoiceUpdateContext = { - guild: this.wheatley.guild, caller: member, channel, wheatley: this.wheatley, diff --git a/src/utils/voice-update.ts b/src/utils/voice-update.ts index 6715a1a4..df00765b 100644 --- a/src/utils/voice-update.ts +++ b/src/utils/voice-update.ts @@ -3,11 +3,9 @@ import * as Discord from "discord.js"; import { Wheatley } from "../wheatley.js"; export type VoiceUpdateContext = { - guild: Discord.Guild; caller: Discord.GuildMember; channel: Discord.VoiceChannel | Discord.StageChannel; wheatley: Wheatley; - excludeUserIds?: string[]; }; /** Predicate that determines whether a member should be included in the refresh set. */ @@ -21,19 +19,11 @@ export type VoiceUpdateResult = { afk_missing: boolean; }; -const MOVE_MEMBERS = Discord.PermissionFlagsBits.MoveMembers; - function is_hard_excluded(member: Discord.GuildMember, context: VoiceUpdateContext): boolean { if (member.id === context.caller.id) { return true; } - if (member.permissions.has(MOVE_MEMBERS)) { - return true; - } - if (context.excludeUserIds?.includes(member.id)) { - return true; - } - return false; + return member.permissions.has(Discord.PermissionFlagsBits.MoveMembers); } export const select_everyone: VoiceUpdateSelector = () => true; @@ -47,67 +37,8 @@ export function exclude_bots(selector: VoiceUpdateSelector): VoiceUpdateSelector }; } -export function require_roles(roleIds: string[], selector: VoiceUpdateSelector): VoiceUpdateSelector { - return (member, context) => { - if (!selector(member, context)) { - return false; - } - return roleIds.some(id => member.roles.cache.has(id)); - }; -} - -export function forbid_roles(roleIds: string[], selector: VoiceUpdateSelector): VoiceUpdateSelector { - return (member, context) => { - if (!selector(member, context)) { - return false; - } - return !roleIds.some(id => member.roles.cache.has(id)); - }; -} - -export function require_permissions( - permissions: Discord.PermissionResolvable, - selector: VoiceUpdateSelector, -): VoiceUpdateSelector { - const bits = BigInt(Discord.PermissionsBitField.resolve(permissions)); - return (member, context) => { - if (!selector(member, context)) { - return false; - } - return member.permissions.has(bits); - }; -} - -export function forbid_permissions( - permissions: Discord.PermissionResolvable, - selector: VoiceUpdateSelector, -): VoiceUpdateSelector { - const bits = BigInt(Discord.PermissionsBitField.resolve(permissions)); - return (member, context) => { - if (!selector(member, context)) { - return false; - } - return !member.permissions.has(bits); - }; -} - -export function include_users(userIds: string[], selector: VoiceUpdateSelector): VoiceUpdateSelector { - const set = new Set(userIds); - return (member, context) => { - if (!selector(member, context)) { - return false; - } - return set.has(member.id); - }; -} - -export function or_include_users(userIds: string[], selector: VoiceUpdateSelector): VoiceUpdateSelector { - const set = new Set(userIds); - return (member, context) => selector(member, context) || set.has(member.id); -} - -export function select_without_role(roleId: string, base: VoiceUpdateSelector = select_everyone): VoiceUpdateSelector { - return forbid_roles([roleId], base); +export function select_without_role(role_id: string): VoiceUpdateSelector { + return member => !member.roles.cache.has(role_id); } export async function perform_voice_update( diff --git a/src/wheatley.ts b/src/wheatley.ts index c2bd88d6..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"; @@ -25,8 +25,6 @@ import { CommandHandler } from "./command-handler.js"; import { CommandSetBuilder } from "./command-abstractions/command-set-builder.js"; import { message_database_entry } from "./modules/wheatley/components/moderation/purge.js"; -const OFFICIAL_TCCPP_GUILD_ID = "331718482485837825"; - export function create_basic_embed(title: string | undefined, color: number, content: string) { const embed = new Discord.EmbedBuilder().setColor(color).setDescription(content); if (title) { @@ -132,7 +130,6 @@ export class Wheatley { }); private mom_ping: string; - private readonly configured_guild_id: string; readonly config: { [key: string]: any; @@ -147,7 +144,6 @@ export class Wheatley { config: wheatley_config, ) { this.freestanding = config.freestanding ?? false; - this.configured_guild_id = config.guild; this.mom_ping = config.mom ? ` <@${config.mom}>` : ""; @@ -166,14 +162,6 @@ export class Wheatley { this.setup(config).catch(this.critical_error.bind(this)); } - is_tccpp() { - return this.configured_guild_id === OFFICIAL_TCCPP_GUILD_ID; - } - - is_tccpp_like() { - return this.is_tccpp() || this.components.has("PermissionManager"); - } - private *locate_components(config: core_config) { const visited = new Set(); @@ -465,16 +453,16 @@ export class Wheatley { /** 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; - if (!afk_channel) { + const original_channel = member.voice.channel; + if (!afk_channel || !original_channel || original_channel.id === afk_channel.id) { return false; } - const original_channel = member.voice.channel; - if (!original_channel || original_channel.id === afk_channel.id) { + // 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; } - await member.voice.setChannel(afk_channel); - await member.voice.setChannel(original_channel); - return true; + return (await api_wrap(() => member.voice.setChannel(original_channel), disconnected)) !== null; } async is_established_member( 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 index 1dc4e173..a8a235aa 100644 --- a/test/voice-take.ts +++ b/test/voice-take.ts @@ -76,6 +76,19 @@ describe("voice take refreshes", () => { 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]); diff --git a/test/voice-update.ts b/test/voice-update.ts index d81bcffb..077ec080 100644 --- a/test/voice-update.ts +++ b/test/voice-update.ts @@ -49,9 +49,7 @@ describe("voice update", () => { const force_voice_permissions_update = vi.fn().mockResolvedValue(true); const wheatley = { guild: { afkChannel: { id: "afk" } }, - components: { has: (name: string) => name === "PermissionManager" }, force_voice_permissions_update, - is_tccpp_like: () => true, }; const channel = createVoiceChannel([]); @@ -93,7 +91,7 @@ describe("voice update", () => { }); }); - it("requires all outside TCCPP when affected-user mode is unavailable", async () => { + 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", @@ -101,8 +99,10 @@ describe("voice update", () => { channel: createVoiceChannel([]), }); const component = Object.assign(Object.create(VoiceUpdate.prototype), { - wheatley: { - is_tccpp_like: () => false, + utilities: { + resolve_role: vi.fn(() => { + throw new Error("unresolved"); + }), }, }); @@ -115,7 +115,7 @@ describe("voice update", () => { ); expect(reply).toHaveBeenCalledOnce(); - expect(reply.mock.calls[0][0].embeds[0].data.description).toContain("Specify `all: true` to refresh everyone."); + expect(reply.mock.calls[0][0].embeds[0].data.description).toContain("Could not resolve the `voice` role"); }); }); @@ -147,7 +147,6 @@ describe("perform_voice_update", () => { const result = await perform_voice_update( { - guild: {} as any, caller: { ...caller, voice: { channel } } as any, channel: channel as any, wheatley: {