Skip to main content

rustigram_bot/
filter.rs

1use rustigram_types::update::UpdateKind;
2
3use crate::context::Context;
4
5/// A predicate evaluated against an incoming [`Context`].
6///
7/// Filters are `Send + Sync + 'static` and cheap to clone, making them safe
8/// to share across tasks. Combine filters with [`FilterExt::and`],
9/// [`FilterExt::or`], and [`FilterExt::not`].
10///
11/// Implement this trait to create custom filters:
12///
13/// ```rust,ignore
14/// use rustigram_bot::filter::Filter;
15/// use rustigram_bot::Context;
16///
17/// #[derive(Clone)]
18/// struct HasPhotoFilter;
19///
20/// impl Filter for HasPhotoFilter {
21///     fn check(&self, ctx: &Context) -> bool {
22///         ctx.message().and_then(|m| m.photo.as_ref()).is_some()
23///     }
24/// }
25/// ```
26pub trait Filter: Send + Sync + 'static {
27    /// Returns `true` if this filter matches the given context.
28    fn check(&self, ctx: &Context) -> bool;
29}
30
31/// Extension methods for composing [`Filter`] values.
32///
33/// Automatically implemented for every type that implements [`Filter`].
34pub trait FilterExt: Filter + Sized + Clone {
35    /// Passes only when both `self` and `other` match.
36    fn and<F: Filter + Clone>(self, other: F) -> And<Self, F> {
37        And {
38            left: self,
39            right: other,
40        }
41    }
42
43    /// Passes when `self` or `other` (or both) match.
44    fn or<F: Filter + Clone>(self, other: F) -> Or<Self, F> {
45        Or {
46            left: self,
47            right: other,
48        }
49    }
50
51    /// Inverts this filter.
52    fn not(self) -> Not<Self> {
53        Not { inner: self }
54    }
55}
56
57impl<F: Filter + Clone> FilterExt for F {}
58
59// ─── Combinators ─────────────────────────────────────────────────────────────
60
61/// Combines two filters with logical AND: passes only if both filters pass.
62#[derive(Clone)]
63pub struct And<L, R> {
64    left: L,
65    right: R,
66}
67impl<L: Filter, R: Filter> Filter for And<L, R> {
68    fn check(&self, ctx: &Context) -> bool {
69        self.left.check(ctx) && self.right.check(ctx)
70    }
71}
72
73/// Combines two filters with logical OR: passes if either filter (or both) pass.
74#[derive(Clone)]
75pub struct Or<L, R> {
76    left: L,
77    right: R,
78}
79impl<L: Filter, R: Filter> Filter for Or<L, R> {
80    fn check(&self, ctx: &Context) -> bool {
81        self.left.check(ctx) || self.right.check(ctx)
82    }
83}
84
85/// Inverts a filter: passes when the inner filter fails, and vice versa.
86#[derive(Clone)]
87pub struct Not<F> {
88    inner: F,
89}
90impl<F: Filter> Filter for Not<F> {
91    fn check(&self, ctx: &Context) -> bool {
92        !self.inner.check(ctx)
93    }
94}
95
96// ─── Function filter ──────────────────────────────────────────────────────────
97
98#[derive(Clone)]
99/// Wraps a plain function or closure as a [`Filter`].
100pub struct FnFilter<F>(pub F);
101
102impl<F: Fn(&Context) -> bool + Send + Sync + Clone + 'static> Filter for FnFilter<F> {
103    fn check(&self, ctx: &Context) -> bool {
104        (self.0)(ctx)
105    }
106}
107
108/// Creates a [`Filter`] from any closure with signature `fn(&Context) -> bool`.
109pub fn filter_fn<F>(f: F) -> FnFilter<F>
110where
111    F: Fn(&Context) -> bool + Send + Sync + Clone + 'static,
112{
113    FnFilter(f)
114}
115
116// ─── Built-in filters ─────────────────────────────────────────────────────────
117
118#[derive(Clone, Copy)]
119/// Passes only for [`Message`](rustigram_types::update::UpdateKind::Message) updates.
120pub struct MessageFilter;
121impl Filter for MessageFilter {
122    fn check(&self, ctx: &Context) -> bool {
123        matches!(ctx.update.kind, UpdateKind::Message(_))
124    }
125}
126
127/// Passes only for `EditedMessage` updates.
128#[derive(Clone, Copy)]
129/// Passes only for [`EditedMessage`](rustigram_types::update::UpdateKind::EditedMessage) updates.
130pub struct EditedMessageFilter;
131impl Filter for EditedMessageFilter {
132    fn check(&self, ctx: &Context) -> bool {
133        matches!(ctx.update.kind, UpdateKind::EditedMessage(_))
134    }
135}
136
137/// Passes only for `CallbackQuery` updates.
138#[derive(Clone, Copy)]
139/// Passes only for [`CallbackQuery`](rustigram_types::update::UpdateKind::CallbackQuery) updates.
140pub struct CallbackQueryFilter;
141impl Filter for CallbackQueryFilter {
142    fn check(&self, ctx: &Context) -> bool {
143        matches!(ctx.update.kind, UpdateKind::CallbackQuery(_))
144    }
145}
146
147/// Passes only for `InlineQuery` updates.
148#[derive(Clone, Copy)]
149/// Passes only for [`InlineQuery`](rustigram_types::update::UpdateKind::InlineQuery) updates.
150pub struct InlineQueryFilter;
151impl Filter for InlineQueryFilter {
152    fn check(&self, ctx: &Context) -> bool {
153        matches!(ctx.update.kind, UpdateKind::InlineQuery(_))
154    }
155}
156
157#[derive(Clone)]
158/// Passes when the message is a bot command matching `command`.
159///
160/// The check is case-insensitive and strips the leading `/` and any
161/// `@BotName` suffix automatically.
162pub struct CommandFilter {
163    command: String,
164}
165
166impl CommandFilter {
167    /// Creates a new filter matching the command `command`.
168    pub fn new(command: impl Into<String>) -> Self {
169        Self {
170            command: command.into(),
171        }
172    }
173}
174
175impl Filter for CommandFilter {
176    fn check(&self, ctx: &Context) -> bool {
177        ctx.command()
178            .is_some_and(|cmd| cmd.eq_ignore_ascii_case(&self.command))
179    }
180}
181
182#[derive(Clone)]
183/// Passes when the message text exactly equals `text`.
184pub struct TextFilter {
185    text: String,
186}
187
188impl TextFilter {
189    /// Creates a new filter matching the exact text `text`.
190    pub fn new(text: impl Into<String>) -> Self {
191        Self { text: text.into() }
192    }
193}
194
195impl Filter for TextFilter {
196    fn check(&self, ctx: &Context) -> bool {
197        ctx.text().is_some_and(|t| t == self.text)
198    }
199}
200
201#[derive(Clone)]
202/// Passes when the message text contains `needle` as a substring.
203pub struct TextContainsFilter {
204    needle: String,
205}
206
207impl TextContainsFilter {
208    /// Creates a new filter matching text containing `needle`.
209    pub fn new(needle: impl Into<String>) -> Self {
210        Self {
211            needle: needle.into(),
212        }
213    }
214}
215
216impl Filter for TextContainsFilter {
217    fn check(&self, ctx: &Context) -> bool {
218        ctx.text().is_some_and(|t| t.contains(self.needle.as_str()))
219    }
220}
221
222#[derive(Clone)]
223/// Passes when the callback query data exactly equals `data`.
224pub struct CallbackDataFilter {
225    data: String,
226}
227
228impl CallbackDataFilter {
229    /// Creates a new filter matching the callback data `data`.
230    pub fn new(data: impl Into<String>) -> Self {
231        Self { data: data.into() }
232    }
233}
234
235impl Filter for CallbackDataFilter {
236    fn check(&self, ctx: &Context) -> bool {
237        ctx.callback_query()
238            .and_then(|q| q.data.as_deref())
239            .is_some_and(|d| d == self.data)
240    }
241}
242
243#[derive(Clone)]
244/// Passes when the callback query data starts with `prefix`.
245pub struct CallbackDataPrefixFilter {
246    prefix: String,
247}
248
249impl CallbackDataPrefixFilter {
250    /// Creates a new filter matching callback data starting with `prefix`.
251    pub fn new(prefix: impl Into<String>) -> Self {
252        Self {
253            prefix: prefix.into(),
254        }
255    }
256}
257
258impl Filter for CallbackDataPrefixFilter {
259    fn check(&self, ctx: &Context) -> bool {
260        ctx.callback_query()
261            .and_then(|q| q.data.as_deref())
262            .is_some_and(|d| d.starts_with(self.prefix.as_str()))
263    }
264}
265
266#[derive(Clone, Copy)]
267/// Passes only for messages in private chats.
268pub struct PrivateChatFilter;
269impl Filter for PrivateChatFilter {
270    fn check(&self, ctx: &Context) -> bool {
271        ctx.message()
272            .is_some_and(|m| matches!(m.chat.kind, rustigram_types::chat::ChatType::Private))
273    }
274}
275
276#[derive(Clone, Copy)]
277/// Passes only for messages in group and supergroup chats.
278pub struct GroupFilter;
279impl Filter for GroupFilter {
280    fn check(&self, ctx: &Context) -> bool {
281        ctx.message().is_some_and(|m| {
282            matches!(
283                m.chat.kind,
284                rustigram_types::chat::ChatType::Group
285                    | rustigram_types::chat::ChatType::Supergroup
286            )
287        })
288    }
289}
290
291/// Passes when the message contains a `web_app_data` field.
292///
293/// Triggered when a user taps a Web App keyboard button that sends data
294/// directly to the bot (as opposed to launching a full TMA session).
295///
296/// Requires the `tma` feature on `rustigram-bot`.
297#[cfg(feature = "tma")]
298#[derive(Clone, Copy)]
299pub struct WebAppDataFilter;
300
301#[cfg(feature = "tma")]
302impl Filter for WebAppDataFilter {
303    fn check(&self, ctx: &Context) -> bool {
304        ctx.message()
305            .and_then(|m| m.web_app_data.as_ref())
306            .is_some()
307    }
308}
309
310/// Passes when the message contains `web_app_data` AND the given predicate
311/// returns `true` for `button_text`.
312///
313/// Useful when multiple Web App buttons have different labels and you want
314/// to route them to separate handlers.
315///
316/// Requires the `tma` feature on `rustigram-bot`.
317#[cfg(feature = "tma")]
318#[derive(Clone)]
319pub struct WebAppDataMatchingFilter<F> {
320    predicate: F,
321}
322
323#[cfg(feature = "tma")]
324impl<F> Filter for WebAppDataMatchingFilter<F>
325where
326    F: Fn(&str) -> bool + Send + Sync + Clone + 'static,
327{
328    fn check(&self, ctx: &Context) -> bool {
329        ctx.message()
330            .and_then(|m| m.web_app_data.as_ref())
331            .is_some_and(|d| (self.predicate)(d.button_text.as_str()))
332    }
333}
334
335/// Convenience constructors for all built-in filters.
336///
337/// Import this module and call functions to create filters:
338///
339/// ```rust,ignore
340/// use rustigram_bot::filter::filters;
341/// use rustigram_bot::filter::FilterExt;
342///
343/// let f = filters::command("start")
344///             .and(filters::private());
345/// ```
346pub mod filters {
347    use super::*;
348
349    /// Passes for any `Message` update.
350    pub fn message() -> MessageFilter {
351        MessageFilter
352    }
353    /// Passes for any `EditedMessage` update.
354    pub fn edited_message() -> EditedMessageFilter {
355        EditedMessageFilter
356    }
357    /// Passes for any `CallbackQuery` update.
358    pub fn callback_query() -> CallbackQueryFilter {
359        CallbackQueryFilter
360    }
361    /// Passes for any `InlineQuery` update.
362    pub fn inline_query() -> InlineQueryFilter {
363        InlineQueryFilter
364    }
365    /// Passes when the message is the given bot command (case-insensitive).
366    pub fn command(cmd: impl Into<String>) -> CommandFilter {
367        CommandFilter::new(cmd)
368    }
369    /// Passes when the message text exactly equals `t`.
370    pub fn text(t: impl Into<String>) -> TextFilter {
371        TextFilter::new(t)
372    }
373    /// Passes when the message text contains `needle` as a substring.
374    pub fn text_contains(needle: impl Into<String>) -> TextContainsFilter {
375        TextContainsFilter::new(needle)
376    }
377    /// Passes when the callback query data exactly equals `data`.
378    pub fn callback_data(data: impl Into<String>) -> CallbackDataFilter {
379        CallbackDataFilter::new(data)
380    }
381    /// Passes when the callback query data starts with `prefix`.
382    pub fn callback_data_prefix(prefix: impl Into<String>) -> CallbackDataPrefixFilter {
383        CallbackDataPrefixFilter::new(prefix)
384    }
385    /// Passes for messages in private chats.
386    pub fn private() -> PrivateChatFilter {
387        PrivateChatFilter
388    }
389    /// Passes for messages in group and supergroup chats.
390    pub fn group() -> GroupFilter {
391        GroupFilter
392    }
393    /// Always passes — useful as a catch-all fallback route.
394    pub fn any() -> FnFilter<fn(&Context) -> bool> {
395        FnFilter(|_| true)
396    }
397    /// Passes for any message that carries `web_app_data`.
398    ///
399    /// Requires the `tma` feature on `rustigram-bot`.
400    #[cfg(feature = "tma")]
401    pub fn web_app_data() -> WebAppDataFilter {
402        WebAppDataFilter
403    }
404    /// Passes for messages whose `web_app_data.button_text` satisfies `predicate`.
405    ///
406    /// # Example
407    ///
408    /// ```rust,ignore
409    /// filters::web_app_data_matching(|btn| btn == "Open Wallet")
410    /// ```
411    ///
412    /// Requires the `tma` feature on `rustigram-bot`.
413    #[cfg(feature = "tma")]
414    pub fn web_app_data_matching<F>(predicate: F) -> WebAppDataMatchingFilter<F>
415    where
416        F: Fn(&str) -> bool + Send + Sync + Clone + 'static,
417    {
418        WebAppDataMatchingFilter { predicate }
419    }
420}