Skip to content

Commit afff3d5

Browse files
committed
feat: add filter::command with parameter extraction
- add command() filter supporting :param, :param?, *param, *param? patterns - add router module with CommandParams for extracted parameters - convert ferogram-macros to proc-macro crate (syn, quote) - export Resource and CommandParams in prelude
1 parent e26bf96 commit afff3d5

5 files changed

Lines changed: 161 additions & 35 deletions

File tree

ferogram-macros/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,9 @@ repository.workspace = true
1010
keywords.workspace = true
1111
edition.workspace = true
1212

13+
[lib]
14+
proc-macro = true
15+
1316
[dependencies]
17+
syn = "2.0"
18+
quote = "1.0"

ferogram-macros/src/lib.rs

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1 @@
1-
pub fn add(left: u64, right: u64) -> u64 {
2-
left + right
3-
}
41

5-
#[cfg(test)]
6-
mod tests {
7-
use super::*;
8-
9-
#[test]
10-
fn it_works() {
11-
let result = add(2, 2);
12-
assert_eq!(result, 4);
13-
}
14-
}

ferogram/examples/dispatcher.rs

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
1212
use std::error::Error;
1313

14-
use ferogram::{Dispatcher, filter, handler, prelude::ConnectionExt};
15-
use grammers::{Client, client::UpdatesConfiguration, update::Message};
14+
use ferogram::prelude::*;
15+
use grammers::{Client, client::UpdatesConfiguration, message::InputMessage, update::Message};
1616

1717
#[tokio::main(flavor = "multi_thread")]
1818
async fn main() -> Result<(), Box<dyn Error>> {
@@ -23,13 +23,24 @@ async fn main() -> Result<(), Box<dyn Error>> {
2323

2424
// Build and run the dispatcher.
2525
Dispatcher::builder()
26-
.add_handler(
27-
handler::new_message(filter::text("hi")).then(|message: Message| async move {
28-
message.reply(message.text()).await?;
26+
.add_handler(handler::new_message(filter::command("/start :id")).then(
27+
|message: Message, params: CommandParams| async move {
28+
let Ok(id) = params.get_parsed::<i64>("id") else {
29+
let id = params.get("id").unwrap();
30+
31+
message
32+
.reply(InputMessage::new().text(format!("Invalid id: {id}")))
33+
.await?;
34+
return Ok(());
35+
};
36+
37+
message
38+
.reply(InputMessage::new().text(id.to_string()))
39+
.await?;
2940

3041
Ok(())
31-
}),
32-
)
42+
},
43+
))
3344
.build()
3445
.run(
3546
pool,

ferogram/src/filter/default.rs

Lines changed: 131 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@
55

66
//! Default and useful filters.
77
8+
use std::collections::HashMap;
9+
810
use grammers::{Client, media::Media, peer::Peer, tl, update::Update};
911

1012
use super::{AsyncMarker, FilterExt, Flow, IntoFilter, SyncMarker};
13+
use crate::router::CommandParams;
1114

1215
/// Always passing filter, it doesn't check for anything.
1316
pub fn always(_: Client, _: Update) -> bool {
@@ -41,45 +44,162 @@ pub fn not<Marker>(filter: impl IntoFilter<Marker>) -> impl IntoFilter<AsyncMark
4144
}
4245

4346
/// Pass if the message text contains the specified pattern.
44-
pub fn text(pat: &'static str) -> impl IntoFilter<SyncMarker> {
47+
pub fn text(pattern: &'static str) -> impl IntoFilter<SyncMarker> {
4548
move |_, update| match update {
4649
Update::NewMessage(message) | Update::MessageEdited(message) => {
47-
message.text().contains(pat)
50+
message.text().contains(pattern)
4851
}
4952
_ => false,
5053
}
5154
}
5255

5356
/// Pass if the message text or query data matches the specified pattern.
54-
pub fn regex(pat: &'static str) -> impl IntoFilter<SyncMarker> {
57+
pub fn regex(pattern: &'static str) -> impl IntoFilter<SyncMarker> {
5558
move |_, update| match update {
5659
Update::NewMessage(message) | Update::MessageEdited(message) => {
57-
let re = regex::Regex::new(pat).unwrap();
58-
60+
let re = regex::Regex::new(pattern).unwrap();
5961
re.is_match(message.text())
6062
}
6163
Update::CallbackQuery(query) => {
62-
let re = regex::bytes::Regex::new(pat).unwrap();
63-
64+
let re = regex::bytes::Regex::new(pattern).unwrap();
6465
re.is_match(query.data())
6566
}
6667
Update::InlineQuery(query) => {
67-
let re = regex::Regex::new(pat).unwrap();
68-
68+
let re = regex::Regex::new(pattern).unwrap();
6969
re.is_match(query.text())
7070
}
7171
_ => false,
7272
}
7373
}
7474

75+
/// Pass if the message text matches the specified command pattern.
76+
///
77+
/// It supports parameters, which are:
78+
/// - `:param`: a one-word required parameter.
79+
/// - `:param?`: a one-word optional parameter.
80+
/// - `*param`: a multiple-word required parameter.
81+
/// - `*param?`: a multiple-word optional parameter.
82+
///
83+
/// Note: optional parameters (those ending in `?`) can only be added at the
84+
/// end of patterns, which also means that required parameters cannot follow
85+
/// optional parameters, otherwise it'll panic.
86+
///
87+
/// # Injects:
88+
/// * [`CommandParams`]: extracted params.
89+
///
90+
/// # Examples
91+
///
92+
/// ```
93+
/// use ferogram::prelude::*;
94+
/// handler::new_message(filter::command("/profile :id?"))
95+
/// ```
96+
pub fn command(pattern: &'static str) -> impl IntoFilter<SyncMarker> {
97+
let parts = pattern.split_whitespace().collect::<Vec<_>>();
98+
if parts.is_empty() {
99+
panic!("Invalid pattern '{pattern}': it needs to have at least one word");
100+
}
101+
102+
let mut pat = format!("^{}", regex::escape(parts[0]));
103+
104+
let mut seen_multiple = false;
105+
let mut seen_optional = false;
106+
107+
for part in &parts[1..] {
108+
let is_optional = part.ends_with('?');
109+
110+
if seen_multiple {
111+
panic!(
112+
"Invalid pattern '{pattern}': parameter '{part}' cannot follow a multiple parameter"
113+
);
114+
}
115+
116+
if let Some(stripped) = part.strip_prefix(':') {
117+
if is_optional {
118+
seen_optional = true;
119+
120+
let name = &stripped[..stripped.len() - 1];
121+
pat.push_str(&format!(r"(?:\s+(?P<{name}>\S+))?"));
122+
} else {
123+
if seen_optional {
124+
panic!(
125+
"Invalid pattern '{pattern}': mandatory parameter '{part}' cannot follow an optional parameter"
126+
);
127+
}
128+
129+
let name = stripped;
130+
pat.push_str(&format!(r"\s+(?P<{name}>\S+)"));
131+
}
132+
} else if let Some(stripped) = part.strip_prefix('*') {
133+
seen_multiple = true;
134+
135+
if is_optional {
136+
seen_optional = true;
137+
138+
let name = &stripped[..stripped.len() - 1];
139+
pat.push_str(&format!(r"(?:\s+(?P<{name}>.*))?"));
140+
} else {
141+
if seen_optional {
142+
panic!(
143+
"Invalid pattern '{pattern}': mandatory parameter '{part}' cannot follow an optional parameter"
144+
);
145+
}
146+
147+
let name = stripped;
148+
pat.push_str(&format!(r"\s+(?P<{name}>.*)"));
149+
}
150+
} else {
151+
if seen_optional {
152+
panic!(
153+
"Invalid pattern '{pattern}': literal word '{part}' cannot follow an optional parameter"
154+
);
155+
}
156+
157+
pat.push_str(&format!(r"\s+{}", regex::escape(part)));
158+
}
159+
}
160+
161+
let re = regex::Regex::new(&pat).unwrap();
162+
163+
fn extract_params(text: &str, re: &regex::Regex) -> Flow {
164+
if let Some(captures) = re.captures(text) {
165+
let mut params = HashMap::new();
166+
for name in re.capture_names().flatten() {
167+
if let Some(m) = captures.name(name) {
168+
params.insert(name.to_string(), m.as_str().to_string());
169+
}
170+
}
171+
172+
super::proceed_with(CommandParams(params))
173+
} else if re.is_match(text) {
174+
super::proceed()
175+
} else {
176+
super::stop()
177+
}
178+
}
179+
180+
move |_, update| match update {
181+
Update::NewMessage(message) => {
182+
let text = message.text();
183+
184+
extract_params(text, &re)
185+
}
186+
Update::CallbackQuery(query) => {
187+
let data = String::from_utf8_lossy(query.data()).to_string();
188+
189+
extract_params(&data, &re)
190+
}
191+
_ => super::stop(),
192+
}
193+
}
194+
75195
/// Pass if the message has a URL.
76196
///
77197
/// It extracts URLs directly from message's format entities, [`tl::enums::MessageEntity::Url`].
78198
///
79199
/// Note: if the `url` feature is enabled, it will also extracts URLs that weren't converted to format
80200
/// entities, which can happen when using a broken client.
81201
///
82-
/// Injects:
202+
/// # Injects:
83203
/// * `Vec<String>`: extracted urls.
84204
pub fn has_url(_: Client, update: Update) -> Flow {
85205
match update {
@@ -128,7 +248,7 @@ pub fn has_url(_: Client, update: Update) -> Flow {
128248

129249
/// Pass if the message has a dice attached to it.
130250
///
131-
/// Injects:
251+
/// # Injects:
132252
/// * [`grammers::message::Dice`][]: dice.
133253
pub fn has_dice(_: Client, update: Update) -> Flow {
134254
match update {

ferogram/src/lib.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,18 @@
77
88
pub mod client;
99
mod context;
10-
mod di;
10+
pub mod di;
1111
mod dispatcher;
1212
pub mod error;
1313
pub mod filter;
1414
pub mod handler;
15+
pub mod router;
1516
mod utils;
1617

1718
use std::error::Error;
1819

1920
pub use context::Context;
20-
pub use di::{Injector, Resource};
21+
use di::Injector;
2122
pub use dispatcher::Dispatcher;
2223
use dispatcher::{DISPATCHER_STOPPED, STOP_DISPATCHER};
2324
pub use handler::Handler;
@@ -27,8 +28,10 @@ pub mod prelude {
2728
pub use grammers_session as session;
2829

2930
pub use super::{
30-
client::*,
31-
filter::{AsyncMarker, Filter, IntoFilter, SyncMarker},
31+
client::ConnectionExt,
32+
di::Resource,
33+
filter::{self, AsyncMarker, Filter, FilterExt, IntoFilter, SyncMarker},
34+
router::CommandParams,
3235
*,
3336
};
3437
}

0 commit comments

Comments
 (0)