fixes
Some checks failed
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled

This commit is contained in:
Mikhail Kilin
2026-01-28 01:29:03 +03:00
parent f291191577
commit 051c4a0265
29 changed files with 2189 additions and 45 deletions

View File

@@ -7,6 +7,7 @@ use crate::tdlib::client::ChatInfo;
use crate::tdlib::TdClient;
pub struct App {
pub config: crate::config::Config,
pub screen: AppScreen,
pub td_client: TdClient,
// Auth state
@@ -88,11 +89,12 @@ pub struct App {
impl App {
pub fn new() -> App {
pub fn new(config: crate::config::Config) -> App {
let mut state = ListState::default();
state.select(Some(0));
App {
config,
screen: AppScreen::Loading,
td_client: TdClient::new(),
phone_input: String::new(),

265
src/config.rs Normal file
View File

@@ -0,0 +1,265 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub general: GeneralConfig,
#[serde(default)]
pub colors: ColorsConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GeneralConfig {
/// Часовой пояс в формате "+03:00" или "-05:00"
#[serde(default = "default_timezone")]
pub timezone: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ColorsConfig {
/// Цвет входящих сообщений (white, gray, cyan и т.д.)
#[serde(default = "default_incoming_color")]
pub incoming_message: String,
/// Цвет исходящих сообщений
#[serde(default = "default_outgoing_color")]
pub outgoing_message: String,
/// Цвет выбранного сообщения
#[serde(default = "default_selected_color")]
pub selected_message: String,
/// Цвет своих реакций
#[serde(default = "default_reaction_chosen_color")]
pub reaction_chosen: String,
/// Цвет чужих реакций
#[serde(default = "default_reaction_other_color")]
pub reaction_other: String,
}
// Дефолтные значения
fn default_timezone() -> String {
"+03:00".to_string()
}
fn default_incoming_color() -> String {
"white".to_string()
}
fn default_outgoing_color() -> String {
"green".to_string()
}
fn default_selected_color() -> String {
"yellow".to_string()
}
fn default_reaction_chosen_color() -> String {
"yellow".to_string()
}
fn default_reaction_other_color() -> String {
"gray".to_string()
}
impl Default for GeneralConfig {
fn default() -> Self {
Self {
timezone: default_timezone(),
}
}
}
impl Default for ColorsConfig {
fn default() -> Self {
Self {
incoming_message: default_incoming_color(),
outgoing_message: default_outgoing_color(),
selected_message: default_selected_color(),
reaction_chosen: default_reaction_chosen_color(),
reaction_other: default_reaction_other_color(),
}
}
}
impl Default for Config {
fn default() -> Self {
Self {
general: GeneralConfig::default(),
colors: ColorsConfig::default(),
}
}
}
impl Config {
/// Путь к конфигурационному файлу
pub fn config_path() -> Option<PathBuf> {
dirs::config_dir().map(|mut path| {
path.push("tele-tui");
path.push("config.toml");
path
})
}
/// Путь к директории конфигурации
pub fn config_dir() -> Option<PathBuf> {
dirs::config_dir().map(|mut path| {
path.push("tele-tui");
path
})
}
/// Загрузить конфигурацию из файла
pub fn load() -> Self {
let config_path = match Self::config_path() {
Some(path) => path,
None => {
eprintln!("Warning: Could not determine config directory, using defaults");
return Self::default();
}
};
if !config_path.exists() {
// Создаём дефолтный конфиг при первом запуске
let default_config = Self::default();
if let Err(e) = default_config.save() {
eprintln!("Warning: Could not create default config: {}", e);
}
return default_config;
}
match fs::read_to_string(&config_path) {
Ok(content) => {
match toml::from_str::<Config>(&content) {
Ok(config) => config,
Err(e) => {
eprintln!("Warning: Could not parse config file: {}", e);
Self::default()
}
}
}
Err(e) => {
eprintln!("Warning: Could not read config file: {}", e);
Self::default()
}
}
}
/// Сохранить конфигурацию в файл
pub fn save(&self) -> Result<(), String> {
let config_dir = Self::config_dir()
.ok_or_else(|| "Could not determine config directory".to_string())?;
// Создаём директорию если её нет
fs::create_dir_all(&config_dir)
.map_err(|e| format!("Could not create config directory: {}", e))?;
let config_path = config_dir.join("config.toml");
let toml_string = toml::to_string_pretty(self)
.map_err(|e| format!("Could not serialize config: {}", e))?;
fs::write(&config_path, toml_string)
.map_err(|e| format!("Could not write config file: {}", e))?;
Ok(())
}
/// Парсит строку цвета в ratatui::style::Color
pub fn parse_color(&self, color_str: &str) -> ratatui::style::Color {
use ratatui::style::Color;
match color_str.to_lowercase().as_str() {
"black" => Color::Black,
"red" => Color::Red,
"green" => Color::Green,
"yellow" => Color::Yellow,
"blue" => Color::Blue,
"magenta" => Color::Magenta,
"cyan" => Color::Cyan,
"gray" | "grey" => Color::Gray,
"white" => Color::White,
"darkgray" | "darkgrey" => Color::DarkGray,
"lightred" => Color::LightRed,
"lightgreen" => Color::LightGreen,
"lightyellow" => Color::LightYellow,
"lightblue" => Color::LightBlue,
"lightmagenta" => Color::LightMagenta,
"lightcyan" => Color::LightCyan,
_ => Color::White, // fallback
}
}
/// Путь к файлу credentials
pub fn credentials_path() -> Option<PathBuf> {
Self::config_dir().map(|dir| dir.join("credentials"))
}
/// Загружает API_ID и API_HASH из credentials файла или .env
/// Возвращает (api_id, api_hash) или ошибку с инструкциями
pub fn load_credentials() -> Result<(i32, String), String> {
use std::env;
// 1. Пробуем загрузить из ~/.config/tele-tui/credentials
if let Some(cred_path) = Self::credentials_path() {
if cred_path.exists() {
if let Ok(content) = fs::read_to_string(&cred_path) {
let mut api_id: Option<i32> = None;
let mut api_hash: Option<String> = None;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
let key = key.trim();
let value = value.trim();
match key {
"API_ID" => {
api_id = value.parse().ok();
}
"API_HASH" => {
api_hash = Some(value.to_string());
}
_ => {}
}
}
}
if let (Some(id), Some(hash)) = (api_id, api_hash) {
return Ok((id, hash));
}
}
}
}
// 2. Пробуем загрузить из переменных окружения (.env)
if let (Ok(api_id_str), Ok(api_hash)) = (env::var("API_ID"), env::var("API_HASH")) {
if let Ok(api_id) = api_id_str.parse::<i32>() {
return Ok((api_id, api_hash));
}
}
// 3. Не нашли credentials - возвращаем инструкции
let credentials_path = Self::credentials_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "~/.config/tele-tui/credentials".to_string());
Err(format!(
"Telegram API credentials not found!\n\n\
Please create a file at:\n {}\n\n\
With the following content:\n\
API_ID=your_api_id\n\
API_HASH=your_api_hash\n\n\
You can get API credentials at: https://my.telegram.org/apps\n\n\
Alternatively, you can create a .env file in the current directory.",
credentials_path
))
}
}

View File

@@ -1,4 +1,5 @@
mod app;
mod config;
mod input;
mod tdlib;
mod ui;
@@ -26,6 +27,9 @@ async fn main() -> Result<(), io::Error> {
// Загружаем переменные окружения из .env
let _ = dotenvy::dotenv();
// Загружаем конфигурацию (создаёт дефолтный если отсутствует)
let config = config::Config::load();
// Отключаем логи TDLib ДО создания клиента
disable_tdlib_logs();
@@ -37,7 +41,7 @@ async fn main() -> Result<(), io::Error> {
let mut terminal = Terminal::new(backend)?;
// Create app state
let mut app = App::new();
let mut app = App::new(config);
let res = run_app(&mut terminal, &mut app).await;
// Restore terminal

View File

@@ -264,11 +264,16 @@ pub struct TdClient {
#[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();
// Загружаем credentials из ~/.config/tele-tui/credentials или .env
let (api_id, api_hash) = match crate::config::Config::load_credentials() {
Ok(creds) => creds,
Err(err_msg) => {
eprintln!("\n{}\n", err_msg);
// Используем дефолтные значения, чтобы приложение запустилось
// Пользователь увидит сообщение об ошибке в UI
(0, String::new())
}
};
let client_id = tdlib_rs::create_client();

View File

@@ -6,7 +6,7 @@ use ratatui::{
Frame,
};
use crate::app::App;
use crate::utils::{format_timestamp, format_date, get_day};
use crate::utils::{format_timestamp_with_tz, format_date, get_day};
use tdlib_rs::enums::TextEntityType;
use tdlib_rs::types::TextEntity;
@@ -510,16 +510,16 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
last_sender = Some(current_sender);
}
// Форматируем время (HH:MM)
let time = format_timestamp(msg.date);
// Форматируем время (HH:MM) с учётом timezone из config
let time = format_timestamp_with_tz(msg.date, &app.config.general.timezone);
// Цвет сообщения (жёлтый если выбрано)
// Цвет сообщения (из config или жёлтый если выбрано)
let msg_color = if is_selected {
Color::Yellow
app.config.parse_color(&app.config.colors.selected_message)
} else if msg.is_outgoing {
Color::Green
app.config.parse_color(&app.config.colors.outgoing_message)
} else {
Color::White
app.config.parse_color(&app.config.colors.incoming_message)
};
// Маркер выбора
@@ -694,9 +694,9 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
};
let style = if reaction.is_chosen {
Style::default().fg(Color::Yellow)
Style::default().fg(app.config.parse_color(&app.config.colors.reaction_chosen))
} else {
Style::default().fg(Color::Gray)
Style::default().fg(app.config.parse_color(&app.config.colors.reaction_other))
};
reaction_spans.push(Span::styled(reaction_text, style));

View File

@@ -22,20 +22,42 @@ pub fn disable_tdlib_logs() {
}
}
/// Форматирование timestamp в время HH:MM
pub fn format_timestamp(timestamp: i32) -> String {
/// Форматирование timestamp в время HH:MM с учётом timezone offset
/// timezone_str: строка формата "+03:00" или "-05:00"
pub fn format_timestamp_with_tz(timestamp: i32, timezone_str: &str) -> String {
let secs = timestamp as i64;
// Конвертируем в локальное время (простой способ без chrono)
// UTC + смещение для локального времени
let hours = ((secs % 86400) / 3600) as u32;
// Парсим timezone offset (например "+03:00" -> 3 часа)
let offset_hours = parse_timezone_offset(timezone_str);
let hours = ((secs % 86400) / 3600) as i32;
let minutes = ((secs % 3600) / 60) as u32;
// Примерное локальное время (добавим 3 часа для MSK, можно настроить)
let local_hours = (hours + 3) % 24;
// Применяем timezone offset
let local_hours = ((hours + offset_hours) % 24 + 24) % 24;
format!("{:02}:{:02}", local_hours, minutes)
}
/// Парсит timezone строку типа "+03:00" в количество часов
fn parse_timezone_offset(tz: &str) -> i32 {
// Простой парсинг "+03:00" или "-05:00"
if tz.len() >= 3 {
let sign = if tz.starts_with('-') { -1 } else { 1 };
let hours_str = &tz[1..3];
if let Ok(hours) = hours_str.parse::<i32>() {
return sign * hours;
}
}
3 // fallback к MSK
}
/// Устаревшая функция для обратной совместимости (используется дефолтный +03:00)
#[allow(dead_code)]
pub fn format_timestamp(timestamp: i32) -> String {
format_timestamp_with_tz(timestamp, "+03:00")
}
/// Форматирование timestamp в дату для разделителя
pub fn format_date(timestamp: i32) -> String {
use std::time::{SystemTime, UNIX_EPOCH};