mirror of
https://github.com/artemium428/428th-exchange-bot.git
synced 2026-09-15 16:56:20 +00:00
Initial commit: 428th exchange Telegram bot.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
commit
8f6d2c07d6
17 changed files with 1974 additions and 0 deletions
13
.env.example
Normal file
13
.env.example
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
BOT_TOKEN=123456:ABC-DEF
|
||||||
|
MANAGER_GROUP_ID=-1001234567890
|
||||||
|
DATABASE_PATH=data/bot.db
|
||||||
|
# Optional text overrides (per language). Prefer _RU / _UK.
|
||||||
|
# Bare WELCOME_TEXT / ACK_TEXT / COOLDOWN_TEXT apply only to RU
|
||||||
|
# (so UK keeps catalog strings and the services button stays in sync).
|
||||||
|
# WELCOME_TEXT_RU=Здравствуйте! Напишите ваш запрос.
|
||||||
|
# WELCOME_TEXT_UK=Вітаємо! Напишіть ваш запит.
|
||||||
|
# ACK_TEXT_RU=Спасибо, подождите ответ.
|
||||||
|
# ACK_TEXT_UK=Дякуємо, зачекайте на відповідь.
|
||||||
|
# COOLDOWN_TEXT_RU=Подождите немного, не спамьте.
|
||||||
|
# COOLDOWN_TEXT_UK=Зачекайте трохи, не спамте.
|
||||||
|
CLIENT_COOLDOWN_SECONDS=5
|
||||||
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
.env
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
.DS_Store
|
||||||
72
README.md
Normal file
72
README.md
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
# 428th Exchange Bot
|
||||||
|
|
||||||
|
Telegram-бот — прослойка между клиентом и менеджером.
|
||||||
|
|
||||||
|
Клиент пишет боту в личку. Менеджер отвечает в супергруппе с **Topics**: у каждого клиента своя тема. Клиент получает ответы от имени бота.
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
- Python 3.9+
|
||||||
|
- Бот от [@BotFather](https://t.me/BotFather)
|
||||||
|
- Супергруппа с включёнными темами (Topics)
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cp .env.example .env
|
||||||
|
# отредактируйте .env
|
||||||
|
python -m bot
|
||||||
|
```
|
||||||
|
|
||||||
|
## Настройка Telegram
|
||||||
|
|
||||||
|
1. Создайте бота у @BotFather и скопируйте token в `BOT_TOKEN`.
|
||||||
|
2. Создайте супергруппу, включите **Topics** (настройки группы → Topics).
|
||||||
|
3. Добавьте бота в группу и сделайте **администратором** с правами:
|
||||||
|
- Manage topics
|
||||||
|
- Post messages
|
||||||
|
- Delete messages (нужно для `/del`)
|
||||||
|
4. Узнайте `chat_id` группы и пропишите в `MANAGER_GROUP_ID` (обычно отрицательный, вида `-100…`).
|
||||||
|
- Запустите бота, добавьте его в группу — в лог напишется id чата.
|
||||||
|
- Либо перешлите любое сообщение из группы боту [@userinfobot](https://t.me/userinfobot) / аналогу.
|
||||||
|
5. Напишите боту `/start` с тестового аккаунта и отправьте сообщение — в группе появится новая тема.
|
||||||
|
|
||||||
|
## Переменные окружения
|
||||||
|
|
||||||
|
| Переменная | Описание |
|
||||||
|
|---|---|
|
||||||
|
| `BOT_TOKEN` | Token бота |
|
||||||
|
| `MANAGER_GROUP_ID` | Id супергруппы с Topics |
|
||||||
|
| `DATABASE_PATH` | Путь к SQLite (по умолчанию `data/bot.db`) |
|
||||||
|
| `WELCOME_TEXT_RU` / `WELCOME_TEXT_UK` | Опционально: приветствие для языка |
|
||||||
|
| `ACK_TEXT_RU` / `ACK_TEXT_UK` | Опционально: текст после сообщения клиента |
|
||||||
|
| `COOLDOWN_TEXT_RU` / `COOLDOWN_TEXT_UK` | Опционально: ответ при кулдауне |
|
||||||
|
| `WELCOME_TEXT` / `ACK_TEXT` / `COOLDOWN_TEXT` | Legacy: только для RU (UK не перезаписывается) |
|
||||||
|
| `CLIENT_COOLDOWN_SECONDS` | Кулдаун клиента в секундах (по умолчанию `5`) |
|
||||||
|
|
||||||
|
## Как это работает
|
||||||
|
|
||||||
|
1. Клиент: `/start` → выбор языка (RU/UK) → приветствие, запись в SQLite.
|
||||||
|
2. Клиент пишет сообщение → бот создаёт тему (если ещё нет) и копирует сообщение менеджерам → «Спасибо, подождите ответ».
|
||||||
|
3. Менеджер пишет в теме клиента → бот копирует содержимое клиенту в личку от своего имени.
|
||||||
|
4. Правки сообщений синхронизируются в обе стороны (текст и подписи).
|
||||||
|
5. Реакции синхронизируются в обе стороны.
|
||||||
|
6. Удаление: ответьте на сообщение командой `/del` — копия удалится и у клиента, и у менеджера.
|
||||||
|
7. Язык можно сменить командой `/lang`. Список услуг — кнопка под приветствием.
|
||||||
|
|
||||||
|
Сообщения бота в теме (копии от клиента) обратно клиенту не уходят.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source .venv/bin/activate
|
||||||
|
python -m bot
|
||||||
|
```
|
||||||
|
|
||||||
|
Данные клиентов хранятся в SQLite (`telegram_id` ↔ `topic_id`).
|
||||||
|
Рядом с `bot.db` могут появляться файлы `-wal` / `-shm` (режим WAL) — их тоже не нужно коммитить (`data/` в `.gitignore`).
|
||||||
|
|
||||||
|
При старте бот проверяет `MANAGER_GROUP_ID`: супергруппа с Topics, бот — админ с правами Manage topics и Delete messages. При ошибке процесс завершится с понятным текстом в логе и stderr.
|
||||||
1
bot/__init__.py
Normal file
1
bot/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Telegram relay bot: client DM ↔ manager forum topics."""
|
||||||
4
bot/__main__.py
Normal file
4
bot/__main__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
from bot.main import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
104
bot/config.py
Normal file
104
bot/config.py
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
DEFAULT_CLIENT_COOLDOWN_SECONDS = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Config:
|
||||||
|
bot_token: str
|
||||||
|
manager_group_id: int
|
||||||
|
database_path: Path
|
||||||
|
# Optional per-language overrides for i18n keys (lang -> text).
|
||||||
|
welcome_text: Dict[str, str]
|
||||||
|
ack_text: Dict[str, str]
|
||||||
|
cooldown_text: Dict[str, str]
|
||||||
|
client_cooldown_seconds: float
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_env(name: str) -> Optional[str]:
|
||||||
|
raw = os.getenv(name)
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
value = raw.strip()
|
||||||
|
return value or None
|
||||||
|
|
||||||
|
|
||||||
|
def _text_overrides_for(key: str) -> Dict[str, str]:
|
||||||
|
"""Load per-language text overrides.
|
||||||
|
|
||||||
|
Supported env vars (example for welcome):
|
||||||
|
WELCOME_TEXT_RU / WELCOME_TEXT_UK — language-specific
|
||||||
|
WELCOME_TEXT — legacy; applies only to default language (ru), so UK
|
||||||
|
keeps its catalog string and the services button stays consistent.
|
||||||
|
"""
|
||||||
|
from bot.i18n import DEFAULT_LANGUAGE, SUPPORTED_LANGUAGES
|
||||||
|
|
||||||
|
overrides: Dict[str, str] = {}
|
||||||
|
legacy = _optional_env(key)
|
||||||
|
if legacy is not None:
|
||||||
|
overrides[DEFAULT_LANGUAGE] = legacy
|
||||||
|
|
||||||
|
for lang in SUPPORTED_LANGUAGES:
|
||||||
|
specific = _optional_env(f"{key}_{lang.upper()}")
|
||||||
|
if specific is not None:
|
||||||
|
overrides[lang] = specific
|
||||||
|
return overrides
|
||||||
|
|
||||||
|
|
||||||
|
def load_config() -> Config:
|
||||||
|
token = os.getenv("BOT_TOKEN", "").strip()
|
||||||
|
if not token:
|
||||||
|
raise RuntimeError("BOT_TOKEN is not set")
|
||||||
|
|
||||||
|
group_raw = os.getenv("MANAGER_GROUP_ID", "").strip()
|
||||||
|
if not group_raw:
|
||||||
|
raise RuntimeError("MANAGER_GROUP_ID is not set")
|
||||||
|
|
||||||
|
try:
|
||||||
|
manager_group_id = int(group_raw)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RuntimeError("MANAGER_GROUP_ID must be an integer") from exc
|
||||||
|
|
||||||
|
db_path = Path(os.getenv("DATABASE_PATH", "data/bot.db")).expanduser()
|
||||||
|
|
||||||
|
cooldown_raw = os.getenv("CLIENT_COOLDOWN_SECONDS", str(DEFAULT_CLIENT_COOLDOWN_SECONDS))
|
||||||
|
try:
|
||||||
|
client_cooldown_seconds = float(cooldown_raw)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RuntimeError("CLIENT_COOLDOWN_SECONDS must be a number") from exc
|
||||||
|
if client_cooldown_seconds < 0:
|
||||||
|
raise RuntimeError("CLIENT_COOLDOWN_SECONDS must be >= 0")
|
||||||
|
|
||||||
|
return Config(
|
||||||
|
bot_token=token,
|
||||||
|
manager_group_id=manager_group_id,
|
||||||
|
database_path=db_path,
|
||||||
|
welcome_text=_text_overrides_for("WELCOME_TEXT"),
|
||||||
|
ack_text=_text_overrides_for("ACK_TEXT"),
|
||||||
|
cooldown_text=_text_overrides_for("COOLDOWN_TEXT"),
|
||||||
|
client_cooldown_seconds=client_cooldown_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_text_overrides(config: Config) -> None:
|
||||||
|
"""Apply optional env text overrides into the i18n catalog (per language)."""
|
||||||
|
from bot.i18n import TEXTS
|
||||||
|
|
||||||
|
for lang, text in config.welcome_text.items():
|
||||||
|
if lang in TEXTS:
|
||||||
|
TEXTS[lang]["welcome"] = text
|
||||||
|
for lang, text in config.ack_text.items():
|
||||||
|
if lang in TEXTS:
|
||||||
|
TEXTS[lang]["ack"] = text
|
||||||
|
for lang, text in config.cooldown_text.items():
|
||||||
|
if lang in TEXTS:
|
||||||
|
TEXTS[lang]["cooldown"] = text
|
||||||
3
bot/db/__init__.py
Normal file
3
bot/db/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from bot.db.repository import Client, ClientRepository
|
||||||
|
|
||||||
|
__all__ = ["Client", "ClientRepository"]
|
||||||
281
bot/db/repository.py
Normal file
281
bot/db/repository.py
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS clients (
|
||||||
|
telegram_id INTEGER PRIMARY KEY,
|
||||||
|
username TEXT,
|
||||||
|
first_name TEXT,
|
||||||
|
last_name TEXT,
|
||||||
|
topic_id INTEGER UNIQUE,
|
||||||
|
language TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_clients_topic_id ON clients(topic_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS message_links (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
client_telegram_id INTEGER NOT NULL,
|
||||||
|
client_message_id INTEGER NOT NULL,
|
||||||
|
manager_message_id INTEGER NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
UNIQUE(client_telegram_id, client_message_id),
|
||||||
|
UNIQUE(manager_message_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_message_links_manager
|
||||||
|
ON message_links(manager_message_id);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Client:
|
||||||
|
telegram_id: int
|
||||||
|
username: Optional[str]
|
||||||
|
first_name: Optional[str]
|
||||||
|
last_name: Optional[str]
|
||||||
|
topic_id: Optional[int]
|
||||||
|
language: Optional[str]
|
||||||
|
created_at: str
|
||||||
|
updated_at: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MessageLink:
|
||||||
|
client_telegram_id: int
|
||||||
|
client_message_id: int
|
||||||
|
manager_message_id: int
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
class ClientRepository:
|
||||||
|
def __init__(self, db_path: Path) -> None:
|
||||||
|
self._db_path = db_path
|
||||||
|
self._conn: Optional[aiosqlite.Connection] = None
|
||||||
|
|
||||||
|
async def connect(self) -> None:
|
||||||
|
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._conn = await aiosqlite.connect(self._db_path)
|
||||||
|
self._conn.row_factory = aiosqlite.Row
|
||||||
|
await self._conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
await self._conn.execute("PRAGMA busy_timeout=5000")
|
||||||
|
await self._conn.executescript(SCHEMA)
|
||||||
|
await self._ensure_columns()
|
||||||
|
await self._conn.commit()
|
||||||
|
|
||||||
|
async def _ensure_columns(self) -> None:
|
||||||
|
conn = self._require_conn()
|
||||||
|
async with conn.execute("PRAGMA table_info(clients)") as cursor:
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
columns = {row[1] for row in rows}
|
||||||
|
if "language" not in columns:
|
||||||
|
await conn.execute("ALTER TABLE clients ADD COLUMN language TEXT")
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._conn is not None:
|
||||||
|
await self._conn.close()
|
||||||
|
self._conn = None
|
||||||
|
|
||||||
|
def _require_conn(self) -> aiosqlite.Connection:
|
||||||
|
if self._conn is None:
|
||||||
|
raise RuntimeError("Database is not connected")
|
||||||
|
return self._conn
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _row_to_client(row: aiosqlite.Row) -> Client:
|
||||||
|
return Client(
|
||||||
|
telegram_id=row["telegram_id"],
|
||||||
|
username=row["username"],
|
||||||
|
first_name=row["first_name"],
|
||||||
|
last_name=row["last_name"],
|
||||||
|
topic_id=row["topic_id"],
|
||||||
|
language=row["language"],
|
||||||
|
created_at=row["created_at"],
|
||||||
|
updated_at=row["updated_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def upsert_client(
|
||||||
|
self,
|
||||||
|
telegram_id: int,
|
||||||
|
username: Optional[str],
|
||||||
|
first_name: Optional[str],
|
||||||
|
last_name: Optional[str],
|
||||||
|
) -> Client:
|
||||||
|
conn = self._require_conn()
|
||||||
|
now = _utcnow()
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO clients (
|
||||||
|
telegram_id, username, first_name, last_name, topic_id, language,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, NULL, NULL, ?, ?)
|
||||||
|
ON CONFLICT(telegram_id) DO UPDATE SET
|
||||||
|
username = excluded.username,
|
||||||
|
first_name = excluded.first_name,
|
||||||
|
last_name = excluded.last_name,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
""",
|
||||||
|
(telegram_id, username, first_name, last_name, now, now),
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
client = await self.get_by_telegram_id(telegram_id)
|
||||||
|
if client is None:
|
||||||
|
raise RuntimeError(f"Failed to upsert client {telegram_id}")
|
||||||
|
return client
|
||||||
|
|
||||||
|
async def get_by_telegram_id(self, telegram_id: int) -> Optional[Client]:
|
||||||
|
conn = self._require_conn()
|
||||||
|
async with conn.execute(
|
||||||
|
"SELECT * FROM clients WHERE telegram_id = ?",
|
||||||
|
(telegram_id,),
|
||||||
|
) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return self._row_to_client(row) if row else None
|
||||||
|
|
||||||
|
async def get_by_topic_id(self, topic_id: int) -> Optional[Client]:
|
||||||
|
conn = self._require_conn()
|
||||||
|
async with conn.execute(
|
||||||
|
"SELECT * FROM clients WHERE topic_id = ?",
|
||||||
|
(topic_id,),
|
||||||
|
) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return self._row_to_client(row) if row else None
|
||||||
|
|
||||||
|
async def set_topic_id(self, telegram_id: int, topic_id: int) -> None:
|
||||||
|
conn = self._require_conn()
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE clients
|
||||||
|
SET topic_id = ?, updated_at = ?
|
||||||
|
WHERE telegram_id = ?
|
||||||
|
""",
|
||||||
|
(topic_id, _utcnow(), telegram_id),
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
async def clear_topic_id(self, telegram_id: int) -> None:
|
||||||
|
conn = self._require_conn()
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE clients
|
||||||
|
SET topic_id = NULL, updated_at = ?
|
||||||
|
WHERE telegram_id = ?
|
||||||
|
""",
|
||||||
|
(_utcnow(), telegram_id),
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
async def set_language(self, telegram_id: int, language: str) -> None:
|
||||||
|
conn = self._require_conn()
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE clients
|
||||||
|
SET language = ?, updated_at = ?
|
||||||
|
WHERE telegram_id = ?
|
||||||
|
""",
|
||||||
|
(language, _utcnow(), telegram_id),
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
async def save_message_link(
|
||||||
|
self,
|
||||||
|
client_telegram_id: int,
|
||||||
|
client_message_id: int,
|
||||||
|
manager_message_id: int,
|
||||||
|
) -> None:
|
||||||
|
conn = self._require_conn()
|
||||||
|
await conn.execute(
|
||||||
|
"DELETE FROM message_links WHERE manager_message_id = ?",
|
||||||
|
(manager_message_id,),
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM message_links
|
||||||
|
WHERE client_telegram_id = ? AND client_message_id = ?
|
||||||
|
""",
|
||||||
|
(client_telegram_id, client_message_id),
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO message_links (
|
||||||
|
client_telegram_id, client_message_id, manager_message_id, created_at
|
||||||
|
) VALUES (?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(client_telegram_id, client_message_id, manager_message_id, _utcnow()),
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
|
|
||||||
|
async def get_link_by_client_message(
|
||||||
|
self,
|
||||||
|
client_telegram_id: int,
|
||||||
|
client_message_id: int,
|
||||||
|
) -> Optional[MessageLink]:
|
||||||
|
conn = self._require_conn()
|
||||||
|
async with conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT client_telegram_id, client_message_id, manager_message_id
|
||||||
|
FROM message_links
|
||||||
|
WHERE client_telegram_id = ? AND client_message_id = ?
|
||||||
|
""",
|
||||||
|
(client_telegram_id, client_message_id),
|
||||||
|
) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return MessageLink(
|
||||||
|
client_telegram_id=row["client_telegram_id"],
|
||||||
|
client_message_id=row["client_message_id"],
|
||||||
|
manager_message_id=row["manager_message_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_link_by_manager_message(
|
||||||
|
self,
|
||||||
|
manager_message_id: int,
|
||||||
|
) -> Optional[MessageLink]:
|
||||||
|
conn = self._require_conn()
|
||||||
|
async with conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT client_telegram_id, client_message_id, manager_message_id
|
||||||
|
FROM message_links
|
||||||
|
WHERE manager_message_id = ?
|
||||||
|
""",
|
||||||
|
(manager_message_id,),
|
||||||
|
) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return MessageLink(
|
||||||
|
client_telegram_id=row["client_telegram_id"],
|
||||||
|
client_message_id=row["client_message_id"],
|
||||||
|
manager_message_id=row["manager_message_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_message_link(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
client_telegram_id: int,
|
||||||
|
client_message_id: int,
|
||||||
|
manager_message_id: int,
|
||||||
|
) -> None:
|
||||||
|
conn = self._require_conn()
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
DELETE FROM message_links
|
||||||
|
WHERE client_telegram_id = ?
|
||||||
|
AND client_message_id = ?
|
||||||
|
AND manager_message_id = ?
|
||||||
|
""",
|
||||||
|
(client_telegram_id, client_message_id, manager_message_id),
|
||||||
|
)
|
||||||
|
await conn.commit()
|
||||||
4
bot/handlers/__init__.py
Normal file
4
bot/handlers/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
from bot.handlers.client import router as client_router
|
||||||
|
from bot.handlers.manager import build_manager_router
|
||||||
|
|
||||||
|
__all__ = ["client_router", "build_manager_router"]
|
||||||
200
bot/handlers/client.py
Normal file
200
bot/handlers/client.py
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.enums import ChatType, ParseMode
|
||||||
|
from aiogram.filters import Command, CommandStart
|
||||||
|
from aiogram.types import CallbackQuery, Message, MessageReactionUpdated
|
||||||
|
|
||||||
|
from bot.db.repository import ClientRepository
|
||||||
|
from bot.i18n import (
|
||||||
|
format_bot_html,
|
||||||
|
format_bot_message,
|
||||||
|
language_keyboard,
|
||||||
|
normalize_language,
|
||||||
|
services_keyboard,
|
||||||
|
t,
|
||||||
|
)
|
||||||
|
from bot.services.cooldown import CooldownService
|
||||||
|
from bot.services.relay import RelayService, is_relayable_content
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = Router(name="client")
|
||||||
|
|
||||||
|
|
||||||
|
async def _answer_bot(message: Message, language: str | None, key: str) -> None:
|
||||||
|
await message.answer(
|
||||||
|
format_bot_message(language, t(language, key)),
|
||||||
|
parse_mode=ParseMode.HTML,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _answer_welcome(message: Message, language: str) -> None:
|
||||||
|
await message.answer(
|
||||||
|
format_bot_message(language, t(language, "welcome")),
|
||||||
|
reply_markup=services_keyboard(language),
|
||||||
|
parse_mode=ParseMode.HTML,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _prompt_language(message: Message) -> None:
|
||||||
|
await message.answer(
|
||||||
|
t(None, "choose_language"),
|
||||||
|
reply_markup=language_keyboard(),
|
||||||
|
parse_mode=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(CommandStart(), F.chat.type == ChatType.PRIVATE)
|
||||||
|
async def cmd_start(message: Message, relay: RelayService) -> None:
|
||||||
|
if message.from_user is None:
|
||||||
|
return
|
||||||
|
client = await relay.ensure_client(message.from_user)
|
||||||
|
if client.language is None:
|
||||||
|
await _prompt_language(message)
|
||||||
|
return
|
||||||
|
await _answer_welcome(message, client.language)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("lang"), F.chat.type == ChatType.PRIVATE)
|
||||||
|
async def cmd_lang(message: Message, relay: RelayService) -> None:
|
||||||
|
if message.from_user is None:
|
||||||
|
return
|
||||||
|
await relay.ensure_client(message.from_user)
|
||||||
|
await _prompt_language(message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(Command("del"), F.chat.type == ChatType.PRIVATE)
|
||||||
|
async def cmd_del(message: Message, relay: RelayService) -> None:
|
||||||
|
"""Reply to a relayed message with /del to remove it on both sides."""
|
||||||
|
if message.from_user is None:
|
||||||
|
return
|
||||||
|
client = await relay.ensure_client(message.from_user)
|
||||||
|
if message.reply_to_message is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
deleted = await relay.delete_paired_from_client(message.bot, message)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to delete paired message for client %s", message.from_user.id)
|
||||||
|
return
|
||||||
|
if deleted:
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await _answer_bot(message, client.language, "deleted_notice")
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("lang:"))
|
||||||
|
async def on_language_chosen(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
relay: RelayService,
|
||||||
|
repo: ClientRepository,
|
||||||
|
) -> None:
|
||||||
|
if callback.from_user is None or callback.data is None:
|
||||||
|
return
|
||||||
|
if callback.message is None or callback.message.chat.type != ChatType.PRIVATE:
|
||||||
|
await callback.answer()
|
||||||
|
return
|
||||||
|
|
||||||
|
language = normalize_language(callback.data.split(":", 1)[1])
|
||||||
|
if language is None:
|
||||||
|
await callback.answer()
|
||||||
|
return
|
||||||
|
|
||||||
|
await relay.ensure_client(callback.from_user)
|
||||||
|
await repo.set_language(callback.from_user.id, language)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
body = f"{t(language, 'language_set')}\n\n{t(language, 'welcome')}"
|
||||||
|
await callback.message.edit_text(
|
||||||
|
format_bot_message(language, body),
|
||||||
|
reply_markup=services_keyboard(language),
|
||||||
|
parse_mode=ParseMode.HTML,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data == "services")
|
||||||
|
async def on_services(
|
||||||
|
callback: CallbackQuery,
|
||||||
|
relay: RelayService,
|
||||||
|
cooldown: CooldownService,
|
||||||
|
) -> None:
|
||||||
|
if callback.from_user is None or callback.message is None:
|
||||||
|
return
|
||||||
|
if callback.message.chat.type != ChatType.PRIVATE:
|
||||||
|
await callback.answer()
|
||||||
|
return
|
||||||
|
|
||||||
|
client = await relay.ensure_client(callback.from_user)
|
||||||
|
if client.language is None:
|
||||||
|
await callback.answer()
|
||||||
|
await _prompt_language(callback.message)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not cooldown.try_take_services(callback.from_user.id):
|
||||||
|
await callback.answer()
|
||||||
|
return
|
||||||
|
|
||||||
|
await callback.answer()
|
||||||
|
await callback.message.answer(
|
||||||
|
format_bot_html(client.language, t(client.language, "services")),
|
||||||
|
parse_mode=ParseMode.HTML,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message_reaction(F.chat.type == ChatType.PRIVATE)
|
||||||
|
async def client_reaction(event: MessageReactionUpdated, relay: RelayService) -> None:
|
||||||
|
try:
|
||||||
|
await relay.relay_reaction(event.bot, event)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to relay client reaction on message %s",
|
||||||
|
event.message_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.edited_message(F.chat.type == ChatType.PRIVATE)
|
||||||
|
async def client_edited(message: Message, relay: RelayService) -> None:
|
||||||
|
if message.from_user is None or not is_relayable_content(message):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await relay.relay_client_edit(message.bot, message)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to relay client edit from %s",
|
||||||
|
message.from_user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.message(F.chat.type == ChatType.PRIVATE)
|
||||||
|
async def client_message(
|
||||||
|
message: Message,
|
||||||
|
relay: RelayService,
|
||||||
|
cooldown: CooldownService,
|
||||||
|
) -> None:
|
||||||
|
if message.from_user is None or not is_relayable_content(message):
|
||||||
|
return
|
||||||
|
|
||||||
|
client = await relay.ensure_client(message.from_user)
|
||||||
|
if client.language is None:
|
||||||
|
await _prompt_language(message)
|
||||||
|
return
|
||||||
|
|
||||||
|
decision = cooldown.check(message.from_user.id, message.media_group_id)
|
||||||
|
if not decision.allowed:
|
||||||
|
await _answer_bot(message, client.language, "cooldown")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await relay.relay_client_to_manager(message.bot, message)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to relay client message from %s", message.from_user.id)
|
||||||
|
await _answer_bot(message, client.language, "send_failed")
|
||||||
|
return
|
||||||
|
|
||||||
|
cooldown.commit(message.from_user.id, message.media_group_id)
|
||||||
|
if decision.should_ack:
|
||||||
|
await _answer_bot(message, client.language, "ack")
|
||||||
91
bot/handlers/manager.py
Normal file
91
bot/handlers/manager.py
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from aiogram import F, Router
|
||||||
|
from aiogram.enums import ChatType
|
||||||
|
from aiogram.filters import Command
|
||||||
|
from aiogram.types import Message, MessageReactionUpdated
|
||||||
|
|
||||||
|
from bot.services.relay import GENERAL_TOPIC_ID, RelayService, is_relayable_content
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def build_manager_router(manager_group_id: int) -> Router:
|
||||||
|
router = Router(name="manager")
|
||||||
|
|
||||||
|
@router.message(
|
||||||
|
Command("del"),
|
||||||
|
F.chat.id == manager_group_id,
|
||||||
|
F.chat.type == ChatType.SUPERGROUP,
|
||||||
|
)
|
||||||
|
async def manager_del(message: Message, relay: RelayService) -> None:
|
||||||
|
"""Reply to a relayed message with /del to remove it on both sides."""
|
||||||
|
if relay.is_self(message.from_user.id if message.from_user else None):
|
||||||
|
return
|
||||||
|
topic_id = message.message_thread_id
|
||||||
|
if topic_id is None or topic_id == GENERAL_TOPIC_ID:
|
||||||
|
return
|
||||||
|
if message.reply_to_message is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
deleted = await relay.delete_paired_from_manager(message.bot, message)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to delete paired message in topic %s", topic_id)
|
||||||
|
return
|
||||||
|
if deleted:
|
||||||
|
try:
|
||||||
|
await message.delete()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@router.message(
|
||||||
|
F.chat.id == manager_group_id,
|
||||||
|
F.chat.type == ChatType.SUPERGROUP,
|
||||||
|
)
|
||||||
|
async def manager_message(message: Message, relay: RelayService) -> None:
|
||||||
|
if relay.is_self(message.from_user.id if message.from_user else None):
|
||||||
|
return
|
||||||
|
|
||||||
|
topic_id = message.message_thread_id
|
||||||
|
if topic_id is None or topic_id == GENERAL_TOPIC_ID:
|
||||||
|
return
|
||||||
|
if not is_relayable_content(message):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await relay.relay_manager_to_client(message.bot, message)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to relay manager message in topic %s", topic_id)
|
||||||
|
|
||||||
|
@router.edited_message(
|
||||||
|
F.chat.id == manager_group_id,
|
||||||
|
F.chat.type == ChatType.SUPERGROUP,
|
||||||
|
)
|
||||||
|
async def manager_edited(message: Message, relay: RelayService) -> None:
|
||||||
|
if relay.is_self(message.from_user.id if message.from_user else None):
|
||||||
|
return
|
||||||
|
|
||||||
|
topic_id = message.message_thread_id
|
||||||
|
if topic_id is None or topic_id == GENERAL_TOPIC_ID:
|
||||||
|
return
|
||||||
|
if not is_relayable_content(message):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await relay.relay_manager_edit(message.bot, message)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to relay manager edit in topic %s", topic_id)
|
||||||
|
|
||||||
|
@router.message_reaction(F.chat.id == manager_group_id)
|
||||||
|
async def manager_reaction(event: MessageReactionUpdated, relay: RelayService) -> None:
|
||||||
|
try:
|
||||||
|
await relay.relay_reaction(event.bot, event)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to relay manager reaction on message %s",
|
||||||
|
event.message_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return router
|
||||||
176
bot/i18n.py
Normal file
176
bot/i18n.py
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from html import escape
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
|
|
||||||
|
DEFAULT_LANGUAGE = "ru"
|
||||||
|
SUPPORTED_LANGUAGES = ("ru", "uk")
|
||||||
|
|
||||||
|
Language = str
|
||||||
|
|
||||||
|
SERVICES_RU = (
|
||||||
|
"<b>🌐 1. Покупка/продажа криптовалюты за наличные</b> — лучшие условия "
|
||||||
|
"во всех странах и крупных городах.\n\n"
|
||||||
|
"<b>💸 2. Перестановка наличных</b> — выдача день в день.\n\n"
|
||||||
|
"<b>💳 3. Оплата на карты</b>\n\n"
|
||||||
|
"• Покупка/продажа криптовалюты.\n\n"
|
||||||
|
"• Оплата на карты EUR, TRY, KZT, PLN, THB.\n\n"
|
||||||
|
"• Оплата физ.лиц (со счетов в ЕС): USD, EUR, GBP через Wise, Paysera, Revolut, ZEN.\n\n"
|
||||||
|
"• Прием/оплата UAH: ТОВ, ФОП, физлица.\n\n"
|
||||||
|
"• Оплата по ссылкам: Visa, Mastercard, American Express.\n\n"
|
||||||
|
"• Оплата Инвойсов (USD, EUR): товар, логистика, маркетинг, IT, Консалтинг, "
|
||||||
|
"тур. услуги, авто (Copart, IAAI), оплата авто в ЕС.\n\n"
|
||||||
|
"<b>🔒 4. Электронные деньги и платежные системы</b>\n\n"
|
||||||
|
"• Payeer, Capitalist, Perfect Money, Skrill, AdvCash, Payoneer.\n\n"
|
||||||
|
"• Оплата Юань на карты: AliPay, WeChat Pay."
|
||||||
|
)
|
||||||
|
|
||||||
|
SERVICES_UK = (
|
||||||
|
"<b>🌐 1. Купівля/продаж криптовалюти за готівку</b> — найкращі умови "
|
||||||
|
"в усіх країнах і великих містах.\n\n"
|
||||||
|
"<b>💸 2. Перестановка готівки</b> — видача в той же день.\n\n"
|
||||||
|
"<b>💳 3. Оплата на картки</b>\n\n"
|
||||||
|
"• Купівля/продаж криптовалюти.\n\n"
|
||||||
|
"• Оплата на картки EUR, TRY, KZT, PLN, THB.\n\n"
|
||||||
|
"• Оплата фіз.осіб (з рахунків в ЄС): USD, EUR, GBP через Wise, Paysera, Revolut, ZEN.\n\n"
|
||||||
|
"• Прийом/оплата UAH: ТОВ, ФОП, фізособи.\n\n"
|
||||||
|
"• Оплата за посиланнями: Visa, Mastercard, American Express.\n\n"
|
||||||
|
"• Оплата інвойсів (USD, EUR): товар, логістика, маркетинг, IT, консалтинг, "
|
||||||
|
"тур. послуги, авто (Copart, IAAI), оплата авто в ЄС.\n\n"
|
||||||
|
"<b>🔒 4. Електронні гроші та платіжні системи</b>\n\n"
|
||||||
|
"• Payeer, Capitalist, Perfect Money, Skrill, AdvCash, Payoneer.\n\n"
|
||||||
|
"• Оплата юаня на картки: AliPay, WeChat Pay."
|
||||||
|
)
|
||||||
|
|
||||||
|
TEXTS: Dict[str, Dict[str, str]] = {
|
||||||
|
"ru": {
|
||||||
|
"welcome": (
|
||||||
|
"Здравствуйте!\n\n"
|
||||||
|
"Напишите ваш запрос — менеджер ответит здесь.\n\n"
|
||||||
|
"Укажите что конкретно вам нужно сделать.\n\n"
|
||||||
|
"🔶 Все наши услуги посмотрите по кнопке ниже."
|
||||||
|
),
|
||||||
|
"ack": "Спасибо, подождите ответ.",
|
||||||
|
"cooldown": "Подождите немного, не спамьте.",
|
||||||
|
"send_failed": (
|
||||||
|
"Не удалось отправить сообщение менеджеру. Попробуйте позже."
|
||||||
|
),
|
||||||
|
"choose_language": "Выберите язык / Оберіть мову:",
|
||||||
|
"language_set": "Язык сохранён: русский.",
|
||||||
|
"prefix_bot": "Бот:",
|
||||||
|
"prefix_manager": "Менеджер:",
|
||||||
|
"lang_button": "Ru",
|
||||||
|
"services_button": "Список услуг",
|
||||||
|
"services": SERVICES_RU,
|
||||||
|
"client_blocked": (
|
||||||
|
"⚠️ Клиент заблокировал бота. Сообщение не доставлено."
|
||||||
|
),
|
||||||
|
"deliver_failed": "⚠️ Не удалось доставить сообщение клиенту.",
|
||||||
|
"edit_failed": "⚠️ Не удалось обновить сообщение у клиента.",
|
||||||
|
"deleted_notice": "🗑 Сообщение удалено.",
|
||||||
|
},
|
||||||
|
"uk": {
|
||||||
|
"welcome": (
|
||||||
|
"Вітаємо!\n\n"
|
||||||
|
"Напишіть ваш запит — менеджер відповість тут.\n\n"
|
||||||
|
"Вкажіть, що саме вам потрібно зробити.\n\n"
|
||||||
|
"🔶 Всі наші послуги подивіться по кнопці нижче."
|
||||||
|
),
|
||||||
|
"ack": "Дякуємо, зачекайте на відповідь.",
|
||||||
|
"cooldown": "Зачекайте трохи, не спамте.",
|
||||||
|
"send_failed": (
|
||||||
|
"Не вдалося надіслати повідомлення менеджеру. Спробуйте пізніше."
|
||||||
|
),
|
||||||
|
"choose_language": "Оберіть мову / Выберите язык:",
|
||||||
|
"language_set": "Мову збережено: українська.",
|
||||||
|
"prefix_bot": "Бот:",
|
||||||
|
"prefix_manager": "Менеджер:",
|
||||||
|
"lang_button": "Ukr",
|
||||||
|
"services_button": "Список послуг",
|
||||||
|
"services": SERVICES_UK,
|
||||||
|
"client_blocked": (
|
||||||
|
"⚠️ Клієнт заблокував бота. Повідомлення не доставлено."
|
||||||
|
),
|
||||||
|
"deliver_failed": "⚠️ Не вдалося доставити повідомлення клієнту.",
|
||||||
|
"edit_failed": "⚠️ Не вдалося оновити повідомлення у клієнта.",
|
||||||
|
"deleted_notice": "🗑 Повідомлення видалено.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_language(language: Optional[str]) -> Optional[str]:
|
||||||
|
if language is None:
|
||||||
|
return None
|
||||||
|
code = language.strip().lower()
|
||||||
|
if code in SUPPORTED_LANGUAGES:
|
||||||
|
return code
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_language(language: Optional[str]) -> str:
|
||||||
|
return normalize_language(language) or DEFAULT_LANGUAGE
|
||||||
|
|
||||||
|
|
||||||
|
def t(language: Optional[str], key: str) -> str:
|
||||||
|
lang = resolve_language(language)
|
||||||
|
return TEXTS[lang][key]
|
||||||
|
|
||||||
|
|
||||||
|
def format_prefix(language: Optional[str], key: str) -> str:
|
||||||
|
return f"<b>{escape(t(language, key))}</b>"
|
||||||
|
|
||||||
|
|
||||||
|
def format_labeled_message(label: str, text: str) -> str:
|
||||||
|
"""Label + plain text body (body is escaped)."""
|
||||||
|
return f"<b>{escape(label)}</b>\n\n{escape(text)}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_labeled_html(label: str, html_body: str) -> str:
|
||||||
|
"""Label + pre-formatted HTML body (body is not escaped)."""
|
||||||
|
return f"<b>{escape(label)}</b>\n\n{html_body}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_bot_message(language: Optional[str], text: str) -> str:
|
||||||
|
return format_labeled_message(t(language, "prefix_bot"), text)
|
||||||
|
|
||||||
|
|
||||||
|
def format_bot_html(language: Optional[str], html_body: str) -> str:
|
||||||
|
"""Bot prefix + pre-formatted HTML body (not escaped)."""
|
||||||
|
return f"{format_prefix(language, 'prefix_bot')}\n\n{html_body}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_manager_message(language: Optional[str], text: str) -> str:
|
||||||
|
return format_labeled_message(t(language, "prefix_manager"), text)
|
||||||
|
|
||||||
|
|
||||||
|
def format_manager_html(language: Optional[str], html_body: str) -> str:
|
||||||
|
return format_labeled_html(t(language, "prefix_manager"), html_body)
|
||||||
|
|
||||||
|
|
||||||
|
def language_keyboard() -> InlineKeyboardMarkup:
|
||||||
|
return InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=TEXTS[code]["lang_button"],
|
||||||
|
callback_data=f"lang:{code}",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
for code in SUPPORTED_LANGUAGES
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def services_keyboard(language: Optional[str]) -> InlineKeyboardMarkup:
|
||||||
|
return InlineKeyboardMarkup(
|
||||||
|
inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=t(language, "services_button"),
|
||||||
|
callback_data="services",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
)
|
||||||
156
bot/main.py
Normal file
156
bot/main.py
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from aiogram import Bot, Dispatcher
|
||||||
|
from aiogram.client.default import DefaultBotProperties
|
||||||
|
from aiogram.enums import ChatMemberStatus, ChatType, ParseMode
|
||||||
|
from aiogram.exceptions import TelegramAPIError
|
||||||
|
from aiogram.types import ChatMemberUpdated
|
||||||
|
|
||||||
|
from bot.config import Config, apply_text_overrides, load_config
|
||||||
|
from bot.db.repository import ClientRepository
|
||||||
|
from bot.handlers.client import router as client_router
|
||||||
|
from bot.handlers.manager import build_manager_router
|
||||||
|
from bot.services.cooldown import CooldownService
|
||||||
|
from bot.services.relay import RelayService
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
||||||
|
stream=sys.stdout,
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def on_bot_added(event: ChatMemberUpdated, config: Config, relay: RelayService) -> None:
|
||||||
|
"""Log chat id when the bot is added to a group — helps set MANAGER_GROUP_ID."""
|
||||||
|
new = event.new_chat_member
|
||||||
|
if not relay.is_self(new.user.id):
|
||||||
|
return
|
||||||
|
if new.status in {ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.MEMBER}:
|
||||||
|
logger.info(
|
||||||
|
"Bot added to chat %s (%s). Use this as MANAGER_GROUP_ID if it is the forum.",
|
||||||
|
event.chat.id,
|
||||||
|
event.chat.title,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_manager_group(bot: Bot, manager_group_id: int) -> None:
|
||||||
|
"""Fail fast if MANAGER_GROUP_ID is wrong or the bot lacks required rights."""
|
||||||
|
try:
|
||||||
|
chat = await bot.get_chat(manager_group_id)
|
||||||
|
except TelegramAPIError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Не удалось получить чат MANAGER_GROUP_ID={manager_group_id}: {exc}. "
|
||||||
|
"Проверьте id и что бот добавлен в группу."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if chat.type != ChatType.SUPERGROUP:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"MANAGER_GROUP_ID должен быть супергруппой, сейчас type={chat.type!r} "
|
||||||
|
f"(chat_id={chat.id}, title={chat.title!r})."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not bool(getattr(chat, "is_forum", False)):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"В чате {chat.id} ({chat.title!r}) не включены Topics. "
|
||||||
|
"Включите темы в настройках группы."
|
||||||
|
)
|
||||||
|
|
||||||
|
me = await bot.get_me()
|
||||||
|
try:
|
||||||
|
member = await bot.get_chat_member(manager_group_id, me.id)
|
||||||
|
except TelegramAPIError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Не удалось проверить права бота в группе {chat.id}: {exc}."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if member.status == ChatMemberStatus.CREATOR:
|
||||||
|
logger.info("Manager group OK: %s (%s), bot is creator", chat.id, chat.title)
|
||||||
|
return
|
||||||
|
|
||||||
|
if member.status != ChatMemberStatus.ADMINISTRATOR:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Бот должен быть администратором группы {chat.title!r} (id={chat.id}). "
|
||||||
|
f"Текущий статус: {member.status!r}."
|
||||||
|
)
|
||||||
|
|
||||||
|
missing: list[str] = []
|
||||||
|
if not bool(getattr(member, "can_manage_topics", True)):
|
||||||
|
missing.append("Manage topics")
|
||||||
|
if not bool(getattr(member, "can_delete_messages", True)):
|
||||||
|
missing.append("Delete messages")
|
||||||
|
can_post = getattr(member, "can_post_messages", None)
|
||||||
|
# In groups can_post_messages may be None; treat explicit False as missing.
|
||||||
|
if can_post is False:
|
||||||
|
missing.append("Post messages")
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Бот — админ группы {chat.title!r}, но не хватает прав: {', '.join(missing)}. "
|
||||||
|
"Выдайте их в настройках администраторов."
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Manager group OK: %s (%s), bot is admin", chat.id, chat.title)
|
||||||
|
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
config = load_config()
|
||||||
|
apply_text_overrides(config)
|
||||||
|
repo = ClientRepository(config.database_path)
|
||||||
|
await repo.connect()
|
||||||
|
|
||||||
|
relay = RelayService(repo, config.manager_group_id)
|
||||||
|
cooldown = CooldownService(config.client_cooldown_seconds)
|
||||||
|
bot = Bot(
|
||||||
|
token=config.bot_token,
|
||||||
|
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
|
||||||
|
)
|
||||||
|
|
||||||
|
me = await bot.get_me()
|
||||||
|
relay.set_bot_id(me.id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await validate_manager_group(bot, config.manager_group_id)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
logger.error("%s", exc)
|
||||||
|
print(f"ERROR: {exc}", file=sys.stderr)
|
||||||
|
await repo.close()
|
||||||
|
await bot.session.close()
|
||||||
|
raise SystemExit(1) from exc
|
||||||
|
|
||||||
|
dp = Dispatcher()
|
||||||
|
dp["config"] = config
|
||||||
|
dp["repo"] = repo
|
||||||
|
dp["relay"] = relay
|
||||||
|
dp["cooldown"] = cooldown
|
||||||
|
|
||||||
|
dp.include_router(client_router)
|
||||||
|
dp.include_router(build_manager_router(config.manager_group_id))
|
||||||
|
dp.my_chat_member.register(on_bot_added)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Starting bot @%s (manager group %s, client cooldown %.1fs)",
|
||||||
|
me.username,
|
||||||
|
config.manager_group_id,
|
||||||
|
config.client_cooldown_seconds,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await dp.start_polling(
|
||||||
|
bot,
|
||||||
|
allowed_updates=dp.resolve_used_update_types(),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await repo.close()
|
||||||
|
await bot.session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
4
bot/services/__init__.py
Normal file
4
bot/services/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
from bot.services.cooldown import CooldownService
|
||||||
|
from bot.services.relay import RelayService, build_topic_name, is_relayable_content
|
||||||
|
|
||||||
|
__all__ = ["CooldownService", "RelayService", "build_topic_name", "is_relayable_content"]
|
||||||
115
bot/services/cooldown.py
Normal file
115
bot/services/cooldown.py
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from threading import Lock
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CooldownDecision:
|
||||||
|
"""Result of a cooldown check before relay."""
|
||||||
|
|
||||||
|
allowed: bool
|
||||||
|
should_ack: bool
|
||||||
|
|
||||||
|
|
||||||
|
class CooldownService:
|
||||||
|
"""Per-user cooldown. In-memory; enough for a single bot process.
|
||||||
|
|
||||||
|
Cooldown is committed only after a successful relay via :meth:`commit`.
|
||||||
|
Media albums share one ack and one cooldown window.
|
||||||
|
Expired entries are pruned opportunistically so the maps stay bounded.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, seconds: float) -> None:
|
||||||
|
self._seconds = max(0.0, seconds)
|
||||||
|
self._last_allowed: dict[int, float] = {}
|
||||||
|
# user_id -> (media_group_id, monotonic_ts when first item was accepted)
|
||||||
|
self._active_albums: dict[int, tuple[str, float]] = {}
|
||||||
|
self._services_last: dict[int, float] = {}
|
||||||
|
self._lock = Lock()
|
||||||
|
# Album items can arrive a few seconds apart.
|
||||||
|
self._album_window = max(self._seconds, 3.0)
|
||||||
|
self._services_seconds = max(self._seconds, 5.0)
|
||||||
|
|
||||||
|
def _retention(self) -> float:
|
||||||
|
return max(self._album_window, self._services_seconds, self._seconds, 1.0) * 2
|
||||||
|
|
||||||
|
def _prune_unlocked(self, now: float) -> None:
|
||||||
|
retain = self._retention()
|
||||||
|
stale_users = [uid for uid, ts in self._last_allowed.items() if now - ts > retain]
|
||||||
|
for uid in stale_users:
|
||||||
|
del self._last_allowed[uid]
|
||||||
|
|
||||||
|
stale_albums = [
|
||||||
|
uid for uid, (_gid, ts) in self._active_albums.items() if now - ts > retain
|
||||||
|
]
|
||||||
|
for uid in stale_albums:
|
||||||
|
del self._active_albums[uid]
|
||||||
|
|
||||||
|
stale_services = [
|
||||||
|
uid for uid, ts in self._services_last.items() if now - ts > retain
|
||||||
|
]
|
||||||
|
for uid in stale_services:
|
||||||
|
del self._services_last[uid]
|
||||||
|
|
||||||
|
def check(self, user_id: int, media_group_id: Optional[str] = None) -> CooldownDecision:
|
||||||
|
"""Return whether the user may relay, without consuming the cooldown."""
|
||||||
|
if self._seconds <= 0 and media_group_id is None:
|
||||||
|
return CooldownDecision(allowed=True, should_ack=True)
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
with self._lock:
|
||||||
|
self._prune_unlocked(now)
|
||||||
|
|
||||||
|
if media_group_id is not None:
|
||||||
|
active = self._active_albums.get(user_id)
|
||||||
|
if (
|
||||||
|
active is not None
|
||||||
|
and active[0] == media_group_id
|
||||||
|
and now - active[1] < self._album_window
|
||||||
|
):
|
||||||
|
return CooldownDecision(allowed=True, should_ack=False)
|
||||||
|
|
||||||
|
last = self._last_allowed.get(user_id)
|
||||||
|
if last is not None and now - last < self._seconds:
|
||||||
|
return CooldownDecision(allowed=False, should_ack=False)
|
||||||
|
|
||||||
|
return CooldownDecision(allowed=True, should_ack=True)
|
||||||
|
|
||||||
|
def commit(self, user_id: int, media_group_id: Optional[str] = None) -> None:
|
||||||
|
"""Mark cooldown after a successful relay."""
|
||||||
|
if self._seconds <= 0 and media_group_id is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
with self._lock:
|
||||||
|
self._prune_unlocked(now)
|
||||||
|
|
||||||
|
if media_group_id is not None:
|
||||||
|
active = self._active_albums.get(user_id)
|
||||||
|
if (
|
||||||
|
active is not None
|
||||||
|
and active[0] == media_group_id
|
||||||
|
and now - active[1] < self._album_window
|
||||||
|
):
|
||||||
|
# Continuation of the same album — cooldown already stamped.
|
||||||
|
return
|
||||||
|
self._active_albums[user_id] = (media_group_id, now)
|
||||||
|
|
||||||
|
self._last_allowed[user_id] = now
|
||||||
|
|
||||||
|
def try_take_services(self, user_id: int) -> bool:
|
||||||
|
"""Return True once; silently False if tapped again within the services window."""
|
||||||
|
if self._services_seconds <= 0:
|
||||||
|
return True
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
with self._lock:
|
||||||
|
self._prune_unlocked(now)
|
||||||
|
last = self._services_last.get(user_id)
|
||||||
|
if last is not None and now - last < self._services_seconds:
|
||||||
|
return False
|
||||||
|
self._services_last[user_id] = now
|
||||||
|
return True
|
||||||
736
bot/services/relay.py
Normal file
736
bot/services/relay.py
Normal file
|
|
@ -0,0 +1,736 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from html import escape
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from aiogram import Bot
|
||||||
|
from aiogram.enums import ChatType, ParseMode
|
||||||
|
from aiogram.exceptions import TelegramAPIError, TelegramBadRequest, TelegramForbiddenError
|
||||||
|
from aiogram.types import Message, MessageReactionUpdated, ReactionTypeCustomEmoji, ReactionTypeEmoji, User
|
||||||
|
from aiogram.utils.text_decorations import html_decoration
|
||||||
|
|
||||||
|
from bot.db.repository import Client, ClientRepository, MessageLink
|
||||||
|
from bot.i18n import (
|
||||||
|
DEFAULT_LANGUAGE,
|
||||||
|
format_labeled_html,
|
||||||
|
format_manager_html,
|
||||||
|
format_prefix,
|
||||||
|
t,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# General topic in forum groups uses thread id 1 (or None in some clients).
|
||||||
|
GENERAL_TOPIC_ID = 1
|
||||||
|
|
||||||
|
TOPIC_NAME_MAX_LEN = 128
|
||||||
|
CAPTION_MAX_LEN = 1024
|
||||||
|
|
||||||
|
|
||||||
|
def build_topic_name(user: User) -> str:
|
||||||
|
name = (user.full_name or "Client").strip() or "Client"
|
||||||
|
if user.username:
|
||||||
|
label = f"{name} (@{user.username}) · {user.id}"
|
||||||
|
else:
|
||||||
|
label = f"{name} · {user.id}"
|
||||||
|
if len(label) <= TOPIC_NAME_MAX_LEN:
|
||||||
|
return label
|
||||||
|
# Keep trailing id; trim the name part.
|
||||||
|
suffix = f" · {user.id}"
|
||||||
|
if user.username:
|
||||||
|
suffix = f" (@{user.username}){suffix}"
|
||||||
|
keep = TOPIC_NAME_MAX_LEN - len(suffix)
|
||||||
|
return f"{name[: max(1, keep)].rstrip()}{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_captionable(message: Message) -> bool:
|
||||||
|
return bool(
|
||||||
|
message.photo
|
||||||
|
or message.document
|
||||||
|
or message.video
|
||||||
|
or message.audio
|
||||||
|
or message.animation
|
||||||
|
or message.voice
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _html_body(text: Optional[str], entities) -> str:
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
return html_decoration.unparse(text, entities or [])
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_prefixed_caption(
|
||||||
|
prefix_html: str,
|
||||||
|
caption: Optional[str],
|
||||||
|
entities,
|
||||||
|
) -> str:
|
||||||
|
"""Build prefix + caption HTML without chopping mid-entity / mid-escape."""
|
||||||
|
if len(prefix_html) > CAPTION_MAX_LEN:
|
||||||
|
return prefix_html[: CAPTION_MAX_LEN - 1] + "…"
|
||||||
|
|
||||||
|
if not caption:
|
||||||
|
return prefix_html
|
||||||
|
|
||||||
|
rich = f"{prefix_html}\n\n{_html_body(caption, entities)}"
|
||||||
|
if len(rich) <= CAPTION_MAX_LEN:
|
||||||
|
return rich
|
||||||
|
|
||||||
|
# Overflow: fall back to escaped plain text and shrink until it fits.
|
||||||
|
plain = caption
|
||||||
|
while plain:
|
||||||
|
candidate = f"{prefix_html}\n\n{escape(plain)}"
|
||||||
|
if len(candidate) <= CAPTION_MAX_LEN:
|
||||||
|
if plain != caption:
|
||||||
|
with_ellipsis = f"{prefix_html}\n\n{escape(plain + '…')}"
|
||||||
|
if len(with_ellipsis) <= CAPTION_MAX_LEN:
|
||||||
|
return with_ellipsis
|
||||||
|
return candidate
|
||||||
|
plain = plain[:-1]
|
||||||
|
return prefix_html
|
||||||
|
|
||||||
|
|
||||||
|
def client_display_name(user: User) -> str:
|
||||||
|
if user.username:
|
||||||
|
return f"@{user.username}"
|
||||||
|
name = (user.full_name or "").strip()
|
||||||
|
return name or "Client"
|
||||||
|
|
||||||
|
|
||||||
|
def client_prefix_html(user: User) -> str:
|
||||||
|
return f"<b>{escape(client_display_name(user))}:</b>"
|
||||||
|
|
||||||
|
|
||||||
|
def bot_reactions_from_update(event: MessageReactionUpdated) -> list:
|
||||||
|
"""Bots may set at most one reaction; skip paid types."""
|
||||||
|
for reaction in event.new_reaction:
|
||||||
|
if isinstance(reaction, ReactionTypeEmoji):
|
||||||
|
return [ReactionTypeEmoji(emoji=reaction.emoji)]
|
||||||
|
if isinstance(reaction, ReactionTypeCustomEmoji):
|
||||||
|
return [ReactionTypeCustomEmoji(custom_emoji_id=reaction.custom_emoji_id)]
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _is_message_gone_error(exc: BaseException) -> bool:
|
||||||
|
text = str(exc).lower()
|
||||||
|
return any(
|
||||||
|
marker in text
|
||||||
|
for marker in (
|
||||||
|
"message to edit not found",
|
||||||
|
"message to delete not found",
|
||||||
|
"message can't be edited",
|
||||||
|
"message not found",
|
||||||
|
"message_id_invalid",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_reply_error(exc: BaseException) -> bool:
|
||||||
|
text = str(exc).lower()
|
||||||
|
return "reply" in text and (
|
||||||
|
"not found" in text or "message to be replied" in text or "replied message" in text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_topic_missing_error(exc: BaseException) -> bool:
|
||||||
|
text = str(exc).lower()
|
||||||
|
upper = str(exc).upper()
|
||||||
|
return any(
|
||||||
|
marker in text
|
||||||
|
for marker in (
|
||||||
|
"message thread not found",
|
||||||
|
"thread not found",
|
||||||
|
"topic not found",
|
||||||
|
"topic_deleted",
|
||||||
|
)
|
||||||
|
) or "TOPIC_DELETED" in upper
|
||||||
|
|
||||||
|
|
||||||
|
def _thread_matches(expected: int, actual: Optional[int]) -> bool:
|
||||||
|
"""False when Telegram dropped the thread (e.g. deleted topic → General)."""
|
||||||
|
if actual is None or actual == GENERAL_TOPIC_ID:
|
||||||
|
return False
|
||||||
|
return actual == expected
|
||||||
|
|
||||||
|
|
||||||
|
class RelayService:
|
||||||
|
def __init__(self, repo: ClientRepository, manager_group_id: int) -> None:
|
||||||
|
self._repo = repo
|
||||||
|
self._manager_group_id = manager_group_id
|
||||||
|
self._bot_id: Optional[int] = None
|
||||||
|
self._topic_locks: Dict[int, asyncio.Lock] = {}
|
||||||
|
self._topic_locks_guard = asyncio.Lock()
|
||||||
|
|
||||||
|
def set_bot_id(self, bot_id: int) -> None:
|
||||||
|
self._bot_id = bot_id
|
||||||
|
|
||||||
|
def is_self(self, user_id: Optional[int]) -> bool:
|
||||||
|
return self._bot_id is not None and user_id == self._bot_id
|
||||||
|
|
||||||
|
async def _lock_for_user(self, user_id: int) -> asyncio.Lock:
|
||||||
|
async with self._topic_locks_guard:
|
||||||
|
lock = self._topic_locks.get(user_id)
|
||||||
|
if lock is None:
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
self._topic_locks[user_id] = lock
|
||||||
|
return lock
|
||||||
|
|
||||||
|
async def ensure_client(self, user: User) -> Client:
|
||||||
|
return await self._repo.upsert_client(
|
||||||
|
telegram_id=user.id,
|
||||||
|
username=user.username,
|
||||||
|
first_name=user.first_name,
|
||||||
|
last_name=user.last_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _topic_exists(self, bot: Bot, topic_id: int) -> bool:
|
||||||
|
"""True if the forum topic still exists.
|
||||||
|
|
||||||
|
Uses send_chat_action (no visible side effects). Closed topics still
|
||||||
|
accept chat actions; deleted ones raise a topic-missing error.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
await bot.send_chat_action(
|
||||||
|
chat_id=self._manager_group_id,
|
||||||
|
action="typing",
|
||||||
|
message_thread_id=topic_id,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except TelegramBadRequest as exc:
|
||||||
|
if _is_topic_missing_error(exc):
|
||||||
|
return False
|
||||||
|
logger.warning("Unexpected error verifying topic %s: %s", topic_id, exc)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def ensure_topic(self, bot: Bot, user: User, client: Client) -> int:
|
||||||
|
if client.topic_id is not None:
|
||||||
|
if await self._topic_exists(bot, client.topic_id):
|
||||||
|
return client.topic_id
|
||||||
|
logger.warning(
|
||||||
|
"Stored topic %s for client %s is gone; will recreate",
|
||||||
|
client.topic_id,
|
||||||
|
user.id,
|
||||||
|
)
|
||||||
|
await self._repo.clear_topic_id(user.id)
|
||||||
|
|
||||||
|
lock = await self._lock_for_user(user.id)
|
||||||
|
async with lock:
|
||||||
|
# Re-read under lock — another coroutine may have created the topic.
|
||||||
|
fresh = await self._repo.get_by_telegram_id(user.id)
|
||||||
|
if fresh is not None and fresh.topic_id is not None:
|
||||||
|
if await self._topic_exists(bot, fresh.topic_id):
|
||||||
|
return fresh.topic_id
|
||||||
|
await self._repo.clear_topic_id(user.id)
|
||||||
|
|
||||||
|
topic = await bot.create_forum_topic(
|
||||||
|
chat_id=self._manager_group_id,
|
||||||
|
name=build_topic_name(user),
|
||||||
|
)
|
||||||
|
await self._repo.set_topic_id(user.id, topic.message_thread_id)
|
||||||
|
logger.info(
|
||||||
|
"Created topic %s for client %s",
|
||||||
|
topic.message_thread_id,
|
||||||
|
user.id,
|
||||||
|
)
|
||||||
|
return topic.message_thread_id
|
||||||
|
|
||||||
|
async def _link_messages(
|
||||||
|
self,
|
||||||
|
client_telegram_id: int,
|
||||||
|
client_message_id: int,
|
||||||
|
manager_message_id: int,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
await self._repo.save_message_link(
|
||||||
|
client_telegram_id=client_telegram_id,
|
||||||
|
client_message_id=client_message_id,
|
||||||
|
manager_message_id=manager_message_id,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to save message link client=%s/%s manager=%s",
|
||||||
|
client_telegram_id,
|
||||||
|
client_message_id,
|
||||||
|
manager_message_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _reply_to_manager_id(
|
||||||
|
self,
|
||||||
|
client_telegram_id: int,
|
||||||
|
message: Message,
|
||||||
|
) -> Optional[int]:
|
||||||
|
reply = message.reply_to_message
|
||||||
|
if reply is None:
|
||||||
|
return None
|
||||||
|
link = await self._repo.get_link_by_client_message(client_telegram_id, reply.message_id)
|
||||||
|
return link.manager_message_id if link else None
|
||||||
|
|
||||||
|
async def _reply_to_client_id(self, message: Message) -> Optional[int]:
|
||||||
|
reply = message.reply_to_message
|
||||||
|
if reply is None:
|
||||||
|
return None
|
||||||
|
link = await self._repo.get_link_by_manager_message(reply.message_id)
|
||||||
|
return link.client_message_id if link else None
|
||||||
|
|
||||||
|
async def _call_with_optional_reply(self, api_call, kwargs: Dict[str, Any]):
|
||||||
|
"""Call Telegram API; retry once without reply_to if the reply target is gone."""
|
||||||
|
try:
|
||||||
|
return await api_call(**kwargs)
|
||||||
|
except TelegramBadRequest as exc:
|
||||||
|
if kwargs.get("reply_to_message_id") is not None and _is_reply_error(exc):
|
||||||
|
retry = dict(kwargs)
|
||||||
|
retry.pop("reply_to_message_id", None)
|
||||||
|
return await api_call(**retry)
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _copy_client_message(
|
||||||
|
self,
|
||||||
|
bot: Bot,
|
||||||
|
message: Message,
|
||||||
|
topic_id: int,
|
||||||
|
user: User,
|
||||||
|
) -> tuple[Optional[int], Optional[int]]:
|
||||||
|
"""Copy/send client content into the topic.
|
||||||
|
|
||||||
|
Returns (manager_message_id, actual_message_thread_id).
|
||||||
|
actual_message_thread_id is set for send_message; None for copy_message
|
||||||
|
(Telegram only returns MessageId there).
|
||||||
|
"""
|
||||||
|
prefix = client_prefix_html(user)
|
||||||
|
reply_to = await self._reply_to_manager_id(user.id, message)
|
||||||
|
|
||||||
|
if message.text is not None and not _is_captionable(message):
|
||||||
|
body = _html_body(message.text, message.entities)
|
||||||
|
kwargs: Dict[str, Any] = {
|
||||||
|
"chat_id": self._manager_group_id,
|
||||||
|
"message_thread_id": topic_id,
|
||||||
|
"text": format_labeled_html(f"{client_display_name(user)}:", body),
|
||||||
|
"parse_mode": ParseMode.HTML,
|
||||||
|
}
|
||||||
|
if reply_to is not None:
|
||||||
|
kwargs["reply_to_message_id"] = reply_to
|
||||||
|
sent = await self._call_with_optional_reply(bot.send_message, kwargs)
|
||||||
|
return sent.message_id, sent.message_thread_id
|
||||||
|
|
||||||
|
if _is_captionable(message):
|
||||||
|
caption = _safe_prefixed_caption(
|
||||||
|
prefix,
|
||||||
|
message.caption,
|
||||||
|
message.caption_entities,
|
||||||
|
)
|
||||||
|
kwargs = {
|
||||||
|
"chat_id": self._manager_group_id,
|
||||||
|
"from_chat_id": message.chat.id,
|
||||||
|
"message_id": message.message_id,
|
||||||
|
"message_thread_id": topic_id,
|
||||||
|
"caption": caption,
|
||||||
|
"parse_mode": ParseMode.HTML,
|
||||||
|
}
|
||||||
|
if reply_to is not None:
|
||||||
|
kwargs["reply_to_message_id"] = reply_to
|
||||||
|
result = await self._call_with_optional_reply(bot.copy_message, kwargs)
|
||||||
|
return result.message_id, None
|
||||||
|
|
||||||
|
# Stickers, video notes, etc. have no caption — topic already identifies the client.
|
||||||
|
kwargs = {
|
||||||
|
"chat_id": self._manager_group_id,
|
||||||
|
"from_chat_id": message.chat.id,
|
||||||
|
"message_id": message.message_id,
|
||||||
|
"message_thread_id": topic_id,
|
||||||
|
}
|
||||||
|
if reply_to is not None:
|
||||||
|
kwargs["reply_to_message_id"] = reply_to
|
||||||
|
result = await self._call_with_optional_reply(bot.copy_message, kwargs)
|
||||||
|
return result.message_id, None
|
||||||
|
|
||||||
|
async def _recreate_topic_for_user(self, bot: Bot, user: User, stale_topic_id: int) -> int:
|
||||||
|
logger.warning("Topic %s missing for client %s; recreating", stale_topic_id, user.id)
|
||||||
|
await self._repo.clear_topic_id(user.id)
|
||||||
|
client = await self._repo.get_by_telegram_id(user.id)
|
||||||
|
if client is None:
|
||||||
|
client = await self.ensure_client(user)
|
||||||
|
else:
|
||||||
|
# Ensure ensure_topic does not trust the cleared id from a stale object.
|
||||||
|
client.topic_id = None
|
||||||
|
return await self.ensure_topic(bot, user, client)
|
||||||
|
|
||||||
|
async def relay_client_to_manager(self, bot: Bot, message: Message) -> None:
|
||||||
|
if message.from_user is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
user = message.from_user
|
||||||
|
client = await self.ensure_client(user)
|
||||||
|
topic_id = await self.ensure_topic(bot, user, client)
|
||||||
|
|
||||||
|
try:
|
||||||
|
manager_message_id, actual_thread = await self._copy_client_message(
|
||||||
|
bot, message, topic_id, user
|
||||||
|
)
|
||||||
|
except TelegramBadRequest as exc:
|
||||||
|
# Topic may have been deleted manually — recreate once.
|
||||||
|
if not _is_topic_missing_error(exc):
|
||||||
|
logger.exception("Failed to copy client message to topic %s", topic_id)
|
||||||
|
raise
|
||||||
|
|
||||||
|
topic_id = await self._recreate_topic_for_user(bot, user, topic_id)
|
||||||
|
manager_message_id, actual_thread = await self._copy_client_message(
|
||||||
|
bot, message, topic_id, user
|
||||||
|
)
|
||||||
|
|
||||||
|
# Text sends can silently land in General when the topic is gone.
|
||||||
|
if (
|
||||||
|
manager_message_id is not None
|
||||||
|
and actual_thread is not None
|
||||||
|
and not _thread_matches(topic_id, actual_thread)
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
"Message for client %s landed in thread %s instead of %s; recreating topic",
|
||||||
|
user.id,
|
||||||
|
actual_thread,
|
||||||
|
topic_id,
|
||||||
|
)
|
||||||
|
await self._safe_delete(bot, self._manager_group_id, manager_message_id)
|
||||||
|
topic_id = await self._recreate_topic_for_user(bot, user, topic_id)
|
||||||
|
manager_message_id, actual_thread = await self._copy_client_message(
|
||||||
|
bot, message, topic_id, user
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
manager_message_id is not None
|
||||||
|
and actual_thread is not None
|
||||||
|
and not _thread_matches(topic_id, actual_thread)
|
||||||
|
):
|
||||||
|
await self._safe_delete(bot, self._manager_group_id, manager_message_id)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to deliver client {user.id} message into topic {topic_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if manager_message_id is not None:
|
||||||
|
await self._link_messages(user.id, message.message_id, manager_message_id)
|
||||||
|
|
||||||
|
async def _deliver_manager_message(
|
||||||
|
self,
|
||||||
|
bot: Bot,
|
||||||
|
client: Client,
|
||||||
|
message: Message,
|
||||||
|
) -> Optional[int]:
|
||||||
|
prefix = format_prefix(client.language, "prefix_manager")
|
||||||
|
reply_to = await self._reply_to_client_id(message)
|
||||||
|
|
||||||
|
if message.text is not None and not _is_captionable(message):
|
||||||
|
body = _html_body(message.text, message.entities)
|
||||||
|
kwargs: Dict[str, Any] = {
|
||||||
|
"chat_id": client.telegram_id,
|
||||||
|
"text": format_manager_html(client.language, body),
|
||||||
|
"parse_mode": ParseMode.HTML,
|
||||||
|
}
|
||||||
|
if reply_to is not None:
|
||||||
|
kwargs["reply_to_message_id"] = reply_to
|
||||||
|
sent = await self._call_with_optional_reply(bot.send_message, kwargs)
|
||||||
|
return sent.message_id
|
||||||
|
|
||||||
|
if _is_captionable(message):
|
||||||
|
caption = _safe_prefixed_caption(
|
||||||
|
prefix,
|
||||||
|
message.caption,
|
||||||
|
message.caption_entities,
|
||||||
|
)
|
||||||
|
kwargs = {
|
||||||
|
"chat_id": client.telegram_id,
|
||||||
|
"from_chat_id": message.chat.id,
|
||||||
|
"message_id": message.message_id,
|
||||||
|
"caption": caption,
|
||||||
|
"parse_mode": ParseMode.HTML,
|
||||||
|
}
|
||||||
|
if reply_to is not None:
|
||||||
|
kwargs["reply_to_message_id"] = reply_to
|
||||||
|
result = await self._call_with_optional_reply(bot.copy_message, kwargs)
|
||||||
|
return result.message_id
|
||||||
|
|
||||||
|
kwargs = {
|
||||||
|
"chat_id": client.telegram_id,
|
||||||
|
"from_chat_id": message.chat.id,
|
||||||
|
"message_id": message.message_id,
|
||||||
|
}
|
||||||
|
if reply_to is not None:
|
||||||
|
kwargs["reply_to_message_id"] = reply_to
|
||||||
|
result = await self._call_with_optional_reply(bot.copy_message, kwargs)
|
||||||
|
return result.message_id
|
||||||
|
|
||||||
|
async def _notify_managers(self, bot: Bot, topic_id: int, key: str) -> None:
|
||||||
|
try:
|
||||||
|
await bot.send_message(
|
||||||
|
chat_id=self._manager_group_id,
|
||||||
|
message_thread_id=topic_id,
|
||||||
|
text=t(DEFAULT_LANGUAGE, key),
|
||||||
|
)
|
||||||
|
except TelegramAPIError:
|
||||||
|
logger.exception("Failed to notify managers (%s) in topic %s", key, topic_id)
|
||||||
|
|
||||||
|
async def relay_manager_to_client(self, bot: Bot, message: Message) -> None:
|
||||||
|
topic_id = message.message_thread_id
|
||||||
|
if topic_id is None or topic_id == GENERAL_TOPIC_ID:
|
||||||
|
return
|
||||||
|
|
||||||
|
client = await self._repo.get_by_topic_id(topic_id)
|
||||||
|
if client is None:
|
||||||
|
logger.warning("No client mapped to topic %s", topic_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
client_message_id = await self._deliver_manager_message(bot, client, message)
|
||||||
|
if client_message_id is not None:
|
||||||
|
await self._link_messages(
|
||||||
|
client.telegram_id,
|
||||||
|
client_message_id,
|
||||||
|
message.message_id,
|
||||||
|
)
|
||||||
|
except TelegramForbiddenError:
|
||||||
|
logger.warning("Client %s blocked the bot", client.telegram_id)
|
||||||
|
await self._notify_managers(bot, topic_id, "client_blocked")
|
||||||
|
except TelegramBadRequest as exc:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to deliver manager message to client %s: %s",
|
||||||
|
client.telegram_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
await self._notify_managers(bot, topic_id, "deliver_failed")
|
||||||
|
|
||||||
|
async def relay_client_edit(self, bot: Bot, message: Message) -> None:
|
||||||
|
"""Mirror a client message edit into the manager topic."""
|
||||||
|
if message.from_user is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
link = await self._repo.get_link_by_client_message(
|
||||||
|
message.from_user.id,
|
||||||
|
message.message_id,
|
||||||
|
)
|
||||||
|
if link is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
user = message.from_user
|
||||||
|
try:
|
||||||
|
if message.text is not None and not _is_captionable(message):
|
||||||
|
body = _html_body(message.text, message.entities)
|
||||||
|
await bot.edit_message_text(
|
||||||
|
chat_id=self._manager_group_id,
|
||||||
|
message_id=link.manager_message_id,
|
||||||
|
text=format_labeled_html(f"{client_display_name(user)}:", body),
|
||||||
|
parse_mode=ParseMode.HTML,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if _is_captionable(message):
|
||||||
|
caption = _safe_prefixed_caption(
|
||||||
|
client_prefix_html(user),
|
||||||
|
message.caption,
|
||||||
|
message.caption_entities,
|
||||||
|
)
|
||||||
|
await bot.edit_message_caption(
|
||||||
|
chat_id=self._manager_group_id,
|
||||||
|
message_id=link.manager_message_id,
|
||||||
|
caption=caption,
|
||||||
|
parse_mode=ParseMode.HTML,
|
||||||
|
)
|
||||||
|
except TelegramBadRequest as exc:
|
||||||
|
if _is_message_gone_error(exc):
|
||||||
|
await self._delete_paired_after_gone(
|
||||||
|
bot,
|
||||||
|
client_telegram_id=link.client_telegram_id,
|
||||||
|
client_message_id=link.client_message_id,
|
||||||
|
manager_message_id=link.manager_message_id,
|
||||||
|
delete_manager=False,
|
||||||
|
delete_client=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to sync client edit to manager message %s",
|
||||||
|
link.manager_message_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def relay_manager_edit(self, bot: Bot, message: Message) -> None:
|
||||||
|
"""Mirror a manager message edit into the client DM."""
|
||||||
|
topic_id = message.message_thread_id
|
||||||
|
if topic_id is None or topic_id == GENERAL_TOPIC_ID:
|
||||||
|
return
|
||||||
|
|
||||||
|
link = await self._repo.get_link_by_manager_message(message.message_id)
|
||||||
|
if link is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
client = await self._repo.get_by_telegram_id(link.client_telegram_id)
|
||||||
|
if client is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if message.text is not None and not _is_captionable(message):
|
||||||
|
body = _html_body(message.text, message.entities)
|
||||||
|
await bot.edit_message_text(
|
||||||
|
chat_id=client.telegram_id,
|
||||||
|
message_id=link.client_message_id,
|
||||||
|
text=format_manager_html(client.language, body),
|
||||||
|
parse_mode=ParseMode.HTML,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if _is_captionable(message):
|
||||||
|
caption = _safe_prefixed_caption(
|
||||||
|
format_prefix(client.language, "prefix_manager"),
|
||||||
|
message.caption,
|
||||||
|
message.caption_entities,
|
||||||
|
)
|
||||||
|
await bot.edit_message_caption(
|
||||||
|
chat_id=client.telegram_id,
|
||||||
|
message_id=link.client_message_id,
|
||||||
|
caption=caption,
|
||||||
|
parse_mode=ParseMode.HTML,
|
||||||
|
)
|
||||||
|
except TelegramForbiddenError:
|
||||||
|
await self._notify_managers(bot, topic_id, "client_blocked")
|
||||||
|
except TelegramBadRequest as exc:
|
||||||
|
if _is_message_gone_error(exc):
|
||||||
|
await self._delete_paired_after_gone(
|
||||||
|
bot,
|
||||||
|
client_telegram_id=link.client_telegram_id,
|
||||||
|
client_message_id=link.client_message_id,
|
||||||
|
manager_message_id=link.manager_message_id,
|
||||||
|
delete_manager=True,
|
||||||
|
delete_client=False,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to sync manager edit to client message %s",
|
||||||
|
link.client_message_id,
|
||||||
|
)
|
||||||
|
await self._notify_managers(bot, topic_id, "edit_failed")
|
||||||
|
|
||||||
|
async def delete_paired_from_client(self, bot: Bot, message: Message) -> bool:
|
||||||
|
"""Delete the manager-side copy for a client message (reply+/del)."""
|
||||||
|
if message.from_user is None or message.reply_to_message is None:
|
||||||
|
return False
|
||||||
|
link = await self._repo.get_link_by_client_message(
|
||||||
|
message.from_user.id,
|
||||||
|
message.reply_to_message.message_id,
|
||||||
|
)
|
||||||
|
if link is None:
|
||||||
|
return False
|
||||||
|
await self._delete_both_sides(bot, link.client_telegram_id, link)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def delete_paired_from_manager(self, bot: Bot, message: Message) -> bool:
|
||||||
|
"""Delete the client-side copy for a manager message (reply+/del)."""
|
||||||
|
if message.reply_to_message is None:
|
||||||
|
return False
|
||||||
|
link = await self._repo.get_link_by_manager_message(
|
||||||
|
message.reply_to_message.message_id,
|
||||||
|
)
|
||||||
|
if link is None:
|
||||||
|
return False
|
||||||
|
await self._delete_both_sides(bot, link.client_telegram_id, link)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _delete_both_sides(
|
||||||
|
self,
|
||||||
|
bot: Bot,
|
||||||
|
client_telegram_id: int,
|
||||||
|
link: MessageLink,
|
||||||
|
) -> None:
|
||||||
|
await self._safe_delete(bot, client_telegram_id, link.client_message_id)
|
||||||
|
await self._safe_delete(bot, self._manager_group_id, link.manager_message_id)
|
||||||
|
await self._repo.delete_message_link(
|
||||||
|
client_telegram_id=link.client_telegram_id,
|
||||||
|
client_message_id=link.client_message_id,
|
||||||
|
manager_message_id=link.manager_message_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _delete_paired_after_gone(
|
||||||
|
self,
|
||||||
|
bot: Bot,
|
||||||
|
*,
|
||||||
|
client_telegram_id: int,
|
||||||
|
client_message_id: int,
|
||||||
|
manager_message_id: int,
|
||||||
|
delete_manager: bool,
|
||||||
|
delete_client: bool,
|
||||||
|
) -> None:
|
||||||
|
if delete_client:
|
||||||
|
await self._safe_delete(bot, client_telegram_id, client_message_id)
|
||||||
|
if delete_manager:
|
||||||
|
await self._safe_delete(bot, self._manager_group_id, manager_message_id)
|
||||||
|
await self._repo.delete_message_link(
|
||||||
|
client_telegram_id=client_telegram_id,
|
||||||
|
client_message_id=client_message_id,
|
||||||
|
manager_message_id=manager_message_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _safe_delete(self, bot: Bot, chat_id: int, message_id: int) -> None:
|
||||||
|
try:
|
||||||
|
await bot.delete_message(chat_id=chat_id, message_id=message_id)
|
||||||
|
except TelegramAPIError:
|
||||||
|
logger.info("Could not delete message %s in chat %s", message_id, chat_id)
|
||||||
|
|
||||||
|
async def relay_reaction(self, bot: Bot, event: MessageReactionUpdated) -> None:
|
||||||
|
if event.user is not None and self.is_self(event.user.id):
|
||||||
|
return
|
||||||
|
|
||||||
|
reactions = bot_reactions_from_update(event)
|
||||||
|
|
||||||
|
if event.chat.type == ChatType.PRIVATE:
|
||||||
|
link = await self._repo.get_link_by_client_message(
|
||||||
|
event.chat.id,
|
||||||
|
event.message_id,
|
||||||
|
)
|
||||||
|
if link is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await bot.set_message_reaction(
|
||||||
|
chat_id=self._manager_group_id,
|
||||||
|
message_id=link.manager_message_id,
|
||||||
|
reaction=reactions,
|
||||||
|
)
|
||||||
|
except TelegramAPIError:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to sync client reaction to manager message %s",
|
||||||
|
link.manager_message_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if event.chat.id != self._manager_group_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
link = await self._repo.get_link_by_manager_message(event.message_id)
|
||||||
|
if link is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
await bot.set_message_reaction(
|
||||||
|
chat_id=link.client_telegram_id,
|
||||||
|
message_id=link.client_message_id,
|
||||||
|
reaction=reactions,
|
||||||
|
)
|
||||||
|
except TelegramAPIError:
|
||||||
|
logger.exception(
|
||||||
|
"Failed to sync manager reaction to client message %s",
|
||||||
|
link.client_message_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_relayable_content(message: Message) -> bool:
|
||||||
|
"""True for user content we can copy (not service messages)."""
|
||||||
|
return bool(
|
||||||
|
message.text
|
||||||
|
or message.caption
|
||||||
|
or message.photo
|
||||||
|
or message.document
|
||||||
|
or message.video
|
||||||
|
or message.audio
|
||||||
|
or message.voice
|
||||||
|
or message.video_note
|
||||||
|
or message.sticker
|
||||||
|
or message.animation
|
||||||
|
or message.contact
|
||||||
|
or message.location
|
||||||
|
or message.venue
|
||||||
|
or message.poll
|
||||||
|
or message.dice
|
||||||
|
)
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
aiogram>=3.15,<4
|
||||||
|
aiosqlite>=0.20
|
||||||
|
python-dotenv>=1.0
|
||||||
Loading…
Add table
Reference in a new issue