Major refactor

This commit is contained in:
2026-04-03 23:04:51 -04:00
parent e7f337c735
commit 35d07e8df1
124 changed files with 4929 additions and 2429 deletions

View File

@@ -0,0 +1,22 @@
[package]
name = "siren-bot"
edition.workspace = true
version.workspace = true
rust-version.workspace = true
authors.workspace = true
[dependencies]
siren-core = { workspace = true }
tokio = { workspace = true }
log = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
serenity = { workspace = true }
songbird = { workspace = true }
symphonia = { workspace = true }
reqwest = { workspace = true }
rand = { workspace = true }
chrono = { workspace = true }
regex = { workspace = true }
uuid = { workspace = true }
lazy_static = { workspace = true }

View File

@@ -0,0 +1,67 @@
use serenity::all::{
CommandInteraction,
Context,
CreateInteractionResponse,
CreateInteractionResponseMessage,
CreateMessage,
EditInteractionResponse,
InteractionResponseFlags,
Message,
ModalInteraction,
UserId,
};
pub async fn process_message(ctx: &Context, command: &CommandInteraction, private: bool) {
create_message_response(&ctx, &command, "Processing...".to_string(), private).await;
}
pub async fn user_dm(ctx: &Context, user_id: &UserId, content: String) -> Option<Message> {
let data = CreateMessage::new().content(content.to_owned());
match user_id.dm(ctx, data).await {
Ok(message) => Some(message),
Err(err) => {
log::error!("Failed to create direct message for {content}\n{err}");
None
}
}
}
pub async fn create_message_response(
ctx: &Context,
command: &CommandInteraction,
content: String,
private: bool,
) {
let mut data = CreateInteractionResponseMessage::new().content(content.to_owned());
if private {
data = data.flags(InteractionResponseFlags::EPHEMERAL);
}
let builder = CreateInteractionResponse::Message(data);
match command.create_response(&ctx.http, builder).await {
Ok(_) => {}
Err(err) => {
log::error!("Failed to create message response for {content}\n{err}");
}
};
}
pub async fn create_modal_response(ctx: &Context, modal: &ModalInteraction) {
let data = CreateInteractionResponseMessage::new();
let builder = CreateInteractionResponse::Message(data);
match modal.create_response(&ctx.http, builder).await {
Ok(_) => {}
Err(err) => {
log::error!("Failed to create modal response\n{err}");
}
}
}
pub async fn edit_response(ctx: &Context, command: &CommandInteraction, content: String) {
let builder = EditInteractionResponse::new().content(content.to_owned());
match command.edit_response(&ctx.http, builder).await {
Ok(_) => {}
Err(err) => {
log::error!("Failed to create response for {content}\n{err}");
}
}
}

View File

@@ -0,0 +1,89 @@
use crate::error::{Error, Result};
use reqwest::Url;
use serenity::{
all::UserId,
client::Cache,
model::prelude::{ChannelId, GuildId},
};
use songbird::Songbird;
use std::sync::Arc;
pub mod mute;
pub mod pause;
pub mod play;
pub mod resume;
pub mod skip;
pub mod stop;
pub mod volume;
/**
* Finds a voice channel that the user is currently in, and attempts to join it.
*/
pub async fn join_voice_channel(
cache: &Arc<Cache>,
manager: &Arc<Songbird>,
guild_id: &GuildId,
user_id: &UserId,
) -> Result<ChannelId> {
let channel_id = find_voice_channel(cache, guild_id, user_id)?;
log::debug!("<{}> Joining channel {}", guild_id.get(), channel_id.get());
match manager
.join(guild_id.to_owned(), channel_id.to_owned())
.await
{
Ok(_) => Ok(channel_id),
Err(e) => {
if e.should_leave_server() || e.should_reconnect_driver() {
log::debug!("<{}> Cleaning up failed voice connection", guild_id.get());
let _ = manager.remove(*guild_id).await;
}
Err(e.into())
}
}
}
/**
* Leaves a voice channel.
*/
pub async fn leave_voice_channel(manager: &Arc<Songbird>, guild_id: &GuildId) -> Result<()> {
if manager.get(guild_id.to_owned()).is_some() {
log::debug!("<{}> Disconnecting from channel", guild_id.get());
manager.remove(*guild_id).await?;
}
Ok(())
}
/**
* Validates whether the given string is a properly formatted URL.
*
* Returns `true` if the input string is a valid URL, otherwise `false`.
*/
fn is_valid_url(url: &str) -> bool {
Url::parse(url).is_ok()
}
/**
* Finds a voice channel that the user is currently in.
*/
fn find_voice_channel(
cache: &Arc<Cache>,
guild_id: &GuildId,
user_id: &UserId,
) -> Result<ChannelId> {
let guild = match guild_id.to_guild_cached(cache) {
Some(g) => g,
None => return Err(Error::new(404, "Guild not found".to_string())),
};
match guild
.voice_states
.get(&user_id)
.and_then(|voice_state| voice_state.channel_id)
{
Some(channel) => Ok(channel),
None => Err(Error::new(
400,
"User is not in a voice channel".to_string(),
)),
}
}

View File

@@ -0,0 +1,54 @@
use crate::{
chat::{edit_response, process_message},
handler::get_songbird,
};
use serenity::{
all::{CommandInteraction, CreateCommand},
prelude::*,
};
pub async fn run(ctx: &Context, command: &CommandInteraction) {
// Create the initial response
process_message(&ctx, &command, false).await;
// Get the songbird manager
let manager = get_songbird();
// Extract the guild ID
let guild_id = match &command.guild_id {
Some(guild_id) => guild_id,
None => {
edit_response(
&ctx,
&command,
"Unable to find the current server ID".to_string(),
)
.await;
return;
}
};
// Mute the track
if let Some(handler_lock) = manager.get(guild_id.to_owned()) {
let mut handler = handler_lock.lock().await;
let is_muted = handler.is_mute();
match handler.mute(!is_muted).await {
Ok(_) => {
if is_muted {
log::debug!("<{guild_id}> Unmuted");
edit_response(&ctx, &command, "Unmuted".to_string()).await;
} else {
log::debug!("<{guild_id}> Muted");
edit_response(&ctx, &command, "Muted".to_string()).await;
}
}
Err(err) => {
edit_response(&ctx, &command, format!("Failed to mute: {}", err)).await;
}
}
}
}
pub fn register() -> CreateCommand {
CreateCommand::new("mute").description("Mute/unmute Siren")
}

View File

@@ -0,0 +1,63 @@
use crate::{
chat::{edit_response, process_message},
error::{Error, Result},
handler::get_songbird,
};
use serenity::{
all::{CommandInteraction, CreateCommand, GuildId},
prelude::*,
};
use songbird::Songbird;
use std::sync::Arc;
pub async fn run(ctx: &Context, command: &CommandInteraction) {
// Create the initial response
process_message(&ctx, &command, false).await;
// Get the songbird manager
let manager = get_songbird();
// Extract the guild ID
let guild_id = match &command.guild_id {
Some(guild_id) => guild_id,
None => {
edit_response(
&ctx,
&command,
"Unable to find the current server ID".to_string(),
)
.await;
return;
}
};
// Pause the track
match pause_track(manager, guild_id).await {
Ok(_) => {
log::debug!("<{guild_id}> Paused the track");
edit_response(&ctx, &command, "Pausing the track".to_string()).await;
}
Err(err) => edit_response(&ctx, &command, format!("Failed to pause: {}", err)).await,
}
}
pub async fn pause_track(manager: &Arc<Songbird>, guild_id: &GuildId) -> Result<()> {
if let Some(handler_lock) = manager.get(guild_id.to_owned()) {
let handler = handler_lock.lock().await;
match handler.queue().current() {
Some(track) => track.pause()?,
None => {
return Err(Error {
status: 404,
details: "No track is currently playing".to_string(),
});
}
}
};
Ok(())
}
pub fn register() -> CreateCommand {
CreateCommand::new("pause").description("Pause the current track")
}

View File

@@ -0,0 +1,218 @@
use super::{is_valid_url, join_voice_channel, leave_voice_channel};
use crate::{
chat::{create_message_response, edit_response, process_message},
error::{Error, Result},
handler::{get_client, get_songbird},
ytdlp::{YtDlp, YtDlpItem},
};
use serenity::{
all::{CommandInteraction, CommandOptionType, CreateCommand, CreateCommandOption},
async_trait,
model::prelude::GuildId,
prelude::*,
};
use siren_core::data::guilds::GuildCache;
use songbird::{
Event,
EventHandler,
Songbird,
TrackEvent,
input::{Input, YoutubeDl},
tracks::TrackHandle,
};
use std::sync::Arc;
pub async fn run(ctx: &Context, command: &CommandInteraction) {
// Process the command options
let track_url = match command.data.options.first() {
Some(o) => o.value.as_str().unwrap(),
None => {
log::warn!(
"<{}> {} attempted to play a track without a track option",
command.guild_id.unwrap(),
command.user.id.get()
);
create_message_response(&ctx, &command, "Track option is missing".to_string(), false).await;
return;
}
};
// Create the initial response
process_message(&ctx, &command, false).await;
// Get the songbird manager
let manager = get_songbird();
// Extract the guild ID
let guild_id = match &command.guild_id {
Some(guild_id) => guild_id,
None => {
edit_response(
&ctx,
&command,
"Unable to find the current server ID".to_string(),
)
.await;
return;
}
};
// Join the user's voice channel
match join_voice_channel(&ctx.cache, &manager, guild_id, &command.user.id).await {
Ok(channel_id) => {
log::debug!(
"<{guild_id}> Play command executed on channel {channel_id} with track: {track_url:?}"
);
// Handle the track url
match enqueue_track(manager, guild_id.to_owned(), track_url).await {
Ok(items) => {
let mut message = format!("Added {} tracks", items.len());
if items.len() == 0 {
message = "No tracks were played".to_string();
log::warn!("<{guild_id}> No tracks were played");
if let Err(err) = leave_voice_channel(&manager, guild_id).await {
log::error!("Failed to leave voice channel: {}", err);
};
} else if items.len() == 1 {
message = format!("Added **{}**", items[0].get_title());
}
edit_response(&ctx, &command, message).await;
}
Err(err) => {
log::error!("Failed to play track: {}", err);
if let Err(err) = leave_voice_channel(&manager, guild_id).await {
log::error!("Failed to leave voice channel: {}", err);
}
edit_response(&ctx, &command, format!("Failed to play track: {}", err)).await;
}
};
}
Err(err) => {
log::warn!("<{guild_id}> Failed to join voice channel: {}", err);
edit_response(&ctx, &command, format!("{}", err)).await;
}
}
}
pub async fn enqueue_track(
manager: &Arc<Songbird>,
guild_id: GuildId,
track_url: &str,
) -> Result<Vec<YtDlpItem>> {
let mut playlist_items: Vec<YtDlpItem> = Vec::new();
if let Some(handler_lock) = manager.get(guild_id) {
let mut handler = handler_lock.lock().await;
let guild = GuildCache::find_by_id(guild_id.get() as i64).await.unwrap();
let valid = is_valid_url(&track_url);
// Check if the URL is valid
if !valid {
log::warn!("<{guild_id}> Invalid track url: {}", track_url);
return Err(Error::new(422, format!("Invalid track url: {}", track_url)));
}
playlist_items = get_ytdlp_items(&track_url)?;
// Add each track to the queue
for item in &playlist_items {
let volume = guild.volume as f32 / 100.0;
let http_client = get_client();
let source = YoutubeDl::new(http_client.to_owned(), item.get_url().to_owned());
let input: Input = source.into();
let track_title = item.get_title().to_owned();
let track_handle: TrackHandle;
track_handle = handler.enqueue_input(input).await;
// Set the volume
let _ = track_handle.set_volume(volume);
log::debug!("<{guild_id}> Added track: {}", track_title);
handler.remove_all_global_events();
handler.add_global_event(
Event::Track(TrackEvent::End),
TrackEndNotifier {
guild_id,
call: manager.clone(),
},
);
}
if handler.queue().is_empty() {
let _ = handler.queue().resume();
}
}
Ok(playlist_items)
}
pub fn get_ytdlp_items(url: &str) -> Result<Vec<YtDlpItem>> {
let output = YtDlp::new()
.arg("--flat-playlist")
.arg("--dump-json")
.arg("--no-check-formats")
.arg(url)
.execute()?;
// Check if yt-dlp exited successfully; log stderr if not
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(Error::new(
500,
format!("yt-dlp failed ({}): {}", output.status, stderr.trim()),
));
}
let stdout = String::from_utf8(output.stdout)?;
let items: Vec<YtDlpItem> = stdout
.split('\n')
.filter_map(|line| {
if line.is_empty() {
None
} else {
Some(
serde_json::from_slice::<YtDlpItem>(line.as_bytes())
.map_err(|err| Error::new(500, err.to_string())),
)
}
})
.filter_map(|parsed| match parsed {
Ok(item) => Some(item),
Err(err) => {
log::warn!("Failed to parse yt-dlp item: {}", err);
None
}
})
.collect();
Ok(items)
}
pub fn register() -> CreateCommand {
CreateCommand::new("play")
.description("Plays the given track")
.add_option(
CreateCommandOption::new(CommandOptionType::String, "track", "The track to be played")
.required(true),
)
}
struct TrackEndNotifier {
pub call: Arc<Songbird>,
pub guild_id: GuildId,
}
#[async_trait]
impl EventHandler for TrackEndNotifier {
async fn act(&self, ctx: &songbird::events::EventContext<'_>) -> Option<songbird::events::Event> {
if let songbird::EventContext::Track(_track_list) = ctx {
if let Some(call) = self.call.get(self.guild_id) {
let mut handler = call.lock().await;
if handler.queue().is_empty() {
log::debug!("Queue is empty, leaving voice channel");
handler.leave().await.unwrap();
}
}
}
None
}
}

View File

@@ -0,0 +1,63 @@
use crate::{
chat::{edit_response, process_message},
error::{Error, Result},
handler::get_songbird,
};
use serenity::{
all::{CommandInteraction, CreateCommand, GuildId},
prelude::*,
};
use songbird::Songbird;
use std::sync::Arc;
pub async fn run(ctx: &Context, command: &CommandInteraction) {
// Create the initial response
process_message(&ctx, &command, false).await;
// Get the songbird manager
let manager = get_songbird();
// Extract the guild ID
let guild_id = match &command.guild_id {
Some(guild_id) => guild_id,
None => {
edit_response(
&ctx,
&command,
"Unable to find the current server ID".to_string(),
)
.await;
return;
}
};
// Resume the track
match resume_track(manager, guild_id).await {
Ok(_) => {
log::debug!("<{guild_id}> Resumed the track");
edit_response(&ctx, &command, "resuming the track".to_string()).await;
}
Err(err) => edit_response(&ctx, &command, format!("Failed to resume: {}", err)).await,
}
}
pub async fn resume_track(manager: &Arc<Songbird>, guild_id: &GuildId) -> Result<()> {
if let Some(handler_lock) = manager.get(guild_id.to_owned()) {
let handler = handler_lock.lock().await;
match handler.queue().current() {
Some(track) => track.play()?,
None => {
return Err(Error {
status: 404,
details: "No track is currently playing".to_string(),
});
}
}
};
Ok(())
}
pub fn register() -> CreateCommand {
CreateCommand::new("resume").description("Resume the current track")
}

View File

@@ -0,0 +1,48 @@
use crate::{
chat::{edit_response, process_message},
handler::get_songbird,
};
use serenity::{
all::{CommandInteraction, CreateCommand},
prelude::*,
};
pub async fn run(ctx: &Context, command: &CommandInteraction) {
// Create the initial response
process_message(&ctx, &command, false).await;
// Get the songbird manager
let manager = get_songbird();
// Extract the guild ID
let guild_id = match &command.guild_id {
Some(guild_id) => guild_id,
None => {
edit_response(
&ctx,
&command,
"Unable to find the current server ID".to_string(),
)
.await;
return;
}
};
// Skip the track
if let Some(handler_lock) = manager.get(guild_id.to_owned()) {
let handler = handler_lock.lock().await;
match handler.queue().skip() {
Ok(_) => {
log::debug!("<{guild_id}> Skipped the track");
edit_response(&ctx, &command, "Skipping the track".to_string()).await;
}
Err(err) => {
edit_response(&ctx, &command, format!("Failed to skip: {}", err)).await;
}
}
}
}
pub fn register() -> CreateCommand {
CreateCommand::new("skip").description("Skip the current track")
}

View File

@@ -0,0 +1,42 @@
use crate::{
chat::{edit_response, process_message},
handler::get_songbird,
};
use serenity::{
all::{CommandInteraction, CreateCommand},
prelude::*,
};
pub async fn run(ctx: &Context, command: &CommandInteraction) {
// Create the initial response
process_message(&ctx, &command, false).await;
// Get the songbird manager
let manager = get_songbird();
// Extract the guild ID
let guild_id = match command.guild_id {
Some(g) => g,
None => {
edit_response(
&ctx,
&command,
"Unable to find the current server ID".to_string(),
)
.await;
return;
}
};
// Stop the track and clear the queue
if let Some(handler_lock) = manager.get(guild_id) {
let handler = handler_lock.lock().await;
handler.queue().stop();
log::debug!("<{guild_id}> Stopped the track");
edit_response(&ctx, &command, "Stopping the tracks".to_string()).await;
}
}
pub fn register() -> CreateCommand {
CreateCommand::new("stop").description("Stop the current track and clear the queue")
}

View File

@@ -0,0 +1,92 @@
use crate::{
chat::{create_message_response, edit_response, process_message},
handler::get_songbird,
};
use serenity::{
all::{CommandInteraction, CommandOptionType, CreateCommand, CreateCommandOption},
model::prelude::GuildId,
prelude::*,
};
use siren_core::data::guilds::GuildCache;
use songbird::Songbird;
use std::sync::Arc;
pub async fn run(ctx: &Context, command: &CommandInteraction) {
// Process the command options
let volume = match command.data.options.first() {
Some(o) => o.value.as_i64().unwrap() as i32,
None => {
log::warn!(
"{} attempted to change the volume without a volume option",
command.user.id.get()
);
create_message_response(
&ctx,
&command,
"Volume option is missing".to_string(),
false,
)
.await;
return;
}
};
// Create the initial response
process_message(&ctx, &command, false).await;
// Get the songbird manager
let manager = get_songbird();
// Extract the guild ID
let guild_id = match &command.guild_id {
Some(guild_id) => guild_id,
None => {
edit_response(
&ctx,
&command,
"Unable to find the current server ID".to_string(),
)
.await;
return;
}
};
// Set the volume
set_volume(&manager, guild_id, volume).await;
log::debug!("<{guild_id}> Setting the volume to {}", volume);
edit_response(&ctx, &command, format!("Setting the volume to {}", volume)).await;
}
pub async fn set_volume(manager: &Arc<Songbird>, guild_id: &GuildId, volume: i32) {
// Format volume to f32 bound between 0.0 and 1.0
let volume = std::cmp::min(100, std::cmp::max(0, volume));
let bound_volume = volume as f32 / 100.0;
// Update the guild cache
let mut guild_cache = GuildCache::find_by_id(guild_id.get() as i64).await.unwrap();
guild_cache.volume = volume;
guild_cache.update().await.unwrap();
// Update the volume of the songbird handler
if let Some(handler_lock) = manager.get(guild_id.to_owned()) {
let handler = handler_lock.lock().await;
for (_, track_handle) in handler.queue().current_queue().iter().enumerate() {
if let Err(err) = track_handle.set_volume(bound_volume) {
log::error!("Unable to set volume: {err}");
}
}
}
}
pub fn register() -> CreateCommand {
CreateCommand::new("volume")
.description("Set the audio player volume")
.add_option(
CreateCommandOption::new(
CommandOptionType::Integer,
"volume",
"Volume between 0 and 100",
)
.required(true),
)
}

View File

@@ -0,0 +1 @@
pub mod schedule;

View File

@@ -0,0 +1,146 @@
use crate::chat::process_message;
use chrono::{DateTime, NaiveDate, TimeZone, Utc};
use regex::Regex;
use serenity::all::{
Color,
CommandInteraction,
CommandOptionType,
Context,
CreateCommand,
CreateCommandOption,
CreateEmbed,
CreateEmbedFooter,
EditInteractionResponse,
Timestamp,
};
use siren_core::data::events::Event;
pub async fn run(ctx: &Context, command: &CommandInteraction) {
// Create the initial response
process_message(&ctx, &command, true).await;
// Process the command options
let title = command.data.options.get(0).unwrap().value.as_str().unwrap();
// let datetime_string = command.data.options.get(1).unwrap().value.as_str().unwrap();
let description = command
.data
.options
.get(2)
.map(|option| option.value.as_str().unwrap());
// Parse the guild ID and author ID
let guild_id = command.guild_id.unwrap();
let author_id = command.user.id;
// Parse the datetime string into a DateTime object
let date_time = Utc::now();
// Create the event
let event = Event {
id: uuid::Uuid::new_v4(),
guild_id: guild_id.get() as i64,
author_id: author_id.get() as i64,
title: title.to_string(),
date_time,
description: description.map(|s| s.to_string()),
rsvp: vec![],
};
// Save the event to the database
event.insert().await.unwrap();
// Create the response embed
let embed_footer = CreateEmbedFooter::new(format!("Created by {}", command.user.name));
let embed = CreateEmbed::new()
.title(title)
.color(Color::TEAL)
.timestamp(Timestamp::now())
.description(description.unwrap_or(""))
.field("Time", date_time.to_rfc2822(), false)
.footer(embed_footer);
let builder = EditInteractionResponse::new().embed(embed);
match command.edit_response(&ctx.http, builder).await {
Ok(_) => {}
Err(err) => {
log::error!("Failed to create schedule embed: {err}");
}
}
}
pub fn register() -> CreateCommand {
CreateCommand::new("schedule")
.description("Schedule a new event")
.add_option(
CreateCommandOption::new(CommandOptionType::String, "title", "The title of the event")
.required(true),
)
.add_option(
CreateCommandOption::new(
CommandOptionType::String,
"datetime",
"The date and time of the event",
)
.required(true),
)
.add_option(CreateCommandOption::new(
CommandOptionType::String,
"description",
"A description of the event",
))
}
// The datetime string can be formatted in the following ways:
// (in) XX <seconds, minutes, hours, days, weeks>
// (at) YYYY-MM-DD HH:MM (AM/PM)
// (at) MM DD (YYYY) HH:MM (AM/PM)
#[allow(dead_code)]
fn parse_datetime(input: &str) -> Option<DateTime<Utc>> {
let regexes = vec![
Regex::new(r"(?i)^\(?at\)?\s+(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})\s*(AM|PM)?$").unwrap(),
Regex::new(r"(?i)^\(?at\)?\s+(\d{2})\s+(\d{2})\s*(\d{4})?\s+(\d{2}):(\d{2})\s*(AM|PM)?$")
.unwrap(),
// ... add other regexes here
];
for regex in regexes {
if let Some(captures) = regex.captures(input) {
if captures.len() == 7 {
// Matches the second format
let (year, month, day) = (
captures.get(1).unwrap().as_str().parse().unwrap_or(1970),
captures.get(2).unwrap().as_str().parse().unwrap_or(1),
captures.get(3).unwrap().as_str().parse().unwrap_or(1),
);
let (mut hour, minute) = (
captures.get(4).unwrap().as_str().parse().unwrap_or(0),
captures.get(5).unwrap().as_str().parse().unwrap_or(0),
);
if let Some(am_pm) = captures.get(6) {
if am_pm.as_str().eq_ignore_ascii_case("PM") && hour != 12 {
hour += 12;
}
if am_pm.as_str().eq_ignore_ascii_case("AM") && hour == 12 {
hour = 0;
}
}
// Create a NaiveDate instance from year, month, day
let naive_date =
NaiveDate::from_ymd_opt(year, month, day).expect("Invalid date parameters");
// Create a NaiveDateTime instance from NaiveDate and time components
let naive_time = naive_date
.and_hms_opt(hour, minute, 0)
.expect("Invalid time parameters");
// Convert the NaiveDateTime to a DateTime<Utc>
return Some(Utc.from_utc_datetime(&naive_time));
}
// handle other cases
}
}
None
}

View File

@@ -0,0 +1,2 @@
pub mod request_roll;
pub mod roll;

View File

@@ -0,0 +1,109 @@
use crate::{
chat::{create_message_response, edit_response},
commands::fun::roll::parse_dice,
};
use serenity::all::{
ButtonStyle,
CommandInteraction,
CommandOptionType,
Context,
CreateActionRow,
CreateButton,
CreateCommand,
CreateCommandOption,
CreateMessage,
Mentionable,
UserId,
};
pub async fn run(ctx: &Context, command: &CommandInteraction) {
// Check if the roll result is hidden
let hidden = command
.data
.options
.iter()
.find(|opt| opt.name == "hidden")
.and_then(|o| o.value.as_bool())
.unwrap_or(false);
// Retrieve the user
let user = command
.data
.options
.iter()
.find(|opt| opt.name == "user")
.and_then(|o| o.value.as_mentionable())
.unwrap();
let user_id = UserId::new(user.get());
create_message_response(
ctx,
&command,
format!("Sending request to {}", user_id.mention()),
true,
)
.await;
let dice_string = command
.data
.options
.get(0)
.and_then(|o| o.value.as_str())
.map(|s| s.split_whitespace().collect::<String>())
.unwrap();
let dice_result = parse_dice(dice_string.as_str());
match dice_result {
Ok(dice) => {
let roll_button = CreateButton::new(format!(
"request_dice_roll|{}|{}|{}|{}|{}",
dice.0,
dice.1,
dice.2,
command.user.id.get(),
hidden
))
.label(format!("🎲 Roll {} 🎲", dice_string)) // The label you want on the button
.style(ButtonStyle::Primary);
let action_row = CreateActionRow::Buttons(vec![roll_button]);
let message = CreateMessage::new()
.content(format!("-# Roll requested from {}", command.user.mention()))
.components(vec![action_row]);
if let Err(why) = user_id.dm(ctx, message).await {
log::error!("failed to send request due to {}", why);
edit_response(ctx, command, "Unable to send dice request".to_string()).await;
};
}
Err(why) => {
edit_response(ctx, &command, why.to_string()).await;
}
}
}
pub fn register() -> CreateCommand {
CreateCommand::new("requestroll")
.description("Request a dice roll from a user")
.add_option(
CreateCommandOption::new(CommandOptionType::String, "dice", "Dice to roll").required(true),
)
.add_option(
CreateCommandOption::new(
CommandOptionType::Mentionable,
"user",
"User to receive the dice roll request",
)
.required(true),
)
.add_option(
CreateCommandOption::new(
CommandOptionType::Boolean,
"hidden",
"Hide the dice roll from the user (Default: False",
)
.required(false),
)
}

View File

@@ -0,0 +1,236 @@
use crate::{
chat::{create_message_response, edit_response},
error::{Error, Result},
};
use rand::RngExt;
use serenity::all::{
CommandInteraction,
CommandOptionType,
Context,
CreateCommand,
CreateCommandOption,
CreateEmbed,
CreateMessage,
Mentionable,
UserId,
};
use siren_core::utils::{a_or_an, number_to_words};
pub async fn run(ctx: &Context, command: &CommandInteraction) {
// Check if the roll result is private
let private = command
.data
.options
.iter()
.find(|opt| opt.name == "private")
.and_then(|o| o.value.as_bool())
.unwrap_or(true);
// Retrieve the user if present
let user = command
.data
.options
.iter()
.find(|opt| opt.name == "user")
.and_then(|o| o.value.as_mentionable());
create_message_response(ctx, &command, "Rolling...".to_string(), private).await;
let dice_string = match command
.data
.options
.get(0)
.and_then(|o| o.value.as_str())
.map(|s| s.split_whitespace().collect::<String>())
{
Some(dice_value) => dice_value,
None => {
log::warn!("Missing or invalid dice option");
let _ = edit_response(&ctx, &command, "Dice option is missing".to_string()).await;
return;
}
};
let dice = parse_dice(dice_string.as_str());
match dice {
Ok((count, sides, modifier)) => {
let total = roll_dice(count, sides, modifier);
let response = format!("(Rolled {})", format_roll(count, sides, modifier));
match user {
Some(id) => {
let user_id = UserId::new(id.get());
let roller_id = command.user.id;
send_roll_message(ctx, total, user_id, roller_id, &response).await;
edit_response(
&ctx,
command,
format!("Sending dice roll results to {}", &user_id.mention()),
)
.await;
}
None => edit_response(&ctx, &command, format!("🎲 {}\n-# {}", total, response)).await,
};
// Check for dice tracks
}
Err(why) => {
edit_response(&ctx, &command, format!("Invalid dice string: {}", why)).await;
}
}
}
pub async fn send_roll_message(
ctx: &Context,
total: i32,
user_id: UserId,
roller_id: UserId,
dice_string: &str,
) {
// Create the dice roll embed
let a = a_or_an(&number_to_words(total));
let embed = CreateEmbed::new()
.title("🎲 Received a dice roll! 🎲".to_string())
.color(0x00FF00)
.description(format!(
"{} rolled {} **{}**\n-# *{}*",
&roller_id.mention(),
a,
total,
dice_string
));
let message = CreateMessage::new().embed(embed);
if let Err(err) = user_id.dm(ctx, message).await {
log::error!("Could not send message: {}", err);
}
}
pub fn format_roll(count: u32, sides: u32, modifier: i32) -> String {
format!(
"{}d{}{}",
count,
sides,
if modifier > 0 {
format!("+{}", modifier)
} else if modifier < 0 {
format!("-{}", modifier)
} else {
"".to_string()
}
)
}
pub fn roll_dice(count: u32, sides: u32, modifier: i32) -> i32 {
let mut rolls = Vec::new();
let mut total = modifier;
for _ in 0..count {
let roll = rand::rng().random_range(1..=sides as i32);
total += roll;
rolls.push(roll);
}
total
}
pub fn parse_dice(dice: &str) -> Result<(u32, u32, i32)> {
// If the input is just a number (e.g., "20" or "6"), assume it's the number of sides
if let Ok(n) = dice.parse::<u32>() {
return Ok((1, n, 0)); // Assume 1 dice with 0 modifiers
}
// If the input starts with "d", assume it's shorthand for "1dX"
let dice = if dice.starts_with("d") {
format!("1{}", dice) // Prepend "1"
} else {
dice.to_string()
};
let mut parts = dice.split(['d', '+', '-'].as_ref());
let mut positive_modifier = true;
// Parse the dice count
let count = match parts.next() {
Some("") => 1, // Handle cases like "d6", assume 1 dice
Some(c) => match c.parse::<u32>() {
Ok(n) => n,
Err(_) => return Err(Error::new(400, format!("Invalid dice count: {}", c))),
},
None => return Err(Error::new(400, format!("Invalid dice string: {}", dice))),
};
// Parse the number of sides
let sides_part = parts
.next()
.ok_or_else(|| Error::new(400, format!("Invalid dice string: {}", dice)))?;
let sides = match sides_part.parse::<u32>() {
Ok(n) => {
if [4, 6, 8, 10, 12, 20, 100].contains(&n) {
n
} else {
return Err(Error::new(
400,
format!(
"Expected one of d4, d6, d8, d10, d12, d20, d100 but received d{}",
n
),
));
}
}
Err(_) => {
return Err(Error::new(
400,
format!(
"Expected one of d4, d6, d8, d10, d12, d20, d100 but received d{}",
sides_part
),
));
}
};
// Determine if there's a modifier (+ or -)
if dice.contains('+') {
positive_modifier = true;
} else if dice.contains('-') {
positive_modifier = false;
}
// Parse the modifier, if present
let modifier = match parts.next() {
Some(m) => match m.parse::<i32>() {
Ok(n) => {
if positive_modifier {
n
} else {
-n
}
}
Err(_) => return Err(Error::new(400, format!("Invalid dice modifier: {}", m))),
},
None => 0, // No modifier found
};
Ok((count, sides, modifier))
}
pub fn register() -> CreateCommand {
CreateCommand::new("roll")
.description("Roll dice")
.add_option(
CreateCommandOption::new(CommandOptionType::String, "dice", "Dice to roll").required(true),
)
.add_option(
CreateCommandOption::new(
CommandOptionType::Boolean,
"private",
"Make the roll private (Default: True)",
)
.required(false),
)
.add_option(
CreateCommandOption::new(
CommandOptionType::Mentionable,
"user",
"User to receive the roll results",
)
.required(false),
)
}

View File

@@ -0,0 +1,4 @@
pub mod audio;
pub mod event;
pub mod fun;
pub mod utility;

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,2 @@
pub mod help;
pub mod ping;

View File

@@ -0,0 +1,19 @@
use crate::chat::create_message_response;
use serenity::all::{CommandInteraction, Context, CreateCommand};
pub async fn run(ctx: &Context, command: &CommandInteraction) {
log::debug!("Ping command executed");
if let Some(guild_id) = command.guild_id {
if let Some(guild) = guild_id.to_guild_cached(&ctx.cache) {
let owner_id = guild.owner_id;
if command.user.id == owner_id {}
}
}
create_message_response(&ctx, &command, "pong".to_string(), true).await;
}
pub fn register() -> CreateCommand {
CreateCommand::new("ping").description("Displays the bot latency")
}

View File

@@ -0,0 +1,89 @@
use serde::{Deserialize, Serialize};
use std::fmt;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Deserialize, Serialize)]
pub struct Error {
pub status: u16,
pub details: String,
}
impl Error {
pub fn new(status: u16, details: String) -> Self {
Self { status, details }
}
pub fn not_found(details: String) -> Self {
Self::new(404, details)
}
pub fn internal_server_error(details: String) -> Self {
Self::new(500, details)
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(self.details.as_str())
}
}
impl std::error::Error for Error {
fn description(&self) -> &str {
&self.details
}
}
impl From<siren_core::error::Error> for Error {
fn from(error: siren_core::error::Error) -> Self {
Self::new(error.status, error.details)
}
}
impl From<serenity::Error> for Error {
fn from(error: serenity::Error) -> Self {
Self::new(500, format!("Discord error: {}", error))
}
}
impl From<songbird::error::JoinError> for Error {
fn from(error: songbird::error::JoinError) -> Self {
use std::error::Error as StdError;
let details = match error.source() {
Some(source) => format!("Unable to join channel: {} ({})", error, source),
None => format!("Unable to join channel: {}", error),
};
Self::new(500, details)
}
}
impl From<songbird::tracks::ControlError> for Error {
fn from(error: songbird::tracks::ControlError) -> Self {
Self::new(500, format!("Track control error: {}", error))
}
}
impl From<std::io::Error> for Error {
fn from(error: std::io::Error) -> Self {
Self::new(500, format!("IO error: {}", error))
}
}
impl From<std::string::FromUtf8Error> for Error {
fn from(error: std::string::FromUtf8Error) -> Self {
Self::new(500, format!("UTF-8 error: {}", error))
}
}
impl From<reqwest::Error> for Error {
fn from(error: reqwest::Error) -> Self {
Self::new(500, format!("HTTP client error: {}", error))
}
}
impl From<serde_json::Error> for Error {
fn from(error: serde_json::Error) -> Self {
Self::new(500, format!("JSON error: {}", error))
}
}

View File

@@ -0,0 +1,244 @@
use super::{chat::create_modal_response, commands};
use crate::{
HttpKey,
commands::fun::roll::{format_roll, roll_dice, send_roll_message},
};
use serenity::{
all::{
CreateInteractionResponse,
EditInteractionResponse,
Interaction,
ResumedEvent,
UnavailableGuild,
UserId,
},
async_trait,
model::{channel::Message, gateway::Ready},
prelude::*,
};
use siren_core::{
data::guilds::GuildCache,
utils::{a_or_an, number_to_words},
};
use songbird::Songbird;
use std::sync::{Arc, OnceLock};
pub struct BotHandler {
pub force_register: bool,
}
static REGISTERED: OnceLock<bool> = OnceLock::new();
static SONGBIRD: OnceLock<Arc<Songbird>> = OnceLock::new();
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
pub fn get_songbird() -> &'static Arc<Songbird> {
SONGBIRD.get().unwrap()
}
pub fn get_client() -> &'static reqwest::Client {
CLIENT.get().unwrap()
}
impl BotHandler {
pub fn new(force_register: bool) -> Self {
Self { force_register }
}
}
#[async_trait]
impl EventHandler for BotHandler {
async fn message(&self, _ctx: Context, msg: Message) {
// Ignore bot messages
if msg.author.bot {
return;
}
// Handle direct messages
if let None = msg.guild_id {
log::trace!("Received DM from {}: {}", msg.author, msg.content);
}
}
async fn ready(&self, ctx: Context, ready: Ready) {
if ready.guilds.is_empty() {
log::warn!("No ready guilds found");
}
if SONGBIRD.get().is_none() {
let songbird = songbird::get(&ctx).await.unwrap();
SONGBIRD
.set(songbird.clone())
.expect("Songbird value could not be set");
}
if CLIENT.get().is_none() {
let http_client = {
let data = ctx.data.read().await;
data
.get::<HttpKey>()
.cloned()
.expect("Guaranteed to exist in the typemap.")
};
CLIENT.set(http_client).ok();
}
// Update registered to prevent reloading the commands
if REGISTERED.get().is_some() {
return;
} else {
REGISTERED.set(true).ok();
}
log::debug!("Registering in {} guild(s)", ready.guilds.len());
for guild in ready.guilds {
update_guild_commands(&ctx, &guild, self.force_register).await;
}
}
async fn resume(&self, _: Context, _: ResumedEvent) {
log::trace!("Resumed");
}
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
if let Interaction::Command(command) = interaction {
log::trace!(
"<{}> Received command: {}",
command.guild_id.unwrap(),
command.data.name
);
match command.data.name.as_str() {
// Match commands without returns
"play" => commands::audio::play::run(&ctx, &command).await,
"stop" => commands::audio::stop::run(&ctx, &command).await,
"pause" => commands::audio::pause::run(&ctx, &command).await,
"resume" => commands::audio::resume::run(&ctx, &command).await,
"mute" => commands::audio::mute::run(&ctx, &command).await,
"skip" => commands::audio::skip::run(&ctx, &command).await,
"volume" => commands::audio::volume::run(&ctx, &command).await,
"schedule" => commands::event::schedule::run(&ctx, &command).await,
"roll" => commands::fun::roll::run(&ctx, &command).await,
"requestroll" => commands::fun::request_roll::run(&ctx, &command).await,
"ping" => commands::utility::ping::run(&ctx, &command).await,
_ => {}
}
} else if let Interaction::Component(component) = interaction {
log::trace!("Received COMPONENT");
let custom_id = &component.data.custom_id;
if custom_id.starts_with("request_dice_roll") {
// Acknowledge the interaction
if let Err(err) = component
.create_response(ctx.http.clone(), CreateInteractionResponse::Acknowledge)
.await
{
log::error!("Could not create dice response: {}", err);
};
let parts = custom_id.split('|').collect::<Vec<&str>>();
if parts.len() == 6 {
let count = parts[1].parse().unwrap();
let sides = parts[2].parse().unwrap();
let modifier = parts[3].parse().unwrap();
let result = roll_dice(count, sides, modifier);
let response = format!("(Rolled {})", format_roll(count, sides, modifier));
let user_id = UserId::from(parts[4].parse::<u64>().unwrap());
let roller_id = component.user.id;
let hidden: bool = parts[5].parse().unwrap();
// Prepare the message based on visibility
let new_message = if hidden {
// For hidden rolls, only reveal "results sent" to the requester
format!("🎲 Results sent to {}\n-# {}", user_id.mention(), response)
} else {
// For public rolls, show the roll result
format!(
"🎲 You rolled {} {}\n-# {}",
a_or_an(&number_to_words(result)),
result,
response
)
};
// Edit the message to update the text and remove buttons
if let Err(err) = component
.edit_response(
ctx.http.clone(),
EditInteractionResponse::new()
.content(new_message)
.components(Vec::new()),
)
.await
{
log::error!("Could not update dice roll message: {}", err);
}
// Send message to the requester
send_roll_message(&ctx, result, user_id, roller_id, &response).await;
} else {
log::error!("Could not handle dice click: {}", custom_id);
}
}
} else if let Interaction::Ping(_ping) = interaction {
log::trace!("Received PING");
} else if let Interaction::Autocomplete(_autocomplete) = interaction {
log::trace!("Received AUTOCOMPLETE");
} else if let Interaction::Modal(modal) = interaction {
log::trace!("Received MODAL");
create_modal_response(&ctx, &modal).await;
}
}
}
async fn update_guild_commands(ctx: &Context, guild: &UnavailableGuild, force_register: bool) {
// List of commands to register for the guild
let guild_commands = vec![
commands::audio::play::register(),
commands::audio::stop::register(),
commands::audio::pause::register(),
commands::audio::resume::register(),
commands::audio::mute::register(),
commands::audio::skip::register(),
commands::audio::volume::register(),
commands::event::schedule::register(),
commands::fun::roll::register(),
commands::fun::request_roll::register(),
commands::utility::ping::register(),
];
let guild_id = guild.id.get() as i64;
let register_commands = match GuildCache::find_by_id(guild_id).await {
Some(_) => force_register,
None => {
// If no guild cache is found, create a new one.
let guild_cache = GuildCache {
id: guild_id,
name: guild.id.name(&ctx.cache),
owner_id: None,
volume: 100,
};
if let Err(err) = guild_cache.insert().await {
log::error!("Could not insert guild cache: {err}");
};
true
}
};
if register_commands {
// Register the commands in the guild
match guild.id.set_commands(&ctx.http, guild_commands).await {
Ok(registered_commands) => {
log::info!(
"Registered {} commands for guild {}",
registered_commands.len(),
guild.id.get()
);
}
Err(why) => {
log::error!(
"Could not register commands for guild {}: {:?}",
guild.id.get(),
why
);
}
};
} else {
log::debug!("Guild {guild_id} is already registered");
}
}

View File

@@ -0,0 +1,14 @@
pub mod chat;
pub mod commands;
pub mod error;
pub mod handler;
pub mod ytdlp;
use reqwest::Client as HttpClient;
use serenity::prelude::TypeMapKey;
pub struct HttpKey;
impl TypeMapKey for HttpKey {
type Value = HttpClient;
}

View File

@@ -0,0 +1,39 @@
mod model;
pub use model::*;
use std::process::{Child, Command, Output, Stdio};
const YOUTUBE_DL_COMMAND: &str = "yt-dlp";
pub struct YtDlp {
command: Command,
args: Vec<String>,
}
impl YtDlp {
pub fn new() -> Self {
let mut cmd = Command::new(YOUTUBE_DL_COMMAND);
cmd
.env("LC_ALL", "en_US.UTF-8")
.stdout(Stdio::piped())
.stdin(Stdio::piped())
.stderr(Stdio::piped());
Self {
command: cmd,
args: Vec::new(),
}
}
pub fn arg(&mut self, arg: &str) -> &mut Self {
self.args.push(arg.to_owned());
self
}
pub fn execute(&mut self) -> std::io::Result<Output> {
self
.command
.args(self.args.clone())
.spawn()
.and_then(Child::wait_with_output)
}
}

View File

@@ -0,0 +1,35 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
#[serde(untagged)]
pub enum YtDlpItem {
PlaylistItem {
id: String,
url: String,
title: String,
duration: Option<f64>,
playlist_index: Option<i32>,
},
VideoItem {
id: String,
webpage_url: String,
title: String,
duration: Option<f64>,
},
}
impl YtDlpItem {
pub fn get_title(&self) -> &str {
match self {
YtDlpItem::PlaylistItem { title, .. } => title,
YtDlpItem::VideoItem { title, .. } => title,
}
}
pub fn get_url(&self) -> &str {
match self {
YtDlpItem::PlaylistItem { url, .. } => url,
YtDlpItem::VideoItem { webpage_url, .. } => webpage_url,
}
}
}