Compare commits
10 Commits
5131e76a1d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ed13f7a8b | |||
|
|
074bc44d4d | ||
|
|
5bd8e2ab57 | ||
|
|
3b3ff9106b | ||
|
|
64307892b5 | ||
|
|
fb19a77534 | ||
|
|
46b6ce6ce6 | ||
|
|
8b4c67fb0a | ||
|
|
e42a52e0ac | ||
|
|
7f1f4eaf4d |
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
Dockerfile
|
||||
target/*
|
||||
@@ -1,15 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.nix]
|
||||
ident_style = space
|
||||
ident_size = 2
|
||||
|
||||
[*.rs]
|
||||
ident_style = space
|
||||
ident_size = 4
|
||||
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -1 +0,0 @@
|
||||
Cargo.nix linguist-generated
|
||||
@@ -1,3 +1,7 @@
|
||||
# 1.0.1
|
||||
|
||||
Finish NixOS module
|
||||
|
||||
# 1.0.0
|
||||
|
||||
Initial release. Slash commands are available for most basic functionality.
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -458,7 +458,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "disconic"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
[package]
|
||||
name = "disconic"
|
||||
description = "Discord bot for interacting with subsonic music libraries"
|
||||
version = "1.0.0"
|
||||
version = "1.1.0"
|
||||
authors = [ "Gabriel Fontes <eu@misterio.me>" ]
|
||||
edition = "2018"
|
||||
edition = "2021"
|
||||
homepage = "https://misterio.me"
|
||||
license = "AGPL-3.0-or-later"
|
||||
rust-version = "1.70.0"
|
||||
|
||||
|
||||
[patch.crates-io.sunk]
|
||||
|
||||
13
Dockerfile
Normal file
13
Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM rust:1.74-bookworm
|
||||
|
||||
COPY . /opt/app
|
||||
|
||||
WORKDIR /opt/app
|
||||
|
||||
RUN apt update && apt install -y cmake
|
||||
|
||||
RUN cargo build --release
|
||||
|
||||
ENTRYPOINT ["/opt/app/target/release/disconic"]
|
||||
|
||||
CMD ["--help"]
|
||||
@@ -10,7 +10,7 @@ I use `sunk` to interact with subsonic, and `serenity`/`poise` to interact with
|
||||
|
||||
You need `rustc` >= 1.70 to compile this crate. Use `rustup` to get it.
|
||||
|
||||
The only crate with non-rust dependencies is `audiopus_sys`, this means you need `libopus` installed to run this. If building, you'll also need `cmake` and `pkg-config`. Consult your distro's documentation on how to get these.
|
||||
The only crate with non-rust dependencies is `audiopus_sys`, this means you need `libopus` installed to run this. If building, you'll also need `pkg-config`. Consult your distro's documentation on how to get these.
|
||||
|
||||
You can quickly get everything you need with nix:
|
||||
|
||||
@@ -46,11 +46,11 @@ With nix, you don't even need to clone the repo. Simply replace `.` with `github
|
||||
|
||||
## Usage
|
||||
|
||||
Start by creating a discord app, getting its bot token, and inviting it to your server. Also get your guild (server) ID. These steps are already documented elsewhere, so will not be covered here.
|
||||
Start by creating a discord app and getting its bot token. Also get your guild (server) ID. These steps are already documented elsewhere, so will not be covered here.
|
||||
|
||||
You can configure the application through CLI arguments (very convenient) or environment variables (better for deployments, dotenv is also supported). Use `--help` to see what the arguments or environment variables are.
|
||||
You can configure the application through CLI arguments or environment variables. Use `--help` to see what the arguments and environment variables are.
|
||||
|
||||
Simply run the binary and everything will be setup for you. The bot will automatically register its commands on your server (if guild_id is set). If you don't see the commands, try sending a message mentioning the bot and `register` (all commands can be ran like that, too).
|
||||
Simply run the binary and everything will be setup for you. If your bot is not a member of the guild, disconic will error out and print the invitation URL. The bot will also automatically register its slash commands on your server whenever it starts.
|
||||
|
||||
## Usage
|
||||
|
||||
|
||||
@@ -17,13 +17,11 @@
|
||||
let
|
||||
forAllSystems = nixpkgs.lib.genAttrs [ "x86_64-linux" "aarch64-linux" ];
|
||||
forAllPkgs = f: forAllSystems (sys: f pkgsFor.${sys});
|
||||
pkgsFor = forAllSystems (system: import nixpkgs {
|
||||
inherit system;
|
||||
overlays = [ rust-overlay.overlays.default ];
|
||||
});
|
||||
pkgsFor = nixpkgs.legacyPackages;
|
||||
|
||||
mkPackage = pkgs: pkgs.callPackage ./default.nix {
|
||||
rustPlatform = pkgs.makeRustPlatform rec {
|
||||
rustc = pkgs.rust-bin.stable.latest.default;
|
||||
rustc = rust-overlay.packages.${pkgs.system}.rust;
|
||||
cargo = rustc;
|
||||
};
|
||||
};
|
||||
|
||||
42
module.nix
42
module.nix
@@ -14,22 +14,18 @@ in {
|
||||
The package implementing disconic
|
||||
'';
|
||||
};
|
||||
subsonicUrl = mkOption {
|
||||
user = mkOption {
|
||||
type = types.str;
|
||||
description = "Subsonic library base API URL";
|
||||
default = "yrmos";
|
||||
description = "Service user that will run the daemon.";
|
||||
};
|
||||
subsonicUser = mkOption {
|
||||
type = types.str;
|
||||
description = "Subsonic user login";
|
||||
};
|
||||
subsonicPasswordFile = mkOption {
|
||||
|
||||
environmentFile = mkOption {
|
||||
type = types.path;
|
||||
description = "File path containing subsonic user password";
|
||||
};
|
||||
discordTokenFile = mkOption {
|
||||
type = types.path;
|
||||
description = "File path containing discord token";
|
||||
default = null;
|
||||
description = "File path containing environment variables.";
|
||||
};
|
||||
|
||||
extraArgs = mkOption {
|
||||
type = types.listOf types.str;
|
||||
default = [ ];
|
||||
@@ -41,13 +37,21 @@ in {
|
||||
systemd.services.disconic = {
|
||||
description = "Disconic, a Discord Subsonic Bot";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig.ExecStart = lib.escapeShellArgs ([
|
||||
(lib.getExe cfg.package)
|
||||
"--subsonic-url=${cfg.subsonicUrl}"
|
||||
"--subsonic-user=${cfg.subsonicUser}"
|
||||
"--subsonic-password=$(cat ${cfg.subsonicPasswordFile})"
|
||||
"--discord-token=$(cat ${cfg.discordTokenFile}"
|
||||
] ++ cfg.extraArgs);
|
||||
serviceConfig = {
|
||||
Restart = "always";
|
||||
User = cfg.user;
|
||||
EnvironmentFile = lib.mkIf (cfg.environmentFile != null) cfg.environmentFile;
|
||||
};
|
||||
script = (lib.getExe cfg.package) + (lib.escapeShellArg cfg.extraArgs);
|
||||
};
|
||||
|
||||
users = {
|
||||
users.${cfg.user} = {
|
||||
description = "disconic service user";
|
||||
isSystemUser = true;
|
||||
group = cfg.user;
|
||||
};
|
||||
groups.${cfg.user} = { };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ pub struct Config {
|
||||
#[clap(long, env = "DISCONIC_SUBSONIC_PASSWORD")]
|
||||
subsonic_password: String,
|
||||
#[clap(long, env = "DISCONIC_DISCORD_GUILD")]
|
||||
discord_guild: Option<u64>,
|
||||
discord_guild: u64,
|
||||
#[clap(long, env = "DISCONIC_DISCORD_TOKEN")]
|
||||
discord_token: String,
|
||||
#[clap(long, env = "DISCONIC_LOG_LEVEL", default_value = "warn")]
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
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 anyhow::{Error, Result};
|
||||
use poise::{samples::create_application_commands, Framework};
|
||||
use serenity::{
|
||||
all::{GuildId, Ready},
|
||||
builder::CreateCommand,
|
||||
client::Client as DiscordClient,
|
||||
prelude::{Context, GatewayIntents},
|
||||
};
|
||||
use songbird::SerenityInit;
|
||||
use sunk::Client as SubsonicClient;
|
||||
|
||||
@@ -36,14 +41,48 @@ async fn on_error(error: poise::FrameworkError<'_, Data, anyhow::Error>) {
|
||||
}
|
||||
}
|
||||
|
||||
async fn on_startup(
|
||||
guild: GuildId,
|
||||
data: Data,
|
||||
create_commands: Vec<CreateCommand>,
|
||||
ctx: &Context,
|
||||
ready: &Ready,
|
||||
framework: &Framework<Data, Error>,
|
||||
) -> Result<Data> {
|
||||
let is_on_guild = (&ready.guilds).iter().any(|x| x.id == guild);
|
||||
|
||||
let bot_id = framework.bot_id().await;
|
||||
let permissions = "311388293184";
|
||||
let scope = "bot%20applications.commands";
|
||||
|
||||
if !is_on_guild {
|
||||
let invite_link = format!(
|
||||
"https://discord.com/oauth2/authorize?client_id={}&permissions={}&scope={}",
|
||||
bot_id, permissions, scope
|
||||
);
|
||||
log::error!("The bot is not your on your guild.");
|
||||
log::error!("Invite it with:\n {}", invite_link);
|
||||
framework.shard_manager().lock().await.shutdown_all().await;
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
guild
|
||||
.set_commands(&ctx.http, create_commands)
|
||||
.await
|
||||
.expect("Failed to register command");
|
||||
log::info!("Registered commands");
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
pub async fn create_client(
|
||||
token: &str,
|
||||
guild_id: Option<u64>,
|
||||
guild_id: u64,
|
||||
subsonic_client: SubsonicClient,
|
||||
) -> Result<DiscordClient> {
|
||||
let intents = GatewayIntents::non_privileged() | GatewayIntents::MESSAGE_CONTENT;
|
||||
let commands = commands::commands();
|
||||
let data = Data { subsonic_client };
|
||||
let guild = GuildId::new(guild_id);
|
||||
|
||||
let create_commands = create_application_commands(&commands);
|
||||
let options = poise::FrameworkOptions {
|
||||
@@ -51,20 +90,15 @@ pub async fn create_client(
|
||||
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 framework = poise::Framework::new(options, move |ctx, ready, framework| {
|
||||
Box::pin(on_startup(
|
||||
guild,
|
||||
data,
|
||||
create_commands,
|
||||
ctx,
|
||||
ready,
|
||||
framework,
|
||||
))
|
||||
});
|
||||
|
||||
let client = DiscordClient::builder(token, intents)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use serenity::all::{ChannelId, Guild};
|
||||
use anyhow::{anyhow, Context as ErrContext, Result};
|
||||
use serenity::{
|
||||
all::{ChannelId, Guild},
|
||||
prelude::TypeMapKey,
|
||||
};
|
||||
use songbird::{
|
||||
input::HttpRequest,
|
||||
tracks::{Track, TrackHandle},
|
||||
@@ -10,7 +13,10 @@ use tokio::sync::Mutex;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::handles::SubsonicSongHandle;
|
||||
pub struct SongHandle;
|
||||
impl TypeMapKey for SongHandle {
|
||||
type Value = Song;
|
||||
}
|
||||
|
||||
pub struct Data {
|
||||
pub subsonic_client: sunk::Client,
|
||||
@@ -24,15 +30,15 @@ pub async fn queue_song(ctx: Context<'_>, song: &Song, client: &sunk::Client) ->
|
||||
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());
|
||||
type_map.insert::<SongHandle>(song.clone());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_guild(ctx: Context<'_>) -> Guild {
|
||||
let guild = ctx.guild().expect("No guild!").clone();
|
||||
log::info!("Got guild: {guild:?}");
|
||||
guild
|
||||
ctx.guild()
|
||||
.expect("Invalid (or no) guild configured!")
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
pub fn get_channel(ctx: Context<'_>) -> Result<ChannelId> {
|
||||
@@ -41,52 +47,45 @@ pub fn get_channel(ctx: Context<'_>) -> Result<ChannelId> {
|
||||
.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
|
||||
voice_state
|
||||
.channel_id
|
||||
.ok_or_else(|| anyhow!("You must be in a voice channel to use this command"))?;
|
||||
|
||||
Ok(channel)
|
||||
.ok_or_else(|| anyhow!("You must be in a voice channel to use this command"))
|
||||
}
|
||||
|
||||
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'"))
|
||||
match manager.get(guild.id) {
|
||||
Some(c) => Ok(c),
|
||||
None => {
|
||||
let channel = get_channel(ctx)?;
|
||||
log::warn!("Not in a voice channel, trying to join {channel}");
|
||||
manager
|
||||
.join(guild.id, channel)
|
||||
.await
|
||||
.context("Couldn't join voice channel. Try running 'join' manually.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_song(track: &TrackHandle) -> Result<Song> {
|
||||
let song = track
|
||||
track
|
||||
.typemap()
|
||||
.read()
|
||||
.await
|
||||
.get::<SubsonicSongHandle>()
|
||||
.get::<SongHandle>()
|
||||
.map(ToOwned::to_owned)
|
||||
.ok_or_else(|| anyhow!("Sound information not found"))?;
|
||||
Ok(song)
|
||||
.ok_or_else(|| anyhow!("Sound information not found"))
|
||||
}
|
||||
|
||||
pub async fn get_manager(ctx: Context<'_>) -> Result<Arc<Songbird>> {
|
||||
let manager = songbird::get(ctx.discord())
|
||||
songbird::get(ctx.discord())
|
||||
.await
|
||||
.ok_or_else(|| anyhow!("Couldn't start manager"))?;
|
||||
log::info!("Got manager: {manager:?}");
|
||||
Ok(manager)
|
||||
.ok_or_else(|| anyhow!("Couldn't start 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();
|
||||
let track = HttpRequest::new(client.reqclient.clone(), url).into();
|
||||
Ok(track)
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
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;
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
use serenity::prelude::TypeMapKey;
|
||||
use sunk::{song::Song as SubsonicSong, Client as SubsonicClient};
|
||||
|
||||
pub struct SubsonicClientHandle;
|
||||
impl TypeMapKey for SubsonicClientHandle {
|
||||
type Value = SubsonicClient;
|
||||
}
|
||||
|
||||
pub struct SubsonicSongHandle;
|
||||
impl TypeMapKey for SubsonicSongHandle {
|
||||
type Value = SubsonicSong;
|
||||
}
|
||||
@@ -1,3 +1,2 @@
|
||||
pub mod config;
|
||||
pub mod discord;
|
||||
pub mod handles;
|
||||
|
||||
Reference in New Issue
Block a user