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 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/mod.rs b/src/bot/callbacks/mod.rs index 8640fb9..dd2d1fc 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_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); - 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 +} 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..8bcb4ea 100644 --- a/src/bot/callbacks/translate.rs +++ b/src/bot/callbacks/translate.rs @@ -1,58 +1,124 @@ -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::{ + 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, RequestError, + prelude::*, + types::{ + CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, MaybeInaccessibleMessage, + Message, ParseMode, + }, + utils::html::escape, }; -use teloxide::utils::html::escape; -use teloxide::{ApiError, RequestError}; 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(()) @@ -66,91 +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 res = google_trans - .translate_async(text_to_translate, "", &*normalized_lang) - .await - .unwrap(); + let translation_futures = text_chunks + .iter() + .map(|chunk| google_trans.translate_async(chunk, "", &normalized_lang)); + + 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); - 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]); - } - - 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 { + 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); } else { - return Err(MyError::from(e)); + 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?; + + 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/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..8c28e07 --- /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::create_with_defaults(&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..6ec34cf 100644 --- a/src/bot/commands/translate.rs +++ b/src/bot/commands/translate.rs @@ -1,12 +1,65 @@ -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, + 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, @@ -51,7 +104,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(); @@ -60,22 +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(()); } } + let text_chunks = split_text_tr(text_to_translate, 2800); + let google_trans = GoogleTranslator::default(); - let res = google_trans - .translate_async(text_to_translate, "", &*target_lang) - .await - .unwrap(); + let translation_futures = text_chunks + .iter() + .map(|chunk| google_trans.translate_async(chunk, "", &target_lang)); + + 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 response = format!("
{}\n
", escape(&res)); + let display_pages = split_text_tr(&full_translated_text, 4000); let lang_display_name = SUPPORTED_LANGUAGES .iter() @@ -83,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/bot/dispatcher.rs b/src/bot/dispatcher.rs index 89dcc13..88ad9a6 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,7 @@ use teloxide::{ update_listeners::Polling, utils::{command::BotCommands, html}, }; -use crate::core::services::currency::converter::is_currency_query; +use crate::bot::inlines::cobalter::handle_inline_video; async fn root_handler( update: Update, @@ -73,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 816d93b..4139860 100644 --- a/src/bot/inlines/cobalter.rs +++ b/src/bot/inlines/cobalter.rs @@ -1,106 +1,47 @@ -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, + InputMessageContent, InputMessageContentText, InputFile, InputMedia, InputMediaVideo + }, }; +use url::Url; -#[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, @@ -108,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![ @@ -174,12 +115,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 +137,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) @@ -217,41 +166,47 @@ pub async fn handle_cobalt_inline( 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(()) -// } +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/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/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 - } } 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/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)] 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) + } } } 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..0da706c --- /dev/null +++ b/src/core/services/cobalt.rs @@ -0,0 +1,122 @@ +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 { + url: String, + original_url: 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 { + url: video_item.url.clone(), + original_url: url.to_string(), + })); + } + Ok(None) + } + 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() + .any(|ext| filename.to_lowercase().ends_with(ext)); + + if is_photo { + Ok(Some(DownloadResult::Photos { + urls: vec![c_url.clone()], + original_url: url.to_string(), + })) + } else { + Ok(Some(DownloadResult::Video { + url: c_url, + original_url: url.to_string(), + })) + } + } + _ => Ok(response + .get_download_url() + .map(|c_url| DownloadResult::Video { + url: c_url, + original_url: url.to_string(), + })), + } +} 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/errors.rs b/src/errors.rs index 1dd8f0b..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,8 +53,14 @@ 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), + + #[error("Serde json error: {0}")] + SerdeJson(#[from] serde_json::Error), } impl From<&str> for MyError { 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 + } +}