From 0a53b85b8a3193f67e148c0f2bde246cc92f010c Mon Sep 17 00:00:00 2001 From: Fringg Date: Sat, 21 Mar 2026 02:49:37 +0300 Subject: [PATCH] refactor: centralize Bot instantiation via create_bot() factory Replace all ~45 direct Bot() calls across the codebase with a centralized create_bot() factory function that automatically configures SOCKS5 proxy session when PROXY_URL is set. This ensures proxy support applies uniformly to all Telegram API traffic. Key changes: - Add app/bot_factory.py with create_bot() factory - Replace direct Bot() instantiation in 33 files - Fix session leaks in cloudpayments.py and auth.py (async with) - Replace 2 direct httpx calls to api.telegram.org with bot.create_invoice_link() (balance.py, wheel.py) - Remove now-unused imports (Bot, DefaultBotProperties, ParseMode, httpx) --- app/bot.py | 11 +--- app/bot_factory.py | 20 ++++++ app/cabinet/routes/admin_partners.py | 10 ++- app/cabinet/routes/admin_payments.py | 7 +- app/cabinet/routes/admin_pinned_messages.py | 9 +-- app/cabinet/routes/admin_promo_offers.py | 9 +-- app/cabinet/routes/admin_roles.py | 4 +- app/cabinet/routes/admin_tickets.py | 9 +-- app/cabinet/routes/admin_traffic.py | 10 +-- app/cabinet/routes/admin_withdrawals.py | 10 ++- app/cabinet/routes/auth.py | 25 +++----- app/cabinet/routes/balance.py | 67 ++++++++------------ app/cabinet/routes/gift.py | 8 +-- app/cabinet/routes/media.py | 14 +--- app/cabinet/routes/partner_application.py | 5 +- app/cabinet/routes/subscription.py | 35 ++++------ app/cabinet/routes/wheel.py | 54 ++++++---------- app/cabinet/routes/withdrawal.py | 5 +- app/database/crud/cryptobot.py | 4 +- app/handlers/referral.py | 5 +- app/services/guest_purchase_service.py | 29 ++++----- app/services/payment/cloudpayments.py | 56 ++++++---------- app/services/payment/freekassa.py | 8 +-- app/services/payment/kassa_ai.py | 8 +-- app/services/payment/pal24.py | 2 +- app/services/promo_group_assignment.py | 5 +- app/services/rbac_bootstrap_service.py | 3 +- app/services/subscription_renewal_service.py | 3 +- app/webapi/routes/media.py | 14 +--- app/webapi/routes/miniapp.py | 6 +- app/webapi/routes/pinned_messages.py | 9 +-- app/webapi/routes/polls.py | 10 +-- app/webapi/routes/tickets.py | 15 +---- main.py | 5 +- 34 files changed, 180 insertions(+), 314 deletions(-) create mode 100644 app/bot_factory.py diff --git a/app/bot.py b/app/bot.py index e4c92dbb..3d656be7 100644 --- a/app/bot.py +++ b/app/bot.py @@ -96,23 +96,18 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: except Exception as e: logger.warning('Кеш не инициализирован', error=e) - from aiogram.client.default import DefaultBotProperties - from aiogram.enums import ParseMode + from app.bot_factory import create_bot + + bot = create_bot() proxy_url = settings.get_proxy_url() - session = None if proxy_url: from urllib.parse import urlparse - from aiogram.client.session.aiohttp import AiohttpSession - - session = AiohttpSession(proxy=proxy_url) parsed = urlparse(proxy_url) masked = f'{parsed.scheme}://***@{parsed.hostname}:{parsed.port}' if parsed.username else proxy_url logger.info('Proxy configured', proxy_url=masked) - bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML), session=session) - maintenance_service.set_bot(bot) logger.info('Бот установлен в maintenance_service') diff --git a/app/bot_factory.py b/app/bot_factory.py new file mode 100644 index 00000000..89e6b5f6 --- /dev/null +++ b/app/bot_factory.py @@ -0,0 +1,20 @@ +"""Factory for creating Bot instances with proxy support.""" + +from aiogram import Bot +from aiogram.client.default import DefaultBotProperties +from aiogram.enums import ParseMode + +from app.config import settings + + +def create_bot(token: str | None = None, **kwargs) -> Bot: + """Create a Bot instance with SOCKS5 proxy session if PROXY_URL is configured.""" + proxy_url = settings.get_proxy_url() + session = None + if proxy_url: + from aiogram.client.session.aiohttp import AiohttpSession + + session = AiohttpSession(proxy=proxy_url) + + kwargs.setdefault('default', DefaultBotProperties(parse_mode=ParseMode.HTML)) + return Bot(token=token or settings.BOT_TOKEN, session=session, **kwargs) diff --git a/app/cabinet/routes/admin_partners.py b/app/cabinet/routes/admin_partners.py index 6f6d65a8..0997f566 100644 --- a/app/cabinet/routes/admin_partners.py +++ b/app/cabinet/routes/admin_partners.py @@ -227,8 +227,7 @@ async def approve_application( # Notify user about approval try: - from aiogram import Bot - + from app.bot_factory import create_bot from app.config import settings from app.services.notification_delivery_service import notification_delivery_service @@ -240,7 +239,7 @@ async def approve_application( tg_message = ( f'✅ Ваша заявка на партнёрство одобрена!\nКомиссия: {request.commission_percent}%{comment_text}' ) - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() try: await notification_delivery_service.notify_partner_approved( user=user, @@ -280,8 +279,7 @@ async def reject_application( # Notify user about rejection try: - from aiogram import Bot - + from app.bot_factory import create_bot from app.config import settings from app.services.notification_delivery_service import notification_delivery_service @@ -291,7 +289,7 @@ async def reject_application( if user: comment_text = f'\nПричина: {request.comment}' if request.comment else '' tg_message = f'❌ Ваша заявка на партнёрство отклонена.{comment_text}' - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() try: await notification_delivery_service.notify_partner_rejected( user=user, diff --git a/app/cabinet/routes/admin_payments.py b/app/cabinet/routes/admin_payments.py index 030137c8..b082754e 100644 --- a/app/cabinet/routes/admin_payments.py +++ b/app/cabinet/routes/admin_payments.py @@ -4,14 +4,11 @@ import math from datetime import UTC, datetime, timedelta import structlog -from aiogram import Bot -from aiogram.client.default import DefaultBotProperties -from aiogram.enums import ParseMode from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings +from app.bot_factory import create_bot from app.database.models import PaymentMethod, User from app.services.payment_search_service import ( MAX_ALL_TIME_DAYS, @@ -550,7 +547,7 @@ async def check_payment_status( old_is_paid = record.is_paid # Run manual check - bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) + bot = create_bot() try: payment_service = PaymentService(bot=bot) updated = await run_manual_check(db, payment_method, payment_id, payment_service) diff --git a/app/cabinet/routes/admin_pinned_messages.py b/app/cabinet/routes/admin_pinned_messages.py index 77dc0341..f780893b 100644 --- a/app/cabinet/routes/admin_pinned_messages.py +++ b/app/cabinet/routes/admin_pinned_messages.py @@ -5,13 +5,11 @@ from datetime import UTC, datetime import structlog from aiogram import Bot -from aiogram.client.default import DefaultBotProperties -from aiogram.enums import ParseMode from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings +from app.bot_factory import create_bot from app.database.models import PinnedMessage, User from app.services.pinned_message_service import ( broadcast_pinned_message, @@ -77,10 +75,7 @@ _cached_bot: Bot | None = None def _get_bot() -> Bot: global _cached_bot if _cached_bot is None: - _cached_bot = Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) + _cached_bot = create_bot() return _cached_bot diff --git a/app/cabinet/routes/admin_promo_offers.py b/app/cabinet/routes/admin_promo_offers.py index a7102219..65e73acb 100644 --- a/app/cabinet/routes/admin_promo_offers.py +++ b/app/cabinet/routes/admin_promo_offers.py @@ -8,15 +8,13 @@ from typing import Any import structlog from aiogram import Bot -from aiogram.client.default import DefaultBotProperties -from aiogram.enums import ParseMode from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings +from app.bot_factory import create_bot from app.database.crud.discount_offer import ( count_discount_offers, list_discount_offers, @@ -369,10 +367,7 @@ async def list_offers( def _get_bot() -> Bot: """Create bot instance for sending notifications.""" - return Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) + return create_bot() def _build_default_promo_message( diff --git a/app/cabinet/routes/admin_roles.py b/app/cabinet/routes/admin_roles.py index e1c4df00..c7191c29 100644 --- a/app/cabinet/routes/admin_roles.py +++ b/app/cabinet/routes/admin_roles.py @@ -511,9 +511,7 @@ async def revoke_role( from app.database.models import UserRole # Lock the assignment row (FOR UPDATE held until commit) - result = await db.execute( - sa.select(UserRole).where(UserRole.id == assignment_id).with_for_update() - ) + result = await db.execute(sa.select(UserRole).where(UserRole.id == assignment_id).with_for_update()) user_role = result.scalar_one_or_none() if not user_role: raise HTTPException( diff --git a/app/cabinet/routes/admin_tickets.py b/app/cabinet/routes/admin_tickets.py index 3778f42b..24ee2ad4 100644 --- a/app/cabinet/routes/admin_tickets.py +++ b/app/cabinet/routes/admin_tickets.py @@ -479,14 +479,9 @@ async def reply_to_ticket( # Try to notify user via Telegram try: - from aiogram import Bot - from aiogram.client.default import DefaultBotProperties - from aiogram.enums import ParseMode + from app.bot_factory import create_bot - bot = Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) + bot = create_bot() try: from app.handlers.admin.tickets import notify_user_about_ticket_reply diff --git a/app/cabinet/routes/admin_traffic.py b/app/cabinet/routes/admin_traffic.py index 8c53fac1..e59ac1fe 100644 --- a/app/cabinet/routes/admin_traffic.py +++ b/app/cabinet/routes/admin_traffic.py @@ -7,16 +7,13 @@ import time from datetime import UTC, datetime, timedelta import structlog -from aiogram import Bot -from aiogram.client.default import DefaultBotProperties -from aiogram.enums import ParseMode from aiogram.types import BufferedInputFile from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.config import settings +from app.bot_factory import create_bot from app.database.models import Subscription, Transaction, TransactionType, User from app.services.remnawave_service import RemnaWaveService @@ -680,10 +677,7 @@ async def export_traffic_csv( filename = f'traffic_usage_{period_label}_{timestamp}.csv' try: - bot = Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) + bot = create_bot() async with bot: await bot.send_document( chat_id=admin.telegram_id, diff --git a/app/cabinet/routes/admin_withdrawals.py b/app/cabinet/routes/admin_withdrawals.py index 9016e78f..956b18f7 100644 --- a/app/cabinet/routes/admin_withdrawals.py +++ b/app/cabinet/routes/admin_withdrawals.py @@ -199,8 +199,7 @@ async def approve_withdrawal( # Notify user about approval try: - from aiogram import Bot - + from app.bot_factory import create_bot from app.config import settings from app.services.notification_delivery_service import notification_delivery_service @@ -211,7 +210,7 @@ async def approve_withdrawal( formatted_amount = settings.format_price(withdrawal.amount_kopeks) comment_text = f'\n{request.comment}' if request.comment else '' tg_message = f'✅ Ваш запрос на вывод {formatted_amount} одобрен.{comment_text}' - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() try: await notification_delivery_service.notify_withdrawal_approved( user=user, @@ -251,8 +250,7 @@ async def reject_withdrawal( # Notify user about rejection try: - from aiogram import Bot - + from app.bot_factory import create_bot from app.config import settings from app.services.notification_delivery_service import notification_delivery_service @@ -263,7 +261,7 @@ async def reject_withdrawal( formatted_amount = settings.format_price(withdrawal.amount_kopeks) comment_text = f'\nПричина: {request.comment}' if request.comment else '' tg_message = f'❌ Ваш запрос на вывод {formatted_amount} отклонён.{comment_text}' - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() try: await notification_delivery_service.notify_withdrawal_rejected( user=user, diff --git a/app/cabinet/routes/auth.py b/app/cabinet/routes/auth.py index f2adcd7b..d47cce94 100644 --- a/app/cabinet/routes/auth.py +++ b/app/cabinet/routes/auth.py @@ -196,12 +196,10 @@ async def _process_campaign_bonus( user.referred_by_id = campaign.partner_user_id await db.flush() try: - from aiogram import Bot - from aiogram.client.default import DefaultBotProperties - from aiogram.enums import ParseMode + from app.bot_factory import create_bot - bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) - await process_referral_registration(db, user.id, campaign.partner_user_id, bot=bot) + async with create_bot() as bot: + await process_referral_registration(db, user.id, campaign.partner_user_id, bot=bot) logger.info( 'Referral set from campaign partner', user_id=user.id, @@ -255,12 +253,11 @@ async def _process_referral_code( return user.referred_by_id = referrer.id await db.flush() - from aiogram import Bot - from aiogram.client.default import DefaultBotProperties - from aiogram.enums import ParseMode - bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) - await process_referral_registration(db, user.id, referrer.id, bot=bot) + from app.bot_factory import create_bot + + async with create_bot() as bot: + await process_referral_registration(db, user.id, referrer.id, bot=bot) logger.info('Referral applied from code', user_id=user.id, referrer_id=referrer.id, referral_code=referral_code) except Exception as e: logger.error('Failed to process referral code', error=e, referral_code=referral_code) @@ -937,12 +934,10 @@ async def register_email_standalone( # Обработать реферальную регистрацию (если есть реферер) if referrer: try: - from aiogram import Bot - from aiogram.client.default import DefaultBotProperties - from aiogram.enums import ParseMode + from app.bot_factory import create_bot - bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) - await process_referral_registration(db, user.id, referrer.id, bot=bot) + async with create_bot() as bot: + await process_referral_registration(db, user.id, referrer.id, bot=bot) logger.info( 'Processed referral registration: user_id=, referrer_id', user_id=user.id, referrer_id=referrer.id ) diff --git a/app/cabinet/routes/balance.py b/app/cabinet/routes/balance.py index db0b7771..d1bb2842 100644 --- a/app/cabinet/routes/balance.py +++ b/app/cabinet/routes/balance.py @@ -4,15 +4,12 @@ import math import time from decimal import ROUND_HALF_UP, Decimal, InvalidOperation -import httpx import structlog -from aiogram import Bot -from aiogram.client.default import DefaultBotProperties -from aiogram.enums import ParseMode from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import desc, func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.bot_factory import create_bot from app.config import settings from app.database.crud.saved_payment_method import ( deactivate_payment_method, @@ -272,50 +269,36 @@ async def create_stars_invoice( # Create invoice through Telegram Bot API try: - bot_token = settings.BOT_TOKEN - api_url = f'https://api.telegram.org/bot{bot_token}/createInvoiceLink' + from aiogram.types import LabeledPrice - async with httpx.AsyncClient() as client: - response = await client.post( - api_url, - json={ - 'title': 'Пополнение баланса VPN', - 'description': f'Пополнение баланса на {normalized_kopeks / 100:.2f} ₽ ({stars_amount} ⭐)', - 'payload': payload, - 'provider_token': '', # Empty for Stars - 'currency': 'XTR', - 'prices': [{'label': 'Пополнение баланса', 'amount': stars_amount}], - }, + async with create_bot() as bot: + invoice_url = await bot.create_invoice_link( + title='Пополнение баланса VPN', + description=f'Пополнение баланса на {normalized_kopeks / 100:.2f} ₽ ({stars_amount} ⭐)', + payload=payload, + provider_token='', + currency='XTR', + prices=[LabeledPrice(label='Пополнение баланса', amount=stars_amount)], ) - result = response.json() + logger.info( + 'Created Stars invoice for balance top-up: user=, amount= kopeks, stars', + user_id=user.id, + amount_kopeks=request.amount_kopeks, + stars_amount=stars_amount, + ) - if not result.get('ok'): - logger.error('Telegram API error', result=result) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail='Failed to create Stars invoice', - ) + return StarsInvoiceResponse( + invoice_url=invoice_url, + stars_amount=stars_amount, + amount_kopeks=normalized_kopeks, + ) - invoice_url = result['result'] - logger.info( - 'Created Stars invoice for balance top-up: user=, amount= kopeks, stars', - user_id=user.id, - amount_kopeks=request.amount_kopeks, - stars_amount=stars_amount, - ) - - return StarsInvoiceResponse( - invoice_url=invoice_url, - stars_amount=stars_amount, - amount_kopeks=normalized_kopeks, - ) - - except httpx.HTTPError as e: - logger.error('HTTP error creating Stars invoice', error=e) + except Exception as e: + logger.error('Error creating Stars invoice', error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail='Failed to connect to Telegram API', + detail='Failed to create Stars invoice', ) @@ -1202,7 +1185,7 @@ async def check_payment_status( old_is_paid = record.is_paid # Run manual check - bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) + bot = create_bot() try: payment_service = PaymentService(bot=bot) updated = await run_manual_check(db, payment_method, payment_id, payment_service) diff --git a/app/cabinet/routes/gift.py b/app/cabinet/routes/gift.py index 9bc3bcc5..0d107360 100644 --- a/app/cabinet/routes/gift.py +++ b/app/cabinet/routes/gift.py @@ -306,9 +306,9 @@ async def create_gift_purchase( else: # 2) Fall back to Bot API (works for public usernames the bot has seen) try: - from aiogram import Bot + from app.bot_factory import create_bot - async with Bot(token=settings.BOT_TOKEN) as bot: + async with create_bot() as bot: chat = await asyncio.wait_for(bot.get_chat(chat_id=f'@{tg_username}'), timeout=5.0) pre_resolved_telegram_id = chat.id except Exception: @@ -371,9 +371,9 @@ async def create_gift_purchase( # Stars payments need a Bot instance to create invoice links bot = None if body.payment_method == 'telegram_stars': - from aiogram import Bot + from app.bot_factory import create_bot - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() payment_service = PaymentService(bot=bot) payment_result = await payment_service.create_guest_payment( diff --git a/app/cabinet/routes/media.py b/app/cabinet/routes/media.py index 4cc99427..23f5c955 100644 --- a/app/cabinet/routes/media.py +++ b/app/cabinet/routes/media.py @@ -3,13 +3,11 @@ import mimetypes import structlog -from aiogram import Bot -from aiogram.client.default import DefaultBotProperties -from aiogram.enums import ParseMode from aiogram.types import BufferedInputFile from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile, status from pydantic import BaseModel +from app.bot_factory import create_bot from app.config import settings from app.database.models import User @@ -98,10 +96,7 @@ async def upload_media( target_chat_id = _resolve_target_chat_id() upload = BufferedInputFile(file_bytes, filename=file.filename or 'upload') - bot = Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) + bot = create_bot() try: if media_type_normalized == 'photo': @@ -158,10 +153,7 @@ async def download_media( Download media file by file_id. Used to display images/documents in ticket messages. """ - bot = Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) + bot = create_bot() try: file = await bot.get_file(file_id) diff --git a/app/cabinet/routes/partner_application.py b/app/cabinet/routes/partner_application.py index 13b551da..6b2e3999 100644 --- a/app/cabinet/routes/partner_application.py +++ b/app/cabinet/routes/partner_application.py @@ -178,12 +178,11 @@ async def apply_for_partner( # Уведомляем админов о новой заявке try: - from aiogram import Bot - + from app.bot_factory import create_bot from app.services.admin_notification_service import AdminNotificationService if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN: - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() try: notification_service = AdminNotificationService(bot) await notification_service.send_partner_application_notification( diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py index da060680..641fa799 100644 --- a/app/cabinet/routes/subscription.py +++ b/app/cabinet/routes/subscription.py @@ -815,12 +815,11 @@ async def purchase_traffic( # Отправляем уведомление админам try: - from aiogram import Bot - + from app.bot_factory import create_bot from app.services.admin_notification_service import AdminNotificationService if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN: - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() try: notification_service = AdminNotificationService(bot) old_traffic = subscription.traffic_limit_gb - request.gb @@ -1043,12 +1042,11 @@ async def purchase_devices_legacy( # Отправляем уведомление админам try: - from aiogram import Bot - + from app.bot_factory import create_bot from app.services.admin_notification_service import AdminNotificationService if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN: - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() try: notification_service = AdminNotificationService(bot) await notification_service.send_subscription_update_notification( @@ -1344,12 +1342,11 @@ async def activate_trial( # Send admin notification about trial activation try: - from aiogram import Bot - + from app.bot_factory import create_bot from app.services.admin_notification_service import AdminNotificationService if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN: - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() try: notification_service = AdminNotificationService(bot) charged_amount = settings.TRIAL_ACTIVATION_PRICE if requires_payment else None @@ -1763,12 +1760,11 @@ async def submit_purchase( # Отправляем уведомление админам о покупке подписки try: - from aiogram import Bot - + from app.bot_factory import create_bot from app.services.admin_notification_service import AdminNotificationService if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN: - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() try: notification_service = AdminNotificationService(bot) is_new_subscription = result.get('was_trial_conversion') or not context.subscription @@ -2161,12 +2157,11 @@ async def purchase_tariff( # Отправляем уведомление админам о покупке/продлении тарифа try: - from aiogram import Bot - + from app.bot_factory import create_bot from app.services.admin_notification_service import AdminNotificationService if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN: - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() try: notification_service = AdminNotificationService(bot) # Определяем тип покупки: новая подписка или продление @@ -2418,12 +2413,11 @@ async def purchase_devices( # Отправляем уведомление админам try: - from aiogram import Bot - + from app.bot_factory import create_bot from app.services.admin_notification_service import AdminNotificationService if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN: - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() try: notification_service = AdminNotificationService(bot) await notification_service.send_subscription_update_notification( @@ -4204,12 +4198,11 @@ async def switch_tariff( # Отправляем уведомление админам о смене тарифа try: - from aiogram import Bot - + from app.bot_factory import create_bot from app.services.admin_notification_service import AdminNotificationService if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN: - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() try: notification_service = AdminNotificationService(bot) await notification_service.send_subscription_purchase_notification( diff --git a/app/cabinet/routes/wheel.py b/app/cabinet/routes/wheel.py index a8f965b1..9dcfe447 100644 --- a/app/cabinet/routes/wheel.py +++ b/app/cabinet/routes/wheel.py @@ -5,7 +5,6 @@ API роуты колеса удачи для пользователей. import math import time -import httpx import structlog from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel @@ -21,7 +20,6 @@ from app.cabinet.schemas.wheel import ( WheelConfigResponse, WheelPrizeDisplay, ) -from app.config import settings from app.database.crud.wheel import ( get_or_create_wheel_config, get_user_spin_history, @@ -251,44 +249,30 @@ async def create_stars_invoice( # Создаем invoice через Telegram Bot API try: - bot_token = settings.BOT_TOKEN - api_url = f'https://api.telegram.org/bot{bot_token}/createInvoiceLink' + from aiogram.types import LabeledPrice - async with httpx.AsyncClient() as client: - response = await client.post( - api_url, - json={ - 'title': 'Колесо удачи', - 'description': f'Спин колеса удачи ({stars_amount} ⭐)', - 'payload': payload, - 'provider_token': '', # Пустой для Stars - 'currency': 'XTR', - 'prices': [{'label': 'Спин колеса', 'amount': stars_amount}], - }, + from app.bot_factory import create_bot + + async with create_bot() as bot: + invoice_url = await bot.create_invoice_link( + title='Колесо удачи', + description=f'Спин колеса удачи ({stars_amount} ⭐)', + payload=payload, + provider_token='', + currency='XTR', + prices=[LabeledPrice(label='Спин колеса', amount=stars_amount)], ) - result = response.json() + logger.info('Created Stars invoice for wheel spin: user=, stars', user_id=user.id, stars_amount=stars_amount) - if not result.get('ok'): - logger.error('Telegram API error', result=result) - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail='Ошибка создания инвойса', - ) + return StarsInvoiceResponse( + invoice_url=invoice_url, + stars_amount=stars_amount, + ) - invoice_url = result['result'] - logger.info( - 'Created Stars invoice for wheel spin: user=, stars', user_id=user.id, stars_amount=stars_amount - ) - - return StarsInvoiceResponse( - invoice_url=invoice_url, - stars_amount=stars_amount, - ) - - except httpx.HTTPError as e: - logger.error('HTTP error creating invoice', error=e) + except Exception as e: + logger.error('Error creating invoice', error=e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail='Ошибка соединения с Telegram', + detail='Ошибка создания инвойса', ) diff --git a/app/cabinet/routes/withdrawal.py b/app/cabinet/routes/withdrawal.py index 132298f3..c7e9d69f 100644 --- a/app/cabinet/routes/withdrawal.py +++ b/app/cabinet/routes/withdrawal.py @@ -70,12 +70,11 @@ async def create_withdrawal( # Уведомляем админов о запросе на вывод try: - from aiogram import Bot - + from app.bot_factory import create_bot from app.services.admin_notification_service import AdminNotificationService if getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) and settings.BOT_TOKEN: - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() try: notification_service = AdminNotificationService(bot) await notification_service.send_withdrawal_request_notification( diff --git a/app/database/crud/cryptobot.py b/app/database/crud/cryptobot.py index ee7b3fc3..309844d0 100644 --- a/app/database/crud/cryptobot.py +++ b/app/database/crud/cryptobot.py @@ -67,9 +67,7 @@ async def get_cryptobot_payment_by_id(db: AsyncSession, payment_id: int) -> Cryp return result.scalar_one_or_none() -async def get_cryptobot_payment_by_invoice_id_for_update( - db: AsyncSession, invoice_id: str -) -> CryptoBotPayment | None: +async def get_cryptobot_payment_by_invoice_id_for_update(db: AsyncSession, invoice_id: str) -> CryptoBotPayment | None: result = await db.execute( select(CryptoBotPayment) .options(selectinload(CryptoBotPayment.user)) diff --git a/app/handlers/referral.py b/app/handlers/referral.py index f68a4e6c..56b0f947 100644 --- a/app/handlers/referral.py +++ b/app/handlers/referral.py @@ -540,10 +540,7 @@ async def create_invite_message(callback: types.CallbackQuery, db_user: User): # Compact share text for switch_inline_query (256-char limit) share_text = invite_text if len(share_text) > 256: - share_text = ( - texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!') - + f'\n\n👇 {bot_referral_link}' - ) + share_text = texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!') + f'\n\n👇 {bot_referral_link}' if cabinet_referral_link and len(share_text) + len(cabinet_referral_link) + 5 <= 256: share_text += f'\n🌐 {cabinet_referral_link}' share_text = share_text[:256] diff --git a/app/services/guest_purchase_service.py b/app/services/guest_purchase_service.py index e5fadc5f..3bb36cbc 100644 --- a/app/services/guest_purchase_service.py +++ b/app/services/guest_purchase_service.py @@ -46,11 +46,10 @@ async def _send_admin_notification( if not getattr(settings, 'ADMIN_NOTIFICATIONS_ENABLED', False) or not settings.BOT_TOKEN: return try: - from aiogram import Bot - + from app.bot_factory import create_bot from app.services.admin_notification_service import AdminNotificationService - async with Bot(token=settings.BOT_TOKEN) as bot: + async with create_bot() as bot: service = AdminNotificationService(bot) await service.send_guest_purchase_notification( purchase, @@ -546,9 +545,9 @@ async def _find_or_create_user( resolved_telegram_id: int | None = pre_resolved_telegram_id if resolved_telegram_id is None: try: - from aiogram import Bot + from app.bot_factory import create_bot - async with Bot(token=settings.BOT_TOKEN) as bot: + async with create_bot() as bot: chat = await asyncio.wait_for( bot.get_chat(chat_id=f'@{username}'), timeout=5.0, @@ -656,11 +655,10 @@ async def _send_telegram_gift_notification( try: import html as html_mod - from aiogram import Bot - from aiogram.client.default import DefaultBotProperties - from aiogram.enums import ParseMode from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup + from app.bot_factory import create_bot + gift_from = '' if purchase.contact_value: safe_name = html_mod.escape(purchase.contact_value) @@ -691,10 +689,7 @@ async def _send_telegram_gift_notification( ] ) - async with Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) as bot: + async with create_bot() as bot: await bot.send_message( chat_id=user.telegram_id, text=text, @@ -1205,8 +1200,7 @@ async def _send_stuck_purchase_alert(data: dict, retry_count: int, phase: str) - try: import html as html_mod - from aiogram import Bot - + from app.bot_factory import create_bot from app.services.admin_notification_service import AdminNotificationService, NotificationCategory amount_rub = data['amount_kopeks'] / 100 @@ -1225,7 +1219,7 @@ async def _send_stuck_purchase_alert(data: dict, retry_count: int, phase: str) - f'Requires manual investigation.' ) - async with Bot(token=settings.BOT_TOKEN) as bot: + async with create_bot() as bot: service = AdminNotificationService(bot) await service.send_admin_notification(text, category=NotificationCategory.ERRORS) except Exception: @@ -1244,8 +1238,7 @@ async def _send_amount_mismatch_alert( try: import html as html_mod - from aiogram import Bot - + from app.bot_factory import create_bot from app.services.admin_notification_service import AdminNotificationService, NotificationCategory text = ( @@ -1260,7 +1253,7 @@ async def _send_amount_mismatch_alert( f'Requires manual investigation.' ) - async with Bot(token=settings.BOT_TOKEN) as bot: + async with create_bot() as bot: service = AdminNotificationService(bot) await service.send_admin_notification(text, category=NotificationCategory.ERRORS) except Exception: diff --git a/app/services/payment/cloudpayments.py b/app/services/payment/cloudpayments.py index 3248e2c4..fd9966ca 100644 --- a/app/services/payment/cloudpayments.py +++ b/app/services/payment/cloudpayments.py @@ -452,18 +452,10 @@ class CloudPaymentsPaymentMixin: transaction: Any, ) -> None: """Send success notification to user via Telegram.""" - from aiogram import Bot - from aiogram.client.default import DefaultBotProperties - from aiogram.enums import ParseMode - from app.config import settings + from app.bot_factory import create_bot from app.localization.texts import get_texts - bot = Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) - # Skip email-only users (no telegram_id) if not user.telegram_id: logger.debug('Skipping CloudPayments notification for email-only user', user_id=user.id) @@ -492,15 +484,16 @@ class CloudPaymentsPaymentMixin: if referrer_info: message += f'\n\n{referrer_info}' - try: - await bot.send_message( - chat_id=user.telegram_id, - text=message, - parse_mode='HTML', - reply_markup=keyboard, - ) - except Exception as error: - logger.warning('Не удалось отправить уведомление пользователю', telegram_id=user.telegram_id, error=error) + async with create_bot() as bot: + try: + await bot.send_message( + chat_id=user.telegram_id, + text=message, + parse_mode='HTML', + reply_markup=keyboard, + ) + except Exception as error: + logger.warning('Не удалось отправить уведомление пользователю', telegram_id=user.telegram_id, error=error) async def _send_cloudpayments_fail_notification( self, @@ -508,27 +501,20 @@ class CloudPaymentsPaymentMixin: message: str, ) -> None: """Send failure notification to user via Telegram.""" - from aiogram import Bot - from aiogram.client.default import DefaultBotProperties - from aiogram.enums import ParseMode - from app.config import settings - - bot = Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) + from app.bot_factory import create_bot text = f'❌ Оплата не прошла\n\n{message}' - try: - await bot.send_message( - chat_id=telegram_id, - text=text, - parse_mode='HTML', - ) - except Exception as error: - logger.warning('Не удалось отправить уведомление пользователю', telegram_id=telegram_id, error=error) + async with create_bot() as bot: + try: + await bot.send_message( + chat_id=telegram_id, + text=text, + parse_mode='HTML', + ) + except Exception as error: + logger.warning('Не удалось отправить уведомление пользователю', telegram_id=telegram_id, error=error) async def get_cloudpayments_payment_status( self, diff --git a/app/services/payment/freekassa.py b/app/services/payment/freekassa.py index 6374b895..5c87750b 100644 --- a/app/services/payment/freekassa.py +++ b/app/services/payment/freekassa.py @@ -509,14 +509,10 @@ class FreekassaPaymentMixin: # Lock payment row before finalization to prevent concurrent double-processing locked = await freekassa_crud.get_freekassa_payment_by_id_for_update(db, payment.id) if not locked: - logger.error( - 'Freekassa status check: не удалось заблокировать платёж', payment_id=payment.id - ) + logger.error('Freekassa status check: не удалось заблокировать платёж', payment_id=payment.id) elif locked.is_paid: # Another concurrent handler already processed — skip - logger.info( - 'Freekassa платеж уже оплачен после блокировки', order_id=locked.order_id - ) + logger.info('Freekassa платеж уже оплачен после блокировки', order_id=locked.order_id) payment = locked else: payment = locked diff --git a/app/services/payment/kassa_ai.py b/app/services/payment/kassa_ai.py index c0821bd1..3c7b032b 100644 --- a/app/services/payment/kassa_ai.py +++ b/app/services/payment/kassa_ai.py @@ -488,14 +488,10 @@ class KassaAiPaymentMixin: # Lock payment row before finalization to prevent concurrent double-processing locked = await kassa_ai_crud.get_kassa_ai_payment_by_id_for_update(db, payment.id) if not locked: - logger.error( - 'KassaAI status check: не удалось заблокировать платёж', payment_id=payment.id - ) + logger.error('KassaAI status check: не удалось заблокировать платёж', payment_id=payment.id) elif locked.is_paid: # Another concurrent handler already processed — skip - logger.info( - 'KassaAI платеж уже оплачен после блокировки', order_id=locked.order_id - ) + logger.info('KassaAI платеж уже оплачен после блокировки', order_id=locked.order_id) payment = locked else: payment = locked diff --git a/app/services/payment/pal24.py b/app/services/payment/pal24.py index 2789fdda..d7c23c0c 100644 --- a/app/services/payment/pal24.py +++ b/app/services/payment/pal24.py @@ -264,7 +264,7 @@ class Pal24PaymentMixin: payment.callback_payload = callback if payment_id is not None: payment.payment_id = payment_id - payment.payment_status = (callback.get('Status') or status) + payment.payment_status = callback.get('Status') or status payment.payment_method = ( callback.get('payment_method') or callback.get('PaymentMethod') diff --git a/app/services/promo_group_assignment.py b/app/services/promo_group_assignment.py index 468eaa63..5b34a6f0 100644 --- a/app/services/promo_group_assignment.py +++ b/app/services/promo_group_assignment.py @@ -1,11 +1,10 @@ from datetime import UTC, datetime import structlog -from aiogram import Bot -from aiogram.client.default import DefaultBotProperties from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.bot_factory import create_bot from app.config import settings from app.database.crud.transaction import get_user_total_spent_kopeks from app.database.crud.user import lock_user_for_update @@ -31,7 +30,7 @@ async def _notify_admins_about_auto_assignment( logger.debug('BOT_TOKEN не настроен — пропускаем уведомление о промогруппе') return - bot = Bot(token=bot_token, default=DefaultBotProperties(parse_mode='HTML')) + bot = create_bot(token=bot_token) try: notification_service = AdminNotificationService(bot) reason = ( diff --git a/app/services/rbac_bootstrap_service.py b/app/services/rbac_bootstrap_service.py index 0111059b..82fda20f 100644 --- a/app/services/rbac_bootstrap_service.py +++ b/app/services/rbac_bootstrap_service.py @@ -251,8 +251,7 @@ async def _warn_if_no_superadmins( ) else: logger.warning( - 'No active superadmin RBAC roles in DB. ' - 'Legacy config admins (ADMIN_IDS/ADMIN_EMAILS) still have access.', + 'No active superadmin RBAC roles in DB. Legacy config admins (ADMIN_IDS/ADMIN_EMAILS) still have access.', ) diff --git a/app/services/subscription_renewal_service.py b/app/services/subscription_renewal_service.py index ce5be416..25396204 100644 --- a/app/services/subscription_renewal_service.py +++ b/app/services/subscription_renewal_service.py @@ -12,6 +12,7 @@ import structlog from aiogram import Bot from sqlalchemy.ext.asyncio import AsyncSession +from app.bot_factory import create_bot from app.config import settings from app.database.crud.subscription import ( add_subscription_servers, @@ -337,7 +338,7 @@ async def with_admin_notification_service( bot: Bot | None = None try: - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() service = AdminNotificationService(bot) await handler(service) except Exception as error: # pragma: no cover - defensive logging diff --git a/app/webapi/routes/media.py b/app/webapi/routes/media.py index 9e30d600..7e50a386 100644 --- a/app/webapi/routes/media.py +++ b/app/webapi/routes/media.py @@ -4,9 +4,6 @@ import mimetypes from typing import Any import structlog -from aiogram import Bot -from aiogram.client.default import DefaultBotProperties -from aiogram.enums import ParseMode from aiogram.types import BufferedInputFile from fastapi import ( APIRouter, @@ -20,6 +17,7 @@ from fastapi import ( status, ) +from app.bot_factory import create_bot from app.config import settings from ..dependencies import require_api_token @@ -72,10 +70,7 @@ async def upload_media( target_chat_id = _resolve_target_chat_id() upload = BufferedInputFile(file_bytes, filename=file.filename or 'upload') - bot = Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) + bot = create_bot() try: if media_type_normalized == 'photo': @@ -121,10 +116,7 @@ async def download_media( file_id: str, _: Any = Security(require_api_token), ) -> Response: - bot = Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) + bot = create_bot() try: file = await bot.get_file(file_id) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 8baab883..65b3a29e 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -10,13 +10,13 @@ from typing import Any from uuid import uuid4 import structlog -from aiogram import Bot from fastapi import APIRouter, Depends, HTTPException, status from pydantic import ValidationError from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload +from app.bot_factory import create_bot from app.config import settings from app.database.crud.discount_offer import ( get_latest_claimed_offer_for_user, @@ -928,7 +928,7 @@ async def create_payment_link( detail='Failed to prepare Stars payment', ) from exc - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() invoice_payload = _build_balance_invoice_payload(user.id, amount_kopeks) try: payment_service = PaymentService(bot) @@ -1399,7 +1399,7 @@ async def create_payment_link( if not settings.BOT_TOKEN: raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail='Bot token is not configured') - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() try: tribute_service = TributeService(bot) payment_url = await tribute_service.create_payment_link( diff --git a/app/webapi/routes/pinned_messages.py b/app/webapi/routes/pinned_messages.py index 584abc93..3d526bfc 100644 --- a/app/webapi/routes/pinned_messages.py +++ b/app/webapi/routes/pinned_messages.py @@ -4,13 +4,11 @@ from datetime import UTC, datetime from typing import Any, Optional from aiogram import Bot -from aiogram.client.default import DefaultBotProperties -from aiogram.enums import ParseMode from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import func, select, update from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings +from app.bot_factory import create_bot from app.database.models import PinnedMessage from app.services.pinned_message_service import ( broadcast_pinned_message, @@ -52,10 +50,7 @@ def _serialize_pinned_message(msg: PinnedMessage) -> PinnedMessageResponse: def _get_bot() -> Bot: """Создать экземпляр бота для API операций.""" - return Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) + return create_bot() @router.get('', response_model=PinnedMessageListResponse) diff --git a/app/webapi/routes/polls.py b/app/webapi/routes/polls.py index 016a3dac..e0844a5f 100644 --- a/app/webapi/routes/polls.py +++ b/app/webapi/routes/polls.py @@ -2,9 +2,6 @@ from __future__ import annotations from typing import Any -from aiogram import Bot -from aiogram.client.default import DefaultBotProperties -from aiogram.enums import ParseMode from fastapi import ( APIRouter, Depends, @@ -328,12 +325,9 @@ async def send_poll( total=0, ) - from app.config import settings + from app.bot_factory import create_bot - bot = Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) + bot = create_bot() try: result = await send_poll_to_users(bot, db, poll, users) diff --git a/app/webapi/routes/tickets.py b/app/webapi/routes/tickets.py index a66f7a2a..01eba7f4 100644 --- a/app/webapi/routes/tickets.py +++ b/app/webapi/routes/tickets.py @@ -4,13 +4,10 @@ from datetime import UTC, datetime from typing import Any import structlog -from aiogram import Bot -from aiogram.client.default import DefaultBotProperties -from aiogram.enums import ParseMode from fastapi import APIRouter, Depends, HTTPException, Query, Request, Security, status from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings +from app.bot_factory import create_bot from app.database.crud.ticket import TicketCRUD, TicketMessageCRUD from app.database.models import Ticket, TicketMessage, TicketStatus @@ -224,10 +221,7 @@ async def reply_to_ticket( media_caption=payload.media_caption, ) - bot = Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) + bot = create_bot() try: from app.handlers.admin.tickets import notify_user_about_ticket_reply @@ -286,10 +280,7 @@ async def get_ticket_message_media( raise HTTPException(status.HTTP_404_NOT_FOUND, 'Media not found for this message') media_url: str | None = None - bot = Bot( - token=settings.BOT_TOKEN, - default=DefaultBotProperties(parse_mode=ParseMode.HTML), - ) + bot = create_bot() try: file = await bot.get_file(message.media_file_id) if file.file_path: diff --git a/main.py b/main.py index d0053181..cc489bd9 100644 --- a/main.py +++ b/main.py @@ -964,11 +964,10 @@ async def _send_crash_notification_on_error(error: Exception) -> None: return try: - from aiogram import Bot - + from app.bot_factory import create_bot from app.services.startup_notification_service import send_crash_notification - bot = Bot(token=settings.BOT_TOKEN) + bot = create_bot() try: traceback_str = traceback.format_exc() await send_crash_notification(bot, error, traceback_str)