diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2534065 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +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:///app/data/bridge.db +REDIS_URL=redis://redis:6379/0 +TG_RATE_LIMIT_DELAY_SEC=3.5 +MAX_RATE_LIMIT_DELAY_SEC=1.0 +LS_TOPIC_PREFIX=πŸ‘€ +MAX_RECONNECT_FETCH_LIMIT=50 +LOG_LEVEL=INFO diff --git a/.env.sample b/.env.sample deleted file mode 100644 index 575d24d..0000000 --- a/.env.sample +++ /dev/null @@ -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 diff --git a/.gitignore b/.gitignore index 192af1f..9048246 100644 --- a/.gitignore +++ b/.gitignore @@ -1,34 +1,9 @@ .env -cache/* - -# Python bytecode / cache +data/ __pycache__/ -*.py[cod] -*$py.class - -# Virtual environments +*.pyc .venv/ venv/ -env/ -ENV/ - -# 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 \ No newline at end of file +*.db +*.db-wal +*.db-shm diff --git a/Dockerfile b/Dockerfile index 74a4a9f..fc55933 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,11 +2,16 @@ FROM python:3.12-slim WORKDIR /app -RUN apt-get update \ - && apt-get install -y --no-install-recommends git \ +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ && rm -rf /var/lib/apt/lists/* -COPY ./src/requirements.txt . -RUN pip install --no-cache-dir -r ./requirements.txt -COPY ./src . -CMD [ "python", "-u", "./main.py" ] +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ ./app/ + +ENV PYTHONUNBUFFERED=1 +ENV PYTHONPATH=/app + +CMD ["python", "-m", "app.main"] diff --git a/README.md b/README.md index ca3374b..7705a3c 100644 --- a/README.md +++ b/README.md @@ -1,122 +1,48 @@ # 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`, поэтому: - -- Π±ΠΎΡ‚ Π΄ΠΎΠ»ΠΆΠ΅Π½ Π±Ρ‹Ρ‚ΡŒ Π΄ΠΎΠ±Π°Π²Π»Π΅Π½ Π² Π½ΡƒΠΆΠ½Ρ‹Π΅ Telegram-Ρ‡Π°Ρ‚Ρ‹; -- Π² этих Ρ‡Π°Ρ‚Π°Ρ… Π΄ΠΎΠ»ΠΆΠ΅Π½ Π±Ρ‹Ρ‚ΡŒ хотя Π±Ρ‹ ΠΎΠ΄ΠΈΠ½ Π°ΠΏΠ΄Π΅ΠΉΡ‚ (сообщСниС/событиС), Ρ‡Ρ‚ΠΎΠ±Ρ‹ Ρ‡Π°Ρ‚ появился Π² Π°ΠΏΠ΄Π΅ΠΉΡ‚Π°Ρ…; -- Ссли Ρƒ Π±ΠΎΡ‚Π° Π²ΠΊΠ»ΡŽΡ‡Π΅Π½ privacy mode, ΠΎΠ½ ΠΌΠΎΠΆΠ΅Ρ‚ Π½Π΅ ΠΏΠΎΠ»ΡƒΡ‡Π°Ρ‚ΡŒ ΠΎΠ±Ρ‹Ρ‡Π½Ρ‹Π΅ сообщСния ΠΈΠ· Π³Ρ€ΡƒΠΏΠΏ β€” Ρ‚ΠΎΠ³Π΄Π° Ρ‡Π°Ρ‚ ΠΌΠΎΠΆΠ΅Ρ‚ Π½Π΅ β€œΠΎΠ±Π½Π°Ρ€ΡƒΠΆΠΈΡ‚ΡŒΡΡβ€ Ρ‡Π΅Ρ€Π΅Π· `getUpdates`. - -### ΠŸΡ€ΠΈΠ²ΡΠ·ΠΊΠ° Max-Ρ‡Π°Ρ‚Π° ΠΊ Telegram-Ρ‡Π°Ρ‚Ρƒ (рСкомСндуСтся) - -Π§Ρ‚ΠΎΠ±Ρ‹ Π½Π΅ Π·Π°Π²ΠΈΡΠ΅Ρ‚ΡŒ ΠΎΡ‚ `getUpdates` ΠΈ privacy mode, ΠΌΠΎΠΆΠ½ΠΎ ΠΎΠ΄ΠΈΠ½ Ρ€Π°Π· β€œΠ·Π°Π±ΠΈΠ½Π΄ΠΈΡ‚ΡŒβ€ Π½ΡƒΠΆΠ½Ρ‹ΠΉ Telegram-Ρ‡Π°Ρ‚ Π½Π° Π½Π°Π·Π²Π°Π½ΠΈΠ΅ Ρ‡Π°Ρ‚Π° Π² Max. - -Π’ Ρ†Π΅Π»Π΅Π²ΠΎΠΌ Telegram-Ρ‡Π°Ρ‚Π΅ напиши ΠΊΠΎΠΌΠ°Π½Π΄Ρƒ: - -- `/bind_max <Ρ‚ΠΎΡ‡Π½ΠΎΠ΅ Π½Π°Π·Π²Π°Π½ΠΈΠ΅ Ρ‡Π°Ρ‚Π° Π² Max>` - -ΠŸΡ€ΠΈΠΌΠ΅Ρ€: - -- `/bind_max Π‘Π²ΠΎΠ±ΠΎΠ΄Π½Ρ‹ΠΉ ΠΌΠΈΠΊΡ€ΠΎΡ„ΠΎΠ½` - -ПослС этого пСрСсылка **ΠΈΠ· Max Π² Telegram** для Ρ‡Π°Ρ‚Π° `Π‘Π²ΠΎΠ±ΠΎΠ΄Π½Ρ‹ΠΉ ΠΌΠΈΠΊΡ€ΠΎΡ„ΠΎΠ½` Π±ΡƒΠ΄Π΅Ρ‚ ΠΈΠ΄Ρ‚ΠΈ строго Π² этот Telegram-Ρ‡Π°Ρ‚ (привязка хранится Π² SQLite). +```bash +docker compose up -d +``` ## ΠŸΠ΅Ρ€Π΅ΠΌΠ΅Π½Π½Ρ‹Π΅ окруТСния -Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ΡΡ Ρ„Π°ΠΉΠ» `.env` Π² ΠΊΠΎΡ€Π½Π΅ ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Π°. +Π‘ΠΌ. `tech-specs.md`, Ρ€Π°Π·Π΄Π΅Π» 6, ΠΈ `.env.example`. -ΠŸΡ€ΠΈΠΌΠ΅Ρ€ смотри Π² `.env.sample`. +## ΠšΠΎΠΌΠ°Π½Π΄Ρ‹ (Ρ‚ΠΎΠ»ΡŒΠΊΠΎ FALLBACK_USER_ID Π² Π›Π‘ с Π±ΠΎΡ‚ΠΎΠΌ) -ΠžΠ±ΡΠ·Π°Ρ‚Π΅Π»ΡŒΠ½Ρ‹Π΅ ΠΏΠ΅Ρ€Π΅ΠΌΠ΅Π½Π½Ρ‹Π΅: +- `/start` β€” статус ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΡ +- `/help` β€” справка +- `/list` β€” список ΠΌΠ°ΠΏΠΏΠΈΠ½Π³ΠΎΠ² +- `/join <ссылка>` β€” Π²ΡΡ‚ΡƒΠΏΠΈΡ‚ΡŒ Π² MAX-Π³Ρ€ΡƒΠΏΠΏΡƒ +- `/leave ` β€” Π²Ρ‹ΠΉΡ‚ΠΈ ΠΈΠ· MAX-Ρ‡Π°Ρ‚Π° +- `/last_messages ` β€” послСдниС 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` - Ρ‚Π°ΠΉΠΌΠ·ΠΎΠ½Π° ΠΊΠΎΠ½Ρ‚Π΅ΠΉΠ½Π΅Ρ€Π°; -- `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 Π½Π΅ совпадаСт. +Redis Π΄ΠΎΠ»ΠΆΠ΅Π½ Π±Ρ‹Ρ‚ΡŒ доступСн ΠΏΠΎ `REDIS_URL`. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..755d2c0 --- /dev/null +++ b/app/config.py @@ -0,0 +1,35 @@ +from pathlib import Path + +from pydantic import model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +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:///app/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: + if self.database_url.startswith("sqlite+aiosqlite:///"): + return self.database_url.removeprefix("sqlite+aiosqlite:///") + return "app/data/bridge.db" + + @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 diff --git a/app/logging_setup.py b/app/logging_setup.py new file mode 100644 index 0000000..3419c5c --- /dev/null +++ b/app/logging_setup.py @@ -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, + ) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..150fb6d --- /dev/null +++ b/app/main.py @@ -0,0 +1,93 @@ +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() + logger.info( + "storage_ready", + sqlite_path=settings.sqlite_path, + mapping_count=len(mappings), + ) + + 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()) diff --git a/app/max_layer/__init__.py b/app/max_layer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/max_layer/client_holder.py b/app/max_layer/client_holder.py new file mode 100644 index 0000000..216c498 --- /dev/null +++ b/app/max_layer/client_holder.py @@ -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 diff --git a/app/max_layer/formatter.py b/app/max_layer/formatter.py new file mode 100644 index 0000000..7e76ec2 --- /dev/null +++ b/app/max_layer/formatter.py @@ -0,0 +1,223 @@ +from typing import Any + +import structlog +from pymax.types.domain.message import Message as MaxMessage + +from app.models.domain import 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 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 build_chat_title(chat, ls_prefix: str) -> tuple[str, bool]: + is_dm = bool(getattr(chat, "is_dialog", False) or chat.type == "DIALOG") + if is_dm: + title = chat.title or "ΠšΠΎΠ½Ρ‚Π°ΠΊΡ‚" + if not title.startswith(ls_prefix.strip()): + title = f"{ls_prefix}{title}" + return title, True + return chat.title or f"Π§Π°Ρ‚ {chat.id}", False + + +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}" diff --git a/app/max_layer/listener.py b/app/max_layer/listener.py new file mode 100644 index 0000000..143eef8 --- /dev/null +++ b/app/max_layer/listener.py @@ -0,0 +1,241 @@ +import asyncio +from pathlib import Path + +import structlog +from pymax import ExtraConfig, Message, WebClient +from pymax.types.domain.enums import ChatType + +from app.config import Settings +from app.media_transfer import download_max_media, tmp_dir +from app.max_layer.client_holder import MaxClientHolder +from app.max_layer.formatter import ( + build_chat_title, + extract_forwarded_content, + format_forwarded_text, + 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 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): + 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 + + is_dm = chat.type == ChatType.DIALOG or getattr(chat, "is_dialog", False) + chat_title, _ = build_chat_title(chat, self._settings.ls_topic_prefix) + + sender_name: str | None = None + if not 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: int | None = None + if message.options and isinstance(message.options, dict): + reply_to = message.options.get("replyTo") + if reply_to is None and message.prev_message_id: + try: + reply_to = int(message.prev_message_id) + except (TypeError, ValueError): + reply_to = None + + 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=is_dm, + ) + + 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=is_dm, + chat_title=chat_title, + 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}", + ) diff --git a/app/max_layer/worker.py b/app/max_layer/worker.py new file mode 100644 index 0000000..5f77b6a --- /dev/null +++ b/app/max_layer/worker.py @@ -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 diff --git a/app/media_transfer.py b/app/media_transfer.py new file mode 100644 index 0000000..ce4bacc --- /dev/null +++ b/app/media_transfer.py @@ -0,0 +1,171 @@ +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) + + +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)) diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/models/domain.py b/app/models/domain.py new file mode 100644 index 0000000..4cc466d --- /dev/null +++ b/app/models/domain.py @@ -0,0 +1,36 @@ +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 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 + 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] diff --git a/app/models/tasks.py b/app/models/tasks.py new file mode 100644 index 0000000..2572521 --- /dev/null +++ b/app/models/tasks.py @@ -0,0 +1,77 @@ +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 + 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}") diff --git a/app/queue/__init__.py b/app/queue/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/queue/protocols.py b/app/queue/protocols.py new file mode 100644 index 0000000..e529cf9 --- /dev/null +++ b/app/queue/protocols.py @@ -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: ... diff --git a/app/queue/redis_queue.py b/app/queue/redis_queue.py new file mode 100644 index 0000000..d217649 --- /dev/null +++ b/app/queue/redis_queue.py @@ -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 diff --git a/app/router/__init__.py b/app/router/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/router/router.py b/app/router/router.py new file mode 100644 index 0000000..88379d4 --- /dev/null +++ b/app/router/router.py @@ -0,0 +1,161 @@ +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, + ) + + 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, + 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) + ) diff --git a/app/storage/__init__.py b/app/storage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/storage/database.py b/app/storage/database.py new file mode 100644 index 0000000..e11b7cf --- /dev/null +++ b/app/storage/database.py @@ -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) diff --git a/app/storage/protocols.py b/app/storage/protocols.py new file mode 100644 index 0000000..57c374d --- /dev/null +++ b/app/storage/protocols.py @@ -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: ... diff --git a/app/storage/repository.py b/app/storage/repository.py new file mode 100644 index 0000000..9d4fbd2 --- /dev/null +++ b/app/storage/repository.py @@ -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, + ) diff --git a/app/telegram_layer/__init__.py b/app/telegram_layer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/telegram_layer/admin.py b/app/telegram_layer/admin.py new file mode 100644 index 0000000..5d5b8d1 --- /dev/null +++ b/app/telegram_layer/admin.py @@ -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 β€” Π²Ρ‹ΠΉΡ‚ΠΈ ΠΈΠ· MAX-Ρ‡Π°Ρ‚Π°\n" + "/last_messages β€” послСдниС 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 ") + 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 ") + 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 diff --git a/app/telegram_layer/bot_holder.py b/app/telegram_layer/bot_holder.py new file mode 100644 index 0000000..25d23b8 --- /dev/null +++ b/app/telegram_layer/bot_holder.py @@ -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 diff --git a/app/telegram_layer/formatter.py b/app/telegram_layer/formatter.py new file mode 100644 index 0000000..4675fdf --- /dev/null +++ b/app/telegram_layer/formatter.py @@ -0,0 +1,73 @@ +from aiogram.types import Message + +from app.models.domain import TgIncomingMessage + + +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_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, + ) diff --git a/app/telegram_layer/listener.py b/app/telegram_layer/listener.py new file mode 100644 index 0000000..9e9c0ca --- /dev/null +++ b/app/telegram_layer/listener.py @@ -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}", + ) diff --git a/app/telegram_layer/worker.py b/app/telegram_layer/worker.py new file mode 100644 index 0000000..6a88756 --- /dev/null +++ b/app/telegram_layer/worker.py @@ -0,0 +1,245 @@ +import asyncio + +import aiohttp +import structlog +from aiogram.types import ( + FSInputFile, + InputMediaDocument, + InputMediaPhoto, + InputMediaVideo, + ReactionTypeEmoji, +) + +from app.config import Settings +from app.media_transfer import cleanup_paths, 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.topic_locks import TopicLockRegistry + +logger = structlog.get_logger(__name__) + + +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 _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: + 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) + logger.info( + "tg_topic_created", + max_chat_id=task.max_chat_id, + thread_id=topic.message_thread_id, + title=task.chat_title, + ) + else: + logger.debug( + "tg_topic_reused", + max_chat_id=task.max_chat_id, + thread_id=mapping.tg_thread_id, + ) + + assert mapping is not None + thread_id = mapping.tg_thread_id + + 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, + reply_to_max_message_id=None, + media=[], + ) + ) + + sent = await self._dispatch_content( + bot, thread_id, formatted, task.media, task.reply_to_tg_message_id + ) + 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=thread_id, + ) + + 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 = { + "chat_id": chat_id, + "message_thread_id": thread_id, + "reply_to_message_id": reply_to, + } + + if not media: + return await bot.send_message(text=text or " ", **kwargs) + + 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": + return await bot.send_photo( + photo=upload, caption=text or None, **kwargs + ) + if item.kind == "video": + return await bot.send_video( + video=upload, caption=text or None, **kwargs + ) + return await bot.send_document( + document=upload, caption=text or None, **kwargs + ) + + group = [] + for idx, (item, upload) in enumerate(uploads): + caption = text if idx == 0 else None + if item.kind == "photo": + group.append(InputMediaPhoto(media=upload, caption=caption)) + elif item.kind == "video": + group.append(InputMediaVideo(media=upload, caption=caption)) + else: + group.append(InputMediaDocument(media=upload, caption=caption)) + + messages = await bot.send_media_group( + chat_id=chat_id, + message_thread_id=thread_id, + media=group, + ) + return messages[0] + 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) diff --git a/app/topic_locks.py b/app/topic_locks.py new file mode 100644 index 0000000..5cdc759 --- /dev/null +++ b/app/topic_locks.py @@ -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 diff --git a/auth.py b/auth.py deleted file mode 100644 index 078fe90..0000000 --- a/auth.py +++ /dev/null @@ -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() diff --git a/docker-compose.yaml b/docker-compose.yaml deleted file mode 100644 index 7b3024c..0000000 --- a/docker-compose.yaml +++ /dev/null @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c1382f5 --- /dev/null +++ b/docker-compose.yml @@ -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:///app/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" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..23c1b42 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/src/bridge.py b/src/bridge.py deleted file mode 100644 index c51a282..0000000 --- a/src/bridge.py +++ /dev/null @@ -1,630 +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 AudioAttach, FileAttach, Message, PhotoAttach, StickerAttach, 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<тСкст>" - # РСшСниС ΠΎ Ρ‚ΠΎΠΌ, Π²ΠΊΠ»ΡŽΡ‡Π°Ρ‚ΡŒ Π»ΠΈ Π½Π°Π·Π²Π°Π½ΠΈΠ΅ Ρ‡Π°Ρ‚Π°, ΠΏΡ€ΠΈΠ½ΠΈΠΌΠ°Π΅ΠΌ послС опрСдСлСния ΠΌΠ°Ρ€ΡˆΡ€ΡƒΡ‚Π°. - 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, - ) - text = self._append_unknown_attachment_notice(parsed=parsed, text=text) - - 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) or bool(parsed.file_urls) - if not has_any_payload: - text = self._build_fallback_unknown_notice(parsed) - - total_media = len(parsed.image_urls) + len(parsed.video_urls) - sent_any = False - 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), - ) - sent_any = True - 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 - - should_send_plain_text = total_media == 0 and not parsed.file_urls and bool(text.strip()) - if should_send_plain_text: - 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 - - for index, file_url in enumerate(parsed.file_urls): - caption = text if not sent_any and index == 0 else None - file_name = parsed.file_names_by_url.get(file_url) - 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_document( - chat_id, - file_url, - file_name=file_name, - 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 - - if not sent_any: - # ПослСдняя страховка: Π³Π°Ρ€Π°Π½Ρ‚ΠΈΡ€ΡƒΠ΅ΠΌ ΡƒΠ²Π΅Π΄ΠΎΠΌΠ»Π΅Π½ΠΈΠ΅ Π² Telegram Π΄Π°ΠΆΠ΅ для пустых/нСизвСстных payload. - fallback_text = text.strip() or self._build_fallback_unknown_notice(parsed) - 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, fallback_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 - - self._storage.mark_forwarded(parsed.message_id, parsed.chat_id) - logger.info( - "Forwarded message %s/%s (images=%s, videos=%s, files=%s, unknown=%s)", - parsed.chat_id, - parsed.message_id, - len(parsed.image_urls), - len(parsed.video_urls), - len(parsed.file_urls), - len(parsed.unknown_attachments), - ) - - async def notify_delivery_failure(self, max_message: Any, error: Exception) -> None: - """Best-effort Π°Π²Π°Ρ€ΠΈΠΉΠ½ΠΎΠ΅ ΡƒΠ²Π΅Π΄ΠΎΠΌΠ»Π΅Π½ΠΈΠ΅, Ссли основной Ρ„ΠΎΡ€Π²Π°Ρ€Π΄ΠΈΠ½Π³ ΡƒΠΏΠ°Π».""" - try: - parsed = parse_message(max_message) - parsed = await self._enrich_from_max(max_message, parsed) - body = self._build_fallback_unknown_notice(parsed) - body = f"{body}\n\n[bridge-error] {type(error).__name__}: {error}" - except Exception: - body = f"[!] Π‘ΠΎΠΎΠ±Ρ‰Π΅Π½ΠΈΠ΅ ΠΈΠ· MAX Π½Π΅ доставлСно Π² Telegram ΠΈΠ·-Π·Π° ошибки bridge: {type(error).__name__}: {error}" - - fallback_chat_id = self._telegram.fallback_user_id - if not fallback_chat_id: - logger.error("Cannot send emergency notice: Telegram fallback user id is empty") - return - try: - await self._telegram.send_text(chat_id=fallback_chat_id, text=body) - logger.warning("Sent emergency notice to Telegram fallback chat %s", fallback_chat_id) - except Exception: - logger.exception("Cannot send emergency notice to Telegram") - - 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) - - await self._collect_message_attachments( - message=max_message, - parsed=parsed, - source_tag="root", - ) - - link = getattr(max_message, "link", None) - linked_message = getattr(link, "message", None) - if linked_message is not None: - # Для reply Π½Π΅ ΠΊΠΎΠΏΠΈΡ€ΡƒΠ΅ΠΌ тСкст исходного сообщСния Π² Ρ‚Π΅Π»ΠΎ: - # ΠΈΠ½Π°Ρ‡Π΅ ΠΏΠΎΠ»ΡƒΡ‡Π°Π΅ΠΌ Π΄ΡƒΠ±Π»ΡŒ (Ρ†ΠΈΡ‚Π°Ρ‚Π° + Ρ‚ΠΎΡ‚ ΠΆΠ΅ тСкст ΠΊΠ°ΠΊ Π½ΠΎΠ²ΠΎΠ΅ сообщСниС). - is_reply = bool(parsed.reply_to_max_message_id) - if not is_reply and not (parsed.text or "").strip(): - linked_text = str(getattr(linked_message, "text", "") or "").strip() - if linked_text: - parsed.text = linked_text - # Для reply нСльзя ΠΏΠ΅Ρ€Π΅Π½ΠΎΡΠΈΡ‚ΡŒ влоТСния linked_message: - # это исходноС сообщСниС, ΠΈ Π΅Π³ΠΎ ΠΌΠ΅Π΄ΠΈΠ° Π½Π΅ Π΄ΠΎΠ»ΠΆΠ½Ρ‹ ΠΎΡ‚ΠΏΡ€Π°Π²Π»ΡΡ‚ΡŒΡΡ ΠΏΠΎΠ²Ρ‚ΠΎΡ€Π½ΠΎ. - if not is_reply: - await self._collect_message_attachments( - message=linked_message, - parsed=parsed, - source_tag="forward", - ) - - # Π£Π±ΠΈΡ€Π°Π΅ΠΌ Π΄ΡƒΠ±Π»ΠΈ URL, Ссли парсСр ΠΈ enrich нашли ΠΎΠ΄ΠΈΠ½Π°ΠΊΠΎΠ²Ρ‹Π΅ влоТСния. - parsed.image_urls = list(dict.fromkeys(parsed.image_urls)) - parsed.video_urls = list(dict.fromkeys(parsed.video_urls)) - parsed.file_urls = list(dict.fromkeys(parsed.file_urls)) - parsed.file_names_by_url = { - url: name for url, name in parsed.file_names_by_url.items() if url in parsed.file_urls and name - } - if parsed.file_urls: - parsed.unknown_attachments = [ - x for x in parsed.unknown_attachments if not self._is_file_unknown_marker(x) - ] - parsed.unknown_attachments = list(dict.fromkeys(parsed.unknown_attachments)) - return parsed - - async def _collect_message_attachments( - self, - *, - message: Any, - parsed: ParsedMessage, - source_tag: str, - ) -> None: - attaches = getattr(message, "attaches", None) or [] - message_chat_id = getattr(message, "chat_id", None) - message_id = getattr(message, "id", None) - if message_chat_id is None: - message_chat_id = parsed.chat_id - - for attach in attaches: - if isinstance(attach, PhotoAttach): - parsed.image_urls.extend(self._extract_photo_urls(attach)) - continue - - if isinstance(attach, VideoAttach): - try: - video = await self._max_client.get_video_by_id( - chat_id=message_chat_id, - message_id=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 (%s)", source_tag) - continue - - if isinstance(attach, FileAttach): - resolved = await self._resolve_file_attach_url( - message_chat_id=message_chat_id, - message_id=message_id, - attach=attach, - ) - if resolved: - parsed.file_urls.append(resolved) - file_name = str(getattr(attach, "name", "") or "").strip() - if file_name: - parsed.file_names_by_url[resolved] = file_name - else: - fallback = str(getattr(attach, "name", "") or "").strip() - if fallback: - parsed.text = self._append_missing_file_note(parsed.text, fallback) - else: - parsed.unknown_attachments.append(type(attach).__name__) - continue - - if isinstance(attach, AudioAttach): - audio_url = str(getattr(attach, "url", "") or "").strip() - if audio_url: - parsed.file_urls.append(audio_url) - else: - parsed.unknown_attachments.append(type(attach).__name__) - continue - - if isinstance(attach, StickerAttach): - sticker_url = str(getattr(attach, "url", "") or "").strip() - if sticker_url: - parsed.image_urls.append(sticker_url) - else: - parsed.unknown_attachments.append(type(attach).__name__) - continue - - # Fallback Π½Π° случай сырого Attach/нСстандартного Ρ‚ΠΈΠΏΠ°: - if await self._resolve_generic_file_attach( - message_chat_id=message_chat_id, - message_id=message_id, - attach=attach, - parsed=parsed, - ): - continue - - urls = self._extract_any_urls(attach) - if urls: - parsed.file_urls.extend(urls) - continue - - parsed.unknown_attachments.append(type(attach).__name__) - - async def _resolve_file_attach_url(self, *, message_chat_id: Any, message_id: Any, attach: FileAttach) -> str | None: - file_id = getattr(attach, "file_id", None) - if file_id is None or message_id is None: - return None - try: - file_info = await self._max_client.get_file_by_id( - chat_id=message_chat_id, - message_id=message_id, - file_id=file_id, - ) - url = getattr(file_info, "url", None) - return str(url) if url else None - except Exception: - logger.exception("Cannot resolve file URL from Max (file_id=%s)", file_id) - return None - - async def _resolve_generic_file_attach( - self, - *, - message_chat_id: Any, - message_id: Any, - attach: Any, - parsed: ParsedMessage, - ) -> bool: - file_id = getattr(attach, "file_id", None) - if file_id is None or message_id is None: - return False - try: - file_info = await self._max_client.get_file_by_id( - chat_id=message_chat_id, - message_id=message_id, - file_id=file_id, - ) - url = getattr(file_info, "url", None) - if url: - resolved = str(url) - parsed.file_urls.append(resolved) - file_name = str(getattr(attach, "name", "") or "").strip() - if file_name: - parsed.file_names_by_url[resolved] = file_name - return True - except Exception: - logger.debug("Cannot resolve generic file attach from Max", exc_info=True) - return False - - 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)) - - def _extract_any_urls(self, node: Any) -> list[str]: - urls: list[str] = [] - seen_ids: set[int] = set() - - def walk(value: Any) -> None: - if value is None: - return - obj_id = id(value) - if obj_id in seen_ids: - return - seen_ids.add(obj_id) - - if isinstance(value, str): - if value.startswith("http://") or value.startswith("https://"): - urls.append(value) - return - if isinstance(value, (list, tuple, set)): - for item in value: - walk(item) - return - if isinstance(value, dict): - for nested in value.values(): - walk(nested) - return - if hasattr(value, "__dict__"): - walk(vars(value)) - - walk(node) - return list(dict.fromkeys(urls)) - - @staticmethod - def _is_forward_attach_like(attach: Any) -> bool: - name = type(attach).__name__.lower() - if "forward" in name or "share" in name or "quote" in name: - return True - if hasattr(attach, "__dict__"): - keys = {str(k).lower() for k in vars(attach).keys()} - if {"forward", "forwarded", "forwards", "link", "message", "messages", "origin", "payload"} & keys: - return True - return False - - @staticmethod - def _append_missing_file_note(current_text: str, file_name: str) -> str: - text = (current_text or "").strip() - note = f"[MAX forwarded file without direct URL] {file_name}" - if not text: - return note - return f"{text}\n{note}" - - @staticmethod - def _append_unknown_attachment_notice(*, parsed: ParsedMessage, text: str) -> str: - if not parsed.unknown_attachments: - return text - unknown_preview = ", ".join(parsed.unknown_attachments[:5]) - suffix = f"\n\n[!] НСизвСстный Ρ‚ΠΈΠΏ влоТСния ΠΈΠ· MAX: {unknown_preview}" - return f"{text}{suffix}" if text else suffix.strip() - - @staticmethod - def _build_fallback_unknown_notice(parsed: ParsedMessage) -> str: - base = MaxToTelegramBridge._format_caption( - sender_name=parsed.sender_name, - chat_name=parsed.chat_name, - text=parsed.text, - include_chat_name=True, - ) - unknown = ", ".join(parsed.unknown_attachments[:5]) if parsed.unknown_attachments else "unknown" - return f"{base}\n\n[!] НСизвСстный ΠΈΠ»ΠΈ пустой Ρ‚ΠΈΠΏ сообщСния ΠΈΠ· MAX (attachments={unknown})." - - @staticmethod - def _is_file_unknown_marker(value: str) -> bool: - normalized = str(value or "").strip().casefold() - return normalized in {"attachtype.file", "fileattach", "file"} diff --git a/src/config.py b/src/config.py deleted file mode 100644 index 1b87435..0000000 --- a/src/config.py +++ /dev/null @@ -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, - ) diff --git a/src/health.py b/src/health.py deleted file mode 100644 index aa25280..0000000 --- a/src/health.py +++ /dev/null @@ -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, - ) - diff --git a/src/health_web.py b/src/health_web.py deleted file mode 100644 index ac0f1a3..0000000 --- a/src/health_web.py +++ /dev/null @@ -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 - diff --git a/src/main.py b/src/main.py deleted file mode 100644 index e8ef232..0000000 --- a/src/main.py +++ /dev/null @@ -1,101 +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 as exc: - logger.exception("Failed to forward Max message") - await bridge.notify_delivery_failure(message, exc) - - 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() diff --git a/src/max_parser.py b/src/max_parser.py deleted file mode 100644 index 5fab5c3..0000000 --- a/src/max_parser.py +++ /dev/null @@ -1,214 +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 _is_forward_like(data: dict[str, Any]) -> bool: - media_type = _stringify(data.get("type") or data.get("media_type") or data.get("kind")).lower() - if "forward" in media_type or "share" in media_type or "quote" in media_type: - return True - forward_keys = { - "forward", - "forwarded", - "forwards", - "link", - "message", - "messages", - "payload", - "quote", - "origin", - } - return any(key in data for key in forward_keys) - - -def _classify_url(url: str, media_type: str) -> str: - lowered = url.lower() - if _is_image(media_type) or lowered.endswith((".jpg", ".jpeg", ".png", ".webp", ".gif")): - return "image" - if _is_video(media_type) or lowered.endswith((".mp4", ".mov", ".mkv", ".avi", ".webm")): - return "video" - return "file" - - -def _collect_urls(node: Any) -> list[str]: - urls: list[str] = [] - seen_ids: set[int] = set() - - def walk(value: Any) -> None: - if value is None: - return - obj_id = id(value) - if obj_id in seen_ids: - return - seen_ids.add(obj_id) - - if isinstance(value, str): - if value.startswith("http://") or value.startswith("https://"): - urls.append(value.strip()) - return - if isinstance(value, (list, tuple, set)): - for item in value: - walk(item) - return - if isinstance(value, dict): - for nested in value.values(): - walk(nested) - return - if hasattr(value, "__dict__"): - walk(vars(value)) - - walk(node) - return list(dict.fromkeys(urls)) - - -def _extract_media_urls(message: Any) -> tuple[list[str], list[str], list[str], list[str]]: - image_urls: list[str] = [] - video_urls: list[str] = [] - file_urls: list[str] = [] - unknown_attachments: 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")) - is_forward_like = _is_forward_like(data) - 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: - nested_urls = _collect_urls(item) - for nested_url in nested_urls: - kind = _classify_url(nested_url, media_type) - if kind == "image": - image_urls.append(nested_url) - elif kind == "video": - video_urls.append(nested_url) - else: - file_urls.append(nested_url) - if nested_urls: - continue - - if not url: - if is_forward_like: - # Forward-ΠΏΠ°ΠΊΠ΅Ρ‚ ΠΌΠΎΠΆΠ΅Ρ‚ Π½Π΅ ΡΠΎΠ΄Π΅Ρ€ΠΆΠ°Ρ‚ΡŒ прямого URL Π² Π²Π΅Ρ€Ρ…Π½Π΅ΠΌ ΡƒΡ€ΠΎΠ²Π½Π΅; - # тСкст/ΠΌΠ΅Π΄ΠΈΠ° достанСм рСкурсивно Π² Π΄Ρ€ΡƒΠ³ΠΈΡ… этапах. - continue - kind = media_type or _stringify(type(item).__name__) or "unknown" - unknown_attachments.append(kind) - continue - - if _is_image(media_type): - image_urls.append(url) - elif _is_video(media_type): - video_urls.append(url) - else: - file_urls.append(url) - - return image_urls, video_urls, file_urls, unknown_attachments - - -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, file_urls, unknown_attachments = _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, - file_urls=file_urls, - unknown_attachments=unknown_attachments, - reply_to_max_message_id=reply_mid, - reply_preview_text=reply_preview, - ) diff --git a/src/models.py b/src/models.py deleted file mode 100644 index 258e3c5..0000000 --- a/src/models.py +++ /dev/null @@ -1,19 +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) - file_urls: list[str] = field(default_factory=list) - # URL -> исходноС имя Ρ„Π°ΠΉΠ»Π° (Ссли ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΠΎΠΏΡ€Π΅Π΄Π΅Π»ΠΈΡ‚ΡŒ Π² MAX). - file_names_by_url: dict[str, str] = field(default_factory=dict) - unknown_attachments: list[str] = field(default_factory=list) - # ΠžΡ‚Π²Π΅Ρ‚ Π² MAX: Message.link ΡƒΠΊΠ°Π·Ρ‹Π²Π°Π΅Ρ‚ Π½Π° исходноС сообщСниС (Ρ‚Ρ€Π΅Π΄). - reply_to_max_message_id: str | None = None - reply_preview_text: str | None = None diff --git a/src/requirements.txt b/src/requirements.txt deleted file mode 100644 index a16ef6a..0000000 --- a/src/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -git+https://github.com/MaxApiTeam/PyMax.git@dev/1.2.6 -requests -python-dotenv diff --git a/src/reverse_bridge.py b/src/reverse_bridge.py deleted file mode 100644 index f70b3ca..0000000 --- a/src/reverse_bridge.py +++ /dev/null @@ -1,559 +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 - - for key in ("document", "audio", "voice", "animation", "sticker", "video_note"): - value = message.get(key) - if isinstance(value, dict) and value.get("file_id"): - 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) - max_chat_id = self._resolve_max_chat_id(message=message, normalized_title=normalized, chat=chat) - 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 ") - 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] = [] - extra_links: list[str] = [] - for m in messages: - extracted, links = await self._extract_attachments(m) - attachments.extend(extracted) - extra_links.extend(links) - - text = self._append_file_links(text=text, links=extra_links) - - 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, extra_links = await self._extract_attachments(message) - text = self._append_file_links(text=text, links=extra_links) - 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 - - def _resolve_max_chat_id( - self, - *, - message: dict[str, Any], - normalized_title: str, - chat: dict[str, Any], - ) -> int | None: - # ΠŸΡ€ΠΈ reply ΠΌΠ°Ρ€ΡˆΡ€ΡƒΡ‚ΠΈΠ·ΠΈΡ€ΡƒΠ΅ΠΌ ΠΏΠΎ исходному ΡΠΎΠΎΠ±Ρ‰Π΅Π½ΠΈΡŽ (контСкст Π΄ΠΈΠ°Π»ΠΎΠ³Π°), - # Ρ‡Ρ‚ΠΎΠ±Ρ‹ Π½Π΅ Π·Π°Π²ΠΈΡΠ΅Ρ‚ΡŒ ΠΎΡ‚ username/title Telegram-Ρ‡Π°Ρ‚Π°. - from_reply = self._resolve_max_chat_id_from_reply(message) - if from_reply is not None: - return from_reply - - if not normalized_title: - logger.error("Telegram chat without title/username, skip (chat=%s)", chat) - return None - - return self._resolve_max_chat_id_by_title(normalized_title) - - def _resolve_max_chat_id_from_reply(self, message: dict[str, Any]) -> int | 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")) - - max_chat_id = self._storage.get_max_chat_id_for_telegram( - telegram_chat_id=telegram_chat_id, - telegram_message_id=str(reply_mid), - ) - if not max_chat_id: - return None - try: - return int(max_chat_id) - except ValueError: - logger.warning( - "Invalid max_chat_id '%s' in mapping for Telegram %s/%s", - max_chat_id, - telegram_chat_id, - reply_mid, - ) - return None - - async def _extract_attachments(self, message: dict[str, Any]) -> tuple[list[Any], list[str]]: - attachments: list[Any] = [] - file_links: list[str] = [] - - # 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") - - for key in ("document", "audio", "voice", "animation", "sticker", "video_note"): - value = message.get(key) - if not isinstance(value, dict): - continue - file_id = str(value.get("file_id") or "") - if not file_id: - continue - try: - url = await self._telegram.get_file_url(file_id) - file_links.append(url) - except Exception: - logger.exception("Cannot fetch Telegram %s URL", key) - - return attachments, file_links - - @staticmethod - def _append_file_links(*, text: str, links: list[str]) -> str: - uniq_links = list(dict.fromkeys([str(link).strip() for link in links if str(link).strip()])) - if not uniq_links: - return text - links_block = "\n".join(f"- {url}" for url in uniq_links) - suffix = f"\n\n[Telegram files]\n{links_block}" - return f"{text}{suffix}" if text else suffix.strip() - - 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) - diff --git a/src/storage.py b/src/storage.py deleted file mode 100644 index e521ccf..0000000 --- a/src/storage.py +++ /dev/null @@ -1,168 +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_max_chat_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_chat_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]) diff --git a/src/telegram_api.py b/src/telegram_api.py deleted file mode 100644 index afe3d4b..0000000 --- a/src/telegram_api.py +++ /dev/null @@ -1,384 +0,0 @@ -import asyncio -import json -import os -import pathlib -import tempfile -import urllib.parse -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 - self._tmp_root: str | 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_document( - self, - chat_id: str, - document_url: str, - file_name: str | None = None, - caption: str | None = None, - *, - reply_to_message_id: int | None = None, - ) -> dict[str, Any]: - # Telegram часто Π½Π΅ ΠΌΠΎΠΆΠ΅Ρ‚ ΡΠΊΠ°Ρ‡Π°Ρ‚ΡŒ URL, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹Π΅ доступны Ρ‚ΠΎΠ»ΡŒΠΊΠΎ ΠΊΠ»ΠΈΠ΅Π½Ρ‚Ρƒ MAX. - # ΠŸΠΎΡΡ‚ΠΎΠΌΡƒ скачиваСм сами Π²ΠΎ Π²Ρ€Π΅ΠΌΠ΅Π½Π½Ρ‹ΠΉ Ρ„Π°ΠΉΠ» ΠΈ отправляСм ΠΊΠ°ΠΊ multipart upload. - safe_name = self._sanitize_filename(file_name) if file_name else None - tmp_path = await self._download_to_temp(document_url, preferred_filename=safe_name) - try: - return await self.send_document_file( - chat_id=chat_id, - file_path=tmp_path, - upload_filename=safe_name, - caption=caption, - reply_to_message_id=reply_to_message_id, - ) - finally: - try: - os.remove(tmp_path) - except OSError: - pass - - async def send_document_file( - self, - *, - chat_id: str, - file_path: str, - upload_filename: str | None = None, - caption: str | None = None, - reply_to_message_id: int | None = None, - ) -> dict[str, Any]: - payload: dict[str, Any] = {"chat_id": str(chat_id)} - if caption: - payload["caption"] = caption - if reply_to_message_id is not None: - payload["reply_to_message_id"] = str(int(reply_to_message_id)) - - filename = upload_filename or pathlib.Path(file_path).name - - def _do_request() -> requests.Response: - with open(file_path, "rb") as f: - files = {"document": (filename, f)} - return requests.post( - f"{self._base_url}/sendDocument", - data=payload, - files=files, - timeout=self._timeout, - ) - - response = await asyncio.to_thread(_do_request) - return self._parse_response(method="sendDocument", response=response) - - 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) - return self._parse_response(method=method, response=response) - - def _parse_response(self, *, method: str, response: requests.Response) -> dict[str, Any]: - 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 - - async def _download_to_temp(self, url: str, preferred_filename: str | None = None) -> str: - filename = preferred_filename or self._infer_filename_from_url(url) or "max-file" - tmp_dir = self._ensure_tmp_root() - fd, path = tempfile.mkstemp(prefix="max2tg_", suffix=f"_{filename}", dir=tmp_dir) - os.close(fd) - - def _do_download() -> None: - with requests.get(url, stream=True, timeout=self._timeout) as r: - r.raise_for_status() - with open(path, "wb") as f: - for chunk in r.iter_content(chunk_size=1024 * 256): - if chunk: - f.write(chunk) - - try: - await asyncio.to_thread(_do_download) - return path - except Exception: - try: - os.remove(path) - except OSError: - pass - raise - - def _ensure_tmp_root(self) -> str: - if self._tmp_root and os.path.isdir(self._tmp_root): - return self._tmp_root - self._tmp_root = tempfile.mkdtemp(prefix="max2tg_") - return self._tmp_root - - @staticmethod - def _infer_filename_from_url(url: str) -> str | None: - try: - parsed = urllib.parse.urlparse(url) - name = pathlib.Path(parsed.path).name - if name and name not in {"/", ".", ".."}: - # Windows-safe filename (ΠΈ Π²ΠΎΠΎΠ±Ρ‰Π΅ бСзопаснСС для FS). - bad = '<>:"/\\|?*' - cleaned = "".join("_" if ch in bad else ch for ch in name).strip().strip(".") - return cleaned or None - except Exception: - return None - return None - - @staticmethod - def _sanitize_filename(name: str | None) -> str | None: - if not name: - return None - bad = '<>:"/\\|?*' - cleaned = "".join("_" if ch in bad else ch for ch in str(name)).strip().strip(".") - return cleaned or None diff --git a/src/telegram_max_commands.py b/src/telegram_max_commands.py deleted file mode 100644 index 7ddcd87..0000000 --- a/src/telegram_max_commands.py +++ /dev/null @@ -1,285 +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() - - -def _deduplicate_chats(chats: list[Any]) -> list[Any]: - """ - Π’ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ ΡƒΠ½ΠΈΠΊΠ°Π»ΡŒΠ½Ρ‹Π΅ Ρ‡Π°Ρ‚Ρ‹ с сохранСниСм исходного порядка. - Π‘Π½Π°Ρ‡Π°Π»Π° пытаСмся ΡƒΠ½ΠΈΠΊΠ°Π»ΠΈΠ·ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ ΠΏΠΎ chat.id, Π·Π°Ρ‚Π΅ΠΌ ΠΏΠΎ Π½ΠΎΡ€ΠΌΠ°Π»ΠΈΠ·ΠΎΠ²Π°Π½Π½ΠΎΠΌΡƒ title. - """ - unique: list[Any] = [] - seen_ids: set[str] = set() - seen_titles: set[str] = set() - - for chat in chats: - chat_id = getattr(chat, "id", None) - if chat_id is not None: - key_id = str(chat_id).strip() - if key_id in seen_ids: - continue - seen_ids.add(key_id) - unique.append(chat) - continue - - key_title = _normalize(_max_chat_title(chat)) - if not key_title: - unique.append(chat) - continue - if key_title in seen_titles: - continue - seen_titles.add(key_title) - unique.append(chat) - - return unique - - -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 = _deduplicate_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 β€” ΠΏΡ€ΠΈΡΠΎΠ΅Π΄ΠΈΠ½ΠΈΡ‚ΡŒΡΡ ΠΊ Π³Ρ€ΡƒΠΏΠΏΠ΅/ΠΊΠ°Π½Π°Π»Ρƒ ΠΏΠΎ ссылкС\n" - "/leave <ΠΠΠ—Π’ΠΠΠ˜Π•> β€” ΠΏΠΎΠΊΠΈΠ½ΡƒΡ‚ΡŒ ΡƒΠΊΠ°Π·Π°Π½Π½Ρ‹ΠΉ ΠΊΠ°Π½Π°Π»\n" - "/last_messages <ΠΠΠ—Π’ΠΠΠ˜Π•> β€” послСдниС 10 сообщСний ΠΈΠ· ΠΊΠ°Π½Π°Π»Π°\n" - "/bind_max <ΠΠΠ—Π’ΠΠΠ˜Π•> β€” ΠΏΡ€ΠΈΠ²ΡΠ·Π°Ρ‚ΡŒ Ρ‚Π΅ΠΊΡƒΡ‰ΠΈΠΉ Telegram-Ρ‡Π°Ρ‚ ΠΊ Ρ‡Π°Ρ‚Ρƒ MAX" - ) - - if cmd == "/list": - await _refresh_chats_best_effort(max_client) - - chats = _deduplicate_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 " - 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 Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΡƒΠ²ΠΈΠ΄Π΅Ρ‚ΡŒ доступныС ΠΊΠΎΠΌΠ°Π½Π΄Ρ‹." - ) - diff --git a/tech-specs.md b/tech-specs.md index cd0b2bc..ea921e8 100644 --- a/tech-specs.md +++ b/tech-specs.md @@ -1,475 +1,262 @@ -# ВСхничСскоС Π·Π°Π΄Π°Π½ΠΈΠ΅: Π΄Π²ΡƒΠ½Π°ΠΏΡ€Π°Π²Π»Π΅Π½Π½Ρ‹ΠΉ мост MAX <-> Telegram - -## 1. НазначСниС систСмы - -БистСма Π΄ΠΎΠ»ΠΆΠ½Π° ΠΎΠ±Π΅ΡΠΏΠ΅Ρ‡ΠΈΠ²Π°Ρ‚ΡŒ Π½Π΅ΠΏΡ€Π΅Ρ€Ρ‹Π²Π½ΡƒΡŽ Π΄Π²ΡƒΠ½Π°ΠΏΡ€Π°Π²Π»Π΅Π½Π½ΡƒΡŽ ΡΠΈΠ½Ρ…Ρ€ΠΎΠ½ΠΈΠ·Π°Ρ†ΠΈΡŽ сообщСний ΠΌΠ΅ΠΆΠ΄Ρƒ мСссСндТСром MAX ΠΈ Telegram: - -- Π½Π°ΠΏΡ€Π°Π²Π»Π΅Π½ΠΈΠ΅ A: входящиС сообщСния ΠΈΠ· MAX ΠΏΠ΅Ρ€Π΅ΡΡ‹Π»Π°ΡŽΡ‚ΡΡ Π² Telegram; -- Π½Π°ΠΏΡ€Π°Π²Π»Π΅Π½ΠΈΠ΅ B: входящиС сообщСния ΠΈΠ· Telegram ΠΏΠ΅Ρ€Π΅ΡΡ‹Π»Π°ΡŽΡ‚ΡΡ Π² MAX; -- ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΈΠ²Π°ΡŽΡ‚ΡΡ тСкст, Ρ„ΠΎΡ‚ΠΎ, Π²ΠΈΠ΄Π΅ΠΎ, Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Ρ‹/Ρ„Π°ΠΉΠ»Ρ‹ ΠΈ ΠΎΡ‚Π²Π΅Ρ‚Ρ‹ (reply) Π² ΠΏΡ€Π΅Π΄Π΅Π»Π°Ρ… доступных API; -- Ρ€Π΅Π°Π»ΠΈΠ·ΠΎΠ²Π°Π½Ρ‹ дСдупликация, Ρ…Ρ€Π°Π½Π΅Π½ΠΈΠ΅ связСй ΠΌΠ΅ΠΆΠ΄Ρƒ сообщСниями Π² ΠΎΠ±ΠΎΠΈΡ… направлСниях ΠΈ Π±Π°Π·ΠΎΠ²Ρ‹ΠΉ health-monitoring; -- прСдусмотрСн fallback-ΠΌΠ°Ρ€ΡˆΡ€ΡƒΡ‚ Π² Telegram ΠΏΡ€ΠΈ отсутствии явного соотвСтствия Ρ‡Π°Ρ‚ΠΎΠ². - -БистСма рСализуСтся ΠΊΠ°ΠΊ ΠΎΠ΄ΠΈΠ½ сСрвисный процСсс (ΠΈΠ»ΠΈ ΠΊΠΎΠ½Ρ‚Π΅ΠΉΠ½Π΅Ρ€), Ρ€Π°Π±ΠΎΡ‚Π°ΡŽΡ‰ΠΈΠΉ постоянно. - -## 2. ΠšΠΎΠ½Ρ‚Π΅ΠΊΡΡ‚ ΠΈ Π³Ρ€Π°Π½ΠΈΡ†Ρ‹ - -### 2.1 Π’Π½Π΅ΡˆΠ½ΠΈΠ΅ зависимости - -ΠžΠ±ΡΠ·Π°Ρ‚Π΅Π»ΡŒΠ½Ρ‹Π΅ внСшниС систСмы: - -- API MAX (Ρ‡Π΅Ρ€Π΅Π· ΠΊΠ»ΠΈΠ΅Π½Ρ‚ΡΠΊΡƒΡŽ Π±ΠΈΠ±Π»ΠΈΠΎΡ‚Π΅ΠΊΡƒ ΠΈΠ»ΠΈ собствСнный API-ΠΊΠ»ΠΈΠ΅Π½Ρ‚); -- Telegram Bot API (Ρ‡Π΅Ρ€Π΅Π· ΠΊΠ»ΠΈΠ΅Π½Ρ‚ΡΠΊΡƒΡŽ Π±ΠΈΠ±Π»ΠΈΠΎΡ‚Π΅ΠΊΡƒ); -- SQLite (ΠΈΠ»ΠΈ совмСстимоС Ρ…Ρ€Π°Π½ΠΈΠ»ΠΈΡ‰Π΅) для локального состояния; -- ΠΏΠ΅Ρ€Π΅ΠΌΠ΅Π½Π½Ρ‹Π΅ окруТСния для ΠΊΠΎΠ½Ρ„ΠΈΠ³ΡƒΡ€Π°Ρ†ΠΈΠΈ. - -### 2.2 Π§Ρ‚ΠΎ Π²Ρ…ΠΎΠ΄ΠΈΡ‚ Π² систСму - -- запуск ΠΈ аутСнтификация ΠΊΠ»ΠΈΠ΅Π½Ρ‚Π° MAX; -- polling Telegram `getUpdates` (Π±Π΅Π· webhook); -- ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΠ° входящих событий Π² ΠΎΠ±ΠΎΠΈΡ… направлСниях; -- ΠΌΠ°Ρ€ΡˆΡ€ΡƒΡ‚ΠΈΠ·Π°Ρ†ΠΈΡ ΠΏΠΎ названию Ρ‡Π°Ρ‚Π° ΠΈ ΠΏΠΎ явным Π±ΠΈΠ½Π΄Π°ΠΌ; -- Ρ…Ρ€Π°Π½Π΅Π½ΠΈΠ΅ mapping/Π΄Π΅Π΄ΡƒΠΏΠ»ΠΈΠΊΠ°Ρ†ΠΈΠΈ/ΠΌΠ°Ρ€ΡˆΡ€ΡƒΡ‚ΠΎΠ²; -- HTTP health endpoints (`/livez`, `/healthz`). - -### 2.3 Π§Ρ‚ΠΎ НЕ Π²Ρ…ΠΎΠ΄ΠΈΡ‚ Π² систСму - -- UI/панСль администрирования; -- слоТная ΠΎΡ‡Π΅Ρ€Π΅Π΄ΡŒ сообщСний (Kafka/RabbitMQ ΠΈ Ρ‚.ΠΏ.); -- гарантированная exactly-once доставка ΠΌΠ΅ΠΆΠ΄Ρƒ ΠΏΠ»Π°Ρ‚Ρ„ΠΎΡ€ΠΌΠ°ΠΌΠΈ; -- ΠΌΠΈΠ³Ρ€Π°Ρ†ΠΈΠΈ Π‘Π” с вСрсионированиСм (Π² Π±Π°Π·ΠΎΠ²ΠΎΠΉ Ρ€Π΅Π°Π»ΠΈΠ·Π°Ρ†ΠΈΠΈ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ `CREATE TABLE IF NOT EXISTS`); -- Ρ…Ρ€Π°Π½Π΅Π½ΠΈΠ΅ ΠΌΠ΅Π΄ΠΈΠ° Π² собствСнной Ρ„Π°ΠΉΠ»ΠΎΠ²ΠΎΠΉ инфраструктурС. - -## 3. Π€ΡƒΠ½ΠΊΡ†ΠΈΠΎΠ½Π°Π»ΡŒΠ½Ρ‹Π΅ трСбования - -## 3.1 MAX -> Telegram - -ΠŸΡ€ΠΈ ΠΏΠΎΠ»ΡƒΡ‡Π΅Π½ΠΈΠΈ сообщСния ΠΈΠ· MAX систСма Π΄ΠΎΠ»ΠΆΠ½Π°: - -1. Π Π°ΡΠΏΠ°Ρ€ΡΠΈΡ‚ΡŒ сообщСниС: - - `message_id`, `chat_id`, `sender_name`, `chat_name`, тСкст; - - список `image_urls`, `video_urls`, `file_urls`; - - список нСизвСстных Π²Π»ΠΎΠΆΠ΅Π½ΠΈΠΉ; - - Π΄Π°Π½Π½Ρ‹Π΅ reply-контСкста (`reply_to_max_message_id`, preview). -2. ΠžΠ±ΠΎΠ³Π°Ρ‚ΠΈΡ‚ΡŒ Π΄Π°Π½Π½Ρ‹Π΅ Ρ‡Π΅Ρ€Π΅Π· API MAX (best effort): - - ΠΏΠΎΠΏΡ‹Ρ‚Π°Ρ‚ΡŒΡΡ ΠΏΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ Ρ‡Π΅Π»ΠΎΠ²Π΅ΠΊΠΎΡ‡ΠΈΡ‚Π°Π΅ΠΌΠΎΠ΅ имя отправитСля; - - ΠΏΠΎΠΏΡ‹Ρ‚Π°Ρ‚ΡŒΡΡ ΠΏΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ Ρ€Π΅Π°Π»ΡŒΠ½ΠΎΠ΅ Π½Π°Π·Π²Π°Π½ΠΈΠ΅ Ρ‡Π°Ρ‚Π°; - - ΠΈΠ·Π²Π»Π΅Ρ‡ΡŒ Π΄ΠΎΠΏΠΎΠ»Π½ΠΈΡ‚Π΅Π»ΡŒΠ½Ρ‹Π΅ влоТСния Ρ‡Π΅Ρ€Π΅Π· Ρ‚ΠΈΠΏΠΈΠ·ΠΈΡ€ΠΎΠ²Π°Π½Π½Ρ‹Π΅ attach-ΠΎΠ±ΡŠΠ΅ΠΊΡ‚Ρ‹. -3. ΠŸΡ€ΠΎΠ²Π΅Ρ€ΠΈΡ‚ΡŒ Π΄Π΅Π΄ΡƒΠΏΠ»ΠΈΠΊΠ°Ρ†ΠΈΡŽ ΠΏΠΎ ΠΏΠ°Ρ€Π΅ `(message_id, chat_id)`; Π΄ΡƒΠ±Π»ΠΈΠΊΠ°Ρ‚Ρ‹ Π½Π΅ ΠΎΡ‚ΠΏΡ€Π°Π²Π»ΡΡ‚ΡŒ. -4. Π’Ρ‹Π±Ρ€Π°Ρ‚ΡŒ Telegram-Ρ‡Π°Ρ‚: - - сначала Ρ‡Π΅Ρ€Π΅Π· явный route (bind) ΠΏΠΎ Π½ΠΎΡ€ΠΌΠ°Π»ΠΈΠ·ΠΎΠ²Π°Π½Π½ΠΎΠΌΡƒ названию MAX-Ρ‡Π°Ρ‚Π°; - - Π·Π°Ρ‚Π΅ΠΌ Ρ‡Π΅Ρ€Π΅Π· кэш Ρ‡Π°Ρ‚ΠΎΠ² Telegram ΠΏΠΎ совпадСнию Π·Π°Π³ΠΎΠ»ΠΎΠ²ΠΊΠ°; - - Ссли Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½ΠΎ β€” ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΠ° Π² `fallback_user_id`. -5. Π‘Ρ„ΠΎΡ€ΠΌΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ тСкст: - - Ссли Ρ†Π΅Π»Π΅Π²ΠΎΠΉ Telegram-Ρ‡Π°Ρ‚ Π½Π°ΠΉΠ΄Π΅Π½ (Π½Π΅ fallback): `"{sender}:\n{text}"`; - - Ссли fallback: `"{sender} / {chat}:\n{text}"`; - - ΠΏΡ€ΠΈ нСизвСстных влоТСниях Π΄ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ ΡƒΠ²Π΅Π΄ΠΎΠΌΠ»Π΅Π½ΠΈΠ΅ Π² ΠΊΠΎΠ½Π΅Ρ†. -6. ΠžΠ±Ρ€Π°Π±ΠΎΡ‚Π°Ρ‚ΡŒ reply: - - ΠΏΠΎΠΏΡ‹Ρ‚Π°Ρ‚ΡŒΡΡ Π½Π°ΠΉΡ‚ΠΈ Telegram `reply_to_message_id` Ρ‡Π΅Ρ€Π΅Π· mapping; - - Π½Π΅ Π΄ΡƒΠ±Π»ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ влоТСния ΠΈΠ· исходного сообщСния, Π½Π° ΠΊΠΎΡ‚ΠΎΡ€ΠΎΠ΅ ΠΎΡ‚Π²Π΅Ρ‡Π°ΡŽΡ‚; - - Ссли mapping Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½ β€” Π΄ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ тСкстовый контСкст ΠΎΡ‚Π²Π΅Ρ‚Π°. -7. ΠžΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ ΠΊΠΎΠ½Ρ‚Π΅Π½Ρ‚: - - ΠΏΡ€ΠΈ `image+video > 1` β€” ΠΎΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ Π΅Π΄ΠΈΠ½Ρ‹ΠΌ альбомом (`sendMediaGroup`); - - ΠΈΠ½Π°Ρ‡Π΅ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ тСкст/ΠΌΠ΅Π΄ΠΈΠ°/Ρ„Π°ΠΉΠ»Ρ‹ ΠΏΠΎΡˆΡ‚ΡƒΡ‡Π½ΠΎ; - - ΠΏΡ€ΠΈ ΠΏΠΎΠ»Π½ΠΎΡΡ‚ΡŒΡŽ пустом payload ΠΎΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ слуТСбный fallback-тСкст. -8. Π‘ΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ mapping ΠΎΡ‚ΠΏΡ€Π°Π²Π»Π΅Π½Π½Ρ‹Ρ… сообщСний. -9. ΠŸΠΎΠΌΠ΅Ρ‚ΠΈΡ‚ΡŒ сообщСниС ΠΊΠ°ΠΊ forwarded. -10. ΠŸΡ€ΠΈ ошибкС основного ΠΏΡƒΡ‚ΠΈ ΡΠ΄Π΅Π»Π°Ρ‚ΡŒ Π°Π²Π°Ρ€ΠΈΠΉΠ½ΠΎΠ΅ best-effort ΡƒΠ²Π΅Π΄ΠΎΠΌΠ»Π΅Π½ΠΈΠ΅ Π² fallback-Ρ‡Π°Ρ‚ Telegram. - -## 3.2 Telegram -> MAX - -БистСма Π΄ΠΎΠ»ΠΆΠ½Π° Π·Π°ΠΏΡƒΡΠΊΠ°Ρ‚ΡŒ СдинствСнный polling-Ρ†ΠΈΠΊΠ» `getUpdates` ΠΈ: - -1. ΠŸΡ€ΠΈ стартС ΠΏΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ `bot_id` Ρ‡Π΅Ρ€Π΅Π· `getMe`. -2. ΠžΠ±Π½ΠΎΠ²ΠΈΡ‚ΡŒ Π»ΠΎΠΊΠ°Π»ΡŒΠ½Ρ‹ΠΉ кэш Ρ‡Π°Ρ‚ΠΎΠ² MAX (`title -> id`) Π½Π° основС доступного списка. -3. Π’ Ρ†ΠΈΠΊΠ»Π΅ ΠΏΠΎΠ»ΡƒΡ‡Π°Ρ‚ΡŒ updates с `offset` ΠΈ `allowed_updates=["message","channel_post"]`. -4. Для ΠΊΠ°ΠΆΠ΄ΠΎΠ³ΠΎ сообщСния: - - ΠΎΡ‚Π±Ρ€ΠΎΡΠΈΡ‚ΡŒ Π½Π΅ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΈΠ²Π°Π΅ΠΌΡ‹Π΅ Ρ‚ΠΈΠΏΡ‹; - - ΠΎΡ‚Π±Ρ€ΠΎΡΠΈΡ‚ΡŒ сообщСния Π±ΠΎΡ‚Π° (Π·Π°Ρ‰ΠΈΡ‚Π° ΠΎΡ‚ ΠΏΠ΅Ρ‚Π΅Π»ΡŒ); - - ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚Π°Ρ‚ΡŒ ΠΊΠΎΠΌΠ°Π½Π΄Ρƒ `/bind_max` Π² ΠΏΡ€ΠΈΠΎΡ€ΠΈΡ‚Π΅Ρ‚Π½ΠΎΠΌ порядкС; - - ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚Π°Ρ‚ΡŒ слуТСбныС ΠΊΠΎΠΌΠ°Π½Π΄Ρ‹ управлСния MAX (Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Π»ΠΈΡ‡ΠΊΠ° + Ρ‚ΠΎΠ»ΡŒΠΊΠΎ fallback user); - - для ΠΎΠ±Ρ‹Ρ‡Π½Ρ‹Ρ… сообщСний Π½Π°ΠΉΡ‚ΠΈ Ρ‡Π°Ρ‚ MAX ΠΏΠΎ Π½ΠΎΡ€ΠΌΠ°Π»ΠΈΠ·ΠΎΠ²Π°Π½Π½ΠΎΠΌΡƒ названию Telegram-Ρ‡Π°Ρ‚Π°; - - Ссли Ρ‡Π°Ρ‚ MAX Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½ β€” Π½Π΅ ΠΏΠ΅Ρ€Π΅ΡΡ‹Π»Π°Ρ‚ΡŒ, Π»ΠΎΠ³ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ ΠΎΡˆΠΈΠ±ΠΊΡƒ. -5. ΠžΠ±Ρ€Π°Π±ΠΎΡ‚Π°Ρ‚ΡŒ `media_group_id`: - - Π±ΡƒΡ„Π΅Ρ€ΠΈΠ·ΠΎΠ²Π°Ρ‚ΡŒ элСмСнты альбома ΠΏΠΎ ΠΊΠ»ΡŽΡ‡Ρƒ `(telegram_chat_id, media_group_id)`; - - послС grace-ΠΏΠ°ΡƒΠ·Ρ‹ (ΠΎΠΊΠΎΠ»ΠΎ 1.2 сСк) ΠΎΡ‚ΠΏΡ€Π°Π²ΠΈΡ‚ΡŒ ΠΎΠ΄Π½ΠΈΠΌ сообщСниСм Π² MAX с нСсколькими влоТСниями. -6. Для ΠΎΠ΄ΠΈΠ½ΠΎΡ‡Π½Ρ‹Ρ… сообщСний: - - ΡΡ„ΠΎΡ€ΠΌΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ тСкст `"{sender}:\n{text_or_caption}"`; - - для Ρ„ΠΎΡ‚ΠΎ/Π²ΠΈΠ΄Π΅ΠΎ ΠΏΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ URL Ρ‡Π΅Ρ€Π΅Π· `getFile` ΠΈ Π²Π»ΠΎΠΆΠΈΡ‚ΡŒ Π² MAX ΠΊΠ°ΠΊ native media attach; - - для document/audio/voice/animation/sticker/video_note Π΄ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ URL-список Π² тСкстовый Π±Π»ΠΎΠΊ; - - ΠΏΡ€ΠΈ reply ΠΏΠΎΠΏΡ‹Ρ‚Π°Ρ‚ΡŒΡΡ Π½Π°ΠΉΡ‚ΠΈ ΡΠΎΠΎΡ‚Π²Π΅Ρ‚ΡΡ‚Π²ΡƒΡŽΡ‰Π΅Π΅ сообщСниС MAX Ρ‡Π΅Ρ€Π΅Π· mapping. -7. ПослС ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎΠΉ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΠΈ: - - ΠΏΠΎΡΡ‚Π°Π²ΠΈΡ‚ΡŒ Ρ€Π΅Π°ΠΊΡ†ΠΈΡŽ Π½Π° Telegram-сообщСниС (emoji, best effort); - - ΡΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ mapping `(telegram chat/message -> max chat/message)`. - -## 3.3 Π£ΠΏΡ€Π°Π²Π»ΡΡŽΡ‰ΠΈΠ΅ ΠΊΠΎΠΌΠ°Π½Π΄Ρ‹ Π² Telegram - -ΠšΠΎΠΌΠ°Π½Π΄Ρ‹ ΠΎΠ±Ρ€Π°Π±Π°Ρ‚Ρ‹Π²Π°ΡŽΡ‚ΡΡ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Π² ΠΏΡ€ΠΈΠ²Π°Ρ‚Π½ΠΎΠΌ Ρ‡Π°Ρ‚Π΅ с Π±ΠΎΡ‚ΠΎΠΌ ΠΈ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ ΠΎΡ‚ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ `fallback_user_id`, ΠΊΡ€ΠΎΠΌΠ΅ `/bind_max` (ΠΎΠ½Π° ΠΌΠΎΠΆΠ΅Ρ‚ Ρ€Π°Π±ΠΎΡ‚Π°Ρ‚ΡŒ Π² Ρ†Π΅Π»Π΅Π²ΠΎΠΌ Ρ‡Π°Ρ‚Π΅). - -ΠŸΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΈΠ²Π°Π΅ΠΌΡ‹Π΅ ΠΊΠΎΠΌΠ°Π½Π΄Ρ‹: - -- `/help` β€” список ΠΊΠΎΠΌΠ°Π½Π΄; -- `/list` β€” список Π°ΠΊΡ‚ΠΈΠ²Π½Ρ‹Ρ… Ρ‡Π°Ρ‚ΠΎΠ² MAX; -- `/join ` β€” вступлСниС Π² Π³Ρ€ΡƒΠΏΠΏΡƒ/ΠΊΠ°Π½Π°Π» MAX ΠΏΠΎ ссылкС; -- `/leave <ΠΠΠ—Π’ΠΠΠ˜Π•>` β€” Π²Ρ‹Ρ…ΠΎΠ΄ ΠΈΠ· ΠΊΠ°Π½Π°Π»Π° MAX (Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Ссли Ρ‚ΠΈΠΏ Ρ‡Π°Ρ‚Π° ΠΎΠΏΡ€Π΅Π΄Π΅Π»Π΅Π½ ΠΊΠ°ΠΊ ΠΊΠ°Π½Π°Π»); -- `/last_messages <ΠΠΠ—Π’ΠΠΠ˜Π•>` β€” послСдниС 10 сообщСний; -- `/bind_max <Ρ‚ΠΎΡ‡Π½ΠΎΠ΅ Π½Π°Π·Π²Π°Π½ΠΈΠ΅ MAX-Ρ‡Π°Ρ‚Π°>` β€” привязка Ρ‚Π΅ΠΊΡƒΡ‰Π΅Π³ΠΎ Telegram-Ρ‡Π°Ρ‚Π° ΠΊ MAX-Ρ‡Π°Ρ‚Ρƒ. - -ВрСбования: - -- Ρƒ ΠΊΠΎΠΌΠ°Π½Π΄ Π΄ΠΎΠ»ΠΆΠ½Ρ‹ Π±Ρ‹Ρ‚ΡŒ понятныС тСкстовыС ΠΎΡ‚Π²Π΅Ρ‚Ρ‹; -- ошибки Π²Π½Π΅ΡˆΠ½ΠΈΡ… API Π΄ΠΎΠ»ΠΆΠ½Ρ‹ Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Ρ‚ΡŒΡΡ Π² ΠΎΡ‚Π²Π΅Ρ‚Π΅, Π½Π΅ падая процСссом; -- `/bind_max` Π΄ΠΎΠ»ΠΆΠ½Π° Π²Π°Π»ΠΈΠ΄ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ сущСствованиС MAX-Ρ‡Π°Ρ‚Π° ΠΏΠ΅Ρ€Π΅Π΄ сохранСниСм ΠΌΠ°Ρ€ΡˆΡ€ΡƒΡ‚Π°. - -## 4. ΠΠ΅Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΎΠ½Π°Π»ΡŒΠ½Ρ‹Π΅ трСбования - -- **ΠΠ°Π΄Π΅ΠΆΠ½ΠΎΡΡ‚ΡŒ:** сСрвис Ρ€Π°Π±ΠΎΡ‚Π°Π΅Ρ‚ бСсконСчно, ΠΏΡ€ΠΈ ΠΎΡˆΠΈΠ±ΠΊΠ°Ρ… polling примСняСт backoff. -- **Π˜Π΄Π΅ΠΌΠΏΠΎΡ‚Π΅Π½Ρ‚Π½ΠΎΡΡ‚ΡŒ (частичная):** дСдупликация MAX->Telegram Ρ‡Π΅Ρ€Π΅Π· Π‘Π”. -- **ΠΠ°Π±Π»ΡŽΠ΄Π°Π΅ΠΌΠΎΡΡ‚ΡŒ:** структурированныС Π»ΠΎΠ³ΠΈ ΠΈ health endpoint. -- **ΠŸΠΎΡ€Ρ‚ΠΈΡ€ΡƒΠ΅ΠΌΠΎΡΡ‚ΡŒ:** рСализация Π²ΠΎΠ·ΠΌΠΎΠΆΠ½Π° Π½Π° любом языкС ΠΏΡ€ΠΈ соблюдСнии ΠΊΠΎΠ½Ρ‚Ρ€Π°ΠΊΡ‚ΠΎΠ². -- **ΠŸΡ€ΠΎΠΈΠ·Π²ΠΎΠ΄ΠΈΡ‚Π΅Π»ΡŒΠ½ΠΎΡΡ‚ΡŒ:** ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΠ° событий Π² near real-time, Π±Π΅Π· тяТСлых batch-процСссов. -- **ΠžΡ‚ΠΊΠ°Π·ΠΎΡƒΡΡ‚ΠΎΠΉΡ‡ΠΈΠ²ΠΎΡΡ‚ΡŒ:** best-effort ΠΏΡ€ΠΈ частичных ΠΎΡ‚ΠΊΠ°Π·Π°Ρ… API/ΠΌΠ΅Π΄ΠΈΠ°. - -## 5. ΠšΠΎΠ½Ρ„ΠΈΠ³ΡƒΡ€Π°Ρ†ΠΈΡ (env contract) - -ΠžΠ±ΡΠ·Π°Ρ‚Π΅Π»ΡŒΠ½Ρ‹Π΅ ΠΏΠ°Ρ€Π°ΠΌΠ΅Ρ‚Ρ€Ρ‹: - -- `MAX_PHONE` β€” Ρ‚Π΅Π»Π΅Ρ„ΠΎΠ½ Π°ΠΊΠΊΠ°ΡƒΠ½Ρ‚Π° MAX (для Π°Π²Ρ‚ΠΎΡ€ΠΈΠ·Π°Ρ†ΠΈΠΈ/сСссии); -- `MAX_WORK_DIR` β€” рабочая дирСктория ΠΊΠ»ΠΈΠ΅Π½Ρ‚Π° MAX (ΠΏΠΎ ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ `cache`); -- `TELEGRAM_BOT_TOKEN` β€” Ρ‚ΠΎΠΊΠ΅Π½ Telegram-Π±ΠΎΡ‚Π°; -- `TELEGRAM_FALLBACK_USER_ID` β€” Telegram user/chat id для fallback; -- `SQLITE_PATH` β€” ΠΏΡƒΡ‚ΡŒ Π΄ΠΎ SQLite Π‘Π” (ΠΏΠΎ ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ `${MAX_WORK_DIR}/max2telegram.db`). - -Π”ΠΎΠΏΠΎΠ»Π½ΠΈΡ‚Π΅Π»ΡŒΠ½ΠΎ: - -- `TZ` β€” Ρ‚Π°ΠΉΠΌΠ·ΠΎΠ½Π° окруТСния; -- `PYTHONUNBUFFERED` (ΠΈΠ»ΠΈ Π°Π½Π°Π»ΠΎΠ³) β€” ΠΏΠΎΠ»ΠΈΡ‚ΠΈΠΊΠ° Π±ΡƒΡ„Π΅Ρ€ΠΈΠ·Π°Ρ†ΠΈΠΈ Π»ΠΎΠ³ΠΎΠ². - -Валидация: - -- ΠΎΠ±ΡΠ·Π°Ρ‚Π΅Π»ΡŒΠ½Ρ‹Π΅ ΠΏΠ΅Ρ€Π΅ΠΌΠ΅Π½Π½Ρ‹Π΅ Π΄ΠΎΠ»ΠΆΠ½Ρ‹ ΠΏΡ€ΠΎΠ²Π΅Ρ€ΡΡ‚ΡŒΡΡ ΠΏΡ€ΠΈ стартС; -- ΠΏΡ€ΠΈ отсутствии ΠΎΠ±ΡΠ·Π°Ρ‚Π΅Π»ΡŒΠ½ΠΎΠΉ ΠΏΠ΅Ρ€Π΅ΠΌΠ΅Π½Π½ΠΎΠΉ процСсс Π·Π°Π²Π΅Ρ€ΡˆΠ°Π΅Ρ‚ запуск с явной ошибкой. - -## 6. АрхитСктура - -### 6.1 ΠšΠΎΠΌΠΏΠΎΠ½Π΅Π½Ρ‚Ρ‹ - -1. **Bootstrap / Main** - - Π·Π°Π³Ρ€ΡƒΠΆΠ°Π΅Ρ‚ ΠΊΠΎΠ½Ρ„ΠΈΠ³; - - создаСт ΠΊΠ»ΠΈΠ΅Π½Ρ‚ΠΎΠ² MAX ΠΈ Telegram; - - ΠΈΠ½ΠΈΡ†ΠΈΠ°Π»ΠΈΠ·ΠΈΡ€ΡƒΠ΅Ρ‚ storage; - - запускаСт health server; - - ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π°Π΅Ρ‚ ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊΠΈ входящих сообщСний MAX; - - запускаСт Telegram->MAX poller ΠΊΠ°ΠΊ Ρ„ΠΎΠ½ΠΎΠ²ΡƒΡŽ Π·Π°Π΄Π°Ρ‡Ρƒ. - -2. **MAX->Telegram Bridge** - - парсинг + enrich входящих MAX-сообщСний; - - ΠΌΠ°Ρ€ΡˆΡ€ΡƒΡ‚ΠΈΠ·Π°Ρ†ΠΈΡ Π² Telegram; - - ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΠ° тСкст/ΠΌΠ΅Π΄ΠΈΠ°/Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Ρ‹; - - ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΠ° ΠΌΠΈΠ³Ρ€Π°Ρ†ΠΈΠΈ Telegram chat id; - - сохранСниС mapping ΠΈ dedup. - -3. **Telegram API Client** - - HTTP-ΠΎΠ±Π΅Ρ€Ρ‚ΠΊΠ° Π½Π°Π΄ Bot API; - - ΠΌΠ΅Ρ‚ΠΎΠ΄Ρ‹ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΠΈ всСх Ρ‚ΠΈΠΏΠΎΠ² ΠΊΠΎΠ½Ρ‚Π΅Π½Ρ‚Π°; - - `getUpdates` + ΠΊΡΡˆΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ извСстных Ρ‡Π°Ρ‚ΠΎΠ²; - - `getFile` для ΠΌΠ΅Π΄ΠΈΠ° URL; - - унифицированная модСль ошибок с ΠΈΠ·Π²Π»Π΅Ρ‡Π΅Π½ΠΈΠ΅ΠΌ `migrate_to_chat_id`. - -4. **Telegram->MAX Bridge** - - polling updates; - - Ρ„ΠΈΠ»ΡŒΡ‚Ρ€Π°Ρ†ΠΈΡ собствСнных сообщСний; - - ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΠ° ΠΊΠΎΠΌΠ°Π½Π΄; - - ΠΏΡ€Π΅ΠΎΠ±Ρ€Π°Π·ΠΎΠ²Π°Π½ΠΈΠ΅ Telegram payload -> MAX message/attachments; - - буфСризация media group; - - рСакция ΠΈ mapping. - -5. **Parser MAX Message** - - ΡƒΠ½ΠΈΠ²Π΅Ρ€ΡΠ°Π»ΡŒΠ½Ρ‹ΠΉ best-effort Ρ€Π°Π·Π±ΠΎΡ€ Ρ€Π°Π·Π½ΠΎΡ€ΠΎΠ΄Π½Ρ‹Ρ… Ρ„ΠΎΡ€ΠΌΠ°Ρ‚ΠΎΠ² Π²Π»ΠΎΠΆΠ΅Π½ΠΈΠΉ; - - классификация URL ΠΏΠΎ Ρ‚ΠΈΠΏΠ°ΠΌ; - - ΠΈΠ·Π²Π»Π΅Ρ‡Π΅Π½ΠΈΠ΅ reply-контСкста. - -6. **Storage** - - Ρ‚Π°Π±Π»ΠΈΡ†Π° Π΄Π΅Π΄ΡƒΠΏΠ»ΠΈΠΊΠ°Ρ†ΠΈΠΈ; - - Ρ‚Π°Π±Π»ΠΈΡ†Π° двустороннСго mapping; - - Ρ‚Π°Π±Π»ΠΈΡ†Π° явных ΠΌΠ°Ρ€ΡˆΡ€ΡƒΡ‚ΠΎΠ² (binds). - -7. **Health subsystem** - - Ρ…Ρ€Π°Π½ΠΈΡ‚ ΠΎΡ‚ΠΌΠ΅Ρ‚ΠΊΠΈ Π²Ρ€Π΅ΠΌΠ΅Π½ΠΈ послСднСго успСха/ошибки ΠΏΠΎ MAX ΠΈ Telegram; - - Ρ„ΠΎΡ€ΠΌΠΈΡ€ΡƒΠ΅Ρ‚ snapshot; - - HTTP endpoint ΠΎΡ‚Π΄Π°Π΅Ρ‚ liveness/readiness. - -8. **Auth utility** - - ΠΎΡ‚Π΄Π΅Π»ΡŒΠ½Ρ‹ΠΉ скрипт ΠΏΠ΅Ρ€Π²ΠΈΡ‡Π½ΠΎΠΉ Π°Π²Ρ‚ΠΎΡ€ΠΈΠ·Π°Ρ†ΠΈΠΈ MAX; - - сохраняСт сСссию Π² Ρ€Π°Π±ΠΎΡ‡Π΅ΠΌ ΠΊΠ°Ρ‚Π°Π»ΠΎΠ³Π΅. - -### 6.2 ЛогичСская схСма взаимодСйствия - -- MAX event -> Parser -> MAX->TG Bridge -> Telegram API -> Storage update. -- Telegram update -> TG->MAX Bridge -> MAX API -> Storage update. -- TG->MAX Bridge Π΅Π΄ΠΈΠ½ΠΎΠ»ΠΈΡ‡Π½ΠΎ Π²Ρ‹Π·Ρ‹Π²Π°Π΅Ρ‚ `getUpdates`, ΠΎΠ΄Π½ΠΎΠ²Ρ€Π΅ΠΌΠ΅Π½Π½ΠΎ наполняя кэш Ρ‡Π°Ρ‚ΠΎΠ² Telegram. -- Storage ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ΡΡ ΠΎΠ±ΠΎΠΈΠΌΠΈ мостами ΠΊΠ°ΠΊ раздСляСмый слой состояния. -- Health обновляСтся ΠΈΠ· polling-Ρ†ΠΈΠΊΠ»ΠΎΠ² ΠΈ MAX событий. - -## 7. МодСль Π΄Π°Π½Π½Ρ‹Ρ… ΠΈ Π‘Π” - -Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅Ρ‚ΡΡ SQLite (ΠΈΠ»ΠΈ эквивалСнт Π² Π΄Ρ€ΡƒΠ³ΠΎΠΉ Π‘Π£Π‘Π”). - -### 7.1 Π’Π°Π±Π»ΠΈΡ†Π° `forwarded_messages` - -НазначСниС: дСдупликация MAX->Telegram. - -Поля: - -- `message_id TEXT NOT NULL` -- `chat_id TEXT NOT NULL` -- `forwarded_at DATETIME DEFAULT CURRENT_TIMESTAMP` - -ΠšΠ»ΡŽΡ‡: - -- `PRIMARY KEY (message_id, chat_id)` - -### 7.2 Π’Π°Π±Π»ΠΈΡ†Π° `message_mapping` - -НазначСниС: двусторонняя связка сообщСний для reply ΠΈ трассировки. - -Поля: - -- `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 NULL` -- `created_at DATETIME DEFAULT CURRENT_TIMESTAMP` - -ΠšΠ»ΡŽΡ‡: - -- `PRIMARY KEY (telegram_chat_id, telegram_message_id)` - -Π˜Π½Π΄Π΅ΠΊΡΡ‹: - -- `(max_chat_id, max_message_id)` β€” поиск Telegram-сообщСния ΠΏΠΎ MAX; -- `(telegram_chat_id, media_group_id)` β€” Π³Ρ€ΡƒΠΏΠΏΠΎΠ²Ρ‹Π΅ ΠΎΠΏΠ΅Ρ€Π°Ρ†ΠΈΠΈ альбомов. - -### 7.3 Π’Π°Π±Π»ΠΈΡ†Π° `chat_routes` - -НазначСниС: явныС ΠΌΠ°Ρ€ΡˆΡ€ΡƒΡ‚Ρ‹ MAX chat title -> Telegram chat id. - -Поля: - -- `max_chat_title_norm TEXT PRIMARY KEY` -- `telegram_chat_id TEXT NOT NULL` -- `telegram_chat_title TEXT NULL` -- `created_at DATETIME DEFAULT CURRENT_TIMESTAMP` - -Нормализация ΠΊΠ»ΡŽΡ‡Π°: - -- trim + casefold/lower (Π±Π΅Π· ΡƒΡ‡Π΅Ρ‚Π° рСгистра). - -## 8. Алгоритмы ΠΈ ΠΏΡ€Π°Π²ΠΈΠ»Π° - -## 8.1 Нормализация Π½Π°Π·Π²Π°Π½ΠΈΠΉ Ρ‡Π°Ρ‚ΠΎΠ² - -Π’ΠΎ всСх ΠΌΠ°Ρ€ΡˆΡ€ΡƒΡ‚ΠΈΠ·ΠΈΡ€ΡƒΡŽΡ‰ΠΈΡ… сравнСниях: - -- ΡƒΠ΄Π°Π»ΠΈΡ‚ΡŒ ΠΊΡ€Π°ΠΉΠ½ΠΈΠ΅ ΠΏΡ€ΠΎΠ±Π΅Π»Ρ‹; -- привСсти ΠΊ рСгистронСзависимой Ρ„ΠΎΡ€ΠΌΠ΅ (`casefold`/`lower`); -- ΡΡ€Π°Π²Π½ΠΈΠ²Π°Ρ‚ΡŒ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Π² Π½ΠΎΡ€ΠΌΠ°Π»ΠΈΠ·ΠΎΠ²Π°Π½Π½ΠΎΠΌ Π²ΠΈΠ΄Π΅. - -## 8.2 ΠŸΠΎΠ»ΠΈΡ‚ΠΈΠΊΠ° ΠΌΠ°Ρ€ΡˆΡ€ΡƒΡ‚ΠΈΠ·Π°Ρ†ΠΈΠΈ MAX -> Telegram - -ΠŸΠΎΡ€ΡΠ΄ΠΎΠΊ Π²Ρ‹Π±ΠΎΡ€Π°: - -1. `chat_routes` ΠΏΠΎ Π½ΠΎΡ€ΠΌΠ°Π»ΠΈΠ·ΠΎΠ²Π°Π½Π½ΠΎΠΌΡƒ MAX title; -2. Π»ΠΎΠΊΠ°Π»ΡŒΠ½Ρ‹ΠΉ кэш Telegram title->id (наполняСтся ΠΈΠ· `getUpdates`); -3. fallback user/chat id. - -## 8.3 ΠŸΠΎΠ»ΠΈΡ‚ΠΈΠΊΠ° ΠΌΠ΅Π΄ΠΈΠ° - -- MAX->Telegram: - - Ссли суммарно Ρ„ΠΎΡ‚ΠΎ+Π²ΠΈΠ΄Π΅ΠΎ большС ΠΎΠ΄Π½ΠΎΠ³ΠΎ, ΠΈΡΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚ΡŒ album API; - - Π΄ΠΎΠΊΡƒΠΌΠ΅Π½Ρ‚Ρ‹ ΠΎΡ‚ΠΏΡ€Π°Π²Π»ΡΡ‚ΡŒ ΠΎΡ‚Π΄Π΅Π»ΡŒΠ½Ρ‹ΠΌΠΈ сообщСниями; - - caption Π΄ΠΎΠ±Π°Π²Π»ΡΡ‚ΡŒ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ ΠΊ ΠΏΠ΅Ρ€Π²ΠΎΠΌΡƒ элСмСнту/ΠΏΠ΅Ρ€Π²ΠΎΠΌΡƒ отправляСмому ΡΠΎΠΎΠ±Ρ‰Π΅Π½ΠΈΡŽ. -- Telegram->MAX: - - Ρ„ΠΎΡ‚ΠΎ/Π²ΠΈΠ΄Π΅ΠΎ ΠΎΡ‚ΠΏΡ€Π°Π²Π»ΡΡ‚ΡŒ ΠΊΠ°ΠΊ native attachments; - - ΠΏΡ€ΠΎΡ‡ΠΈΠ΅ Ρ‚ΠΈΠΏΡ‹ Ρ„Π°ΠΉΠ»ΠΎΠ² ΠΏΡ€ΠΈΠΊΠ»Π°Π΄Ρ‹Π²Π°Ρ‚ΡŒ ссылками Π² тСкстС. - -## 8.4 Reply-сСмантика - -- Для Telegram->MAX: - - Ссли Telegram message являСтся reply, ΠΈΡΠΊΠ°Ρ‚ΡŒ ΡΠΎΠΎΡ‚Π²Π΅Ρ‚ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠΉ `max_message_id` Π² mapping; - - Ссли Π½Π°ΠΉΠ΄Π΅Π½, ΠΎΡ‚ΠΏΡ€Π°Π²Π»ΡΡ‚ΡŒ `reply_to` Π² MAX. -- Для MAX->Telegram: - - Ссли MAX message являСтся reply, ΠΈΡΠΊΠ°Ρ‚ΡŒ `telegram_message_id` Π² mapping; - - Ссли MAX message являСтся reply, Π½Π΅ ΠΊΠΎΠΏΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ ΠΌΠ΅Π΄ΠΈΠ° ΠΈΠ· `link.message` (исходного сообщСния); - - Ссли Π½Π°ΠΉΠ΄Π΅Π½, ΠΎΡ‚ΠΏΡ€Π°Π²Π»ΡΡ‚ΡŒ `reply_to_message_id`; - - Ссли Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½, Π΄ΠΎΠ±Π°Π²Π»ΡΡ‚ΡŒ Ρ‚Π΅ΠΊΡΡ‚ΠΎΠ²ΡƒΡŽ ΠΏΠΎΠΌΠ΅Ρ‚ΠΊΡƒ с ΠΏΡ€Π΅Π²ΡŒΡŽ исходного сообщСния. - -## 8.5 ΠžΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΠ° migration Π² Telegram - -Если Telegram API Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ ΠΎΡˆΠΈΠ±ΠΊΡƒ с `parameters.migrate_to_chat_id`: - -1. ΠΎΠ±Π½ΠΎΠ²ΠΈΡ‚ΡŒ ΠΌΠ°Ρ€ΡˆΡ€ΡƒΡ‚ Π² `chat_routes` Π½Π° Π½ΠΎΠ²Ρ‹ΠΉ chat id; -2. ΠΏΠΎΠ²Ρ‚ΠΎΡ€ΠΈΡ‚ΡŒ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΡƒ Π² Π½ΠΎΠ²Ρ‹ΠΉ chat id; -3. ΡΡ‡ΠΈΡ‚Π°Ρ‚ΡŒ ΠΏΠΎΠ²Ρ‚ΠΎΡ€ ΡƒΡΠΏΠ΅ΡˆΠ½Ρ‹ΠΌ ΠΈΡ‚ΠΎΠ³ΠΎΠ²Ρ‹ΠΌ Ρ€Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚ΠΎΠΌ. - -## 8.6 ДСдупликация - -- примСняСтся для MAX->Telegram ΠΏΠΎ ΠΊΠ»ΡŽΡ‡Ρƒ `(max_message_id, max_chat_id)`; -- послС ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎΠΉ ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΠΈ ΠΎΠ±ΡΠ·Π°Ρ‚Π΅Π»ΡŒΠ½ΠΎ mark-forwarded; -- Π½Π° стартС/рСстартС состояния бСрутся ΠΈΠ· Π‘Π”. - -## 8.7 Buffering media group (Telegram) - -- ΠΊΠ»ΡŽΡ‡ Π±ΡƒΡ„Π΅Ρ€Π°: `(telegram_chat_id, media_group_id)`; -- ΠΊΠ°ΠΆΠ΄ΠΎΠ΅ сообщСниС альбома копится Π² спискС; -- ΠΏΠΎ истСчСнии grace-ΠΏΠ΅Ρ€ΠΈΠΎΠ΄Π° Π³Ρ€ΡƒΠΏΠΏΠ° отправляСтся ΠΎΠ΄Π½ΠΈΠΌ Π²Ρ‹Π·ΠΎΠ²ΠΎΠΌ Π² MAX; -- послС ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΠΈ Π±ΡƒΡ„Π΅Ρ€ очищаСтся. - -## 9. API-ΠΊΠΎΠ½Ρ‚Ρ€Π°ΠΊΡ‚Ρ‹ Π²Π½ΡƒΡ‚Ρ€Π΅Π½Π½ΠΈΡ… ΠΌΠΎΠ΄ΡƒΠ»Π΅ΠΉ - -НиТС абстрактныС ΠΊΠΎΠ½Ρ‚Ρ€Π°ΠΊΡ‚Ρ‹, нСзависимыС ΠΎΡ‚ языка: - -- `Settings load_settings()` - - Ρ‡ΠΈΡ‚Π°Π΅Ρ‚ env; - - Π²Π°Π»ΠΈΠ΄ΠΈΡ€ΡƒΠ΅Ρ‚ ΠΎΠ±ΡΠ·Π°Ρ‚Π΅Π»ΡŒΠ½Ρ‹Π΅ поля; - - Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ immutable-ΠΊΠΎΠ½Ρ„ΠΈΠ³ΡƒΡ€Π°Ρ†ΠΈΡŽ. - -- `ParsedMessage parse_message(MaxMessage msg)` - - best-effort ΠΏΡ€Π΅ΠΎΠ±Ρ€Π°Π·ΠΎΠ²Π°Π½ΠΈΠ΅ сырого MAX-сообщСния Π² ΠΊΠ°Π½ΠΎΠ½ΠΈΡ‡Π΅ΡΠΊΡƒΡŽ структуру. - -- `BridgeStorage` - - `was_forwarded(message_id, chat_id) -> bool` - - `mark_forwarded(message_id, chat_id)` - - `save_mapping(telegram_chat_id, telegram_message_id, max_chat_id, max_message_id, media_group_id?)` - - `get_max_message_id_for_telegram(telegram_chat_id, telegram_message_id) -> str?` - - `get_telegram_message_id_for_max(telegram_chat_id, max_chat_id, max_message_id) -> str?` - - `set_chat_route(max_chat_title_norm, telegram_chat_id, telegram_chat_title?)` - - `get_chat_route(max_chat_title_norm) -> str?` - -- `TelegramClient` - - `resolve_target_chat_id(max_chat_name) -> (chat_id, matched_by_title)` - - `send_text/send_photo/send_video/send_document/send_media_group(...)` - - `get_updates(offset, timeout, limit) -> updates[]` - - `get_file_url(file_id) -> url` - - `add_reaction(chat_id, message_id, emoji)` - -- `MaxToTelegramBridge.forward_message(max_message)` -- `TelegramToMaxBridge.start()` -- `handle_control_command(message, max_client, telegram) -> str?` - -## 10. Health-check модСль - -Π”ΠΎΠ»ΠΆΠ½Ρ‹ Ρ…Ρ€Π°Π½ΠΈΡ‚ΡŒΡΡ timestamps: - -- telegram: `last_ok`, `last_error`; -- max: `last_ok`, `last_error`, `last_event`; -- `started_at`. - -ΠŸΠ°Ρ€Π°ΠΌΠ΅Ρ‚Ρ€: - -- `unhealthy_after_sec` (ΠΏΠΎ ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ ~15 ΠΌΠΈΠ½ΡƒΡ‚). - -ΠŸΡ€Π°Π²ΠΈΠ»Π°: - -- `telegram_healthy = last_ok exists && now-last_ok <= unhealthy_after_sec`; -- `max_healthy = last_ok exists && now-last_ok <= unhealthy_after_sec`; -- `overall_healthy = telegram_healthy && max_healthy`. - -HTTP: - -- `GET /livez` (ΠΈ `/live`, `/`) -> 200, `{status:"live", uptime_sec}`. -- `GET /healthz` (ΠΈ `/health`) -> 200 ΠΈΠ»ΠΈ 503, Π΄Π΅Ρ‚Π°Π»ΡŒΠ½Ρ‹ΠΉ JSON ΠΏΠΎ ΠΊΠΎΠΌΠΏΠΎΠ½Π΅Π½Ρ‚Π°ΠΌ. - -## 11. ПовСдСниС ΠΏΡ€ΠΈ ΠΎΡˆΠΈΠ±ΠΊΠ°Ρ… ΠΈ ΡƒΡΡ‚ΠΎΠΉΡ‡ΠΈΠ²ΠΎΡΡ‚ΡŒ - -- Π›ΡŽΠ±Π°Ρ ошибка ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΠΈ ΠΎΡ‚Π΄Π΅Π»ΡŒΠ½ΠΎΠ³ΠΎ сообщСния Π½Π΅ Π΄ΠΎΠ»ΠΆΠ½Π° ΠΎΡΡ‚Π°Π½Π°Π²Π»ΠΈΠ²Π°Ρ‚ΡŒ сСрвис. -- Polling Telegram ΠΏΡ€ΠΈ ошибкС ΡƒΡ…ΠΎΠ΄ΠΈΡ‚ Π² backoff (Π½Π°ΠΏΡ€ΠΈΠΌΠ΅Ρ€ 10 сСкунд). -- Ошибка Ρ€Π΅Π°ΠΊΡ†ΠΈΠΈ Π² Telegram Π½Π΅ влияСт Π½Π° ΠΎΡΠ½ΠΎΠ²Π½ΡƒΡŽ доставку. -- Ошибка Π°Π²Π°Ρ€ΠΈΠΉΠ½ΠΎΠ³ΠΎ увСдомлСния логируСтся, Π½ΠΎ Π½Π΅ роняСт процСсс. -- ΠŸΡ€ΠΎΠ±Π»Π΅ΠΌΡ‹ с Ρ€Π°Π·Ρ€Π΅ΡˆΠ΅Π½ΠΈΠ΅ΠΌ URL ΠΌΠ΅Π΄ΠΈΠ° ΠΎΠ±Ρ€Π°Π±Π°Ρ‚Ρ‹Π²Π°ΡŽΡ‚ΡΡ best-effort: - - Ρ‡Ρ‚ΠΎ ΡƒΠ΄Π°Π»ΠΎΡΡŒ Π΄ΠΎΡΡ‚Π°Ρ‚ΡŒ β€” отправляСтся; - - Ρ‡Ρ‚ΠΎ Π½Π΅ ΡƒΠ΄Π°Π»ΠΎΡΡŒ β€” отраТаСтся Π² тСкстС/unknown notices. - -## 12. Π›ΠΎΠ³ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ ΠΈ диагностика - -ΠžΠ±ΡΠ·Π°Ρ‚Π΅Π»ΡŒΠ½Ρ‹Π΅ события Π»ΠΎΠ³ΠΎΠ²: - -- старт/остановка ΠΊΠΎΠΌΠΏΠΎΠ½Π΅Π½Ρ‚ΠΎΠ²; -- ΠΌΠ°Ρ€ΡˆΡ€ΡƒΡ‚ΠΈΠ·Π°Ρ†ΠΈΡ сообщСний; -- ΠΎΠ±Π½Π°Ρ€ΡƒΠΆΠ΅Π½ΠΈΠ΅ Π΄ΡƒΠ±Π»ΠΈΠΊΠ°Ρ‚ΠΎΠ²; -- ошибки API ΠΈ stacktrace; -- ΡƒΡΠΏΠ΅ΡˆΠ½Π°Ρ пСрСсылка с количСством Π²Π»ΠΎΠΆΠ΅Π½ΠΈΠΉ; -- ΠΎΠΏΠ΅Ρ€Π°Ρ†ΠΈΠΈ bind ΠΈ migration chat id. - -Π Π΅ΠΊΠΎΠΌΠ΅Π½Π΄ΡƒΠ΅ΠΌΡ‹ΠΉ Ρ„ΠΎΡ€ΠΌΠ°Ρ‚: - -- timestamp + level + logger + message. - -## 13. Π‘Ρ†Π΅Π½Π°Ρ€ΠΈΠΈ запуска ΠΈ Π΄Π΅ΠΏΠ»ΠΎΠΉ - -## 13.1 Π›ΠΎΠΊΠ°Π»ΡŒΠ½Ρ‹ΠΉ запуск - -1. ΠŸΠΎΠ΄Π³ΠΎΡ‚ΠΎΠ²ΠΈΡ‚ΡŒ `.env`. -2. Π£ΡΡ‚Π°Π½ΠΎΠ²ΠΈΡ‚ΡŒ зависимости. -3. Один Ρ€Π°Π· ΠΏΡ€ΠΎΠΉΡ‚ΠΈ auth MAX (ΠΈΠ½Ρ‚Π΅Ρ€Π°ΠΊΡ‚ΠΈΠ²Π½ΠΎ), ΡΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ сСссию. -4. Π—Π°ΠΏΡƒΡΡ‚ΠΈΡ‚ΡŒ основной процСсс. - -## 13.2 ΠšΠΎΠ½Ρ‚Π΅ΠΉΠ½Π΅Ρ€Π½Ρ‹ΠΉ запуск - -- ΠΊΠΎΠ½Ρ‚Π΅ΠΉΠ½Π΅Ρ€ Π΄ΠΎΠ»ΠΆΠ΅Π½ Π²ΠΊΠ»ΡŽΡ‡Π°Ρ‚ΡŒ runtime + зависимости; -- ΠΊΠ°Ρ‚Π°Π»ΠΎΠ³ `cache` Π΄ΠΎΠ»ΠΆΠ΅Π½ Π±Ρ‹Ρ‚ΡŒ volume для сохранСния сСссии ΠΈ SQLite; -- Π΄ΠΎΠ»ΠΆΠ΅Π½ Π±Ρ‹Ρ‚ΡŒ healthcheck Ρ‡Π΅Ρ€Π΅Π· `GET /healthz`. - -## 13.3 CI/CD (Ρ€Π΅ΠΊΠΎΠΌΠ΅Π½Π΄ΠΎΠ²Π°Π½Π½ΠΎ) - -- сборка Docker image ΠΏΡ€ΠΈ push Π² основныС Π²Π΅Ρ‚ΠΊΠΈ; -- публикация Π² registry с Ρ‚Π΅Π³Π°ΠΌΠΈ для dev/release/latest; -- ΠΊΡΡˆΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ слоСв сборки. - -## 14. ВрСбования ΠΊ пСрСносимой Ρ€Π΅Π°Π»ΠΈΠ·Π°Ρ†ΠΈΠΈ (Π½Π° любом языкС) - -Π§Ρ‚ΠΎΠ±Ρ‹ Π²ΠΎΡΡΠΎΠ·Π΄Π°Ρ‚ΡŒ ΠΏΡ€ΠΎΠ΅ΠΊΡ‚ Π² Π΄Ρ€ΡƒΠ³ΠΎΠΌ языкС, Π½Π΅ΠΎΠ±Ρ…ΠΎΠ΄ΠΈΠΌΠΎ ΡΠΎΡ…Ρ€Π°Π½ΠΈΡ‚ΡŒ: - -1. Π”Π²Π° нСзависимых, Π½ΠΎ согласованных ΠΊΠ°Π½Π°Π»Π° ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΠΈ: - - event-driven для MAX сообщСний; - - polling-loop для Telegram updates. -2. Π•Π΄ΠΈΠ½ΠΎΠ΅ постоянноС Ρ…Ρ€Π°Π½ΠΈΠ»ΠΈΡ‰Π΅ с трСмя сущностями: - - dedup; - - mapping; - - routes. -3. Π˜Π΄Π΅Π½Ρ‚ΠΈΡ‡Π½Ρ‹Π΅ ΠΏΡ€Π°Π²ΠΈΠ»Π° Π½ΠΎΡ€ΠΌΠ°Π»ΠΈΠ·Π°Ρ†ΠΈΠΈ Π½Π°Π·Π²Π°Π½ΠΈΠΉ Ρ‡Π°Ρ‚ΠΎΠ² ΠΈ Π²Ρ‹Π±ΠΎΡ€Π° ΠΌΠ°Ρ€ΡˆΡ€ΡƒΡ‚Π°. -4. Reply-ΠΌΠ΅Ρ…Π°Π½ΠΈΠΊΡƒ с fallback-тСкстом ΠΏΡ€ΠΈ отсутствии mapping. -5. ΠŸΠΎΠ»ΠΈΡ‚ΠΈΠΊΡƒ media group ΠΈ порядок ΠΎΡ‚ΠΏΡ€Π°Π²ΠΊΠΈ Π²Π»ΠΎΠΆΠ΅Π½ΠΈΠΉ. -6. ΠžΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΡƒ Telegram migration (`migrate_to_chat_id`) с ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΠ΅ΠΌ ΠΌΠ°Ρ€ΡˆΡ€ΡƒΡ‚Π°. -7. Health-модСль с нСзависимыми ΠΌΠ΅Ρ‚ΠΊΠ°ΠΌΠΈ MAX/Telegram. -8. ΠžΠ³Ρ€Π°Π½ΠΈΡ‡Π΅Π½ΠΈΠ΅: Ρ‚ΠΎΠ»ΡŒΠΊΠΎ ΠΎΠ΄ΠΈΠ½ consumer `getUpdates` Π½Π° экзСмпляр Π±ΠΎΡ‚Π°. - -## 15. Acceptance criteria - -БистСма считаСтся Ρ€Π΅Π°Π»ΠΈΠ·ΠΎΠ²Π°Π½Π½ΠΎΠΉ, Ссли: - -1. ВСкст/Ρ„ΠΎΡ‚ΠΎ/Π²ΠΈΠ΄Π΅ΠΎ/Ρ„Π°ΠΉΠ»Ρ‹ ΠΊΠΎΡ€Ρ€Π΅ΠΊΡ‚Π½ΠΎ ходят Π² ΠΎΠ±Π΅ стороны. -2. MAX->Telegram Π½Π΅ Π΄ΡƒΠ±Π»ΠΈΡ€ΡƒΠ΅Ρ‚ ΡƒΠΆΠ΅ пСрСсланныС сообщСния послС рСстарта. -3. Reply Π² ΠΎΠ±Π΅ стороны сохраняСтся, Ссли mapping сущСствуСт. -4. ΠŸΡ€ΠΈ отсутствии Telegram-совпадСния сообщСниС ΡƒΡ…ΠΎΠ΄ΠΈΡ‚ Π² fallback. -5. `/bind_max` мСняСт ΠΌΠ°Ρ€ΡˆΡ€ΡƒΡ‚ ΠΈ влияСт Π½Π° ΠΏΠΎΡΠ»Π΅Π΄ΡƒΡŽΡ‰ΠΈΠ΅ MAX->Telegram сообщСния. -6. Telegram media group ΠΏΡ€ΠΈΡ…ΠΎΠ΄ΠΈΡ‚ Π² MAX ΠΊΠ°ΠΊ ΠΎΠ΄Π½ΠΎ сообщСниС с мноТСством Π²Π»ΠΎΠΆΠ΅Π½ΠΈΠΉ. -7. `/healthz` Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅Ρ‚ 200 ΠΏΡ€ΠΈ Ρ€Π°Π±ΠΎΡ‡Π΅ΠΌ MAX+Telegram ΠΈ 503 ΠΏΡ€ΠΈ Π΄Π΅Π³Ρ€Π°Π΄Π°Ρ†ΠΈΠΈ. -8. Ошибки ΠΎΡ‚Π΄Π΅Π»ΡŒΠ½Ρ‹Ρ… сообщСний Π½Π΅ приводят ΠΊ остановкС процСсса. - -## 16. Π˜Π·Π²Π΅ΡΡ‚Π½Ρ‹Π΅ ограничСния Ρ‚Π΅ΠΊΡƒΡ‰Π΅ΠΉ Π»ΠΎΠ³ΠΈΠΊΠΈ - -- Автопоиск Telegram-Ρ‡Π°Ρ‚Π° зависит ΠΎΡ‚ Ρ‚ΠΎΠ³ΠΎ, Ρ‡Ρ‚ΠΎ Ρ‡Π°Ρ‚ ΡƒΠΆΠ΅ встрСчался Π² `getUpdates`. -- Для нСстандартных Π²Π»ΠΎΠΆΠ΅Π½ΠΈΠΉ Π²ΠΎΠ·ΠΌΠΎΠΆΠ½Π° частичная дСградация Π² plain text + ссылки. -- ДСдупликация Ρ€Π΅Π°Π»ΠΈΠ·ΠΎΠ²Π°Π½Π° Ρ‚ΠΎΠ»ΡŒΠΊΠΎ для ΠΏΠΎΡ‚ΠΎΠΊΠ° MAX->Telegram. -- ΠšΠΎΠ½ΠΊΡƒΡ€Π΅Π½Ρ‚Π½Ρ‹ΠΉ доступ ΠΊ SQLite Ρ‡Π΅Ρ€Π΅Π· мноТСство соСдинСний допустим для Π½Π΅Π±ΠΎΠ»ΡŒΡˆΠΈΡ… Π½Π°Π³Ρ€ΡƒΠ·ΠΎΠΊ, Π½ΠΎ для high-load ΠΌΠΎΠΆΠ΅Ρ‚ ΠΏΠΎΡ‚Ρ€Π΅Π±ΠΎΠ²Π°Ρ‚ΡŒΡΡ ΠΈΠ½ΠΎΠΉ storage backend. - -## 17. Π Π΅ΠΊΠΎΠΌΠ΅Π½Π΄Π°Ρ†ΠΈΠΈ для Ρ€Π°ΡΡˆΠΈΡ€Π΅Π½ΠΈΡ (Π½Π΅ΠΎΠ±ΡΠ·Π°Ρ‚Π΅Π»ΡŒΠ½ΠΎ) - -- Π΄ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ ΠΌΠΈΠ³Ρ€Π°Ρ†ΠΈΠΈ схСмы Π‘Π”; -- Π΄ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ retry policy с классификациСй transient/permanent ошибок; -- Π΄ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ ΠΌΠ΅Ρ‚Ρ€ΠΈΠΊΠΈ (Prometheus/OpenTelemetry); -- Π΄ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ ΠΈΠ½Ρ‚Π΅Π³Ρ€Π°Ρ†ΠΈΠΎΠ½Π½Ρ‹Π΅ тСсты с ΠΌΠΎΠΊΠ°ΠΌΠΈ API MAX/Telegram; -- Π΄ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ пСрсистСнтный offset Telegram (Ссли Π½ΡƒΠΆΠ΅Π½ recovery Π±Π΅Π· ΠΏΠΎΠ²Ρ‚ΠΎΡ€ΠΎΠ² ΠΌΠ΅ΠΆΠ΄Ρƒ рСстартами). - +# ВСхничСская спСцификация: 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:///app/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 ` | Π’Ρ‹Ρ…ΠΎΠ΄ ΠΈΠ· ΡƒΠΊΠ°Π·Π°Π½Π½ΠΎΠ³ΠΎ MAX-ΠΊΠ°Π½Π°Π»Π° | +| `/last_messages ` | ПослСдниС 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 | `sqlite+aiosqlite:///app/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-ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒ ΠΏΠΎΠ»ΡƒΡ‡Π°Π΅Ρ‚ ΡƒΠ²Π΅Π΄ΠΎΠΌΠ»Π΅Π½ΠΈΠ΅ Π² Π›Π‘ с дСталями. | БизнСс | \ No newline at end of file