9adc69d514 2026-03-25 1: use crate::{
9adc69d514 2026-03-25 2: Arc,
9adc69d514 2026-03-25 3: Mutex,
3fd8c40aa8 2026-03-30 4: core::FeedList,
9adc69d514 2026-03-25 5: };
9adc69d514 2026-03-25 6:
9adc69d514 2026-03-25 7: use std::{
9adc69d514 2026-03-25 8: borrow::Cow,
9adc69d514 2026-03-25 9: fmt,
be0b8602d1 2026-04-18 10: time::Duration,
9adc69d514 2026-03-25 11: };
9adc69d514 2026-03-25 12:
d8c1d259a2 2026-04-23 13: use lazy_static::lazy_static;
13265e7697 2026-01-10 14: use serde::{
13265e7697 2026-01-10 15: Deserialize,
13265e7697 2026-01-10 16: Serialize,
13265e7697 2026-01-10 17: };
d8c1d259a2 2026-04-23 18: use regex::Regex;
be0b8602d1 2026-04-18 19: use smol::Timer;
9c4f09193a 2026-01-09 20: use stacked_errors::{
9adc69d514 2026-03-25 21: bail,
9c4f09193a 2026-01-09 22: Result,
9c4f09193a 2026-01-09 23: StackableErr,
9c4f09193a 2026-01-09 24: };
9c4f09193a 2026-01-09 25: use tgbot::{
be0b8602d1 2026-04-18 26: api::{
be0b8602d1 2026-04-18 27: Client,
be0b8602d1 2026-04-18 28: ExecuteError
be0b8602d1 2026-04-18 29: },
9c4f09193a 2026-01-09 30: types::{
3fd8c40aa8 2026-03-30 31: AnswerCallbackQuery,
9c4f09193a 2026-01-09 32: Bot,
9c4f09193a 2026-01-09 33: ChatPeerId,
be0b8602d1 2026-04-18 34: EditMessageResult,
be0b8602d1 2026-04-18 35: EditMessageText,
9c4f09193a 2026-01-09 36: GetBot,
13265e7697 2026-01-10 37: InlineKeyboardButton,
13265e7697 2026-01-10 38: InlineKeyboardMarkup,
9c4f09193a 2026-01-09 39: Message,
9c4f09193a 2026-01-09 40: ParseMode,
9c4f09193a 2026-01-09 41: SendMessage,
9c4f09193a 2026-01-09 42: },
9c4f09193a 2026-01-09 43: };
13265e7697 2026-01-10 44:
9adc69d514 2026-03-25 45: const CB_VERSION: u8 = 0;
d8c1d259a2 2026-04-23 46:
d8c1d259a2 2026-04-23 47: lazy_static! {
d8c1d259a2 2026-04-23 48: pub static ref RE_CLOSING: Regex = Regex::new(r"</[ \t]*(pre|code)[ \t]*>").unwrap();
d8c1d259a2 2026-04-23 49: }
d8c1d259a2 2026-04-23 50:
d8c1d259a2 2026-04-23 51: // validate input as being postable in preformatted block, all html tags are fine, except tags that
d8c1d259a2 2026-04-23 52: // break the block - </pre> and </code>, we don't need to escape anything else, as telegram manages
d8c1d259a2 2026-04-23 53: // that
d8c1d259a2 2026-04-23 54: pub fn validate (text: &str) -> Result<&str> {
d8c1d259a2 2026-04-23 55: if RE_CLOSING.is_match(text) {
d8c1d259a2 2026-04-23 56: bail!("Telegram closing tag found.");
d8c1d259a2 2026-04-23 57: } else {
d8c1d259a2 2026-04-23 58: Ok(text)
d8c1d259a2 2026-04-23 59: }
d8c1d259a2 2026-04-23 60: }
be0b8602d1 2026-04-18 61:
13265e7697 2026-01-10 62: #[derive(Serialize, Deserialize, Debug)]
9adc69d514 2026-03-25 63: pub enum Callback {
3fd8c40aa8 2026-03-30 64: // Edit one feed (version, name)
3fd8c40aa8 2026-03-30 65: Edit(u8, String),
9adc69d514 2026-03-25 66: // List all feeds (version, name to show, page number)
be0b8602d1 2026-04-18 67: List(u8, String, usize),
3fd8c40aa8 2026-03-30 68: // Show root menu (version)
3fd8c40aa8 2026-03-30 69: Menu(u8),
9adc69d514 2026-03-25 70: }
9adc69d514 2026-03-25 71:
9adc69d514 2026-03-25 72: impl Callback {
3fd8c40aa8 2026-03-30 73: pub fn edit <S>(text: S) -> Callback
3fd8c40aa8 2026-03-30 74: where S: Into<String> {
3fd8c40aa8 2026-03-30 75: Callback::Edit(CB_VERSION, text.into())
3fd8c40aa8 2026-03-30 76: }
3fd8c40aa8 2026-03-30 77:
be0b8602d1 2026-04-18 78: pub fn list <S>(text: S, page: usize) -> Callback
3fd8c40aa8 2026-03-30 79: where S: Into<String> {
3fd8c40aa8 2026-03-30 80: Callback::List(CB_VERSION, text.into(), page)
3fd8c40aa8 2026-03-30 81: }
3fd8c40aa8 2026-03-30 82:
3fd8c40aa8 2026-03-30 83: pub fn menu () -> Callback {
3fd8c40aa8 2026-03-30 84: Callback::Menu(CB_VERSION)
9adc69d514 2026-03-25 85: }
9adc69d514 2026-03-25 86:
9adc69d514 2026-03-25 87: fn version (&self) -> u8 {
9adc69d514 2026-03-25 88: match self {
3fd8c40aa8 2026-03-30 89: Callback::Edit(version, .. ) => *version,
9adc69d514 2026-03-25 90: Callback::List(version, .. ) => *version,
3fd8c40aa8 2026-03-30 91: Callback::Menu(version) => *version,
9adc69d514 2026-03-25 92: }
9adc69d514 2026-03-25 93: }
9adc69d514 2026-03-25 94: }
9adc69d514 2026-03-25 95:
9adc69d514 2026-03-25 96: impl fmt::Display for Callback {
9adc69d514 2026-03-25 97: fn fmt (&self, f: &mut fmt::Formatter) -> fmt::Result {
9adc69d514 2026-03-25 98: f.write_str(&toml::to_string(self).map_err(|_| fmt::Error)?)
9adc69d514 2026-03-25 99: }
9adc69d514 2026-03-25 100: }
9adc69d514 2026-03-25 101:
9adc69d514 2026-03-25 102: /// Produce new Keyboard Markup from current Callback
be0b8602d1 2026-04-18 103: pub async fn get_kb (cb: &Callback, feeds: &Arc<Mutex<FeedList>>) -> Result<InlineKeyboardMarkup> {
9adc69d514 2026-03-25 104: if cb.version() != CB_VERSION {
9adc69d514 2026-03-25 105: bail!("Wrong callback version.");
9adc69d514 2026-03-25 106: }
9adc69d514 2026-03-25 107: let mark = match cb {
3fd8c40aa8 2026-03-30 108: Callback::Edit(_, _name) => { // XXX edit missing
3fd8c40aa8 2026-03-30 109: let kb: Vec<Vec<InlineKeyboardButton>> = vec![];
3fd8c40aa8 2026-03-30 110: InlineKeyboardMarkup::from(kb)
3fd8c40aa8 2026-03-30 111: },
9adc69d514 2026-03-25 112: Callback::List(_, name, page) => {
9adc69d514 2026-03-25 113: let mut kb = vec![];
9adc69d514 2026-03-25 114: let feeds = feeds.lock_arc().await;
9adc69d514 2026-03-25 115: let long = feeds.len() > 6;
9adc69d514 2026-03-25 116: let (start, end) = if long {
be0b8602d1 2026-04-18 117: (page * 5, 5 + page * 5)
9adc69d514 2026-03-25 118: } else {
9adc69d514 2026-03-25 119: (0, 6)
9adc69d514 2026-03-25 120: };
9adc69d514 2026-03-25 121: let mut i = 0;
9adc69d514 2026-03-25 122: if name.is_empty() {
be0b8602d1 2026-04-18 123: let feed_iter = feeds.iter().skip(start);
be0b8602d1 2026-04-18 124: for (id, name) in feed_iter {
374eadef45 2026-03-28 125: kb.push(vec![
374eadef45 2026-03-28 126: InlineKeyboardButton::for_callback_data(
374eadef45 2026-03-28 127: format!("{}. {}", id, name),
3fd8c40aa8 2026-03-30 128: Callback::edit(name).to_string()),
374eadef45 2026-03-28 129: ]);
be0b8602d1 2026-04-18 130: i += 1;
be0b8602d1 2026-04-18 131: if i == end { break }
374eadef45 2026-03-28 132: }
9adc69d514 2026-03-25 133: } else {
374eadef45 2026-03-28 134: let mut found = false;
374eadef45 2026-03-28 135: let mut first_page = None;
374eadef45 2026-03-28 136: for (id, feed_name) in feeds.iter() {
374eadef45 2026-03-28 137: if name == feed_name {
374eadef45 2026-03-28 138: found = true;
374eadef45 2026-03-28 139: }
374eadef45 2026-03-28 140: i += 1;
374eadef45 2026-03-28 141: kb.push(vec![
374eadef45 2026-03-28 142: InlineKeyboardButton::for_callback_data(
374eadef45 2026-03-28 143: format!("{}. {}", id, feed_name),
374eadef45 2026-03-28 144: Callback::list("xxx", *page).to_string()), // XXX edit
374eadef45 2026-03-28 145: ]);
be0b8602d1 2026-04-18 146: if i == end {
374eadef45 2026-03-28 147: // page complete, if found we got the right page, if not - reset and
374eadef45 2026-03-28 148: // continue.
374eadef45 2026-03-28 149: if found {
374eadef45 2026-03-28 150: break
374eadef45 2026-03-28 151: } else {
374eadef45 2026-03-28 152: if first_page.is_none() {
374eadef45 2026-03-28 153: first_page = Some(kb);
374eadef45 2026-03-28 154: }
374eadef45 2026-03-28 155: kb = vec![];
374eadef45 2026-03-28 156: i = 0
374eadef45 2026-03-28 157: }
374eadef45 2026-03-28 158: }
374eadef45 2026-03-28 159: }
374eadef45 2026-03-28 160: if !found {
374eadef45 2026-03-28 161: // name not found, showing first page
374eadef45 2026-03-28 162: kb = first_page.unwrap_or_default();
374eadef45 2026-03-28 163: }
9adc69d514 2026-03-25 164: }
9adc69d514 2026-03-25 165: if long {
9adc69d514 2026-03-25 166: kb.push(vec![
9adc69d514 2026-03-25 167: InlineKeyboardButton::for_callback_data("<<",
9adc69d514 2026-03-25 168: Callback::list("", if *page == 0 { *page } else { page - 1 } ).to_string()),
9adc69d514 2026-03-25 169: InlineKeyboardButton::for_callback_data(">>",
d8c1d259a2 2026-04-23 170: Callback::list("", page.saturating_add(1)).to_string()),
9adc69d514 2026-03-25 171: ]);
9adc69d514 2026-03-25 172: }
3fd8c40aa8 2026-03-30 173: InlineKeyboardMarkup::from(kb)
3fd8c40aa8 2026-03-30 174: },
3fd8c40aa8 2026-03-30 175: Callback::Menu(_) => {
3fd8c40aa8 2026-03-30 176: let kb = vec![
3fd8c40aa8 2026-03-30 177: vec![
3fd8c40aa8 2026-03-30 178: InlineKeyboardButton::for_callback_data(
3fd8c40aa8 2026-03-30 179: "Add new channel",
3fd8c40aa8 2026-03-30 180: Callback::menu().to_string()), // new XXX
3fd8c40aa8 2026-03-30 181: ],
3fd8c40aa8 2026-03-30 182: vec![
3fd8c40aa8 2026-03-30 183: InlineKeyboardButton::for_callback_data(
3fd8c40aa8 2026-03-30 184: "List channels",
3fd8c40aa8 2026-03-30 185: Callback::list("", 0).to_string()),
3fd8c40aa8 2026-03-30 186: ],
3fd8c40aa8 2026-03-30 187: ];
9adc69d514 2026-03-25 188: InlineKeyboardMarkup::from(kb)
9adc69d514 2026-03-25 189: },
9adc69d514 2026-03-25 190: };
9adc69d514 2026-03-25 191: Ok(mark)
9adc69d514 2026-03-25 192: }
9adc69d514 2026-03-25 193:
9adc69d514 2026-03-25 194: pub enum MyMessage <'a> {
9adc69d514 2026-03-25 195: Html { text: Cow<'a, str> },
9adc69d514 2026-03-25 196: HtmlTo { text: Cow<'a, str>, to: ChatPeerId },
9adc69d514 2026-03-25 197: HtmlToKb { text: Cow<'a, str>, to: ChatPeerId, kb: InlineKeyboardMarkup },
13265e7697 2026-01-10 198: }
13265e7697 2026-01-10 199:
9adc69d514 2026-03-25 200: impl MyMessage <'_> {
9adc69d514 2026-03-25 201: pub fn html <'a, S> (text: S) -> MyMessage<'a>
9adc69d514 2026-03-25 202: where S: Into<Cow<'a, str>> {
9adc69d514 2026-03-25 203: let text = text.into();
9adc69d514 2026-03-25 204: MyMessage::Html { text }
9adc69d514 2026-03-25 205: }
9adc69d514 2026-03-25 206:
9adc69d514 2026-03-25 207: pub fn html_to <'a, S> (text: S, to: ChatPeerId) -> MyMessage<'a>
9adc69d514 2026-03-25 208: where S: Into<Cow<'a, str>> {
9adc69d514 2026-03-25 209: let text = text.into();
9adc69d514 2026-03-25 210: MyMessage::HtmlTo { text, to }
9adc69d514 2026-03-25 211: }
9adc69d514 2026-03-25 212:
9adc69d514 2026-03-25 213: pub fn html_to_kb <'a, S> (text: S, to: ChatPeerId, kb: InlineKeyboardMarkup) -> MyMessage<'a>
9adc69d514 2026-03-25 214: where S: Into<Cow<'a, str>> {
9adc69d514 2026-03-25 215: let text = text.into();
9adc69d514 2026-03-25 216: MyMessage::HtmlToKb { text, to, kb }
9adc69d514 2026-03-25 217: }
9adc69d514 2026-03-25 218:
d8c1d259a2 2026-04-23 219: fn req (&self, tg: &Tg) -> SendMessage {
d8c1d259a2 2026-04-23 220: match self {
9adc69d514 2026-03-25 221: MyMessage::Html { text } =>
9adc69d514 2026-03-25 222: SendMessage::new(tg.owner, text.as_ref())
9adc69d514 2026-03-25 223: .with_parse_mode(ParseMode::Html),
9adc69d514 2026-03-25 224: MyMessage::HtmlTo { text, to } =>
9adc69d514 2026-03-25 225: SendMessage::new(*to, text.as_ref())
9adc69d514 2026-03-25 226: .with_parse_mode(ParseMode::Html),
9adc69d514 2026-03-25 227: MyMessage::HtmlToKb { text, to, kb } =>
9adc69d514 2026-03-25 228: SendMessage::new(*to, text.as_ref())
9adc69d514 2026-03-25 229: .with_parse_mode(ParseMode::Html)
9adc69d514 2026-03-25 230: .with_reply_markup(kb.clone()),
d8c1d259a2 2026-04-23 231: }
9adc69d514 2026-03-25 232: }
13265e7697 2026-01-10 233: }
9c4f09193a 2026-01-09 234:
9c4f09193a 2026-01-09 235: #[derive(Clone)]
9c4f09193a 2026-01-09 236: pub struct Tg {
9c4f09193a 2026-01-09 237: pub me: Bot,
9c4f09193a 2026-01-09 238: pub owner: ChatPeerId,
9c4f09193a 2026-01-09 239: pub client: Client,
9c4f09193a 2026-01-09 240: }
9c4f09193a 2026-01-09 241:
9c4f09193a 2026-01-09 242: impl Tg {
fabcca1eaf 2026-01-09 243: /// Construct a new `Tg` instance from configuration.
fabcca1eaf 2026-01-09 244: ///
fabcca1eaf 2026-01-09 245: /// The `settings` must provide the following keys:
fabcca1eaf 2026-01-09 246: /// - `"api_key"` (string),
fabcca1eaf 2026-01-09 247: /// - `"owner"` (integer chat id),
fabcca1eaf 2026-01-09 248: /// - `"api_gateway"` (string).
fabcca1eaf 2026-01-09 249: ///
fabcca1eaf 2026-01-09 250: /// The function initialises the client, configures the gateway and fetches the bot identity
fabcca1eaf 2026-01-09 251: /// before returning the constructed `Tg`.
9c4f09193a 2026-01-09 252: pub async fn new (settings: &config::Config) -> Result<Tg> {
9c4f09193a 2026-01-09 253: let api_key = settings.get_string("api_key").stack()?;
9c4f09193a 2026-01-09 254:
9c4f09193a 2026-01-09 255: let owner = ChatPeerId::from(settings.get_int("owner").stack()?);
be0b8602d1 2026-04-18 256: // We don't use retries, as in async environment this will just get us stuck for extra
be0b8602d1 2026-04-18 257: // amount of time on simple requests. Just bail, show error and ack it in the code. In
be0b8602d1 2026-04-18 258: // other case we might got stuck with multiple open transactions in database.
9c4f09193a 2026-01-09 259: let client = Client::new(&api_key).stack()?
9c4f09193a 2026-01-09 260: .with_host(settings.get_string("api_gateway").stack()?)
9c4f09193a 2026-01-09 261: .with_max_retries(0);
9c4f09193a 2026-01-09 262: let me = client.execute(GetBot).await.stack()?;
9c4f09193a 2026-01-09 263: Ok(Tg {
9c4f09193a 2026-01-09 264: me,
9c4f09193a 2026-01-09 265: owner,
9c4f09193a 2026-01-09 266: client,
9c4f09193a 2026-01-09 267: })
9c4f09193a 2026-01-09 268: }
9c4f09193a 2026-01-09 269:
fabcca1eaf 2026-01-09 270: /// Send a text message to a chat, using an optional target and parse mode.
fabcca1eaf 2026-01-09 271: ///
fabcca1eaf 2026-01-09 272: /// # Returns
fabcca1eaf 2026-01-09 273: /// The sent `Message` on success.
9adc69d514 2026-03-25 274: pub async fn send (&self, msg: MyMessage<'_>) -> Result<Message> {
d8c1d259a2 2026-04-23 275: self.client.execute(msg.req(self)).await.stack()
3fd8c40aa8 2026-03-30 276: }
3fd8c40aa8 2026-03-30 277:
3fd8c40aa8 2026-03-30 278: pub async fn answer_cb (&self, id: String, text: String) -> Result<bool> {
3fd8c40aa8 2026-03-30 279: self.client.execute(
3fd8c40aa8 2026-03-30 280: AnswerCallbackQuery::new(id)
3fd8c40aa8 2026-03-30 281: .with_text(text)
3fd8c40aa8 2026-03-30 282: ).await.stack()
9c4f09193a 2026-01-09 283: }
9c4f09193a 2026-01-09 284:
fabcca1eaf 2026-01-09 285: /// Create a copy of this `Tg` with the owner replaced by the given chat ID.
fabcca1eaf 2026-01-09 286: ///
fabcca1eaf 2026-01-09 287: /// # Parameters
fabcca1eaf 2026-01-09 288: /// - `owner`: The Telegram chat identifier to set as the new owner (expressed as an `i64`).
fabcca1eaf 2026-01-09 289: ///
fabcca1eaf 2026-01-09 290: /// # Returns
fabcca1eaf 2026-01-09 291: /// A new `Tg` instance identical to the original except its `owner` field is set to the provided chat ID.
fabcca1eaf 2026-01-09 292: pub fn with_owner <O>(&self, owner: O) -> Tg
fabcca1eaf 2026-01-09 293: where O: Into<i64> {
9c4f09193a 2026-01-09 294: Tg {
fabcca1eaf 2026-01-09 295: owner: ChatPeerId::from(owner.into()),
9c4f09193a 2026-01-09 296: ..self.clone()
be0b8602d1 2026-04-18 297: }
be0b8602d1 2026-04-18 298: }
be0b8602d1 2026-04-18 299:
d8c1d259a2 2026-04-23 300: // XXX Can loop indefinitely if API calls results retry_after, add max retries?
be0b8602d1 2026-04-18 301: pub async fn update_message (&self, chat_id: i64, message_id: i64, text: String, feeds: &Arc<Mutex<FeedList>>, cb: Callback) -> Result<EditMessageResult> {
be0b8602d1 2026-04-18 302: loop {
be0b8602d1 2026-04-18 303: let req = EditMessageText::for_chat_message(chat_id, message_id, &text)
be0b8602d1 2026-04-18 304: .with_reply_markup(get_kb(&cb, feeds).await.stack()?);
be0b8602d1 2026-04-18 305: let res = self.client.execute(req).await;
be0b8602d1 2026-04-18 306: match res {
be0b8602d1 2026-04-18 307: Ok(res) => return Ok(res),
be0b8602d1 2026-04-18 308: Err(ref err) => {
be0b8602d1 2026-04-18 309: if let ExecuteError::Response(resp) = err
be0b8602d1 2026-04-18 310: && let Some(delay) = resp.retry_after()
be0b8602d1 2026-04-18 311: {
be0b8602d1 2026-04-18 312: if delay > 60 {
be0b8602d1 2026-04-18 313: return res.context("Delay too big (>60), not waiting.");
be0b8602d1 2026-04-18 314: }
be0b8602d1 2026-04-18 315: Timer::after(Duration::from_secs(delay)).await;
be0b8602d1 2026-04-18 316: } else {
be0b8602d1 2026-04-18 317: return res.context("Can't update message");
be0b8602d1 2026-04-18 318: }
be0b8602d1 2026-04-18 319: },
be0b8602d1 2026-04-18 320: };
9c4f09193a 2026-01-09 321: }
9c4f09193a 2026-01-09 322: }
9c4f09193a 2026-01-09 323: }