Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd2d0dd570 | ||
|
|
abaaf65da9 | ||
|
|
699964adc0 | ||
|
|
74b5587c77 | ||
|
|
d196801e2e | ||
|
|
c12958e9a2 | ||
|
|
f4bb61dd1b | ||
|
|
24a7e6cc6a | ||
|
|
c6f5e06eba | ||
|
|
ca35567012 | ||
|
|
cdefe27f89 | ||
|
|
7200d6e342 | ||
|
|
fd4b9b828b | ||
|
|
b2a0ea5bd2 | ||
|
|
b97774c5b5 | ||
|
|
d5b4f18dbc | ||
|
|
a94ee8b554 | ||
|
|
7a90e24427 | ||
|
|
891f9354ac | ||
|
|
89709d6626 | ||
|
|
eea6afbcc7 | ||
|
|
99539542ca | ||
|
|
6e8f810cbd | ||
|
|
c7294ddaba | ||
|
|
cb82637b8e | ||
|
|
aa20ac9a18 | ||
|
|
53464ab25c | ||
|
|
1e0e883c6e | ||
|
|
d14afc5720 | ||
|
|
24706388c3 | ||
|
|
df9e36928c | ||
|
|
c245ccb55b | ||
|
|
ba348eb639 | ||
|
|
f6ce39f7c1 | ||
|
|
4732a97611 |
+27
-5
@@ -31,11 +31,33 @@ YOOKASSA_PAYMENT_SUBJECT=payment
|
||||
# If unset, bot will use polling while YooKassa uses webhooks.
|
||||
TELEGRAM_WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
|
||||
|
||||
# Subscription Prices (integer values)
|
||||
PRICE_1_MONTH=150
|
||||
PRICE_3_MONTHS=300
|
||||
PRICE_6_MONTHS=500
|
||||
PRICE_12_MONTHS=900
|
||||
# Payment Methods
|
||||
YOOKASSA_ENABLED=True
|
||||
STARS_ENABLED=True
|
||||
TRIBUTE_ENABLED=True
|
||||
|
||||
# Subscription Options
|
||||
1_MONTH_ENABLED=True
|
||||
RUB_PRICE_1_MONTH=150
|
||||
STARS_PRICE_1_MONTH=0
|
||||
TRIBUTE_LINK_1_MONTH=
|
||||
|
||||
3_MONTHS_ENABLED=True
|
||||
RUB_PRICE_3_MONTHS=300
|
||||
STARS_PRICE_3_MONTHS=0
|
||||
TRIBUTE_LINK_3_MONTHS=
|
||||
|
||||
6_MONTHS_ENABLED=True
|
||||
RUB_PRICE_6_MONTHS=500
|
||||
STARS_PRICE_6_MONTHS=0
|
||||
TRIBUTE_LINK_6_MONTHS=
|
||||
|
||||
12_MONTHS_ENABLED=True
|
||||
RUB_PRICE_12_MONTHS=900
|
||||
STARS_PRICE_12_MONTHS=0
|
||||
TRIBUTE_LINK_12_MONTHS=
|
||||
# API key for verifying Tribute webhook signatures
|
||||
TRIBUTE_API_KEY=
|
||||
|
||||
# Subscription Expiration Notifications
|
||||
SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS=7
|
||||
|
||||
+11
-2
@@ -1,11 +1,20 @@
|
||||
FROM python:3.11-slim
|
||||
FROM python:3.11-slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN rm -rf /root/.cache
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
|
||||
@@ -93,9 +93,12 @@ This Telegram bot is designed to automate the sale and management of subscriptio
|
||||
* `YOOKASSA_PAYMENT_MODE`: e.g., `full_prepayment`.
|
||||
* `YOOKASSA_PAYMENT_SUBJECT`: e.g., `service`.
|
||||
* `TELEGRAM_WEBHOOK_BASE_URL`: (Optional) If you want Telegram updates via webhook. Can be the same as `YOOKASSA_WEBHOOK_BASE_URL`. If not set, the bot will use polling for Telegram updates.
|
||||
* `PRICE_X_MONTH`: Prices for different subscription durations.
|
||||
* **Payment Method Toggles:** `YOOKASSA_ENABLED`, `STARS_ENABLED`, `TRIBUTE_ENABLED`.
|
||||
* **Subscription Options:** For each duration you can use variables like
|
||||
`1_MONTH_ENABLED`, `RUB_PRICE_1_MONTH`, `STARS_PRICE_1_MONTH`, `TRIBUTE_LINK_1_MONTH`
|
||||
(and corresponding variables for `3_MONTHS`, `6_MONTHS`, `12_MONTHS`).
|
||||
* **Panel API Settings:**
|
||||
* `PANEL_API_URL`: Full URL to your Remnawave panel's API (e.g., `http://localhost:3000/api` or `https://panel.yourdomain.com/api`).
|
||||
* `PANEL_API_URL`: Full URL to your Remnawave panel's API (e.g., `http://remnawave:3000/api` or `https://panel.yourdomain.com/api`).
|
||||
* `PANEL_API_KEY`: API Key for authenticating with the Remnawave panel.
|
||||
* `PANEL_USER_DEFAULT_INBOUND_UUIDS`: (Optional) Comma-separated list of inbound UUIDs from your panel to assign to users. If empty, `activateAllInbounds: true` (panel default) is used for new users.
|
||||
* `TRIAL_ENABLED`, `TRIAL_DURATION_DAYS`, `TRIAL_TRAFFIC_LIMIT_GB`: Settings for the trial period.
|
||||
|
||||
+113
-88
@@ -11,16 +11,23 @@ from config.settings import Settings
|
||||
from db.dal import user_dal, message_log_dal
|
||||
|
||||
from bot.states.admin_states import AdminStates
|
||||
from bot.keyboards.inline.admin_keyboards import get_broadcast_confirmation_keyboard, get_back_to_admin_panel_keyboard, get_admin_panel_keyboard
|
||||
from bot.keyboards.inline.admin_keyboards import (
|
||||
get_broadcast_confirmation_keyboard,
|
||||
get_back_to_admin_panel_keyboard,
|
||||
get_admin_panel_keyboard,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
router = Router(name="admin_broadcast_router")
|
||||
|
||||
|
||||
async def broadcast_message_prompt_handler(callback: types.CallbackQuery,
|
||||
state: FSMContext, i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession):
|
||||
async def broadcast_message_prompt_handler(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
@@ -35,25 +42,28 @@ async def broadcast_message_prompt_handler(callback: types.CallbackQuery,
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(
|
||||
current_lang, i18n))
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
)
|
||||
except Exception as e:
|
||||
logging.warning(
|
||||
f"Could not edit message for broadcast prompt: {e}. Sending new."
|
||||
)
|
||||
await callback.message.answer(
|
||||
prompt_text,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(
|
||||
current_lang, i18n))
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
)
|
||||
await callback.answer()
|
||||
await state.set_state(AdminStates.waiting_for_broadcast_message)
|
||||
|
||||
|
||||
@router.message(AdminStates.waiting_for_broadcast_message, F.text)
|
||||
async def process_broadcast_message_handler(message: types.Message,
|
||||
state: FSMContext, i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession):
|
||||
async def process_broadcast_message_handler(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
@@ -63,28 +73,35 @@ async def process_broadcast_message_handler(message: types.Message,
|
||||
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
broadcast_message_text = message.html_text
|
||||
await state.update_data(broadcast_message=broadcast_message_text)
|
||||
# Сохраняем в state исходный текст и entities
|
||||
text = message.text or message.caption or ""
|
||||
entities = message.entities or message.caption_entities or []
|
||||
|
||||
preview_snippet = (
|
||||
broadcast_message_text[:200] +
|
||||
"...") if len(broadcast_message_text) > 200 else broadcast_message_text
|
||||
confirmation_prompt = _("admin_broadcast_confirm_prompt",
|
||||
message_preview=preview_snippet)
|
||||
await state.update_data(
|
||||
broadcast_text=text,
|
||||
broadcast_entities=entities,
|
||||
)
|
||||
|
||||
await message.answer(confirmation_prompt,
|
||||
reply_markup=get_broadcast_confirmation_keyboard(
|
||||
current_lang, i18n),
|
||||
parse_mode="HTML")
|
||||
preview_snippet = (text[:200] + "...") if len(text) > 200 else text
|
||||
confirmation_prompt = _("admin_broadcast_confirm_prompt", message_preview=preview_snippet)
|
||||
|
||||
await message.answer(
|
||||
confirmation_prompt,
|
||||
reply_markup=get_broadcast_confirmation_keyboard(current_lang, i18n),
|
||||
)
|
||||
await state.set_state(AdminStates.confirming_broadcast)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_action:main",
|
||||
AdminStates.waiting_for_broadcast_message)
|
||||
async def cancel_broadcast_at_prompt_stage(callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
settings: Settings, i18n_data: dict,
|
||||
session: AsyncSession):
|
||||
@router.callback_query(
|
||||
F.data == "admin_action:main", AdminStates.waiting_for_broadcast_message
|
||||
)
|
||||
async def cancel_broadcast_at_prompt_stage(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
@@ -94,96 +111,104 @@ async def cancel_broadcast_at_prompt_stage(callback: types.CallbackQuery,
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(
|
||||
_("admin_broadcast_cancelled_nav_back"), reply_markup=None)
|
||||
_("admin_broadcast_cancelled_nav_back"), reply_markup=None
|
||||
)
|
||||
except Exception:
|
||||
await callback.message.answer(_("admin_broadcast_cancelled_nav_back"))
|
||||
|
||||
await callback.answer(_("admin_broadcast_cancelled_alert"))
|
||||
await state.clear()
|
||||
|
||||
await callback.message.answer(_(key="admin_panel_title"),
|
||||
reply_markup=get_admin_panel_keyboard(
|
||||
i18n, current_lang, settings))
|
||||
await callback.message.answer(
|
||||
_(key="admin_panel_title"),
|
||||
reply_markup=get_admin_panel_keyboard(i18n, current_lang, settings),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("broadcast_final_action:"),
|
||||
AdminStates.confirming_broadcast)
|
||||
async def confirm_broadcast_callback_handler(callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
i18n_data: dict, bot: Bot,
|
||||
settings: Settings,
|
||||
session: AsyncSession):
|
||||
@router.callback_query(
|
||||
F.data.startswith("broadcast_final_action:"),
|
||||
AdminStates.confirming_broadcast,
|
||||
)
|
||||
async def confirm_broadcast_callback_handler(
|
||||
callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
i18n_data: dict,
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error processing broadcast confirmation.",
|
||||
show_alert=True)
|
||||
await callback.answer("Error processing broadcast confirmation.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
action = callback.data.split(":")[1]
|
||||
user_fsm_data = await state.get_data()
|
||||
broadcast_message_to_send = user_fsm_data.get("broadcast_message")
|
||||
|
||||
if action == "send":
|
||||
if not broadcast_message_to_send:
|
||||
await callback.message.edit_text(
|
||||
_("admin_broadcast_error_no_message"))
|
||||
text = user_fsm_data.get("broadcast_text")
|
||||
entities = user_fsm_data.get("broadcast_entities", [])
|
||||
|
||||
if not text:
|
||||
await callback.message.edit_text(_("admin_broadcast_error_no_message"))
|
||||
await state.clear()
|
||||
await callback.answer(_("admin_broadcast_error_no_message_alert"),
|
||||
show_alert=True)
|
||||
await callback.answer(
|
||||
_("admin_broadcast_error_no_message_alert"), show_alert=True
|
||||
)
|
||||
return
|
||||
|
||||
await callback.message.edit_text(_("admin_broadcast_sending_started"),
|
||||
reply_markup=None)
|
||||
await callback.message.edit_text(_("admin_broadcast_sending_started"), reply_markup=None)
|
||||
await callback.answer()
|
||||
|
||||
user_ids_for_broadcast = await user_dal.get_all_active_user_ids_for_broadcast(
|
||||
session)
|
||||
user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session)
|
||||
|
||||
sent_count = 0
|
||||
failed_count = 0
|
||||
logging.info(
|
||||
f"Admin {callback.from_user.id} starting broadcast: '{broadcast_message_to_send[:50]}...' to {len(user_ids_for_broadcast)} users."
|
||||
)
|
||||
admin_user = callback.from_user
|
||||
logging.info(
|
||||
f"Admin {admin_user.id} broadcasting '{text[:50]}...' to {len(user_ids)} users."
|
||||
)
|
||||
|
||||
for user_id_to_send in user_ids_for_broadcast:
|
||||
for uid in user_ids:
|
||||
try:
|
||||
await bot.send_message(user_id_to_send,
|
||||
broadcast_message_to_send,
|
||||
parse_mode="HTML")
|
||||
await bot.send_message(
|
||||
chat_id=uid,
|
||||
text=text,
|
||||
entities=entities,
|
||||
)
|
||||
sent_count += 1
|
||||
|
||||
log_payload_sent = {
|
||||
"user_id": admin_user.id,
|
||||
"telegram_username": admin_user.username,
|
||||
"telegram_first_name": admin_user.first_name,
|
||||
"event_type": "admin_broadcast_sent",
|
||||
"content":
|
||||
f"To user {user_id_to_send}: {broadcast_message_to_send[:70]}...",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": user_id_to_send
|
||||
}
|
||||
await message_log_dal.create_message_log(
|
||||
session, log_payload_sent)
|
||||
session,
|
||||
{
|
||||
"user_id": admin_user.id,
|
||||
"telegram_username": admin_user.username,
|
||||
"telegram_first_name": admin_user.first_name,
|
||||
"event_type": "admin_broadcast_sent",
|
||||
"content": f"To user {uid}: {text[:70]}...",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": uid,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
logging.warning(
|
||||
f"Failed to send broadcast to user {user_id_to_send}: {type(e).__name__} - {e}"
|
||||
f"Failed to send broadcast to {uid}: {type(e).__name__} – {e}"
|
||||
)
|
||||
log_payload_failed = {
|
||||
"user_id": admin_user.id,
|
||||
"telegram_username": admin_user.username,
|
||||
"telegram_first_name": admin_user.first_name,
|
||||
"event_type": "admin_broadcast_failed",
|
||||
"content":
|
||||
f"For user {user_id_to_send}: {type(e).__name__} - {str(e)[:70]}...",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": user_id_to_send
|
||||
}
|
||||
await message_log_dal.create_message_log(
|
||||
session, log_payload_failed)
|
||||
session,
|
||||
{
|
||||
"user_id": admin_user.id,
|
||||
"telegram_username": admin_user.username,
|
||||
"telegram_first_name": admin_user.first_name,
|
||||
"event_type": "admin_broadcast_failed",
|
||||
"content": f"For user {uid}: {type(e).__name__} – {str(e)[:70]}...",
|
||||
"is_admin_event": True,
|
||||
"target_user_id": uid,
|
||||
},
|
||||
)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
try:
|
||||
@@ -192,17 +217,17 @@ async def confirm_broadcast_callback_handler(callback: types.CallbackQuery,
|
||||
await session.rollback()
|
||||
logging.error(f"Error committing broadcast logs: {e_commit}")
|
||||
|
||||
result_message = _("admin_broadcast_finished_stats",
|
||||
sent_count=sent_count,
|
||||
failed_count=failed_count)
|
||||
result_message = _("admin_broadcast_finished_stats", sent_count=sent_count, failed_count=failed_count)
|
||||
await callback.message.answer(
|
||||
result_message,
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
)
|
||||
|
||||
elif action == "cancel":
|
||||
await callback.message.edit_text(
|
||||
_("admin_broadcast_cancelled"),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
await state.clear()
|
||||
|
||||
@@ -24,6 +24,7 @@ router = Router(name="admin_common_router")
|
||||
@router.message(Command("admin"))
|
||||
async def admin_panel_command_handler(
|
||||
message: types.Message,
|
||||
state: FSMContext,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
):
|
||||
@@ -34,6 +35,7 @@ async def admin_panel_command_handler(
|
||||
await message.answer("Language service error.")
|
||||
return
|
||||
|
||||
await state.clear()
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
await message.answer(_(key="admin_panel_title"),
|
||||
reply_markup=get_admin_panel_keyboard(
|
||||
@@ -73,6 +75,9 @@ async def admin_panel_actions_callback_handler(
|
||||
elif action == "create_promo":
|
||||
await admin_promo_handlers.create_promo_prompt_handler(
|
||||
callback, state, i18n_data, settings, session)
|
||||
elif action == "manage_promos":
|
||||
await admin_promo_handlers.manage_promo_codes_handler(
|
||||
callback, i18n_data, settings, session)
|
||||
elif action == "view_promos":
|
||||
await admin_promo_handlers.view_promo_codes_handler(
|
||||
callback, i18n_data, settings, session)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.filters import StateFilter
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
@@ -11,6 +12,7 @@ from db.dal import promo_code_dal
|
||||
|
||||
from bot.states.admin_states import AdminStates
|
||||
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard, get_admin_panel_keyboard
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder, InlineKeyboardButton
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
router = Router(name="admin_promo_codes_router")
|
||||
@@ -194,8 +196,152 @@ async def view_promo_codes_handler(callback: types.CallbackQuery,
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "admin_action:main",
|
||||
AdminStates.waiting_for_promo_details)
|
||||
@router.callback_query(F.data == "admin_action:manage_promos")
|
||||
async def manage_promo_codes_handler(callback: types.CallbackQuery,
|
||||
i18n_data: dict, settings: Settings,
|
||||
session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error displaying promo codes.", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
promo_models = await promo_code_dal.get_all_active_promo_codes(session,
|
||||
limit=20,
|
||||
offset=0)
|
||||
if not promo_models:
|
||||
await callback.message.edit_text(
|
||||
_("admin_no_active_promos"),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
kb = InlineKeyboardBuilder()
|
||||
for promo in promo_models:
|
||||
kb.row(
|
||||
InlineKeyboardButton(
|
||||
text=promo.code,
|
||||
callback_data=f"promo_edit:{promo.promo_code_id}"),
|
||||
InlineKeyboardButton(
|
||||
text=_("admin_promo_delete_button"),
|
||||
callback_data=f"promo_delete:{promo.promo_code_id}"),
|
||||
)
|
||||
kb.row(
|
||||
InlineKeyboardButton(text=_("back_to_admin_panel_button"),
|
||||
callback_data="admin_action:main"))
|
||||
|
||||
await callback.message.edit_text(
|
||||
_("admin_manage_promos_title"),
|
||||
reply_markup=kb.as_markup())
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("promo_edit:"))
|
||||
async def promo_edit_select_handler(callback: types.CallbackQuery, state: FSMContext,
|
||||
i18n_data: dict, settings: Settings,
|
||||
session: AsyncSession):
|
||||
promo_id = int(callback.data.split(":")[1])
|
||||
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not promo or not callback.message:
|
||||
await callback.answer("Error", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
await state.update_data(edit_promo_id=promo_id)
|
||||
await state.set_state(AdminStates.waiting_for_promo_edit_details)
|
||||
await callback.message.edit_text(
|
||||
_("admin_promo_edit_prompt", code=promo.code),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML")
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.message(AdminStates.waiting_for_promo_edit_details, F.text)
|
||||
async def process_promo_edit_details(message: types.Message, state: FSMContext,
|
||||
i18n_data: dict, settings: Settings,
|
||||
session: AsyncSession):
|
||||
data = await state.get_data()
|
||||
promo_id = data.get("edit_promo_id")
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not promo_id:
|
||||
await message.answer("Error")
|
||||
await state.clear()
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
parts = message.text.strip().split()
|
||||
if not (3 <= len(parts) <= 4):
|
||||
await message.answer(_("admin_promo_invalid_format"))
|
||||
return
|
||||
try:
|
||||
code_str = parts[0].upper()
|
||||
bonus = int(parts[1])
|
||||
max_act = int(parts[2])
|
||||
valid_until = None
|
||||
if len(parts) == 4:
|
||||
days = int(parts[3])
|
||||
valid_until = datetime.now(timezone.utc) + timedelta(days=days)
|
||||
except Exception:
|
||||
await message.answer(_("admin_promo_invalid_format_general"))
|
||||
return
|
||||
|
||||
update_data = {
|
||||
"code": code_str,
|
||||
"bonus_days": bonus,
|
||||
"max_activations": max_act,
|
||||
"valid_until": valid_until,
|
||||
}
|
||||
updated = await promo_code_dal.update_promo_code(session, promo_id, update_data)
|
||||
if updated:
|
||||
await session.commit()
|
||||
await message.answer(
|
||||
_("admin_promo_updated_success", code=code_str),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML",
|
||||
)
|
||||
else:
|
||||
await session.rollback()
|
||||
await message.answer(_("admin_promo_not_found"))
|
||||
await state.clear()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("promo_delete:"))
|
||||
async def promo_delete_handler(callback: types.CallbackQuery, i18n_data: dict,
|
||||
settings: Settings, session: AsyncSession):
|
||||
promo_id = int(callback.data.split(":")[1])
|
||||
promo = await promo_code_dal.delete_promo_code(session, promo_id)
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer("Error", show_alert=True)
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
if promo:
|
||||
await session.commit()
|
||||
await callback.message.edit_text(
|
||||
_("admin_promo_deleted_success", code=promo.code),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
|
||||
parse_mode="HTML")
|
||||
else:
|
||||
await session.rollback()
|
||||
await callback.message.edit_text(
|
||||
_("admin_promo_not_found"),
|
||||
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n))
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
F.data == "admin_action:main",
|
||||
StateFilter(
|
||||
AdminStates.waiting_for_promo_details,
|
||||
AdminStates.waiting_for_promo_edit_details,
|
||||
),
|
||||
)
|
||||
async def cancel_promo_creation_state_to_menu(callback: types.CallbackQuery,
|
||||
state: FSMContext,
|
||||
settings: Settings,
|
||||
|
||||
+142
-102
@@ -16,11 +16,14 @@ router = Router(name="admin_sync_router")
|
||||
|
||||
|
||||
@router.message(Command("sync"))
|
||||
async def sync_command_handler(message_event: Union[types.Message,
|
||||
types.CallbackQuery],
|
||||
bot: Bot, settings: Settings, i18n_data: dict,
|
||||
panel_service: PanelApiService,
|
||||
session: AsyncSession):
|
||||
async def sync_command_handler(
|
||||
message_event: Union[types.Message, types.CallbackQuery],
|
||||
bot: Bot,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
panel_service: PanelApiService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
@@ -33,14 +36,15 @@ async def sync_command_handler(message_event: Union[types.Message,
|
||||
return
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||
|
||||
target_chat_id = message_event.chat.id if isinstance(
|
||||
message_event, types.Message) else (
|
||||
message_event.message.chat.id if message_event.message else None)
|
||||
target_chat_id = (
|
||||
message_event.chat.id
|
||||
if isinstance(message_event, types.Message)
|
||||
else (message_event.message.chat.id if message_event.message else None)
|
||||
)
|
||||
if not target_chat_id:
|
||||
logging.error("Sync handler: could not determine target_chat_id.")
|
||||
if isinstance(message_event, types.CallbackQuery):
|
||||
await message_event.answer("Error initiating sync.",
|
||||
show_alert=True)
|
||||
await message_event.answer("Error initiating sync.", show_alert=True)
|
||||
return
|
||||
|
||||
if isinstance(message_event, types.Message):
|
||||
@@ -59,21 +63,21 @@ async def sync_command_handler(message_event: Union[types.Message,
|
||||
if panel_users_data is None:
|
||||
error_msg = "Failed to fetch users from panel or panel API issue."
|
||||
sync_errors.append(error_msg)
|
||||
await panel_sync_dal.update_panel_sync_status(
|
||||
session, "failed", error_msg)
|
||||
await panel_sync_dal.update_panel_sync_status(session, "failed", error_msg)
|
||||
await session.commit()
|
||||
await bot.send_message(target_chat_id,
|
||||
_("sync_failed", details=error_msg))
|
||||
await bot.send_message(target_chat_id, _("sync_failed", details=error_msg))
|
||||
return
|
||||
|
||||
if not panel_users_data:
|
||||
status_msg = "No users found in the panel to sync."
|
||||
await panel_sync_dal.update_panel_sync_status(
|
||||
session, "success", status_msg, 0, 0)
|
||||
session, "success", status_msg, 0, 0
|
||||
)
|
||||
await session.commit()
|
||||
await bot.send_message(
|
||||
target_chat_id,
|
||||
_("sync_completed", status="Success", details=status_msg))
|
||||
_("sync_completed", status="Success", details=status_msg),
|
||||
)
|
||||
return
|
||||
|
||||
total_panel_users = len(panel_users_data)
|
||||
@@ -81,9 +85,9 @@ async def sync_command_handler(message_event: Union[types.Message,
|
||||
|
||||
for panel_user_dict in panel_users_data:
|
||||
users_processed_count += 1
|
||||
panel_uuid = panel_user_dict.get('uuid')
|
||||
telegram_id_from_panel_str = panel_user_dict.get('telegramId')
|
||||
panel_username = panel_user_dict.get('username')
|
||||
panel_uuid = panel_user_dict.get("uuid")
|
||||
telegram_id_from_panel_str = panel_user_dict.get("telegramId")
|
||||
panel_username = panel_user_dict.get("username")
|
||||
|
||||
if not panel_uuid:
|
||||
logging.warning(
|
||||
@@ -111,26 +115,22 @@ async def sync_command_handler(message_event: Union[types.Message,
|
||||
|
||||
continue
|
||||
|
||||
bot_user = await user_dal.get_user_by_id(session,
|
||||
telegram_id_from_panel)
|
||||
bot_user = await user_dal.get_user_by_id(session, telegram_id_from_panel)
|
||||
if not bot_user:
|
||||
user_data_to_create = {
|
||||
"user_id":
|
||||
telegram_id_from_panel,
|
||||
"username":
|
||||
panel_username,
|
||||
"panel_user_uuid":
|
||||
panel_uuid,
|
||||
"language_code":
|
||||
settings.DEFAULT_LANGUAGE,
|
||||
"registration_date":
|
||||
datetime.fromisoformat(
|
||||
panel_user_dict['createdAt'].replace("Z", "+00:00"))
|
||||
if panel_user_dict.get('createdAt') else datetime.now(
|
||||
timezone.utc)
|
||||
"user_id": telegram_id_from_panel,
|
||||
"username": panel_username,
|
||||
"panel_user_uuid": panel_uuid,
|
||||
"language_code": settings.DEFAULT_LANGUAGE,
|
||||
"registration_date": (
|
||||
datetime.fromisoformat(
|
||||
panel_user_dict["createdAt"].replace("Z", "+00:00")
|
||||
)
|
||||
if panel_user_dict.get("createdAt")
|
||||
else datetime.now(timezone.utc)
|
||||
),
|
||||
}
|
||||
bot_user = await user_dal.create_user(session,
|
||||
user_data_to_create)
|
||||
bot_user = await user_dal.create_user(session, user_data_to_create)
|
||||
logging.info(
|
||||
f"Sync: Created new local user {telegram_id_from_panel} from panel data {panel_uuid}."
|
||||
)
|
||||
@@ -142,8 +142,12 @@ async def sync_command_handler(message_event: Union[types.Message,
|
||||
)
|
||||
|
||||
conflicting_user = await user_dal.get_user_by_panel_uuid(
|
||||
session, panel_uuid)
|
||||
if conflicting_user and conflicting_user.user_id != telegram_id_from_panel:
|
||||
session, panel_uuid
|
||||
)
|
||||
if (
|
||||
conflicting_user
|
||||
and conflicting_user.user_id != telegram_id_from_panel
|
||||
):
|
||||
sync_errors.append(
|
||||
f"Panel UUID {panel_uuid} for TG {telegram_id_from_panel} already linked to another TG user {conflicting_user.user_id}."
|
||||
)
|
||||
@@ -151,60 +155,64 @@ async def sync_command_handler(message_event: Union[types.Message,
|
||||
continue
|
||||
|
||||
await user_dal.update_user(
|
||||
session, telegram_id_from_panel, {
|
||||
"panel_user_uuid": panel_uuid,
|
||||
"username": panel_username
|
||||
})
|
||||
session,
|
||||
telegram_id_from_panel,
|
||||
{"panel_user_uuid": panel_uuid, "username": panel_username},
|
||||
)
|
||||
logging.info(
|
||||
f"Sync: Updated panel_uuid for local user {telegram_id_from_panel} to {panel_uuid}."
|
||||
)
|
||||
|
||||
panel_sub_link_id = panel_user_dict.get(
|
||||
'subscriptionUuid') or panel_user_dict.get('shortUuid')
|
||||
"subscriptionUuid"
|
||||
) or panel_user_dict.get("shortUuid")
|
||||
if panel_sub_link_id:
|
||||
end_date_str = panel_user_dict.get('expireAt')
|
||||
start_date_str = panel_user_dict.get('createdAt')
|
||||
end_date_str = panel_user_dict.get("expireAt")
|
||||
start_date_str = panel_user_dict.get("createdAt")
|
||||
|
||||
if end_date_str:
|
||||
try:
|
||||
end_date_obj = datetime.fromisoformat(
|
||||
end_date_str.replace("Z", "+00:00"))
|
||||
start_date_obj = datetime.fromisoformat(
|
||||
start_date_str.replace(
|
||||
"Z",
|
||||
"+00:00")) if start_date_str else datetime.now(
|
||||
timezone.utc)
|
||||
end_date_str.replace("Z", "+00:00")
|
||||
)
|
||||
start_date_obj = (
|
||||
datetime.fromisoformat(
|
||||
start_date_str.replace("Z", "+00:00")
|
||||
)
|
||||
if start_date_str
|
||||
else datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
status_from_panel = panel_user_dict.get(
|
||||
'status', 'UNKNOWN').upper()
|
||||
is_active_flag = 1 if status_from_panel == 'ACTIVE' and end_date_obj > datetime.now(
|
||||
timezone.utc) else 0
|
||||
"status", "UNKNOWN"
|
||||
).upper()
|
||||
is_active_flag = (
|
||||
1
|
||||
if status_from_panel == "ACTIVE"
|
||||
and end_date_obj > datetime.now(timezone.utc)
|
||||
else 0
|
||||
)
|
||||
|
||||
sub_payload = {
|
||||
"user_id":
|
||||
telegram_id_from_panel,
|
||||
"panel_user_uuid":
|
||||
panel_uuid,
|
||||
"panel_subscription_uuid":
|
||||
panel_sub_link_id,
|
||||
"start_date":
|
||||
start_date_obj,
|
||||
"end_date":
|
||||
end_date_obj,
|
||||
"is_active":
|
||||
is_active_flag,
|
||||
"status_from_panel":
|
||||
status_from_panel,
|
||||
"traffic_limit_bytes":
|
||||
panel_user_dict.get('trafficLimitBytes'),
|
||||
"traffic_used_bytes":
|
||||
panel_user_dict.get('usedTrafficBytes')
|
||||
"user_id": telegram_id_from_panel,
|
||||
"panel_user_uuid": panel_uuid,
|
||||
"panel_subscription_uuid": panel_sub_link_id,
|
||||
"start_date": start_date_obj,
|
||||
"end_date": end_date_obj,
|
||||
"is_active": is_active_flag,
|
||||
"status_from_panel": status_from_panel,
|
||||
"traffic_limit_bytes": panel_user_dict.get(
|
||||
"trafficLimitBytes"
|
||||
),
|
||||
"traffic_used_bytes": panel_user_dict.get(
|
||||
"usedTrafficBytes"
|
||||
),
|
||||
}
|
||||
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, panel_uuid, panel_sub_link_id)
|
||||
await subscription_dal.upsert_subscription(
|
||||
session, sub_payload)
|
||||
session, panel_uuid, panel_sub_link_id
|
||||
)
|
||||
await subscription_dal.upsert_subscription(session, sub_payload)
|
||||
subscriptions_synced_count += 1
|
||||
users_synced_successfully += 1
|
||||
except ValueError as e_date:
|
||||
@@ -217,7 +225,8 @@ async def sync_command_handler(message_event: Union[types.Message,
|
||||
except Exception as e_sub_sync:
|
||||
logging.error(
|
||||
f"Sync: Error syncing subscription for panel user {panel_uuid} (TG ID: {telegram_id_from_panel}): {e_sub_sync}",
|
||||
exc_info=True)
|
||||
exc_info=True,
|
||||
)
|
||||
sync_errors.append(
|
||||
f"Sub sync error for panel user {panel_uuid} (TG ID: {telegram_id_from_panel})."
|
||||
)
|
||||
@@ -228,7 +237,8 @@ async def sync_command_handler(message_event: Union[types.Message,
|
||||
else:
|
||||
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, panel_uuid, None)
|
||||
session, panel_uuid, None
|
||||
)
|
||||
logging.info(
|
||||
f"Sync: Panel user {panel_uuid} (TG ID: {telegram_id_from_panel}) has no subscription link on panel. Deactivated local subs if any."
|
||||
)
|
||||
@@ -239,6 +249,17 @@ async def sync_command_handler(message_event: Union[types.Message,
|
||||
f"Sync progress: {users_processed_count}/{total_panel_users} users processed from panel."
|
||||
)
|
||||
|
||||
panel_uuid_set = {u.get("uuid") for u in panel_users_data if u.get("uuid")}
|
||||
local_users_with_uuid = await user_dal.get_all_users_with_panel_uuid(session)
|
||||
for local_user in local_users_with_uuid:
|
||||
if local_user.panel_user_uuid not in panel_uuid_set:
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, local_user.panel_user_uuid, None
|
||||
)
|
||||
logging.info(
|
||||
f"Sync: Local user {local_user.user_id} with panel UUID {local_user.panel_user_uuid} not found on panel. Deactivated local subs."
|
||||
)
|
||||
|
||||
status_msg_key = "sync_completed_details"
|
||||
final_status_type = "success"
|
||||
|
||||
@@ -251,36 +272,51 @@ async def sync_command_handler(message_event: Union[types.Message,
|
||||
details_for_db = f"Successfully processed {users_processed_count} users. Synced {subscriptions_synced_count} subscriptions."
|
||||
|
||||
await panel_sync_dal.update_panel_sync_status(
|
||||
session, final_status_type, details_for_db, users_processed_count,
|
||||
subscriptions_synced_count)
|
||||
session,
|
||||
final_status_type,
|
||||
details_for_db,
|
||||
users_processed_count,
|
||||
subscriptions_synced_count,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
final_user_message = _(status_msg_key,
|
||||
total_checked=total_panel_users,
|
||||
users_synced=users_synced_successfully,
|
||||
subs_synced=subscriptions_synced_count,
|
||||
errors_count=len(sync_errors),
|
||||
error_details_preview=error_preview
|
||||
if sync_errors else _("no_errors_placeholder"))
|
||||
final_user_message = _(
|
||||
status_msg_key,
|
||||
total_checked=total_panel_users,
|
||||
users_synced=users_synced_successfully,
|
||||
subs_synced=subscriptions_synced_count,
|
||||
errors_count=len(sync_errors),
|
||||
error_details_preview=(
|
||||
error_preview if sync_errors else _("no_errors_placeholder")
|
||||
),
|
||||
)
|
||||
await bot.send_message(target_chat_id, final_user_message)
|
||||
|
||||
except Exception as e_sync_global:
|
||||
await session.rollback()
|
||||
logging.error(f"Global error during /sync command: {e_sync_global}",
|
||||
exc_info=True)
|
||||
error_detail_for_db = f"An unexpected error occurred during sync: {str(e_sync_global)[:200]}"
|
||||
logging.error(
|
||||
f"Global error during /sync command: {e_sync_global}", exc_info=True
|
||||
)
|
||||
error_detail_for_db = (
|
||||
f"An unexpected error occurred during sync: {str(e_sync_global)[:200]}"
|
||||
)
|
||||
await panel_sync_dal.update_panel_sync_status(
|
||||
session, "failed", error_detail_for_db, users_processed_count,
|
||||
subscriptions_synced_count)
|
||||
session,
|
||||
"failed",
|
||||
error_detail_for_db,
|
||||
users_processed_count,
|
||||
subscriptions_synced_count,
|
||||
)
|
||||
|
||||
await bot.send_message(target_chat_id,
|
||||
_("sync_failed", details=error_detail_for_db))
|
||||
await bot.send_message(
|
||||
target_chat_id, _("sync_failed", details=error_detail_for_db)
|
||||
)
|
||||
|
||||
|
||||
@router.message(Command("syncstatus"))
|
||||
async def sync_status_command_handler(message: types.Message, i18n_data: dict,
|
||||
settings: Settings,
|
||||
session: AsyncSession):
|
||||
async def sync_status_command_handler(
|
||||
message: types.Message, i18n_data: dict, settings: Settings, session: AsyncSession
|
||||
):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
if not i18n:
|
||||
@@ -292,13 +328,16 @@ async def sync_status_command_handler(message: types.Message, i18n_data: dict,
|
||||
response_text = ""
|
||||
if status_record_model:
|
||||
last_time_val = status_record_model.last_sync_time
|
||||
last_time_str = last_time_val.strftime(
|
||||
'%Y-%m-%d %H:%M:%S UTC') if last_time_val else "N/A"
|
||||
last_time_str = (
|
||||
last_time_val.strftime("%Y-%m-%d %H:%M:%S UTC") if last_time_val else "N/A"
|
||||
)
|
||||
|
||||
details_val = status_record_model.details
|
||||
details_str = (details_val[:200] +
|
||||
"...") if details_val and len(details_val) > 200 else (
|
||||
details_val or "N/A")
|
||||
details_str = (
|
||||
(details_val[:200] + "...")
|
||||
if details_val and len(details_val) > 200
|
||||
else (details_val or "N/A")
|
||||
)
|
||||
|
||||
response_text = (
|
||||
f"<b>{_('admin_stats_last_sync_header')}</b>\n"
|
||||
@@ -306,7 +345,8 @@ async def sync_status_command_handler(message: types.Message, i18n_data: dict,
|
||||
f" {_('admin_stats_sync_status')}: {status_record_model.status}\n"
|
||||
f" {_('admin_stats_sync_users_processed')}: {status_record_model.users_processed_from_panel}\n"
|
||||
f" {_('admin_stats_sync_subs_synced')}: {status_record_model.subscriptions_synced}\n"
|
||||
f" {_('admin_stats_sync_details_label')}: {details_str}")
|
||||
f" {_('admin_stats_sync_details_label')}: {details_str}"
|
||||
)
|
||||
else:
|
||||
response_text = _("admin_sync_status_never_run")
|
||||
|
||||
|
||||
@@ -28,20 +28,16 @@ USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
|
||||
|
||||
async def _get_user_model_by_input(session: AsyncSession,
|
||||
input_text: str) -> Optional[User]:
|
||||
|
||||
user_model: Optional[User] = None
|
||||
if input_text.isdigit():
|
||||
try:
|
||||
user_model = await user_dal.get_user_by_id(session,
|
||||
int(input_text))
|
||||
return await user_dal.get_user(session, user_id=int(input_text))
|
||||
except ValueError:
|
||||
pass
|
||||
elif input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
|
||||
user_model = await user_dal.get_user_by_username(
|
||||
session, input_text[1:])
|
||||
elif USERNAME_REGEX.match(input_text):
|
||||
user_model = await user_dal.get_user_by_username(session, input_text)
|
||||
return user_model
|
||||
return None
|
||||
if input_text.startswith("@") and USERNAME_REGEX.match(input_text[1:]):
|
||||
return await user_dal.get_user(session, username=input_text[1:])
|
||||
if USERNAME_REGEX.match(input_text):
|
||||
return await user_dal.get_user(session, username=input_text)
|
||||
return None
|
||||
|
||||
|
||||
async def ban_user_prompt_handler(callback: types.CallbackQuery,
|
||||
|
||||
@@ -17,7 +17,7 @@ from db.dal import payment_dal, user_dal
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.payment_service import YooKassaService
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from config.settings import Settings
|
||||
|
||||
@@ -105,7 +105,8 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
||||
subscription_months,
|
||||
payment_value,
|
||||
payment_db_id,
|
||||
promo_code_id_from_payment=promo_code_id)
|
||||
promo_code_id_from_payment=promo_code_id,
|
||||
provider="yookassa")
|
||||
|
||||
if not activation_details or not activation_details.get('end_date'):
|
||||
logging.error(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import logging
|
||||
from aiogram import Router, F, types, Bot
|
||||
from aiogram.filters import Command
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, LabeledPrice
|
||||
from typing import Optional, Dict, Any, Union
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -8,11 +9,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_subscription_options_keyboard, get_confirm_subscription_keyboard,
|
||||
get_subscription_options_keyboard, get_payment_method_keyboard,
|
||||
get_payment_url_keyboard, get_back_to_main_menu_markup)
|
||||
from bot.services.payment_service import YooKassaService
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from bot.services.stars_service import StarsService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
router = Router(name="user_subscription_router")
|
||||
@@ -98,28 +101,70 @@ async def select_subscription_period_callback_handler(
|
||||
return
|
||||
|
||||
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
|
||||
confirmation_text_content = get_text("confirm_subscription_prompt",
|
||||
months=months,
|
||||
price=f"{price_rub:.2f}",
|
||||
currency_symbol=currency_symbol_val)
|
||||
reply_markup = get_confirm_subscription_keyboard(months, price_rub,
|
||||
currency_symbol_val,
|
||||
current_lang, i18n)
|
||||
text_content = get_text("choose_payment_method")
|
||||
tribute_url = settings.tribute_payment_links.get(months)
|
||||
stars_price = settings.stars_subscription_options.get(months)
|
||||
reply_markup = get_payment_method_keyboard(
|
||||
months,
|
||||
price_rub,
|
||||
tribute_url,
|
||||
stars_price,
|
||||
currency_symbol_val,
|
||||
current_lang,
|
||||
i18n,
|
||||
settings,
|
||||
)
|
||||
|
||||
try:
|
||||
await callback.message.edit_text(confirmation_text_content,
|
||||
await callback.message.edit_text(text_content,
|
||||
reply_markup=reply_markup)
|
||||
except Exception as e_edit:
|
||||
logging.warning(
|
||||
f"Edit message for subscription confirmation failed: {e_edit}. Sending new one."
|
||||
f"Edit message for payment method selection failed: {e_edit}. Sending new one."
|
||||
)
|
||||
await callback.message.answer(confirmation_text_content,
|
||||
await callback.message.answer(text_content,
|
||||
reply_markup=reply_markup)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("confirm_sub:"))
|
||||
async def confirm_subscription_callback_handler(
|
||||
@router.callback_query(F.data.startswith("pay_stars:"))
|
||||
async def pay_stars_callback_handler(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
|
||||
session: AsyncSession, bot: Bot, stars_service: StarsService):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
_, data_payload = callback.data.split(":", 1)
|
||||
months_str, price_str = data_payload.split(":")
|
||||
months = int(months_str)
|
||||
stars_price = int(price_str)
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid pay_stars data in callback: {callback.data}")
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
user_id = callback.from_user.id
|
||||
payment_description = get_text("payment_description_subscription", months=months)
|
||||
|
||||
payment_id = await stars_service.create_invoice(
|
||||
session, user_id, months, stars_price, payment_description)
|
||||
if payment_id is None:
|
||||
await callback.message.edit_text(get_text("error_payment_gateway"))
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("pay_yk:"))
|
||||
async def pay_yk_callback_handler(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
|
||||
yookassa_service: YooKassaService, session: AsyncSession):
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
@@ -150,7 +195,7 @@ async def confirm_subscription_callback_handler(
|
||||
price_rub = float(price_str)
|
||||
except (ValueError, IndexError):
|
||||
logging.error(
|
||||
f"Invalid confirmation data in callback: {callback.data}")
|
||||
f"Invalid pay_yk data in callback: {callback.data}")
|
||||
await callback.answer(get_text("error_try_again"), show_alert=True)
|
||||
return
|
||||
|
||||
@@ -257,97 +302,115 @@ async def reshow_subscription_options_callback(callback: types.CallbackQuery,
|
||||
|
||||
|
||||
async def my_subscription_command_handler(
|
||||
event: Union[types.Message, types.CallbackQuery], i18n_data: dict,
|
||||
settings: Settings, panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService, session: AsyncSession,
|
||||
bot: Bot):
|
||||
target_message_obj = event.message if isinstance(
|
||||
event, types.CallbackQuery) else event
|
||||
user = event.from_user
|
||||
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
await event.answer()
|
||||
|
||||
event: Union[types.Message, types.CallbackQuery],
|
||||
i18n_data: dict,
|
||||
settings: Settings,
|
||||
panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
bot: Bot
|
||||
):
|
||||
target = event.message if isinstance(event, types.CallbackQuery) else event
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
|
||||
) if i18n else key
|
||||
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||
get_text = lambda key, **kw: i18n.gettext(current_lang, key, **kw)
|
||||
|
||||
if not i18n or not target_message_obj:
|
||||
if not i18n or not target:
|
||||
if isinstance(event, types.Message):
|
||||
await event.answer(get_text("error_occurred_try_again"))
|
||||
return
|
||||
|
||||
if not panel_service or not subscription_service:
|
||||
logging.error(
|
||||
"PanelService or SubscriptionService is missing in my_subscription_command_handler."
|
||||
)
|
||||
await target_message_obj.answer(get_text("error_service_unavailable"))
|
||||
await target.answer(get_text("error_service_unavailable"))
|
||||
return
|
||||
|
||||
active_sub_details = await subscription_service.get_active_subscription_details(
|
||||
session, user.id)
|
||||
active = await subscription_service.get_active_subscription_details(session, event.from_user.id)
|
||||
|
||||
sub_info_text_content = ""
|
||||
if active_sub_details:
|
||||
end_date_obj = active_sub_details.get('end_date')
|
||||
days_left = 0
|
||||
if end_date_obj:
|
||||
if end_date_obj.tzinfo is None:
|
||||
end_date_obj = end_date_obj.replace(tzinfo=timezone.utc)
|
||||
days_left = (end_date_obj.date() - datetime.now().date()).days
|
||||
if not active:
|
||||
text = get_text("subscription_not_active")
|
||||
|
||||
actual_config_link = active_sub_details.get('config_link') or get_text(
|
||||
"config_link_not_available")
|
||||
buy_button = InlineKeyboardButton(
|
||||
text=get_text("menu_subscribe_inline", default="Купить"),
|
||||
callback_data="main_action:subscribe"
|
||||
)
|
||||
back_markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
|
||||
traffic_limit_bytes = active_sub_details.get('traffic_limit_bytes')
|
||||
traffic_used_bytes = active_sub_details.get('traffic_used_bytes')
|
||||
kb = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[buy_button],
|
||||
*back_markup.inline_keyboard
|
||||
]
|
||||
)
|
||||
|
||||
traffic_limit_gb_str = get_text("traffic_unlimited")
|
||||
if traffic_limit_bytes and traffic_limit_bytes > 0:
|
||||
traffic_limit_gb_str = f"{traffic_limit_bytes / (1024**3):.2f} GB"
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
await event.answer()
|
||||
try:
|
||||
await event.message.edit_text(text, reply_markup=kb)
|
||||
except:
|
||||
await event.message.answer(text, reply_markup=kb)
|
||||
else:
|
||||
await event.answer(text, reply_markup=kb)
|
||||
return
|
||||
|
||||
traffic_used_gb_str = get_text("traffic_na")
|
||||
if traffic_used_bytes is not None:
|
||||
traffic_used_gb_str = f"{traffic_used_bytes / (1024**3):.2f} GB"
|
||||
end_date = active.get("end_date")
|
||||
days_left = (
|
||||
(end_date.date() - datetime.now().date()).days
|
||||
if end_date else 0
|
||||
)
|
||||
text = get_text(
|
||||
"my_subscription_details",
|
||||
end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A",
|
||||
days_left=max(0, days_left),
|
||||
status=active.get("status_from_panel", get_text("status_active")).capitalize(),
|
||||
config_link=active.get("config_link") or get_text("config_link_not_available"),
|
||||
traffic_limit=(
|
||||
f"{active['traffic_limit_bytes'] / 2**30:.2f} GB"
|
||||
if active.get("traffic_limit_bytes")
|
||||
else get_text("traffic_unlimited")
|
||||
),
|
||||
traffic_used=(
|
||||
f"{active['traffic_used_bytes'] / 2**30:.2f} GB"
|
||||
if active.get("traffic_used_bytes") is not None
|
||||
else get_text("traffic_na")
|
||||
)
|
||||
)
|
||||
markup = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
|
||||
sub_info_text_content = get_text(
|
||||
"my_subscription_details",
|
||||
end_date=end_date_obj.strftime("%Y-%m-%d")
|
||||
if end_date_obj else "N/A",
|
||||
days_left=max(0, days_left),
|
||||
status=active_sub_details.get(
|
||||
'status_from_panel', get_text('status_active')).capitalize(),
|
||||
config_link=actual_config_link,
|
||||
traffic_limit=traffic_limit_gb_str,
|
||||
traffic_used=traffic_used_gb_str)
|
||||
else:
|
||||
sub_info_text_content = get_text("subscription_not_active")
|
||||
logging.info(
|
||||
f"User {user.id} no active sub details for 'my_subscription'.")
|
||||
|
||||
reply_markup_val = get_back_to_main_menu_markup(current_lang, i18n)
|
||||
|
||||
if isinstance(event, types.CallbackQuery) and event.message:
|
||||
if isinstance(event, types.CallbackQuery):
|
||||
await event.answer()
|
||||
try:
|
||||
await event.message.edit_text(sub_info_text_content,
|
||||
reply_markup=reply_markup_val,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True)
|
||||
except Exception as e_edit:
|
||||
logging.warning(
|
||||
f"Edit 'my_subscription' failed: {e_edit}. Sending new message to chat {target_message_obj.chat.id}."
|
||||
)
|
||||
await bot.send_message(chat_id=target_message_obj.chat.id,
|
||||
text=sub_info_text_content,
|
||||
reply_markup=reply_markup_val,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True)
|
||||
await event.message.edit_text(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||
except:
|
||||
await bot.send_message(chat_id=target.chat.id, text=text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||
else:
|
||||
await target_message_obj.answer(sub_info_text_content,
|
||||
reply_markup=reply_markup_val,
|
||||
parse_mode="HTML",
|
||||
disable_web_page_preview=True)
|
||||
await target.answer(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True)
|
||||
|
||||
|
||||
@router.pre_checkout_query()
|
||||
async def stars_pre_checkout_handler(pre_checkout_query: types.PreCheckoutQuery):
|
||||
await pre_checkout_query.answer(ok=True)
|
||||
|
||||
|
||||
@router.message(F.successful_payment)
|
||||
async def stars_successful_payment_handler(
|
||||
message: types.Message, settings: Settings, i18n_data: dict,
|
||||
session: AsyncSession, stars_service: StarsService):
|
||||
sp = message.successful_payment
|
||||
if not sp or sp.currency != "XTR":
|
||||
return
|
||||
|
||||
payload = sp.invoice_payload or ""
|
||||
try:
|
||||
payment_id_str, months_str = payload.split(":")
|
||||
payment_db_id = int(payment_id_str)
|
||||
months = int(months_str)
|
||||
except (ValueError, IndexError):
|
||||
logging.error(f"Invalid invoice payload for stars payment: {payload}")
|
||||
return
|
||||
|
||||
stars_amount = sp.total_amount
|
||||
await stars_service.process_successful_payment(
|
||||
session, message, payment_db_id, months, stars_amount, i18n_data)
|
||||
|
||||
|
||||
@router.message(Command("connect"))
|
||||
|
||||
@@ -7,7 +7,10 @@ from datetime import datetime
|
||||
from config.settings import Settings
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.keyboards.inline.user_keyboards import get_trial_confirmation_keyboard, get_main_menu_inline_keyboard
|
||||
from bot.keyboards.inline.user_keyboards import (
|
||||
get_trial_confirmation_keyboard,
|
||||
get_main_menu_inline_keyboard,
|
||||
)
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from .start import send_main_menu
|
||||
|
||||
@@ -15,13 +18,16 @@ router = Router(name="user_trial_router")
|
||||
|
||||
|
||||
async def request_trial_confirmation_handler(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
|
||||
subscription_service: SubscriptionService, session: AsyncSession):
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
user_id = callback.from_user.id
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
|
||||
) if i18n else key
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
@@ -29,50 +35,60 @@ async def request_trial_confirmation_handler(
|
||||
|
||||
show_trial_btn_in_menu_if_fail = False
|
||||
if settings.TRIAL_ENABLED:
|
||||
if not await subscription_service.has_had_any_subscription(
|
||||
session, user_id):
|
||||
if not await subscription_service.has_had_any_subscription(session, user_id):
|
||||
show_trial_btn_in_menu_if_fail = True
|
||||
|
||||
if not settings.TRIAL_ENABLED:
|
||||
await callback.message.edit_text(
|
||||
_("trial_feature_disabled"),
|
||||
reply_markup=get_main_menu_inline_keyboard(current_lang, i18n,
|
||||
settings, False))
|
||||
reply_markup=get_main_menu_inline_keyboard(
|
||||
current_lang, i18n, settings, False
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
if await subscription_service.has_had_any_subscription(session, user_id):
|
||||
await callback.message.edit_text(
|
||||
_("trial_already_had_subscription_or_trial"),
|
||||
reply_markup=get_main_menu_inline_keyboard(current_lang, i18n,
|
||||
settings, False))
|
||||
reply_markup=get_main_menu_inline_keyboard(
|
||||
current_lang, i18n, settings, False
|
||||
),
|
||||
)
|
||||
await callback.answer()
|
||||
return
|
||||
|
||||
traffic_gb_display = str(
|
||||
settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
) if settings.TRIAL_TRAFFIC_LIMIT_GB and settings.TRIAL_TRAFFIC_LIMIT_GB > 0 else _(
|
||||
"traffic_unlimited")
|
||||
traffic_gb_display = (
|
||||
str(settings.TRIAL_TRAFFIC_LIMIT_GB)
|
||||
if settings.TRIAL_TRAFFIC_LIMIT_GB and settings.TRIAL_TRAFFIC_LIMIT_GB > 0
|
||||
else _("traffic_unlimited")
|
||||
)
|
||||
|
||||
await callback.message.edit_text(
|
||||
text=_("trial_confirm_prompt",
|
||||
days=settings.TRIAL_DURATION_DAYS,
|
||||
traffic_gb=traffic_gb_display),
|
||||
reply_markup=get_trial_confirmation_keyboard(current_lang, i18n))
|
||||
text=_(
|
||||
"trial_confirm_prompt",
|
||||
days=settings.TRIAL_DURATION_DAYS,
|
||||
traffic_gb=traffic_gb_display,
|
||||
),
|
||||
reply_markup=get_trial_confirmation_keyboard(current_lang, i18n),
|
||||
)
|
||||
await callback.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "trial_action:confirm_activate")
|
||||
async def confirm_activate_trial_handler(
|
||||
callback: types.CallbackQuery, settings: Settings, i18n_data: dict,
|
||||
subscription_service: SubscriptionService,
|
||||
panel_service: PanelApiService, session: AsyncSession):
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
subscription_service: SubscriptionService,
|
||||
panel_service: PanelApiService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
user_id = callback.from_user.id
|
||||
|
||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs
|
||||
) if i18n else key
|
||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
||||
|
||||
if not i18n or not callback.message:
|
||||
await callback.answer(_("error_occurred_try_again"), show_alert=True)
|
||||
@@ -81,26 +97,22 @@ async def confirm_activate_trial_handler(
|
||||
if not settings.TRIAL_ENABLED:
|
||||
await callback.answer(_("trial_feature_disabled"), show_alert=True)
|
||||
|
||||
await send_main_menu(callback,
|
||||
settings,
|
||||
i18n_data,
|
||||
subscription_service,
|
||||
session,
|
||||
is_edit=True)
|
||||
await send_main_menu(
|
||||
callback, settings, i18n_data, subscription_service, session, is_edit=True
|
||||
)
|
||||
return
|
||||
if await subscription_service.has_had_any_subscription(session, user_id):
|
||||
await callback.answer(_("trial_already_had_subscription_or_trial"),
|
||||
show_alert=True)
|
||||
await send_main_menu(callback,
|
||||
settings,
|
||||
i18n_data,
|
||||
subscription_service,
|
||||
session,
|
||||
is_edit=True)
|
||||
await callback.answer(
|
||||
_("trial_already_had_subscription_or_trial"), show_alert=True
|
||||
)
|
||||
await send_main_menu(
|
||||
callback, settings, i18n_data, subscription_service, session, is_edit=True
|
||||
)
|
||||
return
|
||||
|
||||
activation_result = await subscription_service.activate_trial_subscription(
|
||||
session, user_id)
|
||||
session, user_id
|
||||
)
|
||||
|
||||
final_message_text_in_chat = ""
|
||||
show_trial_button_after_action = False
|
||||
@@ -110,35 +122,43 @@ async def confirm_activate_trial_handler(
|
||||
|
||||
end_date_obj = activation_result.get("end_date")
|
||||
config_link_for_trial = activation_result.get("subscription_url") or _(
|
||||
"config_link_not_available")
|
||||
"config_link_not_available"
|
||||
)
|
||||
|
||||
if config_link_for_trial == _(
|
||||
"config_link_not_available") and activation_result.get(
|
||||
"panel_short_uuid"):
|
||||
generated_link = await panel_service.get_subscription_link(
|
||||
activation_result["panel_short_uuid"])
|
||||
if generated_link: config_link_for_trial = generated_link
|
||||
|
||||
traffic_gb_val = activation_result.get("traffic_gb",
|
||||
settings.TRIAL_TRAFFIC_LIMIT_GB)
|
||||
traffic_display = f"{traffic_gb_val} GB" if traffic_gb_val and traffic_gb_val > 0 else _(
|
||||
"traffic_unlimited")
|
||||
traffic_gb_val = activation_result.get(
|
||||
"traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB
|
||||
)
|
||||
traffic_display = (
|
||||
f"{traffic_gb_val} GB"
|
||||
if traffic_gb_val and traffic_gb_val > 0
|
||||
else _("traffic_unlimited")
|
||||
)
|
||||
|
||||
final_message_text_in_chat = _(
|
||||
"trial_activated_details_message",
|
||||
days=activation_result.get("days", settings.TRIAL_DURATION_DAYS),
|
||||
end_date=end_date_obj.strftime('%Y-%m-%d') if isinstance(
|
||||
end_date_obj, datetime) else "N/A",
|
||||
end_date=(
|
||||
end_date_obj.strftime("%Y-%m-%d")
|
||||
if isinstance(end_date_obj, datetime)
|
||||
else "N/A"
|
||||
),
|
||||
config_link=config_link_for_trial,
|
||||
traffic_gb=traffic_display)
|
||||
traffic_gb=traffic_display,
|
||||
)
|
||||
else:
|
||||
message_key_from_service = activation_result.get(
|
||||
"message_key", "trial_activation_failed"
|
||||
) if activation_result else "trial_activation_failed"
|
||||
message_key_from_service = (
|
||||
activation_result.get("message_key", "trial_activation_failed")
|
||||
if activation_result
|
||||
else "trial_activation_failed"
|
||||
)
|
||||
final_message_text_in_chat = _(message_key_from_service)
|
||||
await callback.answer(final_message_text_in_chat, show_alert=True)
|
||||
if settings.TRIAL_ENABLED and not await subscription_service.has_had_any_subscription(
|
||||
session, user_id):
|
||||
if (
|
||||
settings.TRIAL_ENABLED
|
||||
and not await subscription_service.has_had_any_subscription(
|
||||
session, user_id
|
||||
)
|
||||
):
|
||||
show_trial_button_after_action = True
|
||||
|
||||
try:
|
||||
@@ -146,31 +166,38 @@ async def confirm_activate_trial_handler(
|
||||
final_message_text_in_chat,
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_main_menu_inline_keyboard(
|
||||
current_lang, i18n, settings, show_trial_button_after_action),
|
||||
disable_web_page_preview=True)
|
||||
current_lang, i18n, settings, show_trial_button_after_action
|
||||
),
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
except Exception as e_edit:
|
||||
logging.warning(
|
||||
f"Could not edit trial result message: {e_edit}. Sending new one.")
|
||||
f"Could not edit trial result message: {e_edit}. Sending new one."
|
||||
)
|
||||
|
||||
if callback.message and hasattr(callback.message,
|
||||
'chat') and callback.message.chat:
|
||||
if (
|
||||
callback.message
|
||||
and hasattr(callback.message, "chat")
|
||||
and callback.message.chat
|
||||
):
|
||||
await callback.message.chat.send_message(
|
||||
final_message_text_in_chat,
|
||||
parse_mode="HTML",
|
||||
reply_markup=get_main_menu_inline_keyboard(
|
||||
current_lang, i18n, settings,
|
||||
show_trial_button_after_action),
|
||||
disable_web_page_preview=True)
|
||||
current_lang, i18n, settings, show_trial_button_after_action
|
||||
),
|
||||
disable_web_page_preview=True,
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data == "main_action:cancel_trial")
|
||||
async def cancel_trial_activation(callback: types.CallbackQuery,
|
||||
settings: Settings, i18n_data: dict,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession):
|
||||
await send_main_menu(callback,
|
||||
settings,
|
||||
i18n_data,
|
||||
subscription_service,
|
||||
session,
|
||||
is_edit=True)
|
||||
async def cancel_trial_activation(
|
||||
callback: types.CallbackQuery,
|
||||
settings: Settings,
|
||||
i18n_data: dict,
|
||||
subscription_service: SubscriptionService,
|
||||
session: AsyncSession,
|
||||
):
|
||||
await send_main_menu(
|
||||
callback, settings, i18n_data, subscription_service, session, is_edit=True
|
||||
)
|
||||
|
||||
@@ -18,6 +18,8 @@ def get_admin_panel_keyboard(i18n_instance, lang: str,
|
||||
callback_data="admin_action:broadcast")
|
||||
builder.button(text=_(key="admin_create_promo_button"),
|
||||
callback_data="admin_action:create_promo")
|
||||
builder.button(text=_(key="admin_manage_promos_button"),
|
||||
callback_data="admin_action:manage_promos")
|
||||
builder.button(text=_(key="admin_view_promos_button"),
|
||||
callback_data="admin_action:view_promos")
|
||||
builder.button(text=_(key="admin_ban_user_button"),
|
||||
|
||||
@@ -108,16 +108,21 @@ def get_subscription_options_keyboard(subscription_options: Dict[
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
def get_confirm_subscription_keyboard(months: int, price: float,
|
||||
currency_symbol_val: str, lang: str,
|
||||
i18n_instance) -> InlineKeyboardMarkup:
|
||||
def get_payment_method_keyboard(months: int, price: float,
|
||||
tribute_url: Optional[str],
|
||||
stars_price: Optional[int],
|
||||
currency_symbol_val: str, lang: str,
|
||||
i18n_instance, settings: Settings) -> InlineKeyboardMarkup:
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
confirm_text = _(key="confirm_payment_button",
|
||||
price=price,
|
||||
currency_symbol=currency_symbol_val)
|
||||
builder.button(text=confirm_text,
|
||||
callback_data=f"confirm_sub:{months}:{price}")
|
||||
if settings.STARS_ENABLED and stars_price is not None:
|
||||
builder.button(text=_("pay_with_stars_button"),
|
||||
callback_data=f"pay_stars:{months}:{stars_price}")
|
||||
if settings.TRIBUTE_ENABLED and tribute_url:
|
||||
builder.button(text=_("pay_with_tribute_button"), url=tribute_url)
|
||||
if settings.YOOKASSA_ENABLED:
|
||||
builder.button(text=_("pay_with_yookassa_button"),
|
||||
callback_data=f"pay_yk:{months}:{price}")
|
||||
builder.button(text=_(key="cancel_button"),
|
||||
callback_data="main_action:subscribe")
|
||||
builder.adjust(1)
|
||||
@@ -159,5 +164,5 @@ def get_user_banned_keyboard(support_link: Optional[str], lang: str,
|
||||
return None
|
||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text=_(key="contact_support_button"), url=support_link)
|
||||
builder.button(text=_(key="menu_support_button"), url=support_link)
|
||||
return builder.as_markup()
|
||||
|
||||
+129
-83
@@ -26,11 +26,13 @@ from bot.handlers.admin import admin_router_aggregate
|
||||
from bot.filters.admin_filter import AdminFilter
|
||||
|
||||
from bot.services.notification_service import schedule_subscription_notifications
|
||||
from bot.services.payment_service import YooKassaService
|
||||
from bot.services.yookassa_service import YooKassaService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from bot.services.promo_code_service import PromoCodeService
|
||||
from bot.services.stars_service import StarsService
|
||||
from bot.services.tribute_service import TributeService, tribute_webhook_route
|
||||
|
||||
from bot.handlers.user import payment as user_payment_webhook_module
|
||||
|
||||
@@ -41,14 +43,17 @@ class DBSessionMiddleware(BaseMiddleware):
|
||||
super().__init__()
|
||||
self.async_session_factory = async_session_factory
|
||||
|
||||
async def __call__(self, handler: Callable[[Update, Dict[str, Any]],
|
||||
Awaitable[Any]], event: Update,
|
||||
data: Dict[str, Any]) -> Any:
|
||||
async def __call__(
|
||||
self,
|
||||
handler: Callable[[Update, Dict[str, Any]], Awaitable[Any]],
|
||||
event: Update,
|
||||
data: Dict[str, Any],
|
||||
) -> Any:
|
||||
if self.async_session_factory is None:
|
||||
logging.critical(
|
||||
"DBSessionMiddleware: async_session_factory is None!")
|
||||
logging.critical("DBSessionMiddleware: async_session_factory is None!")
|
||||
raise RuntimeError(
|
||||
"async_session_factory not provided to DBSessionMiddleware")
|
||||
"async_session_factory not provided to DBSessionMiddleware"
|
||||
)
|
||||
|
||||
async with self.async_session_factory() as session:
|
||||
data["session"] = session
|
||||
@@ -60,8 +65,8 @@ class DBSessionMiddleware(BaseMiddleware):
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
"DBSessionMiddleware: Exception caused rollback.",
|
||||
exc_info=True)
|
||||
"DBSessionMiddleware: Exception caused rollback.", exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
@@ -89,30 +94,33 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
async_session_factory: sessionmaker = dispatcher["async_session_factory"]
|
||||
|
||||
logging.info("STARTUP: on_startup_configured executing...")
|
||||
scheduler = AsyncIOScheduler(timezone="UTC")
|
||||
try:
|
||||
|
||||
await schedule_subscription_notifications(bot, settings, i18n_instance,
|
||||
scheduler, panel_service,
|
||||
async_session_factory)
|
||||
scheduler.start()
|
||||
dispatcher["scheduler"] = scheduler
|
||||
logging.info("STARTUP: APScheduler started.")
|
||||
except Exception as e:
|
||||
logging.error(f"STARTUP: Failed to start APScheduler: {e}",
|
||||
exc_info=True)
|
||||
existing_scheduler: Optional[AsyncIOScheduler] = dispatcher.get("scheduler")
|
||||
|
||||
telegram_webhook_url_to_set = getattr(settings,
|
||||
'TELEGRAM_WEBHOOK_BASE_URL', None)
|
||||
if telegram_webhook_url_to_set:
|
||||
if settings.BOT_TOKEN in telegram_webhook_url_to_set:
|
||||
logging.error(
|
||||
f"CRITICAL SECURITY RISK: Bot token detected in TELEGRAM_WEBHOOK_BASE_URL ('{telegram_webhook_url_to_set}'). "
|
||||
"This is a major security vulnerability. Webhook will NOT be set."
|
||||
if existing_scheduler and existing_scheduler.running:
|
||||
logging.warning("STARTUP: Scheduler already running, skipping initialization.")
|
||||
else:
|
||||
scheduler = AsyncIOScheduler(timezone="UTC")
|
||||
try:
|
||||
await schedule_subscription_notifications(
|
||||
bot,
|
||||
settings,
|
||||
i18n_instance,
|
||||
scheduler,
|
||||
panel_service,
|
||||
async_session_factory,
|
||||
)
|
||||
full_telegram_webhook_url = "ERROR_URL_TOKEN_DETECTED"
|
||||
else:
|
||||
full_telegram_webhook_url = f"{str(telegram_webhook_url_to_set).rstrip('/')}/{settings.BOT_TOKEN}"
|
||||
scheduler.start()
|
||||
dispatcher["scheduler"] = scheduler
|
||||
logging.info("STARTUP: APScheduler started.")
|
||||
except Exception as e:
|
||||
logging.error(f"STARTUP: Failed to start APScheduler: {e}", exc_info=True)
|
||||
|
||||
telegram_webhook_url_to_set = getattr(settings, "TELEGRAM_WEBHOOK_BASE_URL", None)
|
||||
if telegram_webhook_url_to_set:
|
||||
full_telegram_webhook_url = (
|
||||
f"{str(telegram_webhook_url_to_set).rstrip('/')}/{settings.BOT_TOKEN}"
|
||||
)
|
||||
|
||||
logging.info(
|
||||
f"STARTUP: Attempting to set Telegram webhook to: {full_telegram_webhook_url if full_telegram_webhook_url != 'ERROR_URL_TOKEN_DETECTED' else 'HIDDEN DUE TO TOKEN'}"
|
||||
@@ -128,7 +136,8 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
set_success = await bot.set_webhook(
|
||||
url=full_telegram_webhook_url,
|
||||
drop_pending_updates=True,
|
||||
allowed_updates=dispatcher.resolve_used_update_types())
|
||||
allowed_updates=dispatcher.resolve_used_update_types(),
|
||||
)
|
||||
if set_success:
|
||||
logging.info(
|
||||
f"STARTUP: bot.set_webhook to {full_telegram_webhook_url} returned SUCCESS (True)."
|
||||
@@ -150,7 +159,8 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
||||
except Exception as e_setwebhook:
|
||||
logging.error(
|
||||
f"STARTUP: EXCEPTION during set/get Telegram webhook: {e_setwebhook}",
|
||||
exc_info=True)
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
logging.error(
|
||||
"STARTUP: Skipped setting Telegram webhook due to security or configuration error."
|
||||
@@ -173,11 +183,12 @@ async def on_shutdown_configured(dispatcher: Dispatcher):
|
||||
scheduler.shutdown(wait=False)
|
||||
logging.info("SHUTDOWN: APScheduler shut down.")
|
||||
except Exception as e:
|
||||
logging.error(f"SHUTDOWN: Error shutting down APScheduler: {e}",
|
||||
exc_info=True)
|
||||
logging.error(
|
||||
f"SHUTDOWN: Error shutting down APScheduler: {e}", exc_info=True
|
||||
)
|
||||
|
||||
panel_service: Optional[PanelApiService] = dispatcher.get("panel_service")
|
||||
if panel_service and hasattr(panel_service, 'close_session'):
|
||||
if panel_service and hasattr(panel_service, "close_session"):
|
||||
await panel_service.close_session()
|
||||
logging.info("Panel API service session closed on shutdown.")
|
||||
|
||||
@@ -190,6 +201,7 @@ async def on_shutdown_configured(dispatcher: Dispatcher):
|
||||
logging.warning(f"SHUTDOWN: Failed to close bot session: {e}")
|
||||
|
||||
from db.database_setup import async_engine as global_async_engine
|
||||
|
||||
if global_async_engine:
|
||||
logging.info("SHUTDOWN: Disposing SQLAlchemy engine...")
|
||||
await global_async_engine.dispose()
|
||||
@@ -222,22 +234,40 @@ async def run_bot(settings_param: Settings):
|
||||
f"Failed to get bot info (e.g., for YooKassa default URL): {e}. Using fallback: {actual_bot_username}"
|
||||
)
|
||||
|
||||
i18n_instance = get_i18n_instance(path="locales",
|
||||
default=settings_param.DEFAULT_LANGUAGE)
|
||||
i18n_instance = get_i18n_instance(
|
||||
path="locales", default=settings_param.DEFAULT_LANGUAGE
|
||||
)
|
||||
|
||||
yookassa_service = YooKassaService(
|
||||
shop_id=settings_param.YOOKASSA_SHOP_ID,
|
||||
secret_key=settings_param.YOOKASSA_SECRET_KEY,
|
||||
configured_return_url=settings_param.YOOKASSA_RETURN_URL,
|
||||
bot_username_for_default_return=actual_bot_username,
|
||||
settings_obj=settings_param)
|
||||
settings_obj=settings_param,
|
||||
)
|
||||
panel_service = PanelApiService(settings_param)
|
||||
|
||||
subscription_service = SubscriptionService(settings_param, panel_service)
|
||||
referral_service = ReferralService(settings_param, subscription_service,
|
||||
bot, i18n_instance)
|
||||
promo_code_service = PromoCodeService(settings_param, subscription_service,
|
||||
bot, i18n_instance)
|
||||
subscription_service = SubscriptionService(
|
||||
settings_param, panel_service, bot, i18n_instance
|
||||
)
|
||||
referral_service = ReferralService(
|
||||
settings_param, subscription_service, bot, i18n_instance
|
||||
)
|
||||
promo_code_service = PromoCodeService(
|
||||
settings_param, subscription_service, bot, i18n_instance
|
||||
)
|
||||
stars_service = StarsService(
|
||||
bot, settings_param, i18n_instance, subscription_service, referral_service
|
||||
)
|
||||
tribute_service = TributeService(
|
||||
bot,
|
||||
settings_param,
|
||||
i18n_instance,
|
||||
local_async_session_factory,
|
||||
panel_service,
|
||||
subscription_service,
|
||||
referral_service,
|
||||
)
|
||||
|
||||
dp["i18n_instance"] = i18n_instance
|
||||
dp["yookassa_service"] = yookassa_service
|
||||
@@ -245,15 +275,17 @@ async def run_bot(settings_param: Settings):
|
||||
dp["subscription_service"] = subscription_service
|
||||
dp["referral_service"] = referral_service
|
||||
dp["promo_code_service"] = promo_code_service
|
||||
dp["stars_service"] = stars_service
|
||||
dp["tribute_service"] = tribute_service
|
||||
dp["async_session_factory"] = local_async_session_factory
|
||||
|
||||
dp.update.outer_middleware(DBSessionMiddleware(local_async_session_factory))
|
||||
dp.update.outer_middleware(
|
||||
DBSessionMiddleware(local_async_session_factory))
|
||||
I18nMiddleware(i18n=i18n_instance, settings=settings_param)
|
||||
)
|
||||
dp.update.outer_middleware(
|
||||
I18nMiddleware(i18n=i18n_instance, settings=settings_param))
|
||||
dp.update.outer_middleware(
|
||||
BanCheckMiddleware(settings=settings_param,
|
||||
i18n_instance=i18n_instance))
|
||||
BanCheckMiddleware(settings=settings_param, i18n_instance=i18n_instance)
|
||||
)
|
||||
dp.update.outer_middleware(ActionLoggerMiddleware(settings=settings_param))
|
||||
|
||||
dp.startup.register(on_startup_configured)
|
||||
@@ -261,13 +293,12 @@ async def run_bot(settings_param: Settings):
|
||||
|
||||
await register_all_routers(dp, settings_param)
|
||||
|
||||
tg_webhook_base = getattr(settings_param, 'TELEGRAM_WEBHOOK_BASE_URL',
|
||||
None)
|
||||
yk_webhook_base = getattr(settings_param, 'YOOKASSA_WEBHOOK_BASE_URL',
|
||||
None)
|
||||
tg_webhook_base = getattr(settings_param, "TELEGRAM_WEBHOOK_BASE_URL", None)
|
||||
yk_webhook_base = getattr(settings_param, "YOOKASSA_WEBHOOK_BASE_URL", None)
|
||||
|
||||
should_run_aiohttp_server = bool(tg_webhook_base) or (
|
||||
bool(yk_webhook_base) and bool(settings_param.yookassa_webhook_path))
|
||||
bool(yk_webhook_base) and bool(settings_param.yookassa_webhook_path)
|
||||
)
|
||||
|
||||
telegram_uses_webhook_mode = bool(tg_webhook_base)
|
||||
run_telegram_polling = not telegram_uses_webhook_mode
|
||||
@@ -288,25 +319,28 @@ async def run_bot(settings_param: Settings):
|
||||
|
||||
if should_run_aiohttp_server:
|
||||
app = web.Application()
|
||||
app['bot'] = bot
|
||||
app['dp'] = dp
|
||||
app['settings'] = settings_param
|
||||
app['i18n'] = i18n_instance
|
||||
app['async_session_factory'] = local_async_session_factory
|
||||
app["bot"] = bot
|
||||
app["dp"] = dp
|
||||
app["settings"] = settings_param
|
||||
app["i18n"] = i18n_instance
|
||||
app["async_session_factory"] = local_async_session_factory
|
||||
|
||||
app['yookassa_service'] = yookassa_service
|
||||
app['subscription_service'] = subscription_service
|
||||
app['referral_service'] = referral_service
|
||||
app['panel_service'] = panel_service
|
||||
app["yookassa_service"] = yookassa_service
|
||||
app["subscription_service"] = subscription_service
|
||||
app["referral_service"] = referral_service
|
||||
app["panel_service"] = panel_service
|
||||
app["stars_service"] = stars_service
|
||||
app["tribute_service"] = tribute_service
|
||||
|
||||
setup_application(app, dp, bot=bot)
|
||||
|
||||
if telegram_uses_webhook_mode:
|
||||
telegram_webhook_path = f"/{settings_param.BOT_TOKEN}"
|
||||
if not telegram_webhook_path.startswith('/'):
|
||||
telegram_webhook_path = '/' + telegram_webhook_path
|
||||
app.router.add_post(telegram_webhook_path,
|
||||
SimpleRequestHandler(dispatcher=dp, bot=bot))
|
||||
if not telegram_webhook_path.startswith("/"):
|
||||
telegram_webhook_path = "/" + telegram_webhook_path
|
||||
app.router.add_post(
|
||||
telegram_webhook_path, SimpleRequestHandler(dispatcher=dp, bot=bot)
|
||||
)
|
||||
logging.info(
|
||||
f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)"
|
||||
)
|
||||
@@ -317,40 +351,52 @@ async def run_bot(settings_param: Settings):
|
||||
logging.error(
|
||||
f"YooKassa webhook path is invalid or not configured in settings: {yk_path}. Skipping YooKassa webhook setup."
|
||||
)
|
||||
elif not yk_path.startswith('/'):
|
||||
elif not yk_path.startswith("/"):
|
||||
logging.error(
|
||||
f"CRITICAL: YooKassa webhook path '{yk_path}' from settings does not start with '/'. Correct settings.py or .env. Skipping YooKassa webhook."
|
||||
)
|
||||
else:
|
||||
app.router.add_post(
|
||||
yk_path,
|
||||
user_payment_webhook_module.yookassa_webhook_route)
|
||||
logging.info(
|
||||
f"YooKassa webhook route configured at: [POST] {yk_path}")
|
||||
yk_path, user_payment_webhook_module.yookassa_webhook_route
|
||||
)
|
||||
logging.info(f"YooKassa webhook route configured at: [POST] {yk_path}")
|
||||
|
||||
tribute_path = settings_param.tribute_webhook_path
|
||||
if tribute_path.startswith("/"):
|
||||
app.router.add_post(tribute_path, tribute_webhook_route)
|
||||
logging.info(f"Tribute webhook route configured at: [POST] {tribute_path}")
|
||||
|
||||
web_app_runner = web.AppRunner(app)
|
||||
await web_app_runner.setup()
|
||||
site = web.TCPSite(web_app_runner,
|
||||
host=settings_param.WEB_SERVER_HOST,
|
||||
port=settings_param.WEB_SERVER_PORT)
|
||||
site = web.TCPSite(
|
||||
web_app_runner,
|
||||
host=settings_param.WEB_SERVER_HOST,
|
||||
port=settings_param.WEB_SERVER_PORT,
|
||||
)
|
||||
|
||||
async def web_server_task():
|
||||
await site.start()
|
||||
logging.info(
|
||||
f"AIOHTTP server started on http://{settings_param.WEB_SERVER_HOST}:{settings_param.WEB_SERVER_PORT}"
|
||||
)
|
||||
await asyncio.Event().wait(
|
||||
) if not run_telegram_polling else await asyncio.sleep(31536000)
|
||||
(
|
||||
await asyncio.Event().wait()
|
||||
if not run_telegram_polling
|
||||
else await asyncio.sleep(31536000)
|
||||
)
|
||||
|
||||
main_tasks.append(
|
||||
asyncio.create_task(web_server_task(), name="AIOHTTPServerTask"))
|
||||
asyncio.create_task(web_server_task(), name="AIOHTTPServerTask")
|
||||
)
|
||||
|
||||
if run_telegram_polling:
|
||||
logging.info("Starting bot in Telegram Polling mode...")
|
||||
main_tasks.append(
|
||||
asyncio.create_task(dp.start_polling(
|
||||
bot, allowed_updates=dp.resolve_used_update_types()),
|
||||
name="TelegramPollingTask"))
|
||||
asyncio.create_task(
|
||||
dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types()),
|
||||
name="TelegramPollingTask",
|
||||
)
|
||||
)
|
||||
|
||||
if not main_tasks:
|
||||
logging.error(
|
||||
@@ -366,8 +412,7 @@ async def run_bot(settings_param: Settings):
|
||||
try:
|
||||
await asyncio.gather(*main_tasks)
|
||||
except (KeyboardInterrupt, SystemExit, asyncio.CancelledError) as e:
|
||||
logging.info(
|
||||
f"Main bot loop interrupted/cancelled: {type(e).__name__} - {e}")
|
||||
logging.info(f"Main bot loop interrupted/cancelled: {type(e).__name__} - {e}")
|
||||
finally:
|
||||
logging.info("Initiating final bot shutdown sequence...")
|
||||
for task in main_tasks:
|
||||
@@ -382,7 +427,8 @@ async def run_bot(settings_param: Settings):
|
||||
except Exception as e_task_cancel:
|
||||
logging.error(
|
||||
f"Error during cancellation of task '{task.get_name()}': {e_task_cancel}",
|
||||
exc_info=True)
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
if web_app_runner:
|
||||
await web_app_runner.cleanup()
|
||||
|
||||
@@ -226,6 +226,28 @@ class PanelApiService:
|
||||
|
||||
return None
|
||||
|
||||
async def get_user(
|
||||
self,
|
||||
*,
|
||||
uuid: Optional[str] = None,
|
||||
telegram_id: Optional[int] = None,
|
||||
username: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
log_response: bool = True,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if uuid:
|
||||
return await self.get_user_by_uuid(uuid, log_response=log_response)
|
||||
|
||||
users = await self.get_users_by_filter(
|
||||
telegram_id=telegram_id,
|
||||
username=username,
|
||||
email=email,
|
||||
log_response=log_response,
|
||||
)
|
||||
if users:
|
||||
return users[0]
|
||||
return None
|
||||
|
||||
async def get_users_by_filter(
|
||||
self,
|
||||
telegram_id: Optional[int] = None,
|
||||
|
||||
@@ -5,7 +5,7 @@ from aiogram import Bot
|
||||
|
||||
from config.settings import Settings
|
||||
|
||||
from db.dal import promo_code_dal, user_dal, subscription_dal
|
||||
from db.dal import promo_code_dal, user_dal
|
||||
from db.models import PromoCode, User
|
||||
|
||||
from .subscription_service import SubscriptionService
|
||||
@@ -40,11 +40,6 @@ class PromoCodeService:
|
||||
return False, _("promo_code_already_used_by_user",
|
||||
code=code_input_upper)
|
||||
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user_id)
|
||||
if not active_sub:
|
||||
return False, _("promo_code_no_active_subscription")
|
||||
|
||||
bonus_days = promo_data.bonus_days
|
||||
|
||||
new_end_date = await self.subscription_service.extend_active_subscription_days(
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from aiogram import Bot, types
|
||||
from aiogram.types import LabeledPrice
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from config.settings import Settings
|
||||
from db.dal import payment_dal, user_dal
|
||||
from .subscription_service import SubscriptionService
|
||||
from .referral_service import ReferralService
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
|
||||
class StarsService:
|
||||
def __init__(self, bot: Bot, settings: Settings, i18n: JsonI18n,
|
||||
subscription_service: SubscriptionService,
|
||||
referral_service: ReferralService):
|
||||
self.bot = bot
|
||||
self.settings = settings
|
||||
self.i18n = i18n
|
||||
self.subscription_service = subscription_service
|
||||
self.referral_service = referral_service
|
||||
|
||||
async def create_invoice(self, session: AsyncSession, user_id: int, months: int,
|
||||
stars_price: int, description: str) -> Optional[int]:
|
||||
payment_record_data = {
|
||||
"user_id": user_id,
|
||||
"amount": float(stars_price),
|
||||
"currency": "XTR",
|
||||
"status": "pending_stars",
|
||||
"description": description,
|
||||
"subscription_duration_months": months,
|
||||
"provider": "telegram_stars",
|
||||
}
|
||||
try:
|
||||
db_payment_record = await payment_dal.create_payment_record(
|
||||
session, payment_record_data)
|
||||
await session.commit()
|
||||
except Exception as e_db:
|
||||
await session.rollback()
|
||||
logging.error(f"Failed to create stars payment record: {e_db}",
|
||||
exc_info=True)
|
||||
return None
|
||||
|
||||
payload = f"{db_payment_record.payment_id}:{months}"
|
||||
prices = [LabeledPrice(label=description, amount=stars_price)]
|
||||
try:
|
||||
await self.bot.send_invoice(
|
||||
chat_id=user_id,
|
||||
title=description,
|
||||
description=description,
|
||||
payload=payload,
|
||||
provider_token="",
|
||||
currency="XTR",
|
||||
prices=prices,
|
||||
)
|
||||
return db_payment_record.payment_id
|
||||
except Exception as e_inv:
|
||||
logging.error(f"Failed to send Telegram Stars invoice: {e_inv}",
|
||||
exc_info=True)
|
||||
return None
|
||||
|
||||
async def process_successful_payment(self, session: AsyncSession,
|
||||
message: types.Message,
|
||||
payment_db_id: int,
|
||||
months: int,
|
||||
stars_amount: int,
|
||||
i18n_data: dict) -> None:
|
||||
try:
|
||||
await payment_dal.update_provider_payment_and_status(
|
||||
session, payment_db_id,
|
||||
message.successful_payment.provider_payment_charge_id,
|
||||
"succeeded")
|
||||
await session.commit()
|
||||
except Exception as e_upd:
|
||||
await session.rollback()
|
||||
logging.error(
|
||||
f"Failed to update stars payment record {payment_db_id}: {e_upd}",
|
||||
exc_info=True)
|
||||
return
|
||||
|
||||
activation_details = await self.subscription_service.activate_subscription(
|
||||
session,
|
||||
message.from_user.id,
|
||||
months,
|
||||
float(stars_amount),
|
||||
payment_db_id,
|
||||
provider="telegram_stars",
|
||||
)
|
||||
if not activation_details or not activation_details.get("end_date"):
|
||||
logging.error(
|
||||
f"Failed to activate subscription after stars payment for user {message.from_user.id}")
|
||||
return
|
||||
|
||||
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
||||
session, message.from_user.id, months)
|
||||
await session.commit()
|
||||
|
||||
applied_days = referral_bonus.get("referee_bonus_applied_days") if referral_bonus else None
|
||||
final_end = referral_bonus.get("referee_new_end_date") if referral_bonus else None
|
||||
if not final_end:
|
||||
final_end = activation_details["end_date"]
|
||||
|
||||
current_lang = i18n_data.get("current_language",
|
||||
self.settings.DEFAULT_LANGUAGE)
|
||||
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
||||
_ = lambda k, **kw: i18n.gettext(current_lang, k, **kw) if i18n else k
|
||||
|
||||
if applied_days:
|
||||
inviter_name_display = _("friend_placeholder")
|
||||
db_user = await user_dal.get_user_by_id(session, message.from_user.id)
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
if inviter and inviter.first_name:
|
||||
inviter_name_display = inviter.first_name
|
||||
elif inviter and inviter.username:
|
||||
inviter_name_display = f"@{inviter.username}"
|
||||
success_msg = _(
|
||||
"payment_successful_with_referral_bonus",
|
||||
months=months,
|
||||
base_end_date=activation_details["end_date"].strftime('%Y-%m-%d'),
|
||||
bonus_days=applied_days,
|
||||
final_end_date=final_end.strftime('%Y-%m-%d'),
|
||||
inviter_name=inviter_name_display,
|
||||
)
|
||||
else:
|
||||
success_msg = _("payment_successful", months=months,
|
||||
end_date=final_end.strftime('%Y-%m-%d'))
|
||||
try:
|
||||
await self.bot.send_message(message.from_user.id, success_msg)
|
||||
except Exception as e_send:
|
||||
logging.error(
|
||||
f"Failed to send stars payment success message: {e_send}")
|
||||
|
||||
@@ -2,6 +2,8 @@ import logging
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, Dict, Any, List, Tuple
|
||||
from aiogram import Bot
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
|
||||
from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal
|
||||
from db.models import User, Subscription
|
||||
@@ -12,26 +14,48 @@ from .panel_api_service import PanelApiService
|
||||
|
||||
class SubscriptionService:
|
||||
|
||||
def __init__(self, settings: Settings, panel_service: PanelApiService):
|
||||
def __init__(
|
||||
self,
|
||||
settings: Settings,
|
||||
panel_service: PanelApiService,
|
||||
bot: Optional[Bot] = None,
|
||||
i18n: Optional[JsonI18n] = None,
|
||||
):
|
||||
self.settings = settings
|
||||
self.panel_service = panel_service
|
||||
self.bot = bot
|
||||
self.i18n = i18n
|
||||
|
||||
async def get_user_language(self, session: AsyncSession,
|
||||
user_id: int) -> str:
|
||||
async def get_user_language(self, session: AsyncSession, user_id: int) -> str:
|
||||
user_record = await user_dal.get_user_by_id(session, user_id)
|
||||
return user_record.language_code if user_record and user_record.language_code else self.settings.DEFAULT_LANGUAGE
|
||||
return (
|
||||
user_record.language_code
|
||||
if user_record and user_record.language_code
|
||||
else self.settings.DEFAULT_LANGUAGE
|
||||
)
|
||||
|
||||
async def has_had_any_subscription(self, session: AsyncSession,
|
||||
user_id: int) -> bool:
|
||||
async def has_had_any_subscription(
|
||||
self, session: AsyncSession, user_id: int
|
||||
) -> bool:
|
||||
|
||||
return await subscription_dal.has_any_subscription_for_user(
|
||||
session, user_id)
|
||||
return await subscription_dal.has_any_subscription_for_user(session, user_id)
|
||||
|
||||
async def _notify_admin_panel_user_creation_failed(self, user_id: int):
|
||||
if not self.bot or not self.i18n or not self.settings.ADMIN_IDS:
|
||||
return
|
||||
admin_lang = self.settings.DEFAULT_LANGUAGE
|
||||
_adm = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw)
|
||||
msg = _adm("admin_panel_user_creation_failed", user_id=user_id)
|
||||
for admin_id in self.settings.ADMIN_IDS:
|
||||
try:
|
||||
await self.bot.send_message(admin_id, msg)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Failed to notify admin {admin_id} about panel user creation failure: {e}"
|
||||
)
|
||||
|
||||
async def _get_or_create_panel_user_link_details(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
db_user: Optional[User] = None
|
||||
self, session: AsyncSession, user_id: int, db_user: Optional[User] = None
|
||||
) -> Tuple[Optional[str], Optional[str], Optional[str], bool]:
|
||||
if not db_user:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
@@ -49,7 +73,8 @@ class SubscriptionService:
|
||||
panel_user_created_or_linked_now = False
|
||||
|
||||
panel_users_by_tg_id_list = await self.panel_service.get_users_by_filter(
|
||||
telegram_id=user_id)
|
||||
telegram_id=user_id
|
||||
)
|
||||
if panel_users_by_tg_id_list and len(panel_users_by_tg_id_list) == 1:
|
||||
panel_user_obj_from_api = panel_users_by_tg_id_list[0]
|
||||
logging.info(
|
||||
@@ -68,7 +93,8 @@ class SubscriptionService:
|
||||
f"User {user_id} (local panel_uuid: {current_local_panel_uuid}) not found on panel by TG ID. Fetching by panel_uuid."
|
||||
)
|
||||
panel_user_obj_from_api = await self.panel_service.get_user_by_uuid(
|
||||
current_local_panel_uuid)
|
||||
current_local_panel_uuid
|
||||
)
|
||||
if not panel_user_obj_from_api:
|
||||
logging.warning(
|
||||
f"Local panel_uuid {current_local_panel_uuid} for TG user {user_id} also not found on panel. User might be deleted from panel or UUID desynced."
|
||||
@@ -82,27 +108,33 @@ class SubscriptionService:
|
||||
creation_response = await self.panel_service.create_panel_user(
|
||||
username_on_panel=panel_username_on_panel_standard,
|
||||
telegram_id=user_id,
|
||||
specific_inbound_uuids=self.settings.parsed_default_panel_user_inbound_uuids,
|
||||
)
|
||||
if creation_response and not creation_response.get(
|
||||
"error") and creation_response.get("response"):
|
||||
if (
|
||||
creation_response
|
||||
and not creation_response.get("error")
|
||||
and creation_response.get("response")
|
||||
):
|
||||
panel_user_obj_from_api = creation_response.get("response")
|
||||
panel_user_created_or_linked_now = True
|
||||
|
||||
elif creation_response and creation_response.get(
|
||||
"errorCode") == "A019":
|
||||
elif creation_response and creation_response.get("errorCode") == "A019":
|
||||
logging.warning(
|
||||
f"Panel user '{panel_username_on_panel_standard}' already exists (errorCode A019). Fetching by username."
|
||||
)
|
||||
fetched_by_username_list = await self.panel_service.get_users_by_filter(
|
||||
username=panel_username_on_panel_standard)
|
||||
if fetched_by_username_list and len(
|
||||
fetched_by_username_list) == 1:
|
||||
fetched_by_username_list = (
|
||||
await self.panel_service.get_users_by_filter(
|
||||
username=panel_username_on_panel_standard
|
||||
)
|
||||
)
|
||||
if fetched_by_username_list and len(fetched_by_username_list) == 1:
|
||||
panel_user_obj_from_api = fetched_by_username_list[0]
|
||||
|
||||
if not panel_user_obj_from_api:
|
||||
logging.error(
|
||||
f"Failed to create or link panel user for TG_ID {user_id} with panel username '{panel_username_on_panel_standard}'. Response: {creation_response if 'creation_response' in locals() else 'N/A'}"
|
||||
)
|
||||
await self._notify_admin_panel_user_creation_failed(user_id)
|
||||
return None, None, None, False
|
||||
|
||||
if not panel_user_obj_from_api:
|
||||
@@ -110,33 +142,47 @@ class SubscriptionService:
|
||||
f"Could not obtain panel user object for TG user {user_id} after all checks."
|
||||
)
|
||||
|
||||
return current_local_panel_uuid if current_local_panel_uuid else None, None, None, panel_user_created_or_linked_now
|
||||
return (
|
||||
current_local_panel_uuid if current_local_panel_uuid else None,
|
||||
None,
|
||||
None,
|
||||
panel_user_created_or_linked_now,
|
||||
)
|
||||
|
||||
actual_panel_uuid_from_api = panel_user_obj_from_api.get('uuid')
|
||||
actual_panel_username_from_api = panel_user_obj_from_api.get(
|
||||
'username')
|
||||
panel_telegram_id_from_api = panel_user_obj_from_api.get('telegramId')
|
||||
actual_panel_uuid_from_api = panel_user_obj_from_api.get("uuid")
|
||||
actual_panel_username_from_api = panel_user_obj_from_api.get("username")
|
||||
panel_telegram_id_from_api = panel_user_obj_from_api.get("telegramId")
|
||||
|
||||
if not actual_panel_uuid_from_api:
|
||||
logging.error(
|
||||
f"Panel user object for TG user {user_id} does not contain 'uuid'. Data: {panel_user_obj_from_api}"
|
||||
)
|
||||
return current_local_panel_uuid, None, None, panel_user_created_or_linked_now
|
||||
return (
|
||||
current_local_panel_uuid,
|
||||
None,
|
||||
None,
|
||||
panel_user_created_or_linked_now,
|
||||
)
|
||||
|
||||
needs_local_panel_uuid_update = False
|
||||
if current_local_panel_uuid is None and actual_panel_uuid_from_api:
|
||||
needs_local_panel_uuid_update = True
|
||||
elif current_local_panel_uuid is not None and current_local_panel_uuid != actual_panel_uuid_from_api:
|
||||
elif (
|
||||
current_local_panel_uuid is not None
|
||||
and current_local_panel_uuid != actual_panel_uuid_from_api
|
||||
):
|
||||
logging.warning(
|
||||
f"Local panel_uuid for user {user_id} ('{current_local_panel_uuid}') "
|
||||
f"differs from panel's UUID ('{actual_panel_uuid_from_api}') for their telegramId. "
|
||||
f"Will attempt to update local to panel's version.")
|
||||
f"Will attempt to update local to panel's version."
|
||||
)
|
||||
needs_local_panel_uuid_update = True
|
||||
|
||||
if needs_local_panel_uuid_update:
|
||||
|
||||
conflicting_user_record = await user_dal.get_user_by_panel_uuid(
|
||||
session, actual_panel_uuid_from_api)
|
||||
session, actual_panel_uuid_from_api
|
||||
)
|
||||
if conflicting_user_record and conflicting_user_record.user_id != user_id:
|
||||
logging.error(
|
||||
f"CRITICAL CONFLICT: Panel UUID {actual_panel_uuid_from_api} (from panel for TG ID {user_id}) "
|
||||
@@ -151,14 +197,20 @@ class SubscriptionService:
|
||||
"panel_user_uuid": actual_panel_uuid_from_api
|
||||
}
|
||||
|
||||
if actual_panel_username_from_api and actual_panel_username_from_api != panel_username_on_panel_standard and (
|
||||
if (
|
||||
actual_panel_username_from_api
|
||||
and actual_panel_username_from_api
|
||||
!= panel_username_on_panel_standard
|
||||
and (
|
||||
db_user.username is None
|
||||
or db_user.username != actual_panel_username_from_api):
|
||||
update_data_for_local_user[
|
||||
"username"] = actual_panel_username_from_api
|
||||
or db_user.username != actual_panel_username_from_api
|
||||
)
|
||||
):
|
||||
update_data_for_local_user["username"] = (
|
||||
actual_panel_username_from_api
|
||||
)
|
||||
|
||||
await user_dal.update_user(session, user_id,
|
||||
update_data_for_local_user)
|
||||
await user_dal.update_user(session, user_id, update_data_for_local_user)
|
||||
db_user.panel_user_uuid = actual_panel_uuid_from_api
|
||||
if "username" in update_data_for_local_user:
|
||||
db_user.username = update_data_for_local_user["username"]
|
||||
@@ -175,69 +227,79 @@ class SubscriptionService:
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
if panel_user_obj_from_api and current_local_panel_uuid and panel_telegram_id_int != user_id:
|
||||
if (
|
||||
panel_user_obj_from_api
|
||||
and current_local_panel_uuid
|
||||
and panel_telegram_id_int != user_id
|
||||
):
|
||||
logging.info(
|
||||
f"Panel user {current_local_panel_uuid} has telegramId '{panel_telegram_id_from_api}'. Updating on panel to '{user_id}'."
|
||||
)
|
||||
await self.panel_service.update_user_details_on_panel(
|
||||
current_local_panel_uuid, {"telegramId": user_id})
|
||||
current_local_panel_uuid, {"telegramId": user_id}
|
||||
)
|
||||
|
||||
panel_sub_link_id = panel_user_obj_from_api.get(
|
||||
'subscriptionUuid') or panel_user_obj_from_api.get('shortUuid')
|
||||
panel_short_uuid = panel_user_obj_from_api.get('shortUuid')
|
||||
"subscriptionUuid"
|
||||
) or panel_user_obj_from_api.get("shortUuid")
|
||||
panel_short_uuid = panel_user_obj_from_api.get("shortUuid")
|
||||
|
||||
if not panel_sub_link_id and current_local_panel_uuid:
|
||||
logging.warning(
|
||||
f"No subscriptionUuid or shortUuid found on panel for panel_user_uuid {current_local_panel_uuid} (TG ID: {user_id})."
|
||||
)
|
||||
|
||||
return current_local_panel_uuid, panel_sub_link_id, panel_short_uuid, panel_user_created_or_linked_now
|
||||
return (
|
||||
current_local_panel_uuid,
|
||||
panel_sub_link_id,
|
||||
panel_short_uuid,
|
||||
panel_user_created_or_linked_now,
|
||||
)
|
||||
|
||||
async def activate_trial_subscription(
|
||||
self, session: AsyncSession,
|
||||
user_id: int) -> Optional[Dict[str, Any]]:
|
||||
self, session: AsyncSession, user_id: int
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if not self.settings.TRIAL_ENABLED or self.settings.TRIAL_DURATION_DAYS <= 0:
|
||||
return {
|
||||
"eligible": False,
|
||||
"activated": False,
|
||||
"message_key": "trial_feature_disabled"
|
||||
"message_key": "trial_feature_disabled",
|
||||
}
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user:
|
||||
logging.error(
|
||||
f"User {user_id} not found in DB, cannot activate trial.")
|
||||
logging.error(f"User {user_id} not found in DB, cannot activate trial.")
|
||||
return {
|
||||
"eligible": False,
|
||||
"activated": False,
|
||||
"message_key": "user_not_found_for_trial"
|
||||
"message_key": "user_not_found_for_trial",
|
||||
}
|
||||
|
||||
if await self.has_had_any_subscription(session, user_id):
|
||||
return {
|
||||
"eligible": False,
|
||||
"activated": False,
|
||||
"message_key": "trial_already_had_subscription_or_trial"
|
||||
"message_key": "trial_already_had_subscription_or_trial",
|
||||
}
|
||||
|
||||
panel_user_uuid, panel_sub_link_id, panel_short_uuid, panel_user_created_now = await self._get_or_create_panel_user_link_details(
|
||||
session, user_id, db_user)
|
||||
panel_user_uuid, panel_sub_link_id, panel_short_uuid, panel_user_created_now = (
|
||||
await self._get_or_create_panel_user_link_details(session, user_id, db_user)
|
||||
)
|
||||
|
||||
if not panel_user_uuid or not panel_sub_link_id:
|
||||
logging.error(
|
||||
f"Failed to get panel link details for trial user {user_id}.")
|
||||
logging.error(f"Failed to get panel link details for trial user {user_id}.")
|
||||
return {
|
||||
"eligible": True,
|
||||
"activated": False,
|
||||
"message_key": "trial_activation_failed_panel_link"
|
||||
"message_key": "trial_activation_failed_panel_link",
|
||||
}
|
||||
|
||||
start_date = datetime.now(timezone.utc)
|
||||
end_date = start_date + timedelta(
|
||||
days=self.settings.TRIAL_DURATION_DAYS)
|
||||
end_date = start_date + timedelta(days=self.settings.TRIAL_DURATION_DAYS)
|
||||
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, panel_user_uuid, panel_sub_link_id)
|
||||
session, panel_user_uuid, panel_sub_link_id
|
||||
)
|
||||
|
||||
trial_sub_data = {
|
||||
"user_id": user_id,
|
||||
@@ -255,34 +317,34 @@ class SubscriptionService:
|
||||
except Exception as e_upsert:
|
||||
logging.error(
|
||||
f"Failed to upsert trial subscription for user {user_id}: {e_upsert}",
|
||||
exc_info=True)
|
||||
exc_info=True,
|
||||
)
|
||||
await session.rollback()
|
||||
return {
|
||||
"eligible": True,
|
||||
"activated": False,
|
||||
"message_key": "trial_activation_failed_db"
|
||||
"message_key": "trial_activation_failed_db",
|
||||
}
|
||||
|
||||
panel_update_payload: Dict[str, Any] = {
|
||||
"uuid":
|
||||
panel_user_uuid,
|
||||
"expireAt":
|
||||
end_date.isoformat(timespec='milliseconds').replace('+00:00', 'Z'),
|
||||
"status":
|
||||
"ACTIVE",
|
||||
"trafficLimitBytes":
|
||||
self.settings.trial_traffic_limit_bytes,
|
||||
"trafficLimitStrategy":
|
||||
self.settings.PANEL_USER_DEFAULT_TRAFFIC_STRATEGY,
|
||||
"uuid": panel_user_uuid,
|
||||
"expireAt": end_date.isoformat(timespec="milliseconds").replace(
|
||||
"+00:00", "Z"
|
||||
),
|
||||
"status": "ACTIVE",
|
||||
"trafficLimitBytes": self.settings.trial_traffic_limit_bytes,
|
||||
"trafficLimitStrategy": self.settings.PANEL_USER_DEFAULT_TRAFFIC_STRATEGY,
|
||||
}
|
||||
if self.settings.parsed_default_panel_user_inbound_uuids:
|
||||
panel_update_payload[
|
||||
"activeUserInbounds"] = self.settings.parsed_default_panel_user_inbound_uuids
|
||||
panel_update_payload["activeUserInbounds"] = (
|
||||
self.settings.parsed_default_panel_user_inbound_uuids
|
||||
)
|
||||
elif panel_user_created_now:
|
||||
panel_update_payload["activateAllInbounds"] = True
|
||||
|
||||
updated_panel_user = await self.panel_service.update_user_details_on_panel(
|
||||
panel_user_uuid, panel_update_payload)
|
||||
panel_user_uuid, panel_update_payload
|
||||
)
|
||||
if not updated_panel_user or updated_panel_user.get("error"):
|
||||
logging.warning(
|
||||
f"Panel user details update FAILED for trial user {panel_user_uuid}. Response: {updated_panel_user}"
|
||||
@@ -291,14 +353,13 @@ class SubscriptionService:
|
||||
return {
|
||||
"eligible": True,
|
||||
"activated": False,
|
||||
"message_key": "trial_activation_failed_panel_update"
|
||||
"message_key": "trial_activation_failed_panel_update",
|
||||
}
|
||||
|
||||
await session.commit()
|
||||
|
||||
final_subscription_url = updated_panel_user.get('subscriptionUrl')
|
||||
final_panel_short_uuid = updated_panel_user.get(
|
||||
'shortUuid', panel_short_uuid)
|
||||
final_subscription_url = updated_panel_user.get("subscriptionUrl")
|
||||
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
|
||||
|
||||
return {
|
||||
"eligible": True,
|
||||
@@ -308,7 +369,7 @@ class SubscriptionService:
|
||||
"traffic_gb": self.settings.TRIAL_TRAFFIC_LIMIT_GB,
|
||||
"panel_user_uuid": panel_user_uuid,
|
||||
"panel_short_uuid": final_panel_short_uuid,
|
||||
"subscription_url": final_subscription_url
|
||||
"subscription_url": final_subscription_url,
|
||||
}
|
||||
|
||||
async def activate_subscription(
|
||||
@@ -318,7 +379,8 @@ class SubscriptionService:
|
||||
months: int,
|
||||
payment_amount: float,
|
||||
payment_db_id: int,
|
||||
promo_code_id_from_payment: Optional[int] = None
|
||||
promo_code_id_from_payment: Optional[int] = None,
|
||||
provider: str = "yookassa",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
@@ -328,8 +390,9 @@ class SubscriptionService:
|
||||
)
|
||||
return None
|
||||
|
||||
panel_user_uuid, panel_sub_link_id, panel_short_uuid, panel_user_created_now = await self._get_or_create_panel_user_link_details(
|
||||
session, user_id, db_user)
|
||||
panel_user_uuid, panel_sub_link_id, panel_short_uuid, panel_user_created_now = (
|
||||
await self._get_or_create_panel_user_link_details(session, user_id, db_user)
|
||||
)
|
||||
|
||||
if not panel_user_uuid or not panel_sub_link_id:
|
||||
logging.error(
|
||||
@@ -338,9 +401,14 @@ class SubscriptionService:
|
||||
return None
|
||||
|
||||
current_active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user_id, panel_user_uuid)
|
||||
session, user_id, panel_user_uuid
|
||||
)
|
||||
start_date = datetime.now(timezone.utc)
|
||||
if current_active_sub and current_active_sub.end_date and current_active_sub.end_date > start_date:
|
||||
if (
|
||||
current_active_sub
|
||||
and current_active_sub.end_date
|
||||
and current_active_sub.end_date > start_date
|
||||
):
|
||||
start_date = current_active_sub.end_date
|
||||
|
||||
duration_days_total = months * 30
|
||||
@@ -348,8 +416,13 @@ class SubscriptionService:
|
||||
|
||||
if promo_code_id_from_payment:
|
||||
promo_model = await promo_code_dal.get_promo_code_by_id(
|
||||
session, promo_code_id_from_payment)
|
||||
if promo_model and promo_model.is_active and promo_model.current_activations < promo_model.max_activations:
|
||||
session, promo_code_id_from_payment
|
||||
)
|
||||
if (
|
||||
promo_model
|
||||
and promo_model.is_active
|
||||
and promo_model.current_activations < promo_model.max_activations
|
||||
):
|
||||
applied_promo_bonus_days = promo_model.bonus_days
|
||||
duration_days_total += applied_promo_bonus_days
|
||||
|
||||
@@ -357,10 +430,12 @@ class SubscriptionService:
|
||||
session,
|
||||
promo_code_id_from_payment,
|
||||
user_id,
|
||||
payment_id=payment_db_id)
|
||||
payment_id=payment_db_id,
|
||||
)
|
||||
if activation:
|
||||
await promo_code_dal.increment_promo_code_usage(
|
||||
session, promo_code_id_from_payment)
|
||||
session, promo_code_id_from_payment
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Promo code {promo_code_id_from_payment} was already activated by user {user_id}, but bonus applied via payment {payment_db_id}."
|
||||
@@ -373,7 +448,8 @@ class SubscriptionService:
|
||||
|
||||
final_end_date = start_date + timedelta(days=duration_days_total)
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, panel_user_uuid, panel_sub_link_id)
|
||||
session, panel_user_uuid, panel_sub_link_id
|
||||
)
|
||||
|
||||
sub_payload = {
|
||||
"user_id": user_id,
|
||||
@@ -384,48 +460,48 @@ class SubscriptionService:
|
||||
"duration_months": months,
|
||||
"is_active": True,
|
||||
"status_from_panel": "ACTIVE",
|
||||
"traffic_limit_bytes":
|
||||
self.settings.PANEL_USER_DEFAULT_TRAFFIC_BYTES,
|
||||
"traffic_limit_bytes": self.settings.PANEL_USER_DEFAULT_TRAFFIC_BYTES,
|
||||
"provider": provider,
|
||||
"skip_notifications": provider == "tribute",
|
||||
}
|
||||
try:
|
||||
new_or_updated_sub = await subscription_dal.upsert_subscription(
|
||||
session, sub_payload)
|
||||
session, sub_payload
|
||||
)
|
||||
except Exception as e_upsert_sub:
|
||||
logging.error(
|
||||
f"Failed to upsert paid subscription for user {user_id}: {e_upsert_sub}",
|
||||
exc_info=True)
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
panel_update_payload = {
|
||||
"uuid":
|
||||
panel_user_uuid,
|
||||
"expireAt":
|
||||
final_end_date.isoformat(timespec='milliseconds').replace(
|
||||
'+00:00', 'Z'),
|
||||
"status":
|
||||
"ACTIVE",
|
||||
"trafficLimitBytes":
|
||||
self.settings.PANEL_USER_DEFAULT_TRAFFIC_BYTES,
|
||||
"trafficLimitStrategy":
|
||||
self.settings.PANEL_USER_DEFAULT_TRAFFIC_STRATEGY
|
||||
"uuid": panel_user_uuid,
|
||||
"expireAt": final_end_date.isoformat(timespec="milliseconds").replace(
|
||||
"+00:00", "Z"
|
||||
),
|
||||
"status": "ACTIVE",
|
||||
"trafficLimitBytes": self.settings.PANEL_USER_DEFAULT_TRAFFIC_BYTES,
|
||||
"trafficLimitStrategy": self.settings.PANEL_USER_DEFAULT_TRAFFIC_STRATEGY,
|
||||
}
|
||||
if self.settings.parsed_default_panel_user_inbound_uuids:
|
||||
panel_update_payload[
|
||||
"activeUserInbounds"] = self.settings.parsed_default_panel_user_inbound_uuids
|
||||
panel_update_payload["activeUserInbounds"] = (
|
||||
self.settings.parsed_default_panel_user_inbound_uuids
|
||||
)
|
||||
elif panel_user_created_now:
|
||||
panel_update_payload["activateAllInbounds"] = True
|
||||
|
||||
updated_panel_user = await self.panel_service.update_user_details_on_panel(
|
||||
panel_user_uuid, panel_update_payload)
|
||||
panel_user_uuid, panel_update_payload
|
||||
)
|
||||
if not updated_panel_user or updated_panel_user.get("error"):
|
||||
logging.warning(
|
||||
f"Panel user details update FAILED for paid sub user {panel_user_uuid}. Response: {updated_panel_user}"
|
||||
)
|
||||
return None
|
||||
|
||||
final_subscription_url = updated_panel_user.get('subscriptionUrl')
|
||||
final_panel_short_uuid = updated_panel_user.get(
|
||||
'shortUuid', panel_short_uuid)
|
||||
final_subscription_url = updated_panel_user.get("subscriptionUrl")
|
||||
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
|
||||
|
||||
return {
|
||||
"subscription_id": new_or_updated_sub.subscription_id,
|
||||
@@ -434,48 +510,84 @@ class SubscriptionService:
|
||||
"panel_user_uuid": panel_user_uuid,
|
||||
"panel_short_uuid": final_panel_short_uuid,
|
||||
"subscription_url": final_subscription_url,
|
||||
"applied_promo_bonus_days": applied_promo_bonus_days
|
||||
"applied_promo_bonus_days": applied_promo_bonus_days,
|
||||
}
|
||||
|
||||
async def extend_active_subscription_days(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
bonus_days: int,
|
||||
reason: str = "bonus") -> Optional[datetime]:
|
||||
self,
|
||||
session: AsyncSession,
|
||||
user_id: int,
|
||||
bonus_days: int,
|
||||
reason: str = "bonus",
|
||||
) -> Optional[datetime]:
|
||||
user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not user or not user.panel_user_uuid:
|
||||
if not user:
|
||||
logging.warning(
|
||||
f"Cannot extend subscription for user {user_id}: User or panel_user_uuid not found."
|
||||
f"Cannot extend subscription for user {user_id}: user not found."
|
||||
)
|
||||
return None
|
||||
|
||||
panel_uuid, panel_sub_uuid, _, _ = await self._get_or_create_panel_user_link_details(
|
||||
session, user_id, user
|
||||
)
|
||||
if not panel_uuid or not panel_sub_uuid:
|
||||
logging.error(
|
||||
f"Failed to ensure panel user for subscription extension of user {user_id}."
|
||||
)
|
||||
return None
|
||||
|
||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user_id, user.panel_user_uuid)
|
||||
session, user_id, panel_uuid
|
||||
)
|
||||
if not active_sub or not active_sub.end_date:
|
||||
logging.info(
|
||||
f"No active extendable subscription found for user {user_id} (panel: {user.panel_user_uuid}) for reason: {reason}."
|
||||
f"No active subscription found for user {user_id}. Creating new one for {bonus_days} days."
|
||||
)
|
||||
return None
|
||||
start_date = datetime.now(timezone.utc)
|
||||
new_end_date_obj = start_date + timedelta(days=bonus_days)
|
||||
bonus_sub_payload = {
|
||||
"user_id": user_id,
|
||||
"panel_user_uuid": panel_uuid,
|
||||
"panel_subscription_uuid": panel_sub_uuid,
|
||||
"start_date": start_date,
|
||||
"end_date": new_end_date_obj,
|
||||
"duration_months": 0,
|
||||
"is_active": True,
|
||||
"status_from_panel": "ACTIVE_BONUS",
|
||||
"traffic_limit_bytes": self.settings.PANEL_USER_DEFAULT_TRAFFIC_BYTES,
|
||||
}
|
||||
await subscription_dal.deactivate_other_active_subscriptions(
|
||||
session, panel_uuid, panel_sub_uuid
|
||||
)
|
||||
updated_sub_model = await subscription_dal.upsert_subscription(
|
||||
session, bonus_sub_payload
|
||||
)
|
||||
else:
|
||||
current_end_date = active_sub.end_date
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
start_point_for_bonus = (
|
||||
current_end_date if current_end_date > now_utc else now_utc
|
||||
)
|
||||
new_end_date_obj = start_point_for_bonus + timedelta(days=bonus_days)
|
||||
|
||||
current_end_date = active_sub.end_date
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
start_point_for_bonus = current_end_date if current_end_date > now_utc else now_utc
|
||||
new_end_date_obj = start_point_for_bonus + timedelta(days=bonus_days)
|
||||
|
||||
updated_sub_model = await subscription_dal.update_subscription_end_date(
|
||||
session, active_sub.subscription_id, new_end_date_obj)
|
||||
updated_sub_model = await subscription_dal.update_subscription_end_date(
|
||||
session, active_sub.subscription_id, new_end_date_obj
|
||||
)
|
||||
|
||||
if updated_sub_model:
|
||||
panel_update_success = await self.panel_service.update_user_details_on_panel(
|
||||
user.panel_user_uuid, {
|
||||
"expireAt":
|
||||
new_end_date_obj.isoformat(
|
||||
timespec='milliseconds').replace('+00:00', 'Z')
|
||||
})
|
||||
panel_update_success = (
|
||||
await self.panel_service.update_user_details_on_panel(
|
||||
panel_uuid,
|
||||
{
|
||||
"expireAt": new_end_date_obj.isoformat(
|
||||
timespec="milliseconds"
|
||||
).replace("+00:00", "Z")
|
||||
},
|
||||
)
|
||||
)
|
||||
if not panel_update_success:
|
||||
logging.warning(
|
||||
f"Panel expiry update failed for {user.panel_user_uuid} after {reason} bonus. Local DB was updated to {new_end_date_obj}."
|
||||
f"Panel expiry update failed for {panel_uuid} after {reason} bonus. Local DB was updated to {new_end_date_obj}."
|
||||
)
|
||||
|
||||
logging.info(
|
||||
@@ -489,8 +601,8 @@ class SubscriptionService:
|
||||
return None
|
||||
|
||||
async def get_active_subscription_details(
|
||||
self, session: AsyncSession,
|
||||
user_id: int) -> Optional[Dict[str, Any]]:
|
||||
self, session: AsyncSession, user_id: int
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
if not db_user or not db_user.panel_user_uuid:
|
||||
logging.info(
|
||||
@@ -500,136 +612,131 @@ class SubscriptionService:
|
||||
|
||||
panel_user_uuid = db_user.panel_user_uuid
|
||||
local_active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
||||
session, user_id, panel_user_uuid)
|
||||
panel_user_data = await self.panel_service.get_user_by_uuid(
|
||||
panel_user_uuid)
|
||||
session, user_id, panel_user_uuid
|
||||
)
|
||||
panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid)
|
||||
|
||||
if not panel_user_data:
|
||||
logging.warning(
|
||||
f"Panel user {panel_user_uuid} not found on panel for user {user_id}. Using local data if available."
|
||||
f"Panel user {panel_user_uuid} not found on panel for user {user_id}. Clearing local linkage."
|
||||
)
|
||||
if local_active_sub and local_active_sub.end_date and local_active_sub.end_date > datetime.now(
|
||||
timezone.utc):
|
||||
return {
|
||||
"end_date":
|
||||
local_active_sub.end_date,
|
||||
"status_from_panel":
|
||||
(local_active_sub.status_from_panel or "UNKNOWN") +
|
||||
" (Panel Unreachable)",
|
||||
"config_link":
|
||||
await self.panel_service.get_subscription_link(
|
||||
local_active_sub.panel_subscription_uuid)
|
||||
if local_active_sub.panel_subscription_uuid else "N/A",
|
||||
"traffic_limit_bytes":
|
||||
local_active_sub.traffic_limit_bytes,
|
||||
"traffic_used_bytes":
|
||||
local_active_sub.traffic_used_bytes,
|
||||
"user_bot_username":
|
||||
db_user.username,
|
||||
"is_panel_data":
|
||||
False
|
||||
}
|
||||
await subscription_dal.deactivate_all_user_subscriptions(session, user_id)
|
||||
await user_dal.update_user(session, user_id, {"panel_user_uuid": None})
|
||||
return None
|
||||
|
||||
if local_active_sub:
|
||||
update_payload_local = {}
|
||||
panel_status = panel_user_data.get('status', 'UNKNOWN').upper()
|
||||
panel_expire_at_str = panel_user_data.get('expireAt')
|
||||
panel_traffic_used = panel_user_data.get('usedTrafficBytes')
|
||||
panel_traffic_limit = panel_user_data.get('trafficLimitBytes')
|
||||
panel_status = panel_user_data.get("status", "UNKNOWN").upper()
|
||||
panel_expire_at_str = panel_user_data.get("expireAt")
|
||||
panel_traffic_used = panel_user_data.get("usedTrafficBytes")
|
||||
panel_traffic_limit = panel_user_data.get("trafficLimitBytes")
|
||||
panel_sub_uuid_from_panel = panel_user_data.get(
|
||||
'subscriptionUuid') or panel_user_data.get('shortUuid')
|
||||
"subscriptionUuid"
|
||||
) or panel_user_data.get("shortUuid")
|
||||
|
||||
if local_active_sub.status_from_panel != panel_status:
|
||||
update_payload_local['status_from_panel'] = panel_status
|
||||
update_payload_local["status_from_panel"] = panel_status
|
||||
if panel_expire_at_str:
|
||||
panel_expire_dt = datetime.fromisoformat(
|
||||
panel_expire_at_str.replace("Z", "+00:00"))
|
||||
panel_expire_at_str.replace("Z", "+00:00")
|
||||
)
|
||||
if local_active_sub.end_date.replace(
|
||||
microsecond=0) != panel_expire_dt.replace(
|
||||
microsecond=0):
|
||||
update_payload_local['end_date'] = panel_expire_dt
|
||||
update_payload_local['last_notification_sent'] = None
|
||||
if panel_traffic_used is not None and local_active_sub.traffic_used_bytes != panel_traffic_used:
|
||||
update_payload_local['traffic_used_bytes'] = panel_traffic_used
|
||||
if panel_traffic_limit is not None and local_active_sub.traffic_limit_bytes != panel_traffic_limit:
|
||||
update_payload_local[
|
||||
'traffic_limit_bytes'] = panel_traffic_limit
|
||||
if panel_sub_uuid_from_panel and local_active_sub.panel_subscription_uuid != panel_sub_uuid_from_panel:
|
||||
update_payload_local[
|
||||
'panel_subscription_uuid'] = panel_sub_uuid_from_panel
|
||||
microsecond=0
|
||||
) != panel_expire_dt.replace(microsecond=0):
|
||||
update_payload_local["end_date"] = panel_expire_dt
|
||||
update_payload_local["last_notification_sent"] = None
|
||||
if (
|
||||
panel_traffic_used is not None
|
||||
and local_active_sub.traffic_used_bytes != panel_traffic_used
|
||||
):
|
||||
update_payload_local["traffic_used_bytes"] = panel_traffic_used
|
||||
if (
|
||||
panel_traffic_limit is not None
|
||||
and local_active_sub.traffic_limit_bytes != panel_traffic_limit
|
||||
):
|
||||
update_payload_local["traffic_limit_bytes"] = panel_traffic_limit
|
||||
if (
|
||||
panel_sub_uuid_from_panel
|
||||
and local_active_sub.panel_subscription_uuid
|
||||
!= panel_sub_uuid_from_panel
|
||||
):
|
||||
update_payload_local["panel_subscription_uuid"] = (
|
||||
panel_sub_uuid_from_panel
|
||||
)
|
||||
|
||||
is_active_based_on_panel = panel_status == "ACTIVE" and (
|
||||
panel_expire_dt > datetime.now(timezone.utc)
|
||||
if panel_expire_dt else False)
|
||||
if panel_expire_dt
|
||||
else False
|
||||
)
|
||||
if local_active_sub.is_active != is_active_based_on_panel:
|
||||
update_payload_local['is_active'] = is_active_based_on_panel
|
||||
update_payload_local["is_active"] = is_active_based_on_panel
|
||||
|
||||
if update_payload_local:
|
||||
await subscription_dal.update_subscription(
|
||||
session, local_active_sub.subscription_id,
|
||||
update_payload_local)
|
||||
session, local_active_sub.subscription_id, update_payload_local
|
||||
)
|
||||
|
||||
panel_end_date = datetime.fromisoformat(
|
||||
panel_user_data['expireAt'].replace(
|
||||
"Z", "+00:00")) if panel_user_data.get('expireAt') else None
|
||||
panel_end_date = (
|
||||
datetime.fromisoformat(panel_user_data["expireAt"].replace("Z", "+00:00"))
|
||||
if panel_user_data.get("expireAt")
|
||||
else None
|
||||
)
|
||||
|
||||
return {
|
||||
"end_date":
|
||||
panel_end_date,
|
||||
"status_from_panel":
|
||||
panel_user_data.get('status', 'UNKNOWN').upper(),
|
||||
"config_link":
|
||||
panel_user_data.get('subscriptionUrl')
|
||||
or (await self.panel_service.get_subscription_link(
|
||||
panel_user_data['shortUuid'])
|
||||
if panel_user_data.get('shortUuid') else "N/A"),
|
||||
"traffic_limit_bytes":
|
||||
panel_user_data.get('trafficLimitBytes'),
|
||||
"traffic_used_bytes":
|
||||
panel_user_data.get('usedTrafficBytes'),
|
||||
"user_bot_username":
|
||||
db_user.username,
|
||||
"is_panel_data":
|
||||
True
|
||||
"end_date": panel_end_date,
|
||||
"status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(),
|
||||
"config_link": panel_user_data.get("subscriptionUrl"),
|
||||
"traffic_limit_bytes": panel_user_data.get("trafficLimitBytes"),
|
||||
"traffic_used_bytes": panel_user_data.get("usedTrafficBytes"),
|
||||
"user_bot_username": db_user.username,
|
||||
"is_panel_data": True,
|
||||
}
|
||||
|
||||
async def get_subscriptions_ending_soon(
|
||||
self, session: AsyncSession,
|
||||
days_threshold: int) -> List[Dict[str, Any]]:
|
||||
subs_models_with_users = await subscription_dal.get_subscriptions_near_expiration(
|
||||
session, days_threshold)
|
||||
self, session: AsyncSession, days_threshold: int
|
||||
) -> List[Dict[str, Any]]:
|
||||
subs_models_with_users = (
|
||||
await subscription_dal.get_subscriptions_near_expiration(
|
||||
session, days_threshold
|
||||
)
|
||||
)
|
||||
results = []
|
||||
for sub_model in subs_models_with_users:
|
||||
if sub_model.user and sub_model.end_date:
|
||||
days_left = (sub_model.end_date - datetime.now(
|
||||
timezone.utc)).total_seconds() / (24 * 3600)
|
||||
results.append({
|
||||
'user_id':
|
||||
sub_model.user_id,
|
||||
'first_name':
|
||||
sub_model.user.first_name or f"User {sub_model.user_id}",
|
||||
'language_code':
|
||||
sub_model.user.language_code
|
||||
or self.settings.DEFAULT_LANGUAGE,
|
||||
'end_date_str':
|
||||
sub_model.end_date.strftime('%Y-%m-%d'),
|
||||
'days_left':
|
||||
max(0, int(round(days_left))),
|
||||
'subscription_end_date_iso_for_update':
|
||||
sub_model.end_date
|
||||
})
|
||||
if (
|
||||
sub_model.user
|
||||
and sub_model.end_date
|
||||
and not sub_model.skip_notifications
|
||||
):
|
||||
days_left = (
|
||||
sub_model.end_date - datetime.now(timezone.utc)
|
||||
).total_seconds() / (24 * 3600)
|
||||
results.append(
|
||||
{
|
||||
"user_id": sub_model.user_id,
|
||||
"first_name": sub_model.user.first_name
|
||||
or f"User {sub_model.user_id}",
|
||||
"language_code": sub_model.user.language_code
|
||||
or self.settings.DEFAULT_LANGUAGE,
|
||||
"end_date_str": sub_model.end_date.strftime("%Y-%m-%d"),
|
||||
"days_left": max(0, int(round(days_left))),
|
||||
"subscription_end_date_iso_for_update": sub_model.end_date,
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
async def update_last_notification_sent(self, session: AsyncSession,
|
||||
user_id: int,
|
||||
subscription_end_date: datetime):
|
||||
sub_to_update = await subscription_dal.find_subscription_for_notification_update(
|
||||
session, user_id, subscription_end_date)
|
||||
async def update_last_notification_sent(
|
||||
self, session: AsyncSession, user_id: int, subscription_end_date: datetime
|
||||
):
|
||||
sub_to_update = (
|
||||
await subscription_dal.find_subscription_for_notification_update(
|
||||
session, user_id, subscription_end_date
|
||||
)
|
||||
)
|
||||
if sub_to_update:
|
||||
await subscription_dal.update_subscription_notification_time(
|
||||
session, sub_to_update.subscription_id,
|
||||
datetime.now(timezone.utc))
|
||||
session, sub_to_update.subscription_id, datetime.now(timezone.utc)
|
||||
)
|
||||
logging.info(
|
||||
f"Updated last_notification_sent for user {user_id}, sub_id {sub_to_update.subscription_id}"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import logging
|
||||
import hmac
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Optional
|
||||
|
||||
from aiohttp import web
|
||||
from aiogram import Bot
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from config.settings import Settings
|
||||
from bot.middlewares.i18n import JsonI18n
|
||||
from bot.services.subscription_service import SubscriptionService
|
||||
from bot.services.panel_api_service import PanelApiService
|
||||
from bot.services.referral_service import ReferralService
|
||||
from db.dal import payment_dal, user_dal, subscription_dal
|
||||
|
||||
|
||||
class TributeService:
|
||||
def __init__(self, bot: Bot, settings: Settings, i18n: JsonI18n,
|
||||
async_session_factory: sessionmaker,
|
||||
panel_service: PanelApiService,
|
||||
subscription_service: SubscriptionService,
|
||||
referral_service: ReferralService):
|
||||
self.bot = bot
|
||||
self.settings = settings
|
||||
self.i18n = i18n
|
||||
self.async_session_factory = async_session_factory
|
||||
self.panel_service = panel_service
|
||||
self.subscription_service = subscription_service
|
||||
self.referral_service = referral_service
|
||||
|
||||
async def handle_webhook(self, raw_body: bytes,
|
||||
signature_header: Optional[str]) -> web.Response:
|
||||
settings = self.settings
|
||||
bot = self.bot
|
||||
i18n = self.i18n
|
||||
async_session_factory = self.async_session_factory
|
||||
subscription_service = self.subscription_service
|
||||
referral_service = self.referral_service
|
||||
|
||||
if settings.TRIBUTE_API_KEY:
|
||||
if not signature_header:
|
||||
return web.Response(status=403, text="no_signature")
|
||||
expected_sig = hmac.new(settings.TRIBUTE_API_KEY.encode(), raw_body,
|
||||
hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(expected_sig, signature_header):
|
||||
return web.Response(status=403, text="invalid_signature")
|
||||
|
||||
try:
|
||||
payload = json.loads(raw_body.decode())
|
||||
except Exception:
|
||||
return web.Response(status=400, text="bad_request")
|
||||
|
||||
event_name = payload.get('name')
|
||||
data = payload.get('payload', {})
|
||||
user_id = data.get('telegram_user_id')
|
||||
price_val = data.get('price')
|
||||
|
||||
if not user_id or price_val is None:
|
||||
return web.Response(status=200, text="ok_missing_fields")
|
||||
|
||||
months_map = {int(v): m for m, v in settings.subscription_options.items()}
|
||||
price_rub = price_val / 100
|
||||
months = months_map.get(int(price_rub))
|
||||
if not months:
|
||||
logging.warning(
|
||||
f"Tribute webhook: price {price_val} not mapped to months")
|
||||
return web.Response(status=200, text="ok_price_unmapped")
|
||||
|
||||
async with async_session_factory() as session:
|
||||
if event_name == 'new_subscription':
|
||||
provider_payment_id = str(data.get('subscription_id'))
|
||||
existing_payment = await payment_dal.get_payment_by_provider_payment_id(
|
||||
session, provider_payment_id)
|
||||
if existing_payment:
|
||||
logging.info(
|
||||
"Duplicate Tribute payment webhook ignored for provider_payment_id %s",
|
||||
provider_payment_id,
|
||||
)
|
||||
payment_record = existing_payment
|
||||
else:
|
||||
payment_record = await payment_dal.create_payment_record(
|
||||
session,
|
||||
{
|
||||
'user_id': user_id,
|
||||
'amount': float(price_rub),
|
||||
'currency': 'RUB',
|
||||
'status': 'succeeded',
|
||||
'description': 'Tribute subscription',
|
||||
'subscription_duration_months': months,
|
||||
'provider_payment_id': provider_payment_id,
|
||||
'provider': 'tribute',
|
||||
},
|
||||
)
|
||||
activation_details = await subscription_service.activate_subscription(
|
||||
session,
|
||||
user_id,
|
||||
months,
|
||||
float(price_rub),
|
||||
payment_record.payment_id,
|
||||
provider='tribute',
|
||||
)
|
||||
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
|
||||
session, user_id, months)
|
||||
await session.commit()
|
||||
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
|
||||
|
||||
applied_ref_days = referral_bonus.get('referee_bonus_applied_days') if referral_bonus else None
|
||||
final_end = (referral_bonus.get('referee_new_end_date')
|
||||
if referral_bonus else None)
|
||||
if not final_end:
|
||||
final_end = activation_details.get('end_date')
|
||||
|
||||
if final_end:
|
||||
if applied_ref_days:
|
||||
inviter_name_display = _('friend_placeholder')
|
||||
if db_user and db_user.referred_by_id:
|
||||
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
||||
if inviter and inviter.first_name:
|
||||
inviter_name_display = inviter.first_name
|
||||
elif inviter and inviter.username:
|
||||
inviter_name_display = f"@{inviter.username}"
|
||||
success_msg = _(
|
||||
"payment_successful_with_referral_bonus",
|
||||
months=months,
|
||||
base_end_date=activation_details["end_date"].strftime('%Y-%m-%d'),
|
||||
bonus_days=applied_ref_days,
|
||||
final_end_date=final_end.strftime('%Y-%m-%d'),
|
||||
inviter_name=inviter_name_display)
|
||||
else:
|
||||
success_msg = _(
|
||||
"payment_successful", months=months,
|
||||
end_date=final_end.strftime('%Y-%m-%d'))
|
||||
|
||||
try:
|
||||
await bot.send_message(user_id, success_msg)
|
||||
except Exception as e:
|
||||
logging.error(
|
||||
f"Failed to send Tribute payment success message to user {user_id}: {e}")
|
||||
elif event_name == 'cancelled_subscription':
|
||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||
lang = db_user.language_code if db_user and db_user.language_code else settings.DEFAULT_LANGUAGE
|
||||
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
|
||||
try:
|
||||
await bot.send_message(user_id, _("subscription_cancelled_notification"))
|
||||
except Exception as e:
|
||||
logging.warning(
|
||||
f"Failed to notify user {user_id} about cancellation: {e}")
|
||||
await subscription_dal.set_skip_notifications_for_provider(
|
||||
session, user_id, 'tribute', False)
|
||||
await session.commit()
|
||||
else:
|
||||
await session.commit()
|
||||
return web.Response(status=200, text="ok")
|
||||
|
||||
|
||||
async def tribute_webhook_route(request: web.Request):
|
||||
"""AIOHTTP route handler for Tribute webhook calls."""
|
||||
tribute_service: TributeService = request.app['tribute_service']
|
||||
raw_body = await request.read()
|
||||
signature_header = request.headers.get('trbt-signature')
|
||||
return await tribute_service.handle_webhook(raw_body, signature_header)
|
||||
@@ -6,6 +6,7 @@ class AdminStates(StatesGroup):
|
||||
waiting_for_broadcast_message = State()
|
||||
confirming_broadcast = State()
|
||||
waiting_for_promo_details = State()
|
||||
waiting_for_promo_edit_details = State()
|
||||
waiting_for_user_id_to_ban = State()
|
||||
waiting_for_user_id_to_unban = State()
|
||||
|
||||
|
||||
+73
-12
@@ -36,10 +36,31 @@ class Settings(BaseSettings):
|
||||
|
||||
TELEGRAM_WEBHOOK_BASE_URL: Optional[str] = None
|
||||
|
||||
PRICE_1_MONTH: Optional[int] = Field(default=None)
|
||||
PRICE_3_MONTHS: Optional[int] = Field(default=None)
|
||||
PRICE_6_MONTHS: Optional[int] = Field(default=None)
|
||||
PRICE_12_MONTHS: Optional[int] = Field(default=None)
|
||||
YOOKASSA_ENABLED: bool = Field(default=True)
|
||||
STARS_ENABLED: bool = Field(default=True)
|
||||
TRIBUTE_ENABLED: bool = Field(default=True)
|
||||
|
||||
MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED")
|
||||
MONTH_3_ENABLED: bool = Field(default=True, alias="3_MONTHS_ENABLED")
|
||||
MONTH_6_ENABLED: bool = Field(default=True, alias="6_MONTHS_ENABLED")
|
||||
MONTH_12_ENABLED: bool = Field(default=True, alias="12_MONTHS_ENABLED")
|
||||
|
||||
RUB_PRICE_1_MONTH: Optional[int] = Field(default=None)
|
||||
RUB_PRICE_3_MONTHS: Optional[int] = Field(default=None)
|
||||
RUB_PRICE_6_MONTHS: Optional[int] = Field(default=None)
|
||||
RUB_PRICE_12_MONTHS: Optional[int] = Field(default=None)
|
||||
|
||||
STARS_PRICE_1_MONTH: Optional[int] = Field(default=None)
|
||||
STARS_PRICE_3_MONTHS: Optional[int] = Field(default=None)
|
||||
STARS_PRICE_6_MONTHS: Optional[int] = Field(default=None)
|
||||
STARS_PRICE_12_MONTHS: Optional[int] = Field(default=None)
|
||||
|
||||
|
||||
TRIBUTE_LINK_1_MONTH: Optional[str] = Field(default=None)
|
||||
TRIBUTE_LINK_3_MONTHS: Optional[str] = Field(default=None)
|
||||
TRIBUTE_LINK_6_MONTHS: Optional[str] = Field(default=None)
|
||||
TRIBUTE_LINK_12_MONTHS: Optional[str] = Field(default=None)
|
||||
TRIBUTE_API_KEY: Optional[str] = Field(default=None)
|
||||
|
||||
SUBSCRIPTION_EXPIRATION_NOTIFICATION_DAYS: int = Field(default=7)
|
||||
SUBSCRIPTION_NOTIFICATION_HOUR_UTC: int = Field(default=9)
|
||||
@@ -141,21 +162,61 @@ class Settings(BaseSettings):
|
||||
return f"{self.YOOKASSA_WEBHOOK_BASE_URL.rstrip('/')}{self.yookassa_webhook_path}"
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def tribute_webhook_path(self) -> str:
|
||||
return "/webhook/tribute"
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def tribute_full_webhook_url(self) -> Optional[str]:
|
||||
if self.YOOKASSA_WEBHOOK_BASE_URL:
|
||||
return f"{self.YOOKASSA_WEBHOOK_BASE_URL.rstrip('/')}{self.tribute_webhook_path}"
|
||||
return None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def subscription_options(self) -> Dict[int, float]:
|
||||
options: Dict[int, float] = {}
|
||||
|
||||
if self.PRICE_1_MONTH is not None:
|
||||
options[1] = float(self.PRICE_1_MONTH / 100.0)
|
||||
if self.PRICE_3_MONTHS is not None:
|
||||
options[3] = float(self.PRICE_3_MONTHS / 100.0)
|
||||
if self.PRICE_6_MONTHS is not None:
|
||||
options[6] = float(self.PRICE_6_MONTHS / 100.0)
|
||||
if self.PRICE_12_MONTHS is not None:
|
||||
options[12] = float(self.PRICE_12_MONTHS / 100.0)
|
||||
if self.MONTH_1_ENABLED and self.RUB_PRICE_1_MONTH is not None:
|
||||
options[1] = float(self.RUB_PRICE_1_MONTH)
|
||||
if self.MONTH_3_ENABLED and self.RUB_PRICE_3_MONTHS is not None:
|
||||
options[3] = float(self.RUB_PRICE_3_MONTHS)
|
||||
if self.MONTH_6_ENABLED and self.RUB_PRICE_6_MONTHS is not None:
|
||||
options[6] = float(self.RUB_PRICE_6_MONTHS)
|
||||
if self.MONTH_12_ENABLED and self.RUB_PRICE_12_MONTHS is not None:
|
||||
options[12] = float(self.RUB_PRICE_12_MONTHS)
|
||||
return options
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def stars_subscription_options(self) -> Dict[int, int]:
|
||||
options: Dict[int, int] = {}
|
||||
if self.STARS_ENABLED and self.MONTH_1_ENABLED and self.STARS_PRICE_1_MONTH is not None:
|
||||
options[1] = self.STARS_PRICE_1_MONTH
|
||||
if self.STARS_ENABLED and self.MONTH_3_ENABLED and self.STARS_PRICE_3_MONTHS is not None:
|
||||
options[3] = self.STARS_PRICE_3_MONTHS
|
||||
if self.STARS_ENABLED and self.MONTH_6_ENABLED and self.STARS_PRICE_6_MONTHS is not None:
|
||||
options[6] = self.STARS_PRICE_6_MONTHS
|
||||
if self.STARS_ENABLED and self.MONTH_12_ENABLED and self.STARS_PRICE_12_MONTHS is not None:
|
||||
options[12] = self.STARS_PRICE_12_MONTHS
|
||||
return options
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def tribute_payment_links(self) -> Dict[int, str]:
|
||||
links: Dict[int, str] = {}
|
||||
if self.TRIBUTE_ENABLED and self.MONTH_1_ENABLED and self.TRIBUTE_LINK_1_MONTH:
|
||||
links[1] = self.TRIBUTE_LINK_1_MONTH
|
||||
if self.TRIBUTE_ENABLED and self.MONTH_3_ENABLED and self.TRIBUTE_LINK_3_MONTHS:
|
||||
links[3] = self.TRIBUTE_LINK_3_MONTHS
|
||||
if self.TRIBUTE_ENABLED and self.MONTH_6_ENABLED and self.TRIBUTE_LINK_6_MONTHS:
|
||||
links[6] = self.TRIBUTE_LINK_6_MONTHS
|
||||
if self.TRIBUTE_ENABLED and self.MONTH_12_ENABLED and self.TRIBUTE_LINK_12_MONTHS:
|
||||
links[12] = self.TRIBUTE_LINK_12_MONTHS
|
||||
return links
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def referral_bonus_inviter(self) -> Dict[int, int]:
|
||||
|
||||
@@ -46,6 +46,15 @@ async def get_payment_by_yookassa_id(
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_payment_by_provider_payment_id(
|
||||
session: AsyncSession, provider_payment_id: str) -> Optional[Payment]:
|
||||
"""Fetch a payment by provider-specific identifier."""
|
||||
stmt = select(Payment).where(
|
||||
Payment.provider_payment_id == provider_payment_id)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_payment_by_db_id(session: AsyncSession,
|
||||
payment_db_id: int) -> Optional[Payment]:
|
||||
|
||||
@@ -113,3 +122,23 @@ async def get_recent_payment_logs_with_user(session: AsyncSession,
|
||||
Payment.created_at.desc()).limit(limit).offset(offset))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def update_provider_payment_and_status(
|
||||
session: AsyncSession, payment_db_id: int,
|
||||
provider_payment_id: str, new_status: str) -> Optional[Payment]:
|
||||
payment = await get_payment_by_db_id(session, payment_db_id)
|
||||
if payment:
|
||||
payment.status = new_status
|
||||
payment.provider_payment_id = provider_payment_id
|
||||
payment.updated_at = func.now()
|
||||
await session.flush()
|
||||
await session.refresh(payment)
|
||||
logging.info(
|
||||
f"Payment record {payment.payment_id} updated with provider id {provider_payment_id} and status {new_status}."
|
||||
)
|
||||
else:
|
||||
logging.warning(
|
||||
f"Payment record with DB ID {payment_db_id} not found for provider update."
|
||||
)
|
||||
return payment
|
||||
|
||||
@@ -49,6 +49,27 @@ async def get_all_active_promo_codes(session: AsyncSession,
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def update_promo_code(session: AsyncSession, promo_id: int,
|
||||
update_data: Dict[str, Any]) -> Optional[PromoCode]:
|
||||
promo = await get_promo_code_by_id(session, promo_id)
|
||||
if not promo:
|
||||
return None
|
||||
for key, value in update_data.items():
|
||||
setattr(promo, key, value)
|
||||
await session.flush()
|
||||
await session.refresh(promo)
|
||||
return promo
|
||||
|
||||
|
||||
async def delete_promo_code(session: AsyncSession, promo_id: int) -> Optional[PromoCode]:
|
||||
promo = await get_promo_code_by_id(session, promo_id)
|
||||
if not promo:
|
||||
return None
|
||||
await session.delete(promo)
|
||||
await session.flush()
|
||||
return promo
|
||||
|
||||
|
||||
async def increment_promo_code_usage(
|
||||
session: AsyncSession, promo_code_id: int) -> Optional[PromoCode]:
|
||||
promo = await get_promo_code_by_id(session, promo_code_id)
|
||||
|
||||
@@ -129,6 +129,21 @@ async def deactivate_other_active_subscriptions(
|
||||
)
|
||||
|
||||
|
||||
async def deactivate_all_user_subscriptions(
|
||||
session: AsyncSession, user_id: int) -> int:
|
||||
stmt = (
|
||||
update(Subscription)
|
||||
.where(Subscription.user_id == user_id, Subscription.is_active == True)
|
||||
.values(is_active=False, status_from_panel="INACTIVE_USER_NOT_FOUND")
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
if result.rowcount > 0:
|
||||
logging.info(
|
||||
f"Deactivated {result.rowcount} subscriptions for user {user_id} due to missing panel user."
|
||||
)
|
||||
return result.rowcount
|
||||
|
||||
|
||||
async def update_subscription_end_date(
|
||||
session: AsyncSession, subscription_id: int,
|
||||
new_end_date: datetime) -> Optional[Subscription]:
|
||||
@@ -156,7 +171,9 @@ async def get_subscriptions_near_expiration(
|
||||
threshold_date = now_utc + timedelta(days=days_threshold)
|
||||
|
||||
stmt = (select(Subscription).join(Subscription.user).where(
|
||||
Subscription.is_active == True, Subscription.end_date > now_utc,
|
||||
Subscription.is_active == True,
|
||||
Subscription.skip_notifications == False,
|
||||
Subscription.end_date > now_utc,
|
||||
Subscription.end_date <= threshold_date,
|
||||
or_(
|
||||
Subscription.last_notification_sent == None,
|
||||
@@ -203,3 +220,14 @@ async def find_subscription_for_notification_update(
|
||||
<= subscription_end_date_to_match + timedelta(seconds=1)).limit(1)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def set_skip_notifications_for_provider(
|
||||
session: AsyncSession, user_id: int, provider: str,
|
||||
skip: bool) -> int:
|
||||
stmt = (update(Subscription).where(
|
||||
Subscription.user_id == user_id,
|
||||
Subscription.is_active == True,
|
||||
Subscription.provider == provider).values(skip_notifications=skip))
|
||||
result = await session.execute(stmt)
|
||||
return result.rowcount
|
||||
|
||||
+66
-39
@@ -9,33 +9,47 @@ from datetime import datetime
|
||||
from ..models import User, Subscription
|
||||
|
||||
|
||||
async def get_user_by_id(session: AsyncSession,
|
||||
user_id: int) -> Optional[User]:
|
||||
async def get_user_by_id(session: AsyncSession, user_id: int) -> Optional[User]:
|
||||
stmt = select(User).where(User.user_id == user_id)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_user_by_username(session: AsyncSession,
|
||||
username: str) -> Optional[User]:
|
||||
clean_username = username.lstrip('@').lower()
|
||||
async def get_user_by_username(session: AsyncSession, username: str) -> Optional[User]:
|
||||
clean_username = username.lstrip("@").lower()
|
||||
stmt = select(User).where(func.lower(User.username) == clean_username)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_user_by_panel_uuid(session: AsyncSession,
|
||||
panel_uuid: str) -> Optional[User]:
|
||||
async def get_user_by_panel_uuid(
|
||||
session: AsyncSession, panel_uuid: str
|
||||
) -> Optional[User]:
|
||||
stmt = select(User).where(User.panel_user_uuid == panel_uuid)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_user(session: AsyncSession, user_data: Dict[str,
|
||||
Any]) -> User:
|
||||
async def get_user(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
user_id: Optional[int] = None,
|
||||
username: Optional[str] = None,
|
||||
panel_uuid: Optional[str] = None,
|
||||
) -> Optional[User]:
|
||||
if user_id is not None:
|
||||
return await get_user_by_id(session, user_id)
|
||||
if username is not None:
|
||||
return await get_user_by_username(session, username)
|
||||
if panel_uuid is not None:
|
||||
return await get_user_by_panel_uuid(session, panel_uuid)
|
||||
return None
|
||||
|
||||
if 'registration_date' not in user_data:
|
||||
user_data['registration_date'] = datetime.now()
|
||||
|
||||
async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> User:
|
||||
|
||||
if "registration_date" not in user_data:
|
||||
user_data["registration_date"] = datetime.now()
|
||||
|
||||
new_user = User(**user_data)
|
||||
session.add(new_user)
|
||||
@@ -47,8 +61,9 @@ async def create_user(session: AsyncSession, user_data: Dict[str,
|
||||
return new_user
|
||||
|
||||
|
||||
async def update_user(session: AsyncSession, user_id: int,
|
||||
update_data: Dict[str, Any]) -> Optional[User]:
|
||||
async def update_user(
|
||||
session: AsyncSession, user_id: int, update_data: Dict[str, Any]
|
||||
) -> Optional[User]:
|
||||
user = await get_user_by_id(session, user_id)
|
||||
if user:
|
||||
for key, value in update_data.items():
|
||||
@@ -58,16 +73,17 @@ async def update_user(session: AsyncSession, user_id: int,
|
||||
return user
|
||||
|
||||
|
||||
async def update_user_language(session: AsyncSession, user_id: int,
|
||||
lang_code: str) -> bool:
|
||||
stmt = update(User).where(User.user_id == user_id).values(
|
||||
language_code=lang_code)
|
||||
async def update_user_language(
|
||||
session: AsyncSession, user_id: int, lang_code: str
|
||||
) -> bool:
|
||||
stmt = update(User).where(User.user_id == user_id).values(language_code=lang_code)
|
||||
result = await session.execute(stmt)
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
async def set_user_ban_status(session: AsyncSession, user_id: int,
|
||||
is_banned: bool) -> bool:
|
||||
async def set_user_ban_status(
|
||||
session: AsyncSession, user_id: int, is_banned: bool
|
||||
) -> bool:
|
||||
user = await get_user_by_id(session, user_id)
|
||||
if user:
|
||||
user.is_banned = is_banned
|
||||
@@ -77,23 +93,27 @@ async def set_user_ban_status(session: AsyncSession, user_id: int,
|
||||
return False
|
||||
|
||||
|
||||
async def get_banned_users_paginated(session: AsyncSession, limit: int,
|
||||
offset: int) -> Tuple[List[User], int]:
|
||||
stmt_users = select(User).where(User.is_banned == True).order_by(
|
||||
User.registration_date.desc()).limit(limit).offset(offset)
|
||||
async def get_banned_users_paginated(
|
||||
session: AsyncSession, limit: int, offset: int
|
||||
) -> Tuple[List[User], int]:
|
||||
stmt_users = (
|
||||
select(User)
|
||||
.where(User.is_banned == True)
|
||||
.order_by(User.registration_date.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
result_users = await session.execute(stmt_users)
|
||||
users_list = result_users.scalars().all()
|
||||
|
||||
stmt_count = select(
|
||||
func.count()).select_from(User).where(User.is_banned == True)
|
||||
stmt_count = select(func.count()).select_from(User).where(User.is_banned == True)
|
||||
result_count = await session.execute(stmt_count)
|
||||
total_banned = result_count.scalar_one()
|
||||
|
||||
return users_list, total_banned
|
||||
|
||||
|
||||
async def get_all_active_user_ids_for_broadcast(
|
||||
session: AsyncSession) -> List[int]:
|
||||
async def get_all_active_user_ids_for_broadcast(session: AsyncSession) -> List[int]:
|
||||
stmt = select(User.user_id).where(User.is_banned == False)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
@@ -101,24 +121,31 @@ async def get_all_active_user_ids_for_broadcast(
|
||||
|
||||
async def get_user_count_stats_dal(session: AsyncSession) -> Dict[str, int]:
|
||||
total_users_stmt = select(func.count(User.user_id)).select_from(User)
|
||||
banned_users_stmt = select(func.count(
|
||||
User.user_id)).select_from(User).where(User.is_banned == True)
|
||||
banned_users_stmt = (
|
||||
select(func.count(User.user_id)).select_from(User).where(User.is_banned == True)
|
||||
)
|
||||
|
||||
active_subs_stmt = (select(func.count(
|
||||
func.distinct(Subscription.user_id))).join(
|
||||
User, Subscription.user_id == User.user_id).where(
|
||||
Subscription.is_active == True).where(
|
||||
Subscription.end_date > datetime.now()))
|
||||
active_subs_stmt = (
|
||||
select(func.count(func.distinct(Subscription.user_id)))
|
||||
.join(User, Subscription.user_id == User.user_id)
|
||||
.where(Subscription.is_active == True)
|
||||
.where(Subscription.end_date > datetime.now())
|
||||
)
|
||||
|
||||
total_users = (await
|
||||
session.execute(total_users_stmt)).scalar_one_or_none() or 0
|
||||
banned_users = (
|
||||
await session.execute(banned_users_stmt)).scalar_one_or_none() or 0
|
||||
total_users = (await session.execute(total_users_stmt)).scalar_one_or_none() or 0
|
||||
banned_users = (await session.execute(banned_users_stmt)).scalar_one_or_none() or 0
|
||||
active_subs_users = (
|
||||
await session.execute(active_subs_stmt)).scalar_one_or_none() or 0
|
||||
await session.execute(active_subs_stmt)
|
||||
).scalar_one_or_none() or 0
|
||||
|
||||
return {
|
||||
"total_users": total_users,
|
||||
"banned_users": banned_users,
|
||||
"users_with_active_subscriptions": active_subs_users,
|
||||
}
|
||||
|
||||
|
||||
async def get_all_users_with_panel_uuid(session: AsyncSession) -> List[User]:
|
||||
stmt = select(User).where(User.panel_user_uuid.is_not(None))
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
@@ -70,6 +70,8 @@ class Subscription(Base):
|
||||
traffic_limit_bytes = Column(BigInteger, nullable=True)
|
||||
traffic_used_bytes = Column(BigInteger, nullable=True)
|
||||
last_notification_sent = Column(DateTime(timezone=True), nullable=True)
|
||||
provider = Column(String, nullable=True)
|
||||
skip_notifications = Column(Boolean, default=False)
|
||||
|
||||
user = relationship("User", back_populates="subscriptions")
|
||||
|
||||
@@ -89,6 +91,8 @@ class Payment(Base):
|
||||
unique=True,
|
||||
index=True,
|
||||
nullable=True)
|
||||
provider_payment_id = Column(String, unique=True, nullable=True)
|
||||
provider = Column(String, nullable=False, default="yookassa", index=True)
|
||||
idempotence_key = Column(String, unique=True, nullable=True)
|
||||
amount = Column(Float, nullable=False)
|
||||
currency = Column(String, nullable=False)
|
||||
|
||||
+2
-1
@@ -12,9 +12,10 @@ services:
|
||||
- ./locales:/app/locales
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
remnawave-tg-shop-db:
|
||||
image: postgres:17
|
||||
container_name: remnawave-tg-shop-db
|
||||
hostname: remnawave-tg-shop-db
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
|
||||
+15
-3
@@ -22,9 +22,11 @@
|
||||
"select_subscription_period": "Select subscription period:",
|
||||
"no_subscription_options_available": "No subscription options available at the moment.",
|
||||
"subscribe_for_months_button": "{months} mo. - {price} {currency_symbol}",
|
||||
"confirm_subscription_prompt": "Confirm subscription purchase:\nDuration: {months} mo.\nPrice: {price} {currency_symbol}",
|
||||
"choose_payment_method": "Choose payment method:",
|
||||
"pay_button": "💳 Pay",
|
||||
"confirm_payment_button": "✅ Yes ({price} {currency_symbol})",
|
||||
"pay_with_yookassa_button": "💳 YooKassa",
|
||||
"pay_with_tribute_button": "❤️ Tribute",
|
||||
"pay_with_stars_button": "🌟 Telegram Stars",
|
||||
"cancel_button": "❌ Cancel",
|
||||
"payment_description_subscription": "Subscription payment for {months} mo.",
|
||||
"payment_service_unavailable": "Payment service temporarily unavailable. Please try again later.",
|
||||
@@ -88,6 +90,7 @@
|
||||
"admin_stats_button": "📊 Statistics",
|
||||
"admin_broadcast_button": "📢 Broadcast",
|
||||
"admin_create_promo_button": "🎁 Create Promo",
|
||||
"admin_manage_promos_button": "🛠 Manage Promos",
|
||||
"admin_view_promos_button": "👀 Promo List",
|
||||
"admin_ban_user_button": "🚫 Ban User",
|
||||
"admin_unban_user_button": "✅ Unban User",
|
||||
@@ -139,6 +142,13 @@
|
||||
"admin_promo_list_item": "<code>{code}</code>: +{bonus}d, {current}/{max} used, valid until {valid_until}",
|
||||
"admin_promo_valid_indefinitely": "indefinite",
|
||||
"admin_promo_valid_until_display": "until {date}",
|
||||
"admin_manage_promos_title": "Manage Promo Codes",
|
||||
"admin_promo_edit_button": "✏️ Edit",
|
||||
"admin_promo_delete_button": "🗑 Delete",
|
||||
"admin_promo_edit_prompt": "Send new details for <code>{code}</code> in format: CODE BONUS_DAYS MAX_USES [VALIDITY_DAYS]",
|
||||
"admin_promo_updated_success": "Promo <code>{code}</code> updated.",
|
||||
"admin_promo_deleted_success": "Promo <code>{code}</code> deleted.",
|
||||
"admin_promo_not_found": "Promo not found.",
|
||||
|
||||
"admin_ban_user_prompt": "Enter user ID or @username to ban:",
|
||||
"admin_user_not_found_in_bot_db": "User <code>{user_id}</code> not found in bot database.",
|
||||
@@ -166,7 +176,7 @@
|
||||
"user_card_sub_na": "n/a",
|
||||
"user_card_ban_button": "🚫 Ban",
|
||||
"user_card_unban_button": "✅ Unban",
|
||||
"user_card_back_to_banned_list": "⬅️ Back to Ban List",
|
||||
"user_card_back_to_banned_list_button": "⬅️ Back to Ban List",
|
||||
"admin_confirm_action_title": "Confirm: {action_text}",
|
||||
"ban_verb_l": "ban",
|
||||
"unban_verb_l": "unban",
|
||||
@@ -196,11 +206,13 @@
|
||||
"sync_completed_with_errors_details": "Checked: {total_checked} entries.\nUsers synced/updated: {users_synced}.\nSubscriptions synced/updated: {subs_synced}.\nErrors: {errors_count}.\n\nFirst errors:\n{error_details_preview}",
|
||||
"no_errors_placeholder": "none",
|
||||
"admin_sync_initiated_from_panel": "Sync initiated...",
|
||||
"admin_panel_user_creation_failed": "❌ Failed to create panel user for TG ID {user_id}. Panel unreachable?",
|
||||
"error_displaying_logs_too_long": "Error: logs too long to display in one message. Try viewing logs for a specific user.",
|
||||
"error_displaying_statistics": "Error displaying statistics.",
|
||||
"stub_page_display": "Page",
|
||||
|
||||
"subscription_ending_soon_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription ends on {end_date} (in {days_left} days).\n\nTo avoid interruption, please renew it in the main menu.",
|
||||
"subscription_cancelled_notification": "Your recurring subscription was cancelled. You will keep access until the paid period ends.",
|
||||
|
||||
"error_unknown": "An unknown error occurred."
|
||||
}
|
||||
|
||||
+15
-3
@@ -22,9 +22,11 @@
|
||||
"select_subscription_period": "Выберите срок подписки:",
|
||||
"no_subscription_options_available": "В данный момент нет доступных вариантов подписки.",
|
||||
"subscribe_for_months_button": "{months} мес. - {price} {currency_symbol}",
|
||||
"confirm_subscription_prompt": "Подтвердите покупку подписки:\nСрок: {months} мес.\nЦена: {price} {currency_symbol}",
|
||||
"choose_payment_method": "Выберите способ оплаты:",
|
||||
"pay_button": "💳 Оплатить",
|
||||
"confirm_payment_button": "✅ Да ({price} {currency_symbol})",
|
||||
"pay_with_yookassa_button": "💳 ЮKassa",
|
||||
"pay_with_tribute_button": "❤️ Tribute",
|
||||
"pay_with_stars_button": "🌟 Звезды Telegram",
|
||||
"cancel_button": "❌ Отмена",
|
||||
"payment_description_subscription": "Оплата подписки на {months} мес.",
|
||||
"payment_service_unavailable": "Платежный сервис временно недоступен. Пожалуйста, попробуйте позже.",
|
||||
@@ -88,6 +90,7 @@
|
||||
"admin_stats_button": "📊 Статистика",
|
||||
"admin_broadcast_button": "📢 Рассылка",
|
||||
"admin_create_promo_button": "🎁 Создать промо",
|
||||
"admin_manage_promos_button": "🛠 Управление промо",
|
||||
"admin_view_promos_button": "👀 Список промо",
|
||||
"admin_ban_user_button": "🚫 Забанить",
|
||||
"admin_unban_user_button": "✅ Разбанить",
|
||||
@@ -139,6 +142,13 @@
|
||||
"admin_promo_list_item": "<code>{code}</code>: +{bonus}дн, {current}/{max} акт., до {valid_until}",
|
||||
"admin_promo_valid_indefinitely": "бессрочно",
|
||||
"admin_promo_valid_until_display": "до {date}",
|
||||
"admin_manage_promos_title": "Управление промокодами",
|
||||
"admin_promo_edit_button": "✏️ Изменить",
|
||||
"admin_promo_delete_button": "🗑 Удалить",
|
||||
"admin_promo_edit_prompt": "Отправьте новые данные для <code>{code}</code> в формате: КОД ДНИ_БОНУСА МАКС_АКТИВАЦИЙ [СРОК]",
|
||||
"admin_promo_updated_success": "Промокод <code>{code}</code> обновлен.",
|
||||
"admin_promo_deleted_success": "Промокод <code>{code}</code> удален.",
|
||||
"admin_promo_not_found": "Промокод не найден.",
|
||||
|
||||
"admin_ban_user_prompt": "Введите ID или @username пользователя для блокировки:",
|
||||
"admin_user_not_found_in_bot_db": "Пользователь <code>{user_id}</code> не найден в базе данных бота.",
|
||||
@@ -166,7 +176,7 @@
|
||||
"user_card_sub_na": "н/д",
|
||||
"user_card_ban_button": "🚫 Заблокировать",
|
||||
"user_card_unban_button": "✅ Разблокировать",
|
||||
"user_card_back_to_banned_list": "⬅️ К списку забаненных",
|
||||
"user_card_back_to_banned_list_button": "⬅️ К списку забаненных",
|
||||
"admin_confirm_action_title": "Подтверждение: {action_text}",
|
||||
"ban_verb_l": "блокировка",
|
||||
"unban_verb_l": "разблокировка",
|
||||
@@ -196,11 +206,13 @@
|
||||
"sync_completed_with_errors_details": "Проверено: {total_checked} записей.\nПользователей синхронизировано/обновлено: {users_synced}.\nПодписок синхронизировано/обновлено: {subs_synced}.\nОшибок: {errors_count}.\n\nПервые ошибки:\n{error_details_preview}",
|
||||
"no_errors_placeholder": "нет",
|
||||
"admin_sync_initiated_from_panel": "Синхронизация запущена...",
|
||||
"admin_panel_user_creation_failed": "❌ Не удалось создать пользователя на панели для TG ID {user_id}. Панель недоступна?",
|
||||
"error_displaying_logs_too_long": "Ошибка: логи слишком длинные для отображения одним сообщением. Попробуйте найти логи по конкретному пользователю.",
|
||||
"error_displaying_statistics": "Ошибка отображения статистики.",
|
||||
"stub_page_display": "Страница",
|
||||
|
||||
"subscription_ending_soon_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает {end_date} (через {days_left} дн.).\n\nЧтобы не потерять доступ, пожалуйста, продлите ее заранее в главном меню бота.",
|
||||
"subscription_cancelled_notification": "Ваша подписка отменена. Доступ сохранится до конца оплаченного периода.",
|
||||
|
||||
"error_unknown": "Произошла неизвестная ошибка."
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
aiogram==3.20.0
|
||||
python-dotenv==1.0.1
|
||||
aiohttp==3.9.5
|
||||
aiohttp==3.10.11
|
||||
pydantic==2.7.1
|
||||
yookassa==3.5.0
|
||||
pycountry==23.12.11
|
||||
|
||||
Reference in New Issue
Block a user