use std::sync::Arc; use reqwest::{Client, Url}; use serde::Deserialize; use tokio::signal::unix::{signal, SignalKind}; const BOT_URL: &str = "https://api.telegram.org/bot"; /// Command to search for in a message text const COMMAND: &str = "/xkcd"; /// Long polling timeout in seconds const POLL_TIMEOUT: &str = "300"; /// Update types to receive (filtered out on tg backend) const POLL_TYPES: &str = r#"["message"]"#; /// Shared mutable state object #[derive(Debug)] struct State { offset: u32, } /// Shared config object #[derive(Debug, Clone)] struct Config { client: Client, upd_url: Arc, send_url: Arc, } /// Telegram Bot API response: /// these idiots use `{ok:true,response:...}` schema /// for some reason #[derive(Debug, Deserialize)] struct TgResponse { result: Vec, } /// Telegram Bot API update event schema #[derive(Debug, Deserialize)] struct TgUpdate { update_id: u32, message: Option, } /// Telegram Bot API message object schema #[derive(Debug, Deserialize)] struct TgMessage { #[serde(rename = "message_thread_id")] thread: Option, chat: TgChat, text: Option, } /// Telegram Bot API chat object schema #[derive(Debug, Deserialize)] struct TgChat { id: i64, // #[serde(default)] // is_forum: bool, } /// XKCD.com API comic info schema #[derive(Debug, Deserialize)] struct XkcdInfo { alt: String, img: String, } #[tokio::main] async fn main() -> std::io::Result<()> { let cfg = { let token = std::env::var("BOT_TOKEN").expect("invalid BOT_TOKEN env var value"); Config { client: Client::new(), upd_url: Url::parse(&format!("{BOT_URL}{token}/getUpdates")) .unwrap() .into(), send_url: Url::parse(&format!("{BOT_URL}{token}/sendPhoto")) .unwrap() .into(), } }; let mut s = State { offset: 0 }; let mut sigint = signal(SignalKind::interrupt())?; let mut sigterm = signal(SignalKind::terminate())?; loop { tokio::select! { _ = sigint.recv() => { break; } _ = sigterm.recv() => { break; } out = handler(&cfg, &mut s) => { match out { Ok(_) => {} Err(e) => { eprintln!("[handler] {:?}", e); } } } } } Ok(()) } async fn handler(cfg: &Config, s: &mut State) -> Result<(), reqwest::Error> { let mut updates = cfg .client .get((*cfg.upd_url).clone()) .query(&[ ("offset", s.offset.to_string().as_str()), ("timeout", POLL_TIMEOUT), ("allowed_updates", POLL_TYPES), ]) .send() .await? .json::() .await? .result; for u in &mut updates { let Some(mut msg) = u.message.take() else { continue; }; let Some(text) = msg.text.take() else { continue; }; if !text.starts_with(COMMAND) { continue; } let Some(comic_id) = text .split_whitespace() .skip(1) .next() .and_then(|id| id.parse::().ok()) else { continue; }; tokio::spawn(send_comic(cfg.clone(), msg, comic_id)); } if let Some(u) = updates.last() { s.offset = u.update_id + 1; } Ok(()) } async fn send_comic(cfg: Config, msg: TgMessage, comic_id: u16) -> Result<(), reqwest::Error> { let info = cfg .client .get(format!("https://xkcd.com/{}/info.0.json", comic_id)) .send() .await? .json::() .await?; cfg.client .post((*cfg.send_url).clone()) .form(&[ ("chat_id", msg.chat.id.to_string()), ( "message_thread_id", msg.thread .map(|t| t.to_string()) .unwrap_or("null".to_owned()), ), ("photo", info.img), ("caption", info.alt), ]) .send() .await?; Ok(()) }