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>
320 lines
14 KiB
Rust
320 lines
14 KiB
Rust
//! 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(),
|
||
};
|
||
}
|