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