add: initial commit
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
use serenity::all::{
|
||||
ComponentInteraction, Context, CreateInteractionResponse, CreateInteractionResponseMessage,
|
||||
EditInteractionResponse,
|
||||
};
|
||||
|
||||
use super::controls::{self, ControlAction};
|
||||
use crate::app::buzzer::{
|
||||
BUZZER_CUSTOM_ID, BuzzerSessionKey, LOCK_CUSTOM_ID, RESET_CUSTOM_ID, UNLOCK_CUSTOM_ID,
|
||||
buzzer_sound, session_components,
|
||||
};
|
||||
|
||||
pub async fn run(ctx: &Context, interaction: &ComponentInteraction) {
|
||||
match interaction.data.custom_id.as_str() {
|
||||
BUZZER_CUSTOM_ID => press(ctx, interaction).await,
|
||||
RESET_CUSTOM_ID => run_control(ctx, interaction, ControlAction::Reset).await,
|
||||
LOCK_CUSTOM_ID => run_control(ctx, interaction, ControlAction::Lock).await,
|
||||
UNLOCK_CUSTOM_ID => run_control(ctx, interaction, ControlAction::Unlock).await,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn press(ctx: &Context, interaction: &ComponentInteraction) {
|
||||
let session = {
|
||||
let data = ctx.data.read().await;
|
||||
data.get::<BuzzerSessionKey>().cloned()
|
||||
};
|
||||
|
||||
let Some(session) = session else {
|
||||
respond_ephemeral(ctx, interaction, "The buzzer session is unavailable.").await;
|
||||
return;
|
||||
};
|
||||
|
||||
let mut session = session.lock().await;
|
||||
let Some(active_session) = session.as_mut() else {
|
||||
respond_ephemeral(ctx, interaction, "There is no active buzzer session.").await;
|
||||
return;
|
||||
};
|
||||
|
||||
let belongs_to_session = interaction.guild_id == Some(active_session.guild_id)
|
||||
&& interaction.message.id == active_session.button_message_id;
|
||||
if !belongs_to_session {
|
||||
respond_ephemeral(ctx, interaction, "That buzzer is no longer active.").await;
|
||||
return;
|
||||
}
|
||||
|
||||
let user_voice_channel = ctx.cache.guild(active_session.guild_id).and_then(|guild| {
|
||||
guild
|
||||
.voice_states
|
||||
.get(&interaction.user.id)
|
||||
.and_then(|state| state.channel_id)
|
||||
});
|
||||
if user_voice_channel != Some(active_session.voice_channel_id) {
|
||||
respond_ephemeral(
|
||||
ctx,
|
||||
interaction,
|
||||
"Join the buzzer session's voice channel before buzzing.",
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
|
||||
if !active_session.is_open {
|
||||
let content = match active_session.buzzed_user {
|
||||
Some(user_id) => format!("The buzzer is locked; <@{user_id}> buzzed first."),
|
||||
None => "The buzzer is locked.".to_string(),
|
||||
};
|
||||
respond_ephemeral(ctx, interaction, &content).await;
|
||||
return;
|
||||
}
|
||||
|
||||
active_session.is_open = false;
|
||||
active_session.buzzed_user = Some(interaction.user.id);
|
||||
|
||||
let message = CreateInteractionResponseMessage::new()
|
||||
.content(format!("<@{}> buzzed first!", interaction.user.id))
|
||||
.components(session_components(false));
|
||||
|
||||
if let Err(error) = interaction
|
||||
.create_response(&ctx.http, CreateInteractionResponse::UpdateMessage(message))
|
||||
.await
|
||||
{
|
||||
// Keep the state open if Discord could not disable the actual button.
|
||||
active_session.is_open = true;
|
||||
active_session.buzzed_user = None;
|
||||
tracing::error!(?error, "failed to lock buzzer message");
|
||||
return;
|
||||
}
|
||||
|
||||
let guild_id = active_session.guild_id;
|
||||
drop(session);
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
call.lock().await.play_only_input(buzzer_sound());
|
||||
}
|
||||
|
||||
async fn run_control(ctx: &Context, interaction: &ComponentInteraction, action: ControlAction) {
|
||||
let deferred =
|
||||
CreateInteractionResponse::Defer(CreateInteractionResponseMessage::new().ephemeral(true));
|
||||
if let Err(error) = interaction.create_response(&ctx.http, deferred).await {
|
||||
tracing::error!(
|
||||
?error,
|
||||
?action,
|
||||
"failed to defer buzzer control interaction"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let content = match interaction.guild_id {
|
||||
Some(guild_id) => {
|
||||
controls::apply(
|
||||
ctx,
|
||||
guild_id,
|
||||
interaction.user.id,
|
||||
Some(interaction.message.id),
|
||||
action,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => "Buzzer controls can only be used in a server.".to_string(),
|
||||
};
|
||||
|
||||
if let Err(error) = interaction
|
||||
.edit_response(&ctx.http, EditInteractionResponse::new().content(content))
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
?error,
|
||||
?action,
|
||||
"failed to respond to buzzer control interaction"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async fn respond_ephemeral(ctx: &Context, interaction: &ComponentInteraction, content: &str) {
|
||||
let message = CreateInteractionResponseMessage::new()
|
||||
.content(content)
|
||||
.ephemeral(true);
|
||||
|
||||
if let Err(error) = interaction
|
||||
.create_response(&ctx.http, CreateInteractionResponse::Message(message))
|
||||
.await
|
||||
{
|
||||
tracing::error!(?error, "failed to respond to buzzer interaction");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
use serenity::all::{Context, EditMessage, GuildId, MessageId, UserId};
|
||||
|
||||
use crate::app::buzzer::{BuzzerSessionKey, session_components};
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub enum ControlAction {
|
||||
Reset,
|
||||
Lock,
|
||||
Unlock,
|
||||
}
|
||||
|
||||
pub async fn apply(
|
||||
ctx: &Context,
|
||||
guild_id: GuildId,
|
||||
user_id: UserId,
|
||||
source_message_id: Option<MessageId>,
|
||||
action: ControlAction,
|
||||
) -> String {
|
||||
let session = {
|
||||
let data = ctx.data.read().await;
|
||||
data.get::<BuzzerSessionKey>().cloned()
|
||||
};
|
||||
|
||||
let Some(session) = session else {
|
||||
return "The buzzer session store is unavailable.".to_string();
|
||||
};
|
||||
|
||||
let mut session = session.lock().await;
|
||||
let Some(active_session) = session.as_mut() else {
|
||||
return "There is no active buzzer session.".to_string();
|
||||
};
|
||||
|
||||
if active_session.guild_id != guild_id {
|
||||
return "The active buzzer session belongs to another server.".to_string();
|
||||
}
|
||||
if source_message_id.is_some_and(|message_id| message_id != active_session.button_message_id) {
|
||||
return "That buzzer is no longer active.".to_string();
|
||||
}
|
||||
if active_session.host_id != user_id {
|
||||
return format!("Only the session host can {} the buzzer.", action.verb());
|
||||
}
|
||||
|
||||
let (content, is_open, clear_buzzed_user, success_message) = match action {
|
||||
ControlAction::Reset => (
|
||||
"The buzzer is ready!".to_string(),
|
||||
true,
|
||||
true,
|
||||
"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.")
|
||||
}
|
||||
None => "The buzzer is manually locked.".to_string(),
|
||||
};
|
||||
(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.")
|
||||
}
|
||||
None => "The buzzer is ready!".to_string(),
|
||||
};
|
||||
(content, true, false, "The buzzer has been unlocked.")
|
||||
}
|
||||
};
|
||||
|
||||
let message = EditMessage::new()
|
||||
.content(content)
|
||||
.components(session_components(is_open));
|
||||
|
||||
if let Err(error) = active_session
|
||||
.text_channel_id
|
||||
.edit_message(&ctx.http, active_session.button_message_id, message)
|
||||
.await
|
||||
{
|
||||
tracing::error!(?error, %guild_id, ?action, "failed to update buzzer controls");
|
||||
return format!("I couldn't {} the buzzer message.", action.verb());
|
||||
}
|
||||
|
||||
active_session.is_open = is_open;
|
||||
if clear_buzzed_user {
|
||||
active_session.buzzed_user = None;
|
||||
}
|
||||
|
||||
success_message.to_string()
|
||||
}
|
||||
|
||||
impl ControlAction {
|
||||
fn verb(self) -> &'static str {
|
||||
match self {
|
||||
Self::Reset => "reset",
|
||||
Self::Lock => "lock",
|
||||
Self::Unlock => "unlock",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use serenity::all::{CommandInteraction, Context, CreateCommand};
|
||||
|
||||
use super::controls::{self, ControlAction};
|
||||
|
||||
pub async fn run(ctx: &Context, command: &CommandInteraction) -> String {
|
||||
let Some(guild_id) = command.guild_id else {
|
||||
return "This command can only be used in a server.".to_string();
|
||||
};
|
||||
|
||||
controls::apply(ctx, guild_id, command.user.id, None, ControlAction::Lock).await
|
||||
}
|
||||
|
||||
pub fn register() -> CreateCommand {
|
||||
CreateCommand::new("lock").description("Manually locks the active buzzer")
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod buzzer;
|
||||
pub mod controls;
|
||||
pub mod lock;
|
||||
pub mod reset;
|
||||
pub mod start;
|
||||
pub mod stop;
|
||||
pub mod unlock;
|
||||
@@ -0,0 +1,15 @@
|
||||
use serenity::all::{CommandInteraction, Context, CreateCommand};
|
||||
|
||||
use super::controls::{self, ControlAction};
|
||||
|
||||
pub async fn run(ctx: &Context, command: &CommandInteraction) -> String {
|
||||
let Some(guild_id) = command.guild_id else {
|
||||
return "This command can only be used in a server.".to_string();
|
||||
};
|
||||
|
||||
controls::apply(ctx, guild_id, command.user.id, None, ControlAction::Reset).await
|
||||
}
|
||||
|
||||
pub fn register() -> CreateCommand {
|
||||
CreateCommand::new("reset").description("Clears the current buzz and unlocks the buzzer")
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use serenity::all::{CommandInteraction, Context, CreateCommand, CreateMessage};
|
||||
|
||||
use crate::app::buzzer::{BuzzerSession, BuzzerSessionKey, session_components};
|
||||
|
||||
pub async fn run(ctx: &Context, command: &CommandInteraction) -> String {
|
||||
let Some(guild_id) = command.guild_id else {
|
||||
return "This command can only be used in a server.".to_string();
|
||||
};
|
||||
|
||||
let voice_channel_id = ctx.cache.guild(guild_id).and_then(|guild| {
|
||||
guild
|
||||
.voice_states
|
||||
.get(&command.user.id)
|
||||
.and_then(|state| state.channel_id)
|
||||
});
|
||||
|
||||
let Some(voice_channel_id) = voice_channel_id else {
|
||||
return "You must be in a voice channel before starting a buzzer session.".to_string();
|
||||
};
|
||||
|
||||
let session = {
|
||||
let data = ctx.data.read().await;
|
||||
data.get::<BuzzerSessionKey>().cloned()
|
||||
};
|
||||
|
||||
let Some(session) = session else {
|
||||
return "The buzzer session store is unavailable.".to_string();
|
||||
};
|
||||
|
||||
// Hold the lock through the join so two simultaneous starts cannot both
|
||||
// observe an empty slot and create separate sessions.
|
||||
let mut active_session = session.lock().await;
|
||||
if active_session.is_some() {
|
||||
return "A buzzer session is already active.".to_string();
|
||||
}
|
||||
|
||||
let Some(manager) = songbird::get(ctx).await else {
|
||||
return "The voice manager is unavailable.".to_string();
|
||||
};
|
||||
|
||||
if let Err(error) = manager.join(guild_id, voice_channel_id).await {
|
||||
tracing::error!(?error, %guild_id, %voice_channel_id, "failed to join voice channel");
|
||||
return "I couldn't join your voice channel. Please check my voice permissions and try again."
|
||||
.to_string();
|
||||
}
|
||||
|
||||
let message = CreateMessage::new()
|
||||
.content("The buzzer is ready!")
|
||||
.components(session_components(true));
|
||||
|
||||
let buzzer_message = match command.channel_id.send_message(&ctx.http, message).await {
|
||||
Ok(message) => message,
|
||||
Err(error) => {
|
||||
tracing::error!(?error, %guild_id, "failed to create buzzer message");
|
||||
if let Err(error) = manager.leave(guild_id).await {
|
||||
tracing::warn!(?error, %guild_id, "failed to leave voice after start failed");
|
||||
}
|
||||
return "I joined voice, but couldn't create the buzzer message.".to_string();
|
||||
}
|
||||
};
|
||||
|
||||
*active_session = Some(BuzzerSession::new(
|
||||
guild_id,
|
||||
command.user.id,
|
||||
voice_channel_id,
|
||||
command.channel_id,
|
||||
buzzer_message.id,
|
||||
));
|
||||
|
||||
format!("Buzzer session started in <#{voice_channel_id}>.")
|
||||
}
|
||||
|
||||
pub fn register() -> CreateCommand {
|
||||
CreateCommand::new("start").description("Starts a buzzer session in your voice channel")
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use serenity::all::{CommandInteraction, Context, CreateCommand, EditMessage};
|
||||
|
||||
use crate::app::buzzer::{BuzzerSessionKey, stopped_session_components};
|
||||
|
||||
pub async fn run(ctx: &Context, command: &CommandInteraction) -> String {
|
||||
let Some(guild_id) = command.guild_id else {
|
||||
return "This command can only be used in a server.".to_string();
|
||||
};
|
||||
|
||||
let session = {
|
||||
let data = ctx.data.read().await;
|
||||
data.get::<BuzzerSessionKey>().cloned()
|
||||
};
|
||||
|
||||
let Some(session) = session else {
|
||||
return "The buzzer session store is unavailable.".to_string();
|
||||
};
|
||||
|
||||
let mut session = session.lock().await;
|
||||
let Some(active_session) = session.as_ref() else {
|
||||
return "There is no active buzzer session.".to_string();
|
||||
};
|
||||
|
||||
if active_session.guild_id != guild_id {
|
||||
return "The active buzzer session belongs to another server.".to_string();
|
||||
}
|
||||
if active_session.host_id != command.user.id {
|
||||
return "Only the session host can stop the buzzer session.".to_string();
|
||||
}
|
||||
|
||||
let text_channel_id = active_session.text_channel_id;
|
||||
let button_message_id = active_session.button_message_id;
|
||||
|
||||
let Some(manager) = songbird::get(ctx).await else {
|
||||
return "The voice manager is unavailable.".to_string();
|
||||
};
|
||||
|
||||
// Songbird has no call to remove if Discord already disconnected the bot.
|
||||
if manager.get(guild_id).is_some()
|
||||
&& let Err(error) = manager.remove(guild_id).await
|
||||
{
|
||||
tracing::error!(?error, %guild_id, "failed to disconnect from voice channel");
|
||||
return "I couldn't disconnect from the voice channel.".to_string();
|
||||
}
|
||||
|
||||
let message = EditMessage::new()
|
||||
.content("The buzzer session has ended.")
|
||||
.components(stopped_session_components());
|
||||
let message_edit_failed = text_channel_id
|
||||
.edit_message(&ctx.http, button_message_id, message)
|
||||
.await
|
||||
.inspect_err(|error| {
|
||||
tracing::error!(?error, %guild_id, "failed to disable stopped buzzer message");
|
||||
})
|
||||
.is_err();
|
||||
|
||||
// Clearing the singleton allows a fresh session to be started.
|
||||
*session = None;
|
||||
|
||||
if message_edit_failed {
|
||||
"Disconnected and stopped the session, but couldn't update the old buzzer message."
|
||||
.to_string()
|
||||
} else {
|
||||
"Disconnected and stopped the buzzer session.".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register() -> CreateCommand {
|
||||
CreateCommand::new("stop").description("Stops the buzzer session and disconnects from voice")
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use serenity::all::{CommandInteraction, Context, CreateCommand};
|
||||
|
||||
use super::controls::{self, ControlAction};
|
||||
|
||||
pub async fn run(ctx: &Context, command: &CommandInteraction) -> String {
|
||||
let Some(guild_id) = command.guild_id else {
|
||||
return "This command can only be used in a server.".to_string();
|
||||
};
|
||||
|
||||
controls::apply(ctx, guild_id, command.user.id, None, ControlAction::Unlock).await
|
||||
}
|
||||
|
||||
pub fn register() -> CreateCommand {
|
||||
CreateCommand::new("unlock").description("Manually unlocks the active buzzer")
|
||||
}
|
||||
Reference in New Issue
Block a user