test: add tests

This commit is contained in:
Kristofers Solo 2025-09-22 11:18:36 +03:00
parent 248fe97991
commit dd3b2b618b
Signed by: kristoferssolo
GPG Key ID: 74FF8144483D82C8
9 changed files with 331 additions and 85 deletions

2
Cargo.lock generated
View File

@ -1746,6 +1746,7 @@ dependencies = [
"rand", "rand",
"regex", "regex",
"serde", "serde",
"shlex",
"teloxide", "teloxide",
"tempfile", "tempfile",
"thiserror 2.0.16", "thiserror 2.0.16",
@ -1755,6 +1756,7 @@ dependencies = [
"tracing-bunyan-formatter", "tracing-bunyan-formatter",
"tracing-log 0.2.0", "tracing-log 0.2.0",
"tracing-subscriber", "tracing-subscriber",
"url",
] ]
[[package]] [[package]]

View File

@ -14,6 +14,7 @@ infer = "0.19"
rand = "0.9" rand = "0.9"
regex = "1.11" regex = "1.11"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
shlex = "1.3.0"
teloxide = { version = "0.17", features = ["macros"] } teloxide = { version = "0.17", features = ["macros"] }
tempfile = "3" tempfile = "3"
thiserror = "2.0" thiserror = "2.0"
@ -28,6 +29,7 @@ tracing-appender = "0.2"
tracing-bunyan-formatter = { version = "0.3", default-features = false } tracing-bunyan-formatter = { version = "0.3", default-features = false }
tracing-log = "0.2.0" tracing-log = "0.2.0"
tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] } tracing-subscriber = { version = "0.3", features = ["registry", "env-filter"] }
url = "2.5"
[lints.clippy] [lints.clippy]
pedantic = "warn" pedantic = "warn"

View File

@ -1,11 +1,20 @@
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use rand::{rng, seq::IndexedRandom}; use rand::{rng, seq::IndexedRandom};
use std::{ use std::{
fmt::Display,
path::Path, path::Path,
sync::{Arc, OnceLock}, sync::{Arc, OnceLock},
}; };
use tokio::fs::read_to_string; use tokio::fs::read_to_string;
static DISCLAIMER: &str = "(Roleplay — fictional messages for entertainment.)";
const DISCLAIMER: &str = "(Roleplay — fictional messages for entertainment.)";
pub const TELEGRAM_CAPTION_LIMIT: usize = 4096;
const FALLBACK_COMMENTS: &[&str] = &[
"Oh come on, that's brilliant — and slightly chaotic, like always.",
"That is a proper bit of craftsmanship — then someone presses the red button.",
"Nice shot — looks good on the trailer, not so good on the gearbox.",
"Here you go. Judge for yourself.",
];
#[derive(Debug)] #[derive(Debug)]
pub struct Comments { pub struct Comments {
@ -17,12 +26,10 @@ impl Comments {
/// Create a small dummy/default Comments instance (useful for tests or fallback). /// Create a small dummy/default Comments instance (useful for tests or fallback).
#[must_use] #[must_use]
pub fn dummy() -> Self { pub fn dummy() -> Self {
let lines = vec![ let lines = FALLBACK_COMMENTS
"Oh come on, that's brilliant — and slightly chaotic, like always.".into(), .iter()
"That is a proper bit of craftsmanship — then someone presses the red button.".into(), .map(ToString::to_string)
"Nice shot — looks good on the trailer, not so good on the gearbox.".into(), .collect::<Vec<_>>();
"Here you go. Judge for yourself.".into(),
];
Self { Self {
disclaimer: DISCLAIMER.into(), disclaimer: DISCLAIMER.into(),
lines: lines.into(), lines: lines.into(),
@ -33,14 +40,12 @@ impl Comments {
/// ///
/// # Errors /// # Errors
/// ///
/// - Returns `Error::Io` if reading the file fails (propagated from /// - Returns `Error::Io` if reading the file fails.
/// `tokio::fs::read_to_string`). /// - Returns `Error::Other` if the file contains no usable lines.
/// - Returns `Error::Other` if the file contains no usable lines after
/// filtering (empty or all-comment file).
pub async fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self> { pub async fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
let s = read_to_string(path).await?; let content = read_to_string(path).await?;
let lines = s let lines = content
.lines() .lines()
.map(str::trim) .map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#')) .filter(|l| !l.is_empty() && !l.starts_with('#'))
@ -57,20 +62,35 @@ impl Comments {
}) })
} }
/// Pick a random comment as &str (no allocation). Falls back to a small static /// Pick a random comment. Falls back to a default if the list is empty.
/// string if the list is unexpectedly empty.
#[must_use] #[must_use]
pub fn pick(&self) -> &str { pub fn pick(&self) -> &str {
let mut rng = rng(); let mut rng = rng();
self.lines self.lines
.choose(&mut rng) .choose(&mut rng)
.map_or("Here you go.", String::as_str) .map_or(FALLBACK_COMMENTS[0], AsRef::as_ref)
} }
/// Build a caption by picking a random comment and truncating if necessary.
#[must_use] #[must_use]
#[inline]
pub fn build_caption(&self) -> String { pub fn build_caption(&self) -> String {
self.pick().to_string() let mut caption = self.pick().to_string();
// Trancate if too long for Telegram
if caption.chars().count() > TELEGRAM_CAPTION_LIMIT {
let truncated = caption
.chars()
.take(TELEGRAM_CAPTION_LIMIT.saturating_sub(3))
.collect::<String>();
caption = format!("{truncated}...");
}
caption
}
/// Get a reference to the underlying lines for debugging or testing.
#[cfg(test)]
pub fn lines(&self) -> &[String] {
&self.lines
} }
} }
@ -80,8 +100,7 @@ static GLOBAL_COMMENTS: OnceLock<Comments> = OnceLock::new();
/// ///
/// # Errors /// # Errors
/// ///
/// - Returns `Error::Other` when the global is already initialized (the /// - Returns `Error::Other` when the global is already initialized.
/// underlying `OnceLock::set` fails).
pub fn init_global_comments(comments: Comments) -> Result<()> { pub fn init_global_comments(comments: Comments) -> Result<()> {
GLOBAL_COMMENTS GLOBAL_COMMENTS
.set(comments) .set(comments)
@ -89,6 +108,59 @@ pub fn init_global_comments(comments: Comments) -> Result<()> {
} }
/// Get global comments (if initialized). Returns Option<&'static Comments>. /// Get global comments (if initialized). Returns Option<&'static Comments>.
#[inline]
#[must_use]
pub fn global_comments() -> Option<&'static Comments> { pub fn global_comments() -> Option<&'static Comments> {
GLOBAL_COMMENTS.get() GLOBAL_COMMENTS.get()
} }
impl Display for Comments {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.build_caption())
}
}
impl From<Comments> for String {
fn from(value: Comments) -> Self {
value.to_string()
}
}
impl From<&Comments> for String {
fn from(value: &Comments) -> Self {
value.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dummy_comments() {
let comments = Comments::dummy();
assert_eq!(comments.lines.len(), FALLBACK_COMMENTS.len());
assert!(!comments.lines.is_empty());
}
#[test]
fn build_caption_truncation() {
let long_comment = "A".repeat(TELEGRAM_CAPTION_LIMIT + 10);
let comments = Comments {
disclaimer: DISCLAIMER.into(),
lines: Arc::new(vec![long_comment]),
};
let caption = comments.build_caption();
assert_eq!(caption.chars().count(), TELEGRAM_CAPTION_LIMIT);
assert!(caption.ends_with("..."))
}
#[test]
fn pick_fallbakc() {
let empty_comment = Comments {
disclaimer: DISCLAIMER.into(),
lines: Arc::new(Vec::new()),
};
assert_eq!(empty_comment.pick(), FALLBACK_COMMENTS[0]);
}
}

View File

@ -1,12 +1,25 @@
use crate::{ use crate::{
error::{Error, Result}, error::{Error, Result},
utils::{MediaKind, detect_media_kind_async, send_media_from_path}, utils::{
IMAGE_EXTSTENSIONS, MediaKind, VIDEO_EXTSTENSIONS, detect_media_kind_async,
send_media_from_path,
},
}; };
use futures::{StreamExt, stream}; use futures::{StreamExt, stream};
use std::{path::PathBuf, process::Stdio}; use std::{
cmp::min,
env,
ffi::OsStr,
fs::{self, metadata},
path::{Path, PathBuf},
process::Stdio,
};
use teloxide::{Bot, types::ChatId}; use teloxide::{Bot, types::ChatId};
use tempfile::{TempDir, tempdir}; use tempfile::{TempDir, tempdir};
use tokio::{fs::read_dir, process::Command}; use tokio::{fs::read_dir, process::Command};
use tracing::{info, warn};
const FORBIDDEN_EXTENSIONS: &[&str] = &["json", "txt", "log"];
/// `TempDir` guard + downloaded files. Keep this value alive until you're /// `TempDir` guard + downloaded files. Keep this value alive until you're
/// done sending files so the temporary directory is not deleted. /// done sending files so the temporary directory is not deleted.
@ -19,6 +32,8 @@ pub struct DownloadResult {
/// Run a command in a freshly created temporary directory and collect /// Run a command in a freshly created temporary directory and collect
/// regular files produced there. /// regular files produced there.
/// ///
/// # Arguments
///
/// `cmd` is the command name (e.g. "yt-dlp" or "instaloader"). /// `cmd` is the command name (e.g. "yt-dlp" or "instaloader").
/// `args` are the command arguments (owned Strings so callers can build dynamic args). /// `args` are the command arguments (owned Strings so callers can build dynamic args).
/// ///
@ -29,7 +44,7 @@ pub struct DownloadResult {
/// - `Error::NoMediaFound` if no files were produced. /// - `Error::NoMediaFound` if no files were produced.
#[allow(clippy::similar_names)] #[allow(clippy::similar_names)]
async fn run_command_in_tempdir(cmd: &str, args: &[&str]) -> Result<DownloadResult> { async fn run_command_in_tempdir(cmd: &str, args: &[&str]) -> Result<DownloadResult> {
let tmp = tempdir().map_err(Error::from)?; let tmp = tempdir()?;
let cwd = tmp.path().to_path_buf(); let cwd = tmp.path().to_path_buf();
let output = Command::new(cmd) let output = Command::new(cmd)
@ -39,27 +54,50 @@ async fn run_command_in_tempdir(cmd: &str, args: &[&str]) -> Result<DownloadResu
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::piped()) .stderr(Stdio::piped())
.output() .output()
.await .await?;
.map_err(Error::from)?;
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(Error::Other(format!("{cmd} failed: {stderr}")));
if stderr.is_empty() {
return Err(Error::Other(format!("{cmd} failed: {stderr}")));
}
let err = match cmd {
"instaloader" => Error::instaloader_failed(stderr),
"yt-dlp" => Error::ytdlp_failed(stderr),
_ => Error::Other(format!("{cmd} failed: {stderr}")),
};
return Err(err);
} }
// collect files produced in tempdir (async) // Collect files produced in tempdir (async)
let mut rd = read_dir(&cwd).await?; let mut rd = read_dir(&cwd).await?;
let mut files = Vec::new(); let mut files = Vec::new();
while let Some(entry) = rd.next_entry().await? { while let Some(entry) = rd.next_entry().await? {
if entry.file_type().await?.is_file() { let path = entry.path();
files.push(entry.path()); // Filter out non-media files (logs, metadata, etc.)
if is_potential_media_file(&path) {
files.push(path);
} }
} }
info!(files = files.len(), "Collected files from tempdir");
if files.is_empty() { if files.is_empty() {
let dir_contents = fs::read_dir(&cwd)
.map(|rd| {
rd.filter_map(std::result::Result::ok)
.map(|e| e.path())
.collect::<Vec<_>>()
})
.unwrap_or_default();
warn!(dir_contents = ?dir_contents, "No media files found in tempdir");
return Err(Error::NoMediaFound); return Err(Error::NoMediaFound);
} }
files.sort();
Ok(DownloadResult { Ok(DownloadResult {
tempdir: tmp, tempdir: tmp,
files, files,
@ -90,23 +128,32 @@ pub async fn download_instaloader(shortcode: &str) -> Result<DownloadResult> {
/// ///
/// - Propagates `run_command_in_tempdir` errors. /// - Propagates `run_command_in_tempdir` errors.
pub async fn download_ytdlp(url: &str, cookies: Option<&str>) -> Result<DownloadResult> { pub async fn download_ytdlp(url: &str, cookies: Option<&str>) -> Result<DownloadResult> {
let default_format = "bestvideo[ext=mp4][vcodec^=avc1]+bestaudio/best";
let format_selector = env::var("YTDLP_FORMAT").unwrap_or_else(|_| default_format.into());
let mut args = vec![ let mut args = vec![
"--no-playlist", "--no-playlist",
"--merge-output-format", "--merge-output-format",
"mp4", "mp4",
"-f", "-f",
"bestvideo[ext=mp4][vcodec^=avc1]+bestaudio/best", &format_selector,
"--restrict-filenames", "--restrict-filenames",
"-o", "-o",
"%(id)s.%(ext)s", "%(id)s.%(ext)s",
"--no-warnings",
"--quiet",
]; ];
if let Some(c) = cookies { if let Some(cookie_path) = cookies {
args.push("--cookies"); if Path::new(cookie_path).exists() {
args.push(c); args.extend(["--cookies", cookie_path]);
} else {
warn!("Cookies file not found: {cookie_path}");
}
} }
args.push(url); let quoted_url = shlex::try_quote(url)?;
args.push(&quoted_url);
run_command_in_tempdir("yt-dlp", &args).await run_command_in_tempdir("yt-dlp", &args).await
} }
@ -119,10 +166,20 @@ pub async fn download_ytdlp(url: &str, cookies: Option<&str>) -> Result<Download
/// # Errors /// # Errors
/// ///
/// - Propagates `send_media_from_path` errors or returns NoMediaFound/UnknownMediaKind. /// - Propagates `send_media_from_path` errors or returns NoMediaFound/UnknownMediaKind.
pub async fn process_download_result(bot: &Bot, chat_id: ChatId, dr: DownloadResult) -> Result<()> { pub async fn process_download_result(
// detect kinds in parallel bot: &Bot,
let concurrency = 8; chat_id: ChatId,
let results = stream::iter(dr.files.into_iter().map(|path| async move { mut dr: DownloadResult,
) -> Result<()> {
info!(files = dr.files.len(), "Processing download result");
if dr.files.is_empty() {
return Err(Error::NoMediaFound);
}
// Detect kinds in parallel with limiter concurrency
let concurrency = min(8, dr.files.len());
let results = stream::iter(dr.files.drain(..).map(|path| async move {
let kind = detect_media_kind_async(&path).await; let kind = detect_media_kind_async(&path).await;
match kind { match kind {
MediaKind::Unknown => None, MediaKind::Unknown => None,
@ -133,26 +190,76 @@ pub async fn process_download_result(bot: &Bot, chat_id: ChatId, dr: DownloadRes
.collect::<Vec<Option<(PathBuf, MediaKind)>>>() .collect::<Vec<Option<(PathBuf, MediaKind)>>>()
.await; .await;
let mut media = results let mut media_items = results
.into_iter() .into_iter()
.flatten() .flatten()
.collect::<Vec<(PathBuf, MediaKind)>>(); .filter(|(path, _)| {
metadata(path)
.map(|m| m.is_file() && m.len() > 0)
.unwrap_or(false)
})
.collect::<Vec<_>>();
if media.is_empty() { if media_items.is_empty() {
return Err(Error::NoMediaFound); return Err(Error::NoMediaFound);
} }
// deterministic ordering // deterministic ordering
media.sort_by_key(|(p, _)| p.clone()); media_items.sort_by(|(p1, _), (p2, _)| p1.cmp(p2));
info!(media_items = media_items.len(), "Sending media to chat");
// prefer video over image // prefer video over image
if let Some((path, MediaKind::Video)) = media.iter().find(|(_, k)| *k == MediaKind::Video) { if let Some((path, MediaKind::Video)) = media_items.iter().find(|(_, k)| *k == MediaKind::Video)
return send_media_from_path(bot, chat_id, path.clone(), Some(MediaKind::Video)).await; {
return send_media_from_path(bot, chat_id, path.clone(), MediaKind::Video).await;
} }
if let Some((path, MediaKind::Image)) = media.iter().find(|(_, k)| *k == MediaKind::Image) { if let Some((path, MediaKind::Image)) = media_items.iter().find(|(_, k)| *k == MediaKind::Image)
return send_media_from_path(bot, chat_id, path.clone(), Some(MediaKind::Image)).await; {
return send_media_from_path(bot, chat_id, path.clone(), MediaKind::Image).await;
} }
Err(Error::NoMediaFound) Err(Error::NoMediaFound)
} }
/// Filter function to determine if a file is potentially media based on name/extension.
fn is_potential_media_file(path: &Path) -> bool {
if let Some(filename) = path.file_name().and_then(OsStr::to_str) {
// Skip common non-media files
if filename.starts_with('.') || filename.to_lowercase().contains("metadata") {
return false;
}
}
let ext = match path.extension().and_then(OsStr::to_str) {
Some(e) => e.to_lowercase(),
None => return false,
};
if FORBIDDEN_EXTENSIONS
.iter()
.any(|forbidden| forbidden.eq_ignore_ascii_case(&ext))
{
return false;
}
VIDEO_EXTSTENSIONS
.iter()
.chain(IMAGE_EXTSTENSIONS.iter())
.any(|allowed| allowed.eq_ignore_ascii_case(&ext))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_potential_media_file_() {
assert!(is_potential_media_file(Path::new("video.mp4")));
assert!(is_potential_media_file(Path::new("image.jpg")));
assert!(!is_potential_media_file(Path::new(".DS_Store")));
assert!(!is_potential_media_file(Path::new("metadata.json")));
assert!(!is_potential_media_file(Path::new("download.log")));
}
}

View File

@ -17,12 +17,21 @@ pub enum Error {
#[error("unknown media kind")] #[error("unknown media kind")]
UnknownMediaKind, UnknownMediaKind,
#[error("validation failed: {0}")]
ValidationFailed(String),
#[error("teloxide error: {0}")] #[error("teloxide error: {0}")]
Teloxide(#[from] teloxide::RequestError), Teloxide(#[from] teloxide::RequestError),
#[error("join error: {0}")] #[error("join error: {0}")]
Join(#[from] tokio::task::JoinError), Join(#[from] tokio::task::JoinError),
#[error("rate limit exceeded")]
RateLimit,
#[error("")]
QuoteError(#[from] shlex::QuoteError),
#[error("other: {0}")] #[error("other: {0}")]
Other(String), Other(String),
} }
@ -42,6 +51,11 @@ impl Error {
pub fn ytdlp_failed(text: impl Into<String>) -> Self { pub fn ytdlp_failed(text: impl Into<String>) -> Self {
Self::YTDLPFailed(text.into()) Self::YTDLPFailed(text.into())
} }
#[inline]
pub fn validation_falied(text: impl Into<String>) -> Self {
Self::ValidationFailed(text.into())
}
} }
pub type Result<T> = std::result::Result<T, Error>; pub type Result<T> = std::result::Result<T, Error>;

View File

@ -4,3 +4,4 @@ pub mod error;
pub mod handlers; pub mod handlers;
pub mod telemetry; pub mod telemetry;
pub mod utils; pub mod utils;
pub mod validate;

View File

@ -1,5 +1,5 @@
use crate::{ use crate::{
comments::global_comments, comments::{Comments, global_comments},
error::{Error, Result}, error::{Error, Result},
}; };
use std::{ use std::{
@ -13,10 +13,10 @@ use teloxide::{
types::{ChatId, InputFile}, types::{ChatId, InputFile},
}; };
use tokio::{fs::File, io::AsyncReadExt}; use tokio::{fs::File, io::AsyncReadExt};
use tracing::warn;
const TELEGRAM_CAPTION_LIMIT: usize = 1024; pub const VIDEO_EXTSTENSIONS: &[&str] = &["mp4", "webm", "mov", "mkv", "avi", "m4v", "3gp"];
static VIDEO_EXTS: &[&str] = &["mp4", "webm", "mov", "mkv", "avi"]; pub const IMAGE_EXTSTENSIONS: &[&str] = &["jpg", "jpeg", "png", "webp", "gif", "bmp"];
static IMAGE_EXTS: &[&str] = &["jpg", "jpeg", "png", "webp"];
/// Simple media kind enum shared by handlers. /// Simple media kind enum shared by handlers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@ -27,26 +27,25 @@ pub enum MediaKind {
} }
/// Detect media kind first by extension, then by content/magic (sync). /// Detect media kind first by extension, then by content/magic (sync).
/// NOTE: `infer::get_from_path` is blocking — use `detect_media_kind_async` in
/// async contexts to avoid blocking the Tokio runtime.
pub fn detect_media_kind(path: &Path) -> MediaKind { pub fn detect_media_kind(path: &Path) -> MediaKind {
if let Some(ext) = path.extension().and_then(OsStr::to_str) { if let Some(ext) = path.extension().and_then(OsStr::to_str) {
if VIDEO_EXTS.iter().any(|e| e.eq_ignore_ascii_case(ext)) { let compare = |e: &&str| e.eq_ignore_ascii_case(ext);
if VIDEO_EXTSTENSIONS.iter().any(compare) {
return MediaKind::Video; return MediaKind::Video;
} }
if IMAGE_EXTS.iter().any(|e| e.eq_ignore_ascii_case(ext)) { if IMAGE_EXTSTENSIONS.iter().any(compare) {
return MediaKind::Image; return MediaKind::Image;
} }
} }
// Fallback to MIME type detection
if let Ok(Some(kind)) = infer::get_from_path(path) { if let Ok(Some(kind)) = infer::get_from_path(path) {
let mt = kind.mime_type(); let mime_type = kind.mime_type();
if mt.starts_with("video/") { return match mime_type.split('/').next() {
return MediaKind::Video; Some("video") => MediaKind::Video,
} Some("image") => MediaKind::Image,
if mt.starts_with("image/") { _ => MediaKind::Unknown,
return MediaKind::Image; };
}
} }
MediaKind::Unknown MediaKind::Unknown
@ -56,21 +55,24 @@ pub fn detect_media_kind(path: &Path) -> MediaKind {
/// sample asynchronously and run `infer::get` on the buffer. /// sample asynchronously and run `infer::get` on the buffer.
pub async fn detect_media_kind_async(path: &Path) -> MediaKind { pub async fn detect_media_kind_async(path: &Path) -> MediaKind {
if let Some(ext) = path.extension().and_then(OsStr::to_str) { if let Some(ext) = path.extension().and_then(OsStr::to_str) {
if VIDEO_EXTS.iter().any(|e| e.eq_ignore_ascii_case(ext)) { let compare = |e: &&str| e.eq_ignore_ascii_case(ext);
if VIDEO_EXTSTENSIONS.iter().any(compare) {
return MediaKind::Video; return MediaKind::Video;
} }
if IMAGE_EXTS.iter().any(|e| e.eq_ignore_ascii_case(ext)) { if IMAGE_EXTSTENSIONS.iter().any(compare) {
return MediaKind::Image; return MediaKind::Image;
} }
} }
// Read a small prefix (8 KiB) asynchronously and probe // Read a small prefix (8 KiB) asynchronously and probe
if let Ok(mut f) = File::open(path).await { match File::open(path).await {
let mut buf = vec![0u8; 8192]; Ok(mut file) => {
match f.read(&mut buf).await { let mut buffer = vec![0u8; 8192];
Ok(n) if n > 0 => { if let Ok(n) = file.read(&mut buffer).await
buf.truncate(n); && n > 0
if let Some(k) = infer::get(&buf) { {
buffer.truncate(n);
if let Some(k) = infer::get(&buffer) {
let mt = k.mime_type(); let mt = k.mime_type();
if mt.starts_with("video/") { if mt.starts_with("video/") {
return MediaKind::Video; return MediaKind::Video;
@ -80,8 +82,8 @@ pub async fn detect_media_kind_async(path: &Path) -> MediaKind {
} }
} }
} }
_ => {}
} }
Err(e) => warn!(path = ?path.display(), "Failed to read file for media detection: {e}"),
} }
MediaKind::Unknown MediaKind::Unknown
@ -96,18 +98,11 @@ pub async fn send_media_from_path(
bot: &Bot, bot: &Bot,
chat_id: ChatId, chat_id: ChatId,
path: PathBuf, path: PathBuf,
kind: Option<MediaKind>, kind: MediaKind,
) -> Result<()> { ) -> Result<()> {
let kind = kind.unwrap_or_else(|| detect_media_kind(&path)); let caption_opt = global_comments()
.map(Comments::build_caption)
let caption_opt = global_comments().map(|c| { .filter(|caption| !caption.is_empty());
let mut caption = c.build_caption();
if caption.chars().count() > TELEGRAM_CAPTION_LIMIT {
caption = caption.chars().take(TELEGRAM_CAPTION_LIMIT - 1).collect();
caption.push_str("...");
}
caption
});
match kind { match kind {
MediaKind::Video => { MediaKind::Video => {
@ -116,7 +111,7 @@ pub async fn send_media_from_path(
if let Some(c) = caption_opt { if let Some(c) = caption_opt {
req = req.caption(c); req = req.caption(c);
} }
req.await.map_err(Error::from)?; req.await?;
} }
MediaKind::Image => { MediaKind::Image => {
let photo = InputFile::file(path); let photo = InputFile::file(path);
@ -124,14 +119,34 @@ pub async fn send_media_from_path(
if let Some(c) = caption_opt { if let Some(c) = caption_opt {
req = req.caption(c); req = req.caption(c);
} }
req.await.map_err(Error::from)?; req.await?;
} }
MediaKind::Unknown => { MediaKind::Unknown => {
bot.send_message(chat_id, "No supported media found") bot.send_message(chat_id, "No supported media found")
.await .await?;
.map_err(Error::from)?;
return Err(Error::UnknownMediaKind); return Err(Error::UnknownMediaKind);
} }
} }
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_media_kind_by_extension() {
assert_eq!(detect_media_kind(Path::new("video.mp4")), MediaKind::Video);
assert_eq!(detect_media_kind(Path::new("image.jpg")), MediaKind::Image);
assert_eq!(
detect_media_kind(Path::new("unknown.txt")),
MediaKind::Unknown
);
}
#[test]
fn media_kind_case_insensitive() {
assert_eq!(detect_media_kind(Path::new("VIDEO.MP4")), MediaKind::Video);
assert_eq!(detect_media_kind(Path::new("IMAGE.JPG")), MediaKind::Image);
}
}

23
src/validate/mod.rs Normal file
View File

@ -0,0 +1,23 @@
pub mod utils;
use crate::error::Result;
use regex::Regex;
use std::sync::OnceLock;
/// Trait for validating platform-specific identifiers (e.g., shortcodes, URLs)
/// extracted from user input.
///
/// Implementors should:
/// - Check format (e.g., length, characters).
/// - Canonicalize if needed (e.g., trim query params from a URL).
/// - Return `Ok(canonical_id)` on success or `Err(Error::Other(...))` on failure.
pub trait Validate {
/// Validate the input and return a canonicalized String (e.g., cleaned shortcode or URL).
fn validate(&self, input: &str) -> Result<String>;
}
/// Helper function to create a lazy static Regex (reused across impls).
pub fn lazy_regex(pattern: &str) -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(pattern).expect("failed to compile validation regex"))
}

10
src/validate/utils.rs Normal file
View File

@ -0,0 +1,10 @@
use crate::error::{Error, Result};
/// Trims whitespace and rejects empty strings.
pub fn validate_non_empty(input: &str) -> Result<&str> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err(Error::validation_falied("input cannot be empty"));
}
Ok(trimmed)
}