Implementing core functionnalities of the bot
This commit is contained in:
parent
beef993d29
commit
14be0e93b4
|
@ -6,7 +6,7 @@ edition = "2021"
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serenity = { version="0.11", default-features = false, features = ["client", "gateway", "rustls_backend", "model", "collector" ] }
|
serenity = { version="0.11", default-features = false, features = ["client", "gateway", "rustls_backend", "model", "collector", "absolute_ratelimits" ] }
|
||||||
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||||
mongodb = { version = "2.3.0", default-features = false, features = ["tokio-runtime"] }
|
mongodb = { version = "2.3.0", default-features = false, features = ["tokio-runtime"] }
|
||||||
serde = { version = "1.0", features = [ "derive" ] }
|
serde = { version = "1.0", features = [ "derive" ] }
|
||||||
|
|
|
@ -38,18 +38,19 @@ appenders:
|
||||||
count: 20
|
count: 20
|
||||||
|
|
||||||
root:
|
root:
|
||||||
level: warn
|
level: info
|
||||||
appenders:
|
appenders:
|
||||||
- stdout
|
- stdout
|
||||||
|
|
||||||
loggers:
|
loggers:
|
||||||
bot_infos:
|
serenity:
|
||||||
level: info
|
level: error
|
||||||
appenders:
|
|
||||||
- rolling_debug
|
|
||||||
|
|
||||||
bot_warn_errors:
|
tracing:
|
||||||
level: warn
|
level: error
|
||||||
|
|
||||||
|
logs:
|
||||||
|
level: info
|
||||||
appenders:
|
appenders:
|
||||||
- rolling_logs
|
- rolling_logs
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
use std::sync::{Arc, Mutex};
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
use serenity::{prelude::GatewayIntents, Client as SerenityClient};
|
use serenity::{prelude::GatewayIntents, Client as SerenityClient};
|
||||||
|
|
||||||
|
@ -6,21 +8,33 @@ use crate::{database::Client as DatabaseClient, environment::get_env_variable};
|
||||||
|
|
||||||
use crate::discord::event_handler::Handler;
|
use crate::discord::event_handler::Handler;
|
||||||
|
|
||||||
|
/// The Yorokobot client.
|
||||||
|
///
|
||||||
|
/// 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.
|
||||||
|
/// - 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).
|
||||||
pub struct Client {
|
pub struct Client {
|
||||||
serenity_client: SerenityClient,
|
serenity_client: SerenityClient,
|
||||||
database_client: Arc<Mutex<DatabaseClient>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Client {
|
impl Client {
|
||||||
|
/// Create a new Yorokobot instance
|
||||||
pub async fn new() -> Self {
|
pub async fn new() -> Self {
|
||||||
let database_client = Arc::new(Mutex::new(DatabaseClient::new()));
|
let mut database_client = DatabaseClient::new();
|
||||||
database_client.clone().lock().unwrap().connect();
|
|
||||||
|
database_client.connect().await;
|
||||||
|
|
||||||
let discord_token = get_env_variable("DISCORD_TOKEN");
|
let discord_token = get_env_variable("DISCORD_TOKEN");
|
||||||
let intents = GatewayIntents::GUILD_MESSAGES | GatewayIntents::MESSAGE_CONTENT;
|
let intents = GatewayIntents::GUILD_MESSAGES
|
||||||
|
| GatewayIntents::MESSAGE_CONTENT
|
||||||
|
| GatewayIntents::GUILD_MESSAGE_REACTIONS;
|
||||||
|
|
||||||
let event_handler = Handler {
|
let event_handler = Handler {
|
||||||
database: database_client.clone(),
|
database: Arc::new(database_client),
|
||||||
|
users_with_running_selector: Arc::new(Mutex::new(HashSet::new())),
|
||||||
};
|
};
|
||||||
|
|
||||||
let serenity_client = match SerenityClient::builder(discord_token, intents)
|
let serenity_client = match SerenityClient::builder(discord_token, intents)
|
||||||
|
@ -31,12 +45,10 @@ impl Client {
|
||||||
Err(e) => panic!("Failed to instantiate Discord Client: {e}"),
|
Err(e) => panic!("Failed to instantiate Discord Client: {e}"),
|
||||||
};
|
};
|
||||||
|
|
||||||
Client {
|
Client { serenity_client }
|
||||||
serenity_client,
|
|
||||||
database_client,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start the bot, connecting it to the database and the Discord API.
|
||||||
pub async fn start(&mut self) {
|
pub async fn start(&mut self) {
|
||||||
if let Err(e) = self.serenity_client.start().await {
|
if let Err(e) = self.serenity_client.start().await {
|
||||||
panic!("Could not connect the bot: {e}");
|
panic!("Could not connect the bot: {e}");
|
||||||
|
|
|
@ -1,15 +1,15 @@
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use futures::TryStreamExt;
|
use futures::TryStreamExt;
|
||||||
|
use log::{error, info, trace};
|
||||||
use mongodb::{
|
use mongodb::{
|
||||||
bson::{doc, from_bson, Bson, Document},
|
bson::{doc, from_bson, to_document, Bson, Document},
|
||||||
Client as MongoClient, Collection, Database,
|
Client as MongoClient, Collection, Database,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
|
|
||||||
use crate::environment::get_env_variable;
|
use crate::environment::get_env_variable;
|
||||||
|
|
||||||
use super::models::{YorokobotModel, COLLECTIONS_NAMES};
|
use super::models::{YorokobotCollection, COLLECTIONS_NAMES};
|
||||||
|
|
||||||
/// Database client
|
/// Database client
|
||||||
pub struct Client {
|
pub struct Client {
|
||||||
|
@ -26,42 +26,58 @@ impl Client {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_database(&self) -> &Database {
|
||||||
|
match &self.database {
|
||||||
|
Some(db) => db,
|
||||||
|
None => {
|
||||||
|
error!("Tried to access to the database before instantiating it");
|
||||||
|
panic!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Connect the client
|
/// Connect the client
|
||||||
pub async fn connect(&mut self) {
|
pub async fn connect(&mut self) {
|
||||||
self.mongo_client = match MongoClient::with_uri_str(get_env_variable("MONGODB_URI")).await {
|
self.mongo_client = match MongoClient::with_uri_str(get_env_variable("MONGODB_URI")).await {
|
||||||
Ok(c) => Some(c),
|
Ok(c) => {
|
||||||
Err(e) => panic!("Failed to connect to Mongo database: {e}"),
|
info!("Successfully connected to the Mongo database");
|
||||||
|
Some(c)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to connect to the Mongo database: {e:#?}");
|
||||||
|
panic!();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
self.database = Some(
|
self.database = match &self.mongo_client {
|
||||||
self.mongo_client
|
Some(c) => Some(c.database(get_env_variable("MONGODB_DATABASE").as_str())),
|
||||||
.as_ref()
|
None => {
|
||||||
.unwrap()
|
error!("Got an unexpected None from self.database");
|
||||||
.database(get_env_variable("MONGODB_DATABASE").as_str()),
|
panic!();
|
||||||
);
|
}
|
||||||
|
};
|
||||||
// TODO:
|
|
||||||
// Complete error kind to be more specific.
|
|
||||||
// Ex: DatabaseConnection
|
|
||||||
|
|
||||||
self.check_init_error().await;
|
self.check_init_error().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn check_init_error(&mut self) {
|
async fn check_init_error(&mut self) {
|
||||||
self.check_collections_presence().await;
|
info!("Launching initial database checks");
|
||||||
|
|
||||||
|
let database = self.get_database();
|
||||||
|
|
||||||
|
Self::check_collections_presence(database).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn check_collections_presence(&mut self) {
|
async fn check_collections_presence(db: &Database) {
|
||||||
|
trace!("Starting the collections presence check for the database");
|
||||||
|
|
||||||
let mut missing_collections: Vec<&str> = vec![];
|
let mut missing_collections: Vec<&str> = vec![];
|
||||||
let collections: HashSet<String> = match self
|
let collections: HashSet<String> = match db.list_collection_names(None).await {
|
||||||
.database
|
|
||||||
.as_ref()
|
|
||||||
.unwrap()
|
|
||||||
.list_collection_names(None)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(n) => n.into_iter().collect(),
|
Ok(n) => n.into_iter().collect(),
|
||||||
Err(e) => panic!("Could not list collections: {e}"),
|
Err(e) => {
|
||||||
|
error!("Failed to get the collections for the database: {e:#?}");
|
||||||
|
panic!();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
for col in COLLECTIONS_NAMES {
|
for col in COLLECTIONS_NAMES {
|
||||||
|
@ -71,71 +87,71 @@ impl Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
if !missing_collections.is_empty() {
|
if !missing_collections.is_empty() {
|
||||||
panic!(
|
error!(
|
||||||
"Missing the following the following collections: {}",
|
"Missing the following collections in the Database: {}",
|
||||||
missing_collections.join(", ")
|
missing_collections.join(", ")
|
||||||
);
|
);
|
||||||
|
panic!();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_collection<T: YorokobotModel>(&self) -> Collection<Document> {
|
#[allow(dead_code)]
|
||||||
self.database
|
fn get_collection<T: YorokobotCollection>(&self) -> Collection<Document> {
|
||||||
.as_ref()
|
self.get_database().collection(&T::get_collection_name())
|
||||||
.expect("Could not retrieve database")
|
|
||||||
.collection(&T::get_collection_name())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_typed_collection<T: YorokobotModel>(&self) -> Collection<T> {
|
fn get_typed_collection<T: YorokobotCollection>(&self) -> Collection<T> {
|
||||||
self.database
|
self.get_database()
|
||||||
.as_ref()
|
|
||||||
.expect("Could not retrieve database")
|
|
||||||
.collection::<T>(&T::get_collection_name())
|
.collection::<T>(&T::get_collection_name())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
pub async fn get_by_id<T: YorokobotCollection>(&self, id: &str) -> Result<Option<T>, ()> {
|
||||||
pub async fn get_by_id<T: YorokobotModel + for<'de> Deserialize<'de>>(&self, id: &str) -> T {
|
self.get_one::<T>(doc! {"_id": id}).await
|
||||||
self.get_one(doc! {"_id": id}).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
pub async fn get_one<T: YorokobotCollection>(&self, filter: Document) -> Result<Option<T>, ()> {
|
||||||
pub async fn get_one<T: YorokobotModel + for<'de> Deserialize<'de>>(
|
match self
|
||||||
&self,
|
.get_typed_collection::<T>()
|
||||||
filter: Document,
|
|
||||||
) -> T {
|
|
||||||
let result = self
|
|
||||||
.get_collection::<T>()
|
|
||||||
.find_one(filter, None)
|
.find_one(filter, None)
|
||||||
.await
|
.await
|
||||||
.expect("Could not issue request")
|
{
|
||||||
.expect("Could not find matching data");
|
Ok(e) => Ok(e),
|
||||||
|
Err(_) => Err(()),
|
||||||
from_bson(Bson::Document(result)).expect("Could not deserialize data")
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub async fn get_all<T: YorokobotModel + for<'de> Deserialize<'de>>(
|
pub async fn get_all<T: YorokobotCollection>(
|
||||||
&self,
|
&self,
|
||||||
filter: Option<Document>,
|
filter: Option<Document>,
|
||||||
) -> Vec<T> {
|
) -> Result<Vec<T>, ()> {
|
||||||
let mut result: Vec<T> = vec![];
|
let mut matching_docs: Vec<T> = vec![];
|
||||||
|
|
||||||
let mut cursor = match filter {
|
let result = match filter {
|
||||||
Some(f) => self.get_collection::<T>().find(f, None).await,
|
Some(f) => self.get_collection::<T>().find(f, None).await,
|
||||||
None => self.get_collection::<T>().find(doc! {}, None).await,
|
None => self.get_collection::<T>().find(doc! {}, None).await,
|
||||||
}
|
};
|
||||||
.expect("Could not issue request");
|
|
||||||
|
|
||||||
while let Some(document) = cursor.try_next().await.expect("Could not fetch results") {
|
let mut cursor = match result {
|
||||||
result
|
Ok(c) => c,
|
||||||
.push(from_bson(Bson::Document(document)).expect("Could not deserialize document"));
|
Err(_) => return Err(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
while let Some(document) = match cursor.try_next().await {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => return Err(()),
|
||||||
|
} {
|
||||||
|
match from_bson(Bson::Document(document)) {
|
||||||
|
Ok(d) => matching_docs.push(d),
|
||||||
|
Err(_) => return Err(()),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
Ok(matching_docs)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
// TODO: Set true error handling
|
// TODO: Set true error handling
|
||||||
pub async fn insert_one<T: YorokobotModel + Serialize>(&self, document: T) -> Result<(), ()> {
|
pub async fn insert_one<T: YorokobotCollection>(&self, document: T) -> Result<(), ()> {
|
||||||
match self
|
match self
|
||||||
.get_typed_collection::<T>()
|
.get_typed_collection::<T>()
|
||||||
.insert_one(document, None)
|
.insert_one(document, None)
|
||||||
|
@ -146,12 +162,9 @@ impl Client {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
// TODO: Set true error handling
|
// TODO: Set true error handling
|
||||||
pub async fn insert_many<T: YorokobotModel + Serialize>(
|
#[allow(dead_code)]
|
||||||
&self,
|
pub async fn insert_many<T: YorokobotCollection>(&self, documents: Vec<T>) -> Result<(), ()> {
|
||||||
documents: Vec<T>,
|
|
||||||
) -> Result<(), ()> {
|
|
||||||
match self
|
match self
|
||||||
.get_typed_collection::<T>()
|
.get_typed_collection::<T>()
|
||||||
.insert_many(documents, None)
|
.insert_many(documents, None)
|
||||||
|
@ -163,7 +176,8 @@ impl Client {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Set true error handling
|
// TODO: Set true error handling
|
||||||
pub async fn delete_one<T: YorokobotModel>(&self, document: Document) -> Result<u64, ()> {
|
#[allow(dead_code)]
|
||||||
|
pub async fn delete_one<T: YorokobotCollection>(&self, document: Document) -> Result<u64, ()> {
|
||||||
match self
|
match self
|
||||||
.get_typed_collection::<T>()
|
.get_typed_collection::<T>()
|
||||||
.delete_one(document, None)
|
.delete_one(document, None)
|
||||||
|
@ -174,9 +188,9 @@ impl Client {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
// TODO: Set true error handling
|
// TODO: Set true error handling
|
||||||
pub async fn delete_by_id<T: YorokobotModel>(&self, id: &str) -> Result<(), ()> {
|
#[allow(dead_code)]
|
||||||
|
pub async fn delete_by_id<T: YorokobotCollection>(&self, id: &str) -> Result<(), ()> {
|
||||||
match self
|
match self
|
||||||
.get_typed_collection::<T>()
|
.get_typed_collection::<T>()
|
||||||
.delete_one(doc! {"_id": id}, None)
|
.delete_one(doc! {"_id": id}, None)
|
||||||
|
@ -187,9 +201,9 @@ impl Client {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
// TODO: Set true error handling
|
// TODO: Set true error handling
|
||||||
pub async fn delete_many<T: YorokobotModel>(&self, document: Document) -> Result<(), ()> {
|
#[allow(dead_code)]
|
||||||
|
pub async fn delete_many<T: YorokobotCollection>(&self, document: Document) -> Result<(), ()> {
|
||||||
match self
|
match self
|
||||||
.get_typed_collection::<T>()
|
.get_typed_collection::<T>()
|
||||||
.delete_many(document, None)
|
.delete_many(document, None)
|
||||||
|
@ -200,16 +214,20 @@ impl Client {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
//TODO: Set true error handling
|
//TODO: Set true error handling
|
||||||
pub async fn update_one<T: YorokobotModel>(
|
pub async fn update_one<T: YorokobotCollection>(
|
||||||
&self,
|
&self,
|
||||||
document: Document,
|
object: T,
|
||||||
update: Document,
|
update: Document,
|
||||||
) -> Result<(), ()> {
|
) -> Result<(), ()> {
|
||||||
|
let serialized_doc = match to_document(&object) {
|
||||||
|
Ok(d) => d,
|
||||||
|
Err(_) => return Err(()),
|
||||||
|
};
|
||||||
|
|
||||||
match self
|
match self
|
||||||
.get_typed_collection::<T>()
|
.get_typed_collection::<T>()
|
||||||
.update_one(document, update, None)
|
.update_one(serialized_doc, update, None)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => Ok(()),
|
Ok(_) => Ok(()),
|
||||||
|
@ -217,9 +235,9 @@ impl Client {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
//TODO: Set true error handling
|
//TODO: Set true error handling
|
||||||
pub async fn update_by_id<T: YorokobotModel>(
|
#[allow(dead_code)]
|
||||||
|
pub async fn update_by_id<T: YorokobotCollection>(
|
||||||
&self,
|
&self,
|
||||||
document_id: &str,
|
document_id: &str,
|
||||||
update: Document,
|
update: Document,
|
||||||
|
@ -234,9 +252,9 @@ impl Client {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
//TODO: Set true error handling
|
//TODO: Set true error handling
|
||||||
pub async fn update_many<T: YorokobotModel>(
|
#[allow(dead_code)]
|
||||||
|
pub async fn update_many<T: YorokobotCollection>(
|
||||||
&self,
|
&self,
|
||||||
document: Document,
|
document: Document,
|
||||||
update: Document,
|
update: Document,
|
||||||
|
|
|
@ -1,29 +1,31 @@
|
||||||
//! Bot data models
|
//! Bot data models
|
||||||
|
|
||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
use mongodb::bson::oid::ObjectId;
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
pub const COLLECTIONS_NAMES: [&str; 1] = ["tags"];
|
pub const COLLECTIONS_NAMES: [&str; 1] = ["guilds"];
|
||||||
|
|
||||||
pub trait YorokobotModel {
|
pub trait YorokobotCollection: for<'de> Deserialize<'de> + Serialize + Unpin + Send + Sync {
|
||||||
fn get_collection_name() -> String;
|
fn get_collection_name() -> String;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tags
|
/// Tags
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
pub struct Tag {
|
pub struct Tag {
|
||||||
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
|
||||||
pub id: Option<ObjectId>,
|
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub guild_id: String,
|
|
||||||
pub is_nsfw: bool,
|
pub is_nsfw: bool,
|
||||||
pub subscribers: Vec<String>,
|
pub subscribers: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl YorokobotModel for Tag {
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct Guild {
|
||||||
|
#[serde(rename = "_id")]
|
||||||
|
pub id: String,
|
||||||
|
pub ban_list: Vec<String>,
|
||||||
|
pub tags: Vec<Tag>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl YorokobotCollection for Guild {
|
||||||
fn get_collection_name() -> String {
|
fn get_collection_name() -> String {
|
||||||
"tags".to_string()
|
COLLECTIONS_NAMES[0].to_string()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
pub mod event_handler;
|
pub mod event_handler;
|
||||||
|
mod message_builders;
|
||||||
|
|
||||||
mod commands;
|
mod commands;
|
||||||
|
|
|
@ -1,6 +1,16 @@
|
||||||
mod bulk_create_tag;
|
// mod bulk_create_tag;
|
||||||
pub mod commands;
|
pub mod commons;
|
||||||
mod create_tag;
|
mod create_tag;
|
||||||
mod delete_tag;
|
mod delete_tag;
|
||||||
mod list_tags;
|
mod list_tags;
|
||||||
mod source_code;
|
mod source_code;
|
||||||
|
mod subscribe;
|
||||||
|
mod tag_notify;
|
||||||
|
|
||||||
|
pub use commons::BotCommand;
|
||||||
|
pub use create_tag::CreateTagCommand;
|
||||||
|
pub use delete_tag::DeleteTagCommand;
|
||||||
|
pub use list_tags::ListTagCommand;
|
||||||
|
pub use source_code::SourceCodeCommand;
|
||||||
|
pub use subscribe::SubscribeCommand;
|
||||||
|
pub use tag_notify::TagNotifyCommand;
|
||||||
|
|
|
@ -1,22 +0,0 @@
|
||||||
use serenity::{builder::CreateApplicationCommand, model::prelude::command::CommandOptionType};
|
|
||||||
|
|
||||||
pub fn register(
|
|
||||||
command: &mut CreateApplicationCommand,
|
|
||||||
max_args_number: u32,
|
|
||||||
) -> &mut CreateApplicationCommand {
|
|
||||||
command
|
|
||||||
.name("bulk_create_tag")
|
|
||||||
.description("Add multiples tags");
|
|
||||||
|
|
||||||
for i in 0..max_args_number {
|
|
||||||
command.create_option(|option| {
|
|
||||||
option
|
|
||||||
.name(format!("tag{}", i + 1))
|
|
||||||
.description("A new tag to add")
|
|
||||||
.kind(CommandOptionType::String)
|
|
||||||
.required(i == 0)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
command
|
|
||||||
}
|
|
|
@ -1,49 +0,0 @@
|
||||||
use serenity::{
|
|
||||||
async_trait,
|
|
||||||
builder::CreateInteractionResponseData,
|
|
||||||
model::prelude::{
|
|
||||||
command::{Command, CommandOptionType},
|
|
||||||
interaction::application_command::ApplicationCommandInteraction,
|
|
||||||
},
|
|
||||||
prelude::Context,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::database::Client as DatabaseClient;
|
|
||||||
|
|
||||||
pub struct BotCommandOption {
|
|
||||||
pub name: String,
|
|
||||||
pub description: String,
|
|
||||||
pub kind: CommandOptionType,
|
|
||||||
pub required: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait BotCommand {
|
|
||||||
fn new(context: ApplicationCommandInteraction) -> Self;
|
|
||||||
fn name() -> String;
|
|
||||||
fn description() -> String;
|
|
||||||
fn options_list() -> Vec<BotCommandOption>;
|
|
||||||
async fn run(&self, response: &mut CreateInteractionResponseData, database: &DatabaseClient);
|
|
||||||
|
|
||||||
async fn register(context: &Context) {
|
|
||||||
match Command::create_global_application_command(context, |command| {
|
|
||||||
let mut new_command = command.name(Self::name()).description(Self::description());
|
|
||||||
|
|
||||||
for opt in Self::options_list() {
|
|
||||||
new_command = new_command.create_option(|option| {
|
|
||||||
option
|
|
||||||
.name(opt.name)
|
|
||||||
.description(opt.description)
|
|
||||||
.kind(opt.kind)
|
|
||||||
.required(opt.required)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
new_command
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(_) => println!("Successfully registered the {} command", Self::name()),
|
|
||||||
Err(_) => panic!("Failed to register the {} command", Self::name()),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
77
src/discord/commands/commons.rs
Normal file
77
src/discord/commands/commons.rs
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
use log::{info, warn};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use serenity::{
|
||||||
|
async_trait,
|
||||||
|
model::prelude::{
|
||||||
|
command::{Command, CommandOptionType},
|
||||||
|
interaction::application_command::{
|
||||||
|
ApplicationCommandInteraction, CommandDataOption, CommandDataOptionValue,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
prelude::Context,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::database::Client as DatabaseClient;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum CommandExecutionError {
|
||||||
|
ArgumentExtractionError(String),
|
||||||
|
ArgumentDeserializationError(String),
|
||||||
|
DatabaseQueryError(String),
|
||||||
|
ContextRetrievalError(String),
|
||||||
|
DiscordAPICallError(String),
|
||||||
|
SelectorError(String),
|
||||||
|
UnknownCommand(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct BotCommandOption {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub kind: CommandOptionType,
|
||||||
|
pub required: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait BotCommand {
|
||||||
|
fn new(interaction: ApplicationCommandInteraction) -> Self;
|
||||||
|
fn name() -> String;
|
||||||
|
fn description() -> String;
|
||||||
|
fn options_list() -> Vec<BotCommandOption>;
|
||||||
|
async fn run(
|
||||||
|
&self,
|
||||||
|
context: Context,
|
||||||
|
database: Arc<DatabaseClient>,
|
||||||
|
) -> Result<(), CommandExecutionError>;
|
||||||
|
|
||||||
|
fn extract_option_value(
|
||||||
|
options: &[CommandDataOption],
|
||||||
|
index: usize,
|
||||||
|
) -> Option<CommandDataOptionValue> {
|
||||||
|
let serialized_opt = options.get(index)?;
|
||||||
|
serialized_opt.resolved.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn register(context: &Context) {
|
||||||
|
info!("Starting the {} command registration", Self::name());
|
||||||
|
match Command::create_global_application_command(context, |command| {
|
||||||
|
let mut new_command = command.name(Self::name()).description(Self::description());
|
||||||
|
|
||||||
|
for opt in Self::options_list() {
|
||||||
|
new_command = new_command.create_option(|option| {
|
||||||
|
option
|
||||||
|
.name(opt.name)
|
||||||
|
.description(opt.description)
|
||||||
|
.kind(opt.kind)
|
||||||
|
.required(opt.required)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
new_command
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => info!("Successfully registered the {} command", Self::name()),
|
||||||
|
Err(_) => warn!("Failed to register the {} command", Self::name()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,22 +1,29 @@
|
||||||
use mongodb::bson::doc;
|
use log::debug;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use mongodb::bson::{doc, to_bson};
|
||||||
|
|
||||||
use serenity::{
|
use serenity::{
|
||||||
async_trait,
|
async_trait,
|
||||||
builder::CreateInteractionResponseData,
|
|
||||||
model::{
|
model::{
|
||||||
application::interaction::application_command::ApplicationCommandInteraction,
|
application::interaction::application_command::ApplicationCommandInteraction,
|
||||||
prelude::{
|
prelude::{
|
||||||
command::CommandOptionType, interaction::application_command::CommandDataOptionValue,
|
command::CommandOptionType,
|
||||||
|
interaction::{application_command::CommandDataOptionValue, InteractionResponseType},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
prelude::Context,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::database::{models::Tag, Client as DatabaseClient};
|
use crate::database::{
|
||||||
|
models::{Guild, Tag},
|
||||||
|
Client as DatabaseClient,
|
||||||
|
};
|
||||||
|
|
||||||
use super::commands::{BotCommand, BotCommandOption};
|
use super::commons::{BotCommand, BotCommandOption, CommandExecutionError};
|
||||||
|
|
||||||
struct CreateTagCommand {
|
pub struct CreateTagCommand {
|
||||||
context: ApplicationCommandInteraction,
|
interaction: ApplicationCommandInteraction,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
@ -38,49 +45,93 @@ impl BotCommand for CreateTagCommand {
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn new(context: ApplicationCommandInteraction) -> Self {
|
fn new(interaction: ApplicationCommandInteraction) -> Self {
|
||||||
CreateTagCommand { context }
|
debug!("Creating a new CreateTagCommand object");
|
||||||
|
CreateTagCommand { interaction }
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run(&self, response: &mut CreateInteractionResponseData, database: &DatabaseClient) {
|
async fn run(
|
||||||
let arg = self
|
&self,
|
||||||
.context
|
context: Context,
|
||||||
.data
|
database: Arc<DatabaseClient>,
|
||||||
.options
|
) -> Result<(), CommandExecutionError> {
|
||||||
.get(0)
|
// Extract tag_name parameter
|
||||||
.expect("Missing option")
|
let tag_name = match self.interaction.data.options.get(0) {
|
||||||
.resolved
|
Some(a) => match &a.resolved {
|
||||||
.as_ref()
|
Some(r) => match r {
|
||||||
.expect("Could not deserialize option");
|
CommandDataOptionValue::String(r_str) => Ok(r_str),
|
||||||
|
_ => Err(CommandExecutionError::ArgumentDeserializationError(
|
||||||
|
"Received non String argument for the CreateTagCommand".to_string(),
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
None => Err(CommandExecutionError::ArgumentDeserializationError(
|
||||||
|
"Could not deserialize the argument for the CreateTagCommand".to_string(),
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
None => Err(CommandExecutionError::ArgumentExtractionError(
|
||||||
|
"Failed to get the CreateTagCommand argument".to_string(),
|
||||||
|
)),
|
||||||
|
}?;
|
||||||
|
|
||||||
let guild_id = self
|
// Extract guild id from Serenity context
|
||||||
.context
|
let guild_id = match self.interaction.guild_id {
|
||||||
.guild_id
|
Some(a) => Ok(a.to_string()),
|
||||||
.expect("Could not fetch guild id")
|
None => Err(CommandExecutionError::ContextRetrievalError(
|
||||||
.to_string();
|
"Could not fetch guild id from issued command".to_string(),
|
||||||
|
)),
|
||||||
|
}?;
|
||||||
|
|
||||||
if let CommandDataOptionValue::String(tag_name) = arg {
|
let guild = match database.get_by_id::<Guild>(&guild_id).await {
|
||||||
let matching_tags = database
|
Ok(query) => match query {
|
||||||
.get_all::<Tag>(Some(doc! {"name": tag_name, "guild_id": guild_id.as_str()}))
|
Some(r) => Ok(r),
|
||||||
.await;
|
None => Err(CommandExecutionError::ContextRetrievalError(
|
||||||
|
"Failed to retrieve the guild where the command was issued".to_string(),
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
Err(()) => Err(CommandExecutionError::DatabaseQueryError(
|
||||||
|
"Could not access to the database".to_string(),
|
||||||
|
)),
|
||||||
|
}?;
|
||||||
|
|
||||||
if !matching_tags.is_empty() {
|
let matching_tag = guild.tags.iter().find(|t| t.name == *tag_name);
|
||||||
response.content("This tag already exist for this server");
|
|
||||||
|
let response_content = if matching_tag.is_some() {
|
||||||
|
String::from("This tag already exist for this server.")
|
||||||
} else {
|
} else {
|
||||||
match database
|
let mut new_tags = guild.tags.clone();
|
||||||
.insert_one(Tag {
|
new_tags.push(Tag {
|
||||||
id: None,
|
|
||||||
name: tag_name.to_string(),
|
name: tag_name.to_string(),
|
||||||
guild_id: guild_id.clone(),
|
|
||||||
is_nsfw: false,
|
is_nsfw: false,
|
||||||
subscribers: vec![],
|
subscribers: vec![],
|
||||||
|
});
|
||||||
|
|
||||||
|
match database
|
||||||
|
.update_one(
|
||||||
|
guild,
|
||||||
|
doc! {"$set": { "tags": to_bson(&new_tags).unwrap() }},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => Ok(String::from("Tag successfully created.")),
|
||||||
|
Err(_) => Err(CommandExecutionError::DatabaseQueryError(
|
||||||
|
"Could not add new tag to the database".to_string(),
|
||||||
|
)),
|
||||||
|
}?
|
||||||
|
};
|
||||||
|
|
||||||
|
match self
|
||||||
|
.interaction
|
||||||
|
.create_interaction_response(context.http, |response| {
|
||||||
|
response
|
||||||
|
.kind(InteractionResponseType::ChannelMessageWithSource)
|
||||||
|
.interaction_response_data(|message| message.content(response_content))
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(_) => response.content("Tag successfully created."),
|
Ok(_) => Ok(()),
|
||||||
Err(_) => response.content("Error creating the tag"),
|
Err(_e) => Err(CommandExecutionError::DiscordAPICallError(
|
||||||
};
|
"Failed to answer to the initial command".to_string(),
|
||||||
}
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,129 @@
|
||||||
use serenity::{builder::CreateApplicationCommand, model::prelude::command::CommandOptionType};
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand {
|
use log::debug;
|
||||||
command
|
use mongodb::bson::{doc, to_bson};
|
||||||
.name("delete_tag")
|
|
||||||
.description("Delete a tag")
|
use crate::database::{models::Guild, Client as DatabaseClient};
|
||||||
.create_option(|option| {
|
|
||||||
option
|
use serenity::{
|
||||||
.name("tag")
|
async_trait,
|
||||||
.description("The tag to delete")
|
model::prelude::{
|
||||||
.kind(CommandOptionType::String)
|
command::CommandOptionType,
|
||||||
.required(true)
|
interaction::{
|
||||||
})
|
application_command::{ApplicationCommandInteraction, CommandDataOptionValue},
|
||||||
|
InteractionResponseType,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
prelude::Context,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
commons::{BotCommandOption, CommandExecutionError},
|
||||||
|
BotCommand,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct DeleteTagCommand {
|
||||||
|
interaction: ApplicationCommandInteraction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl BotCommand for DeleteTagCommand {
|
||||||
|
fn new(interaction: ApplicationCommandInteraction) -> Self {
|
||||||
|
debug!("Creating a new DeleteTagCommand object");
|
||||||
|
DeleteTagCommand { interaction }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name() -> String {
|
||||||
|
String::from("delete_tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description() -> String {
|
||||||
|
String::from("Delete a tag from the server")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn options_list() -> Vec<BotCommandOption> {
|
||||||
|
vec![BotCommandOption {
|
||||||
|
name: String::from("tag"),
|
||||||
|
description: String::from("The tag to delete"),
|
||||||
|
kind: CommandOptionType::String,
|
||||||
|
required: true,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(
|
||||||
|
&self,
|
||||||
|
context: Context,
|
||||||
|
database: Arc<DatabaseClient>,
|
||||||
|
) -> Result<(), CommandExecutionError> {
|
||||||
|
let tag_name = match self.interaction.data.options.get(0) {
|
||||||
|
Some(a) => match &a.resolved {
|
||||||
|
Some(r) => match r {
|
||||||
|
CommandDataOptionValue::String(r_str) => Ok(r_str),
|
||||||
|
_ => Err(CommandExecutionError::ArgumentDeserializationError(
|
||||||
|
"Received non String argument for DeleteTagCommand".to_string(),
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
None => Err(CommandExecutionError::ArgumentDeserializationError(
|
||||||
|
"Failed to deserialize argument for DeleteTagCommand".to_string(),
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
None => Err(CommandExecutionError::ArgumentExtractionError(
|
||||||
|
"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(
|
||||||
|
"Failed to extract guild id from current context".to_string(),
|
||||||
|
)),
|
||||||
|
}?;
|
||||||
|
|
||||||
|
let guild = match database.get_by_id::<Guild>(&guild_id).await {
|
||||||
|
Ok(query) => match query {
|
||||||
|
Some(r) => Ok(r),
|
||||||
|
None => Err(CommandExecutionError::ContextRetrievalError(
|
||||||
|
"Failed to retrieve the guild where the command was issued".to_string(),
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
Err(()) => Err(CommandExecutionError::DatabaseQueryError(
|
||||||
|
"Failed to access to the database".to_string(),
|
||||||
|
)),
|
||||||
|
}?;
|
||||||
|
|
||||||
|
let response: String =
|
||||||
|
if let Some(tag_index) = guild.tags.iter().position(|t| t.name == *tag_name) {
|
||||||
|
let mut clone_tags = guild.tags.clone();
|
||||||
|
clone_tags.remove(tag_index);
|
||||||
|
|
||||||
|
match database
|
||||||
|
.update_one::<Guild>(
|
||||||
|
guild,
|
||||||
|
doc! {"$set": { "tags": to_bson(&clone_tags).unwrap() }},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => Ok(String::from("Successfully remove the tag")),
|
||||||
|
Err(()) => Err(CommandExecutionError::DatabaseQueryError(
|
||||||
|
"Failed to remove tag from the database".to_string(),
|
||||||
|
)),
|
||||||
|
}?
|
||||||
|
} else {
|
||||||
|
String::from("No matching tag for this server.")
|
||||||
|
};
|
||||||
|
|
||||||
|
match 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(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,97 @@
|
||||||
use serenity::builder::CreateApplicationCommand;
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand {
|
use log::debug;
|
||||||
command.name("list_tags").description("List your own tags")
|
|
||||||
|
use serenity::{
|
||||||
|
async_trait,
|
||||||
|
model::prelude::interaction::{
|
||||||
|
application_command::ApplicationCommandInteraction, InteractionResponseType,
|
||||||
|
},
|
||||||
|
prelude::Context,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::database::{models::Guild, Client as DatabaseClient};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
commons::{BotCommandOption, CommandExecutionError},
|
||||||
|
BotCommand,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct ListTagCommand {
|
||||||
|
interaction: ApplicationCommandInteraction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl BotCommand for ListTagCommand {
|
||||||
|
fn name() -> String {
|
||||||
|
String::from("list_tags")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description() -> String {
|
||||||
|
String::from("List available tags")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn options_list() -> Vec<BotCommandOption> {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new(interaction: ApplicationCommandInteraction) -> Self {
|
||||||
|
debug!("Creating a new ListTagCommand object");
|
||||||
|
ListTagCommand { interaction }
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(
|
||||||
|
&self,
|
||||||
|
context: Context,
|
||||||
|
database: Arc<DatabaseClient>,
|
||||||
|
) -> Result<(), CommandExecutionError> {
|
||||||
|
let guild_id = match self.interaction.guild_id {
|
||||||
|
Some(id) => Ok(id.to_string()),
|
||||||
|
None => Err(CommandExecutionError::ContextRetrievalError(
|
||||||
|
"Failed to extract guild id from current context".to_string(),
|
||||||
|
)),
|
||||||
|
}?;
|
||||||
|
|
||||||
|
let guild = match database.get_by_id::<Guild>(&guild_id).await {
|
||||||
|
Ok(query) => match query {
|
||||||
|
Some(r) => Ok(r),
|
||||||
|
None => Err(CommandExecutionError::ContextRetrievalError(
|
||||||
|
"Failed to retrieve the guild where the command was issued".to_string(),
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
Err(()) => Err(CommandExecutionError::DatabaseQueryError(
|
||||||
|
"Failed to access to the database".to_string(),
|
||||||
|
)),
|
||||||
|
}?;
|
||||||
|
|
||||||
|
let mut response_content: String;
|
||||||
|
|
||||||
|
if guild.tags.is_empty() {
|
||||||
|
response_content = String::from("No tag available on this server.");
|
||||||
|
} else {
|
||||||
|
response_content = String::from("Available tags on this server:\n");
|
||||||
|
for tag in guild.tags {
|
||||||
|
if response_content.len() + tag.name.len() < 1995 {
|
||||||
|
response_content.push_str(format!("`{}` ", tag.name.as_str()).as_str());
|
||||||
|
} else {
|
||||||
|
response_content.push_str("...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match 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(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,17 +1,65 @@
|
||||||
use serenity::builder::{CreateApplicationCommand, CreateInteractionResponseData};
|
use log::debug;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub fn register(command: &mut CreateApplicationCommand) -> &mut CreateApplicationCommand {
|
use serenity::{
|
||||||
command
|
async_trait,
|
||||||
.name("source_code")
|
model::prelude::interaction::{
|
||||||
.description("Access to the bot source code")
|
application_command::ApplicationCommandInteraction, InteractionResponseType,
|
||||||
|
},
|
||||||
|
prelude::Context,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
database::Client as DatabaseClient,
|
||||||
|
discord::message_builders::embed_builder::EmbedMessageBuilder,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::commons::{BotCommand, BotCommandOption, CommandExecutionError};
|
||||||
|
|
||||||
|
pub struct SourceCodeCommand {
|
||||||
|
interaction: ApplicationCommandInteraction,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn run<'a, 'b>(
|
#[async_trait]
|
||||||
response: &'a mut CreateInteractionResponseData<'b>,
|
impl BotCommand for SourceCodeCommand {
|
||||||
) -> &'a mut CreateInteractionResponseData<'b> {
|
fn name() -> String {
|
||||||
response.embed(|embed| {
|
String::from("about")
|
||||||
embed
|
}
|
||||||
.title("Yorokobot repository")
|
|
||||||
.description("https://sr.ht/~victormignot/yorokobot/")
|
fn description() -> String {
|
||||||
|
String::from("Display the bot credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn options_list() -> Vec<BotCommandOption> {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new(interaction: ApplicationCommandInteraction) -> Self {
|
||||||
|
debug!("Creating a new SourceCodeCommand object");
|
||||||
|
SourceCodeCommand { interaction }
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(
|
||||||
|
&self,
|
||||||
|
context: Context,
|
||||||
|
_: Arc<DatabaseClient>,
|
||||||
|
) -> Result<(), CommandExecutionError> {
|
||||||
|
let embed_builder = EmbedMessageBuilder::new(&context).await?;
|
||||||
|
match self
|
||||||
|
.interaction
|
||||||
|
.create_interaction_response(context.http, |response| {
|
||||||
|
response
|
||||||
|
.kind(InteractionResponseType::ChannelMessageWithSource)
|
||||||
|
.interaction_response_data(|r| {
|
||||||
|
r.set_embed(embed_builder.create_bot_credentials_embed())
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(_e) => Err(CommandExecutionError::DiscordAPICallError(
|
||||||
|
"Failed to answer to the issued command".to_string(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
150
src/discord/commands/subscribe.rs
Normal file
150
src/discord/commands/subscribe.rs
Normal file
|
@ -0,0 +1,150 @@
|
||||||
|
use std::{collections::HashSet, sync::Arc};
|
||||||
|
|
||||||
|
use log::debug;
|
||||||
|
use mongodb::bson::{doc, to_bson};
|
||||||
|
|
||||||
|
use serenity::{
|
||||||
|
async_trait,
|
||||||
|
model::prelude::interaction::{
|
||||||
|
application_command::ApplicationCommandInteraction, InteractionResponseType,
|
||||||
|
},
|
||||||
|
prelude::Context,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
commons::{BotCommandOption, CommandExecutionError},
|
||||||
|
BotCommand,
|
||||||
|
};
|
||||||
|
use crate::{
|
||||||
|
database::{client::Client as DatabaseClient, models::Guild},
|
||||||
|
discord::message_builders::selector_builder::EmbedSelector,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct SubscribeCommand {
|
||||||
|
interaction: ApplicationCommandInteraction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl BotCommand for SubscribeCommand {
|
||||||
|
fn new(interaction: ApplicationCommandInteraction) -> Self {
|
||||||
|
debug!("Creating a new SubscribeCommand object");
|
||||||
|
SubscribeCommand { interaction }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name() -> String {
|
||||||
|
String::from("subscribe")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description() -> String {
|
||||||
|
String::from("Subscribe to a selection of tags")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn options_list() -> Vec<BotCommandOption> {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(
|
||||||
|
&self,
|
||||||
|
context: Context,
|
||||||
|
database: Arc<DatabaseClient>,
|
||||||
|
) -> Result<(), CommandExecutionError> {
|
||||||
|
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(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
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(
|
||||||
|
"Failed to extract guild id from current context".to_string(),
|
||||||
|
)),
|
||||||
|
}?;
|
||||||
|
|
||||||
|
let guild = match database.get_by_id::<Guild>(&guild_id).await {
|
||||||
|
Ok(query) => match query {
|
||||||
|
Some(r) => Ok(r),
|
||||||
|
None => Err(CommandExecutionError::ContextRetrievalError(
|
||||||
|
"Failed to retrieve the guild where the command was issued".to_string(),
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
Err(()) => Err(CommandExecutionError::DatabaseQueryError(
|
||||||
|
"Failed to access to the database".to_string(),
|
||||||
|
)),
|
||||||
|
}?;
|
||||||
|
|
||||||
|
let mut available_tags_str: Vec<String> = Vec::new();
|
||||||
|
let mut user_subscriptions: HashSet<String> = HashSet::new();
|
||||||
|
|
||||||
|
for tag in guild.tags.as_slice() {
|
||||||
|
available_tags_str.push(tag.name.clone());
|
||||||
|
if tag.subscribers.contains(&user_id) {
|
||||||
|
user_subscriptions.insert(tag.name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut selector = EmbedSelector::new(
|
||||||
|
"Select the tags you want to subscribe to".to_string(),
|
||||||
|
Self::description(),
|
||||||
|
&self.interaction,
|
||||||
|
&context,
|
||||||
|
available_tags_str,
|
||||||
|
Some(user_subscriptions),
|
||||||
|
);
|
||||||
|
|
||||||
|
let user_selection = selector.get_user_selection().await?;
|
||||||
|
|
||||||
|
if let Some(selection) = user_selection {
|
||||||
|
let mut cloned_tags = guild.tags.clone();
|
||||||
|
for t in cloned_tags.as_mut_slice() {
|
||||||
|
if selection.contains(&t.name) && !t.subscribers.contains(&user_id) {
|
||||||
|
t.subscribers.push(user_id.clone());
|
||||||
|
} else if !selection.contains(&t.name) && t.subscribers.contains(&user_id) {
|
||||||
|
t.subscribers.retain(|x| *x != user_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
database
|
||||||
|
.update_one::<Guild>(
|
||||||
|
guild,
|
||||||
|
doc! {"$set": {"tags": to_bson(&cloned_tags.to_vec()).unwrap()}},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
CommandExecutionError::DatabaseQueryError(
|
||||||
|
"Failed to update user subscriptions in database".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut response = match self.interaction.get_interaction_response(&context).await {
|
||||||
|
Ok(r) => Ok(r),
|
||||||
|
Err(_e) => Err(CommandExecutionError::ContextRetrievalError(
|
||||||
|
"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
|
||||||
|
.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(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
164
src/discord/commands/tag_notify.rs
Normal file
164
src/discord/commands/tag_notify.rs
Normal file
|
@ -0,0 +1,164 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use log::debug;
|
||||||
|
|
||||||
|
use serenity::{
|
||||||
|
async_trait,
|
||||||
|
model::prelude::{
|
||||||
|
interaction::{
|
||||||
|
application_command::ApplicationCommandInteraction, InteractionResponseType,
|
||||||
|
},
|
||||||
|
UserId,
|
||||||
|
},
|
||||||
|
prelude::{Context, Mentionable},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
commons::{BotCommandOption, CommandExecutionError},
|
||||||
|
BotCommand,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
database::{models::Guild, Client as DatabaseClient},
|
||||||
|
discord::message_builders::selector_builder::EmbedSelector,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct TagNotifyCommand {
|
||||||
|
interaction: ApplicationCommandInteraction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl BotCommand for TagNotifyCommand {
|
||||||
|
fn name() -> String {
|
||||||
|
debug!("Creating a new TagNotifyCommand object");
|
||||||
|
String::from("notify")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn new(interaction: ApplicationCommandInteraction) -> Self {
|
||||||
|
TagNotifyCommand { interaction }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description() -> String {
|
||||||
|
String::from("Ping users according to a list of tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn options_list() -> Vec<BotCommandOption> {
|
||||||
|
vec![]
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(
|
||||||
|
&self,
|
||||||
|
context: Context,
|
||||||
|
database: Arc<DatabaseClient>,
|
||||||
|
) -> Result<(), CommandExecutionError> {
|
||||||
|
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(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let guild_id = match self.interaction.guild_id {
|
||||||
|
Some(id) => Ok(id.to_string()),
|
||||||
|
None => Err(CommandExecutionError::ContextRetrievalError(
|
||||||
|
"Failed to extract guild id from current context".to_string(),
|
||||||
|
)),
|
||||||
|
}?;
|
||||||
|
|
||||||
|
let guild = match database.get_by_id::<Guild>(&guild_id).await {
|
||||||
|
Ok(query) => match query {
|
||||||
|
Some(r) => Ok(r),
|
||||||
|
None => Err(CommandExecutionError::ContextRetrievalError(
|
||||||
|
"Failed to retrieve the guild where the command was issued".to_string(),
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
Err(()) => Err(CommandExecutionError::DatabaseQueryError(
|
||||||
|
"Failed to access to the database".to_string(),
|
||||||
|
)),
|
||||||
|
}?;
|
||||||
|
|
||||||
|
let mut available_tags_str: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
for tag in guild.tags.as_slice() {
|
||||||
|
available_tags_str.push(tag.name.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut selector = EmbedSelector::new(
|
||||||
|
"Select the tags to notify".to_string(),
|
||||||
|
Self::description(),
|
||||||
|
&self.interaction,
|
||||||
|
&context,
|
||||||
|
available_tags_str,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
let user_selection = selector.get_user_selection().await?;
|
||||||
|
|
||||||
|
if let Some(selection) = user_selection {
|
||||||
|
let mut answer = String::new();
|
||||||
|
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(
|
||||||
|
"No matching tag found for selection".to_string(),
|
||||||
|
)),
|
||||||
|
}?;
|
||||||
|
|
||||||
|
for user_id in t.subscribers.as_slice() {
|
||||||
|
match user_id.parse::<u64>() {
|
||||||
|
Ok(id) => match &UserId(id).to_user(&context).await {
|
||||||
|
Ok(e) => {
|
||||||
|
answer += &e.mention().to_string();
|
||||||
|
}
|
||||||
|
Err(_e) => {}
|
||||||
|
},
|
||||||
|
Err(_e) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut response = match self.interaction.get_interaction_response(&context).await {
|
||||||
|
Ok(r) => Ok(r),
|
||||||
|
Err(_e) => Err(CommandExecutionError::ContextRetrievalError(
|
||||||
|
"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
|
||||||
|
.edit(&context, |msg| {
|
||||||
|
msg.suppress_embeds(true);
|
||||||
|
msg.content(if answer.is_empty() {
|
||||||
|
"Nobody to ping for your selection".to_string()
|
||||||
|
} else {
|
||||||
|
answer
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_e| {
|
||||||
|
CommandExecutionError::DiscordAPICallError(
|
||||||
|
"Failed to edit content of the original response message".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
} else {
|
||||||
|
self.interaction
|
||||||
|
.delete_original_interaction_response(&context)
|
||||||
|
.await
|
||||||
|
.map_err(|_e| {
|
||||||
|
CommandExecutionError::DiscordAPICallError(
|
||||||
|
"Failed to delete the original interaction message".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,34 +1,194 @@
|
||||||
use std::sync::{Arc, Mutex};
|
use log::{debug, info, warn};
|
||||||
|
use std::{collections::HashSet, sync::Arc};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
use crate::database::Client as DatabaseClient;
|
use super::commands::{
|
||||||
|
BotCommand, CreateTagCommand, ListTagCommand, SourceCodeCommand, TagNotifyCommand,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
database::{models::Guild, Client as DatabaseClient},
|
||||||
|
discord::commands::{commons::CommandExecutionError, DeleteTagCommand, SubscribeCommand},
|
||||||
|
};
|
||||||
use serenity::{
|
use serenity::{
|
||||||
async_trait,
|
async_trait,
|
||||||
model::gateway::Ready,
|
model::gateway::Ready,
|
||||||
model::prelude::{interaction::Interaction, ResumedEvent},
|
model::prelude::{
|
||||||
|
interaction::{
|
||||||
|
application_command::ApplicationCommandInteraction, Interaction,
|
||||||
|
InteractionResponseType,
|
||||||
|
},
|
||||||
|
ResumedEvent, UserId,
|
||||||
|
},
|
||||||
prelude::{Context, EventHandler},
|
prelude::{Context, EventHandler},
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAX_ARGS_NUMBER: u32 = 25;
|
async fn answer_with_error(ctx: &Context, interaction: &ApplicationCommandInteraction) {
|
||||||
|
const ERROR_MSG: &str = "Internal error while executing your command.";
|
||||||
|
let result = match interaction.get_interaction_response(&ctx).await {
|
||||||
|
Ok(mut m) => {
|
||||||
|
m.edit(&ctx, |msg| msg.suppress_embeds(true).content(ERROR_MSG))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
interaction
|
||||||
|
.create_interaction_response(&ctx, |msg| {
|
||||||
|
msg.kind(InteractionResponseType::ChannelMessageWithSource)
|
||||||
|
.interaction_response_data(|c| c.content(ERROR_MSG))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = result {
|
||||||
|
warn!("Could not reply to user with error message: {e:#?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Handler {
|
pub struct Handler {
|
||||||
pub database: Arc<Mutex<DatabaseClient>>,
|
pub database: Arc<DatabaseClient>,
|
||||||
|
pub users_with_running_selector: Arc<Mutex<HashSet<UserId>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Handler {
|
||||||
|
async fn reset_selector_list(&self) {
|
||||||
|
self.users_with_running_selector.lock().await.clear();
|
||||||
|
debug!("List of user with running selector reset");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl EventHandler for Handler {
|
impl EventHandler for Handler {
|
||||||
async fn ready(&self, ctx: Context, ready: Ready) {
|
async fn ready(&self, ctx: Context, ready: Ready) {
|
||||||
println!("Successfully connected as {}", ready.user.name);
|
// Unregister all application commands before registering them again
|
||||||
|
if let Ok(commands) = ctx.http.get_global_application_commands().await {
|
||||||
|
for command in commands {
|
||||||
|
match ctx
|
||||||
|
.http
|
||||||
|
.delete_global_application_command(*command.id.as_u64())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => debug!("Successfully unregistered {} command.", command.name),
|
||||||
|
Err(_) => debug!("Failed to unregister {} command", command.name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Register commands
|
info!("Successfully connected as {}", ready.user.name);
|
||||||
|
CreateTagCommand::register(&ctx).await;
|
||||||
|
SourceCodeCommand::register(&ctx).await;
|
||||||
|
ListTagCommand::register(&ctx).await;
|
||||||
|
DeleteTagCommand::register(&ctx).await;
|
||||||
|
TagNotifyCommand::register(&ctx).await;
|
||||||
|
SubscribeCommand::register(&ctx).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn resume(&self, _: Context, _: ResumedEvent) {
|
async fn resume(&self, _: Context, _: ResumedEvent) {
|
||||||
println!("Successfully reconnected.")
|
self.reset_selector_list().await;
|
||||||
|
info!("Successfully reconnected.")
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
|
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
|
||||||
|
let mut failed_command = false;
|
||||||
|
|
||||||
if let Interaction::ApplicationCommand(command) = interaction {
|
if let Interaction::ApplicationCommand(command) = interaction {
|
||||||
println!("Received command {}", command.data.name);
|
info!("Received command {}", command.data.name);
|
||||||
|
|
||||||
|
if let Some(guild_id) = command.guild_id {
|
||||||
|
if let Ok(None) = self
|
||||||
|
.database
|
||||||
|
.get_by_id::<Guild>(&guild_id.to_string())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let new_guild = Guild {
|
||||||
|
id: guild_id.to_string(),
|
||||||
|
ban_list: vec![],
|
||||||
|
tags: vec![],
|
||||||
|
};
|
||||||
|
|
||||||
|
match self.database.insert_one(new_guild).await {
|
||||||
|
Ok(()) => info!("Unregistered guild: Adding it to the database"),
|
||||||
|
Err(()) => {
|
||||||
|
warn!("Error adding a new guild in the database");
|
||||||
|
failed_command = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if failed_command {
|
||||||
|
answer_with_error(&ctx, &command).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let command_result = match command.data.name.as_str() {
|
||||||
|
"create_tag" => {
|
||||||
|
CreateTagCommand::new(command.clone())
|
||||||
|
.run(ctx.clone(), self.database.clone())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
"about" => {
|
||||||
|
SourceCodeCommand::new(command.clone())
|
||||||
|
.run(ctx.clone(), self.database.clone())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
"list_tags" => {
|
||||||
|
ListTagCommand::new(command.clone())
|
||||||
|
.run(ctx.clone(), self.database.clone())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
"delete_tag" => {
|
||||||
|
DeleteTagCommand::new(command.clone())
|
||||||
|
.run(ctx.clone(), self.database.clone())
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
"notify" => {
|
||||||
|
let mut users_selector = self.users_with_running_selector.lock().await;
|
||||||
|
|
||||||
|
if !users_selector.contains(&command.user.id) {
|
||||||
|
users_selector.insert(command.user.id);
|
||||||
|
drop(users_selector);
|
||||||
|
|
||||||
|
TagNotifyCommand::new(command.clone())
|
||||||
|
.run(ctx.clone(), self.database.clone())
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
Err(CommandExecutionError::SelectorError(
|
||||||
|
"User has already a selector running".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"subscribe" => {
|
||||||
|
let mut users_selector = self.users_with_running_selector.lock().await;
|
||||||
|
|
||||||
|
if !users_selector.contains(&command.user.id) {
|
||||||
|
users_selector.insert(command.user.id);
|
||||||
|
drop(users_selector);
|
||||||
|
|
||||||
|
SubscribeCommand::new(command.clone())
|
||||||
|
.run(ctx.clone(), self.database.clone())
|
||||||
|
.await
|
||||||
|
} else {
|
||||||
|
Err(CommandExecutionError::SelectorError(
|
||||||
|
"User has already a selector running".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ => Err(CommandExecutionError::UnknownCommand(
|
||||||
|
"Received an unknon command from Discord".to_string(),
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = command_result {
|
||||||
|
warn!("Error while executing command: {e:#?}");
|
||||||
|
answer_with_error(&ctx, &command).await;
|
||||||
|
} else {
|
||||||
|
let mut users_lock = self.users_with_running_selector.lock().await;
|
||||||
|
if users_lock.contains(&command.user.id) {
|
||||||
|
users_lock.remove(&command.user.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
2
src/discord/message_builders.rs
Normal file
2
src/discord/message_builders.rs
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod embed_builder;
|
||||||
|
pub mod selector_builder;
|
113
src/discord/message_builders/embed_builder.rs
Normal file
113
src/discord/message_builders/embed_builder.rs
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use serenity::{
|
||||||
|
builder::{CreateEmbed, CreateEmbedAuthor, CreateEmbedFooter},
|
||||||
|
prelude::Context,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::discord::commands::commons::CommandExecutionError;
|
||||||
|
|
||||||
|
const HTML_COLOR_CODE: u32 = 0xffffff;
|
||||||
|
|
||||||
|
pub struct EmbedMessageBuilder {
|
||||||
|
color_code: u32,
|
||||||
|
embed_author: String,
|
||||||
|
embed_avatar_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
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(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let embed_author = bot_user.name.clone();
|
||||||
|
|
||||||
|
let embed_avatar_url = bot_user
|
||||||
|
.avatar_url()
|
||||||
|
.unwrap_or_else(|| bot_user.default_avatar_url());
|
||||||
|
|
||||||
|
Ok(EmbedMessageBuilder {
|
||||||
|
color_code: HTML_COLOR_CODE,
|
||||||
|
embed_author,
|
||||||
|
embed_avatar_url,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_embed_author(&self) -> CreateEmbedAuthor {
|
||||||
|
CreateEmbedAuthor(HashMap::new())
|
||||||
|
.name(self.embed_author.clone())
|
||||||
|
.icon_url(self.embed_avatar_url.clone())
|
||||||
|
.to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_embed_base(&self) -> CreateEmbed {
|
||||||
|
CreateEmbed(HashMap::new())
|
||||||
|
.set_author(self.create_embed_author())
|
||||||
|
.colour(self.color_code)
|
||||||
|
.to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_embed_pages_footer(
|
||||||
|
&self,
|
||||||
|
current_page: usize,
|
||||||
|
total_pages: usize,
|
||||||
|
) -> CreateEmbedFooter {
|
||||||
|
CreateEmbedFooter(HashMap::new())
|
||||||
|
.text(format!("Page {current_page}/{total_pages}"))
|
||||||
|
.to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_bot_credentials_embed(&self) -> CreateEmbed {
|
||||||
|
self.create_embed_base()
|
||||||
|
.title("Credentials")
|
||||||
|
.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/~victormignot/yorokobot/", false),
|
||||||
|
("Illustrator's Twitter", "https://twitter.com/MaewenMitzuki", false),
|
||||||
|
("Developer's Discord Server", "https://discord.gg/e8Q4zQbJb3", false),
|
||||||
|
])
|
||||||
|
.to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn create_selection_embed(
|
||||||
|
&self,
|
||||||
|
title: &str,
|
||||||
|
description: &str,
|
||||||
|
selectable: &[String],
|
||||||
|
selected: &HashSet<String>,
|
||||||
|
pages: usize,
|
||||||
|
current_page: usize,
|
||||||
|
) -> CreateEmbed {
|
||||||
|
let mut content = "".to_string();
|
||||||
|
|
||||||
|
const SELECTION_EMOTES: [&str; 10] = [
|
||||||
|
":zero:", ":one:", ":two:", ":three:", ":four:", ":five:", ":six:", ":seven:",
|
||||||
|
":eight:", ":nine:",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (value, emote) in selectable.iter().zip(SELECTION_EMOTES.iter()) {
|
||||||
|
content.push_str(
|
||||||
|
format!(
|
||||||
|
"{emote} - *{value}* {}\n",
|
||||||
|
if selected.contains(value) {
|
||||||
|
":white_check_mark:"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.as_str(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.create_embed_base()
|
||||||
|
.title(title)
|
||||||
|
.description(description)
|
||||||
|
.field("Selection", content, false)
|
||||||
|
.set_footer(self.create_embed_pages_footer(current_page, pages))
|
||||||
|
.to_owned()
|
||||||
|
}
|
||||||
|
}
|
305
src/discord/message_builders/selector_builder.rs
Normal file
305
src/discord/message_builders/selector_builder.rs
Normal file
|
@ -0,0 +1,305 @@
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use futures::StreamExt;
|
||||||
|
use log::warn;
|
||||||
|
use serenity::{
|
||||||
|
collector::EventCollectorBuilder,
|
||||||
|
model::prelude::{
|
||||||
|
interaction::application_command::ApplicationCommandInteraction, Event, EventType, Message,
|
||||||
|
ReactionType,
|
||||||
|
},
|
||||||
|
prelude::Context,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::discord::commands::commons::CommandExecutionError;
|
||||||
|
|
||||||
|
use super::embed_builder::EmbedMessageBuilder;
|
||||||
|
|
||||||
|
const MAX_SELECTABLE_PER_PAGE: usize = 10;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Match the Discord 0 to 9 icon (that are encoded with three utf-8 character)
|
||||||
|
* We can notice here that only the first character change between the emote.
|
||||||
|
* This character is in fact the encoded number related to the emote.
|
||||||
|
* Ex: '\u{0030}' = '0', \u{0031} = '1' ...
|
||||||
|
*/
|
||||||
|
const SELECTION_EMOTES: [&str; 10] = [
|
||||||
|
"\u{0030}\u{FE0F}\u{20E3}",
|
||||||
|
"\u{0031}\u{FE0F}\u{20E3}",
|
||||||
|
"\u{0032}\u{FE0F}\u{20E3}",
|
||||||
|
"\u{0033}\u{FE0F}\u{20E3}",
|
||||||
|
"\u{0034}\u{FE0F}\u{20E3}",
|
||||||
|
"\u{0035}\u{FE0F}\u{20E3}",
|
||||||
|
"\u{0036}\u{FE0F}\u{20E3}",
|
||||||
|
"\u{0037}\u{FE0F}\u{20E3}",
|
||||||
|
"\u{0038}\u{FE0F}\u{20E3}",
|
||||||
|
"\u{0039}\u{FE0F}\u{20E3}",
|
||||||
|
];
|
||||||
|
|
||||||
|
const PREVIOUS_PAGE_EMOTE: &str = "\u{2B05}";
|
||||||
|
const NEXT_PAGE_EMOTE: &str = "\u{27A1}";
|
||||||
|
const CONFIRM_EMOTE: &str = "\u{2705}";
|
||||||
|
const CANCEL_EMOTE: &str = "\u{274C}";
|
||||||
|
|
||||||
|
pub struct EmbedSelector<'a> {
|
||||||
|
interaction: &'a ApplicationCommandInteraction,
|
||||||
|
context: &'a Context,
|
||||||
|
embed_answer: Option<Message>,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
selection: HashSet<String>,
|
||||||
|
selectable: Vec<String>,
|
||||||
|
page_number: usize,
|
||||||
|
current_page: usize,
|
||||||
|
aborted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> EmbedSelector<'a> {
|
||||||
|
pub fn new(
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
interaction: &'a ApplicationCommandInteraction,
|
||||||
|
context: &'a Context,
|
||||||
|
selectable: Vec<String>,
|
||||||
|
initial_selection: Option<HashSet<String>>,
|
||||||
|
) -> Self {
|
||||||
|
let selection = match initial_selection {
|
||||||
|
Some(r) => r,
|
||||||
|
None => HashSet::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut selector = EmbedSelector {
|
||||||
|
interaction,
|
||||||
|
context,
|
||||||
|
embed_answer: None,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
selection,
|
||||||
|
selectable: selectable.clone(),
|
||||||
|
page_number: (selectable.len() as f32 / MAX_SELECTABLE_PER_PAGE as f32).ceil() as usize,
|
||||||
|
current_page: 1,
|
||||||
|
aborted: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
selector.selectable.sort();
|
||||||
|
selector
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_selection(
|
||||||
|
&mut self,
|
||||||
|
) -> Result<Option<HashSet<String>>, CommandExecutionError> {
|
||||||
|
let embed_builder = EmbedMessageBuilder::new(self.context).await?;
|
||||||
|
|
||||||
|
match self
|
||||||
|
.interaction
|
||||||
|
.edit_original_interaction_response(self.context, |response| {
|
||||||
|
response.set_embed(embed_builder.create_selection_embed(
|
||||||
|
&self.title,
|
||||||
|
&self.description,
|
||||||
|
&self.selectable[0..self.get_current_page_choice_number()],
|
||||||
|
&self.selection,
|
||||||
|
self.page_number,
|
||||||
|
1,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(m) => {
|
||||||
|
self.embed_answer = Some(m);
|
||||||
|
self.refresh_reactions().await?;
|
||||||
|
Ok(self.wait_selector_end().await?)
|
||||||
|
}
|
||||||
|
Err(_e) => Err(CommandExecutionError::DiscordAPICallError(
|
||||||
|
"Failed to edit original interaction responnse".to_string(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh_reactions(&self) -> Result<(), CommandExecutionError> {
|
||||||
|
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(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
for emote in SELECTION_EMOTES[0..self.get_current_page_choice_number()]
|
||||||
|
.iter()
|
||||||
|
.chain(
|
||||||
|
[
|
||||||
|
PREVIOUS_PAGE_EMOTE,
|
||||||
|
NEXT_PAGE_EMOTE,
|
||||||
|
CANCEL_EMOTE,
|
||||||
|
CONFIRM_EMOTE,
|
||||||
|
]
|
||||||
|
.iter(),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
match &self.embed_answer {
|
||||||
|
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(
|
||||||
|
"Failed to refresh the reactions of the current selector".to_string(),
|
||||||
|
)),
|
||||||
|
}?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(CommandExecutionError::SelectorError(
|
||||||
|
"Tried to delete reaction from a non existent message".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wait_selector_end(
|
||||||
|
&mut self,
|
||||||
|
) -> Result<Option<HashSet<String>>, CommandExecutionError> {
|
||||||
|
let answer = match &self.embed_answer {
|
||||||
|
Some(a) => Ok(a),
|
||||||
|
None => Err(CommandExecutionError::SelectorError(
|
||||||
|
"Tried to start collector before sending it".to_string(),
|
||||||
|
)),
|
||||||
|
}?;
|
||||||
|
|
||||||
|
let mut collector = EventCollectorBuilder::new(self.context)
|
||||||
|
.add_event_type(EventType::ReactionAdd)
|
||||||
|
.add_user_id(self.interaction.user.id)
|
||||||
|
.add_message_id(*answer.id.as_u64())
|
||||||
|
.build()
|
||||||
|
.map_err(|_e| {
|
||||||
|
CommandExecutionError::SelectorError(
|
||||||
|
"Failed to build the EventCollector".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
while let Some(reaction_event) = collector.next().await {
|
||||||
|
let reaction = match *reaction_event {
|
||||||
|
Event::ReactionAdd(ref r) => &r.reaction,
|
||||||
|
ref e => {
|
||||||
|
warn!("Received unexpected event in the selector EventCollector: {e:#?}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let ReactionType::Unicode(reaction_str) = &reaction.emoji {
|
||||||
|
match reaction_str.as_str() {
|
||||||
|
PREVIOUS_PAGE_EMOTE => self.previous_page().await?,
|
||||||
|
NEXT_PAGE_EMOTE => self.next_page().await?,
|
||||||
|
CANCEL_EMOTE => {
|
||||||
|
self.aborted = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
CONFIRM_EMOTE => {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
r => {
|
||||||
|
if SELECTION_EMOTES.contains(&reaction_str.as_str()) {
|
||||||
|
// Extract the number part of the emote unicode
|
||||||
|
let selected_nb = match r.chars().next() {
|
||||||
|
Some(c) => match c.to_digit(10) {
|
||||||
|
Some(nb) => nb as usize,
|
||||||
|
None => {
|
||||||
|
warn!("Failed to cast emote code into number");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
warn!("Received empty emote code in ReactionAdd event");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let tag_index: usize =
|
||||||
|
(self.current_page - 1) * MAX_SELECTABLE_PER_PAGE + selected_nb;
|
||||||
|
|
||||||
|
let tag = &self.selectable[tag_index];
|
||||||
|
|
||||||
|
if self.selection.contains(tag) {
|
||||||
|
self.selection.remove(tag);
|
||||||
|
} else {
|
||||||
|
self.selection.insert(tag.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reaction.delete(self.context).await.map_err(|_e| {
|
||||||
|
CommandExecutionError::DiscordAPICallError(
|
||||||
|
"Failed to delete reaction from selector".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
self.refresh_embed_selection().await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
collector.stop();
|
||||||
|
|
||||||
|
if self.aborted {
|
||||||
|
Ok(None)
|
||||||
|
} else {
|
||||||
|
Ok(Some(self.selection.clone()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn next_page(&mut self) -> Result<(), CommandExecutionError> {
|
||||||
|
if self.current_page != self.page_number {
|
||||||
|
self.current_page += 1;
|
||||||
|
self.refresh_embed_selection().await?;
|
||||||
|
self.refresh_reactions().await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn previous_page(&mut self) -> Result<(), CommandExecutionError> {
|
||||||
|
if self.current_page != 1 {
|
||||||
|
self.current_page -= 1;
|
||||||
|
self.refresh_embed_selection().await?;
|
||||||
|
self.refresh_reactions().await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_current_page_choice_number(&self) -> usize {
|
||||||
|
if self.page_number == self.current_page
|
||||||
|
&& self.selectable.len() % MAX_SELECTABLE_PER_PAGE != 0
|
||||||
|
{
|
||||||
|
self.selectable.len() % MAX_SELECTABLE_PER_PAGE
|
||||||
|
} else {
|
||||||
|
MAX_SELECTABLE_PER_PAGE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh_embed_selection(&mut self) -> Result<(), CommandExecutionError> {
|
||||||
|
let embed_builder = EmbedMessageBuilder::new(self.context).await?;
|
||||||
|
|
||||||
|
let curr_choices = self.selectable
|
||||||
|
[(self.current_page - 1) * 10..(self.current_page * 10).min(self.selectable.len())]
|
||||||
|
.to_vec();
|
||||||
|
|
||||||
|
self.embed_answer
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.edit(self.context, |msg| {
|
||||||
|
msg.set_embed(embed_builder.create_selection_embed(
|
||||||
|
&self.title,
|
||||||
|
&self.description,
|
||||||
|
&curr_choices,
|
||||||
|
&self.selection,
|
||||||
|
self.page_number,
|
||||||
|
self.current_page,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|_e| {
|
||||||
|
CommandExecutionError::DiscordAPICallError(
|
||||||
|
"Failed to edit selector content".to_string(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,7 +3,7 @@
|
||||||
use log::error;
|
use log::error;
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|
||||||
/// Get the environment variiable var_name or panic
|
/// Fetch the `var_name` environment variable.
|
||||||
pub fn get_env_variable(var_name: &str) -> String {
|
pub fn get_env_variable(var_name: &str) -> String {
|
||||||
match env::var(var_name) {
|
match env::var(var_name) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
//!
|
//!
|
||||||
//! [`Serenity`]: https://github.com/serenity-rs/serenity
|
//! [`Serenity`]: https://github.com/serenity-rs/serenity
|
||||||
|
|
||||||
//#![deny(missing_docs)]
|
#![deny(missing_docs)]
|
||||||
#![deny(warnings)]
|
#![deny(warnings)]
|
||||||
|
|
||||||
mod client;
|
mod client;
|
||||||
|
|
Loading…
Reference in a new issue