Split core and TUI crates
This commit is contained in:
17
Cargo.lock
generated
17
Cargo.lock
generated
@@ -3942,6 +3942,22 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20b1c6703d2284b9d4ddb620cd350f726a1c43bb6f7801f4361b55db2421caa8"
|
||||
|
||||
[[package]]
|
||||
name = "tele-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
"chrono",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tdlib-rs",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-test",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tele-tui"
|
||||
version = "0.1.0"
|
||||
@@ -3964,6 +3980,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tdlib-rs",
|
||||
"tele-core",
|
||||
"termwright",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
|
||||
69
Cargo.toml
69
Cargo.toml
@@ -1,65 +1,4 @@
|
||||
[package]
|
||||
name = "tele-tui"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["Your Name <your.email@example.com>"]
|
||||
description = "Terminal UI for Telegram with Vim-style navigation"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/your-username/tele-tui"
|
||||
keywords = ["telegram", "tui", "terminal", "cli"]
|
||||
categories = ["command-line-utilities"]
|
||||
default-run = "tele-tui"
|
||||
|
||||
[features]
|
||||
default = ["clipboard", "url-open", "notifications", "images"]
|
||||
clipboard = ["dep:arboard"]
|
||||
url-open = ["dep:open"]
|
||||
notifications = ["dep:notify-rust"]
|
||||
images = ["dep:ratatui-image", "dep:image"]
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
ratatui = "0.29"
|
||||
crossterm = "0.28"
|
||||
tdlib-rs = { version = "1.2.0", features = ["download-tdlib"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
async-trait = "0.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
dotenvy = "0.15"
|
||||
chrono = "0.4"
|
||||
open = { version = "5.0", optional = true }
|
||||
arboard = { version = "3.4", optional = true }
|
||||
notify-rust = { version = "4.11", optional = true }
|
||||
ratatui-image = { version = "8.1", optional = true, features = ["image-defaults"] }
|
||||
image = { version = "0.25", optional = true }
|
||||
toml = "0.8"
|
||||
dirs = "5.0"
|
||||
thiserror = "1.0"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
base64 = "0.22.1"
|
||||
fs2 = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
insta = "1.34"
|
||||
tokio-test = "0.4"
|
||||
criterion = "0.5"
|
||||
termwright = "0.2"
|
||||
|
||||
[[bin]]
|
||||
name = "tele-tui-test-fixture"
|
||||
path = "src/bin/tele-tui-test-fixture.rs"
|
||||
required-features = ["test-support"]
|
||||
|
||||
[[bench]]
|
||||
name = "group_messages"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "formatting"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "format_markdown"
|
||||
harness = false
|
||||
[workspace]
|
||||
members = ["crates/tele-core", "crates/tele-tui"]
|
||||
default-members = ["crates/tele-tui"]
|
||||
resolver = "2"
|
||||
|
||||
@@ -27,9 +27,11 @@ cargo check
|
||||
|
||||
```bash
|
||||
cargo fmt -- --check
|
||||
cargo check --all-targets --all-features
|
||||
cargo clippy --all-targets --all-features -- -D warnings
|
||||
cargo test --all-features
|
||||
cargo check -p tele-core
|
||||
cargo test -p tele-core
|
||||
cargo check -p tele-tui --all-targets --all-features
|
||||
cargo clippy --workspace --all-targets --all-features -- -D warnings
|
||||
cargo test --workspace --all-features
|
||||
git diff --check
|
||||
```
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
- Для голосовых сообщений нужен `ffplay` из ffmpeg.
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
cargo build -p tele-tui --release
|
||||
```
|
||||
|
||||
## Credentials
|
||||
@@ -54,6 +54,10 @@ cargo run --release
|
||||
cargo run --release -- --account work
|
||||
```
|
||||
|
||||
`Cargo.toml` в корне - workspace manifest. По умолчанию `cargo run` и `cargo test`
|
||||
работают с `crates/tele-tui`; переиспользуемая TDLib-логика лежит в
|
||||
`crates/tele-core`.
|
||||
|
||||
Runtime-конфиг создаётся в `~/.config/tele-tui/config.toml`; пример лежит в [config.toml.example](config.toml.example).
|
||||
|
||||
## Документация
|
||||
@@ -64,6 +68,7 @@ Runtime-конфиг создаётся в `~/.config/tele-tui/config.toml`; п
|
||||
- [docs/HOTKEYS.md](docs/HOTKEYS.md) - горячие клавиши.
|
||||
- [docs/PROJECT_STRUCTURE.md](docs/PROJECT_STRUCTURE.md) - карта подсистем.
|
||||
- [docs/TDLIB_INTEGRATION.md](docs/TDLIB_INTEGRATION.md) - проектные заметки по TDLib.
|
||||
- [docs/IOS_CORE_REUSE.md](docs/IOS_CORE_REUSE.md) - граница `tele-core` для будущего iOS-клиента.
|
||||
|
||||
## Лицензия
|
||||
|
||||
|
||||
29
crates/tele-core/Cargo.toml
Normal file
29
crates/tele-core/Cargo.toml
Normal file
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "tele-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["Your Name <your.email@example.com>"]
|
||||
description = "Reusable Telegram/TDLib core for tele-tui"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/your-username/tele-tui"
|
||||
keywords = ["telegram", "tdlib"]
|
||||
categories = ["api-bindings"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
images = []
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
tdlib-rs = { version = "1.2.0", features = ["download-tdlib"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
async-trait = "0.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
chrono = "0.4"
|
||||
thiserror = "1.0"
|
||||
tracing = "0.1"
|
||||
base64 = "0.22.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
5
crates/tele-core/src/accounts/mod.rs
Normal file
5
crates/tele-core/src/accounts/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
//! Account profile data structures and validation.
|
||||
|
||||
pub mod profile;
|
||||
|
||||
pub use profile::{validate_account_name, AccountProfile, AccountsConfig};
|
||||
114
crates/tele-core/src/accounts/profile.rs
Normal file
114
crates/tele-core/src/accounts/profile.rs
Normal file
@@ -0,0 +1,114 @@
|
||||
//! Account profile data structures and validation.
|
||||
//!
|
||||
//! Defines `AccountProfile` and `AccountsConfig` for multi-account support.
|
||||
//! Account names are validated to contain only alphanumeric characters, hyphens, and underscores.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Configuration for all accounts, stored in `~/.config/tele-tui/accounts.toml`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AccountsConfig {
|
||||
/// Name of the default account to use when no `--account` flag is provided.
|
||||
pub default_account: String,
|
||||
|
||||
/// List of configured accounts.
|
||||
pub accounts: Vec<AccountProfile>,
|
||||
}
|
||||
|
||||
/// A single account profile.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AccountProfile {
|
||||
/// Unique identifier (used in directory names and CLI flag).
|
||||
pub name: String,
|
||||
|
||||
/// Human-readable display name.
|
||||
pub display_name: String,
|
||||
}
|
||||
|
||||
impl AccountsConfig {
|
||||
/// Creates a default config with a single "default" account.
|
||||
pub fn default_single() -> Self {
|
||||
Self {
|
||||
default_account: "default".to_string(),
|
||||
accounts: vec![AccountProfile {
|
||||
name: "default".to_string(),
|
||||
display_name: "Default".to_string(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
/// Finds an account by name.
|
||||
pub fn find_account(&self, name: &str) -> Option<&AccountProfile> {
|
||||
self.accounts.iter().find(|a| a.name == name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates an account name.
|
||||
///
|
||||
/// Valid names contain only lowercase alphanumeric characters, hyphens, and underscores.
|
||||
/// Must be 1-32 characters long.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a descriptive error message if the name is invalid.
|
||||
pub fn validate_account_name(name: &str) -> Result<(), String> {
|
||||
if name.is_empty() {
|
||||
return Err("Account name cannot be empty".to_string());
|
||||
}
|
||||
if name.len() > 32 {
|
||||
return Err("Account name cannot be longer than 32 characters".to_string());
|
||||
}
|
||||
if !name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
|
||||
{
|
||||
return Err(
|
||||
"Account name can only contain lowercase letters, digits, hyphens, and underscores"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if name.starts_with('-') || name.starts_with('_') {
|
||||
return Err("Account name cannot start with a hyphen or underscore".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_validate_account_name_valid() {
|
||||
assert!(validate_account_name("default").is_ok());
|
||||
assert!(validate_account_name("work").is_ok());
|
||||
assert!(validate_account_name("my-account").is_ok());
|
||||
assert!(validate_account_name("account_2").is_ok());
|
||||
assert!(validate_account_name("a").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_account_name_invalid() {
|
||||
assert!(validate_account_name("").is_err());
|
||||
assert!(validate_account_name("My Account").is_err());
|
||||
assert!(validate_account_name("UPPER").is_err());
|
||||
assert!(validate_account_name("with spaces").is_err());
|
||||
assert!(validate_account_name("-starts-with-dash").is_err());
|
||||
assert!(validate_account_name("_starts-with-underscore").is_err());
|
||||
assert!(validate_account_name(&"a".repeat(33)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_single_config() {
|
||||
let config = AccountsConfig::default_single();
|
||||
assert_eq!(config.default_account, "default");
|
||||
assert_eq!(config.accounts.len(), 1);
|
||||
assert_eq!(config.accounts[0].name, "default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_account() {
|
||||
let config = AccountsConfig::default_single();
|
||||
assert!(config.find_account("default").is_some());
|
||||
assert!(config.find_account("nonexistent").is_none());
|
||||
}
|
||||
}
|
||||
6
crates/tele-core/src/constants.rs
Normal file
6
crates/tele-core/src/constants.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
pub const MAX_MESSAGES_IN_CHAT: usize = 500;
|
||||
pub const MAX_USER_CACHE_SIZE: usize = 500;
|
||||
pub const MAX_CHATS: usize = 200;
|
||||
pub const MAX_CHAT_USER_IDS: usize = 500;
|
||||
pub const LAZY_LOAD_USERS_PER_TICK: usize = 5;
|
||||
pub const TDLIB_MESSAGE_LIMIT: i32 = 50;
|
||||
11
crates/tele-core/src/lib.rs
Normal file
11
crates/tele-core/src/lib.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
//! Reusable Telegram/TDLib core for tele-tui and future clients.
|
||||
|
||||
mod constants;
|
||||
mod utils;
|
||||
|
||||
pub mod accounts;
|
||||
pub mod message_grouping;
|
||||
pub mod tdlib;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod test_support;
|
||||
pub mod types;
|
||||
@@ -35,10 +35,10 @@ pub enum MessageGroup<'a> {
|
||||
/// # Примеры
|
||||
///
|
||||
/// ```no_run
|
||||
/// use tele_tui::message_grouping::{group_messages, MessageGroup};
|
||||
/// use tele_core::message_grouping::{group_messages, MessageGroup};
|
||||
///
|
||||
/// # use tele_tui::tdlib::types::MessageBuilder;
|
||||
/// # use tele_tui::types::MessageId;
|
||||
/// # use tele_core::tdlib::types::MessageBuilder;
|
||||
/// # use tele_core::types::MessageId;
|
||||
/// # let msg = MessageBuilder::new(MessageId::new(1)).sender_name("Alice").text("Hello").build();
|
||||
/// let messages = vec![msg];
|
||||
/// let grouped = group_messages(&messages);
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::types::{ChatId, MessageId, UserId};
|
||||
use std::env;
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use tdlib_rs::enums::{Chat as TdChat, ChatList, ConnectionState, Update, UserStatus};
|
||||
use tdlib_rs::functions;
|
||||
@@ -13,7 +13,25 @@ use super::types::{
|
||||
ChatInfo, FolderInfo, MessageInfo, NetworkState, ProfileInfo, UserOnlineStatus,
|
||||
};
|
||||
use super::users::UserCache;
|
||||
use crate::notifications::NotificationManager;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TdCredentials {
|
||||
pub api_id: i32,
|
||||
pub api_hash: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TdClientConfig {
|
||||
pub credentials: TdCredentials,
|
||||
pub db_path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IncomingMessageEvent {
|
||||
pub chat: ChatInfo,
|
||||
pub message: MessageInfo,
|
||||
pub sender_name: String,
|
||||
}
|
||||
|
||||
/// TDLib client wrapper for Telegram integration.
|
||||
///
|
||||
@@ -28,9 +46,15 @@ use crate::notifications::NotificationManager;
|
||||
/// # Examples
|
||||
///
|
||||
/// ```ignore
|
||||
/// use tele_tui::tdlib::TdClient;
|
||||
/// use tele_core::tdlib::TdClient;
|
||||
///
|
||||
/// let mut client = TdClient::new(std::path::PathBuf::from("tdlib_data"));
|
||||
/// let mut client = TdClient::new(tele_core::tdlib::TdClientConfig {
|
||||
/// credentials: tele_core::tdlib::TdCredentials {
|
||||
/// api_id: 123,
|
||||
/// api_hash: "hash".to_string(),
|
||||
/// },
|
||||
/// db_path: std::path::PathBuf::from("tdlib_data"),
|
||||
/// });
|
||||
///
|
||||
/// // Start authorization
|
||||
/// client.send_phone_number("+1234567890".to_string()).await?;
|
||||
@@ -52,7 +76,7 @@ pub struct TdClient {
|
||||
pub message_manager: MessageManager,
|
||||
pub user_cache: UserCache,
|
||||
pub reaction_manager: ReactionManager,
|
||||
pub notification_manager: NotificationManager,
|
||||
incoming_message_events: VecDeque<IncomingMessageEvent>,
|
||||
|
||||
// Состояние сети
|
||||
pub network_state: NetworkState,
|
||||
@@ -62,62 +86,41 @@ pub struct TdClient {
|
||||
impl TdClient {
|
||||
/// Creates a new TDLib client instance.
|
||||
///
|
||||
/// Reads API credentials from:
|
||||
/// 1. ~/.config/tele-tui/credentials file
|
||||
/// 2. Environment variables `API_ID` and `API_HASH` (fallback)
|
||||
///
|
||||
/// Initializes all managers and sets initial network state to Connecting.
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A new `TdClient` instance ready for authentication.
|
||||
pub fn new(db_path: PathBuf) -> Self {
|
||||
// Пробуем загрузить credentials из Config (файл или env)
|
||||
let (api_id, api_hash) = crate::config::Config::load_credentials().unwrap_or_else(|_| {
|
||||
// Fallback на прямое чтение из env (старое поведение)
|
||||
let api_id = env::var("API_ID")
|
||||
.unwrap_or_else(|_| "0".to_string())
|
||||
.parse()
|
||||
.unwrap_or(0);
|
||||
let api_hash = env::var("API_HASH").unwrap_or_default();
|
||||
(api_id, api_hash)
|
||||
});
|
||||
|
||||
pub fn new(config: TdClientConfig) -> Self {
|
||||
let client_id = tdlib_rs::create_client();
|
||||
|
||||
Self {
|
||||
api_id,
|
||||
api_hash,
|
||||
db_path,
|
||||
api_id: config.credentials.api_id,
|
||||
api_hash: config.credentials.api_hash,
|
||||
db_path: config.db_path,
|
||||
client_id,
|
||||
auth: AuthManager::new(client_id),
|
||||
chat_manager: ChatManager::new(client_id),
|
||||
message_manager: MessageManager::new(client_id),
|
||||
user_cache: UserCache::new(client_id),
|
||||
reaction_manager: ReactionManager::new(client_id),
|
||||
notification_manager: NotificationManager::new(),
|
||||
incoming_message_events: VecDeque::new(),
|
||||
network_state: NetworkState::Connecting,
|
||||
}
|
||||
}
|
||||
|
||||
/// Configures notification manager from app config
|
||||
pub fn configure_notifications(&mut self, config: &crate::config::NotificationsConfig) {
|
||||
self.notification_manager.set_enabled(config.enabled);
|
||||
self.notification_manager
|
||||
.set_only_mentions(config.only_mentions);
|
||||
self.notification_manager
|
||||
.set_show_preview(config.show_preview);
|
||||
self.notification_manager.set_timeout(config.timeout_ms);
|
||||
self.notification_manager
|
||||
.set_urgency(config.urgency.clone());
|
||||
pub fn enqueue_incoming_message_event(
|
||||
&mut self,
|
||||
chat: ChatInfo,
|
||||
message: MessageInfo,
|
||||
sender_name: String,
|
||||
) {
|
||||
self.incoming_message_events
|
||||
.push_back(IncomingMessageEvent { chat, message, sender_name });
|
||||
}
|
||||
|
||||
/// Synchronizes muted chats from Telegram to notification manager.
|
||||
///
|
||||
/// Should be called after chats are loaded to ensure muted chats don't trigger notifications.
|
||||
pub fn sync_notification_muted_chats(&mut self) {
|
||||
self.notification_manager
|
||||
.sync_muted_chats(&self.chat_manager.chats);
|
||||
pub fn drain_incoming_message_events(&mut self) -> Vec<IncomingMessageEvent> {
|
||||
self.incoming_message_events.drain(..).collect()
|
||||
}
|
||||
|
||||
// Делегирование к auth
|
||||
@@ -791,7 +794,13 @@ impl TdClient {
|
||||
let _ = functions::close(self.client_id).await;
|
||||
|
||||
// 2. Create new client
|
||||
let new_client = TdClient::new(db_path);
|
||||
let new_client = TdClient::new(TdClientConfig {
|
||||
credentials: TdCredentials {
|
||||
api_id: self.api_id,
|
||||
api_hash: self.api_hash.clone(),
|
||||
},
|
||||
db_path,
|
||||
});
|
||||
|
||||
// 3. Spawn set_tdlib_parameters for new client
|
||||
let new_client_id = new_client.client_id;
|
||||
@@ -5,7 +5,7 @@
|
||||
use super::client::TdClient;
|
||||
use super::r#trait::{
|
||||
AccountClient, AuthClient, ChatActionClient, ChatClient, ClientState, FileClient,
|
||||
MessageClient, NotificationClient, ReactionClient, UpdateClient, UserClient,
|
||||
MessageClient, ReactionClient, UpdateClient, UserClient,
|
||||
};
|
||||
use super::{
|
||||
AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache,
|
||||
@@ -313,17 +313,6 @@ impl ClientState for TdClient {
|
||||
}
|
||||
}
|
||||
|
||||
impl NotificationClient for TdClient {
|
||||
fn configure_notifications(&mut self, config: &crate::config::NotificationsConfig) {
|
||||
self.configure_notifications(config);
|
||||
}
|
||||
|
||||
fn sync_notification_muted_chats(&mut self) {
|
||||
self.notification_manager
|
||||
.sync_muted_chats(&self.chat_manager.chats);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AccountClient for TdClient {
|
||||
async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String> {
|
||||
@@ -336,4 +325,8 @@ impl UpdateClient for TdClient {
|
||||
// Delegate to the real implementation
|
||||
TdClient::handle_update(self, update)
|
||||
}
|
||||
|
||||
fn drain_incoming_message_events(&mut self) -> Vec<super::IncomingMessageEvent> {
|
||||
TdClient::drain_incoming_message_events(self)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ mod chat_helpers; // Chat management helpers
|
||||
pub mod chats;
|
||||
pub mod client;
|
||||
mod client_impl; // Private module for trait implementation
|
||||
pub(crate) mod message_conversion; // Message conversion utilities (for messages.rs)
|
||||
pub mod message_conversion; // Message conversion utilities (for messages.rs)
|
||||
mod message_converter; // Message conversion utilities (for client.rs)
|
||||
pub mod messages;
|
||||
pub mod reactions;
|
||||
@@ -19,7 +19,7 @@ pub use client::TdClient;
|
||||
#[allow(unused_imports)]
|
||||
pub use r#trait::{
|
||||
AccountClient, AuthClient, ChatActionClient, ChatClient, ClientState, FileClient,
|
||||
MessageClient, NotificationClient, ReactionClient, TdClientTrait, UpdateClient, UserClient,
|
||||
MessageClient, ReactionClient, TdClientTrait, UpdateClient, UserClient,
|
||||
};
|
||||
#[allow(unused_imports)]
|
||||
pub use types::{
|
||||
@@ -28,6 +28,7 @@ pub use types::{
|
||||
VoiceDownloadState, VoiceInfo,
|
||||
};
|
||||
|
||||
pub use client::{IncomingMessageEvent, TdClientConfig, TdCredentials};
|
||||
#[cfg(feature = "images")]
|
||||
pub use types::ImageModalState;
|
||||
pub use users::UserCache;
|
||||
@@ -3,7 +3,10 @@
|
||||
//! This trait allows tests to use FakeTdClient instead of real TDLib client.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use crate::tdlib::{AuthState, FolderInfo, MessageInfo, ProfileInfo, UserCache, UserOnlineStatus};
|
||||
use crate::tdlib::{
|
||||
AuthState, FolderInfo, IncomingMessageEvent, MessageInfo, ProfileInfo, UserCache,
|
||||
UserOnlineStatus,
|
||||
};
|
||||
use crate::types::{ChatId, MessageId, UserId};
|
||||
use async_trait::async_trait;
|
||||
use std::borrow::Cow;
|
||||
@@ -163,12 +166,6 @@ pub trait ClientState: Send {
|
||||
fn network_state(&self) -> super::types::NetworkState;
|
||||
}
|
||||
|
||||
/// Notification configuration operations.
|
||||
pub trait NotificationClient: Send {
|
||||
fn configure_notifications(&mut self, config: &crate::config::NotificationsConfig);
|
||||
fn sync_notification_muted_chats(&mut self);
|
||||
}
|
||||
|
||||
/// Account switching operations.
|
||||
#[async_trait]
|
||||
pub trait AccountClient: Send {
|
||||
@@ -182,6 +179,7 @@ pub trait AccountClient: Send {
|
||||
/// TDLib update routing.
|
||||
pub trait UpdateClient: Send {
|
||||
fn handle_update(&mut self, update: Update);
|
||||
fn drain_incoming_message_events(&mut self) -> Vec<IncomingMessageEvent>;
|
||||
}
|
||||
|
||||
/// Facade trait for TDLib client operations
|
||||
@@ -198,7 +196,6 @@ pub trait TdClientTrait:
|
||||
+ ReactionClient
|
||||
+ FileClient
|
||||
+ ClientState
|
||||
+ NotificationClient
|
||||
+ AccountClient
|
||||
+ UpdateClient
|
||||
+ Send
|
||||
@@ -214,7 +211,6 @@ impl<T> TdClientTrait for T where
|
||||
+ ReactionClient
|
||||
+ FileClient
|
||||
+ ClientState
|
||||
+ NotificationClient
|
||||
+ AccountClient
|
||||
+ UpdateClient
|
||||
+ Send
|
||||
@@ -312,8 +312,8 @@ impl MessageInfo {
|
||||
/// # Примеры
|
||||
///
|
||||
/// ```
|
||||
/// use tele_tui::tdlib::MessageBuilder;
|
||||
/// use tele_tui::types::MessageId;
|
||||
/// use tele_core::tdlib::MessageBuilder;
|
||||
/// use tele_core::types::MessageId;
|
||||
///
|
||||
/// let message = MessageBuilder::new(MessageId::new(123))
|
||||
/// .sender_name("Alice")
|
||||
@@ -27,12 +27,9 @@ pub fn handle_new_message_update(client: &mut TdClient, new_msg: UpdateNewMessag
|
||||
crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id);
|
||||
|
||||
// Get sender name (from message or user cache)
|
||||
let sender_name = msg_info.sender_name();
|
||||
let sender_name = msg_info.sender_name().to_string();
|
||||
|
||||
// Send notification
|
||||
let _ = client
|
||||
.notification_manager
|
||||
.notify_new_message(&chat, &msg_info, sender_name);
|
||||
client.enqueue_incoming_message_event(chat, msg_info, sender_name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
use super::fake_tdclient::FakeTdClient;
|
||||
use crate::tdlib::{
|
||||
AccountClient, AuthClient, ChatActionClient, ChatClient, ClientState, FileClient,
|
||||
MessageClient, NotificationClient, ReactionClient, UpdateClient, UserClient,
|
||||
MessageClient, ReactionClient, UpdateClient, UserClient,
|
||||
};
|
||||
use crate::tdlib::{
|
||||
AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache,
|
||||
@@ -342,12 +342,6 @@ impl ClientState for FakeTdClient {
|
||||
}
|
||||
}
|
||||
|
||||
impl NotificationClient for FakeTdClient {
|
||||
fn configure_notifications(&mut self, _config: &crate::config::NotificationsConfig) {}
|
||||
|
||||
fn sync_notification_muted_chats(&mut self) {}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AccountClient for FakeTdClient {
|
||||
async fn recreate_client(&mut self, _db_path: PathBuf) -> Result<(), String> {
|
||||
@@ -357,4 +351,8 @@ impl AccountClient for FakeTdClient {
|
||||
|
||||
impl UpdateClient for FakeTdClient {
|
||||
fn handle_update(&mut self, _update: Update) {}
|
||||
|
||||
fn drain_incoming_message_events(&mut self) -> Vec<crate::tdlib::IncomingMessageEvent> {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
7
crates/tele-core/src/test_support/mod.rs
Normal file
7
crates/tele-core/src/test_support/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
//! Core test support for deterministic TDLib fixtures.
|
||||
|
||||
pub mod fake_tdclient;
|
||||
mod fake_tdclient_impl;
|
||||
pub mod test_data;
|
||||
|
||||
pub use fake_tdclient::FakeTdClient;
|
||||
9
crates/tele-core/src/utils.rs
Normal file
9
crates/tele-core/src/utils.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
use chrono::{DateTime, Local, NaiveDate, Utc};
|
||||
|
||||
pub fn get_day(timestamp: i32) -> i64 {
|
||||
let epoch = NaiveDate::from_ymd_opt(1970, 1, 1).expect("valid epoch date");
|
||||
let msg_day = DateTime::<Utc>::from_timestamp(timestamp as i64, 0)
|
||||
.map(|dt| dt.with_timezone(&Local).date_naive())
|
||||
.unwrap_or(epoch);
|
||||
msg_day.signed_duration_since(epoch).num_days()
|
||||
}
|
||||
66
crates/tele-tui/Cargo.toml
Normal file
66
crates/tele-tui/Cargo.toml
Normal file
@@ -0,0 +1,66 @@
|
||||
[package]
|
||||
name = "tele-tui"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
authors = ["Your Name <your.email@example.com>"]
|
||||
description = "Terminal UI for Telegram with Vim-style navigation"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/your-username/tele-tui"
|
||||
keywords = ["telegram", "tui", "terminal", "cli"]
|
||||
categories = ["command-line-utilities"]
|
||||
default-run = "tele-tui"
|
||||
|
||||
[features]
|
||||
default = ["clipboard", "url-open", "notifications", "images"]
|
||||
clipboard = ["dep:arboard"]
|
||||
url-open = ["dep:open"]
|
||||
notifications = ["dep:notify-rust"]
|
||||
images = ["dep:ratatui-image", "dep:image", "tele-core/images"]
|
||||
test-support = ["tele-core/test-support"]
|
||||
|
||||
[dependencies]
|
||||
tele-core = { path = "../tele-core" }
|
||||
ratatui = "0.29"
|
||||
crossterm = "0.28"
|
||||
tdlib-rs = { version = "1.2.0", features = ["download-tdlib"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
async-trait = "0.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
dotenvy = "0.15"
|
||||
chrono = "0.4"
|
||||
open = { version = "5.0", optional = true }
|
||||
arboard = { version = "3.4", optional = true }
|
||||
notify-rust = { version = "4.11", optional = true }
|
||||
ratatui-image = { version = "8.1", optional = true, features = ["image-defaults"] }
|
||||
image = { version = "0.25", optional = true }
|
||||
toml = "0.8"
|
||||
dirs = "5.0"
|
||||
thiserror = "1.0"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
base64 = "0.22.1"
|
||||
fs2 = "0.4"
|
||||
|
||||
[dev-dependencies]
|
||||
insta = "1.34"
|
||||
tokio-test = "0.4"
|
||||
criterion = "0.5"
|
||||
termwright = "0.2"
|
||||
|
||||
[[bin]]
|
||||
name = "tele-tui-test-fixture"
|
||||
path = "src/bin/tele-tui-test-fixture.rs"
|
||||
required-features = ["test-support"]
|
||||
|
||||
[[bench]]
|
||||
name = "group_messages"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "formatting"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "format_markdown"
|
||||
harness = false
|
||||
@@ -13,7 +13,12 @@ fn main() {
|
||||
fn tdlib_lib_dirs() -> Vec<PathBuf> {
|
||||
let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap());
|
||||
let profile = env::var("PROFILE").unwrap_or_else(|_| "debug".to_string());
|
||||
let build_dir = manifest_dir.join("target").join(profile).join("build");
|
||||
let workspace_dir = manifest_dir
|
||||
.parent()
|
||||
.and_then(Path::parent)
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or(manifest_dir);
|
||||
let build_dir = workspace_dir.join("target").join(profile).join("build");
|
||||
|
||||
let Ok(entries) = fs::read_dir(build_dir) else {
|
||||
return Vec::new();
|
||||
@@ -15,7 +15,9 @@ pub use methods::*;
|
||||
pub use state::AppScreen;
|
||||
|
||||
use crate::accounts::AccountProfile;
|
||||
use crate::notifications::NotificationManager;
|
||||
use crate::tdlib::{ChatInfo, TdClient, TdClientTrait};
|
||||
use crate::tdlib::{TdClientConfig, TdCredentials};
|
||||
use crate::types::{ChatId, MessageId};
|
||||
use ratatui::widgets::ListState;
|
||||
use std::path::PathBuf;
|
||||
@@ -102,6 +104,7 @@ pub struct App<T: TdClientTrait = TdClient> {
|
||||
config: crate::config::Config,
|
||||
pub screen: AppScreen,
|
||||
pub td_client: T,
|
||||
pub notification_manager: NotificationManager,
|
||||
/// Состояние чата - type-safe state machine (новое!)
|
||||
pub chat_state: ChatState,
|
||||
/// Vim-like input mode: Normal (navigation) / Insert (text input)
|
||||
@@ -205,11 +208,13 @@ impl<T: TdClientTrait> App<T> {
|
||||
let inline_image_renderer = crate::media::image_renderer::ImageRenderer::new_fast();
|
||||
#[cfg(feature = "images")]
|
||||
let modal_image_renderer = crate::media::image_renderer::ImageRenderer::new();
|
||||
let notification_manager = NotificationManager::from_config(&config.notifications);
|
||||
|
||||
App {
|
||||
config,
|
||||
screen: AppScreen::Loading,
|
||||
td_client,
|
||||
notification_manager,
|
||||
chat_state: ChatState::Normal,
|
||||
input_mode: InputMode::Normal,
|
||||
phone_input: String::new(),
|
||||
@@ -613,8 +618,18 @@ impl App<TdClient> {
|
||||
///
|
||||
/// A new `App<TdClient>` instance ready to start authentication.
|
||||
pub fn new(config: crate::config::Config, db_path: std::path::PathBuf) -> App<TdClient> {
|
||||
let mut td_client = TdClient::new(db_path);
|
||||
td_client.configure_notifications(&config.notifications);
|
||||
let (api_id, api_hash) = crate::config::Config::load_credentials().unwrap_or_else(|_| {
|
||||
let api_id = std::env::var("API_ID")
|
||||
.unwrap_or_else(|_| "0".to_string())
|
||||
.parse()
|
||||
.unwrap_or(0);
|
||||
let api_hash = std::env::var("API_HASH").unwrap_or_default();
|
||||
(api_id, api_hash)
|
||||
});
|
||||
let td_client = TdClient::new(TdClientConfig {
|
||||
credentials: TdCredentials { api_id, api_hash },
|
||||
db_path,
|
||||
});
|
||||
App::with_client(config, td_client)
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,19 @@
|
||||
// ============================================================================
|
||||
|
||||
/// Максимальное количество сообщений в одном чате (для оптимизации памяти)
|
||||
#[allow(dead_code)]
|
||||
pub const MAX_MESSAGES_IN_CHAT: usize = 500;
|
||||
|
||||
/// Максимальный размер кэша пользователей (LRU)
|
||||
#[allow(dead_code)]
|
||||
pub const MAX_USER_CACHE_SIZE: usize = 500;
|
||||
|
||||
/// Максимальное количество чатов для загрузки
|
||||
#[allow(dead_code)]
|
||||
pub const MAX_CHATS: usize = 200;
|
||||
|
||||
/// Максимальное количество user_ids для хранения в чате
|
||||
#[allow(dead_code)]
|
||||
pub const MAX_CHAT_USER_IDS: usize = 500;
|
||||
|
||||
// ============================================================================
|
||||
@@ -27,6 +31,7 @@ pub const POLL_TIMEOUT_MS: u64 = 16;
|
||||
pub const SHUTDOWN_TIMEOUT_SECS: u64 = 2;
|
||||
|
||||
/// Количество пользователей для ленивой загрузки за один тик
|
||||
#[allow(dead_code)]
|
||||
pub const LAZY_LOAD_USERS_PER_TICK: usize = 5;
|
||||
|
||||
// ============================================================================
|
||||
@@ -34,6 +39,7 @@ pub const LAZY_LOAD_USERS_PER_TICK: usize = 5;
|
||||
// ============================================================================
|
||||
|
||||
/// Лимит количества сообщений для загрузки через TDLib за раз
|
||||
#[allow(dead_code)]
|
||||
pub const TDLIB_MESSAGE_LIMIT: i32 = 50;
|
||||
|
||||
// ============================================================================
|
||||
@@ -50,7 +50,8 @@ pub async fn handle_global_commands<T: TdClientTrait>(app: &mut App<T>, key: Key
|
||||
let _ =
|
||||
with_timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await;
|
||||
// Синхронизируем muted чаты после обновления
|
||||
app.td_client.sync_notification_muted_chats();
|
||||
app.notification_manager
|
||||
.sync_muted_chats(app.td_client.chats());
|
||||
app.status_message = None;
|
||||
true
|
||||
}
|
||||
@@ -11,11 +11,10 @@ pub mod formatting;
|
||||
pub mod input;
|
||||
#[cfg(feature = "images")]
|
||||
pub mod media;
|
||||
pub mod message_grouping;
|
||||
pub mod notifications;
|
||||
pub mod tdlib;
|
||||
#[cfg(any(test, feature = "test-support"))]
|
||||
pub mod test_support;
|
||||
pub mod types;
|
||||
pub mod ui;
|
||||
pub mod utils;
|
||||
|
||||
pub use tele_core::{message_grouping, tdlib, types};
|
||||
@@ -7,13 +7,12 @@ mod formatting;
|
||||
mod input;
|
||||
#[cfg(feature = "images")]
|
||||
mod media;
|
||||
mod message_grouping;
|
||||
mod notifications;
|
||||
mod tdlib;
|
||||
mod types;
|
||||
mod ui;
|
||||
mod utils;
|
||||
|
||||
pub use tele_core::{message_grouping, tdlib, types};
|
||||
|
||||
use crossterm::{
|
||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
|
||||
execute,
|
||||
@@ -202,6 +201,13 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
||||
);
|
||||
}
|
||||
}
|
||||
for event in app.td_client.drain_incoming_message_events() {
|
||||
let _ = app.notification_manager.notify_new_message(
|
||||
&event.chat,
|
||||
&event.message,
|
||||
&event.sender_name,
|
||||
);
|
||||
}
|
||||
|
||||
// Помечаем UI как требующий перерисовки если были обновления
|
||||
if had_updates {
|
||||
@@ -437,7 +443,7 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
|
||||
continue;
|
||||
}
|
||||
let notifications_cfg = app.config().notifications.clone();
|
||||
app.td_client.configure_notifications(¬ifications_cfg);
|
||||
app.notification_manager.configure(¬ifications_cfg);
|
||||
|
||||
// 4. Reset app state
|
||||
app.current_account_name = account_name.clone();
|
||||
@@ -509,7 +515,8 @@ async fn update_screen_state<T: tdlib::TdClientTrait>(app: &mut App<T>) -> bool
|
||||
app.chat_list_state.select(Some(0));
|
||||
}
|
||||
// Синхронизируем muted чаты для notifications
|
||||
app.td_client.sync_notification_muted_chats();
|
||||
app.notification_manager
|
||||
.sync_muted_chats(app.td_client.chats());
|
||||
// Убираем статус загрузки когда чаты появились
|
||||
if app.is_loading {
|
||||
app.is_loading = false;
|
||||
@@ -52,6 +52,22 @@ impl NotificationManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a notification manager from application config.
|
||||
pub fn from_config(config: &crate::config::NotificationsConfig) -> Self {
|
||||
let mut manager = Self::new();
|
||||
manager.configure(config);
|
||||
manager
|
||||
}
|
||||
|
||||
/// Applies notification settings from application config.
|
||||
pub fn configure(&mut self, config: &crate::config::NotificationsConfig) {
|
||||
self.set_enabled(config.enabled);
|
||||
self.set_only_mentions(config.only_mentions);
|
||||
self.set_show_preview(config.show_preview);
|
||||
self.set_timeout(config.timeout_ms);
|
||||
self.set_urgency(config.urgency.clone());
|
||||
}
|
||||
|
||||
/// Sets whether notifications are enabled
|
||||
pub fn set_enabled(&mut self, enabled: bool) {
|
||||
self.enabled = enabled;
|
||||
@@ -1,9 +1,6 @@
|
||||
//! Test-only support for deterministic UI fixtures and integration tests.
|
||||
|
||||
pub mod app_builder;
|
||||
pub mod fake_tdclient;
|
||||
mod fake_tdclient_impl;
|
||||
pub mod snapshot_utils;
|
||||
pub mod test_data;
|
||||
|
||||
pub use fake_tdclient::FakeTdClient;
|
||||
pub use tele_core::test_support::{fake_tdclient, test_data, FakeTdClient};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user