Index: Cargo.toml ================================================================== --- Cargo.toml +++ Cargo.toml @@ -2,11 +2,10 @@ name = "rsstg" version = "0.5.3" authors = [ "arcade@b1t.name" ] edition = "2024" license = "0BSD" -license-file = "LICENSE.0BSD" repository = "http://fs.b1t.name/rsstg" [dependencies] async-compat = "0.2.5" atom_syndication = { version = "0.12.4", features = [ "with-serde" ] } Index: src/command.rs ================================================================== --- src/command.rs +++ src/command.rs @@ -21,24 +21,41 @@ lazy_static! { static ref RE_USERNAME: Regex = Regex::new(r"^@([a-zA-Z][a-zA-Z0-9_]+)$").unwrap(); static ref RE_IV_HASH: Regex = Regex::new(r"^[a-f0-9]{14}$").unwrap(); } +/// Sends an informational message to the message's chat linking to the bot help channel. pub async fn start (core: &Core, msg: &Message) -> Result<()> { core.tg.send("We are open\\. Probably\\. Visit [channel](https://t.me/rsstg_bot_help/3) for details\\.", Some(msg.chat.get_id()), Some(MarkdownV2)).await.stack()?; Ok(()) } +/// Send the sender's subscription list to the chat. +/// +/// Retrieves the message sender's user ID, obtains their subscription list from `core`, +/// and sends the resulting reply into the message chat using MarkdownV2. pub async fn list (core: &Core, msg: &Message) -> Result<()> { let sender = msg.sender.get_user_id() .stack_err("Ignoring unreal users.")?; let reply = core.list(sender).await.stack()?; core.tg.send(reply, Some(msg.chat.get_id()), Some(MarkdownV2)).await.stack()?; Ok(()) } +/// Handle channel-management commands that operate on a single numeric source ID. +/// +/// This validates that exactly one numeric argument is provided, performs the requested +/// operation (check, clean, enable, delete, disable) against the database or core, +/// and sends the resulting reply to the chat. +/// +/// # Parameters +/// +/// - `core`: application core containing database and Telegram clients. +/// - `command`: command string (e.g. "/check", "/clean", "/enable", "/delete", "/disable"). +/// - `msg`: incoming Telegram message that triggered the command; used to determine sender and chat. +/// - `words`: command arguments; expected to contain exactly one element that parses as a 32-bit integer. pub async fn command (core: &Core, command: &str, msg: &Message, words: &[String]) -> Result<()> { let mut conn = core.db.begin().await.stack()?; let sender = msg.sender.get_user_id() .stack_err("Ignoring unreal users.")?; let reply = if words.len() == 1 { @@ -59,10 +76,21 @@ }; core.tg.send(reply, Some(msg.chat.get_id()), None).await.stack()?; Ok(()) } +/// Validate command arguments, check permissions and update or add a channel feed configuration in the database. +/// +/// This function parses and validates parameters supplied by a user command (either "/update ..." or "/add ..."), +/// verifies the channel username and feed URL, optionally validates an IV hash and a replacement regexp, +/// ensures both the bot and the command sender are administrators of the target channel, and performs the database update. +/// +/// # Parameters +/// +/// - `command` — the invoked command, expected to be either `"/update"` (followed by a numeric source id) or `"/add"`. +/// - `msg` — the incoming Telegram message; used to derive the command sender and target chat id for the reply. +/// - `words` — the command arguments: for `"/add"` expected `channel url [iv_hash|'-'] [url_re|'-']`; for `"/update"` the first element must be a numeric `source_id` followed by the same parameters. pub async fn update (core: &Core, command: &str, msg: &Message, words: &[String]) -> Result<()> { let sender = msg.sender.get_user_id() .stack_err("Ignoring unreal users.")?; let mut source_id: Option = None; let at_least = "Requires at least 3 parameters."; @@ -153,8 +181,9 @@ } }; if ! me { bail!("I need to be admin on that channel."); }; if ! user { bail!("You should be admin on that channel."); }; let mut conn = core.db.begin().await.stack()?; - core.tg.send(conn.update(source_id, channel, channel_id, url, iv_hash, url_re, sender).await.stack()?, Some(msg.chat.get_id()), None).await.stack()?; + let update = conn.update(source_id, channel, channel_id, url, iv_hash, url_re, sender).await.stack()?; + core.tg.send(update, Some(msg.chat.get_id()), None).await.stack()?; Ok(()) } Index: src/core.rs ================================================================== --- src/core.rs +++ src/core.rs @@ -123,11 +123,11 @@ impl Core { /// Create a Core instance from configuration and start its background autofetch loop. /// /// The provided `settings` must include: - /// - `owner` (integer): chat id to use as the default destination, + /// - `owner` (integer): default chat id to use as the owner/destination, /// - `api_key` (string): Telegram bot API key, /// - `api_gateway` (string): Telegram API gateway host, /// - `pg` (string): PostgreSQL connection string, /// - optional `proxy` (string): proxy URL for the HTTP client. /// @@ -296,10 +296,15 @@ }; posts.clear(); Ok(format!("Posted: {posted}")) } + /// Determine the delay until the next scheduled fetch and spawn background checks for any overdue sources. + /// + /// This scans the database queue, spawns background tasks to run checks for sources whose `next_fetch` + /// is in the past (each task uses a Core clone with the appropriate owner), and computes the shortest + /// duration until the next `next_fetch`. async fn autofetch(&self) -> Result { let mut delay = chrono::Duration::minutes(1); let now = chrono::Local::now(); let queue = { let mut conn = self.db.begin().await.stack()?; @@ -347,10 +352,16 @@ Ok(reply.join("\n\n")) } } impl UpdateHandler for Core { + /// Dispatches an incoming Telegram update to a matching command handler and reports handler errors to the originating chat. + /// + /// This method inspects the update; if it contains a message that can be parsed as a bot command, + /// it executes the corresponding command handler. If the handler returns an error, the error text + /// is sent back to the message's chat using MarkdownV2 formatting. Unknown commands produce an erro + /// which is also reported to the chat. async fn handle (&self, update: Update) { if let UpdateType::Message(msg) = update.update_type && let Ok(cmd) = Command::try_from(msg) { let msg = cmd.get_message(); @@ -369,8 +380,8 @@ Some(ParseMode::MarkdownV2) ).await { dbg!(err2); } - }; + } // TODO: debug log for skipped updates?; } } Index: src/main.rs ================================================================== --- src/main.rs +++ src/main.rs @@ -21,10 +21,14 @@ })); Ok(()) } +/// Initialises configuration and the bot core, then runs the Telegram long-poll loop. +/// +/// This function loads configuration (with a default API gateway), constructs the application +/// core, and starts the long-polling loop that handles incoming Telegram updates. async fn async_main () -> Result<()> { let settings = config::Config::builder() .set_default("api_gateway", "https://api.telegram.org").stack()? .add_source(config::File::with_name("rsstg")) .build() Index: src/sql.rs ================================================================== --- src/sql.rs +++ src/sql.rs @@ -148,21 +148,26 @@ 0 => { Ok("Source not found.") }, _ => { bail!("Database error.") }, } } + /// Checks whether a post with the given URL exists for the specified source. + /// + /// # Parameters + /// - `post_url`: The URL of the post to check. + /// - `id`: The source identifier (converted to `i64`). + /// + /// # Returns + /// `true` if a post with the URL exists for the source, `false` otherwise. pub async fn exists (&mut self, post_url: &str, id: I) -> Result where I: Into { let row = sqlx::query("select exists(select true from rsstg_post where url = $1 and source_id = $2) as exists;") .bind(post_url) .bind(id.into()) .fetch_one(&mut *self.0).await.stack()?; - if let Some(exists) = row.try_get("exists").stack()? { - Ok(exists) - } else { - bail!("Database error: can't check whether post exists."); - } + row.try_get("exists") + .stack_err("Database error: can't check whether post exists.") } /// Get all pending events for (now + 1 minute) pub async fn get_queue (&mut self) -> Result> { let block: Vec = sqlx::query_as("select source_id, next_fetch, owner, last_scrape from rsstg_order natural left join rsstg_source where next_fetch < now() + interval '1 minute';") Index: src/tg_bot.rs ================================================================== --- src/tg_bot.rs +++ src/tg_bot.rs @@ -20,10 +20,19 @@ pub owner: ChatPeerId, pub client: Client, } impl Tg { + /// Construct a new `Tg` instance from configuration. + /// + /// The `settings` must provide the following keys: + /// - `"api_key"` (string), + /// - `"owner"` (integer chat id), + /// - `"api_gateway"` (string). + /// + /// The function initialises the client, configures the gateway and fetches the bot identity + /// before returning the constructed `Tg`. pub async fn new (settings: &config::Config) -> Result { let api_key = settings.get_string("api_key").stack()?; let owner = ChatPeerId::from(settings.get_int("owner").stack()?); let client = Client::new(&api_key).stack()? @@ -35,10 +44,14 @@ owner, client, }) } + /// Send a text message to a chat, using an optional target and parse mode. + /// + /// # Returns + /// The sent `Message` on success. pub async fn send (&self, msg: S, target: Option, mode: Option) -> Result where S: Into { let msg = msg.into(); let mode = mode.unwrap_or(ParseMode::Html); @@ -47,12 +60,20 @@ SendMessage::new(target, msg) .with_parse_mode(mode) ).await.stack() } - pub fn with_owner (&self, owner: i64) -> Tg { + /// Create a copy of this `Tg` with the owner replaced by the given chat ID. + /// + /// # Parameters + /// - `owner`: The Telegram chat identifier to set as the new owner (expressed as an `i64`). + /// + /// # Returns + /// A new `Tg` instance identical to the original except its `owner` field is set to the provided chat ID. + pub fn with_owner (&self, owner: O) -> Tg + where O: Into { Tg { - owner: ChatPeerId::from(owner), + owner: ChatPeerId::from(owner.into()), ..self.clone() } } }