refactor(handler): use a single Handler type

This commit is contained in:
Kristofers Solo 2025-10-27 12:56:20 +02:00
parent 90805674c6
commit 164b99888e
Signed by: kristoferssolo
GPG Key ID: 74FF8144483D82C8
9 changed files with 96 additions and 263 deletions

View File

@ -109,7 +109,7 @@ async fn run_command_in_tempdir(cmd: &str, args: &[&str]) -> Result<DownloadResu
/// ///
/// - Propagates `run_command_in_tempdir` errors. /// - Propagates `run_command_in_tempdir` errors.
#[cfg(feature = "instagram")] #[cfg(feature = "instagram")]
pub async fn download_instagram(url: &str) -> Result<DownloadResult> { pub async fn download_instagram(url: impl Into<String>) -> Result<DownloadResult> {
let base_args = ["--extractor-args", "instagram:"]; let base_args = ["--extractor-args", "instagram:"];
let mut args = base_args let mut args = base_args
.iter() .iter()
@ -133,7 +133,7 @@ pub async fn download_instagram(url: &str) -> Result<DownloadResult> {
/// ///
/// - Propagates `run_command_in_tempdir` errors. /// - Propagates `run_command_in_tempdir` errors.
#[cfg(feature = "tiktok")] #[cfg(feature = "tiktok")]
pub async fn download_tiktok(url: &str) -> Result<DownloadResult> { pub async fn download_tiktok(url: impl Into<String>) -> Result<DownloadResult> {
let base_args = ["--extractor-args", "tiktok:"]; let base_args = ["--extractor-args", "tiktok:"];
let mut args = base_args let mut args = base_args
.iter() .iter()
@ -157,8 +157,8 @@ pub async fn download_tiktok(url: &str) -> Result<DownloadResult> {
/// ///
/// - Propagates `run_command_in_tempdir` errors. /// - Propagates `run_command_in_tempdir` errors.
#[cfg(feature = "twitter")] #[cfg(feature = "twitter")]
pub async fn download_twitter(url: &str) -> Result<DownloadResult> { pub async fn download_twitter(url: impl Into<String>) -> Result<DownloadResult> {
let args = ["--extractor-args", "twitter:", url]; let args = ["--extractor-args", "twitter:", &url.into()];
run_command_in_tempdir("yt-dlp", &args).await run_command_in_tempdir("yt-dlp", &args).await
} }
@ -168,7 +168,7 @@ pub async fn download_twitter(url: &str) -> Result<DownloadResult> {
/// ///
/// - Propagates `run_command_in_tempdir` errors. /// - Propagates `run_command_in_tempdir` errors.
#[cfg(feature = "youtube")] #[cfg(feature = "youtube")]
pub async fn download_ytdlp(url: &str) -> Result<DownloadResult> { pub async fn download_youtube(url: impl Into<String>) -> Result<DownloadResult> {
let args = [ let args = [
"--no-playlist", "--no-playlist",
"-t", "-t",
@ -181,7 +181,7 @@ pub async fn download_ytdlp(url: &str) -> Result<DownloadResult> {
"-f", "-f",
"--postprocessor-args", "--postprocessor-args",
"ffmpeg:-vf setsar=1 -c:v libx264 -crf 28 -preset ultrafast -maxrate 800k -bufsize 1600k -vf scale=854:480 -c:a aac -b:a 64k -movflags +faststart", "ffmpeg:-vf setsar=1 -c:v libx264 -crf 28 -preset ultrafast -maxrate 800k -bufsize 1600k -vf scale=854:480 -c:a aac -b:a 64k -movflags +faststart",
url, &url.into(),
]; ];
run_command_in_tempdir("yt-dlp", &args).await run_command_in_tempdir("yt-dlp", &args).await

86
src/handler.rs Normal file
View File

@ -0,0 +1,86 @@
use crate::{
download::{DownloadResult, process_download_result},
error::Result,
};
use regex::{Error as RegexError, Regex};
use std::{pin::Pin, sync::Arc};
use teloxide::{Bot, types::ChatId};
use tracing::info;
#[derive(Debug, Clone)]
pub struct Handler {
name: &'static str,
regex: Regex,
handler_fn: fn(&str) -> Pin<Box<dyn Future<Output = Result<DownloadResult>> + Send>>,
}
impl Handler {
#[must_use]
pub fn new(
name: &'static str,
regex_pattern: &'static str,
handler_fn: fn(&str) -> Pin<Box<dyn Future<Output = Result<DownloadResult>> + Send>>,
) -> std::result::Result<Self, RegexError> {
let regex = Regex::new(regex_pattern)?;
Ok(Self {
name,
regex,
handler_fn,
})
}
#[inline]
#[must_use]
pub const fn name(&self) -> &'static str {
self.name
}
#[must_use]
pub fn try_extract(&self, text: &str) -> Option<String> {
self.regex
.captures(text)
.and_then(|c| c.get(0).map(|m| m.as_str().to_owned()))
}
pub async fn handle(&self, bot: &Bot, chat_id: ChatId, url: String) -> Result<()> {
info!(handler = %self.name(), url = %url, "handling url");
let dr = (self.handler_fn)(&url).await?;
process_download_result(bot, chat_id, dr).await
}
}
macro_rules! handler {
($feature:expr, $regex:expr, $download_fn:path) => {
#[cfg(feature = $feature)]
Handler::new($feature, $regex, |url| {
Box::pin($download_fn(url.to_string()))
})
.expect(concat!("failed to create ", $feature, " handler"))
};
}
pub fn create_handlers() -> Arc<[Handler]> {
[
handler!(
"instagram",
r"https?://(?:www\.)?(?:instagram\.com|instagr\.am)/(?:p|reel|tv)/([A-Za-z0-9_-]+)",
crate::download::download_instagram
),
handler!(
"youtube",
r"https?:\/\/(?:www\.)?youtube\.com\/shorts\/[A-Za-z0-9_-]+(?:\?[^\s]*)?",
crate::download::download_youtube
),
handler!(
"twitter",
r"https?://(?:www\.)?twitter\.com/([A-Za-z0-9_]+(?:/[A-Za-z0-9_]+)?)/status/(\d{1,20})",
crate::download::download_twitter
),
handler!(
"tiktok",
r"https?://(?:www\.)?(?:vm|vt|tt|tik)\.tiktok\.com/([A-Za-z0-9_-]+)[/?#]?",
crate::download::download_tiktok
),
]
.into()
}

View File

@ -1,46 +0,0 @@
use crate::download::{download_instagram, process_download_result};
use crate::error::Result;
use crate::handlers::SocialHandler;
use crate::lazy_regex;
use teloxide::{Bot, types::ChatId};
use tracing::info;
lazy_regex!(
URL_RE,
r#"https?://(?:www\.)?(?:instagram\.com|instagr\.am)/(?:p|reel|tv)/([A-Za-z0-9_-]+)"#
);
/// Handler for Instagram posts / reels / tv
#[derive(Clone, Default)]
pub struct InstagramHandler;
impl InstagramHandler {
#[inline]
#[must_use]
pub const fn new() -> Self {
Self
}
}
#[async_trait::async_trait]
impl SocialHandler for InstagramHandler {
fn name(&self) -> &'static str {
"instagram"
}
fn try_extract(&self, text: &str) -> Option<String> {
regex()
.captures(text)
.and_then(|c| c.get(0).map(|m| m.as_str().to_owned()))
}
async fn handle(&self, bot: &Bot, chat_id: ChatId, url: String) -> Result<()> {
info!(handler = %self.name(), url = %url, "handling instagram url");
let dr = download_instagram(&url).await?;
process_download_result(bot, chat_id, dr).await
}
fn box_clone(&self) -> Box<dyn SocialHandler> {
Box::new(self.clone())
}
}

View File

@ -1,53 +0,0 @@
#[cfg(feature = "instagram")]
mod instagram;
#[cfg(feature = "tiktok")]
mod tiktok;
#[cfg(feature = "twitter")]
mod twitter;
#[cfg(feature = "youtube")]
mod youtube;
use crate::error::Result;
use teloxide::{Bot, types::ChatId};
#[cfg(feature = "instagram")]
pub use instagram::InstagramHandler;
#[cfg(feature = "tiktok")]
pub use tiktok::TiktokHandler;
#[cfg(feature = "twitter")]
pub use twitter::TwitterHandler;
#[cfg(feature = "youtube")]
pub use youtube::YouTubeShortsHandler;
#[macro_export]
macro_rules! lazy_regex {
($name:ident, $pattern:expr) => {
static $name: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
fn regex() -> &'static regex::Regex {
$name.get_or_init(|| regex::Regex::new($pattern).expect("failed to compile regex"))
}
};
}
#[async_trait::async_trait]
pub trait SocialHandler: Send + Sync {
/// Short name used for logging etc.
fn name(&self) -> &'static str;
/// Try to extract a platform-specific identifier (shortcode, id, url)
/// from arbitrary text. Return `Some` if the handler should handle this message.
fn try_extract(&self, text: &str) -> Option<String>;
/// Do the heavy-lifting: fetch media and send to `chat_id`.
async fn handle(&self, bot: &Bot, chat_id: ChatId, id: String) -> Result<()>;
/// Clone a boxed handler.
fn box_clone(&self) -> Box<dyn SocialHandler>;
}
impl Clone for Box<dyn SocialHandler> {
fn clone(&self) -> Self {
self.box_clone()
}
}

View File

@ -1,49 +0,0 @@
use crate::{
download::{download_tiktok, process_download_result},
error::Result,
lazy_regex,
};
use teloxide::{Bot, types::ChatId};
use tracing::info;
use crate::handlers::SocialHandler;
lazy_regex!(
URL_RE,
r#"https?://(?:www\.)?(?:vm|vt|tt|tik)\.tiktok\.com/([A-Za-z0-9_-]+)[/?#]?"#
);
/// Handler for Tiktok
#[derive(Clone, Default)]
pub struct TiktokHandler;
impl TiktokHandler {
#[inline]
#[must_use]
pub const fn new() -> Self {
Self
}
}
#[async_trait::async_trait]
impl SocialHandler for TiktokHandler {
fn name(&self) -> &'static str {
"tiktok"
}
fn try_extract(&self, text: &str) -> Option<String> {
regex()
.captures(text)
.and_then(|c| c.get(0).map(|m| m.as_str().to_owned()))
}
async fn handle(&self, bot: &Bot, chat_id: ChatId, url: String) -> Result<()> {
info!(handler = %self.name(), url = %url, "handling tiktok url");
let dr = download_tiktok(&url).await?;
process_download_result(bot, chat_id, dr).await
}
fn box_clone(&self) -> Box<dyn SocialHandler> {
Box::new(self.clone())
}
}

View File

@ -1,49 +0,0 @@
use crate::{
download::{download_twitter, process_download_result},
error::Result,
lazy_regex,
};
use teloxide::{Bot, types::ChatId};
use tracing::info;
use crate::handlers::SocialHandler;
lazy_regex!(
URL_RE,
r#"https?://(?:www\.)?twitter\.com/([A-Za-z0-9_]+(?:/[A-Za-z0-9_]+)?)/status/(\d{1,20})"#
);
/// Handler for Tiktok
#[derive(Clone, Default)]
pub struct TwitterHandler;
impl TwitterHandler {
#[inline]
#[must_use]
pub const fn new() -> Self {
Self
}
}
#[async_trait::async_trait]
impl SocialHandler for TwitterHandler {
fn name(&self) -> &'static str {
"twitter"
}
fn try_extract(&self, text: &str) -> Option<String> {
regex()
.captures(text)
.and_then(|c| c.get(0).map(|m| m.as_str().to_owned()))
}
async fn handle(&self, bot: &Bot, chat_id: ChatId, url: String) -> Result<()> {
info!(handler = %self.name(), url = %url, "handling twitter url");
let dr = download_twitter(&url).await?;
process_download_result(bot, chat_id, dr).await
}
fn box_clone(&self) -> Box<dyn SocialHandler> {
Box::new(self.clone())
}
}

View File

@ -1,46 +0,0 @@
use crate::handlers::SocialHandler;
use crate::lazy_regex;
use crate::{
download::{download_ytdlp, process_download_result},
error::Result,
};
use teloxide::{Bot, types::ChatId};
use tracing::info;
lazy_regex!(
URL_RE,
r#"https?:\/\/(?:www\.)?youtube\.com\/shorts\/[A-Za-z0-9_-]+(?:\?[^\s]*)?"#
);
/// Handler for `YouTube Shorts` (and short youtu.be links)
#[derive(Clone, Default)]
pub struct YouTubeShortsHandler;
impl YouTubeShortsHandler {
#[inline]
#[must_use]
pub const fn new() -> Self {
Self
}
}
#[async_trait::async_trait]
impl SocialHandler for YouTubeShortsHandler {
fn name(&self) -> &'static str {
"youtube"
}
fn try_extract(&self, text: &str) -> Option<String> {
regex().find(text).map(|m| m.as_str().to_owned())
}
async fn handle(&self, bot: &Bot, chat_id: ChatId, url: String) -> Result<()> {
info!(handler = %self.name(), url = %url, "handling youtube url");
let dr = download_ytdlp(&url).await?;
process_download_result(bot, chat_id, dr).await
}
fn box_clone(&self) -> Box<dyn SocialHandler> {
Box::new(self.clone())
}
}

View File

@ -2,6 +2,6 @@ pub mod commands;
pub mod comments; pub mod comments;
pub mod download; pub mod download;
pub mod error; pub mod error;
pub mod handlers; pub mod handler;
pub mod telemetry; pub mod telemetry;
pub mod utils; pub mod utils;

View File

@ -1,10 +1,9 @@
use dotenv::dotenv; use dotenv::dotenv;
use std::sync::Arc;
use teloxide::{Bot, prelude::Requester, repls::CommandReplExt, respond, types::Message}; use teloxide::{Bot, prelude::Requester, repls::CommandReplExt, respond, types::Message};
use tg_relay_rs::{ use tg_relay_rs::{
commands::{Command, answer}, commands::{Command, answer},
comments::{Comments, init_global_comments}, comments::{Comments, init_global_comments},
handlers::SocialHandler, handler::create_handlers,
telemetry::setup_logger, telemetry::setup_logger,
}; };
use tracing::{error, info, warn}; use tracing::{error, info, warn};
@ -28,16 +27,7 @@ async fn main() -> color_eyre::Result<()> {
let bot = Bot::from_env(); let bot = Bot::from_env();
info!("bot starting"); info!("bot starting");
let handlers: Vec<Arc<dyn SocialHandler>> = vec![ let handlers = create_handlers();
#[cfg(feature = "instagram")]
Arc::new(tg_relay_rs::handlers::InstagramHandler),
#[cfg(feature = "youtube")]
Arc::new(tg_relay_rs::handlers::YouTubeShortsHandler),
#[cfg(feature = "tiktok")]
Arc::new(tg_relay_rs::handlers::TiktokHandler),
#[cfg(feature = "twitter")]
Arc::new(tg_relay_rs::handlers::TwitterHandler),
];
Command::repl(bot.clone(), answer).await; Command::repl(bot.clone(), answer).await;
@ -46,7 +36,7 @@ async fn main() -> color_eyre::Result<()> {
let handlers = handlers.clone(); let handlers = handlers.clone();
async move { async move {
if let Some(text) = msg.text() { if let Some(text) = msg.text() {
for handler in handlers { for handler in handlers.iter() {
if let Some(id) = handler.try_extract(text) { if let Some(id) = handler.try_extract(text) {
let handler = handler.clone(); let handler = handler.clone();
let bot_for_task = bot.clone(); let bot_for_task = bot.clone();