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`).