diff --git a/Cargo.lock b/Cargo.lock index 14b6a4e..4711cdc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -205,6 +205,7 @@ dependencies = [ "frankenstein", "futures-util", "log", + "regex", "tokio", ] diff --git a/bot-utils/Cargo.toml b/bot-utils/Cargo.toml index ee44d96..1a53133 100644 --- a/bot-utils/Cargo.toml +++ b/bot-utils/Cargo.toml @@ -7,4 +7,5 @@ edition = "2024" frankenstein = { version = "0.40", features = ["trait-async"] } futures-util = { version = "0.3", default-features = false, features = ["alloc"] } log = "0.4" +regex = "1.11.1" tokio = { version = "1.44.2", features = ["sync", "time", "rt-multi-thread", "macros"] } diff --git a/bot-utils/src/command.rs b/bot-utils/src/command.rs new file mode 100644 index 0000000..6ce997c --- /dev/null +++ b/bot-utils/src/command.rs @@ -0,0 +1,164 @@ +use regex::{Regex, RegexBuilder, escape}; + +#[derive(Debug, Clone, Copy)] +pub struct ParsedCommand<'a> { + pub command: &'a str, + pub username: Option<&'a str>, + pub param: Option<&'a str>, +} + +#[derive(Debug, Clone)] +pub struct CommandParser(Regex); + +impl CommandParser { + pub fn new(username: Option<&str>) -> Self { + let pattern = if let Some(username) = username { + &format!("^/([a-z0-9_]+)(?:@({}))?(?:\\s+(.*))?$", escape(username)) + } else { + log::warn!("Bot has no username!"); + "^/([a-z0-9_]+)(?:@([a-z0-9_]+))?(?:\\s+(.*))?$" + }; + + let regex = RegexBuilder::new(pattern) + .case_insensitive(true) + .dot_matches_new_line(true) + .build() + .expect("regex should be valid!"); + + Self(regex) + } + + pub fn parse<'a>(&self, text: &'a str) -> Option> { + self.0.captures(text).map(|captures| { + let command = captures.get(1).expect("group matches always").as_str(); + let param; + let username; + if captures.len() == 4 { + username = captures.get(2).map(|m| m.as_str()); + param = captures.get(3).map(|m| m.as_str()); + } else { + username = None; + param = captures.get(2).map(|m| m.as_str()); + } + + ParsedCommand { + command, + param, + username, + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_command_parser() { + struct TestCase<'a> { + desc: &'a str, + bot_username: Option<&'a str>, + input: &'a str, + expected_command: Option<&'a str>, + expected_username: Option<&'a str>, + expected_param: Option<&'a str>, + } + + let cases = [ + // Bot username provided + TestCase { + desc: "Match with username and param", + bot_username: Some("my_bot"), + input: "/start@my_bot hello", + expected_command: Some("start"), + expected_username: Some("my_bot"), + expected_param: Some("hello"), + }, + TestCase { + desc: "Match with username and no param", + bot_username: Some("my_bot"), + input: "/start@my_bot", + expected_command: Some("start"), + expected_username: Some("my_bot"), + expected_param: None, + }, + TestCase { + desc: "Match with no username and param", + bot_username: Some("my_bot"), + input: "/start hello", + expected_command: Some("start"), + expected_username: None, + expected_param: Some("hello"), + }, + TestCase { + desc: "Mismatch username", + bot_username: Some("my_bot"), + input: "/start@wrong_bot hello", + expected_command: None, + expected_username: None, + expected_param: None, + }, + // Bot username not provided + TestCase { + desc: "Username missing, param given", + bot_username: None, + input: "/start hello", + expected_command: Some("start"), + expected_username: None, + expected_param: Some("hello"), + }, + TestCase { + desc: "Username present, param given", + bot_username: None, + input: "/start@other_bot hello", + expected_command: Some("start"), + expected_username: Some("other_bot"), + expected_param: Some("hello"), + }, + TestCase { + desc: "Username present, no param", + bot_username: None, + input: "/start@other_bot", + expected_command: Some("start"), + expected_username: Some("other_bot"), + expected_param: None, + }, + TestCase { + desc: "No username, no param", + bot_username: None, + input: "/start", + expected_command: Some("start"), + expected_username: None, + expected_param: None, + }, + ]; + + for case in &cases { + let parser = CommandParser::new(case.bot_username); + let result = parser.parse(case.input); + + match case.expected_command { + Some(cmd) => { + let Some(parsed) = result else { + panic!("{}: Expected a match", case.desc) + }; + assert_eq!(parsed.command, cmd, "{}: command mismatch", case.desc); + assert_eq!( + parsed.username, case.expected_username, + "{}: username mismatch", + case.desc + ); + assert_eq!( + parsed.param, case.expected_param, + "{}: param mismatch", + case.desc + ); + } + None => { + assert!(result.is_none(), "{}: Expected no match", case.desc); + } + } + } + } +} diff --git a/bot-utils/src/lib.rs b/bot-utils/src/lib.rs index 8b0e03a..4a9f587 100644 --- a/bot-utils/src/lib.rs +++ b/bot-utils/src/lib.rs @@ -1,3 +1,5 @@ pub mod broadcasting; +pub mod command; +pub mod updates; pub type ChatId = i64; diff --git a/src/bot/get_updates.rs b/bot-utils/src/updates.rs similarity index 76% rename from src/bot/get_updates.rs rename to bot-utils/src/updates.rs index 32957e7..c20e499 100644 --- a/src/bot/get_updates.rs +++ b/bot-utils/src/updates.rs @@ -1,10 +1,13 @@ use std::collections::HashMap; +use std::fmt::Display; use std::sync::{Arc, Weak}; use std::time::{Duration, Instant}; use frankenstein::AsyncTelegramApi; use frankenstein::methods::GetUpdatesParams; -use frankenstein::types::{AllowedUpdate, ChatMemberUpdated, Message}; +use frankenstein::types::{ + AllowedUpdate, CallbackQuery, ChatMemberUpdated, MaybeInaccessibleMessage, Message, +}; use frankenstein::updates::UpdateContent; use futures_util::FutureExt; use tokio::select; @@ -14,10 +17,19 @@ use tokio::time::sleep; const CLEANUP_PERIOD: Duration = Duration::from_secs(300); +#[allow(unused_variables)] pub trait UpdateHandler: Clone + Send + 'static { - fn handle_message(self, message: Message) -> impl Future + Send; + fn handle_message(self, message: Message) -> impl Future + Send { + async {} + } + + fn handle_my_chat_member(self, update: ChatMemberUpdated) -> impl Future + Send { + async {} + } - fn handle_my_chat_member(self, update: ChatMemberUpdated) -> impl Future + Send; + fn handle_callback_query(self, update: CallbackQuery) -> impl Future + Send { + async {} + } } fn cleanup(last_cleanup: &mut Instant, mutexes: &mut HashMap>>) { @@ -34,9 +46,10 @@ fn cleanup(last_cleanup: &mut Instant, mutexes: &mut HashMap /// Gets new incoming messages and calls `handler` on them, while ensuring that no messages /// from the same chat are processed in parallel. -pub async fn handle_updates( - bot: crate::Bot, +pub async fn handle_updates>( + bot: B, handler: impl UpdateHandler, + allowed_updates: Vec, mut shutdown: oneshot::Receiver<()>, ) { let mut mutexes: HashMap>> = HashMap::new(); @@ -47,7 +60,7 @@ pub async fn handle_updates( let mut params = GetUpdatesParams::builder() .timeout(30) - .allowed_updates(vec![AllowedUpdate::Message, AllowedUpdate::MyChatMember]) + .allowed_updates(allowed_updates) .build(); loop { @@ -65,8 +78,16 @@ pub async fn handle_updates( let chat = match &update.content { UpdateContent::Message(msg) => &*msg.chat, UpdateContent::MyChatMember(member) => &member.chat, + UpdateContent::CallbackQuery(query) => match &query.message { + Some(MaybeInaccessibleMessage::InaccessibleMessage(m)) => &m.chat, + Some(MaybeInaccessibleMessage::Message(m)) => &m.chat, + None => { + log::warn!("Unsupported!"); + continue; + } + }, _ => { - log::warn!("Received unexpected update: {:?}", update.content); + log::warn!("Received unsupported update: {:?}", update.content); continue; } }; @@ -99,6 +120,9 @@ pub async fn handle_updates( UpdateContent::MyChatMember(msg) => { handler.handle_my_chat_member(msg).await } + UpdateContent::CallbackQuery(q) => { + handler.handle_callback_query(q).await; + } _ => log::warn!("Unreachable code reached!"), } drop(guard) diff --git a/src/bot/command_help.rs b/src/bot/command_help.rs index a2ae87d..e97d069 100644 --- a/src/bot/command_help.rs +++ b/src/bot/command_help.rs @@ -90,6 +90,20 @@ fn regex_paragraph() -> impl WriteToMessage { ) } +fn disclaimer_paragraph() -> impl WriteToMessage { + concat!( + bold("⚖️ Hinweis"), + "\nDieser Bot ist ein rein privates, nicht-kommerzielles Projekt zur \ + automatischen Benachrichtigung über neue Vorlagen aus dem ALLRIS®-System der Stadt Bonn. \ + Er steht weder in Verbindung zur Firma CC e-gov GmbH noch zur Stadt Bonn. \n", + bold( + "Für Vollständigkeit, Richtigkeit oder Aktualität der bereitgestellten \ + Informationen wird keine Gewähr übernommen.", + ), + "\n", + ) +} + fn about_paragraph(owner: Option<&str>) -> impl WriteToMessage { from_fn(move |msg| { msg.writeln(bold("👨‍💻 Mehr Infos & Kontakt"))?; @@ -123,6 +137,7 @@ fn message(group: bool, owner: Option<&str>) -> (String, Vec) { msg.writeln(miscellaneous_paragraph())?; msg.writeln(regex_paragraph())?; + msg.writeln(disclaimer_paragraph())?; msg.write(about_paragraph(owner)) }) .to_message() diff --git a/src/bot/mod.rs b/src/bot/mod.rs index eed1e72..1859632 100644 --- a/src/bot/mod.rs +++ b/src/bot/mod.rs @@ -10,19 +10,21 @@ mod command_remove_rule; mod command_rules; mod command_start; mod command_target; -mod get_updates; mod keyboard; use std::fmt::Display; use std::sync::Arc; +use bot_utils::command::{CommandParser, ParsedCommand}; +use bot_utils::updates::UpdateHandler; use frankenstein::AsyncTelegramApi; use frankenstein::methods::{ GetChatAdministratorsParams, SetMyCommandsParams, SetMyDescriptionParams, SetMyShortDescriptionParams, }; -use frankenstein::types::{BotCommand, BotCommandScope, ChatMember, ChatMemberUpdated, Message}; -use regex::Regex; +use frankenstein::types::{ + AllowedUpdate, BotCommand, BotCommandScope, ChatMember, ChatMemberUpdated, Message, +}; use serde::{Deserialize, Serialize}; use telegram_message_builder::{Error as MessageBuilderError, WriteToMessage, concat, text_link}; use tokio::sync::oneshot; @@ -31,7 +33,6 @@ use self::command_new_rule::{PatternInput, TagSelection}; use self::command_remove_all_rules::ConfirmRemoveAllFilters; use self::command_remove_rule::RemoveFilterSelection; use self::command_target::ChannelSelection; -use self::get_updates::UpdateHandler; use self::keyboard::remove_keyboard; use crate::database::{self, SharedDatabaseConnection}; @@ -148,7 +149,7 @@ states! { struct MessageHandler { bot: crate::Bot, database: SharedDatabaseConnection, - command_regex: Regex, + command_parser: CommandParser, owner: Option, } @@ -267,25 +268,12 @@ impl MessageHandler { database: SharedDatabaseConnection, owner: Option, ) -> Result> { - let pattern = if let Some(username) = bot.get_me().await?.result.username { - &format!( - "^/([a-z0-9_]+)(?:@{})?(?:\\s+(.*))?$", - regex::escape(&username) - ) - } else { - log::warn!("Bot has no username!"); - "^/([a-z0-9_]+)(?:\\s+(.*))?$" - }; - - let command_regex = regex::RegexBuilder::new(pattern) - .case_insensitive(true) - .dot_matches_new_line(true) - .build()?; + let command_parser = CommandParser::new(bot.get_me().await?.result.username.as_deref()); let handler = Self { bot, database, - command_regex, + command_parser, owner, }; @@ -293,14 +281,6 @@ impl MessageHandler { Ok(handler) } - - fn parse_command<'a>(&self, text: &'a str) -> Option<(&'a str, Option<&'a str>)> { - self.command_regex.captures(text).map(|captures| { - let command = captures.get(1).unwrap().as_str(); - let params = captures.get(2).map(|m| m.as_str()); - (command, params) - }) - } } #[derive(Clone, Copy, Debug)] @@ -321,8 +301,10 @@ impl HandleMessage<'_> { return Err(Error::TopicsNotSupported); } - if let Some((cmd, param)) = self.inner.parse_command(text) { - return handle_command(self, cmd, param).await; + if let Some(ParsedCommand { command, param, .. }) = + self.inner.command_parser.parse(text) + { + return handle_command(self, command, param).await; } } @@ -465,11 +447,14 @@ impl HandleMessage<'_> { } } -impl UpdateHandler for Arc { +#[derive(Debug, Clone)] +pub struct ArcMessageHandler(Arc); + +impl UpdateHandler for ArcMessageHandler { async fn handle_message(self, message: Message) { HandleMessage { message: &message, - inner: &self, + inner: &self.0, } .handle() .await @@ -486,8 +471,8 @@ impl UpdateHandler for Arc { if !can_send_messages { let delete_chat = async { - self.database.remove_subscription(update.chat.id).await?; - self.database.remove_dialogue(update.chat.id).await?; + self.0.database.remove_subscription(update.chat.id).await?; + self.0.database.remove_dialogue(update.chat.id).await?; HandlerResult::Ok(()) }; @@ -510,5 +495,11 @@ pub async fn run( .await .unwrap(); - get_updates::handle_updates(bot, Arc::new(message_handler), shutdown).await + bot_utils::updates::handle_updates( + bot, + ArcMessageHandler(Arc::new(message_handler)), + vec![AllowedUpdate::Message, AllowedUpdate::MyChatMember], + shutdown, + ) + .await } diff --git a/telegram-message-builder/src/lib.rs b/telegram-message-builder/src/lib.rs index 61c1614..1ab2918 100644 --- a/telegram-message-builder/src/lib.rs +++ b/telegram-message-builder/src/lib.rs @@ -243,6 +243,16 @@ impl WriteToMessage for &dyn WriteToMessage { } } +impl WriteToMessage for Box { + fn write_to(&self, message: &mut MessageBuilder) -> Result<(), Error> { + (**self).write_to(message) + } + + fn to_message(&self) -> Result<(String, Vec), Error> { + (**self).to_message() + } +} + impl WriteToMessage for T { fn write_to(&self, message: &mut MessageBuilder) -> Result<(), Error> { write!(message, "{self}")