From 2761255e6554051d24cc75b0230b274a64beb04b Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 28 Nov 2025 03:58:59 +0300 Subject: [PATCH 01/12] Ensure ReDoc loads reliably --- app/webapi/app.py | 10 ++++++++- app/webapi/docs.py | 33 +++++++++++++++++++++++++++++ app/webserver/unified_app.py | 20 +++++++++++------ tests/webserver/test_unified_app.py | 11 +++++++++- 4 files changed, 66 insertions(+), 8 deletions(-) create mode 100644 app/webapi/docs.py diff --git a/app/webapi/app.py b/app/webapi/app.py index 8c4c4dcd..069c0fef 100644 --- a/app/webapi/app.py +++ b/app/webapi/app.py @@ -4,6 +4,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from app.config import settings +from app.webapi.docs import add_redoc_endpoint from .middleware import RequestLoggingMiddleware from .routes import ( @@ -144,11 +145,18 @@ def create_web_api_app() -> FastAPI: title=settings.WEB_API_TITLE, version=settings.WEB_API_VERSION, docs_url=docs_config.get("docs_url"), - redoc_url=docs_config.get("redoc_url"), + redoc_url=None, openapi_url=docs_config.get("openapi_url"), swagger_ui_parameters={"persistAuthorization": True}, ) + add_redoc_endpoint( + app, + redoc_url=docs_config.get("redoc_url"), + openapi_url=docs_config.get("openapi_url"), + title=settings.WEB_API_TITLE, + ) + allowed_origins = settings.get_web_api_allowed_origins() app.add_middleware( CORSMiddleware, diff --git a/app/webapi/docs.py b/app/webapi/docs.py new file mode 100644 index 00000000..26eeeaf2 --- /dev/null +++ b/app/webapi/docs.py @@ -0,0 +1,33 @@ +from fastapi import FastAPI +from fastapi.openapi.docs import get_redoc_html + + +def add_redoc_endpoint( + app: FastAPI, + *, + redoc_url: str | None, + openapi_url: str | None, + title: str | None, +) -> None: + """Attach a ReDoc endpoint if docs are enabled. + + The default FastAPI ReDoc handler sometimes renders a blank page when the + CDN bundle fails to load. By explicitly registering the handler and + pinning the bundle version, we ensure the endpoint always returns a fully + rendered page. + """ + + if not redoc_url or not openapi_url: + return + + for route in app.router.routes: + if getattr(route, "path", None) == redoc_url: + return + + @app.get(redoc_url, include_in_schema=False) + async def redoc_html(): # pragma: no cover - template rendering + return get_redoc_html( + openapi_url=openapi_url, + title=f"{title or app.title} - ReDoc", + redoc_js_url="https://cdn.jsdelivr.net/npm/redoc@2.1.5/bundles/redoc.standalone.js", + ) diff --git a/app/webserver/unified_app.py b/app/webserver/unified_app.py index d58b454f..3bc4e4e7 100644 --- a/app/webserver/unified_app.py +++ b/app/webserver/unified_app.py @@ -13,6 +13,7 @@ from aiogram import Dispatcher from app.config import settings from app.services.payment_service import PaymentService from app.webapi.app import create_web_api_app +from app.webapi.docs import add_redoc_endpoint from . import payments from . import telegram @@ -47,12 +48,19 @@ def _create_base_app() -> FastAPI: app = create_web_api_app() else: app = FastAPI( - title="Bedolaga Unified Server", - version=settings.WEB_API_VERSION, - docs_url=docs_config.get("docs_url"), - redoc_url=docs_config.get("redoc_url"), - openapi_url=docs_config.get("openapi_url"), - ) + title="Bedolaga Unified Server", + version=settings.WEB_API_VERSION, + docs_url=docs_config.get("docs_url"), + redoc_url=None, + openapi_url=docs_config.get("openapi_url"), + ) + + add_redoc_endpoint( + app, + redoc_url=docs_config.get("redoc_url"), + openapi_url=docs_config.get("openapi_url"), + title="Bedolaga Unified Server", + ) _attach_docs_alias(app, app.docs_url) return app diff --git a/tests/webserver/test_unified_app.py b/tests/webserver/test_unified_app.py index 7404a507..26b0a10c 100644 --- a/tests/webserver/test_unified_app.py +++ b/tests/webserver/test_unified_app.py @@ -133,7 +133,6 @@ async def test_unified_app_docs_enabled_with_alias(monkeypatch: pytest.MonkeyPat app = _build_unified_app(monkeypatch, docs_enabled=True) assert app.docs_url == "/docs" - assert app.redoc_url == "/redoc" assert app.openapi_url == "/openapi.json" alias_route = next( @@ -143,6 +142,16 @@ async def test_unified_app_docs_enabled_with_alias(monkeypatch: pytest.MonkeyPat assert alias_route is not None assert getattr(alias_route, "include_in_schema", True) is False + redoc_route = next( + (route for route in app.routes if getattr(route, "path", None) == "/redoc"), + None, + ) + assert redoc_route is not None + assert getattr(redoc_route, "include_in_schema", True) is False + response = await alias_route.endpoint() # type: ignore[func-returns-value] assert response.status_code == status.HTTP_307_TEMPORARY_REDIRECT assert response.headers["location"] == "/docs" + + redoc_response = await redoc_route.endpoint() # type: ignore[func-returns-value] + assert b"ReDoc" in redoc_response.body # type: ignore[attr-defined] From db60f5c5ba010a2df69bf86b5e599c3ae633d4b9 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 28 Nov 2025 05:41:24 +0300 Subject: [PATCH 02/12] Ensure photo replies default to HTML parse mode --- app/utils/message_patch.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/utils/message_patch.py b/app/utils/message_patch.py index 134ff1ea..ac491c1d 100644 --- a/app/utils/message_patch.py +++ b/app/utils/message_patch.py @@ -90,6 +90,9 @@ async def _answer_with_photo(self: Message, text: str = None, **kwargs): pass language = _get_language(self) + # По умолчанию используем HTML, чтобы Telegram корректно распознавал разметку + kwargs.setdefault("parse_mode", "HTML") + if LOGO_PATH.exists(): try: # Отправляем caption как есть; при ошибке парсинга ниже сработает фоллбек From 8a7fb598fac7849f5af9d1d5d3a8285bfa5261a5 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 28 Nov 2025 05:43:06 +0300 Subject: [PATCH 03/12] Revert "Ensure photo replies default to HTML parse mode" --- app/utils/message_patch.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/utils/message_patch.py b/app/utils/message_patch.py index ac491c1d..134ff1ea 100644 --- a/app/utils/message_patch.py +++ b/app/utils/message_patch.py @@ -90,9 +90,6 @@ async def _answer_with_photo(self: Message, text: str = None, **kwargs): pass language = _get_language(self) - # По умолчанию используем HTML, чтобы Telegram корректно распознавал разметку - kwargs.setdefault("parse_mode", "HTML") - if LOGO_PATH.exists(): try: # Отправляем caption как есть; при ошибке парсинга ниже сработает фоллбек From ff1556ffcdcc440d085fe9bf0dcd2da16bb10fa1 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 28 Nov 2025 05:43:23 +0300 Subject: [PATCH 04/12] Handle parse errors with plain text fallback --- app/utils/photo_message.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/app/utils/photo_message.py b/app/utils/photo_message.py index f7e65752..95d04110 100644 --- a/app/utils/photo_message.py +++ b/app/utils/photo_message.py @@ -1,3 +1,6 @@ +import html +import re + from aiogram import types from aiogram.exceptions import TelegramBadRequest from aiogram.types import FSInputFile, InputMediaPhoto @@ -33,6 +36,14 @@ def _get_language(callback: types.CallbackQuery) -> str | None: return None +def _strip_html(text: str | None) -> str: + if not text: + return "" + + plain_text = html.unescape(re.sub(r"<[^>]+>", "", text)) + return plain_text.strip() + + def _build_base_kwargs(keyboard: types.InlineKeyboardMarkup | None, parse_mode: str | None): kwargs: dict[str, object] = {} if parse_mode is not None: @@ -57,6 +68,19 @@ async def _answer_text( kwargs = prepare_privacy_safe_kwargs(kwargs) kwargs.setdefault("parse_mode", parse_mode or "HTML") + try: + await callback.message.answer( + caption, + **kwargs, + ) + return + except TelegramBadRequest as send_error: + if is_privacy_restricted_error(send_error): + caption = append_privacy_hint(caption, language) + kwargs = prepare_privacy_safe_kwargs(kwargs) + else: + caption = _strip_html(caption) + kwargs.pop("parse_mode", None) await callback.message.answer( caption, From 5e239207239d32f65058d6934f7ea25202e9890e Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 28 Nov 2025 05:48:48 +0300 Subject: [PATCH 05/12] Revert "Handle Telegram parse errors gracefully" --- app/utils/photo_message.py | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/app/utils/photo_message.py b/app/utils/photo_message.py index 95d04110..f7e65752 100644 --- a/app/utils/photo_message.py +++ b/app/utils/photo_message.py @@ -1,6 +1,3 @@ -import html -import re - from aiogram import types from aiogram.exceptions import TelegramBadRequest from aiogram.types import FSInputFile, InputMediaPhoto @@ -36,14 +33,6 @@ def _get_language(callback: types.CallbackQuery) -> str | None: return None -def _strip_html(text: str | None) -> str: - if not text: - return "" - - plain_text = html.unescape(re.sub(r"<[^>]+>", "", text)) - return plain_text.strip() - - def _build_base_kwargs(keyboard: types.InlineKeyboardMarkup | None, parse_mode: str | None): kwargs: dict[str, object] = {} if parse_mode is not None: @@ -68,19 +57,6 @@ async def _answer_text( kwargs = prepare_privacy_safe_kwargs(kwargs) kwargs.setdefault("parse_mode", parse_mode or "HTML") - try: - await callback.message.answer( - caption, - **kwargs, - ) - return - except TelegramBadRequest as send_error: - if is_privacy_restricted_error(send_error): - caption = append_privacy_hint(caption, language) - kwargs = prepare_privacy_safe_kwargs(kwargs) - else: - caption = _strip_html(caption) - kwargs.pop("parse_mode", None) await callback.message.answer( caption, From a437ebd65b8029c006ae8afade8e8595a367451b Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 28 Nov 2025 05:49:04 +0300 Subject: [PATCH 06/12] Improve fallback on Telegram parse errors --- app/utils/message_patch.py | 21 ++++++++++++++++++--- app/utils/photo_message.py | 24 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/app/utils/message_patch.py b/app/utils/message_patch.py index 134ff1ea..7302f6ef 100644 --- a/app/utils/message_patch.py +++ b/app/utils/message_patch.py @@ -1,3 +1,5 @@ +import html +import re from pathlib import Path from typing import Any, Dict @@ -63,6 +65,14 @@ def append_privacy_hint(text: str | None, language: str | None) -> str: return hint +def _strip_html(text: str | None) -> str: + if not text: + return "" + + plain_text = html.unescape(re.sub(r"<[^>]+>", "", text)) + return plain_text.strip() + + def prepare_privacy_safe_kwargs(kwargs: Dict[str, Any] | None = None) -> Dict[str, Any]: safe_kwargs: Dict[str, Any] = dict(kwargs or {}) safe_kwargs.pop("reply_markup", None) @@ -100,7 +110,10 @@ async def _answer_with_photo(self: Message, text: str = None, **kwargs): safe_kwargs = prepare_privacy_safe_kwargs(kwargs) return await _original_answer(self, fallback_text, **safe_kwargs) # Фоллбек, если Telegram ругается на caption или другое ограничение: отправим как текст - return await _original_answer(self, text, **kwargs) + fallback_text = _strip_html(text) + safe_kwargs = dict(kwargs) + safe_kwargs.pop("parse_mode", None) + return await _original_answer(self, fallback_text, **safe_kwargs) except Exception: return await _original_answer(self, text, **kwargs) return await _original_answer(self, text, **kwargs) @@ -153,7 +166,10 @@ async def _edit_with_photo(self: Message, text: str, **kwargs): await self.delete() except Exception: pass - return await _original_answer(self, text, **kwargs) + fallback_text = _strip_html(text) + safe_kwargs = dict(kwargs) + safe_kwargs.pop("parse_mode", None) + return await _original_answer(self, fallback_text, **safe_kwargs) return await _original_edit_text(self, text, **kwargs) @@ -162,4 +178,3 @@ def patch_message_methods(): return Message.answer = _answer_with_photo Message.edit_text = _edit_with_photo - diff --git a/app/utils/photo_message.py b/app/utils/photo_message.py index f7e65752..95d04110 100644 --- a/app/utils/photo_message.py +++ b/app/utils/photo_message.py @@ -1,3 +1,6 @@ +import html +import re + from aiogram import types from aiogram.exceptions import TelegramBadRequest from aiogram.types import FSInputFile, InputMediaPhoto @@ -33,6 +36,14 @@ def _get_language(callback: types.CallbackQuery) -> str | None: return None +def _strip_html(text: str | None) -> str: + if not text: + return "" + + plain_text = html.unescape(re.sub(r"<[^>]+>", "", text)) + return plain_text.strip() + + def _build_base_kwargs(keyboard: types.InlineKeyboardMarkup | None, parse_mode: str | None): kwargs: dict[str, object] = {} if parse_mode is not None: @@ -57,6 +68,19 @@ async def _answer_text( kwargs = prepare_privacy_safe_kwargs(kwargs) kwargs.setdefault("parse_mode", parse_mode or "HTML") + try: + await callback.message.answer( + caption, + **kwargs, + ) + return + except TelegramBadRequest as send_error: + if is_privacy_restricted_error(send_error): + caption = append_privacy_hint(caption, language) + kwargs = prepare_privacy_safe_kwargs(kwargs) + else: + caption = _strip_html(caption) + kwargs.pop("parse_mode", None) await callback.message.answer( caption, From 0392aa5b45dc584a4a02672ad7e4525f80e02103 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 28 Nov 2025 05:50:50 +0300 Subject: [PATCH 07/12] Revert "Improve fallback on Telegram parse errors" --- app/utils/message_patch.py | 21 +++------------------ app/utils/photo_message.py | 24 ------------------------ 2 files changed, 3 insertions(+), 42 deletions(-) diff --git a/app/utils/message_patch.py b/app/utils/message_patch.py index 7302f6ef..134ff1ea 100644 --- a/app/utils/message_patch.py +++ b/app/utils/message_patch.py @@ -1,5 +1,3 @@ -import html -import re from pathlib import Path from typing import Any, Dict @@ -65,14 +63,6 @@ def append_privacy_hint(text: str | None, language: str | None) -> str: return hint -def _strip_html(text: str | None) -> str: - if not text: - return "" - - plain_text = html.unescape(re.sub(r"<[^>]+>", "", text)) - return plain_text.strip() - - def prepare_privacy_safe_kwargs(kwargs: Dict[str, Any] | None = None) -> Dict[str, Any]: safe_kwargs: Dict[str, Any] = dict(kwargs or {}) safe_kwargs.pop("reply_markup", None) @@ -110,10 +100,7 @@ async def _answer_with_photo(self: Message, text: str = None, **kwargs): safe_kwargs = prepare_privacy_safe_kwargs(kwargs) return await _original_answer(self, fallback_text, **safe_kwargs) # Фоллбек, если Telegram ругается на caption или другое ограничение: отправим как текст - fallback_text = _strip_html(text) - safe_kwargs = dict(kwargs) - safe_kwargs.pop("parse_mode", None) - return await _original_answer(self, fallback_text, **safe_kwargs) + return await _original_answer(self, text, **kwargs) except Exception: return await _original_answer(self, text, **kwargs) return await _original_answer(self, text, **kwargs) @@ -166,10 +153,7 @@ async def _edit_with_photo(self: Message, text: str, **kwargs): await self.delete() except Exception: pass - fallback_text = _strip_html(text) - safe_kwargs = dict(kwargs) - safe_kwargs.pop("parse_mode", None) - return await _original_answer(self, fallback_text, **safe_kwargs) + return await _original_answer(self, text, **kwargs) return await _original_edit_text(self, text, **kwargs) @@ -178,3 +162,4 @@ def patch_message_methods(): return Message.answer = _answer_with_photo Message.edit_text = _edit_with_photo + diff --git a/app/utils/photo_message.py b/app/utils/photo_message.py index 95d04110..f7e65752 100644 --- a/app/utils/photo_message.py +++ b/app/utils/photo_message.py @@ -1,6 +1,3 @@ -import html -import re - from aiogram import types from aiogram.exceptions import TelegramBadRequest from aiogram.types import FSInputFile, InputMediaPhoto @@ -36,14 +33,6 @@ def _get_language(callback: types.CallbackQuery) -> str | None: return None -def _strip_html(text: str | None) -> str: - if not text: - return "" - - plain_text = html.unescape(re.sub(r"<[^>]+>", "", text)) - return plain_text.strip() - - def _build_base_kwargs(keyboard: types.InlineKeyboardMarkup | None, parse_mode: str | None): kwargs: dict[str, object] = {} if parse_mode is not None: @@ -68,19 +57,6 @@ async def _answer_text( kwargs = prepare_privacy_safe_kwargs(kwargs) kwargs.setdefault("parse_mode", parse_mode or "HTML") - try: - await callback.message.answer( - caption, - **kwargs, - ) - return - except TelegramBadRequest as send_error: - if is_privacy_restricted_error(send_error): - caption = append_privacy_hint(caption, language) - kwargs = prepare_privacy_safe_kwargs(kwargs) - else: - caption = _strip_html(caption) - kwargs.pop("parse_mode", None) await callback.message.answer( caption, From 79659ec5fe3d3d877e982666d4e3f5d2de445306 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 28 Nov 2025 05:51:05 +0300 Subject: [PATCH 08/12] Harden message send fallback for invalid HTML --- app/utils/message_patch.py | 49 +++++++++++++++++++++++++++++++------- app/utils/photo_message.py | 24 +++++++++++++++++++ 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/app/utils/message_patch.py b/app/utils/message_patch.py index 134ff1ea..a61c5308 100644 --- a/app/utils/message_patch.py +++ b/app/utils/message_patch.py @@ -1,3 +1,5 @@ +import html +import re from pathlib import Path from typing import Any, Dict @@ -19,6 +21,14 @@ _original_answer = Message.answer _original_edit_text = Message.edit_text +def _strip_html(text: str | None) -> str: + if not text: + return "" + + plain_text = html.unescape(re.sub(r"<[^>]+>", "", text)) + return plain_text.strip() + + def _get_language(message: Message) -> str | None: try: user = message.from_user @@ -69,6 +79,29 @@ def prepare_privacy_safe_kwargs(kwargs: Dict[str, Any] | None = None) -> Dict[st return safe_kwargs +async def _answer_plain_text(self: Message, text: str | None, kwargs: Dict[str, Any], error: Exception | None = None): + language = _get_language(self) + + if error and is_privacy_restricted_error(error): + text = append_privacy_hint(text, language) + kwargs = prepare_privacy_safe_kwargs(kwargs) + + try: + return await _original_answer(self, text, **kwargs) + except TelegramBadRequest as send_error: + if is_privacy_restricted_error(send_error): + text = append_privacy_hint(text, language) + kwargs = prepare_privacy_safe_kwargs(kwargs) + else: + text = _strip_html(text) + kwargs = dict(kwargs) + kwargs.pop("parse_mode", None) + + return await _original_answer(self, text, **kwargs) + except Exception: + return await _original_answer(self, text, **kwargs) + + def is_privacy_restricted_error(error: Exception) -> bool: if not isinstance(error, TelegramBadRequest): return False @@ -81,11 +114,11 @@ def is_privacy_restricted_error(error: Exception) -> bool: async def _answer_with_photo(self: Message, text: str = None, **kwargs): # Уважаем флаг в рантайме: если логотип выключен — не подменяем ответ if not settings.ENABLE_LOGO_MODE: - return await _original_answer(self, text, **kwargs) + return await _answer_plain_text(self, text, kwargs) # Если caption слишком длинный для фото — отправим как текст try: if text is not None and len(text) > 900: - return await _original_answer(self, text, **kwargs) + return await _answer_plain_text(self, text, kwargs) except Exception: pass language = _get_language(self) @@ -100,10 +133,10 @@ async def _answer_with_photo(self: Message, text: str = None, **kwargs): safe_kwargs = prepare_privacy_safe_kwargs(kwargs) return await _original_answer(self, fallback_text, **safe_kwargs) # Фоллбек, если Telegram ругается на caption или другое ограничение: отправим как текст - return await _original_answer(self, text, **kwargs) + return await _answer_plain_text(self, text, kwargs, error) except Exception: - return await _original_answer(self, text, **kwargs) - return await _original_answer(self, text, **kwargs) + return await _answer_plain_text(self, text, kwargs) + return await _answer_plain_text(self, text, kwargs) async def _edit_with_photo(self: Message, text: str, **kwargs): @@ -119,7 +152,7 @@ async def _edit_with_photo(self: Message, text: str, **kwargs): await self.delete() except Exception: pass - return await _original_answer(self, text, **kwargs) + return await _answer_plain_text(self, text, kwargs) except Exception: pass # Всегда используем логотип если включен режим логотипа, @@ -147,13 +180,13 @@ async def _edit_with_photo(self: Message, text: str, **kwargs): await self.delete() except Exception: pass - return await _original_answer(self, fallback_text, **safe_kwargs) + return await _answer_plain_text(self, fallback_text, safe_kwargs) # Фоллбек: удалим и отправим обычный текст без фото try: await self.delete() except Exception: pass - return await _original_answer(self, text, **kwargs) + return await _answer_plain_text(self, text, kwargs, error) return await _original_edit_text(self, text, **kwargs) diff --git a/app/utils/photo_message.py b/app/utils/photo_message.py index f7e65752..95d04110 100644 --- a/app/utils/photo_message.py +++ b/app/utils/photo_message.py @@ -1,3 +1,6 @@ +import html +import re + from aiogram import types from aiogram.exceptions import TelegramBadRequest from aiogram.types import FSInputFile, InputMediaPhoto @@ -33,6 +36,14 @@ def _get_language(callback: types.CallbackQuery) -> str | None: return None +def _strip_html(text: str | None) -> str: + if not text: + return "" + + plain_text = html.unescape(re.sub(r"<[^>]+>", "", text)) + return plain_text.strip() + + def _build_base_kwargs(keyboard: types.InlineKeyboardMarkup | None, parse_mode: str | None): kwargs: dict[str, object] = {} if parse_mode is not None: @@ -57,6 +68,19 @@ async def _answer_text( kwargs = prepare_privacy_safe_kwargs(kwargs) kwargs.setdefault("parse_mode", parse_mode or "HTML") + try: + await callback.message.answer( + caption, + **kwargs, + ) + return + except TelegramBadRequest as send_error: + if is_privacy_restricted_error(send_error): + caption = append_privacy_hint(caption, language) + kwargs = prepare_privacy_safe_kwargs(kwargs) + else: + caption = _strip_html(caption) + kwargs.pop("parse_mode", None) await callback.message.answer( caption, From 2d4f9da9a79729610ee70d8dd58fe288e807c40e Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 28 Nov 2025 06:00:16 +0300 Subject: [PATCH 09/12] Revert "Harden message send fallback for invalid HTML" --- app/utils/message_patch.py | 49 +++++++------------------------------- app/utils/photo_message.py | 24 ------------------- 2 files changed, 8 insertions(+), 65 deletions(-) diff --git a/app/utils/message_patch.py b/app/utils/message_patch.py index a61c5308..134ff1ea 100644 --- a/app/utils/message_patch.py +++ b/app/utils/message_patch.py @@ -1,5 +1,3 @@ -import html -import re from pathlib import Path from typing import Any, Dict @@ -21,14 +19,6 @@ _original_answer = Message.answer _original_edit_text = Message.edit_text -def _strip_html(text: str | None) -> str: - if not text: - return "" - - plain_text = html.unescape(re.sub(r"<[^>]+>", "", text)) - return plain_text.strip() - - def _get_language(message: Message) -> str | None: try: user = message.from_user @@ -79,29 +69,6 @@ def prepare_privacy_safe_kwargs(kwargs: Dict[str, Any] | None = None) -> Dict[st return safe_kwargs -async def _answer_plain_text(self: Message, text: str | None, kwargs: Dict[str, Any], error: Exception | None = None): - language = _get_language(self) - - if error and is_privacy_restricted_error(error): - text = append_privacy_hint(text, language) - kwargs = prepare_privacy_safe_kwargs(kwargs) - - try: - return await _original_answer(self, text, **kwargs) - except TelegramBadRequest as send_error: - if is_privacy_restricted_error(send_error): - text = append_privacy_hint(text, language) - kwargs = prepare_privacy_safe_kwargs(kwargs) - else: - text = _strip_html(text) - kwargs = dict(kwargs) - kwargs.pop("parse_mode", None) - - return await _original_answer(self, text, **kwargs) - except Exception: - return await _original_answer(self, text, **kwargs) - - def is_privacy_restricted_error(error: Exception) -> bool: if not isinstance(error, TelegramBadRequest): return False @@ -114,11 +81,11 @@ def is_privacy_restricted_error(error: Exception) -> bool: async def _answer_with_photo(self: Message, text: str = None, **kwargs): # Уважаем флаг в рантайме: если логотип выключен — не подменяем ответ if not settings.ENABLE_LOGO_MODE: - return await _answer_plain_text(self, text, kwargs) + return await _original_answer(self, text, **kwargs) # Если caption слишком длинный для фото — отправим как текст try: if text is not None and len(text) > 900: - return await _answer_plain_text(self, text, kwargs) + return await _original_answer(self, text, **kwargs) except Exception: pass language = _get_language(self) @@ -133,10 +100,10 @@ async def _answer_with_photo(self: Message, text: str = None, **kwargs): safe_kwargs = prepare_privacy_safe_kwargs(kwargs) return await _original_answer(self, fallback_text, **safe_kwargs) # Фоллбек, если Telegram ругается на caption или другое ограничение: отправим как текст - return await _answer_plain_text(self, text, kwargs, error) + return await _original_answer(self, text, **kwargs) except Exception: - return await _answer_plain_text(self, text, kwargs) - return await _answer_plain_text(self, text, kwargs) + return await _original_answer(self, text, **kwargs) + return await _original_answer(self, text, **kwargs) async def _edit_with_photo(self: Message, text: str, **kwargs): @@ -152,7 +119,7 @@ async def _edit_with_photo(self: Message, text: str, **kwargs): await self.delete() except Exception: pass - return await _answer_plain_text(self, text, kwargs) + return await _original_answer(self, text, **kwargs) except Exception: pass # Всегда используем логотип если включен режим логотипа, @@ -180,13 +147,13 @@ async def _edit_with_photo(self: Message, text: str, **kwargs): await self.delete() except Exception: pass - return await _answer_plain_text(self, fallback_text, safe_kwargs) + return await _original_answer(self, fallback_text, **safe_kwargs) # Фоллбек: удалим и отправим обычный текст без фото try: await self.delete() except Exception: pass - return await _answer_plain_text(self, text, kwargs, error) + return await _original_answer(self, text, **kwargs) return await _original_edit_text(self, text, **kwargs) diff --git a/app/utils/photo_message.py b/app/utils/photo_message.py index 95d04110..f7e65752 100644 --- a/app/utils/photo_message.py +++ b/app/utils/photo_message.py @@ -1,6 +1,3 @@ -import html -import re - from aiogram import types from aiogram.exceptions import TelegramBadRequest from aiogram.types import FSInputFile, InputMediaPhoto @@ -36,14 +33,6 @@ def _get_language(callback: types.CallbackQuery) -> str | None: return None -def _strip_html(text: str | None) -> str: - if not text: - return "" - - plain_text = html.unescape(re.sub(r"<[^>]+>", "", text)) - return plain_text.strip() - - def _build_base_kwargs(keyboard: types.InlineKeyboardMarkup | None, parse_mode: str | None): kwargs: dict[str, object] = {} if parse_mode is not None: @@ -68,19 +57,6 @@ async def _answer_text( kwargs = prepare_privacy_safe_kwargs(kwargs) kwargs.setdefault("parse_mode", parse_mode or "HTML") - try: - await callback.message.answer( - caption, - **kwargs, - ) - return - except TelegramBadRequest as send_error: - if is_privacy_restricted_error(send_error): - caption = append_privacy_hint(caption, language) - kwargs = prepare_privacy_safe_kwargs(kwargs) - else: - caption = _strip_html(caption) - kwargs.pop("parse_mode", None) await callback.message.answer( caption, From 71366a813370b623b8ea9df8f3a0b255d4a012f5 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 28 Nov 2025 06:00:32 +0300 Subject: [PATCH 10/12] Remove blockquote markup to prevent Telegram parse errors --- app/handlers/admin/user_messages.py | 13 ++++++----- app/handlers/server_status.py | 2 +- app/handlers/subscription/purchase.py | 4 ++-- app/localization/locales/en.json | 4 ++-- app/localization/locales/ru.json | 4 ++-- app/localization/locales/ua.json | 4 ++-- app/localization/locales/zh.json | 8 +++---- app/utils/message_patch.py | 21 ++++++++++++++--- app/utils/photo_message.py | 24 +++++++++++++++++++ app/utils/validators.py | 33 ++++++++++++++++++++------- 10 files changed, 87 insertions(+), 30 deletions(-) diff --git a/app/handlers/admin/user_messages.py b/app/handlers/admin/user_messages.py index ceacd20a..69209f0d 100644 --- a/app/handlers/admin/user_messages.py +++ b/app/handlers/admin/user_messages.py @@ -14,6 +14,7 @@ from app.database.models import User from app.keyboards.admin import get_admin_main_keyboard from app.utils.validators import ( get_html_help_text, + format_telegram_quote, sanitize_html, validate_html_tags, ) @@ -182,14 +183,14 @@ async def process_new_message_text( ) await state.clear() - + await message.answer( f"✅ Сообщение добавлено!\n\n" f"ID: {new_message.id}\n" f"Статус: {'🟢 Активно' if new_message.is_active else '🔴 Неактивно'}\n" f"Создано: {new_message.created_at.strftime('%d.%m.%Y %H:%M')}\n\n" f"Предварительный просмотр:\n" - f"
{message_text}
", + f"{format_telegram_quote(sanitize_html(message_text))}", reply_markup=get_user_messages_keyboard(db_user.language), parse_mode="HTML" ) @@ -318,7 +319,7 @@ async def view_user_message( await callback.answer("❌ Сообщение не найдено", show_alert=True) return - safe_content = sanitize_html(message.message_text) + safe_content = format_telegram_quote(sanitize_html(message.message_text)) status_text = "🟢 Активно" if message.is_active else "🔴 Неактивно" @@ -328,7 +329,7 @@ async def view_user_message( f"Создано: {message.created_at.strftime('%d.%m.%Y %H:%M')}\n" f"Обновлено: {message.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n" f"Содержимое:\n" - f"
{safe_content}
" + f"{safe_content}" ) await callback.message.edit_text( @@ -458,7 +459,7 @@ async def edit_user_message_start( await callback.message.edit_text( f"✏️ Редактирование сообщения ID {message.id}\n\n" f"Текущий текст:\n" - f"
{sanitize_html(message.message_text)}
\n\n" + f"{format_telegram_quote(sanitize_html(message.message_text))}\n\n" f"Введите новый текст сообщения или отправьте /cancel для отмены:", parse_mode="HTML" ) @@ -523,7 +524,7 @@ async def process_edit_message_text( f"ID: {updated_message.id}\n" f"Обновлено: {updated_message.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n" f"Новый текст:\n" - f"
{sanitize_html(new_text)}
", + f"{format_telegram_quote(sanitize_html(new_text))}", reply_markup=get_user_messages_keyboard(db_user.language), parse_mode="HTML" ) diff --git a/app/handlers/server_status.py b/app/handlers/server_status.py index c223dc5a..38406026 100644 --- a/app/handlers/server_status.py +++ b/app/handlers/server_status.py @@ -194,7 +194,7 @@ def _format_server_lines( name = server.display_name or server.name flag_prefix = f"{server.flag} " if server.flag else "" server_line = f"{flag_prefix}{name} — {latency_text}" - lines.append(f"
{server_line}
") + lines.append(f"• {server_line}") return lines diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index cba86cfa..0d76956b 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -355,7 +355,7 @@ async def show_subscription_info( if show_devices and devices_list: message += "\n\n" + texts.t( "SUBSCRIPTION_CONNECTED_DEVICES_TITLE", - "
📱 Подключенные устройства:\n", + "📱 Подключенные устройства:\n", ) for device in devices_list[:5]: platform = device.get('platform', 'Unknown') @@ -365,7 +365,7 @@ async def show_subscription_info( if len(device_info) > 35: device_info = device_info[:32] + "..." message += f"• {device_info}\n" - message += texts.t("SUBSCRIPTION_CONNECTED_DEVICES_FOOTER", "
") + message += texts.t("SUBSCRIPTION_CONNECTED_DEVICES_FOOTER", "") subscription_link = get_display_subscription_link(subscription) hide_subscription_link = settings.should_hide_subscription_link() diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 34067554..0cd59252 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1276,8 +1276,8 @@ "SUBSCRIPTION_APPS_PROMPT": "Choose an app to connect:", "SUBSCRIPTION_APPS_TITLE": "📱 Apps for {device_name}", "SUBSCRIPTION_APP_NOT_FOUND": "❌ App not found", - "SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "", - "SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "
📱 Connected devices:\n", + "SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "", + "SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "📱 Connected devices:\n", "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Connect subscription\n\n📱 Tap the button below to open the app:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Connect subscription\n\n🔗 Subscription link:\n{subscription_url}\n\n💡 Choose your device to get detailed setup instructions:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 Connect subscription\n\nℹ️ The subscription link is available via the buttons below or in the “My subscription” section.\n\n💡 Choose your device to get detailed setup instructions:", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 3d3aef2c..13770fbc 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1288,8 +1288,8 @@ "SUBSCRIPTION_APPS_PROMPT": "Выберите приложение для подключения:", "SUBSCRIPTION_APPS_TITLE": "📱 Приложения для {device_name}", "SUBSCRIPTION_APP_NOT_FOUND": "❌ Приложение не найдено", - "SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "
", - "SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "
📱 Подключенные устройства:\n", + "SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "", + "SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "📱 Подключенные устройства:\n", "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Подключить подписку\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Подключить подписку\n\n🔗 Ссылка подписки:\n{subscription_url}\n\n💡 Выберите ваше устройство для получения подробной инструкции по настройке:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 Подключить подписку\n\nℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе «Моя подписка».\n\n💡 Выберите ваше устройство для получения подробной инструкции по настройке:", diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json index d291f56a..a9bce5be 100644 --- a/app/localization/locales/ua.json +++ b/app/localization/locales/ua.json @@ -1280,8 +1280,8 @@ "SUBSCRIPTION_APPS_PROMPT": "Оберіть додаток для підключення:", "SUBSCRIPTION_APPS_TITLE": "📱 Додатки для {device_name}", "SUBSCRIPTION_APP_NOT_FOUND": "❌ Додаток не знайдено", - "SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "
", - "SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "
📱 Підключені пристрої:\n", + "SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "", + "SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "📱 Підключені пристрої:\n", "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Підключити підписку\n\n📱 Натисніть кнопку нижче, щоб відкрити додаток:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Підключити підписку\n\n🔗 Посилання підписки:\n{subscription_url}\n\n💡 Оберіть ваш пристрій для отримання детальної інструкції з налаштування:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 Підключити підписку\n\nℹ️ Посилання підписки доступне за кнопками нижче або в розділі «Моя підписка».\n\n💡 Оберіть ваш пристрій для отримання детальної інструкції з налаштування:", diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json index d6c32b4e..3d892f37 100644 --- a/app/localization/locales/zh.json +++ b/app/localization/locales/zh.json @@ -1279,8 +1279,8 @@ "SUBSCRIPTION_APPS_PROMPT":"请选择要连接的应用程序:", "SUBSCRIPTION_APPS_TITLE":"📱适用于{device_name}的应用程序", "SUBSCRIPTION_APP_NOT_FOUND":"❌未找到应用程序", -"SUBSCRIPTION_CONNECTED_DEVICES_FOOTER":"
", -"SUBSCRIPTION_CONNECTED_DEVICES_TITLE":"
📱已连接设备:\n", +"SUBSCRIPTION_CONNECTED_DEVICES_FOOTER":"", +"SUBSCRIPTION_CONNECTED_DEVICES_TITLE":"📱已连接设备:\n", "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE":"🚀连接订阅\n\n📱点击下方按钮打开应用程序:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE":"📱连接订阅\n\n🔗订阅链接:\n{subscription_url}\n\n💡请选择您的设备以获取详细设置说明:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN":"📱连接订阅\n\nℹ️订阅链接在下方按钮中或“我的订阅”部分可用。\n\n💡请选择您的设备以获取详细设置说明:", @@ -1606,8 +1606,8 @@ "SUBSCRIPTION_APPS_PROMPT":"请选择要连接的应用程序:", "SUBSCRIPTION_APPS_TITLE":"📱适用于{device_name}的应用程序", "SUBSCRIPTION_APP_NOT_FOUND":"❌未找到应用程序", -"SUBSCRIPTION_CONNECTED_DEVICES_FOOTER":"
", -"SUBSCRIPTION_CONNECTED_DEVICES_TITLE":"
📱已连接设备:\n", +"SUBSCRIPTION_CONNECTED_DEVICES_FOOTER":"", +"SUBSCRIPTION_CONNECTED_DEVICES_TITLE":"📱已连接设备:\n", "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE":"🚀连接订阅\n\n📱点击下方按钮打开应用程序:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE":"📱连接订阅\n\n🔗订阅链接:\n{subscription_url}\n\n💡请选择您的设备以获取详细设置说明:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN":"📱连接订阅\n\nℹ️订阅链接在下方按钮中或“我的订阅”部分可用。\n\n💡请选择您的设备以获取详细设置说明:", diff --git a/app/utils/message_patch.py b/app/utils/message_patch.py index 134ff1ea..7302f6ef 100644 --- a/app/utils/message_patch.py +++ b/app/utils/message_patch.py @@ -1,3 +1,5 @@ +import html +import re from pathlib import Path from typing import Any, Dict @@ -63,6 +65,14 @@ def append_privacy_hint(text: str | None, language: str | None) -> str: return hint +def _strip_html(text: str | None) -> str: + if not text: + return "" + + plain_text = html.unescape(re.sub(r"<[^>]+>", "", text)) + return plain_text.strip() + + def prepare_privacy_safe_kwargs(kwargs: Dict[str, Any] | None = None) -> Dict[str, Any]: safe_kwargs: Dict[str, Any] = dict(kwargs or {}) safe_kwargs.pop("reply_markup", None) @@ -100,7 +110,10 @@ async def _answer_with_photo(self: Message, text: str = None, **kwargs): safe_kwargs = prepare_privacy_safe_kwargs(kwargs) return await _original_answer(self, fallback_text, **safe_kwargs) # Фоллбек, если Telegram ругается на caption или другое ограничение: отправим как текст - return await _original_answer(self, text, **kwargs) + fallback_text = _strip_html(text) + safe_kwargs = dict(kwargs) + safe_kwargs.pop("parse_mode", None) + return await _original_answer(self, fallback_text, **safe_kwargs) except Exception: return await _original_answer(self, text, **kwargs) return await _original_answer(self, text, **kwargs) @@ -153,7 +166,10 @@ async def _edit_with_photo(self: Message, text: str, **kwargs): await self.delete() except Exception: pass - return await _original_answer(self, text, **kwargs) + fallback_text = _strip_html(text) + safe_kwargs = dict(kwargs) + safe_kwargs.pop("parse_mode", None) + return await _original_answer(self, fallback_text, **safe_kwargs) return await _original_edit_text(self, text, **kwargs) @@ -162,4 +178,3 @@ def patch_message_methods(): return Message.answer = _answer_with_photo Message.edit_text = _edit_with_photo - diff --git a/app/utils/photo_message.py b/app/utils/photo_message.py index f7e65752..95d04110 100644 --- a/app/utils/photo_message.py +++ b/app/utils/photo_message.py @@ -1,3 +1,6 @@ +import html +import re + from aiogram import types from aiogram.exceptions import TelegramBadRequest from aiogram.types import FSInputFile, InputMediaPhoto @@ -33,6 +36,14 @@ def _get_language(callback: types.CallbackQuery) -> str | None: return None +def _strip_html(text: str | None) -> str: + if not text: + return "" + + plain_text = html.unescape(re.sub(r"<[^>]+>", "", text)) + return plain_text.strip() + + def _build_base_kwargs(keyboard: types.InlineKeyboardMarkup | None, parse_mode: str | None): kwargs: dict[str, object] = {} if parse_mode is not None: @@ -57,6 +68,19 @@ async def _answer_text( kwargs = prepare_privacy_safe_kwargs(kwargs) kwargs.setdefault("parse_mode", parse_mode or "HTML") + try: + await callback.message.answer( + caption, + **kwargs, + ) + return + except TelegramBadRequest as send_error: + if is_privacy_restricted_error(send_error): + caption = append_privacy_hint(caption, language) + kwargs = prepare_privacy_safe_kwargs(kwargs) + else: + caption = _strip_html(caption) + kwargs.pop("parse_mode", None) await callback.message.answer( caption, diff --git a/app/utils/validators.py b/app/utils/validators.py index 43cecbab..0e3b7432 100644 --- a/app/utils/validators.py +++ b/app/utils/validators.py @@ -4,14 +4,13 @@ from datetime import datetime import html ALLOWED_HTML_TAGS = { - 'b', 'strong', - 'i', 'em', - 'u', 'ins', - 's', 'strike', 'del', - 'code', - 'pre', - 'a', - 'blockquote' + 'b', 'strong', + 'i', 'em', + 'u', 'ins', + 's', 'strike', 'del', + 'code', + 'pre', + 'a' } SELF_CLOSING_TAGS = { @@ -146,6 +145,24 @@ def sanitize_html(text: str) -> str: return text +def strip_blockquote_tags(text: str) -> str: + """Remove Telegram-unsupported blockquote tags (both raw and escaped).""" + if not text: + return text + + without_tags = re.sub(r"\s*", "", text, flags=re.IGNORECASE) + without_escaped = re.sub(r"</?blockquote[^&]*>\s*", "", without_tags, flags=re.IGNORECASE) + return without_escaped + + +def format_telegram_quote(text: str | None) -> str: + """Format text as a lightweight quote safe for Telegram HTML parse mode.""" + clean_text = strip_blockquote_tags(text or "").strip() + if not clean_text: + return "—" + return f"❝ {clean_text} ❞" + + def sanitize_telegram_name(name: Optional[str]) -> Optional[str]: """Санитизация Telegram-имени для безопасной вставки в HTML и хранения. Заменяет угловые скобки и амперсанд на безопасные визуальные аналоги. From 150f9e741a78dfdf8deee252477d0f18566e3eb0 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 28 Nov 2025 06:02:08 +0300 Subject: [PATCH 11/12] Revert "Remove blockquote markup to prevent Telegram parse errors" --- app/handlers/admin/user_messages.py | 13 +++++------ app/handlers/server_status.py | 2 +- app/handlers/subscription/purchase.py | 4 ++-- app/localization/locales/en.json | 4 ++-- app/localization/locales/ru.json | 4 ++-- app/localization/locales/ua.json | 4 ++-- app/localization/locales/zh.json | 8 +++---- app/utils/message_patch.py | 21 +++-------------- app/utils/photo_message.py | 24 ------------------- app/utils/validators.py | 33 +++++++-------------------- 10 files changed, 30 insertions(+), 87 deletions(-) diff --git a/app/handlers/admin/user_messages.py b/app/handlers/admin/user_messages.py index 69209f0d..ceacd20a 100644 --- a/app/handlers/admin/user_messages.py +++ b/app/handlers/admin/user_messages.py @@ -14,7 +14,6 @@ from app.database.models import User from app.keyboards.admin import get_admin_main_keyboard from app.utils.validators import ( get_html_help_text, - format_telegram_quote, sanitize_html, validate_html_tags, ) @@ -183,14 +182,14 @@ async def process_new_message_text( ) await state.clear() - + await message.answer( f"✅ Сообщение добавлено!\n\n" f"ID: {new_message.id}\n" f"Статус: {'🟢 Активно' if new_message.is_active else '🔴 Неактивно'}\n" f"Создано: {new_message.created_at.strftime('%d.%m.%Y %H:%M')}\n\n" f"Предварительный просмотр:\n" - f"{format_telegram_quote(sanitize_html(message_text))}", + f"
{message_text}
", reply_markup=get_user_messages_keyboard(db_user.language), parse_mode="HTML" ) @@ -319,7 +318,7 @@ async def view_user_message( await callback.answer("❌ Сообщение не найдено", show_alert=True) return - safe_content = format_telegram_quote(sanitize_html(message.message_text)) + safe_content = sanitize_html(message.message_text) status_text = "🟢 Активно" if message.is_active else "🔴 Неактивно" @@ -329,7 +328,7 @@ async def view_user_message( f"Создано: {message.created_at.strftime('%d.%m.%Y %H:%M')}\n" f"Обновлено: {message.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n" f"Содержимое:\n" - f"{safe_content}" + f"
{safe_content}
" ) await callback.message.edit_text( @@ -459,7 +458,7 @@ async def edit_user_message_start( await callback.message.edit_text( f"✏️ Редактирование сообщения ID {message.id}\n\n" f"Текущий текст:\n" - f"{format_telegram_quote(sanitize_html(message.message_text))}\n\n" + f"
{sanitize_html(message.message_text)}
\n\n" f"Введите новый текст сообщения или отправьте /cancel для отмены:", parse_mode="HTML" ) @@ -524,7 +523,7 @@ async def process_edit_message_text( f"ID: {updated_message.id}\n" f"Обновлено: {updated_message.updated_at.strftime('%d.%m.%Y %H:%M')}\n\n" f"Новый текст:\n" - f"{format_telegram_quote(sanitize_html(new_text))}", + f"
{sanitize_html(new_text)}
", reply_markup=get_user_messages_keyboard(db_user.language), parse_mode="HTML" ) diff --git a/app/handlers/server_status.py b/app/handlers/server_status.py index 38406026..c223dc5a 100644 --- a/app/handlers/server_status.py +++ b/app/handlers/server_status.py @@ -194,7 +194,7 @@ def _format_server_lines( name = server.display_name or server.name flag_prefix = f"{server.flag} " if server.flag else "" server_line = f"{flag_prefix}{name} — {latency_text}" - lines.append(f"• {server_line}") + lines.append(f"
{server_line}
") return lines diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py index 0d76956b..cba86cfa 100644 --- a/app/handlers/subscription/purchase.py +++ b/app/handlers/subscription/purchase.py @@ -355,7 +355,7 @@ async def show_subscription_info( if show_devices and devices_list: message += "\n\n" + texts.t( "SUBSCRIPTION_CONNECTED_DEVICES_TITLE", - "📱 Подключенные устройства:\n", + "
📱 Подключенные устройства:\n", ) for device in devices_list[:5]: platform = device.get('platform', 'Unknown') @@ -365,7 +365,7 @@ async def show_subscription_info( if len(device_info) > 35: device_info = device_info[:32] + "..." message += f"• {device_info}\n" - message += texts.t("SUBSCRIPTION_CONNECTED_DEVICES_FOOTER", "") + message += texts.t("SUBSCRIPTION_CONNECTED_DEVICES_FOOTER", "
") subscription_link = get_display_subscription_link(subscription) hide_subscription_link = settings.should_hide_subscription_link() diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 0cd59252..34067554 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -1276,8 +1276,8 @@ "SUBSCRIPTION_APPS_PROMPT": "Choose an app to connect:", "SUBSCRIPTION_APPS_TITLE": "📱 Apps for {device_name}", "SUBSCRIPTION_APP_NOT_FOUND": "❌ App not found", - "SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "", - "SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "📱 Connected devices:\n", + "SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "
", + "SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "
📱 Connected devices:\n", "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Connect subscription\n\n📱 Tap the button below to open the app:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Connect subscription\n\n🔗 Subscription link:\n{subscription_url}\n\n💡 Choose your device to get detailed setup instructions:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 Connect subscription\n\nℹ️ The subscription link is available via the buttons below or in the “My subscription” section.\n\n💡 Choose your device to get detailed setup instructions:", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 13770fbc..3d3aef2c 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -1288,8 +1288,8 @@ "SUBSCRIPTION_APPS_PROMPT": "Выберите приложение для подключения:", "SUBSCRIPTION_APPS_TITLE": "📱 Приложения для {device_name}", "SUBSCRIPTION_APP_NOT_FOUND": "❌ Приложение не найдено", - "SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "", - "SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "📱 Подключенные устройства:\n", + "SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "
", + "SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "
📱 Подключенные устройства:\n", "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Подключить подписку\n\n📱 Нажмите кнопку ниже, чтобы открыть приложение:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Подключить подписку\n\n🔗 Ссылка подписки:\n{subscription_url}\n\n💡 Выберите ваше устройство для получения подробной инструкции по настройке:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 Подключить подписку\n\nℹ️ Ссылка подписки доступна по кнопкам ниже или в разделе «Моя подписка».\n\n💡 Выберите ваше устройство для получения подробной инструкции по настройке:", diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json index a9bce5be..d291f56a 100644 --- a/app/localization/locales/ua.json +++ b/app/localization/locales/ua.json @@ -1280,8 +1280,8 @@ "SUBSCRIPTION_APPS_PROMPT": "Оберіть додаток для підключення:", "SUBSCRIPTION_APPS_TITLE": "📱 Додатки для {device_name}", "SUBSCRIPTION_APP_NOT_FOUND": "❌ Додаток не знайдено", - "SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "", - "SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "📱 Підключені пристрої:\n", + "SUBSCRIPTION_CONNECTED_DEVICES_FOOTER": "
", + "SUBSCRIPTION_CONNECTED_DEVICES_TITLE": "
📱 Підключені пристрої:\n", "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE": "🚀 Підключити підписку\n\n📱 Натисніть кнопку нижче, щоб відкрити додаток:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE": "📱 Підключити підписку\n\n🔗 Посилання підписки:\n{subscription_url}\n\n💡 Оберіть ваш пристрій для отримання детальної інструкції з налаштування:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN": "📱 Підключити підписку\n\nℹ️ Посилання підписки доступне за кнопками нижче або в розділі «Моя підписка».\n\n💡 Оберіть ваш пристрій для отримання детальної інструкції з налаштування:", diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json index 3d892f37..d6c32b4e 100644 --- a/app/localization/locales/zh.json +++ b/app/localization/locales/zh.json @@ -1279,8 +1279,8 @@ "SUBSCRIPTION_APPS_PROMPT":"请选择要连接的应用程序:", "SUBSCRIPTION_APPS_TITLE":"📱适用于{device_name}的应用程序", "SUBSCRIPTION_APP_NOT_FOUND":"❌未找到应用程序", -"SUBSCRIPTION_CONNECTED_DEVICES_FOOTER":"", -"SUBSCRIPTION_CONNECTED_DEVICES_TITLE":"📱已连接设备:\n", +"SUBSCRIPTION_CONNECTED_DEVICES_FOOTER":"
", +"SUBSCRIPTION_CONNECTED_DEVICES_TITLE":"
📱已连接设备:\n", "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE":"🚀连接订阅\n\n📱点击下方按钮打开应用程序:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE":"📱连接订阅\n\n🔗订阅链接:\n{subscription_url}\n\n💡请选择您的设备以获取详细设置说明:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN":"📱连接订阅\n\nℹ️订阅链接在下方按钮中或“我的订阅”部分可用。\n\n💡请选择您的设备以获取详细设置说明:", @@ -1606,8 +1606,8 @@ "SUBSCRIPTION_APPS_PROMPT":"请选择要连接的应用程序:", "SUBSCRIPTION_APPS_TITLE":"📱适用于{device_name}的应用程序", "SUBSCRIPTION_APP_NOT_FOUND":"❌未找到应用程序", -"SUBSCRIPTION_CONNECTED_DEVICES_FOOTER":"", -"SUBSCRIPTION_CONNECTED_DEVICES_TITLE":"📱已连接设备:\n", +"SUBSCRIPTION_CONNECTED_DEVICES_FOOTER":"
", +"SUBSCRIPTION_CONNECTED_DEVICES_TITLE":"
📱已连接设备:\n", "SUBSCRIPTION_CONNECT_CUSTOM_MESSAGE":"🚀连接订阅\n\n📱点击下方按钮打开应用程序:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE":"📱连接订阅\n\n🔗订阅链接:\n{subscription_url}\n\n💡请选择您的设备以获取详细设置说明:", "SUBSCRIPTION_CONNECT_DEVICE_MESSAGE_HIDDEN":"📱连接订阅\n\nℹ️订阅链接在下方按钮中或“我的订阅”部分可用。\n\n💡请选择您的设备以获取详细设置说明:", diff --git a/app/utils/message_patch.py b/app/utils/message_patch.py index 7302f6ef..134ff1ea 100644 --- a/app/utils/message_patch.py +++ b/app/utils/message_patch.py @@ -1,5 +1,3 @@ -import html -import re from pathlib import Path from typing import Any, Dict @@ -65,14 +63,6 @@ def append_privacy_hint(text: str | None, language: str | None) -> str: return hint -def _strip_html(text: str | None) -> str: - if not text: - return "" - - plain_text = html.unescape(re.sub(r"<[^>]+>", "", text)) - return plain_text.strip() - - def prepare_privacy_safe_kwargs(kwargs: Dict[str, Any] | None = None) -> Dict[str, Any]: safe_kwargs: Dict[str, Any] = dict(kwargs or {}) safe_kwargs.pop("reply_markup", None) @@ -110,10 +100,7 @@ async def _answer_with_photo(self: Message, text: str = None, **kwargs): safe_kwargs = prepare_privacy_safe_kwargs(kwargs) return await _original_answer(self, fallback_text, **safe_kwargs) # Фоллбек, если Telegram ругается на caption или другое ограничение: отправим как текст - fallback_text = _strip_html(text) - safe_kwargs = dict(kwargs) - safe_kwargs.pop("parse_mode", None) - return await _original_answer(self, fallback_text, **safe_kwargs) + return await _original_answer(self, text, **kwargs) except Exception: return await _original_answer(self, text, **kwargs) return await _original_answer(self, text, **kwargs) @@ -166,10 +153,7 @@ async def _edit_with_photo(self: Message, text: str, **kwargs): await self.delete() except Exception: pass - fallback_text = _strip_html(text) - safe_kwargs = dict(kwargs) - safe_kwargs.pop("parse_mode", None) - return await _original_answer(self, fallback_text, **safe_kwargs) + return await _original_answer(self, text, **kwargs) return await _original_edit_text(self, text, **kwargs) @@ -178,3 +162,4 @@ def patch_message_methods(): return Message.answer = _answer_with_photo Message.edit_text = _edit_with_photo + diff --git a/app/utils/photo_message.py b/app/utils/photo_message.py index 95d04110..f7e65752 100644 --- a/app/utils/photo_message.py +++ b/app/utils/photo_message.py @@ -1,6 +1,3 @@ -import html -import re - from aiogram import types from aiogram.exceptions import TelegramBadRequest from aiogram.types import FSInputFile, InputMediaPhoto @@ -36,14 +33,6 @@ def _get_language(callback: types.CallbackQuery) -> str | None: return None -def _strip_html(text: str | None) -> str: - if not text: - return "" - - plain_text = html.unescape(re.sub(r"<[^>]+>", "", text)) - return plain_text.strip() - - def _build_base_kwargs(keyboard: types.InlineKeyboardMarkup | None, parse_mode: str | None): kwargs: dict[str, object] = {} if parse_mode is not None: @@ -68,19 +57,6 @@ async def _answer_text( kwargs = prepare_privacy_safe_kwargs(kwargs) kwargs.setdefault("parse_mode", parse_mode or "HTML") - try: - await callback.message.answer( - caption, - **kwargs, - ) - return - except TelegramBadRequest as send_error: - if is_privacy_restricted_error(send_error): - caption = append_privacy_hint(caption, language) - kwargs = prepare_privacy_safe_kwargs(kwargs) - else: - caption = _strip_html(caption) - kwargs.pop("parse_mode", None) await callback.message.answer( caption, diff --git a/app/utils/validators.py b/app/utils/validators.py index 0e3b7432..43cecbab 100644 --- a/app/utils/validators.py +++ b/app/utils/validators.py @@ -4,13 +4,14 @@ from datetime import datetime import html ALLOWED_HTML_TAGS = { - 'b', 'strong', - 'i', 'em', - 'u', 'ins', - 's', 'strike', 'del', - 'code', - 'pre', - 'a' + 'b', 'strong', + 'i', 'em', + 'u', 'ins', + 's', 'strike', 'del', + 'code', + 'pre', + 'a', + 'blockquote' } SELF_CLOSING_TAGS = { @@ -145,24 +146,6 @@ def sanitize_html(text: str) -> str: return text -def strip_blockquote_tags(text: str) -> str: - """Remove Telegram-unsupported blockquote tags (both raw and escaped).""" - if not text: - return text - - without_tags = re.sub(r"\s*", "", text, flags=re.IGNORECASE) - without_escaped = re.sub(r"</?blockquote[^&]*>\s*", "", without_tags, flags=re.IGNORECASE) - return without_escaped - - -def format_telegram_quote(text: str | None) -> str: - """Format text as a lightweight quote safe for Telegram HTML parse mode.""" - clean_text = strip_blockquote_tags(text or "").strip() - if not clean_text: - return "—" - return f"❝ {clean_text} ❞" - - def sanitize_telegram_name(name: Optional[str]) -> Optional[str]: """Санитизация Telegram-имени для безопасной вставки в HTML и хранения. Заменяет угловые скобки и амперсанд на безопасные визуальные аналоги. From fff01d1ce3d194331557c0227ba65ff81ce2afd4 Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 28 Nov 2025 06:23:55 +0300 Subject: [PATCH 12/12] Update validators.py --- app/utils/validators.py | 53 ++++++++++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/app/utils/validators.py b/app/utils/validators.py index 43cecbab..a4294a7d 100644 --- a/app/utils/validators.py +++ b/app/utils/validators.py @@ -121,27 +121,52 @@ def validate_subscription_period(days: Union[str, int]) -> Optional[int]: def sanitize_html(text: str) -> str: + """ + Безопасно санитизирует HTML-текст, заменяя HTML-сущности на соответствующие теги, + при этом предотвращая XSS-уязвимости за счет безопасной обработки атрибутов. + + Args: + text (str): Текст с HTML-сущностями (например, <b> жирный </b>) + + Returns: + str: Санитизированный HTML-текст (например, жирный ) + """ if not text: return text - text = html.escape(text) + # Для безопасности нужно обработать разрешенные теги, заменяя их сущности на теги + # Но при этом безопасно обрабатывая атрибуты, чтобы избежать XSS allowed_tags = ALLOWED_HTML_TAGS.union(SELF_CLOSING_TAGS) + # Обработка всех разрешенных тегов for tag in allowed_tags: - text = re.sub( - f'<(/?{tag}\\b[^>]*)>', - lambda m: "<" - + ( - m.group(1) - .replace(""", "\"") - .replace("'", "'") - .replace("&", "&") - ) - + ">", - text, - flags=re.IGNORECASE - ) + # Паттерн: захватываем <tag>, </tag>, или <tag атрибуты> + # Используем более сложный паттерн, чтобы захватить атрибуты до закрывающего > + # (?s) - позволяет . захватывать новую строку + # [^>]*? - ленивый захват до > + pattern = rf'(<)(/?{tag}\b)([^>]*?)(>)' + + def replace_tag(match): + opening = match.group(1) # < + full_tag_content = match.group(2) # /?tagname + attrs_part = match.group(3) # атрибуты (без >) + closing = match.group(4) # > + + # Убираем начальный пробел, если есть + if attrs_part.startswith(' '): + attrs_part = attrs_part[1:] + + # Формируем результат + if attrs_part: + # Безопасно обрабатываем атрибуты, заменяя только безопасные сущности + # Не разворачиваем < и > внутри атрибутов, чтобы избежать XSS + processed_attrs = attrs_part.replace('"', '"').replace(''', "'") + return f'<{full_tag_content} {processed_attrs}>' + else: + return f'<{full_tag_content}>' + + text = re.sub(pattern, replace_tag, text, flags=re.IGNORECASE) return text