"""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 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, verify_chat_and_topic 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 sentry_sdk.types import Event, Hint from app.observability.sentry_scrub import ( drop_payments_disabled_event, redact_telegram_bot_token, scrub_payment_request_body, scrub_pii_event, ) def _before_send(event: Event, hint: Hint) -> Event | None: """Композиция payments-disabled drop (#3471) + платёжный body-wipe (PR-D2) + 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. Payments-disabled drop и платёжный body-wipe — belt-and-suspenders: этот процесс не держит ASGI-приложения (нет `request`/HTTPException в event сегодня, реальный источник 503 — app/main.py), но тот же обработчик передан ОБОИМ каналам ниже (before_send/before_send_transaction) ради единообразия со всеми точками инициализации sentry_sdk в проекте. """ dropped = drop_payments_disabled_event(event, hint) # type: ignore[arg-type] if dropped is None: return None scrubbed = scrub_payment_request_body(dropped, hint) # type: ignore[arg-type] if scrubbed is None: return None scrubbed = scrub_pii_event(scrubbed, hint) # type: ignore[arg-type] if scrubbed is None: return None return redact_telegram_bot_token(scrubbed, hint) # type: ignore[arg-type,return-value] 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, before_send_transaction=_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: # `async with` — чтобы пул keep-alive соединений закрывался при любом выходе # из поллинга (кооперативный drain по SIGTERM, hard-cancel, исключение). # Клиент один на весь процесс: пересоздание на запрос убивало keep-alive и # заставляло каждый long-poll начинаться с TCP+TLS-хендшейка. async with TelegramClient( settings.telegram_bot_token, relay_base_url=settings.telegram_relay_base_url, relay_secret=settings.telegram_relay_secret, # Бот-роль (review H2, #3471) — своя, меньшая доля общего бюджета # группы; см. докстринг настройки в app.core.config. group_rate_limit_per_minute=settings.telegram_group_rate_limit_bot_per_minute, ) as client: # Startup-проверка (#3471): убеждаемся ОДИН раз, что чат/тема живы, # прежде чем уходить в бесконечный poll loop. Не блокирует и не роняет # запуск при неудаче — см. докстринг `verify_chat_and_topic`. await verify_chat_and_topic( client, chat_id=settings.telegram_support_chat_id, topic_id=settings.telegram_support_topic_id, label="support", ) await verify_chat_and_topic( client, chat_id=settings.telegram_alerts_chat_id, topic_id=settings.telegram_alerts_topic_id, label="alerts", ) 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())