Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[package]
name = "good-first-bot-rs"
name = "good-first-bot"
version = "0.1.0"
edition = "2024"

Expand Down
25 changes: 18 additions & 7 deletions src/bot_handler/callback_actions.rs
Original file line number Diff line number Diff line change
@@ -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,
}
2 changes: 2 additions & 0 deletions src/bot_handler/callbacks/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//! This module contains handlers for callback queries.

pub mod list;
pub mod remove;
pub mod toggle_label;
Expand Down
4 changes: 4 additions & 0 deletions src/bot_handler/commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//! This module contains handlers for bot commands.

pub mod add;
pub mod help;
pub mod list;
Expand All @@ -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<()>;
}

Expand Down
36 changes: 32 additions & 4 deletions src/bot_handler/mod.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -26,26 +31,36 @@ type DialogueStorage = SqliteStorage<Json>;

/// 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<CommandState, DialogueStorage>,
/// 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<serde_json::Error>),

/// 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),
}
Expand All @@ -59,19 +74,26 @@ impl From<RepositoryServiceError> for BotHandlerError {
}
}

/// A convenience type alias for `Result<T, BotHandlerError>`.
pub type BotHandlerResult<T> = Result<T, BotHandlerError>;

/// 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,
}
Expand All @@ -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,
},
}

Expand Down Expand Up @@ -145,6 +172,7 @@ impl BotHandler {
Ok(())
}

/// Handles an incoming callback query.
pub async fn handle_callback_query(
&self,
query: &CallbackQuery,
Expand Down
10 changes: 10 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self, VarError> {
Ok(Self {
github_token: env::var("GITHUB_TOKEN")?,
Expand Down
16 changes: 16 additions & 0 deletions src/github/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#![allow(missing_docs)]
#[cfg(test)]
mod tests;

Expand All @@ -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,
}
Expand All @@ -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 {
Expand All @@ -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",
Expand All @@ -80,6 +92,7 @@ type DateTime = String;
)]
pub struct Repository;

/// GraphQL query for fetching issues.
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "src/github/schema.graphql",
Expand All @@ -89,6 +102,7 @@ pub struct Repository;
)]
pub struct Issues;

/// GraphQL query for fetching labels.
#[derive(GraphQLQuery)]
#[graphql(
schema_path = "src/github/schema.graphql",
Expand All @@ -98,13 +112,15 @@ pub struct Issues;
)]
pub struct Labels;

/// The default implementation of the `GithubClient` trait.
#[derive(Clone)]
pub struct DefaultGithubClient {
client: Client,
graphql_url: String,
}

impl DefaultGithubClient {
/// Creates a new `DefaultGithubClient`.
pub fn new(github_token: &str, graphql_url: &str) -> Result<Self, GithubError> {
// Build the HTTP client with the GitHub token.
let mut headers = HeaderMap::new();
Expand Down
82 changes: 82 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<dyn std::error::Error>> {
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(())
}
Loading