diff --git a/requirements.txt b/requirements.txt index ed2b38f..41c1a46 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,6 @@ textual telethon -python-dotenv \ No newline at end of file +python-dotenv +emoji +Pillow +pywhatkit \ No newline at end of file diff --git a/src/app.py b/src/app.py index e7b896c..be900d3 100644 --- a/src/app.py +++ b/src/app.py @@ -1,11 +1,17 @@ """Главный файл приложения""" import os +import sys from dotenv import load_dotenv from telethon import TelegramClient, events from textual.app import App +from rich.console import Console from src.screens import AuthScreen, ChatScreen +# Настройка консоли для корректной работы с Unicode +console = Console(force_terminal=True, color_system="auto") +sys.stdout = console + load_dotenv() api_id = os.getenv("API_ID") @@ -23,6 +29,11 @@ class TelegramTUI(App): """Класс приложения""" CSS_PATH = "style.tcss" + TITLE = "Telegram TUI" + + def __init__(self): + super().__init__() + self.console = console async def on_mount(self) -> None: self.telegram_client = TelegramClient("user", api_id, api_hash) diff --git a/src/screens.py b/src/screens.py index 15a0a49..5d7fc9d 100644 --- a/src/screens.py +++ b/src/screens.py @@ -3,9 +3,11 @@ from textual.screen import Screen from textual.widgets import Label, Input, Footer, Static, ContentSwitcher from textual.containers import Vertical, Horizontal, VerticalScroll +from textual.events import Key from telethon.errors import SessionPasswordNeededError from telethon import TelegramClient, events -from src.widgets import Dialog, Chat +from src.widgets import Dialog, Chat, normalize_text +from textual import log class AuthScreen(Screen): """Класс экрана логина в аккаунт""" @@ -25,11 +27,11 @@ class AuthScreen(Screen): def compose(self): with Vertical(id="auth_container"): - yield Label("Добро пожаловать в Telegram TUI") - yield Input(placeholder="Номер телефона", id="phone") - yield Input(placeholder="Код", id="code", disabled=True) + yield Label(normalize_text("Добро пожаловать в Telegram TUI")) + yield Input(placeholder=normalize_text("Номер телефона"), id="phone") + yield Input(placeholder=normalize_text("Код"), id="code", disabled=True) yield Input( - placeholder="Пароль", + placeholder=normalize_text("Пароль"), id="password", password=True, disabled=True @@ -37,13 +39,13 @@ class AuthScreen(Screen): async def on_input_submitted(self, event: Input.Submitted) -> None: if event.input.id == "phone": - self.phone = event.value + self.phone = normalize_text(event.value) self.ac.query_one("#phone").disabled = True self.ac.query_one("#code").disabled = False await self.client.send_code_request(phone=self.phone) elif event.input.id == "code": try: - self.code = event.value + self.code = normalize_text(event.value) self.ac.query_one("#code").disabled = True await self.client.sign_in(phone=self.phone, code=self.code) self.app.pop_screen() @@ -52,7 +54,7 @@ class AuthScreen(Screen): self.ac.query_one("#code").disabled = True self.ac.query_one("#password").disabled = False elif event.input.id == "password": - self.password = event.value + self.password = normalize_text(event.value) await self.client.sign_in(password=self.password) await self.client.start() self.app.pop_screen() @@ -70,6 +72,10 @@ class ChatScreen(Screen): ): super().__init__(name, id, classes) self.telegram_client = telegram_client + self.search_query = "" + self.selected_chat_index = 0 + self.chats = [] + self.focused_element = "search" # search, chat_list, dialog async def on_mount(self): self.limit = 100 @@ -78,8 +84,11 @@ class ChatScreen(Screen): .query_one("#main_container")\ .query_one("#chats")\ .query_one("#chat_container") + + self.search_input = self.query_one("#search_input") + self.help_label = self.query_one("#help_label") - print("Первоначальная загрузка виджетов чатов...") + log("Первоначальная загрузка виджетов чатов...") self.mount_chats( len( await self.telegram_client.get_dialogs( @@ -87,12 +96,12 @@ class ChatScreen(Screen): ) ) ) - print("Первоначальная загрузка виджетов чата завершена") + log("Первоначальная загрузка виджетов чата завершена") self.is_chat_update_blocked = False await self.update_chat_list() - print("Первоначальная загрузка чатов завершена") + log("Первоначальная загрузка чатов завершена") for event in ( events.NewMessage, @@ -102,7 +111,7 @@ class ChatScreen(Screen): self.telegram_client.on(event())(self.update_chat_list) def mount_chats(self, limit: int): - print("Загрузка виджетов чатов...") + log("Загрузка виджетов чатов...") chats_amount = len(self.chat_container.query(Chat)) @@ -114,10 +123,10 @@ class ChatScreen(Screen): for i in range(chats_amount - limit): self.chat_container.query(Chat).last().remove() - print("Виджеты чатов загружены") + log("Виджеты чатов загружены") async def update_chat_list(self, event = None): - print("Запрос обновления чатов") + log("Запрос обновления чатов") if not self.is_chat_update_blocked: self.is_chat_update_blocked = True @@ -125,27 +134,95 @@ class ChatScreen(Screen): dialogs = await self.telegram_client.get_dialogs( limit=self.limit, archived=False ) - print("Получены диалоги") + log("Получены диалоги") + + # Фильтруем диалоги по поисковому запросу + if self.search_query: + dialogs = [ + d for d in dialogs + if self.search_query.lower() in normalize_text(d.name).lower() + ] limit = len(dialogs) self.mount_chats(limit) for i in range(limit): chat = self.chat_container.query_one(f"#chat-{i + 1}") - chat.username = str(dialogs[i].name) - chat.msg = str(dialogs[i].message.message) + chat.username = normalize_text(str(dialogs[i].name)) + chat.msg = normalize_text(str(dialogs[i].message.message)) chat.peer_id = dialogs[i].id + chat.is_selected = (i == self.selected_chat_index) + chat.is_focused = (self.focused_element == "chat_list" and i == self.selected_chat_index) self.is_chat_update_blocked = False - print("Чаты обновлены") + log("Чаты обновлены") else: - print("Обновление чатов невозможно: уже выполняется") + log("Обновление чатов невозможно: уже выполняется") + + def on_input_changed(self, event: Input.Changed) -> None: + if event.input.id == "search_input": + self.search_query = normalize_text(event.value) + self.selected_chat_index = 0 + self.update_chat_list() + + def on_key(self, event: Key) -> None: + if event.key == "tab": + # Переключаем фокус между элементами + if self.focused_element == "search": + self.focused_element = "chat_list" + self.search_input.blur() + self.update_chat_list() + elif self.focused_element == "chat_list": + self.focused_element = "search" + self.search_input.focus() + self.update_chat_list() + return + + if self.focused_element == "search": + return + + chats = self.chat_container.query(Chat) + if not chats: + return + + if event.key == "up": + self.selected_chat_index = max(0, self.selected_chat_index - 1) + for i, chat in enumerate(chats): + chat.is_selected = (i == self.selected_chat_index) + chat.is_focused = (i == self.selected_chat_index) + # Прокручиваем к выбранному чату + selected_chat = chats[self.selected_chat_index] + self.chat_container.scroll_to(selected_chat, animate=False) + elif event.key == "down": + self.selected_chat_index = min(len(chats) - 1, self.selected_chat_index + 1) + for i, chat in enumerate(chats): + chat.is_selected = (i == self.selected_chat_index) + chat.is_focused = (i == self.selected_chat_index) + # Прокручиваем к выбранному чату + selected_chat = chats[self.selected_chat_index] + self.chat_container.scroll_to(selected_chat, animate=False) + elif event.key == "enter": + chats[self.selected_chat_index].on_click() + elif event.key == "escape": + # Возвращаемся к списку чатов + self.app.pop_screen() + self.app.push_screen("chats") + elif event.key == "/": + # Фокус на поиск + self.focused_element = "search" + self.search_input.focus() + self.update_chat_list() def compose(self): yield Footer() with Horizontal(id="main_container"): - with Horizontal(id="chats"): + with Vertical(id="chats"): + yield Label( + "Навигация: Tab - переключение фокуса, ↑↓ - выбор чата, Enter - открыть, Esc - назад, / - поиск", + id="help_label", + classes="help-text" + ) + yield Input(placeholder=normalize_text("Поиск чатов..."), id="search_input") yield VerticalScroll(id="chat_container") - #TODO: сделать кнопку чтобы прогрузить больше чатов yield ContentSwitcher(id="dialog_switcher") #yield Dialog(telegram_client=self.telegram_client) diff --git a/src/widgets.py b/src/widgets.py index f8926fe..613e7d8 100644 --- a/src/widgets.py +++ b/src/widgets.py @@ -7,6 +7,71 @@ from textual.widgets import Input, Button, Label, Static, ContentSwitcher from textual.app import ComposeResult, RenderResult from telethon import TelegramClient, events, utils import datetime +import unicodedata +import re +import emoji +import os +import tempfile +from PIL import Image +import pywhatkit as kit +from textual import log + +def remove_emoji(text: str) -> str: + """Удаляет эмодзи из текста""" + if not text: + return "" + return emoji.replace_emoji(text, '') + +def normalize_text(text: str) -> str: + """Нормализует текст для корректного отображения""" + if not text: + return "" + # Удаляем эмодзи + text = remove_emoji(text) + # Удаляем все управляющие символы + text = ''.join(char for char in text if unicodedata.category(char)[0] != 'C') + # Нормализуем Unicode + text = unicodedata.normalize('NFKC', text) + # Заменяем специальные символы на их ASCII-эквиваленты + text = text.replace('—', '-').replace('–', '-').replace('…', '...') + # Удаляем все непечатаемые символы + text = ''.join(char for char in text if char.isprintable()) + return text + +def safe_ascii(text: str) -> str: + """Преобразует текст в безопасный ASCII-формат""" + if not text: + return "" + # Удаляем эмодзи + text = remove_emoji(text) + # Оставляем только ASCII символы и пробелы + return ''.join(char for char in text if ord(char) < 128 or char.isspace()) + +def convert_image_to_ascii(image_path: str, width: int = 50) -> str: + """Конвертирует изображение в ASCII-арт""" + try: + # Открываем изображение + img = Image.open(image_path) + + # Конвертируем в RGB если нужно + if img.mode != 'RGB': + img = img.convert('RGB') + + # Изменяем размер, сохраняя пропорции + aspect_ratio = img.height / img.width + height = int(width * aspect_ratio * 0.5) # * 0.5 потому что символы выше чем шире + img = img.resize((width, height)) + + # Конвертируем в ASCII + ascii_str = kit.image_to_ascii_art(image_path, output_file=None) + + # Очищаем временный файл + os.remove(image_path) + + return ascii_str + except Exception as e: + log(f"Ошибка конвертации изображения: {e}") + return "Ошибка загрузки изображения" class Chat(Widget): """Класс виджета чата для панели чатов""" @@ -14,6 +79,8 @@ class Chat(Widget): username: Reactive[str] = Reactive(" ", recompose=True) msg: Reactive[str] = Reactive(" ", recompose=True) peer_id: Reactive[int] = Reactive(0) + is_selected: Reactive[bool] = Reactive(False) + is_focused: Reactive[bool] = Reactive(False) def __init__( self, @@ -33,28 +100,40 @@ class Chat(Widget): self.switcher = self.screen.query_one(Horizontal).query_one("#dialog_switcher", ContentSwitcher) def on_click(self) -> None: + # Снимаем выделение со всех чатов + for chat in self.screen.query(Chat): + chat.is_selected = False + chat.is_focused = False + + # Выделяем текущий чат + self.is_selected = True + self.is_focused = True + dialog_id = f"dialog-{str(self.peer_id)}" - print("click 1") try: self.switcher.mount(Dialog( telegram_client=self.app.telegram_client, chat_id=self.peer_id, id=dialog_id )) - print("click 1.1") except: - print("click 1.2") - print("click 2") + pass self.switcher.current = dialog_id self.switcher.recompose() - print("click 3") def compose(self) -> ComposeResult: - with Horizontal(): - yield Label(f"┌───┐\n│ {self.username[:1]:1} │\n└───┘") + with Horizontal(classes="chat-item"): + # Используем ASCII-символы для рамки + yield Label(f"+---+\n| {safe_ascii(self.username[:1].upper()):1} |\n+---+") with Vertical(): - yield Label(self.username, id="name") - yield Label(self.msg, id="last_msg") + yield Label(normalize_text(self.username), id="name") + yield Label(normalize_text(self.msg), id="last_msg") + + def on_mouse_enter(self) -> None: + self.add_class("hover") + + def on_mouse_leave(self) -> None: + self.remove_class("hover") class Dialog(Widget): """Класс окна диалога""" @@ -71,12 +150,16 @@ class Dialog(Widget): self.telegram_client = telegram_client self.chat_id = chat_id self.is_msg_update_blocked = False + self.messages_loaded = 0 + self.is_loading = False async def on_mount(self) -> None: - self.limit = 10 + self.limit = 50 # Увеличиваем начальное количество сообщений + self.messages_loaded = self.limit self.msg_input = self.query_one("#msg_input") self.dialog = self.query_one(Vertical).query_one("#dialog") + self.load_more_btn = self.query_one("#load_more") self.me = await self.telegram_client.get_me() @@ -108,7 +191,7 @@ class Dialog(Widget): self.dialog.query(Message).last().remove() async def update_dialog(self, event = None) -> None: - print("Запрос обновления сообщений") + log("Запрос обновления сообщений") if not self.is_msg_update_blocked: self.is_msg_update_blocked = True @@ -116,7 +199,7 @@ class Dialog(Widget): messages = await self.telegram_client.get_messages( entity=self.chat_id, limit=self.limit ) - print("Получены сообщения") + log("Получены сообщения") limit = len(messages) self.mount_messages(limit) @@ -124,36 +207,76 @@ class Dialog(Widget): for i in range(limit): msg = self.dialog.query_one(f"#msg-{i + 1}") msg.message = "" - if str(messages[i].message): - msg.message = str(messages[i].message) - #TODO: завести это: + # Обрабатываем изображения + if messages[i].media: + try: + # Скачиваем изображение + image_data = await self.telegram_client.download_media( + messages[i].media, + bytes + ) + if image_data: + await msg.set_image(image_data) + except Exception as e: + log(f"Ошибка загрузки изображения: {e}") + + # Обрабатываем текст + if str(messages[i].message): + msg.message = normalize_text(str(messages[i].message)) + try: is_me = messages[i].from_id.user_id == self.me.id except: is_me = False msg.is_me = is_me - msg.username = utils.get_display_name(messages[i].sender) + msg.username = normalize_text(utils.get_display_name(messages[i].sender)) msg.send_time = messages[i]\ .date\ .astimezone(datetime.timezone.utc)\ .strftime("%H:%M") self.is_msg_update_blocked = False - print("Сообщения обновлены") + log("Сообщения обновлены") else: - print("Обновление сообщений невозможно: уже выполняется") + log("Обновление сообщений невозможно: уже выполняется") + + async def load_more_messages(self) -> None: + if not self.is_loading: + self.is_loading = True + self.load_more_btn.disabled = True + self.load_more_btn.label = "Загрузка..." + + try: + messages = await self.telegram_client.get_messages( + entity=self.chat_id, + limit=self.limit, + offset_id=self.messages_loaded + ) + + if messages: + self.messages_loaded += len(messages) + self.mount_messages(self.messages_loaded) + await self.update_dialog() + finally: + self.is_loading = False + self.load_more_btn.disabled = False + self.load_more_btn.label = "Загрузить еще" def compose(self) -> ComposeResult: with Vertical(): + yield Button("Загрузить еще", id="load_more", variant="default") yield VerticalScroll(id="dialog") with Horizontal(id="input_place"): yield Input(placeholder="Сообщение", id="msg_input") - yield Button(label="➤", id="send", variant="primary") + yield Button(label=">", id="send", variant="primary") - async def on_button_pressed(self, event = None) -> None: - await self.send_message() + async def on_button_pressed(self, event) -> None: + if event.button.id == "load_more": + await self.load_more_messages() + else: + await self.send_message() async def on_input_submitted(self, event = None) -> None: await self.send_message() @@ -162,7 +285,7 @@ class Dialog(Widget): try: await self.telegram_client.send_message( self.chat_id, - str(self.msg_input.value) + normalize_text(str(self.msg_input.value)) ) except ValueError: self.app.notify("Ошибка отправки") @@ -190,8 +313,8 @@ class Message(Widget): pass def compose(self) -> ComposeResult: - static = Static(self.message) - static.border_title = self.username * (not self.is_me) + static = Static(normalize_text(self.message)) + static.border_title = normalize_text(self.username) * (not self.is_me) static.border_subtitle = self.send_time with Container(): diff --git a/style.tcss b/style.tcss new file mode 100644 index 0000000..818a84e --- /dev/null +++ b/style.tcss @@ -0,0 +1,119 @@ +/* Основные стили */ +Screen { + background: $surface; + color: $text; +} + +/* Стили для подсказки */ +.help-text { + color: $text-muted; + text-align: center; + padding: 1; + background: $surface; + border: solid $accent; + margin: 1; +} + +/* Стили для чатов */ +.chat-item { + padding: 1 2; + height: auto; + min-height: 3; + border: solid $accent; + margin: 1 0; + transition: background 500ms; +} + +.chat-item:hover { + background: $accent 20%; +} + +.chat-item.selected { + background: $accent 30%; + border: solid $accent; +} + +.chat-item.focused { + background: $accent 40%; + border: solid $accent; + outline: solid $accent; +} + +.chat-item.selected.focused { + background: $accent 50%; +} + +#chat_container { + height: 100%; + border: solid $accent; + background: $surface; +} + +/* Стили для диалога */ +#dialog { + height: 100%; + border: solid $accent; + background: $surface; +} + +#input_place { + height: auto; + padding: 1; + background: $surface; + border: solid $accent; +} + +/* Стили для сообщений */ +.message { + margin: 1 0; + padding: 1; + border: solid $accent; +} + +.message.is_me_true { + background: $accent 20%; + margin-left: 20%; +} + +.message.is_me_false { + background: $surface; + margin-right: 20%; +} + +/* Стили для ASCII-арта */ +.ascii-art { + font-family: monospace; + white-space: pre; + margin: 1 0; + padding: 1; + background: $surface; + border: solid $accent; +} + +/* Стили для поиска */ +#search_input { + margin: 1; + border: solid $accent; +} + +/* Стили для кнопок */ +Button { + margin: 1; + min-width: 10; +} + +Button#load_more { + width: 100%; + margin: 0; + border: none; + background: $accent 20%; +} + +Button#load_more:hover { + background: $accent 30%; +} + +Button#load_more:disabled { + background: $accent 10%; + color: $text-muted; +} \ No newline at end of file