diff --git a/src/bot.rs b/src/bot.rs index 0031af1..3b32a1f 100644 --- a/src/bot.rs +++ b/src/bot.rs @@ -1,3 +1,7 @@ +//! Bot services. +//! +//! The shuttle_runtime for the BotService is defined here, which +//! binds itself to the `SocketAddr` provided by shuttle. use crate::{ database::*, message::{display_tally, list_runs, list_users}, @@ -6,11 +10,15 @@ use sqlx::PgPool; use teloxide::{prelude::*, utils::command::BotCommands}; use tracing::error; +/// Encapsulate the BotService. pub struct BotService { + /// Teloxide Bot. pub bot: Bot, + /// Database connection. pub postgres: PgPool, } +/// Required implementation of the `shuttle_runtime::Service` trait for `BotService`. #[shuttle_runtime::async_trait] impl shuttle_runtime::Service for BotService { async fn bind(self, _addr: std::net::SocketAddr) -> Result<(), shuttle_runtime::Error> { @@ -23,7 +31,11 @@ impl shuttle_runtime::Service for BotService { } } +/// impl block for `BotService`. impl BotService { + /// Clones `bot` and `db_connection` before passing these over to `Command`, also + /// defined within teloxide. It parses incoming commands, matches them and + /// hands them over to the `answer` methold. async fn start(&self) -> Result<(), shuttle_runtime::CustomError> { let bot = self.bot.clone(); let db_connection = self.postgres.clone(); @@ -37,31 +49,62 @@ impl BotService { } } +/// Enumeration of commands accepted by the bot. #[derive(BotCommands, Clone)] #[command( rename_rule = "lowercase", description = "The following commands are supported:" )] enum Command { - #[command(description = "display this text")] + /// Matched to `/help` -> displays commands and their documentation. + #[command(description = "Display this text. Usage: /help")] Help, - #[command(description = "Show users registered on telerun within the chat.")] + #[command(description = "Show users registered on telerun within the chat. Usage: /show")] + /// Matched to `/show` -> displays users within chat. Show, + /// Matched to `/add ` -> creates users in db if not present, + /// then adds run data to runs table. #[command( - description = "Add run data to database. Format is /add %distance (km)% %username%", + description = "Add run data to database. Usage: /add ", parse_with = "split" )] - Add { distance: f32, user_name: String }, - #[command(description = "Edit data for a run.", parse_with = "split")] - Edit { run_id: i32, distance: f32 }, - #[command(description = "Remove a run from database.")] - Delete { run_id: i32 }, - #[command(description = "Tallies current medals and distances.")] + Add { + /// Distance run in km + distance: f32, + /// Name of user to tie the run to. Must be unique. + user_name: String, + }, + /// Matched to `/edit ` -> edits stored run data. + #[command( + description = "Edit data for a run. Usage: /edit ", + parse_with = "split" + )] + Edit { + /// Id of run as stored in runs table. + run_id: i32, + /// Corrected distance run in km. + distance: f32, + }, + /// Matched to `/delete ` -> removes a certain run from database. + #[command(description = "Remove a run from database. Usage: /delete ")] + Delete { + /// Id of run to remove from table. + run_id: i32, + }, + /// Matched to `/tally` -> sends score board as message through Telegram. + #[command(description = "Tallies current medals and distances. Usage: /tally")] Tally, - #[command(description = "Lists recent runs. Number of runs to display must be specified.")] - List { limit: u32 }, + /// Matched to `/list ` -> displays runs registered by the group chat, subject to a limit. + #[command( + description = "Lists recent runs. Number of runs to display must be specified. Usage: /list " + )] + List { + /// Limit to query from db. + limit: u32, + }, } +/// Function used for handling various commands matched. async fn answer(bot: Bot, msg: Message, cmd: Command, db_connection: PgPool) -> ResponseResult<()> { match cmd { Command::Help => { diff --git a/src/database.rs b/src/database.rs index 9abde55..be9c4f4 100644 --- a/src/database.rs +++ b/src/database.rs @@ -1,10 +1,20 @@ +//! Database operations. +//! +//! [sqlx](https://docs.rs/sqlx/latest/sqlx/) is used to interact with the +//! Postgresql database. Macros are used to check queries against the +//! database at compile time. use crate::models::{Run, Score, User}; use sqlx::PgPool; use teloxide::types::ChatId; use tracing::error; +/// Convenience type to wrap a generic `Ok` and `sqlx::Error`. type DBResult = Result; +/// Creates a user in users table. +/// +/// Users are tied to the `chat_id` that the message came from +/// and the `user_name` input. This combination must be unique. pub async fn create_user(user_name: &str, chat_id: ChatId, connection: &PgPool) -> DBResult<()> { sqlx::query!( "INSERT INTO users (chat_id, user_name) @@ -19,6 +29,9 @@ pub async fn create_user(user_name: &str, chat_id: ChatId, connection: &PgPool) Ok(()) } +/// Retrieves a user. +/// +/// Fetches user information based on `(user_name, chat_id)`. async fn get_user(user_name: &str, chat_id: ChatId, connection: &PgPool) -> DBResult> { let user: Option = sqlx::query_as!( User, @@ -34,6 +47,9 @@ async fn get_user(user_name: &str, chat_id: ChatId, connection: &PgPool) -> DBRe Ok(user) } +/// Fetchers users in a chat. +/// +/// Retrieves users in a chat from `ChatId`. pub async fn get_users_in_chat( chat_id: ChatId, connection: &PgPool, @@ -61,7 +77,19 @@ pub async fn get_users_in_chat( } } -// TODO: refactor in the future +/// Wrapper for adding run data. +/// +/// # Arguments +/// * `distance` - Distance run in km +/// * `user_name` - Name user wishes to tie the run to. +/// * `chat_id` - Unique ID identifying the chat, this comes from Telegram. +/// +/// # Remarks +/// +/// Due to design flaws, we first check whether that user has +/// added a run before. If not, we need to first create that user. +/// Afterwards, we run `get_user` again to retrieve its `user_id`. +/// Following which, we then actually add the run to the database. pub async fn add_run_wrapper( distance: f32, user_name: &str, @@ -85,6 +113,9 @@ pub async fn add_run_wrapper( Ok(()) } +/// Adds run data. +/// +/// Performs the actual database update for adding run data. async fn add_run(distance: f32, user_id: i32, connection: &PgPool) -> DBResult<()> { sqlx::query!( "INSERT INTO runs (distance, user_id) @@ -99,6 +130,9 @@ async fn add_run(distance: f32, user_id: i32, connection: &PgPool) -> DBResult<( Ok(()) } +/// Fetches runs fromt the chat. +/// +/// `limit` must be specified or the `answer` cannot match the enum. pub async fn get_runs( chat_id: ChatId, limit: i64, @@ -137,6 +171,7 @@ pub async fn get_runs( } } +/// Updates a certain run by id. pub async fn update_run(run_id: i32, distance: f32, connection: &PgPool) -> DBResult<()> { sqlx::query!( "UPDATE runs @@ -151,6 +186,7 @@ pub async fn update_run(run_id: i32, distance: f32, connection: &PgPool) -> DBRe Ok(()) } +/// Deletes a run by id. pub async fn delete_run(run_id: i32, connection: &PgPool) -> DBResult<()> { sqlx::query!( "DELETE FROM runs @@ -163,6 +199,7 @@ pub async fn delete_run(run_id: i32, connection: &PgPool) -> DBResult<()> { Ok(()) } +/// Aggregates runs into a tally (`Vec`) pub async fn get_tally(chat_id: ChatId, connection: &PgPool) -> DBResult>> { let users = get_users_in_chat(chat_id, connection).await?; diff --git a/src/main.rs b/src/main.rs index 2fadd9d..b18f4df 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,12 @@ +#![warn(missing_docs)] +#![warn(clippy::missing_docs_in_private_items)] + +//! Telegram bot for a running contest between friends. +//! +//! Implements a telegram bot hosted on shuttle.rs (subject to change). +//! Shuttle provisions infrastructure from our infrastructure as code +//! that is used in this codebase. + mod bot; mod database; mod message; @@ -8,6 +17,15 @@ use shuttle_secrets::SecretStore; use sqlx::PgPool; use teloxide::prelude::*; +/// Entry point to the telegram bot service. +/// +/// We pass in the resources that we wish to provision as arguments to `shuttle_main()`. +/// As a requirement of shuttle.rs for provisioning a database, we run sqlx migrations +/// as the first step as well. +/// +/// Next, we load in our telegram bot's key and as it is a requirement for teloxide. +/// +/// Finally, we start our service. #[shuttle_runtime::main] async fn shuttle_main( #[shuttle_secrets::Secrets] secrets: SecretStore, diff --git a/src/message.rs b/src/message.rs index aa77ba6..ee9d673 100644 --- a/src/message.rs +++ b/src/message.rs @@ -1,10 +1,18 @@ +//! Templating and message preparation. +//! +//! This module reads in Rust-native objects and renders +//! them as `String`s using [askama](https://crates.io/crates/askama/0.7.2) +//! as the templating engine. + use crate::models::{Run, Score, User}; use askama::Template; use std::fmt; use std::ops; +/// NewType implementation so that Display can be implemented for it. struct RunDisplay(Run); +/// Deref is implemented so that accessing `Run`'s contents is easier. impl ops::Deref for RunDisplay { type Target = Run; @@ -42,12 +50,19 @@ impl fmt::Display for Score { } } +/// Struct Run display. #[derive(Template)] #[template(path = "list_runs.j2")] struct ListRunTemplate<'a> { + /// Reference to `runs` for askama to access. runs: &'a Vec, } +/// Displays runs fetched from database. +/// +/// Function takes in an `Option` and will check if any records have +/// been retrieved, else it will output that there are no runs +/// stored in the database. pub fn list_runs(runs: Option>) -> String { if let Some(runs) = runs { let run_displays: Vec = runs.into_iter().map(RunDisplay).collect(); @@ -61,12 +76,19 @@ pub fn list_runs(runs: Option>) -> String { } } +/// Struct User display. #[derive(Template)] #[template(path = "list_users.j2")] struct ListUserTemplate<'a> { + /// Reference to `users` for askama to access. users: &'a Vec, } +/// Displays users fetched from database. +/// +/// Function takes in an `Option` and will check if any records have +/// been retrieved, else it will output that there are no users +/// stored in the database. pub fn list_users(users: Option>) -> String { if let Some(users) = users { let user_template = ListUserTemplate { users: &users }; @@ -77,12 +99,19 @@ pub fn list_users(users: Option>) -> String { } } +/// Struct Tally display. #[derive(Template)] #[template(path = "list_tally.j2")] struct ListTallyTemplate<'a> { + /// Reference to `scores` for askama to access. scores: &'a Vec, } +/// Displays score aggregates fetched from database. +/// +/// Function takes in an `Option` and will check if any records have +/// been retrieved, else it will output that there the tally +/// cannot be generated. pub fn display_tally(scores: Option>) -> String { if let Some(scores) = scores { let tally_template = ListTallyTemplate { scores: &scores }; diff --git a/src/models.rs b/src/models.rs index 0a111af..2d402ca 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,22 +1,43 @@ +//! Struct models for database tables. +//! +//! Contains structs for an "ORM-like" approach to +//! database interactions. + use sqlx::types::chrono; +/// Represents a user row in the `users` table. #[derive(sqlx::FromRow)] pub struct User { + /// User id pub id: i32, + /// Id of telegram chat pub chat_id: String, + /// Self-specified username pub user_name: String, } +/// Represents a run row in the `runs` table. #[derive(sqlx::FromRow)] pub struct Run { + /// Run id pub id: i32, + /// Distance ran for a particular run pub distance: f32, + /// Datetime when the run was submitted to the database pub run_datetime: Option, + /// User_id of the user who submitted the run pub user_id: i32, } +/// Represents a score that appears in the tally. +/// +/// While this struct those not correspond direclty to a database +/// table, it is built directly from results retrieved. pub struct Score { + /// Self-specified username pub user_name: String, + /// Number of runs for the user, or in this case, medals pub medals: u32, + /// Total distance run by the user pub distance: f32, }