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
|
# 1.0.0
|
||||||
|
|
||||||
Initial release. Slash commands are available for most basic functionality.
|
Initial release. Slash commands are available for most basic functionality.
|
||||||
|
|||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -458,7 +458,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "disconic"
|
name = "disconic"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"clap",
|
"clap",
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "disconic"
|
name = "disconic"
|
||||||
description = "Discord bot for interacting with subsonic music libraries"
|
description = "Discord bot for interacting with subsonic music libraries"
|
||||||
version = "1.0.0"
|
version = "1.1.0"
|
||||||
authors = [ "Gabriel Fontes <eu@misterio.me>" ]
|
authors = [ "Gabriel Fontes <eu@misterio.me>" ]
|
||||||
edition = "2018"
|
edition = "2021"
|
||||||
homepage = "https://misterio.me"
|
homepage = "https://misterio.me"
|
||||||
license = "AGPL-3.0-or-later"
|
license = "AGPL-3.0-or-later"
|
||||||
|
rust-version = "1.70.0"
|
||||||
|
|
||||||
|
|
||||||
[patch.crates-io.sunk]
|
[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.
|
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:
|
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
|
## 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
|
## Usage
|
||||||
|
|
||||||
|
|||||||
@@ -17,13 +17,11 @@
|
|||||||
let
|
let
|
||||||
forAllSystems = nixpkgs.lib.genAttrs [ "x86_64-linux" "aarch64-linux" ];
|
forAllSystems = nixpkgs.lib.genAttrs [ "x86_64-linux" "aarch64-linux" ];
|
||||||
forAllPkgs = f: forAllSystems (sys: f pkgsFor.${sys});
|
forAllPkgs = f: forAllSystems (sys: f pkgsFor.${sys});
|
||||||
pkgsFor = forAllSystems (system: import nixpkgs {
|
pkgsFor = nixpkgs.legacyPackages;
|
||||||
inherit system;
|
|
||||||
overlays = [ rust-overlay.overlays.default ];
|
|
||||||
});
|
|
||||||
mkPackage = pkgs: pkgs.callPackage ./default.nix {
|
mkPackage = pkgs: pkgs.callPackage ./default.nix {
|
||||||
rustPlatform = pkgs.makeRustPlatform rec {
|
rustPlatform = pkgs.makeRustPlatform rec {
|
||||||
rustc = pkgs.rust-bin.stable.latest.default;
|
rustc = rust-overlay.packages.${pkgs.system}.rust;
|
||||||
cargo = rustc;
|
cargo = rustc;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
42
module.nix
42
module.nix
@@ -14,22 +14,18 @@ in {
|
|||||||
The package implementing disconic
|
The package implementing disconic
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
subsonicUrl = mkOption {
|
user = mkOption {
|
||||||
type = types.str;
|
type = types.str;
|
||||||
description = "Subsonic library base API URL";
|
default = "yrmos";
|
||||||
|
description = "Service user that will run the daemon.";
|
||||||
};
|
};
|
||||||
subsonicUser = mkOption {
|
|
||||||
type = types.str;
|
environmentFile = mkOption {
|
||||||
description = "Subsonic user login";
|
|
||||||
};
|
|
||||||
subsonicPasswordFile = mkOption {
|
|
||||||
type = types.path;
|
type = types.path;
|
||||||
description = "File path containing subsonic user password";
|
default = null;
|
||||||
};
|
description = "File path containing environment variables.";
|
||||||
discordTokenFile = mkOption {
|
|
||||||
type = types.path;
|
|
||||||
description = "File path containing discord token";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
extraArgs = mkOption {
|
extraArgs = mkOption {
|
||||||
type = types.listOf types.str;
|
type = types.listOf types.str;
|
||||||
default = [ ];
|
default = [ ];
|
||||||
@@ -41,13 +37,21 @@ in {
|
|||||||
systemd.services.disconic = {
|
systemd.services.disconic = {
|
||||||
description = "Disconic, a Discord Subsonic Bot";
|
description = "Disconic, a Discord Subsonic Bot";
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
serviceConfig.ExecStart = lib.escapeShellArgs ([
|
serviceConfig = {
|
||||||
(lib.getExe cfg.package)
|
Restart = "always";
|
||||||
"--subsonic-url=${cfg.subsonicUrl}"
|
User = cfg.user;
|
||||||
"--subsonic-user=${cfg.subsonicUser}"
|
EnvironmentFile = lib.mkIf (cfg.environmentFile != null) cfg.environmentFile;
|
||||||
"--subsonic-password=$(cat ${cfg.subsonicPasswordFile})"
|
};
|
||||||
"--discord-token=$(cat ${cfg.discordTokenFile}"
|
script = (lib.getExe cfg.package) + (lib.escapeShellArg cfg.extraArgs);
|
||||||
] ++ 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")]
|
#[clap(long, env = "DISCONIC_SUBSONIC_PASSWORD")]
|
||||||
subsonic_password: String,
|
subsonic_password: String,
|
||||||
#[clap(long, env = "DISCONIC_DISCORD_GUILD")]
|
#[clap(long, env = "DISCONIC_DISCORD_GUILD")]
|
||||||
discord_guild: Option<u64>,
|
discord_guild: u64,
|
||||||
#[clap(long, env = "DISCONIC_DISCORD_TOKEN")]
|
#[clap(long, env = "DISCONIC_DISCORD_TOKEN")]
|
||||||
discord_token: String,
|
discord_token: String,
|
||||||
#[clap(long, env = "DISCONIC_LOG_LEVEL", default_value = "warn")]
|
#[clap(long, env = "DISCONIC_LOG_LEVEL", default_value = "warn")]
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
use crate::discord::{commands, Data};
|
use crate::discord::{commands, Data};
|
||||||
use anyhow::Result;
|
use anyhow::{Error, Result};
|
||||||
use poise::samples::create_application_commands;
|
use poise::{samples::create_application_commands, Framework};
|
||||||
use serenity::{all::GuildId, client::Client as DiscordClient, prelude::GatewayIntents};
|
use serenity::{
|
||||||
|
all::{GuildId, Ready},
|
||||||
|
builder::CreateCommand,
|
||||||
|
client::Client as DiscordClient,
|
||||||
|
prelude::{Context, GatewayIntents},
|
||||||
|
};
|
||||||
use songbird::SerenityInit;
|
use songbird::SerenityInit;
|
||||||
use sunk::Client as SubsonicClient;
|
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(
|
pub async fn create_client(
|
||||||
token: &str,
|
token: &str,
|
||||||
guild_id: Option<u64>,
|
guild_id: u64,
|
||||||
subsonic_client: SubsonicClient,
|
subsonic_client: SubsonicClient,
|
||||||
) -> Result<DiscordClient> {
|
) -> Result<DiscordClient> {
|
||||||
let intents = GatewayIntents::non_privileged() | GatewayIntents::MESSAGE_CONTENT;
|
let intents = GatewayIntents::non_privileged() | GatewayIntents::MESSAGE_CONTENT;
|
||||||
let commands = commands::commands();
|
let commands = commands::commands();
|
||||||
let data = Data { subsonic_client };
|
let data = Data { subsonic_client };
|
||||||
|
let guild = GuildId::new(guild_id);
|
||||||
|
|
||||||
let create_commands = create_application_commands(&commands);
|
let create_commands = create_application_commands(&commands);
|
||||||
let options = poise::FrameworkOptions {
|
let options = poise::FrameworkOptions {
|
||||||
@@ -51,20 +90,15 @@ pub async fn create_client(
|
|||||||
on_error: |e| Box::pin(on_error(e)),
|
on_error: |e| Box::pin(on_error(e)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let framework = poise::Framework::new(options, move |ctx, _ready, _framework| {
|
let framework = poise::Framework::new(options, move |ctx, ready, framework| {
|
||||||
Box::pin(async move {
|
Box::pin(on_startup(
|
||||||
if let Some(id) = guild_id {
|
guild,
|
||||||
let guild = GuildId::new(id);
|
data,
|
||||||
guild
|
create_commands,
|
||||||
.set_commands(&ctx.http, create_commands)
|
ctx,
|
||||||
.await
|
ready,
|
||||||
.expect("Failed to register command");
|
framework,
|
||||||
} 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)
|
let client = DiscordClient::builder(token, intents)
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Context as ErrContext, Result};
|
||||||
use serenity::all::{ChannelId, Guild};
|
use serenity::{
|
||||||
|
all::{ChannelId, Guild},
|
||||||
|
prelude::TypeMapKey,
|
||||||
|
};
|
||||||
use songbird::{
|
use songbird::{
|
||||||
input::HttpRequest,
|
input::HttpRequest,
|
||||||
tracks::{Track, TrackHandle},
|
tracks::{Track, TrackHandle},
|
||||||
@@ -10,7 +13,10 @@ use tokio::sync::Mutex;
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::handles::SubsonicSongHandle;
|
pub struct SongHandle;
|
||||||
|
impl TypeMapKey for SongHandle {
|
||||||
|
type Value = Song;
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Data {
|
pub struct Data {
|
||||||
pub subsonic_client: sunk::Client,
|
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 = load_song(song, client).await?;
|
||||||
let track_handle = handler.enqueue(track).await;
|
let track_handle = handler.enqueue(track).await;
|
||||||
let mut type_map = track_handle.typemap().write().await;
|
let mut type_map = track_handle.typemap().write().await;
|
||||||
type_map.insert::<SubsonicSongHandle>(song.clone());
|
type_map.insert::<SongHandle>(song.clone());
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_guild(ctx: Context<'_>) -> Guild {
|
pub fn get_guild(ctx: Context<'_>) -> Guild {
|
||||||
let guild = ctx.guild().expect("No guild!").clone();
|
ctx.guild()
|
||||||
log::info!("Got guild: {guild:?}");
|
.expect("Invalid (or no) guild configured!")
|
||||||
guild
|
.to_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_channel(ctx: Context<'_>) -> Result<ChannelId> {
|
pub fn get_channel(ctx: Context<'_>) -> Result<ChannelId> {
|
||||||
@@ -41,52 +47,45 @@ pub fn get_channel(ctx: Context<'_>) -> Result<ChannelId> {
|
|||||||
.voice_states
|
.voice_states
|
||||||
.get(&ctx.author().id)
|
.get(&ctx.author().id)
|
||||||
.ok_or_else(|| anyhow!("You must be in a voice channel to use this command"))?;
|
.ok_or_else(|| anyhow!("You must be in a voice channel to use this command"))?;
|
||||||
let channel = voice_state
|
voice_state
|
||||||
.channel_id
|
.channel_id
|
||||||
.ok_or_else(|| anyhow!("You must be in a voice channel to use this command"))?;
|
.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>>> {
|
pub async fn get_call(ctx: Context<'_>) -> Result<Arc<Mutex<songbird::Call>>> {
|
||||||
let manager = get_manager(ctx).await?;
|
let manager = get_manager(ctx).await?;
|
||||||
let guild = get_guild(ctx);
|
let guild = get_guild(ctx);
|
||||||
let call = manager.get(guild.id);
|
match manager.get(guild.id) {
|
||||||
|
Some(c) => Ok(c),
|
||||||
if let Some(c) = call {
|
None => {
|
||||||
Ok(c)
|
let channel = get_channel(ctx)?;
|
||||||
} else {
|
log::warn!("Not in a voice channel, trying to join {channel}");
|
||||||
let channel = get_channel(ctx)?;
|
manager
|
||||||
log::warn!("Not in a voice channel, trying to join {channel}");
|
.join(guild.id, channel)
|
||||||
let _handler = manager.join(guild.id, channel).await;
|
.await
|
||||||
manager
|
.context("Couldn't join voice channel. Try running 'join' manually.")
|
||||||
.get(guild.id)
|
}
|
||||||
.ok_or_else(|| anyhow!("Not in a voice channel, try running 'join'"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_song(track: &TrackHandle) -> Result<Song> {
|
pub async fn get_song(track: &TrackHandle) -> Result<Song> {
|
||||||
let song = track
|
track
|
||||||
.typemap()
|
.typemap()
|
||||||
.read()
|
.read()
|
||||||
.await
|
.await
|
||||||
.get::<SubsonicSongHandle>()
|
.get::<SongHandle>()
|
||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
.ok_or_else(|| anyhow!("Sound information not found"))?;
|
.ok_or_else(|| anyhow!("Sound information not found"))
|
||||||
Ok(song)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_manager(ctx: Context<'_>) -> Result<Arc<Songbird>> {
|
pub async fn get_manager(ctx: Context<'_>) -> Result<Arc<Songbird>> {
|
||||||
let manager = songbird::get(ctx.discord())
|
songbird::get(ctx.discord())
|
||||||
.await
|
.await
|
||||||
.ok_or_else(|| anyhow!("Couldn't start manager"))?;
|
.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> {
|
pub async fn load_song(song: &Song, client: &sunk::Client) -> Result<Track> {
|
||||||
log::info!("Loading song {song:?}");
|
|
||||||
let url = song.stream_url(client)?;
|
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)
|
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 client;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod common;
|
pub mod common;
|
||||||
pub mod handler;
|
|
||||||
|
|
||||||
pub use client::create_client;
|
pub use client::create_client;
|
||||||
pub use common::{Context, Data};
|
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 config;
|
||||||
pub mod discord;
|
pub mod discord;
|
||||||
pub mod handles;
|
|
||||||
|
|||||||
Reference in New Issue
Block a user