"""Standalone entrypoint для Telegram support-bridge воркера (#tgsupport). Зачем отдельный процесс/контейнер: `getUpdates` long-polling держит открытый HTTP-запрос к Telegram до 30с за раз в бесконечном цикле — деплой основного API (docker restart tradein-backend) не должен обрывать эту петлю на середине, как и API не должен блокироваться долгим poll'ом. Тот же паттерн, что и `scheduler_main.py` (#1182) для scraper'ов — отдельный контейнер с тем же образом, другая команда. Запуск: python -m app.tgbot_main Kill-switch: TELEGRAM_BOT_TOKEN пуст (дефолт) → воркер логирует «disabled» и блокируется на `wait_for_shutdown()` (idle, ~0 CPU) — НЕ `sys.exit(0)`. Сервис в compose поднят с `restart: unless-stopped`, который рестартует контейнер независимо от кода выхода — чистый exit(0) без токена дал бы бесконечный рестарт-луп. Idle-блокировка держит процесс живым (автозапуск после ребута VPS работает штатно через restart-policy) без CPU-луп и без спама рестартов; SIGTERM просто убивает процесс — восстанавливать здесь нечего (bridge-задача не запущена). """ from __future__ import annotations import asyncio import logging import os import signal from contextlib import suppress from typing import Any from app.core.config import settings from app.core.db import SessionLocal from app.core.shutdown import request_shutdown, shutdown_requested, wait_for_shutdown from app.services.tgbot.bridge import run_poll_loop from app.services.tgbot.client import TelegramClient logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s", ) # httpx INFO-логи печатают ПОЛНЫЙ request URL, включая Telegram Bot API токен # в пути (https://api.telegram.org/bot:/...) — `httpx: HTTP Request: # POST https://api.telegram.org/bot/getMe "HTTP/1.1 401 Unauthorized"`. # При бесконечном long-polling'e это боевой токен в `docker logs` каждые ~30с. # WARNING+ у httpx не логирует URL запроса (#tgsupport review, воспроизведено). logging.getLogger("httpx").setLevel(logging.WARNING) logger = logging.getLogger(__name__) # Тот же safety-net паттерн, что scheduler_main.py — ниже docker stop_grace_period. _DRAIN_TIMEOUT_S = 100.0 # Мониторинг ошибок — GlitchTip (Sentry-совместимый, #396). Только integrations # без Starlette/FastAPI — здесь нет ASGI-приложения (тот же выбор что scheduler_main). if settings.glitchtip_dsn: import sentry_sdk from sentry_sdk.integrations.httpx import HttpxIntegration from sentry_sdk.integrations.logging import LoggingIntegration from app.observability.sentry_scrub import redact_telegram_bot_token, scrub_pii_event def _before_send(event: Any, hint: dict[str, Any]) -> Any: """Композиция PII-scrub (form-данные) + Telegram bot-токен redaction (#tgsupport review). Токен утекает ДВУМЯ независимыми векторами, которые `include_local_variables=False` ниже и этот хук закрывают вместе: 1. `include_local_variables=True` (sentry_sdk default) кладёт stack-frame locals (`self._base`/`url` в `TelegramClient._request`) в traceback — закрыто через `include_local_variables=False` в `sentry_sdk.init`. 2. `HttpxIntegration` кладёт полный request URL в span `data` (не только traceback) — `traces_sample_rate=0.0` спасает СЕЙЧАС, но молча перестанет спасать, если трейсинг когда-нибудь включат. Regex-редактор — belt-and-suspenders на случай #1 (если include_local_variables случайно вернут) И на span data. """ scrubbed = scrub_pii_event(event, hint) if scrubbed is None: return None return redact_telegram_bot_token(scrubbed, hint) sentry_sdk.init( dsn=settings.glitchtip_dsn, environment=settings.environment, release=os.getenv("GIT_SHA") or os.getenv("SENTRY_RELEASE") or "unknown", traces_sample_rate=0.0, send_default_pii=False, include_local_variables=False, before_send=_before_send, integrations=[ HttpxIntegration(), LoggingIntegration(level=logging.INFO, event_level=logging.ERROR), ], ) logger.info("GlitchTip monitoring enabled (tgbot_main)") def _should_run() -> bool: """Kill-switch: TELEGRAM_BOT_TOKEN не задан → бот выключен (dev/staging без секрета).""" return bool(settings.telegram_bot_token) async def _run_bridge() -> None: client = TelegramClient(settings.telegram_bot_token) await run_poll_loop(client, SessionLocal) async def _await_bridge(task: asyncio.Task[None]) -> None: """Кооперативный SIGTERM-drain — идентичная семантика scheduler_main._await_scheduler. `run_poll_loop` сам проверяет `shutdown_requested()` между итерациями (между getUpdates-вызовами) — long-polling запрос к Telegram (до 30с) докручивается, затем цикл выходит сам. Safety-net здесь на случай зависшего HTTP-вызова. """ shutdown_waiter = asyncio.create_task(wait_for_shutdown()) try: await asyncio.wait({task, shutdown_waiter}, return_when=asyncio.FIRST_COMPLETED) finally: shutdown_waiter.cancel() with suppress(asyncio.CancelledError): await shutdown_waiter if task.done(): task.result() logger.info("tgbot_main: bridge task exited cleanly") return logger.info( "tgbot_main: SIGTERM-drain — waiting up to %.0fs for current poll iteration to finish", _DRAIN_TIMEOUT_S, ) try: await asyncio.wait_for(task, timeout=_DRAIN_TIMEOUT_S) logger.info("tgbot_main: bridge drained and exited cleanly") except TimeoutError: logger.warning( "tgbot_main: drain exceeded %.0fs grace — hard-cancelling bridge task", _DRAIN_TIMEOUT_S, ) task.cancel() with suppress(asyncio.CancelledError): await task async def _run() -> None: task = asyncio.create_task(_run_bridge()) loop = asyncio.get_running_loop() def _on_signal(signum: int) -> None: logger.info("tgbot_main: signal %d received — requesting cooperative drain", signum) request_shutdown() try: loop.add_signal_handler(signal.SIGTERM, lambda: _on_signal(signal.SIGTERM)) loop.add_signal_handler(signal.SIGINT, lambda: _on_signal(signal.SIGINT)) except NotImplementedError: # Windows dev: signal handlers через loop не поддерживаются logger.warning("tgbot_main: loop.add_signal_handler not supported (Windows dev)") await _await_bridge(task) if shutdown_requested(): logger.info("tgbot_main: bridge drained cleanly (SIGTERM)") else: logger.info("tgbot_main: bridge task exited") if __name__ == "__main__": if not _should_run(): # NOT sys.exit(0): compose service has `restart: unless-stopped`, который # рестартует контейнер независимо от кода выхода — чистый exit(0) без # токена дал бы бесконечный рестарт-луп. Idle-блокировка вместо этого: # ~0 CPU, SIGTERM просто убивает процесс (нечего дренировать). logger.warning( "tgbot_main: TELEGRAM_BOT_TOKEN не задан — бот выключен, " "блокируемся на idle (не exit, чтобы не было рестарт-лупа с restart:unless-stopped)" ) asyncio.run(wait_for_shutdown()) else: asyncio.run(_run())