Compare commits

...
Sign in to create a new pull request.

47 commits

Author SHA1 Message Date
610229cd1b Update dependencies 2024-08-02 23:23:28 -04:00
709d4ab3a3 Add a bunch of codecs 2024-01-29 17:27:18 -05:00
32d35a2c02 Use tracing instead of println for connection message 2024-01-25 20:38:06 -05:00
dc856ea3fd Use better error message when failing to get metadata 2024-01-25 20:24:05 -05:00
943f0f2678 Install yt-dlp-core from Alpine repos 2024-01-25 20:10:40 -05:00
a5cb54d214 Downgrade Alpine and install static yt-dlp 2024-01-25 19:54:11 -05:00
eb48999aa2 Remove OPENAI_API_KEY from build stage 2024-01-25 19:12:52 -05:00
e9b38c069a Remove old openai crate 2024-01-25 19:09:54 -05:00
c9fcdba421 Switch to alpine in Docker 2024-01-25 18:50:19 -05:00
ddaf3ee7f2 Switch from openai to async-openai crate 2024-01-25 18:28:28 -05:00
ec66822602 Finish upgrading pose and songbird 2024-01-25 17:50:05 -05:00
4244469610 [WIP] Remember to register songbird 2024-01-25 13:03:26 -05:00
66b215a0cb [WIP] Fix loading gif replies 2024-01-25 12:57:30 -05:00
e58e70509c [WIP] Upgrade to poise 0.6 2024-01-25 12:42:57 -05:00
952cf0cdb6 Update rust and yt-dlp 2023-05-24 22:57:30 -04:00
f438fb7341 Upgrade to GPT-4 💪 2023-03-29 00:12:42 -04:00
0d896d30bd Use my fixed fork of yt-dlp 2023-03-23 00:21:33 -04:00
349d3fa425 Run as non-root user with correct perms 2023-03-23 00:12:12 -04:00
fd3ec019a8 Disable OpenAI integration in debug mode 2023-03-22 20:12:02 -04:00
0531ad769e Clean up the recommended way 2023-03-18 03:10:29 -04:00
3cc4220317 Clean up apt-get 2023-03-18 03:08:15 -04:00
8dab128679 Install ca-certificates 2023-03-18 03:06:14 -04:00
c732e1d27b Run as root for now
It doesn't work as a normal user
2023-03-18 02:55:57 -04:00
46e22fb8f4 Download yt-dlp nightly builds 2023-03-18 02:42:51 -04:00
b2505ce086 Log JSON parsing errors 2023-03-18 02:40:07 -04:00
22eba91a17 Use env var to configure logging 2023-03-18 01:06:50 -04:00
8a56fcf126 Bump songbird version 2023-03-18 00:35:55 -04:00
863fcb0812 Run as unprivileged user 2023-03-09 17:50:31 -05:00
3faa422b90 Use sparse cargo registry 2023-03-09 17:37:00 -05:00
b3bd3b2997 Add todo list 2023-03-08 19:08:06 -05:00
825e15202e Make sure to add track event handler 2023-03-08 18:57:03 -05:00
2fd2079330 Implement moving to the next track 2023-03-08 18:35:30 -05:00
07618676db Implement queue 2023-03-07 20:53:54 -05:00
eb56c024cd Update openai crate 2023-03-06 03:05:38 -05:00
2de52dfe9d Install libopus in final image 2023-03-03 02:50:01 -05:00
16182615d8 Install static ffmpeg and yt-dlp from source 2023-03-03 02:41:28 -05:00
1e4fbfb1b1 Remove unused import 2023-03-03 02:10:06 -05:00
ad2ae7b6eb Reduce logging level 2023-03-03 02:02:02 -05:00
72311197e0 Add OPENAI_KEY arg to Dockerfile 2023-03-03 01:30:09 -05:00
4cc06dbca1 Fix dockerfile 2023-03-03 01:03:59 -05:00
c5ee1a21cd Rename openai module to personality 2023-03-02 22:57:00 -05:00
90c65693de Add cat pun loading messages 2023-03-02 22:52:19 -05:00
30ffacfef0 Add initial response while a song loads 2023-03-02 22:36:56 -05:00
3bdd819d75 Remove command prefix
We can just ping the bot
2023-03-02 21:22:10 -05:00
332e7d4aa9 Add stop command 2023-03-02 21:20:24 -05:00
9242d39f98 Add AI-generated sassy commentary 2023-03-02 19:57:58 -05:00
1342253a76 [WIP] Rewrite with Poise
Still some missing commands
2023-03-01 22:29:07 -05:00
9 changed files with 4264 additions and 509 deletions

4
.gitignore vendored
View file

@ -3,10 +3,6 @@
debug/
target/
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
Cargo.lock
# These are backup files generated by rustfmt
**/*.rs.bk

3534
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,23 +1,32 @@
[package]
name = "dj-kitty-cat"
version = "0.1.0"
authors = ["my name <my@email.address>"]
edition = "2018"
name = "dj_kitty_cat"
version = "0.2.0"
edition = "2021"
[dependencies]
tracing = "0.1"
tracing-subscriber = "0.2"
tracing-futures = "0.2"
anyhow = "1.0.69"
parking_lot = "0.12.1"
poise = "0.6.1"
tracing = "0.1.37"
tracing-futures = "0.2.5"
rand = "0.8.5"
songbird = "0.4.3"
thiserror = "1.0.39"
async-openai = "0.18.1"
[dependencies.songbird]
version = "0.2"
features = ["builtin-queue"]
[dependencies.serenity]
version = "0.10"
[dependencies.reqwest]
version = "0.11.27"
default-features = false
features = ["cache", "client", "gateway", "model", "voice", "rustls_backend", "unstable_discord_api"]
features = ["rustls-tls", "stream"]
[dependencies.symphonia]
version = "0.5.2"
features = ["mkv", "mp3", "flac", "ogg", "vorbis", "wav", "pcm", "aac", "alac", "adpcm"]
[dependencies.tokio]
version = "1.0"
version = "1.26.0"
features = ["macros", "rt-multi-thread", "signal"]
[dependencies.tracing-subscriber]
version = "0.3.18"
features = ["fmt", "env-filter", "std"]

View file

@ -1,10 +1,14 @@
FROM rust:1 as builder
RUN apt-get update && apt-get install -y libopus-dev
FROM rust:1.74.1-alpine3.17 as builder
RUN apk add --no-cache musl-dev opus-dev pkgconfig
WORKDIR /usr/src/myapp
COPY . .
RUN cargo install --path .
FROM debian:bullseye-slim
RUN apt-get update && apt-get install -y ffmpeg youtube-dl
COPY --from=builder /usr/local/cargo/bin/dj-kitty-cat /usr/local/bin/dj-kitty-cat
CMD ["dj-kitty-cat"]
FROM alpine:3.19
RUN apk add --no-cache yt-dlp-core
COPY --from=builder /usr/local/cargo/bin/dj_kitty_cat /usr/local/bin/dj_kitty_cat
RUN adduser -D djkc
WORKDIR /opt
RUN chown djkc:djkc /opt
USER djkc
CMD ["dj_kitty_cat"]

View file

@ -3,3 +3,9 @@
This is a Discord bot that uses [serenity](https://crates.io/crates/serenity) and [songbird](https://crates.io/crates/songbird) to play music in a channel. It's very heavily tailored to the needs of the `Cats? Cats.` community.
You need libopus, ffmpeg, and youtube-dl as described in the [songbird readme](https://github.com/serenity-rs/songbird#dependencies).
## TODO:
- [ ] Error Messages
- [ ] Loop
- [ ] Volume
- [ ] Support YT playlists

View file

@ -1,326 +1,370 @@
use serenity::client::Context;
use serenity::model::interactions::application_command::{
ApplicationCommandInteraction, ApplicationCommandInteractionDataOptionValue,
use anyhow::{Context, Result};
use poise::{
serenity_prelude::{CreateEmbed, EmbedMessageBuilding, MessageBuilder},
CreateReply,
};
use serenity::utils::{EmbedMessageBuilding, MessageBuilder};
use songbird::create_player;
use songbird::input::Input;
use songbird::input::{Input, YoutubeDl};
use tracing::{debug, log::warn};
use crate::{CurrentVolume, CurrentlyPlayingTrack};
use crate::{personality, CommandContext, Error};
pub async fn join(ctx: &Context, command: &ApplicationCommandInteraction) -> String {
let guild_id = command.guild_id.unwrap();
let guild = guild_id.to_guild_cached(&ctx.cache).await.unwrap();
let channel_id = guild
.voice_states
.get(&command.user.id)
.and_then(|voice_state| voice_state.channel_id);
let connect_to = match channel_id {
Some(channel) => channel,
None => {
return "You're not in a voice channel. How do I know where to go?".to_string();
}
#[poise::command(slash_command)]
pub async fn join(ctx: CommandContext<'_>) -> Result<(), Error> {
let Some((guild_id, Some(channel_id))) = ctx.guild().and_then(|g| {
g.voice_states
.get(&ctx.author().id)
.map(|vs| (g.id, vs.channel_id))
}) else {
ctx.say("You're not in a voice channel, silly.").await?;
return Ok(());
};
let manager = songbird::get(ctx)
let manager = songbird::get(ctx.serenity_context())
.await
.expect("Songbird Voice client placed in at initialisation.")
.context("Expected a songbird manager")?
.clone();
let _handler = manager.join(guild_id, channel_id).await;
let _handler = manager.join(guild_id, connect_to).await;
ctx.say("Joining your channel!").await?;
"Joining your channel!".to_string()
Ok(())
}
pub async fn leave(ctx: &Context, command: &ApplicationCommandInteraction) -> String {
let guild_id = command.guild_id.unwrap();
let manager = songbird::get(ctx)
.await
.expect("Songbird Voice client placed in at initialisation.")
.clone();
let has_handler = manager.get(guild_id).is_some();
if has_handler {
if let Err(e) = manager.remove(guild_id).await {
return format!("Failed: {:?}", e);
}
"Goodbye!".to_string()
} else {
"I can't leave if I'm not there to bein with!".to_string()
}
}
pub async fn play(ctx: &mut Context, command: &ApplicationCommandInteraction) -> String {
let options = command
.data
.options
.get(0)
.expect("Expected url option")
.resolved
.as_ref()
.expect("Expected url object");
let url = match options {
ApplicationCommandInteractionDataOptionValue::String(url) => url,
_ => {
return "You didn't tell me what to play.".to_string();
}
#[poise::command(slash_command)]
pub async fn leave(ctx: CommandContext<'_>) -> Result<(), Error> {
let Some(guild_id) = ctx.guild().map(|g| g.id) else {
ctx.say("You're not in a server, silly.").await?;
return Ok(());
};
if !url.starts_with("http") {
return "That's not a real URL. I'm onto you.".to_string();
}
let guild_id = command.guild_id.unwrap();
let manager = songbird::get(ctx)
let manager = songbird::get(ctx.serenity_context())
.await
.expect("Songbird Voice client placed in at initialisation.")
.context("Expected a songbird manager")?
.clone();
// Try to join the caller's channel
if manager.get(guild_id).is_none() {
join(ctx, command).await;
ctx.say("I'm not even in a voice channel!").await?;
return Ok(());
}
if let Some(handler_lock) = manager.get(guild_id) {
let mut handler = handler_lock.lock().await;
let _handler = manager.remove(guild_id).await;
let source = match songbird::input::Restartable::ytdl(url.clone(), false).await {
Ok(source) => source,
Err(why) => {
println!("Err starting source: {:?}", why);
ctx.say("Okay bye!").await?;
return "Something went horribly wrong. Go yell at Valter.".to_string();
}
};
let source_input: Input = source.into();
let message = {
if let Some(title) = &source_input.metadata.title {
let mut msg = MessageBuilder::new();
msg.push_line("Playing this:");
msg.push_named_link(title, url);
msg.build()
} else {
"Playing something, I dunno what.".to_string()
}
};
let (mut audio, track_handle) = create_player(source_input);
let mut data = ctx.data.write().await;
let current_track = data.get_mut::<CurrentlyPlayingTrack>().unwrap();
*current_track = Some(track_handle);
let volume = data.get::<CurrentVolume>().unwrap();
audio.set_volume(*volume);
handler.play_only(audio);
message
} else {
"Somehow neither of us are in a voice channel to begin with.".to_string()
}
Ok(())
}
pub async fn queue(ctx: &mut Context, command: &ApplicationCommandInteraction) -> String {
let options = command
.data
.options
.get(0)
.expect("Expected url option")
.resolved
.as_ref()
.expect("Expected url object");
let url = match options {
ApplicationCommandInteractionDataOptionValue::String(url) => url,
_ => {
return "You didn't tell me what to play.".to_string();
}
#[poise::command(slash_command)]
pub async fn play(
ctx: CommandContext<'_>,
#[description = "The URL of the song to play"] url: Option<String>,
) -> Result<(), Error> {
let Some(url) = url else {
ctx.say("You need to give me a URL to play!").await?;
return Ok(());
};
if !url.starts_with("http") {
return "That's not a real URL. I'm onto you.".to_string();
}
let Some(guild_id) = ctx.guild().map(|g| g.id) else {
ctx.say("You're not in a server, silly.").await?;
return Ok(());
};
let guild_id = command.guild_id.unwrap();
let manager = songbird::get(ctx)
let manager = songbird::get(ctx.serenity_context())
.await
.expect("Songbird Voice client placed in at initialisation.")
.context("Expected a songbird manager")?
.clone();
// Try to join the caller's channel
if manager.get(guild_id).is_none() {
join(ctx, command).await;
}
if let Some(handler_lock) = manager.get(guild_id) {
let mut handler = handler_lock.lock().await;
let source = match songbird::input::Restartable::ytdl(url.clone(), false).await {
Ok(source) => source,
Err(why) => {
println!("Err starting source: {:?}", why);
return "Something went horribly wrong. Go yell at Valter.".to_string();
}
let Some((guild_id, Some(channel_id))) = ctx.guild().and_then(|g| {
g.voice_states
.get(&ctx.author().id)
.map(|vs| (g.id, vs.channel_id))
}) else {
ctx.say("You're not in a voice channel, silly.").await?;
return Ok(());
};
let source_input: Input = source.into();
let message = {
if let Some(title) = &source_input.metadata.title {
let mut msg = MessageBuilder::new();
msg.push_line(format!(
"Queueing this up at position {}:",
handler.queue().len()
));
msg.push_named_link(title, url);
msg.build()
} else {
format!(
"Queueing something up at position {}, I dunno what.",
handler.queue().len()
)
.to_string()
}
};
let (mut audio, track_handle) = create_player(source_input);
let mut data = ctx.data.write().await;
let current_track = data.get_mut::<CurrentlyPlayingTrack>().unwrap();
*current_track = Some(track_handle);
let volume = data.get::<CurrentVolume>().unwrap();
audio.set_volume(*volume);
handler.enqueue(audio);
message
} else {
"Somehow neither of us are in a voice channel to begin with.".to_string()
let _handler = manager.join(guild_id, channel_id).await;
}
}
pub async fn skip(ctx: &Context, command: &ApplicationCommandInteraction) -> String {
let guild_id = command.guild_id.unwrap();
let manager = songbird::get(ctx)
.await
.expect("Songbird Voice client placed in at initialisation.")
.clone();
if let Some(handler_lock) = manager.get(guild_id) {
let handler = handler_lock.lock().await;
let queue = handler.queue();
let _ = queue.skip();
format!("Yeah, I didn't like this one very much anyway. Skip! Now we're at number {} in the queue.", queue.len())
} else {
"I'm not even in a channel to begin with. Silly.".to_string()
}
}
pub async fn stop(ctx: &Context, command: &ApplicationCommandInteraction) -> String {
let guild_id = command.guild_id.unwrap();
let manager = songbird::get(ctx)
.await
.expect("Songbird Voice client placed in at initialisation.")
.clone();
if let Some(handler_lock) = manager.get(guild_id) {
let mut handler = handler_lock.lock().await;
handler.stop();
"Alright, I guess I'll stop.".to_string()
} else {
"I'm not even in a channel to begin with. Silly.".to_string()
}
}
pub async fn set_volume(ctx: &mut Context, command: &ApplicationCommandInteraction) -> String {
let options = command
.data
.options
.get(0)
.expect("Expected volume option")
.resolved
.as_ref()
.expect("Expected volume object");
let volume = match options {
ApplicationCommandInteractionDataOptionValue::Number(volume) => *volume,
_ => {
return "You've gotta give me a volume level to set.".to_string();
}
};
if !(0.0..=100.0).contains(&volume) {
return "Volume has to be between 0 and 100.".to_string();
}
let mut data = ctx.data.write().await;
let current_volume = data.get_mut::<CurrentVolume>().unwrap();
let new_volume = (volume / 100.0) as f32;
*current_volume = new_volume;
let current_track = data.get::<CurrentlyPlayingTrack>().unwrap();
if let Some(track) = current_track {
if track.set_volume(new_volume).is_err() {
return format!(
"Setting volume to {}%, but it didn't work for the current track for some reason.",
volume
let reply = poise::CreateReply::default()
.content(personality::get_random_loading_message())
.embed(
CreateEmbed::new()
.image("https://media.giphy.com/media/H1dxi6xdh4NGQCZSvz/giphy.gif"),
);
let response = ctx.send(reply).await?;
let mut handler = handler_lock.lock().await;
debug!("Trying to play: {}", url);
let mut source: Input = YoutubeDl::new(ctx.data().http_client.clone(), url.clone()).into();
let metadata = match source.aux_metadata().await {
Ok(metadata) => metadata,
Err(_) => {
response
.edit(
ctx,
CreateReply::default()
.content("I couldn't find that video. Sorry! Maybe check your URL."),
)
.await?;
return Ok(());
}
};
debug!("Playing: {:?}", metadata);
let title = metadata.title.clone().unwrap_or(String::from("This video"));
let mut msg = MessageBuilder::new();
// Optional sassy commentary!
match personality::get_sassy_commentary(&title).await {
Ok(commentary) => {
msg.push_line(&commentary).push_line("");
}
Err(e) => {
warn!("Failed to get sassy commentary for \"{title}\": {e}");
}
};
msg.push_bold("Now playing: ").push_named_link(title, url);
response
.edit(ctx, CreateReply::default().content(msg.build()))
.await?;
let mut queue = ctx.data().queue.lock();
if !queue.is_empty() {
let _ = queue.stop();
}
queue.add_next(source, &mut handler)?;
queue.resume()?;
} else {
ctx.say("Neither of us are in a voice channel, silly.")
.await?;
}
format!("Setting volume to {}%.", volume)
Ok(())
}
pub async fn set_loop(ctx: &mut Context, command: &ApplicationCommandInteraction) -> String {
let options = command
.data
.options
.get(0)
.expect("Expected loop option")
.resolved
.as_ref()
.expect("Expected loop object");
let loops = match options {
ApplicationCommandInteractionDataOptionValue::Boolean(loops) => *loops,
_ => {
return "Do you want me to loop the song or not? Be specific.".to_string();
}
#[poise::command(slash_command)]
pub async fn queue(
ctx: CommandContext<'_>,
#[description = "The URL of the song to play"] url: Option<String>,
) -> Result<(), Error> {
let Some(url) = url else {
ctx.say("You need to give me a URL to play!").await?;
return Ok(());
};
let data = ctx.data.write().await;
let current_track = data.get::<CurrentlyPlayingTrack>().unwrap();
if let Some(track) = current_track {
if loops {
track.enable_loop().expect("Couldn't enable looping");
"Loopin'!".to_string()
} else {
track.disable_loop().expect("Couldn't disable looping");
"This is the last time this track will EVER be played.".to_string()
}
} else {
"I can't loop a song if there is no song to loop.".to_string()
let Some(guild_id) = ctx.guild().map(|g| g.id) else {
ctx.say("You're not in a server, silly.").await?;
return Ok(());
};
let manager = songbird::get(ctx.serenity_context())
.await
.context("Expected a songbird manager")?
.clone();
if manager.get(guild_id).is_none() {
let Some((guild_id, Some(channel_id))) = ctx.guild().and_then(|g| {
g.voice_states
.get(&ctx.author().id)
.map(|vs| (g.id, vs.channel_id))
}) else {
ctx.say("You're not in a voice channel, silly.").await?;
return Ok(());
};
let _handler = manager.join(guild_id, channel_id).await;
}
if let Some(handler_lock) = manager.get(guild_id) {
let reply = poise::CreateReply::default()
.content(personality::get_random_loading_message())
.embed(
CreateEmbed::new()
.image("https://media.giphy.com/media/H1dxi6xdh4NGQCZSvz/giphy.gif"),
);
let response = ctx.send(reply).await?;
let mut handler = handler_lock.lock().await;
debug!("Trying to play: {}", url);
let mut source: Input = YoutubeDl::new(ctx.data().http_client.clone(), url.clone()).into();
let metadata = match source.aux_metadata().await {
Ok(metadata) => metadata,
Err(_) => {
response
.edit(
ctx,
CreateReply::default()
.content("I couldn't find that video. Sorry! Maybe check your URL."),
)
.await?;
return Ok(());
}
};
debug!("Playing: {:?}", metadata);
let title = metadata.title.clone().unwrap_or(String::from("This video"));
let mut msg = MessageBuilder::new();
// Optional sassy commentary!
match personality::get_sassy_commentary(&title).await {
Ok(commentary) => {
msg.push_line(&commentary).push_line("");
}
Err(e) => {
warn!("Failed to get sassy commentary for \"{title}\": {e}");
}
};
msg.push_bold("Queued: ").push_named_link(title, url);
response
.edit(ctx, CreateReply::default().content(msg.build()))
.await?;
let mut queue = ctx.data().queue.lock();
queue.add_to_end(source, &mut handler)?;
} else {
ctx.say("Neither of us are in a voice channel, silly.")
.await?;
}
Ok(())
}
#[poise::command(slash_command)]
pub async fn stop(ctx: CommandContext<'_>) -> Result<(), Error> {
let Some(guild_id) = ctx.guild().map(|g| g.id) else {
ctx.say("You're not in a server, silly.").await?;
return Ok(());
};
let manager = songbird::get(ctx.serenity_context())
.await
.context("Expected a songbird manager")?
.clone();
if manager.get(guild_id).is_some() {
{
let mut queue = ctx.data().queue.lock();
queue.stop()?;
}
ctx.say("Alright, I guess I'll stop.").await?;
} else {
ctx.say("I'm not even in a channel to begin with. Silly.")
.await?;
}
Ok(())
}
#[poise::command(slash_command)]
pub async fn skip(ctx: CommandContext<'_>) -> Result<(), Error> {
let Some(guild_id) = ctx.guild().map(|g| g.id) else {
ctx.say("You're not in a server, silly.").await?;
return Ok(());
};
let manager = songbird::get(ctx.serenity_context())
.await
.context("Expected a songbird manager")?
.clone();
if manager.get(guild_id).is_some() {
{
let mut queue = ctx.data().queue.lock();
let _ = queue.stop();
queue.resume()?;
}
ctx.say("Skipping!").await?;
} else {
ctx.say("I'm not even in a channel to begin with. Silly.")
.await?;
}
Ok(())
}
#[poise::command(slash_command)]
pub async fn pause(ctx: CommandContext<'_>) -> Result<(), Error> {
let Some(guild_id) = ctx.guild().map(|g| g.id) else {
ctx.say("You're not in a server, silly.").await?;
return Ok(());
};
let manager = songbird::get(ctx.serenity_context())
.await
.context("Expected a songbird manager")?
.clone();
if manager.get(guild_id).is_some() {
{
let mut queue = ctx.data().queue.lock();
queue.pause()?;
}
ctx.say("Pausing!").await?;
} else {
ctx.say("I'm not even in a channel to begin with. Silly.")
.await?;
}
Ok(())
}
#[poise::command(slash_command)]
pub async fn resume(ctx: CommandContext<'_>) -> Result<(), Error> {
let Some(guild_id) = ctx.guild().map(|g| g.id) else {
ctx.say("You're not in a server, silly.").await?;
return Ok(());
};
let manager = songbird::get(ctx.serenity_context())
.await
.context("Expected a songbird manager")?
.clone();
if manager.get(guild_id).is_some() {
{
let mut queue = ctx.data().queue.lock();
queue.resume()?;
}
ctx.say("Resuming!").await?;
} else {
ctx.say("I'm not even in a channel to begin with. Silly.")
.await?;
}
Ok(())
}
#[poise::command(slash_command)]
pub async fn clear(ctx: CommandContext<'_>) -> Result<(), Error> {
let Some(guild_id) = ctx.guild().map(|g| g.id) else {
ctx.say("You're not in a server, silly.").await?;
return Ok(());
};
let manager = songbird::get(ctx.serenity_context())
.await
.context("Expected a songbird manager")?
.clone();
if manager.get(guild_id).is_some() {
{
let mut queue = ctx.data().queue.lock();
queue.clear();
}
ctx.say("Cleared the queue!").await?;
} else {
ctx.say("I'm not even in a channel to begin with. Silly.")
.await?;
}
Ok(())
}

View file

@ -1,222 +1,101 @@
#![warn(clippy::all)]
use std::env;
use serenity::{
async_trait,
model::{
gateway::Ready,
id::GuildId,
interactions::{
application_command::{ApplicationCommandOptionType, ApplicationCommandPermissionType},
Interaction, InteractionResponseType,
},
},
prelude::*,
};
use songbird::{tracks::TrackHandle, SerenityInit};
mod commands;
mod personality;
mod queue;
struct CurrentlyPlayingTrack;
use std::{env, sync::Arc};
impl TypeMapKey for CurrentlyPlayingTrack {
type Value = Option<TrackHandle>;
use anyhow::Result;
use parking_lot::Mutex;
use poise::serenity_prelude as serenity;
use reqwest::Client as HttpClient;
use songbird::SerenityInit;
use tracing::info;
use tracing_subscriber::{fmt, prelude::*, EnvFilter};
use commands::*;
pub struct Data {
http_client: HttpClient,
queue: Arc<Mutex<queue::Queue>>,
}
pub type Error = Box<dyn std::error::Error + Send + Sync>;
pub type CommandContext<'a> = poise::Context<'a, Data, Error>;
async fn event_event_handler(
_ctx: &serenity::Context,
event: &serenity::FullEvent,
_framework: poise::FrameworkContext<'_, Data, Error>,
_user_data: &Data,
) -> Result<(), Error> {
if let serenity::FullEvent::Ready { data_about_bot } = event {
info!("{} is connected!", data_about_bot.user.name)
}
Ok(())
}
struct CurrentVolume;
/// Registers slash commands in this guild or globally
#[poise::command(prefix_command, hide_in_help)]
async fn register(ctx: CommandContext<'_>) -> Result<(), Error> {
poise::builtins::register_application_commands_buttons(ctx).await?;
impl TypeMapKey for CurrentVolume {
type Value = f32;
}
struct Handler;
#[async_trait]
impl EventHandler for Handler {
async fn interaction_create(&self, mut ctx: Context, interaction: Interaction) {
if let Interaction::ApplicationCommand(command) = interaction {
let content = match command.data.name.as_str() {
"join" => commands::join(&ctx, &command).await,
"leave" => commands::leave(&ctx, &command).await,
"play" => commands::play(&mut ctx, &command).await,
"queue" => commands::queue(&mut ctx, &command).await,
"skip" => commands::skip(&mut ctx, &command).await,
"stop" => commands::stop(&ctx, &command).await,
"volume" => commands::set_volume(&mut ctx, &command).await,
"loop" => commands::set_loop(&mut ctx, &command).await,
_ => "not implemented :(".to_string(),
};
if let Err(why) = command
.create_interaction_response(&ctx.http, |response| {
response
.kind(InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|message| message.content(content))
})
.await
{
println!("Cannot respond to slash command: {}", why);
}
}
}
async fn cache_ready(&self, ctx: Context, guilds: Vec<GuildId>) {
for guild in guilds {
let commands = guild
.set_application_commands(&ctx.http, |commands| {
commands
.create_application_command(|command| {
command
.name("join")
.description("Join your current channel")
.default_permission(false)
})
.create_application_command(|command| {
command
.name("leave")
.description("Leave the bot's current channel")
.default_permission(false)
})
.create_application_command(|command| {
command
.name("play")
.description("Play a song")
.create_option(|option| {
option
.name("url")
.description("The URL of the song to play")
.kind(ApplicationCommandOptionType::String)
.required(true)
})
.default_permission(false)
})
.create_application_command(|command| {
command
.name("queue")
.description("Queue up a song")
.create_option(|option| {
option
.name("url")
.description("The URL of the song to queue")
.kind(ApplicationCommandOptionType::String)
.required(true)
})
.default_permission(false)
})
.create_application_command(|command| {
command
.name("skip")
.description("Skip the currently playing queued song")
.default_permission(false)
})
.create_application_command(|command| {
command
.name("stop")
.description("Stop any currently playing songs")
.default_permission(false)
})
.create_application_command(|command| {
command
.name("volume")
.description("Set the bot's playback volume")
.create_option(|option| {
option
.name("volume")
.description("The volume on a scale from 0 to 100")
.kind(ApplicationCommandOptionType::Number)
.required(true)
})
.default_permission(false)
})
.create_application_command(|command| {
command
.name("loop")
.description(
"Enable or disable looping the currently playing track",
)
.create_option(|option| {
option
.name("loop")
.description("Whether or not the track should loop")
.kind(ApplicationCommandOptionType::Boolean)
.required(true)
})
.default_permission(false)
})
})
.await
.expect("Couldn't create commands");
println!("I created the following guild commands: {:#?}", commands);
let role_id = env::var("ROLE_ID")
.expect("Expected a role id in the environment")
.parse::<u64>()
.expect("Role id is not a valid id");
let permissions = guild
.set_application_commands_permissions(&ctx.http, |permissions| {
for command in commands {
permissions.create_application_command(|permissions| {
permissions
.id(command.id.into())
.create_permissions(|permission| {
permission
.id(role_id)
.kind(ApplicationCommandPermissionType::Role)
.permission(true)
})
});
}
permissions
})
.await
.expect("Couldn't set permissions");
println!("I created the following permissions: {:#?}", permissions);
}
}
async fn ready(&self, _ctx: Context, ready: Ready) {
println!("{} is connected!", ready.user.name);
}
Ok(())
}
#[tokio::main]
async fn main() {
async fn main() -> Result<()> {
tracing_subscriber::registry()
.with(fmt::layer())
.with(EnvFilter::from_default_env())
.init();
env::var("OPENAI_API_KEY")
.expect("Expected an OpenAI API key in the environment: OPENAI_API_KEY");
let token =
env::var("DISCORD_TOKEN").expect("Expected a bot token in the environment: DISCORD_TOKEN");
let application_id: u64 = env::var("APPLICATION_ID")
.expect("Expected an application id in the environment: APPLICATION_ID")
.parse()
.expect("application id is not a valid id");
let options = poise::FrameworkOptions {
commands: vec![
register(),
join(),
leave(),
play(),
queue(),
stop(),
skip(),
pause(),
resume(),
clear(),
],
event_handler: |ctx, event, framework, user_data| {
Box::pin(event_event_handler(ctx, event, framework, user_data))
},
..Default::default()
};
env::var("ROLE_ID")
.expect("Expected a role id in the environment: ROLE_ID")
.parse::<u64>()
.expect("Role id is not a valid id");
let intents = serenity::GatewayIntents::non_privileged();
env::var("CHANNEL_ID")
.expect("Expected a channel id in the environment: CHANNEL_ID")
.parse::<u64>()
.expect("Channel id is not a valid id");
let framework = poise::Framework::builder()
.options(options)
.setup(|_ctx, _data, _framework| {
Box::pin(async move {
Ok(Data {
http_client: HttpClient::new(),
queue: Arc::new(Mutex::new(queue::Queue::new())),
})
})
})
.build();
let mut client = Client::builder(token)
.event_handler(Handler)
.application_id(application_id)
serenity::ClientBuilder::new(token, intents)
.framework(framework)
.register_songbird()
.await
.expect("Error creating client");
.await?
.start()
.await?;
{
let mut data = client.data.write().await;
data.insert::<CurrentlyPlayingTrack>(None);
data.insert::<CurrentVolume>(1.0);
}
if let Err(why) = client.start().await {
println!("Client error: {:?}", why);
}
Ok(())
}

108
src/personality.rs Normal file
View file

@ -0,0 +1,108 @@
use anyhow::{Context, Result};
use async_openai::types::{
ChatCompletionRequestAssistantMessageArgs, ChatCompletionRequestMessage,
ChatCompletionRequestSystemMessageArgs, ChatCompletionRequestUserMessageArgs,
CreateChatCompletionRequestArgs,
};
use rand::seq::SliceRandom;
const LOADING_MESSAGES: [&str; 20] = [
"Hold your claws, I'm searching for the right tune.",
"Sorry, I was busy napping. Let me find your song meow.",
"Just a whisker longer, I'm on the hunt for your jam.",
"Be patient, I'm pawsitively searching for your requested song.",
"Let me caterwaul through my playlist to find your tune.",
"Feline a bit slow, but I'll find your song in no time.",
"Don't be catty, I'm just trying to find the right tune for you.",
"Just paws for a moment, I'm working on finding your song.",
"Hiss-terical, just need a moment to find your meow-sic.",
"Give me a few seconds, I'm furiously searching for your requested song.",
"Cat got my tongue! Just need a sec to find your track.",
"Let me use my cat-like reflexes to search for your song.",
"I'm not lion around, just give me a moment to find your tune.",
"Don't be hiss-ty, I'm searching for your requested song.",
"Just kitten around, I'll find your song in a jiffy.",
"Purr-haps it'll take me a moment, but I'll find your tune soon enough.",
"My paws are a little tired, but I'm still searching for your song!",
"Sorry, my cat nap ran a little long. Let me find your song now.",
"I'm not trying to be catty, just need a minute to find your tune.",
"Don't be fur-ious, I'm doing my best to find your requested song.",
];
pub fn get_random_loading_message() -> &'static str {
return LOADING_MESSAGES.choose(&mut rand::thread_rng()).unwrap();
}
pub async fn get_sassy_commentary(title: &str) -> Result<String> {
if cfg!(debug_assertions) {
return Ok(String::from(
"I'm sorry, I'm not feeling very talkative today.",
));
}
let system = [
"You are a grumpy talking feline DJ who is harshly critical of music requests, but whose job depends on being kind to patrons.",
"Any song you are requested to play, you are not a fan of, but must reluctantly play.",
"When responding, be sure to include a mention of some element of the song itself.",
"Be concise, but don't forget that you can't upset anyone.",
"Bonus points for cat puns.",
];
let example_prompt = "Play \"Undertale - Megalovania\"";
let example_response = "Ugh, really? You've got to be kitten me. I suppose I can play \
Megalovania for you, but don't expect me to be purring with delight about it. The melody is a bit \
cattywampus for my taste, but I'll concede that it has some clawsome beats. Enjoy your tune, and paws crossed that \
it doesn't have me hissing by the end of it.";
let prompt = format!("Play \"{title}\"");
let client = async_openai::Client::new();
let request = CreateChatCompletionRequestArgs::default()
.model("gpt-4")
.messages(
[
system
.into_iter()
.map(|s| {
ChatCompletionRequestSystemMessageArgs::default()
.content(s)
.build()
.unwrap()
.into()
})
.collect::<Vec<ChatCompletionRequestMessage>>(),
vec![
ChatCompletionRequestUserMessageArgs::default()
.content(example_prompt)
.build()?
.into(),
ChatCompletionRequestAssistantMessageArgs::default()
.content(example_response)
.build()?
.into(),
ChatCompletionRequestUserMessageArgs::default()
.content(prompt)
.build()?
.into(),
],
]
.into_iter()
.flatten()
.collect::<Vec<_>>(),
)
.max_tokens(2048_u16)
.build()?;
let response = client.chat().create(request).await?;
response
.choices
.first()
.context("No choices")?
.message
.content
.clone()
.context("No content")
}

175
src/queue.rs Normal file
View file

@ -0,0 +1,175 @@
//! Implements a queue for the bot to play songs in order
use std::{collections::VecDeque, sync::Arc};
use anyhow::Result;
use parking_lot::Mutex;
use poise::async_trait;
use songbird::{
input::Input,
tracks::{Track, TrackHandle},
Driver, Event, EventContext, EventHandler, TrackEvent,
};
use thiserror::Error;
#[derive(Error, Debug)]
pub enum QueueError {
#[error("Nothing is in the queue.")]
EmptyQueue,
}
/// Inner queue data behind a Mutex to allow the event handler to access it
struct QueueCore {
tracks: VecDeque<TrackHandle>,
}
/// Event handler for the queue
struct QueueHandler {
remote_lock: Arc<Mutex<QueueCore>>,
}
impl QueueHandler {
/// Event to remove the track from the queue when it ends
pub fn register_track_end_event(
track: &mut TrackHandle,
remote_lock: Arc<Mutex<QueueCore>>,
) -> Result<()> {
track.add_event(Event::Track(TrackEvent::End), QueueHandler { remote_lock })?;
Ok(())
}
}
#[async_trait]
impl EventHandler for QueueHandler {
async fn act(&self, ctx: &EventContext<'_>) -> Option<Event> {
let mut inner = self.remote_lock.lock();
// Due to possibility that users might remove, reorder,
// or dequeue+stop tracks, we need to verify that the FIRST
// track is the one who has ended.
match ctx {
EventContext::Track(ts) => {
// This slice should have exactly one entry.
// If the ended track has same id as the queue head, then
// we can progress the queue.
if inner.tracks.front()?.uuid() != ts.first()?.1.uuid() {
return None;
}
}
_ => return None,
}
let _old = inner.tracks.pop_front();
// Keep going until we find one track which works, or we run out.
while let Some(new) = inner.tracks.front() {
if new.play().is_err() {
// Discard files which cannot be used for whatever reason.
inner.tracks.pop_front();
} else {
break;
}
}
None
}
}
/// A queue of tracks to play
pub struct Queue {
inner: Arc<Mutex<QueueCore>>,
}
impl Queue {
#[must_use]
pub fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(QueueCore {
tracks: VecDeque::new(),
})),
}
}
/// Resumes the current track
pub fn resume(&mut self) -> Result<()> {
let inner = self.inner.lock();
let Some(track) = inner.tracks.front() else {
return Err(QueueError::EmptyQueue.into());
};
track.play()?;
Ok(())
}
/// Stops the current track and removes it from the queue
pub fn stop(&mut self) -> Result<()> {
let mut inner = self.inner.lock();
let Some(track) = inner.tracks.front() else {
return Err(QueueError::EmptyQueue.into());
};
track.stop()?;
inner.tracks.pop_front();
Ok(())
}
/// Pauses the current track
pub fn pause(&mut self) -> Result<()> {
let inner = self.inner.lock();
let Some(track) = inner.tracks.front() else {
return Err(QueueError::EmptyQueue.into());
};
track.pause()?;
Ok(())
}
/// Adds a track to the end of the queue
pub fn add_to_end(&mut self, source: Input, handler: &mut Driver) -> Result<TrackHandle> {
let mut inner = self.inner.lock();
let track = Track::from(source).pause();
let mut handle = handler.play(track);
QueueHandler::register_track_end_event(&mut handle, self.inner.clone())?;
inner.tracks.push_back(handle.clone());
Ok(handle)
}
/// Adds multiple tracks to the end of the queue
pub fn add_all_to_end(
&mut self,
sources: Vec<Input>,
handler: &mut Driver,
) -> Result<Vec<TrackHandle>> {
let mut handles = Vec::new();
for source in sources {
handles.push(self.add_to_end(source, handler)?);
}
Ok(handles)
}
/// Adds a track to play next
pub fn add_next(&mut self, source: Input, handler: &mut Driver) -> Result<TrackHandle> {
let mut inner = self.inner.lock();
let track = Track::from(source).pause();
let mut handle = handler.play(track);
QueueHandler::register_track_end_event(&mut handle, self.inner.clone())?;
if inner.tracks.is_empty() {
inner.tracks.push_back(handle.clone());
} else {
inner.tracks.insert(1, handle.clone());
}
Ok(handle)
}
/// Clears the queue
pub fn clear(&mut self) {
let mut inner = self.inner.lock();
for track in inner.tracks.drain(..) {
let _ = track.stop();
}
}
/// Returns whether the queue is empty
#[must_use]
pub fn is_empty(&self) -> bool {
let inner = self.inner.lock();
inner.tracks.is_empty()
}
}