├── .envrc ├── .gitignore ├── .dockerignore ├── .env ├── src ├── reddit │ ├── mod.rs │ ├── api.rs │ └── types.rs ├── args.rs ├── types.rs ├── download.rs ├── config.rs ├── ytdlp.rs ├── messages.rs ├── bot.rs ├── db.rs └── main.rs ├── config.example.toml ├── justfile ├── Cargo.toml ├── Dockerfile ├── README.md └── Cargo.lock /.envrc: -------------------------------------------------------------------------------- 1 | dotenv 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | target/ 3 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | CONFIG_PATH=config.toml 2 | RUST_LOG=info 3 | -------------------------------------------------------------------------------- /src/reddit/mod.rs: -------------------------------------------------------------------------------- 1 | mod api; 2 | mod types; 3 | pub use api::*; 4 | pub use types::*; 5 | -------------------------------------------------------------------------------- /config.example.toml: -------------------------------------------------------------------------------- 1 | db_path = "/data/data.db3" 2 | authorized_user_ids = [123123123] 3 | telegram_bot_token = "xxx" 4 | check_interval_secs = 600 5 | skip_initial_send = true 6 | default_limit = 5 7 | default_time = "week" 8 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | build: 2 | cargo build 3 | 4 | build-release: 5 | cargo build --release 6 | 7 | dev *FLAGS: 8 | fd .rs | entr -r cargo run {{FLAGS}} 9 | 10 | test *FLAGS: 11 | cargo test {{FLAGS}} 12 | 13 | testw *FLAGS: 14 | fd .rs | entr -r cargo test {{FLAGS}} 15 | 16 | install: 17 | cargo install --path . 18 | -------------------------------------------------------------------------------- /src/args.rs: -------------------------------------------------------------------------------- 1 | use getopts::Options; 2 | use log::*; 3 | use std::env; 4 | 5 | pub fn parse_args() -> getopts::Matches { 6 | let args: Vec = env::args().collect(); 7 | let mut opts = Options::new(); 8 | opts.optopt("", "debug-post", "", ""); 9 | opts.optopt("", "chat-id", "", ""); 10 | match opts.parse(&args[1..]) { 11 | Ok(m) => m, 12 | Err(f) => { 13 | error!("{}", f); 14 | std::process::exit(1); 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/types.rs: -------------------------------------------------------------------------------- 1 | use crate::reddit::{PostType, TopPostsTimePeriod}; 2 | use std::path::PathBuf; 3 | 4 | #[derive(Debug)] 5 | pub struct Video { 6 | pub path: PathBuf, 7 | pub width: u16, 8 | pub height: u16, 9 | } 10 | 11 | #[derive(Debug, PartialEq, Eq)] 12 | pub struct Subscription { 13 | pub chat_id: i64, 14 | pub subreddit: String, 15 | pub limit: Option, 16 | pub time: Option, 17 | pub filter: Option, 18 | } 19 | 20 | #[derive(Debug, Clone, PartialEq, Eq)] 21 | pub struct SubscriptionArgs { 22 | pub subreddit: String, 23 | pub limit: Option, 24 | pub time: Option, 25 | pub filter: Option, 26 | } 27 | -------------------------------------------------------------------------------- /src/download.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use log::*; 3 | 4 | use std::io::Write; 5 | use std::{ 6 | fs::File, 7 | path::{Path, PathBuf}, 8 | }; 9 | use tempdir::TempDir; 10 | use url::Url; 11 | 12 | /// Downloads url to a file and returns the path along with handle to temp dir in which the file is. 13 | /// Whe the temp dir value is dropped, the contents in file system are deleted. 14 | pub async fn download_url_to_tmp(url: &str) -> Result<(PathBuf, TempDir)> { 15 | info!("downloading {url}"); 16 | let mut res = reqwest::get(url).await?; 17 | let tmp_dir = TempDir::new("tgreddit")?; 18 | let parsed_url = Url::parse(url)?; 19 | let tmp_filename = Path::new(parsed_url.path()) 20 | .file_name() 21 | .context("could not get basename from url")?; 22 | let tmp_path = tmp_dir.path().join(tmp_filename); 23 | let mut file = File::create(&tmp_path) 24 | .map_err(|_| anyhow::anyhow!("failed to create file {:?}", tmp_path))?; 25 | 26 | while let Some(bytes) = res.chunk().await? { 27 | file.write(&bytes) 28 | .map_err(|_| anyhow::anyhow!("error writing to file {:?}", tmp_path))?; 29 | } 30 | 31 | info!("downloaded {url} to {}", tmp_path.to_string_lossy()); 32 | Ok((tmp_path, tmp_dir)) 33 | } 34 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "tgreddit" 3 | version = "0.1.4" 4 | edition = "2021" 5 | description = "Get the top posts of your favorite subreddits to Telegram" 6 | license = "MIT" 7 | 8 | [dependencies] 9 | anyhow = "1.0.64" 10 | chrono = "0.4.22" 11 | duct = "0.13.5" 12 | env_logger = "0.9.0" 13 | getopts = "0.2.21" 14 | itertools = "0.10.3" 15 | lazy_static = "1.4.0" 16 | log = "0.4.17" 17 | regex = { version = "1.6.0", default-features = false, features = ["std", "unicode-perl"] } 18 | rusqlite = { version = "0.28.0", features = ["chrono", "bundled"] } 19 | rusqlite_migration = "1.0.0" 20 | secrecy = { version = "0.8.0", features = ["serde"] } 21 | serde = { version = "1.0.144", features = ["derive"] } 22 | serde_derive = "1.0.144" 23 | serde_json = "1.0.85" 24 | signal-hook = "0.3.14" 25 | strum = "0.24.1" 26 | strum_macros = "0.24.3" 27 | teloxide = { version = "0.12.2", features = ["macros", "auto-send"] } 28 | tempdir = "0.3.7" 29 | thiserror = "1.0.34" 30 | tokio = { version = "1.21.0", features = ["rt-multi-thread", "macros", "sync"] } 31 | toml = "0.5.9" 32 | url = "2.2.2" 33 | xdg = "2.4.1" 34 | reqwest = { version = "0.11.11", features = ["json"] } 35 | 36 | # Use vendored openssl. We don't depend on it directly. 37 | openssl = { version = "0.10.41", features = ["vendored"], optional = true } 38 | 39 | [features] 40 | vendored-openssl = ["openssl"] 41 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Step 1: Compute a recipe file 2 | FROM rust:1.70.0-slim-bookworm as chef 3 | RUN --mount=type=cache,target=/usr/local/cargo/registry \ 4 | cargo install cargo-chef 5 | 6 | # Step 2: Compute a recipe file 7 | FROM chef as planner 8 | WORKDIR /app 9 | COPY Cargo.toml Cargo.lock ./ 10 | COPY src ./src 11 | RUN cargo chef prepare --recipe-path recipe.json 12 | 13 | # Step 3: Cache project dependencies 14 | FROM chef as cacher 15 | WORKDIR /app 16 | RUN rustup target add aarch64-unknown-linux-gnu 17 | RUN apt-get update && apt-get install -y \ 18 | gcc-aarch64-linux-gnu musl-tools libssl-dev perl cmake make \ 19 | && rm -rf /var/lib/apt/lists/* 20 | COPY --from=planner /app/recipe.json recipe.json 21 | RUN --mount=type=cache,target=/usr/local/cargo/registry \ 22 | cargo chef cook --release --target aarch64-unknown-linux-gnu --recipe-path recipe.json --features vendored-openssl 23 | 24 | # Step 4: Build the binary 25 | FROM rust:1.70.0-slim-bookworm as builder 26 | WORKDIR /app 27 | RUN rustup target add aarch64-unknown-linux-gnu 28 | COPY Cargo.toml Cargo.lock ./ 29 | COPY src ./src 30 | COPY --from=cacher /app/target target 31 | COPY --from=cacher $CARGO_HOME $CARGO_HOME 32 | RUN --mount=type=cache,target=/usr/local/cargo/registry \ 33 | cargo build --release --target aarch64-unknown-linux-gnu --features vendored-openssl 34 | 35 | # Step 5: Create the final image with binary and deps 36 | FROM debian:bookworm-slim 37 | WORKDIR /app 38 | COPY --from=builder /app/target/aarch64-unknown-linux-gnu/release/tgreddit . 39 | RUN apt-get update && apt-get install -y \ 40 | curl python3 ffmpeg \ 41 | && rm -rf /var/lib/apt/lists/* 42 | RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/download/2024.04.09/yt-dlp -o /usr/local/bin/yt-dlp 43 | RUN chmod a+rx /usr/local/bin/yt-dlp 44 | ENTRYPOINT ["./tgreddit"] 45 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use log::error; 2 | use secrecy::{ExposeSecret, Secret}; 3 | use serde::Deserialize; 4 | use std::{env, path::PathBuf}; 5 | 6 | use crate::{ 7 | reddit::{PostType, TopPostsTimePeriod}, 8 | PKG_NAME, 9 | }; 10 | 11 | const CONFIG_PATH_ENV: &str = "CONFIG_PATH"; 12 | pub const DEFAULT_LIMIT: u32 = 1; 13 | pub const DEFAULT_TIME_PERIOD: TopPostsTimePeriod = TopPostsTimePeriod::Day; 14 | 15 | #[derive(Debug, Deserialize)] 16 | pub struct SecretString(Secret); 17 | 18 | impl SecretString { 19 | pub fn expose_secret(&self) -> &str { 20 | self.0.expose_secret() 21 | } 22 | } 23 | 24 | impl Default for SecretString { 25 | fn default() -> Self { 26 | SecretString(Secret::new("".to_string())) 27 | } 28 | } 29 | 30 | #[derive(Deserialize, Debug, Default)] 31 | pub struct Config { 32 | pub authorized_user_ids: Vec, 33 | #[serde(default = "default_db_path")] 34 | pub db_path: PathBuf, 35 | pub telegram_bot_token: SecretString, 36 | pub check_interval_secs: u64, 37 | #[serde(default = "default_skip_initial_send")] 38 | pub skip_initial_send: bool, 39 | pub links_base_url: Option, 40 | pub default_limit: Option, 41 | pub default_time: Option, 42 | pub default_filter: Option, 43 | } 44 | 45 | pub fn read_config() -> Config { 46 | env::var(CONFIG_PATH_ENV) 47 | .map_err(|_| format!("{CONFIG_PATH_ENV} environment variable not set")) 48 | .and_then(|config_path| std::fs::read(config_path).map_err(|e| e.to_string())) 49 | .and_then(|bytes| toml::from_slice(&bytes).map_err(|e| e.to_string())) 50 | .unwrap_or_else(|err| { 51 | error!("failed to read config: {err}"); 52 | std::process::exit(1); 53 | }) 54 | } 55 | 56 | fn default_db_path() -> PathBuf { 57 | let xdg_dirs = xdg::BaseDirectories::with_prefix(PKG_NAME).unwrap(); 58 | xdg_dirs.place_state_file("data.db3").unwrap() 59 | } 60 | 61 | fn default_skip_initial_send() -> bool { 62 | true 63 | } 64 | -------------------------------------------------------------------------------- /src/ytdlp.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use duct::cmd; 3 | use lazy_static::lazy_static; 4 | use log::{error, info}; 5 | use std::{ 6 | ffi::OsString, 7 | fs, 8 | io::{BufRead, BufReader}, 9 | path::Path, 10 | }; 11 | 12 | use crate::types::*; 13 | 14 | use regex::Regex; 15 | use tempdir::TempDir; 16 | 17 | fn make_ytdlp_args(output: &Path, url: &str) -> Vec { 18 | vec![ 19 | "--paths".into(), 20 | output.into(), 21 | "--output".into(), 22 | // To get telegram show correct aspect ratio for video, we need the dimensions and simplest 23 | // way to make that happens is have yt-dlp write them in the filename. 24 | "video_%(width)sx%(height)s.%(ext)s".into(), 25 | url.into(), 26 | ] 27 | } 28 | 29 | /// Downloads given url with yt-dlp and returns path to video 30 | pub fn download(url: &str) -> Result<(Video, TempDir)> { 31 | let tmp_dir = TempDir::new("tgreddit")?; 32 | let tmp_path = tmp_dir.path(); 33 | let ytdlp_args = make_ytdlp_args(tmp_dir.path(), url); 34 | 35 | info!("running yt-dlp with arguments {:?}", ytdlp_args); 36 | let duct_exp = cmd("yt-dlp", ytdlp_args).stderr_to_stdout(); 37 | let reader = match duct_exp.reader() { 38 | Ok(child) => child, 39 | Err(err) => { 40 | error!("failed to run yt-dlp:\n{}", err); 41 | return Err(anyhow::anyhow!(err)); 42 | } 43 | }; 44 | 45 | let lines = BufReader::new(reader).lines(); 46 | for line_result in lines { 47 | match line_result { 48 | Ok(line) => info!("{line}"), 49 | Err(_) => { 50 | error!("failed to read yt-dlp output"); 51 | return Err(anyhow::anyhow!("failed to read yt-dlp output")); 52 | } 53 | } 54 | } 55 | 56 | // yt-dlp is expected to write a single file, which is the video, to tmp_path 57 | let video_path = fs::read_dir(tmp_path) 58 | .expect("could not read files in temp dir") 59 | .map(|de| de.unwrap().path()) 60 | .next() 61 | .expect("video file in temp dir"); 62 | 63 | let dimensions = 64 | parse_dimensions_from_path(&video_path).expect("video filename should have dimensions"); 65 | 66 | let video = Video { 67 | path: video_path, 68 | width: dimensions.0, 69 | height: dimensions.1, 70 | }; 71 | 72 | Ok((video, tmp_dir)) 73 | } 74 | 75 | fn parse_dimensions_from_path(path: &Path) -> Option<(u16, u16)> { 76 | lazy_static! { 77 | static ref RE: Regex = Regex::new(r"_(?P\d+)x(?P\d+)\.").unwrap(); 78 | } 79 | 80 | let path_str = path.to_string_lossy(); 81 | let caps = RE.captures(&path_str)?; 82 | let width = caps.name("width")?.as_str().parse::().ok()?; 83 | let height = caps.name("height")?.as_str().parse::().ok()?; 84 | 85 | Some((width, height)) 86 | } 87 | 88 | #[cfg(test)] 89 | mod tests { 90 | use super::parse_dimensions_from_path; 91 | use std::path::Path; 92 | 93 | #[test] 94 | fn test_parse_dimensions_from_path() { 95 | assert_eq!( 96 | parse_dimensions_from_path(Path::new("/foo/bar/video_1920x1080.mp4")), 97 | Some((1920, 1080)) 98 | ); 99 | 100 | assert_eq!( 101 | parse_dimensions_from_path(Path::new("/foo/bar/video_asdfax1080.mp4")), 102 | None, 103 | ); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/messages.rs: -------------------------------------------------------------------------------- 1 | use crate::reddit; 2 | use crate::*; 3 | use itertools::Itertools; 4 | 5 | fn escape(html: &str) -> String { 6 | html.replace('<', "<").replace('>', ">") 7 | } 8 | 9 | fn format_html_anchor(href: &str, text: &str) -> String { 10 | format!(r#"{}"#, escape(text)) 11 | } 12 | 13 | fn format_subreddit_link(subreddit: &str, base_url: Option<&str>) -> String { 14 | format_html_anchor( 15 | &reddit::format_subreddit_url(subreddit, base_url), 16 | &format!("/r/{}", &subreddit), 17 | ) 18 | } 19 | 20 | fn format_meta_html(post: &reddit::Post, links_base_url: Option<&str>) -> String { 21 | let subreddit_link = format_subreddit_link(&post.subreddit, links_base_url); 22 | let comments_link = format_html_anchor(&post.format_permalink_url(links_base_url), "comments"); 23 | 24 | // If using custom links base url, the old reddit link doesn't make sense. 25 | match links_base_url { 26 | Some(_) => format!("{subreddit_link} [{comments_link}]"), 27 | None => { 28 | let old_comments_link = format_html_anchor(&post.format_old_permalink_url(), "old"); 29 | format!("{subreddit_link} [{comments_link}, {old_comments_link}]") 30 | } 31 | } 32 | } 33 | 34 | pub fn format_media_caption_html(post: &reddit::Post, links_base_url: Option<&str>) -> String { 35 | let title = &post.title; 36 | let meta = format_meta_html(post, links_base_url); 37 | format!("{title}\n{meta}") 38 | } 39 | 40 | pub fn format_link_message_html(post: &reddit::Post, links_base_url: Option<&str>) -> String { 41 | let title = format_html_anchor(&post.url, &post.title); 42 | let meta = format_meta_html(post, links_base_url); 43 | format!("{title}\n{meta}") 44 | } 45 | 46 | pub fn format_subscription_list(post: &[Subscription]) -> String { 47 | fn format_subscription(sub: &Subscription) -> String { 48 | let mut args = vec![]; 49 | if let Some(time) = sub.time { 50 | args.push(format!("time={}", time)); 51 | } 52 | if let Some(limit) = sub.limit { 53 | args.push(format!("limit={}", limit)); 54 | } 55 | if let Some(filter) = sub.filter { 56 | args.push(format!("filter={}", filter)); 57 | } 58 | 59 | let args_str = if !args.is_empty() { 60 | format!("({})", args.join(", ")) 61 | } else { 62 | "".to_string() 63 | }; 64 | 65 | [sub.subreddit.to_owned(), args_str] 66 | .join(" ") 67 | .trim_end() 68 | .to_string() 69 | } 70 | 71 | if post.is_empty() { 72 | "No subscriptions".to_owned() 73 | } else { 74 | post.iter().map(format_subscription).join("\n") 75 | } 76 | } 77 | 78 | #[cfg(test)] 79 | mod tests { 80 | use super::*; 81 | 82 | #[test] 83 | fn test_format_html_anchor() { 84 | assert_eq!( 85 | format_html_anchor("https://example.com", ""), 86 | r#"<hello></world>"# 87 | ) 88 | } 89 | 90 | #[test] 91 | fn test_format_subscription_list() { 92 | assert_eq!( 93 | format_subscription_list(&[ 94 | Subscription { 95 | chat_id: 1, 96 | subreddit: "foo".to_owned(), 97 | limit: None, 98 | time: None, 99 | filter: None, 100 | }, 101 | Subscription { 102 | chat_id: 1, 103 | subreddit: "bar".to_owned(), 104 | limit: Some(1), 105 | time: Some(TopPostsTimePeriod::Week), 106 | filter: None, 107 | }, 108 | ]), 109 | "foo\nbar (time=week, limit=1)" 110 | ) 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/reddit/api.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use anyhow::{Context, Result}; 3 | use log::{error, info}; 4 | use thiserror::Error; 5 | use url::Url; 6 | 7 | static REDDIT_BASE_URL: &str = "https://www.reddit.com"; 8 | static APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); 9 | 10 | fn get_base_url() -> Url { 11 | Url::parse(REDDIT_BASE_URL).unwrap() 12 | } 13 | 14 | fn get_client() -> reqwest::ClientBuilder { 15 | reqwest::Client::builder().user_agent(APP_USER_AGENT) 16 | } 17 | 18 | pub fn format_url_from_path(path: &str, base_url: Option<&str>) -> String { 19 | let base_url = match base_url { 20 | Some(u) => u, 21 | None => REDDIT_BASE_URL, 22 | }; 23 | format!("{base_url}{path}") 24 | } 25 | 26 | pub fn to_old_reddit_url(url: &str) -> String { 27 | // If this fails it's bug 28 | let mut url = Url::parse(url).unwrap(); 29 | url.set_host(Some("old.reddit.com")).unwrap(); 30 | url.to_string() 31 | } 32 | 33 | pub fn format_subreddit_url(subreddit: &str, base_url: Option<&str>) -> String { 34 | format_url_from_path(&format!("/r/{subreddit}"), base_url) 35 | } 36 | 37 | pub async fn get_subreddit_top_posts( 38 | subreddit: &str, 39 | limit: u32, 40 | time: &TopPostsTimePeriod, 41 | ) -> Result> { 42 | info!("getting top posts for /r/{subreddit} limit={limit} time={time:?}"); 43 | let url = get_base_url() 44 | .join(&format!("/r/{subreddit}/top.json")) 45 | .unwrap(); 46 | let client = get_client().build()?; 47 | let res = client 48 | .get(url) 49 | .query(&[ 50 | ("limit", &limit.to_string()), 51 | ("t", &format!("{:?}", time).to_lowercase()), 52 | ]) 53 | .send() 54 | .await? 55 | .json::() 56 | .await?; 57 | let posts = res.data.children.into_iter().map(|e| e.data).collect(); 58 | Ok(posts) 59 | } 60 | 61 | pub async fn get_link(link_id: &str) -> Result { 62 | info!("getting link id {link_id}"); 63 | let url = get_base_url().join("/api/info.json")?; 64 | let client = get_client().build()?; 65 | let res = client 66 | .get(url) 67 | .query(&[("id", &format!("t3_{link_id}"))]) 68 | .send() 69 | .await 70 | .context("failed to send request")?; 71 | 72 | let status = res.status(); 73 | let body = res.text().await.context("failed to read response body")?; 74 | 75 | if !status.is_success() { 76 | error!("request failed with status: {}", status); 77 | error!("response body: {}", body); 78 | anyhow::bail!("Request failed with status: {}", status); 79 | } 80 | 81 | match serde_json::from_str::(&body) { 82 | Ok(parsed) => parsed 83 | .data 84 | .children 85 | .into_iter() 86 | .map(|e| e.data) 87 | .next() 88 | .context("no post in response") 89 | .map_err(|e| { 90 | error!("failed to get posts for {link_id}: {:?}", e); 91 | e 92 | }), 93 | Err(e) => { 94 | error!("error decoding response body: {}", e); 95 | error!("response body: {}", body); 96 | Err(anyhow::anyhow!("error decoding response body: {}", e)) 97 | } 98 | } 99 | } 100 | 101 | #[allow(clippy::large_enum_variant)] 102 | #[derive(Error, Debug)] 103 | pub enum SubredditAboutError { 104 | #[error("no such subreddit")] 105 | NoSuchSubreddit, 106 | #[error(transparent)] 107 | Reqwest(#[from] reqwest::Error), 108 | #[error(transparent)] 109 | UrlParseError(#[from] url::ParseError), 110 | #[error(transparent)] 111 | IO(#[from] std::io::Error), 112 | } 113 | 114 | pub async fn get_subreddit_about(subreddit: &str) -> Result { 115 | info!("getting subreddit about for /r/{subreddit}"); 116 | let client = get_client() 117 | .redirect(reqwest::redirect::Policy::none()) 118 | .build()?; 119 | let url = get_base_url().join(&format!("/r/{subreddit}/about.json"))?; 120 | let res = client.get(url).send().await?; 121 | 122 | match res.status() { 123 | reqwest::StatusCode::FOUND => Err(SubredditAboutError::NoSuchSubreddit), 124 | _ => { 125 | let data = res.json::().await?.data; 126 | Ok(data) 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/reddit/types.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use super::*; 4 | use anyhow::{Context, Result}; 5 | use serde::{Deserialize, Deserializer}; 6 | use strum_macros::{Display, EnumString}; 7 | use url::Url; 8 | 9 | #[derive(Display, Debug, Clone, PartialEq, Hash, Eq, Deserialize, Copy, EnumString)] 10 | #[serde(rename_all = "snake_case")] 11 | #[strum(serialize_all = "snake_case")] 12 | pub enum PostType { 13 | Image, 14 | Video, 15 | Link, 16 | SelfText, 17 | Gallery, 18 | Unknown, 19 | } 20 | 21 | #[derive(Display, Debug, Clone, PartialEq, Hash, Eq, Deserialize, Copy, EnumString)] 22 | #[serde(rename_all = "snake_case")] 23 | #[strum(serialize_all = "snake_case")] 24 | pub enum TopPostsTimePeriod { 25 | Hour, 26 | Day, 27 | Week, 28 | Month, 29 | Year, 30 | All, 31 | } 32 | 33 | #[derive(Deserialize, Debug)] 34 | pub struct ListingResponse { 35 | pub data: ListingResponseData, 36 | } 37 | 38 | #[derive(Deserialize, Debug)] 39 | pub struct ListingResponseData { 40 | pub children: Vec, 41 | } 42 | 43 | #[derive(Deserialize, Debug)] 44 | pub struct ListingItem { 45 | pub data: Post, 46 | } 47 | 48 | #[derive(Deserialize, Debug, Clone)] 49 | pub struct GalleryDataItem { 50 | pub caption: Option, 51 | pub media_id: String, 52 | pub id: u32, 53 | } 54 | 55 | #[derive(Deserialize, Debug, Clone)] 56 | pub struct GalleryData { 57 | pub items: Vec, 58 | } 59 | 60 | #[derive(Deserialize, Debug, Clone)] 61 | pub struct Media { 62 | pub x: u16, 63 | pub y: u16, 64 | #[serde(rename = "u")] 65 | pub url: String, 66 | } 67 | 68 | #[derive(Deserialize, Debug, Clone)] 69 | pub struct MediaMetadata { 70 | pub status: String, 71 | pub e: String, 72 | #[serde(rename = "m")] 73 | pub mime: String, 74 | pub s: Media, 75 | } 76 | 77 | #[derive(Debug, Clone)] 78 | pub struct Post { 79 | pub id: String, 80 | pub created: f32, 81 | pub subreddit: String, 82 | pub title: String, 83 | pub is_video: bool, 84 | pub ups: u32, 85 | pub permalink: String, 86 | pub url: String, 87 | pub post_hint: Option, 88 | pub is_self: bool, 89 | pub is_gallery: Option, 90 | pub post_type: PostType, 91 | pub crosspost_parent_list: Option>, 92 | pub gallery_data: Option, 93 | pub media_metadata: Option>, 94 | } 95 | 96 | impl<'de> Deserialize<'de> for Post { 97 | fn deserialize(deserializer: D) -> Result 98 | where 99 | D: Deserializer<'de>, 100 | { 101 | #[derive(Deserialize)] 102 | pub struct PostHelper { 103 | pub id: String, 104 | pub created: f32, 105 | pub subreddit: String, 106 | pub title: String, 107 | pub is_video: bool, 108 | pub ups: u32, 109 | pub permalink: String, 110 | pub url: String, 111 | pub post_hint: Option, 112 | pub is_self: bool, 113 | pub is_gallery: Option, 114 | pub crosspost_parent_list: Option>, 115 | pub gallery_data: Option, 116 | pub media_metadata: Option>, 117 | } 118 | 119 | impl PostHelper { 120 | pub fn is_downloadable_video(&self) -> bool { 121 | let is_downloadable_3rd_party = || -> Result { 122 | let url = Url::parse(&self.url)?; 123 | let host = url.host_str().context("no host in url")?; 124 | let path = url.path(); 125 | let is_imgur_gif = host == "i.imgur.com" && path.ends_with(".gifv"); 126 | let is_gfycat_gif = host == "gfycat.com"; 127 | Ok(is_imgur_gif || is_gfycat_gif) 128 | }; 129 | 130 | // If the post is a crosspost with a video, it can be downloaded with post.url as 131 | // url as yt-dlp follows redirects 132 | let is_downloadable_crosspost = || -> bool { 133 | self.crosspost_parent_list 134 | .as_ref() 135 | .map(|list| list.iter().any(|post| post.post_type == PostType::Video)) 136 | .unwrap_or(false) 137 | }; 138 | 139 | self.is_video 140 | || is_downloadable_crosspost() 141 | || is_downloadable_3rd_party().unwrap_or(false) 142 | } 143 | } 144 | 145 | let helper = PostHelper::deserialize(deserializer)?; 146 | let post_hint = helper.post_hint.as_deref(); 147 | let post_type = if helper.is_downloadable_video() { 148 | PostType::Video 149 | } else if post_hint == Some("image") { 150 | PostType::Image 151 | // post_hint => rich:video can be a link to a youtube video, which are not worthwhile to 152 | // download due to their length, though exceptions could be made for short (< 1min) videos 153 | } else if post_hint == Some("link") || post_hint == Some("rich:video") { 154 | PostType::Link 155 | } else if helper.is_self { 156 | PostType::SelfText 157 | } else if helper.is_gallery.unwrap_or(false) { 158 | PostType::Gallery 159 | } else { 160 | PostType::Unknown 161 | }; 162 | 163 | Ok(Post { 164 | id: helper.id, 165 | created: helper.created, 166 | subreddit: helper.subreddit, 167 | title: helper.title, 168 | is_video: helper.is_video, 169 | ups: helper.ups, 170 | permalink: helper.permalink, 171 | url: helper.url, 172 | post_hint: helper.post_hint, 173 | is_self: helper.is_self, 174 | crosspost_parent_list: helper.crosspost_parent_list, 175 | is_gallery: helper.is_gallery, 176 | post_type, 177 | gallery_data: helper.gallery_data, 178 | media_metadata: helper.media_metadata, 179 | }) 180 | } 181 | } 182 | 183 | impl Post { 184 | pub(crate) fn format_permalink_url(&self, base_url: Option<&str>) -> String { 185 | format_url_from_path(&self.permalink, base_url) 186 | } 187 | 188 | pub(crate) fn format_old_permalink_url(&self) -> String { 189 | to_old_reddit_url(&format_url_from_path(&self.permalink, None)) 190 | } 191 | } 192 | 193 | #[derive(Deserialize, Debug)] 194 | pub struct SubredditAboutResponse { 195 | pub data: SubredditAbout, 196 | } 197 | 198 | #[derive(Deserialize, Debug)] 199 | pub struct SubredditAbout { 200 | pub display_name: String, 201 | pub display_name_prefixed: String, 202 | } 203 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tgreddit 2 | 3 | A telegram bot that gives you a feed of top posts from your favorite subreddits. 4 | 5 | The killer feature: No need to visit Reddit, as all media is embedded thanks to 6 | [yt-dlp][yt-dlp] and Telegram's excellent media support. 7 | 8 | Intended to be self-hosted, as Reddit's API has rate-limiting and downloading 9 | videos with `yt-dlp` can be resource intensive. The simplest way to self-host is 10 | to use the prebuilt [docker image](#docker-image) that includes necessary 11 | dependencies. 12 | 13 | 14 | 15 | 16 | 17 | ## install 18 | 19 | ```sh 20 | $ cargo install tgreddit 21 | ``` 22 | 23 | ### requirements 24 | 25 | Depends on [yt-dlp][yt-dlp] (and for good results, yt-dlp requires ffmpeg). 26 | 27 | ## bot commands 28 | 29 | ### `/sub [limit=] [time=