mirror of
https://github.com/kristoferssolo/tg-relay-rs.git
synced 2025-12-20 11:04:41 +00:00
test: add tests
This commit is contained in:
parent
248fe97991
commit
dd3b2b618b
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -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]]
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
112
src/comments.rs
112
src/comments.rs
@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
159
src/download.rs
159
src/download.rs
@ -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("ed_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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
14
src/error.rs
14
src/error.rs
@ -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>;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
93
src/utils.rs
93
src/utils.rs
@ -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
23
src/validate/mod.rs
Normal 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
10
src/validate/utils.rs
Normal 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)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user