Files
remnawave-bedolaga-telegram…/app/webserver/telegram.py
Fringg 1f0fef114b refactor: complete structlog migration with contextvars, kwargs, and logging hardening
- Add ContextVarsMiddleware for automatic user_id/chat_id/username binding
  via structlog contextvars (aiogram) and http_method/http_path (FastAPI)
- Use bound_contextvars() context manager instead of clear_contextvars()
  to safely restore previous state instead of wiping all context
- Register ContextVarsMiddleware as outermost middleware (before GlobalError)
  so all error logs include user context
- Replace structlog.get_logger() with structlog.get_logger(__name__) across
  270 calls in 265 files for meaningful logger names
- Switch wrapper_class from BoundLogger to make_filtering_bound_logger()
  for pre-processor level filtering (performance optimization)
- Migrate 1411 %-style positional arg logger calls to structlog kwargs
  style across 161 files via AST script
- Migrate log_rotation_service.py from stdlib logging to structlog
- Add payment module prefixes to TelegramNotifierProcessor.IGNORED_LOGGER_PREFIXES
  and ExcludePaymentFilter.PAYMENT_MODULES to prevent payment data leaking
  to Telegram notifications and general log files
- Fix LoggingMiddleware: add from_user null-safety for channel posts,
  switch time.time() to time.monotonic() for duration measurement
- Remove duplicate logger assignments in purchase.py, config.py,
  inline.py, and admin/payments.py
2026-02-16 09:18:12 +03:00

249 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import asyncio
from typing import Any
import structlog
from aiogram import Bot, Dispatcher
from aiogram.types import Update
from fastapi import APIRouter, HTTPException, Request, status
from fastapi.responses import JSONResponse
from app.config import settings
logger = structlog.get_logger(__name__)
class TelegramWebhookProcessorError(RuntimeError):
"""Базовое исключение очереди Telegram webhook."""
class TelegramWebhookProcessorNotRunningError(TelegramWebhookProcessorError):
"""Очередь ещё не запущена или уже остановлена."""
class TelegramWebhookOverloadedError(TelegramWebhookProcessorError):
"""Очередь переполнена и не успевает обрабатывать новые обновления."""
class TelegramWebhookProcessor:
"""Асинхронная очередь обработки Telegram webhook-ов."""
def __init__(
self,
*,
bot: Bot,
dispatcher: Dispatcher,
queue_maxsize: int,
worker_count: int,
enqueue_timeout: float,
shutdown_timeout: float,
) -> None:
self._bot = bot
self._dispatcher = dispatcher
self._queue_maxsize = max(1, queue_maxsize)
self._worker_count = max(0, worker_count)
self._enqueue_timeout = max(0.0, enqueue_timeout)
self._shutdown_timeout = max(1.0, shutdown_timeout)
self._queue: asyncio.Queue[Update | object] = asyncio.Queue(maxsize=self._queue_maxsize)
self._workers: list[asyncio.Task[None]] = []
self._running = False
self._stop_sentinel: object = object()
self._lifecycle_lock = asyncio.Lock()
@property
def is_running(self) -> bool:
return self._running
async def start(self) -> None:
async with self._lifecycle_lock:
if self._running:
return
self._running = True
self._queue = asyncio.Queue(maxsize=self._queue_maxsize)
self._workers.clear()
for index in range(self._worker_count):
task = asyncio.create_task(
self._worker_loop(index),
name=f'telegram-webhook-worker-{index}',
)
self._workers.append(task)
if self._worker_count:
logger.info(
'🚀 Telegram webhook processor запущен: воркеров, очередь',
worker_count=self._worker_count,
queue_maxsize=self._queue_maxsize,
)
else:
logger.warning('Telegram webhook processor запущен без воркеров — обновления не будут обрабатываться')
async def stop(self) -> None:
async with self._lifecycle_lock:
if not self._running:
return
self._running = False
if self._worker_count > 0:
try:
await asyncio.wait_for(self._queue.join(), timeout=self._shutdown_timeout)
except TimeoutError:
logger.warning(
'⏱️ Не удалось дождаться завершения очереди Telegram webhook за секунд',
shutdown_timeout=self._shutdown_timeout,
)
else:
drained = 0
while not self._queue.empty():
try:
self._queue.get_nowait()
except asyncio.QueueEmpty: # pragma: no cover - гонка состояния
break
else:
drained += 1
self._queue.task_done()
if drained:
logger.warning(
'Очередь Telegram webhook остановлена без воркеров, потеряно обновлений', drained=drained
)
for _ in range(len(self._workers)):
try:
self._queue.put_nowait(self._stop_sentinel)
except asyncio.QueueFull:
# Очередь переполнена, подождём пока освободится место
await self._queue.put(self._stop_sentinel)
if self._workers:
await asyncio.gather(*self._workers, return_exceptions=True)
self._workers.clear()
logger.info('🛑 Telegram webhook processor остановлен')
async def enqueue(self, update: Update) -> None:
if not self._running:
raise TelegramWebhookProcessorNotRunningError
try:
if self._enqueue_timeout <= 0:
self._queue.put_nowait(update)
else:
await asyncio.wait_for(self._queue.put(update), timeout=self._enqueue_timeout)
except asyncio.QueueFull as error: # pragma: no cover - защитный сценарий
raise TelegramWebhookOverloadedError from error
except TimeoutError as error:
raise TelegramWebhookOverloadedError from error
async def wait_until_drained(self, timeout: float | None = None) -> None:
if not self._running or self._worker_count == 0:
return
if timeout is None:
await self._queue.join()
return
await asyncio.wait_for(self._queue.join(), timeout=timeout)
async def _worker_loop(self, worker_id: int) -> None:
try:
while True:
try:
item = await self._queue.get()
except asyncio.CancelledError: # pragma: no cover - остановка приложения
logger.debug('Worker cancelled', worker_id=worker_id)
raise
if item is self._stop_sentinel:
self._queue.task_done()
break
update = item
try:
await self._dispatcher.feed_update(self._bot, update) # type: ignore[arg-type]
except asyncio.CancelledError: # pragma: no cover - остановка приложения
logger.debug('Worker cancelled during processing', worker_id=worker_id)
raise
except Exception as error: # pragma: no cover - логируем сбой обработчика
logger.exception('Ошибка обработки Telegram update в worker', worker_id=worker_id, error=error)
finally:
self._queue.task_done()
finally:
logger.debug('Worker завершён', worker_id=worker_id)
async def _dispatch_update(
update: Update,
*,
dispatcher: Dispatcher,
bot: Bot,
processor: TelegramWebhookProcessor | None,
) -> None:
if processor is not None:
try:
await processor.enqueue(update)
except TelegramWebhookOverloadedError as error:
logger.warning('Очередь Telegram webhook переполнена', error=error)
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail='webhook_queue_full') from error
except TelegramWebhookProcessorNotRunningError as error:
logger.error('Telegram webhook processor неактивен', error=error)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail='webhook_processor_unavailable'
) from error
return
await dispatcher.feed_update(bot, update)
def create_telegram_router(
bot: Bot,
dispatcher: Dispatcher,
*,
processor: TelegramWebhookProcessor | None = None,
) -> APIRouter:
router = APIRouter()
webhook_path = settings.get_telegram_webhook_path()
secret_token = settings.WEBHOOK_SECRET_TOKEN
@router.post(webhook_path)
async def telegram_webhook(request: Request) -> JSONResponse:
if secret_token:
header_token = request.headers.get('X-Telegram-Bot-Api-Secret-Token')
if header_token != secret_token:
logger.warning('Получен Telegram webhook с неверным секретом')
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail='invalid_secret_token')
content_type = request.headers.get('content-type', '')
if content_type and 'application/json' not in content_type.lower():
raise HTTPException(status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, detail='invalid_content_type')
try:
payload: Any = await request.json()
except Exception as error: # pragma: no cover - defensive logging
logger.error('Ошибка чтения Telegram webhook', error=error)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='invalid_payload') from error
try:
update = Update.model_validate(payload)
except Exception as error: # pragma: no cover - defensive logging
logger.error('Ошибка валидации Telegram update', error=error)
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='invalid_update') from error
await _dispatch_update(update, dispatcher=dispatcher, bot=bot, processor=processor)
return JSONResponse({'status': 'ok'})
@router.get('/health/telegram-webhook')
async def telegram_webhook_health() -> JSONResponse:
return JSONResponse(
{
'status': 'ok',
'mode': settings.get_bot_run_mode(),
'path': webhook_path,
'webhook_configured': bool(settings.get_telegram_webhook_url()),
'queue_maxsize': settings.get_webhook_queue_maxsize(),
'workers': settings.get_webhook_worker_count(),
}
)
return router