From 63bf04bb706f735f9f3243b41ed135b36389871a Mon Sep 17 00:00:00 2001 From: Egor Date: Wed, 8 Oct 2025 02:30:39 +0300 Subject: [PATCH] =?UTF-8?q?Revert=20"=D0=97=D0=B0=D1=89=D0=B8=D1=82=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=20=D0=B2=D0=BD?= =?UTF-8?q?=D0=B5=D1=88=D0=BD=D0=B5=D0=B9=20=D0=B0=D0=B4=D0=BC=D0=B8=D0=BD?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BE=D1=82=20=D0=BF=D0=BE=D0=B4=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=8B"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/config.py | 36 ------- app/handlers/admin/bot_configuration.py | 119 ++++----------------- app/services/external_admin_service.py | 136 ------------------------ app/services/system_settings_service.py | 54 +--------- app/webapi/routes/config.py | 16 +-- app/webapi/schemas/config.py | 1 - main.py | 20 ---- 7 files changed, 24 insertions(+), 358 deletions(-) delete mode 100644 app/services/external_admin_service.py diff --git a/app/config.py b/app/config.py index fe233c4a..4d2f184a 100644 --- a/app/config.py +++ b/app/config.py @@ -1,5 +1,3 @@ -import hashlib -import hmac import logging import os import re @@ -278,9 +276,6 @@ class Settings(BaseSettings): BACKUP_SEND_CHAT_ID: Optional[str] = None BACKUP_SEND_TOPIC_ID: Optional[int] = None - EXTERNAL_ADMIN_TOKEN: Optional[str] = None - EXTERNAL_ADMIN_TOKEN_BOT_ID: Optional[int] = None - @field_validator('SERVER_STATUS_MODE', mode='before') @classmethod def normalize_server_status_mode(cls, value: Optional[str]) -> str: @@ -561,37 +556,6 @@ class Settings(BaseSettings): def get_app_config_cache_ttl(self) -> int: return self.APP_CONFIG_CACHE_TTL - - def build_external_admin_token(self, bot_username: str) -> str: - """Генерирует детерминированный и криптографически стойкий токен внешней админки.""" - normalized = (bot_username or "").strip().lstrip("@").lower() - if not normalized: - raise ValueError("Bot username is required to build external admin token") - - secret = (self.BOT_TOKEN or "").strip() - if not secret: - raise ValueError("Bot token is required to build external admin token") - - digest = hmac.new( - key=secret.encode("utf-8"), - msg=f"remnawave.external_admin::{normalized}".encode("utf-8"), - digestmod=hashlib.sha256, - ).hexdigest() - return digest[:48] - - def get_external_admin_token(self) -> Optional[str]: - token = (self.EXTERNAL_ADMIN_TOKEN or "").strip() - return token or None - - def get_external_admin_bot_id(self) -> Optional[int]: - try: - return int(self.EXTERNAL_ADMIN_TOKEN_BOT_ID) if self.EXTERNAL_ADMIN_TOKEN_BOT_ID else None - except (TypeError, ValueError): # pragma: no cover - защитная ветка для некорректных значений - logging.getLogger(__name__).warning( - "Некорректный идентификатор бота для внешней админки: %s", - self.EXTERNAL_ADMIN_TOKEN_BOT_ID, - ) - return None def is_traffic_selectable(self) -> bool: return self.TRAFFIC_SELECTION_MODE.lower() == "selectable" diff --git a/app/handlers/admin/bot_configuration.py b/app/handlers/admin/bot_configuration.py index 9b1e2cc1..ad0bea38 100644 --- a/app/handlers/admin/bot_configuration.py +++ b/app/handlers/admin/bot_configuration.py @@ -18,10 +18,7 @@ from app.config import settings from app.services.remnawave_service import RemnaWaveService from app.services.payment_service import PaymentService from app.services.tribute_service import TributeService -from app.services.system_settings_service import ( - ReadOnlySettingError, - bot_configuration_service, -) +from app.services.system_settings_service import bot_configuration_service from app.states import BotConfigStates from app.utils.decorators import admin_required, error_handler from app.utils.currency_converter import currency_converter @@ -110,12 +107,6 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = { "icon": "⚡", "categories": ("WEB_API", "WEBHOOK", "LOG", "DEBUG"), }, - "external_admin": { - "title": "🛡️ Внешняя админка", - "description": "Токен, по которому внешняя админка проверяет запросы.", - "icon": "🛡️", - "categories": ("EXTERNAL_ADMIN",), - }, } CATEGORY_GROUP_ORDER: Tuple[str, ...] = ( @@ -132,7 +123,6 @@ CATEGORY_GROUP_ORDER: Tuple[str, ...] = ( "server", "maintenance", "advanced", - "external_admin", ) CATEGORY_GROUP_DEFINITIONS: Tuple[Tuple[str, str, Tuple[str, ...]], ...] = tuple( @@ -697,12 +687,6 @@ async def apply_preset( try: await bot_configuration_service.set_value(db, setting_key, value) applied.append(setting_key) - except ReadOnlySettingError: - logging.getLogger(__name__).info( - "Пропускаем настройку %s из пресета %s: только для чтения", - setting_key, - preset_key, - ) except Exception as error: logging.getLogger(__name__).warning( "Не удалось применить пресет %s для %s: %s", @@ -870,14 +854,8 @@ async def handle_import_message( errors.append(f"{setting_key}: {error}") continue - if bot_configuration_service.is_read_only(setting_key): - skipped.append(setting_key) - continue - try: - await bot_configuration_service.set_value(db, setting_key, value_to_apply) - applied.append(setting_key) - except ReadOnlySettingError: - skipped.append(setting_key) + await bot_configuration_service.set_value(db, setting_key, value_to_apply) + applied.append(setting_key) await db.commit() @@ -1383,10 +1361,9 @@ def _build_setting_keyboard( definition = bot_configuration_service.get_definition(key) rows: list[list[types.InlineKeyboardButton]] = [] callback_token = bot_configuration_service.get_callback_token(key) - is_read_only = bot_configuration_service.is_read_only(key) choice_options = bot_configuration_service.get_choice_options(key) - if choice_options and not is_read_only: + if choice_options: current_value = bot_configuration_service.get_current_value(key) choice_buttons: list[types.InlineKeyboardButton] = [] for option in choice_options: @@ -1408,7 +1385,7 @@ def _build_setting_keyboard( for chunk in _chunk(choice_buttons, 2): rows.append(list(chunk)) - if definition.python_type is bool and not is_read_only: + if definition.python_type is bool: rows.append([ types.InlineKeyboardButton( text="🔁 Переключить", @@ -1418,17 +1395,16 @@ def _build_setting_keyboard( ) ]) - if not is_read_only: - rows.append([ - types.InlineKeyboardButton( - text="✏️ Изменить", - callback_data=( - f"botcfg_edit:{group_key}:{category_page}:{settings_page}:{callback_token}" - ), - ) - ]) + rows.append([ + types.InlineKeyboardButton( + text="✏️ Изменить", + callback_data=( + f"botcfg_edit:{group_key}:{category_page}:{settings_page}:{callback_token}" + ), + ) + ]) - if bot_configuration_service.has_override(key) and not is_read_only: + if bot_configuration_service.has_override(key): rows.append([ types.InlineKeyboardButton( text="♻️ Сбросить", @@ -1438,14 +1414,6 @@ def _build_setting_keyboard( ) ]) - if is_read_only: - rows.append([ - types.InlineKeyboardButton( - text="🔒 Только для чтения", - callback_data="botcfg_group:noop", - ) - ]) - rows.append([ types.InlineKeyboardButton( text="⬅️ Назад", @@ -1470,11 +1438,6 @@ def _render_setting_text(key: str) -> str: f"📌 Текущее: {summary['current']}", f"📦 По умолчанию: {summary['original']}", f"✳️ Переопределено: {'Да' if summary['has_override'] else 'Нет'}", - *( - ["🔒 Режим: Только для чтения (управляется автоматически)"] - if summary.get("is_read_only") - else [] - ), "", f"📘 Описание: {guidance['description']}", f"📐 Формат: {guidance['format']}", @@ -2103,9 +2066,6 @@ async def start_edit_setting( except KeyError: await callback.answer("Эта настройка больше недоступна", show_alert=True) return - if bot_configuration_service.is_read_only(key): - await callback.answer("Эта настройка доступна только для чтения", show_alert=True) - return definition = bot_configuration_service.get_definition(key) summary = bot_configuration_service.get_setting_summary(key) @@ -2171,23 +2131,13 @@ async def handle_edit_setting( await state.clear() return - if bot_configuration_service.is_read_only(key): - await message.answer("⚠️ Эта настройка доступна только для чтения.") - await state.clear() - return - try: value = bot_configuration_service.parse_user_value(key, message.text or "") except ValueError as error: await message.answer(f"⚠️ {error}") return - try: - await bot_configuration_service.set_value(db, key, value) - except ReadOnlySettingError: - await message.answer("⚠️ Эта настройка доступна только для чтения.") - await state.clear() - return + await bot_configuration_service.set_value(db, key, value) await db.commit() text = _render_setting_text(key) @@ -2222,23 +2172,13 @@ async def handle_direct_setting_input( if not key: return - if bot_configuration_service.is_read_only(key): - await message.answer("⚠️ Эта настройка доступна только для чтения.") - await state.clear() - return - try: value = bot_configuration_service.parse_user_value(key, message.text or "") except ValueError as error: await message.answer(f"⚠️ {error}") return - try: - await bot_configuration_service.set_value(db, key, value) - except ReadOnlySettingError: - await message.answer("⚠️ Эта настройка доступна только для чтения.") - await state.clear() - return + await bot_configuration_service.set_value(db, key, value) await db.commit() text = _render_setting_text(key) @@ -2280,14 +2220,7 @@ async def reset_setting( except KeyError: await callback.answer("Эта настройка больше недоступна", show_alert=True) return - if bot_configuration_service.is_read_only(key): - await callback.answer("Эта настройка доступна только для чтения", show_alert=True) - return - try: - await bot_configuration_service.reset_value(db, key) - except ReadOnlySettingError: - await callback.answer("Эта настройка доступна только для чтения", show_alert=True) - return + await bot_configuration_service.reset_value(db, key) await db.commit() text = _render_setting_text(key) @@ -2327,16 +2260,9 @@ async def toggle_setting( except KeyError: await callback.answer("Эта настройка больше недоступна", show_alert=True) return - if bot_configuration_service.is_read_only(key): - await callback.answer("Эта настройка доступна только для чтения", show_alert=True) - return current = bot_configuration_service.get_current_value(key) new_value = not bool(current) - try: - await bot_configuration_service.set_value(db, key, new_value) - except ReadOnlySettingError: - await callback.answer("Эта настройка доступна только для чтения", show_alert=True) - return + await bot_configuration_service.set_value(db, key, new_value) await db.commit() text = _render_setting_text(key) @@ -2378,9 +2304,6 @@ async def apply_setting_choice( except KeyError: await callback.answer("Эта настройка больше недоступна", show_alert=True) return - if bot_configuration_service.is_read_only(key): - await callback.answer("Эта настройка доступна только для чтения", show_alert=True) - return try: value = bot_configuration_service.resolve_choice_token(key, choice_token) @@ -2388,11 +2311,7 @@ async def apply_setting_choice( await callback.answer("Это значение больше недоступно", show_alert=True) return - try: - await bot_configuration_service.set_value(db, key, value) - except ReadOnlySettingError: - await callback.answer("Эта настройка доступна только для чтения", show_alert=True) - return + await bot_configuration_service.set_value(db, key, value) await db.commit() text = _render_setting_text(key) diff --git a/app/services/external_admin_service.py b/app/services/external_admin_service.py deleted file mode 100644 index 325fa894..00000000 --- a/app/services/external_admin_service.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Утилиты для синхронизации токена внешней админки.""" - -from __future__ import annotations - -import logging -from typing import Optional - -from sqlalchemy import select -from sqlalchemy.exc import SQLAlchemyError - -from app.config import settings -from app.database.database import AsyncSessionLocal -from app.database.models import SystemSetting -from app.services.system_settings_service import ( - ReadOnlySettingError, - bot_configuration_service, -) - - -logger = logging.getLogger(__name__) - - -async def ensure_external_admin_token( - bot_username: Optional[str], - bot_id: Optional[int], -) -> Optional[str]: - """Генерирует и сохраняет токен внешней админки, если требуется.""" - - username_raw = (bot_username or "").strip() - if not username_raw: - logger.warning( - "⚠️ Не удалось обеспечить токен внешней админки: username бота отсутствует", - ) - return None - - normalized_username = username_raw.lstrip("@").lower() - if not normalized_username: - logger.warning( - "⚠️ Не удалось обеспечить токен внешней админки: username пустой после нормализации", - ) - return None - - try: - token = settings.build_external_admin_token(normalized_username) - except Exception as error: # pragma: no cover - защитный блок - logger.error("❌ Ошибка генерации токена внешней админки: %s", error) - return None - - try: - async with AsyncSessionLocal() as session: - result = await session.execute( - select(SystemSetting.key, SystemSetting.value).where( - SystemSetting.key.in_( - ["EXTERNAL_ADMIN_TOKEN", "EXTERNAL_ADMIN_TOKEN_BOT_ID"] - ) - ) - ) - rows = dict(result.all()) - existing_token = rows.get("EXTERNAL_ADMIN_TOKEN") - existing_bot_id_raw = rows.get("EXTERNAL_ADMIN_TOKEN_BOT_ID") - - existing_bot_id: Optional[int] = None - if existing_bot_id_raw is not None: - try: - existing_bot_id = int(existing_bot_id_raw) - except (TypeError, ValueError): # pragma: no cover - защита от мусорных значений - logger.warning( - "⚠️ Не удалось разобрать сохраненный идентификатор бота внешней админки: %s", - existing_bot_id_raw, - ) - - if existing_token == token and existing_bot_id == bot_id: - if settings.get_external_admin_token() != token: - settings.EXTERNAL_ADMIN_TOKEN = token - if settings.EXTERNAL_ADMIN_TOKEN_BOT_ID != existing_bot_id: - settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = existing_bot_id - return token - - if existing_bot_id is not None and bot_id is not None and existing_bot_id != bot_id: - logger.error( - "❌ Обнаружено несовпадение ID бота для токена внешней админки: сохранен %s, текущий %s", - existing_bot_id, - bot_id, - ) - settings.EXTERNAL_ADMIN_TOKEN = existing_token - settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = existing_bot_id - return None - - updates: list[tuple[str, object]] = [] - if existing_token != token: - updates.append(("EXTERNAL_ADMIN_TOKEN", token)) - - if bot_id is not None and existing_bot_id != bot_id: - updates.append(("EXTERNAL_ADMIN_TOKEN_BOT_ID", bot_id)) - - if not updates: - # Токен совпал, но могли отсутствовать значения в настройках приложения - if settings.get_external_admin_token() != (existing_token or token): - settings.EXTERNAL_ADMIN_TOKEN = existing_token or token - if existing_bot_id is not None and ( - settings.EXTERNAL_ADMIN_TOKEN_BOT_ID != existing_bot_id - ): - settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = existing_bot_id - elif ( - bot_id is not None - and settings.EXTERNAL_ADMIN_TOKEN_BOT_ID != bot_id - and existing_bot_id is None - ): - settings.EXTERNAL_ADMIN_TOKEN_BOT_ID = bot_id - return existing_token or token - - try: - for key, value in updates: - await bot_configuration_service.set_value( - session, - key, - value, - force=True, - ) - await session.commit() - logger.info( - "✅ Токен внешней админки синхронизирован для @%s", - normalized_username, - ) - except ReadOnlySettingError: # pragma: no cover - force=True предотвращает исключение - await session.rollback() - logger.warning( - "⚠️ Не удалось сохранить токен внешней админки из-за ограничения доступа", - ) - return None - - return token - except SQLAlchemyError as error: - logger.error("❌ Ошибка сохранения токена внешней админки: %s", error) - return None - diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index cca1771f..e8529e0d 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -56,16 +56,9 @@ class ChoiceOption: description: Optional[str] = None -class ReadOnlySettingError(RuntimeError): - """Исключение, выбрасываемое при попытке изменить настройку только для чтения.""" - - class BotConfigurationService: EXCLUDED_KEYS: set[str] = {"BOT_TOKEN", "ADMIN_IDS"} - READ_ONLY_KEYS: set[str] = {"EXTERNAL_ADMIN_TOKEN", "EXTERNAL_ADMIN_TOKEN_BOT_ID"} - PLAIN_TEXT_KEYS: set[str] = {"EXTERNAL_ADMIN_TOKEN", "EXTERNAL_ADMIN_TOKEN_BOT_ID"} - CATEGORY_TITLES: Dict[str, str] = { "CORE": "🤖 Основные настройки", "SUPPORT": "💬 Поддержка и тикеты", @@ -78,7 +71,6 @@ class BotConfigurationService: "TRIBUTE": "🎁 Tribute", "MULENPAY": "💰 MulenPay", "PAL24": "🏦 PAL24 / PayPalych", - "EXTERNAL_ADMIN": "🛡️ Внешняя админка", "SUBSCRIPTIONS_CORE": "📅 Подписки и лимиты", "PERIODS": "📆 Периоды подписок", "SUBSCRIPTION_PRICES": "💵 Стоимость тарифов", @@ -126,7 +118,6 @@ class BotConfigurationService: "PAL24": "PAL24 / PayPalych подключения и лимиты.", "TRIBUTE": "Tribute и донат-сервисы.", "TELEGRAM": "Telegram Stars и их стоимость.", - "EXTERNAL_ADMIN": "Токен внешней админки для проверки запросов.", "SUBSCRIPTIONS_CORE": "Лимиты устройств, трафика и базовые цены подписок.", "PERIODS": "Доступные периоды подписок и продлений.", "SUBSCRIPTION_PRICES": "Стоимость подписок по периодам в копейках.", @@ -264,7 +255,6 @@ class BotConfigurationService: "MULENPAY_": "MULENPAY", "PAL24_": "PAL24", "PAYMENT_": "PAYMENT", - "EXTERNAL_ADMIN_": "EXTERNAL_ADMIN", "CONNECT_BUTTON_HAPP": "HAPP", "HAPP_": "HAPP", "SKIP_": "SKIP", @@ -404,20 +394,6 @@ class BotConfigurationService: "warning": "Недоступный адрес приведет к ошибкам при управлении VPN-учетками.", "dependencies": "REMNAWAVE_API_KEY или REMNAWAVE_USERNAME/REMNAWAVE_PASSWORD", }, - "EXTERNAL_ADMIN_TOKEN": { - "description": "Приватный токен, который использует внешняя админка для проверки запросов.", - "format": "Значение генерируется автоматически из username бота и его токена и доступно только для чтения.", - "example": "Генерируется автоматически", - "warning": "Токен обновится при смене username или токена бота.", - "dependencies": "Username телеграм-бота, токен бота", - }, - "EXTERNAL_ADMIN_TOKEN_BOT_ID": { - "description": "Идентификатор телеграм-бота, с которым связан токен внешней админки.", - "format": "Проставляется автоматически после первого запуска и не редактируется вручную.", - "example": "123456789", - "warning": "Несовпадение ID блокирует обновление токена, предотвращая его подмену на другом боте.", - "dependencies": "Результат вызова getMe() в Telegram Bot API", - }, } @classmethod @@ -429,10 +405,6 @@ class BotConfigurationService: definition = cls.get_definition(key) return definition.python_type is bool - @classmethod - def is_read_only(cls, key: str) -> bool: - return key in cls.READ_ONLY_KEYS - @classmethod def _format_numeric_with_unit(cls, key: str, value: Union[int, float]) -> Optional[str]: if isinstance(value, bool): @@ -483,8 +455,6 @@ class BotConfigurationService: cleaned = value.strip() if not cleaned: return "—" - if key in cls.PLAIN_TEXT_KEYS: - return cleaned if any(keyword in key.upper() for keyword in ("TOKEN", "SECRET", "PASSWORD", "KEY")): return "••••••••" items = cls._split_comma_values(cleaned) @@ -890,17 +860,7 @@ class BotConfigurationService: return parsed_value @classmethod - async def set_value( - cls, - db: AsyncSession, - key: str, - value: Any, - *, - force: bool = False, - ) -> None: - if cls.is_read_only(key) and not force: - raise ReadOnlySettingError(f"Setting {key} is read-only") - + async def set_value(cls, db: AsyncSession, key: str, value: Any) -> None: raw_value = cls.serialize_value(key, value) await upsert_system_setting(db, key, raw_value) cls._overrides_raw[key] = raw_value @@ -910,16 +870,7 @@ class BotConfigurationService: await cls._sync_default_web_api_token() @classmethod - async def reset_value( - cls, - db: AsyncSession, - key: str, - *, - force: bool = False, - ) -> None: - if cls.is_read_only(key) and not force: - raise ReadOnlySettingError(f"Setting {key} is read-only") - + async def reset_value(cls, db: AsyncSession, key: str) -> None: await delete_system_setting(db, key) cls._overrides_raw.pop(key, None) original = cls.get_original_value(key) @@ -974,7 +925,6 @@ class BotConfigurationService: "category_key": definition.category_key, "category_label": definition.category_label, "has_override": has_override, - "is_read_only": cls.is_read_only(key), } diff --git a/app/webapi/routes/config.py b/app/webapi/routes/config.py index 46965da4..92869266 100644 --- a/app/webapi/routes/config.py +++ b/app/webapi/routes/config.py @@ -5,10 +5,7 @@ from typing import Any, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Security, status from sqlalchemy.ext.asyncio import AsyncSession -from app.services.system_settings_service import ( - ReadOnlySettingError, - bot_configuration_service, -) +from app.services.system_settings_service import bot_configuration_service from ..dependencies import get_db_session, require_api_token from ..schemas.config import ( @@ -97,7 +94,6 @@ def _serialize_definition(definition, include_choices: bool = True) -> SettingDe current=current, original=original, has_override=has_override, - read_only=bot_configuration_service.is_read_only(definition.key), choices=choices, ) @@ -157,10 +153,7 @@ async def update_setting( raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error value = _coerce_value(key, payload.value) - try: - await bot_configuration_service.set_value(db, key, value) - except ReadOnlySettingError as error: - raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error + await bot_configuration_service.set_value(db, key, value) await db.commit() return _serialize_definition(definition) @@ -177,9 +170,6 @@ async def reset_setting( except KeyError as error: raise HTTPException(status.HTTP_404_NOT_FOUND, "Setting not found") from error - try: - await bot_configuration_service.reset_value(db, key) - except ReadOnlySettingError as error: - raise HTTPException(status.HTTP_403_FORBIDDEN, str(error)) from error + await bot_configuration_service.reset_value(db, key) await db.commit() return _serialize_definition(definition) diff --git a/app/webapi/schemas/config.py b/app/webapi/schemas/config.py index 7497444d..39aedbad 100644 --- a/app/webapi/schemas/config.py +++ b/app/webapi/schemas/config.py @@ -45,7 +45,6 @@ class SettingDefinition(BaseModel): current: Any | None = Field(default=None) original: Any | None = Field(default=None) has_override: bool - read_only: bool = Field(default=False) choices: list[SettingChoice] = Field(default_factory=list) model_config = ConfigDict(extra="forbid") diff --git a/main.py b/main.py index 7c0d92c4..51d6bfa2 100644 --- a/main.py +++ b/main.py @@ -22,7 +22,6 @@ from app.services.backup_service import backup_service from app.services.reporting_service import reporting_service from app.localization.loader import ensure_locale_templates from app.services.system_settings_service import bot_configuration_service -from app.services.external_admin_service import ensure_external_admin_token from app.services.broadcast_service import broadcast_service from app.utils.startup_timeline import StartupTimeline @@ -187,25 +186,6 @@ async def main(): payment_service = PaymentService(bot) - async with timeline.stage( - "Внешняя админка", - "🛡️", - success_message="Токен внешней админки готов", - ) as stage: - try: - bot_user = await bot.get_me() - token = await ensure_external_admin_token( - bot_user.username, - bot_user.id, - ) - if token: - stage.log("Токен синхронизирован") - else: - stage.warning("Не удалось получить токен внешней админки") - except Exception as error: # pragma: no cover - защитный блок - stage.warning(f"Ошибка подготовки внешней админки: {error}") - logger.error("❌ Ошибка подготовки внешней админки: %s", error) - webhook_needed = ( settings.TRIBUTE_ENABLED or settings.is_cryptobot_enabled()