kcode-telegram-text-delivery 0.1.0

UTF-16-aware splitting and retried Telegram text delivery
Documentation
use teloxide::{
    payloads::SendMessageSetters,
    prelude::{Bot, Requester},
    requests::Request,
    types::{ChatId, Message, MessageId, ReplyParameters},
};

const TELEGRAM_MESSAGE_LIMIT: usize = 4_000;

pub async fn send_telegram_text(
    bot: &Bot,
    chat_id: i64,
    text: &str,
    reply_to_message_id: Option<i64>,
) -> Result<Vec<Message>, teloxide::RequestError> {
    let mut sent = Vec::new();
    for (index, chunk) in telegram_chunks(text, TELEGRAM_MESSAGE_LIMIT)
        .into_iter()
        .enumerate()
    {
        let mut request = bot.send_message(ChatId(chat_id), chunk);
        if index == 0
            && let Some(message_id) =
                reply_to_message_id.and_then(|value| i32::try_from(value).ok())
        {
            request = request.reply_parameters(
                ReplyParameters::new(MessageId(message_id)).allow_sending_without_reply(),
            );
        }
        let message =
            kcode_telegram_request_policy::retry_request("send_message", || request.clone().send())
                .await?;
        sent.push(message);
    }
    Ok(sent)
}

pub async fn send_telegram_message(
    bot: &Bot,
    chat_id: ChatId,
    text: impl Into<String>,
) -> Result<Message, teloxide::RequestError> {
    let request = bot.send_message(chat_id, text.into());
    kcode_telegram_request_policy::retry_request("send_message", || request.clone().send()).await
}

fn telegram_chunks(text: &str, max_utf16_units: usize) -> Vec<String> {
    assert!(max_utf16_units >= 2);
    if text.encode_utf16().count() <= max_utf16_units {
        return vec![text.to_owned()];
    }

    let mut chunks = Vec::new();
    let mut start = 0;
    let mut units = 0;
    for (index, character) in text.char_indices() {
        let character_units = character.len_utf16();
        if units > 0 && units + character_units > max_utf16_units {
            chunks.push(text[start..index].to_owned());
            start = index;
            units = 0;
        }
        units += character_units;
    }
    if start < text.len() {
        chunks.push(text[start..].to_owned());
    }
    chunks
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn splitting_preserves_exact_whitespace_and_bytes() {
        let text = format!("  {}\n{}\t  ", "a".repeat(10), "😀".repeat(10));
        let chunks = telegram_chunks(&text, 12);
        assert!(chunks.len() > 1);
        assert!(
            chunks
                .iter()
                .all(|chunk| chunk.encode_utf16().count() <= 12)
        );
        assert_eq!(chunks.concat().as_bytes(), text.as_bytes());
    }

    #[test]
    fn astral_characters_are_kept_whole_at_utf16_boundaries() {
        let text = format!("{}😀b", "a".repeat(3_999));
        let chunks = telegram_chunks(&text, TELEGRAM_MESSAGE_LIMIT);
        assert_eq!(chunks.len(), 2);
        assert_eq!(chunks[0].encode_utf16().count(), 3_999);
        assert_eq!(chunks[1], "😀b");
        assert_eq!(chunks[1].encode_utf16().count(), 3);
        assert_eq!(chunks.concat(), text);
    }

    #[test]
    fn an_astral_character_can_end_an_exact_utf16_chunk() {
        let text = format!("{}😀z", "a".repeat(3_998));
        let chunks = telegram_chunks(&text, TELEGRAM_MESSAGE_LIMIT);
        assert_eq!(chunks.len(), 2);
        assert_eq!(chunks[0].encode_utf16().count(), TELEGRAM_MESSAGE_LIMIT);
        assert!(chunks[0].ends_with('😀'));
        assert_eq!(chunks[1], "z");
        assert_eq!(chunks.concat(), text);
    }
}