Create client to establish connection with discord and add basic framework with sample command
This commit is contained in:
24
cipher_discord_bot/Cargo.toml
Normal file
24
cipher_discord_bot/Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "cipher_discord_bot"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.5.27", features = ["derive", "env"] }
|
||||
dotenvy = { version = "0.15.7", features = ["clap"] }
|
||||
env_logger = "0.11.6"
|
||||
humantime = "2.1.0"
|
||||
log = "0.4.25"
|
||||
poise = "0.6.1"
|
||||
cipher_core = { path = "../cipher_core" }
|
||||
cipher_database = { path = "../cipher_database", default-features = false }
|
||||
secrecy = "0.10.3"
|
||||
serenity = "0.12.4"
|
||||
thiserror = "2.0.11"
|
||||
tokio = { version = "1.43.0", features = ["full"] }
|
||||
|
||||
[features]
|
||||
default = ["mysql", "postgres", "sqlite"]
|
||||
mysql = ["cipher_database/mysql"]
|
||||
postgres = ["cipher_database/postgres"]
|
||||
sqlite = ["cipher_database/sqlite"]
|
||||
30
cipher_discord_bot/src/app/event_handler.rs
Normal file
30
cipher_discord_bot/src/app/event_handler.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use cipher_core::repository::RepositoryProvider;
|
||||
use serenity::all::FullEvent;
|
||||
|
||||
use crate::utils;
|
||||
|
||||
use super::AppData;
|
||||
use super::AppError;
|
||||
|
||||
pub async fn event_handler<R: RepositoryProvider>(
|
||||
serenity_ctx: &serenity::client::Context,
|
||||
event: &FullEvent,
|
||||
framework_ctx: poise::FrameworkContext<'_, AppData<R>, AppError<R::BackendError>>,
|
||||
_data: &AppData<R>,
|
||||
) -> Result<(), AppError<R::BackendError>> {
|
||||
match event {
|
||||
FullEvent::Ready { data_about_bot } => {
|
||||
log::info!(
|
||||
"Connected as {} in {} guild(s).",
|
||||
data_about_bot.user.name,
|
||||
data_about_bot.guilds.len()
|
||||
);
|
||||
}
|
||||
FullEvent::CacheReady { guilds } => {
|
||||
utils::register_in_guilds(serenity_ctx, &framework_ctx.options.commands, guilds).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
41
cipher_discord_bot/src/app/framework.rs
Normal file
41
cipher_discord_bot/src/app/framework.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use poise::Framework;
|
||||
use poise::FrameworkOptions;
|
||||
use cipher_core::repository::RepositoryProvider;
|
||||
|
||||
use crate::commands;
|
||||
|
||||
use super::event_handler;
|
||||
use super::on_error;
|
||||
use super::AppData;
|
||||
use super::AppError;
|
||||
|
||||
pub fn framework<R>(repository_provider: R) -> Framework<AppData<R>, AppError<R::BackendError>>
|
||||
where
|
||||
R: RepositoryProvider + Send + Sync + 'static,
|
||||
R::BackendError: Send + Sync,
|
||||
for<'a> R::Repository<'a>: Send + Sync,
|
||||
{
|
||||
let commands = commands::commands();
|
||||
|
||||
let app_data = AppData {
|
||||
repository_provider,
|
||||
};
|
||||
|
||||
let options = FrameworkOptions::<AppData<R>, AppError<R::BackendError>> {
|
||||
commands,
|
||||
on_error: |framework_error| {
|
||||
Box::pin(async move { on_error::on_error(framework_error).await })
|
||||
},
|
||||
event_handler: |serenity_ctx, event, framework_ctx, data| {
|
||||
Box::pin(async move {
|
||||
event_handler::event_handler(serenity_ctx, event, framework_ctx, data).await
|
||||
})
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
Framework::builder()
|
||||
.options(options)
|
||||
.setup(|_ctx, _ready, _framework| Box::pin(async move { Ok(app_data) }))
|
||||
.build()
|
||||
}
|
||||
63
cipher_discord_bot/src/app/mod.rs
Normal file
63
cipher_discord_bot/src/app/mod.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use cipher_core::repository::RepositoryError;
|
||||
use cipher_core::repository::RepositoryProvider;
|
||||
use secrecy::ExposeSecret;
|
||||
use serenity::all::GatewayIntents;
|
||||
use serenity::Client;
|
||||
|
||||
use crate::cli::DiscordCredentials;
|
||||
|
||||
mod event_handler;
|
||||
mod framework;
|
||||
mod on_error;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppStartError {
|
||||
#[error(transparent)]
|
||||
SerenityError(#[from] serenity::Error),
|
||||
}
|
||||
|
||||
pub struct AppData<R> {
|
||||
repository_provider: R,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError<E> {
|
||||
#[error(transparent)]
|
||||
SerenityError(#[from] serenity::Error),
|
||||
#[error(transparent)]
|
||||
RepositoryError(#[from] RepositoryError<E>),
|
||||
}
|
||||
|
||||
pub type AppContext<'a, R, E> = poise::ApplicationContext<'a, AppData<R>, AppError<E>>;
|
||||
pub type AppCommand<R, E> = poise::Command<AppData<R>, AppError<E>>;
|
||||
|
||||
pub async fn start<R>(credentials: DiscordCredentials, repository_provider: R) -> Result<(), AppStartError>
|
||||
where
|
||||
R: RepositoryProvider + Send + Sync + 'static,
|
||||
R::BackendError: Send + Sync,
|
||||
for<'a> R::Repository<'a>: Send + Sync,
|
||||
{
|
||||
let mut client = Client::builder(credentials.bot_token.expose_secret(), GatewayIntents::all())
|
||||
.framework(framework::framework(repository_provider))
|
||||
.await?;
|
||||
|
||||
let shard_manager = client.shard_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = tokio::signal::ctrl_c().await {
|
||||
log::error!("Failed to register ctrl+c handler: {}", err);
|
||||
}
|
||||
log::info!("Stopping.");
|
||||
shard_manager.shutdown_all().await;
|
||||
});
|
||||
|
||||
client.start().await.map_err(AppStartError::from)
|
||||
}
|
||||
|
||||
impl<R> AppData<R>
|
||||
where
|
||||
R: RepositoryProvider,
|
||||
{
|
||||
pub async fn repository(&self) -> Result<R::Repository<'_>, RepositoryError<R::BackendError>> {
|
||||
self.repository_provider.get().await
|
||||
}
|
||||
}
|
||||
298
cipher_discord_bot/src/app/on_error.rs
Normal file
298
cipher_discord_bot/src/app/on_error.rs
Normal file
@@ -0,0 +1,298 @@
|
||||
use cipher_core::repository::RepositoryError;
|
||||
use cipher_core::repository::RepositoryProvider;
|
||||
use poise::CreateReply;
|
||||
use poise::FrameworkError;
|
||||
use serenity::all::Color;
|
||||
use serenity::all::CreateEmbed;
|
||||
use serenity::all::Permissions;
|
||||
|
||||
use super::AppData;
|
||||
use super::AppError;
|
||||
|
||||
struct ErrorMessage {
|
||||
embed: Option<ErrorEmbed>,
|
||||
log: Option<ErrorLog>,
|
||||
}
|
||||
|
||||
struct ErrorEmbed {
|
||||
title: String,
|
||||
description: String,
|
||||
}
|
||||
|
||||
struct ErrorLog {
|
||||
message: String,
|
||||
log_level: log::Level,
|
||||
}
|
||||
|
||||
impl ErrorMessage {
|
||||
fn new<T, D, M>(title: T, description: D, message: M, log_level: log::Level) -> Self
|
||||
where
|
||||
T: ToString,
|
||||
D: ToString,
|
||||
M: ToString,
|
||||
{
|
||||
Self {
|
||||
embed: Some(ErrorEmbed { title: title.to_string(), description: description.to_string() }),
|
||||
log: Some(ErrorLog { message: message.to_string(), log_level }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[rustfmt::skip]
|
||||
pub async fn on_error<R>(framework_error: FrameworkError<'_, AppData<R>, AppError<R::BackendError>>)
|
||||
where
|
||||
R: RepositoryProvider,
|
||||
{
|
||||
let ctx = framework_error.ctx();
|
||||
|
||||
let error_data = ErrorMessage::from(framework_error);
|
||||
|
||||
match error_data.log {
|
||||
Some(ErrorLog { message, log_level: log::Level::Trace }) => log::trace!("{}", message),
|
||||
Some(ErrorLog { message, log_level: log::Level::Debug }) => log::debug!("{}", message),
|
||||
Some(ErrorLog { message, log_level: log::Level::Info }) => log::info!("{}", message),
|
||||
Some(ErrorLog { message, log_level: log::Level::Warn }) => log::warn!("{}", message),
|
||||
Some(ErrorLog { message, log_level: log::Level::Error }) => log::error!("{}", message),
|
||||
None => {},
|
||||
}
|
||||
|
||||
if let Some((ctx, ErrorEmbed { title, description })) = ctx.zip(error_data.embed) {
|
||||
let embed = CreateEmbed::new()
|
||||
.title(title)
|
||||
.description(description)
|
||||
.color(Color::RED);
|
||||
|
||||
let reply = CreateReply::default()
|
||||
.embed(embed)
|
||||
.ephemeral(true);
|
||||
|
||||
ctx.send(reply).await.ok();
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, R> From<FrameworkError<'a, AppData<R>, AppError<R::BackendError>>> for ErrorMessage
|
||||
where
|
||||
R: RepositoryProvider,
|
||||
{
|
||||
fn from(value: FrameworkError<'a, AppData<R>, AppError<R::BackendError>>) -> ErrorMessage {
|
||||
use FrameworkError as F;
|
||||
|
||||
fn format_permissions(permissions: Permissions) -> String {
|
||||
permissions
|
||||
.iter_names()
|
||||
.map(|(name, _)| format!("`{}`", name))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
match value {
|
||||
F::Setup { error, framework, data_about_bot, ctx, .. } => error.into(),
|
||||
F::EventHandler { error, ctx, event, framework, .. } => error.into(),
|
||||
F::Command { error, ctx, .. } => error.into(),
|
||||
F::SubcommandRequired { ctx } => ErrorMessage::new(
|
||||
"Expected Subcommand",
|
||||
format!("Expected subcommand for `/{}`. Please contact a bot administrator to review the logs for further details.", ctx.command().qualified_name),
|
||||
format!("expected subcommand for `/{}`. this error has likely occurred due to the application commands not being synced with discord.", ctx.command().qualified_name),
|
||||
log::Level::Error,
|
||||
),
|
||||
F::CommandPanic { ctx, payload, .. } => ErrorMessage::new(
|
||||
"A Panic Has Occurred",
|
||||
"A panic has occurred during command execution. Please contact a bot administrator to review the logs for further details.",
|
||||
format!("panic in command `{}`: {}", ctx.command().qualified_name, payload.unwrap_or_else(|| "Unknown panic".to_string())),
|
||||
log::Level::Error,
|
||||
),
|
||||
F::ArgumentParse { error, input, ctx, .. } => ErrorMessage::new(
|
||||
"Argument Parse Error",
|
||||
"Failed to parse argument in command. Please contact a bot administrator to review the logs for further details.",
|
||||
format!("failed to parse argument in command `{}` on input {:?}", ctx.command().qualified_name, input),
|
||||
log::Level::Error,
|
||||
),
|
||||
F::CommandStructureMismatch { description, ctx, .. } => ErrorMessage::new(
|
||||
"Command Structure Mismatch",
|
||||
"Unexpected application command structure. Please contact a bot administrator to review the logs for further details.",
|
||||
format!("unexpected application command structure in command `{}`: {}", ctx.command.qualified_name, description),
|
||||
log::Level::Error,
|
||||
),
|
||||
F::CooldownHit { remaining_cooldown, ctx, .. } => ErrorMessage::new(
|
||||
"Cooldown Hit",
|
||||
format!("You can't use that command right now. Try again in {}.", humantime::format_duration(remaining_cooldown)),
|
||||
format!("cooldown hit in command `{}` ({:?} remaining)", ctx.command().qualified_name, remaining_cooldown),
|
||||
log::Level::Info,
|
||||
),
|
||||
F::MissingBotPermissions { missing_permissions, ctx, .. } => ErrorMessage::new(
|
||||
"Insufficient Bot Permissions",
|
||||
format!("The bot is missing the following permissions: {}.", format_permissions(missing_permissions)),
|
||||
format!("bot is missing permissions ({}) to execute command `{}`", missing_permissions, ctx.command().qualified_name),
|
||||
log::Level::Info,
|
||||
),
|
||||
F::MissingUserPermissions { missing_permissions, ctx, .. } => ErrorMessage::new(
|
||||
"Insufficient User Permissions",
|
||||
missing_permissions
|
||||
.map(|p| format!("You are missing the following permissions: {}.", format_permissions(p)))
|
||||
.unwrap_or_else(|| "Failed to get user permissions.".to_string()),
|
||||
format!("user is or may be missing permissions ({:?}) to execute command `{}`", missing_permissions, ctx.command().qualified_name),
|
||||
log::Level::Info,
|
||||
),
|
||||
F::NotAnOwner { ctx, .. } => ErrorMessage::new(
|
||||
"Owner Only Command",
|
||||
format!("`/{}` can only be used by bot owners.", ctx.command().qualified_name),
|
||||
format!("owner-only command `{}` cannot be run by non-owners", ctx.command().qualified_name),
|
||||
log::Level::Info,
|
||||
),
|
||||
F::GuildOnly { ctx, .. } => ErrorMessage::new(
|
||||
"Guild Only Command",
|
||||
format!("`/{}` can only be used in a server.", ctx.command().qualified_name),
|
||||
format!("guild-only command `{}` cannot be run in DMs", ctx.command().qualified_name),
|
||||
log::Level::Info,
|
||||
),
|
||||
F::DmOnly { ctx, .. } => ErrorMessage::new(
|
||||
"Direct Message Only Command",
|
||||
format!("`/{}` can only be used in direct messages.", ctx.command().qualified_name),
|
||||
format!("DM-only command `{}` cannot be run in DMs", ctx.command().qualified_name),
|
||||
log::Level::Info,
|
||||
),
|
||||
F::NsfwOnly { ctx, .. } => ErrorMessage::new(
|
||||
"NSFW Only Command",
|
||||
format!("`/{}` can only be used in channels marked as NSFW.", ctx.command().qualified_name),
|
||||
format!("nsfw-only command `{}` cannot be run in non-nsfw channels", ctx.command().qualified_name),
|
||||
log::Level::Info,
|
||||
),
|
||||
F::CommandCheckFailed { error, ctx, .. } => error.map(Into::into).unwrap_or_else(|| ErrorMessage::new(
|
||||
"Command Check Failed",
|
||||
"A pre-command check failed without a reason. Please contact a bot administrator to review the logs for further details.",
|
||||
format!("pre-command check for command `{}` either denied access or errored without a reason", ctx.command().qualified_name),
|
||||
log::Level::Warn,
|
||||
)),
|
||||
F::DynamicPrefix { error, ctx, msg, .. } => ErrorMessage::new(
|
||||
"Dynamic Prefix Error",
|
||||
format!("Dynamic prefix callback error on message {:?}", msg.content),
|
||||
format!("dynamic prefix callback error on message {:?}", msg.content),
|
||||
log::Level::Error,
|
||||
),
|
||||
F::UnknownCommand { ctx, msg, prefix, msg_content, framework, invocation_data, trigger, .. } => ErrorMessage::new(
|
||||
"Unknown Command",
|
||||
format!("Unknown command `{}`", msg_content),
|
||||
format!("unknown command `{}`", msg_content),
|
||||
log::Level::Error,
|
||||
),
|
||||
F::UnknownInteraction { ctx, framework, interaction, .. } => ErrorMessage::new(
|
||||
"Unknown Interaction",
|
||||
format!("Unknown interaction `{}`", interaction.data.name),
|
||||
format!("unknown interaction `{}`", interaction.data.name),
|
||||
log::Level::Error,
|
||||
),
|
||||
unknown_error => ErrorMessage::new(
|
||||
"Unexpected Error",
|
||||
"An unexpected error has occurred. Please contact a bot administrator to review the logs for further details.",
|
||||
format!("unknown error: {}", unknown_error),
|
||||
log::Level::Error,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<E> From<AppError<E>> for ErrorMessage
|
||||
where
|
||||
E: std::error::Error,
|
||||
{
|
||||
fn from(value: AppError<E>) -> ErrorMessage {
|
||||
use AppError as A;
|
||||
use serenity::Error as S;
|
||||
|
||||
#[allow(unused)]
|
||||
match value {
|
||||
A::SerenityError(S::Decode(msg, value)) => ErrorMessage::new(
|
||||
"Internal Error",
|
||||
"Please contact a bot administrator to review the logs for further details.",
|
||||
msg,
|
||||
log::Level::Error,
|
||||
),
|
||||
A::SerenityError(S::Format(error)) => ErrorMessage::new(
|
||||
"Internal Error",
|
||||
"Please contact a bot administrator to review the logs for further details.",
|
||||
error,
|
||||
log::Level::Error,
|
||||
),
|
||||
A::SerenityError(S::Io(error)) => ErrorMessage::new(
|
||||
"Internal Error",
|
||||
"Please contact a bot administrator to review the logs for further details.",
|
||||
error,
|
||||
log::Level::Error,
|
||||
),
|
||||
A::SerenityError(S::Json(error)) => ErrorMessage::new(
|
||||
"Internal Error",
|
||||
"Please contact a bot administrator to review the logs for further details.",
|
||||
error,
|
||||
log::Level::Error,
|
||||
),
|
||||
A::SerenityError(S::Model(error)) => ErrorMessage::new(
|
||||
"Internal Error",
|
||||
"Please contact a bot administrator to review the logs for further details.",
|
||||
error,
|
||||
log::Level::Error,
|
||||
),
|
||||
A::SerenityError(S::ExceededLimit(_, _)) => ErrorMessage::new(
|
||||
"Internal Error",
|
||||
"Please contact a bot administrator to review the logs for further details.",
|
||||
"input exceeded a limit",
|
||||
log::Level::Error,
|
||||
),
|
||||
A::SerenityError(S::NotInRange(_, _, _, _)) => ErrorMessage::new(
|
||||
"Internal Error",
|
||||
"Please contact a bot administrator to review the logs for further details.",
|
||||
"input is not in the specified range",
|
||||
log::Level::Error,
|
||||
),
|
||||
A::SerenityError(S::Other(msg)) => ErrorMessage::new(
|
||||
"Internal Error",
|
||||
"Please contact a bot administrator to review the logs for further details.",
|
||||
msg,
|
||||
log::Level::Error,
|
||||
),
|
||||
A::SerenityError(S::Url(msg)) => ErrorMessage::new(
|
||||
"Internal Error",
|
||||
"Please contact a bot administrator to review the logs for further details.",
|
||||
msg,
|
||||
log::Level::Error,
|
||||
),
|
||||
A::SerenityError(S::Client(error)) => ErrorMessage::new(
|
||||
"Internal Error",
|
||||
"Please contact a bot administrator to review the logs for further details.",
|
||||
error,
|
||||
log::Level::Error,
|
||||
),
|
||||
A::SerenityError(S::Gateway(error)) => ErrorMessage::new(
|
||||
"Internal Error",
|
||||
"Please contact a bot administrator to review the logs for further details.",
|
||||
error,
|
||||
log::Level::Error,
|
||||
),
|
||||
A::SerenityError(S::Http(http_error)) => ErrorMessage::new(
|
||||
"Internal Error",
|
||||
"Please contact a bot administrator to review the logs for further details.",
|
||||
http_error,
|
||||
log::Level::Error,
|
||||
),
|
||||
A::SerenityError(S::Tungstenite(error)) => ErrorMessage::new(
|
||||
"Internal Error",
|
||||
"Please contact a bot administrator to review the logs for further details.",
|
||||
error,
|
||||
log::Level::Error,
|
||||
),
|
||||
A::SerenityError(unknown_error) => ErrorMessage::new(
|
||||
"Internal Error",
|
||||
"Please contact a bot administrator to review the logs for further details.",
|
||||
format!("unknown error: {}", unknown_error),
|
||||
log::Level::Error,
|
||||
),
|
||||
|
||||
A::RepositoryError(RepositoryError(error)) => ErrorMessage::new(
|
||||
"Repository Backend Error",
|
||||
"Please contact a bot administrator to review the logs for further details.",
|
||||
format!("repository backend error: {}", error),
|
||||
log::Level::Error,
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
29
cipher_discord_bot/src/cli/command.rs
Normal file
29
cipher_discord_bot/src/cli/command.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use clap::Parser;
|
||||
|
||||
/// Subcommand of the CLI application.
|
||||
#[derive(Debug, Clone, Parser)]
|
||||
pub enum Command {
|
||||
/// Start the main discord bot application.
|
||||
#[command(
|
||||
name = "start",
|
||||
about,
|
||||
long_about = None,
|
||||
)]
|
||||
Start(super::start::Start),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CommandError {
|
||||
#[error(transparent)]
|
||||
StartError(#[from] super::start::StartError),
|
||||
}
|
||||
|
||||
impl Command {
|
||||
pub async fn execute(self) -> Result<(), CommandError> {
|
||||
match self {
|
||||
Command::Start(start) => start.execute().await?,
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
148
cipher_discord_bot/src/cli/mod.rs
Normal file
148
cipher_discord_bot/src/cli/mod.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
use std::fmt::Debug;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
use clap::ValueEnum;
|
||||
use command::Command;
|
||||
use secrecy::SecretString;
|
||||
|
||||
pub mod command;
|
||||
pub mod start;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CliError {
|
||||
#[error(transparent)]
|
||||
Dotenvy(#[from] dotenvy::Error),
|
||||
}
|
||||
|
||||
/// Parses command line arguments.
|
||||
pub fn parse() -> Result<Cli, CliError> {
|
||||
// First phase: If a dotenv file is specified, load values from it.
|
||||
// This allows values in the dotenv file to be used when parsing other arguments.
|
||||
let dotenv = Dotenv::parse();
|
||||
if let Some(dotenv_path) = &dotenv.path {
|
||||
dotenvy::from_path_override(dotenv_path)?;
|
||||
}
|
||||
|
||||
// Second phase: Parse CLI options using the `Cli` parser.
|
||||
let mut cli = Cli::parse();
|
||||
|
||||
// The `Cli` parser contains the `Dotenv` parser and it may parse different results in different phases.
|
||||
// It is replaced here to ensure the `Cli` instance reflects the original dotenv configuration.
|
||||
cli.dotenv = dotenv;
|
||||
|
||||
return Ok(cli);
|
||||
}
|
||||
|
||||
/// Main command line interface for the librarian application.
|
||||
///
|
||||
/// This struct combines the dotenv configuration with the subcommands
|
||||
/// that the CLI application supports.
|
||||
///
|
||||
/// The help and version flags and subcommands are explicitly enabled
|
||||
/// because `Dotenv` disables them and `Cli` requires them to be enabled.
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(
|
||||
name = "cipher",
|
||||
about,
|
||||
version,
|
||||
long_about = None,
|
||||
ignore_errors = false,
|
||||
disable_help_flag = false,
|
||||
disable_help_subcommand = false,
|
||||
disable_version_flag = false,
|
||||
)]
|
||||
pub struct Cli {
|
||||
/// Configuration for loading environment variables from a dotenv file.
|
||||
#[command(flatten)]
|
||||
pub dotenv: Dotenv,
|
||||
|
||||
/// The command to be executed as part of the CLI application.
|
||||
#[command(subcommand)]
|
||||
pub command: Command,
|
||||
}
|
||||
|
||||
/// Configuration for loading environment variables from a dotenv file.
|
||||
///
|
||||
/// This struct is used to specify the path to a dotenv file from which
|
||||
/// environment variables can be loaded. The default value is `.env`.
|
||||
///
|
||||
/// The help and version flags and subcommands are disabled because
|
||||
/// they are handled by `Cli` which is parsed after `Dotenv`.
|
||||
#[derive(Debug, Clone, Parser)]
|
||||
#[command(
|
||||
ignore_errors = true,
|
||||
disable_help_flag = true,
|
||||
disable_help_subcommand = true,
|
||||
disable_version_flag = true
|
||||
)]
|
||||
pub struct Dotenv {
|
||||
/// The path to the dotenv file.
|
||||
#[arg(
|
||||
name = "path",
|
||||
short = None,
|
||||
long = "dotenv",
|
||||
env = "DOTENV",
|
||||
)]
|
||||
pub path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, ValueEnum)]
|
||||
pub enum DatabaseDialect {
|
||||
#[cfg(feature = "mysql")]
|
||||
Mysql,
|
||||
#[cfg(feature = "postgres")]
|
||||
Postgres,
|
||||
#[cfg(feature = "sqlite")]
|
||||
Sqlite,
|
||||
}
|
||||
|
||||
impl From<DatabaseDialect> for cipher_database::DatabaseDialect {
|
||||
fn from(value: DatabaseDialect) -> Self {
|
||||
use cipher_database::DatabaseDialect as Dialect;
|
||||
match value {
|
||||
#[cfg(feature = "mysql")]
|
||||
DatabaseDialect::Mysql => Dialect::Mysql,
|
||||
#[cfg(feature = "postgres")]
|
||||
DatabaseDialect::Postgres => Dialect::Postgres,
|
||||
#[cfg(feature = "sqlite")]
|
||||
DatabaseDialect::Sqlite => Dialect::Sqlite,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Credentials required to establish a database connection.
|
||||
#[derive(Clone, Debug, Parser)]
|
||||
pub struct DatabaseCredentials {
|
||||
/// The dialect of the database to connect to.
|
||||
#[arg(
|
||||
short = None,
|
||||
long = "database-dialect",
|
||||
env = "DATABASE_DIALECT",
|
||||
)]
|
||||
pub dialect: DatabaseDialect,
|
||||
|
||||
/// The URL of the database to connect to. This should include the
|
||||
/// necessary credentials (username and password) and the database
|
||||
/// name, following the format: `dialect://username:password@host:port/database`.
|
||||
#[arg(
|
||||
short = None,
|
||||
long = "database-url",
|
||||
env = "DATABASE_URL",
|
||||
hide_env_values(true),
|
||||
)]
|
||||
pub url: SecretString,
|
||||
}
|
||||
|
||||
/// Credentials required to authenticate a bot with Discord.
|
||||
#[derive(Clone, Debug, Parser)]
|
||||
pub struct DiscordCredentials {
|
||||
/// The token used to authenticate the bot with Discord.
|
||||
#[arg(
|
||||
short = None,
|
||||
long = "bot-token",
|
||||
env = "BOT_TOKEN",
|
||||
hide_env_values(true),
|
||||
)]
|
||||
pub bot_token: SecretString,
|
||||
}
|
||||
62
cipher_discord_bot/src/cli/start.rs
Normal file
62
cipher_discord_bot/src/cli/start.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use clap::Parser;
|
||||
use secrecy::ExposeSecret;
|
||||
|
||||
use super::DatabaseCredentials;
|
||||
use super::DiscordCredentials;
|
||||
|
||||
/// Start the main discord bot application.
|
||||
#[derive(Debug, Clone, Parser)]
|
||||
pub struct Start {
|
||||
/// Credentials required to establish a database connection.
|
||||
#[command(flatten)]
|
||||
pub database: DatabaseCredentials,
|
||||
|
||||
/// Credentials required to authenticate a bot with Discord.
|
||||
#[command(flatten)]
|
||||
pub discord: DiscordCredentials,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum StartError {
|
||||
#[error(transparent)]
|
||||
RepositoryBackendError(#[from] cipher_database::BackendError),
|
||||
#[error(transparent)]
|
||||
AppError(#[from] crate::app::AppStartError),
|
||||
}
|
||||
|
||||
impl Start {
|
||||
pub async fn execute(self) -> Result<(), StartError> {
|
||||
log::debug!("{:#?}", self);
|
||||
|
||||
let database_url = self.database.url.expose_secret();
|
||||
|
||||
match self.database.dialect {
|
||||
#[cfg(feature = "mysql")]
|
||||
crate::cli::DatabaseDialect::Mysql => {
|
||||
log::info!("Running any pending database migrations.");
|
||||
cipher_database::mysql::run_pending_migrations(database_url)?;
|
||||
let repository_provider = cipher_database::mysql::repository_provider(database_url).await?;
|
||||
log::info!("Starting discord application.");
|
||||
crate::app::start(self.discord, repository_provider).await?;
|
||||
},
|
||||
#[cfg(feature = "postgres")]
|
||||
crate::cli::DatabaseDialect::Postgres => {
|
||||
log::info!("Running any pending database migrations.");
|
||||
cipher_database::postgres::run_pending_migrations(database_url)?;
|
||||
let repository_provider = cipher_database::postgres::repository_provider(database_url).await?;
|
||||
log::info!("Starting discord application.");
|
||||
crate::app::start(self.discord, repository_provider).await?;
|
||||
},
|
||||
#[cfg(feature = "sqlite")]
|
||||
crate::cli::DatabaseDialect::Sqlite => {
|
||||
log::info!("Running any pending database migrations.");
|
||||
cipher_database::sqlite::run_pending_migrations(database_url)?;
|
||||
let repository_provider = cipher_database::sqlite::repository_provider(database_url).await?;
|
||||
log::info!("Starting discord application.");
|
||||
crate::app::start(self.discord, repository_provider).await?;
|
||||
},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
14
cipher_discord_bot/src/commands/mod.rs
Normal file
14
cipher_discord_bot/src/commands/mod.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use cipher_core::repository::RepositoryProvider;
|
||||
|
||||
use crate::app::AppCommand;
|
||||
|
||||
mod ping;
|
||||
|
||||
pub fn commands<R>() -> Vec<AppCommand<R, R::BackendError>>
|
||||
where
|
||||
R: RepositoryProvider + Send + Sync + 'static,
|
||||
{
|
||||
vec![
|
||||
ping::ping(),
|
||||
]
|
||||
}
|
||||
22
cipher_discord_bot/src/commands/ping.rs
Normal file
22
cipher_discord_bot/src/commands/ping.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
use poise::CreateReply;
|
||||
use cipher_core::repository::RepositoryProvider;
|
||||
use serenity::all::CreateEmbed;
|
||||
|
||||
use crate::app::AppContext;
|
||||
use crate::app::AppError;
|
||||
use crate::utils;
|
||||
|
||||
/// Is the bot alive or dead? :thinking:
|
||||
#[poise::command(slash_command)]
|
||||
pub async fn ping<R: RepositoryProvider + Send + Sync>(ctx: AppContext<'_, R, R::BackendError>) -> Result<(), AppError<R::BackendError>> {
|
||||
let embed = CreateEmbed::new()
|
||||
.title("Pong :ping_pong:")
|
||||
.color(utils::bot_color(&ctx).await);
|
||||
|
||||
let reply = CreateReply::default()
|
||||
.embed(embed);
|
||||
|
||||
ctx.send(reply).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
22
cipher_discord_bot/src/main.rs
Normal file
22
cipher_discord_bot/src/main.rs
Normal file
@@ -0,0 +1,22 @@
|
||||
mod app;
|
||||
mod cli;
|
||||
mod commands;
|
||||
mod utils;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum MainError {
|
||||
#[error(transparent)]
|
||||
CliError(#[from] cli::CliError),
|
||||
#[error(transparent)]
|
||||
CommandError(#[from] cli::command::CommandError)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), MainError> {
|
||||
let c = cli::parse()?;
|
||||
env_logger::init();
|
||||
|
||||
c.command.execute().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
35
cipher_discord_bot/src/utils.rs
Normal file
35
cipher_discord_bot/src/utils.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
use cipher_core::repository::RepositoryProvider;
|
||||
use serenity::all::{Color, GuildId};
|
||||
|
||||
use crate::app::{AppCommand, AppContext};
|
||||
|
||||
pub async fn register_in_guilds<R>(
|
||||
serenity_ctx: &serenity::client::Context,
|
||||
commands: &[AppCommand<R, R::BackendError>],
|
||||
guilds: &[GuildId],
|
||||
)
|
||||
where
|
||||
R: RepositoryProvider,
|
||||
{
|
||||
for guild in guilds {
|
||||
let result = poise::builtins::register_in_guild(serenity_ctx, commands, *guild).await;
|
||||
match (guild.name(serenity_ctx), result) {
|
||||
(None, Err(err)) => log::warn!("Failed to register command in guild with id {}: {}", guild.get().to_string(), err),
|
||||
(None, Ok(())) => log::info!("Successfully registered commands in guild with id {}", guild.get().to_string()),
|
||||
(Some(guild_name), Err(err)) => log::warn!("Failed to register command in guild with id {} ({}): {}", guild.get().to_string(), guild_name, err),
|
||||
(Some(guild_name), Ok(())) => log::info!("Successfully registered commands in guild with id {} ({})", guild.get().to_string(), guild_name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn bot_color<R>(ctx: &AppContext<'_, R, R::BackendError>) -> Color
|
||||
where
|
||||
R: RepositoryProvider + Send + Sync,
|
||||
{
|
||||
let member = match ctx.guild_id() {
|
||||
Some(guild) => guild.member(ctx, ctx.framework.bot_id).await.ok(),
|
||||
None => None,
|
||||
};
|
||||
|
||||
member.and_then(|m| m.colour(ctx)).unwrap_or(Color::BLURPLE)
|
||||
}
|
||||
Reference in New Issue
Block a user