Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Birthbot 0.1.0 #1

Merged
merged 45 commits into from Oct 20, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
0d90c9e
Add Cargo file
Oct 16, 2022
636f18b
Add main file
Oct 16, 2022
d869e65
Add commands module
Oct 16, 2022
f377a9e
Add birthday command
Oct 16, 2022
fac694b
Add macros module
Oct 16, 2022
dc48191
Retrieve birthdays from database
Oct 16, 2022
c476487
Remove datetime dependency
Oct 16, 2022
f8faac7
Tweak conditional compilation
Oct 16, 2022
b26d43a
Set birthday in database
Oct 16, 2022
c7a835c
Add error type for bot
Oct 17, 2022
d3adf27
Refactor command handling to use bot error type
Oct 17, 2022
bec0d1a
Remove unused features
Oct 17, 2022
a78562c
Separate bot errors into user and non-user errors
Oct 17, 2022
e5b3bc4
Split into library and binary
Oct 17, 2022
c6c01d6
Refactor module structure and add documentation
Oct 17, 2022
d548bae
Add documentation and confine macros to crate
Oct 18, 2022
36ef69a
Add timezone support for birthdays
Oct 18, 2022
13e4dc5
Display only dates and not times
Oct 18, 2022
b8908bb
Use embeds instead of regular messages
Oct 18, 2022
2d8e76c
Refactor error macro
Oct 18, 2022
bd0e569
Add comments
Oct 18, 2022
03d72fa
Tweak embed for command errors
Oct 18, 2022
f117095
Move sub-commands into separate modules
Oct 19, 2022
18b1591
Add cron job for checking birthdays
Oct 19, 2022
39ceb0c
Use task spawning instead of cron jobs
Oct 19, 2022
d9d730f
Use error printing instead of regular printing macro
Oct 20, 2022
acf34d9
Announce age along with birthday
Oct 20, 2022
66dbfcb
Add announce subcommand
Oct 20, 2022
ec69034
Tweak command descriptions
Oct 20, 2022
4f251a5
Add unannounce subcommand
Oct 20, 2022
baca42f
Update instead of replace document
Oct 20, 2022
9b0ffca
Update instead of replace document
Oct 20, 2022
39452a2
Update instead of replace document
Oct 20, 2022
4585ab8
Add unset subcommand
Oct 20, 2022
0212aaf
Tweak command order
Oct 20, 2022
1404b2e
Tweak command matching order
Oct 20, 2022
3f960d8
Use fixed interval for birthday checks
Oct 20, 2022
c311561
Use fixed interval for birthday checks
Oct 20, 2022
3d2145a
Prevent setting other users' birthdays
Oct 20, 2022
5570c4d
Prevent removing other users' birthdays
Oct 20, 2022
ee69bd8
Fix function name
Oct 20, 2022
b7459cc
Add README
Oct 20, 2022
6e44e7e
Tweak use statements
Oct 20, 2022
a232c39
Tweak function
Oct 20, 2022
a8e28f7
Update documentation comment
Oct 20, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
15 changes: 15 additions & 0 deletions Cargo.toml
@@ -0,0 +1,15 @@
[package]
name = "birthbot"
version = "0.1.0"
edition = "2021"

[dependencies]
chrono = "0.4.22"
dotenv = "0.15.0"
mongodb = "2.3.1"
serenity = { version = "0.11", default-features = false, features = ["client", "gateway", "rustls_backend", "model"] }
tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] }

[features]
default = []
guild = []
54 changes: 54 additions & 0 deletions README.md
@@ -0,0 +1,54 @@
# Birthbot

**Birthbot** is a Discord bot for keeping track of birthdays.

# Setup

- ## Environment

**Birthbot** expects a `.env` file in its executing directory (or its parent(s)).
This must contain the following information:
- A `TOKEN` key, with **Birthbot**'s secret token
- A `CLUSTER` key, with the URI of the MongoDB cluster to use (usually in the format `mongodb+srv://USERNAME:PASSWORD@CLUSTER.CODE.mongodb.net/?retryWrites=true&w=majority`)
- A `DATABASE` key, with the name of the database to use

Optionally, if **Birthbot** is compiled with the `guild` Cargo feature, it must also contain a `GUILD` key with a guild ID.

- ## Database

**Birthbot** stores all its data on a MongoDB database as specified by the `.env` file.
Separate collections are used for each guild, in which separate documents are used to store each user's birthday.

# Features

- ## Commands

**Birthbot** recognises the following slash commands:
- `birthday get` to retrieve a user's birthday
- `birthday set` to add or update the command user's birthday
- `birthday unset` to remove the command user's birthday
- `birthday announce` to add or update the channel used for birthday announcements
- `birthday unannounce` to remove the channel used for birthday announcements

- ## Announcements

Every 24 hours beginning from its startup time, **Birthbot** checks its database for any ongoing birthdays, taking timezones into account.

If any matches are found, they are announced in the channel as specified by `birthday announce`.
If no announcement channel is set up, the birthdays are ignored.

# Errors

If and when an error occurs, **Birthbot** attempts to notify the command user of it.
Every error will also be printed to the standard error output.

Thanks to Rust's fantastic error handling, it is nearly impossible for **Birthbot** to crash due to an error.

# Privacy

**Birthbot** stores the bare minimum amount of data necessary to perform its tasks.
This includes:
- Guild IDs - *used as collection identifiers*
- User IDs - *used as document identifiers*
- Channel IDs - *used to announce birthdays*
- Birthdays - *used to check birthdays*
83 changes: 83 additions & 0 deletions src/commands/birthday/announce.rs
@@ -0,0 +1,83 @@
//! Generates and handles the `birthday announce` sub-command.

use mongodb::bson;
use mongodb::bson::Document;

use serenity::builder::CreateApplicationCommandOption;
use serenity::model::application::command::CommandOptionType;
use serenity::model::application::interaction::application_command::ApplicationCommandInteraction;
use serenity::model::application::interaction::application_command::CommandDataOption;
use serenity::model::channel::PartialChannel;
use serenity::prelude::Context;
use serenity::utils::Colour;

use crate::errors::BotError;

/// Generates the `birthday announce` sub-command.
pub fn create_birthday_announce_subcommand(subcommand: &mut CreateApplicationCommandOption) -> &mut CreateApplicationCommandOption {
subcommand
.kind(CommandOptionType::SubCommand)
.name("announce")
.description("Add or update the channel for announcing birthdays.")
.create_sub_option(|option| option
.kind(CommandOptionType::Channel)
.name("channel")
.description("The channel for birthday announcements")
.required(true))
}

/// Handles the `birthday announce` sub-command.
///
/// # Errors
/// A [BotError] is returned in situations including but not limited to:
/// - The sub-command option is not resolved or has an invalid value
/// - There was an error connecting to, querying, or updating the database
/// - There was an error responding to the command
pub async fn handle_birthday_announce_subcommand(subcommand: &CommandDataOption, command: &ApplicationCommandInteraction, context: &Context) -> Result<(), BotError> {
// Retrieve command options
let channel = require_command_channel_option!(subcommand.options.get(0), "channel")?;
let guild = command.guild_id
.ok_or(BotError::UserError(String::from("This command can only be performed in a guild.")))?;
// Build query and operation documents
let query = bson::doc! {
"config": {
"$exists": true,
"$type": "object",
},
};
let operation = bson::doc! {
"$set": {
"config.channel": channel.id.0 as i64,
},
};
// Connect to database and find collection
let database = super::connect_mongodb().await?;
let collection = database.collection::<Document>(guild.to_string().as_str());
// Update or insert document
let result = collection
.find_one_and_update(query, operation, None)
.await?;
match result {
None => {
let insertion = bson::doc! {
"config": {
"channel": channel.id.0 as i64,
},
};
collection
.insert_one(insertion, None)
.await?;
respond_birthday_announce(channel, "added", command, context).await
},
Some(_) => respond_birthday_announce(channel, "updated", command, context).await,
}
}

async fn respond_birthday_announce(channel: &PartialChannel, action: impl Into<String>, command: &ApplicationCommandInteraction, context: &Context) -> Result<(), BotError> {
command_response!(command, context, |data| data
.embed(|embed| embed
.title("Success")
.description(format!("The birthday announcement channel was successfully {}.", action.into()))
.field("Channel", format!("<#{}>", channel.id), true)
.colour(Colour::from_rgb(87, 242, 135))))
}
96 changes: 96 additions & 0 deletions src/commands/birthday/check.rs
@@ -0,0 +1,96 @@
//! Generates and executes the asynchronous task for checking birthdays.

use std::time::Duration;

use chrono::Datelike;
use chrono::Utc;

use mongodb::Collection;
use mongodb::bson;
use mongodb::bson::Document;

use serenity::client::Context;
use serenity::model::id::ChannelId;
use serenity::utils::Colour;

use tokio;
use tokio::time;

use crate::errors::BotError;

/// Spawns an asynchronous task to check for birthdays every day.
pub fn create_birthday_scheduler(context: &Context) {
let cloned = context.clone();
tokio::spawn(async move {
if let Err(error) = loop_checks(&cloned).await {
eprintln!("{:?}", error);
}
});
}

async fn loop_checks(context: &Context) -> Result<(), BotError> {
loop {
check_birthdays(context).await?;
time::sleep(Duration::from_secs(86400)).await;
}
}

async fn check_birthdays(context: &Context) -> Result<(), BotError> {
// Connect to database
let database = super::connect_mongodb().await?;
// Retrieve all collections in database
let names = database
.list_collection_names(None)
.await?;
for name in names {
let query = bson_birthday!();
// Retrieve all documents in collection
let collection = database.collection::<Document>(name.as_str());
let mut documents = collection
.find(query, None)
.await?;
while documents.advance().await? {
// Check if current server day is user's birthday
let document = documents.deserialize_current()?;
let user = document.get_i64("user")?;
let birth_date = super::get_birthday(&document)?
.with_timezone(&Utc);
let server_date = Utc::now();
// If birthday, announce in channel
if birth_date.day() == server_date.day() && birth_date.month() == server_date.month() {
let age = server_date.year() - document
.get_document("birth")?
.get_i32("year")?;
announce_birthday(user, age, &collection, context).await?;
}
}
}
Ok(())
}

async fn announce_birthday(user: i64, age: i32, collection: &Collection<Document>, context: &Context) -> Result<(), BotError> {
// Retrieve channel ID from collection
let query = bson::doc! {
"config.channel": {
"$exists": true,
"$type": "long",
},
};
let config = collection
.find_one(query, None)
.await?;
// If channel ID does not exist, no announcement is made
if let Some(config) = config {
ChannelId(config
.get_document("config")?
.get_i64("channel")? as u64)
.send_message(&context.http, |message| message
.embed(|embed| embed
.title("Birthday")
.description(format!("It's <@{}>'s birthday!", user))
.field("Age", age, true)
.colour(Colour::from_rgb(235, 69, 158))))
.await?;
}
Ok(())
}
86 changes: 86 additions & 0 deletions src/commands/birthday/get.rs
@@ -0,0 +1,86 @@
//! Generates and handles the `birthday get` sub-command.

use mongodb::bson::Document;

use serenity::builder::CreateApplicationCommandOption;
use serenity::model::application::command::CommandOptionType;
use serenity::model::application::interaction::application_command::ApplicationCommandInteraction;
use serenity::model::application::interaction::application_command::CommandDataOption;
use serenity::model::user::User;
use serenity::prelude::Context;
use serenity::utils::Colour;

use crate::errors::BotError;

/// Generates the `birthday get` sub-command.
pub fn create_birthday_get_subcommand(subcommand: &mut CreateApplicationCommandOption) -> &mut CreateApplicationCommandOption{
subcommand
.kind(CommandOptionType::SubCommand)
.name("get")
.description("Retrieve a user's birthday.")
.create_sub_option(|option| option
.kind(CommandOptionType::User)
.name("user")
.description("Whose birthday to get")
.required(false))
}

/// Handles the `birthday get` sub-command.
///
/// # Errors
/// A [BotError] is returned in situations including but not limited to:
/// - The sub-command option is not resolved or has an invalid value
/// - There was an error connecting to or querying the database
/// - There was an error responding to the command
pub async fn handle_birthday_get_subcommand(subcommand: &CommandDataOption, command: &ApplicationCommandInteraction, context: &Context) -> Result<(), BotError> {
// Retrieve command options
let user = require_command_user_option!(subcommand.options.get(0), "user", &command.user);
let guild = command.guild_id
.ok_or(BotError::UserError(String::from("This command can only be performed in a guild.")))?;
// Build query document
let query = bson_birthday!(user.id.0 as i64);
// Connect to database and find collection
let database = super::connect_mongodb().await?;
let collection = database.collection::<Document>(guild.to_string().as_str());
// Retrieve document
let result = collection
.find_one(query, None)
.await?;
respond_birthday_get(result, user, command, context)
.await
}

async fn respond_birthday_get(result: Option<Document>, user: &User, command: &ApplicationCommandInteraction, context: &Context) -> Result<(), BotError> {
match result {
// If query returned nothing, birthday has not been set yet
None => {
let description = if user.id == command.user.id {
String::from("You haven't set a birthday yet.")
} else {
format!("<@{}> hasn't set a birthday yet.", user.id)
};
command_response!(command, context, |data| data
.ephemeral(true)
.embed(|embed| embed
.title("Error")
.description(description)
.colour(Colour::from_rgb(237, 66, 69))))
},
// If query returned a document, parse and show the birthday
Some(document) => {
let date = super::get_birthday(&document)?;
let description = if user.id == command.user.id {
String::from("Your birthday was successfully retrieved.")
} else {
format!("<@{}>'s birthday was successfully retrieved.", user.id)
};
command_response!(command, context, |data| data
.ephemeral(true)
.embed(|embed| embed
.title("Success")
.description(description)
.field("Birthday", date.date(), true)
.colour(Colour::from_rgb(87, 242, 135))))
},
}
}