Lines of
src/tg_bot.rs
from check-in 374eadef45
that are changed by the sequence of edits moving toward
check-in 3fd8c40aa8:
1: use crate::{
2: Arc,
3: Mutex,
4: };
5:
6: use std::{
7: borrow::Cow,
374eadef45 2026-03-28 8: collections::HashMap,
9: fmt,
10: };
11:
12: use serde::{
13: Deserialize,
14: Serialize,
15: };
16: use stacked_errors::{
17: bail,
18: Result,
19: StackableErr,
20: };
21: use tgbot::{
22: api::Client,
23: types::{
24: Bot,
25: ChatPeerId,
26: GetBot,
27: InlineKeyboardButton,
28: InlineKeyboardMarkup,
29: Message,
30: ParseMode,
31: SendMessage,
32: },
33: };
34:
35: const CB_VERSION: u8 = 0;
36:
37: #[derive(Serialize, Deserialize, Debug)]
38: pub enum Callback {
39: // List all feeds (version, name to show, page number)
40: List(u8, String, u8),
41: }
42:
43: impl Callback {
374eadef45 2026-03-28 44: pub fn list (text: &str, page: u8) -> Callback {
374eadef45 2026-03-28 45: Callback::List(CB_VERSION, text.to_owned(), page)
46: }
47:
48: fn version (&self) -> u8 {
49: match self {
50: Callback::List(version, .. ) => *version,
51: }
52: }
53: }
54:
55: impl fmt::Display for Callback {
56: fn fmt (&self, f: &mut fmt::Formatter) -> fmt::Result {
57: f.write_str(&toml::to_string(self).map_err(|_| fmt::Error)?)
58: }
59: }
60:
61: /// Produce new Keyboard Markup from current Callback
374eadef45 2026-03-28 62: pub async fn get_kb (cb: &Callback, feeds: Arc<Mutex<HashMap<i32, String>>>) -> Result<InlineKeyboardMarkup> {
63: if cb.version() != CB_VERSION {
64: bail!("Wrong callback version.");
65: }
66: let mark = match cb {
67: Callback::List(_, name, page) => {
68: let mut kb = vec![];
69: let feeds = feeds.lock_arc().await;
70: let long = feeds.len() > 6;
71: let (start, end) = if long {
374eadef45 2026-03-28 72: (page * 5, 5 + page * 5)
73: } else {
74: (0, 6)
75: };
76: let mut i = 0;
77: if name.is_empty() {
78: for (id, name) in feeds.iter() {
79: if i < start { continue }
374eadef45 2026-03-28 80: if i > end { break }
374eadef45 2026-03-28 81: i += 1;
82: kb.push(vec![
83: InlineKeyboardButton::for_callback_data(
84: format!("{}. {}", id, name),
374eadef45 2026-03-28 85: Callback::list("xxx", *page).to_string()), // XXX edit
86: ]);
87: }
88: } else {
89: let mut found = false;
90: let mut first_page = None;
91: for (id, feed_name) in feeds.iter() {
92: if name == feed_name {
93: found = true;
94: }
95: i += 1;
96: kb.push(vec![
97: InlineKeyboardButton::for_callback_data(
98: format!("{}. {}", id, feed_name),
99: Callback::list("xxx", *page).to_string()), // XXX edit
100: ]);
101: if i > end {
102: // page complete, if found we got the right page, if not - reset and
103: // continue.
104: if found {
105: break
106: } else {
107: if first_page.is_none() {
108: first_page = Some(kb);
109: }
110: kb = vec![];
111: i = 0
112: }
113: }
114: }
115: if !found {
116: // name not found, showing first page
117: kb = first_page.unwrap_or_default();
118: }
119: }
120: if long {
121: kb.push(vec![
122: InlineKeyboardButton::for_callback_data("<<",
123: Callback::list("", if *page == 0 { *page } else { page - 1 } ).to_string()),
124: InlineKeyboardButton::for_callback_data(">>",
125: Callback::list("", page + 1).to_string()),
126: ]);
127: }
128: InlineKeyboardMarkup::from(kb)
129: },
130: };
131: Ok(mark)
132: }
133:
134: pub enum MyMessage <'a> {
135: Html { text: Cow<'a, str> },
136: HtmlTo { text: Cow<'a, str>, to: ChatPeerId },
137: HtmlToKb { text: Cow<'a, str>, to: ChatPeerId, kb: InlineKeyboardMarkup },
374eadef45 2026-03-28 138: Text { text: Cow<'a, str> },
374eadef45 2026-03-28 139: TextTo { text: Cow<'a, str>, to: ChatPeerId },
140: }
141:
142: impl MyMessage <'_> {
143: pub fn html <'a, S> (text: S) -> MyMessage<'a>
144: where S: Into<Cow<'a, str>> {
145: let text = text.into();
146: MyMessage::Html { text }
147: }
148:
149: pub fn html_to <'a, S> (text: S, to: ChatPeerId) -> MyMessage<'a>
150: where S: Into<Cow<'a, str>> {
151: let text = text.into();
152: MyMessage::HtmlTo { text, to }
153: }
154:
155: pub fn html_to_kb <'a, S> (text: S, to: ChatPeerId, kb: InlineKeyboardMarkup) -> MyMessage<'a>
156: where S: Into<Cow<'a, str>> {
157: let text = text.into();
158: MyMessage::HtmlToKb { text, to, kb }
159: }
160:
374eadef45 2026-03-28 161: pub fn text <'a, S> (text: S) -> MyMessage<'a>
374eadef45 2026-03-28 162: where S: Into<Cow<'a, str>> {
374eadef45 2026-03-28 163: let text = text.into();
374eadef45 2026-03-28 164: MyMessage::Text { text }
374eadef45 2026-03-28 165: }
374eadef45 2026-03-28 166:
374eadef45 2026-03-28 167: pub fn text_to <'a, S> (text: S, to: ChatPeerId) -> MyMessage<'a>
374eadef45 2026-03-28 168: where S: Into<Cow<'a, str>> {
374eadef45 2026-03-28 169: let text = text.into();
374eadef45 2026-03-28 170: MyMessage::TextTo { text, to }
374eadef45 2026-03-28 171: }
374eadef45 2026-03-28 172:
173: fn req (&self, tg: &Tg) -> Result<SendMessage> {
174: Ok(match self {
175: MyMessage::Html { text } =>
176: SendMessage::new(tg.owner, text.as_ref())
177: .with_parse_mode(ParseMode::Html),
178: MyMessage::HtmlTo { text, to } =>
179: SendMessage::new(*to, text.as_ref())
180: .with_parse_mode(ParseMode::Html),
181: MyMessage::HtmlToKb { text, to, kb } =>
182: SendMessage::new(*to, text.as_ref())
183: .with_parse_mode(ParseMode::Html)
184: .with_reply_markup(kb.clone()),
374eadef45 2026-03-28 185: MyMessage::Text { text } =>
374eadef45 2026-03-28 186: SendMessage::new(tg.owner, text.as_ref())
374eadef45 2026-03-28 187: .with_parse_mode(ParseMode::MarkdownV2),
374eadef45 2026-03-28 188: MyMessage::TextTo { text, to } =>
374eadef45 2026-03-28 189: SendMessage::new(*to, text.as_ref())
374eadef45 2026-03-28 190: .with_parse_mode(ParseMode::MarkdownV2),
191: })
192: }
193: }
194:
195: #[derive(Clone)]
196: pub struct Tg {
197: pub me: Bot,
198: pub owner: ChatPeerId,
199: pub client: Client,
200: }
201:
202: impl Tg {
203: /// Construct a new `Tg` instance from configuration.
204: ///
205: /// The `settings` must provide the following keys:
206: /// - `"api_key"` (string),
207: /// - `"owner"` (integer chat id),
208: /// - `"api_gateway"` (string).
209: ///
210: /// The function initialises the client, configures the gateway and fetches the bot identity
211: /// before returning the constructed `Tg`.
212: pub async fn new (settings: &config::Config) -> Result<Tg> {
213: let api_key = settings.get_string("api_key").stack()?;
214:
215: let owner = ChatPeerId::from(settings.get_int("owner").stack()?);
216: let client = Client::new(&api_key).stack()?
217: .with_host(settings.get_string("api_gateway").stack()?)
218: .with_max_retries(0);
219: let me = client.execute(GetBot).await.stack()?;
220: Ok(Tg {
221: me,
222: owner,
223: client,
224: })
225: }
226:
227: /// Send a text message to a chat, using an optional target and parse mode.
228: ///
229: /// # Returns
230: /// The sent `Message` on success.
231: pub async fn send (&self, msg: MyMessage<'_>) -> Result<Message> {
232: self.client.execute(msg.req(self)?).await.stack()
233: }
234:
235: /// Create a copy of this `Tg` with the owner replaced by the given chat ID.
236: ///
237: /// # Parameters
238: /// - `owner`: The Telegram chat identifier to set as the new owner (expressed as an `i64`).
239: ///
240: /// # Returns
241: /// A new `Tg` instance identical to the original except its `owner` field is set to the provided chat ID.
242: pub fn with_owner <O>(&self, owner: O) -> Tg
243: where O: Into<i64> {
244: Tg {
245: owner: ChatPeerId::from(owner.into()),
246: ..self.clone()
247: }
248: }
249: }