This commit is contained in:
Mikhail Kilin
2026-01-18 19:52:44 +03:00
parent 2edbc33afb
commit 7540a30e06
20 changed files with 3285 additions and 843 deletions

2
.env Normal file
View File

@@ -0,0 +1,2 @@
API_ID=36457397
API_HASH=f74f670f33f3fa30a89b46c58dac2ff7

29
CLAUDE.md Normal file
View 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 тестированию

1887
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -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"] }

View File

@@ -62,7 +62,10 @@
4) "command + 1", "command + 2" и так далее - переключение между папками, которые созданы в телеграме 4) "command + 1", "command + 2" и так далее - переключение между папками, которые созданы в телеграме
5) из интерфейса "**message**" - это инпут для ввода сообщения в открытый чат 5) из интерфейса "**message**" - это инпут для ввода сообщения в открытый чат
6) ctrl + s - фокус в инпут поиска чата 6) ctrl + s - фокус в инпут поиска чата
7) `**commands**` - сюда вставь описания команд, которые есть в приложении 7) Esc - закрытие открытого чата
8) command + стрелка вверх (или ctrl + k) - выделяем самый верхний чат (без открытия)
9) поддержка русской раскладки: "р о л д" соответствует "h j k l"
10) `**commands**` - сюда вставь описания команд, которые есть в приложении
## Технологии ## Технологии
Пишем на rust-е Пишем на rust-е

3
build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tdlib_rs::build::build(None);
}

View File

@@ -1,20 +0,0 @@
# Telegram TUI
## Prompt
Проект - TUI интерфейс для телеграмма
Порядок чтения:
1) DEVELOPMENT.md - правило работы (обязательно)
2) CONTEXT.md - текущий статус
3) ROADMAP.md - план и задачи
4) REQUIREMENTS.md / ARCHITECTURE.md - по необходимости
5) E2E_TESTS.md - перед написанием тестов
После работы обнови CONTEXT.md файл
После прочтения скажи "Жду инструкций"
## Архитектура
пока нет, никак не ограничиваю

636
docs/TDLIB_INTEGRATION.md Normal file
View 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. ⬜ Реализовать обработку обновлений в реальном времени

View File

@@ -1,201 +0,0 @@
use crate::telegram::{Chat, Message};
#[derive(Debug)]
pub struct App {
pub tabs: Vec<String>,
pub selected_tab: usize,
pub chats: Vec<Chat>,
pub selected_chat: Option<usize>,
pub messages: Vec<Message>,
pub input: String,
pub search_query: String,
}
impl App {
pub fn new() -> Self {
Self {
tabs: vec![
"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) {
if index < self.tabs.len() {
self.selected_tab = index;
}
}
pub fn next_chat(&mut self) {
if !self.chats.is_empty() {
self.selected_chat = Some(
self.selected_chat
.map(|i| (i + 1) % self.chats.len())
.unwrap_or(0),
);
self.load_messages();
}
}
pub fn previous_chat(&mut self) {
if !self.chats.is_empty() {
self.selected_chat = Some(
self.selected_chat
.map(|i| if i == 0 { self.chats.len() - 1 } else { i - 1 })
.unwrap_or(0),
);
self.load_messages();
}
}
pub fn open_chat(&mut self) {
self.load_messages();
}
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 {
format!("👤 {}", chat.name)
}
})
.unwrap_or_default()
}
}
impl Default for App {
fn default() -> Self {
Self::new()
}
}

View File

@@ -1,32 +1,190 @@
mod app; mod tdlib;
mod telegram;
mod ui;
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, backend::CrosstermBackend,
Terminal, layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
Frame, Terminal,
}; };
use std::ffi::CString;
use std::io; use std::io;
use std::os::raw::c_char;
use std::sync::mpsc;
use std::time::Duration;
use tdlib::client::{AuthState, ChatInfo, MessageInfo};
use tdlib::TdClient;
use tdlib_rs::enums::Update;
use app::App; // FFI для синхронного вызова TDLib (отключение логов до создания клиента)
#[link(name = "tdjson")]
extern "C" {
fn td_execute(request: *const c_char) -> *const c_char;
}
/// Отключаем логи TDLib синхронно, до создания клиента
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());
}
}
#[derive(PartialEq, Clone)]
enum AppScreen {
Loading,
Auth,
Main,
}
struct App {
screen: AppScreen,
td_client: TdClient,
// Auth state
phone_input: String,
code_input: String,
password_input: String,
error_message: Option<String>,
status_message: Option<String>,
// Main app state
chats: Vec<ChatInfo>,
chat_list_state: ListState,
selected_chat: Option<usize>,
current_messages: Vec<MessageInfo>,
folders: Vec<String>,
selected_folder: usize,
is_loading: bool,
}
impl App {
fn new() -> App {
let mut state = ListState::default();
state.select(Some(0));
App {
screen: AppScreen::Loading,
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: None,
current_messages: Vec::new(),
folders: vec!["All".to_string()],
selected_folder: 0,
is_loading: true,
}
}
fn next_chat(&mut self) {
if self.chats.is_empty() {
return;
}
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));
}
fn previous_chat(&mut self) {
if self.chats.is_empty() {
return;
}
let i = match self.chat_list_state.selected() {
Some(i) => {
if i == 0 {
self.chats.len() - 1
} else {
i - 1
}
}
None => 0,
};
self.chat_list_state.select(Some(i));
}
fn select_current_chat(&mut self) {
if let Some(i) = self.chat_list_state.selected() {
if i < self.chats.len() {
self.selected_chat = Some(i);
}
}
}
fn close_chat(&mut self) {
self.selected_chat = None;
self.current_messages.clear();
}
fn select_first_chat(&mut self) {
if !self.chats.is_empty() {
self.chat_list_state.select(Some(0));
}
}
fn get_selected_chat_id(&self) -> Option<i64> {
self.selected_chat
.and_then(|idx| self.chats.get(idx))
.map(|chat| chat.id)
}
}
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<(), io::Error> {
// Загружаем переменные окружения из .env
let _ = dotenvy::dotenv();
// Отключаем логи TDLib ДО создания клиента
disable_tdlib_logs();
// Запускаем поток для получения updates от TDLib
let (update_tx, update_rx) = mpsc::channel::<Update>();
std::thread::spawn(move || {
loop {
if let Some((update, _client_id)) = tdlib_rs::receive() {
if update_tx.send(update).is_err() {
break; // Канал закрыт, выходим
}
}
}
});
// 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, update_rx).await;
// Restore terminal
disable_raw_mode()?; disable_raw_mode()?;
execute!( execute!(
terminal.backend_mut(), terminal.backend_mut(),
@@ -45,24 +203,635 @@ 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<()> { update_rx: mpsc::Receiver<Update>,
loop { ) -> io::Result<()> {
terminal.draw(|f| ui::draw(f, app))?; // Инициализируем TDLib
if let Err(e) = app.td_client.init().await {
app.error_message = Some(e);
}
if event::poll(std::time::Duration::from_millis(100))? { loop {
// Обрабатываем все доступные обновления от 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(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) {
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());
// Запрашиваем загрузку чатов (они придут через updates)
if let Err(e) = app.td_client.load_chats(50).await {
app.error_message = Some(e);
}
app.is_loading = false;
app.status_message = None;
}
// Синхронизируем чаты из td_client в app
if !app.td_client.chats.is_empty() && app.chats.len() != app.td_client.chats.len() {
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));
}
}
}
AuthState::Closed => {
app.status_message = Some("Соединение закрыто".to_string());
}
AuthState::Error(e) => {
app.error_message = Some(e.clone());
}
}
}
async fn handle_auth_input(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 app.td_client.send_phone_number(app.phone_input.clone()).await {
Ok(_) => {
app.error_message = None;
app.status_message = None;
}
Err(e) => {
app.error_message = Some(e);
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 app.td_client.send_code(app.code_input.clone()).await {
Ok(_) => {
app.error_message = None;
app.status_message = None;
}
Err(e) => {
app.error_message = Some(e);
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 app.td_client.send_password(app.password_input.clone()).await {
Ok(_) => {
app.error_message = None;
app.status_message = None;
}
Err(e) => {
app.error_message = Some(e);
app.status_message = None;
}
}
}
}
_ => {}
},
_ => {} _ => {}
} }
} }
async fn handle_main_input(app: &mut App, key: event::KeyEvent) {
let has_super = key.modifiers.contains(KeyModifiers::SUPER);
let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
// Navigate down: j, Down, д (Russian)
KeyCode::Char('j') | KeyCode::Char('д') | KeyCode::Down if !has_super && !has_ctrl => {
app.next_chat();
}
// Navigate up: k, Up, л (Russian)
KeyCode::Char('k') | KeyCode::Char('л') | KeyCode::Up if !has_super && !has_ctrl => {
app.previous_chat();
}
// Jump to first chat: Cmd+Up or Ctrl+k/л
KeyCode::Up if has_super => {
app.select_first_chat();
}
KeyCode::Char('k') | KeyCode::Char('л') if has_ctrl => {
app.select_first_chat();
}
KeyCode::Enter => {
let prev_selected = app.selected_chat;
app.select_current_chat();
// Если выбрали новый чат, загружаем историю
if app.selected_chat != prev_selected {
if let Some(chat_id) = app.get_selected_chat_id() {
app.status_message = Some("Загрузка сообщений...".to_string());
match app.td_client.get_chat_history(chat_id, 30).await {
Ok(messages) => {
app.current_messages = messages;
app.status_message = None;
}
Err(e) => {
app.error_message = Some(e);
app.status_message = None;
} }
} }
} }
}
}
KeyCode::Esc => {
app.close_chat();
}
KeyCode::Char('r') if has_ctrl => {
// Обновить список чатов
app.status_message = Some("Обновление чатов...".to_string());
if let Err(e) = app.td_client.load_chats(50).await {
app.error_message = Some(e);
}
app.status_message = None;
}
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;
}
}
_ => {}
}
}
fn ui(f: &mut Frame, app: &mut App) {
match app.screen {
AppScreen::Loading => render_loading_screen(f, app),
AppScreen::Auth => render_auth_screen(f, app),
AppScreen::Main => render_main_screen(f, app),
}
}
fn render_loading_screen(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]);
}
fn render_auth_screen(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]);
}
}
fn render_main_screen(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]);
render_chat_list(f, main_chunks[0], app);
render_messages(f, main_chunks[1], app);
render_footer(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);
}
fn render_chat_list(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()
.enumerate()
.map(|(idx, chat)| {
let is_selected = app.selected_chat == Some(idx);
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]);
}
fn render_messages(f: &mut Frame, area: Rect, app: &App) {
if let Some(chat_idx) = app.selected_chat {
if let Some(chat) = app.chats.get(chat_idx) {
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),
)));
}
let messages_widget =
Paragraph::new(lines).block(Block::default().borders(Borders::ALL));
f.render_widget(messages_widget, message_chunks[1]);
// Input box
let input = Paragraph::new("> ...")
.block(Block::default().borders(Borders::ALL))
.style(Style::default().fg(Color::Yellow));
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);
}
}
fn render_footer(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 {
" j/k: Navigate | Ctrl+k: First | Enter: Open | Esc: Close | 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);
}
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)
}
}

331
src/tdlib/client.rs Normal file
View File

@@ -0,0 +1,331 @@
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)]
pub enum AuthState {
WaitTdlibParameters,
WaitPhoneNumber,
WaitCode,
WaitPassword,
Ready,
Closed,
Error(String),
}
#[derive(Debug, Clone)]
pub struct ChatInfo {
pub id: i64,
pub title: String,
pub last_message: String,
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>,
}
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)
}
/// Инициализация 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 (String, not Vec)
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 = update
.last_message
.as_ref()
.map(|msg| extract_message_text_static(msg))
.unwrap_or_default();
if let Some(chat) = self.chats.iter_mut().find(|c| c.id == chat_id) {
chat.last_message = last_message_text;
}
}
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 = td_chat
.last_message
.as_ref()
.map(|m| extract_message_text_static(m))
.unwrap_or_default();
let chat_info = ChatInfo {
id: td_chat.id,
title: td_chat.title.clone(),
last_message,
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.unread_count = chat_info.unread_count;
} else {
self.chats.push(chat_info);
}
}
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;
let result = functions::get_chat_history(
chat_id,
0,
0,
limit,
false,
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)),
}
}
/// Получение информации о пользователе по 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)),
}
}
}
/// Статическая функция для извлечения текста сообщения (без &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
View File

@@ -0,0 +1,3 @@
pub mod client;
pub use client::TdClient;

View File

@@ -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,
}

View File

@@ -1,170 +0,0 @@
use crate::app::App;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame,
};
pub fn draw(f: &mut Frame, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(0),
Constraint::Length(3),
])
.split(f.area());
draw_tabs(f, app, chunks[0]);
let main_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(30), Constraint::Percentage(70)])
.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);
}

BIN
tdlib_data/db.sqlite Normal file

Binary file not shown.

BIN
tdlib_data/db.sqlite-shm Normal file

Binary file not shown.

BIN
tdlib_data/db.sqlite-wal Normal file

Binary file not shown.

BIN
tdlib_data/td.binlog Normal file

Binary file not shown.