Check-in [3fd8c40aa8]
Logged in as anonymous
Overview
Comment:update workflow, get rid of TEXT MardownV2, rewrite handle, add more callbacks
Downloads: Tarball | ZIP archive | SQL archive
Timelines: family | ancestors | descendants | both | trunk
Files: files | file ages | folders
SHA3-256: 3fd8c40aa8d86f47c2d227490ecc7fb44b69ebba88edf35b79aa640ac79496a4
User & Date: arcade on 2026-03-30 14:20:11.771
Other Links: manifest | tags
Context
2026-04-18
18:31
bump, update workflow, add update reaction code, change paging logic leaf check-in: be0b8602d1 user: arcade tags: trunk
2026-03-30
14:20
update workflow, get rid of TEXT MardownV2, rewrite handle, add more callbacks check-in: 3fd8c40aa8 user: arcade tags: trunk
2026-03-28
13:24
comments, reordering, better paging check-in: 374eadef45 user: arcade tags: trunk
Changes
1

2





3
4
5
6
7
8
9
10
11
12
13
14

15
16
17
18

19
20
21
22
1
2

3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

24
25
26
27
28

+
-
+
+
+
+
+












+



-
+




name: rust-ci
on: [ pull_request ]
on: push

# sccache enable for rust/C builds
env:
  SCCACHE_GHA_ENABLED: "true"
  RUSTC_WRAPPER: "sccache"

jobs:
  rust-ci-run:
    name: Run rust-clippy analyzing and tests
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      # SETUP
      - uses: actions/checkout@v6
      - uses: dtolnay/rust-toolchain@stable
      - uses: Swatinem/rust-cache@v2
      - uses: mozilla-actions/sccache-action@v0.0.9

      # TESTS
      - name: Run tests
        run: cargo test --all-targets --all-features --release
        run: cargo test --all-targets --all-features

      # CLIPPY
      - name: Run rust-clippy
        run: cargo clippy --all-targets --all-features -- -D warnings
12
13
14
15
16
17
18

19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35


36
37
38
39
40
41
42
43
44
45
46
47
48
49

50
51
52
53
54
55
56
57
58


59
60
61
62
63
64
65
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34


35
36
37
38
39
40
41
42
43
44
45
46
47
48
49

50
51
52
53
54
55
56
57


58
59
60
61
62
63
64
65
66







+















-
-
+
+













-
+







-
-
+
+







use sedregex::ReplaceCommand;
use stacked_errors::{
	Result,
	StackableErr,
	bail,
};
use tgbot::types::{
	CallbackQuery,
	ChatMember,
	ChatUsername,
	GetChat,
	GetChatAdministrators,
	Message,
};
use url::Url;

lazy_static! {
	static ref RE_USERNAME: Regex = Regex::new(r"^@([a-zA-Z][a-zA-Z0-9_]+)$").unwrap();
	static ref RE_IV_HASH: Regex = Regex::new(r"^[a-f0-9]{14}$").unwrap();
}

/// Sends an informational message to the message's chat linking to the bot help channel.
pub async fn start (core: &Core, msg: &Message) -> Result<()> {
	core.tg.send(MyMessage::text_to(
		"We are open\\. Probably\\. Visit [channel](https://t.me/rsstg_bot_help/3) for details\\.",
	core.tg.send(MyMessage::html_to(
		"We are open. Probably. Visit <a href=\"https://t.me/rsstg_bot_help/3\">channel</a>) for details.",
		msg.chat.get_id()
	)).await.stack()?;
	Ok(())
}

/// Send the sender's subscription list to the chat.
///
/// Retrieves the message sender's user ID, obtains their subscription list from `core`,
/// and sends the resulting reply into the message chat using MarkdownV2.
pub async fn list (core: &Core, msg: &Message) -> Result<()> {
	let sender = msg.sender.get_user_id()
		.stack_err("Ignoring unreal users.")?;
	let reply = core.list(sender).await.stack()?;
	core.tg.send(MyMessage::text_to(reply, msg.chat.get_id())).await.stack()?;
	core.tg.send(MyMessage::html_to(reply, msg.chat.get_id())).await.stack()?;
	Ok(())
}

pub async fn test (core: &Core, msg: &Message) -> Result<()> {
	let sender: i64 = msg.sender.get_user_id()
		.stack_err("Ignoring unreal users.")?.into();
	let feeds = core.get_feeds(sender).await.stack()?;
	let kb = get_kb(&Callback::list("", 0), feeds).await.stack()?;
	core.tg.send(MyMessage::html_to_kb(format!("List of feeds owned by {:?}:", msg.sender.get_user_username()), msg.chat.get_id(), kb)).await.stack()?;
	let kb = get_kb(&Callback::menu(), feeds).await.stack()?;
	core.tg.send(MyMessage::html_to_kb("Main menu:", msg.chat.get_id(), kb)).await.stack()?;
	Ok(())
}

/// Handle channel-management commands that operate on a single numeric source ID.
///
/// This validates that exactly one numeric argument is provided, performs the requested
/// operation (check, clean, enable, delete, disable) against the database or core,
1
2
3
4
5
6

7
8
9
10
11
12
13
1
2
3
4
5
6
7
8
9
10
11
12
13
14






+







use crate::{
	Arc,
	command,
	Mutex,
	sql::Db,
	tg_bot::{
		Callback,
		MyMessage,
		Tg,
	},
};

use std::{
	borrow::Cow,
33
34
35
36
37
38
39

40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54








55
56
57
58
59
60
61







+













-
-
-
-
-
-
-
-







	StackableErr,
	anyhow,
	bail,
};
use tgbot::{
	handler::UpdateHandler,
	types::{
		CallbackQuery,
		ChatPeerId,
		Command,
		Update,
		UpdateType,
		UserPeerId,
	},
};
use ttl_cache::TtlCache;

lazy_static!{
	pub static ref RE_SPECIAL: Regex = Regex::new(r"([\-_*\[\]()~`>#+|{}\.!])").unwrap();
}

/// Escape characters that are special in Telegram MarkdownV2 by prefixing them with a backslash.
///
/// This ensures the returned string can be used as MarkdownV2-formatted Telegram message content
/// without special characters being interpreted as MarkdownV2 markup.
pub fn encode (text: &str) -> Cow<'_, str> {
	RE_SPECIAL.replace_all(text, "\\$1")
}

// This one does nothing except making sure only one token exists for each id
pub struct Token {
	running: Arc<Mutex<HashSet<i32>>>,
	my_id: i32,
}

impl Token {
105
106
107
108
109
110
111
112

113
114
115
116
117
118
119
99
100
101
102
103
104
105

106
107
108
109
110
111
112
113







-
+







		smol::block_on(async {
			let mut set = self.running.lock_arc().await;
			set.remove(&self.my_id);
		})
	}
}

type FeedList = HashMap<i32, String>;
pub type FeedList = HashMap<i32, String>;
type UserCache = TtlCache<i64, Arc<Mutex<FeedList>>>;

#[derive(Clone)]
pub struct Core {
	pub tg: Tg,
	pub db: Db,
	pub feeds: Arc<Mutex<UserCache>>,
332
333
334
335
336
337
338
339

340
341
342
343
344
345
346
326
327
328
329
330
331
332

333
334
335
336
337
338
339
340







-
+







								Ok(Some(source)) => source.to_string(),
								Ok(None) => "Source not found in database?".to_string(),
								Err(err) => format!("Failed to fetch source data:\n{err}"),
							}
						};
						smol::spawn(Compat::new(async move {
							if let Err(err) = clone.check(source_id, true, Some(last_scrape)).await
								&& let Err(err) = clone.tg.send(MyMessage::text(format!("šŸ›‘ {source}\n{}", encode(&err.to_string())))).await
								&& let Err(err) = clone.tg.send(MyMessage::html(format!("šŸ›‘ {source}\n<pre>{}</pre>", &err.to_string()))).await
							{
								eprintln!("Check error: {err}");
							};
						})).detach();
					}
				} else if next_fetch - now < delay {
					delay = next_fetch - now;
358
359
360
361
362
363
364
365

366
367
368
369
370
371
372
352
353
354
355
356
357
358

359
360
361
362
363
364
365
366







-
+







		for row in conn.get_list(owner).await.stack()? {
			reply.push(row.to_string());
		};
		Ok(reply.join("\n\n"))
	}

	/// Returns current cached list of feed for requested user, or loads data from database
	pub async fn get_feeds (&self, owner: i64) -> Result<Arc<Mutex<HashMap<i32, String>>>> {
	pub async fn get_feeds (&self, owner: i64) -> Result<Arc<Mutex<FeedList>>> {
		let mut feeds = self.feeds.lock_arc().await;
		Ok(match feeds.get(&owner) {
			None => {
				let mut conn = self.db.begin().await.stack()?;
				let feed_list = conn.get_feeds(owner).await.stack()?;
				let mut map = HashMap::new();
				for feed in feed_list {
413
414
415
416
417
418
419






420
421
422
423
424
425
426
427
428
429
430
431




432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454






































407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428



429
430
431
432























433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470







+
+
+
+
+
+









-
-
-
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
		}
		// in case we failed to found feed we need to remove - just reload everything from database
		if !dropped {
			self.get_feeds(owner).await.stack()?;
		}
		Ok(())
	}

	pub async fn cb (&self, query: &CallbackQuery, cb: &str) -> Result<()> {
		let cb: Callback = toml::from_str(cb).stack()?;
		todo!();
		Ok(())
	}
}

impl UpdateHandler for Core {
	/// Dispatches an incoming Telegram update to a matching command handler and reports handler errors to the originating chat.
	///
	/// This method inspects the update; if it contains a message that can be parsed as a bot command,
	/// it executes the corresponding command handler. If the handler returns an error, the error text
	/// is sent back to the message's chat using MarkdownV2 formatting. Unknown commands produce an error
	/// which is also reported to the chat.
	async fn handle (&self, update: Update) {
		if let UpdateType::Message(msg) = update.update_type 
			&& let Ok(cmd) = Command::try_from(*msg)
	async fn handle (&self, update: Update) -> () {
		match update.update_type {
			UpdateType::Message(msg) => {
				if let Ok(cmd) = Command::try_from(*msg) {
		{
			let msg = cmd.get_message();
			let words = cmd.get_args();
			let command = cmd.get_name();
			let res = match command {
				"/check" | "/clean" | "/enable" | "/delete" | "/disable" => command::command(self, command, msg, words).await,
				"/start" => command::start(self, msg).await,
				"/list" => command::list(self, msg).await,
				"/test" => command::test(self, msg).await,
				"/add" | "/update" => command::update(self, command, msg, words).await,
				any => Err(anyhow!("Unknown command: {any}")),
			};
			if let Err(err) = res 
				&& let Err(err2) = self.tg.send(MyMessage::text_to(
					format!("\\#error\n```\n{err}\n```"),
					msg.chat.get_id(),
				)).await
			{
				dbg!(err2);
			}
		} // TODO: debug log for skipped updates?;
	}
}
					let msg = cmd.get_message();
					let words = cmd.get_args();
					let command = cmd.get_name();
					let res = match command {
						"/check" | "/clean" | "/enable" | "/delete" | "/disable" => command::command(self, command, msg, words).await,
						"/start" => command::start(self, msg).await,
						"/list" => command::list(self, msg).await,
						"/test" => command::test(self, msg).await,
						"/add" | "/update" => command::update(self, command, msg, words).await,
						any => Err(anyhow!("Unknown command: {any}")),
					};
					if let Err(err) = res 
						&& let Err(err2) = self.tg.send(MyMessage::html_to(
							format!("#error<pre>{err}</pre>"),
							msg.chat.get_id(),
						)).await
					{
						dbg!(err2);
					}
				} else {
					// not a command
				}
			},
			UpdateType::CallbackQuery(query) => {
				if let Some(ref cb) = query.data
					&& let Err(err) = self.cb(&query, cb).await
				{
					if let Err(err) = self.tg.answer_cb(query.id, err.to_string()).await {
						println!("{err:?}");
					}
				}
			},
			_ => {
				println!("Unhandled UpdateKind:\n{update:?}")
			},
		}
	}
}
33
34
35
36
37
38
39
40

41
42
43
44
45
46

47
48
49

50
51
52
53
54
55
56
33
34
35
36
37
38
39

40
41
42
43
44
45

46
47
48

49
50
51
52
53
54
55
56







-
+





-
+


-
+







	pub url: String,
	pub iv_hash: Option<String>,
	pub url_re: Option<String>,
}

impl fmt::Display for List {
	fn fmt(&self, f: &mut fmt::Formatter<'_>) -> std::result::Result<(), fmt::Error> {
		write!(f, "\\#feed\\_{} \\*ļøāƒ£ `{}` {}\nšŸ”— `{}`", self.source_id, self.channel,
		write!(f, "#feed_{} *ļøāƒ£ <code>{}</code> {}\nšŸ”— <code>{}</code>", self.source_id, self.channel,
			match self.enabled {
				true  => "šŸ”„ enabled",
				false => "ā›” disabled",
			}, self.url)?;
		if let Some(iv_hash) = &self.iv_hash {
			write!(f, "\nIV: `{iv_hash}`")?;
			write!(f, "\nIV: <code>{iv_hash}</code>")?;
		}
		if let Some(url_re) = &self.url_re {
			write!(f, "\nRE: `{url_re}`")?;
			write!(f, "\nRE: <code>{url_re}</code>")?;
		}
		Ok(())
	}
}

/// One feed, used for caching and menu navigation
#[derive(sqlx::FromRow, Debug)]
1
2
3

4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

24
25
26
27
28
29
30
31
32
33
34
35
36
37
38


39
40


41
42
43





44
45







46
47
48
49

50

51
52
53
54
55
56
57
58
59
60
61
62

63
64
65
66




67
68
69
70
71
72

73
74
75
76
77
78
79
80
81

82
83
84
85

86

87
88
89
90
91
92
93
1
2
3
4
5
6
7
8

9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53


54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78

79
80
81
82
83
84
85
86
87
88
89
90
91
92

93
94
95
96
97
98
99


100
101
102
103
104

105
106
107
108
109
110
111
112
113
114



+




-















+















+
+


+
+



+
+
+
+
+
-
-
+
+
+
+
+
+
+




+

+











-
+




+
+
+
+





-
+






-
-

+



-
+

+







use crate::{
	Arc,
	Mutex,
	core::FeedList,
};

use std::{
	borrow::Cow,
	collections::HashMap,
	fmt,
};

use serde::{
	Deserialize,
	Serialize,
};
use stacked_errors::{
	bail,
	Result,
	StackableErr,
};
use tgbot::{
	api::Client,
	types::{
		AnswerCallbackQuery,
		Bot,
		ChatPeerId,
		GetBot,
		InlineKeyboardButton,
		InlineKeyboardMarkup,
		Message,
		ParseMode,
		SendMessage,
	},
};

const CB_VERSION: u8 = 0;

#[derive(Serialize, Deserialize, Debug)]
pub enum Callback {
	// Edit one feed (version, name)
	Edit(u8, String),
	// List all feeds (version, name to show, page number)
	List(u8, String, u8),
	// Show root menu (version)
	Menu(u8),
}

impl Callback {
	pub fn edit <S>(text: S) -> Callback
	where S: Into<String> {
		Callback::Edit(CB_VERSION, text.into())
	}

	pub fn list (text: &str, page: u8) -> Callback {
		Callback::List(CB_VERSION, text.to_owned(), page)
	pub fn list <S>(text: S, page: u8) -> Callback
	where S: Into<String> {
		Callback::List(CB_VERSION, text.into(), page)
	}

	pub fn menu () -> Callback {
		Callback::Menu(CB_VERSION)
	}

	fn version (&self) -> u8 {
		match self {
			Callback::Edit(version, .. ) => *version,
			Callback::List(version, .. ) => *version,
			Callback::Menu(version) => *version,
		}
	}
}

impl fmt::Display for Callback {
	fn fmt (&self, f: &mut fmt::Formatter) -> fmt::Result {
		f.write_str(&toml::to_string(self).map_err(|_| fmt::Error)?)
	}
}

/// Produce new Keyboard Markup from current Callback
pub async fn get_kb (cb: &Callback, feeds: Arc<Mutex<HashMap<i32, String>>>) -> Result<InlineKeyboardMarkup> {
pub async fn get_kb (cb: &Callback, feeds: Arc<Mutex<FeedList>>) -> Result<InlineKeyboardMarkup> {
	if cb.version() != CB_VERSION {
		bail!("Wrong callback version.");
	}
	let mark = match cb {
		Callback::Edit(_, _name) => { // XXX edit missing
			let kb: Vec<Vec<InlineKeyboardButton>> = vec![];
			InlineKeyboardMarkup::from(kb)
		},
		Callback::List(_, name, page) => {
			let mut kb = vec![];
			let feeds = feeds.lock_arc().await;
			let long = feeds.len() > 6;
			let (start, end) = if long {
				(page * 5, 5 + page * 5)
				(page * 5 + 1, 5 + page * 5)
			} else {
				(0, 6)
			};
			let mut i = 0;
			if name.is_empty() {
				for (id, name) in feeds.iter() {
					if i < start { continue }
					if i > end { break }
					i += 1;
					if i < start { continue }
					kb.push(vec![
						InlineKeyboardButton::for_callback_data(
							format!("{}. {}", id, name),
							Callback::list("xxx", *page).to_string()), // XXX edit
							Callback::edit(name).to_string()),
					]);
					if i > end { break }
				}
			} else {
				let mut found = false;
				let mut first_page = None;
				for (id, feed_name) in feeds.iter() {
					if name == feed_name {
						found = true;
123
124
125
126
127
128
129















130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173


174
175
176
177
178
179
180







+
+
+
+
+
+
+
+
+
+
+
+
+
+
+








-
-







						Callback::list("", if *page == 0 { *page } else { page - 1 } ).to_string()),
					InlineKeyboardButton::for_callback_data(">>",
						Callback::list("", page + 1).to_string()),
				]);
			}
			InlineKeyboardMarkup::from(kb)
		},
		Callback::Menu(_) => {
			let kb = vec![
				vec![
					InlineKeyboardButton::for_callback_data(
						"Add new channel",
						Callback::menu().to_string()), // new XXX
				],
				vec![
					InlineKeyboardButton::for_callback_data(
						"List channels",
						Callback::list("", 0).to_string()),
				],
			];
			InlineKeyboardMarkup::from(kb)
		},
	};
	Ok(mark)
}

pub enum MyMessage <'a> {
	Html { text: Cow<'a, str> },
	HtmlTo { text: Cow<'a, str>, to: ChatPeerId },
	HtmlToKb { text: Cow<'a, str>, to: ChatPeerId, kb: InlineKeyboardMarkup },
	Text { text: Cow<'a, str> },
	TextTo { text: Cow<'a, str>, to: ChatPeerId },
}

impl MyMessage <'_> {
	pub fn html <'a, S> (text: S) -> MyMessage<'a>
	where S: Into<Cow<'a, str>> {
		let text = text.into();
		MyMessage::Html { text }
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
188
189
190
191
192
193
194












195
196
197
198
199
200
201
202
203
204
205
206






207
208
209
210
211
212
213







-
-
-
-
-
-
-
-
-
-
-
-












-
-
-
-
-
-







	
	pub fn html_to_kb <'a, S> (text: S, to: ChatPeerId, kb: InlineKeyboardMarkup) -> MyMessage<'a>
	where S: Into<Cow<'a, str>> {
		let text = text.into();
		MyMessage::HtmlToKb { text, to, kb }
	}
	
	pub fn text <'a, S> (text: S) -> MyMessage<'a>
	where S: Into<Cow<'a, str>> {
		let text = text.into();
		MyMessage::Text { text }
	}
	
	pub fn text_to <'a, S> (text: S, to: ChatPeerId) -> MyMessage<'a>
	where S: Into<Cow<'a, str>> {
		let text = text.into();
		MyMessage::TextTo { text, to }
	}
	
	fn req (&self, tg: &Tg) -> Result<SendMessage> {
		Ok(match self {
			MyMessage::Html { text } =>
				SendMessage::new(tg.owner, text.as_ref())
					.with_parse_mode(ParseMode::Html),
			MyMessage::HtmlTo { text, to } =>
				SendMessage::new(*to, text.as_ref())
					.with_parse_mode(ParseMode::Html),
			MyMessage::HtmlToKb { text, to, kb } =>
				SendMessage::new(*to, text.as_ref())
					.with_parse_mode(ParseMode::Html)
					.with_reply_markup(kb.clone()),
			MyMessage::Text { text } =>
				SendMessage::new(tg.owner, text.as_ref())
					.with_parse_mode(ParseMode::MarkdownV2),
			MyMessage::TextTo { text, to } =>
				SendMessage::new(*to, text.as_ref())
					.with_parse_mode(ParseMode::MarkdownV2),
		})
	}
}

#[derive(Clone)]
pub struct Tg {
	pub me: Bot,
227
228
229
230
231
232
233







234
235
236
237
238
239
240
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263







+
+
+
+
+
+
+







	/// Send a text message to a chat, using an optional target and parse mode.
	///
	/// # Returns
	/// The sent `Message` on success.
	pub async fn send (&self, msg: MyMessage<'_>) -> Result<Message> {
		self.client.execute(msg.req(self)?).await.stack()
	}

	pub async fn answer_cb (&self, id: String, text: String) -> Result<bool> {
		self.client.execute(
			AnswerCallbackQuery::new(id)
				.with_text(text)
		).await.stack()
	}

	/// Create a copy of this `Tg` with the owner replaced by the given chat ID.
	///
	/// # Parameters
	/// - `owner`: The Telegram chat identifier to set as the new owner (expressed as an `i64`).
	///
	/// # Returns