Initial commit
This commit is contained in:
commit
6bcedfd0cd
7 changed files with 420 additions and 0 deletions
5
.dockerignore
Normal file
5
.dockerignore
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
target
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
.git
|
||||||
|
.gitignore
|
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
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
|
||||||
|
|
||||||
|
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||||
|
*.pdb
|
||||||
|
|
||||||
|
.vscode/
|
20
Cargo.toml
Normal file
20
Cargo.toml
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
[package]
|
||||||
|
name = "dj-kitty-cat"
|
||||||
|
version = "0.1.0"
|
||||||
|
authors = ["my name <my@email.address>"]
|
||||||
|
edition = "2018"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = "0.2"
|
||||||
|
tracing-futures = "0.2"
|
||||||
|
songbird = "0.2"
|
||||||
|
|
||||||
|
[dependencies.serenity]
|
||||||
|
version = "0.10"
|
||||||
|
default-features = false
|
||||||
|
features = ["cache", "client", "gateway", "model", "voice", "rustls_backend", "unstable_discord_api"]
|
||||||
|
|
||||||
|
[dependencies.tokio]
|
||||||
|
version = "1.0"
|
||||||
|
features = ["macros", "rt-multi-thread", "signal"]
|
10
Dockerfile
Normal file
10
Dockerfile
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
FROM rust:1.55-alpine3.14 as builder
|
||||||
|
RUN apk add --no-cache musl-dev opus-dev
|
||||||
|
WORKDIR /usr/src/myapp
|
||||||
|
COPY . .
|
||||||
|
RUN cargo install --path .
|
||||||
|
|
||||||
|
FROM alpine:3.14
|
||||||
|
RUN apk add --no-cache ffmpeg youtube-dl
|
||||||
|
COPY --from=builder /usr/local/cargo/bin/dj-kitty-cat /usr/local/bin/dj-kitty-cat
|
||||||
|
CMD ["dj-kitty-cat"]
|
5
README.md
Normal file
5
README.md
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
# DJ Kitty Cat
|
||||||
|
|
||||||
|
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).
|
179
src/commands.rs
Normal file
179
src/commands.rs
Normal file
|
@ -0,0 +1,179 @@
|
||||||
|
use serenity::client::Context;
|
||||||
|
|
||||||
|
use serenity::model::interactions::application_command::{
|
||||||
|
ApplicationCommandInteraction, ApplicationCommandInteractionDataOptionValue,
|
||||||
|
};
|
||||||
|
use serenity::utils::{EmbedMessageBuilding, MessageBuilder};
|
||||||
|
use songbird::create_player;
|
||||||
|
|
||||||
|
use crate::{CurrentVolume, CurrentlyPlayingTrack};
|
||||||
|
|
||||||
|
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".to_string();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let manager = songbird::get(ctx)
|
||||||
|
.await
|
||||||
|
.expect("Songbird Voice client placed in at initialisation.")
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
let _handler = manager.join(guild_id, connect_to).await;
|
||||||
|
|
||||||
|
"Joining your channel".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
"Left voice channel".to_string()
|
||||||
|
} else {
|
||||||
|
"I'm not in a voice channel".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 "Must provide a URL to a video or audio".to_string();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !url.starts_with("http") {
|
||||||
|
return "Must provide a valid URL".to_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;
|
||||||
|
|
||||||
|
let source = match songbird::ytdl(&url).await {
|
||||||
|
Ok(source) => source,
|
||||||
|
Err(why) => {
|
||||||
|
println!("Err starting source: {:?}", why);
|
||||||
|
|
||||||
|
return "Error sourcing ffmpeg".to_string();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let message = {
|
||||||
|
if let Some(title) = &source.metadata.title {
|
||||||
|
let mut msg = MessageBuilder::new();
|
||||||
|
msg.push_line("Playing song:");
|
||||||
|
msg.push_named_link(title, url);
|
||||||
|
msg.build()
|
||||||
|
} else {
|
||||||
|
"Playing song".to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (mut audio, track_handle) = create_player(source);
|
||||||
|
|
||||||
|
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 {
|
||||||
|
"Not in a voice channel to play in".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();
|
||||||
|
|
||||||
|
"Stopping song".to_string()
|
||||||
|
} else {
|
||||||
|
"Not in a voice channel to play in".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 "Must provide a volume level".to_string();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !(0.0..=100.0).contains(&volume) {
|
||||||
|
return "Must provide a value 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 {
|
||||||
|
track.set_volume(new_volume).unwrap();
|
||||||
|
format!("Setting volume to {}%", volume)
|
||||||
|
} else {
|
||||||
|
"No track is currently playing".to_string()
|
||||||
|
}
|
||||||
|
}
|
185
src/main.rs
Normal file
185
src/main.rs
Normal file
|
@ -0,0 +1,185 @@
|
||||||
|
#![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;
|
||||||
|
|
||||||
|
struct CurrentlyPlayingTrack;
|
||||||
|
|
||||||
|
impl TypeMapKey for CurrentlyPlayingTrack {
|
||||||
|
type Value = Option<TrackHandle>;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct CurrentVolume;
|
||||||
|
|
||||||
|
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,
|
||||||
|
"stop" => commands::stop(&ctx, &command).await,
|
||||||
|
"volume" => commands::set_volume(&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("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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
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");
|
||||||
|
|
||||||
|
env::var("ROLE_ID")
|
||||||
|
.expect("Expected a role id in the environment: ROLE_ID")
|
||||||
|
.parse::<u64>()
|
||||||
|
.expect("Role id is not a valid id");
|
||||||
|
|
||||||
|
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 mut client = Client::builder(token)
|
||||||
|
.event_handler(Handler)
|
||||||
|
.application_id(application_id)
|
||||||
|
.register_songbird()
|
||||||
|
.await
|
||||||
|
.expect("Error creating client");
|
||||||
|
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Add table
Reference in a new issue