feat: add commentary

This commit is contained in:
2025-09-19 21:18:26 +03:00
parent 36270c7a3f
commit 7a9ef2c48c
8 changed files with 327 additions and 10 deletions

94
src/comments.rs Normal file
View File

@@ -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<Vec<String>>,
}
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<P: AsRef<Path>>(path: P) -> Result<Self> {
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::<Vec<_>>();
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<Comments> = 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()
}

View File

@@ -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<String>) -> Self {
Self::Other(text.into())
}
}
pub type Result<T> = std::result::Result<T, Error>;

View File

@@ -1,3 +1,4 @@
pub mod comments;
pub mod error;
pub mod handlers;
pub mod telemetry;

View File

@@ -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");

View File

@@ -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<MediaKind>,
) -> 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")