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
.docker
.dockerignore
tmp/
# Byte-compiled / optimized / DLL files
**/__pycache__/
+1 -1
View File
@@ -37,7 +37,7 @@ WEBAPP_ENABLED=True #
WEBAPP_SERVER_HOST=0.0.0.0 # Internal listen host
WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port
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_SESSION_TTL_SECONDS=2592000 # Web App session lifetime
WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age
+3 -3
View File
@@ -71,7 +71,7 @@
| `WEBAPP_ENABLED` | Включить Web App в том же контейнере, но на отдельном порту. | `true` |
| `WEBAPP_SERVER_PORT` | Внутренний порт Web App. | `8081` |
| `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` |
| `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` |
| `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` |
@@ -202,7 +202,7 @@ Web App запускается в том же контейнере, что и б
WEBAPP_SERVER_PORT=8081
SUBSCRIPTION_MINI_APP_URL=https://app.domain.com/
WEBAPP_TITLE="Моя подписка"
WEBAPP_PRIMARY_COLOR="#10b981"
WEBAPP_PRIMARY_COLOR="#00fe7a"
WEBAPP_LOGO_URL=
```
@@ -248,7 +248,7 @@ Web App запускается в том же контейнере, что и б
docker compose up -d --build
```
После этого кнопка «Моя подписка» в меню бота откроет Web App. В MVP Web App показывает текущую ссылку подключения, остаток времени, трафик, тарифы и создает платежные ссылки через уже настроенные платежные системы. Успешные оплаты обрабатываются существующими webhook-обработчиками.
После этого кнопка «Моя подписка» в меню бота откроет Web App. В MVP Web App показывает текущую ссылку подключения, остаток времени и трафик. Оплата открывается отдельной кнопкой «Продлить подписку/Добавить дни»: пользователь пошагово выбирает срок, способ оплаты и создает платежную ссылку через уже настроенные платежные системы. Успешные оплаты обрабатываются существующими webhook-обработчиками.
## Подробная инструкция для развертывания на сервере с панелью 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 bot.middlewares.i18n import JsonI18n
from bot.utils.callback_answer import safe_answer_callback
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)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await callback.answer("Language service error.", show_alert=True)
await safe_answer_callback(callback, "Language service error.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
if not callback.message:
logging.error(
"CallbackQuery has no message in prompt_promo_code_input")
await callback.answer(_("error_occurred_processing_request"),
show_alert=True)
await safe_answer_callback(
callback,
_("error_occurred_processing_request"),
show_alert=True,
)
return
try:
@@ -58,7 +62,7 @@ async def prompt_promo_code_input(callback: types.CallbackQuery,
text=_(key="promo_code_prompt"),
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)
logging.info(
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")
if not i18n:
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
logging.info(
@@ -200,5 +204,8 @@ async def cancel_promo_input_via_button(
else:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
await callback.answer(_("promo_input_cancelled_short"),
show_alert=False)
await safe_answer_callback(
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 bot.middlewares.i18n import JsonI18n
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
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}."
)
if isinstance(target_event, types.CallbackQuery):
await target_event.answer(_("error_displaying_menu"),
show_alert=True)
await safe_answer_callback(
target_event,
_("error_displaying_menu"),
show_alert=True,
)
return
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)
if isinstance(target_event, types.CallbackQuery):
try:
await target_event.answer()
except Exception:
pass
await safe_answer_callback(target_event)
except Exception as e_send_edit:
logging.warning(
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}"
)
if isinstance(target_event, types.CallbackQuery):
try:
await target_event.answer(
_("error_occurred_try_again") if is_edit else None)
except Exception:
pass
await safe_answer_callback(
target_event,
_("error_occurred_try_again") if is_edit else None,
)
async def ensure_required_channel_subscription(
@@ -693,8 +693,11 @@ async def verify_channel_subscription_callback(
welcome_text)
try:
await callback.answer(_(key="channel_subscription_verified_success"),
show_alert=True)
await safe_answer_callback(
callback,
_(key="channel_subscription_verified_success"),
show_alert=True,
)
except Exception:
pass
@@ -725,7 +728,11 @@ async def language_command_handler(
event, types.CallbackQuery) else event
if not target_message_obj:
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
if isinstance(event, types.CallbackQuery):
@@ -736,7 +743,7 @@ async def language_command_handler(
except Exception:
await target_message_obj.answer(text_to_send,
reply_markup=reply_markup)
await event.answer()
await safe_answer_callback(event)
else:
await target_message_obj.answer(text_to_send,
reply_markup=reply_markup)
@@ -748,15 +755,21 @@ async def select_language_callback_handler(
subscription_service: SubscriptionService, session: AsyncSession):
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Service error or message context lost.",
show_alert=True)
await safe_answer_callback(
callback,
"Service error or message context lost.",
show_alert=True,
)
return
try:
lang_code = callback.data.split("_")[2]
except IndexError:
await callback.answer("Error processing language selection.",
show_alert=True)
await safe_answer_callback(
callback,
"Error processing language selection.",
show_alert=True,
)
return
user_id = callback.from_user.id
@@ -767,18 +780,22 @@ async def select_language_callback_handler(
i18n_data["current_language"] = lang_code
_ = 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(
f"User {user_id} language updated to {lang_code} in session.")
else:
await callback.answer("Could not set language.", show_alert=True)
await safe_answer_callback(
callback,
"Could not set language.",
show_alert=True,
)
return
except Exception as e_lang_update:
logging.error(
f"Error updating lang for user {user_id}: {e_lang_update}",
exc_info=True)
await callback.answer("Error setting language.", show_alert=True)
await safe_answer_callback(callback, "Error setting language.", show_alert=True)
return
await send_main_menu(callback,
settings,
@@ -803,7 +820,11 @@ async def main_action_callback_handler(
from . import trial_handler as user_trial_handlers
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
if action == "subscribe":
@@ -834,8 +855,11 @@ async def main_action_callback_handler(
current_lang = i18n_data.get("current_language",
settings.DEFAULT_LANGUAGE)
if not i18n:
await callback.answer("Language service error.",
show_alert=True)
await safe_answer_callback(
callback,
"Language service error.",
show_alert=True,
)
return
_ = lambda key, **kwargs: i18n.gettext(
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
if not privacy_url and not user_agreement_url:
await callback.answer(_("error_occurred_try_again"),
show_alert=True)
await safe_answer_callback(
callback,
_("error_occurred_try_again"),
show_alert=True,
)
return
reply_markup = get_information_links_keyboard(
@@ -860,7 +887,7 @@ async def main_action_callback_handler(
except Exception:
await callback.message.answer(_(key="info_links_message"),
reply_markup=reply_markup)
await callback.answer()
await safe_answer_callback(callback)
elif action == "back_to_main":
await send_main_menu(callback,
settings,
@@ -878,5 +905,9 @@ async def main_action_callback_handler(
else:
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
_ = lambda key, **kwargs: i18n.gettext(
i18n_data.get("current_language"), key, **kw) if i18n else key
await callback.answer(_("main_menu_unknown_action"), show_alert=True)
i18n_data.get("current_language"), key, **kwargs) if i18n else key
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 aiogram import BaseMiddleware
from aiogram.exceptions import TelegramBadRequest
from aiogram.types import Update
from sqlalchemy.orm import sessionmaker
from bot.utils.callback_answer import is_expired_callback_answer_error
class DBSessionMiddleware(BaseMiddleware):
@@ -31,6 +34,18 @@ class DBSessionMiddleware(BaseMiddleware):
await session.commit()
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:
await session.rollback()
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_PORT: int = Field(default=8081)
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_SESSION_TTL_SECONDS: int = Field(default=30 * 24 * 60 * 60)
WEBAPP_AUTH_MAX_AGE_SECONDS: int = Field(default=24 * 60 * 60)