Compare commits
5 Commits
main
...
e1bceada6d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1bceada6d | ||
|
|
dfce86d3db | ||
|
|
7540a30e06 | ||
|
|
2edbc33afb | ||
|
|
060170923e |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1 +1,7 @@
|
|||||||
/target
|
/target
|
||||||
|
|
||||||
|
# TDLib session data (contains auth tokens - NEVER commit!)
|
||||||
|
/tdlib_data/
|
||||||
|
|
||||||
|
# Environment variables (contains API keys)
|
||||||
|
.env
|
||||||
|
|||||||
29
CLAUDE.md
Normal file
29
CLAUDE.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Telegram TUI
|
||||||
|
|
||||||
|
## Prompt
|
||||||
|
|
||||||
|
Проект - TUI интерфейс для телеграмма
|
||||||
|
|
||||||
|
Порядок чтения:
|
||||||
|
1) DEVELOPMENT.md - правило работы (обязательно)
|
||||||
|
2) CONTEXT.md - текущий статус
|
||||||
|
3) ROADMAP.md - план и задачи
|
||||||
|
4) REQUIREMENTS.md / ARCHITECTURE.md - по необходимости
|
||||||
|
5) E2E_TESTS.md - перед написанием тестов
|
||||||
|
|
||||||
|
После работы обнови CONTEXT.md файл
|
||||||
|
|
||||||
|
После прочтения скажи "Жду инструкций"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Важные файлы
|
||||||
|
|
||||||
|
- [DEVELOPMENT.md](DEVELOPMENT.md) — **читай первым!** Правила локальной разработки
|
||||||
|
- [CONTEXT.md](CONTEXT.md) — текущий статус, что сделано
|
||||||
|
- [ROADMAP.md](ROADMAP.md) — план разработки, задачи по фазам
|
||||||
|
- [REQUIREMENTS.md](REQUIREMENTS.md) — требования к продукту
|
||||||
|
- [ARCHITECTURE.md](ARCHITECTURE.md) — C4, sequence diagrams, API контракты, UI прототипы
|
||||||
|
- [E2E_TESTING.md](E2E_TESTING.md) — **читай перед написанием тестов!** Гайд по e2e тестированию
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
80
CONTEXT.md
Normal file
80
CONTEXT.md
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# Текущий контекст проекта
|
||||||
|
|
||||||
|
## Статус: Базовая интеграция с TDLib работает
|
||||||
|
|
||||||
|
### Что сделано
|
||||||
|
|
||||||
|
#### TDLib интеграция
|
||||||
|
- Подключена библиотека `tdlib-rs` v1.1 с автоматической загрузкой TDLib
|
||||||
|
- Реализована авторизация через телефон + код + 2FA пароль
|
||||||
|
- Сессия сохраняется автоматически в папке `tdlib_data/`
|
||||||
|
- Отключены логи TDLib через FFI вызов `td_execute` до создания клиента
|
||||||
|
- Updates обрабатываются в отдельном потоке через `mpsc` канал (неблокирующе)
|
||||||
|
|
||||||
|
#### Функциональность
|
||||||
|
- Загрузка списка чатов (до 50 штук)
|
||||||
|
- Отображение названия чата и счётчика непрочитанных
|
||||||
|
- Загрузка истории сообщений при открытии чата
|
||||||
|
- Отображение сообщений с именем отправителя и временем
|
||||||
|
|
||||||
|
#### Управление
|
||||||
|
- `j/k` или стрелки — навигация по списку чатов
|
||||||
|
- `д/л` — русская раскладка для j/k
|
||||||
|
- `Enter` — открыть выбранный чат
|
||||||
|
- `Esc` — закрыть открытый чат
|
||||||
|
- `Ctrl+k` — перейти к первому чату
|
||||||
|
- `Ctrl+R` — обновить список чатов
|
||||||
|
- `Ctrl+C` — выход
|
||||||
|
|
||||||
|
### Структура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── main.rs # Точка входа, UI рендеринг, event loop
|
||||||
|
├── tdlib/
|
||||||
|
│ ├── mod.rs # Модуль экспорта
|
||||||
|
│ └── client.rs # TdClient: авторизация, загрузка чатов, сообщений
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ключевые решения
|
||||||
|
|
||||||
|
1. **Неблокирующий receive**: TDLib updates приходят в отдельном потоке и передаются в main loop через `mpsc::channel`. Это позволяет UI оставаться отзывчивым.
|
||||||
|
|
||||||
|
2. **FFI для логов**: Используем прямой вызов `td_execute` для отключения логов синхронно, до создания клиента, чтобы избежать вывода в терминал.
|
||||||
|
|
||||||
|
3. **Синхронизация чатов**: Чаты загружаются асинхронно через updates. Main loop периодически синхронизирует `app.chats` с `td_client.chats`.
|
||||||
|
|
||||||
|
### Зависимости (Cargo.toml)
|
||||||
|
|
||||||
|
```toml
|
||||||
|
ratatui = "0.29"
|
||||||
|
crossterm = "0.28"
|
||||||
|
tdlib-rs = { version = "1.1", features = ["download-tdlib"] }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
dotenvy = "0.15"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Переменные окружения (.env)
|
||||||
|
|
||||||
|
```
|
||||||
|
API_ID=your_api_id
|
||||||
|
API_HASH=your_api_hash
|
||||||
|
```
|
||||||
|
|
||||||
|
## Что НЕ сделано / TODO
|
||||||
|
|
||||||
|
- [ ] Отправка сообщений
|
||||||
|
- [ ] Поиск по чатам
|
||||||
|
- [ ] Папки телеграма (сейчас только "All")
|
||||||
|
- [ ] Отображение онлайн-статуса пользователя
|
||||||
|
- [ ] Markdown форматирование в сообщениях
|
||||||
|
- [ ] Скролл истории сообщений
|
||||||
|
- [ ] Отметка сообщений как прочитанные
|
||||||
|
- [ ] Обновление чатов в реальном времени (новые сообщения)
|
||||||
|
|
||||||
|
## Известные проблемы
|
||||||
|
|
||||||
|
1. При первом запуске нужно пройти авторизацию
|
||||||
|
2. Имя отправителя показывается как "User_ID" (нужно загружать имена пользователей)
|
||||||
1887
Cargo.lock
generated
1887
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -6,10 +6,11 @@ edition = "2021"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
ratatui = "0.29"
|
ratatui = "0.29"
|
||||||
crossterm = "0.28"
|
crossterm = "0.28"
|
||||||
|
tdlib-rs = { version = "1.1", features = ["download-tdlib"] }
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
grammers-client = "0.7"
|
|
||||||
grammers-session = "0.7"
|
|
||||||
anyhow = "1.0"
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
chrono = "0.4"
|
dotenvy = "0.15"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tdlib-rs = { version = "1.1", features = ["download-tdlib"] }
|
||||||
|
|||||||
102
DEVELOPMENT.md
Normal file
102
DEVELOPMENT.md
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
# Правила локальной разработки
|
||||||
|
|
||||||
|
> **Обязательно к прочтению перед началом работы!**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Инструменты
|
||||||
|
|
||||||
|
### MCP серверы
|
||||||
|
- **Serena** — для работы с кодом (символьная навигация, редактирование)
|
||||||
|
- **Context7** — для получения актуальной документации по библиотекам
|
||||||
|
|
||||||
|
Используй эти инструменты для эффективной работы с кодовой базой.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Правила работы
|
||||||
|
|
||||||
|
### 1. Никогда не запускай сервисы самостоятельно
|
||||||
|
|
||||||
|
**ЗАПРЕЩЕНО** запускать `cargo run`, `cargo build` и подобные команды.
|
||||||
|
|
||||||
|
**Вместо этого попроси пользователя запустить:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Запусти, пожалуйста:
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Тестирование — только ручное
|
||||||
|
|
||||||
|
После завершения задачи:
|
||||||
|
1. Опиши сценарии для проверки
|
||||||
|
2. Попроси пользователя проверить вручную
|
||||||
|
3. Дождись фидбека
|
||||||
|
|
||||||
|
**Формат:**
|
||||||
|
```
|
||||||
|
Готово! Проверь, пожалуйста:
|
||||||
|
|
||||||
|
1. Открой cargo run
|
||||||
|
2. понавигируйся в списке чатов кнопками h j k l
|
||||||
|
3. Нажми Enter для открытия чата
|
||||||
|
4. Убедись, что чат прогурзился
|
||||||
|
|
||||||
|
Напиши, если что-то не работает.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Работа поэтапно
|
||||||
|
|
||||||
|
Делай работу **небольшими итерациями**:
|
||||||
|
|
||||||
|
1. **Один этап = одна логическая единица**
|
||||||
|
- Один endpoint
|
||||||
|
- Один компонент
|
||||||
|
- Одна фича
|
||||||
|
|
||||||
|
2. **После каждого этапа:**
|
||||||
|
- Сообщи что сделано
|
||||||
|
- Дай сценарий проверки
|
||||||
|
- Дождись подтверждения
|
||||||
|
|
||||||
|
3. **Не делай сразу много:**
|
||||||
|
- ❌ Весь CRUD за раз
|
||||||
|
- ✅ Сначала GET, проверили, потом POST, проверили...
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Чеклист перед началом работы
|
||||||
|
|
||||||
|
- [ ] Прочитал CONTEXT.md
|
||||||
|
- [ ] Прочитал ROADMAP.md (понял текущую фазу)
|
||||||
|
- [ ] Понял задачу
|
||||||
|
- [ ] Готов работать поэтапно
|
||||||
|
- [ ] Помню: не запускаю сервисы сам, прошу пользователя
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Пример правильного workflow
|
||||||
|
|
||||||
|
```
|
||||||
|
Пользователь: Сделай endpoint для получения списка идей
|
||||||
|
|
||||||
|
Агент:
|
||||||
|
1. Читает документацию через Context7 (NestJS, TypeORM)
|
||||||
|
2. Использует Serena для навигации по коду
|
||||||
|
3. Создаёт endpoint GET /api/ideas
|
||||||
|
4. Сообщает:
|
||||||
|
|
||||||
|
"Создал endpoint GET /api/ideas.
|
||||||
|
|
||||||
|
Запусти backend:
|
||||||
|
cd backend && npm run dev
|
||||||
|
|
||||||
|
Проверь:
|
||||||
|
curl http://localhost:4001/api/ideas
|
||||||
|
|
||||||
|
Должен вернуться пустой массив: { data: [], meta: {...} }"
|
||||||
|
|
||||||
|
5. Ждёт фидбек
|
||||||
|
6. Переходит к следующему этапу
|
||||||
|
```
|
||||||
89
REQUIREMENTS.md
Normal file
89
REQUIREMENTS.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# TTUI - Требование к приложению
|
||||||
|
|
||||||
|
## Описание приложения
|
||||||
|
|
||||||
|
Терминальный интерфейс для telegram
|
||||||
|
|
||||||
|
## Функциональные требования
|
||||||
|
|
||||||
|
### Интерфейс
|
||||||
|
|
||||||
|
┌─ TTUI ───────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 1:All │ 2:Personal │ 3:Work │ 4:Bots │
|
||||||
|
├──────────────────────┬───────────────────────────────────────────────────────┤
|
||||||
|
│ 🔍 Search... │ 👤 Mom (online) │
|
||||||
|
├──────────────────────┼───────────────────────────────────────────────────────┤
|
||||||
|
│ 📌 Saved Messages │ Today, Dec 21│
|
||||||
|
│ ▌ Mom (2)│ │
|
||||||
|
│ Boss │ Mom ────────────────────────────────────────── 14:20 │
|
||||||
|
│ Rust Community │ Привет! Ты покормил кота? │
|
||||||
|
│ Durov │ │
|
||||||
|
│ News Channel │ You ─────────────────────────────────────── 14:22 ✓✓ │
|
||||||
|
│ Spam Bot │ Да, конечно. Купил ему корм. │
|
||||||
|
│ Wife │ Скоро буду дома. │
|
||||||
|
│ Team Lead │ │
|
||||||
|
│ DevOps Chat (9)│ Mom ────────────────────────────────────────── 14:23 │
|
||||||
|
│ Server Alerts │ Отлично, захвати хлеба. │
|
||||||
|
│ Gym Bro │ │
|
||||||
|
│ Design Team │ You ─────────────────────────────────────── 14:25 ✓ │
|
||||||
|
│ Project X │ Ок. │
|
||||||
|
│ HR │ │
|
||||||
|
│ Mom's Friend │ │
|
||||||
|
│ Taxi Bot │ │
|
||||||
|
│ │ │
|
||||||
|
│ │ │
|
||||||
|
│ │ │
|
||||||
|
│ │ │
|
||||||
|
│ │ │
|
||||||
|
├──────────────────────┼───────────────────────────────────────────────────────┤
|
||||||
|
│ [User: Online] │ > **message** │
|
||||||
|
└──────────────────────┴───────────────────────────────────────────────────────┘
|
||||||
|
**commands**
|
||||||
|
|
||||||
|
|
||||||
|
### Список желаемого
|
||||||
|
1) футер - список папок в телеграме
|
||||||
|
2) список с чатами - лички и группы, сверху инпут для поиска чата
|
||||||
|
3) основной контент - открытый чат с сообщениями из чата, если никакой чат не открыт, то контент пустой, ничего не показываем. Снизу - инпут для ввода сообщения в чат, который открыт
|
||||||
|
4) снизу списка чата статус онлайн или нет сам пользователь приложения
|
||||||
|
5) при открытии чата должна загружаться история чата, а так же подгружаться новые сообщения от собеседника.
|
||||||
|
6) выделяем сообщения собеседника его никнеймом, группируем его сообщения и разделяем наши сообщения и сообщения собеседника, как на интерфейсе сверху
|
||||||
|
7) отображаем наше сообщение символом `✓`, если телеграм подтвердил, что сообщение отправлено, и выделяем `✓✓` если собеседник прочитал его
|
||||||
|
8) при навигации в чате выделяем строку курсивом, при выборе чата (то есть его открытии) ставим в начало символ ▌
|
||||||
|
9) `(2)` — счетчик непрочитанных (можно красить в красный/зеленый).
|
||||||
|
10) `muted` — статус чата (серый цвет).
|
||||||
|
11) `@` — пинг/меншн.
|
||||||
|
12) с видео/картинками/голосовые пока ничего не делаем, ренденим заглушку (с упоминанием что это картинка или видео и тд)
|
||||||
|
|
||||||
|
### Управление
|
||||||
|
1) ctrl+c или command+c - выход из программы
|
||||||
|
2) "h j k l" - влево, вниз, вверх, вправо (навигация в левом столбце) vim-style управление
|
||||||
|
3) стрелки - управление, так же как и "h j k l"
|
||||||
|
4) "command + 1", "command + 2" и так далее - переключение между папками, которые созданы в телеграме
|
||||||
|
5) из интерфейса "**message**" - это инпут для ввода сообщения в открытый чат
|
||||||
|
6) ctrl + s - фокус в инпут поиска чата
|
||||||
|
7) Esc - закрытие открытого чата
|
||||||
|
8) command + стрелка вверх (или ctrl + k) - выделяем самый верхний чат (без открытия)
|
||||||
|
9) поддержка русской раскладки: "р о л д" соответствует "h j k l"
|
||||||
|
10) Ctrl+R - обновить список чатов
|
||||||
|
|
||||||
|
### Реализованные команды (footer)
|
||||||
|
|
||||||
|
```
|
||||||
|
j/k: Navigate | Ctrl+k: First | Enter: Open | Esc: Close | Ctrl+R: Refresh | Ctrl+C: Quit
|
||||||
|
```
|
||||||
|
|
||||||
|
## Технологии
|
||||||
|
Пишем на rust-е
|
||||||
|
|
||||||
|
1) ratatui - для tui интерфейса
|
||||||
|
2) tdlib-rs - для подключения апи телеграма (обёртка над TDLib)
|
||||||
|
3) tokio - async runtime
|
||||||
|
4) crossterm - кроссплатформенный терминал
|
||||||
|
|
||||||
|
## Нефункциональные требования
|
||||||
|
|
||||||
|
### Производительность
|
||||||
|
1) программа должна выдавать 60 фпс
|
||||||
|
2) интерфейс не должен мерцать
|
||||||
|
3) минимальное разрешение - 600 символов, максимального нет, не ограничиваем
|
||||||
48
ROADMAP.md
Normal file
48
ROADMAP.md
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
# Roadmap
|
||||||
|
|
||||||
|
## Фаза 1: Базовая инфраструктура [DONE]
|
||||||
|
|
||||||
|
- [x] Настройка проекта (Cargo.toml)
|
||||||
|
- [x] TUI фреймворк (ratatui + crossterm)
|
||||||
|
- [x] Базовый layout (папки, список чатов, область сообщений)
|
||||||
|
- [x] Vim-style навигация (hjkl, стрелки)
|
||||||
|
- [x] Русская раскладка (ролд)
|
||||||
|
|
||||||
|
## Фаза 2: TDLib интеграция [DONE]
|
||||||
|
|
||||||
|
- [x] Подключение tdlib-rs
|
||||||
|
- [x] Авторизация (телефон + код + 2FA)
|
||||||
|
- [x] Сохранение сессии
|
||||||
|
- [x] Загрузка списка чатов
|
||||||
|
- [x] Загрузка истории сообщений
|
||||||
|
- [x] Отключение логов TDLib
|
||||||
|
|
||||||
|
## Фаза 3: Улучшение UX [IN PROGRESS]
|
||||||
|
|
||||||
|
- [ ] Отправка сообщений
|
||||||
|
- [ ] Поиск по чатам (Ctrl+S)
|
||||||
|
- [ ] Скролл истории сообщений
|
||||||
|
- [ ] Загрузка имён пользователей (вместо User_ID)
|
||||||
|
- [ ] Отметка сообщений как прочитанные
|
||||||
|
- [ ] Реальное время: новые сообщения
|
||||||
|
|
||||||
|
## Фаза 4: Папки и фильтрация
|
||||||
|
|
||||||
|
- [ ] Загрузка папок из Telegram
|
||||||
|
- [ ] Переключение между папками (Cmd+1, Cmd+2, ...)
|
||||||
|
- [ ] Фильтрация чатов по папке
|
||||||
|
|
||||||
|
## Фаза 5: Расширенный функционал
|
||||||
|
|
||||||
|
- [ ] Отображение онлайн-статуса
|
||||||
|
- [ ] Статус доставки/прочтения (✓, ✓✓)
|
||||||
|
- [ ] Поддержка медиа-заглушек (фото, видео, голосовые)
|
||||||
|
- [ ] Mentions (@)
|
||||||
|
- [ ] Muted чаты (серый цвет)
|
||||||
|
|
||||||
|
## Фаза 6: Полировка
|
||||||
|
|
||||||
|
- [ ] Оптимизация 60 FPS
|
||||||
|
- [ ] Минимальное разрешение 600 символов
|
||||||
|
- [ ] Обработка ошибок сети
|
||||||
|
- [ ] Graceful shutdown
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
Что нужно сделать - telegram TUI, то есть terminal user interface для телеграма
|
|
||||||
Ограничения технологий - используем rust-lang, TUI делаем на ratatui, используем telegram api для клиентских приложений
|
|
||||||
|
|
||||||
Интерфейс -
|
|
||||||
|
|
||||||
┌─ Telegram TUI ───────────────────────────────────────────────────────────────┐
|
|
||||||
│ 1:All │ 2:Personal │ 3:Work │ 4:Bots │
|
|
||||||
├──────────────────────┬───────────────────────────────────────────────────────┤
|
|
||||||
│ 🔍 Search... │ 👤 Mom (online) │
|
|
||||||
├──────────────────────┼───────────────────────────────────────────────────────┤
|
|
||||||
│ 📌 Saved Messages │ Today, Dec 21│
|
|
||||||
│ ▌ Mom (2)│ │
|
|
||||||
│ Boss │ Mom ────────────────────────────────────────── 14:20 │
|
|
||||||
│ Rust Community │ Привет! Ты покормил кота? │
|
|
||||||
│ Durov │ │
|
|
||||||
│ News Channel │ You ─────────────────────────────────────── 14:22 ✓✓ │
|
|
||||||
│ Spam Bot │ Да, конечно. Купил ему корм. │
|
|
||||||
│ Wife │ Скоро буду дома. │
|
|
||||||
│ Team Lead │ │
|
|
||||||
│ DevOps Chat (9)│ Mom ────────────────────────────────────────── 14:23 │
|
|
||||||
│ Server Alerts │ Отлично, захвати хлеба. │
|
|
||||||
│ Gym Bro │ │
|
|
||||||
│ Design Team │ You ─────────────────────────────────────── 14:25 ✓ │
|
|
||||||
│ Project X │ Ок. │
|
|
||||||
│ HR │ │
|
|
||||||
│ Mom's Friend │ │
|
|
||||||
│ Taxi Bot │ │
|
|
||||||
│ │ │
|
|
||||||
│ │ │
|
|
||||||
│ │ │
|
|
||||||
│ │ │
|
|
||||||
│ │ │
|
|
||||||
├──────────────────────┼───────────────────────────────────────────────────────┤
|
|
||||||
│ [User: Online] │ > Ок, скоро буд_ │
|
|
||||||
└──────────────────────┴───────────────────────────────────────────────────────┘
|
|
||||||
Esc: Back | Enter: Open | ^R: Reply | ^E: Edit | ^D: Delete
|
|
||||||
|
|
||||||
|
|
||||||
Так же еще добавляем:
|
|
||||||
1) Авторизацию через Telegram
|
|
||||||
636
docs/TDLIB_INTEGRATION.md
Normal file
636
docs/TDLIB_INTEGRATION.md
Normal file
@@ -0,0 +1,636 @@
|
|||||||
|
# Интеграция TDLib в Telegram TUI
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
TDLib (Telegram Database Library) — это официальная кроссплатформенная библиотека для создания Telegram клиентов. Она предоставляет полный функционал Telegram API с автоматическим управлением сессиями, кэшированием и синхронизацией.
|
||||||
|
|
||||||
|
## Выбор библиотеки для Rust
|
||||||
|
|
||||||
|
Существует несколько Rust-оберток для TDLib:
|
||||||
|
|
||||||
|
### 1. rust-tdlib
|
||||||
|
- **GitHub**: [antonio-antuan/rust-tdlib](https://github.com/antonio-antuan/rust-tdlib)
|
||||||
|
- **docs.rs**: https://docs.rs/rust-tdlib
|
||||||
|
- **Особенности**:
|
||||||
|
- Async/await с tokio
|
||||||
|
- Client/Worker архитектура
|
||||||
|
- Требует предварительной сборки TDLib
|
||||||
|
|
||||||
|
### 2. tdlib-rs (Рекомендуется)
|
||||||
|
- **GitHub**: [FedericoBruzzone/tdlib-rs](https://github.com/FedericoBruzzone/tdlib-rs)
|
||||||
|
- **crates.io**: https://crates.io/crates/tdlib-rs
|
||||||
|
- **docs.rs**: https://docs.rs/tdlib/latest/tdlib/
|
||||||
|
- **Преимущества**:
|
||||||
|
- ✅ Не требует предварительной установки TDLib
|
||||||
|
- ✅ Кроссплатформенность (Windows, Linux, macOS)
|
||||||
|
- ✅ Автоматическая загрузка прекомпилированных бинарников
|
||||||
|
- ✅ Поддержка TDLib v1.8.29
|
||||||
|
- ✅ Автогенерация типов из TL схемы
|
||||||
|
|
||||||
|
## Установка tdlib-rs
|
||||||
|
|
||||||
|
### Вариант 1: Автоматическая загрузка (Рекомендуется)
|
||||||
|
|
||||||
|
**Cargo.toml:**
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
tdlib-rs = { version = "0.3", features = ["download-tdlib"] }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tdlib-rs = { version = "0.3", features = ["download-tdlib"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
**build.rs:**
|
||||||
|
```rust
|
||||||
|
fn main() {
|
||||||
|
tdlib_rs::build::build(None);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Вариант 2: Локальная установка TDLib
|
||||||
|
|
||||||
|
Если TDLib уже установлен (версия 1.8.29):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export LOCAL_TDLIB_PATH=$HOME/lib/tdlib
|
||||||
|
```
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
tdlib-rs = { version = "0.3", features = ["local-tdlib"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Вариант 3: Через pkg-config
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PKG_CONFIG_PATH=$HOME/lib/tdlib/lib/pkgconfig/:$PKG_CONFIG_PATH
|
||||||
|
export LD_LIBRARY_PATH=$HOME/lib/tdlib/lib/:$LD_LIBRARY_PATH
|
||||||
|
```
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
tdlib-rs = { version = "0.3", features = ["pkg-config"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Архитектура TDLib
|
||||||
|
|
||||||
|
### Основные компоненты
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Your Application │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ ┌────────────┐ ┌──────────────┐ ┌────────────────┐ │
|
||||||
|
│ │ Client │ │ Update Stream │ │ API Requests │ │
|
||||||
|
│ └────────────┘ └──────────────┘ └────────────────┘ │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ TDLib Client │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ Auth State │ │ Local Cache │ │ API Handler │ │
|
||||||
|
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ Telegram Servers │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Поток работы
|
||||||
|
|
||||||
|
1. **Инициализация** → TDLib запускается с параметрами
|
||||||
|
2. **Авторизация** → Проход через стейт-машину авторизации
|
||||||
|
3. **Синхронизация** → Загрузка базовых данных (чаты, контакты)
|
||||||
|
4. **Updates Stream** → Постоянный поток обновлений от сервера
|
||||||
|
5. **API Requests** → Запросы на получение данных / отправку сообщений
|
||||||
|
|
||||||
|
## Процесс авторизации
|
||||||
|
|
||||||
|
### Стейт-машина авторизации
|
||||||
|
|
||||||
|
TDLib работает через систему состояний. Приложение получает обновления `updateAuthorizationState` и реагирует на них:
|
||||||
|
|
||||||
|
```
|
||||||
|
authorizationStateWaitTdlibParameters
|
||||||
|
↓ (вызываем setTdlibParameters)
|
||||||
|
authorizationStateWaitPhoneNumber
|
||||||
|
↓ (вызываем setAuthenticationPhoneNumber)
|
||||||
|
authorizationStateWaitCode
|
||||||
|
↓ (вызываем checkAuthenticationCode)
|
||||||
|
authorizationStateWaitPassword (опционально, если 2FA)
|
||||||
|
↓ (вызываем checkAuthenticationPassword)
|
||||||
|
authorizationStateReady ✓
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 1: Получение API ключей
|
||||||
|
|
||||||
|
Перед началом работы нужно:
|
||||||
|
1. Зайти на https://my.telegram.org
|
||||||
|
2. Войти с номером телефона
|
||||||
|
3. Перейти в "API development tools"
|
||||||
|
4. Создать приложение и получить `api_id` и `api_hash`
|
||||||
|
|
||||||
|
### Шаг 2: Инициализация TDLib
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use tdlib::{functions, types};
|
||||||
|
|
||||||
|
async fn init_tdlib() {
|
||||||
|
// Параметры инициализации
|
||||||
|
let params = types::TdlibParameters {
|
||||||
|
database_directory: "./tdlib_db".to_string(),
|
||||||
|
use_message_database: true,
|
||||||
|
use_secret_chats: true,
|
||||||
|
api_id: env::var("API_ID").unwrap().parse().unwrap(),
|
||||||
|
api_hash: env::var("API_HASH").unwrap(),
|
||||||
|
system_language_code: "en".to_string(),
|
||||||
|
device_model: "Desktop".to_string(),
|
||||||
|
system_version: "Unknown".to_string(),
|
||||||
|
application_version: "0.1.0".to_string(),
|
||||||
|
enable_storage_optimizer: true,
|
||||||
|
ignore_file_names: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Отправляем параметры
|
||||||
|
functions::set_tdlib_parameters(params, &client).await?;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 3: Ввод номера телефона
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn authenticate_with_phone(phone: String, client: &Client) {
|
||||||
|
let phone_number = types::SetAuthenticationPhoneNumber {
|
||||||
|
phone_number: phone,
|
||||||
|
settings: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
functions::set_authentication_phone_number(phone_number, client).await?;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 4: Ввод кода подтверждения
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn verify_code(code: String, client: &Client) {
|
||||||
|
let check_code = types::CheckAuthenticationCode {
|
||||||
|
code,
|
||||||
|
};
|
||||||
|
|
||||||
|
functions::check_authentication_code(check_code, client).await?;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Шаг 5: Ввод пароля 2FA (если включен)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn verify_password(password: String, client: &Client) {
|
||||||
|
let check_password = types::CheckAuthenticationPassword {
|
||||||
|
password,
|
||||||
|
};
|
||||||
|
|
||||||
|
functions::check_authentication_password(check_password, client).await?;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Получение списка чатов
|
||||||
|
|
||||||
|
### Концепция чатов в TDLib
|
||||||
|
|
||||||
|
TDLib автоматически кэширует чаты локально. Приложение должно:
|
||||||
|
1. Подписаться на обновления `updateNewChat`
|
||||||
|
2. Вызвать `loadChats()` для загрузки чатов
|
||||||
|
3. Поддерживать локальный кэш с сортировкой
|
||||||
|
|
||||||
|
### Типы списков чатов
|
||||||
|
|
||||||
|
- **Main** — основные чаты
|
||||||
|
- **Archive** — архивные чаты
|
||||||
|
- **Folder** — пользовательские папки
|
||||||
|
|
||||||
|
### Загрузка чатов
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use tdlib::{functions, types};
|
||||||
|
|
||||||
|
async fn load_chats(client: &Client) -> Result<Vec<Chat>> {
|
||||||
|
// Указываем тип списка (Main, Archive, или конкретная папка)
|
||||||
|
let chat_list = types::ChatList::Main;
|
||||||
|
|
||||||
|
// Загружаем чаты
|
||||||
|
// limit - количество чатов для загрузки
|
||||||
|
functions::load_chats(
|
||||||
|
types::LoadChats {
|
||||||
|
chat_list: Some(chat_list),
|
||||||
|
limit: 50,
|
||||||
|
},
|
||||||
|
client
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
// После вызова loadChats, чаты будут приходить через updateNewChat
|
||||||
|
Ok(vec![])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Получение информации о чате
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn get_chat_info(chat_id: i64, client: &Client) -> Result<types::Chat> {
|
||||||
|
let chat = functions::get_chat(
|
||||||
|
types::GetChat { chat_id },
|
||||||
|
client
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
Ok(chat)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Сортировка чатов
|
||||||
|
|
||||||
|
Чаты нужно сортировать по паре `(position.order, chat.id)` в порядке убывания:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
chats.sort_by(|a, b| {
|
||||||
|
let order_a = a.positions.get(0).map(|p| p.order).unwrap_or(0);
|
||||||
|
let order_b = b.positions.get(0).map(|p| p.order).unwrap_or(0);
|
||||||
|
|
||||||
|
order_b.cmp(&order_a)
|
||||||
|
.then_with(|| b.id.cmp(&a.id))
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Получение истории сообщений
|
||||||
|
|
||||||
|
### Загрузка сообщений из чата
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn get_chat_history(
|
||||||
|
chat_id: i64,
|
||||||
|
from_message_id: i64,
|
||||||
|
limit: i32,
|
||||||
|
client: &Client
|
||||||
|
) -> Result<Vec<types::Message>> {
|
||||||
|
let history = functions::get_chat_history(
|
||||||
|
types::GetChatHistory {
|
||||||
|
chat_id,
|
||||||
|
from_message_id, // 0 для последних сообщений
|
||||||
|
offset: 0,
|
||||||
|
limit,
|
||||||
|
only_local: false,
|
||||||
|
},
|
||||||
|
client
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
Ok(history.messages.unwrap_or_default())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Пагинация сообщений
|
||||||
|
|
||||||
|
Сообщения возвращаются в обратном хронологическом порядке (новые → старые).
|
||||||
|
|
||||||
|
Для загрузки следующей страницы:
|
||||||
|
```rust
|
||||||
|
// Первая загрузка (последние сообщения)
|
||||||
|
let messages = get_chat_history(chat_id, 0, 50, &client).await?;
|
||||||
|
|
||||||
|
// Загрузка более старых сообщений
|
||||||
|
if let Some(oldest_msg) = messages.last() {
|
||||||
|
let older_messages = get_chat_history(
|
||||||
|
chat_id,
|
||||||
|
oldest_msg.id,
|
||||||
|
50,
|
||||||
|
&client
|
||||||
|
).await?;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Обработка обновлений (Updates Stream)
|
||||||
|
|
||||||
|
### Типы обновлений
|
||||||
|
|
||||||
|
TDLib отправляет обновления через `Update` enum:
|
||||||
|
|
||||||
|
- `UpdateNewMessage` — новое сообщение
|
||||||
|
- `UpdateMessageContent` — изменение контента сообщения
|
||||||
|
- `UpdateMessageSendSucceeded` — сообщение успешно отправлено
|
||||||
|
- `UpdateMessageSendFailed` — ошибка отправки
|
||||||
|
- `UpdateChatLastMessage` — изменилось последнее сообщение чата
|
||||||
|
- `UpdateChatPosition` — изменилась позиция чата в списке
|
||||||
|
- `UpdateNewChat` — новый чат добавлен
|
||||||
|
- `UpdateUser` — обновилась информация о пользователе
|
||||||
|
- `UpdateUserStatus` — изменился статус пользователя (онлайн/оффлайн)
|
||||||
|
- `UpdateChatReadInbox` — прочитаны входящие сообщения
|
||||||
|
- `UpdateChatReadOutbox` — прочитаны исходящие сообщения
|
||||||
|
|
||||||
|
### Слушатель обновлений
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use tdlib::types::Update;
|
||||||
|
|
||||||
|
async fn handle_updates(client: Client) {
|
||||||
|
loop {
|
||||||
|
match client.receive() {
|
||||||
|
Some(Update::NewMessage(update)) => {
|
||||||
|
println!("New message in chat {}: {}",
|
||||||
|
update.message.chat_id,
|
||||||
|
update.message.content
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Some(Update::MessageSendSucceeded(update)) => {
|
||||||
|
println!("Message sent successfully: {}", update.message.id);
|
||||||
|
}
|
||||||
|
Some(Update::UserStatus(update)) => {
|
||||||
|
println!("User {} is now {:?}",
|
||||||
|
update.user_id,
|
||||||
|
update.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Some(Update::NewChat(update)) => {
|
||||||
|
println!("New chat added: {}", update.chat.title);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Отправка сообщений
|
||||||
|
|
||||||
|
### Отправка текстового сообщения
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn send_message(
|
||||||
|
chat_id: i64,
|
||||||
|
text: String,
|
||||||
|
client: &Client
|
||||||
|
) -> Result<types::Message> {
|
||||||
|
let input_content = types::InputMessageContent::InputMessageText(
|
||||||
|
types::InputMessageText {
|
||||||
|
text: types::FormattedText {
|
||||||
|
text,
|
||||||
|
entities: vec![],
|
||||||
|
},
|
||||||
|
disable_web_page_preview: false,
|
||||||
|
clear_draft: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let message = functions::send_message(
|
||||||
|
types::SendMessage {
|
||||||
|
chat_id,
|
||||||
|
message_thread_id: 0,
|
||||||
|
reply_to: None,
|
||||||
|
options: None,
|
||||||
|
reply_markup: None,
|
||||||
|
input_message_content: input_content,
|
||||||
|
},
|
||||||
|
client
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
Ok(message)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Статусы доставки и прочтения
|
||||||
|
|
||||||
|
Для отображения ✓ и ✓✓:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn get_message_status(message: &types::Message) -> &str {
|
||||||
|
if message.is_outgoing {
|
||||||
|
match &message.sending_state {
|
||||||
|
Some(types::MessageSendingState::Pending) => "", // отправляется
|
||||||
|
Some(types::MessageSendingState::Failed(_)) => "✗", // ошибка
|
||||||
|
None => {
|
||||||
|
// Отправлено успешно
|
||||||
|
if message.chat_id > 0 { // личный чат
|
||||||
|
// Проверяем, прочитано ли
|
||||||
|
// (нужно следить за UpdateChatReadOutbox)
|
||||||
|
"✓✓" // или "✓" если не прочитано
|
||||||
|
} else {
|
||||||
|
"✓" // групповой чат
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"" // входящее сообщение
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Работа с папками (Folders)
|
||||||
|
|
||||||
|
### Получение списка папок
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn get_chat_folders(client: &Client) -> Result<Vec<types::ChatFolderInfo>> {
|
||||||
|
let folders = functions::get_chat_folders(
|
||||||
|
types::GetChatFolders {},
|
||||||
|
client
|
||||||
|
).await?;
|
||||||
|
|
||||||
|
Ok(folders.chat_folders)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Фильтрация чатов по папке
|
||||||
|
|
||||||
|
```rust
|
||||||
|
async fn get_chats_in_folder(folder_id: i32, client: &Client) {
|
||||||
|
let chat_list = types::ChatList::Folder {
|
||||||
|
chat_folder_id: folder_id
|
||||||
|
};
|
||||||
|
|
||||||
|
functions::load_chats(
|
||||||
|
types::LoadChats {
|
||||||
|
chat_list: Some(chat_list),
|
||||||
|
limit: 50,
|
||||||
|
},
|
||||||
|
client
|
||||||
|
).await?;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Архитектура приложения
|
||||||
|
|
||||||
|
### Рекомендуемая структура
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── main.rs # Entry point, UI loop
|
||||||
|
├── tdlib/
|
||||||
|
│ ├── mod.rs # TDLib module
|
||||||
|
│ ├── client.rs # Client wrapper
|
||||||
|
│ ├── auth.rs # Authentication logic
|
||||||
|
│ └── updates.rs # Update handlers
|
||||||
|
├── ui/
|
||||||
|
│ ├── mod.rs
|
||||||
|
│ ├── app.rs # App state
|
||||||
|
│ ├── layout.rs # UI layout
|
||||||
|
│ └── components/ # UI components
|
||||||
|
└── models/
|
||||||
|
├── chat.rs # Chat models
|
||||||
|
└── message.rs # Message models
|
||||||
|
```
|
||||||
|
|
||||||
|
### Разделение ответственности
|
||||||
|
|
||||||
|
1. **TDLib Client** — управление клиентом, запросы к API
|
||||||
|
2. **Update Handler** — обработка обновлений в фоне
|
||||||
|
3. **App State** — состояние приложения (чаты, сообщения, UI)
|
||||||
|
4. **UI Layer** — отрисовка интерфейса (ratatui)
|
||||||
|
|
||||||
|
### Коммуникация между слоями
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// Используем каналы для коммуникации
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
enum AppEvent {
|
||||||
|
NewMessage(Message),
|
||||||
|
ChatUpdated(Chat),
|
||||||
|
UserStatusChanged(i64, UserStatus),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
// Канал для событий от TDLib
|
||||||
|
let (tx, mut rx) = mpsc::channel::<AppEvent>(100);
|
||||||
|
|
||||||
|
// Запускаем TDLib в отдельной задаче
|
||||||
|
tokio::spawn(async move {
|
||||||
|
run_tdlib_client(tx).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Основной UI loop
|
||||||
|
loop {
|
||||||
|
// Проверяем события
|
||||||
|
while let Ok(event) = rx.try_recv() {
|
||||||
|
match event {
|
||||||
|
AppEvent::NewMessage(msg) => {
|
||||||
|
// Обновляем UI
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отрисовываем UI
|
||||||
|
terminal.draw(|f| ui(f, &app))?;
|
||||||
|
|
||||||
|
// Обрабатываем ввод пользователя
|
||||||
|
handle_input()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Пример: Минимальный клиент
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use tdlib::{Client, ClientState, functions, types};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
// 1. Создаем клиент
|
||||||
|
let (sender, mut receiver) = mpsc::channel(100);
|
||||||
|
let client = Client::new(sender);
|
||||||
|
|
||||||
|
// 2. Запускаем клиент
|
||||||
|
tokio::spawn(async move {
|
||||||
|
client.start().await;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Ждем авторизации
|
||||||
|
let mut authorized = false;
|
||||||
|
|
||||||
|
while let Some(update) = receiver.recv().await {
|
||||||
|
match update {
|
||||||
|
types::Update::AuthorizationState(state) => {
|
||||||
|
match state.authorization_state {
|
||||||
|
types::AuthorizationState::WaitTdlibParameters => {
|
||||||
|
// Отправляем параметры
|
||||||
|
init_tdlib(&client).await?;
|
||||||
|
}
|
||||||
|
types::AuthorizationState::WaitPhoneNumber => {
|
||||||
|
// Запрашиваем номер у пользователя
|
||||||
|
let phone = read_phone_from_user();
|
||||||
|
authenticate_with_phone(phone, &client).await?;
|
||||||
|
}
|
||||||
|
types::AuthorizationState::WaitCode(_) => {
|
||||||
|
// Запрашиваем код
|
||||||
|
let code = read_code_from_user();
|
||||||
|
verify_code(code, &client).await?;
|
||||||
|
}
|
||||||
|
types::AuthorizationState::Ready => {
|
||||||
|
authorized = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Загружаем чаты
|
||||||
|
if authorized {
|
||||||
|
load_chats(&client).await?;
|
||||||
|
|
||||||
|
// 5. Слушаем обновления
|
||||||
|
while let Some(update) = receiver.recv().await {
|
||||||
|
handle_update(update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Кэширование
|
||||||
|
- Всегда включай `use_message_database: true`
|
||||||
|
- Храни кэш чатов и сообщений в памяти
|
||||||
|
- Используй `only_local: true` для быстрого доступа
|
||||||
|
|
||||||
|
### 2. Обработка ошибок
|
||||||
|
- Все TDLib функции возвращают `Result`
|
||||||
|
- Обрабатывай потерю соединения
|
||||||
|
- Переподключайся при ошибках сети
|
||||||
|
|
||||||
|
### 3. Производительность
|
||||||
|
- Не загружай все чаты сразу (используй пагинацию)
|
||||||
|
- Лимитируй количество сообщений в истории
|
||||||
|
- Используй `offset` для ленивой загрузки
|
||||||
|
|
||||||
|
### 4. UI/UX
|
||||||
|
- Показывай индикаторы загрузки
|
||||||
|
- Кэшируй отрисованные элементы
|
||||||
|
- Обновляй UI только при изменениях
|
||||||
|
|
||||||
|
## Полезные ссылки
|
||||||
|
|
||||||
|
### Официальная документация
|
||||||
|
- [TDLib Getting Started](https://core.telegram.org/tdlib/getting-started)
|
||||||
|
- [TDLib Documentation](https://core.telegram.org/tdlib/docs/)
|
||||||
|
|
||||||
|
### Rust библиотеки
|
||||||
|
- [rust-tdlib GitHub](https://github.com/antonio-antuan/rust-tdlib)
|
||||||
|
- [rust-tdlib docs.rs](https://docs.rs/rust-tdlib)
|
||||||
|
- [tdlib-rs GitHub](https://github.com/FedericoBruzzone/tdlib-rs)
|
||||||
|
- [tdlib-rs docs.rs](https://docs.rs/tdlib/latest/tdlib/)
|
||||||
|
|
||||||
|
### API Reference
|
||||||
|
- [tdlib::functions](https://docs.rs/tdlib/latest/tdlib/functions/index.html)
|
||||||
|
- [tdlib::types](https://docs.rs/tdlib-types/latest/tdlib_types/types/index.html)
|
||||||
|
|
||||||
|
## Следующие шаги
|
||||||
|
|
||||||
|
1. ✅ Изучить документацию TDLib
|
||||||
|
2. ⬜ Добавить зависимость tdlib-rs в проект
|
||||||
|
3. ⬜ Реализовать модуль авторизации
|
||||||
|
4. ⬜ Реализовать загрузку чатов
|
||||||
|
5. ⬜ Реализовать загрузку сообщений
|
||||||
|
6. ⬜ Интегрировать с существующим UI
|
||||||
|
7. ⬜ Добавить отправку сообщений
|
||||||
|
8. ⬜ Реализовать обработку обновлений в реальном времени
|
||||||
277
src/app/mod.rs
277
src/app/mod.rs
@@ -1,201 +1,118 @@
|
|||||||
use crate::telegram::{Chat, Message};
|
mod state;
|
||||||
|
|
||||||
|
pub use state::AppScreen;
|
||||||
|
|
||||||
|
use ratatui::widgets::ListState;
|
||||||
|
use crate::tdlib::client::{ChatInfo, MessageInfo};
|
||||||
|
use crate::tdlib::TdClient;
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
pub tabs: Vec<String>,
|
pub screen: AppScreen,
|
||||||
pub selected_tab: usize,
|
pub td_client: TdClient,
|
||||||
pub chats: Vec<Chat>,
|
// Auth state
|
||||||
pub selected_chat: Option<usize>,
|
pub phone_input: String,
|
||||||
pub messages: Vec<Message>,
|
pub code_input: String,
|
||||||
pub input: String,
|
pub password_input: String,
|
||||||
pub search_query: String,
|
pub error_message: Option<String>,
|
||||||
|
pub status_message: Option<String>,
|
||||||
|
// Main app state
|
||||||
|
pub chats: Vec<ChatInfo>,
|
||||||
|
pub chat_list_state: ListState,
|
||||||
|
pub selected_chat_id: Option<i64>,
|
||||||
|
pub current_messages: Vec<MessageInfo>,
|
||||||
|
pub message_input: String,
|
||||||
|
pub message_scroll_offset: usize,
|
||||||
|
pub folders: Vec<String>,
|
||||||
|
pub selected_folder: usize,
|
||||||
|
pub is_loading: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> App {
|
||||||
Self {
|
let mut state = ListState::default();
|
||||||
tabs: vec![
|
state.select(Some(0));
|
||||||
"All".to_string(),
|
|
||||||
"Personal".to_string(),
|
|
||||||
"Work".to_string(),
|
|
||||||
"Bots".to_string(),
|
|
||||||
],
|
|
||||||
selected_tab: 0,
|
|
||||||
chats: Self::mock_chats(),
|
|
||||||
selected_chat: Some(0),
|
|
||||||
messages: Self::mock_messages(),
|
|
||||||
input: String::new(),
|
|
||||||
search_query: String::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn select_tab(&mut self, index: usize) {
|
App {
|
||||||
if index < self.tabs.len() {
|
screen: AppScreen::Loading,
|
||||||
self.selected_tab = index;
|
td_client: TdClient::new(),
|
||||||
|
phone_input: String::new(),
|
||||||
|
code_input: String::new(),
|
||||||
|
password_input: String::new(),
|
||||||
|
error_message: None,
|
||||||
|
status_message: Some("Инициализация TDLib...".to_string()),
|
||||||
|
chats: Vec::new(),
|
||||||
|
chat_list_state: state,
|
||||||
|
selected_chat_id: None,
|
||||||
|
current_messages: Vec::new(),
|
||||||
|
message_input: String::new(),
|
||||||
|
message_scroll_offset: 0,
|
||||||
|
folders: vec!["All".to_string()],
|
||||||
|
selected_folder: 0,
|
||||||
|
is_loading: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn next_chat(&mut self) {
|
pub fn next_chat(&mut self) {
|
||||||
if !self.chats.is_empty() {
|
if self.chats.is_empty() {
|
||||||
self.selected_chat = Some(
|
return;
|
||||||
self.selected_chat
|
|
||||||
.map(|i| (i + 1) % self.chats.len())
|
|
||||||
.unwrap_or(0),
|
|
||||||
);
|
|
||||||
self.load_messages();
|
|
||||||
}
|
}
|
||||||
|
let i = match self.chat_list_state.selected() {
|
||||||
|
Some(i) => {
|
||||||
|
if i >= self.chats.len() - 1 {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
i + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
self.chat_list_state.select(Some(i));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn previous_chat(&mut self) {
|
pub fn previous_chat(&mut self) {
|
||||||
if !self.chats.is_empty() {
|
if self.chats.is_empty() {
|
||||||
self.selected_chat = Some(
|
return;
|
||||||
self.selected_chat
|
|
||||||
.map(|i| if i == 0 { self.chats.len() - 1 } else { i - 1 })
|
|
||||||
.unwrap_or(0),
|
|
||||||
);
|
|
||||||
self.load_messages();
|
|
||||||
}
|
}
|
||||||
}
|
let i = match self.chat_list_state.selected() {
|
||||||
|
Some(i) => {
|
||||||
pub fn open_chat(&mut self) {
|
if i == 0 {
|
||||||
self.load_messages();
|
self.chats.len() - 1
|
||||||
}
|
|
||||||
|
|
||||||
fn load_messages(&mut self) {
|
|
||||||
self.messages = Self::mock_messages();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mock_chats() -> Vec<Chat> {
|
|
||||||
vec![
|
|
||||||
Chat {
|
|
||||||
name: "Saved Messages".to_string(),
|
|
||||||
last_message: "My notes...".to_string(),
|
|
||||||
unread_count: 0,
|
|
||||||
is_pinned: true,
|
|
||||||
is_online: false,
|
|
||||||
},
|
|
||||||
Chat {
|
|
||||||
name: "Mom".to_string(),
|
|
||||||
last_message: "Отлично, захвати хлеба.".to_string(),
|
|
||||||
unread_count: 2,
|
|
||||||
is_pinned: false,
|
|
||||||
is_online: true,
|
|
||||||
},
|
|
||||||
Chat {
|
|
||||||
name: "Boss".to_string(),
|
|
||||||
last_message: "Meeting at 3pm".to_string(),
|
|
||||||
unread_count: 0,
|
|
||||||
is_pinned: false,
|
|
||||||
is_online: false,
|
|
||||||
},
|
|
||||||
Chat {
|
|
||||||
name: "Rust Community".to_string(),
|
|
||||||
last_message: "Check out this crate...".to_string(),
|
|
||||||
unread_count: 0,
|
|
||||||
is_pinned: false,
|
|
||||||
is_online: false,
|
|
||||||
},
|
|
||||||
Chat {
|
|
||||||
name: "Durov".to_string(),
|
|
||||||
last_message: "Privacy matters".to_string(),
|
|
||||||
unread_count: 0,
|
|
||||||
is_pinned: false,
|
|
||||||
is_online: false,
|
|
||||||
},
|
|
||||||
Chat {
|
|
||||||
name: "News Channel".to_string(),
|
|
||||||
last_message: "Breaking news...".to_string(),
|
|
||||||
unread_count: 0,
|
|
||||||
is_pinned: false,
|
|
||||||
is_online: false,
|
|
||||||
},
|
|
||||||
Chat {
|
|
||||||
name: "Spam Bot".to_string(),
|
|
||||||
last_message: "Click here!!!".to_string(),
|
|
||||||
unread_count: 0,
|
|
||||||
is_pinned: false,
|
|
||||||
is_online: false,
|
|
||||||
},
|
|
||||||
Chat {
|
|
||||||
name: "Wife".to_string(),
|
|
||||||
last_message: "Don't forget the milk".to_string(),
|
|
||||||
unread_count: 0,
|
|
||||||
is_pinned: false,
|
|
||||||
is_online: false,
|
|
||||||
},
|
|
||||||
Chat {
|
|
||||||
name: "Team Lead".to_string(),
|
|
||||||
last_message: "Code review please".to_string(),
|
|
||||||
unread_count: 0,
|
|
||||||
is_pinned: false,
|
|
||||||
is_online: false,
|
|
||||||
},
|
|
||||||
Chat {
|
|
||||||
name: "DevOps Chat".to_string(),
|
|
||||||
last_message: "Server is down!".to_string(),
|
|
||||||
unread_count: 9,
|
|
||||||
is_pinned: false,
|
|
||||||
is_online: false,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
fn mock_messages() -> Vec<Message> {
|
|
||||||
vec![
|
|
||||||
Message {
|
|
||||||
sender: "Mom".to_string(),
|
|
||||||
text: "Привет! Ты покормил кота?".to_string(),
|
|
||||||
time: "14:20".to_string(),
|
|
||||||
is_outgoing: false,
|
|
||||||
read_status: 0,
|
|
||||||
},
|
|
||||||
Message {
|
|
||||||
sender: "You".to_string(),
|
|
||||||
text: "Да, конечно. Купил ему корм.".to_string(),
|
|
||||||
time: "14:22".to_string(),
|
|
||||||
is_outgoing: true,
|
|
||||||
read_status: 2,
|
|
||||||
},
|
|
||||||
Message {
|
|
||||||
sender: "You".to_string(),
|
|
||||||
text: "Скоро буду дома.".to_string(),
|
|
||||||
time: "14:22".to_string(),
|
|
||||||
is_outgoing: true,
|
|
||||||
read_status: 2,
|
|
||||||
},
|
|
||||||
Message {
|
|
||||||
sender: "Mom".to_string(),
|
|
||||||
text: "Отлично, захвати хлеба.".to_string(),
|
|
||||||
time: "14:23".to_string(),
|
|
||||||
is_outgoing: false,
|
|
||||||
read_status: 0,
|
|
||||||
},
|
|
||||||
Message {
|
|
||||||
sender: "You".to_string(),
|
|
||||||
text: "Ок.".to_string(),
|
|
||||||
time: "14:25".to_string(),
|
|
||||||
is_outgoing: true,
|
|
||||||
read_status: 1,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_current_chat_name(&self) -> String {
|
|
||||||
self.selected_chat
|
|
||||||
.and_then(|i| self.chats.get(i))
|
|
||||||
.map(|chat| {
|
|
||||||
if chat.is_online {
|
|
||||||
format!("👤 {} (online)", chat.name)
|
|
||||||
} else {
|
} else {
|
||||||
format!("👤 {}", chat.name)
|
i - 1
|
||||||
}
|
}
|
||||||
})
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
}
|
||||||
}
|
None => 0,
|
||||||
|
};
|
||||||
|
self.chat_list_state.select(Some(i));
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for App {
|
pub fn select_current_chat(&mut self) {
|
||||||
fn default() -> Self {
|
if let Some(i) = self.chat_list_state.selected() {
|
||||||
Self::new()
|
if let Some(chat) = self.chats.get(i) {
|
||||||
|
self.selected_chat_id = Some(chat.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn close_chat(&mut self) {
|
||||||
|
self.selected_chat_id = None;
|
||||||
|
self.current_messages.clear();
|
||||||
|
self.message_input.clear();
|
||||||
|
self.message_scroll_offset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_first_chat(&mut self) {
|
||||||
|
if !self.chats.is_empty() {
|
||||||
|
self.chat_list_state.select(Some(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_selected_chat_id(&self) -> Option<i64> {
|
||||||
|
self.selected_chat_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_selected_chat(&self) -> Option<&ChatInfo> {
|
||||||
|
self.selected_chat_id
|
||||||
|
.and_then(|id| self.chats.iter().find(|c| c.id == id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
6
src/app/state.rs
Normal file
6
src/app/state.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
#[derive(PartialEq, Clone)]
|
||||||
|
pub enum AppScreen {
|
||||||
|
Loading,
|
||||||
|
Auth,
|
||||||
|
Main,
|
||||||
|
}
|
||||||
101
src/input/auth.rs
Normal file
101
src/input/auth.rs
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
use crossterm::event::KeyCode;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::time::timeout;
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::tdlib::client::AuthState;
|
||||||
|
|
||||||
|
pub async fn handle(app: &mut App, key_code: KeyCode) {
|
||||||
|
match &app.td_client.auth_state {
|
||||||
|
AuthState::WaitPhoneNumber => match key_code {
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
app.phone_input.push(c);
|
||||||
|
app.error_message = None;
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
app.phone_input.pop();
|
||||||
|
app.error_message = None;
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if !app.phone_input.is_empty() {
|
||||||
|
app.status_message = Some("Отправка номера...".to_string());
|
||||||
|
match timeout(Duration::from_secs(10), app.td_client.send_phone_number(app.phone_input.clone())).await {
|
||||||
|
Ok(Ok(_)) => {
|
||||||
|
app.error_message = None;
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
app.error_message = Some(e);
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
app.error_message = Some("Таймаут".to_string());
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
AuthState::WaitCode => match key_code {
|
||||||
|
KeyCode::Char(c) if c.is_numeric() => {
|
||||||
|
app.code_input.push(c);
|
||||||
|
app.error_message = None;
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
app.code_input.pop();
|
||||||
|
app.error_message = None;
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if !app.code_input.is_empty() {
|
||||||
|
app.status_message = Some("Проверка кода...".to_string());
|
||||||
|
match timeout(Duration::from_secs(10), app.td_client.send_code(app.code_input.clone())).await {
|
||||||
|
Ok(Ok(_)) => {
|
||||||
|
app.error_message = None;
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
app.error_message = Some(e);
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
app.error_message = Some("Таймаут".to_string());
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
AuthState::WaitPassword => match key_code {
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
app.password_input.push(c);
|
||||||
|
app.error_message = None;
|
||||||
|
}
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
app.password_input.pop();
|
||||||
|
app.error_message = None;
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
if !app.password_input.is_empty() {
|
||||||
|
app.status_message = Some("Проверка пароля...".to_string());
|
||||||
|
match timeout(Duration::from_secs(10), app.td_client.send_password(app.password_input.clone())).await {
|
||||||
|
Ok(Ok(_)) => {
|
||||||
|
app.error_message = None;
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
app.error_message = Some(e);
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
app.error_message = Some("Таймаут".to_string());
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
174
src/input/main_input.rs
Normal file
174
src/input/main_input.rs
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::time::timeout;
|
||||||
|
use crate::app::App;
|
||||||
|
|
||||||
|
pub async fn handle(app: &mut App, key: KeyEvent) {
|
||||||
|
let has_super = key.modifiers.contains(KeyModifiers::SUPER);
|
||||||
|
let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
|
|
||||||
|
// Глобальные команды (работают всегда)
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('r') if has_ctrl => {
|
||||||
|
app.status_message = Some("Обновление чатов...".to_string());
|
||||||
|
let _ = timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await;
|
||||||
|
app.status_message = None;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cmd+j/k - навигация (работает и в списке чатов, и для скролла сообщений)
|
||||||
|
if has_super {
|
||||||
|
match key.code {
|
||||||
|
// Cmd+j - вниз (следующий чат ИЛИ скролл вниз)
|
||||||
|
KeyCode::Char('j') | KeyCode::Char('д') | KeyCode::Down => {
|
||||||
|
if app.selected_chat_id.is_some() {
|
||||||
|
// В открытом чате - скролл вниз (к новым сообщениям)
|
||||||
|
if app.message_scroll_offset > 0 {
|
||||||
|
app.message_scroll_offset = app.message_scroll_offset.saturating_sub(3);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// В списке чатов - следующий чат
|
||||||
|
app.next_chat();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Cmd+k - вверх (предыдущий чат ИЛИ скролл вверх)
|
||||||
|
KeyCode::Char('k') | KeyCode::Char('л') | KeyCode::Up => {
|
||||||
|
if app.selected_chat_id.is_some() {
|
||||||
|
// В открытом чате - скролл вверх (к старым сообщениям)
|
||||||
|
app.message_scroll_offset += 3;
|
||||||
|
|
||||||
|
// Проверяем, нужно ли подгрузить старые сообщения
|
||||||
|
if !app.current_messages.is_empty() {
|
||||||
|
let oldest_msg_id = app.current_messages.first().map(|m| m.id).unwrap_or(0);
|
||||||
|
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||||
|
// Подгружаем больше сообщений если скролл близко к верху
|
||||||
|
if app.message_scroll_offset > app.current_messages.len().saturating_sub(10) {
|
||||||
|
if let Ok(Ok(older)) = timeout(
|
||||||
|
Duration::from_secs(3),
|
||||||
|
app.td_client.load_older_messages(chat_id, oldest_msg_id, 20)
|
||||||
|
).await {
|
||||||
|
if !older.is_empty() {
|
||||||
|
// Добавляем старые сообщения в начало
|
||||||
|
let mut new_messages = older;
|
||||||
|
new_messages.extend(app.current_messages.drain(..));
|
||||||
|
app.current_messages = new_messages;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// В списке чатов - предыдущий чат
|
||||||
|
app.previous_chat();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+k - в первый чат (только в режиме списка)
|
||||||
|
if has_ctrl && matches!(key.code, KeyCode::Char('k') | KeyCode::Char('л')) {
|
||||||
|
if app.selected_chat_id.is_none() {
|
||||||
|
app.select_first_chat();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter - открыть чат или отправить сообщение
|
||||||
|
if key.code == KeyCode::Enter {
|
||||||
|
if app.selected_chat_id.is_some() {
|
||||||
|
// Отправка сообщения
|
||||||
|
if !app.message_input.is_empty() {
|
||||||
|
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||||
|
let text = app.message_input.clone();
|
||||||
|
app.message_input.clear();
|
||||||
|
|
||||||
|
match timeout(Duration::from_secs(5), app.td_client.send_message(chat_id, text.clone())).await {
|
||||||
|
Ok(Ok(sent_msg)) => {
|
||||||
|
// Добавляем отправленное сообщение в список
|
||||||
|
app.current_messages.push(sent_msg);
|
||||||
|
// Сбрасываем скролл чтобы видеть новое сообщение
|
||||||
|
app.message_scroll_offset = 0;
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
app.error_message = Some(e);
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
app.error_message = Some("Таймаут отправки".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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 timeout(Duration::from_secs(5), app.td_client.get_chat_history(chat_id, 50)).await {
|
||||||
|
Ok(Ok(messages)) => {
|
||||||
|
app.current_messages = messages;
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
Ok(Err(e)) => {
|
||||||
|
app.error_message = Some(e);
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
app.error_message = Some("Таймаут загрузки сообщений".to_string());
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Esc - закрыть чат
|
||||||
|
if key.code == KeyCode::Esc {
|
||||||
|
if app.selected_chat_id.is_some() {
|
||||||
|
app.close_chat();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ввод текста в режиме открытого чата
|
||||||
|
if app.selected_chat_id.is_some() {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Backspace => {
|
||||||
|
app.message_input.pop();
|
||||||
|
}
|
||||||
|
KeyCode::Char(c) => {
|
||||||
|
app.message_input.push(c);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// В режиме списка чатов - навигация j/k и переключение папок
|
||||||
|
match key.code {
|
||||||
|
// j или д - следующий чат
|
||||||
|
KeyCode::Char('j') | KeyCode::Char('д') | KeyCode::Down => {
|
||||||
|
app.next_chat();
|
||||||
|
}
|
||||||
|
// k или л - предыдущий чат
|
||||||
|
KeyCode::Char('k') | KeyCode::Char('л') | KeyCode::Up => {
|
||||||
|
app.previous_chat();
|
||||||
|
}
|
||||||
|
// Цифры - переключение папок
|
||||||
|
KeyCode::Char(c) if c >= '1' && c <= '9' => {
|
||||||
|
let folder_idx = (c as usize) - ('1' as usize);
|
||||||
|
if folder_idx < app.folders.len() {
|
||||||
|
app.selected_folder = folder_idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/input/mod.rs
Normal file
5
src/input/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
mod auth;
|
||||||
|
mod main_input;
|
||||||
|
|
||||||
|
pub use auth::handle as handle_auth_input;
|
||||||
|
pub use main_input::handle as handle_main_input;
|
||||||
154
src/main.rs
154
src/main.rs
@@ -1,32 +1,44 @@
|
|||||||
mod app;
|
mod app;
|
||||||
mod telegram;
|
mod input;
|
||||||
|
mod tdlib;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
|
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
|
||||||
execute,
|
execute,
|
||||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||||
backend::CrosstermBackend,
|
|
||||||
Terminal,
|
|
||||||
};
|
|
||||||
use std::io;
|
use std::io;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tdlib_rs::enums::Update;
|
||||||
|
|
||||||
use app::App;
|
use app::{App, AppScreen};
|
||||||
|
use input::{handle_auth_input, handle_main_input};
|
||||||
|
use tdlib::client::AuthState;
|
||||||
|
use utils::disable_tdlib_logs;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<(), io::Error> {
|
||||||
|
// Загружаем переменные окружения из .env
|
||||||
|
let _ = dotenvy::dotenv();
|
||||||
|
|
||||||
|
// Отключаем логи TDLib ДО создания клиента
|
||||||
|
disable_tdlib_logs();
|
||||||
|
|
||||||
|
// Setup terminal
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||||
let backend = CrosstermBackend::new(stdout);
|
let backend = CrosstermBackend::new(stdout);
|
||||||
let mut terminal = Terminal::new(backend)?;
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
// Create app state
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
let res = run_app(&mut terminal, &mut app).await;
|
let res = run_app(&mut terminal, &mut app).await;
|
||||||
|
|
||||||
|
// Restore terminal
|
||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
execute!(
|
execute!(
|
||||||
terminal.backend_mut(),
|
terminal.backend_mut(),
|
||||||
@@ -45,24 +57,120 @@ async fn main() -> Result<()> {
|
|||||||
async fn run_app<B: ratatui::backend::Backend>(
|
async fn run_app<B: ratatui::backend::Backend>(
|
||||||
terminal: &mut Terminal<B>,
|
terminal: &mut Terminal<B>,
|
||||||
app: &mut App,
|
app: &mut App,
|
||||||
) -> Result<()> {
|
) -> io::Result<()> {
|
||||||
loop {
|
// Канал для передачи updates из polling задачи в main loop
|
||||||
terminal.draw(|f| ui::draw(f, app))?;
|
let (update_tx, mut update_rx) = tokio::sync::mpsc::unbounded_channel::<Update>();
|
||||||
|
|
||||||
if event::poll(std::time::Duration::from_millis(100))? {
|
// Запускаем polling TDLib receive() в отдельной задаче
|
||||||
|
tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
// receive() блокирующий, поэтому запускаем в blocking thread
|
||||||
|
let result = tokio::task::spawn_blocking(|| tdlib_rs::receive()).await;
|
||||||
|
if let Ok(Some((update, _client_id))) = result {
|
||||||
|
let _ = update_tx.send(update);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Запускаем инициализацию TDLib в фоне
|
||||||
|
let client_id = app.td_client.client_id();
|
||||||
|
let api_id = app.td_client.api_id;
|
||||||
|
let api_hash = app.td_client.api_hash.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = tdlib_rs::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
|
||||||
|
api_id,
|
||||||
|
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
|
||||||
|
client_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
|
||||||
|
loop {
|
||||||
|
// Обрабатываем updates от TDLib из канала (неблокирующе)
|
||||||
|
while let Ok(update) = update_rx.try_recv() {
|
||||||
|
app.td_client.handle_update(update);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем состояние экрана на основе auth_state
|
||||||
|
update_screen_state(app).await;
|
||||||
|
|
||||||
|
terminal.draw(|f| ui::render(f, app))?;
|
||||||
|
|
||||||
|
// Используем poll для неблокирующего чтения событий
|
||||||
|
if event::poll(Duration::from_millis(100))? {
|
||||||
if let Event::Key(key) = event::read()? {
|
if let Event::Key(key) = event::read()? {
|
||||||
match key.code {
|
// Global quit command
|
||||||
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
|
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||||
KeyCode::Char('1') => app.select_tab(0),
|
return Ok(());
|
||||||
KeyCode::Char('2') => app.select_tab(1),
|
}
|
||||||
KeyCode::Char('3') => app.select_tab(2),
|
|
||||||
KeyCode::Char('4') => app.select_tab(3),
|
match app.screen {
|
||||||
KeyCode::Up => app.previous_chat(),
|
AppScreen::Loading => {
|
||||||
KeyCode::Down => app.next_chat(),
|
// В состоянии загрузки игнорируем ввод
|
||||||
KeyCode::Enter => app.open_chat(),
|
}
|
||||||
_ => {}
|
AppScreen::Auth => handle_auth_input(app, key.code).await,
|
||||||
|
AppScreen::Main => handle_main_input(app, key).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn update_screen_state(app: &mut App) {
|
||||||
|
use tokio::time::timeout;
|
||||||
|
|
||||||
|
let prev_screen = app.screen.clone();
|
||||||
|
|
||||||
|
match &app.td_client.auth_state {
|
||||||
|
AuthState::WaitTdlibParameters => {
|
||||||
|
app.screen = AppScreen::Loading;
|
||||||
|
app.status_message = Some("Инициализация TDLib...".to_string());
|
||||||
|
}
|
||||||
|
AuthState::WaitPhoneNumber | AuthState::WaitCode | AuthState::WaitPassword => {
|
||||||
|
app.screen = AppScreen::Auth;
|
||||||
|
app.is_loading = false;
|
||||||
|
}
|
||||||
|
AuthState::Ready => {
|
||||||
|
if prev_screen != AppScreen::Main {
|
||||||
|
app.screen = AppScreen::Main;
|
||||||
|
app.is_loading = true;
|
||||||
|
app.status_message = Some("Загрузка чатов...".to_string());
|
||||||
|
|
||||||
|
// Запрашиваем загрузку чатов с таймаутом
|
||||||
|
let _ = timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Синхронизируем чаты из td_client в app
|
||||||
|
if !app.td_client.chats.is_empty() {
|
||||||
|
app.chats = app.td_client.chats.clone();
|
||||||
|
if app.chat_list_state.selected().is_none() && !app.chats.is_empty() {
|
||||||
|
app.chat_list_state.select(Some(0));
|
||||||
|
}
|
||||||
|
// Убираем статус загрузки когда чаты появились
|
||||||
|
if app.is_loading {
|
||||||
|
app.is_loading = false;
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AuthState::Closed => {
|
||||||
|
app.status_message = Some("Соединение закрыто".to_string());
|
||||||
|
}
|
||||||
|
AuthState::Error(e) => {
|
||||||
|
app.error_message = Some(e.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
423
src/tdlib/client.rs
Normal file
423
src/tdlib/client.rs
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
use std::env;
|
||||||
|
use tdlib_rs::enums::{AuthorizationState, ChatList, MessageContent, Update, User};
|
||||||
|
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 last_message: String,
|
||||||
|
pub last_message_date: i32,
|
||||||
|
pub unread_count: i32,
|
||||||
|
pub is_pinned: bool,
|
||||||
|
pub order: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct MessageInfo {
|
||||||
|
pub id: i64,
|
||||||
|
pub sender_name: String,
|
||||||
|
pub is_outgoing: bool,
|
||||||
|
pub content: String,
|
||||||
|
pub date: i32,
|
||||||
|
pub is_read: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TdClient {
|
||||||
|
pub auth_state: AuthState,
|
||||||
|
pub api_id: i32,
|
||||||
|
pub api_hash: String,
|
||||||
|
client_id: i32,
|
||||||
|
pub chats: Vec<ChatInfo>,
|
||||||
|
pub current_chat_messages: Vec<MessageInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
impl TdClient {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let api_id: i32 = env::var("API_ID")
|
||||||
|
.unwrap_or_else(|_| "0".to_string())
|
||||||
|
.parse()
|
||||||
|
.unwrap_or(0);
|
||||||
|
let api_hash = env::var("API_HASH").unwrap_or_default();
|
||||||
|
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_authenticated(&self) -> bool {
|
||||||
|
matches!(self.auth_state, AuthState::Ready)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn client_id(&self) -> i32 {
|
||||||
|
self.client_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Инициализация 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), 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пересортируем после обновления
|
||||||
|
self.chats.sort_by(|a, b| b.last_message_date.cmp(&a.last_message_date));
|
||||||
|
}
|
||||||
|
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::NewMessage(_new_msg) => {
|
||||||
|
// Новые сообщения обрабатываются при обновлении UI
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
let (last_message, last_message_date) = td_chat
|
||||||
|
.last_message
|
||||||
|
.as_ref()
|
||||||
|
.map(|m| (extract_message_text_static(m), m.date))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let chat_info = ChatInfo {
|
||||||
|
id: td_chat.id,
|
||||||
|
title: td_chat.title.clone(),
|
||||||
|
last_message,
|
||||||
|
last_message_date,
|
||||||
|
unread_count: td_chat.unread_count,
|
||||||
|
is_pinned: false,
|
||||||
|
order: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
} else {
|
||||||
|
self.chats.push(chat_info);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сортируем чаты по дате последнего сообщения (новые сверху)
|
||||||
|
self.chats.sort_by(|a, b| b.last_message_date.cmp(&a.last_message_date));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn convert_message(&self, message: &TdMessage) -> MessageInfo {
|
||||||
|
let sender_name = match &message.sender_id {
|
||||||
|
tdlib_rs::enums::MessageSender::User(user) => format!("User_{}", user.user_id),
|
||||||
|
tdlib_rs::enums::MessageSender::Chat(chat) => format!("Chat_{}", chat.chat_id),
|
||||||
|
};
|
||||||
|
|
||||||
|
MessageInfo {
|
||||||
|
id: message.id,
|
||||||
|
sender_name,
|
||||||
|
is_outgoing: message.is_outgoing,
|
||||||
|
content: extract_message_text_static(message),
|
||||||
|
date: message.date,
|
||||||
|
is_read: !message.is_outgoing || message.id <= 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Отправка номера телефона
|
||||||
|
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 get_chat_history(
|
||||||
|
&mut self,
|
||||||
|
chat_id: i64,
|
||||||
|
limit: i32,
|
||||||
|
) -> Result<Vec<MessageInfo>, String> {
|
||||||
|
let _ = functions::open_chat(chat_id, self.client_id).await;
|
||||||
|
|
||||||
|
// Загружаем историю с сервера (only_local=false)
|
||||||
|
let result = functions::get_chat_history(
|
||||||
|
chat_id,
|
||||||
|
0, // from_message_id (0 = с последнего сообщения)
|
||||||
|
0, // offset
|
||||||
|
limit,
|
||||||
|
false, // only_local - загружаем с сервера!
|
||||||
|
self.client_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(tdlib_rs::enums::Messages::Messages(messages)) => {
|
||||||
|
let mut result_messages: Vec<MessageInfo> = messages
|
||||||
|
.messages
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|m| m.map(|msg| self.convert_message(&msg)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Сообщения приходят от новых к старым, переворачиваем
|
||||||
|
result_messages.reverse();
|
||||||
|
self.current_chat_messages = result_messages.clone();
|
||||||
|
Ok(result_messages)
|
||||||
|
}
|
||||||
|
Err(e) => Err(format!("Ошибка загрузки сообщений: {:?}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Загрузка старых сообщений (для скролла вверх)
|
||||||
|
pub async fn load_older_messages(
|
||||||
|
&mut self,
|
||||||
|
chat_id: i64,
|
||||||
|
from_message_id: i64,
|
||||||
|
limit: i32,
|
||||||
|
) -> Result<Vec<MessageInfo>, 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<MessageInfo> = messages
|
||||||
|
.messages
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|m| m.map(|msg| self.convert_message(&msg)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Сообщения приходят от новых к старым, переворачиваем
|
||||||
|
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<i64, String> {
|
||||||
|
match functions::get_me(self.client_id).await {
|
||||||
|
Ok(user) => {
|
||||||
|
match user {
|
||||||
|
User::User(u) => Ok(u.id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => Err(format!("Ошибка получения профиля: {:?}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Отправка текстового сообщения
|
||||||
|
pub async fn send_message(&self, chat_id: i64, text: String) -> Result<MessageInfo, String> {
|
||||||
|
use tdlib_rs::types::{FormattedText, InputMessageText};
|
||||||
|
use tdlib_rs::enums::InputMessageContent;
|
||||||
|
|
||||||
|
let content = InputMessageContent::InputMessageText(InputMessageText {
|
||||||
|
text: FormattedText {
|
||||||
|
text: text.clone(),
|
||||||
|
entities: vec![],
|
||||||
|
},
|
||||||
|
link_preview_options: None,
|
||||||
|
clear_draft: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
let result = functions::send_message(
|
||||||
|
chat_id,
|
||||||
|
0, // message_thread_id
|
||||||
|
None, // reply_to
|
||||||
|
None, // options
|
||||||
|
content,
|
||||||
|
self.client_id,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(tdlib_rs::enums::Message::Message(msg)) => {
|
||||||
|
// Конвертируем отправленное сообщение в MessageInfo
|
||||||
|
Ok(MessageInfo {
|
||||||
|
id: msg.id,
|
||||||
|
sender_name: "You".to_string(),
|
||||||
|
is_outgoing: true,
|
||||||
|
content: text,
|
||||||
|
date: msg.date,
|
||||||
|
is_read: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(e) => Err(format!("Ошибка отправки сообщения: {:?}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Статическая функция для извлечения текста сообщения (без &self)
|
||||||
|
fn extract_message_text_static(message: &TdMessage) -> String {
|
||||||
|
match &message.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(_) => "[Видео]".to_string(),
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/tdlib/mod.rs
Normal file
3
src/tdlib/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod client;
|
||||||
|
|
||||||
|
pub use client::TdClient;
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Chat {
|
|
||||||
pub name: String,
|
|
||||||
pub last_message: String,
|
|
||||||
pub unread_count: usize,
|
|
||||||
pub is_pinned: bool,
|
|
||||||
pub is_online: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Message {
|
|
||||||
pub sender: String,
|
|
||||||
pub text: String,
|
|
||||||
pub time: String,
|
|
||||||
pub is_outgoing: bool,
|
|
||||||
pub read_status: u8,
|
|
||||||
}
|
|
||||||
136
src/ui/auth.rs
Normal file
136
src/ui/auth.rs
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
use ratatui::{
|
||||||
|
layout::{Alignment, Constraint, Direction, Layout},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::Line,
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::tdlib::client::AuthState;
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, app: &App) {
|
||||||
|
let area = f.area();
|
||||||
|
|
||||||
|
let vertical_chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage(30),
|
||||||
|
Constraint::Length(15),
|
||||||
|
Constraint::Percentage(30),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
let horizontal_chunks = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage(25),
|
||||||
|
Constraint::Percentage(50),
|
||||||
|
Constraint::Percentage(25),
|
||||||
|
])
|
||||||
|
.split(vertical_chunks[1]);
|
||||||
|
|
||||||
|
let auth_area = horizontal_chunks[1];
|
||||||
|
|
||||||
|
let auth_chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3), // Title
|
||||||
|
Constraint::Length(4), // Instructions
|
||||||
|
Constraint::Length(3), // Input
|
||||||
|
Constraint::Length(2), // Error/Status message
|
||||||
|
Constraint::Min(0), // Spacer
|
||||||
|
])
|
||||||
|
.split(auth_area);
|
||||||
|
|
||||||
|
// Title
|
||||||
|
let title = Paragraph::new("TTUI - Telegram Authentication")
|
||||||
|
.style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.block(Block::default().borders(Borders::ALL));
|
||||||
|
f.render_widget(title, auth_chunks[0]);
|
||||||
|
|
||||||
|
// Instructions and Input based on auth state
|
||||||
|
match &app.td_client.auth_state {
|
||||||
|
AuthState::WaitPhoneNumber => {
|
||||||
|
let instructions = vec![
|
||||||
|
Line::from("Введите номер телефона в международном формате"),
|
||||||
|
Line::from("Пример: +79991111111"),
|
||||||
|
];
|
||||||
|
let instructions_widget = Paragraph::new(instructions)
|
||||||
|
.style(Style::default().fg(Color::Gray))
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.block(Block::default().borders(Borders::NONE));
|
||||||
|
f.render_widget(instructions_widget, auth_chunks[1]);
|
||||||
|
|
||||||
|
let input_text = format!("📱 {}", app.phone_input);
|
||||||
|
let input = Paragraph::new(input_text)
|
||||||
|
.style(Style::default().fg(Color::Yellow))
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(" Phone Number "),
|
||||||
|
);
|
||||||
|
f.render_widget(input, auth_chunks[2]);
|
||||||
|
}
|
||||||
|
AuthState::WaitCode => {
|
||||||
|
let instructions = vec![
|
||||||
|
Line::from("Введите код подтверждения из Telegram"),
|
||||||
|
Line::from("Код был отправлен на ваш номер"),
|
||||||
|
];
|
||||||
|
let instructions_widget = Paragraph::new(instructions)
|
||||||
|
.style(Style::default().fg(Color::Gray))
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.block(Block::default().borders(Borders::NONE));
|
||||||
|
f.render_widget(instructions_widget, auth_chunks[1]);
|
||||||
|
|
||||||
|
let input_text = format!("🔐 {}", app.code_input);
|
||||||
|
let input = Paragraph::new(input_text)
|
||||||
|
.style(Style::default().fg(Color::Yellow))
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(" Verification Code "),
|
||||||
|
);
|
||||||
|
f.render_widget(input, auth_chunks[2]);
|
||||||
|
}
|
||||||
|
AuthState::WaitPassword => {
|
||||||
|
let instructions = vec![
|
||||||
|
Line::from("Введите пароль двухфакторной аутентификации"),
|
||||||
|
Line::from(""),
|
||||||
|
];
|
||||||
|
let instructions_widget = Paragraph::new(instructions)
|
||||||
|
.style(Style::default().fg(Color::Gray))
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.block(Block::default().borders(Borders::NONE));
|
||||||
|
f.render_widget(instructions_widget, auth_chunks[1]);
|
||||||
|
|
||||||
|
let masked_password = "*".repeat(app.password_input.len());
|
||||||
|
let input_text = format!("🔒 {}", masked_password);
|
||||||
|
let input = Paragraph::new(input_text)
|
||||||
|
.style(Style::default().fg(Color::Yellow))
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(" Password "));
|
||||||
|
f.render_widget(input, auth_chunks[2]);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error or status message
|
||||||
|
if let Some(error) = &app.error_message {
|
||||||
|
let error_widget = Paragraph::new(error.as_str())
|
||||||
|
.style(Style::default().fg(Color::Red))
|
||||||
|
.alignment(Alignment::Center);
|
||||||
|
f.render_widget(error_widget, auth_chunks[3]);
|
||||||
|
} else if let Some(status) = &app.status_message {
|
||||||
|
let status_widget = Paragraph::new(status.as_str())
|
||||||
|
.style(Style::default().fg(Color::Yellow))
|
||||||
|
.alignment(Alignment::Center);
|
||||||
|
f.render_widget(status_widget, auth_chunks[3]);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/ui/chat_list.rs
Normal file
61
src/ui/chat_list.rs
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
use ratatui::{
|
||||||
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
widgets::{Block, Borders, List, ListItem, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
use crate::app::App;
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
|
||||||
|
let chat_chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3), // Search box
|
||||||
|
Constraint::Min(0), // Chat list
|
||||||
|
Constraint::Length(3), // User status
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// Search box
|
||||||
|
let search = Paragraph::new("🔍 Search...")
|
||||||
|
.block(Block::default().borders(Borders::ALL))
|
||||||
|
.style(Style::default().fg(Color::DarkGray));
|
||||||
|
f.render_widget(search, chat_chunks[0]);
|
||||||
|
|
||||||
|
// Chat list
|
||||||
|
let items: Vec<ListItem> = app
|
||||||
|
.chats
|
||||||
|
.iter()
|
||||||
|
.map(|chat| {
|
||||||
|
let is_selected = app.selected_chat_id == Some(chat.id);
|
||||||
|
let prefix = if is_selected { "▌ " } else { " " };
|
||||||
|
|
||||||
|
let unread_badge = if chat.unread_count > 0 {
|
||||||
|
format!(" ({})", chat.unread_count)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
let content = format!("{}{}{}", prefix, chat.title, unread_badge);
|
||||||
|
let style = Style::default().fg(Color::White);
|
||||||
|
|
||||||
|
ListItem::new(content).style(style)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let chats_list = List::new(items)
|
||||||
|
.block(Block::default().borders(Borders::ALL))
|
||||||
|
.highlight_style(
|
||||||
|
Style::default()
|
||||||
|
.add_modifier(Modifier::ITALIC)
|
||||||
|
.fg(Color::Yellow),
|
||||||
|
);
|
||||||
|
|
||||||
|
f.render_stateful_widget(chats_list, chat_chunks[1], &mut app.chat_list_state);
|
||||||
|
|
||||||
|
// User status
|
||||||
|
let status = Paragraph::new("[User: Online]")
|
||||||
|
.block(Block::default().borders(Borders::ALL))
|
||||||
|
.style(Style::default().fg(Color::Green));
|
||||||
|
f.render_widget(status, chat_chunks[2]);
|
||||||
|
}
|
||||||
30
src/ui/footer.rs
Normal file
30
src/ui/footer.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
use ratatui::{
|
||||||
|
layout::Rect,
|
||||||
|
style::{Color, Style},
|
||||||
|
widgets::Paragraph,
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
use crate::app::App;
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
let status = if let Some(msg) = &app.status_message {
|
||||||
|
format!(" {} ", msg)
|
||||||
|
} else if let Some(err) = &app.error_message {
|
||||||
|
format!(" Error: {} ", err)
|
||||||
|
} else if app.selected_chat_id.is_some() {
|
||||||
|
" Cmd+j/k: Scroll | Enter: Send | Esc: Close | Ctrl+R: Refresh | Ctrl+C: Quit ".to_string()
|
||||||
|
} else {
|
||||||
|
" Cmd+j/k: Navigate | Ctrl+k: First | Enter: Open | Ctrl+R: Refresh | Ctrl+C: Quit ".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let style = if app.error_message.is_some() {
|
||||||
|
Style::default().fg(Color::Red)
|
||||||
|
} else if app.status_message.is_some() {
|
||||||
|
Style::default().fg(Color::Yellow)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::DarkGray)
|
||||||
|
};
|
||||||
|
|
||||||
|
let footer = Paragraph::new(status).style(style);
|
||||||
|
f.render_widget(footer, area);
|
||||||
|
}
|
||||||
40
src/ui/loading.rs
Normal file
40
src/ui/loading.rs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
use ratatui::{
|
||||||
|
layout::{Alignment, Constraint, Direction, Layout},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
use crate::app::App;
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, app: &App) {
|
||||||
|
let area = f.area();
|
||||||
|
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage(40),
|
||||||
|
Constraint::Length(5),
|
||||||
|
Constraint::Percentage(40),
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
let message = app
|
||||||
|
.status_message
|
||||||
|
.as_deref()
|
||||||
|
.unwrap_or("Загрузка...");
|
||||||
|
|
||||||
|
let loading = Paragraph::new(message)
|
||||||
|
.style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
)
|
||||||
|
.alignment(Alignment::Center)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.title(" TTUI "),
|
||||||
|
);
|
||||||
|
|
||||||
|
f.render_widget(loading, chunks[1]);
|
||||||
|
}
|
||||||
62
src/ui/main_screen.rs
Normal file
62
src/ui/main_screen.rs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
use ratatui::{
|
||||||
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
use crate::app::App;
|
||||||
|
use super::{chat_list, messages, footer};
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, app: &mut App) {
|
||||||
|
let chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3), // Folders/tabs
|
||||||
|
Constraint::Min(0), // Main content
|
||||||
|
Constraint::Length(1), // Commands footer
|
||||||
|
])
|
||||||
|
.split(f.area());
|
||||||
|
|
||||||
|
render_folders(f, chunks[0], app);
|
||||||
|
|
||||||
|
let main_chunks = Layout::default()
|
||||||
|
.direction(Direction::Horizontal)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Percentage(30), // Chat list
|
||||||
|
Constraint::Percentage(70), // Messages area
|
||||||
|
])
|
||||||
|
.split(chunks[1]);
|
||||||
|
|
||||||
|
chat_list::render(f, main_chunks[0], app);
|
||||||
|
messages::render(f, main_chunks[1], app);
|
||||||
|
footer::render(f, chunks[2], app);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_folders(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
let mut spans = vec![];
|
||||||
|
|
||||||
|
for (i, folder) in app.folders.iter().enumerate() {
|
||||||
|
let style = if i == app.selected_folder {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::White)
|
||||||
|
};
|
||||||
|
|
||||||
|
spans.push(Span::styled(format!(" {}:{} ", i + 1, folder), style));
|
||||||
|
if i < app.folders.len() - 1 {
|
||||||
|
spans.push(Span::raw("│"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let folders_line = Line::from(spans);
|
||||||
|
let folders_widget = Paragraph::new(folders_line).block(
|
||||||
|
Block::default()
|
||||||
|
.title(" TTUI ")
|
||||||
|
.borders(Borders::ALL),
|
||||||
|
);
|
||||||
|
|
||||||
|
f.render_widget(folders_widget, area);
|
||||||
|
}
|
||||||
116
src/ui/messages.rs
Normal file
116
src/ui/messages.rs
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
use ratatui::{
|
||||||
|
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
text::{Line, Span},
|
||||||
|
widgets::{Block, Borders, Paragraph},
|
||||||
|
Frame,
|
||||||
|
};
|
||||||
|
use crate::app::App;
|
||||||
|
use crate::utils::format_timestamp;
|
||||||
|
|
||||||
|
pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
||||||
|
if let Some(chat) = app.get_selected_chat() {
|
||||||
|
let message_chunks = Layout::default()
|
||||||
|
.direction(Direction::Vertical)
|
||||||
|
.constraints([
|
||||||
|
Constraint::Length(3), // Chat header
|
||||||
|
Constraint::Min(0), // Messages
|
||||||
|
Constraint::Length(3), // Input box
|
||||||
|
])
|
||||||
|
.split(area);
|
||||||
|
|
||||||
|
// Chat header
|
||||||
|
let header = Paragraph::new(format!("👤 {}", chat.title))
|
||||||
|
.block(Block::default().borders(Borders::ALL))
|
||||||
|
.style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
);
|
||||||
|
f.render_widget(header, message_chunks[0]);
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
let mut lines: Vec<Line> = Vec::new();
|
||||||
|
|
||||||
|
for msg in &app.current_messages {
|
||||||
|
let sender_style = if msg.is_outgoing {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Green)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
};
|
||||||
|
|
||||||
|
let sender_name = if msg.is_outgoing {
|
||||||
|
"You".to_string()
|
||||||
|
} else {
|
||||||
|
msg.sender_name.clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let read_mark = if msg.is_outgoing {
|
||||||
|
if msg.is_read { " ✓✓" } else { " ✓" }
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
|
||||||
|
// Форматируем время
|
||||||
|
let time = format_timestamp(msg.date);
|
||||||
|
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::styled(format!("{} ", sender_name), sender_style),
|
||||||
|
Span::raw("── "),
|
||||||
|
Span::styled(format!("{}{}", time, read_mark), Style::default().fg(Color::DarkGray)),
|
||||||
|
]));
|
||||||
|
lines.push(Line::from(msg.content.clone()));
|
||||||
|
lines.push(Line::from(""));
|
||||||
|
}
|
||||||
|
|
||||||
|
if lines.is_empty() {
|
||||||
|
lines.push(Line::from(Span::styled(
|
||||||
|
"Нет сообщений",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вычисляем скролл с учётом пользовательского offset
|
||||||
|
let visible_height = message_chunks[1].height.saturating_sub(2) as usize;
|
||||||
|
let total_lines = lines.len();
|
||||||
|
|
||||||
|
let base_scroll = if total_lines > visible_height {
|
||||||
|
total_lines - visible_height
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
let scroll_offset = base_scroll.saturating_sub(app.message_scroll_offset) as u16;
|
||||||
|
|
||||||
|
let messages_widget = Paragraph::new(lines)
|
||||||
|
.block(Block::default().borders(Borders::ALL))
|
||||||
|
.scroll((scroll_offset, 0));
|
||||||
|
f.render_widget(messages_widget, message_chunks[1]);
|
||||||
|
|
||||||
|
// Input box
|
||||||
|
let input_text = if app.message_input.is_empty() {
|
||||||
|
"> Введите сообщение...".to_string()
|
||||||
|
} else {
|
||||||
|
format!("> {}", app.message_input)
|
||||||
|
};
|
||||||
|
let input_style = if app.message_input.is_empty() {
|
||||||
|
Style::default().fg(Color::DarkGray)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(Color::Yellow)
|
||||||
|
};
|
||||||
|
let input = Paragraph::new(input_text)
|
||||||
|
.block(Block::default().borders(Borders::ALL))
|
||||||
|
.style(input_style);
|
||||||
|
f.render_widget(input, message_chunks[2]);
|
||||||
|
} else {
|
||||||
|
let empty = Paragraph::new("Выберите чат")
|
||||||
|
.block(Block::default().borders(Borders::ALL))
|
||||||
|
.style(Style::default().fg(Color::DarkGray))
|
||||||
|
.alignment(Alignment::Center);
|
||||||
|
f.render_widget(empty, area);
|
||||||
|
}
|
||||||
|
}
|
||||||
179
src/ui/mod.rs
179
src/ui/mod.rs
@@ -1,170 +1,17 @@
|
|||||||
use crate::app::App;
|
mod loading;
|
||||||
use ratatui::{
|
mod auth;
|
||||||
layout::{Constraint, Direction, Layout, Rect},
|
mod main_screen;
|
||||||
style::{Color, Modifier, Style},
|
mod chat_list;
|
||||||
text::{Line, Span},
|
mod messages;
|
||||||
widgets::{Block, Borders, List, ListItem, Paragraph},
|
mod footer;
|
||||||
Frame,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn draw(f: &mut Frame, app: &App) {
|
use ratatui::Frame;
|
||||||
let chunks = Layout::default()
|
use crate::app::{App, AppScreen};
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Length(3),
|
|
||||||
Constraint::Min(0),
|
|
||||||
Constraint::Length(3),
|
|
||||||
])
|
|
||||||
.split(f.area());
|
|
||||||
|
|
||||||
draw_tabs(f, app, chunks[0]);
|
pub fn render(f: &mut Frame, app: &mut App) {
|
||||||
|
match app.screen {
|
||||||
let main_chunks = Layout::default()
|
AppScreen::Loading => loading::render(f, app),
|
||||||
.direction(Direction::Horizontal)
|
AppScreen::Auth => auth::render(f, app),
|
||||||
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
|
AppScreen::Main => main_screen::render(f, app),
|
||||||
.split(chunks[1]);
|
|
||||||
|
|
||||||
draw_chat_list(f, app, main_chunks[0]);
|
|
||||||
draw_messages(f, app, main_chunks[1]);
|
|
||||||
draw_status_bar(f, app, chunks[2]);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_tabs(f: &mut Frame, app: &App, area: Rect) {
|
|
||||||
let tabs: Vec<Span> = app
|
|
||||||
.tabs
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, t)| {
|
|
||||||
let style = if i == app.selected_tab {
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Yellow)
|
|
||||||
.add_modifier(Modifier::BOLD)
|
|
||||||
} else {
|
|
||||||
Style::default().fg(Color::White)
|
|
||||||
};
|
|
||||||
Span::styled(format!(" {}:{} ", i + 1, t), style)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let tabs_line = Line::from(tabs);
|
|
||||||
let tabs_paragraph = Paragraph::new(tabs_line).block(
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.title("Telegram TUI"),
|
|
||||||
);
|
|
||||||
|
|
||||||
f.render_widget(tabs_paragraph, area);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_chat_list(f: &mut Frame, app: &App, area: Rect) {
|
|
||||||
let chunks = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([Constraint::Length(3), Constraint::Min(0)])
|
|
||||||
.split(area);
|
|
||||||
|
|
||||||
let search = Paragraph::new(format!("🔍 {}", app.search_query))
|
|
||||||
.block(Block::default().borders(Borders::ALL));
|
|
||||||
f.render_widget(search, chunks[0]);
|
|
||||||
|
|
||||||
let items: Vec<ListItem> = app
|
|
||||||
.chats
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, chat)| {
|
|
||||||
let pin_icon = if chat.is_pinned { "📌 " } else { " " };
|
|
||||||
let unread_badge = if chat.unread_count > 0 {
|
|
||||||
format!(" ({})", chat.unread_count)
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
};
|
|
||||||
|
|
||||||
let content = format!("{}{}{}", pin_icon, chat.name, unread_badge);
|
|
||||||
|
|
||||||
let style = if Some(i) == app.selected_chat {
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::Yellow)
|
|
||||||
.add_modifier(Modifier::BOLD)
|
|
||||||
.add_modifier(Modifier::REVERSED)
|
|
||||||
} else if chat.unread_count > 0 {
|
|
||||||
Style::default().fg(Color::Cyan)
|
|
||||||
} else {
|
|
||||||
Style::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
ListItem::new(content).style(style)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let list = List::new(items).block(Block::default().borders(Borders::ALL));
|
|
||||||
|
|
||||||
f.render_widget(list, chunks[1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_messages(f: &mut Frame, app: &App, area: Rect) {
|
|
||||||
let chunks = Layout::default()
|
|
||||||
.direction(Direction::Vertical)
|
|
||||||
.constraints([
|
|
||||||
Constraint::Length(3),
|
|
||||||
Constraint::Min(0),
|
|
||||||
Constraint::Length(3),
|
|
||||||
])
|
|
||||||
.split(area);
|
|
||||||
|
|
||||||
let header = Paragraph::new(app.get_current_chat_name()).block(
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.style(Style::default().fg(Color::White)),
|
|
||||||
);
|
|
||||||
f.render_widget(header, chunks[0]);
|
|
||||||
|
|
||||||
let mut message_lines: Vec<Line> = vec![];
|
|
||||||
|
|
||||||
for msg in &app.messages {
|
|
||||||
message_lines.push(Line::from(""));
|
|
||||||
|
|
||||||
let time_and_name = if msg.is_outgoing {
|
|
||||||
let status = match msg.read_status {
|
|
||||||
2 => "✓✓",
|
|
||||||
1 => "✓",
|
|
||||||
_ => "",
|
|
||||||
};
|
|
||||||
format!("{} ────────────────────────────────────── {} {}",
|
|
||||||
msg.sender, msg.time, status)
|
|
||||||
} else {
|
|
||||||
format!("{} ──────────────────────────────────────── {}",
|
|
||||||
msg.sender, msg.time)
|
|
||||||
};
|
|
||||||
|
|
||||||
let style = if msg.is_outgoing {
|
|
||||||
Style::default().fg(Color::Green)
|
|
||||||
} else {
|
|
||||||
Style::default().fg(Color::Cyan)
|
|
||||||
};
|
|
||||||
|
|
||||||
message_lines.push(Line::from(Span::styled(time_and_name, style)));
|
|
||||||
message_lines.push(Line::from(msg.text.clone()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let messages = Paragraph::new(message_lines)
|
|
||||||
.block(Block::default().borders(Borders::ALL))
|
|
||||||
.style(Style::default().fg(Color::White));
|
|
||||||
|
|
||||||
f.render_widget(messages, chunks[1]);
|
|
||||||
|
|
||||||
let input = Paragraph::new(format!("> {}_", app.input))
|
|
||||||
.block(Block::default().borders(Borders::ALL));
|
|
||||||
f.render_widget(input, chunks[2]);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn draw_status_bar(f: &mut Frame, _app: &App, area: Rect) {
|
|
||||||
let status_text = " Esc: Back | Enter: Open | ^R: Reply | ^E: Edit | ^D: Delete";
|
|
||||||
let status = Paragraph::new(status_text)
|
|
||||||
.style(Style::default().fg(Color::Gray))
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.borders(Borders::TOP)
|
|
||||||
.title("[User: Online]"),
|
|
||||||
);
|
|
||||||
|
|
||||||
f.render_widget(status, area);
|
|
||||||
}
|
}
|
||||||
|
|||||||
47
src/utils.rs
Normal file
47
src/utils.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
use std::ffi::CString;
|
||||||
|
use std::os::raw::c_char;
|
||||||
|
|
||||||
|
#[link(name = "tdjson")]
|
||||||
|
extern "C" {
|
||||||
|
fn td_execute(request: *const c_char) -> *const c_char;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Отключаем логи TDLib синхронно, до создания клиента
|
||||||
|
pub fn disable_tdlib_logs() {
|
||||||
|
let request = r#"{"@type":"setLogVerbosityLevel","new_verbosity_level":0}"#;
|
||||||
|
let c_request = CString::new(request).unwrap();
|
||||||
|
unsafe {
|
||||||
|
let _ = td_execute(c_request.as_ptr());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Также перенаправляем логи в никуда
|
||||||
|
let request2 = r#"{"@type":"setLogStream","log_stream":{"@type":"logStreamEmpty"}}"#;
|
||||||
|
let c_request2 = CString::new(request2).unwrap();
|
||||||
|
unsafe {
|
||||||
|
let _ = td_execute(c_request2.as_ptr());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Форматирование timestamp в человекочитаемый формат
|
||||||
|
pub fn format_timestamp(timestamp: i32) -> String {
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs() as i32;
|
||||||
|
|
||||||
|
let diff = now - timestamp;
|
||||||
|
|
||||||
|
if diff < 60 {
|
||||||
|
"just now".to_string()
|
||||||
|
} else if diff < 3600 {
|
||||||
|
format!("{}m ago", diff / 60)
|
||||||
|
} else if diff < 86400 {
|
||||||
|
format!("{}h ago", diff / 3600)
|
||||||
|
} else {
|
||||||
|
let secs = timestamp as u64;
|
||||||
|
let days = secs / 86400;
|
||||||
|
format!("{}d ago", (now as u64 / 86400) - days)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user