use poise to register slash commands, separate files more

This commit is contained in:
Gabriel Fontes
2023-06-11 19:19:14 -03:00
parent e147916d27
commit 5b573abcc6
22 changed files with 642 additions and 434 deletions

71
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"]

View File

@@ -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=";
};
};

View File

@@ -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> {

View File

@@ -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(&current).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
View 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)
}

View 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(())
}

View 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(())
}

View 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(())
}

View 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(),
]
}

View 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(&current).await?;
let text = format!(
"**Currently playing**: {} - {}",
song.title,
song.artist.as_deref().unwrap_or_default()
);
ctx.say(text).await?;
Ok(())
}

View 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(())
}

View 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(())
}

View 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(())
}

View 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(())
}

View 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(())
}

View 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(())
}

View 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(())
}

View 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
View 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
View 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
View 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;