Add per-user default search setting

This commit is contained in:
2026-02-16 22:00:19 +01:00
parent a3ab5e9508
commit fb4ef7e952
6 changed files with 359 additions and 20 deletions

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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<Throttle<Bot>>) -> 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 {

View File

@@ -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<action>(off)|(on))_(?P<code>[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"),
}
}
}

View File

@@ -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<Throttle<Bot>>) -> BotHandlerInternal {
let keyboard = InlineKeyboardMarkup {
inline_keyboard: vec![vec![InlineKeyboardButton {
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<Throttle<Bot>>) -> 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<DefaultSearchType>) -> 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<Throttle<Bot>>,
@@ -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<SmartString> = 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
{

View File

@@ -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<reqwest::Client> = 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<Self> {
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<Option<DefaultSearchType>, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt = Option::<String>::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<DefaultSearchType>,
}
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<DefaultSearchType>,
) -> anyhow::Result<UserSettings> {
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::<UserSettings>().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<DefaultSearchType> {
match get_user_settings(user_id).await {
Ok(Some(s)) => s.default_search,
_ => None,
}
}
pub async fn get_langs() -> anyhow::Result<Vec<Lang>> {
let response = CLIENT
.get(format!("{}/languages/", &config::CONFIG.user_settings_url))