use super::send::message_in_thread;
use teloxide::prelude::*;
pub(crate) fn prepend_caption(caption: &str, body: String) -> String {
if caption.trim().is_empty() {
body
} else {
format!("{caption}\n\n{body}")
}
}
pub(crate) async fn archive_image_markers(
text: &str,
session_id: uuid::Uuid,
fs: &crate::services::FileService,
) -> String {
use std::path::PathBuf;
let mut replacements: Vec<(String, String)> = Vec::new();
let mut rest = text;
while let Some(start) = rest.find("<<IMG:") {
let after = &rest[start + 6..];
let Some(end) = after.find(">>") else { break };
let path = &after[..end];
if !path.starts_with("http")
&& let Ok(file) = fs
.get_or_create_file(session_id, PathBuf::from(path), None)
.await
{
let new = file.path.to_string_lossy().to_string();
if new != path {
replacements.push((path.to_string(), new));
}
}
rest = &after[end + 2..];
}
let mut out = text.to_string();
for (old, new) in replacements {
out = out.replace(&format!("<<IMG:{old}>>"), &format!("<<IMG:{new}>>"));
}
out
}
pub(crate) fn sanitize_filename_component(s: &str) -> String {
let cleaned: String = s
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '.' || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
let trimmed = cleaned.trim_matches('_');
if trimmed.is_empty() {
"file".to_string()
} else {
trimmed.to_string()
}
}
pub(crate) fn attachment_tmp_name(
original: Option<&str>,
fallback_stem: &str,
chat_id: i64,
ts: i64,
fallback_ext: &str,
) -> String {
let (stem, ext) = match original {
Some(name) => {
let ext = name
.rsplit('.')
.next()
.filter(|e| *e != name)
.unwrap_or(fallback_ext);
let stem = name.strip_suffix(&format!(".{ext}")).unwrap_or(name);
(sanitize_filename_component(stem), ext.to_string())
}
None => (fallback_stem.to_string(), fallback_ext.to_string()),
};
let ext = sanitize_filename_component(&ext);
format!("{stem}-{chat_id}-{ts}.{ext}")
}
pub(crate) fn migrate_flat_channel_attachments() {
let base = dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(".opencrabs")
.join("channel_attachments");
let moved = migrate_flat_attachments_in(&base, "telegram");
if moved > 0 {
tracing::info!("channel_attachments: migrated {moved} flat file(s) → telegram/");
}
}
pub(crate) fn migrate_flat_attachments_in(base: &std::path::Path, platform: &str) -> u32 {
let Ok(entries) = std::fs::read_dir(base) else {
return 0;
};
let target = base.join(platform);
let mut moved = 0u32;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(name) = path.file_name() else {
continue;
};
if let Err(e) = std::fs::create_dir_all(&target) {
tracing::warn!("channel_attachments migration: mkdir failed: {e}");
return moved;
}
let dest = target.join(name);
if dest.exists() {
continue; }
match std::fs::rename(&path, &dest) {
Ok(()) => moved += 1,
Err(e) => tracing::warn!("channel_attachments migration: rename failed: {e}"),
}
}
moved
}
pub(crate) async fn save_incoming_files_to_tmp(bot: &Bot, msg: &Message, bot_token: &str) {
use std::path::PathBuf;
if matches!(msg.chat.kind, teloxide::types::ChatKind::Private { .. }) {
return;
}
let tmp_dir: PathBuf = crate::config::opencrabs_home().join("tmp");
let _ = std::fs::create_dir_all(&tmp_dir);
let chat_id = msg.chat.id.0;
let ts = chrono::Utc::now().timestamp();
if let Some(voice) = msg.voice() {
save_telegram_file(
bot,
bot_token,
voice.file.id.clone(),
&tmp_dir,
&format!("voice-{chat_id}-{ts}.ogg"),
)
.await;
}
if let Some(vn) = msg.video_note() {
save_telegram_file(
bot,
bot_token,
vn.file.id.clone(),
&tmp_dir,
&format!("video_note-{chat_id}-{ts}.mp4"),
)
.await;
}
if let Some(doc) = msg.document() {
let name = attachment_tmp_name(doc.file_name.as_deref(), "doc", chat_id, ts, "bin");
save_telegram_file(bot, bot_token, doc.file.id.clone(), &tmp_dir, &name).await;
}
if let Some(audio) = msg.audio() {
let name = attachment_tmp_name(audio.file_name.as_deref(), "audio", chat_id, ts, "ogg");
save_telegram_file(bot, bot_token, audio.file.id.clone(), &tmp_dir, &name).await;
}
if let Some(largest) = msg.photo().and_then(|sizes| sizes.last()) {
save_telegram_file(
bot,
bot_token,
largest.file.id.clone(),
&tmp_dir,
&format!("photo-{chat_id}-{ts}.jpg"),
)
.await;
}
}
pub(crate) async fn save_telegram_file(
bot: &Bot,
bot_token: &str,
file_id: teloxide::types::FileId,
dir: &std::path::Path,
filename: &str,
) {
let file = match bot.get_file(file_id).await {
Ok(f) => f,
Err(e) => {
tracing::warn!("Telegram: tmp save: get_file failed: {e}");
return;
}
};
let url = format!("https://api.telegram.org/file/bot{bot_token}/{}", file.path);
let bytes = match reqwest::get(&url).await {
Ok(r) => match r.bytes().await {
Ok(b) => b,
Err(e) => {
tracing::warn!("Telegram: tmp save: read bytes failed: {e}");
return;
}
},
Err(e) => {
tracing::warn!("Telegram: tmp save: download failed: {e}");
return;
}
};
let path = dir.join(filename);
match std::fs::write(&path, &bytes) {
Ok(()) => tracing::info!("Telegram: saved incoming file → {}", path.display()),
Err(e) => tracing::warn!("Telegram: tmp save: write failed: {e}"),
}
}
pub(crate) fn find_recent_voice_in_tmp(
chat_id: i64,
max_age_secs: i64,
) -> Option<std::path::PathBuf> {
find_recent_tmp_file(chat_id, "voice", max_age_secs)
}
pub(crate) fn find_recent_tmp_file(
chat_id: i64,
kind: &str,
max_age_secs: i64,
) -> Option<std::path::PathBuf> {
use std::path::PathBuf;
let tmp_dir: PathBuf = crate::config::opencrabs_home().join("tmp");
let now = chrono::Utc::now().timestamp();
let prefix = format!("{kind}-{chat_id}-");
let mut best: Option<(i64, PathBuf)> = None;
let entries = std::fs::read_dir(&tmp_dir).ok()?;
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.starts_with(&prefix) {
continue;
}
let ts_str = name_str.strip_prefix(&prefix)?.split('.').next()?;
let ts: i64 = ts_str.parse().ok()?;
if now - ts > max_age_secs {
continue;
}
match &best {
Some((best_ts, _)) if ts <= *best_ts => {}
_ => best = Some((ts, entry.path())),
}
}
best.map(|(_, p)| p)
}
pub(crate) fn find_all_recent_tmp_files(
chat_id: i64,
kind: &str,
max_age_secs: i64,
) -> Vec<std::path::PathBuf> {
use std::path::PathBuf;
let tmp_dir: PathBuf = crate::config::opencrabs_home().join("tmp");
let now = chrono::Utc::now().timestamp();
let prefix = format!("{kind}-{chat_id}-");
let mut results: Vec<(i64, PathBuf)> = Vec::new();
let entries = match std::fs::read_dir(&tmp_dir) {
Ok(e) => e,
Err(_) => return Vec::new(),
};
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.starts_with(&prefix) {
continue;
}
let ts_str = match name_str
.strip_prefix(&prefix)
.and_then(|s| s.split('.').next())
{
Some(s) => s,
None => continue,
};
let ts: i64 = match ts_str.parse() {
Ok(t) => t,
Err(_) => continue,
};
if now - ts <= max_age_secs {
results.push((ts, entry.path()));
}
}
results.sort_by_key(|(ts, _)| *ts);
results.into_iter().map(|(_, p)| p).collect()
}
pub(crate) async fn fetch_file_or_notify(
bot: &Bot,
file_id: teloxide::types::FileId,
chat_id: ChatId,
thread_id: Option<teloxide::types::ThreadId>,
kind: &str,
) -> Option<teloxide::types::File> {
use teloxide::payloads::SendMessageSetters;
match bot.get_file(file_id.clone()).await {
Ok(f) => Some(f),
Err(e) => {
let s = e.to_string();
let reply = if s.contains("file is too big") {
format!(
"📎 That {kind} exceeds Telegram's 20 MB Bot API download limit. \
The chat itself accepts larger files but bots cannot fetch them. \
Please trim or compress the {kind} to under 20 MB and resend."
)
} else {
format!("Failed to fetch {kind}: {s}")
};
tracing::warn!("Telegram: get_file failed for {}: {}", kind, s);
if let Err(send_err) = message_in_thread(bot, chat_id, thread_id, reply)
.disable_notification(true)
.await
{
tracing::warn!(
"Telegram: failed to send size-limit reply to user: {}",
send_err
);
}
None
}
}
}