This commit is contained in:
Mikhail Kilin
2026-01-30 23:55:01 +03:00
parent 433233d766
commit bba5cbd22d
25 changed files with 5896 additions and 1469 deletions

View File

@@ -84,6 +84,27 @@ excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# the name by which the project can be referenced within Serena
project_name: "tele-tui"
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
included_optional_tools: []
# list of mode names to that are always to be included in the set of active modes
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this setting overrides the global configuration.
# Set this to [] to disable base modes for this project.
# Set this to a list of mode names to always include the respective modes for this project.
base_modes:
# list of mode names that are to be activated by default.
# The full set of modes to be activated is base_modes + default_modes.
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
# This setting can, in turn, be overridden by CLI parameters (--mode).
default_modes:
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
fixed_tools: []

View File

@@ -321,6 +321,46 @@ reaction_other = "gray"
Подробности: [TESTING_PROGRESS.md](TESTING_PROGRESS.md)
### Рефакторинг — Приоритет 1 ЗАВЕРШЁН! 🏗️✨ (2026-01-30)
**Статус**: Priority 1 (3/3 задач) ✅ ЗАВЕРШЕНО!
**Завершено**:
-**P1.3 — Константы** (ранее)
- Вынесены магические числа в `src/constants.rs`
- Улучшена читаемость и maintainability
-**P1.2 — Разделение TdClient** (2026-01-30)
- Разделён монолитный TdClient (2036 строк, 87KB) на 7 модулей:
- `auth.rs` — AuthManager + AuthState enum (6.8KB)
- `chats.rs` — ChatManager для операций с чатами (8.1KB)
- `messages.rs` — MessageManager для сообщений (18.5KB)
- `users.rs` — UserCache с LRU кэшем (6.2KB)
- `reactions.rs` — ReactionManager (4.2KB)
- `types.rs` — Общие типы данных (10.8KB)
- `mod.rs` — Экспорты модулей
- Размер client.rs сократился на **50%** (87KB → 42.5KB)
- Исправлено 130+ ошибок компиляции из-за изменений в tdlib-rs API
- Все 330 тестов проходят ✅
-**P1.1 — ChatState enum** (2026-01-30)
- Схлопнуты 14 boolean полей в type-safe enum `ChatState`
- Невозможно иметь несколько состояний одновременно
- Данные состояния хранятся вместе с ним
- Варианты: Normal, MessageSelection, Editing, Reply, Forward, DeleteConfirmation, ReactionPicker, Profile, SearchInChat, PinnedMessages
- Обновлены все методы App для делегирования к ChatState
- Все 330 тестов проходят ✅
**Преимущества**:
- Код стал более модульным и maintainable
- Улучшена type-safety
- Проще добавлять новые фичи
- Лучше читаемость
**Следующие шаги**: Priority 2 (типобезопасность: Error enum, Newtype для ID)
Подробности: [REFACTORING_ROADMAP.md](REFACTORING_ROADMAP.md)
## Что НЕ сделано / TODO
Все пункты Фазы 9 завершены! Можно переходить к следующей фазе разработки или продолжить написание тестов.
@@ -329,12 +369,16 @@ reaction_other = "gray"
См. [REFACTORING_ROADMAP.md](REFACTORING_ROADMAP.md) для детального плана рефакторинга.
Основные области для улучшения:
1. **ChatState enum** — схлопнуть boolean состояния в type-safe enum
2. **Разделение TdClient** — слишком много ответственности в одном модуле
3. **Типобезопасность** — newtype pattern для ID, error enum
4. **UI компоненты** — выделить переиспользуемые компоненты
5. **Тестирование** — добавить юнит-тесты для критичных функций
**Завершено** (Priority 1):
1. ~~**ChatState enum**~~ — схлопнуты boolean состояния в type-safe enum
2. ~~**Разделение TdClient**~~ ✅ — разделён на 7 модулей
3. ~~**Константы**~~ ✅ — вынесены в отдельный модуль
**В работе** (Priority 2-5):
1. **Типобезопасность** — newtype pattern для ID, error enum
2. **UI компоненты** — выделить переиспользуемые компоненты
3. **Форматирование** — вынести markdown форматирование в отдельный модуль
4. **Юнит-тесты** — добавить для utils и других модулей
## Известные проблемы

View File

@@ -604,13 +604,16 @@ tracing-subscriber = "0.3"
## Метрики прогресса
- [ ] Priority 1: 0/3 задач
- [x] Priority 1: 3/3 задач ✅ ЗАВЕРШЕНО!
- [x] P1.1 — ChatState enum
- [x] P1.2 — Разделить TdClient
- [x] P1.3 — Константы
- [ ] Priority 2: 0/3 задач
- [ ] Priority 3: 0/4 задач
- [ ] Priority 4: 0/4 задач
- [ ] Priority 5: 0/3 задач
**Всего**: 0/17 задач
**Всего**: 3/17 задач (18%)
---

View File

@@ -1,7 +1,6 @@
// Chat state management - type-safe state machine for chat modes
use crate::tdlib::client::MessageInfo;
use crate::tdlib::ProfileInfo;
use crate::tdlib::{MessageInfo, ProfileInfo};
/// Состояния чата - взаимоисключающие режимы работы с чатом
#[derive(Debug, Clone)]

View File

@@ -4,8 +4,7 @@ mod state;
pub use chat_state::ChatState;
pub use state::AppScreen;
use crate::tdlib::client::ChatInfo;
use crate::tdlib::TdClient;
use crate::tdlib::{ChatInfo, TdClient};
use ratatui::widgets::ListState;
pub struct App {
@@ -125,15 +124,15 @@ impl App {
// Сбрасываем состояние чата в нормальный режим
self.chat_state = ChatState::Normal;
// Очищаем данные в TdClient
self.td_client.current_chat_id = None;
self.td_client.current_chat_messages.clear();
self.td_client.typing_status = None;
self.td_client.current_pinned_message = None;
self.td_client.set_current_chat_id(None);
self.td_client.current_chat_messages_mut().clear();
self.td_client.set_typing_status(None);
self.td_client.set_current_pinned_message(None);
}
/// Начать выбор сообщения для редактирования (при стрелке вверх в пустом инпуте)
pub fn start_message_selection(&mut self) {
if self.td_client.current_chat_messages.is_empty() {
if self.td_client.current_chat_messages().is_empty() {
return;
}
// Начинаем с последнего сообщения (индекс 0 = самое новое снизу)
@@ -142,7 +141,7 @@ impl App {
/// Выбрать предыдущее сообщение (вверх по списку = увеличить индекс)
pub fn select_previous_message(&mut self) {
let total = self.td_client.current_chat_messages.len();
let total = self.td_client.current_chat_messages().len();
if total == 0 {
return;
}
@@ -163,14 +162,14 @@ impl App {
}
/// Получить выбранное сообщение
pub fn get_selected_message(&self) -> Option<&crate::tdlib::client::MessageInfo> {
pub fn get_selected_message(&self) -> Option<&crate::tdlib::MessageInfo> {
self.chat_state.selected_message_index().and_then(|idx| {
let total = self.td_client.current_chat_messages.len();
let total = self.td_client.current_chat_messages().len();
if total == 0 || idx >= total {
return None;
}
// idx=0 это последнее сообщение (total-1), idx=1 это предпоследнее (total-2), и т.д.
self.td_client.current_chat_messages.get(total - 1 - idx)
self.td_client.current_chat_messages().get(total - 1 - idx)
})
}
@@ -346,10 +345,10 @@ impl App {
}
/// Получить сообщение, на которое отвечаем
pub fn get_replying_to_message(&self) -> Option<&crate::tdlib::client::MessageInfo> {
pub fn get_replying_to_message(&self) -> Option<&crate::tdlib::MessageInfo> {
self.chat_state.selected_message_id().and_then(|id| {
self.td_client
.current_chat_messages
.current_chat_messages()
.iter()
.find(|m| m.id == id)
})
@@ -380,13 +379,13 @@ impl App {
}
/// Получить сообщение для пересылки
pub fn get_forwarding_message(&self) -> Option<&crate::tdlib::client::MessageInfo> {
pub fn get_forwarding_message(&self) -> Option<&crate::tdlib::MessageInfo> {
if !self.chat_state.is_forward() {
return None;
}
self.chat_state.selected_message_id().and_then(|id| {
self.td_client
.current_chat_messages
.current_chat_messages()
.iter()
.find(|m| m.id == id)
})
@@ -400,7 +399,7 @@ impl App {
}
/// Войти в режим pinned (вызывается после загрузки pinned сообщений)
pub fn enter_pinned_mode(&mut self, messages: Vec<crate::tdlib::client::MessageInfo>) {
pub fn enter_pinned_mode(&mut self, messages: Vec<crate::tdlib::MessageInfo>) {
if !messages.is_empty() {
self.chat_state = ChatState::PinnedMessages {
messages,
@@ -437,7 +436,7 @@ impl App {
}
/// Получить текущее выбранное pinned сообщение
pub fn get_selected_pinned(&self) -> Option<&crate::tdlib::client::MessageInfo> {
pub fn get_selected_pinned(&self) -> Option<&crate::tdlib::MessageInfo> {
if let ChatState::PinnedMessages {
messages,
selected_index,
@@ -476,7 +475,7 @@ impl App {
}
/// Установить результаты поиска
pub fn set_search_results(&mut self, results: Vec<crate::tdlib::client::MessageInfo>) {
pub fn set_search_results(&mut self, results: Vec<crate::tdlib::MessageInfo>) {
if let ChatState::SearchInChat { results: r, selected_index, .. } = &mut self.chat_state {
*r = results;
*selected_index = 0;
@@ -507,7 +506,7 @@ impl App {
}
/// Получить текущий выбранный результат
pub fn get_selected_search_result(&self) -> Option<&crate::tdlib::client::MessageInfo> {
pub fn get_selected_search_result(&self) -> Option<&crate::tdlib::MessageInfo> {
if let ChatState::SearchInChat {
results,
selected_index,
@@ -551,7 +550,7 @@ impl App {
}
/// Получить результаты поиска
pub fn get_search_results(&self) -> Option<&[crate::tdlib::client::MessageInfo]> {
pub fn get_search_results(&self) -> Option<&[crate::tdlib::MessageInfo]> {
if let ChatState::SearchInChat { results, .. } = &self.chat_state {
Some(results.as_slice())
} else {

View File

@@ -1,11 +1,11 @@
use crate::app::App;
use crate::tdlib::client::AuthState;
use crate::tdlib::AuthState;
use crossterm::event::KeyCode;
use std::time::Duration;
use tokio::time::timeout;
pub async fn handle(app: &mut App, key_code: KeyCode) {
match &app.td_client.auth_state {
match &app.td_client.auth_state() {
AuthState::WaitPhoneNumber => match key_code {
KeyCode::Char(c) => {
app.phone_input.push(c);

View File

@@ -189,12 +189,12 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
if let Some(msg_id) = app.get_selected_search_result_id() {
let msg_index = app
.td_client
.current_chat_messages
.current_chat_messages()
.iter()
.position(|m| m.id == msg_id);
if let Some(idx) = msg_index {
let total = app.td_client.current_chat_messages.len();
let total = app.td_client.current_chat_messages().len();
app.message_scroll_offset = total.saturating_sub(idx + 5);
}
app.exit_message_search_mode();
@@ -263,13 +263,13 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
// Ищем индекс сообщения в текущей истории
let msg_index = app
.td_client
.current_chat_messages
.current_chat_messages()
.iter()
.position(|m| m.id == msg_id);
if let Some(idx) = msg_index {
// Вычисляем scroll offset чтобы показать сообщение
let total = app.td_client.current_chat_messages.len();
let total = app.td_client.current_chat_messages().len();
app.message_scroll_offset = total.saturating_sub(idx + 5);
}
app.exit_pinned_mode();
@@ -375,7 +375,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
// Находим сообщение для проверки can_be_deleted_for_all_users
let can_delete_for_all = app
.td_client
.current_chat_messages
.current_chat_messages()
.iter()
.find(|m| m.id == msg_id)
.map(|m| m.can_be_deleted_for_all_users)
@@ -394,7 +394,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
Ok(Ok(_)) => {
// Удаляем из локального списка
app.td_client
.current_chat_messages
.current_chat_messages_mut()
.retain(|m| m.id != msg_id);
// Сбрасываем состояние
app.chat_state = crate::app::ChatState::Normal;
@@ -576,7 +576,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
// Обновляем сообщение в списке
if let Some(msg) = app
.td_client
.current_chat_messages
.current_chat_messages_mut()
.iter_mut()
.find(|m| m.id == msg_id)
{
@@ -602,7 +602,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
};
// Создаём ReplyInfo ДО отправки, пока сообщение точно доступно
let reply_info = app.get_replying_to_message().map(|m| {
crate::tdlib::client::ReplyInfo {
crate::tdlib::ReplyInfo {
message_id: m.id,
sender_name: m.sender_name.clone(),
text: m.content.clone(),
@@ -933,31 +933,29 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
app.message_scroll_offset += 3;
// Проверяем, нужно ли подгрузить старые сообщения
if !app.td_client.current_chat_messages.is_empty() {
if !app.td_client.current_chat_messages().is_empty() {
let oldest_msg_id = app
.td_client
.current_chat_messages
.current_chat_messages()
.first()
.map(|m| m.id)
.unwrap_or(0);
if let Some(chat_id) = app.get_selected_chat_id() {
// Подгружаем больше сообщений если скролл близко к верху
if app.message_scroll_offset
> app.td_client.current_chat_messages.len().saturating_sub(10)
> app.td_client.current_chat_messages().len().saturating_sub(10)
{
if let Ok(Ok(older)) = timeout(
Duration::from_secs(3),
app.td_client
.load_older_messages(chat_id, oldest_msg_id, 20),
.load_older_messages(chat_id, oldest_msg_id),
)
.await
{
if !older.is_empty() {
// Добавляем старые сообщения в начало
let mut new_messages = older;
new_messages
.extend(app.td_client.current_chat_messages.drain(..));
app.td_client.current_chat_messages = new_messages;
let msgs = app.td_client.current_chat_messages_mut();
msgs.splice(0..0, older);
}
}
}
@@ -984,7 +982,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
app.selected_folder_id = None;
} else {
// 2, 3, 4... = папки из TDLib
if let Some(folder) = app.td_client.folders.get(folder_num - 1) {
if let Some(folder) = app.td_client.folders().get(folder_num - 1) {
let folder_id = folder.id;
app.selected_folder_id = Some(folder_id);
// Загружаем чаты папки
@@ -1035,7 +1033,7 @@ fn copy_to_clipboard(text: &str) -> Result<(), String> {
}
/// Форматирует сообщение для копирования с контекстом
fn format_message_for_clipboard(msg: &crate::tdlib::client::MessageInfo) -> String {
fn format_message_for_clipboard(msg: &crate::tdlib::MessageInfo) -> String {
let mut result = String::new();
// Добавляем forward контекст если есть

View File

@@ -21,7 +21,7 @@ use tdlib_rs::enums::Update;
use app::{App, AppScreen};
use constants::{POLL_TIMEOUT_MS, SHUTDOWN_TIMEOUT_SECS};
use input::{handle_auth_input, handle_main_input};
use tdlib::client::AuthState;
use tdlib::AuthState;
use utils::disable_tdlib_logs;
#[tokio::main]
@@ -127,12 +127,12 @@ async fn run_app<B: ratatui::backend::Backend>(
}
// Обрабатываем очередь сообщений для отметки как прочитанных
if !app.td_client.pending_view_messages.is_empty() {
if !app.td_client.pending_view_messages().is_empty() {
app.td_client.process_pending_view_messages().await;
}
// Обрабатываем очередь user_id для загрузки имён
if !app.td_client.pending_user_ids.is_empty() {
if !app.td_client.pending_user_ids().is_empty() {
app.td_client.process_pending_user_ids().await;
}
@@ -199,7 +199,7 @@ async fn update_screen_state(app: &mut App) -> bool {
let prev_error = app.error_message.clone();
let prev_chats_len = app.chats.len();
match &app.td_client.auth_state {
match &app.td_client.auth_state() {
AuthState::WaitTdlibParameters => {
app.screen = AppScreen::Loading;
app.status_message = Some("Инициализация TDLib...".to_string());
@@ -219,8 +219,8 @@ async fn update_screen_state(app: &mut App) -> bool {
}
// Синхронизируем чаты из td_client в app
if !app.td_client.chats.is_empty() {
app.chats = app.td_client.chats.clone();
if !app.td_client.chats().is_empty() {
app.chats = app.td_client.chats().to_vec();
if app.chat_list_state.selected().is_none() && !app.chats.is_empty() {
app.chat_list_state.select(Some(0));
}

72
src/tdlib/auth.rs Normal file
View File

@@ -0,0 +1,72 @@
use tdlib_rs::enums::{AuthorizationState, Update};
use tdlib_rs::functions;
#[derive(Debug, Clone, PartialEq)]
#[allow(dead_code)]
pub enum AuthState {
WaitTdlibParameters,
WaitPhoneNumber,
WaitCode,
WaitPassword,
Ready,
Closed,
Error(String),
}
/// Менеджер авторизации TDLib
pub struct AuthManager {
pub state: AuthState,
client_id: i32,
}
impl AuthManager {
pub fn new(client_id: i32) -> Self {
Self {
state: AuthState::WaitTdlibParameters,
client_id,
}
}
pub fn is_authenticated(&self) -> bool {
self.state == AuthState::Ready
}
/// Обработать обновление авторизации
pub fn handle_auth_update(&mut self, update: &Update) {
if let Update::AuthorizationState(auth_update) = update {
self.state = match &auth_update.authorization_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,
_ => return,
};
}
}
/// Отправить номер телефона
pub async fn send_phone_number(&self, phone: String) -> Result<(), String> {
functions::set_authentication_phone_number(phone, None, self.client_id)
.await
.map(|_| ())
.map_err(|e| format!("Ошибка отправки номера: {:?}", e))
}
/// Отправить код подтверждения
pub async fn send_code(&self, code: String) -> Result<(), String> {
functions::check_authentication_code(code, self.client_id)
.await
.map(|_| ())
.map_err(|e| format!("Ошибка проверки кода: {:?}", e))
}
/// Отправить пароль 2FA
pub async fn send_password(&self, password: String) -> Result<(), String> {
functions::check_authentication_password(password, self.client_id)
.await
.map(|_| ())
.map_err(|e| format!("Ошибка проверки пароля: {:?}", e))
}
}

211
src/tdlib/chats.rs Normal file
View File

@@ -0,0 +1,211 @@
use crate::constants::TDLIB_CHAT_LIMIT;
use std::time::Instant;
use tdlib_rs::enums::{ChatAction, ChatList, ChatType};
use tdlib_rs::functions;
use super::types::{ChatInfo, FolderInfo, MessageInfo, ProfileInfo};
/// Менеджер чатов
pub struct ChatManager {
pub chats: Vec<ChatInfo>,
pub folders: Vec<FolderInfo>,
pub main_chat_list_position: i32,
/// Typing status для текущего чата: (user_id, action_text, timestamp)
pub typing_status: Option<(i64, String, Instant)>,
client_id: i32,
}
impl ChatManager {
pub fn new(client_id: i32) -> Self {
Self {
chats: Vec::new(),
folders: Vec::new(),
main_chat_list_position: 0,
typing_status: None,
client_id,
}
}
/// Загрузить чаты из основного списка
pub async fn load_chats(&mut self, limit: i32) -> Result<(), String> {
let result = functions::load_chats(Some(ChatList::Main), limit, self.client_id).await;
match result {
Ok(_) => Ok(()),
Err(e) => Err(format!("Ошибка загрузки чатов: {:?}", e)),
}
}
/// Загрузить чаты из папки
pub async fn load_folder_chats(&mut self, folder_id: i32, limit: i32) -> Result<(), String> {
let chat_list =
ChatList::Folder(tdlib_rs::types::ChatListFolder { chat_folder_id: folder_id });
let result = functions::load_chats(Some(chat_list), limit, self.client_id).await;
match result {
Ok(_) => Ok(()),
Err(e) => Err(format!("Ошибка загрузки папки: {:?}", e)),
}
}
/// Покинуть чат/группу
pub async fn leave_chat(&self, chat_id: i64) -> Result<(), String> {
let result = functions::leave_chat(chat_id, self.client_id).await;
match result {
Ok(_) => Ok(()),
Err(e) => Err(format!("Ошибка выхода из чата: {:?}", e)),
}
}
/// Получить информацию профиля чата
pub async fn get_profile_info(&self, chat_id: i64) -> Result<ProfileInfo, String> {
// Получаем основную информацию о чате
let chat_result = functions::get_chat(chat_id, self.client_id).await;
let chat_enum = match chat_result {
Ok(c) => c,
Err(e) => return Err(format!("Ошибка получения чата: {:?}", e)),
};
let chat = match chat_enum {
tdlib_rs::enums::Chat::Chat(c) => c,
_ => return Err("Неожиданный тип чата".to_string()),
};
let chat_type_str = match &chat.r#type {
ChatType::Private(_) => "Личный чат",
ChatType::Supergroup(sg) => {
if sg.is_channel {
"Канал"
} else {
"Группа"
}
}
ChatType::BasicGroup(_) => "Группа",
ChatType::Secret(_) => "Секретный чат",
};
let is_group = matches!(
&chat.r#type,
ChatType::Supergroup(_) | ChatType::BasicGroup(_)
);
// Для личных чатов получаем информацию о пользователе
let (bio, phone_number, username, online_status) = if let ChatType::Private(private_chat) =
&chat.r#type
{
match functions::get_user(private_chat.user_id, self.client_id).await {
Ok(tdlib_rs::enums::User::User(user)) => {
let bio_opt = if let Ok(tdlib_rs::enums::UserFullInfo::UserFullInfo(full_info)) =
functions::get_user_full_info(private_chat.user_id, self.client_id).await
{
full_info.bio.map(|b| b.text)
} else {
None
};
let online_status_str = match user.status {
tdlib_rs::enums::UserStatus::Online(_) => Some("В сети".to_string()),
tdlib_rs::enums::UserStatus::Recently(_) => {
Some("Был(а) недавно".to_string())
}
tdlib_rs::enums::UserStatus::LastWeek(_) => {
Some("Был(а) на этой неделе".to_string())
}
tdlib_rs::enums::UserStatus::LastMonth(_) => {
Some("Был(а) в этом месяце".to_string())
}
tdlib_rs::enums::UserStatus::Offline(s) => {
// Форматируем время последнего визита
Some(format!("Был(а) в сети {}", s.was_online))
}
_ => None,
};
let username_opt = user
.usernames
.as_ref()
.map(|u| u.editable_username.clone());
(bio_opt, Some(user.phone_number.clone()), username_opt, online_status_str)
}
_ => (None, None, None, None),
}
} else {
(None, None, None, None)
};
// Для групп/каналов получаем полную информацию
let (member_count, description, invite_link) = if is_group {
if let ChatType::Supergroup(sg) = &chat.r#type {
match functions::get_supergroup_full_info(sg.supergroup_id, self.client_id).await {
Ok(tdlib_rs::enums::SupergroupFullInfo::SupergroupFullInfo(full_info)) => {
let desc = if !full_info.description.is_empty() {
Some(full_info.description.clone())
} else {
None
};
let link = full_info.invite_link.as_ref().map(|l| l.invite_link.clone());
(Some(full_info.member_count), desc, link)
}
_ => (None, None, None),
}
} else if let ChatType::BasicGroup(bg) = &chat.r#type {
match functions::get_basic_group_full_info(bg.basic_group_id, self.client_id).await
{
Ok(tdlib_rs::enums::BasicGroupFullInfo::BasicGroupFullInfo(full_info)) => {
let desc = if !full_info.description.is_empty() {
Some(full_info.description.clone())
} else {
None
};
let link = full_info.invite_link.map(|l| l.invite_link);
(Some(full_info.members.len() as i32), desc, link)
}
Err(_) => (None, None, None),
}
} else {
(None, None, None)
}
} else {
(None, None, None)
};
Ok(ProfileInfo {
chat_id,
title: chat.title,
username,
bio,
phone_number,
chat_type: chat_type_str.to_string(),
member_count,
description,
invite_link,
is_group,
online_status,
})
}
/// Отправить typing action
pub async fn send_chat_action(&self, chat_id: i64, action: ChatAction) {
let _ = functions::send_chat_action(chat_id, 0, Some(action), self.client_id).await;
}
/// Очистить устаревший typing status (вызывать периодически)
pub fn clear_stale_typing_status(&mut self) -> bool {
if let Some((_, _, timestamp)) = self.typing_status {
if timestamp.elapsed().as_secs() > 5 {
self.typing_status = None;
return true;
}
}
false
}
/// Получить текст typing индикатора
pub fn get_typing_text(&self) -> Option<String> {
self.typing_status
.as_ref()
.map(|(_, action, _)| action.clone())
}
}

File diff suppressed because it is too large Load Diff

2036
src/tdlib/client.rs.backup Normal file

File diff suppressed because it is too large Load Diff

2036
src/tdlib/client.rs.old Normal file

File diff suppressed because it is too large Load Diff

545
src/tdlib/messages.rs Normal file
View File

@@ -0,0 +1,545 @@
use crate::constants::{MAX_MESSAGES_IN_CHAT, TDLIB_MESSAGE_LIMIT};
use tdlib_rs::enums::{ChatAction, InputMessageContent, InputMessageReplyTo, MessageContent, MessageSender, SearchMessagesFilter, TextParseMode};
use tdlib_rs::functions;
use tdlib_rs::types::{Chat as TdChat, FormattedText, InputMessageReplyToMessage, InputMessageText, Message as TdMessage, TextEntity, TextParseModeMarkdown};
use super::types::{ForwardInfo, MessageInfo, ReactionInfo, ReplyInfo};
/// Менеджер сообщений
pub struct MessageManager {
pub current_chat_messages: Vec<MessageInfo>,
pub current_chat_id: Option<i64>,
pub current_pinned_message: Option<MessageInfo>,
/// Очередь сообщений для отметки как прочитанных: (chat_id, message_ids)
pub pending_view_messages: Vec<(i64, Vec<i64>)>,
client_id: i32,
}
impl MessageManager {
pub fn new(client_id: i32) -> Self {
Self {
current_chat_messages: Vec::new(),
current_chat_id: None,
current_pinned_message: None,
pending_view_messages: Vec::new(),
client_id,
}
}
/// Добавить сообщение в список текущего чата
pub fn push_message(&mut self, msg: MessageInfo) {
self.current_chat_messages.insert(0, msg);
// Ограничиваем размер списка
if self.current_chat_messages.len() > MAX_MESSAGES_IN_CHAT {
self.current_chat_messages.truncate(MAX_MESSAGES_IN_CHAT);
}
}
/// Получить историю чата
pub async fn get_chat_history(
&mut self,
chat_id: i64,
limit: i32,
) -> Result<Vec<MessageInfo>, String> {
// Устанавливаем текущий чат для получения новых сообщений
self.current_chat_id = Some(chat_id);
let result = functions::get_chat_history(
chat_id,
0, // from_message_id
0, // offset
limit,
false,
self.client_id,
)
.await;
match result {
Ok(tdlib_rs::enums::Messages::Messages(messages_obj)) => {
let mut messages = Vec::new();
for msg_opt in messages_obj.messages.iter().rev() {
if let Some(msg) = msg_opt {
if let Some(info) = self.convert_message(msg).await {
messages.push(info);
}
}
}
Ok(messages)
}
Ok(_) => Err("Неожиданный тип сообщений".to_string()),
Err(e) => Err(format!("Ошибка загрузки истории: {:?}", e)),
}
}
/// Загрузить более старые сообщения
pub async fn load_older_messages(
&mut self,
chat_id: i64,
from_message_id: i64,
) -> Result<Vec<MessageInfo>, String> {
let result = functions::get_chat_history(
chat_id,
from_message_id,
0, // offset
TDLIB_MESSAGE_LIMIT,
false,
self.client_id,
)
.await;
match result {
Ok(tdlib_rs::enums::Messages::Messages(messages_obj)) => {
let mut messages = Vec::new();
for msg_opt in messages_obj.messages.iter().rev() {
if let Some(msg) = msg_opt {
if let Some(info) = self.convert_message(msg).await {
messages.push(info);
}
}
}
Ok(messages)
}
Ok(_) => Err("Неожиданный тип сообщений".to_string()),
Err(e) => Err(format!("Ошибка загрузки старых сообщений: {:?}", e)),
}
}
/// Получить закреплённые сообщения
pub async fn get_pinned_messages(&mut self, chat_id: i64) -> Result<Vec<MessageInfo>, String> {
let result = functions::search_chat_messages(
chat_id,
String::new(),
None,
0, // from_message_id
0, // offset
100, // limit
Some(SearchMessagesFilter::Pinned),
0, // message_thread_id
0, // saved_messages_topic_id
self.client_id,
)
.await;
match result {
Ok(tdlib_rs::enums::FoundChatMessages::FoundChatMessages(messages_obj)) => {
let mut pinned_messages = Vec::new();
for msg in messages_obj.messages.iter().rev() {
if let Some(info) = self.convert_message(msg).await {
pinned_messages.push(info);
}
}
Ok(pinned_messages)
}
Ok(_) => Err("Неожиданный тип результата поиска".to_string()),
Err(e) => Err(format!("Ошибка загрузки закреплённых: {:?}", e)),
}
}
/// Загрузить текущее закреплённое сообщение
pub async fn load_current_pinned_message(&mut self, chat_id: i64) {
// TODO: В tdlib-rs 1.8.29 поле pinned_message_id было удалено из Chat.
// Нужно использовать getChatPinnedMessage или альтернативный способ.
// Временно отключено.
let _ = chat_id;
self.current_pinned_message = None;
// match functions::get_chat(chat_id, self.client_id).await {
// Ok(tdlib_rs::enums::Chat::Chat(chat)) => {
// // chat.pinned_message_id больше не существует
// }
// _ => {}
// }
}
/// Поиск сообщений в чате
pub async fn search_messages(
&self,
chat_id: i64,
query: &str,
) -> Result<Vec<MessageInfo>, String> {
let result = functions::search_chat_messages(
chat_id,
query.to_string(),
None,
0, // from_message_id
0, // offset
100, // limit
None,
0, // message_thread_id
0, // saved_messages_topic_id
self.client_id,
)
.await;
match result {
Ok(tdlib_rs::enums::FoundChatMessages::FoundChatMessages(messages_obj)) => {
let mut search_results = Vec::new();
for msg in messages_obj.messages.iter().rev() {
if let Some(info) = self.convert_message(msg).await {
search_results.push(info);
}
}
Ok(search_results)
}
Ok(_) => Err("Неожиданный тип результата поиска".to_string()),
Err(e) => Err(format!("Ошибка поиска: {:?}", e)),
}
}
/// Отправить сообщение
pub async fn send_message(
&self,
chat_id: i64,
text: String,
reply_to_message_id: Option<i64>,
_reply_info: Option<ReplyInfo>,
) -> Result<MessageInfo, String> {
// Парсим markdown в тексте
let formatted_text = match functions::parse_text_entities(
text.clone(),
TextParseMode::Markdown(TextParseModeMarkdown { version: 2 }),
self.client_id,
)
.await
{
Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => {
FormattedText {
text: ft.text,
entities: ft.entities,
}
}
Err(_) => FormattedText {
text: text.clone(),
entities: vec![],
},
};
let content = InputMessageContent::InputMessageText(InputMessageText {
text: formatted_text,
link_preview_options: None,
clear_draft: true,
});
let reply_to = reply_to_message_id.map(|msg_id| {
InputMessageReplyTo::Message(InputMessageReplyToMessage {
chat_id: 0,
message_id: msg_id,
quote: None,
})
});
let result = functions::send_message(
chat_id,
0, // message_thread_id
reply_to,
None, // options
content,
self.client_id,
)
.await;
match result {
Ok(tdlib_rs::enums::Message::Message(msg)) => self
.convert_message(&msg)
.await
.ok_or_else(|| "Не удалось конвертировать сообщение".to_string()),
Ok(_) => Err("Неожиданный тип сообщения".to_string()),
Err(e) => Err(format!("Ошибка отправки сообщения: {:?}", e)),
}
}
/// Редактировать сообщение
pub async fn edit_message(
&self,
chat_id: i64,
message_id: i64,
text: String,
) -> Result<MessageInfo, String> {
let formatted_text = match functions::parse_text_entities(
text.clone(),
TextParseMode::Markdown(TextParseModeMarkdown { version: 2 }),
self.client_id,
)
.await
{
Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => {
FormattedText {
text: ft.text,
entities: ft.entities,
}
}
Err(_) => FormattedText {
text: text.clone(),
entities: vec![],
},
};
let content = InputMessageContent::InputMessageText(InputMessageText {
text: formatted_text,
link_preview_options: None,
clear_draft: true,
});
let result =
functions::edit_message_text(chat_id, message_id, content, self.client_id).await;
match result {
Ok(tdlib_rs::enums::Message::Message(msg)) => self
.convert_message(&msg)
.await
.ok_or_else(|| "Не удалось конвертировать отредактированное сообщение".to_string()),
Ok(_) => Err("Неожиданный тип сообщения".to_string()),
Err(e) => Err(format!("Ошибка редактирования: {:?}", e)),
}
}
/// Удалить сообщения
pub async fn delete_messages(
&self,
chat_id: i64,
message_ids: Vec<i64>,
revoke: bool,
) -> Result<(), String> {
let result =
functions::delete_messages(chat_id, message_ids, revoke, self.client_id).await;
match result {
Ok(_) => Ok(()),
Err(e) => Err(format!("Ошибка удаления: {:?}", e)),
}
}
/// Переслать сообщения
pub async fn forward_messages(
&self,
to_chat_id: i64,
from_chat_id: i64,
message_ids: Vec<i64>,
) -> Result<(), String> {
let result = functions::forward_messages(
to_chat_id,
0, // message_thread_id
from_chat_id,
message_ids,
None, // options
false, // send_copy
false, // remove_caption
self.client_id,
)
.await;
match result {
Ok(_) => Ok(()),
Err(e) => Err(format!("Ошибка пересылки: {:?}", e)),
}
}
/// Установить черновик
pub async fn set_draft_message(&self, chat_id: i64, text: String) -> Result<(), String> {
use tdlib_rs::types::DraftMessage;
let draft = if text.is_empty() {
None
} else {
Some(DraftMessage {
reply_to: None,
date: 0,
input_message_text: InputMessageContent::InputMessageText(InputMessageText {
text: FormattedText {
text: text.clone(),
entities: vec![],
},
link_preview_options: None,
clear_draft: false,
}),
})
};
let result = functions::set_chat_draft_message(chat_id, 0, draft, self.client_id).await;
match result {
Ok(_) => Ok(()),
Err(e) => Err(format!("Ошибка сохранения черновика: {:?}", e)),
}
}
/// Обработать очередь просмотра сообщений
pub async fn process_pending_view_messages(&mut self) {
if self.pending_view_messages.is_empty() {
return;
}
let batch = std::mem::take(&mut self.pending_view_messages);
for (chat_id, message_ids) in batch {
let _ = functions::view_messages(chat_id, message_ids, None, true, self.client_id).await;
}
}
/// Конвертировать TdMessage в MessageInfo
async fn convert_message(&self, msg: &TdMessage) -> Option<MessageInfo> {
let content_text = match &msg.content {
MessageContent::MessageText(t) => t.text.text.clone(),
MessageContent::MessagePhoto(p) => {
let caption_text = p.caption.text.clone();
if caption_text.is_empty() { "[Фото]".to_string() } else { caption_text }
}
MessageContent::MessageVideo(v) => {
let caption_text = v.caption.text.clone();
if caption_text.is_empty() { "[Видео]".to_string() } else { caption_text }
}
MessageContent::MessageDocument(d) => {
let caption_text = d.caption.text.clone();
if caption_text.is_empty() { format!("[Файл: {}]", d.document.file_name) } else { caption_text }
}
MessageContent::MessageSticker(s) => {
format!("[Стикер: {}]", s.sticker.emoji)
}
MessageContent::MessageAnimation(a) => {
let caption_text = a.caption.text.clone();
if caption_text.is_empty() { "[GIF]".to_string() } else { caption_text }
}
MessageContent::MessageVoiceNote(v) => {
let caption_text = v.caption.text.clone();
if caption_text.is_empty() { "[Голосовое]".to_string() } else { caption_text }
}
MessageContent::MessageAudio(a) => {
let caption_text = a.caption.text.clone();
if caption_text.is_empty() {
let title = a.audio.title.clone();
let performer = a.audio.performer.clone();
if !title.is_empty() || !performer.is_empty() {
format!("[Аудио: {} - {}]", performer, title)
} else {
"[Аудио]".to_string()
}
} else {
caption_text
}
}
_ => "[Неподдерживаемый тип сообщения]".to_string(),
};
let entities = if let MessageContent::MessageText(t) = &msg.content {
t.text.entities.clone()
} else {
vec![]
};
let sender_name = match &msg.sender_id {
MessageSender::User(user) => {
match functions::get_user(user.user_id, self.client_id).await {
Ok(tdlib_rs::enums::User::User(u)) => format!("{} {}", u.first_name, u.last_name).trim().to_string(),
_ => format!("User {}", user.user_id),
}
}
MessageSender::Chat(chat) => format!("Chat {}", chat.chat_id),
};
let forward_from = msg.forward_info.as_ref().and_then(|fi| {
if let tdlib_rs::enums::MessageOrigin::User(origin_user) = &fi.origin {
Some(ForwardInfo {
sender_name: format!("User {}", origin_user.sender_user_id),
date: fi.date,
})
} else {
None
}
});
let reply_to = if let Some(ref reply_to) = msg.reply_to {
if let tdlib_rs::enums::MessageReplyTo::Message(reply_msg) = reply_to {
// Здесь можно загрузить информацию об оригинальном сообщении
Some(ReplyInfo {
message_id: reply_msg.message_id,
sender_name: "Unknown".to_string(),
text: "...".to_string(),
})
} else {
None
}
} else {
None
};
let reactions: Vec<ReactionInfo> = msg
.interaction_info
.as_ref()
.and_then(|ii| ii.reactions.as_ref())
.map(|reactions| {
reactions
.reactions
.iter()
.filter_map(|r| {
if let tdlib_rs::enums::ReactionType::Emoji(emoji_type) = &r.r#type {
Some(ReactionInfo {
emoji: emoji_type.emoji.clone(),
count: r.total_count,
is_chosen: r.is_chosen,
})
} else {
None
}
})
.collect()
})
.unwrap_or_default();
Some(MessageInfo {
id: msg.id,
sender_name,
is_outgoing: msg.is_outgoing,
content: content_text,
entities,
date: msg.date,
edit_date: msg.edit_date,
is_read: !msg.contains_unread_mention,
can_be_edited: msg.can_be_edited,
can_be_deleted_only_for_self: msg.can_be_deleted_only_for_self,
can_be_deleted_for_all_users: msg.can_be_deleted_for_all_users,
reply_to,
forward_from,
reactions,
})
}
/// Получить недостающую reply информацию для сообщений
pub async fn fetch_missing_reply_info(&mut self) {
// Collect message IDs that need to be fetched
let mut to_fetch = Vec::new();
for msg in &self.current_chat_messages {
if let Some(ref reply) = msg.reply_to {
if reply.sender_name == "Unknown" {
to_fetch.push(reply.message_id);
}
}
}
// Fetch missing messages
if let Some(chat_id) = self.current_chat_id {
for message_id in to_fetch {
if let Ok(original_msg_enum) =
functions::get_message(chat_id, message_id, self.client_id).await
{
if let tdlib_rs::enums::Message::Message(original_msg) = original_msg_enum {
if let Some(orig_info) = self.convert_message(&original_msg).await {
// Update the reply info
for msg in &mut self.current_chat_messages {
if let Some(ref mut reply) = msg.reply_to {
if reply.message_id == message_id {
reply.sender_name = orig_info.sender_name.clone();
reply.text = orig_info
.content
.chars()
.take(50)
.collect::<String>();
}
}
}
}
}
}
}
}
}
}

View File

@@ -1,13 +1,19 @@
// Модули
pub mod auth;
pub mod chats;
pub mod client;
pub mod messages;
pub mod reactions;
pub mod types;
pub mod users;
pub use client::ChatInfo;
pub use client::FolderInfo;
pub use client::ForwardInfo;
pub use client::MessageInfo;
pub use client::NetworkState;
pub use client::ProfileInfo;
pub use client::ReactionInfo;
pub use client::ReplyInfo;
// Экспорт основных типов
pub use auth::AuthState;
pub use client::TdClient;
pub use client::UserOnlineStatus;
pub use types::{
ChatInfo, FolderInfo, ForwardInfo, MessageInfo, NetworkState, ProfileInfo, ReactionInfo,
ReplyInfo, UserOnlineStatus,
};
// Re-export ChatAction для удобства
pub use tdlib_rs::enums::ChatAction;

126
src/tdlib/reactions.rs Normal file
View File

@@ -0,0 +1,126 @@
use tdlib_rs::enums::ReactionType;
use tdlib_rs::functions;
use tdlib_rs::types::ReactionTypeEmoji;
/// Менеджер реакций на сообщения
pub struct ReactionManager {
client_id: i32,
}
impl ReactionManager {
pub fn new(client_id: i32) -> Self {
Self { client_id }
}
/// Получить доступные реакции для сообщения
pub async fn get_message_available_reactions(
&self,
chat_id: i64,
message_id: i64,
) -> Result<Vec<String>, String> {
// Получаем сообщение
let msg_result = functions::get_message(chat_id, message_id, self.client_id).await;
let msg = match msg_result {
Ok(m) => m,
Err(e) => return Err(format!("Ошибка получения сообщения: {:?}", e)),
};
// Получаем доступные реакции для чата
let reactions_result = functions::get_message_available_reactions(
chat_id,
message_id,
10, // row_size
self.client_id,
)
.await;
match reactions_result {
Ok(_available) => {
// TODO: В tdlib-rs 1.8.29 структура AvailableReactions изменилась
// Временно используем fallback на стандартные реакции
let emojis: Vec<String> = Vec::new();
// let emojis: Vec<String> = if let tdlib_rs::enums::AvailableReactions::AvailableReactions(ar) = available {
// ar.top_reactions.iter().filter_map(...).collect()
// } else {
// Vec::new()
// };
if emojis.is_empty() {
// Фолбек на стандартные реакции
Ok(vec![
"👍".to_string(),
"👎".to_string(),
"❤️".to_string(),
"🔥".to_string(),
"😊".to_string(),
"😢".to_string(),
"😮".to_string(),
"🎉".to_string(),
"🤔".to_string(),
"😡".to_string(),
"😎".to_string(),
"🤝".to_string(),
])
} else {
Ok(emojis)
}
}
Err(_) => {
// В случае ошибки возвращаем стандартный набор
Ok(vec![
"👍".to_string(),
"👎".to_string(),
"❤️".to_string(),
"🔥".to_string(),
"😊".to_string(),
"😢".to_string(),
"😮".to_string(),
"🎉".to_string(),
"🤔".to_string(),
"😡".to_string(),
"😎".to_string(),
"🤝".to_string(),
])
}
}
}
/// Переключить реакцию на сообщение
pub async fn toggle_reaction(
&self,
chat_id: i64,
message_id: i64,
emoji: String,
) -> Result<(), String> {
let reaction = ReactionType::Emoji(ReactionTypeEmoji { emoji });
let result = functions::add_message_reaction(
chat_id,
message_id,
reaction.clone(),
false, // is_big
false, // update_recent_reactions
self.client_id,
)
.await;
match result {
Ok(_) => Ok(()),
Err(_) => {
// Если добавление не удалось, пытаемся удалить
let remove_result = functions::remove_message_reaction(
chat_id,
message_id,
reaction,
self.client_id,
)
.await;
match remove_result {
Ok(_) => Ok(()),
Err(e) => Err(format!("Ошибка переключения реакции: {:?}", e)),
}
}
}
}
}

136
src/tdlib/types.rs Normal file
View File

@@ -0,0 +1,136 @@
use tdlib_rs::types::TextEntity;
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct ChatInfo {
pub id: i64,
pub title: String,
pub username: Option<String>,
pub last_message: String,
pub last_message_date: i32,
pub unread_count: i32,
/// Количество непрочитанных упоминаний (@)
pub unread_mention_count: i32,
pub is_pinned: bool,
pub order: i64,
/// ID последнего прочитанного исходящего сообщения (для галочек)
pub last_read_outbox_message_id: i64,
/// ID папок, в которых находится чат
pub folder_ids: Vec<i32>,
/// Чат замьючен (уведомления отключены)
pub is_muted: bool,
/// Черновик сообщения
pub draft_text: Option<String>,
}
/// Информация о сообщении, на которое отвечают
#[derive(Debug, Clone)]
pub struct ReplyInfo {
/// ID сообщения, на которое отвечают
pub message_id: i64,
/// Имя отправителя оригинального сообщения
pub sender_name: String,
/// Текст оригинального сообщения (превью)
pub text: String,
}
/// Информация о пересланном сообщении
#[derive(Debug, Clone)]
pub struct ForwardInfo {
/// Имя оригинального отправителя
pub sender_name: String,
/// Дата оригинального сообщения (для будущего использования)
#[allow(dead_code)]
pub date: i32,
}
/// Информация о реакции на сообщение
#[derive(Debug, Clone)]
pub struct ReactionInfo {
/// Эмодзи реакции (например, "👍")
pub emoji: String,
/// Количество людей, поставивших эту реакцию
pub count: i32,
/// Поставил ли текущий пользователь эту реакцию
pub is_chosen: bool,
}
#[derive(Debug, Clone)]
pub struct MessageInfo {
pub id: i64,
pub sender_name: String,
pub is_outgoing: bool,
pub content: String,
/// Сущности форматирования (bold, italic, code и т.д.)
pub entities: Vec<TextEntity>,
pub date: i32,
/// Дата редактирования (0 если не редактировалось)
pub edit_date: i32,
pub is_read: bool,
/// Можно ли редактировать сообщение
pub can_be_edited: bool,
/// Можно ли удалить только для себя
pub can_be_deleted_only_for_self: bool,
/// Можно ли удалить для всех
pub can_be_deleted_for_all_users: bool,
/// Информация о reply (если это ответ на сообщение)
pub reply_to: Option<ReplyInfo>,
/// Информация о forward (если сообщение переслано)
pub forward_from: Option<ForwardInfo>,
/// Реакции на сообщение
pub reactions: Vec<ReactionInfo>,
}
#[derive(Debug, Clone)]
pub struct FolderInfo {
pub id: i32,
pub name: String,
}
/// Информация о профиле чата/пользователя
#[derive(Debug, Clone)]
pub struct ProfileInfo {
pub chat_id: i64,
pub title: String,
pub username: Option<String>,
pub bio: Option<String>,
pub phone_number: Option<String>,
pub chat_type: String, // "Личный чат", "Группа", "Канал"
pub member_count: Option<i32>,
pub description: Option<String>,
pub invite_link: Option<String>,
pub is_group: bool,
pub online_status: Option<String>,
}
/// Состояние сетевого соединения
#[derive(Debug, Clone, PartialEq)]
pub enum NetworkState {
/// Ожидание подключения к сети
WaitingForNetwork,
/// Подключение к прокси
ConnectingToProxy,
/// Подключение к серверам Telegram
Connecting,
/// Обновление данных
Updating,
/// Подключено
Ready,
}
/// Онлайн-статус пользователя
#[derive(Debug, Clone, PartialEq)]
pub enum UserOnlineStatus {
/// Онлайн
Online,
/// Был недавно (менее часа назад)
Recently,
/// Был на этой неделе
LastWeek,
/// Был в этом месяце
LastMonth,
/// Давно не был
LongTimeAgo,
/// Оффлайн с указанием времени (unix timestamp)
Offline(i32),
}

205
src/tdlib/users.rs Normal file
View File

@@ -0,0 +1,205 @@
use crate::constants::{LAZY_LOAD_USERS_PER_TICK, MAX_CHAT_USER_IDS, MAX_USER_CACHE_SIZE};
use std::collections::HashMap;
use tdlib_rs::enums::{User, UserStatus};
use tdlib_rs::functions;
use super::types::UserOnlineStatus;
/// Простой LRU-кэш на основе HashMap + Vec для отслеживания порядка
pub struct LruCache<V> {
map: HashMap<i64, V>,
/// Порядок доступа: последний элемент — самый недавно использованный
order: Vec<i64>,
capacity: usize,
}
impl<V: Clone> LruCache<V> {
pub fn new(capacity: usize) -> Self {
Self {
map: HashMap::with_capacity(capacity),
order: Vec::with_capacity(capacity),
capacity,
}
}
/// Получить значение и обновить порядок доступа
pub fn get(&mut self, key: &i64) -> Option<&V> {
if self.map.contains_key(key) {
// Перемещаем ключ в конец (самый недавно использованный)
self.order.retain(|k| k != key);
self.order.push(*key);
self.map.get(key)
} else {
None
}
}
/// Получить значение без обновления порядка (для read-only доступа)
pub fn peek(&self, key: &i64) -> Option<&V> {
self.map.get(key)
}
/// Вставить значение
pub fn insert(&mut self, key: i64, value: V) {
if self.map.contains_key(&key) {
// Обновляем существующее значение
self.map.insert(key, value);
self.order.retain(|k| *k != key);
self.order.push(key);
} else {
// Если кэш полон, удаляем самый старый элемент
if self.map.len() >= self.capacity {
if let Some(oldest) = self.order.first().copied() {
self.order.remove(0);
self.map.remove(&oldest);
}
}
self.map.insert(key, value);
self.order.push(key);
}
}
/// Проверить наличие ключа
pub fn contains_key(&self, key: &i64) -> bool {
self.map.contains_key(key)
}
/// Количество элементов
#[allow(dead_code)]
pub fn len(&self) -> usize {
self.map.len()
}
}
/// Кеш пользователей и их данных
pub struct UserCache {
/// LRU-кэш usernames: user_id -> username
pub user_usernames: LruCache<String>,
/// LRU-кэш имён: user_id -> display_name (first_name + last_name)
pub user_names: LruCache<String>,
/// Связь chat_id -> user_id для приватных чатов
pub chat_user_ids: HashMap<i64, i64>,
/// Очередь user_id для загрузки имён
pub pending_user_ids: Vec<i64>,
/// LRU-кэш онлайн-статусов пользователей: user_id -> status
pub user_statuses: LruCache<UserOnlineStatus>,
client_id: i32,
}
impl UserCache {
pub fn new(client_id: i32) -> Self {
Self {
user_usernames: LruCache::new(MAX_USER_CACHE_SIZE),
user_names: LruCache::new(MAX_USER_CACHE_SIZE),
chat_user_ids: HashMap::with_capacity(MAX_CHAT_USER_IDS),
pending_user_ids: Vec::new(),
user_statuses: LruCache::new(MAX_USER_CACHE_SIZE),
client_id,
}
}
/// Получить username пользователя
pub fn get_username(&mut self, user_id: &i64) -> Option<&String> {
self.user_usernames.get(user_id)
}
/// Получить имя пользователя
pub fn get_name(&mut self, user_id: &i64) -> Option<&String> {
self.user_names.get(user_id)
}
/// Получить user_id по chat_id
pub fn get_user_id_by_chat(&self, chat_id: i64) -> Option<i64> {
self.chat_user_ids.get(&chat_id).copied()
}
/// Получить статус пользователя по chat_id
pub fn get_status_by_chat_id(&self, chat_id: i64) -> Option<&UserOnlineStatus> {
let user_id = self.chat_user_ids.get(&chat_id)?;
self.user_statuses.peek(user_id)
}
/// Обработать обновление пользователя
pub fn handle_user_update(&mut self, user_enum: &User) {
if let User::User(user) = user_enum {
let user_id = user.id;
// Сохраняем username
if let Some(username) = user.usernames.as_ref().map(|u| u.editable_username.clone()) {
self.user_usernames.insert(user_id, username);
}
// Сохраняем имя
let display_name = format!("{} {}", user.first_name, user.last_name).trim().to_string();
self.user_names.insert(user_id, display_name);
// Обновляем статус
self.update_status(user_id, &user.status);
}
}
/// Обработать обновление статуса пользователя
pub fn update_status(&mut self, user_id: i64, status: &UserStatus) {
let online_status = match status {
UserStatus::Online(_) => UserOnlineStatus::Online,
UserStatus::Recently(_) => UserOnlineStatus::Recently,
UserStatus::LastWeek(_) => UserOnlineStatus::LastWeek,
UserStatus::LastMonth(_) => UserOnlineStatus::LastMonth,
UserStatus::Offline(s) => UserOnlineStatus::Offline(s.was_online),
_ => return,
};
self.user_statuses.insert(user_id, online_status);
}
/// Сохранить связь chat_id -> user_id
pub fn register_private_chat(&mut self, chat_id: i64, user_id: i64) {
self.chat_user_ids.insert(chat_id, user_id);
}
/// Получить имя пользователя (асинхронно с загрузкой если нужно)
pub async fn get_user_name(&self, user_id: i64) -> String {
// Сначала пытаемся получить из кэша
if let Some(name) = self.user_names.peek(&user_id) {
return name.clone();
}
// Загружаем пользователя
match functions::get_user(user_id, self.client_id).await {
Ok(User::User(user)) => {
let name = format!("{} {}", user.first_name, user.last_name).trim().to_string();
name
}
_ => format!("User {}", user_id),
}
}
/// Обработать очередь отложенных user_ids (загрузка имён небольшими порциями)
pub async fn process_pending_user_ids(&mut self) {
if self.pending_user_ids.is_empty() {
return;
}
// Берём первые N user_ids для загрузки
let batch: Vec<i64> = self
.pending_user_ids
.drain(..self.pending_user_ids.len().min(LAZY_LOAD_USERS_PER_TICK))
.collect();
for user_id in batch {
if self.user_names.contains_key(&user_id) {
continue; // Уже в кэше
}
match functions::get_user(user_id, self.client_id).await {
Ok(user_enum) => {
self.handle_user_update(&user_enum);
}
Err(_) => {
// Если не удалось загрузить, сохраняем placeholder
self.user_names
.insert(user_id, format!("User {}", user_id));
}
}
}
}
}

View File

@@ -1,5 +1,5 @@
use crate::app::App;
use crate::tdlib::client::AuthState;
use crate::tdlib::AuthState;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
@@ -54,7 +54,7 @@ pub fn render(f: &mut Frame, app: &App) {
f.render_widget(title, auth_chunks[0]);
// Instructions and Input based on auth state
match &app.td_client.auth_state {
match &app.td_client.auth_state() {
AuthState::WaitPhoneNumber => {
let instructions = vec![
Line::from("Введите номер телефона в международном формате"),

View File

@@ -66,7 +66,7 @@ fn render_folders(f: &mut Frame, area: Rect, app: &App) {
spans.push(Span::styled(" 1:All ", all_style));
// Папки из TDLib (клавиши 2, 3, 4...)
for (i, folder) in app.td_client.folders.iter().enumerate() {
for (i, folder) in app.td_client.folders().iter().enumerate() {
spans.push(Span::raw(""));
let style = if app.selected_folder_id == Some(folder.id) {

View File

@@ -353,7 +353,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
let input_height = (input_lines + 2).min(10).max(3);
// Проверяем, есть ли закреплённое сообщение
let has_pinned = app.td_client.current_pinned_message.is_some();
let has_pinned = app.td_client.current_pinned_message().is_some();
let message_chunks = if has_pinned {
Layout::default()
@@ -380,7 +380,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
// Chat header с typing status
let typing_action = app
.td_client
.typing_status
.typing_status()
.as_ref()
.map(|(_, action, _)| action.clone());
let header_line = if let Some(action) = typing_action {
@@ -419,7 +419,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
f.render_widget(header, message_chunks[0]);
// Pinned bar (если есть закреплённое сообщение)
if let Some(pinned_msg) = &app.td_client.current_pinned_message {
if let Some(pinned_msg) = &app.td_client.current_pinned_message() {
let pinned_preview: String = pinned_msg.content.chars().take(40).collect();
let ellipsis = if pinned_msg.content.chars().count() > 40 {
"..."
@@ -458,7 +458,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
// Номер строки, где начинается выбранное сообщение (для автоскролла)
let mut selected_msg_line: Option<usize> = None;
for msg in &app.td_client.current_chat_messages {
for msg in app.td_client.current_chat_messages() {
// Проверяем, выбрано ли это сообщение
let is_selected = selected_msg_id == Some(msg.id);

View File

@@ -1,5 +1,5 @@
use crate::app::App;
use crate::tdlib::client::ProfileInfo;
use crate::tdlib::ProfileInfo;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},

View File

@@ -4,7 +4,7 @@ use ratatui::widgets::ListState;
use std::collections::HashMap;
use tele_tui::app::{App, AppScreen, ChatState};
use tele_tui::config::Config;
use tele_tui::tdlib::client::AuthState;
use tele_tui::tdlib::AuthState;
use tele_tui::tdlib::{ChatInfo, MessageInfo};
/// Builder для создания тестового App
@@ -239,7 +239,7 @@ impl TestAppBuilder {
// Применяем auth state
if let Some(auth_state) = self.auth_state {
app.td_client.auth_state = auth_state;
app.td_client.auth.state = auth_state;
}
// Применяем auth inputs
@@ -263,8 +263,8 @@ impl TestAppBuilder {
// Применяем сообщения к текущему открытому чату
if let Some(chat_id) = self.selected_chat_id {
if let Some(messages) = self.messages.get(&chat_id) {
app.td_client.current_chat_messages = messages.clone();
app.td_client.current_chat_id = Some(chat_id);
app.td_client.message_manager.current_chat_messages = messages.clone();
app.td_client.set_current_chat_id(Some(chat_id));
}
}

View File

@@ -134,7 +134,7 @@ fn snapshot_pinned_message() {
.build();
// Устанавливаем закреплённое сообщение
app.td_client.current_pinned_message = Some(pinned_msg);
app.td_client.set_current_pinned_message(Some(pinned_msg));
let buffer = render_to_buffer(80, 24, |f| {
tele_tui::ui::messages::render(f, f.area(), &app);

View File

@@ -7,7 +7,7 @@ use helpers::snapshot_utils::{buffer_to_string, render_to_buffer};
use helpers::test_data::create_test_chat;
use insta::assert_snapshot;
use tele_tui::app::AppScreen;
use tele_tui::tdlib::client::AuthState;
use tele_tui::tdlib::AuthState;
#[test]
fn snapshot_loading_screen_default() {