Files
telegram-tui/src/tdlib/update_handlers.rs
Mikhail Kilin bea0bcbed0 feat: implement desktop notifications with comprehensive filtering
Implemented Phase 10 (Desktop Notifications) with three stages:
notify-rust integration, smart filtering, and production polish.

Stage 1 - Base Implementation:
- Add NotificationManager module (src/notifications.rs, 350+ lines)
- Integrate notify-rust 4.11 with feature flag "notifications"
- Implement NotificationsConfig in config.toml (enabled, only_mentions, show_preview)
- Add notification_manager field to TdClient
- Create configure_notifications() method for config integration
- Hook into handle_new_message_update() to send notifications
- Send notifications for messages outside current chat
- Format notification body with sender name and message preview

Stage 2 - Smart Filtering:
- Sync muted chats from Telegram (sync_muted_chats method)
- Filter muted chats from notifications automatically
- Add MessageInfo::has_mention() to detect @username mentions
- Implement only_mentions filter (notify only when mentioned)
- Beautify media labels with emojis (📷 📹 🎤 🎨 📎 etc.)
- Support 10+ media types in notification preview

Stage 3 - Production Polish:
- Add graceful error handling (no panics on notification failure)
- Implement comprehensive logging (tracing::debug!/warn!)
- Add timeout_ms configuration (0 = system default)
- Add urgency configuration (low/normal/critical, Linux only)
- Platform-specific #[cfg] for urgency support
- Log all notification skip reasons at debug level

Hotkey Change:
- Move profile view from 'i' to Ctrl+i to avoid conflicts

Technical Details:
- Cross-platform support (macOS, Linux, Windows)
- Feature flag for optional notifications support
- Graceful fallback when notifications unavailable
- LRU-friendly muted chats sync
- Test coverage for all core notification logic
- All 75 tests passing

Files Changed:
- NEW: src/notifications.rs - Complete NotificationManager
- NEW: config.example.toml - Example configuration with notifications
- Modified: Cargo.toml - Add notify-rust 4.11 dependency
- Modified: src/config/mod.rs - Add NotificationsConfig struct
- Modified: src/tdlib/types.rs - Add has_mention() method
- Modified: src/tdlib/client.rs - Add notification integration
- Modified: src/tdlib/update_handlers.rs - Hook notifications
- Modified: src/config/keybindings.rs - Change profile to Ctrl+i
- Modified: tests/* - Add notification config to tests

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-05 01:27:44 +03:00

320 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Update handlers for TDLib events.
//!
//! This module contains functions that process various types of updates from TDLib.
//! Each handler is responsible for updating the application state based on the received update.
use crate::types::{ChatId, MessageId, UserId};
use std::time::Instant;
use tdlib_rs::enums::{
AuthorizationState, ChatAction, ChatList, MessageSender,
};
use tdlib_rs::types::{
UpdateChatAction, UpdateChatDraftMessage, UpdateChatPosition,
UpdateMessageInteractionInfo, UpdateMessageSendSucceeded, UpdateNewMessage, UpdateUser,
};
use super::auth::AuthState;
use super::client::TdClient;
use super::types::ReactionInfo;
/// Обрабатывает Update::NewMessage - добавление нового сообщения
pub fn handle_new_message_update(client: &mut TdClient, new_msg: UpdateNewMessage) {
let chat_id = ChatId::new(new_msg.message.chat_id);
// Если сообщение НЕ для текущего открытого чата - отправляем уведомление
if Some(chat_id) != client.current_chat_id() {
// Find and clone chat info to avoid borrow checker issues
if let Some(chat) = client.chats().iter().find(|c| c.id == chat_id).cloned() {
let msg_info = crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id);
// Get sender name (from message or user cache)
let sender_name = msg_info.sender_name();
// Send notification
let _ = client.notification_manager.notify_new_message(
&chat,
&msg_info,
sender_name,
);
}
return;
}
// Добавляем новое сообщение если это текущий открытый чат
let msg_info = crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id);
let msg_id = msg_info.id();
let is_incoming = !msg_info.is_outgoing();
// Проверяем, есть ли уже сообщение с таким id
let existing_idx = client
.current_chat_messages()
.iter()
.position(|m| m.id() == msg_info.id());
match existing_idx {
Some(idx) => {
// Сообщение уже есть - обновляем
if is_incoming {
client.current_chat_messages_mut()[idx] = msg_info;
} else {
// Для исходящих: обновляем can_be_edited и другие поля,
// но сохраняем reply_to (добавленный при отправке)
let existing = &mut client.current_chat_messages_mut()[idx];
existing.state.can_be_edited = msg_info.state.can_be_edited;
existing.state.can_be_deleted_only_for_self =
msg_info.state.can_be_deleted_only_for_self;
existing.state.can_be_deleted_for_all_users =
msg_info.state.can_be_deleted_for_all_users;
existing.state.is_read = msg_info.state.is_read;
}
}
None => {
// Нового сообщения нет - добавляем
client.push_message(msg_info.clone());
// Если это входящее сообщение — добавляем в очередь для отметки как прочитанное
if is_incoming {
client.pending_view_messages_mut().push((chat_id, vec![msg_id]));
}
}
}
}
/// Обрабатывает Update::ChatAction - статус набора текста/отправки файлов
pub fn handle_chat_action_update(client: &mut TdClient, update: UpdateChatAction) {
// Обрабатываем только для текущего открытого чата
if Some(ChatId::new(update.chat_id)) != client.current_chat_id() {
return;
}
// Извлекаем user_id из sender_id
let MessageSender::User(user) = update.sender_id else {
return; // Игнорируем действия от имени чата
};
let user_id = UserId::new(user.user_id);
// Определяем текст действия
let action_text = match update.action {
ChatAction::Typing => Some("печатает...".to_string()),
ChatAction::RecordingVideo => Some("записывает видео...".to_string()),
ChatAction::UploadingVideo(_) => Some("отправляет видео...".to_string()),
ChatAction::RecordingVoiceNote => Some("записывает голосовое...".to_string()),
ChatAction::UploadingVoiceNote(_) => Some("отправляет голосовое...".to_string()),
ChatAction::UploadingPhoto(_) => Some("отправляет фото...".to_string()),
ChatAction::UploadingDocument(_) => Some("отправляет файл...".to_string()),
ChatAction::ChoosingSticker => Some("выбирает стикер...".to_string()),
ChatAction::RecordingVideoNote => Some("записывает видеосообщение...".to_string()),
ChatAction::UploadingVideoNote(_) => Some("отправляет видеосообщение...".to_string()),
ChatAction::Cancel | _ => None, // Отмена или неизвестное действие
};
match action_text {
Some(text) => client.set_typing_status(Some((user_id, text, Instant::now()))),
None => client.set_typing_status(None),
}
}
/// Обрабатывает Update::ChatPosition - изменение позиции чата в списке.
///
/// Обновляет order и is_pinned для чатов в Main списке,
/// управляет folder_ids для чатов в папках.
pub fn handle_chat_position_update(client: &mut TdClient, update: UpdateChatPosition) {
let chat_id = ChatId::new(update.chat_id);
match &update.position.list {
ChatList::Main => {
if update.position.order == 0 {
// Чат больше не в Main (перемещён в архив и т.д.)
client.chats_mut().retain(|c| c.id != chat_id);
} else {
// Обновляем позицию существующего чата
crate::tdlib::chat_helpers::update_chat(client, chat_id, |chat| {
chat.order = update.position.order;
chat.is_pinned = update.position.is_pinned;
});
}
// Пересортируем по order
client.chats_mut().sort_by(|a, b| b.order.cmp(&a.order));
}
ChatList::Folder(folder) => {
// Обновляем folder_ids для чата
crate::tdlib::chat_helpers::update_chat(client, chat_id, |chat| {
if update.position.order == 0 {
// Чат удалён из папки
chat.folder_ids.retain(|&id| id != folder.chat_folder_id);
} else {
// Чат добавлен в папку
if !chat.folder_ids.contains(&folder.chat_folder_id) {
chat.folder_ids.push(folder.chat_folder_id);
}
}
});
}
ChatList::Archive => {
// Архив пока не обрабатываем
}
}
}
/// Обрабатывает Update::User - обновление информации о пользователе.
///
/// Сохраняет display name и username в кэше,
/// обновляет username в связанных чатах,
/// удаляет "Deleted Account" из списка чатов.
pub fn handle_user_update(client: &mut TdClient, update: UpdateUser) {
let user = update.user;
// Пропускаем удалённые аккаунты (пустое имя)
if user.first_name.is_empty() && user.last_name.is_empty() {
// Удаляем чаты с этим пользователем из списка
let user_id = user.id;
// Clone chat_user_ids to avoid borrow conflict
let chat_user_ids = client.user_cache.chat_user_ids.clone();
client
.chats_mut()
.retain(|c| chat_user_ids.get(&c.id) != Some(&UserId::new(user_id)));
return;
}
// Сохраняем display name (first_name + last_name)
let display_name = if user.last_name.is_empty() {
user.first_name.clone()
} else {
format!("{} {}", user.first_name, user.last_name)
};
client.user_cache.user_names.insert(UserId::new(user.id), display_name);
// Сохраняем username если есть (с упрощённым извлечением через and_then)
if let Some(username) = user.usernames
.as_ref()
.and_then(|u| u.active_usernames.first())
{
client.user_cache.user_usernames.insert(UserId::new(user.id), username.to_string());
// Обновляем username в чатах, связанных с этим пользователем
for (&chat_id, &user_id) in &client.user_cache.chat_user_ids.clone() {
if user_id == UserId::new(user.id) {
crate::tdlib::chat_helpers::update_chat(client, chat_id, |chat| {
chat.username = Some(format!("@{}", username));
});
}
}
}
// LRU-кэш автоматически удаляет старые записи при вставке
}
/// Обрабатывает Update::MessageInteractionInfo - обновление реакций на сообщение.
///
/// Обновляет список реакций для сообщения в текущем открытом чате.
pub fn handle_message_interaction_info_update(
client: &mut TdClient,
update: UpdateMessageInteractionInfo,
) {
// Обновляем реакции в текущем открытом чате
if Some(ChatId::new(update.chat_id)) != client.current_chat_id() {
return;
}
let Some(msg) = client
.current_chat_messages_mut()
.iter_mut()
.find(|m| m.id() == MessageId::new(update.message_id))
else {
return;
};
// Извлекаем реакции из interaction_info
msg.interactions.reactions = update
.interaction_info
.as_ref()
.and_then(|info| info.reactions.as_ref())
.map(|reactions| {
reactions
.reactions
.iter()
.filter_map(|reaction| {
let emoji = match &reaction.r#type {
tdlib_rs::enums::ReactionType::Emoji(e) => e.emoji.clone(),
tdlib_rs::enums::ReactionType::CustomEmoji(_) => return None,
};
Some(ReactionInfo {
emoji,
count: reaction.total_count,
is_chosen: reaction.is_chosen,
})
})
.collect()
})
.unwrap_or_default();
}
/// Обрабатывает Update::MessageSendSucceeded - успешная отправка сообщения.
///
/// Заменяет временный ID сообщения на настоящий ID от сервера,
/// сохраняя reply_info из временного сообщения.
pub fn handle_message_send_succeeded_update(
client: &mut TdClient,
update: UpdateMessageSendSucceeded,
) {
let old_id = MessageId::new(update.old_message_id);
let chat_id = ChatId::new(update.message.chat_id);
// Обрабатываем только если это текущий открытый чат
if Some(chat_id) != client.current_chat_id() {
return;
}
// Находим сообщение с временным ID
let Some(idx) = client
.current_chat_messages()
.iter()
.position(|m| m.id() == old_id)
else {
return;
};
// Конвертируем новое сообщение
let mut new_msg = crate::tdlib::message_converter::convert_message(client, &update.message, chat_id);
// Сохраняем reply_info из старого сообщения (если было)
let old_reply = client.current_chat_messages()[idx]
.interactions
.reply_to
.clone();
if let Some(reply) = old_reply {
new_msg.interactions.reply_to = Some(reply);
}
// Заменяем старое сообщение на новое
client.current_chat_messages_mut()[idx] = new_msg;
}
/// Обрабатывает Update::ChatDraftMessage - обновление черновика сообщения в чате.
///
/// Извлекает текст черновика и сохраняет его в ChatInfo для отображения в списке чатов.
pub fn handle_chat_draft_message_update(client: &mut TdClient, update: UpdateChatDraftMessage) {
crate::tdlib::chat_helpers::update_chat(client, ChatId::new(update.chat_id), |chat| {
chat.draft_text = update.draft_message.as_ref().and_then(|draft| {
// Извлекаем текст из InputMessageText с помощью pattern matching
match &draft.input_message_text {
tdlib_rs::enums::InputMessageContent::InputMessageText(text_msg) => {
Some(text_msg.text.text.clone())
}
_ => None,
}
});
});
}
/// Обрабатывает изменение состояния авторизации
pub fn handle_auth_state(client: &mut TdClient, state: AuthorizationState) {
client.auth.state = match state {
AuthorizationState::WaitTdlibParameters => AuthState::WaitTdlibParameters,
AuthorizationState::WaitPhoneNumber => AuthState::WaitPhoneNumber,
AuthorizationState::WaitCode(_) => AuthState::WaitCode,
AuthorizationState::WaitPassword(_) => AuthState::WaitPassword,
AuthorizationState::Ready => AuthState::Ready,
AuthorizationState::Closed => AuthState::Closed,
_ => client.auth.state.clone(),
};
}