From 89dcb9828216bdd6fbab9b2346ec12c1e7040e2c Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Thu, 28 Aug 2025 19:07:04 +0300 Subject: [PATCH 01/66] A weird implementation of cobalt with using `ccobalt` --- Cargo.toml | 1 + src/config.rs | 14 ++++- src/handlers/markups/inlines/cobalter.rs | 76 ++++++++++++++++++++++++ src/handlers/markups/inlines/mod.rs | 1 + src/loader.rs | 1 + src/util/mod.rs | 2 +- 6 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 src/handlers/markups/inlines/cobalter.rs diff --git a/Cargo.toml b/Cargo.toml index abb8e6a..bdd95c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,3 +22,4 @@ async-trait = "0.1.88" log = "0.4.27" pretty_env_logger = "0.5.0" uuid = { version = "1.17.0", features = ["v4"] } +ccobalt = "0.2.1" diff --git a/src/config.rs b/src/config.rs index 59d19b3..1f64e44 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,5 +1,5 @@ use crate::util::currency::converter::{CurrencyConverter, OutputLanguage}; -use crate::util::json::{JsonConfig, read_json_config}; +use crate::util::json::{read_json_config, JsonConfig}; use dotenv::dotenv; use std::sync::Arc; use teloxide::prelude::*; @@ -7,6 +7,7 @@ use teloxide::prelude::*; #[derive(Clone)] pub struct Config { bot: Bot, + cobalt_client: ccobalt::Client, #[allow(dead_code)] owners: Vec, version: String, @@ -23,6 +24,12 @@ impl Config { let version = std::env::var("CARGO_PKG_VERSION").expect("CARGO_PKG_VERSION expected"); let bot = Bot::new(bot_token); + let cobalt_client = ccobalt::Client::builder() + .base_url("https://cobalt-backend.canine.tools/") + .api_key("21212121-2121-2121-2121-212121212121") // todo: change api key + .build() + .expect("Failed to build cobalt client"); + let owners: Vec = std::env::var("OWNERS") .expect("OWNERS expected") .split(',') @@ -35,6 +42,7 @@ impl Config { Config { bot, + cobalt_client, owners, version, json_config, @@ -47,6 +55,10 @@ impl Config { &self.bot } + pub fn get_cobalt_client(&self) -> &ccobalt::Client { + &self.cobalt_client + } + pub fn get_version(&self) -> &str { &self.version } diff --git a/src/handlers/markups/inlines/cobalter.rs b/src/handlers/markups/inlines/cobalter.rs new file mode 100644 index 0000000..ce2fed9 --- /dev/null +++ b/src/handlers/markups/inlines/cobalter.rs @@ -0,0 +1,76 @@ +use crate::config::Config; +use once_cell::sync::Lazy; +use regex::Regex; +use std::error as error_handler; +use std::str::FromStr; +use std::sync::Arc; +use ccobalt::model::request::{DownloadRequest, VideoQuality}; +use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup, InlineQuery, InlineQueryResult, InlineQueryResultArticle, InlineQueryResultsButton, InputMessageContent, InputMessageContentText}; +use teloxide::Bot; +use teloxide::payloads::{AnswerInlineQuery, AnswerInlineQuerySetters, SendVideoSetters}; +use teloxide::prelude::{Request, Requester}; + +static URL_REGEX: Lazy = + Lazy::new(|| Regex::new(r"^(https?)://[^\s/$.?#].[^\s]*$").unwrap()); + +#[allow(dead_code)] +fn is_url(text: &str) -> bool { + URL_REGEX.is_match(text) +} + +pub async fn is_query_url(inline_query: InlineQuery) -> bool { + URL_REGEX.is_match(&inline_query.query) +} + +fn url_to_reqwest_url(url: String) -> reqwest::Url { + reqwest::Url::from_str(&url).unwrap() +} + +pub async fn handle_cobalt_inline( + bot: Bot, + q: InlineQuery, + config: Arc, +) -> Result<(), Box> { + let url = &q.query; + if is_url(url) { + let request = DownloadRequest { + url: url.to_string(), + video_quality: Some(VideoQuality::Q720), + ..Default::default() + }; + + let cobalt_client = config.get_cobalt_client(); + + match cobalt_client.download_and_save(&request, "test", ".").await { + Ok(video_file) => { + let keyboard = InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::url( + "🔗 Open Link".to_string(), + url.parse()?, + )]]); + + let article = InlineQueryResultArticle::new( + "cobalt_prompt", + "Cobalt Test", + InputMessageContent::Text(InputMessageContentText::new( + "Test", + )), + ) + .description("Teestttt 22") + .reply_markup(keyboard); + + bot.answer_inline_query(q.id, vec![InlineQueryResult::Article(article)]) + .send() + .await?; + + Ok(()) + } + Err(e) => { + log::error!("Failed to download: {:?}", e); + Ok(()) + } + } + } else { + // Not a supported URL, optionally handle or ignore + Ok(()) + } +} diff --git a/src/handlers/markups/inlines/mod.rs b/src/handlers/markups/inlines/mod.rs index 99199a4..46ba27d 100644 --- a/src/handlers/markups/inlines/mod.rs +++ b/src/handlers/markups/inlines/mod.rs @@ -1,2 +1,3 @@ pub mod currency; pub mod delete_msg_inline; +pub mod cobalter; diff --git a/src/loader.rs b/src/loader.rs index 172a10f..dcff3fd 100644 --- a/src/loader.rs +++ b/src/loader.rs @@ -29,6 +29,7 @@ pub fn inline_query_handler() -> Handler< teloxide::dispatching::DpHandlerDescription, > { dptree::entry().branch(dptree::filter_async(is_currency_query).endpoint(handle_currency_inline)) + //.branch(dptree::filter_async(is_query_url).endpoint(handle_cobalt_inline)) } async fn run_bot(config: Arc) -> Result<(), MyError> { diff --git a/src/util/mod.rs b/src/util/mod.rs index 9202033..30e8772 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -3,4 +3,4 @@ pub mod enums; pub mod errors; pub mod inline; pub mod json; -pub mod transcription; +pub mod transcription; \ No newline at end of file From 610431adde0d327fd55ed38ccd8b1b6331e4307b Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Thu, 28 Aug 2025 21:13:46 +0300 Subject: [PATCH 02/66] Added new button `Summarize` in transcription.rs and a bit implemented it --- config.json | 3 +- src/handlers/markups/markuper.rs | 3 + src/util/json.rs | 5 ++ src/util/transcription.rs | 132 ++++++++++++++++++++++++++++--- 4 files changed, 130 insertions(+), 13 deletions(-) diff --git a/config.json b/config.json index 19d9d7f..f9619ff 100644 --- a/config.json +++ b/config.json @@ -1,4 +1,5 @@ { "ai_model": "gemini-2.5-flash", - "ai_prompt": "You are a highly specialized audio-to-text transcription service. Your SOLE purpose is to accurately transcribe the spoken words from the audio track of the provided file.\n\n**Crucial Instruction: You MUST completely ignore the visual stream of the file. Your task is NOT to describe the video.**\n\n- **DO:** Listen to the audio and transcribe it word-for-word (verbatim).\n- **DO:** Maintain the original language of the speech.\n\n- **DO NOT:** Describe scenes, people, objects, actions, logos, or the environment.\n- **DO NOT:** Analyze the camera work or shot composition.\n- **DO NOT:** Provide summaries, explanations, or any commentary.\n- **DO NOT:** Add headers, timestamps, or any formatting.\n\nReturn ONLY the raw, plain transcribed text. If no speech is present, return \"[no speech]\"." + "ai_prompt": "You are a highly specialized audio-to-text transcription service. Your SOLE purpose is to accurately transcribe the spoken words from the audio track of the provided file.\n\n**Crucial Instruction: You MUST completely ignore the visual stream of the file. Your task is NOT to describe the video.**\n\n- **DO:** Listen to the audio and transcribe it word-for-word (verbatim).\n- **DO:** Maintain the original language of the speech.\n\n- **DO NOT:** Describe scenes, people, objects, actions, logos, or the environment.\n- **DO NOT:** Analyze the camera work or shot composition.\n- **DO NOT:** Provide summaries, explanations, or any commentary.\n- **DO NOT:** Add headers, timestamps, or any formatting.\n\nReturn ONLY the raw, plain transcribed text. If no speech is present, return \"[no speech]\".", + "summarize_prompt": "You are an assistant that transcribes and then summarizes spoken content. First, accurately and fully transcribe the voice message, keeping the original language. Then, briefly summarize the transcribed text in the same language. Output only the final summary. Do not include the full transcription, and do not add any extra words like 'Summary' or 'Transcription'. Do not explain or comment. The output must be plain and concise." } \ No newline at end of file diff --git a/src/handlers/markups/markuper.rs b/src/handlers/markups/markuper.rs index 8e35333..dad5bd1 100644 --- a/src/handlers/markups/markuper.rs +++ b/src/handlers/markups/markuper.rs @@ -4,6 +4,7 @@ use crate::util::errors::MyError; use teloxide::Bot; use teloxide::requests::Requester; use teloxide::types::CallbackQuery; +use crate::util::transcription::summarization_handler; pub(crate) async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { let _config = Config::new().await; @@ -14,6 +15,8 @@ pub(crate) async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Resul if data.starts_with("delete_msg") { delete_msg_handler(bot, qq).await + } else if data.starts_with("summarize") { + summarization_handler(bot, qq, &_config).await } else { bot.answer_callback_query(qq.id).await.unwrap(); Ok(()) diff --git a/src/util/json.rs b/src/util/json.rs index ff9a486..ffa78d6 100644 --- a/src/util/json.rs +++ b/src/util/json.rs @@ -7,6 +7,7 @@ use std::path::Path; pub struct JsonConfig { pub ai_model: String, pub ai_prompt: String, + pub summarize_prompt: String, } impl JsonConfig { @@ -17,6 +18,10 @@ impl JsonConfig { pub fn get_ai_prompt(&self) -> &str { &self.ai_prompt } + + pub fn get_summarize_prompt(&self) -> &str { + &self.summarize_prompt + } } pub fn read_json_config>(path: P) -> Result> { diff --git a/src/util/transcription.rs b/src/util/transcription.rs index 72d043f..6deed3c 100644 --- a/src/util/transcription.rs +++ b/src/util/transcription.rs @@ -9,7 +9,10 @@ use std::time::Duration; use teloxide::Bot; use teloxide::payloads::{EditMessageTextSetters, SendMessageSetters}; use teloxide::requests::{Request as TeloxideRequest, Requester}; -use teloxide::types::{Message, MessageKind, ParseMode, ReplyParameters}; +use teloxide::types::{ + CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message, MessageKind, ParseMode, + ReplyParameters, +}; pub async fn transcription_handler(bot: Bot, msg: Message, config: &Config) -> Result<(), MyError> { let message = bot @@ -21,6 +24,11 @@ pub async fn transcription_handler(bot: Bot, msg: Message, config: &Config) -> R let original_user_id = msg.from.clone().unwrap().id; + let keyboard = InlineKeyboardMarkup::new(vec![vec![ + InlineKeyboardButton::callback("✨", "summarize"), + InlineKeyboardButton::callback("🗑️", format!("delete_{}", original_user_id.0)) + ]]); + if let Some(message) = message { if let Some(file) = get_file_id(&msg).await { let file_data = save_file_to_memory(&bot, &file.file_id).await?; @@ -37,19 +45,19 @@ pub async fn transcription_handler(bot: Bot, msg: Message, config: &Config) -> R message.id, format!("
{}
", text_parts[0]), ) - .parse_mode(ParseMode::Html) - .reply_markup(delete_message_button(original_user_id.0)) - .await?; + .parse_mode(ParseMode::Html) + .reply_markup(keyboard.clone()) + .await?; for part in text_parts.iter().skip(1) { bot.send_message( msg.chat.id, format!("
\n{}\n
", part), ) - .reply_parameters(ReplyParameters::new(msg.id)) - .parse_mode(ParseMode::Html) - .reply_markup(delete_message_button(original_user_id.0)) - .await?; + .reply_parameters(ReplyParameters::new(msg.id)) + .parse_mode(ParseMode::Html) + .reply_markup(keyboard.clone()) + .await?; } } else { bot.edit_message_text( @@ -57,14 +65,114 @@ pub async fn transcription_handler(bot: Bot, msg: Message, config: &Config) -> R message.id, "Не удалось найти голосовое сообщение.", ) - .parse_mode(ParseMode::Html) - // .reply_markup(delete_message_button()) - .await?; + .parse_mode(ParseMode::Html) + .await?; } } Ok(()) } +pub async fn summarization_handler(bot: Bot, query: CallbackQuery, config: &Config) -> Result<(), MyError> { + if let Some(message) = query.message { + bot.answer_callback_query(query.id).await?; + + let original_msg = if let Some(reply) = message.regular_message(){ + reply.reply_to_message().unwrap() + } else { + bot.edit_message_text( + message.chat().id, + message.id(), + "Не удалось найти исходное сообщение для обработки.", + ) + .await?; + return Ok(()); + }; + + if let Some(audio_struct) = get_file_id(original_msg).await { + bot.edit_message_text( + message.chat().id, + message.id(), + "Составляю краткое содержание из аудио...", + ) + .parse_mode(ParseMode::Html) + .await?; + + let file_data = save_file_to_memory(&bot, &audio_struct.file_id).await?; + + let summary_result = summarize_audio( + audio_struct.mime_type, + file_data, + config.clone(), + ).await; + + match summary_result { + Ok(summary) => { + let final_text = format!("Краткое содержание:\n
{}
", summary); + + bot.edit_message_text( + message.chat().id, + message.id(), + final_text + ) + .parse_mode(ParseMode::Html) + // .reply_markup(delete_message_button(original_user_id)) + .await?; + } + Err(e) => { + error!("Error during summarization: {:?}", e); + bot.edit_message_text( + message.chat().id, + message.id(), + "❌ Ошибка при составлении краткого содержания.", + ) + .reply_markup(message.regular_message().unwrap().reply_markup().cloned().unwrap()) + .await?; + } + } + } else { + bot.edit_message_text( + message.chat().id, + message.id(), + "Не удалось найти аудио в исходном сообщении.", + ) + .await?; + } + } + Ok(()) +} + +async fn summarize_audio(mime_type: String, data: Bytes, config: Config) -> Result { + let mut settings = gem_rs::types::Settings::new(); + settings.set_all_safety_settings(HarmBlockThreshold::BlockNone); + + let ai_model = config.get_json_config().get_ai_model().to_owned(); + let prompt = config.get_json_config().get_summarize_prompt().to_owned(); + + let mut context = gem_rs::types::Context::new(); + context.push_message(gem_rs::types::Role::Model, prompt); + + let mut client = gem_rs::client::GemSession::Builder() + .model(gem_rs::api::Models::Custom(ai_model)) + .timeout(Some(Duration::from_secs(120))) + .context(context) + .build(); + + let response = client + .send_blob( + gem_rs::types::Blob::new(&mime_type, &data), + gem_rs::types::Role::User, + &settings, + ) + .await?; + + Ok(response + .get_results() + .first() + .cloned() + .unwrap_or_else(|| "Не удалось получить краткое содержание.".to_string())) +} + + pub async fn get_file_id(msg: &Message) -> Option { match &msg.kind { MessageKind::Common(common) => match &common.media_kind { @@ -203,4 +311,4 @@ fn split_text(text: String, chunk_size: usize) -> Vec { .chunks(chunk_size) .map(|chunk| chunk.iter().collect()) .collect() -} +} \ No newline at end of file From 1e92cbfa9021f854c5e16147e99880f6406772d4 Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Thu, 28 Aug 2025 21:20:24 +0300 Subject: [PATCH 03/66] Updated README a bit --- README.md | 21 +++++---------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index bf36072..5aaba49 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,19 @@
-

Fulturate-rs - Telegram Utility Bot 🤖

- (Currently, the bot is in Russian. The next version**s** will include a 100% language switcher.) +

Fulturate-rs - Best utility bot for Telegram on Rust 🤖

## ✨ Features: - **Speech Recognition** 🎤: Convert spoken words into text for a more interactive experience. - **Currency Conversion** 💰: Convert between different currencies easily. - -## 🔹 Todo: -- **Download Videos/Music from URLs** 🎥🎶: Seamlessly download multimedia content. -- **Download Images from URLs** 🖼️: Fetch images directly from URLs. -- **Download Files from URLs** 📂: Download various types of files from provided URLs. -- **Download Audios from URLs** 🎧: Grab audio files from URLs for easy access. -- **Settings** ⚙️: Manage and adjust bot settings to your preferences. -- **Model Switcher (Speech Recognition)** 🧠: Switch between different speech recognition models for better accuracy. -- **Language Switcher** 🌍: Switch between multiple languages with ease. -- **Message Translation** 🌐: Translate messages into your preferred language. +- **Summarization** ✨: Summarize audio files. ## 🚀 Getting Started: To get started with Fulturate-rs, follow these steps: *(If you don't have Rust installed, visit: [Rust Install](https://www.rust-lang.org/tools/install))* 1. Clone the repository: - `git clone https://github.com/Weever1337/fulturate-rs.git` + `git clone https://github.com/Fulturate/bot.git` 2. Run the bot: `cargo run` OR run with Docker: @@ -33,10 +23,9 @@ To get started with Fulturate-rs, follow these steps: - **News Channel**: [Fulturate News](https://t.me/FulturateNews) - **Bot**: [Fulturate Bot](https://t.me/FulturateBot) - **Owner's Bio-Channel in Telegram**: [Weever](https://t.me/Weever) -- **Owner's Shitpost Channel (RUS) in Telegram**: [WeeverDev](https://t.me/WeeverDev) ## 💖 Credits: -- **Nixxoq**: [GitHub](https://github.com/nixxoq), [Telegram](https://t.me/nixxoq) +- **Nixxoq (did a LOT of work)**: [GitHub](https://github.com/nixxoq), [Telegram](https://t.me/nixxoq) ## 📄 License: -- **MIT License**: [MIT License](https://github.com/Weever1337/fulturate-rs/blob/main/LICENSE) +- **MIT License**: [MIT License](https://github.com/Fulturate/bot/blob/main/LICENSE) From 6d2ca58bc167fcfa52902443af37cf2432d8365e Mon Sep 17 00:00:00 2001 From: nixxoq Date: Thu, 28 Aug 2025 22:34:25 +0300 Subject: [PATCH 04/66] feat(transcription): send error message about file size limit --- src/util/transcription.rs | 85 ++++++++++++++++++++++++--------------- 1 file changed, 52 insertions(+), 33 deletions(-) diff --git a/src/util/transcription.rs b/src/util/transcription.rs index 6deed3c..0cc2f78 100644 --- a/src/util/transcription.rs +++ b/src/util/transcription.rs @@ -26,12 +26,21 @@ pub async fn transcription_handler(bot: Bot, msg: Message, config: &Config) -> R let keyboard = InlineKeyboardMarkup::new(vec![vec![ InlineKeyboardButton::callback("✨", "summarize"), - InlineKeyboardButton::callback("🗑️", format!("delete_{}", original_user_id.0)) + InlineKeyboardButton::callback("🗑️", format!("delete_{}", original_user_id.0)), ]]); if let Some(message) = message { if let Some(file) = get_file_id(&msg).await { - let file_data = save_file_to_memory(&bot, &file.file_id).await?; + let Ok(file_data) = save_file_to_memory(&bot, &file.file_id).await else { + bot.edit_message_text( + msg.chat.id, + message.id, + "❌ Ошибка: Не удалось скачать файл. Возможно, он слишком большой (>20MB).", + ) + .await?; + return Ok(()); + }; + let transcription = Transcription { mime_type: file.mime_type, data: file_data, @@ -45,19 +54,19 @@ pub async fn transcription_handler(bot: Bot, msg: Message, config: &Config) -> R message.id, format!("
{}
", text_parts[0]), ) - .parse_mode(ParseMode::Html) - .reply_markup(keyboard.clone()) - .await?; + .parse_mode(ParseMode::Html) + .reply_markup(keyboard.clone()) + .await?; for part in text_parts.iter().skip(1) { bot.send_message( msg.chat.id, format!("
\n{}\n
", part), ) - .reply_parameters(ReplyParameters::new(msg.id)) - .parse_mode(ParseMode::Html) - .reply_markup(keyboard.clone()) - .await?; + .reply_parameters(ReplyParameters::new(msg.id)) + .parse_mode(ParseMode::Html) + .reply_markup(keyboard.clone()) + .await?; } } else { bot.edit_message_text( @@ -65,18 +74,22 @@ pub async fn transcription_handler(bot: Bot, msg: Message, config: &Config) -> R message.id, "Не удалось найти голосовое сообщение.", ) - .parse_mode(ParseMode::Html) - .await?; + .parse_mode(ParseMode::Html) + .await?; } } Ok(()) } -pub async fn summarization_handler(bot: Bot, query: CallbackQuery, config: &Config) -> Result<(), MyError> { +pub async fn summarization_handler( + bot: Bot, + query: CallbackQuery, + config: &Config, +) -> Result<(), MyError> { if let Some(message) = query.message { bot.answer_callback_query(query.id).await?; - let original_msg = if let Some(reply) = message.regular_message(){ + let original_msg = if let Some(reply) = message.regular_message() { reply.reply_to_message().unwrap() } else { bot.edit_message_text( @@ -84,7 +97,7 @@ pub async fn summarization_handler(bot: Bot, query: CallbackQuery, config: &Conf message.id(), "Не удалось найти исходное сообщение для обработки.", ) - .await?; + .await?; return Ok(()); }; @@ -94,26 +107,22 @@ pub async fn summarization_handler(bot: Bot, query: CallbackQuery, config: &Conf message.id(), "Составляю краткое содержание из аудио...", ) - .parse_mode(ParseMode::Html) - .await?; + .parse_mode(ParseMode::Html) + .await?; let file_data = save_file_to_memory(&bot, &audio_struct.file_id).await?; - let summary_result = summarize_audio( - audio_struct.mime_type, - file_data, - config.clone(), - ).await; + let summary_result = + summarize_audio(audio_struct.mime_type, file_data, config.clone()).await; match summary_result { Ok(summary) => { - let final_text = format!("Краткое содержание:\n
{}
", summary); + let final_text = format!( + "Краткое содержание:\n
{}
", + summary + ); - bot.edit_message_text( - message.chat().id, - message.id(), - final_text - ) + bot.edit_message_text(message.chat().id, message.id(), final_text) .parse_mode(ParseMode::Html) // .reply_markup(delete_message_button(original_user_id)) .await?; @@ -125,8 +134,15 @@ pub async fn summarization_handler(bot: Bot, query: CallbackQuery, config: &Conf message.id(), "❌ Ошибка при составлении краткого содержания.", ) - .reply_markup(message.regular_message().unwrap().reply_markup().cloned().unwrap()) - .await?; + .reply_markup( + message + .regular_message() + .unwrap() + .reply_markup() + .cloned() + .unwrap(), + ) + .await?; } } } else { @@ -135,13 +151,17 @@ pub async fn summarization_handler(bot: Bot, query: CallbackQuery, config: &Conf message.id(), "Не удалось найти аудио в исходном сообщении.", ) - .await?; + .await?; } } Ok(()) } -async fn summarize_audio(mime_type: String, data: Bytes, config: Config) -> Result { +async fn summarize_audio( + mime_type: String, + data: Bytes, + config: Config, +) -> Result { let mut settings = gem_rs::types::Settings::new(); settings.set_all_safety_settings(HarmBlockThreshold::BlockNone); @@ -172,7 +192,6 @@ async fn summarize_audio(mime_type: String, data: Bytes, config: Config) -> Resu .unwrap_or_else(|| "Не удалось получить краткое содержание.".to_string())) } - pub async fn get_file_id(msg: &Message) -> Option { match &msg.kind { MessageKind::Common(common) => match &common.media_kind { @@ -311,4 +330,4 @@ fn split_text(text: String, chunk_size: usize) -> Vec { .chunks(chunk_size) .map(|chunk| chunk.iter().collect()) .collect() -} \ No newline at end of file +} From 305f0927356fc509337d08448cbc832b539de10d Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Thu, 28 Aug 2025 23:59:14 +0300 Subject: [PATCH 05/66] A very early version of translate command --- Cargo.toml | 1 + src/handlers/commander.rs | 4 +- src/handlers/commands/mod.rs | 1 + src/handlers/commands/translate.rs | 75 ++++++++++++++++++++++++++++++ src/util/enums.rs | 2 + 5 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 src/handlers/commands/translate.rs diff --git a/Cargo.toml b/Cargo.toml index bdd95c4..c76d881 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,3 +23,4 @@ log = "0.4.27" pretty_env_logger = "0.5.0" uuid = { version = "1.17.0", features = ["v4"] } ccobalt = "0.2.1" +translators = { version = "0.1.5", features = ["google", "tokio-async"] } \ No newline at end of file diff --git a/src/handlers/commander.rs b/src/handlers/commander.rs index f1d4006..58c0814 100644 --- a/src/handlers/commander.rs +++ b/src/handlers/commander.rs @@ -8,6 +8,7 @@ use crate::util::{enums::Command, errors::MyError}; use teloxide::Bot; use teloxide::prelude::Message; use tokio::task; +use crate::handlers::commands::translate::translate_handler; pub(crate) async fn command_handlers( bot: Bot, @@ -18,10 +19,11 @@ pub(crate) async fn command_handlers( task::spawn(async move { match cmd { Command::Start(arg) => start_handler(bot, message, &config, arg).await, + Command::Translate(arg) => translate_handler(bot, message, &config, arg).await, Command::SpeechRecognition => speech_recognition_handler(bot, message, &config).await, Command::SetCurrency { code } => currency_codes_handler(bot, message, code).await, Command::ListCurrency => currency_codes_list_handler(bot, message).await, } }); Ok(()) -} +} \ No newline at end of file diff --git a/src/handlers/commands/mod.rs b/src/handlers/commands/mod.rs index a652eb2..9aa0656 100644 --- a/src/handlers/commands/mod.rs +++ b/src/handlers/commands/mod.rs @@ -1,3 +1,4 @@ pub mod settings; pub mod speech_recognition; pub mod start; +pub mod translate; diff --git a/src/handlers/commands/translate.rs b/src/handlers/commands/translate.rs new file mode 100644 index 0000000..a8f1f0c --- /dev/null +++ b/src/handlers/commands/translate.rs @@ -0,0 +1,75 @@ +use crate::config::Config; +use crate::util::{errors::MyError, transcription::transcription_handler}; +use teloxide::prelude::*; +use teloxide::types::{ParseMode, ReplyParameters, User}; +use translators::{GoogleTranslator, Translator}; + +pub async fn translate_handler( + bot: Bot, + msg: Message, + config: &Config, + arg: String, +) -> Result<(), MyError> { + let text_to_translate = msg.reply_to_message().unwrap().text().unwrap(); + let user = msg.from().unwrap(); + + if msg.reply_to_message().is_some() { + let target_lang = if arg.trim().is_empty() { + get_user_target_language(config, user).await? + } else { + normalize_language_code(&arg.trim()) + }; + + // let translated_text = translate_text( + // text_to_translate, + // &target_lang, + // translation_method, + // config + // ).await?; + + let google_trans = GoogleTranslator::default(); + let res = google_trans + .translate_async(text_to_translate, "", &*target_lang) + .await + .unwrap(); + + let response = format!( + "🌐 Перевод на {}:\n
{}\n
", + target_lang.to_uppercase(), + res, + ); + + bot.send_message(msg.chat.id, response) + .parse_mode(ParseMode::Html) + .await?; + } else { + bot.send_message(msg.chat.id, "Нужно ответить на то сообщение, которое требуется перевести, чтобы использовать эту команду.") + .reply_parameters(ReplyParameters::new(msg.id)) + .parse_mode(ParseMode::Html) + .await?; + } + Ok(()) +} + +async fn get_user_target_language(_config: &Config, user: &User) -> Result { + let lang_code = user.language_code.as_ref() // todo: add user settings + .unwrap_or(&"en".to_string()) + .clone(); + + Ok(normalize_language_code(&lang_code)) +} + +fn normalize_language_code(lang: &str) -> String { + match lang.to_lowercase().as_str() { + "ua" | "ukrainian" | "украинский" | "uk" => "uk".to_string(), + "ru" | "russian" | "русский" => "ru".to_string(), + "en" | "english" | "английский" => "en".to_string(), + "de" | "german" | "немецкий" => "de".to_string(), + "fr" | "french" | "французский" => "fr".to_string(), + "es" | "spanish" | "испанский" => "es".to_string(), + "it" | "italian" | "итальянский" => "it".to_string(), + "zh" | "chinese" | "китайский" => "zh".to_string(), + "ja" | "japanese" | "японский" => "ja".to_string(), + _ => lang.to_lowercase(), + } +} \ No newline at end of file diff --git a/src/util/enums.rs b/src/util/enums.rs index 42053a1..864908c 100644 --- a/src/util/enums.rs +++ b/src/util/enums.rs @@ -7,6 +7,8 @@ pub enum Command { Start(String), #[command(description = "Speech recognition", alias = "sr")] SpeechRecognition, + #[command(description = "Translate", alias = "tr")] + Translate(String), #[command(parse_with = "split", description = "Set currency to convert")] SetCurrency { code: String }, #[command(description = "List of available currencies to convert")] From c0e4d3c33403bf3c03ae366ac1a104cde843992c Mon Sep 17 00:00:00 2001 From: nixxoq Date: Fri, 29 Aug 2025 20:34:54 +0300 Subject: [PATCH 06/66] chore(env): add REDIS_URL value --- .env.example | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 17e02b9..f269d6f 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,5 @@ BOT_TOKEN= GEMINI_API_KEY= OWNERS=701733705, 6452350296 MONGODB_URL= -RUST_LOG=info \ No newline at end of file +RUST_LOG=info +REDIS_URL= \ No newline at end of file From 58b69ef8cf3b033af60db3faf4b3319db1e548a8 Mon Sep 17 00:00:00 2001 From: nixxoq Date: Fri, 29 Aug 2025 20:37:23 +0300 Subject: [PATCH 07/66] feat(db): add redis database feat(config): parse redis url from env --- src/config.rs | 23 +++++++++++++++++++---- src/db/mod.rs | 3 ++- src/db/redis.rs | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 src/db/redis.rs diff --git a/src/config.rs b/src/config.rs index 1f64e44..4eeb18c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,8 @@ +use crate::db::redis::RedisCache; use crate::util::currency::converter::{CurrencyConverter, OutputLanguage}; -use crate::util::json::{read_json_config, JsonConfig}; +use crate::util::json::{JsonConfig, read_json_config}; use dotenv::dotenv; +use redis::Client as RedisClient; use std::sync::Arc; use teloxide::prelude::*; @@ -14,6 +16,7 @@ pub struct Config { json_config: JsonConfig, currency_converter: Arc, mongodb_url: String, + redis_client: RedisCache, } impl Config { @@ -40,6 +43,11 @@ impl Config { let currency_converter = Arc::new(CurrencyConverter::new(OutputLanguage::Russian).unwrap()); // TODO: get language from config let mongodb_url = std::env::var("MONGODB_URL").expect("MONGODB_URL expected"); + let redis_url = std::env::var("REDIS_URL").expect("REDIS_URL expected"); + let redis_client = RedisClient::open(redis_url).expect("Failed to open Redis client"); + + let redis_client = RedisCache::new(redis_client); + Config { bot, cobalt_client, @@ -47,7 +55,8 @@ impl Config { version, json_config, currency_converter, - mongodb_url + mongodb_url, + redis_client, } } @@ -75,6 +84,12 @@ impl Config { pub fn get_currency_converter(&self) -> &CurrencyConverter { &self.currency_converter } - - pub fn get_mongodb_url(&self) -> &str { &self.mongodb_url } + + pub fn get_mongodb_url(&self) -> &str { + &self.mongodb_url + } + + pub fn get_redis_client(&self) -> &RedisCache { + &self.redis_client + } } diff --git a/src/db/mod.rs b/src/db/mod.rs index 570a2ed..686259f 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1,2 +1,3 @@ +pub mod functions; +pub mod redis; pub mod schemas; -pub mod functions; \ No newline at end of file diff --git a/src/db/redis.rs b/src/db/redis.rs new file mode 100644 index 0000000..60adeee --- /dev/null +++ b/src/db/redis.rs @@ -0,0 +1,32 @@ +use redis::{AsyncCommands, Client, FromRedisValue, RedisError, ToRedisArgs}; +use serde::Serialize; +use serde::de::DeserializeOwned; + +#[derive(Clone)] +pub struct RedisCache { + client: Client, +} + +impl RedisCache { + pub fn new(client: Client) -> Self { + Self { client } + } + + pub async fn get(&self, key: &str) -> Result, RedisError> + where + T: FromRedisValue + DeserializeOwned, + { + let mut con = self.client.get_multiplexed_tokio_connection().await?; + let result: Option = con.get(key).await?; + Ok(result) + } + + pub async fn set(&self, key: &str, value: &T, ttl_seconds: usize) -> Result<(), RedisError> + where + T: ToRedisArgs + Serialize + Sync, + { + let mut con = self.client.get_multiplexed_tokio_connection().await?; + let _: () = con.set_ex(key, value, ttl_seconds as u64).await?; + Ok(()) + } +} From ee6ed396074dfde0b2950739ec4dcb953d1425cd Mon Sep 17 00:00:00 2001 From: nixxoq Date: Fri, 29 Aug 2025 20:38:06 +0300 Subject: [PATCH 08/66] feat(errors): use thiserror with other errors handling --- src/util/errors.rs | 44 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/src/util/errors.rs b/src/util/errors.rs index c82b3cd..b20598c 100644 --- a/src/util/errors.rs +++ b/src/util/errors.rs @@ -1 +1,43 @@ -pub type MyError = Box; +use crate::util::currency::converter::ConvertError; +use oximod::_error::oximod_error::OxiModError; +use redis; +use teloxide::RequestError; +use thiserror::Error; +use url::ParseError; + +#[derive(Error, Debug)] +pub enum MyError { + #[error("Teloxide API Error: {0}")] + Teloxide(#[from] RequestError), + + #[error("Redis Error: {0}")] + Redis(#[from] redis::RedisError), + + #[error("Reqwest Error: {0}")] + Reqwest(#[from] reqwest::Error), + + #[error("Gemini AI Error: {0}")] + Gemini(#[from] gem_rs::errors::GemError), + + #[error("Currency Conversion Error: {0}")] + CurrencyConversion(#[from] ConvertError), + + #[error("MongoDB Error: {0}")] + MongoDb(#[from] OxiModError), + + #[error("Failed to parse URL: {0}")] + UrlParse(#[from] ParseError), + + #[error("Application Error: {0}")] + Other(String), +} +impl From<&str> for MyError { + fn from(s: &str) -> Self { + MyError::Other(s.to_string()) + } +} +impl From for MyError { + fn from(s: String) -> Self { + MyError::Other(s) + } +} From 75fd4bc26f188645ee154da1810b2c627fd1135c Mon Sep 17 00:00:00 2001 From: nixxoq Date: Fri, 29 Aug 2025 20:38:33 +0300 Subject: [PATCH 09/66] chore(loader): fix compile error by using MyError --- src/loader.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/loader.rs b/src/loader.rs index dcff3fd..b0e7647 100644 --- a/src/loader.rs +++ b/src/loader.rs @@ -25,7 +25,7 @@ use teloxide::{ pub fn inline_query_handler() -> Handler< 'static, - Result<(), Box>, + Result<(), MyError>, teloxide::dispatching::DpHandlerDescription, > { dptree::entry().branch(dptree::filter_async(is_currency_query).endpoint(handle_currency_inline)) From 9b1ff736f5c69e23cd6b865cb6bccb4abe032404 Mon Sep 17 00:00:00 2001 From: nixxoq Date: Fri, 29 Aug 2025 20:40:23 +0300 Subject: [PATCH 10/66] feat(transcription): save original and summarized texts to the redis cache --- src/util/enums.rs | 1 + src/util/transcription.rs | 407 ++++++++++++++++++++++++++++---------- 2 files changed, 306 insertions(+), 102 deletions(-) diff --git a/src/util/enums.rs b/src/util/enums.rs index 864908c..b3d69fb 100644 --- a/src/util/enums.rs +++ b/src/util/enums.rs @@ -18,4 +18,5 @@ pub enum Command { pub struct AudioStruct { pub mime_type: String, pub file_id: String, + pub file_unique_id: String, } diff --git a/src/util/transcription.rs b/src/util/transcription.rs index 0cc2f78..4c03d5c 100644 --- a/src/util/transcription.rs +++ b/src/util/transcription.rs @@ -1,19 +1,74 @@ use super::enums::AudioStruct; use crate::config::Config; use crate::util::errors::MyError; -use crate::util::inline::delete_message_button; use bytes::Bytes; use gem_rs::types::HarmBlockThreshold; -use log::{error, info}; +use log::{debug, error, info}; +use redis_macros::{FromRedisValue, ToRedisArgs}; +use serde::{Deserialize, Serialize}; use std::time::Duration; use teloxide::Bot; -use teloxide::payloads::{EditMessageTextSetters, SendMessageSetters}; +use teloxide::payloads::{AnswerCallbackQuerySetters, EditMessageTextSetters, SendMessageSetters}; use teloxide::requests::{Request as TeloxideRequest, Requester}; use teloxide::types::{ CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message, MessageKind, ParseMode, ReplyParameters, }; +#[derive(Debug, Serialize, Deserialize, FromRedisValue, ToRedisArgs, Clone)] +struct TranscriptionCache { + full_text: String, + summary: Option, + file_id: String, + mime_type: String, +} + +async fn get_cached( + bot: &Bot, + file: &AudioStruct, + config: &Config, +) -> Result { + let cache = config.get_redis_client(); + let file_cache_key = format!("transcription_by_file:{}", &file.file_unique_id); + + if let Some(cached_text) = cache.get::(&file_cache_key).await? { + debug!("File cache HIT for unique_id: {}", &file.file_unique_id); + return Ok(cached_text); + } + + let file_data = save_file_to_memory(bot, &file.file_id).await?; + + let transcription = Transcription { + mime_type: file.mime_type.to_string(), + data: file_data, + config: config.clone(), + }; + let processed_parts = transcription.to_text().await; + + if processed_parts.is_empty() || processed_parts[0].contains("Не удалось преобразовать") + { + let error_message = processed_parts.get(0).cloned().unwrap_or_default(); + return Err(MyError::Other(error_message)); + } + + let full_text = processed_parts.join("\n\n"); + + let new_cache_entry = TranscriptionCache { + full_text, + summary: None, + file_id: file.file_id.clone(), + mime_type: file.mime_type.clone(), + }; + + cache.set(&file_cache_key, &new_cache_entry, 86400).await?; + debug!( + "Saved new transcription to file cache for unique_id: {}", + file.file_id + ); + + Ok(new_cache_entry) +} + pub async fn transcription_handler(bot: Bot, msg: Message, config: &Config) -> Result<(), MyError> { let message = bot .send_message(msg.chat.id, "Обрабатываю аудио...") @@ -22,138 +77,228 @@ pub async fn transcription_handler(bot: Bot, msg: Message, config: &Config) -> R .await .ok(); - let original_user_id = msg.from.clone().unwrap().id; - - let keyboard = InlineKeyboardMarkup::new(vec![vec![ - InlineKeyboardButton::callback("✨", "summarize"), - InlineKeyboardButton::callback("🗑️", format!("delete_{}", original_user_id.0)), - ]]); + let Some(message) = message else { + return Ok(()); + }; - if let Some(message) = message { - if let Some(file) = get_file_id(&msg).await { - let Ok(file_data) = save_file_to_memory(&bot, &file.file_id).await else { - bot.edit_message_text( - msg.chat.id, - message.id, - "❌ Ошибка: Не удалось скачать файл. Возможно, он слишком большой (>20MB).", - ) - .await?; - return Ok(()); - }; + if let Some(file) = get_file_id(&msg).await { + match get_cached(&bot, &file, config).await { + Ok(cache_entry) => { + let cache = config.get_redis_client(); - let transcription = Transcription { - mime_type: file.mime_type, - data: file_data, - config: config.clone(), - }; + let message_file_map_key = format!("message_file_map:{}", message.id); + cache + .set(&message_file_map_key, &file.file_unique_id, 3600) + .await?; - let text_parts = transcription.to_text().await; + let text_parts = split_text(cache_entry.full_text, 4000); - bot.edit_message_text( - msg.chat.id, - message.id, - format!("
{}
", text_parts[0]), - ) - .parse_mode(ParseMode::Html) - .reply_markup(keyboard.clone()) - .await?; + let keyboard = InlineKeyboardMarkup::new(vec![vec![ + InlineKeyboardButton::callback("✨", "summarize"), + InlineKeyboardButton::callback( + "🗑️", + format!("delete_{}", msg.from.unwrap().id.0), + ), + ]]); - for part in text_parts.iter().skip(1) { - bot.send_message( + bot.edit_message_text( msg.chat.id, - format!("
\n{}\n
", part), + message.id, + format!("
{}
", text_parts[0]), ) - .reply_parameters(ReplyParameters::new(msg.id)) .parse_mode(ParseMode::Html) .reply_markup(keyboard.clone()) .await?; + + for part in text_parts.iter().skip(1) { + bot.send_message( + msg.chat.id, + format!("
\n{}\n
", part), + ) + .reply_parameters(ReplyParameters::new(msg.id)) + .parse_mode(ParseMode::Html) + .reply_markup(keyboard.clone()) + .await?; + } + } + Err(e) => { + error!("Failed to get transcription: {:?}", e); + let error_text = match e { + MyError::Other(msg) if msg.contains("Не удалось преобразовать") => { + msg + } + MyError::Reqwest(_) => { + "❌ Ошибка: Не удалось скачать файл. Возможно, он слишком большой (>20MB)." + .to_string() + } + _ => "❌ Произошла неизвестная ошибка при обработке аудио.".to_string(), + }; + bot.edit_message_text(message.chat.id, message.id, error_text) + .await?; } - } else { - bot.edit_message_text( - msg.chat.id, - message.id, - "Не удалось найти голосовое сообщение.", - ) - .parse_mode(ParseMode::Html) - .await?; } + } else { + bot.edit_message_text( + message.chat.id, + message.id, + "Не удалось найти голосовое сообщение.", + ) + .parse_mode(ParseMode::Html) + .await?; } Ok(()) } +pub async fn back_handler(bot: Bot, query: CallbackQuery, config: &Config) -> Result<(), MyError> { + let Some(message) = query.message else { + return Ok(()); + }; + bot.answer_callback_query(query.id).await?; + + let cache = config.get_redis_client(); + let message_cache_key = format!("message_file_map:{}", message.id()); + + let Some(file_unique_id) = cache.get::(&message_cache_key).await? else { + bot.edit_message_text( + message.chat().id, + message.id(), + "❌ Не удалось найти исходное сообщение.", + ) + .await?; + return Ok(()); + }; + + let file_cache_key = format!("transcription_by_file:{}", file_unique_id); + let Some(cache_entry) = cache.get::(&file_cache_key).await? else { + bot.edit_message_text( + message.chat().id, + message.id(), + "❌ Не удалось найти исходный текст в кеше.", + ) + .await?; + return Ok(()); + }; + + let text_parts = split_text(cache_entry.full_text, 4000); + + bot.edit_message_text( + message.chat().id, + message.id(), + format!("
{}
", text_parts[0]), + ) + .parse_mode(ParseMode::Html) + .reply_markup(InlineKeyboardMarkup::new(vec![vec![ + InlineKeyboardButton::callback("✨", "summarize"), + InlineKeyboardButton::callback("🗑️", format!("delete_{}", query.from.id.0)), + ]])) + .await?; + + Ok(()) +} + pub async fn summarization_handler( bot: Bot, query: CallbackQuery, config: &Config, ) -> Result<(), MyError> { - if let Some(message) = query.message { - bot.answer_callback_query(query.id).await?; + let Some(message) = query.message else { + return Ok(()); + }; + + let Some(message) = message.regular_message() else { + return Ok(()); + }; + + bot.answer_callback_query(query.id).await?; + + let cache = config.get_redis_client(); + + let message_file_map_key = format!("message_file_map:{}", message.id); + let Some(file_unique_id) = cache.get::(&message_file_map_key).await? else { + bot.edit_message_text( + message.chat.id, + message.id, + "❌ Не удалось обработать запрос. Кнопка устарела.", + ) + .await?; + + return Ok(()); + }; - let original_msg = if let Some(reply) = message.regular_message() { - reply.reply_to_message().unwrap() - } else { + let file_cache_key = format!("transcription_by_file:{}", file_unique_id); + + let mut cache_entry = match cache.get::(&file_cache_key).await? { + Some(entry) => entry, + None => { bot.edit_message_text( - message.chat().id, - message.id(), - "Не удалось найти исходное сообщение для обработки.", + message.chat.id, + message.id, + "❌ Не удалось найти исходное аудио.", ) .await?; + return Ok(()); - }; + } + }; - if let Some(audio_struct) = get_file_id(original_msg).await { - bot.edit_message_text( - message.chat().id, - message.id(), - "Составляю краткое содержание из аудио...", - ) + if let Some(cached_summary) = cache_entry.summary { + let final_text = format!( + "Краткое содержание:\n
{}
", + cached_summary + ); + + let back_keyboard = InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::callback( + "⬅️ Назад", + "back_to_full", + )]]); + + bot.edit_message_text(message.chat.id, message.id, final_text) .parse_mode(ParseMode::Html) + .reply_markup(back_keyboard) .await?; - let file_data = save_file_to_memory(&bot, &audio_struct.file_id).await?; + return Ok(()); + } - let summary_result = - summarize_audio(audio_struct.mime_type, file_data, config.clone()).await; + bot.edit_message_text( + message.chat.id, + message.id, + "Составляю краткое содержание...", + ) + .await?; + + let file_data = save_file_to_memory(&bot, &cache_entry.file_id).await?; + let new_summary = + summarize_audio(cache_entry.mime_type.clone(), file_data, config.clone()).await?; + + if new_summary.is_empty() || new_summary.contains("Не удалось получить") { + bot.edit_message_text( + message.chat.id, + message.id, + "❌ Не удалось составить краткое содержание.", + ) + .await?; + return Ok(()); + } - match summary_result { - Ok(summary) => { - let final_text = format!( - "Краткое содержание:\n
{}
", - summary - ); + cache_entry.summary = Some(new_summary.clone()); + cache.set(&file_cache_key, &cache_entry, 86400).await?; + + let final_text = format!( + "Краткое содержание:\n
{}
", + new_summary + ); + + let back_keyboard = InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::callback( + "⬅️ Назад", + "back_to_full", + )]]); + + bot.edit_message_text(message.chat.id, message.id, final_text) + .parse_mode(ParseMode::Html) + .reply_markup(back_keyboard) + .await?; - bot.edit_message_text(message.chat().id, message.id(), final_text) - .parse_mode(ParseMode::Html) - // .reply_markup(delete_message_button(original_user_id)) - .await?; - } - Err(e) => { - error!("Error during summarization: {:?}", e); - bot.edit_message_text( - message.chat().id, - message.id(), - "❌ Ошибка при составлении краткого содержания.", - ) - .reply_markup( - message - .regular_message() - .unwrap() - .reply_markup() - .cloned() - .unwrap(), - ) - .await?; - } - } - } else { - bot.edit_message_text( - message.chat().id, - message.id(), - "Не удалось найти аудио в исходном сообщении.", - ) - .await?; - } - } Ok(()) } @@ -204,6 +349,7 @@ pub async fn get_file_id(msg: &Message) -> Option { .essence_str() .to_owned(), file_id: audio.audio.file.id.0.to_string(), + file_unique_id: audio.audio.file.unique_id.0.to_string(), }), teloxide::types::MediaKind::Voice(voice) => Some(AudioStruct { mime_type: voice @@ -214,10 +360,12 @@ pub async fn get_file_id(msg: &Message) -> Option { .essence_str() .to_owned(), file_id: voice.voice.file.id.0.to_owned(), + file_unique_id: voice.voice.file.unique_id.0.to_owned(), }), teloxide::types::MediaKind::VideoNote(video_note) => Some(AudioStruct { mime_type: "video/mp4".to_owned(), file_id: video_note.video_note.file.id.0.to_owned(), + file_unique_id: video_note.video_note.file.unique_id.0.to_owned(), }), _ => None, }, @@ -331,3 +479,58 @@ fn split_text(text: String, chunk_size: usize) -> Vec { .map(|chunk| chunk.iter().collect()) .collect() } + +pub async fn delete_transcription_handler(bot: Bot, query: CallbackQuery) -> Result<(), MyError> { + let Some(message_to_delete) = query.message else { + return Ok(()); + }; + + let Some(message) = message_to_delete.regular_message() else { + return Ok(()); + }; + + let Some(data) = query.data else { + return Ok(()); + }; + let clicker = query.from; + + let target_user_id_str = data.strip_prefix("delete_").unwrap_or_default(); + let Ok(target_user_id) = target_user_id_str.parse::() else { + bot.answer_callback_query(query.id) + .text("❌ Ошибка: неверный ID в кнопке.") + .show_alert(true) + .await?; + return Ok(()); + }; + + let mut has_permission = false; + + if clicker.id.0 == target_user_id { + has_permission = true; + } + + if !has_permission && message.chat.is_group() || message.chat.is_supergroup() { + if let Ok(member) = bot.get_chat_member(message.chat.id, clicker.id).await { + if member.is_privileged() { + has_permission = true; + } + } + } + + if !has_permission { + bot.answer_callback_query(query.id) + .text("❌ Удалить может только автор сообщения или администратор.") + .show_alert(true) + .await?; + return Ok(()); + } + + bot.answer_callback_query(query.id).await?; + + bot.delete_message(message.chat.id, message.id) + .await + .map_err(|e| error!("Failed to delete bot's message: {:?}", e)) + .ok(); + + Ok(()) +} From fc32e53fb565428f9ed2e242daf64ae548ed875f Mon Sep 17 00:00:00 2001 From: nixxoq Date: Fri, 29 Aug 2025 20:41:33 +0300 Subject: [PATCH 11/66] feat(markuper): use new handler to delete message from bot, add handler for backing to original text --- .../markups/inlines/delete_msg_inline.rs | 74 ------------------- src/handlers/markups/inlines/mod.rs | 1 - src/handlers/markups/markuper.rs | 11 ++- 3 files changed, 7 insertions(+), 79 deletions(-) delete mode 100644 src/handlers/markups/inlines/delete_msg_inline.rs diff --git a/src/handlers/markups/inlines/delete_msg_inline.rs b/src/handlers/markups/inlines/delete_msg_inline.rs deleted file mode 100644 index 9a9158c..0000000 --- a/src/handlers/markups/inlines/delete_msg_inline.rs +++ /dev/null @@ -1,74 +0,0 @@ -use log::error; -use crate::util::errors::MyError; -use crate::util::inline::DELETE_CALLBACK_DATA; -use teloxide::Bot; -use teloxide::payloads::AnswerCallbackQuerySetters; -use teloxide::prelude::CallbackQuery; -use teloxide::requests::Requester; - -pub async fn delete_msg_handler(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { - let data = match q.data { - Some(ref data) => data, - None => return Ok(()), - }; - - let parts: Vec<&str> = data.split(':').collect(); - - if parts.len() != 2 || parts[0] != DELETE_CALLBACK_DATA { - bot.answer_callback_query(q.id) - .text("❌ Неверный формат данных для удаления.") - .show_alert(true) - .await?; - return Ok(()); - } - - let original_user_id: u64 = match parts[1].parse() { - Ok(id) => id, - Err(_) => { - bot.answer_callback_query(q.id) - .text("❌ Произошла ошибка (неверный ID пользователя).") - .show_alert(true) - .await?; - return Ok(()); - } - }; - - let message = match q.message { - Some(msg) => msg, - None => { - bot.answer_callback_query(q.id) - .text("❌ Произошла неопознанная ошибка (сообщение не найдено).") - .show_alert(true) - .await?; - return Ok(()); - } - }; - - let chat_id = message.chat().id; - let message_id = message.id(); - - let member = bot.get_chat_member(chat_id, q.from.id).await; - - if let Ok(member) = member - && (member.user.id.0 == original_user_id || member.is_privileged()) - { - return match bot.delete_message(chat_id, message_id).await { - Ok(_) => Ok(()), - Err(e) => { - error!("Failed to delete message: {:?}", e); - bot.answer_callback_query(q.id) - .text("❌ Не удалось удалить сообщение (возможно, у меня нет прав или сообщение слишком старое).") - .show_alert(true) - .await?; - Ok(()) - } - }; - } - - bot.answer_callback_query(q.id) - .text("❌ У вас нет прав для удаления этого сообщения.") - .show_alert(true) - .await?; - - Ok(()) -} diff --git a/src/handlers/markups/inlines/mod.rs b/src/handlers/markups/inlines/mod.rs index 46ba27d..93a4866 100644 --- a/src/handlers/markups/inlines/mod.rs +++ b/src/handlers/markups/inlines/mod.rs @@ -1,3 +1,2 @@ pub mod currency; -pub mod delete_msg_inline; pub mod cobalter; diff --git a/src/handlers/markups/markuper.rs b/src/handlers/markups/markuper.rs index dad5bd1..88ca997 100644 --- a/src/handlers/markups/markuper.rs +++ b/src/handlers/markups/markuper.rs @@ -1,10 +1,11 @@ use crate::config::Config; -use crate::handlers::markups::inlines::delete_msg_inline::delete_msg_handler; use crate::util::errors::MyError; +use crate::util::transcription::{ + back_handler, delete_transcription_handler, summarization_handler, +}; use teloxide::Bot; use teloxide::requests::Requester; use teloxide::types::CallbackQuery; -use crate::util::transcription::summarization_handler; pub(crate) async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { let _config = Config::new().await; @@ -13,10 +14,12 @@ pub(crate) async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Resul let qq = q.clone(); let data = qq.data.clone().unwrap(); - if data.starts_with("delete_msg") { - delete_msg_handler(bot, qq).await + if data.starts_with("delete_") { + delete_transcription_handler(bot, qq).await } else if data.starts_with("summarize") { summarization_handler(bot, qq, &_config).await + } else if data.starts_with("back_to_full") { + back_handler(bot, qq, &_config).await } else { bot.answer_callback_query(qq.id).await.unwrap(); Ok(()) From 08853ee0720c63d8a6b387fa48f715259cc811ef Mon Sep 17 00:00:00 2001 From: nixxoq Date: Fri, 29 Aug 2025 20:41:57 +0300 Subject: [PATCH 12/66] chore(cobalter & currency): fix compilation errors --- src/handlers/markups/inlines/cobalter.rs | 12 +++++++----- src/handlers/markups/inlines/currency.rs | 4 ++-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/handlers/markups/inlines/cobalter.rs b/src/handlers/markups/inlines/cobalter.rs index ce2fed9..24061a6 100644 --- a/src/handlers/markups/inlines/cobalter.rs +++ b/src/handlers/markups/inlines/cobalter.rs @@ -1,14 +1,18 @@ use crate::config::Config; +use ccobalt::model::request::{DownloadRequest, VideoQuality}; use once_cell::sync::Lazy; use regex::Regex; use std::error as error_handler; use std::str::FromStr; use std::sync::Arc; -use ccobalt::model::request::{DownloadRequest, VideoQuality}; -use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup, InlineQuery, InlineQueryResult, InlineQueryResultArticle, InlineQueryResultsButton, InputMessageContent, InputMessageContentText}; use teloxide::Bot; use teloxide::payloads::{AnswerInlineQuery, AnswerInlineQuerySetters, SendVideoSetters}; use teloxide::prelude::{Request, Requester}; +use teloxide::types::{ + InlineKeyboardButton, InlineKeyboardMarkup, InlineQuery, InlineQueryResult, + InlineQueryResultArticle, InlineQueryResultsButton, InputMessageContent, + InputMessageContentText, +}; static URL_REGEX: Lazy = Lazy::new(|| Regex::new(r"^(https?)://[^\s/$.?#].[^\s]*$").unwrap()); @@ -51,9 +55,7 @@ pub async fn handle_cobalt_inline( let article = InlineQueryResultArticle::new( "cobalt_prompt", "Cobalt Test", - InputMessageContent::Text(InputMessageContentText::new( - "Test", - )), + InputMessageContent::Text(InputMessageContentText::new("Test")), ) .description("Teestttt 22") .reply_markup(keyboard); diff --git a/src/handlers/markups/inlines/currency.rs b/src/handlers/markups/inlines/currency.rs index fa5bbc1..e98b6c5 100644 --- a/src/handlers/markups/inlines/currency.rs +++ b/src/handlers/markups/inlines/currency.rs @@ -1,9 +1,9 @@ use crate::config::Config; use crate::db::schemas::user::User; +use crate::util::errors::MyError; use log::{debug, error}; use mongodb::bson::doc; use oximod::Model; -use std::error as error_handler; use std::sync::Arc; use teloxide::Bot; use teloxide::payloads::AnswerInlineQuerySetters; @@ -20,7 +20,7 @@ pub async fn handle_currency_inline( q: InlineQuery, config: Arc, me: Me, -) -> Result<(), Box> { +) -> Result<(), MyError> { let user_id_str = q.from.id.to_string(); let user_exists = User::find_one(doc! { "user_id": &user_id_str }) From 2bc7c767e3e4b77f7cd22f6cfe17911fe67bb521 Mon Sep 17 00:00:00 2001 From: nixxoq Date: Fri, 29 Aug 2025 20:42:15 +0300 Subject: [PATCH 13/66] chore(cargo): add redis, redis-macros and url dependencies --- Cargo.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index c76d881..aaa80b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,4 +23,7 @@ log = "0.4.27" pretty_env_logger = "0.5.0" uuid = { version = "1.17.0", features = ["v4"] } ccobalt = "0.2.1" -translators = { version = "0.1.5", features = ["google", "tokio-async"] } \ No newline at end of file +translators = { version = "0.1.5", features = ["google", "tokio-async"] } +redis = { version = "0.32.5", features = ["tokio-comp"] } +redis-macros = { version = "0.5.6" } +url = "2.5.4" \ No newline at end of file From 63998a3138b236396e370cd75eca061586b130a8 Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Sat, 30 Aug 2025 02:15:07 +0300 Subject: [PATCH 14/66] Added a very-very bad settings system --- .env.example | 6 +- src/db/schemas.rs | 14 +++ src/db/schemas/settings.rs | 81 +++++++++++++++++ src/handlers/commander.rs | 3 +- src/handlers/commands/settings.rs | 77 +++++++++++++++- src/handlers/markups/markuper.rs | 20 +++-- src/handlers/markups/markups/mod.rs | 1 + src/handlers/markups/markups/module.rs | 117 +++++++++++++++++++++++++ src/handlers/markups/mod.rs | 1 + src/util/enums.rs | 2 + src/util/errors.rs | 4 +- 11 files changed, 312 insertions(+), 14 deletions(-) create mode 100644 src/db/schemas/settings.rs create mode 100644 src/handlers/markups/markups/mod.rs create mode 100644 src/handlers/markups/markups/module.rs diff --git a/.env.example b/.env.example index f269d6f..44636ab 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ BOT_TOKEN= GEMINI_API_KEY= -OWNERS=701733705, 6452350296 -MONGODB_URL= +OWNERS=701733705,6452350296 +MONGODB_URL=mongodb://localhost:27017 RUST_LOG=info -REDIS_URL= \ No newline at end of file +REDIS_URL=redis://127.0.0.1:6379 \ No newline at end of file diff --git a/src/db/schemas.rs b/src/db/schemas.rs index 0e18331..ae1df66 100644 --- a/src/db/schemas.rs +++ b/src/db/schemas.rs @@ -1,10 +1,13 @@ pub mod group; pub mod user; +pub mod settings; use crate::util::currency::converter::CurrencyStruct; use async_trait::async_trait; use mongodb::results::UpdateResult; use oximod::_error::oximod_error::OxiModError; +use crate::db::schemas::settings::ModuleSettings; +use crate::util::errors::MyError; #[async_trait] pub trait BaseFunctions: Sized { @@ -20,3 +23,14 @@ pub trait CurrenciesFunctions: Sized { -> Result; async fn remove_currency(id: &str, currency: &str) -> Result; } + +#[async_trait] +pub trait SettingsRepo { + async fn get_or_create(owner_id: &str, owner_type: &str) -> Result + where + Self: Sized; + + fn modules(&self) -> &Vec; + + fn modules_mut(&mut self) -> &mut Vec; +} diff --git a/src/db/schemas/settings.rs b/src/db/schemas/settings.rs new file mode 100644 index 0000000..deaed16 --- /dev/null +++ b/src/db/schemas/settings.rs @@ -0,0 +1,81 @@ +use crate::db::schemas::SettingsRepo; +use crate::util::errors::MyError; +use async_trait::async_trait; +use mongodb::bson::{doc, oid::ObjectId}; +use oximod::{Model, ModelTrait}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Model)] +#[db("fulturate")] +#[collection("settings")] +pub struct Settings { + #[serde(skip_serializing_if = "Option::is_none")] + _id: Option, + + #[index(unique, name = "owner")] + pub owner_id: String, + + pub owner_type: String, + + #[serde(default)] + pub modules: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ModuleSettings { + pub key: String, // уникальный ключ модуля, например "currency" или "speech_recog" + pub enabled: bool, // включен или нет + pub description: String, // описание модуля + #[serde(default)] // тут ещё было бы неплохо добавить лимиты, по типу максимум и какой сейчас, + // но мне че то лень это продумывать + pub options: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ModuleOption { + pub key: String, + pub value: String, +} + +#[async_trait] +impl SettingsRepo for Settings { + async fn get_or_create(owner_id: &str, owner_type: &str) -> Result { + if let Some(found) = Settings::find_one(doc! { "owner_id": owner_id, "owner_type": owner_type }).await? { + Ok(found) + } else { + let default_modules = vec![ + ModuleSettings { + key: "currency".to_string(), + enabled: false, + description: "Конвертация валют".to_string(), + options: vec![ + ModuleOption { key: "currencies".into(), value: "USD,EUR".into() }, + ], + }, + ModuleSettings { + key: "speech".to_string(), + enabled: false, + description: "Распознавание речи".to_string(), + options: vec![ + ModuleOption { key: "model".into(), value: "Gemini 2.5 Flash".into() }, + ModuleOption { key: "token".into(), value: "".into() }, + ], + }, + ]; + let new = Settings::new() + .owner_id(owner_id.to_string()) + .owner_type(owner_type.to_string()) + .modules(default_modules); + ModelTrait::save(&new).await?; + Settings::get_or_create(owner_id, owner_type).await + } + } + + fn modules(&self) -> &Vec { + &self.modules + } + + fn modules_mut(&mut self) -> &mut Vec { + &mut self.modules + } +} diff --git a/src/handlers/commander.rs b/src/handlers/commander.rs index 58c0814..d9c8387 100644 --- a/src/handlers/commander.rs +++ b/src/handlers/commander.rs @@ -1,6 +1,6 @@ use crate::config::Config; use crate::handlers::commands::{ - settings::{currency_codes_handler, currency_codes_list_handler}, + settings::{currency_codes_handler, currency_codes_list_handler, settings_command_handler}, speech_recognition::speech_recognition_handler, start::start_handler, }; @@ -23,6 +23,7 @@ pub(crate) async fn command_handlers( Command::SpeechRecognition => speech_recognition_handler(bot, message, &config).await, Command::SetCurrency { code } => currency_codes_handler(bot, message, code).await, Command::ListCurrency => currency_codes_list_handler(bot, message).await, + Command::Settings => settings_command_handler(bot, message, &config).await, } }); Ok(()) diff --git a/src/handlers/commands/settings.rs b/src/handlers/commands/settings.rs index a0a0b13..308241a 100644 --- a/src/handlers/commands/settings.rs +++ b/src/handlers/commands/settings.rs @@ -1,10 +1,12 @@ -use crate::db::schemas::{BaseFunctions, CurrenciesFunctions}; +use crate::config::Config; +use crate::db::schemas::settings::Settings; +use crate::db::schemas::{BaseFunctions, CurrenciesFunctions, SettingsRepo}; use crate::{ db::functions::get_or_create, db::schemas::group::Group, db::schemas::user::User, util::{ - currency::converter::{CURRENCY_CONFIG_PATH, get_all_currency_codes}, + currency::converter::{get_all_currency_codes, CURRENCY_CONFIG_PATH}, errors::MyError, }, }; @@ -12,7 +14,7 @@ use log::error; use oximod::Model; use std::collections::HashSet; use teloxide::prelude::*; -use teloxide::types::{ParseMode, ReplyParameters}; +use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup, MaybeInaccessibleMessage, ParseMode, ReplyParameters}; pub async fn handle_currency_update( bot: Bot, @@ -152,3 +154,72 @@ pub async fn currency_codes_list_handler(bot: Bot, msg: Message) -> Result<(), M Ok(()) } + + +// new settings +pub async fn settings_command_handler( + bot: Bot, + message: Message, + _config: &Config, +) -> Result<(), MyError> { + let owner_id: String = if let Some(user) = message.from() { + user.id.to_string() + } else { + message.chat.id.to_string() + }; + + let owner_type = if message.chat.is_private() { "user" } else { "group" }; + + let settings = Settings::get_or_create(&owner_id, &owner_type).await?; + + let keyboard = InlineKeyboardMarkup::new( + settings + .modules + .iter() + .map(|m| { + let status = if m.enabled { "✅" } else { "❌" }; + let text = format!("{status} {}", m.description); + let callback_data = format!("module_select:{owner_type}:{owner_id}:{}", m.key); + vec![InlineKeyboardButton::callback(text, callback_data)] + }) + .collect::>(), + ); + + bot.send_message(message.chat.id, "Настройки модулей:") + .reply_markup(keyboard) + .await?; + + Ok(()) +} + +pub async fn update_settings_message( + bot: Bot, + message: MaybeInaccessibleMessage, + owner_id: String, + owner_type: String, +) -> Result<(), MyError> { + let settings = Settings::get_or_create(&owner_id, &owner_type).await?; + + let keyboard = InlineKeyboardMarkup::new( + settings + .modules + .iter() + .map(|m| { + let status = if m.enabled { "✅" } else { "❌" }; + let text = format!("{status} {}", m.description); + let callback_data = format!("module_select:{owner_type}:{owner_id}:{}", m.key); + vec![InlineKeyboardButton::callback(text, callback_data)] + }) + .collect::>(), + ); + + let text = "Настройки модулей:"; + + if let MaybeInaccessibleMessage::Regular(msg) = message { + let _ = bot.edit_message_text(msg.chat.id, msg.id, text) + .reply_markup(keyboard) + .await; + } + + Ok(()) +} \ No newline at end of file diff --git a/src/handlers/markups/markuper.rs b/src/handlers/markups/markuper.rs index 88ca997..58b69ef7 100644 --- a/src/handlers/markups/markuper.rs +++ b/src/handlers/markups/markuper.rs @@ -6,25 +6,33 @@ use crate::util::transcription::{ use teloxide::Bot; use teloxide::requests::Requester; use teloxide::types::CallbackQuery; +use crate::handlers::markups::markups::module::{module_option_handler, module_select_handler, module_toggle_handler, settings_back_handler}; pub(crate) async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { let _config = Config::new().await; - tokio::spawn(async move { + // tokio::spawn(async move { let qq = q.clone(); let data = qq.data.clone().unwrap(); if data.starts_with("delete_") { - delete_transcription_handler(bot, qq).await + delete_transcription_handler(bot, qq).await? } else if data.starts_with("summarize") { - summarization_handler(bot, qq, &_config).await + summarization_handler(bot, qq, &_config).await? } else if data.starts_with("back_to_full") { - back_handler(bot, qq, &_config).await + back_handler(bot, qq, &_config).await? + } else if data.starts_with("module_select:") { + module_select_handler(bot, qq).await? + } else if data.starts_with("module_toggle") { + module_toggle_handler(bot, qq).await? + } else if data.starts_with("module_opt:") { + module_option_handler(bot, qq).await? + } else if data.starts_with("settings_back:") { + settings_back_handler(bot, qq).await? } else { bot.answer_callback_query(qq.id).await.unwrap(); - Ok(()) } - }); + // }); Ok(()) } diff --git a/src/handlers/markups/markups/mod.rs b/src/handlers/markups/markups/mod.rs new file mode 100644 index 0000000..a94a210 --- /dev/null +++ b/src/handlers/markups/markups/mod.rs @@ -0,0 +1 @@ +pub mod module; \ No newline at end of file diff --git a/src/handlers/markups/markups/module.rs b/src/handlers/markups/markups/module.rs new file mode 100644 index 0000000..4928e2c --- /dev/null +++ b/src/handlers/markups/markups/module.rs @@ -0,0 +1,117 @@ +use oximod::Model; +use teloxide::Bot; +use teloxide::payloads::{AnswerCallbackQuerySetters, EditMessageTextSetters}; +use teloxide::prelude::{CallbackQuery, Requester}; +use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup}; +use crate::db::schemas::settings::Settings; +use crate::db::schemas::SettingsRepo; +use crate::handlers::commands::settings::{update_settings_message}; +use crate::util::errors::MyError; + +pub async fn module_select_handler( + bot: Bot, + q: CallbackQuery, +) -> Result<(), MyError> { + let data = match q.data.as_ref() { + Some(d) => d, + None => return Ok(()), + }; + let message = match q.message.as_ref() { + Some(m) => m, + None => return Ok(()), + }; + + let parts: Vec<_> = data.split(':').collect(); + if parts.len() < 4 { + log::error!("Invalid callback data format: {}", data); + return Ok(()); + } + let owner_type = parts[1]; + let owner_id = parts[2]; + let module_key = parts[3]; + + let mut settings = Settings::get_or_create(owner_id, owner_type).await?; + let module = settings + .modules_mut() + .iter_mut() + .find(|m| m.key == module_key) + .unwrap(); + + let toggle_label = if module.enabled { "Выключить" } else { "Включить" }; + let toggle_cb = format!("module_toggle:{owner_type}:{owner_id}:{module_key}"); + + let mut keyboard_rows: Vec> = vec![]; + + keyboard_rows.push(vec![InlineKeyboardButton::callback(toggle_label, toggle_cb)]); + + for opt in module.options.iter() { + let label = format!("{}: {}", opt.key, opt.value); + let cb = format!("module_opt:{owner_type}:{owner_id}:{module_key}:{}", opt.key); + keyboard_rows.push(vec![InlineKeyboardButton::callback(label, cb)]); + } + + let back_button_cb = format!("settings_back:{owner_type}:{owner_id}"); + keyboard_rows.push(vec![InlineKeyboardButton::callback("⬅️ Назад", back_button_cb)]); + + let keyboard = InlineKeyboardMarkup::new(keyboard_rows); + + bot.edit_message_text( + message.chat().id, + message.id(), + format!("⚙️ Настройки модуля: {}", module.description), + ) + .reply_markup(keyboard) + .await?; + + Ok(()) +} + +pub async fn module_toggle_handler(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { + println!("module_toggle_handler"); + let parts: Vec<_> = q.data.as_ref().unwrap().split(':').collect(); + println!("1"); + let owner_type = parts[1]; + println!("2:{}", owner_type); + let owner_id = parts[2]; + println!("3:{}", owner_id); + let module_key = parts[3]; + println!("4:{}", module_key); + + let mut settings = Settings::get_or_create(owner_id, owner_type).await?; + if let Some(m) = settings.modules_mut().iter_mut().find(|m| m.key == module_key) { + println!("5"); + m.enabled = !m.enabled; + println!("6"); + } + println!("7"); + settings.save().await?; // FIXME: this fucking ahh code freezes or gives error but i'm lazy to fix it + println!("8"); + + update_settings_message(bot, q.message.unwrap().clone(), owner_id.to_string(), owner_type.to_string()).await +} + +pub async fn module_option_handler(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { + let parts: Vec<_> = q.data.as_ref().unwrap().split(':').collect(); + let owner_type = parts[1]; + let owner_id = parts[2]; + let module_key = parts[3]; + let option_key = parts[4]; + + let settings = Settings::get_or_create(owner_id, owner_type).await?; + let module = settings.modules.iter().find(|m| m.key == module_key).unwrap(); + let opt = module.options.iter().find(|o| o.key == option_key).unwrap(); + + bot.answer_callback_query(q.id.clone()) + .text(format!("Текущая опция «{}»: {}", opt.key, opt.value)) + .show_alert(true) + .await?; + Ok(()) +} + +pub async fn settings_back_handler(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { + let parts: Vec<_> = q.data.as_ref().unwrap().split(':').collect(); + let owner_type = parts[1]; + let owner_id = parts[2]; + + update_settings_message(bot, q.message.unwrap().clone(), owner_id.to_string(), owner_type.to_string()).await +} diff --git a/src/handlers/markups/mod.rs b/src/handlers/markups/mod.rs index 3c92dfb..1db0329 100644 --- a/src/handlers/markups/mod.rs +++ b/src/handlers/markups/mod.rs @@ -1,2 +1,3 @@ pub mod inlines; pub mod markuper; +mod markups; diff --git a/src/util/enums.rs b/src/util/enums.rs index b3d69fb..8f6a80e 100644 --- a/src/util/enums.rs +++ b/src/util/enums.rs @@ -13,6 +13,8 @@ pub enum Command { SetCurrency { code: String }, #[command(description = "List of available currencies to convert")] ListCurrency, + #[command(description = "Settings of bot")] + Settings, } pub struct AudioStruct { diff --git a/src/util/errors.rs b/src/util/errors.rs index b20598c..ffae168 100644 --- a/src/util/errors.rs +++ b/src/util/errors.rs @@ -31,13 +31,15 @@ pub enum MyError { #[error("Application Error: {0}")] Other(String), } + impl From<&str> for MyError { fn from(s: &str) -> Self { MyError::Other(s.to_string()) } } + impl From for MyError { fn from(s: String) -> Self { MyError::Other(s) } -} +} \ No newline at end of file From 6ba57f75f14b731ece6549dbdbf2d4e4cdd9ca53 Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Sat, 30 Aug 2025 02:24:53 +0300 Subject: [PATCH 15/66] Removed warnings; Enabled cobalt system --- .env.example | 1 + src/config.rs | 3 ++- src/db/schemas.rs | 2 -- src/db/schemas/settings.rs | 4 ---- src/handlers/commander.rs | 2 +- src/handlers/commands/settings.rs | 2 +- src/handlers/commands/start.rs | 4 ++-- src/handlers/commands/translate.rs | 6 +++--- src/handlers/markups/inlines/cobalter.rs | 16 ++++------------ src/loader.rs | 3 ++- 10 files changed, 16 insertions(+), 27 deletions(-) diff --git a/.env.example b/.env.example index 44636ab..8f43935 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ BOT_TOKEN= GEMINI_API_KEY= +COBALT_API_KEY= OWNERS=701733705,6452350296 MONGODB_URL=mongodb://localhost:27017 RUST_LOG=info diff --git a/src/config.rs b/src/config.rs index 4eeb18c..ea7c809 100644 --- a/src/config.rs +++ b/src/config.rs @@ -24,12 +24,13 @@ impl Config { dotenv().ok(); let bot_token = std::env::var("BOT_TOKEN").expect("BOT_TOKEN expected"); + let cobalt_api_key = std::env::var("COBALT_API_KEY").expect("COBALT_API_KEY expected"); let version = std::env::var("CARGO_PKG_VERSION").expect("CARGO_PKG_VERSION expected"); let bot = Bot::new(bot_token); let cobalt_client = ccobalt::Client::builder() .base_url("https://cobalt-backend.canine.tools/") - .api_key("21212121-2121-2121-2121-212121212121") // todo: change api key + .api_key(cobalt_api_key) .build() .expect("Failed to build cobalt client"); diff --git a/src/db/schemas.rs b/src/db/schemas.rs index ae1df66..728f70c 100644 --- a/src/db/schemas.rs +++ b/src/db/schemas.rs @@ -30,7 +30,5 @@ pub trait SettingsRepo { where Self: Sized; - fn modules(&self) -> &Vec; - fn modules_mut(&mut self) -> &mut Vec; } diff --git a/src/db/schemas/settings.rs b/src/db/schemas/settings.rs index deaed16..5f4c0b5 100644 --- a/src/db/schemas/settings.rs +++ b/src/db/schemas/settings.rs @@ -71,10 +71,6 @@ impl SettingsRepo for Settings { } } - fn modules(&self) -> &Vec { - &self.modules - } - fn modules_mut(&mut self) -> &mut Vec { &mut self.modules } diff --git a/src/handlers/commander.rs b/src/handlers/commander.rs index d9c8387..a2d2039 100644 --- a/src/handlers/commander.rs +++ b/src/handlers/commander.rs @@ -19,7 +19,7 @@ pub(crate) async fn command_handlers( task::spawn(async move { match cmd { Command::Start(arg) => start_handler(bot, message, &config, arg).await, - Command::Translate(arg) => translate_handler(bot, message, &config, arg).await, + Command::Translate(arg) => translate_handler(bot, &message, &config, arg).await, Command::SpeechRecognition => speech_recognition_handler(bot, message, &config).await, Command::SetCurrency { code } => currency_codes_handler(bot, message, code).await, Command::ListCurrency => currency_codes_list_handler(bot, message).await, diff --git a/src/handlers/commands/settings.rs b/src/handlers/commands/settings.rs index 308241a..1e5b736 100644 --- a/src/handlers/commands/settings.rs +++ b/src/handlers/commands/settings.rs @@ -162,7 +162,7 @@ pub async fn settings_command_handler( message: Message, _config: &Config, ) -> Result<(), MyError> { - let owner_id: String = if let Some(user) = message.from() { + let owner_id: String = if let Some(user) = message.from { user.id.to_string() } else { message.chat.id.to_string() diff --git a/src/handlers/commands/start.rs b/src/handlers/commands/start.rs index 68b8b9a..385e486 100644 --- a/src/handlers/commands/start.rs +++ b/src/handlers/commands/start.rs @@ -2,7 +2,7 @@ use crate::config::Config; use crate::db::schemas::user::User; use crate::util::currency::converter::get_default_currencies; use crate::util::errors::MyError; -use log::{error, info}; +use log::error; use mongodb::bson::doc; use oximod::Model; use std::time::Instant; @@ -14,7 +14,7 @@ pub async fn start_handler( bot: Bot, message: Message, config: &Config, - arg: String, + _arg: String, ) -> Result<(), MyError> { if message.chat.is_private() { let user = message.from.clone().unwrap(); diff --git a/src/handlers/commands/translate.rs b/src/handlers/commands/translate.rs index a8f1f0c..feb28f7 100644 --- a/src/handlers/commands/translate.rs +++ b/src/handlers/commands/translate.rs @@ -1,17 +1,17 @@ use crate::config::Config; -use crate::util::{errors::MyError, transcription::transcription_handler}; +use crate::util::errors::MyError; use teloxide::prelude::*; use teloxide::types::{ParseMode, ReplyParameters, User}; use translators::{GoogleTranslator, Translator}; pub async fn translate_handler( bot: Bot, - msg: Message, + msg: &Message, config: &Config, arg: String, ) -> Result<(), MyError> { let text_to_translate = msg.reply_to_message().unwrap().text().unwrap(); - let user = msg.from().unwrap(); + let user = &msg.from.clone().unwrap(); if msg.reply_to_message().is_some() { let target_lang = if arg.trim().is_empty() { diff --git a/src/handlers/markups/inlines/cobalter.rs b/src/handlers/markups/inlines/cobalter.rs index 24061a6..afd180c 100644 --- a/src/handlers/markups/inlines/cobalter.rs +++ b/src/handlers/markups/inlines/cobalter.rs @@ -2,22 +2,19 @@ use crate::config::Config; use ccobalt::model::request::{DownloadRequest, VideoQuality}; use once_cell::sync::Lazy; use regex::Regex; -use std::error as error_handler; -use std::str::FromStr; use std::sync::Arc; use teloxide::Bot; -use teloxide::payloads::{AnswerInlineQuery, AnswerInlineQuerySetters, SendVideoSetters}; use teloxide::prelude::{Request, Requester}; use teloxide::types::{ InlineKeyboardButton, InlineKeyboardMarkup, InlineQuery, InlineQueryResult, - InlineQueryResultArticle, InlineQueryResultsButton, InputMessageContent, + InlineQueryResultArticle, InputMessageContent, InputMessageContentText, }; +use crate::util::errors::MyError; static URL_REGEX: Lazy = Lazy::new(|| Regex::new(r"^(https?)://[^\s/$.?#].[^\s]*$").unwrap()); -#[allow(dead_code)] fn is_url(text: &str) -> bool { URL_REGEX.is_match(text) } @@ -26,15 +23,11 @@ pub async fn is_query_url(inline_query: InlineQuery) -> bool { URL_REGEX.is_match(&inline_query.query) } -fn url_to_reqwest_url(url: String) -> reqwest::Url { - reqwest::Url::from_str(&url).unwrap() -} - pub async fn handle_cobalt_inline( bot: Bot, q: InlineQuery, config: Arc, -) -> Result<(), Box> { +) -> Result<(), MyError> { let url = &q.query; if is_url(url) { let request = DownloadRequest { @@ -46,7 +39,7 @@ pub async fn handle_cobalt_inline( let cobalt_client = config.get_cobalt_client(); match cobalt_client.download_and_save(&request, "test", ".").await { - Ok(video_file) => { + Ok(_video_file) => { let keyboard = InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::url( "🔗 Open Link".to_string(), url.parse()?, @@ -72,7 +65,6 @@ pub async fn handle_cobalt_inline( } } } else { - // Not a supported URL, optionally handle or ignore Ok(()) } } diff --git a/src/loader.rs b/src/loader.rs index b0e7647..588758c 100644 --- a/src/loader.rs +++ b/src/loader.rs @@ -22,6 +22,7 @@ use teloxide::{ types::Update, utils::command::BotCommands, }; +use crate::handlers::markups::inlines::cobalter::{handle_cobalt_inline, is_query_url}; pub fn inline_query_handler() -> Handler< 'static, @@ -29,7 +30,7 @@ pub fn inline_query_handler() -> Handler< teloxide::dispatching::DpHandlerDescription, > { dptree::entry().branch(dptree::filter_async(is_currency_query).endpoint(handle_currency_inline)) - //.branch(dptree::filter_async(is_query_url).endpoint(handle_cobalt_inline)) + .branch(dptree::filter_async(is_query_url).endpoint(handle_cobalt_inline)) } async fn run_bot(config: Arc) -> Result<(), MyError> { From 68ff6049c872388abb8d64454c8ec5a941ac4648 Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Sat, 30 Aug 2025 23:28:37 +0300 Subject: [PATCH 16/66] Implemented Whisper System and some fixes in namespaces --- src/db/redis.rs | 27 +- src/handlers/markups/callbacks/mod.rs | 2 + .../markups/{markups => callbacks}/module.rs | 0 src/handlers/markups/callbacks/whisper.rs | 88 +++++++ src/handlers/markups/inlines/mod.rs | 1 + src/handlers/markups/inlines/whisper.rs | 235 ++++++++++++++++++ src/handlers/markups/markuper.rs | 5 +- src/handlers/markups/markups/mod.rs | 1 - src/handlers/markups/mod.rs | 2 +- src/loader.rs | 5 +- 10 files changed, 354 insertions(+), 12 deletions(-) create mode 100644 src/handlers/markups/callbacks/mod.rs rename src/handlers/markups/{markups => callbacks}/module.rs (100%) create mode 100644 src/handlers/markups/callbacks/whisper.rs create mode 100644 src/handlers/markups/inlines/whisper.rs delete mode 100644 src/handlers/markups/markups/mod.rs diff --git a/src/db/redis.rs b/src/db/redis.rs index 60adeee..9f428f1 100644 --- a/src/db/redis.rs +++ b/src/db/redis.rs @@ -1,6 +1,6 @@ -use redis::{AsyncCommands, Client, FromRedisValue, RedisError, ToRedisArgs}; -use serde::Serialize; +use redis::{AsyncCommands, Client, RedisError}; use serde::de::DeserializeOwned; +use serde::Serialize; #[derive(Clone)] pub struct RedisCache { @@ -14,19 +14,30 @@ impl RedisCache { pub async fn get(&self, key: &str) -> Result, RedisError> where - T: FromRedisValue + DeserializeOwned, + T: DeserializeOwned, { let mut con = self.client.get_multiplexed_tokio_connection().await?; - let result: Option = con.get(key).await?; - Ok(result) + let result: Option = con.get(key).await?; + + match result { + Some(s) => Ok(serde_json::from_str(&s).ok()), + None => Ok(None), + } } pub async fn set(&self, key: &str, value: &T, ttl_seconds: usize) -> Result<(), RedisError> where - T: ToRedisArgs + Serialize + Sync, + T: Serialize + Sync, { let mut con = self.client.get_multiplexed_tokio_connection().await?; - let _: () = con.set_ex(key, value, ttl_seconds as u64).await?; + let json_value = serde_json::to_string(value).unwrap(); + let _: () = con.set_ex(key, json_value, ttl_seconds as u64).await?; Ok(()) } -} + + pub async fn delete(&self, key: &str) -> Result<(), RedisError> { + let mut con = self.client.get_multiplexed_tokio_connection().await?; + let _: () = con.del(key).await?; + Ok(()) + } +} \ No newline at end of file diff --git a/src/handlers/markups/callbacks/mod.rs b/src/handlers/markups/callbacks/mod.rs new file mode 100644 index 0000000..6c91bee --- /dev/null +++ b/src/handlers/markups/callbacks/mod.rs @@ -0,0 +1,2 @@ +pub mod module; +pub mod whisper; \ No newline at end of file diff --git a/src/handlers/markups/markups/module.rs b/src/handlers/markups/callbacks/module.rs similarity index 100% rename from src/handlers/markups/markups/module.rs rename to src/handlers/markups/callbacks/module.rs diff --git a/src/handlers/markups/callbacks/whisper.rs b/src/handlers/markups/callbacks/whisper.rs new file mode 100644 index 0000000..cf37009 --- /dev/null +++ b/src/handlers/markups/callbacks/whisper.rs @@ -0,0 +1,88 @@ +use super::super::inlines::whisper::Whisper; +use crate::config::Config; +use crate::util::errors::MyError; +use teloxide::payloads::{AnswerCallbackQuerySetters, EditMessageTextSetters}; +use teloxide::prelude::{CallbackQuery, Requester}; +use teloxide::types::InlineKeyboardMarkup; +use teloxide::Bot; + +pub async fn handle_whisper_callback( + bot: Bot, + q: CallbackQuery, + config: &Config, +) -> Result<(), MyError> { + let data = q.data.as_ref().ok_or("Callback query data is empty")?; + + let parts: Vec<&str> = data.split('_').collect(); + if parts.len() != 3 || parts[0] != "whisper" { + return Ok(()); + } + let action = parts[1]; + let whisper_id = parts[2]; + + let user = q.from.clone(); + let username = user.username.clone().unwrap_or_default(); + + let redis_key = format!("whisper:{}", whisper_id); + + let whisper: Option = config.get_redis_client().get(&redis_key).await?; + + let whisper = match whisper { + Some(w) => w, + None => { + bot.answer_callback_query(q.id) + .text("❌ Этот шепот истек или был забыт.") + .show_alert(true) + .await?; + return Ok(()); + } + }; + + let is_sender = user.id.0 == whisper.sender_id; + let is_recipient = whisper.recipients.iter().any(|r| { + if r.id != 0 { + r.id == user.id.0 + } else if let Some(uname) = &r.username { + uname == &username + } else { + false + } + }); + + if !is_sender && !is_recipient { + bot.answer_callback_query(q.id) + .text("🤫 Это не для тебя.") + .show_alert(true) + .await?; + return Ok(()); + } + + match action { + "read" => { + let alert_text = format!("{}", whisper.content); + bot.answer_callback_query(q.id) + .text(alert_text) + .show_alert(true) + .await?; + } + "forget" => { + config.get_redis_client().delete(&redis_key).await?; + bot.answer_callback_query(q.id) + .text("Шепот забыт.") + .await?; + + if let Some(message) = q.message { + bot.edit_message_text( + message.chat().id, + message.id(), + format!("🤫 Шепот от {} был забыт.", whisper.sender_first_name), + ) + .reply_markup(InlineKeyboardMarkup::new(vec![vec![]])) + .await?; + } + } + _ => {} + } + + Ok(()) +} \ No newline at end of file diff --git a/src/handlers/markups/inlines/mod.rs b/src/handlers/markups/inlines/mod.rs index 93a4866..1d8ffa1 100644 --- a/src/handlers/markups/inlines/mod.rs +++ b/src/handlers/markups/inlines/mod.rs @@ -1,2 +1,3 @@ pub mod currency; pub mod cobalter; +pub mod whisper; diff --git a/src/handlers/markups/inlines/whisper.rs b/src/handlers/markups/inlines/whisper.rs new file mode 100644 index 0000000..d533785 --- /dev/null +++ b/src/handlers/markups/inlines/whisper.rs @@ -0,0 +1,235 @@ +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use teloxide::payloads::AnswerInlineQuerySetters; +use teloxide::prelude::Requester; +use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup, InlineQuery, InlineQueryResult, InlineQueryResultArticle, InputMessageContent, InputMessageContentText, ParseMode}; +use teloxide::Bot; +use uuid::Uuid; + +use crate::config::Config; +use crate::util::errors::MyError; +use log::{error}; +use teloxide::utils::html; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct Recipient { + pub id: u64, + pub first_name: String, + pub username: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Whisper { + pub sender_id: u64, + pub sender_first_name: String, + pub content: String, + pub recipients: Vec, +} + +fn parse_query(query: &str) -> (String, Vec) { + let mut recipients = Vec::new(); + let mut content_end_index = query.len(); + + for part in query.split_whitespace().rev() { + if part.starts_with('@') && part.len() > 1 { + recipients.push(part.to_string()); + content_end_index = query.rfind(part).unwrap_or(query.len()); + } else if part.parse::().is_ok() { + recipients.push(part.to_string()); + content_end_index = query.rfind(part).unwrap_or(query.len()); + } else { + break; + } + } + recipients.reverse(); + + let content = query[..content_end_index].trim().to_string(); + (content, recipients) +} + +async fn update_recents( + config: &Config, + user_id: u64, + new_recipients: &[Recipient], +) -> Result<(), MyError> { + let redis_key = format!("whisper_recents:{}", user_id); + let mut recents: Vec = config + .get_redis_client() + .get(&redis_key) + .await? + .unwrap_or_default(); + + for new_recipient in new_recipients.iter().rev() { + recents.retain(|r| r.id != new_recipient.id); + recents.insert(0, new_recipient.clone()); + } + + recents.truncate(5); + + config + .get_redis_client() + .set(&redis_key, &recents, 86400 * 30) + .await?; + Ok(()) +} + +pub async fn handle_whisper_inline( + bot: Bot, + q: InlineQuery, + config: Arc, +) -> Result<(), MyError> { + if q.query.is_empty() { + let article = InlineQueryResultArticle::new( + "whisper_help", + "Как использовать шепот?", + InputMessageContent::Text(InputMessageContentText::new( + "Начните вводить сообщение, а в конце укажите получателей через @username или их Telegram ID.", + )), + ) + .description("Пример: Привет! @username 123456789"); + + bot.answer_inline_query(q.id, vec![InlineQueryResult::Article(article)]).cache_time(5).await?; + return Ok(()); + } + + let (content, recipient_identifiers) = parse_query(&q.query); + let sender = q.from.clone(); + + if content.is_empty() { + return Ok(()); + } + + if recipient_identifiers.is_empty() { + let redis_key = format!("whisper_recents:{}", q.from.id.0); + let _recents: Option> = config.get_redis_client().get(&redis_key).await?; + + let mut results = Vec::new(); + // if let Some(recents) = recents { // todo: fix this. i'm lazy + // for person in recents { + // let query_filler = person + // .username + // .as_ref() + // .map(|u| format!("@{}", u)) + // .unwrap_or_else(|| person.id.to_string()); + // + // let keyboard = InlineKeyboardMarkup::new(vec![vec![ + // InlineKeyboardButton::switch_inline_query_current_chat( + // format!("Выбрать {}", person.first_name), + // format!("{} ", query_filler), + // ), + // ]]); + // + // let article = InlineQueryResultArticle::new( + // format!("recent_{}", person.id), + // format!("✍️ Написать {}", person.first_name), + // InputMessageContent::Text(InputMessageContentText::new(format!( + // "Нажмите кнопку ниже, чтобы начать шепот для {}", + // person.first_name + // ))), + // ) + // .description("Нажмите кнопку ниже, чтобы выбрать этого пользователя") + // .reply_markup(keyboard); + // results.push(InlineQueryResult::Article(article)); + // } + // } + + let article = InlineQueryResultArticle::new( + "whisper_no_recipients", + "Кому шептать?", + InputMessageContent::Text(InputMessageContentText::new( + "Укажите получателей, добавив их юзернеймы (@username) или ID в конце сообщения.", + )), + ) + .description("Вы не указали получателя."); + results.push(InlineQueryResult::Article(article)); + + bot.answer_inline_query(q.id, results) + .cache_time(10) + .await?; + return Ok(()); + } + + let mut recipients: Vec = Vec::new(); + for identifier in &recipient_identifiers { + if identifier.starts_with('@') { + recipients.push(Recipient { + id: 0, + first_name: identifier[1..].to_string(), + username: Some(identifier[1..].to_string()), + }); + } else if let Ok(id) = identifier.parse::() { + recipients.push(Recipient { + id, + first_name: format!("{}", id), + username: None, + }); + } + } + + recipients.push(Recipient { + id: sender.id.0, + first_name: sender.first_name.clone(), + username: sender.username.clone(), + }); + + let recipients_for_recents: Vec = recipients + .iter() + .filter(|r| r.id != sender.id.0) + .cloned() + .collect(); + + if !recipients_for_recents.is_empty() { + if let Err(e) = update_recents(&config, sender.id.0, &recipients_for_recents).await { + error!("Failed to update recent contacts: {:?}", e); + } + } + + let whisper_id = Uuid::new_v4().to_string(); + let whisper = Whisper { + sender_id: sender.id.0, + sender_first_name: sender.first_name.clone(), + content: content.clone(), + recipients, + }; + + let redis_key = format!("whisper:{}", whisper_id); + config.get_redis_client().set(&redis_key, &whisper, 86400).await?; + + let keyboard = InlineKeyboardMarkup::new(vec![vec![ + InlineKeyboardButton::callback("👁️ Прочитать", format!("whisper_read_{}", whisper_id)), + InlineKeyboardButton::callback("🗑️ Забыть", format!("whisper_forget_{}", whisper_id)), + ]]); + + let recipients_str = whisper.recipients + .iter() + .filter(|r| r.id != sender.id.0) + .map(|r| format!("{}", r.id, html::escape(&r.first_name))) + .collect::>() + .join(", "); + + let message_text = format!("🤫 {} шепчет для {}", whisper.sender_first_name, recipients_str); + + let article = InlineQueryResultArticle::new( + whisper_id, + "Нажмите, чтобы отправить шепот", + InputMessageContent::Text( + InputMessageContentText::new(message_text).parse_mode(ParseMode::Html), + ), + ) + .description(format!("Сообщение: {}", content)) + .reply_markup(keyboard); + + if let Err(e) = bot + .answer_inline_query(q.id, vec![InlineQueryResult::Article(article)]) + .cache_time(0) + .await + { + error!("Failed to answer whisper inline query: {:?}", e); + } + + Ok(()) +} + +pub async fn is_whisper_query(_q: InlineQuery) -> bool { + true +} \ No newline at end of file diff --git a/src/handlers/markups/markuper.rs b/src/handlers/markups/markuper.rs index 58b69ef7..64f3590 100644 --- a/src/handlers/markups/markuper.rs +++ b/src/handlers/markups/markuper.rs @@ -6,7 +6,8 @@ use crate::util::transcription::{ use teloxide::Bot; use teloxide::requests::Requester; use teloxide::types::CallbackQuery; -use crate::handlers::markups::markups::module::{module_option_handler, module_select_handler, module_toggle_handler, settings_back_handler}; +use crate::handlers::markups::callbacks::module::{module_option_handler, module_select_handler, module_toggle_handler, settings_back_handler}; +use crate::handlers::markups::callbacks::whisper::handle_whisper_callback; pub(crate) async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { let _config = Config::new().await; @@ -29,6 +30,8 @@ pub(crate) async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Resul module_option_handler(bot, qq).await? } else if data.starts_with("settings_back:") { settings_back_handler(bot, qq).await? + } else if data.starts_with("whisper") { + handle_whisper_callback(bot, qq, &_config).await? } else { bot.answer_callback_query(qq.id).await.unwrap(); } diff --git a/src/handlers/markups/markups/mod.rs b/src/handlers/markups/markups/mod.rs deleted file mode 100644 index a94a210..0000000 --- a/src/handlers/markups/markups/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod module; \ No newline at end of file diff --git a/src/handlers/markups/mod.rs b/src/handlers/markups/mod.rs index 1db0329..438e78f 100644 --- a/src/handlers/markups/mod.rs +++ b/src/handlers/markups/mod.rs @@ -1,3 +1,3 @@ pub mod inlines; pub mod markuper; -mod markups; +mod callbacks; diff --git a/src/loader.rs b/src/loader.rs index 588758c..4b06c37 100644 --- a/src/loader.rs +++ b/src/loader.rs @@ -23,14 +23,17 @@ use teloxide::{ utils::command::BotCommands, }; use crate::handlers::markups::inlines::cobalter::{handle_cobalt_inline, is_query_url}; +use crate::handlers::markups::inlines::whisper::{handle_whisper_inline, is_whisper_query}; pub fn inline_query_handler() -> Handler< 'static, Result<(), MyError>, teloxide::dispatching::DpHandlerDescription, > { - dptree::entry().branch(dptree::filter_async(is_currency_query).endpoint(handle_currency_inline)) + dptree::entry() + .branch(dptree::filter_async(is_currency_query).endpoint(handle_currency_inline)) .branch(dptree::filter_async(is_query_url).endpoint(handle_cobalt_inline)) + .branch(dptree::filter_async(is_whisper_query).endpoint(handle_whisper_inline)) } async fn run_bot(config: Arc) -> Result<(), MyError> { From 375469187c31fd1c2581ae8a256a9ae02c14b42b Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Sat, 30 Aug 2025 23:30:17 +0300 Subject: [PATCH 17/66] Updated license --- LICENSE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/LICENSE b/LICENSE index 55504f4..0423517 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2012-2025 Weever (Weever1337) +Copyright (c) 2012-2025 Fulturate Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the From 1a7c4a41ca4d9e403f10fe2ef1535221059fc7b4 Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Sun, 31 Aug 2025 01:23:06 +0300 Subject: [PATCH 18/66] Finished translate system --- src/handlers/commands/translate.rs | 202 ++++++++++++++++---- src/handlers/markups/callbacks/mod.rs | 3 +- src/handlers/markups/callbacks/translate.rs | 128 +++++++++++++ src/handlers/markups/markuper.rs | 3 + 4 files changed, 295 insertions(+), 41 deletions(-) create mode 100644 src/handlers/markups/callbacks/translate.rs diff --git a/src/handlers/commands/translate.rs b/src/handlers/commands/translate.rs index feb28f7..db37e25 100644 --- a/src/handlers/commands/translate.rs +++ b/src/handlers/commands/translate.rs @@ -1,65 +1,187 @@ use crate::config::Config; use crate::util::errors::MyError; use teloxide::prelude::*; -use teloxide::types::{ParseMode, ReplyParameters, User}; +use teloxide::types::{ + InlineKeyboardButton, InlineKeyboardMarkup, Message, ParseMode, ReplyParameters, +}; use translators::{GoogleTranslator, Translator}; +pub const SUPPORTED_LANGUAGES: &[(&str, &str)] = &[ + ("uk", "🇺🇦 Українська"), + ("en", "🇬🇧 English"), + ("us", "🇺🇸 English (US)"), + ("ru", "🇷🇺 Русский"), + ("de", "🇩🇪 Deutsch"), + ("fr", "🇫🇷 Français"), + ("es", "🇪🇸 Español"), + ("it", "🇮🇹 Italiano"), + ("zh", "🇨🇳 中文"), + ("ja", "🇯🇵 日本語"), + ("ko", "🇰🇷 한국어"), + ("pl", "🇵🇱 Polski"), + ("ar", "🇸🇦 العربية"), + ("pt", "🇵🇹 Português"), + ("tr", "🇹🇷 Türkçe"), + ("nl", "🇳🇱 Nederlands"), + ("sv", "🇸🇪 Svenska"), + ("no", "🇳🇴 Norsk"), + ("da", "🇩🇰 Dansk"), + ("fi", "🇫🇮 Suomi"), + ("el", "🇬🇷 Ελληνικά"), + ("he", "🇮🇱 עברית"), + ("hi", "🇮🇳 हिन्दी"), + ("id", "🇮🇩 Indonesia"), + ("vi", "🇻🇳 Tiếng Việt"), + ("th", "🇹🇭 ภาษาไทย"), + ("cs", "🇨🇿 Čeština"), + ("hu", "🇭🇺 Magyar"), + ("ro", "🇷🇴 Română"), + ("bg", "🇧🇬 Български"), + ("sr", "🇷🇸 Српски"), + ("hr", "🇭🇷 Hrvatski"), + ("sk", "🇸🇰 Slovenčina"), + ("sl", "🇸🇮 Slovenščina"), + ("lt", "🇱🇹 Lietuvių"), + ("lv", "🇱🇻 Latviešu"), + ("et", "🇪🇪 Eesti"), +]; +pub const LANGUAGES_PER_PAGE: usize = 6; + pub async fn translate_handler( bot: Bot, msg: &Message, config: &Config, arg: String, ) -> Result<(), MyError> { - let text_to_translate = msg.reply_to_message().unwrap().text().unwrap(); - let user = &msg.from.clone().unwrap(); + let replied_to_message = match msg.reply_to_message() { + Some(message) => message, + None => { + bot.send_message( + msg.chat.id, + "Нужно ответить на то сообщение, которое требуется перевести, чтобы использовать эту команду.", + ) + .reply_parameters(ReplyParameters::new(msg.id)) + .parse_mode(ParseMode::Html) + .await?; + return Ok(()); + } + }; - if msg.reply_to_message().is_some() { - let target_lang = if arg.trim().is_empty() { - get_user_target_language(config, user).await? - } else { - normalize_language_code(&arg.trim()) - }; - - // let translated_text = translate_text( - // text_to_translate, - // &target_lang, - // translation_method, - // config - // ).await?; - - let google_trans = GoogleTranslator::default(); - let res = google_trans - .translate_async(text_to_translate, "", &*target_lang) - .await - .unwrap(); - - let response = format!( - "🌐 Перевод на {}:\n
{}\n
", - target_lang.to_uppercase(), - res, - ); - - bot.send_message(msg.chat.id, response) - .parse_mode(ParseMode::Html) - .await?; - } else { - bot.send_message(msg.chat.id, "Нужно ответить на то сообщение, которое требуется перевести, чтобы использовать эту команду.") + let text_to_translate = match replied_to_message.text() { + Some(text) => text, + None => { + bot.send_message(msg.chat.id, "Отвечать нужно на сообщение с текстом.") + .reply_parameters(ReplyParameters::new(msg.id)) + .parse_mode(ParseMode::Html) + .await?; + return Ok(()); + } + }; + + let user = msg.from.clone().unwrap(); + if user.is_bot { + bot.send_message(msg.chat.id, "Отвечать нужно на сообщение от пользователя.") .reply_parameters(ReplyParameters::new(msg.id)) .parse_mode(ParseMode::Html) .await?; + return Ok(()); } + + let target_lang: String; + + if !arg.trim().is_empty() { + target_lang = normalize_language_code(&arg.trim()); + } else { + let redis_key = format!("user_lang:{}", user.id); + let redis_client = config.get_redis_client(); + let cached_lang: Option = redis_client.get(&redis_key).await?; + + if let Some(lang) = cached_lang { + target_lang = lang; + } else { + let keyboard = create_language_keyboard(0); + bot.send_message(msg.chat.id, "Выберите язык для перевода:") + .reply_markup(keyboard) + .reply_parameters(ReplyParameters::new(replied_to_message.id)) + .await?; + return Ok(()); + } + } + + let google_trans = GoogleTranslator::default(); + let res = google_trans + .translate_async(text_to_translate, "", &*target_lang) + .await + .unwrap(); + + let response = format!( + "
{}\n
", + res, + ); + + let lang_display_name = SUPPORTED_LANGUAGES + .iter() + .find(|(code, _)| *code == target_lang) + .map(|(_, name)| *name) + .unwrap_or(&target_lang); + + let keyboard = InlineKeyboardMarkup::new(vec![vec![ + InlineKeyboardButton::callback(lang_display_name.to_string(), "tr_show_langs".to_string()), + ]]); + + + bot.send_message(msg.chat.id, response) + .reply_parameters(ReplyParameters::new(replied_to_message.id)) + .parse_mode(ParseMode::Html) + .reply_markup(keyboard) + .await?; + Ok(()) } -async fn get_user_target_language(_config: &Config, user: &User) -> Result { - let lang_code = user.language_code.as_ref() // todo: add user settings - .unwrap_or(&"en".to_string()) - .clone(); +pub fn create_language_keyboard(page: usize) -> InlineKeyboardMarkup { + let mut keyboard: Vec> = Vec::new(); + let start = page * LANGUAGES_PER_PAGE; + let end = std::cmp::min(start + LANGUAGES_PER_PAGE, SUPPORTED_LANGUAGES.len()); + + if start >= end { + return InlineKeyboardMarkup::new(keyboard); + } + + let page_languages = &SUPPORTED_LANGUAGES[start..end]; + + for chunk in page_languages.chunks(2) { + let row = chunk + .iter() + .map(|(code, name)| { + InlineKeyboardButton::callback(name.to_string(), format!("tr_lang:{}", code)) + }) + .collect(); + keyboard.push(row); + } + + let mut nav_row: Vec = Vec::new(); + if page > 0 { + nav_row.push(InlineKeyboardButton::callback( + "⬅️".to_string(), + format!("tr_page:{}", page - 1), + )); + } + if end < SUPPORTED_LANGUAGES.len() { + nav_row.push(InlineKeyboardButton::callback( + "➡️".to_string(), + format!("tr_page:{}", page + 1), + )); + } + + if !nav_row.is_empty() { + keyboard.push(nav_row); + } - Ok(normalize_language_code(&lang_code)) + InlineKeyboardMarkup::new(keyboard) } -fn normalize_language_code(lang: &str) -> String { +pub fn normalize_language_code(lang: &str) -> String { match lang.to_lowercase().as_str() { "ua" | "ukrainian" | "украинский" | "uk" => "uk".to_string(), "ru" | "russian" | "русский" => "ru".to_string(), diff --git a/src/handlers/markups/callbacks/mod.rs b/src/handlers/markups/callbacks/mod.rs index 6c91bee..ad3c854 100644 --- a/src/handlers/markups/callbacks/mod.rs +++ b/src/handlers/markups/callbacks/mod.rs @@ -1,2 +1,3 @@ pub mod module; -pub mod whisper; \ No newline at end of file +pub mod whisper; +pub mod translate; \ No newline at end of file diff --git a/src/handlers/markups/callbacks/translate.rs b/src/handlers/markups/callbacks/translate.rs new file mode 100644 index 0000000..1de3cfe --- /dev/null +++ b/src/handlers/markups/callbacks/translate.rs @@ -0,0 +1,128 @@ +use teloxide::Bot; +use teloxide::payloads::{EditMessageReplyMarkupSetters, EditMessageTextSetters}; +use teloxide::prelude::Requester; +use teloxide::types::{CallbackQuery, InlineKeyboardButton, MaybeInaccessibleMessage, Message, ParseMode}; +use crate::config::Config; +use crate::util::errors::MyError; +use translators::{GoogleTranslator, Translator}; +use crate::handlers::commands::translate::{create_language_keyboard, normalize_language_code, SUPPORTED_LANGUAGES}; +use crate::util::inline::delete_message_button; + +pub async fn handle_translate_callback( + bot: Bot, + q: CallbackQuery, + config: &Config, +) -> Result<(), MyError> { + let callback_id = q.id.clone(); + + if let (Some(data), Some(MaybeInaccessibleMessage::Regular(message))) = (&q.data, &q.message) { + bot.answer_callback_query(callback_id).await?; + + if data.starts_with("tr_page:") { + handle_pagination(bot, message, data).await?; + } else if data.starts_with("tr_lang:") { + handle_language_selection(bot, message, data, q.from.clone(), config).await?; + } else if data == "tr_show_langs" { + handle_show_languages(bot, message).await?; + } + } else { + bot.answer_callback_query(callback_id).await?; + } + Ok(()) +} + +async fn handle_pagination(bot: Bot, message: &Message, data: &str) -> Result<(), MyError> { + match data.trim_start_matches("tr_page:").parse::() { + Ok(page) => { + let keyboard = create_language_keyboard(page); + bot.edit_message_reply_markup(message.chat.id, message.id) + .reply_markup(keyboard) + .await?; + Ok(()) + } + Err(_) => { + log::warn!("Failed to parse page number from: {}", data); + Ok(()) + } + } +} + +async fn handle_language_selection( + bot: Bot, + message: &Message, + data: &str, + user: teloxide::types::User, + config: &Config, +) -> Result<(), MyError> { + let target_lang = data.trim_start_matches("tr_lang:"); + + let original_message = match message.reply_to_message() { + Some(msg) => msg, + None => { + bot.edit_message_text(message.chat.id, message.id, "Ошибка: не удалось найти исходное сообщение. Попробуйте снова.") + .await?; + return Ok(()); + } + }; + + let text_to_translate = match original_message.text() { + Some(text) => text, + None => { + bot.edit_message_text(message.chat.id, message.id, "В исходном сообщении нет текста для перевода.") + .await?; + return Ok(()); + } + }; + + let redis_key = format!("user_lang:{}", user.id); + let redis_client = config.get_redis_client(); + let ttl_seconds = 2 * 60 * 60; // 2 hours + redis_client + .set(&redis_key, &target_lang.to_string(), ttl_seconds) + .await?; + + let normalized_lang = normalize_language_code(target_lang); + let google_trans = GoogleTranslator::default(); + let res = google_trans + .translate_async(text_to_translate, "", &*normalized_lang) + .await + .unwrap(); + + let response = format!( + "
{}\n
", + res, + ); + + let lang_display_name = SUPPORTED_LANGUAGES + .iter() + .find(|(code, _)| *code == normalized_lang) + .map(|(_, name)| *name) + .unwrap_or(&normalized_lang); + + let switch_lang_button = InlineKeyboardButton::callback( + lang_display_name.to_string(), + "tr_show_langs".to_string(), + ); + + let mut keyboard = delete_message_button(user.id.0); + if let Some(first_row) = keyboard.inline_keyboard.get_mut(0) { + first_row.insert(0, switch_lang_button); + } else { + keyboard.inline_keyboard.push(vec![switch_lang_button]); + } + + bot.edit_message_text(message.chat.id, message.id, response) + .parse_mode(ParseMode::Html) + .reply_markup(keyboard) + .await?; + + Ok(()) +} + +async fn handle_show_languages(bot: Bot, message: &Message) -> Result<(), MyError> { + let keyboard = create_language_keyboard(0); + bot.edit_message_text(message.chat.id, message.id, "Выберите язык для перевода:") + .reply_markup(keyboard) + .await?; + Ok(()) +} \ No newline at end of file diff --git a/src/handlers/markups/markuper.rs b/src/handlers/markups/markuper.rs index 64f3590..d5ae07a 100644 --- a/src/handlers/markups/markuper.rs +++ b/src/handlers/markups/markuper.rs @@ -7,6 +7,7 @@ use teloxide::Bot; use teloxide::requests::Requester; use teloxide::types::CallbackQuery; use crate::handlers::markups::callbacks::module::{module_option_handler, module_select_handler, module_toggle_handler, settings_back_handler}; +use crate::handlers::markups::callbacks::translate::handle_translate_callback; use crate::handlers::markups::callbacks::whisper::handle_whisper_callback; pub(crate) async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { @@ -32,6 +33,8 @@ pub(crate) async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Resul settings_back_handler(bot, qq).await? } else if data.starts_with("whisper") { handle_whisper_callback(bot, qq, &_config).await? + } else if data.starts_with("tr_") { + handle_translate_callback(bot, qq, &_config).await? } else { bot.answer_callback_query(qq.id).await.unwrap(); } From 309df92ecd27be5387d416caedfc2b7123dafb45 Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Sun, 31 Aug 2025 18:44:04 +0300 Subject: [PATCH 19/66] Separate delete button from transcription; Added universal id `72` for allow delete message for all --- src/handlers/commands/translate.rs | 17 +++++-- src/handlers/markups/callbacks/delete.rs | 63 ++++++++++++++++++++++++ src/handlers/markups/callbacks/mod.rs | 3 +- src/handlers/markups/markuper.rs | 5 +- src/util/transcription.rs | 55 --------------------- 5 files changed, 81 insertions(+), 62 deletions(-) create mode 100644 src/handlers/markups/callbacks/delete.rs diff --git a/src/handlers/commands/translate.rs b/src/handlers/commands/translate.rs index db37e25..19c98f0 100644 --- a/src/handlers/commands/translate.rs +++ b/src/handlers/commands/translate.rs @@ -1,10 +1,12 @@ use crate::config::Config; use crate::util::errors::MyError; use teloxide::prelude::*; +use teloxide::repl; use teloxide::types::{ InlineKeyboardButton, InlineKeyboardMarkup, Message, ParseMode, ReplyParameters, }; use translators::{GoogleTranslator, Translator}; +use crate::util::inline::delete_message_button; pub const SUPPORTED_LANGUAGES: &[(&str, &str)] = &[ ("uk", "🇺🇦 Українська"), @@ -79,7 +81,7 @@ pub async fn translate_handler( }; let user = msg.from.clone().unwrap(); - if user.is_bot { + if replied_to_message.clone().from.unwrap().is_bot { bot.send_message(msg.chat.id, "Отвечать нужно на сообщение от пользователя.") .reply_parameters(ReplyParameters::new(msg.id)) .parse_mode(ParseMode::Html) @@ -125,10 +127,17 @@ pub async fn translate_handler( .map(|(_, name)| *name) .unwrap_or(&target_lang); - let keyboard = InlineKeyboardMarkup::new(vec![vec![ - InlineKeyboardButton::callback(lang_display_name.to_string(), "tr_show_langs".to_string()), - ]]); + let switch_lang_button = InlineKeyboardButton::callback( + lang_display_name.to_string(), + "tr_show_langs".to_string(), + ); + let mut keyboard = delete_message_button(user.id.0); + if let Some(first_row) = keyboard.inline_keyboard.get_mut(0) { + first_row.insert(0, switch_lang_button); + } else { + keyboard.inline_keyboard.push(vec![switch_lang_button]); + } bot.send_message(msg.chat.id, response) .reply_parameters(ReplyParameters::new(replied_to_message.id)) diff --git a/src/handlers/markups/callbacks/delete.rs b/src/handlers/markups/callbacks/delete.rs new file mode 100644 index 0000000..d2a76c1 --- /dev/null +++ b/src/handlers/markups/callbacks/delete.rs @@ -0,0 +1,63 @@ +use log::error; +use teloxide::Bot; +use teloxide::payloads::AnswerCallbackQuerySetters; +use teloxide::prelude::{CallbackQuery, Requester}; +use crate::util::errors::MyError; + +pub async fn delete_message_handler(bot: Bot, query: CallbackQuery) -> Result<(), MyError> { + let Some(message_to_delete) = query.message else { + return Ok(()); + }; + + let Some(message) = message_to_delete.regular_message() else { + return Ok(()); + }; + + let Some(data) = query.data else { + return Ok(()); + }; + + let clicker = query.from; + + let target_user_id_str = data.strip_prefix("delete_").unwrap_or_default(); + let Ok(target_user_id) = target_user_id_str.parse::() else { + bot.answer_callback_query(query.id) + .text("❌ Ошибка: неверный ID в кнопке.") + .show_alert(true) + .await?; + return Ok(()); + }; + + let mut has_permission = false; + + if target_user_id == 72 { + has_permission = true; + } else if clicker.id.0 == target_user_id { + has_permission = true; + } + + if !has_permission && message.chat.is_group() || message.chat.is_supergroup() { + if let Ok(member) = bot.get_chat_member(message.chat.id, clicker.id).await { + if member.is_privileged() { + has_permission = true; + } + } + } + + if !has_permission { + bot.answer_callback_query(query.id) + .text("❌ Удалить может только автор сообщения или администратор.") + .show_alert(true) + .await?; + return Ok(()); + } + + bot.answer_callback_query(query.id).await?; + + bot.delete_message(message.chat.id, message.id) + .await + .map_err(|e| error!("Failed to delete bot's message: {:?}", e)) + .ok(); + + Ok(()) +} \ No newline at end of file diff --git a/src/handlers/markups/callbacks/mod.rs b/src/handlers/markups/callbacks/mod.rs index ad3c854..05f7898 100644 --- a/src/handlers/markups/callbacks/mod.rs +++ b/src/handlers/markups/callbacks/mod.rs @@ -1,3 +1,4 @@ pub mod module; pub mod whisper; -pub mod translate; \ No newline at end of file +pub mod translate; +pub mod delete; \ No newline at end of file diff --git a/src/handlers/markups/markuper.rs b/src/handlers/markups/markuper.rs index d5ae07a..2d3af08 100644 --- a/src/handlers/markups/markuper.rs +++ b/src/handlers/markups/markuper.rs @@ -1,11 +1,12 @@ use crate::config::Config; use crate::util::errors::MyError; use crate::util::transcription::{ - back_handler, delete_transcription_handler, summarization_handler, + back_handler, summarization_handler, }; use teloxide::Bot; use teloxide::requests::Requester; use teloxide::types::CallbackQuery; +use crate::handlers::markups::callbacks::delete::delete_message_handler; use crate::handlers::markups::callbacks::module::{module_option_handler, module_select_handler, module_toggle_handler, settings_back_handler}; use crate::handlers::markups::callbacks::translate::handle_translate_callback; use crate::handlers::markups::callbacks::whisper::handle_whisper_callback; @@ -18,7 +19,7 @@ pub(crate) async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Resul let data = qq.data.clone().unwrap(); if data.starts_with("delete_") { - delete_transcription_handler(bot, qq).await? + delete_message_handler(bot, qq).await? } else if data.starts_with("summarize") { summarization_handler(bot, qq, &_config).await? } else if data.starts_with("back_to_full") { diff --git a/src/util/transcription.rs b/src/util/transcription.rs index 4c03d5c..38fcaa4 100644 --- a/src/util/transcription.rs +++ b/src/util/transcription.rs @@ -479,58 +479,3 @@ fn split_text(text: String, chunk_size: usize) -> Vec { .map(|chunk| chunk.iter().collect()) .collect() } - -pub async fn delete_transcription_handler(bot: Bot, query: CallbackQuery) -> Result<(), MyError> { - let Some(message_to_delete) = query.message else { - return Ok(()); - }; - - let Some(message) = message_to_delete.regular_message() else { - return Ok(()); - }; - - let Some(data) = query.data else { - return Ok(()); - }; - let clicker = query.from; - - let target_user_id_str = data.strip_prefix("delete_").unwrap_or_default(); - let Ok(target_user_id) = target_user_id_str.parse::() else { - bot.answer_callback_query(query.id) - .text("❌ Ошибка: неверный ID в кнопке.") - .show_alert(true) - .await?; - return Ok(()); - }; - - let mut has_permission = false; - - if clicker.id.0 == target_user_id { - has_permission = true; - } - - if !has_permission && message.chat.is_group() || message.chat.is_supergroup() { - if let Ok(member) = bot.get_chat_member(message.chat.id, clicker.id).await { - if member.is_privileged() { - has_permission = true; - } - } - } - - if !has_permission { - bot.answer_callback_query(query.id) - .text("❌ Удалить может только автор сообщения или администратор.") - .show_alert(true) - .await?; - return Ok(()); - } - - bot.answer_callback_query(query.id).await?; - - bot.delete_message(message.chat.id, message.id) - .await - .map_err(|e| error!("Failed to delete bot's message: {:?}", e)) - .ok(); - - Ok(()) -} From 48d49b1caace7e983ecbae104d34a404c29e6369 Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Sun, 31 Aug 2025 19:00:21 +0300 Subject: [PATCH 20/66] Added error handler for catching it and fix --- .env.example | 1 + src/config.rs | 8 ++++ src/loader.rs | 126 ++++++++++++++++++++++++++++++++++++++++++-------- 3 files changed, 115 insertions(+), 20 deletions(-) diff --git a/.env.example b/.env.example index 8f43935..05220f4 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ BOT_TOKEN= GEMINI_API_KEY= COBALT_API_KEY= OWNERS=701733705,6452350296 +LOG_CHAT_ID= MONGODB_URL=mongodb://localhost:27017 RUST_LOG=info REDIS_URL=redis://127.0.0.1:6379 \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index ea7c809..5414a22 100644 --- a/src/config.rs +++ b/src/config.rs @@ -12,6 +12,7 @@ pub struct Config { cobalt_client: ccobalt::Client, #[allow(dead_code)] owners: Vec, + log_chat_id: String, version: String, json_config: JsonConfig, currency_converter: Arc, @@ -40,6 +41,8 @@ impl Config { .filter_map(|id| id.trim().parse().ok()) .collect(); + let log_chat_id = std::env::var("LOG_CHAT_ID").expect("LOG_CHAT_ID expected"); + let json_config = read_json_config("config.json").expect("Unable to read config.json"); let currency_converter = Arc::new(CurrencyConverter::new(OutputLanguage::Russian).unwrap()); // TODO: get language from config let mongodb_url = std::env::var("MONGODB_URL").expect("MONGODB_URL expected"); @@ -53,6 +56,7 @@ impl Config { bot, cobalt_client, owners, + log_chat_id, version, json_config, currency_converter, @@ -78,6 +82,10 @@ impl Config { self.owners.contains(&id) } + pub fn get_log_chat_id(&self) -> &str { + &self.log_chat_id + } + pub fn get_json_config(&self) -> &JsonConfig { &self.json_config } diff --git a/src/loader.rs b/src/loader.rs index 4b06c37..82ad314 100644 --- a/src/loader.rs +++ b/src/loader.rs @@ -1,6 +1,9 @@ +use crate::handlers::markups::inlines::cobalter::{handle_cobalt_inline, is_query_url}; use crate::handlers::markups::inlines::currency::handle_currency_inline; +use crate::handlers::markups::inlines::whisper::{handle_whisper_inline, is_whisper_query}; use crate::handlers::messages::messager::{handle_currency, handle_speech}; use crate::util::currency::converter::is_currency_query; +use crate::util::inline::delete_message_button; use crate::{ config::Config, handlers::{ @@ -11,24 +14,48 @@ use crate::{ }; use log::info; use oximod::set_global_client; +use std::convert::Infallible; +use std::fmt::Write; +use std::ops::ControlFlow; use std::sync::Arc; use teloxide::dispatching::MessageFilterExt; -use teloxide::prelude::{Handler, LoggingErrorHandler, Message}; +use teloxide::error_handlers::LoggingErrorHandler; +use teloxide::payloads::SendDocumentSetters; +use teloxide::prelude::{ChatId, Handler, Message, Requester}; +use teloxide::types::{Chat, InputFile, ParseMode, User}; use teloxide::update_listeners::Polling; +use teloxide::utils::html; use teloxide::{ - dispatching::{Dispatcher, HandlerExt, UpdateFilterExt}, + dispatching::{Dispatcher, DpHandlerDescription, HandlerExt, UpdateFilterExt}, dptree, - prelude::Requester, - types::Update, + types::{Me, Update}, utils::command::BotCommands, + Bot, }; -use crate::handlers::markups::inlines::cobalter::{handle_cobalt_inline, is_query_url}; -use crate::handlers::markups::inlines::whisper::{handle_whisper_inline, is_whisper_query}; + +async fn root_handler( + update: Update, + config: Arc, + bot: Bot, + logic: Arc, DpHandlerDescription>>, + me: Me, +) -> Result<(), Infallible> { + let deps = dptree::deps![update.clone(), config.clone(), bot.clone(), me.clone()]; + let result = logic.dispatch(deps).await; + + if let ControlFlow::Break(Err(err)) = result { + let error_handler_endpoint: Handler<'static, (), DpHandlerDescription> = dptree::endpoint(handle_error); + let error_deps = dptree::deps![Arc::new(err), update, config, bot]; + let _ = error_handler_endpoint.dispatch(error_deps).await; + } + + Ok(()) +} pub fn inline_query_handler() -> Handler< 'static, Result<(), MyError>, - teloxide::dispatching::DpHandlerDescription, + DpHandlerDescription, > { dptree::entry() .branch(dptree::filter_async(is_currency_query).endpoint(handle_currency_inline)) @@ -38,12 +65,10 @@ pub fn inline_query_handler() -> Handler< async fn run_bot(config: Arc) -> Result<(), MyError> { let command_menu = Command::bot_commands(); - config - .get_bot() - .set_my_commands(command_menu.clone()) - .await?; + let bot = config.get_bot(); + bot.set_my_commands(command_menu.clone()).await?; - let handlers = dptree::entry() + let logic_handlers = dptree::entry() .branch( Update::filter_message() .filter_command::() @@ -58,33 +83,94 @@ async fn run_bot(config: Arc) -> Result<(), MyError> { .branch(Update::filter_my_chat_member().endpoint(handle_bot_added)) .branch(Update::filter_inline_query().branch(inline_query_handler())); - let bot = config.get_bot().clone(); - let bot_name = config.get_bot().get_my_name().await?; + let me = bot.get_me().await?; + info!("Bot name: {:?}", me.username()); - info!("Bot name: {:?}", bot_name.name); let listener = Polling::builder(bot.clone()).drop_pending_updates().build(); - Dispatcher::builder(bot.clone(), handlers) - .dependencies(dptree::deps![config.clone()]) + Dispatcher::builder(bot.clone(), dptree::endpoint(root_handler)) + .dependencies(dptree::deps![config.clone(), Arc::new(logic_handlers), me]) .enable_ctrlc_handler() .build() .dispatch_with_listener(listener, LoggingErrorHandler::new()) .await; + Ok(()) } async fn run_database(config: Arc) -> Result<(), MyError> { let url = config.get_mongodb_url().to_owned(); set_global_client(url.clone()).await?; - info!("Database connected successfully. URL: {}", url); - Ok(()) } pub async fn run() -> Result<(), MyError> { let config = Arc::new(Config::new().await); - let _th = tokio::join!(run_database(config.clone()), run_bot(config.clone())); Ok(()) } + +fn extract_info(update: &Update) -> (Option<&User>, Option<&Chat>) { + match &update.kind { + teloxide::types::UpdateKind::Message(m) => (m.from.as_ref(), Some(&m.chat)), + teloxide::types::UpdateKind::CallbackQuery(q) => { + (Some(&q.from), q.message.as_ref().map(|m| m.chat())) + } + teloxide::types::UpdateKind::InlineQuery(q) => (Some(&q.from), None), + teloxide::types::UpdateKind::MyChatMember(m) => (Some(&m.from), Some(&m.chat)), + _ => (None, None), + } +} + +fn short_error_name(error: &MyError) -> String { + format!("{}", error) +} + +pub async fn handle_error(err: Arc, update: Update, config: Arc, bot: Bot) { + log::error!("An error has occurred: {:?}", err); // ahh fuck + + let (user, chat) = extract_info(&update); + let mut message_text = String::new(); + + writeln!(&mut message_text, "🚨 Новая ошибка!\n").unwrap(); + + if let Some(chat) = chat { + let title = chat.title().map_or("".to_string(), |t| format!(" ({})", html::escape(t))); + writeln!(&mut message_text, "В чате: {}{}", chat.id, title).unwrap(); + } else { + writeln!(&mut message_text, "В чате: (???)").unwrap(); + } + + if let Some(user) = user { + let username = user.username.as_ref().map_or("".to_string(), |u| format!(" (@{})", u)); + let full_name = html::escape(&user.full_name()); + writeln!(&mut message_text, "Вызвал: {} ({}){}", full_name, user.id, username).unwrap(); + } else { + writeln!(&mut message_text, "Вызвал: (???)").unwrap(); + } + + let error_name = short_error_name(&err); + writeln!(&mut message_text, "\nОшибка:\n
{}
", html::escape(&error_name)).unwrap(); + + let hashtag = "#error"; + writeln!(&mut message_text, "\n{}", hashtag).unwrap(); + + let full_error_text = format!("{:#?}", err); + let document = InputFile::memory(full_error_text.into_bytes()).file_name("error_details.txt"); + + if let Ok(log_chat_id) = config.get_log_chat_id().parse::() { + let chat_id = ChatId(log_chat_id); + + match bot.send_document(chat_id, document) + .caption(message_text) + .parse_mode(ParseMode::Html) + .reply_markup(delete_message_button(72)) + .await { + Ok(_) => info!("Error report sent successfully to chat {}", log_chat_id), + Err(e) => log::error!("Failed to send error report to chat {}: {}", log_chat_id, e), + } + } else { + log::error!("LOG_CHAT_ID is not a valid integer: {}", config.get_log_chat_id()); + } +} \ No newline at end of file From 4469c103c05d3e840a1396832ff4546b192b7010 Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Sun, 31 Aug 2025 19:00:48 +0300 Subject: [PATCH 21/66] Reformat imports in transcription.rs --- src/util/transcription.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/util/transcription.rs b/src/util/transcription.rs index 38fcaa4..4bf2d97 100644 --- a/src/util/transcription.rs +++ b/src/util/transcription.rs @@ -7,13 +7,13 @@ use log::{debug, error, info}; use redis_macros::{FromRedisValue, ToRedisArgs}; use serde::{Deserialize, Serialize}; use std::time::Duration; -use teloxide::Bot; -use teloxide::payloads::{AnswerCallbackQuerySetters, EditMessageTextSetters, SendMessageSetters}; +use teloxide::payloads::{EditMessageTextSetters, SendMessageSetters}; use teloxide::requests::{Request as TeloxideRequest, Requester}; use teloxide::types::{ CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message, MessageKind, ParseMode, ReplyParameters, }; +use teloxide::Bot; #[derive(Debug, Serialize, Deserialize, FromRedisValue, ToRedisArgs, Clone)] struct TranscriptionCache { From 54ec68d20c60cf874b53d5584b27bf85102071b8 Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Sun, 31 Aug 2025 19:06:57 +0300 Subject: [PATCH 22/66] Fixed problem with pagination in translate (Teloxide(Api(MessageNotModified) --- src/handlers/markups/callbacks/translate.rs | 39 ++++++++++++++++----- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/src/handlers/markups/callbacks/translate.rs b/src/handlers/markups/callbacks/translate.rs index 1de3cfe..894c9f6 100644 --- a/src/handlers/markups/callbacks/translate.rs +++ b/src/handlers/markups/callbacks/translate.rs @@ -2,6 +2,7 @@ use teloxide::Bot; use teloxide::payloads::{EditMessageReplyMarkupSetters, EditMessageTextSetters}; use teloxide::prelude::Requester; use teloxide::types::{CallbackQuery, InlineKeyboardButton, MaybeInaccessibleMessage, Message, ParseMode}; +use teloxide::{ApiError, RequestError}; use crate::config::Config; use crate::util::errors::MyError; use translators::{GoogleTranslator, Translator}; @@ -35,16 +36,22 @@ async fn handle_pagination(bot: Bot, message: &Message, data: &str) -> Result<() match data.trim_start_matches("tr_page:").parse::() { Ok(page) => { let keyboard = create_language_keyboard(page); - bot.edit_message_reply_markup(message.chat.id, message.id) + if let Err(e) = bot + .edit_message_reply_markup(message.chat.id, message.id) .reply_markup(keyboard) - .await?; - Ok(()) + .await + { + if let RequestError::Api(ApiError::MessageNotModified) = e { + } else { + return Err(MyError::from(e)); + } + } } Err(_) => { log::warn!("Failed to parse page number from: {}", data); - Ok(()) } } + Ok(()) } async fn handle_language_selection( @@ -76,7 +83,7 @@ async fn handle_language_selection( let redis_key = format!("user_lang:{}", user.id); let redis_client = config.get_redis_client(); - let ttl_seconds = 2 * 60 * 60; // 2 hours + let ttl_seconds = 2 * 60 * 60; redis_client .set(&redis_key, &target_lang.to_string(), ttl_seconds) .await?; @@ -111,18 +118,32 @@ async fn handle_language_selection( keyboard.inline_keyboard.push(vec![switch_lang_button]); } - bot.edit_message_text(message.chat.id, message.id, response) + if let Err(e) = bot + .edit_message_text(message.chat.id, message.id, response) .parse_mode(ParseMode::Html) .reply_markup(keyboard) - .await?; + .await + { + if let RequestError::Api(ApiError::MessageNotModified) = e { + } else { + return Err(MyError::from(e)); + } + } Ok(()) } async fn handle_show_languages(bot: Bot, message: &Message) -> Result<(), MyError> { let keyboard = create_language_keyboard(0); - bot.edit_message_text(message.chat.id, message.id, "Выберите язык для перевода:") + if let Err(e) = bot + .edit_message_text(message.chat.id, message.id, "Выберите язык для перевода:") .reply_markup(keyboard) - .await?; + .await + { + if let RequestError::Api(ApiError::MessageNotModified) = e { + } else { + return Err(MyError::from(e)); + } + } Ok(()) } \ No newline at end of file From d2bbc12a77d0194aa1a390be48485418980d59de Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Sun, 31 Aug 2025 20:10:45 +0300 Subject: [PATCH 23/66] Fixed delete button issue --- src/handlers/commands/translate.rs | 3 +-- src/handlers/markups/callbacks/delete.rs | 2 +- src/handlers/markups/markuper.rs | 2 +- src/util/transcription.rs | 4 ++-- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/handlers/commands/translate.rs b/src/handlers/commands/translate.rs index 19c98f0..58ed64e 100644 --- a/src/handlers/commands/translate.rs +++ b/src/handlers/commands/translate.rs @@ -1,12 +1,11 @@ use crate::config::Config; use crate::util::errors::MyError; +use crate::util::inline::delete_message_button; use teloxide::prelude::*; -use teloxide::repl; use teloxide::types::{ InlineKeyboardButton, InlineKeyboardMarkup, Message, ParseMode, ReplyParameters, }; use translators::{GoogleTranslator, Translator}; -use crate::util::inline::delete_message_button; pub const SUPPORTED_LANGUAGES: &[(&str, &str)] = &[ ("uk", "🇺🇦 Українська"), diff --git a/src/handlers/markups/callbacks/delete.rs b/src/handlers/markups/callbacks/delete.rs index d2a76c1..f438663 100644 --- a/src/handlers/markups/callbacks/delete.rs +++ b/src/handlers/markups/callbacks/delete.rs @@ -19,7 +19,7 @@ pub async fn delete_message_handler(bot: Bot, query: CallbackQuery) -> Result<() let clicker = query.from; - let target_user_id_str = data.strip_prefix("delete_").unwrap_or_default(); + let target_user_id_str = data.strip_prefix("delete_msg:").unwrap_or_default(); let Ok(target_user_id) = target_user_id_str.parse::() else { bot.answer_callback_query(query.id) .text("❌ Ошибка: неверный ID в кнопке.") diff --git a/src/handlers/markups/markuper.rs b/src/handlers/markups/markuper.rs index 2d3af08..53a9295 100644 --- a/src/handlers/markups/markuper.rs +++ b/src/handlers/markups/markuper.rs @@ -18,7 +18,7 @@ pub(crate) async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Resul let qq = q.clone(); let data = qq.data.clone().unwrap(); - if data.starts_with("delete_") { + if data.starts_with("delete_msg") { delete_message_handler(bot, qq).await? } else if data.starts_with("summarize") { summarization_handler(bot, qq, &_config).await? diff --git a/src/util/transcription.rs b/src/util/transcription.rs index 4bf2d97..5b9b170 100644 --- a/src/util/transcription.rs +++ b/src/util/transcription.rs @@ -97,7 +97,7 @@ pub async fn transcription_handler(bot: Bot, msg: Message, config: &Config) -> R InlineKeyboardButton::callback("✨", "summarize"), InlineKeyboardButton::callback( "🗑️", - format!("delete_{}", msg.from.unwrap().id.0), + format!("delete_msg:{}", msg.from.unwrap().id.0), ), ]]); @@ -189,7 +189,7 @@ pub async fn back_handler(bot: Bot, query: CallbackQuery, config: &Config) -> Re .parse_mode(ParseMode::Html) .reply_markup(InlineKeyboardMarkup::new(vec![vec![ InlineKeyboardButton::callback("✨", "summarize"), - InlineKeyboardButton::callback("🗑️", format!("delete_{}", query.from.id.0)), + InlineKeyboardButton::callback("🗑️", format!("delete_msg:{}", query.from.id.0)), ]])) .await?; From 1fe5467e2c9a9196eb19a5fb65b854cbd3b5c4c0 Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Sun, 31 Aug 2025 20:11:10 +0300 Subject: [PATCH 24/66] Added support for threads in logs --- .env.example | 2 ++ src/config.rs | 23 +++++++++++++++++++++++ src/loader.rs | 13 +++++++------ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 05220f4..2f4853c 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,8 @@ GEMINI_API_KEY= COBALT_API_KEY= OWNERS=701733705,6452350296 LOG_CHAT_ID= +ERROR_CHAT_THREAD_ID=0 +WARN_CHAT_THREAD_ID=0 MONGODB_URL=mongodb://localhost:27017 RUST_LOG=info REDIS_URL=redis://127.0.0.1:6379 \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index 5414a22..2aa2f16 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,6 +13,9 @@ pub struct Config { #[allow(dead_code)] owners: Vec, log_chat_id: String, + error_chat_thread_id: String, + #[allow(dead_code)] + warn_chat_thread_id: String, version: String, json_config: JsonConfig, currency_converter: Arc, @@ -42,6 +45,15 @@ impl Config { .collect(); let log_chat_id = std::env::var("LOG_CHAT_ID").expect("LOG_CHAT_ID expected"); + let error_chat_thread_id: String = std::env::var("ERROR_CHAT_THREAD_ID") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(0.to_string()); + + let warn_chat_thread_id: String = std::env::var("WARN_CHAT_THREAD_ID") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(0.to_string()); let json_config = read_json_config("config.json").expect("Unable to read config.json"); let currency_converter = Arc::new(CurrencyConverter::new(OutputLanguage::Russian).unwrap()); // TODO: get language from config @@ -57,6 +69,8 @@ impl Config { cobalt_client, owners, log_chat_id, + error_chat_thread_id, + warn_chat_thread_id, version, json_config, currency_converter, @@ -86,6 +100,15 @@ impl Config { &self.log_chat_id } + pub fn get_error_chat_thread_id(&self) -> &str { + &self.error_chat_thread_id + } + + #[allow(dead_code)] + pub fn get_warn_chat_thread_id(&self) -> &str { + &self.warn_chat_thread_id + } + pub fn get_json_config(&self) -> &JsonConfig { &self.json_config } diff --git a/src/loader.rs b/src/loader.rs index 82ad314..7e6a09e 100644 --- a/src/loader.rs +++ b/src/loader.rs @@ -12,7 +12,7 @@ use crate::{ }, util::{enums::Command, errors::MyError}, }; -use log::info; +use log::{error, info}; use oximod::set_global_client; use std::convert::Infallible; use std::fmt::Write; @@ -22,7 +22,7 @@ use teloxide::dispatching::MessageFilterExt; use teloxide::error_handlers::LoggingErrorHandler; use teloxide::payloads::SendDocumentSetters; use teloxide::prelude::{ChatId, Handler, Message, Requester}; -use teloxide::types::{Chat, InputFile, ParseMode, User}; +use teloxide::types::{Chat, InputFile, MessageId, ParseMode, ThreadId, User}; use teloxide::update_listeners::Polling; use teloxide::utils::html; use teloxide::{ @@ -128,7 +128,7 @@ fn short_error_name(error: &MyError) -> String { } pub async fn handle_error(err: Arc, update: Update, config: Arc, bot: Bot) { - log::error!("An error has occurred: {:?}", err); // ahh fuck + error!("An error has occurred: {:?}", err); // ahh fuck let (user, chat) = extract_info(&update); let mut message_text = String::new(); @@ -159,18 +159,19 @@ pub async fn handle_error(err: Arc, update: Update, config: Arc let full_error_text = format!("{:#?}", err); let document = InputFile::memory(full_error_text.into_bytes()).file_name("error_details.txt"); - if let Ok(log_chat_id) = config.get_log_chat_id().parse::() { + if let (Ok(log_chat_id), Ok(error_thread_id)) = (config.get_log_chat_id().parse::(), config.get_error_chat_thread_id().parse::()) { let chat_id = ChatId(log_chat_id); match bot.send_document(chat_id, document) .caption(message_text) .parse_mode(ParseMode::Html) .reply_markup(delete_message_button(72)) + .message_thread_id(ThreadId(MessageId(error_thread_id))) .await { Ok(_) => info!("Error report sent successfully to chat {}", log_chat_id), - Err(e) => log::error!("Failed to send error report to chat {}: {}", log_chat_id, e), + Err(e) => error!("Failed to send error report to chat {}: {}", log_chat_id, e), } } else { - log::error!("LOG_CHAT_ID is not a valid integer: {}", config.get_log_chat_id()); + error!("LOG_CHAT_ID ({}) or ERROR_CHAT_THREAD_ID ({}) is not a valid integer", config.get_log_chat_id(), config.get_error_chat_thread_id()); } } \ No newline at end of file From 6c416fd8c961647db3d893fa8295e88997e9d7df Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Mon, 1 Sep 2025 01:16:31 +0300 Subject: [PATCH 25/66] Added cobalt tools (backport from nixxoq's patches and some upgrades in code). Now it's: 1. Caching video/photo for 24 hours 2. Works fine (except photos). But maybe we will do *loading message* 3. We should to parse thumbnail of video, photo (or download it and give to user?) --- Cargo.toml | 6 +- src/config.rs | 4 +- src/db/redis.rs | 41 +++-- src/db/schemas.rs | 14 +- src/db/schemas/settings.rs | 94 +++++++++-- src/db/schemas/user.rs | 22 ++- src/handlers/commands/settings.rs | 2 - src/handlers/keyboards.rs | 60 +++++++ src/handlers/markups/callbacks/module.rs | 159 ++++++++++++++---- src/handlers/markups/inlines/cobalter.rs | 202 ++++++++++++++++++----- src/handlers/markups/markuper.rs | 16 +- src/handlers/mod.rs | 1 + src/util/errors.rs | 24 +++ 13 files changed, 518 insertions(+), 127 deletions(-) create mode 100644 src/handlers/keyboards.rs diff --git a/Cargo.toml b/Cargo.toml index aaa80b7..c3735fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,8 +22,10 @@ async-trait = "0.1.88" log = "0.4.27" pretty_env_logger = "0.5.0" uuid = { version = "1.17.0", features = ["v4"] } -ccobalt = "0.2.1" +ccobalt = { git = "https://github.com/fulturate/ccobalt.git" } translators = { version = "0.1.5", features = ["google", "tokio-async"] } redis = { version = "0.32.5", features = ["tokio-comp"] } redis-macros = { version = "0.5.6" } -url = "2.5.4" \ No newline at end of file +url = "2.5.4" +base64 = "0.22.1" +mime = "0.3.17" \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index 2aa2f16..297a684 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,10 +1,11 @@ use crate::db::redis::RedisCache; use crate::util::currency::converter::{CurrencyConverter, OutputLanguage}; -use crate::util::json::{JsonConfig, read_json_config}; +use crate::util::json::{read_json_config, JsonConfig}; use dotenv::dotenv; use redis::Client as RedisClient; use std::sync::Arc; use teloxide::prelude::*; +use teloxide::types::Me; #[derive(Clone)] pub struct Config { @@ -35,6 +36,7 @@ impl Config { let cobalt_client = ccobalt::Client::builder() .base_url("https://cobalt-backend.canine.tools/") .api_key(cobalt_api_key) + .user_agent("Fulturate/6.6.6 (rust) (+https://github.com/weever1337/fulturate-rs)".to_string()) .build() .expect("Failed to build cobalt client"); diff --git a/src/db/redis.rs b/src/db/redis.rs index 9f428f1..3f7a86f 100644 --- a/src/db/redis.rs +++ b/src/db/redis.rs @@ -1,6 +1,5 @@ use redis::{AsyncCommands, Client, RedisError}; -use serde::de::DeserializeOwned; -use serde::Serialize; +use serde::{de::DeserializeOwned, Serialize}; #[derive(Clone)] pub struct RedisCache { @@ -12,23 +11,18 @@ impl RedisCache { Self { client } } - pub async fn get(&self, key: &str) -> Result, RedisError> - where - T: DeserializeOwned, - { + pub async fn get(&self, key: &str) -> Result, RedisError> { let mut con = self.client.get_multiplexed_tokio_connection().await?; let result: Option = con.get(key).await?; - - match result { - Some(s) => Ok(serde_json::from_str(&s).ok()), - None => Ok(None), - } + Ok(result.and_then(|s| serde_json::from_str(&s).ok())) } - pub async fn set(&self, key: &str, value: &T, ttl_seconds: usize) -> Result<(), RedisError> - where - T: Serialize + Sync, - { + pub async fn set( + &self, + key: &str, + value: &T, + ttl_seconds: usize, + ) -> Result<(), RedisError> { let mut con = self.client.get_multiplexed_tokio_connection().await?; let json_value = serde_json::to_string(value).unwrap(); let _: () = con.set_ex(key, json_value, ttl_seconds as u64).await?; @@ -37,7 +31,22 @@ impl RedisCache { pub async fn delete(&self, key: &str) -> Result<(), RedisError> { let mut con = self.client.get_multiplexed_tokio_connection().await?; - let _: () = con.del(key).await?; + let _: i64 = con.del(key).await?; Ok(()) } + + pub async fn get_and_delete( + &self, + key: &str, + ) -> Result, RedisError> { + let mut con = self.client.get_multiplexed_tokio_connection().await?; + + let (result, _): (Option, i64) = redis::pipe() + .get(key) + .del(key) + .query_async(&mut con) + .await?; + + Ok(result.and_then(|s| serde_json::from_str(&s).ok())) + } } \ No newline at end of file diff --git a/src/db/schemas.rs b/src/db/schemas.rs index 728f70c..4efa6dd 100644 --- a/src/db/schemas.rs +++ b/src/db/schemas.rs @@ -2,12 +2,12 @@ pub mod group; pub mod user; pub mod settings; +use crate::db::schemas::settings::ModuleSettings; use crate::util::currency::converter::CurrencyStruct; +use crate::util::errors::MyError; use async_trait::async_trait; use mongodb::results::UpdateResult; use oximod::_error::oximod_error::OxiModError; -use crate::db::schemas::settings::ModuleSettings; -use crate::util::errors::MyError; #[async_trait] pub trait BaseFunctions: Sized { @@ -30,5 +30,15 @@ pub trait SettingsRepo { where Self: Sized; + async fn update_module( + owner_id: &str, + owner_type: &str, + module_key: &str, + modifier: F, + ) -> Result + where + Self: Sized, + F: FnOnce(&mut ModuleSettings) + Send; + fn modules_mut(&mut self) -> &mut Vec; } diff --git a/src/db/schemas/settings.rs b/src/db/schemas/settings.rs index 5f4c0b5..555faab 100644 --- a/src/db/schemas/settings.rs +++ b/src/db/schemas/settings.rs @@ -1,6 +1,7 @@ use crate::db::schemas::SettingsRepo; use crate::util::errors::MyError; use async_trait::async_trait; +use mongodb::bson; use mongodb::bson::{doc, oid::ObjectId}; use oximod::{Model, ModelTrait}; use serde::{Deserialize, Serialize}; @@ -23,11 +24,11 @@ pub struct Settings { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ModuleSettings { - pub key: String, // уникальный ключ модуля, например "currency" или "speech_recog" - pub enabled: bool, // включен или нет - pub description: String, // описание модуля - #[serde(default)] // тут ещё было бы неплохо добавить лимиты, по типу максимум и какой сейчас, - // но мне че то лень это продумывать + pub key: String, // уникальный ключ модуля, например "currency" или "speech_recog" + pub enabled: bool, // включен или нет + pub description: String, // описание модуля + #[serde(default)] // тут ещё было бы неплохо добавить лимиты, по типу максимум и какой сейчас, + // но мне че то лень это продумывать pub options: Vec, } @@ -40,7 +41,9 @@ pub struct ModuleOption { #[async_trait] impl SettingsRepo for Settings { async fn get_or_create(owner_id: &str, owner_type: &str) -> Result { - if let Some(found) = Settings::find_one(doc! { "owner_id": owner_id, "owner_type": owner_type }).await? { + if let Some(found) = + Settings::find_one(doc! { "owner_id": owner_id, "owner_type": owner_type }).await? + { Ok(found) } else { let default_modules = vec![ @@ -48,19 +51,27 @@ impl SettingsRepo for Settings { key: "currency".to_string(), enabled: false, description: "Конвертация валют".to_string(), - options: vec![ - ModuleOption { key: "currencies".into(), value: "USD,EUR".into() }, - ], + options: vec![ModuleOption { + key: "currencies".into(), + value: "USD,EUR".into(), + }], }, ModuleSettings { key: "speech".to_string(), enabled: false, description: "Распознавание речи".to_string(), options: vec![ - ModuleOption { key: "model".into(), value: "Gemini 2.5 Flash".into() }, - ModuleOption { key: "token".into(), value: "".into() }, + ModuleOption { + key: "model".into(), + value: "Gemini 2.5 Flash".into(), + }, + ModuleOption { + key: "token".into(), + value: "".into(), + }, ], }, + create_cobalt_module(), ]; let new = Settings::new() .owner_id(owner_id.to_string()) @@ -71,7 +82,68 @@ impl SettingsRepo for Settings { } } + async fn update_module( + owner_id: &str, + owner_type: &str, + module_key: &str, + modifier: F, + ) -> Result + where + Self: Sized, + F: FnOnce(&mut ModuleSettings) + Send, + { + let mut settings = Self::get_or_create(owner_id, owner_type).await?; + + if let Some(module) = settings + .modules_mut() + .iter_mut() + .find(|m| m.key == module_key) + { + modifier(module); + } else { + return Err(MyError::ModuleNotFound(module_key.to_string())); + } + + let filter = doc! { "owner_id": owner_id, "owner_type": owner_type }; + let modules_as_bson = bson::to_bson(&settings.modules)?; + let update = doc! { "$set": { "modules": modules_as_bson } }; + + Self::update_one(filter, update).await?; + + Ok(settings) + } + fn modules_mut(&mut self) -> &mut Vec { &mut self.modules } } + +fn create_cobalt_module() -> ModuleSettings { + ModuleSettings { + key: "cobalt".to_string(), + enabled: true, + description: "Настройки для Cobalt Downloader".to_string(), + options: vec![ + ModuleOption { + key: "preferred_output".into(), + value: "auto".into(), + }, + ModuleOption { + key: "video_format".into(), + value: "h264".into(), + }, + ModuleOption { + key: "video_quality".into(), + value: "1080".into(), + }, + ModuleOption { + key: "audio_format".into(), + value: "mp3".into(), + }, + ModuleOption { + key: "attribution".into(), + value: "false".into(), + }, // Используем строку "false" для унификации + ], + } +} diff --git a/src/db/schemas/user.rs b/src/db/schemas/user.rs index 45834ee..5018d03 100644 --- a/src/db/schemas/user.rs +++ b/src/db/schemas/user.rs @@ -17,6 +17,9 @@ pub struct User { #[index(sparse, name = "convertable_currencies")] #[serde(default)] pub convertable_currencies: Vec, + + #[serde(default)] + pub download_count: i64, } #[async_trait] @@ -57,17 +60,24 @@ impl CurrenciesFunctions for User { doc! {"user_id": user_id}, doc! {"$push": {"convertable_currencies": currency_to_add } }, ) - .await + .await } - async fn remove_currency( - user_id: &str, - currency: &str, - ) -> Result { + async fn remove_currency(user_id: &str, currency: &str) -> Result { Self::update_one( doc! {"user_id": user_id}, doc! {"$pull": {"convertable_currencies": {"code": currency} } }, ) - .await + .await + } +} + +impl User { + pub async fn increment_download_count(user_id: &str) -> Result { + Self::update_one( + doc! { "user_id": user_id }, + doc! { "$inc": { "download_count": 1 } }, + ) + .await } } diff --git a/src/handlers/commands/settings.rs b/src/handlers/commands/settings.rs index 1e5b736..0de4348 100644 --- a/src/handlers/commands/settings.rs +++ b/src/handlers/commands/settings.rs @@ -124,7 +124,6 @@ pub async fn currency_codes_list_handler(bot: Bot, msg: Message) -> Result<(), M .collect::>() .join("\n"); - // safety: assume that bot commands always exists let result = bot .get_my_commands() .await @@ -139,7 +138,6 @@ pub async fn currency_codes_list_handler(bot: Bot, msg: Message) -> Result<(), M codes_list, &command.command, &command.command ); } else { - // default fallback message = format!( "Available currencies to set up:
{}
\n\nNotes:\n✅ - enabled\n❌ - disabled", codes_list diff --git a/src/handlers/keyboards.rs b/src/handlers/keyboards.rs new file mode 100644 index 0000000..738a90c --- /dev/null +++ b/src/handlers/keyboards.rs @@ -0,0 +1,60 @@ +use crate::db::schemas::settings::ModuleOption; +use base64::{engine::general_purpose, Engine as _}; +use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup}; + +pub fn make_single_download_keyboard(url: &str, format: &str) -> InlineKeyboardMarkup { + let button_text = if format == "audio" { + "Download as Audio" + } else { + "Download as Video" + }; + + let encoded_url = general_purpose::STANDARD.encode(url); + let callback_data = format!("download:{}:{}", format, encoded_url); + + InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::callback( + button_text.to_string(), + callback_data, + )]]) +} + +pub fn make_option_selection_keyboard( + owner_type: &str, + owner_id: &str, + module_key: &str, + option: &ModuleOption, +) -> InlineKeyboardMarkup { + let options: Vec<&str> = match (module_key, option.key.as_str()) { + ("cobalt", "video_quality") => vec!["720", "1080", "1440", "max"], + ("cobalt", "audio_format") => vec!["mp3", "best", "wav", "opus"], + ("cobalt", "attribution") => vec!["true", "false"], + _ => vec![], + }; + + let buttons = options.into_iter().map(|opt| { + let display_text = match (option.key.as_str(), opt) { + ("attribution", "true") => "On", + ("attribution", "false") => "Off", + _ => opt, + }; + + let display = if opt == option.value { + format!("• {} •", display_text) + } else { + display_text.to_string() + }; + + let cb_data = format!( + "settings_set:{}:{}:{}:{}:{}", + owner_type, owner_id, module_key, option.key, opt + ); + InlineKeyboardButton::callback(display, cb_data) + }); + + let mut keyboard: Vec> = buttons.map(|b| vec![b]).collect(); + + let back_cb = format!("module_select:{}:{}:{}", owner_type, owner_id, module_key); + keyboard.push(vec![InlineKeyboardButton::callback("⬅️ Back", back_cb)]); + + InlineKeyboardMarkup::new(keyboard) +} \ No newline at end of file diff --git a/src/handlers/markups/callbacks/module.rs b/src/handlers/markups/callbacks/module.rs index 4928e2c..5d26315 100644 --- a/src/handlers/markups/callbacks/module.rs +++ b/src/handlers/markups/callbacks/module.rs @@ -1,17 +1,14 @@ -use oximod::Model; -use teloxide::Bot; -use teloxide::payloads::{AnswerCallbackQuerySetters, EditMessageTextSetters}; -use teloxide::prelude::{CallbackQuery, Requester}; -use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup}; use crate::db::schemas::settings::Settings; use crate::db::schemas::SettingsRepo; -use crate::handlers::commands::settings::{update_settings_message}; +use crate::handlers::commands::settings::update_settings_message; +use crate::handlers::keyboards::make_option_selection_keyboard; use crate::util::errors::MyError; +use teloxide::payloads::EditMessageTextSetters; +use teloxide::prelude::{CallbackQuery, Requester}; +use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup}; +use teloxide::Bot; -pub async fn module_select_handler( - bot: Bot, - q: CallbackQuery, -) -> Result<(), MyError> { +pub async fn module_select_handler(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { let data = match q.data.as_ref() { Some(d) => d, None => return Ok(()), @@ -37,21 +34,34 @@ pub async fn module_select_handler( .find(|m| m.key == module_key) .unwrap(); - let toggle_label = if module.enabled { "Выключить" } else { "Включить" }; + let toggle_label = if module.enabled { + "Выключить" + } else { + "Включить" + }; let toggle_cb = format!("module_toggle:{owner_type}:{owner_id}:{module_key}"); let mut keyboard_rows: Vec> = vec![]; - keyboard_rows.push(vec![InlineKeyboardButton::callback(toggle_label, toggle_cb)]); + keyboard_rows.push(vec![InlineKeyboardButton::callback( + toggle_label, + toggle_cb, + )]); for opt in module.options.iter() { let label = format!("{}: {}", opt.key, opt.value); - let cb = format!("module_opt:{owner_type}:{owner_id}:{module_key}:{}", opt.key); + let cb = format!( + "module_opt:{owner_type}:{owner_id}:{module_key}:{}", + opt.key + ); keyboard_rows.push(vec![InlineKeyboardButton::callback(label, cb)]); } let back_button_cb = format!("settings_back:{owner_type}:{owner_id}"); - keyboard_rows.push(vec![InlineKeyboardButton::callback("⬅️ Назад", back_button_cb)]); + keyboard_rows.push(vec![InlineKeyboardButton::callback( + "⬅️ Назад", + back_button_cb, + )]); let keyboard = InlineKeyboardMarkup::new(keyboard_rows); @@ -67,44 +77,81 @@ pub async fn module_select_handler( } pub async fn module_toggle_handler(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { - println!("module_toggle_handler"); let parts: Vec<_> = q.data.as_ref().unwrap().split(':').collect(); - println!("1"); let owner_type = parts[1]; - println!("2:{}", owner_type); let owner_id = parts[2]; - println!("3:{}", owner_id); let module_key = parts[3]; - println!("4:{}", module_key); - let mut settings = Settings::get_or_create(owner_id, owner_type).await?; - if let Some(m) = settings.modules_mut().iter_mut().find(|m| m.key == module_key) { - println!("5"); - m.enabled = !m.enabled; - println!("6"); - } - println!("7"); - settings.save().await?; // FIXME: this fucking ahh code freezes or gives error but i'm lazy to fix it - println!("8"); + Settings::update_module(owner_id, owner_type, module_key, |module| { + module.enabled = !module.enabled; + }) + .await?; - update_settings_message(bot, q.message.unwrap().clone(), owner_id.to_string(), owner_type.to_string()).await + update_settings_message( + bot, + q.message.unwrap().clone(), + owner_id.to_string(), + owner_type.to_string(), + ) + .await } +// pub async fn module_option_handler(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { +// let parts: Vec<_> = q.data.as_ref().unwrap().split(':').collect(); +// let owner_type = parts[1]; +// let owner_id = parts[2]; +// let module_key = parts[3]; +// let option_key = parts[4]; +// +// let settings = Settings::get_or_create(owner_id, owner_type).await?; +// let module = settings +// .modules +// .iter() +// .find(|m| m.key == module_key) +// .unwrap(); +// let opt = module.options.iter().find(|o| o.key == option_key).unwrap(); +// +// bot.answer_callback_query(q.id.clone()) +// .text(format!("Текущая опция «{}»: {}", opt.key, opt.value)) +// .show_alert(true) +// .await?; +// Ok(()) +// } pub async fn module_option_handler(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { - let parts: Vec<_> = q.data.as_ref().unwrap().split(':').collect(); + let message = q + .message + .as_ref() + .ok_or_else(|| MyError::Other("No message in callback".into()))?; + let data = q + .data + .as_ref() + .ok_or_else(|| MyError::Other("No data in callback".into()))?; + + let parts: Vec<_> = data.split(':').collect(); let owner_type = parts[1]; let owner_id = parts[2]; let module_key = parts[3]; let option_key = parts[4]; let settings = Settings::get_or_create(owner_id, owner_type).await?; - let module = settings.modules.iter().find(|m| m.key == module_key).unwrap(); + let module = settings + .modules + .iter() + .find(|m| m.key == module_key) + .unwrap(); let opt = module.options.iter().find(|o| o.key == option_key).unwrap(); - bot.answer_callback_query(q.id.clone()) - .text(format!("Текущая опция «{}»: {}", opt.key, opt.value)) - .show_alert(true) + let keyboard = make_option_selection_keyboard(owner_type, owner_id, module_key, opt); + + let option_name = option_key.replace('_', " "); + bot.edit_message_text( + message.chat().id, + message.id(), + format!("Select a value for '{}':", option_name), + ) + .reply_markup(keyboard) .await?; + Ok(()) } @@ -113,5 +160,47 @@ pub async fn settings_back_handler(bot: Bot, q: CallbackQuery) -> Result<(), MyE let owner_type = parts[1]; let owner_id = parts[2]; - update_settings_message(bot, q.message.unwrap().clone(), owner_id.to_string(), owner_type.to_string()).await + update_settings_message( + bot, + q.message.unwrap().clone(), + owner_id.to_string(), + owner_type.to_string(), + ) + .await +} + +pub async fn settings_set_handler(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { + q.message.as_ref().ok_or_else(|| MyError::Other("No message in callback".into()))?; + + let data = q + .data + .clone() + .ok_or_else(|| MyError::Other("No data in callback".into()))?; + + let parts: Vec<_> = data.split(':').collect(); + if parts.len() < 6 { + return Err(MyError::Other( + "Invalid callback data for settings_set".into(), + )); + } + let owner_type = parts[1]; + let owner_id = parts[2]; + let module_key = parts[3]; + let option_key = parts[4]; + let new_value = parts[5].to_string(); + + Settings::update_module(owner_id, owner_type, module_key, |module| { + if let Some(option) = module.options.iter_mut().find(|o| o.key == option_key) { + option.value = new_value; + } + }) + .await?; + + let mut updated_q = q; + updated_q.data = Some(format!( + "module_select:{}:{}:{}", + owner_type, owner_id, module_key + )); + + module_select_handler(bot, updated_q).await } diff --git a/src/handlers/markups/inlines/cobalter.rs b/src/handlers/markups/inlines/cobalter.rs index afd180c..db0880c 100644 --- a/src/handlers/markups/inlines/cobalter.rs +++ b/src/handlers/markups/inlines/cobalter.rs @@ -1,70 +1,182 @@ use crate::config::Config; -use ccobalt::model::request::{DownloadRequest, VideoQuality}; +use crate::db::schemas::settings::Settings; +use crate::db::schemas::SettingsRepo; +use crate::util::errors::MyError; +use ccobalt::model::request::{DownloadRequest, FilenameStyle, VideoQuality}; +use ccobalt::model::response::DownloadResponse; +use mime::Mime; use once_cell::sync::Lazy; use regex::Regex; +use serde::{Deserialize, Serialize}; use std::sync::Arc; -use teloxide::Bot; -use teloxide::prelude::{Request, Requester}; +use teloxide::prelude::*; use teloxide::types::{ - InlineKeyboardButton, InlineKeyboardMarkup, InlineQuery, InlineQueryResult, - InlineQueryResultArticle, InputMessageContent, - InputMessageContentText, + InlineQuery, InlineQueryResult, InlineQueryResultArticle, InlineQueryResultPhoto, + InlineQueryResultVideo, InputMessageContent, InputMessageContentText, }; -use crate::util::errors::MyError; +use teloxide::Bot; -static URL_REGEX: Lazy = - Lazy::new(|| Regex::new(r"^(https?)://[^\s/$.?#].[^\s]*$").unwrap()); +const FALLBACK_THUMB_URL: &str = "https://i.imgur.com/424242.png"; // todo: change it to a real thumbnail + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum DownloadResult { + Video(String), + Photos(Vec), +} + +pub async fn resolve_download_url( + url: &str, + settings: &Settings, + client: &ccobalt::Client, +) -> Result, MyError> { + let get_opt = |key: &str| -> String { + settings + .modules + .iter() + .find(|m| m.key == "cobalt") + .and_then(|m| m.options.iter().find(|o| o.key == key)) + .map(|o| o.value.clone()) + .unwrap_or_default() + }; + + let cobalt_req = DownloadRequest { + url: url.to_string(), + filename_style: Some(FilenameStyle::Pretty), + video_quality: Some(match get_opt("video_quality").as_str() { + "720" => VideoQuality::Q720, + "1080" => VideoQuality::Q1080, + "1440" => VideoQuality::Q1440, + "max" => VideoQuality::Max, + _ => VideoQuality::Q720, + }), + ..Default::default() + }; + + let response = client.resolve_download(&cobalt_req).await?; -fn is_url(text: &str) -> bool { - URL_REGEX.is_match(text) + match response { + DownloadResponse::Error { error } => { + log::error!("Cobalt API error: {:?}", error); + Err(error.into()) + } + DownloadResponse::Picker { picker, .. } => { + let photo_urls: Vec = picker + .iter() + .filter(|item| item.kind == "photo") + .map(|item| item.url.clone()) + .collect(); + + if !photo_urls.is_empty() { + return Ok(Some(DownloadResult::Photos(photo_urls))); + } + + if let Some(video_item) = picker.iter().find(|item| item.kind == "video") { + return Ok(Some(DownloadResult::Video(video_item.url.clone()))); + } + + Ok(None) + } + _ => { + if let Some(download_url) = response.get_download_url() { + Ok(Some(DownloadResult::Video(download_url))) + } else { + Ok(None) + } + } + } } +static URL_REGEX: Lazy = + Lazy::new(|| Regex::new(r"^(https?)://[^\s/$.?#].[^\s]*$").unwrap()); + pub async fn is_query_url(inline_query: InlineQuery) -> bool { URL_REGEX.is_match(&inline_query.query) } +fn build_results_from_media( + original_url: &str, + media: DownloadResult, +) -> Vec { + match media { + DownloadResult::Video(url) => { + if let (Ok(video_url), Ok(thumb_url)) = (url.parse(), FALLBACK_THUMB_URL.parse()) { + let mime_type: Mime = "video/mp4".parse().unwrap(); + let video_result = InlineQueryResultVideo::new( + original_url, + video_url, + mime_type, + thumb_url, + "Video", + ); + vec![video_result.into()] + } else { + vec![] + } + } + DownloadResult::Photos(urls) => urls + .into_iter() + .enumerate() + .filter_map(|(i, url_str)| { + if let (Ok(photo_url), Ok(thumb_url)) = (url_str.parse(), url_str.parse()) { + let photo_result = InlineQueryResultPhoto::new( + format!("{}_{}", original_url, i), + photo_url, + thumb_url, + ); + Some(photo_result.into()) + } else { + None + } + }) + .collect(), + } +} + pub async fn handle_cobalt_inline( bot: Bot, q: InlineQuery, config: Arc, ) -> Result<(), MyError> { - let url = &q.query; - if is_url(url) { - let request = DownloadRequest { - url: url.to_string(), - video_quality: Some(VideoQuality::Q720), - ..Default::default() - }; - - let cobalt_client = config.get_cobalt_client(); + let url = q.query.trim(); + if !URL_REGEX.is_match(url) { + return Ok(()); + } - match cobalt_client.download_and_save(&request, "test", ".").await { - Ok(_video_file) => { - let keyboard = InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::url( - "🔗 Open Link".to_string(), - url.parse()?, - )]]); - - let article = InlineQueryResultArticle::new( - "cobalt_prompt", - "Cobalt Test", - InputMessageContent::Text(InputMessageContentText::new("Test")), - ) - .description("Teestttt 22") - .reply_markup(keyboard); + let user_id_str = q.from.id.to_string(); + let redis = config.get_redis_client(); + let cache_key = format!("cobalt_cache:{}", url); - bot.answer_inline_query(q.id, vec![InlineQueryResult::Article(article)]) - .send() - .await?; + let results = if let Ok(Some(cached_result)) = redis.get::(&cache_key).await { + build_results_from_media(url, cached_result) + } else { + let settings = Settings::get_or_create(&user_id_str, "user").await?; + let cobalt_client = config.get_cobalt_client(); + let result = resolve_download_url(url, &settings, cobalt_client).await; - Ok(()) + match result { + Ok(Some(download_result)) => { + let ttl_24_hours = 86400; + let result_to_cache = download_result.clone(); + if let Err(e) = redis.set(&cache_key, &result_to_cache, ttl_24_hours).await { + log::error!("Failed to cache cobalt result: {}", e); + } + build_results_from_media(url, download_result) } - Err(e) => { - log::error!("Failed to download: {:?}", e); - Ok(()) + _ => { + let error_article = InlineQueryResultArticle::new( + "error", + "Error", + InputMessageContent::Text(InputMessageContentText::new( + "Failed to process link. Media not found or an error occurred.", + )), + ) + .description("Could not fetch media. Please try again later."); + vec![error_article.into()] } } - } else { - Ok(()) - } -} + }; + + bot.answer_inline_query(q.id, results).await?; + + Ok(()) +} \ No newline at end of file diff --git a/src/handlers/markups/markuper.rs b/src/handlers/markups/markuper.rs index 53a9295..02cdef2 100644 --- a/src/handlers/markups/markuper.rs +++ b/src/handlers/markups/markuper.rs @@ -1,15 +1,15 @@ use crate::config::Config; +use crate::handlers::markups::callbacks::delete::delete_message_handler; +use crate::handlers::markups::callbacks::module::{module_option_handler, module_select_handler, module_toggle_handler, settings_back_handler, settings_set_handler}; +use crate::handlers::markups::callbacks::translate::handle_translate_callback; +use crate::handlers::markups::callbacks::whisper::handle_whisper_callback; use crate::util::errors::MyError; use crate::util::transcription::{ back_handler, summarization_handler, }; -use teloxide::Bot; use teloxide::requests::Requester; use teloxide::types::CallbackQuery; -use crate::handlers::markups::callbacks::delete::delete_message_handler; -use crate::handlers::markups::callbacks::module::{module_option_handler, module_select_handler, module_toggle_handler, settings_back_handler}; -use crate::handlers::markups::callbacks::translate::handle_translate_callback; -use crate::handlers::markups::callbacks::whisper::handle_whisper_callback; +use teloxide::Bot; pub(crate) async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { let _config = Config::new().await; @@ -18,7 +18,9 @@ pub(crate) async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Resul let qq = q.clone(); let data = qq.data.clone().unwrap(); - if data.starts_with("delete_msg") { + if data.starts_with("settings_set:") { + settings_set_handler(bot, qq).await? + } else if data.starts_with("delete_msg") { delete_message_handler(bot, qq).await? } else if data.starts_with("summarize") { summarization_handler(bot, qq, &_config).await? @@ -37,7 +39,7 @@ pub(crate) async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Resul } else if data.starts_with("tr_") { handle_translate_callback(bot, qq, &_config).await? } else { - bot.answer_callback_query(qq.id).await.unwrap(); + bot.answer_callback_query(qq.id).await?; } // }); diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index f8cf0b2..d0ee267 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -2,3 +2,4 @@ pub(crate) mod commander; pub mod commands; pub mod markups; pub mod messages; +pub mod keyboards; diff --git a/src/util/errors.rs b/src/util/errors.rs index ffae168..6c801d1 100644 --- a/src/util/errors.rs +++ b/src/util/errors.rs @@ -1,4 +1,7 @@ +use std::string::FromUtf8Error; use crate::util::currency::converter::ConvertError; +use ccobalt::model::error::CobaltError; +use mongodb::bson; use oximod::_error::oximod_error::OxiModError; use redis; use teloxide::RequestError; @@ -30,6 +33,27 @@ pub enum MyError { #[error("Application Error: {0}")] Other(String), + + #[error("Bson serialization error: {0}")] + Bson(#[from] bson::ser::Error), + + #[error("Module '{0}' not found")] + ModuleNotFound(String), + + #[error("Uuid error: {0}")] + Uuid(#[from] uuid::Error), + + #[error("Cobalt error: {0}")] + CobaltError(#[from] CobaltError), + + #[error("BSON OID error: {0}")] + BsonOid(#[from] bson::oid::Error), + + #[error("Base64 decoding error: {0}")] + Base64(#[from] base64::DecodeError), + + #[error("UTF-8 conversion error: {0}")] + Utf8(#[from] FromUtf8Error), } impl From<&str> for MyError { From 8fbb4d08ae4a389833cbe07170c5d99f53c90cd8 Mon Sep 17 00:00:00 2001 From: nixxoq Date: Tue, 2 Sep 2025 09:16:12 +0300 Subject: [PATCH 26/66] fix(loader): filter also video_note --- src/config.rs | 6 +++-- src/loader.rs | 61 +++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 48 insertions(+), 19 deletions(-) diff --git a/src/config.rs b/src/config.rs index 297a684..056797a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,6 @@ use crate::db::redis::RedisCache; use crate::util::currency::converter::{CurrencyConverter, OutputLanguage}; -use crate::util::json::{read_json_config, JsonConfig}; +use crate::util::json::{JsonConfig, read_json_config}; use dotenv::dotenv; use redis::Client as RedisClient; use std::sync::Arc; @@ -36,7 +36,9 @@ impl Config { let cobalt_client = ccobalt::Client::builder() .base_url("https://cobalt-backend.canine.tools/") .api_key(cobalt_api_key) - .user_agent("Fulturate/6.6.6 (rust) (+https://github.com/weever1337/fulturate-rs)".to_string()) + .user_agent( + "Fulturate/6.6.6 (rust) (+https://github.com/weever1337/fulturate-rs)".to_string(), + ) .build() .expect("Failed to build cobalt client"); diff --git a/src/loader.rs b/src/loader.rs index 7e6a09e..0c007f7 100644 --- a/src/loader.rs +++ b/src/loader.rs @@ -26,11 +26,11 @@ use teloxide::types::{Chat, InputFile, MessageId, ParseMode, ThreadId, User}; use teloxide::update_listeners::Polling; use teloxide::utils::html; use teloxide::{ + Bot, dispatching::{Dispatcher, DpHandlerDescription, HandlerExt, UpdateFilterExt}, dptree, types::{Me, Update}, utils::command::BotCommands, - Bot, }; async fn root_handler( @@ -44,7 +44,8 @@ async fn root_handler( let result = logic.dispatch(deps).await; if let ControlFlow::Break(Err(err)) = result { - let error_handler_endpoint: Handler<'static, (), DpHandlerDescription> = dptree::endpoint(handle_error); + let error_handler_endpoint: Handler<'static, (), DpHandlerDescription> = + dptree::endpoint(handle_error); let error_deps = dptree::deps![Arc::new(err), update, config, bot]; let _ = error_handler_endpoint.dispatch(error_deps).await; } @@ -52,11 +53,7 @@ async fn root_handler( Ok(()) } -pub fn inline_query_handler() -> Handler< - 'static, - Result<(), MyError>, - DpHandlerDescription, -> { +pub fn inline_query_handler() -> Handler<'static, Result<(), MyError>, DpHandlerDescription> { dptree::entry() .branch(dptree::filter_async(is_currency_query).endpoint(handle_currency_inline)) .branch(dptree::filter_async(is_query_url).endpoint(handle_cobalt_inline)) @@ -77,6 +74,7 @@ async fn run_bot(config: Arc) -> Result<(), MyError> { .branch( Update::filter_message() .branch(Message::filter_text().endpoint(handle_currency)) + .branch(Message::filter_video_note().endpoint(handle_speech)) .branch(Message::filter_voice().endpoint(handle_speech)), ) .branch(Update::filter_callback_query().endpoint(callback_query_handlers)) @@ -136,22 +134,42 @@ pub async fn handle_error(err: Arc, update: Update, config: Arc writeln!(&mut message_text, "🚨 Новая ошибка!\n").unwrap(); if let Some(chat) = chat { - let title = chat.title().map_or("".to_string(), |t| format!(" ({})", html::escape(t))); - writeln!(&mut message_text, "В чате: {}{}", chat.id, title).unwrap(); + let title = chat + .title() + .map_or("".to_string(), |t| format!(" ({})", html::escape(t))); + writeln!( + &mut message_text, + "В чате: {}{}", + chat.id, title + ) + .unwrap(); } else { writeln!(&mut message_text, "В чате: (???)").unwrap(); } if let Some(user) = user { - let username = user.username.as_ref().map_or("".to_string(), |u| format!(" (@{})", u)); + let username = user + .username + .as_ref() + .map_or("".to_string(), |u| format!(" (@{})", u)); let full_name = html::escape(&user.full_name()); - writeln!(&mut message_text, "Вызвал: {} ({}){}", full_name, user.id, username).unwrap(); + writeln!( + &mut message_text, + "Вызвал: {} ({}){}", + full_name, user.id, username + ) + .unwrap(); } else { writeln!(&mut message_text, "Вызвал: (???)").unwrap(); } let error_name = short_error_name(&err); - writeln!(&mut message_text, "\nОшибка:\n
{}
", html::escape(&error_name)).unwrap(); + writeln!( + &mut message_text, + "\nОшибка:\n
{}
", + html::escape(&error_name) + ) + .unwrap(); let hashtag = "#error"; writeln!(&mut message_text, "\n{}", hashtag).unwrap(); @@ -159,19 +177,28 @@ pub async fn handle_error(err: Arc, update: Update, config: Arc let full_error_text = format!("{:#?}", err); let document = InputFile::memory(full_error_text.into_bytes()).file_name("error_details.txt"); - if let (Ok(log_chat_id), Ok(error_thread_id)) = (config.get_log_chat_id().parse::(), config.get_error_chat_thread_id().parse::()) { + if let (Ok(log_chat_id), Ok(error_thread_id)) = ( + config.get_log_chat_id().parse::(), + config.get_error_chat_thread_id().parse::(), + ) { let chat_id = ChatId(log_chat_id); - match bot.send_document(chat_id, document) + match bot + .send_document(chat_id, document) .caption(message_text) .parse_mode(ParseMode::Html) .reply_markup(delete_message_button(72)) .message_thread_id(ThreadId(MessageId(error_thread_id))) - .await { + .await + { Ok(_) => info!("Error report sent successfully to chat {}", log_chat_id), Err(e) => error!("Failed to send error report to chat {}: {}", log_chat_id, e), } } else { - error!("LOG_CHAT_ID ({}) or ERROR_CHAT_THREAD_ID ({}) is not a valid integer", config.get_log_chat_id(), config.get_error_chat_thread_id()); + error!( + "LOG_CHAT_ID ({}) or ERROR_CHAT_THREAD_ID ({}) is not a valid integer", + config.get_log_chat_id(), + config.get_error_chat_thread_id() + ); } -} \ No newline at end of file +} From 17205fdfc2cfb9314361470b67ccbd7d126bafa1 Mon Sep 17 00:00:00 2001 From: nixxoq Date: Tue, 2 Sep 2025 12:30:11 +0300 Subject: [PATCH 27/66] feat(config): use log::error! macros instead of except --- src/config.rs | 55 ++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/src/config.rs b/src/config.rs index 056797a..dab936f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,6 +2,7 @@ use crate::db::redis::RedisCache; use crate::util::currency::converter::{CurrencyConverter, OutputLanguage}; use crate::util::json::{JsonConfig, read_json_config}; use dotenv::dotenv; +use log::error; use redis::Client as RedisClient; use std::sync::Arc; use teloxide::prelude::*; @@ -28,9 +29,18 @@ impl Config { pub async fn new() -> Self { dotenv().ok(); - let bot_token = std::env::var("BOT_TOKEN").expect("BOT_TOKEN expected"); - let cobalt_api_key = std::env::var("COBALT_API_KEY").expect("COBALT_API_KEY expected"); - let version = std::env::var("CARGO_PKG_VERSION").expect("CARGO_PKG_VERSION expected"); + let Ok(bot_token) = std::env::var("BOT_TOKEN") else { + error!("Expected BOT_TOKEN env var"); + std::process::exit(1); + }; + let Ok(cobalt_api_key) = std::env::var("COBALT_API_KEY") else { + error!("COBALT_API_KEY expected"); + std::process::exit(1); + }; + let Ok(version) = std::env::var("CARGO_PKG_VERSION") else { + error!("CARGO_PKG_VERSION expected"); + std::process::exit(1); + }; let bot = Bot::new(bot_token); let cobalt_client = ccobalt::Client::builder() @@ -40,15 +50,24 @@ impl Config { "Fulturate/6.6.6 (rust) (+https://github.com/weever1337/fulturate-rs)".to_string(), ) .build() - .expect("Failed to build cobalt client"); + .unwrap_or_else(|_err| { + error!("Failed to build cobalt client"); + std::process::exit(1); + }); let owners: Vec = std::env::var("OWNERS") - .expect("OWNERS expected") + .unwrap_or_else(|_| { + error!("OWNERS expected"); + std::process::exit(1) + }) .split(',') .filter_map(|id| id.trim().parse().ok()) .collect(); - let log_chat_id = std::env::var("LOG_CHAT_ID").expect("LOG_CHAT_ID expected"); + let Ok(log_chat_id) = std::env::var("LOG_CHAT_ID") else { + error!("LOG_CHAT_ID expected"); + std::process::exit(1); + }; let error_chat_thread_id: String = std::env::var("ERROR_CHAT_THREAD_ID") .ok() .and_then(|s| s.parse().ok()) @@ -59,13 +78,25 @@ impl Config { .and_then(|s| s.parse().ok()) .unwrap_or(0.to_string()); - let json_config = read_json_config("config.json").expect("Unable to read config.json"); + let Ok(json_config) = read_json_config("config.json") else { + error!("Unable to read config.json"); + std::process::exit(1); + }; let currency_converter = Arc::new(CurrencyConverter::new(OutputLanguage::Russian).unwrap()); // TODO: get language from config - let mongodb_url = std::env::var("MONGODB_URL").expect("MONGODB_URL expected"); - - let redis_url = std::env::var("REDIS_URL").expect("REDIS_URL expected"); - let redis_client = RedisClient::open(redis_url).expect("Failed to open Redis client"); - + let Ok(mongodb_url) = std::env::var("MONGODB_URL") else { + error!("MONGODB_URL expected"); + std::process::exit(1); + }; + + let Ok(redis_url) = std::env::var("REDIS_URL") else { + error!("REDIS_URL expected"); + std::process::exit(1); + }; + + let Ok(redis_client) = RedisClient::open(redis_url.to_owned()) else { + error!("Failed to open Redis client"); + std::process::exit(1); + }; let redis_client = RedisCache::new(redis_client); Config { From 75939db7bd88258bc14968f712584081e3aa82e9 Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Thu, 4 Sep 2025 15:37:47 +0300 Subject: [PATCH 28/66] my brain is not braining --- Cargo.toml | 3 +- src/config.rs | 1 - src/db/redis.rs | 15 ++ src/handlers/commands/settings.rs | 2 +- src/handlers/keyboards.rs | 45 +++++- .../markups/callbacks/cobalter_pagination.rs | 88 ++++++++++ src/handlers/markups/callbacks/mod.rs | 3 +- src/handlers/markups/inlines/cobalter.rs | 150 +++++++++++------- src/handlers/markups/markuper.rs | 46 +++--- src/loader.rs | 5 +- src/util/transcription.rs | 2 +- 11 files changed, 268 insertions(+), 92 deletions(-) create mode 100644 src/handlers/markups/callbacks/cobalter_pagination.rs diff --git a/Cargo.toml b/Cargo.toml index c3735fa..db872dd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,4 +28,5 @@ redis = { version = "0.32.5", features = ["tokio-comp"] } redis-macros = { version = "0.5.6" } url = "2.5.4" base64 = "0.22.1" -mime = "0.3.17" \ No newline at end of file +mime = "0.3.17" +md5 = "0.7.0" \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index dab936f..11fd671 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,7 +6,6 @@ use log::error; use redis::Client as RedisClient; use std::sync::Arc; use teloxide::prelude::*; -use teloxide::types::Me; #[derive(Clone)] pub struct Config { diff --git a/src/db/redis.rs b/src/db/redis.rs index 3f7a86f..3c37a77 100644 --- a/src/db/redis.rs +++ b/src/db/redis.rs @@ -49,4 +49,19 @@ impl RedisCache { Ok(result.and_then(|s| serde_json::from_str(&s).ok())) } + + pub async fn set_url_hash_mapping( + &self, + url_hash: &str, + original_url: &str, + ttl_seconds: usize, + ) -> Result<(), RedisError> { + let key = format!("url_hash:{}", url_hash); + self.set(&key, &original_url.to_string(), ttl_seconds).await + } + + pub async fn get_url_by_hash(&self, url_hash: &str) -> Result, RedisError> { + let key = format!("url_hash:{}", url_hash); + self.get(&key).await + } } \ No newline at end of file diff --git a/src/handlers/commands/settings.rs b/src/handlers/commands/settings.rs index 0de4348..6f0e0e2 100644 --- a/src/handlers/commands/settings.rs +++ b/src/handlers/commands/settings.rs @@ -168,7 +168,7 @@ pub async fn settings_command_handler( let owner_type = if message.chat.is_private() { "user" } else { "group" }; - let settings = Settings::get_or_create(&owner_id, &owner_type).await?; + let settings = Settings::get_or_create(&owner_id, owner_type).await?; let keyboard = InlineKeyboardMarkup::new( settings diff --git a/src/handlers/keyboards.rs b/src/handlers/keyboards.rs index 738a90c..81feca5 100644 --- a/src/handlers/keyboards.rs +++ b/src/handlers/keyboards.rs @@ -8,16 +8,51 @@ pub fn make_single_download_keyboard(url: &str, format: &str) -> InlineKeyboardM } else { "Download as Video" }; - let encoded_url = general_purpose::STANDARD.encode(url); let callback_data = format!("download:{}:{}", format, encoded_url); - InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::callback( button_text.to_string(), callback_data, )]]) } +pub fn make_photo_pagination_keyboard( + url_hash: &str, + current_index: usize, + total_photos: usize, + user_id: u64, + original_url: &str, +) -> InlineKeyboardMarkup { + let mut row = Vec::new(); + + if current_index > 0 { + let prev_index = current_index - 1; + let cb_data = format!( + "cobalt_page:{}:{}:{}:{}", + user_id, url_hash, prev_index, total_photos + ); + row.push(InlineKeyboardButton::callback("⬅️", cb_data)); + } + + row.push(InlineKeyboardButton::url("URL", original_url.to_string().parse().unwrap())); + + row.push(InlineKeyboardButton::callback( + format!("{}/{}", current_index + 1, total_photos), + "cobalt_page:noop:0:0:0", + )); + + if current_index + 1 < total_photos { + let next_index = current_index + 1; + let cb_data = format!( + "cobalt_page:{}:{}:{}:{}", + user_id, url_hash, next_index, total_photos + ); + row.push(InlineKeyboardButton::callback("➡️", cb_data)); + } + + InlineKeyboardMarkup::new(vec![row]) +} + pub fn make_option_selection_keyboard( owner_type: &str, owner_id: &str, @@ -30,31 +65,25 @@ pub fn make_option_selection_keyboard( ("cobalt", "attribution") => vec!["true", "false"], _ => vec![], }; - let buttons = options.into_iter().map(|opt| { let display_text = match (option.key.as_str(), opt) { ("attribution", "true") => "On", ("attribution", "false") => "Off", _ => opt, }; - let display = if opt == option.value { format!("• {} •", display_text) } else { display_text.to_string() }; - let cb_data = format!( "settings_set:{}:{}:{}:{}:{}", owner_type, owner_id, module_key, option.key, opt ); InlineKeyboardButton::callback(display, cb_data) }); - let mut keyboard: Vec> = buttons.map(|b| vec![b]).collect(); - let back_cb = format!("module_select:{}:{}:{}", owner_type, owner_id, module_key); keyboard.push(vec![InlineKeyboardButton::callback("⬅️ Back", back_cb)]); - InlineKeyboardMarkup::new(keyboard) } \ No newline at end of file diff --git a/src/handlers/markups/callbacks/cobalter_pagination.rs b/src/handlers/markups/callbacks/cobalter_pagination.rs new file mode 100644 index 0000000..5a781c6 --- /dev/null +++ b/src/handlers/markups/callbacks/cobalter_pagination.rs @@ -0,0 +1,88 @@ +use crate::config::Config; +use crate::handlers::keyboards::make_photo_pagination_keyboard; +use crate::handlers::markups::inlines::cobalter::DownloadResult; +use crate::util::errors::MyError; +use std::sync::Arc; +use teloxide::prelude::*; +use teloxide::types::{CallbackQuery, InputFile, InputMedia, InputMediaPhoto, True}; +use teloxide::RequestError; +use url::Url; + +pub async fn handle_cobalt_pagination( + bot: Bot, + q: CallbackQuery, + config: Arc, +) -> Result<(), MyError> { + let CallbackQuery { id, data, message, inline_message_id, from, .. } = q; + let mut answer = bot.answer_callback_query(id.clone()); + + if let Some(data) = data { + let parts: Vec<&str> = data.split(':').collect(); + + if parts.len() >= 5 && parts[0] == "cobalt_page" { + if parts[1] == "noop" { + answer.await?; + return Ok(()); + } + + let Ok(original_user_id) = parts[1].parse::() else { + answer.text("Invalid user ID in callback.").await?; + return Ok(()); + }; + + if from.id.0 != original_user_id { + answer.text("Вы не можете использовать эти кнопки.").show_alert(true).await?; + return Ok(()); + } + + let url_hash = parts[2]; + let cache_key = format!("cobalt_cache:{}", url_hash); + + let index_res = parts.get(3).and_then(|s| s.parse::().ok()); + let total_res = parts.get(4).and_then(|s| s.parse::().ok()); + + if let (Some(index), Some(total)) = (index_res, total_res) { + let redis = config.get_redis_client(); + match redis.get::(&cache_key).await { + Ok(Some(DownloadResult::Photos { urls, original_url })) => { + if let Some(photo_url_str) = urls.get(index) { + if let Ok(url) = Url::parse(photo_url_str) { + let media = InputMedia::Photo(InputMediaPhoto::new(InputFile::url(url))); + let keyboard = make_photo_pagination_keyboard(&cache_key, index, total, original_user_id, &*original_url); + + let edit_result = if let Some(msg) = message { + bot.edit_message_media(msg.chat().id, msg.id(), media) + .reply_markup(keyboard) + .await + .map(|_| ()) + } else if let Some(inline_id) = inline_message_id { + bot.edit_message_media_inline(inline_id, media) + .reply_markup(keyboard) + .await + .map(|_| ()) + } else { + log::warn!("CallbackQuery has neither message nor inline_message_id"); + Ok(()) + }; + + if let Err(RequestError::Api(err)) = &edit_result { + if !err.to_string().contains("message is not modified") { + log::error!("Failed to edit message for pagination: {}", err); + answer = answer.text("Не удалось обновить фото."); + } + } + } + } + } + _ => { + answer = answer + .text("Извините, срок хранения этих фото истёк.") + .show_alert(true); + } + } + } + } + } + answer.await?; + Ok(()) +} \ No newline at end of file diff --git a/src/handlers/markups/callbacks/mod.rs b/src/handlers/markups/callbacks/mod.rs index 05f7898..92742e5 100644 --- a/src/handlers/markups/callbacks/mod.rs +++ b/src/handlers/markups/callbacks/mod.rs @@ -1,4 +1,5 @@ pub mod module; pub mod whisper; pub mod translate; -pub mod delete; \ No newline at end of file +pub mod delete; +pub mod cobalter_pagination; \ No newline at end of file diff --git a/src/handlers/markups/inlines/cobalter.rs b/src/handlers/markups/inlines/cobalter.rs index db0880c..aecdc56 100644 --- a/src/handlers/markups/inlines/cobalter.rs +++ b/src/handlers/markups/inlines/cobalter.rs @@ -1,27 +1,25 @@ use crate::config::Config; use crate::db::schemas::settings::Settings; use crate::db::schemas::SettingsRepo; +use crate::handlers::keyboards::make_photo_pagination_keyboard; use crate::util::errors::MyError; use ccobalt::model::request::{DownloadRequest, FilenameStyle, VideoQuality}; use ccobalt::model::response::DownloadResponse; -use mime::Mime; use once_cell::sync::Lazy; use regex::Regex; use serde::{Deserialize, Serialize}; use std::sync::Arc; use teloxide::prelude::*; -use teloxide::types::{ - InlineQuery, InlineQueryResult, InlineQueryResultArticle, InlineQueryResultPhoto, - InlineQueryResultVideo, InputMessageContent, InputMessageContentText, -}; +use teloxide::types::{ChosenInlineResult, InlineQuery, InlineQueryResult, InlineQueryResultArticle, InlineQueryResultPhoto, InputFile, InputMedia, InputMediaVideo, InputMessageContent, InputMessageContentText}; use teloxide::Bot; -const FALLBACK_THUMB_URL: &str = "https://i.imgur.com/424242.png"; // todo: change it to a real thumbnail - #[derive(Serialize, Deserialize, Debug, Clone)] pub enum DownloadResult { Video(String), - Photos(Vec), + Photos { + urls: Vec, + original_url: String, + }, } pub async fn resolve_download_url( @@ -38,7 +36,6 @@ pub async fn resolve_download_url( .map(|o| o.value.clone()) .unwrap_or_default() }; - let cobalt_req = DownloadRequest { url: url.to_string(), filename_style: Some(FilenameStyle::Pretty), @@ -51,9 +48,7 @@ pub async fn resolve_download_url( }), ..Default::default() }; - let response = client.resolve_download(&cobalt_req).await?; - match response { DownloadResponse::Error { error } => { log::error!("Cobalt API error: {:?}", error); @@ -65,15 +60,15 @@ pub async fn resolve_download_url( .filter(|item| item.kind == "photo") .map(|item| item.url.clone()) .collect(); - if !photo_urls.is_empty() { - return Ok(Some(DownloadResult::Photos(photo_urls))); + return Ok(Some(DownloadResult::Photos { + urls: photo_urls, + original_url: url.to_string(), + })); } - if let Some(video_item) = picker.iter().find(|item| item.kind == "video") { return Ok(Some(DownloadResult::Video(video_item.url.clone()))); } - Ok(None) } _ => { @@ -96,39 +91,47 @@ pub async fn is_query_url(inline_query: InlineQuery) -> bool { fn build_results_from_media( original_url: &str, media: DownloadResult, + url_hash: &str, + user_id: u64, ) -> Vec { match media { - DownloadResult::Video(url) => { - if let (Ok(video_url), Ok(thumb_url)) = (url.parse(), FALLBACK_THUMB_URL.parse()) { - let mime_type: Mime = "video/mp4".parse().unwrap(); - let video_result = InlineQueryResultVideo::new( - original_url, - video_url, - mime_type, - thumb_url, - "Video", - ); - vec![video_result.into()] - } else { - vec![] - } + DownloadResult::Video(_) => { + let result_id = format!("cobalt_video:{}", url_hash); + let article = InlineQueryResultArticle::new( + result_id, + "Скачать видео", + InputMessageContent::Text(InputMessageContentText::new("⏳ Получение видео...")), + ) + .description("Нажмите, чтобы отправить видео в чат"); + vec![article.into()] + } + DownloadResult::Photos { urls, .. } => { + let total = urls.len(); + urls.into_iter() + .enumerate() + .filter_map(|(i, url_str)| { + if let (Ok(photo_url), Ok(thumb_url)) = (url_str.parse(), url_str.parse()) { + let result_id = format!("{}_{}", original_url, i); + let mut photo_result = + InlineQueryResultPhoto::new(result_id, photo_url, thumb_url); + + if total > 1 { + let keyboard = make_photo_pagination_keyboard( + url_hash, + i, + total, + user_id, + original_url, + ); + photo_result = photo_result.reply_markup(keyboard); + } + Some(photo_result.into()) + } else { + None + } + }) + .collect() } - DownloadResult::Photos(urls) => urls - .into_iter() - .enumerate() - .filter_map(|(i, url_str)| { - if let (Ok(photo_url), Ok(thumb_url)) = (url_str.parse(), url_str.parse()) { - let photo_result = InlineQueryResultPhoto::new( - format!("{}_{}", original_url, i), - photo_url, - thumb_url, - ); - Some(photo_result.into()) - } else { - None - } - }) - .collect(), } } @@ -141,26 +144,28 @@ pub async fn handle_cobalt_inline( if !URL_REGEX.is_match(url) { return Ok(()); } - + let user_id = q.from.id.0; let user_id_str = q.from.id.to_string(); + + let url_hash_digest = md5::compute(url); + let url_hash = format!("{:x}", url_hash_digest); + let cache_key = format!("cobalt_cache:{}", url_hash); + let redis = config.get_redis_client(); - let cache_key = format!("cobalt_cache:{}", url); let results = if let Ok(Some(cached_result)) = redis.get::(&cache_key).await { - build_results_from_media(url, cached_result) + build_results_from_media(url, cached_result, &url_hash, user_id) } else { let settings = Settings::get_or_create(&user_id_str, "user").await?; let cobalt_client = config.get_cobalt_client(); let result = resolve_download_url(url, &settings, cobalt_client).await; - match result { Ok(Some(download_result)) => { - let ttl_24_hours = 86400; - let result_to_cache = download_result.clone(); - if let Err(e) = redis.set(&cache_key, &result_to_cache, ttl_24_hours).await { + let ttl_42_hours = 151_200; + if let Err(e) = redis.set(&cache_key, &download_result, ttl_42_hours).await { log::error!("Failed to cache cobalt result: {}", e); } - build_results_from_media(url, download_result) + build_results_from_media(url, download_result, &url_hash, user_id) } _ => { let error_article = InlineQueryResultArticle::new( @@ -175,8 +180,43 @@ pub async fn handle_cobalt_inline( } } }; + bot.answer_inline_query(q.id, results) + .cache_time(0) + .await?; + Ok(()) +} - bot.answer_inline_query(q.id, results).await?; - +pub async fn handle_chosen_inline_video( + bot: Bot, + chosen: ChosenInlineResult, + config: Arc, +) -> Result<(), MyError> { + if let Some(inline_message_id) = chosen.inline_message_id { + if let Some(url_hash) = chosen.result_id.strip_prefix("cobalt_video:") { + let redis = config.get_redis_client(); + let cache_key = format!("cobalt_cache:{}", url_hash); + + if let Ok(Some(DownloadResult::Video(video_url))) = + redis.get::(&cache_key).await + { + let media = InputMedia::Video(InputMediaVideo::new(InputFile::url(video_url.parse()?))); + if let Err(e) = bot.edit_message_media_inline(&inline_message_id, media).await + { + log::error!("Failed to edit message with video: {}", e); + bot.edit_message_text_inline( + inline_message_id, + "Ошибка: не удалось отправить видео.", + ) + .await?; + } + } else { + bot.edit_message_text_inline( + inline_message_id, + "Ошибка: видео не найдено в кэше или срок его хранения истёк.", + ) + .await?; + } + } + } Ok(()) } \ No newline at end of file diff --git a/src/handlers/markups/markuper.rs b/src/handlers/markups/markuper.rs index 02cdef2..f189d7e 100644 --- a/src/handlers/markups/markuper.rs +++ b/src/handlers/markups/markuper.rs @@ -1,47 +1,49 @@ use crate::config::Config; +use crate::handlers::markups::callbacks::cobalter_pagination::handle_cobalt_pagination; use crate::handlers::markups::callbacks::delete::delete_message_handler; -use crate::handlers::markups::callbacks::module::{module_option_handler, module_select_handler, module_toggle_handler, settings_back_handler, settings_set_handler}; +use crate::handlers::markups::callbacks::module::{ + module_option_handler, module_select_handler, module_toggle_handler, settings_back_handler, + settings_set_handler, +}; use crate::handlers::markups::callbacks::translate::handle_translate_callback; use crate::handlers::markups::callbacks::whisper::handle_whisper_callback; use crate::util::errors::MyError; -use crate::util::transcription::{ - back_handler, summarization_handler, -}; +use crate::util::transcription::{back_handler, summarization_handler}; +use std::sync::Arc; use teloxide::requests::Requester; use teloxide::types::CallbackQuery; use teloxide::Bot; pub(crate) async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { - let _config = Config::new().await; - - // tokio::spawn(async move { - let qq = q.clone(); - let data = qq.data.clone().unwrap(); + let config = Arc::new(Config::new().await); + if let Some(data) = &q.data { if data.starts_with("settings_set:") { - settings_set_handler(bot, qq).await? + settings_set_handler(bot, q).await? } else if data.starts_with("delete_msg") { - delete_message_handler(bot, qq).await? + delete_message_handler(bot, q).await? } else if data.starts_with("summarize") { - summarization_handler(bot, qq, &_config).await? + summarization_handler(bot, q, &config).await? } else if data.starts_with("back_to_full") { - back_handler(bot, qq, &_config).await? + back_handler(bot, q, &config).await? } else if data.starts_with("module_select:") { - module_select_handler(bot, qq).await? + module_select_handler(bot, q).await? } else if data.starts_with("module_toggle") { - module_toggle_handler(bot, qq).await? + module_toggle_handler(bot, q).await? } else if data.starts_with("module_opt:") { - module_option_handler(bot, qq).await? + module_option_handler(bot, q).await? } else if data.starts_with("settings_back:") { - settings_back_handler(bot, qq).await? + settings_back_handler(bot, q).await? } else if data.starts_with("whisper") { - handle_whisper_callback(bot, qq, &_config).await? + handle_whisper_callback(bot, q, &config).await? } else if data.starts_with("tr_") { - handle_translate_callback(bot, qq, &_config).await? + handle_translate_callback(bot, q, &config).await? + } else if data.starts_with("cobalt_page:") { + handle_cobalt_pagination(bot, q, config).await? } else { - bot.answer_callback_query(qq.id).await?; + bot.answer_callback_query(q.id).await?; } - // }); + } Ok(()) -} +} \ No newline at end of file diff --git a/src/loader.rs b/src/loader.rs index 0c007f7..75e0dd4 100644 --- a/src/loader.rs +++ b/src/loader.rs @@ -1,4 +1,4 @@ -use crate::handlers::markups::inlines::cobalter::{handle_cobalt_inline, is_query_url}; +use crate::handlers::markups::inlines::cobalter::{handle_chosen_inline_video, handle_cobalt_inline, is_query_url}; use crate::handlers::markups::inlines::currency::handle_currency_inline; use crate::handlers::markups::inlines::whisper::{handle_whisper_inline, is_whisper_query}; use crate::handlers::messages::messager::{handle_currency, handle_speech}; @@ -79,7 +79,8 @@ async fn run_bot(config: Arc) -> Result<(), MyError> { ) .branch(Update::filter_callback_query().endpoint(callback_query_handlers)) .branch(Update::filter_my_chat_member().endpoint(handle_bot_added)) - .branch(Update::filter_inline_query().branch(inline_query_handler())); + .branch(Update::filter_inline_query().branch(inline_query_handler()) + .branch(Update::filter_chosen_inline_result().endpoint(handle_chosen_inline_video))); let me = bot.get_me().await?; info!("Bot name: {:?}", me.username()); diff --git a/src/util/transcription.rs b/src/util/transcription.rs index 5b9b170..5cecf96 100644 --- a/src/util/transcription.rs +++ b/src/util/transcription.rs @@ -47,7 +47,7 @@ async fn get_cached( if processed_parts.is_empty() || processed_parts[0].contains("Не удалось преобразовать") { - let error_message = processed_parts.get(0).cloned().unwrap_or_default(); + let error_message = processed_parts.first().cloned().unwrap_or_default(); return Err(MyError::Other(error_message)); } From aa4300457c0eab512ca61adfe073a8348c6e4fa5 Mon Sep 17 00:00:00 2001 From: nixxoq Date: Sat, 6 Sep 2025 15:58:45 +0300 Subject: [PATCH 29/66] feat(whisper): store recent recipients to redis cache fix(whisper): generate unique hash for every recipient to avoid RESULT_ID_DUPLICATE error chore(redis): add dead-code attributes to unused functions --- src/db/redis.rs | 8 +- src/handlers/markups/callbacks/whisper.rs | 29 +++-- src/handlers/markups/inlines/whisper.rs | 137 ++++++++++++++-------- 3 files changed, 108 insertions(+), 66 deletions(-) diff --git a/src/db/redis.rs b/src/db/redis.rs index 3c37a77..9aefdea 100644 --- a/src/db/redis.rs +++ b/src/db/redis.rs @@ -1,5 +1,5 @@ use redis::{AsyncCommands, Client, RedisError}; -use serde::{de::DeserializeOwned, Serialize}; +use serde::{Serialize, de::DeserializeOwned}; #[derive(Clone)] pub struct RedisCache { @@ -35,6 +35,7 @@ impl RedisCache { Ok(()) } + #[allow(dead_code)] pub async fn get_and_delete( &self, key: &str, @@ -50,6 +51,7 @@ impl RedisCache { Ok(result.and_then(|s| serde_json::from_str(&s).ok())) } + #[allow(dead_code)] pub async fn set_url_hash_mapping( &self, url_hash: &str, @@ -60,8 +62,10 @@ impl RedisCache { self.set(&key, &original_url.to_string(), ttl_seconds).await } + // todo: remove them on pre-release stage + #[allow(dead_code)] pub async fn get_url_by_hash(&self, url_hash: &str) -> Result, RedisError> { let key = format!("url_hash:{}", url_hash); self.get(&key).await } -} \ No newline at end of file +} diff --git a/src/handlers/markups/callbacks/whisper.rs b/src/handlers/markups/callbacks/whisper.rs index cf37009..7807908 100644 --- a/src/handlers/markups/callbacks/whisper.rs +++ b/src/handlers/markups/callbacks/whisper.rs @@ -1,10 +1,10 @@ use super::super::inlines::whisper::Whisper; use crate::config::Config; use crate::util::errors::MyError; +use teloxide::Bot; use teloxide::payloads::{AnswerCallbackQuerySetters, EditMessageTextSetters}; use teloxide::prelude::{CallbackQuery, Requester}; use teloxide::types::InlineKeyboardMarkup; -use teloxide::Bot; pub async fn handle_whisper_callback( bot: Bot, @@ -40,13 +40,18 @@ pub async fn handle_whisper_callback( let is_sender = user.id.0 == whisper.sender_id; let is_recipient = whisper.recipients.iter().any(|r| { - if r.id != 0 { - r.id == user.id.0 - } else if let Some(uname) = &r.username { - uname == &username - } else { - false + if r.id == Some(user.id.0) { + return true; + } + + if let (Some(recipient_username), Some(username)) = ( + &r.username, + &user.username.as_ref().map(|s| s.to_lowercase()), + ) && recipient_username == username + { + return true; } + false }); if !is_sender && !is_recipient { @@ -67,9 +72,7 @@ pub async fn handle_whisper_callback( } "forget" => { config.get_redis_client().delete(&redis_key).await?; - bot.answer_callback_query(q.id) - .text("Шепот забыт.") - .await?; + bot.answer_callback_query(q.id).text("Шепот забыт.").await?; if let Some(message) = q.message { bot.edit_message_text( @@ -77,12 +80,12 @@ pub async fn handle_whisper_callback( message.id(), format!("🤫 Шепот от {} был забыт.", whisper.sender_first_name), ) - .reply_markup(InlineKeyboardMarkup::new(vec![vec![]])) - .await?; + .reply_markup(InlineKeyboardMarkup::new(vec![vec![]])) + .await?; } } _ => {} } Ok(()) -} \ No newline at end of file +} diff --git a/src/handlers/markups/inlines/whisper.rs b/src/handlers/markups/inlines/whisper.rs index d533785..a3a2dcd 100644 --- a/src/handlers/markups/inlines/whisper.rs +++ b/src/handlers/markups/inlines/whisper.rs @@ -1,19 +1,23 @@ use serde::{Deserialize, Serialize}; +use std::hash::{DefaultHasher, Hash, Hasher}; use std::sync::Arc; -use teloxide::payloads::AnswerInlineQuerySetters; -use teloxide::prelude::Requester; -use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup, InlineQuery, InlineQueryResult, InlineQueryResultArticle, InputMessageContent, InputMessageContentText, ParseMode}; use teloxide::Bot; +use teloxide::payloads::AnswerInlineQuerySetters; +use teloxide::prelude::{Requester, UserId}; +use teloxide::types::{ + InlineKeyboardButton, InlineKeyboardMarkup, InlineQuery, InlineQueryResult, + InlineQueryResultArticle, InputMessageContent, InputMessageContentText, ParseMode, +}; use uuid::Uuid; use crate::config::Config; use crate::util::errors::MyError; -use log::{error}; +use log::error; use teloxide::utils::html; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct Recipient { - pub id: u64, + pub id: Option, pub first_name: String, pub username: Option, } @@ -26,6 +30,14 @@ pub struct Whisper { pub recipients: Vec, } +fn generate_recipient_hash(person: &Recipient) -> String { + let mut s = DefaultHasher::new(); + person.id.hash(&mut s); + person.username.hash(&mut s); + + format!("{:x}", s.finish()) +} + fn parse_query(query: &str) -> (String, Vec) { let mut recipients = Vec::new(); let mut content_end_index = query.len(); @@ -60,7 +72,12 @@ async fn update_recents( .unwrap_or_default(); for new_recipient in new_recipients.iter().rev() { - recents.retain(|r| r.id != new_recipient.id); + recents.retain(|r| { + let is_same_id = new_recipient.id.is_some() && r.id == new_recipient.id; + let is_same_username = + new_recipient.username.is_some() && r.username == new_recipient.username; + !is_same_id && !is_same_username + }); recents.insert(0, new_recipient.clone()); } @@ -88,7 +105,9 @@ pub async fn handle_whisper_inline( ) .description("Пример: Привет! @username 123456789"); - bot.answer_inline_query(q.id, vec![InlineQueryResult::Article(article)]).cache_time(5).await?; + bot.answer_inline_query(q.id, vec![InlineQueryResult::Article(article)]) + .cache_time(5) + .await?; return Ok(()); } @@ -101,37 +120,39 @@ pub async fn handle_whisper_inline( if recipient_identifiers.is_empty() { let redis_key = format!("whisper_recents:{}", q.from.id.0); - let _recents: Option> = config.get_redis_client().get(&redis_key).await?; + let recents: Option> = config.get_redis_client().get(&redis_key).await?; let mut results = Vec::new(); - // if let Some(recents) = recents { // todo: fix this. i'm lazy - // for person in recents { - // let query_filler = person - // .username - // .as_ref() - // .map(|u| format!("@{}", u)) - // .unwrap_or_else(|| person.id.to_string()); - // - // let keyboard = InlineKeyboardMarkup::new(vec![vec![ - // InlineKeyboardButton::switch_inline_query_current_chat( - // format!("Выбрать {}", person.first_name), - // format!("{} ", query_filler), - // ), - // ]]); - // - // let article = InlineQueryResultArticle::new( - // format!("recent_{}", person.id), - // format!("✍️ Написать {}", person.first_name), - // InputMessageContent::Text(InputMessageContentText::new(format!( - // "Нажмите кнопку ниже, чтобы начать шепот для {}", - // person.first_name - // ))), - // ) - // .description("Нажмите кнопку ниже, чтобы выбрать этого пользователя") - // .reply_markup(keyboard); - // results.push(InlineQueryResult::Article(article)); - // } - // } + if let Some(recents) = recents { + for person in recents { + let query_filler = if let Some(u) = &person.username { + format!("@{}", u) + } else if let Some(id) = person.id { + id.to_string() + } else { + continue; + }; + + let keyboard = InlineKeyboardMarkup::new(vec![vec![ + InlineKeyboardButton::switch_inline_query_current_chat( + format!("Выбрать {}", person.first_name), + format!("{} {} ", q.query.trim(), query_filler), + ), + ]]); + + let article = InlineQueryResultArticle::new( + format!("recent_{}", generate_recipient_hash(&person)), + format!("✍️ Написать {}", person.first_name), + InputMessageContent::Text(InputMessageContentText::new(format!( + "Нажмите кнопку ниже, чтобы начать шепот для {}", + person.first_name + ))), + ) + .description("Нажмите кнопку ниже, чтобы выбрать этого пользователя") + .reply_markup(keyboard); + results.push(InlineQueryResult::Article(article)); + } + } let article = InlineQueryResultArticle::new( "whisper_no_recipients", @@ -140,7 +161,7 @@ pub async fn handle_whisper_inline( "Укажите получателей, добавив их юзернеймы (@username) или ID в конце сообщения.", )), ) - .description("Вы не указали получателя."); + .description("Вы не указали получателя."); results.push(InlineQueryResult::Article(article)); bot.answer_inline_query(q.id, results) @@ -152,14 +173,15 @@ pub async fn handle_whisper_inline( let mut recipients: Vec = Vec::new(); for identifier in &recipient_identifiers { if identifier.starts_with('@') { + let username = identifier[1..].to_string(); recipients.push(Recipient { - id: 0, - first_name: identifier[1..].to_string(), - username: Some(identifier[1..].to_string()), + id: None, + first_name: username.clone(), + username: Some(username.to_lowercase()), }); } else if let Ok(id) = identifier.parse::() { recipients.push(Recipient { - id, + id: Some(id), first_name: format!("{}", id), username: None, }); @@ -167,14 +189,14 @@ pub async fn handle_whisper_inline( } recipients.push(Recipient { - id: sender.id.0, + id: Some(sender.id.0), first_name: sender.first_name.clone(), username: sender.username.clone(), }); let recipients_for_recents: Vec = recipients .iter() - .filter(|r| r.id != sender.id.0) + .filter(|r| r.id != Some(sender.id.0)) .cloned() .collect(); @@ -193,21 +215,34 @@ pub async fn handle_whisper_inline( }; let redis_key = format!("whisper:{}", whisper_id); - config.get_redis_client().set(&redis_key, &whisper, 86400).await?; + config + .get_redis_client() + .set(&redis_key, &whisper, 86400) + .await?; let keyboard = InlineKeyboardMarkup::new(vec![vec![ InlineKeyboardButton::callback("👁️ Прочитать", format!("whisper_read_{}", whisper_id)), InlineKeyboardButton::callback("🗑️ Забыть", format!("whisper_forget_{}", whisper_id)), ]]); - let recipients_str = whisper.recipients + let recipients_str = whisper + .recipients .iter() - .filter(|r| r.id != sender.id.0) - .map(|r| format!("{}", r.id, html::escape(&r.first_name))) + .filter(|r| r.id != Some(sender.id.0)) + .map(|r| { + if let Some(id) = r.id { + html::user_mention(UserId(id), &r.first_name) + } else { + format!("@{}", html::escape(&r.first_name)) + } + }) .collect::>() .join(", "); - let message_text = format!("🤫 {} шепчет для {}", whisper.sender_first_name, recipients_str); + let message_text = format!( + "🤫 {} шепчет для {}", + whisper.sender_first_name, recipients_str + ); let article = InlineQueryResultArticle::new( whisper_id, @@ -216,8 +251,8 @@ pub async fn handle_whisper_inline( InputMessageContentText::new(message_text).parse_mode(ParseMode::Html), ), ) - .description(format!("Сообщение: {}", content)) - .reply_markup(keyboard); + .description(format!("Сообщение: {}", content)) + .reply_markup(keyboard); if let Err(e) = bot .answer_inline_query(q.id, vec![InlineQueryResult::Article(article)]) @@ -232,4 +267,4 @@ pub async fn handle_whisper_inline( pub async fn is_whisper_query(_q: InlineQuery) -> bool { true -} \ No newline at end of file +} From 2951cdcde9ca4dd306dedd808d6e19187f4c61f5 Mon Sep 17 00:00:00 2001 From: nixxoq Date: Sat, 6 Sep 2025 15:59:16 +0300 Subject: [PATCH 30/66] chore(translate): clippy messing --- src/handlers/commands/translate.rs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/handlers/commands/translate.rs b/src/handlers/commands/translate.rs index 58ed64e..db4c337 100644 --- a/src/handlers/commands/translate.rs +++ b/src/handlers/commands/translate.rs @@ -115,10 +115,7 @@ pub async fn translate_handler( .await .unwrap(); - let response = format!( - "
{}\n
", - res, - ); + let response = format!("
{}\n
", res,); let lang_display_name = SUPPORTED_LANGUAGES .iter() @@ -126,10 +123,8 @@ pub async fn translate_handler( .map(|(_, name)| *name) .unwrap_or(&target_lang); - let switch_lang_button = InlineKeyboardButton::callback( - lang_display_name.to_string(), - "tr_show_langs".to_string(), - ); + let switch_lang_button = + InlineKeyboardButton::callback(lang_display_name.to_string(), "tr_show_langs".to_string()); let mut keyboard = delete_message_button(user.id.0); if let Some(first_row) = keyboard.inline_keyboard.get_mut(0) { @@ -202,4 +197,4 @@ pub fn normalize_language_code(lang: &str) -> String { "ja" | "japanese" | "японский" => "ja".to_string(), _ => lang.to_lowercase(), } -} \ No newline at end of file +} From ef0eb07802135a30359d12dfab605b1334e75161 Mon Sep 17 00:00:00 2001 From: nixxoq Date: Sat, 6 Sep 2025 15:59:47 +0300 Subject: [PATCH 31/66] chore(currency & loader): import messin' --- src/handlers/markups/inlines/currency.rs | 20 +++++----- src/loader.rs | 51 ++++++++++++------------ 2 files changed, 36 insertions(+), 35 deletions(-) diff --git a/src/handlers/markups/inlines/currency.rs b/src/handlers/markups/inlines/currency.rs index e98b6c5..6461767 100644 --- a/src/handlers/markups/inlines/currency.rs +++ b/src/handlers/markups/inlines/currency.rs @@ -1,17 +1,17 @@ -use crate::config::Config; -use crate::db::schemas::user::User; -use crate::util::errors::MyError; +use crate::{config::Config, db::schemas::user::User, util::errors::MyError}; use log::{debug, error}; use mongodb::bson::doc; use oximod::Model; use std::sync::Arc; -use teloxide::Bot; -use teloxide::payloads::AnswerInlineQuerySetters; -use teloxide::prelude::Requester; -use teloxide::types::{ - Chat, ChatId, ChatKind, ChatPrivate, InlineKeyboardButton, InlineKeyboardMarkup, InlineQuery, - InlineQueryResult, InlineQueryResultArticle, InputMessageContent, InputMessageContentText, Me, - ParseMode, +use teloxide::{ + Bot, + payloads::AnswerInlineQuerySetters, + prelude::Requester, + types::{ + Chat, ChatId, ChatKind, ChatPrivate, InlineKeyboardButton, InlineKeyboardMarkup, + InlineQuery, InlineQueryResult, InlineQueryResultArticle, InputMessageContent, + InputMessageContentText, Me, ParseMode, + }, }; use uuid::Uuid; diff --git a/src/loader.rs b/src/loader.rs index 75e0dd4..760b74c 100644 --- a/src/loader.rs +++ b/src/loader.rs @@ -1,36 +1,38 @@ -use crate::handlers::markups::inlines::cobalter::{handle_chosen_inline_video, handle_cobalt_inline, is_query_url}; -use crate::handlers::markups::inlines::currency::handle_currency_inline; -use crate::handlers::markups::inlines::whisper::{handle_whisper_inline, is_whisper_query}; -use crate::handlers::messages::messager::{handle_currency, handle_speech}; -use crate::util::currency::converter::is_currency_query; -use crate::util::inline::delete_message_button; use crate::{ config::Config, handlers::{ - commander::command_handlers, markups::markuper::callback_query_handlers, - messages::chat::handle_bot_added, + commander::command_handlers, + markups::inlines::{ + cobalter::{handle_cobalt_inline, is_query_url}, + currency::handle_currency_inline, + whisper::{handle_whisper_inline, is_whisper_query}, + }, + markups::markuper::callback_query_handlers, + messages::{ + chat::handle_bot_added, + messager::{handle_currency, handle_speech}, + }, + }, + util::{ + currency::converter::is_currency_query, enums::Command, errors::MyError, + inline::delete_message_button, }, - util::{enums::Command, errors::MyError}, }; use log::{error, info}; use oximod::set_global_client; -use std::convert::Infallible; -use std::fmt::Write; -use std::ops::ControlFlow; -use std::sync::Arc; -use teloxide::dispatching::MessageFilterExt; -use teloxide::error_handlers::LoggingErrorHandler; -use teloxide::payloads::SendDocumentSetters; -use teloxide::prelude::{ChatId, Handler, Message, Requester}; -use teloxide::types::{Chat, InputFile, MessageId, ParseMode, ThreadId, User}; -use teloxide::update_listeners::Polling; -use teloxide::utils::html; +use std::{convert::Infallible, fmt::Write, ops::ControlFlow, sync::Arc}; use teloxide::{ Bot, - dispatching::{Dispatcher, DpHandlerDescription, HandlerExt, UpdateFilterExt}, + dispatching::{ + Dispatcher, DpHandlerDescription, HandlerExt, MessageFilterExt, UpdateFilterExt, + }, dptree, - types::{Me, Update}, - utils::command::BotCommands, + error_handlers::LoggingErrorHandler, + payloads::SendDocumentSetters, + prelude::{ChatId, Handler, Message, Requester}, + types::{Chat, InputFile, Me, MessageId, ParseMode, ThreadId, Update, User}, + update_listeners::Polling, + utils::{command::BotCommands, html}, }; async fn root_handler( @@ -79,8 +81,7 @@ async fn run_bot(config: Arc) -> Result<(), MyError> { ) .branch(Update::filter_callback_query().endpoint(callback_query_handlers)) .branch(Update::filter_my_chat_member().endpoint(handle_bot_added)) - .branch(Update::filter_inline_query().branch(inline_query_handler()) - .branch(Update::filter_chosen_inline_result().endpoint(handle_chosen_inline_video))); + .branch(Update::filter_inline_query().branch(inline_query_handler())); let me = bot.get_me().await?; info!("Bot name: {:?}", me.username()); From 4f55d4c8dd83b35ac784596ef8af4e3ffd7f2b1a Mon Sep 17 00:00:00 2001 From: nixxoq Date: Sat, 6 Sep 2025 16:10:42 +0300 Subject: [PATCH 32/66] feat(cobalter): read changes in description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feat(cobalter): handle `DownloadResponse::Tunnel` and `DownloadResponse::Redirect` responses - fix(cobalter): fixed the issue with sending video by reverting spaghetti code from my patch 😪 - feat(cobalter): added pagination for photos (especially this piece of shit, I spent two days to fix the pagination issue from the previous commit, exists in TikTok). So you can select any other photo you want to view without reusing bot's inline query - feat(cobalter): you can now select photos to send from the inline query (assuming I fixed the previous, ok?) and yes... I refactored whole pagination handler (thanks gemma) because I don't like previous way --- src/handlers/keyboards.rs | 36 ++-- .../markups/callbacks/cobalt_pagination.rs | 116 +++++++++++++ .../markups/callbacks/cobalter_pagination.rs | 88 ---------- src/handlers/markups/callbacks/mod.rs | 6 +- src/handlers/markups/inlines/cobalter.rs | 164 +++++++++++------- src/handlers/markups/markuper.rs | 34 ++-- src/handlers/markups/mod.rs | 2 +- 7 files changed, 255 insertions(+), 191 deletions(-) create mode 100644 src/handlers/markups/callbacks/cobalt_pagination.rs delete mode 100644 src/handlers/markups/callbacks/cobalter_pagination.rs diff --git a/src/handlers/keyboards.rs b/src/handlers/keyboards.rs index 81feca5..1d5f39d 100644 --- a/src/handlers/keyboards.rs +++ b/src/handlers/keyboards.rs @@ -1,18 +1,11 @@ use crate::db::schemas::settings::ModuleOption; -use base64::{engine::general_purpose, Engine as _}; +use base64::{Engine as _, engine::general_purpose}; use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup}; -pub fn make_single_download_keyboard(url: &str, format: &str) -> InlineKeyboardMarkup { - let button_text = if format == "audio" { - "Download as Audio" - } else { - "Download as Video" - }; - let encoded_url = general_purpose::STANDARD.encode(url); - let callback_data = format!("download:{}:{}", format, encoded_url); - InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::callback( - button_text.to_string(), - callback_data, +pub fn make_single_url_keyboard(url: &str) -> InlineKeyboardMarkup { + InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::url( + "URL", + url.parse().unwrap(), )]]) } @@ -28,29 +21,30 @@ pub fn make_photo_pagination_keyboard( if current_index > 0 { let prev_index = current_index - 1; let cb_data = format!( - "cobalt_page:{}:{}:{}:{}", - user_id, url_hash, prev_index, total_photos + "cobalt:{}:{}:{}:{}", + user_id, prev_index, total_photos, url_hash ); row.push(InlineKeyboardButton::callback("⬅️", cb_data)); } - row.push(InlineKeyboardButton::url("URL", original_url.to_string().parse().unwrap())); - row.push(InlineKeyboardButton::callback( format!("{}/{}", current_index + 1, total_photos), - "cobalt_page:noop:0:0:0", + "cobalt:noop", )); if current_index + 1 < total_photos { let next_index = current_index + 1; let cb_data = format!( - "cobalt_page:{}:{}:{}:{}", - user_id, url_hash, next_index, total_photos + "cobalt:{}:{}:{}:{}", + user_id, next_index, total_photos, url_hash ); row.push(InlineKeyboardButton::callback("➡️", cb_data)); } - InlineKeyboardMarkup::new(vec![row]) + InlineKeyboardMarkup::new(vec![row]).append_row(vec![InlineKeyboardButton::url( + "URL", + original_url.to_string().parse().unwrap(), + )]) } pub fn make_option_selection_keyboard( @@ -86,4 +80,4 @@ pub fn make_option_selection_keyboard( let back_cb = format!("module_select:{}:{}:{}", owner_type, owner_id, module_key); keyboard.push(vec![InlineKeyboardButton::callback("⬅️ Back", back_cb)]); InlineKeyboardMarkup::new(keyboard) -} \ No newline at end of file +} diff --git a/src/handlers/markups/callbacks/cobalt_pagination.rs b/src/handlers/markups/callbacks/cobalt_pagination.rs new file mode 100644 index 0000000..9a82dcc --- /dev/null +++ b/src/handlers/markups/callbacks/cobalt_pagination.rs @@ -0,0 +1,116 @@ +use crate::config::Config; +use crate::handlers::keyboards::make_photo_pagination_keyboard; +use crate::handlers::markups::inlines::cobalter::DownloadResult; +use crate::util::errors::MyError; +use std::sync::Arc; +use teloxide::prelude::*; +use teloxide::types::{CallbackQuery, InputFile, InputMedia, InputMediaPhoto}; +use teloxide::{ApiError, RequestError}; + +struct PagingData<'a> { + original_user_id: u64, + index: usize, + total: usize, + url_hash: &'a str, +} + +impl<'a> PagingData<'a> { + fn from_parts(parts: &'a [&'a str]) -> Option { + if parts.len() < 5 { + return None; + } + Some(Self { + original_user_id: parts.get(1)?.parse().ok()?, + index: parts.get(2)?.parse().ok()?, + total: parts.get(3)?.parse().ok()?, + url_hash: parts.get(4)?, + }) + } +} + +pub async fn handle_cobalt_pagination( + bot: Bot, + q: CallbackQuery, + config: Arc, +) -> Result<(), MyError> { + let Some(data) = q.data else { return Ok(()) }; + let parts: Vec<&str> = data.split(':').collect(); + + if parts.get(1) == Some(&"noop") { + bot.answer_callback_query(q.id).await?; + return Ok(()); + } + + let Some(paging_data) = PagingData::from_parts(&parts) else { + log::warn!("Invalid callback data format: {}", data); + return Ok(()); + }; + + if q.from.id.0 != paging_data.original_user_id { + bot.answer_callback_query(q.id) + .text("Вы не можете использовать эти кнопки.") + .show_alert(true) + .await?; + return Ok(()); + } + + let cache_key = format!("cobalt_cache:{}", paging_data.url_hash); + let redis = config.get_redis_client(); + let Ok(Some(DownloadResult::Photos { urls, original_url })) = redis.get(&cache_key).await + else { + bot.answer_callback_query(q.id) + .text("Извините, срок хранения этих фото истёк.") + .show_alert(true) + .await?; + return Ok(()); + }; + + let Some(photo_url_str) = urls.get(paging_data.index) else { + log::error!( + "Pagination index {} is out of bounds for len {}", + paging_data.index, + urls.len() + ); + return Ok(()); + }; + let Ok(url) = photo_url_str.parse() else { + log::error!("Failed to parse photo URL: {}", photo_url_str); + return Ok(()); + }; + + let media = InputMedia::Photo(InputMediaPhoto::new(InputFile::url(url))); + let keyboard = make_photo_pagination_keyboard( + paging_data.url_hash, + paging_data.index, + paging_data.total, + paging_data.original_user_id, + &original_url, + ); + + let edit_result = if let Some(msg) = q.message { + bot.edit_message_media(msg.chat().id, msg.id(), media) + .reply_markup(keyboard) + .await + .map(|_| ()) + } else if let Some(inline_id) = q.inline_message_id { + bot.edit_message_media_inline(inline_id, media) + .reply_markup(keyboard) + .await + .map(|_| ()) + } else { + return Ok(()); + }; + + if let Err(e) = edit_result { + if !matches!(e, RequestError::Api(ApiError::MessageNotModified)) { + log::error!("Failed to edit message for pagination: {}", e); + bot.answer_callback_query(q.id.clone()) + .text("Не удалось обновить фото.") + .await?; + } + } + + bot.answer_callback_query(q.id).await?; + + Ok(()) +} diff --git a/src/handlers/markups/callbacks/cobalter_pagination.rs b/src/handlers/markups/callbacks/cobalter_pagination.rs deleted file mode 100644 index 5a781c6..0000000 --- a/src/handlers/markups/callbacks/cobalter_pagination.rs +++ /dev/null @@ -1,88 +0,0 @@ -use crate::config::Config; -use crate::handlers::keyboards::make_photo_pagination_keyboard; -use crate::handlers::markups::inlines::cobalter::DownloadResult; -use crate::util::errors::MyError; -use std::sync::Arc; -use teloxide::prelude::*; -use teloxide::types::{CallbackQuery, InputFile, InputMedia, InputMediaPhoto, True}; -use teloxide::RequestError; -use url::Url; - -pub async fn handle_cobalt_pagination( - bot: Bot, - q: CallbackQuery, - config: Arc, -) -> Result<(), MyError> { - let CallbackQuery { id, data, message, inline_message_id, from, .. } = q; - let mut answer = bot.answer_callback_query(id.clone()); - - if let Some(data) = data { - let parts: Vec<&str> = data.split(':').collect(); - - if parts.len() >= 5 && parts[0] == "cobalt_page" { - if parts[1] == "noop" { - answer.await?; - return Ok(()); - } - - let Ok(original_user_id) = parts[1].parse::() else { - answer.text("Invalid user ID in callback.").await?; - return Ok(()); - }; - - if from.id.0 != original_user_id { - answer.text("Вы не можете использовать эти кнопки.").show_alert(true).await?; - return Ok(()); - } - - let url_hash = parts[2]; - let cache_key = format!("cobalt_cache:{}", url_hash); - - let index_res = parts.get(3).and_then(|s| s.parse::().ok()); - let total_res = parts.get(4).and_then(|s| s.parse::().ok()); - - if let (Some(index), Some(total)) = (index_res, total_res) { - let redis = config.get_redis_client(); - match redis.get::(&cache_key).await { - Ok(Some(DownloadResult::Photos { urls, original_url })) => { - if let Some(photo_url_str) = urls.get(index) { - if let Ok(url) = Url::parse(photo_url_str) { - let media = InputMedia::Photo(InputMediaPhoto::new(InputFile::url(url))); - let keyboard = make_photo_pagination_keyboard(&cache_key, index, total, original_user_id, &*original_url); - - let edit_result = if let Some(msg) = message { - bot.edit_message_media(msg.chat().id, msg.id(), media) - .reply_markup(keyboard) - .await - .map(|_| ()) - } else if let Some(inline_id) = inline_message_id { - bot.edit_message_media_inline(inline_id, media) - .reply_markup(keyboard) - .await - .map(|_| ()) - } else { - log::warn!("CallbackQuery has neither message nor inline_message_id"); - Ok(()) - }; - - if let Err(RequestError::Api(err)) = &edit_result { - if !err.to_string().contains("message is not modified") { - log::error!("Failed to edit message for pagination: {}", err); - answer = answer.text("Не удалось обновить фото."); - } - } - } - } - } - _ => { - answer = answer - .text("Извините, срок хранения этих фото истёк.") - .show_alert(true); - } - } - } - } - } - answer.await?; - Ok(()) -} \ No newline at end of file diff --git a/src/handlers/markups/callbacks/mod.rs b/src/handlers/markups/callbacks/mod.rs index 92742e5..70d1327 100644 --- a/src/handlers/markups/callbacks/mod.rs +++ b/src/handlers/markups/callbacks/mod.rs @@ -1,5 +1,5 @@ +pub mod cobalt_pagination; +pub mod delete; pub mod module; -pub mod whisper; pub mod translate; -pub mod delete; -pub mod cobalter_pagination; \ No newline at end of file +pub mod whisper; diff --git a/src/handlers/markups/inlines/cobalter.rs b/src/handlers/markups/inlines/cobalter.rs index aecdc56..5fd37c7 100644 --- a/src/handlers/markups/inlines/cobalter.rs +++ b/src/handlers/markups/inlines/cobalter.rs @@ -1,17 +1,23 @@ use crate::config::Config; -use crate::db::schemas::settings::Settings; use crate::db::schemas::SettingsRepo; -use crate::handlers::keyboards::make_photo_pagination_keyboard; +use crate::db::schemas::settings::Settings; +use crate::handlers::keyboards::{make_photo_pagination_keyboard, make_single_url_keyboard}; use crate::util::errors::MyError; use ccobalt::model::request::{DownloadRequest, FilenameStyle, VideoQuality}; use ccobalt::model::response::DownloadResponse; +use log::info; use once_cell::sync::Lazy; use regex::Regex; use serde::{Deserialize, Serialize}; use std::sync::Arc; -use teloxide::prelude::*; -use teloxide::types::{ChosenInlineResult, InlineQuery, InlineQueryResult, InlineQueryResultArticle, InlineQueryResultPhoto, InputFile, InputMedia, InputMediaVideo, InputMessageContent, InputMessageContentText}; use teloxide::Bot; +use teloxide::prelude::*; +use teloxide::types::{ + ChosenInlineResult, InlineKeyboardButton, InlineKeyboardMarkup, InlineQuery, InlineQueryResult, + InlineQueryResultArticle, InlineQueryResultPhoto, InlineQueryResultVideo, InputFile, + InputMedia, InputMediaVideo, InputMessageContent, InputMessageContentText, +}; +use url::Url; #[derive(Serialize, Deserialize, Debug, Clone)] pub enum DownloadResult { @@ -71,13 +77,23 @@ pub async fn resolve_download_url( } Ok(None) } - _ => { - if let Some(download_url) = response.get_download_url() { - Ok(Some(DownloadResult::Video(download_url))) + DownloadResponse::Tunnel { url, filename } + | DownloadResponse::Redirect { url, filename } => { + const PHOTO_EXTENSIONS: &[&str] = &[".jpg", ".jpeg", ".png", ".gif", ".webp"]; + let is_photo = PHOTO_EXTENSIONS + .iter() + .any(|ext| filename.to_lowercase().ends_with(ext)); + + if is_photo { + Ok(Some(DownloadResult::Photos { + urls: vec![url.clone()], + original_url: url, + })) } else { - Ok(None) + Ok(Some(DownloadResult::Video(url))) } } + _ => Ok(response.get_download_url().map(DownloadResult::Video)), } } @@ -95,15 +111,31 @@ fn build_results_from_media( user_id: u64, ) -> Vec { match media { - DownloadResult::Video(_) => { - let result_id = format!("cobalt_video:{}", url_hash); - let article = InlineQueryResultArticle::new( - result_id, - "Скачать видео", - InputMessageContent::Text(InputMessageContentText::new("⏳ Получение видео...")), - ) - .description("Нажмите, чтобы отправить видео в чат"); - vec![article.into()] + DownloadResult::Video(video_url) => { + if let Ok(url) = video_url.parse() { + let url_kb = make_single_url_keyboard(original_url); + + let result = InlineQueryResultVideo::new( + format!("cobalt_video:{}", url_hash), + url, + "video/mp4".parse().unwrap(), + "https://i.imgur.com/D0A9Gxh.png".parse().unwrap(), /* preview */ + "Скачать видео".to_string(), + ) + .reply_markup(url_kb); + vec![result.into()] + } else { + vec![ + InlineQueryResultArticle::new( + format!("cobalt_video:{}", url_hash), + "Видео не найдено", + InputMessageContent::Text(InputMessageContentText::new( + "❌ не удалось получить видео", + )), + ) + .into(), + ] + } } DownloadResult::Photos { urls, .. } => { let total = urls.len(); @@ -111,20 +143,24 @@ fn build_results_from_media( .enumerate() .filter_map(|(i, url_str)| { if let (Ok(photo_url), Ok(thumb_url)) = (url_str.parse(), url_str.parse()) { - let result_id = format!("{}_{}", original_url, i); - let mut photo_result = - InlineQueryResultPhoto::new(result_id, photo_url, thumb_url); + let result_id = format!("{}_{}", url_hash, i); - if total > 1 { - let keyboard = make_photo_pagination_keyboard( + let keyboard = if total > 1 { + make_photo_pagination_keyboard( url_hash, i, total, user_id, original_url, - ); - photo_result = photo_result.reply_markup(keyboard); - } + ) + } else { + make_single_url_keyboard(original_url) + }; + + let photo_result = + InlineQueryResultPhoto::new(result_id, photo_url, thumb_url) + .reply_markup(keyboard); + Some(photo_result.into()) } else { None @@ -175,48 +211,50 @@ pub async fn handle_cobalt_inline( "Failed to process link. Media not found or an error occurred.", )), ) - .description("Could not fetch media. Please try again later."); + .description("Could not fetch media. Please try again later."); vec![error_article.into()] } } }; - bot.answer_inline_query(q.id, results) - .cache_time(0) - .await?; + bot.answer_inline_query(q.id, results).cache_time(0).await?; Ok(()) } -pub async fn handle_chosen_inline_video( - bot: Bot, - chosen: ChosenInlineResult, - config: Arc, -) -> Result<(), MyError> { - if let Some(inline_message_id) = chosen.inline_message_id { - if let Some(url_hash) = chosen.result_id.strip_prefix("cobalt_video:") { - let redis = config.get_redis_client(); - let cache_key = format!("cobalt_cache:{}", url_hash); - - if let Ok(Some(DownloadResult::Video(video_url))) = - redis.get::(&cache_key).await - { - let media = InputMedia::Video(InputMediaVideo::new(InputFile::url(video_url.parse()?))); - if let Err(e) = bot.edit_message_media_inline(&inline_message_id, media).await - { - log::error!("Failed to edit message with video: {}", e); - bot.edit_message_text_inline( - inline_message_id, - "Ошибка: не удалось отправить видео.", - ) - .await?; - } - } else { - bot.edit_message_text_inline( - inline_message_id, - "Ошибка: видео не найдено в кэше или срок его хранения истёк.", - ) - .await?; - } - } - } - Ok(()) -} \ No newline at end of file +// completely useless +// pub async fn handle_chosen_inline_video( +// bot: Bot, +// chosen: ChosenInlineResult, +// config: Arc, +// ) -> Result<(), MyError> { +// if let Some(inline_message_id) = chosen.inline_message_id { +// if let Some(url_hash) = chosen.result_id.strip_prefix("cobalt_video:") { +// let redis = config.get_redis_client(); +// let cache_key = format!("cobalt_cache:{}", url_hash); +// +// if let Ok(Some(DownloadResult::Video(video_url))) = +// redis.get::(&cache_key).await +// { +// let media = +// InputMedia::Video(InputMediaVideo::new(InputFile::url(video_url.parse()?))); +// if let Err(e) = bot +// .edit_message_media_inline(&inline_message_id, media) +// .await +// { +// log::error!("Failed to edit message with video: {}", e); +// bot.edit_message_text_inline( +// inline_message_id, +// "Ошибка: не удалось отправить видео.", +// ) +// .await?; +// } +// } else { +// bot.edit_message_text_inline( +// inline_message_id, +// "Ошибка: видео не найдено в кэше или срок его хранения истёк.", +// ) +// .await?; +// } +// } +// } +// Ok(()) +// } diff --git a/src/handlers/markups/markuper.rs b/src/handlers/markups/markuper.rs index f189d7e..f24549a 100644 --- a/src/handlers/markups/markuper.rs +++ b/src/handlers/markups/markuper.rs @@ -1,18 +1,22 @@ -use crate::config::Config; -use crate::handlers::markups::callbacks::cobalter_pagination::handle_cobalt_pagination; -use crate::handlers::markups::callbacks::delete::delete_message_handler; -use crate::handlers::markups::callbacks::module::{ - module_option_handler, module_select_handler, module_toggle_handler, settings_back_handler, - settings_set_handler, +use crate::{ + config::Config, + handlers::markups::callbacks::{ + cobalt_pagination::handle_cobalt_pagination, + delete::delete_message_handler, + module::{ + module_option_handler, module_select_handler, module_toggle_handler, + settings_back_handler, settings_set_handler, + }, + translate::handle_translate_callback, + whisper::handle_whisper_callback, + }, + util::{ + errors::MyError, + transcription::{back_handler, summarization_handler}, + }, }; -use crate::handlers::markups::callbacks::translate::handle_translate_callback; -use crate::handlers::markups::callbacks::whisper::handle_whisper_callback; -use crate::util::errors::MyError; -use crate::util::transcription::{back_handler, summarization_handler}; use std::sync::Arc; -use teloxide::requests::Requester; -use teloxide::types::CallbackQuery; -use teloxide::Bot; +use teloxide::{Bot, requests::Requester, types::CallbackQuery}; pub(crate) async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { let config = Arc::new(Config::new().await); @@ -38,7 +42,7 @@ pub(crate) async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Resul handle_whisper_callback(bot, q, &config).await? } else if data.starts_with("tr_") { handle_translate_callback(bot, q, &config).await? - } else if data.starts_with("cobalt_page:") { + } else if data.starts_with("cobalt:") { handle_cobalt_pagination(bot, q, config).await? } else { bot.answer_callback_query(q.id).await?; @@ -46,4 +50,4 @@ pub(crate) async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Resul } Ok(()) -} \ No newline at end of file +} diff --git a/src/handlers/markups/mod.rs b/src/handlers/markups/mod.rs index 438e78f..58375dc 100644 --- a/src/handlers/markups/mod.rs +++ b/src/handlers/markups/mod.rs @@ -1,3 +1,3 @@ +mod callbacks; pub mod inlines; pub mod markuper; -mod callbacks; From b1e63f4fe3563f648c6c94be058bbc9f4f4e6fbf Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Sun, 7 Sep 2025 01:00:02 +0300 Subject: [PATCH 33/66] new structure of project --- .../callbacks/cobalt_pagination.rs | 6 +-- .../markups => bot}/callbacks/delete.rs | 4 +- .../markups => bot}/callbacks/mod.rs | 0 .../markups => bot}/callbacks/module.rs | 32 +++++++------- .../markups => bot}/callbacks/translate.rs | 43 +++++++++++-------- .../markups => bot}/callbacks/whisper.rs | 6 +-- src/{loader.rs => bot/dispatcher.rs} | 32 ++++++-------- src/{ => bot}/handlers/commander.rs | 23 +++++----- src/{ => bot}/handlers/commands/mod.rs | 0 src/{ => bot}/handlers/commands/settings.rs | 35 ++++++++------- .../handlers/commands/speech_recognition.rs | 3 +- src/{ => bot}/handlers/commands/start.rs | 4 +- src/{ => bot}/handlers/commands/translate.rs | 2 +- src/{ => bot}/handlers/keyboards.rs | 3 +- src/{ => bot}/handlers/markups/markuper.rs | 28 +++++------- src/bot/handlers/markups/mod.rs | 1 + src/{ => bot}/handlers/messages/chat.rs | 6 +-- src/{ => bot}/handlers/messages/messager.rs | 8 ++-- src/{ => bot}/handlers/messages/mod.rs | 4 +- src/{ => bot}/handlers/messages/sound/mod.rs | 0 .../handlers/messages/sound/voice.rs | 2 +- .../handlers/messages/sound/voice_note.rs | 2 +- src/{ => bot}/handlers/messages/sounder.rs | 11 ++--- src/{ => bot}/handlers/mod.rs | 4 +- .../markups => bot}/inlines/cobalter.rs | 15 +++---- .../markups => bot}/inlines/currency.rs | 4 +- src/{handlers/markups => bot}/inlines/mod.rs | 2 +- .../markups => bot}/inlines/whisper.rs | 2 +- src/bot/mod.rs | 4 ++ src/{config.rs => config/mod.rs} | 2 +- src/{ => core}/db/functions.rs | 2 +- src/{ => core}/db/mod.rs | 0 src/{ => core}/db/redis.rs | 0 src/{ => core}/db/schemas.rs | 6 +-- src/{ => core}/db/schemas/group.rs | 5 ++- src/{ => core}/db/schemas/settings.rs | 4 +- src/{ => core}/db/schemas/user.rs | 11 ++--- src/core/mod.rs | 1 + src/{util => }/errors.rs | 4 +- src/handlers/markups/mod.rs | 3 -- src/lib.rs | 8 ++-- src/main.rs | 8 +--- src/util/currency/converter.rs | 8 ++-- src/util/currency/mod.rs | 2 +- src/util/mod.rs | 3 +- src/util/transcription.rs | 4 +- 46 files changed, 173 insertions(+), 184 deletions(-) rename src/{handlers/markups => bot}/callbacks/cobalt_pagination.rs (95%) rename src/{handlers/markups => bot}/callbacks/delete.rs (98%) rename src/{handlers/markups => bot}/callbacks/mod.rs (100%) rename src/{handlers/markups => bot}/callbacks/module.rs (92%) rename src/{handlers/markups => bot}/callbacks/translate.rs (80%) rename src/{handlers/markups => bot}/callbacks/whisper.rs (94%) rename src/{loader.rs => bot/dispatcher.rs} (90%) rename src/{ => bot}/handlers/commander.rs (60%) rename src/{ => bot}/handlers/commands/mod.rs (100%) rename src/{ => bot}/handlers/commands/settings.rs (90%) rename src/{ => bot}/handlers/commands/speech_recognition.rs (79%) rename src/{ => bot}/handlers/commands/start.rs (97%) rename src/{ => bot}/handlers/commands/translate.rs (99%) rename src/{ => bot}/handlers/keyboards.rs (96%) rename src/{ => bot}/handlers/markups/markuper.rs (69%) create mode 100644 src/bot/handlers/markups/mod.rs rename src/{ => bot}/handlers/messages/chat.rs (93%) rename src/{ => bot}/handlers/messages/messager.rs (90%) rename src/{ => bot}/handlers/messages/mod.rs (66%) rename src/{ => bot}/handlers/messages/sound/mod.rs (100%) rename src/{ => bot}/handlers/messages/sound/voice.rs (88%) rename src/{ => bot}/handlers/messages/sound/voice_note.rs (88%) rename src/{ => bot}/handlers/messages/sounder.rs (60%) rename src/{ => bot}/handlers/mod.rs (73%) rename src/{handlers/markups => bot}/inlines/cobalter.rs (95%) rename src/{handlers/markups => bot}/inlines/currency.rs (97%) rename src/{handlers/markups => bot}/inlines/mod.rs (100%) rename src/{handlers/markups => bot}/inlines/whisper.rs (99%) create mode 100644 src/bot/mod.rs rename src/{config.rs => config/mod.rs} (99%) rename src/{ => core}/db/functions.rs (85%) rename src/{ => core}/db/mod.rs (100%) rename src/{ => core}/db/redis.rs (100%) rename src/{ => core}/db/schemas.rs (93%) rename src/{ => core}/db/schemas/group.rs (94%) rename src/{ => core}/db/schemas/settings.rs (98%) rename src/{ => core}/db/schemas/user.rs (91%) create mode 100644 src/core/mod.rs rename src/{util => }/errors.rs (99%) delete mode 100644 src/handlers/markups/mod.rs diff --git a/src/handlers/markups/callbacks/cobalt_pagination.rs b/src/bot/callbacks/cobalt_pagination.rs similarity index 95% rename from src/handlers/markups/callbacks/cobalt_pagination.rs rename to src/bot/callbacks/cobalt_pagination.rs index 9a82dcc..0977c47 100644 --- a/src/handlers/markups/callbacks/cobalt_pagination.rs +++ b/src/bot/callbacks/cobalt_pagination.rs @@ -1,7 +1,7 @@ +use crate::bot::handlers::keyboards::make_photo_pagination_keyboard; +use crate::bot::inlines::cobalter::DownloadResult; use crate::config::Config; -use crate::handlers::keyboards::make_photo_pagination_keyboard; -use crate::handlers::markups::inlines::cobalter::DownloadResult; -use crate::util::errors::MyError; +use crate::errors::MyError; use std::sync::Arc; use teloxide::prelude::*; use teloxide::types::{CallbackQuery, InputFile, InputMedia, InputMediaPhoto}; diff --git a/src/handlers/markups/callbacks/delete.rs b/src/bot/callbacks/delete.rs similarity index 98% rename from src/handlers/markups/callbacks/delete.rs rename to src/bot/callbacks/delete.rs index f438663..0c1eb22 100644 --- a/src/handlers/markups/callbacks/delete.rs +++ b/src/bot/callbacks/delete.rs @@ -1,8 +1,8 @@ +use crate::errors::MyError; use log::error; use teloxide::Bot; use teloxide::payloads::AnswerCallbackQuerySetters; use teloxide::prelude::{CallbackQuery, Requester}; -use crate::util::errors::MyError; pub async fn delete_message_handler(bot: Bot, query: CallbackQuery) -> Result<(), MyError> { let Some(message_to_delete) = query.message else { @@ -60,4 +60,4 @@ pub async fn delete_message_handler(bot: Bot, query: CallbackQuery) -> Result<() .ok(); Ok(()) -} \ No newline at end of file +} diff --git a/src/handlers/markups/callbacks/mod.rs b/src/bot/callbacks/mod.rs similarity index 100% rename from src/handlers/markups/callbacks/mod.rs rename to src/bot/callbacks/mod.rs diff --git a/src/handlers/markups/callbacks/module.rs b/src/bot/callbacks/module.rs similarity index 92% rename from src/handlers/markups/callbacks/module.rs rename to src/bot/callbacks/module.rs index 5d26315..69e22dd 100644 --- a/src/handlers/markups/callbacks/module.rs +++ b/src/bot/callbacks/module.rs @@ -1,12 +1,12 @@ -use crate::db::schemas::settings::Settings; -use crate::db::schemas::SettingsRepo; -use crate::handlers::commands::settings::update_settings_message; -use crate::handlers::keyboards::make_option_selection_keyboard; -use crate::util::errors::MyError; +use crate::bot::handlers::commands::settings::update_settings_message; +use crate::bot::handlers::keyboards::make_option_selection_keyboard; +use crate::core::db::schemas::SettingsRepo; +use crate::core::db::schemas::settings::Settings; +use crate::errors::MyError; +use teloxide::Bot; use teloxide::payloads::EditMessageTextSetters; use teloxide::prelude::{CallbackQuery, Requester}; use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup}; -use teloxide::Bot; pub async fn module_select_handler(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { let data = match q.data.as_ref() { @@ -70,8 +70,8 @@ pub async fn module_select_handler(bot: Bot, q: CallbackQuery) -> Result<(), MyE message.id(), format!("⚙️ Настройки модуля: {}", module.description), ) - .reply_markup(keyboard) - .await?; + .reply_markup(keyboard) + .await?; Ok(()) } @@ -85,7 +85,7 @@ pub async fn module_toggle_handler(bot: Bot, q: CallbackQuery) -> Result<(), MyE Settings::update_module(owner_id, owner_type, module_key, |module| { module.enabled = !module.enabled; }) - .await?; + .await?; update_settings_message( bot, @@ -93,7 +93,7 @@ pub async fn module_toggle_handler(bot: Bot, q: CallbackQuery) -> Result<(), MyE owner_id.to_string(), owner_type.to_string(), ) - .await + .await } // pub async fn module_option_handler(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { @@ -149,8 +149,8 @@ pub async fn module_option_handler(bot: Bot, q: CallbackQuery) -> Result<(), MyE message.id(), format!("Select a value for '{}':", option_name), ) - .reply_markup(keyboard) - .await?; + .reply_markup(keyboard) + .await?; Ok(()) } @@ -166,11 +166,13 @@ pub async fn settings_back_handler(bot: Bot, q: CallbackQuery) -> Result<(), MyE owner_id.to_string(), owner_type.to_string(), ) - .await + .await } pub async fn settings_set_handler(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { - q.message.as_ref().ok_or_else(|| MyError::Other("No message in callback".into()))?; + q.message + .as_ref() + .ok_or_else(|| MyError::Other("No message in callback".into()))?; let data = q .data @@ -194,7 +196,7 @@ pub async fn settings_set_handler(bot: Bot, q: CallbackQuery) -> Result<(), MyEr option.value = new_value; } }) - .await?; + .await?; let mut updated_q = q; updated_q.data = Some(format!( diff --git a/src/handlers/markups/callbacks/translate.rs b/src/bot/callbacks/translate.rs similarity index 80% rename from src/handlers/markups/callbacks/translate.rs rename to src/bot/callbacks/translate.rs index 894c9f6..7ecb6d6 100644 --- a/src/handlers/markups/callbacks/translate.rs +++ b/src/bot/callbacks/translate.rs @@ -1,13 +1,17 @@ +use crate::bot::handlers::commands::translate::{ + SUPPORTED_LANGUAGES, create_language_keyboard, normalize_language_code, +}; +use crate::config::Config; +use crate::errors::MyError; +use crate::util::inline::delete_message_button; use teloxide::Bot; use teloxide::payloads::{EditMessageReplyMarkupSetters, EditMessageTextSetters}; use teloxide::prelude::Requester; -use teloxide::types::{CallbackQuery, InlineKeyboardButton, MaybeInaccessibleMessage, Message, ParseMode}; +use teloxide::types::{ + CallbackQuery, InlineKeyboardButton, MaybeInaccessibleMessage, Message, ParseMode, +}; use teloxide::{ApiError, RequestError}; -use crate::config::Config; -use crate::util::errors::MyError; use translators::{GoogleTranslator, Translator}; -use crate::handlers::commands::translate::{create_language_keyboard, normalize_language_code, SUPPORTED_LANGUAGES}; -use crate::util::inline::delete_message_button; pub async fn handle_translate_callback( bot: Bot, @@ -66,8 +70,12 @@ async fn handle_language_selection( let original_message = match message.reply_to_message() { Some(msg) => msg, None => { - bot.edit_message_text(message.chat.id, message.id, "Ошибка: не удалось найти исходное сообщение. Попробуйте снова.") - .await?; + bot.edit_message_text( + message.chat.id, + message.id, + "Ошибка: не удалось найти исходное сообщение. Попробуйте снова.", + ) + .await?; return Ok(()); } }; @@ -75,8 +83,12 @@ async fn handle_language_selection( let text_to_translate = match original_message.text() { Some(text) => text, None => { - bot.edit_message_text(message.chat.id, message.id, "В исходном сообщении нет текста для перевода.") - .await?; + bot.edit_message_text( + message.chat.id, + message.id, + "В исходном сообщении нет текста для перевода.", + ) + .await?; return Ok(()); } }; @@ -95,10 +107,7 @@ async fn handle_language_selection( .await .unwrap(); - let response = format!( - "
{}\n
", - res, - ); + let response = format!("
{}\n
", res,); let lang_display_name = SUPPORTED_LANGUAGES .iter() @@ -106,10 +115,8 @@ async fn handle_language_selection( .map(|(_, name)| *name) .unwrap_or(&normalized_lang); - let switch_lang_button = InlineKeyboardButton::callback( - lang_display_name.to_string(), - "tr_show_langs".to_string(), - ); + let switch_lang_button = + InlineKeyboardButton::callback(lang_display_name.to_string(), "tr_show_langs".to_string()); let mut keyboard = delete_message_button(user.id.0); if let Some(first_row) = keyboard.inline_keyboard.get_mut(0) { @@ -146,4 +153,4 @@ async fn handle_show_languages(bot: Bot, message: &Message) -> Result<(), MyErro } } Ok(()) -} \ No newline at end of file +} diff --git a/src/handlers/markups/callbacks/whisper.rs b/src/bot/callbacks/whisper.rs similarity index 94% rename from src/handlers/markups/callbacks/whisper.rs rename to src/bot/callbacks/whisper.rs index 7807908..404fd28 100644 --- a/src/handlers/markups/callbacks/whisper.rs +++ b/src/bot/callbacks/whisper.rs @@ -1,6 +1,6 @@ -use super::super::inlines::whisper::Whisper; +use crate::bot::inlines::whisper::Whisper; use crate::config::Config; -use crate::util::errors::MyError; +use crate::errors::MyError; use teloxide::Bot; use teloxide::payloads::{AnswerCallbackQuerySetters, EditMessageTextSetters}; use teloxide::prelude::{CallbackQuery, Requester}; @@ -21,7 +21,7 @@ pub async fn handle_whisper_callback( let whisper_id = parts[2]; let user = q.from.clone(); - let username = user.username.clone().unwrap_or_default(); + let _username = user.username.clone().unwrap_or_default(); let redis_key = format!("whisper:{}", whisper_id); diff --git a/src/loader.rs b/src/bot/dispatcher.rs similarity index 90% rename from src/loader.rs rename to src/bot/dispatcher.rs index 760b74c..200a765 100644 --- a/src/loader.rs +++ b/src/bot/dispatcher.rs @@ -1,23 +1,15 @@ -use crate::{ - config::Config, - handlers::{ - commander::command_handlers, - markups::inlines::{ - cobalter::{handle_cobalt_inline, is_query_url}, - currency::handle_currency_inline, - whisper::{handle_whisper_inline, is_whisper_query}, - }, - markups::markuper::callback_query_handlers, - messages::{ - chat::handle_bot_added, - messager::{handle_currency, handle_speech}, - }, - }, - util::{ - currency::converter::is_currency_query, enums::Command, errors::MyError, - inline::delete_message_button, - }, -}; +use crate::bot::handlers::commander::command_handlers; +use crate::bot::handlers::markups::markuper::callback_query_handlers; +use crate::bot::handlers::messages::chat::handle_bot_added; +use crate::bot::handlers::messages::messager::{handle_currency, handle_speech}; +use crate::bot::inlines::cobalter::{handle_cobalt_inline, is_query_url}; +use crate::bot::inlines::currency::handle_currency_inline; +use crate::bot::inlines::whisper::{handle_whisper_inline, is_whisper_query}; +use crate::config::Config; +use crate::errors::MyError; +use crate::util::currency::converter::is_currency_query; +use crate::util::enums::Command; +use crate::util::inline::delete_message_button; use log::{error, info}; use oximod::set_global_client; use std::{convert::Infallible, fmt::Write, ops::ControlFlow, sync::Arc}; diff --git a/src/handlers/commander.rs b/src/bot/handlers/commander.rs similarity index 60% rename from src/handlers/commander.rs rename to src/bot/handlers/commander.rs index a2d2039..371cddd 100644 --- a/src/handlers/commander.rs +++ b/src/bot/handlers/commander.rs @@ -1,20 +1,17 @@ -use crate::config::Config; -use crate::handlers::commands::{ - settings::{currency_codes_handler, currency_codes_list_handler, settings_command_handler}, - speech_recognition::speech_recognition_handler, - start::start_handler, +use crate::bot::handlers::commands::settings::{ + currency_codes_handler, currency_codes_list_handler, settings_command_handler, }; -use crate::util::{enums::Command, errors::MyError}; +use crate::bot::handlers::commands::speech_recognition::speech_recognition_handler; +use crate::bot::handlers::commands::start::start_handler; +use crate::bot::handlers::commands::translate::translate_handler; +use crate::config::Config; +use crate::errors::MyError; +use crate::util::enums::Command; use teloxide::Bot; use teloxide::prelude::Message; use tokio::task; -use crate::handlers::commands::translate::translate_handler; -pub(crate) async fn command_handlers( - bot: Bot, - message: Message, - cmd: Command, -) -> Result<(), MyError> { +pub async fn command_handlers(bot: Bot, message: Message, cmd: Command) -> Result<(), MyError> { let config = Config::new().await; task::spawn(async move { match cmd { @@ -27,4 +24,4 @@ pub(crate) async fn command_handlers( } }); Ok(()) -} \ No newline at end of file +} diff --git a/src/handlers/commands/mod.rs b/src/bot/handlers/commands/mod.rs similarity index 100% rename from src/handlers/commands/mod.rs rename to src/bot/handlers/commands/mod.rs diff --git a/src/handlers/commands/settings.rs b/src/bot/handlers/commands/settings.rs similarity index 90% rename from src/handlers/commands/settings.rs rename to src/bot/handlers/commands/settings.rs index 6f0e0e2..65c2381 100644 --- a/src/handlers/commands/settings.rs +++ b/src/bot/handlers/commands/settings.rs @@ -1,20 +1,19 @@ use crate::config::Config; -use crate::db::schemas::settings::Settings; -use crate::db::schemas::{BaseFunctions, CurrenciesFunctions, SettingsRepo}; -use crate::{ - db::functions::get_or_create, - db::schemas::group::Group, - db::schemas::user::User, - util::{ - currency::converter::{get_all_currency_codes, CURRENCY_CONFIG_PATH}, - errors::MyError, - }, -}; +use crate::core::db::functions::get_or_create; +use crate::core::db::schemas::group::Group; +use crate::core::db::schemas::settings::Settings; +use crate::core::db::schemas::user::User; +use crate::core::db::schemas::{BaseFunctions, CurrenciesFunctions, SettingsRepo}; +use crate::errors::MyError; +use crate::util::currency::converter::{CURRENCY_CONFIG_PATH, get_all_currency_codes}; use log::error; use oximod::Model; use std::collections::HashSet; use teloxide::prelude::*; -use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup, MaybeInaccessibleMessage, ParseMode, ReplyParameters}; +use teloxide::types::{ + InlineKeyboardButton, InlineKeyboardMarkup, MaybeInaccessibleMessage, ParseMode, + ReplyParameters, +}; pub async fn handle_currency_update( bot: Bot, @@ -153,7 +152,6 @@ pub async fn currency_codes_list_handler(bot: Bot, msg: Message) -> Result<(), M Ok(()) } - // new settings pub async fn settings_command_handler( bot: Bot, @@ -166,7 +164,11 @@ pub async fn settings_command_handler( message.chat.id.to_string() }; - let owner_type = if message.chat.is_private() { "user" } else { "group" }; + let owner_type = if message.chat.is_private() { + "user" + } else { + "group" + }; let settings = Settings::get_or_create(&owner_id, owner_type).await?; @@ -214,10 +216,11 @@ pub async fn update_settings_message( let text = "Настройки модулей:"; if let MaybeInaccessibleMessage::Regular(msg) = message { - let _ = bot.edit_message_text(msg.chat.id, msg.id, text) + let _ = bot + .edit_message_text(msg.chat.id, msg.id, text) .reply_markup(keyboard) .await; } Ok(()) -} \ No newline at end of file +} diff --git a/src/handlers/commands/speech_recognition.rs b/src/bot/handlers/commands/speech_recognition.rs similarity index 79% rename from src/handlers/commands/speech_recognition.rs rename to src/bot/handlers/commands/speech_recognition.rs index abc7a3a..2781d87 100644 --- a/src/handlers/commands/speech_recognition.rs +++ b/src/bot/handlers/commands/speech_recognition.rs @@ -1,5 +1,6 @@ use crate::config::Config; -use crate::util::{errors::MyError, transcription::transcription_handler}; +use crate::errors::MyError; +use crate::util::transcription::transcription_handler; use teloxide::prelude::*; pub async fn speech_recognition_handler( diff --git a/src/handlers/commands/start.rs b/src/bot/handlers/commands/start.rs similarity index 97% rename from src/handlers/commands/start.rs rename to src/bot/handlers/commands/start.rs index 385e486..88270fb 100644 --- a/src/handlers/commands/start.rs +++ b/src/bot/handlers/commands/start.rs @@ -1,7 +1,7 @@ use crate::config::Config; -use crate::db::schemas::user::User; +use crate::core::db::schemas::user::User; +use crate::errors::MyError; use crate::util::currency::converter::get_default_currencies; -use crate::util::errors::MyError; use log::error; use mongodb::bson::doc; use oximod::Model; diff --git a/src/handlers/commands/translate.rs b/src/bot/handlers/commands/translate.rs similarity index 99% rename from src/handlers/commands/translate.rs rename to src/bot/handlers/commands/translate.rs index db4c337..83bd67d 100644 --- a/src/handlers/commands/translate.rs +++ b/src/bot/handlers/commands/translate.rs @@ -1,5 +1,5 @@ use crate::config::Config; -use crate::util::errors::MyError; +use crate::errors::MyError; use crate::util::inline::delete_message_button; use teloxide::prelude::*; use teloxide::types::{ diff --git a/src/handlers/keyboards.rs b/src/bot/handlers/keyboards.rs similarity index 96% rename from src/handlers/keyboards.rs rename to src/bot/handlers/keyboards.rs index 1d5f39d..480b5ab 100644 --- a/src/handlers/keyboards.rs +++ b/src/bot/handlers/keyboards.rs @@ -1,5 +1,4 @@ -use crate::db::schemas::settings::ModuleOption; -use base64::{Engine as _, engine::general_purpose}; +use crate::core::db::schemas::settings::ModuleOption; use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup}; pub fn make_single_url_keyboard(url: &str) -> InlineKeyboardMarkup { diff --git a/src/handlers/markups/markuper.rs b/src/bot/handlers/markups/markuper.rs similarity index 69% rename from src/handlers/markups/markuper.rs rename to src/bot/handlers/markups/markuper.rs index f24549a..a506e17 100644 --- a/src/handlers/markups/markuper.rs +++ b/src/bot/handlers/markups/markuper.rs @@ -1,24 +1,18 @@ -use crate::{ - config::Config, - handlers::markups::callbacks::{ - cobalt_pagination::handle_cobalt_pagination, - delete::delete_message_handler, - module::{ - module_option_handler, module_select_handler, module_toggle_handler, - settings_back_handler, settings_set_handler, - }, - translate::handle_translate_callback, - whisper::handle_whisper_callback, - }, - util::{ - errors::MyError, - transcription::{back_handler, summarization_handler}, - }, +use crate::bot::callbacks::cobalt_pagination::handle_cobalt_pagination; +use crate::bot::callbacks::delete::delete_message_handler; +use crate::bot::callbacks::module::{ + module_option_handler, module_select_handler, module_toggle_handler, settings_back_handler, + settings_set_handler, }; +use crate::bot::callbacks::translate::handle_translate_callback; +use crate::bot::callbacks::whisper::handle_whisper_callback; +use crate::config::Config; +use crate::errors::MyError; +use crate::util::transcription::{back_handler, summarization_handler}; use std::sync::Arc; use teloxide::{Bot, requests::Requester, types::CallbackQuery}; -pub(crate) async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { +pub async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { let config = Arc::new(Config::new().await); if let Some(data) = &q.data { diff --git a/src/bot/handlers/markups/mod.rs b/src/bot/handlers/markups/mod.rs new file mode 100644 index 0000000..46ffe0a --- /dev/null +++ b/src/bot/handlers/markups/mod.rs @@ -0,0 +1 @@ +pub mod markuper; diff --git a/src/handlers/messages/chat.rs b/src/bot/handlers/messages/chat.rs similarity index 93% rename from src/handlers/messages/chat.rs rename to src/bot/handlers/messages/chat.rs index 9d4abb7..9947011 100644 --- a/src/handlers/messages/chat.rs +++ b/src/bot/handlers/messages/chat.rs @@ -1,7 +1,7 @@ -use crate::db::schemas::group::Group; -use crate::db::schemas::user::User; +use crate::core::db::schemas::group::Group; +use crate::core::db::schemas::user::User; +use crate::errors::MyError; use crate::util::currency::converter::get_default_currencies; -use crate::util::errors::MyError; use log::{error, info}; use mongodb::bson::doc; use oximod::ModelTrait; diff --git a/src/handlers/messages/messager.rs b/src/bot/handlers/messages/messager.rs similarity index 90% rename from src/handlers/messages/messager.rs rename to src/bot/handlers/messages/messager.rs index 6638900..846cbd7 100644 --- a/src/handlers/messages/messager.rs +++ b/src/bot/handlers/messages/messager.rs @@ -1,6 +1,6 @@ +use crate::bot::handlers::messages::sounder::sound_handlers; use crate::config::Config; -use crate::handlers::messages::sounder::sound_handlers; -use crate::util::errors::MyError; +use crate::errors::MyError; use crate::util::inline::delete_message_button; use log::error; use teloxide::Bot; @@ -9,7 +9,7 @@ use teloxide::requests::Requester; use teloxide::types::{Message, ParseMode, ReplyParameters}; use tokio::task; -pub(crate) async fn handle_speech(bot: Bot, message: Message) -> Result<(), MyError> { +pub async fn handle_speech(bot: Bot, message: Message) -> Result<(), MyError> { let config = Config::new().await; let user = message.from.clone().unwrap(); @@ -26,7 +26,7 @@ pub(crate) async fn handle_speech(bot: Bot, message: Message) -> Result<(), MyEr Ok(()) } -pub(crate) async fn handle_currency(bot: Bot, message: Message) -> Result<(), MyError> { +pub async fn handle_currency(bot: Bot, message: Message) -> Result<(), MyError> { let config = Config::new().await; let bot_clone = bot.clone(); diff --git a/src/handlers/messages/mod.rs b/src/bot/handlers/messages/mod.rs similarity index 66% rename from src/handlers/messages/mod.rs rename to src/bot/handlers/messages/mod.rs index 20aad1c..0102531 100644 --- a/src/handlers/messages/mod.rs +++ b/src/bot/handlers/messages/mod.rs @@ -1,4 +1,4 @@ +pub mod chat; pub mod messager; pub mod sound; -pub(crate) mod sounder; -pub mod chat; +pub mod sounder; diff --git a/src/handlers/messages/sound/mod.rs b/src/bot/handlers/messages/sound/mod.rs similarity index 100% rename from src/handlers/messages/sound/mod.rs rename to src/bot/handlers/messages/sound/mod.rs diff --git a/src/handlers/messages/sound/voice.rs b/src/bot/handlers/messages/sound/voice.rs similarity index 88% rename from src/handlers/messages/sound/voice.rs rename to src/bot/handlers/messages/sound/voice.rs index a198dee..d6285b0 100644 --- a/src/handlers/messages/sound/voice.rs +++ b/src/bot/handlers/messages/sound/voice.rs @@ -1,5 +1,5 @@ use crate::config::Config; -use crate::util::errors::MyError; +use crate::errors::MyError; use crate::util::transcription::transcription_handler; use teloxide::prelude::*; diff --git a/src/handlers/messages/sound/voice_note.rs b/src/bot/handlers/messages/sound/voice_note.rs similarity index 88% rename from src/handlers/messages/sound/voice_note.rs rename to src/bot/handlers/messages/sound/voice_note.rs index 7b91383..04da4a7 100644 --- a/src/handlers/messages/sound/voice_note.rs +++ b/src/bot/handlers/messages/sound/voice_note.rs @@ -1,5 +1,5 @@ use crate::config::Config; -use crate::util::errors::MyError; +use crate::errors::MyError; use crate::util::transcription::transcription_handler; use teloxide::prelude::*; diff --git a/src/handlers/messages/sounder.rs b/src/bot/handlers/messages/sounder.rs similarity index 60% rename from src/handlers/messages/sounder.rs rename to src/bot/handlers/messages/sounder.rs index e467357..76b0dfb 100644 --- a/src/handlers/messages/sounder.rs +++ b/src/bot/handlers/messages/sounder.rs @@ -1,14 +1,11 @@ +use crate::bot::handlers::messages::sound::voice::voice_handler; +use crate::bot::handlers::messages::sound::voice_note::voice_note_handler; use crate::config::Config; -use crate::handlers::messages::sound::{voice::voice_handler, voice_note::voice_note_handler}; -use crate::util::errors::MyError; +use crate::errors::MyError; use teloxide::Bot; use teloxide::prelude::Message; -pub(crate) async fn sound_handlers( - bot: Bot, - message: Message, - config: &Config, -) -> Result<(), MyError> { +pub async fn sound_handlers(bot: Bot, message: Message, config: &Config) -> Result<(), MyError> { let config = config.clone(); tokio::spawn(async move { if message.voice().is_some() { diff --git a/src/handlers/mod.rs b/src/bot/handlers/mod.rs similarity index 73% rename from src/handlers/mod.rs rename to src/bot/handlers/mod.rs index d0ee267..de9a65e 100644 --- a/src/handlers/mod.rs +++ b/src/bot/handlers/mod.rs @@ -1,5 +1,5 @@ -pub(crate) mod commander; +pub mod commander; pub mod commands; +pub mod keyboards; pub mod markups; pub mod messages; -pub mod keyboards; diff --git a/src/handlers/markups/inlines/cobalter.rs b/src/bot/inlines/cobalter.rs similarity index 95% rename from src/handlers/markups/inlines/cobalter.rs rename to src/bot/inlines/cobalter.rs index 5fd37c7..eb42789 100644 --- a/src/handlers/markups/inlines/cobalter.rs +++ b/src/bot/inlines/cobalter.rs @@ -1,11 +1,10 @@ +use crate::bot::handlers::keyboards::{make_photo_pagination_keyboard, make_single_url_keyboard}; use crate::config::Config; -use crate::db::schemas::SettingsRepo; -use crate::db::schemas::settings::Settings; -use crate::handlers::keyboards::{make_photo_pagination_keyboard, make_single_url_keyboard}; -use crate::util::errors::MyError; +use crate::core::db::schemas::SettingsRepo; +use crate::core::db::schemas::settings::Settings; +use crate::errors::MyError; use ccobalt::model::request::{DownloadRequest, FilenameStyle, VideoQuality}; use ccobalt::model::response::DownloadResponse; -use log::info; use once_cell::sync::Lazy; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -13,11 +12,9 @@ use std::sync::Arc; use teloxide::Bot; use teloxide::prelude::*; use teloxide::types::{ - ChosenInlineResult, InlineKeyboardButton, InlineKeyboardMarkup, InlineQuery, InlineQueryResult, - InlineQueryResultArticle, InlineQueryResultPhoto, InlineQueryResultVideo, InputFile, - InputMedia, InputMediaVideo, InputMessageContent, InputMessageContentText, + InlineQuery, InlineQueryResult, + InlineQueryResultArticle, InlineQueryResultPhoto, InlineQueryResultVideo, InputMessageContent, InputMessageContentText, }; -use url::Url; #[derive(Serialize, Deserialize, Debug, Clone)] pub enum DownloadResult { diff --git a/src/handlers/markups/inlines/currency.rs b/src/bot/inlines/currency.rs similarity index 97% rename from src/handlers/markups/inlines/currency.rs rename to src/bot/inlines/currency.rs index 6461767..7b1cba1 100644 --- a/src/handlers/markups/inlines/currency.rs +++ b/src/bot/inlines/currency.rs @@ -1,4 +1,6 @@ -use crate::{config::Config, db::schemas::user::User, util::errors::MyError}; +use crate::config::Config; +use crate::core::db::schemas::user::User; +use crate::errors::MyError; use log::{debug, error}; use mongodb::bson::doc; use oximod::Model; diff --git a/src/handlers/markups/inlines/mod.rs b/src/bot/inlines/mod.rs similarity index 100% rename from src/handlers/markups/inlines/mod.rs rename to src/bot/inlines/mod.rs index 1d8ffa1..e0a2024 100644 --- a/src/handlers/markups/inlines/mod.rs +++ b/src/bot/inlines/mod.rs @@ -1,3 +1,3 @@ -pub mod currency; pub mod cobalter; +pub mod currency; pub mod whisper; diff --git a/src/handlers/markups/inlines/whisper.rs b/src/bot/inlines/whisper.rs similarity index 99% rename from src/handlers/markups/inlines/whisper.rs rename to src/bot/inlines/whisper.rs index a3a2dcd..3c51779 100644 --- a/src/handlers/markups/inlines/whisper.rs +++ b/src/bot/inlines/whisper.rs @@ -11,7 +11,7 @@ use teloxide::types::{ use uuid::Uuid; use crate::config::Config; -use crate::util::errors::MyError; +use crate::errors::MyError; use log::error; use teloxide::utils::html; diff --git a/src/bot/mod.rs b/src/bot/mod.rs new file mode 100644 index 0000000..b3827ef --- /dev/null +++ b/src/bot/mod.rs @@ -0,0 +1,4 @@ +mod callbacks; +pub mod dispatcher; +mod handlers; +pub mod inlines; diff --git a/src/config.rs b/src/config/mod.rs similarity index 99% rename from src/config.rs rename to src/config/mod.rs index 11fd671..3528ddc 100644 --- a/src/config.rs +++ b/src/config/mod.rs @@ -1,4 +1,4 @@ -use crate::db::redis::RedisCache; +use crate::core::db::redis::RedisCache; use crate::util::currency::converter::{CurrencyConverter, OutputLanguage}; use crate::util::json::{JsonConfig, read_json_config}; use dotenv::dotenv; diff --git a/src/db/functions.rs b/src/core/db/functions.rs similarity index 85% rename from src/db/functions.rs rename to src/core/db/functions.rs index 7935fa2..777d286 100644 --- a/src/db/functions.rs +++ b/src/core/db/functions.rs @@ -1,4 +1,4 @@ -use crate::db::schemas::BaseFunctions; +use crate::core::db::schemas::BaseFunctions; use oximod::_error::oximod_error::OxiModError; pub async fn get_or_create(id: String) -> Result { diff --git a/src/db/mod.rs b/src/core/db/mod.rs similarity index 100% rename from src/db/mod.rs rename to src/core/db/mod.rs diff --git a/src/db/redis.rs b/src/core/db/redis.rs similarity index 100% rename from src/db/redis.rs rename to src/core/db/redis.rs diff --git a/src/db/schemas.rs b/src/core/db/schemas.rs similarity index 93% rename from src/db/schemas.rs rename to src/core/db/schemas.rs index 4efa6dd..e4c13b0 100644 --- a/src/db/schemas.rs +++ b/src/core/db/schemas.rs @@ -1,10 +1,10 @@ pub mod group; -pub mod user; pub mod settings; +pub mod user; -use crate::db::schemas::settings::ModuleSettings; +use crate::core::db::schemas::settings::ModuleSettings; +use crate::errors::MyError; use crate::util::currency::converter::CurrencyStruct; -use crate::util::errors::MyError; use async_trait::async_trait; use mongodb::results::UpdateResult; use oximod::_error::oximod_error::OxiModError; diff --git a/src/db/schemas/group.rs b/src/core/db/schemas/group.rs similarity index 94% rename from src/db/schemas/group.rs rename to src/core/db/schemas/group.rs index a176318..7d2fb5a 100644 --- a/src/db/schemas/group.rs +++ b/src/core/db/schemas/group.rs @@ -1,4 +1,5 @@ -use crate::db::schemas::{BaseFunctions, CurrenciesFunctions, CurrencyStruct}; +use crate::core::db::schemas::{BaseFunctions, CurrenciesFunctions}; +use crate::util::currency::converter::CurrencyStruct; use async_trait::async_trait; use mongodb::bson; use mongodb::bson::{doc, oid::ObjectId}; @@ -33,7 +34,7 @@ impl BaseFunctions for Group { .group_id(id.clone()) .convertable_currencies(vec![]); new_group.save().await?; - + ::find_by_id(id) .await? .ok_or_else(|| { diff --git a/src/db/schemas/settings.rs b/src/core/db/schemas/settings.rs similarity index 98% rename from src/db/schemas/settings.rs rename to src/core/db/schemas/settings.rs index 555faab..6b67a56 100644 --- a/src/db/schemas/settings.rs +++ b/src/core/db/schemas/settings.rs @@ -1,5 +1,5 @@ -use crate::db::schemas::SettingsRepo; -use crate::util::errors::MyError; +use crate::core::db::schemas::SettingsRepo; +use crate::errors::MyError; use async_trait::async_trait; use mongodb::bson; use mongodb::bson::{doc, oid::ObjectId}; diff --git a/src/db/schemas/user.rs b/src/core/db/schemas/user.rs similarity index 91% rename from src/db/schemas/user.rs rename to src/core/db/schemas/user.rs index 5018d03..8a3e30b 100644 --- a/src/db/schemas/user.rs +++ b/src/core/db/schemas/user.rs @@ -1,9 +1,10 @@ -use crate::db::schemas::{BaseFunctions, CurrenciesFunctions, CurrencyStruct}; +use crate::core::db::schemas::{BaseFunctions, CurrenciesFunctions}; +use crate::util::currency::converter::CurrencyStruct; use async_trait::async_trait; use mongodb::bson; use mongodb::bson::{doc, oid::ObjectId}; use mongodb::results::UpdateResult; -use oximod::{Model, _error::oximod_error::OxiModError}; +use oximod::{_error::oximod_error::OxiModError, Model}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize, Model)] @@ -60,7 +61,7 @@ impl CurrenciesFunctions for User { doc! {"user_id": user_id}, doc! {"$push": {"convertable_currencies": currency_to_add } }, ) - .await + .await } async fn remove_currency(user_id: &str, currency: &str) -> Result { @@ -68,7 +69,7 @@ impl CurrenciesFunctions for User { doc! {"user_id": user_id}, doc! {"$pull": {"convertable_currencies": {"code": currency} } }, ) - .await + .await } } @@ -78,6 +79,6 @@ impl User { doc! { "user_id": user_id }, doc! { "$inc": { "download_count": 1 } }, ) - .await + .await } } diff --git a/src/core/mod.rs b/src/core/mod.rs new file mode 100644 index 0000000..dec1023 --- /dev/null +++ b/src/core/mod.rs @@ -0,0 +1 @@ +pub mod db; diff --git a/src/util/errors.rs b/src/errors.rs similarity index 99% rename from src/util/errors.rs rename to src/errors.rs index 6c801d1..e9dec35 100644 --- a/src/util/errors.rs +++ b/src/errors.rs @@ -1,9 +1,9 @@ -use std::string::FromUtf8Error; use crate::util::currency::converter::ConvertError; use ccobalt::model::error::CobaltError; use mongodb::bson; use oximod::_error::oximod_error::OxiModError; use redis; +use std::string::FromUtf8Error; use teloxide::RequestError; use thiserror::Error; use url::ParseError; @@ -66,4 +66,4 @@ impl From for MyError { fn from(s: String) -> Self { MyError::Other(s) } -} \ No newline at end of file +} diff --git a/src/handlers/markups/mod.rs b/src/handlers/markups/mod.rs deleted file mode 100644 index 58375dc..0000000 --- a/src/handlers/markups/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -mod callbacks; -pub mod inlines; -pub mod markuper; diff --git a/src/lib.rs b/src/lib.rs index 10bf9dc..ae77c26 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,5 @@ -pub(crate) mod db; -pub(crate) mod config; -pub(crate) mod handlers; -pub mod loader; +pub mod bot; +pub mod config; +pub mod core; +pub mod errors; pub mod util; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 4b20379..4c8b208 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,4 @@ -mod config; -pub(crate) mod db; -mod handlers; -mod loader; -mod util; - -use crate::loader::run; +use fulturate::bot::dispatcher::run; use log::{error, info}; #[tokio::main] diff --git a/src/util/currency/converter.rs b/src/util/currency/converter.rs index 9e5a4fe..e7312db 100644 --- a/src/util/currency/converter.rs +++ b/src/util/currency/converter.rs @@ -6,9 +6,10 @@ use std::{ }; use super::structs::WORD_VALUES; -use crate::db::schemas::CurrenciesFunctions; -use crate::db::schemas::group::Group; -use crate::db::schemas::user::User; +use crate::core::db::schemas::CurrenciesFunctions; +use crate::core::db::schemas::group::Group; +use crate::core::db::schemas::user::User; +use crate::errors::MyError; use log::{debug, error, warn}; use once_cell::sync::Lazy; use oximod::Model; @@ -19,7 +20,6 @@ use teloxide::prelude::InlineQuery; use teloxide::types::Chat; use thiserror::Error; use tokio::sync::Mutex; -use crate::util::errors::MyError; const CACHE_DURATION_SECS: u64 = 60 * 10; pub const CURRENCY_CONFIG_PATH: &str = "currencies.json"; diff --git a/src/util/currency/mod.rs b/src/util/currency/mod.rs index d53112d..4a3b96f 100644 --- a/src/util/currency/mod.rs +++ b/src/util/currency/mod.rs @@ -1,2 +1,2 @@ pub mod converter; -mod structs; +pub mod structs; diff --git a/src/util/mod.rs b/src/util/mod.rs index 30e8772..dae7591 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,6 +1,5 @@ pub mod currency; pub mod enums; -pub mod errors; pub mod inline; pub mod json; -pub mod transcription; \ No newline at end of file +pub mod transcription; diff --git a/src/util/transcription.rs b/src/util/transcription.rs index 5cecf96..fea776a 100644 --- a/src/util/transcription.rs +++ b/src/util/transcription.rs @@ -1,19 +1,19 @@ use super::enums::AudioStruct; use crate::config::Config; -use crate::util::errors::MyError; +use crate::errors::MyError; use bytes::Bytes; use gem_rs::types::HarmBlockThreshold; use log::{debug, error, info}; use redis_macros::{FromRedisValue, ToRedisArgs}; use serde::{Deserialize, Serialize}; use std::time::Duration; +use teloxide::Bot; use teloxide::payloads::{EditMessageTextSetters, SendMessageSetters}; use teloxide::requests::{Request as TeloxideRequest, Requester}; use teloxide::types::{ CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message, MessageKind, ParseMode, ReplyParameters, }; -use teloxide::Bot; #[derive(Debug, Serialize, Deserialize, FromRedisValue, ToRedisArgs, Clone)] struct TranscriptionCache { From 62f0f4300558b79922933b2b3086641b8ccaff24 Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Sun, 7 Sep 2025 01:04:29 +0300 Subject: [PATCH 34/66] fixed translation escape --- src/bot/callbacks/translate.rs | 9 +++++---- src/bot/handlers/commands/translate.rs | 3 ++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/bot/callbacks/translate.rs b/src/bot/callbacks/translate.rs index 7ecb6d6..681f1ae 100644 --- a/src/bot/callbacks/translate.rs +++ b/src/bot/callbacks/translate.rs @@ -10,6 +10,7 @@ use teloxide::prelude::Requester; use teloxide::types::{ CallbackQuery, InlineKeyboardButton, MaybeInaccessibleMessage, Message, ParseMode, }; +use teloxide::utils::html::escape; use teloxide::{ApiError, RequestError}; use translators::{GoogleTranslator, Translator}; @@ -75,7 +76,7 @@ async fn handle_language_selection( message.id, "Ошибка: не удалось найти исходное сообщение. Попробуйте снова.", ) - .await?; + .await?; return Ok(()); } }; @@ -88,7 +89,7 @@ async fn handle_language_selection( message.id, "В исходном сообщении нет текста для перевода.", ) - .await?; + .await?; return Ok(()); } }; @@ -107,7 +108,7 @@ async fn handle_language_selection( .await .unwrap(); - let response = format!("
{}\n
", res,); + let response = format!("
{}\n
", escape(&res)); let lang_display_name = SUPPORTED_LANGUAGES .iter() @@ -153,4 +154,4 @@ async fn handle_show_languages(bot: Bot, message: &Message) -> Result<(), MyErro } } Ok(()) -} +} \ No newline at end of file diff --git a/src/bot/handlers/commands/translate.rs b/src/bot/handlers/commands/translate.rs index 83bd67d..cbaec9f 100644 --- a/src/bot/handlers/commands/translate.rs +++ b/src/bot/handlers/commands/translate.rs @@ -5,6 +5,7 @@ use teloxide::prelude::*; use teloxide::types::{ InlineKeyboardButton, InlineKeyboardMarkup, Message, ParseMode, ReplyParameters, }; +use teloxide::utils::html::escape; use translators::{GoogleTranslator, Translator}; pub const SUPPORTED_LANGUAGES: &[(&str, &str)] = &[ @@ -115,7 +116,7 @@ pub async fn translate_handler( .await .unwrap(); - let response = format!("
{}\n
", res,); + let response = format!("
{}\n
", escape(&res)); let lang_display_name = SUPPORTED_LANGUAGES .iter() From dc8f291112cbb6af1edbec65a4fd406d502e05fd Mon Sep 17 00:00:00 2001 From: nixxoq Date: Mon, 8 Sep 2025 21:50:06 +0300 Subject: [PATCH 35/66] New structure of project [2/3] todo: - move src/bot/messages/messager.rs to src/bot/messages.rs (with own function handler to easily add any message-related handler here) --- src/bot/callbacks/cobalt_pagination.rs | 2 +- src/bot/callbacks/mod.rs | 49 +++++ src/bot/callbacks/module.rs | 4 +- src/bot/callbacks/translate.rs | 11 +- .../{handlers/commander.rs => commands.rs} | 8 +- src/bot/dispatcher.rs | 8 +- src/bot/handlers/commands/mod.rs | 4 - src/bot/handlers/commands/translate.rs | 201 ------------------ .../settings.rs => currency_settings.rs} | 97 +-------- src/bot/handlers/markups/markuper.rs | 47 ---- src/bot/handlers/markups/mod.rs | 1 - src/bot/handlers/mod.rs | 10 +- .../{commands => }/speech_recognition.rs | 0 src/bot/handlers/{commands => }/start.rs | 0 src/bot/handlers/translate.rs | 103 +++++++++ src/bot/inlines/cobalter.rs | 6 +- .../keyboards.rs => keyboards/cobalt.rs} | 0 src/bot/keyboards/mod.rs | 2 + src/bot/keyboards/translate.rs | 44 ++++ src/bot/{handlers => }/messages/chat.rs | 0 src/bot/{handlers => }/messages/messager.rs | 2 +- src/bot/{handlers => }/messages/mod.rs | 0 src/bot/{handlers => }/messages/sound/mod.rs | 0 .../{handlers => }/messages/sound/voice.rs | 0 .../messages/sound/voice_note.rs | 0 src/bot/{handlers => }/messages/sounder.rs | 4 +- src/bot/mod.rs | 3 + src/core/mod.rs | 1 + src/core/services/currency.rs | 92 ++++++++ src/core/services/mod.rs | 3 + src/core/services/speech_recognition.rs | 0 src/core/services/translation.rs | 55 +++++ 32 files changed, 389 insertions(+), 368 deletions(-) rename src/bot/{handlers/commander.rs => commands.rs} (79%) delete mode 100644 src/bot/handlers/commands/mod.rs delete mode 100644 src/bot/handlers/commands/translate.rs rename src/bot/handlers/{commands/settings.rs => currency_settings.rs} (61%) delete mode 100644 src/bot/handlers/markups/markuper.rs delete mode 100644 src/bot/handlers/markups/mod.rs rename src/bot/handlers/{commands => }/speech_recognition.rs (100%) rename src/bot/handlers/{commands => }/start.rs (100%) create mode 100644 src/bot/handlers/translate.rs rename src/bot/{handlers/keyboards.rs => keyboards/cobalt.rs} (100%) create mode 100644 src/bot/keyboards/mod.rs create mode 100644 src/bot/keyboards/translate.rs rename src/bot/{handlers => }/messages/chat.rs (100%) rename src/bot/{handlers => }/messages/messager.rs (97%) rename src/bot/{handlers => }/messages/mod.rs (100%) rename src/bot/{handlers => }/messages/sound/mod.rs (100%) rename src/bot/{handlers => }/messages/sound/voice.rs (100%) rename src/bot/{handlers => }/messages/sound/voice_note.rs (100%) rename src/bot/{handlers => }/messages/sounder.rs (79%) create mode 100644 src/core/services/currency.rs create mode 100644 src/core/services/mod.rs create mode 100644 src/core/services/speech_recognition.rs create mode 100644 src/core/services/translation.rs diff --git a/src/bot/callbacks/cobalt_pagination.rs b/src/bot/callbacks/cobalt_pagination.rs index 0977c47..82df3dc 100644 --- a/src/bot/callbacks/cobalt_pagination.rs +++ b/src/bot/callbacks/cobalt_pagination.rs @@ -1,4 +1,4 @@ -use crate::bot::handlers::keyboards::make_photo_pagination_keyboard; +use crate::bot::keyboards::cobalt::make_photo_pagination_keyboard; use crate::bot::inlines::cobalter::DownloadResult; use crate::config::Config; use crate::errors::MyError; diff --git a/src/bot/callbacks/mod.rs b/src/bot/callbacks/mod.rs index 70d1327..68d881b 100644 --- a/src/bot/callbacks/mod.rs +++ b/src/bot/callbacks/mod.rs @@ -1,5 +1,54 @@ +use crate::bot::callbacks::cobalt_pagination::handle_cobalt_pagination; +use crate::bot::callbacks::delete::delete_message_handler; +use crate::bot::callbacks::module::{ + module_option_handler, module_select_handler, module_toggle_handler, settings_back_handler, + settings_set_handler, +}; +use crate::bot::callbacks::translate::handle_translate_callback; +use crate::bot::callbacks::whisper::handle_whisper_callback; +use crate::config::Config; +use crate::errors::MyError; +use crate::util::transcription::{back_handler, summarization_handler}; +use std::sync::Arc; +use teloxide::Bot; +use teloxide::prelude::{CallbackQuery, Requester}; + pub mod cobalt_pagination; pub mod delete; pub mod module; pub mod translate; pub mod whisper; + +pub async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { + let config = Arc::new(Config::new().await); + + if let Some(data) = &q.data { + if data.starts_with("settings_set:") { + settings_set_handler(bot, q).await? + } else if data.starts_with("delete_msg") { + delete_message_handler(bot, q).await? + } else if data.starts_with("summarize") { + summarization_handler(bot, q, &config).await? + } else if data.starts_with("back_to_full") { + back_handler(bot, q, &config).await? + } else if data.starts_with("module_select:") { + module_select_handler(bot, q).await? + } else if data.starts_with("module_toggle") { + module_toggle_handler(bot, q).await? + } else if data.starts_with("module_opt:") { + module_option_handler(bot, q).await? + } else if data.starts_with("settings_back:") { + settings_back_handler(bot, q).await? + } else if data.starts_with("whisper") { + handle_whisper_callback(bot, q, &config).await? + } else if data.starts_with("tr_") { + handle_translate_callback(bot, q, &config).await? + } else if data.starts_with("cobalt:") { + handle_cobalt_pagination(bot, q, config).await? + } else { + bot.answer_callback_query(q.id).await?; + } + } + + Ok(()) +} diff --git a/src/bot/callbacks/module.rs b/src/bot/callbacks/module.rs index 69e22dd..09a06c1 100644 --- a/src/bot/callbacks/module.rs +++ b/src/bot/callbacks/module.rs @@ -1,5 +1,5 @@ -use crate::bot::handlers::commands::settings::update_settings_message; -use crate::bot::handlers::keyboards::make_option_selection_keyboard; +use crate::bot::handlers::currency_settings::update_settings_message; +use crate::bot::keyboards::cobalt::make_option_selection_keyboard; use crate::core::db::schemas::SettingsRepo; use crate::core::db::schemas::settings::Settings; use crate::errors::MyError; diff --git a/src/bot/callbacks/translate.rs b/src/bot/callbacks/translate.rs index 681f1ae..05b868f 100644 --- a/src/bot/callbacks/translate.rs +++ b/src/bot/callbacks/translate.rs @@ -1,7 +1,6 @@ -use crate::bot::handlers::commands::translate::{ - SUPPORTED_LANGUAGES, create_language_keyboard, normalize_language_code, -}; +use crate::bot::keyboards::translate::create_language_keyboard; use crate::config::Config; +use crate::core::services::translation::{SUPPORTED_LANGUAGES, normalize_language_code}; use crate::errors::MyError; use crate::util::inline::delete_message_button; use teloxide::Bot; @@ -76,7 +75,7 @@ async fn handle_language_selection( message.id, "Ошибка: не удалось найти исходное сообщение. Попробуйте снова.", ) - .await?; + .await?; return Ok(()); } }; @@ -89,7 +88,7 @@ async fn handle_language_selection( message.id, "В исходном сообщении нет текста для перевода.", ) - .await?; + .await?; return Ok(()); } }; @@ -154,4 +153,4 @@ async fn handle_show_languages(bot: Bot, message: &Message) -> Result<(), MyErro } } Ok(()) -} \ No newline at end of file +} diff --git a/src/bot/handlers/commander.rs b/src/bot/commands.rs similarity index 79% rename from src/bot/handlers/commander.rs rename to src/bot/commands.rs index 371cddd..46c0f9d 100644 --- a/src/bot/handlers/commander.rs +++ b/src/bot/commands.rs @@ -1,9 +1,9 @@ -use crate::bot::handlers::commands::settings::{ +use crate::bot::handlers::currency_settings::{ currency_codes_handler, currency_codes_list_handler, settings_command_handler, }; -use crate::bot::handlers::commands::speech_recognition::speech_recognition_handler; -use crate::bot::handlers::commands::start::start_handler; -use crate::bot::handlers::commands::translate::translate_handler; +use crate::bot::handlers::speech_recognition::speech_recognition_handler; +use crate::bot::handlers::start::start_handler; +use crate::bot::handlers::translate::translate_handler; use crate::config::Config; use crate::errors::MyError; use crate::util::enums::Command; diff --git a/src/bot/dispatcher.rs b/src/bot/dispatcher.rs index 200a765..e850669 100644 --- a/src/bot/dispatcher.rs +++ b/src/bot/dispatcher.rs @@ -1,7 +1,7 @@ -use crate::bot::handlers::commander::command_handlers; -use crate::bot::handlers::markups::markuper::callback_query_handlers; -use crate::bot::handlers::messages::chat::handle_bot_added; -use crate::bot::handlers::messages::messager::{handle_currency, handle_speech}; +use crate::bot::callbacks::callback_query_handlers; +use crate::bot::commands::command_handlers; +use crate::bot::messages::chat::handle_bot_added; +use crate::bot::messages::messager::{handle_currency, handle_speech}; use crate::bot::inlines::cobalter::{handle_cobalt_inline, is_query_url}; use crate::bot::inlines::currency::handle_currency_inline; use crate::bot::inlines::whisper::{handle_whisper_inline, is_whisper_query}; diff --git a/src/bot/handlers/commands/mod.rs b/src/bot/handlers/commands/mod.rs deleted file mode 100644 index 9aa0656..0000000 --- a/src/bot/handlers/commands/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod settings; -pub mod speech_recognition; -pub mod start; -pub mod translate; diff --git a/src/bot/handlers/commands/translate.rs b/src/bot/handlers/commands/translate.rs deleted file mode 100644 index cbaec9f..0000000 --- a/src/bot/handlers/commands/translate.rs +++ /dev/null @@ -1,201 +0,0 @@ -use crate::config::Config; -use crate::errors::MyError; -use crate::util::inline::delete_message_button; -use teloxide::prelude::*; -use teloxide::types::{ - InlineKeyboardButton, InlineKeyboardMarkup, Message, ParseMode, ReplyParameters, -}; -use teloxide::utils::html::escape; -use translators::{GoogleTranslator, Translator}; - -pub const SUPPORTED_LANGUAGES: &[(&str, &str)] = &[ - ("uk", "🇺🇦 Українська"), - ("en", "🇬🇧 English"), - ("us", "🇺🇸 English (US)"), - ("ru", "🇷🇺 Русский"), - ("de", "🇩🇪 Deutsch"), - ("fr", "🇫🇷 Français"), - ("es", "🇪🇸 Español"), - ("it", "🇮🇹 Italiano"), - ("zh", "🇨🇳 中文"), - ("ja", "🇯🇵 日本語"), - ("ko", "🇰🇷 한국어"), - ("pl", "🇵🇱 Polski"), - ("ar", "🇸🇦 العربية"), - ("pt", "🇵🇹 Português"), - ("tr", "🇹🇷 Türkçe"), - ("nl", "🇳🇱 Nederlands"), - ("sv", "🇸🇪 Svenska"), - ("no", "🇳🇴 Norsk"), - ("da", "🇩🇰 Dansk"), - ("fi", "🇫🇮 Suomi"), - ("el", "🇬🇷 Ελληνικά"), - ("he", "🇮🇱 עברית"), - ("hi", "🇮🇳 हिन्दी"), - ("id", "🇮🇩 Indonesia"), - ("vi", "🇻🇳 Tiếng Việt"), - ("th", "🇹🇭 ภาษาไทย"), - ("cs", "🇨🇿 Čeština"), - ("hu", "🇭🇺 Magyar"), - ("ro", "🇷🇴 Română"), - ("bg", "🇧🇬 Български"), - ("sr", "🇷🇸 Српски"), - ("hr", "🇭🇷 Hrvatski"), - ("sk", "🇸🇰 Slovenčina"), - ("sl", "🇸🇮 Slovenščina"), - ("lt", "🇱🇹 Lietuvių"), - ("lv", "🇱🇻 Latviešu"), - ("et", "🇪🇪 Eesti"), -]; -pub const LANGUAGES_PER_PAGE: usize = 6; - -pub async fn translate_handler( - bot: Bot, - msg: &Message, - config: &Config, - arg: String, -) -> Result<(), MyError> { - let replied_to_message = match msg.reply_to_message() { - Some(message) => message, - None => { - bot.send_message( - msg.chat.id, - "Нужно ответить на то сообщение, которое требуется перевести, чтобы использовать эту команду.", - ) - .reply_parameters(ReplyParameters::new(msg.id)) - .parse_mode(ParseMode::Html) - .await?; - return Ok(()); - } - }; - - let text_to_translate = match replied_to_message.text() { - Some(text) => text, - None => { - bot.send_message(msg.chat.id, "Отвечать нужно на сообщение с текстом.") - .reply_parameters(ReplyParameters::new(msg.id)) - .parse_mode(ParseMode::Html) - .await?; - return Ok(()); - } - }; - - let user = msg.from.clone().unwrap(); - if replied_to_message.clone().from.unwrap().is_bot { - bot.send_message(msg.chat.id, "Отвечать нужно на сообщение от пользователя.") - .reply_parameters(ReplyParameters::new(msg.id)) - .parse_mode(ParseMode::Html) - .await?; - return Ok(()); - } - - let target_lang: String; - - if !arg.trim().is_empty() { - target_lang = normalize_language_code(&arg.trim()); - } else { - let redis_key = format!("user_lang:{}", user.id); - let redis_client = config.get_redis_client(); - let cached_lang: Option = redis_client.get(&redis_key).await?; - - if let Some(lang) = cached_lang { - target_lang = lang; - } else { - let keyboard = create_language_keyboard(0); - bot.send_message(msg.chat.id, "Выберите язык для перевода:") - .reply_markup(keyboard) - .reply_parameters(ReplyParameters::new(replied_to_message.id)) - .await?; - return Ok(()); - } - } - - let google_trans = GoogleTranslator::default(); - let res = google_trans - .translate_async(text_to_translate, "", &*target_lang) - .await - .unwrap(); - - let response = format!("
{}\n
", escape(&res)); - - let lang_display_name = SUPPORTED_LANGUAGES - .iter() - .find(|(code, _)| *code == target_lang) - .map(|(_, name)| *name) - .unwrap_or(&target_lang); - - let switch_lang_button = - InlineKeyboardButton::callback(lang_display_name.to_string(), "tr_show_langs".to_string()); - - let mut keyboard = delete_message_button(user.id.0); - if let Some(first_row) = keyboard.inline_keyboard.get_mut(0) { - first_row.insert(0, switch_lang_button); - } else { - keyboard.inline_keyboard.push(vec![switch_lang_button]); - } - - bot.send_message(msg.chat.id, response) - .reply_parameters(ReplyParameters::new(replied_to_message.id)) - .parse_mode(ParseMode::Html) - .reply_markup(keyboard) - .await?; - - Ok(()) -} - -pub fn create_language_keyboard(page: usize) -> InlineKeyboardMarkup { - let mut keyboard: Vec> = Vec::new(); - let start = page * LANGUAGES_PER_PAGE; - let end = std::cmp::min(start + LANGUAGES_PER_PAGE, SUPPORTED_LANGUAGES.len()); - - if start >= end { - return InlineKeyboardMarkup::new(keyboard); - } - - let page_languages = &SUPPORTED_LANGUAGES[start..end]; - - for chunk in page_languages.chunks(2) { - let row = chunk - .iter() - .map(|(code, name)| { - InlineKeyboardButton::callback(name.to_string(), format!("tr_lang:{}", code)) - }) - .collect(); - keyboard.push(row); - } - - let mut nav_row: Vec = Vec::new(); - if page > 0 { - nav_row.push(InlineKeyboardButton::callback( - "⬅️".to_string(), - format!("tr_page:{}", page - 1), - )); - } - if end < SUPPORTED_LANGUAGES.len() { - nav_row.push(InlineKeyboardButton::callback( - "➡️".to_string(), - format!("tr_page:{}", page + 1), - )); - } - - if !nav_row.is_empty() { - keyboard.push(nav_row); - } - - InlineKeyboardMarkup::new(keyboard) -} - -pub fn normalize_language_code(lang: &str) -> String { - match lang.to_lowercase().as_str() { - "ua" | "ukrainian" | "украинский" | "uk" => "uk".to_string(), - "ru" | "russian" | "русский" => "ru".to_string(), - "en" | "english" | "английский" => "en".to_string(), - "de" | "german" | "немецкий" => "de".to_string(), - "fr" | "french" | "французский" => "fr".to_string(), - "es" | "spanish" | "испанский" => "es".to_string(), - "it" | "italian" | "итальянский" => "it".to_string(), - "zh" | "chinese" | "китайский" => "zh".to_string(), - "ja" | "japanese" | "японский" => "ja".to_string(), - _ => lang.to_lowercase(), - } -} diff --git a/src/bot/handlers/commands/settings.rs b/src/bot/handlers/currency_settings.rs similarity index 61% rename from src/bot/handlers/commands/settings.rs rename to src/bot/handlers/currency_settings.rs index 65c2381..c6a02a5 100644 --- a/src/bot/handlers/commands/settings.rs +++ b/src/bot/handlers/currency_settings.rs @@ -1,80 +1,17 @@ use crate::config::Config; -use crate::core::db::functions::get_or_create; +use crate::core::db::schemas::SettingsRepo; use crate::core::db::schemas::group::Group; use crate::core::db::schemas::settings::Settings; use crate::core::db::schemas::user::User; -use crate::core::db::schemas::{BaseFunctions, CurrenciesFunctions, SettingsRepo}; +use crate::core::services::currency::{get_enabled_codes, handle_currency_update}; use crate::errors::MyError; use crate::util::currency::converter::{CURRENCY_CONFIG_PATH, get_all_currency_codes}; -use log::error; -use oximod::Model; -use std::collections::HashSet; use teloxide::prelude::*; use teloxide::types::{ InlineKeyboardButton, InlineKeyboardMarkup, MaybeInaccessibleMessage, ParseMode, ReplyParameters, }; -pub async fn handle_currency_update( - bot: Bot, - msg: Message, - code: String, -) -> Result<(), MyError> { - let code = code.to_uppercase(); - - let all_codes = get_all_currency_codes(CURRENCY_CONFIG_PATH.parse().unwrap())?; - if !all_codes.iter().any(|c| c.code == code) { - let message = format!("Currency code {} does not exist.", code); - bot.send_message(msg.chat.id, message) - .parse_mode(ParseMode::Html) - .reply_parameters(ReplyParameters::new(msg.id)) - .await?; - return Ok(()); - } - - let entity_id = msg.chat.id.to_string(); - let entity = match get_or_create::(entity_id).await { - Ok(e) => e, - Err(e) => { - error!("Failed to get or create entity: {:?}", e); - let message = "Error: Could not access settings. Try again".to_string(); - bot.send_message(msg.chat.id, message) - .reply_parameters(ReplyParameters::new(msg.id)) - .await?; - return Ok(()); - } - }; - - let is_enabled = entity.get_currencies().iter().any(|c| c.code == code); - - let (update_func, action_text) = if is_enabled { - (T::remove_currency(entity.get_id(), &code), "removed") - } else { - let currency_to_add = all_codes.iter().find(|x| x.code == code).unwrap(); - (T::add_currency(entity.get_id(), currency_to_add), "added") - }; - - let message = match update_func.await { - Ok(_) => { - format!( - "Successfully {} {} from currency conversion.", - action_text, code - ) - } - Err(e) => { - error!("--- Update failed: {:?} ---", e); - "Failed to apply changes.".to_string() - } - }; - - bot.send_message(msg.chat.id, message) - .parse_mode(ParseMode::Html) - .reply_parameters(ReplyParameters::new(msg.id)) - .await?; - - Ok(()) -} - pub async fn currency_codes_handler(bot: Bot, msg: Message, code: String) -> Result<(), MyError> { if msg.chat.is_private() { handle_currency_update::(bot, msg, code).await @@ -83,27 +20,9 @@ pub async fn currency_codes_handler(bot: Bot, msg: Message, code: String) -> Res } } -async fn get_enabled_codes(msg: &Message) -> HashSet { - let chat_id = msg.chat.id.to_string(); - if msg.chat.is_private() { - if let Ok(Some(user)) = ::find_by_id(chat_id).await { - return user - .get_currencies() - .iter() - .map(|c| c.code.clone()) - .collect(); - } - } else if let Ok(Some(group)) = ::find_by_id(chat_id).await { - return group - .get_currencies() - .iter() - .map(|c| c.code.clone()) - .collect(); - } - - HashSet::new() -} - +/// Deprecated, but still working currency settings +/// +/// TODO: move currency settings to /settings in near future pub async fn currency_codes_list_handler(bot: Bot, msg: Message) -> Result<(), MyError> { let mut message = String::from("Could not load available currencies."); @@ -152,7 +71,11 @@ pub async fn currency_codes_list_handler(bot: Bot, msg: Message) -> Result<(), M Ok(()) } -// new settings +/// +/// new settings +/// +/// TODO: Use traits (interfaces) as a basis for other modules to iterate and show them in the settings menu +/// (or this system, maybe, waiting for entire rewrite) pub async fn settings_command_handler( bot: Bot, message: Message, diff --git a/src/bot/handlers/markups/markuper.rs b/src/bot/handlers/markups/markuper.rs deleted file mode 100644 index a506e17..0000000 --- a/src/bot/handlers/markups/markuper.rs +++ /dev/null @@ -1,47 +0,0 @@ -use crate::bot::callbacks::cobalt_pagination::handle_cobalt_pagination; -use crate::bot::callbacks::delete::delete_message_handler; -use crate::bot::callbacks::module::{ - module_option_handler, module_select_handler, module_toggle_handler, settings_back_handler, - settings_set_handler, -}; -use crate::bot::callbacks::translate::handle_translate_callback; -use crate::bot::callbacks::whisper::handle_whisper_callback; -use crate::config::Config; -use crate::errors::MyError; -use crate::util::transcription::{back_handler, summarization_handler}; -use std::sync::Arc; -use teloxide::{Bot, requests::Requester, types::CallbackQuery}; - -pub async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { - let config = Arc::new(Config::new().await); - - if let Some(data) = &q.data { - if data.starts_with("settings_set:") { - settings_set_handler(bot, q).await? - } else if data.starts_with("delete_msg") { - delete_message_handler(bot, q).await? - } else if data.starts_with("summarize") { - summarization_handler(bot, q, &config).await? - } else if data.starts_with("back_to_full") { - back_handler(bot, q, &config).await? - } else if data.starts_with("module_select:") { - module_select_handler(bot, q).await? - } else if data.starts_with("module_toggle") { - module_toggle_handler(bot, q).await? - } else if data.starts_with("module_opt:") { - module_option_handler(bot, q).await? - } else if data.starts_with("settings_back:") { - settings_back_handler(bot, q).await? - } else if data.starts_with("whisper") { - handle_whisper_callback(bot, q, &config).await? - } else if data.starts_with("tr_") { - handle_translate_callback(bot, q, &config).await? - } else if data.starts_with("cobalt:") { - handle_cobalt_pagination(bot, q, config).await? - } else { - bot.answer_callback_query(q.id).await?; - } - } - - Ok(()) -} diff --git a/src/bot/handlers/markups/mod.rs b/src/bot/handlers/markups/mod.rs deleted file mode 100644 index 46ffe0a..0000000 --- a/src/bot/handlers/markups/mod.rs +++ /dev/null @@ -1 +0,0 @@ -pub mod markuper; diff --git a/src/bot/handlers/mod.rs b/src/bot/handlers/mod.rs index de9a65e..7b2a1cf 100644 --- a/src/bot/handlers/mod.rs +++ b/src/bot/handlers/mod.rs @@ -1,5 +1,5 @@ -pub mod commander; -pub mod commands; -pub mod keyboards; -pub mod markups; -pub mod messages; +// pub mod commands; +pub mod currency_settings; +pub mod speech_recognition; +pub mod start; +pub mod translate; diff --git a/src/bot/handlers/commands/speech_recognition.rs b/src/bot/handlers/speech_recognition.rs similarity index 100% rename from src/bot/handlers/commands/speech_recognition.rs rename to src/bot/handlers/speech_recognition.rs diff --git a/src/bot/handlers/commands/start.rs b/src/bot/handlers/start.rs similarity index 100% rename from src/bot/handlers/commands/start.rs rename to src/bot/handlers/start.rs diff --git a/src/bot/handlers/translate.rs b/src/bot/handlers/translate.rs new file mode 100644 index 0000000..8bbd88f --- /dev/null +++ b/src/bot/handlers/translate.rs @@ -0,0 +1,103 @@ +use crate::bot::keyboards::translate::create_language_keyboard; +use crate::config::Config; +use crate::core::services::translation::{SUPPORTED_LANGUAGES, normalize_language_code}; +use crate::errors::MyError; +use crate::util::inline::delete_message_button; +use teloxide::prelude::*; +use teloxide::types::{InlineKeyboardButton, Message, ParseMode, ReplyParameters}; +use teloxide::utils::html::escape; +use translators::{GoogleTranslator, Translator}; + +pub async fn translate_handler( + bot: Bot, + msg: &Message, + config: &Config, + arg: String, +) -> Result<(), MyError> { + let replied_to_message = match msg.reply_to_message() { + Some(message) => message, + None => { + bot.send_message( + msg.chat.id, + "Нужно ответить на то сообщение, которое требуется перевести, чтобы использовать эту команду.", + ) + .reply_parameters(ReplyParameters::new(msg.id)) + .parse_mode(ParseMode::Html) + .await?; + return Ok(()); + } + }; + + let text_to_translate = match replied_to_message.text() { + Some(text) => text, + None => { + bot.send_message(msg.chat.id, "Отвечать нужно на сообщение с текстом.") + .reply_parameters(ReplyParameters::new(msg.id)) + .parse_mode(ParseMode::Html) + .await?; + return Ok(()); + } + }; + + let user = msg.from.clone().unwrap(); + if replied_to_message.clone().from.unwrap().is_bot { + bot.send_message(msg.chat.id, "Отвечать нужно на сообщение от пользователя.") + .reply_parameters(ReplyParameters::new(msg.id)) + .parse_mode(ParseMode::Html) + .await?; + return Ok(()); + } + + let target_lang: String; + + if !arg.trim().is_empty() { + target_lang = normalize_language_code(&arg.trim()); + } else { + let redis_key = format!("user_lang:{}", user.id); + let redis_client = config.get_redis_client(); + let cached_lang: Option = redis_client.get(&redis_key).await?; + + if let Some(lang) = cached_lang { + target_lang = lang; + } else { + let keyboard = create_language_keyboard(0); + bot.send_message(msg.chat.id, "Выберите язык для перевода:") + .reply_markup(keyboard) + .reply_parameters(ReplyParameters::new(replied_to_message.id)) + .await?; + return Ok(()); + } + } + + let google_trans = GoogleTranslator::default(); + let res = google_trans + .translate_async(text_to_translate, "", &*target_lang) + .await + .unwrap(); + + let response = format!("
{}\n
", escape(&res)); + + let lang_display_name = SUPPORTED_LANGUAGES + .iter() + .find(|(code, _)| *code == target_lang) + .map(|(_, name)| *name) + .unwrap_or(&target_lang); + + let switch_lang_button = + InlineKeyboardButton::callback(lang_display_name.to_string(), "tr_show_langs".to_string()); + + let mut keyboard = delete_message_button(user.id.0); + if let Some(first_row) = keyboard.inline_keyboard.get_mut(0) { + first_row.insert(0, switch_lang_button); + } else { + keyboard.inline_keyboard.push(vec![switch_lang_button]); + } + + bot.send_message(msg.chat.id, response) + .reply_parameters(ReplyParameters::new(replied_to_message.id)) + .parse_mode(ParseMode::Html) + .reply_markup(keyboard) + .await?; + + Ok(()) +} diff --git a/src/bot/inlines/cobalter.rs b/src/bot/inlines/cobalter.rs index eb42789..8189878 100644 --- a/src/bot/inlines/cobalter.rs +++ b/src/bot/inlines/cobalter.rs @@ -1,4 +1,4 @@ -use crate::bot::handlers::keyboards::{make_photo_pagination_keyboard, make_single_url_keyboard}; +use crate::bot::keyboards::cobalt::{make_photo_pagination_keyboard, make_single_url_keyboard}; use crate::config::Config; use crate::core::db::schemas::SettingsRepo; use crate::core::db::schemas::settings::Settings; @@ -12,8 +12,8 @@ use std::sync::Arc; use teloxide::Bot; use teloxide::prelude::*; use teloxide::types::{ - InlineQuery, InlineQueryResult, - InlineQueryResultArticle, InlineQueryResultPhoto, InlineQueryResultVideo, InputMessageContent, InputMessageContentText, + InlineQuery, InlineQueryResult, InlineQueryResultArticle, InlineQueryResultPhoto, + InlineQueryResultVideo, InputMessageContent, InputMessageContentText, }; #[derive(Serialize, Deserialize, Debug, Clone)] diff --git a/src/bot/handlers/keyboards.rs b/src/bot/keyboards/cobalt.rs similarity index 100% rename from src/bot/handlers/keyboards.rs rename to src/bot/keyboards/cobalt.rs diff --git a/src/bot/keyboards/mod.rs b/src/bot/keyboards/mod.rs new file mode 100644 index 0000000..5a447cb --- /dev/null +++ b/src/bot/keyboards/mod.rs @@ -0,0 +1,2 @@ +pub mod translate; +pub mod cobalt; \ No newline at end of file diff --git a/src/bot/keyboards/translate.rs b/src/bot/keyboards/translate.rs new file mode 100644 index 0000000..7e399de --- /dev/null +++ b/src/bot/keyboards/translate.rs @@ -0,0 +1,44 @@ +use crate::core::services::translation::{LANGUAGES_PER_PAGE, SUPPORTED_LANGUAGES}; +use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup}; + +pub fn create_language_keyboard(page: usize) -> InlineKeyboardMarkup { + let mut keyboard: Vec> = Vec::new(); + let start = page * LANGUAGES_PER_PAGE; + let end = std::cmp::min(start + LANGUAGES_PER_PAGE, SUPPORTED_LANGUAGES.len()); + + if start >= end { + return InlineKeyboardMarkup::new(keyboard); + } + + let page_languages = &SUPPORTED_LANGUAGES[start..end]; + + for chunk in page_languages.chunks(2) { + let row = chunk + .iter() + .map(|(code, name)| { + InlineKeyboardButton::callback(name.to_string(), format!("tr_lang:{}", code)) + }) + .collect(); + keyboard.push(row); + } + + let mut nav_row: Vec = Vec::new(); + if page > 0 { + nav_row.push(InlineKeyboardButton::callback( + "⬅️".to_string(), + format!("tr_page:{}", page - 1), + )); + } + if end < SUPPORTED_LANGUAGES.len() { + nav_row.push(InlineKeyboardButton::callback( + "➡️".to_string(), + format!("tr_page:{}", page + 1), + )); + } + + if !nav_row.is_empty() { + keyboard.push(nav_row); + } + + InlineKeyboardMarkup::new(keyboard) +} diff --git a/src/bot/handlers/messages/chat.rs b/src/bot/messages/chat.rs similarity index 100% rename from src/bot/handlers/messages/chat.rs rename to src/bot/messages/chat.rs diff --git a/src/bot/handlers/messages/messager.rs b/src/bot/messages/messager.rs similarity index 97% rename from src/bot/handlers/messages/messager.rs rename to src/bot/messages/messager.rs index 846cbd7..df794c1 100644 --- a/src/bot/handlers/messages/messager.rs +++ b/src/bot/messages/messager.rs @@ -1,4 +1,4 @@ -use crate::bot::handlers::messages::sounder::sound_handlers; +use crate::bot::messages::sounder::sound_handlers; use crate::config::Config; use crate::errors::MyError; use crate::util::inline::delete_message_button; diff --git a/src/bot/handlers/messages/mod.rs b/src/bot/messages/mod.rs similarity index 100% rename from src/bot/handlers/messages/mod.rs rename to src/bot/messages/mod.rs diff --git a/src/bot/handlers/messages/sound/mod.rs b/src/bot/messages/sound/mod.rs similarity index 100% rename from src/bot/handlers/messages/sound/mod.rs rename to src/bot/messages/sound/mod.rs diff --git a/src/bot/handlers/messages/sound/voice.rs b/src/bot/messages/sound/voice.rs similarity index 100% rename from src/bot/handlers/messages/sound/voice.rs rename to src/bot/messages/sound/voice.rs diff --git a/src/bot/handlers/messages/sound/voice_note.rs b/src/bot/messages/sound/voice_note.rs similarity index 100% rename from src/bot/handlers/messages/sound/voice_note.rs rename to src/bot/messages/sound/voice_note.rs diff --git a/src/bot/handlers/messages/sounder.rs b/src/bot/messages/sounder.rs similarity index 79% rename from src/bot/handlers/messages/sounder.rs rename to src/bot/messages/sounder.rs index 76b0dfb..8ea2d63 100644 --- a/src/bot/handlers/messages/sounder.rs +++ b/src/bot/messages/sounder.rs @@ -1,5 +1,5 @@ -use crate::bot::handlers::messages::sound::voice::voice_handler; -use crate::bot::handlers::messages::sound::voice_note::voice_note_handler; +use crate::bot::messages::sound::voice::voice_handler; +use crate::bot::messages::sound::voice_note::voice_note_handler; use crate::config::Config; use crate::errors::MyError; use teloxide::Bot; diff --git a/src/bot/mod.rs b/src/bot/mod.rs index b3827ef..aca7c3b 100644 --- a/src/bot/mod.rs +++ b/src/bot/mod.rs @@ -1,4 +1,7 @@ mod callbacks; +pub mod commands; pub mod dispatcher; mod handlers; pub mod inlines; +pub mod keyboards; +pub mod messages; diff --git a/src/core/mod.rs b/src/core/mod.rs index dec1023..7163739 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1 +1,2 @@ pub mod db; +pub mod services; diff --git a/src/core/services/currency.rs b/src/core/services/currency.rs new file mode 100644 index 0000000..fe43179 --- /dev/null +++ b/src/core/services/currency.rs @@ -0,0 +1,92 @@ +use crate::core::db::functions::get_or_create; +use crate::core::db::schemas::group::Group; +use crate::core::db::schemas::user::User; +use crate::core::db::schemas::{BaseFunctions, CurrenciesFunctions}; +use crate::errors::MyError; +use crate::util::currency::converter::{CURRENCY_CONFIG_PATH, get_all_currency_codes}; +use log::error; +use oximod::Model; +use std::collections::HashSet; +use teloxide::prelude::*; +use teloxide::types::{ParseMode, ReplyParameters}; + +pub async fn handle_currency_update( + bot: Bot, + msg: Message, + code: String, +) -> Result<(), MyError> { + let code = code.to_uppercase(); + + let all_codes = get_all_currency_codes(CURRENCY_CONFIG_PATH.parse().unwrap())?; + if !all_codes.iter().any(|c| c.code == code) { + let message = format!("Currency code {} does not exist.", code); + bot.send_message(msg.chat.id, message) + .parse_mode(ParseMode::Html) + .reply_parameters(ReplyParameters::new(msg.id)) + .await?; + return Ok(()); + } + + let entity_id = msg.chat.id.to_string(); + let entity = match get_or_create::(entity_id).await { + Ok(e) => e, + Err(e) => { + error!("Failed to get or create entity: {:?}", e); + let message = "Error: Could not access settings. Try again".to_string(); + bot.send_message(msg.chat.id, message) + .reply_parameters(ReplyParameters::new(msg.id)) + .await?; + return Ok(()); + } + }; + + let is_enabled = entity.get_currencies().iter().any(|c| c.code == code); + + let (update_func, action_text) = if is_enabled { + (T::remove_currency(entity.get_id(), &code), "removed") + } else { + let currency_to_add = all_codes.iter().find(|x| x.code == code).unwrap(); + (T::add_currency(entity.get_id(), currency_to_add), "added") + }; + + let message = match update_func.await { + Ok(_) => { + format!( + "Successfully {} {} from currency conversion.", + action_text, code + ) + } + Err(e) => { + error!("--- Update failed: {:?} ---", e); + "Failed to apply changes.".to_string() + } + }; + + bot.send_message(msg.chat.id, message) + .parse_mode(ParseMode::Html) + .reply_parameters(ReplyParameters::new(msg.id)) + .await?; + + Ok(()) +} + +pub async fn get_enabled_codes(msg: &Message) -> HashSet { + let chat_id = msg.chat.id.to_string(); + if msg.chat.is_private() { + if let Ok(Some(user)) = ::find_by_id(chat_id).await { + return user + .get_currencies() + .iter() + .map(|c| c.code.clone()) + .collect(); + } + } else if let Ok(Some(group)) = ::find_by_id(chat_id).await { + return group + .get_currencies() + .iter() + .map(|c| c.code.clone()) + .collect(); + } + + HashSet::new() +} diff --git a/src/core/services/mod.rs b/src/core/services/mod.rs new file mode 100644 index 0000000..cb278c3 --- /dev/null +++ b/src/core/services/mod.rs @@ -0,0 +1,3 @@ +pub mod currency; +pub mod speech_recognition; +pub mod translation; diff --git a/src/core/services/speech_recognition.rs b/src/core/services/speech_recognition.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/core/services/translation.rs b/src/core/services/translation.rs new file mode 100644 index 0000000..113f6a5 --- /dev/null +++ b/src/core/services/translation.rs @@ -0,0 +1,55 @@ +pub const SUPPORTED_LANGUAGES: &[(&str, &str)] = &[ + ("uk", "🇺🇦 Українська"), + ("en", "🇬🇧 English"), + ("us", "🇺🇸 English (US)"), + ("ru", "🇷🇺 Русский"), + ("de", "🇩🇪 Deutsch"), + ("fr", "🇫🇷 Français"), + ("es", "🇪🇸 Español"), + ("it", "🇮🇹 Italiano"), + ("zh", "🇨🇳 中文"), + ("ja", "🇯🇵 日本語"), + ("ko", "🇰🇷 한국어"), + ("pl", "🇵🇱 Polski"), + ("ar", "🇸🇦 العربية"), + ("pt", "🇵🇹 Português"), + ("tr", "🇹🇷 Türkçe"), + ("nl", "🇳🇱 Nederlands"), + ("sv", "🇸🇪 Svenska"), + ("no", "🇳🇴 Norsk"), + ("da", "🇩🇰 Dansk"), + ("fi", "🇫🇮 Suomi"), + ("el", "🇬🇷 Ελληνικά"), + ("he", "🇮🇱 עברית"), + ("hi", "🇮🇳 हिन्दी"), + ("id", "🇮🇩 Indonesia"), + ("vi", "🇻🇳 Tiếng Việt"), + ("th", "🇹🇭 ภาษาไทย"), + ("cs", "🇨🇿 Čeština"), + ("hu", "🇭🇺 Magyar"), + ("ro", "🇷🇴 Română"), + ("bg", "🇧🇬 Български"), + ("sr", "🇷🇸 Српски"), + ("hr", "🇭🇷 Hrvatski"), + ("sk", "🇸🇰 Slovenčina"), + ("sl", "🇸🇮 Slovenščina"), + ("lt", "🇱🇹 Lietuvių"), + ("lv", "🇱🇻 Latviešu"), + ("et", "🇪🇪 Eesti"), +]; +pub const LANGUAGES_PER_PAGE: usize = 6; + +pub fn normalize_language_code(lang: &str) -> String { + match lang.to_lowercase().as_str() { + "ua" | "ukrainian" | "украинский" | "uk" => "uk".to_string(), + "ru" | "russian" | "русский" => "ru".to_string(), + "en" | "english" | "английский" => "en".to_string(), + "de" | "german" | "немецкий" => "de".to_string(), + "fr" | "french" | "французский" => "fr".to_string(), + "es" | "spanish" | "испанский" => "es".to_string(), + "it" | "italian" | "итальянский" => "it".to_string(), + "zh" | "chinese" | "китайский" => "zh".to_string(), + "ja" | "japanese" | "японский" => "ja".to_string(), + _ => lang.to_lowercase(), + } +} From d770104b457f56979b38da2736d4f575b61f4339 Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Wed, 10 Sep 2025 11:09:14 +0300 Subject: [PATCH 36/66] [2.5/3] updated structure of fulturate --- src/bot/callbacks/cobalt_pagination.rs | 2 +- src/bot/callbacks/mod.rs | 2 +- src/bot/callbacks/module.rs | 2 +- src/bot/callbacks/translate.rs | 2 +- src/bot/callbacks/whisper.rs | 2 +- src/bot/{commands.rs => commander.rs} | 10 +++++----- src/bot/{handlers => commands}/currency_settings.rs | 2 +- src/bot/{handlers => commands}/mod.rs | 0 src/bot/{handlers => commands}/speech_recognition.rs | 2 +- src/bot/{handlers => commands}/start.rs | 2 +- src/bot/{handlers => commands}/translate.rs | 2 +- src/bot/dispatcher.rs | 6 +++--- src/bot/inlines/cobalter.rs | 2 +- src/bot/inlines/currency.rs | 2 +- src/bot/inlines/whisper.rs | 2 +- src/bot/{messages => }/messager.rs | 2 +- src/bot/messages/mod.rs | 1 - src/bot/messages/sound/voice.rs | 2 +- src/bot/messages/sound/voice_note.rs | 2 +- src/bot/messages/sounder.rs | 2 +- src/bot/mod.rs | 7 ++++--- src/{ => core}/config/mod.rs | 2 +- src/core/mod.rs | 1 + src/lib.rs | 1 - src/util/transcription.rs | 2 +- 25 files changed, 31 insertions(+), 31 deletions(-) rename src/bot/{commands.rs => commander.rs} (79%) rename src/bot/{handlers => commands}/currency_settings.rs (99%) rename src/bot/{handlers => commands}/mod.rs (100%) rename src/bot/{handlers => commands}/speech_recognition.rs (92%) rename src/bot/{handlers => commands}/start.rs (98%) rename src/bot/{handlers => commands}/translate.rs (99%) rename src/bot/{messages => }/messager.rs (98%) rename src/{ => core}/config/mod.rs (98%) diff --git a/src/bot/callbacks/cobalt_pagination.rs b/src/bot/callbacks/cobalt_pagination.rs index 82df3dc..5bb97bb 100644 --- a/src/bot/callbacks/cobalt_pagination.rs +++ b/src/bot/callbacks/cobalt_pagination.rs @@ -1,6 +1,6 @@ use crate::bot::keyboards::cobalt::make_photo_pagination_keyboard; use crate::bot::inlines::cobalter::DownloadResult; -use crate::config::Config; +use crate::core::config::Config; use crate::errors::MyError; use std::sync::Arc; use teloxide::prelude::*; diff --git a/src/bot/callbacks/mod.rs b/src/bot/callbacks/mod.rs index 68d881b..6b9ec77 100644 --- a/src/bot/callbacks/mod.rs +++ b/src/bot/callbacks/mod.rs @@ -6,7 +6,7 @@ use crate::bot::callbacks::module::{ }; use crate::bot::callbacks::translate::handle_translate_callback; use crate::bot::callbacks::whisper::handle_whisper_callback; -use crate::config::Config; +use crate::core::config::Config; use crate::errors::MyError; use crate::util::transcription::{back_handler, summarization_handler}; use std::sync::Arc; diff --git a/src/bot/callbacks/module.rs b/src/bot/callbacks/module.rs index 09a06c1..c4de1a3 100644 --- a/src/bot/callbacks/module.rs +++ b/src/bot/callbacks/module.rs @@ -1,4 +1,4 @@ -use crate::bot::handlers::currency_settings::update_settings_message; +use crate::bot::commands::currency_settings::update_settings_message; use crate::bot::keyboards::cobalt::make_option_selection_keyboard; use crate::core::db::schemas::SettingsRepo; use crate::core::db::schemas::settings::Settings; diff --git a/src/bot/callbacks/translate.rs b/src/bot/callbacks/translate.rs index 05b868f..fe53f87 100644 --- a/src/bot/callbacks/translate.rs +++ b/src/bot/callbacks/translate.rs @@ -1,5 +1,5 @@ use crate::bot::keyboards::translate::create_language_keyboard; -use crate::config::Config; +use crate::core::config::Config; use crate::core::services::translation::{SUPPORTED_LANGUAGES, normalize_language_code}; use crate::errors::MyError; use crate::util::inline::delete_message_button; diff --git a/src/bot/callbacks/whisper.rs b/src/bot/callbacks/whisper.rs index 404fd28..263b82e 100644 --- a/src/bot/callbacks/whisper.rs +++ b/src/bot/callbacks/whisper.rs @@ -1,5 +1,5 @@ use crate::bot::inlines::whisper::Whisper; -use crate::config::Config; +use crate::core::config::Config; use crate::errors::MyError; use teloxide::Bot; use teloxide::payloads::{AnswerCallbackQuerySetters, EditMessageTextSetters}; diff --git a/src/bot/commands.rs b/src/bot/commander.rs similarity index 79% rename from src/bot/commands.rs rename to src/bot/commander.rs index 46c0f9d..d39ca15 100644 --- a/src/bot/commands.rs +++ b/src/bot/commander.rs @@ -1,10 +1,10 @@ -use crate::bot::handlers::currency_settings::{ +use crate::bot::commands::currency_settings::{ currency_codes_handler, currency_codes_list_handler, settings_command_handler, }; -use crate::bot::handlers::speech_recognition::speech_recognition_handler; -use crate::bot::handlers::start::start_handler; -use crate::bot::handlers::translate::translate_handler; -use crate::config::Config; +use crate::bot::commands::speech_recognition::speech_recognition_handler; +use crate::bot::commands::start::start_handler; +use crate::bot::commands::translate::translate_handler; +use crate::core::config::Config; use crate::errors::MyError; use crate::util::enums::Command; use teloxide::Bot; diff --git a/src/bot/handlers/currency_settings.rs b/src/bot/commands/currency_settings.rs similarity index 99% rename from src/bot/handlers/currency_settings.rs rename to src/bot/commands/currency_settings.rs index c6a02a5..28a05ca 100644 --- a/src/bot/handlers/currency_settings.rs +++ b/src/bot/commands/currency_settings.rs @@ -1,4 +1,4 @@ -use crate::config::Config; +use crate::core::config::Config; use crate::core::db::schemas::SettingsRepo; use crate::core::db::schemas::group::Group; use crate::core::db::schemas::settings::Settings; diff --git a/src/bot/handlers/mod.rs b/src/bot/commands/mod.rs similarity index 100% rename from src/bot/handlers/mod.rs rename to src/bot/commands/mod.rs diff --git a/src/bot/handlers/speech_recognition.rs b/src/bot/commands/speech_recognition.rs similarity index 92% rename from src/bot/handlers/speech_recognition.rs rename to src/bot/commands/speech_recognition.rs index 2781d87..048b054 100644 --- a/src/bot/handlers/speech_recognition.rs +++ b/src/bot/commands/speech_recognition.rs @@ -1,4 +1,4 @@ -use crate::config::Config; +use crate::core::config::Config; use crate::errors::MyError; use crate::util::transcription::transcription_handler; use teloxide::prelude::*; diff --git a/src/bot/handlers/start.rs b/src/bot/commands/start.rs similarity index 98% rename from src/bot/handlers/start.rs rename to src/bot/commands/start.rs index 88270fb..2cda672 100644 --- a/src/bot/handlers/start.rs +++ b/src/bot/commands/start.rs @@ -1,4 +1,4 @@ -use crate::config::Config; +use crate::core::config::Config; use crate::core::db::schemas::user::User; use crate::errors::MyError; use crate::util::currency::converter::get_default_currencies; diff --git a/src/bot/handlers/translate.rs b/src/bot/commands/translate.rs similarity index 99% rename from src/bot/handlers/translate.rs rename to src/bot/commands/translate.rs index 8bbd88f..dfb7219 100644 --- a/src/bot/handlers/translate.rs +++ b/src/bot/commands/translate.rs @@ -1,5 +1,5 @@ use crate::bot::keyboards::translate::create_language_keyboard; -use crate::config::Config; +use crate::core::config::Config; use crate::core::services::translation::{SUPPORTED_LANGUAGES, normalize_language_code}; use crate::errors::MyError; use crate::util::inline::delete_message_button; diff --git a/src/bot/dispatcher.rs b/src/bot/dispatcher.rs index e850669..cf5e295 100644 --- a/src/bot/dispatcher.rs +++ b/src/bot/dispatcher.rs @@ -1,11 +1,11 @@ use crate::bot::callbacks::callback_query_handlers; -use crate::bot::commands::command_handlers; +use crate::bot::commander::command_handlers; use crate::bot::messages::chat::handle_bot_added; -use crate::bot::messages::messager::{handle_currency, handle_speech}; +use crate::bot::messager::{handle_currency, handle_speech}; use crate::bot::inlines::cobalter::{handle_cobalt_inline, is_query_url}; use crate::bot::inlines::currency::handle_currency_inline; use crate::bot::inlines::whisper::{handle_whisper_inline, is_whisper_query}; -use crate::config::Config; +use crate::core::config::Config; use crate::errors::MyError; use crate::util::currency::converter::is_currency_query; use crate::util::enums::Command; diff --git a/src/bot/inlines/cobalter.rs b/src/bot/inlines/cobalter.rs index 8189878..816d93b 100644 --- a/src/bot/inlines/cobalter.rs +++ b/src/bot/inlines/cobalter.rs @@ -1,5 +1,5 @@ use crate::bot::keyboards::cobalt::{make_photo_pagination_keyboard, make_single_url_keyboard}; -use crate::config::Config; +use crate::core::config::Config; use crate::core::db::schemas::SettingsRepo; use crate::core::db::schemas::settings::Settings; use crate::errors::MyError; diff --git a/src/bot/inlines/currency.rs b/src/bot/inlines/currency.rs index 7b1cba1..2c7d983 100644 --- a/src/bot/inlines/currency.rs +++ b/src/bot/inlines/currency.rs @@ -1,4 +1,4 @@ -use crate::config::Config; +use crate::core::config::Config; use crate::core::db::schemas::user::User; use crate::errors::MyError; use log::{debug, error}; diff --git a/src/bot/inlines/whisper.rs b/src/bot/inlines/whisper.rs index 3c51779..3963a68 100644 --- a/src/bot/inlines/whisper.rs +++ b/src/bot/inlines/whisper.rs @@ -10,7 +10,7 @@ use teloxide::types::{ }; use uuid::Uuid; -use crate::config::Config; +use crate::core::config::Config; use crate::errors::MyError; use log::error; use teloxide::utils::html; diff --git a/src/bot/messages/messager.rs b/src/bot/messager.rs similarity index 98% rename from src/bot/messages/messager.rs rename to src/bot/messager.rs index df794c1..b05b0ae 100644 --- a/src/bot/messages/messager.rs +++ b/src/bot/messager.rs @@ -1,5 +1,5 @@ use crate::bot::messages::sounder::sound_handlers; -use crate::config::Config; +use crate::core::config::Config; use crate::errors::MyError; use crate::util::inline::delete_message_button; use log::error; diff --git a/src/bot/messages/mod.rs b/src/bot/messages/mod.rs index 0102531..7c70fb9 100644 --- a/src/bot/messages/mod.rs +++ b/src/bot/messages/mod.rs @@ -1,4 +1,3 @@ pub mod chat; -pub mod messager; pub mod sound; pub mod sounder; diff --git a/src/bot/messages/sound/voice.rs b/src/bot/messages/sound/voice.rs index d6285b0..fc334a5 100644 --- a/src/bot/messages/sound/voice.rs +++ b/src/bot/messages/sound/voice.rs @@ -1,4 +1,4 @@ -use crate::config::Config; +use crate::core::config::Config; use crate::errors::MyError; use crate::util::transcription::transcription_handler; use teloxide::prelude::*; diff --git a/src/bot/messages/sound/voice_note.rs b/src/bot/messages/sound/voice_note.rs index 04da4a7..4704685 100644 --- a/src/bot/messages/sound/voice_note.rs +++ b/src/bot/messages/sound/voice_note.rs @@ -1,4 +1,4 @@ -use crate::config::Config; +use crate::core::config::Config; use crate::errors::MyError; use crate::util::transcription::transcription_handler; use teloxide::prelude::*; diff --git a/src/bot/messages/sounder.rs b/src/bot/messages/sounder.rs index 8ea2d63..14d0395 100644 --- a/src/bot/messages/sounder.rs +++ b/src/bot/messages/sounder.rs @@ -1,6 +1,6 @@ use crate::bot::messages::sound::voice::voice_handler; use crate::bot::messages::sound::voice_note::voice_note_handler; -use crate::config::Config; +use crate::core::config::Config; use crate::errors::MyError; use teloxide::Bot; use teloxide::prelude::Message; diff --git a/src/bot/mod.rs b/src/bot/mod.rs index aca7c3b..feca7a3 100644 --- a/src/bot/mod.rs +++ b/src/bot/mod.rs @@ -1,7 +1,8 @@ -mod callbacks; -pub mod commands; +pub mod callbacks; pub mod dispatcher; -mod handlers; pub mod inlines; pub mod keyboards; pub mod messages; +pub mod messager; +pub mod commander; +pub mod commands; \ No newline at end of file diff --git a/src/config/mod.rs b/src/core/config/mod.rs similarity index 98% rename from src/config/mod.rs rename to src/core/config/mod.rs index 3528ddc..6387e12 100644 --- a/src/config/mod.rs +++ b/src/core/config/mod.rs @@ -77,7 +77,7 @@ impl Config { .and_then(|s| s.parse().ok()) .unwrap_or(0.to_string()); - let Ok(json_config) = read_json_config("config.json") else { + let Ok(json_config) = read_json_config("config.json") else { // todo: remove JsonConfig because useless when we will get /settings error!("Unable to read config.json"); std::process::exit(1); }; diff --git a/src/core/mod.rs b/src/core/mod.rs index 7163739..5b14180 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,2 +1,3 @@ pub mod db; pub mod services; +pub mod config; diff --git a/src/lib.rs b/src/lib.rs index ae77c26..bc9c4db 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,4 @@ pub mod bot; -pub mod config; pub mod core; pub mod errors; pub mod util; \ No newline at end of file diff --git a/src/util/transcription.rs b/src/util/transcription.rs index fea776a..9e49c5d 100644 --- a/src/util/transcription.rs +++ b/src/util/transcription.rs @@ -1,5 +1,5 @@ use super::enums::AudioStruct; -use crate::config::Config; +use crate::core::config::Config; use crate::errors::MyError; use bytes::Bytes; use gem_rs::types::HarmBlockThreshold; From d77aeb6456615d76bb544bb852b420b5a370119e Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Wed, 10 Sep 2025 11:20:12 +0300 Subject: [PATCH 37/66] [3/3] updated structure of fulturate --- src/bot/callbacks/mod.rs | 2 +- src/bot/callbacks/translate.rs | 2 +- src/bot/commands/currency_settings.rs | 4 ++-- src/bot/commands/speech_recognition.rs | 2 +- src/bot/commands/start.rs | 2 +- src/bot/commands/translate.rs | 2 +- src/bot/dispatcher.rs | 4 ++-- src/{util/inline.rs => bot/keyboards/delete.rs} | 2 +- src/bot/keyboards/mod.rs | 3 ++- src/bot/messager.rs | 2 +- src/bot/messages/chat.rs | 2 +- src/bot/messages/sound/voice.rs | 2 +- src/bot/messages/sound/voice_note.rs | 2 +- src/{util => core/config}/json.rs | 2 +- src/core/config/mod.rs | 6 ++++-- src/core/db/schemas.rs | 2 +- src/core/db/schemas/group.rs | 2 +- src/core/db/schemas/user.rs | 2 +- src/core/services/{currency.rs => currencier.rs} | 2 +- src/{util => core/services}/currency/converter.rs | 0 src/{util => core/services}/currency/mod.rs | 0 src/{util => core/services}/currency/structs.rs | 0 src/core/services/mod.rs | 2 ++ src/{util => core/services}/transcription.rs | 2 +- src/errors.rs | 2 +- src/util/mod.rs | 4 ---- 26 files changed, 29 insertions(+), 28 deletions(-) rename src/{util/inline.rs => bot/keyboards/delete.rs} (99%) rename src/{util => core/config}/json.rs (100%) rename src/core/services/{currency.rs => currencier.rs} (96%) rename src/{util => core/services}/currency/converter.rs (100%) rename src/{util => core/services}/currency/mod.rs (100%) rename src/{util => core/services}/currency/structs.rs (100%) rename src/{util => core/services}/transcription.rs (99%) diff --git a/src/bot/callbacks/mod.rs b/src/bot/callbacks/mod.rs index 6b9ec77..54021a4 100644 --- a/src/bot/callbacks/mod.rs +++ b/src/bot/callbacks/mod.rs @@ -8,10 +8,10 @@ use crate::bot::callbacks::translate::handle_translate_callback; use crate::bot::callbacks::whisper::handle_whisper_callback; use crate::core::config::Config; use crate::errors::MyError; -use crate::util::transcription::{back_handler, summarization_handler}; use std::sync::Arc; use teloxide::Bot; use teloxide::prelude::{CallbackQuery, Requester}; +use crate::core::services::transcription::{back_handler, summarization_handler}; pub mod cobalt_pagination; pub mod delete; diff --git a/src/bot/callbacks/translate.rs b/src/bot/callbacks/translate.rs index fe53f87..12db18f 100644 --- a/src/bot/callbacks/translate.rs +++ b/src/bot/callbacks/translate.rs @@ -2,7 +2,7 @@ use crate::bot::keyboards::translate::create_language_keyboard; use crate::core::config::Config; use crate::core::services::translation::{SUPPORTED_LANGUAGES, normalize_language_code}; use crate::errors::MyError; -use crate::util::inline::delete_message_button; +use crate::bot::keyboards::delete::delete_message_button; use teloxide::Bot; use teloxide::payloads::{EditMessageReplyMarkupSetters, EditMessageTextSetters}; use teloxide::prelude::Requester; diff --git a/src/bot/commands/currency_settings.rs b/src/bot/commands/currency_settings.rs index 28a05ca..59eea5a 100644 --- a/src/bot/commands/currency_settings.rs +++ b/src/bot/commands/currency_settings.rs @@ -3,14 +3,14 @@ use crate::core::db::schemas::SettingsRepo; use crate::core::db::schemas::group::Group; use crate::core::db::schemas::settings::Settings; use crate::core::db::schemas::user::User; -use crate::core::services::currency::{get_enabled_codes, handle_currency_update}; use crate::errors::MyError; -use crate::util::currency::converter::{CURRENCY_CONFIG_PATH, get_all_currency_codes}; +use crate::core::services::currency::converter::{CURRENCY_CONFIG_PATH, get_all_currency_codes}; use teloxide::prelude::*; use teloxide::types::{ InlineKeyboardButton, InlineKeyboardMarkup, MaybeInaccessibleMessage, ParseMode, ReplyParameters, }; +use crate::core::services::currencier::{get_enabled_codes, handle_currency_update}; pub async fn currency_codes_handler(bot: Bot, msg: Message, code: String) -> Result<(), MyError> { if msg.chat.is_private() { diff --git a/src/bot/commands/speech_recognition.rs b/src/bot/commands/speech_recognition.rs index 048b054..7cf17ed 100644 --- a/src/bot/commands/speech_recognition.rs +++ b/src/bot/commands/speech_recognition.rs @@ -1,6 +1,6 @@ use crate::core::config::Config; use crate::errors::MyError; -use crate::util::transcription::transcription_handler; +use crate::core::services::transcription::transcription_handler; use teloxide::prelude::*; pub async fn speech_recognition_handler( diff --git a/src/bot/commands/start.rs b/src/bot/commands/start.rs index 2cda672..057f8dd 100644 --- a/src/bot/commands/start.rs +++ b/src/bot/commands/start.rs @@ -1,7 +1,7 @@ use crate::core::config::Config; use crate::core::db::schemas::user::User; use crate::errors::MyError; -use crate::util::currency::converter::get_default_currencies; +use crate::core::services::currency::converter::get_default_currencies; use log::error; use mongodb::bson::doc; use oximod::Model; diff --git a/src/bot/commands/translate.rs b/src/bot/commands/translate.rs index dfb7219..8c9c871 100644 --- a/src/bot/commands/translate.rs +++ b/src/bot/commands/translate.rs @@ -2,7 +2,7 @@ use crate::bot::keyboards::translate::create_language_keyboard; use crate::core::config::Config; use crate::core::services::translation::{SUPPORTED_LANGUAGES, normalize_language_code}; use crate::errors::MyError; -use crate::util::inline::delete_message_button; +use crate::bot::keyboards::delete::delete_message_button; use teloxide::prelude::*; use teloxide::types::{InlineKeyboardButton, Message, ParseMode, ReplyParameters}; use teloxide::utils::html::escape; diff --git a/src/bot/dispatcher.rs b/src/bot/dispatcher.rs index cf5e295..89dcc13 100644 --- a/src/bot/dispatcher.rs +++ b/src/bot/dispatcher.rs @@ -7,9 +7,8 @@ use crate::bot::inlines::currency::handle_currency_inline; use crate::bot::inlines::whisper::{handle_whisper_inline, is_whisper_query}; use crate::core::config::Config; use crate::errors::MyError; -use crate::util::currency::converter::is_currency_query; use crate::util::enums::Command; -use crate::util::inline::delete_message_button; +use crate::bot::keyboards::delete::delete_message_button; use log::{error, info}; use oximod::set_global_client; use std::{convert::Infallible, fmt::Write, ops::ControlFlow, sync::Arc}; @@ -26,6 +25,7 @@ use teloxide::{ update_listeners::Polling, utils::{command::BotCommands, html}, }; +use crate::core::services::currency::converter::is_currency_query; async fn root_handler( update: Update, diff --git a/src/util/inline.rs b/src/bot/keyboards/delete.rs similarity index 99% rename from src/util/inline.rs rename to src/bot/keyboards/delete.rs index fb42927..1cde4f1 100644 --- a/src/util/inline.rs +++ b/src/bot/keyboards/delete.rs @@ -7,4 +7,4 @@ pub(crate) fn delete_message_button(original_user_id: u64) -> InlineKeyboardMark let delete_button = InlineKeyboardButton::callback("🗑️", callback_data); InlineKeyboardMarkup::new(vec![vec![delete_button]]) -} +} \ No newline at end of file diff --git a/src/bot/keyboards/mod.rs b/src/bot/keyboards/mod.rs index 5a447cb..e608c0e 100644 --- a/src/bot/keyboards/mod.rs +++ b/src/bot/keyboards/mod.rs @@ -1,2 +1,3 @@ pub mod translate; -pub mod cobalt; \ No newline at end of file +pub mod cobalt; +pub mod delete; \ No newline at end of file diff --git a/src/bot/messager.rs b/src/bot/messager.rs index b05b0ae..aa7e6ff 100644 --- a/src/bot/messager.rs +++ b/src/bot/messager.rs @@ -1,13 +1,13 @@ use crate::bot::messages::sounder::sound_handlers; use crate::core::config::Config; use crate::errors::MyError; -use crate::util::inline::delete_message_button; use log::error; use teloxide::Bot; use teloxide::payloads::SendMessageSetters; use teloxide::requests::Requester; use teloxide::types::{Message, ParseMode, ReplyParameters}; use tokio::task; +use crate::bot::keyboards::delete::delete_message_button; pub async fn handle_speech(bot: Bot, message: Message) -> Result<(), MyError> { let config = Config::new().await; diff --git a/src/bot/messages/chat.rs b/src/bot/messages/chat.rs index 9947011..d9b9ac5 100644 --- a/src/bot/messages/chat.rs +++ b/src/bot/messages/chat.rs @@ -1,7 +1,7 @@ use crate::core::db::schemas::group::Group; use crate::core::db::schemas::user::User; use crate::errors::MyError; -use crate::util::currency::converter::get_default_currencies; +use crate::core::services::currency::converter::get_default_currencies; use log::{error, info}; use mongodb::bson::doc; use oximod::ModelTrait; diff --git a/src/bot/messages/sound/voice.rs b/src/bot/messages/sound/voice.rs index fc334a5..3c2bef4 100644 --- a/src/bot/messages/sound/voice.rs +++ b/src/bot/messages/sound/voice.rs @@ -1,6 +1,6 @@ use crate::core::config::Config; use crate::errors::MyError; -use crate::util::transcription::transcription_handler; +use crate::core::services::transcription::transcription_handler; use teloxide::prelude::*; pub async fn voice_handler(bot: Bot, msg: Message, config: &Config) -> Result<(), MyError> { diff --git a/src/bot/messages/sound/voice_note.rs b/src/bot/messages/sound/voice_note.rs index 4704685..a62943f 100644 --- a/src/bot/messages/sound/voice_note.rs +++ b/src/bot/messages/sound/voice_note.rs @@ -1,6 +1,6 @@ use crate::core::config::Config; use crate::errors::MyError; -use crate::util::transcription::transcription_handler; +use crate::core::services::transcription::transcription_handler; use teloxide::prelude::*; pub async fn voice_note_handler(bot: Bot, msg: Message, config: &Config) -> Result<(), MyError> { diff --git a/src/util/json.rs b/src/core/config/json.rs similarity index 100% rename from src/util/json.rs rename to src/core/config/json.rs index ffa78d6..5a07df7 100644 --- a/src/util/json.rs +++ b/src/core/config/json.rs @@ -1,7 +1,7 @@ -use serde::Deserialize; use std::fs::File; use std::io::Read; use std::path::Path; +use serde::Deserialize; #[derive(Deserialize, Debug, Clone)] pub struct JsonConfig { diff --git a/src/core/config/mod.rs b/src/core/config/mod.rs index 6387e12..68b4adc 100644 --- a/src/core/config/mod.rs +++ b/src/core/config/mod.rs @@ -1,11 +1,13 @@ +mod json; + use crate::core::db::redis::RedisCache; -use crate::util::currency::converter::{CurrencyConverter, OutputLanguage}; -use crate::util::json::{JsonConfig, read_json_config}; +use crate::core::services::currency::converter::{CurrencyConverter, OutputLanguage}; use dotenv::dotenv; use log::error; use redis::Client as RedisClient; use std::sync::Arc; use teloxide::prelude::*; +use crate::core::config::json::{read_json_config, JsonConfig}; #[derive(Clone)] pub struct Config { diff --git a/src/core/db/schemas.rs b/src/core/db/schemas.rs index e4c13b0..f5fd046 100644 --- a/src/core/db/schemas.rs +++ b/src/core/db/schemas.rs @@ -4,7 +4,7 @@ pub mod user; use crate::core::db::schemas::settings::ModuleSettings; use crate::errors::MyError; -use crate::util::currency::converter::CurrencyStruct; +use crate::core::services::currency::converter::CurrencyStruct; use async_trait::async_trait; use mongodb::results::UpdateResult; use oximod::_error::oximod_error::OxiModError; diff --git a/src/core/db/schemas/group.rs b/src/core/db/schemas/group.rs index 7d2fb5a..b396663 100644 --- a/src/core/db/schemas/group.rs +++ b/src/core/db/schemas/group.rs @@ -1,5 +1,5 @@ use crate::core::db::schemas::{BaseFunctions, CurrenciesFunctions}; -use crate::util::currency::converter::CurrencyStruct; +use crate::core::services::currency::converter::CurrencyStruct; use async_trait::async_trait; use mongodb::bson; use mongodb::bson::{doc, oid::ObjectId}; diff --git a/src/core/db/schemas/user.rs b/src/core/db/schemas/user.rs index 8a3e30b..f971c7b 100644 --- a/src/core/db/schemas/user.rs +++ b/src/core/db/schemas/user.rs @@ -1,5 +1,5 @@ use crate::core::db::schemas::{BaseFunctions, CurrenciesFunctions}; -use crate::util::currency::converter::CurrencyStruct; +use crate::core::services::currency::converter::CurrencyStruct; use async_trait::async_trait; use mongodb::bson; use mongodb::bson::{doc, oid::ObjectId}; diff --git a/src/core/services/currency.rs b/src/core/services/currencier.rs similarity index 96% rename from src/core/services/currency.rs rename to src/core/services/currencier.rs index fe43179..9b0921d 100644 --- a/src/core/services/currency.rs +++ b/src/core/services/currencier.rs @@ -3,7 +3,7 @@ use crate::core::db::schemas::group::Group; use crate::core::db::schemas::user::User; use crate::core::db::schemas::{BaseFunctions, CurrenciesFunctions}; use crate::errors::MyError; -use crate::util::currency::converter::{CURRENCY_CONFIG_PATH, get_all_currency_codes}; +use crate::core::services::currency::converter::{CURRENCY_CONFIG_PATH, get_all_currency_codes}; use log::error; use oximod::Model; use std::collections::HashSet; diff --git a/src/util/currency/converter.rs b/src/core/services/currency/converter.rs similarity index 100% rename from src/util/currency/converter.rs rename to src/core/services/currency/converter.rs diff --git a/src/util/currency/mod.rs b/src/core/services/currency/mod.rs similarity index 100% rename from src/util/currency/mod.rs rename to src/core/services/currency/mod.rs diff --git a/src/util/currency/structs.rs b/src/core/services/currency/structs.rs similarity index 100% rename from src/util/currency/structs.rs rename to src/core/services/currency/structs.rs diff --git a/src/core/services/mod.rs b/src/core/services/mod.rs index cb278c3..e6253e4 100644 --- a/src/core/services/mod.rs +++ b/src/core/services/mod.rs @@ -1,3 +1,5 @@ pub mod currency; pub mod speech_recognition; pub mod translation; +pub mod currencier; +pub mod transcription; diff --git a/src/util/transcription.rs b/src/core/services/transcription.rs similarity index 99% rename from src/util/transcription.rs rename to src/core/services/transcription.rs index 9e49c5d..b9d6d55 100644 --- a/src/util/transcription.rs +++ b/src/core/services/transcription.rs @@ -1,4 +1,3 @@ -use super::enums::AudioStruct; use crate::core::config::Config; use crate::errors::MyError; use bytes::Bytes; @@ -14,6 +13,7 @@ use teloxide::types::{ CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message, MessageKind, ParseMode, ReplyParameters, }; +use crate::util::enums::AudioStruct; #[derive(Debug, Serialize, Deserialize, FromRedisValue, ToRedisArgs, Clone)] struct TranscriptionCache { diff --git a/src/errors.rs b/src/errors.rs index e9dec35..1dd8f0b 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -1,4 +1,4 @@ -use crate::util::currency::converter::ConvertError; +use crate::core::services::currency::converter::ConvertError; use ccobalt::model::error::CobaltError; use mongodb::bson; use oximod::_error::oximod_error::OxiModError; diff --git a/src/util/mod.rs b/src/util/mod.rs index dae7591..e4e9fda 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,5 +1 @@ -pub mod currency; pub mod enums; -pub mod inline; -pub mod json; -pub mod transcription; From fe3b8bc99e2dcf264ec27d0f740e3431b3138f5f Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Sat, 13 Sep 2025 13:00:20 +0300 Subject: [PATCH 38/66] fixed speech recognition command with "unknown" answer --- src/bot/commands/speech_recognition.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/bot/commands/speech_recognition.rs b/src/bot/commands/speech_recognition.rs index 7cf17ed..508f3a1 100644 --- a/src/bot/commands/speech_recognition.rs +++ b/src/bot/commands/speech_recognition.rs @@ -1,7 +1,9 @@ +use teloxide::dispatching::dialogue::GetChatId; use crate::core::config::Config; use crate::errors::MyError; use crate::core::services::transcription::transcription_handler; use teloxide::prelude::*; +use teloxide::types::ReplyParameters; pub async fn speech_recognition_handler( bot: Bot, @@ -10,6 +12,10 @@ pub async fn speech_recognition_handler( ) -> Result<(), MyError> { if msg.reply_to_message().is_some() { transcription_handler(bot, msg.reply_to_message().unwrap().clone(), config).await?; + } else { + bot.send_message(msg.chat_id().unwrap(), "Ответьте на голосовое сообщение.") + .reply_parameters(ReplyParameters::new(msg.id)) + .await?; } Ok(()) } From 4108bfb7d44b765eccd48019c27f6eceeaa4fe13 Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Sat, 13 Sep 2025 13:26:01 +0300 Subject: [PATCH 39/66] added pagination in transcriptions for better experience --- src/bot/callbacks/mod.rs | 5 +- src/bot/callbacks/transcription.rs | 212 +++++++++++++++++++ src/bot/keyboards/mod.rs | 3 +- src/bot/keyboards/transcription.rs | 45 ++++ src/core/services/transcription.rs | 328 +++++------------------------ 5 files changed, 320 insertions(+), 273 deletions(-) create mode 100644 src/bot/callbacks/transcription.rs create mode 100644 src/bot/keyboards/transcription.rs diff --git a/src/bot/callbacks/mod.rs b/src/bot/callbacks/mod.rs index 54021a4..201b77c 100644 --- a/src/bot/callbacks/mod.rs +++ b/src/bot/callbacks/mod.rs @@ -11,13 +11,14 @@ use crate::errors::MyError; use std::sync::Arc; use teloxide::Bot; use teloxide::prelude::{CallbackQuery, Requester}; -use crate::core::services::transcription::{back_handler, summarization_handler}; +use crate::bot::callbacks::transcription::{back_handler, pagination_handler, summarization_handler}; pub mod cobalt_pagination; pub mod delete; pub mod module; pub mod translate; pub mod whisper; +pub mod transcription; pub async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { let config = Arc::new(Config::new().await); @@ -31,6 +32,8 @@ pub async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Result<(), M summarization_handler(bot, q, &config).await? } else if data.starts_with("back_to_full") { back_handler(bot, q, &config).await? + } else if data.starts_with("paginate:") { + pagination_handler(bot, q, &config).await? } else if data.starts_with("module_select:") { module_select_handler(bot, q).await? } else if data.starts_with("module_toggle") { diff --git a/src/bot/callbacks/transcription.rs b/src/bot/callbacks/transcription.rs new file mode 100644 index 0000000..e4ae46e --- /dev/null +++ b/src/bot/callbacks/transcription.rs @@ -0,0 +1,212 @@ +use crate::bot::keyboards::transcription::{create_summary_keyboard, create_transcription_keyboard}; +use crate::core::config::Config; +use crate::core::services::transcription::{ + save_file_to_memory, split_text, summarize_audio, TranscriptionCache, +}; +use crate::errors::MyError; +use teloxide::prelude::*; +use teloxide::types::ParseMode; + +pub async fn pagination_handler( + bot: Bot, + query: CallbackQuery, + config: &Config, +) -> Result<(), MyError> { + let Some(message) = query.message.as_ref().and_then(|m| m.regular_message()) else { + bot.answer_callback_query(query.id).await?; + return Ok(()); + }; + + let Some(data) = query.data.as_ref() else { + return Ok(()); + }; + + let parts: Vec<&str> = data.split(':').collect(); + if parts.len() != 4 || parts[0] != "paginate" { + bot.answer_callback_query(query.id).await?; + return Ok(()); + } + + let page: usize = parts[1].parse().unwrap_or(1); + let total_pages: usize = parts[2].parse().unwrap_or(1); + let user_id: u64 = parts[3].parse().unwrap_or(0); + + if query.from.id.0 != user_id { + bot.answer_callback_query(query.id) + .text("Вы не можете управлять этим сообщением.") + .show_alert(true) + .await?; + return Ok(()); + } + + bot.answer_callback_query(query.id).await?; + + let cache = config.get_redis_client(); + let message_cache_key = format!("message_file_map:{}", message.id); + + let Some(file_unique_id) = cache.get::(&message_cache_key).await? else { + bot.edit_message_text( + message.chat.id, + message.id, + "❌ Не удалось обработать запрос. Кнопка устарела.", + ) + .await?; + return Ok(()); + }; + + let file_cache_key = format!("transcription_by_file:{}", file_unique_id); + let Some(cache_entry) = cache.get::(&file_cache_key).await? else { + bot.edit_message_text( + message.chat.id, + message.id, + "❌ Не удалось найти исходный текст в кеше.", + ) + .await?; + return Ok(()); + }; + + let text_parts = split_text(&cache_entry.full_text, 4000); + if page == 0 || page > text_parts.len() { + return Ok(()); + } + + let new_text = format!("
{}
", text_parts[page - 1]); + let new_keyboard = create_transcription_keyboard(page, total_pages, user_id); + + if message.text() != Some(new_text.as_str()) || message.reply_markup() != Some(&new_keyboard) { + bot.edit_message_text(message.chat.id, message.id, new_text) + .parse_mode(ParseMode::Html) + .reply_markup(new_keyboard) + .await?; + } + + Ok(()) +} + +pub async fn back_handler(bot: Bot, query: CallbackQuery, config: &Config) -> Result<(), MyError> { + let Some(message) = query.message else { + return Ok(()); + }; + bot.answer_callback_query(query.id).await?; + + let cache = config.get_redis_client(); + let message_cache_key = format!("message_file_map:{}", message.id()); + let Some(file_unique_id) = cache.get::(&message_cache_key).await? else { + bot.edit_message_text( + message.chat().id, + message.id(), + "❌ Не удалось найти исходное сообщение.", + ) + .await?; + return Ok(()); + }; + + let file_cache_key = format!("transcription_by_file:{}", file_unique_id); + let Some(cache_entry) = cache.get::(&file_cache_key).await? else { + bot.edit_message_text( + message.chat().id, + message.id(), + "❌ Не удалось найти исходный текст в кеше.", + ) + .await?; + return Ok(()); + }; + + let text_parts = split_text(&cache_entry.full_text, 4000); + let keyboard = create_transcription_keyboard(1, text_parts.len(), query.from.id.0); + + bot.edit_message_text( + message.chat().id, + message.id(), + format!("
{}
", text_parts[0]), + ) + .parse_mode(ParseMode::Html) + .reply_markup(keyboard) + .await?; + + Ok(()) +} + +pub async fn summarization_handler( + bot: Bot, + query: CallbackQuery, + config: &Config, +) -> Result<(), MyError> { + let Some(message) = query.message else { + return Ok(()); + }; + bot.answer_callback_query(query.id).await?; + + let cache = config.get_redis_client(); + let message_file_map_key = format!("message_file_map:{}", message.id()); + let Some(file_unique_id) = cache.get::(&message_file_map_key).await? else { + bot.edit_message_text( + message.chat().id, + message.id(), + "❌ Не удалось обработать запрос. Кнопка устарела.", + ) + .await?; + return Ok(()); + }; + + let file_cache_key = format!("transcription_by_file:{}", file_unique_id); + let mut cache_entry = match cache.get::(&file_cache_key).await? { + Some(entry) => entry, + None => { + bot.edit_message_text( + message.chat().id, + message.id(), + "❌ Не удалось найти исходное аудио.", + ) + .await?; + return Ok(()); + } + }; + + if let Some(cached_summary) = cache_entry.summary { + let final_text = format!( + "Краткое содержание:\n
{}
", + cached_summary + ); + bot.edit_message_text(message.chat().id, message.id(), final_text) + .parse_mode(ParseMode::Html) + .reply_markup(create_summary_keyboard()) + .await?; + return Ok(()); + } + + bot.edit_message_text( + message.chat().id, + message.id(), + "Составляю краткое содержание...", + ) + .await?; + + let file_data = save_file_to_memory(&bot, &cache_entry.file_id).await?; + let new_summary = + summarize_audio(cache_entry.mime_type.clone(), file_data, config.clone()).await?; + + if new_summary.is_empty() || new_summary.contains("Не удалось получить") { + bot.edit_message_text( + message.chat().id, + message.id(), + "❌ Не удалось составить краткое содержание.", + ) + .await?; + return Ok(()); + } + + cache_entry.summary = Some(new_summary.clone()); + cache.set(&file_cache_key, &cache_entry, 86400).await?; + + let final_text = format!( + "Краткое содержание:\n
{}
", + new_summary + ); + bot.edit_message_text(message.chat().id, message.id(), final_text) + .parse_mode(ParseMode::Html) + .reply_markup(create_summary_keyboard()) + .await?; + + Ok(()) +} \ No newline at end of file diff --git a/src/bot/keyboards/mod.rs b/src/bot/keyboards/mod.rs index e608c0e..9a090a1 100644 --- a/src/bot/keyboards/mod.rs +++ b/src/bot/keyboards/mod.rs @@ -1,3 +1,4 @@ pub mod translate; pub mod cobalt; -pub mod delete; \ No newline at end of file +pub mod delete; +pub mod transcription; \ No newline at end of file diff --git a/src/bot/keyboards/transcription.rs b/src/bot/keyboards/transcription.rs new file mode 100644 index 0000000..3c4ec06 --- /dev/null +++ b/src/bot/keyboards/transcription.rs @@ -0,0 +1,45 @@ +use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup}; + +pub fn create_transcription_keyboard( + page: usize, + total_pages: usize, + user_id: u64, +) -> InlineKeyboardMarkup { + let mut row = vec![]; + + if page > 1 { + row.push(InlineKeyboardButton::callback( + "⬅️", + format!("paginate:{}:{}:{}", page - 1, total_pages, user_id), + )); + } + + row.push(InlineKeyboardButton::callback( + format!("📄 {}/{}", page, total_pages), + "noop", + )); + + if page < total_pages { + row.push(InlineKeyboardButton::callback( + "➡️", + format!("paginate:{}:{}:{}", page + 1, total_pages, user_id), + )); + } + + let summary_button = InlineKeyboardButton::callback("✨", "summarize"); + let delete_button = + InlineKeyboardButton::callback("🗑️", format!("delete_msg:{}", user_id)); + + if total_pages > 1 { + InlineKeyboardMarkup::new(vec![row, vec![summary_button, delete_button]]) + } else { + InlineKeyboardMarkup::new(vec![vec![summary_button, delete_button]]) + } +} + +pub fn create_summary_keyboard() -> InlineKeyboardMarkup { + InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::callback( + "⬅️ Назад", + "back_to_full", + )]]) +} \ No newline at end of file diff --git a/src/core/services/transcription.rs b/src/core/services/transcription.rs index b9d6d55..76d11b6 100644 --- a/src/core/services/transcription.rs +++ b/src/core/services/transcription.rs @@ -1,26 +1,24 @@ +use crate::bot::keyboards::transcription::create_transcription_keyboard; use crate::core::config::Config; use crate::errors::MyError; +use crate::util::enums::AudioStruct; use bytes::Bytes; -use gem_rs::types::HarmBlockThreshold; +use gem_rs::types::{HarmBlockThreshold, Context, Role, Settings, Blob}; +use gem_rs::client::GemSession; +use gem_rs::api::Models; use log::{debug, error, info}; use redis_macros::{FromRedisValue, ToRedisArgs}; use serde::{Deserialize, Serialize}; use std::time::Duration; -use teloxide::Bot; -use teloxide::payloads::{EditMessageTextSetters, SendMessageSetters}; -use teloxide::requests::{Request as TeloxideRequest, Requester}; -use teloxide::types::{ - CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message, MessageKind, ParseMode, - ReplyParameters, -}; -use crate::util::enums::AudioStruct; +use teloxide::prelude::*; +use teloxide::types::{FileId, MessageKind, ParseMode, ReplyParameters}; #[derive(Debug, Serialize, Deserialize, FromRedisValue, ToRedisArgs, Clone)] -struct TranscriptionCache { - full_text: String, - summary: Option, - file_id: String, - mime_type: String, +pub struct TranscriptionCache { + pub full_text: String, + pub summary: Option, + pub file_id: String, + pub mime_type: String, } async fn get_cached( @@ -37,7 +35,6 @@ async fn get_cached( } let file_data = save_file_to_memory(bot, &file.file_id).await?; - let transcription = Transcription { mime_type: file.mime_type.to_string(), data: file_data, @@ -45,14 +42,12 @@ async fn get_cached( }; let processed_parts = transcription.to_text().await; - if processed_parts.is_empty() || processed_parts[0].contains("Не удалось преобразовать") - { + if processed_parts.is_empty() || processed_parts[0].contains("Не удалось преобразовать") { let error_message = processed_parts.first().cloned().unwrap_or_default(); return Err(MyError::Other(error_message)); } let full_text = processed_parts.join("\n\n"); - let new_cache_entry = TranscriptionCache { full_text, summary: None, @@ -60,7 +55,9 @@ async fn get_cached( mime_type: file.mime_type.clone(), }; - cache.set(&file_cache_key, &new_cache_entry, 86400).await?; + cache + .set(&file_cache_key, &new_cache_entry, 86400) + .await?; debug!( "Saved new transcription to file cache for unique_id: {}", file.file_id @@ -77,7 +74,9 @@ pub async fn transcription_handler(bot: Bot, msg: Message, config: &Config) -> R .await .ok(); - let Some(message) = message else { + let Some(message) = message else { return Ok(()) }; + let Some(user) = msg.clone().from else { + bot.edit_message_text(message.chat.id, message.id, "Не удалось определить пользователя.").await?; return Ok(()); }; @@ -85,48 +84,31 @@ pub async fn transcription_handler(bot: Bot, msg: Message, config: &Config) -> R match get_cached(&bot, &file, config).await { Ok(cache_entry) => { let cache = config.get_redis_client(); - let message_file_map_key = format!("message_file_map:{}", message.id); cache .set(&message_file_map_key, &file.file_unique_id, 3600) .await?; - let text_parts = split_text(cache_entry.full_text, 4000); - - let keyboard = InlineKeyboardMarkup::new(vec![vec![ - InlineKeyboardButton::callback("✨", "summarize"), - InlineKeyboardButton::callback( - "🗑️", - format!("delete_msg:{}", msg.from.unwrap().id.0), - ), - ]]); + let text_parts = split_text(&cache_entry.full_text, 4000); + if text_parts.is_empty() { + bot.edit_message_text(message.chat.id, message.id, "❌ Получен пустой текст.").await?; + return Ok(()); + } + let keyboard = create_transcription_keyboard(1, text_parts.len(), user.id.0); bot.edit_message_text( msg.chat.id, message.id, format!("
{}
", text_parts[0]), ) - .parse_mode(ParseMode::Html) - .reply_markup(keyboard.clone()) - .await?; - - for part in text_parts.iter().skip(1) { - bot.send_message( - msg.chat.id, - format!("
\n{}\n
", part), - ) - .reply_parameters(ReplyParameters::new(msg.id)) .parse_mode(ParseMode::Html) - .reply_markup(keyboard.clone()) + .reply_markup(keyboard) .await?; - } } Err(e) => { error!("Failed to get transcription: {:?}", e); let error_text = match e { - MyError::Other(msg) if msg.contains("Не удалось преобразовать") => { - msg - } + MyError::Other(msg) if msg.contains("Не удалось преобразовать") => msg, MyError::Reqwest(_) => { "❌ Ошибка: Не удалось скачать файл. Возможно, он слишком большой (>20MB)." .to_string() @@ -143,191 +125,34 @@ pub async fn transcription_handler(bot: Bot, msg: Message, config: &Config) -> R message.id, "Не удалось найти голосовое сообщение.", ) - .parse_mode(ParseMode::Html) - .await?; - } - Ok(()) -} - -pub async fn back_handler(bot: Bot, query: CallbackQuery, config: &Config) -> Result<(), MyError> { - let Some(message) = query.message else { - return Ok(()); - }; - bot.answer_callback_query(query.id).await?; - - let cache = config.get_redis_client(); - let message_cache_key = format!("message_file_map:{}", message.id()); - - let Some(file_unique_id) = cache.get::(&message_cache_key).await? else { - bot.edit_message_text( - message.chat().id, - message.id(), - "❌ Не удалось найти исходное сообщение.", - ) - .await?; - return Ok(()); - }; - - let file_cache_key = format!("transcription_by_file:{}", file_unique_id); - let Some(cache_entry) = cache.get::(&file_cache_key).await? else { - bot.edit_message_text( - message.chat().id, - message.id(), - "❌ Не удалось найти исходный текст в кеше.", - ) - .await?; - return Ok(()); - }; - - let text_parts = split_text(cache_entry.full_text, 4000); - - bot.edit_message_text( - message.chat().id, - message.id(), - format!("
{}
", text_parts[0]), - ) - .parse_mode(ParseMode::Html) - .reply_markup(InlineKeyboardMarkup::new(vec![vec![ - InlineKeyboardButton::callback("✨", "summarize"), - InlineKeyboardButton::callback("🗑️", format!("delete_msg:{}", query.from.id.0)), - ]])) - .await?; - - Ok(()) -} - -pub async fn summarization_handler( - bot: Bot, - query: CallbackQuery, - config: &Config, -) -> Result<(), MyError> { - let Some(message) = query.message else { - return Ok(()); - }; - - let Some(message) = message.regular_message() else { - return Ok(()); - }; - - bot.answer_callback_query(query.id).await?; - - let cache = config.get_redis_client(); - - let message_file_map_key = format!("message_file_map:{}", message.id); - let Some(file_unique_id) = cache.get::(&message_file_map_key).await? else { - bot.edit_message_text( - message.chat.id, - message.id, - "❌ Не удалось обработать запрос. Кнопка устарела.", - ) - .await?; - - return Ok(()); - }; - - let file_cache_key = format!("transcription_by_file:{}", file_unique_id); - - let mut cache_entry = match cache.get::(&file_cache_key).await? { - Some(entry) => entry, - None => { - bot.edit_message_text( - message.chat.id, - message.id, - "❌ Не удалось найти исходное аудио.", - ) - .await?; - - return Ok(()); - } - }; - - if let Some(cached_summary) = cache_entry.summary { - let final_text = format!( - "Краткое содержание:\n
{}
", - cached_summary - ); - - let back_keyboard = InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::callback( - "⬅️ Назад", - "back_to_full", - )]]); - - bot.edit_message_text(message.chat.id, message.id, final_text) .parse_mode(ParseMode::Html) - .reply_markup(back_keyboard) .await?; - - return Ok(()); - } - - bot.edit_message_text( - message.chat.id, - message.id, - "Составляю краткое содержание...", - ) - .await?; - - let file_data = save_file_to_memory(&bot, &cache_entry.file_id).await?; - let new_summary = - summarize_audio(cache_entry.mime_type.clone(), file_data, config.clone()).await?; - - if new_summary.is_empty() || new_summary.contains("Не удалось получить") { - bot.edit_message_text( - message.chat.id, - message.id, - "❌ Не удалось составить краткое содержание.", - ) - .await?; - return Ok(()); } - - cache_entry.summary = Some(new_summary.clone()); - cache.set(&file_cache_key, &cache_entry, 86400).await?; - - let final_text = format!( - "Краткое содержание:\n
{}
", - new_summary - ); - - let back_keyboard = InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::callback( - "⬅️ Назад", - "back_to_full", - )]]); - - bot.edit_message_text(message.chat.id, message.id, final_text) - .parse_mode(ParseMode::Html) - .reply_markup(back_keyboard) - .await?; - Ok(()) } -async fn summarize_audio( +pub async fn summarize_audio( mime_type: String, data: Bytes, config: Config, ) -> Result { - let mut settings = gem_rs::types::Settings::new(); + let mut settings = Settings::new(); settings.set_all_safety_settings(HarmBlockThreshold::BlockNone); let ai_model = config.get_json_config().get_ai_model().to_owned(); let prompt = config.get_json_config().get_summarize_prompt().to_owned(); - let mut context = gem_rs::types::Context::new(); - context.push_message(gem_rs::types::Role::Model, prompt); + let mut context = Context::new(); + context.push_message(Role::Model, prompt); - let mut client = gem_rs::client::GemSession::Builder() - .model(gem_rs::api::Models::Custom(ai_model)) + let mut client = GemSession::Builder() + .model(Models::Custom(ai_model)) .timeout(Some(Duration::from_secs(120))) .context(context) .build(); let response = client - .send_blob( - gem_rs::types::Blob::new(&mime_type, &data), - gem_rs::types::Role::User, - &settings, - ) + .send_blob(Blob::new(&mime_type, &data), Role::User, &settings) .await?; Ok(response @@ -341,24 +166,12 @@ pub async fn get_file_id(msg: &Message) -> Option { match &msg.kind { MessageKind::Common(common) => match &common.media_kind { teloxide::types::MediaKind::Audio(audio) => Some(AudioStruct { - mime_type: audio - .audio - .mime_type - .as_ref() - .unwrap() - .essence_str() - .to_owned(), + mime_type: audio.audio.mime_type.as_ref()?.essence_str().to_owned(), file_id: audio.audio.file.id.0.to_string(), file_unique_id: audio.audio.file.unique_id.0.to_string(), }), teloxide::types::MediaKind::Voice(voice) => Some(AudioStruct { - mime_type: voice - .voice - .mime_type - .as_ref() - .unwrap() - .essence_str() - .to_owned(), + mime_type: voice.voice.mime_type.as_ref()?.essence_str().to_owned(), file_id: voice.voice.file.id.0.to_owned(), file_unique_id: voice.voice.file.unique_id.0.to_owned(), }), @@ -375,7 +188,7 @@ pub async fn get_file_id(msg: &Message) -> Option { pub async fn save_file_to_memory(bot: &Bot, file_id: &str) -> Result { let file = bot - .get_file(teloxide::types::FileId(file_id.to_string())) + .get_file(FileId(file_id.to_string())) .send() .await?; let file_url = format!( @@ -383,11 +196,8 @@ pub async fn save_file_to_memory(bot: &Bot, file_id: &str) -> Result Vec { - let mut settings = gem_rs::types::Settings::new(); + let mut settings = Settings::new(); settings.set_all_safety_settings(HarmBlockThreshold::BlockNone); let error_answer = "❌ Не удалось преобразовать текст из сообщения.".to_string(); - let ai_model = self.config.get_json_config().get_ai_model().to_owned(); let prompt = self.config.get_json_config().get_ai_prompt().to_owned(); - let mut context = gem_rs::types::Context::new(); - context.push_message(gem_rs::types::Role::Model, prompt); + let mut context = Context::new(); + context.push_message(Role::Model, prompt); - let mut client = gem_rs::client::GemSession::Builder() - .model(gem_rs::api::Models::Custom(ai_model)) + let mut client = GemSession::Builder() + .model(Models::Custom(ai_model)) .timeout(Some(Duration::from_secs(120))) .context(context) .build(); let mut attempts = 0; - let mut last_text = String::new(); let mut last_error = String::new(); while attempts < 3 { match client .send_blob( - gem_rs::types::Blob::new(&self.mime_type, &self.data), - gem_rs::types::Role::User, + Blob::new(&self.mime_type, &self.data), + Role::User, &settings, ) .await { Ok(response) => { - let full_text = response - .get_results() - .first() - .cloned() - .unwrap_or_else(|| "".to_string()); - - if full_text == last_text && !full_text.is_empty() { - continue; - } - last_text = full_text.clone(); - + let full_text = response.get_results().first().cloned().unwrap_or_default(); if !full_text.is_empty() { - return split_text(full_text, 4000); - } else { - attempts += 1; - info!( - "Received empty successful response from transcription service, attempt {}", - attempts - ); + return split_text(&full_text, 4000); } + attempts += 1; + info!("Received empty response, attempt {}", attempts); } Err(error) => { attempts += 1; - - if error.to_string() == last_error { - continue; - } - last_error = error.to_string(); - - error!( - "Error with transcription (attempt {}): {:?}", - attempts, error - ); + let error_string = error.to_string(); + if error_string == last_error { continue; } + last_error = error_string; + error!("Transcription error (attempt {}): {:?}", attempts, error); } } } @@ -469,13 +257,11 @@ impl Transcription { } } -fn split_text(text: String, chunk_size: usize) -> Vec { - if text.is_empty() { - return vec![]; - } +pub fn split_text(text: &str, chunk_size: usize) -> Vec { + if text.is_empty() { return vec![]; } text.chars() .collect::>() .chunks(chunk_size) - .map(|chunk| chunk.iter().collect()) + .map(|c| c.iter().collect()) .collect() -} +} \ No newline at end of file From da267c3f46f55a3633820598e30abeb41731d692 Mon Sep 17 00:00:00 2001 From: nixxoq Date: Sat, 13 Sep 2025 13:29:46 +0300 Subject: [PATCH 40/66] implement keyboard-paginator builder --- src/util/paginator.rs | 109 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 src/util/paginator.rs diff --git a/src/util/paginator.rs b/src/util/paginator.rs new file mode 100644 index 0000000..7a252da --- /dev/null +++ b/src/util/paginator.rs @@ -0,0 +1,109 @@ +use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup}; + +pub struct Paginator<'a, T> { + module_key: &'a str, + + items: &'a [T], + + per_page: usize, + + columns: usize, + + current_page: usize, + + bottom_rows: Vec>, + + callback_prefix: String, +} + +impl<'a, T> Paginator<'a, T> { + pub fn new(module_key: &'a str, items: &'a [T]) -> Self { + Self { + module_key, + items, + per_page: 12, + columns: 3, + current_page: 0, + bottom_rows: Vec::new(), + callback_prefix: module_key.to_string(), + } + } + + pub fn per_page(mut self, per_page: usize) -> Self { + self.per_page = per_page; + self + } + + pub fn columns(mut self, columns: usize) -> Self { + self.columns = columns; + self + } + + pub fn current_page(mut self, page: usize) -> Self { + self.current_page = page; + self + } + + pub fn add_bottom_row(mut self, row: Vec) -> Self { + self.bottom_rows.push(row); + self + } + + pub fn set_callback_prefix(mut self, prefix: String) -> Self { + self.callback_prefix = prefix; + self + } + + pub fn build(&self, button_mapper: F) -> InlineKeyboardMarkup + where + F: Fn(&T) -> InlineKeyboardButton, + { + let total_items = self.items.len(); + if total_items == 0 { + return InlineKeyboardMarkup::new(self.bottom_rows.clone()); + } + + let total_pages = (total_items + self.per_page - 1) / self.per_page; + let page = self.current_page.min(total_pages - 1); + + let start = page * self.per_page; + let end = (start + self.per_page).min(total_items); + let page_items = &self.items[start..end]; + + let mut keyboard: Vec> = page_items + .iter() + .map(button_mapper) + .collect::>() + .chunks(self.columns) + .map(|chunk| chunk.to_vec()) + .collect(); + + let mut nav_row = Vec::new(); + if page > 0 { + nav_row.push(InlineKeyboardButton::callback( + "⬅️", + format!("{}:page:{}", self.module_key, page - 1), + )); + } + + nav_row.push(InlineKeyboardButton::callback( + format!("{}/{}", page + 1, total_pages), + "noop", + )); + + if page + 1 < total_pages { + nav_row.push(InlineKeyboardButton::callback( + "➡️", + format!("{}:page:{}", self.module_key, page + 1), + )); + } + + if nav_row.len() > 1 { + keyboard.push(nav_row); + } + + keyboard.extend(self.bottom_rows.clone()); + + InlineKeyboardMarkup::new(keyboard) + } +} From 97f53cc986ba32f6c64d48fca2e13c1912929c22 Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Sat, 13 Sep 2025 13:31:21 +0300 Subject: [PATCH 41/66] added delete-confirm system --- src/bot/callbacks/delete.rs | 128 ++++++++++++++++++++++++++---------- src/bot/callbacks/mod.rs | 18 +++-- src/bot/keyboards/delete.rs | 14 +++- 3 files changed, 116 insertions(+), 44 deletions(-) diff --git a/src/bot/callbacks/delete.rs b/src/bot/callbacks/delete.rs index 0c1eb22..171fba9 100644 --- a/src/bot/callbacks/delete.rs +++ b/src/bot/callbacks/delete.rs @@ -1,23 +1,35 @@ +use crate::bot::callbacks::transcription::back_handler; +use crate::bot::keyboards::delete::confirm_delete_keyboard; +use crate::core::config::Config; use crate::errors::MyError; use log::error; -use teloxide::Bot; -use teloxide::payloads::AnswerCallbackQuerySetters; -use teloxide::prelude::{CallbackQuery, Requester}; +use teloxide::prelude::*; +use teloxide::types::{ChatId, User}; -pub async fn delete_message_handler(bot: Bot, query: CallbackQuery) -> Result<(), MyError> { - let Some(message_to_delete) = query.message else { - return Ok(()); - }; - - let Some(message) = message_to_delete.regular_message() else { - return Ok(()); - }; - - let Some(data) = query.data else { - return Ok(()); - }; +async fn has_delete_permission( + bot: &Bot, + chat_id: ChatId, + is_group: bool, + clicker: &User, + target_user_id: u64, +) -> bool { + if target_user_id == 72 { + return true; + } + if clicker.id.0 == target_user_id { + return true; + } + if is_group { + if let Ok(member) = bot.get_chat_member(chat_id, clicker.id).await { + return member.is_privileged(); + } + } + false +} - let clicker = query.from; +pub async fn handle_delete_request(bot: Bot, query: CallbackQuery) -> Result<(), MyError> { + let Some(message) = query.message.as_ref().and_then(|m| m.regular_message()) else { return Ok(()) }; + let Some(data) = query.data.as_ref() else { return Ok(()) }; let target_user_id_str = data.strip_prefix("delete_msg:").unwrap_or_default(); let Ok(target_user_id) = target_user_id_str.parse::() else { @@ -28,36 +40,80 @@ pub async fn delete_message_handler(bot: Bot, query: CallbackQuery) -> Result<() return Ok(()); }; - let mut has_permission = false; + let can_delete = has_delete_permission( + &bot, + message.chat.id, + message.chat.is_group() || message.chat.is_supergroup(), + &query.from, + target_user_id, + ) + .await; - if target_user_id == 72 { - has_permission = true; - } else if clicker.id.0 == target_user_id { - has_permission = true; + if !can_delete { + bot.answer_callback_query(query.id) + .text("❌ Удалить может только автор сообщения или администратор.") + .show_alert(true) + .await?; + return Ok(()); } - if !has_permission && message.chat.is_group() || message.chat.is_supergroup() { - if let Ok(member) = bot.get_chat_member(message.chat.id, clicker.id).await { - if member.is_privileged() { - has_permission = true; - } - } - } + bot.answer_callback_query(query.id).await?; + bot.edit_message_text( + message.chat.id, + message.id, + "Вы уверены, что хотите удалить?", + ) + .reply_markup(confirm_delete_keyboard(target_user_id)) + .await?; + + Ok(()) +} + +pub async fn handle_delete_confirmation( + bot: Bot, + query: CallbackQuery, + config: &Config, +) -> Result<(), MyError> { + let Some(message) = query.message.as_ref().and_then(|m| m.regular_message()) else { return Ok(()) }; + let Some(data) = query.data.as_ref() else { return Ok(()) }; - if !has_permission { + let parts: Vec<&str> = data.split(':').collect(); + if parts.len() != 3 { return Ok(()) }; + + let Ok(target_user_id) = parts[1].parse::() else { return Ok(()) }; + let action = parts[2]; + + let can_delete = has_delete_permission( + &bot, + message.chat.id, + message.chat.is_group() || message.chat.is_supergroup(), + &query.from, + target_user_id, + ) + .await; + + if !can_delete { bot.answer_callback_query(query.id) - .text("❌ Удалить может только автор сообщения или администратор.") + .text("❌ У вас нет прав для этого действия.") .show_alert(true) .await?; return Ok(()); } - bot.answer_callback_query(query.id).await?; + bot.answer_callback_query(query.clone().id).await?; - bot.delete_message(message.chat.id, message.id) - .await - .map_err(|e| error!("Failed to delete bot's message: {:?}", e)) - .ok(); + match action { + "yes" => { + bot.delete_message(message.chat.id, message.id) + .await + .map_err(|e| error!("Failed to delete message: {:?}", e)) + .ok(); + } + "no" => { + back_handler(bot, query, config).await?; + } + _ => {} + } Ok(()) -} +} \ No newline at end of file diff --git a/src/bot/callbacks/mod.rs b/src/bot/callbacks/mod.rs index 201b77c..f70cbbb 100644 --- a/src/bot/callbacks/mod.rs +++ b/src/bot/callbacks/mod.rs @@ -1,24 +1,26 @@ use crate::bot::callbacks::cobalt_pagination::handle_cobalt_pagination; -use crate::bot::callbacks::delete::delete_message_handler; +use crate::bot::callbacks::delete::{handle_delete_confirmation, handle_delete_request}; use crate::bot::callbacks::module::{ module_option_handler, module_select_handler, module_toggle_handler, settings_back_handler, settings_set_handler, }; +use crate::bot::callbacks::transcription::{ + back_handler, pagination_handler, summarization_handler, +}; use crate::bot::callbacks::translate::handle_translate_callback; use crate::bot::callbacks::whisper::handle_whisper_callback; use crate::core::config::Config; use crate::errors::MyError; use std::sync::Arc; -use teloxide::Bot; use teloxide::prelude::{CallbackQuery, Requester}; -use crate::bot::callbacks::transcription::{back_handler, pagination_handler, summarization_handler}; +use teloxide::Bot; pub mod cobalt_pagination; pub mod delete; pub mod module; +pub mod transcription; pub mod translate; pub mod whisper; -pub mod transcription; pub async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { let config = Arc::new(Config::new().await); @@ -26,8 +28,10 @@ pub async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Result<(), M if let Some(data) = &q.data { if data.starts_with("settings_set:") { settings_set_handler(bot, q).await? - } else if data.starts_with("delete_msg") { - delete_message_handler(bot, q).await? + } else if data.starts_with("delete_msg:") { + handle_delete_request(bot, q).await? + } else if data.starts_with("delete_confirm:") { + handle_delete_confirmation(bot, q, &config).await? } else if data.starts_with("summarize") { summarization_handler(bot, q, &config).await? } else if data.starts_with("back_to_full") { @@ -54,4 +58,4 @@ pub async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Result<(), M } Ok(()) -} +} \ No newline at end of file diff --git a/src/bot/keyboards/delete.rs b/src/bot/keyboards/delete.rs index 1cde4f1..a16e8a8 100644 --- a/src/bot/keyboards/delete.rs +++ b/src/bot/keyboards/delete.rs @@ -1,10 +1,22 @@ use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup}; pub const DELETE_CALLBACK_DATA: &str = "delete_msg"; +pub const CONFIRM_DELETE_CALLBACK_DATA: &str = "delete_confirm"; pub(crate) fn delete_message_button(original_user_id: u64) -> InlineKeyboardMarkup { let callback_data = format!("{}:{}", DELETE_CALLBACK_DATA, original_user_id); let delete_button = InlineKeyboardButton::callback("🗑️", callback_data); - InlineKeyboardMarkup::new(vec![vec![delete_button]]) +} + +pub(crate) fn confirm_delete_keyboard(original_user_id: u64) -> InlineKeyboardMarkup { + let yes_callback = format!("{}:{}:yes", CONFIRM_DELETE_CALLBACK_DATA, original_user_id); + let no_callback = format!("{}:{}:no", CONFIRM_DELETE_CALLBACK_DATA, original_user_id); + + let buttons = vec![ + InlineKeyboardButton::callback("Да", yes_callback), + InlineKeyboardButton::callback("Нет", no_callback), + ]; + + InlineKeyboardMarkup::new(vec![buttons]) } \ No newline at end of file From f2b47a96c645b8fa8a17780f13a786f2e711584a Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Sat, 13 Sep 2025 14:02:15 +0300 Subject: [PATCH 42/66] reimplemented transcription paginator on new pagination system --- src/bot/callbacks/mod.rs | 2 +- src/bot/callbacks/transcription.rs | 114 +++++++---------------------- src/bot/keyboards/transcription.rs | 40 +++++----- src/core/services/transcription.rs | 29 ++++---- src/util/mod.rs | 1 + 5 files changed, 65 insertions(+), 121 deletions(-) diff --git a/src/bot/callbacks/mod.rs b/src/bot/callbacks/mod.rs index f70cbbb..8640fb9 100644 --- a/src/bot/callbacks/mod.rs +++ b/src/bot/callbacks/mod.rs @@ -36,7 +36,7 @@ pub async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Result<(), M summarization_handler(bot, q, &config).await? } else if data.starts_with("back_to_full") { back_handler(bot, q, &config).await? - } else if data.starts_with("paginate:") { + } else if data.starts_with("transcription:page:") { pagination_handler(bot, q, &config).await? } else if data.starts_with("module_select:") { module_select_handler(bot, q).await? diff --git a/src/bot/callbacks/transcription.rs b/src/bot/callbacks/transcription.rs index e4ae46e..4c7e020 100644 --- a/src/bot/callbacks/transcription.rs +++ b/src/bot/callbacks/transcription.rs @@ -1,4 +1,6 @@ -use crate::bot::keyboards::transcription::{create_summary_keyboard, create_transcription_keyboard}; +use crate::bot::keyboards::transcription::{ + create_summary_keyboard, create_transcription_keyboard, TRANSCRIPTION_MODULE_KEY, +}; use crate::core::config::Config; use crate::core::services::transcription::{ save_file_to_memory, split_text, summarize_audio, TranscriptionCache, @@ -13,65 +15,37 @@ pub async fn pagination_handler( config: &Config, ) -> Result<(), MyError> { let Some(message) = query.message.as_ref().and_then(|m| m.regular_message()) else { - bot.answer_callback_query(query.id).await?; - return Ok(()); - }; - - let Some(data) = query.data.as_ref() else { return Ok(()); }; + let Some(data) = query.data.as_ref() else { return Ok(()) }; let parts: Vec<&str> = data.split(':').collect(); - if parts.len() != 4 || parts[0] != "paginate" { - bot.answer_callback_query(query.id).await?; - return Ok(()); - } - - let page: usize = parts[1].parse().unwrap_or(1); - let total_pages: usize = parts[2].parse().unwrap_or(1); - let user_id: u64 = parts[3].parse().unwrap_or(0); - - if query.from.id.0 != user_id { - bot.answer_callback_query(query.id) - .text("Вы не можете управлять этим сообщением.") - .show_alert(true) - .await?; + if !(parts.len() == 3 && parts[0] == TRANSCRIPTION_MODULE_KEY && parts[1] == "page") { return Ok(()); } - bot.answer_callback_query(query.id).await?; + let Ok(page) = parts[2].parse::() else { return Ok(()) }; let cache = config.get_redis_client(); let message_cache_key = format!("message_file_map:{}", message.id); - let Some(file_unique_id) = cache.get::(&message_cache_key).await? else { - bot.edit_message_text( - message.chat.id, - message.id, - "❌ Не удалось обработать запрос. Кнопка устарела.", - ) - .await?; + bot.edit_message_text(message.chat.id, message.id, "❌ Кнопка устарела.").await?; return Ok(()); }; let file_cache_key = format!("transcription_by_file:{}", file_unique_id); let Some(cache_entry) = cache.get::(&file_cache_key).await? else { - bot.edit_message_text( - message.chat.id, - message.id, - "❌ Не удалось найти исходный текст в кеше.", - ) - .await?; + bot.edit_message_text(message.chat.id, message.id, "❌ Не удалось найти текст в кеше.").await?; return Ok(()); }; let text_parts = split_text(&cache_entry.full_text, 4000); - if page == 0 || page > text_parts.len() { + if page >= text_parts.len() { return Ok(()); } - let new_text = format!("
{}
", text_parts[page - 1]); - let new_keyboard = create_transcription_keyboard(page, total_pages, user_id); + let new_text = format!("
{}
", text_parts[page]); + let new_keyboard = create_transcription_keyboard(page, text_parts.len(), query.from.id.0); if message.text() != Some(new_text.as_str()) || message.reply_markup() != Some(&new_keyboard) { bot.edit_message_text(message.chat.id, message.id, new_text) @@ -84,40 +58,27 @@ pub async fn pagination_handler( } pub async fn back_handler(bot: Bot, query: CallbackQuery, config: &Config) -> Result<(), MyError> { - let Some(message) = query.message else { - return Ok(()); - }; - bot.answer_callback_query(query.id).await?; + let Some(message) = query.message.and_then(|m| m.regular_message().cloned()) else { return Ok(()) }; let cache = config.get_redis_client(); - let message_cache_key = format!("message_file_map:{}", message.id()); + let message_cache_key = format!("message_file_map:{}", message.id); let Some(file_unique_id) = cache.get::(&message_cache_key).await? else { - bot.edit_message_text( - message.chat().id, - message.id(), - "❌ Не удалось найти исходное сообщение.", - ) - .await?; + bot.edit_message_text(message.chat.id, message.id, "❌ Не удалось найти исходное сообщение.").await?; return Ok(()); }; let file_cache_key = format!("transcription_by_file:{}", file_unique_id); let Some(cache_entry) = cache.get::(&file_cache_key).await? else { - bot.edit_message_text( - message.chat().id, - message.id(), - "❌ Не удалось найти исходный текст в кеше.", - ) - .await?; + bot.edit_message_text(message.chat.id, message.id, "❌ Не удалось найти текст в кеше.").await?; return Ok(()); }; let text_parts = split_text(&cache_entry.full_text, 4000); - let keyboard = create_transcription_keyboard(1, text_parts.len(), query.from.id.0); + let keyboard = create_transcription_keyboard(0, text_parts.len(), query.from.id.0); bot.edit_message_text( - message.chat().id, - message.id(), + message.chat.id, + message.id, format!("
{}
", text_parts[0]), ) .parse_mode(ParseMode::Html) @@ -132,20 +93,12 @@ pub async fn summarization_handler( query: CallbackQuery, config: &Config, ) -> Result<(), MyError> { - let Some(message) = query.message else { - return Ok(()); - }; - bot.answer_callback_query(query.id).await?; + let Some(message) = query.message.and_then(|m| m.regular_message().cloned()) else { return Ok(()) }; let cache = config.get_redis_client(); - let message_file_map_key = format!("message_file_map:{}", message.id()); + let message_file_map_key = format!("message_file_map:{}", message.id); let Some(file_unique_id) = cache.get::(&message_file_map_key).await? else { - bot.edit_message_text( - message.chat().id, - message.id(), - "❌ Не удалось обработать запрос. Кнопка устарела.", - ) - .await?; + bot.edit_message_text(message.chat.id, message.id, "❌ Кнопка устарела.").await?; return Ok(()); }; @@ -153,12 +106,7 @@ pub async fn summarization_handler( let mut cache_entry = match cache.get::(&file_cache_key).await? { Some(entry) => entry, None => { - bot.edit_message_text( - message.chat().id, - message.id(), - "❌ Не удалось найти исходное аудио.", - ) - .await?; + bot.edit_message_text(message.chat.id, message.id, "❌ Не удалось найти исходное аудио.").await?; return Ok(()); } }; @@ -168,31 +116,21 @@ pub async fn summarization_handler( "Краткое содержание:\n
{}
", cached_summary ); - bot.edit_message_text(message.chat().id, message.id(), final_text) + bot.edit_message_text(message.chat.id, message.id, final_text) .parse_mode(ParseMode::Html) .reply_markup(create_summary_keyboard()) .await?; return Ok(()); } - bot.edit_message_text( - message.chat().id, - message.id(), - "Составляю краткое содержание...", - ) - .await?; + bot.edit_message_text(message.chat.id, message.id, "Составляю краткое содержание...").await?; let file_data = save_file_to_memory(&bot, &cache_entry.file_id).await?; let new_summary = summarize_audio(cache_entry.mime_type.clone(), file_data, config.clone()).await?; if new_summary.is_empty() || new_summary.contains("Не удалось получить") { - bot.edit_message_text( - message.chat().id, - message.id(), - "❌ Не удалось составить краткое содержание.", - ) - .await?; + bot.edit_message_text(message.chat.id, message.id, "❌ Не удалось составить краткое содержание.").await?; return Ok(()); } @@ -203,7 +141,7 @@ pub async fn summarization_handler( "Краткое содержание:\n
{}
", new_summary ); - bot.edit_message_text(message.chat().id, message.id(), final_text) + bot.edit_message_text(message.chat.id, message.id, final_text) .parse_mode(ParseMode::Html) .reply_markup(create_summary_keyboard()) .await?; diff --git a/src/bot/keyboards/transcription.rs b/src/bot/keyboards/transcription.rs index 3c4ec06..0c48bb6 100644 --- a/src/bot/keyboards/transcription.rs +++ b/src/bot/keyboards/transcription.rs @@ -1,40 +1,46 @@ use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup}; +pub const TRANSCRIPTION_MODULE_KEY: &str = "transcription"; + pub fn create_transcription_keyboard( - page: usize, + current_page: usize, total_pages: usize, user_id: u64, ) -> InlineKeyboardMarkup { - let mut row = vec![]; + let mut keyboard: Vec> = vec![]; + + let mut nav_row = Vec::new(); - if page > 1 { - row.push(InlineKeyboardButton::callback( + if current_page > 0 { + nav_row.push(InlineKeyboardButton::callback( "⬅️", - format!("paginate:{}:{}:{}", page - 1, total_pages, user_id), + format!("{}:page:{}", TRANSCRIPTION_MODULE_KEY, current_page - 1), )); } - row.push(InlineKeyboardButton::callback( - format!("📄 {}/{}", page, total_pages), + nav_row.push(InlineKeyboardButton::callback( + format!("📄 {}/{}", current_page + 1, total_pages), "noop", )); - if page < total_pages { - row.push(InlineKeyboardButton::callback( + if current_page + 1 < total_pages { + nav_row.push(InlineKeyboardButton::callback( "➡️", - format!("paginate:{}:{}:{}", page + 1, total_pages, user_id), + format!("{}:page:{}", TRANSCRIPTION_MODULE_KEY, current_page + 1), )); } - let summary_button = InlineKeyboardButton::callback("✨", "summarize"); - let delete_button = - InlineKeyboardButton::callback("🗑️", format!("delete_msg:{}", user_id)); - if total_pages > 1 { - InlineKeyboardMarkup::new(vec![row, vec![summary_button, delete_button]]) - } else { - InlineKeyboardMarkup::new(vec![vec![summary_button, delete_button]]) + keyboard.push(nav_row); } + + let action_row = vec![ + InlineKeyboardButton::callback("✨", "summarize"), + InlineKeyboardButton::callback("🗑️", format!("delete_msg:{}", user_id)), + ]; + keyboard.push(action_row); + + InlineKeyboardMarkup::new(keyboard) } pub fn create_summary_keyboard() -> InlineKeyboardMarkup { diff --git a/src/core/services/transcription.rs b/src/core/services/transcription.rs index 76d11b6..ec091b2 100644 --- a/src/core/services/transcription.rs +++ b/src/core/services/transcription.rs @@ -3,9 +3,9 @@ use crate::core::config::Config; use crate::errors::MyError; use crate::util::enums::AudioStruct; use bytes::Bytes; -use gem_rs::types::{HarmBlockThreshold, Context, Role, Settings, Blob}; -use gem_rs::client::GemSession; use gem_rs::api::Models; +use gem_rs::client::GemSession; +use gem_rs::types::{Blob, Context, HarmBlockThreshold, Role, Settings}; use log::{debug, error, info}; use redis_macros::{FromRedisValue, ToRedisArgs}; use serde::{Deserialize, Serialize}; @@ -55,9 +55,7 @@ async fn get_cached( mime_type: file.mime_type.clone(), }; - cache - .set(&file_cache_key, &new_cache_entry, 86400) - .await?; + cache.set(&file_cache_key, &new_cache_entry, 86400).await?; debug!( "Saved new transcription to file cache for unique_id: {}", file.file_id @@ -75,7 +73,7 @@ pub async fn transcription_handler(bot: Bot, msg: Message, config: &Config) -> R .ok(); let Some(message) = message else { return Ok(()) }; - let Some(user) = msg.clone().from else { + let Some(user) = msg.from.as_ref() else { bot.edit_message_text(message.chat.id, message.id, "Не удалось определить пользователя.").await?; return Ok(()); }; @@ -91,11 +89,12 @@ pub async fn transcription_handler(bot: Bot, msg: Message, config: &Config) -> R let text_parts = split_text(&cache_entry.full_text, 4000); if text_parts.is_empty() { - bot.edit_message_text(message.chat.id, message.id, "❌ Получен пустой текст.").await?; + bot.edit_message_text(message.chat.id, message.id, "❌ Получен пустой текст.") + .await?; return Ok(()); } - let keyboard = create_transcription_keyboard(1, text_parts.len(), user.id.0); + let keyboard = create_transcription_keyboard(0, text_parts.len(), user.id.0); bot.edit_message_text( msg.chat.id, message.id, @@ -229,11 +228,7 @@ impl Transcription { while attempts < 3 { match client - .send_blob( - Blob::new(&self.mime_type, &self.data), - Role::User, - &settings, - ) + .send_blob(Blob::new(&self.mime_type, &self.data), Role::User, &settings) .await { Ok(response) => { @@ -247,7 +242,9 @@ impl Transcription { Err(error) => { attempts += 1; let error_string = error.to_string(); - if error_string == last_error { continue; } + if error_string == last_error { + continue; + } last_error = error_string; error!("Transcription error (attempt {}): {:?}", attempts, error); } @@ -258,7 +255,9 @@ impl Transcription { } pub fn split_text(text: &str, chunk_size: usize) -> Vec { - if text.is_empty() { return vec![]; } + if text.is_empty() { + return vec![]; + } text.chars() .collect::>() .chunks(chunk_size) diff --git a/src/util/mod.rs b/src/util/mod.rs index e4e9fda..0b0c902 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1 +1,2 @@ pub mod enums; +pub mod paginator; From c86152370ed21bb2447e10397e64435cbb55268f Mon Sep 17 00:00:00 2001 From: nixxoq Date: Fri, 12 Sep 2025 21:30:22 +0300 Subject: [PATCH 43/66] ability to convert serde_json::Error to MyError enum --- src/errors.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/errors.rs b/src/errors.rs index 1dd8f0b..74f86b8 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -54,6 +54,9 @@ pub enum MyError { #[error("UTF-8 conversion error: {0}")] Utf8(#[from] FromUtf8Error), + + #[error("Serde json error: {0}")] + SerdeJson(#[from] serde_json::Error), } impl From<&str> for MyError { From 1598ea213a9ab07b12216b0e34a5051e1fb05706 Mon Sep 17 00:00:00 2001 From: nixxoq Date: Wed, 17 Sep 2025 21:39:31 +0300 Subject: [PATCH 44/66] chore(callbacks/mod): refactor general callbacks handler --- src/bot/callbacks/mod.rs | 215 +++++++++++++++++++++++++++++++-------- 1 file changed, 171 insertions(+), 44 deletions(-) diff --git a/src/bot/callbacks/mod.rs b/src/bot/callbacks/mod.rs index 8640fb9..0c996c2 100644 --- a/src/bot/callbacks/mod.rs +++ b/src/bot/callbacks/mod.rs @@ -1,61 +1,188 @@ -use crate::bot::callbacks::cobalt_pagination::handle_cobalt_pagination; -use crate::bot::callbacks::delete::{handle_delete_confirmation, handle_delete_request}; -use crate::bot::callbacks::module::{ - module_option_handler, module_select_handler, module_toggle_handler, settings_back_handler, - settings_set_handler, +use crate::{ + bot::{ + callbacks::{ + cobalt_pagination::handle_cobalt_pagination, + delete::{handle_delete_confirmation, handle_delete_request}, + translate::handle_translate_callback, + whisper::handle_whisper_callback, + }, + commands::settings::update_settings_message, + modules::{Owner, registry::MOD_MANAGER}, + }, + core::{ + config::Config, + services::speech_recognition::{back_handler, pagination_handler, summarization_handler}, + }, + errors::MyError, }; -use crate::bot::callbacks::transcription::{ - back_handler, pagination_handler, summarization_handler, -}; -use crate::bot::callbacks::translate::handle_translate_callback; -use crate::bot::callbacks::whisper::handle_whisper_callback; -use crate::core::config::Config; -use crate::errors::MyError; use std::sync::Arc; -use teloxide::prelude::{CallbackQuery, Requester}; -use teloxide::Bot; +use teloxide::{ + Bot, + payloads::EditMessageTextSetters, + prelude::{CallbackQuery, Requester}, +}; pub mod cobalt_pagination; pub mod delete; -pub mod module; -pub mod transcription; pub mod translate; pub mod whisper; +enum CallbackAction<'a> { + ModuleSettings { + module_key: &'a str, + rest: &'a str, + }, + ModuleSelect { + owner_type: &'a str, + owner_id: &'a str, + module_key: &'a str, + }, + SettingsBack { + owner_type: &'a str, + owner_id: &'a str, + }, + CobaltPagination, + DeleteMessage, + DeleteConfirmation, + Summarize, + SpeechPage, + BackToFull, + Whisper, + Translate, + NoOp, +} + +fn parse_callback_data(data: &'_ str) -> Option> { + if data == "noop" { + return Some(CallbackAction::NoOp); + } + + if let Some(rest) = data.strip_prefix("module_select:") { + let parts: Vec<_> = rest.split(':').collect(); + if parts.len() == 3 { + return Some(CallbackAction::ModuleSelect { + owner_type: parts[0], + owner_id: parts[1], + module_key: parts[2], + }); + } + } + + if let Some(rest) = data.strip_prefix("settings_back:") { + let parts: Vec<_> = rest.split(':').collect(); + if parts.len() == 2 { + return Some(CallbackAction::SettingsBack { + owner_type: parts[0], + owner_id: parts[1], + }); + } + } + + if let Some(module_key) = MOD_MANAGER.get_all_modules().iter().find_map(|m| { + data.starts_with(&format!("{}:settings:", m.key())) + .then_some(m.key()) + }) { + let rest = data + .strip_prefix(&format!("{}:settings:", module_key)) + .unwrap_or(""); + return Some(CallbackAction::ModuleSettings { module_key, rest }); + } + + if data.starts_with("delete_msg") { + return Some(CallbackAction::DeleteMessage); + } + if data.starts_with("delete_confirmation:") { + return Some(CallbackAction::DeleteConfirmation); + } + if data.starts_with("summarize") { + return Some(CallbackAction::Summarize); + } + if data.starts_with("speech:page:") { + return Some(CallbackAction::SpeechPage); + } + if data.starts_with("back_to_full") { + return Some(CallbackAction::BackToFull); + } + if data.starts_with("whisper") { + return Some(CallbackAction::Whisper); + } + if data.starts_with("tr_") { + return Some(CallbackAction::Translate); + } + if data.starts_with("cobalt:") { + return Some(CallbackAction::CobaltPagination); + } + + None +} + pub async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { let config = Arc::new(Config::new().await); - if let Some(data) = &q.data { - if data.starts_with("settings_set:") { - settings_set_handler(bot, q).await? - } else if data.starts_with("delete_msg:") { - handle_delete_request(bot, q).await? - } else if data.starts_with("delete_confirm:") { + let Some(data) = &q.data else { + return Ok(()); + }; + + match parse_callback_data(data) { + Some(CallbackAction::ModuleSelect { + owner_type, + owner_id, + module_key, + }) => { + if let (Some(module), Some(message)) = (MOD_MANAGER.get_module(module_key), &q.message) + { + let owner = Owner { + id: owner_id.to_string(), + r#type: owner_type.to_string(), + }; + let (text, keyboard) = module.get_settings_ui(&owner).await?; + bot.edit_message_text(message.chat().id, message.id(), text) + .reply_markup(keyboard) + .await?; + } + } + Some(CallbackAction::SettingsBack { + owner_type, + owner_id, + }) => { + if let Some(message) = q.message { + update_settings_message(bot, message, owner_id.to_string(), owner_type.to_string()) + .await?; + } + } + Some(CallbackAction::ModuleSettings { module_key, rest }) => { + if let (Some(module), Some(message)) = (MOD_MANAGER.get_module(module_key), &q.message) + { + let owner = Owner { + id: message.chat().id.to_string(), + r#type: (if message.chat().is_private() { + "user" + } else { + "group" + }) + .to_string(), + }; + module.handle_callback(bot, &q, &owner, rest).await?; + } + } + Some(CallbackAction::CobaltPagination) => handle_cobalt_pagination(bot, q, config).await?, + Some(CallbackAction::DeleteMessage) => handle_delete_request(bot, q).await?, + Some(CallbackAction::DeleteConfirmation) => { handle_delete_confirmation(bot, q, &config).await? - } else if data.starts_with("summarize") { - summarization_handler(bot, q, &config).await? - } else if data.starts_with("back_to_full") { - back_handler(bot, q, &config).await? - } else if data.starts_with("transcription:page:") { - pagination_handler(bot, q, &config).await? - } else if data.starts_with("module_select:") { - module_select_handler(bot, q).await? - } else if data.starts_with("module_toggle") { - module_toggle_handler(bot, q).await? - } else if data.starts_with("module_opt:") { - module_option_handler(bot, q).await? - } else if data.starts_with("settings_back:") { - settings_back_handler(bot, q).await? - } else if data.starts_with("whisper") { - handle_whisper_callback(bot, q, &config).await? - } else if data.starts_with("tr_") { - handle_translate_callback(bot, q, &config).await? - } else if data.starts_with("cobalt:") { - handle_cobalt_pagination(bot, q, config).await? - } else { + } + Some(CallbackAction::Summarize) => summarization_handler(bot, q, &config).await?, + Some(CallbackAction::SpeechPage) => pagination_handler(bot, q, &config).await?, + Some(CallbackAction::BackToFull) => back_handler(bot, q, &config).await?, + Some(CallbackAction::Whisper) => handle_whisper_callback(bot, q, &config).await?, + Some(CallbackAction::Translate) => handle_translate_callback(bot, q, &config).await?, + Some(CallbackAction::NoOp) => { + bot.answer_callback_query(q.id).await?; + } + None => { + log::warn!("Unhandled callback query data: {}", data); bot.answer_callback_query(q.id).await?; } } Ok(()) -} \ No newline at end of file +} From 9683d650587c568e4fe4f2f7466f7708fb58ee92 Mon Sep 17 00:00:00 2001 From: nixxoq Date: Wed, 17 Sep 2025 21:42:57 +0300 Subject: [PATCH 45/66] feat(core/db): add new Settings schemas chore(core/db): remove old SettingsRepo trait --- src/core/db/schemas.rs | 21 ---- src/core/db/schemas/settings.rs | 207 ++++++++++++++------------------ 2 files changed, 87 insertions(+), 141 deletions(-) diff --git a/src/core/db/schemas.rs b/src/core/db/schemas.rs index f5fd046..1cfa8da 100644 --- a/src/core/db/schemas.rs +++ b/src/core/db/schemas.rs @@ -2,8 +2,6 @@ pub mod group; pub mod settings; pub mod user; -use crate::core::db::schemas::settings::ModuleSettings; -use crate::errors::MyError; use crate::core::services::currency::converter::CurrencyStruct; use async_trait::async_trait; use mongodb::results::UpdateResult; @@ -23,22 +21,3 @@ pub trait CurrenciesFunctions: Sized { -> Result; async fn remove_currency(id: &str, currency: &str) -> Result; } - -#[async_trait] -pub trait SettingsRepo { - async fn get_or_create(owner_id: &str, owner_type: &str) -> Result - where - Self: Sized; - - async fn update_module( - owner_id: &str, - owner_type: &str, - module_key: &str, - modifier: F, - ) -> Result - where - Self: Sized, - F: FnOnce(&mut ModuleSettings) + Send; - - fn modules_mut(&mut self) -> &mut Vec; -} diff --git a/src/core/db/schemas/settings.rs b/src/core/db/schemas/settings.rs index 6b67a56..2460802 100644 --- a/src/core/db/schemas/settings.rs +++ b/src/core/db/schemas/settings.rs @@ -1,10 +1,17 @@ -use crate::core::db::schemas::SettingsRepo; -use crate::errors::MyError; -use async_trait::async_trait; -use mongodb::bson; -use mongodb::bson::{doc, oid::ObjectId}; -use oximod::{Model, ModelTrait}; +use crate::{ + bot::modules::{ModuleSettings, Owner}, + errors::MyError, +}; +use mongodb::{ + bson, + bson::{doc, oid::ObjectId}, +}; + +use crate::bot::modules::registry::MOD_MANAGER; +use oximod::Model; use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; #[derive(Debug, Clone, Serialize, Deserialize, Model)] #[db("fulturate")] @@ -15,135 +22,95 @@ pub struct Settings { #[index(unique, name = "owner")] pub owner_id: String, - pub owner_type: String, #[serde(default)] - pub modules: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ModuleSettings { - pub key: String, // уникальный ключ модуля, например "currency" или "speech_recog" - pub enabled: bool, // включен или нет - pub description: String, // описание модуля - #[serde(default)] // тут ещё было бы неплохо добавить лимиты, по типу максимум и какой сейчас, - // но мне че то лень это продумывать - pub options: Vec, + pub modules: BTreeMap, } -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct ModuleOption { - pub key: String, - pub value: String, -} +impl Settings { + pub async fn create_with_defaults(owner: &Owner) -> Result { + let mut modules_map = BTreeMap::::new(); -#[async_trait] -impl SettingsRepo for Settings { - async fn get_or_create(owner_id: &str, owner_type: &str) -> Result { - if let Some(found) = - Settings::find_one(doc! { "owner_id": owner_id, "owner_type": owner_type }).await? - { - Ok(found) - } else { - let default_modules = vec![ - ModuleSettings { - key: "currency".to_string(), - enabled: false, - description: "Конвертация валют".to_string(), - options: vec![ModuleOption { - key: "currencies".into(), - value: "USD,EUR".into(), - }], - }, - ModuleSettings { - key: "speech".to_string(), - enabled: false, - description: "Распознавание речи".to_string(), - options: vec![ - ModuleOption { - key: "model".into(), - value: "Gemini 2.5 Flash".into(), - }, - ModuleOption { - key: "token".into(), - value: "".into(), - }, - ], - }, - create_cobalt_module(), - ]; - let new = Settings::new() - .owner_id(owner_id.to_string()) - .owner_type(owner_type.to_string()) - .modules(default_modules); - ModelTrait::save(&new).await?; - Settings::get_or_create(owner_id, owner_type).await + for module in MOD_MANAGER.get_all_modules() { + if module.enabled_for(&owner.r#type) { + match module.factory_settings() { + Ok(settings_json) => { + modules_map.insert(module.key().to_string(), settings_json); + } + Err(e) => log::error!( + "Failed to get default settings for module '{}': {}", + module.key(), + e + ), + } + } } + + let new_doc = Settings::new() + .owner_id(owner.id.clone()) + .owner_type(owner.r#type.clone()) + .modules(modules_map); + + new_doc.save().await?; + Ok(new_doc) } - async fn update_module( - owner_id: &str, - owner_type: &str, + pub async fn get_module_settings( + owner: &Owner, module_key: &str, - modifier: F, - ) -> Result - where - Self: Sized, - F: FnOnce(&mut ModuleSettings) + Send, - { - let mut settings = Self::get_or_create(owner_id, owner_type).await?; - - if let Some(module) = settings - .modules_mut() - .iter_mut() - .find(|m| m.key == module_key) - { - modifier(module); - } else { - return Err(MyError::ModuleNotFound(module_key.to_string())); - } - - let filter = doc! { "owner_id": owner_id, "owner_type": owner_type }; - let modules_as_bson = bson::to_bson(&settings.modules)?; - let update = doc! { "$set": { "modules": modules_as_bson } }; + ) -> Result { + let settings_doc = Self::get_or_create(owner).await?; - Self::update_one(filter, update).await?; + let module_settings = settings_doc.modules.get(module_key).map_or_else( + || Ok(T::default()), + |json_val| serde_json::from_value(json_val.clone()).map_err(MyError::from), + )?; - Ok(settings) + Ok(module_settings) } - fn modules_mut(&mut self) -> &mut Vec { - &mut self.modules + pub async fn update_module_settings( + owner: &Owner, + module_key: &str, + new_settings: T, + ) -> Result<(), MyError> { + let json_val = serde_json::to_value(new_settings)?; + + let result = Self::update_one( + doc! { "owner_id": &owner.id, "owner_type": &owner.r#type }, + doc! { "$set": { format!("modules.{}", module_key): bson::to_bson(&json_val)? } }, + ) + .await?; + + if result.matched_count == 0 { + let mut modules = BTreeMap::new(); + modules.insert(module_key.to_string(), json_val); + + let new_doc = Settings::new() + .owner_id(owner.id.clone()) + .owner_type(owner.r#type.clone()) + .modules(modules); + + new_doc.save().await?; + } + + Ok(()) } -} -fn create_cobalt_module() -> ModuleSettings { - ModuleSettings { - key: "cobalt".to_string(), - enabled: true, - description: "Настройки для Cobalt Downloader".to_string(), - options: vec![ - ModuleOption { - key: "preferred_output".into(), - value: "auto".into(), - }, - ModuleOption { - key: "video_format".into(), - value: "h264".into(), - }, - ModuleOption { - key: "video_quality".into(), - value: "1080".into(), - }, - ModuleOption { - key: "audio_format".into(), - value: "mp3".into(), - }, - ModuleOption { - key: "attribution".into(), - value: "false".into(), - }, // Используем строку "false" для унификации - ], + pub(crate) async fn get_or_create(owner: &Owner) -> Result { + if let Some(found) = + Settings::find_one(doc! { "owner_id": &owner.id, "owner_type": &owner.r#type }).await? + { + Ok(found) + } else { + let new_doc = Settings::new() + .owner_id(owner.id.clone()) + .owner_type(owner.r#type.clone()) + .modules(BTreeMap::new()); + + new_doc.save().await?; + Ok(new_doc) + } } } From eb17f846d665af3087c0ffc788b7c9dcaec0eb46 Mon Sep 17 00:00:00 2001 From: nixxoq Date: Wed, 17 Sep 2025 21:43:29 +0300 Subject: [PATCH 46/66] chore(core/db/schemas): format import --- src/core/db/schemas/group.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/core/db/schemas/group.rs b/src/core/db/schemas/group.rs index b396663..2f3cd4c 100644 --- a/src/core/db/schemas/group.rs +++ b/src/core/db/schemas/group.rs @@ -1,11 +1,14 @@ -use crate::core::db::schemas::{BaseFunctions, CurrenciesFunctions}; -use crate::core::services::currency::converter::CurrencyStruct; +use crate::core::{ + db::schemas::{BaseFunctions, CurrenciesFunctions}, + services::currency::converter::CurrencyStruct, +}; use async_trait::async_trait; -use mongodb::bson; -use mongodb::bson::{doc, oid::ObjectId}; -use mongodb::results::UpdateResult; -use oximod::_error::oximod_error::OxiModError; -use oximod::Model; +use mongodb::{ + bson, + bson::{doc, oid::ObjectId}, + results::UpdateResult, +}; +use oximod::{_error::oximod_error::OxiModError, Model}; use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize, Model)] From f26d5c03cdd1b9e3aa588f3c45d7951573bdfdda Mon Sep 17 00:00:00 2001 From: nixxoq Date: Wed, 17 Sep 2025 21:43:44 +0300 Subject: [PATCH 47/66] feat(errors): handle TranslationError on MyError enumerator --- src/errors.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/errors.rs b/src/errors.rs index 74f86b8..94c67da 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -6,6 +6,7 @@ use redis; use std::string::FromUtf8Error; use teloxide::RequestError; use thiserror::Error; +use translators::Error as TranslatorError; use url::ParseError; #[derive(Error, Debug)] @@ -52,6 +53,9 @@ pub enum MyError { #[error("Base64 decoding error: {0}")] Base64(#[from] base64::DecodeError), + #[error("Translation service error: {0}")] + Translation(#[from] TranslatorError), + #[error("UTF-8 conversion error: {0}")] Utf8(#[from] FromUtf8Error), From 2c48918a47e06ff9d30fd13ae5d28a1376ad75b3 Mon Sep 17 00:00:00 2001 From: nixxoq Date: Wed, 17 Sep 2025 22:19:51 +0300 Subject: [PATCH 48/66] =?UTF-8?q?So,=20I=20spent=20four=20days=20on=20rewo?= =?UTF-8?q?rking=20the=20current=20settings=20in=20a=20new=20way...=20?= =?UTF-8?q?=F0=9F=98=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CHANGES: --- Settings: feat(bot/modules): implement ModuleManager, add a basic trait for implementing custom modules feat(bot/modules): implement Currency and Cobalt modules feat(commands/settings): add a handler for the `/settings` command --- Core: feat(paginator): update the paginator to build a clean paginator (without any items) and build one with existing items; apply changes from clippy feat(dispatcher): organize imports feat(util/enums, bot/commander): remove old currency settings enumerations feat(chat): update the registration process for new groups and when the user has unblocked the bot chore(core/config/json): organize imports --- speech recognition: chore(core/services/speech_recognition): move Speech Recognition / Paraphase functions to the core/services folder chore(core/services/speech_recognition): `transcription_handler` now requires a reference to `teloxide.types.Message` instead of ownership (cloning) it chore(commands/speech_recognition): use let...else construct to unwrap Message type instead of classic if-else + clippy, organizing imports --- whisper: chore(callbacks/whisper): some refactoring + add TODOs chore(inlines/whisper): accept clippy changes --- currency conversion: chore(core/services/currencier): organize imports feat(inlines/currency): check if the currency converter is enabled by the user feat(services/currency): check if the currency converter is enabled for the group chore(util/currency_values): move from core/services/currency to src/util folder feat(messager): check if currency converter module is enabled for user/group before processing --- Cobalt: feat(keyboards/cobalt): move cobalt settings pagination to the src/modules folder; build a photo-like keyboard using Paginator impl (from paginator.rs) chore(callbacks/cobalt_pagination): accept clippy changes + organize imports feat(inlines/cobalter): check if the module is enabled by the user chore(cobalt): move the main cobalt implementations to the core/services folder and other changes, such as moving all (perhaps) keyboards to the new pagination system and a LOT OF CLIPPY changes (changes) *Finished this commit with a song "Korn - Get Up!"* --- src/bot/callbacks/cobalt_pagination.rs | 34 +- src/bot/callbacks/delete.rs | 62 ++- src/bot/callbacks/module.rs | 208 -------- src/bot/callbacks/transcription.rs | 150 ------ src/bot/callbacks/translate.rs | 45 +- src/bot/callbacks/whisper.rs | 20 +- src/bot/commander.rs | 23 +- src/bot/commands/currency_settings.rs | 149 ------ src/bot/commands/mod.rs | 3 +- src/bot/commands/settings.rs | 103 ++++ src/bot/commands/speech_recognition.rs | 24 +- src/bot/commands/start.rs | 79 ++- src/bot/commands/translate.rs | 37 +- src/bot/dispatcher.rs | 29 +- src/bot/inlines/cobalter.rs | 175 ++----- src/bot/inlines/currency.rs | 66 ++- src/bot/inlines/whisper.rs | 46 +- src/bot/keyboards/cobalt.rs | 72 +-- src/bot/keyboards/delete.rs | 2 +- src/bot/keyboards/mod.rs | 4 +- src/bot/keyboards/transcription.rs | 47 +- src/bot/keyboards/translate.rs | 48 +- src/bot/messager.rs | 45 +- src/bot/messages/chat.rs | 81 ++-- src/bot/messages/sound/voice.rs | 4 +- src/bot/messages/sound/voice_note.rs | 4 +- src/bot/messages/sounder.rs | 12 +- src/bot/mod.rs | 7 +- src/bot/modules/cobalt.rs | 181 +++++++ src/bot/modules/currency.rs | 219 +++++++++ src/bot/modules/mod.rs | 45 ++ src/bot/modules/registry.rs | 31 ++ src/core/config/json.rs | 4 +- src/core/config/mod.rs | 11 +- src/core/db/schemas/user.rs | 15 +- src/core/mod.rs | 2 +- src/core/services/cobalt.rs | 102 ++++ src/core/services/currencier.rs | 22 +- src/core/services/currency/converter.rs | 79 ++- src/core/services/currency/mod.rs | 1 - src/core/services/mod.rs | 4 +- src/core/services/speech_recognition.rs | 459 ++++++++++++++++++ src/core/services/transcription.rs | 266 ---------- src/lib.rs | 2 +- .../structs.rs => util/currency_values.rs} | 0 src/util/enums.rs | 6 +- src/util/mod.rs | 12 + src/util/paginator.rs | 158 ++++-- 48 files changed, 1755 insertions(+), 1443 deletions(-) delete mode 100644 src/bot/callbacks/module.rs delete mode 100644 src/bot/callbacks/transcription.rs delete mode 100644 src/bot/commands/currency_settings.rs create mode 100644 src/bot/commands/settings.rs create mode 100644 src/bot/modules/cobalt.rs create mode 100644 src/bot/modules/currency.rs create mode 100644 src/bot/modules/mod.rs create mode 100644 src/bot/modules/registry.rs create mode 100644 src/core/services/cobalt.rs delete mode 100644 src/core/services/transcription.rs rename src/{core/services/currency/structs.rs => util/currency_values.rs} (100%) diff --git a/src/bot/callbacks/cobalt_pagination.rs b/src/bot/callbacks/cobalt_pagination.rs index 5bb97bb..2774702 100644 --- a/src/bot/callbacks/cobalt_pagination.rs +++ b/src/bot/callbacks/cobalt_pagination.rs @@ -1,11 +1,14 @@ -use crate::bot::keyboards::cobalt::make_photo_pagination_keyboard; -use crate::bot::inlines::cobalter::DownloadResult; -use crate::core::config::Config; -use crate::errors::MyError; +use crate::{ + bot::keyboards::cobalt::make_photo_pagination_keyboard, + core::{config::Config, services::cobalt::DownloadResult}, + errors::MyError, +}; use std::sync::Arc; -use teloxide::prelude::*; -use teloxide::types::{CallbackQuery, InputFile, InputMedia, InputMediaPhoto}; -use teloxide::{ApiError, RequestError}; +use teloxide::{ + ApiError, RequestError, + prelude::*, + types::{CallbackQuery, InputFile, InputMedia, InputMediaPhoto}, +}; struct PagingData<'a> { original_user_id: u64, @@ -19,6 +22,7 @@ impl<'a> PagingData<'a> { if parts.len() < 5 { return None; } + Some(Self { original_user_id: parts.get(1)?.parse().ok()?, index: parts.get(2)?.parse().ok()?, @@ -42,7 +46,7 @@ pub async fn handle_cobalt_pagination( } let Some(paging_data) = PagingData::from_parts(&parts) else { - log::warn!("Invalid callback data format: {}", data); + log::error!("Invalid callback data format: {}", data); return Ok(()); }; @@ -101,13 +105,13 @@ pub async fn handle_cobalt_pagination( return Ok(()); }; - if let Err(e) = edit_result { - if !matches!(e, RequestError::Api(ApiError::MessageNotModified)) { - log::error!("Failed to edit message for pagination: {}", e); - bot.answer_callback_query(q.id.clone()) - .text("Не удалось обновить фото.") - .await?; - } + if let Err(e) = edit_result + && !matches!(e, RequestError::Api(ApiError::MessageNotModified)) + { + log::error!("Failed to edit message for pagination: {}", e); + bot.answer_callback_query(q.id.clone()) + .text("Не удалось обновить фото.") + .await?; } bot.answer_callback_query(q.id).await?; diff --git a/src/bot/callbacks/delete.rs b/src/bot/callbacks/delete.rs index 171fba9..63cca39 100644 --- a/src/bot/callbacks/delete.rs +++ b/src/bot/callbacks/delete.rs @@ -1,10 +1,13 @@ -use crate::bot::callbacks::transcription::back_handler; -use crate::bot::keyboards::delete::confirm_delete_keyboard; -use crate::core::config::Config; -use crate::errors::MyError; +use crate::{ + bot::keyboards::delete::confirm_delete_keyboard, + core::{config::Config, services::speech_recognition::back_handler}, + errors::MyError, +}; use log::error; -use teloxide::prelude::*; -use teloxide::types::{ChatId, User}; +use teloxide::{ + prelude::*, + types::{ChatId, User}, +}; async fn has_delete_permission( bot: &Bot, @@ -13,23 +16,24 @@ async fn has_delete_permission( clicker: &User, target_user_id: u64, ) -> bool { - if target_user_id == 72 { + if target_user_id == 72 || clicker.id.0 == target_user_id { return true; } - if clicker.id.0 == target_user_id { - return true; - } - if is_group { - if let Ok(member) = bot.get_chat_member(chat_id, clicker.id).await { - return member.is_privileged(); - } + + if is_group && let Ok(member) = bot.get_chat_member(chat_id, clicker.id).await { + return member.is_privileged(); } + false } pub async fn handle_delete_request(bot: Bot, query: CallbackQuery) -> Result<(), MyError> { - let Some(message) = query.message.as_ref().and_then(|m| m.regular_message()) else { return Ok(()) }; - let Some(data) = query.data.as_ref() else { return Ok(()) }; + let Some(message) = query.message.as_ref().and_then(|m| m.regular_message()) else { + return Ok(()); + }; + let Some(data) = query.data.as_ref() else { + return Ok(()); + }; let target_user_id_str = data.strip_prefix("delete_msg:").unwrap_or_default(); let Ok(target_user_id) = target_user_id_str.parse::() else { @@ -47,7 +51,7 @@ pub async fn handle_delete_request(bot: Bot, query: CallbackQuery) -> Result<(), &query.from, target_user_id, ) - .await; + .await; if !can_delete { bot.answer_callback_query(query.id) @@ -63,8 +67,8 @@ pub async fn handle_delete_request(bot: Bot, query: CallbackQuery) -> Result<(), message.id, "Вы уверены, что хотите удалить?", ) - .reply_markup(confirm_delete_keyboard(target_user_id)) - .await?; + .reply_markup(confirm_delete_keyboard(target_user_id)) + .await?; Ok(()) } @@ -74,13 +78,21 @@ pub async fn handle_delete_confirmation( query: CallbackQuery, config: &Config, ) -> Result<(), MyError> { - let Some(message) = query.message.as_ref().and_then(|m| m.regular_message()) else { return Ok(()) }; - let Some(data) = query.data.as_ref() else { return Ok(()) }; + let Some(message) = query.message.as_ref().and_then(|m| m.regular_message()) else { + return Ok(()); + }; + let Some(data) = query.data.as_ref() else { + return Ok(()); + }; let parts: Vec<&str> = data.split(':').collect(); - if parts.len() != 3 { return Ok(()) }; + if parts.len() != 3 { + return Ok(()); + }; - let Ok(target_user_id) = parts[1].parse::() else { return Ok(()) }; + let Ok(target_user_id) = parts[1].parse::() else { + return Ok(()); + }; let action = parts[2]; let can_delete = has_delete_permission( @@ -90,7 +102,7 @@ pub async fn handle_delete_confirmation( &query.from, target_user_id, ) - .await; + .await; if !can_delete { bot.answer_callback_query(query.id) @@ -116,4 +128,4 @@ pub async fn handle_delete_confirmation( } Ok(()) -} \ No newline at end of file +} diff --git a/src/bot/callbacks/module.rs b/src/bot/callbacks/module.rs deleted file mode 100644 index c4de1a3..0000000 --- a/src/bot/callbacks/module.rs +++ /dev/null @@ -1,208 +0,0 @@ -use crate::bot::commands::currency_settings::update_settings_message; -use crate::bot::keyboards::cobalt::make_option_selection_keyboard; -use crate::core::db::schemas::SettingsRepo; -use crate::core::db::schemas::settings::Settings; -use crate::errors::MyError; -use teloxide::Bot; -use teloxide::payloads::EditMessageTextSetters; -use teloxide::prelude::{CallbackQuery, Requester}; -use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup}; - -pub async fn module_select_handler(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { - let data = match q.data.as_ref() { - Some(d) => d, - None => return Ok(()), - }; - let message = match q.message.as_ref() { - Some(m) => m, - None => return Ok(()), - }; - - let parts: Vec<_> = data.split(':').collect(); - if parts.len() < 4 { - log::error!("Invalid callback data format: {}", data); - return Ok(()); - } - let owner_type = parts[1]; - let owner_id = parts[2]; - let module_key = parts[3]; - - let mut settings = Settings::get_or_create(owner_id, owner_type).await?; - let module = settings - .modules_mut() - .iter_mut() - .find(|m| m.key == module_key) - .unwrap(); - - let toggle_label = if module.enabled { - "Выключить" - } else { - "Включить" - }; - let toggle_cb = format!("module_toggle:{owner_type}:{owner_id}:{module_key}"); - - let mut keyboard_rows: Vec> = vec![]; - - keyboard_rows.push(vec![InlineKeyboardButton::callback( - toggle_label, - toggle_cb, - )]); - - for opt in module.options.iter() { - let label = format!("{}: {}", opt.key, opt.value); - let cb = format!( - "module_opt:{owner_type}:{owner_id}:{module_key}:{}", - opt.key - ); - keyboard_rows.push(vec![InlineKeyboardButton::callback(label, cb)]); - } - - let back_button_cb = format!("settings_back:{owner_type}:{owner_id}"); - keyboard_rows.push(vec![InlineKeyboardButton::callback( - "⬅️ Назад", - back_button_cb, - )]); - - let keyboard = InlineKeyboardMarkup::new(keyboard_rows); - - bot.edit_message_text( - message.chat().id, - message.id(), - format!("⚙️ Настройки модуля: {}", module.description), - ) - .reply_markup(keyboard) - .await?; - - Ok(()) -} - -pub async fn module_toggle_handler(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { - let parts: Vec<_> = q.data.as_ref().unwrap().split(':').collect(); - let owner_type = parts[1]; - let owner_id = parts[2]; - let module_key = parts[3]; - - Settings::update_module(owner_id, owner_type, module_key, |module| { - module.enabled = !module.enabled; - }) - .await?; - - update_settings_message( - bot, - q.message.unwrap().clone(), - owner_id.to_string(), - owner_type.to_string(), - ) - .await -} - -// pub async fn module_option_handler(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { -// let parts: Vec<_> = q.data.as_ref().unwrap().split(':').collect(); -// let owner_type = parts[1]; -// let owner_id = parts[2]; -// let module_key = parts[3]; -// let option_key = parts[4]; -// -// let settings = Settings::get_or_create(owner_id, owner_type).await?; -// let module = settings -// .modules -// .iter() -// .find(|m| m.key == module_key) -// .unwrap(); -// let opt = module.options.iter().find(|o| o.key == option_key).unwrap(); -// -// bot.answer_callback_query(q.id.clone()) -// .text(format!("Текущая опция «{}»: {}", opt.key, opt.value)) -// .show_alert(true) -// .await?; -// Ok(()) -// } -pub async fn module_option_handler(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { - let message = q - .message - .as_ref() - .ok_or_else(|| MyError::Other("No message in callback".into()))?; - let data = q - .data - .as_ref() - .ok_or_else(|| MyError::Other("No data in callback".into()))?; - - let parts: Vec<_> = data.split(':').collect(); - let owner_type = parts[1]; - let owner_id = parts[2]; - let module_key = parts[3]; - let option_key = parts[4]; - - let settings = Settings::get_or_create(owner_id, owner_type).await?; - let module = settings - .modules - .iter() - .find(|m| m.key == module_key) - .unwrap(); - let opt = module.options.iter().find(|o| o.key == option_key).unwrap(); - - let keyboard = make_option_selection_keyboard(owner_type, owner_id, module_key, opt); - - let option_name = option_key.replace('_', " "); - bot.edit_message_text( - message.chat().id, - message.id(), - format!("Select a value for '{}':", option_name), - ) - .reply_markup(keyboard) - .await?; - - Ok(()) -} - -pub async fn settings_back_handler(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { - let parts: Vec<_> = q.data.as_ref().unwrap().split(':').collect(); - let owner_type = parts[1]; - let owner_id = parts[2]; - - update_settings_message( - bot, - q.message.unwrap().clone(), - owner_id.to_string(), - owner_type.to_string(), - ) - .await -} - -pub async fn settings_set_handler(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { - q.message - .as_ref() - .ok_or_else(|| MyError::Other("No message in callback".into()))?; - - let data = q - .data - .clone() - .ok_or_else(|| MyError::Other("No data in callback".into()))?; - - let parts: Vec<_> = data.split(':').collect(); - if parts.len() < 6 { - return Err(MyError::Other( - "Invalid callback data for settings_set".into(), - )); - } - let owner_type = parts[1]; - let owner_id = parts[2]; - let module_key = parts[3]; - let option_key = parts[4]; - let new_value = parts[5].to_string(); - - Settings::update_module(owner_id, owner_type, module_key, |module| { - if let Some(option) = module.options.iter_mut().find(|o| o.key == option_key) { - option.value = new_value; - } - }) - .await?; - - let mut updated_q = q; - updated_q.data = Some(format!( - "module_select:{}:{}:{}", - owner_type, owner_id, module_key - )); - - module_select_handler(bot, updated_q).await -} diff --git a/src/bot/callbacks/transcription.rs b/src/bot/callbacks/transcription.rs deleted file mode 100644 index 4c7e020..0000000 --- a/src/bot/callbacks/transcription.rs +++ /dev/null @@ -1,150 +0,0 @@ -use crate::bot::keyboards::transcription::{ - create_summary_keyboard, create_transcription_keyboard, TRANSCRIPTION_MODULE_KEY, -}; -use crate::core::config::Config; -use crate::core::services::transcription::{ - save_file_to_memory, split_text, summarize_audio, TranscriptionCache, -}; -use crate::errors::MyError; -use teloxide::prelude::*; -use teloxide::types::ParseMode; - -pub async fn pagination_handler( - bot: Bot, - query: CallbackQuery, - config: &Config, -) -> Result<(), MyError> { - let Some(message) = query.message.as_ref().and_then(|m| m.regular_message()) else { - return Ok(()); - }; - let Some(data) = query.data.as_ref() else { return Ok(()) }; - - let parts: Vec<&str> = data.split(':').collect(); - if !(parts.len() == 3 && parts[0] == TRANSCRIPTION_MODULE_KEY && parts[1] == "page") { - return Ok(()); - } - - let Ok(page) = parts[2].parse::() else { return Ok(()) }; - - let cache = config.get_redis_client(); - let message_cache_key = format!("message_file_map:{}", message.id); - let Some(file_unique_id) = cache.get::(&message_cache_key).await? else { - bot.edit_message_text(message.chat.id, message.id, "❌ Кнопка устарела.").await?; - return Ok(()); - }; - - let file_cache_key = format!("transcription_by_file:{}", file_unique_id); - let Some(cache_entry) = cache.get::(&file_cache_key).await? else { - bot.edit_message_text(message.chat.id, message.id, "❌ Не удалось найти текст в кеше.").await?; - return Ok(()); - }; - - let text_parts = split_text(&cache_entry.full_text, 4000); - if page >= text_parts.len() { - return Ok(()); - } - - let new_text = format!("
{}
", text_parts[page]); - let new_keyboard = create_transcription_keyboard(page, text_parts.len(), query.from.id.0); - - if message.text() != Some(new_text.as_str()) || message.reply_markup() != Some(&new_keyboard) { - bot.edit_message_text(message.chat.id, message.id, new_text) - .parse_mode(ParseMode::Html) - .reply_markup(new_keyboard) - .await?; - } - - Ok(()) -} - -pub async fn back_handler(bot: Bot, query: CallbackQuery, config: &Config) -> Result<(), MyError> { - let Some(message) = query.message.and_then(|m| m.regular_message().cloned()) else { return Ok(()) }; - - let cache = config.get_redis_client(); - let message_cache_key = format!("message_file_map:{}", message.id); - let Some(file_unique_id) = cache.get::(&message_cache_key).await? else { - bot.edit_message_text(message.chat.id, message.id, "❌ Не удалось найти исходное сообщение.").await?; - return Ok(()); - }; - - let file_cache_key = format!("transcription_by_file:{}", file_unique_id); - let Some(cache_entry) = cache.get::(&file_cache_key).await? else { - bot.edit_message_text(message.chat.id, message.id, "❌ Не удалось найти текст в кеше.").await?; - return Ok(()); - }; - - let text_parts = split_text(&cache_entry.full_text, 4000); - let keyboard = create_transcription_keyboard(0, text_parts.len(), query.from.id.0); - - bot.edit_message_text( - message.chat.id, - message.id, - format!("
{}
", text_parts[0]), - ) - .parse_mode(ParseMode::Html) - .reply_markup(keyboard) - .await?; - - Ok(()) -} - -pub async fn summarization_handler( - bot: Bot, - query: CallbackQuery, - config: &Config, -) -> Result<(), MyError> { - let Some(message) = query.message.and_then(|m| m.regular_message().cloned()) else { return Ok(()) }; - - let cache = config.get_redis_client(); - let message_file_map_key = format!("message_file_map:{}", message.id); - let Some(file_unique_id) = cache.get::(&message_file_map_key).await? else { - bot.edit_message_text(message.chat.id, message.id, "❌ Кнопка устарела.").await?; - return Ok(()); - }; - - let file_cache_key = format!("transcription_by_file:{}", file_unique_id); - let mut cache_entry = match cache.get::(&file_cache_key).await? { - Some(entry) => entry, - None => { - bot.edit_message_text(message.chat.id, message.id, "❌ Не удалось найти исходное аудио.").await?; - return Ok(()); - } - }; - - if let Some(cached_summary) = cache_entry.summary { - let final_text = format!( - "Краткое содержание:\n
{}
", - cached_summary - ); - bot.edit_message_text(message.chat.id, message.id, final_text) - .parse_mode(ParseMode::Html) - .reply_markup(create_summary_keyboard()) - .await?; - return Ok(()); - } - - bot.edit_message_text(message.chat.id, message.id, "Составляю краткое содержание...").await?; - - let file_data = save_file_to_memory(&bot, &cache_entry.file_id).await?; - let new_summary = - summarize_audio(cache_entry.mime_type.clone(), file_data, config.clone()).await?; - - if new_summary.is_empty() || new_summary.contains("Не удалось получить") { - bot.edit_message_text(message.chat.id, message.id, "❌ Не удалось составить краткое содержание.").await?; - return Ok(()); - } - - cache_entry.summary = Some(new_summary.clone()); - cache.set(&file_cache_key, &cache_entry, 86400).await?; - - let final_text = format!( - "Краткое содержание:\n
{}
", - new_summary - ); - bot.edit_message_text(message.chat.id, message.id, final_text) - .parse_mode(ParseMode::Html) - .reply_markup(create_summary_keyboard()) - .await?; - - Ok(()) -} \ No newline at end of file diff --git a/src/bot/callbacks/translate.rs b/src/bot/callbacks/translate.rs index 12db18f..af018be 100644 --- a/src/bot/callbacks/translate.rs +++ b/src/bot/callbacks/translate.rs @@ -1,16 +1,18 @@ -use crate::bot::keyboards::translate::create_language_keyboard; -use crate::core::config::Config; -use crate::core::services::translation::{SUPPORTED_LANGUAGES, normalize_language_code}; -use crate::errors::MyError; -use crate::bot::keyboards::delete::delete_message_button; -use teloxide::Bot; -use teloxide::payloads::{EditMessageReplyMarkupSetters, EditMessageTextSetters}; -use teloxide::prelude::Requester; -use teloxide::types::{ - CallbackQuery, InlineKeyboardButton, MaybeInaccessibleMessage, Message, ParseMode, +use crate::{ + bot::keyboards::{delete::delete_message_button, translate::create_language_keyboard}, + core::{ + config::Config, + services::translation::{SUPPORTED_LANGUAGES, normalize_language_code}, + }, + errors::MyError, +}; +use teloxide::{ + ApiError, Bot, RequestError, + payloads::{EditMessageReplyMarkupSetters, EditMessageTextSetters}, + prelude::Requester, + types::{CallbackQuery, InlineKeyboardButton, MaybeInaccessibleMessage, Message, ParseMode}, + utils::html::escape, }; -use teloxide::utils::html::escape; -use teloxide::{ApiError, RequestError}; use translators::{GoogleTranslator, Translator}; pub async fn handle_translate_callback( @@ -102,10 +104,12 @@ async fn handle_language_selection( let normalized_lang = normalize_language_code(target_lang); let google_trans = GoogleTranslator::default(); + + // FIXME: Google Translator limit is up to ~3100 characters. We must think workaround for avoiding "panic" error + // log::info!("text: {}", text_to_translate); let res = google_trans - .translate_async(text_to_translate, "", &*normalized_lang) - .await - .unwrap(); + .translate_async(text_to_translate, "", &normalized_lang) + .await?; let response = format!("
{}\n
", escape(&res)); @@ -119,10 +123,13 @@ async fn handle_language_selection( InlineKeyboardButton::callback(lang_display_name.to_string(), "tr_show_langs".to_string()); let mut keyboard = delete_message_button(user.id.0); - if let Some(first_row) = keyboard.inline_keyboard.get_mut(0) { - first_row.insert(0, switch_lang_button); - } else { - keyboard.inline_keyboard.push(vec![switch_lang_button]); + match keyboard.inline_keyboard.get_mut(0) { + Some(first_row) => { + first_row.insert(0, switch_lang_button); + } + None => { + keyboard.inline_keyboard.push(vec![switch_lang_button]); + } } if let Err(e) = bot diff --git a/src/bot/callbacks/whisper.rs b/src/bot/callbacks/whisper.rs index 263b82e..5f37705 100644 --- a/src/bot/callbacks/whisper.rs +++ b/src/bot/callbacks/whisper.rs @@ -1,11 +1,12 @@ -use crate::bot::inlines::whisper::Whisper; -use crate::core::config::Config; -use crate::errors::MyError; -use teloxide::Bot; -use teloxide::payloads::{AnswerCallbackQuerySetters, EditMessageTextSetters}; -use teloxide::prelude::{CallbackQuery, Requester}; -use teloxide::types::InlineKeyboardMarkup; +use crate::{bot::inlines::whisper::Whisper, core::config::Config, errors::MyError}; +use teloxide::{ + Bot, + payloads::{AnswerCallbackQuerySetters, EditMessageTextSetters}, + prelude::{CallbackQuery, Requester}, + types::InlineKeyboardMarkup, +}; +// TODO: refactor entire handler pub async fn handle_whisper_callback( bot: Bot, q: CallbackQuery, @@ -17,11 +18,11 @@ pub async fn handle_whisper_callback( if parts.len() != 3 || parts[0] != "whisper" { return Ok(()); } + let action = parts[1]; let whisper_id = parts[2]; let user = q.from.clone(); - let _username = user.username.clone().unwrap_or_default(); let redis_key = format!("whisper:{}", whisper_id); @@ -64,9 +65,8 @@ pub async fn handle_whisper_callback( match action { "read" => { - let alert_text = format!("{}", whisper.content); bot.answer_callback_query(q.id) - .text(alert_text) + .text(whisper.content.to_string()) .show_alert(true) .await?; } diff --git a/src/bot/commander.rs b/src/bot/commander.rs index d39ca15..9f3acf8 100644 --- a/src/bot/commander.rs +++ b/src/bot/commander.rs @@ -1,14 +1,13 @@ -use crate::bot::commands::currency_settings::{ - currency_codes_handler, currency_codes_list_handler, settings_command_handler, +use crate::{ + bot::commands::{ + settings::settings_command_handler, speech_recognition::speech_recognition_handler, + start::start_handler, translate::translate_handler, + }, + core::config::Config, + errors::MyError, + util::enums::Command, }; -use crate::bot::commands::speech_recognition::speech_recognition_handler; -use crate::bot::commands::start::start_handler; -use crate::bot::commands::translate::translate_handler; -use crate::core::config::Config; -use crate::errors::MyError; -use crate::util::enums::Command; -use teloxide::Bot; -use teloxide::prelude::Message; +use teloxide::{Bot, prelude::Message}; use tokio::task; pub async fn command_handlers(bot: Bot, message: Message, cmd: Command) -> Result<(), MyError> { @@ -18,9 +17,7 @@ pub async fn command_handlers(bot: Bot, message: Message, cmd: Command) -> Resul Command::Start(arg) => start_handler(bot, message, &config, arg).await, Command::Translate(arg) => translate_handler(bot, &message, &config, arg).await, Command::SpeechRecognition => speech_recognition_handler(bot, message, &config).await, - Command::SetCurrency { code } => currency_codes_handler(bot, message, code).await, - Command::ListCurrency => currency_codes_list_handler(bot, message).await, - Command::Settings => settings_command_handler(bot, message, &config).await, + Command::Settings => settings_command_handler(bot, message).await, } }); Ok(()) diff --git a/src/bot/commands/currency_settings.rs b/src/bot/commands/currency_settings.rs deleted file mode 100644 index 59eea5a..0000000 --- a/src/bot/commands/currency_settings.rs +++ /dev/null @@ -1,149 +0,0 @@ -use crate::core::config::Config; -use crate::core::db::schemas::SettingsRepo; -use crate::core::db::schemas::group::Group; -use crate::core::db::schemas::settings::Settings; -use crate::core::db::schemas::user::User; -use crate::errors::MyError; -use crate::core::services::currency::converter::{CURRENCY_CONFIG_PATH, get_all_currency_codes}; -use teloxide::prelude::*; -use teloxide::types::{ - InlineKeyboardButton, InlineKeyboardMarkup, MaybeInaccessibleMessage, ParseMode, - ReplyParameters, -}; -use crate::core::services::currencier::{get_enabled_codes, handle_currency_update}; - -pub async fn currency_codes_handler(bot: Bot, msg: Message, code: String) -> Result<(), MyError> { - if msg.chat.is_private() { - handle_currency_update::(bot, msg, code).await - } else { - handle_currency_update::(bot, msg, code).await - } -} - -/// Deprecated, but still working currency settings -/// -/// TODO: move currency settings to /settings in near future -pub async fn currency_codes_list_handler(bot: Bot, msg: Message) -> Result<(), MyError> { - let mut message = String::from("Could not load available currencies."); - - if let Ok(codes) = get_all_currency_codes(CURRENCY_CONFIG_PATH.parse().unwrap()) { - let enabled_codes = get_enabled_codes(&msg).await; - - let codes_list = codes - .iter() - .map(|currency| { - let icon = if enabled_codes.contains(¤cy.code) { - "✅" - } else { - "❌" - }; - format!("{} {} - {}", currency.flag, currency.code, icon) - }) - .collect::>() - .join("\n"); - - let result = bot - .get_my_commands() - .await - .unwrap() - .iter() - .find_map(|command| (&command.command == "setcurrency").then(|| command.clone())); - - if let Some(command) = result { - message = format!( - // FIXME: better hardcoding <3 - "Available currencies to set up:
{}
\n\nUsage: /{} CURRENCY_CODE (e.g., /{} UAH) to enable/disable it.\n\nNotes:\n✅ - enabled\n❌ - disabled", - codes_list, &command.command, &command.command - ); - } else { - message = format!( - "Available currencies to set up:
{}
\n\nNotes:\n✅ - enabled\n❌ - disabled", - codes_list - ); - } - } - - bot.send_message(msg.chat.id, message) - .parse_mode(ParseMode::Html) - .reply_parameters(ReplyParameters::new(msg.id)) - .await?; - - Ok(()) -} - -/// -/// new settings -/// -/// TODO: Use traits (interfaces) as a basis for other modules to iterate and show them in the settings menu -/// (or this system, maybe, waiting for entire rewrite) -pub async fn settings_command_handler( - bot: Bot, - message: Message, - _config: &Config, -) -> Result<(), MyError> { - let owner_id: String = if let Some(user) = message.from { - user.id.to_string() - } else { - message.chat.id.to_string() - }; - - let owner_type = if message.chat.is_private() { - "user" - } else { - "group" - }; - - let settings = Settings::get_or_create(&owner_id, owner_type).await?; - - let keyboard = InlineKeyboardMarkup::new( - settings - .modules - .iter() - .map(|m| { - let status = if m.enabled { "✅" } else { "❌" }; - let text = format!("{status} {}", m.description); - let callback_data = format!("module_select:{owner_type}:{owner_id}:{}", m.key); - vec![InlineKeyboardButton::callback(text, callback_data)] - }) - .collect::>(), - ); - - bot.send_message(message.chat.id, "Настройки модулей:") - .reply_markup(keyboard) - .await?; - - Ok(()) -} - -pub async fn update_settings_message( - bot: Bot, - message: MaybeInaccessibleMessage, - owner_id: String, - owner_type: String, -) -> Result<(), MyError> { - let settings = Settings::get_or_create(&owner_id, &owner_type).await?; - - let keyboard = InlineKeyboardMarkup::new( - settings - .modules - .iter() - .map(|m| { - let status = if m.enabled { "✅" } else { "❌" }; - let text = format!("{status} {}", m.description); - let callback_data = format!("module_select:{owner_type}:{owner_id}:{}", m.key); - vec![InlineKeyboardButton::callback(text, callback_data)] - }) - .collect::>(), - ); - - let text = "Настройки модулей:"; - - if let MaybeInaccessibleMessage::Regular(msg) = message { - let _ = bot - .edit_message_text(msg.chat.id, msg.id, text) - .reply_markup(keyboard) - .await; - } - - Ok(()) -} diff --git a/src/bot/commands/mod.rs b/src/bot/commands/mod.rs index 7b2a1cf..9aa0656 100644 --- a/src/bot/commands/mod.rs +++ b/src/bot/commands/mod.rs @@ -1,5 +1,4 @@ -// pub mod commands; -pub mod currency_settings; +pub mod settings; pub mod speech_recognition; pub mod start; pub mod translate; diff --git a/src/bot/commands/settings.rs b/src/bot/commands/settings.rs new file mode 100644 index 0000000..2eef715 --- /dev/null +++ b/src/bot/commands/settings.rs @@ -0,0 +1,103 @@ +use crate::bot::modules::Owner; +use crate::bot::modules::registry::MOD_MANAGER; +use crate::core::db::schemas::settings::Settings; +use crate::errors::MyError; +use teloxide::prelude::*; +use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup, MaybeInaccessibleMessage}; + +pub async fn settings_command_handler(bot: Bot, message: Message) -> Result<(), MyError> { + let owner_id = message.chat.id.to_string(); + let owner_type = if message.chat.is_private() { + "user" + } else { + "group" + } + .to_string(); + + let settings_doc = Settings::get_or_create(&Owner { + id: owner_id.clone(), + r#type: owner_type.clone(), + }) + .await?; + + let kb_buttons: Vec> = MOD_MANAGER + .get_all_modules() + .into_iter() + .map(|module| { + let settings: serde_json::Value = settings_doc + .modules + .get(module.key()) + .cloned() + .unwrap_or_default(); + + let is_enabled = settings + .get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let status = if is_enabled { "✅" } else { "❌" }; + let text = format!("{} {}", status, module.description()); + let callback_data = + format!("module_select:{}:{}:{}", owner_type, owner_id, module.key()); + + vec![InlineKeyboardButton::callback(text, callback_data)] + }) + .collect(); + + let keyboard = InlineKeyboardMarkup::new(kb_buttons); + + bot.send_message(message.chat.id, "Настройки модулей:") + .reply_markup(keyboard) + .await?; + + Ok(()) +} + +pub async fn update_settings_message( + bot: Bot, + message: MaybeInaccessibleMessage, + owner_id: String, + owner_type: String, +) -> Result<(), MyError> { + let settings_doc = Settings::get_or_create(&Owner { + id: owner_id.clone(), + r#type: owner_type.clone(), + }) + .await?; + + let kb_buttons: Vec> = MOD_MANAGER + .get_all_modules() + .into_iter() + .map(|module| { + let settings: serde_json::Value = settings_doc + .modules + .get(module.key()) + .cloned() + .unwrap_or_default(); + let is_enabled = settings + .get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let status = if is_enabled { "✅" } else { "❌" }; + let text = format!("{} {}", status, module.description()); + let callback_data = + format!("module_select:{}:{}:{}", owner_type, owner_id, module.key()); + + vec![InlineKeyboardButton::callback(text, callback_data)] + }) + .collect(); + + let keyboard = InlineKeyboardMarkup::new(kb_buttons); + + let text = "Настройки модулей:"; + + if let MaybeInaccessibleMessage::Regular(msg) = message { + let _ = bot + .edit_message_text(msg.chat.id, msg.id, text) + .reply_markup(keyboard) + .await; + } + + Ok(()) +} diff --git a/src/bot/commands/speech_recognition.rs b/src/bot/commands/speech_recognition.rs index 508f3a1..6831e75 100644 --- a/src/bot/commands/speech_recognition.rs +++ b/src/bot/commands/speech_recognition.rs @@ -1,21 +1,23 @@ -use teloxide::dispatching::dialogue::GetChatId; -use crate::core::config::Config; -use crate::errors::MyError; -use crate::core::services::transcription::transcription_handler; -use teloxide::prelude::*; -use teloxide::types::ReplyParameters; +use crate::{ + core::{config::Config, services::speech_recognition::transcription_handler}, + errors::MyError, +}; +use teloxide::{prelude::*, types::ReplyParameters}; pub async fn speech_recognition_handler( bot: Bot, msg: Message, config: &Config, ) -> Result<(), MyError> { - if msg.reply_to_message().is_some() { - transcription_handler(bot, msg.reply_to_message().unwrap().clone(), config).await?; - } else { - bot.send_message(msg.chat_id().unwrap(), "Ответьте на голосовое сообщение.") + let Some(message) = msg.reply_to_message() else { + bot.send_message(msg.chat.id, "Ответьте на голосовое сообщение.") .reply_parameters(ReplyParameters::new(msg.id)) .await?; - } + + return Ok(()); + }; + + transcription_handler(bot, message, config).await?; + Ok(()) } diff --git a/src/bot/commands/start.rs b/src/bot/commands/start.rs index 057f8dd..05acc49 100644 --- a/src/bot/commands/start.rs +++ b/src/bot/commands/start.rs @@ -1,14 +1,19 @@ -use crate::core::config::Config; -use crate::core::db::schemas::user::User; -use crate::errors::MyError; -use crate::core::services::currency::converter::get_default_currencies; -use log::error; +use crate::{ + bot::modules::Owner, + core::{ + config::Config, + db::schemas::{settings::Settings, user::User}, + }, + errors::MyError, +}; use mongodb::bson::doc; use oximod::Model; use std::time::Instant; use sysinfo::System; -use teloxide::prelude::*; -use teloxide::types::{ParseMode, ReplyParameters}; +use teloxide::{ + prelude::*, + types::{ParseMode, ReplyParameters}, +}; pub async fn start_handler( bot: Bot, @@ -19,47 +24,29 @@ pub async fn start_handler( if message.chat.is_private() { let user = message.from.clone().unwrap(); - match User::find_one(doc! { "user_id": &user.id.to_string() }).await { - Ok(Some(_)) => {} - Ok(None) => { - let necessary_codes = get_default_currencies()?; - - return match User::new() - .user_id(user.id.to_string().clone()) - .convertable_currencies(necessary_codes) - .save() - .await - { - Ok(_) => { - bot.send_message( - message.chat.id, - "Welcome! You have been successfully registered", - ) - .await?; - Ok(()) - } - Err(e) => { - error!("Failed to save new user {} to DB: {}", &user.id, e); - bot.send_message( - message.chat.id, - "Something went wrong during registration. Please try again later.", - ) - .await?; - Ok(()) - } - }; - } - Err(e) => { - error!("Database error while checking user {}: {}", &user.id, e); - bot.send_message( - message.chat.id, - "A database error occurred. Please try again later.", - ) + if User::find_one(doc! { "user_id": &user.id.to_string() }) + .await? + .is_none() + { + User::new() + .user_id(user.id.to_string().clone()) + .save() .await?; - return Ok(()); - } - }; + + let owner = Owner { + id: user.id.to_string(), + r#type: "user".to_string(), + }; + Settings::create_with_defaults(&owner).await?; + + bot.send_message( + message.chat.id, + "Welcome! You have been successfully registered", + ) + .await?; + } } + let version = config.get_version(); let start_time = Instant::now(); diff --git a/src/bot/commands/translate.rs b/src/bot/commands/translate.rs index 8c9c871..84b78b1 100644 --- a/src/bot/commands/translate.rs +++ b/src/bot/commands/translate.rs @@ -1,11 +1,16 @@ -use crate::bot::keyboards::translate::create_language_keyboard; -use crate::core::config::Config; -use crate::core::services::translation::{SUPPORTED_LANGUAGES, normalize_language_code}; -use crate::errors::MyError; -use crate::bot::keyboards::delete::delete_message_button; -use teloxide::prelude::*; -use teloxide::types::{InlineKeyboardButton, Message, ParseMode, ReplyParameters}; -use teloxide::utils::html::escape; +use crate::{ + bot::keyboards::{delete::delete_message_button, translate::create_language_keyboard}, + core::{ + config::Config, + services::translation::{SUPPORTED_LANGUAGES, normalize_language_code}, + }, + errors::MyError, +}; +use teloxide::{ + prelude::*, + types::{InlineKeyboardButton, Message, ParseMode, ReplyParameters}, + utils::html::escape, +}; use translators::{GoogleTranslator, Translator}; pub async fn translate_handler( @@ -51,7 +56,7 @@ pub async fn translate_handler( let target_lang: String; if !arg.trim().is_empty() { - target_lang = normalize_language_code(&arg.trim()); + target_lang = normalize_language_code(arg.trim()); } else { let redis_key = format!("user_lang:{}", user.id); let redis_client = config.get_redis_client(); @@ -69,11 +74,17 @@ pub async fn translate_handler( } } - let google_trans = GoogleTranslator::default(); + let google_trans = GoogleTranslator::builder() + .text_limit(12000usize) + .delay(3usize) + .timeout(50usize) + .build(); + + // FIXME: Google Translator limit is up to ~3100 characters. We must think workaround for avoiding "panic" error + // log::info!("text: {}", text_to_translate); let res = google_trans - .translate_async(text_to_translate, "", &*target_lang) - .await - .unwrap(); + .translate_async(text_to_translate, "", &target_lang) + .await?; let response = format!("
{}\n
", escape(&res)); diff --git a/src/bot/dispatcher.rs b/src/bot/dispatcher.rs index 89dcc13..f423345 100644 --- a/src/bot/dispatcher.rs +++ b/src/bot/dispatcher.rs @@ -1,14 +1,20 @@ -use crate::bot::callbacks::callback_query_handlers; -use crate::bot::commander::command_handlers; -use crate::bot::messages::chat::handle_bot_added; -use crate::bot::messager::{handle_currency, handle_speech}; -use crate::bot::inlines::cobalter::{handle_cobalt_inline, is_query_url}; -use crate::bot::inlines::currency::handle_currency_inline; -use crate::bot::inlines::whisper::{handle_whisper_inline, is_whisper_query}; -use crate::core::config::Config; -use crate::errors::MyError; -use crate::util::enums::Command; -use crate::bot::keyboards::delete::delete_message_button; +use crate::{ + bot::{ + callbacks::callback_query_handlers, + commander::command_handlers, + inlines::{ + cobalter::{handle_cobalt_inline, is_query_url}, + currency::{handle_currency_inline, is_currency_query}, + whisper::{handle_whisper_inline, is_whisper_query}, + }, + keyboards::delete::delete_message_button, + messager::{handle_currency, handle_speech}, + messages::chat::handle_bot_added, + }, + core::config::Config, + errors::MyError, + util::enums::Command, +}; use log::{error, info}; use oximod::set_global_client; use std::{convert::Infallible, fmt::Write, ops::ControlFlow, sync::Arc}; @@ -25,7 +31,6 @@ use teloxide::{ update_listeners::Polling, utils::{command::BotCommands, html}, }; -use crate::core::services::currency::converter::is_currency_query; async fn root_handler( update: Update, diff --git a/src/bot/inlines/cobalter.rs b/src/bot/inlines/cobalter.rs index 816d93b..d0bdf28 100644 --- a/src/bot/inlines/cobalter.rs +++ b/src/bot/inlines/cobalter.rs @@ -1,106 +1,46 @@ -use crate::bot::keyboards::cobalt::{make_photo_pagination_keyboard, make_single_url_keyboard}; -use crate::core::config::Config; -use crate::core::db::schemas::SettingsRepo; -use crate::core::db::schemas::settings::Settings; -use crate::errors::MyError; -use ccobalt::model::request::{DownloadRequest, FilenameStyle, VideoQuality}; -use ccobalt::model::response::DownloadResponse; +use crate::{ + bot::{ + keyboards::cobalt::{make_photo_pagination_keyboard, make_single_url_keyboard}, + modules::{Owner, cobalt::CobaltSettings}, + }, + core::{ + config::Config, + db::schemas::settings::Settings, + services::cobalt::{DownloadResult, resolve_download_url}, + }, + errors::MyError, +}; use once_cell::sync::Lazy; use regex::Regex; -use serde::{Deserialize, Serialize}; use std::sync::Arc; -use teloxide::Bot; -use teloxide::prelude::*; -use teloxide::types::{ - InlineQuery, InlineQueryResult, InlineQueryResultArticle, InlineQueryResultPhoto, - InlineQueryResultVideo, InputMessageContent, InputMessageContentText, +use teloxide::{ + Bot, + prelude::*, + types::{ + InlineQuery, InlineQueryResult, InlineQueryResultArticle, InlineQueryResultPhoto, + InlineQueryResultVideo, InputMessageContent, InputMessageContentText, + }, }; -#[derive(Serialize, Deserialize, Debug, Clone)] -pub enum DownloadResult { - Video(String), - Photos { - urls: Vec, - original_url: String, - }, -} +static URL_REGEX: Lazy = + Lazy::new(|| Regex::new(r"^(https?)://[^\s/$.?#].[^\s]*$").unwrap()); -pub async fn resolve_download_url( - url: &str, - settings: &Settings, - client: &ccobalt::Client, -) -> Result, MyError> { - let get_opt = |key: &str| -> String { - settings - .modules - .iter() - .find(|m| m.key == "cobalt") - .and_then(|m| m.options.iter().find(|o| o.key == key)) - .map(|o| o.value.clone()) - .unwrap_or_default() +pub async fn is_query_url(inline_query: InlineQuery) -> bool { + if !URL_REGEX.is_match(&inline_query.query) { + return false; }; - let cobalt_req = DownloadRequest { - url: url.to_string(), - filename_style: Some(FilenameStyle::Pretty), - video_quality: Some(match get_opt("video_quality").as_str() { - "720" => VideoQuality::Q720, - "1080" => VideoQuality::Q1080, - "1440" => VideoQuality::Q1440, - "max" => VideoQuality::Max, - _ => VideoQuality::Q720, - }), - ..Default::default() + + let owner = Owner { + id: inline_query.from.id.to_string(), + r#type: "user".to_string(), }; - let response = client.resolve_download(&cobalt_req).await?; - match response { - DownloadResponse::Error { error } => { - log::error!("Cobalt API error: {:?}", error); - Err(error.into()) - } - DownloadResponse::Picker { picker, .. } => { - let photo_urls: Vec = picker - .iter() - .filter(|item| item.kind == "photo") - .map(|item| item.url.clone()) - .collect(); - if !photo_urls.is_empty() { - return Ok(Some(DownloadResult::Photos { - urls: photo_urls, - original_url: url.to_string(), - })); - } - if let Some(video_item) = picker.iter().find(|item| item.kind == "video") { - return Ok(Some(DownloadResult::Video(video_item.url.clone()))); - } - Ok(None) - } - DownloadResponse::Tunnel { url, filename } - | DownloadResponse::Redirect { url, filename } => { - const PHOTO_EXTENSIONS: &[&str] = &[".jpg", ".jpeg", ".png", ".gif", ".webp"]; - let is_photo = PHOTO_EXTENSIONS - .iter() - .any(|ext| filename.to_lowercase().ends_with(ext)); - if is_photo { - Ok(Some(DownloadResult::Photos { - urls: vec![url.clone()], - original_url: url, - })) - } else { - Ok(Some(DownloadResult::Video(url))) - } - } - _ => Ok(response.get_download_url().map(DownloadResult::Video)), + match Settings::get_module_settings::(&owner, "cobalt").await { + Ok(settings) => settings.enabled, + Err(_) => false, } } -static URL_REGEX: Lazy = - Lazy::new(|| Regex::new(r"^(https?)://[^\s/$.?#].[^\s]*$").unwrap()); - -pub async fn is_query_url(inline_query: InlineQuery) -> bool { - URL_REGEX.is_match(&inline_query.query) -} - fn build_results_from_media( original_url: &str, media: DownloadResult, @@ -174,12 +114,19 @@ pub async fn handle_cobalt_inline( config: Arc, ) -> Result<(), MyError> { let url = q.query.trim(); + if !URL_REGEX.is_match(url) { return Ok(()); } + let user_id = q.from.id.0; let user_id_str = q.from.id.to_string(); + let owner = Owner { + id: user_id_str, + r#type: "user".to_string(), + }; + let url_hash_digest = md5::compute(url); let url_hash = format!("{:x}", url_hash_digest); let cache_key = format!("cobalt_cache:{}", url_hash); @@ -189,13 +136,14 @@ pub async fn handle_cobalt_inline( let results = if let Ok(Some(cached_result)) = redis.get::(&cache_key).await { build_results_from_media(url, cached_result, &url_hash, user_id) } else { - let settings = Settings::get_or_create(&user_id_str, "user").await?; + let settings = Settings::get_module_settings::(&owner, "cobalt").await?; + let cobalt_client = config.get_cobalt_client(); let result = resolve_download_url(url, &settings, cobalt_client).await; + match result { Ok(Some(download_result)) => { - let ttl_42_hours = 151_200; - if let Err(e) = redis.set(&cache_key, &download_result, ttl_42_hours).await { + if let Err(e) = redis.set(&cache_key, &download_result, 42 * 60 * 60).await { log::error!("Failed to cache cobalt result: {}", e); } build_results_from_media(url, download_result, &url_hash, user_id) @@ -216,42 +164,3 @@ pub async fn handle_cobalt_inline( bot.answer_inline_query(q.id, results).cache_time(0).await?; Ok(()) } - -// completely useless -// pub async fn handle_chosen_inline_video( -// bot: Bot, -// chosen: ChosenInlineResult, -// config: Arc, -// ) -> Result<(), MyError> { -// if let Some(inline_message_id) = chosen.inline_message_id { -// if let Some(url_hash) = chosen.result_id.strip_prefix("cobalt_video:") { -// let redis = config.get_redis_client(); -// let cache_key = format!("cobalt_cache:{}", url_hash); -// -// if let Ok(Some(DownloadResult::Video(video_url))) = -// redis.get::(&cache_key).await -// { -// let media = -// InputMedia::Video(InputMediaVideo::new(InputFile::url(video_url.parse()?))); -// if let Err(e) = bot -// .edit_message_media_inline(&inline_message_id, media) -// .await -// { -// log::error!("Failed to edit message with video: {}", e); -// bot.edit_message_text_inline( -// inline_message_id, -// "Ошибка: не удалось отправить видео.", -// ) -// .await?; -// } -// } else { -// bot.edit_message_text_inline( -// inline_message_id, -// "Ошибка: видео не найдено в кэше или срок его хранения истёк.", -// ) -// .await?; -// } -// } -// } -// Ok(()) -// } diff --git a/src/bot/inlines/currency.rs b/src/bot/inlines/currency.rs index 2c7d983..04e64df 100644 --- a/src/bot/inlines/currency.rs +++ b/src/bot/inlines/currency.rs @@ -1,6 +1,12 @@ -use crate::core::config::Config; -use crate::core::db::schemas::user::User; -use crate::errors::MyError; +use crate::{ + bot::modules::{Owner, currency::CurrencySettings}, + core::{ + config::Config, + db::schemas::{settings::Settings, user::User}, + services::currency::converter::CURRENCY_REGEX, + }, + errors::MyError, +}; use log::{debug, error}; use mongodb::bson::doc; use oximod::Model; @@ -10,13 +16,34 @@ use teloxide::{ payloads::AnswerInlineQuerySetters, prelude::Requester, types::{ - Chat, ChatId, ChatKind, ChatPrivate, InlineKeyboardButton, InlineKeyboardMarkup, - InlineQuery, InlineQueryResult, InlineQueryResultArticle, InputMessageContent, - InputMessageContentText, Me, ParseMode, + InlineKeyboardButton, InlineKeyboardMarkup, InlineQuery, InlineQueryResult, + InlineQueryResultArticle, InputMessageContent, InputMessageContentText, Me, ParseMode, }, }; use uuid::Uuid; +pub async fn is_currency_query(q: InlineQuery) -> bool { + let owner = Owner { + id: q.from.id.to_string(), + r#type: "user".to_string(), + }; + + if !CURRENCY_REGEX.is_match(&q.query) { + return false; + } + + match Settings::get_module_settings::(&owner, "currency").await { + Ok(settings) => settings.enabled, + Err(e) => { + error!( + "DB error checking currency module status for user {}: {}", + q.from.id, e + ); + false + } + } +} + pub async fn handle_currency_inline( bot: Bot, q: InlineQuery, @@ -65,26 +92,29 @@ pub async fn handle_currency_inline( let converter = config.get_currency_converter(); let text_to_process = &q.query; - // HACK - let pseudo_chat = Chat { - id: ChatId(q.from.id.0 as i64), - kind: ChatKind::Private(ChatPrivate { - first_name: Option::from(q.from.first_name.clone()), - last_name: q.from.last_name.clone(), - username: q.from.username.clone(), - }), + let owner = Owner { + id: q.from.id.to_string(), + r#type: "user".to_string(), // hack: inline-query always from user }; - match converter.process_text(text_to_process, &pseudo_chat).await { + // HACK + // let pseudo_chat = Chat { + // id: ChatId(q.from.id.0 as i64), + // kind: ChatKind::Private(ChatPrivate { + // first_name: Option::from(q.from.first_name.clone()), + // last_name: q.from.last_name.clone(), + // username: q.from.username.clone(), + // }), + // }; + + match converter.process_text(text_to_process, &owner).await { Ok(mut results) => { if results.is_empty() { debug!("No currency conversion results for: {}", &q.query); return Ok(()); } - if results.len() > 5 { - results.truncate(5); - } + results.truncate(5); let raw_results = results.join("\n"); diff --git a/src/bot/inlines/whisper.rs b/src/bot/inlines/whisper.rs index 3963a68..2bdde54 100644 --- a/src/bot/inlines/whisper.rs +++ b/src/bot/inlines/whisper.rs @@ -1,20 +1,22 @@ +use crate::{core::config::Config, errors::MyError}; +use log::error; use serde::{Deserialize, Serialize}; -use std::hash::{DefaultHasher, Hash, Hasher}; -use std::sync::Arc; -use teloxide::Bot; -use teloxide::payloads::AnswerInlineQuerySetters; -use teloxide::prelude::{Requester, UserId}; -use teloxide::types::{ - InlineKeyboardButton, InlineKeyboardMarkup, InlineQuery, InlineQueryResult, - InlineQueryResultArticle, InputMessageContent, InputMessageContentText, ParseMode, +use std::{ + hash::{DefaultHasher, Hash, Hasher}, + sync::Arc, +}; +use teloxide::{ + Bot, + payloads::AnswerInlineQuerySetters, + prelude::{Requester, UserId}, + types::{ + InlineKeyboardButton, InlineKeyboardMarkup, InlineQuery, InlineQueryResult, + InlineQueryResultArticle, InputMessageContent, InputMessageContentText, ParseMode, + }, + utils::html, }; use uuid::Uuid; -use crate::core::config::Config; -use crate::errors::MyError; -use log::error; -use teloxide::utils::html; - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct Recipient { pub id: Option, @@ -43,10 +45,7 @@ fn parse_query(query: &str) -> (String, Vec) { let mut content_end_index = query.len(); for part in query.split_whitespace().rev() { - if part.starts_with('@') && part.len() > 1 { - recipients.push(part.to_string()); - content_end_index = query.rfind(part).unwrap_or(query.len()); - } else if part.parse::().is_ok() { + if part.starts_with('@') && part.len() > 1 || part.parse::().is_ok() { recipients.push(part.to_string()); content_end_index = query.rfind(part).unwrap_or(query.len()); } else { @@ -172,8 +171,8 @@ pub async fn handle_whisper_inline( let mut recipients: Vec = Vec::new(); for identifier in &recipient_identifiers { - if identifier.starts_with('@') { - let username = identifier[1..].to_string(); + if let Some(username) = identifier.strip_prefix('@') { + let username = username.to_string(); recipients.push(Recipient { id: None, first_name: username.clone(), @@ -200,10 +199,10 @@ pub async fn handle_whisper_inline( .cloned() .collect(); - if !recipients_for_recents.is_empty() { - if let Err(e) = update_recents(&config, sender.id.0, &recipients_for_recents).await { - error!("Failed to update recent contacts: {:?}", e); - } + if !recipients_for_recents.is_empty() + && let Err(e) = update_recents(&config, sender.id.0, &recipients_for_recents).await + { + error!("Failed to update recent contacts: {:?}", e); } let whisper_id = Uuid::new_v4().to_string(); @@ -265,6 +264,7 @@ pub async fn handle_whisper_inline( Ok(()) } +// TODO: impl module settings for whisper query pub async fn is_whisper_query(_q: InlineQuery) -> bool { true } diff --git a/src/bot/keyboards/cobalt.rs b/src/bot/keyboards/cobalt.rs index 480b5ab..2c9d601 100644 --- a/src/bot/keyboards/cobalt.rs +++ b/src/bot/keyboards/cobalt.rs @@ -1,4 +1,4 @@ -use crate::core::db::schemas::settings::ModuleOption; +use crate::util::paginator::{FrameBuild, Paginator}; use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup}; pub fn make_single_url_keyboard(url: &str) -> InlineKeyboardMarkup { @@ -15,68 +15,16 @@ pub fn make_photo_pagination_keyboard( user_id: u64, original_url: &str, ) -> InlineKeyboardMarkup { - let mut row = Vec::new(); - - if current_index > 0 { - let prev_index = current_index - 1; - let cb_data = format!( - "cobalt:{}:{}:{}:{}", - user_id, prev_index, total_photos, url_hash - ); - row.push(InlineKeyboardButton::callback("⬅️", cb_data)); - } - - row.push(InlineKeyboardButton::callback( - format!("{}/{}", current_index + 1, total_photos), - "cobalt:noop", - )); - - if current_index + 1 < total_photos { - let next_index = current_index + 1; - let cb_data = format!( - "cobalt:{}:{}:{}:{}", - user_id, next_index, total_photos, url_hash - ); - row.push(InlineKeyboardButton::callback("➡️", cb_data)); - } - - InlineKeyboardMarkup::new(vec![row]).append_row(vec![InlineKeyboardButton::url( + let url_button_row = vec![InlineKeyboardButton::url( "URL", original_url.to_string().parse().unwrap(), - )]) -} + )]; -pub fn make_option_selection_keyboard( - owner_type: &str, - owner_id: &str, - module_key: &str, - option: &ModuleOption, -) -> InlineKeyboardMarkup { - let options: Vec<&str> = match (module_key, option.key.as_str()) { - ("cobalt", "video_quality") => vec!["720", "1080", "1440", "max"], - ("cobalt", "audio_format") => vec!["mp3", "best", "wav", "opus"], - ("cobalt", "attribution") => vec!["true", "false"], - _ => vec![], - }; - let buttons = options.into_iter().map(|opt| { - let display_text = match (option.key.as_str(), opt) { - ("attribution", "true") => "On", - ("attribution", "false") => "Off", - _ => opt, - }; - let display = if opt == option.value { - format!("• {} •", display_text) - } else { - display_text.to_string() - }; - let cb_data = format!( - "settings_set:{}:{}:{}:{}:{}", - owner_type, owner_id, module_key, option.key, opt - ); - InlineKeyboardButton::callback(display, cb_data) - }); - let mut keyboard: Vec> = buttons.map(|b| vec![b]).collect(); - let back_cb = format!("module_select:{}:{}:{}", owner_type, owner_id, module_key); - keyboard.push(vec![InlineKeyboardButton::callback("⬅️ Back", back_cb)]); - InlineKeyboardMarkup::new(keyboard) + Paginator::new("cobalt", total_photos) + .current_page(current_index) + .add_bottom_row(url_button_row) + .set_callback_formatter(move |page| { + format!("cobalt:{}:{}:{}:{}", user_id, page, total_photos, url_hash) + }) + .build() } diff --git a/src/bot/keyboards/delete.rs b/src/bot/keyboards/delete.rs index a16e8a8..322c713 100644 --- a/src/bot/keyboards/delete.rs +++ b/src/bot/keyboards/delete.rs @@ -19,4 +19,4 @@ pub(crate) fn confirm_delete_keyboard(original_user_id: u64) -> InlineKeyboardMa ]; InlineKeyboardMarkup::new(vec![buttons]) -} \ No newline at end of file +} diff --git a/src/bot/keyboards/mod.rs b/src/bot/keyboards/mod.rs index 9a090a1..75a1fb9 100644 --- a/src/bot/keyboards/mod.rs +++ b/src/bot/keyboards/mod.rs @@ -1,4 +1,4 @@ -pub mod translate; pub mod cobalt; pub mod delete; -pub mod transcription; \ No newline at end of file +pub mod transcription; +pub mod translate; diff --git a/src/bot/keyboards/transcription.rs b/src/bot/keyboards/transcription.rs index 0c48bb6..daca6c6 100644 --- a/src/bot/keyboards/transcription.rs +++ b/src/bot/keyboards/transcription.rs @@ -1,46 +1,21 @@ +use crate::util::paginator::{FrameBuild, Paginator}; use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup}; -pub const TRANSCRIPTION_MODULE_KEY: &str = "transcription"; +pub const TRANSCRIPTION_MODULE_KEY: &str = "speech"; pub fn create_transcription_keyboard( current_page: usize, total_pages: usize, user_id: u64, ) -> InlineKeyboardMarkup { - let mut keyboard: Vec> = vec![]; - - let mut nav_row = Vec::new(); - - if current_page > 0 { - nav_row.push(InlineKeyboardButton::callback( - "⬅️", - format!("{}:page:{}", TRANSCRIPTION_MODULE_KEY, current_page - 1), - )); - } - - nav_row.push(InlineKeyboardButton::callback( - format!("📄 {}/{}", current_page + 1, total_pages), - "noop", - )); - - if current_page + 1 < total_pages { - nav_row.push(InlineKeyboardButton::callback( - "➡️", - format!("{}:page:{}", TRANSCRIPTION_MODULE_KEY, current_page + 1), - )); - } - - if total_pages > 1 { - keyboard.push(nav_row); - } - - let action_row = vec![ - InlineKeyboardButton::callback("✨", "summarize"), - InlineKeyboardButton::callback("🗑️", format!("delete_msg:{}", user_id)), - ]; - keyboard.push(action_row); - - InlineKeyboardMarkup::new(keyboard) + let summary_button = InlineKeyboardButton::callback("✨", "summarize"); + let delete_button = InlineKeyboardButton::callback("🗑️", format!("delete_msg:{}", user_id)); + + Paginator::new(TRANSCRIPTION_MODULE_KEY, total_pages) + .current_page(current_page) + .add_bottom_row(vec![summary_button]) + .add_bottom_row(vec![delete_button]) + .build() } pub fn create_summary_keyboard() -> InlineKeyboardMarkup { @@ -48,4 +23,4 @@ pub fn create_summary_keyboard() -> InlineKeyboardMarkup { "⬅️ Назад", "back_to_full", )]]) -} \ No newline at end of file +} diff --git a/src/bot/keyboards/translate.rs b/src/bot/keyboards/translate.rs index 7e399de..7a5a0b0 100644 --- a/src/bot/keyboards/translate.rs +++ b/src/bot/keyboards/translate.rs @@ -1,44 +1,14 @@ use crate::core::services::translation::{LANGUAGES_PER_PAGE, SUPPORTED_LANGUAGES}; +use crate::util::paginator::{ItemsBuild, Paginator}; use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup}; pub fn create_language_keyboard(page: usize) -> InlineKeyboardMarkup { - let mut keyboard: Vec> = Vec::new(); - let start = page * LANGUAGES_PER_PAGE; - let end = std::cmp::min(start + LANGUAGES_PER_PAGE, SUPPORTED_LANGUAGES.len()); - - if start >= end { - return InlineKeyboardMarkup::new(keyboard); - } - - let page_languages = &SUPPORTED_LANGUAGES[start..end]; - - for chunk in page_languages.chunks(2) { - let row = chunk - .iter() - .map(|(code, name)| { - InlineKeyboardButton::callback(name.to_string(), format!("tr_lang:{}", code)) - }) - .collect(); - keyboard.push(row); - } - - let mut nav_row: Vec = Vec::new(); - if page > 0 { - nav_row.push(InlineKeyboardButton::callback( - "⬅️".to_string(), - format!("tr_page:{}", page - 1), - )); - } - if end < SUPPORTED_LANGUAGES.len() { - nav_row.push(InlineKeyboardButton::callback( - "➡️".to_string(), - format!("tr_page:{}", page + 1), - )); - } - - if !nav_row.is_empty() { - keyboard.push(nav_row); - } - - InlineKeyboardMarkup::new(keyboard) + Paginator::from("tr", SUPPORTED_LANGUAGES) + .per_page(LANGUAGES_PER_PAGE) + .columns(2) + .current_page(page) + .set_callback_formatter(|p| format!("tr_page:{}", p)) + .build(|(code, name)| { + InlineKeyboardButton::callback(name.to_string(), format!("tr_lang:{}", code)) + }) } diff --git a/src/bot/messager.rs b/src/bot/messager.rs index aa7e6ff..2663322 100644 --- a/src/bot/messager.rs +++ b/src/bot/messager.rs @@ -1,13 +1,18 @@ -use crate::bot::messages::sounder::sound_handlers; -use crate::core::config::Config; -use crate::errors::MyError; +use crate::{ + bot::{ + keyboards::delete::delete_message_button, messages::sounder::sound_handlers, modules::Owner, + }, + core::config::Config, + errors::MyError, +}; use log::error; -use teloxide::Bot; -use teloxide::payloads::SendMessageSetters; -use teloxide::requests::Requester; -use teloxide::types::{Message, ParseMode, ReplyParameters}; +use teloxide::{ + Bot, + payloads::SendMessageSetters, + requests::Requester, + types::{Message, ParseMode, ReplyParameters}, +}; use tokio::task; -use crate::bot::keyboards::delete::delete_message_button; pub async fn handle_speech(bot: Bot, message: Message) -> Result<(), MyError> { let config = Config::new().await; @@ -29,8 +34,6 @@ pub async fn handle_speech(bot: Bot, message: Message) -> Result<(), MyError> { pub async fn handle_currency(bot: Bot, message: Message) -> Result<(), MyError> { let config = Config::new().await; - let bot_clone = bot.clone(); - task::spawn(async move { let user = message.from.clone().unwrap(); @@ -43,15 +46,23 @@ pub async fn handle_currency(bot: Bot, message: Message) -> Result<(), MyError> let converter = config.get_currency_converter(); if let Some(text) = message.text() { - match converter.process_text(text, &message.chat).await { + let owner = Owner { + id: message.chat.id.to_string(), + r#type: (if message.chat.is_private() { + "user" + } else { + "group" + }) + .to_string(), + }; + + match converter.process_text(text, &owner).await { Ok(mut results) => { if results.is_empty() { return; } - if results.len() > 5 { - results.truncate(5); - } + results.truncate(5); let formatted_blocks: Vec = results .into_iter() @@ -61,10 +72,8 @@ pub async fn handle_currency(bot: Bot, message: Message) -> Result<(), MyError> }) .collect(); - let final_message = formatted_blocks.join("\n"); - - if let Err(e) = bot_clone - .send_message(message.chat.id, final_message) + if let Err(e) = bot + .send_message(message.chat.id, formatted_blocks.join("\n")) .parse_mode(ParseMode::Html) .reply_markup(delete_message_button(user.id.0)) .reply_parameters(ReplyParameters::new(message.id)) diff --git a/src/bot/messages/chat.rs b/src/bot/messages/chat.rs index d9b9ac5..cdaf587 100644 --- a/src/bot/messages/chat.rs +++ b/src/bot/messages/chat.rs @@ -1,17 +1,39 @@ -use crate::core::db::schemas::group::Group; -use crate::core::db::schemas::user::User; -use crate::errors::MyError; -use crate::core::services::currency::converter::get_default_currencies; +use crate::{ + bot::modules::Owner, + core::db::schemas::{group::Group, settings::Settings, user::User}, + errors::MyError, +}; use log::{error, info}; use mongodb::bson::doc; use oximod::ModelTrait; -use teloxide::Bot; -use teloxide::payloads::SendMessageSetters; -use teloxide::prelude::Requester; -use teloxide::types::{ChatMemberUpdated, ParseMode}; +use teloxide::{ + Bot, + payloads::SendMessageSetters, + prelude::Requester, + types::{ChatMemberUpdated, InlineKeyboardButton, InlineKeyboardMarkup, ParseMode}, +}; pub async fn handle_bot_added(bot: Bot, update: ChatMemberUpdated) -> Result<(), MyError> { let id = update.chat.id.to_string(); + let news_link_button = InlineKeyboardButton::url( + "Канал с новостями о Боте", + "https://t.me/fulturate".parse().unwrap(), + ); + let github_link_button = InlineKeyboardButton::url( + "Github", + "https://github.com/Fulturate/bot".parse().unwrap(), + ); + let msg = bot + .send_message( + update.chat.id, + "Добро пожаловать в Fulturate!\n\nМы тут когда нибудь что-то точно сделаем..." + .to_string(), + ) + .parse_mode(ParseMode::Html) + .reply_markup(InlineKeyboardMarkup::new(vec![vec![ + news_link_button, + github_link_button, + ]])); if update.new_chat_member.is_banned() || update.new_chat_member.is_left() { info!("Administrator is banned or user is blocked me. Deleting from DB"); @@ -31,32 +53,25 @@ pub async fn handle_bot_added(bot: Bot, update: ChatMemberUpdated) -> Result<(), info!("New chat added. ID: {}", id); - let necessary_codes = get_default_currencies()?; - - let new_query = if update.chat.is_private() { - User::new() - .user_id(id.clone()) - .convertable_currencies(necessary_codes) - .save() - .await - } else { - Group::new() - .group_id(id.clone()) - .convertable_currencies(necessary_codes) - .save() - .await - }; - - match new_query { - Ok(_) => { - // todo: welcome message - bot.send_message(update.chat.id, "Hello world".to_string()) - .parse_mode(ParseMode::Html) - .await?; - } - Err(e) => { - error!("Could not save new entity. ID: {} | Error: {}", &id, e); + if update.chat.is_private() { + if User::find_one(doc! { "user_id": &id }).await?.is_none() { + User::new().user_id(id.clone()).save().await?; + let owner = Owner { + id, + r#type: "user".to_string(), + }; + Settings::create_with_defaults(&owner).await?; + + msg.await?; } + } else if Group::find_one(doc! { "group_id": &id }).await?.is_none() { + Group::new().group_id(id.clone()).save().await?; + let owner = Owner { + id, + r#type: "group".to_string(), + }; + Settings::create_with_defaults(&owner).await?; + msg.await?; } Ok(()) diff --git a/src/bot/messages/sound/voice.rs b/src/bot/messages/sound/voice.rs index 3c2bef4..2319a48 100644 --- a/src/bot/messages/sound/voice.rs +++ b/src/bot/messages/sound/voice.rs @@ -1,8 +1,8 @@ use crate::core::config::Config; +use crate::core::services::speech_recognition::transcription_handler; use crate::errors::MyError; -use crate::core::services::transcription::transcription_handler; use teloxide::prelude::*; pub async fn voice_handler(bot: Bot, msg: Message, config: &Config) -> Result<(), MyError> { - transcription_handler(bot, msg, config).await + transcription_handler(bot, &msg, config).await } diff --git a/src/bot/messages/sound/voice_note.rs b/src/bot/messages/sound/voice_note.rs index a62943f..456c510 100644 --- a/src/bot/messages/sound/voice_note.rs +++ b/src/bot/messages/sound/voice_note.rs @@ -1,8 +1,8 @@ use crate::core::config::Config; +use crate::core::services::speech_recognition::transcription_handler; use crate::errors::MyError; -use crate::core::services::transcription::transcription_handler; use teloxide::prelude::*; pub async fn voice_note_handler(bot: Bot, msg: Message, config: &Config) -> Result<(), MyError> { - transcription_handler(bot, msg, config).await + transcription_handler(bot, &msg, config).await } diff --git a/src/bot/messages/sounder.rs b/src/bot/messages/sounder.rs index 14d0395..8c7abaa 100644 --- a/src/bot/messages/sounder.rs +++ b/src/bot/messages/sounder.rs @@ -1,9 +1,9 @@ -use crate::bot::messages::sound::voice::voice_handler; -use crate::bot::messages::sound::voice_note::voice_note_handler; -use crate::core::config::Config; -use crate::errors::MyError; -use teloxide::Bot; -use teloxide::prelude::Message; +use crate::{ + bot::messages::sound::{voice::voice_handler, voice_note::voice_note_handler}, + core::config::Config, + errors::MyError, +}; +use teloxide::{Bot, prelude::Message}; pub async fn sound_handlers(bot: Bot, message: Message, config: &Config) -> Result<(), MyError> { let config = config.clone(); diff --git a/src/bot/mod.rs b/src/bot/mod.rs index feca7a3..fc20429 100644 --- a/src/bot/mod.rs +++ b/src/bot/mod.rs @@ -1,8 +1,9 @@ pub mod callbacks; +pub mod commander; +pub mod commands; pub mod dispatcher; pub mod inlines; pub mod keyboards; -pub mod messages; pub mod messager; -pub mod commander; -pub mod commands; \ No newline at end of file +pub mod messages; +pub mod modules; diff --git a/src/bot/modules/cobalt.rs b/src/bot/modules/cobalt.rs new file mode 100644 index 0000000..ea72407 --- /dev/null +++ b/src/bot/modules/cobalt.rs @@ -0,0 +1,181 @@ +use crate::{ + bot::modules::{Module, ModuleSettings, Owner}, + core::{db::schemas::settings::Settings, services::cobalt::VideoQuality}, + errors::MyError, +}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use teloxide::{ + prelude::*, + types::{InlineKeyboardButton, InlineKeyboardMarkup}, +}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CobaltSettings { + pub enabled: bool, + pub video_quality: VideoQuality, + pub attribution: bool, +} + +impl Default for CobaltSettings { + fn default() -> Self { + Self { + enabled: false, + video_quality: VideoQuality::Q1080, + attribution: false, + } + } +} + +impl ModuleSettings for CobaltSettings {} + +pub struct CobaltModule; + +#[async_trait] +impl Module for CobaltModule { + fn key(&self) -> &'static str { + "cobalt" + } + fn description(&self) -> &'static str { + "Настройки для Cobalt Downloader" + } + + async fn get_settings_ui( + &self, + owner: &Owner, + ) -> Result<(String, InlineKeyboardMarkup), MyError> { + let settings: CobaltSettings = Settings::get_module_settings(owner, self.key()).await?; + + let text = format!( + "⚙️ Настройки модуля: {}\n\nСтатус: {}", + self.description(), + if settings.enabled { + "✅ Включен" + } else { + "❌ Выключен" + } + ); + + let toggle_button = InlineKeyboardButton::callback( + if settings.enabled { + "Выключить модуль" + } else { + "Включить модуль" + }, + format!("{}:settings:toggle_module", self.key()), + ); + + let quality_options = [ + VideoQuality::Q720, + VideoQuality::Q1080, + VideoQuality::Q1440, + VideoQuality::Max, + ]; + let quality_buttons = quality_options + .iter() + .map(|q| { + let display_text = if settings.video_quality == *q { + format!("• {}p •", q.as_str()) + } else { + format!("{}p", q.as_str()) + }; + let cb_data = format!("{}:settings:set:quality:{}", self.key(), q.as_str()); + InlineKeyboardButton::callback(display_text, cb_data) + }) + .collect::>(); + + let attr_text = if settings.attribution { + "Атрибуция: Вкл ✅" + } else { + "Атрибуция: Выкл ❌" + }; + let attr_cb = format!( + "{}:settings:set:attribution:{}", + self.key(), + !settings.attribution + ); + + let keyboard = InlineKeyboardMarkup::new(vec![ + vec![toggle_button], + vec![InlineKeyboardButton::callback("Качество видео", "noop")], + quality_buttons, + vec![InlineKeyboardButton::callback(attr_text, attr_cb)], + vec![InlineKeyboardButton::callback( + "⬅️ Назад", + format!("settings_back:{}:{}", owner.r#type, owner.id), + )], + ]); + + Ok((text, keyboard)) + } + + async fn handle_callback( + &self, + bot: Bot, + q: &CallbackQuery, + owner: &Owner, + data: &str, + ) -> Result<(), MyError> { + let Some(message) = &q.message else { + return Ok(()); + }; + + let Some(message) = message.regular_message() else { + return Ok(()); + }; + + let parts: Vec<_> = data.split(':').collect(); + + if parts.len() == 1 && parts[0] == "toggle_module" { + let mut settings: CobaltSettings = + Settings::get_module_settings(owner, self.key()).await?; + settings.enabled = !settings.enabled; + Settings::update_module_settings(owner, self.key(), settings).await?; + + let (text, keyboard) = self.get_settings_ui(owner).await?; + bot.edit_message_text(message.chat.id, message.id, text) + .reply_markup(keyboard) + .await?; + return Ok(()); + } + + if parts.len() < 3 || parts[0] != "set" { + bot.answer_callback_query(q.id.clone()).await?; + return Ok(()); + } + + let mut settings: CobaltSettings = Settings::get_module_settings(owner, self.key()).await?; + + match (parts[1], parts[2]) { + ("quality", val) => { + settings.video_quality = VideoQuality::from_str(val); + } + ("attribution", val) => { + settings.attribution = val.parse().unwrap_or(false); + } + _ => {} + } + + Settings::update_module_settings(owner, self.key(), settings).await?; + + let (text, keyboard) = self.get_settings_ui(owner).await?; + bot.edit_message_text(message.chat.id, message.id, text) + .reply_markup(keyboard) + .await?; + + Ok(()) + } + + fn enabled_for(&self, owner_type: &str) -> bool { + owner_type == "user" // user + } + + fn factory_settings(&self) -> Result { + let factory_settings = CobaltSettings { + enabled: true, + video_quality: VideoQuality::Q1080, + attribution: false, + }; + Ok(serde_json::to_value(factory_settings)?) + } +} diff --git a/src/bot/modules/currency.rs b/src/bot/modules/currency.rs new file mode 100644 index 0000000..c45e190 --- /dev/null +++ b/src/bot/modules/currency.rs @@ -0,0 +1,219 @@ +use crate::{ + bot::modules::{Module, ModuleSettings, Owner}, + core::{ + db::schemas::{group::Group, settings::Settings, user::User}, + services::{ + currencier::handle_currency_update, + currency::converter::{CURRENCY_CONFIG_PATH, get_all_currency_codes}, + }, + }, + errors::MyError, + util::paginator::{ItemsBuild, Paginator}, +}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use teloxide::{ + prelude::*, + types::{InlineKeyboardButton, InlineKeyboardMarkup}, +}; + +#[derive(Debug, Serialize, Deserialize, Clone, Default)] +pub struct CurrencySettings { + pub enabled: bool, + pub selected_codes: Vec, +} + +// impl Default for CurrencySettings { +// fn default() -> Self { +// Self { +// enabled: false, +// selected_codes: vec![], +// } +// } +// } + +impl ModuleSettings for CurrencySettings {} + +pub struct CurrencyModule; + +#[async_trait] +impl Module for CurrencyModule { + fn key(&self) -> &'static str { + "currency" + } + + fn description(&self) -> &'static str { + "Настройки конвертации валют" + } + + async fn get_settings_ui( + &self, + owner: &Owner, + ) -> Result<(String, InlineKeyboardMarkup), MyError> { + self.get_paged_settings_ui(owner, 0).await + } + + async fn handle_callback( + &self, + bot: Bot, + q: &CallbackQuery, + owner: &Owner, + data: &str, + ) -> Result<(), MyError> { + let Some(message) = &q.message else { + return Ok(()); + }; + + let Some(message) = message.regular_message() else { + return Ok(()); + }; + let parts: Vec<_> = data.split(':').collect(); + + if parts.len() == 1 && parts[0] == "toggle_module" { + let mut settings: CurrencySettings = + Settings::get_module_settings(owner, self.key()).await?; + + settings.enabled = !settings.enabled; + + if settings.enabled && settings.selected_codes.is_empty() { + settings.selected_codes = vec![ + "UAH".to_string(), + "RUB".to_string(), + "USD".to_string(), + "BYN".to_string(), + "EUR".to_string(), + "TON".to_string(), + ]; + } + + Settings::update_module_settings(owner, self.key(), settings).await?; + + let (text, keyboard) = self.get_paged_settings_ui(owner, 0).await?; + + bot.edit_message_text(message.chat.id, message.id, text) + .reply_markup(keyboard) + .await?; + + return Ok(()); + } + + if parts.len() == 2 && parts[0] == "page" { + let page = parts[1].parse::().unwrap_or(0); + + let (text, keyboard) = self.get_paged_settings_ui(owner, page).await?; + + bot.edit_message_text(message.chat.id, message.id, text) + .reply_markup(keyboard) + .await?; + + return Ok(()); + } + + if parts.len() == 2 && parts[0] == "toggle" { + let currency_code = parts[1].to_string(); + let mut settings: CurrencySettings = + Settings::get_module_settings(owner, self.key()).await?; + + if let Some(pos) = settings + .selected_codes + .iter() + .position(|c| *c == currency_code) + { + settings.selected_codes.remove(pos); + } else { + settings.selected_codes.push(currency_code); + } + Settings::update_module_settings(owner, self.key(), settings).await?; + + let (text, keyboard) = self.get_paged_settings_ui(owner, 0).await?; // TODO: сохранить текущую страницу + + bot.edit_message_text(message.chat.id, message.id, text) + .reply_markup(keyboard) + .await?; + } else { + bot.answer_callback_query(q.id.clone()).await?; + } + + Ok(()) + } + + fn enabled_for(&self, _owner_type: &str) -> bool { + true // all + } + + fn factory_settings(&self) -> Result { + let factory_settings = CurrencySettings { + enabled: true, + selected_codes: vec![ + "UAH".to_string(), + "RUB".to_string(), + "USD".to_string(), + "BYN".to_string(), + "EUR".to_string(), + "TON".to_string(), + ], + }; + Ok(serde_json::to_value(factory_settings)?) + } +} + +impl CurrencyModule { + async fn get_paged_settings_ui( + &self, + owner: &Owner, + page: usize, + ) -> Result<(String, InlineKeyboardMarkup), MyError> { + let settings: CurrencySettings = Settings::get_module_settings(owner, self.key()).await?; + let text = format!( + "⚙️ Настройки модуля: {}\n\nСтатус: {}\n\nВыберите валюты для отображения.", + self.description(), + if settings.enabled { + "✅ Включен" + } else { + "❌ Выключен" + } + ); + + let toggle_button = InlineKeyboardButton::callback( + if settings.enabled { + "Выключить модуль" + } else { + "Включить модуль" + }, + format!("{}:settings:toggle_module", self.key()), + ); + + let all_currencies = get_all_currency_codes(CURRENCY_CONFIG_PATH.parse().unwrap())?; + + let back_button = InlineKeyboardButton::callback( + "⬅️ Назад", + format!("settings_back:{}:{}", owner.r#type, owner.id), + ); + + let mut keyboard = Paginator::from(self.key(), &all_currencies) + .per_page(12) + .columns(3) + .current_page(page) + .add_bottom_row(vec![back_button]) + .set_callback_prefix(format!("{}:settings", self.key())) + .build(|currency| { + let is_selected = settings.selected_codes.contains(¤cy.code); + let icon = if is_selected { "✅" } else { "❌" }; + let button_text = format!("{} {}", icon, currency.code); + let callback_data = format!("{}:settings:toggle:{}", self.key(), currency.code); + InlineKeyboardButton::callback(button_text, callback_data) + }); + + keyboard.inline_keyboard.insert(0, vec![toggle_button]); + + Ok((text, keyboard)) + } +} + +pub async fn currency_codes_handler(bot: Bot, msg: Message, code: String) -> Result<(), MyError> { + if msg.chat.is_private() { + handle_currency_update::(bot, msg, code).await + } else { + handle_currency_update::(bot, msg, code).await + } +} diff --git a/src/bot/modules/mod.rs b/src/bot/modules/mod.rs new file mode 100644 index 0000000..cd59b6e --- /dev/null +++ b/src/bot/modules/mod.rs @@ -0,0 +1,45 @@ +pub mod cobalt; +pub mod currency; +pub mod registry; + +use crate::errors::MyError; +use async_trait::async_trait; +use serde::{Serialize, de::DeserializeOwned}; +use std::fmt::Debug; +use teloxide::{prelude::*, types::InlineKeyboardMarkup}; + +#[derive(Clone, Debug)] +pub struct Owner { + pub id: String, + pub r#type: String, // user, group only +} + +#[async_trait] +pub trait ModuleSettings: + Sized + Default + Serialize + DeserializeOwned + Debug + Send + Sync +{ +} + +#[async_trait] +pub trait Module: Send + Sync { + fn key(&self) -> &'static str; + + fn description(&self) -> &'static str; + + async fn get_settings_ui( + &self, + owner: &Owner, + ) -> Result<(String, InlineKeyboardMarkup), MyError>; + + async fn handle_callback( + &self, + bot: Bot, + q: &CallbackQuery, + owner: &Owner, + data: &str, + ) -> Result<(), MyError>; + + fn enabled_for(&self, owner_type: &str) -> bool; + + fn factory_settings(&self) -> Result; +} diff --git a/src/bot/modules/registry.rs b/src/bot/modules/registry.rs new file mode 100644 index 0000000..e9d6c04 --- /dev/null +++ b/src/bot/modules/registry.rs @@ -0,0 +1,31 @@ +use super::{Module, cobalt::CobaltModule}; +use crate::bot::modules::currency::CurrencyModule; +use once_cell::sync::Lazy; +use std::{collections::BTreeMap, sync::Arc}; + +pub struct ModuleManager { + modules: BTreeMap>, +} + +impl ModuleManager { + fn new() -> Self { + let modules: Vec> = vec![Arc::new(CobaltModule), Arc::new(CurrencyModule)]; + + let modules = modules + .into_iter() + .map(|module| (module.key().to_string(), module)) + .collect(); + + Self { modules } + } + + pub fn get_module(&self, key: &str) -> Option<&Arc> { + self.modules.get(key) + } + + pub fn get_all_modules(&self) -> Vec<&Arc> { + self.modules.values().collect() + } +} + +pub static MOD_MANAGER: Lazy = Lazy::new(ModuleManager::new); diff --git a/src/core/config/json.rs b/src/core/config/json.rs index 5a07df7..f27cff0 100644 --- a/src/core/config/json.rs +++ b/src/core/config/json.rs @@ -1,7 +1,5 @@ -use std::fs::File; -use std::io::Read; -use std::path::Path; use serde::Deserialize; +use std::{fs::File, io::Read, path::Path}; #[derive(Deserialize, Debug, Clone)] pub struct JsonConfig { diff --git a/src/core/config/mod.rs b/src/core/config/mod.rs index 68b4adc..1f923e6 100644 --- a/src/core/config/mod.rs +++ b/src/core/config/mod.rs @@ -1,13 +1,15 @@ mod json; -use crate::core::db::redis::RedisCache; -use crate::core::services::currency::converter::{CurrencyConverter, OutputLanguage}; +use crate::core::{ + config::json::{JsonConfig, read_json_config}, + db::redis::RedisCache, + services::currency::converter::{CurrencyConverter, OutputLanguage}, +}; use dotenv::dotenv; use log::error; use redis::Client as RedisClient; use std::sync::Arc; use teloxide::prelude::*; -use crate::core::config::json::{read_json_config, JsonConfig}; #[derive(Clone)] pub struct Config { @@ -79,7 +81,8 @@ impl Config { .and_then(|s| s.parse().ok()) .unwrap_or(0.to_string()); - let Ok(json_config) = read_json_config("config.json") else { // todo: remove JsonConfig because useless when we will get /settings + let Ok(json_config) = read_json_config("config.json") else { + // todo: remove JsonConfig because useless when we will get /settings error!("Unable to read config.json"); std::process::exit(1); }; diff --git a/src/core/db/schemas/user.rs b/src/core/db/schemas/user.rs index f971c7b..adcf02e 100644 --- a/src/core/db/schemas/user.rs +++ b/src/core/db/schemas/user.rs @@ -1,9 +1,13 @@ -use crate::core::db::schemas::{BaseFunctions, CurrenciesFunctions}; -use crate::core::services::currency::converter::CurrencyStruct; +use crate::core::{ + db::schemas::{BaseFunctions, CurrenciesFunctions}, + services::currency::converter::CurrencyStruct, +}; use async_trait::async_trait; -use mongodb::bson; -use mongodb::bson::{doc, oid::ObjectId}; -use mongodb::results::UpdateResult; +use mongodb::{ + bson, + bson::{doc, oid::ObjectId}, + results::UpdateResult, +}; use oximod::{_error::oximod_error::OxiModError, Model}; use serde::{Deserialize, Serialize}; @@ -57,6 +61,7 @@ impl CurrenciesFunctions for User { currency: &CurrencyStruct, ) -> Result { let currency_to_add = bson::to_bson(currency).unwrap(); + Self::update_one( doc! {"user_id": user_id}, doc! {"$push": {"convertable_currencies": currency_to_add } }, diff --git a/src/core/mod.rs b/src/core/mod.rs index 5b14180..11506cc 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,3 +1,3 @@ +pub mod config; pub mod db; pub mod services; -pub mod config; diff --git a/src/core/services/cobalt.rs b/src/core/services/cobalt.rs new file mode 100644 index 0000000..7026074 --- /dev/null +++ b/src/core/services/cobalt.rs @@ -0,0 +1,102 @@ +use crate::{bot::modules::cobalt::CobaltSettings, errors::MyError}; +use ccobalt::model::{ + request::{DownloadRequest, FilenameStyle}, + response::DownloadResponse, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub enum VideoQuality { + Q720, + Q1080, + Q1440, + Max, +} + +impl VideoQuality { + pub fn as_str(&self) -> &'static str { + match self { + VideoQuality::Q720 => "720", + VideoQuality::Q1080 => "1080", + VideoQuality::Q1440 => "1440", + VideoQuality::Max => "max", + } + } + + pub fn from_str(s: &str) -> Self { + match s { + "1080" => VideoQuality::Q1080, + "1440" => VideoQuality::Q1440, + "max" => VideoQuality::Max, + _ => VideoQuality::Q720, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum DownloadResult { + Video(String), + Photos { + urls: Vec, + original_url: String, + }, +} + +pub async fn resolve_download_url( + url: &str, + settings: &CobaltSettings, + client: &ccobalt::Client, +) -> Result, MyError> { + let cobalt_req = DownloadRequest { + url: url.to_string(), + filename_style: Some(FilenameStyle::Pretty), + video_quality: Some(match settings.video_quality { + VideoQuality::Q720 => ccobalt::model::request::VideoQuality::Q720, + VideoQuality::Q1080 => ccobalt::model::request::VideoQuality::Q1080, + VideoQuality::Q1440 => ccobalt::model::request::VideoQuality::Q1440, + VideoQuality::Max => ccobalt::model::request::VideoQuality::Max, + }), + ..Default::default() + }; + let response = client.resolve_download(&cobalt_req).await?; + match response { + DownloadResponse::Error { error } => { + log::error!("Cobalt API error: {:?}", error); + Err(error.into()) + } + DownloadResponse::Picker { picker, .. } => { + let photo_urls: Vec = picker + .iter() + .filter(|item| item.kind == "photo") + .map(|item| item.url.clone()) + .collect(); + if !photo_urls.is_empty() { + return Ok(Some(DownloadResult::Photos { + urls: photo_urls, + original_url: url.to_string(), + })); + } + if let Some(video_item) = picker.iter().find(|item| item.kind == "video") { + return Ok(Some(DownloadResult::Video(video_item.url.clone()))); + } + Ok(None) + } + DownloadResponse::Tunnel { url, filename } + | DownloadResponse::Redirect { url, filename } => { + const PHOTO_EXTENSIONS: &[&str] = &[".jpg", ".jpeg", ".png", ".gif", ".webp"]; + let is_photo = PHOTO_EXTENSIONS + .iter() + .any(|ext| filename.to_lowercase().ends_with(ext)); + + if is_photo { + Ok(Some(DownloadResult::Photos { + urls: vec![url.clone()], + original_url: url, + })) + } else { + Ok(Some(DownloadResult::Video(url))) + } + } + _ => Ok(response.get_download_url().map(DownloadResult::Video)), + } +} diff --git a/src/core/services/currencier.rs b/src/core/services/currencier.rs index 9b0921d..0b30f25 100644 --- a/src/core/services/currencier.rs +++ b/src/core/services/currencier.rs @@ -1,14 +1,20 @@ -use crate::core::db::functions::get_or_create; -use crate::core::db::schemas::group::Group; -use crate::core::db::schemas::user::User; -use crate::core::db::schemas::{BaseFunctions, CurrenciesFunctions}; -use crate::errors::MyError; -use crate::core::services::currency::converter::{CURRENCY_CONFIG_PATH, get_all_currency_codes}; +use crate::{ + core::{ + db::{ + functions::get_or_create, + schemas::{BaseFunctions, CurrenciesFunctions, group::Group, user::User}, + }, + services::currency::converter::{CURRENCY_CONFIG_PATH, get_all_currency_codes}, + }, + errors::MyError, +}; use log::error; use oximod::Model; use std::collections::HashSet; -use teloxide::prelude::*; -use teloxide::types::{ParseMode, ReplyParameters}; +use teloxide::{ + prelude::*, + types::{ParseMode, ReplyParameters}, +}; pub async fn handle_currency_update( bot: Bot, diff --git a/src/core/services/currency/converter.rs b/src/core/services/currency/converter.rs index e7312db..0a656bd 100644 --- a/src/core/services/currency/converter.rs +++ b/src/core/services/currency/converter.rs @@ -1,23 +1,20 @@ -use std::{ - collections::HashMap, - fs, - sync::Arc, - time::{Duration, Instant}, +use crate::{ + bot::modules::{Owner, currency::CurrencySettings}, + core::db::schemas::settings::Settings, + errors::MyError, + util::currency_values::WORD_VALUES, }; - -use super::structs::WORD_VALUES; -use crate::core::db::schemas::CurrenciesFunctions; -use crate::core::db::schemas::group::Group; -use crate::core::db::schemas::user::User; -use crate::errors::MyError; use log::{debug, error, warn}; use once_cell::sync::Lazy; -use oximod::Model; use regex::Regex; use reqwest::Client; use serde::{Deserialize, Serialize}; -use teloxide::prelude::InlineQuery; -use teloxide::types::Chat; +use std::{ + collections::HashMap, + fs, + sync::Arc, + time::{Duration, Instant}, +}; use thiserror::Error; use tokio::sync::Mutex; @@ -129,7 +126,7 @@ fn build_regex_from_config() -> Result { Ok(regex_string) } -static CURRENCY_REGEX: Lazy = Lazy::new(|| { +pub(crate) static CURRENCY_REGEX: Lazy = Lazy::new(|| { let regex_string = build_regex_from_config() .map_err(|e| e.to_string()) .expect("FATAL: Could not build regex from currency config file."); @@ -144,10 +141,6 @@ static COMPONENT_RE: Lazy = Lazy::new(|| { static INFIX_K_RE: Lazy = Lazy::new(|| Regex::new(r"^(\d+(?:[.,]\d+)?)[kк](\d{1,3})$").unwrap()); -pub async fn is_currency_query(q: InlineQuery) -> bool { - CURRENCY_REGEX.is_match(&q.query) -} - pub fn get_all_currency_codes(config_file: String) -> Result, ConvertError> { let mut codes: Vec = vec![]; @@ -268,7 +261,6 @@ impl CurrencyConverter { .map_err(|e| ConvertError::ConfigFileParseError(config_path_str.to_string(), e))?; let mut currency_map = HashMap::new(); - // let mut target_codes = Vec::new(); // for fucking ton api let mut ton_tickers = Vec::new(); @@ -277,10 +269,6 @@ impl CurrencyConverter { let mut ton_address_to_code = HashMap::new(); for currency in currencies { - // if currency.is_target { - // target_codes.push(currency.code.clone()); - // } - if currency.source == "tonapi" && let Some(identifier) = ¤cy.api_identifier { @@ -302,7 +290,6 @@ impl CurrencyConverter { cache: Arc::new(Mutex::new(None)), client: Client::new(), currency_info: currency_map, - // target_currencies: target_codes, language, ton_tickers, ton_addresses, @@ -701,32 +688,30 @@ impl CurrencyConverter { Ok(result.trim_end().to_string()) } - pub async fn process_text(&self, text: &str, chat: &Chat) -> Result, ConvertError> { - let chat_id_str = chat.id.to_string(); - let target_codes = if chat.is_private() { - match User::find_one(mongodb::bson::doc! { "user_id": chat_id_str }).await { - Ok(Some(user)) => user - .get_currencies() - .iter() - .map(|c| c.code.clone()) - .collect(), - _ => Vec::new(), - } - } else { - match Group::find_one(mongodb::bson::doc! { "group_id": chat_id_str }).await { - Ok(Some(group)) => group - .get_currencies() - .iter() - .map(|c| c.code.clone()) - .collect(), - _ => Vec::new(), - } - }; + pub async fn process_text( + &self, + text: &str, + owner: &Owner, + ) -> Result, ConvertError> { + let currency_settings: CurrencySettings = + match Settings::get_module_settings(owner, "currency").await { + Ok(settings) => settings, + Err(e) => { + error!( + "Could not get currency settings for owner {:?}: {}", + owner, e + ); - if target_codes.is_empty() { + return Ok(Vec::new()); + } + }; + + if !currency_settings.enabled || currency_settings.selected_codes.is_empty() { return Ok(Vec::new()); } + let target_codes = currency_settings.selected_codes; + let detected_currencies = self.parse_text_for_currencies(text)?; if detected_currencies.is_empty() { return Ok(Vec::new()); diff --git a/src/core/services/currency/mod.rs b/src/core/services/currency/mod.rs index 4a3b96f..9c6a24e 100644 --- a/src/core/services/currency/mod.rs +++ b/src/core/services/currency/mod.rs @@ -1,2 +1 @@ pub mod converter; -pub mod structs; diff --git a/src/core/services/mod.rs b/src/core/services/mod.rs index e6253e4..119a26a 100644 --- a/src/core/services/mod.rs +++ b/src/core/services/mod.rs @@ -1,5 +1,5 @@ +pub mod cobalt; +pub mod currencier; pub mod currency; pub mod speech_recognition; pub mod translation; -pub mod currencier; -pub mod transcription; diff --git a/src/core/services/speech_recognition.rs b/src/core/services/speech_recognition.rs index e69de29..fc4f1bc 100644 --- a/src/core/services/speech_recognition.rs +++ b/src/core/services/speech_recognition.rs @@ -0,0 +1,459 @@ +use crate::{ + bot::keyboards::transcription::{create_summary_keyboard, create_transcription_keyboard}, + core::config::Config, + errors::MyError, + util::{enums::AudioStruct, split_text}, +}; +use bytes::Bytes; +use gem_rs::{ + api::Models, + client::GemSession, + types::{Blob, Context, HarmBlockThreshold, Role, Settings}, +}; +use log::{debug, error, info}; +use redis_macros::{FromRedisValue, ToRedisArgs}; +use serde::{Deserialize, Serialize}; +use std::time::Duration; +use teloxide::{ + prelude::*, + types::{FileId, MessageKind, ParseMode, ReplyParameters}, +}; + +#[derive(Debug, Serialize, Deserialize, FromRedisValue, ToRedisArgs, Clone)] +pub struct TranscriptionCache { + pub full_text: String, + pub summary: Option, + pub file_id: String, + pub mime_type: String, +} + +pub struct Transcription { + pub(crate) mime_type: String, + pub(crate) data: Bytes, + pub(crate) config: Config, +} + +pub async fn pagination_handler( + bot: Bot, + query: CallbackQuery, + config: &Config, +) -> Result<(), MyError> { + let Some(message) = query.message.as_ref().and_then(|m| m.regular_message()) else { + return Ok(()); + }; + let Some(data) = query.data.as_ref() else { + return Ok(()); + }; + + let parts: Vec<&str> = data.split(':').collect(); + if !(parts.len() == 3 && parts[0] == "speech" && parts[1] == "page") { + return Ok(()); + } + + let Ok(page) = parts[2].parse::() else { + return Ok(()); + }; + + let cache = config.get_redis_client(); + let message_cache_key = format!("message_file_map:{}", message.id); + let Some(file_unique_id): Option = cache.get::(&message_cache_key).await? + else { + bot.edit_message_text(message.chat.id, message.id, "❌ Кнопка устарела.") + .await?; + return Ok(()); + }; + + let file_cache_key = format!("transcription_by_file:{}", file_unique_id); + let Some(cache_entry) = cache.get::(&file_cache_key).await? else { + bot.edit_message_text( + message.chat.id, + message.id, + "❌ Не удалось найти текст в кеше.", + ) + .await?; + return Ok(()); + }; + + let text_parts = split_text(&cache_entry.full_text, 4000); + if page >= text_parts.len() { + return Ok(()); + } + + let new_text = format!("
{}
", text_parts[page]); + let new_keyboard = create_transcription_keyboard(page, text_parts.len(), query.from.id.0); + + if message.text() != Some(new_text.as_str()) || message.reply_markup() != Some(&new_keyboard) { + bot.edit_message_text(message.chat.id, message.id, new_text) + .parse_mode(ParseMode::Html) + .reply_markup(new_keyboard) + .await?; + } + + Ok(()) +} + +pub async fn back_handler(bot: Bot, query: CallbackQuery, config: &Config) -> Result<(), MyError> { + let Some(message) = query.message.and_then(|m| m.regular_message().cloned()) else { + return Ok(()); + }; + + let cache = config.get_redis_client(); + let message_cache_key = format!("message_file_map:{}", message.id); + let Some(file_unique_id) = cache.get::(&message_cache_key).await? else { + bot.edit_message_text( + message.chat.id, + message.id, + "❌ Не удалось найти исходное сообщение.", + ) + .await?; + return Ok(()); + }; + + let file_cache_key = format!("transcription_by_file:{}", file_unique_id); + let Some(cache_entry): Option = + cache.get::(&file_cache_key).await? + else { + bot.edit_message_text( + message.chat.id, + message.id, + "❌ Не удалось найти текст в кеше.", + ) + .await?; + return Ok(()); + }; + + let text_parts = split_text(&cache_entry.full_text, 4000); + let keyboard = create_transcription_keyboard(0, text_parts.len(), query.from.id.0); + + bot.edit_message_text( + message.chat.id, + message.id, + format!("
{}
", text_parts[0]), + ) + .parse_mode(ParseMode::Html) + .reply_markup(keyboard) + .await?; + + Ok(()) +} + +pub async fn summarization_handler( + bot: Bot, + query: CallbackQuery, + config: &Config, +) -> Result<(), MyError> { + let Some(message) = query.message.and_then(|m| m.regular_message().cloned()) else { + return Ok(()); + }; + + let cache = config.get_redis_client(); + let message_file_map_key = format!("message_file_map:{}", message.id); + let Some(file_unique_id) = cache.get::(&message_file_map_key).await? else { + bot.edit_message_text(message.chat.id, message.id, "❌ Кнопка устарела.") + .await?; + return Ok(()); + }; + + let file_cache_key = format!("transcription_by_file:{}", file_unique_id); + let mut cache_entry = match cache.get::(&file_cache_key).await? { + Some(entry) => entry, + None => { + bot.edit_message_text( + message.chat.id, + message.id, + "❌ Не удалось найти исходное аудио.", + ) + .await?; + return Ok(()); + } + }; + + if let Some(cached_summary) = cache_entry.summary { + let final_text = format!( + "Краткое содержание:\n
{}
", + cached_summary + ); + bot.edit_message_text(message.chat.id, message.id, final_text) + .parse_mode(ParseMode::Html) + .reply_markup(create_summary_keyboard()) + .await?; + return Ok(()); + } + + bot.edit_message_text( + message.chat.id, + message.id, + "Составляю краткое содержание...", + ) + .await?; + + let file_data = save_file_to_memory(&bot, &cache_entry.file_id).await?; + let new_summary = + summarize_audio(cache_entry.mime_type.clone(), file_data, config.clone()).await?; + + if new_summary.is_empty() || new_summary.contains("Не удалось получить") { + bot.edit_message_text( + message.chat.id, + message.id, + "❌ Не удалось составить краткое содержание.", + ) + .await?; + return Ok(()); + } + + cache_entry.summary = Some(new_summary.clone()); + cache.set(&file_cache_key, &cache_entry, 86400).await?; + + let final_text = format!( + "Краткое содержание:\n
{}
", + new_summary + ); + bot.edit_message_text(message.chat.id, message.id, final_text) + .parse_mode(ParseMode::Html) + .reply_markup(create_summary_keyboard()) + .await?; + + Ok(()) +} + +async fn get_cached( + bot: &Bot, + file: &AudioStruct, + config: &Config, +) -> Result { + let cache = config.get_redis_client(); + let file_cache_key = format!("transcription_by_file:{}", &file.file_unique_id); + + if let Some(cached_text) = cache.get::(&file_cache_key).await? { + debug!("File cache HIT for unique_id: {}", &file.file_unique_id); + return Ok(cached_text); + } + + let file_data = save_file_to_memory(bot, &file.file_id).await?; + let transcription = Transcription { + mime_type: file.mime_type.to_string(), + data: file_data, + config: config.clone(), + }; + let processed_parts = transcription.to_text().await; + + if processed_parts.is_empty() || processed_parts[0].contains("Не удалось преобразовать") + { + let error_message = processed_parts.first().cloned().unwrap_or_default(); + return Err(MyError::Other(error_message)); + } + + let full_text = processed_parts.join("\n\n"); + let new_cache_entry = TranscriptionCache { + full_text, + summary: None, + file_id: file.file_id.clone(), + mime_type: file.mime_type.clone(), + }; + + cache.set(&file_cache_key, &new_cache_entry, 86400).await?; + debug!( + "Saved new transcription to file cache for unique_id: {}", + file.file_id + ); + + Ok(new_cache_entry) +} + +pub async fn transcription_handler( + bot: Bot, + msg: &Message, + config: &Config, +) -> Result<(), MyError> { + let message = bot + .send_message(msg.chat.id, "Обрабатываю аудио...") + .reply_parameters(ReplyParameters::new(msg.id)) + .parse_mode(ParseMode::Html) + .await + .ok(); + + let Some(message) = message else { + return Ok(()); + }; + let Some(user) = msg.from.as_ref() else { + bot.edit_message_text( + message.chat.id, + message.id, + "Не удалось определить пользователя.", + ) + .await?; + return Ok(()); + }; + + if let Some(file) = get_file_id(msg).await { + match get_cached(&bot, &file, config).await { + Ok(cache_entry) => { + let cache = config.get_redis_client(); + let message_file_map_key = format!("message_file_map:{}", message.id); + cache + .set(&message_file_map_key, &file.file_unique_id, 3600) + .await?; + + let text_parts = split_text(&cache_entry.full_text, 4000); + if text_parts.is_empty() { + bot.edit_message_text(message.chat.id, message.id, "❌ Получен пустой текст.") + .await?; + return Ok(()); + } + + let keyboard = create_transcription_keyboard(0, text_parts.len(), user.id.0); + bot.edit_message_text( + msg.chat.id, + message.id, + format!("
{}
", text_parts[0]), + ) + .parse_mode(ParseMode::Html) + .reply_markup(keyboard) + .await?; + } + Err(e) => { + error!("Failed to get transcription: {:?}", e); + let error_text = match e { + MyError::Other(msg) if msg.contains("Не удалось преобразовать") => { + msg + } + MyError::Reqwest(_) => { + "❌ Ошибка: Не удалось скачать файл. Возможно, он слишком большой (>20MB)." + .to_string() + } + _ => "❌ Произошла неизвестная ошибка при обработке аудио.".to_string(), + }; + bot.edit_message_text(message.chat.id, message.id, error_text) + .await?; + } + } + } else { + bot.edit_message_text( + message.chat.id, + message.id, + "Не удалось найти голосовое сообщение.", + ) + .parse_mode(ParseMode::Html) + .await?; + } + Ok(()) +} + +pub async fn summarize_audio( + mime_type: String, + data: Bytes, + config: Config, +) -> Result { + let mut settings = Settings::new(); + settings.set_all_safety_settings(HarmBlockThreshold::BlockNone); + + let ai_model = config.get_json_config().get_ai_model().to_owned(); + let prompt = config.get_json_config().get_summarize_prompt().to_owned(); + + let mut context = Context::new(); + context.push_message(Role::Model, prompt); + + let mut client = GemSession::Builder() + .model(Models::Custom(ai_model)) + .timeout(Some(Duration::from_secs(120))) + .context(context) + .build(); + + let response = client + .send_blob(Blob::new(&mime_type, &data), Role::User, &settings) + .await?; + + Ok(response + .get_results() + .first() + .cloned() + .unwrap_or_else(|| "Не удалось получить краткое содержание.".to_string())) +} + +pub async fn get_file_id(msg: &Message) -> Option { + match &msg.kind { + MessageKind::Common(common) => match &common.media_kind { + teloxide::types::MediaKind::Audio(audio) => Some(AudioStruct { + mime_type: audio.audio.mime_type.as_ref()?.essence_str().to_owned(), + file_id: audio.audio.file.id.0.to_string(), + file_unique_id: audio.audio.file.unique_id.0.to_string(), + }), + teloxide::types::MediaKind::Voice(voice) => Some(AudioStruct { + mime_type: voice.voice.mime_type.as_ref()?.essence_str().to_owned(), + file_id: voice.voice.file.id.0.to_owned(), + file_unique_id: voice.voice.file.unique_id.0.to_owned(), + }), + teloxide::types::MediaKind::VideoNote(video_note) => Some(AudioStruct { + mime_type: "video/mp4".to_owned(), + file_id: video_note.video_note.file.id.0.to_owned(), + file_unique_id: video_note.video_note.file.unique_id.0.to_owned(), + }), + _ => None, + }, + _ => None, + } +} + +pub async fn save_file_to_memory(bot: &Bot, file_id: &str) -> Result { + let file = bot.get_file(FileId(file_id.to_string())).send().await?; + let file_url = format!( + "https://api.telegram.org/file/bot{}/{}", + bot.token(), + file.path + ); + let response = reqwest::get(file_url).await?; + Ok(response.bytes().await?) +} + +impl Transcription { + pub async fn to_text(&self) -> Vec { + let mut settings = Settings::new(); + settings.set_all_safety_settings(HarmBlockThreshold::BlockNone); + + let error_answer = "❌ Не удалось преобразовать текст из сообщения.".to_string(); + let ai_model = self.config.get_json_config().get_ai_model().to_owned(); + let prompt = self.config.get_json_config().get_ai_prompt().to_owned(); + + let mut context = Context::new(); + context.push_message(Role::Model, prompt); + + let mut client = GemSession::Builder() + .model(Models::Custom(ai_model)) + .timeout(Some(Duration::from_secs(120))) + .context(context) + .build(); + + let mut attempts = 0; + let mut last_error = String::new(); + + while attempts < 3 { + match client + .send_blob( + Blob::new(&self.mime_type, &self.data), + Role::User, + &settings, + ) + .await + { + Ok(response) => { + let full_text = response.get_results().first().cloned().unwrap_or_default(); + if !full_text.is_empty() { + return split_text(&full_text, 4000); + } + attempts += 1; + info!("Received empty response, attempt {}", attempts); + } + Err(error) => { + attempts += 1; + let error_string = error.to_string(); + if error_string == last_error { + continue; + } + last_error = error_string; + error!("Transcription error (attempt {}): {:?}", attempts, error); + } + } + } + vec![error_answer + "\n\n" + &last_error] + } +} diff --git a/src/core/services/transcription.rs b/src/core/services/transcription.rs deleted file mode 100644 index ec091b2..0000000 --- a/src/core/services/transcription.rs +++ /dev/null @@ -1,266 +0,0 @@ -use crate::bot::keyboards::transcription::create_transcription_keyboard; -use crate::core::config::Config; -use crate::errors::MyError; -use crate::util::enums::AudioStruct; -use bytes::Bytes; -use gem_rs::api::Models; -use gem_rs::client::GemSession; -use gem_rs::types::{Blob, Context, HarmBlockThreshold, Role, Settings}; -use log::{debug, error, info}; -use redis_macros::{FromRedisValue, ToRedisArgs}; -use serde::{Deserialize, Serialize}; -use std::time::Duration; -use teloxide::prelude::*; -use teloxide::types::{FileId, MessageKind, ParseMode, ReplyParameters}; - -#[derive(Debug, Serialize, Deserialize, FromRedisValue, ToRedisArgs, Clone)] -pub struct TranscriptionCache { - pub full_text: String, - pub summary: Option, - pub file_id: String, - pub mime_type: String, -} - -async fn get_cached( - bot: &Bot, - file: &AudioStruct, - config: &Config, -) -> Result { - let cache = config.get_redis_client(); - let file_cache_key = format!("transcription_by_file:{}", &file.file_unique_id); - - if let Some(cached_text) = cache.get::(&file_cache_key).await? { - debug!("File cache HIT for unique_id: {}", &file.file_unique_id); - return Ok(cached_text); - } - - let file_data = save_file_to_memory(bot, &file.file_id).await?; - let transcription = Transcription { - mime_type: file.mime_type.to_string(), - data: file_data, - config: config.clone(), - }; - let processed_parts = transcription.to_text().await; - - if processed_parts.is_empty() || processed_parts[0].contains("Не удалось преобразовать") { - let error_message = processed_parts.first().cloned().unwrap_or_default(); - return Err(MyError::Other(error_message)); - } - - let full_text = processed_parts.join("\n\n"); - let new_cache_entry = TranscriptionCache { - full_text, - summary: None, - file_id: file.file_id.clone(), - mime_type: file.mime_type.clone(), - }; - - cache.set(&file_cache_key, &new_cache_entry, 86400).await?; - debug!( - "Saved new transcription to file cache for unique_id: {}", - file.file_id - ); - - Ok(new_cache_entry) -} - -pub async fn transcription_handler(bot: Bot, msg: Message, config: &Config) -> Result<(), MyError> { - let message = bot - .send_message(msg.chat.id, "Обрабатываю аудио...") - .reply_parameters(ReplyParameters::new(msg.id)) - .parse_mode(ParseMode::Html) - .await - .ok(); - - let Some(message) = message else { return Ok(()) }; - let Some(user) = msg.from.as_ref() else { - bot.edit_message_text(message.chat.id, message.id, "Не удалось определить пользователя.").await?; - return Ok(()); - }; - - if let Some(file) = get_file_id(&msg).await { - match get_cached(&bot, &file, config).await { - Ok(cache_entry) => { - let cache = config.get_redis_client(); - let message_file_map_key = format!("message_file_map:{}", message.id); - cache - .set(&message_file_map_key, &file.file_unique_id, 3600) - .await?; - - let text_parts = split_text(&cache_entry.full_text, 4000); - if text_parts.is_empty() { - bot.edit_message_text(message.chat.id, message.id, "❌ Получен пустой текст.") - .await?; - return Ok(()); - } - - let keyboard = create_transcription_keyboard(0, text_parts.len(), user.id.0); - bot.edit_message_text( - msg.chat.id, - message.id, - format!("
{}
", text_parts[0]), - ) - .parse_mode(ParseMode::Html) - .reply_markup(keyboard) - .await?; - } - Err(e) => { - error!("Failed to get transcription: {:?}", e); - let error_text = match e { - MyError::Other(msg) if msg.contains("Не удалось преобразовать") => msg, - MyError::Reqwest(_) => { - "❌ Ошибка: Не удалось скачать файл. Возможно, он слишком большой (>20MB)." - .to_string() - } - _ => "❌ Произошла неизвестная ошибка при обработке аудио.".to_string(), - }; - bot.edit_message_text(message.chat.id, message.id, error_text) - .await?; - } - } - } else { - bot.edit_message_text( - message.chat.id, - message.id, - "Не удалось найти голосовое сообщение.", - ) - .parse_mode(ParseMode::Html) - .await?; - } - Ok(()) -} - -pub async fn summarize_audio( - mime_type: String, - data: Bytes, - config: Config, -) -> Result { - let mut settings = Settings::new(); - settings.set_all_safety_settings(HarmBlockThreshold::BlockNone); - - let ai_model = config.get_json_config().get_ai_model().to_owned(); - let prompt = config.get_json_config().get_summarize_prompt().to_owned(); - - let mut context = Context::new(); - context.push_message(Role::Model, prompt); - - let mut client = GemSession::Builder() - .model(Models::Custom(ai_model)) - .timeout(Some(Duration::from_secs(120))) - .context(context) - .build(); - - let response = client - .send_blob(Blob::new(&mime_type, &data), Role::User, &settings) - .await?; - - Ok(response - .get_results() - .first() - .cloned() - .unwrap_or_else(|| "Не удалось получить краткое содержание.".to_string())) -} - -pub async fn get_file_id(msg: &Message) -> Option { - match &msg.kind { - MessageKind::Common(common) => match &common.media_kind { - teloxide::types::MediaKind::Audio(audio) => Some(AudioStruct { - mime_type: audio.audio.mime_type.as_ref()?.essence_str().to_owned(), - file_id: audio.audio.file.id.0.to_string(), - file_unique_id: audio.audio.file.unique_id.0.to_string(), - }), - teloxide::types::MediaKind::Voice(voice) => Some(AudioStruct { - mime_type: voice.voice.mime_type.as_ref()?.essence_str().to_owned(), - file_id: voice.voice.file.id.0.to_owned(), - file_unique_id: voice.voice.file.unique_id.0.to_owned(), - }), - teloxide::types::MediaKind::VideoNote(video_note) => Some(AudioStruct { - mime_type: "video/mp4".to_owned(), - file_id: video_note.video_note.file.id.0.to_owned(), - file_unique_id: video_note.video_note.file.unique_id.0.to_owned(), - }), - _ => None, - }, - _ => None, - } -} - -pub async fn save_file_to_memory(bot: &Bot, file_id: &str) -> Result { - let file = bot - .get_file(FileId(file_id.to_string())) - .send() - .await?; - let file_url = format!( - "https://api.telegram.org/file/bot{}/{}", - bot.token(), - file.path - ); - let response = reqwest::get(file_url).await?; - Ok(response.bytes().await?) -} - -pub struct Transcription { - pub(crate) mime_type: String, - pub(crate) data: Bytes, - pub(crate) config: Config, -} - -impl Transcription { - pub async fn to_text(&self) -> Vec { - let mut settings = Settings::new(); - settings.set_all_safety_settings(HarmBlockThreshold::BlockNone); - - let error_answer = "❌ Не удалось преобразовать текст из сообщения.".to_string(); - let ai_model = self.config.get_json_config().get_ai_model().to_owned(); - let prompt = self.config.get_json_config().get_ai_prompt().to_owned(); - - let mut context = Context::new(); - context.push_message(Role::Model, prompt); - - let mut client = GemSession::Builder() - .model(Models::Custom(ai_model)) - .timeout(Some(Duration::from_secs(120))) - .context(context) - .build(); - - let mut attempts = 0; - let mut last_error = String::new(); - - while attempts < 3 { - match client - .send_blob(Blob::new(&self.mime_type, &self.data), Role::User, &settings) - .await - { - Ok(response) => { - let full_text = response.get_results().first().cloned().unwrap_or_default(); - if !full_text.is_empty() { - return split_text(&full_text, 4000); - } - attempts += 1; - info!("Received empty response, attempt {}", attempts); - } - Err(error) => { - attempts += 1; - let error_string = error.to_string(); - if error_string == last_error { - continue; - } - last_error = error_string; - error!("Transcription error (attempt {}): {:?}", attempts, error); - } - } - } - vec![error_answer + "\n\n" + &last_error] - } -} - -pub fn split_text(text: &str, chunk_size: usize) -> Vec { - if text.is_empty() { - return vec![]; - } - text.chars() - .collect::>() - .chunks(chunk_size) - .map(|c| c.iter().collect()) - .collect() -} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index bc9c4db..811da79 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,4 @@ pub mod bot; pub mod core; pub mod errors; -pub mod util; \ No newline at end of file +pub mod util; diff --git a/src/core/services/currency/structs.rs b/src/util/currency_values.rs similarity index 100% rename from src/core/services/currency/structs.rs rename to src/util/currency_values.rs diff --git a/src/util/enums.rs b/src/util/enums.rs index 8f6a80e..eb0c14f 100644 --- a/src/util/enums.rs +++ b/src/util/enums.rs @@ -9,11 +9,7 @@ pub enum Command { SpeechRecognition, #[command(description = "Translate", alias = "tr")] Translate(String), - #[command(parse_with = "split", description = "Set currency to convert")] - SetCurrency { code: String }, - #[command(description = "List of available currencies to convert")] - ListCurrency, - #[command(description = "Settings of bot")] + #[command(description = "Bot settings")] Settings, } diff --git a/src/util/mod.rs b/src/util/mod.rs index 0b0c902..58941ce 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,2 +1,14 @@ +pub mod currency_values; pub mod enums; pub mod paginator; + +pub fn split_text(text: &str, chunk_size: usize) -> Vec { + if text.is_empty() { + return vec![]; + } + text.chars() + .collect::>() + .chunks(chunk_size) + .map(|c| c.iter().collect()) + .collect() +} diff --git a/src/util/paginator.rs b/src/util/paginator.rs index 7a252da..2a3577c 100644 --- a/src/util/paginator.rs +++ b/src/util/paginator.rs @@ -1,88 +1,97 @@ use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup}; pub struct Paginator<'a, T> { - module_key: &'a str, - items: &'a [T], - per_page: usize, - columns: usize, - current_page: usize, - bottom_rows: Vec>, - callback_prefix: String, + total_items: Option, + callback_formatter: Option String + 'a>>, +} + +pub trait ItemsBuild<'a, T> { + fn build(&self, button_mapper: F) -> InlineKeyboardMarkup + where + F: Fn(&T) -> InlineKeyboardButton; +} + +pub trait FrameBuild { + fn build(&self) -> InlineKeyboardMarkup; +} + +impl<'a> Paginator<'a, ()> { + pub fn new(module_key: &'a str, total_items: usize) -> Self { + Self { + items: &[], + total_items: Some(total_items), + per_page: 1, + columns: 1, + current_page: 0, + bottom_rows: Vec::new(), + callback_prefix: module_key.to_string(), + callback_formatter: None, + } + } } impl<'a, T> Paginator<'a, T> { - pub fn new(module_key: &'a str, items: &'a [T]) -> Self { + pub fn from(module_key: &'a str, items: &'a [T]) -> Self { Self { - module_key, items, per_page: 12, columns: 3, current_page: 0, bottom_rows: Vec::new(), callback_prefix: module_key.to_string(), + total_items: None, + callback_formatter: None, } } +} - pub fn per_page(mut self, per_page: usize) -> Self { - self.per_page = per_page; - self - } - - pub fn columns(mut self, columns: usize) -> Self { - self.columns = columns; - self - } - - pub fn current_page(mut self, page: usize) -> Self { - self.current_page = page; - self - } - - pub fn add_bottom_row(mut self, row: Vec) -> Self { - self.bottom_rows.push(row); - self - } - - pub fn set_callback_prefix(mut self, prefix: String) -> Self { - self.callback_prefix = prefix; - self - } - - pub fn build(&self, button_mapper: F) -> InlineKeyboardMarkup +impl<'a, T> ItemsBuild<'a, T> for Paginator<'a, T> { + fn build(&self, button_mapper: F) -> InlineKeyboardMarkup where F: Fn(&T) -> InlineKeyboardButton, { - let total_items = self.items.len(); + let total_items = self.total_items.unwrap_or(self.items.len()); if total_items == 0 { return InlineKeyboardMarkup::new(self.bottom_rows.clone()); } - let total_pages = (total_items + self.per_page - 1) / self.per_page; + // let total_pages = (total_items + self.per_page - 1) / self.per_page; + let total_pages = total_items.div_ceil(self.per_page); let page = self.current_page.min(total_pages - 1); - let start = page * self.per_page; - let end = (start + self.per_page).min(total_items); - let page_items = &self.items[start..end]; + let mut keyboard: Vec> = if !self.items.is_empty() { + let start = page * self.per_page; + let end = (start + self.per_page).min(self.items.len()); + + let page_items = &self.items[start..end]; - let mut keyboard: Vec> = page_items - .iter() - .map(button_mapper) - .collect::>() - .chunks(self.columns) - .map(|chunk| chunk.to_vec()) - .collect(); + page_items + .iter() + .map(button_mapper) + .collect::>() + .chunks(self.columns) + .map(|chunk| chunk.to_vec()) + .collect() + } else { + Vec::new() + }; let mut nav_row = Vec::new(); if page > 0 { + let prev_page = page - 1; + nav_row.push(InlineKeyboardButton::callback( "⬅️", - format!("{}:page:{}", self.module_key, page - 1), + self.callback_formatter.as_ref().map_or_else( + || format!("{}:page:{}", self.callback_prefix, prev_page), + |f| f(prev_page), + ), )); } @@ -92,9 +101,14 @@ impl<'a, T> Paginator<'a, T> { )); if page + 1 < total_pages { + let next_page = page + 1; + nav_row.push(InlineKeyboardButton::callback( "➡️", - format!("{}:page:{}", self.module_key, page + 1), + self.callback_formatter.as_ref().map_or_else( + || format!("{}:page:{}", self.callback_prefix, next_page), + |f| f(next_page), + ), )); } @@ -107,3 +121,49 @@ impl<'a, T> Paginator<'a, T> { InlineKeyboardMarkup::new(keyboard) } } + +impl<'a> FrameBuild for Paginator<'a, ()> { + fn build(&self) -> InlineKeyboardMarkup { + ItemsBuild::build(self, |_| unreachable!()) + } +} + +impl<'a, T> Paginator<'a, T> { + pub fn per_page(mut self, per_page: usize) -> Self { + self.per_page = per_page; + self + } + + pub fn columns(mut self, columns: usize) -> Self { + self.columns = columns; + self + } + + pub fn current_page(mut self, page: usize) -> Self { + self.current_page = page; + self + } + + pub fn add_bottom_row(mut self, row: Vec) -> Self { + self.bottom_rows.push(row); + self + } + + pub fn set_callback_prefix(mut self, prefix: String) -> Self { + self.callback_prefix = prefix; + self + } + + pub fn set_callback_formatter(mut self, formatter: F) -> Self + where + F: Fn(usize) -> String + 'a, + { + self.callback_formatter = Some(Box::new(formatter)); + self + } + + pub fn set_total_items(mut self, total: usize) -> Self { + self.total_items = Some(total); + self + } +} From 32112d0e1ff6f61bb7df281129c96ab636f7d47d Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Wed, 17 Sep 2025 22:32:14 +0300 Subject: [PATCH 49/66] removed get_url_by_hash and set_url_by_hash_mapping because useless --- src/core/db/redis.rs | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/src/core/db/redis.rs b/src/core/db/redis.rs index 9aefdea..c66ef33 100644 --- a/src/core/db/redis.rs +++ b/src/core/db/redis.rs @@ -50,22 +50,4 @@ impl RedisCache { Ok(result.and_then(|s| serde_json::from_str(&s).ok())) } - - #[allow(dead_code)] - pub async fn set_url_hash_mapping( - &self, - url_hash: &str, - original_url: &str, - ttl_seconds: usize, - ) -> Result<(), RedisError> { - let key = format!("url_hash:{}", url_hash); - self.set(&key, &original_url.to_string(), ttl_seconds).await - } - - // todo: remove them on pre-release stage - #[allow(dead_code)] - pub async fn get_url_by_hash(&self, url_hash: &str) -> Result, RedisError> { - let key = format!("url_hash:{}", url_hash); - self.get(&key).await - } } From 8dea99173fcb5b1ce6deb9117fa67525ec1272f7 Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Wed, 17 Sep 2025 23:12:26 +0300 Subject: [PATCH 50/66] =?UTF-8?q?fixed=20google=20translation=20issue=20wi?= =?UTF-8?q?th=20limit=20via=20SYMBOL=20LIMITTT=F0=9F=A4=99=F0=9F=A4=99?= =?UTF-8?q?=F0=9F=A4=99=F0=9F=A4=99=F0=9F=A4=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/bot/commands/translate.rs | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/src/bot/commands/translate.rs b/src/bot/commands/translate.rs index 84b78b1..38d728b 100644 --- a/src/bot/commands/translate.rs +++ b/src/bot/commands/translate.rs @@ -74,14 +74,41 @@ pub async fn translate_handler( } } + if text_to_translate.trim().is_empty() { + bot.send_message(msg.chat.id, "В сообщении нет текста для перевода.") + .reply_parameters(ReplyParameters::new(msg.id)) + .parse_mode(ParseMode::Html) + .await?; + return Ok(()); + } + + if text_to_translate.trim().len() >= 3000 { // amm guys? I'm... I'm good at fixing issues... I did it because: + // 1. Google Translator API has a limit of 3100+ characters + // 2. I'm lazy to do paginator 🤙🤙🤙 + bot.send_message(msg.chat.id, "Текст превышает лимит в 3000 символов.") + .reply_parameters(ReplyParameters::new(msg.id)) + .parse_mode(ParseMode::Html) + .await?; + return Ok(()); + } + + if target_lang.is_empty() { + bot.send_message( + msg.chat.id, + "Не удалось распознать язык. Пожалуйста, укажите корректный язык.", + ) + .reply_parameters(ReplyParameters::new(msg.id)) + .parse_mode(ParseMode::Html) + .await?; + return Ok(()); + } + let google_trans = GoogleTranslator::builder() .text_limit(12000usize) .delay(3usize) .timeout(50usize) .build(); - // FIXME: Google Translator limit is up to ~3100 characters. We must think workaround for avoiding "panic" error - // log::info!("text: {}", text_to_translate); let res = google_trans .translate_async(text_to_translate, "", &target_lang) .await?; From e9144b7fc4a20fd22cd22346324e44c6d0aa5905 Mon Sep 17 00:00:00 2001 From: nixxoq Date: Thu, 18 Sep 2025 00:32:24 +0300 Subject: [PATCH 51/66] fix(callbacks/mod): delete confirmation don't work properly after major refactoring from me --- src/bot/callbacks/mod.rs | 4 ++-- src/util/mod.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/bot/callbacks/mod.rs b/src/bot/callbacks/mod.rs index 0c996c2..dd2d1fc 100644 --- a/src/bot/callbacks/mod.rs +++ b/src/bot/callbacks/mod.rs @@ -91,7 +91,7 @@ fn parse_callback_data(data: &'_ str) -> Option> { if data.starts_with("delete_msg") { return Some(CallbackAction::DeleteMessage); } - if data.starts_with("delete_confirmation:") { + if data.starts_with("delete_confirm:") { return Some(CallbackAction::DeleteConfirmation); } if data.starts_with("summarize") { @@ -106,7 +106,7 @@ fn parse_callback_data(data: &'_ str) -> Option> { if data.starts_with("whisper") { return Some(CallbackAction::Whisper); } - if data.starts_with("tr_") { + if data.starts_with("tr_") || data.starts_with("tr:") { return Some(CallbackAction::Translate); } if data.starts_with("cobalt:") { diff --git a/src/util/mod.rs b/src/util/mod.rs index 58941ce..8444b86 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -11,4 +11,4 @@ pub fn split_text(text: &str, chunk_size: usize) -> Vec { .chunks(chunk_size) .map(|c| c.iter().collect()) .collect() -} +} \ No newline at end of file From c3bfdc6d34e1ce301a1a80b3ef05fdba7fb0b2da Mon Sep 17 00:00:00 2001 From: nixxoq Date: Thu, 18 Sep 2025 00:32:41 +0300 Subject: [PATCH 52/66] chore(cargo): add futures crate --- Cargo.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index db872dd..f4fd71d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,4 +29,5 @@ redis-macros = { version = "0.5.6" } url = "2.5.4" base64 = "0.22.1" mime = "0.3.17" -md5 = "0.7.0" \ No newline at end of file +md5 = "0.7.0" +futures = "0.3.31" \ No newline at end of file From 7cc7f7134772a3f736c30ad68b6d287d0081c1ef Mon Sep 17 00:00:00 2001 From: nixxoq Date: Thu, 18 Sep 2025 00:36:38 +0300 Subject: [PATCH 53/66] feat(translator): split message into chunks and do the translation a bit lazy as before --- src/bot/callbacks/translate.rs | 294 +++++++++++++++++++++++---------- src/bot/commands/translate.rs | 170 +++++++++++++------ src/util/mod.rs | 2 +- 3 files changed, 328 insertions(+), 138 deletions(-) diff --git a/src/bot/callbacks/translate.rs b/src/bot/callbacks/translate.rs index af018be..8bcb4ea 100644 --- a/src/bot/callbacks/translate.rs +++ b/src/bot/callbacks/translate.rs @@ -1,60 +1,124 @@ use crate::{ - bot::keyboards::{delete::delete_message_button, translate::create_language_keyboard}, + bot::{ + commands::translate::{TranslateJob, TranslationCache, split_text_tr}, + keyboards::{delete::delete_message_button, translate::create_language_keyboard}, + }, core::{ config::Config, services::translation::{SUPPORTED_LANGUAGES, normalize_language_code}, }, errors::MyError, + util::paginator::{FrameBuild, Paginator}, }; +use futures::future::join_all; use teloxide::{ - ApiError, Bot, RequestError, - payloads::{EditMessageReplyMarkupSetters, EditMessageTextSetters}, - prelude::Requester, - types::{CallbackQuery, InlineKeyboardButton, MaybeInaccessibleMessage, Message, ParseMode}, + ApiError, RequestError, + prelude::*, + types::{ + CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, MaybeInaccessibleMessage, + Message, ParseMode, + }, utils::html::escape, }; use translators::{GoogleTranslator, Translator}; +use uuid::Uuid; pub async fn handle_translate_callback( bot: Bot, q: CallbackQuery, config: &Config, ) -> Result<(), MyError> { - let callback_id = q.id.clone(); - if let (Some(data), Some(MaybeInaccessibleMessage::Regular(message))) = (&q.data, &q.message) { - bot.answer_callback_query(callback_id).await?; + bot.answer_callback_query(q.id.clone()).await?; - if data.starts_with("tr_page:") { - handle_pagination(bot, message, data).await?; + if let Some(rest) = data.strip_prefix("tr:page:") { + let parts: Vec<_> = rest.split(':').collect(); + if parts.len() == 2 { + let translation_id = parts[0]; + if let Ok(page) = parts[1].parse::() { + handle_translation_pagination(&bot, message, translation_id, page, config) + .await?; + } + } + } else if data.starts_with("tr_page:") { + handle_language_menu_pagination(bot, message, data).await?; } else if data.starts_with("tr_lang:") { handle_language_selection(bot, message, data, q.from.clone(), config).await?; } else if data == "tr_show_langs" { - handle_show_languages(bot, message).await?; + handle_show_languages(&bot, message, &q.from, config).await?; } } else { - bot.answer_callback_query(callback_id).await?; + bot.answer_callback_query(q.id).await?; } Ok(()) } -async fn handle_pagination(bot: Bot, message: &Message, data: &str) -> Result<(), MyError> { - match data.trim_start_matches("tr_page:").parse::() { - Ok(page) => { - let keyboard = create_language_keyboard(page); - if let Err(e) = bot - .edit_message_reply_markup(message.chat.id, message.id) - .reply_markup(keyboard) - .await - { - if let RequestError::Api(ApiError::MessageNotModified) = e { - } else { - return Err(MyError::from(e)); - } - } - } - Err(_) => { - log::warn!("Failed to parse page number from: {}", data); +async fn handle_translation_pagination( + bot: &Bot, + message: &Message, + translation_id: &str, + page: usize, + config: &Config, +) -> Result<(), MyError> { + let redis_key = format!("translation:{}", translation_id); + let cache: Option = config.get_redis_client().get(&redis_key).await?; + + if let Some(cache_data) = cache { + let lang_display_name = SUPPORTED_LANGUAGES + .iter() + .find(|(code, _)| *code == cache_data.target_lang) + .map(|(_, name)| *name) + .unwrap_or(&cache_data.target_lang); + + let switch_lang_button = + InlineKeyboardButton::callback(lang_display_name.to_string(), "tr_show_langs"); + + let delete_button = delete_message_button(cache_data.user_id) + .inline_keyboard + .remove(0) + .remove(0); + + let keyboard = Paginator::new("tr", cache_data.pages.len()) + .current_page(page) + .set_callback_formatter(move |p| format!("tr:page:{}:{}", translation_id, p)) + .add_bottom_row(vec![switch_lang_button, delete_button]) + .build(); + + let new_text = format!( + "
{}
", + escape(cache_data.pages.get(page).unwrap_or(&"".to_string())) + ); + + bot.edit_message_text(message.chat.id, message.id, new_text) + .parse_mode(ParseMode::Html) + .reply_markup(keyboard) + .await?; + } else { + bot.edit_message_text( + message.chat.id, + message.id, + "Срок действия кеша перевода истек. Пожалуйста, переведите заново.", + ) + .reply_markup(InlineKeyboardMarkup::new(vec![vec![]])) + .await?; + } + Ok(()) +} + +async fn handle_language_menu_pagination( + bot: Bot, + message: &Message, + data: &str, +) -> Result<(), MyError> { + if let Ok(page) = data.trim_start_matches("tr_page:").parse::() { + let keyboard = create_language_keyboard(page); + if let Err(e) = bot + .edit_message_reply_markup(message.chat.id, message.id) + .reply_markup(keyboard) + .await + && !matches!(e, RequestError::Api(ApiError::MessageNotModified)) + { + return Err(MyError::from(e)); } } Ok(()) @@ -68,96 +132,144 @@ async fn handle_language_selection( config: &Config, ) -> Result<(), MyError> { let target_lang = data.trim_start_matches("tr_lang:"); + let redis_client = config.get_redis_client(); - let original_message = match message.reply_to_message() { - Some(msg) => msg, - None => { - bot.edit_message_text( - message.chat.id, - message.id, - "Ошибка: не удалось найти исходное сообщение. Попробуйте снова.", - ) - .await?; - return Ok(()); - } - }; + let redis_key_job = format!("translate_job:{}", user.id); + let job: Option = redis_client.get_and_delete(&redis_key_job).await?; - let text_to_translate = match original_message.text() { - Some(text) => text, - None => { - bot.edit_message_text( - message.chat.id, - message.id, - "В исходном сообщении нет текста для перевода.", - ) - .await?; - return Ok(()); - } + let Some(job) = job else { + bot.edit_message_text( + message.chat.id, + message.id, + "Задача на перевод устарела. Пожалуйста, запросите перевод снова.", + ) + .await?; + return Ok(()); }; - let redis_key = format!("user_lang:{}", user.id); - let redis_client = config.get_redis_client(); - let ttl_seconds = 2 * 60 * 60; + let text_to_translate = &job.text; + + let redis_key_user_lang = format!("user_lang:{}", user.id); redis_client - .set(&redis_key, &target_lang.to_string(), ttl_seconds) + .set(&redis_key_user_lang, &target_lang.to_string(), 7200) .await?; let normalized_lang = normalize_language_code(target_lang); + + let text_chunks = split_text_tr(text_to_translate, 2800); let google_trans = GoogleTranslator::default(); + let translation_futures = text_chunks + .iter() + .map(|chunk| google_trans.translate_async(chunk, "", &normalized_lang)); - // FIXME: Google Translator limit is up to ~3100 characters. We must think workaround for avoiding "panic" error - // log::info!("text: {}", text_to_translate); - let res = google_trans - .translate_async(text_to_translate, "", &normalized_lang) - .await?; + let results = join_all(translation_futures).await; + let translated_chunks: Vec = results.into_iter().filter_map(Result::ok).collect(); + let full_translated_text = translated_chunks.join("\n\n"); - let response = format!("
{}\n
", escape(&res)); + if full_translated_text.is_empty() { + bot.edit_message_text( + message.chat.id, + message.id, + "Не удалось перевести текст. Возможно, API временно недоступен.", + ) + .await?; + return Ok(()); + } + let display_pages = split_text_tr(&full_translated_text, 4000); let lang_display_name = SUPPORTED_LANGUAGES .iter() .find(|(code, _)| *code == normalized_lang) .map(|(_, name)| *name) .unwrap_or(&normalized_lang); - let switch_lang_button = - InlineKeyboardButton::callback(lang_display_name.to_string(), "tr_show_langs".to_string()); - - let mut keyboard = delete_message_button(user.id.0); - match keyboard.inline_keyboard.get_mut(0) { - Some(first_row) => { + if display_pages.len() <= 1 { + let response = format!("
{}
", escape(&full_translated_text)); + let switch_lang_button = + InlineKeyboardButton::callback(lang_display_name.to_string(), "tr_show_langs"); + let mut keyboard = delete_message_button(user.id.0); + if let Some(first_row) = keyboard.inline_keyboard.get_mut(0) { first_row.insert(0, switch_lang_button); - } - None => { + } else { keyboard.inline_keyboard.push(vec![switch_lang_button]); } - } + bot.edit_message_text(message.chat.id, message.id, response) + .parse_mode(ParseMode::Html) + .reply_markup(keyboard) + .await?; + } else { + let translation_id = Uuid::new_v4().to_string(); + let redis_key = format!("translation:{}", translation_id); + let cache_data = TranslationCache { + pages: display_pages.clone(), + user_id: user.id.0, + original_url: None, + target_lang: target_lang.to_string(), + }; + redis_client.set(&redis_key, &cache_data, 3600).await?; - if let Err(e) = bot - .edit_message_text(message.chat.id, message.id, response) - .parse_mode(ParseMode::Html) - .reply_markup(keyboard) - .await - { - if let RequestError::Api(ApiError::MessageNotModified) = e { - } else { - return Err(MyError::from(e)); - } + let switch_lang_button = + InlineKeyboardButton::callback(lang_display_name.to_string(), "tr_show_langs"); + let delete_button = delete_message_button(user.id.0) + .inline_keyboard + .remove(0) + .remove(0); + + let keyboard = Paginator::new("tr", display_pages.len()) + .current_page(0) + .set_callback_formatter(move |page| format!("tr:page:{}:{}", translation_id, page)) + .add_bottom_row(vec![switch_lang_button, delete_button]) + .build(); + + let response_text = format!("
{}
", escape(&display_pages[0])); + bot.edit_message_text(message.chat.id, message.id, response_text) + .parse_mode(ParseMode::Html) + .reply_markup(keyboard) + .await?; } Ok(()) } -async fn handle_show_languages(bot: Bot, message: &Message) -> Result<(), MyError> { - let keyboard = create_language_keyboard(0); - if let Err(e) = bot - .edit_message_text(message.chat.id, message.id, "Выберите язык для перевода:") - .reply_markup(keyboard) - .await - { - if let RequestError::Api(ApiError::MessageNotModified) = e { - } else { - return Err(MyError::from(e)); +async fn handle_show_languages( + bot: &Bot, + message: &Message, + user: &teloxide::types::User, + config: &Config, +) -> Result<(), MyError> { + if let Some(original_message) = message.reply_to_message() { + if let Some(text) = original_message + .text() + .or_else(|| original_message.caption()) + { + let job = TranslateJob { + text: text.to_string(), + user_id: user.id.0, + }; + let redis_key_job = format!("translate_job:{}", user.id); + config + .get_redis_client() + .set(&redis_key_job, &job, 600) + .await?; } + } else { + bot.edit_message_text( + message.chat.id, + message.id, + "Не удалось найти исходное сообщение для смены языка.", + ) + .await?; + return Ok(()); } + + let keyboard = create_language_keyboard(0); + bot.edit_message_text( + message.chat.id, + message.id, + "Выберите новый язык для перевода:", + ) + .reply_markup(keyboard) + .await?; + Ok(()) } diff --git a/src/bot/commands/translate.rs b/src/bot/commands/translate.rs index 38d728b..6ec34cf 100644 --- a/src/bot/commands/translate.rs +++ b/src/bot/commands/translate.rs @@ -5,13 +5,61 @@ use crate::{ services::translation::{SUPPORTED_LANGUAGES, normalize_language_code}, }, errors::MyError, + util::paginator::{FrameBuild, Paginator}, }; +use futures::future::join_all; +use serde::{Deserialize, Serialize}; use teloxide::{ prelude::*, types::{InlineKeyboardButton, Message, ParseMode, ReplyParameters}, utils::html::escape, }; use translators::{GoogleTranslator, Translator}; +use uuid::Uuid; + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct TranslationCache { + pub(crate) pages: Vec, + pub(crate) user_id: u64, + pub(crate) original_url: Option, + pub(crate) target_lang: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct TranslateJob { + pub text: String, + pub user_id: u64, +} + +pub fn split_text_tr(text: &str, chunk_size: usize) -> Vec { + if text.len() <= chunk_size { + return vec![text.to_string()]; + } + + let mut chunks = Vec::new(); + let mut current_chunk = String::with_capacity(chunk_size); + + for paragraph in text.split("\n\n") { + if current_chunk.len() + paragraph.len() + 2 > chunk_size && !current_chunk.is_empty() { + chunks.push(current_chunk.trim().to_string()); + current_chunk.clear(); + } + if paragraph.len() > chunk_size { + for part in paragraph.chars().collect::>().chunks(chunk_size) { + chunks.push(part.iter().collect()); + } + } else { + current_chunk.push_str(paragraph); + current_chunk.push_str("\n\n"); + } + } + + if !current_chunk.is_empty() { + chunks.push(current_chunk.trim().to_string()); + } + + chunks +} pub async fn translate_handler( bot: Bot, @@ -65,55 +113,44 @@ pub async fn translate_handler( if let Some(lang) = cached_lang { target_lang = lang; } else { + let job = TranslateJob { + text: text_to_translate.to_string(), + user_id: user.id.0, + }; + + config + .get_redis_client() + .set(&format!("translate_job:{}", user.id), &job, 600) + .await?; + let keyboard = create_language_keyboard(0); bot.send_message(msg.chat.id, "Выберите язык для перевода:") .reply_markup(keyboard) .reply_parameters(ReplyParameters::new(replied_to_message.id)) .await?; + return Ok(()); } } - if text_to_translate.trim().is_empty() { - bot.send_message(msg.chat.id, "В сообщении нет текста для перевода.") - .reply_parameters(ReplyParameters::new(msg.id)) - .parse_mode(ParseMode::Html) - .await?; - return Ok(()); - } + let text_chunks = split_text_tr(text_to_translate, 2800); - if text_to_translate.trim().len() >= 3000 { // amm guys? I'm... I'm good at fixing issues... I did it because: - // 1. Google Translator API has a limit of 3100+ characters - // 2. I'm lazy to do paginator 🤙🤙🤙 - bot.send_message(msg.chat.id, "Текст превышает лимит в 3000 символов.") - .reply_parameters(ReplyParameters::new(msg.id)) - .parse_mode(ParseMode::Html) - .await?; - return Ok(()); - } + let google_trans = GoogleTranslator::default(); + let translation_futures = text_chunks + .iter() + .map(|chunk| google_trans.translate_async(chunk, "", &target_lang)); - if target_lang.is_empty() { - bot.send_message( - msg.chat.id, - "Не удалось распознать язык. Пожалуйста, укажите корректный язык.", - ) - .reply_parameters(ReplyParameters::new(msg.id)) - .parse_mode(ParseMode::Html) + let results = join_all(translation_futures).await; + let translated_chunks: Vec = results.into_iter().filter_map(Result::ok).collect(); + let full_translated_text = translated_chunks.join("\n\n"); + + if full_translated_text.is_empty() { + bot.send_message(msg.chat.id, "Не удалось перевести текст.") .await?; return Ok(()); } - let google_trans = GoogleTranslator::builder() - .text_limit(12000usize) - .delay(3usize) - .timeout(50usize) - .build(); - - let res = google_trans - .translate_async(text_to_translate, "", &target_lang) - .await?; - - let response = format!("
{}\n
", escape(&res)); + let display_pages = split_text_tr(&full_translated_text, 4000); let lang_display_name = SUPPORTED_LANGUAGES .iter() @@ -121,21 +158,62 @@ pub async fn translate_handler( .map(|(_, name)| *name) .unwrap_or(&target_lang); - let switch_lang_button = - InlineKeyboardButton::callback(lang_display_name.to_string(), "tr_show_langs".to_string()); + if display_pages.len() <= 1 { + let response = format!("
{}
", escape(&full_translated_text)); + + let switch_lang_button = + InlineKeyboardButton::callback(lang_display_name.to_string(), "tr_show_langs"); + + let mut keyboard = delete_message_button(user.id.0); + match keyboard.inline_keyboard.get_mut(0) { + Some(first_row) => { + first_row.insert(0, switch_lang_button); + } + None => { + keyboard.inline_keyboard.push(vec![switch_lang_button]); + } + } - let mut keyboard = delete_message_button(user.id.0); - if let Some(first_row) = keyboard.inline_keyboard.get_mut(0) { - first_row.insert(0, switch_lang_button); + bot.send_message(msg.chat.id, response) + .reply_parameters(ReplyParameters::new(replied_to_message.id)) + .parse_mode(ParseMode::Html) + .reply_markup(keyboard) + .await?; } else { - keyboard.inline_keyboard.push(vec![switch_lang_button]); - } + let translation_id = Uuid::new_v4().to_string(); + let redis_key = format!("translation:{}", translation_id); + + let cache_data = TranslationCache { + pages: display_pages.clone(), + user_id: user.id.0, + original_url: None, + target_lang: target_lang.to_string(), + }; + config + .get_redis_client() + .set(&redis_key, &cache_data, 3600) + .await?; - bot.send_message(msg.chat.id, response) - .reply_parameters(ReplyParameters::new(replied_to_message.id)) - .parse_mode(ParseMode::Html) - .reply_markup(keyboard) - .await?; + let switch_lang_button = + InlineKeyboardButton::callback(lang_display_name.to_string(), "tr_show_langs"); + let delete_button = delete_message_button(user.id.0) + .inline_keyboard + .remove(0) + .remove(0); + + let keyboard = Paginator::new("tr", display_pages.len()) + .current_page(0) + .set_callback_formatter(move |page| format!("tr:page:{}:{}", translation_id, page)) + .add_bottom_row(vec![switch_lang_button, delete_button]) + .build(); + + let response_text = format!("
{}
", escape(&display_pages[0])); + bot.send_message(msg.chat.id, response_text) + .reply_parameters(ReplyParameters::new(replied_to_message.id)) + .parse_mode(ParseMode::Html) + .reply_markup(keyboard) + .await?; + } Ok(()) } diff --git a/src/util/mod.rs b/src/util/mod.rs index 8444b86..58941ce 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -11,4 +11,4 @@ pub fn split_text(text: &str, chunk_size: usize) -> Vec { .chunks(chunk_size) .map(|c| c.iter().collect()) .collect() -} \ No newline at end of file +} From fa3c9e1bb6f00c44cc11854cbc96e6ec09060e40 Mon Sep 17 00:00:00 2001 From: nixxoq Date: Thu, 18 Sep 2025 12:21:56 +0300 Subject: [PATCH 54/66] feat(cobalt): show video after sending inline query result to chat (also removes thumbnail placeholder) --- src/bot/dispatcher.rs | 4 ++- src/bot/inlines/cobalter.rs | 64 +++++++++++++++++++++++++++++++------ src/core/services/cobalt.rs | 36 ++++++++++++++++----- 3 files changed, 86 insertions(+), 18 deletions(-) diff --git a/src/bot/dispatcher.rs b/src/bot/dispatcher.rs index f423345..88ad9a6 100644 --- a/src/bot/dispatcher.rs +++ b/src/bot/dispatcher.rs @@ -31,6 +31,7 @@ use teloxide::{ update_listeners::Polling, utils::{command::BotCommands, html}, }; +use crate::bot::inlines::cobalter::handle_inline_video; async fn root_handler( update: Update, @@ -78,7 +79,8 @@ async fn run_bot(config: Arc) -> Result<(), MyError> { ) .branch(Update::filter_callback_query().endpoint(callback_query_handlers)) .branch(Update::filter_my_chat_member().endpoint(handle_bot_added)) - .branch(Update::filter_inline_query().branch(inline_query_handler())); + .branch(Update::filter_inline_query().branch(inline_query_handler())) + .branch(Update::filter_chosen_inline_result().endpoint(handle_inline_video)); let me = bot.get_me().await?; info!("Bot name: {:?}", me.username()); diff --git a/src/bot/inlines/cobalter.rs b/src/bot/inlines/cobalter.rs index d0bdf28..4139860 100644 --- a/src/bot/inlines/cobalter.rs +++ b/src/bot/inlines/cobalter.rs @@ -18,9 +18,10 @@ use teloxide::{ prelude::*, types::{ InlineQuery, InlineQueryResult, InlineQueryResultArticle, InlineQueryResultPhoto, - InlineQueryResultVideo, InputMessageContent, InputMessageContentText, + InputMessageContent, InputMessageContentText, InputFile, InputMedia, InputMediaVideo }, }; +use url::Url; static URL_REGEX: Lazy = Lazy::new(|| Regex::new(r"^(https?)://[^\s/$.?#].[^\s]*$").unwrap()); @@ -48,18 +49,18 @@ fn build_results_from_media( user_id: u64, ) -> Vec { match media { - DownloadResult::Video(video_url) => { - if let Ok(url) = video_url.parse() { + DownloadResult::Video { url, .. } => { + if let Ok(_url) = url.parse::() { let url_kb = make_single_url_keyboard(original_url); - - let result = InlineQueryResultVideo::new( + let result = InlineQueryResultArticle::new( format!("cobalt_video:{}", url_hash), - url, - "video/mp4".parse().unwrap(), - "https://i.imgur.com/D0A9Gxh.png".parse().unwrap(), /* preview */ - "Скачать видео".to_string(), + "Скачать видео", + InputMessageContent::Text(InputMessageContentText::new( + "Нажмите, чтобы отправить видео", + )), ) .reply_markup(url_kb); + vec![result.into()] } else { vec![ @@ -164,3 +165,48 @@ pub async fn handle_cobalt_inline( bot.answer_inline_query(q.id, results).cache_time(0).await?; Ok(()) } + +pub async fn handle_inline_video( + bot: Bot, + chosen: ChosenInlineResult, + config: Arc, +) -> Result<(), MyError> { + let Some(inline_message_id) = chosen.inline_message_id else { + return Ok(()); + }; + + let Some(url_hash) = chosen.result_id.strip_prefix("cobalt_video:") else { + return Ok(()); + }; + + bot.edit_message_text_inline(&inline_message_id, "⏳ Загружаю видео...") + .await?; + + let redis = config.get_redis_client(); + let cache_key = format!("cobalt_cache:{}", url_hash); + + match redis.get::(&cache_key).await? { + Some(DownloadResult::Video { url, original_url }) => { + let media = InputMedia::Video(InputMediaVideo::new(InputFile::url(url.parse()?))); + let url_kb = make_single_url_keyboard(&original_url); + + if let Err(_e) = bot + .edit_message_media_inline(&inline_message_id, media) + .reply_markup(url_kb) + .await + { + bot.edit_message_text_inline( + inline_message_id, + "❌ Ошибка: не удалось отправить видео.", + ) + .await?; + } + } + _ => { + bot.edit_message_text_inline(inline_message_id, "❌ Ошибка: видео не найдено в кэше.") + .await?; + } + } + + Ok(()) +} diff --git a/src/core/services/cobalt.rs b/src/core/services/cobalt.rs index 7026074..0da706c 100644 --- a/src/core/services/cobalt.rs +++ b/src/core/services/cobalt.rs @@ -35,7 +35,10 @@ impl VideoQuality { #[derive(Serialize, Deserialize, Debug, Clone)] pub enum DownloadResult { - Video(String), + Video { + url: String, + original_url: String, + }, Photos { urls: Vec, original_url: String, @@ -77,12 +80,21 @@ pub async fn resolve_download_url( })); } if let Some(video_item) = picker.iter().find(|item| item.kind == "video") { - return Ok(Some(DownloadResult::Video(video_item.url.clone()))); + return Ok(Some(DownloadResult::Video { + url: video_item.url.clone(), + original_url: url.to_string(), + })); } Ok(None) } - DownloadResponse::Tunnel { url, filename } - | DownloadResponse::Redirect { url, filename } => { + DownloadResponse::Tunnel { + url: c_url, + filename, + } + | DownloadResponse::Redirect { + url: c_url, + filename, + } => { const PHOTO_EXTENSIONS: &[&str] = &[".jpg", ".jpeg", ".png", ".gif", ".webp"]; let is_photo = PHOTO_EXTENSIONS .iter() @@ -90,13 +102,21 @@ pub async fn resolve_download_url( if is_photo { Ok(Some(DownloadResult::Photos { - urls: vec![url.clone()], - original_url: url, + urls: vec![c_url.clone()], + original_url: url.to_string(), })) } else { - Ok(Some(DownloadResult::Video(url))) + Ok(Some(DownloadResult::Video { + url: c_url, + original_url: url.to_string(), + })) } } - _ => Ok(response.get_download_url().map(DownloadResult::Video)), + _ => Ok(response + .get_download_url() + .map(|c_url| DownloadResult::Video { + url: c_url, + original_url: url.to_string(), + })), } } From 697d87219a569c4b3c9bfddfb398370edd7f2831 Mon Sep 17 00:00:00 2001 From: nixxoq Date: Fri, 19 Sep 2025 18:16:11 +0300 Subject: [PATCH 55/66] fix(commands/settings): register settings for new user with default modules --- src/bot/commands/settings.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/bot/commands/settings.rs b/src/bot/commands/settings.rs index 2eef715..8c28e07 100644 --- a/src/bot/commands/settings.rs +++ b/src/bot/commands/settings.rs @@ -14,7 +14,7 @@ pub async fn settings_command_handler(bot: Bot, message: Message) -> Result<(), } .to_string(); - let settings_doc = Settings::get_or_create(&Owner { + let settings_doc = Settings::create_with_defaults(&Owner { id: owner_id.clone(), r#type: owner_type.clone(), }) From cf589b54225f6d6cf3c2b18c27ae001731d61894 Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Sun, 21 Sep 2025 01:25:47 +0300 Subject: [PATCH 56/66] fixed settings issue --- src/bot/commands/settings.rs | 2 +- src/core/db/schemas/settings.rs | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/bot/commands/settings.rs b/src/bot/commands/settings.rs index 8c28e07..2eef715 100644 --- a/src/bot/commands/settings.rs +++ b/src/bot/commands/settings.rs @@ -14,7 +14,7 @@ pub async fn settings_command_handler(bot: Bot, message: Message) -> Result<(), } .to_string(); - let settings_doc = Settings::create_with_defaults(&Owner { + let settings_doc = Settings::get_or_create(&Owner { id: owner_id.clone(), r#type: owner_type.clone(), }) diff --git a/src/core/db/schemas/settings.rs b/src/core/db/schemas/settings.rs index 2460802..817bdf4 100644 --- a/src/core/db/schemas/settings.rs +++ b/src/core/db/schemas/settings.rs @@ -104,13 +104,15 @@ impl Settings { { Ok(found) } else { - let new_doc = Settings::new() - .owner_id(owner.id.clone()) - .owner_type(owner.r#type.clone()) - .modules(BTreeMap::new()); - - new_doc.save().await?; + let new_doc = Self::create_with_defaults(owner).await?; Ok(new_doc) + // let new_doc = Settings::new() + // .owner_id(owner.id.clone()) + // .owner_type(owner.r#type.clone()) + // .modules(BTreeMap::new()); + // + // new_doc.save().await?; + // Ok(new_doc) } } } From 56d293b3f22028f408c8cb05ada06d41b7b005a0 Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Sun, 21 Sep 2025 01:27:43 +0300 Subject: [PATCH 57/66] implemented whisper module --- src/bot/inlines/whisper.rs | 14 +++ src/bot/modules/currency.rs | 27 +++-- src/bot/modules/mod.rs | 1 + src/bot/modules/registry.rs | 3 +- src/bot/modules/whisper.rs | 132 ++++++++++++++++++++++++ src/core/services/currency/converter.rs | 3 +- 6 files changed, 168 insertions(+), 12 deletions(-) create mode 100644 src/bot/modules/whisper.rs diff --git a/src/bot/inlines/whisper.rs b/src/bot/inlines/whisper.rs index 2bdde54..2c0bb7f 100644 --- a/src/bot/inlines/whisper.rs +++ b/src/bot/inlines/whisper.rs @@ -16,6 +16,10 @@ use teloxide::{ utils::html, }; use uuid::Uuid; +use crate::bot::modules::cobalt::CobaltSettings; +use crate::bot::modules::Owner; +use crate::bot::modules::whisper::WhisperSettings; +use crate::core::db::schemas::settings::Settings; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct Recipient { @@ -94,6 +98,16 @@ pub async fn handle_whisper_inline( q: InlineQuery, config: Arc, ) -> Result<(), MyError> { + let owner = Owner { + id: q.from.id.to_string(), + r#type: "user".to_string(), + }; + + let settings = Settings::get_module_settings::(&owner, "whisper").await?; + if !settings.enabled { + return Ok(()); + } + if q.query.is_empty() { let article = InlineQueryResultArticle::new( "whisper_help", diff --git a/src/bot/modules/currency.rs b/src/bot/modules/currency.rs index c45e190..717fe45 100644 --- a/src/bot/modules/currency.rs +++ b/src/bot/modules/currency.rs @@ -16,21 +16,30 @@ use teloxide::{ prelude::*, types::{InlineKeyboardButton, InlineKeyboardMarkup}, }; +use crate::core::services::currency::converter::get_default_currencies; -#[derive(Debug, Serialize, Deserialize, Clone, Default)] +#[derive(Debug, Serialize, Deserialize, Clone)] pub struct CurrencySettings { pub enabled: bool, pub selected_codes: Vec, } -// impl Default for CurrencySettings { -// fn default() -> Self { -// Self { -// enabled: false, -// selected_codes: vec![], -// } -// } -// } +impl Default for CurrencySettings { + fn default() -> Self { + let default_currencies = get_default_currencies() + .map(|currencies| + currencies.into_iter() + .map(|c| c.code) + .collect::>() + ) + .unwrap_or_else(|_| vec!["usd".to_string(), "eur".to_string()]); + + Self { + enabled: true, + selected_codes: default_currencies, + } + } +} impl ModuleSettings for CurrencySettings {} diff --git a/src/bot/modules/mod.rs b/src/bot/modules/mod.rs index cd59b6e..14941f3 100644 --- a/src/bot/modules/mod.rs +++ b/src/bot/modules/mod.rs @@ -1,6 +1,7 @@ pub mod cobalt; pub mod currency; pub mod registry; +pub mod whisper; use crate::errors::MyError; use async_trait::async_trait; diff --git a/src/bot/modules/registry.rs b/src/bot/modules/registry.rs index e9d6c04..5b803fa 100644 --- a/src/bot/modules/registry.rs +++ b/src/bot/modules/registry.rs @@ -2,6 +2,7 @@ use super::{Module, cobalt::CobaltModule}; use crate::bot::modules::currency::CurrencyModule; use once_cell::sync::Lazy; use std::{collections::BTreeMap, sync::Arc}; +use crate::bot::modules::whisper::WhisperModule; pub struct ModuleManager { modules: BTreeMap>, @@ -9,7 +10,7 @@ pub struct ModuleManager { impl ModuleManager { fn new() -> Self { - let modules: Vec> = vec![Arc::new(CobaltModule), Arc::new(CurrencyModule)]; + let modules: Vec> = vec![Arc::new(CobaltModule), Arc::new(CurrencyModule), Arc::new(WhisperModule)]; let modules = modules .into_iter() diff --git a/src/bot/modules/whisper.rs b/src/bot/modules/whisper.rs new file mode 100644 index 0000000..6e82935 --- /dev/null +++ b/src/bot/modules/whisper.rs @@ -0,0 +1,132 @@ +use crate::{ + bot::modules::{Module, ModuleSettings, Owner}, + core::db::schemas::settings::Settings, + errors::MyError, +}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use teloxide::{ + prelude::*, + types::{InlineKeyboardButton, InlineKeyboardMarkup}, +}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct WhisperSettings { + pub enabled: bool, +} + +impl Default for WhisperSettings { + fn default() -> Self { + Self { + enabled: false, + } + } +} + +impl ModuleSettings for WhisperSettings {} + +pub struct WhisperModule; + +#[async_trait] +impl Module for WhisperModule { + fn key(&self) -> &'static str { + "whisper" + } + fn description(&self) -> &'static str { + "Настройки для Whisper System" + } + + async fn get_settings_ui( + &self, + owner: &Owner, + ) -> Result<(String, InlineKeyboardMarkup), MyError> { + let settings: WhisperSettings = Settings::get_module_settings(owner, self.key()).await?; + + let text = format!( + "⚙️ Настройки модуля: {}\n\nСтатус: {}", + self.description(), + if settings.enabled { + "✅ Включен" + } else { + "❌ Выключен" + } + ); + + let toggle_button = InlineKeyboardButton::callback( + if settings.enabled { + "Выключить модуль" + } else { + "Включить модуль" + }, + format!("{}:settings:toggle_module", self.key()), + ); + + let keyboard = InlineKeyboardMarkup::new(vec![ + vec![toggle_button], + vec![InlineKeyboardButton::callback( + "⬅️ Назад", + format!("settings_back:{}:{}", owner.r#type, owner.id), + )], + ]); + + Ok((text, keyboard)) + } + + async fn handle_callback( + &self, + bot: Bot, + q: &CallbackQuery, + owner: &Owner, + data: &str, + ) -> Result<(), MyError> { + let Some(message) = &q.message else { + return Ok(()); + }; + + let Some(message) = message.regular_message() else { + return Ok(()); + }; + + let parts: Vec<_> = data.split(':').collect(); + + if parts.len() == 1 && parts[0] == "toggle_module" { + let mut settings: WhisperSettings = + Settings::get_module_settings(owner, self.key()).await?; + settings.enabled = !settings.enabled; + Settings::update_module_settings(owner, self.key(), settings).await?; + + let (text, keyboard) = self.get_settings_ui(owner).await?; + bot.edit_message_text(message.chat.id, message.id, text) + .reply_markup(keyboard) + .await?; + return Ok(()); + } + + if parts.len() < 3 || parts[0] != "set" { + bot.answer_callback_query(q.id.clone()).await?; + return Ok(()); + } + + let settings: WhisperSettings = Settings::get_module_settings(owner, self.key()).await?; + + Settings::update_module_settings(owner, self.key(), settings).await?; + + let (text, keyboard) = self.get_settings_ui(owner).await?; + bot.edit_message_text(message.chat.id, message.id, text) + .reply_markup(keyboard) + .await?; + + Ok(()) + } + + fn enabled_for(&self, owner_type: &str) -> bool { + owner_type == "user" // user + } + + fn factory_settings(&self) -> Result { + let factory_settings = WhisperSettings { + enabled: true, + }; + Ok(serde_json::to_value(factory_settings)?) + } +} diff --git a/src/core/services/currency/converter.rs b/src/core/services/currency/converter.rs index 0a656bd..3e00dca 100644 --- a/src/core/services/currency/converter.rs +++ b/src/core/services/currency/converter.rs @@ -160,11 +160,10 @@ pub fn get_default_currencies() -> Result, MyError> { let all_codes = get_all_currency_codes(CURRENCY_CONFIG_PATH.parse().unwrap())?; let necessary_codes = all_codes - .iter() + .into_iter() .filter(|c| { ["uah", "rub", "usd", "byn", "eur", "ton"].contains(&c.code.to_lowercase().as_str()) }) - .cloned() .collect::>(); Ok(necessary_codes) From 92c8de119c2d9b1f53e9c787e39807d95c1c7858 Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Sun, 21 Sep 2025 01:53:57 +0300 Subject: [PATCH 58/66] global inline handlers like user register and disabled modules --- src/bot/dispatcher.rs | 137 +++++++++++++++++++++++++++++++----- src/bot/inlines/currency.rs | 45 +----------- src/bot/inlines/whisper.rs | 1 - 3 files changed, 124 insertions(+), 59 deletions(-) diff --git a/src/bot/dispatcher.rs b/src/bot/dispatcher.rs index 88ad9a6..e9f6b98 100644 --- a/src/bot/dispatcher.rs +++ b/src/bot/dispatcher.rs @@ -1,9 +1,12 @@ +use crate::bot::modules::registry::MOD_MANAGER; +use crate::bot::modules::Owner; +use crate::core::db::schemas::settings::Settings; use crate::{ bot::{ callbacks::callback_query_handlers, commander::command_handlers, inlines::{ - cobalter::{handle_cobalt_inline, is_query_url}, + cobalter::{handle_cobalt_inline, handle_inline_video, is_query_url}, currency::{handle_currency_inline, is_currency_query}, whisper::{handle_whisper_inline, is_whisper_query}, }, @@ -11,27 +14,33 @@ use crate::{ messager::{handle_currency, handle_speech}, messages::chat::handle_bot_added, }, - core::config::Config, + core::{config::Config, + db::schemas::user::User as DBUser}, errors::MyError, util::enums::Command, }; -use log::{error, info}; -use oximod::set_global_client; +use log::{debug, error, info}; +use mongodb::bson::doc; +use oximod::{set_global_client, Model}; +use serde::Deserialize; use std::{convert::Infallible, fmt::Write, ops::ControlFlow, sync::Arc}; use teloxide::{ - Bot, dispatching::{ Dispatcher, DpHandlerDescription, HandlerExt, MessageFilterExt, UpdateFilterExt, }, dptree, error_handlers::LoggingErrorHandler, - payloads::SendDocumentSetters, + payloads::{AnswerInlineQuerySetters, SendDocumentSetters}, prelude::{ChatId, Handler, Message, Requester}, - types::{Chat, InputFile, Me, MessageId, ParseMode, ThreadId, Update, User}, + types::{ + Chat, InlineKeyboardButton, InlineKeyboardMarkup, InlineQuery, InlineQueryResult, + InlineQueryResultArticle, InputFile, InputMessageContent, InputMessageContentText, Me, + MessageId, ParseMode, ThreadId, Update, User, + }, update_listeners::Polling, utils::{command::BotCommands, html}, + Bot, }; -use crate::bot::inlines::cobalter::handle_inline_video; async fn root_handler( update: Update, @@ -53,11 +62,107 @@ async fn root_handler( Ok(()) } +async fn is_user_registered(q: InlineQuery) -> bool { + let user_id_str = q.from.id.to_string(); + DBUser::find_one(doc! { "user_id": &user_id_str }) + .await + .map_or(false, |user| user.is_some()) +} + +async fn prompt_registration(bot: Bot, q: InlineQuery, me: Me) -> Result<(), MyError> { + let user_id_str = q.from.id.to_string(); + debug!("User {} not found. Offering to register.", user_id_str); + + let start_url = format!("https://t.me/{}?start=inl", me.username()); + + let keyboard = InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::url( + "▶️ Зарегистрироваться".to_string(), + start_url.parse()?, + )]]); + + let article = InlineQueryResultArticle::new( + "register_prompt", + "Вы не зарегистрированы", + InputMessageContent::Text(InputMessageContentText::new( + "Чтобы использовать бота, пожалуйста, сначала начните диалог с ним.", + )), + ) + .description("Нажмите здесь, чтобы начать чат с ботом и разблокировать все функции.") + .reply_markup(keyboard); + + if let Err(e) = bot + .answer_inline_query(q.id, vec![InlineQueryResult::Article(article)]) + .cache_time(10) + .await + { + error!("Failed to send 'register' inline prompt: {:?}", e); + } + + Ok(()) +} + +#[derive(Deserialize)] +struct EnabledCheck { + enabled: bool, +} + +async fn are_any_inline_modules_enabled(q: InlineQuery) -> bool { + let owner = Owner { + id: q.from.id.to_string(), + r#type: "user".to_string(), + }; + + if let Ok(settings) = Settings::get_or_create(&owner).await { + for module in MOD_MANAGER.get_all_modules() { + if module.enabled_for(&*owner.r#type) { + if let Some(settings_json) = settings.modules.get(module.key()) { + if let Ok(check) = serde_json::from_value::(settings_json.clone()) + { + if check.enabled { + return true; + } + } + } + } + } + } + false +} + +async fn send_modules_disabled_message(bot: Bot, q: InlineQuery) -> Result<(), MyError> { + let article = InlineQueryResultArticle::new( + "modules_disabled", + "Все модули выключены", + InputMessageContent::Text(InputMessageContentText::new( + "Все инлайн-модули выключены. Чтобы ими воспользоваться, активируйте их в настройках.", + )), + ) + .description("Используйте /settings в чате с ботом, чтобы включить их."); + + bot.answer_inline_query(q.id, vec![InlineQueryResult::Article(article)]) + .cache_time(10) + .await?; + Ok(()) +} + pub fn inline_query_handler() -> Handler<'static, Result<(), MyError>, DpHandlerDescription> { dptree::entry() - .branch(dptree::filter_async(is_currency_query).endpoint(handle_currency_inline)) - .branch(dptree::filter_async(is_query_url).endpoint(handle_cobalt_inline)) - .branch(dptree::filter_async(is_whisper_query).endpoint(handle_whisper_inline)) + .branch( + dptree::filter_async(|q: InlineQuery| async move { !is_user_registered(q).await }) + .endpoint(prompt_registration), + ) + .branch( + dptree::filter_async(is_user_registered) + .filter_async(|q: InlineQuery| async move { !are_any_inline_modules_enabled(q).await }) + .endpoint(send_modules_disabled_message), + ) + .branch( + dptree::filter_async(is_user_registered) + .filter_async(are_any_inline_modules_enabled) + .branch(dptree::filter_async(is_currency_query).endpoint(handle_currency_inline)) + .branch(dptree::filter_async(is_query_url).endpoint(handle_cobalt_inline)) + .branch(dptree::filter_async(is_whisper_query).endpoint(handle_whisper_inline)), + ) } async fn run_bot(config: Arc) -> Result<(), MyError> { @@ -127,7 +232,7 @@ fn short_error_name(error: &MyError) -> String { } pub async fn handle_error(err: Arc, update: Update, config: Arc, bot: Bot) { - error!("An error has occurred: {:?}", err); // ahh fuck + error!("An error has occurred: {:?}", err); let (user, chat) = extract_info(&update); let mut message_text = String::new(); @@ -143,7 +248,7 @@ pub async fn handle_error(err: Arc, update: Update, config: Arc "В чате: {}{}", chat.id, title ) - .unwrap(); + .unwrap(); } else { writeln!(&mut message_text, "В чате: (???)").unwrap(); } @@ -159,7 +264,7 @@ pub async fn handle_error(err: Arc, update: Update, config: Arc "Вызвал: {} ({}){}", full_name, user.id, username ) - .unwrap(); + .unwrap(); } else { writeln!(&mut message_text, "Вызвал: (???)").unwrap(); } @@ -170,7 +275,7 @@ pub async fn handle_error(err: Arc, update: Update, config: Arc "\nОшибка:\n
{}
", html::escape(&error_name) ) - .unwrap(); + .unwrap(); let hashtag = "#error"; writeln!(&mut message_text, "\n{}", hashtag).unwrap(); @@ -202,4 +307,4 @@ pub async fn handle_error(err: Arc, update: Update, config: Arc config.get_error_chat_thread_id() ); } -} +} \ No newline at end of file diff --git a/src/bot/inlines/currency.rs b/src/bot/inlines/currency.rs index 04e64df..20d362f 100644 --- a/src/bot/inlines/currency.rs +++ b/src/bot/inlines/currency.rs @@ -2,21 +2,19 @@ use crate::{ bot::modules::{Owner, currency::CurrencySettings}, core::{ config::Config, - db::schemas::{settings::Settings, user::User}, + db::schemas::{settings::Settings}, services::currency::converter::CURRENCY_REGEX, }, errors::MyError, }; use log::{debug, error}; -use mongodb::bson::doc; -use oximod::Model; use std::sync::Arc; use teloxide::{ Bot, payloads::AnswerInlineQuerySetters, prelude::Requester, types::{ - InlineKeyboardButton, InlineKeyboardMarkup, InlineQuery, InlineQueryResult, + InlineQuery, InlineQueryResult, InlineQueryResultArticle, InputMessageContent, InputMessageContentText, Me, ParseMode, }, }; @@ -48,45 +46,8 @@ pub async fn handle_currency_inline( bot: Bot, q: InlineQuery, config: Arc, - me: Me, + _me: Me, ) -> Result<(), MyError> { - let user_id_str = q.from.id.to_string(); - - let user_exists = User::find_one(doc! { "user_id": &user_id_str }) - .await? - .is_some(); - - if !user_exists { - debug!("User {} not found. Offering to register.", user_id_str); - - let start_url = format!("https://t.me/{}?start=inl", me.username()); - - let keyboard = InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::url( - "▶️ Register".to_string(), - start_url.parse()?, - )]]); - - let article = InlineQueryResultArticle::new( - "register_prompt", - "You are not registered", - InputMessageContent::Text(InputMessageContentText::new( - "To use the bot, please start a conversation with it first.", - )), - ) - .description("Click here to start a chat with the bot and unlock all features.") - .reply_markup(keyboard); - - if let Err(e) = bot - .answer_inline_query(q.id, vec![InlineQueryResult::Article(article)]) - .cache_time(10) - .await - { - error!("Failed to send 'register' inline prompt: {:?}", e); - } - - return Ok(()); - } - debug!("Handling currency inline query: {}", &q.query); let converter = config.get_currency_converter(); diff --git a/src/bot/inlines/whisper.rs b/src/bot/inlines/whisper.rs index 2c0bb7f..7d17730 100644 --- a/src/bot/inlines/whisper.rs +++ b/src/bot/inlines/whisper.rs @@ -16,7 +16,6 @@ use teloxide::{ utils::html, }; use uuid::Uuid; -use crate::bot::modules::cobalt::CobaltSettings; use crate::bot::modules::Owner; use crate::bot::modules::whisper::WhisperSettings; use crate::core::db::schemas::settings::Settings; From 47112b54e112b01abfe166b2c06976e48f756cd1 Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Sun, 21 Sep 2025 02:05:32 +0300 Subject: [PATCH 59/66] early settings design stuff --- src/bot/callbacks/mod.rs | 1 + src/bot/commands/settings.rs | 31 +++++++++++++++++++++++-------- src/bot/modules/cobalt.rs | 4 +++- src/bot/modules/currency.rs | 5 ++++- src/bot/modules/whisper.rs | 4 +++- 5 files changed, 34 insertions(+), 11 deletions(-) diff --git a/src/bot/callbacks/mod.rs b/src/bot/callbacks/mod.rs index dd2d1fc..28ae0f1 100644 --- a/src/bot/callbacks/mod.rs +++ b/src/bot/callbacks/mod.rs @@ -138,6 +138,7 @@ pub async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Result<(), M let (text, keyboard) = module.get_settings_ui(&owner).await?; bot.edit_message_text(message.chat().id, message.id(), text) .reply_markup(keyboard) + .parse_mode(teloxide::types::ParseMode::Html) .await?; } } diff --git a/src/bot/commands/settings.rs b/src/bot/commands/settings.rs index 2eef715..526b5f7 100644 --- a/src/bot/commands/settings.rs +++ b/src/bot/commands/settings.rs @@ -12,13 +12,20 @@ pub async fn settings_command_handler(bot: Bot, message: Message) -> Result<(), } else { "group" } - .to_string(); + .to_string(); let settings_doc = Settings::get_or_create(&Owner { id: owner_id.clone(), r#type: owner_type.clone(), }) - .await?; + .await?; + + let text = String::from( + "⚙️ Настройки модулей\n\n\ + Нажмите на кнопку, чтобы включить или выключить соответствующий модуль.\n\ + ✅ – модуль включён\n\ + ❌ – модуль выключен\n\n" + ); let kb_buttons: Vec> = MOD_MANAGER .get_all_modules() @@ -36,7 +43,8 @@ pub async fn settings_command_handler(bot: Bot, message: Message) -> Result<(), .unwrap_or(false); let status = if is_enabled { "✅" } else { "❌" }; - let text = format!("{} {}", status, module.description()); + let text = format!("{} — {}", status, module.description()); + let callback_data = format!("module_select:{}:{}:{}", owner_type, owner_id, module.key()); @@ -46,7 +54,8 @@ pub async fn settings_command_handler(bot: Bot, message: Message) -> Result<(), let keyboard = InlineKeyboardMarkup::new(kb_buttons); - bot.send_message(message.chat.id, "Настройки модулей:") + bot.send_message(message.chat.id, text) + .parse_mode(teloxide::types::ParseMode::Html) .reply_markup(keyboard) .await?; @@ -63,7 +72,14 @@ pub async fn update_settings_message( id: owner_id.clone(), r#type: owner_type.clone(), }) - .await?; + .await?; + + let text = String::from( + "⚙️ Настройки модулей\n\n\ + Нажмите на кнопку, чтобы включить или выключить соответствующий модуль.\n\ + ✅ – модуль включён\n\ + ❌ – модуль выключен\n\n" + ); let kb_buttons: Vec> = MOD_MANAGER .get_all_modules() @@ -80,7 +96,7 @@ pub async fn update_settings_message( .unwrap_or(false); let status = if is_enabled { "✅" } else { "❌" }; - let text = format!("{} {}", status, module.description()); + let text = format!("{} — {}", status, module.description()); let callback_data = format!("module_select:{}:{}:{}", owner_type, owner_id, module.key()); @@ -90,11 +106,10 @@ pub async fn update_settings_message( let keyboard = InlineKeyboardMarkup::new(kb_buttons); - let text = "Настройки модулей:"; - if let MaybeInaccessibleMessage::Regular(msg) = message { let _ = bot .edit_message_text(msg.chat.id, msg.id, text) + .parse_mode(teloxide::types::ParseMode::Html) .reply_markup(keyboard) .await; } diff --git a/src/bot/modules/cobalt.rs b/src/bot/modules/cobalt.rs index ea72407..5fbee56 100644 --- a/src/bot/modules/cobalt.rs +++ b/src/bot/modules/cobalt.rs @@ -47,7 +47,7 @@ impl Module for CobaltModule { let settings: CobaltSettings = Settings::get_module_settings(owner, self.key()).await?; let text = format!( - "⚙️ Настройки модуля: {}\n\nСтатус: {}", + "⚙️ Настройки модуля: {}\n\nСтатус: {}", self.description(), if settings.enabled { "✅ Включен" @@ -135,6 +135,7 @@ impl Module for CobaltModule { let (text, keyboard) = self.get_settings_ui(owner).await?; bot.edit_message_text(message.chat.id, message.id, text) .reply_markup(keyboard) + .parse_mode(teloxide::types::ParseMode::Html) .await?; return Ok(()); } @@ -161,6 +162,7 @@ impl Module for CobaltModule { let (text, keyboard) = self.get_settings_ui(owner).await?; bot.edit_message_text(message.chat.id, message.id, text) .reply_markup(keyboard) + .parse_mode(teloxide::types::ParseMode::Html) .await?; Ok(()) diff --git a/src/bot/modules/currency.rs b/src/bot/modules/currency.rs index 717fe45..19fa5f9 100644 --- a/src/bot/modules/currency.rs +++ b/src/bot/modules/currency.rs @@ -101,6 +101,7 @@ impl Module for CurrencyModule { bot.edit_message_text(message.chat.id, message.id, text) .reply_markup(keyboard) + .parse_mode(teloxide::types::ParseMode::Html) .await?; return Ok(()); @@ -113,6 +114,7 @@ impl Module for CurrencyModule { bot.edit_message_text(message.chat.id, message.id, text) .reply_markup(keyboard) + .parse_mode(teloxide::types::ParseMode::Html) .await?; return Ok(()); @@ -138,6 +140,7 @@ impl Module for CurrencyModule { bot.edit_message_text(message.chat.id, message.id, text) .reply_markup(keyboard) + .parse_mode(teloxide::types::ParseMode::Html) .await?; } else { bot.answer_callback_query(q.id.clone()).await?; @@ -174,7 +177,7 @@ impl CurrencyModule { ) -> Result<(String, InlineKeyboardMarkup), MyError> { let settings: CurrencySettings = Settings::get_module_settings(owner, self.key()).await?; let text = format!( - "⚙️ Настройки модуля: {}\n\nСтатус: {}\n\nВыберите валюты для отображения.", + "⚙️ Настройки модуля: {}\n\nСтатус: {}\n\nВыберите валюты для отображения.", self.description(), if settings.enabled { "✅ Включен" diff --git a/src/bot/modules/whisper.rs b/src/bot/modules/whisper.rs index 6e82935..4d80e2c 100644 --- a/src/bot/modules/whisper.rs +++ b/src/bot/modules/whisper.rs @@ -43,7 +43,7 @@ impl Module for WhisperModule { let settings: WhisperSettings = Settings::get_module_settings(owner, self.key()).await?; let text = format!( - "⚙️ Настройки модуля: {}\n\nСтатус: {}", + "⚙️ Настройки модуля: {}\n\nСтатус: {}", self.description(), if settings.enabled { "✅ Включен" @@ -98,6 +98,7 @@ impl Module for WhisperModule { let (text, keyboard) = self.get_settings_ui(owner).await?; bot.edit_message_text(message.chat.id, message.id, text) .reply_markup(keyboard) + .parse_mode(teloxide::types::ParseMode::Html) .await?; return Ok(()); } @@ -114,6 +115,7 @@ impl Module for WhisperModule { let (text, keyboard) = self.get_settings_ui(owner).await?; bot.edit_message_text(message.chat.id, message.id, text) .reply_markup(keyboard) + .parse_mode(teloxide::types::ParseMode::Html) .await?; Ok(()) From e86027d0b17831861aba27bd436157c4f224f786 Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Sun, 21 Sep 2025 02:10:15 +0300 Subject: [PATCH 60/66] no to description, yes to name! --- src/bot/commands/settings.rs | 4 ++-- src/bot/modules/cobalt.rs | 7 ++++++- src/bot/modules/currency.rs | 6 +++++- src/bot/modules/mod.rs | 2 ++ src/bot/modules/whisper.rs | 7 ++++++- 5 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/bot/commands/settings.rs b/src/bot/commands/settings.rs index 526b5f7..a010342 100644 --- a/src/bot/commands/settings.rs +++ b/src/bot/commands/settings.rs @@ -43,7 +43,7 @@ pub async fn settings_command_handler(bot: Bot, message: Message) -> Result<(), .unwrap_or(false); let status = if is_enabled { "✅" } else { "❌" }; - let text = format!("{} — {}", status, module.description()); + let text = format!("{} — {}", status, module.name()); let callback_data = format!("module_select:{}:{}:{}", owner_type, owner_id, module.key()); @@ -96,7 +96,7 @@ pub async fn update_settings_message( .unwrap_or(false); let status = if is_enabled { "✅" } else { "❌" }; - let text = format!("{} — {}", status, module.description()); + let text = format!("{} — {}", status, module.name()); let callback_data = format!("module_select:{}:{}:{}", owner_type, owner_id, module.key()); diff --git a/src/bot/modules/cobalt.rs b/src/bot/modules/cobalt.rs index 5fbee56..41123db 100644 --- a/src/bot/modules/cobalt.rs +++ b/src/bot/modules/cobalt.rs @@ -36,8 +36,13 @@ impl Module for CobaltModule { fn key(&self) -> &'static str { "cobalt" } + + fn name(&self) -> &'static str { + "Cobalt Downloader" + } + fn description(&self) -> &'static str { - "Настройки для Cobalt Downloader" + "Возможность скачивать видео, фото, аудио" // todo change this shit } async fn get_settings_ui( diff --git a/src/bot/modules/currency.rs b/src/bot/modules/currency.rs index 19fa5f9..ead286b 100644 --- a/src/bot/modules/currency.rs +++ b/src/bot/modules/currency.rs @@ -51,8 +51,12 @@ impl Module for CurrencyModule { "currency" } + fn name(&self) -> &'static str { + "Конвертер валют" + } + fn description(&self) -> &'static str { - "Настройки конвертации валют" + "Возможность конвертировать валюты" // todo change this shit } async fn get_settings_ui( diff --git a/src/bot/modules/mod.rs b/src/bot/modules/mod.rs index 14941f3..70631f4 100644 --- a/src/bot/modules/mod.rs +++ b/src/bot/modules/mod.rs @@ -25,6 +25,8 @@ pub trait ModuleSettings: pub trait Module: Send + Sync { fn key(&self) -> &'static str; + fn name(&self) -> &'static str; + fn description(&self) -> &'static str; async fn get_settings_ui( diff --git a/src/bot/modules/whisper.rs b/src/bot/modules/whisper.rs index 4d80e2c..91b83b3 100644 --- a/src/bot/modules/whisper.rs +++ b/src/bot/modules/whisper.rs @@ -32,8 +32,13 @@ impl Module for WhisperModule { fn key(&self) -> &'static str { "whisper" } + + fn name(&self) -> &'static str { + "Whisper System" + } + fn description(&self) -> &'static str { - "Настройки для Whisper System" + "Модуль «шептать», позволяющая работать с текстовыми сообщениями в более приватном режиме. Протестировать можно через inlin'ы." } async fn get_settings_ui( From fb273f021bee26a17129c4e8c05e62052a008941 Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Sun, 21 Sep 2025 16:06:04 +0300 Subject: [PATCH 61/66] enhanced settings stuff with designed system not perfect, but I think this better than old "enabled_for" --- src/bot/commands/settings.rs | 2 +- src/bot/dispatcher.rs | 2 +- src/bot/modules/cobalt.rs | 12 +++++++++++- src/bot/modules/currency.rs | 13 ++++++++++++- src/bot/modules/mod.rs | 5 ++++- src/bot/modules/registry.rs | 4 ++++ src/bot/modules/whisper.rs | 12 +++++++++++- src/core/db/schemas/settings.rs | 2 +- 8 files changed, 45 insertions(+), 7 deletions(-) diff --git a/src/bot/commands/settings.rs b/src/bot/commands/settings.rs index a010342..3ca9d13 100644 --- a/src/bot/commands/settings.rs +++ b/src/bot/commands/settings.rs @@ -28,7 +28,7 @@ pub async fn settings_command_handler(bot: Bot, message: Message) -> Result<(), ); let kb_buttons: Vec> = MOD_MANAGER - .get_all_modules() + .get_designed_modules(&*owner_type) .into_iter() .map(|module| { let settings: serde_json::Value = settings_doc diff --git a/src/bot/dispatcher.rs b/src/bot/dispatcher.rs index e9f6b98..782e2f4 100644 --- a/src/bot/dispatcher.rs +++ b/src/bot/dispatcher.rs @@ -114,7 +114,7 @@ async fn are_any_inline_modules_enabled(q: InlineQuery) -> bool { if let Ok(settings) = Settings::get_or_create(&owner).await { for module in MOD_MANAGER.get_all_modules() { - if module.enabled_for(&*owner.r#type) { + if module.is_enabled(&owner).await { if let Some(settings_json) = settings.modules.get(module.key()) { if let Ok(check) = serde_json::from_value::(settings_json.clone()) { diff --git a/src/bot/modules/cobalt.rs b/src/bot/modules/cobalt.rs index 41123db..9d91a1d 100644 --- a/src/bot/modules/cobalt.rs +++ b/src/bot/modules/cobalt.rs @@ -173,10 +173,20 @@ impl Module for CobaltModule { Ok(()) } - fn enabled_for(&self, owner_type: &str) -> bool { + fn designed_for(&self, owner_type: &str) -> bool { owner_type == "user" // user } + async fn is_enabled(&self, owner: &Owner) -> bool { + if !self.designed_for(&*owner.r#type) { + return false; + } + + let settings: CobaltSettings = Settings::get_module_settings(owner, self.key()).await.unwrap(); // god of unwraps + + settings.enabled + } + fn factory_settings(&self) -> Result { let factory_settings = CobaltSettings { enabled: true, diff --git a/src/bot/modules/currency.rs b/src/bot/modules/currency.rs index ead286b..500a3af 100644 --- a/src/bot/modules/currency.rs +++ b/src/bot/modules/currency.rs @@ -16,6 +16,7 @@ use teloxide::{ prelude::*, types::{InlineKeyboardButton, InlineKeyboardMarkup}, }; +use crate::bot::modules::whisper::WhisperSettings; use crate::core::services::currency::converter::get_default_currencies; #[derive(Debug, Serialize, Deserialize, Clone)] @@ -153,10 +154,20 @@ impl Module for CurrencyModule { Ok(()) } - fn enabled_for(&self, _owner_type: &str) -> bool { + fn designed_for(&self, _owner_type: &str) -> bool { true // all } + async fn is_enabled(&self, owner: &Owner) -> bool { + if !self.designed_for(&*owner.r#type) { + return false; + } + + let settings: CurrencySettings = Settings::get_module_settings(owner, self.key()).await.unwrap(); // god of unwraps + + settings.enabled + } + fn factory_settings(&self) -> Result { let factory_settings = CurrencySettings { enabled: true, diff --git a/src/bot/modules/mod.rs b/src/bot/modules/mod.rs index 70631f4..be83be9 100644 --- a/src/bot/modules/mod.rs +++ b/src/bot/modules/mod.rs @@ -42,7 +42,10 @@ pub trait Module: Send + Sync { data: &str, ) -> Result<(), MyError>; - fn enabled_for(&self, owner_type: &str) -> bool; + // this function returns true if the module is designed for the owner type. like if module is designed for user, it will return true for user and false for group, and doesn't show in group + fn designed_for(&self, owner_type: &str) -> bool; + + async fn is_enabled(&self, owner: &Owner) -> bool; fn factory_settings(&self) -> Result; } diff --git a/src/bot/modules/registry.rs b/src/bot/modules/registry.rs index 5b803fa..123c9e7 100644 --- a/src/bot/modules/registry.rs +++ b/src/bot/modules/registry.rs @@ -27,6 +27,10 @@ impl ModuleManager { pub fn get_all_modules(&self) -> Vec<&Arc> { self.modules.values().collect() } + + pub fn get_designed_modules(&self, owner_type: &str) -> Vec<&Arc> { + self.modules.values().filter(|module| module.designed_for(owner_type)).collect() + } } pub static MOD_MANAGER: Lazy = Lazy::new(ModuleManager::new); diff --git a/src/bot/modules/whisper.rs b/src/bot/modules/whisper.rs index 91b83b3..9451b92 100644 --- a/src/bot/modules/whisper.rs +++ b/src/bot/modules/whisper.rs @@ -126,10 +126,20 @@ impl Module for WhisperModule { Ok(()) } - fn enabled_for(&self, owner_type: &str) -> bool { + fn designed_for(&self, owner_type: &str) -> bool { owner_type == "user" // user } + async fn is_enabled(&self, owner: &Owner) -> bool { + if !self.designed_for(&*owner.r#type) { + return false; + } + + let settings: WhisperSettings = Settings::get_module_settings(owner, self.key()).await.unwrap(); // god of unwraps + + settings.enabled + } + fn factory_settings(&self) -> Result { let factory_settings = WhisperSettings { enabled: true, diff --git a/src/core/db/schemas/settings.rs b/src/core/db/schemas/settings.rs index 817bdf4..677df15 100644 --- a/src/core/db/schemas/settings.rs +++ b/src/core/db/schemas/settings.rs @@ -33,7 +33,7 @@ impl Settings { let mut modules_map = BTreeMap::::new(); for module in MOD_MANAGER.get_all_modules() { - if module.enabled_for(&owner.r#type) { + if module.designed_for(&owner.r#type) { match module.factory_settings() { Ok(settings_json) => { modules_map.insert(module.key().to_string(), settings_json); From 30a8a57e77fb55dc892831a8edd6a2b07f35783d Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Sun, 21 Sep 2025 16:30:48 +0300 Subject: [PATCH 62/66] added button "delete your data" in settings --- src/bot/callbacks/delete.rs | 164 +++++++++++++++++++++++- src/bot/callbacks/mod.rs | 40 ++++-- src/bot/commands/settings.rs | 10 +- src/bot/modules/currency.rs | 1 - src/core/services/speech_recognition.rs | 4 +- 5 files changed, 194 insertions(+), 25 deletions(-) diff --git a/src/bot/callbacks/delete.rs b/src/bot/callbacks/delete.rs index 63cca39..a6749e5 100644 --- a/src/bot/callbacks/delete.rs +++ b/src/bot/callbacks/delete.rs @@ -1,12 +1,21 @@ use crate::{ - bot::keyboards::delete::confirm_delete_keyboard, - core::{config::Config, services::speech_recognition::back_handler}, + bot::{keyboards::delete::confirm_delete_keyboard, modules::Owner}, + core::{ + config::Config, + db::schemas::{ + group::Group as GroupSchema, settings::Settings as SettingsSchema, + user::User as UserSchema, + }, + services::speech_recognition::back_handler, + }, errors::MyError, }; use log::error; +use mongodb::bson::doc; +use oximod::Model; use teloxide::{ prelude::*, - types::{ChatId, User}, + types::{ChatId, InlineKeyboardButton, InlineKeyboardMarkup, User}, }; async fn has_delete_permission( @@ -27,6 +36,71 @@ async fn has_delete_permission( false } +async fn has_data_delete_permission( + bot: &Bot, + chat: &teloxide::types::Chat, + clicker: &User, +) -> bool { + if chat.is_private() { + return true; + } + if chat.is_group() || chat.is_supergroup() { + if let Ok(member) = bot.get_chat_member(chat.id, clicker.id).await { + return member.is_owner(); + } + } + false +} + +pub async fn handle_delete_data(bot: Bot, query: CallbackQuery) -> Result<(), MyError> { + let Some(message) = query.message.as_ref() else { + return Ok(()); + }; + + let can_delete = has_data_delete_permission(&bot, &message.chat(), &query.from).await; + + if !can_delete { + bot.answer_callback_query(query.id) + .text("❌ Удалить данные чата может только его владелец.") + .show_alert(true) + .await?; + return Ok(()); + } + + let (owner_type, owner_id, confirmation_text) = if message.chat().is_private() { + ( + "user", + query.from.id.to_string(), + "Вы уверены, что хотите удалить все ваши данные из бота?\n\nЭто действие необратимо!", + ) + } else { + ( + "group", + message.chat().id.to_string(), + "Вы уверены, что хотите удалить все данные этого чата из бота?\n\nЭто действие необратимо!", + ) + }; + + let keyboard = InlineKeyboardMarkup::new(vec![vec![ + InlineKeyboardButton::callback( + "Да, удалить", + format!("delete_data_confirm:{}:{}:yes", owner_type, owner_id), + ), + InlineKeyboardButton::callback( + "Нет, отмена", + format!("delete_data_confirm:{}:{}:no", owner_type, owner_id), + ), + ]]); + + bot.answer_callback_query(query.id).await?; + bot.edit_message_text(message.chat().id, message.id(), confirmation_text) + .reply_markup(keyboard) + .parse_mode(teloxide::types::ParseMode::Html) + .await?; + + Ok(()) +} + pub async fn handle_delete_request(bot: Bot, query: CallbackQuery) -> Result<(), MyError> { let Some(message) = query.message.as_ref().and_then(|m| m.regular_message()) else { return Ok(()); @@ -51,7 +125,7 @@ pub async fn handle_delete_request(bot: Bot, query: CallbackQuery) -> Result<(), &query.from, target_user_id, ) - .await; + .await; if !can_delete { bot.answer_callback_query(query.id) @@ -67,8 +141,8 @@ pub async fn handle_delete_request(bot: Bot, query: CallbackQuery) -> Result<(), message.id, "Вы уверены, что хотите удалить?", ) - .reply_markup(confirm_delete_keyboard(target_user_id)) - .await?; + .reply_markup(confirm_delete_keyboard(target_user_id)) + .await?; Ok(()) } @@ -102,7 +176,7 @@ pub async fn handle_delete_confirmation( &query.from, target_user_id, ) - .await; + .await; if !can_delete { bot.answer_callback_query(query.id) @@ -129,3 +203,79 @@ pub async fn handle_delete_confirmation( Ok(()) } + +pub async fn handle_delete_data_confirmation( + bot: Bot, + query: CallbackQuery, +) -> Result<(), MyError> { + let Some(message) = query.message.as_ref() else { + return Ok(()); + }; + let Some(data) = query.data.as_ref() else { + return Ok(()); + }; + + let parts: Vec<&str> = data + .strip_prefix("delete_data_confirm:") + .unwrap_or_default() + .split(':') + .collect(); + if parts.len() != 3 { + return Ok(()); + } + + let owner_type = parts[0]; + let owner_id = parts[1]; + let action = parts[2]; + + let can_delete = has_data_delete_permission(&bot, message.chat(), &query.from).await; + if !can_delete { + bot.answer_callback_query(query.id) + .text("❌ У вас нет прав для этого действия.") + .show_alert(true) + .await?; + return Ok(()); + } + + bot.answer_callback_query(query.id).await?; + + match action { + "yes" => { + let owner = Owner { + id: owner_id.to_string(), + r#type: owner_type.to_string(), + }; + + SettingsSchema::delete_one(doc! { "owner_id": &owner.id, "owner_type": &owner.r#type }) + .await?; + + if owner.r#type == "user" { + UserSchema::delete_one(doc! { "user_id": &owner.id }).await?; + } else if owner.r#type == "group" { + GroupSchema::delete_one(doc! { "group_id": &owner.id }).await?; + } + + let final_text = if owner.r#type == "user" { + "✅ Все ваши данные были успешно удалены." + } else { + "✅ Все данные этого чата были успешно удалены." + }; + + bot.edit_message_text(message.chat().id, message.id(), final_text) + .reply_markup(InlineKeyboardMarkup::new(vec![vec![]])) + .await?; + } + "no" => { + bot.edit_message_text( + message.chat().id, + message.id(), + "✅ Удаление данных отменено.", + ) + .reply_markup(InlineKeyboardMarkup::new(vec![vec![]])) + .await?; + } + _ => {} + } + + Ok(()) +} \ No newline at end of file diff --git a/src/bot/callbacks/mod.rs b/src/bot/callbacks/mod.rs index 28ae0f1..882fb3c 100644 --- a/src/bot/callbacks/mod.rs +++ b/src/bot/callbacks/mod.rs @@ -2,12 +2,15 @@ use crate::{ bot::{ callbacks::{ cobalt_pagination::handle_cobalt_pagination, - delete::{handle_delete_confirmation, handle_delete_request}, + delete::{ + handle_delete_confirmation, handle_delete_data_confirmation, + handle_delete_request, + }, translate::handle_translate_callback, whisper::handle_whisper_callback, }, commands::settings::update_settings_message, - modules::{Owner, registry::MOD_MANAGER}, + modules::{registry::MOD_MANAGER, Owner}, }, core::{ config::Config, @@ -17,10 +20,11 @@ use crate::{ }; use std::sync::Arc; use teloxide::{ - Bot, payloads::EditMessageTextSetters, prelude::{CallbackQuery, Requester}, + Bot, }; +use crate::bot::callbacks::delete::handle_delete_data; pub mod cobalt_pagination; pub mod delete; @@ -42,6 +46,8 @@ enum CallbackAction<'a> { owner_id: &'a str, }, CobaltPagination, + DeleteData, + DeleteDataConfirmation, DeleteMessage, DeleteConfirmation, Summarize, @@ -88,6 +94,12 @@ fn parse_callback_data(data: &'_ str) -> Option> { return Some(CallbackAction::ModuleSettings { module_key, rest }); } + if data.starts_with("delete_data_confirm:") { + return Some(CallbackAction::DeleteDataConfirmation); + } + if data == "delete_data" { + return Some(CallbackAction::DeleteData); + } if data.starts_with("delete_msg") { return Some(CallbackAction::DeleteMessage); } @@ -125,10 +137,10 @@ pub async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Result<(), M match parse_callback_data(data) { Some(CallbackAction::ModuleSelect { - owner_type, - owner_id, - module_key, - }) => { + owner_type, + owner_id, + module_key, + }) => { if let (Some(module), Some(message)) = (MOD_MANAGER.get_module(module_key), &q.message) { let owner = Owner { @@ -143,9 +155,9 @@ pub async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Result<(), M } } Some(CallbackAction::SettingsBack { - owner_type, - owner_id, - }) => { + owner_type, + owner_id, + }) => { if let Some(message) = q.message { update_settings_message(bot, message, owner_id.to_string(), owner_type.to_string()) .await?; @@ -161,12 +173,16 @@ pub async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Result<(), M } else { "group" }) - .to_string(), + .to_string(), }; module.handle_callback(bot, &q, &owner, rest).await?; } } Some(CallbackAction::CobaltPagination) => handle_cobalt_pagination(bot, q, config).await?, + Some(CallbackAction::DeleteData) => handle_delete_data(bot, q).await?, + Some(CallbackAction::DeleteDataConfirmation) => { + handle_delete_data_confirmation(bot, q).await? + } Some(CallbackAction::DeleteMessage) => handle_delete_request(bot, q).await?, Some(CallbackAction::DeleteConfirmation) => { handle_delete_confirmation(bot, q, &config).await? @@ -186,4 +202,4 @@ pub async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Result<(), M } Ok(()) -} +} \ No newline at end of file diff --git a/src/bot/commands/settings.rs b/src/bot/commands/settings.rs index 3ca9d13..43d427c 100644 --- a/src/bot/commands/settings.rs +++ b/src/bot/commands/settings.rs @@ -27,7 +27,7 @@ pub async fn settings_command_handler(bot: Bot, message: Message) -> Result<(), ❌ – модуль выключен\n\n" ); - let kb_buttons: Vec> = MOD_MANAGER + let mut kb_buttons: Vec> = MOD_MANAGER .get_designed_modules(&*owner_type) .into_iter() .map(|module| { @@ -52,6 +52,8 @@ pub async fn settings_command_handler(bot: Bot, message: Message) -> Result<(), }) .collect(); + kb_buttons.push(vec![InlineKeyboardButton::callback("🗑️ Удалить данные с бота", "delete_data")]); + let keyboard = InlineKeyboardMarkup::new(kb_buttons); bot.send_message(message.chat.id, text) @@ -81,8 +83,8 @@ pub async fn update_settings_message( ❌ – модуль выключен\n\n" ); - let kb_buttons: Vec> = MOD_MANAGER - .get_all_modules() + let mut kb_buttons: Vec> = MOD_MANAGER + .get_designed_modules(&*owner_type) .into_iter() .map(|module| { let settings: serde_json::Value = settings_doc @@ -104,6 +106,8 @@ pub async fn update_settings_message( }) .collect(); + kb_buttons.push(vec![InlineKeyboardButton::callback("🗑️ Удалить данные с бота", "delete_data")]); + let keyboard = InlineKeyboardMarkup::new(kb_buttons); if let MaybeInaccessibleMessage::Regular(msg) = message { diff --git a/src/bot/modules/currency.rs b/src/bot/modules/currency.rs index 500a3af..648064d 100644 --- a/src/bot/modules/currency.rs +++ b/src/bot/modules/currency.rs @@ -16,7 +16,6 @@ use teloxide::{ prelude::*, types::{InlineKeyboardButton, InlineKeyboardMarkup}, }; -use crate::bot::modules::whisper::WhisperSettings; use crate::core::services::currency::converter::get_default_currencies; #[derive(Debug, Serialize, Deserialize, Clone)] diff --git a/src/core/services/speech_recognition.rs b/src/core/services/speech_recognition.rs index fc4f1bc..b296162 100644 --- a/src/core/services/speech_recognition.rs +++ b/src/core/services/speech_recognition.rs @@ -170,7 +170,7 @@ pub async fn summarization_handler( if let Some(cached_summary) = cache_entry.summary { let final_text = format!( - "Краткое содержание:\n
{}
", + "✨:\n
{}
", cached_summary ); bot.edit_message_text(message.chat.id, message.id, final_text) @@ -205,7 +205,7 @@ pub async fn summarization_handler( cache.set(&file_cache_key, &cache_entry, 86400).await?; let final_text = format!( - "Краткое содержание:\n
{}
", + "✨:\n
{}
", new_summary ); bot.edit_message_text(message.chat.id, message.id, final_text) From 73886646d5030b351452cf8692df0fa6332aa2b1 Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Sun, 21 Sep 2025 16:39:51 +0300 Subject: [PATCH 63/66] now other users cant interact with setting buttons --- src/bot/callbacks/mod.rs | 171 +++++++++++++++++------------------ src/bot/commands/settings.rs | 51 ++++++----- src/bot/modules/cobalt.rs | 49 +++++----- src/bot/modules/currency.rs | 91 ++++++------------- src/bot/modules/mod.rs | 8 +- src/bot/modules/whisper.rs | 58 +++--------- src/errors.rs | 3 + 7 files changed, 183 insertions(+), 248 deletions(-) diff --git a/src/bot/callbacks/mod.rs b/src/bot/callbacks/mod.rs index 882fb3c..9314245 100644 --- a/src/bot/callbacks/mod.rs +++ b/src/bot/callbacks/mod.rs @@ -3,7 +3,7 @@ use crate::{ callbacks::{ cobalt_pagination::handle_cobalt_pagination, delete::{ - handle_delete_confirmation, handle_delete_data_confirmation, + handle_delete_confirmation, handle_delete_data, handle_delete_data_confirmation, handle_delete_request, }, translate::handle_translate_callback, @@ -24,7 +24,7 @@ use teloxide::{ prelude::{CallbackQuery, Requester}, Bot, }; -use crate::bot::callbacks::delete::handle_delete_data; +use teloxide::payloads::AnswerCallbackQuerySetters; pub mod cobalt_pagination; pub mod delete; @@ -35,18 +35,23 @@ enum CallbackAction<'a> { ModuleSettings { module_key: &'a str, rest: &'a str, + commander_id: u64, }, ModuleSelect { owner_type: &'a str, owner_id: &'a str, module_key: &'a str, + commander_id: u64, }, SettingsBack { owner_type: &'a str, owner_id: &'a str, + commander_id: u64, + }, + DeleteData { + commander_id: u64, }, CobaltPagination, - DeleteData, DeleteDataConfirmation, DeleteMessage, DeleteConfirmation, @@ -65,128 +70,120 @@ fn parse_callback_data(data: &'_ str) -> Option> { if let Some(rest) = data.strip_prefix("module_select:") { let parts: Vec<_> = rest.split(':').collect(); - if parts.len() == 3 { - return Some(CallbackAction::ModuleSelect { - owner_type: parts[0], - owner_id: parts[1], - module_key: parts[2], - }); + if parts.len() == 4 { + if let Ok(commander_id) = parts[3].parse() { + return Some(CallbackAction::ModuleSelect { + owner_type: parts[0], + owner_id: parts[1], + module_key: parts[2], + commander_id, + }); + } } } if let Some(rest) = data.strip_prefix("settings_back:") { let parts: Vec<_> = rest.split(':').collect(); - if parts.len() == 2 { - return Some(CallbackAction::SettingsBack { - owner_type: parts[0], - owner_id: parts[1], - }); + if parts.len() == 3 { + if let Ok(commander_id) = parts[2].parse() { + return Some(CallbackAction::SettingsBack { + owner_type: parts[0], + owner_id: parts[1], + commander_id, + }); + } } } if let Some(module_key) = MOD_MANAGER.get_all_modules().iter().find_map(|m| { - data.starts_with(&format!("{}:settings:", m.key())) - .then_some(m.key()) + data.starts_with(&format!("{}:settings:", m.key())).then_some(m.key()) }) { - let rest = data - .strip_prefix(&format!("{}:settings:", module_key)) - .unwrap_or(""); - return Some(CallbackAction::ModuleSettings { module_key, rest }); + let rest_with_id = data.strip_prefix(&format!("{}:settings:", module_key)).unwrap_or(""); + let parts: Vec<_> = rest_with_id.rsplitn(2, ':').collect(); + if parts.len() == 2 { + if let Ok(commander_id) = parts[0].parse() { + let rest = parts[1]; + return Some(CallbackAction::ModuleSettings { + module_key, + rest, + commander_id, + }); + } + } } - if data.starts_with("delete_data_confirm:") { - return Some(CallbackAction::DeleteDataConfirmation); - } - if data == "delete_data" { - return Some(CallbackAction::DeleteData); - } - if data.starts_with("delete_msg") { - return Some(CallbackAction::DeleteMessage); - } - if data.starts_with("delete_confirm:") { - return Some(CallbackAction::DeleteConfirmation); - } - if data.starts_with("summarize") { - return Some(CallbackAction::Summarize); - } - if data.starts_with("speech:page:") { - return Some(CallbackAction::SpeechPage); - } - if data.starts_with("back_to_full") { - return Some(CallbackAction::BackToFull); - } - if data.starts_with("whisper") { - return Some(CallbackAction::Whisper); - } - if data.starts_with("tr_") || data.starts_with("tr:") { - return Some(CallbackAction::Translate); - } - if data.starts_with("cobalt:") { - return Some(CallbackAction::CobaltPagination); + if let Some(commander_id_str) = data.strip_prefix("delete_data:") { + if let Ok(commander_id) = commander_id_str.parse() { + return Some(CallbackAction::DeleteData { commander_id }); + } } + if data.starts_with("delete_data_confirm:") { return Some(CallbackAction::DeleteDataConfirmation); } + if data.starts_with("delete_msg") { return Some(CallbackAction::DeleteMessage); } + if data.starts_with("delete_confirm:") { return Some(CallbackAction::DeleteConfirmation); } + if data.starts_with("summarize") { return Some(CallbackAction::Summarize); } + if data.starts_with("speech:page:") { return Some(CallbackAction::SpeechPage); } + if data.starts_with("back_to_full") { return Some(CallbackAction::BackToFull); } + if data.starts_with("whisper") { return Some(CallbackAction::Whisper); } + if data.starts_with("tr_") || data.starts_with("tr:") { return Some(CallbackAction::Translate); } + if data.starts_with("cobalt:") { return Some(CallbackAction::CobaltPagination); } + None } pub async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { let config = Arc::new(Config::new().await); - let Some(data) = &q.data else { - return Ok(()); - }; + let Some(data) = &q.data else { return Ok(()); }; match parse_callback_data(data) { - Some(CallbackAction::ModuleSelect { - owner_type, - owner_id, - module_key, - }) => { - if let (Some(module), Some(message)) = (MOD_MANAGER.get_module(module_key), &q.message) - { - let owner = Owner { - id: owner_id.to_string(), - r#type: owner_type.to_string(), - }; - let (text, keyboard) = module.get_settings_ui(&owner).await?; + Some(CallbackAction::ModuleSelect { owner_type, owner_id, module_key, commander_id }) => { + if q.from.id.0 != commander_id { + bot.answer_callback_query(q.id).text("❌ Вы не можете управлять этими настройками.").show_alert(true).await?; + return Ok(()); + } + if let (Some(module), Some(message)) = (MOD_MANAGER.get_module(module_key), &q.message) { + let owner = Owner { id: owner_id.to_string(), r#type: owner_type.to_string() }; + let (text, keyboard) = module.get_settings_ui(&owner, commander_id).await?; bot.edit_message_text(message.chat().id, message.id(), text) .reply_markup(keyboard) .parse_mode(teloxide::types::ParseMode::Html) .await?; } } - Some(CallbackAction::SettingsBack { - owner_type, - owner_id, - }) => { + Some(CallbackAction::SettingsBack { owner_type, owner_id, commander_id }) => { + if q.from.id.0 != commander_id { + bot.answer_callback_query(q.id).text("❌ Вы не можете управлять этими настройками.").show_alert(true).await?; + return Ok(()); + } if let Some(message) = q.message { - update_settings_message(bot, message, owner_id.to_string(), owner_type.to_string()) - .await?; + update_settings_message(bot, message, owner_id.to_string(), owner_type.to_string(), commander_id).await?; } } - Some(CallbackAction::ModuleSettings { module_key, rest }) => { - if let (Some(module), Some(message)) = (MOD_MANAGER.get_module(module_key), &q.message) - { + Some(CallbackAction::ModuleSettings { module_key, rest, commander_id }) => { + if q.from.id.0 != commander_id { + bot.answer_callback_query(q.id).text("❌ Вы не можете управлять этими настройками.").show_alert(true).await?; + return Ok(()); + } + if let (Some(module), Some(message)) = (MOD_MANAGER.get_module(module_key), &q.message) { let owner = Owner { id: message.chat().id.to_string(), - r#type: (if message.chat().is_private() { - "user" - } else { - "group" - }) - .to_string(), + r#type: (if message.chat().is_private() { "user" } else { "group" }).to_string(), }; - module.handle_callback(bot, &q, &owner, rest).await?; + module.handle_callback(bot, &q, &owner, rest, commander_id).await?; } } - Some(CallbackAction::CobaltPagination) => handle_cobalt_pagination(bot, q, config).await?, - Some(CallbackAction::DeleteData) => handle_delete_data(bot, q).await?, - Some(CallbackAction::DeleteDataConfirmation) => { - handle_delete_data_confirmation(bot, q).await? + Some(CallbackAction::DeleteData { commander_id }) => { + if q.from.id.0 != commander_id { + bot.answer_callback_query(q.id).text("❌ Вы не можете управлять этими настройками.").show_alert(true).await?; + return Ok(()); + } + handle_delete_data(bot, q).await? } + Some(CallbackAction::CobaltPagination) => handle_cobalt_pagination(bot, q, config).await?, + Some(CallbackAction::DeleteDataConfirmation) => handle_delete_data_confirmation(bot, q).await?, Some(CallbackAction::DeleteMessage) => handle_delete_request(bot, q).await?, - Some(CallbackAction::DeleteConfirmation) => { - handle_delete_confirmation(bot, q, &config).await? - } + Some(CallbackAction::DeleteConfirmation) => handle_delete_confirmation(bot, q, &config).await?, Some(CallbackAction::Summarize) => summarization_handler(bot, q, &config).await?, Some(CallbackAction::SpeechPage) => pagination_handler(bot, q, &config).await?, Some(CallbackAction::BackToFull) => back_handler(bot, q, &config).await?, diff --git a/src/bot/commands/settings.rs b/src/bot/commands/settings.rs index 43d427c..e982edf 100644 --- a/src/bot/commands/settings.rs +++ b/src/bot/commands/settings.rs @@ -6,13 +6,10 @@ use teloxide::prelude::*; use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup, MaybeInaccessibleMessage}; pub async fn settings_command_handler(bot: Bot, message: Message) -> Result<(), MyError> { + let commander_id = message.from().map(|u| u.id.0).ok_or(MyError::UserNotFound)?; + let owner_id = message.chat.id.to_string(); - let owner_type = if message.chat.is_private() { - "user" - } else { - "group" - } - .to_string(); + let owner_type = if message.chat.is_private() { "user" } else { "group" }.to_string(); let settings_doc = Settings::get_or_create(&Owner { id: owner_id.clone(), @@ -24,7 +21,8 @@ pub async fn settings_command_handler(bot: Bot, message: Message) -> Result<(), "⚙️ Настройки модулей\n\n\ Нажмите на кнопку, чтобы включить или выключить соответствующий модуль.\n\ ✅ – модуль включён\n\ - ❌ – модуль выключен\n\n" + ❌ – модуль выключен\n\n\ + Только тот, кто вызвал это сообщение, может управлять настройками." ); let mut kb_buttons: Vec> = MOD_MANAGER @@ -37,22 +35,24 @@ pub async fn settings_command_handler(bot: Bot, message: Message) -> Result<(), .cloned() .unwrap_or_default(); - let is_enabled = settings - .get("enabled") - .and_then(|v| v.as_bool()) - .unwrap_or(false); + let is_enabled = settings.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false); let status = if is_enabled { "✅" } else { "❌" }; let text = format!("{} — {}", status, module.name()); - let callback_data = - format!("module_select:{}:{}:{}", owner_type, owner_id, module.key()); + let callback_data = format!( + "module_select:{}:{}:{}:{}", + owner_type, owner_id, module.key(), commander_id + ); vec![InlineKeyboardButton::callback(text, callback_data)] }) .collect(); - kb_buttons.push(vec![InlineKeyboardButton::callback("🗑️ Удалить данные с бота", "delete_data")]); + kb_buttons.push(vec![InlineKeyboardButton::callback( + "🗑️ Удалить данные с бота", + format!("delete_data:{}", commander_id), + )]); let keyboard = InlineKeyboardMarkup::new(kb_buttons); @@ -69,6 +69,7 @@ pub async fn update_settings_message( message: MaybeInaccessibleMessage, owner_id: String, owner_type: String, + commander_id: u64, ) -> Result<(), MyError> { let settings_doc = Settings::get_or_create(&Owner { id: owner_id.clone(), @@ -80,7 +81,8 @@ pub async fn update_settings_message( "⚙️ Настройки модулей\n\n\ Нажмите на кнопку, чтобы включить или выключить соответствующий модуль.\n\ ✅ – модуль включён\n\ - ❌ – модуль выключен\n\n" + ❌ – модуль выключен\n\n\ + Только тот, кто вызвал это сообщение, может управлять настройками." ); let mut kb_buttons: Vec> = MOD_MANAGER @@ -92,21 +94,24 @@ pub async fn update_settings_message( .get(module.key()) .cloned() .unwrap_or_default(); - let is_enabled = settings - .get("enabled") - .and_then(|v| v.as_bool()) - .unwrap_or(false); + let is_enabled = settings.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false); let status = if is_enabled { "✅" } else { "❌" }; let text = format!("{} — {}", status, module.name()); - let callback_data = - format!("module_select:{}:{}:{}", owner_type, owner_id, module.key()); + + let callback_data = format!( + "module_select:{}:{}:{}:{}", + owner_type, owner_id, module.key(), commander_id + ); vec![InlineKeyboardButton::callback(text, callback_data)] }) .collect(); - kb_buttons.push(vec![InlineKeyboardButton::callback("🗑️ Удалить данные с бота", "delete_data")]); + kb_buttons.push(vec![InlineKeyboardButton::callback( + "🗑️ Удалить данные с бота", + format!("delete_data:{}", commander_id), + )]); let keyboard = InlineKeyboardMarkup::new(kb_buttons); @@ -119,4 +124,4 @@ pub async fn update_settings_message( } Ok(()) -} +} \ No newline at end of file diff --git a/src/bot/modules/cobalt.rs b/src/bot/modules/cobalt.rs index 9d91a1d..75e919f 100644 --- a/src/bot/modules/cobalt.rs +++ b/src/bot/modules/cobalt.rs @@ -48,26 +48,19 @@ impl Module for CobaltModule { async fn get_settings_ui( &self, owner: &Owner, + commander_id: u64, ) -> Result<(String, InlineKeyboardMarkup), MyError> { let settings: CobaltSettings = Settings::get_module_settings(owner, self.key()).await?; let text = format!( "⚙️ Настройки модуля: {}\n\nСтатус: {}", self.description(), - if settings.enabled { - "✅ Включен" - } else { - "❌ Выключен" - } + if settings.enabled { "✅ Включен" } else { "❌ Выключен" } ); let toggle_button = InlineKeyboardButton::callback( - if settings.enabled { - "Выключить модуль" - } else { - "Включить модуль" - }, - format!("{}:settings:toggle_module", self.key()), + if settings.enabled { "Выключить модуль" } else { "Включить модуль" }, + format!("{}:settings:toggle_module:{}", self.key(), commander_id), ); let quality_options = [ @@ -84,7 +77,12 @@ impl Module for CobaltModule { } else { format!("{}p", q.as_str()) }; - let cb_data = format!("{}:settings:set:quality:{}", self.key(), q.as_str()); + let cb_data = format!( + "{}:settings:set:quality:{}:{}", + self.key(), + q.as_str(), + commander_id + ); InlineKeyboardButton::callback(display_text, cb_data) }) .collect::>(); @@ -95,9 +93,10 @@ impl Module for CobaltModule { "Атрибуция: Выкл ❌" }; let attr_cb = format!( - "{}:settings:set:attribution:{}", + "{}:settings:set:attribution:{}:{}", self.key(), - !settings.attribution + !settings.attribution, + commander_id ); let keyboard = InlineKeyboardMarkup::new(vec![ @@ -107,7 +106,7 @@ impl Module for CobaltModule { vec![InlineKeyboardButton::callback(attr_text, attr_cb)], vec![InlineKeyboardButton::callback( "⬅️ Назад", - format!("settings_back:{}:{}", owner.r#type, owner.id), + format!("settings_back:{}:{}:{}", owner.r#type, owner.id, commander_id), )], ]); @@ -120,14 +119,10 @@ impl Module for CobaltModule { q: &CallbackQuery, owner: &Owner, data: &str, + commander_id: u64, ) -> Result<(), MyError> { - let Some(message) = &q.message else { - return Ok(()); - }; - - let Some(message) = message.regular_message() else { - return Ok(()); - }; + let Some(message) = &q.message else { return Ok(()); }; + let Some(message) = message.regular_message() else { return Ok(()); }; let parts: Vec<_> = data.split(':').collect(); @@ -137,7 +132,7 @@ impl Module for CobaltModule { settings.enabled = !settings.enabled; Settings::update_module_settings(owner, self.key(), settings).await?; - let (text, keyboard) = self.get_settings_ui(owner).await?; + let (text, keyboard) = self.get_settings_ui(owner, commander_id).await?; bot.edit_message_text(message.chat.id, message.id, text) .reply_markup(keyboard) .parse_mode(teloxide::types::ParseMode::Html) @@ -164,7 +159,7 @@ impl Module for CobaltModule { Settings::update_module_settings(owner, self.key(), settings).await?; - let (text, keyboard) = self.get_settings_ui(owner).await?; + let (text, keyboard) = self.get_settings_ui(owner, commander_id).await?; bot.edit_message_text(message.chat.id, message.id, text) .reply_markup(keyboard) .parse_mode(teloxide::types::ParseMode::Html) @@ -174,16 +169,14 @@ impl Module for CobaltModule { } fn designed_for(&self, owner_type: &str) -> bool { - owner_type == "user" // user + owner_type == "user" } async fn is_enabled(&self, owner: &Owner) -> bool { if !self.designed_for(&*owner.r#type) { return false; } - let settings: CobaltSettings = Settings::get_module_settings(owner, self.key()).await.unwrap(); // god of unwraps - settings.enabled } @@ -195,4 +188,4 @@ impl Module for CobaltModule { }; Ok(serde_json::to_value(factory_settings)?) } -} +} \ No newline at end of file diff --git a/src/bot/modules/currency.rs b/src/bot/modules/currency.rs index 648064d..b08e3a5 100644 --- a/src/bot/modules/currency.rs +++ b/src/bot/modules/currency.rs @@ -4,7 +4,7 @@ use crate::{ db::schemas::{group::Group, settings::Settings, user::User}, services::{ currencier::handle_currency_update, - currency::converter::{CURRENCY_CONFIG_PATH, get_all_currency_codes}, + currency::converter::{get_all_currency_codes, get_default_currencies, CURRENCY_CONFIG_PATH}, }, }, errors::MyError, @@ -16,7 +16,6 @@ use teloxide::{ prelude::*, types::{InlineKeyboardButton, InlineKeyboardMarkup}, }; -use crate::core::services::currency::converter::get_default_currencies; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct CurrencySettings { @@ -27,13 +26,9 @@ pub struct CurrencySettings { impl Default for CurrencySettings { fn default() -> Self { let default_currencies = get_default_currencies() - .map(|currencies| - currencies.into_iter() - .map(|c| c.code) - .collect::>() - ) + .map(|currencies| currencies.into_iter().map(|c| c.code).collect::>()) .unwrap_or_else(|_| vec!["usd".to_string(), "eur".to_string()]); - + Self { enabled: true, selected_codes: default_currencies, @@ -62,8 +57,9 @@ impl Module for CurrencyModule { async fn get_settings_ui( &self, owner: &Owner, + commander_id: u64, ) -> Result<(String, InlineKeyboardMarkup), MyError> { - self.get_paged_settings_ui(owner, 0).await + self.get_paged_settings_ui(owner, 0, commander_id).await } async fn handle_callback( @@ -72,55 +68,40 @@ impl Module for CurrencyModule { q: &CallbackQuery, owner: &Owner, data: &str, + commander_id: u64, ) -> Result<(), MyError> { - let Some(message) = &q.message else { - return Ok(()); - }; + let Some(message) = &q.message else { return Ok(()); }; + let Some(message) = message.regular_message() else { return Ok(()); }; - let Some(message) = message.regular_message() else { - return Ok(()); - }; let parts: Vec<_> = data.split(':').collect(); if parts.len() == 1 && parts[0] == "toggle_module" { let mut settings: CurrencySettings = Settings::get_module_settings(owner, self.key()).await?; - settings.enabled = !settings.enabled; - if settings.enabled && settings.selected_codes.is_empty() { settings.selected_codes = vec![ - "UAH".to_string(), - "RUB".to_string(), - "USD".to_string(), - "BYN".to_string(), - "EUR".to_string(), - "TON".to_string(), + "UAH".to_string(), "RUB".to_string(), "USD".to_string(), + "BYN".to_string(), "EUR".to_string(), "TON".to_string(), ]; } - Settings::update_module_settings(owner, self.key(), settings).await?; - let (text, keyboard) = self.get_paged_settings_ui(owner, 0).await?; - + let (text, keyboard) = self.get_paged_settings_ui(owner, 0, commander_id).await?; bot.edit_message_text(message.chat.id, message.id, text) .reply_markup(keyboard) .parse_mode(teloxide::types::ParseMode::Html) .await?; - return Ok(()); } if parts.len() == 2 && parts[0] == "page" { let page = parts[1].parse::().unwrap_or(0); - - let (text, keyboard) = self.get_paged_settings_ui(owner, page).await?; - + let (text, keyboard) = self.get_paged_settings_ui(owner, page, commander_id).await?; bot.edit_message_text(message.chat.id, message.id, text) .reply_markup(keyboard) .parse_mode(teloxide::types::ParseMode::Html) .await?; - return Ok(()); } @@ -128,20 +109,13 @@ impl Module for CurrencyModule { let currency_code = parts[1].to_string(); let mut settings: CurrencySettings = Settings::get_module_settings(owner, self.key()).await?; - - if let Some(pos) = settings - .selected_codes - .iter() - .position(|c| *c == currency_code) - { + if let Some(pos) = settings.selected_codes.iter().position(|c| *c == currency_code) { settings.selected_codes.remove(pos); } else { settings.selected_codes.push(currency_code); } Settings::update_module_settings(owner, self.key(), settings).await?; - - let (text, keyboard) = self.get_paged_settings_ui(owner, 0).await?; // TODO: сохранить текущую страницу - + let (text, keyboard) = self.get_paged_settings_ui(owner, 0, commander_id).await?; // TODO: сохранить текущую страницу bot.edit_message_text(message.chat.id, message.id, text) .reply_markup(keyboard) .parse_mode(teloxide::types::ParseMode::Html) @@ -149,7 +123,6 @@ impl Module for CurrencyModule { } else { bot.answer_callback_query(q.id.clone()).await?; } - Ok(()) } @@ -161,9 +134,7 @@ impl Module for CurrencyModule { if !self.designed_for(&*owner.r#type) { return false; } - let settings: CurrencySettings = Settings::get_module_settings(owner, self.key()).await.unwrap(); // god of unwraps - settings.enabled } @@ -171,12 +142,8 @@ impl Module for CurrencyModule { let factory_settings = CurrencySettings { enabled: true, selected_codes: vec![ - "UAH".to_string(), - "RUB".to_string(), - "USD".to_string(), - "BYN".to_string(), - "EUR".to_string(), - "TON".to_string(), + "UAH".to_string(), "RUB".to_string(), "USD".to_string(), + "BYN".to_string(), "EUR".to_string(), "TON".to_string(), ], }; Ok(serde_json::to_value(factory_settings)?) @@ -188,32 +155,25 @@ impl CurrencyModule { &self, owner: &Owner, page: usize, + commander_id: u64, ) -> Result<(String, InlineKeyboardMarkup), MyError> { let settings: CurrencySettings = Settings::get_module_settings(owner, self.key()).await?; let text = format!( "⚙️ Настройки модуля: {}\n\nСтатус: {}\n\nВыберите валюты для отображения.", self.description(), - if settings.enabled { - "✅ Включен" - } else { - "❌ Выключен" - } + if settings.enabled { "✅ Включен" } else { "❌ Выключен" } ); let toggle_button = InlineKeyboardButton::callback( - if settings.enabled { - "Выключить модуль" - } else { - "Включить модуль" - }, - format!("{}:settings:toggle_module", self.key()), + if settings.enabled { "Выключить модуль" } else { "Включить модуль" }, + format!("{}:settings:toggle_module:{}", self.key(), commander_id), ); let all_currencies = get_all_currency_codes(CURRENCY_CONFIG_PATH.parse().unwrap())?; let back_button = InlineKeyboardButton::callback( "⬅️ Назад", - format!("settings_back:{}:{}", owner.r#type, owner.id), + format!("settings_back:{}:{}:{}", owner.r#type, owner.id, commander_id), ); let mut keyboard = Paginator::from(self.key(), &all_currencies) @@ -226,7 +186,12 @@ impl CurrencyModule { let is_selected = settings.selected_codes.contains(¤cy.code); let icon = if is_selected { "✅" } else { "❌" }; let button_text = format!("{} {}", icon, currency.code); - let callback_data = format!("{}:settings:toggle:{}", self.key(), currency.code); + let callback_data = format!( + "{}:settings:toggle:{}:{}", + self.key(), + currency.code, + commander_id + ); InlineKeyboardButton::callback(button_text, callback_data) }); @@ -242,4 +207,4 @@ pub async fn currency_codes_handler(bot: Bot, msg: Message, code: String) -> Res } else { handle_currency_update::(bot, msg, code).await } -} +} \ No newline at end of file diff --git a/src/bot/modules/mod.rs b/src/bot/modules/mod.rs index be83be9..e0c3d1c 100644 --- a/src/bot/modules/mod.rs +++ b/src/bot/modules/mod.rs @@ -5,7 +5,7 @@ pub mod whisper; use crate::errors::MyError; use async_trait::async_trait; -use serde::{Serialize, de::DeserializeOwned}; +use serde::{de::DeserializeOwned, Serialize}; use std::fmt::Debug; use teloxide::{prelude::*, types::InlineKeyboardMarkup}; @@ -17,7 +17,7 @@ pub struct Owner { #[async_trait] pub trait ModuleSettings: - Sized + Default + Serialize + DeserializeOwned + Debug + Send + Sync +Sized + Default + Serialize + DeserializeOwned + Debug + Send + Sync { } @@ -32,6 +32,7 @@ pub trait Module: Send + Sync { async fn get_settings_ui( &self, owner: &Owner, + commander_id: u64, ) -> Result<(String, InlineKeyboardMarkup), MyError>; async fn handle_callback( @@ -40,6 +41,7 @@ pub trait Module: Send + Sync { q: &CallbackQuery, owner: &Owner, data: &str, + commander_id: u64, ) -> Result<(), MyError>; // this function returns true if the module is designed for the owner type. like if module is designed for user, it will return true for user and false for group, and doesn't show in group @@ -48,4 +50,4 @@ pub trait Module: Send + Sync { async fn is_enabled(&self, owner: &Owner) -> bool; fn factory_settings(&self) -> Result; -} +} \ No newline at end of file diff --git a/src/bot/modules/whisper.rs b/src/bot/modules/whisper.rs index 9451b92..4236ec0 100644 --- a/src/bot/modules/whisper.rs +++ b/src/bot/modules/whisper.rs @@ -17,9 +17,7 @@ pub struct WhisperSettings { impl Default for WhisperSettings { fn default() -> Self { - Self { - enabled: false, - } + Self { enabled: false } } } @@ -44,33 +42,26 @@ impl Module for WhisperModule { async fn get_settings_ui( &self, owner: &Owner, + commander_id: u64, ) -> Result<(String, InlineKeyboardMarkup), MyError> { let settings: WhisperSettings = Settings::get_module_settings(owner, self.key()).await?; let text = format!( "⚙️ Настройки модуля: {}\n\nСтатус: {}", self.description(), - if settings.enabled { - "✅ Включен" - } else { - "❌ Выключен" - } + if settings.enabled { "✅ Включен" } else { "❌ Выключен" } ); let toggle_button = InlineKeyboardButton::callback( - if settings.enabled { - "Выключить модуль" - } else { - "Включить модуль" - }, - format!("{}:settings:toggle_module", self.key()), + if settings.enabled { "Выключить модуль" } else { "Включить модуль" }, + format!("{}:settings:toggle_module:{}", self.key(), commander_id), ); let keyboard = InlineKeyboardMarkup::new(vec![ vec![toggle_button], vec![InlineKeyboardButton::callback( "⬅️ Назад", - format!("settings_back:{}:{}", owner.r#type, owner.id), + format!("settings_back:{}:{}:{}", owner.r#type, owner.id, commander_id), )], ]); @@ -83,14 +74,10 @@ impl Module for WhisperModule { q: &CallbackQuery, owner: &Owner, data: &str, + commander_id: u64, ) -> Result<(), MyError> { - let Some(message) = &q.message else { - return Ok(()); - }; - - let Some(message) = message.regular_message() else { - return Ok(()); - }; + let Some(message) = &q.message else { return Ok(()); }; + let Some(message) = message.regular_message() else { return Ok(()); }; let parts: Vec<_> = data.split(':').collect(); @@ -100,7 +87,7 @@ impl Module for WhisperModule { settings.enabled = !settings.enabled; Settings::update_module_settings(owner, self.key(), settings).await?; - let (text, keyboard) = self.get_settings_ui(owner).await?; + let (text, keyboard) = self.get_settings_ui(owner, commander_id).await?; bot.edit_message_text(message.chat.id, message.id, text) .reply_markup(keyboard) .parse_mode(teloxide::types::ParseMode::Html) @@ -108,42 +95,25 @@ impl Module for WhisperModule { return Ok(()); } - if parts.len() < 3 || parts[0] != "set" { - bot.answer_callback_query(q.id.clone()).await?; - return Ok(()); - } - - let settings: WhisperSettings = Settings::get_module_settings(owner, self.key()).await?; - - Settings::update_module_settings(owner, self.key(), settings).await?; - - let (text, keyboard) = self.get_settings_ui(owner).await?; - bot.edit_message_text(message.chat.id, message.id, text) - .reply_markup(keyboard) - .parse_mode(teloxide::types::ParseMode::Html) - .await?; + bot.answer_callback_query(q.id.clone()).await?; Ok(()) } fn designed_for(&self, owner_type: &str) -> bool { - owner_type == "user" // user + owner_type == "user" } async fn is_enabled(&self, owner: &Owner) -> bool { if !self.designed_for(&*owner.r#type) { return false; } - let settings: WhisperSettings = Settings::get_module_settings(owner, self.key()).await.unwrap(); // god of unwraps - settings.enabled } fn factory_settings(&self) -> Result { - let factory_settings = WhisperSettings { - enabled: true, - }; + let factory_settings = WhisperSettings { enabled: true }; Ok(serde_json::to_value(factory_settings)?) } -} +} \ No newline at end of file diff --git a/src/errors.rs b/src/errors.rs index 94c67da..61d6e16 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -61,6 +61,9 @@ pub enum MyError { #[error("Serde json error: {0}")] SerdeJson(#[from] serde_json::Error), + + #[error("User not found")] + UserNotFound, } impl From<&str> for MyError { From c4574816642459f34b8900e9115b2fcffba417ca Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Sun, 21 Sep 2025 17:01:55 +0300 Subject: [PATCH 64/66] updated start command and welcome message --- src/bot/commands/settings.rs | 2 +- src/bot/commands/start.rs | 77 +++++++++++++++------------ src/bot/messages/chat.rs | 100 ++++++++++++++++++++--------------- src/core/db/schemas/group.rs | 11 ++++ src/core/db/schemas/user.rs | 9 ++++ 5 files changed, 123 insertions(+), 76 deletions(-) diff --git a/src/bot/commands/settings.rs b/src/bot/commands/settings.rs index e982edf..7d22594 100644 --- a/src/bot/commands/settings.rs +++ b/src/bot/commands/settings.rs @@ -6,7 +6,7 @@ use teloxide::prelude::*; use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup, MaybeInaccessibleMessage}; pub async fn settings_command_handler(bot: Bot, message: Message) -> Result<(), MyError> { - let commander_id = message.from().map(|u| u.id.0).ok_or(MyError::UserNotFound)?; + let commander_id = message.from.map(|u| u.id.0).ok_or(MyError::UserNotFound)?; let owner_id = message.chat.id.to_string(); let owner_type = if message.chat.is_private() { "user" } else { "group" }.to_string(); diff --git a/src/bot/commands/start.rs b/src/bot/commands/start.rs index 05acc49..0008f2c 100644 --- a/src/bot/commands/start.rs +++ b/src/bot/commands/start.rs @@ -14,6 +14,7 @@ use teloxide::{ prelude::*, types::{ParseMode, ReplyParameters}, }; +use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup}; pub async fn start_handler( bot: Bot, @@ -21,29 +22,20 @@ pub async fn start_handler( config: &Config, _arg: String, ) -> Result<(), MyError> { - if message.chat.is_private() { - let user = message.from.clone().unwrap(); - - if User::find_one(doc! { "user_id": &user.id.to_string() }) - .await? - .is_none() - { - User::new() - .user_id(user.id.to_string().clone()) - .save() - .await?; + let mut is_new_user = false; - let owner = Owner { - id: user.id.to_string(), - r#type: "user".to_string(), - }; - Settings::create_with_defaults(&owner).await?; + if message.chat.is_private() { + if let Some(user) = message.from { + if User::find_one(doc! { "user_id": &user.id.to_string() }).await?.is_none() { + is_new_user = true; + User::new().user_id(user.id.to_string().clone()).save().await?; - bot.send_message( - message.chat.id, - "Welcome! You have been successfully registered", - ) - .await?; + let owner = Owner { + id: user.id.to_string(), + r#type: "user".to_string(), + }; + Settings::create_with_defaults(&owner).await?; + } } } @@ -56,28 +48,47 @@ pub async fn start_handler( let mut system_info = System::new_all(); system_info.refresh_all(); - let total_ram_bytes = system_info.total_memory(); - let used_ram_bytes = system_info.used_memory(); - - let total_ram_mb = total_ram_bytes / (1024 * 1024); - let used_ram_mb = used_ram_bytes / (1024 * 1024); - + let total_ram_mb = system_info.total_memory() / (1024 * 1024); + let used_ram_mb = system_info.used_memory() / (1024 * 1024); let cpu_usage_percent = system_info.global_cpu_usage(); + let welcome_part = if is_new_user { + "Добро пожаловать! 👋\n\n\ + Я Fulturate — ваш многофункциональный ассистент. \ + Чтобы посмотреть все возможности и настроить меня, используйте команду /settings.\n\n".to_string() + } else { + "Fulturate тут! ⚙️\n\n".to_string() + }; + let response_message = format!( - "[BETA] Telegram Bot by @Weever && @nixxoq\n\ + "{welcome_part}\ + Статус системы:\n\
\
-        > Version: {}\n\
-        > API Ping: {} ms\n\
-        > CPU Usage: {:.2}%\n\
-        > RAM Usage: {}/{} MB\n\
+        > Версия:      {}\n\
+        > Пинг API:    {} мс\n\
+        > Нагрузка ЦП: {:.2}%\n\
+        > ОЗУ:         {}/{} МБ\n\
         
", version, api_ping, cpu_usage_percent, used_ram_mb, total_ram_mb ); + let news_link_button = + InlineKeyboardButton::url("Канал с новостями", "https://t.me/fulturate".parse().unwrap()); + let terms_of_use_link_button = InlineKeyboardButton::url( + "Условия использования", + "https://telegra.ph/Terms-Of-Use--Usloviya-ispolzovaniya-09-21" + .parse() + .unwrap(), + ); + bot.send_message(message.chat.id, response_message) .reply_parameters(ReplyParameters::new(message.id)) .parse_mode(ParseMode::Html) + .reply_markup(InlineKeyboardMarkup::new(vec![vec![ + news_link_button, + terms_of_use_link_button, + ]])) .await?; + Ok(()) -} +} \ No newline at end of file diff --git a/src/bot/messages/chat.rs b/src/bot/messages/chat.rs index cdaf587..dab0a20 100644 --- a/src/bot/messages/chat.rs +++ b/src/bot/messages/chat.rs @@ -3,76 +3,92 @@ use crate::{ core::db::schemas::{group::Group, settings::Settings, user::User}, errors::MyError, }; -use log::{error, info}; +use log::{info}; use mongodb::bson::doc; use oximod::ModelTrait; use teloxide::{ - Bot, payloads::SendMessageSetters, prelude::Requester, types::{ChatMemberUpdated, InlineKeyboardButton, InlineKeyboardMarkup, ParseMode}, + Bot, }; pub async fn handle_bot_added(bot: Bot, update: ChatMemberUpdated) -> Result<(), MyError> { let id = update.chat.id.to_string(); - let news_link_button = InlineKeyboardButton::url( - "Канал с новостями о Боте", - "https://t.me/fulturate".parse().unwrap(), - ); - let github_link_button = InlineKeyboardButton::url( - "Github", - "https://github.com/Fulturate/bot".parse().unwrap(), - ); - let msg = bot - .send_message( - update.chat.id, - "Добро пожаловать в Fulturate!\n\nМы тут когда нибудь что-то точно сделаем..." - .to_string(), - ) - .parse_mode(ParseMode::Html) - .reply_markup(InlineKeyboardMarkup::new(vec![vec![ - news_link_button, - github_link_button, - ]])); if update.new_chat_member.is_banned() || update.new_chat_member.is_left() { - info!("Administrator is banned or user is blocked me. Deleting from DB"); + info!("Bot was kicked/banned. Deleting all data for ID: {}", &id); - let delete_result = if update.chat.is_private() { - User::delete(doc! { "user_id": id.clone() }).await - } else { - Group::delete(doc! { "group_id": id.clone() }).await - }; + let owner_type = if update.chat.is_private() { "user" } else { "group" }; - if let Err(e) = delete_result { - error!("Could not delete entity. ID: {} | Error: {}", &id, e); + if owner_type == "user" { + User::delete(doc! { "user_id": &id }).await.ok(); + } else { + Group::delete(doc! { "group_id": &id }).await.ok(); } + Settings::delete(doc! { "owner_id": &id, "owner_type": owner_type }) + .await + .ok(); + return Ok(()); } - info!("New chat added. ID: {}", id); + info!("Bot added to chat. ID: {}", &id); + + let welcome_text = if update.chat.is_private() { + "Добро пожаловать в Fulturate! 👋\n\n\ + Я готов помочь с различными задачами!\n\n\ + Вот краткий список моих возможностей:\n\ + - 📥 Скачивание медиа: из различных источников.\n\ + - 💱 Конвертация валют: актуальные курсы всегда под рукой.\n\ + - 🤫 Система «шепота»: для более приватного общения.\n\n\ + Чтобы настроить модули под себя и узнать больше, используйте команду /settings." + .to_string() + } else { + "Спасибо, что добавили Fulturate в ваш чат! 🎉\n\n\ + Я многофункциональный бот, готовый помогать вашему чату!\n\n\ + Для полноценной работы мне необходимы права администратора. \ + Это позволит мне обрабатывать команды и эффективно взаимодействовать с участниками.\n\n\ + Чтобы настроить мои модули и возможности, один из администраторов чата может использовать команду /settings." + .to_string() + }; + + let news_link_button = + InlineKeyboardButton::url("Канал с новостями", "https://t.me/fulturate".parse().unwrap()); + let terms_of_use_link_button = InlineKeyboardButton::url( + "Условия использования", + "https://telegra.ph/Terms-Of-Use--Usloviya-ispolzovaniya-09-21" + .parse()?, + ); + + bot.send_message(update.chat.id, welcome_text) + .parse_mode(ParseMode::Html) + .reply_markup(InlineKeyboardMarkup::new(vec![vec![ + news_link_button, + terms_of_use_link_button, + ]])) + .await?; if update.chat.is_private() { if User::find_one(doc! { "user_id": &id }).await?.is_none() { User::new().user_id(id.clone()).save().await?; - let owner = Owner { - id, - r#type: "user".to_string(), - }; - Settings::create_with_defaults(&owner).await?; - - msg.await?; } - } else if Group::find_one(doc! { "group_id": &id }).await?.is_none() { - Group::new().group_id(id.clone()).save().await?; + let owner = Owner { + id, + r#type: "user".to_string(), + }; + Settings::get_or_create(&owner).await?; + } else { + if Group::find_one(doc! { "group_id": &id }).await?.is_none() { + Group::new().group_id(id.clone()).save().await?; + } let owner = Owner { id, r#type: "group".to_string(), }; - Settings::create_with_defaults(&owner).await?; - msg.await?; + Settings::get_or_create(&owner).await?; } Ok(()) -} +} \ No newline at end of file diff --git a/src/core/db/schemas/group.rs b/src/core/db/schemas/group.rs index 2f3cd4c..f25d247 100644 --- a/src/core/db/schemas/group.rs +++ b/src/core/db/schemas/group.rs @@ -76,3 +76,14 @@ impl CurrenciesFunctions for Group { .await } } + +impl Group { + pub async fn get_or_create(id: &str) -> Result { + if Self::find_one(doc! { "group_id": id }).await?.is_some() { + Ok(false) + } else { + Self::new().group_id(id.to_string()).save().await?; + Ok(true) + } + } +} diff --git a/src/core/db/schemas/user.rs b/src/core/db/schemas/user.rs index adcf02e..f450bf8 100644 --- a/src/core/db/schemas/user.rs +++ b/src/core/db/schemas/user.rs @@ -79,6 +79,15 @@ impl CurrenciesFunctions for User { } impl User { + pub async fn get_or_create(id: &str) -> Result { + if Self::find_one(doc! { "user_id": id }).await?.is_some() { + Ok(false) + } else { + Self::new().user_id(id.to_string()).save().await?; + Ok(true) + } + } + pub async fn increment_download_count(user_id: &str) -> Result { Self::update_one( doc! { "user_id": user_id }, From 6e645731145f2b94fcba984d8e6b535198d9a3fb Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Sun, 21 Sep 2025 17:04:21 +0300 Subject: [PATCH 65/66] cargo clippy my brother --- src/bot/callbacks/delete.rs | 7 +++---- src/bot/callbacks/mod.rs | 20 ++++++++------------ src/bot/commands/settings.rs | 4 ++-- src/bot/commands/start.rs | 8 +++----- src/bot/dispatcher.rs | 14 +++++--------- src/bot/modules/cobalt.rs | 4 ++-- src/bot/modules/currency.rs | 2 +- src/bot/modules/whisper.rs | 8 ++------ src/core/services/cobalt.rs | 2 +- 9 files changed, 27 insertions(+), 42 deletions(-) diff --git a/src/bot/callbacks/delete.rs b/src/bot/callbacks/delete.rs index a6749e5..99169e2 100644 --- a/src/bot/callbacks/delete.rs +++ b/src/bot/callbacks/delete.rs @@ -44,11 +44,10 @@ async fn has_data_delete_permission( if chat.is_private() { return true; } - if chat.is_group() || chat.is_supergroup() { - if let Ok(member) = bot.get_chat_member(chat.id, clicker.id).await { + if (chat.is_group() || chat.is_supergroup()) + && let Ok(member) = bot.get_chat_member(chat.id, clicker.id).await { return member.is_owner(); } - } false } @@ -57,7 +56,7 @@ pub async fn handle_delete_data(bot: Bot, query: CallbackQuery) -> Result<(), My return Ok(()); }; - let can_delete = has_data_delete_permission(&bot, &message.chat(), &query.from).await; + let can_delete = has_data_delete_permission(&bot, message.chat(), &query.from).await; if !can_delete { bot.answer_callback_query(query.id) diff --git a/src/bot/callbacks/mod.rs b/src/bot/callbacks/mod.rs index 9314245..0e41464 100644 --- a/src/bot/callbacks/mod.rs +++ b/src/bot/callbacks/mod.rs @@ -70,8 +70,8 @@ fn parse_callback_data(data: &'_ str) -> Option> { if let Some(rest) = data.strip_prefix("module_select:") { let parts: Vec<_> = rest.split(':').collect(); - if parts.len() == 4 { - if let Ok(commander_id) = parts[3].parse() { + if parts.len() == 4 + && let Ok(commander_id) = parts[3].parse() { return Some(CallbackAction::ModuleSelect { owner_type: parts[0], owner_id: parts[1], @@ -79,20 +79,18 @@ fn parse_callback_data(data: &'_ str) -> Option> { commander_id, }); } - } } if let Some(rest) = data.strip_prefix("settings_back:") { let parts: Vec<_> = rest.split(':').collect(); - if parts.len() == 3 { - if let Ok(commander_id) = parts[2].parse() { + if parts.len() == 3 + && let Ok(commander_id) = parts[2].parse() { return Some(CallbackAction::SettingsBack { owner_type: parts[0], owner_id: parts[1], commander_id, }); } - } } if let Some(module_key) = MOD_MANAGER.get_all_modules().iter().find_map(|m| { @@ -100,8 +98,8 @@ fn parse_callback_data(data: &'_ str) -> Option> { }) { let rest_with_id = data.strip_prefix(&format!("{}:settings:", module_key)).unwrap_or(""); let parts: Vec<_> = rest_with_id.rsplitn(2, ':').collect(); - if parts.len() == 2 { - if let Ok(commander_id) = parts[0].parse() { + if parts.len() == 2 + && let Ok(commander_id) = parts[0].parse() { let rest = parts[1]; return Some(CallbackAction::ModuleSettings { module_key, @@ -109,14 +107,12 @@ fn parse_callback_data(data: &'_ str) -> Option> { commander_id, }); } - } } - if let Some(commander_id_str) = data.strip_prefix("delete_data:") { - if let Ok(commander_id) = commander_id_str.parse() { + if let Some(commander_id_str) = data.strip_prefix("delete_data:") + && let Ok(commander_id) = commander_id_str.parse() { return Some(CallbackAction::DeleteData { commander_id }); } - } if data.starts_with("delete_data_confirm:") { return Some(CallbackAction::DeleteDataConfirmation); } if data.starts_with("delete_msg") { return Some(CallbackAction::DeleteMessage); } diff --git a/src/bot/commands/settings.rs b/src/bot/commands/settings.rs index 7d22594..a97a62b 100644 --- a/src/bot/commands/settings.rs +++ b/src/bot/commands/settings.rs @@ -26,7 +26,7 @@ pub async fn settings_command_handler(bot: Bot, message: Message) -> Result<(), ); let mut kb_buttons: Vec> = MOD_MANAGER - .get_designed_modules(&*owner_type) + .get_designed_modules(&owner_type) .into_iter() .map(|module| { let settings: serde_json::Value = settings_doc @@ -86,7 +86,7 @@ pub async fn update_settings_message( ); let mut kb_buttons: Vec> = MOD_MANAGER - .get_designed_modules(&*owner_type) + .get_designed_modules(&owner_type) .into_iter() .map(|module| { let settings: serde_json::Value = settings_doc diff --git a/src/bot/commands/start.rs b/src/bot/commands/start.rs index 0008f2c..5bd5e62 100644 --- a/src/bot/commands/start.rs +++ b/src/bot/commands/start.rs @@ -24,9 +24,9 @@ pub async fn start_handler( ) -> Result<(), MyError> { let mut is_new_user = false; - if message.chat.is_private() { - if let Some(user) = message.from { - if User::find_one(doc! { "user_id": &user.id.to_string() }).await?.is_none() { + if message.chat.is_private() + && let Some(user) = message.from + && User::find_one(doc! { "user_id": &user.id.to_string() }).await?.is_none() { is_new_user = true; User::new().user_id(user.id.to_string().clone()).save().await?; @@ -36,8 +36,6 @@ pub async fn start_handler( }; Settings::create_with_defaults(&owner).await?; } - } - } let version = config.get_version(); diff --git a/src/bot/dispatcher.rs b/src/bot/dispatcher.rs index 782e2f4..4b96019 100644 --- a/src/bot/dispatcher.rs +++ b/src/bot/dispatcher.rs @@ -66,7 +66,7 @@ async fn is_user_registered(q: InlineQuery) -> bool { let user_id_str = q.from.id.to_string(); DBUser::find_one(doc! { "user_id": &user_id_str }) .await - .map_or(false, |user| user.is_some()) + .is_ok_and(|user| user.is_some()) } async fn prompt_registration(bot: Bot, q: InlineQuery, me: Me) -> Result<(), MyError> { @@ -114,16 +114,12 @@ async fn are_any_inline_modules_enabled(q: InlineQuery) -> bool { if let Ok(settings) = Settings::get_or_create(&owner).await { for module in MOD_MANAGER.get_all_modules() { - if module.is_enabled(&owner).await { - if let Some(settings_json) = settings.modules.get(module.key()) { - if let Ok(check) = serde_json::from_value::(settings_json.clone()) - { - if check.enabled { + if module.is_enabled(&owner).await + && let Some(settings_json) = settings.modules.get(module.key()) + && let Ok(check) = serde_json::from_value::(settings_json.clone()) + && check.enabled { return true; } - } - } - } } } false diff --git a/src/bot/modules/cobalt.rs b/src/bot/modules/cobalt.rs index 75e919f..30bf175 100644 --- a/src/bot/modules/cobalt.rs +++ b/src/bot/modules/cobalt.rs @@ -149,7 +149,7 @@ impl Module for CobaltModule { match (parts[1], parts[2]) { ("quality", val) => { - settings.video_quality = VideoQuality::from_str(val); + settings.video_quality = VideoQuality::parse_quality(val); } ("attribution", val) => { settings.attribution = val.parse().unwrap_or(false); @@ -173,7 +173,7 @@ impl Module for CobaltModule { } async fn is_enabled(&self, owner: &Owner) -> bool { - if !self.designed_for(&*owner.r#type) { + if !self.designed_for(&owner.r#type) { return false; } let settings: CobaltSettings = Settings::get_module_settings(owner, self.key()).await.unwrap(); // god of unwraps diff --git a/src/bot/modules/currency.rs b/src/bot/modules/currency.rs index b08e3a5..79f0ba5 100644 --- a/src/bot/modules/currency.rs +++ b/src/bot/modules/currency.rs @@ -131,7 +131,7 @@ impl Module for CurrencyModule { } async fn is_enabled(&self, owner: &Owner) -> bool { - if !self.designed_for(&*owner.r#type) { + if !self.designed_for(&owner.r#type) { return false; } let settings: CurrencySettings = Settings::get_module_settings(owner, self.key()).await.unwrap(); // god of unwraps diff --git a/src/bot/modules/whisper.rs b/src/bot/modules/whisper.rs index 4236ec0..f88fa08 100644 --- a/src/bot/modules/whisper.rs +++ b/src/bot/modules/whisper.rs @@ -11,15 +11,11 @@ use teloxide::{ }; #[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Default)] pub struct WhisperSettings { pub enabled: bool, } -impl Default for WhisperSettings { - fn default() -> Self { - Self { enabled: false } - } -} impl ModuleSettings for WhisperSettings {} @@ -105,7 +101,7 @@ impl Module for WhisperModule { } async fn is_enabled(&self, owner: &Owner) -> bool { - if !self.designed_for(&*owner.r#type) { + if !self.designed_for(&owner.r#type) { return false; } let settings: WhisperSettings = Settings::get_module_settings(owner, self.key()).await.unwrap(); // god of unwraps diff --git a/src/core/services/cobalt.rs b/src/core/services/cobalt.rs index 0da706c..d5b2d1f 100644 --- a/src/core/services/cobalt.rs +++ b/src/core/services/cobalt.rs @@ -23,7 +23,7 @@ impl VideoQuality { } } - pub fn from_str(s: &str) -> Self { + pub fn parse_quality(s: &str) -> Self { match s { "1080" => VideoQuality::Q1080, "1440" => VideoQuality::Q1440, From a751130fb34ce028168362f8a13270e22ccf733e Mon Sep 17 00:00:00 2001 From: Weever1337 Date: Sun, 21 Sep 2025 17:30:32 +0300 Subject: [PATCH 66/66] updated descriptions for modules --- src/bot/commands/settings.rs | 6 ++---- src/bot/modules/cobalt.rs | 8 ++++++-- src/bot/modules/currency.rs | 5 +++-- src/bot/modules/whisper.rs | 5 +++-- 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/src/bot/commands/settings.rs b/src/bot/commands/settings.rs index a97a62b..2157424 100644 --- a/src/bot/commands/settings.rs +++ b/src/bot/commands/settings.rs @@ -21,8 +21,7 @@ pub async fn settings_command_handler(bot: Bot, message: Message) -> Result<(), "⚙️ Настройки модулей\n\n\ Нажмите на кнопку, чтобы включить или выключить соответствующий модуль.\n\ ✅ – модуль включён\n\ - ❌ – модуль выключен\n\n\ - Только тот, кто вызвал это сообщение, может управлять настройками." + ❌ – модуль выключен\n\n" ); let mut kb_buttons: Vec> = MOD_MANAGER @@ -81,8 +80,7 @@ pub async fn update_settings_message( "⚙️ Настройки модулей\n\n\ Нажмите на кнопку, чтобы включить или выключить соответствующий модуль.\n\ ✅ – модуль включён\n\ - ❌ – модуль выключен\n\n\ - Только тот, кто вызвал это сообщение, может управлять настройками." + ❌ – модуль выключен\n\n" ); let mut kb_buttons: Vec> = MOD_MANAGER diff --git a/src/bot/modules/cobalt.rs b/src/bot/modules/cobalt.rs index 30bf175..aa6f79d 100644 --- a/src/bot/modules/cobalt.rs +++ b/src/bot/modules/cobalt.rs @@ -42,7 +42,10 @@ impl Module for CobaltModule { } fn description(&self) -> &'static str { - "Возможность скачивать видео, фото, аудио" // todo change this shit + "Можно скачивать видео, фото и аудио с популярных платформ: YouTube, TikTok, Reddit (только видео), Instagram, Bluesky, Bilibili, Dailymotion, Facebook, Loom, OK, Pinterest, Newgrounds, Snapchat, SoundCloud, Streamable, Tumblr, Twitch Clips, Twitter, Iméo, Xiaohongshu. \ + Огромная благодарность создателям утилиты cobalt.tools.\n\n\ + Модуль доступен через inlin'ы: \"@fulturatebot ссылка на медиа\"\n\ + Некоторые сервисы могут быть временно недоступны не по нашей вине из-за ограничений или изменений на стороне платформ." } async fn get_settings_ui( @@ -53,7 +56,8 @@ impl Module for CobaltModule { let settings: CobaltSettings = Settings::get_module_settings(owner, self.key()).await?; let text = format!( - "⚙️ Настройки модуля: {}\n\nСтатус: {}", + "⚙️ Настройки модуля: {}\n
{}
\nСтатус: {}", + self.name(), self.description(), if settings.enabled { "✅ Включен" } else { "❌ Выключен" } ); diff --git a/src/bot/modules/currency.rs b/src/bot/modules/currency.rs index 79f0ba5..55b63bb 100644 --- a/src/bot/modules/currency.rs +++ b/src/bot/modules/currency.rs @@ -51,7 +51,7 @@ impl Module for CurrencyModule { } fn description(&self) -> &'static str { - "Возможность конвертировать валюты" // todo change this shit + "Конвертация валют и криптовалют с актуальными курсами" } async fn get_settings_ui( @@ -159,7 +159,8 @@ impl CurrencyModule { ) -> Result<(String, InlineKeyboardMarkup), MyError> { let settings: CurrencySettings = Settings::get_module_settings(owner, self.key()).await?; let text = format!( - "⚙️ Настройки модуля: {}\n\nСтатус: {}\n\nВыберите валюты для отображения.", + "⚙️ Настройки модуля: {}\n
{}
\nСтатус: {}\n\nВыберите валюты для отображения.", + self.name(), self.description(), if settings.enabled { "✅ Включен" } else { "❌ Выключен" } ); diff --git a/src/bot/modules/whisper.rs b/src/bot/modules/whisper.rs index f88fa08..19286cb 100644 --- a/src/bot/modules/whisper.rs +++ b/src/bot/modules/whisper.rs @@ -32,7 +32,7 @@ impl Module for WhisperModule { } fn description(&self) -> &'static str { - "Модуль «шептать», позволяющая работать с текстовыми сообщениями в более приватном режиме. Протестировать можно через inlin'ы." + "Модуль «шептать», позволяющий работать с текстовыми сообщениями в более приватном режиме. Протестировать можно через inlin'ы: \"@fulturatebot *сообщение шепота* @username1 *id*\"" } async fn get_settings_ui( @@ -43,7 +43,8 @@ impl Module for WhisperModule { let settings: WhisperSettings = Settings::get_module_settings(owner, self.key()).await?; let text = format!( - "⚙️ Настройки модуля: {}\n\nСтатус: {}", + "⚙️ Настройки модуля: {}\n
{}
\nСтатус: {}", + self.name(), self.description(), if settings.enabled { "✅ Включен" } else { "❌ Выключен" } );