use super::entity::{Entity, EntityKind, Style};
use super::utf16::{utf16_len, utf16_len_rtrimmed};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct Parts {
text: String,
entities: Vec<Entity>,
}
impl Parts {
fn push_styled(&mut self, text: &str, style: Style) {
let offset: usize = utf16_len(&self.text);
let length: usize = utf16_len_rtrimmed(text);
self.text.push_str(text);
if length == 0 {
return;
}
for kind in style.kinds() {
self.entities.push(Entity::new(kind, offset, length));
}
}
fn push_kind(&mut self, text: &str, kind: EntityKind) {
let offset: usize = utf16_len(&self.text);
let length: usize = utf16_len_rtrimmed(text);
self.text.push_str(text);
if length == 0 {
return;
}
self.entities.push(Entity::new(kind, offset, length));
}
fn push_block(&mut self, inner: Self, wrapper: EntityKind) {
let offset: usize = utf16_len(&self.text);
let length: usize = utf16_len_rtrimmed(&inner.text);
for mut entity in inner.entities {
entity.offset += offset;
self.entities.push(entity);
}
self.text.push_str(&inner.text);
if length == 0 {
return;
}
self.entities.push(Entity::new(wrapper, offset, length));
}
}
#[derive(Debug, Clone, Default)]
pub struct InlineBuilder {
parts: Parts,
}
impl InlineBuilder {
#[must_use]
pub fn text(mut self, text: impl AsRef<str>) -> Self {
self.parts.push_styled(text.as_ref(), Style::NONE);
self
}
#[must_use]
pub fn styled(mut self, text: impl AsRef<str>, style: Style) -> Self {
self.parts.push_styled(text.as_ref(), style);
self
}
#[must_use]
pub fn bold(self, text: impl AsRef<str>) -> Self {
self.styled(text, Style::BOLD)
}
#[must_use]
pub fn italic(self, text: impl AsRef<str>) -> Self {
self.styled(text, Style::ITALIC)
}
#[must_use]
pub fn underline(self, text: impl AsRef<str>) -> Self {
self.styled(text, Style::UNDERLINE)
}
#[must_use]
pub fn strikethrough(self, text: impl AsRef<str>) -> Self {
self.styled(text, Style::STRIKETHROUGH)
}
#[must_use]
pub fn spoiler(self, text: impl AsRef<str>) -> Self {
self.styled(text, Style::SPOILER)
}
#[must_use]
pub fn code(self, text: impl AsRef<str>) -> Self {
self.styled(text, Style::CODE)
}
#[must_use]
pub fn link(mut self, text: impl AsRef<str>, url: impl Into<String>) -> Self {
self.parts
.push_kind(text.as_ref(), EntityKind::TextLink { url: url.into() });
self
}
#[must_use]
pub fn mention(mut self, text: impl AsRef<str>, user_id: i64) -> Self {
self.parts
.push_kind(text.as_ref(), EntityKind::TextMention { user_id });
self
}
#[must_use]
pub fn custom_emoji(
mut self,
text: impl AsRef<str>,
custom_emoji_id: impl Into<String>,
) -> Self {
self.parts.push_kind(
text.as_ref(),
EntityKind::CustomEmoji {
custom_emoji_id: custom_emoji_id.into(),
},
);
self
}
fn into_parts(self) -> Parts {
self.parts
}
}
#[derive(Debug, Clone, Default)]
pub struct MessageBuilder {
parts: Parts,
media: Option<Media>,
options: SendOptions,
}
impl MessageBuilder {
#[must_use]
pub fn text(mut self, text: impl AsRef<str>) -> Self {
self.parts.push_styled(text.as_ref(), Style::NONE);
self
}
#[must_use]
pub fn styled(mut self, text: impl AsRef<str>, style: Style) -> Self {
self.parts.push_styled(text.as_ref(), style);
self
}
#[must_use]
pub fn bold(self, text: impl AsRef<str>) -> Self {
self.styled(text, Style::BOLD)
}
#[must_use]
pub fn italic(self, text: impl AsRef<str>) -> Self {
self.styled(text, Style::ITALIC)
}
#[must_use]
pub fn underline(self, text: impl AsRef<str>) -> Self {
self.styled(text, Style::UNDERLINE)
}
#[must_use]
pub fn strikethrough(self, text: impl AsRef<str>) -> Self {
self.styled(text, Style::STRIKETHROUGH)
}
#[must_use]
pub fn spoiler(self, text: impl AsRef<str>) -> Self {
self.styled(text, Style::SPOILER)
}
#[must_use]
pub fn code(self, text: impl AsRef<str>) -> Self {
self.styled(text, Style::CODE)
}
#[must_use]
pub fn link(mut self, text: impl AsRef<str>, url: impl Into<String>) -> Self {
self.parts
.push_kind(text.as_ref(), EntityKind::TextLink { url: url.into() });
self
}
#[must_use]
pub fn mention(mut self, text: impl AsRef<str>, user_id: i64) -> Self {
self.parts
.push_kind(text.as_ref(), EntityKind::TextMention { user_id });
self
}
#[must_use]
pub fn custom_emoji(
mut self,
text: impl AsRef<str>,
custom_emoji_id: impl Into<String>,
) -> Self {
self.parts.push_kind(
text.as_ref(),
EntityKind::CustomEmoji {
custom_emoji_id: custom_emoji_id.into(),
},
);
self
}
#[must_use]
pub fn pre(mut self, text: impl AsRef<str>, language: Option<&str>) -> Self {
self.parts.push_kind(
text.as_ref(),
EntityKind::Pre {
language: language.map(str::to_string),
},
);
self
}
#[must_use]
pub fn blockquote(mut self, build: impl FnOnce(InlineBuilder) -> InlineBuilder) -> Self {
let inner: Parts = build(InlineBuilder::default()).into_parts();
self.parts.push_block(inner, EntityKind::Blockquote);
self
}
#[must_use]
pub fn expandable_blockquote(
mut self,
build: impl FnOnce(InlineBuilder) -> InlineBuilder,
) -> Self {
let inner: Parts = build(InlineBuilder::default()).into_parts();
self.parts
.push_block(inner, EntityKind::ExpandableBlockquote);
self
}
#[must_use]
pub fn photo(mut self, source: FileSource) -> Self {
self.media = Some(Media::Photo(source));
self
}
#[must_use]
pub fn document(mut self, source: FileSource) -> Self {
self.media = Some(Media::Document(source));
self
}
#[must_use]
pub const fn disable_notification(mut self, disable: bool) -> Self {
self.options.disable_notification = disable;
self
}
#[must_use]
pub const fn protect_content(mut self, protect: bool) -> Self {
self.options.protect_content = protect;
self
}
#[must_use]
pub const fn disable_link_preview(mut self, disable: bool) -> Self {
self.options.disable_link_preview = Some(disable);
self
}
#[must_use]
pub const fn message_thread_id(mut self, thread_id: i64) -> Self {
self.options.message_thread_id = Some(thread_id);
self
}
#[must_use]
pub const fn reply_to_message_id(mut self, message_id: i64) -> Self {
self.options.reply_to_message_id = Some(message_id);
self
}
#[must_use]
pub fn build(self) -> Message {
Message {
text: self.parts.text,
entities: self.parts.entities,
media: self.media,
options: self.options,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileSource {
Bytes {
filename: String,
bytes: Vec<u8>,
},
Url(String),
FileId(String),
}
impl FileSource {
#[must_use]
pub fn bytes(filename: impl Into<String>, bytes: impl Into<Vec<u8>>) -> Self {
Self::Bytes {
filename: filename.into(),
bytes: bytes.into(),
}
}
#[must_use]
pub fn url(url: impl Into<String>) -> Self {
Self::Url(url.into())
}
#[must_use]
pub fn file_id(file_id: impl Into<String>) -> Self {
Self::FileId(file_id.into())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Media {
Photo(FileSource),
Document(FileSource),
}
impl Media {
#[must_use]
pub const fn source(&self) -> &FileSource {
match self {
Self::Photo(source) | Self::Document(source) => source,
}
}
#[must_use]
pub(crate) const fn api_method(&self) -> &'static str {
match self {
Self::Photo(_) => "sendPhoto",
Self::Document(_) => "sendDocument",
}
}
#[must_use]
pub(crate) const fn field_name(&self) -> &'static str {
match self {
Self::Photo(_) => "photo",
Self::Document(_) => "document",
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SendOptions {
pub disable_notification: bool,
pub protect_content: bool,
pub disable_link_preview: Option<bool>,
pub message_thread_id: Option<i64>,
pub reply_to_message_id: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Message {
pub(crate) text: String,
pub(crate) entities: Vec<Entity>,
pub(crate) media: Option<Media>,
pub(crate) options: SendOptions,
}
impl Message {
#[must_use]
pub fn builder() -> MessageBuilder {
MessageBuilder::default()
}
#[must_use]
pub fn text(text: impl Into<String>) -> Self {
Self {
text: text.into(),
entities: Vec::new(),
media: None,
options: SendOptions::default(),
}
}
#[must_use]
pub fn as_text(&self) -> &str {
&self.text
}
#[must_use]
pub fn entities(&self) -> &[Entity] {
&self.entities
}
#[must_use]
pub const fn media(&self) -> Option<&Media> {
self.media.as_ref()
}
#[must_use]
pub const fn options(&self) -> &SendOptions {
&self.options
}
}
impl From<&str> for Message {
fn from(text: &str) -> Self {
Self::text(text)
}
}
impl From<String> for Message {
fn from(text: String) -> Self {
Self::text(text)
}
}
impl From<&String> for Message {
fn from(text: &String) -> Self {
Self::text(text.as_str())
}
}
#[cfg(test)]
mod tests {
use super::{FileSource, Media, Message};
use crate::telegram::entity::{Entity, EntityKind, Style};
#[test]
fn plain_text_produces_no_entities() {
let message: Message = Message::from("Game over!");
let expected_text: &str = "Game over!";
let actual_text: &str = message.as_text();
assert_eq!(expected_text, actual_text);
let expected_entities: Vec<Entity> = Vec::new();
let actual_entities: Vec<Entity> = message.entities().to_vec();
assert_eq!(expected_entities, actual_entities);
}
#[test]
fn bold_span_gets_correct_offset_and_length() {
let message: Message = Message::builder().text("Hi ").bold("there").build();
let expected_text: &str = "Hi there";
let actual_text: &str = message.as_text();
assert_eq!(expected_text, actual_text);
let expected: Vec<Entity> = vec![Entity::new(EntityKind::Bold, 3, 5)];
let actual: Vec<Entity> = message.entities().to_vec();
assert_eq!(expected, actual);
}
#[test]
fn offsets_account_for_surrogate_pairs() {
let message: Message = Message::builder().text("🎨 ").bold("New").build();
let expected: Vec<Entity> = vec![Entity::new(EntityKind::Bold, 3, 3)];
let actual: Vec<Entity> = message.entities().to_vec();
assert_eq!(expected, actual);
}
#[test]
fn combined_styles_emit_one_entity_each_over_the_same_span() {
let message: Message = Message::builder()
.styled("wow", Style::BOLD | Style::ITALIC)
.build();
let expected: Vec<Entity> = vec![
Entity::new(EntityKind::Bold, 0, 3),
Entity::new(EntityKind::Italic, 0, 3),
];
let actual: Vec<Entity> = message.entities().to_vec();
assert_eq!(expected, actual);
}
#[test]
fn entity_length_excludes_trailing_whitespace() {
let message: Message = Message::builder().bold("Header\n\t").text("body").build();
let expected_text: &str = "Header\n\tbody";
let actual_text: &str = message.as_text();
assert_eq!(expected_text, actual_text);
let expected: Vec<Entity> = vec![Entity::new(EntityKind::Bold, 0, 6)];
let actual: Vec<Entity> = message.entities().to_vec();
assert_eq!(expected, actual);
}
#[test]
fn whitespace_only_span_produces_no_entity() {
let message: Message = Message::builder().bold(" ").text("x").build();
let expected: Vec<Entity> = Vec::new();
let actual: Vec<Entity> = message.entities().to_vec();
assert_eq!(expected, actual);
}
#[test]
fn untrusted_text_is_never_escaped_or_altered() {
let hostile: &str = r"*_[]()~`>#+-=|{}.!\ <b>&";
let message: Message = Message::builder().code(hostile).build();
let expected: String = hostile.to_string();
let actual: String = message.as_text().to_string();
assert_eq!(expected, actual);
}
#[test]
fn link_carries_its_url() {
let message: Message = Message::builder()
.link("View", "https://example.com")
.build();
let expected: Vec<Entity> = vec![Entity::new(
EntityKind::TextLink {
url: String::from("https://example.com"),
},
0,
4,
)];
let actual: Vec<Entity> = message.entities().to_vec();
assert_eq!(expected, actual);
}
#[test]
fn blockquote_wraps_its_inner_content_and_rebases_entities() {
let message: Message = Message::builder()
.text("before ")
.blockquote(|b| b.text("note: ").italic("generated"))
.build();
let expected_text: &str = "before note: generated";
let actual_text: &str = message.as_text();
assert_eq!(expected_text, actual_text);
let expected: Vec<Entity> = vec![
Entity::new(EntityKind::Italic, 13, 9),
Entity::new(EntityKind::Blockquote, 7, 15),
];
let actual: Vec<Entity> = message.entities().to_vec();
assert_eq!(expected, actual);
}
#[test]
fn pre_records_its_language() {
let message: Message = Message::builder().pre("let x = 1;", Some("rust")).build();
let expected: Vec<Entity> = vec![Entity::new(
EntityKind::Pre {
language: Some(String::from("rust")),
},
0,
10,
)];
let actual: Vec<Entity> = message.entities().to_vec();
assert_eq!(expected, actual);
}
#[test]
fn photo_attaches_media_and_keeps_text_as_caption() {
let message: Message = Message::builder()
.bold("Pattern ready")
.photo(FileSource::url("https://example.com/p.png"))
.build();
let expected_text: &str = "Pattern ready";
let actual_text: &str = message.as_text();
assert_eq!(expected_text, actual_text);
let expected: Option<Media> = Some(Media::Photo(FileSource::Url(String::from(
"https://example.com/p.png",
))));
let actual: Option<Media> = message.media().cloned();
assert_eq!(expected, actual);
}
#[test]
fn send_options_round_trip() {
let message: Message = Message::builder()
.text("quiet")
.disable_notification(true)
.protect_content(true)
.disable_link_preview(true)
.message_thread_id(7)
.reply_to_message_id(11)
.build();
assert!(message.options().disable_notification);
assert!(message.options().protect_content);
let expected_preview: Option<bool> = Some(true);
let actual_preview: Option<bool> = message.options().disable_link_preview;
assert_eq!(expected_preview, actual_preview);
let expected_thread: Option<i64> = Some(7);
let actual_thread: Option<i64> = message.options().message_thread_id;
assert_eq!(expected_thread, actual_thread);
let expected_reply: Option<i64> = Some(11);
let actual_reply: Option<i64> = message.options().reply_to_message_id;
assert_eq!(expected_reply, actual_reply);
}
}