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..99d2d9cb 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", @@ -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/components/moderation/voice-take.ts b/src/modules/wheatley/components/moderation/voice-take.ts index 77d4e432..05997511 100644 --- a/src/modules/wheatley/components/moderation/voice-take.ts +++ b/src/modules/wheatley/components/moderation/voice-take.ts @@ -14,9 +14,17 @@ 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"; +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 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; @@ -62,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)), ), ); } @@ -97,24 +116,87 @@ 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) { + 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 && !channel_has_member_with_role(channel, this.roles.voice_moderator.id)) { + 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 && !channel_has_member_with_role(channel, this.roles.voice_moderator.id)) { + 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 && !channel_has_member_with_role(channel, this.roles.voice_moderator.id)) { + await this.wheatley.force_voice_permissions_update(member); + } } async is_moderation_applied_in_discord(moderation: basic_moderation_with_user) { 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..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 }; +} diff --git a/src/wheatley.ts b/src/wheatley.ts index 48d6af69..7de0c85a 100644 --- a/src/wheatley.ts +++ b/src/wheatley.ts @@ -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; + 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, ) {