mod types; use types::*; use reqwest::{Client, Url}; 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"; /// Length of the command substring const CMD_LEN: usize = COMMAND.len(); /// 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"]"#; #[tokio::main] async fn main() -> std::io::Result<()> { let cfg = { let token = std::env::var("BOT_TOKEN").expect("invalid BOT_TOKEN env var value"); let client = Client::new(); init_cmds(&client, &token) .await .expect("could not make request to API"); Config { client, 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 init_cmds(client: &Client, token: &str) -> Result<(), reqwest::Error> { client .post(format!("{BOT_URL}{token}/setMyCommands")) .form(&[( "commands", serde_json::json!([ { "command": COMMAND[1..], // cmd name without leading slash "description": "Get XKCD comic by id", } ]) .to_string(), )]) .send() .await? .error_for_status()?; 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? .error_for_status()? .json::() .await? .result; updates .iter_mut() .filter_map(|u| { u.message.take().and_then(|mut msg| { msg.text.take().and_then(|text| { // starts with our command if text.get(..CMD_LEN)? == COMMAND // check space or @ after cmd name // to ensure that our command is // not a part of any other cmd && [" ", "@"].contains(&text.get(CMD_LEN..CMD_LEN + 1)?) { let mut args = text.split_whitespace(); args.next(); // skip command name match args.next()? { "ru" => { let comic_id = args.next()?.parse::().ok()?; let url = format!("https://xkcd.ru/{}/?json=true", comic_id); Some(SenderCtx { msg, url, comic_id }) } "en" => { let comic_id = args.next()?.parse::().ok()?; let url = format!("https://xkcd.com/{}/info.0.json", comic_id); Some(SenderCtx { msg, url, comic_id }) } comic_id => { let comic_id = comic_id.parse::().ok()?; let url = format!("https://xkcd.com/{}/info.0.json", comic_id); Some(SenderCtx { msg, url, comic_id }) } } } else { None } }) }) }) .for_each(|ctx| { tokio::spawn(send_comic(cfg.clone(), ctx)); }); if let Some(u) = updates.last() { s.offset = u.update_id + 1; } Ok(()) } async fn send_comic(cfg: Config, ctx: SenderCtx) -> Result<(), reqwest::Error> { let info = cfg .client .get(ctx.url) .send() .await? .error_for_status()? .json::() .await?; cfg.client .post((*cfg.send_url).clone()) .form(&[ ("chat_id", ctx.msg.chat.id.to_string()), ( "message_thread_id", ctx.msg .thread .map(|t| t.to_string()) .unwrap_or("null".to_owned()), ), ("photo", info.img), ("caption", info.alt), ]) .send() .await?; Ok(()) }