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 @@
-
-
-
+
+
-
+
+
...
+
Ваша ссылка подключения и срок доступа
+
+
+
-
Окончание
+
ends_at
...
@@ -414,29 +658,68 @@
Подключиться
Скопировать ссылку
+
+ Продлить подписку/Добавить дни
-
Оплата подписки
-
+
+
+
+
Оплата подписки
+
Выберите срок, способ оплаты и создайте платеж.
+
+
×
+
-
- Способ оплаты
-
+
+
+ 01
+ Срок
+
+
+ 02
+ Метод
+
+
+ 03
+ Платеж
+
+
+
+
+
select_period
+
+
Выбрать способ оплаты
+
+
+
+
select_method
+
+
+ Назад
+ Создать платеж
+
+
+
+
+
payment_status
+
+
+
Открыть оплату
+
Проверить оплату
+
Выбрать другой способ
+
+
-
-
- Открыть оплату
- Проверить оплату
-
-
- Поддержка
+ Поддержка
+
telegram_auth
Вход через Telegram
-
Откройте бота и подтвердите вход. После подтверждения эта страница обновится автоматически.
+
Откройте бота и подтвердите вход. После подтверждения страница обновится автоматически.
Открыть 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 `
${escapeHtml(plan.title)}
- Доступ на ${escapeHtml(plan.title)}
+ access_period
- ${formatMoney(plan.price, plan.currency)}${stars}
+ ${escapeHtml(formatMoney(plan.price, plan.currency) + stars)}
`;
}).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 `
-
+
${escapeHtml(method.name)}
${escapeHtml(state.selectedPlan.title)}
- ${amount}
+ ${escapeHtml(amount)}
`;
}).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)