use poise to register slash commands, separate files more
This commit is contained in:
71
Cargo.lock
generated
71
Cargo.lock
generated
@@ -380,6 +380,41 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"darling_macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e"
|
||||
dependencies = [
|
||||
"darling_core",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "5.4.0"
|
||||
@@ -429,6 +464,7 @@ dependencies = [
|
||||
"clap",
|
||||
"dotenv",
|
||||
"log",
|
||||
"poise",
|
||||
"reqwest",
|
||||
"serenity",
|
||||
"simple_logger",
|
||||
@@ -814,6 +850,12 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ident_case"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "0.4.0"
|
||||
@@ -1216,6 +1258,35 @@ dependencies = [
|
||||
"pnet_base",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "poise"
|
||||
version = "0.4.1"
|
||||
source = "git+https://github.com/serenity-rs/poise?branch=serenity-next#50ddbbb85a66068e3de33a9ba3c6ff72e62596b6"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"derivative",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"log",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"poise_macros",
|
||||
"regex",
|
||||
"serenity",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "poise_macros"
|
||||
version = "0.4.0"
|
||||
source = "git+https://github.com/serenity-rs/poise?branch=serenity-next#50ddbbb85a66068e3de33a9ba3c6ff72e62596b6"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "poly1305"
|
||||
version = "0.8.0"
|
||||
|
||||
@@ -28,7 +28,7 @@ features = [ "rustls-tls" ]
|
||||
version = "0.11"
|
||||
git = "https://github.com/serenity-rs/serenity"
|
||||
branch = "next"
|
||||
features = [ "client", "standard_framework", "voice", "rustls_backend" ]
|
||||
features = [ "client", "voice", "rustls_backend" ]
|
||||
|
||||
[dependencies.songbird]
|
||||
version = "0.3"
|
||||
@@ -36,6 +36,11 @@ git = "https://github.com/serenity-rs/songbird"
|
||||
branch = "next"
|
||||
features = [ "builtin-queue" ]
|
||||
|
||||
[dependencies.poise]
|
||||
version = "0.4"
|
||||
git = "https://github.com/serenity-rs/poise"
|
||||
branch = "serenity-next"
|
||||
|
||||
[dependencies.symphonia]
|
||||
version = "0.5"
|
||||
features = ["aac", "mp3"]
|
||||
|
||||
@@ -16,6 +16,7 @@ in rustPlatform.buildRustPackage {
|
||||
"sunk-0.1.2" = "sha256-gxDinyzKHxph2D5OZ9TevVEWtsEnnFpu4BfkkBnRMx4=";
|
||||
"serenity-0.11.5" = "sha256-10s0kflNYEMwUXAgrh6d1IUk3ZRSCkAilz9m1lVhXhA=";
|
||||
"songbird-0.3.2" = "sha256-8wzCcV9W6K0MHqZ8yhTIMjh165NV8OQ9zlgrRrIhlOI=";
|
||||
"poise-0.4.1" = "sha256-MPfg3miu9tesGKChaR8vz8RZA45I5uJs60MtgxDJryw=";
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
use crate::handles::SubsonicClientHandle;
|
||||
use crate::discord;
|
||||
use anyhow::{Context as ErrContext, Result};
|
||||
use clap::Parser;
|
||||
use log::LevelFilter;
|
||||
use serenity::{
|
||||
client::Client as DiscordClient, framework::standard::StandardFramework,
|
||||
prelude::GatewayIntents,
|
||||
};
|
||||
use serenity::client::Client as DiscordClient;
|
||||
use simple_logger::SimpleLogger;
|
||||
use songbird::SerenityInit;
|
||||
use sunk::Client as SubsonicClient;
|
||||
|
||||
use crate::discord::{after_hook, Handler, GENERAL_GROUP};
|
||||
|
||||
#[derive(Parser, Clone)]
|
||||
pub struct Config {
|
||||
#[clap(long, env = "DISCONIC_SUBSONIC_URL")]
|
||||
@@ -20,6 +14,8 @@ pub struct Config {
|
||||
subsonic_user: String,
|
||||
#[clap(long, env = "DISCONIC_SUBSONIC_PASSWORD")]
|
||||
subsonic_password: String,
|
||||
#[clap(long, env = "DISCONIC_DISCORD_GUILD")]
|
||||
discord_guild: Option<u64>,
|
||||
#[clap(long, env = "DISCONIC_DISCORD_TOKEN")]
|
||||
discord_token: String,
|
||||
#[clap(long, env = "DISCONIC_LOG_LEVEL", default_value = "warn")]
|
||||
@@ -27,22 +23,8 @@ pub struct Config {
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub async fn discord(&self, ss: SubsonicClient) -> Result<DiscordClient> {
|
||||
let intents = GatewayIntents::non_privileged() | GatewayIntents::MESSAGE_CONTENT;
|
||||
let framework = StandardFramework::new()
|
||||
.group(&GENERAL_GROUP)
|
||||
.after(after_hook);
|
||||
|
||||
framework.configure(|c| c.prefix("~"));
|
||||
|
||||
let client = DiscordClient::builder(&self.discord_token, intents)
|
||||
.event_handler(Handler)
|
||||
.framework(framework)
|
||||
.type_map_insert::<SubsonicClientHandle>(ss)
|
||||
.register_songbird()
|
||||
.await?;
|
||||
|
||||
Ok(client)
|
||||
pub async fn discord(&self, subsonic_client: SubsonicClient) -> Result<DiscordClient> {
|
||||
discord::create_client(&self.discord_token, self.discord_guild, subsonic_client).await
|
||||
}
|
||||
|
||||
pub async fn subsonic(&self) -> Result<SubsonicClient> {
|
||||
|
||||
409
src/discord.rs
409
src/discord.rs
@@ -1,409 +0,0 @@
|
||||
use anyhow::{anyhow, Context as ErrContext, Result};
|
||||
use serenity::{
|
||||
all::{ChannelId, GuildId},
|
||||
async_trait,
|
||||
client::{Context, EventHandler},
|
||||
framework::standard::{
|
||||
macros::{command, group, hook},
|
||||
Args, CommandResult,
|
||||
},
|
||||
model::{channel::Message, gateway::Ready},
|
||||
utils::MessageBuilder,
|
||||
};
|
||||
use songbird::{
|
||||
input::HttpRequest,
|
||||
tracks::{Track, TrackHandle},
|
||||
Songbird,
|
||||
};
|
||||
use sunk::{
|
||||
search::{self, SearchPage},
|
||||
song::Song,
|
||||
Streamable,
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::handles::{SubsonicClientHandle, SubsonicSongHandle};
|
||||
|
||||
#[group]
|
||||
#[commands(
|
||||
song, random, skip, stop, pause, resume, queue, nowplaying, remove, album, join, leave
|
||||
)]
|
||||
pub struct General;
|
||||
|
||||
pub struct Handler;
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for Handler {
|
||||
async fn ready(&self, _: Context, ready: Ready) {
|
||||
log::info!("{} is connected!", ready.user.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[hook]
|
||||
pub async fn after_hook(ctx: &Context, msg: &Message, cmd_name: &str, error: CommandResult) {
|
||||
if let Err(why) = error {
|
||||
msg.reply(&ctx.http, format!("{:?}", why)).await.ok();
|
||||
eprintln!("{}: {:?}", cmd_name, why,);
|
||||
}
|
||||
}
|
||||
|
||||
#[command]
|
||||
#[only_in(guilds)]
|
||||
async fn leave(ctx: &Context, msg: &Message) -> CommandResult {
|
||||
let guild = get_guild(ctx, msg)?;
|
||||
let manager = get_manager(ctx).await?;
|
||||
manager.remove(guild).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
#[only_in(guilds)]
|
||||
async fn join(ctx: &Context, msg: &Message) -> CommandResult {
|
||||
let guild = get_guild(ctx, msg)?;
|
||||
let channel = get_channel(ctx, msg)?;
|
||||
let manager = get_manager(ctx).await?;
|
||||
let _handler = manager.join(guild, channel).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
#[only_in(guilds)]
|
||||
#[aliases(s, p, play)]
|
||||
/// Play a named song
|
||||
async fn song(ctx: &Context, msg: &Message, args: Args) -> CommandResult {
|
||||
let data = ctx.data.read().await;
|
||||
let music_client = data
|
||||
.get::<SubsonicClientHandle>()
|
||||
.expect("Couldn't retrieve music client");
|
||||
|
||||
let search_size = SearchPage::new().with_size(1);
|
||||
let ignore = search::NONE;
|
||||
|
||||
let result = music_client
|
||||
.search(args.rest(), ignore, ignore, search_size)
|
||||
.await
|
||||
.with_context(|| anyhow!("Could not search for song"))?
|
||||
.songs;
|
||||
|
||||
let song = result
|
||||
.first()
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| anyhow!("No song matching search found"))?;
|
||||
|
||||
log::info!("Found song {song:?}");
|
||||
queue_song(ctx, msg, &song, music_client).await?;
|
||||
|
||||
let message = format!(
|
||||
"Added **{} - {}** to the queue",
|
||||
song.title,
|
||||
song.artist.as_deref().unwrap_or_default(),
|
||||
);
|
||||
msg.reply(&ctx.http, &MessageBuilder::new().push(&message).build())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
#[only_in(guilds)]
|
||||
#[aliases(a, album)]
|
||||
/// Play a named album
|
||||
async fn album(ctx: &Context, msg: &Message, args: Args) -> CommandResult {
|
||||
let data = ctx.data.read().await;
|
||||
let music_client = data
|
||||
.get::<SubsonicClientHandle>()
|
||||
.expect("Couldn't retrieve music client");
|
||||
|
||||
let search_size = SearchPage::new().with_size(1);
|
||||
let ignore = search::NONE;
|
||||
|
||||
let result = music_client
|
||||
.search(args.rest(), ignore, search_size, ignore)
|
||||
.await?
|
||||
.albums;
|
||||
|
||||
let album = result
|
||||
.first()
|
||||
.ok_or_else(|| anyhow!("No albums matching search found"))?;
|
||||
|
||||
let songs = album.songs(music_client).await?;
|
||||
for song in songs.iter() {
|
||||
queue_song(ctx, msg, &song, music_client).await?;
|
||||
}
|
||||
|
||||
let message = format!(
|
||||
"Added album **{} - {}** ({} songs) to the queue",
|
||||
album.name,
|
||||
album.artist.as_deref().unwrap_or_default(),
|
||||
songs.len(),
|
||||
);
|
||||
msg.reply(&ctx.http, &MessageBuilder::new().push(&message).build())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
#[only_in(guilds)]
|
||||
#[aliases(r, rand)]
|
||||
/// Play a random song
|
||||
async fn random(ctx: &Context, msg: &Message) -> CommandResult {
|
||||
let data = ctx.data.read().await;
|
||||
let music_client = data
|
||||
.get::<SubsonicClientHandle>()
|
||||
.expect("Couldn't retrieve music client");
|
||||
|
||||
let result = Song::random(music_client, 1).await?;
|
||||
|
||||
let song: Song = result
|
||||
.first()
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| anyhow!("No song matching search found"))?;
|
||||
queue_song(ctx, msg, &song, music_client).await?;
|
||||
|
||||
let message = format!(
|
||||
"Added **{} - {}** to the queue",
|
||||
song.title,
|
||||
song.artist.as_deref().unwrap_or_default(),
|
||||
);
|
||||
msg.reply(&ctx.http, &MessageBuilder::new().push(&message).build())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
#[only_in(guilds)]
|
||||
/// Skip song(s)
|
||||
async fn skip(ctx: &Context, msg: &Message, args: Args) -> CommandResult {
|
||||
let call = get_call(ctx, msg).await?;
|
||||
let handler = call.lock().await;
|
||||
let n = args.rest().parse().unwrap_or(1);
|
||||
|
||||
let queue = handler.queue();
|
||||
for _ in 0..(n - 1) {
|
||||
queue.dequeue(1).ok_or_else(|| anyhow!("Song not found"))?;
|
||||
}
|
||||
queue.skip()?;
|
||||
|
||||
msg.reply(&ctx.http, &format!("{n} song(s) skipped"))
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
#[only_in(guilds)]
|
||||
/// Clear queue and stop playing
|
||||
async fn stop(ctx: &Context, msg: &Message) -> CommandResult {
|
||||
let call = get_call(ctx, msg).await?;
|
||||
let handler = call.lock().await;
|
||||
|
||||
let queue = handler.queue();
|
||||
queue.stop();
|
||||
|
||||
msg.reply(&ctx.http, "Stopped playing").await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
#[only_in(guilds)]
|
||||
/// Pause playing current song
|
||||
async fn pause(ctx: &Context, msg: &Message) -> CommandResult {
|
||||
let call = get_call(ctx, msg).await?;
|
||||
let handler = call.lock().await;
|
||||
|
||||
let queue = handler.queue();
|
||||
let current = queue
|
||||
.current()
|
||||
.ok_or_else(|| anyhow!("Not currently playing"))?;
|
||||
current.pause()?;
|
||||
|
||||
msg.reply(&ctx.http, "Paused playing").await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
#[only_in(guilds)]
|
||||
#[aliases(resume)]
|
||||
/// Resume playing current song
|
||||
async fn resume(ctx: &Context, msg: &Message) -> CommandResult {
|
||||
let call = get_call(ctx, msg).await?;
|
||||
let handler = call.lock().await;
|
||||
|
||||
let queue = handler.queue();
|
||||
queue.resume()?;
|
||||
|
||||
msg.reply(&ctx.http, "Resumed playing").await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
#[only_in(guilds)]
|
||||
#[aliases(nowplaying, now, np, playing)]
|
||||
/// Show currently playing song
|
||||
async fn nowplaying(ctx: &Context, msg: &Message) -> CommandResult {
|
||||
let call = get_call(ctx, msg).await?;
|
||||
let handler = call.lock().await;
|
||||
|
||||
let queue = handler.queue();
|
||||
let current = queue
|
||||
.current()
|
||||
.ok_or_else(|| anyhow!("Not currently playing"))?;
|
||||
|
||||
let song = get_song(¤t).await?;
|
||||
let text = format!(
|
||||
"**Currently playing**: {} - {}",
|
||||
song.title,
|
||||
song.artist.as_deref().unwrap_or_default()
|
||||
);
|
||||
msg.reply(&ctx.http, text).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
#[only_in(guilds)]
|
||||
#[aliases(q)]
|
||||
/// Show song queue
|
||||
async fn queue(ctx: &Context, msg: &Message) -> CommandResult {
|
||||
let call = get_call(ctx, msg).await?;
|
||||
let handler = call.lock().await;
|
||||
|
||||
let current_queue = handler.queue().current_queue();
|
||||
|
||||
let text = if current_queue.is_empty() {
|
||||
"No songs queued".into()
|
||||
} else {
|
||||
let mut text = String::new();
|
||||
let mut queue = current_queue.iter().enumerate();
|
||||
|
||||
let (_, track) = queue.next().unwrap();
|
||||
let song = get_song(track).await?;
|
||||
text.push_str(&format!(
|
||||
"**Currently playing**: {} - {}\n\n",
|
||||
song.title,
|
||||
song.artist.as_deref().unwrap_or_default()
|
||||
));
|
||||
|
||||
text.push_str("**Next songs in queue**:\n");
|
||||
for (i, track) in queue {
|
||||
let song = get_song(track).await?;
|
||||
let song_text = format!(
|
||||
"**{}.** {} - {}",
|
||||
i,
|
||||
song.title,
|
||||
song.artist.as_deref().unwrap_or_default()
|
||||
);
|
||||
text.push_str(&song_text);
|
||||
text.push('\n');
|
||||
}
|
||||
text
|
||||
};
|
||||
|
||||
msg.reply(&ctx.http, text).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[command]
|
||||
#[only_in(guilds)]
|
||||
/// Remove song from queue, given id
|
||||
async fn remove(ctx: &Context, msg: &Message, mut args: Args) -> CommandResult {
|
||||
let call = get_call(ctx, msg).await?;
|
||||
let handler = call.lock().await;
|
||||
|
||||
let index: usize = args.single()?;
|
||||
|
||||
let queue = handler.queue();
|
||||
let track = queue
|
||||
.dequeue(index - 1)
|
||||
.ok_or_else(|| anyhow!("Song not found"))?;
|
||||
let song = get_song(&track).await?;
|
||||
let text = format!("Removed track: {}", song.title);
|
||||
msg.reply(&ctx.http, text).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ==========================
|
||||
// ==========================
|
||||
// ==========================
|
||||
|
||||
async fn queue_song(
|
||||
ctx: &Context,
|
||||
msg: &Message,
|
||||
song: &Song,
|
||||
client: &sunk::Client,
|
||||
) -> Result<()> {
|
||||
let call = get_call(ctx, msg).await?;
|
||||
let mut handler = call.lock().await;
|
||||
|
||||
let track = load_song(song, client).await?;
|
||||
let track_handle = handler.enqueue(track).await;
|
||||
let mut type_map = track_handle.typemap().write().await;
|
||||
type_map.insert::<SubsonicSongHandle>(song.clone());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_guild(ctx: &Context, msg: &Message) -> Result<GuildId> {
|
||||
let guild = msg.guild(&ctx.cache).unwrap();
|
||||
Ok(guild.id)
|
||||
}
|
||||
|
||||
fn get_channel(ctx: &Context, msg: &Message) -> Result<ChannelId> {
|
||||
let guild = msg.guild(&ctx.cache).unwrap();
|
||||
let channel_id = guild
|
||||
.voice_states
|
||||
.get(&msg.author.id)
|
||||
.and_then(|voice_state| voice_state.channel_id)
|
||||
.ok_or_else(|| anyhow!("You must be in a voice channel to use this command"))?;
|
||||
|
||||
Ok(channel_id)
|
||||
}
|
||||
|
||||
async fn get_call(ctx: &Context, msg: &Message) -> Result<Arc<Mutex<songbird::Call>>> {
|
||||
let manager = get_manager(ctx).await?;
|
||||
let guild = get_guild(ctx, msg)?;
|
||||
let call = manager.get(guild);
|
||||
|
||||
if let Some(c) = call {
|
||||
Ok(c)
|
||||
} else {
|
||||
let channel = get_channel(ctx, msg)?;
|
||||
log::warn!("Not in a voice channel, trying to join");
|
||||
let _handler = manager.join(guild, channel).await;
|
||||
manager
|
||||
.get(guild)
|
||||
.ok_or_else(|| anyhow!("Not in a voice channel, try running 'join'"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_song(track: &TrackHandle) -> Result<Song> {
|
||||
let song = track
|
||||
.typemap()
|
||||
.read()
|
||||
.await
|
||||
.get::<SubsonicSongHandle>()
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| anyhow!("Sound information not found"))?;
|
||||
Ok(song)
|
||||
}
|
||||
|
||||
async fn get_manager(ctx: &Context) -> Result<Arc<Songbird>> {
|
||||
let manager = songbird::get(ctx)
|
||||
.await
|
||||
.ok_or_else(|| anyhow!("Couldn't start manager"))?;
|
||||
Ok(manager)
|
||||
}
|
||||
|
||||
async fn load_song(song: &Song, client: &sunk::Client) -> Result<Track> {
|
||||
log::info!("Loading song {song:?}");
|
||||
let url = song.stream_url(client)?;
|
||||
let track: Track = HttpRequest::new(client.reqclient.clone(), url).into();
|
||||
Ok(track)
|
||||
}
|
||||
75
src/discord/client.rs
Normal file
75
src/discord/client.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use crate::discord::{commands, Data};
|
||||
use anyhow::Result;
|
||||
use poise::samples::create_application_commands;
|
||||
use serenity::{all::GuildId, client::Client as DiscordClient, prelude::GatewayIntents};
|
||||
use songbird::SerenityInit;
|
||||
use sunk::Client as SubsonicClient;
|
||||
|
||||
async fn on_error(error: poise::FrameworkError<'_, Data, anyhow::Error>) {
|
||||
let context = error.ctx();
|
||||
let message = match error {
|
||||
poise::FrameworkError::Command { error, ctx } => {
|
||||
format!(
|
||||
"Error while running `{}`: **{:?}**",
|
||||
ctx.command().name,
|
||||
error
|
||||
)
|
||||
}
|
||||
poise::FrameworkError::Listener { error, event, .. } => {
|
||||
format!(
|
||||
"Error on event `{:?}`: **{:?}**",
|
||||
event.snake_case_name(),
|
||||
error
|
||||
)
|
||||
}
|
||||
error => {
|
||||
if let Err(e) = poise::builtins::on_error(error).await {
|
||||
format!("Error: **{}**", e)
|
||||
} else {
|
||||
format!("Error: **unknown**")
|
||||
}
|
||||
}
|
||||
};
|
||||
log::error!("{message}");
|
||||
if let Some(ctx) = context {
|
||||
ctx.say(message).await.ok();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_client(
|
||||
token: &str,
|
||||
guild_id: Option<u64>,
|
||||
subsonic_client: SubsonicClient,
|
||||
) -> Result<DiscordClient> {
|
||||
let intents = GatewayIntents::non_privileged() | GatewayIntents::MESSAGE_CONTENT;
|
||||
let commands = commands::commands();
|
||||
let data = Data { subsonic_client };
|
||||
|
||||
let create_commands = create_application_commands(&commands);
|
||||
let options = poise::FrameworkOptions {
|
||||
commands,
|
||||
on_error: |e| Box::pin(on_error(e)),
|
||||
..Default::default()
|
||||
};
|
||||
let framework = poise::Framework::new(options, move |ctx, _ready, _framework| {
|
||||
Box::pin(async move {
|
||||
if let Some(id) = guild_id {
|
||||
let guild = GuildId::new(id);
|
||||
guild
|
||||
.set_commands(&ctx.http, create_commands)
|
||||
.await
|
||||
.expect("Failed to register command");
|
||||
} else {
|
||||
log::warn!("Guild ID not configured. You'll have to run 'register' to register the slash commands.")
|
||||
}
|
||||
log::info!("Registered commands");
|
||||
Ok(data)
|
||||
})
|
||||
});
|
||||
|
||||
let client = DiscordClient::builder(token, intents)
|
||||
.framework(framework)
|
||||
.register_songbird()
|
||||
.await?;
|
||||
Ok(client)
|
||||
}
|
||||
41
src/discord/commands/album.rs
Normal file
41
src/discord/commands/album.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use poise::command;
|
||||
use sunk::search;
|
||||
|
||||
use crate::discord::{common::queue_song, Context};
|
||||
|
||||
/// Search for an album, and queue all its songs
|
||||
#[command(slash_command, prefix_command)]
|
||||
pub async fn album(
|
||||
ctx: Context<'_>,
|
||||
#[description = "What to search for"] query: String,
|
||||
) -> Result<()> {
|
||||
let music_client = &ctx.data().subsonic_client;
|
||||
|
||||
let search_size = search::SearchPage::new().with_size(1);
|
||||
let ignore = search::NONE;
|
||||
|
||||
let result = music_client
|
||||
.search(&query, ignore, search_size, ignore)
|
||||
.await?
|
||||
.albums;
|
||||
|
||||
let album = result
|
||||
.first()
|
||||
.ok_or_else(|| anyhow!("No albums matching search found"))?;
|
||||
|
||||
let songs = album.songs(&music_client).await?;
|
||||
for song in songs.iter() {
|
||||
queue_song(ctx, &song, &music_client).await?;
|
||||
}
|
||||
|
||||
let message = format!(
|
||||
"Added album **{} - {}** ({} songs) to the queue",
|
||||
album.name,
|
||||
album.artist.as_deref().unwrap_or_default(),
|
||||
songs.len(),
|
||||
);
|
||||
ctx.say(message).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
20
src/discord/commands/join.rs
Normal file
20
src/discord/commands/join.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use anyhow::Result;
|
||||
use poise::command;
|
||||
|
||||
use crate::discord::{
|
||||
common::{get_channel, get_guild, get_manager},
|
||||
Context,
|
||||
};
|
||||
|
||||
/// Join the voice chat where the caller is
|
||||
#[command(slash_command, prefix_command)]
|
||||
pub async fn join(ctx: Context<'_>) -> Result<()> {
|
||||
let guild = get_guild(ctx);
|
||||
let channel = get_channel(ctx)?;
|
||||
let manager = get_manager(ctx).await?;
|
||||
log::info!("Got manager: {manager:?}");
|
||||
let call = manager.join(guild.id, channel).await?;
|
||||
log::info!("Got call: {call:?}");
|
||||
ctx.say("Hi! Try '/song' or '/album' to start").await?;
|
||||
Ok(())
|
||||
}
|
||||
17
src/discord/commands/leave.rs
Normal file
17
src/discord/commands/leave.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use anyhow::Result;
|
||||
use poise::command;
|
||||
|
||||
use crate::discord::{
|
||||
common::{get_guild, get_manager},
|
||||
Context,
|
||||
};
|
||||
|
||||
/// Leave the voice chat
|
||||
#[command(slash_command, prefix_command)]
|
||||
pub async fn leave(ctx: Context<'_>) -> Result<()> {
|
||||
let guild = get_guild(ctx);
|
||||
let manager = get_manager(ctx).await?;
|
||||
manager.remove(guild.id).await?;
|
||||
ctx.say("Bye!").await?;
|
||||
Ok(())
|
||||
}
|
||||
36
src/discord/commands/mod.rs
Normal file
36
src/discord/commands/mod.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
pub mod album;
|
||||
pub mod join;
|
||||
pub mod leave;
|
||||
pub mod nowplaying;
|
||||
pub mod pause;
|
||||
pub mod queue;
|
||||
pub mod random;
|
||||
pub mod remove;
|
||||
pub mod resume;
|
||||
pub mod skip;
|
||||
pub mod song;
|
||||
pub mod stop;
|
||||
|
||||
#[poise::command(prefix_command)]
|
||||
pub async fn register(ctx: crate::discord::Context<'_>) -> Result<(), anyhow::Error> {
|
||||
poise::builtins::register_application_commands_buttons(ctx).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn commands() -> Vec<poise::Command<super::Data, anyhow::Error>> {
|
||||
vec![
|
||||
register(),
|
||||
album::album(),
|
||||
join::join(),
|
||||
leave::leave(),
|
||||
nowplaying::nowplaying(),
|
||||
pause::pause(),
|
||||
queue::queue(),
|
||||
random::random(),
|
||||
remove::remove(),
|
||||
resume::resume(),
|
||||
skip::skip(),
|
||||
song::song(),
|
||||
stop::stop(),
|
||||
]
|
||||
}
|
||||
29
src/discord/commands/nowplaying.rs
Normal file
29
src/discord/commands/nowplaying.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use poise::command;
|
||||
|
||||
use crate::discord::{
|
||||
common::{get_call, get_song},
|
||||
Context,
|
||||
};
|
||||
|
||||
/// Show what's currently playing
|
||||
#[command(slash_command, prefix_command)]
|
||||
pub async fn nowplaying(ctx: Context<'_>) -> Result<()> {
|
||||
let call = get_call(ctx).await?;
|
||||
let handler = call.lock().await;
|
||||
|
||||
let queue = handler.queue();
|
||||
let current = queue
|
||||
.current()
|
||||
.ok_or_else(|| anyhow!("Not currently playing"))?;
|
||||
|
||||
let song = get_song(¤t).await?;
|
||||
let text = format!(
|
||||
"**Currently playing**: {} - {}",
|
||||
song.title,
|
||||
song.artist.as_deref().unwrap_or_default()
|
||||
);
|
||||
ctx.say(text).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
22
src/discord/commands/pause.rs
Normal file
22
src/discord/commands/pause.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use anyhow::Result;
|
||||
use poise::command;
|
||||
|
||||
use crate::discord::{common::get_call, Context};
|
||||
|
||||
/// Pause playback
|
||||
#[command(slash_command, prefix_command)]
|
||||
pub async fn pause(ctx: Context<'_>) -> Result<()> {
|
||||
let call = get_call(ctx).await?;
|
||||
let handler = call.lock().await;
|
||||
|
||||
let queue = handler.queue();
|
||||
let current = queue.current();
|
||||
if let Some(track) = current {
|
||||
track.pause()?;
|
||||
ctx.say("Paused playback").await?;
|
||||
} else {
|
||||
ctx.say("Not currently playing").await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
48
src/discord/commands/queue.rs
Normal file
48
src/discord/commands/queue.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use anyhow::Result;
|
||||
use poise::command;
|
||||
|
||||
use crate::discord::{
|
||||
common::{get_call, get_song},
|
||||
Context,
|
||||
};
|
||||
|
||||
/// Get play queue list
|
||||
#[command(slash_command, prefix_command)]
|
||||
pub async fn queue(ctx: Context<'_>) -> Result<()> {
|
||||
let call = get_call(ctx).await?;
|
||||
let handler = call.lock().await;
|
||||
|
||||
let current_queue = handler.queue().current_queue();
|
||||
|
||||
let text = if current_queue.is_empty() {
|
||||
"No songs queued".into()
|
||||
} else {
|
||||
let mut text = String::new();
|
||||
let mut queue = current_queue.iter().enumerate();
|
||||
|
||||
let (_, track) = queue.next().unwrap();
|
||||
let song = get_song(track).await?;
|
||||
text.push_str(&format!(
|
||||
"**Currently playing**: {} - {}\n\n",
|
||||
song.title,
|
||||
song.artist.as_deref().unwrap_or_default()
|
||||
));
|
||||
|
||||
text.push_str("**Next songs in queue**:\n");
|
||||
for (i, track) in queue {
|
||||
let song = get_song(track).await?;
|
||||
let song_text = format!(
|
||||
"**{}.** {} - {}",
|
||||
i,
|
||||
song.title,
|
||||
song.artist.as_deref().unwrap_or_default()
|
||||
);
|
||||
text.push_str(&song_text);
|
||||
text.push('\n');
|
||||
}
|
||||
text
|
||||
};
|
||||
|
||||
ctx.say(text).await?;
|
||||
Ok(())
|
||||
}
|
||||
29
src/discord/commands/random.rs
Normal file
29
src/discord/commands/random.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use poise::command;
|
||||
use serenity::utils::MessageBuilder;
|
||||
use sunk::song::Song;
|
||||
|
||||
use crate::discord::{common::queue_song, Context};
|
||||
|
||||
/// Play a random song
|
||||
#[command(slash_command, prefix_command)]
|
||||
pub async fn random(ctx: Context<'_>) -> Result<()> {
|
||||
let music_client = &ctx.data().subsonic_client;
|
||||
|
||||
let result = Song::random(music_client, 1).await?;
|
||||
let song: Song = result
|
||||
.first()
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| anyhow!("No song matching search found"))?;
|
||||
queue_song(ctx, &song, music_client).await?;
|
||||
|
||||
let message = format!(
|
||||
"Added **{} - {}** to the queue",
|
||||
song.title,
|
||||
song.artist.as_deref().unwrap_or_default(),
|
||||
);
|
||||
ctx.say(&MessageBuilder::new().push(&message).build())
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
26
src/discord/commands/remove.rs
Normal file
26
src/discord/commands/remove.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use poise::command;
|
||||
|
||||
use crate::discord::{
|
||||
common::{get_call, get_song},
|
||||
Context,
|
||||
};
|
||||
|
||||
/// Remove song from queue
|
||||
#[command(slash_command, prefix_command)]
|
||||
pub async fn remove(
|
||||
ctx: Context<'_>,
|
||||
#[description = "Song position on the queue"] index: usize,
|
||||
) -> Result<()> {
|
||||
let call = get_call(ctx).await?;
|
||||
let handler = call.lock().await;
|
||||
|
||||
let queue = handler.queue();
|
||||
let track = queue
|
||||
.dequeue(index - 1)
|
||||
.ok_or_else(|| anyhow!("Song not found"))?;
|
||||
let song = get_song(&track).await?;
|
||||
let text = format!("Removed track: {}", song.title);
|
||||
ctx.say(text).await?;
|
||||
Ok(())
|
||||
}
|
||||
18
src/discord/commands/resume.rs
Normal file
18
src/discord/commands/resume.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use anyhow::Result;
|
||||
use poise::command;
|
||||
|
||||
use crate::discord::{common::get_call, Context};
|
||||
|
||||
/// Resume playing current song
|
||||
#[command(slash_command, prefix_command)]
|
||||
pub async fn resume(ctx: Context<'_>) -> Result<()> {
|
||||
let call = get_call(ctx).await?;
|
||||
let handler = call.lock().await;
|
||||
|
||||
let queue = handler.queue();
|
||||
queue.resume()?;
|
||||
|
||||
ctx.say("Resumed playing").await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
25
src/discord/commands/skip.rs
Normal file
25
src/discord/commands/skip.rs
Normal file
@@ -0,0 +1,25 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use poise::command;
|
||||
|
||||
use crate::discord::{common::get_call, Context};
|
||||
|
||||
/// Skip song(s)
|
||||
#[command(slash_command, prefix_command)]
|
||||
pub async fn skip(
|
||||
ctx: Context<'_>,
|
||||
#[description = "Number of songs to skip"] n: Option<usize>,
|
||||
) -> Result<()> {
|
||||
let call = get_call(ctx).await?;
|
||||
let handler = call.lock().await;
|
||||
let n = n.unwrap_or(1);
|
||||
|
||||
let queue = handler.queue();
|
||||
for _ in 0..(n - 1) {
|
||||
queue.dequeue(1).ok_or_else(|| anyhow!("Song not found"))?;
|
||||
}
|
||||
queue.skip()?;
|
||||
|
||||
ctx.say(&format!("{n} song(s) skipped")).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
40
src/discord/commands/song.rs
Normal file
40
src/discord/commands/song.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use anyhow::{anyhow, Context as ErrContext, Result};
|
||||
use poise::command;
|
||||
use sunk::search::{self, SearchPage};
|
||||
|
||||
use crate::discord::{common::queue_song, Context};
|
||||
|
||||
/// Search for a song, and queue it
|
||||
#[command(slash_command, prefix_command)]
|
||||
pub async fn song(
|
||||
ctx: Context<'_>,
|
||||
#[description = "What to search for"] query: String,
|
||||
) -> Result<()> {
|
||||
let music_client = &ctx.data().subsonic_client;
|
||||
|
||||
let search_size = SearchPage::new().with_size(1);
|
||||
let ignore = search::NONE;
|
||||
|
||||
let result = music_client
|
||||
.search(&query, ignore, ignore, search_size)
|
||||
.await
|
||||
.with_context(|| anyhow!("Could not search for song"))?
|
||||
.songs;
|
||||
|
||||
let song = result
|
||||
.first()
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| anyhow!("No song matching search found"))?;
|
||||
|
||||
log::info!("Found song {song:?}");
|
||||
queue_song(ctx, &song, music_client).await?;
|
||||
|
||||
let message = format!(
|
||||
"Added **{} - {}** to the queue",
|
||||
song.title,
|
||||
song.artist.as_deref().unwrap_or_default(),
|
||||
);
|
||||
ctx.say(message).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
18
src/discord/commands/stop.rs
Normal file
18
src/discord/commands/stop.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use anyhow::Result;
|
||||
use poise::command;
|
||||
|
||||
use crate::discord::{common::get_call, Context};
|
||||
|
||||
/// Stop playing and clear queue
|
||||
#[command(slash_command, prefix_command)]
|
||||
pub async fn stop(ctx: Context<'_>) -> Result<()> {
|
||||
let call = get_call(ctx).await?;
|
||||
let handler = call.lock().await;
|
||||
|
||||
let queue = handler.queue();
|
||||
queue.stop();
|
||||
|
||||
ctx.say("Stopped playing").await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
92
src/discord/common.rs
Normal file
92
src/discord/common.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use serenity::all::{ChannelId, Guild};
|
||||
use songbird::{
|
||||
input::HttpRequest,
|
||||
tracks::{Track, TrackHandle},
|
||||
Songbird,
|
||||
};
|
||||
use sunk::{song::Song, Streamable};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::handles::SubsonicSongHandle;
|
||||
|
||||
pub struct Data {
|
||||
pub subsonic_client: sunk::Client,
|
||||
}
|
||||
pub type Context<'a> = poise::Context<'a, Data, anyhow::Error>;
|
||||
|
||||
pub async fn queue_song(ctx: Context<'_>, song: &Song, client: &sunk::Client) -> Result<()> {
|
||||
let call = get_call(ctx).await?;
|
||||
let mut handler = call.lock().await;
|
||||
|
||||
let track = load_song(song, client).await?;
|
||||
let track_handle = handler.enqueue(track).await;
|
||||
let mut type_map = track_handle.typemap().write().await;
|
||||
type_map.insert::<SubsonicSongHandle>(song.clone());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_guild(ctx: Context<'_>) -> Guild {
|
||||
let guild = ctx.guild().expect("No guild!").clone();
|
||||
log::info!("Got guild: {guild:?}");
|
||||
guild
|
||||
}
|
||||
|
||||
pub fn get_channel(ctx: Context<'_>) -> Result<ChannelId> {
|
||||
let guild = get_guild(ctx);
|
||||
let voice_state = guild
|
||||
.voice_states
|
||||
.get(&ctx.author().id)
|
||||
.ok_or_else(|| anyhow!("You must be in a voice channel to use this command"))?;
|
||||
let channel = voice_state
|
||||
.channel_id
|
||||
.ok_or_else(|| anyhow!("You must be in a voice channel to use this command"))?;
|
||||
|
||||
Ok(channel)
|
||||
}
|
||||
|
||||
pub async fn get_call(ctx: Context<'_>) -> Result<Arc<Mutex<songbird::Call>>> {
|
||||
let manager = get_manager(ctx).await?;
|
||||
let guild = get_guild(ctx);
|
||||
let call = manager.get(guild.id);
|
||||
|
||||
if let Some(c) = call {
|
||||
Ok(c)
|
||||
} else {
|
||||
let channel = get_channel(ctx)?;
|
||||
log::warn!("Not in a voice channel, trying to join {channel}");
|
||||
let _handler = manager.join(guild.id, channel).await;
|
||||
manager
|
||||
.get(guild.id)
|
||||
.ok_or_else(|| anyhow!("Not in a voice channel, try running 'join'"))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_song(track: &TrackHandle) -> Result<Song> {
|
||||
let song = track
|
||||
.typemap()
|
||||
.read()
|
||||
.await
|
||||
.get::<SubsonicSongHandle>()
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| anyhow!("Sound information not found"))?;
|
||||
Ok(song)
|
||||
}
|
||||
|
||||
pub async fn get_manager(ctx: Context<'_>) -> Result<Arc<Songbird>> {
|
||||
let manager = songbird::get(ctx.discord())
|
||||
.await
|
||||
.ok_or_else(|| anyhow!("Couldn't start manager"))?;
|
||||
log::info!("Got manager: {manager:?}");
|
||||
Ok(manager)
|
||||
}
|
||||
|
||||
pub async fn load_song(song: &Song, client: &sunk::Client) -> Result<Track> {
|
||||
log::info!("Loading song {song:?}");
|
||||
let url = song.stream_url(client)?;
|
||||
let track: Track = HttpRequest::new(client.reqclient.clone(), url).into();
|
||||
Ok(track)
|
||||
}
|
||||
14
src/discord/handler.rs
Normal file
14
src/discord/handler.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use serenity::{
|
||||
all::Ready,
|
||||
async_trait,
|
||||
prelude::{Context, EventHandler},
|
||||
};
|
||||
|
||||
pub struct Handler;
|
||||
|
||||
#[async_trait]
|
||||
impl EventHandler for Handler {
|
||||
async fn ready(&self, _: Context, ready: Ready) {
|
||||
log::info!("{} is connected!", ready.user.name);
|
||||
}
|
||||
}
|
||||
8
src/discord/mod.rs
Normal file
8
src/discord/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
pub mod client;
|
||||
pub mod commands;
|
||||
pub mod common;
|
||||
pub mod handler;
|
||||
|
||||
pub use client::create_client;
|
||||
pub use common::{Context, Data};
|
||||
pub use handler::Handler;
|
||||
Reference in New Issue
Block a user