Compare commits

...

4 Commits

8 changed files with 176 additions and 181 deletions

View File

@ -9,7 +9,9 @@ API_ID=2040
API_HASH=b18441a1ff607e10a989891a5462e627
# Конфиг
CURRENT_USER = "user"
CURRENT_USER = "user" # Name for user session file
DO_NOTIFY = 0 # Linux-only for now
UTC_OFFSET = 0
UTC_OFFSET = 0 # UTC timezone offset
LANGUAGE = "en" # en, ru
CHATS_LIMIT = 100 # Chats mount limit
MESSAGES_LIMIT = 50 # Messages mount limit

View File

@ -2,6 +2,10 @@
Тальк — клиент Telegram с текстовым пользовательским интерфейсом, написанный на Python, Telethon и Textual.
Будьте добры, по-русски | [In English, please](readme/README-en.md)
![Окно приложения](https://github.com/user-attachments/assets/b2cecda6-b9c0-44d5-ae6d-894e73f0ca47)
## Требования
- Python 3.12

46
readme/README-en.md Normal file
View File

@ -0,0 +1,46 @@
# Talc
Talc is a Telegram client with TUI, written in Python, Telethon and Textual.
[Будьте добры, по-русски](../README.md) | In English, please
![App window](https://github.com/user-attachments/assets/b2cecda6-b9c0-44d5-ae6d-894e73f0ca47)
## Requirements
- Python 3.12
- pyenv (recommended for managing Python versions)
## Installation
1. Install Python 3.12 via pyenv:
```bash
pyenv install 3.12
pyenv local 3.12
```
2. Create and activate virtual enviroment:
```bash
python -m venv .venv
source .venv/bin/activate # for Linux/macOS
# or
.venv\Scripts\activate # for Windows
```
3. Install dependencies:
```bash
pip install -r requirements.txt
```
4. Configure enviroment variables:
```bash
cp .env.example .env
# Configure .env file and add your API-keys
# Get keys on https://my.telegram.org/apps
```
## Run
```bash
./main.py
```

View File

@ -10,12 +10,12 @@ from src.screens import AuthScreen, ChatScreen
import src.locales
load_dotenv()
API_ID = getenv("API_ID")
API_HASH = getenv("API_HASH")
LANGUAGE = getenv("LANGUAGE")
UTC_OFFSET = getenv("UTC_OFFSET")
env_consts = ["API_ID", "API_HASH", "LANGUAGE", "UTC_OFFSET", "CHATS_LIMIT",
"MESSAGES_LIMIT"]
for name in env_consts:
exec(f"{name} = getenv(\"{name}\")")
if "" in [API_ID, API_HASH, LANGUAGE, UTC_OFFSET]:
if "" in list(map(lambda x: globals()[x], env_consts)):
raise ValueError(
"Недостаточно параметров в .env файле."
"Скопируйте .env.example в .env и заполните свои API-ключи."
@ -23,9 +23,10 @@ if "" in [API_ID, API_HASH, LANGUAGE, UTC_OFFSET]:
"Copy .env.example into .env and fill your API-keys."
)
API_ID = int(API_ID)
API_ID = int(API_ID) # type: ignore
locale = dict(zip(
getattr(src.locales, "codes"), getattr(src.locales, LANGUAGE)
getattr(src.locales, "codes"),
getattr(src.locales, LANGUAGE) # type: ignore
))
class Talc(App):
@ -50,13 +51,15 @@ class Talc(App):
):
super().__init__(driver_class, css_path, watch_css, ansi_color)
self.locale = locale
self.timezone = timezone(timedelta(hours=int(UTC_OFFSET)))
self.timezone = timezone(timedelta(hours=int(UTC_OFFSET))) # type: ignore
self.CHATS_LIMIT = CHATS_LIMIT # type: ignore
self.MESSAGES_LIMIT = MESSAGES_LIMIT # type: ignore
async def on_mount(self) -> None:
self.telegram_client = TelegramClient(
getenv("CURRENT_USER"),
API_ID,
API_HASH
API_HASH # type: ignore
)
await self.telegram_client.connect()

View File

@ -30,8 +30,15 @@ class AuthScreen(Screen):
def compose(self) -> ComposeResult:
with Vertical(id="auth_container"):
yield Label(self.locale["auth_greeting"])
yield Input(placeholder=self.locale["phone_number"], id="phone")
yield Input(placeholder=self.locale["code"], id="code", disabled=True)
yield Input(
placeholder=self.locale["phone_number"],
id="phone"
)
yield Input(
placeholder=self.locale["code"],
id="code",
disabled=True
)
yield Input(
placeholder=self.locale["password"],
id="password",
@ -79,7 +86,7 @@ class ChatScreen(Screen):
self.locale = self.app.locale
async def on_mount(self) -> None:
self.limit = 100
self.limit = int(self.app.CHATS_LIMIT)
# Получение ID пользователя (себя)
self.me_id = await self.telegram_client.get_peer_id("me")
@ -88,6 +95,8 @@ class ChatScreen(Screen):
.query_one("#main_container")\
.query_one("#chats")\
.query_one("#chat_container")
self.switcher = self.screen.query_one(Horizontal)\
.query_one("#dialog_switcher", ContentSwitcher)
print("Первоначальная загрузка виджетов чатов...")
self.mount_chats(
@ -120,7 +129,7 @@ class ChatScreen(Screen):
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)
@ -178,11 +187,24 @@ class ChatScreen(Screen):
else:
chat.msg = str(dialogs[i].message.message)
if self.switcher.current is not None:
current_dialog = \
self.switcher.query_one(f"#{self.switcher.current}")
if chat.peer_id == int(current_dialog.id[7:]):
chat.add_class("selected_chat")
else:
chat.remove_class("selected_chat")
self.is_chat_update_blocked = False
print("Чаты обновлены")
else:
print("Обновление чатов невозможно: уже выполняется")
if self.switcher.current is not None:
current_dialog = \
self.switcher.query_one(f"#{self.switcher.current}")
await current_dialog.update_dialog()
def notify_send(self, event) -> None:
if not event:
return None
@ -196,9 +218,9 @@ class ChatScreen(Screen):
yield VerticalScroll(id="chat_container")
#TODO: сделать кнопку, чтобы прогрузить больше чатов,
# или ленивую прокрутку
with ContentSwitcher(id="dialog_switcher"):
yield ContentSwitcher(id="dialog_switcher")
# ↑ Внутри него как раз крутятся диалоги
yield Label(
self.locale["start_converse"],
id="start_converse_label"
) #TODO: не показывается надпись, надо будет исправить
#yield Label(
# self.locale["start_converse"],
# id="start_converse_label"
#) #TODO: не показывается надпись, надо будет исправить

View File

@ -74,10 +74,44 @@ ContentSwitcher {
height: 100%;
}
.start_converse_label {
#start_converse_label {
width: 100%;
height: 100%;
text-align: center;
content-align: center middle;
color: $panel;
}
TopBar {
max-height: 3;
width: 100%;
background: $surface;
}
TopBar Horizontal {
align: left middle;
}
.avatar {
border: solid $foreground;
text-align: center;
width: 5;
height: 3;
}
.peername_top_bar {
padding: 1;
}
.odd {
background: $surface;
}
.selected_chat {
background: $foreground;
color: $background
}
.selected_chat .avatar{
border: solid $background;
}

View File

@ -2,24 +2,22 @@
from textual.containers import Horizontal, Vertical, Container, VerticalScroll
from textual.widget import Widget
from textual.reactive import Reactive
from textual.reactive import reactive
from textual.widgets import Input, Button, Label, Static, ContentSwitcher
from textual.app import ComposeResult, RenderResult
from textual.content import Content
from textual.style import Style
from telethon import TelegramClient, events, utils, types
import datetime
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)
is_channel: Reactive[bool] = Reactive(False, recompose=True)
peer_id: Reactive[int] = Reactive(0)
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)
is_channel: reactive[bool] = reactive(False)
peer_id: reactive[int] = reactive(0)
def __init__(
self,
@ -39,6 +37,11 @@ class Chat(Widget):
self.switcher = self.screen.query_one(Horizontal)\
.query_one("#dialog_switcher", ContentSwitcher)
if int(self.id[5:]) % 2 != 0:
self.add_class("odd")
else:
self.add_class("even")
def on_click(self) -> None:
# Получение ID диалога и создание DOM-ID на его основе
dialog_id = f"dialog-{str(self.peer_id)}"
@ -60,7 +63,7 @@ class Chat(Widget):
def compose(self) -> ComposeResult:
with Horizontal():
yield Label(f"┌───┐\n{self.peername[:1]:1}\n└───┘")
yield Label(self.peername[:1], classes="avatar")
with Vertical():
yield Label(self.peername, id="peername", markup=False)
if self.is_group:
@ -87,25 +90,19 @@ class Dialog(Widget):
self.is_channel = is_channel
async def on_mount(self) -> None:
self.limit = 10
self.limit = int(self.app.MESSAGES_LIMIT)
if not self.is_channel:
self.msg_input = self.query_one("#msg_input")
self.dialog = self.query_one(Vertical).query_one("#dialog")
self.top_bar = self.query_one(Vertical).query_one(TopBar)
self.switcher = self.screen.query_one(Horizontal)\
.query_one("#dialog_switcher", ContentSwitcher)
self.me = await self.telegram_client.get_me()
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)
#self.dialog.scroll_down(animate=False, immediate=True)
def mount_messages(self, limit: int) -> None:
@ -126,7 +123,7 @@ class Dialog(Widget):
async def update_dialog(self, event = None) -> None:
print("Запрос обновления сообщений")
if not self.is_msg_update_blocked:
if not self.is_msg_update_blocked and self.switcher.current == self.id:
self.is_msg_update_blocked = True
messages = await self.telegram_client.get_messages(
@ -186,11 +183,15 @@ class Dialog(Widget):
msg.is_me = is_me
msg.username = utils.get_display_name(messages[i].sender)
msg.send_time = messages[i]\
msg.info = messages[i]\
.date\
.astimezone(self.timezone)\
.strftime("%H:%M")
self.top_bar.peername = utils.get_display_name(
await self.telegram_client.get_entity(self.chat_id)
)
self.is_msg_update_blocked = False
print("Сообщения обновлены")
else:
@ -198,6 +199,7 @@ class Dialog(Widget):
def compose(self) -> ComposeResult:
with Vertical():
yield TopBar()
yield VerticalScroll(id="dialog")
if not self.is_channel:
with Horizontal(id="input_place"):
@ -227,27 +229,18 @@ class Dialog(Widget):
class Message(Widget):
"""Класс виджета сообщений для окна диалога"""
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)
message: reactive[Content] = reactive("", recompose=True)
is_me: reactive[bool] = reactive(False, recompose=True)
username: reactive[str] = reactive("", recompose=True)
info: 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 __init__(self, id=None) -> None:
super().__init__(id=id)
def compose(self) -> ComposeResult:
label = Label(self.message, markup=False)
label.border_title = self.username * (not self.is_me)
label.border_subtitle = self.send_time
label.border_subtitle = self.info
with Container():
yield label
@ -256,3 +249,13 @@ class Message(Widget):
self.classes = "is_me_true"
else:
self.classes = "is_me_false"
class TopBar(Widget):
"""Класс виджета верхней панели для окна диалога"""
peername: reactive[str] = reactive(" ", recompose=True)
def compose(self) -> ComposeResult:
with Horizontal():
yield Label(self.peername[:1], classes="avatar")
yield Label(self.peername, classes="peername_top_bar")

View File

@ -1,119 +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-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;
}