feat: add config struct

This commit is contained in:
Kristofers Solo 2025-10-28 17:55:13 +02:00
parent fee8178ad2
commit 9a6e8bbefc
Signed by: kristoferssolo
GPG Key ID: 8687F2D3EEE6F0ED
9 changed files with 178 additions and 79 deletions

1
Cargo.lock generated
View File

@ -1739,7 +1739,6 @@ dependencies = [
"infer", "infer",
"rand", "rand",
"regex", "regex",
"serde",
"teloxide", "teloxide",
"tempfile", "tempfile",
"thiserror 2.0.16", "thiserror 2.0.16",

View File

@ -14,7 +14,6 @@ futures = "0.3"
infer = "0.19" infer = "0.19"
rand = "0.9" rand = "0.9"
regex = "1.11" regex = "1.11"
serde = { version = "1.0", features = ["derive"] }
teloxide = { version = "0.17", features = ["macros"] } teloxide = { version = "0.17", features = ["macros"] }
tempfile = "3" tempfile = "3"
thiserror = "2.0" thiserror = "2.0"

View File

@ -1,7 +1,6 @@
use crate::comments::global_comments;
use teloxide::{prelude::*, utils::command::BotCommands}; use teloxide::{prelude::*, utils::command::BotCommands};
use crate::comments::{Comments, global_comments};
#[derive(BotCommands, Clone)] #[derive(BotCommands, Clone)]
#[command(rename_rule = "lowercase")] #[command(rename_rule = "lowercase")]
pub enum Command { pub enum Command {
@ -25,9 +24,8 @@ pub async fn answer(bot: Bot, msg: Message, cmd: Command) -> ResponseResult<()>
.await? .await?
} }
Command::Curse => { Command::Curse => {
let comment = global_comments().map(Comments::build_caption); let comment = global_comments().build_caption();
bot.send_message(msg.chat.id, comment.unwrap_or_else(|| "To comment".into())) bot.send_message(msg.chat.id, comment).await?
.await?
} }
}; };

View File

@ -7,6 +7,8 @@ use std::{
}; };
use tokio::fs::read_to_string; use tokio::fs::read_to_string;
static GLOBAL_COMMENTS: OnceLock<Comments> = OnceLock::new();
const DISCLAIMER: &str = "(Roleplay — fictional messages for entertainment.)"; const DISCLAIMER: &str = "(Roleplay — fictional messages for entertainment.)";
pub const TELEGRAM_CAPTION_LIMIT: usize = 4096; pub const TELEGRAM_CAPTION_LIMIT: usize = 4096;
const FALLBACK_COMMENTS: &[&str] = &[ const FALLBACK_COMMENTS: &[&str] = &[
@ -92,26 +94,28 @@ impl Comments {
pub fn lines(&self) -> &[String] { pub fn lines(&self) -> &[String] {
&self.lines &self.lines
} }
/// Initialize the global comments (call once at startup).
///
/// # Errors
///
/// Returns `Error::Other` when the global is already initialized.
pub fn init(self) -> Result<()> {
GLOBAL_COMMENTS
.set(self)
.map_err(|_| Error::other("comments already initialized"))
}
} }
static GLOBAL_COMMENTS: OnceLock<Comments> = OnceLock::new(); /// Get global comments (initialized by `Comments::init(self)`).
/// Initialize the global comments (call once at startup).
/// ///
/// # Errors /// # Panics
/// ///
/// - Returns `Error::Other` when the global is already initialized. /// Panics if comments have not been initialized.
pub fn init_global_comments(comments: Comments) -> Result<()> {
GLOBAL_COMMENTS
.set(comments)
.map_err(|_| Error::other("comments already initialized"))
}
/// Get global comments (if initialized).
#[inline] #[inline]
#[must_use] #[must_use]
pub fn global_comments() -> Option<&'static Comments> { pub fn global_comments() -> &'static Comments {
GLOBAL_COMMENTS.get() GLOBAL_COMMENTS.get().expect("comments not initialized")
} }
impl Display for Comments { impl Display for Comments {

111
src/config.rs Normal file
View File

@ -0,0 +1,111 @@
use crate::error::{Error, Result};
use std::{env, fmt::Debug, path::PathBuf, sync::OnceLock};
static GLOBAL_CONFIG: OnceLock<Config> = OnceLock::new();
#[derive(Debug, Clone, Default)]
pub struct Config {
pub youtube: YoutubeConfig,
pub instagram: InstagramConfig,
pub tiktok: TiktokConfig,
pub twitter: TwitterConfig,
}
#[derive(Debug, Clone)]
pub struct YoutubeConfig {
pub cookies_path: Option<PathBuf>,
pub postprocessor_args: String,
}
#[derive(Debug, Clone, Default)]
pub struct InstagramConfig {
pub cookies_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Default)]
pub struct TiktokConfig {
pub cookies_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Default)]
pub struct TwitterConfig {
pub cookies_path: Option<PathBuf>,
}
impl Config {
/// Load configuration from environment variables.
#[must_use]
pub fn from_env() -> Self {
Self {
youtube: YoutubeConfig::from_env(),
instagram: InstagramConfig::from_env(),
tiktok: TiktokConfig::from_env(),
twitter: TwitterConfig::from_env(),
}
}
/// Initialize the global config (call once at startup).
///
/// # Errors
///
/// Returns error if config is already initialized.
pub fn init(self) -> Result<()> {
GLOBAL_CONFIG
.set(self)
.map_err(|_| Error::other("config already initialized"))
}
}
/// Get global config (initialized by `Config::init(self)`).
#[must_use]
pub fn global_config() -> Config {
GLOBAL_CONFIG.get().cloned().unwrap_or_default()
}
impl YoutubeConfig {
const DEFAULT_POSTPROCESSOR_ARGS: &'static str = "ffmpeg:-vf setsar=1 -c:v libx264 -crf 20 -preset ultrafast -c:a aac -b:a 128k -movflags +faststart";
fn from_env() -> Self {
Self {
cookies_path: get_path_from_env("YOUTUBE_SESSION_COOKIE_PATH"),
postprocessor_args: env::var("YOUTUBE_POSTPROCESSOR_ARGS")
.unwrap_or_else(|_| Self::DEFAULT_POSTPROCESSOR_ARGS.to_string()),
}
}
}
impl InstagramConfig {
fn from_env() -> Self {
Self {
cookies_path: get_path_from_env("IG_SESSION_COOKIE_PATH"),
}
}
}
impl TiktokConfig {
fn from_env() -> Self {
Self {
cookies_path: get_path_from_env("TIKTOK_SESSION_COOKIE_PATH"),
}
}
}
impl TwitterConfig {
fn from_env() -> Self {
Self {
cookies_path: get_path_from_env("TWITTER_SESSION_COOKIE_PATH"),
}
}
}
fn get_path_from_env(key: &str) -> Option<PathBuf> {
env::var(key).ok().map(PathBuf::from)
}
impl Default for YoutubeConfig {
fn default() -> Self {
Self {
cookies_path: None,
postprocessor_args: Self::DEFAULT_POSTPROCESSOR_ARGS.into(),
}
}
}

View File

@ -1,3 +1,4 @@
use crate::config::global_config;
use crate::{ use crate::{
error::{Error, Result}, error::{Error, Result},
utils::{ utils::{
@ -8,7 +9,6 @@ use crate::{
use futures::{StreamExt, stream}; use futures::{StreamExt, stream};
use std::{ use std::{
cmp::min, cmp::min,
env,
ffi::OsStr, ffi::OsStr,
fs::{self, metadata}, fs::{self, metadata},
path::{Path, PathBuf}, path::{Path, PathBuf},
@ -105,21 +105,12 @@ 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: impl Into<String>) -> Result<DownloadResult> { pub async fn download_instagram(url: impl Into<String>) -> Result<DownloadResult> {
let base_args = ["-t", "mp4", "--extractor-args", "instagram:"]; let config = global_config();
let mut args = base_args let args = ["-t", "mp4", "--extractor-args", "instagram:"]
.iter() .iter()
.map(ToString::to_string) .map(ToString::to_string)
.collect::<Vec<_>>(); .collect();
run_yt_dlp(args, config.instagram.cookies_path.as_ref(), &url.into()).await
if let Ok(cookies_path) = env::var("IG_SESSION_COOKIE_PATH") {
args.extend(["--cookies".into(), cookies_path]);
}
args.push(url.into());
let args_ref = args.iter().map(String::as_ref).collect::<Vec<_>>();
run_command_in_tempdir("yt-dlp", &args_ref).await
} }
/// Download a Tiktok URL with yt-dlp. /// Download a Tiktok URL with yt-dlp.
@ -129,21 +120,12 @@ pub async fn download_instagram(url: impl Into<String>) -> 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: impl Into<String>) -> Result<DownloadResult> { pub async fn download_tiktok(url: impl Into<String>) -> Result<DownloadResult> {
let base_args = ["-t", "mp4", "--extractor-args", "tiktok:"]; let config = global_config();
let mut args = base_args let args = ["-t", "mp4", "--extractor-args", "tiktok:"]
.iter() .iter()
.map(ToString::to_string) .map(ToString::to_string)
.collect::<Vec<_>>(); .collect();
run_yt_dlp(args, config.tiktok.cookies_path.as_ref(), &url.into()).await
if let Ok(cookies_path) = env::var("TIKTOK_SESSION_COOKIE_PATH") {
args.extend(["--cookies".into(), cookies_path]);
}
args.push(url.into());
let args_ref = args.iter().map(String::as_ref).collect::<Vec<_>>();
run_command_in_tempdir("yt-dlp", &args_ref).await
} }
/// Download a Twitter URL with yt-dlp. /// Download a Twitter URL with yt-dlp.
@ -153,8 +135,12 @@ pub async fn download_tiktok(url: impl Into<String>) -> 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: impl Into<String>) -> Result<DownloadResult> { pub async fn download_twitter(url: impl Into<String>) -> Result<DownloadResult> {
let args = ["-t", "mp4", "--extractor-args", "twitter:", &url.into()]; let config = global_config();
run_command_in_tempdir("yt-dlp", &args).await let args = ["-t", "mp4", "--extractor-args", "twitter:"]
.iter()
.map(ToString::to_string)
.collect();
run_yt_dlp(args, config.twitter.cookies_path.as_ref(), &url.into()).await
} }
/// Download a URL with yt-dlp. /// Download a URL with yt-dlp.
@ -164,7 +150,8 @@ pub async fn download_twitter(url: impl Into<String>) -> Result<DownloadResult>
/// - Propagates `run_command_in_tempdir` errors. /// - Propagates `run_command_in_tempdir` errors.
#[cfg(feature = "youtube")] #[cfg(feature = "youtube")]
pub async fn download_youtube(url: impl Into<String>) -> Result<DownloadResult> { pub async fn download_youtube(url: impl Into<String>) -> Result<DownloadResult> {
let base_args = [ let config = global_config();
let args = [
"--no-playlist", "--no-playlist",
"-t", "-t",
"mp4", "mp4",
@ -172,21 +159,12 @@ pub async fn download_youtube(url: impl Into<String>) -> Result<DownloadResult>
"-o", "-o",
"%(title)s.%(ext)s", "%(title)s.%(ext)s",
"--postprocessor-args", "--postprocessor-args",
"ffmpeg:-vf setsar=1 -c:v libx264 -crf 20 -preset veryfast -c:a aac -b:a 128k -movflags +faststart", &config.youtube.postprocessor_args,
]; ]
let mut args = base_args .iter()
.iter() .map(ToString::to_string)
.map(ToString::to_string) .collect();
.collect::<Vec<_>>(); run_yt_dlp(args, config.youtube.cookies_path.as_ref(), &url.into()).await
if let Ok(cookies_path) = env::var("YOUTUBE_SESSION_COOKIE_PATH") {
args.extend(["--cookies".into(), cookies_path]);
}
args.push(url.into());
let args_ref = args.iter().map(String::as_ref).collect::<Vec<_>>();
run_command_in_tempdir("yt-dlp", &args_ref).await
} }
/// Post-process a `DownloadResult`. /// Post-process a `DownloadResult`.
@ -278,6 +256,20 @@ fn is_potential_media_file(path: &Path) -> bool {
.any(|allowed| allowed.eq_ignore_ascii_case(&ext)) .any(|allowed| allowed.eq_ignore_ascii_case(&ext))
} }
async fn run_yt_dlp(
mut args: Vec<String>,
cookies_path: Option<&PathBuf>,
url: &str,
) -> Result<DownloadResult> {
if let Some(path) = cookies_path {
args.extend(["--cookies".to_string(), path.to_string_lossy().to_string()]);
}
args.push(url.to_string());
let args_ref = args.iter().map(String::as_ref).collect::<Vec<_>>();
run_command_in_tempdir("yt-dlp", &args_ref).await
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@ -1,5 +1,6 @@
pub mod commands; pub mod commands;
pub mod comments; pub mod comments;
pub mod config;
pub mod download; pub mod download;
pub mod error; pub mod error;
pub mod handler; pub mod handler;

View File

@ -1,7 +1,7 @@
use dotenv::dotenv; use dotenv::dotenv;
use teloxide::{prelude::*, respond}; use teloxide::{prelude::*, respond};
use tg_relay_rs::{ use tg_relay_rs::{
comments::{Comments, init_global_comments}, comments::Comments,
handler::{Handler, create_handlers}, handler::{Handler, create_handlers},
telemetry::setup_logger, telemetry::setup_logger,
}; };
@ -13,15 +13,15 @@ async fn main() -> color_eyre::Result<()> {
color_eyre::install().expect("color-eyre install"); color_eyre::install().expect("color-eyre install");
setup_logger(); setup_logger();
let comments = Comments::load_from_file("comments.txt") Comments::load_from_file("comments.txt")
.await .await
.map_err(|e| { .map_err(|e| {
warn!("failed to laod comments.txt: {}; using dummy comments", e); warn!("failed to laod comments.txt: {}; using dummy comments", e);
e e
}) })
.unwrap_or_else(|_| Comments::dummy()); .unwrap_or_else(|_| Comments::dummy())
.init()
init_global_comments(comments).expect("failed to initialize global comments"); .expect("failed to initialize comments");
let bot = Bot::from_env(); let bot = Bot::from_env();
info!("bot starting"); info!("bot starting");

View File

@ -1,5 +1,5 @@
use crate::{ use crate::{
comments::{Comments, global_comments}, comments::global_comments,
error::{Error, Result}, error::{Error, Result},
}; };
use capitalize::Capitalize; use capitalize::Capitalize;
@ -109,18 +109,13 @@ pub async fn send_media_from_path(
path: PathBuf, path: PathBuf,
kind: MediaKind, kind: MediaKind,
) -> Result<()> { ) -> Result<()> {
let caption_opt = global_comments() let caption = global_comments().build_caption();
.map(Comments::build_caption)
.filter(|caption| !caption.is_empty());
let input = InputFile::file(path); let input = InputFile::file(path);
macro_rules! send_msg { macro_rules! send_msg {
($request_expr:expr) => {{ ($request_expr:expr) => {{
let mut request = $request_expr; let mut request = $request_expr;
if let Some(cap) = caption_opt { request = request.caption(caption);
request = request.caption(cap);
}
match request.await { match request.await {
Ok(message) => info!(message_id = message.id.to_string(), "{} sent", kind), Ok(message) => info!(message_id = message.id.to_string(), "{} sent", kind),
Err(e) => { Err(e) => {