diff --git a/Cargo.lock b/Cargo.lock index e7bcd5a..d59ea43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1149,6 +1149,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -1203,6 +1212,35 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom", +] + [[package]] name = "rc-box" version = "1.3.0" @@ -1705,8 +1743,9 @@ dependencies = [ "dotenv", "futures", "infer", - "once_cell", + "rand", "regex", + "serde", "teloxide", "tempfile", "thiserror 2.0.16", diff --git a/Cargo.toml b/Cargo.toml index 96ffe1f..2b2b990 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,8 +11,9 @@ color-eyre = "0.6" dotenv = "0.15" futures = "0.3.31" infer = "0.19" -once_cell = "1.21.3" +rand = "0.9" regex = "1.11" +serde = { version = "1.0", features = ["derive"] } teloxide = { version = "0.17", features = ["macros"] } tempfile = "3" thiserror = "2.0" diff --git a/comments.txt b/comments.txt new file mode 100644 index 0000000..e146304 --- /dev/null +++ b/comments.txt @@ -0,0 +1,141 @@ +P-foking-18. Again. Are we even trying? +The car is a piece of shit. What do you want from me? A miracle? +He missed the apex by a foking mile. Was he looking at the birds? +We have the pace of a foking milk float. Honestly. +Did we put fuel in the car? It looks like he is pushing it. +This is not a qualifying lap, this is a foking parade lap. +Both of them! Not just one, both! Unbelievable. +Tomorrow we start from the back. At least we cannot go further back. +What was that? He braked in the wrong foking country. +The data says we should be faster. The data is a foking liar. +What the fok was that strategy? Did we let a fan decide? +Tell him to shut up and drive the foking car. +A 5-second pitstop? Are we having a picnic out there? +He’s complaining about graining? The race foking started two laps ago! +Blue flags! Just get out of the foking way! We are not racing him! +The deg is foking ridiculous on this tyre. Box. Now. +Don't tell me about the gap. I can see the gap. It's getting foking bigger. +Why is he fighting him? He will destroy the tyres for nothing! +Fok, we look like a bunch of wankers. +Is the engine on fire? No? Then tell him to keep foking pushing. +So, to summarize: the start was shit, the middle was shit, and the end was shit. +We scored zero points. Again. I am not even angry, I am just… tired. +Who is responsible for this? Everyone. Everyone is foking responsible. +Do not show me the positives. There are no foking positives today. +We got lapped. Twice. By a Sauber. A FOKING SAUBER. +Next race we try something new: we try to be less shit. +This performance was unacceptable. Go home, think about it. +I need a drink. Or maybe five drinks. +Don't talk to me. Honestly, nobody talk to me for an hour. +We are the slowest foking team on the grid. Fact. +Look at that! He foksmashed the car. Again. +That's another 200,000 dollars. Fok. Gene is going to love this. +What a foking idiot. He had the whole track to himself. +Are you okay? ... *[long pause]* ... Okay. Now, what the fok happened? +The suspension is gone, the wing is gone... he brought back half a car. +He is a foking rockstar for crashing. +Was the wall moving? I don't think so. +He blames the car. Of course he blames the foking car. +Two drivers, one corner. And now, zero foking cars. +Get the spare parts. All of them. We will need them. +You saw the race, no? Why are you asking me? It was shit. +We are not here to be last. But today, we are. So, what is your question? +Yes, it was a fantastic result. We finished. That is the highlight. +The drivers did their best. Their best was just not very good. +We need a bigger budget, a better car, and maybe some foking luck. +I am not going to stand here and say we are happy. We are foking not. +Next year will be better. It cannot be foking worse. +He is a young driver. He makes mistakes. Expensive, foking stupid mistakes. +Why did we pit then? Because the crystal ball was broken today. +Look, we are a small team. We do our best. Sometimes our best is just shit. +The gearbox is foked. Just foked. +It's not smoke, it's 'unscheduled pyrotechnics'. +We build a car for a whole year and a foking $10 sensor fails. +Engine problem. Of course. It's a Ferrari engine, what do you expect? +Hydraulics are gone. Park the foking thing before it burns. +It just went 'bang'. A very expensive 'bang'. +He says he has no power. Tell him welcome to my foking world. +The brakes failed. FOKING BRAKES. That is not a small problem. +Something is loose in the cockpit. I hope it's not his foking brain. +This part should last 5 races. It lasted 5 laps. +This is not a foking kindergarten. +We need to pull our foking finger out. Now. +Honestly, some days I think I'd be happier farming. At least the potatoes don't crash. +Everyone is a wanker until they prove otherwise. +I don't have time for this bullshit. +Focus. Just foking focus on your job. +Hope is not a strategy. +This sport will give you a foking heart attack. +If you don't want pressure, go sell ice cream. +Sometimes you just have to say 'fok it'. +I am surrounded by foking idiots. +What are we doing here? Seriously, what is the foking point? +No more excuses. I am sick of the foking excuses. +It is what it is. A foking mess. +This is racing. It's brutal. +Right. Last race was shit. This race, we will be less shit. Go. +Do not fok this up. That is all I ask. +Points. We need foking points. I don't care how. +Drive like you foking mean it for once. +Let's show them we are not a bunch of wankers. +If you crash, don't foking call me. +Be aggressive. Smart, but foking aggressive. +Forget the pressure. Just drive the damn car. +We are the underdog. Let's go bite someone in the foking ankles. +Okay guys. Let's go have some fun... and by fun, I mean don't finish last. +The catering is good today. That is the only good thing. +He wants a new contract? Show me some foking results first. +My patience is thinner than a foking razor blade right now. +I need more coffee and less stupid people. +This regulation change is a foking joke. +Don't smile at me. We have nothing to smile about. +Who designed this part? I want to have a word with him. A very loud word. +Unbelievable. Just... foking unbelievable. +For foks sake. +I'm too old for this shit. +Can we just go home now? +Another day, another foking drama. +The drivers' briefing was a waste of foking time. +He is now officially a member of the wanker club. +Everything is foked. Completely foked. +Call Gene. Tell him... actually, don't foking call Gene. +Narrator: It was not, in fact, fine. +And at that moment, they knew... they messed up. +My entire life is a series of these moments. +Task failed successfully. +I have so many questions, and all of them start with "why?" +Well, that escalated quickly. +This is fine. I'm okay with the events that are unfolding currently. +We're all just one bad decision away from being in this video. +Calculated. But man, am I bad at math. +And the Darwin Award goes to... +Me trying to get my life together. +My last two brain cells trying to function. +Monday hitting me like: +Me pretending to be productive at work. +My motivation leaving my body at 2 PM. +Another meeting that could have been an email. +Me running on 3 hours of sleep and 6 shots of espresso. +Adulting is a scam. +My bank account looking at me after I buy one (1) coffee. +Me trying to follow a recipe. +No thoughts, just vibes. +The lights are on but nobody's home. +He's a little confused, but he's got the spirit. +Someone come get their cat, it's broken. +This is what peak performance looks like. +Not a single thought behind those eyes. +The internal monologue here must be fascinating. +He understood the assignment. +My spirit animal. +What the dog doin? +The internet was a mistake. +This is my entire personality now. +Just a normal day on planet Earth. +Explain this in historical terms. +The vibe I'm trying to bring into 2025. +And I'll f*cking do it again. +Zero chaos in this video. None at all. +This has been living in my head rent-free. +POV: You gave them one (1) ounce of confidence. +I don't get paid enough for this shit. diff --git a/src/comments.rs b/src/comments.rs new file mode 100644 index 0000000..5774aea --- /dev/null +++ b/src/comments.rs @@ -0,0 +1,94 @@ +use crate::error::{Error, Result}; +use rand::{rng, seq::IndexedRandom}; +use std::{ + path::Path, + sync::{Arc, OnceLock}, +}; +use tokio::fs::read_to_string; +static DISCLAIMER: &str = "(Roleplay — fictional messages for entertainment.)"; + +#[derive(Debug)] +pub struct Comments { + pub disclaimer: String, + lines: Arc>, +} + +impl Comments { + /// Create a small dummy/default Comments instance (useful for tests or fallback). + #[must_use] + pub fn dummy() -> Self { + let lines = vec![ + "Oh come on, that's brilliant — and slightly chaotic, like always.".into(), + "That is a proper bit of craftsmanship — then someone presses the red button.".into(), + "Nice shot — looks good on the trailer, not so good on the gearbox.".into(), + "Here you go. Judge for yourself.".into(), + ]; + Self { + disclaimer: DISCLAIMER.into(), + lines: lines.into(), + } + } + + /// Load comments from a plaintext file asynchronously. + /// + /// # Errors + /// + /// - Returns `Error::Io` if reading the file fails (propagated from + /// `tokio::fs::read_to_string`). + /// - Returns `Error::Other` if the file contains no usable lines after + /// filtering (empty or all-comment file). + pub async fn load_from_file>(path: P) -> Result { + let s = read_to_string(path).await?; + + let lines = s + .lines() + .map(str::trim) + .filter(|l| !l.is_empty() && !l.starts_with('#')) + .map(ToString::to_string) + .collect::>(); + + if lines.is_empty() { + return Err(Error::other("comments file contains no usable lines")); + } + + Ok(Self { + disclaimer: DISCLAIMER.into(), + lines: lines.into(), + }) + } + + /// Pick a random comment as &str (no allocation). Falls back to a small static + /// string if the list is unexpectedly empty. + #[must_use] + pub fn pick(&self) -> &str { + let mut rng = rng(); + self.lines + .choose(&mut rng) + .map_or("Here you go.", String::as_str) + } + + #[must_use] + #[inline] + pub fn build_caption(&self) -> String { + self.pick().to_string() + } +} + +static GLOBAL_COMMENTS: OnceLock = OnceLock::new(); + +/// Initialize the global comments (call once at startup). +/// +/// # Errors +/// +/// - Returns `Error::Other` when the global is already initialized (the +/// underlying `OnceLock::set` fails). +pub fn init_global_comments(comments: Comments) -> Result<()> { + GLOBAL_COMMENTS + .set(comments) + .map_err(|_| Error::other("comments already initialized")) +} + +/// Get global comments (if initialized). Returns Option<&'static Comments>. +pub fn global_comments() -> Option<&'static Comments> { + GLOBAL_COMMENTS.get() +} diff --git a/src/error.rs b/src/error.rs index 5ceecb8..3ba9cb1 100644 --- a/src/error.rs +++ b/src/error.rs @@ -3,7 +3,7 @@ use thiserror::Error; #[derive(Debug, Error)] pub enum Error { #[error("io error: {0}")] - Io(#[from] std::io::Error), + Io(#[from] tokio::io::Error), #[error("instaloader failed: {0}")] InstaloaderFaileled(String), @@ -24,4 +24,11 @@ pub enum Error { Other(String), } +impl Error { + #[inline] + pub fn other(text: impl Into) -> Self { + Self::Other(text.into()) + } +} + pub type Result = std::result::Result; diff --git a/src/lib.rs b/src/lib.rs index 6e43309..ecef7e5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +pub mod comments; pub mod error; pub mod handlers; pub mod telemetry; diff --git a/src/main.rs b/src/main.rs index cfacce7..fae8664 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,10 +2,11 @@ use dotenv::dotenv; use std::sync::Arc; use teloxide::{Bot, prelude::Requester, respond, types::Message}; use tg_relay_rs::{ + comments::{Comments, init_global_comments}, handlers::{InstagramHandler, SocialHandler}, telemetry::setup_logger, }; -use tracing::{error, info}; +use tracing::{error, info, warn}; #[tokio::main] async fn main() -> color_eyre::Result<()> { @@ -13,6 +14,16 @@ async fn main() -> color_eyre::Result<()> { color_eyre::install().expect("color-eyre install"); setup_logger(); + let comments = Comments::load_from_file("comments.txt") + .await + .map_err(|e| { + warn!("failed to laod comments.txt: {}; using dummy comments", e); + e + }) + .unwrap_or_else(|_| Comments::dummy()); + + init_global_comments(comments).expect("failed to initialize global comments"); + let bot = Bot::from_env(); info!("bot starting"); diff --git a/src/utils.rs b/src/utils.rs index 468a420..6f86a0d 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,15 +1,23 @@ -use crate::error::{Error, Result}; +use crate::{ + comments::global_comments, + error::{Error, Result}, +}; use std::{ ffi::OsStr, path::{Path, PathBuf}, }; use teloxide::{ Bot, + payloads::{SendPhotoSetters, SendVideoSetters}, prelude::Requester, types::{ChatId, InputFile}, }; use tokio::{fs::File, io::AsyncReadExt}; +const TELEGRAM_CAPTION_LIMIT: usize = 1024; +static VIDEO_EXTS: &[&str] = &["mp4", "webm", "mov", "mkv", "avi"]; +static IMAGE_EXTS: &[&str] = &["jpg", "jpeg", "png", "webp"]; + /// Simple media kind enum shared by handlers. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MediaKind { @@ -18,9 +26,6 @@ pub enum MediaKind { Unknown, } -static VIDEO_EXTS: &[&str] = &["mp4", "webm", "mov", "mkv", "avi"]; -static IMAGE_EXTS: &[&str] = &["jpg", "jpeg", "png", "webp"]; - /// 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. @@ -94,14 +99,32 @@ pub async fn send_media_from_path( kind: Option, ) -> Result<()> { let kind = kind.unwrap_or_else(|| detect_media_kind(&path)); + + let caption_opt = global_comments().map(|c| { + 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 { MediaKind::Video => { let video = InputFile::file(path); - bot.send_video(chat_id, video).await.map_err(Error::from)?; + let mut req = bot.send_video(chat_id, video); + if let Some(c) = caption_opt { + req = req.caption(c); + } + req.await.map_err(Error::from)?; } MediaKind::Image => { let photo = InputFile::file(path); - bot.send_photo(chat_id, photo).await.map_err(Error::from)?; + let mut req = bot.send_photo(chat_id, photo); + if let Some(c) = caption_opt { + req = req.caption(c); + } + req.await.map_err(Error::from)?; } MediaKind::Unknown => { bot.send_message(chat_id, "No supported media found")