use std::path::{Path, PathBuf};
pub fn extract_img_markers(text: &str) -> (String, Vec<String>) {
extract_markers_with_prefix(text, "<<IMG:")
}
pub fn extract_vid_markers(text: &str) -> (String, Vec<String>) {
extract_markers_with_prefix(text, "<<VID:")
}
pub fn extract_react_marker(text: &str) -> (String, Option<String>) {
extract_react_marker_inner(text, true)
}
pub fn extract_react_marker_lenient(text: &str) -> (String, Option<String>) {
extract_react_marker_inner(text, false)
}
pub fn strip_invalid_react_markers(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut i = 0;
while i < text.len() {
let ch = text[i..].chars().next().expect("i lies on a char boundary");
if ch == '<'
&& let Some(open_len) = match_react_open(&text[i..])
&& let Some((rel_end, term_len)) = find_react_close(&text[i + open_len..])
{
let payload = text[i + open_len..i + open_len + rel_end].trim();
if !is_reaction_emoji(payload) {
i += open_len + rel_end + term_len; if out.ends_with('`') && text[i..].starts_with('`') {
out.pop();
i += 1;
}
if out.ends_with(' ') && text[i..].starts_with(' ') {
i += 1;
}
continue;
}
}
out.push(ch);
i += ch.len_utf8();
}
out.trim().to_string()
}
fn extract_react_marker_inner(
text_arg: &str,
respect_code_spans: bool,
) -> (String, Option<String>) {
let plain = scan_react_text(text_arg, respect_code_spans);
if plain.1.is_some() || !text_arg.contains('`') {
return plain;
}
match recover_orphan_fenced_directive(text_arg) {
Some((cleaned, emoji)) => (cleaned.trim().to_string(), Some(emoji)),
None => plain,
}
}
fn scan_react_text(text: &str, respect_code_spans: bool) -> (String, Option<String>) {
let mut out = String::with_capacity(text.len());
let mut emoji: Option<String> = None;
let mut in_code = false;
let mut i = 0;
while i < text.len() {
let ch = text[i..].chars().next().expect("i lies on a char boundary");
if ch == '`' {
in_code = !in_code;
out.push(ch);
i += 1;
continue;
}
if (!respect_code_spans || !in_code)
&& let Some(open_len) = match_react_open(&text[i..])
&& let Some((rel_end, term_len)) = find_react_close(&text[i + open_len..])
{
let payload = text[i + open_len..i + open_len + rel_end].trim();
if is_reaction_emoji(payload) {
if emoji.is_none() {
emoji = Some(payload.to_string());
}
i += open_len + rel_end + term_len; continue;
}
}
out.push(ch);
i += ch.len_utf8();
}
(out.trim().to_string(), emoji)
}
fn is_fence_junk_prefix(prefix: &str) -> bool {
let mut rest = prefix.trim_start_matches([' ', '\t', '\r', '\n']);
if let Some(r) = rest.strip_prefix('<').or_else(|| rest.strip_prefix('\\')) {
rest = r.trim_start_matches([' ', '\t', '\r', '\n']);
}
if let Some(r) = rest.strip_prefix("```") {
rest = r;
let tag_len = rest.chars().take_while(|c| c.is_alphanumeric()).count();
if tag_len > 12 {
return false;
}
rest = &rest[tag_len..];
}
rest.bytes()
.all(|b| matches!(b, b'`' | b'<' | b'\\' | b' ' | b'\t' | b'\r' | b'\n'))
}
fn recover_orphan_fenced_directive(text: &str) -> Option<(String, String)> {
if !text.contains('`') {
return None;
}
let scan_end = text
.char_indices()
.map(|(i, _)| i)
.chain(std::iter::once(text.len()))
.find(|&i| i >= 240)
.unwrap_or(text.len());
let mut found: Option<(usize, String)> = None;
for (i, ch) in text[..scan_end].char_indices() {
if ch != '<' {
continue;
}
if !is_fence_junk_prefix(&text[..i]) {
continue;
}
if let Some(open_len) = match_react_open(&text[i..])
&& let Some((rel_end, term_len)) = find_react_close(&text[i + open_len..])
&& is_reaction_emoji(text[i + open_len..i + open_len + rel_end].trim())
{
let emoji = text[i + open_len..i + open_len + rel_end]
.trim()
.to_string();
found = Some((i + open_len + rel_end + term_len, emoji));
break;
}
}
let (after, emoji) = found?;
let mut owned = text[after..].to_string();
if let Some(pos) = owned.rfind('\n')
&& owned[pos + 1..].trim_start().starts_with("```")
{
owned.truncate(pos);
}
Some((owned, emoji))
}
fn match_react_open(s: &str) -> Option<usize> {
let bytes = s.as_bytes();
if bytes.first() != Some(&b'<') {
return None;
}
let mut j = 1;
let mut angle_brackets = 1usize; while let Some(c) = bytes.get(j) {
match c {
b'<' => {
angle_brackets += 1;
j += 1;
}
b'\\' => j += 1,
_ => break,
}
}
const TAG: &str = "react:";
if s[j..].starts_with(TAG) {
Some(j + TAG.len())
} else if angle_brackets >= 2 {
Some(j)
} else {
None
}
}
fn find_react_close(s: &str) -> Option<(usize, usize)> {
const TERMS: [&str; 3] = [">>", "</react>", ">"];
let mut best: Option<(usize, usize)> = None;
for term in TERMS {
if let Some(pos) = s.find(term) {
let better = match best {
Some((bpos, blen)) => pos < bpos || (pos == bpos && term.len() > blen),
None => true,
};
if better {
best = Some((pos, term.len()));
}
}
}
best
}
fn is_reaction_emoji(payload: &str) -> bool {
!payload.is_empty() && payload.chars().count() <= 8 && payload.chars().all(|c| !c.is_ascii())
}
fn extract_markers_with_prefix(text: &str, prefix: &str) -> (String, Vec<String>) {
let mut out = text.to_string();
let mut paths = Vec::new();
let prefix_len = prefix.len();
while let Some(start) = out.find(prefix) {
let Some(rel_end) = out[start..].find(">>") else {
break;
};
let end = start + rel_end + 2; let path = out[start + prefix_len..start + rel_end].trim().to_string();
if !path.is_empty() {
paths.push(path);
}
out.replace_range(start..end, "");
}
(out.trim().to_string(), paths)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LocalImageFailureReason {
NotFound,
NotAFile,
Empty,
Unreadable,
TooLarge,
DeliveryFailed,
}
impl LocalImageFailureReason {
pub fn as_str(self) -> &'static str {
match self {
Self::NotFound => "file not found",
Self::NotAFile => "not a regular file",
Self::Empty => "file is empty (0 bytes)",
Self::Unreadable => "file could not be read",
Self::TooLarge => "larger than the size limit",
Self::DeliveryFailed => "the channel could not deliver it",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalImageFailure {
pub raw: String,
pub resolved: Option<PathBuf>,
pub reason: LocalImageFailureReason,
}
impl LocalImageFailure {
pub fn describe(&self) -> String {
format!("{} ({})", self.raw, self.reason.as_str())
}
}
pub fn code_regions(text: &str) -> Vec<bool> {
let mut regions = vec![false; text.len()];
let mut in_code = false;
for (i, byte) in text.bytes().enumerate() {
regions[i] = in_code;
if byte == b'`' {
in_code = !in_code;
}
}
regions
}
pub(crate) fn is_telegram_media_ref(raw: &str) -> bool {
let lower = raw.to_ascii_lowercase();
lower.starts_with("tg://") || lower.starts_with("attach://")
}
pub(crate) fn is_remote_url(raw: &str) -> bool {
const SCHEMES: [&str; 3] = ["http://", "https://", "data:"];
let lower = raw.to_ascii_lowercase();
SCHEMES.iter().any(|scheme| lower.starts_with(scheme))
}
fn parse_markdown_ref(
text: &str,
start: usize,
bracket_len: usize,
) -> Option<(usize, String, String, Option<String>)> {
let open = match bracket_len {
2 => "![",
1 => "[",
_ => return None,
};
debug_assert!(text[start..].starts_with(open));
if start > 0 && text[..start].ends_with('\\') {
return None;
}
let label_end = text[start + bracket_len..].find(']')?;
let paren = start + bracket_len + label_end + 1;
if !text[paren..].starts_with('(') {
return None;
}
let label = text[start + bracket_len..start + bracket_len + label_end].to_string();
let cursor = skip_whitespace(text, paren + 1);
let (target, mut after_target) = if text[cursor..].starts_with('<') {
let close = text[cursor + 1..].find('>')?;
let target = text[cursor + 1..cursor + 1 + close].to_string();
(target, cursor + 1 + close + 1)
} else {
let mut end = cursor;
while let Some(ch) = text[end..].chars().next() {
if ch.is_whitespace() || ch == ')' {
break;
}
end += ch.len_utf8();
}
(text[cursor..end].to_string(), end)
};
after_target = skip_whitespace(text, after_target);
let mut title: Option<String> = None;
if let Some(quote) = text[after_target..].chars().next()
&& (quote == '"' || quote == '\'')
{
let close = text[after_target + 1..].find(quote)?;
let parsed = text[after_target + 1..after_target + 1 + close].trim();
if !parsed.is_empty() {
title = Some(parsed.to_string());
}
after_target = skip_whitespace(text, after_target + 1 + close + 1);
}
if !text[after_target..].starts_with(')') || target.trim().is_empty() {
return None;
}
Some((after_target + 1, label, target, title))
}
fn skip_whitespace(text: &str, from: usize) -> usize {
let mut cursor = from;
while let Some(ch) = text[cursor..].chars().next() {
if !ch.is_whitespace() {
break;
}
cursor += ch.len_utf8();
}
cursor
}
fn has_url_scheme(raw: &str) -> bool {
let mut chars = raw.chars();
match chars.next() {
Some(first) if first.is_ascii_alphabetic() => {}
_ => return false,
}
for ch in chars {
if ch == ':' {
return true;
}
if !(ch.is_ascii_alphanumeric() || ch == '+' || ch == '-' || ch == '.') {
return false;
}
}
false
}
pub const TELEGRAM_DOCUMENT_MAX_BYTES: u64 = 50 * 1024 * 1024;
pub fn validate_local_file(path: &Path) -> Result<(), LocalImageFailureReason> {
let meta = match std::fs::metadata(path) {
Ok(meta) => meta,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Err(LocalImageFailureReason::NotFound);
}
Err(_) => return Err(LocalImageFailureReason::Unreadable),
};
if !meta.is_file() {
return Err(LocalImageFailureReason::NotAFile);
}
if meta.len() == 0 {
return Err(LocalImageFailureReason::Empty);
}
if meta.len() > TELEGRAM_DOCUMENT_MAX_BYTES {
return Err(LocalImageFailureReason::TooLarge);
}
std::fs::File::open(path).map_err(|_| LocalImageFailureReason::Unreadable)?;
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Resolution {
Local(PathBuf),
Rejected {
path: PathBuf,
reason: LocalImageFailureReason,
},
Skip,
}
fn resolve_file_target(target: &str, base_dir: Option<&Path>) -> Resolution {
let trimmed = target.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
return Resolution::Skip;
}
if is_remote_url(trimmed) || is_telegram_media_ref(trimmed) || has_url_scheme(trimmed) {
return Resolution::Skip;
}
let expanded = crate::brain::tools::error::expand_tilde(trimmed);
let path = if expanded.is_absolute() {
expanded
} else {
match base_dir {
Some(dir) => dir.join(expanded),
None => return Resolution::Skip,
}
};
match validate_local_file(&path) {
Ok(()) => Resolution::Local(path),
Err(reason) => Resolution::Rejected { path, reason },
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LocalFile {
pub path: PathBuf,
pub caption: Option<String>,
pub marker_span: Option<std::ops::Range<usize>>,
}
#[derive(Debug, Clone, Default)]
pub struct LocalFileScan {
pub text: String,
pub attachments: Vec<LocalFile>,
pub failures: Vec<LocalImageFailure>,
}
fn record_file_candidate(
raw: &str,
label: &str,
target: &str,
base_dir: Option<&Path>,
scan: &mut LocalFileScan,
) -> bool {
match resolve_file_target(target, base_dir) {
Resolution::Local(path) => {
let caption = if label.trim().is_empty() {
None
} else {
Some(label.to_string())
};
scan.attachments.push(LocalFile {
path,
caption,
marker_span: None,
});
true
}
Resolution::Rejected { path, reason } => {
scan.failures.push(LocalImageFailure {
raw: raw.to_string(),
resolved: Some(path),
reason,
});
false
}
Resolution::Skip => false,
}
}
fn is_markdown_file(path: &Path) -> bool {
matches!(
path.extension()
.and_then(|e| e.to_str())
.map(|e| e.to_ascii_lowercase())
.as_deref(),
Some("md") | Some("markdown")
)
}
fn disarm_autolink(text: &str) -> String {
const ZWSP: char = '\u{200b}';
let chars: Vec<char> = text.chars().collect();
let mut out = String::with_capacity(text.len() + 8);
for (i, &c) in chars.iter().enumerate() {
out.push(c);
if c == '.'
&& let Some(&next) = chars.get(i + 1)
&& next.is_ascii_alphanumeric()
{
out.push(ZWSP);
}
}
out
}
pub(crate) fn file_marker_text(label: &str, target: &str) -> String {
let trimmed = label.trim();
let text = if !trimmed.is_empty() {
trimmed.to_string()
} else {
std::path::Path::new(target)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "file".to_string())
};
disarm_autolink(&text)
}
fn consume_marker_prefix(buf: &mut String) {
loop {
let trimmed = buf.trim_end_matches([' ', '\t']);
if !trimmed.ends_with('\u{1F4CE}') {
break;
}
let cut = trimmed.len() - '\u{1F4CE}'.len_utf8();
buf.truncate(cut);
}
}
fn push_file_marker(out: &mut String, label: &str, target: &str) -> std::ops::Range<usize> {
consume_marker_prefix(out);
let start = out.len();
out.push_str("📎 ");
out.push_str(&file_marker_text(label, target));
start..out.len()
}
pub fn extract_local_files(text: &str, base_dir: Option<&Path>) -> LocalFileScan {
let regions = code_regions(text);
let mut scan = LocalFileScan {
text: String::with_capacity(text.len()),
..LocalFileScan::default()
};
let mut i = 0;
while i < text.len() {
if !regions[i]
&& text[i..].starts_with('[')
&& !text[..i].ends_with('!')
&& let Some((end, label, target, _title)) = parse_markdown_ref(text, i, 1)
&& record_file_candidate(&text[i..end], &label, &target, base_dir, &mut scan)
{
let span = push_file_marker(&mut scan.text, &label, &target);
if let Some(record) = scan.attachments.last_mut() {
record.marker_span = Some(span);
}
i = end;
continue;
}
let ch = text[i..].chars().next().expect("i lies on a char boundary");
scan.text.push(ch);
i += ch.len_utf8();
}
let lead = scan.text.len() - scan.text.trim_start().len();
if lead > 0 {
for record in &mut scan.attachments {
if let Some(span) = &mut record.marker_span {
span.start -= lead;
span.end -= lead;
}
}
}
scan.text = scan.text.trim().to_string();
scan
}
pub const DOC_ID_PREFIX: &str = "doc";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedFileRef {
pub id: String,
pub file: LocalFile,
}
#[derive(Debug, Clone, Default)]
pub struct LocalFileRewrite {
pub rich: String,
pub entries: Vec<ResolvedFileRef>,
}
struct FileRewriter<'a> {
base_dir: Option<&'a Path>,
id_prefix: &'a str,
already_delivered: &'a [PathBuf],
inline_markdown_documents: bool,
rich: String,
entries: Vec<ResolvedFileRef>,
}
impl FileRewriter<'_> {
fn file(&mut self, label: &str, target: &str) -> bool {
match resolve_file_target(target, self.base_dir) {
Resolution::Local(path) => {
if self.already_delivered.contains(&path) {
return true;
}
if !self.inline_markdown_documents && is_markdown_file(&path) {
push_file_marker(&mut self.rich, label, target);
return true;
}
let id = format!("{}{}", self.id_prefix, self.entries.len());
let caption = if label.trim().is_empty() {
None
} else {
Some(label.to_string())
};
push_document_ref(&mut self.rich, label, target, &id);
self.entries.push(ResolvedFileRef {
id,
file: LocalFile {
path,
caption,
marker_span: None,
},
});
}
Resolution::Rejected { .. } | Resolution::Skip => return false,
}
true
}
}
fn push_document_ref(out: &mut String, label: &str, target: &str, id: &str) {
consume_marker_prefix(out);
let alt = file_marker_text(label, target);
out.push_str(&format!(""));
}
pub(crate) fn probe_document_bytes(
attachments: &[LocalFile],
) -> (
std::collections::HashMap<PathBuf, Vec<u8>>,
Vec<LocalImageFailure>,
) {
let mut readable = std::collections::HashMap::new();
let mut failures = Vec::new();
for file in attachments {
match std::fs::read(&file.path) {
Ok(bytes) => {
readable.insert(file.path.clone(), bytes);
}
Err(e) => {
tracing::error!(
"Telegram: failed to read file {} for the rich array: {}",
file.path.display(),
e
);
failures.push(LocalImageFailure {
raw: file.path.display().to_string(),
resolved: Some(file.path.clone()),
reason: LocalImageFailureReason::Unreadable,
});
}
}
}
(readable, failures)
}
pub fn rewrite_local_files(
text: &str,
base_dir: Option<&Path>,
id_prefix: &str,
already_delivered: &[PathBuf],
inline_markdown_documents: bool,
) -> LocalFileRewrite {
let regions = code_regions(text);
let mut rw = FileRewriter {
base_dir,
id_prefix,
already_delivered,
inline_markdown_documents,
rich: String::with_capacity(text.len()),
entries: Vec::new(),
};
let mut i = 0;
while i < text.len() {
if !regions[i]
&& text[i..].starts_with('[')
&& !text[..i].ends_with('!')
&& let Some((end, label, target, _title)) = parse_markdown_ref(text, i, 1)
&& rw.file(&label, &target)
{
i = end;
continue;
}
let ch = text[i..].chars().next().expect("i lies on a char boundary");
rw.rich.push(ch);
i += ch.len_utf8();
}
LocalFileRewrite {
rich: rw.rich.trim().to_string(),
entries: rw.entries,
}
}
pub fn file_failure_notice(failures: &[LocalImageFailure]) -> Option<String> {
if failures.is_empty() {
return None;
}
let mut notice = String::from(
"⚠️ File not attached: the reply referenced a file that could not be delivered:",
);
for failure in failures {
notice.push_str("\n- ");
notice.push_str(&failure.describe());
}
Some(notice)
}
pub fn append_file_failure_notice(body: &str, failures: &[LocalImageFailure]) -> String {
match file_failure_notice(failures) {
None => body.to_string(),
Some(notice) => {
if body.trim().is_empty() {
notice
} else {
format!("{}\n\n{notice}", body.trim_end())
}
}
}
}