init
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
1636
Cargo.lock
generated
Normal file
1636
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
Cargo.toml
Normal file
15
Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "tele-tui"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ratatui = "0.29"
|
||||||
|
crossterm = "0.28"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
grammers-client = "0.7"
|
||||||
|
grammers-session = "0.7"
|
||||||
|
anyhow = "1.0"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
chrono = "0.4"
|
||||||
40
docs/README.md
Normal file
40
docs/README.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
Что нужно сделать - telegram TUI, то есть terminal user interface для телеграма
|
||||||
|
Ограничения технологий - используем rust-lang, TUI делаем на ratatui, используем telegram api для клиентских приложений
|
||||||
|
|
||||||
|
Интерфейс -
|
||||||
|
|
||||||
|
┌─ Telegram TUI ───────────────────────────────────────────────────────────────┐
|
||||||
|
│ 1:All │ 2:Personal │ 3:Work │ 4:Bots │
|
||||||
|
├──────────────────────┬───────────────────────────────────────────────────────┤
|
||||||
|
│ 🔍 Search... │ 👤 Mom (online) │
|
||||||
|
├──────────────────────┼───────────────────────────────────────────────────────┤
|
||||||
|
│ 📌 Saved Messages │ Today, Dec 21│
|
||||||
|
│ ▌ Mom (2)│ │
|
||||||
|
│ Boss │ Mom ────────────────────────────────────────── 14:20 │
|
||||||
|
│ Rust Community │ Привет! Ты покормил кота? │
|
||||||
|
│ Durov │ │
|
||||||
|
│ News Channel │ You ─────────────────────────────────────── 14:22 ✓✓ │
|
||||||
|
│ Spam Bot │ Да, конечно. Купил ему корм. │
|
||||||
|
│ Wife │ Скоро буду дома. │
|
||||||
|
│ Team Lead │ │
|
||||||
|
│ DevOps Chat (9)│ Mom ────────────────────────────────────────── 14:23 │
|
||||||
|
│ Server Alerts │ Отлично, захвати хлеба. │
|
||||||
|
│ Gym Bro │ │
|
||||||
|
│ Design Team │ You ─────────────────────────────────────── 14:25 ✓ │
|
||||||
|
│ Project X │ Ок. │
|
||||||
|
│ HR │ │
|
||||||
|
│ Mom's Friend │ │
|
||||||
|
│ Taxi Bot │ │
|
||||||
|
│ │ │
|
||||||
|
│ │ │
|
||||||
|
│ │ │
|
||||||
|
│ │ │
|
||||||
|
│ │ │
|
||||||
|
├──────────────────────┼───────────────────────────────────────────────────────┤
|
||||||
|
│ [User: Online] │ > Ок, скоро буд_ │
|
||||||
|
└──────────────────────┴───────────────────────────────────────────────────────┘
|
||||||
|
Esc: Back | Enter: Open | ^R: Reply | ^E: Edit | ^D: Delete
|
||||||
|
|
||||||
|
|
||||||
|
Так же еще добавляем:
|
||||||
|
1) Авторизацию через Telegram
|
||||||
201
src/app/mod.rs
Normal file
201
src/app/mod.rs
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/main.rs
Normal file
68
src/main.rs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
mod app;
|
||||||
|
mod telegram;
|
||||||
|
mod ui;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use crossterm::{
|
||||||
|
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
|
||||||
|
execute,
|
||||||
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
|
};
|
||||||
|
use ratatui::{
|
||||||
|
backend::CrosstermBackend,
|
||||||
|
Terminal,
|
||||||
|
};
|
||||||
|
use std::io;
|
||||||
|
|
||||||
|
use app::App;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
enable_raw_mode()?;
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||||
|
let backend = CrosstermBackend::new(stdout);
|
||||||
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
let res = run_app(&mut terminal, &mut app).await;
|
||||||
|
|
||||||
|
disable_raw_mode()?;
|
||||||
|
execute!(
|
||||||
|
terminal.backend_mut(),
|
||||||
|
LeaveAlternateScreen,
|
||||||
|
DisableMouseCapture
|
||||||
|
)?;
|
||||||
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
|
if let Err(err) = res {
|
||||||
|
println!("Error: {:?}", err);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_app<B: ratatui::backend::Backend>(
|
||||||
|
terminal: &mut Terminal<B>,
|
||||||
|
app: &mut App,
|
||||||
|
) -> Result<()> {
|
||||||
|
loop {
|
||||||
|
terminal.draw(|f| ui::draw(f, app))?;
|
||||||
|
|
||||||
|
if event::poll(std::time::Duration::from_millis(100))? {
|
||||||
|
if let Event::Key(key) = event::read()? {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Char('q') | KeyCode::Esc => return Ok(()),
|
||||||
|
KeyCode::Char('1') => app.select_tab(0),
|
||||||
|
KeyCode::Char('2') => app.select_tab(1),
|
||||||
|
KeyCode::Char('3') => app.select_tab(2),
|
||||||
|
KeyCode::Char('4') => app.select_tab(3),
|
||||||
|
KeyCode::Up => app.previous_chat(),
|
||||||
|
KeyCode::Down => app.next_chat(),
|
||||||
|
KeyCode::Enter => app.open_chat(),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/telegram/mod.rs
Normal file
17
src/telegram/mod.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
#[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,
|
||||||
|
}
|
||||||
170
src/ui/mod.rs
Normal file
170
src/ui/mod.rs
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user