Compare commits

..

5 Commits

Author SHA1 Message Date
wheelchairy
c01d5954dc Should work, at least the chat sheet. I'll finalize it completely soon. 2025-03-27 00:24:18 +03:00
Andrew Linim
8b135af7ea
Include official apikeys for lazy people
want get your account BANNED? use official keys with your telethon app 
2025-03-27 00:07:47 +03:00
wheelchairy
8cb35e12c2 Switching to .env 2025-03-27 00:01:24 +03:00
Lain
1e93c8cb47
Delete tokens.py
Addendum to the last commit about deleting tokens.py
2025-03-26 23:53:58 +03:00
wheelchairy
2be3e5e1d5 Small change: don't use tokens.py right away, make .py.example so we don't accidentally merge our tokens. This is important. 2025-03-26 23:52:38 +03:00
9 changed files with 444 additions and 52 deletions

7
.env.example Normal file
View File

@ -0,0 +1,7 @@
# ВАЖНО: Это официальные ключи Telegram Desktop.
# Их использование крайне нежелательно!
# Получите свои API-ключи приложения на https://my.telegram.org/apps и вставьте их ниже.
# Спасибо за понимание!
API_ID=2040
API_HASH=b18441a1ff607e10a989891a5462e627

4
.gitignore vendored
View File

@ -2,4 +2,6 @@ test.py
*.session
*.session-journal
__pycache__
*/__pycache__
*/__pycache__
tokens.py
.env

View File

@ -1,3 +1,42 @@
# Тальк
Тальк — клиент Telegram с текстовым пользовательским интерфейсом, написанный на Python, Telethon и Textual.
## Требования
- Python 3.12
- pyenv (рекомендуется для управления версиями Python)
## Установка
1. Установите Python 3.12 с помощью pyenv:
```bash
pyenv install 3.12
pyenv local 3.12
```
2. Создайте и активируйте виртуальное окружение:
```bash
python -m venv .venv
source .venv/bin/activate # для Linux/macOS
# или
.venv\Scripts\activate # для Windows
```
3. Установите зависимости:
```bash
pip install -r requirements.txt
```
4. Настройте переменные окружения:
```bash
cp .env.example .env
# Отредактируйте .env файл, добавив свои API ключи
# Получите ключи на https://my.telegram.org/apps
```
## Запуск
```bash
python src/app.py
```

View File

@ -1,2 +1,6 @@
textual
telethon
telethon
python-dotenv
emoji
Pillow
pywhatkit

View File

@ -1,14 +1,39 @@
"""Главный файл приложения"""
import os
import sys
from dotenv import load_dotenv
from telethon import TelegramClient, events
from textual.app import App
from tokens import api_id, api_hash
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")
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 = int(api_id)
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)

View File

@ -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)

View File

@ -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():

119
style.tcss Normal file
View File

@ -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;
}

View File

@ -1,4 +0,0 @@
"""Получите свои API-ключи на https://my.telegram.org/apps"""
api_id = 12345
api_hash = "0123456789abcdef"