add: initial commit

This commit is contained in:
2026-07-31 13:02:37 -04:00
commit e1a69c05a8
19 changed files with 5920 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
use flake
+1
View File
@@ -0,0 +1 @@
/target
Generated
+5021
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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
View File
@@ -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
}
+94
View File
@@ -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";
};
};
});
};
}
+153
View File
@@ -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");
}
}
+107
View File
@@ -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",
}
}
}
+15
View File
@@ -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")
}
+7
View File
@@ -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;
+15
View File
@@ -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")
}
+75
View File
@@ -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")
}
+70
View File
@@ -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")
}
+15
View File
@@ -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")
}
+6
View File
@@ -0,0 +1,6 @@
pub struct Configuration;
pub fn config() -> Configuration {
let _ = dotenvy::dotenv();
Configuration
}
+124
View File
@@ -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;
}
+1
View File
@@ -0,0 +1 @@
pub mod buzzer;
+130
View File
@@ -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:?}");
}
}
+1
View File
@@ -0,0 +1 @@