Skip to main content

botkit_telegram/
bot.rs

1use std::sync::Arc;
2
3use botkit_core::{BotBuilder, BotError, Context, ContextData, IntoHandler, Response};
4use executor_core::spawn;
5use http_kit::{Body, Endpoint, HttpError, Request, Response as HttpResponse, StatusCode};
6use tracing::{error, warn};
7
8use crate::client::TelegramClient;
9use crate::event::TelegramContextData;
10use crate::types::{
11    BotCommand, InlineKeyboardButton, InlineKeyboardMarkup, ReplyMarkup, Update, UpdateKind,
12};
13
14/// Error type for the Telegram webhook endpoint
15#[derive(Debug)]
16pub struct WebhookError(BotError);
17
18impl std::fmt::Display for WebhookError {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        write!(f, "{}", self.0)
21    }
22}
23
24impl std::error::Error for WebhookError {}
25
26impl HttpError for WebhookError {
27    fn status(&self) -> StatusCode {
28        StatusCode::INTERNAL_SERVER_ERROR
29    }
30}
31
32/// Telegram bot builder
33///
34/// Create a bot with command and button handlers, then call `build()` to get
35/// a webhook handler that implements skyzen's `Endpoint` trait.
36///
37/// # Example
38/// ```ignore
39/// use botkit_core::User;
40/// use botkit_telegram::{TelegramBot, TelegramWebhook};
41///
42/// // Simple handler - no Context needed!
43/// async fn ping() -> &'static str {
44///     "Pong!"
45/// }
46///
47/// // With extractors
48/// async fn greet(user: User) -> String {
49///     format!("Hello, {}!", user.name)
50/// }
51///
52/// // Return TelegramWebhook directly - it implements Endpoint
53/// #[skyzen::main]
54/// fn main() -> TelegramWebhook {
55///     TelegramBot::new(token)
56///         .command("ping", ping)
57///         .command("greet", greet)
58///         .build()
59/// }
60/// ```
61pub struct TelegramBot {
62    token: String,
63    builder: BotBuilder,
64}
65
66impl TelegramBot {
67    /// Create a new Telegram bot
68    pub fn new(token: impl Into<String>) -> Self {
69        Self {
70            token: token.into(),
71            builder: BotBuilder::new(),
72        }
73    }
74
75    /// Register a command handler (e.g., /start, /help)
76    pub fn command<H, Args>(mut self, name: impl Into<String>, handler: H) -> Self
77    where
78        H: IntoHandler<Args>,
79    {
80        self.builder = self.builder.command(name, handler);
81        self
82    }
83
84    /// Register a command handler with description
85    ///
86    /// The description appears in Telegram's command menu (slash command suggestions).
87    pub fn command_with_description<H, Args>(
88        mut self,
89        name: impl Into<String>,
90        description: impl Into<String>,
91        handler: H,
92    ) -> Self
93    where
94        H: IntoHandler<Args>,
95    {
96        self.builder = self
97            .builder
98            .command_with_description(name, description, handler);
99        self
100    }
101
102    /// Register a button handler (callback query data pattern)
103    ///
104    /// Pattern can end with `*` for prefix matching (e.g., "confirm_*")
105    pub fn button<H, Args>(mut self, pattern: impl Into<String>, handler: H) -> Self
106    where
107        H: IntoHandler<Args>,
108    {
109        self.builder = self.builder.button(pattern, handler);
110        self
111    }
112
113    /// Register a message handler
114    pub fn message<H, Args>(mut self, handler: H) -> Self
115    where
116        H: IntoHandler<Args>,
117    {
118        self.builder = self.builder.message(handler);
119        self
120    }
121
122    /// Build the webhook handler (for use with skyzen's Endpoint)
123    pub fn build(self) -> TelegramWebhook {
124        TelegramWebhook {
125            client: TelegramClient::new(&self.token),
126            builder: Arc::new(self.builder),
127        }
128    }
129
130    /// Run the bot using long-polling (no server needed)
131    ///
132    /// This method polls Telegram's API for updates and processes them
133    /// using the registered handlers. It runs forever until interrupted.
134    ///
135    /// # Example
136    /// ```ignore
137    /// use botkit_telegram::TelegramBot;
138    ///
139    /// async fn ping() -> &'static str { "Pong!" }
140    ///
141    /// TelegramBot::new(token)
142    ///     .command("ping", ping)
143    ///     .run_polling()
144    ///     .await;
145    /// ```
146    pub async fn run_polling(self) -> Result<(), BotError> {
147        let client = TelegramClient::new(&self.token);
148
149        let commands: Vec<BotCommand> = self
150            .builder
151            .commands()
152            .map(|(name, desc)| BotCommand::new(name, if desc.is_empty() { name } else { desc }))
153            .collect();
154
155        if !commands.is_empty()
156            && let Err(e) = client.set_my_commands(&commands).await
157        {
158            warn!("Failed to register commands: {}", e);
159        }
160
161        let builder = Arc::new(self.builder);
162
163        client.delete_webhook().await?;
164
165        let mut offset: Option<i64> = None;
166
167        loop {
168            match client.get_updates(offset, Some(30)).await {
169                Ok(updates) => {
170                    for update in updates {
171                        offset = Some(update.update_id + 1);
172                        if let Err(e) = process_update_sync(&client, &builder, update).await {
173                            error!("Error handling update: {}", e);
174                        }
175                    }
176                }
177                Err(e) => {
178                    error!("Error fetching updates: {}", e);
179                }
180            }
181        }
182    }
183}
184
185/// Telegram webhook handler
186///
187/// Handles incoming webhook updates from Telegram. Use with a skyzen router.
188#[derive(Clone)]
189pub struct TelegramWebhook {
190    client: TelegramClient,
191    builder: Arc<BotBuilder>,
192}
193
194impl TelegramWebhook {
195    /// Get the client for making API calls
196    pub fn client(&self) -> &TelegramClient {
197        &self.client
198    }
199
200    /// Handle a webhook update
201    ///
202    /// This is the main entry point for processing Telegram updates.
203    pub async fn handle(&self, update: Update) -> Result<(), BotError> {
204        self.handle_update(update).await
205    }
206
207    /// Handle a webhook update (internal)
208    async fn handle_update(&self, update: Update) -> Result<(), BotError> {
209        acknowledge_callback_query(&self.client, &update).await?;
210
211        let (event_type, value) = match &update.kind {
212            UpdateKind::Message(msg) => {
213                let data = TelegramContextData::new(update.clone(), self.client.clone());
214                if let Some(cmd_name) = data.command_name() {
215                    ("command", cmd_name.to_string())
216                } else {
217                    ("message", msg.text.clone().unwrap_or_default())
218                }
219            }
220            UpdateKind::CallbackQuery(cq) => ("button", cq.data.clone().unwrap_or_default()),
221            _ => return Ok(()),
222        };
223
224        let handler = self.builder.find_handler(event_type, &value);
225
226        if let Some(handler) = handler {
227            let data = TelegramContextData::new(update.clone(), self.client.clone());
228            let ctx = Context::new(data);
229
230            let client = self.client.clone();
231            spawn(async move {
232                let response = handler.call(ctx).await;
233                if let Err(e) = send_response(&client, &update, response).await {
234                    error!("Telegram response error: {}", e);
235                }
236            })
237            .detach();
238        }
239
240        Ok(())
241    }
242}
243
244async fn process_update_sync(
245    client: &TelegramClient,
246    builder: &BotBuilder,
247    update: Update,
248) -> Result<(), BotError> {
249    acknowledge_callback_query(client, &update).await?;
250
251    let (event_type, value) = match &update.kind {
252        UpdateKind::Message(msg) => {
253            let data = TelegramContextData::new(update.clone(), client.clone());
254            if let Some(cmd_name) = data.command_name() {
255                ("command", cmd_name.to_string())
256            } else {
257                ("message", msg.text.clone().unwrap_or_default())
258            }
259        }
260        UpdateKind::CallbackQuery(cq) => ("button", cq.data.clone().unwrap_or_default()),
261        _ => return Ok(()),
262    };
263
264    if let Some(handler) = builder.find_handler(event_type, &value) {
265        let data = TelegramContextData::new(update.clone(), client.clone());
266        let ctx = Context::new(data);
267        let response = handler.call(ctx).await;
268        send_response(client, &update, response).await?;
269    }
270
271    Ok(())
272}
273
274async fn acknowledge_callback_query(
275    client: &TelegramClient,
276    update: &Update,
277) -> Result<(), BotError> {
278    if let UpdateKind::CallbackQuery(callback_query) = &update.kind {
279        client
280            .answer_callback_query(&callback_query.id, None, false)
281            .await?;
282    }
283
284    Ok(())
285}
286
287async fn send_response(
288    client: &TelegramClient,
289    update: &Update,
290    mut response: Response,
291) -> Result<(), BotError> {
292    if response.is_empty() || response.is_acknowledge() {
293        return Ok(());
294    }
295
296    let chat_id = match &update.kind {
297        UpdateKind::Message(m) | UpdateKind::EditedMessage(m) => m.chat.id,
298        UpdateKind::CallbackQuery(cq) => cq.message.as_ref().map(|m| m.chat.id).unwrap_or(0),
299        _ => return Ok(()),
300    };
301
302    if chat_id == 0 {
303        return Ok(());
304    }
305
306    if response.is_file()
307        && let Some(file_response) = response.take_file()
308    {
309        let _ = client.send_chat_action(chat_id, "upload_document").await;
310
311        return client
312            .send_document(
313                chat_id,
314                file_response.file,
315                file_response.filename.as_deref(),
316                file_response.caption.as_deref(),
317            )
318            .await;
319    }
320
321    let content = response.content().unwrap_or("");
322    if content.is_empty() {
323        return Ok(());
324    }
325
326    let reply_markup = build_reply_markup(&response);
327
328    client.send_message(chat_id, content, reply_markup).await
329}
330
331fn build_reply_markup(response: &Response) -> Option<ReplyMarkup> {
332    use botkit_core::types::component::Component;
333
334    let components = response.components();
335    if components.is_empty() {
336        return None;
337    }
338
339    let mut rows: Vec<Vec<InlineKeyboardButton>> = Vec::new();
340
341    for component in components {
342        match component {
343            Component::ActionRow(action_row) => {
344                let row: Vec<InlineKeyboardButton> = action_row
345                    .components
346                    .iter()
347                    .filter_map(|c| match c {
348                        Component::Button(btn) => {
349                            if let Some(url) = &btn.url {
350                                Some(InlineKeyboardButton::url(&btn.label, url))
351                            } else {
352                                btn.custom_id.as_ref().map(|custom_id| {
353                                    InlineKeyboardButton::callback(&btn.label, custom_id)
354                                })
355                            }
356                        }
357                        _ => None,
358                    })
359                    .collect();
360
361                if !row.is_empty() {
362                    rows.push(row);
363                }
364            }
365            Component::Button(btn) => {
366                let button = if let Some(url) = &btn.url {
367                    InlineKeyboardButton::url(&btn.label, url)
368                } else if let Some(custom_id) = &btn.custom_id {
369                    InlineKeyboardButton::callback(&btn.label, custom_id)
370                } else {
371                    continue;
372                };
373                rows.push(vec![button]);
374            }
375            _ => {}
376        }
377    }
378
379    if rows.is_empty() {
380        None
381    } else {
382        Some(ReplyMarkup::InlineKeyboard(InlineKeyboardMarkup {
383            inline_keyboard: rows,
384        }))
385    }
386}
387
388impl Endpoint for TelegramWebhook {
389    type Error = WebhookError;
390
391    async fn respond(&mut self, request: &mut Request) -> Result<HttpResponse, Self::Error> {
392        let update: Update = request
393            .body_mut()
394            .into_json()
395            .await
396            .map_err(|e| WebhookError(BotError::Other(e.to_string())))?;
397
398        self.handle(update).await.map_err(WebhookError)?;
399
400        Ok(HttpResponse::new(Body::from_bytes("OK")))
401    }
402}