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