Skip to main content

botkit_telegram/
types.rs

1use serde::{Deserialize, Serialize};
2
3/// Telegram Update object received via webhook
4///
5/// Deserialized field by field rather than with a flattened enum: Telegram adds
6/// new update kinds regularly, and a bot must keep accepting them (as
7/// [`UpdateKind::Unknown`]) instead of failing the whole payload — a rejected
8/// update is redelivered forever.
9#[derive(Debug, Clone)]
10pub struct Update {
11    pub update_id: i64,
12    pub kind: UpdateKind,
13}
14
15/// The kind of update received
16#[derive(Debug, Clone)]
17#[allow(clippy::large_enum_variant)]
18pub enum UpdateKind {
19    /// A new message in a chat
20    Message(Message),
21    /// An edit to a message already delivered
22    EditedMessage(Message),
23    /// An inline keyboard button press
24    CallbackQuery(CallbackQuery),
25    /// A reaction on a message changed. Only delivered when `message_reaction`
26    /// appears in `allowed_updates`.
27    MessageReaction(MessageReactionUpdated),
28    /// An update this version does not model
29    Unknown,
30}
31
32impl<'de> Deserialize<'de> for Update {
33    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
34        /// Every update kind botkit understands, plus the id shared by all.
35        #[derive(Deserialize)]
36        struct RawUpdate {
37            update_id: i64,
38            message: Option<Message>,
39            edited_message: Option<Message>,
40            callback_query: Option<CallbackQuery>,
41            message_reaction: Option<MessageReactionUpdated>,
42        }
43
44        let raw = RawUpdate::deserialize(deserializer)?;
45
46        let kind = if let Some(message) = raw.message {
47            UpdateKind::Message(message)
48        } else if let Some(message) = raw.edited_message {
49            UpdateKind::EditedMessage(message)
50        } else if let Some(callback_query) = raw.callback_query {
51            UpdateKind::CallbackQuery(callback_query)
52        } else if let Some(reaction) = raw.message_reaction {
53            UpdateKind::MessageReaction(reaction)
54        } else {
55            UpdateKind::Unknown
56        };
57
58        Ok(Self {
59            update_id: raw.update_id,
60            kind,
61        })
62    }
63}
64
65/// Telegram Message object
66#[derive(Debug, Clone, Deserialize)]
67pub struct Message {
68    pub message_id: i64,
69    pub from: Option<User>,
70    pub chat: Chat,
71    pub date: i64,
72    /// Edit timestamp for an edited message.
73    pub edit_date: Option<i64>,
74    pub text: Option<String>,
75    /// Caption under attached media — media messages carry their text here
76    /// instead of `text`.
77    pub caption: Option<String>,
78    pub entities: Option<Vec<MessageEntity>>,
79    pub reply_to_message: Option<Box<Message>>,
80    /// A sticker attached to the message.
81    pub sticker: Option<Sticker>,
82    /// Attached photo, largest variant last.
83    pub photo: Option<Vec<PhotoSize>>,
84    /// Attached video.
85    pub video: Option<MediaFile>,
86    /// Attached audio track.
87    pub audio: Option<MediaFile>,
88    /// Attached voice note.
89    pub voice: Option<MediaFile>,
90    /// Attached document.
91    pub document: Option<MediaFile>,
92    /// Attached animation (GIF).
93    pub animation: Option<MediaFile>,
94    /// Forum topic the message was posted to, if the chat has topics.
95    pub message_thread_id: Option<i64>,
96}
97
98/// A sticker attached to a message or contained in a sticker set.
99#[derive(Debug, Clone, Deserialize)]
100pub struct Sticker {
101    /// Identifier usable with `sendSticker` to resend it.
102    pub file_id: String,
103    /// Persistent identifier invariant across bots and re-uploads.
104    pub file_unique_id: String,
105    /// `regular`, `mask`, or `custom_emoji`.
106    #[serde(rename = "type")]
107    pub sticker_type: String,
108    /// The emoji associated with the sticker.
109    pub emoji: Option<String>,
110    /// Name of the sticker set it belongs to, if any.
111    pub set_name: Option<String>,
112    /// True for `.tgs` animated stickers.
113    #[serde(default)]
114    pub is_animated: bool,
115    /// True for `.webm` video stickers.
116    #[serde(default)]
117    pub is_video: bool,
118}
119
120/// One size of a photo attached to a message.
121#[derive(Debug, Clone, Deserialize)]
122pub struct PhotoSize {
123    /// Identifier usable to re-send or fetch the file.
124    pub file_id: String,
125    /// Persistent identifier invariant across bots and re-uploads.
126    pub file_unique_id: String,
127    /// Photo width in pixels.
128    pub width: i64,
129    /// Photo height in pixels.
130    pub height: i64,
131}
132
133/// A file attached to a message (video, audio, voice note, document, …).
134#[derive(Debug, Clone, Deserialize)]
135pub struct MediaFile {
136    /// Identifier usable to re-send or fetch the file.
137    pub file_id: String,
138    /// Persistent identifier invariant across bots and re-uploads.
139    pub file_unique_id: String,
140    /// MIME type reported by the sender.
141    pub mime_type: Option<String>,
142    /// Original file name (documents only).
143    pub file_name: Option<String>,
144}
145
146/// A file as returned by `getFile` — `file_path` is the download path under
147/// `https://api.telegram.org/file/bot<token>/`.
148#[derive(Debug, Clone, Deserialize)]
149pub struct File {
150    /// Identifier usable with `sendX`/`getFile`.
151    pub file_id: String,
152    /// Persistent identifier invariant across bots and re-uploads.
153    pub file_unique_id: String,
154    /// File size in bytes, if known.
155    pub file_size: Option<i64>,
156    /// Server-side download path. Files up to 20MB are downloadable; the path
157    /// stays valid for at least an hour.
158    pub file_path: Option<String>,
159}
160
161/// A sticker set as returned by `getStickerSet`.
162#[derive(Debug, Clone, Deserialize)]
163pub struct StickerSet {
164    /// The set's short name (the `t.me/addstickers/<name>` slug).
165    pub name: String,
166    /// Human-readable title.
167    pub title: String,
168    /// `regular`, `mask`, or `custom_emoji`.
169    pub sticker_type: String,
170    /// Every sticker in the set, in order.
171    pub stickers: Vec<Sticker>,
172}
173
174/// Telegram User object
175#[derive(Debug, Clone, Deserialize)]
176pub struct User {
177    pub id: i64,
178    pub is_bot: bool,
179    pub first_name: String,
180    pub last_name: Option<String>,
181    pub username: Option<String>,
182    pub language_code: Option<String>,
183    /// `getMe` only: whether group privacy mode is off, i.e. the bot
184    /// receives every group message rather than only commands, replies,
185    /// and mentions.
186    #[serde(default)]
187    pub can_read_all_group_messages: Option<bool>,
188}
189
190/// Telegram Chat object
191#[derive(Debug, Clone, Deserialize)]
192pub struct Chat {
193    pub id: i64,
194    #[serde(rename = "type")]
195    pub chat_type: ChatType,
196    pub title: Option<String>,
197    pub username: Option<String>,
198    pub first_name: Option<String>,
199    pub last_name: Option<String>,
200}
201
202/// Chat type
203#[derive(Debug, Clone, Deserialize)]
204#[serde(rename_all = "snake_case")]
205pub enum ChatType {
206    Private,
207    Group,
208    Supergroup,
209    Channel,
210}
211
212/// Message entity (commands, mentions, etc.)
213#[derive(Debug, Clone, Deserialize)]
214pub struct MessageEntity {
215    #[serde(rename = "type")]
216    pub entity_type: EntityType,
217    pub offset: i64,
218    pub length: i64,
219    /// `text_mention` entities carry the mentioned user inline.
220    #[serde(default)]
221    pub user: Option<User>,
222}
223
224/// Entity type
225#[derive(Debug, Clone, Deserialize)]
226#[serde(rename_all = "snake_case")]
227pub enum EntityType {
228    Mention,
229    Hashtag,
230    Cashtag,
231    BotCommand,
232    Url,
233    Email,
234    PhoneNumber,
235    Bold,
236    Italic,
237    Underline,
238    Strikethrough,
239    Spoiler,
240    Code,
241    Pre,
242    TextLink,
243    TextMention,
244    CustomEmoji,
245    #[serde(other)]
246    Unknown,
247}
248
249/// Callback query (button press)
250#[derive(Debug, Clone, Deserialize)]
251pub struct CallbackQuery {
252    pub id: String,
253    pub from: User,
254    pub message: Option<Message>,
255    pub inline_message_id: Option<String>,
256    pub chat_instance: String,
257    pub data: Option<String>,
258}
259
260/// Telegram `MessageReactionUpdated` — a user's reaction set on a message
261/// changed.
262#[derive(Debug, Clone, Deserialize)]
263pub struct MessageReactionUpdated {
264    pub chat: Chat,
265    /// The message the reactions apply to.
266    pub message_id: i64,
267    /// When the reaction changed.
268    pub date: Option<i64>,
269    /// The user who changed their reaction (absent for anonymous admins).
270    pub user: Option<User>,
271    /// The acting chat, when a channel reacted anonymously.
272    pub actor_chat: Option<Chat>,
273    /// Reactions before the change.
274    pub old_reaction: Vec<ReactionType>,
275    /// Reactions after the change.
276    pub new_reaction: Vec<ReactionType>,
277}
278
279/// Telegram `ReactionType` — a standard emoji, a custom emoji, or a paid
280/// reaction.
281#[derive(Debug, Clone, Deserialize)]
282#[serde(tag = "type", rename_all = "snake_case")]
283pub enum ReactionType {
284    /// A standard emoji (`👍`, `❤`, …).
285    Emoji {
286        /// The emoji character.
287        emoji: String,
288    },
289    /// A custom emoji sticker.
290    CustomEmoji {
291        /// The custom emoji's id.
292        custom_emoji_id: String,
293    },
294    /// Telegram's paid reaction.
295    Paid,
296}
297
298/// Inline keyboard markup
299#[derive(Debug, Clone, Serialize)]
300pub struct InlineKeyboardMarkup {
301    pub inline_keyboard: Vec<Vec<InlineKeyboardButton>>,
302}
303
304/// Inline keyboard button
305#[derive(Debug, Clone, Serialize)]
306pub struct InlineKeyboardButton {
307    pub text: String,
308    #[serde(skip_serializing_if = "Option::is_none")]
309    pub url: Option<String>,
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub callback_data: Option<String>,
312}
313
314impl InlineKeyboardButton {
315    pub fn callback(text: impl Into<String>, data: impl Into<String>) -> Self {
316        Self {
317            text: text.into(),
318            url: None,
319            callback_data: Some(data.into()),
320        }
321    }
322
323    pub fn url(text: impl Into<String>, url: impl Into<String>) -> Self {
324        Self {
325            text: text.into(),
326            url: Some(url.into()),
327            callback_data: None,
328        }
329    }
330}
331
332/// Reply markup options
333#[derive(Debug, Clone, Serialize)]
334#[serde(untagged)]
335pub enum ReplyMarkup {
336    InlineKeyboard(InlineKeyboardMarkup),
337}
338
339/// Bot command for setMyCommands API
340#[derive(Debug, Clone, Serialize)]
341pub struct BotCommand {
342    /// Command name without the leading slash
343    pub command: String,
344    /// Description shown in the command menu
345    pub description: String,
346}
347
348impl BotCommand {
349    /// Create a new bot command
350    pub fn new(command: impl Into<String>, description: impl Into<String>) -> Self {
351        Self {
352            command: command.into(),
353            description: description.into(),
354        }
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    fn parse(json: serde_json::Value) -> Update {
363        serde_json::from_value(json).expect("update parses")
364    }
365
366    #[test]
367    fn parses_a_message_update() {
368        let update = parse(serde_json::json!({
369            "update_id": 1,
370            "message": {
371                "message_id": 5,
372                "date": 0,
373                "chat": { "id": 42, "type": "supergroup", "title": "Room" },
374                "text": "hi"
375            }
376        }));
377
378        assert_eq!(update.update_id, 1);
379        let UpdateKind::Message(message) = update.kind else {
380            panic!("expected a message");
381        };
382        assert_eq!(message.message_id, 5);
383        assert_eq!(message.text.as_deref(), Some("hi"));
384    }
385
386    #[test]
387    fn parses_an_edited_message_update() {
388        let update = parse(serde_json::json!({
389            "update_id": 2,
390            "edited_message": {
391                "message_id": 5,
392                "date": 0,
393                "chat": { "id": 42, "type": "private" },
394                "text": "fixed"
395            }
396        }));
397        assert!(matches!(update.kind, UpdateKind::EditedMessage(_)));
398    }
399
400    #[test]
401    fn unmodelled_update_kinds_still_parse() {
402        // Telegram keeps adding update kinds; rejecting one would make it
403        // redeliver the same update forever.
404        for kind in ["poll", "my_chat_member", "inline_query", "channel_post"] {
405            let update = parse(serde_json::json!({
406                "update_id": 3,
407                kind: { "id": "whatever", "unexpected": [1, 2, 3] }
408            }));
409            assert!(matches!(update.kind, UpdateKind::Unknown), "{kind}");
410            assert_eq!(update.update_id, 3);
411        }
412    }
413
414    #[test]
415    fn unknown_fields_on_known_kinds_are_ignored() {
416        let update = parse(serde_json::json!({
417            "update_id": 4,
418            "message": {
419                "message_id": 5,
420                "date": 0,
421                "chat": { "id": 42, "type": "private" },
422                "text": "hi",
423                "some_future_field": { "nested": true }
424            }
425        }));
426        assert!(matches!(update.kind, UpdateKind::Message(_)));
427    }
428
429    #[test]
430    fn inline_keyboards_serialize_the_way_telegram_expects() {
431        let markup = ReplyMarkup::InlineKeyboard(InlineKeyboardMarkup {
432            inline_keyboard: vec![vec![
433                InlineKeyboardButton::callback("Yes", "yes"),
434                InlineKeyboardButton::url("Docs", "https://example.com"),
435            ]],
436        });
437
438        let json = serde_json::to_value(&markup).unwrap();
439        assert_eq!(
440            json,
441            serde_json::json!({
442                "inline_keyboard": [[
443                    { "text": "Yes", "callback_data": "yes" },
444                    { "text": "Docs", "url": "https://example.com" }
445                ]]
446            })
447        );
448    }
449}