Compare commits

...

11 commits

25 changed files with 1324 additions and 1085 deletions

1
.gitignore vendored
View file

@ -1,5 +1,6 @@
target/
logs/
.helix
result
.env

1635
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,10 +1,11 @@
[package]
name = "yorokobot"
description = "A Discord bot to handle a topic subscription system"
description = "Discord bot implementing a topic management system"
version = "0.2.1"
authors = [ "Victor Mignot <dala@dalaran.fr>" ]
license = "EUPL-1.2"
readme = "README.md"
repository = "https://git.dalaran.fr/dala/yorokobot"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@ -14,6 +15,10 @@ serenity = { version="0.11", default-features = false, features = ["client", "ga
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
mongodb = { version = "2.3.0", default-features = false, features = ["tokio-runtime"] }
serde = { version = "1.0", features = [ "derive" ] }
log4rs = { version = "1.2.0", features = [ "console_appender", "rolling_file_appender", "compound_policy", "fixed_window_roller", "size_trigger", "gzip", "background_rotation" ] }
log = "0.4.17"
futures = "0.3.25"
env_logger = "0.11.6"
[lints.rust]
unsafe_code= "forbid"
missing_docs = "forbid"

View file

@ -1,35 +1,9 @@
# YorokoBot
# Yorokobot
This bot is a remake of the original HokuBot
using Rust with the Serenity crate and the Nix package manager.
Yorokobot is a Discord bot that implement a topic-subscription systems.
It allows server owner to create a list of custom topics to which users can then subscribe.
The main instance of this bot was made specifically for the [Archetype:Moon](https://archetype-moon.fr/)
server and is not public.
If you want to add the bot to your own server (or host a public version), you can run your own instance
of Yorokobot as long as the license terms are respected.
## Licensing
## What is YorokoBot ?
YorokoBot is a Discord bot allowing users to subscribe to tags.
Tags represent someone's center of interest.
If something related to this topic is post on Discord, the sender can use
the bot to ping each users that subscribed to this topic.
## Why using YorokoBot when Discord roles exist ?
To keep it simple, to prevent a Discord server to create a ton of roles.
## How Yorokobot permissions are managed ?
Yorokobot has no permission management system.
Discord provides for each server a way to handle who can use a specific command and on which channel.
Once the bot in on your server, you can do a right click on the bot user and select "Manage integration".
## Is there a global banlist ?
No, at least for now.
## Why is not the main bot instance public ?
I'm currently a student and this is just a project I did because it would be useful for the Archetype-Moon Discord server.
However, it was important for me to make it available to everyone who wanted to use it as long as they are ready to run it themselves.
This project is licensed under the `European Union Public License v. 1.2`.
Please refer to the `LICENSE` file if needed.

18
default.nix Normal file
View file

@ -0,0 +1,18 @@
{
pkgs ? import ./pkgs.nix,
...
}:
pkgs.rustPlatform.buildRustPackage {
pname = "yorokobot";
version = "0.2.1";
src = ./.;
cargoHash = "sha256-jspdQtP+z8SXD7gs5G0GrvJLt6sU6RyvGyn5SPQU85U=";
meta = with pkgs.lib; {
description = "Discord bot implementing a topic management system";
homepage = "https://git.dalaran.fr/dala/yorokobot";
license = licenses.eupl12;
};
}

94
flake.lock generated
View file

@ -1,94 +0,0 @@
{
"nodes": {
"flake-utils": {
"locked": {
"lastModified": 1659877975,
"narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"flake-utils_2": {
"locked": {
"lastModified": 1656928814,
"narHash": "sha256-RIFfgBuKz6Hp89yRr7+NR5tzIAbn52h8vT6vXkYjZoM=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "7e2a3b3dfd9af950a856d66b0a7d01e3c18aa249",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1663494472,
"narHash": "sha256-fSowlaoXXWcAM8m9wA6u+eTJJtvruYHMA+Lb/tFi/qM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "f677051b8dc0b5e2a9348941c99eea8c4b0ff28f",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1659102345,
"narHash": "sha256-Vbzlz254EMZvn28BhpN8JOi5EuKqnHZ3ujFYgFcSGvk=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "11b60e4f80d87794a2a4a8a256391b37c59a1ea7",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"flake-utils": "flake-utils_2",
"nixpkgs": "nixpkgs_2"
},
"locked": {
"lastModified": 1663729386,
"narHash": "sha256-aKdxkiYUGuvgy+eKq4jubf/gZN7TBoF6huE4w0chhDE=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "0300688a98e053712108d4e22d5bdcf9c9106d8c",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

View file

@ -1,58 +0,0 @@
{
description = "Discord Bot managing users interests.";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
rust-overlay.url = "github:oxalica/rust-overlay";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = {self, nixpkgs, rust-overlay, flake-utils, ...}:
flake-utils.lib.eachDefaultSystem (system:
let
overlays = [ (import rust-overlay) ];
pkgs = import nixpkgs {
inherit system overlays;
};
in
with pkgs;
{
packages.default = pkgs.rustPlatform.buildRustPackage rec {
pname = "yorokobot";
version = "0.2.1";
src = self;
cargoSha256 = "sha256-FfhyVjCZRvjbSAQ8aGTegjtSh5d4vMSoxzk+xagmj9w=";
meta = with pkgs.lib; {
description = "Discord bot implementing a topic management system";
homepage = "https://sr.ht/~dala/yorokobot";
license = licenses.agpl3;
};
};
devShells.default = mkShell {
buildInputs = [
(
rust-bin.stable.latest.default.override {
extensions = [ "rust-src" ];
}
)
];
packages = with pkgs; [
rust-analyzer
rustfmt
gdb
];
shellHook = ''
set -a
source .env
set +a
'';
};
}
);
}

80
npins/default.nix Normal file
View file

@ -0,0 +1,80 @@
# Generated by npins. Do not modify; will be overwritten regularly
let
data = builtins.fromJSON (builtins.readFile ./sources.json);
version = data.version;
mkSource =
spec:
assert spec ? type;
let
path =
if spec.type == "Git" then
mkGitSource spec
else if spec.type == "GitRelease" then
mkGitSource spec
else if spec.type == "PyPi" then
mkPyPiSource spec
else if spec.type == "Channel" then
mkChannelSource spec
else
builtins.throw "Unknown source type ${spec.type}";
in
spec // { outPath = path; };
mkGitSource =
{
repository,
revision,
url ? null,
hash,
branch ? null,
...
}:
assert repository ? type;
# At the moment, either it is a plain git repository (which has an url), or it is a GitHub/GitLab repository
# In the latter case, there we will always be an url to the tarball
if url != null then
(builtins.fetchTarball {
inherit url;
sha256 = hash; # FIXME: check nix version & use SRI hashes
})
else
assert repository.type == "Git";
let
urlToName =
url: rev:
let
matched = builtins.match "^.*/([^/]*)(\\.git)?$" repository.url;
short = builtins.substring 0 7 rev;
appendShort = if (builtins.match "[a-f0-9]*" rev) != null then "-${short}" else "";
in
"${if matched == null then "source" else builtins.head matched}${appendShort}";
name = urlToName repository.url revision;
in
builtins.fetchGit {
url = repository.url;
rev = revision;
inherit name;
# hash = hash;
};
mkPyPiSource =
{ url, hash, ... }:
builtins.fetchurl {
inherit url;
sha256 = hash;
};
mkChannelSource =
{ url, hash, ... }:
builtins.fetchTarball {
inherit url;
sha256 = hash;
};
in
if version == 3 then
builtins.mapAttrs (_: mkSource) data.pins
else
throw "Unsupported format version ${toString version} in sources.json. Try running `npins upgrade`"

11
npins/sources.json Normal file
View file

@ -0,0 +1,11 @@
{
"pins": {
"nixpkgs": {
"type": "Channel",
"name": "nixpkgs-unstable",
"url": "https://releases.nixos.org/nixpkgs/nixpkgs-25.05pre730187.0e82ab234249/nixexprs.tar.xz",
"hash": "0s5snh81d5n9zxcn9n2fqk6jcinfd5ys97fcw8qyrs9dlprp1slw"
}
},
"version": 3
}

4
pkgs.nix Normal file
View file

@ -0,0 +1,4 @@
let
inherit (import ./npins) nixpkgs;
in
import nixpkgs { }

23
shell.nix Normal file
View file

@ -0,0 +1,23 @@
{
pkgs ? import ./pkgs.nix,
...
}:
pkgs.mkShell {
strictDeps = true;
nativeBuildInputs = with pkgs; [
rustc
cargo
];
packages = with pkgs; [
clippy
rust-analyzer
rustfmt
gdb
nixfmt-rfc-style
nil
ltex-ls
marksman
];
}

View file

@ -12,10 +12,10 @@ use crate::discord::event_handler::Handler;
///
/// To launch Yorokobot, you have to set the following environment variables:
/// - DISCORD_TOKEN: The secret Discord provide you when creating a new bot in the
/// Discord Developper websites.
/// Discord Developper websites.
/// - MONGODB_URI: The connection string to your Mongo database.
/// - MONGODB_DATABASE: The database to use in your Mongo instance (falcultative if given in the
/// MONGODB_URI connection string).
/// MONGODB_URI connection string).
pub struct Client {
serenity_client: SerenityClient,
}

View file

@ -81,7 +81,7 @@ impl Client {
/// This method will panic if:
/// - The MONGODB_URI environment variable is not set.
/// - If the database to use is not specified in the connection string given with MONGODB_URI
/// and if MONGODB_DATABASE is not set.
/// and if MONGODB_DATABASE is not set.
/// - If the given credentials are invalid.
pub async fn connect(&mut self) {
self.mongo_client = match MongoClient::with_uri_str(get_env_variable("MONGODB_URI")).await {

View file

@ -1,7 +1,7 @@
//! Commons elements that are used in while executing the bot commands.
use log::{info, warn};
use std::sync::Arc;
use std::{fmt::Display, sync::Arc};
use serenity::{
async_trait,
@ -18,31 +18,65 @@ use crate::database::Client as DatabaseClient;
/// The kind of errors that can be returned while executing a command.
#[derive(Debug)]
pub enum CommandExecutionError {
pub enum Error {
/// An error that is returned when the `Serenity` crate failed to get the argument from the
/// Discord API payload.
ArgumentExtractionError(String),
InvalidCommand(String),
/// An error that is returned when the cast from the argument value to a Rust type failed.
ArgumentDeserializationError(String),
ArgumentDeserialization(String),
/// The kind of error that is returned when a Database query failed.
DatabaseQueryError(String),
DatabaseQuery(String),
/// Error returned when we fail to extract the context of a Discord commands.
ContextRetrievalError(String),
ContextRetrieval(String),
/// Error returned when sending a command to the discord API failed.
DiscordAPICallError(String),
DiscordAPICall(serenity::Error),
/// Error returned when there was an issue while using a selector embed in the command
/// response.
SelectorError(String),
Selector(String),
/// Error returned when the given command is unknown to Yorokobot.
UnknownCommand(String),
}
impl Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let string = match self {
Error::InvalidCommand(e) => format!("Received an invalid command: {e}"),
Error::ArgumentDeserialization(e) => {
format!("Failed to deserialize command argument: {e}")
}
Error::DatabaseQuery(e) => format!("Failed to execute a database query: {e}"),
Error::ContextRetrieval(e) => {
format!("Failed to retrieve current session context: {e}")
}
Error::DiscordAPICall(e) => format!("Error during Discord API call: {e}"),
Error::Selector(e) => format!("Met an error while using an embed selector: {e}"),
Error::UnknownCommand(e) => format!("Met an unknown bot command: {e}"),
};
write!(f, "{string}")
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Error::InvalidCommand(_) => None,
Error::ArgumentDeserialization(_) => None,
Error::DatabaseQuery(_) => None,
Error::ContextRetrieval(_) => None,
Error::DiscordAPICall(error) => error.source(),
Error::Selector(_) => None,
Error::UnknownCommand(_) => None,
}
}
}
/// An option that can/have to be passed to a Discord command.
pub struct BotCommandOption {
pub name: String,
@ -67,11 +101,7 @@ pub trait BotCommand {
fn options_list() -> Vec<BotCommandOption>;
/// Execute the command with the given `context` and using the given `database`.
async fn run(
&self,
context: Context,
database: Arc<DatabaseClient>,
) -> Result<(), CommandExecutionError>;
async fn run(&self, context: Context, database: Arc<DatabaseClient>) -> Result<(), Error>;
/// Extract and deserialize the `index`th value from the command `options`.
fn extract_option_value(

View file

@ -20,7 +20,7 @@ use crate::database::{
Client as DatabaseClient,
};
use super::commons::{BotCommand, BotCommandOption, CommandExecutionError};
use super::commons::{BotCommand, BotCommandOption, Error};
pub struct CreateTagCommand {
interaction: ApplicationCommandInteraction,
@ -50,25 +50,21 @@ impl BotCommand for CreateTagCommand {
CreateTagCommand { interaction }
}
async fn run(
&self,
context: Context,
database: Arc<DatabaseClient>,
) -> Result<(), CommandExecutionError> {
async fn run(&self, context: Context, database: Arc<DatabaseClient>) -> Result<(), Error> {
// Extract tag_name parameter
let tag_name = match self.interaction.data.options.get(0) {
let tag_name = match self.interaction.data.options.first() {
Some(a) => match &a.resolved {
Some(r) => match r {
CommandDataOptionValue::String(r_str) => Ok(r_str),
_ => Err(CommandExecutionError::ArgumentDeserializationError(
_ => Err(Error::ArgumentDeserialization(
"Received non String argument for the CreateTagCommand".to_string(),
)),
},
None => Err(CommandExecutionError::ArgumentDeserializationError(
None => Err(Error::ArgumentDeserialization(
"Could not deserialize the argument for the CreateTagCommand".to_string(),
)),
},
None => Err(CommandExecutionError::ArgumentExtractionError(
None => Err(Error::InvalidCommand(
"Failed to get the CreateTagCommand argument".to_string(),
)),
}?;
@ -76,7 +72,7 @@ impl BotCommand for CreateTagCommand {
// Extract guild id from Serenity context
let guild_id = match self.interaction.guild_id {
Some(a) => Ok(a.to_string()),
None => Err(CommandExecutionError::ContextRetrievalError(
None => Err(Error::ContextRetrieval(
"Could not fetch guild id from issued command".to_string(),
)),
}?;
@ -84,11 +80,11 @@ impl BotCommand for CreateTagCommand {
let guild = match database.get_by_id::<Guild>(&guild_id).await {
Ok(query) => match query {
Some(r) => Ok(r),
None => Err(CommandExecutionError::ContextRetrievalError(
None => Err(Error::ContextRetrieval(
"Failed to retrieve the guild where the command was issued".to_string(),
)),
},
Err(()) => Err(CommandExecutionError::DatabaseQueryError(
Err(()) => Err(Error::DatabaseQuery(
"Could not access to the database".to_string(),
)),
}?;
@ -113,25 +109,19 @@ impl BotCommand for CreateTagCommand {
.await
{
Ok(_) => Ok(String::from("Tag successfully created.")),
Err(_) => Err(CommandExecutionError::DatabaseQueryError(
Err(_) => Err(Error::DatabaseQuery(
"Could not add new tag to the database".to_string(),
)),
}?
};
match self
.interaction
self.interaction
.create_interaction_response(context.http, |response| {
response
.kind(InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|message| message.content(response_content))
})
.await
{
Ok(_) => Ok(()),
Err(_e) => Err(CommandExecutionError::DiscordAPICallError(
"Failed to answer to the initial command".to_string(),
)),
}
.map_err(Error::DiscordAPICall)
}
}

View file

@ -18,7 +18,7 @@ use serenity::{
};
use super::{
commons::{BotCommandOption, CommandExecutionError},
commons::{BotCommandOption, Error},
BotCommand,
};
@ -50,31 +50,27 @@ impl BotCommand for DeleteTagCommand {
}]
}
async fn run(
&self,
context: Context,
database: Arc<DatabaseClient>,
) -> Result<(), CommandExecutionError> {
let tag_name = match self.interaction.data.options.get(0) {
async fn run(&self, context: Context, database: Arc<DatabaseClient>) -> Result<(), Error> {
let tag_name = match self.interaction.data.options.first() {
Some(a) => match &a.resolved {
Some(r) => match r {
CommandDataOptionValue::String(r_str) => Ok(r_str),
_ => Err(CommandExecutionError::ArgumentDeserializationError(
_ => Err(Error::ArgumentDeserialization(
"Received non String argument for DeleteTagCommand".to_string(),
)),
},
None => Err(CommandExecutionError::ArgumentDeserializationError(
None => Err(Error::ArgumentDeserialization(
"Failed to deserialize argument for DeleteTagCommand".to_string(),
)),
},
None => Err(CommandExecutionError::ArgumentExtractionError(
None => Err(Error::InvalidCommand(
"Failed to find argument in DeleteTagCommand".to_string(),
)),
}?;
let guild_id = match self.interaction.guild_id {
Some(r) => Ok(r.to_string()),
None => Err(CommandExecutionError::ContextRetrievalError(
None => Err(Error::ContextRetrieval(
"Failed to extract guild id from current context".to_string(),
)),
}?;
@ -82,11 +78,11 @@ impl BotCommand for DeleteTagCommand {
let guild = match database.get_by_id::<Guild>(&guild_id).await {
Ok(query) => match query {
Some(r) => Ok(r),
None => Err(CommandExecutionError::ContextRetrievalError(
None => Err(Error::ContextRetrieval(
"Failed to retrieve the guild where the command was issued".to_string(),
)),
},
Err(()) => Err(CommandExecutionError::DatabaseQueryError(
Err(()) => Err(Error::DatabaseQuery(
"Failed to access to the database".to_string(),
)),
}?;
@ -104,7 +100,7 @@ impl BotCommand for DeleteTagCommand {
.await
{
Ok(_) => Ok(String::from("Successfully remove the tag")),
Err(()) => Err(CommandExecutionError::DatabaseQueryError(
Err(()) => Err(Error::DatabaseQuery(
"Failed to remove tag from the database".to_string(),
)),
}?
@ -112,18 +108,12 @@ impl BotCommand for DeleteTagCommand {
String::from("No matching tag for this server.")
};
match self
.interaction
self.interaction
.create_interaction_response(context.http, |r| {
r.kind(InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|message| message.content(response))
})
.await
{
Ok(()) => Ok(()),
Err(_e) => Err(CommandExecutionError::DiscordAPICallError(
"Failed to answer the initial command".to_string(),
)),
}
.map_err(Error::DiscordAPICall)
}
}

View file

@ -13,7 +13,7 @@ use serenity::{
use crate::database::{models::Guild, Client as DatabaseClient};
use super::{
commons::{BotCommandOption, CommandExecutionError},
commons::{BotCommandOption, Error},
BotCommand,
};
@ -40,14 +40,10 @@ impl BotCommand for ListTagCommand {
ListTagCommand { interaction }
}
async fn run(
&self,
context: Context,
database: Arc<DatabaseClient>,
) -> Result<(), CommandExecutionError> {
async fn run(&self, context: Context, database: Arc<DatabaseClient>) -> Result<(), Error> {
let guild_id = match self.interaction.guild_id {
Some(id) => Ok(id.to_string()),
None => Err(CommandExecutionError::ContextRetrievalError(
None => Err(Error::ContextRetrieval(
"Failed to extract guild id from current context".to_string(),
)),
}?;
@ -55,11 +51,11 @@ impl BotCommand for ListTagCommand {
let guild = match database.get_by_id::<Guild>(&guild_id).await {
Ok(query) => match query {
Some(r) => Ok(r),
None => Err(CommandExecutionError::ContextRetrievalError(
None => Err(Error::ContextRetrieval(
"Failed to retrieve the guild where the command was issued".to_string(),
)),
},
Err(()) => Err(CommandExecutionError::DatabaseQueryError(
Err(()) => Err(Error::DatabaseQuery(
"Failed to access to the database".to_string(),
)),
}?;
@ -79,19 +75,13 @@ impl BotCommand for ListTagCommand {
}
}
match self
.interaction
self.interaction
.create_interaction_response(context.http, |response| {
response
.kind(InteractionResponseType::ChannelMessageWithSource)
.interaction_response_data(|message| message.content(response_content))
})
.await
{
Ok(()) => Ok(()),
Err(_) => Err(CommandExecutionError::DiscordAPICallError(
"Failed to answer to the initial command".to_string(),
)),
}
.map_err(Error::DiscordAPICall)
}
}

View file

@ -14,7 +14,7 @@ use crate::{
discord::message_builders::embed_builder::EmbedMessageBuilder,
};
use super::commons::{BotCommand, BotCommandOption, CommandExecutionError};
use super::commons::{BotCommand, BotCommandOption, Error};
pub struct SourceCodeCommand {
interaction: ApplicationCommandInteraction,
@ -39,14 +39,9 @@ impl BotCommand for SourceCodeCommand {
SourceCodeCommand { interaction }
}
async fn run(
&self,
context: Context,
_: Arc<DatabaseClient>,
) -> Result<(), CommandExecutionError> {
async fn run(&self, context: Context, _: Arc<DatabaseClient>) -> Result<(), Error> {
let embed_builder = EmbedMessageBuilder::new(&context).await?;
match self
.interaction
self.interaction
.create_interaction_response(context.http, |response| {
response
.kind(InteractionResponseType::ChannelMessageWithSource)
@ -55,11 +50,6 @@ impl BotCommand for SourceCodeCommand {
})
})
.await
{
Ok(()) => Ok(()),
Err(_e) => Err(CommandExecutionError::DiscordAPICallError(
"Failed to answer to the issued command".to_string(),
)),
}
.map_err(Error::DiscordAPICall)
}
}

View file

@ -12,7 +12,7 @@ use serenity::{
};
use super::{
commons::{BotCommandOption, CommandExecutionError},
commons::{BotCommandOption, Error},
BotCommand,
};
use crate::{
@ -43,26 +43,18 @@ impl BotCommand for SubscribeCommand {
vec![]
}
async fn run(
&self,
context: Context,
database: Arc<DatabaseClient>,
) -> Result<(), CommandExecutionError> {
async fn run(&self, context: Context, database: Arc<DatabaseClient>) -> Result<(), Error> {
self.interaction
.create_interaction_response(&context.http, |response| {
response.kind(InteractionResponseType::DeferredChannelMessageWithSource)
})
.await
.map_err(|_e| {
CommandExecutionError::DiscordAPICallError(
"Failed to answer with a temporary response".to_string(),
)
})?;
.map_err(Error::DiscordAPICall)?;
let user_id = self.interaction.user.id.to_string();
let guild_id = match self.interaction.guild_id {
Some(id) => Ok(id.to_string()),
None => Err(CommandExecutionError::ContextRetrievalError(
None => Err(Error::ContextRetrieval(
"Failed to extract guild id from current context".to_string(),
)),
}?;
@ -70,11 +62,11 @@ impl BotCommand for SubscribeCommand {
let guild = match database.get_by_id::<Guild>(&guild_id).await {
Ok(query) => match query {
Some(r) => Ok(r),
None => Err(CommandExecutionError::ContextRetrievalError(
None => Err(Error::ContextRetrieval(
"Failed to retrieve the guild where the command was issued".to_string(),
)),
},
Err(()) => Err(CommandExecutionError::DatabaseQueryError(
Err(()) => Err(Error::DatabaseQuery(
"Failed to access to the database".to_string(),
)),
}?;
@ -117,7 +109,7 @@ impl BotCommand for SubscribeCommand {
)
.await
.map_err(|_| {
CommandExecutionError::DatabaseQueryError(
Error::DatabaseQuery(
"Failed to update user subscriptions in database".to_string(),
)
})?;
@ -125,25 +117,20 @@ impl BotCommand for SubscribeCommand {
let mut response = match self.interaction.get_interaction_response(&context).await {
Ok(r) => Ok(r),
Err(_e) => Err(CommandExecutionError::ContextRetrievalError(
Err(_e) => Err(Error::ContextRetrieval(
"Failed to fetch initial interaction response".to_string(),
)),
}?;
response.delete_reactions(&context).await.map_err(|_e| {
CommandExecutionError::DiscordAPICallError(
"Failed to remove reactions from initial response".to_string(),
)
})?;
response
.delete_reactions(&context)
.await
.map_err(Error::DiscordAPICall)?;
response
.edit(&context, |msg| msg.suppress_embeds(true).content("Done !"))
.await
.map_err(|_e| {
CommandExecutionError::DiscordAPICallError(
"Failed to edit content of the original response message".to_string(),
)
})?;
.map_err(Error::DiscordAPICall)?;
Ok(())
}

View file

@ -14,7 +14,7 @@ use serenity::{
};
use super::{
commons::{BotCommandOption, CommandExecutionError},
commons::{BotCommandOption, Error},
BotCommand,
};
@ -46,25 +46,17 @@ impl BotCommand for TagNotifyCommand {
vec![]
}
async fn run(
&self,
context: Context,
database: Arc<DatabaseClient>,
) -> Result<(), CommandExecutionError> {
async fn run(&self, context: Context, database: Arc<DatabaseClient>) -> Result<(), Error> {
self.interaction
.create_interaction_response(&context.http, |response| {
response.kind(InteractionResponseType::DeferredChannelMessageWithSource)
})
.await
.map_err(|_e| {
CommandExecutionError::DiscordAPICallError(
"Failed to answer with a temporary response".to_string(),
)
})?;
.map_err(Error::DiscordAPICall)?;
let guild_id = match self.interaction.guild_id {
Some(id) => Ok(id.to_string()),
None => Err(CommandExecutionError::ContextRetrievalError(
None => Err(Error::ContextRetrieval(
"Failed to extract guild id from current context".to_string(),
)),
}?;
@ -72,11 +64,11 @@ impl BotCommand for TagNotifyCommand {
let guild = match database.get_by_id::<Guild>(&guild_id).await {
Ok(query) => match query {
Some(r) => Ok(r),
None => Err(CommandExecutionError::ContextRetrievalError(
None => Err(Error::ContextRetrieval(
"Failed to retrieve the guild where the command was issued".to_string(),
)),
},
Err(()) => Err(CommandExecutionError::DatabaseQueryError(
Err(()) => Err(Error::DatabaseQuery(
"Failed to access to the database".to_string(),
)),
}?;
@ -104,7 +96,7 @@ impl BotCommand for TagNotifyCommand {
for selected_tag in selection {
let t = match guild.tags.iter().find(|s| s.name == selected_tag) {
Some(t) => Ok(t),
None => Err(CommandExecutionError::ArgumentExtractionError(
None => Err(Error::InvalidCommand(
"No matching tag found for selection".to_string(),
)),
}?;
@ -128,16 +120,15 @@ impl BotCommand for TagNotifyCommand {
let response = match self.interaction.get_interaction_response(&context).await {
Ok(r) => Ok(r),
Err(_e) => Err(CommandExecutionError::ContextRetrievalError(
Err(_e) => Err(Error::ContextRetrieval(
"Failed to fetch initial interaction response".to_string(),
)),
}?;
response.delete(&context).await.map_err(|_e| {
CommandExecutionError::DiscordAPICallError(
"Failed to remove the embed message".to_string(),
)
})?;
response
.delete(&context)
.await
.map_err(Error::DiscordAPICall)?;
// We have to create a new message as editing the original response will not notify
// the pinged users
@ -150,20 +141,12 @@ impl BotCommand for TagNotifyCommand {
})
})
.await
.map_err(|_e| {
CommandExecutionError::DiscordAPICallError(
"Failed to create a new message to ping users".to_string(),
)
})?;
.map_err(Error::DiscordAPICall)?;
} else {
self.interaction
.delete_original_interaction_response(&context)
.await
.map_err(|_e| {
CommandExecutionError::DiscordAPICallError(
"Failed to delete the original interaction message".to_string(),
)
})?;
.map_err(Error::DiscordAPICall)?;
}
Ok(())

View file

@ -8,7 +8,7 @@ use super::commands::{
use crate::{
database::{models::Guild, Client as DatabaseClient},
discord::commands::{commons::CommandExecutionError, DeleteTagCommand, SubscribeCommand},
discord::commands::{commons::Error, DeleteTagCommand, SubscribeCommand},
};
use serenity::{
async_trait,
@ -153,7 +153,7 @@ impl EventHandler for Handler {
.await
} else {
drop(users_selector);
Err(CommandExecutionError::SelectorError(
Err(Error::Selector(
"User has already a selector running".to_string(),
))
}
@ -170,13 +170,13 @@ impl EventHandler for Handler {
.await
} else {
drop(users_selector);
Err(CommandExecutionError::SelectorError(
Err(Error::Selector(
"User has already a selector running".to_string(),
))
}
}
_ => Err(CommandExecutionError::UnknownCommand(
_ => Err(Error::UnknownCommand(
"Received an unknown command from Discord".to_string(),
)),
};

View file

@ -5,7 +5,7 @@ use serenity::{
prelude::Context,
};
use crate::discord::commands::commons::CommandExecutionError;
use crate::discord::commands::commons::Error;
const HTML_COLOR_CODE: u32 = 0xffffff;
@ -16,12 +16,11 @@ pub struct EmbedMessageBuilder {
}
impl EmbedMessageBuilder {
pub async fn new(context: &Context) -> Result<Self, CommandExecutionError> {
let bot_user = context.http.get_current_user().await.map_err(|_e| {
CommandExecutionError::ContextRetrievalError(
"Failed to get current bot user".to_string(),
)
})?;
pub async fn new(context: &Context) -> Result<Self, Error> {
let bot_user =
context.http.get_current_user().await.map_err(|_e| {
Error::ContextRetrieval("Failed to get current bot user".to_string())
})?;
let embed_author = bot_user.name.clone();
@ -62,13 +61,20 @@ impl EmbedMessageBuilder {
pub fn create_bot_credentials_embed(&self) -> CreateEmbed {
self.create_embed_base()
.title("Credentials")
.title("Yorokobot")
.fields(vec![
("Creator", "This bot was created by Victor Mignot (aka Dala).\nMastodon link: https://fosstodon.org/@Dala", false),
("License", "The source code is under the GNU Affero General Public License v3.0", false),
("Source code", "https://sr.ht/~dala/yorokobot/", false),
("Illustrator's Twitter", "https://twitter.com/MaewenMitzuki", false),
("Developer's Discord Server", "https://discord.gg/e8Q4zQbJb3", false),
("Version", env!("CARGO_PKG_VERSION"), false),
(
"License",
"The source code is under the European Union Public License v1.2",
false,
),
("Source code", env!("CARGO_PKG_REPOSITORY"), false),
(
"Illustrator's Twitter",
"https://twitter.com/MaewenMitzuki",
false,
),
])
.to_owned()
}

View file

@ -11,7 +11,7 @@ use serenity::{
prelude::Context,
};
use crate::discord::commands::commons::CommandExecutionError;
use crate::discord::commands::commons::Error;
use super::embed_builder::EmbedMessageBuilder;
@ -64,10 +64,7 @@ impl<'a> EmbedSelector<'a> {
selectable: Vec<String>,
initial_selection: Option<HashSet<String>>,
) -> Self {
let selection = match initial_selection {
Some(r) => r,
None => HashSet::new(),
};
let selection = initial_selection.unwrap_or_default();
let mut selector = EmbedSelector {
interaction,
@ -86,9 +83,7 @@ impl<'a> EmbedSelector<'a> {
selector
}
pub async fn get_user_selection(
&mut self,
) -> Result<Option<HashSet<String>>, CommandExecutionError> {
pub async fn get_user_selection(&mut self) -> Result<Option<HashSet<String>>, Error> {
let embed_builder = EmbedMessageBuilder::new(self.context).await?;
match self
@ -110,19 +105,16 @@ impl<'a> EmbedSelector<'a> {
self.display_reactions().await?;
Ok(self.wait_selector_end().await?)
}
Err(_e) => Err(CommandExecutionError::DiscordAPICallError(
"Failed to edit original interaction response".to_string(),
)),
Err(e) => Err(Error::DiscordAPICall(e)),
}
}
async fn display_reactions(&self) -> Result<(), CommandExecutionError> {
async fn display_reactions(&self) -> Result<(), Error> {
if let Some(answer) = &self.embed_answer {
answer.delete_reactions(self.context).await.map_err(|_e| {
CommandExecutionError::DiscordAPICallError(
"Failed to delete reaction on the current selector".to_string(),
)
})?;
answer
.delete_reactions(self.context)
.await
.map_err(Error::DiscordAPICall)?;
for emote in SELECTION_EMOTES[0..self.get_current_page_choice_number()]
.iter()
@ -140,30 +132,24 @@ impl<'a> EmbedSelector<'a> {
Some(a) => a
.react(self.context, ReactionType::Unicode(emote.to_string()))
.await
.map_err(|_e| {
CommandExecutionError::DiscordAPICallError(
"Failed to add reactions on the current selector".to_string(),
)
}),
None => Err(CommandExecutionError::SelectorError(
.map_err(Error::DiscordAPICall),
None => Err(Error::Selector(
"Failed to refresh the reactions of the current selector".to_string(),
)),
}?;
}
Ok(())
} else {
Err(CommandExecutionError::SelectorError(
Err(Error::Selector(
"Tried to delete reaction from a non existent message".to_string(),
))
}
}
async fn wait_selector_end(
&mut self,
) -> Result<Option<HashSet<String>>, CommandExecutionError> {
async fn wait_selector_end(&mut self) -> Result<Option<HashSet<String>>, Error> {
let answer = match &self.embed_answer {
Some(a) => Ok(a),
None => Err(CommandExecutionError::SelectorError(
None => Err(Error::Selector(
"Tried to start collector before sending it".to_string(),
)),
}?;
@ -174,11 +160,7 @@ impl<'a> EmbedSelector<'a> {
.add_message_id(*answer.id.as_u64())
.timeout(Duration::from_secs(COLLECTOR_MAX_DURATION_SEC))
.build()
.map_err(|_e| {
CommandExecutionError::SelectorError(
"Failed to build the EventCollector".to_string(),
)
})?;
.map_err(|_e| Error::Selector("Failed to build the EventCollector".to_string()))?;
while let Some(reaction_event) = collector.next().await {
let reaction = match *reaction_event {
@ -235,11 +217,10 @@ impl<'a> EmbedSelector<'a> {
}
}
reaction.delete(self.context).await.map_err(|_e| {
CommandExecutionError::DiscordAPICallError(
"Failed to delete reaction from selector".to_string(),
)
})?;
reaction
.delete(self.context)
.await
.map_err(Error::DiscordAPICall)?;
}
}
collector.stop();
@ -251,7 +232,7 @@ impl<'a> EmbedSelector<'a> {
}
}
async fn next_page(&mut self) -> Result<(), CommandExecutionError> {
async fn next_page(&mut self) -> Result<(), Error> {
if self.current_page != self.page_number {
self.current_page += 1;
self.refresh_embed_selection().await?;
@ -259,7 +240,7 @@ impl<'a> EmbedSelector<'a> {
Ok(())
}
async fn previous_page(&mut self) -> Result<(), CommandExecutionError> {
async fn previous_page(&mut self) -> Result<(), Error> {
if self.current_page != 1 {
self.current_page -= 1;
self.refresh_embed_selection().await?;
@ -277,7 +258,7 @@ impl<'a> EmbedSelector<'a> {
}
}
async fn refresh_embed_selection(&mut self) -> Result<(), CommandExecutionError> {
async fn refresh_embed_selection(&mut self) -> Result<(), Error> {
let embed_builder = EmbedMessageBuilder::new(self.context).await?;
let curr_choices = self.selectable
@ -298,10 +279,6 @@ impl<'a> EmbedSelector<'a> {
))
})
.await
.map_err(|_e| {
CommandExecutionError::DiscordAPICallError(
"Failed to edit selector content".to_string(),
)
})
.map_err(Error::DiscordAPICall)
}
}

View file

@ -1,64 +0,0 @@
use log::LevelFilter;
use log4rs::{
append::{
console::ConsoleAppender,
rolling_file::{
policy::compound::{
roll::fixed_window::FixedWindowRoller, trigger::size::SizeTrigger, CompoundPolicy,
},
RollingFileAppender,
},
},
config::{Appender, Logger, Root},
encode::pattern::PatternEncoder,
Config,
};
const FILE_MAX_SIZE: u64 = 30000000; //30 mB
const LOGS_MAX_FILES: u32 = 10;
const LOG_FORMAT: &str = "{d(%Y-%m-%d %H:%M:%S)} | {({l}):5.5} | {f}:{L} — {m}{n}";
const LOGS_FILE: &str = "logs/yorokobot_latest.log";
const LOGS_ARCHIVE_FILE_PATTERN: &str = "logs/yorokobot_{}.gz";
/// Configure the bot logger
pub fn init_logger() -> log4rs::Handle {
// Rollings logs trigger
let size_triger = SizeTrigger::new(FILE_MAX_SIZE);
// Rolling logs rollers
let logs_roller = FixedWindowRoller::builder()
.base(1)
.build(LOGS_ARCHIVE_FILE_PATTERN, LOGS_MAX_FILES)
.unwrap();
// Rolling logs policies
let rolling_logs_policy = CompoundPolicy::new(Box::new(size_triger), Box::new(logs_roller));
// Appenders
let stdout = ConsoleAppender::builder()
.encoder(Box::new(PatternEncoder::new(LOG_FORMAT)))
.build();
let rolling_logs = RollingFileAppender::builder()
.encoder(Box::new(PatternEncoder::new(LOG_FORMAT)))
.build(LOGS_FILE, Box::new(rolling_logs_policy))
.unwrap();
let config = Config::builder()
.appender(Appender::builder().build("stdout", Box::new(stdout)))
.appender(Appender::builder().build("rolling_logs", Box::new(rolling_logs)))
// Don't print the serenity crate logs
.logger(Logger::builder().build("serenity", LevelFilter::Error))
// Don't print the tracing crate logs
.logger(Logger::builder().build("tracing", LevelFilter::Error))
.build(
Root::builder()
.appender("stdout")
.appender("rolling_logs")
.build(LevelFilter::Info),
)
.unwrap();
log4rs::init_config(config).unwrap()
}

View file

@ -3,23 +3,16 @@
//!
//! [`Serenity`]: https://github.com/serenity-rs/serenity
#![deny(missing_docs)]
#![deny(warnings)]
mod client;
mod database;
mod discord;
mod environment;
mod logs;
use client::Client;
use logs::init_logger;
#[tokio::main]
async fn main() {
// Start the logger
let _handle = init_logger();
env_logger::init();
let mut client = Client::new().await;
client.start().await;