Skip to main content

cratefield_adapter_telegram/
update.rs

1//! The inbound half: one webhook delivery body, parsed into a typed
2//! [`Update`]. The twin of [`client`](crate::client), which sends.
3//!
4//! Telegram counts entity offsets and lengths in **UTF-16 code units**, not
5//! bytes and not characters, so a link sliced out of a text that opens with
6//! an emoji lands two units further along than a naive `char_indices` walk
7//! would place it. Everything here slices through `encode_utf16` and
8//! decodes the slice back.
9//!
10//! Unknown fields are ignored: the wire grows, this struct does not have
11//! to.
12
13use serde::Deserialize;
14
15/// Parses one delivery body into an [`Update`].
16///
17/// # Errors
18///
19/// [`UpdateError::Malformed`] when the body is not JSON or does not carry
20/// the fields every update has (`update_id`, and one of the known
21/// payloads). A message this crate does not know the shape of is never an
22/// error — it is [`UpdateKind::Other`].
23pub fn parse_update(body: &[u8]) -> Result<Update, UpdateError> {
24    let raw: RawUpdate =
25        serde_json::from_slice(body).map_err(|error| UpdateError::Malformed(error.to_string()))?;
26    let kind = if let Some(callback) = raw.callback_query {
27        UpdateKind::CallbackQuery(build_callback(callback))
28    } else if let Some(post) = raw.channel_post {
29        UpdateKind::ChannelPost(ChannelPost {
30            message: build_message(post),
31            edited: false,
32        })
33    } else if let Some(post) = raw.edited_channel_post {
34        UpdateKind::ChannelPost(ChannelPost {
35            message: build_message(post),
36            edited: true,
37        })
38    } else if let Some(message) = raw.message {
39        as_command_or_message(message)
40    } else {
41        // `edited_message` and every payload this crate does not model.
42        UpdateKind::Other
43    };
44    Ok(Update {
45        update_id: raw.update_id,
46        kind,
47    })
48}
49
50/// Why a body could not be read.
51#[derive(Debug, Clone, PartialEq, Eq)]
52#[non_exhaustive]
53pub enum UpdateError {
54    /// The body is not JSON, or not an update shape. Carries the serde
55    /// message only — never the body itself.
56    Malformed(String),
57}
58
59impl std::fmt::Display for UpdateError {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        match self {
62            Self::Malformed(detail) => write!(f, "malformed telegram update body: {detail}"),
63        }
64    }
65}
66
67impl std::error::Error for UpdateError {}
68
69/// One delivery: its id — the dedup key against Telegram's at-least-once
70/// redelivery — and what it carries.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct Update {
73    /// Telegram's monotonically increasing delivery id. Claim it through a
74    /// dedup ledger (`cratefield_core::Inbox`) before acting: the
75    /// secret-token webhook proves the sender but nothing stops a replay.
76    pub update_id: i64,
77    /// What the delivery carries.
78    pub kind: UpdateKind,
79}
80
81/// What one delivery carries.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub enum UpdateKind {
84    /// A plain message — one without a leading `/command`.
85    Message(Message),
86    /// A message whose text starts with a `/command`.
87    Command(Command),
88    /// An inline-keyboard press.
89    CallbackQuery(CallbackQuery),
90    /// A post in a channel the bot is in, possibly an edit of one.
91    ChannelPost(ChannelPost),
92    /// Anything else — `edited_message`, shipping events, whatever Telegram
93    /// grows. The `update_id` still dedups it.
94    Other,
95}
96
97/// A Telegram user, as the update carries them.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct User {
100    /// The user's id, stable across their username changes.
101    pub id: i64,
102    /// Whether this "user" is another bot.
103    pub is_bot: bool,
104    /// The `@username`, absent when the user has none or hides it.
105    pub username: Option<String>,
106    /// The display name — the one field Telegram always sends.
107    pub first_name: String,
108    /// The client language, when the user shares it: an IETF tag like `en`.
109    pub language_code: Option<String>,
110}
111
112/// What kind of chat a message arrived in.
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum ChatKind {
115    /// A one-to-one conversation with the bot.
116    Private,
117    /// A basic group.
118    Group,
119    /// A supergroup — the form basic groups migrate into.
120    Supergroup,
121    /// A channel, where only admins post.
122    Channel,
123    /// A type this crate does not know.
124    Other,
125}
126
127/// The chat a message arrived in.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct Chat {
130    /// The chat id, negative for groups, supergroups and channels.
131    pub id: i64,
132    /// Private, group, supergroup, channel, or other.
133    pub kind: ChatKind,
134    /// The group or channel title; absent for a private chat.
135    pub title: Option<String>,
136    /// The public `@username`, when the chat has one.
137    pub username: Option<String>,
138}
139
140/// A plain message. `text` is the message's `text`, or its `caption` when
141/// the message carries media instead; `links` gathers the URLs from both
142/// the `entities` and the `caption_entities`.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct Message {
145    /// The message's id within its chat — what `edit_message` and
146    /// `delete_message` address.
147    pub message_id: i64,
148    /// Where the message arrived.
149    pub chat: Chat,
150    /// Who sent it. Absent on channel posts and on messages Telegram
151    /// attributes to the channel itself.
152    pub from: Option<User>,
153    /// When the message was sent, as a Unix timestamp.
154    pub date: i64,
155    /// The text, or the caption of a media message.
156    pub text: Option<String>,
157    /// Every URL the message names: `url` entities sliced out of the text
158    /// by their UTF-16 offsets, then `text_link` entities' own `url`s, in
159    /// wire order.
160    pub links: Vec<String>,
161}
162
163/// A message whose text starts with a `/command`.
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct Command {
166    /// The whole message, command included.
167    pub message: Message,
168    /// The command name: lowercase, without the leading `/` and without
169    /// any `@bot` suffix — `/start` and `/Start@MyBot` are both `start`.
170    pub name: String,
171    /// The bot the command was addressed to, from `/command@bot` — a group
172    /// with several bots disambiguates this way.
173    pub bot: Option<String>,
174    /// Everything after the command word, trimmed. Empty when there was
175    /// nothing after it.
176    pub args: String,
177}
178
179/// An inline-keyboard press: the `data` an [`InlineButton`](crate::InlineButton) carried, plus
180/// who pressed it and where.
181#[derive(Debug, Clone, PartialEq, Eq)]
182pub struct CallbackQuery {
183    /// What `answer_callback` acknowledges.
184    pub id: String,
185    /// Who pressed the button.
186    pub from: User,
187    /// The chat the button lives in, when Telegram still exposes the
188    /// message to the bot.
189    pub chat_id: Option<i64>,
190    /// The message the button lives on, same caveat.
191    pub message_id: Option<i64>,
192    /// The button's `data`, when it was a callback button.
193    pub data: Option<String>,
194}
195
196/// A post in a channel the bot is in.
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct ChannelPost {
199    /// The post itself.
200    pub message: Message,
201    /// Whether this delivery edited an earlier post
202    /// (`edited_channel_post`) rather than posted a new one.
203    pub edited: bool,
204}
205
206/// A message that is not a channel post: a leading `bot_command` entity
207/// makes it a [`UpdateKind::Command`], anything else is a plain message.
208fn as_command_or_message(raw: RawMessage) -> UpdateKind {
209    match command_parts(&raw) {
210        Some((name, bot, args)) => UpdateKind::Command(Command {
211            message: build_message(raw),
212            name,
213            bot,
214            args,
215        }),
216        None => UpdateKind::Message(build_message(raw)),
217    }
218}
219
220/// Splits `/name@bot args` out of a message whose text opens with a
221/// `bot_command` entity, or `None` when it does not.
222fn command_parts(raw: &RawMessage) -> Option<(String, Option<String>, String)> {
223    // A command rides `text`; an entity over a caption names nothing.
224    let text = raw.text.as_deref()?;
225    let entity = raw
226        .entities
227        .iter()
228        .find(|entity| entity.entity_type == "bot_command" && entity.offset == 0)?;
229    let length = usize::try_from(entity.length).ok()?;
230    let units: Vec<u16> = text.encode_utf16().collect();
231    if length == 0 || length > units.len() || units[0] != u16::from(b'/') {
232        return None;
233    }
234    let word = String::from_utf16_lossy(&units[1..length]);
235    let (name, bot) = match word.split_once('@') {
236        Some((name, bot)) if !name.is_empty() => (name, Some(bot.to_owned())),
237        Some(_) => return None,
238        None => (word.as_str(), None),
239    };
240    let args = String::from_utf16_lossy(&units[length..]).trim().to_owned();
241    Some((name.to_ascii_lowercase(), bot, args))
242}
243
244fn build_message(raw: RawMessage) -> Message {
245    // Collected before the moves: the entities borrow `raw`.
246    let mut links = Vec::new();
247    collect_links(raw.text.as_deref(), &raw.entities, &mut links);
248    collect_links(raw.caption.as_deref(), &raw.caption_entities, &mut links);
249    Message {
250        message_id: raw.message_id,
251        chat: build_chat(raw.chat),
252        from: raw.from.map(build_user),
253        date: raw.date,
254        // A media message has no `text`, only a `caption`; keep the words
255        // either way.
256        text: raw.text.or(raw.caption),
257        links,
258    }
259}
260
261fn build_chat(raw: RawChat) -> Chat {
262    Chat {
263        id: raw.id,
264        kind: match raw.chat_type.as_deref() {
265            Some("private") => ChatKind::Private,
266            Some("group") => ChatKind::Group,
267            Some("supergroup") => ChatKind::Supergroup,
268            Some("channel") => ChatKind::Channel,
269            _ => ChatKind::Other,
270        },
271        title: raw.title,
272        username: raw.username,
273    }
274}
275
276fn build_user(raw: RawUser) -> User {
277    User {
278        id: raw.id,
279        is_bot: raw.is_bot,
280        username: raw.username,
281        first_name: raw.first_name,
282        language_code: raw.language_code,
283    }
284}
285
286fn build_callback(raw: RawCallbackQuery) -> CallbackQuery {
287    let (chat_id, message_id) = raw.message.map_or((None, None), |message| {
288        (Some(message.chat.id), Some(message.message_id))
289    });
290    CallbackQuery {
291        id: raw.id,
292        from: build_user(raw.from),
293        chat_id,
294        message_id,
295        data: raw.data,
296    }
297}
298
299/// Collects the URLs `entities` name in `source` — `url` entities sliced
300/// out of the text by their UTF-16 offsets, `text_link` entities by their
301/// own `url` field. Out-of-range entities are skipped, not fatal: Telegram
302/// computed them, but the text and the entities ride separate fields and a
303/// parse here must never fail a delivery that verified.
304fn collect_links(source: Option<&str>, entities: &[RawEntity], links: &mut Vec<String>) {
305    let Some(text) = source else {
306        return;
307    };
308    let units: Vec<u16> = text.encode_utf16().collect();
309    for entity in entities {
310        match entity.entity_type.as_str() {
311            "url" => {
312                let (Ok(start), Ok(length)) = (
313                    usize::try_from(entity.offset),
314                    usize::try_from(entity.length),
315                ) else {
316                    continue;
317                };
318                let Some(end) = start.checked_add(length).filter(|&end| end <= units.len()) else {
319                    continue;
320                };
321                links.push(String::from_utf16_lossy(&units[start..end]));
322            }
323            "text_link" => {
324                if let Some(url) = &entity.url {
325                    links.push(url.clone());
326                }
327            }
328            _ => {}
329        }
330    }
331}
332
333// ---------------------------------------------------------------------------
334// The wire
335
336#[derive(Deserialize)]
337struct RawUpdate {
338    update_id: i64,
339    #[serde(default)]
340    message: Option<RawMessage>,
341    // `edited_message` is deliberately absent: serde ignores unknown
342    // fields, so an edit arrives with no known payload and falls through
343    // to `UpdateKind::Other` below.
344    #[serde(default)]
345    channel_post: Option<RawMessage>,
346    #[serde(default)]
347    edited_channel_post: Option<RawMessage>,
348    #[serde(default)]
349    callback_query: Option<RawCallbackQuery>,
350}
351
352#[derive(Deserialize)]
353struct RawMessage {
354    message_id: i64,
355    chat: RawChat,
356    #[serde(default)]
357    from: Option<RawUser>,
358    #[serde(default)]
359    date: i64,
360    #[serde(default)]
361    text: Option<String>,
362    #[serde(default)]
363    caption: Option<String>,
364    #[serde(default)]
365    entities: Vec<RawEntity>,
366    #[serde(default)]
367    caption_entities: Vec<RawEntity>,
368}
369
370#[derive(Deserialize)]
371struct RawChat {
372    id: i64,
373    #[serde(rename = "type")]
374    chat_type: Option<String>,
375    #[serde(default)]
376    title: Option<String>,
377    #[serde(default)]
378    username: Option<String>,
379}
380
381#[derive(Deserialize)]
382struct RawUser {
383    id: i64,
384    #[serde(default)]
385    is_bot: bool,
386    #[serde(default)]
387    username: Option<String>,
388    #[serde(default)]
389    first_name: String,
390    #[serde(default)]
391    language_code: Option<String>,
392}
393
394#[derive(Deserialize)]
395struct RawEntity {
396    #[serde(rename = "type")]
397    entity_type: String,
398    offset: i64,
399    length: i64,
400    #[serde(default)]
401    url: Option<String>,
402}
403
404#[derive(Deserialize)]
405struct RawCallbackQuery {
406    id: String,
407    from: RawUser,
408    #[serde(default)]
409    message: Option<RawMessage>,
410    #[serde(default)]
411    data: Option<String>,
412}