Index: .github/workflows/rust-clippy.yml
==================================================================
--- .github/workflows/rust-clippy.yml
+++ .github/workflows/rust-clippy.yml
@@ -1,7 +1,12 @@
name: rust-ci
-on: push
+on: [ pull_request ]
+
+# 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
@@ -10,13 +15,14 @@
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
Index: src/command.rs
==================================================================
--- src/command.rs
+++ src/command.rs
@@ -14,10 +14,11 @@
Result,
StackableErr,
bail,
};
use tgbot::types::{
+ CallbackQuery,
ChatMember,
ChatUsername,
GetChat,
GetChatAdministrators,
Message,
@@ -29,12 +30,12 @@
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 channel) for details.",
msg.chat.get_id()
)).await.stack()?;
Ok(())
}
@@ -44,20 +45,20 @@
/// 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.
///
Index: src/core.rs
==================================================================
--- src/core.rs
+++ src/core.rs
@@ -2,10 +2,11 @@
Arc,
command,
Mutex,
sql::Db,
tg_bot::{
+ Callback,
MyMessage,
Tg,
},
};
@@ -35,10 +36,11 @@
bail,
};
use tgbot::{
handler::UpdateHandler,
types::{
+ CallbackQuery,
ChatPeerId,
Command,
Update,
UpdateType,
UserPeerId,
@@ -48,18 +50,10 @@
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>>,
my_id: i32,
}
@@ -107,11 +101,11 @@
set.remove(&self.my_id);
})
}
}
-type FeedList = HashMap;
+pub type FeedList = HashMap;
type UserCache = TtlCache>>;
#[derive(Clone)]
pub struct Core {
pub tg: Tg,
@@ -334,11 +328,11 @@
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{}", &err.to_string()))).await
{
eprintln!("Check error: {err}");
};
})).detach();
}
@@ -360,11 +354,11 @@
};
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>>> {
+ pub async fn get_feeds (&self, owner: i64) -> Result>> {
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()?;
@@ -415,40 +409,62 @@
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)
- {
- 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?;
+ 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::html_to(
+ format!("#error{err}"),
+ 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:?}")
+ },
+ }
}
}
Index: src/sql.rs
==================================================================
--- src/sql.rs
+++ src/sql.rs
@@ -35,20 +35,20 @@
pub url_re: Option,
}
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_{} *ļøā£ {} {}\nš {}", 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: {iv_hash}")?;
}
if let Some(url_re) = &self.url_re {
- write!(f, "\nRE: `{url_re}`")?;
+ write!(f, "\nRE: {url_re}")?;
}
Ok(())
}
}
Index: src/tg_bot.rs
==================================================================
--- src/tg_bot.rs
+++ src/tg_bot.rs
@@ -1,13 +1,13 @@
use crate::{
Arc,
Mutex,
+ core::FeedList,
};
use std::{
borrow::Cow,
- collections::HashMap,
fmt,
};
use serde::{
Deserialize,
@@ -19,10 +19,11 @@
StackableErr,
};
use tgbot::{
api::Client,
types::{
+ AnswerCallbackQuery,
Bot,
ChatPeerId,
GetBot,
InlineKeyboardButton,
InlineKeyboardMarkup,
@@ -34,22 +35,38 @@
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 list (text: &str, page: u8) -> Callback {
- Callback::List(CB_VERSION, text.to_owned(), page)
+ pub fn edit (text: S) -> Callback
+ where S: Into {
+ Callback::Edit(CB_VERSION, text.into())
+ }
+
+ pub fn list (text: S, page: u8) -> Callback
+ where S: Into {
+ 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 {
@@ -57,35 +74,39 @@
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>>) -> Result {
+pub async fn get_kb (cb: &Callback, feeds: Arc>) -> Result {
if cb.version() != CB_VERSION {
bail!("Wrong callback version.");
}
let mark = match cb {
+ Callback::Edit(_, _name) => { // XXX edit missing
+ let kb: Vec> = 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() {
@@ -125,20 +146,33 @@
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> {
@@ -156,22 +190,10 @@
where S: Into> {
let text = text.into();
MyMessage::HtmlToKb { text, to, kb }
}
- pub fn text <'a, S> (text: S) -> MyMessage<'a>
- where S: Into> {
- let text = text.into();
- MyMessage::Text { text }
- }
-
- pub fn text_to <'a, S> (text: S, to: ChatPeerId) -> MyMessage<'a>
- where S: Into> {
- let text = text.into();
- MyMessage::TextTo { text, to }
- }
-
fn req (&self, tg: &Tg) -> Result {
Ok(match self {
MyMessage::Html { text } =>
SendMessage::new(tg.owner, text.as_ref())
.with_parse_mode(ParseMode::Html),
@@ -180,16 +202,10 @@
.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)]
@@ -229,10 +245,17 @@
/// # Returns
/// The sent `Message` on success.
pub async fn send (&self, msg: MyMessage<'_>) -> Result {
self.client.execute(msg.req(self)?).await.stack()
}
+
+ pub async fn answer_cb (&self, id: String, text: String) -> Result {
+ 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`).