use crate::Role;
use crate::util::TELEGRAM_MEDIA_MARKER_RE;
use crate::util::html::decode_html_entities;
use regex::Captures;
use serde::{Deserialize, Serialize};
pub const REPLY_SNIPPET_MAX_CHARS: usize = 100;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ReplyReference {
pub author: String,
pub snippet: String,
}
#[must_use]
fn truncate_capped(input: &str, max_chars: usize) -> String {
if max_chars == 0 {
return String::new();
}
if input.chars().count() <= max_chars {
return input.to_string();
}
let byte_idx = input
.char_indices()
.nth(max_chars - 1)
.map_or(input.len(), |(idx, _)| idx);
format!("{}…", &input[..byte_idx])
}
#[must_use]
pub fn normalize_reply_text(raw: &str) -> String {
let mut text = decode_html_entities(raw);
let trimmed = text.trim();
let unwrapped: String =
if trimmed.starts_with("<blockquote>") && trimmed.ends_with("</blockquote>") {
let start = "<blockquote>".len();
let end = trimmed.len() - "</blockquote>".len();
&trimmed[start..end]
} else {
trimmed
}
.trim()
.to_string();
text = unwrapped;
text.retain(|c| c != '<' && c != '>');
text = text.replace("\r\n", " ").replace('\n', " ");
text = TELEGRAM_MEDIA_MARKER_RE
.replace_all(&text, |caps: &Captures| {
let kind = caps
.name("kind")
.expect("normalize_reply_text: media marker 'kind' group")
.as_str()
.to_ascii_uppercase();
match kind.as_str() {
"IMAGE" => "[Photo]".to_string(),
"AUDIO" => "[Voice message]".to_string(),
"VIDEO" => "[Video]".to_string(),
_ => unreachable!("media marker regex matches only IMAGE|AUDIO|VIDEO"),
}
})
.into_owned();
truncate_capped(&text, REPLY_SNIPPET_MAX_CHARS)
}
#[must_use]
fn format_reply_marker(reply: &ReplyReference) -> String {
format!(
"<reply-to>{}: {}</reply-to>:\n",
reply.author, reply.snippet
)
}
#[must_use]
pub fn apply_reply_marker(content: &str, reply: &ReplyReference) -> String {
format!("{}{content}", format_reply_marker(reply))
}
#[must_use]
pub fn strip_agent_role_suffix(agent_role: &str) -> &str {
agent_role
.rsplit_once('_')
.and_then(|(base, suffix)| {
if suffix.chars().all(|c| c.is_ascii_digit()) {
Some(base)
} else {
None
}
})
.unwrap_or(agent_role)
}
#[must_use]
pub fn agent_author_label(agent_role: Option<&str>) -> String {
agent_role
.map(strip_agent_role_suffix)
.and_then(|stripped| stripped.parse::<Role>().ok())
.map_or_else(
|| "Agent".to_string(),
|role| role.display_label().to_string(),
)
}
#[cfg(test)]
#[path = "reply_tests.rs"]
mod tests;