Skip to main content

webserver_base/telegram/
message.rs

1use super::entity::{Entity, EntityKind, Style};
2use super::utf16::{utf16_len, utf16_len_rtrimmed};
3
4/// The accumulated text and entities of a message under construction.
5#[derive(Debug, Clone, Default, PartialEq, Eq)]
6struct Parts {
7    text: String,
8    entities: Vec<Entity>,
9}
10
11impl Parts {
12    /// Appends text carrying zero or more inline styles.
13    ///
14    /// Each style in the set produces its own entity over the same span, which
15    /// is how Telegram represents combined formatting.
16    fn push_styled(&mut self, text: &str, style: Style) {
17        let offset: usize = utf16_len(&self.text);
18        // Telegram requires entity lengths to exclude trailing whitespace.
19        let length: usize = utf16_len_rtrimmed(text);
20
21        self.text.push_str(text);
22
23        if length == 0 {
24            return;
25        }
26
27        for kind in style.kinds() {
28            self.entities.push(Entity::new(kind, offset, length));
29        }
30    }
31
32    /// Appends text carrying exactly one entity kind.
33    fn push_kind(&mut self, text: &str, kind: EntityKind) {
34        let offset: usize = utf16_len(&self.text);
35        let length: usize = utf16_len_rtrimmed(text);
36
37        self.text.push_str(text);
38
39        if length == 0 {
40            return;
41        }
42
43        self.entities.push(Entity::new(kind, offset, length));
44    }
45
46    /// Appends another set of parts, rebasing its entities, and wraps the
47    /// appended span in `wrapper`.
48    fn push_block(&mut self, inner: Self, wrapper: EntityKind) {
49        let offset: usize = utf16_len(&self.text);
50        let length: usize = utf16_len_rtrimmed(&inner.text);
51
52        for mut entity in inner.entities {
53            entity.offset += offset;
54            self.entities.push(entity);
55        }
56
57        self.text.push_str(&inner.text);
58
59        if length == 0 {
60            return;
61        }
62
63        self.entities.push(Entity::new(wrapper, offset, length));
64    }
65}
66
67/// Builds the contents of a block-level entity, such as a blockquote.
68///
69/// This deliberately exposes only *inline* formatting. Telegram documents that
70/// `pre` cannot be nested inside other entities, and nesting a blockquote in a
71/// blockquote is meaningless, so neither is reachable here — the restriction is
72/// enforced by the type system rather than discovered at runtime.
73#[derive(Debug, Clone, Default)]
74pub struct InlineBuilder {
75    parts: Parts,
76}
77
78impl InlineBuilder {
79    /// Appends unformatted text.
80    #[must_use]
81    pub fn text(mut self, text: impl AsRef<str>) -> Self {
82        self.parts.push_styled(text.as_ref(), Style::NONE);
83        self
84    }
85
86    /// Appends text carrying an arbitrary combination of inline styles.
87    #[must_use]
88    pub fn styled(mut self, text: impl AsRef<str>, style: Style) -> Self {
89        self.parts.push_styled(text.as_ref(), style);
90        self
91    }
92
93    /// Appends **bold** text.
94    #[must_use]
95    pub fn bold(self, text: impl AsRef<str>) -> Self {
96        self.styled(text, Style::BOLD)
97    }
98
99    /// Appends *italic* text.
100    #[must_use]
101    pub fn italic(self, text: impl AsRef<str>) -> Self {
102        self.styled(text, Style::ITALIC)
103    }
104
105    /// Appends underlined text.
106    #[must_use]
107    pub fn underline(self, text: impl AsRef<str>) -> Self {
108        self.styled(text, Style::UNDERLINE)
109    }
110
111    /// Appends struck-through text.
112    #[must_use]
113    pub fn strikethrough(self, text: impl AsRef<str>) -> Self {
114        self.styled(text, Style::STRIKETHROUGH)
115    }
116
117    /// Appends text hidden behind a spoiler.
118    #[must_use]
119    pub fn spoiler(self, text: impl AsRef<str>) -> Self {
120        self.styled(text, Style::SPOILER)
121    }
122
123    /// Appends `monospaced` inline code.
124    #[must_use]
125    pub fn code(self, text: impl AsRef<str>) -> Self {
126        self.styled(text, Style::CODE)
127    }
128
129    /// Appends text which links to a URL.
130    #[must_use]
131    pub fn link(mut self, text: impl AsRef<str>, url: impl Into<String>) -> Self {
132        self.parts
133            .push_kind(text.as_ref(), EntityKind::TextLink { url: url.into() });
134        self
135    }
136
137    /// Appends a mention of a user who has no username.
138    #[must_use]
139    pub fn mention(mut self, text: impl AsRef<str>, user_id: i64) -> Self {
140        self.parts
141            .push_kind(text.as_ref(), EntityKind::TextMention { user_id });
142        self
143    }
144
145    /// Appends an inline custom emoji sticker.
146    #[must_use]
147    pub fn custom_emoji(
148        mut self,
149        text: impl AsRef<str>,
150        custom_emoji_id: impl Into<String>,
151    ) -> Self {
152        self.parts.push_kind(
153            text.as_ref(),
154            EntityKind::CustomEmoji {
155                custom_emoji_id: custom_emoji_id.into(),
156            },
157        );
158        self
159    }
160
161    /// Consumes this builder, yielding its accumulated parts.
162    fn into_parts(self) -> Parts {
163        self.parts
164    }
165}
166
167/// Builds a [`Message`] from styled spans.
168///
169/// Formatting is expressed as Telegram *entities* rather than as `parse_mode`
170/// markup, which means interpolated text is never parsed and therefore never
171/// needs escaping. A filename, a username, or an arbitrary error string can be
172/// passed straight through with no sanitizing step.
173#[derive(Debug, Clone, Default)]
174pub struct MessageBuilder {
175    parts: Parts,
176    media: Option<Media>,
177    options: SendOptions,
178}
179
180impl MessageBuilder {
181    /// Appends unformatted text.
182    #[must_use]
183    pub fn text(mut self, text: impl AsRef<str>) -> Self {
184        self.parts.push_styled(text.as_ref(), Style::NONE);
185        self
186    }
187
188    /// Appends text carrying an arbitrary combination of inline styles.
189    #[must_use]
190    pub fn styled(mut self, text: impl AsRef<str>, style: Style) -> Self {
191        self.parts.push_styled(text.as_ref(), style);
192        self
193    }
194
195    /// Appends **bold** text.
196    #[must_use]
197    pub fn bold(self, text: impl AsRef<str>) -> Self {
198        self.styled(text, Style::BOLD)
199    }
200
201    /// Appends *italic* text.
202    #[must_use]
203    pub fn italic(self, text: impl AsRef<str>) -> Self {
204        self.styled(text, Style::ITALIC)
205    }
206
207    /// Appends underlined text.
208    #[must_use]
209    pub fn underline(self, text: impl AsRef<str>) -> Self {
210        self.styled(text, Style::UNDERLINE)
211    }
212
213    /// Appends struck-through text.
214    #[must_use]
215    pub fn strikethrough(self, text: impl AsRef<str>) -> Self {
216        self.styled(text, Style::STRIKETHROUGH)
217    }
218
219    /// Appends text hidden behind a spoiler.
220    #[must_use]
221    pub fn spoiler(self, text: impl AsRef<str>) -> Self {
222        self.styled(text, Style::SPOILER)
223    }
224
225    /// Appends `monospaced` inline code.
226    #[must_use]
227    pub fn code(self, text: impl AsRef<str>) -> Self {
228        self.styled(text, Style::CODE)
229    }
230
231    /// Appends text which links to a URL.
232    #[must_use]
233    pub fn link(mut self, text: impl AsRef<str>, url: impl Into<String>) -> Self {
234        self.parts
235            .push_kind(text.as_ref(), EntityKind::TextLink { url: url.into() });
236        self
237    }
238
239    /// Appends a mention of a user who has no username.
240    #[must_use]
241    pub fn mention(mut self, text: impl AsRef<str>, user_id: i64) -> Self {
242        self.parts
243            .push_kind(text.as_ref(), EntityKind::TextMention { user_id });
244        self
245    }
246
247    /// Appends an inline custom emoji sticker.
248    #[must_use]
249    pub fn custom_emoji(
250        mut self,
251        text: impl AsRef<str>,
252        custom_emoji_id: impl Into<String>,
253    ) -> Self {
254        self.parts.push_kind(
255            text.as_ref(),
256            EntityKind::CustomEmoji {
257                custom_emoji_id: custom_emoji_id.into(),
258            },
259        );
260        self
261    }
262
263    /// Appends a preformatted block, optionally tagged with a language.
264    ///
265    /// Only available at the top level: Telegram does not permit `pre` to be
266    /// nested inside other entities.
267    #[must_use]
268    pub fn pre(mut self, text: impl AsRef<str>, language: Option<&str>) -> Self {
269        self.parts.push_kind(
270            text.as_ref(),
271            EntityKind::Pre {
272                language: language.map(str::to_string),
273            },
274        );
275        self
276    }
277
278    /// Appends a block quotation containing inline-formatted content.
279    #[must_use]
280    pub fn blockquote(mut self, build: impl FnOnce(InlineBuilder) -> InlineBuilder) -> Self {
281        let inner: Parts = build(InlineBuilder::default()).into_parts();
282        self.parts.push_block(inner, EntityKind::Blockquote);
283        self
284    }
285
286    /// Appends a block quotation which is collapsed by default.
287    #[must_use]
288    pub fn expandable_blockquote(
289        mut self,
290        build: impl FnOnce(InlineBuilder) -> InlineBuilder,
291    ) -> Self {
292        let inner: Parts = build(InlineBuilder::default()).into_parts();
293        self.parts
294            .push_block(inner, EntityKind::ExpandableBlockquote);
295        self
296    }
297
298    /// Attaches a photo, making the accumulated text its caption.
299    #[must_use]
300    pub fn photo(mut self, source: FileSource) -> Self {
301        self.media = Some(Media::Photo(source));
302        self
303    }
304
305    /// Attaches a document, making the accumulated text its caption.
306    #[must_use]
307    pub fn document(mut self, source: FileSource) -> Self {
308        self.media = Some(Media::Document(source));
309        self
310    }
311
312    /// Delivers the message silently, without a notification sound.
313    #[must_use]
314    pub const fn disable_notification(mut self, disable: bool) -> Self {
315        self.options.disable_notification = disable;
316        self
317    }
318
319    /// Prevents the message from being forwarded or saved.
320    #[must_use]
321    pub const fn protect_content(mut self, protect: bool) -> Self {
322        self.options.protect_content = protect;
323        self
324    }
325
326    /// Controls whether a link in the message generates a preview card.
327    #[must_use]
328    pub const fn disable_link_preview(mut self, disable: bool) -> Self {
329        self.options.disable_link_preview = Some(disable);
330        self
331    }
332
333    /// Targets a specific topic within a forum chat.
334    #[must_use]
335    pub const fn message_thread_id(mut self, thread_id: i64) -> Self {
336        self.options.message_thread_id = Some(thread_id);
337        self
338    }
339
340    /// Sends the message as a reply to an existing one.
341    #[must_use]
342    pub const fn reply_to_message_id(mut self, message_id: i64) -> Self {
343        self.options.reply_to_message_id = Some(message_id);
344        self
345    }
346
347    /// Finalizes the message.
348    #[must_use]
349    pub fn build(self) -> Message {
350        Message {
351            text: self.parts.text,
352            entities: self.parts.entities,
353            media: self.media,
354            options: self.options,
355        }
356    }
357}
358
359/// Where the bytes of an uploaded photo or document come from.
360#[derive(Debug, Clone, PartialEq, Eq)]
361pub enum FileSource {
362    /// Raw bytes held in memory, uploaded as multipart form data.
363    ///
364    /// The natural fit for generated content — a rendered image which never
365    /// touches disk.
366    Bytes {
367        /// The filename Telegram should display.
368        filename: String,
369        /// The file's contents.
370        bytes: Vec<u8>,
371    },
372    /// An HTTPS URL which Telegram fetches itself, costing no upload bandwidth.
373    Url(String),
374    /// A file already uploaded to Telegram, re-sent by its identifier.
375    FileId(String),
376}
377
378impl FileSource {
379    /// Creates a [`FileSource::Bytes`].
380    #[must_use]
381    pub fn bytes(filename: impl Into<String>, bytes: impl Into<Vec<u8>>) -> Self {
382        Self::Bytes {
383            filename: filename.into(),
384            bytes: bytes.into(),
385        }
386    }
387
388    /// Creates a [`FileSource::Url`].
389    #[must_use]
390    pub fn url(url: impl Into<String>) -> Self {
391        Self::Url(url.into())
392    }
393
394    /// Creates a [`FileSource::FileId`].
395    #[must_use]
396    pub fn file_id(file_id: impl Into<String>) -> Self {
397        Self::FileId(file_id.into())
398    }
399}
400
401/// An attachment carried alongside a message's caption.
402#[derive(Debug, Clone, PartialEq, Eq)]
403pub enum Media {
404    /// A photo, displayed inline and re-encoded by Telegram.
405    Photo(FileSource),
406    /// A document, delivered as-is.
407    Document(FileSource),
408}
409
410impl Media {
411    /// The file this attachment carries.
412    #[must_use]
413    pub const fn source(&self) -> &FileSource {
414        match self {
415            Self::Photo(source) | Self::Document(source) => source,
416        }
417    }
418
419    /// The Bot API method which sends this attachment.
420    #[must_use]
421    pub(crate) const fn api_method(&self) -> &'static str {
422        match self {
423            Self::Photo(_) => "sendPhoto",
424            Self::Document(_) => "sendDocument",
425        }
426    }
427
428    /// The request field name which carries this attachment.
429    #[must_use]
430    pub(crate) const fn field_name(&self) -> &'static str {
431        match self {
432            Self::Photo(_) => "photo",
433            Self::Document(_) => "document",
434        }
435    }
436}
437
438/// Optional per-send parameters.
439#[derive(Debug, Clone, Default, PartialEq, Eq)]
440pub struct SendOptions {
441    /// Deliver silently, without a notification sound.
442    pub disable_notification: bool,
443    /// Prevent forwarding and saving.
444    pub protect_content: bool,
445    /// Whether to suppress link preview cards. `None` leaves Telegram's default.
446    pub disable_link_preview: Option<bool>,
447    /// Target a specific topic within a forum chat.
448    pub message_thread_id: Option<i64>,
449    /// Send as a reply to an existing message.
450    pub reply_to_message_id: Option<i64>,
451}
452
453/// A fully constructed message, ready to send.
454#[derive(Debug, Clone, PartialEq, Eq)]
455pub struct Message {
456    pub(crate) text: String,
457    pub(crate) entities: Vec<Entity>,
458    pub(crate) media: Option<Media>,
459    pub(crate) options: SendOptions,
460}
461
462impl Message {
463    /// Starts building a message.
464    #[must_use]
465    pub fn builder() -> MessageBuilder {
466        MessageBuilder::default()
467    }
468
469    /// Creates an unformatted, single-span message.
470    #[must_use]
471    pub fn text(text: impl Into<String>) -> Self {
472        Self {
473            text: text.into(),
474            entities: Vec::new(),
475            media: None,
476            options: SendOptions::default(),
477        }
478    }
479
480    /// The message's text, without any markup.
481    #[must_use]
482    pub fn as_text(&self) -> &str {
483        &self.text
484    }
485
486    /// The message's formatting entities.
487    #[must_use]
488    pub fn entities(&self) -> &[Entity] {
489        &self.entities
490    }
491
492    /// The message's attachment, if any.
493    #[must_use]
494    pub const fn media(&self) -> Option<&Media> {
495        self.media.as_ref()
496    }
497
498    /// The message's optional send parameters.
499    #[must_use]
500    pub const fn options(&self) -> &SendOptions {
501        &self.options
502    }
503}
504
505impl From<&str> for Message {
506    fn from(text: &str) -> Self {
507        Self::text(text)
508    }
509}
510
511impl From<String> for Message {
512    fn from(text: String) -> Self {
513        Self::text(text)
514    }
515}
516
517impl From<&String> for Message {
518    fn from(text: &String) -> Self {
519        Self::text(text.as_str())
520    }
521}
522
523#[cfg(test)]
524mod tests {
525    use super::{FileSource, Media, Message};
526    use crate::telegram::entity::{Entity, EntityKind, Style};
527
528    #[test]
529    fn plain_text_produces_no_entities() {
530        let message: Message = Message::from("Game over!");
531
532        let expected_text: &str = "Game over!";
533        let actual_text: &str = message.as_text();
534        assert_eq!(expected_text, actual_text);
535
536        let expected_entities: Vec<Entity> = Vec::new();
537        let actual_entities: Vec<Entity> = message.entities().to_vec();
538        assert_eq!(expected_entities, actual_entities);
539    }
540
541    #[test]
542    fn bold_span_gets_correct_offset_and_length() {
543        let message: Message = Message::builder().text("Hi ").bold("there").build();
544
545        let expected_text: &str = "Hi there";
546        let actual_text: &str = message.as_text();
547        assert_eq!(expected_text, actual_text);
548
549        let expected: Vec<Entity> = vec![Entity::new(EntityKind::Bold, 3, 5)];
550        let actual: Vec<Entity> = message.entities().to_vec();
551        assert_eq!(expected, actual);
552    }
553
554    #[test]
555    fn offsets_account_for_surrogate_pairs() {
556        // "🎨 " is 3 UTF-16 code units (2 for the emoji, 1 for the space),
557        // even though it is only 2 Rust chars.
558        let message: Message = Message::builder().text("🎨 ").bold("New").build();
559
560        let expected: Vec<Entity> = vec![Entity::new(EntityKind::Bold, 3, 3)];
561        let actual: Vec<Entity> = message.entities().to_vec();
562        assert_eq!(expected, actual);
563    }
564
565    #[test]
566    fn combined_styles_emit_one_entity_each_over_the_same_span() {
567        let message: Message = Message::builder()
568            .styled("wow", Style::BOLD | Style::ITALIC)
569            .build();
570
571        let expected: Vec<Entity> = vec![
572            Entity::new(EntityKind::Bold, 0, 3),
573            Entity::new(EntityKind::Italic, 0, 3),
574        ];
575        let actual: Vec<Entity> = message.entities().to_vec();
576        assert_eq!(expected, actual);
577    }
578
579    #[test]
580    fn entity_length_excludes_trailing_whitespace() {
581        // Telegram requires entities to be rtrimmed before their length is computed.
582        let message: Message = Message::builder().bold("Header\n\t").text("body").build();
583
584        let expected_text: &str = "Header\n\tbody";
585        let actual_text: &str = message.as_text();
586        assert_eq!(expected_text, actual_text);
587
588        let expected: Vec<Entity> = vec![Entity::new(EntityKind::Bold, 0, 6)];
589        let actual: Vec<Entity> = message.entities().to_vec();
590        assert_eq!(expected, actual);
591    }
592
593    #[test]
594    fn whitespace_only_span_produces_no_entity() {
595        let message: Message = Message::builder().bold("   ").text("x").build();
596
597        let expected: Vec<Entity> = Vec::new();
598        let actual: Vec<Entity> = message.entities().to_vec();
599        assert_eq!(expected, actual);
600    }
601
602    #[test]
603    fn untrusted_text_is_never_escaped_or_altered() {
604        // The entire point of the entities design: markup characters which
605        // would break MarkdownV2 are just ordinary text here.
606        let hostile: &str = r"*_[]()~`>#+-=|{}.!\ <b>&";
607        let message: Message = Message::builder().code(hostile).build();
608
609        let expected: String = hostile.to_string();
610        let actual: String = message.as_text().to_string();
611        assert_eq!(expected, actual);
612    }
613
614    #[test]
615    fn link_carries_its_url() {
616        let message: Message = Message::builder()
617            .link("View", "https://example.com")
618            .build();
619
620        let expected: Vec<Entity> = vec![Entity::new(
621            EntityKind::TextLink {
622                url: String::from("https://example.com"),
623            },
624            0,
625            4,
626        )];
627        let actual: Vec<Entity> = message.entities().to_vec();
628        assert_eq!(expected, actual);
629    }
630
631    #[test]
632    fn blockquote_wraps_its_inner_content_and_rebases_entities() {
633        let message: Message = Message::builder()
634            .text("before ")
635            .blockquote(|b| b.text("note: ").italic("generated"))
636            .build();
637
638        let expected_text: &str = "before note: generated";
639        let actual_text: &str = message.as_text();
640        assert_eq!(expected_text, actual_text);
641
642        let expected: Vec<Entity> = vec![
643            Entity::new(EntityKind::Italic, 13, 9),
644            Entity::new(EntityKind::Blockquote, 7, 15),
645        ];
646        let actual: Vec<Entity> = message.entities().to_vec();
647        assert_eq!(expected, actual);
648    }
649
650    #[test]
651    fn pre_records_its_language() {
652        let message: Message = Message::builder().pre("let x = 1;", Some("rust")).build();
653
654        let expected: Vec<Entity> = vec![Entity::new(
655            EntityKind::Pre {
656                language: Some(String::from("rust")),
657            },
658            0,
659            10,
660        )];
661        let actual: Vec<Entity> = message.entities().to_vec();
662        assert_eq!(expected, actual);
663    }
664
665    #[test]
666    fn photo_attaches_media_and_keeps_text_as_caption() {
667        let message: Message = Message::builder()
668            .bold("Pattern ready")
669            .photo(FileSource::url("https://example.com/p.png"))
670            .build();
671
672        let expected_text: &str = "Pattern ready";
673        let actual_text: &str = message.as_text();
674        assert_eq!(expected_text, actual_text);
675
676        let expected: Option<Media> = Some(Media::Photo(FileSource::Url(String::from(
677            "https://example.com/p.png",
678        ))));
679        let actual: Option<Media> = message.media().cloned();
680        assert_eq!(expected, actual);
681    }
682
683    #[test]
684    fn send_options_round_trip() {
685        let message: Message = Message::builder()
686            .text("quiet")
687            .disable_notification(true)
688            .protect_content(true)
689            .disable_link_preview(true)
690            .message_thread_id(7)
691            .reply_to_message_id(11)
692            .build();
693
694        assert!(message.options().disable_notification);
695        assert!(message.options().protect_content);
696
697        let expected_preview: Option<bool> = Some(true);
698        let actual_preview: Option<bool> = message.options().disable_link_preview;
699        assert_eq!(expected_preview, actual_preview);
700
701        let expected_thread: Option<i64> = Some(7);
702        let actual_thread: Option<i64> = message.options().message_thread_id;
703        assert_eq!(expected_thread, actual_thread);
704
705        let expected_reply: Option<i64> = Some(11);
706        let actual_reply: Option<i64> = message.options().reply_to_message_id;
707        assert_eq!(expected_reply, actual_reply);
708    }
709}