diff --git a/.env.example b/.env.example index 17e02b9..2f4853c 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,10 @@ BOT_TOKEN= GEMINI_API_KEY= -OWNERS=701733705, 6452350296 -MONGODB_URL= -RUST_LOG=info \ No newline at end of file +COBALT_API_KEY= +OWNERS=701733705,6452350296 +LOG_CHAT_ID= +ERROR_CHAT_THREAD_ID=0 +WARN_CHAT_THREAD_ID=0 +MONGODB_URL=mongodb://localhost:27017 +RUST_LOG=info +REDIS_URL=redis://127.0.0.1:6379 \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml index cb0d07e..63d473f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,3 +22,12 @@ async-trait = "0.1.88" log = "0.4.27" pretty_env_logger = "0.5.0" uuid = { version = "1.17.0", features = ["v4"] } +ccobalt = { git = "https://github.com/fulturate/ccobalt.git" } +translators = { version = "0.1.5", features = ["google", "tokio-async"] } +redis = { version = "0.32.5", features = ["tokio-comp"] } +redis-macros = { version = "0.5.6" } +url = "2.5.4" +base64 = "0.22.1" +mime = "0.3.17" +md5 = "0.7.0" +futures = "0.3.31" \ No newline at end of file diff --git a/LICENSE b/LICENSE index 55504f4..0423517 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2012-2025 Weever (Weever1337) +Copyright (c) 2012-2025 Fulturate Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the diff --git a/README.md b/README.md index bf36072..5aaba49 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,19 @@
-

Fulturate-rs - Telegram Utility Bot 🤖

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

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

## ✨ Features: - **Speech Recognition** 🎤: Convert spoken words into text for a more interactive experience. - **Currency Conversion** 💰: Convert between different currencies easily. - -## 🔹 Todo: -- **Download Videos/Music from URLs** 🎥🎶: Seamlessly download multimedia content. -- **Download Images from URLs** 🖼️: Fetch images directly from URLs. -- **Download Files from URLs** 📂: Download various types of files from provided URLs. -- **Download Audios from URLs** 🎧: Grab audio files from URLs for easy access. -- **Settings** ⚙️: Manage and adjust bot settings to your preferences. -- **Model Switcher (Speech Recognition)** 🧠: Switch between different speech recognition models for better accuracy. -- **Language Switcher** 🌍: Switch between multiple languages with ease. -- **Message Translation** 🌐: Translate messages into your preferred language. +- **Summarization** ✨: Summarize audio files. ## 🚀 Getting Started: To get started with Fulturate-rs, follow these steps: *(If you don't have Rust installed, visit: [Rust Install](https://www.rust-lang.org/tools/install))* 1. Clone the repository: - `git clone https://github.com/Weever1337/fulturate-rs.git` + `git clone https://github.com/Fulturate/bot.git` 2. Run the bot: `cargo run` OR run with Docker: @@ -33,10 +23,9 @@ To get started with Fulturate-rs, follow these steps: - **News Channel**: [Fulturate News](https://t.me/FulturateNews) - **Bot**: [Fulturate Bot](https://t.me/FulturateBot) - **Owner's Bio-Channel in Telegram**: [Weever](https://t.me/Weever) -- **Owner's Shitpost Channel (RUS) in Telegram**: [WeeverDev](https://t.me/WeeverDev) ## 💖 Credits: -- **Nixxoq**: [GitHub](https://github.com/nixxoq), [Telegram](https://t.me/nixxoq) +- **Nixxoq (did a LOT of work)**: [GitHub](https://github.com/nixxoq), [Telegram](https://t.me/nixxoq) ## 📄 License: -- **MIT License**: [MIT License](https://github.com/Weever1337/fulturate-rs/blob/main/LICENSE) +- **MIT License**: [MIT License](https://github.com/Fulturate/bot/blob/main/LICENSE) diff --git a/config.json b/config.json index 19d9d7f..f9619ff 100644 --- a/config.json +++ b/config.json @@ -1,4 +1,5 @@ { "ai_model": "gemini-2.5-flash", - "ai_prompt": "You are a highly specialized audio-to-text transcription service. Your SOLE purpose is to accurately transcribe the spoken words from the audio track of the provided file.\n\n**Crucial Instruction: You MUST completely ignore the visual stream of the file. Your task is NOT to describe the video.**\n\n- **DO:** Listen to the audio and transcribe it word-for-word (verbatim).\n- **DO:** Maintain the original language of the speech.\n\n- **DO NOT:** Describe scenes, people, objects, actions, logos, or the environment.\n- **DO NOT:** Analyze the camera work or shot composition.\n- **DO NOT:** Provide summaries, explanations, or any commentary.\n- **DO NOT:** Add headers, timestamps, or any formatting.\n\nReturn ONLY the raw, plain transcribed text. If no speech is present, return \"[no speech]\"." + "ai_prompt": "You are a highly specialized audio-to-text transcription service. Your SOLE purpose is to accurately transcribe the spoken words from the audio track of the provided file.\n\n**Crucial Instruction: You MUST completely ignore the visual stream of the file. Your task is NOT to describe the video.**\n\n- **DO:** Listen to the audio and transcribe it word-for-word (verbatim).\n- **DO:** Maintain the original language of the speech.\n\n- **DO NOT:** Describe scenes, people, objects, actions, logos, or the environment.\n- **DO NOT:** Analyze the camera work or shot composition.\n- **DO NOT:** Provide summaries, explanations, or any commentary.\n- **DO NOT:** Add headers, timestamps, or any formatting.\n\nReturn ONLY the raw, plain transcribed text. If no speech is present, return \"[no speech]\".", + "summarize_prompt": "You are an assistant that transcribes and then summarizes spoken content. First, accurately and fully transcribe the voice message, keeping the original language. Then, briefly summarize the transcribed text in the same language. Output only the final summary. Do not include the full transcription, and do not add any extra words like 'Summary' or 'Transcription'. Do not explain or comment. The output must be plain and concise." } \ No newline at end of file diff --git a/src/bot/callbacks/cobalt_pagination.rs b/src/bot/callbacks/cobalt_pagination.rs new file mode 100644 index 0000000..2774702 --- /dev/null +++ b/src/bot/callbacks/cobalt_pagination.rs @@ -0,0 +1,120 @@ +use crate::{ + bot::keyboards::cobalt::make_photo_pagination_keyboard, + core::{config::Config, services::cobalt::DownloadResult}, + errors::MyError, +}; +use std::sync::Arc; +use teloxide::{ + ApiError, RequestError, + prelude::*, + types::{CallbackQuery, InputFile, InputMedia, InputMediaPhoto}, +}; + +struct PagingData<'a> { + original_user_id: u64, + index: usize, + total: usize, + url_hash: &'a str, +} + +impl<'a> PagingData<'a> { + fn from_parts(parts: &'a [&'a str]) -> Option { + if parts.len() < 5 { + return None; + } + + Some(Self { + original_user_id: parts.get(1)?.parse().ok()?, + index: parts.get(2)?.parse().ok()?, + total: parts.get(3)?.parse().ok()?, + url_hash: parts.get(4)?, + }) + } +} + +pub async fn handle_cobalt_pagination( + bot: Bot, + q: CallbackQuery, + config: Arc, +) -> Result<(), MyError> { + let Some(data) = q.data else { return Ok(()) }; + let parts: Vec<&str> = data.split(':').collect(); + + if parts.get(1) == Some(&"noop") { + bot.answer_callback_query(q.id).await?; + return Ok(()); + } + + let Some(paging_data) = PagingData::from_parts(&parts) else { + log::error!("Invalid callback data format: {}", data); + return Ok(()); + }; + + if q.from.id.0 != paging_data.original_user_id { + bot.answer_callback_query(q.id) + .text("Вы не можете использовать эти кнопки.") + .show_alert(true) + .await?; + return Ok(()); + } + + let cache_key = format!("cobalt_cache:{}", paging_data.url_hash); + let redis = config.get_redis_client(); + let Ok(Some(DownloadResult::Photos { urls, original_url })) = redis.get(&cache_key).await + else { + bot.answer_callback_query(q.id) + .text("Извините, срок хранения этих фото истёк.") + .show_alert(true) + .await?; + return Ok(()); + }; + + let Some(photo_url_str) = urls.get(paging_data.index) else { + log::error!( + "Pagination index {} is out of bounds for len {}", + paging_data.index, + urls.len() + ); + return Ok(()); + }; + let Ok(url) = photo_url_str.parse() else { + log::error!("Failed to parse photo URL: {}", photo_url_str); + return Ok(()); + }; + + let media = InputMedia::Photo(InputMediaPhoto::new(InputFile::url(url))); + let keyboard = make_photo_pagination_keyboard( + paging_data.url_hash, + paging_data.index, + paging_data.total, + paging_data.original_user_id, + &original_url, + ); + + let edit_result = if let Some(msg) = q.message { + bot.edit_message_media(msg.chat().id, msg.id(), media) + .reply_markup(keyboard) + .await + .map(|_| ()) + } else if let Some(inline_id) = q.inline_message_id { + bot.edit_message_media_inline(inline_id, media) + .reply_markup(keyboard) + .await + .map(|_| ()) + } else { + return Ok(()); + }; + + if let Err(e) = edit_result + && !matches!(e, RequestError::Api(ApiError::MessageNotModified)) + { + log::error!("Failed to edit message for pagination: {}", e); + bot.answer_callback_query(q.id.clone()) + .text("Не удалось обновить фото.") + .await?; + } + + bot.answer_callback_query(q.id).await?; + + Ok(()) +} diff --git a/src/bot/callbacks/delete.rs b/src/bot/callbacks/delete.rs new file mode 100644 index 0000000..99169e2 --- /dev/null +++ b/src/bot/callbacks/delete.rs @@ -0,0 +1,280 @@ +use crate::{ + bot::{keyboards::delete::confirm_delete_keyboard, modules::Owner}, + core::{ + config::Config, + db::schemas::{ + group::Group as GroupSchema, settings::Settings as SettingsSchema, + user::User as UserSchema, + }, + services::speech_recognition::back_handler, + }, + errors::MyError, +}; +use log::error; +use mongodb::bson::doc; +use oximod::Model; +use teloxide::{ + prelude::*, + types::{ChatId, InlineKeyboardButton, InlineKeyboardMarkup, User}, +}; + +async fn has_delete_permission( + bot: &Bot, + chat_id: ChatId, + is_group: bool, + clicker: &User, + target_user_id: u64, +) -> bool { + if target_user_id == 72 || clicker.id.0 == target_user_id { + return true; + } + + if is_group && let Ok(member) = bot.get_chat_member(chat_id, clicker.id).await { + return member.is_privileged(); + } + + false +} + +async fn has_data_delete_permission( + bot: &Bot, + chat: &teloxide::types::Chat, + clicker: &User, +) -> bool { + if chat.is_private() { + return true; + } + if (chat.is_group() || chat.is_supergroup()) + && let Ok(member) = bot.get_chat_member(chat.id, clicker.id).await { + return member.is_owner(); + } + false +} + +pub async fn handle_delete_data(bot: Bot, query: CallbackQuery) -> Result<(), MyError> { + let Some(message) = query.message.as_ref() else { + return Ok(()); + }; + + let can_delete = has_data_delete_permission(&bot, message.chat(), &query.from).await; + + if !can_delete { + bot.answer_callback_query(query.id) + .text("❌ Удалить данные чата может только его владелец.") + .show_alert(true) + .await?; + return Ok(()); + } + + let (owner_type, owner_id, confirmation_text) = if message.chat().is_private() { + ( + "user", + query.from.id.to_string(), + "Вы уверены, что хотите удалить все ваши данные из бота?\n\nЭто действие необратимо!", + ) + } else { + ( + "group", + message.chat().id.to_string(), + "Вы уверены, что хотите удалить все данные этого чата из бота?\n\nЭто действие необратимо!", + ) + }; + + let keyboard = InlineKeyboardMarkup::new(vec![vec![ + InlineKeyboardButton::callback( + "Да, удалить", + format!("delete_data_confirm:{}:{}:yes", owner_type, owner_id), + ), + InlineKeyboardButton::callback( + "Нет, отмена", + format!("delete_data_confirm:{}:{}:no", owner_type, owner_id), + ), + ]]); + + bot.answer_callback_query(query.id).await?; + bot.edit_message_text(message.chat().id, message.id(), confirmation_text) + .reply_markup(keyboard) + .parse_mode(teloxide::types::ParseMode::Html) + .await?; + + Ok(()) +} + +pub async fn handle_delete_request(bot: Bot, query: CallbackQuery) -> Result<(), MyError> { + let Some(message) = query.message.as_ref().and_then(|m| m.regular_message()) else { + return Ok(()); + }; + 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 { + bot.answer_callback_query(query.id) + .text("❌ Ошибка: неверный ID в кнопке.") + .show_alert(true) + .await?; + return Ok(()); + }; + + let can_delete = has_delete_permission( + &bot, + message.chat.id, + message.chat.is_group() || message.chat.is_supergroup(), + &query.from, + target_user_id, + ) + .await; + + if !can_delete { + bot.answer_callback_query(query.id) + .text("❌ Удалить может только автор сообщения или администратор.") + .show_alert(true) + .await?; + return Ok(()); + } + + bot.answer_callback_query(query.id).await?; + bot.edit_message_text( + message.chat.id, + message.id, + "Вы уверены, что хотите удалить?", + ) + .reply_markup(confirm_delete_keyboard(target_user_id)) + .await?; + + Ok(()) +} + +pub async fn handle_delete_confirmation( + bot: Bot, + query: CallbackQuery, + config: &Config, +) -> Result<(), MyError> { + let Some(message) = query.message.as_ref().and_then(|m| m.regular_message()) else { + return Ok(()); + }; + let Some(data) = query.data.as_ref() else { + return Ok(()); + }; + + let parts: Vec<&str> = data.split(':').collect(); + if parts.len() != 3 { + return Ok(()); + }; + + let Ok(target_user_id) = parts[1].parse::() else { + return Ok(()); + }; + let action = parts[2]; + + let can_delete = has_delete_permission( + &bot, + message.chat.id, + message.chat.is_group() || message.chat.is_supergroup(), + &query.from, + target_user_id, + ) + .await; + + if !can_delete { + bot.answer_callback_query(query.id) + .text("❌ У вас нет прав для этого действия.") + .show_alert(true) + .await?; + return Ok(()); + } + + bot.answer_callback_query(query.clone().id).await?; + + match action { + "yes" => { + bot.delete_message(message.chat.id, message.id) + .await + .map_err(|e| error!("Failed to delete message: {:?}", e)) + .ok(); + } + "no" => { + back_handler(bot, query, config).await?; + } + _ => {} + } + + Ok(()) +} + +pub async fn handle_delete_data_confirmation( + bot: Bot, + query: CallbackQuery, +) -> Result<(), MyError> { + let Some(message) = query.message.as_ref() else { + return Ok(()); + }; + let Some(data) = query.data.as_ref() else { + return Ok(()); + }; + + let parts: Vec<&str> = data + .strip_prefix("delete_data_confirm:") + .unwrap_or_default() + .split(':') + .collect(); + if parts.len() != 3 { + return Ok(()); + } + + let owner_type = parts[0]; + let owner_id = parts[1]; + let action = parts[2]; + + let can_delete = has_data_delete_permission(&bot, message.chat(), &query.from).await; + if !can_delete { + bot.answer_callback_query(query.id) + .text("❌ У вас нет прав для этого действия.") + .show_alert(true) + .await?; + return Ok(()); + } + + bot.answer_callback_query(query.id).await?; + + match action { + "yes" => { + let owner = Owner { + id: owner_id.to_string(), + r#type: owner_type.to_string(), + }; + + SettingsSchema::delete_one(doc! { "owner_id": &owner.id, "owner_type": &owner.r#type }) + .await?; + + if owner.r#type == "user" { + UserSchema::delete_one(doc! { "user_id": &owner.id }).await?; + } else if owner.r#type == "group" { + GroupSchema::delete_one(doc! { "group_id": &owner.id }).await?; + } + + let final_text = if owner.r#type == "user" { + "✅ Все ваши данные были успешно удалены." + } else { + "✅ Все данные этого чата были успешно удалены." + }; + + bot.edit_message_text(message.chat().id, message.id(), final_text) + .reply_markup(InlineKeyboardMarkup::new(vec![vec![]])) + .await?; + } + "no" => { + bot.edit_message_text( + message.chat().id, + message.id(), + "✅ Удаление данных отменено.", + ) + .reply_markup(InlineKeyboardMarkup::new(vec![vec![]])) + .await?; + } + _ => {} + } + + Ok(()) +} \ No newline at end of file diff --git a/src/bot/callbacks/mod.rs b/src/bot/callbacks/mod.rs new file mode 100644 index 0000000..0e41464 --- /dev/null +++ b/src/bot/callbacks/mod.rs @@ -0,0 +1,198 @@ +use crate::{ + bot::{ + callbacks::{ + cobalt_pagination::handle_cobalt_pagination, + delete::{ + handle_delete_confirmation, handle_delete_data, handle_delete_data_confirmation, + handle_delete_request, + }, + translate::handle_translate_callback, + whisper::handle_whisper_callback, + }, + commands::settings::update_settings_message, + modules::{registry::MOD_MANAGER, Owner}, + }, + core::{ + config::Config, + services::speech_recognition::{back_handler, pagination_handler, summarization_handler}, + }, + errors::MyError, +}; +use std::sync::Arc; +use teloxide::{ + payloads::EditMessageTextSetters, + prelude::{CallbackQuery, Requester}, + Bot, +}; +use teloxide::payloads::AnswerCallbackQuerySetters; + +pub mod cobalt_pagination; +pub mod delete; +pub mod translate; +pub mod whisper; + +enum CallbackAction<'a> { + ModuleSettings { + module_key: &'a str, + rest: &'a str, + commander_id: u64, + }, + ModuleSelect { + owner_type: &'a str, + owner_id: &'a str, + module_key: &'a str, + commander_id: u64, + }, + SettingsBack { + owner_type: &'a str, + owner_id: &'a str, + commander_id: u64, + }, + DeleteData { + commander_id: u64, + }, + CobaltPagination, + DeleteDataConfirmation, + 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() == 4 + && let Ok(commander_id) = parts[3].parse() { + return Some(CallbackAction::ModuleSelect { + owner_type: parts[0], + owner_id: parts[1], + module_key: parts[2], + commander_id, + }); + } + } + + if let Some(rest) = data.strip_prefix("settings_back:") { + let parts: Vec<_> = rest.split(':').collect(); + if parts.len() == 3 + && let Ok(commander_id) = parts[2].parse() { + return Some(CallbackAction::SettingsBack { + owner_type: parts[0], + owner_id: parts[1], + commander_id, + }); + } + } + + if let Some(module_key) = MOD_MANAGER.get_all_modules().iter().find_map(|m| { + data.starts_with(&format!("{}:settings:", m.key())).then_some(m.key()) + }) { + let rest_with_id = data.strip_prefix(&format!("{}:settings:", module_key)).unwrap_or(""); + let parts: Vec<_> = rest_with_id.rsplitn(2, ':').collect(); + if parts.len() == 2 + && let Ok(commander_id) = parts[0].parse() { + let rest = parts[1]; + return Some(CallbackAction::ModuleSettings { + module_key, + rest, + commander_id, + }); + } + } + + if let Some(commander_id_str) = data.strip_prefix("delete_data:") + && let Ok(commander_id) = commander_id_str.parse() { + return Some(CallbackAction::DeleteData { commander_id }); + } + + if data.starts_with("delete_data_confirm:") { return Some(CallbackAction::DeleteDataConfirmation); } + if data.starts_with("delete_msg") { return Some(CallbackAction::DeleteMessage); } + if data.starts_with("delete_confirm:") { return Some(CallbackAction::DeleteConfirmation); } + if data.starts_with("summarize") { return Some(CallbackAction::Summarize); } + if data.starts_with("speech:page:") { return Some(CallbackAction::SpeechPage); } + if data.starts_with("back_to_full") { return Some(CallbackAction::BackToFull); } + if data.starts_with("whisper") { return Some(CallbackAction::Whisper); } + if data.starts_with("tr_") || data.starts_with("tr:") { return Some(CallbackAction::Translate); } + if data.starts_with("cobalt:") { return Some(CallbackAction::CobaltPagination); } + + None +} + +pub async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { + let config = Arc::new(Config::new().await); + + let Some(data) = &q.data else { return Ok(()); }; + + match parse_callback_data(data) { + Some(CallbackAction::ModuleSelect { owner_type, owner_id, module_key, commander_id }) => { + if q.from.id.0 != commander_id { + bot.answer_callback_query(q.id).text("❌ Вы не можете управлять этими настройками.").show_alert(true).await?; + return Ok(()); + } + if let (Some(module), Some(message)) = (MOD_MANAGER.get_module(module_key), &q.message) { + let owner = Owner { id: owner_id.to_string(), r#type: owner_type.to_string() }; + let (text, keyboard) = module.get_settings_ui(&owner, commander_id).await?; + bot.edit_message_text(message.chat().id, message.id(), text) + .reply_markup(keyboard) + .parse_mode(teloxide::types::ParseMode::Html) + .await?; + } + } + Some(CallbackAction::SettingsBack { owner_type, owner_id, commander_id }) => { + if q.from.id.0 != commander_id { + bot.answer_callback_query(q.id).text("❌ Вы не можете управлять этими настройками.").show_alert(true).await?; + return Ok(()); + } + if let Some(message) = q.message { + update_settings_message(bot, message, owner_id.to_string(), owner_type.to_string(), commander_id).await?; + } + } + Some(CallbackAction::ModuleSettings { module_key, rest, commander_id }) => { + if q.from.id.0 != commander_id { + bot.answer_callback_query(q.id).text("❌ Вы не можете управлять этими настройками.").show_alert(true).await?; + return Ok(()); + } + if let (Some(module), Some(message)) = (MOD_MANAGER.get_module(module_key), &q.message) { + let owner = Owner { + id: message.chat().id.to_string(), + r#type: (if message.chat().is_private() { "user" } else { "group" }).to_string(), + }; + module.handle_callback(bot, &q, &owner, rest, commander_id).await?; + } + } + Some(CallbackAction::DeleteData { commander_id }) => { + if q.from.id.0 != commander_id { + bot.answer_callback_query(q.id).text("❌ Вы не можете управлять этими настройками.").show_alert(true).await?; + return Ok(()); + } + handle_delete_data(bot, q).await? + } + Some(CallbackAction::CobaltPagination) => handle_cobalt_pagination(bot, q, config).await?, + Some(CallbackAction::DeleteDataConfirmation) => handle_delete_data_confirmation(bot, q).await?, + Some(CallbackAction::DeleteMessage) => handle_delete_request(bot, q).await?, + Some(CallbackAction::DeleteConfirmation) => handle_delete_confirmation(bot, q, &config).await?, + Some(CallbackAction::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/translate.rs b/src/bot/callbacks/translate.rs new file mode 100644 index 0000000..8bcb4ea --- /dev/null +++ b/src/bot/callbacks/translate.rs @@ -0,0 +1,275 @@ +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 translators::{GoogleTranslator, Translator}; +use uuid::Uuid; + +pub async fn handle_translate_callback( + bot: Bot, + q: CallbackQuery, + config: &Config, +) -> Result<(), MyError> { + if let (Some(data), Some(MaybeInaccessibleMessage::Regular(message))) = (&q.data, &q.message) { + bot.answer_callback_query(q.id.clone()).await?; + + if let Some(rest) = data.strip_prefix("tr:page:") { + let parts: Vec<_> = rest.split(':').collect(); + if parts.len() == 2 { + 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, &q.from, config).await?; + } + } else { + bot.answer_callback_query(q.id).await?; + } + Ok(()) +} + +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(()) +} + +async fn handle_language_selection( + bot: Bot, + message: &Message, + data: &str, + user: teloxide::types::User, + config: &Config, +) -> Result<(), MyError> { + let target_lang = data.trim_start_matches("tr_lang:"); + let redis_client = config.get_redis_client(); + + let redis_key_job = format!("translate_job:{}", user.id); + let job: Option = redis_client.get_and_delete(&redis_key_job).await?; + + let Some(job) = job else { + bot.edit_message_text( + message.chat.id, + message.id, + "Задача на перевод устарела. Пожалуйста, запросите перевод снова.", + ) + .await?; + return Ok(()); + }; + + let text_to_translate = &job.text; + + let redis_key_user_lang = format!("user_lang:{}", user.id); + redis_client + .set(&redis_key_user_lang, &target_lang.to_string(), 7200) + .await?; + + let normalized_lang = normalize_language_code(target_lang); + + let text_chunks = split_text_tr(text_to_translate, 2800); + let google_trans = GoogleTranslator::default(); + let translation_futures = text_chunks + .iter() + .map(|chunk| google_trans.translate_async(chunk, "", &normalized_lang)); + + 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.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); + + 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 { + 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, + 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 new file mode 100644 index 0000000..5f37705 --- /dev/null +++ b/src/bot/callbacks/whisper.rs @@ -0,0 +1,91 @@ +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, + config: &Config, +) -> Result<(), MyError> { + let data = q.data.as_ref().ok_or("Callback query data is empty")?; + + let parts: Vec<&str> = data.split('_').collect(); + if parts.len() != 3 || parts[0] != "whisper" { + return Ok(()); + } + + let action = parts[1]; + let whisper_id = parts[2]; + + let user = q.from.clone(); + + let redis_key = format!("whisper:{}", whisper_id); + + let whisper: Option = config.get_redis_client().get(&redis_key).await?; + + let whisper = match whisper { + Some(w) => w, + None => { + bot.answer_callback_query(q.id) + .text("❌ Этот шепот истек или был забыт.") + .show_alert(true) + .await?; + return Ok(()); + } + }; + + let is_sender = user.id.0 == whisper.sender_id; + let is_recipient = whisper.recipients.iter().any(|r| { + if r.id == Some(user.id.0) { + return true; + } + + if let (Some(recipient_username), Some(username)) = ( + &r.username, + &user.username.as_ref().map(|s| s.to_lowercase()), + ) && recipient_username == username + { + return true; + } + false + }); + + if !is_sender && !is_recipient { + bot.answer_callback_query(q.id) + .text("🤫 Это не для тебя.") + .show_alert(true) + .await?; + return Ok(()); + } + + match action { + "read" => { + bot.answer_callback_query(q.id) + .text(whisper.content.to_string()) + .show_alert(true) + .await?; + } + "forget" => { + config.get_redis_client().delete(&redis_key).await?; + bot.answer_callback_query(q.id).text("Шепот забыт.").await?; + + if let Some(message) = q.message { + bot.edit_message_text( + message.chat().id, + message.id(), + format!("🤫 Шепот от {} был забыт.", whisper.sender_first_name), + ) + .reply_markup(InlineKeyboardMarkup::new(vec![vec![]])) + .await?; + } + } + _ => {} + } + + Ok(()) +} diff --git a/src/bot/commander.rs b/src/bot/commander.rs new file mode 100644 index 0000000..9f3acf8 --- /dev/null +++ b/src/bot/commander.rs @@ -0,0 +1,24 @@ +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 teloxide::{Bot, prelude::Message}; +use tokio::task; + +pub async fn command_handlers(bot: Bot, message: Message, cmd: Command) -> Result<(), MyError> { + let config = Config::new().await; + task::spawn(async move { + match cmd { + Command::Start(arg) => start_handler(bot, message, &config, arg).await, + Command::Translate(arg) => translate_handler(bot, &message, &config, arg).await, + Command::SpeechRecognition => speech_recognition_handler(bot, message, &config).await, + Command::Settings => settings_command_handler(bot, message).await, + } + }); + Ok(()) +} diff --git a/src/handlers/commands/mod.rs b/src/bot/commands/mod.rs similarity index 76% rename from src/handlers/commands/mod.rs rename to src/bot/commands/mod.rs index a652eb2..9aa0656 100644 --- a/src/handlers/commands/mod.rs +++ b/src/bot/commands/mod.rs @@ -1,3 +1,4 @@ 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..2157424 --- /dev/null +++ b/src/bot/commands/settings.rs @@ -0,0 +1,125 @@ +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 commander_id = message.from.map(|u| u.id.0).ok_or(MyError::UserNotFound)?; + + let owner_id = message.chat.id.to_string(); + let owner_type = if message.chat.is_private() { "user" } else { "group" }.to_string(); + + let settings_doc = Settings::get_or_create(&Owner { + id: owner_id.clone(), + r#type: owner_type.clone(), + }) + .await?; + + let text = String::from( + "⚙️ Настройки модулей\n\n\ + Нажмите на кнопку, чтобы включить или выключить соответствующий модуль.\n\ + ✅ – модуль включён\n\ + ❌ – модуль выключен\n\n" + ); + + let mut kb_buttons: Vec> = MOD_MANAGER + .get_designed_modules(&owner_type) + .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.name()); + + let callback_data = format!( + "module_select:{}:{}:{}:{}", + owner_type, owner_id, module.key(), commander_id + ); + + vec![InlineKeyboardButton::callback(text, callback_data)] + }) + .collect(); + + kb_buttons.push(vec![InlineKeyboardButton::callback( + "🗑️ Удалить данные с бота", + format!("delete_data:{}", commander_id), + )]); + + let keyboard = InlineKeyboardMarkup::new(kb_buttons); + + bot.send_message(message.chat.id, text) + .parse_mode(teloxide::types::ParseMode::Html) + .reply_markup(keyboard) + .await?; + + Ok(()) +} + +pub async fn update_settings_message( + bot: Bot, + message: MaybeInaccessibleMessage, + owner_id: String, + owner_type: String, + commander_id: u64, +) -> Result<(), MyError> { + let settings_doc = Settings::get_or_create(&Owner { + id: owner_id.clone(), + r#type: owner_type.clone(), + }) + .await?; + + let text = String::from( + "⚙️ Настройки модулей\n\n\ + Нажмите на кнопку, чтобы включить или выключить соответствующий модуль.\n\ + ✅ – модуль включён\n\ + ❌ – модуль выключен\n\n" + ); + + let mut kb_buttons: Vec> = MOD_MANAGER + .get_designed_modules(&owner_type) + .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.name()); + + let callback_data = format!( + "module_select:{}:{}:{}:{}", + owner_type, owner_id, module.key(), commander_id + ); + + vec![InlineKeyboardButton::callback(text, callback_data)] + }) + .collect(); + + kb_buttons.push(vec![InlineKeyboardButton::callback( + "🗑️ Удалить данные с бота", + format!("delete_data:{}", commander_id), + )]); + + let keyboard = InlineKeyboardMarkup::new(kb_buttons); + + if let MaybeInaccessibleMessage::Regular(msg) = message { + let _ = bot + .edit_message_text(msg.chat.id, msg.id, text) + .parse_mode(teloxide::types::ParseMode::Html) + .reply_markup(keyboard) + .await; + } + + Ok(()) +} \ No newline at end of file diff --git a/src/bot/commands/speech_recognition.rs b/src/bot/commands/speech_recognition.rs new file mode 100644 index 0000000..6831e75 --- /dev/null +++ b/src/bot/commands/speech_recognition.rs @@ -0,0 +1,23 @@ +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> { + 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 new file mode 100644 index 0000000..5bd5e62 --- /dev/null +++ b/src/bot/commands/start.rs @@ -0,0 +1,92 @@ +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::*, + types::{ParseMode, ReplyParameters}, +}; +use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup}; + +pub async fn start_handler( + bot: Bot, + message: Message, + config: &Config, + _arg: String, +) -> Result<(), MyError> { + let mut is_new_user = false; + + if message.chat.is_private() + && let Some(user) = message.from + && User::find_one(doc! { "user_id": &user.id.to_string() }).await?.is_none() { + is_new_user = true; + User::new().user_id(user.id.to_string().clone()).save().await?; + + let owner = Owner { + id: user.id.to_string(), + r#type: "user".to_string(), + }; + Settings::create_with_defaults(&owner).await?; + } + + let version = config.get_version(); + + let start_time = Instant::now(); + bot.get_me().await?; + let api_ping = start_time.elapsed().as_millis(); + + let mut system_info = System::new_all(); + system_info.refresh_all(); + + let total_ram_mb = system_info.total_memory() / (1024 * 1024); + let used_ram_mb = system_info.used_memory() / (1024 * 1024); + let cpu_usage_percent = system_info.global_cpu_usage(); + + let welcome_part = if is_new_user { + "Добро пожаловать! 👋\n\n\ + Я Fulturate — ваш многофункциональный ассистент. \ + Чтобы посмотреть все возможности и настроить меня, используйте команду /settings.\n\n".to_string() + } else { + "Fulturate тут! ⚙️\n\n".to_string() + }; + + let response_message = format!( + "{welcome_part}\ + Статус системы:\n\ +
\
+        > Версия:      {}\n\
+        > Пинг API:    {} мс\n\
+        > Нагрузка ЦП: {:.2}%\n\
+        > ОЗУ:         {}/{} МБ\n\
+        
", + version, api_ping, cpu_usage_percent, used_ram_mb, total_ram_mb + ); + + let news_link_button = + InlineKeyboardButton::url("Канал с новостями", "https://t.me/fulturate".parse().unwrap()); + let terms_of_use_link_button = InlineKeyboardButton::url( + "Условия использования", + "https://telegra.ph/Terms-Of-Use--Usloviya-ispolzovaniya-09-21" + .parse() + .unwrap(), + ); + + bot.send_message(message.chat.id, response_message) + .reply_parameters(ReplyParameters::new(message.id)) + .parse_mode(ParseMode::Html) + .reply_markup(InlineKeyboardMarkup::new(vec![vec![ + news_link_button, + terms_of_use_link_button, + ]])) + .await?; + + Ok(()) +} \ No newline at end of file diff --git a/src/bot/commands/translate.rs b/src/bot/commands/translate.rs new file mode 100644 index 0000000..6ec34cf --- /dev/null +++ b/src/bot/commands/translate.rs @@ -0,0 +1,219 @@ +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, + msg: &Message, + config: &Config, + arg: String, +) -> Result<(), MyError> { + let replied_to_message = match msg.reply_to_message() { + Some(message) => message, + None => { + bot.send_message( + msg.chat.id, + "Нужно ответить на то сообщение, которое требуется перевести, чтобы использовать эту команду.", + ) + .reply_parameters(ReplyParameters::new(msg.id)) + .parse_mode(ParseMode::Html) + .await?; + return Ok(()); + } + }; + + let text_to_translate = match replied_to_message.text() { + Some(text) => text, + None => { + bot.send_message(msg.chat.id, "Отвечать нужно на сообщение с текстом.") + .reply_parameters(ReplyParameters::new(msg.id)) + .parse_mode(ParseMode::Html) + .await?; + return Ok(()); + } + }; + + let user = msg.from.clone().unwrap(); + if replied_to_message.clone().from.unwrap().is_bot { + bot.send_message(msg.chat.id, "Отвечать нужно на сообщение от пользователя.") + .reply_parameters(ReplyParameters::new(msg.id)) + .parse_mode(ParseMode::Html) + .await?; + return Ok(()); + } + + let target_lang: String; + + if !arg.trim().is_empty() { + target_lang = normalize_language_code(arg.trim()); + } else { + let redis_key = format!("user_lang:{}", user.id); + let redis_client = config.get_redis_client(); + let cached_lang: Option = redis_client.get(&redis_key).await?; + + if let Some(lang) = cached_lang { + target_lang = lang; + } else { + let 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 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 display_pages = split_text_tr(&full_translated_text, 4000); + + let lang_display_name = SUPPORTED_LANGUAGES + .iter() + .find(|(code, _)| *code == target_lang) + .map(|(_, name)| *name) + .unwrap_or(&target_lang); + + 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]); + } + } + + bot.send_message(msg.chat.id, response) + .reply_parameters(ReplyParameters::new(replied_to_message.id)) + .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(), + }; + config + .get_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.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 new file mode 100644 index 0000000..4b96019 --- /dev/null +++ b/src/bot/dispatcher.rs @@ -0,0 +1,306 @@ +use crate::bot::modules::registry::MOD_MANAGER; +use crate::bot::modules::Owner; +use crate::core::db::schemas::settings::Settings; +use crate::{ + bot::{ + callbacks::callback_query_handlers, + commander::command_handlers, + inlines::{ + cobalter::{handle_cobalt_inline, handle_inline_video, 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, + db::schemas::user::User as DBUser}, + errors::MyError, + util::enums::Command, +}; +use log::{debug, error, info}; +use mongodb::bson::doc; +use oximod::{set_global_client, Model}; +use serde::Deserialize; +use std::{convert::Infallible, fmt::Write, ops::ControlFlow, sync::Arc}; +use teloxide::{ + dispatching::{ + Dispatcher, DpHandlerDescription, HandlerExt, MessageFilterExt, UpdateFilterExt, + }, + dptree, + error_handlers::LoggingErrorHandler, + payloads::{AnswerInlineQuerySetters, SendDocumentSetters}, + prelude::{ChatId, Handler, Message, Requester}, + types::{ + Chat, InlineKeyboardButton, InlineKeyboardMarkup, InlineQuery, InlineQueryResult, + InlineQueryResultArticle, InputFile, InputMessageContent, InputMessageContentText, Me, + MessageId, ParseMode, ThreadId, Update, User, + }, + update_listeners::Polling, + utils::{command::BotCommands, html}, + Bot, +}; + +async fn root_handler( + update: Update, + config: Arc, + bot: Bot, + logic: Arc, DpHandlerDescription>>, + me: Me, +) -> Result<(), Infallible> { + let deps = dptree::deps![update.clone(), config.clone(), bot.clone(), me.clone()]; + let result = logic.dispatch(deps).await; + + if let ControlFlow::Break(Err(err)) = result { + let error_handler_endpoint: Handler<'static, (), DpHandlerDescription> = + dptree::endpoint(handle_error); + let error_deps = dptree::deps![Arc::new(err), update, config, bot]; + let _ = error_handler_endpoint.dispatch(error_deps).await; + } + + Ok(()) +} + +async fn is_user_registered(q: InlineQuery) -> bool { + let user_id_str = q.from.id.to_string(); + DBUser::find_one(doc! { "user_id": &user_id_str }) + .await + .is_ok_and(|user| user.is_some()) +} + +async fn prompt_registration(bot: Bot, q: InlineQuery, me: Me) -> Result<(), MyError> { + let user_id_str = q.from.id.to_string(); + debug!("User {} not found. Offering to register.", user_id_str); + + let start_url = format!("https://t.me/{}?start=inl", me.username()); + + let keyboard = InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::url( + "▶️ Зарегистрироваться".to_string(), + start_url.parse()?, + )]]); + + let article = InlineQueryResultArticle::new( + "register_prompt", + "Вы не зарегистрированы", + InputMessageContent::Text(InputMessageContentText::new( + "Чтобы использовать бота, пожалуйста, сначала начните диалог с ним.", + )), + ) + .description("Нажмите здесь, чтобы начать чат с ботом и разблокировать все функции.") + .reply_markup(keyboard); + + if let Err(e) = bot + .answer_inline_query(q.id, vec![InlineQueryResult::Article(article)]) + .cache_time(10) + .await + { + error!("Failed to send 'register' inline prompt: {:?}", e); + } + + Ok(()) +} + +#[derive(Deserialize)] +struct EnabledCheck { + enabled: bool, +} + +async fn are_any_inline_modules_enabled(q: InlineQuery) -> bool { + let owner = Owner { + id: q.from.id.to_string(), + r#type: "user".to_string(), + }; + + if let Ok(settings) = Settings::get_or_create(&owner).await { + for module in MOD_MANAGER.get_all_modules() { + if module.is_enabled(&owner).await + && let Some(settings_json) = settings.modules.get(module.key()) + && let Ok(check) = serde_json::from_value::(settings_json.clone()) + && check.enabled { + return true; + } + } + } + false +} + +async fn send_modules_disabled_message(bot: Bot, q: InlineQuery) -> Result<(), MyError> { + let article = InlineQueryResultArticle::new( + "modules_disabled", + "Все модули выключены", + InputMessageContent::Text(InputMessageContentText::new( + "Все инлайн-модули выключены. Чтобы ими воспользоваться, активируйте их в настройках.", + )), + ) + .description("Используйте /settings в чате с ботом, чтобы включить их."); + + bot.answer_inline_query(q.id, vec![InlineQueryResult::Article(article)]) + .cache_time(10) + .await?; + Ok(()) +} + +pub fn inline_query_handler() -> Handler<'static, Result<(), MyError>, DpHandlerDescription> { + dptree::entry() + .branch( + dptree::filter_async(|q: InlineQuery| async move { !is_user_registered(q).await }) + .endpoint(prompt_registration), + ) + .branch( + dptree::filter_async(is_user_registered) + .filter_async(|q: InlineQuery| async move { !are_any_inline_modules_enabled(q).await }) + .endpoint(send_modules_disabled_message), + ) + .branch( + dptree::filter_async(is_user_registered) + .filter_async(are_any_inline_modules_enabled) + .branch(dptree::filter_async(is_currency_query).endpoint(handle_currency_inline)) + .branch(dptree::filter_async(is_query_url).endpoint(handle_cobalt_inline)) + .branch(dptree::filter_async(is_whisper_query).endpoint(handle_whisper_inline)), + ) +} + +async fn run_bot(config: Arc) -> Result<(), MyError> { + let command_menu = Command::bot_commands(); + let bot = config.get_bot(); + bot.set_my_commands(command_menu.clone()).await?; + + let logic_handlers = dptree::entry() + .branch( + Update::filter_message() + .filter_command::() + .endpoint(command_handlers), + ) + .branch( + Update::filter_message() + .branch(Message::filter_text().endpoint(handle_currency)) + .branch(Message::filter_video_note().endpoint(handle_speech)) + .branch(Message::filter_voice().endpoint(handle_speech)), + ) + .branch(Update::filter_callback_query().endpoint(callback_query_handlers)) + .branch(Update::filter_my_chat_member().endpoint(handle_bot_added)) + .branch(Update::filter_inline_query().branch(inline_query_handler())) + .branch(Update::filter_chosen_inline_result().endpoint(handle_inline_video)); + + let me = bot.get_me().await?; + info!("Bot name: {:?}", me.username()); + + let listener = Polling::builder(bot.clone()).drop_pending_updates().build(); + + Dispatcher::builder(bot.clone(), dptree::endpoint(root_handler)) + .dependencies(dptree::deps![config.clone(), Arc::new(logic_handlers), me]) + .enable_ctrlc_handler() + .build() + .dispatch_with_listener(listener, LoggingErrorHandler::new()) + .await; + + Ok(()) +} + +async fn run_database(config: Arc) -> Result<(), MyError> { + let url = config.get_mongodb_url().to_owned(); + set_global_client(url.clone()).await?; + info!("Database connected successfully. URL: {}", url); + Ok(()) +} + +pub async fn run() -> Result<(), MyError> { + let config = Arc::new(Config::new().await); + let _th = tokio::join!(run_database(config.clone()), run_bot(config.clone())); + Ok(()) +} + +fn extract_info(update: &Update) -> (Option<&User>, Option<&Chat>) { + match &update.kind { + teloxide::types::UpdateKind::Message(m) => (m.from.as_ref(), Some(&m.chat)), + teloxide::types::UpdateKind::CallbackQuery(q) => { + (Some(&q.from), q.message.as_ref().map(|m| m.chat())) + } + teloxide::types::UpdateKind::InlineQuery(q) => (Some(&q.from), None), + teloxide::types::UpdateKind::MyChatMember(m) => (Some(&m.from), Some(&m.chat)), + _ => (None, None), + } +} + +fn short_error_name(error: &MyError) -> String { + format!("{}", error) +} + +pub async fn handle_error(err: Arc, update: Update, config: Arc, bot: Bot) { + error!("An error has occurred: {:?}", err); + + let (user, chat) = extract_info(&update); + let mut message_text = String::new(); + + writeln!(&mut message_text, "🚨 Новая ошибка!\n").unwrap(); + + if let Some(chat) = chat { + let title = chat + .title() + .map_or("".to_string(), |t| format!(" ({})", html::escape(t))); + writeln!( + &mut message_text, + "В чате: {}{}", + chat.id, title + ) + .unwrap(); + } else { + writeln!(&mut message_text, "В чате: (???)").unwrap(); + } + + if let Some(user) = user { + let username = user + .username + .as_ref() + .map_or("".to_string(), |u| format!(" (@{})", u)); + let full_name = html::escape(&user.full_name()); + writeln!( + &mut message_text, + "Вызвал: {} ({}){}", + full_name, user.id, username + ) + .unwrap(); + } else { + writeln!(&mut message_text, "Вызвал: (???)").unwrap(); + } + + let error_name = short_error_name(&err); + writeln!( + &mut message_text, + "\nОшибка:\n
{}
", + html::escape(&error_name) + ) + .unwrap(); + + let hashtag = "#error"; + writeln!(&mut message_text, "\n{}", hashtag).unwrap(); + + let full_error_text = format!("{:#?}", err); + let document = InputFile::memory(full_error_text.into_bytes()).file_name("error_details.txt"); + + if let (Ok(log_chat_id), Ok(error_thread_id)) = ( + config.get_log_chat_id().parse::(), + config.get_error_chat_thread_id().parse::(), + ) { + let chat_id = ChatId(log_chat_id); + + match bot + .send_document(chat_id, document) + .caption(message_text) + .parse_mode(ParseMode::Html) + .reply_markup(delete_message_button(72)) + .message_thread_id(ThreadId(MessageId(error_thread_id))) + .await + { + Ok(_) => info!("Error report sent successfully to chat {}", log_chat_id), + Err(e) => error!("Failed to send error report to chat {}: {}", log_chat_id, e), + } + } else { + error!( + "LOG_CHAT_ID ({}) or ERROR_CHAT_THREAD_ID ({}) is not a valid integer", + config.get_log_chat_id(), + config.get_error_chat_thread_id() + ); + } +} \ No newline at end of file diff --git a/src/bot/inlines/cobalter.rs b/src/bot/inlines/cobalter.rs new file mode 100644 index 0000000..4139860 --- /dev/null +++ b/src/bot/inlines/cobalter.rs @@ -0,0 +1,212 @@ +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 std::sync::Arc; +use teloxide::{ + Bot, + prelude::*, + types::{ + InlineQuery, InlineQueryResult, InlineQueryResultArticle, InlineQueryResultPhoto, + InputMessageContent, InputMessageContentText, InputFile, InputMedia, InputMediaVideo + }, +}; +use url::Url; + +static URL_REGEX: Lazy = + Lazy::new(|| Regex::new(r"^(https?)://[^\s/$.?#].[^\s]*$").unwrap()); + +pub async fn is_query_url(inline_query: InlineQuery) -> bool { + if !URL_REGEX.is_match(&inline_query.query) { + return false; + }; + + let owner = Owner { + id: inline_query.from.id.to_string(), + r#type: "user".to_string(), + }; + + match Settings::get_module_settings::(&owner, "cobalt").await { + Ok(settings) => settings.enabled, + Err(_) => false, + } +} + +fn build_results_from_media( + original_url: &str, + media: DownloadResult, + url_hash: &str, + user_id: u64, +) -> Vec { + match media { + DownloadResult::Video { url, .. } => { + if let Ok(_url) = url.parse::() { + let url_kb = make_single_url_keyboard(original_url); + let result = InlineQueryResultArticle::new( + format!("cobalt_video:{}", url_hash), + "Скачать видео", + InputMessageContent::Text(InputMessageContentText::new( + "Нажмите, чтобы отправить видео", + )), + ) + .reply_markup(url_kb); + + vec![result.into()] + } else { + vec![ + InlineQueryResultArticle::new( + format!("cobalt_video:{}", url_hash), + "Видео не найдено", + InputMessageContent::Text(InputMessageContentText::new( + "❌ не удалось получить видео", + )), + ) + .into(), + ] + } + } + DownloadResult::Photos { urls, .. } => { + let total = urls.len(); + urls.into_iter() + .enumerate() + .filter_map(|(i, url_str)| { + if let (Ok(photo_url), Ok(thumb_url)) = (url_str.parse(), url_str.parse()) { + let result_id = format!("{}_{}", url_hash, i); + + let keyboard = if total > 1 { + make_photo_pagination_keyboard( + url_hash, + i, + total, + user_id, + original_url, + ) + } else { + make_single_url_keyboard(original_url) + }; + + let photo_result = + InlineQueryResultPhoto::new(result_id, photo_url, thumb_url) + .reply_markup(keyboard); + + Some(photo_result.into()) + } else { + None + } + }) + .collect() + } + } +} + +pub async fn handle_cobalt_inline( + bot: Bot, + q: InlineQuery, + 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); + + let redis = config.get_redis_client(); + + 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_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)) => { + 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) + } + _ => { + let error_article = InlineQueryResultArticle::new( + "error", + "Error", + InputMessageContent::Text(InputMessageContentText::new( + "Failed to process link. Media not found or an error occurred.", + )), + ) + .description("Could not fetch media. Please try again later."); + vec![error_article.into()] + } + } + }; + bot.answer_inline_query(q.id, results).cache_time(0).await?; + Ok(()) +} + +pub async fn handle_inline_video( + bot: Bot, + chosen: ChosenInlineResult, + config: Arc, +) -> Result<(), MyError> { + let Some(inline_message_id) = chosen.inline_message_id else { + return Ok(()); + }; + + let Some(url_hash) = chosen.result_id.strip_prefix("cobalt_video:") else { + return Ok(()); + }; + + bot.edit_message_text_inline(&inline_message_id, "⏳ Загружаю видео...") + .await?; + + let redis = config.get_redis_client(); + let cache_key = format!("cobalt_cache:{}", url_hash); + + match redis.get::(&cache_key).await? { + Some(DownloadResult::Video { url, original_url }) => { + let media = InputMedia::Video(InputMediaVideo::new(InputFile::url(url.parse()?))); + let url_kb = make_single_url_keyboard(&original_url); + + if let Err(_e) = bot + .edit_message_media_inline(&inline_message_id, media) + .reply_markup(url_kb) + .await + { + bot.edit_message_text_inline( + inline_message_id, + "❌ Ошибка: не удалось отправить видео.", + ) + .await?; + } + } + _ => { + bot.edit_message_text_inline(inline_message_id, "❌ Ошибка: видео не найдено в кэше.") + .await?; + } + } + + Ok(()) +} diff --git a/src/bot/inlines/currency.rs b/src/bot/inlines/currency.rs new file mode 100644 index 0000000..20d362f --- /dev/null +++ b/src/bot/inlines/currency.rs @@ -0,0 +1,119 @@ +use crate::{ + bot::modules::{Owner, currency::CurrencySettings}, + core::{ + config::Config, + db::schemas::{settings::Settings}, + services::currency::converter::CURRENCY_REGEX, + }, + errors::MyError, +}; +use log::{debug, error}; +use std::sync::Arc; +use teloxide::{ + Bot, + payloads::AnswerInlineQuerySetters, + prelude::Requester, + types::{ + 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, + config: Arc, + _me: Me, +) -> Result<(), MyError> { + debug!("Handling currency inline query: {}", &q.query); + + let converter = config.get_currency_converter(); + let text_to_process = &q.query; + + let owner = Owner { + id: q.from.id.to_string(), + r#type: "user".to_string(), // hack: inline-query always from user + }; + + // 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(()); + } + + results.truncate(5); + + let raw_results = results.join("\n"); + + let formatted = results + .into_iter() + .map(|result_block| { + let escaped_block = teloxide::utils::html::escape(&result_block); + format!("
{}
", escaped_block) + }) + .collect::>(); + let final_message = formatted.join("\n"); + + let article = InlineQueryResultArticle::new( + Uuid::new_v4().to_string(), + "Currency Conversion", + InputMessageContent::Text( + InputMessageContentText::new(final_message.clone()).parse_mode(ParseMode::Html), + ), + ) + .description(raw_results); + + let result = InlineQueryResult::Article(article); + + if let Err(e) = bot + .answer_inline_query(q.id, vec![result]) + .cache_time(2) + .await + { + error!("Failed to answer currency inline query: {:?}", e); + } + } + Err(e) => { + error!( + "Currency conversion processing error in inline mode: {:?}", + e + ); + } + } + + Ok(()) +} diff --git a/src/bot/inlines/mod.rs b/src/bot/inlines/mod.rs new file mode 100644 index 0000000..e0a2024 --- /dev/null +++ b/src/bot/inlines/mod.rs @@ -0,0 +1,3 @@ +pub mod cobalter; +pub mod currency; +pub mod whisper; diff --git a/src/bot/inlines/whisper.rs b/src/bot/inlines/whisper.rs new file mode 100644 index 0000000..7d17730 --- /dev/null +++ b/src/bot/inlines/whisper.rs @@ -0,0 +1,283 @@ +use crate::{core::config::Config, errors::MyError}; +use log::error; +use serde::{Deserialize, Serialize}; +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::bot::modules::Owner; +use crate::bot::modules::whisper::WhisperSettings; +use crate::core::db::schemas::settings::Settings; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] +pub struct Recipient { + pub id: Option, + pub first_name: String, + pub username: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Whisper { + pub sender_id: u64, + pub sender_first_name: String, + pub content: String, + pub recipients: Vec, +} + +fn generate_recipient_hash(person: &Recipient) -> String { + let mut s = DefaultHasher::new(); + person.id.hash(&mut s); + person.username.hash(&mut s); + + format!("{:x}", s.finish()) +} + +fn parse_query(query: &str) -> (String, Vec) { + let mut recipients = Vec::new(); + let mut content_end_index = query.len(); + + for part in query.split_whitespace().rev() { + 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 { + break; + } + } + recipients.reverse(); + + let content = query[..content_end_index].trim().to_string(); + (content, recipients) +} + +async fn update_recents( + config: &Config, + user_id: u64, + new_recipients: &[Recipient], +) -> Result<(), MyError> { + let redis_key = format!("whisper_recents:{}", user_id); + let mut recents: Vec = config + .get_redis_client() + .get(&redis_key) + .await? + .unwrap_or_default(); + + for new_recipient in new_recipients.iter().rev() { + recents.retain(|r| { + let is_same_id = new_recipient.id.is_some() && r.id == new_recipient.id; + let is_same_username = + new_recipient.username.is_some() && r.username == new_recipient.username; + !is_same_id && !is_same_username + }); + recents.insert(0, new_recipient.clone()); + } + + recents.truncate(5); + + config + .get_redis_client() + .set(&redis_key, &recents, 86400 * 30) + .await?; + Ok(()) +} + +pub async fn handle_whisper_inline( + bot: Bot, + q: InlineQuery, + config: Arc, +) -> Result<(), MyError> { + let owner = Owner { + id: q.from.id.to_string(), + r#type: "user".to_string(), + }; + + let settings = Settings::get_module_settings::(&owner, "whisper").await?; + if !settings.enabled { + return Ok(()); + } + + if q.query.is_empty() { + let article = InlineQueryResultArticle::new( + "whisper_help", + "Как использовать шепот?", + InputMessageContent::Text(InputMessageContentText::new( + "Начните вводить сообщение, а в конце укажите получателей через @username или их Telegram ID.", + )), + ) + .description("Пример: Привет! @username 123456789"); + + bot.answer_inline_query(q.id, vec![InlineQueryResult::Article(article)]) + .cache_time(5) + .await?; + return Ok(()); + } + + let (content, recipient_identifiers) = parse_query(&q.query); + let sender = q.from.clone(); + + if content.is_empty() { + return Ok(()); + } + + if recipient_identifiers.is_empty() { + let redis_key = format!("whisper_recents:{}", q.from.id.0); + let recents: Option> = config.get_redis_client().get(&redis_key).await?; + + let mut results = Vec::new(); + if let Some(recents) = recents { + for person in recents { + let query_filler = if let Some(u) = &person.username { + format!("@{}", u) + } else if let Some(id) = person.id { + id.to_string() + } else { + continue; + }; + + let keyboard = InlineKeyboardMarkup::new(vec![vec![ + InlineKeyboardButton::switch_inline_query_current_chat( + format!("Выбрать {}", person.first_name), + format!("{} {} ", q.query.trim(), query_filler), + ), + ]]); + + let article = InlineQueryResultArticle::new( + format!("recent_{}", generate_recipient_hash(&person)), + format!("✍️ Написать {}", person.first_name), + InputMessageContent::Text(InputMessageContentText::new(format!( + "Нажмите кнопку ниже, чтобы начать шепот для {}", + person.first_name + ))), + ) + .description("Нажмите кнопку ниже, чтобы выбрать этого пользователя") + .reply_markup(keyboard); + results.push(InlineQueryResult::Article(article)); + } + } + + let article = InlineQueryResultArticle::new( + "whisper_no_recipients", + "Кому шептать?", + InputMessageContent::Text(InputMessageContentText::new( + "Укажите получателей, добавив их юзернеймы (@username) или ID в конце сообщения.", + )), + ) + .description("Вы не указали получателя."); + results.push(InlineQueryResult::Article(article)); + + bot.answer_inline_query(q.id, results) + .cache_time(10) + .await?; + return Ok(()); + } + + let mut recipients: Vec = Vec::new(); + for identifier in &recipient_identifiers { + if let Some(username) = identifier.strip_prefix('@') { + let username = username.to_string(); + recipients.push(Recipient { + id: None, + first_name: username.clone(), + username: Some(username.to_lowercase()), + }); + } else if let Ok(id) = identifier.parse::() { + recipients.push(Recipient { + id: Some(id), + first_name: format!("{}", id), + username: None, + }); + } + } + + recipients.push(Recipient { + id: Some(sender.id.0), + first_name: sender.first_name.clone(), + username: sender.username.clone(), + }); + + let recipients_for_recents: Vec = recipients + .iter() + .filter(|r| r.id != Some(sender.id.0)) + .cloned() + .collect(); + + 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(); + let whisper = Whisper { + sender_id: sender.id.0, + sender_first_name: sender.first_name.clone(), + content: content.clone(), + recipients, + }; + + let redis_key = format!("whisper:{}", whisper_id); + config + .get_redis_client() + .set(&redis_key, &whisper, 86400) + .await?; + + let keyboard = InlineKeyboardMarkup::new(vec![vec![ + InlineKeyboardButton::callback("👁️ Прочитать", format!("whisper_read_{}", whisper_id)), + InlineKeyboardButton::callback("🗑️ Забыть", format!("whisper_forget_{}", whisper_id)), + ]]); + + let recipients_str = whisper + .recipients + .iter() + .filter(|r| r.id != Some(sender.id.0)) + .map(|r| { + if let Some(id) = r.id { + html::user_mention(UserId(id), &r.first_name) + } else { + format!("@{}", html::escape(&r.first_name)) + } + }) + .collect::>() + .join(", "); + + let message_text = format!( + "🤫 {} шепчет для {}", + whisper.sender_first_name, recipients_str + ); + + let article = InlineQueryResultArticle::new( + whisper_id, + "Нажмите, чтобы отправить шепот", + InputMessageContent::Text( + InputMessageContentText::new(message_text).parse_mode(ParseMode::Html), + ), + ) + .description(format!("Сообщение: {}", content)) + .reply_markup(keyboard); + + if let Err(e) = bot + .answer_inline_query(q.id, vec![InlineQueryResult::Article(article)]) + .cache_time(0) + .await + { + error!("Failed to answer whisper inline query: {:?}", e); + } + + Ok(()) +} + +// 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 new file mode 100644 index 0000000..2c9d601 --- /dev/null +++ b/src/bot/keyboards/cobalt.rs @@ -0,0 +1,30 @@ +use crate::util::paginator::{FrameBuild, Paginator}; +use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup}; + +pub fn make_single_url_keyboard(url: &str) -> InlineKeyboardMarkup { + InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::url( + "URL", + url.parse().unwrap(), + )]]) +} + +pub fn make_photo_pagination_keyboard( + url_hash: &str, + current_index: usize, + total_photos: usize, + user_id: u64, + original_url: &str, +) -> InlineKeyboardMarkup { + let url_button_row = vec![InlineKeyboardButton::url( + "URL", + original_url.to_string().parse().unwrap(), + )]; + + 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 new file mode 100644 index 0000000..322c713 --- /dev/null +++ b/src/bot/keyboards/delete.rs @@ -0,0 +1,22 @@ +use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup}; + +pub const DELETE_CALLBACK_DATA: &str = "delete_msg"; +pub const CONFIRM_DELETE_CALLBACK_DATA: &str = "delete_confirm"; + +pub(crate) fn delete_message_button(original_user_id: u64) -> InlineKeyboardMarkup { + let callback_data = format!("{}:{}", DELETE_CALLBACK_DATA, original_user_id); + let delete_button = InlineKeyboardButton::callback("🗑️", callback_data); + InlineKeyboardMarkup::new(vec![vec![delete_button]]) +} + +pub(crate) fn confirm_delete_keyboard(original_user_id: u64) -> InlineKeyboardMarkup { + let yes_callback = format!("{}:{}:yes", CONFIRM_DELETE_CALLBACK_DATA, original_user_id); + let no_callback = format!("{}:{}:no", CONFIRM_DELETE_CALLBACK_DATA, original_user_id); + + let buttons = vec![ + InlineKeyboardButton::callback("Да", yes_callback), + InlineKeyboardButton::callback("Нет", no_callback), + ]; + + InlineKeyboardMarkup::new(vec![buttons]) +} diff --git a/src/bot/keyboards/mod.rs b/src/bot/keyboards/mod.rs new file mode 100644 index 0000000..75a1fb9 --- /dev/null +++ b/src/bot/keyboards/mod.rs @@ -0,0 +1,4 @@ +pub mod cobalt; +pub mod delete; +pub mod transcription; +pub mod translate; diff --git a/src/bot/keyboards/transcription.rs b/src/bot/keyboards/transcription.rs new file mode 100644 index 0000000..daca6c6 --- /dev/null +++ b/src/bot/keyboards/transcription.rs @@ -0,0 +1,26 @@ +use crate::util::paginator::{FrameBuild, Paginator}; +use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup}; + +pub const TRANSCRIPTION_MODULE_KEY: &str = "speech"; + +pub fn create_transcription_keyboard( + current_page: usize, + total_pages: usize, + user_id: u64, +) -> InlineKeyboardMarkup { + 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 { + InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::callback( + "⬅️ Назад", + "back_to_full", + )]]) +} diff --git a/src/bot/keyboards/translate.rs b/src/bot/keyboards/translate.rs new file mode 100644 index 0000000..7a5a0b0 --- /dev/null +++ b/src/bot/keyboards/translate.rs @@ -0,0 +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 { + 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/handlers/messages/messager.rs b/src/bot/messager.rs similarity index 54% rename from src/handlers/messages/messager.rs rename to src/bot/messager.rs index e097f0a..2663322 100644 --- a/src/handlers/messages/messager.rs +++ b/src/bot/messager.rs @@ -1,21 +1,20 @@ -use crate::config::Config; -use crate::handlers::messages::sounder::sound_handlers; -use crate::util::errors::MyError; -use crate::util::inline::delete_message_button; +use crate::{ + bot::{ + keyboards::delete::delete_message_button, messages::sounder::sound_handlers, modules::Owner, + }, + core::config::Config, + errors::MyError, +}; use log::error; -use mongodb::bson::doc; -use oximod::Model; -use teloxide::Bot; -use teloxide::payloads::{EditMessageLiveLocationSetters, 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::db::schemas::group::Group; -use crate::db::schemas::user::User; -use crate::util::currency::converter::get_default_currencies; -use crate::util::db::create_default_values; -pub(crate) async fn handle_speech(bot: Bot, message: Message) -> Result<(), MyError> { +pub async fn handle_speech(bot: Bot, message: Message) -> Result<(), MyError> { let config = Config::new().await; let user = message.from.clone().unwrap(); @@ -32,14 +31,11 @@ pub(crate) async fn handle_speech(bot: Bot, message: Message) -> Result<(), MyEr Ok(()) } -pub(crate) async fn handle_currency(bot: Bot, message: Message) -> Result<(), MyError> { +pub async fn handle_currency(bot: Bot, message: Message) -> Result<(), MyError> { let config = Config::new().await; - let bot_clone = bot.clone(); - task::spawn(async move { let user = message.from.clone().unwrap(); - let id = message.chat.id.to_string(); if message.forward_from_user().is_some_and(|orig| orig.is_bot) || user.is_bot @@ -48,27 +44,25 @@ pub(crate) async fn handle_currency(bot: Bot, message: Message) -> Result<(), My return; } - if !message.chat.is_private() { - if !Group::exists(doc! { "group_id": &id }).await.unwrap() { - let _ = create_default_values(id.clone(), false).await; - } - } - - if !User::exists(doc! { "user_id": user.id.0.to_string() }).await.unwrap() { - let _ = create_default_values(user.id.0.to_string(), true).await; - } - 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() @@ -78,10 +72,8 @@ pub(crate) async fn handle_currency(bot: Bot, message: Message) -> Result<(), My }) .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 new file mode 100644 index 0000000..dab0a20 --- /dev/null +++ b/src/bot/messages/chat.rs @@ -0,0 +1,94 @@ +use crate::{ + bot::modules::Owner, + core::db::schemas::{group::Group, settings::Settings, user::User}, + errors::MyError, +}; +use log::{info}; +use mongodb::bson::doc; +use oximod::ModelTrait; +use teloxide::{ + payloads::SendMessageSetters, + prelude::Requester, + types::{ChatMemberUpdated, InlineKeyboardButton, InlineKeyboardMarkup, ParseMode}, + Bot, +}; + +pub async fn handle_bot_added(bot: Bot, update: ChatMemberUpdated) -> Result<(), MyError> { + let id = update.chat.id.to_string(); + + if update.new_chat_member.is_banned() || update.new_chat_member.is_left() { + info!("Bot was kicked/banned. Deleting all data for ID: {}", &id); + + let owner_type = if update.chat.is_private() { "user" } else { "group" }; + + if owner_type == "user" { + User::delete(doc! { "user_id": &id }).await.ok(); + } else { + Group::delete(doc! { "group_id": &id }).await.ok(); + } + + Settings::delete(doc! { "owner_id": &id, "owner_type": owner_type }) + .await + .ok(); + + return Ok(()); + } + + info!("Bot added to chat. ID: {}", &id); + + let welcome_text = if update.chat.is_private() { + "Добро пожаловать в Fulturate! 👋\n\n\ + Я готов помочь с различными задачами!\n\n\ + Вот краткий список моих возможностей:\n\ + - 📥 Скачивание медиа: из различных источников.\n\ + - 💱 Конвертация валют: актуальные курсы всегда под рукой.\n\ + - 🤫 Система «шепота»: для более приватного общения.\n\n\ + Чтобы настроить модули под себя и узнать больше, используйте команду /settings." + .to_string() + } else { + "Спасибо, что добавили Fulturate в ваш чат! 🎉\n\n\ + Я многофункциональный бот, готовый помогать вашему чату!\n\n\ + Для полноценной работы мне необходимы права администратора. \ + Это позволит мне обрабатывать команды и эффективно взаимодействовать с участниками.\n\n\ + Чтобы настроить мои модули и возможности, один из администраторов чата может использовать команду /settings." + .to_string() + }; + + let news_link_button = + InlineKeyboardButton::url("Канал с новостями", "https://t.me/fulturate".parse().unwrap()); + let terms_of_use_link_button = InlineKeyboardButton::url( + "Условия использования", + "https://telegra.ph/Terms-Of-Use--Usloviya-ispolzovaniya-09-21" + .parse()?, + ); + + bot.send_message(update.chat.id, welcome_text) + .parse_mode(ParseMode::Html) + .reply_markup(InlineKeyboardMarkup::new(vec![vec![ + news_link_button, + terms_of_use_link_button, + ]])) + .await?; + + if update.chat.is_private() { + if User::find_one(doc! { "user_id": &id }).await?.is_none() { + User::new().user_id(id.clone()).save().await?; + } + let owner = Owner { + id, + r#type: "user".to_string(), + }; + Settings::get_or_create(&owner).await?; + } else { + if Group::find_one(doc! { "group_id": &id }).await?.is_none() { + Group::new().group_id(id.clone()).save().await?; + } + let owner = Owner { + id, + r#type: "group".to_string(), + }; + Settings::get_or_create(&owner).await?; + } + + Ok(()) +} \ No newline at end of file diff --git a/src/bot/messages/mod.rs b/src/bot/messages/mod.rs new file mode 100644 index 0000000..7c70fb9 --- /dev/null +++ b/src/bot/messages/mod.rs @@ -0,0 +1,3 @@ +pub mod chat; +pub mod sound; +pub mod sounder; diff --git a/src/handlers/messages/sound/mod.rs b/src/bot/messages/sound/mod.rs similarity index 100% rename from src/handlers/messages/sound/mod.rs rename to src/bot/messages/sound/mod.rs diff --git a/src/bot/messages/sound/voice.rs b/src/bot/messages/sound/voice.rs new file mode 100644 index 0000000..2319a48 --- /dev/null +++ b/src/bot/messages/sound/voice.rs @@ -0,0 +1,8 @@ +use crate::core::config::Config; +use crate::core::services::speech_recognition::transcription_handler; +use crate::errors::MyError; +use teloxide::prelude::*; + +pub async fn voice_handler(bot: Bot, msg: Message, config: &Config) -> Result<(), MyError> { + transcription_handler(bot, &msg, config).await +} diff --git a/src/bot/messages/sound/voice_note.rs b/src/bot/messages/sound/voice_note.rs new file mode 100644 index 0000000..456c510 --- /dev/null +++ b/src/bot/messages/sound/voice_note.rs @@ -0,0 +1,8 @@ +use crate::core::config::Config; +use crate::core::services::speech_recognition::transcription_handler; +use crate::errors::MyError; +use teloxide::prelude::*; + +pub async fn voice_note_handler(bot: Bot, msg: Message, config: &Config) -> Result<(), MyError> { + transcription_handler(bot, &msg, config).await +} diff --git a/src/handlers/messages/sounder.rs b/src/bot/messages/sounder.rs similarity index 50% rename from src/handlers/messages/sounder.rs rename to src/bot/messages/sounder.rs index e467357..8c7abaa 100644 --- a/src/handlers/messages/sounder.rs +++ b/src/bot/messages/sounder.rs @@ -1,14 +1,11 @@ -use crate::config::Config; -use crate::handlers::messages::sound::{voice::voice_handler, voice_note::voice_note_handler}; -use crate::util::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(crate) async fn sound_handlers( - bot: Bot, - message: Message, - config: &Config, -) -> Result<(), MyError> { +pub async fn sound_handlers(bot: Bot, message: Message, config: &Config) -> Result<(), MyError> { let config = config.clone(); tokio::spawn(async move { if message.voice().is_some() { diff --git a/src/bot/mod.rs b/src/bot/mod.rs new file mode 100644 index 0000000..fc20429 --- /dev/null +++ b/src/bot/mod.rs @@ -0,0 +1,9 @@ +pub mod callbacks; +pub mod commander; +pub mod commands; +pub mod dispatcher; +pub mod inlines; +pub mod keyboards; +pub mod messager; +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..aa6f79d --- /dev/null +++ b/src/bot/modules/cobalt.rs @@ -0,0 +1,195 @@ +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 name(&self) -> &'static str { + "Cobalt Downloader" + } + + fn description(&self) -> &'static str { + "Можно скачивать видео, фото и аудио с популярных платформ: YouTube, TikTok, Reddit (только видео), Instagram, Bluesky, Bilibili, Dailymotion, Facebook, Loom, OK, Pinterest, Newgrounds, Snapchat, SoundCloud, Streamable, Tumblr, Twitch Clips, Twitter, Iméo, Xiaohongshu. \ + Огромная благодарность создателям утилиты cobalt.tools.\n\n\ + Модуль доступен через inlin'ы: \"@fulturatebot ссылка на медиа\"\n\ + Некоторые сервисы могут быть временно недоступны не по нашей вине из-за ограничений или изменений на стороне платформ." + } + + async fn get_settings_ui( + &self, + owner: &Owner, + commander_id: u64, + ) -> Result<(String, InlineKeyboardMarkup), MyError> { + let settings: CobaltSettings = Settings::get_module_settings(owner, self.key()).await?; + + let text = format!( + "⚙️ Настройки модуля: {}\n
{}
\nСтатус: {}", + self.name(), + self.description(), + if settings.enabled { "✅ Включен" } else { "❌ Выключен" } + ); + + let toggle_button = InlineKeyboardButton::callback( + if settings.enabled { "Выключить модуль" } else { "Включить модуль" }, + format!("{}:settings:toggle_module:{}", self.key(), commander_id), + ); + + let 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(), + commander_id + ); + 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, + commander_id + ); + + 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, commander_id), + )], + ]); + + Ok((text, keyboard)) + } + + async fn handle_callback( + &self, + bot: Bot, + q: &CallbackQuery, + owner: &Owner, + data: &str, + commander_id: u64, + ) -> Result<(), MyError> { + let Some(message) = &q.message else { return Ok(()); }; + let Some(message) = message.regular_message() else { return Ok(()); }; + + let parts: Vec<_> = data.split(':').collect(); + + if parts.len() == 1 && parts[0] == "toggle_module" { + let mut settings: 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, commander_id).await?; + bot.edit_message_text(message.chat.id, message.id, text) + .reply_markup(keyboard) + .parse_mode(teloxide::types::ParseMode::Html) + .await?; + return Ok(()); + } + + if parts.len() < 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::parse_quality(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, commander_id).await?; + bot.edit_message_text(message.chat.id, message.id, text) + .reply_markup(keyboard) + .parse_mode(teloxide::types::ParseMode::Html) + .await?; + + Ok(()) + } + + fn designed_for(&self, owner_type: &str) -> bool { + owner_type == "user" + } + + async fn is_enabled(&self, owner: &Owner) -> bool { + if !self.designed_for(&owner.r#type) { + return false; + } + let settings: CobaltSettings = Settings::get_module_settings(owner, self.key()).await.unwrap(); // god of unwraps + settings.enabled + } + + fn factory_settings(&self) -> Result { + let factory_settings = CobaltSettings { + enabled: true, + video_quality: VideoQuality::Q1080, + attribution: false, + }; + Ok(serde_json::to_value(factory_settings)?) + } +} \ No newline at end of file diff --git a/src/bot/modules/currency.rs b/src/bot/modules/currency.rs new file mode 100644 index 0000000..55b63bb --- /dev/null +++ b/src/bot/modules/currency.rs @@ -0,0 +1,211 @@ +use crate::{ + bot::modules::{Module, ModuleSettings, Owner}, + core::{ + db::schemas::{group::Group, settings::Settings, user::User}, + services::{ + currencier::handle_currency_update, + currency::converter::{get_all_currency_codes, get_default_currencies, CURRENCY_CONFIG_PATH}, + }, + }, + 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)] +pub struct CurrencySettings { + pub enabled: bool, + pub selected_codes: Vec, +} + +impl Default for CurrencySettings { + fn default() -> Self { + let default_currencies = get_default_currencies() + .map(|currencies| currencies.into_iter().map(|c| c.code).collect::>()) + .unwrap_or_else(|_| vec!["usd".to_string(), "eur".to_string()]); + + Self { + enabled: true, + selected_codes: default_currencies, + } + } +} + +impl ModuleSettings for CurrencySettings {} + +pub struct CurrencyModule; + +#[async_trait] +impl Module for CurrencyModule { + fn key(&self) -> &'static str { + "currency" + } + + fn name(&self) -> &'static str { + "Конвертер валют" + } + + fn description(&self) -> &'static str { + "Конвертация валют и криптовалют с актуальными курсами" + } + + async fn get_settings_ui( + &self, + owner: &Owner, + commander_id: u64, + ) -> Result<(String, InlineKeyboardMarkup), MyError> { + self.get_paged_settings_ui(owner, 0, commander_id).await + } + + async fn handle_callback( + &self, + bot: Bot, + q: &CallbackQuery, + owner: &Owner, + data: &str, + commander_id: u64, + ) -> Result<(), MyError> { + let Some(message) = &q.message else { return Ok(()); }; + let Some(message) = message.regular_message() else { return Ok(()); }; + + let parts: Vec<_> = data.split(':').collect(); + + if parts.len() == 1 && parts[0] == "toggle_module" { + let mut settings: 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, commander_id).await?; + bot.edit_message_text(message.chat.id, message.id, text) + .reply_markup(keyboard) + .parse_mode(teloxide::types::ParseMode::Html) + .await?; + return Ok(()); + } + + if parts.len() == 2 && parts[0] == "page" { + let page = parts[1].parse::().unwrap_or(0); + let (text, keyboard) = self.get_paged_settings_ui(owner, page, commander_id).await?; + bot.edit_message_text(message.chat.id, message.id, text) + .reply_markup(keyboard) + .parse_mode(teloxide::types::ParseMode::Html) + .await?; + return Ok(()); + } + + if parts.len() == 2 && parts[0] == "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, commander_id).await?; // TODO: сохранить текущую страницу + bot.edit_message_text(message.chat.id, message.id, text) + .reply_markup(keyboard) + .parse_mode(teloxide::types::ParseMode::Html) + .await?; + } else { + bot.answer_callback_query(q.id.clone()).await?; + } + Ok(()) + } + + fn designed_for(&self, _owner_type: &str) -> bool { + true // all + } + + async fn is_enabled(&self, owner: &Owner) -> bool { + if !self.designed_for(&owner.r#type) { + return false; + } + let settings: CurrencySettings = Settings::get_module_settings(owner, self.key()).await.unwrap(); // god of unwraps + settings.enabled + } + + fn factory_settings(&self) -> Result { + let factory_settings = CurrencySettings { + enabled: true, + 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, + commander_id: u64, + ) -> Result<(String, InlineKeyboardMarkup), MyError> { + let settings: CurrencySettings = Settings::get_module_settings(owner, self.key()).await?; + let text = format!( + "⚙️ Настройки модуля: {}\n
{}
\nСтатус: {}\n\nВыберите валюты для отображения.", + self.name(), + self.description(), + if settings.enabled { "✅ Включен" } else { "❌ Выключен" } + ); + + let toggle_button = InlineKeyboardButton::callback( + if settings.enabled { "Выключить модуль" } else { "Включить модуль" }, + format!("{}:settings:toggle_module:{}", self.key(), commander_id), + ); + + let all_currencies = get_all_currency_codes(CURRENCY_CONFIG_PATH.parse().unwrap())?; + + let back_button = InlineKeyboardButton::callback( + "⬅️ Назад", + format!("settings_back:{}:{}:{}", owner.r#type, owner.id, commander_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, + commander_id + ); + 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 + } +} \ No newline at end of file diff --git a/src/bot/modules/mod.rs b/src/bot/modules/mod.rs new file mode 100644 index 0000000..e0c3d1c --- /dev/null +++ b/src/bot/modules/mod.rs @@ -0,0 +1,53 @@ +pub mod cobalt; +pub mod currency; +pub mod registry; +pub mod whisper; + +use crate::errors::MyError; +use async_trait::async_trait; +use serde::{de::DeserializeOwned, Serialize}; +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 name(&self) -> &'static str; + + fn description(&self) -> &'static str; + + async fn get_settings_ui( + &self, + owner: &Owner, + commander_id: u64, + ) -> Result<(String, InlineKeyboardMarkup), MyError>; + + async fn handle_callback( + &self, + bot: Bot, + q: &CallbackQuery, + owner: &Owner, + data: &str, + commander_id: u64, + ) -> Result<(), MyError>; + + // this function returns true if the module is designed for the owner type. like if module is designed for user, it will return true for user and false for group, and doesn't show in group + fn designed_for(&self, owner_type: &str) -> bool; + + async fn is_enabled(&self, owner: &Owner) -> bool; + + fn factory_settings(&self) -> Result; +} \ No newline at end of file diff --git a/src/bot/modules/registry.rs b/src/bot/modules/registry.rs new file mode 100644 index 0000000..123c9e7 --- /dev/null +++ b/src/bot/modules/registry.rs @@ -0,0 +1,36 @@ +use super::{Module, cobalt::CobaltModule}; +use crate::bot::modules::currency::CurrencyModule; +use once_cell::sync::Lazy; +use std::{collections::BTreeMap, sync::Arc}; +use crate::bot::modules::whisper::WhisperModule; + +pub struct ModuleManager { + modules: BTreeMap>, +} + +impl ModuleManager { + fn new() -> Self { + let modules: Vec> = vec![Arc::new(CobaltModule), Arc::new(CurrencyModule), Arc::new(WhisperModule)]; + + 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 fn get_designed_modules(&self, owner_type: &str) -> Vec<&Arc> { + self.modules.values().filter(|module| module.designed_for(owner_type)).collect() + } +} + +pub static MOD_MANAGER: Lazy = Lazy::new(ModuleManager::new); diff --git a/src/bot/modules/whisper.rs b/src/bot/modules/whisper.rs new file mode 100644 index 0000000..19286cb --- /dev/null +++ b/src/bot/modules/whisper.rs @@ -0,0 +1,116 @@ +use crate::{ + bot::modules::{Module, ModuleSettings, Owner}, + core::db::schemas::settings::Settings, + errors::MyError, +}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use teloxide::{ + prelude::*, + types::{InlineKeyboardButton, InlineKeyboardMarkup}, +}; + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Default)] +pub struct WhisperSettings { + pub enabled: bool, +} + + +impl ModuleSettings for WhisperSettings {} + +pub struct WhisperModule; + +#[async_trait] +impl Module for WhisperModule { + fn key(&self) -> &'static str { + "whisper" + } + + fn name(&self) -> &'static str { + "Whisper System" + } + + fn description(&self) -> &'static str { + "Модуль «шептать», позволяющий работать с текстовыми сообщениями в более приватном режиме. Протестировать можно через inlin'ы: \"@fulturatebot *сообщение шепота* @username1 *id*\"" + } + + async fn get_settings_ui( + &self, + owner: &Owner, + commander_id: u64, + ) -> Result<(String, InlineKeyboardMarkup), MyError> { + let settings: WhisperSettings = Settings::get_module_settings(owner, self.key()).await?; + + let text = format!( + "⚙️ Настройки модуля: {}\n
{}
\nСтатус: {}", + self.name(), + self.description(), + if settings.enabled { "✅ Включен" } else { "❌ Выключен" } + ); + + let toggle_button = InlineKeyboardButton::callback( + if settings.enabled { "Выключить модуль" } else { "Включить модуль" }, + format!("{}:settings:toggle_module:{}", self.key(), commander_id), + ); + + let keyboard = InlineKeyboardMarkup::new(vec![ + vec![toggle_button], + vec![InlineKeyboardButton::callback( + "⬅️ Назад", + format!("settings_back:{}:{}:{}", owner.r#type, owner.id, commander_id), + )], + ]); + + Ok((text, keyboard)) + } + + async fn handle_callback( + &self, + bot: Bot, + q: &CallbackQuery, + owner: &Owner, + data: &str, + commander_id: u64, + ) -> Result<(), MyError> { + let Some(message) = &q.message else { return Ok(()); }; + let Some(message) = message.regular_message() else { return Ok(()); }; + + let parts: Vec<_> = data.split(':').collect(); + + if parts.len() == 1 && parts[0] == "toggle_module" { + let mut settings: WhisperSettings = + Settings::get_module_settings(owner, self.key()).await?; + settings.enabled = !settings.enabled; + Settings::update_module_settings(owner, self.key(), settings).await?; + + let (text, keyboard) = self.get_settings_ui(owner, commander_id).await?; + bot.edit_message_text(message.chat.id, message.id, text) + .reply_markup(keyboard) + .parse_mode(teloxide::types::ParseMode::Html) + .await?; + return Ok(()); + } + + bot.answer_callback_query(q.id.clone()).await?; + + Ok(()) + } + + fn designed_for(&self, owner_type: &str) -> bool { + owner_type == "user" + } + + async fn is_enabled(&self, owner: &Owner) -> bool { + if !self.designed_for(&owner.r#type) { + return false; + } + let settings: WhisperSettings = Settings::get_module_settings(owner, self.key()).await.unwrap(); // god of unwraps + settings.enabled + } + + fn factory_settings(&self) -> Result { + let factory_settings = WhisperSettings { enabled: true }; + Ok(serde_json::to_value(factory_settings)?) + } +} \ No newline at end of file diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index 71edceb..0000000 --- a/src/config.rs +++ /dev/null @@ -1,70 +0,0 @@ -use crate::util::currency::converter::{CurrencyConverter, OutputLanguage}; -use crate::util::json::{JsonConfig, read_json_config}; -use dotenv::dotenv; -use std::sync::Arc; -use teloxide::prelude::*; - -#[derive(Clone)] -pub struct Config { - bot: Bot, - #[allow(dead_code)] - owners: Vec, - version: String, - json_config: JsonConfig, - currency_converter: Arc, - mongodb_url: String, -} - -impl Config { - pub async fn new() -> Self { - dotenv().ok(); - - let bot_token = std::env::var("BOT_TOKEN").expect("BOT_TOKEN expected"); - let version = std::env::var("CARGO_PKG_VERSION").expect("CARGO_PKG_VERSION expected"); - let bot = Bot::new(bot_token); - - let owners: Vec = std::env::var("OWNERS") - .expect("OWNERS expected") - .split(',') - .filter_map(|id| id.trim().parse().ok()) - .collect(); - - let json_config = read_json_config("config.json").expect("Unable to read config.json"); - let currency_converter = Arc::new(CurrencyConverter::new(OutputLanguage::Russian).unwrap()); // TODO: get language from config - let mongodb_url = std::env::var("MONGODB_URL").expect("MONGODB_URL expected"); - - Config { - bot, - owners, - version, - json_config, - currency_converter, - mongodb_url, - } - } - - pub fn get_bot(&self) -> &Bot { - &self.bot - } - - pub fn get_version(&self) -> &str { - &self.version - } - - #[allow(dead_code)] - pub fn is_id_in_owners(&self, id: String) -> bool { - self.owners.contains(&id) - } - - pub fn get_json_config(&self) -> &JsonConfig { - &self.json_config - } - - pub fn get_currency_converter(&self) -> &CurrencyConverter { - &self.currency_converter - } - - pub fn get_mongodb_url(&self) -> &str { - &self.mongodb_url - } -} diff --git a/src/util/json.rs b/src/core/config/json.rs similarity index 78% rename from src/util/json.rs rename to src/core/config/json.rs index ff9a486..f27cff0 100644 --- a/src/util/json.rs +++ b/src/core/config/json.rs @@ -1,12 +1,11 @@ use serde::Deserialize; -use std::fs::File; -use std::io::Read; -use std::path::Path; +use std::{fs::File, io::Read, path::Path}; #[derive(Deserialize, Debug, Clone)] pub struct JsonConfig { pub ai_model: String, pub ai_prompt: String, + pub summarize_prompt: String, } impl JsonConfig { @@ -17,6 +16,10 @@ impl JsonConfig { pub fn get_ai_prompt(&self) -> &str { &self.ai_prompt } + + pub fn get_summarize_prompt(&self) -> &str { + &self.summarize_prompt + } } pub fn read_json_config>(path: P) -> Result> { diff --git a/src/core/config/mod.rs b/src/core/config/mod.rs new file mode 100644 index 0000000..1f923e6 --- /dev/null +++ b/src/core/config/mod.rs @@ -0,0 +1,166 @@ +mod json; + +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::*; + +#[derive(Clone)] +pub struct Config { + bot: Bot, + cobalt_client: ccobalt::Client, + #[allow(dead_code)] + owners: Vec, + log_chat_id: String, + error_chat_thread_id: String, + #[allow(dead_code)] + warn_chat_thread_id: String, + version: String, + json_config: JsonConfig, + currency_converter: Arc, + mongodb_url: String, + redis_client: RedisCache, +} + +impl Config { + pub async fn new() -> Self { + dotenv().ok(); + + let Ok(bot_token) = std::env::var("BOT_TOKEN") else { + error!("Expected BOT_TOKEN env var"); + std::process::exit(1); + }; + let Ok(cobalt_api_key) = std::env::var("COBALT_API_KEY") else { + error!("COBALT_API_KEY expected"); + std::process::exit(1); + }; + let Ok(version) = std::env::var("CARGO_PKG_VERSION") else { + error!("CARGO_PKG_VERSION expected"); + std::process::exit(1); + }; + let bot = Bot::new(bot_token); + + let cobalt_client = ccobalt::Client::builder() + .base_url("https://cobalt-backend.canine.tools/") + .api_key(cobalt_api_key) + .user_agent( + "Fulturate/6.6.6 (rust) (+https://github.com/weever1337/fulturate-rs)".to_string(), + ) + .build() + .unwrap_or_else(|_err| { + error!("Failed to build cobalt client"); + std::process::exit(1); + }); + + let owners: Vec = std::env::var("OWNERS") + .unwrap_or_else(|_| { + error!("OWNERS expected"); + std::process::exit(1) + }) + .split(',') + .filter_map(|id| id.trim().parse().ok()) + .collect(); + + let Ok(log_chat_id) = std::env::var("LOG_CHAT_ID") else { + error!("LOG_CHAT_ID expected"); + std::process::exit(1); + }; + let error_chat_thread_id: String = std::env::var("ERROR_CHAT_THREAD_ID") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(0.to_string()); + + let warn_chat_thread_id: String = std::env::var("WARN_CHAT_THREAD_ID") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(0.to_string()); + + let 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); + }; + let currency_converter = Arc::new(CurrencyConverter::new(OutputLanguage::Russian).unwrap()); // TODO: get language from config + let Ok(mongodb_url) = std::env::var("MONGODB_URL") else { + error!("MONGODB_URL expected"); + std::process::exit(1); + }; + + let Ok(redis_url) = std::env::var("REDIS_URL") else { + error!("REDIS_URL expected"); + std::process::exit(1); + }; + + let Ok(redis_client) = RedisClient::open(redis_url.to_owned()) else { + error!("Failed to open Redis client"); + std::process::exit(1); + }; + let redis_client = RedisCache::new(redis_client); + + Config { + bot, + cobalt_client, + owners, + log_chat_id, + error_chat_thread_id, + warn_chat_thread_id, + version, + json_config, + currency_converter, + mongodb_url, + redis_client, + } + } + + pub fn get_bot(&self) -> &Bot { + &self.bot + } + + pub fn get_cobalt_client(&self) -> &ccobalt::Client { + &self.cobalt_client + } + + pub fn get_version(&self) -> &str { + &self.version + } + + #[allow(dead_code)] + pub fn is_id_in_owners(&self, id: String) -> bool { + self.owners.contains(&id) + } + + pub fn get_log_chat_id(&self) -> &str { + &self.log_chat_id + } + + pub fn get_error_chat_thread_id(&self) -> &str { + &self.error_chat_thread_id + } + + #[allow(dead_code)] + pub fn get_warn_chat_thread_id(&self) -> &str { + &self.warn_chat_thread_id + } + + pub fn get_json_config(&self) -> &JsonConfig { + &self.json_config + } + + pub fn get_currency_converter(&self) -> &CurrencyConverter { + &self.currency_converter + } + + pub fn get_mongodb_url(&self) -> &str { + &self.mongodb_url + } + + pub fn get_redis_client(&self) -> &RedisCache { + &self.redis_client + } +} diff --git a/src/db/functions.rs b/src/core/db/functions.rs similarity index 85% rename from src/db/functions.rs rename to src/core/db/functions.rs index 7935fa2..777d286 100644 --- a/src/db/functions.rs +++ b/src/core/db/functions.rs @@ -1,4 +1,4 @@ -use crate::db::schemas::BaseFunctions; +use crate::core::db::schemas::BaseFunctions; use oximod::_error::oximod_error::OxiModError; pub async fn get_or_create(id: String) -> Result { diff --git a/src/core/db/mod.rs b/src/core/db/mod.rs new file mode 100644 index 0000000..686259f --- /dev/null +++ b/src/core/db/mod.rs @@ -0,0 +1,3 @@ +pub mod functions; +pub mod redis; +pub mod schemas; diff --git a/src/core/db/redis.rs b/src/core/db/redis.rs new file mode 100644 index 0000000..c66ef33 --- /dev/null +++ b/src/core/db/redis.rs @@ -0,0 +1,53 @@ +use redis::{AsyncCommands, Client, RedisError}; +use serde::{Serialize, de::DeserializeOwned}; + +#[derive(Clone)] +pub struct RedisCache { + client: Client, +} + +impl RedisCache { + pub fn new(client: Client) -> Self { + Self { client } + } + + pub async fn get(&self, key: &str) -> Result, RedisError> { + let mut con = self.client.get_multiplexed_tokio_connection().await?; + let result: Option = con.get(key).await?; + Ok(result.and_then(|s| serde_json::from_str(&s).ok())) + } + + pub async fn set( + &self, + key: &str, + value: &T, + ttl_seconds: usize, + ) -> Result<(), RedisError> { + let mut con = self.client.get_multiplexed_tokio_connection().await?; + let json_value = serde_json::to_string(value).unwrap(); + let _: () = con.set_ex(key, json_value, ttl_seconds as u64).await?; + Ok(()) + } + + pub async fn delete(&self, key: &str) -> Result<(), RedisError> { + let mut con = self.client.get_multiplexed_tokio_connection().await?; + let _: i64 = con.del(key).await?; + Ok(()) + } + + #[allow(dead_code)] + pub async fn get_and_delete( + &self, + key: &str, + ) -> Result, RedisError> { + let mut con = self.client.get_multiplexed_tokio_connection().await?; + + let (result, _): (Option, i64) = redis::pipe() + .get(key) + .del(key) + .query_async(&mut con) + .await?; + + Ok(result.and_then(|s| serde_json::from_str(&s).ok())) + } +} diff --git a/src/db/schemas.rs b/src/core/db/schemas.rs similarity index 89% rename from src/db/schemas.rs rename to src/core/db/schemas.rs index 0e18331..1cfa8da 100644 --- a/src/db/schemas.rs +++ b/src/core/db/schemas.rs @@ -1,7 +1,8 @@ pub mod group; +pub mod settings; pub mod user; -use crate::util::currency::converter::CurrencyStruct; +use crate::core::services::currency::converter::CurrencyStruct; use async_trait::async_trait; use mongodb::results::UpdateResult; use oximod::_error::oximod_error::OxiModError; diff --git a/src/db/schemas/group.rs b/src/core/db/schemas/group.rs similarity index 77% rename from src/db/schemas/group.rs rename to src/core/db/schemas/group.rs index a176318..f25d247 100644 --- a/src/db/schemas/group.rs +++ b/src/core/db/schemas/group.rs @@ -1,10 +1,14 @@ -use crate::db::schemas::{BaseFunctions, CurrenciesFunctions, 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)] @@ -33,7 +37,7 @@ impl BaseFunctions for Group { .group_id(id.clone()) .convertable_currencies(vec![]); new_group.save().await?; - + ::find_by_id(id) .await? .ok_or_else(|| { @@ -72,3 +76,14 @@ impl CurrenciesFunctions for Group { .await } } + +impl Group { + pub async fn get_or_create(id: &str) -> Result { + if Self::find_one(doc! { "group_id": id }).await?.is_some() { + Ok(false) + } else { + Self::new().group_id(id.to_string()).save().await?; + Ok(true) + } + } +} diff --git a/src/core/db/schemas/settings.rs b/src/core/db/schemas/settings.rs new file mode 100644 index 0000000..677df15 --- /dev/null +++ b/src/core/db/schemas/settings.rs @@ -0,0 +1,118 @@ +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")] +#[collection("settings")] +pub struct Settings { + #[serde(skip_serializing_if = "Option::is_none")] + _id: Option, + + #[index(unique, name = "owner")] + pub owner_id: String, + pub owner_type: String, + + #[serde(default)] + pub modules: BTreeMap, +} + +impl Settings { + pub async fn create_with_defaults(owner: &Owner) -> Result { + let mut modules_map = BTreeMap::::new(); + + for module in MOD_MANAGER.get_all_modules() { + if module.designed_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) + } + + pub async fn get_module_settings( + owner: &Owner, + module_key: &str, + ) -> Result { + let settings_doc = Self::get_or_create(owner).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(module_settings) + } + + 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(()) + } + + 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 = Self::create_with_defaults(owner).await?; + Ok(new_doc) + // let new_doc = Settings::new() + // .owner_id(owner.id.clone()) + // .owner_type(owner.r#type.clone()) + // .modules(BTreeMap::new()); + // + // new_doc.save().await?; + // Ok(new_doc) + } + } +} diff --git a/src/db/schemas/user.rs b/src/core/db/schemas/user.rs similarity index 65% rename from src/db/schemas/user.rs rename to src/core/db/schemas/user.rs index 45834ee..f450bf8 100644 --- a/src/db/schemas/user.rs +++ b/src/core/db/schemas/user.rs @@ -1,9 +1,14 @@ -use crate::db::schemas::{BaseFunctions, CurrenciesFunctions, 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::{Model, _error::oximod_error::OxiModError}; +use mongodb::{ + bson, + bson::{doc, oid::ObjectId}, + results::UpdateResult, +}; +use oximod::{_error::oximod_error::OxiModError, Model}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize, Model)] @@ -17,6 +22,9 @@ pub struct User { #[index(sparse, name = "convertable_currencies")] #[serde(default)] pub convertable_currencies: Vec, + + #[serde(default)] + pub download_count: i64, } #[async_trait] @@ -53,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 } }, @@ -60,10 +69,7 @@ impl CurrenciesFunctions for User { .await } - async fn remove_currency( - user_id: &str, - currency: &str, - ) -> Result { + async fn remove_currency(user_id: &str, currency: &str) -> Result { Self::update_one( doc! {"user_id": user_id}, doc! {"$pull": {"convertable_currencies": {"code": currency} } }, @@ -71,3 +77,22 @@ impl CurrenciesFunctions for User { .await } } + +impl User { + pub async fn get_or_create(id: &str) -> Result { + if Self::find_one(doc! { "user_id": id }).await?.is_some() { + Ok(false) + } else { + Self::new().user_id(id.to_string()).save().await?; + Ok(true) + } + } + + pub async fn increment_download_count(user_id: &str) -> Result { + Self::update_one( + doc! { "user_id": user_id }, + doc! { "$inc": { "download_count": 1 } }, + ) + .await + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs new file mode 100644 index 0000000..11506cc --- /dev/null +++ b/src/core/mod.rs @@ -0,0 +1,3 @@ +pub mod config; +pub mod db; +pub mod services; diff --git a/src/core/services/cobalt.rs b/src/core/services/cobalt.rs new file mode 100644 index 0000000..d5b2d1f --- /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 parse_quality(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/handlers/commands/settings.rs b/src/core/services/currencier.rs similarity index 51% rename from src/handlers/commands/settings.rs rename to src/core/services/currencier.rs index a0a0b13..0b30f25 100644 --- a/src/handlers/commands/settings.rs +++ b/src/core/services/currencier.rs @@ -1,18 +1,20 @@ -use crate::db::schemas::{BaseFunctions, CurrenciesFunctions}; use crate::{ - db::functions::get_or_create, - db::schemas::group::Group, - db::schemas::user::User, - util::{ - currency::converter::{CURRENCY_CONFIG_PATH, get_all_currency_codes}, - errors::MyError, + 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, @@ -74,15 +76,7 @@ pub async fn handle_currency_update Result<(), MyError> { - if msg.chat.is_private() { - handle_currency_update::(bot, msg, code).await - } else { - handle_currency_update::(bot, msg, code).await - } -} - -async fn get_enabled_codes(msg: &Message) -> HashSet { +pub async fn get_enabled_codes(msg: &Message) -> HashSet { let chat_id = msg.chat.id.to_string(); if msg.chat.is_private() { if let Ok(Some(user)) = ::find_by_id(chat_id).await { @@ -102,53 +96,3 @@ async fn get_enabled_codes(msg: &Message) -> HashSet { HashSet::new() } - -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"); - - // safety: assume that bot commands always exists - 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 { - // default fallback - 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(()) -} diff --git a/src/util/currency/converter.rs b/src/core/services/currency/converter.rs similarity index 94% rename from src/util/currency/converter.rs rename to src/core/services/currency/converter.rs index 9e5a4fe..3e00dca 100644 --- a/src/util/currency/converter.rs +++ b/src/core/services/currency/converter.rs @@ -1,25 +1,22 @@ -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::db::schemas::CurrenciesFunctions; -use crate::db::schemas::group::Group; -use crate::db::schemas::user::User; 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; -use crate::util::errors::MyError; const CACHE_DURATION_SECS: u64 = 60 * 10; pub const CURRENCY_CONFIG_PATH: &str = "currencies.json"; @@ -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![]; @@ -167,11 +160,10 @@ pub fn get_default_currencies() -> Result, MyError> { let all_codes = get_all_currency_codes(CURRENCY_CONFIG_PATH.parse().unwrap())?; let necessary_codes = all_codes - .iter() + .into_iter() .filter(|c| { ["uah", "rub", "usd", "byn", "eur", "ton"].contains(&c.code.to_lowercase().as_str()) }) - .cloned() .collect::>(); Ok(necessary_codes) @@ -268,7 +260,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 +268,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 +289,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 +687,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/util/currency/mod.rs b/src/core/services/currency/mod.rs similarity index 59% rename from src/util/currency/mod.rs rename to src/core/services/currency/mod.rs index d53112d..9c6a24e 100644 --- a/src/util/currency/mod.rs +++ b/src/core/services/currency/mod.rs @@ -1,2 +1 @@ pub mod converter; -mod structs; diff --git a/src/core/services/mod.rs b/src/core/services/mod.rs new file mode 100644 index 0000000..119a26a --- /dev/null +++ b/src/core/services/mod.rs @@ -0,0 +1,5 @@ +pub mod cobalt; +pub mod currencier; +pub mod currency; +pub mod speech_recognition; +pub mod translation; diff --git a/src/core/services/speech_recognition.rs b/src/core/services/speech_recognition.rs new file mode 100644 index 0000000..b296162 --- /dev/null +++ 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/translation.rs b/src/core/services/translation.rs new file mode 100644 index 0000000..113f6a5 --- /dev/null +++ b/src/core/services/translation.rs @@ -0,0 +1,55 @@ +pub const SUPPORTED_LANGUAGES: &[(&str, &str)] = &[ + ("uk", "🇺🇦 Українська"), + ("en", "🇬🇧 English"), + ("us", "🇺🇸 English (US)"), + ("ru", "🇷🇺 Русский"), + ("de", "🇩🇪 Deutsch"), + ("fr", "🇫🇷 Français"), + ("es", "🇪🇸 Español"), + ("it", "🇮🇹 Italiano"), + ("zh", "🇨🇳 中文"), + ("ja", "🇯🇵 日本語"), + ("ko", "🇰🇷 한국어"), + ("pl", "🇵🇱 Polski"), + ("ar", "🇸🇦 العربية"), + ("pt", "🇵🇹 Português"), + ("tr", "🇹🇷 Türkçe"), + ("nl", "🇳🇱 Nederlands"), + ("sv", "🇸🇪 Svenska"), + ("no", "🇳🇴 Norsk"), + ("da", "🇩🇰 Dansk"), + ("fi", "🇫🇮 Suomi"), + ("el", "🇬🇷 Ελληνικά"), + ("he", "🇮🇱 עברית"), + ("hi", "🇮🇳 हिन्दी"), + ("id", "🇮🇩 Indonesia"), + ("vi", "🇻🇳 Tiếng Việt"), + ("th", "🇹🇭 ภาษาไทย"), + ("cs", "🇨🇿 Čeština"), + ("hu", "🇭🇺 Magyar"), + ("ro", "🇷🇴 Română"), + ("bg", "🇧🇬 Български"), + ("sr", "🇷🇸 Српски"), + ("hr", "🇭🇷 Hrvatski"), + ("sk", "🇸🇰 Slovenčina"), + ("sl", "🇸🇮 Slovenščina"), + ("lt", "🇱🇹 Lietuvių"), + ("lv", "🇱🇻 Latviešu"), + ("et", "🇪🇪 Eesti"), +]; +pub const LANGUAGES_PER_PAGE: usize = 6; + +pub fn normalize_language_code(lang: &str) -> String { + match lang.to_lowercase().as_str() { + "ua" | "ukrainian" | "украинский" | "uk" => "uk".to_string(), + "ru" | "russian" | "русский" => "ru".to_string(), + "en" | "english" | "английский" => "en".to_string(), + "de" | "german" | "немецкий" => "de".to_string(), + "fr" | "french" | "французский" => "fr".to_string(), + "es" | "spanish" | "испанский" => "es".to_string(), + "it" | "italian" | "итальянский" => "it".to_string(), + "zh" | "chinese" | "китайский" => "zh".to_string(), + "ja" | "japanese" | "японский" => "ja".to_string(), + _ => lang.to_lowercase(), + } +} diff --git a/src/db/mod.rs b/src/db/mod.rs deleted file mode 100644 index 570a2ed..0000000 --- a/src/db/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod schemas; -pub mod functions; \ No newline at end of file diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..61d6e16 --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,79 @@ +use crate::core::services::currency::converter::ConvertError; +use ccobalt::model::error::CobaltError; +use mongodb::bson; +use oximod::_error::oximod_error::OxiModError; +use redis; +use std::string::FromUtf8Error; +use teloxide::RequestError; +use thiserror::Error; +use translators::Error as TranslatorError; +use url::ParseError; + +#[derive(Error, Debug)] +pub enum MyError { + #[error("Teloxide API Error: {0}")] + Teloxide(#[from] RequestError), + + #[error("Redis Error: {0}")] + Redis(#[from] redis::RedisError), + + #[error("Reqwest Error: {0}")] + Reqwest(#[from] reqwest::Error), + + #[error("Gemini AI Error: {0}")] + Gemini(#[from] gem_rs::errors::GemError), + + #[error("Currency Conversion Error: {0}")] + CurrencyConversion(#[from] ConvertError), + + #[error("MongoDB Error: {0}")] + MongoDb(#[from] OxiModError), + + #[error("Failed to parse URL: {0}")] + UrlParse(#[from] ParseError), + + #[error("Application Error: {0}")] + Other(String), + + #[error("Bson serialization error: {0}")] + Bson(#[from] bson::ser::Error), + + #[error("Module '{0}' not found")] + ModuleNotFound(String), + + #[error("Uuid error: {0}")] + Uuid(#[from] uuid::Error), + + #[error("Cobalt error: {0}")] + CobaltError(#[from] CobaltError), + + #[error("BSON OID error: {0}")] + BsonOid(#[from] bson::oid::Error), + + #[error("Base64 decoding error: {0}")] + Base64(#[from] base64::DecodeError), + + #[error("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), + + #[error("User not found")] + UserNotFound, +} + +impl From<&str> for MyError { + fn from(s: &str) -> Self { + MyError::Other(s.to_string()) + } +} + +impl From for MyError { + fn from(s: String) -> Self { + MyError::Other(s) + } +} diff --git a/src/handlers/commander.rs b/src/handlers/commander.rs deleted file mode 100644 index f1d4006..0000000 --- a/src/handlers/commander.rs +++ /dev/null @@ -1,27 +0,0 @@ -use crate::config::Config; -use crate::handlers::commands::{ - settings::{currency_codes_handler, currency_codes_list_handler}, - speech_recognition::speech_recognition_handler, - start::start_handler, -}; -use crate::util::{enums::Command, errors::MyError}; -use teloxide::Bot; -use teloxide::prelude::Message; -use tokio::task; - -pub(crate) async fn command_handlers( - bot: Bot, - message: Message, - cmd: Command, -) -> Result<(), MyError> { - let config = Config::new().await; - task::spawn(async move { - match cmd { - Command::Start(arg) => start_handler(bot, message, &config, arg).await, - Command::SpeechRecognition => speech_recognition_handler(bot, message, &config).await, - Command::SetCurrency { code } => currency_codes_handler(bot, message, code).await, - Command::ListCurrency => currency_codes_list_handler(bot, message).await, - } - }); - Ok(()) -} diff --git a/src/handlers/commands/speech_recognition.rs b/src/handlers/commands/speech_recognition.rs deleted file mode 100644 index abc7a3a..0000000 --- a/src/handlers/commands/speech_recognition.rs +++ /dev/null @@ -1,14 +0,0 @@ -use crate::config::Config; -use crate::util::{errors::MyError, transcription::transcription_handler}; -use teloxide::prelude::*; - -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?; - } - Ok(()) -} diff --git a/src/handlers/commands/start.rs b/src/handlers/commands/start.rs deleted file mode 100644 index 68b8b9a..0000000 --- a/src/handlers/commands/start.rs +++ /dev/null @@ -1,96 +0,0 @@ -use crate::config::Config; -use crate::db::schemas::user::User; -use crate::util::currency::converter::get_default_currencies; -use crate::util::errors::MyError; -use log::{error, info}; -use mongodb::bson::doc; -use oximod::Model; -use std::time::Instant; -use sysinfo::System; -use teloxide::prelude::*; -use teloxide::types::{ParseMode, ReplyParameters}; - -pub async fn start_handler( - bot: Bot, - message: Message, - config: &Config, - arg: String, -) -> Result<(), MyError> { - 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.", - ) - .await?; - return Ok(()); - } - }; - } - let version = config.get_version(); - - let start_time = Instant::now(); - bot.get_me().await?; - let api_ping = start_time.elapsed().as_millis(); - - let mut system_info = System::new_all(); - system_info.refresh_all(); - - let total_ram_bytes = system_info.total_memory(); - let used_ram_bytes = system_info.used_memory(); - - let total_ram_mb = total_ram_bytes / (1024 * 1024); - let used_ram_mb = used_ram_bytes / (1024 * 1024); - - let cpu_usage_percent = system_info.global_cpu_usage(); - - let response_message = format!( - "[BETA] Telegram Bot by @Weever && @nixxoq\n\ -
\
-        > Version: {}\n\
-        > API Ping: {} ms\n\
-        > CPU Usage: {:.2}%\n\
-        > RAM Usage: {}/{} MB\n\
-        
", - version, api_ping, cpu_usage_percent, used_ram_mb, total_ram_mb - ); - - bot.send_message(message.chat.id, response_message) - .reply_parameters(ReplyParameters::new(message.id)) - .parse_mode(ParseMode::Html) - .await?; - Ok(()) -} diff --git a/src/handlers/markups/inlines/currency.rs b/src/handlers/markups/inlines/currency.rs deleted file mode 100644 index fa5bbc1..0000000 --- a/src/handlers/markups/inlines/currency.rs +++ /dev/null @@ -1,126 +0,0 @@ -use crate::config::Config; -use crate::db::schemas::user::User; -use log::{debug, error}; -use mongodb::bson::doc; -use oximod::Model; -use std::error as error_handler; -use std::sync::Arc; -use teloxide::Bot; -use teloxide::payloads::AnswerInlineQuerySetters; -use teloxide::prelude::Requester; -use teloxide::types::{ - Chat, ChatId, ChatKind, ChatPrivate, InlineKeyboardButton, InlineKeyboardMarkup, InlineQuery, - InlineQueryResult, InlineQueryResultArticle, InputMessageContent, InputMessageContentText, Me, - ParseMode, -}; -use uuid::Uuid; - -pub async fn handle_currency_inline( - bot: Bot, - q: InlineQuery, - config: Arc, - me: Me, -) -> Result<(), Box> { - let user_id_str = q.from.id.to_string(); - - let user_exists = User::find_one(doc! { "user_id": &user_id_str }) - .await? - .is_some(); - - if !user_exists { - debug!("User {} not found. Offering to register.", user_id_str); - - let start_url = format!("https://t.me/{}?start=inl", me.username()); - - let keyboard = InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::url( - "▶️ Register".to_string(), - start_url.parse()?, - )]]); - - let article = InlineQueryResultArticle::new( - "register_prompt", - "You are not registered", - InputMessageContent::Text(InputMessageContentText::new( - "To use the bot, please start a conversation with it first.", - )), - ) - .description("Click here to start a chat with the bot and unlock all features.") - .reply_markup(keyboard); - - if let Err(e) = bot - .answer_inline_query(q.id, vec![InlineQueryResult::Article(article)]) - .cache_time(10) - .await - { - error!("Failed to send 'register' inline prompt: {:?}", e); - } - - return Ok(()); - } - - debug!("Handling currency inline query: {}", &q.query); - - let converter = config.get_currency_converter(); - 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(), - }), - }; - - match converter.process_text(text_to_process, &pseudo_chat).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); - } - - let raw_results = results.join("\n"); - - let formatted = results - .into_iter() - .map(|result_block| { - let escaped_block = teloxide::utils::html::escape(&result_block); - format!("
{}
", escaped_block) - }) - .collect::>(); - let final_message = formatted.join("\n"); - - let article = InlineQueryResultArticle::new( - Uuid::new_v4().to_string(), - "Currency Conversion", - InputMessageContent::Text( - InputMessageContentText::new(final_message.clone()).parse_mode(ParseMode::Html), - ), - ) - .description(raw_results); - - let result = InlineQueryResult::Article(article); - - if let Err(e) = bot - .answer_inline_query(q.id, vec![result]) - .cache_time(2) - .await - { - error!("Failed to answer currency inline query: {:?}", e); - } - } - Err(e) => { - error!( - "Currency conversion processing error in inline mode: {:?}", - e - ); - } - } - - Ok(()) -} diff --git a/src/handlers/markups/inlines/delete_msg_inline.rs b/src/handlers/markups/inlines/delete_msg_inline.rs deleted file mode 100644 index 9a9158c..0000000 --- a/src/handlers/markups/inlines/delete_msg_inline.rs +++ /dev/null @@ -1,74 +0,0 @@ -use log::error; -use crate::util::errors::MyError; -use crate::util::inline::DELETE_CALLBACK_DATA; -use teloxide::Bot; -use teloxide::payloads::AnswerCallbackQuerySetters; -use teloxide::prelude::CallbackQuery; -use teloxide::requests::Requester; - -pub async fn delete_msg_handler(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { - let data = match q.data { - Some(ref data) => data, - None => return Ok(()), - }; - - let parts: Vec<&str> = data.split(':').collect(); - - if parts.len() != 2 || parts[0] != DELETE_CALLBACK_DATA { - bot.answer_callback_query(q.id) - .text("❌ Неверный формат данных для удаления.") - .show_alert(true) - .await?; - return Ok(()); - } - - let original_user_id: u64 = match parts[1].parse() { - Ok(id) => id, - Err(_) => { - bot.answer_callback_query(q.id) - .text("❌ Произошла ошибка (неверный ID пользователя).") - .show_alert(true) - .await?; - return Ok(()); - } - }; - - let message = match q.message { - Some(msg) => msg, - None => { - bot.answer_callback_query(q.id) - .text("❌ Произошла неопознанная ошибка (сообщение не найдено).") - .show_alert(true) - .await?; - return Ok(()); - } - }; - - let chat_id = message.chat().id; - let message_id = message.id(); - - let member = bot.get_chat_member(chat_id, q.from.id).await; - - if let Ok(member) = member - && (member.user.id.0 == original_user_id || member.is_privileged()) - { - return match bot.delete_message(chat_id, message_id).await { - Ok(_) => Ok(()), - Err(e) => { - error!("Failed to delete message: {:?}", e); - bot.answer_callback_query(q.id) - .text("❌ Не удалось удалить сообщение (возможно, у меня нет прав или сообщение слишком старое).") - .show_alert(true) - .await?; - Ok(()) - } - }; - } - - bot.answer_callback_query(q.id) - .text("❌ У вас нет прав для удаления этого сообщения.") - .show_alert(true) - .await?; - - Ok(()) -} diff --git a/src/handlers/markups/inlines/mod.rs b/src/handlers/markups/inlines/mod.rs deleted file mode 100644 index 99199a4..0000000 --- a/src/handlers/markups/inlines/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod currency; -pub mod delete_msg_inline; diff --git a/src/handlers/markups/markuper.rs b/src/handlers/markups/markuper.rs deleted file mode 100644 index 8e35333..0000000 --- a/src/handlers/markups/markuper.rs +++ /dev/null @@ -1,24 +0,0 @@ -use crate::config::Config; -use crate::handlers::markups::inlines::delete_msg_inline::delete_msg_handler; -use crate::util::errors::MyError; -use teloxide::Bot; -use teloxide::requests::Requester; -use teloxide::types::CallbackQuery; - -pub(crate) async fn callback_query_handlers(bot: Bot, q: CallbackQuery) -> Result<(), MyError> { - let _config = Config::new().await; - - tokio::spawn(async move { - let qq = q.clone(); - let data = qq.data.clone().unwrap(); - - if data.starts_with("delete_msg") { - delete_msg_handler(bot, qq).await - } else { - bot.answer_callback_query(qq.id).await.unwrap(); - Ok(()) - } - }); - - Ok(()) -} diff --git a/src/handlers/markups/mod.rs b/src/handlers/markups/mod.rs deleted file mode 100644 index 3c92dfb..0000000 --- a/src/handlers/markups/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod inlines; -pub mod markuper; diff --git a/src/handlers/messages/chat.rs b/src/handlers/messages/chat.rs deleted file mode 100644 index dc71c6c..0000000 --- a/src/handlers/messages/chat.rs +++ /dev/null @@ -1,54 +0,0 @@ -use crate::db::schemas::group::Group; -use crate::db::schemas::user::User; -use crate::util::currency::converter::get_default_currencies; -use crate::util::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, InlineKeyboardButton, InlineKeyboardMarkup, ParseMode}; -use crate::util::db::create_default_values; - -pub async fn handle_bot_experience(bot: Bot, update: ChatMemberUpdated) -> Result<(), MyError> { - let id = update.chat.id.to_string(); - - 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"); - - let delete_result = if update.chat.is_private() { - User::delete(doc! { "user_id": id.clone() }).await - } else { - Group::delete(doc! { "group_id": id.clone() }).await - }; - - if let Err(e) = delete_result { - error!("Could not delete entity. ID: {} | Error: {}", &id, e); - } - - return Ok(()); - } - - info!("New chat added. ID: {}", id); - - let new_query = create_default_values(id.clone(), update.chat.is_private()).await; - - match new_query { - Ok(_) => { - // todo: finish welcome message - 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()); - - 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]])) - .await?; - } - Err(e) => { - error!("Could not save new entity. ID: {} | Error: {}", &id, e); - } - } - - Ok(()) -} diff --git a/src/handlers/messages/mod.rs b/src/handlers/messages/mod.rs deleted file mode 100644 index 20aad1c..0000000 --- a/src/handlers/messages/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod messager; -pub mod sound; -pub(crate) mod sounder; -pub mod chat; diff --git a/src/handlers/messages/sound/voice.rs b/src/handlers/messages/sound/voice.rs deleted file mode 100644 index a198dee..0000000 --- a/src/handlers/messages/sound/voice.rs +++ /dev/null @@ -1,8 +0,0 @@ -use crate::config::Config; -use crate::util::errors::MyError; -use crate::util::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 -} diff --git a/src/handlers/messages/sound/voice_note.rs b/src/handlers/messages/sound/voice_note.rs deleted file mode 100644 index 7b91383..0000000 --- a/src/handlers/messages/sound/voice_note.rs +++ /dev/null @@ -1,8 +0,0 @@ -use crate::config::Config; -use crate::util::errors::MyError; -use crate::util::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 -} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs deleted file mode 100644 index f8cf0b2..0000000 --- a/src/handlers/mod.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub(crate) mod commander; -pub mod commands; -pub mod markups; -pub mod messages; diff --git a/src/lib.rs b/src/lib.rs index 10bf9dc..811da79 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,4 @@ -pub(crate) mod db; -pub(crate) mod config; -pub(crate) mod handlers; -pub mod loader; -pub mod util; \ No newline at end of file +pub mod bot; +pub mod core; +pub mod errors; +pub mod util; diff --git a/src/loader.rs b/src/loader.rs deleted file mode 100644 index 1ad43e9..0000000 --- a/src/loader.rs +++ /dev/null @@ -1,85 +0,0 @@ -use crate::handlers::markups::inlines::currency::handle_currency_inline; -use crate::handlers::messages::messager::{handle_currency, handle_speech}; -use crate::util::currency::converter::is_currency_query; -use crate::{ - config::Config, - handlers::{ - commander::command_handlers, markups::markuper::callback_query_handlers, - messages::chat::handle_bot_experience, - }, - util::{enums::Command, errors::MyError}, -}; -use log::info; -use oximod::set_global_client; -use std::sync::Arc; -use teloxide::dispatching::MessageFilterExt; -use teloxide::prelude::{Handler, LoggingErrorHandler, Message}; -use teloxide::update_listeners::Polling; -use teloxide::{ - dispatching::{Dispatcher, HandlerExt, UpdateFilterExt}, - dptree, - prelude::Requester, - types::Update, - utils::command::BotCommands, -}; - -pub fn inline_query_handler() -> Handler< - 'static, - Result<(), Box>, - teloxide::dispatching::DpHandlerDescription, -> { - dptree::entry().branch(dptree::filter_async(is_currency_query).endpoint(handle_currency_inline)) -} - -async fn run_bot(config: Arc) -> Result<(), MyError> { - let command_menu = Command::bot_commands(); - config - .get_bot() - .set_my_commands(command_menu.clone()) - .await?; - - let handlers = dptree::entry() - .branch( - Update::filter_message() - .filter_command::() - .endpoint(command_handlers), - ) - .branch( - Update::filter_message() - .branch(Message::filter_text().endpoint(handle_currency)) - .branch(Message::filter_voice().endpoint(handle_speech)), - ) - .branch(Update::filter_callback_query().endpoint(callback_query_handlers)) - .branch(Update::filter_my_chat_member().endpoint(handle_bot_experience)) - .branch(Update::filter_inline_query().branch(inline_query_handler())); - - let bot = config.get_bot().clone(); - let bot_name = config.get_bot().get_my_name().await?; - - info!("Bot name: {:?}", bot_name.name); - let listener = Polling::builder(bot.clone()).drop_pending_updates().build(); - - Dispatcher::builder(bot.clone(), handlers) - .dependencies(dptree::deps![config.clone()]) - .enable_ctrlc_handler() - .build() - .dispatch_with_listener(listener, LoggingErrorHandler::new()) - .await; - Ok(()) -} - -async fn run_database(config: Arc) -> Result<(), MyError> { - let url = config.get_mongodb_url().to_owned(); - set_global_client(url.clone()).await?; - - info!("Database connected successfully. URL: {}", url); - - Ok(()) -} - -pub async fn run() -> Result<(), MyError> { - let config = Arc::new(Config::new().await); - - let _th = tokio::join!(run_database(config.clone()), run_bot(config.clone())); - Ok(()) -} diff --git a/src/main.rs b/src/main.rs index 4b20379..4c8b208 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,10 +1,4 @@ -mod config; -pub(crate) mod db; -mod handlers; -mod loader; -mod util; - -use crate::loader::run; +use fulturate::bot::dispatcher::run; use log::{error, info}; #[tokio::main] diff --git a/src/util/currency/structs.rs b/src/util/currency_values.rs similarity index 100% rename from src/util/currency/structs.rs rename to src/util/currency_values.rs diff --git a/src/util/enums.rs b/src/util/enums.rs index 42053a1..eb0c14f 100644 --- a/src/util/enums.rs +++ b/src/util/enums.rs @@ -7,13 +7,14 @@ pub enum Command { Start(String), #[command(description = "Speech recognition", alias = "sr")] SpeechRecognition, - #[command(parse_with = "split", description = "Set currency to convert")] - SetCurrency { code: String }, - #[command(description = "List of available currencies to convert")] - ListCurrency, + #[command(description = "Translate", alias = "tr")] + Translate(String), + #[command(description = "Bot settings")] + Settings, } pub struct AudioStruct { pub mime_type: String, pub file_id: String, + pub file_unique_id: String, } diff --git a/src/util/errors.rs b/src/util/errors.rs deleted file mode 100644 index c82b3cd..0000000 --- a/src/util/errors.rs +++ /dev/null @@ -1 +0,0 @@ -pub type MyError = Box; diff --git a/src/util/inline.rs b/src/util/inline.rs deleted file mode 100644 index fb42927..0000000 --- a/src/util/inline.rs +++ /dev/null @@ -1,10 +0,0 @@ -use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup}; - -pub const DELETE_CALLBACK_DATA: &str = "delete_msg"; - -pub(crate) fn delete_message_button(original_user_id: u64) -> InlineKeyboardMarkup { - let callback_data = format!("{}:{}", DELETE_CALLBACK_DATA, original_user_id); - let delete_button = InlineKeyboardButton::callback("🗑️", callback_data); - - InlineKeyboardMarkup::new(vec![vec![delete_button]]) -} diff --git a/src/util/mod.rs b/src/util/mod.rs index 7d717fe..58941ce 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,7 +1,14 @@ -pub mod currency; +pub mod currency_values; pub mod enums; -pub mod errors; -pub mod inline; -pub mod json; -pub mod transcription; -pub mod db; +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 new file mode 100644 index 0000000..2a3577c --- /dev/null +++ b/src/util/paginator.rs @@ -0,0 +1,169 @@ +use teloxide::types::{InlineKeyboardButton, InlineKeyboardMarkup}; + +pub struct Paginator<'a, T> { + 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 from(module_key: &'a str, items: &'a [T]) -> Self { + Self { + 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, + } + } +} + +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.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.div_ceil(self.per_page); + let page = self.current_page.min(total_pages - 1); + + 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]; + + 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( + "⬅️", + self.callback_formatter.as_ref().map_or_else( + || format!("{}:page:{}", self.callback_prefix, prev_page), + |f| f(prev_page), + ), + )); + } + + nav_row.push(InlineKeyboardButton::callback( + format!("{}/{}", page + 1, total_pages), + "noop", + )); + + if page + 1 < total_pages { + let next_page = page + 1; + + nav_row.push(InlineKeyboardButton::callback( + "➡️", + self.callback_formatter.as_ref().map_or_else( + || format!("{}:page:{}", self.callback_prefix, next_page), + |f| f(next_page), + ), + )); + } + + if nav_row.len() > 1 { + keyboard.push(nav_row); + } + + keyboard.extend(self.bottom_rows.clone()); + + 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 + } +} diff --git a/src/util/transcription.rs b/src/util/transcription.rs deleted file mode 100644 index 72d043f..0000000 --- a/src/util/transcription.rs +++ /dev/null @@ -1,206 +0,0 @@ -use super::enums::AudioStruct; -use crate::config::Config; -use crate::util::errors::MyError; -use crate::util::inline::delete_message_button; -use bytes::Bytes; -use gem_rs::types::HarmBlockThreshold; -use log::{error, info}; -use std::time::Duration; -use teloxide::Bot; -use teloxide::payloads::{EditMessageTextSetters, SendMessageSetters}; -use teloxide::requests::{Request as TeloxideRequest, Requester}; -use teloxide::types::{Message, MessageKind, ParseMode, ReplyParameters}; - -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 original_user_id = msg.from.clone().unwrap().id; - - if let Some(message) = message { - if let Some(file) = get_file_id(&msg).await { - let file_data = save_file_to_memory(&bot, &file.file_id).await?; - let transcription = Transcription { - mime_type: file.mime_type, - data: file_data, - config: config.clone(), - }; - - let text_parts = transcription.to_text().await; - - bot.edit_message_text( - msg.chat.id, - message.id, - format!("
{}
", text_parts[0]), - ) - .parse_mode(ParseMode::Html) - .reply_markup(delete_message_button(original_user_id.0)) - .await?; - - for part in text_parts.iter().skip(1) { - bot.send_message( - msg.chat.id, - format!("
\n{}\n
", part), - ) - .reply_parameters(ReplyParameters::new(msg.id)) - .parse_mode(ParseMode::Html) - .reply_markup(delete_message_button(original_user_id.0)) - .await?; - } - } else { - bot.edit_message_text( - msg.chat.id, - message.id, - "Не удалось найти голосовое сообщение.", - ) - .parse_mode(ParseMode::Html) - // .reply_markup(delete_message_button()) - .await?; - } - } - Ok(()) -} - -pub async fn get_file_id(msg: &Message) -> Option { - match &msg.kind { - MessageKind::Common(common) => match &common.media_kind { - teloxide::types::MediaKind::Audio(audio) => Some(AudioStruct { - mime_type: audio - .audio - .mime_type - .as_ref() - .unwrap() - .essence_str() - .to_owned(), - file_id: audio.audio.file.id.0.to_string(), - }), - teloxide::types::MediaKind::Voice(voice) => Some(AudioStruct { - mime_type: voice - .voice - .mime_type - .as_ref() - .unwrap() - .essence_str() - .to_owned(), - file_id: voice.voice.file.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(), - }), - _ => None, - }, - _ => None, - } -} - -pub async fn save_file_to_memory(bot: &Bot, file_id: &str) -> Result { - let file = bot - .get_file(teloxide::types::FileId(file_id.to_string())) - .send() - .await?; - let file_url = format!( - "https://api.telegram.org/file/bot{}/{}", - bot.token(), - file.path - ); - - let response = reqwest::get(file_url).await?; - let bytes = response.bytes().await?; - - Ok(bytes) -} - -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 = gem_rs::types::Settings::new(); - settings.set_all_safety_settings(HarmBlockThreshold::BlockNone); - - let error_answer = "❌ Не удалось преобразовать текст из сообщения.".to_string(); - - let ai_model = self.config.get_json_config().get_ai_model().to_owned(); - let prompt = self.config.get_json_config().get_ai_prompt().to_owned(); - - let mut context = gem_rs::types::Context::new(); - context.push_message(gem_rs::types::Role::Model, prompt); - - let mut client = gem_rs::client::GemSession::Builder() - .model(gem_rs::api::Models::Custom(ai_model)) - .timeout(Some(Duration::from_secs(120))) - .context(context) - .build(); - - let mut attempts = 0; - let mut last_text = String::new(); - let mut last_error = String::new(); - - while attempts < 3 { - match client - .send_blob( - gem_rs::types::Blob::new(&self.mime_type, &self.data), - gem_rs::types::Role::User, - &settings, - ) - .await - { - Ok(response) => { - let full_text = response - .get_results() - .first() - .cloned() - .unwrap_or_else(|| "".to_string()); - - if full_text == last_text && !full_text.is_empty() { - continue; - } - last_text = full_text.clone(); - - if !full_text.is_empty() { - return split_text(full_text, 4000); - } else { - attempts += 1; - info!( - "Received empty successful response from transcription service, attempt {}", - attempts - ); - } - } - Err(error) => { - attempts += 1; - - if error.to_string() == last_error { - continue; - } - last_error = error.to_string(); - - error!( - "Error with transcription (attempt {}): {:?}", - attempts, error - ); - } - } - } - vec![error_answer + "\n\n" + &last_error] - } -} - -fn split_text(text: String, chunk_size: usize) -> Vec { - if text.is_empty() { - return vec![]; - } - text.chars() - .collect::>() - .chunks(chunk_size) - .map(|chunk| chunk.iter().collect()) - .collect() -}