diff --git a/.dockerignore b/.dockerignore index 47cfd69..7662200 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,6 +17,7 @@ docker-compose.yml Dockerfile .docker .dockerignore +tmp/ # Byte-compiled / optimized / DLL files **/__pycache__/ diff --git a/.env.example b/.env.example index 68b28bc..0d9320a 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index daf7ee1..58b76c7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/bot/app/web/templates/subscription_webapp.html b/bot/app/web/templates/subscription_webapp.html index 1b982d1..7fa2019 100644 --- a/bot/app/web/templates/subscription_webapp.html +++ b/bot/app/web/templates/subscription_webapp.html @@ -5,51 +5,62 @@ - + Моя подписка + + + @@ -381,31 +620,36 @@
-
R
-
Моя подписка
+
/
+
+
/tnnl
+
Моя подписка
+
- +
-
-
-
-
Осталось
-
...
-
+
+
+
subscription
...
-
+
+

...

+

Ваша ссылка подключения и срок доступа

+
+ +
-
Окончание
+
ends_at
...
-
Трафик
+
traffic
...
@@ -414,29 +658,68 @@
+ +
-
Оплата подписки
-
+
+
telegram_auth
- + Открыть Telegram
@@ -449,12 +732,16 @@ token: localStorage.getItem('rw_webapp_token') || '', data: null, selectedPlan: null, + selectedMethod: null, payment: null, + paymentFlowOpen: false, + paymentStep: 'plan', + creatingPayment: false, authPoll: null }; - document.documentElement.classList.toggle('theme-dark', !tg || tg.colorScheme !== 'light'); - document.documentElement.style.setProperty('--accent', CFG.primaryColor || '#10b981'); + const accent = CFG.primaryColor || '#00fe7a'; + document.documentElement.style.setProperty('--accent', accent); document.title = CFG.title || 'Моя подписка'; document.getElementById('brand-title').textContent = CFG.title || 'Моя подписка'; if (CFG.logoUrl) { @@ -470,7 +757,8 @@ try { tg.ready(); tg.expand(); - tg.setHeaderColor(CFG.primaryColor || '#10b981'); + tg.setHeaderColor('#05070a'); + tg.setBackgroundColor('#05070a'); } catch (e) { } } @@ -531,10 +819,15 @@ } async function loadData() { + const previousMonths = state.selectedPlan && state.selectedPlan.months; const data = await api('/me'); if (!data.ok) throw new Error(data.error || 'load failed'); state.data = data; - state.selectedPlan = data.plans && data.plans.length ? data.plans[0] : null; + const plans = data.plans || []; + state.selectedPlan = plans.find(plan => plan.months === previousMonths) || plans[0] || null; + state.selectedMethod = null; + state.payment = null; + state.paymentStep = 'plan'; render(); showApp(); } @@ -546,13 +839,12 @@ function render() { renderSubscription(state.data.subscription); - renderPlans(state.data.plans || []); - renderMethods(); + renderPaymentFlow(); } function renderSubscription(sub) { const badge = document.getElementById('status-badge'); - badge.textContent = sub.active ? 'Активна' : 'Не активна'; + badge.textContent = sub.active ? 'ACTIVE' : 'INACTIVE'; badge.classList.toggle('off', !sub.active); document.getElementById('remaining').textContent = sub.remaining_text || 'Нет активной подписки'; document.getElementById('end-date').textContent = sub.end_date_text || 'N/A'; @@ -560,103 +852,201 @@ document.getElementById('connect-actions').classList.toggle('hidden', !sub.connect_url && !sub.config_link); } + function togglePaymentFlow() { + if (state.paymentFlowOpen) { + closePaymentFlow(); + return; + } + state.paymentFlowOpen = true; + state.paymentStep = 'plan'; + state.payment = null; + state.selectedMethod = null; + renderPaymentFlow(); + document.getElementById('payment-flow').scrollIntoView({behavior: 'smooth', block: 'start'}); + } + + function closePaymentFlow() { + state.paymentFlowOpen = false; + state.paymentStep = 'plan'; + state.payment = null; + state.selectedMethod = null; + renderPaymentFlow(); + } + + function renderPaymentFlow() { + const flow = document.getElementById('payment-flow'); + flow.classList.toggle('hidden', !state.paymentFlowOpen); + if (!state.paymentFlowOpen) return; + + renderStepState(); + renderPlans(state.data.plans || []); + renderMethods(); + renderPaymentResult(); + } + + function renderStepState() { + const order = ['plan', 'method', 'result']; + order.forEach((step, index) => { + const node = document.getElementById('step-' + step); + const currentIndex = order.indexOf(state.paymentStep); + node.classList.toggle('active', state.paymentStep === step); + node.classList.toggle('done', currentIndex > index); + }); + + document.getElementById('plan-step').classList.toggle('hidden', state.paymentStep !== 'plan'); + document.getElementById('method-step').classList.toggle('hidden', state.paymentStep !== 'method'); + document.getElementById('result-step').classList.toggle('hidden', state.paymentStep !== 'result'); + } + function renderPlans(plans) { const wrap = document.getElementById('plans'); + const nextBtn = document.getElementById('to-methods-btn'); if (!plans.length) { wrap.innerHTML = '
Тарифы не настроены.
'; - document.getElementById('methods-wrap').classList.add('hidden'); + nextBtn.disabled = true; return; } + nextBtn.disabled = !state.selectedPlan; wrap.innerHTML = plans.map(plan => { const isActive = state.selectedPlan && state.selectedPlan.months === plan.months; - const stars = plan.stars_price ? ' · ' + plan.stars_price + ' ⭐' : ''; + const stars = plan.stars_price ? ' / ' + plan.stars_price + ' Stars' : ''; return ` `; }).join(''); } function renderMethods() { - const wrap = document.getElementById('methods-wrap'); const methods = document.getElementById('methods'); + const createBtn = document.getElementById('create-payment-btn'); if (!state.selectedPlan) { - wrap.classList.add('hidden'); + methods.innerHTML = '
Сначала выберите срок подписки.
'; + createBtn.disabled = true; return; } - const available = (state.data.payment_methods || []).filter(method => { - if (method.id === 'stars') return Number.isFinite(Number(state.selectedPlan.stars_price)); - return true; - }); - wrap.classList.remove('hidden'); + + const available = getAvailableMethods(); + if (state.selectedMethod && !available.some(method => method.id === state.selectedMethod)) { + state.selectedMethod = null; + } + + createBtn.disabled = !state.selectedMethod || state.creatingPayment; if (!available.length) { methods.innerHTML = '
Нет доступных способов оплаты.
'; + createBtn.disabled = true; return; } + methods.innerHTML = available.map(method => { + const isActive = state.selectedMethod === method.id; const amount = method.id === 'stars' - ? state.selectedPlan.stars_price + ' ⭐' + ? state.selectedPlan.stars_price + ' Stars' : formatMoney(state.selectedPlan.price, state.selectedPlan.currency); return ` - `; }).join(''); } - function selectPlan(months) { - state.selectedPlan = (state.data.plans || []).find(plan => plan.months === months); - renderPlans(state.data.plans || []); - renderMethods(); - document.getElementById('payment-panel').classList.remove('show'); - } - - async function createPayment(method) { - if (!state.selectedPlan) return; - setMethodsDisabled(true); - try { - const data = await api('/payments', { - method: 'POST', - body: JSON.stringify({months: state.selectedPlan.months, method}) - }); - if (!data.ok) throw new Error(data.message || 'Не удалось создать платеж'); - state.payment = data; - showPaymentPanel(data); - openPaymentUrl(); - } catch (e) { - showToast(e.message || 'Ошибка оплаты'); - } finally { - setMethodsDisabled(false); - } - } - - function showPaymentPanel(payment) { - const panel = document.getElementById('payment-panel'); + function renderPaymentResult() { + const payment = state.payment; const msg = document.getElementById('payment-message'); const openBtn = document.getElementById('payment-open-btn'); const checkBtn = document.getElementById('payment-check-btn'); - panel.classList.add('show'); + if (!payment) { + msg.textContent = 'Платеж еще не создан.'; + openBtn.classList.add('hidden'); + checkBtn.classList.add('hidden'); + return; + } + if (payment.action === 'invoice_sent') { - msg.textContent = 'Счет отправлен в чат с ботом.'; + msg.textContent = 'Счет отправлен в чат с ботом. После оплаты вернитесь сюда и проверьте статус.'; openBtn.classList.add('hidden'); } else { - msg.textContent = 'Платеж создан. После оплаты нажмите проверку или обновите страницу.'; - openBtn.classList.remove('hidden'); + msg.textContent = 'Платеж создан. Откройте оплату, завершите ее у провайдера и затем проверьте статус.'; + openBtn.classList.toggle('hidden', !payment.payment_url); } checkBtn.classList.toggle('hidden', !payment.payment_id); } + function selectPlan(months) { + state.selectedPlan = (state.data.plans || []).find(plan => plan.months === months) || null; + state.selectedMethod = null; + state.payment = null; + renderPaymentFlow(); + } + + function selectMethod(method) { + state.selectedMethod = method; + state.payment = null; + renderMethods(); + renderPaymentResult(); + } + + function goToPaymentStep(step) { + if (step === 'method' && !state.selectedPlan) { + showToast('Выберите срок подписки'); + return; + } + if (step === 'result' && !state.payment) { + showToast('Создайте платеж'); + return; + } + state.paymentStep = step; + renderPaymentFlow(); + } + + async function createPaymentFromSelection() { + if (!state.selectedPlan) { + showToast('Выберите срок подписки'); + return; + } + if (!state.selectedMethod) { + showToast('Выберите способ оплаты'); + return; + } + + state.creatingPayment = true; + renderMethods(); + try { + const data = await api('/payments', { + method: 'POST', + body: JSON.stringify({months: state.selectedPlan.months, method: state.selectedMethod}) + }); + if (!data.ok) throw new Error(data.message || 'Не удалось создать платеж'); + state.payment = data; + state.paymentStep = 'result'; + renderPaymentFlow(); + } catch (e) { + showToast(e.message || 'Ошибка оплаты'); + } finally { + state.creatingPayment = false; + renderPaymentFlow(); + } + } + + function getAvailableMethods() { + if (!state.data || !state.selectedPlan) return []; + return (state.data.payment_methods || []).filter(method => { + if (method.id === 'stars') return Number(state.selectedPlan.stars_price || 0) > 0; + return true; + }); + } + function openPaymentUrl() { const payment = state.payment; if (!payment || !payment.payment_url) return; @@ -678,6 +1068,7 @@ const data = await api('/payments/' + state.payment.payment_id); if (data.paid) { showToast('Оплата подтверждена'); + state.paymentFlowOpen = false; await loadData(); } else { showToast('Платеж пока не подтвержден'); @@ -755,10 +1146,6 @@ document.getElementById('login').classList.add('show'); } - function setMethodsDisabled(disabled) { - document.querySelectorAll('.method').forEach(btn => btn.disabled = disabled); - } - function showToast(message) { if (tg && tg.showAlert) { tg.showAlert(message); diff --git a/bot/handlers/user/promo_user.py b/bot/handlers/user/promo_user.py index 2fc02df..40f6b6a 100644 --- a/bot/handlers/user/promo_user.py +++ b/bot/handlers/user/promo_user.py @@ -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, + ) diff --git a/bot/handlers/user/start.py b/bot/handlers/user/start.py index 29beba5..6c7a260 100644 --- a/bot/handlers/user/start.py +++ b/bot/handlers/user/start.py @@ -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, + ) diff --git a/bot/middlewares/db_session.py b/bot/middlewares/db_session.py index 006a2d2..02cfce0 100644 --- a/bot/middlewares/db_session.py +++ b/bot/middlewares/db_session.py @@ -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( diff --git a/bot/utils/callback_answer.py b/bot/utils/callback_answer.py new file mode 100644 index 0000000..504edb6 --- /dev/null +++ b/bot/utils/callback_answer.py @@ -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 diff --git a/config/settings.py b/config/settings.py index b20c8db..6fa4774 100644 --- a/config/settings.py +++ b/config/settings.py @@ -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)