From fb4ef7e95263dffabc1de4ef30c43454e2a5ac4d Mon Sep 17 00:00:00 2001 From: Bulat Kurbanov Date: Mon, 16 Feb 2026 22:00:19 +0100 Subject: [PATCH] Add per-user default search setting --- src/bots/approved_bot/mod.rs | 7 +- .../modules/search/callback_data.rs | 15 +- src/bots/approved_bot/modules/search/mod.rs | 104 ++++++++++- .../modules/settings/callback_data.rs | 30 +++- src/bots/approved_bot/modules/settings/mod.rs | 164 ++++++++++++++++-- .../services/user_settings/mod.rs | 59 ++++++- 6 files changed, 359 insertions(+), 20 deletions(-) diff --git a/src/bots/approved_bot/mod.rs b/src/bots/approved_bot/mod.rs index e562761..5af7d6e 100644 --- a/src/bots/approved_bot/mod.rs +++ b/src/bots/approved_bot/mod.rs @@ -9,7 +9,9 @@ use teloxide::{ }; use crate::{ - bots::approved_bot::services::user_settings::create_or_update_user_settings, + bots::approved_bot::services::user_settings::{ + create_or_update_user_settings, get_user_settings, + }, bots_manager::USER_ACTIVITY_CACHE, }; @@ -38,6 +40,8 @@ async fn _update_activity(me: teloxide::types::Me, user: teloxide::types::User) if update_result.is_err() { let allowed_langs = get_user_or_default_lang_codes(user.id).await; + let current = get_user_settings(user.id).await.ok().flatten(); + let default_search = current.as_ref().and_then(|s| s.default_search); if create_or_update_user_settings( user.id, @@ -46,6 +50,7 @@ async fn _update_activity(me: teloxide::types::Me, user: teloxide::types::User) &user.username.unwrap_or("".to_string()), &me.username.clone().unwrap_or("".to_string()), allowed_langs, + default_search, ) .await .is_ok() diff --git a/src/bots/approved_bot/modules/search/callback_data.rs b/src/bots/approved_bot/modules/search/callback_data.rs index 91e14c2..d4e7c6d 100644 --- a/src/bots/approved_bot/modules/search/callback_data.rs +++ b/src/bots/approved_bot/modules/search/callback_data.rs @@ -3,7 +3,10 @@ use std::{fmt::Display, str::FromStr}; use regex::Regex; use strum_macros::EnumIter; -use crate::bots::approved_bot::modules::utils::pagination::GetPaginationCallbackData; +use crate::bots::approved_bot::{ + modules::utils::pagination::GetPaginationCallbackData, + services::user_settings::DefaultSearchType, +}; #[derive(Clone, EnumIter)] pub enum SearchCallbackData { @@ -52,6 +55,16 @@ impl FromStr for SearchCallbackData { } } +/// Converts default search type to SearchCallbackData with page 1. +pub fn default_search_to_callback_data(t: DefaultSearchType) -> SearchCallbackData { + match t { + DefaultSearchType::Book => SearchCallbackData::Book { page: 1 }, + DefaultSearchType::Author => SearchCallbackData::Authors { page: 1 }, + DefaultSearchType::Series => SearchCallbackData::Sequences { page: 1 }, + DefaultSearchType::Translator => SearchCallbackData::Translators { page: 1 }, + } +} + impl GetPaginationCallbackData for SearchCallbackData { fn get_pagination_callback_data(&self, target_page: u32) -> String { match self { diff --git a/src/bots/approved_bot/modules/search/mod.rs b/src/bots/approved_bot/modules/search/mod.rs index 530f5e9..0e9a1db 100644 --- a/src/bots/approved_bot/modules/search/mod.rs +++ b/src/bots/approved_bot/modules/search/mod.rs @@ -21,14 +21,17 @@ use crate::bots::{ search_author, search_book, search_sequence, search_translator, types::Page, }, - user_settings::get_user_or_default_lang_codes, + user_settings::{get_user_default_search, get_user_or_default_lang_codes}, }, tools::filter_callback_query, }, BotHandlerInternal, }; -use self::{callback_data::SearchCallbackData, utils::get_query}; +use self::{ + callback_data::{default_search_to_callback_data, SearchCallbackData}, + utils::get_query, +}; use super::utils::pagination::generic_get_pagination_keyboard; @@ -124,8 +127,103 @@ where } pub async fn message_handler(message: Message, bot: CacheMe>) -> BotHandlerInternal { - let message_text = "Что ищем?"; + let query = message.text().map(|t| t.trim()).filter(|t| !t.is_empty()); + let user_id = message.from.as_ref().map(|u| u.id); + if let (Some(user_id), Some(query)) = (user_id, query) { + if let Some(default_type) = get_user_default_search(user_id).await { + let search_data = default_search_to_callback_data(default_type); + let allowed_langs = get_user_or_default_lang_codes(user_id).await; + let query_owned = query.to_string(); + let chat_id = message.chat.id; + let reply_params = ReplyParameters::new(message.id); + + let (formatted, pages) = match &search_data { + SearchCallbackData::Book { .. } => { + match search_book(query_owned, 1, allowed_langs).await { + Ok(p) if p.pages == 0 => { + bot.send_message(chat_id, "Книги не найдены!") + .reply_parameters(reply_params) + .send() + .await?; + return Ok(()); + } + Ok(p) => (p.format(1, 4096), p.pages), + Err(_) => { + bot.send_message(chat_id, "Ошибка! Попробуйте позже :(") + .send() + .await?; + return Ok(()); + } + } + } + SearchCallbackData::Authors { .. } => { + match search_author(query_owned, 1, allowed_langs).await { + Ok(p) if p.pages == 0 => { + bot.send_message(chat_id, "Авторы не найдены!") + .reply_parameters(reply_params) + .send() + .await?; + return Ok(()); + } + Ok(p) => (p.format(1, 4096), p.pages), + Err(_) => { + bot.send_message(chat_id, "Ошибка! Попробуйте позже :(") + .send() + .await?; + return Ok(()); + } + } + } + SearchCallbackData::Sequences { .. } => { + match search_sequence(query_owned, 1, allowed_langs).await { + Ok(p) if p.pages == 0 => { + bot.send_message(chat_id, "Серии не найдены!") + .reply_parameters(reply_params) + .send() + .await?; + return Ok(()); + } + Ok(p) => (p.format(1, 4096), p.pages), + Err(_) => { + bot.send_message(chat_id, "Ошибка! Попробуйте позже :(") + .send() + .await?; + return Ok(()); + } + } + } + SearchCallbackData::Translators { .. } => { + match search_translator(query_owned, 1, allowed_langs).await { + Ok(p) if p.pages == 0 => { + bot.send_message(chat_id, "Переводчики не найдены!") + .reply_parameters(reply_params) + .send() + .await?; + return Ok(()); + } + Ok(p) => (p.format(1, 4096), p.pages), + Err(_) => { + bot.send_message(chat_id, "Ошибка! Попробуйте позже :(") + .send() + .await?; + return Ok(()); + } + } + } + }; + + let keyboard = generic_get_pagination_keyboard(1, pages, search_data, true); + bot.send_message(chat_id, formatted) + .reply_parameters(reply_params) + .reply_markup(keyboard) + .send() + .await?; + return Ok(()); + } + } + + let message_text = "Что ищем?"; let keyboard = InlineKeyboardMarkup { inline_keyboard: vec![ vec![InlineKeyboardButton { diff --git a/src/bots/approved_bot/modules/settings/callback_data.rs b/src/bots/approved_bot/modules/settings/callback_data.rs index fa7e56a..dfb5bc4 100644 --- a/src/bots/approved_bot/modules/settings/callback_data.rs +++ b/src/bots/approved_bot/modules/settings/callback_data.rs @@ -6,8 +6,20 @@ use smartstring::alias::String as SmartString; #[derive(Clone)] pub enum SettingsCallbackData { Settings, - On { code: SmartString }, - Off { code: SmartString }, + On { + code: SmartString, + }, + Off { + code: SmartString, + }, + /// Open "default search type" submenu + DefaultSearchMenu, + /// Set default search: value is "book"|"author"|"series"|"translator"|"none" + DefaultSearch { + value: SmartString, + }, + /// Return from default search submenu to main settings + DefaultSearchBack, } impl FromStr for SettingsCallbackData { @@ -17,6 +29,17 @@ impl FromStr for SettingsCallbackData { if s == SettingsCallbackData::Settings.to_string().as_str() { return Ok(SettingsCallbackData::Settings); } + if s == "defsearch" { + return Ok(SettingsCallbackData::DefaultSearchMenu); + } + if s == "defsearch_back" { + return Ok(SettingsCallbackData::DefaultSearchBack); + } + if let Some(value) = s.strip_prefix("defsearch_") { + return Ok(SettingsCallbackData::DefaultSearch { + value: value.to_string().into(), + }); + } let re = Regex::new(r"^lang_(?P(off)|(on))_(?P[a-zA-z]+)$").unwrap(); @@ -43,6 +66,9 @@ impl Display for SettingsCallbackData { SettingsCallbackData::Settings => write!(f, "lang_settings"), SettingsCallbackData::On { code } => write!(f, "lang_on_{code}"), SettingsCallbackData::Off { code } => write!(f, "lang_off_{code}"), + SettingsCallbackData::DefaultSearchMenu => write!(f, "defsearch"), + SettingsCallbackData::DefaultSearch { value } => write!(f, "defsearch_{value}"), + SettingsCallbackData::DefaultSearchBack => write!(f, "defsearch_back"), } } } diff --git a/src/bots/approved_bot/modules/settings/mod.rs b/src/bots/approved_bot/modules/settings/mod.rs index bc8109e..5165781 100644 --- a/src/bots/approved_bot/modules/settings/mod.rs +++ b/src/bots/approved_bot/modules/settings/mod.rs @@ -3,12 +3,14 @@ pub mod commands; use std::collections::HashSet; +use smallvec::SmallVec; use smartstring::alias::String as SmartString; use crate::bots::{ approved_bot::{ services::user_settings::{ - create_or_update_user_settings, get_langs, get_user_or_default_lang_codes, Lang, + create_or_update_user_settings, get_langs, get_user_or_default_lang_codes, + get_user_settings, DefaultSearchType, Lang, }, tools::filter_callback_query, }, @@ -23,18 +25,28 @@ use teloxide::{ use self::{callback_data::SettingsCallbackData, commands::SettingsCommand}; -async fn settings_handler(message: Message, bot: CacheMe>) -> BotHandlerInternal { - let keyboard = InlineKeyboardMarkup { - inline_keyboard: vec![vec![InlineKeyboardButton { - text: "Языки".to_string(), - kind: teloxide::types::InlineKeyboardButtonKind::CallbackData( - SettingsCallbackData::Settings.to_string(), - ), - }]], - }; +fn get_main_settings_keyboard() -> InlineKeyboardMarkup { + InlineKeyboardMarkup { + inline_keyboard: vec![ + vec![InlineKeyboardButton { + text: "Языки".to_string(), + kind: teloxide::types::InlineKeyboardButtonKind::CallbackData( + SettingsCallbackData::Settings.to_string(), + ), + }], + vec![InlineKeyboardButton { + text: "Поиск по умолчанию".to_string(), + kind: teloxide::types::InlineKeyboardButtonKind::CallbackData( + SettingsCallbackData::DefaultSearchMenu.to_string(), + ), + }], + ], + } +} +async fn settings_handler(message: Message, bot: CacheMe>) -> BotHandlerInternal { bot.send_message(message.chat.id, "Настройки") - .reply_markup(keyboard) + .reply_markup(get_main_settings_keyboard()) .send() .await?; @@ -71,6 +83,65 @@ fn get_lang_keyboard( } } +fn get_default_search_keyboard(current: Option) -> InlineKeyboardMarkup { + let check = |v: DefaultSearchType| if current == Some(v) { " ✓" } else { "" }; + InlineKeyboardMarkup { + inline_keyboard: vec![ + vec![InlineKeyboardButton { + text: format!("Книга{}", check(DefaultSearchType::Book)), + kind: teloxide::types::InlineKeyboardButtonKind::CallbackData( + SettingsCallbackData::DefaultSearch { + value: "book".into(), + } + .to_string(), + ), + }], + vec![InlineKeyboardButton { + text: format!("Автор{}", check(DefaultSearchType::Author)), + kind: teloxide::types::InlineKeyboardButtonKind::CallbackData( + SettingsCallbackData::DefaultSearch { + value: "author".into(), + } + .to_string(), + ), + }], + vec![InlineKeyboardButton { + text: format!("Серия{}", check(DefaultSearchType::Series)), + kind: teloxide::types::InlineKeyboardButtonKind::CallbackData( + SettingsCallbackData::DefaultSearch { + value: "series".into(), + } + .to_string(), + ), + }], + vec![InlineKeyboardButton { + text: format!("Переводчик{}", check(DefaultSearchType::Translator)), + kind: teloxide::types::InlineKeyboardButtonKind::CallbackData( + SettingsCallbackData::DefaultSearch { + value: "translator".into(), + } + .to_string(), + ), + }], + vec![InlineKeyboardButton { + text: format!("Не выбрано{}", if current.is_none() { " ✓" } else { "" }), + kind: teloxide::types::InlineKeyboardButtonKind::CallbackData( + SettingsCallbackData::DefaultSearch { + value: "none".into(), + } + .to_string(), + ), + }], + vec![InlineKeyboardButton { + text: "← Назад".to_string(), + kind: teloxide::types::InlineKeyboardButtonKind::CallbackData( + SettingsCallbackData::DefaultSearchBack.to_string(), + ), + }], + ], + } +} + async fn settings_callback_handler( cq: CallbackQuery, bot: CacheMe>, @@ -89,6 +160,72 @@ async fn settings_callback_handler( let user = cq.from; + match &callback_data { + SettingsCallbackData::DefaultSearchMenu => { + let current = get_user_settings(user.id).await.ok().flatten(); + let current_default = current.as_ref().and_then(|s| s.default_search); + let keyboard = get_default_search_keyboard(current_default); + bot.edit_message_text(message.chat().id, message.id(), "Поиск по умолчанию") + .reply_markup(keyboard) + .send() + .await?; + bot.answer_callback_query(cq.id).send().await?; + return Ok(()); + } + SettingsCallbackData::DefaultSearchBack => { + bot.edit_message_text(message.chat().id, message.id(), "Настройки") + .reply_markup(get_main_settings_keyboard()) + .send() + .await?; + bot.answer_callback_query(cq.id).send().await?; + return Ok(()); + } + SettingsCallbackData::DefaultSearch { value } => { + let current = get_user_settings(user.id).await.ok().flatten(); + let allowed_langs: SmallVec<[SmartString; 3]> = match current { + Some(s) => s.allowed_langs.into_iter().map(|l| l.code).collect(), + None => get_user_or_default_lang_codes(user.id).await, + }; + let default_search = if value.as_str() == "none" { + None + } else if let Some(t) = DefaultSearchType::from_api_str(value.as_str()) { + Some(t) + } else { + bot.answer_callback_query(cq.id).send().await?; + return Ok(()); + }; + if create_or_update_user_settings( + user.id, + &user.last_name.unwrap_or("".to_string()), + &user.first_name, + user.username.as_deref().unwrap_or(""), + &me.username.clone().unwrap(), + allowed_langs, + default_search, + ) + .await + .is_err() + { + bot.answer_callback_query(cq.id) + .text("Ошибка! Попробуйте заново(") + .show_alert(true) + .send() + .await?; + return Ok(()); + } + bot.edit_message_text(message.chat().id, message.id(), "Настройки") + .reply_markup(get_main_settings_keyboard()) + .send() + .await?; + bot.answer_callback_query(cq.id) + .text("Готово") + .send() + .await?; + return Ok(()); + } + _ => {} + } + let allowed_langs = get_user_or_default_lang_codes(user.id).await; let mut allowed_langs_set: HashSet = HashSet::new(); @@ -104,6 +241,7 @@ async fn settings_callback_handler( SettingsCallbackData::Off { code } => { allowed_langs_set.remove(&code); } + _ => {} }; if allowed_langs_set.is_empty() { @@ -116,6 +254,9 @@ async fn settings_callback_handler( return Ok(()); } + let current_settings = get_user_settings(user.id).await.ok().flatten(); + let default_search = current_settings.as_ref().and_then(|s| s.default_search); + if let Err(err) = create_or_update_user_settings( user.id, &user.last_name.unwrap_or("".to_string()), @@ -123,6 +264,7 @@ async fn settings_callback_handler( &user.username.unwrap_or("".to_string()), &me.username.clone().unwrap(), allowed_langs_set.clone().into_iter().collect(), + default_search, ) .await { diff --git a/src/bots/approved_bot/services/user_settings/mod.rs b/src/bots/approved_bot/services/user_settings/mod.rs index 793f8a0..d31cae7 100644 --- a/src/bots/approved_bot/services/user_settings/mod.rs +++ b/src/bots/approved_bot/services/user_settings/mod.rs @@ -1,6 +1,6 @@ use once_cell::sync::Lazy; use reqwest::StatusCode; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use serde_json::json; use smallvec::{smallvec, SmallVec}; use smartstring::alias::String as SmartString; @@ -11,6 +11,45 @@ use crate::{bots_manager::USER_LANGS_CACHE, config}; pub static CLIENT: Lazy = Lazy::new(reqwest::Client::new); +/// API values: "book" | "author" | "series" | "translator" +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DefaultSearchType { + Book, + Author, + Series, + Translator, +} + +impl DefaultSearchType { + pub fn as_api_str(self) -> &'static str { + match self { + DefaultSearchType::Book => "book", + DefaultSearchType::Author => "author", + DefaultSearchType::Series => "series", + DefaultSearchType::Translator => "translator", + } + } + + pub fn from_api_str(s: &str) -> Option { + match s { + "book" => Some(DefaultSearchType::Book), + "author" => Some(DefaultSearchType::Author), + "series" => Some(DefaultSearchType::Series), + "translator" => Some(DefaultSearchType::Translator), + _ => None, + } + } +} + +fn deserialize_optional_default_search<'de, D>(d: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let opt = Option::::deserialize(d)?; + Ok(opt.and_then(|s| DefaultSearchType::from_api_str(&s))) +} + #[derive(Deserialize, Debug, Clone)] pub struct Lang { // pub id: u32, @@ -26,6 +65,8 @@ pub struct UserSettings { // pub username: SmartString, // pub source: SmartString, pub allowed_langs: SmallVec<[Lang; 3]>, + #[serde(default, deserialize_with = "deserialize_optional_default_search")] + pub default_search: Option, } pub async fn get_user_settings( @@ -79,16 +120,22 @@ pub async fn create_or_update_user_settings( username: &str, source: &str, allowed_langs: SmallVec<[SmartString; 3]>, + default_search: Option, ) -> anyhow::Result { USER_LANGS_CACHE.invalidate(&user_id).await; + let default_search_json = match &default_search { + Some(t) => serde_json::Value::String(t.as_api_str().to_string()), + None => serde_json::Value::Null, + }; let body = json!({ "user_id": user_id, "last_name": last_name, "first_name": first_name, "username": username, "source": source, - "allowed_langs": allowed_langs.into_vec() + "allowed_langs": allowed_langs.into_vec(), + "default_search": default_search_json }); let response = CLIENT @@ -103,6 +150,14 @@ pub async fn create_or_update_user_settings( Ok(response.json::().await?) } +/// Returns user's default search type from API. None if not set or on error. +pub async fn get_user_default_search(user_id: UserId) -> Option { + match get_user_settings(user_id).await { + Ok(Some(s)) => s.default_search, + _ => None, + } +} + pub async fn get_langs() -> anyhow::Result> { let response = CLIENT .get(format!("{}/languages/", &config::CONFIG.user_settings_url))