fix: tune webapp visual and fix some errors

This commit is contained in:
3252a8
2026-04-22 22:35:10 +03:00
parent 4f1b7d0832
commit 259d0646bc
9 changed files with 770 additions and 277 deletions
+1
View File
@@ -17,6 +17,7 @@ docker-compose.yml
Dockerfile Dockerfile
.docker .docker
.dockerignore .dockerignore
tmp/
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
**/__pycache__/ **/__pycache__/
+1 -1
View File
@@ -37,7 +37,7 @@ WEBAPP_ENABLED=True #
WEBAPP_SERVER_HOST=0.0.0.0 # Internal listen host WEBAPP_SERVER_HOST=0.0.0.0 # Internal listen host
WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port
WEBAPP_TITLE="Моя подписка" # Mini App title WEBAPP_TITLE="Моя подписка" # Mini App title
WEBAPP_PRIMARY_COLOR="#10b981" # Main UI color WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color
WEBAPP_LOGO_URL= # Optional logo URL WEBAPP_LOGO_URL= # Optional logo URL
WEBAPP_SESSION_TTL_SECONDS=2592000 # Web App session lifetime WEBAPP_SESSION_TTL_SECONDS=2592000 # Web App session lifetime
WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age
+3 -3
View File
@@ -71,7 +71,7 @@
| `WEBAPP_ENABLED` | Включить Web App в том же контейнере, но на отдельном порту. | `true` | | `WEBAPP_ENABLED` | Включить Web App в том же контейнере, но на отдельном порту. | `true` |
| `WEBAPP_SERVER_PORT` | Внутренний порт Web App. | `8081` | | `WEBAPP_SERVER_PORT` | Внутренний порт Web App. | `8081` |
| `WEBAPP_TITLE` | Заголовок Web App. | `Моя подписка` | | `WEBAPP_TITLE` | Заголовок Web App. | `Моя подписка` |
| `WEBAPP_PRIMARY_COLOR` | Основной цвет Web App. | `#10b981` | | `WEBAPP_PRIMARY_COLOR` | Основной цвет Web App. | `#00fe7a` |
| `WEBAPP_LOGO_URL` | (Опционально) URL логотипа Web App. | `https://domain.com/logo.png` | | `WEBAPP_LOGO_URL` | (Опционально) URL логотипа Web App. | `https://domain.com/logo.png` |
| `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` | | `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` |
| `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` | | `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` |
@@ -202,7 +202,7 @@ Web App запускается в том же контейнере, что и б
WEBAPP_SERVER_PORT=8081 WEBAPP_SERVER_PORT=8081
SUBSCRIPTION_MINI_APP_URL=https://app.domain.com/ SUBSCRIPTION_MINI_APP_URL=https://app.domain.com/
WEBAPP_TITLE="Моя подписка" WEBAPP_TITLE="Моя подписка"
WEBAPP_PRIMARY_COLOR="#10b981" WEBAPP_PRIMARY_COLOR="#00fe7a"
WEBAPP_LOGO_URL= WEBAPP_LOGO_URL=
``` ```
@@ -248,7 +248,7 @@ Web App запускается в том же контейнере, что и б
docker compose up -d --build docker compose up -d --build
``` ```
После этого кнопка «Моя подписка» в меню бота откроет Web App. В MVP Web App показывает текущую ссылку подключения, остаток времени, трафик, тарифы и создает платежные ссылки через уже настроенные платежные системы. Успешные оплаты обрабатываются существующими webhook-обработчиками. После этого кнопка «Моя подписка» в меню бота откроет Web App. В MVP Web App показывает текущую ссылку подключения, остаток времени и трафик. Оплата открывается отдельной кнопкой «Продлить подписку/Добавить дни»: пользователь пошагово выбирает срок, способ оплаты и создает платежную ссылку через уже настроенные платежные системы. Успешные оплаты обрабатываются существующими webhook-обработчиками.
## Подробная инструкция для развертывания на сервере с панелью Remnawave ## Подробная инструкция для развертывания на сервере с панелью Remnawave
File diff suppressed because it is too large Load Diff
+14 -7
View File
@@ -16,6 +16,7 @@ from bot.keyboards.inline.user_keyboards import (
) )
from datetime import datetime from datetime import datetime
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from bot.utils.callback_answer import safe_answer_callback
from .start import send_main_menu from .start import send_main_menu
@@ -35,15 +36,18 @@ async def prompt_promo_code_input(callback: types.CallbackQuery,
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n: if not i18n:
await callback.answer("Language service error.", show_alert=True) await safe_answer_callback(callback, "Language service error.", show_alert=True)
return return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
if not callback.message: if not callback.message:
logging.error( logging.error(
"CallbackQuery has no message in prompt_promo_code_input") "CallbackQuery has no message in prompt_promo_code_input")
await callback.answer(_("error_occurred_processing_request"), await safe_answer_callback(
show_alert=True) callback,
_("error_occurred_processing_request"),
show_alert=True,
)
return return
try: try:
@@ -58,7 +62,7 @@ async def prompt_promo_code_input(callback: types.CallbackQuery,
text=_(key="promo_code_prompt"), text=_(key="promo_code_prompt"),
reply_markup=get_back_to_main_menu_markup(current_lang, i18n)) reply_markup=get_back_to_main_menu_markup(current_lang, i18n))
await callback.answer() await safe_answer_callback(callback)
await state.set_state(UserPromoStates.waiting_for_promo_code) await state.set_state(UserPromoStates.waiting_for_promo_code)
logging.info( logging.info(
f"User {callback.from_user.id} entered state UserPromoStates.waiting_for_promo_code. " f"User {callback.from_user.id} entered state UserPromoStates.waiting_for_promo_code. "
@@ -181,7 +185,7 @@ async def cancel_promo_input_via_button(
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n: if not i18n:
logging.error("i18n missing in cancel_promo_input_via_button") logging.error("i18n missing in cancel_promo_input_via_button")
await callback.answer("Language error", show_alert=True) await safe_answer_callback(callback, "Language error", show_alert=True)
return return
logging.info( logging.info(
@@ -200,5 +204,8 @@ async def cancel_promo_input_via_button(
else: else:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
await callback.answer(_("promo_input_cancelled_short"), await safe_answer_callback(
show_alert=False) callback,
_("promo_input_cancelled_short"),
show_alert=False,
)
+61 -30
View File
@@ -25,6 +25,7 @@ from bot.services.promo_code_service import PromoCodeService
from config.settings import Settings from config.settings import Settings
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from bot.utils.text_sanitizer import sanitize_username, sanitize_display_name from bot.utils.text_sanitizer import sanitize_username, sanitize_display_name
from bot.utils.callback_answer import safe_answer_callback
from bot.app.web.webapp_auth import authorize_pending_webapp_auth_token from bot.app.web.webapp_auth import authorize_pending_webapp_auth_token
router = Router(name="user_start_router") router = Router(name="user_start_router")
@@ -91,8 +92,11 @@ async def send_main_menu(target_event: Union[types.Message,
f"send_main_menu: target_message_obj is None for event from user {user_id}." f"send_main_menu: target_message_obj is None for event from user {user_id}."
) )
if isinstance(target_event, types.CallbackQuery): if isinstance(target_event, types.CallbackQuery):
await target_event.answer(_("error_displaying_menu"), await safe_answer_callback(
show_alert=True) target_event,
_("error_displaying_menu"),
show_alert=True,
)
return return
try: try:
@@ -102,10 +106,7 @@ async def send_main_menu(target_event: Union[types.Message,
await target_message_obj.answer(text, reply_markup=reply_markup) await target_message_obj.answer(text, reply_markup=reply_markup)
if isinstance(target_event, types.CallbackQuery): if isinstance(target_event, types.CallbackQuery):
try: await safe_answer_callback(target_event)
await target_event.answer()
except Exception:
pass
except Exception as e_send_edit: except Exception as e_send_edit:
logging.warning( logging.warning(
f"Failed to send/edit main menu (user: {user_id}, is_edit: {is_edit}): {type(e_send_edit).__name__} - {e_send_edit}." f"Failed to send/edit main menu (user: {user_id}, is_edit: {is_edit}): {type(e_send_edit).__name__} - {e_send_edit}."
@@ -118,11 +119,10 @@ async def send_main_menu(target_event: Union[types.Message,
f"Also failed to send new main menu message for user {user_id}: {e_send_new}" f"Also failed to send new main menu message for user {user_id}: {e_send_new}"
) )
if isinstance(target_event, types.CallbackQuery): if isinstance(target_event, types.CallbackQuery):
try: await safe_answer_callback(
await target_event.answer( target_event,
_("error_occurred_try_again") if is_edit else None) _("error_occurred_try_again") if is_edit else None,
except Exception: )
pass
async def ensure_required_channel_subscription( async def ensure_required_channel_subscription(
@@ -693,8 +693,11 @@ async def verify_channel_subscription_callback(
welcome_text) welcome_text)
try: try:
await callback.answer(_(key="channel_subscription_verified_success"), await safe_answer_callback(
show_alert=True) callback,
_(key="channel_subscription_verified_success"),
show_alert=True,
)
except Exception: except Exception:
pass pass
@@ -725,7 +728,11 @@ async def language_command_handler(
event, types.CallbackQuery) else event event, types.CallbackQuery) else event
if not target_message_obj: if not target_message_obj:
if isinstance(event, types.CallbackQuery): if isinstance(event, types.CallbackQuery):
await event.answer(_("error_occurred_try_again"), show_alert=True) await safe_answer_callback(
event,
_("error_occurred_try_again"),
show_alert=True,
)
return return
if isinstance(event, types.CallbackQuery): if isinstance(event, types.CallbackQuery):
@@ -736,7 +743,7 @@ async def language_command_handler(
except Exception: except Exception:
await target_message_obj.answer(text_to_send, await target_message_obj.answer(text_to_send,
reply_markup=reply_markup) reply_markup=reply_markup)
await event.answer() await safe_answer_callback(event)
else: else:
await target_message_obj.answer(text_to_send, await target_message_obj.answer(text_to_send,
reply_markup=reply_markup) reply_markup=reply_markup)
@@ -748,15 +755,21 @@ async def select_language_callback_handler(
subscription_service: SubscriptionService, session: AsyncSession): subscription_service: SubscriptionService, session: AsyncSession):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message: if not i18n or not callback.message:
await callback.answer("Service error or message context lost.", await safe_answer_callback(
show_alert=True) callback,
"Service error or message context lost.",
show_alert=True,
)
return return
try: try:
lang_code = callback.data.split("_")[2] lang_code = callback.data.split("_")[2]
except IndexError: except IndexError:
await callback.answer("Error processing language selection.", await safe_answer_callback(
show_alert=True) callback,
"Error processing language selection.",
show_alert=True,
)
return return
user_id = callback.from_user.id user_id = callback.from_user.id
@@ -767,18 +780,22 @@ async def select_language_callback_handler(
i18n_data["current_language"] = lang_code i18n_data["current_language"] = lang_code
_ = lambda key, **kwargs: i18n.gettext(lang_code, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(lang_code, key, **kwargs)
await callback.answer(_(key="language_set_alert")) await safe_answer_callback(callback, _(key="language_set_alert"))
logging.info( logging.info(
f"User {user_id} language updated to {lang_code} in session.") f"User {user_id} language updated to {lang_code} in session.")
else: else:
await callback.answer("Could not set language.", show_alert=True) await safe_answer_callback(
callback,
"Could not set language.",
show_alert=True,
)
return return
except Exception as e_lang_update: except Exception as e_lang_update:
logging.error( logging.error(
f"Error updating lang for user {user_id}: {e_lang_update}", f"Error updating lang for user {user_id}: {e_lang_update}",
exc_info=True) exc_info=True)
await callback.answer("Error setting language.", show_alert=True) await safe_answer_callback(callback, "Error setting language.", show_alert=True)
return return
await send_main_menu(callback, await send_main_menu(callback,
settings, settings,
@@ -803,7 +820,11 @@ async def main_action_callback_handler(
from . import trial_handler as user_trial_handlers from . import trial_handler as user_trial_handlers
if not callback.message: if not callback.message:
await callback.answer("Error: message context lost.", show_alert=True) await safe_answer_callback(
callback,
"Error: message context lost.",
show_alert=True,
)
return return
if action == "subscribe": if action == "subscribe":
@@ -834,8 +855,11 @@ async def main_action_callback_handler(
current_lang = i18n_data.get("current_language", current_lang = i18n_data.get("current_language",
settings.DEFAULT_LANGUAGE) settings.DEFAULT_LANGUAGE)
if not i18n: if not i18n:
await callback.answer("Language service error.", await safe_answer_callback(
show_alert=True) callback,
"Language service error.",
show_alert=True,
)
return return
_ = lambda key, **kwargs: i18n.gettext( _ = lambda key, **kwargs: i18n.gettext(
current_lang, key, **kwargs) if i18n else key current_lang, key, **kwargs) if i18n else key
@@ -844,8 +868,11 @@ async def main_action_callback_handler(
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
if not privacy_url and not user_agreement_url: if not privacy_url and not user_agreement_url:
await callback.answer(_("error_occurred_try_again"), await safe_answer_callback(
show_alert=True) callback,
_("error_occurred_try_again"),
show_alert=True,
)
return return
reply_markup = get_information_links_keyboard( reply_markup = get_information_links_keyboard(
@@ -860,7 +887,7 @@ async def main_action_callback_handler(
except Exception: except Exception:
await callback.message.answer(_(key="info_links_message"), await callback.message.answer(_(key="info_links_message"),
reply_markup=reply_markup) reply_markup=reply_markup)
await callback.answer() await safe_answer_callback(callback)
elif action == "back_to_main": elif action == "back_to_main":
await send_main_menu(callback, await send_main_menu(callback,
settings, settings,
@@ -878,5 +905,9 @@ async def main_action_callback_handler(
else: else:
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
_ = lambda key, **kwargs: i18n.gettext( _ = lambda key, **kwargs: i18n.gettext(
i18n_data.get("current_language"), key, **kw) if i18n else key i18n_data.get("current_language"), key, **kwargs) if i18n else key
await callback.answer(_("main_menu_unknown_action"), show_alert=True) await safe_answer_callback(
callback,
_("main_menu_unknown_action"),
show_alert=True,
)
+15
View File
@@ -2,9 +2,12 @@ import logging
from typing import Callable, Dict, Any, Awaitable from typing import Callable, Dict, Any, Awaitable
from aiogram import BaseMiddleware from aiogram import BaseMiddleware
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import Update from aiogram.types import Update
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from bot.utils.callback_answer import is_expired_callback_answer_error
class DBSessionMiddleware(BaseMiddleware): class DBSessionMiddleware(BaseMiddleware):
@@ -31,6 +34,18 @@ class DBSessionMiddleware(BaseMiddleware):
await session.commit() await session.commit()
return result return result
except TelegramBadRequest as error:
await session.rollback()
if is_expired_callback_answer_error(error):
logging.info(
"DBSessionMiddleware: expired callback answer ignored; "
"session rolled back for the interrupted update."
)
return None
logging.error(
"DBSessionMiddleware: Exception caused rollback.", exc_info=True
)
raise
except Exception: except Exception:
await session.rollback() await session.rollback()
logging.error( logging.error(
+52
View File
@@ -0,0 +1,52 @@
import logging
from typing import Any
from aiogram.exceptions import TelegramAPIError, TelegramBadRequest
from aiogram.types import CallbackQuery
_EXPIRED_CALLBACK_MARKERS = (
"query is too old",
"response timeout expired",
"query id is invalid",
)
def is_expired_callback_answer_error(error: BaseException) -> bool:
if not isinstance(error, TelegramBadRequest):
return False
message = str(error).lower()
return any(marker in message for marker in _EXPIRED_CALLBACK_MARKERS)
async def safe_answer_callback(
callback: CallbackQuery,
*args: Any,
**kwargs: Any,
) -> bool:
try:
await callback.answer(*args, **kwargs)
return True
except TelegramBadRequest as error:
user_id = getattr(getattr(callback, "from_user", None), "id", "unknown")
if is_expired_callback_answer_error(error):
logging.info(
"Ignored expired callback answer for user %s: %s",
user_id,
error,
)
return False
logging.warning(
"Failed to answer callback query for user %s: %s",
user_id,
error,
)
return False
except TelegramAPIError as error:
user_id = getattr(getattr(callback, "from_user", None), "id", "unknown")
logging.warning(
"Telegram API error while answering callback query for user %s: %s",
user_id,
error,
)
return False
+1 -1
View File
@@ -209,7 +209,7 @@ class Settings(BaseSettings):
WEBAPP_SERVER_HOST: str = Field(default="0.0.0.0") WEBAPP_SERVER_HOST: str = Field(default="0.0.0.0")
WEBAPP_SERVER_PORT: int = Field(default=8081) WEBAPP_SERVER_PORT: int = Field(default=8081)
WEBAPP_TITLE: str = Field(default="Моя подписка") WEBAPP_TITLE: str = Field(default="Моя подписка")
WEBAPP_PRIMARY_COLOR: str = Field(default="#10b981") WEBAPP_PRIMARY_COLOR: str = Field(default="#00fe7a")
WEBAPP_LOGO_URL: Optional[str] = Field(default=None) WEBAPP_LOGO_URL: Optional[str] = Field(default=None)
WEBAPP_SESSION_TTL_SECONDS: int = Field(default=30 * 24 * 60 * 60) WEBAPP_SESSION_TTL_SECONDS: int = Field(default=30 * 24 * 60 * 60)
WEBAPP_AUTH_MAX_AGE_SECONDS: int = Field(default=24 * 60 * 60) WEBAPP_AUTH_MAX_AGE_SECONDS: int = Field(default=24 * 60 * 60)