mirror of
https://github.com/kristoferssolo/tg-relay-rs.git
synced 2026-02-25 05:08:15 +00:00
feat: add commentary
This commit is contained in:
94
src/comments.rs
Normal file
94
src/comments.rs
Normal 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()
|
||||
}
|
||||
@@ -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>;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod comments;
|
||||
pub mod error;
|
||||
pub mod handlers;
|
||||
pub mod telemetry;
|
||||
|
||||
13
src/main.rs
13
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");
|
||||
|
||||
|
||||
35
src/utils.rs
35
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<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")
|
||||
|
||||
Reference in New Issue
Block a user