Skip to main content

botkit_telegram/
bot.rs

1use std::future::Future;
2use std::sync::Arc;
3use std::time::Duration;
4
5use botkit_core::{
6    Bot, BotBuilder, BotError, Context, ContextData, Event, IntoHandler, Response, Shutdown,
7};
8use executor_core::spawn;
9use http_kit::{Body, Endpoint, HttpError, Request, Response as HttpResponse, StatusCode};
10use tracing::{error, warn};
11
12use crate::client::TelegramClient;
13use crate::event::TelegramContextData;
14use crate::types::{
15    BotCommand, InlineKeyboardButton, InlineKeyboardMarkup, ReplyMarkup, Update, UpdateKind,
16};
17
18/// How long Telegram holds a long-poll open before returning empty.
19const POLL_TIMEOUT_SECS: u32 = 30;
20/// First wait after a failed `getUpdates`; doubles up to the max.
21const INITIAL_POLL_BACKOFF: Duration = Duration::from_secs(1);
22/// Longest wait between failed `getUpdates` attempts.
23const MAX_POLL_BACKOFF: Duration = Duration::from_secs(60);
24
25/// Error type for the Telegram webhook endpoint
26#[derive(Debug)]
27pub struct WebhookError(BotError);
28
29impl WebhookError {
30    /// The underlying bot error
31    pub fn into_inner(self) -> BotError {
32        self.0
33    }
34}
35
36impl std::fmt::Display for WebhookError {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        write!(f, "{}", self.0)
39    }
40}
41
42impl std::error::Error for WebhookError {}
43
44impl HttpError for WebhookError {
45    fn status(&self) -> StatusCode {
46        StatusCode::INTERNAL_SERVER_ERROR
47    }
48}
49
50/// Telegram bot builder
51///
52/// Create a bot with command and button handlers, then either call `build()`
53/// for a webhook endpoint or `run()` for long polling.
54///
55/// # Example
56/// ```ignore
57/// use botkit_core::User;
58/// use botkit_telegram::{TelegramBot, TelegramWebhook};
59///
60/// // Simple handler - no Context needed!
61/// async fn ping() -> &'static str {
62///     "Pong!"
63/// }
64///
65/// // With extractors
66/// async fn greet(user: User) -> String {
67///     format!("Hello, {}!", user.name)
68/// }
69///
70/// // Return TelegramWebhook directly - it implements Endpoint
71/// #[skyzen::main]
72/// fn main() -> TelegramWebhook {
73///     TelegramBot::new(token)
74///         .command("ping", ping)
75///         .command("greet", greet)
76///         .build()
77/// }
78/// ```
79pub struct TelegramBot {
80    token: String,
81    builder: BotBuilder,
82    register_commands: bool,
83}
84
85impl TelegramBot {
86    /// Create a new Telegram bot
87    pub fn new(token: impl Into<String>) -> Self {
88        Self {
89            token: token.into(),
90            builder: BotBuilder::new(),
91            register_commands: true,
92        }
93    }
94
95    /// Register a command handler (e.g., /start, /help)
96    pub fn command<H, Args>(mut self, name: impl Into<String>, handler: H) -> Self
97    where
98        H: IntoHandler<Args>,
99    {
100        self.builder = self.builder.command(name, handler);
101        self
102    }
103
104    /// Register a command handler with description
105    ///
106    /// The description appears in Telegram's command menu (slash command suggestions).
107    pub fn command_with_description<H, Args>(
108        mut self,
109        name: impl Into<String>,
110        description: impl Into<String>,
111        handler: H,
112    ) -> Self
113    where
114        H: IntoHandler<Args>,
115    {
116        self.builder = self
117            .builder
118            .command_with_description(name, description, handler);
119        self
120    }
121
122    /// Register a button handler (callback query data pattern)
123    ///
124    /// Pattern can end with `*` for prefix matching (e.g., "confirm_*")
125    pub fn button<H, Args>(mut self, pattern: impl Into<String>, handler: H) -> Self
126    where
127        H: IntoHandler<Args>,
128    {
129        self.builder = self.builder.button(pattern, handler);
130        self
131    }
132
133    /// Register a message handler
134    pub fn message<H, Args>(mut self, handler: H) -> Self
135    where
136        H: IntoHandler<Args>,
137    {
138        self.builder = self.builder.message(handler);
139        self
140    }
141
142    /// Register a handler for events nothing else claimed
143    ///
144    /// Unregistered commands and unmatched callbacks reach the fallback
145    /// rather than being dropped. See [`BotBuilder::fallback`].
146    pub fn fallback<H, Args>(mut self, handler: H) -> Self
147    where
148        H: IntoHandler<Args>,
149    {
150        self.builder = self.builder.fallback(handler);
151        self
152    }
153
154    /// Stop publishing the registered commands to Telegram on startup
155    ///
156    /// On by default for [`TelegramBot::run`]; webhook mode never registers
157    /// commands, since `build()` does no I/O.
158    pub fn skip_command_registration(mut self) -> Self {
159        self.register_commands = false;
160        self
161    }
162
163    /// Build the webhook handler (for use with skyzen's Endpoint)
164    ///
165    /// Registering the command menu is a network call, so webhook deployments
166    /// should call [`TelegramClient::set_my_commands`] themselves at startup;
167    /// [`TelegramBot::commands`] renders the list to pass it.
168    pub fn build(self) -> TelegramWebhook {
169        TelegramWebhook {
170            dispatcher: Arc::new(Dispatcher {
171                client: TelegramClient::new(&self.token),
172                builder: self.builder,
173            }),
174        }
175    }
176
177    /// The command menu entries implied by the registered handlers
178    pub fn commands(&self) -> Vec<BotCommand> {
179        self.builder
180            .commands()
181            .map(|command| {
182                // Telegram rejects an empty description, so fall back to the name.
183                let description = if command.description.is_empty() {
184                    command.name
185                } else {
186                    command.description
187                };
188                BotCommand::new(command.name, description)
189            })
190            .collect()
191    }
192}
193
194impl Bot for TelegramBot {
195    async fn run_until(self, shutdown: Shutdown) -> Result<(), BotError> {
196        let client = TelegramClient::new(&self.token);
197
198        if self.register_commands {
199            let commands = self.commands();
200            if !commands.is_empty()
201                && let Err(e) = client.set_my_commands(&commands).await
202            {
203                warn!("Failed to register commands: {e}");
204            }
205        }
206
207        // Long polling and webhooks are mutually exclusive on Telegram's side.
208        client.delete_webhook().await?;
209
210        let dispatcher = Arc::new(Dispatcher {
211            client,
212            builder: self.builder,
213        });
214
215        poll_updates(dispatcher, shutdown).await
216    }
217}
218
219async fn poll_updates(dispatcher: Arc<Dispatcher>, shutdown: Shutdown) -> Result<(), BotError> {
220    let mut offset: Option<i64> = None;
221    let mut backoff = INITIAL_POLL_BACKOFF;
222
223    loop {
224        if shutdown.is_shutdown() {
225            return Ok(());
226        }
227
228        let poll = dispatcher
229            .client
230            .get_updates(offset, Some(POLL_TIMEOUT_SECS));
231
232        let updates = match race_shutdown(poll, &shutdown).await {
233            None => return Ok(()),
234            Some(Ok(updates)) => {
235                backoff = INITIAL_POLL_BACKOFF;
236                updates
237            }
238            Some(Err(e)) => {
239                // Without a backoff a persistent failure (revoked token,
240                // network outage) becomes a hot loop against Telegram's API.
241                error!("Error fetching updates: {e}");
242                if race_shutdown(sleep(backoff), &shutdown).await.is_none() {
243                    return Ok(());
244                }
245                backoff = (backoff * 2).min(MAX_POLL_BACKOFF);
246                continue;
247            }
248        };
249
250        for update in updates {
251            // Acknowledge before handling: an update that panics a handler must
252            // not be redelivered forever.
253            offset = Some(update.update_id + 1);
254
255            if let Err(e) = dispatcher.dispatch(update).await {
256                error!("Error handling update: {e}");
257            }
258        }
259    }
260}
261
262/// Routes updates to handlers and sends whatever they return.
263///
264/// Shared by webhook and polling mode so both behave identically.
265struct Dispatcher {
266    client: TelegramClient,
267    builder: BotBuilder,
268}
269
270impl Dispatcher {
271    /// Handle one update to completion.
272    async fn dispatch(&self, update: Update) -> Result<(), BotError> {
273        let Some((data, handler)) = self.prepare(update).await? else {
274            return Ok(());
275        };
276
277        let chat_id = data.chat_id();
278        let thread_id = data.thread_id();
279        let response = handler.call(Context::new(data)).await;
280        send_response(&self.client, chat_id, thread_id, response).await
281    }
282
283    /// Handle one update in the background, so a webhook can reply immediately.
284    async fn dispatch_detached(self: &Arc<Self>, update: Update) -> Result<(), BotError> {
285        let Some((data, handler)) = self.prepare(update).await? else {
286            return Ok(());
287        };
288
289        let this = Arc::clone(self);
290        spawn(async move {
291            let chat_id = data.chat_id();
292            let thread_id = data.thread_id();
293            let response = handler.call(Context::new(data)).await;
294            if let Err(e) = send_response(&this.client, chat_id, thread_id, response).await {
295                error!("Telegram response error: {e}");
296            }
297        })
298        .detach();
299
300        Ok(())
301    }
302
303    /// Acknowledge the update and resolve it to a handler, building the context
304    /// exactly once.
305    async fn prepare(
306        &self,
307        update: Update,
308    ) -> Result<Option<(TelegramContextData, botkit_core::AnyHandler)>, BotError> {
309        // Telegram spins the button until the query is answered, so do it
310        // before the handler runs rather than after. A failure here is cosmetic
311        // - it must not cost the user their button press.
312        if let UpdateKind::CallbackQuery(callback_query) = &update.kind
313            && let Err(e) = self
314                .client
315                .answer_callback_query(&callback_query.id, None, false)
316                .await
317        {
318            warn!("Failed to answer callback query: {e}");
319        }
320
321        // Edits and reactions route as messages: they only arrive when the
322        // bot opted into them via `allowed_updates`, and the message handler
323        // is where a bot would observe them. The handler can tell them apart
324        // through `UpdateKind`.
325        if !matches!(
326            update.kind,
327            UpdateKind::Message(_)
328                | UpdateKind::EditedMessage(_)
329                | UpdateKind::CallbackQuery(_)
330                | UpdateKind::MessageReaction(_)
331        ) {
332            return Ok(None);
333        }
334
335        let is_callback = matches!(update.kind, UpdateKind::CallbackQuery(_));
336        let data = TelegramContextData::new(update, self.client.clone());
337
338        let event = match (is_callback, data.command_name(), data.button_id()) {
339            // A callback carries a button id or nothing we can route on; it is
340            // never a message, so don't let it fall through to the catch-all.
341            (true, _, Some(button)) => Event::Button(button),
342            (true, _, None) => return Ok(None),
343            (false, Some(command), _) => Event::Command(command),
344            (false, None, _) => Event::Message,
345        };
346
347        Ok(self
348            .builder
349            .route(event)
350            .cloned()
351            .map(|handler| (data, handler)))
352    }
353}
354
355/// Telegram webhook handler
356///
357/// Handles incoming webhook updates from Telegram. Use with a skyzen router.
358#[derive(Clone)]
359pub struct TelegramWebhook {
360    dispatcher: Arc<Dispatcher>,
361}
362
363impl TelegramWebhook {
364    /// Get the client for making API calls
365    pub fn client(&self) -> &TelegramClient {
366        &self.dispatcher.client
367    }
368
369    /// Handle a webhook update
370    ///
371    /// Returns as soon as the update is routed; the handler runs in the
372    /// background so Telegram isn't kept waiting on slow work.
373    pub async fn handle(&self, update: Update) -> Result<(), BotError> {
374        self.dispatcher.dispatch_detached(update).await
375    }
376}
377
378impl Endpoint for TelegramWebhook {
379    type Error = WebhookError;
380
381    async fn respond(&mut self, request: &mut Request) -> Result<HttpResponse, Self::Error> {
382        let update: Update = request
383            .body_mut()
384            .into_json()
385            .await
386            .map_err(|e| WebhookError(BotError::Other(e.to_string())))?;
387
388        self.handle(update).await.map_err(WebhookError)?;
389
390        Ok(HttpResponse::new(Body::from_bytes("OK")))
391    }
392}
393
394async fn send_response(
395    client: &TelegramClient,
396    chat_id: Option<i64>,
397    thread_id: Option<i64>,
398    mut response: Response,
399) -> Result<(), BotError> {
400    if response.is_empty() || response.is_acknowledge() {
401        return Ok(());
402    }
403
404    // Callback queries from inline messages carry no chat to reply in.
405    let Some(chat_id) = chat_id else {
406        return Ok(());
407    };
408
409    if let Some(file) = response.take_file() {
410        let _ = client
411            .send_chat_action(chat_id, "upload_document", thread_id)
412            .await;
413
414        return client
415            .send_document(
416                chat_id,
417                file.file,
418                file.filename.as_deref(),
419                file.caption.as_deref(),
420                thread_id,
421            )
422            .await
423            .map(|_| ());
424    }
425
426    let content = response.content().unwrap_or("");
427    if content.is_empty() {
428        return Ok(());
429    }
430
431    client
432        .send_message(chat_id, content, thread_id, build_reply_markup(&response))
433        .await?;
434    Ok(())
435}
436
437/// Flatten unified components into Telegram's inline keyboard rows.
438///
439/// A bare button becomes a row of its own; select menus have no Telegram
440/// equivalent and are skipped.
441fn build_reply_markup(response: &Response) -> Option<ReplyMarkup> {
442    use botkit_core::types::component::{Button, Component};
443
444    fn to_button(button: &Button) -> Option<InlineKeyboardButton> {
445        match (&button.url, &button.custom_id) {
446            (Some(url), _) => Some(InlineKeyboardButton::url(&button.label, url)),
447            (None, Some(custom_id)) => {
448                Some(InlineKeyboardButton::callback(&button.label, custom_id))
449            }
450            (None, None) => None,
451        }
452    }
453
454    let mut rows: Vec<Vec<InlineKeyboardButton>> = Vec::new();
455
456    for component in response.components() {
457        match component {
458            Component::ActionRow(action_row) => {
459                let row: Vec<_> = action_row
460                    .components
461                    .iter()
462                    .filter_map(|c| match c {
463                        Component::Button(button) => to_button(button),
464                        _ => None,
465                    })
466                    .collect();
467
468                if !row.is_empty() {
469                    rows.push(row);
470                }
471            }
472            Component::Button(button) => rows.extend(to_button(button).map(|b| vec![b])),
473            Component::SelectMenu(_) => {}
474        }
475    }
476
477    (!rows.is_empty()).then_some(ReplyMarkup::InlineKeyboard(InlineKeyboardMarkup {
478        inline_keyboard: rows,
479    }))
480}
481
482/// Await `future`, giving up early if shutdown is requested.
483async fn race_shutdown<F: Future>(future: F, shutdown: &Shutdown) -> Option<F::Output> {
484    futures_lite::future::or(async { Some(future.await) }, async {
485        shutdown.wait().await;
486        None
487    })
488    .await
489}
490
491async fn sleep(duration: Duration) {
492    async_io::Timer::after(duration).await;
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use botkit_core::types::component::{ActionRow, Button, Component, SelectMenu, SelectOption};
499
500    /// The event an update routes to, without performing any network calls.
501    fn routed_event(update: &Update) -> Option<&'static str> {
502        if !matches!(
503            update.kind,
504            UpdateKind::Message(_)
505                | UpdateKind::EditedMessage(_)
506                | UpdateKind::CallbackQuery(_)
507                | UpdateKind::MessageReaction(_)
508        ) {
509            return None;
510        }
511
512        let is_callback = matches!(update.kind, UpdateKind::CallbackQuery(_));
513        let data = TelegramContextData::new(update.clone(), TelegramClient::new("t"));
514
515        match (is_callback, data.command_name(), data.button_id()) {
516            (true, _, Some(_)) => Some("button"),
517            (true, _, None) => None,
518            (false, Some(_), _) => Some("command"),
519            (false, None, _) => Some("message"),
520        }
521    }
522
523    fn update(json: serde_json::Value) -> Update {
524        serde_json::from_value(json).expect("update parses")
525    }
526
527    #[test]
528    fn commands_messages_and_buttons_route_to_distinct_events() {
529        let command = update(serde_json::json!({
530            "update_id": 1,
531            "message": {
532                "message_id": 1, "date": 0,
533                "chat": { "id": 1, "type": "private" },
534                "text": "/ping",
535                "entities": [{ "type": "bot_command", "offset": 0, "length": 5 }]
536            }
537        }));
538        assert_eq!(routed_event(&command), Some("command"));
539
540        let message = update(serde_json::json!({
541            "update_id": 2,
542            "message": {
543                "message_id": 1, "date": 0,
544                "chat": { "id": 1, "type": "private" },
545                "text": "just chatting"
546            }
547        }));
548        assert_eq!(routed_event(&message), Some("message"));
549
550        let button = update(serde_json::json!({
551            "update_id": 3,
552            "callback_query": {
553                "id": "cb", "chat_instance": "x", "data": "confirm",
554                "from": { "id": 1, "is_bot": false, "first_name": "Ada" }
555            }
556        }));
557        assert_eq!(routed_event(&button), Some("button"));
558    }
559
560    #[test]
561    fn reaction_updates_route_to_the_message_handler() {
562        let reaction = update(serde_json::json!({
563            "update_id": 7,
564            "message_reaction": {
565                "message_id": 9,
566                "chat": { "id": 42, "type": "private" },
567                "user": { "id": 1, "is_bot": false, "first_name": "Ada" },
568                "date": 0,
569                "old_reaction": [],
570                "new_reaction": [{ "type": "emoji", "emoji": "👍" }]
571            }
572        }));
573        assert_eq!(routed_event(&reaction), Some("message"));
574
575        let data = TelegramContextData::new(reaction, TelegramClient::new("t"));
576        assert_eq!(data.chat_id(), Some(42));
577        assert_eq!(data.user_name(), "Ada");
578        let UpdateKind::MessageReaction(r) = &data.update.kind else {
579            panic!("expected a reaction update");
580        };
581        assert_eq!(r.message_id, 9);
582        assert!(matches!(
583            r.new_reaction.as_slice(),
584            [crate::types::ReactionType::Emoji { .. }]
585        ));
586    }
587
588    #[test]
589    fn a_callback_without_data_does_not_fall_through_to_the_message_handler() {
590        let update = update(serde_json::json!({
591            "update_id": 4,
592            "callback_query": {
593                "id": "cb", "chat_instance": "x",
594                "from": { "id": 1, "is_bot": false, "first_name": "Ada" }
595            }
596        }));
597        assert_eq!(routed_event(&update), None);
598    }
599
600    #[test]
601    fn edits_route_to_the_message_handler_but_unmodelled_kinds_do_not() {
602        let edit = update(serde_json::json!({
603            "update_id": 5,
604            "edited_message": {
605                "message_id": 1, "date": 0,
606                "chat": { "id": 1, "type": "private" },
607                "text": "reworded"
608            }
609        }));
610        assert_eq!(routed_event(&edit), Some("message"));
611
612        let poll = update(serde_json::json!({ "update_id": 6, "poll": { "id": "p" } }));
613        assert_eq!(routed_event(&poll), None);
614    }
615
616    fn markup(response: &Response) -> Option<Vec<Vec<InlineKeyboardButton>>> {
617        build_reply_markup(response).map(|ReplyMarkup::InlineKeyboard(m)| m.inline_keyboard)
618    }
619
620    #[test]
621    fn no_components_means_no_markup() {
622        assert!(markup(&Response::text("hi")).is_none());
623    }
624
625    #[test]
626    fn action_rows_become_keyboard_rows() {
627        let response = Response::text("hi").with_components(vec![Component::ActionRow(
628            ActionRow::buttons(vec![
629                Button::primary("a", "A"),
630                Button::link("https://example.com", "Link"),
631            ]),
632        )]);
633
634        let rows = markup(&response).expect("one row");
635        assert_eq!(rows.len(), 1);
636        assert_eq!(rows[0][0].callback_data.as_deref(), Some("a"));
637        assert_eq!(rows[0][1].url.as_deref(), Some("https://example.com"));
638        assert!(rows[0][1].callback_data.is_none());
639    }
640
641    #[test]
642    fn bare_buttons_get_their_own_row() {
643        let response = Response::text("hi").with_components(vec![
644            Component::Button(Button::primary("a", "A")),
645            Component::Button(Button::secondary("b", "B")),
646        ]);
647
648        let rows = markup(&response).expect("two rows");
649        assert_eq!(rows.len(), 2);
650        assert_eq!(rows[0][0].text, "A");
651        assert_eq!(rows[1][0].text, "B");
652    }
653
654    #[test]
655    fn components_without_a_telegram_equivalent_are_skipped() {
656        let response = Response::text("hi").with_components(vec![
657            Component::SelectMenu(SelectMenu::new("menu", vec![SelectOption::new("l", "v")])),
658            Component::ActionRow(ActionRow::new(vec![])),
659        ]);
660        assert!(markup(&response).is_none());
661    }
662}