diff --git a/src/commands/buzzer.rs b/src/commands/buzzer.rs index 4b365af..f390659 100644 --- a/src/commands/buzzer.rs +++ b/src/commands/buzzer.rs @@ -4,6 +4,7 @@ use serenity::all::{ }; use super::controls::{self, ControlAction}; +use super::voice; use crate::app::buzzer::{ BUZZER_CUSTOM_ID, BuzzerSessionKey, LOCK_CUSTOM_ID, RESET_CUSTOM_ID, UNLOCK_CUSTOM_ID, buzzer_sound, session_components, @@ -87,18 +88,35 @@ async fn press(ctx: &Context, interaction: &ComponentInteraction) { } let guild_id = active_session.guild_id; - drop(session); + match songbird::get(ctx).await { + Some(manager) => match manager.get(guild_id) { + Some(call) => { + call.lock().await.play_only_input(buzzer_sound()); + } + None => { + tracing::error!(%guild_id, "bot is not connected to voice for active buzzer"); + } + }, + None => tracing::error!("voice manager unavailable while playing buzzer"), + } - let Some(manager) = songbird::get(ctx).await else { - tracing::error!("voice manager unavailable while playing buzzer"); - return; - }; - let Some(call) = manager.get(guild_id) else { - tracing::error!(%guild_id, "bot is not connected to voice for active buzzer"); - return; - }; + let exempt_user_ids = [active_session.host_id, interaction.user.id]; + let voice_result = voice::mute_channel_except(ctx, active_session, &exempt_user_ids).await; + if voice_result.failures > 0 { + tracing::warn!( + failures = voice_result.failures, + %guild_id, + "buzzer locked with participant mute failures" + ); - call.lock().await.play_only_input(buzzer_sound()); + let warning = EditInteractionResponse::new().content(format!( + "<@{}> buzzed first! I couldn't mute {} participant(s); check my Mute Members permission and role position.", + interaction.user.id, voice_result.failures, + )); + if let Err(error) = interaction.edit_response(&ctx.http, warning).await { + tracing::error!(?error, "failed to report participant mute failures"); + } + } } async fn run_control(ctx: &Context, interaction: &ComponentInteraction, action: ControlAction) { diff --git a/src/commands/controls.rs b/src/commands/controls.rs index 6c1830c..3d32e8c 100644 --- a/src/commands/controls.rs +++ b/src/commands/controls.rs @@ -1,5 +1,6 @@ use serenity::all::{Context, EditMessage, GuildId, MessageId, UserId}; +use super::voice; use crate::app::buzzer::{BuzzerSessionKey, session_components}; #[derive(Clone, Copy, Debug)] @@ -48,10 +49,6 @@ pub async fn apply( "The buzzer has been reset.", ), ControlAction::Lock => { - if !active_session.is_open { - return "The buzzer is already locked.".to_string(); - } - let content = match active_session.buzzed_user { Some(user_id) => { format!("<@{user_id}> buzzed first! The buzzer is manually locked.") @@ -61,10 +58,6 @@ pub async fn apply( (content, false, false, "The buzzer has been locked.") } ControlAction::Unlock => { - if active_session.is_open { - return "The buzzer is already unlocked.".to_string(); - } - let content = match active_session.buzzed_user { Some(user_id) => { format!("<@{user_id}> buzzed first! The buzzer is manually unlocked.") @@ -93,7 +86,28 @@ pub async fn apply( active_session.buzzed_user = None; } - success_message.to_string() + let voice_result = match action { + ControlAction::Lock => { + let mut exempt_user_ids = vec![active_session.host_id]; + if let Some(buzzed_user_id) = active_session.buzzed_user { + exempt_user_ids.push(buzzed_user_id); + } + voice::mute_channel_except(ctx, active_session, &exempt_user_ids).await + } + ControlAction::Reset | ControlAction::Unlock => { + voice::unmute_session_members(ctx, active_session).await + } + }; + + if voice_result.failures == 0 { + success_message.to_string() + } else { + format!( + "{success_message} I couldn't {} {} participant(s); check my Mute Members permission and role position.", + action.failed_voice_verb(), + voice_result.failures, + ) + } } impl ControlAction { @@ -104,4 +118,11 @@ impl ControlAction { Self::Unlock => "unlock", } } + + fn failed_voice_verb(self) -> &'static str { + match self { + Self::Lock => "mute", + Self::Reset | Self::Unlock => "unmute", + } + } } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 52206ff..2951c78 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -5,3 +5,4 @@ pub mod reset; pub mod start; pub mod stop; pub mod unlock; +mod voice; diff --git a/src/commands/stop.rs b/src/commands/stop.rs index 97609d7..5199080 100644 --- a/src/commands/stop.rs +++ b/src/commands/stop.rs @@ -1,5 +1,6 @@ use serenity::all::{CommandInteraction, Context, CreateCommand, EditMessage}; +use super::voice; use crate::app::buzzer::{BuzzerSessionKey, stopped_session_components}; pub async fn run(ctx: &Context, command: &CommandInteraction) -> String { @@ -17,7 +18,7 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) -> String { }; let mut session = session.lock().await; - let Some(active_session) = session.as_ref() else { + let Some(active_session) = session.as_mut() else { return "There is no active buzzer session.".to_string(); }; @@ -31,6 +32,14 @@ pub async fn run(ctx: &Context, command: &CommandInteraction) -> String { let text_channel_id = active_session.text_channel_id; let button_message_id = active_session.button_message_id; + let voice_result = voice::unmute_session_members(ctx, active_session).await; + if voice_result.failures > 0 { + return format!( + "I couldn't unmute {} participant(s), so the session was not stopped. Check my Mute Members permission and role position, then try again.", + voice_result.failures, + ); + } + let Some(manager) = songbird::get(ctx).await else { return "The voice manager is unavailable.".to_string(); }; diff --git a/src/commands/voice.rs b/src/commands/voice.rs new file mode 100644 index 0000000..ac100a3 --- /dev/null +++ b/src/commands/voice.rs @@ -0,0 +1,119 @@ +use serenity::all::{Context, EditMember, UserId}; + +use crate::app::buzzer::BuzzerSession; + +#[derive(Debug, Default)] +pub struct VoiceUpdateResult { + pub failures: usize, +} + +/// Server-mutes every human participant in the session voice channel except +/// the supplied users. Pre-existing server mutes are left untouched so the bot +/// does not later undo a moderator's mute. +pub async fn mute_channel_except( + ctx: &Context, + session: &mut BuzzerSession, + exempt_user_ids: &[UserId], +) -> VoiceUpdateResult { + let bot_user_id = ctx.cache.current_user().id; + let participants = ctx.cache.guild(session.guild_id).map(|guild| { + guild + .voice_states + .values() + .filter(|state| state.channel_id == Some(session.voice_channel_id)) + .map(|state| { + let is_bot = state.user_id == bot_user_id + || state.member.as_ref().is_some_and(|member| member.user.bot); + (state.user_id, state.mute, is_bot) + }) + .collect::>() + }); + + let Some(participants) = participants else { + tracing::error!( + guild_id = %session.guild_id, + "guild unavailable while updating buzzer voice mutes" + ); + return VoiceUpdateResult { failures: 1 }; + }; + + let mut result = VoiceUpdateResult::default(); + for (user_id, already_muted, is_bot) in participants { + if is_bot { + continue; + } + + if exempt_user_ids.contains(&user_id) { + if session.muted_user_ids.contains(&user_id) + && set_server_mute(ctx, session, user_id, false).await + { + session.muted_user_ids.remove(&user_id); + } else if session.muted_user_ids.contains(&user_id) { + result.failures += 1; + } + continue; + } + + // A mute not recorded in the session belongs to a moderator or some + // other system and must not be claimed or later reversed by this bot. + if already_muted || session.muted_user_ids.contains(&user_id) { + continue; + } + + if set_server_mute(ctx, session, user_id, true).await { + session.muted_user_ids.insert(user_id); + } else { + result.failures += 1; + } + } + + result +} + +/// Unmutes only members that this buzzer session successfully muted. +pub async fn unmute_session_members( + ctx: &Context, + session: &mut BuzzerSession, +) -> VoiceUpdateResult { + let muted_user_ids = session.muted_user_ids.iter().copied().collect::>(); + let mut result = VoiceUpdateResult::default(); + + for user_id in muted_user_ids { + if set_server_mute(ctx, session, user_id, false).await { + session.muted_user_ids.remove(&user_id); + } else { + result.failures += 1; + } + } + + result +} + +async fn set_server_mute( + ctx: &Context, + session: &BuzzerSession, + user_id: UserId, + muted: bool, +) -> bool { + let builder = EditMember::new() + .mute(muted) + .audit_log_reason("Hildebrand buzzer session"); + + match session + .guild_id + .edit_member(&ctx.http, user_id, builder) + .await + { + Ok(_) => true, + Err(error) => { + tracing::error!( + ?error, + guild_id = %session.guild_id, + %user_id, + muted, + "failed to update participant server mute" + ); + false + } + } +} diff --git a/src/lib/buzzer.rs b/src/lib/buzzer.rs index 849c346..b03f3f6 100644 --- a/src/lib/buzzer.rs +++ b/src/lib/buzzer.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::f32::consts::TAU; use std::io::Cursor; use std::sync::Arc; @@ -24,6 +25,9 @@ pub struct BuzzerSession { pub buzzed_user: Option, pub is_open: bool, + /// Members server-muted by this session. Only these members are unmuted + /// when the buzzer is reset, unlocked, or stopped. + pub muted_user_ids: HashSet, } impl BuzzerSession { @@ -42,6 +46,7 @@ impl BuzzerSession { button_message_id, buzzed_user: None, is_open: true, + muted_user_ids: HashSet::new(), } } }