Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40e85abf5a | ||
|
|
3a50472ca9 | ||
|
|
2b5260049b | ||
|
|
aecfe4dedf | ||
|
|
e7c32da1f9 | ||
|
|
64fe9c466e | ||
|
|
8bba0d9dd5 | ||
|
|
d5ddde3103 | ||
|
|
cabfb9b5f9 | ||
|
|
cc9d05b11b | ||
|
|
64040d6bc5 | ||
|
|
87c4c765e5 | ||
|
|
2d32bf4807 | ||
|
|
d019e884b6 | ||
|
|
e40297787e | ||
|
|
dcf4441340 | ||
|
|
1ef5cf4e38 |
@@ -0,0 +1,6 @@
|
|||||||
|
MAX_TOKEN=eyJhbGci...
|
||||||
|
MAX_DEVICE_ID=a1b2c3d4-e5f6-7890-abcd-ef1234567890
|
||||||
|
TG_BOT_TOKEN=123456:ABC-DEF1234...
|
||||||
|
TG_FORUM_CHANNEL_ID=-1001234567890
|
||||||
|
FALLBACK_USER_ID=987654321
|
||||||
|
DATABASE_URL=sqlite+aiosqlite:///data/bridge.db
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
TZ=Europe/Moscow
|
|
||||||
PYTHONUNBUFFERED=0
|
|
||||||
MAX_PHONE=+10000000000
|
|
||||||
MAX_WORK_DIR=cache
|
|
||||||
TELEGRAM_BOT_TOKEN=123456789:your_bot_token
|
|
||||||
TELEGRAM_FALLBACK_USER_ID=123456789
|
|
||||||
## SQLite база (по умолчанию используется cache/max2telegram.db)
|
|
||||||
SQLITE_PATH=cache/max2telegram.db
|
|
||||||
+5
-30
@@ -1,34 +1,9 @@
|
|||||||
.env
|
.env
|
||||||
cache/*
|
data/
|
||||||
|
|
||||||
# Python bytecode / cache
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.pyc
|
||||||
*$py.class
|
|
||||||
|
|
||||||
# Virtual environments
|
|
||||||
.venv/
|
.venv/
|
||||||
venv/
|
venv/
|
||||||
env/
|
*.db
|
||||||
ENV/
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
# Build / packaging
|
|
||||||
build/
|
|
||||||
dist/
|
|
||||||
*.egg-info/
|
|
||||||
.eggs/
|
|
||||||
pip-wheel-metadata/
|
|
||||||
|
|
||||||
# Tool caches
|
|
||||||
.pytest_cache/
|
|
||||||
.mypy_cache/
|
|
||||||
.ruff_cache/
|
|
||||||
.coverage
|
|
||||||
.coverage.*
|
|
||||||
htmlcov/
|
|
||||||
|
|
||||||
# IDE / OS
|
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|||||||
+11
-6
@@ -2,11 +2,16 @@ FROM python:3.12-slim
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
&& apt-get install -y --no-install-recommends git \
|
gcc \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
COPY ./src/requirements.txt .
|
|
||||||
RUN pip install --no-cache-dir -r ./requirements.txt
|
|
||||||
|
|
||||||
COPY ./src .
|
COPY requirements.txt .
|
||||||
CMD [ "python", "-u", "./main.py" ]
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app/ ./app/
|
||||||
|
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
|
||||||
|
CMD ["python", "-m", "app.main"]
|
||||||
|
|||||||
@@ -1,122 +1,48 @@
|
|||||||
# max2telegram
|
# max2telegram
|
||||||
|
|
||||||
Мост для пересылки сообщений из Max в Telegram.
|
Асинхронный мост для двусторонней синхронизации переписки между мессенджером **MAX** и **Telegram** (форум-канал с топиками).
|
||||||
|
|
||||||
## Что умеет
|
## Архитектура
|
||||||
|
|
||||||
- Слушает входящие сообщения в Max через `PyMax`.
|
Приложение построено по слоям:
|
||||||
- Пересылает в Telegram:
|
|
||||||
- текст;
|
|
||||||
- фото;
|
|
||||||
- видео.
|
|
||||||
- Для нескольких вложений отправляет единым альбомом (`sendMediaGroup`).
|
|
||||||
- В тексте указывает источник в формате: `MAX: <ник> / <название чата>`.
|
|
||||||
- Защищает от дублей через SQLite (`forwarded_messages`).
|
|
||||||
|
|
||||||
## Логика выбора чата в Telegram
|
| Слой | Назначение |
|
||||||
|
|------|------------|
|
||||||
|
| `max_layer` | WebSocket-клиент MAX (PyMAX WebClient), listener и worker |
|
||||||
|
| `telegram_layer` | Aiogram dispatcher, TG worker, admin-команды |
|
||||||
|
| `router` | Маршрутизация по ID, дедупликация, постановка в очереди |
|
||||||
|
| `queue` | Redis-очереди `max2tg_queue` и `tg2max_queue` |
|
||||||
|
| `storage` | SQLite: маппинги, маркеры, связи сообщений |
|
||||||
|
|
||||||
1. Берется название чата из Max.
|
## Быстрый старт
|
||||||
2. Если для этого Max-чата есть явная привязка (см. `/bind_max` ниже), сообщение отправляется в привязанный Telegram-чат.
|
|
||||||
3. Иначе: если у бота найден Telegram-чат с таким же названием, сообщение отправляется туда.
|
|
||||||
4. Если совпадения нет, сообщение уходит в личку на `TELEGRAM_FALLBACK_USER_ID`.
|
|
||||||
|
|
||||||
## Важно про поиск чатов
|
1. Скопируйте `.env.example` в `.env` и заполните переменные.
|
||||||
|
2. Создайте папку `data/` (монтируется для SQLite и сессии PyMAX).
|
||||||
|
3. Запустите:
|
||||||
|
|
||||||
Поиск чатов по названию выполняется через `getUpdates`, поэтому:
|
```bash
|
||||||
|
docker compose up -d
|
||||||
- бот должен быть добавлен в нужные Telegram-чаты;
|
```
|
||||||
- в этих чатах должен быть хотя бы один апдейт (сообщение/событие), чтобы чат появился в апдейтах;
|
|
||||||
- если у бота включен privacy mode, он может не получать обычные сообщения из групп — тогда чат может не “обнаружиться” через `getUpdates`.
|
|
||||||
|
|
||||||
### Привязка Max-чата к Telegram-чату (рекомендуется)
|
|
||||||
|
|
||||||
Чтобы не зависеть от `getUpdates` и privacy mode, можно один раз “забиндить” нужный Telegram-чат на название чата в Max.
|
|
||||||
|
|
||||||
В целевом Telegram-чате напиши команду:
|
|
||||||
|
|
||||||
- `/bind_max <точное название чата в Max>`
|
|
||||||
|
|
||||||
Пример:
|
|
||||||
|
|
||||||
- `/bind_max Свободный микрофон`
|
|
||||||
|
|
||||||
После этого пересылка **из Max в Telegram** для чата `Свободный микрофон` будет идти строго в этот Telegram-чат (привязка хранится в SQLite).
|
|
||||||
|
|
||||||
## Переменные окружения
|
## Переменные окружения
|
||||||
|
|
||||||
Используется файл `.env` в корне проекта.
|
См. `tech-specs.md`, раздел 6, и `.env.example`.
|
||||||
|
|
||||||
Пример смотри в `.env.sample`.
|
## Команды (только FALLBACK_USER_ID в ЛС с ботом)
|
||||||
|
|
||||||
Обязательные переменные:
|
- `/start` — статус подключения
|
||||||
|
- `/help` — справка
|
||||||
|
- `/list` — список маппингов
|
||||||
|
- `/join <ссылка>` — вступить в MAX-группу
|
||||||
|
- `/leave <id или название>` — выйти из MAX-чата
|
||||||
|
- `/last_messages <id или название>` — последние 10 сообщений
|
||||||
|
|
||||||
- `MAX_PHONE` - номер телефона аккаунта Max;
|
## Локальная разработка
|
||||||
- `MAX_WORK_DIR` - директория сессии Max (обычно `cache`);
|
|
||||||
- `TELEGRAM_BOT_TOKEN` - токен Telegram-бота;
|
|
||||||
- `TELEGRAM_FALLBACK_USER_ID` - id пользователя Telegram для fallback отправки;
|
|
||||||
- `SQLITE_PATH` - путь к SQLite базе (рекомендуется `cache/max2telegram.db`, по умолчанию так и будет).
|
|
||||||
|
|
||||||
Дополнительно:
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
export $(cat .env | xargs) # Linux/macOS
|
||||||
|
python -m app.main
|
||||||
|
```
|
||||||
|
|
||||||
- `TZ` - таймзона контейнера;
|
Redis должен быть доступен по `REDIS_URL`.
|
||||||
- `PYTHONUNBUFFERED` - режим буферизации вывода Python.
|
|
||||||
|
|
||||||
## Быстрый старт (Docker)
|
|
||||||
|
|
||||||
1. Скопировать шаблон:
|
|
||||||
- `copy .env.sample .env` (Windows)
|
|
||||||
2. Заполнить `.env` своими значениями.
|
|
||||||
3. Пройти аутентификацию Max (см. ниже).
|
|
||||||
4. Запустить:
|
|
||||||
- `docker compose up -d --build`
|
|
||||||
5. Логи:
|
|
||||||
- `docker compose logs -f`
|
|
||||||
|
|
||||||
## Аутентификация Max
|
|
||||||
|
|
||||||
Перед запуском приложения необходимо пройти аутентификацию Max, чтобы создать сессионный файл.
|
|
||||||
|
|
||||||
### Локально (без Docker)
|
|
||||||
|
|
||||||
1. Убедиться, что `.env` заполнен, особенно `MAX_PHONE`.
|
|
||||||
2. Запустить скрипт аутентификации:
|
|
||||||
- `python auth.py`
|
|
||||||
3. Следовать инструкциям в консоли (ввод кода подтверждения и т.д.).
|
|
||||||
4. После успешной аутентификации сессионный файл сохранится в директории `MAX_WORK_DIR` (по умолчанию `cache`).
|
|
||||||
|
|
||||||
### В Docker
|
|
||||||
|
|
||||||
1. Запустить контейнер с интерактивной сессией:
|
|
||||||
- `docker compose run --rm max2telegram python auth.py`
|
|
||||||
2. Следовать инструкциям в консоли.
|
|
||||||
3. После завершения запустить основное приложение:
|
|
||||||
- `docker compose up -d`
|
|
||||||
|
|
||||||
## Запуск локально (без Docker)
|
|
||||||
|
|
||||||
1. Установить зависимости:
|
|
||||||
- `pip install -r src/requirements.txt`
|
|
||||||
2. Создать `.env` на основе `.env.sample`.
|
|
||||||
3. Пройти аутентификацию Max:
|
|
||||||
- `python auth.py`
|
|
||||||
4. Запустить приложение:
|
|
||||||
- `python src/main.py`
|
|
||||||
|
|
||||||
## Структура проекта
|
|
||||||
|
|
||||||
- `auth.py` - скрипт аутентификации Max (создание сессии).
|
|
||||||
- `src/main.py` - точка входа, инициализация клиентов.
|
|
||||||
- `src/bridge.py` - основная логика маршрутизации и отправки.
|
|
||||||
- `src/telegram_api.py` - Telegram Bot API клиент.
|
|
||||||
- `src/max_parser.py` - разбор входящих сообщений Max.
|
|
||||||
- `src/storage.py` - SQLite слой для дедупликации.
|
|
||||||
- `src/config.py` - загрузка конфигурации из окружения.
|
|
||||||
|
|
||||||
## Типичные проблемы
|
|
||||||
|
|
||||||
- Фото/видео не приходят:
|
|
||||||
- проверь, что бот имеет доступ к целевому чату;
|
|
||||||
- проверь валидность URL вложений и токена бота.
|
|
||||||
- Сообщения идут только в fallback:
|
|
||||||
- бот еще не видел апдейты из нужного чата;
|
|
||||||
- название чата в Max и Telegram не совпадает.
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import model_validator
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
from sqlalchemy.engine.url import make_url
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||||
|
|
||||||
|
max_token: str
|
||||||
|
max_device_id: str
|
||||||
|
tg_bot_token: str
|
||||||
|
tg_forum_channel_id: int
|
||||||
|
fallback_user_id: int
|
||||||
|
database_url: str = "sqlite+aiosqlite:///data/bridge.db"
|
||||||
|
redis_url: str = "redis://redis:6379/0"
|
||||||
|
tg_rate_limit_delay_sec: float = 3.5
|
||||||
|
max_rate_limit_delay_sec: float = 1.0
|
||||||
|
ls_topic_prefix: str = "👤 "
|
||||||
|
max_reconnect_fetch_limit: int = 50
|
||||||
|
log_level: str = "INFO"
|
||||||
|
data_dir: str = ""
|
||||||
|
max_session_name: str = "max_session.db"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sqlite_path(self) -> str:
|
||||||
|
db = make_url(self.database_url).database
|
||||||
|
if not db:
|
||||||
|
return str(Path("data/bridge.db").resolve())
|
||||||
|
path = Path(db)
|
||||||
|
return str(path if path.is_absolute() else path.resolve())
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _default_data_dir(self) -> "Settings":
|
||||||
|
if not self.data_dir:
|
||||||
|
object.__setattr__(self, "data_dir", str(Path(self.sqlite_path).parent))
|
||||||
|
return self
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(level: str) -> None:
|
||||||
|
logging.basicConfig(
|
||||||
|
format="%(message)s",
|
||||||
|
stream=sys.stdout,
|
||||||
|
level=getattr(logging, level.upper(), logging.INFO),
|
||||||
|
)
|
||||||
|
structlog.configure(
|
||||||
|
processors=[
|
||||||
|
structlog.contextvars.merge_contextvars,
|
||||||
|
structlog.processors.add_log_level,
|
||||||
|
structlog.processors.TimeStamper(fmt="iso"),
|
||||||
|
structlog.processors.StackInfoRenderer(),
|
||||||
|
structlog.processors.format_exc_info,
|
||||||
|
structlog.processors.JSONRenderer(),
|
||||||
|
],
|
||||||
|
wrapper_class=structlog.make_filtering_bound_logger(
|
||||||
|
getattr(logging, level.upper(), logging.INFO)
|
||||||
|
),
|
||||||
|
context_class=dict,
|
||||||
|
logger_factory=structlog.PrintLoggerFactory(),
|
||||||
|
cache_logger_on_first_use=True,
|
||||||
|
)
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
import asyncio
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
from app.logging_setup import setup_logging
|
||||||
|
from app.max_layer.client_holder import MaxClientHolder
|
||||||
|
from app.max_layer.listener import MaxListener
|
||||||
|
from app.max_layer.worker import MaxWorker
|
||||||
|
from app.queue.redis_queue import RedisQueue
|
||||||
|
from app.router.router import MessageRouter
|
||||||
|
from app.storage.database import create_engine, create_session_factory
|
||||||
|
from app.storage.repository import SqliteStorage
|
||||||
|
from app.telegram_layer.bot_holder import BotHolder
|
||||||
|
from app.telegram_layer.listener import TelegramListener
|
||||||
|
from app.telegram_layer.worker import TelegramWorker
|
||||||
|
from app.topic_locks import TopicLockRegistry
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
SERVICE_RESTART_DELAY_SEC = 3.0
|
||||||
|
|
||||||
|
|
||||||
|
async def run_service(
|
||||||
|
name: str,
|
||||||
|
runner: Callable[[], Awaitable[None]],
|
||||||
|
) -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await runner()
|
||||||
|
logger.warning("service_stopped", service=name)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("service_cancelled", service=name)
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
logger.exception("service_failed", service=name)
|
||||||
|
await asyncio.sleep(SERVICE_RESTART_DELAY_SEC)
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
settings = Settings()
|
||||||
|
setup_logging(settings.log_level)
|
||||||
|
Path(settings.sqlite_path).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
Path(settings.data_dir).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
engine = create_engine(settings.database_url)
|
||||||
|
session_factory = create_session_factory(engine)
|
||||||
|
storage = SqliteStorage(session_factory)
|
||||||
|
await storage.init()
|
||||||
|
mappings = await storage.list_mappings()
|
||||||
|
sqlite_file = Path(settings.sqlite_path)
|
||||||
|
db_exists = sqlite_file.is_file()
|
||||||
|
db_size_bytes = sqlite_file.stat().st_size if db_exists else 0
|
||||||
|
logger.info(
|
||||||
|
"storage_ready",
|
||||||
|
sqlite_path=settings.sqlite_path,
|
||||||
|
database_url=settings.database_url,
|
||||||
|
db_file_exists=db_exists,
|
||||||
|
db_file_size_bytes=db_size_bytes,
|
||||||
|
mapping_count=len(mappings),
|
||||||
|
)
|
||||||
|
if len(mappings) == 0:
|
||||||
|
logger.info(
|
||||||
|
"storage_no_mappings",
|
||||||
|
reason="chat_mappings_table_empty",
|
||||||
|
effect="new_tg_forum_topic_will_be_created_for_each_max_chat",
|
||||||
|
hint="after_container_recreate_check_volume_mount_data_dir_and_sqlite_path",
|
||||||
|
sqlite_path=settings.sqlite_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
legacy_db = Path.cwd() / "app" / "data" / "bridge.db"
|
||||||
|
if (
|
||||||
|
legacy_db.is_file()
|
||||||
|
and legacy_db.resolve() != sqlite_file.resolve()
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
"sqlite_legacy_path_detected",
|
||||||
|
legacy_path=str(legacy_db.resolve()),
|
||||||
|
active_path=settings.sqlite_path,
|
||||||
|
hint="old_database_url_app/data/bridge_db_writes_outside_volume_use_data/bridge_db",
|
||||||
|
)
|
||||||
|
|
||||||
|
queue = RedisQueue(settings.redis_url)
|
||||||
|
await queue.connect()
|
||||||
|
|
||||||
|
topic_locks = TopicLockRegistry()
|
||||||
|
router = MessageRouter(
|
||||||
|
storage=storage,
|
||||||
|
queue=queue,
|
||||||
|
forum_channel_id=settings.tg_forum_channel_id,
|
||||||
|
topic_locks=topic_locks,
|
||||||
|
)
|
||||||
|
|
||||||
|
max_holder = MaxClientHolder()
|
||||||
|
bot_holder = BotHolder()
|
||||||
|
|
||||||
|
max_listener = MaxListener(settings, max_holder, router, storage)
|
||||||
|
tg_listener = TelegramListener(
|
||||||
|
settings, bot_holder, router, storage, max_holder
|
||||||
|
)
|
||||||
|
tg_worker = TelegramWorker(
|
||||||
|
settings, bot_holder, max_holder, queue, storage, topic_locks
|
||||||
|
)
|
||||||
|
max_worker = MaxWorker(settings, max_holder, bot_holder, queue, router, storage)
|
||||||
|
|
||||||
|
logger.info("max2telegram_starting")
|
||||||
|
|
||||||
|
await asyncio.gather(
|
||||||
|
run_service("max_listener", max_listener.run),
|
||||||
|
run_service("tg_listener", tg_listener.run),
|
||||||
|
run_service("tg_worker", tg_worker.run),
|
||||||
|
run_service("max_worker", max_worker.run),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import asyncio
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pymax import WebClient
|
||||||
|
|
||||||
|
|
||||||
|
class MaxClientHolder:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.client: WebClient | None = None
|
||||||
|
self.ready = asyncio.Event()
|
||||||
|
self.my_user_id: int | None = None
|
||||||
|
|
||||||
|
def set_client(self, client: "WebClient", my_user_id: int | None) -> None:
|
||||||
|
self.client = client
|
||||||
|
self.my_user_id = my_user_id
|
||||||
|
self.ready.set()
|
||||||
|
|
||||||
|
async def wait_client(self) -> "WebClient":
|
||||||
|
await self.ready.wait()
|
||||||
|
assert self.client is not None
|
||||||
|
return self.client
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from pymax.types.domain.message import Message as MaxMessage
|
||||||
|
|
||||||
|
from app.models.domain import MaxChatMeta, MaxIncomingMessage
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
def get_forward_link(message: MaxMessage) -> dict[str, Any] | None:
|
||||||
|
link = getattr(message, "link", None)
|
||||||
|
if not isinstance(link, dict):
|
||||||
|
return None
|
||||||
|
if str(link.get("type", "")).upper() != "FORWARD":
|
||||||
|
return None
|
||||||
|
return link
|
||||||
|
|
||||||
|
|
||||||
|
def get_reply_target(message: MaxMessage) -> int | None:
|
||||||
|
link = getattr(message, "link", None)
|
||||||
|
if not isinstance(link, dict):
|
||||||
|
return None
|
||||||
|
if str(link.get("type", "")).upper() != "REPLY":
|
||||||
|
return None
|
||||||
|
target = link.get("messageId") or link.get("message_id")
|
||||||
|
if target is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(target)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def extract_forwarded_content(
|
||||||
|
message: MaxMessage,
|
||||||
|
) -> tuple[str, list[dict[str, Any]]] | None:
|
||||||
|
link = get_forward_link(message)
|
||||||
|
if link is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
nested = link.get("message")
|
||||||
|
if not isinstance(nested, dict):
|
||||||
|
logger.warning(
|
||||||
|
"max_forward_missing_nested_message",
|
||||||
|
chat_id=message.chat_id,
|
||||||
|
message_id=message.id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
text = nested.get("text") or ""
|
||||||
|
attaches = nested.get("attaches") or []
|
||||||
|
if not isinstance(attaches, list):
|
||||||
|
attaches = []
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"max_forward_extracted",
|
||||||
|
chat_id=message.chat_id,
|
||||||
|
message_id=message.id,
|
||||||
|
nested_message_id=nested.get("id"),
|
||||||
|
nested_sender=nested.get("sender"),
|
||||||
|
text_len=len(text),
|
||||||
|
attach_count=len(attaches),
|
||||||
|
)
|
||||||
|
return text, attaches
|
||||||
|
|
||||||
|
|
||||||
|
def format_forwarded_text(
|
||||||
|
original_text: str,
|
||||||
|
original_sender_name: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
header = "↪️ Переслано"
|
||||||
|
if original_sender_name:
|
||||||
|
header += f" от {original_sender_name}"
|
||||||
|
if original_text:
|
||||||
|
return f"{header}:\n{original_text}"
|
||||||
|
return header
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_type(attach: dict[str, Any]) -> str:
|
||||||
|
return str(attach.get("_type") or attach.get("type") or "").upper()
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_raw_attaches(
|
||||||
|
client,
|
||||||
|
chat_id: int,
|
||||||
|
message_id: int,
|
||||||
|
attaches: list[dict[str, Any]],
|
||||||
|
) -> list[dict]:
|
||||||
|
items: list[dict] = []
|
||||||
|
for attach in attaches:
|
||||||
|
if not isinstance(attach, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
attach_type = _attach_type(attach)
|
||||||
|
if attach_type == "PHOTO":
|
||||||
|
url = attach.get("baseUrl") or attach.get("base_url")
|
||||||
|
if url:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"kind": "photo",
|
||||||
|
"url": url,
|
||||||
|
"max_chat_id": chat_id,
|
||||||
|
"max_message_id": message_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if attach_type == "VIDEO":
|
||||||
|
video_id = attach.get("videoId") or attach.get("video_id")
|
||||||
|
if video_id is not None:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"kind": "video",
|
||||||
|
"max_chat_id": chat_id,
|
||||||
|
"max_message_id": message_id,
|
||||||
|
"max_video_id": video_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if attach_type == "FILE":
|
||||||
|
file_id = attach.get("fileId") or attach.get("file_id")
|
||||||
|
if file_id is not None:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"kind": "document",
|
||||||
|
"file_name": attach.get("name"),
|
||||||
|
"max_chat_id": chat_id,
|
||||||
|
"max_message_id": message_id,
|
||||||
|
"max_file_id": file_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
url = attach.get("baseUrl") or attach.get("base_url") or attach.get("url")
|
||||||
|
if url:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"kind": "document",
|
||||||
|
"url": url,
|
||||||
|
"max_chat_id": chat_id,
|
||||||
|
"max_message_id": message_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"max_raw_attaches_resolved",
|
||||||
|
chat_id=chat_id,
|
||||||
|
message_id=message_id,
|
||||||
|
input_count=len(attaches),
|
||||||
|
resolved_count=len(items),
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_media(client, message: MaxMessage) -> list[dict]:
|
||||||
|
from pymax.types.domain.attachments.file import FileAttachment
|
||||||
|
from pymax.types.domain.attachments.photo import PhotoAttachment
|
||||||
|
from pymax.types.domain.attachments.video import VideoAttachment
|
||||||
|
|
||||||
|
items: list[dict] = []
|
||||||
|
chat_id = message.chat_id
|
||||||
|
if chat_id is None:
|
||||||
|
return items
|
||||||
|
|
||||||
|
for attach in message.attaches:
|
||||||
|
if isinstance(attach, dict):
|
||||||
|
items.extend(
|
||||||
|
await resolve_raw_attaches(client, chat_id, message.id, [attach])
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if isinstance(attach, PhotoAttachment):
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"kind": "photo",
|
||||||
|
"url": attach.base_url,
|
||||||
|
"max_chat_id": chat_id,
|
||||||
|
"max_message_id": message.id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif isinstance(attach, VideoAttachment):
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"kind": "video",
|
||||||
|
"max_chat_id": chat_id,
|
||||||
|
"max_message_id": message.id,
|
||||||
|
"max_video_id": attach.video_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif isinstance(attach, FileAttachment):
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"kind": "document",
|
||||||
|
"file_name": attach.name,
|
||||||
|
"max_chat_id": chat_id,
|
||||||
|
"max_message_id": message.id,
|
||||||
|
"max_file_id": attach.file_id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
url = getattr(attach, "base_url", None) or getattr(attach, "url", None)
|
||||||
|
if url:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"kind": "document",
|
||||||
|
"url": url,
|
||||||
|
"max_chat_id": chat_id,
|
||||||
|
"max_message_id": message.id,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_image_url(*values: str | None) -> str | None:
|
||||||
|
for value in values:
|
||||||
|
if value and value.strip():
|
||||||
|
return value.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _chat_icon_url(chat) -> str | None:
|
||||||
|
return _pick_image_url(
|
||||||
|
getattr(chat, "base_icon_url", None),
|
||||||
|
getattr(chat, "base_raw_icon_url", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _user_icon_url(user) -> str | None:
|
||||||
|
return _pick_image_url(
|
||||||
|
getattr(user, "base_url", None),
|
||||||
|
getattr(user, "base_raw_url", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def resolve_chat_icon_url(
|
||||||
|
client,
|
||||||
|
chat,
|
||||||
|
my_user_id: int | None,
|
||||||
|
) -> str | None:
|
||||||
|
is_dm = bool(getattr(chat, "is_dialog", False) or chat.type == "DIALOG")
|
||||||
|
if is_dm:
|
||||||
|
for user_id in chat.participants or {}:
|
||||||
|
if my_user_id and user_id == my_user_id:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
user = await client.get_user(user_id)
|
||||||
|
icon_url = _user_icon_url(user)
|
||||||
|
if icon_url:
|
||||||
|
return icon_url
|
||||||
|
except Exception:
|
||||||
|
logger.debug(
|
||||||
|
"max_dm_icon_lookup_failed",
|
||||||
|
chat_id=chat.id,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return _chat_icon_url(chat)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_chat_meta(chat, ls_prefix: str) -> MaxChatMeta:
|
||||||
|
is_dm = bool(getattr(chat, "is_dialog", False) or chat.type == "DIALOG")
|
||||||
|
chat_name = chat.title or ("Контакт" if is_dm else f"Чат {chat.id}")
|
||||||
|
topic_title = chat_name
|
||||||
|
if is_dm and not topic_title.startswith(ls_prefix.strip()):
|
||||||
|
topic_title = f"{ls_prefix}{topic_title}"
|
||||||
|
return MaxChatMeta(
|
||||||
|
topic_title=topic_title,
|
||||||
|
chat_name=chat_name,
|
||||||
|
is_dm=is_dm,
|
||||||
|
icon_url=_chat_icon_url(chat),
|
||||||
|
participants_count=getattr(chat, "participants_count", 0) or 0,
|
||||||
|
link=getattr(chat, "link", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_chat_title(chat, ls_prefix: str) -> tuple[str, bool]:
|
||||||
|
meta = extract_chat_meta(chat, ls_prefix)
|
||||||
|
return meta.topic_title, meta.is_dm
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_sender_name(user) -> str:
|
||||||
|
if user is None:
|
||||||
|
return "Неизвестный"
|
||||||
|
if user.names:
|
||||||
|
name = user.names[0]
|
||||||
|
parts = [name.first_name, name.last_name]
|
||||||
|
return " ".join(p for p in parts if p) or f"User {user.id}"
|
||||||
|
return f"User {user.id}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_max_text(message: MaxIncomingMessage) -> str:
|
||||||
|
if message.is_dm:
|
||||||
|
return message.text
|
||||||
|
sender = message.sender_name or "Неизвестный"
|
||||||
|
return f"{sender}:\n{message.text}"
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
from pymax import ExtraConfig, Message, WebClient
|
||||||
|
from app.config import Settings
|
||||||
|
from app.media_transfer import download_max_image_url, download_max_media, tmp_dir
|
||||||
|
from app.max_layer.client_holder import MaxClientHolder
|
||||||
|
from app.max_layer.formatter import (
|
||||||
|
extract_chat_meta,
|
||||||
|
extract_forwarded_content,
|
||||||
|
resolve_chat_icon_url,
|
||||||
|
format_forwarded_text,
|
||||||
|
get_reply_target,
|
||||||
|
resolve_media,
|
||||||
|
resolve_raw_attaches,
|
||||||
|
resolve_sender_name,
|
||||||
|
)
|
||||||
|
from app.models.domain import MaxIncomingMessage
|
||||||
|
from app.router.router import MessageRouter
|
||||||
|
from app.storage.protocols import StoragePort
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MaxListener:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
settings: Settings,
|
||||||
|
holder: MaxClientHolder,
|
||||||
|
router: MessageRouter,
|
||||||
|
storage: StoragePort,
|
||||||
|
) -> None:
|
||||||
|
self._settings = settings
|
||||||
|
self._holder = holder
|
||||||
|
self._router = router
|
||||||
|
self._storage = storage
|
||||||
|
self._client: WebClient | None = None
|
||||||
|
self._tmp_dir = tmp_dir(self._settings.data_dir)
|
||||||
|
|
||||||
|
def build_client(self) -> WebClient:
|
||||||
|
work_dir = str(Path(self._settings.data_dir))
|
||||||
|
extra = ExtraConfig(
|
||||||
|
token=self._settings.max_token,
|
||||||
|
device_id=self._settings.max_device_id,
|
||||||
|
log_level=self._settings.log_level,
|
||||||
|
reconnect=True,
|
||||||
|
reconnect_delay=3.0,
|
||||||
|
telemetry=False,
|
||||||
|
)
|
||||||
|
client = WebClient(
|
||||||
|
session_name=self._settings.max_session_name,
|
||||||
|
work_dir=work_dir,
|
||||||
|
extra_config=extra,
|
||||||
|
)
|
||||||
|
self._register_handlers(client)
|
||||||
|
return client
|
||||||
|
|
||||||
|
def _register_handlers(self, client: WebClient) -> None:
|
||||||
|
@client.on_start()
|
||||||
|
async def on_start(c: WebClient) -> None:
|
||||||
|
my_id = c.me.contact.id if c.me else None
|
||||||
|
self._holder.set_client(c, my_id)
|
||||||
|
logger.info("max_client_started", user_id=my_id)
|
||||||
|
await self._catch_up_history(c)
|
||||||
|
|
||||||
|
@client.on_message()
|
||||||
|
async def on_message(message: Message, c: WebClient) -> None:
|
||||||
|
logger.debug(
|
||||||
|
"max_message_event",
|
||||||
|
chat_id=message.chat_id,
|
||||||
|
message_id=message.id,
|
||||||
|
sender=message.sender,
|
||||||
|
msg_type=message.type,
|
||||||
|
has_text=bool(message.text),
|
||||||
|
attach_count=len(message.attaches),
|
||||||
|
is_forward=bool(getattr(message, "link", None)),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self._process_message(c, message)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"max_message_handler_failed",
|
||||||
|
chat_id=message.chat_id,
|
||||||
|
message_id=message.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _download_chat_icon(self, icon_url: str | None) -> str | None:
|
||||||
|
if not icon_url:
|
||||||
|
return None
|
||||||
|
dest = self._tmp_dir / f"chat_icon_{uuid4().hex}.jpg"
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
if not await download_max_image_url(session, icon_url, dest):
|
||||||
|
logger.warning("max_chat_icon_download_failed", url=icon_url)
|
||||||
|
return None
|
||||||
|
return str(dest)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("max_chat_icon_download_failed", url=icon_url)
|
||||||
|
dest.unlink(missing_ok=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
self._client = self.build_client()
|
||||||
|
await self._client.start()
|
||||||
|
|
||||||
|
async def _catch_up_history(self, client: WebClient) -> None:
|
||||||
|
if not client.chats:
|
||||||
|
logger.info("max_history_catch_up_skipped", reason="no_chats")
|
||||||
|
return
|
||||||
|
limit = self._settings.max_reconnect_fetch_limit
|
||||||
|
logger.info("max_history_catch_up_started", chat_count=len(client.chats), limit=limit)
|
||||||
|
for chat in client.chats:
|
||||||
|
try:
|
||||||
|
messages = await client.fetch_history(chat_id=chat.id, backward=limit)
|
||||||
|
if not messages:
|
||||||
|
logger.debug("max_history_empty", chat_id=chat.id)
|
||||||
|
continue
|
||||||
|
logger.info("max_history_fetched", chat_id=chat.id, count=len(messages))
|
||||||
|
for msg in sorted(messages, key=lambda m: m.id):
|
||||||
|
if msg.chat_id is None:
|
||||||
|
msg.chat_id = chat.id
|
||||||
|
await self._process_message(client, msg)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("max_history_fetch_failed", chat_id=chat.id)
|
||||||
|
|
||||||
|
async def _process_message(self, client: WebClient, message: Message) -> None:
|
||||||
|
if message.chat_id is None:
|
||||||
|
logger.debug("max_message_skipped", reason="no_chat_id", message_id=message.id)
|
||||||
|
return
|
||||||
|
if self._holder.my_user_id and message.sender == self._holder.my_user_id:
|
||||||
|
link = await self._storage.get_tg_message_by_max(
|
||||||
|
message.chat_id, message.id
|
||||||
|
)
|
||||||
|
if link is not None:
|
||||||
|
logger.debug(
|
||||||
|
"max_message_skipped",
|
||||||
|
reason="own_echo",
|
||||||
|
chat_id=message.chat_id,
|
||||||
|
message_id=message.id,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
forwarded = extract_forwarded_content(message)
|
||||||
|
effective_text = message.text or ""
|
||||||
|
forwarded_attaches: list | None = None
|
||||||
|
if forwarded is not None:
|
||||||
|
nested_text, nested_attaches = forwarded
|
||||||
|
original_sender_name: str | None = None
|
||||||
|
nested = getattr(message, "link", {}).get("message", {})
|
||||||
|
nested_sender = nested.get("sender") if isinstance(nested, dict) else None
|
||||||
|
if nested_sender:
|
||||||
|
try:
|
||||||
|
user = await client.get_user(nested_sender)
|
||||||
|
original_sender_name = resolve_sender_name(user)
|
||||||
|
except Exception:
|
||||||
|
original_sender_name = f"User {nested_sender}"
|
||||||
|
logger.warning(
|
||||||
|
"max_forward_sender_lookup_failed",
|
||||||
|
chat_id=message.chat_id,
|
||||||
|
message_id=message.id,
|
||||||
|
nested_sender=nested_sender,
|
||||||
|
)
|
||||||
|
effective_text = format_forwarded_text(nested_text, original_sender_name)
|
||||||
|
forwarded_attaches = nested_attaches
|
||||||
|
|
||||||
|
has_content = bool(
|
||||||
|
effective_text.strip()
|
||||||
|
or message.attaches
|
||||||
|
or forwarded_attaches
|
||||||
|
)
|
||||||
|
if not has_content:
|
||||||
|
logger.debug(
|
||||||
|
"max_message_skipped",
|
||||||
|
reason="empty",
|
||||||
|
chat_id=message.chat_id,
|
||||||
|
message_id=message.id,
|
||||||
|
is_forward=forwarded is not None,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
chat = await client.get_chat(message.chat_id)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("max_chat_fetch_failed", chat_id=message.chat_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
chat_meta = extract_chat_meta(chat, self._settings.ls_topic_prefix)
|
||||||
|
chat_icon_url = await resolve_chat_icon_url(
|
||||||
|
client, chat, self._holder.my_user_id
|
||||||
|
)
|
||||||
|
chat_icon_local_path = await self._download_chat_icon(chat_icon_url)
|
||||||
|
|
||||||
|
sender_name: str | None = None
|
||||||
|
if not chat_meta.is_dm and message.sender:
|
||||||
|
try:
|
||||||
|
user = await client.get_user(message.sender)
|
||||||
|
sender_name = resolve_sender_name(user)
|
||||||
|
except Exception:
|
||||||
|
sender_name = f"User {message.sender}"
|
||||||
|
|
||||||
|
reply_to = get_reply_target(message)
|
||||||
|
|
||||||
|
try:
|
||||||
|
if forwarded_attaches is not None:
|
||||||
|
# File API requires the forward wrapper message id in this chat,
|
||||||
|
# not the nested original message id (error.user.file.access).
|
||||||
|
media = await resolve_raw_attaches(
|
||||||
|
client, message.chat_id, message.id, forwarded_attaches
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
media = await resolve_media(client, message)
|
||||||
|
media = await download_max_media(client, media, self._tmp_dir)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"max_media_resolve_failed",
|
||||||
|
chat_id=message.chat_id,
|
||||||
|
message_id=message.id,
|
||||||
|
)
|
||||||
|
media = []
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"max_message_processing",
|
||||||
|
chat_id=message.chat_id,
|
||||||
|
message_id=message.id,
|
||||||
|
is_forward=forwarded is not None,
|
||||||
|
text_len=len(effective_text),
|
||||||
|
media_count=len(media),
|
||||||
|
is_dm=chat_meta.is_dm,
|
||||||
|
reply_to=reply_to,
|
||||||
|
)
|
||||||
|
|
||||||
|
incoming = MaxIncomingMessage(
|
||||||
|
max_chat_id=message.chat_id,
|
||||||
|
max_message_id=message.id,
|
||||||
|
text=effective_text,
|
||||||
|
sender_id=message.sender,
|
||||||
|
sender_name=sender_name,
|
||||||
|
is_dm=chat_meta.is_dm,
|
||||||
|
chat_title=chat_meta.topic_title,
|
||||||
|
chat_name=chat_meta.chat_name,
|
||||||
|
chat_icon_url=chat_icon_url,
|
||||||
|
chat_icon_local_path=chat_icon_local_path,
|
||||||
|
participants_count=chat_meta.participants_count,
|
||||||
|
max_chat_link=chat_meta.link,
|
||||||
|
reply_to_max_message_id=reply_to,
|
||||||
|
media=media,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self._router.handle_max_message(incoming)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"max_route_failed",
|
||||||
|
chat_id=message.chat_id,
|
||||||
|
message_id=message.id,
|
||||||
|
)
|
||||||
|
await self._router.notify_error(
|
||||||
|
"Ошибка маршрутизации MAX → TG",
|
||||||
|
f"chat_id={message.chat_id} message_id={message.id}",
|
||||||
|
)
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import structlog
|
||||||
|
from pymax import File, Photo, Video
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
from app.media_transfer import download_media_item, tmp_dir
|
||||||
|
from app.max_layer.client_holder import MaxClientHolder
|
||||||
|
from app.models.tasks import SetReactionTask, Tg2MaxTask
|
||||||
|
from app.queue.protocols import QueuePort
|
||||||
|
from app.router.router import MessageRouter
|
||||||
|
from app.storage.protocols import StoragePort
|
||||||
|
from app.telegram_layer.bot_holder import BotHolder
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MaxWorker:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
settings: Settings,
|
||||||
|
holder: MaxClientHolder,
|
||||||
|
bot_holder: BotHolder,
|
||||||
|
queue: QueuePort,
|
||||||
|
router: MessageRouter,
|
||||||
|
storage: StoragePort,
|
||||||
|
) -> None:
|
||||||
|
self._settings = settings
|
||||||
|
self._holder = holder
|
||||||
|
self._bot_holder = bot_holder
|
||||||
|
self._queue = queue
|
||||||
|
self._router = router
|
||||||
|
self._storage = storage
|
||||||
|
self._running = True
|
||||||
|
self._tmp_dir = tmp_dir(self._settings.data_dir)
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
logger.info("max_worker_started")
|
||||||
|
while self._running:
|
||||||
|
task = await self._queue.dequeue_tg2max(timeout=5)
|
||||||
|
if task is None:
|
||||||
|
continue
|
||||||
|
logger.info(
|
||||||
|
"max_worker_task_received",
|
||||||
|
tg_message_id=task.tg_message_id,
|
||||||
|
max_chat_id=task.max_chat_id,
|
||||||
|
media_count=len(task.media),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self._process(task)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("max_worker_failed", tg_message_id=task.tg_message_id)
|
||||||
|
await self._router.notify_error(
|
||||||
|
"Ошибка отправки в MAX",
|
||||||
|
f"tg_message_id={task.tg_message_id}: {exc}",
|
||||||
|
)
|
||||||
|
await asyncio.sleep(self._settings.max_rate_limit_delay_sec)
|
||||||
|
|
||||||
|
async def _process(self, task: Tg2MaxTask) -> None:
|
||||||
|
client = await self._holder.wait_client()
|
||||||
|
attachments = await self._build_attachments(task.media)
|
||||||
|
logger.debug(
|
||||||
|
"max_worker_sending",
|
||||||
|
max_chat_id=task.max_chat_id,
|
||||||
|
attachment_count=len(attachments),
|
||||||
|
text_len=len(task.text),
|
||||||
|
reply_to=task.reply_to_max_message_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
sent = await client.send_message(
|
||||||
|
chat_id=task.max_chat_id,
|
||||||
|
text=task.text,
|
||||||
|
reply_to=task.reply_to_max_message_id,
|
||||||
|
attachments=attachments or None,
|
||||||
|
)
|
||||||
|
if sent is None:
|
||||||
|
raise RuntimeError("MAX API returned no message")
|
||||||
|
|
||||||
|
await self._storage.save_message_link(
|
||||||
|
task.max_chat_id,
|
||||||
|
sent.id,
|
||||||
|
task.tg_chat_id,
|
||||||
|
task.tg_thread_id,
|
||||||
|
task.tg_message_id,
|
||||||
|
)
|
||||||
|
await self._storage.update_sync_marker(task.max_chat_id, sent.id)
|
||||||
|
|
||||||
|
await self._queue.enqueue_max2tg(
|
||||||
|
SetReactionTask(
|
||||||
|
tg_chat_id=task.tg_chat_id,
|
||||||
|
tg_message_id=task.tg_message_id,
|
||||||
|
tg_thread_id=task.tg_thread_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"max_message_sent",
|
||||||
|
max_chat_id=task.max_chat_id,
|
||||||
|
max_message_id=sent.id,
|
||||||
|
tg_message_id=task.tg_message_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _build_attachments(self, media: list) -> list:
|
||||||
|
if not media:
|
||||||
|
return []
|
||||||
|
bot = await self._bot_holder.wait_bot()
|
||||||
|
result = []
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
for item in media:
|
||||||
|
kind = item.kind if hasattr(item, "kind") else item.get("kind", "")
|
||||||
|
if kind == "unsupported":
|
||||||
|
continue
|
||||||
|
path = await download_media_item(bot, session, item, self._tmp_dir)
|
||||||
|
if path is None:
|
||||||
|
logger.warning(
|
||||||
|
"max_worker_media_skipped",
|
||||||
|
kind=kind,
|
||||||
|
file_id=getattr(item, "file_id", None) or item.get("file_id"),
|
||||||
|
url=getattr(item, "url", None) or item.get("url"),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if "photo" in kind:
|
||||||
|
result.append(Photo(path=str(path)))
|
||||||
|
elif "video" in kind:
|
||||||
|
result.append(Video(path=str(path)))
|
||||||
|
else:
|
||||||
|
result.append(File(path=str(path)))
|
||||||
|
return result
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from app.models.tasks import MediaItem
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
_MAX_CDN_HEADERS = {
|
||||||
|
"User-Agent": (
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def tmp_dir(data_dir: str) -> Path:
|
||||||
|
path = Path(data_dir) / "tmp"
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _item_field(item: MediaItem | dict, name: str, default=None):
|
||||||
|
if isinstance(item, MediaItem):
|
||||||
|
return getattr(item, name, default)
|
||||||
|
return item.get(name, default)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_file_name(kind: str) -> str:
|
||||||
|
if "photo" in kind:
|
||||||
|
return f"{uuid4().hex}.jpg"
|
||||||
|
if "video" in kind:
|
||||||
|
return f"{uuid4().hex}.mp4"
|
||||||
|
return f"{uuid4().hex}.bin"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_file_name(item: MediaItem | dict) -> str:
|
||||||
|
kind = _item_field(item, "kind", "") or ""
|
||||||
|
file_name = _item_field(item, "file_name")
|
||||||
|
if file_name:
|
||||||
|
return file_name
|
||||||
|
return _default_file_name(kind)
|
||||||
|
|
||||||
|
|
||||||
|
def max_image_url_candidates(url: str) -> list[str]:
|
||||||
|
normalized = url.strip()
|
||||||
|
if not normalized:
|
||||||
|
return []
|
||||||
|
if normalized.startswith("//"):
|
||||||
|
normalized = f"https:{normalized}"
|
||||||
|
candidates = [normalized]
|
||||||
|
if "i.oneme.ru" in normalized and "size=" not in normalized:
|
||||||
|
sep = "&" if "?" in normalized else "?"
|
||||||
|
for size in (512, 256, 128):
|
||||||
|
sized = f"{normalized}{sep}size={size}"
|
||||||
|
if sized not in candidates:
|
||||||
|
candidates.append(sized)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
async def download_url_to_file(
|
||||||
|
session: aiohttp.ClientSession,
|
||||||
|
url: str,
|
||||||
|
dest: Path,
|
||||||
|
) -> bool:
|
||||||
|
return await _download_url(session, url, dest)
|
||||||
|
|
||||||
|
|
||||||
|
async def download_max_image_url(
|
||||||
|
session: aiohttp.ClientSession,
|
||||||
|
url: str,
|
||||||
|
dest: Path,
|
||||||
|
) -> bool:
|
||||||
|
for candidate in max_image_url_candidates(url):
|
||||||
|
if await _download_url(session, candidate, dest):
|
||||||
|
if dest.stat().st_size > 0:
|
||||||
|
return True
|
||||||
|
dest.unlink(missing_ok=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _download_url(
|
||||||
|
session: aiohttp.ClientSession,
|
||||||
|
url: str,
|
||||||
|
dest: Path,
|
||||||
|
) -> bool:
|
||||||
|
try:
|
||||||
|
async with session.get(url, headers=_MAX_CDN_HEADERS) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
logger.warning(
|
||||||
|
"media_download_http_error",
|
||||||
|
url=url,
|
||||||
|
status=resp.status,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
dest.write_bytes(await resp.read())
|
||||||
|
return True
|
||||||
|
except aiohttp.ClientError as exc:
|
||||||
|
logger.warning("media_download_failed", url=url, error=str(exc))
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_max_url(client, item: MediaItem | dict) -> str | None:
|
||||||
|
chat_id = _item_field(item, "max_chat_id")
|
||||||
|
message_id = _item_field(item, "max_message_id")
|
||||||
|
if chat_id is None or message_id is None:
|
||||||
|
return _item_field(item, "url")
|
||||||
|
|
||||||
|
file_id = _item_field(item, "max_file_id")
|
||||||
|
if file_id is not None:
|
||||||
|
file_req = await client.get_file_by_id(chat_id, message_id, file_id)
|
||||||
|
return file_req.url if file_req else None
|
||||||
|
|
||||||
|
video_id = _item_field(item, "max_video_id")
|
||||||
|
if video_id is not None:
|
||||||
|
video = await client.get_video_by_id(chat_id, message_id, video_id)
|
||||||
|
return video.url if video else None
|
||||||
|
|
||||||
|
return _item_field(item, "url")
|
||||||
|
|
||||||
|
|
||||||
|
async def download_max_media(
|
||||||
|
client,
|
||||||
|
items: list[dict],
|
||||||
|
dest_dir: Path,
|
||||||
|
) -> list[dict]:
|
||||||
|
if not items:
|
||||||
|
return []
|
||||||
|
|
||||||
|
downloaded: list[dict] = []
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
for item in items:
|
||||||
|
url = await _resolve_max_url(client, item)
|
||||||
|
if not url:
|
||||||
|
logger.warning(
|
||||||
|
"max_media_url_unresolved",
|
||||||
|
kind=item.get("kind"),
|
||||||
|
max_file_id=item.get("max_file_id"),
|
||||||
|
max_video_id=item.get("max_video_id"),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
file_name = resolve_file_name(item)
|
||||||
|
dest = dest_dir / f"{uuid4().hex}_{file_name}"
|
||||||
|
if not await _download_url(session, url, dest):
|
||||||
|
logger.warning(
|
||||||
|
"max_media_download_skipped",
|
||||||
|
kind=item.get("kind"),
|
||||||
|
max_file_id=item.get("max_file_id"),
|
||||||
|
max_video_id=item.get("max_video_id"),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
stored = dict(item)
|
||||||
|
stored["local_path"] = str(dest)
|
||||||
|
stored.pop("url", None)
|
||||||
|
downloaded.append(stored)
|
||||||
|
|
||||||
|
return downloaded
|
||||||
|
|
||||||
|
|
||||||
|
async def download_media_item(
|
||||||
|
bot,
|
||||||
|
session: aiohttp.ClientSession,
|
||||||
|
item: MediaItem | dict,
|
||||||
|
dest_dir: Path,
|
||||||
|
*,
|
||||||
|
max_client=None,
|
||||||
|
) -> Path | None:
|
||||||
|
local_path = _item_field(item, "local_path")
|
||||||
|
if local_path:
|
||||||
|
path = Path(local_path)
|
||||||
|
if path.is_file():
|
||||||
|
return path
|
||||||
|
logger.warning("media_local_path_missing", path=local_path)
|
||||||
|
|
||||||
|
file_id = _item_field(item, "file_id")
|
||||||
|
file_name = resolve_file_name(item)
|
||||||
|
|
||||||
|
if file_id:
|
||||||
|
tg_file = await bot.get_file(file_id)
|
||||||
|
if tg_file.file_path is None:
|
||||||
|
return None
|
||||||
|
dest = dest_dir / file_name
|
||||||
|
await bot.download_file(tg_file.file_path, destination=dest)
|
||||||
|
return dest
|
||||||
|
|
||||||
|
url = _item_field(item, "url")
|
||||||
|
if url is None and max_client is not None:
|
||||||
|
url = await _resolve_max_url(max_client, item)
|
||||||
|
|
||||||
|
if url:
|
||||||
|
dest = dest_dir / file_name
|
||||||
|
if await _download_url(session, url, dest):
|
||||||
|
return dest
|
||||||
|
return None
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_paths(paths: list[Path]) -> None:
|
||||||
|
for path in paths:
|
||||||
|
try:
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
logger.warning("media_temp_cleanup_failed", path=str(path))
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class ChatMapping:
|
||||||
|
max_chat_id: int
|
||||||
|
tg_chat_id: int
|
||||||
|
tg_thread_id: int
|
||||||
|
display_name: str
|
||||||
|
is_dm: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class MaxChatMeta:
|
||||||
|
topic_title: str
|
||||||
|
chat_name: str
|
||||||
|
is_dm: bool
|
||||||
|
icon_url: str | None
|
||||||
|
participants_count: int
|
||||||
|
link: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class MaxIncomingMessage:
|
||||||
|
max_chat_id: int
|
||||||
|
max_message_id: int
|
||||||
|
text: str
|
||||||
|
sender_id: int | None
|
||||||
|
sender_name: str | None
|
||||||
|
is_dm: bool
|
||||||
|
chat_title: str
|
||||||
|
chat_name: str
|
||||||
|
chat_icon_url: str | None
|
||||||
|
chat_icon_local_path: str | None
|
||||||
|
participants_count: int
|
||||||
|
max_chat_link: str | None
|
||||||
|
reply_to_max_message_id: int | None
|
||||||
|
media: list[dict]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class TgIncomingMessage:
|
||||||
|
tg_chat_id: int
|
||||||
|
tg_message_id: int
|
||||||
|
tg_thread_id: int | None
|
||||||
|
text: str
|
||||||
|
author_name: str
|
||||||
|
author_username: str | None
|
||||||
|
is_bot: bool
|
||||||
|
reply_to_tg_message_id: int | None
|
||||||
|
media: list[dict]
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class TaskKind(str, Enum):
|
||||||
|
SEND_TO_TG = "send_to_tg"
|
||||||
|
SET_REACTION = "set_reaction"
|
||||||
|
NOTIFY_FALLBACK = "notify_fallback"
|
||||||
|
SEND_TO_MAX = "send_to_max"
|
||||||
|
|
||||||
|
|
||||||
|
class MediaItem(BaseModel):
|
||||||
|
kind: str
|
||||||
|
url: str | None = None
|
||||||
|
file_id: str | None = None
|
||||||
|
file_name: str | None = None
|
||||||
|
mime_type: str | None = None
|
||||||
|
caption: str | None = None
|
||||||
|
local_path: str | None = None
|
||||||
|
max_chat_id: int | None = None
|
||||||
|
max_message_id: int | None = None
|
||||||
|
max_file_id: int | None = None
|
||||||
|
max_video_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Max2TgTask(BaseModel):
|
||||||
|
kind: TaskKind = TaskKind.SEND_TO_TG
|
||||||
|
max_chat_id: int
|
||||||
|
max_message_id: int
|
||||||
|
text: str
|
||||||
|
sender_name: str | None = None
|
||||||
|
is_dm: bool = False
|
||||||
|
chat_title: str
|
||||||
|
chat_name: str = ""
|
||||||
|
chat_icon_url: str | None = None
|
||||||
|
chat_icon_local_path: str | None = None
|
||||||
|
participants_count: int = 0
|
||||||
|
max_chat_link: str | None = None
|
||||||
|
needs_new_topic: bool = False
|
||||||
|
reply_to_tg_message_id: int | None = None
|
||||||
|
media: list[MediaItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class SetReactionTask(BaseModel):
|
||||||
|
kind: TaskKind = TaskKind.SET_REACTION
|
||||||
|
tg_chat_id: int
|
||||||
|
tg_message_id: int
|
||||||
|
tg_thread_id: int | None = None
|
||||||
|
emoji: str = "🦄"
|
||||||
|
|
||||||
|
|
||||||
|
class NotifyFallbackTask(BaseModel):
|
||||||
|
kind: TaskKind = TaskKind.NOTIFY_FALLBACK
|
||||||
|
title: str
|
||||||
|
details: str
|
||||||
|
|
||||||
|
|
||||||
|
class Tg2MaxTask(BaseModel):
|
||||||
|
kind: TaskKind = TaskKind.SEND_TO_MAX
|
||||||
|
max_chat_id: int
|
||||||
|
text: str
|
||||||
|
tg_chat_id: int
|
||||||
|
tg_message_id: int
|
||||||
|
tg_thread_id: int | None = None
|
||||||
|
reply_to_max_message_id: int | None = None
|
||||||
|
media: list[MediaItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_queue_payload(data: str) -> Max2TgTask | SetReactionTask | NotifyFallbackTask | Tg2MaxTask:
|
||||||
|
raw: dict[str, Any] = __import__("json").loads(data)
|
||||||
|
kind = TaskKind(raw.get("kind", TaskKind.SEND_TO_TG))
|
||||||
|
if kind == TaskKind.SEND_TO_TG:
|
||||||
|
return Max2TgTask.model_validate(raw)
|
||||||
|
if kind == TaskKind.SET_REACTION:
|
||||||
|
return SetReactionTask.model_validate(raw)
|
||||||
|
if kind == TaskKind.NOTIFY_FALLBACK:
|
||||||
|
return NotifyFallbackTask.model_validate(raw)
|
||||||
|
if kind == TaskKind.SEND_TO_MAX:
|
||||||
|
return Tg2MaxTask.model_validate(raw)
|
||||||
|
raise ValueError(f"Unknown task kind: {kind}")
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from app.models.tasks import Max2TgTask, NotifyFallbackTask, SetReactionTask, Tg2MaxTask
|
||||||
|
|
||||||
|
|
||||||
|
class QueuePort(Protocol):
|
||||||
|
async def connect(self) -> None: ...
|
||||||
|
|
||||||
|
async def close(self) -> None: ...
|
||||||
|
|
||||||
|
async def enqueue_max2tg(self, task: Max2TgTask | SetReactionTask | NotifyFallbackTask) -> None: ...
|
||||||
|
|
||||||
|
async def dequeue_max2tg(self, timeout: int = 0) -> Max2TgTask | SetReactionTask | NotifyFallbackTask | None: ...
|
||||||
|
|
||||||
|
async def enqueue_tg2max(self, task: Tg2MaxTask) -> None: ...
|
||||||
|
|
||||||
|
async def dequeue_tg2max(self, timeout: int = 0) -> Tg2MaxTask | None: ...
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
import redis.asyncio as redis
|
||||||
|
import structlog
|
||||||
|
from redis.exceptions import TimeoutError as RedisTimeoutError
|
||||||
|
|
||||||
|
from app.models.tasks import (
|
||||||
|
Max2TgTask,
|
||||||
|
NotifyFallbackTask,
|
||||||
|
SetReactionTask,
|
||||||
|
Tg2MaxTask,
|
||||||
|
parse_queue_payload,
|
||||||
|
)
|
||||||
|
from app.queue.protocols import QueuePort
|
||||||
|
|
||||||
|
MAX2TG_QUEUE = "max2tg_queue"
|
||||||
|
TG2MAX_QUEUE = "tg2max_queue"
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RedisQueue(QueuePort):
|
||||||
|
def __init__(self, redis_url: str) -> None:
|
||||||
|
self._redis_url = redis_url
|
||||||
|
self._client: redis.Redis | None = None
|
||||||
|
|
||||||
|
async def _blpop(self, queue: str, timeout: int) -> tuple[str, str] | None:
|
||||||
|
assert self._client is not None
|
||||||
|
try:
|
||||||
|
return await self._client.blpop(queue, timeout=timeout or None)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except RedisTimeoutError as exc:
|
||||||
|
task = asyncio.current_task()
|
||||||
|
if task is not None and task.cancelling():
|
||||||
|
raise asyncio.CancelledError() from exc
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def connect(self) -> None:
|
||||||
|
self._client = redis.from_url(self._redis_url, decode_responses=True)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
if self._client is not None:
|
||||||
|
await self._client.aclose()
|
||||||
|
|
||||||
|
async def enqueue_max2tg(
|
||||||
|
self, task: Max2TgTask | SetReactionTask | NotifyFallbackTask
|
||||||
|
) -> None:
|
||||||
|
assert self._client is not None
|
||||||
|
await self._client.rpush(MAX2TG_QUEUE, task.model_dump_json())
|
||||||
|
logger.debug("queue_enqueued", queue=MAX2TG_QUEUE, kind=task.kind)
|
||||||
|
|
||||||
|
async def dequeue_max2tg(
|
||||||
|
self, timeout: int = 0
|
||||||
|
) -> Max2TgTask | SetReactionTask | NotifyFallbackTask | None:
|
||||||
|
assert self._client is not None
|
||||||
|
result = await self._blpop(MAX2TG_QUEUE, timeout=timeout)
|
||||||
|
if result is None:
|
||||||
|
return None
|
||||||
|
_, payload = result
|
||||||
|
task = parse_queue_payload(payload)
|
||||||
|
logger.debug("queue_dequeued", queue=MAX2TG_QUEUE, kind=task.kind)
|
||||||
|
return task
|
||||||
|
|
||||||
|
async def enqueue_tg2max(self, task: Tg2MaxTask) -> None:
|
||||||
|
assert self._client is not None
|
||||||
|
await self._client.rpush(TG2MAX_QUEUE, task.model_dump_json())
|
||||||
|
logger.debug(
|
||||||
|
"queue_enqueued",
|
||||||
|
queue=TG2MAX_QUEUE,
|
||||||
|
kind=task.kind,
|
||||||
|
tg_message_id=task.tg_message_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def dequeue_tg2max(self, timeout: int = 0) -> Tg2MaxTask | None:
|
||||||
|
assert self._client is not None
|
||||||
|
result = await self._blpop(TG2MAX_QUEUE, timeout=timeout)
|
||||||
|
if result is None:
|
||||||
|
return None
|
||||||
|
_, payload = result
|
||||||
|
data = json.loads(payload)
|
||||||
|
task = Tg2MaxTask.model_validate(data)
|
||||||
|
logger.debug(
|
||||||
|
"queue_dequeued",
|
||||||
|
queue=TG2MAX_QUEUE,
|
||||||
|
tg_message_id=task.tg_message_id,
|
||||||
|
)
|
||||||
|
return task
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import structlog
|
||||||
|
|
||||||
|
from app.models.domain import MaxIncomingMessage, TgIncomingMessage
|
||||||
|
from app.models.tasks import Max2TgTask, MediaItem, NotifyFallbackTask, Tg2MaxTask
|
||||||
|
from app.queue.protocols import QueuePort
|
||||||
|
from app.storage.protocols import StoragePort
|
||||||
|
from app.topic_locks import TopicLockRegistry
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MessageRouter:
|
||||||
|
"""Маршрутизация сообщений между MAX и Telegram через очереди и хранилище."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
storage: StoragePort,
|
||||||
|
queue: QueuePort,
|
||||||
|
forum_channel_id: int,
|
||||||
|
topic_locks: TopicLockRegistry,
|
||||||
|
) -> None:
|
||||||
|
self._storage = storage
|
||||||
|
self._queue = queue
|
||||||
|
self._forum_channel_id = forum_channel_id
|
||||||
|
self._topic_locks = topic_locks
|
||||||
|
|
||||||
|
async def handle_max_message(self, message: MaxIncomingMessage) -> bool:
|
||||||
|
logger.debug(
|
||||||
|
"router_max_message_received",
|
||||||
|
max_chat_id=message.max_chat_id,
|
||||||
|
message_id=message.max_message_id,
|
||||||
|
text_len=len(message.text),
|
||||||
|
media_count=len(message.media),
|
||||||
|
)
|
||||||
|
if message.max_chat_id is None or message.max_message_id is None:
|
||||||
|
logger.warning(
|
||||||
|
"router_max_message_rejected",
|
||||||
|
reason="missing_ids",
|
||||||
|
max_chat_id=message.max_chat_id,
|
||||||
|
message_id=message.max_message_id,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async with self._topic_locks.lock(message.max_chat_id):
|
||||||
|
mapping = await self._storage.get_mapping_by_max_chat(message.max_chat_id)
|
||||||
|
needs_new_topic = mapping is None
|
||||||
|
if mapping is not None:
|
||||||
|
logger.debug(
|
||||||
|
"router_mapping_found",
|
||||||
|
max_chat_id=message.max_chat_id,
|
||||||
|
thread_id=mapping.tg_thread_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
all_mappings = await self._storage.list_mappings()
|
||||||
|
logger.info(
|
||||||
|
"router_no_mapping",
|
||||||
|
max_chat_id=message.max_chat_id,
|
||||||
|
reason="chat_mapping_not_found_in_storage",
|
||||||
|
will_create_topic=True,
|
||||||
|
total_mappings_in_storage=len(all_mappings),
|
||||||
|
known_max_chat_ids=[m.max_chat_id for m in all_mappings] if all_mappings else [],
|
||||||
|
hint=(
|
||||||
|
"empty_db_after_container_recreate"
|
||||||
|
if not all_mappings
|
||||||
|
else "mapping_missing_for_this_chat_only"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
reply_to_tg: int | None = None
|
||||||
|
if message.reply_to_max_message_id is not None:
|
||||||
|
link = await self._storage.get_tg_message_by_max(
|
||||||
|
message.max_chat_id, message.reply_to_max_message_id
|
||||||
|
)
|
||||||
|
if link:
|
||||||
|
reply_to_tg = link[2]
|
||||||
|
logger.debug(
|
||||||
|
"router_reply_mapped",
|
||||||
|
max_reply_to=message.reply_to_max_message_id,
|
||||||
|
tg_reply_to=reply_to_tg,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"router_reply_not_found",
|
||||||
|
max_chat_id=message.max_chat_id,
|
||||||
|
max_reply_to=message.reply_to_max_message_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
media = [MediaItem.model_validate(item) for item in message.media]
|
||||||
|
|
||||||
|
task = Max2TgTask(
|
||||||
|
max_chat_id=message.max_chat_id,
|
||||||
|
max_message_id=message.max_message_id,
|
||||||
|
text=message.text,
|
||||||
|
sender_name=message.sender_name,
|
||||||
|
is_dm=message.is_dm,
|
||||||
|
chat_title=message.chat_title,
|
||||||
|
chat_name=message.chat_name,
|
||||||
|
chat_icon_url=message.chat_icon_url,
|
||||||
|
chat_icon_local_path=message.chat_icon_local_path,
|
||||||
|
participants_count=message.participants_count,
|
||||||
|
max_chat_link=message.max_chat_link,
|
||||||
|
needs_new_topic=needs_new_topic,
|
||||||
|
reply_to_tg_message_id=reply_to_tg,
|
||||||
|
media=media,
|
||||||
|
)
|
||||||
|
await self._queue.enqueue_max2tg(task)
|
||||||
|
logger.info(
|
||||||
|
"max_message_enqueued",
|
||||||
|
max_chat_id=message.max_chat_id,
|
||||||
|
message_id=message.max_message_id,
|
||||||
|
needs_new_topic=needs_new_topic,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def handle_tg_message(self, message: TgIncomingMessage) -> bool:
|
||||||
|
logger.debug(
|
||||||
|
"router_tg_message_received",
|
||||||
|
tg_chat_id=message.tg_chat_id,
|
||||||
|
tg_message_id=message.tg_message_id,
|
||||||
|
thread_id=message.tg_thread_id,
|
||||||
|
text_len=len(message.text),
|
||||||
|
)
|
||||||
|
if message.is_bot:
|
||||||
|
logger.debug("router_tg_message_skipped", reason="is_bot")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if message.tg_chat_id != self._forum_channel_id:
|
||||||
|
logger.debug(
|
||||||
|
"router_tg_message_skipped",
|
||||||
|
reason="wrong_chat",
|
||||||
|
tg_chat_id=message.tg_chat_id,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if message.tg_thread_id is None:
|
||||||
|
logger.debug("router_tg_message_skipped", reason="no_thread_id")
|
||||||
|
return False
|
||||||
|
|
||||||
|
mapping = await self._storage.get_mapping_by_tg_thread(
|
||||||
|
message.tg_chat_id, message.tg_thread_id
|
||||||
|
)
|
||||||
|
if mapping is None:
|
||||||
|
logger.debug(
|
||||||
|
"tg_message_no_mapping",
|
||||||
|
tg_chat_id=message.tg_chat_id,
|
||||||
|
thread_id=message.tg_thread_id,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
reply_to_max: int | None = None
|
||||||
|
if message.reply_to_tg_message_id is not None:
|
||||||
|
link = await self._storage.get_max_message_by_tg(
|
||||||
|
message.tg_chat_id, message.reply_to_tg_message_id
|
||||||
|
)
|
||||||
|
if link:
|
||||||
|
reply_to_max = link[1]
|
||||||
|
|
||||||
|
media = [MediaItem.model_validate(item) for item in message.media]
|
||||||
|
|
||||||
|
task = Tg2MaxTask(
|
||||||
|
max_chat_id=mapping.max_chat_id,
|
||||||
|
text=message.text,
|
||||||
|
tg_chat_id=message.tg_chat_id,
|
||||||
|
tg_message_id=message.tg_message_id,
|
||||||
|
tg_thread_id=message.tg_thread_id,
|
||||||
|
reply_to_max_message_id=reply_to_max,
|
||||||
|
media=media,
|
||||||
|
)
|
||||||
|
await self._queue.enqueue_tg2max(task)
|
||||||
|
logger.info(
|
||||||
|
"tg_message_enqueued",
|
||||||
|
tg_message_id=message.tg_message_id,
|
||||||
|
max_chat_id=mapping.max_chat_id,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def notify_error(self, title: str, details: str) -> None:
|
||||||
|
logger.error("router_notify_error", title=title, details=details)
|
||||||
|
await self._queue.enqueue_max2tg(
|
||||||
|
NotifyFallbackTask(title=title, details=details)
|
||||||
|
)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
from sqlalchemy import event
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
|
||||||
|
def create_engine(database_url: str) -> AsyncEngine:
|
||||||
|
engine = create_async_engine(database_url, echo=False)
|
||||||
|
|
||||||
|
@event.listens_for(engine.sync_engine, "connect")
|
||||||
|
def _set_sqlite_pragma(dbapi_conn, _record) -> None:
|
||||||
|
cursor = dbapi_conn.cursor()
|
||||||
|
cursor.execute("PRAGMA journal_mode=WAL")
|
||||||
|
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
def create_session_factory(engine: AsyncEngine) -> async_sessionmaker:
|
||||||
|
return async_sessionmaker(engine, expire_on_commit=False)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from app.models.domain import ChatMapping
|
||||||
|
|
||||||
|
|
||||||
|
class StoragePort(Protocol):
|
||||||
|
async def init(self) -> None: ...
|
||||||
|
|
||||||
|
async def get_mapping_by_max_chat(self, max_chat_id: int) -> ChatMapping | None: ...
|
||||||
|
|
||||||
|
async def get_mapping_by_tg_thread(
|
||||||
|
self, tg_chat_id: int, tg_thread_id: int
|
||||||
|
) -> ChatMapping | None: ...
|
||||||
|
|
||||||
|
async def save_mapping(self, mapping: ChatMapping) -> None: ...
|
||||||
|
|
||||||
|
async def list_mappings(self) -> list[ChatMapping]: ...
|
||||||
|
|
||||||
|
async def get_sync_marker(self, max_chat_id: int) -> int | None: ...
|
||||||
|
|
||||||
|
async def update_sync_marker(self, max_chat_id: int, message_id: int) -> None: ...
|
||||||
|
|
||||||
|
async def save_message_link(
|
||||||
|
self,
|
||||||
|
max_chat_id: int,
|
||||||
|
max_message_id: int,
|
||||||
|
tg_chat_id: int,
|
||||||
|
tg_thread_id: int,
|
||||||
|
tg_message_id: int,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
async def get_tg_message_by_max(
|
||||||
|
self, max_chat_id: int, max_message_id: int
|
||||||
|
) -> tuple[int, int, int] | None: ...
|
||||||
|
|
||||||
|
async def get_max_message_by_tg(
|
||||||
|
self, tg_chat_id: int, tg_message_id: int
|
||||||
|
) -> tuple[int, int] | None: ...
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, Boolean, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
|
||||||
|
from app.models.domain import ChatMapping
|
||||||
|
from app.storage.protocols import StoragePort
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ChatMappingRow(Base):
|
||||||
|
__tablename__ = "chat_mappings"
|
||||||
|
|
||||||
|
max_chat_id = Column(Integer, primary_key=True)
|
||||||
|
tg_chat_id = Column(Integer, nullable=False)
|
||||||
|
tg_thread_id = Column(Integer, nullable=False)
|
||||||
|
display_name = Column(String, nullable=False)
|
||||||
|
is_dm = Column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
|
||||||
|
class SyncMarkerRow(Base):
|
||||||
|
__tablename__ = "sync_markers"
|
||||||
|
|
||||||
|
max_chat_id = Column(Integer, primary_key=True)
|
||||||
|
last_processed_message_id = Column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
|
||||||
|
class MessageLinkRow(Base):
|
||||||
|
__tablename__ = "message_links"
|
||||||
|
|
||||||
|
max_chat_id = Column(Integer, primary_key=True)
|
||||||
|
max_message_id = Column(Integer, primary_key=True)
|
||||||
|
tg_chat_id = Column(Integer, nullable=False)
|
||||||
|
tg_thread_id = Column(Integer, nullable=False)
|
||||||
|
tg_message_id = Column(Integer, nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class SqliteStorage(StoragePort):
|
||||||
|
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||||
|
self._session_factory = session_factory
|
||||||
|
|
||||||
|
async def init(self) -> None:
|
||||||
|
async with self._session_factory() as session:
|
||||||
|
async with session.begin():
|
||||||
|
conn = await session.connection()
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
|
||||||
|
async def get_mapping_by_max_chat(self, max_chat_id: int) -> ChatMapping | None:
|
||||||
|
async with self._session_factory() as session:
|
||||||
|
row = await session.get(ChatMappingRow, max_chat_id)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return self._to_mapping(row)
|
||||||
|
|
||||||
|
async def get_mapping_by_tg_thread(
|
||||||
|
self, tg_chat_id: int, tg_thread_id: int
|
||||||
|
) -> ChatMapping | None:
|
||||||
|
async with self._session_factory() as session:
|
||||||
|
stmt = select(ChatMappingRow).where(
|
||||||
|
ChatMappingRow.tg_chat_id == tg_chat_id,
|
||||||
|
ChatMappingRow.tg_thread_id == tg_thread_id,
|
||||||
|
)
|
||||||
|
row = (await session.execute(stmt)).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return self._to_mapping(row)
|
||||||
|
|
||||||
|
async def save_mapping(self, mapping: ChatMapping) -> None:
|
||||||
|
async with self._session_factory() as session:
|
||||||
|
async with session.begin():
|
||||||
|
row = await session.get(ChatMappingRow, mapping.max_chat_id)
|
||||||
|
if row is None:
|
||||||
|
session.add(
|
||||||
|
ChatMappingRow(
|
||||||
|
max_chat_id=mapping.max_chat_id,
|
||||||
|
tg_chat_id=mapping.tg_chat_id,
|
||||||
|
tg_thread_id=mapping.tg_thread_id,
|
||||||
|
display_name=mapping.display_name,
|
||||||
|
is_dm=mapping.is_dm,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
row.tg_chat_id = mapping.tg_chat_id
|
||||||
|
row.tg_thread_id = mapping.tg_thread_id
|
||||||
|
row.display_name = mapping.display_name
|
||||||
|
row.is_dm = mapping.is_dm
|
||||||
|
|
||||||
|
async def list_mappings(self) -> list[ChatMapping]:
|
||||||
|
async with self._session_factory() as session:
|
||||||
|
rows = (await session.execute(select(ChatMappingRow))).scalars().all()
|
||||||
|
return [self._to_mapping(row) for row in rows]
|
||||||
|
|
||||||
|
async def get_sync_marker(self, max_chat_id: int) -> int | None:
|
||||||
|
async with self._session_factory() as session:
|
||||||
|
row = await session.get(SyncMarkerRow, max_chat_id)
|
||||||
|
return row.last_processed_message_id if row else None
|
||||||
|
|
||||||
|
async def update_sync_marker(self, max_chat_id: int, message_id: int) -> None:
|
||||||
|
async with self._session_factory() as session:
|
||||||
|
async with session.begin():
|
||||||
|
await session.merge(
|
||||||
|
SyncMarkerRow(max_chat_id=max_chat_id, last_processed_message_id=message_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def save_message_link(
|
||||||
|
self,
|
||||||
|
max_chat_id: int,
|
||||||
|
max_message_id: int,
|
||||||
|
tg_chat_id: int,
|
||||||
|
tg_thread_id: int,
|
||||||
|
tg_message_id: int,
|
||||||
|
) -> None:
|
||||||
|
async with self._session_factory() as session:
|
||||||
|
async with session.begin():
|
||||||
|
await session.merge(
|
||||||
|
MessageLinkRow(
|
||||||
|
max_chat_id=max_chat_id,
|
||||||
|
max_message_id=max_message_id,
|
||||||
|
tg_chat_id=tg_chat_id,
|
||||||
|
tg_thread_id=tg_thread_id,
|
||||||
|
tg_message_id=tg_message_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_tg_message_by_max(
|
||||||
|
self, max_chat_id: int, max_message_id: int
|
||||||
|
) -> tuple[int, int, int] | None:
|
||||||
|
async with self._session_factory() as session:
|
||||||
|
row = await session.get(MessageLinkRow, (max_chat_id, max_message_id))
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return row.tg_chat_id, row.tg_thread_id, row.tg_message_id
|
||||||
|
|
||||||
|
async def get_max_message_by_tg(
|
||||||
|
self, tg_chat_id: int, tg_message_id: int
|
||||||
|
) -> tuple[int, int] | None:
|
||||||
|
async with self._session_factory() as session:
|
||||||
|
stmt = select(MessageLinkRow).where(
|
||||||
|
MessageLinkRow.tg_chat_id == tg_chat_id,
|
||||||
|
MessageLinkRow.tg_message_id == tg_message_id,
|
||||||
|
)
|
||||||
|
row = (await session.execute(stmt)).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return row.max_chat_id, row.max_message_id
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_mapping(row: ChatMappingRow) -> ChatMapping:
|
||||||
|
return ChatMapping(
|
||||||
|
max_chat_id=row.max_chat_id,
|
||||||
|
tg_chat_id=row.tg_chat_id,
|
||||||
|
tg_thread_id=row.tg_thread_id,
|
||||||
|
display_name=row.display_name,
|
||||||
|
is_dm=row.is_dm,
|
||||||
|
)
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
from aiogram import Dispatcher, F
|
||||||
|
from aiogram.enums import ChatType
|
||||||
|
from aiogram.filters import Command, CommandObject
|
||||||
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
from pymax.types.domain.enums import ChatType as MaxChatType
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
from app.max_layer.client_holder import MaxClientHolder
|
||||||
|
from app.max_layer.formatter import resolve_sender_name
|
||||||
|
from app.storage.protocols import StoragePort
|
||||||
|
|
||||||
|
|
||||||
|
def register_admin_handlers(
|
||||||
|
dp: Dispatcher,
|
||||||
|
settings: Settings,
|
||||||
|
storage: StoragePort,
|
||||||
|
max_holder: MaxClientHolder,
|
||||||
|
) -> None:
|
||||||
|
fallback = (
|
||||||
|
F.chat.type == ChatType.PRIVATE,
|
||||||
|
F.from_user.id == settings.fallback_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@dp.message(Command("start"), *fallback)
|
||||||
|
async def cmd_start(message: Message) -> None:
|
||||||
|
connected = max_holder.ready.is_set()
|
||||||
|
status = "подключён" if connected else "ожидание подключения"
|
||||||
|
await message.answer(
|
||||||
|
"max2telegram bridge\n\n"
|
||||||
|
f"Статус MAX: {status}\n\n"
|
||||||
|
"Команды: /help, /list, /join, /leave, /last_messages"
|
||||||
|
)
|
||||||
|
|
||||||
|
@dp.message(Command("help"), *fallback)
|
||||||
|
async def cmd_help(message: Message) -> None:
|
||||||
|
await message.answer(
|
||||||
|
"/start — статус\n"
|
||||||
|
"/help — справка\n"
|
||||||
|
"/list — активные маппинги\n"
|
||||||
|
"/join <ссылка> — вступить в MAX-группу\n"
|
||||||
|
"/leave <id или название> — выйти из MAX-чата\n"
|
||||||
|
"/last_messages <id или название> — последние 10 сообщений"
|
||||||
|
)
|
||||||
|
|
||||||
|
@dp.message(Command("list"), *fallback)
|
||||||
|
async def cmd_list(message: Message) -> None:
|
||||||
|
mappings = await storage.list_mappings()
|
||||||
|
if not mappings:
|
||||||
|
await message.answer("Маппинги не найдены.")
|
||||||
|
return
|
||||||
|
lines = [
|
||||||
|
f"• {m.display_name}\n MAX: {m.max_chat_id} → TG thread: {m.tg_thread_id}"
|
||||||
|
for m in mappings
|
||||||
|
]
|
||||||
|
await message.answer("Активные маппинги:\n\n" + "\n".join(lines))
|
||||||
|
|
||||||
|
@dp.message(Command("join"), *fallback)
|
||||||
|
async def cmd_join(message: Message, command: CommandObject) -> None:
|
||||||
|
if not command.args:
|
||||||
|
await message.answer("Использование: /join <ссылка>")
|
||||||
|
return
|
||||||
|
client = await max_holder.wait_client()
|
||||||
|
try:
|
||||||
|
chat = await client.join_group(command.args.strip())
|
||||||
|
await message.answer(f"Вступили в «{chat.title}» (id={chat.id})")
|
||||||
|
except Exception as exc:
|
||||||
|
await message.answer(f"Ошибка: {exc}")
|
||||||
|
|
||||||
|
@dp.message(Command("leave"), *fallback)
|
||||||
|
async def cmd_leave(message: Message, command: CommandObject) -> None:
|
||||||
|
if not command.args:
|
||||||
|
await message.answer("Использование: /leave <id или название>")
|
||||||
|
return
|
||||||
|
client = await max_holder.wait_client()
|
||||||
|
target = command.args.strip()
|
||||||
|
chat = await _resolve_chat(client, target)
|
||||||
|
if chat is None:
|
||||||
|
await message.answer("Чат не найден.")
|
||||||
|
return
|
||||||
|
if chat.type == MaxChatType.CHANNEL:
|
||||||
|
await client.leave_channel(chat.id)
|
||||||
|
else:
|
||||||
|
await client.leave_group(chat.id)
|
||||||
|
await message.answer(f"Вышли из «{chat.title}» (id={chat.id})")
|
||||||
|
|
||||||
|
@dp.message(Command("last_messages"), *fallback)
|
||||||
|
async def cmd_last_messages(message: Message, command: CommandObject) -> None:
|
||||||
|
if not command.args:
|
||||||
|
await message.answer("Использование: /last_messages <id или название>")
|
||||||
|
return
|
||||||
|
client = await max_holder.wait_client()
|
||||||
|
target = command.args.strip()
|
||||||
|
chat = await _resolve_chat(client, target)
|
||||||
|
if chat is None:
|
||||||
|
await message.answer("Чат не найден.")
|
||||||
|
return
|
||||||
|
history = await client.fetch_history(chat_id=chat.id, backward=10)
|
||||||
|
if not history:
|
||||||
|
await message.answer("Сообщений нет.")
|
||||||
|
return
|
||||||
|
lines = []
|
||||||
|
for msg in reversed(history):
|
||||||
|
sender = f"User {msg.sender}" if msg.sender else "?"
|
||||||
|
if msg.sender:
|
||||||
|
try:
|
||||||
|
user = await client.get_user(msg.sender)
|
||||||
|
sender = resolve_sender_name(user)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
text = (msg.text or "")[:200]
|
||||||
|
lines.append(f"[{msg.id}] {sender}: {text}")
|
||||||
|
await message.answer("\n".join(lines) or "Пусто.")
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_chat(client, target: str):
|
||||||
|
if target.isdigit():
|
||||||
|
try:
|
||||||
|
return await client.get_chat(int(target))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if client.chats:
|
||||||
|
for chat in client.chats:
|
||||||
|
if chat.title and target.lower() in chat.title.lower():
|
||||||
|
return chat
|
||||||
|
return None
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import asyncio
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from aiogram import Bot
|
||||||
|
|
||||||
|
|
||||||
|
class BotHolder:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.bot: Bot | None = None
|
||||||
|
self.ready = asyncio.Event()
|
||||||
|
|
||||||
|
def set_bot(self, bot: "Bot") -> None:
|
||||||
|
self.bot = bot
|
||||||
|
self.ready.set()
|
||||||
|
|
||||||
|
async def wait_bot(self) -> "Bot":
|
||||||
|
await self.ready.wait()
|
||||||
|
assert self.bot is not None
|
||||||
|
return self.bot
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
from app.models.domain import TgIncomingMessage
|
||||||
|
|
||||||
|
TG_CAPTION_MAX_LEN = 1024
|
||||||
|
TG_MESSAGE_MAX_LEN = 4096
|
||||||
|
|
||||||
|
|
||||||
|
def split_text_for_media_caption(text: str) -> tuple[str | None, str | None]:
|
||||||
|
if not text:
|
||||||
|
return None, None
|
||||||
|
if len(text) <= TG_CAPTION_MAX_LEN:
|
||||||
|
return text, None
|
||||||
|
return None, text
|
||||||
|
|
||||||
|
|
||||||
|
def format_tg_author(message: Message) -> tuple[str, str | None]:
|
||||||
|
user = message.from_user
|
||||||
|
if user is None:
|
||||||
|
return "Unknown", None
|
||||||
|
parts = [user.first_name or "", user.last_name or ""]
|
||||||
|
name = " ".join(p for p in parts if p).strip() or (user.username or "User")
|
||||||
|
return name, user.username
|
||||||
|
|
||||||
|
|
||||||
|
def format_topic_pin_text(
|
||||||
|
*,
|
||||||
|
chat_name: str,
|
||||||
|
is_dm: bool,
|
||||||
|
max_chat_id: int,
|
||||||
|
participants_count: int,
|
||||||
|
max_chat_link: str | None,
|
||||||
|
) -> str:
|
||||||
|
chat_type = "PRIVATE" if is_dm else "CHAT"
|
||||||
|
lines = [
|
||||||
|
f"{chat_name} · Тип: {chat_type}",
|
||||||
|
f"id: {max_chat_id}",
|
||||||
|
f"Участников: {participants_count}",
|
||||||
|
]
|
||||||
|
if max_chat_link:
|
||||||
|
lines.extend(["", f"🔗 {max_chat_link}"])
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def format_tg_to_max_text(author_name: str, username: str | None, text: str) -> str:
|
||||||
|
handle = f" (@{username})" if username else ""
|
||||||
|
return f"{author_name}{handle}:\n{text}"
|
||||||
|
|
||||||
|
|
||||||
|
def extract_tg_media(message: Message) -> list[dict]:
|
||||||
|
items: list[dict] = []
|
||||||
|
if message.photo:
|
||||||
|
photo = message.photo[-1]
|
||||||
|
items.append({"kind": "photo", "file_id": photo.file_id})
|
||||||
|
elif message.video:
|
||||||
|
items.append({"kind": "video", "file_id": message.video.file_id})
|
||||||
|
elif message.document:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"kind": "document",
|
||||||
|
"file_id": message.document.file_id,
|
||||||
|
"file_name": message.document.file_name,
|
||||||
|
"mime_type": message.document.mime_type,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif message.audio:
|
||||||
|
items.append({"kind": "audio", "file_id": message.audio.file_id})
|
||||||
|
elif message.voice:
|
||||||
|
items.append({"kind": "voice", "file_id": message.voice.file_id})
|
||||||
|
elif message.sticker or message.animation or message.video_note:
|
||||||
|
items.append({"kind": "unsupported", "file_id": None})
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def build_tg_incoming(message: Message, forum_channel_id: int) -> TgIncomingMessage | None:
|
||||||
|
if message.chat.id != forum_channel_id:
|
||||||
|
return None
|
||||||
|
if message.is_topic_message is False and message.message_thread_id is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
author_name, username = format_tg_author(message)
|
||||||
|
text = message.text or message.caption or ""
|
||||||
|
media = extract_tg_media(message)
|
||||||
|
|
||||||
|
if not text and not media:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if media and not text and any(m.get("kind") == "unsupported" for m in media):
|
||||||
|
text = "[Telegram files]"
|
||||||
|
|
||||||
|
return TgIncomingMessage(
|
||||||
|
tg_chat_id=message.chat.id,
|
||||||
|
tg_message_id=message.message_id,
|
||||||
|
tg_thread_id=message.message_thread_id,
|
||||||
|
text=text,
|
||||||
|
author_name=author_name,
|
||||||
|
author_username=username,
|
||||||
|
is_bot=bool(message.from_user and message.from_user.is_bot),
|
||||||
|
reply_to_tg_message_id=message.reply_to_message.message_id
|
||||||
|
if message.reply_to_message
|
||||||
|
else None,
|
||||||
|
media=media,
|
||||||
|
)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import structlog
|
||||||
|
from aiogram import Bot, Dispatcher, F
|
||||||
|
from aiogram.enums import ChatType
|
||||||
|
from aiogram.types import Message
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
from app.router.router import MessageRouter
|
||||||
|
from app.storage.protocols import StoragePort
|
||||||
|
from app.telegram_layer.admin import register_admin_handlers
|
||||||
|
from app.telegram_layer.bot_holder import BotHolder
|
||||||
|
from app.telegram_layer.formatter import build_tg_incoming, format_tg_to_max_text
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramListener:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
settings: Settings,
|
||||||
|
bot_holder: BotHolder,
|
||||||
|
router: MessageRouter,
|
||||||
|
storage: StoragePort,
|
||||||
|
max_holder,
|
||||||
|
) -> None:
|
||||||
|
self._settings = settings
|
||||||
|
self._bot_holder = bot_holder
|
||||||
|
self._router = router
|
||||||
|
self._storage = storage
|
||||||
|
self._max_holder = max_holder
|
||||||
|
self._bot = Bot(token=settings.tg_bot_token)
|
||||||
|
self._dp = Dispatcher()
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
self._bot_holder.set_bot(self._bot)
|
||||||
|
register_admin_handlers(
|
||||||
|
self._dp,
|
||||||
|
self._settings,
|
||||||
|
self._storage,
|
||||||
|
self._max_holder,
|
||||||
|
)
|
||||||
|
self._dp.message.register(
|
||||||
|
self._on_forum_message,
|
||||||
|
F.chat.id == self._settings.tg_forum_channel_id,
|
||||||
|
F.chat.type == ChatType.SUPERGROUP,
|
||||||
|
)
|
||||||
|
logger.info("telegram_listener_started")
|
||||||
|
try:
|
||||||
|
await self._dp.start_polling(self._bot)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("telegram_polling_failed")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def _on_forum_message(self, message: Message) -> None:
|
||||||
|
logger.debug(
|
||||||
|
"tg_forum_message_received",
|
||||||
|
message_id=message.message_id,
|
||||||
|
thread_id=message.message_thread_id,
|
||||||
|
has_text=bool(message.text or message.caption),
|
||||||
|
)
|
||||||
|
incoming = build_tg_incoming(message, self._settings.tg_forum_channel_id)
|
||||||
|
if incoming is None:
|
||||||
|
logger.debug("tg_forum_message_skipped", message_id=message.message_id)
|
||||||
|
return
|
||||||
|
incoming.text = format_tg_to_max_text(
|
||||||
|
incoming.author_name,
|
||||||
|
incoming.author_username,
|
||||||
|
incoming.text,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self._router.handle_tg_message(incoming)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("tg_route_failed", message_id=message.message_id)
|
||||||
|
await self._router.notify_error(
|
||||||
|
"Ошибка маршрутизации TG → MAX",
|
||||||
|
f"message_id={message.message_id}",
|
||||||
|
)
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
import asyncio
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
import structlog
|
||||||
|
from aiogram.exceptions import TelegramBadRequest
|
||||||
|
from aiogram.types import (
|
||||||
|
FSInputFile,
|
||||||
|
InputMediaDocument,
|
||||||
|
InputMediaPhoto,
|
||||||
|
InputMediaVideo,
|
||||||
|
ReactionTypeEmoji,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
from app.media_transfer import (
|
||||||
|
cleanup_paths,
|
||||||
|
download_max_image_url,
|
||||||
|
download_media_item,
|
||||||
|
resolve_file_name,
|
||||||
|
tmp_dir,
|
||||||
|
)
|
||||||
|
from app.max_layer.client_holder import MaxClientHolder
|
||||||
|
from app.max_layer.formatter import format_max_text
|
||||||
|
from app.models.domain import ChatMapping, MaxIncomingMessage
|
||||||
|
from app.models.tasks import (
|
||||||
|
Max2TgTask,
|
||||||
|
MediaItem,
|
||||||
|
NotifyFallbackTask,
|
||||||
|
SetReactionTask,
|
||||||
|
)
|
||||||
|
from app.queue.protocols import QueuePort
|
||||||
|
from app.storage.protocols import StoragePort
|
||||||
|
from app.telegram_layer.bot_holder import BotHolder
|
||||||
|
from app.telegram_layer.formatter import (
|
||||||
|
format_topic_pin_text,
|
||||||
|
split_text_for_media_caption,
|
||||||
|
)
|
||||||
|
from app.topic_locks import TopicLockRegistry
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_thread_not_found(exc: TelegramBadRequest) -> bool:
|
||||||
|
return "message thread not found" in exc.message.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramWorker:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
settings: Settings,
|
||||||
|
bot_holder: BotHolder,
|
||||||
|
max_holder: MaxClientHolder,
|
||||||
|
queue: QueuePort,
|
||||||
|
storage: StoragePort,
|
||||||
|
topic_locks: TopicLockRegistry,
|
||||||
|
) -> None:
|
||||||
|
self._settings = settings
|
||||||
|
self._bot_holder = bot_holder
|
||||||
|
self._max_holder = max_holder
|
||||||
|
self._queue = queue
|
||||||
|
self._storage = storage
|
||||||
|
self._topic_locks = topic_locks
|
||||||
|
self._running = True
|
||||||
|
self._tmp_dir = tmp_dir(self._settings.data_dir)
|
||||||
|
|
||||||
|
async def run(self) -> None:
|
||||||
|
logger.info("tg_worker_started")
|
||||||
|
while self._running:
|
||||||
|
task = await self._queue.dequeue_max2tg(timeout=5)
|
||||||
|
if task is None:
|
||||||
|
continue
|
||||||
|
logger.info("tg_worker_task_received", task_kind=task.kind)
|
||||||
|
try:
|
||||||
|
if isinstance(task, Max2TgTask):
|
||||||
|
await self._send_to_tg(task)
|
||||||
|
elif isinstance(task, SetReactionTask):
|
||||||
|
await self._set_reaction(task)
|
||||||
|
elif isinstance(task, NotifyFallbackTask):
|
||||||
|
await self._notify_fallback(task)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("tg_worker_failed", task_kind=task.kind)
|
||||||
|
if isinstance(task, Max2TgTask):
|
||||||
|
await self._queue.enqueue_max2tg(
|
||||||
|
NotifyFallbackTask(
|
||||||
|
title="Ошибка отправки в Telegram",
|
||||||
|
details=f"max_message_id={task.max_message_id}: {exc}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await asyncio.sleep(self._settings.tg_rate_limit_delay_sec)
|
||||||
|
|
||||||
|
async def _create_and_save_topic(
|
||||||
|
self,
|
||||||
|
bot,
|
||||||
|
task: Max2TgTask,
|
||||||
|
*,
|
||||||
|
reason: str,
|
||||||
|
old_thread_id: int | None = None,
|
||||||
|
) -> ChatMapping:
|
||||||
|
topic = await bot.create_forum_topic(
|
||||||
|
chat_id=self._settings.tg_forum_channel_id,
|
||||||
|
name=task.chat_title[:128],
|
||||||
|
)
|
||||||
|
mapping = ChatMapping(
|
||||||
|
max_chat_id=task.max_chat_id,
|
||||||
|
tg_chat_id=self._settings.tg_forum_channel_id,
|
||||||
|
tg_thread_id=topic.message_thread_id,
|
||||||
|
display_name=task.chat_title,
|
||||||
|
is_dm=task.is_dm,
|
||||||
|
)
|
||||||
|
await self._storage.save_mapping(mapping)
|
||||||
|
await self._send_and_pin_topic_info(bot, topic.message_thread_id, task)
|
||||||
|
logger.info(
|
||||||
|
"tg_topic_created",
|
||||||
|
max_chat_id=task.max_chat_id,
|
||||||
|
thread_id=topic.message_thread_id,
|
||||||
|
title=task.chat_title,
|
||||||
|
reason=reason,
|
||||||
|
old_thread_id=old_thread_id,
|
||||||
|
)
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
async def _send_and_pin_topic_info(
|
||||||
|
self,
|
||||||
|
bot,
|
||||||
|
thread_id: int,
|
||||||
|
task: Max2TgTask,
|
||||||
|
) -> None:
|
||||||
|
chat_name = task.chat_name or task.chat_title
|
||||||
|
caption = format_topic_pin_text(
|
||||||
|
chat_name=chat_name,
|
||||||
|
is_dm=task.is_dm,
|
||||||
|
max_chat_id=task.max_chat_id,
|
||||||
|
participants_count=task.participants_count,
|
||||||
|
max_chat_link=task.max_chat_link,
|
||||||
|
)
|
||||||
|
kwargs: dict = {
|
||||||
|
"chat_id": self._settings.tg_forum_channel_id,
|
||||||
|
"message_thread_id": thread_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
downloaded: Path | None = None
|
||||||
|
icon_path: Path | None = None
|
||||||
|
try:
|
||||||
|
icon_path = await self._resolve_topic_icon_path(task)
|
||||||
|
if icon_path is not None:
|
||||||
|
sent = await bot.send_photo(
|
||||||
|
photo=FSInputFile(icon_path),
|
||||||
|
caption=caption,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
not task.chat_icon_local_path
|
||||||
|
or icon_path.resolve() != Path(task.chat_icon_local_path).resolve()
|
||||||
|
):
|
||||||
|
downloaded = icon_path
|
||||||
|
else:
|
||||||
|
sent = await bot.send_message(text=caption, **kwargs)
|
||||||
|
|
||||||
|
await bot.pin_chat_message(
|
||||||
|
chat_id=self._settings.tg_forum_channel_id,
|
||||||
|
message_id=sent.message_id,
|
||||||
|
disable_notification=True,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"tg_topic_info_pinned",
|
||||||
|
max_chat_id=task.max_chat_id,
|
||||||
|
thread_id=thread_id,
|
||||||
|
message_id=sent.message_id,
|
||||||
|
has_icon=icon_path is not None,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"tg_topic_info_pin_failed",
|
||||||
|
max_chat_id=task.max_chat_id,
|
||||||
|
thread_id=thread_id,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if downloaded is not None:
|
||||||
|
cleanup_paths([downloaded])
|
||||||
|
|
||||||
|
async def _resolve_topic_icon_path(self, task: Max2TgTask) -> Path | None:
|
||||||
|
if task.chat_icon_local_path:
|
||||||
|
local = Path(task.chat_icon_local_path)
|
||||||
|
if local.is_file() and local.stat().st_size > 0:
|
||||||
|
return local
|
||||||
|
|
||||||
|
if not task.chat_icon_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
dest = self._tmp_dir / f"topic_icon_{task.max_chat_id}_{uuid4().hex}.jpg"
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
if await download_max_image_url(session, task.chat_icon_url, dest):
|
||||||
|
return dest
|
||||||
|
logger.warning(
|
||||||
|
"tg_topic_icon_download_failed",
|
||||||
|
max_chat_id=task.max_chat_id,
|
||||||
|
url=task.chat_icon_url,
|
||||||
|
)
|
||||||
|
dest.unlink(missing_ok=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _send_to_tg(self, task: Max2TgTask) -> None:
|
||||||
|
bot = await self._bot_holder.wait_bot()
|
||||||
|
logger.debug(
|
||||||
|
"tg_worker_send_start",
|
||||||
|
max_chat_id=task.max_chat_id,
|
||||||
|
max_message_id=task.max_message_id,
|
||||||
|
needs_new_topic=task.needs_new_topic,
|
||||||
|
media_count=len(task.media),
|
||||||
|
)
|
||||||
|
|
||||||
|
async with self._topic_locks.lock(task.max_chat_id):
|
||||||
|
mapping = await self._storage.get_mapping_by_max_chat(task.max_chat_id)
|
||||||
|
if mapping is None:
|
||||||
|
logger.info(
|
||||||
|
"tg_topic_creating",
|
||||||
|
max_chat_id=task.max_chat_id,
|
||||||
|
title=task.chat_title,
|
||||||
|
reason="no_mapping_in_storage_at_send_time",
|
||||||
|
needs_new_topic_from_router=task.needs_new_topic,
|
||||||
|
sqlite_path=self._settings.sqlite_path,
|
||||||
|
hint="mapping_missing_in_sqlite_will_call_create_forum_topic",
|
||||||
|
)
|
||||||
|
mapping = await self._create_and_save_topic(
|
||||||
|
bot, task, reason="no_mapping_in_storage"
|
||||||
|
)
|
||||||
|
elif task.needs_new_topic:
|
||||||
|
logger.info(
|
||||||
|
"tg_topic_reused",
|
||||||
|
max_chat_id=task.max_chat_id,
|
||||||
|
thread_id=mapping.tg_thread_id,
|
||||||
|
reason="mapping_found_despite_needs_new_topic_flag",
|
||||||
|
hint="mapping_appeared_between_router_enqueue_and_worker_send",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"tg_topic_reused",
|
||||||
|
max_chat_id=task.max_chat_id,
|
||||||
|
thread_id=mapping.tg_thread_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert mapping is not None
|
||||||
|
|
||||||
|
formatted = format_max_text(
|
||||||
|
MaxIncomingMessage(
|
||||||
|
max_chat_id=task.max_chat_id,
|
||||||
|
max_message_id=task.max_message_id,
|
||||||
|
text=task.text,
|
||||||
|
sender_id=None,
|
||||||
|
sender_name=task.sender_name,
|
||||||
|
is_dm=task.is_dm,
|
||||||
|
chat_title=task.chat_title,
|
||||||
|
chat_name=task.chat_name,
|
||||||
|
chat_icon_url=task.chat_icon_url,
|
||||||
|
chat_icon_local_path=task.chat_icon_local_path,
|
||||||
|
participants_count=task.participants_count,
|
||||||
|
max_chat_link=task.max_chat_link,
|
||||||
|
reply_to_max_message_id=None,
|
||||||
|
media=[],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
reply_to = task.reply_to_tg_message_id
|
||||||
|
try:
|
||||||
|
sent = await self._dispatch_content(
|
||||||
|
bot, mapping.tg_thread_id, formatted, task.media, reply_to
|
||||||
|
)
|
||||||
|
except TelegramBadRequest as exc:
|
||||||
|
if not _is_thread_not_found(exc):
|
||||||
|
raise
|
||||||
|
old_thread_id = mapping.tg_thread_id
|
||||||
|
logger.info(
|
||||||
|
"tg_topic_stale",
|
||||||
|
max_chat_id=task.max_chat_id,
|
||||||
|
old_thread_id=old_thread_id,
|
||||||
|
reason="telegram_message_thread_not_found",
|
||||||
|
action="recreate_topic_and_update_binding",
|
||||||
|
)
|
||||||
|
async with self._topic_locks.lock(task.max_chat_id):
|
||||||
|
mapping = await self._create_and_save_topic(
|
||||||
|
bot,
|
||||||
|
task,
|
||||||
|
reason="telegram_thread_recreated",
|
||||||
|
old_thread_id=old_thread_id,
|
||||||
|
)
|
||||||
|
sent = await self._dispatch_content(
|
||||||
|
bot, mapping.tg_thread_id, formatted, task.media, None
|
||||||
|
)
|
||||||
|
if sent is None:
|
||||||
|
raise RuntimeError("Telegram API returned no message")
|
||||||
|
|
||||||
|
await self._storage.save_message_link(
|
||||||
|
task.max_chat_id,
|
||||||
|
task.max_message_id,
|
||||||
|
mapping.tg_chat_id,
|
||||||
|
mapping.tg_thread_id,
|
||||||
|
sent.message_id,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"tg_message_sent",
|
||||||
|
max_message_id=task.max_message_id,
|
||||||
|
tg_message_id=sent.message_id,
|
||||||
|
thread_id=mapping.tg_thread_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _send_overflow_text(
|
||||||
|
self,
|
||||||
|
bot,
|
||||||
|
*,
|
||||||
|
thread_id: int,
|
||||||
|
text: str,
|
||||||
|
reply_to_message_id: int,
|
||||||
|
) -> None:
|
||||||
|
await bot.send_message(
|
||||||
|
chat_id=self._settings.tg_forum_channel_id,
|
||||||
|
message_thread_id=thread_id,
|
||||||
|
text=text,
|
||||||
|
reply_to_message_id=reply_to_message_id,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"tg_caption_overflow_sent",
|
||||||
|
thread_id=thread_id,
|
||||||
|
text_len=len(text),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _dispatch_content(
|
||||||
|
self,
|
||||||
|
bot,
|
||||||
|
thread_id: int,
|
||||||
|
text: str,
|
||||||
|
media: list[MediaItem],
|
||||||
|
reply_to: int | None,
|
||||||
|
):
|
||||||
|
chat_id = self._settings.tg_forum_channel_id
|
||||||
|
kwargs: dict = {
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"message_thread_id": thread_id,
|
||||||
|
}
|
||||||
|
if reply_to is not None:
|
||||||
|
kwargs["reply_to_message_id"] = reply_to
|
||||||
|
|
||||||
|
if not media:
|
||||||
|
return await bot.send_message(text=text or " ", **kwargs)
|
||||||
|
|
||||||
|
caption, overflow = split_text_for_media_caption(text)
|
||||||
|
|
||||||
|
downloaded: list = []
|
||||||
|
try:
|
||||||
|
max_client = self._max_holder.client
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
uploads: list[tuple[MediaItem, FSInputFile]] = []
|
||||||
|
for item in media:
|
||||||
|
path = await download_media_item(
|
||||||
|
bot,
|
||||||
|
session,
|
||||||
|
item,
|
||||||
|
self._tmp_dir,
|
||||||
|
max_client=max_client,
|
||||||
|
)
|
||||||
|
if path is None:
|
||||||
|
logger.warning(
|
||||||
|
"tg_worker_media_skipped",
|
||||||
|
kind=item.kind,
|
||||||
|
file_id=item.file_id,
|
||||||
|
url=item.url,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
downloaded.append(path)
|
||||||
|
uploads.append(
|
||||||
|
(item, FSInputFile(path, filename=resolve_file_name(item)))
|
||||||
|
)
|
||||||
|
|
||||||
|
if not uploads:
|
||||||
|
fallback = text or "[Telegram files]"
|
||||||
|
return await bot.send_message(text=fallback, **kwargs)
|
||||||
|
|
||||||
|
if len(uploads) == 1:
|
||||||
|
item, upload = uploads[0]
|
||||||
|
if item.kind == "photo":
|
||||||
|
sent = await bot.send_photo(
|
||||||
|
photo=upload, caption=caption, **kwargs
|
||||||
|
)
|
||||||
|
elif item.kind == "video":
|
||||||
|
sent = await bot.send_video(
|
||||||
|
video=upload, caption=caption, **kwargs
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
sent = await bot.send_document(
|
||||||
|
document=upload, caption=caption, **kwargs
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
group = []
|
||||||
|
for idx, (item, upload) in enumerate(uploads):
|
||||||
|
item_caption = caption if idx == 0 else None
|
||||||
|
if item.kind == "photo":
|
||||||
|
group.append(InputMediaPhoto(media=upload, caption=item_caption))
|
||||||
|
elif item.kind == "video":
|
||||||
|
group.append(InputMediaVideo(media=upload, caption=item_caption))
|
||||||
|
else:
|
||||||
|
group.append(InputMediaDocument(media=upload, caption=item_caption))
|
||||||
|
|
||||||
|
messages = await bot.send_media_group(
|
||||||
|
chat_id=chat_id,
|
||||||
|
message_thread_id=thread_id,
|
||||||
|
media=group,
|
||||||
|
)
|
||||||
|
sent = messages[0]
|
||||||
|
|
||||||
|
if overflow:
|
||||||
|
await self._send_overflow_text(
|
||||||
|
bot,
|
||||||
|
thread_id=thread_id,
|
||||||
|
text=overflow,
|
||||||
|
reply_to_message_id=sent.message_id,
|
||||||
|
)
|
||||||
|
return sent
|
||||||
|
finally:
|
||||||
|
cleanup_paths(downloaded)
|
||||||
|
|
||||||
|
async def _set_reaction(self, task: SetReactionTask) -> None:
|
||||||
|
bot = await self._bot_holder.wait_bot()
|
||||||
|
await bot.set_message_reaction(
|
||||||
|
chat_id=task.tg_chat_id,
|
||||||
|
message_id=task.tg_message_id,
|
||||||
|
reaction=[ReactionTypeEmoji(emoji=task.emoji)],
|
||||||
|
)
|
||||||
|
logger.info("tg_reaction_set", message_id=task.tg_message_id)
|
||||||
|
|
||||||
|
async def _notify_fallback(self, task: NotifyFallbackTask) -> None:
|
||||||
|
bot = await self._bot_holder.wait_bot()
|
||||||
|
text = f"⚠️ {task.title}\n\n{task.details}"
|
||||||
|
await bot.send_message(chat_id=self._settings.fallback_user_id, text=text)
|
||||||
|
logger.warning("fallback_notified", title=task.title)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
|
||||||
|
class TopicLockRegistry:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._locks: dict[int, asyncio.Lock] = {}
|
||||||
|
|
||||||
|
def lock(self, max_chat_id: int) -> asyncio.Lock:
|
||||||
|
lock = self._locks.get(max_chat_id)
|
||||||
|
if lock is None:
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
self._locks[max_chat_id] = lock
|
||||||
|
return lock
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
from pymax import MaxClient
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
phone = os.getenv("MAX_PHONE", "").strip()
|
|
||||||
work_dir = os.getenv("MAX_WORK_DIR", "cache").strip() or "cache"
|
|
||||||
|
|
||||||
if not phone:
|
|
||||||
print("Error: MAX_PHONE environment variable is required", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
client = MaxClient(phone=phone, work_dir=work_dir)
|
|
||||||
print(f"Starting Max authentication for {phone}")
|
|
||||||
print(f"Session will be stored in: {work_dir}")
|
|
||||||
|
|
||||||
async def _run() -> None:
|
|
||||||
await client.start()
|
|
||||||
print(f"Authentication successful!")
|
|
||||||
print(f"Me: {client.me.username or client.me.first_name} (id={client.me.id})")
|
|
||||||
print("Session file saved. You can now run the main application.")
|
|
||||||
await client.stop()
|
|
||||||
|
|
||||||
asyncio.run(_run())
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
version: '3'
|
|
||||||
services:
|
|
||||||
max:
|
|
||||||
build: ./
|
|
||||||
container_name: max
|
|
||||||
restart: always
|
|
||||||
env_file:
|
|
||||||
- ./.env
|
|
||||||
volumes:
|
|
||||||
- "./cache:/app/cache"
|
|
||||||
- "/etc/hosts:/etc/hosts"
|
|
||||||
ports:
|
|
||||||
- 5004:5000
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "python", "-c", "import urllib.request,sys; import json; r=urllib.request.urlopen('http://127.0.0.1:5000/healthz', timeout=3); sys.exit(0 if r.status==200 else 1)"]
|
|
||||||
interval: 30s
|
|
||||||
timeout: 5s
|
|
||||||
retries: 3
|
|
||||||
start_period: 30s
|
|
||||||
logging:
|
|
||||||
driver: json-file
|
|
||||||
options:
|
|
||||||
max-size: 50m
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- ./data/redis:/data
|
||||||
|
command: redis-server --appendonly yes
|
||||||
|
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
restart: always
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
environment:
|
||||||
|
- MAX_TOKEN=${MAX_TOKEN}
|
||||||
|
- MAX_DEVICE_ID=${MAX_DEVICE_ID}
|
||||||
|
- TG_BOT_TOKEN=${TG_BOT_TOKEN}
|
||||||
|
- TG_FORUM_CHANNEL_ID=${TG_FORUM_CHANNEL_ID}
|
||||||
|
- FALLBACK_USER_ID=${FALLBACK_USER_ID}
|
||||||
|
- DATABASE_URL=sqlite+aiosqlite:///data/bridge.db
|
||||||
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
- TG_RATE_LIMIT_DELAY_SEC=3.5
|
||||||
|
- MAX_RATE_LIMIT_DELAY_SEC=1.0
|
||||||
|
- MAX_RECONNECT_FETCH_LIMIT=50
|
||||||
|
- LOG_LEVEL=INFO
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
maxapi-python>=2.0.0
|
||||||
|
aiogram>=3.13.0
|
||||||
|
redis[hiredis]>=5.0.0
|
||||||
|
sqlalchemy[asyncio]>=2.0.0
|
||||||
|
aiosqlite>=0.20.0
|
||||||
|
structlog>=24.0.0
|
||||||
|
pydantic>=2.0.0
|
||||||
|
pydantic-settings>=2.0.0
|
||||||
|
aiohttp>=3.9.0
|
||||||
|
aiofiles>=23.0.0
|
||||||
-347
@@ -1,347 +0,0 @@
|
|||||||
import logging
|
|
||||||
from collections.abc import Awaitable, Callable
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from max_parser import parse_message
|
|
||||||
from models import ParsedMessage
|
|
||||||
from pymax import MaxClient
|
|
||||||
from pymax.types import PhotoAttach, VideoAttach
|
|
||||||
from storage import BridgeStorage
|
|
||||||
from telegram_api import TelegramApiError, TelegramClient
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class MaxToTelegramBridge:
|
|
||||||
def __init__(self, max_client: MaxClient, telegram: TelegramClient, storage: BridgeStorage) -> None:
|
|
||||||
self._max_client = max_client
|
|
||||||
self._telegram = telegram
|
|
||||||
self._storage = storage
|
|
||||||
|
|
||||||
async def forward_message(self, max_message: Any) -> None:
|
|
||||||
if self._is_self_message(max_message):
|
|
||||||
logger.debug("Skip self message %s/%s", getattr(max_message, "chat_id", "?"), getattr(max_message, "id", "?"))
|
|
||||||
return
|
|
||||||
|
|
||||||
parsed = parse_message(max_message)
|
|
||||||
parsed = await self._enrich_from_max(max_message, parsed)
|
|
||||||
|
|
||||||
if self._storage.was_forwarded(parsed.message_id, parsed.chat_id):
|
|
||||||
logger.debug("Skip duplicated message %s/%s", parsed.chat_id, parsed.message_id)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Формат зависит от маршрута:
|
|
||||||
# - если найден целевой Telegram-чат (не fallback): "Ирина:\n<текст>"
|
|
||||||
# - если fallback: "Ирина / Свободный микрофон:\n<текст>"
|
|
||||||
# Решение о том, включать ли название чата, принимаем после определения маршрута.
|
|
||||||
has_any_payload = bool((parsed.text or "").strip()) or bool(parsed.image_urls) or bool(parsed.video_urls)
|
|
||||||
if not has_any_payload:
|
|
||||||
logger.debug("Skip empty message %s/%s", parsed.chat_id, parsed.message_id)
|
|
||||||
return
|
|
||||||
|
|
||||||
normalized = parsed.chat_name.strip().casefold()
|
|
||||||
routed = self._storage.get_chat_route(max_chat_title_norm=normalized)
|
|
||||||
if routed:
|
|
||||||
target_chat_id = routed
|
|
||||||
is_fallback = False
|
|
||||||
logger.info("Route Max chat '%s' to Telegram chat %s (bound)", parsed.chat_name, target_chat_id)
|
|
||||||
else:
|
|
||||||
target_chat_id, matched_by_title = await self._telegram.resolve_target_chat_id(parsed.chat_name)
|
|
||||||
if matched_by_title:
|
|
||||||
is_fallback = False
|
|
||||||
logger.info("Route Max chat '%s' to Telegram chat %s", parsed.chat_name, target_chat_id)
|
|
||||||
else:
|
|
||||||
is_fallback = True
|
|
||||||
logger.info(
|
|
||||||
"Telegram chat '%s' not found, route to fallback user %s",
|
|
||||||
parsed.chat_name,
|
|
||||||
target_chat_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
text = self._format_caption(
|
|
||||||
sender_name=parsed.sender_name,
|
|
||||||
chat_name=parsed.chat_name,
|
|
||||||
text=parsed.text,
|
|
||||||
include_chat_name=is_fallback,
|
|
||||||
)
|
|
||||||
|
|
||||||
reply_telegram_mid = self._resolve_telegram_reply_to(
|
|
||||||
telegram_chat_id=str(target_chat_id),
|
|
||||||
max_chat_id=str(parsed.chat_id),
|
|
||||||
parsed=parsed,
|
|
||||||
)
|
|
||||||
if parsed.reply_to_max_message_id and reply_telegram_mid is None:
|
|
||||||
text = self._prepend_max_reply_context(parsed, text)
|
|
||||||
|
|
||||||
has_any_payload = bool(text.strip()) or bool(parsed.image_urls) or bool(parsed.video_urls)
|
|
||||||
if not has_any_payload:
|
|
||||||
logger.debug("Skip empty message %s/%s", parsed.chat_id, parsed.message_id)
|
|
||||||
return
|
|
||||||
|
|
||||||
total_media = len(parsed.image_urls) + len(parsed.video_urls)
|
|
||||||
if total_media > 1:
|
|
||||||
# Отправляем одним альбомом в Telegram (единое сообщение).
|
|
||||||
target_chat_id, sent_messages = await self._send_with_migration_retry(
|
|
||||||
target_chat_id=target_chat_id,
|
|
||||||
max_chat_title_norm=normalized,
|
|
||||||
max_chat_title=parsed.chat_name,
|
|
||||||
send_action=lambda chat_id: self._telegram.send_media_group(
|
|
||||||
chat_id=chat_id,
|
|
||||||
image_urls=parsed.image_urls,
|
|
||||||
video_urls=parsed.video_urls,
|
|
||||||
caption=text,
|
|
||||||
reply_to_message_id=reply_telegram_mid,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for sent in sent_messages:
|
|
||||||
mid = sent.get("message_id")
|
|
||||||
if mid is None:
|
|
||||||
continue
|
|
||||||
self._storage.save_mapping(
|
|
||||||
telegram_chat_id=str(target_chat_id),
|
|
||||||
telegram_message_id=str(mid),
|
|
||||||
max_chat_id=str(parsed.chat_id),
|
|
||||||
max_message_id=str(parsed.message_id),
|
|
||||||
)
|
|
||||||
self._storage.mark_forwarded(parsed.message_id, parsed.chat_id)
|
|
||||||
logger.info(
|
|
||||||
"Forwarded media group %s/%s (images=%s, videos=%s)",
|
|
||||||
parsed.chat_id,
|
|
||||||
parsed.message_id,
|
|
||||||
len(parsed.image_urls),
|
|
||||||
len(parsed.video_urls),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
sent_any = False
|
|
||||||
if parsed.text.strip() and total_media == 0:
|
|
||||||
target_chat_id, sent = await self._send_with_migration_retry(
|
|
||||||
target_chat_id=target_chat_id,
|
|
||||||
max_chat_title_norm=normalized,
|
|
||||||
max_chat_title=parsed.chat_name,
|
|
||||||
send_action=lambda chat_id: self._telegram.send_text(
|
|
||||||
chat_id, text, reply_to_message_id=reply_telegram_mid
|
|
||||||
),
|
|
||||||
)
|
|
||||||
mid = sent.get("result", {}).get("message_id") if isinstance(sent.get("result"), dict) else None
|
|
||||||
if mid is not None:
|
|
||||||
self._storage.save_mapping(
|
|
||||||
telegram_chat_id=str(target_chat_id),
|
|
||||||
telegram_message_id=str(mid),
|
|
||||||
max_chat_id=str(parsed.chat_id),
|
|
||||||
max_message_id=str(parsed.message_id),
|
|
||||||
)
|
|
||||||
sent_any = True
|
|
||||||
|
|
||||||
for index, image_url in enumerate(parsed.image_urls):
|
|
||||||
caption = text if not sent_any and index == 0 else None
|
|
||||||
target_chat_id, sent = await self._send_with_migration_retry(
|
|
||||||
target_chat_id=target_chat_id,
|
|
||||||
max_chat_title_norm=normalized,
|
|
||||||
max_chat_title=parsed.chat_name,
|
|
||||||
send_action=lambda chat_id: self._telegram.send_photo(
|
|
||||||
chat_id,
|
|
||||||
image_url,
|
|
||||||
caption=caption,
|
|
||||||
reply_to_message_id=reply_telegram_mid if not sent_any and index == 0 else None,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
mid = sent.get("result", {}).get("message_id") if isinstance(sent.get("result"), dict) else None
|
|
||||||
if mid is not None:
|
|
||||||
self._storage.save_mapping(
|
|
||||||
telegram_chat_id=str(target_chat_id),
|
|
||||||
telegram_message_id=str(mid),
|
|
||||||
max_chat_id=str(parsed.chat_id),
|
|
||||||
max_message_id=str(parsed.message_id),
|
|
||||||
)
|
|
||||||
sent_any = True
|
|
||||||
|
|
||||||
for index, video_url in enumerate(parsed.video_urls):
|
|
||||||
caption = text if not sent_any and index == 0 else None
|
|
||||||
target_chat_id, sent = await self._send_with_migration_retry(
|
|
||||||
target_chat_id=target_chat_id,
|
|
||||||
max_chat_title_norm=normalized,
|
|
||||||
max_chat_title=parsed.chat_name,
|
|
||||||
send_action=lambda chat_id: self._telegram.send_video(
|
|
||||||
chat_id,
|
|
||||||
video_url,
|
|
||||||
caption=caption,
|
|
||||||
reply_to_message_id=reply_telegram_mid if not sent_any and index == 0 else None,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
mid = sent.get("result", {}).get("message_id") if isinstance(sent.get("result"), dict) else None
|
|
||||||
if mid is not None:
|
|
||||||
self._storage.save_mapping(
|
|
||||||
telegram_chat_id=str(target_chat_id),
|
|
||||||
telegram_message_id=str(mid),
|
|
||||||
max_chat_id=str(parsed.chat_id),
|
|
||||||
max_message_id=str(parsed.message_id),
|
|
||||||
)
|
|
||||||
sent_any = True
|
|
||||||
|
|
||||||
self._storage.mark_forwarded(parsed.message_id, parsed.chat_id)
|
|
||||||
logger.info(
|
|
||||||
"Forwarded message %s/%s (images=%s, videos=%s)",
|
|
||||||
parsed.chat_id,
|
|
||||||
parsed.message_id,
|
|
||||||
len(parsed.image_urls),
|
|
||||||
len(parsed.video_urls),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _send_with_migration_retry(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
target_chat_id: str,
|
|
||||||
max_chat_title_norm: str,
|
|
||||||
max_chat_title: str,
|
|
||||||
send_action: Callable[[str], Awaitable[Any]],
|
|
||||||
) -> tuple[str, Any]:
|
|
||||||
try:
|
|
||||||
sent = await send_action(target_chat_id)
|
|
||||||
return target_chat_id, sent
|
|
||||||
except TelegramApiError as exc:
|
|
||||||
migrated_chat_id = exc.migrate_to_chat_id
|
|
||||||
if not migrated_chat_id or migrated_chat_id == str(target_chat_id):
|
|
||||||
raise
|
|
||||||
logger.warning(
|
|
||||||
"Telegram chat %s upgraded to %s for MAX chat '%s'; update route and retry",
|
|
||||||
target_chat_id,
|
|
||||||
migrated_chat_id,
|
|
||||||
max_chat_title,
|
|
||||||
)
|
|
||||||
self._storage.set_chat_route(
|
|
||||||
max_chat_title_norm=max_chat_title_norm,
|
|
||||||
telegram_chat_id=migrated_chat_id,
|
|
||||||
telegram_chat_title=max_chat_title,
|
|
||||||
)
|
|
||||||
sent = await send_action(migrated_chat_id)
|
|
||||||
return migrated_chat_id, sent
|
|
||||||
|
|
||||||
def _resolve_telegram_reply_to(
|
|
||||||
self, *, telegram_chat_id: str, max_chat_id: str, parsed: ParsedMessage
|
|
||||||
) -> int | None:
|
|
||||||
if not parsed.reply_to_max_message_id:
|
|
||||||
return None
|
|
||||||
raw = self._storage.get_telegram_message_id_for_max(
|
|
||||||
telegram_chat_id=telegram_chat_id,
|
|
||||||
max_chat_id=max_chat_id,
|
|
||||||
max_message_id=str(parsed.reply_to_max_message_id),
|
|
||||||
)
|
|
||||||
if not raw:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return int(raw)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _prepend_max_reply_context(parsed: ParsedMessage, body: str) -> str:
|
|
||||||
"""Если в Telegram нет исходного сообщения — сохраняем контекст ответа текстом."""
|
|
||||||
prev = (parsed.reply_preview_text or "").strip()
|
|
||||||
if prev:
|
|
||||||
quoted = "\n".join(f"> {line}" for line in prev.splitlines()[:25])
|
|
||||||
return f"↪ ответ в MAX:\n{quoted}\n\n{body}"
|
|
||||||
return f"↪ ответ в MAX (сообщение id={parsed.reply_to_max_message_id})\n\n{body}"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _format_caption(*, sender_name: str, chat_name: str, text: str, include_chat_name: bool) -> str:
|
|
||||||
sender_name = (sender_name or "").strip() or "unknown"
|
|
||||||
chat_name = (chat_name or "").strip() or "direct"
|
|
||||||
|
|
||||||
if include_chat_name:
|
|
||||||
header = f"{sender_name} / {chat_name}:"
|
|
||||||
else:
|
|
||||||
header = f"{sender_name}:"
|
|
||||||
|
|
||||||
body = (text or "").strip()
|
|
||||||
if body:
|
|
||||||
return f"{header}\n{body}"
|
|
||||||
return header
|
|
||||||
|
|
||||||
async def _enrich_from_max(self, max_message: Any, parsed: ParsedMessage) -> ParsedMessage:
|
|
||||||
# Имена отправителя и чата берем из API Max, чтобы всегда получить человекочитаемый формат.
|
|
||||||
try:
|
|
||||||
user = await self._max_client.get_user(user_id=max_message.sender)
|
|
||||||
if user and getattr(user, "names", None):
|
|
||||||
first_name = getattr(user.names[0], "name", "")
|
|
||||||
if first_name:
|
|
||||||
parsed.sender_name = str(first_name)
|
|
||||||
except Exception:
|
|
||||||
logger.debug("Cannot resolve sender name", exc_info=True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
chat = await self._max_client.get_chat(chat_id=max_message.chat_id)
|
|
||||||
title = getattr(chat, "title", None)
|
|
||||||
if title:
|
|
||||||
parsed.chat_name = str(title)
|
|
||||||
except Exception:
|
|
||||||
logger.debug("Cannot resolve chat title", exc_info=True)
|
|
||||||
|
|
||||||
attaches = getattr(max_message, "attaches", None) or []
|
|
||||||
for attach in attaches:
|
|
||||||
if isinstance(attach, PhotoAttach):
|
|
||||||
parsed.image_urls.extend(self._extract_photo_urls(attach))
|
|
||||||
elif isinstance(attach, VideoAttach):
|
|
||||||
try:
|
|
||||||
video = await self._max_client.get_video_by_id(
|
|
||||||
chat_id=max_message.chat_id,
|
|
||||||
message_id=max_message.id,
|
|
||||||
video_id=attach.video_id,
|
|
||||||
)
|
|
||||||
video_url = getattr(video, "url", None)
|
|
||||||
if video_url:
|
|
||||||
parsed.video_urls.append(str(video_url))
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Cannot resolve video URL from Max")
|
|
||||||
|
|
||||||
# Убираем дубли URL, если парсер и enrich нашли одинаковые вложения.
|
|
||||||
parsed.image_urls = list(dict.fromkeys(parsed.image_urls))
|
|
||||||
parsed.video_urls = list(dict.fromkeys(parsed.video_urls))
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
def _is_self_message(self, max_message: Any) -> bool:
|
|
||||||
sender = getattr(max_message, "sender", None)
|
|
||||||
me = getattr(self._max_client, "me", None)
|
|
||||||
my_id = getattr(me, "id", None)
|
|
||||||
if sender is None or my_id is None:
|
|
||||||
return False
|
|
||||||
return str(sender) == str(my_id)
|
|
||||||
|
|
||||||
def _extract_photo_urls(self, attach: PhotoAttach) -> list[str]:
|
|
||||||
urls: list[str] = []
|
|
||||||
seen_ids: set[int] = set()
|
|
||||||
|
|
||||||
def walk(node: Any) -> None:
|
|
||||||
if node is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
obj_id = id(node)
|
|
||||||
if obj_id in seen_ids:
|
|
||||||
return
|
|
||||||
seen_ids.add(obj_id)
|
|
||||||
|
|
||||||
if isinstance(node, str):
|
|
||||||
if node.startswith("http://") or node.startswith("https://"):
|
|
||||||
urls.append(node)
|
|
||||||
return
|
|
||||||
|
|
||||||
if isinstance(node, (list, tuple, set)):
|
|
||||||
for item in node:
|
|
||||||
walk(item)
|
|
||||||
return
|
|
||||||
|
|
||||||
if isinstance(node, dict):
|
|
||||||
for key, value in node.items():
|
|
||||||
# Поиск всех возможных URL полей, включая альбомы/варианты размеров.
|
|
||||||
if key in {"base_url", "url", "src", "download_url"} and isinstance(value, str):
|
|
||||||
if value.startswith("http://") or value.startswith("https://"):
|
|
||||||
urls.append(value)
|
|
||||||
else:
|
|
||||||
walk(value)
|
|
||||||
return
|
|
||||||
|
|
||||||
if hasattr(node, "__dict__"):
|
|
||||||
walk(vars(node))
|
|
||||||
|
|
||||||
walk(attach)
|
|
||||||
return list(dict.fromkeys(urls))
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import os
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Settings:
|
|
||||||
max_phone: str
|
|
||||||
max_work_dir: str
|
|
||||||
telegram_bot_token: str
|
|
||||||
telegram_fallback_user_id: str
|
|
||||||
sqlite_path: str
|
|
||||||
|
|
||||||
|
|
||||||
def _require_env(name: str) -> str:
|
|
||||||
value = os.getenv(name, "").strip()
|
|
||||||
if not value:
|
|
||||||
raise ValueError(f"Environment variable {name} is required")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def load_settings() -> Settings:
|
|
||||||
max_work_dir = os.getenv("MAX_WORK_DIR", "cache").strip() or "cache"
|
|
||||||
sqlite_env = os.getenv("SQLITE_PATH", "").strip()
|
|
||||||
sqlite_default = os.path.join(max_work_dir, "max2telegram.db")
|
|
||||||
sqlite_path = sqlite_env or sqlite_default
|
|
||||||
|
|
||||||
return Settings(
|
|
||||||
max_phone=_require_env("MAX_PHONE"),
|
|
||||||
max_work_dir=max_work_dir,
|
|
||||||
telegram_bot_token=_require_env("TELEGRAM_BOT_TOKEN"),
|
|
||||||
telegram_fallback_user_id=_require_env("TELEGRAM_FALLBACK_USER_ID"),
|
|
||||||
sqlite_path=sqlite_path,
|
|
||||||
)
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import threading
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class HealthSnapshot:
|
|
||||||
now: float
|
|
||||||
uptime_sec: float
|
|
||||||
telegram_last_ok_ago_sec: float | None
|
|
||||||
telegram_last_error_ago_sec: float | None
|
|
||||||
max_last_ok_ago_sec: float | None
|
|
||||||
max_last_error_ago_sec: float | None
|
|
||||||
max_last_event_ago_sec: float | None
|
|
||||||
|
|
||||||
telegram_healthy: bool
|
|
||||||
max_healthy: bool
|
|
||||||
overall_healthy: bool
|
|
||||||
|
|
||||||
|
|
||||||
class HealthState:
|
|
||||||
def __init__(self, *, unhealthy_after_sec: float = 15 * 60) -> None:
|
|
||||||
self._lock = threading.Lock()
|
|
||||||
self._started_at = time.time()
|
|
||||||
self._unhealthy_after_sec = float(unhealthy_after_sec)
|
|
||||||
|
|
||||||
self._telegram_last_ok: float | None = None
|
|
||||||
self._telegram_last_error: float | None = None
|
|
||||||
|
|
||||||
self._max_last_ok: float | None = None
|
|
||||||
self._max_last_error: float | None = None
|
|
||||||
self._max_last_event: float | None = None
|
|
||||||
|
|
||||||
def mark_telegram_ok(self) -> None:
|
|
||||||
with self._lock:
|
|
||||||
self._telegram_last_ok = time.time()
|
|
||||||
|
|
||||||
def mark_telegram_error(self) -> None:
|
|
||||||
with self._lock:
|
|
||||||
self._telegram_last_error = time.time()
|
|
||||||
|
|
||||||
def mark_max_ok(self) -> None:
|
|
||||||
with self._lock:
|
|
||||||
self._max_last_ok = time.time()
|
|
||||||
|
|
||||||
def mark_max_error(self) -> None:
|
|
||||||
with self._lock:
|
|
||||||
self._max_last_error = time.time()
|
|
||||||
|
|
||||||
def mark_max_event(self) -> None:
|
|
||||||
with self._lock:
|
|
||||||
self._max_last_event = time.time()
|
|
||||||
|
|
||||||
def snapshot(self) -> HealthSnapshot:
|
|
||||||
now = time.time()
|
|
||||||
with self._lock:
|
|
||||||
started_at = self._started_at
|
|
||||||
unhealthy_after = self._unhealthy_after_sec
|
|
||||||
|
|
||||||
t_ok = self._telegram_last_ok
|
|
||||||
t_err = self._telegram_last_error
|
|
||||||
m_ok = self._max_last_ok
|
|
||||||
m_err = self._max_last_error
|
|
||||||
m_evt = self._max_last_event
|
|
||||||
|
|
||||||
def ago(ts: float | None) -> float | None:
|
|
||||||
if ts is None:
|
|
||||||
return None
|
|
||||||
return max(0.0, now - ts)
|
|
||||||
|
|
||||||
telegram_last_ok_ago = ago(t_ok)
|
|
||||||
max_last_ok_ago = ago(m_ok)
|
|
||||||
|
|
||||||
telegram_healthy = telegram_last_ok_ago is not None and telegram_last_ok_ago <= unhealthy_after
|
|
||||||
max_healthy = max_last_ok_ago is not None and max_last_ok_ago <= unhealthy_after
|
|
||||||
|
|
||||||
overall_healthy = telegram_healthy and max_healthy
|
|
||||||
|
|
||||||
return HealthSnapshot(
|
|
||||||
now=now,
|
|
||||||
uptime_sec=max(0.0, now - started_at),
|
|
||||||
telegram_last_ok_ago_sec=telegram_last_ok_ago,
|
|
||||||
telegram_last_error_ago_sec=ago(t_err),
|
|
||||||
max_last_ok_ago_sec=max_last_ok_ago,
|
|
||||||
max_last_error_ago_sec=ago(m_err),
|
|
||||||
max_last_event_ago_sec=ago(m_evt),
|
|
||||||
telegram_healthy=telegram_healthy,
|
|
||||||
max_healthy=max_healthy,
|
|
||||||
overall_healthy=overall_healthy,
|
|
||||||
)
|
|
||||||
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
import json
|
|
||||||
import logging
|
|
||||||
import threading
|
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from health import HealthState
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class _Handler(BaseHTTPRequestHandler):
|
|
||||||
health: HealthState
|
|
||||||
|
|
||||||
def log_message(self, format: str, *args: Any) -> None: # noqa: A003
|
|
||||||
# Убираем спам от http.server, оставляем только наши логи.
|
|
||||||
logger.debug("health_http " + format, *args)
|
|
||||||
|
|
||||||
def _send_json(self, status: int, payload: dict[str, Any]) -> None:
|
|
||||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
||||||
self.send_response(status)
|
|
||||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
||||||
self.send_header("Content-Length", str(len(body)))
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body)
|
|
||||||
|
|
||||||
def do_GET(self) -> None: # noqa: N802
|
|
||||||
if self.path in {"/livez", "/live", "/"}:
|
|
||||||
snap = self.health.snapshot()
|
|
||||||
self._send_json(
|
|
||||||
200,
|
|
||||||
{
|
|
||||||
"status": "live",
|
|
||||||
"uptime_sec": snap.uptime_sec,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.path in {"/healthz", "/health"}:
|
|
||||||
snap = self.health.snapshot()
|
|
||||||
status = 200 if snap.overall_healthy else 503
|
|
||||||
self._send_json(
|
|
||||||
status,
|
|
||||||
{
|
|
||||||
"status": "ok" if snap.overall_healthy else "unhealthy",
|
|
||||||
"telegram": {
|
|
||||||
"healthy": snap.telegram_healthy,
|
|
||||||
"last_ok_ago_sec": snap.telegram_last_ok_ago_sec,
|
|
||||||
"last_error_ago_sec": snap.telegram_last_error_ago_sec,
|
|
||||||
},
|
|
||||||
"max": {
|
|
||||||
"healthy": snap.max_healthy,
|
|
||||||
"last_ok_ago_sec": snap.max_last_ok_ago_sec,
|
|
||||||
"last_error_ago_sec": snap.max_last_error_ago_sec,
|
|
||||||
"last_event_ago_sec": snap.max_last_event_ago_sec,
|
|
||||||
},
|
|
||||||
"uptime_sec": snap.uptime_sec,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
self._send_json(404, {"error": "not_found"})
|
|
||||||
|
|
||||||
|
|
||||||
def start_health_server(*, host: str, port: int, health: HealthState) -> threading.Thread:
|
|
||||||
class Handler(_Handler):
|
|
||||||
pass
|
|
||||||
|
|
||||||
Handler.health = health
|
|
||||||
server = ThreadingHTTPServer((host, port), Handler)
|
|
||||||
|
|
||||||
def _run() -> None:
|
|
||||||
logger.info("Health server listening on %s:%s", host, port)
|
|
||||||
server.serve_forever(poll_interval=0.5)
|
|
||||||
|
|
||||||
thread = threading.Thread(target=_run, name="health-http", daemon=True)
|
|
||||||
thread.start()
|
|
||||||
return thread
|
|
||||||
|
|
||||||
-100
@@ -1,100 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from pymax import MaxClient, Message
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
from bridge import MaxToTelegramBridge
|
|
||||||
from config import load_settings
|
|
||||||
from health import HealthState
|
|
||||||
from health_web import start_health_server
|
|
||||||
from reverse_bridge import TelegramToMaxBridge
|
|
||||||
from storage import BridgeStorage
|
|
||||||
from telegram_api import TelegramClient
|
|
||||||
|
|
||||||
|
|
||||||
def _setup_logging() -> None:
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.DEBUG,
|
|
||||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_client() -> tuple[MaxClient, MaxToTelegramBridge]:
|
|
||||||
settings = load_settings()
|
|
||||||
max_client = MaxClient(
|
|
||||||
phone=settings.max_phone,
|
|
||||||
work_dir=settings.max_work_dir,
|
|
||||||
)
|
|
||||||
telegram_client = TelegramClient(
|
|
||||||
bot_token=settings.telegram_bot_token,
|
|
||||||
fallback_user_id=settings.telegram_fallback_user_id,
|
|
||||||
)
|
|
||||||
storage = BridgeStorage(settings.sqlite_path)
|
|
||||||
bridge = MaxToTelegramBridge(max_client=max_client, telegram=telegram_client, storage=storage)
|
|
||||||
return max_client, bridge
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
load_dotenv()
|
|
||||||
_setup_logging()
|
|
||||||
settings = load_settings()
|
|
||||||
max_client = MaxClient(phone=settings.max_phone, work_dir=settings.max_work_dir)
|
|
||||||
telegram_client = TelegramClient(
|
|
||||||
bot_token=settings.telegram_bot_token,
|
|
||||||
fallback_user_id=settings.telegram_fallback_user_id,
|
|
||||||
)
|
|
||||||
storage = BridgeStorage(settings.sqlite_path)
|
|
||||||
bridge = MaxToTelegramBridge(max_client=max_client, telegram=telegram_client, storage=storage)
|
|
||||||
health = HealthState(unhealthy_after_sec=15 * 60)
|
|
||||||
start_health_server(host="0.0.0.0", port=5000, health=health)
|
|
||||||
|
|
||||||
reverse_bridge = TelegramToMaxBridge(max_client=max_client, telegram=telegram_client, storage=storage, health=health)
|
|
||||||
logger = logging.getLogger("max2telegram")
|
|
||||||
reverse_bridge_task: asyncio.Task[None] | None = None
|
|
||||||
max_probe_task: asyncio.Task[None] | None = None
|
|
||||||
|
|
||||||
@max_client.on_start
|
|
||||||
async def on_start() -> None:
|
|
||||||
nonlocal reverse_bridge_task, max_probe_task
|
|
||||||
logger.info("Max client started as %s", max_client.me.id)
|
|
||||||
health.mark_max_ok()
|
|
||||||
if reverse_bridge_task is None or reverse_bridge_task.done():
|
|
||||||
reverse_bridge_task = asyncio.create_task(reverse_bridge.start(), name="reverse-bridge")
|
|
||||||
if max_probe_task is None or max_probe_task.done():
|
|
||||||
max_probe_task = asyncio.create_task(
|
|
||||||
_max_probe_loop(max_client=max_client, health=health),
|
|
||||||
name="max-probe-loop",
|
|
||||||
)
|
|
||||||
|
|
||||||
@max_client.on_message()
|
|
||||||
async def on_message(message: Message) -> None:
|
|
||||||
health.mark_max_event()
|
|
||||||
try:
|
|
||||||
await bridge.forward_message(message)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Failed to forward Max message")
|
|
||||||
|
|
||||||
asyncio.run(max_client.start())
|
|
||||||
|
|
||||||
|
|
||||||
async def _max_probe_loop(*, max_client: MaxClient, health: HealthState) -> None:
|
|
||||||
# Best-effort контроль соединения: периодически дергаем API.
|
|
||||||
# Если PyMax разорвет соединение/сломается сессия, это обычно проявится как исключение.
|
|
||||||
await asyncio.sleep(2)
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
me = getattr(max_client, "me", None)
|
|
||||||
my_id = getattr(me, "id", None)
|
|
||||||
if my_id is not None:
|
|
||||||
await max_client.get_user(user_id=my_id)
|
|
||||||
health.mark_max_ok()
|
|
||||||
except Exception:
|
|
||||||
health.mark_max_error()
|
|
||||||
logger = logging.getLogger("max2telegram")
|
|
||||||
logger.exception("Max probe failed")
|
|
||||||
await asyncio.sleep(60)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
from typing import Any
|
|
||||||
|
|
||||||
from models import ParsedMessage
|
|
||||||
|
|
||||||
|
|
||||||
def _stringify(value: Any) -> str:
|
|
||||||
if value is None:
|
|
||||||
return ""
|
|
||||||
return str(value).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _get_attr(obj: Any, names: list[str], default: Any = None) -> Any:
|
|
||||||
for name in names:
|
|
||||||
if hasattr(obj, name):
|
|
||||||
value = getattr(obj, name)
|
|
||||||
if value is not None:
|
|
||||||
return value
|
|
||||||
return default
|
|
||||||
|
|
||||||
|
|
||||||
def _as_dict(obj: Any) -> dict[str, Any]:
|
|
||||||
if isinstance(obj, dict):
|
|
||||||
return obj
|
|
||||||
if hasattr(obj, "__dict__"):
|
|
||||||
return vars(obj)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _is_image(media_type: str) -> bool:
|
|
||||||
value = media_type.lower()
|
|
||||||
return "image" in value or "photo" in value or value in {"jpg", "jpeg", "png", "webp"}
|
|
||||||
|
|
||||||
|
|
||||||
def _is_video(media_type: str) -> bool:
|
|
||||||
value = media_type.lower()
|
|
||||||
return "video" in value or value in {"mp4", "mov", "mkv", "avi"}
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_media_urls(message: Any) -> tuple[list[str], list[str]]:
|
|
||||||
image_urls: list[str] = []
|
|
||||||
video_urls: list[str] = []
|
|
||||||
|
|
||||||
# В PyMax рабочее поле для вложений обычно называется attaches.
|
|
||||||
raw_attachments = _get_attr(message, ["attaches", "attachments", "media", "files"], default=[]) or []
|
|
||||||
for item in raw_attachments:
|
|
||||||
data = _as_dict(item)
|
|
||||||
media_type = _stringify(data.get("type") or data.get("media_type") or data.get("kind"))
|
|
||||||
url = _stringify(
|
|
||||||
data.get("base_url")
|
|
||||||
or data.get("url")
|
|
||||||
or data.get("link")
|
|
||||||
or data.get("download_url")
|
|
||||||
or data.get("src")
|
|
||||||
)
|
|
||||||
|
|
||||||
if not url:
|
|
||||||
nested = data.get("file") or data.get("payload")
|
|
||||||
nested_data = _as_dict(nested)
|
|
||||||
url = _stringify(
|
|
||||||
nested_data.get("base_url")
|
|
||||||
or nested_data.get("url")
|
|
||||||
or nested_data.get("link")
|
|
||||||
or nested_data.get("download_url")
|
|
||||||
or nested_data.get("src")
|
|
||||||
)
|
|
||||||
if not media_type:
|
|
||||||
media_type = _stringify(nested_data.get("type") or nested_data.get("media_type"))
|
|
||||||
|
|
||||||
if not url:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if _is_image(media_type):
|
|
||||||
image_urls.append(url)
|
|
||||||
elif _is_video(media_type):
|
|
||||||
video_urls.append(url)
|
|
||||||
|
|
||||||
return image_urls, video_urls
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_max_reply(message: Any) -> tuple[str | None, str | None]:
|
|
||||||
"""Извлекает id сообщения MAX, на которое ответили, и короткий превью-текст (если есть)."""
|
|
||||||
link = _get_attr(message, ["link"], default=None)
|
|
||||||
if link is None:
|
|
||||||
return None, None
|
|
||||||
inner = _get_attr(link, ["message"], default=None)
|
|
||||||
if inner is None:
|
|
||||||
return None, None
|
|
||||||
mid = _stringify(_get_attr(inner, ["id", "message_id", "mid"]))
|
|
||||||
if not mid:
|
|
||||||
return None, None
|
|
||||||
preview = _stringify(_get_attr(inner, ["text", "message", "body"]))
|
|
||||||
if preview and len(preview) > 500:
|
|
||||||
preview = preview[:497] + "..."
|
|
||||||
return mid, preview or None
|
|
||||||
|
|
||||||
|
|
||||||
def parse_message(message: Any) -> ParsedMessage:
|
|
||||||
sender = _get_attr(message, ["sender", "sender_name", "author"], default="unknown")
|
|
||||||
sender_data = _as_dict(sender)
|
|
||||||
sender_name = (
|
|
||||||
_stringify(sender_data.get("nickname"))
|
|
||||||
or _stringify(sender_data.get("username"))
|
|
||||||
or _stringify(sender_data.get("name"))
|
|
||||||
or _stringify(sender)
|
|
||||||
or "unknown"
|
|
||||||
)
|
|
||||||
|
|
||||||
chat_name = (
|
|
||||||
_stringify(_get_attr(message, ["chat_title", "chat_name", "group_name"]))
|
|
||||||
or _stringify(_get_attr(message, ["chat"], default=""))
|
|
||||||
or "direct"
|
|
||||||
)
|
|
||||||
|
|
||||||
message_id = _stringify(_get_attr(message, ["id", "message_id", "mid"])) or "unknown-id"
|
|
||||||
chat_id = _stringify(_get_attr(message, ["chat_id", "dialog_id", "peer_id"])) or "unknown-chat"
|
|
||||||
text = _stringify(_get_attr(message, ["text", "message", "body"]))
|
|
||||||
|
|
||||||
image_urls, video_urls = _extract_media_urls(message)
|
|
||||||
reply_mid, reply_preview = _extract_max_reply(message)
|
|
||||||
return ParsedMessage(
|
|
||||||
message_id=message_id,
|
|
||||||
chat_id=chat_id,
|
|
||||||
sender_name=sender_name,
|
|
||||||
chat_name=chat_name,
|
|
||||||
text=text,
|
|
||||||
image_urls=image_urls,
|
|
||||||
video_urls=video_urls,
|
|
||||||
reply_to_max_message_id=reply_mid,
|
|
||||||
reply_preview_text=reply_preview,
|
|
||||||
)
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
from dataclasses import dataclass, field
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ParsedMessage:
|
|
||||||
message_id: str
|
|
||||||
chat_id: str
|
|
||||||
sender_name: str
|
|
||||||
chat_name: str
|
|
||||||
text: str
|
|
||||||
image_urls: list[str] = field(default_factory=list)
|
|
||||||
video_urls: list[str] = field(default_factory=list)
|
|
||||||
# Ответ в MAX: Message.link указывает на исходное сообщение (тред).
|
|
||||||
reply_to_max_message_id: str | None = None
|
|
||||||
reply_preview_text: str | None = None
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
git+https://github.com/MaxApiTeam/PyMax.git@dev/1.2.6
|
|
||||||
requests
|
|
||||||
python-dotenv
|
|
||||||
@@ -1,480 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from pymax import MaxClient
|
|
||||||
from pymax.files import Photo, Video
|
|
||||||
|
|
||||||
from storage import BridgeStorage
|
|
||||||
from telegram_api import TelegramClient
|
|
||||||
from health import HealthState
|
|
||||||
from telegram_max_commands import handle_control_command
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_title(value: str) -> str:
|
|
||||||
return str(value or "").strip().casefold()
|
|
||||||
|
|
||||||
|
|
||||||
def _telegram_chat_title(chat: dict[str, Any]) -> str:
|
|
||||||
# для каналов обычно есть title; для групп тоже; в крайнем случае — username
|
|
||||||
return str(chat.get("title") or chat.get("username") or "").strip()
|
|
||||||
|
|
||||||
def _format_sender_line(sender: dict[str, Any] | None) -> str:
|
|
||||||
if not isinstance(sender, dict):
|
|
||||||
return "Unknown:"
|
|
||||||
|
|
||||||
first = str(sender.get("first_name") or "").strip()
|
|
||||||
last = str(sender.get("last_name") or "").strip()
|
|
||||||
username = str(sender.get("username") or "").strip()
|
|
||||||
|
|
||||||
full_name = " ".join([p for p in (first, last) if p])
|
|
||||||
if not full_name:
|
|
||||||
full_name = "Unknown"
|
|
||||||
|
|
||||||
if username:
|
|
||||||
return f"{full_name} (@{username}):"
|
|
||||||
return f"{full_name}:"
|
|
||||||
|
|
||||||
|
|
||||||
def _format_forward_text(*, sender: dict[str, Any] | None, text: str) -> str:
|
|
||||||
header = _format_sender_line(sender)
|
|
||||||
body = str(text or "").strip()
|
|
||||||
if body:
|
|
||||||
return f"{header}\n{body}"
|
|
||||||
return header
|
|
||||||
|
|
||||||
|
|
||||||
def _is_supported_telegram_message(message: dict[str, Any]) -> bool:
|
|
||||||
# Текстовые сообщения и команды.
|
|
||||||
text = message.get("text")
|
|
||||||
if isinstance(text, str) and text.strip():
|
|
||||||
return True
|
|
||||||
|
|
||||||
photos = message.get("photo")
|
|
||||||
has_photo = isinstance(photos, list) and any(isinstance(p, dict) and p.get("file_id") for p in photos)
|
|
||||||
if has_photo:
|
|
||||||
return True
|
|
||||||
|
|
||||||
video = message.get("video")
|
|
||||||
has_video = isinstance(video, dict) and video.get("file_id")
|
|
||||||
if has_video:
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _MediaGroupBuffer:
|
|
||||||
first_seen_monotonic: float
|
|
||||||
updates: list[dict[str, Any]] = field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class TelegramToMaxBridge:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
max_client: MaxClient,
|
|
||||||
telegram: TelegramClient,
|
|
||||||
storage: BridgeStorage,
|
|
||||||
health: "HealthState | None" = None,
|
|
||||||
) -> None:
|
|
||||||
self._max_client = max_client
|
|
||||||
self._telegram = telegram
|
|
||||||
self._storage = storage
|
|
||||||
self._health = health
|
|
||||||
self._max_title_to_id: dict[str, int] = {}
|
|
||||||
|
|
||||||
self._bot_id: str | None = None
|
|
||||||
self._offset: int | None = None
|
|
||||||
|
|
||||||
self._media_groups: dict[tuple[str, str], _MediaGroupBuffer] = {}
|
|
||||||
self._media_group_grace_sec = 1.2
|
|
||||||
self._start_lock = asyncio.Lock()
|
|
||||||
self._is_running = False
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
|
||||||
async with self._start_lock:
|
|
||||||
if self._is_running:
|
|
||||||
logger.warning("Telegram->Max bridge start skipped: poller is already running")
|
|
||||||
return
|
|
||||||
self._is_running = True
|
|
||||||
|
|
||||||
try:
|
|
||||||
me = await self._telegram.get_me()
|
|
||||||
self._bot_id = str(me.get("id") or "")
|
|
||||||
if not self._bot_id:
|
|
||||||
raise RuntimeError("Cannot resolve Telegram bot id (getMe)")
|
|
||||||
|
|
||||||
self._refresh_max_chat_cache()
|
|
||||||
logger.info("Telegram->Max bridge started (bot_id=%s)", self._bot_id)
|
|
||||||
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
updates = await self._telegram.get_updates(offset=self._offset, timeout=25, limit=100)
|
|
||||||
if self._health:
|
|
||||||
self._health.mark_telegram_ok()
|
|
||||||
await self._handle_updates(updates)
|
|
||||||
except Exception:
|
|
||||||
if self._health:
|
|
||||||
self._health.mark_telegram_error()
|
|
||||||
logger.exception("Telegram polling loop error")
|
|
||||||
# 409 Conflict: где-то еще идет getUpdates (другой инстанс или webhook/второй poller).
|
|
||||||
# Делаем backoff, чтобы не долбить API.
|
|
||||||
await asyncio.sleep(10)
|
|
||||||
finally:
|
|
||||||
async with self._start_lock:
|
|
||||||
self._is_running = False
|
|
||||||
|
|
||||||
async def _handle_updates(self, updates: list[dict[str, Any]]) -> None:
|
|
||||||
max_update_id = None
|
|
||||||
for upd in updates:
|
|
||||||
upd_id = upd.get("update_id")
|
|
||||||
if isinstance(upd_id, int):
|
|
||||||
max_update_id = upd_id if max_update_id is None else max(max_update_id, upd_id)
|
|
||||||
|
|
||||||
message = None
|
|
||||||
for container in ("message", "channel_post"):
|
|
||||||
candidate = upd.get(container)
|
|
||||||
if isinstance(candidate, dict):
|
|
||||||
message = candidate
|
|
||||||
break
|
|
||||||
if not message:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not _is_supported_telegram_message(message):
|
|
||||||
continue
|
|
||||||
|
|
||||||
if self._is_own_telegram_message(message):
|
|
||||||
continue
|
|
||||||
|
|
||||||
await self._handle_message(message)
|
|
||||||
|
|
||||||
if max_update_id is not None:
|
|
||||||
self._offset = max_update_id + 1
|
|
||||||
|
|
||||||
await self._flush_ready_media_groups()
|
|
||||||
|
|
||||||
def _is_own_telegram_message(self, message: dict[str, Any]) -> bool:
|
|
||||||
sender = message.get("from")
|
|
||||||
if isinstance(sender, dict):
|
|
||||||
if sender.get("is_bot") is True:
|
|
||||||
# важно: не уйти в цикл на собственных постах бота
|
|
||||||
return True
|
|
||||||
if self._bot_id and str(sender.get("id") or "") == self._bot_id:
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def _handle_message(self, message: dict[str, Any]) -> None:
|
|
||||||
chat = message.get("chat")
|
|
||||||
if not isinstance(chat, dict):
|
|
||||||
return
|
|
||||||
|
|
||||||
# Команда привязки чата Telegram к названию чата в MAX (для Max->Telegram маршрутизации).
|
|
||||||
# Обрабатываем раньше control-команд, чтобы /bind_max не попадала как "неизвестная".
|
|
||||||
text = str(message.get("text") or "").strip()
|
|
||||||
cmd = text.split(maxsplit=1)[0].split("@", 1)[0].strip().casefold() if text else ""
|
|
||||||
if cmd == "/bind_max":
|
|
||||||
await self._handle_bind_max_command(message, chat)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Управление MAX через Telegram: только личка боту и только от fallback_user_id.
|
|
||||||
# В этом случае команду не пересылаем в MAX.
|
|
||||||
try:
|
|
||||||
reply = await handle_control_command(message, max_client=self._max_client, telegram=self._telegram)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Telegram MAX control command handler failed")
|
|
||||||
reply = None
|
|
||||||
if reply is not None:
|
|
||||||
chat_id = str(chat.get("id") or "")
|
|
||||||
if chat_id:
|
|
||||||
try:
|
|
||||||
await self._telegram.send_text(chat_id=chat_id, text=reply)
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Cannot send Telegram reply for control command")
|
|
||||||
return
|
|
||||||
|
|
||||||
chat_title = _telegram_chat_title(chat)
|
|
||||||
normalized = _normalize_title(chat_title)
|
|
||||||
if not normalized:
|
|
||||||
logger.error("Telegram chat without title/username, skip (chat=%s)", chat)
|
|
||||||
return
|
|
||||||
|
|
||||||
max_chat_id = self._resolve_max_chat_id_by_title(normalized)
|
|
||||||
if max_chat_id is None:
|
|
||||||
# требование: если в MAX нет канала/группы — ошибка и не пересылать
|
|
||||||
logger.error("MAX чат с названием '%s' не найден — сообщение не пересылаю", chat_title)
|
|
||||||
return
|
|
||||||
|
|
||||||
telegram_chat_id = str(chat.get("id"))
|
|
||||||
telegram_message_id = str(message.get("message_id"))
|
|
||||||
|
|
||||||
media_group_id = message.get("media_group_id")
|
|
||||||
if media_group_id is not None:
|
|
||||||
key = (telegram_chat_id, str(media_group_id))
|
|
||||||
buf = self._media_groups.get(key)
|
|
||||||
if buf is None:
|
|
||||||
buf = _MediaGroupBuffer(first_seen_monotonic=time.monotonic())
|
|
||||||
self._media_groups[key] = buf
|
|
||||||
buf.updates.append(message)
|
|
||||||
return
|
|
||||||
|
|
||||||
await self._forward_single_message(
|
|
||||||
max_chat_id=max_chat_id,
|
|
||||||
telegram_chat_id=telegram_chat_id,
|
|
||||||
telegram_message_id=telegram_message_id,
|
|
||||||
message=message,
|
|
||||||
media_group_id=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _handle_bind_max_command(self, message: dict[str, Any], chat: dict[str, Any]) -> None:
|
|
||||||
raw = str(message.get("text") or "").strip()
|
|
||||||
chat_id = str(chat.get("id") or "")
|
|
||||||
# формат: /bind_max <точное название чата в MAX>
|
|
||||||
parts = raw.split(maxsplit=1)
|
|
||||||
if len(parts) < 2 or not parts[1].strip():
|
|
||||||
logger.error("bind_max: missing MAX chat title. Use: /bind_max <MAX chat title>")
|
|
||||||
if chat_id:
|
|
||||||
await self._telegram.send_text(chat_id=chat_id, text="Использование: /bind_max <точное название чата в MAX>")
|
|
||||||
return
|
|
||||||
|
|
||||||
max_title = parts[1].strip()
|
|
||||||
norm = _normalize_title(max_title)
|
|
||||||
|
|
||||||
# проверяем, что чат существует в MAX
|
|
||||||
max_chat_id = self._resolve_max_chat_id_by_title(norm)
|
|
||||||
if max_chat_id is None:
|
|
||||||
logger.error("bind_max: MAX чат '%s' не найден — привязку не сохраняю", max_title)
|
|
||||||
if chat_id:
|
|
||||||
await self._telegram.send_text(chat_id=chat_id, text=f"MAX чат '{max_title}' не найден. Привязка не сохранена.")
|
|
||||||
return
|
|
||||||
|
|
||||||
telegram_chat_id = chat_id
|
|
||||||
telegram_title = _telegram_chat_title(chat)
|
|
||||||
self._storage.set_chat_route(
|
|
||||||
max_chat_title_norm=norm,
|
|
||||||
telegram_chat_id=telegram_chat_id,
|
|
||||||
telegram_chat_title=telegram_title,
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
"bind_max: bound MAX '%s' -> Telegram '%s' (%s)",
|
|
||||||
max_title,
|
|
||||||
telegram_title,
|
|
||||||
telegram_chat_id,
|
|
||||||
)
|
|
||||||
if chat_id:
|
|
||||||
await self._telegram.send_text(
|
|
||||||
chat_id=chat_id,
|
|
||||||
text=f"Канал успешно привязан: Telegram '{telegram_title or telegram_chat_id}' -> MAX '{max_title}'.",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _flush_ready_media_groups(self) -> None:
|
|
||||||
now = time.monotonic()
|
|
||||||
ready: list[tuple[tuple[str, str], _MediaGroupBuffer]] = []
|
|
||||||
for key, buf in self._media_groups.items():
|
|
||||||
if (now - buf.first_seen_monotonic) >= self._media_group_grace_sec:
|
|
||||||
ready.append((key, buf))
|
|
||||||
|
|
||||||
for key, buf in ready:
|
|
||||||
self._media_groups.pop(key, None)
|
|
||||||
telegram_chat_id, media_group_id = key
|
|
||||||
# сообщения альбома приходят отдельно; отправляем в MAX одним сообщением с несколькими attachments
|
|
||||||
await self._forward_media_group(
|
|
||||||
telegram_chat_id=telegram_chat_id,
|
|
||||||
media_group_id=media_group_id,
|
|
||||||
messages=buf.updates,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _forward_media_group(self, *, telegram_chat_id: str, media_group_id: str, messages: list[dict[str, Any]]) -> None:
|
|
||||||
if not messages:
|
|
||||||
return
|
|
||||||
|
|
||||||
chat = messages[0].get("chat")
|
|
||||||
if not isinstance(chat, dict):
|
|
||||||
return
|
|
||||||
chat_title = _telegram_chat_title(chat)
|
|
||||||
normalized = _normalize_title(chat_title)
|
|
||||||
max_chat_id = self._resolve_max_chat_id_by_title(normalized)
|
|
||||||
if max_chat_id is None:
|
|
||||||
logger.error("MAX чат с названием '%s' не найден — media group не пересылаю", chat_title)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Telegram может прислать несколько элементов с caption только на первом. Берём text/caption с первого, где он есть.
|
|
||||||
raw_text = ""
|
|
||||||
for m in messages:
|
|
||||||
cand = str(m.get("text") or m.get("caption") or "").strip()
|
|
||||||
if cand:
|
|
||||||
raw_text = cand
|
|
||||||
break
|
|
||||||
text = _format_forward_text(sender=messages[0].get("from"), text=raw_text)
|
|
||||||
|
|
||||||
reply_to = self._resolve_reply_to_max_id(max_chat_id=max_chat_id, message=messages[0])
|
|
||||||
|
|
||||||
attachments: list[Any] = []
|
|
||||||
for m in messages:
|
|
||||||
attachments.extend(await self._extract_attachments(m))
|
|
||||||
|
|
||||||
if not text.strip() and not attachments:
|
|
||||||
return
|
|
||||||
|
|
||||||
sent = await self._max_client.send_message(
|
|
||||||
chat_id=max_chat_id,
|
|
||||||
text=text,
|
|
||||||
attachments=attachments if attachments else None,
|
|
||||||
reply_to=reply_to,
|
|
||||||
)
|
|
||||||
if not sent:
|
|
||||||
logger.error("MAX send_message returned empty for media group (chat_id=%s)", max_chat_id)
|
|
||||||
return
|
|
||||||
|
|
||||||
max_message_id = str(getattr(sent, "id", "") or "")
|
|
||||||
if not max_message_id:
|
|
||||||
logger.error("Cannot resolve MAX message id after sending media group (chat_id=%s)", max_chat_id)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Реакция на каждое сообщение альбома в Telegram — best effort.
|
|
||||||
for m in messages:
|
|
||||||
mid = m.get("message_id")
|
|
||||||
if mid is None:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
await self._telegram.add_reaction(chat_id=telegram_chat_id, message_id=str(mid), emoji="🦄")
|
|
||||||
except Exception:
|
|
||||||
logger.debug("Cannot add Telegram reaction", exc_info=True)
|
|
||||||
|
|
||||||
for m in messages:
|
|
||||||
tid = str(m.get("message_id"))
|
|
||||||
if tid:
|
|
||||||
self._storage.save_mapping(
|
|
||||||
telegram_chat_id=telegram_chat_id,
|
|
||||||
telegram_message_id=tid,
|
|
||||||
max_chat_id=str(max_chat_id),
|
|
||||||
max_message_id=max_message_id,
|
|
||||||
media_group_id=media_group_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Forwarded Telegram media group %s (count=%s) -> MAX %s/%s",
|
|
||||||
media_group_id,
|
|
||||||
len(messages),
|
|
||||||
max_chat_id,
|
|
||||||
max_message_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _forward_single_message(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
max_chat_id: int,
|
|
||||||
telegram_chat_id: str,
|
|
||||||
telegram_message_id: str,
|
|
||||||
message: dict[str, Any],
|
|
||||||
media_group_id: str | None,
|
|
||||||
) -> None:
|
|
||||||
raw_text = str(message.get("text") or message.get("caption") or "").strip()
|
|
||||||
text = _format_forward_text(sender=message.get("from"), text=raw_text)
|
|
||||||
attachments = await self._extract_attachments(message)
|
|
||||||
if not text.strip() and not attachments:
|
|
||||||
return
|
|
||||||
|
|
||||||
reply_to = self._resolve_reply_to_max_id(max_chat_id=max_chat_id, message=message)
|
|
||||||
|
|
||||||
sent = await self._max_client.send_message(
|
|
||||||
chat_id=max_chat_id,
|
|
||||||
text=text,
|
|
||||||
attachments=attachments if attachments else None,
|
|
||||||
reply_to=reply_to,
|
|
||||||
)
|
|
||||||
if not sent:
|
|
||||||
logger.error("MAX send_message returned empty (chat_id=%s)", max_chat_id)
|
|
||||||
return
|
|
||||||
|
|
||||||
max_message_id = str(getattr(sent, "id", "") or "")
|
|
||||||
if not max_message_id:
|
|
||||||
logger.error("Cannot resolve MAX message id after sending (chat_id=%s)", max_chat_id)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Реакция в Telegram: best effort.
|
|
||||||
try:
|
|
||||||
await self._telegram.add_reaction(chat_id=telegram_chat_id, message_id=telegram_message_id, emoji="🦄")
|
|
||||||
except Exception:
|
|
||||||
logger.debug("Cannot add Telegram reaction", exc_info=True)
|
|
||||||
|
|
||||||
self._storage.save_mapping(
|
|
||||||
telegram_chat_id=telegram_chat_id,
|
|
||||||
telegram_message_id=telegram_message_id,
|
|
||||||
max_chat_id=str(max_chat_id),
|
|
||||||
max_message_id=max_message_id,
|
|
||||||
media_group_id=media_group_id,
|
|
||||||
)
|
|
||||||
logger.info("Forwarded Telegram %s/%s -> MAX %s/%s", telegram_chat_id, telegram_message_id, max_chat_id, max_message_id)
|
|
||||||
|
|
||||||
def _resolve_reply_to_max_id(self, *, max_chat_id: int, message: dict[str, Any]) -> str | None:
|
|
||||||
reply = message.get("reply_to_message")
|
|
||||||
if not isinstance(reply, dict):
|
|
||||||
return None
|
|
||||||
reply_mid = reply.get("message_id")
|
|
||||||
if reply_mid is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
chat = message.get("chat")
|
|
||||||
if not isinstance(chat, dict):
|
|
||||||
return None
|
|
||||||
telegram_chat_id = str(chat.get("id"))
|
|
||||||
mapped = self._storage.get_max_message_id_for_telegram(
|
|
||||||
telegram_chat_id=telegram_chat_id,
|
|
||||||
telegram_message_id=str(reply_mid),
|
|
||||||
)
|
|
||||||
# reply_to в MAX — это id сообщения; если не нашли, просто отправляем без reply
|
|
||||||
return mapped
|
|
||||||
|
|
||||||
async def _extract_attachments(self, message: dict[str, Any]) -> list[Any]:
|
|
||||||
attachments: list[Any] = []
|
|
||||||
|
|
||||||
# photo: массив размеров, берём последний (самый большой)
|
|
||||||
photos = message.get("photo")
|
|
||||||
if isinstance(photos, list) and photos:
|
|
||||||
best = None
|
|
||||||
for p in photos:
|
|
||||||
if isinstance(p, dict) and p.get("file_id"):
|
|
||||||
best = p
|
|
||||||
if best and isinstance(best, dict):
|
|
||||||
file_id = str(best.get("file_id"))
|
|
||||||
if file_id:
|
|
||||||
try:
|
|
||||||
url = await self._telegram.get_file_url(file_id)
|
|
||||||
attachments.append(Photo(url=url))
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Cannot fetch Telegram photo URL")
|
|
||||||
|
|
||||||
video = message.get("video")
|
|
||||||
if isinstance(video, dict) and video.get("file_id"):
|
|
||||||
file_id = str(video.get("file_id"))
|
|
||||||
if file_id:
|
|
||||||
try:
|
|
||||||
url = await self._telegram.get_file_url(file_id)
|
|
||||||
attachments.append(Video(url=url))
|
|
||||||
except Exception:
|
|
||||||
logger.exception("Cannot fetch Telegram video URL")
|
|
||||||
|
|
||||||
return attachments
|
|
||||||
|
|
||||||
def _refresh_max_chat_cache(self) -> None:
|
|
||||||
title_to_id: dict[str, int] = {}
|
|
||||||
for chat in getattr(self._max_client, "chats", []) or []:
|
|
||||||
title = getattr(chat, "title", None)
|
|
||||||
chat_id = getattr(chat, "id", None)
|
|
||||||
if title and chat_id is not None:
|
|
||||||
title_to_id[_normalize_title(str(title))] = int(chat_id)
|
|
||||||
self._max_title_to_id = title_to_id
|
|
||||||
|
|
||||||
def _resolve_max_chat_id_by_title(self, normalized_title: str) -> int | None:
|
|
||||||
chat_id = self._max_title_to_id.get(normalized_title)
|
|
||||||
if chat_id is not None:
|
|
||||||
return chat_id
|
|
||||||
# на всякий случай обновим кэш (например, если добавили чат во время работы)
|
|
||||||
self._refresh_max_chat_cache()
|
|
||||||
return self._max_title_to_id.get(normalized_title)
|
|
||||||
|
|
||||||
-152
@@ -1,152 +0,0 @@
|
|||||||
import sqlite3
|
|
||||||
from contextlib import closing
|
|
||||||
|
|
||||||
|
|
||||||
class BridgeStorage:
|
|
||||||
def __init__(self, db_path: str) -> None:
|
|
||||||
self._db_path = db_path
|
|
||||||
self._init_db()
|
|
||||||
|
|
||||||
def _connect(self) -> sqlite3.Connection:
|
|
||||||
return sqlite3.connect(self._db_path)
|
|
||||||
|
|
||||||
def _init_db(self) -> None:
|
|
||||||
with closing(self._connect()) as conn:
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS forwarded_messages (
|
|
||||||
message_id TEXT NOT NULL,
|
|
||||||
chat_id TEXT NOT NULL,
|
|
||||||
forwarded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (message_id, chat_id)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS message_mapping (
|
|
||||||
telegram_chat_id TEXT NOT NULL,
|
|
||||||
telegram_message_id TEXT NOT NULL,
|
|
||||||
max_chat_id TEXT NOT NULL,
|
|
||||||
max_message_id TEXT NOT NULL,
|
|
||||||
media_group_id TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
PRIMARY KEY (telegram_chat_id, telegram_message_id)
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_message_mapping_max ON message_mapping (max_chat_id, max_message_id)"
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"CREATE INDEX IF NOT EXISTS idx_message_mapping_media_group ON message_mapping (telegram_chat_id, media_group_id)"
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
CREATE TABLE IF NOT EXISTS chat_routes (
|
|
||||||
max_chat_title_norm TEXT PRIMARY KEY,
|
|
||||||
telegram_chat_id TEXT NOT NULL,
|
|
||||||
telegram_chat_title TEXT,
|
|
||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
||||||
)
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
def was_forwarded(self, message_id: str, chat_id: str) -> bool:
|
|
||||||
with closing(self._connect()) as conn:
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT 1 FROM forwarded_messages WHERE message_id = ? AND chat_id = ?",
|
|
||||||
(message_id, chat_id),
|
|
||||||
).fetchone()
|
|
||||||
return row is not None
|
|
||||||
|
|
||||||
def mark_forwarded(self, message_id: str, chat_id: str) -> None:
|
|
||||||
with closing(self._connect()) as conn:
|
|
||||||
conn.execute(
|
|
||||||
"INSERT OR IGNORE INTO forwarded_messages (message_id, chat_id) VALUES (?, ?)",
|
|
||||||
(message_id, chat_id),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
def save_mapping(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
telegram_chat_id: str,
|
|
||||||
telegram_message_id: str,
|
|
||||||
max_chat_id: str,
|
|
||||||
max_message_id: str,
|
|
||||||
media_group_id: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
with closing(self._connect()) as conn:
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT OR REPLACE INTO message_mapping
|
|
||||||
(telegram_chat_id, telegram_message_id, max_chat_id, max_message_id, media_group_id)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
""",
|
|
||||||
(telegram_chat_id, telegram_message_id, max_chat_id, max_message_id, media_group_id),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
def get_max_message_id_for_telegram(
|
|
||||||
self, *, telegram_chat_id: str, telegram_message_id: str
|
|
||||||
) -> str | None:
|
|
||||||
with closing(self._connect()) as conn:
|
|
||||||
row = conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT max_message_id
|
|
||||||
FROM message_mapping
|
|
||||||
WHERE telegram_chat_id = ? AND telegram_message_id = ?
|
|
||||||
""",
|
|
||||||
(telegram_chat_id, telegram_message_id),
|
|
||||||
).fetchone()
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
return str(row[0])
|
|
||||||
|
|
||||||
def get_telegram_message_id_for_max(
|
|
||||||
self, *, telegram_chat_id: str, max_chat_id: str, max_message_id: str
|
|
||||||
) -> str | None:
|
|
||||||
"""Обратный поиск: какое сообщение в Telegram соответствует паре MAX chat/message."""
|
|
||||||
with closing(self._connect()) as conn:
|
|
||||||
row = conn.execute(
|
|
||||||
"""
|
|
||||||
SELECT telegram_message_id
|
|
||||||
FROM message_mapping
|
|
||||||
WHERE telegram_chat_id = ? AND max_chat_id = ? AND max_message_id = ?
|
|
||||||
ORDER BY telegram_message_id
|
|
||||||
LIMIT 1
|
|
||||||
""",
|
|
||||||
(telegram_chat_id, max_chat_id, max_message_id),
|
|
||||||
).fetchone()
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
return str(row[0])
|
|
||||||
|
|
||||||
def set_chat_route(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
max_chat_title_norm: str,
|
|
||||||
telegram_chat_id: str,
|
|
||||||
telegram_chat_title: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
with closing(self._connect()) as conn:
|
|
||||||
conn.execute(
|
|
||||||
"""
|
|
||||||
INSERT OR REPLACE INTO chat_routes (max_chat_title_norm, telegram_chat_id, telegram_chat_title)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
""",
|
|
||||||
(max_chat_title_norm, telegram_chat_id, telegram_chat_title),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
def get_chat_route(self, *, max_chat_title_norm: str) -> str | None:
|
|
||||||
with closing(self._connect()) as conn:
|
|
||||||
row = conn.execute(
|
|
||||||
"SELECT telegram_chat_id FROM chat_routes WHERE max_chat_title_norm = ?",
|
|
||||||
(max_chat_title_norm,),
|
|
||||||
).fetchone()
|
|
||||||
if not row:
|
|
||||||
return None
|
|
||||||
return str(row[0])
|
|
||||||
@@ -1,267 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import json
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
|
|
||||||
class TelegramApiError(RuntimeError):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
message: str,
|
|
||||||
*,
|
|
||||||
method: str | None = None,
|
|
||||||
status_code: int | None = None,
|
|
||||||
error_code: int | None = None,
|
|
||||||
description: str | None = None,
|
|
||||||
parameters: dict[str, Any] | None = None,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(message)
|
|
||||||
self.method = method
|
|
||||||
self.status_code = status_code
|
|
||||||
self.error_code = error_code
|
|
||||||
self.description = description
|
|
||||||
self.parameters = parameters or {}
|
|
||||||
|
|
||||||
@property
|
|
||||||
def migrate_to_chat_id(self) -> str | None:
|
|
||||||
value = self.parameters.get("migrate_to_chat_id")
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
|
|
||||||
class TelegramClient:
|
|
||||||
def __init__(self, bot_token: str, fallback_user_id: str, timeout: int = 30) -> None:
|
|
||||||
self._base_url = f"https://api.telegram.org/bot{bot_token}"
|
|
||||||
self._file_base_url = f"https://api.telegram.org/file/bot{bot_token}"
|
|
||||||
self._fallback_user_id = fallback_user_id
|
|
||||||
self._timeout = timeout
|
|
||||||
self._chat_title_to_id: dict[str, str] = {}
|
|
||||||
self._me: dict[str, Any] | None = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def fallback_user_id(self) -> str:
|
|
||||||
return str(self._fallback_user_id or "")
|
|
||||||
|
|
||||||
async def resolve_target_chat_id(self, max_chat_name: str) -> tuple[str, bool]:
|
|
||||||
chat_id = await self._find_chat_id_by_title(max_chat_name)
|
|
||||||
if chat_id:
|
|
||||||
return chat_id, True
|
|
||||||
return self._fallback_user_id, False
|
|
||||||
|
|
||||||
async def send_text(
|
|
||||||
self,
|
|
||||||
chat_id: str,
|
|
||||||
text: str,
|
|
||||||
*,
|
|
||||||
reply_to_message_id: int | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
payload: dict[str, Any] = {
|
|
||||||
"chat_id": chat_id,
|
|
||||||
"text": text,
|
|
||||||
"disable_web_page_preview": True,
|
|
||||||
}
|
|
||||||
if reply_to_message_id is not None:
|
|
||||||
payload["reply_to_message_id"] = reply_to_message_id
|
|
||||||
return await self._request("sendMessage", payload)
|
|
||||||
|
|
||||||
async def send_photo(
|
|
||||||
self,
|
|
||||||
chat_id: str,
|
|
||||||
photo_url: str,
|
|
||||||
caption: str | None = None,
|
|
||||||
*,
|
|
||||||
reply_to_message_id: int | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
payload: dict[str, Any] = {
|
|
||||||
"chat_id": chat_id,
|
|
||||||
"photo": photo_url,
|
|
||||||
}
|
|
||||||
if caption:
|
|
||||||
payload["caption"] = caption
|
|
||||||
if reply_to_message_id is not None:
|
|
||||||
payload["reply_to_message_id"] = reply_to_message_id
|
|
||||||
return await self._request("sendPhoto", payload)
|
|
||||||
|
|
||||||
async def send_video(
|
|
||||||
self,
|
|
||||||
chat_id: str,
|
|
||||||
video_url: str,
|
|
||||||
caption: str | None = None,
|
|
||||||
*,
|
|
||||||
reply_to_message_id: int | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
payload: dict[str, Any] = {
|
|
||||||
"chat_id": chat_id,
|
|
||||||
"video": video_url,
|
|
||||||
"supports_streaming": True,
|
|
||||||
}
|
|
||||||
if caption:
|
|
||||||
payload["caption"] = caption
|
|
||||||
if reply_to_message_id is not None:
|
|
||||||
payload["reply_to_message_id"] = reply_to_message_id
|
|
||||||
return await self._request("sendVideo", payload)
|
|
||||||
|
|
||||||
async def send_media_group(
|
|
||||||
self,
|
|
||||||
chat_id: str,
|
|
||||||
image_urls: list[str],
|
|
||||||
video_urls: list[str],
|
|
||||||
caption: str | None = None,
|
|
||||||
*,
|
|
||||||
reply_to_message_id: int | None = None,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
media: list[dict[str, Any]] = []
|
|
||||||
|
|
||||||
for url in image_urls:
|
|
||||||
media.append({"type": "photo", "media": url})
|
|
||||||
|
|
||||||
for url in video_urls:
|
|
||||||
media.append({"type": "video", "media": url, "supports_streaming": True})
|
|
||||||
|
|
||||||
if not media:
|
|
||||||
return []
|
|
||||||
|
|
||||||
if caption:
|
|
||||||
media[0]["caption"] = caption
|
|
||||||
|
|
||||||
mg_payload: dict[str, Any] = {
|
|
||||||
"chat_id": chat_id,
|
|
||||||
"media": media,
|
|
||||||
}
|
|
||||||
if reply_to_message_id is not None:
|
|
||||||
mg_payload["reply_to_message_id"] = reply_to_message_id
|
|
||||||
data = await self._request(
|
|
||||||
"sendMediaGroup",
|
|
||||||
mg_payload,
|
|
||||||
)
|
|
||||||
result = data.get("result", [])
|
|
||||||
if not isinstance(result, list):
|
|
||||||
return []
|
|
||||||
return [m for m in result if isinstance(m, dict)]
|
|
||||||
|
|
||||||
async def get_me(self) -> dict[str, Any]:
|
|
||||||
if self._me is not None:
|
|
||||||
return self._me
|
|
||||||
data = await self._request("getMe", {})
|
|
||||||
me = data.get("result")
|
|
||||||
if not isinstance(me, dict):
|
|
||||||
raise TelegramApiError(f"Telegram getMe: unexpected payload {data}")
|
|
||||||
self._me = me
|
|
||||||
return me
|
|
||||||
|
|
||||||
async def get_updates(self, *, offset: int | None, timeout: int = 25, limit: int = 100) -> list[dict[str, Any]]:
|
|
||||||
payload: dict[str, Any] = {
|
|
||||||
"timeout": timeout,
|
|
||||||
"limit": limit,
|
|
||||||
# Реагируем только на новые сообщения/посты (текст, фото, видео).
|
|
||||||
"allowed_updates": ["message", "channel_post"],
|
|
||||||
}
|
|
||||||
if offset is not None:
|
|
||||||
payload["offset"] = offset
|
|
||||||
data = await self._request("getUpdates", payload)
|
|
||||||
result = data.get("result", [])
|
|
||||||
if not isinstance(result, list):
|
|
||||||
return []
|
|
||||||
updates = [u for u in result if isinstance(u, dict)]
|
|
||||||
# Важно: не делаем getUpdates нигде больше (иначе 409 Conflict).
|
|
||||||
# Наполняем кэш чатов только из этого потока.
|
|
||||||
for upd in updates:
|
|
||||||
for container in ("message", "channel_post"):
|
|
||||||
msg = upd.get(container)
|
|
||||||
if not isinstance(msg, dict):
|
|
||||||
continue
|
|
||||||
chat = msg.get("chat")
|
|
||||||
if not isinstance(chat, dict):
|
|
||||||
continue
|
|
||||||
self._cache_chat(chat)
|
|
||||||
return updates
|
|
||||||
|
|
||||||
async def get_file_url(self, file_id: str) -> str:
|
|
||||||
data = await self._request("getFile", {"file_id": file_id})
|
|
||||||
result = data.get("result")
|
|
||||||
if not isinstance(result, dict):
|
|
||||||
raise TelegramApiError(f"Telegram getFile: unexpected payload {data}")
|
|
||||||
file_path = result.get("file_path")
|
|
||||||
if not isinstance(file_path, str) or not file_path.strip():
|
|
||||||
raise TelegramApiError(f"Telegram getFile: missing file_path {data}")
|
|
||||||
return f"{self._file_base_url}/{file_path}"
|
|
||||||
|
|
||||||
async def add_reaction(self, *, chat_id: str, message_id: str, emoji: str) -> None:
|
|
||||||
# setMessageReaction доступен не везде/не всегда; ошибки реакции не должны ломать бридж
|
|
||||||
await self._request(
|
|
||||||
"setMessageReaction",
|
|
||||||
{
|
|
||||||
"chat_id": chat_id,
|
|
||||||
"message_id": int(message_id),
|
|
||||||
"reaction": [{"type": "emoji", "emoji": emoji}],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
def _cache_chat(self, chat: dict[str, Any]) -> None:
|
|
||||||
title_value = self._extract_chat_title(chat)
|
|
||||||
chat_id = chat.get("id")
|
|
||||||
if title_value and chat_id is not None:
|
|
||||||
self._chat_title_to_id[self._normalize_title(title_value)] = str(chat_id)
|
|
||||||
|
|
||||||
async def _find_chat_id_by_title(self, chat_title: str) -> str | None:
|
|
||||||
normalized = self._normalize_title(chat_title)
|
|
||||||
if not normalized:
|
|
||||||
return None
|
|
||||||
|
|
||||||
cached = self._chat_title_to_id.get(normalized)
|
|
||||||
if cached:
|
|
||||||
return cached
|
|
||||||
# Не дергаем getUpdates здесь — это вызовет конфликт с polling циклом.
|
|
||||||
return None
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _extract_chat_title(chat: dict[str, Any]) -> str:
|
|
||||||
return str(chat.get("title") or chat.get("username") or "").strip()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_title(value: str) -> str:
|
|
||||||
return value.strip().casefold()
|
|
||||||
|
|
||||||
async def _request(self, method: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
url = f"{self._base_url}/{method}"
|
|
||||||
|
|
||||||
def _do_request() -> requests.Response:
|
|
||||||
return requests.post(url, json=payload, timeout=self._timeout)
|
|
||||||
|
|
||||||
response = await asyncio.to_thread(_do_request)
|
|
||||||
if response.status_code >= 400:
|
|
||||||
error_code: int | None = None
|
|
||||||
description: str | None = None
|
|
||||||
parameters: dict[str, Any] = {}
|
|
||||||
try:
|
|
||||||
payload_data = response.json()
|
|
||||||
if isinstance(payload_data, dict):
|
|
||||||
if isinstance(payload_data.get("error_code"), int):
|
|
||||||
error_code = payload_data.get("error_code")
|
|
||||||
if isinstance(payload_data.get("description"), str):
|
|
||||||
description = payload_data.get("description")
|
|
||||||
if isinstance(payload_data.get("parameters"), dict):
|
|
||||||
parameters = payload_data.get("parameters", {})
|
|
||||||
except (json.JSONDecodeError, ValueError):
|
|
||||||
payload_data = None
|
|
||||||
|
|
||||||
raise TelegramApiError(
|
|
||||||
f"Telegram HTTP error on {method}: {response.status_code} {response.text}",
|
|
||||||
method=method,
|
|
||||||
status_code=response.status_code,
|
|
||||||
error_code=error_code,
|
|
||||||
description=description,
|
|
||||||
parameters=parameters,
|
|
||||||
)
|
|
||||||
data = response.json()
|
|
||||||
if not data.get("ok"):
|
|
||||||
raise TelegramApiError(
|
|
||||||
f"Telegram API error on {method}: {data}",
|
|
||||||
method=method,
|
|
||||||
error_code=data.get("error_code") if isinstance(data.get("error_code"), int) else None,
|
|
||||||
description=data.get("description") if isinstance(data.get("description"), str) else None,
|
|
||||||
parameters=data.get("parameters") if isinstance(data.get("parameters"), dict) else None,
|
|
||||||
)
|
|
||||||
return data
|
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
import logging
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from pymax import MaxClient
|
|
||||||
|
|
||||||
from telegram_api import TelegramClient
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_private_chat(message: dict[str, Any]) -> bool:
|
|
||||||
chat = message.get("chat")
|
|
||||||
if not isinstance(chat, dict):
|
|
||||||
return False
|
|
||||||
return str(chat.get("type") or "").strip().casefold() == "private"
|
|
||||||
|
|
||||||
|
|
||||||
def _sender_id(message: dict[str, Any]) -> str:
|
|
||||||
sender = message.get("from")
|
|
||||||
if not isinstance(sender, dict):
|
|
||||||
return ""
|
|
||||||
return str(sender.get("id") or "")
|
|
||||||
|
|
||||||
|
|
||||||
def _chat_id(message: dict[str, Any]) -> str:
|
|
||||||
chat = message.get("chat")
|
|
||||||
if not isinstance(chat, dict):
|
|
||||||
return ""
|
|
||||||
return str(chat.get("id") or "")
|
|
||||||
|
|
||||||
|
|
||||||
def _infer_max_chat_type(chat_obj: Any) -> str:
|
|
||||||
"""
|
|
||||||
Возвращает один из: direct / группа / канал
|
|
||||||
Best-effort: структура объектов pymax может отличаться между версиями.
|
|
||||||
"""
|
|
||||||
# 1) Явные признаки по полям.
|
|
||||||
for name in ("type", "chat_type", "kind"):
|
|
||||||
value = getattr(chat_obj, name, None)
|
|
||||||
if value is None:
|
|
||||||
continue
|
|
||||||
v = str(value).strip().casefold()
|
|
||||||
# PyMax часто использует enum-ы вида ChatType.CHANNEL, поэтому проверяем и точные значения, и подстроки.
|
|
||||||
if v in {"channel", "канал"} or "channel" in v or "канал" in v:
|
|
||||||
return "канал"
|
|
||||||
if v in {"group", "supergroup", "группа"} or "group" in v or "груп" in v:
|
|
||||||
return "группа"
|
|
||||||
if v in {"direct", "dm", "private"} or "direct" in v or "private" in v or "dm" == v:
|
|
||||||
return "direct"
|
|
||||||
|
|
||||||
# 2) Флаги.
|
|
||||||
for true_names, mapped in (
|
|
||||||
(("is_channel", "channel"), "канал"),
|
|
||||||
(("is_group", "group"), "группа"),
|
|
||||||
(("is_direct", "direct"), "direct"),
|
|
||||||
):
|
|
||||||
for n in true_names:
|
|
||||||
try:
|
|
||||||
if getattr(chat_obj, n, False) is True:
|
|
||||||
return mapped
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 3) Эвристика по количеству участников.
|
|
||||||
for n in ("members_count", "participants", "participants_count", "member_count"):
|
|
||||||
try:
|
|
||||||
value = getattr(chat_obj, n, None)
|
|
||||||
except Exception:
|
|
||||||
value = None
|
|
||||||
if isinstance(value, int):
|
|
||||||
return "группа" if value > 2 else "direct"
|
|
||||||
|
|
||||||
return "direct"
|
|
||||||
|
|
||||||
|
|
||||||
def _max_chat_title(chat_obj: Any) -> str:
|
|
||||||
for n in ("title", "name", "chat_title"):
|
|
||||||
try:
|
|
||||||
value = getattr(chat_obj, n, None)
|
|
||||||
except Exception:
|
|
||||||
value = None
|
|
||||||
if isinstance(value, str) and value.strip():
|
|
||||||
return value.strip()
|
|
||||||
# запасной вариант
|
|
||||||
cid = getattr(chat_obj, "id", None)
|
|
||||||
return f"chat {cid}" if cid is not None else "chat"
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize(value: str) -> str:
|
|
||||||
return str(value or "").strip().casefold()
|
|
||||||
|
|
||||||
|
|
||||||
async def _refresh_chats_best_effort(max_client: MaxClient) -> None:
|
|
||||||
# group.py: fetch_chats(marker=None) заполняет max_client.chats
|
|
||||||
try:
|
|
||||||
fetch = getattr(max_client, "fetch_chats", None)
|
|
||||||
if fetch is not None:
|
|
||||||
await fetch()
|
|
||||||
except Exception:
|
|
||||||
logger.debug("MAX fetch_chats failed (non-fatal)", exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _find_chat_by_title(max_client: MaxClient, title: str) -> Any | None:
|
|
||||||
wanted = _normalize(title)
|
|
||||||
if not wanted:
|
|
||||||
return None
|
|
||||||
chats = list(getattr(max_client, "chats", []) or [])
|
|
||||||
for c in chats:
|
|
||||||
if _normalize(_max_chat_title(c)) == wanted:
|
|
||||||
return c
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
async def _join_by_link(max_client: MaxClient, link: str) -> str:
|
|
||||||
"""
|
|
||||||
PyMax: вступление в группу по ссылке — join_group(link).
|
|
||||||
См. исходники: https://github.com/MaxApiTeam/PyMax/blob/041dedeb9f9461b3360e2881a8a18a767de74871/src/pymax/mixins/group.py#L260
|
|
||||||
"""
|
|
||||||
method = getattr(max_client, "join_group", None)
|
|
||||||
if method is None:
|
|
||||||
return "Не смог присоединиться: у клиента MAX нет метода join_group(link)."
|
|
||||||
|
|
||||||
try:
|
|
||||||
await method(link)
|
|
||||||
return "Ок: вступил по ссылке."
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("MAX join_group failed")
|
|
||||||
return f"Ошибка при присоединении: {e}"
|
|
||||||
|
|
||||||
|
|
||||||
async def handle_control_command(
|
|
||||||
message: dict[str, Any],
|
|
||||||
*,
|
|
||||||
max_client: MaxClient,
|
|
||||||
telegram: TelegramClient,
|
|
||||||
) -> str | None:
|
|
||||||
"""
|
|
||||||
Возвращает текст ответа, если команда обработана.
|
|
||||||
Если None — не команда/не наш случай.
|
|
||||||
"""
|
|
||||||
text = str(message.get("text") or "").strip()
|
|
||||||
if not text.startswith("/"):
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not _is_private_chat(message):
|
|
||||||
return None
|
|
||||||
|
|
||||||
if _sender_id(message) != str(telegram.fallback_user_id):
|
|
||||||
return None
|
|
||||||
|
|
||||||
cmd, *rest = text.split(maxsplit=1)
|
|
||||||
cmd = cmd.split("@", 1)[0].strip().casefold()
|
|
||||||
arg = rest[0].strip() if rest else ""
|
|
||||||
|
|
||||||
if cmd == "/help":
|
|
||||||
return (
|
|
||||||
"Команды управления MAX (только для fallback_user_id в личке):\n"
|
|
||||||
"/help — справка\n"
|
|
||||||
"/list — список активных чатов MAX\n"
|
|
||||||
"/join <LINK> — присоединиться к группе/каналу по ссылке\n"
|
|
||||||
"/leave <НАЗВАНИЕ> — покинуть указанный канал\n"
|
|
||||||
"/last_messages <НАЗВАНИЕ> — последние 10 сообщений из канала\n"
|
|
||||||
"/bind_max <НАЗВАНИЕ> — привязать текущий Telegram-чат к чату MAX"
|
|
||||||
)
|
|
||||||
|
|
||||||
if cmd == "/list":
|
|
||||||
await _refresh_chats_best_effort(max_client)
|
|
||||||
|
|
||||||
chats = list(getattr(max_client, "chats", []) or [])
|
|
||||||
if not chats:
|
|
||||||
return "Список чатов пуст (или клиент MAX ещё не успел их загрузить)."
|
|
||||||
|
|
||||||
lines: list[str] = []
|
|
||||||
for c in chats:
|
|
||||||
title = _max_chat_title(c)
|
|
||||||
ctype = _infer_max_chat_type(c)
|
|
||||||
lines.append(f"- {title} ({ctype})")
|
|
||||||
return "Активные чаты MAX:\n" + "\n".join(lines)
|
|
||||||
|
|
||||||
if cmd == "/join":
|
|
||||||
if not arg:
|
|
||||||
return "Использование: /join <LINK>"
|
|
||||||
return await _join_by_link(max_client, arg)
|
|
||||||
|
|
||||||
if cmd == "/leave":
|
|
||||||
if not arg:
|
|
||||||
return "Использование: /leave <НАЗВАНИЕ>"
|
|
||||||
|
|
||||||
await _refresh_chats_best_effort(max_client)
|
|
||||||
chat = _find_chat_by_title(max_client, arg)
|
|
||||||
if chat is None:
|
|
||||||
return f"Чат/канал не найден в активных: {arg}"
|
|
||||||
|
|
||||||
chat_id = getattr(chat, "id", None)
|
|
||||||
if chat_id is None:
|
|
||||||
return "Не смог определить id чата."
|
|
||||||
|
|
||||||
ctype = _infer_max_chat_type(chat)
|
|
||||||
if ctype != "канал":
|
|
||||||
return f"'{_max_chat_title(chat)}' — это не канал (тип: {ctype})."
|
|
||||||
|
|
||||||
method = getattr(max_client, "leave_channel", None)
|
|
||||||
if method is None:
|
|
||||||
return "У клиента MAX нет метода leave_channel(chat_id)."
|
|
||||||
|
|
||||||
try:
|
|
||||||
await method(int(chat_id))
|
|
||||||
return f"Ок: покинул канал '{_max_chat_title(chat)}'."
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("MAX leave_channel failed")
|
|
||||||
return f"Ошибка при выходе: {e}"
|
|
||||||
|
|
||||||
if cmd == "/last_messages":
|
|
||||||
if not arg:
|
|
||||||
return "Использование: /last_messages <НАЗВАНИЕ>"
|
|
||||||
|
|
||||||
await _refresh_chats_best_effort(max_client)
|
|
||||||
chat = _find_chat_by_title(max_client, arg)
|
|
||||||
if chat is None:
|
|
||||||
return f"Канал не найден в активных: {arg}"
|
|
||||||
|
|
||||||
chat_id = getattr(chat, "id", None)
|
|
||||||
if chat_id is None:
|
|
||||||
return "Не смог определить id канала."
|
|
||||||
|
|
||||||
try:
|
|
||||||
# PyMax: MessageMixin.fetch_history(chat_id, from_time=None, forward=0, backward=200)
|
|
||||||
# https://github.com/MaxApiTeam/PyMax/blob/041dedeb9f9461b3360e2881a8a18a767de74871/src/pymax/mixins/message.py#L594
|
|
||||||
history = await max_client.fetch_history(chat_id=int(chat_id), forward=0, backward=10)
|
|
||||||
except AttributeError:
|
|
||||||
return "У клиента MAX нет метода fetch_history(chat_id, from_time=None, forward=0, backward=200)."
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("MAX fetch_history failed")
|
|
||||||
return f"Ошибка при получении истории: {e}"
|
|
||||||
|
|
||||||
messages = list(history or [])
|
|
||||||
if not messages:
|
|
||||||
return f"В канале '{_max_chat_title(chat)}' нет сообщений (или история недоступна)."
|
|
||||||
|
|
||||||
lines: list[str] = []
|
|
||||||
for m in messages[:10]:
|
|
||||||
text_value = str(getattr(m, "text", "") or "").strip()
|
|
||||||
mid = getattr(m, "id", None)
|
|
||||||
if text_value:
|
|
||||||
lines.append(f"- {mid}: {text_value}")
|
|
||||||
else:
|
|
||||||
lines.append(f"- {mid}: <без текста>")
|
|
||||||
return f"Последние сообщения из '{_max_chat_title(chat)}':\n" + "\n".join(lines)
|
|
||||||
|
|
||||||
return (
|
|
||||||
"Неизвестная команда.\n"
|
|
||||||
"Набери /help чтобы увидеть доступные команды."
|
|
||||||
)
|
|
||||||
|
|
||||||
+262
@@ -0,0 +1,262 @@
|
|||||||
|
# Техническая спецификация: max2telegram (v3.2)
|
||||||
|
|
||||||
|
## 1. Назначение продукта
|
||||||
|
|
||||||
|
**max2telegram** — асинхронный сервис-мост для двусторонней синхронизации переписки между мессенджером **MAX** и **Telegram**. Сторона Telegram представлена **одним Форум-каналом** с множеством тематических топиков.
|
||||||
|
|
||||||
|
Сервис использует **WebSocket-подключение** к API MAX для мгновенного получения событий и хранит **маркеры (курсоры) последнего обработанного сообщения** в базе данных. Это гарантирует, что ни одно сообщение не будет потеряно или обработано дважды во время перезапуска сервиса, сетевых сбоев или даунтайма. Вся маршрутизация выполняется **строго по идентификаторам**, а отправка в обе стороны регулируется асинхронными Redis-очередями для соблюдения лимитов API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Участники и роли
|
||||||
|
|
||||||
|
| Участник | Роль в системе |
|
||||||
|
|----------|----------------|
|
||||||
|
| **Аккаунт MAX** | Источник и приёмник сообщений в MAX (групповые чаты и личные диалоги). |
|
||||||
|
| **Telegram-бот** | Администратор Форум-канала; создаёт топики, читает сообщения по `thread_id`, ставит реакции, выполняет команды. |
|
||||||
|
| **Доверенный оператор (fallback)** | Получает системные уведомления об ошибках очередей; выполняет управляющие команды. **В части пересылки сообщений имеет те же права, что и любой другой участник форума.** |
|
||||||
|
| **Участники форума Telegram** | Могут писать в любые маппированные топики; все их сообщения асинхронно пересылаются в MAX. |
|
||||||
|
| **Оператор инфраструктуры** | Настраивает Docker Compose, управляет переменными окружения, уровнем логирования и контролирует логи. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Архитектура и потоки данных
|
||||||
|
|
||||||
|
### 3.1. Общая схема
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph MAX["MAX API (WebSocket)"]
|
||||||
|
MC[Чаты и ЛС MAX]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Bridge["max2telegram (Python App)"]
|
||||||
|
WS_LAYER[Слой клиента MAX]
|
||||||
|
TG_LAYER[Слой клиента Telegram]
|
||||||
|
ROUTER[Слой маршрутизации]
|
||||||
|
STATE[(Слой состояния: маркеры в SQLite)]
|
||||||
|
MAP[(Слой маппинга: ID в SQLite)]
|
||||||
|
QUEUE[(Слой очередей: Redis)]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph TG["Telegram Bot API"]
|
||||||
|
FC[Форум-канал]
|
||||||
|
TOPICS[Топики]
|
||||||
|
FB[Личка fallback]
|
||||||
|
end
|
||||||
|
|
||||||
|
MC <-->|WS Stream| WS_LAYER
|
||||||
|
WS_LAYER <--> STATE
|
||||||
|
WS_LAYER --> ROUTER
|
||||||
|
ROUTER <--> MAP
|
||||||
|
ROUTER --> QUEUE
|
||||||
|
QUEUE --> TG_LAYER
|
||||||
|
TG_LAYER --> FC
|
||||||
|
|
||||||
|
FC --> TG_LAYER --> ROUTER
|
||||||
|
ROUTER --> QUEUE
|
||||||
|
QUEUE --> WS_LAYER
|
||||||
|
WS_LAYER --> MC
|
||||||
|
|
||||||
|
FB <--> TG_LAYER
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2. Поток «MAX → Telegram» (с маркерами и очередью)
|
||||||
|
1. `Слой клиента MAX` подключается по WebSocket, запрашивает последние маркеры из `Слоя состояния`.
|
||||||
|
2. При получении нового события:
|
||||||
|
- **Дедупликация:** если `message_id` <= `last_message_id`, событие игнорируется.
|
||||||
|
- `Слой маршрутизации` проверяет маппинг в `Слое маппинга`. Если нет – запрашивает у `Слоя клиента Telegram` создание топика, сохраняет маппинг.
|
||||||
|
- Задача сериализуется и передается в `Слой очередей` (`max2tg_queue`).
|
||||||
|
- **Маркер** атомарно обновляется в `Слое состояния`.
|
||||||
|
3. `Слой клиента Telegram` (Воркер) забирает задачу, соблюдает лимит (≥ 3.5 сек), отправляет сообщение и сохраняет связь `max_message_id` ↔ `tg_message_id` в БД.
|
||||||
|
|
||||||
|
### 3.3. Поток «Telegram → MAX» (с очередью)
|
||||||
|
1. `Слой клиента Telegram` получает сообщение из топика (`tg_chat_id` + `message_thread_id`). Игнорирует сообщения бота.
|
||||||
|
2. `Слой маршрутизации` ищет пару `{tg_chat_id, message_thread_id}` в `Слое маппинга`.
|
||||||
|
3. Если найдено: задача передается в `Слой очередей` (`tg2max_queue`). Если нет: игнорируется.
|
||||||
|
4. `Слой клиента MAX` (Воркер) забирает задачу, ждет настраиваемого лимита, отправляет сообщение в MAX.
|
||||||
|
5. **Только после успеха от API MAX**, `Слой клиента Telegram` ставит реакцию 🦄 на исходное сообщение.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Технические требования и стек технологий
|
||||||
|
|
||||||
|
### 4.1. Стек разработки
|
||||||
|
| Компонент | Технология | Обоснование / Требования |
|
||||||
|
|-----------|------------|--------------------------|
|
||||||
|
| **Язык** | Python 3.10+ | Нативная поддержка `asyncio`, строгая типизация (type hints). |
|
||||||
|
| **Клиент MAX** | `PyMAX` | **Обязательная поддержка WebSocket** для стриминга событий. Должен поддерживать переподключение и запрос истории по курсору/ID. |
|
||||||
|
| **Клиент Telegram** | `aiogram 3.x` | Предпочтительно для Bot API (работа с форумами, очередями, поллингом/вебхуками). |
|
||||||
|
| **База данных** | SQLite | Хранение маппингов и маркеров состояния. **Обязательно**: режим `PRAGMA journal_mode=WAL` и `PRAGMA synchronous=NORMAL`. Библиотека: `aiosqlite` или `SQLAlchemy` (async). |
|
||||||
|
| **Очереди** | Redis | Хранение очередей `max2tg_queue` и `tg2max_queue`. Персистентность через `appendonly yes` (AOF). Библиотека: `redis-py` (async). |
|
||||||
|
| **Логирование** | `structlog` / `logging` | Структурированные JSON-логи. Уровень логирования настраивается через переменную окружения. |
|
||||||
|
|
||||||
|
### 4.2. Архитектура приложения (Python)
|
||||||
|
Приложение строится на базе `asyncio` и состоит из следующих независимых задач (tasks):
|
||||||
|
1. **MAX WebSocket Listener**: Поддерживает соединение, парсит события, управляет маркерами.
|
||||||
|
2. **Telegram Listener**: Aiogram Dispatcher.
|
||||||
|
3. **TG Worker**: Цикл `BLPOP` из `max2tg_queue` с `asyncio.sleep(3.5)`.
|
||||||
|
4. **MAX Worker**: Цикл `BLPOP` из `tg2max_queue` с настраиваемой задержкой.
|
||||||
|
5. **Admin Handler**: Обработчик команд только от `FALLBACK_USER_ID`.
|
||||||
|
|
||||||
|
### 4.3. Требования к развертыванию (Docker + Docker Compose)
|
||||||
|
Сервис поставляется в виде готового к запуску окружения. Папка `./data` монтируется для сохранения SQLite БД и сессии PyMAX между перезапусками.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
command: redis-server --appendonly yes
|
||||||
|
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
restart: always
|
||||||
|
depends_on:
|
||||||
|
- redis
|
||||||
|
environment:
|
||||||
|
- MAX_TOKEN=${MAX_TOKEN}
|
||||||
|
- MAX_DEVICE_ID=${MAX_DEVICE_ID}
|
||||||
|
- TG_BOT_TOKEN=${TG_BOT_TOKEN}
|
||||||
|
- TG_FORUM_CHANNEL_ID=${TG_FORUM_CHANNEL_ID}
|
||||||
|
- FALLBACK_USER_ID=${FALLBACK_USER_ID}
|
||||||
|
- DATABASE_URL=sqlite+aiosqlite:///data/bridge.db
|
||||||
|
- REDIS_URL=redis://redis:6379/0
|
||||||
|
- TG_RATE_LIMIT_DELAY_SEC=3.5
|
||||||
|
- MAX_RATE_LIMIT_DELAY_SEC=1.0
|
||||||
|
- MAX_RECONNECT_FETCH_LIMIT=50
|
||||||
|
- LOG_LEVEL=INFO
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis_data:
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4. Архитектурные принципы и стандарты разработки
|
||||||
|
Проект обязан строго следовать следующим инженерным стандартам:
|
||||||
|
|
||||||
|
1. **Строгое разделение слоёв (Layered Architecture)**:
|
||||||
|
- `MAX Layer` отвечает **только** за взаимодействие с API MAX (подключение WS, отправка/получение сообщений, сессия).
|
||||||
|
- `Telegram Layer` отвечает **только** за взаимодействие с Telegram Bot API (получение обновлений, отправка в топики, реакции, обработка команд).
|
||||||
|
- `Router Layer` отвечает **только** за логику сопоставления идентификаторов и принятие решений о маршруте.
|
||||||
|
- `Queue Layer` отвечает **только** за сериализацию, буферизацию и доставку задач между слоями.
|
||||||
|
- `Storage Layer` отвечает **только** за атомарные операции чтения/записи в SQLite/Redis.
|
||||||
|
- **Запрещено**: вызывать методы одного слоя из другого напрямую. Например, `MAX Layer` не может ставить реакции или создавать топики в Telegram. Вся межслойная коммуникация проходит через интерфейсы абстракций и очереди.
|
||||||
|
|
||||||
|
2. **SOLID**:
|
||||||
|
- **S**ingle Responsibility: Каждый модуль/класс выполняет одну четкую задачу.
|
||||||
|
- **O**pen/Closed: Расширение функционала (новые типы медиа, новые команды) происходит без модификации стабильного кода ядерных компонентов.
|
||||||
|
- **L**iskov Substitution & **I**nterface Segregation: Чёткие контракты между слоями, минимум методов в интерфейсах.
|
||||||
|
- **D**ependency Inversion: Слои зависят от абстракций (протоколов/интерфейсов), а не от конкретных реализаций клиентов.
|
||||||
|
|
||||||
|
3. **KISS**:
|
||||||
|
- Избегать избыточной абстракции и "over-engineering".
|
||||||
|
- Использовать стандартные паттерны `asyncio`, `aiogram` и `redis-py`.
|
||||||
|
- Конфигурация, бизнес-логика и инфраструктурный код должны быть явно разделены, но не размазаны по сотне файлов.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Бизнес-функции и правила
|
||||||
|
|
||||||
|
### 5.1. Двусторонняя пересылка и очереди
|
||||||
|
- **Гарантированная доставка**: Сообщения не теряются при перезапуске контейнера благодаря персистентным Redis-очередям.
|
||||||
|
- **Rate Limiting**: В Telegram ≥ 3.5 сек, в MAX — настраиваемая задержка.
|
||||||
|
- **Подтверждение**: Реакция 🦄 ставится **только после** успешного подтверждения отправки из очереди в MAX.
|
||||||
|
|
||||||
|
### 5.2. Маршрутизация и управление топиками
|
||||||
|
- **Строгое сопоставление по ID**: Ключ поиска: `max_chat_id` ↔ `{tg_chat_id, tg_thread_id}`.
|
||||||
|
- **Автоматическое создание**: При отсутствии маппинга создается топик. Имя: `"Название чата"` или `"👤 Имя контакта"`. Имя хранится только для UX (`/list`).
|
||||||
|
- **Игнорирование**: Сообщения из TG-топиков без маппинга игнорируются.
|
||||||
|
|
||||||
|
### 5.3. Формат сообщений и контент
|
||||||
|
- **MAX → TG**: В групповых топиках: `<Имя отправителя>:\n<текст>`. В ЛС-топиках: только текст.
|
||||||
|
- **TG → MAX**: Всегда: `<Имя Фамилия> (@username):\n<текст>`.
|
||||||
|
- **Медиа**: Фото, видео, альбомы, файлы поддерживаются нативно. Ненативные типы передаются ссылками `[Telegram files]`.
|
||||||
|
- **Треды**: Поддерживаются через `max_message_id` ↔ `tg_message_id` в БД.
|
||||||
|
|
||||||
|
### 5.4. Управление и команды
|
||||||
|
Команды обрабатываются **исключительно** в личной переписке с ботом и **только** от `FALLBACK_USER_ID`. В части пересылки сообщений все участники форума равны.
|
||||||
|
|
||||||
|
| Команда | Действие |
|
||||||
|
|---------|----------|
|
||||||
|
| `/start` | Приветствие, краткая справка, проверка статуса подключения |
|
||||||
|
| `/help` | Полная справка по доступным командам управления |
|
||||||
|
| `/list` | Список активных маппингов (ID, названия, статус) |
|
||||||
|
| `/join <ссылка>` | Вступление в MAX-группу или канал |
|
||||||
|
| `/leave <id или название>` | Выход из указанного MAX-канала |
|
||||||
|
| `/last_messages <id или название>` | Последние 10 сообщений из MAX-чата или ЛС |
|
||||||
|
|
||||||
|
### 5.5. Надежность и восстановление (WebSocket и маркеры)
|
||||||
|
1. **Хранение маркеров**: Таблица `sync_markers` в SQLite хранит `last_processed_message_id` для каждого чата.
|
||||||
|
2. **Идемпотентность**: События с `message_id` <= маркера отбрасываются.
|
||||||
|
3. **Восстановление после даунтайма**: При старте читаются маркеры, запрашивается история (до `MAX_RECONNECT_FETCH_LIMIT`), сообщения ставятся в очередь.
|
||||||
|
4. **Атомарность**: Маркер обновляется только после успешной постановки в Redis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Конфигурация (Переменные окружения)
|
||||||
|
|
||||||
|
| Переменная | Описание | Пример / Значение по умолчанию |
|
||||||
|
|------------|----------|--------------------------------|
|
||||||
|
| `MAX_TOKEN` | Токен авторизации аккаунта MAX | `eyJhbGci...` |
|
||||||
|
| `MAX_DEVICE_ID` | Уникальный идентификатор устройства/сессии | `a1b2c3d4...` |
|
||||||
|
| `TG_BOT_TOKEN` | Токен Telegram-бота от @BotFather | `123456:ABC-DEF1234...` |
|
||||||
|
| `TG_FORUM_CHANNEL_ID` | ID Форум-канала (строго с `-100`) | `-1001234567890` |
|
||||||
|
| `FALLBACK_USER_ID` | Telegram ID администратора | `987654321` |
|
||||||
|
| `DATABASE_URL` | Строка подключения к SQLite (относительно `WORKDIR=/app`) | `sqlite+aiosqlite:///data/bridge.db` |
|
||||||
|
| `REDIS_URL` | Строка подключения к Redis | `redis://redis:6379/0` |
|
||||||
|
| `TG_RATE_LIMIT_DELAY_SEC` | Задержка между отправками в TG | `3.5` |
|
||||||
|
| `MAX_RATE_LIMIT_DELAY_SEC` | Задержка между отправками в MAX | `1.0` |
|
||||||
|
| `LS_TOPIC_PREFIX` | Префикс для именования ЛС-топиков | `👤 ` |
|
||||||
|
| `MAX_RECONNECT_FETCH_LIMIT` | Макс. кол-во сообщений для дозагрузки из истории | `50` |
|
||||||
|
| `LOG_LEVEL` | Уровень логирования (`DEBUG`, `INFO`, `WARNING`, `ERROR`) | `INFO` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Границы ответственности продукта
|
||||||
|
|
||||||
|
**Входит в scope:**
|
||||||
|
- Асинхронная двусторонняя пересылка текста и медиа через Redis-очереди.
|
||||||
|
- Подключение к MAX через **WebSocket** с обработкой разрывов связи.
|
||||||
|
- Хранение и использование **маркеров последнего сообщения** для предотвращения потери данных.
|
||||||
|
- Автоматическое создание топиков и **строгая маршрутизация только по идентификаторам**.
|
||||||
|
- Соблюдение rate limits и настройка уровня логирования.
|
||||||
|
- Поддержка тредов, дедупликация, равенство участников форума.
|
||||||
|
- Удалённое управление MAX (`/start`, `/help`, `/list` и др.) только для fallback-пользователя.
|
||||||
|
- Архитектурное соблюдение слоёв, SOLID, KISS.
|
||||||
|
- Контейнеризация (Docker + Docker Compose).
|
||||||
|
|
||||||
|
**Не входит в scope:**
|
||||||
|
- Маршрутизация по названиям чатов/контактов.
|
||||||
|
- Автоматическое переименование топиков при смене имени в MAX.
|
||||||
|
- Редактирование и удаление доставленных сообщений.
|
||||||
|
- Синхронизация статусов «прочитано», индикаторов набора текста, звонков.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Критерии приёмки (Бизнес и Технические)
|
||||||
|
|
||||||
|
| № | Критерий | Тип проверки |
|
||||||
|
|---|----------|--------------|
|
||||||
|
| 1 | Приложение успешно запускается командой `docker compose up -d`. | Технический |
|
||||||
|
| 2 | SQLite работает в режиме WAL, Redis сохраняет очередь между рестартами. | Технический |
|
||||||
|
| 3 | Подключение к MAX осуществляется по **WebSocket**, автоматическое переподключение при обрыве. | Технический |
|
||||||
|
| 4 | **Архитектура**: Отсутствуют прямые вызовы API одного слоя из другого. Код структурирован по слоям, соответствует SOLID и KISS. | Технический/Code Review |
|
||||||
|
| 5 | В SQLite корректно обновляется `last_processed_message_id`. Дубликаты при переподключении WS не попадают в очередь. | Технический |
|
||||||
|
| 6 | **Сценарий даунтайма**: После 5-минутного простоя пропущенные сообщения доставляются без потерь и дублей. | Бизнес/Технический |
|
||||||
|
| 7 | Переименование чата в MAX **не прерывает** пересылку (маршрутизация строго по ID). | Бизнес |
|
||||||
|
| 8 | Сообщение из TG пересылается в MAX **только если** маппинг найден в БД. | Бизнес |
|
||||||
|
| 9 | При флуде (10+ сообщений) соблюдаются интервалы: ≥ 3.5 сек (TG) и ≥ `MAX_RATE_LIMIT_DELAY_SEC` (MAX). Ошибки `429` отсутствуют. | Технический |
|
||||||
|
| 10 | Реакция 🦄 ставится в TG **строго после** успешной отправки из очереди в MAX. | Бизнес |
|
||||||
|
| 11 | Любой участник форума может писать в маппированный ЛС-топик, сообщение доходит до контакта в MAX. | Бизнес |
|
||||||
|
| 12 | Команды `/start`, `/help`, `/list` и др. работают **только** в личной переписке и **только** от `FALLBACK_USER_ID`. | Бизнес |
|
||||||
|
| 13 | Уровень логирования (`LOG_LEVEL`) применяется ко всем компонентам, логи структурированы в JSON. | Технический |
|
||||||
|
| 14 | При критической ошибке отправки fallback-пользователь получает уведомление в ЛС с деталями. | Бизнес |
|
||||||
Reference in New Issue
Block a user