Skip to main content

webserver_base/telegram/
entity.rs

1use std::ops::{BitOr, BitOrAssign};
2
3use serde::{Serialize, Serializer, ser::SerializeMap};
4
5/// A set of inline text styles which can be combined.
6///
7/// Telegram permits entities to overlap, so combining styles simply emits one
8/// entity per style over the same span. That is why this is a bit set rather
9/// than a nesting API: `Style::BOLD | Style::ITALIC` needs no nesting at all.
10///
11/// Styles which carry data — links, mentions, preformatted blocks, custom
12/// emoji — are not expressible as flags and have dedicated builder methods.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
14pub struct Style(u8);
15
16impl Style {
17    /// No styling; the text is plain.
18    pub const NONE: Self = Self(0);
19    /// **Bold** text.
20    pub const BOLD: Self = Self(1 << 0);
21    /// *Italic* text.
22    pub const ITALIC: Self = Self(1 << 1);
23    /// Underlined text.
24    pub const UNDERLINE: Self = Self(1 << 2);
25    /// ~~Struck through~~ text.
26    pub const STRIKETHROUGH: Self = Self(1 << 3);
27    /// Text hidden behind a spoiler.
28    pub const SPOILER: Self = Self(1 << 4);
29    /// `Monospaced` inline code.
30    pub const CODE: Self = Self(1 << 5);
31
32    /// Every flag, paired with the entity it produces, in a stable order.
33    const ALL: [(Self, EntityKind); 6] = [
34        (Self::BOLD, EntityKind::Bold),
35        (Self::ITALIC, EntityKind::Italic),
36        (Self::UNDERLINE, EntityKind::Underline),
37        (Self::STRIKETHROUGH, EntityKind::Strikethrough),
38        (Self::SPOILER, EntityKind::Spoiler),
39        (Self::CODE, EntityKind::Code),
40    ];
41
42    /// Whether every flag in `other` is set.
43    #[must_use]
44    pub const fn contains(self, other: Self) -> bool {
45        other.0 != 0 && (self.0 & other.0) == other.0
46    }
47
48    /// Whether no flags are set.
49    #[must_use]
50    pub const fn is_empty(self) -> bool {
51        self.0 == 0
52    }
53
54    /// Expands this set into the individual entity kinds it represents.
55    #[must_use]
56    pub(crate) fn kinds(self) -> Vec<EntityKind> {
57        Self::ALL
58            .iter()
59            .filter(|(flag, _)| self.contains(*flag))
60            .map(|(_, kind)| kind.clone())
61            .collect()
62    }
63}
64
65impl BitOr for Style {
66    type Output = Self;
67
68    fn bitor(self, rhs: Self) -> Self::Output {
69        Self(self.0 | rhs.0)
70    }
71}
72
73impl BitOrAssign for Style {
74    fn bitor_assign(&mut self, rhs: Self) {
75        self.0 |= rhs.0;
76    }
77}
78
79/// The kind of a message entity, including any data it carries.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum EntityKind {
82    /// **Bold** text.
83    Bold,
84    /// *Italic* text.
85    Italic,
86    /// Underlined text.
87    Underline,
88    /// ~~Struck through~~ text.
89    Strikethrough,
90    /// Text hidden behind a spoiler.
91    Spoiler,
92    /// `Monospaced` inline code.
93    Code,
94    /// A preformatted block, optionally tagged with a language for highlighting.
95    ///
96    /// Telegram documents that this entity **cannot be nested inside other
97    /// entities**, which is why the builder only exposes it at the top level.
98    Pre {
99        /// The programming language of the block's contents.
100        language: Option<String>,
101    },
102    /// Text which links to a URL.
103    TextLink {
104        /// The URL to open.
105        url: String,
106    },
107    /// Text which mentions a user who has no username.
108    TextMention {
109        /// The mentioned user's numeric id.
110        user_id: i64,
111    },
112    /// An inline custom emoji sticker.
113    CustomEmoji {
114        /// The custom emoji's identifier.
115        custom_emoji_id: String,
116    },
117    /// A block quotation.
118    Blockquote,
119    /// A block quotation which is collapsed by default.
120    ExpandableBlockquote,
121}
122
123impl EntityKind {
124    /// The value Telegram expects in the entity's `type` field.
125    #[must_use]
126    pub(crate) const fn wire_name(&self) -> &'static str {
127        match self {
128            Self::Bold => "bold",
129            Self::Italic => "italic",
130            Self::Underline => "underline",
131            Self::Strikethrough => "strikethrough",
132            Self::Spoiler => "spoiler",
133            Self::Code => "code",
134            Self::Pre { .. } => "pre",
135            Self::TextLink { .. } => "text_link",
136            Self::TextMention { .. } => "text_mention",
137            Self::CustomEmoji { .. } => "custom_emoji",
138            Self::Blockquote => "blockquote",
139            Self::ExpandableBlockquote => "expandable_blockquote",
140        }
141    }
142}
143
144/// A styled span of a message, positioned in UTF-16 code units.
145///
146/// This is Telegram's canonical representation of formatting. Sending entities
147/// directly — rather than a `parse_mode` string — means there is no markup
148/// embedded in the text, and therefore nothing which ever needs escaping.
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct Entity {
151    /// What kind of formatting this span carries.
152    pub kind: EntityKind,
153    /// Offset of the span's start, in UTF-16 code units.
154    pub offset: usize,
155    /// Length of the span, in UTF-16 code units.
156    pub length: usize,
157}
158
159impl Entity {
160    /// Creates a new [`Entity`].
161    #[must_use]
162    pub const fn new(kind: EntityKind, offset: usize, length: usize) -> Self {
163        Self {
164            kind,
165            offset,
166            length,
167        }
168    }
169
170    /// The exclusive end of this span, in UTF-16 code units.
171    #[must_use]
172    pub const fn end(&self) -> usize {
173        self.offset + self.length
174    }
175}
176
177impl Serialize for Entity {
178    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
179        // Telegram's wire format is flat: the discriminant is a `type` field
180        // alongside the data fields, rather than a nested object.
181        let extra_fields: usize = match &self.kind {
182            EntityKind::Pre { language } => usize::from(language.is_some()),
183            EntityKind::TextLink { .. }
184            | EntityKind::TextMention { .. }
185            | EntityKind::CustomEmoji { .. } => 1,
186            _ => 0,
187        };
188
189        let mut map: S::SerializeMap = serializer.serialize_map(Some(3 + extra_fields))?;
190        map.serialize_entry("type", self.kind.wire_name())?;
191        map.serialize_entry("offset", &self.offset)?;
192        map.serialize_entry("length", &self.length)?;
193
194        match &self.kind {
195            EntityKind::Pre {
196                language: Some(language),
197            } => map.serialize_entry("language", language)?,
198            EntityKind::TextLink { url } => map.serialize_entry("url", url)?,
199            EntityKind::TextMention { user_id } => {
200                map.serialize_entry("user", &TextMentionUser { id: *user_id })?;
201            }
202            EntityKind::CustomEmoji { custom_emoji_id } => {
203                map.serialize_entry("custom_emoji_id", custom_emoji_id)?;
204            }
205            _ => {}
206        }
207
208        map.end()
209    }
210}
211
212/// Telegram expects `text_mention` to carry a nested `User` object.
213#[derive(Serialize)]
214struct TextMentionUser {
215    id: i64,
216}
217
218#[cfg(test)]
219mod tests {
220    use super::{Entity, EntityKind, Style};
221
222    #[test]
223    fn combined_styles_contain_each_component() {
224        let style: Style = Style::BOLD | Style::ITALIC;
225
226        assert!(style.contains(Style::BOLD));
227        assert!(style.contains(Style::ITALIC));
228        assert!(!style.contains(Style::CODE));
229    }
230
231    #[test]
232    fn empty_style_contains_nothing() {
233        let style: Style = Style::NONE;
234
235        assert!(style.is_empty());
236        assert!(!style.contains(Style::BOLD));
237    }
238
239    #[test]
240    fn combined_styles_expand_to_one_kind_each() {
241        let expected: Vec<EntityKind> = vec![EntityKind::Bold, EntityKind::Italic];
242        let actual: Vec<EntityKind> = (Style::BOLD | Style::ITALIC).kinds();
243        assert_eq!(expected, actual);
244    }
245
246    #[test]
247    fn every_style_flag_expands() {
248        let all: Style = Style::BOLD
249            | Style::ITALIC
250            | Style::UNDERLINE
251            | Style::STRIKETHROUGH
252            | Style::SPOILER
253            | Style::CODE;
254
255        let expected: usize = 6;
256        let actual: usize = all.kinds().len();
257        assert_eq!(expected, actual);
258    }
259
260    #[test]
261    fn entity_end_is_offset_plus_length() {
262        let entity: Entity = Entity::new(EntityKind::Bold, 5, 3);
263
264        let expected: usize = 8;
265        let actual: usize = entity.end();
266        assert_eq!(expected, actual);
267    }
268
269    #[test]
270    fn simple_entity_serializes_flat() {
271        let expected: String = String::from(r#"{"type":"bold","offset":0,"length":4}"#);
272        let actual: String = serde_json::to_string(&Entity::new(EntityKind::Bold, 0, 4))
273            .expect("entity should serialize");
274        assert_eq!(expected, actual);
275    }
276
277    #[test]
278    fn text_link_serializes_its_url() {
279        let entity: Entity = Entity::new(
280            EntityKind::TextLink {
281                url: String::from("https://example.com"),
282            },
283            2,
284            4,
285        );
286
287        let expected: String = String::from(
288            r#"{"type":"text_link","offset":2,"length":4,"url":"https://example.com"}"#,
289        );
290        let actual: String = serde_json::to_string(&entity).expect("entity should serialize");
291        assert_eq!(expected, actual);
292    }
293
294    #[test]
295    fn pre_omits_language_when_absent() {
296        let entity: Entity = Entity::new(EntityKind::Pre { language: None }, 0, 5);
297
298        let expected: String = String::from(r#"{"type":"pre","offset":0,"length":5}"#);
299        let actual: String = serde_json::to_string(&entity).expect("entity should serialize");
300        assert_eq!(expected, actual);
301    }
302
303    #[test]
304    fn pre_includes_language_when_present() {
305        let entity: Entity = Entity::new(
306            EntityKind::Pre {
307                language: Some(String::from("rust")),
308            },
309            0,
310            5,
311        );
312
313        let expected: String =
314            String::from(r#"{"type":"pre","offset":0,"length":5,"language":"rust"}"#);
315        let actual: String = serde_json::to_string(&entity).expect("entity should serialize");
316        assert_eq!(expected, actual);
317    }
318
319    #[test]
320    fn text_mention_serializes_a_nested_user() {
321        let entity: Entity = Entity::new(EntityKind::TextMention { user_id: 42 }, 0, 3);
322
323        let expected: String =
324            String::from(r#"{"type":"text_mention","offset":0,"length":3,"user":{"id":42}}"#);
325        let actual: String = serde_json::to_string(&entity).expect("entity should serialize");
326        assert_eq!(expected, actual);
327    }
328}