xkcdtg/src/main.rs

184 lines
4.2 KiB
Rust
Raw Normal View History

2024-11-26 22:45:46 +04:00
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<Url>,
send_url: Arc<Url>,
}
/// Telegram Bot API response:
/// these idiots use `{ok:true,response:...}` schema
/// for some reason
#[derive(Debug, Deserialize)]
struct TgResponse {
result: Vec<TgUpdate>,
}
/// Telegram Bot API update event schema
#[derive(Debug, Deserialize)]
struct TgUpdate {
update_id: u32,
message: Option<TgMessage>,
}
/// Telegram Bot API message object schema
#[derive(Debug, Deserialize)]
struct TgMessage {
#[serde(rename = "message_thread_id")]
thread: Option<i32>,
chat: TgChat,
text: Option<String>,
}
/// 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::<TgResponse>()
.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::<u16>().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::<XkcdInfo>()
.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(())
}