Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions bot-utils/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
164 changes: 164 additions & 0 deletions bot-utils/src/command.rs
Original file line number Diff line number Diff line change
@@ -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<ParsedCommand<'a>> {
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);
}
}
}
}
}
2 changes: 2 additions & 0 deletions bot-utils/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
pub mod broadcasting;
pub mod command;
pub mod updates;

pub type ChatId = i64;
38 changes: 31 additions & 7 deletions src/bot/get_updates.rs → bot-utils/src/updates.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<Output = ()> + Send;
fn handle_message(self, message: Message) -> impl Future<Output = ()> + Send {
async {}
}

fn handle_my_chat_member(self, update: ChatMemberUpdated) -> impl Future<Output = ()> + Send {
async {}
}

fn handle_my_chat_member(self, update: ChatMemberUpdated) -> impl Future<Output = ()> + Send;
fn handle_callback_query(self, update: CallbackQuery) -> impl Future<Output = ()> + Send {
async {}
}
}

fn cleanup(last_cleanup: &mut Instant, mutexes: &mut HashMap<i64, Weak<Mutex<()>>>) {
Expand All @@ -34,9 +46,10 @@ fn cleanup(last_cleanup: &mut Instant, mutexes: &mut HashMap<i64, Weak<Mutex<()>

/// 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<B: AsyncTelegramApi<Error: Display>>(
bot: B,
handler: impl UpdateHandler,
allowed_updates: Vec<AllowedUpdate>,
mut shutdown: oneshot::Receiver<()>,
) {
let mut mutexes: HashMap<i64, Weak<Mutex<()>>> = HashMap::new();
Expand All @@ -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 {
Expand All @@ -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;
}
};
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions src/bot/command_help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))?;
Expand Down Expand Up @@ -123,6 +137,7 @@ fn message(group: bool, owner: Option<&str>) -> (String, Vec<MessageEntity>) {

msg.writeln(miscellaneous_paragraph())?;
msg.writeln(regex_paragraph())?;
msg.writeln(disclaimer_paragraph())?;
msg.write(about_paragraph(owner))
})
.to_message()
Expand Down
Loading
Loading