mirror of
https://github.com/avitoras/telegram-tui.git
synced 2025-07-27 11:20:31 +00:00
UNSTABLE | switch to urwid (fixing chat list)
This commit is contained in:
parent
fca2d580b0
commit
4992e71948
@ -2,6 +2,10 @@
|
||||
# Их использование крайне нежелательно!
|
||||
# Получите свои API-ключи приложения на https://my.telegram.org/apps и вставьте их ниже.
|
||||
# Спасибо за понимание!
|
||||
|
||||
API_ID=2040
|
||||
API_HASH=b18441a1ff607e10a989891a5462e627
|
||||
|
||||
# Настройки приложения
|
||||
APP_NAME=Telegram TUI
|
||||
APP_VERSION=1.0
|
||||
DEVICE_MODEL=Desktop
|
||||
|
25
.gitignore
vendored
25
.gitignore
vendored
@ -1,10 +1,19 @@
|
||||
test.py
|
||||
*.session
|
||||
*.session-journal
|
||||
__pycache__
|
||||
*/__pycache__
|
||||
tokens.py
|
||||
.env
|
||||
venv*
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
|
||||
# Telegram session files
|
||||
*.session
|
||||
*.session-journal
|
||||
|
||||
# Environment
|
||||
.env
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
|
63
README.md
63
README.md
@ -1,26 +1,26 @@
|
||||
# Тальк
|
||||
# Telegram TUI Client
|
||||
|
||||
Тальк — клиент Telegram с текстовым пользовательским интерфейсом, написанный на Python, Telethon и Textual.
|
||||
|
||||
## Требования
|
||||
|
||||
- Python 3.12
|
||||
- pyenv (рекомендуется для управления версиями Python)
|
||||
Консольный клиент Telegram на базе urwid с поддержкой:
|
||||
- Просмотра чатов и сообщений
|
||||
- Поиска по чатам
|
||||
- Навигации с помощью клавиатуры
|
||||
- Поддержки папок (Архив)
|
||||
- Корректного отображения эмодзи и Unicode
|
||||
|
||||
## Установка
|
||||
|
||||
1. Установите Python 3.12 с помощью pyenv:
|
||||
1. Клонируйте репозиторий:
|
||||
```bash
|
||||
pyenv install 3.12
|
||||
pyenv local 3.12
|
||||
git clone https://github.com/yourusername/talc.git
|
||||
cd talc
|
||||
```
|
||||
|
||||
2. Создайте и активируйте виртуальное окружение:
|
||||
2. Создайте виртуальное окружение и активируйте его:
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate # для Linux/macOS
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Linux/macOS
|
||||
# или
|
||||
.venv\Scripts\activate # для Windows
|
||||
venv\Scripts\activate # Windows
|
||||
```
|
||||
|
||||
3. Установите зависимости:
|
||||
@ -28,15 +28,42 @@ source .venv/bin/activate # для Linux/macOS
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
4. Настройте переменные окружения:
|
||||
4. Скопируйте `.env.example` в `.env`:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Отредактируйте .env файл, добавив свои API ключи
|
||||
# Получите ключи на https://my.telegram.org/apps
|
||||
```
|
||||
|
||||
5. Получите API ключи на https://my.telegram.org/apps и добавьте их в `.env`
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
python src/app.py
|
||||
python main_urwid.py
|
||||
```
|
||||
|
||||
## Управление
|
||||
|
||||
- Tab: Переключение фокуса между поиском и списком чатов
|
||||
- ↑↓: Выбор чата
|
||||
- Enter: Открыть выбранный чат
|
||||
- Esc: Вернуться к списку чатов
|
||||
- /: Быстрый доступ к поиску
|
||||
- []: Переключение между основными чатами и архивом
|
||||
- Q: Выход
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
talc/
|
||||
├── main_urwid.py # Основной файл запуска
|
||||
├── requirements.txt # Зависимости проекта
|
||||
├── .env.example # Пример конфигурации
|
||||
├── .env # Конфигурация (не включена в git)
|
||||
└── urwid_client/ # Основной код приложения
|
||||
├── __init__.py
|
||||
└── telegram_tui.py # Реализация клиента
|
||||
```
|
||||
|
||||
## Лицензия
|
||||
|
||||
MIT
|
||||
|
8
main.py
8
main.py
@ -1,8 +0,0 @@
|
||||
#!/usr/bin/python
|
||||
"""Файл инициализации приложения"""
|
||||
|
||||
from src.app import TelegramTUI
|
||||
|
||||
if __name__ == "__main__":
|
||||
tg = TelegramTUI()
|
||||
tg.run()
|
@ -1,7 +1,5 @@
|
||||
textual
|
||||
telethon
|
||||
python-dotenv
|
||||
emoji
|
||||
Pillow
|
||||
pywhatkit
|
||||
urwid
|
||||
urwid>=2.1.2
|
||||
telethon>=1.34.0
|
||||
python-dotenv>=1.0.0
|
||||
emoji>=2.10.1
|
||||
nest_asyncio>=1.6.0
|
@ -1,5 +0,0 @@
|
||||
urwid>=2.1.2
|
||||
telethon>=1.34.0
|
||||
python-dotenv>=1.0.0
|
||||
emoji>=2.10.1
|
||||
nest_asyncio>=1.6.0
|
111
src/app.py
111
src/app.py
@ -1,111 +0,0 @@
|
||||
"""Файл с основным классом приложения"""
|
||||
|
||||
from textual.app import App
|
||||
from textual.binding import Binding
|
||||
from telethon import TelegramClient
|
||||
import os
|
||||
import asyncio
|
||||
from src.screens import AuthScreen, ChatScreen
|
||||
from textual import log
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Загружаем переменные окружения из .env файла
|
||||
load_dotenv()
|
||||
|
||||
# Проверяем наличие API ключей
|
||||
api_id = os.getenv("API_ID")
|
||||
api_hash = os.getenv("API_HASH")
|
||||
|
||||
if not api_id or not api_hash:
|
||||
raise ValueError(
|
||||
"API_ID и API_HASH не найдены в .env файле. "
|
||||
"Пожалуйста, скопируйте .env.example в .env и заполните свои ключи."
|
||||
)
|
||||
|
||||
# Преобразуем API_ID в число
|
||||
api_id = int(api_id)
|
||||
|
||||
class TelegramTUI(App):
|
||||
"""Класс основного приложения"""
|
||||
|
||||
BINDINGS = [
|
||||
Binding("ctrl+c,ctrl+q", "quit", "Выход", show=True),
|
||||
]
|
||||
|
||||
CSS_PATH = "style.tcss"
|
||||
TITLE = "Telegram TUI"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
driver_class=None,
|
||||
css_path=None,
|
||||
watch_css=False
|
||||
):
|
||||
super().__init__(
|
||||
driver_class=driver_class,
|
||||
css_path=css_path,
|
||||
watch_css=watch_css
|
||||
)
|
||||
|
||||
# Инициализируем клиент Telegram
|
||||
session_file = "talc.session"
|
||||
|
||||
# Если сессия существует и заблокирована, удаляем её
|
||||
if os.path.exists(session_file):
|
||||
try:
|
||||
os.remove(session_file)
|
||||
log("Старая сессия удалена")
|
||||
except Exception as e:
|
||||
log(f"Ошибка удаления сессии: {e}")
|
||||
|
||||
self.telegram_client = TelegramClient(
|
||||
session_file,
|
||||
api_id=api_id,
|
||||
api_hash=api_hash,
|
||||
system_version="macOS 14.3.1",
|
||||
device_model="MacBook",
|
||||
app_version="1.0"
|
||||
)
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
"""Действия при запуске приложения"""
|
||||
try:
|
||||
# Подключаемся к Telegram
|
||||
await self.telegram_client.connect()
|
||||
log("Подключено к Telegram")
|
||||
|
||||
# Устанавливаем экраны
|
||||
chat_screen = ChatScreen(telegram_client=self.telegram_client)
|
||||
self.install_screen(chat_screen, name="chats")
|
||||
|
||||
auth_screen = AuthScreen(telegram_client=self.telegram_client)
|
||||
self.install_screen(auth_screen, name="auth")
|
||||
|
||||
# Проверяем авторизацию и показываем нужный экран
|
||||
if await self.telegram_client.is_user_authorized():
|
||||
await self.push_screen("chats")
|
||||
else:
|
||||
await self.push_screen("auth")
|
||||
|
||||
except Exception as e:
|
||||
log(f"Ошибка при запуске: {e}")
|
||||
self.exit()
|
||||
|
||||
async def on_unmount(self) -> None:
|
||||
"""Действия при закрытии приложения"""
|
||||
try:
|
||||
if self.telegram_client and self.telegram_client.is_connected():
|
||||
await self.telegram_client.disconnect()
|
||||
log("Отключено от Telegram")
|
||||
except Exception as e:
|
||||
log(f"Ошибка при закрытии: {e}")
|
||||
|
||||
async def action_quit(self) -> None:
|
||||
"""Действие при выходе из приложения"""
|
||||
try:
|
||||
if self.telegram_client and self.telegram_client.is_connected():
|
||||
await self.telegram_client.disconnect()
|
||||
log("Отключено от Telegram")
|
||||
except Exception as e:
|
||||
log(f"Ошибка при выходе: {e}")
|
||||
self.exit()
|
231
src/screens.py
231
src/screens.py
@ -1,231 +0,0 @@
|
||||
"""Файл с кастомными экранами приложения"""
|
||||
|
||||
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, normalize_text
|
||||
from textual import log
|
||||
|
||||
class AuthScreen(Screen):
|
||||
"""Класс экрана логина в аккаунт"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name = None,
|
||||
id = None,
|
||||
classes = None,
|
||||
telegram_client: TelegramClient | None = None
|
||||
):
|
||||
super().__init__(name, id, classes)
|
||||
self.client = telegram_client
|
||||
|
||||
def on_mount(self):
|
||||
self.ac = self.query_one("#auth_container")
|
||||
|
||||
def compose(self):
|
||||
with Vertical(id="auth_container"):
|
||||
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=normalize_text("Пароль"),
|
||||
id="password",
|
||||
password=True,
|
||||
disabled=True
|
||||
)
|
||||
|
||||
async def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||
if event.input.id == "phone":
|
||||
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 = 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()
|
||||
self.app.push_screen("chats")
|
||||
except SessionPasswordNeededError:
|
||||
self.ac.query_one("#code").disabled = True
|
||||
self.ac.query_one("#password").disabled = False
|
||||
elif event.input.id == "password":
|
||||
self.password = normalize_text(event.value)
|
||||
await self.client.sign_in(password=self.password)
|
||||
await self.client.start()
|
||||
self.app.pop_screen()
|
||||
self.app.push_screen("chats")
|
||||
|
||||
class ChatScreen(Screen):
|
||||
"""Класс экрана чатов, он же основной экран приложения"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name = None,
|
||||
id = None,
|
||||
classes = None,
|
||||
telegram_client: TelegramClient | None = None
|
||||
):
|
||||
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
|
||||
self.current_folder = None # None - основная папка, число - ID папки
|
||||
self.folders = [] # Список доступных папок
|
||||
|
||||
async def on_mount(self):
|
||||
self.limit = 100
|
||||
|
||||
self.chat_container = self\
|
||||
.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")
|
||||
|
||||
# Получаем список папок
|
||||
try:
|
||||
self.folders = await self.telegram_client.get_dialogs(folder=1)
|
||||
log(f"Найдено папок: {len(self.folders)}")
|
||||
except Exception as e:
|
||||
log(f"Ошибка получения папок: {e}")
|
||||
self.folders = []
|
||||
|
||||
# Загружаем чаты
|
||||
log("Первоначальная загрузка виджетов чатов...")
|
||||
try:
|
||||
dialogs = await self.telegram_client.get_dialogs(
|
||||
limit=self.limit,
|
||||
archived=False,
|
||||
folder=self.current_folder
|
||||
)
|
||||
self.mount_chats(len(dialogs))
|
||||
log("Первоначальная загрузка виджетов чата завершена")
|
||||
except Exception as e:
|
||||
log(f"Ошибка загрузки чатов: {e}")
|
||||
|
||||
self.is_chat_update_blocked = False
|
||||
await self.update_chat_list()
|
||||
|
||||
log("Первоначальная загрузка чатов завершена")
|
||||
|
||||
for event in (
|
||||
events.NewMessage,
|
||||
events.MessageDeleted,
|
||||
events.MessageEdited
|
||||
):
|
||||
self.telegram_client.on(event())(self.update_chat_list)
|
||||
|
||||
def mount_chats(self, limit: int):
|
||||
log("Загрузка виджетов чатов...")
|
||||
|
||||
chats_amount = len(self.chat_container.query(Chat))
|
||||
|
||||
if limit > chats_amount:
|
||||
for i in range(limit - chats_amount):
|
||||
chat = Chat(id=f"chat-{i + chats_amount + 1}")
|
||||
self.chat_container.mount(chat)
|
||||
elif limit < chats_amount:
|
||||
for i in range(chats_amount - limit):
|
||||
self.chat_container.query(Chat).last().remove()
|
||||
|
||||
log("Виджеты чатов загружены")
|
||||
|
||||
async def update_chat_list(self, event = None):
|
||||
log("Запрос обновления чатов")
|
||||
|
||||
if not self.is_chat_update_blocked:
|
||||
self.is_chat_update_blocked = True
|
||||
|
||||
try:
|
||||
# Получаем диалоги для текущей папки
|
||||
dialogs = await self.telegram_client.get_dialogs(
|
||||
limit=self.limit,
|
||||
archived=False,
|
||||
folder=self.current_folder
|
||||
)
|
||||
log(f"Получены диалоги для папки {self.current_folder}")
|
||||
|
||||
# Фильтруем диалоги по поисковому запросу
|
||||
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 = normalize_text(str(dialogs[i].name))
|
||||
chat.msg = normalize_text(str(dialogs[i].message.message if dialogs[i].message else ""))
|
||||
chat.peer_id = dialogs[i].id
|
||||
chat.is_selected = (i == self.selected_chat_index)
|
||||
chat.folder = 1 if self.current_folder else 0
|
||||
|
||||
except Exception as e:
|
||||
log(f"Ошибка обновления чатов: {e}")
|
||||
finally:
|
||||
self.is_chat_update_blocked = False
|
||||
log("Чаты обновлены")
|
||||
else:
|
||||
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()
|
||||
# Фокусируемся на первом чате
|
||||
first_chat = self.chat_container.query_one("#chat-1")
|
||||
if first_chat:
|
||||
first_chat.focus()
|
||||
elif self.focused_element == "chat_list":
|
||||
self.focused_element = "search"
|
||||
self.search_input.focus()
|
||||
return
|
||||
|
||||
if event.key == "/":
|
||||
# Фокус на поиск
|
||||
self.focused_element = "search"
|
||||
self.search_input.focus()
|
||||
elif event.key == "[":
|
||||
# Переход в предыдущую папку
|
||||
if self.current_folder is not None:
|
||||
self.current_folder = None
|
||||
self.selected_chat_index = 0
|
||||
self.update_chat_list()
|
||||
elif event.key == "]":
|
||||
# Переход в следующую папку
|
||||
if self.current_folder is None and self.folders:
|
||||
self.current_folder = 1 # Архив
|
||||
self.selected_chat_index = 0
|
||||
self.update_chat_list()
|
||||
|
||||
def compose(self):
|
||||
yield Footer()
|
||||
with Horizontal(id="main_container"):
|
||||
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")
|
||||
yield ContentSwitcher(id="dialog_switcher")
|
@ -1,64 +0,0 @@
|
||||
#chats {
|
||||
width: 30%;
|
||||
}
|
||||
|
||||
#dialog {
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
Chat {
|
||||
height: 3;
|
||||
}
|
||||
|
||||
Rule {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
Message {
|
||||
height: auto;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
Message Container {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
#input_place {
|
||||
height: 3;
|
||||
width: 70%;
|
||||
align-horizontal: center;
|
||||
}
|
||||
|
||||
#msg_input {
|
||||
width: 65%;
|
||||
}
|
||||
|
||||
#auth_container{
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
.is_me_true Container {
|
||||
padding: 0 0 0 15;
|
||||
align: right middle;
|
||||
}
|
||||
|
||||
.is_me_true Static {
|
||||
border: solid $primary;
|
||||
width: auto;
|
||||
height: auto;
|
||||
text-align: right;
|
||||
min-width: 11;
|
||||
}
|
||||
|
||||
.is_me_false Container {
|
||||
padding: 0 15 0 0;
|
||||
align: left middle;
|
||||
}
|
||||
|
||||
.is_me_false Static {
|
||||
border: solid $foreground;
|
||||
width: auto;
|
||||
height: auto;
|
||||
text-align: left;
|
||||
min-width: 11;
|
||||
}
|
465
src/widgets.py
465
src/widgets.py
@ -1,465 +0,0 @@
|
||||
"""Файл с кастомными виджетами приложения"""
|
||||
|
||||
from textual.containers import Horizontal, Vertical, Container, VerticalScroll
|
||||
from textual.widget import Widget
|
||||
from textual.reactive import Reactive
|
||||
from textual.widgets import Input, Button, Label, Static, ContentSwitcher
|
||||
from textual.app import ComposeResult, RenderResult
|
||||
from textual.keys import Keys
|
||||
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 ""
|
||||
|
||||
try:
|
||||
# Преобразуем в строку, если это не строка
|
||||
text = str(text)
|
||||
|
||||
# Удаляем эмодзи
|
||||
text = remove_emoji(text)
|
||||
|
||||
# Нормализуем Unicode
|
||||
text = unicodedata.normalize('NFKC', text)
|
||||
|
||||
# Заменяем специальные символы на их ASCII-эквиваленты
|
||||
text = text.replace('—', '-').replace('–', '-').replace('…', '...')
|
||||
|
||||
# Удаляем все управляющие символы, кроме новой строки и табуляции
|
||||
text = ''.join(char for char in text if unicodedata.category(char)[0] != 'C'
|
||||
or char in ('\n', '\t'))
|
||||
|
||||
# Удаляем множественные пробелы
|
||||
text = ' '.join(text.split())
|
||||
|
||||
return text
|
||||
except Exception as e:
|
||||
log(f"Ошибка нормализации текста: {e}")
|
||||
return "Ошибка отображения"
|
||||
|
||||
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(Static):
|
||||
"""Класс виджета чата для панели чатов"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
Chat {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 3;
|
||||
padding: 1 2;
|
||||
border: solid $accent;
|
||||
margin: 1 0;
|
||||
background: $surface;
|
||||
}
|
||||
Chat:hover {
|
||||
background: $accent 20%;
|
||||
}
|
||||
Chat.-selected {
|
||||
background: $accent 30%;
|
||||
}
|
||||
Chat:focus {
|
||||
background: $accent 40%;
|
||||
border: double $accent;
|
||||
}
|
||||
.chat-avatar {
|
||||
width: 3;
|
||||
height: 3;
|
||||
content-align: center middle;
|
||||
border: solid $accent;
|
||||
margin-right: 1;
|
||||
background: $boost;
|
||||
}
|
||||
.chat-content {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
.chat-name {
|
||||
width: 100%;
|
||||
color: $text;
|
||||
text-style: bold;
|
||||
}
|
||||
.chat-message {
|
||||
width: 100%;
|
||||
color: $text-muted;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
disabled: bool = False
|
||||
) -> None:
|
||||
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
|
||||
self.can_focus = True
|
||||
self._username = ""
|
||||
self._msg = ""
|
||||
self._peer_id = 0
|
||||
self._is_selected = False
|
||||
self._folder = 0
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.switcher = self.screen.query_one("#dialog_switcher", ContentSwitcher)
|
||||
|
||||
@property
|
||||
def username(self) -> str:
|
||||
return self._username
|
||||
|
||||
@username.setter
|
||||
def username(self, value: str) -> None:
|
||||
self._username = value
|
||||
self.refresh()
|
||||
|
||||
@property
|
||||
def msg(self) -> str:
|
||||
return self._msg
|
||||
|
||||
@msg.setter
|
||||
def msg(self, value: str) -> None:
|
||||
self._msg = value
|
||||
self.refresh()
|
||||
|
||||
@property
|
||||
def peer_id(self) -> int:
|
||||
return self._peer_id
|
||||
|
||||
@peer_id.setter
|
||||
def peer_id(self, value: int) -> None:
|
||||
self._peer_id = value
|
||||
|
||||
@property
|
||||
def is_selected(self) -> bool:
|
||||
return self._is_selected
|
||||
|
||||
@is_selected.setter
|
||||
def is_selected(self, value: bool) -> None:
|
||||
self._is_selected = value
|
||||
self.set_class(value, "-selected")
|
||||
|
||||
@property
|
||||
def folder(self) -> int:
|
||||
return self._folder
|
||||
|
||||
@folder.setter
|
||||
def folder(self, value: int) -> None:
|
||||
self._folder = value
|
||||
self.refresh()
|
||||
|
||||
def on_focus(self) -> None:
|
||||
# Снимаем выделение со всех чатов
|
||||
for chat in self.screen.query(Chat):
|
||||
chat.is_selected = False
|
||||
|
||||
# Выделяем текущий чат
|
||||
self.is_selected = True
|
||||
|
||||
# Прокручиваем к этому чату
|
||||
self.screen.chat_container.scroll_to(self, animate=False)
|
||||
|
||||
def on_click(self) -> None:
|
||||
self.focus()
|
||||
|
||||
dialog_id = f"dialog-{str(self.peer_id)}"
|
||||
try:
|
||||
self.switcher.mount(Dialog(
|
||||
telegram_client=self.app.telegram_client,
|
||||
chat_id=self.peer_id,
|
||||
id=dialog_id
|
||||
))
|
||||
except Exception as e:
|
||||
log(f"Ошибка открытия диалога: {e}")
|
||||
return
|
||||
|
||||
self.switcher.current = dialog_id
|
||||
|
||||
def on_key(self, event: Keys) -> None:
|
||||
if event.key == "enter":
|
||||
self.on_click()
|
||||
elif event.key == "up" and self.id != "chat-1":
|
||||
# Фокусируемся на предыдущем чате
|
||||
prev_chat = self.screen.chat_container.query_one(f"#chat-{int(self.id.split('-')[1]) - 1}")
|
||||
if prev_chat:
|
||||
prev_chat.focus()
|
||||
elif event.key == "down":
|
||||
# Фокусируемся на следующем чате
|
||||
next_chat = self.screen.chat_container.query_one(f"#chat-{int(self.id.split('-')[1]) + 1}")
|
||||
if next_chat:
|
||||
next_chat.focus()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Компонуем виджет чата"""
|
||||
try:
|
||||
# Подготавливаем данные
|
||||
name = normalize_text(self._username)
|
||||
if not name:
|
||||
name = "Без названия"
|
||||
|
||||
msg = normalize_text(self._msg)
|
||||
if not msg:
|
||||
msg = "Нет сообщений"
|
||||
elif len(msg) > 50:
|
||||
msg = msg[:47] + "..."
|
||||
|
||||
# Добавляем метку папки если нужно
|
||||
if self._folder == 1:
|
||||
name += " [Архив]"
|
||||
|
||||
# Получаем первую букву для аватара (используем только видимые символы)
|
||||
first_letter = next((c for c in name if c.isprintable()), "?")
|
||||
|
||||
# Создаем виджеты
|
||||
with Horizontal():
|
||||
yield Static(first_letter, classes="chat-avatar")
|
||||
with Vertical(classes="chat-content"):
|
||||
yield Static(name, classes="chat-name")
|
||||
yield Static(msg, classes="chat-message")
|
||||
|
||||
except Exception as e:
|
||||
log(f"Ошибка отображения чата: {e}")
|
||||
# Показываем запасной вариант в случае ошибки
|
||||
with Horizontal():
|
||||
yield Static("?", classes="chat-avatar")
|
||||
with Vertical(classes="chat-content"):
|
||||
yield Static("Ошибка отображения", classes="chat-name")
|
||||
yield Static("Попробуйте обновить список", classes="chat-message")
|
||||
|
||||
class Dialog(Widget):
|
||||
"""Класс окна диалога"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
id=None,
|
||||
classes=None,
|
||||
disabled=None,
|
||||
telegram_client: TelegramClient | None = None,
|
||||
chat_id = None
|
||||
) -> None:
|
||||
super().__init__(id=id, classes=classes, disabled=disabled)
|
||||
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 = 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()
|
||||
|
||||
self.dialog.scroll_end(animate=False)
|
||||
await self.update_dialog()
|
||||
|
||||
for event in (
|
||||
events.NewMessage,
|
||||
events.MessageDeleted,
|
||||
events.MessageEdited
|
||||
):
|
||||
self.telegram_client.on(
|
||||
event(chats=(self.chat_id))
|
||||
)(self.update_dialog)
|
||||
|
||||
def mount_messages(self, limit: int) -> None:
|
||||
print("Загрузка виджетов сообщений...")
|
||||
|
||||
msg_amount = len(self.dialog.query(Message))
|
||||
|
||||
if limit > msg_amount:
|
||||
for i in range(limit - msg_amount):
|
||||
self.dialog.mount(
|
||||
Message(id=f"msg-{i + msg_amount + 1}"),
|
||||
before=0
|
||||
)
|
||||
elif limit < msg_amount:
|
||||
for i in range(msg_amount - limit):
|
||||
self.dialog.query(Message).last().remove()
|
||||
|
||||
async def update_dialog(self, event = None) -> None:
|
||||
log("Запрос обновления сообщений")
|
||||
|
||||
if not self.is_msg_update_blocked:
|
||||
self.is_msg_update_blocked = True
|
||||
|
||||
messages = await self.telegram_client.get_messages(
|
||||
entity=self.chat_id, limit=self.limit
|
||||
)
|
||||
log("Получены сообщения")
|
||||
|
||||
limit = len(messages)
|
||||
self.mount_messages(limit)
|
||||
|
||||
for i in range(limit):
|
||||
msg = self.dialog.query_one(f"#msg-{i + 1}")
|
||||
msg.message = ""
|
||||
|
||||
# Обрабатываем изображения
|
||||
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 = 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
|
||||
log("Сообщения обновлены")
|
||||
else:
|
||||
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")
|
||||
|
||||
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()
|
||||
|
||||
async def send_message(self) -> None:
|
||||
try:
|
||||
await self.telegram_client.send_message(
|
||||
self.chat_id,
|
||||
normalize_text(str(self.msg_input.value))
|
||||
)
|
||||
except ValueError:
|
||||
self.app.notify("Ошибка отправки")
|
||||
self.msg_input.value = ""
|
||||
await self.update_dialog()
|
||||
|
||||
class Message(Widget):
|
||||
"""Класс виджета сообщений для окна диалога"""
|
||||
|
||||
message: Reactive[str] = Reactive("", recompose=True)
|
||||
is_me: Reactive[bool] = Reactive(False, recompose=True)
|
||||
username: Reactive[str] = Reactive("", recompose=True)
|
||||
send_time: Reactive[str] = Reactive("", recompose=True)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name=None,
|
||||
id=None,
|
||||
classes=None,
|
||||
disabled=False
|
||||
) -> None:
|
||||
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
pass
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
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():
|
||||
yield static
|
||||
|
||||
if self.is_me:
|
||||
self.classes = "is_me_true"
|
||||
else:
|
||||
self.classes = "is_me_false"
|
152
style.tcss
152
style.tcss
@ -1,152 +0,0 @@
|
||||
/* Основные стили */
|
||||
Screen {
|
||||
background: $surface;
|
||||
color: $text;
|
||||
}
|
||||
|
||||
/* Стили для подсказки */
|
||||
.help-text {
|
||||
color: $text-muted;
|
||||
text-align: center;
|
||||
padding: 1;
|
||||
background: $surface;
|
||||
border: solid $accent;
|
||||
margin: 1;
|
||||
}
|
||||
|
||||
/* Стили для чатов */
|
||||
Chat {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 3;
|
||||
padding: 1 2;
|
||||
border: solid $accent;
|
||||
margin: 1 0;
|
||||
background: $surface;
|
||||
}
|
||||
|
||||
Chat:hover {
|
||||
background: $accent 20%;
|
||||
}
|
||||
|
||||
Chat.-selected {
|
||||
background: $accent 30%;
|
||||
border: solid $accent;
|
||||
}
|
||||
|
||||
Chat:focus {
|
||||
background: $accent 40%;
|
||||
border: double $accent;
|
||||
}
|
||||
|
||||
.chat-avatar {
|
||||
width: 3;
|
||||
height: 3;
|
||||
content-align: center middle;
|
||||
border: solid $accent;
|
||||
margin-right: 1;
|
||||
background: $boost;
|
||||
}
|
||||
|
||||
.chat-content {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.chat-name {
|
||||
width: 100%;
|
||||
color: $text;
|
||||
text-style: bold;
|
||||
}
|
||||
|
||||
.chat-message {
|
||||
width: 100%;
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
/* Стили для диалога */
|
||||
#dialog {
|
||||
height: 100%;
|
||||
border: solid $accent;
|
||||
background: $surface;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
#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;
|
||||
}
|
||||
|
||||
/* Стили для контейнеров */
|
||||
#chats {
|
||||
width: 30%;
|
||||
border-right: solid $accent;
|
||||
}
|
||||
|
||||
#dialog_switcher {
|
||||
width: 70%;
|
||||
}
|
||||
|
||||
#chat_container {
|
||||
height: 100%;
|
||||
border: solid $accent;
|
||||
background: $surface;
|
||||
padding: 1;
|
||||
}
|
@ -107,7 +107,9 @@ class ChatWidget(urwid.WidgetWrap):
|
||||
return True
|
||||
|
||||
def keypress(self, size, key):
|
||||
if key in ('up', 'down'):
|
||||
return key
|
||||
return super().keypress(size, key)
|
||||
|
||||
class MessageWidget(urwid.WidgetWrap):
|
||||
"""Виджет сообщения"""
|
||||
@ -276,10 +278,13 @@ class TelegramTUI:
|
||||
async def update_chat_list(self):
|
||||
"""Обновляет список чатов"""
|
||||
try:
|
||||
# Получаем папки
|
||||
if not self.folders:
|
||||
self.folders = await self.telegram_client.get_dialogs_folders()
|
||||
|
||||
# Получаем диалоги
|
||||
dialogs = await self.telegram_client.get_dialogs(
|
||||
limit=100,
|
||||
archived=False,
|
||||
folder=self.current_folder
|
||||
)
|
||||
|
||||
@ -305,9 +310,20 @@ class TelegramTUI:
|
||||
)
|
||||
self.chat_list.body.append(chat)
|
||||
|
||||
# Обновляем фокус
|
||||
if self.chat_list.body:
|
||||
self.chat_list.set_focus(self.selected_chat_index)
|
||||
self.update_selected_chat()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка обновления чатов: {e}")
|
||||
|
||||
def update_selected_chat(self):
|
||||
"""Обновляет выделение выбранного чата"""
|
||||
for i, chat in enumerate(self.chat_list.body):
|
||||
chat.is_selected = (i == self.selected_chat_index)
|
||||
chat.update_widget()
|
||||
|
||||
async def update_message_list(self, chat_id):
|
||||
"""Обновляет список сообщений"""
|
||||
try:
|
||||
@ -377,6 +393,20 @@ class TelegramTUI:
|
||||
self.selected_chat_index = 0
|
||||
await self.update_chat_list()
|
||||
|
||||
elif key == 'up' and self.focused_element == "chat_list":
|
||||
# Выбор предыдущего чата
|
||||
if self.chat_list.body:
|
||||
self.selected_chat_index = max(0, self.selected_chat_index - 1)
|
||||
self.chat_list.set_focus(self.selected_chat_index)
|
||||
self.update_selected_chat()
|
||||
|
||||
elif key == 'down' and self.focused_element == "chat_list":
|
||||
# Выбор следующего чата
|
||||
if self.chat_list.body:
|
||||
self.selected_chat_index = min(len(self.chat_list.body) - 1, self.selected_chat_index + 1)
|
||||
self.chat_list.set_focus(self.selected_chat_index)
|
||||
self.update_selected_chat()
|
||||
|
||||
elif key == 'enter' and self.focused_element == "chat_list":
|
||||
# Открываем выбранный чат
|
||||
focused = self.chat_list.get_focus()[0]
|
||||
|
Loading…
x
Reference in New Issue
Block a user