mirror of
https://github.com/avitoras/telegram-tui.git
synced 2025-07-27 11:20:31 +00:00
Compare commits
2 Commits
e523fce7bd
...
83f4cac86b
Author | SHA1 | Date | |
---|---|---|---|
![]() |
83f4cac86b | ||
![]() |
359ee43615 |
@ -2,6 +2,14 @@
|
||||
# Их использование крайне нежелательно!
|
||||
# Получите свои API-ключи приложения на https://my.telegram.org/apps и вставьте их ниже.
|
||||
# Спасибо за понимание!
|
||||
#
|
||||
# In EN: WARNING: it's official TG API-keys, please, don't use them, get yours via link above. Thanks!
|
||||
|
||||
API_ID=2040
|
||||
API_HASH=b18441a1ff607e10a989891a5462e627
|
||||
|
||||
# Конфиг
|
||||
CURRENT_USER = "user"
|
||||
DO_NOTIFY = False
|
||||
UTC_OFFSET = 0
|
||||
LANGUAGE = "en"
|
6
main.py
6
main.py
@ -1,8 +1,8 @@
|
||||
#!/usr/bin/python
|
||||
"""Файл инициализации приложения"""
|
||||
|
||||
from src.app import TelegramTUI
|
||||
from src.app import Talc
|
||||
|
||||
if __name__ == "__main__":
|
||||
tg = TelegramTUI()
|
||||
tg.run()
|
||||
talc = Talc()
|
||||
talc.run()
|
||||
|
@ -1,6 +1,3 @@
|
||||
textual
|
||||
telethon
|
||||
python-dotenv
|
||||
emoji
|
||||
Pillow
|
||||
pywhatkit
|
38
src/app.py
38
src/app.py
@ -1,44 +1,33 @@
|
||||
"""Главный файл приложения"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from os import getenv
|
||||
from dotenv import load_dotenv
|
||||
from telethon import TelegramClient, events
|
||||
from telethon import TelegramClient
|
||||
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
|
||||
"""
|
||||
# спойлер: не помогло
|
||||
import src.locales
|
||||
|
||||
load_dotenv()
|
||||
API_ID = getenv("API_ID")
|
||||
API_HASH = getenv("API_HASH")
|
||||
|
||||
api_id = os.getenv("API_ID")
|
||||
api_hash = os.getenv("API_HASH")
|
||||
|
||||
if not api_id or not api_hash:
|
||||
if not API_ID or not API_HASH:
|
||||
raise ValueError(
|
||||
"API_ID и API_HASH не найдены в .env файле. "
|
||||
"Пожалуйста, скопируйте .env.example в .env и заполните свои ключи."
|
||||
)
|
||||
|
||||
api_id = int(api_id)
|
||||
API_ID = int(API_ID)
|
||||
|
||||
class TelegramTUI(App):
|
||||
#locale = locales.
|
||||
|
||||
class Talc(App):
|
||||
"""Класс приложения"""
|
||||
|
||||
CSS_PATH = "style.tcss"
|
||||
TITLE = "Telegram TUI"
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
self.telegram_client = TelegramClient("user", api_id, api_hash)
|
||||
self.telegram_client = TelegramClient(getenv("CURRENT_USER"), API_ID, API_HASH)
|
||||
await self.telegram_client.connect()
|
||||
|
||||
chat_screen = ChatScreen(telegram_client=self.telegram_client)
|
||||
@ -51,9 +40,8 @@ class TelegramTUI(App):
|
||||
else:
|
||||
self.push_screen("chats")
|
||||
|
||||
self.scroll_sensitivity_y = 1.0
|
||||
|
||||
async def on_exit_app(self):
|
||||
await self.telegram_client.disconnect()
|
||||
return super()._on_exit_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise Exception("Запущен не тот файл. Запустите main.py.")
|
||||
|
2
src/locales.py
Normal file
2
src/locales.py
Normal file
@ -0,0 +1,2 @@
|
||||
ru = {"greeting_auth": "Добро пожаловать в Тальк", "phone_number": "Номер телефона", "code": "Код", "password": "Пароль", "you": "Вы", "mention": "Вас упомянули"}
|
||||
en = {"greeting_auth": "Welcome to Talc", "phone_number": "Phone number", "code": "Code", "password": "Password", "you": "You", "mention": "You got mentioned"}
|
233
src/screens.py
233
src/screens.py
@ -3,12 +3,12 @@
|
||||
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 textual.app import ComposeResult
|
||||
from telethon.errors import SessionPasswordNeededError
|
||||
from telethon import TelegramClient, events
|
||||
from src.widgets import Dialog, Chat, normalize_text
|
||||
from textual import log
|
||||
from textual.keys import Keys, _character_to_key
|
||||
from src.widgets import Chat
|
||||
from os import system, getenv
|
||||
from telethon.utils import get_display_name
|
||||
|
||||
class AuthScreen(Screen):
|
||||
"""Класс экрана логина в аккаунт"""
|
||||
@ -19,83 +19,75 @@ class AuthScreen(Screen):
|
||||
id = None,
|
||||
classes = None,
|
||||
telegram_client: TelegramClient | None = None
|
||||
):
|
||||
) -> None:
|
||||
super().__init__(name, id, classes)
|
||||
self.client = telegram_client
|
||||
|
||||
def on_mount(self):
|
||||
def on_mount(self) -> None:
|
||||
self.ac = self.query_one("#auth_container")
|
||||
|
||||
def compose(self):
|
||||
def compose(self) -> ComposeResult:
|
||||
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 Label("Добро пожаловать в Talc")
|
||||
yield Input(placeholder="Номер телефона", id="phone")
|
||||
yield Input(placeholder="Код", id="code", disabled=True)
|
||||
yield Input(
|
||||
placeholder=normalize_text("Пароль"),
|
||||
placeholder="Пароль",
|
||||
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)
|
||||
match event.input.id:
|
||||
case "phone":
|
||||
self.phone = 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)
|
||||
case "code":
|
||||
try:
|
||||
self.code = 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
|
||||
case "password":
|
||||
self.password = event.value
|
||||
await self.client.sign_in(password=self.password)
|
||||
await self.client.start()
|
||||
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):
|
||||
"""Класс экрана чатов, он же основной экран приложения"""
|
||||
|
||||
BINDINGS = [
|
||||
(Keys.Tab, "log(\"Нажат таб\")", "Переключение фокуса"),
|
||||
(Keys.Enter, "log(\"Нажат энтер\")", "Открыть"),
|
||||
(Keys.Escape, "log(\"Нажат эскейп\")", "Назад"),
|
||||
(_character_to_key("/"), "log(\"Нажат слэш\")", "Поиск")
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name = None,
|
||||
id = None,
|
||||
classes = None,
|
||||
telegram_client: TelegramClient | None = 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, dialog
|
||||
self.DO_NOTIFY = getenv("DO_NOTIFY")
|
||||
|
||||
async def on_mount(self):
|
||||
async def on_mount(self) -> None:
|
||||
self.limit = 100
|
||||
|
||||
#Получение ID пользователя (себя)
|
||||
self.me_id = await self.telegram_client.get_peer_id("me")
|
||||
# Получение объекта контейнера чатов
|
||||
self.chat_container = self\
|
||||
.query_one("#main_container")\
|
||||
.query_one("#chats")\
|
||||
.query_one("#chat_container")
|
||||
|
||||
self.search_input = self.query_one("#search_input")
|
||||
|
||||
log("Первоначальная загрузка виджетов чатов...")
|
||||
print("Первоначальная загрузка виджетов чатов...")
|
||||
self.mount_chats(
|
||||
len(
|
||||
await self.telegram_client.get_dialogs(
|
||||
@ -103,37 +95,44 @@ class ChatScreen(Screen):
|
||||
)
|
||||
)
|
||||
)
|
||||
log("Первоначальная загрузка виджетов чата завершена")
|
||||
print("Первоначальная загрузка виджетов чата завершена")
|
||||
|
||||
self.is_chat_update_blocked = False
|
||||
await self.update_chat_list()
|
||||
print("Первоначальная загрузка чатов завершена")
|
||||
|
||||
log("Первоначальная загрузка чатов завершена")
|
||||
|
||||
# Автообновление чатов при следующих событиях
|
||||
for event in (
|
||||
events.NewMessage,
|
||||
events.MessageDeleted,
|
||||
events.MessageEdited
|
||||
):
|
||||
self.telegram_client.on(event())(self.update_chat_list)
|
||||
self.telegram_client.on(events.NewMessage)(self.notify_send)
|
||||
|
||||
def mount_chats(self, limit: int):
|
||||
log("Загрузка виджетов чатов...")
|
||||
def mount_chats(self, limit: int) -> None:
|
||||
"""Функция маунта чатов"""
|
||||
print("Загрузка виджетов чатов...")
|
||||
|
||||
# Счёт текущего количества примонтированных чатов
|
||||
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("Виджеты чатов загружены")
|
||||
print("Виджеты чатов загружены")
|
||||
|
||||
async def update_chat_list(self, event = None):
|
||||
log("Запрос обновления чатов")
|
||||
async def update_chat_list(self, event = None) -> None:
|
||||
"""Функция обновления чатов (и уведомления)"""
|
||||
print("Запрос обновления чатов")
|
||||
|
||||
if not self.is_chat_update_blocked:
|
||||
self.is_chat_update_blocked = True
|
||||
@ -141,96 +140,62 @@ class ChatScreen(Screen):
|
||||
dialogs = await self.telegram_client.get_dialogs(
|
||||
limit=self.limit, archived=False
|
||||
)
|
||||
log("Получены диалоги")
|
||||
|
||||
# Фильтруем диалоги по поисковому запросу
|
||||
if self.search_query:
|
||||
dialogs = [
|
||||
d for d in dialogs
|
||||
if self.search_query.lower() in \
|
||||
normalize_text(d.name).lower()
|
||||
]
|
||||
print("Получены диалоги")
|
||||
|
||||
# Маунт виджетов чатов в панели чатов по лимиту
|
||||
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))
|
||||
|
||||
chat.peername = str(dialogs[i].name)
|
||||
chat.is_group = dialogs[i].is_group
|
||||
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)
|
||||
|
||||
try:
|
||||
is_my_msg = \
|
||||
dialogs[i].message.from_id.user_id == self.me_id
|
||||
except:
|
||||
is_my_msg = dialogs[i].id == self.me_id
|
||||
|
||||
if dialogs[i].is_group and is_my_msg:
|
||||
chat.username = "Вы"
|
||||
chat.msg = str(dialogs[i].message.message)
|
||||
elif dialogs[i].is_group:
|
||||
chat.username = str(
|
||||
get_display_name(dialogs[i].message.sender)
|
||||
)
|
||||
chat.msg = str(dialogs[i].message.message)
|
||||
elif is_my_msg:
|
||||
chat.msg = "Вы: " * is_my_msg + str(
|
||||
dialogs[i].message.message
|
||||
)
|
||||
else:
|
||||
chat.msg = str(dialogs[i].message.message)
|
||||
|
||||
self.is_chat_update_blocked = False
|
||||
log("Чаты обновлены")
|
||||
print("Чаты обновлены")
|
||||
else:
|
||||
log("Обновление чатов невозможно: уже выполняется")
|
||||
print("Обновление чатов невозможно: уже выполняется")
|
||||
|
||||
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 notify_send(self, event) -> None:
|
||||
if not event:
|
||||
return None
|
||||
if bool(self.DO_NOTIFY) and event.mentioned and not self.app.focused:
|
||||
system(f"notify-send \"Вас упомянули\" Talc")
|
||||
|
||||
def on_key(self, event: Key) -> None:
|
||||
if event.key == Keys.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
|
||||
|
||||
match event.key:
|
||||
case Keys.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)
|
||||
case Keys.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)
|
||||
case Keys.Enter:
|
||||
chats[self.selected_chat_index].on_click()
|
||||
case Keys.Escape:
|
||||
# Возвращаемся к списку чатов
|
||||
self.app.pop_screen()
|
||||
self.app.push_screen("chats")
|
||||
case "/": #Не работает: нужен кейкод слэша
|
||||
# Фокус на поиск
|
||||
self.focused_element = "search"
|
||||
self.search_input.focus()
|
||||
self.update_chat_list()
|
||||
|
||||
def compose(self):
|
||||
yield Footer()
|
||||
with Horizontal(id="main_container"):
|
||||
with Vertical(id="chats"):
|
||||
yield Input(placeholder=normalize_text("Поиск чатов..."), id="search_input")
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Footer() # Нижняя панель с подсказками
|
||||
with Horizontal(id="main_container"): # Основной контейнер
|
||||
with Horizontal(id="chats"):
|
||||
yield VerticalScroll(id="chat_container")
|
||||
yield ContentSwitcher(id="dialog_switcher")
|
||||
#yield Dialog(telegram_client=self.telegram_client)
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise Exception("Запущен не тот файл. Запустите main.py.")
|
||||
#TODO: сделать кнопку, чтобы прогрузить больше чатов,
|
||||
# или ленивую прокрутку
|
||||
with ContentSwitcher(id="dialog_switcher"):
|
||||
# ↑ Внутри него как раз крутятся диалоги
|
||||
yield Label(
|
||||
"Нажмите на чат в панели слева, чтобы начать общаться",
|
||||
id="begin_talk_label"
|
||||
) #TODO: не показывается надпись, надо будет исправить
|
||||
|
@ -10,6 +10,10 @@ Chat {
|
||||
height: 3;
|
||||
}
|
||||
|
||||
Chat Horizontal Vertical Label{
|
||||
height: 1;
|
||||
}
|
||||
|
||||
Rule {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
@ -42,12 +46,13 @@ Message Container {
|
||||
align: right middle;
|
||||
}
|
||||
|
||||
.is_me_true Static {
|
||||
.is_me_true Container Label {
|
||||
border: solid $primary;
|
||||
width: auto;
|
||||
height: auto;
|
||||
text-align: right;
|
||||
text-align: left;
|
||||
min-width: 11;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.is_me_false Container {
|
||||
@ -55,10 +60,24 @@ Message Container {
|
||||
align: left middle;
|
||||
}
|
||||
|
||||
.is_me_false Static {
|
||||
.is_me_false Container Label {
|
||||
border: solid $foreground;
|
||||
width: auto;
|
||||
height: auto;
|
||||
text-align: left;
|
||||
min-width: 11;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
ContentSwitcher {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.begin_talk_label {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-align: center;
|
||||
content-align: center middle;
|
||||
color: $panel;
|
||||
}
|
||||
|
246
src/widgets.py
246
src/widgets.py
@ -5,85 +5,19 @@ 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 telethon import TelegramClient, events, utils
|
||||
from textual.content import Content
|
||||
from telethon import TelegramClient, events, utils, types
|
||||
import datetime
|
||||
import unicodedata
|
||||
import re
|
||||
import emoji
|
||||
import os
|
||||
import tempfile
|
||||
from PIL import Image
|
||||
#import pywhatkit as kit
|
||||
from textual import log
|
||||
from warnings import deprecated
|
||||
|
||||
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('–', '-')
|
||||
# Удаляем все непечатаемые символы
|
||||
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())
|
||||
|
||||
@deprecated("Не работает на моём компьютере.")
|
||||
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 "Ошибка загрузки изображения"
|
||||
from os import getenv
|
||||
|
||||
class Chat(Widget):
|
||||
"""Класс виджета чата для панели чатов"""
|
||||
|
||||
username: Reactive[str] = Reactive(" ", recompose=True)
|
||||
peername: Reactive[str] = Reactive(" ", recompose=True)
|
||||
msg: Reactive[str] = Reactive(" ", recompose=True)
|
||||
is_group: Reactive[bool] = Reactive(False, recompose=True)
|
||||
peer_id: Reactive[int] = Reactive(0)
|
||||
is_selected: Reactive[bool] = Reactive(False)
|
||||
is_focused: Reactive[bool] = Reactive(False)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@ -100,19 +34,14 @@ class Chat(Widget):
|
||||
)
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.switcher = self.screen.query_one(Horizontal).query_one("#dialog_switcher", ContentSwitcher)
|
||||
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
|
||||
|
||||
# Получение ID диалога и создание DOM-ID на его основе
|
||||
dialog_id = f"dialog-{str(self.peer_id)}"
|
||||
|
||||
# Маунт диалога
|
||||
try:
|
||||
self.switcher.mount(Dialog(
|
||||
telegram_client=self.app.telegram_client,
|
||||
@ -120,34 +49,21 @@ class Chat(Widget):
|
||||
id=dialog_id
|
||||
))
|
||||
except:
|
||||
# Диалог уже есть: ничего не делаем
|
||||
pass
|
||||
|
||||
self.switcher.current = dialog_id
|
||||
self.switcher.recompose()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Horizontal(classes="chat-item"):
|
||||
"""
|
||||
# Используем ASCII-символы для рамки
|
||||
yield Label(
|
||||
f"┌───┐\n│ {normalize_text(
|
||||
self.username[:1].upper()
|
||||
):1} │\n└───┘"
|
||||
)
|
||||
with Horizontal():
|
||||
yield Label(f"┌───┐\n│ {self.peername[:1]:1} │\n└───┘")
|
||||
with Vertical():
|
||||
yield Label(normalize_text(self.username), id="name")
|
||||
yield Label(normalize_text(self.msg), id="last_msg")
|
||||
"""
|
||||
yield Label(f"┌───┐\n│ {self.username[:1].upper():1} │\n└───┘")
|
||||
with Vertical():
|
||||
yield Label(self.username, id="name")
|
||||
yield Label(self.peername, id="peername")
|
||||
if self.is_group:
|
||||
yield Label(self.username, id="name")
|
||||
yield Label(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):
|
||||
"""Класс окна диалога"""
|
||||
|
||||
@ -158,25 +74,23 @@ class Dialog(Widget):
|
||||
disabled=None,
|
||||
telegram_client: TelegramClient | None = None,
|
||||
chat_id = None
|
||||
) -> 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
|
||||
self.timezone = datetime.timezone(
|
||||
datetime.timedelta(hours=int(getenv("UTC_OFFSET")))
|
||||
)
|
||||
|
||||
async def on_mount(self) -> None:
|
||||
self.limit = 50 # Увеличиваем начальное количество сообщений
|
||||
self.messages_loaded = self.limit
|
||||
self.limit = 10
|
||||
|
||||
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 (
|
||||
@ -188,6 +102,8 @@ class Dialog(Widget):
|
||||
event(chats=(self.chat_id))
|
||||
)(self.update_dialog)
|
||||
|
||||
self.dialog.scroll_down(animate=False, immediate=True)
|
||||
|
||||
def mount_messages(self, limit: int) -> None:
|
||||
print("Загрузка виджетов сообщений...")
|
||||
|
||||
@ -204,7 +120,7 @@ class Dialog(Widget):
|
||||
self.dialog.query(Message).last().remove()
|
||||
|
||||
async def update_dialog(self, event = None) -> None:
|
||||
log("Запрос обновления сообщений")
|
||||
print("Запрос обновления сообщений")
|
||||
|
||||
if not self.is_msg_update_blocked:
|
||||
self.is_msg_update_blocked = True
|
||||
@ -212,31 +128,52 @@ class Dialog(Widget):
|
||||
messages = await self.telegram_client.get_messages(
|
||||
entity=self.chat_id, limit=self.limit
|
||||
)
|
||||
log("Получены сообщения")
|
||||
print("Получены сообщения")
|
||||
|
||||
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))
|
||||
message = Content(
|
||||
"[Медиа] " * \
|
||||
bool(messages[i].media) + \
|
||||
str(messages[i].message)
|
||||
)
|
||||
else:
|
||||
message = Content("[Медиа]" * bool(messages[i].media) + "")
|
||||
|
||||
entities = messages[i].entities
|
||||
if entities:
|
||||
for entity in entities:
|
||||
match type(entity):
|
||||
case types.MessageEntityBold:
|
||||
message = message.stylize(
|
||||
"bold",
|
||||
entity.offset,
|
||||
entity.offset + entity.length
|
||||
)
|
||||
case types.MessageEntityUnderline:
|
||||
message = message.stylize(
|
||||
"underline",
|
||||
entity.offset,
|
||||
entity.offset + entity.length
|
||||
)
|
||||
case types.MessageEntityItalic:
|
||||
message = message.stylize(
|
||||
"italic",
|
||||
entity.offset,
|
||||
entity.offset + entity.length
|
||||
)
|
||||
case types.MessageEntityStrike:
|
||||
message = message.stylize(
|
||||
"strike",
|
||||
entity.offset,
|
||||
entity.offset + entity.length
|
||||
)
|
||||
|
||||
msg.message = message
|
||||
|
||||
try:
|
||||
is_me = messages[i].from_id.user_id == self.me.id
|
||||
@ -244,52 +181,26 @@ class Dialog(Widget):
|
||||
is_me = False
|
||||
|
||||
msg.is_me = is_me
|
||||
msg.username = normalize_text(utils.get_display_name(messages[i].sender))
|
||||
msg.username = utils.get_display_name(messages[i].sender)
|
||||
msg.send_time = messages[i]\
|
||||
.date\
|
||||
.astimezone(datetime.timezone.utc)\
|
||||
.astimezone(self.timezone)\
|
||||
.strftime("%H:%M")
|
||||
|
||||
self.is_msg_update_blocked = False
|
||||
log("Сообщения обновлены")
|
||||
print("Сообщения обновлены")
|
||||
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 = "Загрузить еще"
|
||||
print("Обновление сообщений невозможно: уже выполняется")
|
||||
|
||||
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:
|
||||
if event.button.id == "load_more":
|
||||
await self.load_more_messages()
|
||||
else:
|
||||
await self.send_message()
|
||||
async def on_button_pressed(self, event = None) -> None:
|
||||
await self.send_message()
|
||||
|
||||
async def on_input_submitted(self, event = None) -> None:
|
||||
await self.send_message()
|
||||
@ -298,7 +209,7 @@ class Dialog(Widget):
|
||||
try:
|
||||
await self.telegram_client.send_message(
|
||||
self.chat_id,
|
||||
normalize_text(str(self.msg_input.value))
|
||||
str(self.msg_input.value)
|
||||
)
|
||||
except ValueError:
|
||||
self.app.notify("Ошибка отправки")
|
||||
@ -308,7 +219,7 @@ class Dialog(Widget):
|
||||
class Message(Widget):
|
||||
"""Класс виджета сообщений для окна диалога"""
|
||||
|
||||
message: Reactive[str] = Reactive("", recompose=True)
|
||||
message: Reactive[Content] = Reactive("", recompose=True)
|
||||
is_me: Reactive[bool] = Reactive(False, recompose=True)
|
||||
username: Reactive[str] = Reactive("", recompose=True)
|
||||
send_time: Reactive[str] = Reactive("", recompose=True)
|
||||
@ -326,17 +237,14 @@ class Message(Widget):
|
||||
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
|
||||
label = Label(self.message, markup=False)
|
||||
label.border_title = self.username * (not self.is_me)
|
||||
label.border_subtitle = self.send_time
|
||||
|
||||
with Container():
|
||||
yield static
|
||||
yield label
|
||||
|
||||
if self.is_me:
|
||||
self.classes = "is_me_true"
|
||||
else:
|
||||
self.classes = "is_me_false"
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise Exception("Запущен не тот файл. Запустите main.py.")
|
||||
|
Loading…
x
Reference in New Issue
Block a user