1use super::entity::{Entity, EntityKind, Style};
2use super::utf16::{utf16_len, utf16_len_rtrimmed};
3
4#[derive(Debug, Clone, Default, PartialEq, Eq)]
6struct Parts {
7 text: String,
8 entities: Vec<Entity>,
9}
10
11impl Parts {
12 fn push_styled(&mut self, text: &str, style: Style) {
17 let offset: usize = utf16_len(&self.text);
18 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 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 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#[derive(Debug, Clone, Default)]
74pub struct InlineBuilder {
75 parts: Parts,
76}
77
78impl InlineBuilder {
79 #[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 #[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 #[must_use]
95 pub fn bold(self, text: impl AsRef<str>) -> Self {
96 self.styled(text, Style::BOLD)
97 }
98
99 #[must_use]
101 pub fn italic(self, text: impl AsRef<str>) -> Self {
102 self.styled(text, Style::ITALIC)
103 }
104
105 #[must_use]
107 pub fn underline(self, text: impl AsRef<str>) -> Self {
108 self.styled(text, Style::UNDERLINE)
109 }
110
111 #[must_use]
113 pub fn strikethrough(self, text: impl AsRef<str>) -> Self {
114 self.styled(text, Style::STRIKETHROUGH)
115 }
116
117 #[must_use]
119 pub fn spoiler(self, text: impl AsRef<str>) -> Self {
120 self.styled(text, Style::SPOILER)
121 }
122
123 #[must_use]
125 pub fn code(self, text: impl AsRef<str>) -> Self {
126 self.styled(text, Style::CODE)
127 }
128
129 #[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 #[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 #[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 fn into_parts(self) -> Parts {
163 self.parts
164 }
165}
166
167#[derive(Debug, Clone, Default)]
174pub struct MessageBuilder {
175 parts: Parts,
176 media: Option<Media>,
177 options: SendOptions,
178}
179
180impl MessageBuilder {
181 #[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 #[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 #[must_use]
197 pub fn bold(self, text: impl AsRef<str>) -> Self {
198 self.styled(text, Style::BOLD)
199 }
200
201 #[must_use]
203 pub fn italic(self, text: impl AsRef<str>) -> Self {
204 self.styled(text, Style::ITALIC)
205 }
206
207 #[must_use]
209 pub fn underline(self, text: impl AsRef<str>) -> Self {
210 self.styled(text, Style::UNDERLINE)
211 }
212
213 #[must_use]
215 pub fn strikethrough(self, text: impl AsRef<str>) -> Self {
216 self.styled(text, Style::STRIKETHROUGH)
217 }
218
219 #[must_use]
221 pub fn spoiler(self, text: impl AsRef<str>) -> Self {
222 self.styled(text, Style::SPOILER)
223 }
224
225 #[must_use]
227 pub fn code(self, text: impl AsRef<str>) -> Self {
228 self.styled(text, Style::CODE)
229 }
230
231 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
300 pub fn photo(mut self, source: FileSource) -> Self {
301 self.media = Some(Media::Photo(source));
302 self
303 }
304
305 #[must_use]
307 pub fn document(mut self, source: FileSource) -> Self {
308 self.media = Some(Media::Document(source));
309 self
310 }
311
312 #[must_use]
314 pub const fn disable_notification(mut self, disable: bool) -> Self {
315 self.options.disable_notification = disable;
316 self
317 }
318
319 #[must_use]
321 pub const fn protect_content(mut self, protect: bool) -> Self {
322 self.options.protect_content = protect;
323 self
324 }
325
326 #[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 #[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 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
361pub enum FileSource {
362 Bytes {
367 filename: String,
369 bytes: Vec<u8>,
371 },
372 Url(String),
374 FileId(String),
376}
377
378impl FileSource {
379 #[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 #[must_use]
390 pub fn url(url: impl Into<String>) -> Self {
391 Self::Url(url.into())
392 }
393
394 #[must_use]
396 pub fn file_id(file_id: impl Into<String>) -> Self {
397 Self::FileId(file_id.into())
398 }
399}
400
401#[derive(Debug, Clone, PartialEq, Eq)]
403pub enum Media {
404 Photo(FileSource),
406 Document(FileSource),
408}
409
410impl Media {
411 #[must_use]
413 pub const fn source(&self) -> &FileSource {
414 match self {
415 Self::Photo(source) | Self::Document(source) => source,
416 }
417 }
418
419 #[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 #[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
440pub struct SendOptions {
441 pub disable_notification: bool,
443 pub protect_content: bool,
445 pub disable_link_preview: Option<bool>,
447 pub message_thread_id: Option<i64>,
449 pub reply_to_message_id: Option<i64>,
451}
452
453#[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 #[must_use]
465 pub fn builder() -> MessageBuilder {
466 MessageBuilder::default()
467 }
468
469 #[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 #[must_use]
482 pub fn as_text(&self) -> &str {
483 &self.text
484 }
485
486 #[must_use]
488 pub fn entities(&self) -> &[Entity] {
489 &self.entities
490 }
491
492 #[must_use]
494 pub const fn media(&self) -> Option<&Media> {
495 self.media.as_ref()
496 }
497
498 #[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 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 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 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}