diff --git a/Cargo.lock b/Cargo.lock index b53694b..ccfa7e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -716,7 +716,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] -name = "good-first-bot-rs" +name = "good-first-bot" version = "0.1.0" dependencies = [ "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 1f7eb45..3334be5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "good-first-bot-rs" +name = "good-first-bot" version = "0.1.0" edition = "2024" diff --git a/src/bot_handler/callback_actions.rs b/src/bot_handler/callback_actions.rs index a42547d..b507b71 100644 --- a/src/bot_handler/callback_actions.rs +++ b/src/bot_handler/callback_actions.rs @@ -1,25 +1,36 @@ use serde::{Deserialize, Serialize}; +/// Represents the actions that can be triggered by an inline keyboard button. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum CallbackAction<'a> { + /// View the details of a specific repository. #[serde(rename = "vrd")] - ViewRepoDetails(&'a str, usize), // ViewRepoDetails("owner/repo", from_page) + ViewRepoDetails(&'a str, usize), // ("owner/repo", from_page) + /// View the labels for a specific repository. #[serde(rename = "vrl")] - ViewRepoLabels(&'a str, usize, usize), // ViewRepoLabels("owner/repo", labels_page, from_page) + ViewRepoLabels(&'a str, usize, usize), // ("owner/repo", labels_page, from_page) + /// Prompt the user to confirm removing a repository. #[serde(rename = "rrp")] RemoveRepoPrompt(&'a str), + /// Toggle a label for a repository. #[serde(rename = "tl")] ToggleLabel(&'a str, usize, usize), // ("label", labels_page, from_page) + /// Go back to the repository details view. #[serde(rename = "brd")] - BackToRepoDetails(&'a str, usize), // BackToRepoDetails("owner/repo", from_page) + BackToRepoDetails(&'a str, usize), // ("owner/repo", from_page) + /// Paginate through the list of repositories. #[serde(rename = "lrp")] - ListReposPage(usize), // ListReposPage(page) + ListReposPage(usize), // (page) + /// Go back to the main repository list view. #[serde(rename = "brl")] - BackToRepoList(usize), // BackToRepoList(page) - // Command keyboard actions, should be handled as commands: + BackToRepoList(usize), // (page) + /// A command to show the help message, triggered from a button. CmdHelp, - CmdList, // List all repos, default page 1 + /// A command to list all repositories, triggered from a button. + CmdList, + /// A command to add a new repository, triggered from a button. CmdAdd, + /// A command to show the overview, triggered from a button. CmdOverview, } diff --git a/src/bot_handler/callbacks/mod.rs b/src/bot_handler/callbacks/mod.rs index ddb0add..df5e442 100644 --- a/src/bot_handler/callbacks/mod.rs +++ b/src/bot_handler/callbacks/mod.rs @@ -1,3 +1,5 @@ +//! This module contains handlers for callback queries. + pub mod list; pub mod remove; pub mod toggle_label; diff --git a/src/bot_handler/commands/mod.rs b/src/bot_handler/commands/mod.rs index 2fbdf9f..fd6ccf1 100644 --- a/src/bot_handler/commands/mod.rs +++ b/src/bot_handler/commands/mod.rs @@ -1,3 +1,5 @@ +//! This module contains handlers for bot commands. + pub mod add; pub mod help; pub mod list; @@ -8,8 +10,10 @@ use async_trait::async_trait; use crate::bot_handler::{BotHandlerResult, Context}; +/// A trait for handling bot commands. #[async_trait] pub trait CommandHandler { + /// Handles a command. async fn handle(self, ctx: Context<'_>) -> BotHandlerResult<()>; } diff --git a/src/bot_handler/mod.rs b/src/bot_handler/mod.rs index ddce145..9b2b38a 100644 --- a/src/bot_handler/mod.rs +++ b/src/bot_handler/mod.rs @@ -1,6 +1,11 @@ -mod callback_actions; -mod callbacks; -mod commands; +//! This module provides the main bot handler for processing commands and +//! callback queries. +#[allow(missing_docs)] +pub mod callback_actions; +#[allow(missing_docs)] +pub mod callbacks; +#[allow(missing_docs)] +pub mod commands; #[cfg(test)] mod tests; @@ -26,26 +31,36 @@ type DialogueStorage = SqliteStorage; /// Context groups the data needed by all command and callback handlers. pub struct Context<'a> { + /// A reference to the main `BotHandler`. pub handler: &'a BotHandler, + /// The message that triggered the handler. pub message: &'a Message, + /// The dialogue for managing command state. pub dialogue: &'a Dialogue, + /// The callback query, if the handler was triggered by one. pub query: Option<&'a CallbackQuery>, } +/// Represents errors that can occur in the bot handler. #[derive(Error, Debug)] pub enum BotHandlerError { + /// Represents an error with invalid user input. #[error("Invalid input: {0}")] InvalidInput(String), + /// Represents an error with the dialogue storage. #[error("Failed to get or update dialogue: {0}")] DialogueError(#[from] SqliteStorageError), + /// Represents an error sending a message. #[error("Failed to send message: {0}")] SendMessageError(#[from] MessagingError), + /// Represents an internal error from the repository service. #[error("Internal error: {0}")] InternalError(RepositoryServiceError), + /// Represents an error when a user exceeds a limit. #[error("Limit exceeded: {0}")] LimitExceeded(String), } @@ -59,19 +74,26 @@ impl From for BotHandlerError { } } +/// A convenience type alias for `Result`. pub type BotHandlerResult = Result; +/// Represents the available bot commands. #[derive(BotCommands, Clone)] #[command(rename_rule = "lowercase", description = "Available commands:")] pub enum Command { + /// Start the bot and show a welcome message. #[command(description = "Start the bot and show welcome message.")] Start, + /// Show the help message. #[command(description = "Show this help text.")] Help, + /// Add a repository to track. #[command(description = "Add a repository by replying with the repository url.")] Add, + /// List the repositories being tracked. #[command(description = "List tracked repositories.")] List, + /// Show an overview of all tracked repositories and their labels. #[command(description = "Show an overview of tracked repositories.")] Overview, } @@ -86,12 +108,17 @@ pub struct BotHandler { /// The state of the command. #[derive(Clone, Default, Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum CommandState { + /// The default state, where no command is active. #[default] None, + /// The bot is waiting for the user to reply with repository URLs. AwaitingAddRepo, + /// The user is viewing the labels for a specific repository. ViewingRepoLabels { + /// The full name of the repository (e.g., "owner/repo"). repo_id: String, - from_page: usize, // The page from which the user navigated to labels + /// The page number of the repository list the user came from. + from_page: usize, }, } @@ -145,6 +172,7 @@ impl BotHandler { Ok(()) } + /// Handles an incoming callback query. pub async fn handle_callback_query( &self, query: &CallbackQuery, diff --git a/src/config.rs b/src/config.rs index 5901d60..c902a28 100644 --- a/src/config.rs +++ b/src/config.rs @@ -7,19 +7,29 @@ const DEFAULT_REPOS_PER_USER: usize = 20; const DEFAULT_LABELS_PER_REPO: usize = 10; const DEFAULT_MAX_CONCURRENCY: usize = 10; +/// Represents the application configuration. #[derive(Debug)] pub struct Config { + /// The GitHub API token. pub github_token: String, + /// The URL of the GitHub GraphQL API. pub github_graphql_url: String, + /// The Telegram bot token. pub telegram_bot_token: String, + /// The interval in seconds to poll for new issues. pub poll_interval: u64, + /// The URL of the database. pub database_url: String, + /// The maximum number of repositories a user can track. pub max_repos_per_user: usize, + /// The maximum number of labels a user can track per repository. pub max_labels_per_repo: usize, + /// The maximum number of concurrent requests to make to the GitHub API. pub max_concurrency: usize, } impl Config { + /// Creates a new `Config` instance from environment variables. pub fn from_env() -> Result { Ok(Self { github_token: env::var("GITHUB_TOKEN")?, diff --git a/src/github/mod.rs b/src/github/mod.rs index d4fee07..fa0f6b5 100644 --- a/src/github/mod.rs +++ b/src/github/mod.rs @@ -1,3 +1,4 @@ +#![allow(missing_docs)] #[cfg(test)] mod tests; @@ -13,24 +14,33 @@ use reqwest::{ }; use thiserror::Error; +/// Represents errors that can occur when interacting with the GitHub API. #[derive(Debug, Error)] pub enum GithubError { + /// A network or HTTP request error from the underlying `reqwest` client. #[error("Network or HTTP request error: {source}")] RequestError { + /// The source `reqwest` error. #[from] source: reqwest::Error, }, + /// An error representing an invalid HTTP header value. #[error("Invalid HTTP header value: {0}")] InvalidHeader(#[from] reqwest::header::InvalidHeaderValue), + /// An error from the GraphQL API. #[error("GraphQL API error: {0}")] GraphQLApiError(String), + /// An error during JSON serialization or deserialization. #[error("Failed to (de)serialize JSON: {source}")] SerializationError { + /// The source `serde_json` error. #[from] source: serde_json::Error, }, + /// An error indicating that the GitHub API rate limit has been exceeded. #[error("GitHub API rate limited")] RateLimited, + /// An error indicating that the request was not authorized. #[error("GitHub authentication failed")] Unauthorized, } @@ -46,6 +56,7 @@ fn is_retryable_graphql_error(error: &graphql_client::Error) -> bool { .unwrap_or(false) } +/// A trait for interacting with the GitHub API. #[automock] #[async_trait] pub trait GithubClient: Send + Sync { @@ -71,6 +82,7 @@ pub trait GithubClient: Send + Sync { // GraphQL DateTime scalar type. type DateTime = String; +/// GraphQL query for checking if a repository exists. #[derive(GraphQLQuery)] #[graphql( schema_path = "src/github/schema.graphql", @@ -80,6 +92,7 @@ type DateTime = String; )] pub struct Repository; +/// GraphQL query for fetching issues. #[derive(GraphQLQuery)] #[graphql( schema_path = "src/github/schema.graphql", @@ -89,6 +102,7 @@ pub struct Repository; )] pub struct Issues; +/// GraphQL query for fetching labels. #[derive(GraphQLQuery)] #[graphql( schema_path = "src/github/schema.graphql", @@ -98,6 +112,7 @@ pub struct Issues; )] pub struct Labels; +/// The default implementation of the `GithubClient` trait. #[derive(Clone)] pub struct DefaultGithubClient { client: Client, @@ -105,6 +120,7 @@ pub struct DefaultGithubClient { } impl DefaultGithubClient { + /// Creates a new `DefaultGithubClient`. pub fn new(github_token: &str, graphql_url: &str) -> Result { // Build the HTTP client with the GitHub token. let mut headers = HeaderMap::new(); diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..db0cd03 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,82 @@ +#![warn(missing_docs)] +//! A Telegram bot for tracking GitHub issues. +//! +//! This bot allows users to track repositories and receive notifications for +//! new issues. It provides a simple interface to +//! add, remove, and list tracked repositories. + +/// The main handler for the bot's logic. +pub mod bot_handler; +/// The configuration for the application. +pub mod config; +/// The dispatcher for routing updates to the correct handlers. +pub mod dispatcher; +/// The client for interacting with the GitHub API. +pub mod github; +/// The service for sending messages to the user. +pub mod messaging; +/// A utility for paginating data. +pub mod pagination; +/// The poller for fetching new issues from GitHub. +pub mod poller; +/// The service for managing repositories. +pub mod repository; +/// The storage layer for persisting data. +pub mod storage; + +use std::sync::Arc; + +use teloxide::{ + dispatching::dialogue::{SqliteStorage, serializer}, + prelude::*, +}; + +use crate::{ + bot_handler::BotHandler, config::Config, messaging::TelegramMessagingService, + poller::GithubPoller, repository::DefaultRepositoryService, + storage::sqlite::SqliteStorage as ApplicationStorage, +}; + +/// Runs the bot. +pub async fn run() -> Result<(), Box> { + let config = Config::from_env()?; + let storage = Arc::new(ApplicationStorage::new(&config.database_url).await?); + let bot = Bot::new(config.telegram_bot_token.clone()); + let github_client = Arc::new(github::DefaultGithubClient::new( + &config.github_token, + &config.github_graphql_url, + )?); + + let messaging_service = Arc::new(TelegramMessagingService::new(bot.clone())); + + // Spawn a polling task for issues. + let github_poller = GithubPoller::new( + github_client.clone(), + storage.clone(), + messaging_service.clone(), + config.poll_interval, + config.max_concurrency, + ); + + tokio::spawn(async move { + if let Err(e) = github_poller.run().await { + tracing::error!("Error in poller: {e}"); + } + }); + + let dialogue_storage = SqliteStorage::open(&config.database_url, serializer::Json).await?; + let repo_manager_service = Arc::new(DefaultRepositoryService::new( + storage.clone(), + github_client.clone(), + config.max_repos_per_user, + config.max_labels_per_repo, + )); + let handler = + Arc::new(BotHandler::new(messaging_service, repo_manager_service, config.max_concurrency)); + let mut dispatcher = dispatcher::BotDispatcher::new(handler, dialogue_storage).build(bot); + tracing::debug!("Dispatcher built successfully."); + + dispatcher.dispatch().await; + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 9c2dee9..099aaaf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,34 +1,6 @@ -#![warn(missing_docs)] -//! A Telegram bot for tracking beginner-friendly GitHub issues. -//! -//! This bot allows users to track repositories and receive notifications for -//! new issues labeled as "good first issue". It provides a simple interface to -//! add, remove, and list tracked repositories. - -mod bot_handler; -mod config; -mod dispatcher; -mod github; -mod messaging; -mod pagination; -mod poller; -mod repository; -mod storage; - -use std::sync::Arc; - -use teloxide::{ - dispatching::dialogue::{SqliteStorage, serializer}, - prelude::*, -}; +use good_first_bot::run; use tracing_subscriber::EnvFilter; -use crate::{ - bot_handler::BotHandler, config::Config, messaging::TelegramMessagingService, - poller::GithubPoller, repository::DefaultRepositoryService, - storage::sqlite::SqliteStorage as ApplicationStorage, -}; - #[tokio::main] async fn main() -> Result<(), Box> { dotenv::dotenv().ok(); @@ -48,46 +20,3 @@ async fn main() -> Result<(), Box> { Ok(()) } - -async fn run() -> Result<(), Box> { - let config = Config::from_env()?; - let storage = Arc::new(ApplicationStorage::new(&config.database_url).await?); - let bot = Bot::new(config.telegram_bot_token.clone()); - let github_client = Arc::new(github::DefaultGithubClient::new( - &config.github_token, - &config.github_graphql_url, - )?); - - let messaging_service = Arc::new(TelegramMessagingService::new(bot.clone())); - - // Spawn a polling task for issues. - let github_poller = GithubPoller::new( - github_client.clone(), - storage.clone(), - messaging_service.clone(), - config.poll_interval, - config.max_concurrency, - ); - - tokio::spawn(async move { - if let Err(e) = github_poller.run().await { - tracing::error!("Error in poller: {e}"); - } - }); - - let dialogue_storage = SqliteStorage::open(&config.database_url, serializer::Json).await?; - let repo_manager_service = Arc::new(DefaultRepositoryService::new( - storage.clone(), - github_client.clone(), - config.max_repos_per_user, - config.max_labels_per_repo, - )); - let handler = - Arc::new(BotHandler::new(messaging_service, repo_manager_service, config.max_concurrency)); - let mut dispatcher = dispatcher::BotDispatcher::new(handler, dialogue_storage).build(bot); - tracing::debug!("Dispatcher built successfully."); - - dispatcher.dispatch().await; - - Ok(()) -} diff --git a/src/messaging/mod.rs b/src/messaging/mod.rs index 23a172c..401185c 100644 --- a/src/messaging/mod.rs +++ b/src/messaging/mod.rs @@ -27,8 +27,10 @@ use crate::{ storage::RepoEntity, }; +/// Represents errors that can occur when sending messages. #[derive(Debug, Error)] pub enum MessagingError { + /// An error from the underlying `teloxide` library. #[error("Teloxide API request failed: {0}")] TeloxideRequest(#[from] teloxide::RequestError), } @@ -161,12 +163,13 @@ pub trait MessagingService: Send + Sync { ) -> Result<()>; } -/// Telegram messaging service. +/// The default implementation of the `MessagingService` trait. pub struct TelegramMessagingService { bot: Bot, } impl TelegramMessagingService { + /// Creates a new `TelegramMessagingService`. pub fn new(bot: Bot) -> Self { Self { bot } } diff --git a/src/pagination.rs b/src/pagination.rs index d6ed3c3..a37fc88 100644 --- a/src/pagination.rs +++ b/src/pagination.rs @@ -1,16 +1,22 @@ -/// Pagination structure to handle paginated data (labels, repositories, etc.) +/// A structure to handle paginated data. #[derive(Debug, Eq, PartialEq, Clone)] pub struct Paginated { + /// The items on the current page. pub items: Vec, - pub page: usize, // Current page number (1-indexed) + /// The current page number (1-indexed). + pub page: usize, + /// The number of items per page. pub page_size: usize, + /// The total number of items across all pages. pub total_items: usize, + /// The total number of pages. pub total_pages: usize, } const DEFAULT_PAGE_SIZE: usize = 10; impl Paginated { + /// Creates a new `Paginated` instance. pub fn new(items: Vec, page: usize) -> Self { let total_items = items.len(); @@ -33,14 +39,17 @@ impl Paginated { } } + /// Returns `true` if there is a next page. pub fn has_next(&self) -> bool { self.page < self.total_pages } + /// Returns `true` if there is a previous page. pub fn has_prev(&self) -> bool { self.page > 1 } + /// Returns a slice of the items on the current page. pub fn get_page_items(&self) -> &[T] { if self.items.is_empty() || self.page_size == 0 { return &[]; diff --git a/src/poller/mod.rs b/src/poller/mod.rs index 8609ec8..0fdea23 100644 --- a/src/poller/mod.rs +++ b/src/poller/mod.rs @@ -18,12 +18,16 @@ use crate::{ storage::{RepoEntity, RepoStorage, StorageError}, }; +/// Represents errors that can occur during the polling process. #[derive(Debug, Error)] pub enum PollerError { + /// An error from the GitHub client. #[error("Failed to poll GitHub issues")] Github(#[from] GithubError), + /// An error from the storage layer. #[error("Failed to access storage")] Storage(#[from] StorageError), + /// An error from the messaging service. #[error("Failed to send message to Telegram")] Messaging(#[from] MessagingError), } @@ -43,7 +47,7 @@ pub struct GithubPoller { } impl GithubPoller { - /// Create a new GithubPoller. + /// Create a new `GithubPoller`. pub fn new( github_client: Arc, storage: Arc, @@ -54,7 +58,7 @@ impl GithubPoller { Self { github_client, storage, messaging_service, poll_interval, max_concurrency } } - /// Run the Poller. + /// Run the poller. pub async fn run(&self) -> Result<()> { tracing::debug!("Starting GitHub poller"); diff --git a/src/repository/mod.rs b/src/repository/mod.rs index d50859f..2c989dc 100644 --- a/src/repository/mod.rs +++ b/src/repository/mod.rs @@ -14,14 +14,18 @@ use crate::{ storage::{RepoEntity, RepoStorage, StorageError}, }; +/// Represents errors that can occur in the repository service. #[derive(Debug, Error)] pub enum RepositoryServiceError { + /// An error from the GitHub client. #[error("Github client error")] GithubClientError(#[from] GithubError), + /// An error from the storage layer. #[error("Storage error: {0}")] StorageError(#[from] StorageError), + /// An error indicating that a user has exceeded a limit. #[error("Limit exceeded for user: {0}")] LimitExceeded(String), } @@ -32,12 +36,17 @@ type Result = std::result::Result; /// status. #[derive(Debug, Clone, PartialEq, Eq)] pub struct LabelNormalized { + /// The name of the label. pub name: String, + /// The color of the label. pub color: String, + /// The number of issues with this label. pub count: i64, + /// Whether the user is tracking this label. pub is_selected: bool, } +/// A trait for managing repositories. #[automock] #[async_trait] pub trait RepositoryService: Send + Sync { @@ -77,6 +86,7 @@ pub trait RepositoryService: Send + Sync { ) -> Result; } +/// The default implementation of the `RepositoryService` trait. pub struct DefaultRepositoryService { storage: Arc, github_client: Arc, @@ -85,6 +95,7 @@ pub struct DefaultRepositoryService { } impl DefaultRepositoryService { + /// Creates a new `DefaultRepositoryService`. pub fn new( storage: Arc, github_client: Arc, diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 4661dfb..5ff08e7 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -11,16 +11,21 @@ pub use repo_entity::RepoEntity; use teloxide::types::ChatId; use thiserror::Error; +/// Represents errors that can occur in the storage layer. #[derive(Debug, Error)] pub enum StorageError { + /// An error from the underlying database. #[error("Database error: {0}")] DbError(String), + /// An error indicating that data in the database is invalid. #[error("Data integrity error: Stored repository '{0}' is invalid: {1}")] DataIntegrityError(String, #[source] Box), } +/// A convenience type alias for `Result`. pub type StorageResult = Result; +/// A trait for storing and retrieving repository data. #[automock] #[async_trait] pub trait RepoStorage: Send + Sync { diff --git a/src/storage/repo_entity.rs b/src/storage/repo_entity.rs index f49c6b0..e77ffb6 100644 --- a/src/storage/repo_entity.rs +++ b/src/storage/repo_entity.rs @@ -3,22 +3,30 @@ use std::{fmt, str::FromStr}; use thiserror::Error; use url::Url; +/// Represents errors that can occur when parsing a `RepoEntity`. #[derive(Error, Debug, Clone)] pub enum RepoEntityError { + /// An error indicating that the URL is invalid. #[error("Invalid URL: {0}")] Url(String), + /// An error indicating that the repository format is invalid. #[error("Invalid repository format: {0}")] Format(String), + /// An error indicating that the owner or repository name is empty. #[error("Owner or repository name cannot be empty")] NameWithOwner, } type Result = std::result::Result; +/// Represents a GitHub repository. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct RepoEntity { + /// The owner of the repository. pub owner: String, + /// The name of the repository. pub name: String, + /// The name of the repository with the owner (e.g., "owner/repo"). pub name_with_owner: String, } diff --git a/src/storage/sqlite.rs b/src/storage/sqlite.rs index 44074c1..3fe3016 100644 --- a/src/storage/sqlite.rs +++ b/src/storage/sqlite.rs @@ -1,3 +1,6 @@ +//! This module provides an implementation of `RepoStorage` using SQLite as the +//! backing store. + use std::{ collections::{HashMap, HashSet}, str::FromStr, @@ -14,11 +17,13 @@ use crate::storage::{RepoEntity, RepoStorage, StorageError, StorageResult}; const INITIAL_DEFAULT_LABELS_JSON: &str = r#"["good first issue","beginner-friendly","help wanted"]"#; +/// An implementation of `RepoStorage` that uses SQLite as the backing store. pub struct SqliteStorage { pool: Pool, } impl SqliteStorage { + /// Creates a new `SqliteStorage` instance. pub async fn new(database_url: &str) -> StorageResult { tracing::debug!("Connecting to SQLite database: {database_url}"); let pool = SqlitePool::connect(database_url)