add: initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
Generated
+5021
File diff suppressed because it is too large
Load Diff
+23
@@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "hildebrand"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
serenity = { version = "0.12.5", features = [
|
||||||
|
"client",
|
||||||
|
"gateway",
|
||||||
|
"rustls_backend",
|
||||||
|
"model",
|
||||||
|
"collector",
|
||||||
|
] }
|
||||||
|
songbird = "0.6"
|
||||||
|
symphonia = { version = "0.5", features = ["mp3"] }
|
||||||
|
tokio = { version = "1.41.0", features = ["macros", "rt-multi-thread"] }
|
||||||
|
reqwest = { version = "0.12.9", features = ["json", "rustls-tls"] }
|
||||||
|
tracing = "0.1.40"
|
||||||
|
dotenvy = "0.15.7"
|
||||||
|
rand = "0.8.5"
|
||||||
|
chrono = { version = "0.4.38" }
|
||||||
|
serde = "1.0.214"
|
||||||
|
serde_json = "1.0.132"
|
||||||
Generated
+61
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-schemas": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1780327564,
|
||||||
|
"narHash": "sha256-HiRPtA0spK+Dkgbhz/1zW9glXxNVB+L4Rj2VYmdawb8=",
|
||||||
|
"rev": "6cc9bd98891b1fc6bb2b8cb3277df8bc72799ca6",
|
||||||
|
"revCount": 149,
|
||||||
|
"type": "tarball",
|
||||||
|
"url": "https://api.flakehub.com/f/pinned/DeterminateSystems/flake-schemas/0.5.0/019e83cf-9af3-78b1-ac5b-70e68ad1efe1/source.tar.gz"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"type": "tarball",
|
||||||
|
"url": "https://flakehub.com/f/DeterminateSystems/flake-schemas/%2A"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1784280462,
|
||||||
|
"narHash": "sha256-DtoqIqM7VkR6NxAkcLpMwmi02USwWb3JdmNGLyhthc0=",
|
||||||
|
"rev": "293d6abedf0478e681a4dfcfcb35b30fc796a32f",
|
||||||
|
"revCount": 1009740,
|
||||||
|
"type": "tarball",
|
||||||
|
"url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.2605.1009740%2Brev-293d6abedf0478e681a4dfcfcb35b30fc796a32f/019f73de-20f7-7ec2-98c8-65726640dbf9/source.tar.gz"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"type": "tarball",
|
||||||
|
"url": "https://flakehub.com/f/NixOS/nixpkgs/%2A"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"flake-schemas": "flake-schemas",
|
||||||
|
"nixpkgs": "nixpkgs",
|
||||||
|
"rust-overlay": "rust-overlay"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rust-overlay": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": [
|
||||||
|
"nixpkgs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1784438913,
|
||||||
|
"narHash": "sha256-NYF7ZM5ip0u+w1pBFDpIGEbrbgN/wpnLFAmBkWkYMXw=",
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"rev": "afacd6819d3765a05814ee8e3de74c77d42ac799",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "oxalica",
|
||||||
|
"repo": "rust-overlay",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# This flake was initially generated by fh, the CLI for FlakeHub (version 0.1.18)
|
||||||
|
{
|
||||||
|
description = "A trivia buzzer";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
flake-schemas.url = "https://flakehub.com/f/DeterminateSystems/flake-schemas/*";
|
||||||
|
nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/*";
|
||||||
|
rust-overlay = {
|
||||||
|
url = "github:oxalica/rust-overlay";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = {
|
||||||
|
self,
|
||||||
|
flake-schemas,
|
||||||
|
nixpkgs,
|
||||||
|
rust-overlay,
|
||||||
|
}: let
|
||||||
|
overlays = [
|
||||||
|
rust-overlay.overlays.default
|
||||||
|
(final: _: {
|
||||||
|
rustToolchain = final.rust-bin.stable.latest.default.override {extensions = ["rust-src"];};
|
||||||
|
})
|
||||||
|
];
|
||||||
|
|
||||||
|
supportedSystems = ["x86_64-linux" "aarch64-darwin" "aarch64-linux"];
|
||||||
|
forEachSupportedSystem = f:
|
||||||
|
nixpkgs.lib.genAttrs supportedSystems (system:
|
||||||
|
f {
|
||||||
|
pkgs = import nixpkgs {inherit overlays system;};
|
||||||
|
});
|
||||||
|
in {
|
||||||
|
schemas = flake-schemas.schemas;
|
||||||
|
|
||||||
|
devShells = forEachSupportedSystem ({pkgs}: {
|
||||||
|
default = pkgs.mkShell {
|
||||||
|
packages = with pkgs; [
|
||||||
|
rustToolchain
|
||||||
|
cargo-bloat
|
||||||
|
cargo-edit
|
||||||
|
cargo-outdated
|
||||||
|
cargo-udeps
|
||||||
|
cargo-watch
|
||||||
|
rust-analyzer
|
||||||
|
cmake
|
||||||
|
curl
|
||||||
|
git
|
||||||
|
jq
|
||||||
|
wget
|
||||||
|
nixpkgs-fmt
|
||||||
|
pkg-config-unwrapped
|
||||||
|
openssl.dev
|
||||||
|
];
|
||||||
|
|
||||||
|
env = {
|
||||||
|
RUST_BACKTRACE = "1";
|
||||||
|
RUST_SRC_PATH = "${pkgs.rustToolchain}/lib/rustlib/src/rust/library";
|
||||||
|
PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
packages = forEachSupportedSystem ({ pkgs }: {
|
||||||
|
default = let
|
||||||
|
pname = "hildebrand";
|
||||||
|
version = "master";
|
||||||
|
|
||||||
|
src = pkgs.fetchFromGitea {
|
||||||
|
domain = "scm.wyattjmiller.com";
|
||||||
|
owner = "wymiller";
|
||||||
|
repo = "hildebrand";
|
||||||
|
rev = "${version}";
|
||||||
|
sha256 = "sha256-bNIzb57qPzzgwGVTjk2N59P7Rtt0MdyJY12sTAH05/s=";
|
||||||
|
};
|
||||||
|
in pkgs.rustPlatform.buildRustPackage {
|
||||||
|
inherit pname version src;
|
||||||
|
cargoLock = { lockFile = "${src}/Cargo.lock"; };
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "A Discord trivia buzzer bot";
|
||||||
|
mainProgram = "caitsith";
|
||||||
|
homepage = "https://scm.wyattjmiller.com/wymiller/caitsith";
|
||||||
|
maintainers = [ "wymillerlinux" ];
|
||||||
|
platforms = [ "x86_64-linux" ];
|
||||||
|
};
|
||||||
|
|
||||||
|
env = {
|
||||||
|
PKG_CONFIG_PATH = "${pkgs.openssl.dev}/lib/pkgconfig";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
pub struct Configuration;
|
||||||
|
|
||||||
|
pub fn config() -> Configuration {
|
||||||
|
let _ = dotenvy::dotenv();
|
||||||
|
Configuration
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
use std::f32::consts::TAU;
|
||||||
|
use std::io::Cursor;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use serenity::all::{
|
||||||
|
ButtonStyle, ChannelId, CreateActionRow, CreateButton, GuildId, MessageId, UserId,
|
||||||
|
};
|
||||||
|
use serenity::prelude::TypeMapKey;
|
||||||
|
use songbird::input::{Input, RawAdapter};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
pub const BUZZER_CUSTOM_ID: &str = "buzzer:press";
|
||||||
|
pub const RESET_CUSTOM_ID: &str = "buzzer:reset";
|
||||||
|
pub const LOCK_CUSTOM_ID: &str = "buzzer:lock";
|
||||||
|
pub const UNLOCK_CUSTOM_ID: &str = "buzzer:unlock";
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct BuzzerSession {
|
||||||
|
pub guild_id: GuildId,
|
||||||
|
pub host_id: UserId,
|
||||||
|
pub voice_channel_id: ChannelId,
|
||||||
|
pub text_channel_id: ChannelId,
|
||||||
|
pub button_message_id: MessageId,
|
||||||
|
|
||||||
|
pub buzzed_user: Option<UserId>,
|
||||||
|
pub is_open: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BuzzerSession {
|
||||||
|
pub fn new(
|
||||||
|
guild_id: GuildId,
|
||||||
|
host_id: UserId,
|
||||||
|
voice_channel_id: ChannelId,
|
||||||
|
text_channel_id: ChannelId,
|
||||||
|
button_message_id: MessageId,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
guild_id,
|
||||||
|
host_id,
|
||||||
|
voice_channel_id,
|
||||||
|
text_channel_id,
|
||||||
|
button_message_id,
|
||||||
|
buzzed_user: None,
|
||||||
|
is_open: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_session_component(custom_id: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
custom_id,
|
||||||
|
BUZZER_CUSTOM_ID | RESET_CUSTOM_ID | LOCK_CUSTOM_ID | UNLOCK_CUSTOM_ID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn session_components(is_open: bool) -> Vec<CreateActionRow> {
|
||||||
|
control_components(is_open, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stopped_session_components() -> Vec<CreateActionRow> {
|
||||||
|
control_components(false, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn control_components(is_open: bool, session_ended: bool) -> Vec<CreateActionRow> {
|
||||||
|
let buzzer = CreateButton::new(BUZZER_CUSTOM_ID)
|
||||||
|
.label("BUZZ!")
|
||||||
|
.style(ButtonStyle::Danger)
|
||||||
|
.disabled(!is_open || session_ended);
|
||||||
|
let reset = CreateButton::new(RESET_CUSTOM_ID)
|
||||||
|
.label("Reset")
|
||||||
|
.style(ButtonStyle::Secondary)
|
||||||
|
.disabled(session_ended);
|
||||||
|
let lock = CreateButton::new(LOCK_CUSTOM_ID)
|
||||||
|
.label("Lock")
|
||||||
|
.style(ButtonStyle::Secondary)
|
||||||
|
.disabled(!is_open || session_ended);
|
||||||
|
let unlock = CreateButton::new(UNLOCK_CUSTOM_ID)
|
||||||
|
.label("Unlock")
|
||||||
|
.style(ButtonStyle::Success)
|
||||||
|
.disabled(is_open || session_ended);
|
||||||
|
|
||||||
|
vec![CreateActionRow::Buttons(vec![buzzer, reset, lock, unlock])]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Produces a short, self-contained buzzer tone as stereo f32 PCM.
|
||||||
|
pub fn buzzer_sound() -> Input {
|
||||||
|
const SAMPLE_RATE: u32 = 48_000;
|
||||||
|
const DURATION_SECONDS: f32 = 0.4;
|
||||||
|
const FREQUENCY_HZ: f32 = 180.0;
|
||||||
|
|
||||||
|
let sample_count = (SAMPLE_RATE as f32 * DURATION_SECONDS) as usize;
|
||||||
|
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.
|
||||||
|
///
|
||||||
|
/// Keeping the optional session behind one shared mutex makes the check and
|
||||||
|
/// creation performed by `/start` atomic, even when commands arrive together.
|
||||||
|
pub type ActiveBuzzerSession = Arc<Mutex<Option<BuzzerSession>>>;
|
||||||
|
|
||||||
|
pub struct BuzzerSessionKey;
|
||||||
|
|
||||||
|
impl TypeMapKey for BuzzerSessionKey {
|
||||||
|
type Value = ActiveBuzzerSession;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pub mod buzzer;
|
||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
#[path = "lib/mod.rs"]
|
||||||
|
mod app;
|
||||||
|
mod commands;
|
||||||
|
mod config;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
|
use std::env;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use serenity::async_trait;
|
||||||
|
use serenity::builder::{
|
||||||
|
CreateInteractionResponse, CreateInteractionResponseMessage, EditInteractionResponse,
|
||||||
|
};
|
||||||
|
use serenity::model::application::Interaction;
|
||||||
|
use serenity::model::gateway::Ready;
|
||||||
|
use serenity::model::id::GuildId;
|
||||||
|
use serenity::prelude::*;
|
||||||
|
use songbird::SerenityInit;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::app::buzzer::{BuzzerSessionKey, is_session_component};
|
||||||
|
struct Handler;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl EventHandler for Handler {
|
||||||
|
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
|
||||||
|
match interaction {
|
||||||
|
Interaction::Command(command) => {
|
||||||
|
println!("Received command interaction: {command:#?}");
|
||||||
|
|
||||||
|
// Voice joins and message edits can exceed Discord's initial
|
||||||
|
// interaction deadline, so acknowledge implemented commands first.
|
||||||
|
let deferred = matches!(
|
||||||
|
command.data.name.as_str(),
|
||||||
|
"start" | "reset" | "lock" | "unlock" | "stop"
|
||||||
|
);
|
||||||
|
if deferred
|
||||||
|
&& let Err(why) = command
|
||||||
|
.create_response(
|
||||||
|
&ctx.http,
|
||||||
|
CreateInteractionResponse::Defer(
|
||||||
|
CreateInteractionResponseMessage::new(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
println!("Cannot defer slash command response: {why}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let content = match command.data.name.as_str() {
|
||||||
|
"start" => Some(commands::start::run(&ctx, &command).await),
|
||||||
|
"reset" => Some(commands::reset::run(&ctx, &command).await),
|
||||||
|
"lock" => Some(commands::lock::run(&ctx, &command).await),
|
||||||
|
"unlock" => Some(commands::unlock::run(&ctx, &command).await),
|
||||||
|
"stop" => Some(commands::stop::run(&ctx, &command).await),
|
||||||
|
_ => Some("not implemented :(".to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(content) = content {
|
||||||
|
if deferred {
|
||||||
|
let builder = EditInteractionResponse::new().content(content);
|
||||||
|
if let Err(why) = command.edit_response(&ctx.http, builder).await {
|
||||||
|
println!("Cannot edit slash command response: {why}");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let data = CreateInteractionResponseMessage::new().content(content);
|
||||||
|
let builder = CreateInteractionResponse::Message(data);
|
||||||
|
if let Err(why) = command.create_response(&ctx.http, builder).await {
|
||||||
|
println!("Cannot respond to slash command: {why}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Interaction::Component(component)
|
||||||
|
if is_session_component(&component.data.custom_id) =>
|
||||||
|
{
|
||||||
|
commands::buzzer::run(&ctx, &component).await;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ready(&self, ctx: Context, ready: Ready) {
|
||||||
|
println!("{} is connected!", ready.user.name);
|
||||||
|
|
||||||
|
let guild_id = GuildId::new(
|
||||||
|
env::var("GUILD_ID")
|
||||||
|
.expect("Expected GUILD_ID in environment")
|
||||||
|
.parse()
|
||||||
|
.expect("GUILD_ID must be an integer"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let commands = vec![
|
||||||
|
commands::start::register(),
|
||||||
|
commands::reset::register(),
|
||||||
|
commands::lock::register(),
|
||||||
|
commands::unlock::register(),
|
||||||
|
commands::stop::register(),
|
||||||
|
];
|
||||||
|
|
||||||
|
if let Err(why) = guild_id.set_commands(&ctx.http, commands).await {
|
||||||
|
println!("Cannot register slash commands: {why}");
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("{} is ready to rock and roll!", ready.user.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
// setting up configuration
|
||||||
|
let _ = config::config();
|
||||||
|
|
||||||
|
// setting up the discord bot
|
||||||
|
let token = env::var("DISCORD_TOKEN").expect("Expected a token in the environment");
|
||||||
|
|
||||||
|
// setting up the discord client
|
||||||
|
let intents = GatewayIntents::GUILDS | GatewayIntents::GUILD_VOICE_STATES;
|
||||||
|
let mut client = Client::builder(token, intents)
|
||||||
|
.event_handler(Handler)
|
||||||
|
.type_map_insert::<BuzzerSessionKey>(Arc::new(Mutex::new(None)))
|
||||||
|
.register_songbird()
|
||||||
|
.await
|
||||||
|
.expect("Error creating client");
|
||||||
|
|
||||||
|
if let Err(why) = client.start().await {
|
||||||
|
println!("Client error: {why:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
Reference in New Issue
Block a user