Compare commits

...
5 Commits
11 changed files with 285 additions and 56 deletions
@@ -0,0 +1,29 @@
name: Build (nix)
on:
workflow_run:
workflows:
- Update flake lock
types:
- completed
permissions:
contents: read
jobs:
build:
if: ${{ gitea.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
# The update workflow may have pushed a newer flake.lock commit.
ref: ${{ gitea.event.workflow_run.head_branch }}
- name: Install upstream Nix
uses: cachix/install-nix-action@v31
- name: Build flake
run: nix build
+40
View File
@@ -0,0 +1,40 @@
name: Update flake lock
on:
schedule:
# Gitea cron schedules use UTC. 07:00 UTC is 03:00 EDT.
- cron: "0 7 * * 0"
workflow_dispatch:
permissions:
contents: write
jobs:
update-flake-lock:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Install upstream Nix
uses: cachix/install-nix-action@v31
- name: Update flake lock
run: nix flake update
- name: Commit and push changes
shell: bash
run: |
set -euo pipefail
if git diff --quiet -- flake.lock; then
echo "flake.lock is already up to date"
exit 0
fi
git config user.name "gitea-actions[bot]"
git config user.email "gitea-actions[bot]@noreply.localhost"
git add flake.lock
git commit -m "chore: update flake lock"
git push
BIN
View File
Binary file not shown.
Generated
+8 -8
View File
@@ -16,12 +16,12 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1784280462, "lastModified": 1788584326,
"narHash": "sha256-DtoqIqM7VkR6NxAkcLpMwmi02USwWb3JdmNGLyhthc0=", "narHash": "sha256-Fd3OB8J9JhgliQwOKcqx4M672CInxi1I5VnwsaXeSQo=",
"rev": "293d6abedf0478e681a4dfcfcb35b30fc796a32f", "rev": "6713828a351efa628b025a1adf7f43cbf8597513",
"revCount": 1009740, "revCount": 1013464,
"type": "tarball", "type": "tarball",
"url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.2605.1009740%2Brev-293d6abedf0478e681a4dfcfcb35b30fc796a32f/019f73de-20f7-7ec2-98c8-65726640dbf9/source.tar.gz" "url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.2605.1013464%2Brev-6713828a351efa628b025a1adf7f43cbf8597513/01a07539-88a8-7940-bbe9-bd2b0da40c98/source.tar.gz"
}, },
"original": { "original": {
"type": "tarball", "type": "tarball",
@@ -42,11 +42,11 @@
] ]
}, },
"locked": { "locked": {
"lastModified": 1784438913, "lastModified": 1788678114,
"narHash": "sha256-NYF7ZM5ip0u+w1pBFDpIGEbrbgN/wpnLFAmBkWkYMXw=", "narHash": "sha256-pcqbpV4ZI79al6KAlr+WJjaEo/PYfgctRlG43D5BSJM=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "afacd6819d3765a05814ee8e3de74c77d42ac799", "rev": "4748ec2f5ed4a881474ed4c98aa71a5308cdac8d",
"type": "github" "type": "github"
}, },
"original": { "original": {
+29 -11
View File
@@ -4,6 +4,7 @@ use serenity::all::{
}; };
use super::controls::{self, ControlAction}; use super::controls::{self, ControlAction};
use super::voice;
use crate::app::buzzer::{ use crate::app::buzzer::{
BUZZER_CUSTOM_ID, BuzzerSessionKey, LOCK_CUSTOM_ID, RESET_CUSTOM_ID, UNLOCK_CUSTOM_ID, BUZZER_CUSTOM_ID, BuzzerSessionKey, LOCK_CUSTOM_ID, RESET_CUSTOM_ID, UNLOCK_CUSTOM_ID,
buzzer_sound, session_components, buzzer_sound, session_components,
@@ -87,18 +88,35 @@ async fn press(ctx: &Context, interaction: &ComponentInteraction) {
} }
let guild_id = active_session.guild_id; let guild_id = active_session.guild_id;
drop(session); match songbird::get(ctx).await {
Some(manager) => match manager.get(guild_id) {
let Some(manager) = songbird::get(ctx).await else { Some(call) => {
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;
};
call.lock().await.play_only_input(buzzer_sound()); 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 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"
);
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) { async fn run_control(ctx: &Context, interaction: &ComponentInteraction, action: ControlAction) {
+29 -8
View File
@@ -1,5 +1,6 @@
use serenity::all::{Context, EditMessage, GuildId, MessageId, UserId}; use serenity::all::{Context, EditMessage, GuildId, MessageId, UserId};
use super::voice;
use crate::app::buzzer::{BuzzerSessionKey, session_components}; use crate::app::buzzer::{BuzzerSessionKey, session_components};
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
@@ -48,10 +49,6 @@ pub async fn apply(
"The buzzer has been reset.", "The buzzer has been reset.",
), ),
ControlAction::Lock => { ControlAction::Lock => {
if !active_session.is_open {
return "The buzzer is already locked.".to_string();
}
let content = match active_session.buzzed_user { let content = match active_session.buzzed_user {
Some(user_id) => { Some(user_id) => {
format!("<@{user_id}> buzzed first! The buzzer is manually locked.") 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.") (content, false, false, "The buzzer has been locked.")
} }
ControlAction::Unlock => { ControlAction::Unlock => {
if active_session.is_open {
return "The buzzer is already unlocked.".to_string();
}
let content = match active_session.buzzed_user { let content = match active_session.buzzed_user {
Some(user_id) => { Some(user_id) => {
format!("<@{user_id}> buzzed first! The buzzer is manually unlocked.") format!("<@{user_id}> buzzed first! The buzzer is manually unlocked.")
@@ -93,7 +86,28 @@ pub async fn apply(
active_session.buzzed_user = None; active_session.buzzed_user = None;
} }
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() 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 { impl ControlAction {
@@ -104,4 +118,11 @@ impl ControlAction {
Self::Unlock => "unlock", Self::Unlock => "unlock",
} }
} }
fn failed_voice_verb(self) -> &'static str {
match self {
Self::Lock => "mute",
Self::Reset | Self::Unlock => "unmute",
}
}
} }
+1
View File
@@ -5,3 +5,4 @@ pub mod reset;
pub mod start; pub mod start;
pub mod stop; pub mod stop;
pub mod unlock; pub mod unlock;
mod voice;
+10 -1
View File
@@ -1,5 +1,6 @@
use serenity::all::{CommandInteraction, Context, CreateCommand, EditMessage}; use serenity::all::{CommandInteraction, Context, CreateCommand, EditMessage};
use super::voice;
use crate::app::buzzer::{BuzzerSessionKey, stopped_session_components}; use crate::app::buzzer::{BuzzerSessionKey, stopped_session_components};
pub async fn run(ctx: &Context, command: &CommandInteraction) -> String { 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 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(); 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 text_channel_id = active_session.text_channel_id;
let button_message_id = active_session.button_message_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 { let Some(manager) = songbird::get(ctx).await else {
return "The voice manager is unavailable.".to_string(); return "The voice manager is unavailable.".to_string();
}; };
+119
View File
@@ -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::<Vec<_>>()
});
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::<Vec<_>>();
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
}
}
}
+9 -28
View File
@@ -1,12 +1,11 @@
use std::f32::consts::TAU; use std::collections::HashSet;
use std::io::Cursor;
use std::sync::Arc; use std::sync::Arc;
use serenity::all::{ use serenity::all::{
ButtonStyle, ChannelId, CreateActionRow, CreateButton, GuildId, MessageId, UserId, ButtonStyle, ChannelId, CreateActionRow, CreateButton, GuildId, MessageId, UserId,
}; };
use serenity::prelude::TypeMapKey; use serenity::prelude::TypeMapKey;
use songbird::input::{Input, RawAdapter}; use songbird::input::Input;
use tokio::sync::Mutex; use tokio::sync::Mutex;
pub const BUZZER_CUSTOM_ID: &str = "buzzer:press"; pub const BUZZER_CUSTOM_ID: &str = "buzzer:press";
@@ -24,6 +23,9 @@ pub struct BuzzerSession {
pub buzzed_user: Option<UserId>, pub buzzed_user: Option<UserId>,
pub is_open: bool, 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<UserId>,
} }
impl BuzzerSession { impl BuzzerSession {
@@ -42,6 +44,7 @@ impl BuzzerSession {
button_message_id, button_message_id,
buzzed_user: None, buzzed_user: None,
is_open: true, is_open: true,
muted_user_ids: HashSet::new(),
} }
} }
} }
@@ -82,33 +85,11 @@ fn control_components(is_open: bool, session_ended: bool) -> Vec<CreateActionRow
vec![CreateActionRow::Buttons(vec![buzzer, reset, lock, unlock])] vec![CreateActionRow::Buttons(vec![buzzer, reset, lock, unlock])]
} }
/// Produces a short, self-contained buzzer tone as stereo f32 PCM. /// Returns the bundled buzzer sound.
pub fn buzzer_sound() -> Input { pub fn buzzer_sound() -> Input {
const SAMPLE_RATE: u32 = 48_000; const BUZZ_MP3: &[u8] = include_bytes!("../../assets/buzz.mp3");
const DURATION_SECONDS: f32 = 0.4;
const FREQUENCY_HZ: f32 = 180.0;
let sample_count = (SAMPLE_RATE as f32 * DURATION_SECONDS) as usize; BUZZ_MP3.into()
let mut pcm = Vec::with_capacity(sample_count * 2 * size_of::<f32>());
for sample_index in 0..sample_count {
let time = sample_index as f32 / SAMPLE_RATE as f32;
let progress = sample_index as f32 / sample_count as f32;
let attack = (time / 0.01).min(1.0);
let release = ((1.0 - progress) / 0.08).min(1.0);
let envelope = attack * release;
// Odd harmonics give the tone the rasp of a physical game-show buzzer.
let phase = TAU * FREQUENCY_HZ * time;
let value =
envelope * (phase.sin() + (3.0 * phase).sin() / 3.0 + (5.0 * phase).sin() / 5.0) * 0.32;
for _ in 0..2 {
pcm.extend_from_slice(&value.to_le_bytes());
}
}
RawAdapter::new(Cursor::new(pcm), SAMPLE_RATE, 2).into()
} }
/// The application's single active buzzer session. /// The application's single active buzzer session.
+11
View File
@@ -83,6 +83,17 @@ impl EventHandler for Handler {
async fn ready(&self, ctx: Context, ready: Ready) { async fn ready(&self, ctx: Context, ready: Ready) {
println!("{} is connected!", ready.user.name); println!("{} is connected!", ready.user.name);
// Commands were previously registered per guild. Clear those legacy
// registrations so only the global command set appears.
for guild in &ready.guilds {
if let Err(why) = guild.id.set_commands(&ctx.http, Vec::new()).await {
println!(
"Cannot remove legacy slash commands from guild {}: {why}",
guild.id
);
}
}
let commands = vec![ let commands = vec![
commands::start::register(), commands::start::register(),
commands::reset::register(), commands::reset::register(),