From e2971e5ff5459108cdad008d671740816233204e Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Tue, 3 Mar 2026 01:04:55 +0300 Subject: [PATCH 1/3] chore: add symbol_info_budget and language_backend fields to serena config Co-Authored-By: Claude Sonnet 4.6 --- .serena/project.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.serena/project.yml b/.serena/project.yml index 34017e5..28d6c2a 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -108,3 +108,14 @@ 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: [] + +# override of the corresponding setting in serena_config.yml, see the documentation there. +# If null or missing, the value from the global config is used. +symbol_info_budget: + +# The language backend to use for this project. +# If not set, the global setting from serena_config.yml is used. +# Valid values: LSP, JetBrains +# Note: the backend is fixed at startup. If a project with a different backend +# is activated post-init, an error will be returned. +language_backend: -- 2.49.1 From 07a41ff7963597d1bb236d95c02d4a866139c29b Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Tue, 3 Mar 2026 01:15:42 +0300 Subject: [PATCH 2/3] chore: remove unused and outdated files - config.example.toml: duplicate of config.toml.example - REFACTORING_ROADMAP.md, REFACTORING_OPPORTUNITIES.md: refactoring done in Phase 13 - TESTING_PROGRESS.md, TESTING_ROADMAP.md: stale since February, superseded by ROADMAP.md - CHANGELOG.md: never maintained - FAQ.md, CONTRIBUTING.md, SECURITY.md, INSTALL.md: boilerplate for a personal project - .github/: GitHub templates unused (project hosted on Gitea) Co-Authored-By: Claude Sonnet 4.6 --- .github/ISSUE_TEMPLATE/bug_report.md | 40 - .github/ISSUE_TEMPLATE/feature_request.md | 34 - .github/pull_request_template.md | 51 - CHANGELOG.md | 66 -- CONTRIBUTING.md | 125 --- FAQ.md | 227 ----- INSTALL.md | 122 --- REFACTORING_OPPORTUNITIES.md | 855 ---------------- REFACTORING_ROADMAP.md | 1120 --------------------- SECURITY.md | 64 -- TESTING_PROGRESS.md | 571 ----------- TESTING_ROADMAP.md | 620 ------------ config.example.toml | 35 - 13 files changed, 3930 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.md delete mode 100644 .github/ISSUE_TEMPLATE/feature_request.md delete mode 100644 .github/pull_request_template.md delete mode 100644 CHANGELOG.md delete mode 100644 CONTRIBUTING.md delete mode 100644 FAQ.md delete mode 100644 INSTALL.md delete mode 100644 REFACTORING_OPPORTUNITIES.md delete mode 100644 REFACTORING_ROADMAP.md delete mode 100644 SECURITY.md delete mode 100644 TESTING_PROGRESS.md delete mode 100644 TESTING_ROADMAP.md delete mode 100644 config.example.toml diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index e4acc8b..0000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -name: Bug Report -about: Сообщить о проблеме или баге -title: '[BUG] ' -labels: bug -assignees: '' ---- - -## Описание бага -Четкое и краткое описание проблемы. - -## Шаги для воспроизведения -1. Запустить '...' -2. Нажать на '...' -3. Прокрутить вниз до '...' -4. Увидеть ошибку - -## Ожидаемое поведение -Что должно было произойти. - -## Фактическое поведение -Что произошло на самом деле. - -## Скриншоты -Если применимо, добавьте скриншоты для демонстрации проблемы. - -## Окружение -- **ОС**: [например, macOS 14.0, Ubuntu 22.04, Windows 11] -- **Rust версия**: [вывод `rustc --version`] -- **tele-tui версия**: [вывод `cargo pkgid`] -- **Размер терминала**: [например, 100x30] - -## Логи -Если есть логи или сообщения об ошибках, вставьте их сюда: -``` -вставьте логи здесь -``` - -## Дополнительный контекст -Любая другая информация, которая может помочь в решении проблемы. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index c5702cd..0000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -name: Feature Request -about: Предложить новую функцию или улучшение -title: '[FEATURE] ' -labels: enhancement -assignees: '' ---- - -## Связано с проблемой? -Есть ли проблема, которую это решит? Например: "Меня расстраивает, что [...]" - -## Описание решения -Четкое и краткое описание того, что вы хотите. - -## Альтернативы -Какие альтернативные решения или функции вы рассматривали? - -## Примеры использования -Как эта функция будет использоваться? Приведите примеры: - -1. Пользователь делает X -2. Система делает Y -3. Результат: Z - -## Приоритет -- [ ] Критичная функция — без неё приложение малополезно -- [ ] Важная функция — значительно улучшит UX -- [ ] Nice to have — было бы удобно - -## Проверка roadmap -- [ ] Я проверил [ROADMAP.md](../ROADMAP.md) и этой функции там нет - -## Дополнительный контекст -Скриншоты, ссылки на похожие реализации в других приложениях, и т.д. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index caaf515..0000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,51 +0,0 @@ -## Описание - -Краткое описание изменений в этом PR. - -## Тип изменений - -- [ ] Bug fix (исправление бага) -- [ ] New feature (новая функция) -- [ ] Breaking change (изменение, ломающее обратную совместимость) -- [ ] Refactoring (рефакторинг без изменения функциональности) -- [ ] Documentation (изменения в документации) -- [ ] Performance improvement (улучшение производительности) - -## Связанные Issue - -Fixes #(номер issue) - -## Как протестировано? - -Опишите тесты, которые вы провели: - -- [ ] Тест A -- [ ] Тест B -- [ ] Тест C - -## Сценарии тестирования - -Подробные шаги для проверки изменений: - -1. Запустить `cargo run` -2. Сделать X -3. Убедиться, что Y - -## Чеклист - -- [ ] Мой код следует стилю проекта -- [ ] Я запустил `cargo fmt` -- [ ] Я запустил `cargo clippy` и исправил warnings -- [ ] Код компилируется без ошибок (`cargo build`) -- [ ] Я протестировал изменения вручную -- [ ] Я обновил документацию (если необходимо) -- [ ] Я добавил тесты (если применимо) -- [ ] Все существующие тесты проходят - -## Скриншоты (если применимо) - -Добавьте скриншоты для демонстрации UI изменений. - -## Дополнительные заметки - -Любая дополнительная информация для ревьюверов. diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 712a3c5..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,66 +0,0 @@ -# Changelog - -Все значительные изменения в этом проекте будут документированы в этом файле. - -Формат основан на [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -и этот проект придерживается [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -## [0.1.0] - 2024-12-XX - -### Добавлено - -#### Базовая функциональность -- TDLib интеграция с авторизацией (телефон + код + 2FA) -- Отображение списка чатов с поддержкой папок -- Загрузка и отображение истории сообщений -- Отправка текстовых сообщений -- Vim-style навигация (hjkl) с поддержкой русской раскладки (ролд) -- Поиск по чатам (Ctrl+S) -- Поиск внутри чата (Ctrl+F) - -#### Сообщения -- Группировка по дате и отправителю -- Markdown форматирование (жирный, курсив, подчёркивание, зачёркивание, код, спойлеры) -- Редактирование сообщений -- Удаление сообщений с подтверждением -- Reply на сообщения -- Forward сообщений -- Копирование в системный буфер обмена -- Реакции на сообщения с emoji picker - -#### UI/UX -- Индикаторы: онлайн-статус (●), прочитанность (✓/✓✓), редактирование (✎) -- Иконки: 📌 закреплённые чаты, 🔇 замьюченные, @ упоминания -- Typing indicator ("печатает...") -- Закреплённые сообщения -- Профиль пользователя/чата -- Черновики с автосохранением -- Динамический инпут (расширение до 10 строк) -- Блочный курсор с навигацией -- Состояние сети в футере - -#### Конфигурация -- TOML конфигурация (~/.config/tele-tui/config.toml) -- Настройка часового пояса -- Настройка цветовой схемы -- Приоритетная загрузка credentials из XDG config dir - -#### Оптимизации -- 60 FPS рендеринг -- LRU кеширование пользователей (лимит 500) -- Lazy loading имён пользователей -- Лимиты памяти (500 сообщений на чат, 200 чатов) -- Graceful shutdown - -### Изменено -- Время отображается с учётом настроенного timezone - -### Исправлено -- Корректная обработка TDLib updates в отдельном потоке -- Правильное выравнивание для длинных сообщений -- Приоритет обработки input для модалок - -[Unreleased]: https://github.com/your-username/tele-tui/compare/v0.1.0...HEAD -[0.1.0]: https://github.com/your-username/tele-tui/releases/tag/v0.1.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index b452d5c..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,125 +0,0 @@ -# Contributing to tele-tui - -Спасибо за интерес к проекту! Мы рады любому вкладу. - -## Как помочь проекту - -### Сообщить о баге - -1. Проверьте, нет ли уже такого issue в [Issues](https://github.com/your-username/tele-tui/issues) -2. Создайте новый issue с описанием: - - Шаги для воспроизведения - - Ожидаемое поведение - - Фактическое поведение - - Версия ОС и Rust - - Логи (если есть) - -### Предложить новую фичу - -1. Проверьте [ROADMAP.md](ROADMAP.md) — возможно, эта фича уже запланирована -2. Создайте issue с меткой `enhancement` -3. Опишите: - - Зачем нужна эта фича - - Как она должна работать - - Примеры использования - -### Внести код - -1. **Fork** репозитория -2. Создайте **feature branch**: `git checkout -b feature/amazing-feature` -3. Прочитайте [DEVELOPMENT.md](DEVELOPMENT.md) для понимания процесса разработки -4. Внесите изменения -5. Протестируйте локально -6. Commit: `git commit -m 'Add amazing feature'` -7. Push: `git push origin feature/amazing-feature` -8. Создайте **Pull Request** - -## Правила кода - -### Стиль кода - -- Используйте `cargo fmt` перед коммитом -- Следуйте [Rust API Guidelines](https://rust-lang.github.io/api-guidelines/) -- Добавляйте комментарии для сложной логики - -### Структура коммитов - -``` -: <краткое описание> - -<подробное описание (опционально)> -``` - -Типы: -- `feat`: новая фича -- `fix`: исправление бага -- `refactor`: рефакторинг без изменения функциональности -- `docs`: изменения в документации -- `style`: форматирование, отступы -- `test`: добавление тестов -- `chore`: обновление зависимостей, конфигурации - -Примеры: -``` -feat: add emoji reactions to messages - -fix: correct timezone offset calculation - -docs: update installation instructions -``` - -### Тестирование - -- Протестируйте вручную все изменения -- Опишите сценарии тестирования в PR -- Убедитесь, что `cargo build` проходит без ошибок -- Убедитесь, что `cargo fmt` и `cargo clippy` не дают предупреждений - -## Процесс Review - -1. Maintainer проверит ваш PR -2. Возможны комментарии и запросы на изменения -3. После одобрения PR будет смержен -4. Ваш вклад появится в следующем релизе - -## Архитектура проекта - -Перед началом работы рекомендуем ознакомиться: - -- [REQUIREMENTS.md](REQUIREMENTS.md) — функциональные требования -- [CONTEXT.md](CONTEXT.md) — текущий статус и архитектурные решения -- [ROADMAP.md](ROADMAP.md) — план развития - -### Структура кода - -``` -src/ -├── main.rs # Event loop, инициализация -├── config.rs # Конфигурация -├── app/ # Состояние приложения -├── ui/ # Отрисовка UI -├── input/ # Обработка ввода -├── utils.rs # Утилиты -└── tdlib/ # TDLib интеграция -``` - -### Ключевые принципы - -1. **Неблокирующий UI**: TDLib updates в отдельном потоке -2. **Оптимизация памяти**: LRU кеши, лимиты на коллекции -3. **Vim-style навигация**: консистентные хоткеи -4. **Graceful degradation**: fallback для отсутствующих данных - -## Code of Conduct - -- Будьте вежливы и уважительны -- Конструктивная критика приветствуется -- Фокус на технических аспектах - -## Вопросы? - -Создайте issue с меткой `question` или свяжитесь с maintainers. - -## Лицензия - -Внося код в этот проект, вы соглашаетесь с лицензией [MIT](LICENSE). diff --git a/FAQ.md b/FAQ.md deleted file mode 100644 index 1bbfac1..0000000 --- a/FAQ.md +++ /dev/null @@ -1,227 +0,0 @@ -# FAQ — Часто задаваемые вопросы - -## Установка и запуск - -### Где получить API credentials? - -1. Перейдите на https://my.telegram.org/apps -2. Войдите с вашим номером телефона -3. Создайте новое приложение -4. Скопируйте `api_id` и `api_hash` - -### Где хранить credentials? - -**Рекомендуется** (приоритет 1): -```bash -~/.config/tele-tui/credentials -``` - -**Альтернатива** (приоритет 2): -```bash -.env # в корне проекта -``` - -### Ошибка "Telegram API credentials not found!" - -Убедитесь, что вы создали файл credentials (см. выше) с правильным форматом: -``` -API_ID=12345678 -API_HASH=abcdef1234567890abcdef1234567890 -``` - -### Где хранится сессия Telegram? - -В папке `./tdlib_data/` в директории запуска приложения. Эта папка содержит: -- Токены авторизации -- Кеш сообщений -- Другие данные TDLib - -**Важно**: Не удаляйте эту папку, иначе придётся заново авторизоваться. - -## Использование - -### Как переключаться между папками? - -Нажмите клавиши `1-9` для переключения между первыми 9 папками Telegram. - -### Как искать сообщения в чате? - -1. Откройте чат -2. Нажмите `Ctrl+F` -3. Введите поисковый запрос -4. Используйте `n` / `N` для навигации по результатам - -### Как скопировать текст сообщения? - -1. При пустом поле ввода нажмите `↑` для выбора сообщения -2. Нажмите `y` (или `н` на русской раскладке) -3. Текст скопирован в системный буфер обмена - -### Как ответить на сообщение? - -1. Выберите сообщение (`↑` при пустом инпуте) -2. Нажмите `r` (или `к` на русской раскладке) -3. Введите ответ -4. Нажмите `Enter` - -### Как удалить сообщение? - -1. Выберите сообщение -2. Нажмите `d` / `в` / `Delete` -3. Подтвердите удаление: `y` / `Enter` - -### Как добавить реакцию? - -1. Выберите сообщение -2. Нажмите `e` (или `у` на русской раскладке) -3. Выберите emoji стрелками -4. Нажмите `Enter` - -### Почему не работают хоткеи на русской раскладке? - -Убедитесь, что вы используете **русскую раскладку**, а не транслит. Поддерживаемые комбинации: -- `р о л д` → `h j k l` (навигация) -- `к` → `r` (reply) -- `а` → `f` (forward) -- `в` → `d` (delete) -- `н` → `y` (copy) -- `у` → `e` (react) - -## Конфигурация - -### Где находится конфигурационный файл? - -```bash -~/.config/tele-tui/config.toml -``` - -Создаётся автоматически при первом запуске. - -### Как изменить часовой пояс? - -Отредактируйте `~/.config/tele-tui/config.toml`: - -```toml -[general] -timezone = "+05:00" # Ваш часовой пояс -``` - -### Как изменить цветовую схему? - -Отредактируйте секцию `[colors]` в конфиге: - -```toml -[colors] -incoming_message = "cyan" -outgoing_message = "lightgreen" -selected_message = "lightyellow" -``` - -Поддерживаемые цвета: black, red, green, yellow, blue, magenta, cyan, gray, white, darkgray, lightred, lightgreen, lightyellow, lightblue, lightmagenta, lightcyan. - -### Нужно ли перезапускать приложение после изменения конфига? - -Да, изменения в `config.toml` применяются только при запуске приложения. - -## Проблемы - -### Приложение зависает при запуске - -Возможные причины: -1. **Нет интернета**: проверьте подключение -2. **TDLib не может подключиться**: проверьте firewall/прокси -3. **Неверные credentials**: проверьте API_ID и API_HASH - -### Сообщения не загружаются - -1. Проверьте статус сети в футере (внизу экрана) -2. Попробуйте обновить: `Ctrl+R` -3. Перезапустите приложение - -### "Deleted Account" в списке чатов - -Это пользователи, которые удалили свой аккаунт Telegram. Они автоматически фильтруются и не отображаются в списке. - -### Не отображаются медиафайлы - -Медиафайлы (фото, видео, голосовые, стикеры) отображаются как заглушки: [Фото], [Видео], [Голосовое], [Стикер]. Полная поддержка медиа может быть добавлена в будущем. - -### Ошибка компиляции при сборке - -**TDLib download failed**: -- Проверьте интернет-соединение -- Убедитесь, что у вас достаточно места на диске - -**Linking with cc failed**: -- macOS: `xcode-select --install` -- Linux: `sudo apt-get install build-essential` -- Windows: установите Visual Studio Build Tools - -### Как сбросить сессию? - -Удалите папку `tdlib_data/`: -```bash -rm -rf tdlib_data/ -``` - -При следующем запуске потребуется заново авторизоваться. - -## Производительность - -### Приложение тормозит - -Проверьте: -1. Количество открытых чатов (лимит 200) -2. Количество сообщений в открытом чате (лимит 500) -3. Размер терминала (минимум 80x20) - -Приложение автоматически очищает старые данные при достижении лимитов. - -### Высокое использование памяти - -Это нормально при большом количестве чатов и сообщений. Приложение использует LRU кеши с ограничениями: -- 500 пользователей в кеше -- 500 сообщений на чат -- 200 чатов - -## Разработка - -### Как внести вклад в проект? - -См. [CONTRIBUTING.md](CONTRIBUTING.md) - -### Где найти план развития? - -См. [ROADMAP.md](ROADMAP.md) - -### Как сообщить о баге? - -Создайте issue на GitHub с описанием: -- Шаги для воспроизведения -- Ожидаемое и фактическое поведение -- Версия ОС и Rust -- Логи (если есть) - -## Безопасность - -### Безопасно ли хранить credentials в файле? - -Да, если вы: -1. Используете `~/.config/tele-tui/credentials` -2. Установили права доступа: `chmod 600 ~/.config/tele-tui/credentials` -3. Не коммитите этот файл в git (уже в `.gitignore`) - -### Что делать при компрометации credentials? - -1. Удалите приложение на https://my.telegram.org/apps -2. Создайте новое приложение с новыми credentials -3. Обновите файл `credentials` -4. Удалите папку `tdlib_data/` и авторизуйтесь заново - -### Включена ли двухфакторная аутентификация? - -Если вы включили 2FA в Telegram, приложение запросит пароль при первой авторизации. - -## Ещё вопросы? - -Создайте issue на GitHub или свяжитесь с maintainers. diff --git a/INSTALL.md b/INSTALL.md deleted file mode 100644 index 776834a..0000000 --- a/INSTALL.md +++ /dev/null @@ -1,122 +0,0 @@ -# Установка tele-tui - -## Требования - -- **Rust**: версия 1.70 или выше ([установить](https://rustup.rs/)) -- **TDLib**: скачивается автоматически через tdlib-rs - -## Шаг 1: Клонирование репозитория - -```bash -git clone https://github.com/your-username/tele-tui.git -cd tele-tui -``` - -## Шаг 2: Получение API credentials - -1. Перейдите на https://my.telegram.org/apps -2. Войдите с вашим номером телефона -3. Создайте новое приложение -4. Скопируйте **api_id** и **api_hash** - -## Шаг 3: Настройка credentials - -### Вариант A: XDG config directory (рекомендуется) - -Создайте файл `~/.config/tele-tui/credentials`: - -```bash -mkdir -p ~/.config/tele-tui -cat > ~/.config/tele-tui/credentials << EOF -API_ID=your_api_id_here -API_HASH=your_api_hash_here -EOF -``` - -### Вариант B: .env файл - -Создайте файл `.env` в корне проекта: - -```bash -cp credentials.example .env -# Отредактируйте .env и вставьте ваши credentials -``` - -## Шаг 4: Сборка - -```bash -cargo build --release -``` - -## Шаг 5: Запуск - -```bash -cargo run --release -``` - -Или запустите скомпилированный бинарник: - -```bash -./target/release/tele-tui -``` - -## Первый запуск - -При первом запуске вам нужно будет: - -1. Ввести номер телефона (с кодом страны, например: +79991234567) -2. Ввести код подтверждения из Telegram -3. Если включена 2FA — ввести пароль - -Сессия сохраняется в `./tdlib_data/`, при следующем запуске авторизация не потребуется. - -## Настройка (опционально) - -Конфигурационный файл создаётся автоматически при первом запуске в `~/.config/tele-tui/config.toml`. - -Вы можете отредактировать его для настройки: -- Часового пояса -- Цветовой схемы - -Пример конфигурации см. в файле `config.toml.example`. - -## Устранение неполадок - -### "Telegram API credentials not found!" - -Убедитесь, что вы создали файл credentials (см. Шаг 3). - -### "error: linking with `cc` failed" - -Убедитесь, что у вас установлен C компилятор: -- macOS: `xcode-select --install` -- Linux: `sudo apt-get install build-essential` (Debian/Ubuntu) -- Windows: установите Visual Studio Build Tools - -### TDLib download failed - -Проверьте подключение к интернету. TDLib скачивается автоматически при первой сборке. - -## Обновление - -```bash -git pull -cargo build --release -``` - -Ваши credentials и конфигурация сохранятся. - -## Удаление - -Чтобы полностью удалить приложение и все данные: - -```bash -# Удалить проект -rm -rf tele-tui/ - -# Удалить конфигурацию и credentials -rm -rf ~/.config/tele-tui/ - -# Удалить сессию Telegram (опционально, потребуется новая авторизация) -# rm -rf ./tdlib_data/ -``` diff --git a/REFACTORING_OPPORTUNITIES.md b/REFACTORING_OPPORTUNITIES.md deleted file mode 100644 index 8a3e671..0000000 --- a/REFACTORING_OPPORTUNITIES.md +++ /dev/null @@ -1,855 +0,0 @@ -# Возможности для рефакторинга - -> Результаты аудита кодовой базы от 2026-02-02 -> Обновлено: 2026-02-04 -> Статус: В работе (2/10 категорий полностью завершены, 3 частично) - -## Оглавление - -1. [Дублирование кода](#1-дублирование-кода) -2. [Большие файлы/функции](#2-большие-файлыфункции) -3. [Сложная вложенность](#3-сложная-вложенность) -4. [Нарушение Single Responsibility](#4-нарушение-single-responsibility) -5. [Плохая инкапсуляция](#5-плохая-инкапсуляция) -6. [Отсутствующие абстракции](#6-отсутствующие-абстракции) -7. [Несогласованность](#7-несогласованность) -8. [Перекрытие функциональности](#8-перекрытие-функциональности) -9. [Проблемы производительности](#9-проблемы-производительности) -10. [Отсутствующие архитектурные паттерны](#10-отсутствующие-архитектурные-паттерны) - ---- - -## 1. Дублирование кода - -**Приоритет:** 🔴 Высокий -**Статус:** ✅ ПОЛНОСТЬЮ ЗАВЕРШЕНО! (2026-02-02) -**Объем:** 15-20% кодовой базы → Устранено! - -### Проблемы - -- **Timeout/Retry паттерны** (~20 экземпляров в обработке ввода) - - Повторяющаяся логика таймаутов в `src/input/main_input.rs` - - Одинаковые паттерны retry в разных обработчиках - -- **Обработка модальных окон** (5+ мест) - - Логика открытия/закрытия модалок дублируется - - Валидация ввода в модальных окнах повторяется - - Обработка Escape для закрытия модалок в каждом месте - -- **Паттерны валидации** - - Проверка пустых строк - - Валидация ID чатов/сообщений - - Проверка длины текста - -### Решение - -- [x] Создать `retry_utils.rs` с функциями `with_timeout()`, `with_retry()` - **Выполнено и интегрировано** (2026-02-02) - - Создан `src/utils/retry.rs` с тремя функциями: `with_timeout()`, `with_timeout_msg()`, `with_timeout_ignore()` - - Заменены ВСЕ прямые использования `tokio::time::timeout` (8+ мест: main_input.rs, auth.rs, main.rs) - - Код стал чище и короче (убрано вложенное Ok/Err матчинг) - - **100% покрытие** - больше нет прямых timeout вызовов -- [x] Создать `modal_handler.rs` с общей логикой модальных окон - **Выполнено** (2026-02-01) - - Создан `src/utils/modal_handler.rs` (120+ строк) - - 4 функции: `handle_modal_key()`, `should_close_modal()`, `should_confirm_modal()`, `handle_yes_no()` - - Enum `ModalAction` для type-safe обработки - - Поддержка английской и русской раскладки (y/д, n/т) - - 4 unit теста (все проходят) -- [x] Создать `validation.rs` с переиспользуемыми валидаторами - **Выполнено и интегрировано** (2026-02-02) - - Создан `src/utils/validation.rs` (180+ строк) - - 7 функций валидации: `is_non_empty()`, `is_within_length()`, `is_valid_chat_id()`, `is_valid_message_id()`, `is_valid_user_id()`, `has_items()`, `validate_text_input()` - - Покрывает все основные паттерны валидации - - 7 unit тестов (все проходят) - - **Интегрировано в 4 местах:** auth.rs (phone/code/password), main_input.rs (message validation) - -### Файлы - -- `src/input/main_input.rs` -- `src/app/handlers/*.rs` -- `src/ui/modals/*.rs` - ---- - -## 2. Большие файлы/функции - -**Приоритет:** 🔴 Высокий -**Статус:** ✅ **ПОЛНОСТЬЮ ЗАВЕРШЕНО!** (обновлено 2026-02-04) -**Объем:** Все 4 файла отрефакторены! (4/4, 100%! 🎉) - -### Проблемы - -| Файл | Строки | Проблема | Статус | -|------|--------|----------|--------| -| `src/input/main_input.rs` | ~~1164~~ → ~1200 | ~~Одна функция `handle()` на ~800 строк~~ | ✅ **РЕШЕНО** (handle() → 82 строки) | -| `src/tdlib/client.rs` | ~~1259~~ → 599 | ~~Смешение facade и бизнес-логики~~ | ✅ **РЕШЕНО** (1259 → 599 строк, -52%) | -| `src/ui/messages.rs` | 905 | ~~Рендеринг всех типов сообщений~~ | ✅ **НЕ ТРЕБУЕТСЯ** (render() → 92 строки, Phase 5) | -| `src/tdlib/messages.rs` | ~~850~~ → 757 | ~~Обработка всех типов обновлений сообщений~~ | ✅ **РЕШЕНО** (convert_message() → 57 строк, -62%) | - -### Решение - -#### 2.1. Разделить `src/input/main_input.rs` - ✅ **ЗАВЕРШЕНО** (2026-02-03) - -**Phase 1-2** (2026-02-02): -- [x] Создана структура `src/input/handlers/` (7 модулей) - ПОДГОТОВКА -- [x] Создан `handlers/clipboard.rs` (~100 строк) - извлечён из main_input -- [x] Создан `handlers/global.rs` (~90 строк) - извлечён из main_input -- [x] Созданы заглушки: `profile.rs`, `search.rs`, `modal.rs`, `messages.rs`, `chat_list.rs` - -**Phase 2-3** (2026-02-03): -- [x] **Извлечено 13 специализированных функций-обработчиков** (~946 строк): - - `handle_open_chat_keyboard_input()` (~129 строк) - - `handle_chat_list_navigation()` (~34 строки) - - `handle_profile_mode()` (~120 строк) - - `handle_message_search_mode()` (~73 строки) - - `handle_pinned_mode()` (~42 строки) - - `handle_reaction_picker_mode()` (~90 строк) - - `handle_delete_confirmation()` (~60 строк) - - `handle_forward_mode()` (~52 строки) - - `handle_chat_search_mode()` (~43 строки) - - `handle_enter_key()` (~145 строк) - - `handle_escape_key()` (~35 строк) - - `handle_message_selection()` (~95 строк) - - `handle_profile_open()` (~28 строк) - -**Phase 4** (2026-02-03): -- [x] **Упрощена вложенность** (early returns, let-else guards) -- [x] **Извлечено 3 вспомогательных функции**: - - `edit_message()` (~50 строк) - - `send_new_message()` (~55 строк) - - `perform_message_search()` (~20 строк) - -**Итоговый результат**: -- ✅ Функция `handle()` сократилась с **891 до 82 строк** (91% сокращение! 🎉) -- ✅ Глубина вложенности: **6+ уровней → 2-3 уровня** -- ✅ Все 196 тестов проходят успешно -- ✅ Код стал **линейным и простым для понимания** - -**Примечание**: Вместо создания отдельных файлов в handlers/ (что привело бы к поломке), мы выбрали подход извлечения функций внутри main_input.rs. Это позволило радикально упростить код без риска регрессий. - -#### 2.2. Разделить `src/tdlib/client.rs` - ✅ **ЗАВЕРШЕНО** (2026-02-04) - -**Этап 1** (2026-02-04): Извлечение Update Handlers -- [x] Создан модуль `src/tdlib/update_handlers.rs` (302 строки) -- [x] **Извлечено 8 handler функций** (~350 строк): - - `handle_new_message_update()` — добавление новых сообщений (44 строки) - - `handle_chat_action_update()` — статус набора текста (32 строки) - - `handle_chat_position_update()` — управление позициями чатов (36 строк) - - `handle_user_update()` — обработка информации о пользователях (40 строк) - - `handle_message_interaction_info_update()` — обновление реакций (44 строки) - - `handle_message_send_succeeded_update()` — успешная отправка (35 строк) - - `handle_chat_draft_message_update()` — черновики сообщений (15 строк) - - `handle_auth_state()` — изменение состояния авторизации (10 строк) -- [x] Обновлён `handle_update()` для делегирования в update_handlers -- [x] Результат: **client.rs 1259 → 983 строки** (22% сокращение) - -**Этап 2** (2026-02-04): Извлечение Message Converter -- [x] Создан модуль `src/tdlib/message_converter.rs` (250 строк) -- [x] **Извлечено 6 conversion функций** (~240 строк): - - `convert_message()` — основная конвертация TDLib → MessageInfo (150+ строк) - - `extract_reply_info()` — извлечение reply информации (30 строк) - - `extract_forward_info()` — извлечение forward информации (25 строк) - - `extract_reactions()` — извлечение реакций (20 строк) - - `get_origin_sender_name()` — получение имени отправителя (15 строк) - - `update_reply_info_from_loaded_messages()` — обновление reply из кэша (30 строк) -- [x] Исправлены ошибки компиляции с неверными именами полей -- [x] Обновлены вызовы в update_handlers.rs -- [x] Результат: **client.rs 983 → 754 строки** (23% сокращение) - -**Этап 3** (2026-02-04): Извлечение Chat Helpers -- [x] Создан модуль `src/tdlib/chat_helpers.rs` (149 строк) -- [x] **Извлечено 3 helper функции** (~140 строк): - - `find_chat_mut()` — поиск чата по ID (15 строк) - - `update_chat()` — обновление чата через closure (15 строк, используется 9+ раз) - - `add_or_update_chat()` — добавление/обновление чата в списке (110+ строк) -- [x] Использован sed для замены вызовов методов по всей кодовой базе -- [x] Результат: **client.rs 754 → 599 строк** (21% сокращение) - -**Итоговый результат**: -- ✅ Файл `client.rs` сократился с **1259 до 599 строк** (52% сокращение! 🎉) -- ✅ Создано **3 новых модуля** с чёткой ответственностью: - - `update_handlers.rs` — обработка всех типов TDLib Update - - `message_converter.rs` — конвертация TDLib Message → MessageInfo - - `chat_helpers.rs` — утилиты для работы с чатами -- ✅ Все **590+ тестов** проходят успешно -- ✅ Код стал **модульным и лучше организованным** -- ✅ `TdClient` теперь ближе к **facade pattern** (делегирует в специализированные модули) - -#### 2.3. Упростить `src/ui/messages.rs` - ✅ **ЗАВЕРШЕНО** (Phase 5, 2026-02-03) - -**Уже выполнено в Phase 5**: -- [x] Извлечены 4 функции рендеринга (~350 строк): - - `render_chat_header()` — заголовок с typing status (~47 строк) - - `render_pinned_bar()` — панель закреплённого сообщения (~30 строк) - - `render_message_list()` — список сообщений с автоскроллом (~98 строк) - - `render_input_box()` — input с режимами (forward/select/edit/reply) (~146 строк) -- [x] Функция `render()` сократилась с **390 до 92 строк** (76% сокращение! 🎉) -- [x] Глубина вложенности: **6+ уровней → 2-3 уровня** -- [x] Код стал **модульным и простым для понимания** - -**Итоговый результат**: -- ✅ Файл остался цельным (905 строк), но хорошо организован -- ✅ Главная функция `render()` компактная (92 строки) -- ✅ Все вспомогательные функции извлечены (render_search_mode, render_pinned_mode, и др.) -- ✅ **Дальнейшее разделение не требуется** — цели достигнуты - -#### 2.4. Упростить `src/tdlib/messages.rs` - ✅ **ЗАВЕРШЕНО** (2026-02-04) - -**Этап 1** (2026-02-04): Извлечение Message Conversion Helpers -- [x] Создан модуль `src/tdlib/message_conversion.rs` (158 строк) -- [x] **Извлечено 6 вспомогательных функций**: - - `extract_content_text()` — извлечение текста из различных типов сообщений (~80 строк) - - `extract_entities()` — извлечение форматирования (~10 строк) - - `extract_sender_name()` — получение имени отправителя с API вызовом (~15 строк) - - `extract_forward_info()` — информация о пересылке (~12 строк) - - `extract_reply_info()` — информация об ответе (~15 строк) - - `extract_reactions()` — реакции на сообщение (~26 строк) -- [x] Метод `convert_message()` сократился с **150 до 57 строк** (62% сокращение! 🎉) -- [x] Результат: **messages.rs 850 → 757 строк** (11% сокращение) - -**Итоговый результат**: -- ✅ Файл `messages.rs` сократился до **757 строк** -- ✅ Создан модуль **message_conversion.rs** с переиспользуемыми функциями -- ✅ Метод `convert_message()` теперь **компактный и читаемый** (57 строк) -- ✅ Все **629 тестов** проходят успешно -- ✅ **Дальнейшее разделение не требуется** — MessageManager хорошо организован - -### Файлы - -- `src/input/main_input.rs` -- `src/tdlib/client.rs` -- `src/ui/messages.rs` -- `src/tdlib/messages.rs` - ---- - -## 3. Сложная вложенность - -**Приоритет:** 🟡 Средний -**Статус:** ✅ **ПОЛНОСТЬЮ ЗАВЕРШЕНО!** (обновлено 2026-02-04) -**Объем:** ~30 функций → 0 функций (все проблемные решены) - -### Проблемы - -- ~~4-5 уровней вложенности в обработке ввода~~ ✅ **Решено в main_input.rs** -- Глубокая вложенность в обработке обновлений TDLib -- ~~Множественные `if let` / `match` вложенные друг в друга~~ ✅ **Решено в main_input.rs** - -### Примеры - -```rust -// src/input/main_input.rs - было (типичный пример) -if let Some(chat_id) = app.selected_chat { - if let Some(message_id) = app.selected_message { - if app.is_message_outgoing(chat_id, message_id) { - match key.code { - // еще больше вложенности (6+ уровней) - } - } - } -} - -// Стало (после Phase 4 рефакторинга) -let Some(chat_id) = app.selected_chat else { return Ok(false) }; -let Some(message_id) = app.selected_message else { return Ok(false) }; - -if !app.is_message_outgoing(chat_id, message_id) { - return Ok(false); // early return -} -// Линейная логика (2-3 уровня максимум) -``` - -### Решение - -#### Выполнено в `src/input/main_input.rs` (2026-02-03) - -- [x] **Применены early returns** - уменьшили вложенность с 6+ до 2-3 уровней -- [x] **Извлечена вложенная логика** в 3 функции: - - `edit_message()` — редактирование сообщения (~50 строк) - - `send_new_message()` — отправка нового сообщения (~55 строк) - - `perform_message_search()` — поиск по сообщениям (~20 строк) -- [x] **Использованы let-else guard clauses** — современный Rust паттерн -- [x] **Упрощены 6 функций**: - - `handle_profile_mode()` — упрощён блок Enter с let-else - - `handle_profile_open()` — применён early return guard - - `handle_enter_key()` — разделена на части, сокращена с ~130 до ~40 строк - - `handle_message_search_mode()` — извлечена логика поиска - - `handle_escape_key()` — преобразован в early returns - - `handle_message_selection()` — применены let-else guards - -**Результат Phase 4**: -- ✅ Глубина вложенности: **6+ уровней → 2-3 уровня** -- ✅ Код стал **максимально линейным и читаемым** -- ✅ Применены современные Rust паттерны (let-else, guards) - -#### Выполнено в `src/tdlib/client.rs` (2026-02-03, Этап 3) - -- [x] **Добавлены helper методы** для устранения дублирования: - - `find_chat_mut()` — поиск чата по ID - - `update_chat()` — обновление чата через closure (использовано 9+ раз) -- [x] **Извлечено 5 handler методов** из `handle_update()`: - - `handle_chat_position_update()` — управление позициями чатов (43 строки) - - `handle_user_update()` — обработка информации о пользователях (46 строк) - - `handle_message_interaction_info_update()` — обновление реакций (44 строки) - - `handle_message_send_succeeded_update()` — успешная отправка (38 строк) - - `handle_chat_draft_message_update()` — черновики (18 строк) -- [x] **Упрощено 7 функций** с применением let-else guards, early returns, unwrap_or_else: - - `handle_chat_action_update()` — статус набора текста (4 → 2 уровня) - - `handle_new_message_update()` — добавление сообщений (3 → 2 уровня) - - `handle_chat_draft_message_update()` — черновики (if-let → match) - - `handle_user_update()` — usernames (вложенные if-let → and_then) - - `convert_message()` — кэш имён (if-let → unwrap_or_else) - - `extract_reply_info()` — reply информация (вложенные if-let → map/or_else) - - `update_reply_info_from_loaded_messages()` — обновление reply (4 → 1-2 уровня) - -**Результат Этапа 3 (client.rs)**: -- ✅ Функция `handle_update()` сократилась с **268 до 122 строк** (54% сокращение!) -- ✅ Устранено дублирование: ~9 повторений pattern → 2 helper метода -- ✅ Глубина вложенности: **4-5 уровней → 2-3 уровня** -- ✅ Применены modern patterns: let-else guards, early returns, filter chains - -#### Дополнительные улучшения вложенности (2026-02-04) - -- [x] **Упрощена `src/tdlib/messages.rs`** (строки 718-755) - - `fetch_missing_reply_info()`: 7 уровней → 2-3 уровня - - Извлечена функция `fetch_and_update_reply()` - - Использованы let-else guards и iterator chains - - Максимальная вложенность: **44 → 28 пробелов** - -- [x] **Упрощена `src/tdlib/messages.rs`** (строки 147-182) - - `get_chat_history()` retry loop: 6 уровней → 3 уровня - - Извлечен `messages_obj` после match - - Early continue для пустых результатов - - Использован `.flatten()` вместо вложенного if-let - -- [x] **Упрощена `src/input/main_input.rs`** (строки 500-546) - - `handle_forward_mode()`: 7 уровней → 2-3 уровня - - Извлечена функция `forward_selected_message()` - - Использованы early returns (let-else guards) - - Максимальная вложенность: **40 → 36 пробелов** - -- [x] **Упрощена `src/input/main_input.rs`** (reaction picker) - - Извлечена функция `send_reaction()` - - Использованы let-else guards - - Вложенность: 5 уровней → 2-3 уровня - -- [x] **Упрощена `src/input/main_input.rs`** (scroll + load older) - - Извлечена функция `load_older_messages_if_needed()` - - Использованы early returns - - Вложенность: 6 уровней → 2-3 уровня - -- [x] **Упрощена `src/config.rs`** (строки 563-609) - - `load_credentials()`: 7 уровней → 2-3 уровня - - Извлечены функции `load_credentials_from_file()` и `load_credentials_from_env()` - - Использованы `?` operator для Option chains - - Максимальная вложенность: **36 → 32 пробелов** - -**Итоговый результат**: -- ✅ Все файлы с вложенностью >32 пробелов обработаны -- ✅ Применены современные Rust паттерны (let-else guards, early returns, ? operator, iterator chains) -- ✅ Извлечено 8 новых функций для разделения ответственности -- ✅ Максимальная вложенность во всем проекте: **≤32 пробелов (8 уровней)** - -### Файлы - -- ✅ `src/input/main_input.rs` — **ПОЛНОСТЬЮ ЗАВЕРШЕНО** (Phase 4 + доп. улучшения: 40 → 36 пробелов) -- ✅ `src/tdlib/client.rs` — **ЗАВЕРШЕНО** (Этап 3: 268 → 122 строки в handle_update) -- ✅ `src/tdlib/messages.rs` — **ПОЛНОСТЬЮ ЗАВЕРШЕНО** (44 → 28 пробелов) -- ✅ `src/config.rs` — **ПОЛНОСТЬЮ ЗАВЕРШЕНО** (36 → 32 пробелов) -- ✅ Все остальные модули — **проверены, вложенность приемлема** (≤32 пробелов) - ---- - -## 4. Нарушение Single Responsibility - -**Приоритет:** 🟡 Средний -**Статус:** ❌ Не начато -**Объем:** 2 основных структуры - -### Проблемы - -#### 4.1. `App` struct (50+ методов) - -Смешивает ответственности: -- UI state management -- Business logic -- TDLib interaction -- Input handling -- Search logic -- Profile management -- Folder management - -#### 4.2. `TdClient` (facade + бизнес-логика) - -Смешивает: -- Facade pattern (делегирование) -- Update processing -- Cache management -- Network operations - -### Решение - -#### Разделить `App` - -- [ ] Создать `ChatListState` (состояние списка чатов) -- [ ] Создать `MessageViewState` (состояние просмотра сообщений) -- [ ] Создать `ComposeState` (состояние написания сообщения) -- [ ] Создать `SearchState` (состояние поиска) -- [ ] Создать `ProfileState` (состояние профиля) -- [ ] `App` становится координатором этих state объектов - -#### Разделить `TdClient` - -- [ ] `TdClient` только facade (делегирование) -- [ ] Бизнес-логика в `MessageService`, `ChatService`, etc. -- [ ] Update processing в отдельном модуле - -### Файлы - -- `src/app/mod.rs` -- `src/tdlib/client.rs` - ---- - -## 5. Плохая инкапсуляция - -**Приоритет:** 🔴 Высокий -**Статус:** ✅ Частично выполнено (2026-02-01) -**Объем:** Вся структура `App` - -### Проблемы - -- **22 публичных поля** в `App` - ```rust - pub struct App { - pub td_client: TdClient, - pub chats: Vec, - pub selected_chat: Option, - pub messages: HashMap>, - // ... еще 18 полей - } - ``` - -- **Прямой доступ везде** - ```rust - app.selected_chat = Some(chat_id); // Плохо - app.chats.push(new_chat); // Плохо - app.messages.clear(); // Плохо - ``` - -- **Тесты манипулируют внутренностями** - ```rust - app.td_client.user_cache.chat_user_ids.insert(...); // Слишком глубоко - ``` - -### Решение - -- [x] Сделать критичные поля приватными - **Частично выполнено** (2026-02-01) - - ✅ `config` сделан приватным (readonly через getter `app.config()`) - - ✅ Добавлены 30+ методов-геттеров и сеттеров для всех полей - - ⏳ Остальные поля оставлены pub для совместимости (требуется массовый рефакторинг) -- [x] Добавить getter методы где нужно - **Выполнено** - - 30+ методов: `phone_input()`, `set_phone_input()`, `screen()`, `set_screen()`, `is_loading()`, и т.д. -- [ ] Полная инкапсуляция всех полей (требует обновления 170+ мест в коде) -- [ ] Создать методы для операций (вместо прямого доступа) - ```rust - // Вместо app.selected_chat = Some(chat_id) - app.select_chat(chat_id); // Уже есть! - - // Вместо app.chats.push(new_chat) - app.add_chat(new_chat); // TODO - ``` - -### Файлы - -- `src/app/mod.rs` -- `src/app/state.rs` (новый) -- Все тесты - ---- - -## 6. Отсутствующие абстракции - -**Приоритет:** 🟡 Средний -**Статус:** ✅ Частично выполнено (2026-02-04) -**Объем:** 3 основные абстракции (2/3 завершены, 1/3 уже была) - -### Проблемы - -#### 6.1. Создать `KeyHandler` trait ✅ ЗАВЕРШЕНО! (2026-02-04) - -- [x] Создать `src/input/key_handler.rs` - **Выполнено** (380+ строк) - - Enum `KeyResult` (Handled, HandledNeedsRedraw, NotHandled, Quit) - - Trait `KeyHandler` с методом `handle_key()` и `priority()` - - Struct `GlobalKeyHandler` - обработчик глобальных команд (Quit, OpenSearch) - - Struct `ChatListKeyHandler` - навигация по списку чатов, выбор папок - - Struct `MessageViewKeyHandler` - скролл сообщений, поиск в чате - - Struct `MessageSelectionKeyHandler` - действия с выбранным сообщением - - Struct `KeyHandlerChain` - цепочка обработчиков с приоритетами - - 3 unit теста (все проходят) -- [ ] Интегрировать в main_input.rs (заменить текущую логику) -- [ ] Добавить недостающие методы в App (enter_search_mode и т.д.) - -#### 6.2. Нет абстракции для network operations - -Timeout/retry логика дублируется: - -```rust -// Повторяется ~20 раз -let result = tokio::time::timeout( - Duration::from_millis(100), - operation() -).await; -``` - -#### 6.3. Хардкод горячих клавиш - -Невозможно изменить без правки кода: - -```rust -KeyCode::Char('e') => edit_message(), // Хардкод -KeyCode::Char('d') => delete_message(), // Хардкод -``` - -### Решение - -#### 6.1. Создать `KeyHandler` trait - -- [ ] Создать `src/input/key_handler.rs` - ```rust - trait KeyHandler { - fn handle_key(&mut self, app: &mut App, key: KeyEvent) -> Result; - } - ``` -- [ ] Реализовать для каждого экрана: - - `ChatListKeyHandler` - - `MessagesKeyHandler` - - `ComposeKeyHandler` - - `SearchKeyHandler` - -#### 6.2. Создать network utilities - -- [ ] Создать `src/utils/network.rs` - ```rust - async fn with_timeout(f: F, timeout_ms: u64) -> Result - async fn with_retry(f: F, max_retries: u32) -> Result - ``` - -#### 6.3. Создать систему горячих клавиш ✅ ЗАВЕРШЕНО! (2026-02-04) - -- [x] Создать `src/config/keybindings.rs` - **Выполнено** - - Enum `Command` с 40+ командами (навигация, чат, сообщения, input) - - Struct `KeyBinding` с поддержкой модификаторов (Ctrl, Shift, Alt и т.д.) - - Struct `Keybindings` с HashMap> - - Поддержка множественных bindings для одной команды (EN/RU раскладки) - - Сериализация/десериализация KeyCode и KeyModifiers - - 4 unit теста (все проходят) -- [ ] Интегрировать в приложение (вместо HotkeysConfig) -- [ ] Загружать из конфига (опционально, с fallback на defaults) - -### Файлы - -- `src/input/key_handler.rs` (новый) -- `src/utils/network.rs` (новый) -- `src/config/keybindings.rs` (новый) - ---- - -## 7. Несогласованность - -**Приоритет:** 🟢 Низкий -**Статус:** ❌ Не начато -**Объем:** Вся кодовая база - -### Проблемы - -#### 7.1. Разные типы ошибок - -```rust -// В одних местах -Result - -// В других -Result> - -// В третьих -Result // с неявным типом ошибки -``` - -#### 7.2. Разные паттерны state management - -- В одних местах флаги (`is_editing: bool`) -- В других энумы (`EditMode::Active`) -- В третьих Option (`editing_message: Option`) - -#### 7.3. Разные подходы к валидации - -- Иногда в UI слое -- Иногда в бизнес-логике -- Иногда в обработчиках ввода - -### Решение - -- [ ] Стандартизировать обработку ошибок (один тип ошибки) -- [ ] Выбрать единый подход к state management (enum-based) -- [ ] Определить слой для валидации (бизнес-логика) -- [ ] Создать style guide в документации - -### Файлы - -- Вся кодовая база - ---- - -## 8. Перекрытие функциональности - -**Приоритет:** 🟡 Средний -**Статус:** ✅ Выполнено (2026-02-04) -**Объем:** 2 основные области (2/2 завершены) - -### Проблемы - -#### 8.1. Централизовать фильтрацию чатов ✅ ЗАВЕРШЕНО! (2026-02-04) - -- [x] Создать `src/app/chat_filter.rs` - **Выполнено** (470+ строк) - - Struct `ChatFilterCriteria` с builder pattern - - Поддержка фильтрации по: папке, поиску, pinned, unread, mentions, muted, archived - - Struct `ChatFilter` с методами фильтрации - - Enum `ChatSortOrder` для сортировки (ByLastMessage, ByTitle, ByUnreadCount, PinnedFirst) - - Методы подсчёта: count, count_unread, count_unread_mentions - - 6 unit тестов (все проходят) -- [ ] Заменить дублирующуюся логику в App и UI на ChatFilter -- [ ] Удалить старые методы фильтрации из App - -#### 8.2. Централизовать обработку сообщений ✅ ЗАВЕРШЕНО! (2026-02-04) - -- [x] Создать `src/app/message_service.rs` - **Выполнено** (508+ строк) - - Struct `MessageGroup` для группировки по дате - - Struct `SenderGroup` для группировки по отправителю - - Struct `MessageSearchResult` с контекстом поиска - - Struct `MessageService` с 13 методами бизнес-логики: - - `group_by_date()` - группировка сообщений по датам - - `group_by_sender()` - группировка по отправителю - - `search()` - полнотекстовый поиск с контекстом - - `find_next()` / `find_previous()` - навигация по результатам - - `filter_by_sender()` / `filter_unread()` - фильтрация - - `find_by_id()` / `find_index_by_id()` - поиск по ID - - `get_last_n()` - получение последних N сообщений - - `get_in_date_range()` - фильтрация по диапазону дат - - `count_by_sender_type()` - статистика по типам - - `create_index()` - создание быстрого индекса - - 7 unit тестов (все проходят) -- [ ] Заменить разрозненную логику в App/UI на MessageService -- [ ] Чёткое разделение: TDLib → Service → UI - -### Решение - -#### 8.1. Централизовать фильтрацию ✅ - -- [x] Создать `src/app/chat_filter.rs` - **Выполнено** -- [x] Один источник правды для фильтрации - **Выполнено** -- [ ] UI и App используют его - TODO (требует интеграции) - -#### 8.2. Четко разделить слои обработки сообщений ✅ - -- [x] `tdlib/messages.rs` - только получение и преобразование - **Выполнено** -- [x] `app/message_service.rs` - бизнес-логика - **Выполнено** -- [x] `ui/messages.rs` - только рендеринг - **Было уже реализовано** - -### Файлы - -- `src/app/chat_filter.rs` (новый) -- `src/app/message_service.rs` (новый) -- `src/tdlib/messages.rs` -- `src/ui/messages.rs` - ---- - -## 9. Проблемы производительности - -**Приоритет:** 🟢 Низкий -**Статус:** ❌ Не начато -**Объем:** Локальные оптимизации - -### Проблемы - -#### 9.1. Множественные клоны в обработке ввода - -```rust -let text = app.input_text.clone(); // Клон -let chat_id = app.selected_chat.clone(); // Клон -// Используются только для чтения -``` - -#### 9.2. Нет кеширования результатов поиска - -- Каждый поиск выполняется заново -- Нет инвалидации кеша при изменениях - -#### 9.3. Неэффективная LRU cache - -- `Vec::retain()` + `Vec::push()` на каждый доступ -- O(n) вместо потенциального O(1) - -### Решение - -- [ ] Заменить клоны на borrowing где возможно -- [ ] Добавить `SearchCache` с TTL -- [ ] Оптимизировать `LruCache` (использовать `VecDeque` или готовую библиотеку) - -### Файлы - -- `src/input/main_input.rs` -- `src/app/search.rs` -- `src/tdlib/users.rs` (LruCache) - ---- - -## 10. Отсутствующие архитектурные паттерны - -**Приоритет:** 🟢 Низкий -**Статус:** ❌ Не начато -**Объем:** Архитектурные изменения - -### Проблемы - -#### 10.1. Нет Event Bus - -Компоненты напрямую вызывают друг друга: -- Сложно тестировать -- Сильная связанность -- Тяжело добавлять новые фичи - -#### 10.2. Нет Repository паттерна - -Прямой доступ к данным везде: -- `app.messages.get(chat_id)` -- `app.chats.iter().find(...)` -- Нет единой точки доступа к данным - -#### 10.3. Нет Service Layer - -Бизнес-логика размазана: -- Часть в `App` -- Часть в `TdClient` -- Часть в UI handlers - -### Решение - -#### 10.1. Event Bus (опционально) - -- [ ] Создать `src/event_bus.rs` -- [ ] Pub/Sub для событий между компонентами -- [ ] Decoupling - -#### 10.2. Repository Pattern - -- [ ] Создать `src/repositories/chat_repository.rs` -- [ ] Создать `src/repositories/message_repository.rs` -- [ ] Создать `src/repositories/user_repository.rs` -- [ ] Единая точка доступа к данным - -#### 10.3. Service Layer - -- [ ] Создать `src/services/chat_service.rs` -- [ ] Создать `src/services/message_service.rs` -- [ ] Создать `src/services/search_service.rs` -- [ ] Вся бизнес-логика в сервисах - -### Файлы - -- `src/event_bus.rs` (новый, опционально) -- `src/repositories/*.rs` (новые) -- `src/services/*.rs` (новые) - ---- - -## Приоритизация - -### 🔴 Высокий приоритет (начать первым) - -1. **Дублирование кода** - быстрый win, улучшит поддерживаемость -2. **Большие файлы** - критично для навигации и понимания кода -3. **Плохая инкапсуляция** - защитит от ошибок, улучшит API - -### 🟡 Средний приоритет (после высокого) - -4. **Сложная вложенность** - улучшит читаемость -5. **Single Responsibility** - улучшит архитектуру -6. **Отсутствующие абстракции** - упростит расширение -7. **Перекрытие функциональности** - уберет путаницу - -### 🟢 Низкий приоритет (когда будет время) - -8. **Несогласованность** - косметические улучшения -9. **Производительность** - пока не critical path -10. **Архитектурные паттерны** - nice to have - ---- - -## План выполнения - -### Фаза 1: Быстрые победы (1-2 дня) - -- [x] #1: Создать утилиты для дублирующегося кода - **ЗАВЕРШЕНО** (2026-02-02) - - retry utils: 100% покрытие (все timeout заменены) - - modal_handler: интегрирован в 2 диалогах - - validation: интегрирован в 4 местах -- [ ] #5: Инкапсулировать поля App - **Частично** (геттеры добавлены) - -### Фаза 2: Разделение больших файлов (3-5 дней) - -- [ ] #2.1: Разделить `main_input.rs` -- [ ] #2.2: Разделить `client.rs` -- [ ] #2.3: Разделить `messages.rs` - -### Фаза 3: Улучшение архитектуры (5-7 дней) - -- [ ] #4: Разделить ответственности App/TdClient -- [ ] #6: Добавить абстракции (KeyHandler, network utils) -- [ ] #8: Убрать перекрытие функциональности - -### Фаза 4: Полировка (2-3 дня) - -- [x] #3: Упростить вложенность - **Частично** (main_input.rs завершён 2026-02-03) -- [ ] #7: Стандартизировать подходы -- [ ] #9: Оптимизировать производительность - -### Фаза 5: Архитектурные паттерны (опционально) - -- [ ] #10: Рассмотреть Event Bus / Repository / Service Layer - ---- - -## Метрики - -### До рефакторинга - -- Строк кода: ~15,000 -- Файлов: ~50 -- Средний размер файла: 300 строк -- Максимальный файл: 1167 строк -- Дублирование: ~15-20% -- Публичных полей в App: 22 -- Прямые вызовы timeout: 8+ - -### Текущее состояние (2026-02-04) - -- ✅ Дублирование timeout: **УСТРАНЕНО** (0 прямых вызовов, все через retry utils) -- ✅ Дублирование modal: **УСТРАНЕНО** (используется modal_handler) -- ✅ Дублирование validation: **УСТРАНЕНО** (используется validation utils) -- ✅ Вложенность в main_input.rs: **УПРОЩЕНА** (6+ уровней → 2-3 уровня) -- ✅ Размер handle() в main_input.rs: **СОКРАЩЁН** (891 строк → 82 строки, 91% сокращение) -- ✅ Размер client.rs: **СОКРАЩЁН** (1259 строк → 599 строк, 52% сокращение) -- ✅ Размер render() в ui/messages.rs: **СОКРАЩЁН** (390 строк → 92 строки, 76% сокращение) -- ✅ Размер convert_message() в tdlib/messages.rs: **СОКРАЩЁН** (150 строк → 57 строк, 62% сокращение) -- ⏳ Публичных полей в App: 22 → 21 (config приватный, геттеры добавлены) -- ✅ **Все большие функции отрефакторены!** 🎉 - -### Цели после рефакторинга - -- Максимальный файл: <500 строк -- Дублирование: <5% ✅ **ДОСТИГНУТО для категории #1!** -- Глубина вложенности: ≤3 уровня ✅ **ДОСТИГНУТО для main_input.rs!** -- Публичных полей в App: 0 -- Все файлы <400 строк (в идеале) -- Улучшенная тестируемость -- Более четкое разделение ответственностей diff --git a/REFACTORING_ROADMAP.md b/REFACTORING_ROADMAP.md deleted file mode 100644 index 09098aa..0000000 --- a/REFACTORING_ROADMAP.md +++ /dev/null @@ -1,1120 +0,0 @@ -# Refactoring Roadmap - -Этот документ содержит список технического долга и планов по рефакторингу кодовой базы. - -## Приоритет 1: Критичные улучшения - -### 1. Схлопнуть состояния чата в enum - -**Проблема**: Сейчас состояния чата хранятся как отдельные boolean поля в `App`: -```rust -is_message_selection_mode: bool, -is_editing_mode: bool, -is_reply_mode: bool, -is_forward_mode: bool, -is_delete_confirmation: bool, -is_reaction_picker_mode: bool, -is_profile_mode: bool, -is_search_in_chat_mode: bool, -``` - -**Решение**: Создать enum `ChatState`: -```rust -enum ChatState { - Normal, - MessageSelection { - selected_message_id: i64, - }, - Editing { - message_id: i64, - original_text: String, - }, - Reply { - message_id: i64, - preview_text: String, - }, - Forward { - message_id: i64, - selected_chat_index: usize, - }, - DeleteConfirmation { - message_id: i64, - }, - ReactionPicker { - message_id: i64, - available_reactions: Vec, - selected_index: usize, - }, - Profile { - info: ProfileInfo, - }, - SearchInChat { - query: String, - results: Vec, - current_index: usize, - }, -} -``` - -**Преимущества**: -- Невозможно иметь несколько состояний одновременно (type-safe) -- Проще обрабатывать переходы между состояниями -- Меньше полей в `App` -- Данные, связанные с состоянием, хранятся вместе с ним - -**Затронутые файлы**: -- `src/app/mod.rs` (добавить enum, убрать boolean поля) -- `src/input/main_input.rs` (изменить логику обработки на match) -- `src/ui/messages.rs` (изменить рендеринг на match) - ---- - -### 2. Разделить TdClient на несколько модулей - -**Проблема**: `TdClient` в `src/tdlib/client.rs` (~1500+ строк) делает слишком много: -- Авторизация -- Управление чатами -- Управление сообщениями -- Кеширование пользователей -- Реакции -- Network state - -**Решение**: Разделить на модули: -``` -src/tdlib/ -├── mod.rs # Экспорт публичных типов -├── client.rs # Основной TdClient -├── auth.rs # AuthManager -├── chats.rs # ChatManager -├── messages.rs # MessageManager -├── users.rs # UserCache -└── reactions.rs # ReactionManager -``` - -**Преимущества**: -- Принцип единственной ответственности -- Проще тестировать отдельные модули -- Легче найти и изменить код - ---- - -### 3. Вынести константы в отдельный модуль - -**Проблема**: Магические числа разбросаны по всему коду: -```rust -// В разных местах: -500 // MAX_MESSAGES_IN_CHAT -500 // MAX_USER_CACHE_SIZE -200 // MAX_CHATS -8 // Emoji picker columns -10 // Max input height -16 // Poll timeout (60 FPS) -``` - -**Решение**: Создать `src/constants.rs`: -```rust -// Memory limits -pub const MAX_MESSAGES_IN_CHAT: usize = 500; -pub const MAX_USER_CACHE_SIZE: usize = 500; -pub const MAX_CHATS: usize = 200; -pub const MAX_CHAT_USER_IDS: usize = 500; - -// UI constants -pub const EMOJI_PICKER_COLUMNS: usize = 8; -pub const EMOJI_PICKER_ROWS: usize = 6; -pub const MAX_INPUT_HEIGHT: usize = 10; -pub const MIN_TERMINAL_WIDTH: u16 = 80; -pub const MIN_TERMINAL_HEIGHT: u16 = 20; - -// Performance -pub const POLL_TIMEOUT_MS: u64 = 16; // 60 FPS -pub const SHUTDOWN_TIMEOUT_SECS: u64 = 2; -pub const LAZY_LOAD_USERS_PER_TICK: usize = 5; - -// TDLib -pub const TDLIB_CHAT_LIMIT: i32 = 50; -pub const TDLIB_MESSAGE_LIMIT: i32 = 50; -``` - -**Преимущества**: -- Единое место для всех констант -- Проще изменить значения -- Самодокументирующийся код - ---- - -## Приоритет 2: Улучшение типобезопасности - -### 4. Newtype pattern для ID ✅ ЗАВЕРШЕНО! - -**Статус**: ЗАВЕРШЕНО (2026-01-31) - -**Проблема**: Везде используется `i64` для `chat_id`, `message_id`, `user_id` — легко перепутать. - -**Решение**: ✅ Реализовано в `src/types.rs`: -```rust -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub struct ChatId(pub i64); - -impl ChatId { - pub fn new(id: i64) -> Self { Self(id) } - pub fn as_i64(&self) -> i64 { self.0 } -} - -impl From for ChatId { - fn from(id: i64) -> Self { ChatId(id) } -} - -impl Display for ChatId { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.0) - } -} - -// Аналогично для MessageId и UserId -``` - -**Что сделано**: -- ✅ Создан `src/types.rs` с тремя типами: `ChatId`, `MessageId`, `UserId` -- ✅ Добавлены методы `new()`, `as_i64()`, `From`, `Display` -- ✅ Реализованы traits: `Hash`, `Eq`, `Serialize`, `Deserialize` -- ✅ Обновлены 15+ модулей: - - `tdlib/types.rs`, `tdlib/chats.rs`, `tdlib/messages.rs`, `tdlib/users.rs` - - `tdlib/reactions.rs`, `tdlib/client.rs` - - `app/mod.rs`, `app/chat_state.rs`, `input/main_input.rs` - - Test helpers: `app_builder.rs`, `test_data.rs` -- ✅ Исправлены 53 ошибки компиляции -- ✅ Код компилируется успешно - -**Преимущества**: -- ✅ Невозможно случайно передать message_id вместо chat_id -- ✅ Компилятор ловит ошибки на этапе компиляции -- ✅ Улучшенная читаемость кода -- ✅ Самодокументирующиеся типы - ---- - -### 5. Создать enum для ошибок - -**Проблема**: Везде используется `Result` — теряется контекст ошибок. - -**Решение**: Создать `src/error.rs`: -```rust -#[derive(Debug, thiserror::Error)] -pub enum TeletuiError { - #[error("TDLib error: {0}")] - TdLib(String), - - #[error("Configuration error: {0}")] - Config(String), - - #[error("Network error: {0}")] - Network(String), - - #[error("Authentication error: {0}")] - Auth(String), - - #[error("Invalid timezone format: {0}")] - InvalidTimezone(String), - - #[error("Invalid color: {0}")] - InvalidColor(String), - - #[error("IO error: {0}")] - Io(#[from] std::io::Error), -} - -pub type Result = std::result::Result; -``` - -**Зависимости**: `thiserror = "1.0"` - -**Преимущества**: -- Типобезопасная обработка ошибок -- Понятные сообщения об ошибках -- Возможность pattern matching - ---- - -### 6. Группировка полей MessageInfo ✅ ЗАВЕРШЕНО! - -**Статус**: ЗАВЕРШЕНО (2026-01-31) - -**Проблема**: `MessageInfo` имеет слишком много плоских полей (~15+). - -**Решение**: ✅ Реализовано - группировка в логические структуры: -```rust -pub struct MessageInfo { - pub metadata: MessageMetadata, - pub content: MessageContent, - pub state: MessageState, - pub interactions: MessageInteractions, -} - -pub struct MessageMetadata { - pub id: MessageId, - pub chat_id: ChatId, - pub sender_id: UserId, - pub date: i32, -} - -pub struct MessageContent { - pub text: String, - pub formatted_text: Option, - pub media_type: Option, -} - -pub struct MessageState { - pub is_outgoing: bool, - pub is_edited: bool, - pub is_pinned: bool, -} - -pub struct MessageInteractions { - pub reply_to_message_id: Option, - pub forward_info: Option, - pub reactions: Vec, - pub read_count: i32, -} -``` - -**Что сделано**: -- ✅ Созданы 4 структуры: MessageMetadata, MessageContent, MessageState, MessageInteractions -- ✅ Обновлена MessageInfo для использования новых структур -- ✅ Добавлен конструктор MessageInfo::new() -- ✅ Добавлены getter методы (id(), text(), sender_name(), и др.) -- ✅ Обновлены 14 файлов (~200+ обращений): - - ui/messages.rs: рендеринг (100+ изменений) - - app/mod.rs: логика приложения - - input/main_input.rs: обработка ввода - - tdlib/client.rs: обработка updates - - Все тестовые файлы -- ✅ Код компилируется успешно - -**Преимущества**: -- ✅ Логическая группировка данных -- ✅ Проще добавлять новые поля -- ✅ Улучшенная читаемость кода -- ✅ Меньше параметров в конструкторах (используется new()) - ---- - -### MessageBuilder pattern ✅ ЗАВЕРШЕНО! - -**Статус**: ЗАВЕРШЕНО (2026-01-31) - -**Проблема**: MessageInfo::new() принимает 14 параметров, что неудобно и подвержено ошибкам. - -**Решение**: ✅ Реализован MessageBuilder с fluent API: -```rust -let message = MessageBuilder::new(MessageId::new(123)) - .sender_name("Alice") - .text("Hello, world!") - .outgoing() - .read() - .build(); -``` - -**Что сделано**: -- ✅ Создана структура MessageBuilder в tdlib/types.rs -- ✅ Реализовано 16 методов fluent API: - - Базовые: sender_name, text, entities, date, edit_date - - Флаги: outgoing, incoming, read, unread, edited - - Права: editable, deletable_for_self, deletable_for_all - - Дополнительно: reply_to, forward_from, reactions, add_reaction -- ✅ Обновлён convert_message() для использования builder -- ✅ Добавлены 6 unit тестов -- ✅ Код компилируется успешно - -**Преимущества**: -- ✅ Более читабельный код -- ✅ Самодокументирующийся API -- ✅ Гибкость в установке опциональных полей -- ✅ Проще поддерживать и расширять - -**🎉 Priority 2 ЗАВЕРШЁН НА 100%! 🎉** - ---- - -## Приоритет 3: Архитектурные улучшения - -### 7. Выделить UI компоненты ✅ ЗАВЕРШЕНО! - -**Статус**: ЗАВЕРШЕНО (5/5 компонентов, 2026-02-02) - -**Проблема**: Код рендеринга дублируется, сложно переиспользовать. - -**Решение**: ✅ Создано `src/ui/components/`: -``` -src/ui/components/ -├── mod.rs ✅ -├── modal.rs ✅ (87 строк, полностью реализовано) -├── input_field.rs ✅ (54 строк, полностью реализовано) -├── message_bubble.rs ⚠️ (27 строк, placeholder, блокируется P3.8 и P3.9) -├── chat_list_item.rs ✅ (78 строк, полностью реализовано) -└── emoji_picker.rs ✅ (112 строк, полностью реализовано) -``` - -**Что сделано**: -- ✅ Создана структура модулей `src/ui/components/` -- ✅ Реализовано 5 из 5 компонентов: - - `modal.rs` — базовые модалки с центрированием (87 строк) - - `input_field.rs` — текстовое поле с курсором (54 строки) - - `chat_list_item.rs` — элемент списка чатов (78 строк) - - `emoji_picker.rs` — picker реакций (112 строк) - - `message_bubble.rs` — рендеринг сообщений (437 строк) ✅ **ЗАВЕРШЕНО 2026-02-02** -- ✅ Все компоненты используются в UI -- ✅ `messages.rs` использует `message_grouping` и компоненты - -**Преимущества**: -- ✅ Переиспользуемые компоненты -- ✅ Консистентный UI -- ✅ Проще тестировать - ---- - -### 8. Вынести форматирование в отдельный модуль - -**Проблема**: Markdown форматирование захардкожено в `messages.rs` (~200+ строк). - -**Решение**: Создать `src/formatting.rs`: -```rust -pub struct FormattedSpan { - pub text: String, - pub style: Style, -} - -pub fn format_text_entities( - text: &str, - entities: &[TextEntity], -) -> Vec { - // Вся логика форматирования -} -``` - -**Преимущества**: -- Разделение ответственности -- Можно тестировать отдельно -- Переиспользование в других местах - ---- - -### 9. Вынести логику группировки сообщений ✅ ЗАВЕРШЕНО! - -**Статус**: ЗАВЕРШЕНО (2026-01-31) - -**Проблема**: Логика группировки сообщений смешана с рендерингом в `messages.rs`. - -**Решение**: ✅ Создан `src/message_grouping.rs`: -```rust -pub enum MessageGroup { - DateSeparator(i32), - SenderHeader { is_outgoing: bool, sender_name: String }, - Message(MessageInfo), -} - -pub fn group_messages(messages: &[MessageInfo]) -> Vec { - // Логика группировки по дате и отправителю -} -``` - -**Что сделано**: -- ✅ Создан модуль `src/message_grouping.rs` (255 строк) -- ✅ Реализован enum `MessageGroup` с тремя вариантами -- ✅ Реализована функция `group_messages()` для группировки по дате и отправителю -- ✅ Добавлена полная документация с примерами -- ✅ Написано 5 unit тестов (все проходят) -- ✅ Модуль добавлен в `src/lib.rs` -- ✅ Код компилируется успешно - -**Преимущества**: -- ✅ Чистое разделение логики и представления -- ✅ Легче тестировать группировку (покрыто тестами) -- ✅ Можно переиспользовать -- ✅ Готово для интеграции в `messages.rs` - ---- - -### 10. Hotkey mapping в конфиг ✅ ЗАВЕРШЕНО! - -**Статус**: ЗАВЕРШЕНО (2026-01-31) - -**Проблема**: Хоткеи захардкожены в коде, нельзя настроить. - -**Решение**: ✅ Добавлено в `config.toml`: -```toml -[hotkeys] -# Навигация (vim + русские + стрелки) -up = ["k", "р", "Up"] -down = ["j", "о", "Down"] -left = ["h", "р", "Left"] -right = ["l", "д", "Right"] - -# Действия (англ + русские) -reply = ["r", "к"] -forward = ["f", "а"] -delete = ["d", "в", "Delete"] -copy = ["y", "н"] -react = ["e", "у"] -profile = ["i", "ш"] -``` - -**Что сделано**: -- ✅ Создана структура `HotkeysConfig` в `src/config.rs` -- ✅ Добавлены поля для всех действий (10 hotkeys) -- ✅ Реализован метод `matches(key: KeyCode, action: &str) -> bool` -- ✅ Поддержка символьных клавиш (англ + русские) -- ✅ Поддержка специальных клавиш (Up, Down, Left, Right, Delete, Enter, Esc) -- ✅ Добавлены дефолтные значения для всех hotkeys -- ✅ Написано 9 unit тестов (all passing ✅) -- ✅ Добавлена полная rustdoc документация -- ✅ Config::default() включает hotkeys - -**Примеры использования**: -```rust -let config = Config::default(); - -// Проверяем английскую клавишу -if config.hotkeys.matches(KeyCode::Char('r'), "reply") { - // Начать ответ -} - -// Проверяем русскую клавишу -if config.hotkeys.matches(KeyCode::Char('к'), "reply") { - // Начать ответ (та же логика) -} - -// Проверяем стрелку -if config.hotkeys.matches(KeyCode::Up, "up") { - // Вверх по списку -} -``` - -**Преимущества**: -- ✅ Пользовательская настройка хоткеев через config.toml -- ✅ Проще добавлять новые действия -- ✅ Документация хоткеев в конфиге -- ✅ Централизованное управление клавишами -- ✅ Поддержка русской раскладки out of the box - -**🎉 Priority 3 ЗАВЕРШЁН НА 100%! 🎉** - ---- - -## Приоритет 4: Качество кода - -### 11. Добавить юнит-тесты ✅ ЗАВЕРШЕНО! - -**Статус**: ЗАВЕРШЕНО 100% (+106 строк тестов, 2026-02-01) - -**Что сделано**: -- ✅ Добавлены 9 unit тестов в `src/utils.rs` (в секции `#[cfg(test)]`) -- ✅ Покрыты все edge cases для форматирования времени -- ✅ Тестирование приватных функций через публичный API -- ✅ Все 54 unit теста проходят (было 45, +9 новых) - -**Добавленные тесты**: -- `format_timestamp_with_tz` - положительный offset (+03:00) -- `format_timestamp_with_tz` - отрицательный offset (-05:00) -- `format_timestamp_with_tz` - нулевой offset (UTC) -- `format_timestamp_with_tz` - переход через полночь -- `format_timestamp_with_tz` - невалидный timezone (fallback) -- `get_day` - расчет дня из timestamp -- `get_day_grouping` - группировка сообщений по дням -- `format_datetime` - полная дата и время с MSK -- `parse_timezone_offset` - через публичный API (приватная функция) - -**Примеры**: -```rust -#[test] -fn test_format_timestamp_with_tz_positive_offset() { - let timestamp = 1640000000; // 2021-12-20 11:33:20 UTC - assert_eq!(format_timestamp_with_tz(timestamp, "+03:00"), "14:33"); -} - -#[test] -fn test_get_day_grouping() { - let msg1 = 1640000000; // 2021-12-20 09:33:20 - let msg2 = 1640040000; // 2021-12-20 20:40:00 - assert_eq!(get_day(msg1), get_day(msg2)); // Один день -} -``` - -**Запуск**: `cargo test --lib utils::tests` - ---- - -### 12. Добавить rustdoc комментарии ✅ ЗАВЕРШЕНО! - -**Статус**: ЗАВЕРШЕНО 100% (+900 строк документации, 2026-02-01) - -**Что сделано**: -- ✅ Документированы все TDLib модули (auth, chats, messages, reactions, users) -- ✅ Документированы все публичные структуры и методы -- ✅ Добавлены примеры использования (34 doctests) -- ✅ Документация для Config и утилит (formatting) -- ✅ Все doctests работают (30 ignored для async, 4 compiled) - -**Модули с документацией**: -- `src/tdlib/auth.rs` - AuthManager, AuthState (6 doctests) -- `src/tdlib/chats.rs` - ChatManager (8 doctests) -- `src/tdlib/messages.rs` - MessageManager, 14 методов (6 doctests) -- `src/tdlib/reactions.rs` - ReactionManager (3 doctests) -- `src/tdlib/users.rs` - UserCache, LruCache (2 doctests) -- `src/config.rs` - Config, ColorsConfig, GeneralConfig (4 doctests) -- `src/formatting.rs` - Форматирование текста (2 doctests) -- `src/tdlib/client.rs` - TdClient (1 doctest) -- `src/app/mod.rs` - App (1 doctest) -- `src/message_grouping.rs` - Группировка (1 doctest) -- `src/tdlib/types.rs` - MessageBuilder (1 doctest) - -**Примеры**: -```rust -/// Менеджер авторизации TDLib. -/// -/// # Examples -/// -/// ```ignore -/// let mut auth_manager = AuthManager::new(client_id); -/// auth_manager.send_phone_number("+1234567890".to_string()).await?; -/// auth_manager.send_code("12345".to_string()).await?; -/// ``` -pub struct AuthManager { ... } -``` - -**Генерация**: `cargo doc --open` - ---- - -### 13. Config валидация ✅ ЗАВЕРШЕНО! - -**Статус**: ЗАВЕРШЕНО 100% (+149 строк тестов, 2026-02-01) - -**Что сделано**: -- ✅ Валидация уже была реализована в `config.rs:344-389` -- ✅ Вызов валидации в `Config::load():450-456` -- ✅ Добавлено 15 comprehensive тестов для полного покрытия -- ✅ Все 23 config теста проходят (8 существующих + 15 новых) - -**Добавленные тесты**: -- Валидация дефолтного конфига -- Timezone: валидный (+03:00, -05:00), невалидный (без знака) -- Цвета: все 18 стандартных ratatui цветов -- Невалидные цвета (rainbow, purple, pink) -- Case-insensitive парсинг (RED, Green, YELLOW) -- parse_color() для всех вариантов (standard, light, gray/grey) -- Fallback к White для невалидных цветов - -**Реализация**: Уже была добавлена ранее: -```rust -impl Config { - pub fn validate(&self) -> Result<(), TeletuiError> { - // Проверка timezone - if !self.general.timezone.starts_with('+') - && !self.general.timezone.starts_with('-') { - return Err(TeletuiError::InvalidTimezone( - format!("Timezone must start with + or -: {}", self.general.timezone) - )); - } - - // Проверка цветов - let valid_colors = [ - "black", "red", "green", "yellow", "blue", "magenta", - "cyan", "gray", "white", "darkgray", "lightred", - "lightgreen", "lightyellow", "lightblue", - "lightmagenta", "lightcyan" - ]; - - for color_name in [ - &self.colors.incoming_message, - &self.colors.outgoing_message, - &self.colors.selected_message, - &self.colors.reaction_chosen, - &self.colors.reaction_other, - ] { - if !valid_colors.contains(&color_name.to_lowercase().as_str()) { - return Err(TeletuiError::InvalidColor( - format!("Unknown color: {}", color_name) - )); - } - } - - Ok(()) - } -} -``` - -Вызывать при загрузке: -```rust -pub fn load() -> Self { - let config = // ... загрузка из файла - if let Err(e) = config.validate() { - eprintln!("Config validation error: {}", e); - return Self::default(); - } - config -} -``` - ---- - -### 14. Async/await консистентность ✅ ЗАВЕРШЕНО! - -**Статус**: ЗАВЕРШЕНО 100% (проверка кода, 2026-02-01) - -**Проверка показала**: Код уже соответствует требованиям! - -**Что проверено**: -- ✅ `std::fs` используется только в `Config::load()` при старте (не в async runtime) -- ✅ `std::thread::sleep` - не найдено ни разу -- ✅ `tokio::time::sleep` используется в async функциях (messages.rs) -- ✅ `tokio::time::timeout` используется (auth.rs, main_input.rs, main.rs) -- ✅ Все файловые операции вызываются синхронно при инициализации - -**Детали**: -```rust -// ✓ ПРАВИЛЬНО: Config::load() при старте, перед async runtime -#[tokio::main] -async fn main() -> Result<(), io::Error> { - let config = config::Config::load(); // Синхронно, при инициализации - // ... async runtime начинается позже -} - -// ✓ ПРАВИЛЬНО: tokio::time::sleep в async функциях -async fn load_messages() { - use tokio::time::{sleep, Duration}; - sleep(Duration::from_millis(100)).await; // Не блокирует -} -``` - -**Вывод**: Блокирующих вызовов в async контексте нет. Код async-clean. - ---- - -## Приоритет 5: Опциональные улучшения - -### 15. Feature flags для зависимостей ✅ ЗАВЕРШЕНО - -**Проблема**: Все зависимости всегда включены. - -**Решение**: В `Cargo.toml`: -```toml -[features] -default = ["clipboard", "url-open"] -clipboard = ["dep:arboard"] -url-open = ["dep:open"] - -[dependencies] -arboard = { version = "3.4", optional = true } -open = { version = "5.0", optional = true } -``` - -**Реализовано**: -- ✅ Добавлены feature flags в Cargo.toml -- ✅ Зависимости `arboard` и `open` сделаны опциональными -- ✅ Условная компиляция в `src/input/main_input.rs`: - - `#[cfg(feature = "url-open")]` для `open::that()` - - `#[cfg(feature = "clipboard")]` / `#[cfg(not(feature = "clipboard"))]` для `copy_to_clipboard()` -- ✅ Условная компиляция в `tests/copy.rs`: - - `#[cfg(all(test, feature = "clipboard"))]` для clipboard тестов - -**Преимущества**: -- ✅ Уменьшение размера бинарника -- ✅ Опциональная функциональность -- ✅ Graceful degradation при отключении фич - ---- - -### 16. LRU cache обобщение ✅ ЗАВЕРШЕНО - -**Проблема**: Отдельные LRU кеши для `user_names` и `user_statuses`. - -**Решение**: Создать обобщённый `LruCache` или использовать готовый крейт `lru = "0.12"`. - -**Реализовано**: -- ✅ Обобщённая структура `LruCache` в `src/tdlib/users.rs` -- ✅ Type parameters: - - `K: Eq + Hash + Clone + Copy` — тип ключа - - `V: Clone` — тип значения -- ✅ Обновлена `UserCache`: - - `user_usernames: LruCache` - - `user_names: LruCache` - - `user_statuses: LruCache` -- ✅ Все методы обобщены: `get()`, `peek()`, `insert()`, `contains_key()`, `len()` - -**Преимущества**: -- ✅ Переиспользуемая реализация для любых типов ключей -- ✅ Type-safe кеширование -- ✅ Без дополнительных зависимостей - ---- - -### 17. Tracing вместо println! ✅ ЗАВЕРШЕНО - -**Проблема**: Используется `eprintln!` для логов. - -**Решение**: Использовать `tracing`: -```rust -use tracing::{info, warn, error, debug}; - -// Вместо -eprintln!("Warning: Could not load config: {}", e); - -// Использовать -warn!("Could not load config: {}", e); -``` - -**Реализовано**: -- ✅ Добавлены зависимости в `Cargo.toml`: - - `tracing = "0.1"` - - `tracing-subscriber = { version = "0.3", features = ["env-filter"] }` -- ✅ Инициализирован subscriber в `main.rs`: - - Уровень логов по умолчанию: `warn` - - Настраивается через переменную окружения `RUST_LOG` -- ✅ Заменены все `eprintln!` на tracing макросы в `src/config.rs`: - - 4× `warn!()` для предупреждений - - 1× `error!()` для ошибок валидации - - 1× `warn!()` для fallback на дефолтную конфигурацию - -**Преимущества**: -- ✅ Структурированное логирование -- ✅ Настраиваемые уровни логов (через `RUST_LOG`) -- ✅ Лучшая интеграция с async кодом -- ✅ Единый подход к логированию во всём проекте - ---- - -## Метрики прогресса - -- [x] Priority 1: 3/3 задач ✅ ЗАВЕРШЕНО! - - [x] P1.1 — ChatState enum - - [x] P1.2 — Разделить TdClient - - [x] P1.3 — Константы -- [x] Priority 2: 5/5 задач ✅ ЗАВЕРШЕНО! 🎉 - - [x] P2.5 — Error enum - - [x] P2.3 — Config validation - - [x] P2.4 — Newtype для ID - - [x] P2.6 — MessageInfo реструктуризация - - [x] P2.7 — MessageBuilder pattern -- [x] Priority 3: 4/4 задач ✅ ЗАВЕРШЕНО! 🎉🎉 - - [x] P3.7 — UI компоненты (5/5) ✅ ПОЛНОСТЬЮ ЗАВЕРШЕНО 2026-02-02! - - [x] P3.8 — Formatting модуль ✅ - - [x] P3.9 — Message Grouping ✅ - - [x] P3.10 — Hotkey Mapping ✅ -- [x] Priority 4: 4/4 задач ✅ ЗАВЕРШЕНО! 🎉🎉🎉 - - [x] P4.11 — Unit tests ✅ - - [x] P4.12 — Rustdoc ✅ - - [x] P4.13 — Config validation ✅ - - [x] P4.14 — Async/await consistency ✅ -- [x] Priority 5: 3/3 задач ✅ ЗАВЕРШЕНО! 🎉🎉🎉 - - [x] P5.15 — Feature flags ✅ - - [x] P5.16 — LRU cache обобщение ✅ - - [x] P5.17 — Tracing ✅ -- [x] Priority 6: 1/1 задач ✅ ЗАВЕРШЕНО! 🎉🎉🎉🎉 - - [x] P6.1 — Dependency Injection для TdClient (ВСЕ 8 этапов завершены!) - -**Всего**: 21/21 задач (100%) 🎊🎉 РЕФАКТОРИНГ ПОЛНОСТЬЮ ЗАВЕРШЁН! - ---- - -## Предусловие: Тесты - -**ВАЖНО**: Перед началом рефакторинга необходимо написать тесты! - -См. [TESTING_ROADMAP.md](TESTING_ROADMAP.md) для плана покрытия тестами. - -Минимальное покрытие для начала рефакторинга: -- ✅ Фаза 0: Инфраструктура (helpers, fake client) -- ✅ Snapshot тесты для основных экранов (chat list, messages) -- ✅ Integration тесты для критичных flow (send, edit, navigation) - -**Зачем**: Тесты гарантируют, что рефакторинг не сломает функциональность. - ---- - -## Порядок выполнения - -Рекомендуется выполнять в следующем порядке: - -1. **P1.3** — Константы (быстро, малый риск) -2. **P1.1** — ChatState enum (высокий impact) -3. **P2.5** — Error enum (улучшает весь код) -4. **P4.11** — Тесты для utils (базовая проверка) -5. **P1.2** — Разделить TdClient (большой рефакторинг) -6. **P2.4** — Newtype для ID (широкие изменения) -7. **P3.7** — UI компоненты (постепенно) -8. **P3.8** — Форматирование (изоляция логики) -9. **P3.9** — Группировка сообщений (изоляция логики) -10. Остальные по необходимости - ---- - -## Принципы рефакторинга - -1. **Один PR = одна задача** — не смешивать рефакторинг разных областей -2. **Тесты прежде всего** — добавить тесты перед рефакторингом -3. **Обратная совместимость** — сохранять работоспособность на каждом шаге -4. **Маленькие шаги** — лучше 10 маленьких PR, чем 1 огромный -5. **Документация** — обновлять документацию после изменений - ---- - -## Приоритет 6: Улучшение тестируемости - -### P6.1 — Dependency Injection для TdClient ✅ ЗАВЕРШЕНО! - -**Статус**: ✅ ЗАВЕРШЕНО (ВСЕ 8 этапов завершены!) - 2026-02-02 - -**Проблема**: - -В текущей реализации тесты создают **настоящий** `TdClient`, который вызывает `tdlib_rs::create_client()`. Это приводит к: -1. **Зависанию тестов** — TDLib не инициализирован и блокирует async вызовы -2. **Verbose логи** — TDLib выводит много логов при создании клиента -3. **Медленные тесты** — создание TDLib клиента занимает время -4. **Хаки в продакшн коде** — пришлось добавить `tokio::time::timeout(100ms)` для всех вызовов TDLib чтобы тесты не зависали - -**Проблемные места** (src/input/main_input.rs): -```rust -// Строка 867-870: timeout для send_chat_action при вводе символов -let _ = tokio::time::timeout( - Duration::from_millis(100), - app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Typing) -).await; - -// Строка 683-686: timeout для set_draft_message при закрытии чата -let _ = tokio::time::timeout( - Duration::from_millis(100), - app.td_client.set_draft_message(chat_id, draft_text) -).await; - -// Строка 592-594: timeout для send_chat_action Cancel при отправке сообщения -let _ = tokio::time::timeout( - Duration::from_millis(100), - app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Cancel) -).await; -``` - -**Решения**: - -#### Вариант 1: Trait-based Dependency Injection (рекомендуется) - -Создать trait `TdClientTrait` и сделать `App` generic: - -```rust -// src/tdlib/trait.rs -#[async_trait] -pub trait TdClientTrait { - async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction); - async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<()>; - async fn get_chat_history(&mut self, chat_id: ChatId, limit: i32) -> Result>; - async fn send_message(&mut self, chat_id: ChatId, text: String, reply_to: Option, reply_info: Option) -> Result; - async fn edit_message(&mut self, chat_id: ChatId, message_id: MessageId, text: String) -> Result; - async fn delete_messages(&mut self, chat_id: ChatId, message_ids: Vec, revoke: bool) -> Result<()>; - async fn forward_messages(&mut self, to_chat_id: ChatId, from_chat_id: ChatId, message_ids: Vec) -> Result<()>; - async fn toggle_reaction(&self, chat_id: ChatId, message_id: MessageId, emoji: String) -> Result<()>; - async fn get_message_available_reactions(&self, chat_id: ChatId, message_id: MessageId) -> Result>; - async fn search_messages(&self, chat_id: ChatId, query: &str) -> Result>; - async fn get_profile_info(&self, chat_id: ChatId) -> Result; - async fn leave_chat(&self, chat_id: ChatId) -> Result<()>; - async fn load_chats(&mut self, limit: usize) -> Result, String>; - async fn load_folder_chats(&mut self, folder_id: i32, limit: usize) -> Result<(), String>; - async fn get_pinned_messages(&self, chat_id: ChatId) -> Result, String>; - async fn load_current_pinned_message(&mut self, chat_id: ChatId); - async fn fetch_missing_reply_info(&mut self); - // ... все остальные методы - - // Синхронные методы - fn current_chat_messages(&self) -> &[MessageInfo]; - fn current_chat_messages_mut(&mut self) -> &mut Vec; - fn set_current_chat_id(&mut self, chat_id: Option); - fn folders(&self) -> &[FolderInfo]; - fn network_state(&self) -> NetworkState; - fn typing_status(&self) -> Option<(i64, String)>; - fn current_pinned_message(&self) -> Option<&MessageInfo>; - fn push_message(&mut self, message: MessageInfo); - fn set_typing_status(&mut self, status: Option<(i64, String)>); - fn set_current_pinned_message(&mut self, message: Option); -} - -// Real implementation -#[async_trait] -impl TdClientTrait for TdClient { - // Реализация всех методов, делегируя к существующим - async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) { - self.send_chat_action(chat_id, action).await - } - // ... остальные методы -} - -// Fake implementation для тестов -#[async_trait] -impl TdClientTrait for FakeTdClient { - // Реализация для тестов (уже есть в tests/helpers/fake_tdclient.rs) - async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) { - self.chat_actions.lock().unwrap().push((chat_id.as_i64(), action.to_string())); - } - // ... остальные методы -} - -// App становится generic -pub struct App { - pub td_client: T, - pub config: Config, - // ... остальные поля -} - -impl App { - pub fn new(config: Config, td_client: T) -> Self { - // ... - } - // ... все остальные методы -} - -// Специализация для продакшена -impl App { - pub fn new_default(config: Config) -> Self { - Self::new(config, TdClient::new()) - } -} - -// TestAppBuilder для тестов -impl TestAppBuilder { - pub fn build(self) -> App { - let td_client = FakeTdClient::new() - .with_chats(self.chats) - .with_messages(self.selected_chat_id.unwrap_or(0), self.messages); - - App::new(self.config, td_client) - } -} -``` - -**Плюсы**: -- ✅ Чистая архитектура, настоящий dependency injection -- ✅ Тесты не создают реальный TDLib — **быстрые и тихие** -- ✅ Убираем timeout'ы из продакшн кода — **чистота** -- ✅ Легко мокировать для unit-тестов -- ✅ Соответствует принципам SOLID (Dependency Inversion) - -**Минусы**: -- ❌ Большой рефакторинг (~50+ файлов) -- ❌ Усложнение кода (generics везде: `App`, `handle_input`) -- ❌ Потеря простоты для небольшого проекта -- ❌ Нужна библиотека `async-trait` для async методов в trait - -**Затронутые файлы**: -- `src/tdlib/trait.rs` (новый) — trait определение -- `src/tdlib/client.rs` — impl TdClientTrait for TdClient -- `src/tdlib/mod.rs` — экспорт trait -- `src/app/mod.rs` — App -- `src/input/main_input.rs` — функции становятся generic -- `src/input/auth.rs` — функции становятся generic -- `src/ui/*.rs` — функции рендеринга становятся generic -- `src/main.rs` — использовать App -- `tests/helpers/fake_tdclient.rs` — impl TdClientTrait for FakeTdClient -- `tests/helpers/app_builder.rs` — build() возвращает App -- Все интеграционные тесты (~15 файлов) - -**Оценка трудозатрат**: ~2-3 дня работы - ---- - -#### Вариант 2: Enum Dispatch (компромисс) - -```rust -// src/tdlib/wrapper.rs -pub enum TdClientWrapper { - Real(TdClient), - Fake(FakeTdClient), -} - -impl TdClientWrapper { - async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) { - match self { - Self::Real(c) => c.send_chat_action(chat_id, action).await, - Self::Fake(c) => c.send_chat_action(chat_id, action).await, - } - } - // ... все остальные методы с match на обе ветки -} - -// App использует wrapper -pub struct App { - pub td_client: TdClientWrapper, - // ... -} -``` - -**Плюсы**: -- ✅ Меньше изменений чем trait (нет generics) -- ✅ Тесты используют Fake -- ✅ Проще понять чем trait + generics - -**Минусы**: -- ❌ Всё равно много boilerplate (каждый метод требует match) -- ❌ Runtime dispatch overhead (небольшой) -- ❌ Не такой чистый как trait -- ❌ В продакшене всегда Real, но проверка match всё равно есть - -**Затронутые файлы**: ~20-30 файлов (меньше чем Вариант 1) - -**Оценка трудозатрат**: ~1 день работы - ---- - -#### Вариант 3: Оставить как есть (текущее состояние) - -**Обоснование**: -- Timeout'ы — это не "хак", а **защита от зависания UI** -- Даже в продакшене UI не должен зависать если TDLib глючит -- 100ms timeout на typing action и draft — нормально, это не критичные операции -- Защищает от deadlock'ов и network issues -- Простота важнее для небольшого проекта - -**Плюсы**: -- ✅ Нет дополнительной работы -- ✅ Код остаётся простым -- ✅ Timeout'ы улучшают надёжность даже в продакшене -- ✅ Тесты работают (хоть и создают TDLib) - -**Минусы**: -- ⚠️ Verbose логи TDLib в тестах (можно игнорировать) -- ⚠️ Тесты чуть медленнее (~0.1s на тест из-за инициализации TDLib) -- ⚠️ Timeout'ы в продакшн коде (но это не обязательно плохо) - ---- - -**Рекомендация**: - -- **Для прототипа/MVP**: Вариант 3 (текущее состояние) ✅ -- **Для production-ready проекта**: Вариант 1 (trait injection) ⭐ -- **Для быстрого улучшения**: Вариант 2 (enum dispatch) - -**Финальное решение** (2026-02-02): Реализован **Вариант 1 (trait injection)** ✅🎉 - -После завершения всех 8 этапов рефакторинга: -- ✅ Создан `TdClientTrait` с 40+ методами -- ✅ Реализован trait для `TdClient` и `FakeTdClient` -- ✅ `App` стал generic: `App` -- ✅ Все UI и input handlers обновлены на generic -- ✅ Тесты используют `FakeTdClient` (быстро, без логов TDLib) -- ✅ Продакшн использует `TdClient` (реальный TDLib) -- ✅ Timeout'ы убраны из продакшн кода -- ✅ Исправлен stack overflow в 6 методах trait реализации -- ✅ Все 196+ тестов проходят - -**Преимущества реализации**: -- 🛡️ Чистая архитектура без timeout хаков -- ⚡ Быстрые тесты (FakeTdClient работает мгновенно) -- 📝 Нет verbose логов TDLib в тестах -- 🔧 Type-safe dependency injection -- 🎯 Легко добавлять новые реализации trait - ---- - -## Примечания - -- Этот документ живой и будет обновляться -- Новые пункты добавляются по мере обнаружения -- После завершения задачи отмечать в метриках -- При появлении блокеров — документировать в соответствующей секции diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index 8b8d966..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,64 +0,0 @@ -# Security Policy - -## Поддерживаемые версии - -| Версия | Поддержка | -| ------ | ------------------ | -| 0.1.x | :white_check_mark: | - -## Сообщить об уязвимости - -Если вы обнаружили уязвимость в безопасности, пожалуйста: - -1. **НЕ создавайте публичный issue** -2. Отправьте email на: [security@example.com] -3. Опишите: - - Тип уязвимости - - Шаги для воспроизведения - - Потенциальное влияние - - Предложения по исправлению (если есть) - -## Безопасность credentials - -### Важно - -- **НИКОГДА** не коммитьте файлы с credentials в git -- Файлы `credentials` и `.env` уже добавлены в `.gitignore` -- TDLib сессия в `tdlib_data/` содержит токены авторизации — НЕ делитесь этой папкой - -### Рекомендации - -1. **Используйте XDG config directory**: - - Храните credentials в `~/.config/tele-tui/credentials` - - Установите права доступа: `chmod 600 ~/.config/tele-tui/credentials` - -2. **Защита сессии**: - - Папка `tdlib_data/` содержит вашу авторизованную сессию - - Не копируйте её на другие машины - - При компрометации — удалите папку и авторизуйтесь заново - -3. **Двухфакторная аутентификация**: - - Настоятельно рекомендуется включить 2FA в Telegram - - Это защитит ваш аккаунт даже при утечке API credentials - -## Известные ограничения - -### TDLib -- Приложение использует официальную библиотеку TDLib от Telegram -- Безопасность зависит от актуальности TDLib (автообновление через tdlib-rs) - -### Конфигурация -- Конфигурационный файл `config.toml` НЕ содержит чувствительных данных -- Credentials хранятся отдельно в файле `credentials` - -## Обновления безопасности - -Мы оперативно реагируем на сообщения об уязвимостях и выпускаем патчи в течение: -- **Критические**: 24-48 часов -- **Высокие**: 3-7 дней -- **Средние**: 2-4 недели -- **Низкие**: включаются в следующий релиз - -## Спасибо - -Мы ценим ваш вклад в безопасность проекта! diff --git a/TESTING_PROGRESS.md b/TESTING_PROGRESS.md deleted file mode 100644 index 7299fcb..0000000 --- a/TESTING_PROGRESS.md +++ /dev/null @@ -1,571 +0,0 @@ -# Testing Progress Report - -## Текущий статус: ВСЕ ТЕСТЫ ЗАВЕРШЕНЫ! 🎉🎊🚀 - -Все UI snapshot тесты и все integration тесты готовы! Превзошли план! - -Дата: 2026-01-30 (обновлено #6 — ФИНАЛ) - ---- - -## ✅ Что сделано - -### Phase 2: Integration Tests (99%) 🔥 - -**Всего:** 73 integration теста из 74 запланированных - -#### Phase 2.1: Send Message Flow (100%) ✅ -**Файл**: `tests/send_message.rs` (6 тестов) - -- ✅ Отправка текстового сообщения -- ✅ Отправка нескольких сообщений обновляет список -- ✅ Отправка с markdown форматированием -- ✅ Отправка в разные чаты -- ✅ Получение входящего сообщения -- ✅ Отправка с reply - -#### Phase 2.2: Edit Message Flow (100%) ✅ -**Файл**: `tests/edit_message.rs` (6 тестов) - -- ✅ Редактирование текста сообщения -- ✅ Установка edit_date после редактирования -- ✅ Проверка can_be_edited перед редактированием -- ✅ Редактирование только своих сообщений -- ✅ Множественные редактирования -- ✅ Редактирование с форматированием - -#### Phase 2.3: Delete Message Flow (100%) ✅ -**Файл**: `tests/delete_message.rs` (6 тестов) - -- ✅ Удаление сообщения из списка -- ✅ Множественные удаления -- ✅ Проверка can_be_deleted -- ✅ Удаление только своих сообщений -- ✅ Удаление из разных чатов -- ✅ Delete with revoke - -#### Phase 2.4: Reply & Forward Flow (100%) ✅ -**Файл**: `tests/reply_forward.rs` (8 тестов) - -- ✅ Reply на сообщение с превью -- ✅ Reply сохраняет связь с оригиналом -- ✅ Forward сообщения -- ✅ Forward с sender_name -- ✅ Forward в разные чаты -- ✅ Reply + Forward комбо -- ✅ Reply на forwarded сообщение -- ✅ Forward reply сообщения - -#### Phase 2.5: Reactions Flow (100%) ✅ -**Файл**: `tests/reactions.rs` (10 тестов) - -- ✅ Добавление реакции на сообщение -- ✅ Удаление реакции (toggle) -- ✅ Множественные реакции на одно сообщение -- ✅ Реакции от разных пользователей -- ✅ Подсчёт реакций -- ✅ Chosen реакция (своя) -- ✅ Реакции обновляются в реальном времени -- ✅ Получение доступных реакций чата -- ✅ Реакции на forwarded сообщения -- ✅ Очистка всех реакций - -#### Phase 2.6: Search Flow (100%) ✅ -**Файл**: `tests/search.rs` (8 тестов) - -- ✅ Поиск по названию чата -- ✅ Поиск по @username -- ✅ Поиск по сообщениям в чате -- ✅ Навигация по результатам поиска -- ✅ Case-insensitive поиск -- ✅ Поиск с пробелами -- ✅ Поиск возвращает пустой список если нет совпадений -- ✅ Очистка поиска - -#### Phase 2.7: Drafts Flow (100%) ✅ -**Файл**: `tests/drafts.rs` (7 тестов) - -- ✅ Сохранение черновика при переключении чатов -- ✅ Восстановление черновика при возврате -- ✅ Удаление черновика после отправки -- ✅ Черновики для разных чатов независимы -- ✅ Индикатор черновика в списке чатов -- ✅ Пустой черновик не сохраняется -- ✅ Черновик сохраняется при закрытии чата - -#### Phase 2.8: Navigation Flow (100%) ✅ -**Файл**: `tests/navigation.rs` (7 тестов) - -- ✅ Навигация по списку чатов (↑/↓) -- ✅ Открытие чата (Enter) -- ✅ Закрытие чата (Esc) -- ✅ Скролл сообщений (↑/↓) -- ✅ Переключение между папками (1-9) -- ✅ Навигация с wrap (переход с конца на начало) -- ✅ Навигация в пустом списке - -#### Phase 2.9: Profile Flow (100%) ✅ -**Файл**: `tests/profile.rs` (6 тестов) - -- ✅ Открытие профиля личного чата -- ✅ Профиль показывает имя и username -- ✅ Профиль показывает телефон -- ✅ Открытие профиля группы -- ✅ Профиль группы показывает участников -- ✅ Закрытие профиля (Esc) - -#### Phase 2.10: Network & Typing Flow (100%) ✅ -**Файл**: `tests/network_typing.rs` (9 тестов) - -- ✅ Typing indicator при наборе текста -- ✅ Отправка typing action -- ✅ Получение typing статуса -- ✅ Typing timeout -- ✅ Network state: WaitingForNetwork -- ✅ Network state: ConnectingToProxy -- ✅ Network state: Connecting -- ✅ Network state: Updating -- ✅ Network state: Ready - -#### Phase 2.11: Copy Flow (100%) ✅ -**Файл**: `tests/copy.rs` (9 тестов) - -- ✅ Форматирование простого сообщения -- ✅ Форматирование с forward контекстом -- ✅ Форматирование с reply контекстом -- ✅ Форматирование с forward + reply одновременно -- ✅ Форматирование длинного сообщения -- ✅ Форматирование с markdown entities -- ✅ Clipboard initialization (игнорируется в CI) -- ✅ Копирование в реальный clipboard (ручное тестирование) -- ✅ Кроссплатформенность clipboard - -#### Phase 2.12: Config Flow (100%) ✅ -**Файл**: `tests/config.rs` (11 тестов) - -- ✅ Дефолтные значения конфигурации -- ✅ Кастомные значения конфигурации -- ✅ Парсинг валидных цветов (red, green, blue, etc.) -- ✅ Парсинг light цветов (lightred, lightgreen, etc.) -- ✅ Парсинг невалидного цвета с fallback на White -- ✅ Case-insensitive парсинг цветов -- ✅ TOML сериализация и десериализация -- ✅ Частичный TOML использует дефолты -- ✅ Различные форматы timezone (+03:00, -05:00, +00:00) -- ✅ Загрузка credentials из переменных окружения -- ✅ Проверка формата ошибки когда credentials не найдены - ---- - -### Фаза 1: UI Snapshot Tests (100%) ✅ - -**Всего:** 55 snapshot тестов - -#### Фаза 1.1: Chat List (100%) ✅ -**Файл**: `tests/chat_list.rs` (9 тестов) - -#### Фаза 1.2: Messages (100%) ✅ -**Файл**: `tests/messages.rs` (18 тестов) - -#### Фаза 1.3: Modals (100%) ✅ -**Файл**: `tests/modals.rs` (8 тестов) - -#### Фаза 1.4: Input Field (100%) ✅ -**Файл**: `tests/input_field.rs` (7 тестов) - -#### Snapshot тесты для поля ввода: -- ✅ `snapshot_empty_input` — пустое поле ввода с плейсхолдером -- ✅ `snapshot_input_with_text` — поле с текстом и курсором █ -- ✅ `snapshot_input_long_text_2_lines` — длинный текст на 2 строки -- ✅ `snapshot_input_long_text_max_lines` — очень длинный текст (максимум 10 строк) -- ✅ `snapshot_input_editing_mode` — режим редактирования с превью оригинального сообщения -- ✅ `snapshot_input_reply_mode` — режим ответа с превью сообщения -- ✅ `snapshot_input_search_mode` — поле поиска с query - -#### Результаты: -- **7 новых snapshot тестов** — все проходят ✅ -- **7 snapshots приняты** через `cargo insta accept` -- **Все тесты проходят**: 90 тестов (21 chat_list + 19 input_field + 30 messages + 20 modals) - ---- - -### Фаза 1.6: Screens Snapshot Tests (100%) ✅ - -**Файл**: `tests/screens.rs` (7 тестов) - -#### Snapshot тесты для полных экранов: -- ✅ `snapshot_loading_screen_default` — экран загрузки (дефолтный) -- ✅ `snapshot_loading_screen_with_status` — экран загрузки со статусом -- ✅ `snapshot_auth_screen_phone` — экран авторизации (ввод телефона) -- ✅ `snapshot_auth_screen_code` — экран авторизации (ввод кода) -- ✅ `snapshot_auth_screen_password` — экран авторизации (ввод пароля 2FA) -- ✅ `snapshot_main_screen_empty` — главный экран (пустой список чатов) -- ✅ `snapshot_main_screen_terminal_too_small` — предупреждение о маленьком терминале - -#### Обновления TestAppBuilder: -- ✅ Добавлен метод `status_message(message)` — установить статус для loading screen -- ✅ Добавлен метод `auth_state(state)` — установить состояние авторизации -- ✅ Добавлен метод `phone_input(phone)` — установить phone input -- ✅ Добавлен метод `code_input(code)` — установить code input -- ✅ Добавлен метод `password_input(password)` — установить password input -- ✅ Добавлены поля: `status_message`, `auth_state`, `phone_input`, `code_input`, `password_input` -- ✅ Обновлен `build()` — применяет auth состояние и inputs - -#### Результаты: -- **7 новых snapshot тестов** — все проходят ✅ -- **7 snapshots приняты** через `cargo insta accept` -- **Все тесты проходят**: 127 тестов (21 chat_list + 19 input_field + 30 messages + 20 modals + 18 footer + 19 screens) - ---- - -### Фаза 1.5: Footer Snapshot Tests (100%) ✅ - -**Файл**: `tests/footer.rs` (6 тестов) - -#### Snapshot тесты для нижней панели: -- ✅ `snapshot_footer_chat_list` — footer в списке чатов -- ✅ `snapshot_footer_open_chat` — footer в открытом чате -- ✅ `snapshot_footer_network_waiting` — footer с "⚠ Нет сети" -- ✅ `snapshot_footer_network_connecting_proxy` — footer с "⏳ Прокси..." -- ✅ `snapshot_footer_network_connecting` — footer с "⏳ Подключение..." -- ✅ `snapshot_footer_search_mode` — footer в режиме поиска - -#### Изменения: -- ✅ Сделан `footer` модуль публичным в `src/ui/mod.rs` - -#### Результаты: -- **6 новых snapshot тестов** — все проходят ✅ -- **6 snapshots приняты** через `cargo insta accept` -- **Все тесты проходят**: 96 тестов (21 chat_list + 19 input_field + 30 messages + 20 modals + 18 footer) - ---- - -### Фаза 1.4: Input Field Snapshot Tests (100%) ✅ - -**Файл**: `tests/modals.rs` (8 тестов) - -#### Snapshot тесты для модальных окон: -- ✅ `snapshot_delete_confirmation_modal` — модалка подтверждения удаления -- ✅ `snapshot_emoji_picker_default` — emoji picker с дефолтным выбором -- ✅ `snapshot_emoji_picker_with_selection` — emoji picker с выбранной реакцией (курсор) -- ✅ `snapshot_profile_personal_chat` — профиль личного чата -- ✅ `snapshot_profile_group_chat` — профиль группы (с участниками) -- ✅ `snapshot_pinned_message` — закреплённое сообщение вверху чата -- ✅ `snapshot_search_in_chat` — поиск в чате с результатами -- ✅ `snapshot_forward_mode` — режим пересылки (выбор чата) - -#### Обновления TestAppBuilder: -- ✅ Добавлен метод `with_chats(chats)` — добавить несколько чатов сразу -- ✅ Добавлен метод `message_search(query)` — режим поиска по сообщениям -- ✅ Добавлен метод `forward_mode(message_id)` — режим пересылки -- ✅ Добавлены поля: `message_search_mode`, `message_search_query`, `forwarding_message_id`, `is_selecting_forward_chat` - -#### Исправления: -- ✅ Переименованы тесты с динамическими датами (today/yesterday) на фиксированный old_date -- ✅ Удалены нестабильные snapshots зависящие от текущей даты -- ✅ Все модальные режимы теперь тестируются через snapshots - -#### Результаты: -- **8 новых snapshot тестов** — все проходят ✅ -- **8 snapshots приняты** через `cargo insta accept` -- **Все тесты проходят**: 71 тест (21 chat_list + 30 messages + 20 modals) - ---- - -### Фаза 1.2: Messages Snapshot Tests (95%) ✅ - -**Файл**: `tests/messages.rs` (19 тестов) - -#### Snapshot тесты для области сообщений: -- ✅ `snapshot_empty_chat` — пустой чат без сообщений -- ✅ `snapshot_single_incoming_message` — одно входящее сообщение -- ✅ `snapshot_single_outgoing_message` — одно исходящее сообщение -- ✅ `snapshot_date_separator_today` — разделитель "Сегодня" -- ✅ `snapshot_date_separator_yesterday` — разделитель "Вчера" -- ✅ `snapshot_sender_grouping` — группировка по отправителю (Alice → Alice → Bob) -- ✅ `snapshot_outgoing_sent` — исходящее с ✓ (отправлено) -- ✅ `snapshot_outgoing_read` — исходящее с ✓✓ (прочитано) -- ✅ `snapshot_edited_message` — сообщение с индикатором ✎ -- ✅ `snapshot_long_message_wrap` — длинное сообщение с переносом -- ✅ `snapshot_markdown_bold_italic_code` — **bold** *italic* `code` -- ✅ `snapshot_markdown_link_mention` — [links](url) и @mentions -- ✅ `snapshot_markdown_spoiler` — ||спойлер|| -- ✅ `snapshot_media_placeholder` — [Фото], [Видео] и т.д. -- ✅ `snapshot_reply_message` — reply с превью оригинала -- ✅ `snapshot_forwarded_message` — ↪ Переслано от Alice -- ✅ `snapshot_single_reaction` — сообщение с одной реакцией [👍] -- ✅ `snapshot_multiple_reactions` — [👍] 5 👎 3 -- ✅ `snapshot_selected_message` — выбранное сообщение (подсветка) - -#### Обновления TestAppBuilder: -- ✅ Добавлен метод `with_message(chat_id, message)` — добавить одно сообщение -- ✅ Добавлен метод `with_messages(chat_id, messages)` — добавить несколько сообщений -- ✅ Добавлен метод `selecting_message(index)` — установить выбранное сообщение -- ✅ Обновлен `build()` — применяет сообщения к `app.td_client.current_chat_messages` - -#### Результаты: -- **19 новых snapshot тестов** — все проходят ✅ -- **19 snapshots приняты** через `cargo insta accept` -- **Все тесты проходят**: 52 теста (21 chat_list + 31 messages) - ---- - -### Фаза 0: Инфраструктура (100%) - -#### 1. Зависимости -- ✅ Добавлено `insta = "1.34"` для snapshot тестов -- ✅ Добавлено `tokio-test = "0.4"` для async тестов -- ✅ Настроен `.gitignore` для `.snap.new` файлов - -#### 2. Test Helpers (5 модулей) - -**`tests/helpers/mod.rs`** -- Экспортирует все вспомогательные модули -- Удобный доступ к TestAppBuilder, FakeTdClient и утилитам - -**`tests/helpers/test_data.rs`** -- ✅ `TestChatBuilder` — fluent API для создания тестовых чатов -- ✅ `TestMessageBuilder` — fluent API для создания тестовых сообщений -- ✅ Хелперы: `create_test_chat()`, `create_test_message()`, `create_test_user()` -- ✅ Поддержка всех полей: unread, pinned, muted, mentions, reactions, reply, forward - -**`tests/helpers/fake_tdclient.rs`** -- ✅ `FakeTdClient` — in-memory мок для интеграционных тестов -- ✅ Методы: `send_message()`, `edit_message()`, `delete_message()`, `add_reaction()` -- ✅ Tracking отправленных/отредактированных/удалённых сообщений -- ✅ Fluent API для построения клиента с данными -- ✅ Встроенные юнит-тесты для проверки мока - -**`tests/helpers/snapshot_utils.rs`** -- ✅ `buffer_to_string()` — конвертация ratatui Buffer в строку для snapshots -- ✅ `render_to_buffer()` — рендеринг UI в виртуальный терминал -- ✅ `assert_ui_snapshot!` макрос для упрощения snapshot тестов -- ✅ Удаление trailing spaces для чистых snapshots -- ✅ Встроенные тесты - -**`tests/helpers/app_builder.rs`** -- ✅ `TestAppBuilder` — fluent API для создания тестового App -- ✅ Методы: `with_chat()`, `selected_chat()`, `message_input()`, `searching()`, etc. -- ✅ Поддержка всех режимов: edit, reply, search, reaction_picker, profile -- ✅ Встроенные тесты для билдера - -#### 3. Первые UI тесты - -**`tests/ui/chat_list_test.rs`** (9 тестов) -- ✅ snapshot_empty_chat_list -- ✅ snapshot_chat_list_with_three_chats -- ✅ snapshot_chat_with_unread_count -- ✅ snapshot_chat_with_pinned -- ✅ snapshot_chat_with_muted -- ✅ snapshot_chat_with_mentions -- ✅ snapshot_selected_chat -- ✅ snapshot_chat_long_title -- ✅ snapshot_chat_search_mode - ---- - -## 📊 Метрики - -**Создано файлов**: 18 -- 5 helpers -- 6 snapshot test files (chat_list, messages, modals, input_field, footer, screens) -- 10 integration test files (send_message, edit_message, delete_message, reply_forward, reactions, search, drafts, navigation, profile, network_typing) -- 1 mod.rs - -**Строк кода**: ~6500+ -- Helpers: ~1000 строк -- Snapshot тесты: ~1200 строк -- Integration тесты: ~4300 строк - -**Тестов написано**: -- Snapshot тесты: 55 -- Integration тесты: 73 -- Helper тесты: ~12 -- **Всего: 140+ тестов** - -**Покрытие**: -- Фаза 0: Инфраструктура ✅ (100%) -- Фаза 1: UI Snapshot Tests ✅ (100%) - - 1.1 Chat List: 9/9 ✅ - - 1.2 Messages: 18/18 ✅ - - 1.3 Modals: 8/8 ✅ - - 1.4 Input Field: 7/7 ✅ - - 1.5 Footer: 6/6 ✅ - - 1.6 Screens: 7/7 ✅ -- Фаза 2: Integration Tests ✅ (100%!) - - 2.1 Send Message: 6/6 ✅ - - 2.2 Edit Message: 6/6 ✅ - - 2.3 Delete Message: 6/6 ✅ - - 2.4 Reply & Forward: 8/8 ✅ - - 2.5 Reactions: 10/10 ✅ - - 2.6 Search: 8/8 ✅ - - 2.7 Drafts: 7/7 ✅ - - 2.8 Navigation: 7/7 ✅ - - 2.9 Profile: 6/6 ✅ - - 2.10 Network & Typing: 9/9 ✅ - - 2.11 Copy: 9/9 ✅ (вместо 3!) - - 2.12 Config: 11/11 ✅ (вместо 8!) -- **Общий прогресс: 148/151 (98%) — ПРЕВЗОШЛИ ПЛАН!** 🎉 - ---- - -## 🏗️ Структура - -``` -tests/ -├── helpers/ -│ ├── mod.rs ✅ Создан -│ ├── app_builder.rs ✅ Создан + 5 тестов -│ ├── fake_tdclient.rs ✅ Создан + 4 теста -│ ├── snapshot_utils.rs ✅ Создан + 2 теста -│ └── test_data.rs ✅ Создан -└── ui/ - ├── mod.rs ✅ Создан - └── chat_list_test.rs ✅ Создан (9 snapshot тестов) -``` - ---- - -## 🎯 Примеры использования - -### Создание тестового чата -```rust -let chat = TestChatBuilder::new("Mom", 123) - .unread_count(5) - .pinned() - .muted() - .draft("Hello...") - .build(); -``` - -### Создание тестового App -```rust -let app = TestAppBuilder::new() - .with_chat(chat) - .selected_chat(123) - .message_input("Hello!") - .build(); -``` - -### Snapshot тест -```rust -#[test] -fn snapshot_my_ui() { - let app = TestAppBuilder::new() - .with_chat(create_test_chat("Mom", 123)) - .build(); - - let buffer = render_to_buffer(80, 24, |f| { - render_chat_list(f, f.size(), &app); - }); - - assert_snapshot!("my_ui", buffer_to_string(&buffer)); -} -``` - -### Мок клиент для интеграционных тестов -```rust -let mut client = FakeTdClient::new() - .with_chat(create_test_chat("Mom", 123)); - -let msg_id = client.send_message(123, "Hello".to_string(), None); -assert_eq!(client.sent_messages().len(), 1); -``` - ---- - -## 🎉 ВСЕ ОСНОВНЫЕ ТЕСТЫ ЗАВЕРШЕНЫ! - -### Прогресс: 98% (148/151 тестов) — ПРЕВЗОШЛИ ПЛАН! 🚀 - -**Все основные тесты готовы:** -- ✅ Phase 0: Инфраструктура (100%) -- ✅ Phase 1: UI Snapshot Tests (100%) — 55 тестов -- ✅ Phase 2: Integration Tests (100%!) — 93 теста - -**Превзошли план на 9 тестов!** -- Copy Flow: 9 тестов (вместо 3) -- Config Flow: 11 тестов (вместо 8) - -### Опциональные тесты (можно сделать позже) - -#### Фаза 3: E2E Smoke Tests (4 теста) -**Файл**: `tests/e2e/smoke_test.rs` - -- [ ] Приложение запускается без краша -- [ ] Приложение рендерит loading screen -- [ ] Приложение корректно завершается по Ctrl+C -- [ ] Минимальный размер терминала не крашит приложение - -**Примечание**: E2E тесты требуют реального TDLib или сложного мока, поэтому опциональны. - -#### Фаза 4: Дополнительные тесты (8 тестов) - -**4.1 Utils Tests** (5 тестов) -- [ ] `format_timestamp_with_tz` с разными timezone -- [ ] `parse_timezone_offset` валидные значения -- [ ] `parse_timezone_offset` инвалидные значения (fallback) -- [ ] `format_date` для сегодня, вчера, старых дат -- [ ] `format_was_online` для разных временных промежутков - -**4.2 Performance Benchmarks** (3 теста) -- [ ] Benchmark рендеринга 100 сообщений -- [ ] Benchmark рендеринга списка 50 чатов -- [ ] Benchmark форматирования markdown текста - -### Итого - -**Завершено**: 148 тестов (98%) -**Опционально**: 12 тестов (2%) -**Всего**: 160 тестов потенциально - ---- - -## 💡 Технические заметки - -### Текущие ограничения -1. **TestAppBuilder создаёт реальный TdClient** — подходит только для UI/snapshot тестов -2. **Для интеграционных тестов** понадобится рефакторинг: либо trait для TdClient, либо dependency injection - -### Решения -- Snapshot тесты используют TestAppBuilder (UI рендеринг без вызова TdClient методов) -- Интеграционные тесты будут использовать FakeTdClient напрямую -- Возможно потребуется создать `IntegrationTestSession` для комплексных сценариев - ---- - -## ✨ Качество кода - -**Все helpers покрыты тестами**: -- `app_builder.rs`: 5 тестов -- `fake_tdclient.rs`: 4 теста -- `snapshot_utils.rs`: 2 теста - -**Документация**: -- Все публичные функции имеют doc-комментарии -- Примеры использования в комментариях -- README-секция в TESTING_ROADMAP.md - ---- - -## 🎓 Что изучили - -1. **Snapshot testing** с insta — мощный инструмент для TUI -2. **ratatui::backend::TestBackend** — виртуальный терминал для тестов -3. **Fluent builder pattern** — удобно для построения тестовых данных -4. **Test helpers organization** — разделение на модули для переиспользования - ---- - -## 📝 Обновлённые файлы - -- `Cargo.toml` — добавлены dev-dependencies -- `.gitignore` — добавлены правила для snapshots -- `TESTING_ROADMAP.md` — обновлён прогресс -- `README.md` — добавлена ссылка на TESTING_ROADMAP -- `REFACTORING_ROADMAP.md` — добавлено предусловие о тестах - ---- - -**Статус**: Готов к продолжению! 🚀 -**Следующий шаг**: Запустить тесты и убедиться что всё компилируется, затем продолжить с Фазы 1.2 diff --git a/TESTING_ROADMAP.md b/TESTING_ROADMAP.md deleted file mode 100644 index 9e62235..0000000 --- a/TESTING_ROADMAP.md +++ /dev/null @@ -1,620 +0,0 @@ -# Testing Roadmap - -План покрытия tele-tui тестами с фокусом на интеграционные и e2e тесты. - -## Стратегия тестирования - -### Подход: Комбо (Snapshot + Integration + E2E) - -1. **Snapshot Testing (70%)** — проверка UI рендеринга через insta -2. **Integration Testing (25%)** — проверка логики и flow через FakeTdClient -3. **E2E Smoke Testing (5%)** — базовая проверка что приложение запускается - -### Почему не юнит-тесты? - -- TUI сложно тестировать через юниты (моки, хрупкость) -- Интеграционные тесты дают больше уверенности -- Snapshots ловят UI регрессии лучше, чем assert координат - ---- - -## Фаза 0: Инфраструктура - -### Зависимости - -- [x] Добавить `insta = "1.34"` в dev-dependencies -- [x] Добавить `tokio-test = "0.4"` в dev-dependencies -- [x] Настроить `.gitignore` для snapshots (добавить `tests/snapshots/*.new`) - -### Helpers и Test Utilities - -- [x] Создать `tests/helpers/mod.rs` -- [x] Создать `tests/helpers/app_builder.rs` — builder для тестового App -- [x] Создать `tests/helpers/fake_tdclient.rs` — mock TDLib клиент -- [x] Создать `tests/helpers/snapshot_utils.rs` — утилиты для snapshot тестов -- [x] Создать `tests/helpers/test_data.rs` — фикстуры данных (чаты, сообщения) - -```rust -// tests/helpers/mod.rs -pub mod app_builder; -pub mod fake_tdclient; -pub mod snapshot_utils; -pub mod test_data; - -pub use app_builder::TestAppBuilder; -pub use fake_tdclient::FakeTdClient; -pub use snapshot_utils::{render_to_string, assert_ui_snapshot}; -pub use test_data::{create_test_chat, create_test_message}; -``` - -**Файлы для создания**: -``` -tests/ -├── helpers/ -│ ├── mod.rs -│ ├── app_builder.rs -│ ├── fake_tdclient.rs -│ ├── snapshot_utils.rs -│ └── test_data.rs -└── snapshots/ # Создаётся insta автоматически -``` - ---- - -## Фаза 1: Snapshot Tests для UI (Приоритет: ВЫСОКИЙ) - -### 1.1 Chat List — Список чатов - -**Файл**: `tests/ui/chat_list_test.rs` - -- [x] Пустой список чатов -- [x] Список с 3 чатами (без индикаторов) -- [x] Чат с непрочитанными сообщениями `(5)` -- [x] Чат с иконкой закреплённого 📌 -- [x] Чат с иконкой mute 🔇 -- [x] Чат с индикатором mention @ -- [ ] Чат с онлайн-статусом ● -- [x] Выбранный чат (с ▌) -- [x] Список чатов в режиме поиска -- [x] Длинное название чата (обрезка) - -**Пример теста**: -```rust -#[test] -fn snapshot_chat_list_with_unread() { - let app = TestAppBuilder::new() - .with_chat(create_test_chat("Mom", 123, unread: 5)) - .with_chat(create_test_chat("Boss", 456, unread: 0)) - .build(); - - assert_ui_snapshot!("chat_list_with_unread", app, |f, app| { - render_chat_list(f, f.size(), app); - }); -} -``` - ---- - -### 1.2 Messages — Область сообщений - -**Файл**: `tests/messages.rs` - -- [x] Пустой чат (нет сообщений) -- [x] Одно входящее сообщение -- [x] Одно исходящее сообщение -- [x] Группировка по дате (разделитель "Сегодня") -- [x] Группировка по дате (разделитель "Вчера") -- [x] Группировка по отправителю (заголовок с именем) -- [x] Исходящее сообщение с ✓ (отправлено) -- [x] Исходящее сообщение с ✓✓ (прочитано) -- [x] Сообщение с индикатором редактирования ✎ -- [x] Длинное сообщение (wrap на несколько строк) -- [x] Markdown: жирный, курсив, код -- [x] Markdown: ссылка, упоминание -- [x] Markdown: спойлер -- [x] Сообщение с медиа-заглушкой [Фото] -- [x] Reply сообщение с превью -- [x] Пересланное сообщение (↪ Переслано от) -- [x] Сообщение с одной реакцией [👍] -- [x] Сообщение с несколькими реакциями [👍] 5 👎 3 -- [x] Выбранное сообщение (подсветка) - ---- - -### 1.3 Modals — Модальные окна - -**Файл**: `tests/modals.rs` - -- [x] Delete confirmation модалка -- [x] Emoji picker (8x6 сетка) -- [x] Emoji picker с выбранной реакцией (курсор) -- [x] Profile модалка (личный чат) -- [x] Profile модалка (группа) -- [x] Pinned message вверху чата -- [x] Search в чате (с результатами) -- [x] Forward mode (список чатов для пересылки) - ---- - -### 1.4 Input Field — Поле ввода - -**Файл**: `tests/input_field.rs` - -- [x] Пустое поле ввода -- [x] Поле ввода с текстом и курсором █ -- [x] Поле ввода с длинным текстом (2 строки) -- [x] Поле ввода с длинным текстом (10 строк, максимум) -- [x] Режим редактирования (с превью) -- [x] Режим reply (с превью сообщения) -- [x] Режим поиска (с query) - ---- - -### 1.5 Footer — Нижняя панель ✅ - -**Файл**: `tests/footer.rs` - -- [x] Footer в списке чатов (команды навигации) -- [x] Footer в открытом чате (команды сообщений) -- [x] Footer с индикатором "⚠ Нет сети" -- [x] Footer с индикатором "⏳ Подключение к прокси..." -- [x] Footer с индикатором "⏳ Подключение..." -- [x] Footer в режиме поиска - ---- - -### 1.6 Screens — Полные экраны ✅ - -**Файл**: `tests/screens.rs` - -- [x] Loading screen (default) -- [x] Loading screen (со статусом) -- [x] Auth screen (ввод телефона) -- [x] Auth screen (ввод кода) -- [x] Auth screen (ввод пароля 2FA) -- [x] Main screen (пустой список чатов) -- [x] Минимальный размер терминала (предупреждение) - ---- - -## Фаза 2: Integration Tests для логики (Приоритет: ВЫСОКИЙ) - -### 2.1 Send Message Flow ✅ - -**Файл**: `tests/send_message.rs` (6 тестов) - -- [x] Отправка текстового сообщения -- [x] Отправка нескольких сообщений -- [x] Отправка с markdown форматированием -- [x] Отправка в разные чаты -- [x] Получение входящего сообщения -- [x] Отправка с reply - ---- - -### 2.2 Edit Message Flow ✅ - -**Файл**: `tests/edit_message.rs` (6 тестов) - -- [x] Редактирование текста сообщения -- [x] Установка edit_date после редактирования -- [x] Проверка can_be_edited перед редактированием -- [x] Редактирование только своих сообщений -- [x] Множественные редактирования -- [x] Редактирование с форматированием - ---- - -### 2.3 Delete Message Flow ✅ - -**Файл**: `tests/delete_message.rs` (6 тестов) - -- [x] Удаление сообщения из списка -- [x] Множественные удаления -- [x] Проверка can_be_deleted -- [x] Удаление только своих сообщений -- [x] Удаление из разных чатов -- [x] Delete with revoke - ---- - -### 2.4 Reply & Forward Flow ✅ - -**Файл**: `tests/reply_forward.rs` (8 тестов) - -- [x] Reply на сообщение с превью -- [x] Reply сохраняет связь с оригиналом -- [x] Forward сообщения -- [x] Forward с sender_name -- [x] Forward в разные чаты -- [x] Reply + Forward комбо -- [x] Reply на forwarded сообщение -- [x] Forward reply сообщения - ---- - -### 2.5 Reactions Flow ✅ - -**Файл**: `tests/reactions.rs` (10 тестов) - -- [x] Добавление реакции на сообщение -- [x] Удаление реакции (toggle) -- [x] Множественные реакции на одно сообщение -- [x] Реакции от разных пользователей -- [x] Подсчёт реакций -- [x] Chosen реакция (своя) -- [x] Реакции обновляются в реальном времени -- [x] Получение доступных реакций чата -- [x] Реакции на forwarded сообщения -- [x] Очистка всех реакций - ---- - -### 2.6 Search Flow ✅ - -**Файл**: `tests/search.rs` (8 тестов) - -- [x] Поиск по названию чата -- [x] Поиск по @username -- [x] Поиск по сообщениям в чате -- [x] Навигация по результатам поиска -- [x] Case-insensitive поиск -- [x] Поиск с пробелами -- [x] Поиск возвращает пустой список если нет совпадений -- [x] Очистка поиска - ---- - -### 2.7 Drafts Flow ✅ - -**Файл**: `tests/drafts.rs` (7 тестов) - -- [x] Сохранение черновика при переключении чатов -- [x] Восстановление черновика при возврате -- [x] Удаление черновика после отправки -- [x] Черновики для разных чатов независимы -- [x] Индикатор черновика в списке чатов -- [x] Пустой черновик не сохраняется -- [x] Черновик сохраняется при закрытии чата - ---- - -### 2.8 Navigation Flow ✅ - -**Файл**: `tests/navigation.rs` (7 тестов) - -- [x] Навигация по списку чатов (↑/↓) -- [x] Открытие чата (Enter) -- [x] Закрытие чата (Esc) -- [x] Скролл сообщений (↑/↓) -- [x] Переключение между папками (1-9) -- [x] Навигация с wrap (переход с конца на начало) -- [x] Навигация в пустом списке - ---- - -### 2.9 Profile Flow ✅ - -**Файл**: `tests/profile.rs` (6 тестов) - -- [x] Открытие профиля личного чата -- [x] Профиль показывает имя и username -- [x] Профиль показывает телефон -- [x] Открытие профиля группы -- [x] Профиль группы показывает участников -- [x] Закрытие профиля (Esc) - ---- - -### 2.10 Network & Typing Flow ✅ - -**Файл**: `tests/network_typing.rs` (9 тестов) - -- [x] Typing indicator при наборе текста -- [x] Отправка typing action -- [x] Получение typing статуса -- [x] Typing timeout -- [x] Network state: WaitingForNetwork -- [x] Network state: ConnectingToProxy -- [x] Network state: Connecting -- [x] Network state: Updating -- [x] Network state: Ready - ---- - -### 2.11 Copy Flow ✅ - -**Файл**: `tests/copy.rs` (9 тестов - ПРЕВЗОШЛИ ПЛАН!) - -- [x] Форматирование простого сообщения -- [x] Форматирование с forward контекстом -- [x] Форматирование с reply контекстом -- [x] Форматирование с forward + reply одновременно -- [x] Форматирование длинного сообщения -- [x] Форматирование с markdown entities -- [x] Clipboard initialization -- [x] Копирование в реальный clipboard (ручное) -- [x] Кроссплатформенность clipboard - ---- - -### 2.12 Config Flow ✅ - -**Файл**: `tests/config.rs` (11 тестов - ПРЕВЗОШЛИ ПЛАН!) - -- [x] Дефолтные значения конфигурации -- [x] Кастомные значения конфигурации -- [x] Парсинг валидных цветов -- [x] Парсинг light цветов -- [x] Парсинг невалидного цвета с fallback -- [x] Case-insensitive парсинг цветов -- [x] TOML сериализация и десериализация -- [x] Частичный TOML использует дефолты -- [x] Различные форматы timezone -- [x] Загрузка credentials из переменных окружения -- [x] Проверка формата ошибки когда credentials не найдены - ---- - -## Фаза 3: E2E Integration Tests (Приоритет: СРЕДНИЙ) ✅ - -### 3.1 Smoke Tests ✅ -**Файл**: `tests/e2e_smoke.rs` (4 теста) - -- [x] Приложение запускается без краша -- [x] Проверка минимального размера терминала -- [x] Базовые константы приложения -- [x] Graceful shutdown флаг - -### 3.2 User Journey Tests ✅ -**Файл**: `tests/e2e_user_journey.rs` (8 тестов) - -- [x] App Launch → Auth → Chat List -- [x] Open Chat → Load History → Send Message -- [x] Receive Incoming Message While Chat Open -- [x] Multi-step conversation flow -- [x] Switch between chats -- [x] Edit message in conversation flow -- [x] Reply to message in conversation -- [x] Network state changes during conversation - -**Итого**: 12/12 E2E тестов (100%) ✅ - -**Примечание**: Все тесты используют FakeTdClient для полной симуляции TDLib без реального подключения. - ---- - -## Фаза 4: Дополнительные тесты (Приоритет: НИЗКИЙ) - -### 4.1 Utils Tests - -**Файл**: `tests/unit/utils_test.rs` - -- [ ] `format_timestamp_with_tz` с разными timezone -- [ ] `parse_timezone_offset` валидные значения -- [ ] `parse_timezone_offset` инвалидные значения (fallback) -- [ ] `format_date` для сегодня, вчера, старых дат -- [ ] `format_was_online` для разных временных промежутков - -### 4.2 Performance Tests - -**Файл**: `tests/performance/render_bench.rs` - -- [ ] Benchmark рендеринга 100 сообщений -- [ ] Benchmark рендеринга списка 50 чатов -- [ ] Benchmark форматирования markdown текста - ---- - -## Метрики прогресса - -### Фаза 0: Инфраструктура -- [x] 8/8 задач выполнено ✅ - -### Фаза 1: Snapshot Tests ✅ -- [x] 1.1 Chat List: 10/10 (100%) ✅ -- [x] 1.2 Messages: 19/19 (100%) ✅ -- [x] 1.3 Modals: 8/8 (100%) ✅ -- [x] 1.4 Input Field: 7/7 (100%) ✅ -- [x] 1.5 Footer: 6/6 (100%) ✅ -- [x] 1.6 Screens: 7/7 (100%) ✅ -- **Итого: 57/57 snapshot тестов (100%)** ✅ - -### Фаза 2: Integration Tests ✅ -- [x] 2.1 Send Message: 6/6 ✅ -- [x] 2.2 Edit Message: 6/6 ✅ -- [x] 2.3 Delete Message: 6/6 ✅ -- [x] 2.4 Reply & Forward: 8/8 ✅ -- [x] 2.5 Reactions: 10/10 ✅ -- [x] 2.6 Search: 8/8 ✅ -- [x] 2.7 Drafts: 7/7 ✅ -- [x] 2.8 Navigation: 7/7 ✅ -- [x] 2.9 Profile: 6/6 ✅ -- [x] 2.10 Network & Typing: 9/9 ✅ -- [x] 2.11 Copy: 9/9 ✅ (вместо 3!) -- [x] 2.12 Config: 11/11 ✅ (вместо 8!) -- **Итого: 93/93 интеграционных тестов (100%!) — ПРЕВЗОШЛИ ПЛАН!** 🎉 - -### Фаза 3: E2E Integration -- [x] 3.1 Smoke Tests: 4/4 ✅ -- [x] 3.2 User Journey: 8/8 ✅ -- **Итого: 12/12 E2E тестов (100%)** ✅ - -### Фаза 4: Дополнительно -- [ ] 4.1 Utils: 0/5 -- [ ] 4.2 Performance: 0/3 -- **Итого: 0/8 дополнительных тестов** - ---- - -## Общий прогресс - -**Всего**: 164/171 тестов (96%) — ПРЕВЗОШЛИ ПЛАН! 🎉🎉🎉 - -**Фаза 0 (Инфраструктура)**: ✅ Завершена (100%) -**Фаза 1 (UI Snapshot Tests)**: ✅ 57/57 (100%) — ЗАВЕРШЕНА! 🎉 -- 1.1 Chat List: 10/10 (включая онлайн-статус) ✅ -- 1.2 Messages: 19/19 ✅ -- 1.3 Modals: 8/8 ✅ -- 1.4 Input Field: 7/7 ✅ -- 1.5 Footer: 6/6 ✅ -- 1.6 Screens: 7/7 ✅ - -**Фаза 2 (Integration Tests)**: ✅ 93/93 (100%!) — ПРЕВЗОШЛИ ПЛАН! -- Завершено: 2.1-2.12 ✅ -- Превзошли план на 9 тестов: Copy (9 вместо 3), Config (11 вместо 8) - -**Фаза 3 (E2E Integration Tests)**: ✅ 12/12 (100%) — ЗАВЕРШЕНА! 🎉 -- Smoke Tests: 4/4 ✅ -- User Journey: 8/8 ✅ - -**Опционально**: -- Фаза 4 (Utils + Performance): 0/8 - ---- - -## Приоритизация - -### Критичные (делать в первую очередь): -1. **Фаза 0**: Инфраструктура (без неё никуда) -2. **1.2**: Messages snapshots (ядро приложения) -3. **2.1**: Send message (основной flow) -4. **2.8**: Navigation (базовая навигация) - -### Важные (делать после критичных): -5. **1.1**: Chat list snapshots -6. **2.2**: Edit message -7. **2.3**: Delete message -8. **2.5**: Reactions -9. **2.6**: Search - -### Желательные (можно отложить): -10. **1.3-1.6**: Остальные snapshots -11. **2.4, 2.7, 2.9-2.12**: Остальные flows -12. **Фаза 3**: E2E smoke tests - -### Опциональные (по желанию): -13. **Фаза 4**: Utils и performance - ---- - -## Технологии - -### Основные -- **insta** — snapshot testing -- **tokio-test** — async testing utilities -- **ratatui::backend::TestBackend** — виртуальный терминал - -### Дополнительные (опционально) -- **expectrl** — для E2E тестов с реальным бинарником -- **criterion** — для бенчмарков (фаза 4.2) -- **mockall** — если понадобятся моки (скорее всего нет) - ---- - -## Примеры структуры тестов - -### Snapshot Test -```rust -use insta::assert_snapshot; -use ratatui::backend::TestBackend; -use ratatui::Terminal; - -#[test] -fn snapshot_messages_with_reactions() { - let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); - - let app = TestAppBuilder::new() - .with_message(create_test_message("Hello!", reactions: vec![ - reaction("👍", 1, chosen: true), - reaction("👎", 3, chosen: false), - ])) - .build(); - - terminal.draw(|f| { - render_messages(f, f.size(), &app); - }).unwrap(); - - let buffer = terminal.backend().buffer(); - assert_snapshot!(buffer_to_string(buffer)); -} -``` - -### Integration Test -```rust -use crate::helpers::{TestAppBuilder, FakeTdClient}; - -#[tokio::test] -async fn test_send_message_updates_ui() { - let fake_client = FakeTdClient::new() - .with_chat("Mom", 123); - - let mut app = TestAppBuilder::new() - .with_client(fake_client) - .with_selected_chat(123) - .build(); - - // Ввод текста - app.input_text = "Hello!".to_string(); - - // Отправка - app.handle_key(KeyCode::Enter).await; - - // Проверки - assert_eq!(app.input_text, ""); // Инпут очистился - assert_eq!(app.current_messages().len(), 1); - assert_eq!(app.current_messages()[0].text, "Hello!"); - assert_eq!(fake_client.sent_messages().len(), 1); -} -``` - ---- - -## Команды - -```bash -# Прогнать все тесты -cargo test - -# Прогнать только snapshot тесты -cargo test --test ui - -# Прогнать только integration тесты -cargo test --test integration - -# Обновить snapshots (после ревью изменений) -cargo insta review - -# Принять все новые snapshots -cargo insta accept - -# Показать diff для изменённых snapshots -cargo insta test --review -``` - ---- - -## Правила - -1. **Один тест = один сценарий** — не делать мега-тесты -2. **Snapshots коммитим** — они часть тестов -3. **Фикстуры переиспользуем** — общие данные в `test_data.rs` -4. **Тесты изолированы** — каждый тест создаёт свой App -5. **Порядок не важен** — тесты можно запускать в любом порядке - ---- - -## TODO перед началом - -- [ ] Прочитать документацию insta: https://insta.rs/ -- [ ] Решить: нужен ли trait для TdClient или достаточно FakeTdClient -- [ ] Обсудить: какие тесты делать в первую очередь - ---- - -## Примечания - -- Этот документ будет обновляться по мере написания тестов -- После завершения фазы — отмечать в метриках -- Если тест падает или не актуален — документировать причину -- Snapshots хранятся в `tests/snapshots/__snapshots__/` diff --git a/config.example.toml b/config.example.toml deleted file mode 100644 index 806a875..0000000 --- a/config.example.toml +++ /dev/null @@ -1,35 +0,0 @@ -# Telegram TUI Configuration Example -# Copy this file to ~/.config/tele-tui/config.toml - -[general] -# Timezone offset (e.g., "+03:00", "-05:00") -timezone = "+03:00" - -[colors] -# Colors: red, green, blue, yellow, cyan, magenta, white, black, gray -# Also available: lightred, lightgreen, lightblue, lightyellow, lightcyan, lightmagenta -incoming_message = "white" -outgoing_message = "green" -selected_message = "yellow" -reaction_chosen = "yellow" -reaction_other = "gray" - -[notifications] -# Enable desktop notifications for new messages -enabled = true - -# Only notify when you are mentioned (@username) -only_mentions = false - -# Show message preview text in notifications -show_preview = true - -# Notification timeout in milliseconds (0 = system default) -timeout_ms = 5000 - -# Notification urgency level: "low", "normal", "critical" -# Note: Only works on Linux (libnotify), ignored on macOS/Windows -urgency = "normal" - -# Note: Notifications respect Telegram's mute settings -# Muted chats won't trigger notifications -- 2.49.1 From c89a5e13f831f36c035db8a5366d48879460f972 Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Tue, 3 Mar 2026 01:17:47 +0300 Subject: [PATCH 3/3] chore: remove leftover backup files from src/ Co-Authored-By: Claude Sonnet 4.6 --- src/input/main_input.rs.backup | 1139 ------------------ src/tdlib/client.rs.backup | 2036 -------------------------------- src/tdlib/client.rs.old | 2036 -------------------------------- 3 files changed, 5211 deletions(-) delete mode 100644 src/input/main_input.rs.backup delete mode 100644 src/tdlib/client.rs.backup delete mode 100644 src/tdlib/client.rs.old diff --git a/src/input/main_input.rs.backup b/src/input/main_input.rs.backup deleted file mode 100644 index bf4c4b7..0000000 --- a/src/input/main_input.rs.backup +++ /dev/null @@ -1,1139 +0,0 @@ -use crate::app::App; -use crate::tdlib::ChatAction; -use crate::types::{ChatId, MessageId}; -use crate::utils::{with_timeout, with_timeout_msg}; -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use std::time::{Duration, Instant}; - -pub async fn handle(app: &mut App, key: KeyEvent) { - let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL); - - // Глобальные команды (работают всегда) - match key.code { - KeyCode::Char('r') if has_ctrl => { - app.status_message = Some("Обновление чатов...".to_string()); - let _ = with_timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await; - app.status_message = None; - return; - } - KeyCode::Char('s') if has_ctrl => { - // Ctrl+S - начать поиск (только если чат не открыт) - if app.selected_chat_id.is_none() { - app.start_search(); - } - return; - } - KeyCode::Char('p') if has_ctrl => { - // Ctrl+P - режим просмотра закреплённых сообщений - if app.selected_chat_id.is_some() && !app.is_pinned_mode() { - if let Some(chat_id) = app.get_selected_chat_id() { - app.status_message = Some("Загрузка закреплённых...".to_string()); - match with_timeout_msg( - Duration::from_secs(5), - app.td_client.get_pinned_messages(ChatId::new(chat_id)), - "Таймаут загрузки", - ) - .await - { - Ok(messages) => { - let messages: Vec = messages; - if messages.is_empty() { - app.status_message = Some("Нет закреплённых сообщений".to_string()); - } else { - app.enter_pinned_mode(messages); - app.status_message = None; - } - } - Err(e) => { - app.error_message = Some(e); - app.status_message = None; - } - } - } - } - return; - } - KeyCode::Char('f') if has_ctrl => { - // Ctrl+F - поиск по сообщениям в открытом чате - if app.selected_chat_id.is_some() - && !app.is_pinned_mode() - && !app.is_message_search_mode() - { - app.enter_message_search_mode(); - } - return; - } - - _ => {} - } - - // Режим профиля - if app.is_profile_mode() { - // Обработка подтверждения выхода из группы - let confirmation_step = app.get_leave_group_confirmation_step(); - if confirmation_step > 0 { - match key.code { - KeyCode::Char('y') | KeyCode::Char('н') | KeyCode::Enter => { - if confirmation_step == 1 { - // Первое подтверждение - показываем второе - app.show_leave_group_final_confirmation(); - } else if confirmation_step == 2 { - // Второе подтверждение - выходим из группы - if let Some(chat_id) = app.selected_chat_id { - let leave_result = app.td_client.leave_chat(chat_id).await; - match leave_result { - Ok(_) => { - app.status_message = Some("Вы вышли из группы".to_string()); - app.exit_profile_mode(); - app.close_chat(); - } - Err(e) => { - app.error_message = Some(e); - app.cancel_leave_group(); - } - } - } - } - } - KeyCode::Char('n') | KeyCode::Char('т') | KeyCode::Esc => { - // Отмена - app.cancel_leave_group(); - } - _ => {} - } - return; - } - - // Обычная навигация по профилю - match key.code { - KeyCode::Esc => { - app.exit_profile_mode(); - } - KeyCode::Up => { - app.select_previous_profile_action(); - } - KeyCode::Down => { - if let Some(profile) = app.get_profile_info() { - let max_actions = get_available_actions_count(profile); - app.select_next_profile_action(max_actions); - } - } - KeyCode::Enter => { - // Выполнить выбранное действие - if let Some(profile) = app.get_profile_info() { - let actions = get_available_actions_count(profile); - let action_index = app.get_selected_profile_action().unwrap_or(0); - - if action_index < actions { - // Определяем какое действие выбрано - let mut current_idx = 0; - - // Действие: Открыть в браузере - if profile.username.is_some() { - if action_index == current_idx { - if let Some(username) = &profile.username { - let url = format!( - "https://t.me/{}", - username.trim_start_matches('@') - ); - #[cfg(feature = "url-open")] - { - match open::that(&url) { - Ok(_) => { - app.status_message = Some(format!("Открыто: {}", url)); - } - Err(e) => { - app.error_message = - Some(format!("Ошибка открытия браузера: {}", e)); - } - } - } - #[cfg(not(feature = "url-open"))] - { - app.error_message = Some( - "Открытие URL недоступно (требуется feature 'url-open')".to_string() - ); - } - } - return; - } - current_idx += 1; - } - - // Действие: Скопировать ID - if action_index == current_idx { - app.status_message = - Some(format!("ID скопирован: {}", profile.chat_id)); - return; - } - current_idx += 1; - - // Действие: Покинуть группу - if profile.is_group && action_index == current_idx { - app.show_leave_group_confirmation(); - } - } - } - } - _ => {} - } - return; - } - - // Режим поиска по сообщениям - if app.is_message_search_mode() { - match key.code { - KeyCode::Esc => { - app.exit_message_search_mode(); - } - KeyCode::Up | KeyCode::Char('N') => { - app.select_previous_search_result(); - } - KeyCode::Down | KeyCode::Char('n') => { - app.select_next_search_result(); - } - KeyCode::Enter => { - // Перейти к выбранному сообщению - if let Some(msg_id) = app.get_selected_search_result_id() { - let msg_id = MessageId::new(msg_id); - let msg_index = app - .td_client - .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(); - app.message_scroll_offset = total.saturating_sub(idx + 5); - } - app.exit_message_search_mode(); - } - } - KeyCode::Backspace => { - // Удаляем символ из запроса - if let Some(mut query) = app.get_search_query().map(|s| s.to_string()) { - query.pop(); - app.update_search_query(query.clone()); - // Выполняем поиск при изменении запроса - if let Some(chat_id) = app.get_selected_chat_id() { - if !query.is_empty() { - if let Ok(results) = with_timeout( - Duration::from_secs(3), - app.td_client.search_messages(ChatId::new(chat_id), &query), - ) - .await - { - app.set_search_results(results); - } - } else { - app.set_search_results(Vec::new()); - } - } - } - } - KeyCode::Char(c) => { - // Добавляем символ к запросу - if let Some(mut query) = app.get_search_query().map(|s| s.to_string()) { - query.push(c); - app.update_search_query(query.clone()); - // Выполняем поиск при изменении запроса - if let Some(chat_id) = app.get_selected_chat_id() { - if let Ok(results) = with_timeout( - Duration::from_secs(3), - app.td_client.search_messages(ChatId::new(chat_id), &query), - ) - .await - { - app.set_search_results(results); - } - } - } - } - _ => {} - } - return; - } - - // Режим просмотра закреплённых сообщений - if app.is_pinned_mode() { - match key.code { - KeyCode::Esc => { - app.exit_pinned_mode(); - } - KeyCode::Up => { - app.select_previous_pinned(); - } - KeyCode::Down => { - app.select_next_pinned(); - } - KeyCode::Enter => { - // Перейти к сообщению в истории - if let Some(msg_id) = app.get_selected_pinned_id() { - let msg_id = MessageId::new(msg_id); - // Ищем индекс сообщения в текущей истории - let msg_index = app - .td_client - .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(); - app.message_scroll_offset = total.saturating_sub(idx + 5); - } - app.exit_pinned_mode(); - } - } - _ => {} - } - return; - } - - // Обработка ввода в режиме выбора реакции - if app.is_reaction_picker_mode() { - match key.code { - KeyCode::Left => { - app.select_previous_reaction(); - app.needs_redraw = true; - } - KeyCode::Right => { - app.select_next_reaction(); - app.needs_redraw = true; - } - KeyCode::Up => { - // Переход на ряд выше (8 эмодзи в ряду) - if let crate::app::ChatState::ReactionPicker { - selected_index, - .. - } = &mut app.chat_state - { - if *selected_index >= 8 { - *selected_index = selected_index.saturating_sub(8); - app.needs_redraw = true; - } - } - } - KeyCode::Down => { - // Переход на ряд ниже (8 эмодзи в ряду) - if let crate::app::ChatState::ReactionPicker { - selected_index, - available_reactions, - .. - } = &mut app.chat_state - { - let new_index = *selected_index + 8; - if new_index < available_reactions.len() { - *selected_index = new_index; - app.needs_redraw = true; - } - } - } - KeyCode::Enter => { - // Добавить/убрать реакцию - if let Some(emoji) = app.get_selected_reaction().cloned() { - if let Some(message_id) = app.get_selected_message_for_reaction() { - if let Some(chat_id) = app.selected_chat_id { - let message_id = MessageId::new(message_id); - app.status_message = Some("Отправка реакции...".to_string()); - app.needs_redraw = true; - - match with_timeout_msg( - Duration::from_secs(5), - app.td_client - .toggle_reaction(chat_id, message_id, emoji.clone()), - "Таймаут отправки реакции", - ) - .await - { - Ok(_) => { - app.status_message = - Some(format!("Реакция {} добавлена", emoji)); - app.exit_reaction_picker_mode(); - app.needs_redraw = true; - } - Err(e) => { - app.error_message = Some(e); - app.status_message = None; - app.needs_redraw = true; - } - } - } - } - } - } - KeyCode::Esc => { - app.exit_reaction_picker_mode(); - app.needs_redraw = true; - } - _ => {} - } - return; - } - - // Модалка подтверждения удаления - if app.is_confirm_delete_shown() { - match key.code { - KeyCode::Char('y') | KeyCode::Char('н') | KeyCode::Enter => { - // Подтверждение удаления - if let Some(msg_id) = app.chat_state.selected_message_id() { - if let Some(chat_id) = app.get_selected_chat_id() { - // Находим сообщение для проверки can_be_deleted_for_all_users - let can_delete_for_all = app - .td_client - .current_chat_messages() - .iter() - .find(|m| m.id() == msg_id) - .map(|m| m.can_be_deleted_for_all_users()) - .unwrap_or(false); - - match with_timeout_msg( - Duration::from_secs(5), - app.td_client.delete_messages( - ChatId::new(chat_id), - vec![msg_id], - can_delete_for_all, - ), - "Таймаут удаления", - ) - .await - { - Ok(_) => { - // Удаляем из локального списка - app.td_client - .current_chat_messages_mut() - .retain(|m| m.id() != msg_id); - // Сбрасываем состояние - app.chat_state = crate::app::ChatState::Normal; - } - Err(e) => { - app.error_message = Some(e); - } - } - } - } - // Закрываем модалку - app.chat_state = crate::app::ChatState::Normal; - } - KeyCode::Char('n') | KeyCode::Char('т') | KeyCode::Esc => { - // Отмена удаления - app.chat_state = crate::app::ChatState::Normal; - } - _ => {} - } - return; - } - - // Режим выбора чата для пересылки - if app.is_forwarding() { - match key.code { - KeyCode::Esc => { - app.cancel_forward(); - } - KeyCode::Enter => { - // Выбираем чат и пересылаем сообщение - let filtered = app.get_filtered_chats(); - if let Some(i) = app.chat_list_state.selected() { - if let Some(chat) = filtered.get(i) { - let to_chat_id = chat.id; - if let Some(msg_id) = app.chat_state.selected_message_id() { - if let Some(from_chat_id) = app.get_selected_chat_id() { - match with_timeout_msg( - Duration::from_secs(5), - app.td_client.forward_messages( - to_chat_id, - ChatId::new(from_chat_id), - vec![msg_id], - ), - "Таймаут пересылки", - ) - .await - { - Ok(_) => { - app.status_message = - Some("Сообщение переслано".to_string()); - } - Err(e) => { - app.error_message = Some(e); - } - } - } - } - } - } - app.cancel_forward(); - } - KeyCode::Down => { - app.next_chat(); - } - KeyCode::Up => { - app.previous_chat(); - } - _ => {} - } - return; - } - - // Режим поиска - if app.is_searching { - match key.code { - KeyCode::Esc => { - app.cancel_search(); - } - KeyCode::Enter => { - // Выбрать чат из отфильтрованного списка - app.select_filtered_chat(); - if let Some(chat_id) = app.get_selected_chat_id() { - app.status_message = Some("Загрузка сообщений...".to_string()); - app.message_scroll_offset = 0; - match with_timeout_msg( - Duration::from_secs(10), - app.td_client.get_chat_history(ChatId::new(chat_id), 100), - "Таймаут загрузки сообщений", - ) - .await - { - Ok(messages) => { - // Сохраняем загруженные сообщения - *app.td_client.current_chat_messages_mut() = messages; - // ВАЖНО: Устанавливаем current_chat_id ТОЛЬКО ПОСЛЕ сохранения истории - // Это предотвращает race condition с Update::NewMessage - app.td_client.set_current_chat_id(Some(ChatId::new(chat_id))); - // Загружаем недостающие reply info - let _ = tokio::time::timeout( - Duration::from_secs(5), - app.td_client.fetch_missing_reply_info(), - ) - .await; - // Загружаем последнее закреплённое сообщение - let _ = tokio::time::timeout( - Duration::from_secs(2), - app.td_client.load_current_pinned_message(ChatId::new(chat_id)), - ) - .await; - // Загружаем черновик - app.load_draft(); - app.status_message = None; - } - Err(e) => { - app.error_message = Some(e); - app.status_message = None; - } - } - } - } - KeyCode::Backspace => { - app.search_query.pop(); - // Сбрасываем выделение при изменении запроса - app.chat_list_state.select(Some(0)); - } - KeyCode::Down => { - app.next_filtered_chat(); - } - KeyCode::Up => { - app.previous_filtered_chat(); - } - KeyCode::Char(c) => { - app.search_query.push(c); - // Сбрасываем выделение при изменении запроса - app.chat_list_state.select(Some(0)); - } - _ => {} - } - return; - } - - // Enter - открыть чат, отправить сообщение или редактировать - if key.code == KeyCode::Enter { - if app.selected_chat_id.is_some() { - // Режим выбора сообщения - if app.is_selecting_message() { - // Начать редактирование выбранного сообщения - if app.start_editing_selected() { - // Редактирование начато - } else { - // Нельзя редактировать это сообщение - app.chat_state = crate::app::ChatState::Normal; - } - return; - } - - // Отправка или редактирование сообщения - if !app.message_input.is_empty() { - if let Some(chat_id) = app.get_selected_chat_id() { - let text = app.message_input.clone(); - - if app.is_editing() { - // Режим редактирования - if let Some(msg_id) = app.chat_state.selected_message_id() { - // Проверяем, что сообщение есть в локальном кэше - let msg_exists = app.td_client.current_chat_messages() - .iter() - .any(|m| m.id() == msg_id); - - if !msg_exists { - app.error_message = Some(format!( - "Сообщение {} не найдено в кэше чата {}", - msg_id.as_i64(), chat_id - )); - app.chat_state = crate::app::ChatState::Normal; - app.message_input.clear(); - app.cursor_position = 0; - return; - } - - match with_timeout_msg( - Duration::from_secs(5), - app.td_client.edit_message(ChatId::new(chat_id), msg_id, text), - "Таймаут редактирования", - ) - .await - { - Ok(mut edited_msg) => { - // Сохраняем reply_to из старого сообщения (если есть) - let messages = app.td_client.current_chat_messages_mut(); - if let Some(pos) = messages.iter().position(|m| m.id() == msg_id) { - let old_reply_to = messages[pos].interactions.reply_to.clone(); - // Если в старом сообщении был reply и в новом он "Unknown" - сохраняем старый - if let Some(old_reply) = old_reply_to { - if edited_msg.interactions.reply_to.as_ref() - .map_or(true, |r| r.sender_name == "Unknown") { - edited_msg.interactions.reply_to = Some(old_reply); - } - } - // Заменяем сообщение - messages[pos] = edited_msg; - } - // Очищаем инпут и сбрасываем состояние ПОСЛЕ успешного редактирования - app.message_input.clear(); - app.cursor_position = 0; - app.chat_state = crate::app::ChatState::Normal; - app.needs_redraw = true; // ВАЖНО: перерисовываем UI - } - Err(e) => { - app.error_message = Some(e); - } - } - } - } else { - // Обычная отправка (или reply) - let reply_to_id = if app.is_replying() { - app.chat_state.selected_message_id() - } else { - None - }; - // Создаём ReplyInfo ДО отправки, пока сообщение точно доступно - let reply_info = app.get_replying_to_message().map(|m| { - crate::tdlib::ReplyInfo { - message_id: m.id(), - sender_name: m.sender_name().to_string(), - text: m.text().to_string(), - } - }); - app.message_input.clear(); - app.cursor_position = 0; - // Сбрасываем режим reply если он был активен - if app.is_replying() { - app.chat_state = crate::app::ChatState::Normal; - } - app.last_typing_sent = None; - - // Отменяем typing status - app.td_client - .send_chat_action(ChatId::new(chat_id), ChatAction::Cancel) - .await; - - match with_timeout_msg( - Duration::from_secs(5), - app.td_client - .send_message(ChatId::new(chat_id), text, reply_to_id, reply_info), - "Таймаут отправки", - ) - .await - { - Ok(sent_msg) => { - // Добавляем отправленное сообщение в список (с лимитом) - app.td_client.push_message(sent_msg); - // Сбрасываем скролл чтобы видеть новое сообщение - app.message_scroll_offset = 0; - } - Err(e) => { - app.error_message = Some(e); - } - } - } - } - } - } else { - // Открываем чат - let prev_selected = app.selected_chat_id; - app.select_current_chat(); - - if app.selected_chat_id != prev_selected { - if let Some(chat_id) = app.get_selected_chat_id() { - app.status_message = Some("Загрузка сообщений...".to_string()); - app.message_scroll_offset = 0; - match with_timeout_msg( - Duration::from_secs(10), - app.td_client.get_chat_history(ChatId::new(chat_id), 100), - "Таймаут загрузки сообщений", - ) - .await - { - Ok(messages) => { - // Сохраняем загруженные сообщения - *app.td_client.current_chat_messages_mut() = messages; - // ВАЖНО: Устанавливаем current_chat_id ТОЛЬКО ПОСЛЕ сохранения истории - // Это предотвращает race condition с Update::NewMessage - app.td_client.set_current_chat_id(Some(ChatId::new(chat_id))); - // Загружаем недостающие reply info - let _ = tokio::time::timeout( - Duration::from_secs(5), - app.td_client.fetch_missing_reply_info(), - ) - .await; - // Загружаем последнее закреплённое сообщение - let _ = tokio::time::timeout( - Duration::from_secs(2), - app.td_client.load_current_pinned_message(ChatId::new(chat_id)), - ) - .await; - // Загружаем черновик - app.load_draft(); - app.status_message = None; - } - Err(e) => { - app.error_message = Some(e); - app.status_message = None; - } - } - } - } - } - return; - } - - // Esc - отменить выбор/редактирование/reply или закрыть чат - if key.code == KeyCode::Esc { - if app.is_selecting_message() { - // Отменить выбор сообщения - app.chat_state = crate::app::ChatState::Normal; - } else if app.is_editing() { - // Отменить редактирование - app.cancel_editing(); - } else if app.is_replying() { - // Отменить режим ответа - app.cancel_reply(); - } else if app.selected_chat_id.is_some() { - // Сохраняем черновик если есть текст в инпуте - if let Some(chat_id) = app.selected_chat_id { - if !app.message_input.is_empty() && !app.is_editing() && !app.is_replying() { - let draft_text = app.message_input.clone(); - let _ = app.td_client.set_draft_message(chat_id, draft_text).await; - } else if app.message_input.is_empty() { - // Очищаем черновик если инпут пустой - let _ = app - .td_client - .set_draft_message(chat_id, String::new()) - .await; - } - } - app.close_chat(); - } - return; - } - - // Режим открытого чата - if app.selected_chat_id.is_some() { - // Режим выбора сообщения для редактирования/удаления - if app.is_selecting_message() { - match key.code { - KeyCode::Up => { - app.select_previous_message(); - } - KeyCode::Down => { - app.select_next_message(); - // Если вышли из режима выбора (индекс стал None), ничего не делаем - } - KeyCode::Char('d') | KeyCode::Char('в') | KeyCode::Delete => { - // Показать модалку подтверждения удаления - if let Some(msg) = app.get_selected_message() { - let can_delete = - msg.can_be_deleted_only_for_self() || msg.can_be_deleted_for_all_users(); - if can_delete { - app.chat_state = crate::app::ChatState::DeleteConfirmation { - message_id: msg.id(), - }; - } - } - } - KeyCode::Char('r') | KeyCode::Char('к') => { - // Начать режим ответа на выбранное сообщение - app.start_reply_to_selected(); - } - KeyCode::Char('f') | KeyCode::Char('а') => { - // Начать режим пересылки - app.start_forward_selected(); - } - KeyCode::Char('y') | KeyCode::Char('н') => { - // Копировать сообщение - if let Some(msg) = app.get_selected_message() { - let text = format_message_for_clipboard(msg); - match copy_to_clipboard(&text) { - Ok(_) => { - app.status_message = Some("Сообщение скопировано".to_string()); - } - Err(e) => { - app.error_message = Some(format!("Ошибка копирования: {}", e)); - } - } - } - } - KeyCode::Char('e') | KeyCode::Char('у') => { - // Открыть emoji picker для добавления реакции - if let Some(msg) = app.get_selected_message() { - let chat_id = app.selected_chat_id.unwrap(); - let message_id = msg.id(); - - app.status_message = Some("Загрузка реакций...".to_string()); - app.needs_redraw = true; - - // Запрашиваем доступные реакции - match with_timeout_msg( - Duration::from_secs(5), - app.td_client - .get_message_available_reactions(chat_id, message_id), - "Таймаут загрузки реакций", - ) - .await - { - Ok(reactions) => { - let reactions: Vec = reactions; - if reactions.is_empty() { - app.error_message = - Some("Реакции недоступны для этого сообщения".to_string()); - app.status_message = None; - app.needs_redraw = true; - } else { - app.enter_reaction_picker_mode(message_id.as_i64(), reactions); - app.status_message = None; - app.needs_redraw = true; - } - } - Err(e) => { - app.error_message = Some(e); - app.status_message = None; - app.needs_redraw = true; - } - } - } - } - _ => {} - } - return; - } - - // Ctrl+U для профиля - if key.code == KeyCode::Char('u') && has_ctrl { - if let Some(chat_id) = app.selected_chat_id { - app.status_message = Some("Загрузка профиля...".to_string()); - match with_timeout_msg( - Duration::from_secs(5), - app.td_client.get_profile_info(chat_id), - "Таймаут загрузки профиля", - ) - .await - { - Ok(profile) => { - app.enter_profile_mode(profile); - app.status_message = None; - } - Err(e) => { - app.error_message = Some(e); - app.status_message = None; - } - } - } - return; - } - - match key.code { - KeyCode::Backspace => { - // Удаляем символ слева от курсора - if app.cursor_position > 0 { - let chars: Vec = app.message_input.chars().collect(); - let mut new_input = String::new(); - for (i, ch) in chars.iter().enumerate() { - if i != app.cursor_position - 1 { - new_input.push(*ch); - } - } - app.message_input = new_input; - app.cursor_position -= 1; - } - } - KeyCode::Delete => { - // Удаляем символ справа от курсора - let len = app.message_input.chars().count(); - if app.cursor_position < len { - let chars: Vec = app.message_input.chars().collect(); - let mut new_input = String::new(); - for (i, ch) in chars.iter().enumerate() { - if i != app.cursor_position { - new_input.push(*ch); - } - } - app.message_input = new_input; - } - } - KeyCode::Char(c) => { - // Вставляем символ в позицию курсора - let chars: Vec = app.message_input.chars().collect(); - let mut new_input = String::new(); - for (i, ch) in chars.iter().enumerate() { - if i == app.cursor_position { - new_input.push(c); - } - new_input.push(*ch); - } - if app.cursor_position >= chars.len() { - new_input.push(c); - } - app.message_input = new_input; - app.cursor_position += 1; - - // Отправляем typing status с throttling (не чаще 1 раза в 5 сек) - let should_send_typing = app - .last_typing_sent - .map(|t| t.elapsed().as_secs() >= 5) - .unwrap_or(true); - if should_send_typing { - if let Some(chat_id) = app.get_selected_chat_id() { - app.td_client - .send_chat_action(ChatId::new(chat_id), ChatAction::Typing) - .await; - app.last_typing_sent = Some(Instant::now()); - } - } - } - KeyCode::Left => { - // Курсор влево - if app.cursor_position > 0 { - app.cursor_position -= 1; - } - } - KeyCode::Right => { - // Курсор вправо - let len = app.message_input.chars().count(); - if app.cursor_position < len { - app.cursor_position += 1; - } - } - KeyCode::Home => { - // Курсор в начало - app.cursor_position = 0; - } - KeyCode::End => { - // Курсор в конец - app.cursor_position = app.message_input.chars().count(); - } - // Стрелки вверх/вниз - скролл сообщений или начало выбора - KeyCode::Down => { - // Скролл вниз (к новым сообщениям) - if app.message_scroll_offset > 0 { - app.message_scroll_offset = app.message_scroll_offset.saturating_sub(3); - } - } - KeyCode::Up => { - // Если инпут пустой и не в режиме редактирования — начать выбор сообщения - if app.message_input.is_empty() && !app.is_editing() { - app.start_message_selection(); - } else { - // Скролл вверх (к старым сообщениям) - app.message_scroll_offset += 3; - - // Проверяем, нужно ли подгрузить старые сообщения - if !app.td_client.current_chat_messages().is_empty() { - let oldest_msg_id = app - .td_client - .current_chat_messages() - .first() - .map(|m| m.id()) - .unwrap_or(MessageId::new(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) - { - if let Ok(older) = with_timeout( - Duration::from_secs(3), - app.td_client - .load_older_messages(ChatId::new(chat_id), oldest_msg_id), - ) - .await - { - let older: Vec = older; - if !older.is_empty() { - // Добавляем старые сообщения в начало - let msgs = app.td_client.current_chat_messages_mut(); - msgs.splice(0..0, older); - } - } - } - } - } - } - } - _ => {} - } - } else { - // В режиме списка чатов - навигация стрелками и переключение папок - match key.code { - KeyCode::Down => { - app.next_chat(); - } - KeyCode::Up => { - app.previous_chat(); - } - // Цифры 1-9 - переключение папок - KeyCode::Char(c) if c >= '1' && c <= '9' => { - let folder_num = (c as usize) - ('1' as usize); // 0-based - if folder_num == 0 { - // 1 = All - app.selected_folder_id = None; - } else { - // 2, 3, 4... = папки из TDLib - if let Some(folder) = app.td_client.folders().get(folder_num - 1) { - let folder_id = folder.id; - app.selected_folder_id = Some(folder_id); - // Загружаем чаты папки - app.status_message = Some("Загрузка чатов папки...".to_string()); - let _ = with_timeout( - Duration::from_secs(5), - app.td_client.load_folder_chats(folder_id, 50), - ) - .await; - app.status_message = None; - } - } - app.chat_list_state.select(Some(0)); - } - _ => {} - } - } -} - -/// Подсчёт количества доступных действий в профиле -fn get_available_actions_count(profile: &crate::tdlib::ProfileInfo) -> usize { - let mut count = 0; - - if profile.username.is_some() { - count += 1; // Открыть в браузере - } - - count += 1; // Скопировать ID - - if profile.is_group { - count += 1; // Покинуть группу - } - - count -} - -/// Копирует текст в системный буфер обмена -#[cfg(feature = "clipboard")] -fn copy_to_clipboard(text: &str) -> Result<(), String> { - use arboard::Clipboard; - - let mut clipboard = - Clipboard::new().map_err(|e| format!("Не удалось инициализировать буфер обмена: {}", e))?; - clipboard - .set_text(text) - .map_err(|e| format!("Не удалось скопировать: {}", e))?; - - Ok(()) -} - -/// Заглушка для copy_to_clipboard когда feature "clipboard" выключена -#[cfg(not(feature = "clipboard"))] -fn copy_to_clipboard(_text: &str) -> Result<(), String> { - Err("Копирование в буфер обмена недоступно (требуется feature 'clipboard')".to_string()) -} - -/// Форматирует сообщение для копирования с контекстом -fn format_message_for_clipboard(msg: &crate::tdlib::MessageInfo) -> String { - let mut result = String::new(); - - // Добавляем forward контекст если есть - if let Some(forward) = msg.forward_from() { - result.push_str(&format!("↪ Переслано от {}\n", forward.sender_name)); - } - - // Добавляем reply контекст если есть - if let Some(reply) = msg.reply_to() { - result.push_str(&format!("┌ {}: {}\n", reply.sender_name, reply.text)); - } - - // Добавляем основной текст с markdown форматированием - result.push_str(&convert_entities_to_markdown(msg.text(), msg.entities())); - - result -} - -/// Конвертирует текст с entities в markdown -fn convert_entities_to_markdown(text: &str, entities: &[tdlib_rs::types::TextEntity]) -> String { - use tdlib_rs::enums::TextEntityType; - - if entities.is_empty() { - return text.to_string(); - } - - // Создаём вектор символов для работы с unicode - let chars: Vec = text.chars().collect(); - let mut result = String::new(); - let mut i = 0; - - while i < chars.len() { - // Ищем entity, который начинается в текущей позиции - let mut entity_found = false; - - for entity in entities { - if entity.offset as usize == i { - entity_found = true; - let end = (entity.offset + entity.length) as usize; - let entity_text: String = chars[i..end.min(chars.len())].iter().collect(); - - // Применяем форматирование в зависимости от типа - let formatted = match &entity.r#type { - TextEntityType::Bold => format!("**{}**", entity_text), - TextEntityType::Italic => format!("*{}*", entity_text), - TextEntityType::Underline => format!("__{}__", entity_text), - TextEntityType::Strikethrough => format!("~~{}~~", entity_text), - TextEntityType::Code | TextEntityType::Pre | TextEntityType::PreCode(_) => { - format!("`{}`", entity_text) - } - TextEntityType::TextUrl(url_info) => { - format!("[{}]({})", entity_text, url_info.url) - } - TextEntityType::Url => format!("<{}>", entity_text), - TextEntityType::Mention | TextEntityType::MentionName(_) => { - format!("@{}", entity_text.trim_start_matches('@')) - } - TextEntityType::Spoiler => format!("||{}||", entity_text), - _ => entity_text, - }; - - result.push_str(&formatted); - i = end; - break; - } - } - - if !entity_found { - result.push(chars[i]); - i += 1; - } - } - - result -} diff --git a/src/tdlib/client.rs.backup b/src/tdlib/client.rs.backup deleted file mode 100644 index 4d075f4..0000000 --- a/src/tdlib/client.rs.backup +++ /dev/null @@ -1,2036 +0,0 @@ -use crate::constants::{ - LAZY_LOAD_USERS_PER_TICK, MAX_CHAT_USER_IDS, MAX_CHATS, MAX_MESSAGES_IN_CHAT, - MAX_USER_CACHE_SIZE, TDLIB_CHAT_LIMIT, TDLIB_MESSAGE_LIMIT, -}; -use std::collections::HashMap; -use std::env; -use std::time::Instant; -use tdlib_rs::enums::{ - AuthorizationState, ChatAction, ChatList, ChatType, ConnectionState, MessageContent, - MessageSender, SearchMessagesFilter, Update, User, UserStatus, -}; -use tdlib_rs::types::TextEntity; - -/// Простой LRU-кэш на основе HashMap + Vec для отслеживания порядка -pub struct LruCache { - map: HashMap, - /// Порядок доступа: последний элемент — самый недавно использованный - order: Vec, - capacity: usize, -} - -impl LruCache { - 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() - } -} -use tdlib_rs::functions; -use tdlib_rs::types::{Chat as TdChat, Message as TdMessage}; - -#[derive(Debug, Clone, PartialEq)] -#[allow(dead_code)] -pub enum AuthState { - WaitTdlibParameters, - WaitPhoneNumber, - WaitCode, - WaitPassword, - Ready, - Closed, - Error(String), -} - -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub struct ChatInfo { - pub id: i64, - pub title: String, - pub username: Option, - 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, - /// Чат замьючен (уведомления отключены) - pub is_muted: bool, - /// Черновик сообщения - pub draft_text: Option, -} - -/// Информация о сообщении, на которое отвечают -#[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, - 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, - /// Информация о forward (если сообщение переслано) - pub forward_from: Option, - /// Реакции на сообщение - pub reactions: Vec, -} - -#[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, - pub bio: Option, - pub phone_number: Option, - pub chat_type: String, // "Личный чат", "Группа", "Канал" - pub member_count: Option, - pub description: Option, - pub invite_link: Option, - pub is_group: bool, - pub online_status: Option, -} - -/// Состояние сетевого соединения -#[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), -} - -pub struct TdClient { - pub auth_state: AuthState, - pub api_id: i32, - pub api_hash: String, - client_id: i32, - pub chats: Vec, - pub current_chat_messages: Vec, - /// ID текущего открытого чата (для получения новых сообщений) - pub current_chat_id: Option, - /// LRU-кэш usernames: user_id -> username - user_usernames: LruCache, - /// LRU-кэш имён: user_id -> display_name (first_name + last_name) - user_names: LruCache, - /// Связь chat_id -> user_id для приватных чатов - chat_user_ids: HashMap, - /// Очередь сообщений для отметки как прочитанных: (chat_id, message_ids) - pub pending_view_messages: Vec<(i64, Vec)>, - /// Очередь user_id для загрузки имён - pub pending_user_ids: Vec, - /// Папки чатов - pub folders: Vec, - /// Позиция основного списка среди папок - pub main_chat_list_position: i32, - /// LRU-кэш онлайн-статусов пользователей: user_id -> status - user_statuses: LruCache, - /// Состояние сетевого соединения - pub network_state: NetworkState, - /// Typing status для текущего чата: (user_id, action_text, timestamp) - pub typing_status: Option<(i64, String, Instant)>, - /// Последнее закреплённое сообщение текущего чата - pub current_pinned_message: Option, -} - -#[allow(dead_code)] -impl TdClient { - pub fn new() -> Self { - // Загружаем credentials из ~/.config/tele-tui/credentials или .env - let (api_id, api_hash) = match crate::config::Config::load_credentials() { - Ok(creds) => creds, - Err(err_msg) => { - eprintln!("\n{}\n", err_msg); - // Используем дефолтные значения, чтобы приложение запустилось - // Пользователь увидит сообщение об ошибке в UI - (0, String::new()) - } - }; - - let client_id = tdlib_rs::create_client(); - - TdClient { - auth_state: AuthState::WaitTdlibParameters, - api_id, - api_hash, - client_id, - chats: Vec::new(), - current_chat_messages: Vec::new(), - current_chat_id: None, - user_usernames: LruCache::new(MAX_USER_CACHE_SIZE), - user_names: LruCache::new(MAX_USER_CACHE_SIZE), - chat_user_ids: HashMap::new(), - pending_view_messages: Vec::new(), - pending_user_ids: Vec::new(), - folders: Vec::new(), - main_chat_list_position: 0, - user_statuses: LruCache::new(MAX_USER_CACHE_SIZE), - network_state: NetworkState::Connecting, - typing_status: None, - current_pinned_message: None, - } - } - - pub fn is_authenticated(&self) -> bool { - matches!(self.auth_state, AuthState::Ready) - } - - pub fn client_id(&self) -> i32 { - self.client_id - } - - /// Добавляет сообщение в текущий чат с соблюдением лимита - /// Если сообщение с таким id уже есть — заменяет его (сохраняя reply_to) - pub fn push_message(&mut self, msg: MessageInfo) { - // Проверяем, есть ли уже сообщение с таким id - if let Some(idx) = self - .current_chat_messages - .iter() - .position(|m| m.id == msg.id) - { - // Если новое сообщение имеет reply_to, или старое не имеет — заменяем - if msg.reply_to.is_some() || self.current_chat_messages[idx].reply_to.is_none() { - self.current_chat_messages[idx] = msg; - } - return; - } - - self.current_chat_messages.push(msg); - // Ограничиваем количество сообщений (удаляем старые) - if self.current_chat_messages.len() > MAX_MESSAGES_IN_CHAT { - self.current_chat_messages.remove(0); - } - } - - /// Получение онлайн-статуса пользователя по chat_id (для приватных чатов) - /// Использует peek для read-only доступа (не обновляет LRU порядок) - pub fn get_user_status_by_chat_id(&self, chat_id: i64) -> Option<&UserOnlineStatus> { - self.chat_user_ids - .get(&chat_id) - .and_then(|user_id| self.user_statuses.peek(user_id)) - } - - /// Очищает typing status если прошло более 6 секунд - /// Возвращает true если статус был очищен (нужна перерисовка) - pub fn clear_stale_typing_status(&mut self) -> bool { - if let Some((_, _, timestamp)) = &self.typing_status { - if timestamp.elapsed().as_secs() > 6 { - self.typing_status = None; - return true; - } - } - false - } - - /// Возвращает текст typing status с именем пользователя - /// Например: "Вася печатает..." - pub fn get_typing_text(&self) -> Option { - self.typing_status.as_ref().map(|(user_id, action, _)| { - let name = self - .user_names - .peek(user_id) - .cloned() - .unwrap_or_else(|| "Кто-то".to_string()); - format!("{} {}", name, action) - }) - } - - /// Инициализация TDLib с параметрами - pub async fn init(&mut self) -> Result<(), String> { - let result = functions::set_tdlib_parameters( - false, // use_test_dc - "tdlib_data".to_string(), // database_directory - "".to_string(), // files_directory - "".to_string(), // database_encryption_key - true, // use_file_database - true, // use_chat_info_database - true, // use_message_database - false, // use_secret_chats - self.api_id, // api_id - self.api_hash.clone(), // api_hash - "en".to_string(), // system_language_code - "Desktop".to_string(), // device_model - "".to_string(), // system_version - env!("CARGO_PKG_VERSION").to_string(), // application_version - self.client_id, - ) - .await; - - match result { - Ok(_) => Ok(()), - Err(e) => Err(format!("Failed to set TDLib parameters: {:?}", e)), - } - } - - /// Обрабатываем одно обновление от TDLib - pub fn handle_update(&mut self, update: Update) { - match update { - Update::AuthorizationState(state) => { - self.handle_auth_state(state.authorization_state); - } - Update::NewChat(new_chat) => { - self.add_or_update_chat(&new_chat.chat); - } - Update::ChatLastMessage(update) => { - let chat_id = update.chat_id; - let (last_message_text, last_message_date) = update - .last_message - .as_ref() - .map(|msg| (extract_message_text_static(msg).0, msg.date)) - .unwrap_or_default(); - - if let Some(chat) = self.chats.iter_mut().find(|c| c.id == chat_id) { - chat.last_message = last_message_text; - chat.last_message_date = last_message_date; - } - - // Обновляем позиции если они пришли - for pos in &update.positions { - if matches!(pos.list, ChatList::Main) { - if let Some(chat) = self.chats.iter_mut().find(|c| c.id == chat_id) { - chat.order = pos.order; - chat.is_pinned = pos.is_pinned; - } - } - } - - // Пересортируем по order - self.chats.sort_by(|a, b| b.order.cmp(&a.order)); - } - Update::ChatReadInbox(update) => { - if let Some(chat) = self.chats.iter_mut().find(|c| c.id == update.chat_id) { - chat.unread_count = update.unread_count; - } - } - Update::ChatUnreadMentionCount(update) => { - if let Some(chat) = self.chats.iter_mut().find(|c| c.id == update.chat_id) { - chat.unread_mention_count = update.unread_mention_count; - } - } - Update::ChatNotificationSettings(update) => { - if let Some(chat) = self.chats.iter_mut().find(|c| c.id == update.chat_id) { - // mute_for > 0 означает что чат замьючен - chat.is_muted = update.notification_settings.mute_for > 0; - } - } - Update::ChatReadOutbox(update) => { - // Обновляем last_read_outbox_message_id когда собеседник прочитал сообщения - if let Some(chat) = self.chats.iter_mut().find(|c| c.id == update.chat_id) { - chat.last_read_outbox_message_id = update.last_read_outbox_message_id; - } - // Если это текущий открытый чат — обновляем is_read у сообщений - if Some(update.chat_id) == self.current_chat_id { - for msg in &mut self.current_chat_messages { - if msg.is_outgoing && msg.id <= update.last_read_outbox_message_id { - msg.is_read = true; - } - } - } - } - Update::ChatPosition(update) => { - // Обновляем позицию чата или удаляем его из списка - match &update.position.list { - ChatList::Main => { - if update.position.order == 0 { - // Чат больше не в Main (перемещён в архив и т.д.) - self.chats.retain(|c| c.id != update.chat_id); - } else if let Some(chat) = - self.chats.iter_mut().find(|c| c.id == update.chat_id) - { - // Обновляем позицию существующего чата - chat.order = update.position.order; - chat.is_pinned = update.position.is_pinned; - } - // Пересортируем по order - self.chats.sort_by(|a, b| b.order.cmp(&a.order)); - } - ChatList::Folder(folder) => { - // Обновляем folder_ids для чата - if let Some(chat) = self.chats.iter_mut().find(|c| c.id == update.chat_id) { - 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::NewMessage(new_msg) => { - // Добавляем новое сообщение если это текущий открытый чат - let chat_id = new_msg.message.chat_id; - if Some(chat_id) == self.current_chat_id { - let msg_info = self.convert_message(&new_msg.message, chat_id); - let msg_id = msg_info.id; - let is_incoming = !msg_info.is_outgoing; - - // Проверяем, есть ли уже сообщение с таким id - let existing_idx = self - .current_chat_messages - .iter() - .position(|m| m.id == msg_info.id); - - match existing_idx { - Some(idx) => { - // Сообщение уже есть - обновляем - if is_incoming { - self.current_chat_messages[idx] = msg_info; - } else { - // Для исходящих: обновляем can_be_edited и другие поля, - // но сохраняем reply_to (добавленный при отправке) - let existing = &mut self.current_chat_messages[idx]; - existing.can_be_edited = msg_info.can_be_edited; - existing.can_be_deleted_only_for_self = - msg_info.can_be_deleted_only_for_self; - existing.can_be_deleted_for_all_users = - msg_info.can_be_deleted_for_all_users; - existing.is_read = msg_info.is_read; - } - } - None => { - // Нового сообщения нет - добавляем - self.push_message(msg_info); - // Если это входящее сообщение — добавляем в очередь для отметки как прочитанное - if is_incoming { - self.pending_view_messages.push((chat_id, vec![msg_id])); - } - } - } - } - } - Update::User(update) => { - // Сохраняем имя и username пользователя - let user = update.user; - - // Пропускаем удалённые аккаунты (пустое имя) - if user.first_name.is_empty() && user.last_name.is_empty() { - // Удаляем чаты с этим пользователем из списка - let user_id = user.id; - self.chats - .retain(|c| self.chat_user_ids.get(&c.id) != Some(&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) - }; - self.user_names.insert(user.id, display_name); - - // Сохраняем username если есть - if let Some(usernames) = user.usernames { - if let Some(username) = usernames.active_usernames.first() { - self.user_usernames.insert(user.id, username.clone()); - // Обновляем username в чатах, связанных с этим пользователем - for (&chat_id, &user_id) in &self.chat_user_ids.clone() { - if user_id == user.id { - if let Some(chat) = self.chats.iter_mut().find(|c| c.id == chat_id) - { - chat.username = Some(format!("@{}", username)); - } - } - } - } - } - // LRU-кэш автоматически удаляет старые записи при вставке - } - Update::ChatFolders(update) => { - // Обновляем список папок - self.folders = update - .chat_folders - .into_iter() - .map(|f| FolderInfo { id: f.id, name: f.title }) - .collect(); - self.main_chat_list_position = update.main_chat_list_position; - } - Update::UserStatus(update) => { - // Обновляем онлайн-статус пользователя - let status = match update.status { - UserStatus::Online(_) => UserOnlineStatus::Online, - UserStatus::Offline(offline) => UserOnlineStatus::Offline(offline.was_online), - UserStatus::Recently(_) => UserOnlineStatus::Recently, - UserStatus::LastWeek(_) => UserOnlineStatus::LastWeek, - UserStatus::LastMonth(_) => UserOnlineStatus::LastMonth, - UserStatus::Empty => UserOnlineStatus::LongTimeAgo, - }; - self.user_statuses.insert(update.user_id, status); - } - Update::ConnectionState(update) => { - // Обновляем состояние сетевого соединения - self.network_state = match update.state { - ConnectionState::WaitingForNetwork => NetworkState::WaitingForNetwork, - ConnectionState::ConnectingToProxy => NetworkState::ConnectingToProxy, - ConnectionState::Connecting => NetworkState::Connecting, - ConnectionState::Updating => NetworkState::Updating, - ConnectionState::Ready => NetworkState::Ready, - }; - } - Update::ChatAction(update) => { - // Обрабатываем только для текущего открытого чата - if Some(update.chat_id) == self.current_chat_id { - // Извлекаем user_id из sender_id - let user_id = match update.sender_id { - MessageSender::User(user) => Some(user.user_id), - MessageSender::Chat(_) => None, // Игнорируем действия от имени чата - }; - - if let Some(user_id) = 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, // Отмена — сбрасываем статус - _ => None, - }; - - if let Some(text) = action_text { - self.typing_status = Some((user_id, text, Instant::now())); - } else { - // Cancel или неизвестное действие — сбрасываем - self.typing_status = None; - } - } - } - } - Update::ChatDraftMessage(update) => { - // Обновляем черновик в списке чатов - if let Some(chat) = self.chats.iter_mut().find(|c| c.id == update.chat_id) { - chat.draft_text = update.draft_message.as_ref().and_then(|draft| { - // Извлекаем текст из InputMessageText - if let tdlib_rs::enums::InputMessageContent::InputMessageText(text_msg) = - &draft.input_message_text - { - Some(text_msg.text.text.clone()) - } else { - None - } - }); - } - } - Update::MessageInteractionInfo(update) => { - // Обновляем реакции в текущем открытом чате - if Some(update.chat_id) == self.current_chat_id { - if let Some(msg) = self - .current_chat_messages - .iter_mut() - .find(|m| m.id == update.message_id) - { - // Извлекаем реакции из interaction_info - msg.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(); - } - } - } - _ => {} - } - } - - fn handle_auth_state(&mut self, state: AuthorizationState) { - self.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, - _ => self.auth_state.clone(), - }; - } - - fn add_or_update_chat(&mut self, td_chat: &TdChat) { - // Пропускаем удалённые аккаунты - if td_chat.title == "Deleted Account" || td_chat.title.is_empty() { - // Удаляем из списка если уже был добавлен - self.chats.retain(|c| c.id != td_chat.id); - return; - } - - // Ищем позицию в Main списке (если есть) - let main_position = td_chat - .positions - .iter() - .find(|pos| matches!(pos.list, ChatList::Main)); - - // Получаем order и is_pinned из позиции, или используем значения по умолчанию - let (order, is_pinned) = main_position - .map(|p| (p.order, p.is_pinned)) - .unwrap_or((1, false)); // order=1 чтобы чат отображался - - let (last_message, last_message_date) = td_chat - .last_message - .as_ref() - .map(|m| (extract_message_text_static(m).0, m.date)) - .unwrap_or_default(); - - // Извлекаем user_id для приватных чатов и сохраняем связь - let username = match &td_chat.r#type { - ChatType::Private(private) => { - // Ограничиваем размер chat_user_ids - if self.chat_user_ids.len() >= MAX_CHAT_USER_IDS - && !self.chat_user_ids.contains_key(&td_chat.id) - { - // Удаляем случайную запись (первую найденную) - if let Some(&key) = self.chat_user_ids.keys().next() { - self.chat_user_ids.remove(&key); - } - } - self.chat_user_ids.insert(td_chat.id, private.user_id); - // Проверяем, есть ли уже username в кэше (peek не обновляет LRU) - self.user_usernames - .peek(&private.user_id) - .map(|u| format!("@{}", u)) - } - _ => None, - }; - - // Извлекаем ID папок из позиций - let folder_ids: Vec = td_chat - .positions - .iter() - .filter_map(|pos| { - if let ChatList::Folder(folder) = &pos.list { - Some(folder.chat_folder_id) - } else { - None - } - }) - .collect(); - - // Проверяем mute статус - let is_muted = td_chat.notification_settings.mute_for > 0; - - let chat_info = ChatInfo { - id: td_chat.id, - title: td_chat.title.clone(), - username, - last_message, - last_message_date, - unread_count: td_chat.unread_count, - unread_mention_count: td_chat.unread_mention_count, - is_pinned, - order, - last_read_outbox_message_id: td_chat.last_read_outbox_message_id, - folder_ids, - is_muted, - draft_text: None, - }; - - if let Some(existing) = self.chats.iter_mut().find(|c| c.id == td_chat.id) { - existing.title = chat_info.title; - existing.last_message = chat_info.last_message; - existing.last_message_date = chat_info.last_message_date; - existing.unread_count = chat_info.unread_count; - existing.unread_mention_count = chat_info.unread_mention_count; - existing.last_read_outbox_message_id = chat_info.last_read_outbox_message_id; - existing.folder_ids = chat_info.folder_ids; - existing.is_muted = chat_info.is_muted; - // Обновляем username если он появился - if chat_info.username.is_some() { - existing.username = chat_info.username; - } - // Обновляем позицию только если она пришла - if main_position.is_some() { - existing.is_pinned = chat_info.is_pinned; - existing.order = chat_info.order; - } - } else { - self.chats.push(chat_info); - // Ограничиваем количество чатов - if self.chats.len() > MAX_CHATS { - // Удаляем чат с наименьшим order (наименее активный) - if let Some(min_idx) = self - .chats - .iter() - .enumerate() - .min_by_key(|(_, c)| c.order) - .map(|(i, _)| i) - { - self.chats.remove(min_idx); - } - } - } - - // Сортируем чаты по order (TDLib order учитывает pinned и время) - self.chats.sort_by(|a, b| b.order.cmp(&a.order)); - } - - fn convert_message(&mut self, message: &TdMessage, chat_id: i64) -> MessageInfo { - let sender_name = match &message.sender_id { - tdlib_rs::enums::MessageSender::User(user) => { - // Пробуем получить имя из кеша (get обновляет LRU порядок) - if let Some(name) = self.user_names.get(&user.user_id).cloned() { - name - } else { - // Добавляем в очередь для загрузки - if !self.pending_user_ids.contains(&user.user_id) { - self.pending_user_ids.push(user.user_id); - } - format!("User_{}", user.user_id) - } - } - tdlib_rs::enums::MessageSender::Chat(chat) => { - // Для чатов используем название чата - self.chats - .iter() - .find(|c| c.id == chat.chat_id) - .map(|c| c.title.clone()) - .unwrap_or_else(|| format!("Chat_{}", chat.chat_id)) - } - }; - - // Определяем, прочитано ли исходящее сообщение - let is_read = if message.is_outgoing { - // Сообщение прочитано, если его ID <= last_read_outbox_message_id чата - self.chats - .iter() - .find(|c| c.id == chat_id) - .map(|c| message.id <= c.last_read_outbox_message_id) - .unwrap_or(false) - } else { - true // Входящие сообщения не показывают галочки - }; - - let (content, entities) = extract_message_text_static(message); - - // Извлекаем информацию о reply - let reply_to = self.extract_reply_info(message); - - // Извлекаем информацию о forward - let forward_from = self.extract_forward_info(message); - - // Извлекаем реакции - let reactions = self.extract_reactions(message); - - MessageInfo { - id: message.id, - sender_name, - is_outgoing: message.is_outgoing, - content, - entities, - date: message.date, - edit_date: message.edit_date, - is_read, - can_be_edited: message.can_be_edited, - can_be_deleted_only_for_self: message.can_be_deleted_only_for_self, - can_be_deleted_for_all_users: message.can_be_deleted_for_all_users, - reply_to, - forward_from, - reactions, - } - } - - /// Извлекает информацию о reply из сообщения - fn extract_reply_info(&self, message: &TdMessage) -> Option { - use tdlib_rs::enums::MessageReplyTo; - - match &message.reply_to { - Some(MessageReplyTo::Message(reply)) => { - // Получаем имя отправителя из origin или ищем сообщение в текущем списке - let sender_name = if let Some(origin) = &reply.origin { - self.get_origin_sender_name(origin) - } else { - // Пробуем найти оригинальное сообщение в текущем списке - self.current_chat_messages - .iter() - .find(|m| m.id == reply.message_id) - .map(|m| m.sender_name.clone()) - .unwrap_or_else(|| "...".to_string()) - }; - - // Получаем текст из content или quote - let text = if let Some(quote) = &reply.quote { - quote.text.text.clone() - } else if let Some(content) = &reply.content { - extract_content_text(content) - } else { - // Пробуем найти в текущих сообщениях - self.current_chat_messages - .iter() - .find(|m| m.id == reply.message_id) - .map(|m| m.content.clone()) - .unwrap_or_default() - }; - - Some(ReplyInfo { message_id: reply.message_id, sender_name, text }) - } - _ => None, - } - } - - /// Извлекает информацию о forward из сообщения - fn extract_forward_info(&self, message: &TdMessage) -> Option { - message.forward_info.as_ref().map(|info| { - let sender_name = self.get_origin_sender_name(&info.origin); - ForwardInfo { sender_name, date: info.date } - }) - } - - /// Извлекает информацию о реакциях из сообщения - fn extract_reactions(&self, message: &TdMessage) -> Vec { - message - .interaction_info - .as_ref() - .and_then(|info| info.reactions.as_ref()) - .map(|reactions| { - reactions - .reactions - .iter() - .filter_map(|reaction| { - // Извлекаем эмодзи из ReactionType - let emoji = match &reaction.r#type { - tdlib_rs::enums::ReactionType::Emoji(e) => e.emoji.clone(), - tdlib_rs::enums::ReactionType::CustomEmoji(_) => return None, // Пока игнорируем custom emoji - }; - - Some(ReactionInfo { - emoji, - count: reaction.total_count, - is_chosen: reaction.is_chosen, - }) - }) - .collect() - }) - .unwrap_or_default() - } - - /// Получает имя отправителя из MessageOrigin - fn get_origin_sender_name(&self, origin: &tdlib_rs::enums::MessageOrigin) -> String { - use tdlib_rs::enums::MessageOrigin; - match origin { - MessageOrigin::User(u) => self - .user_names - .peek(&u.sender_user_id) - .cloned() - .unwrap_or_else(|| format!("User_{}", u.sender_user_id)), - MessageOrigin::Chat(c) => self - .chats - .iter() - .find(|chat| chat.id == c.sender_chat_id) - .map(|chat| chat.title.clone()) - .unwrap_or_else(|| "Чат".to_string()), - MessageOrigin::HiddenUser(h) => h.sender_name.clone(), - MessageOrigin::Channel(c) => self - .chats - .iter() - .find(|chat| chat.id == c.chat_id) - .map(|chat| chat.title.clone()) - .unwrap_or_else(|| "Канал".to_string()), - } - } - - /// Обновляет reply info для сообщений, где данные не были загружены - /// Вызывается после загрузки истории, когда все сообщения уже в списке - fn update_reply_info_from_loaded_messages(&mut self) { - // Собираем данные для обновления (id -> (sender_name, content)) - let msg_data: std::collections::HashMap = self - .current_chat_messages - .iter() - .map(|m| (m.id, (m.sender_name.clone(), m.content.clone()))) - .collect(); - - // Обновляем reply_to для сообщений с неполными данными - for msg in &mut self.current_chat_messages { - if let Some(ref mut reply) = msg.reply_to { - // Если sender_name = "..." или text пустой — пробуем заполнить - if reply.sender_name == "..." || reply.text.is_empty() { - if let Some((sender, content)) = msg_data.get(&reply.message_id) { - if reply.sender_name == "..." { - reply.sender_name = sender.clone(); - } - if reply.text.is_empty() { - reply.text = content.clone(); - } - } - } - } - } - } - - /// Асинхронно обновляет reply info, загружая недостающие сообщения - pub async fn fetch_missing_reply_info(&mut self) { - let chat_id = match self.current_chat_id { - Some(id) => id, - None => return, - }; - - // Собираем message_id для которых нужно загрузить данные - let missing_ids: Vec = self - .current_chat_messages - .iter() - .filter_map(|msg| { - msg.reply_to.as_ref().and_then(|reply| { - if reply.sender_name == "..." || reply.text.is_empty() { - Some(reply.message_id) - } else { - None - } - }) - }) - .collect(); - - if missing_ids.is_empty() { - return; - } - - // Загружаем каждое сообщение и кэшируем данные - let mut reply_cache: std::collections::HashMap = - std::collections::HashMap::new(); - - for msg_id in missing_ids { - if reply_cache.contains_key(&msg_id) { - continue; - } - - if let Ok(tdlib_rs::enums::Message::Message(msg)) = - functions::get_message(chat_id, msg_id, self.client_id).await - { - let sender_name = match &msg.sender_id { - tdlib_rs::enums::MessageSender::User(user) => self - .user_names - .get(&user.user_id) - .cloned() - .unwrap_or_else(|| format!("User_{}", user.user_id)), - tdlib_rs::enums::MessageSender::Chat(chat) => self - .chats - .iter() - .find(|c| c.id == chat.chat_id) - .map(|c| c.title.clone()) - .unwrap_or_else(|| "Чат".to_string()), - }; - let (content, _) = extract_message_text_static(&msg); - reply_cache.insert(msg_id, (sender_name, content)); - } - } - - // Применяем загруженные данные - for msg in &mut self.current_chat_messages { - if let Some(ref mut reply) = msg.reply_to { - if let Some((sender, content)) = reply_cache.get(&reply.message_id) { - if reply.sender_name == "..." { - reply.sender_name = sender.clone(); - } - if reply.text.is_empty() { - reply.text = content.clone(); - } - } - } - } - } - - /// Отправка номера телефона - pub async fn send_phone_number(&mut self, phone: String) -> Result<(), String> { - let result = functions::set_authentication_phone_number(phone, None, self.client_id).await; - - match result { - Ok(_) => Ok(()), - Err(e) => Err(format!("Ошибка отправки номера: {:?}", e)), - } - } - - /// Отправка кода подтверждения - pub async fn send_code(&mut self, code: String) -> Result<(), String> { - let result = functions::check_authentication_code(code, self.client_id).await; - - match result { - Ok(_) => Ok(()), - Err(e) => Err(format!("Неверный код: {:?}", e)), - } - } - - /// Отправка пароля 2FA - pub async fn send_password(&mut self, password: String) -> Result<(), String> { - let result = functions::check_authentication_password(password, self.client_id).await; - - match result { - Ok(_) => Ok(()), - Err(e) => Err(format!("Неверный пароль: {:?}", e)), - } - } - - /// Загрузка списка чатов - 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 get_chat_history( - &mut self, - chat_id: i64, - limit: i32, - ) -> Result, String> { - // Устанавливаем текущий чат для получения новых сообщений - self.current_chat_id = Some(chat_id); - let _ = functions::open_chat(chat_id, self.client_id).await; - - // Пробуем загрузить несколько раз, так как сообщения могут подгружаться с сервера - let mut all_messages: Vec = Vec::new(); - let mut from_message_id: i64 = 0; - let mut attempts = 0; - const MAX_ATTEMPTS: i32 = 3; - - while attempts < MAX_ATTEMPTS { - let result = functions::get_chat_history( - chat_id, - from_message_id, - 0, // offset - limit, - false, // only_local - загружаем с сервера! - self.client_id, - ) - .await; - - match result { - Ok(tdlib_rs::enums::Messages::Messages(messages)) => { - let mut batch: Vec = Vec::new(); - for m in messages.messages.into_iter().flatten() { - batch.push(self.convert_message(&m, chat_id)); - } - - if batch.is_empty() { - break; - } - - // Запоминаем ID самого старого сообщения для следующей загрузки - if let Some(oldest) = batch.last() { - from_message_id = oldest.id; - } - - // Добавляем сообщения (они приходят от новых к старым) - all_messages.extend(batch); - attempts += 1; - - // Если получили достаточно сообщений, выходим - if all_messages.len() >= limit as usize { - break; - } - } - Err(e) => { - if all_messages.is_empty() { - return Err(format!("Ошибка загрузки сообщений: {:?}", e)); - } - break; - } - } - } - - // Сообщения приходят от новых к старым, переворачиваем - all_messages.reverse(); - self.current_chat_messages = all_messages.clone(); - - // Обновляем reply info для сообщений где данные не были загружены - self.update_reply_info_from_loaded_messages(); - - // Отмечаем сообщения как прочитанные - if !all_messages.is_empty() { - let message_ids: Vec = all_messages.iter().map(|m| m.id).collect(); - let _ = functions::view_messages( - chat_id, - message_ids, - None, // source - true, // force_read - self.client_id, - ) - .await; - } - - Ok(all_messages) - } - - /// Загрузка закреплённых сообщений чата - pub async fn get_pinned_messages(&mut self, chat_id: i64) -> Result, String> { - let result = functions::search_chat_messages( - chat_id, - "".to_string(), // query - None, // sender_id - 0, // from_message_id - 0, // offset - 100, // limit - Some(SearchMessagesFilter::Pinned), // filter - 0, // message_thread_id - 0, // saved_messages_topic_id - self.client_id, - ) - .await; - - match result { - Ok(tdlib_rs::enums::FoundChatMessages::FoundChatMessages(found)) => { - let mut messages: Vec = Vec::new(); - for m in found.messages { - messages.push(self.convert_message(&m, chat_id)); - } - // Сообщения приходят от новых к старым, оставляем как есть - Ok(messages) - } - Err(e) => Err(format!("Ошибка загрузки закреплённых: {:?}", e)), - } - } - - /// Загружает последнее закреплённое сообщение для текущего чата - pub async fn load_current_pinned_message(&mut self, chat_id: i64) { - let result = functions::search_chat_messages( - chat_id, - "".to_string(), - None, - 0, - 0, - 1, // Только одно сообщение - Some(SearchMessagesFilter::Pinned), - 0, - 0, - self.client_id, - ) - .await; - - match result { - Ok(tdlib_rs::enums::FoundChatMessages::FoundChatMessages(found)) => { - if let Some(m) = found.messages.first() { - self.current_pinned_message = Some(self.convert_message(m, chat_id)); - } else { - self.current_pinned_message = None; - } - } - Err(_) => { - self.current_pinned_message = None; - } - } - } - - /// Поиск сообщений в чате по тексту - pub async fn search_messages( - &mut self, - chat_id: i64, - query: &str, - ) -> Result, String> { - if query.trim().is_empty() { - return Ok(Vec::new()); - } - - let result = functions::search_chat_messages( - chat_id, - query.to_string(), - None, // sender_id - 0, // from_message_id - 0, // offset - TDLIB_MESSAGE_LIMIT, // limit - None, // filter (no filter = search by text) - 0, // message_thread_id - 0, // saved_messages_topic_id - self.client_id, - ) - .await; - - match result { - Ok(tdlib_rs::enums::FoundChatMessages::FoundChatMessages(found)) => { - let mut messages: Vec = Vec::new(); - for m in found.messages { - messages.push(self.convert_message(&m, chat_id)); - } - Ok(messages) - } - Err(e) => Err(format!("Ошибка поиска: {:?}", e)), - } - } - - /// Получение полной информации о чате для профиля - pub async fn get_profile_info(&self, chat_id: i64) -> Result { - use tdlib_rs::enums::ChatType; - - // Получаем основную информацию о чате - let chat_result = functions::get_chat(chat_id, self.client_id).await; - let chat = match chat_result { - Ok(tdlib_rs::enums::Chat::Chat(c)) => c, - Err(e) => return Err(format!("Ошибка загрузки чата: {:?}", e)), - }; - - let mut profile = ProfileInfo { - chat_id, - title: chat.title.clone(), - username: None, - bio: None, - phone_number: None, - chat_type: String::new(), - member_count: None, - description: None, - invite_link: None, - is_group: false, - online_status: None, - }; - - match &chat.r#type { - ChatType::Private(private_chat) => { - profile.chat_type = "Личный чат".to_string(); - profile.is_group = false; - - // Получаем полную информацию о пользователе - let user_result = functions::get_user(private_chat.user_id, self.client_id).await; - if let Ok(tdlib_rs::enums::User::User(user)) = user_result { - // Username - if let Some(usernames) = user.usernames { - if let Some(username) = usernames.active_usernames.first() { - profile.username = Some(format!("@{}", username)); - } - } - - // Phone number - if !user.phone_number.is_empty() { - profile.phone_number = Some(format!("+{}", user.phone_number)); - } - - // Online status - profile.online_status = Some(match user.status { - tdlib_rs::enums::UserStatus::Online(_) => "Онлайн".to_string(), - tdlib_rs::enums::UserStatus::Recently(_) => "Был(а) недавно".to_string(), - tdlib_rs::enums::UserStatus::LastWeek(_) => { - "Был(а) на этой неделе".to_string() - } - tdlib_rs::enums::UserStatus::LastMonth(_) => { - "Был(а) в этом месяце".to_string() - } - tdlib_rs::enums::UserStatus::Offline(offline) => { - crate::utils::format_was_online(offline.was_online) - } - _ => "Давно не был(а)".to_string(), - }); - } - - // Bio (getUserFullInfo) - let full_info_result = - functions::get_user_full_info(private_chat.user_id, self.client_id).await; - if let Ok(tdlib_rs::enums::UserFullInfo::UserFullInfo(full_info)) = full_info_result - { - if let Some(bio_obj) = full_info.bio { - profile.bio = Some(bio_obj.text); - } - } - } - ChatType::BasicGroup(basic_group) => { - profile.chat_type = "Группа".to_string(); - profile.is_group = true; - - // Получаем информацию о группе - let group_result = - functions::get_basic_group(basic_group.basic_group_id, self.client_id).await; - if let Ok(tdlib_rs::enums::BasicGroup::BasicGroup(group)) = group_result { - profile.member_count = Some(group.member_count); - } - - // Полная информация о группе - let full_info_result = functions::get_basic_group_full_info( - basic_group.basic_group_id, - self.client_id, - ) - .await; - if let Ok(tdlib_rs::enums::BasicGroupFullInfo::BasicGroupFullInfo(full_info)) = - full_info_result - { - if !full_info.description.is_empty() { - profile.description = Some(full_info.description); - } - if let Some(link) = full_info.invite_link { - profile.invite_link = Some(link.invite_link); - } - } - } - ChatType::Supergroup(supergroup) => { - // Получаем информацию о супергруппе - let sg_result = - functions::get_supergroup(supergroup.supergroup_id, self.client_id).await; - if let Ok(tdlib_rs::enums::Supergroup::Supergroup(sg)) = sg_result { - profile.chat_type = if sg.is_channel { - "Канал".to_string() - } else { - "Супергруппа".to_string() - }; - profile.is_group = !sg.is_channel; - profile.member_count = Some(sg.member_count); - - // Username - if let Some(usernames) = sg.usernames { - if let Some(username) = usernames.active_usernames.first() { - profile.username = Some(format!("@{}", username)); - } - } - } - - // Полная информация о супергруппе - let full_info_result = - functions::get_supergroup_full_info(supergroup.supergroup_id, self.client_id) - .await; - if let Ok(tdlib_rs::enums::SupergroupFullInfo::SupergroupFullInfo(full_info)) = - full_info_result - { - if !full_info.description.is_empty() { - profile.description = Some(full_info.description); - } - if let Some(link) = full_info.invite_link { - profile.invite_link = Some(link.invite_link); - } - } - } - ChatType::Secret(_) => { - profile.chat_type = "Секретный чат".to_string(); - } - } - - Ok(profile) - } - - /// Выйти из группы/канала - 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 load_older_messages( - &mut self, - chat_id: i64, - from_message_id: i64, - limit: i32, - ) -> Result, String> { - let result = functions::get_chat_history( - chat_id, - from_message_id, - 0, // offset - limit, - false, // only_local - self.client_id, - ) - .await; - - match result { - Ok(tdlib_rs::enums::Messages::Messages(messages)) => { - let mut result_messages: Vec = Vec::new(); - for m in messages.messages.into_iter().flatten() { - result_messages.push(self.convert_message(&m, chat_id)); - } - - // Сообщения приходят от новых к старым, переворачиваем - result_messages.reverse(); - Ok(result_messages) - } - Err(e) => Err(format!("Ошибка загрузки сообщений: {:?}", e)), - } - } - - /// Получение информации о пользователе по ID - pub async fn get_user_name(&self, user_id: i64) -> String { - match functions::get_user(user_id, self.client_id).await { - Ok(user) => { - // User is an enum, need to match it - match user { - User::User(u) => { - let first = u.first_name; - let last = u.last_name; - if last.is_empty() { - first - } else { - format!("{} {}", first, last) - } - } - } - } - Err(_) => format!("User_{}", user_id), - } - } - - /// Получение моего user_id - pub async fn get_me(&self) -> Result { - match functions::get_me(self.client_id).await { - Ok(user) => match user { - User::User(u) => Ok(u.id), - }, - Err(e) => Err(format!("Ошибка получения профиля: {:?}", e)), - } - } - - /// Отправка статуса действия в чат (typing, cancel и т.д.) - pub async fn send_chat_action(&self, chat_id: i64, action: ChatAction) { - let _ = functions::send_chat_action( - chat_id, - 0, // message_thread_id - Some(action), - self.client_id, - ) - .await; - } - - /// Отправка текстового сообщения с поддержкой Markdown и reply - pub async fn send_message( - &self, - chat_id: i64, - text: String, - reply_to_message_id: Option, - reply_info: Option, - ) -> Result { - use tdlib_rs::enums::{InputMessageContent, InputMessageReplyTo, TextParseMode}; - use tdlib_rs::types::{ - FormattedText, InputMessageReplyToMessage, InputMessageText, TextParseModeMarkdown, - }; - - // Парсим 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(_) => { - // Если парсинг не удался, отправляем как plain text - FormattedText { text: text.clone(), entities: vec![] } - } - }; - - let content = InputMessageContent::InputMessageText(InputMessageText { - text: formatted_text, - link_preview_options: None, - clear_draft: true, - }); - - // Создаём reply_to если есть message_id для ответа - // chat_id: 0 означает ответ в том же чате - 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)) => { - // Извлекаем текст и entities из отправленного сообщения - let (content, entities) = extract_message_text_static(&msg); - - Ok(MessageInfo { - id: msg.id, - sender_name: "Вы".to_string(), - is_outgoing: true, - content, - entities, - date: msg.date, - edit_date: msg.edit_date, - is_read: false, - 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: reply_info, - forward_from: None, - reactions: Vec::new(), - }) - } - Err(e) => Err(format!("Ошибка отправки сообщения: {:?}", e)), - } - } - - /// Получить доступные реакции для сообщения - pub async fn get_message_available_reactions( - &mut self, - chat_id: i64, - message_id: i64, - ) -> Result, String> { - use tdlib_rs::functions; - - let result = functions::get_message_available_reactions( - chat_id, - message_id, - 8, // row_size - количество реакций в ряду - self.client_id, - ) - .await; - - match result { - Ok(tdlib_rs::enums::AvailableReactions::AvailableReactions(reactions)) => { - // Извлекаем эмодзи из доступных реакций - // Используем top_reactions (самые популярные реакции) - let mut emojis: Vec = reactions - .top_reactions - .iter() - .filter_map(|reaction| { - if let tdlib_rs::enums::ReactionType::Emoji(e) = &reaction.r#type { - Some(e.emoji.clone()) - } else { - None - } - }) - .collect(); - - // Если top_reactions пустой, используем popular_reactions - if emojis.is_empty() { - emojis = reactions - .popular_reactions - .iter() - .filter_map(|reaction| { - if let tdlib_rs::enums::ReactionType::Emoji(e) = &reaction.r#type { - Some(e.emoji.clone()) - } else { - None - } - }) - .collect(); - } - - Ok(emojis) - } - Err(e) => Err(format!("Ошибка получения реакций: {:?}", e)), - } - } - - /// Добавить реакцию на сообщение (или убрать, если уже поставлена) - pub async fn toggle_reaction( - &mut self, - chat_id: i64, - message_id: i64, - emoji: String, - ) -> Result<(), String> { - use tdlib_rs::enums::ReactionType; - use tdlib_rs::functions; - use tdlib_rs::types::ReactionTypeEmoji; - - let reaction_type = ReactionType::Emoji(ReactionTypeEmoji { emoji }); - - let result = functions::add_message_reaction( - chat_id, - message_id, - reaction_type, - false, // is_big - обычная реакция (не "большая" анимация) - true, // update_recent_reactions - обновить список недавних реакций - self.client_id, - ) - .await; - - match result { - Ok(_) => Ok(()), - Err(e) => Err(format!("Ошибка добавления реакции: {:?}", e)), - } - } - - /// Редактирование текстового сообщения с поддержкой Markdown - /// Устанавливает черновик для чата через TDLib API - pub async fn set_draft_message(&self, chat_id: i64, text: String) -> Result<(), String> { - use tdlib_rs::enums::InputMessageContent; - use tdlib_rs::types::{DraftMessage, FormattedText, InputMessageText}; - - if text.is_empty() { - // Очищаем черновик - let result = functions::set_chat_draft_message( - chat_id, - 0, // message_thread_id - None, // draft_message (None = очистить) - self.client_id, - ) - .await; - - match result { - Ok(_) => Ok(()), - Err(e) => Err(format!("Ошибка очистки черновика: {:?}", e)), - } - } else { - // Создаём черновик - let formatted_text = FormattedText { text: text.clone(), entities: vec![] }; - - let input_message = InputMessageContent::InputMessageText(InputMessageText { - text: formatted_text, - link_preview_options: None, - clear_draft: false, - }); - - let draft = DraftMessage { - reply_to: None, - date: 0, // TDLib установит текущее время - input_message_text: input_message, - }; - - let result = functions::set_chat_draft_message( - chat_id, - 0, // message_thread_id - Some(draft), - self.client_id, - ) - .await; - - match result { - Ok(_) => Ok(()), - Err(e) => Err(format!("Ошибка установки черновика: {:?}", e)), - } - } - } - - pub async fn edit_message( - &self, - chat_id: i64, - message_id: i64, - text: String, - ) -> Result { - use tdlib_rs::enums::{InputMessageContent, TextParseMode}; - use tdlib_rs::types::{FormattedText, InputMessageText, TextParseModeMarkdown}; - - // Парсим 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(_) => { - // Если парсинг не удался, отправляем как plain text - 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)) => { - let (content, entities) = extract_message_text_static(&msg); - Ok(MessageInfo { - id: msg.id, - sender_name: "Вы".to_string(), - is_outgoing: true, - content, - entities, - date: msg.date, - edit_date: msg.edit_date, - is_read: true, - 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: None, // При редактировании reply сохраняется из оригинала - forward_from: None, // При редактировании forward сохраняется из оригинала - reactions: Vec::new(), // При редактировании реакции сохраняются из оригинала - }) - } - Err(e) => Err(format!("Ошибка редактирования сообщения: {:?}", e)), - } - } - - /// Удаление сообщений - /// revoke = true удаляет для всех, false только для себя - pub async fn delete_messages( - &self, - chat_id: i64, - message_ids: Vec, - 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, - ) -> 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 process_pending_view_messages(&mut self) { - let pending = std::mem::take(&mut self.pending_view_messages); - for (chat_id, message_ids) in pending { - let _ = functions::view_messages( - chat_id, - message_ids, - None, // source - true, // force_read - self.client_id, - ) - .await; - } - } - - /// Обработка очереди user_id для загрузки имён (lazy loading) - /// Загружает только последние 5 запросов за цикл для снижения нагрузки - pub async fn process_pending_user_ids(&mut self) { - // Берём только последние запросы (они актуальнее — от недавних сообщений) - const LAZY_LOAD_USERS_PER_TICK: usize = 5; - - // Убираем дубликаты и уже загруженные - self.pending_user_ids - .retain(|id| !self.user_names.contains_key(id)); - self.pending_user_ids.dedup(); - - // Берём последние LAZY_LOAD_USERS_PER_TICK элементов - let start = self.pending_user_ids.len().saturating_sub(LAZY_LOAD_USERS_PER_TICK); - let batch: Vec = self.pending_user_ids.drain(start..).collect(); - - for user_id in batch { - // Загружаем информацию о пользователе - if let Ok(User::User(user)) = functions::get_user(user_id, self.client_id).await { - let display_name = if user.last_name.is_empty() { - user.first_name.clone() - } else { - format!("{} {}", user.first_name, user.last_name) - }; - self.user_names.insert(user_id, display_name.clone()); - - // Обновляем имя в текущих сообщениях - for msg in &mut self.current_chat_messages { - if msg.sender_name == format!("User_{}", user_id) { - msg.sender_name = display_name.clone(); - } - } - } - } - - // Ограничиваем размер очереди (старые запросы отбрасываем) - const MAX_QUEUE_SIZE: usize = 50; - if self.pending_user_ids.len() > MAX_QUEUE_SIZE { - let excess = self.pending_user_ids.len() - MAX_QUEUE_SIZE; - self.pending_user_ids.drain(0..excess); - } - } -} - -/// Статическая функция для извлечения текста и entities сообщения (без &self) -fn extract_message_text_static(message: &TdMessage) -> (String, Vec) { - match &message.content { - MessageContent::MessageText(text) => (text.text.text.clone(), text.text.entities.clone()), - MessageContent::MessagePhoto(photo) => { - if photo.caption.text.is_empty() { - ("[Фото]".to_string(), vec![]) - } else { - // Добавляем смещение для "[Фото] " к entities - let prefix_len = "[Фото] ".chars().count() as i32; - let adjusted_entities: Vec = photo - .caption - .entities - .iter() - .map(|e| TextEntity { - offset: e.offset + prefix_len, - length: e.length, - r#type: e.r#type.clone(), - }) - .collect(); - (format!("[Фото] {}", photo.caption.text), adjusted_entities) - } - } - MessageContent::MessageVideo(video) => { - if video.caption.text.is_empty() { - ("[Видео]".to_string(), vec![]) - } else { - let prefix_len = "[Видео] ".chars().count() as i32; - let adjusted_entities: Vec = video - .caption - .entities - .iter() - .map(|e| TextEntity { - offset: e.offset + prefix_len, - length: e.length, - r#type: e.r#type.clone(), - }) - .collect(); - (format!("[Видео] {}", video.caption.text), adjusted_entities) - } - } - MessageContent::MessageDocument(doc) => { - (format!("[Файл: {}]", doc.document.file_name), vec![]) - } - MessageContent::MessageVoiceNote(_) => ("[Голосовое сообщение]".to_string(), vec![]), - MessageContent::MessageVideoNote(_) => ("[Видеосообщение]".to_string(), vec![]), - MessageContent::MessageSticker(sticker) => { - (format!("[Стикер: {}]", sticker.sticker.emoji), vec![]) - } - MessageContent::MessageAnimation(anim) => { - if anim.caption.text.is_empty() { - ("[GIF]".to_string(), vec![]) - } else { - let prefix_len = "[GIF] ".chars().count() as i32; - let adjusted_entities: Vec = anim - .caption - .entities - .iter() - .map(|e| TextEntity { - offset: e.offset + prefix_len, - length: e.length, - r#type: e.r#type.clone(), - }) - .collect(); - (format!("[GIF] {}", anim.caption.text), adjusted_entities) - } - } - MessageContent::MessageAudio(audio) => (format!("[Аудио: {}]", audio.audio.title), vec![]), - MessageContent::MessageCall(_) => ("[Звонок]".to_string(), vec![]), - MessageContent::MessagePoll(poll) => { - (format!("[Опрос: {}]", poll.poll.question.text), vec![]) - } - _ => ("[Сообщение]".to_string(), vec![]), - } -} - -/// Извлекает текст из MessageContent (для reply preview) -fn extract_content_text(content: &MessageContent) -> String { - match content { - MessageContent::MessageText(text) => text.text.text.clone(), - MessageContent::MessagePhoto(photo) => { - if photo.caption.text.is_empty() { - "[Фото]".to_string() - } else { - format!("[Фото] {}", photo.caption.text) - } - } - MessageContent::MessageVideo(video) => { - if video.caption.text.is_empty() { - "[Видео]".to_string() - } else { - format!("[Видео] {}", video.caption.text) - } - } - MessageContent::MessageDocument(doc) => format!("[Файл: {}]", doc.document.file_name), - MessageContent::MessageVoiceNote(_) => "[Голосовое]".to_string(), - MessageContent::MessageVideoNote(_) => "[Видеосообщение]".to_string(), - MessageContent::MessageSticker(sticker) => format!("[Стикер: {}]", sticker.sticker.emoji), - MessageContent::MessageAnimation(_) => "[GIF]".to_string(), - MessageContent::MessageAudio(audio) => format!("[Аудио: {}]", audio.audio.title), - MessageContent::MessageCall(_) => "[Звонок]".to_string(), - MessageContent::MessagePoll(poll) => format!("[Опрос: {}]", poll.poll.question.text), - _ => "[Сообщение]".to_string(), - } -} diff --git a/src/tdlib/client.rs.old b/src/tdlib/client.rs.old deleted file mode 100644 index 4d075f4..0000000 --- a/src/tdlib/client.rs.old +++ /dev/null @@ -1,2036 +0,0 @@ -use crate::constants::{ - LAZY_LOAD_USERS_PER_TICK, MAX_CHAT_USER_IDS, MAX_CHATS, MAX_MESSAGES_IN_CHAT, - MAX_USER_CACHE_SIZE, TDLIB_CHAT_LIMIT, TDLIB_MESSAGE_LIMIT, -}; -use std::collections::HashMap; -use std::env; -use std::time::Instant; -use tdlib_rs::enums::{ - AuthorizationState, ChatAction, ChatList, ChatType, ConnectionState, MessageContent, - MessageSender, SearchMessagesFilter, Update, User, UserStatus, -}; -use tdlib_rs::types::TextEntity; - -/// Простой LRU-кэш на основе HashMap + Vec для отслеживания порядка -pub struct LruCache { - map: HashMap, - /// Порядок доступа: последний элемент — самый недавно использованный - order: Vec, - capacity: usize, -} - -impl LruCache { - 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() - } -} -use tdlib_rs::functions; -use tdlib_rs::types::{Chat as TdChat, Message as TdMessage}; - -#[derive(Debug, Clone, PartialEq)] -#[allow(dead_code)] -pub enum AuthState { - WaitTdlibParameters, - WaitPhoneNumber, - WaitCode, - WaitPassword, - Ready, - Closed, - Error(String), -} - -#[derive(Debug, Clone)] -#[allow(dead_code)] -pub struct ChatInfo { - pub id: i64, - pub title: String, - pub username: Option, - 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, - /// Чат замьючен (уведомления отключены) - pub is_muted: bool, - /// Черновик сообщения - pub draft_text: Option, -} - -/// Информация о сообщении, на которое отвечают -#[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, - 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, - /// Информация о forward (если сообщение переслано) - pub forward_from: Option, - /// Реакции на сообщение - pub reactions: Vec, -} - -#[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, - pub bio: Option, - pub phone_number: Option, - pub chat_type: String, // "Личный чат", "Группа", "Канал" - pub member_count: Option, - pub description: Option, - pub invite_link: Option, - pub is_group: bool, - pub online_status: Option, -} - -/// Состояние сетевого соединения -#[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), -} - -pub struct TdClient { - pub auth_state: AuthState, - pub api_id: i32, - pub api_hash: String, - client_id: i32, - pub chats: Vec, - pub current_chat_messages: Vec, - /// ID текущего открытого чата (для получения новых сообщений) - pub current_chat_id: Option, - /// LRU-кэш usernames: user_id -> username - user_usernames: LruCache, - /// LRU-кэш имён: user_id -> display_name (first_name + last_name) - user_names: LruCache, - /// Связь chat_id -> user_id для приватных чатов - chat_user_ids: HashMap, - /// Очередь сообщений для отметки как прочитанных: (chat_id, message_ids) - pub pending_view_messages: Vec<(i64, Vec)>, - /// Очередь user_id для загрузки имён - pub pending_user_ids: Vec, - /// Папки чатов - pub folders: Vec, - /// Позиция основного списка среди папок - pub main_chat_list_position: i32, - /// LRU-кэш онлайн-статусов пользователей: user_id -> status - user_statuses: LruCache, - /// Состояние сетевого соединения - pub network_state: NetworkState, - /// Typing status для текущего чата: (user_id, action_text, timestamp) - pub typing_status: Option<(i64, String, Instant)>, - /// Последнее закреплённое сообщение текущего чата - pub current_pinned_message: Option, -} - -#[allow(dead_code)] -impl TdClient { - pub fn new() -> Self { - // Загружаем credentials из ~/.config/tele-tui/credentials или .env - let (api_id, api_hash) = match crate::config::Config::load_credentials() { - Ok(creds) => creds, - Err(err_msg) => { - eprintln!("\n{}\n", err_msg); - // Используем дефолтные значения, чтобы приложение запустилось - // Пользователь увидит сообщение об ошибке в UI - (0, String::new()) - } - }; - - let client_id = tdlib_rs::create_client(); - - TdClient { - auth_state: AuthState::WaitTdlibParameters, - api_id, - api_hash, - client_id, - chats: Vec::new(), - current_chat_messages: Vec::new(), - current_chat_id: None, - user_usernames: LruCache::new(MAX_USER_CACHE_SIZE), - user_names: LruCache::new(MAX_USER_CACHE_SIZE), - chat_user_ids: HashMap::new(), - pending_view_messages: Vec::new(), - pending_user_ids: Vec::new(), - folders: Vec::new(), - main_chat_list_position: 0, - user_statuses: LruCache::new(MAX_USER_CACHE_SIZE), - network_state: NetworkState::Connecting, - typing_status: None, - current_pinned_message: None, - } - } - - pub fn is_authenticated(&self) -> bool { - matches!(self.auth_state, AuthState::Ready) - } - - pub fn client_id(&self) -> i32 { - self.client_id - } - - /// Добавляет сообщение в текущий чат с соблюдением лимита - /// Если сообщение с таким id уже есть — заменяет его (сохраняя reply_to) - pub fn push_message(&mut self, msg: MessageInfo) { - // Проверяем, есть ли уже сообщение с таким id - if let Some(idx) = self - .current_chat_messages - .iter() - .position(|m| m.id == msg.id) - { - // Если новое сообщение имеет reply_to, или старое не имеет — заменяем - if msg.reply_to.is_some() || self.current_chat_messages[idx].reply_to.is_none() { - self.current_chat_messages[idx] = msg; - } - return; - } - - self.current_chat_messages.push(msg); - // Ограничиваем количество сообщений (удаляем старые) - if self.current_chat_messages.len() > MAX_MESSAGES_IN_CHAT { - self.current_chat_messages.remove(0); - } - } - - /// Получение онлайн-статуса пользователя по chat_id (для приватных чатов) - /// Использует peek для read-only доступа (не обновляет LRU порядок) - pub fn get_user_status_by_chat_id(&self, chat_id: i64) -> Option<&UserOnlineStatus> { - self.chat_user_ids - .get(&chat_id) - .and_then(|user_id| self.user_statuses.peek(user_id)) - } - - /// Очищает typing status если прошло более 6 секунд - /// Возвращает true если статус был очищен (нужна перерисовка) - pub fn clear_stale_typing_status(&mut self) -> bool { - if let Some((_, _, timestamp)) = &self.typing_status { - if timestamp.elapsed().as_secs() > 6 { - self.typing_status = None; - return true; - } - } - false - } - - /// Возвращает текст typing status с именем пользователя - /// Например: "Вася печатает..." - pub fn get_typing_text(&self) -> Option { - self.typing_status.as_ref().map(|(user_id, action, _)| { - let name = self - .user_names - .peek(user_id) - .cloned() - .unwrap_or_else(|| "Кто-то".to_string()); - format!("{} {}", name, action) - }) - } - - /// Инициализация TDLib с параметрами - pub async fn init(&mut self) -> Result<(), String> { - let result = functions::set_tdlib_parameters( - false, // use_test_dc - "tdlib_data".to_string(), // database_directory - "".to_string(), // files_directory - "".to_string(), // database_encryption_key - true, // use_file_database - true, // use_chat_info_database - true, // use_message_database - false, // use_secret_chats - self.api_id, // api_id - self.api_hash.clone(), // api_hash - "en".to_string(), // system_language_code - "Desktop".to_string(), // device_model - "".to_string(), // system_version - env!("CARGO_PKG_VERSION").to_string(), // application_version - self.client_id, - ) - .await; - - match result { - Ok(_) => Ok(()), - Err(e) => Err(format!("Failed to set TDLib parameters: {:?}", e)), - } - } - - /// Обрабатываем одно обновление от TDLib - pub fn handle_update(&mut self, update: Update) { - match update { - Update::AuthorizationState(state) => { - self.handle_auth_state(state.authorization_state); - } - Update::NewChat(new_chat) => { - self.add_or_update_chat(&new_chat.chat); - } - Update::ChatLastMessage(update) => { - let chat_id = update.chat_id; - let (last_message_text, last_message_date) = update - .last_message - .as_ref() - .map(|msg| (extract_message_text_static(msg).0, msg.date)) - .unwrap_or_default(); - - if let Some(chat) = self.chats.iter_mut().find(|c| c.id == chat_id) { - chat.last_message = last_message_text; - chat.last_message_date = last_message_date; - } - - // Обновляем позиции если они пришли - for pos in &update.positions { - if matches!(pos.list, ChatList::Main) { - if let Some(chat) = self.chats.iter_mut().find(|c| c.id == chat_id) { - chat.order = pos.order; - chat.is_pinned = pos.is_pinned; - } - } - } - - // Пересортируем по order - self.chats.sort_by(|a, b| b.order.cmp(&a.order)); - } - Update::ChatReadInbox(update) => { - if let Some(chat) = self.chats.iter_mut().find(|c| c.id == update.chat_id) { - chat.unread_count = update.unread_count; - } - } - Update::ChatUnreadMentionCount(update) => { - if let Some(chat) = self.chats.iter_mut().find(|c| c.id == update.chat_id) { - chat.unread_mention_count = update.unread_mention_count; - } - } - Update::ChatNotificationSettings(update) => { - if let Some(chat) = self.chats.iter_mut().find(|c| c.id == update.chat_id) { - // mute_for > 0 означает что чат замьючен - chat.is_muted = update.notification_settings.mute_for > 0; - } - } - Update::ChatReadOutbox(update) => { - // Обновляем last_read_outbox_message_id когда собеседник прочитал сообщения - if let Some(chat) = self.chats.iter_mut().find(|c| c.id == update.chat_id) { - chat.last_read_outbox_message_id = update.last_read_outbox_message_id; - } - // Если это текущий открытый чат — обновляем is_read у сообщений - if Some(update.chat_id) == self.current_chat_id { - for msg in &mut self.current_chat_messages { - if msg.is_outgoing && msg.id <= update.last_read_outbox_message_id { - msg.is_read = true; - } - } - } - } - Update::ChatPosition(update) => { - // Обновляем позицию чата или удаляем его из списка - match &update.position.list { - ChatList::Main => { - if update.position.order == 0 { - // Чат больше не в Main (перемещён в архив и т.д.) - self.chats.retain(|c| c.id != update.chat_id); - } else if let Some(chat) = - self.chats.iter_mut().find(|c| c.id == update.chat_id) - { - // Обновляем позицию существующего чата - chat.order = update.position.order; - chat.is_pinned = update.position.is_pinned; - } - // Пересортируем по order - self.chats.sort_by(|a, b| b.order.cmp(&a.order)); - } - ChatList::Folder(folder) => { - // Обновляем folder_ids для чата - if let Some(chat) = self.chats.iter_mut().find(|c| c.id == update.chat_id) { - 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::NewMessage(new_msg) => { - // Добавляем новое сообщение если это текущий открытый чат - let chat_id = new_msg.message.chat_id; - if Some(chat_id) == self.current_chat_id { - let msg_info = self.convert_message(&new_msg.message, chat_id); - let msg_id = msg_info.id; - let is_incoming = !msg_info.is_outgoing; - - // Проверяем, есть ли уже сообщение с таким id - let existing_idx = self - .current_chat_messages - .iter() - .position(|m| m.id == msg_info.id); - - match existing_idx { - Some(idx) => { - // Сообщение уже есть - обновляем - if is_incoming { - self.current_chat_messages[idx] = msg_info; - } else { - // Для исходящих: обновляем can_be_edited и другие поля, - // но сохраняем reply_to (добавленный при отправке) - let existing = &mut self.current_chat_messages[idx]; - existing.can_be_edited = msg_info.can_be_edited; - existing.can_be_deleted_only_for_self = - msg_info.can_be_deleted_only_for_self; - existing.can_be_deleted_for_all_users = - msg_info.can_be_deleted_for_all_users; - existing.is_read = msg_info.is_read; - } - } - None => { - // Нового сообщения нет - добавляем - self.push_message(msg_info); - // Если это входящее сообщение — добавляем в очередь для отметки как прочитанное - if is_incoming { - self.pending_view_messages.push((chat_id, vec![msg_id])); - } - } - } - } - } - Update::User(update) => { - // Сохраняем имя и username пользователя - let user = update.user; - - // Пропускаем удалённые аккаунты (пустое имя) - if user.first_name.is_empty() && user.last_name.is_empty() { - // Удаляем чаты с этим пользователем из списка - let user_id = user.id; - self.chats - .retain(|c| self.chat_user_ids.get(&c.id) != Some(&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) - }; - self.user_names.insert(user.id, display_name); - - // Сохраняем username если есть - if let Some(usernames) = user.usernames { - if let Some(username) = usernames.active_usernames.first() { - self.user_usernames.insert(user.id, username.clone()); - // Обновляем username в чатах, связанных с этим пользователем - for (&chat_id, &user_id) in &self.chat_user_ids.clone() { - if user_id == user.id { - if let Some(chat) = self.chats.iter_mut().find(|c| c.id == chat_id) - { - chat.username = Some(format!("@{}", username)); - } - } - } - } - } - // LRU-кэш автоматически удаляет старые записи при вставке - } - Update::ChatFolders(update) => { - // Обновляем список папок - self.folders = update - .chat_folders - .into_iter() - .map(|f| FolderInfo { id: f.id, name: f.title }) - .collect(); - self.main_chat_list_position = update.main_chat_list_position; - } - Update::UserStatus(update) => { - // Обновляем онлайн-статус пользователя - let status = match update.status { - UserStatus::Online(_) => UserOnlineStatus::Online, - UserStatus::Offline(offline) => UserOnlineStatus::Offline(offline.was_online), - UserStatus::Recently(_) => UserOnlineStatus::Recently, - UserStatus::LastWeek(_) => UserOnlineStatus::LastWeek, - UserStatus::LastMonth(_) => UserOnlineStatus::LastMonth, - UserStatus::Empty => UserOnlineStatus::LongTimeAgo, - }; - self.user_statuses.insert(update.user_id, status); - } - Update::ConnectionState(update) => { - // Обновляем состояние сетевого соединения - self.network_state = match update.state { - ConnectionState::WaitingForNetwork => NetworkState::WaitingForNetwork, - ConnectionState::ConnectingToProxy => NetworkState::ConnectingToProxy, - ConnectionState::Connecting => NetworkState::Connecting, - ConnectionState::Updating => NetworkState::Updating, - ConnectionState::Ready => NetworkState::Ready, - }; - } - Update::ChatAction(update) => { - // Обрабатываем только для текущего открытого чата - if Some(update.chat_id) == self.current_chat_id { - // Извлекаем user_id из sender_id - let user_id = match update.sender_id { - MessageSender::User(user) => Some(user.user_id), - MessageSender::Chat(_) => None, // Игнорируем действия от имени чата - }; - - if let Some(user_id) = 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, // Отмена — сбрасываем статус - _ => None, - }; - - if let Some(text) = action_text { - self.typing_status = Some((user_id, text, Instant::now())); - } else { - // Cancel или неизвестное действие — сбрасываем - self.typing_status = None; - } - } - } - } - Update::ChatDraftMessage(update) => { - // Обновляем черновик в списке чатов - if let Some(chat) = self.chats.iter_mut().find(|c| c.id == update.chat_id) { - chat.draft_text = update.draft_message.as_ref().and_then(|draft| { - // Извлекаем текст из InputMessageText - if let tdlib_rs::enums::InputMessageContent::InputMessageText(text_msg) = - &draft.input_message_text - { - Some(text_msg.text.text.clone()) - } else { - None - } - }); - } - } - Update::MessageInteractionInfo(update) => { - // Обновляем реакции в текущем открытом чате - if Some(update.chat_id) == self.current_chat_id { - if let Some(msg) = self - .current_chat_messages - .iter_mut() - .find(|m| m.id == update.message_id) - { - // Извлекаем реакции из interaction_info - msg.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(); - } - } - } - _ => {} - } - } - - fn handle_auth_state(&mut self, state: AuthorizationState) { - self.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, - _ => self.auth_state.clone(), - }; - } - - fn add_or_update_chat(&mut self, td_chat: &TdChat) { - // Пропускаем удалённые аккаунты - if td_chat.title == "Deleted Account" || td_chat.title.is_empty() { - // Удаляем из списка если уже был добавлен - self.chats.retain(|c| c.id != td_chat.id); - return; - } - - // Ищем позицию в Main списке (если есть) - let main_position = td_chat - .positions - .iter() - .find(|pos| matches!(pos.list, ChatList::Main)); - - // Получаем order и is_pinned из позиции, или используем значения по умолчанию - let (order, is_pinned) = main_position - .map(|p| (p.order, p.is_pinned)) - .unwrap_or((1, false)); // order=1 чтобы чат отображался - - let (last_message, last_message_date) = td_chat - .last_message - .as_ref() - .map(|m| (extract_message_text_static(m).0, m.date)) - .unwrap_or_default(); - - // Извлекаем user_id для приватных чатов и сохраняем связь - let username = match &td_chat.r#type { - ChatType::Private(private) => { - // Ограничиваем размер chat_user_ids - if self.chat_user_ids.len() >= MAX_CHAT_USER_IDS - && !self.chat_user_ids.contains_key(&td_chat.id) - { - // Удаляем случайную запись (первую найденную) - if let Some(&key) = self.chat_user_ids.keys().next() { - self.chat_user_ids.remove(&key); - } - } - self.chat_user_ids.insert(td_chat.id, private.user_id); - // Проверяем, есть ли уже username в кэше (peek не обновляет LRU) - self.user_usernames - .peek(&private.user_id) - .map(|u| format!("@{}", u)) - } - _ => None, - }; - - // Извлекаем ID папок из позиций - let folder_ids: Vec = td_chat - .positions - .iter() - .filter_map(|pos| { - if let ChatList::Folder(folder) = &pos.list { - Some(folder.chat_folder_id) - } else { - None - } - }) - .collect(); - - // Проверяем mute статус - let is_muted = td_chat.notification_settings.mute_for > 0; - - let chat_info = ChatInfo { - id: td_chat.id, - title: td_chat.title.clone(), - username, - last_message, - last_message_date, - unread_count: td_chat.unread_count, - unread_mention_count: td_chat.unread_mention_count, - is_pinned, - order, - last_read_outbox_message_id: td_chat.last_read_outbox_message_id, - folder_ids, - is_muted, - draft_text: None, - }; - - if let Some(existing) = self.chats.iter_mut().find(|c| c.id == td_chat.id) { - existing.title = chat_info.title; - existing.last_message = chat_info.last_message; - existing.last_message_date = chat_info.last_message_date; - existing.unread_count = chat_info.unread_count; - existing.unread_mention_count = chat_info.unread_mention_count; - existing.last_read_outbox_message_id = chat_info.last_read_outbox_message_id; - existing.folder_ids = chat_info.folder_ids; - existing.is_muted = chat_info.is_muted; - // Обновляем username если он появился - if chat_info.username.is_some() { - existing.username = chat_info.username; - } - // Обновляем позицию только если она пришла - if main_position.is_some() { - existing.is_pinned = chat_info.is_pinned; - existing.order = chat_info.order; - } - } else { - self.chats.push(chat_info); - // Ограничиваем количество чатов - if self.chats.len() > MAX_CHATS { - // Удаляем чат с наименьшим order (наименее активный) - if let Some(min_idx) = self - .chats - .iter() - .enumerate() - .min_by_key(|(_, c)| c.order) - .map(|(i, _)| i) - { - self.chats.remove(min_idx); - } - } - } - - // Сортируем чаты по order (TDLib order учитывает pinned и время) - self.chats.sort_by(|a, b| b.order.cmp(&a.order)); - } - - fn convert_message(&mut self, message: &TdMessage, chat_id: i64) -> MessageInfo { - let sender_name = match &message.sender_id { - tdlib_rs::enums::MessageSender::User(user) => { - // Пробуем получить имя из кеша (get обновляет LRU порядок) - if let Some(name) = self.user_names.get(&user.user_id).cloned() { - name - } else { - // Добавляем в очередь для загрузки - if !self.pending_user_ids.contains(&user.user_id) { - self.pending_user_ids.push(user.user_id); - } - format!("User_{}", user.user_id) - } - } - tdlib_rs::enums::MessageSender::Chat(chat) => { - // Для чатов используем название чата - self.chats - .iter() - .find(|c| c.id == chat.chat_id) - .map(|c| c.title.clone()) - .unwrap_or_else(|| format!("Chat_{}", chat.chat_id)) - } - }; - - // Определяем, прочитано ли исходящее сообщение - let is_read = if message.is_outgoing { - // Сообщение прочитано, если его ID <= last_read_outbox_message_id чата - self.chats - .iter() - .find(|c| c.id == chat_id) - .map(|c| message.id <= c.last_read_outbox_message_id) - .unwrap_or(false) - } else { - true // Входящие сообщения не показывают галочки - }; - - let (content, entities) = extract_message_text_static(message); - - // Извлекаем информацию о reply - let reply_to = self.extract_reply_info(message); - - // Извлекаем информацию о forward - let forward_from = self.extract_forward_info(message); - - // Извлекаем реакции - let reactions = self.extract_reactions(message); - - MessageInfo { - id: message.id, - sender_name, - is_outgoing: message.is_outgoing, - content, - entities, - date: message.date, - edit_date: message.edit_date, - is_read, - can_be_edited: message.can_be_edited, - can_be_deleted_only_for_self: message.can_be_deleted_only_for_self, - can_be_deleted_for_all_users: message.can_be_deleted_for_all_users, - reply_to, - forward_from, - reactions, - } - } - - /// Извлекает информацию о reply из сообщения - fn extract_reply_info(&self, message: &TdMessage) -> Option { - use tdlib_rs::enums::MessageReplyTo; - - match &message.reply_to { - Some(MessageReplyTo::Message(reply)) => { - // Получаем имя отправителя из origin или ищем сообщение в текущем списке - let sender_name = if let Some(origin) = &reply.origin { - self.get_origin_sender_name(origin) - } else { - // Пробуем найти оригинальное сообщение в текущем списке - self.current_chat_messages - .iter() - .find(|m| m.id == reply.message_id) - .map(|m| m.sender_name.clone()) - .unwrap_or_else(|| "...".to_string()) - }; - - // Получаем текст из content или quote - let text = if let Some(quote) = &reply.quote { - quote.text.text.clone() - } else if let Some(content) = &reply.content { - extract_content_text(content) - } else { - // Пробуем найти в текущих сообщениях - self.current_chat_messages - .iter() - .find(|m| m.id == reply.message_id) - .map(|m| m.content.clone()) - .unwrap_or_default() - }; - - Some(ReplyInfo { message_id: reply.message_id, sender_name, text }) - } - _ => None, - } - } - - /// Извлекает информацию о forward из сообщения - fn extract_forward_info(&self, message: &TdMessage) -> Option { - message.forward_info.as_ref().map(|info| { - let sender_name = self.get_origin_sender_name(&info.origin); - ForwardInfo { sender_name, date: info.date } - }) - } - - /// Извлекает информацию о реакциях из сообщения - fn extract_reactions(&self, message: &TdMessage) -> Vec { - message - .interaction_info - .as_ref() - .and_then(|info| info.reactions.as_ref()) - .map(|reactions| { - reactions - .reactions - .iter() - .filter_map(|reaction| { - // Извлекаем эмодзи из ReactionType - let emoji = match &reaction.r#type { - tdlib_rs::enums::ReactionType::Emoji(e) => e.emoji.clone(), - tdlib_rs::enums::ReactionType::CustomEmoji(_) => return None, // Пока игнорируем custom emoji - }; - - Some(ReactionInfo { - emoji, - count: reaction.total_count, - is_chosen: reaction.is_chosen, - }) - }) - .collect() - }) - .unwrap_or_default() - } - - /// Получает имя отправителя из MessageOrigin - fn get_origin_sender_name(&self, origin: &tdlib_rs::enums::MessageOrigin) -> String { - use tdlib_rs::enums::MessageOrigin; - match origin { - MessageOrigin::User(u) => self - .user_names - .peek(&u.sender_user_id) - .cloned() - .unwrap_or_else(|| format!("User_{}", u.sender_user_id)), - MessageOrigin::Chat(c) => self - .chats - .iter() - .find(|chat| chat.id == c.sender_chat_id) - .map(|chat| chat.title.clone()) - .unwrap_or_else(|| "Чат".to_string()), - MessageOrigin::HiddenUser(h) => h.sender_name.clone(), - MessageOrigin::Channel(c) => self - .chats - .iter() - .find(|chat| chat.id == c.chat_id) - .map(|chat| chat.title.clone()) - .unwrap_or_else(|| "Канал".to_string()), - } - } - - /// Обновляет reply info для сообщений, где данные не были загружены - /// Вызывается после загрузки истории, когда все сообщения уже в списке - fn update_reply_info_from_loaded_messages(&mut self) { - // Собираем данные для обновления (id -> (sender_name, content)) - let msg_data: std::collections::HashMap = self - .current_chat_messages - .iter() - .map(|m| (m.id, (m.sender_name.clone(), m.content.clone()))) - .collect(); - - // Обновляем reply_to для сообщений с неполными данными - for msg in &mut self.current_chat_messages { - if let Some(ref mut reply) = msg.reply_to { - // Если sender_name = "..." или text пустой — пробуем заполнить - if reply.sender_name == "..." || reply.text.is_empty() { - if let Some((sender, content)) = msg_data.get(&reply.message_id) { - if reply.sender_name == "..." { - reply.sender_name = sender.clone(); - } - if reply.text.is_empty() { - reply.text = content.clone(); - } - } - } - } - } - } - - /// Асинхронно обновляет reply info, загружая недостающие сообщения - pub async fn fetch_missing_reply_info(&mut self) { - let chat_id = match self.current_chat_id { - Some(id) => id, - None => return, - }; - - // Собираем message_id для которых нужно загрузить данные - let missing_ids: Vec = self - .current_chat_messages - .iter() - .filter_map(|msg| { - msg.reply_to.as_ref().and_then(|reply| { - if reply.sender_name == "..." || reply.text.is_empty() { - Some(reply.message_id) - } else { - None - } - }) - }) - .collect(); - - if missing_ids.is_empty() { - return; - } - - // Загружаем каждое сообщение и кэшируем данные - let mut reply_cache: std::collections::HashMap = - std::collections::HashMap::new(); - - for msg_id in missing_ids { - if reply_cache.contains_key(&msg_id) { - continue; - } - - if let Ok(tdlib_rs::enums::Message::Message(msg)) = - functions::get_message(chat_id, msg_id, self.client_id).await - { - let sender_name = match &msg.sender_id { - tdlib_rs::enums::MessageSender::User(user) => self - .user_names - .get(&user.user_id) - .cloned() - .unwrap_or_else(|| format!("User_{}", user.user_id)), - tdlib_rs::enums::MessageSender::Chat(chat) => self - .chats - .iter() - .find(|c| c.id == chat.chat_id) - .map(|c| c.title.clone()) - .unwrap_or_else(|| "Чат".to_string()), - }; - let (content, _) = extract_message_text_static(&msg); - reply_cache.insert(msg_id, (sender_name, content)); - } - } - - // Применяем загруженные данные - for msg in &mut self.current_chat_messages { - if let Some(ref mut reply) = msg.reply_to { - if let Some((sender, content)) = reply_cache.get(&reply.message_id) { - if reply.sender_name == "..." { - reply.sender_name = sender.clone(); - } - if reply.text.is_empty() { - reply.text = content.clone(); - } - } - } - } - } - - /// Отправка номера телефона - pub async fn send_phone_number(&mut self, phone: String) -> Result<(), String> { - let result = functions::set_authentication_phone_number(phone, None, self.client_id).await; - - match result { - Ok(_) => Ok(()), - Err(e) => Err(format!("Ошибка отправки номера: {:?}", e)), - } - } - - /// Отправка кода подтверждения - pub async fn send_code(&mut self, code: String) -> Result<(), String> { - let result = functions::check_authentication_code(code, self.client_id).await; - - match result { - Ok(_) => Ok(()), - Err(e) => Err(format!("Неверный код: {:?}", e)), - } - } - - /// Отправка пароля 2FA - pub async fn send_password(&mut self, password: String) -> Result<(), String> { - let result = functions::check_authentication_password(password, self.client_id).await; - - match result { - Ok(_) => Ok(()), - Err(e) => Err(format!("Неверный пароль: {:?}", e)), - } - } - - /// Загрузка списка чатов - 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 get_chat_history( - &mut self, - chat_id: i64, - limit: i32, - ) -> Result, String> { - // Устанавливаем текущий чат для получения новых сообщений - self.current_chat_id = Some(chat_id); - let _ = functions::open_chat(chat_id, self.client_id).await; - - // Пробуем загрузить несколько раз, так как сообщения могут подгружаться с сервера - let mut all_messages: Vec = Vec::new(); - let mut from_message_id: i64 = 0; - let mut attempts = 0; - const MAX_ATTEMPTS: i32 = 3; - - while attempts < MAX_ATTEMPTS { - let result = functions::get_chat_history( - chat_id, - from_message_id, - 0, // offset - limit, - false, // only_local - загружаем с сервера! - self.client_id, - ) - .await; - - match result { - Ok(tdlib_rs::enums::Messages::Messages(messages)) => { - let mut batch: Vec = Vec::new(); - for m in messages.messages.into_iter().flatten() { - batch.push(self.convert_message(&m, chat_id)); - } - - if batch.is_empty() { - break; - } - - // Запоминаем ID самого старого сообщения для следующей загрузки - if let Some(oldest) = batch.last() { - from_message_id = oldest.id; - } - - // Добавляем сообщения (они приходят от новых к старым) - all_messages.extend(batch); - attempts += 1; - - // Если получили достаточно сообщений, выходим - if all_messages.len() >= limit as usize { - break; - } - } - Err(e) => { - if all_messages.is_empty() { - return Err(format!("Ошибка загрузки сообщений: {:?}", e)); - } - break; - } - } - } - - // Сообщения приходят от новых к старым, переворачиваем - all_messages.reverse(); - self.current_chat_messages = all_messages.clone(); - - // Обновляем reply info для сообщений где данные не были загружены - self.update_reply_info_from_loaded_messages(); - - // Отмечаем сообщения как прочитанные - if !all_messages.is_empty() { - let message_ids: Vec = all_messages.iter().map(|m| m.id).collect(); - let _ = functions::view_messages( - chat_id, - message_ids, - None, // source - true, // force_read - self.client_id, - ) - .await; - } - - Ok(all_messages) - } - - /// Загрузка закреплённых сообщений чата - pub async fn get_pinned_messages(&mut self, chat_id: i64) -> Result, String> { - let result = functions::search_chat_messages( - chat_id, - "".to_string(), // query - None, // sender_id - 0, // from_message_id - 0, // offset - 100, // limit - Some(SearchMessagesFilter::Pinned), // filter - 0, // message_thread_id - 0, // saved_messages_topic_id - self.client_id, - ) - .await; - - match result { - Ok(tdlib_rs::enums::FoundChatMessages::FoundChatMessages(found)) => { - let mut messages: Vec = Vec::new(); - for m in found.messages { - messages.push(self.convert_message(&m, chat_id)); - } - // Сообщения приходят от новых к старым, оставляем как есть - Ok(messages) - } - Err(e) => Err(format!("Ошибка загрузки закреплённых: {:?}", e)), - } - } - - /// Загружает последнее закреплённое сообщение для текущего чата - pub async fn load_current_pinned_message(&mut self, chat_id: i64) { - let result = functions::search_chat_messages( - chat_id, - "".to_string(), - None, - 0, - 0, - 1, // Только одно сообщение - Some(SearchMessagesFilter::Pinned), - 0, - 0, - self.client_id, - ) - .await; - - match result { - Ok(tdlib_rs::enums::FoundChatMessages::FoundChatMessages(found)) => { - if let Some(m) = found.messages.first() { - self.current_pinned_message = Some(self.convert_message(m, chat_id)); - } else { - self.current_pinned_message = None; - } - } - Err(_) => { - self.current_pinned_message = None; - } - } - } - - /// Поиск сообщений в чате по тексту - pub async fn search_messages( - &mut self, - chat_id: i64, - query: &str, - ) -> Result, String> { - if query.trim().is_empty() { - return Ok(Vec::new()); - } - - let result = functions::search_chat_messages( - chat_id, - query.to_string(), - None, // sender_id - 0, // from_message_id - 0, // offset - TDLIB_MESSAGE_LIMIT, // limit - None, // filter (no filter = search by text) - 0, // message_thread_id - 0, // saved_messages_topic_id - self.client_id, - ) - .await; - - match result { - Ok(tdlib_rs::enums::FoundChatMessages::FoundChatMessages(found)) => { - let mut messages: Vec = Vec::new(); - for m in found.messages { - messages.push(self.convert_message(&m, chat_id)); - } - Ok(messages) - } - Err(e) => Err(format!("Ошибка поиска: {:?}", e)), - } - } - - /// Получение полной информации о чате для профиля - pub async fn get_profile_info(&self, chat_id: i64) -> Result { - use tdlib_rs::enums::ChatType; - - // Получаем основную информацию о чате - let chat_result = functions::get_chat(chat_id, self.client_id).await; - let chat = match chat_result { - Ok(tdlib_rs::enums::Chat::Chat(c)) => c, - Err(e) => return Err(format!("Ошибка загрузки чата: {:?}", e)), - }; - - let mut profile = ProfileInfo { - chat_id, - title: chat.title.clone(), - username: None, - bio: None, - phone_number: None, - chat_type: String::new(), - member_count: None, - description: None, - invite_link: None, - is_group: false, - online_status: None, - }; - - match &chat.r#type { - ChatType::Private(private_chat) => { - profile.chat_type = "Личный чат".to_string(); - profile.is_group = false; - - // Получаем полную информацию о пользователе - let user_result = functions::get_user(private_chat.user_id, self.client_id).await; - if let Ok(tdlib_rs::enums::User::User(user)) = user_result { - // Username - if let Some(usernames) = user.usernames { - if let Some(username) = usernames.active_usernames.first() { - profile.username = Some(format!("@{}", username)); - } - } - - // Phone number - if !user.phone_number.is_empty() { - profile.phone_number = Some(format!("+{}", user.phone_number)); - } - - // Online status - profile.online_status = Some(match user.status { - tdlib_rs::enums::UserStatus::Online(_) => "Онлайн".to_string(), - tdlib_rs::enums::UserStatus::Recently(_) => "Был(а) недавно".to_string(), - tdlib_rs::enums::UserStatus::LastWeek(_) => { - "Был(а) на этой неделе".to_string() - } - tdlib_rs::enums::UserStatus::LastMonth(_) => { - "Был(а) в этом месяце".to_string() - } - tdlib_rs::enums::UserStatus::Offline(offline) => { - crate::utils::format_was_online(offline.was_online) - } - _ => "Давно не был(а)".to_string(), - }); - } - - // Bio (getUserFullInfo) - let full_info_result = - functions::get_user_full_info(private_chat.user_id, self.client_id).await; - if let Ok(tdlib_rs::enums::UserFullInfo::UserFullInfo(full_info)) = full_info_result - { - if let Some(bio_obj) = full_info.bio { - profile.bio = Some(bio_obj.text); - } - } - } - ChatType::BasicGroup(basic_group) => { - profile.chat_type = "Группа".to_string(); - profile.is_group = true; - - // Получаем информацию о группе - let group_result = - functions::get_basic_group(basic_group.basic_group_id, self.client_id).await; - if let Ok(tdlib_rs::enums::BasicGroup::BasicGroup(group)) = group_result { - profile.member_count = Some(group.member_count); - } - - // Полная информация о группе - let full_info_result = functions::get_basic_group_full_info( - basic_group.basic_group_id, - self.client_id, - ) - .await; - if let Ok(tdlib_rs::enums::BasicGroupFullInfo::BasicGroupFullInfo(full_info)) = - full_info_result - { - if !full_info.description.is_empty() { - profile.description = Some(full_info.description); - } - if let Some(link) = full_info.invite_link { - profile.invite_link = Some(link.invite_link); - } - } - } - ChatType::Supergroup(supergroup) => { - // Получаем информацию о супергруппе - let sg_result = - functions::get_supergroup(supergroup.supergroup_id, self.client_id).await; - if let Ok(tdlib_rs::enums::Supergroup::Supergroup(sg)) = sg_result { - profile.chat_type = if sg.is_channel { - "Канал".to_string() - } else { - "Супергруппа".to_string() - }; - profile.is_group = !sg.is_channel; - profile.member_count = Some(sg.member_count); - - // Username - if let Some(usernames) = sg.usernames { - if let Some(username) = usernames.active_usernames.first() { - profile.username = Some(format!("@{}", username)); - } - } - } - - // Полная информация о супергруппе - let full_info_result = - functions::get_supergroup_full_info(supergroup.supergroup_id, self.client_id) - .await; - if let Ok(tdlib_rs::enums::SupergroupFullInfo::SupergroupFullInfo(full_info)) = - full_info_result - { - if !full_info.description.is_empty() { - profile.description = Some(full_info.description); - } - if let Some(link) = full_info.invite_link { - profile.invite_link = Some(link.invite_link); - } - } - } - ChatType::Secret(_) => { - profile.chat_type = "Секретный чат".to_string(); - } - } - - Ok(profile) - } - - /// Выйти из группы/канала - 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 load_older_messages( - &mut self, - chat_id: i64, - from_message_id: i64, - limit: i32, - ) -> Result, String> { - let result = functions::get_chat_history( - chat_id, - from_message_id, - 0, // offset - limit, - false, // only_local - self.client_id, - ) - .await; - - match result { - Ok(tdlib_rs::enums::Messages::Messages(messages)) => { - let mut result_messages: Vec = Vec::new(); - for m in messages.messages.into_iter().flatten() { - result_messages.push(self.convert_message(&m, chat_id)); - } - - // Сообщения приходят от новых к старым, переворачиваем - result_messages.reverse(); - Ok(result_messages) - } - Err(e) => Err(format!("Ошибка загрузки сообщений: {:?}", e)), - } - } - - /// Получение информации о пользователе по ID - pub async fn get_user_name(&self, user_id: i64) -> String { - match functions::get_user(user_id, self.client_id).await { - Ok(user) => { - // User is an enum, need to match it - match user { - User::User(u) => { - let first = u.first_name; - let last = u.last_name; - if last.is_empty() { - first - } else { - format!("{} {}", first, last) - } - } - } - } - Err(_) => format!("User_{}", user_id), - } - } - - /// Получение моего user_id - pub async fn get_me(&self) -> Result { - match functions::get_me(self.client_id).await { - Ok(user) => match user { - User::User(u) => Ok(u.id), - }, - Err(e) => Err(format!("Ошибка получения профиля: {:?}", e)), - } - } - - /// Отправка статуса действия в чат (typing, cancel и т.д.) - pub async fn send_chat_action(&self, chat_id: i64, action: ChatAction) { - let _ = functions::send_chat_action( - chat_id, - 0, // message_thread_id - Some(action), - self.client_id, - ) - .await; - } - - /// Отправка текстового сообщения с поддержкой Markdown и reply - pub async fn send_message( - &self, - chat_id: i64, - text: String, - reply_to_message_id: Option, - reply_info: Option, - ) -> Result { - use tdlib_rs::enums::{InputMessageContent, InputMessageReplyTo, TextParseMode}; - use tdlib_rs::types::{ - FormattedText, InputMessageReplyToMessage, InputMessageText, TextParseModeMarkdown, - }; - - // Парсим 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(_) => { - // Если парсинг не удался, отправляем как plain text - FormattedText { text: text.clone(), entities: vec![] } - } - }; - - let content = InputMessageContent::InputMessageText(InputMessageText { - text: formatted_text, - link_preview_options: None, - clear_draft: true, - }); - - // Создаём reply_to если есть message_id для ответа - // chat_id: 0 означает ответ в том же чате - 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)) => { - // Извлекаем текст и entities из отправленного сообщения - let (content, entities) = extract_message_text_static(&msg); - - Ok(MessageInfo { - id: msg.id, - sender_name: "Вы".to_string(), - is_outgoing: true, - content, - entities, - date: msg.date, - edit_date: msg.edit_date, - is_read: false, - 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: reply_info, - forward_from: None, - reactions: Vec::new(), - }) - } - Err(e) => Err(format!("Ошибка отправки сообщения: {:?}", e)), - } - } - - /// Получить доступные реакции для сообщения - pub async fn get_message_available_reactions( - &mut self, - chat_id: i64, - message_id: i64, - ) -> Result, String> { - use tdlib_rs::functions; - - let result = functions::get_message_available_reactions( - chat_id, - message_id, - 8, // row_size - количество реакций в ряду - self.client_id, - ) - .await; - - match result { - Ok(tdlib_rs::enums::AvailableReactions::AvailableReactions(reactions)) => { - // Извлекаем эмодзи из доступных реакций - // Используем top_reactions (самые популярные реакции) - let mut emojis: Vec = reactions - .top_reactions - .iter() - .filter_map(|reaction| { - if let tdlib_rs::enums::ReactionType::Emoji(e) = &reaction.r#type { - Some(e.emoji.clone()) - } else { - None - } - }) - .collect(); - - // Если top_reactions пустой, используем popular_reactions - if emojis.is_empty() { - emojis = reactions - .popular_reactions - .iter() - .filter_map(|reaction| { - if let tdlib_rs::enums::ReactionType::Emoji(e) = &reaction.r#type { - Some(e.emoji.clone()) - } else { - None - } - }) - .collect(); - } - - Ok(emojis) - } - Err(e) => Err(format!("Ошибка получения реакций: {:?}", e)), - } - } - - /// Добавить реакцию на сообщение (или убрать, если уже поставлена) - pub async fn toggle_reaction( - &mut self, - chat_id: i64, - message_id: i64, - emoji: String, - ) -> Result<(), String> { - use tdlib_rs::enums::ReactionType; - use tdlib_rs::functions; - use tdlib_rs::types::ReactionTypeEmoji; - - let reaction_type = ReactionType::Emoji(ReactionTypeEmoji { emoji }); - - let result = functions::add_message_reaction( - chat_id, - message_id, - reaction_type, - false, // is_big - обычная реакция (не "большая" анимация) - true, // update_recent_reactions - обновить список недавних реакций - self.client_id, - ) - .await; - - match result { - Ok(_) => Ok(()), - Err(e) => Err(format!("Ошибка добавления реакции: {:?}", e)), - } - } - - /// Редактирование текстового сообщения с поддержкой Markdown - /// Устанавливает черновик для чата через TDLib API - pub async fn set_draft_message(&self, chat_id: i64, text: String) -> Result<(), String> { - use tdlib_rs::enums::InputMessageContent; - use tdlib_rs::types::{DraftMessage, FormattedText, InputMessageText}; - - if text.is_empty() { - // Очищаем черновик - let result = functions::set_chat_draft_message( - chat_id, - 0, // message_thread_id - None, // draft_message (None = очистить) - self.client_id, - ) - .await; - - match result { - Ok(_) => Ok(()), - Err(e) => Err(format!("Ошибка очистки черновика: {:?}", e)), - } - } else { - // Создаём черновик - let formatted_text = FormattedText { text: text.clone(), entities: vec![] }; - - let input_message = InputMessageContent::InputMessageText(InputMessageText { - text: formatted_text, - link_preview_options: None, - clear_draft: false, - }); - - let draft = DraftMessage { - reply_to: None, - date: 0, // TDLib установит текущее время - input_message_text: input_message, - }; - - let result = functions::set_chat_draft_message( - chat_id, - 0, // message_thread_id - Some(draft), - self.client_id, - ) - .await; - - match result { - Ok(_) => Ok(()), - Err(e) => Err(format!("Ошибка установки черновика: {:?}", e)), - } - } - } - - pub async fn edit_message( - &self, - chat_id: i64, - message_id: i64, - text: String, - ) -> Result { - use tdlib_rs::enums::{InputMessageContent, TextParseMode}; - use tdlib_rs::types::{FormattedText, InputMessageText, TextParseModeMarkdown}; - - // Парсим 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(_) => { - // Если парсинг не удался, отправляем как plain text - 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)) => { - let (content, entities) = extract_message_text_static(&msg); - Ok(MessageInfo { - id: msg.id, - sender_name: "Вы".to_string(), - is_outgoing: true, - content, - entities, - date: msg.date, - edit_date: msg.edit_date, - is_read: true, - 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: None, // При редактировании reply сохраняется из оригинала - forward_from: None, // При редактировании forward сохраняется из оригинала - reactions: Vec::new(), // При редактировании реакции сохраняются из оригинала - }) - } - Err(e) => Err(format!("Ошибка редактирования сообщения: {:?}", e)), - } - } - - /// Удаление сообщений - /// revoke = true удаляет для всех, false только для себя - pub async fn delete_messages( - &self, - chat_id: i64, - message_ids: Vec, - 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, - ) -> 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 process_pending_view_messages(&mut self) { - let pending = std::mem::take(&mut self.pending_view_messages); - for (chat_id, message_ids) in pending { - let _ = functions::view_messages( - chat_id, - message_ids, - None, // source - true, // force_read - self.client_id, - ) - .await; - } - } - - /// Обработка очереди user_id для загрузки имён (lazy loading) - /// Загружает только последние 5 запросов за цикл для снижения нагрузки - pub async fn process_pending_user_ids(&mut self) { - // Берём только последние запросы (они актуальнее — от недавних сообщений) - const LAZY_LOAD_USERS_PER_TICK: usize = 5; - - // Убираем дубликаты и уже загруженные - self.pending_user_ids - .retain(|id| !self.user_names.contains_key(id)); - self.pending_user_ids.dedup(); - - // Берём последние LAZY_LOAD_USERS_PER_TICK элементов - let start = self.pending_user_ids.len().saturating_sub(LAZY_LOAD_USERS_PER_TICK); - let batch: Vec = self.pending_user_ids.drain(start..).collect(); - - for user_id in batch { - // Загружаем информацию о пользователе - if let Ok(User::User(user)) = functions::get_user(user_id, self.client_id).await { - let display_name = if user.last_name.is_empty() { - user.first_name.clone() - } else { - format!("{} {}", user.first_name, user.last_name) - }; - self.user_names.insert(user_id, display_name.clone()); - - // Обновляем имя в текущих сообщениях - for msg in &mut self.current_chat_messages { - if msg.sender_name == format!("User_{}", user_id) { - msg.sender_name = display_name.clone(); - } - } - } - } - - // Ограничиваем размер очереди (старые запросы отбрасываем) - const MAX_QUEUE_SIZE: usize = 50; - if self.pending_user_ids.len() > MAX_QUEUE_SIZE { - let excess = self.pending_user_ids.len() - MAX_QUEUE_SIZE; - self.pending_user_ids.drain(0..excess); - } - } -} - -/// Статическая функция для извлечения текста и entities сообщения (без &self) -fn extract_message_text_static(message: &TdMessage) -> (String, Vec) { - match &message.content { - MessageContent::MessageText(text) => (text.text.text.clone(), text.text.entities.clone()), - MessageContent::MessagePhoto(photo) => { - if photo.caption.text.is_empty() { - ("[Фото]".to_string(), vec![]) - } else { - // Добавляем смещение для "[Фото] " к entities - let prefix_len = "[Фото] ".chars().count() as i32; - let adjusted_entities: Vec = photo - .caption - .entities - .iter() - .map(|e| TextEntity { - offset: e.offset + prefix_len, - length: e.length, - r#type: e.r#type.clone(), - }) - .collect(); - (format!("[Фото] {}", photo.caption.text), adjusted_entities) - } - } - MessageContent::MessageVideo(video) => { - if video.caption.text.is_empty() { - ("[Видео]".to_string(), vec![]) - } else { - let prefix_len = "[Видео] ".chars().count() as i32; - let adjusted_entities: Vec = video - .caption - .entities - .iter() - .map(|e| TextEntity { - offset: e.offset + prefix_len, - length: e.length, - r#type: e.r#type.clone(), - }) - .collect(); - (format!("[Видео] {}", video.caption.text), adjusted_entities) - } - } - MessageContent::MessageDocument(doc) => { - (format!("[Файл: {}]", doc.document.file_name), vec![]) - } - MessageContent::MessageVoiceNote(_) => ("[Голосовое сообщение]".to_string(), vec![]), - MessageContent::MessageVideoNote(_) => ("[Видеосообщение]".to_string(), vec![]), - MessageContent::MessageSticker(sticker) => { - (format!("[Стикер: {}]", sticker.sticker.emoji), vec![]) - } - MessageContent::MessageAnimation(anim) => { - if anim.caption.text.is_empty() { - ("[GIF]".to_string(), vec![]) - } else { - let prefix_len = "[GIF] ".chars().count() as i32; - let adjusted_entities: Vec = anim - .caption - .entities - .iter() - .map(|e| TextEntity { - offset: e.offset + prefix_len, - length: e.length, - r#type: e.r#type.clone(), - }) - .collect(); - (format!("[GIF] {}", anim.caption.text), adjusted_entities) - } - } - MessageContent::MessageAudio(audio) => (format!("[Аудио: {}]", audio.audio.title), vec![]), - MessageContent::MessageCall(_) => ("[Звонок]".to_string(), vec![]), - MessageContent::MessagePoll(poll) => { - (format!("[Опрос: {}]", poll.poll.question.text), vec![]) - } - _ => ("[Сообщение]".to_string(), vec![]), - } -} - -/// Извлекает текст из MessageContent (для reply preview) -fn extract_content_text(content: &MessageContent) -> String { - match content { - MessageContent::MessageText(text) => text.text.text.clone(), - MessageContent::MessagePhoto(photo) => { - if photo.caption.text.is_empty() { - "[Фото]".to_string() - } else { - format!("[Фото] {}", photo.caption.text) - } - } - MessageContent::MessageVideo(video) => { - if video.caption.text.is_empty() { - "[Видео]".to_string() - } else { - format!("[Видео] {}", video.caption.text) - } - } - MessageContent::MessageDocument(doc) => format!("[Файл: {}]", doc.document.file_name), - MessageContent::MessageVoiceNote(_) => "[Голосовое]".to_string(), - MessageContent::MessageVideoNote(_) => "[Видеосообщение]".to_string(), - MessageContent::MessageSticker(sticker) => format!("[Стикер: {}]", sticker.sticker.emoji), - MessageContent::MessageAnimation(_) => "[GIF]".to_string(), - MessageContent::MessageAudio(audio) => format!("[Аудио: {}]", audio.audio.title), - MessageContent::MessageCall(_) => "[Звонок]".to_string(), - MessageContent::MessagePoll(poll) => format!("[Опрос: {}]", poll.poll.question.text), - _ => "[Сообщение]".to_string(), - } -} -- 2.49.1