diff --git a/Cargo.toml b/Cargo.toml index 78b2886..d9833fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fulturate" -version = "0.13.1" +version = "0.13.2" edition = "2024" [dependencies] @@ -10,7 +10,7 @@ dotenv = "0.15" sysinfo = "0.37.0" reqwest = "0.12.22" bytes = "1.10.0" -gem-rs = "0.1.80" +gem-rs = { git = "https://github.com/Fulturate/Gem-rs.git"} serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" regex = "1.11.1" diff --git a/src/bot/callbacks/delete.rs b/src/bot/callbacks/delete.rs index 99169e2..39df038 100644 --- a/src/bot/callbacks/delete.rs +++ b/src/bot/callbacks/delete.rs @@ -9,33 +9,16 @@ use crate::{ services::speech_recognition::back_handler, }, errors::MyError, + util::is_admin_or_author, }; use log::error; use mongodb::bson::doc; use oximod::Model; use teloxide::{ prelude::*, - types::{ChatId, InlineKeyboardButton, InlineKeyboardMarkup, User}, + types::{InlineKeyboardButton, InlineKeyboardMarkup, User}, }; -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 || clicker.id.0 == target_user_id { - return true; - } - - if is_group && let Ok(member) = bot.get_chat_member(chat_id, clicker.id).await { - return member.is_privileged(); - } - - false -} - async fn has_data_delete_permission( bot: &Bot, chat: &teloxide::types::Chat, @@ -45,9 +28,10 @@ async fn has_data_delete_permission( return true; } if (chat.is_group() || chat.is_supergroup()) - && let Ok(member) = bot.get_chat_member(chat.id, clicker.id).await { - return member.is_owner(); - } + && let Ok(member) = bot.get_chat_member(chat.id, clicker.id).await + { + return member.is_owner(); + } false } @@ -117,14 +101,14 @@ pub async fn handle_delete_request(bot: Bot, query: CallbackQuery) -> Result<(), return Ok(()); }; - let can_delete = has_delete_permission( + let can_delete = is_admin_or_author( &bot, message.chat.id, message.chat.is_group() || message.chat.is_supergroup(), &query.from, target_user_id, ) - .await; + .await; if !can_delete { bot.answer_callback_query(query.id) @@ -140,8 +124,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(()) } @@ -168,14 +152,14 @@ pub async fn handle_delete_confirmation( }; let action = parts[2]; - let can_delete = has_delete_permission( + let can_delete = is_admin_or_author( &bot, message.chat.id, message.chat.is_group() || message.chat.is_supergroup(), &query.from, target_user_id, ) - .await; + .await; if !can_delete { bot.answer_callback_query(query.id) @@ -270,11 +254,11 @@ pub async fn handle_delete_data_confirmation( message.id(), "✅ Удаление данных отменено.", ) - .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/bot/callbacks/mod.rs b/src/bot/callbacks/mod.rs index 5f2ad48..341796e 100644 --- a/src/bot/callbacks/mod.rs +++ b/src/bot/callbacks/mod.rs @@ -25,6 +25,7 @@ use teloxide::{ payloads::{AnswerCallbackQuerySetters, EditMessageTextSetters}, prelude::{CallbackQuery, Requester}, }; +use crate::core::services::speech_recognition::retry_speech_handler; pub mod cobalt_pagination; pub mod delete; @@ -55,7 +56,15 @@ enum CallbackAction<'a> { DeleteDataConfirmation, DeleteMessage, DeleteConfirmation, - Summarize, + Summarize { + user_id: u64, + }, + RetrySpeech { + message_id: i32, + user_id: u64, + action_type: &'a str, // "transcribe" or "summarize" + attempt: u32, + }, SpeechPage, BackToFull, Whisper, @@ -135,8 +144,26 @@ fn parse_callback_data(data: &'_ str) -> Option> { if data.starts_with("delete_confirm:") { return Some(CallbackAction::DeleteConfirmation); } - if data.starts_with("summarize") { - return Some(CallbackAction::Summarize); + // if data.starts_with("summarize") { + if let Some(author_id) = data.strip_prefix("summarize:") + && let Ok(author_id) = author_id.parse() + { + return Some(CallbackAction::Summarize { user_id: author_id }); + } + if let Some(rest) = data.strip_prefix("retry_speech:") { + let parts: Vec<_> = rest.splitn(4, ':').collect(); + if parts.len() == 4 + && let Ok(message_id) = parts[0].parse() + && let Ok(user_id) = parts[1].parse() + && let Ok(attempt) = parts[3].parse() + { + return Some(CallbackAction::RetrySpeech { + message_id, + user_id, + action_type: parts[2], // ahh tupoy The trait bound `&str: FromStr` is not satisfied + attempt + }); + } } if data.starts_with("speech:page:") { return Some(CallbackAction::SpeechPage); @@ -160,7 +187,7 @@ fn parse_callback_data(data: &'_ str) -> Option> { 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 { + let Some(data) = &q.data.clone() else { return Ok(()); }; @@ -250,7 +277,7 @@ 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, commander_id) @@ -275,7 +302,10 @@ pub async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Result<(), M Some(CallbackAction::DeleteConfirmation) => { handle_delete_confirmation(bot, q, &config).await? } - Some(CallbackAction::Summarize) => summarization_handler(bot, q, &config).await?, + Some(CallbackAction::Summarize {user_id}) => summarization_handler(bot, q, &config, user_id).await?, + Some(CallbackAction::RetrySpeech { message_id, user_id, action_type, attempt }) => { + retry_speech_handler(bot, q.clone(), &config, message_id, user_id, action_type, attempt).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?, @@ -290,4 +320,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/callbacks/translate.rs b/src/bot/callbacks/translate.rs index 8bcb4ea..74873e5 100644 --- a/src/bot/callbacks/translate.rs +++ b/src/bot/callbacks/translate.rs @@ -8,15 +8,19 @@ use crate::{ services::translation::{SUPPORTED_LANGUAGES, normalize_language_code}, }, errors::MyError, - util::paginator::{FrameBuild, Paginator}, + util::{ + is_author, + paginator::{FrameBuild, Paginator}, + }, }; use futures::future::join_all; +use log::info; use teloxide::{ ApiError, RequestError, prelude::*, types::{ CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, MaybeInaccessibleMessage, - Message, ParseMode, + Message, ParseMode, User, }, utils::html::escape, }; @@ -29,11 +33,25 @@ pub async fn handle_translate_callback( config: &Config, ) -> Result<(), MyError> { if let (Some(data), Some(MaybeInaccessibleMessage::Regular(message))) = (&q.data, &q.message) { + if let Some(author_id) = data + .rsplit_once(":") + .and_then(|(_, last)| last.parse::().ok()) + && !is_author(&q.from, author_id) + { + bot.answer_callback_query(q.id) + .text("❌ у вас нет прав использовать эту кнопку!") + .show_alert(true) + .await?; + + return Ok(()); + } + bot.answer_callback_query(q.id.clone()).await?; if let Some(rest) = data.strip_prefix("tr:page:") { let parts: Vec<_> = rest.split(':').collect(); - if parts.len() == 2 { + if parts.len() >= 2 { + info!("parts: {:#?}", parts); let translation_id = parts[0]; if let Ok(page) = parts[1].parse::() { handle_translation_pagination(&bot, message, translation_id, page, config) @@ -44,7 +62,7 @@ pub async fn handle_translate_callback( 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" { + } else if data.starts_with("tr_show_langs") { handle_show_languages(&bot, message, &q.from, config).await?; } } else { @@ -110,8 +128,14 @@ async fn handle_language_menu_pagination( message: &Message, data: &str, ) -> Result<(), MyError> { - if let Ok(page) = data.trim_start_matches("tr_page:").parse::() { - let keyboard = create_language_keyboard(page); + let parts = data + .strip_prefix("tr_page:") + .and_then(|rest| rest.rsplit_once(':')); + + if let Some((page_str, user_id_str)) = parts + && let (Ok(page), Ok(user_id)) = (page_str.parse(), user_id_str.parse()) + { + let keyboard = create_language_keyboard(page, user_id); if let Err(e) = bot .edit_message_reply_markup(message.chat.id, message.id) .reply_markup(keyboard) @@ -128,10 +152,16 @@ async fn handle_language_selection( bot: Bot, message: &Message, data: &str, - user: teloxide::types::User, + user: User, config: &Config, ) -> Result<(), MyError> { - let target_lang = data.trim_start_matches("tr_lang:"); + let Some(target_lang) = data + .trim_start_matches("tr_lang:") + .rsplit_once(":").map(|(lang, _)| lang) + else { + return Ok(()); + }; + let redis_client = config.get_redis_client(); let redis_key_job = format!("translate_job:{}", user.id); @@ -185,8 +215,10 @@ async fn handle_language_selection( 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 switch_lang_button = InlineKeyboardButton::callback( + lang_display_name.to_string(), + format!("tr_show_langs:{}", user.id.0), + ); 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); @@ -234,7 +266,7 @@ async fn handle_language_selection( async fn handle_show_languages( bot: &Bot, message: &Message, - user: &teloxide::types::User, + user: &User, config: &Config, ) -> Result<(), MyError> { if let Some(original_message) = message.reply_to_message() { @@ -262,7 +294,7 @@ async fn handle_show_languages( return Ok(()); } - let keyboard = create_language_keyboard(0); + let keyboard = create_language_keyboard(0, user.id.0); bot.edit_message_text( message.chat.id, message.id, diff --git a/src/bot/commands/translate.rs b/src/bot/commands/translate.rs index 6ec34cf..cdbdf33 100644 --- a/src/bot/commands/translate.rs +++ b/src/bot/commands/translate.rs @@ -123,7 +123,7 @@ pub async fn translate_handler( .set(&format!("translate_job:{}", user.id), &job, 600) .await?; - let keyboard = create_language_keyboard(0); + let keyboard = create_language_keyboard(0, user.id.0); bot.send_message(msg.chat.id, "Выберите язык для перевода:") .reply_markup(keyboard) .reply_parameters(ReplyParameters::new(replied_to_message.id)) diff --git a/src/bot/dispatcher.rs b/src/bot/dispatcher.rs index 4b96019..75170bd 100644 --- a/src/bot/dispatcher.rs +++ b/src/bot/dispatcher.rs @@ -1,6 +1,3 @@ -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, @@ -13,18 +10,22 @@ use crate::{ keyboards::delete::delete_message_button, messager::{handle_currency, handle_speech}, messages::chat::handle_bot_added, + modules::{Owner, registry::MOD_MANAGER}, + }, + core::{ + config::Config, + db::schemas::{settings::Settings, user::User as DBUser}, }, - core::{config::Config, - db::schemas::user::User as DBUser}, errors::MyError, util::enums::Command, }; use log::{debug, error, info}; use mongodb::bson::doc; -use oximod::{set_global_client, Model}; +use oximod::{Model, set_global_client}; use serde::Deserialize; use std::{convert::Infallible, fmt::Write, ops::ControlFlow, sync::Arc}; use teloxide::{ + Bot, dispatching::{ Dispatcher, DpHandlerDescription, HandlerExt, MessageFilterExt, UpdateFilterExt, }, @@ -39,8 +40,8 @@ use teloxide::{ }, update_listeners::Polling, utils::{command::BotCommands, html}, - Bot, }; +use crate::bot::inlines::translate::{handle_translate_inline, is_translate_query}; async fn root_handler( update: Update, @@ -87,8 +88,8 @@ async fn prompt_registration(bot: Bot, q: InlineQuery, me: Me) -> Result<(), MyE "Чтобы использовать бота, пожалуйста, сначала начните диалог с ним.", )), ) - .description("Нажмите здесь, чтобы начать чат с ботом и разблокировать все функции.") - .reply_markup(keyboard); + .description("Нажмите здесь, чтобы начать чат с ботом и разблокировать все функции.") + .reply_markup(keyboard); if let Err(e) = bot .answer_inline_query(q.id, vec![InlineQueryResult::Article(article)]) @@ -116,10 +117,11 @@ async fn are_any_inline_modules_enabled(q: InlineQuery) -> bool { for module in MOD_MANAGER.get_all_modules() { 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; - } + && let Ok(check) = serde_json::from_value::(settings_json.clone()) + && check.enabled + { + return true; + } } } false @@ -133,7 +135,7 @@ async fn send_modules_disabled_message(bot: Bot, q: InlineQuery) -> Result<(), M "Все инлайн-модули выключены. Чтобы ими воспользоваться, активируйте их в настройках.", )), ) - .description("Используйте /settings в чате с ботом, чтобы включить их."); + .description("Используйте /settings в чате с ботом, чтобы включить их."); bot.answer_inline_query(q.id, vec![InlineQueryResult::Article(article)]) .cache_time(10) @@ -149,7 +151,9 @@ pub fn inline_query_handler() -> Handler<'static, Result<(), MyError>, DpHandler ) .branch( dptree::filter_async(is_user_registered) - .filter_async(|q: InlineQuery| async move { !are_any_inline_modules_enabled(q).await }) + .filter_async( + |q: InlineQuery| async move { !are_any_inline_modules_enabled(q).await }, + ) .endpoint(send_modules_disabled_message), ) .branch( @@ -157,6 +161,7 @@ pub fn inline_query_handler() -> Handler<'static, Result<(), MyError>, DpHandler .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_translate_query).endpoint(handle_translate_inline)) .branch(dptree::filter_async(is_whisper_query).endpoint(handle_whisper_inline)), ) } @@ -244,7 +249,7 @@ pub async fn handle_error(err: Arc, update: Update, config: Arc "В чате: {}{}", chat.id, title ) - .unwrap(); + .unwrap(); } else { writeln!(&mut message_text, "В чате: (???)").unwrap(); } @@ -260,7 +265,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(); } @@ -271,7 +276,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(); @@ -303,4 +308,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/mod.rs b/src/bot/inlines/mod.rs index e0a2024..20b5a3f 100644 --- a/src/bot/inlines/mod.rs +++ b/src/bot/inlines/mod.rs @@ -1,3 +1,4 @@ pub mod cobalter; pub mod currency; pub mod whisper; +pub mod translate; diff --git a/src/bot/inlines/translate.rs b/src/bot/inlines/translate.rs new file mode 100644 index 0000000..99751a3 --- /dev/null +++ b/src/bot/inlines/translate.rs @@ -0,0 +1,119 @@ +use crate::{ + bot::modules::{translate::TranslateSettings, Owner}, + core::{ + config::Config, + db::schemas::settings::Settings, + services::translation::{normalize_language_code, SUPPORTED_LANGUAGES}, + }, + errors::MyError, +}; +use futures::future::join_all; +use std::sync::Arc; +use teloxide::{ + payloads::AnswerInlineQuerySetters, + prelude::*, + types::{ + InlineQuery, InlineQueryResult, InlineQueryResultArticle, InputMessageContent, + InputMessageContentText, + }, +}; +use translators::{GoogleTranslator, Translator}; +use uuid::Uuid; + +pub async fn handle_translate_inline( + bot: Bot, + q: InlineQuery, + config: Arc, +) -> Result<(), MyError> { + let text_to_translate = q.query.trim(); + + if text_to_translate.is_empty() { + let help_article = InlineQueryResultArticle::new( + "translate_help", + "Как использовать inline-перевод?", + InputMessageContent::Text(InputMessageContentText::new( + "Просто начните вводить текст, который хотите перевести.", + )), + ) + .description("Введите текст для перевода..."); + + bot.answer_inline_query(q.id, vec![InlineQueryResult::Article(help_article)]) + .cache_time(10) + .await?; + return Ok(()); + } + + let redis_key = format!("user_lang:{}", q.from.id); + let cached_lang: Option = config.get_redis_client().get(&redis_key).await?; + + let mut target_langs = vec!["en".to_string(), "ru".to_string(), "uk".to_string(), "de".to_string()]; + if let Some(lang) = cached_lang { + target_langs.retain(|l| l != &lang); + target_langs.insert(0, lang); + } + target_langs.dedup(); + + let google_trans = GoogleTranslator::default(); + let translation_futures = target_langs.iter().map(|lang| { + let text = text_to_translate.to_string(); + let lang = lang.to_string(); + let value = google_trans.clone(); + async move { + let normalized_lang = normalize_language_code(&lang); + value + .translate_async(&text, "", &normalized_lang) + .await + .map(|translated_text| (normalized_lang, translated_text)) + } + }); + + let results = join_all(translation_futures).await; + let successful_translations: Vec<(String, String)> = + results.into_iter().filter_map(Result::ok).collect(); + + if successful_translations.is_empty() { + let no_result_article = InlineQueryResultArticle::new( + "translate_no_result", + "Не удалось перевести", + InputMessageContent::Text(InputMessageContentText::new( + "Не удалось выполнить перевод. Попробуйте позже.", + )), + ) + .description("Сервис перевода может быть недоступен."); + + bot.answer_inline_query(q.id, vec![InlineQueryResult::Article(no_result_article)]) + .await?; + return Ok(()); + } + + let mut articles = Vec::new(); + for (lang_code, translated_text) in successful_translations { + let lang_display_name = SUPPORTED_LANGUAGES + .iter() + .find(|(code, _)| *code == lang_code) + .map(|(_, name)| *name) + .unwrap_or(&lang_code); + + let article = InlineQueryResultArticle::new( + Uuid::new_v4().to_string(), + format!("Перевод на {}", lang_display_name), + InputMessageContent::Text(InputMessageContentText::new(translated_text.clone())), + ) + .description(translated_text); + articles.push(InlineQueryResult::Article(article)); + } + + bot.answer_inline_query(q.id, articles).await?; + + Ok(()) +} + +pub async fn is_translate_query(q: InlineQuery) -> bool { + let owner = Owner { + id: q.from.id.to_string(), + r#type: "user".to_string(), + }; + + let settings = Settings::get_module_settings::(&owner, "translate").await.unwrap(); + settings.enabled +} diff --git a/src/bot/inlines/whisper.rs b/src/bot/inlines/whisper.rs index 7d17730..34a6a99 100644 --- a/src/bot/inlines/whisper.rs +++ b/src/bot/inlines/whisper.rs @@ -97,16 +97,6 @@ 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", @@ -277,7 +267,12 @@ pub async fn handle_whisper_inline( Ok(()) } -// TODO: impl module settings for whisper query -pub async fn is_whisper_query(_q: InlineQuery) -> bool { - true +pub async fn is_whisper_query(q: InlineQuery) -> bool { + let owner = Owner { + id: q.from.id.to_string(), + r#type: "user".to_string(), + }; + + let settings = Settings::get_module_settings::(&owner, "whisper").await.unwrap(); + settings.enabled } diff --git a/src/bot/keyboards/transcription.rs b/src/bot/keyboards/transcription.rs index daca6c6..d031ae0 100644 --- a/src/bot/keyboards/transcription.rs +++ b/src/bot/keyboards/transcription.rs @@ -8,7 +8,7 @@ pub fn create_transcription_keyboard( total_pages: usize, user_id: u64, ) -> InlineKeyboardMarkup { - let summary_button = InlineKeyboardButton::callback("✨", "summarize"); + let summary_button = InlineKeyboardButton::callback("✨", format!("summarize:{}", user_id)); let delete_button = InlineKeyboardButton::callback("🗑️", format!("delete_msg:{}", user_id)); Paginator::new(TRANSCRIPTION_MODULE_KEY, total_pages) @@ -24,3 +24,10 @@ pub fn create_summary_keyboard() -> InlineKeyboardMarkup { "back_to_full", )]]) } + +pub fn create_retry_keyboard(message_id: i32, user_id: u64, action_type: &str, attempt: u32) -> InlineKeyboardMarkup { + InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::callback( + "🔁 Повторить попытку", + format!("retry_speech:{}:{}:{}:{}", message_id, user_id, action_type, attempt), + )]/*, delete_message_button(user_id).inline_keyboard.first().unwrap().to_vec()*/]) +} \ No newline at end of file diff --git a/src/bot/keyboards/translate.rs b/src/bot/keyboards/translate.rs index 7a5a0b0..677ee94 100644 --- a/src/bot/keyboards/translate.rs +++ b/src/bot/keyboards/translate.rs @@ -2,13 +2,16 @@ 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 { +pub fn create_language_keyboard(page: usize, user_id: u64) -> InlineKeyboardMarkup { Paginator::from("tr", SUPPORTED_LANGUAGES) .per_page(LANGUAGES_PER_PAGE) .columns(2) .current_page(page) - .set_callback_formatter(|p| format!("tr_page:{}", p)) + .set_callback_formatter(|p| format!("tr_page:{}:{}", p, user_id)) .build(|(code, name)| { - InlineKeyboardButton::callback(name.to_string(), format!("tr_lang:{}", code)) + InlineKeyboardButton::callback( + name.to_string(), + format!("tr_lang:{}:{}", code, user_id), + ) }) } diff --git a/src/bot/modules/mod.rs b/src/bot/modules/mod.rs index e0c3d1c..dd6062b 100644 --- a/src/bot/modules/mod.rs +++ b/src/bot/modules/mod.rs @@ -2,6 +2,7 @@ pub mod cobalt; pub mod currency; pub mod registry; pub mod whisper; +pub mod translate; use crate::errors::MyError; use async_trait::async_trait; diff --git a/src/bot/modules/registry.rs b/src/bot/modules/registry.rs index 123c9e7..3bcd65d 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::translate::TranslateModule; use crate::bot::modules::whisper::WhisperModule; pub struct ModuleManager { @@ -10,7 +11,7 @@ pub struct ModuleManager { impl ModuleManager { fn new() -> Self { - let modules: Vec> = vec![Arc::new(CobaltModule), Arc::new(CurrencyModule), Arc::new(WhisperModule)]; + let modules: Vec> = vec![Arc::new(CobaltModule), Arc::new(CurrencyModule), Arc::new(WhisperModule), Arc::new(TranslateModule)]; let modules = modules .into_iter() diff --git a/src/bot/modules/translate.rs b/src/bot/modules/translate.rs new file mode 100644 index 0000000..035a8ff --- /dev/null +++ b/src/bot/modules/translate.rs @@ -0,0 +1,116 @@ +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)] +#[derive(Default)] +pub struct TranslateSettings { + pub enabled: bool, + // TODO: save language +} + +impl ModuleSettings for TranslateSettings {} + +pub struct TranslateModule; + +#[async_trait] +impl Module for TranslateModule { + fn key(&self) -> &'static str { + "translate" + } + + fn name(&self) -> &'static str { + "Translate" + } + + fn description(&self) -> &'static str { + "Модуль перевода, позволяющий быстро получить перевод введенного текста. Протестировать можно через inlin'ы: \"@fulturatebot *слова или фраза для перевода*\"" + } + + async fn get_settings_ui( + &self, + owner: &Owner, + commander_id: u64, + ) -> Result<(String, InlineKeyboardMarkup), MyError> { + let settings: TranslateSettings = Settings::get_module_settings(owner, self.key()).await?; + + let text = format!( + "⚙️ Настройки модуля: {}\n
{}
\nСтатус: {}", + self.name(), + self.description(), + if settings.enabled { "✅ Включен" } else { "❌ Выключен" } + ); + + let toggle_button = InlineKeyboardButton::callback( + 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, commander_id), + )], + ]); + + Ok((text, keyboard)) + } + + async fn handle_callback( + &self, + bot: Bot, + 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 parts: Vec<_> = data.split(':').collect(); + + if parts.len() == 1 && parts[0] == "toggle_module" { + let mut settings: TranslateSettings = + 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, 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(()); + } + + bot.answer_callback_query(q.id.clone()).await?; + + Ok(()) + } + + fn designed_for(&self, owner_type: &str) -> bool { + owner_type == "user" + } + + async fn is_enabled(&self, owner: &Owner) -> bool { + if !self.designed_for(&owner.r#type) { + return false; + } + let settings: TranslateSettings = Settings::get_module_settings(owner, self.key()).await.unwrap(); // god of unwraps + settings.enabled + } + + fn factory_settings(&self) -> Result { + let factory_settings = TranslateSettings { enabled: true }; + Ok(serde_json::to_value(factory_settings)?) + } +} \ No newline at end of file diff --git a/src/core/services/mod.rs b/src/core/services/mod.rs index 119a26a..77d5aaf 100644 --- a/src/core/services/mod.rs +++ b/src/core/services/mod.rs @@ -2,4 +2,4 @@ pub mod cobalt; pub mod currencier; pub mod currency; pub mod speech_recognition; -pub mod translation; +pub mod translation; \ No newline at end of file diff --git a/src/core/services/speech_recognition.rs b/src/core/services/speech_recognition.rs index b296162..b703f67 100644 --- a/src/core/services/speech_recognition.rs +++ b/src/core/services/speech_recognition.rs @@ -1,8 +1,10 @@ use crate::{ - bot::keyboards::transcription::{create_summary_keyboard, create_transcription_keyboard}, + bot::keyboards::transcription::{ + create_summary_keyboard, create_transcription_keyboard, create_retry_keyboard, + }, core::config::Config, errors::MyError, - util::{enums::AudioStruct, split_text}, + util::{enums::AudioStruct, is_admin_or_author, split_text}, }; use bytes::Bytes; use gem_rs::{ @@ -18,6 +20,7 @@ use teloxide::{ prelude::*, types::{FileId, MessageKind, ParseMode, ReplyParameters}, }; +use teloxide::utils::html; #[derive(Debug, Serialize, Deserialize, FromRedisValue, ToRedisArgs, Clone)] pub struct TranscriptionCache { @@ -25,6 +28,7 @@ pub struct TranscriptionCache { pub summary: Option, pub file_id: String, pub mime_type: String, + pub attempt: u32, } pub struct Transcription { @@ -70,7 +74,7 @@ pub async fn pagination_handler( message.id, "❌ Не удалось найти текст в кеше.", ) - .await?; + .await?; return Ok(()); }; @@ -105,7 +109,7 @@ pub async fn back_handler(bot: Bot, query: CallbackQuery, config: &Config) -> Re message.id, "❌ Не удалось найти исходное сообщение.", ) - .await?; + .await?; return Ok(()); }; @@ -118,7 +122,7 @@ pub async fn back_handler(bot: Bot, query: CallbackQuery, config: &Config) -> Re message.id, "❌ Не удалось найти текст в кеше.", ) - .await?; + .await?; return Ok(()); }; @@ -130,9 +134,9 @@ pub async fn back_handler(bot: Bot, query: CallbackQuery, config: &Config) -> Re message.id, format!("
{}
", text_parts[0]), ) - .parse_mode(ParseMode::Html) - .reply_markup(keyboard) - .await?; + .parse_mode(ParseMode::Html) + .reply_markup(keyboard) + .await?; Ok(()) } @@ -141,11 +145,36 @@ pub async fn summarization_handler( bot: Bot, query: CallbackQuery, config: &Config, + user_id: u64, ) -> Result<(), MyError> { let Some(message) = query.message.and_then(|m| m.regular_message().cloned()) else { return Ok(()); }; + if !is_admin_or_author( + &bot, + message.chat.id, + message.chat.is_group() || message.chat.is_supergroup(), + &query.from, + user_id, + ) + .await + { + bot.answer_callback_query(query.id) + .text("❌ У вас нет прав использовать эту кнопку!") + .show_alert(true) + .await?; + return Ok(()); + } + + let Some(audio_message_id) = message.reply_to_message().map(|m| m.id.0) else { + bot.answer_callback_query(query.id) + .text("❌ Не удалось найти исходное сообщение для повторной попытки.") + .show_alert(true) + .await?; + 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 { @@ -163,7 +192,7 @@ pub async fn summarization_handler( message.id, "❌ Не удалось найти исходное аудио.", ) - .await?; + .await?; return Ok(()); } }; @@ -180,38 +209,54 @@ pub async fn summarization_handler( return Ok(()); } + if let Some(text) = message.text() && text.contains("[no speech]") { + bot.edit_message_text(message.chat.id, message.id, "❌ Нельзя составить краткое содержание из аудио без речи.") + .parse_mode(ParseMode::Html) + .reply_markup(create_summary_keyboard()) + .await?; + return Ok(()); + } + bot.edit_message_text( message.chat.id, message.id, "Составляю краткое содержание...", ) - .await?; + .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?; + let file_data_result = save_file_to_memory(&bot, &cache_entry.file_id).await; + let new_summary_result = match file_data_result { + Ok(file_data) => summarize_audio(cache_entry.mime_type.clone(), file_data, config.clone()).await, + Err(e) => { + error!("Failed to download file for summarization: {:?}", e); + Err(e) + } + }; - if new_summary.is_empty() || new_summary.contains("Не удалось получить") { - bot.edit_message_text( - message.chat.id, - message.id, - "❌ Не удалось составить краткое содержание.", - ) - .await?; - return Ok(()); - } + match new_summary_result { + Ok(new_summary) if !new_summary.is_empty() && !new_summary.contains("Не удалось получить") => { + cache_entry.summary = Some(new_summary.clone()); + cache.set(&file_cache_key, &cache_entry, 86400).await?; - cache_entry.summary = Some(new_summary.clone()); - cache.set(&file_cache_key, &cache_entry, 86400).await?; + let final_text = format!("✨:\n
{}
", html::escape(&new_summary)); + bot.edit_message_text(message.chat.id, message.id, final_text) + .parse_mode(ParseMode::Html) + .reply_markup(create_summary_keyboard()) + .await?; + } + _ => { + let error_text = "❌ Не удалось составить краткое содержание."; + let retry_keyboard = create_retry_keyboard(audio_message_id, user_id, "summarize", cache_entry.attempt); - 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?; + bot.edit_message_text( + message.chat.id, + message.id, + error_text, + ) + .reply_markup(retry_keyboard) + .await?; + } + } Ok(()) } @@ -220,14 +265,17 @@ async fn get_cached( bot: &Bot, file: &AudioStruct, config: &Config, + force_no_cache: bool, ) -> 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); - } + if !force_no_cache + && let Some(cached_text) = cache.get::(&file_cache_key).await? + && !cached_text.full_text.is_empty() { + 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 { @@ -249,12 +297,13 @@ async fn get_cached( summary: None, file_id: file.file_id.clone(), mime_type: file.mime_type.clone(), + attempt: 0 }; cache.set(&file_cache_key, &new_cache_entry, 86400).await?; debug!( "Saved new transcription to file cache for unique_id: {}", - file.file_id + file.file_unique_id ); Ok(new_cache_entry) @@ -275,25 +324,37 @@ pub async fn transcription_handler( 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?; + .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 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, 86400) + .await?; + let file_cache_key = format!("transcription_by_file:{}", &file.file_unique_id); + let empty_cache = TranscriptionCache { + full_text: String::new(), + summary: None, + file_id: file.file_id.clone(), + mime_type: file.mime_type.clone(), + attempt: 0 + }; + cache.set(&file_cache_key, &empty_cache, 86400).await?; + + match get_cached(&bot, &file, config, false).await { + Ok(cache_entry) => { let text_parts = split_text(&cache_entry.full_text, 4000); if text_parts.is_empty() { bot.edit_message_text(message.chat.id, message.id, "❌ Получен пустой текст.") @@ -305,16 +366,16 @@ pub async fn transcription_handler( bot.edit_message_text( msg.chat.id, message.id, - format!("
{}
", text_parts[0]), + format!("
{}
", html::escape(&text_parts[0])), ) - .parse_mode(ParseMode::Html) - .reply_markup(keyboard) - .await?; + .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("Не удалось преобразовать") => { + MyError::Other(msg) if msg.contains("❌ Не удалось преобразовать") => { msg } MyError::Reqwest(_) => { @@ -323,7 +384,12 @@ pub async fn transcription_handler( } _ => "❌ Произошла неизвестная ошибка при обработке аудио.".to_string(), }; + + let original_message_id = msg.id.0; + let retry_keyboard = create_retry_keyboard(original_message_id, user.id.0, "transcribe", 0); // can have an error + bot.edit_message_text(message.chat.id, message.id, error_text) + .reply_markup(retry_keyboard) .await?; } } @@ -331,14 +397,212 @@ pub async fn transcription_handler( bot.edit_message_text( message.chat.id, message.id, - "Не удалось найти голосовое сообщение.", + "❌ Не удалось найти голосовое сообщение.", ) - .parse_mode(ParseMode::Html) - .await?; + .parse_mode(ParseMode::Html) + .await?; } Ok(()) } +pub async fn retry_speech_handler( + bot: Bot, + query: CallbackQuery, + config: &Config, + _original_message_id: i32, + user_id: u64, + action_type: &str, + attempt: u32 +) -> Result<(), MyError> { + let Some(message) = query.message.and_then(|m| m.regular_message().cloned()) else { + return Ok(()); + }; + + 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?; + + if attempt >= 1 { + bot.edit_message_text( + message.chat.id, + message.id, + "❌ Лимит попыток исчерпан.", + ) + .await?; + return Ok(()); + } + + let bot_message_id = message.id.0; + let new_attempt = attempt + 1; + + let Some(replied_to_audio_message_id) = message.reply_to_message().map(|m| m.id.0) else { + bot.edit_message_text( + message.chat.id, + message.id, + "❌ Не удалось найти исходное сообщение для повторной попытки.", + ) + .await?; + return Ok(()); + }; + + let cache = config.get_redis_client(); + let message_file_map_key = format!("message_file_map:{}", bot_message_id); + + let Some(file_unique_id): Option = 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); + + match action_type { + "transcribe" => { + bot.edit_message_text( + message.chat.id, + message.id, + "🔁 Повторная обработка аудио...", + ) + .await?; + + let Some(cache_entry_template): Option = cache.get(&file_cache_key).await? else { + info!("{}", &file_cache_key); + bot.edit_message_text(message.chat.id, message.id, "❌ Не удалось получить метаданные аудио.").await?; + return Ok(()); + }; + + let file = AudioStruct { + mime_type: cache_entry_template.mime_type, + file_id: cache_entry_template.file_id, + file_unique_id: file_unique_id.clone(), + }; + + cache.delete(&file_cache_key).await?; + + match get_cached(&bot, &file, config, true).await { + Ok(cache_entry) => { + let text_parts = split_text(&cache_entry.full_text, 4000); + + let keyboard = create_transcription_keyboard(0, text_parts.len(), user_id); + bot.edit_message_text( + message.chat.id, + message.id, + format!("
{}
", html::escape(&text_parts[0])), + ) + .parse_mode(ParseMode::Html) + .reply_markup(keyboard) + .await?; + } + Err(e) => { + error!("Failed to get transcription on retry: {:?}", e); + let error_text = match e { + MyError::Other(msg) if msg.contains("❌ Не удалось преобразовать") => { + msg + } + MyError::Reqwest(_) => { + "❌ Ошибка: Не удалось скачать файл. Возможно, он слишком большой (>20MB)." + .to_string() + } + _ => "❌ Произошла неизвестная ошибка при обработке аудио.".to_string(), + }; + + let retry_keyboard = create_retry_keyboard(replied_to_audio_message_id, user_id, "transcribe", new_attempt); + + bot.edit_message_text(message.chat.id, message.id, error_text) + .reply_markup(retry_keyboard) + .await?; + + let empty_cache = TranscriptionCache { + full_text: String::new(), + summary: None, + file_id: file.file_id.clone(), + mime_type: file.mime_type.clone(), + attempt: new_attempt + }; + cache.set(&file_cache_key, &empty_cache, 86400).await?; + } + } + } + "summarize" => { + bot.edit_message_text( + message.chat.id, + message.id, + "🔁 Повторное составление краткого содержания...", + ) + .await?; + + 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(()); + } + }; + + cache_entry.summary = None; + + let file_data_result = save_file_to_memory(&bot, &cache_entry.file_id).await; + let new_summary_result = match file_data_result { + Ok(file_data) => summarize_audio(cache_entry.mime_type.clone(), file_data, config.clone()).await, + Err(e) => { + error!("Failed to download file for summarization on retry: {:?}", e); + Err(e) + } + }; + + match new_summary_result { + Ok(new_summary) if !new_summary.is_empty() && !new_summary.contains("Не удалось получить") => { + cache_entry.summary = Some(new_summary.clone()); + cache_entry.attempt = 0; + cache.set(&file_cache_key, &cache_entry, 86400).await?; + + let final_text = format!("✨:\n
{}
", html::escape(&new_summary)); + bot.edit_message_text(message.chat.id, message.id, final_text) + .parse_mode(ParseMode::Html) + .reply_markup(create_summary_keyboard()) + .await?; + } + _ => { + let error_text = "❌ Не удалось составить краткое содержание."; + + cache_entry.attempt = new_attempt; + + let retry_keyboard = create_retry_keyboard(replied_to_audio_message_id, user_id, "summarize", cache_entry.attempt); + + bot.edit_message_text( + message.chat.id, + message.id, + error_text, + ) + .reply_markup(retry_keyboard) + .await?; + + cache_entry.summary = None; + cache.set(&file_cache_key, &cache_entry, 86400).await?; + } + } + } + _ => { + log::warn!("Unknown action type for retry: {}", action_type); + bot.edit_message_text(message.chat.id, message.id, "❌ Неизвестный тип действия для повторной попытки.") + .await?; + } + } + + Ok(()) +} + pub async fn summarize_audio( mime_type: String, data: Bytes, @@ -367,7 +631,7 @@ pub async fn summarize_audio( .get_results() .first() .cloned() - .unwrap_or_else(|| "Не удалось получить краткое содержание.".to_string())) + .unwrap_or_else(|| "❌ Не удалось получить краткое содержание.".to_string())) } pub async fn get_file_id(msg: &Message) -> Option { @@ -456,4 +720,4 @@ impl Transcription { } vec![error_answer + "\n\n" + &last_error] } -} +} \ No newline at end of file diff --git a/src/util/mod.rs b/src/util/mod.rs index 58941ce..01fffe0 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,3 +1,7 @@ +use teloxide::Bot; +use teloxide::prelude::{ChatId, Requester}; +use teloxide::types::User; + pub mod currency_values; pub mod enums; pub mod paginator; @@ -12,3 +16,29 @@ pub fn split_text(text: &str, chunk_size: usize) -> Vec { .map(|c| c.iter().collect()) .collect() } + +pub async fn is_admin_or_author( + bot: &Bot, + chat_id: ChatId, + is_group: bool, + clicker: &User, + target_user_id: u64, +) -> bool { + if target_user_id == 72 || clicker.id.0 == target_user_id { + return true; + } + + if is_group && let Ok(member) = bot.get_chat_member(chat_id, clicker.id).await { + return member.is_privileged(); + } + + false +} + +pub fn is_author(clicker: &User, target_user_id: u64) -> bool { + if target_user_id == 72 || clicker.id.0 == target_user_id { + return true; + } + + false +}