Compare commits

...

10 Commits

Author SHA1 Message Date
1ed13f7a8b Add Dockerfile to create images 2023-12-22 11:58:46 -06:00
Gabriel Fontes
074bc44d4d require guild id, show invite link if needed
small improvements on common functions
2023-06-14 00:17:33 -03:00
Gabriel Fontes
5bd8e2ab57 remove dead code 2023-06-12 09:49:39 -03:00
Gabriel Fontes
3b3ff9106b version bump 2023-06-12 09:38:46 -03:00
Gabriel Fontes
64307892b5 remove editorconfig and gitattributes
Don't need gitattributes no more, and editorconfig is pretty much replaced by rustfmt + nixfmt
2023-06-12 09:24:13 -03:00
Gabriel Fontes
fb19a77534 nix: simplify and fix module, hopefully 2023-06-12 09:23:12 -03:00
Gabriel Fontes
46b6ce6ce6 nix: try to fix module 2023-06-12 00:27:56 -03:00
Gabriel Fontes
8b4c67fb0a fix msrv, bump edition 2023-06-12 00:26:52 -03:00
Gabriel Fontes
e42a52e0ac fix rust overlay when using disconic in an overlay 2023-06-11 20:24:31 -03:00
Gabriel Fontes
7f1f4eaf4d nix: module improvements 2023-06-11 20:12:13 -03:00
17 changed files with 137 additions and 127 deletions

2
.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
Dockerfile
target/*

View File

@@ -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
View File

@@ -1 +0,0 @@
Cargo.nix linguist-generated

View File

@@ -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
View File

@@ -458,7 +458,7 @@ dependencies = [
[[package]]
name = "disconic"
version = "1.0.0"
version = "1.1.0"
dependencies = [
"anyhow",
"clap",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 {
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}");
let _handler = manager.join(guild.id, channel).await;
manager
.get(guild.id)
.ok_or_else(|| anyhow!("Not in a voice channel, try running 'join'"))
.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)
}

View File

@@ -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);
}
}

View File

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

View File

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

View File

@@ -1,3 +1,2 @@
pub mod config;
pub mod discord;
pub mod handles;