Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e86fc76b6 | ||
|
|
829a18715a | ||
|
|
844c8e12a7 | ||
|
|
ef75f905f4 | ||
|
|
cd816cbe5c | ||
|
|
2b0fa3a314 | ||
|
|
ead990e61c | ||
|
|
eb6e343e4c | ||
|
|
caf5b88eef | ||
|
|
183bef070d | ||
|
|
643bf5fabb | ||
|
|
7d446d5f64 | ||
|
|
73ce689d5e | ||
|
|
5791f2e58c | ||
|
|
69cc2cbe29 | ||
|
|
7df2e127a4 | ||
|
|
d503cf7f7f | ||
|
|
c0d70030c3 | ||
|
|
4e7c36dbf7 | ||
|
|
3b0b88e9a0 | ||
|
|
207e7751cd |
@@ -44,6 +44,7 @@ YOOKASSA_RETURN_URL=https://t.me/your_bot #
|
|||||||
YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com # Default email for sending receipts
|
YOOKASSA_DEFAULT_RECEIPT_EMAIL=your_email@example.com # Default email for sending receipts
|
||||||
YOOKASSA_VAT_CODE=1 # VAT code
|
YOOKASSA_VAT_CODE=1 # VAT code
|
||||||
YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle
|
YOOKASSA_AUTOPAYMENTS_ENABLED=False # Auto-renew toggle
|
||||||
|
YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING=True # Force automatic card binding when autopay is enabled (set to False to show the save-card checkbox)
|
||||||
|
|
||||||
# FreeKassa Payment Gateway Configuration
|
# FreeKassa Payment Gateway Configuration
|
||||||
FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa
|
FREEKASSA_MERCHANT_ID=your_shop_id # Your shop ID in FreeKassa
|
||||||
@@ -92,6 +93,7 @@ SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 # Da
|
|||||||
|
|
||||||
|
|
||||||
REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee
|
REFERRAL_ONE_BONUS_PER_REFEREE=False # Give a bonus only once per referee
|
||||||
|
LEGACY_REFS=true # Allow ref_<tg_id> links. Leave unset/true unless you want to disable old links
|
||||||
# Referral Bonus Days
|
# Referral Bonus Days
|
||||||
# Bonus for the inviting user
|
# Bonus for the inviting user
|
||||||
REFERRAL_BONUS_DAYS_1_MONTH=3
|
REFERRAL_BONUS_DAYS_1_MONTH=3
|
||||||
|
|||||||
@@ -83,6 +83,8 @@
|
|||||||
| `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). |
|
| `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). |
|
||||||
| `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. |
|
| `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. |
|
||||||
| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
|
| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
|
||||||
|
| `YOOKASSA_AUTOPAYMENTS_ENABLED` | Включить автопродление (сохранение карт, автосписания, управление способами оплаты). |
|
||||||
|
| `YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING` | Требовать обязательную привязку карты при оплате с автосписанием. Установите `false`, чтобы пользователю показывался чекбокс «Сохранить карту». |
|
||||||
| `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). |
|
| `CRYPTOPAY_ENABLED` | Включить/выключить CryptoPay (`true`/`false`). |
|
||||||
| `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. |
|
| `CRYPTOPAY_TOKEN` | Токен из вашего CryptoPay App. |
|
||||||
| `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). |
|
| `FREEKASSA_ENABLED` | Включить/выключить FreeKassa (`true`/`false`). |
|
||||||
|
|||||||
@@ -98,8 +98,22 @@ async def admin_panel_actions_callback_handler(
|
|||||||
await admin_user_mgmnt_handlers.unban_user_prompt_handler(
|
await admin_user_mgmnt_handlers.unban_user_prompt_handler(
|
||||||
callback, state, i18n_data, settings, session)
|
callback, state, i18n_data, settings, session)
|
||||||
elif action == "users_management":
|
elif action == "users_management":
|
||||||
|
# This is deprecated, kept for compatibility
|
||||||
from . import user_management as admin_user_management_handlers
|
from . import user_management as admin_user_management_handlers
|
||||||
await admin_user_management_handlers.user_management_menu_handler(
|
await admin_user_management_handlers.user_search_prompt_handler(
|
||||||
|
callback, state, i18n_data, settings, session)
|
||||||
|
elif action == "users_list" and len(action_parts) > 2:
|
||||||
|
# Route to users list handler with page number
|
||||||
|
from . import user_management as admin_user_management_handlers
|
||||||
|
try:
|
||||||
|
page = int(action_parts[2])
|
||||||
|
await admin_user_management_handlers.users_list_handler(
|
||||||
|
callback, i18n_data, settings, session, page)
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
await callback.answer("Invalid page number", show_alert=True)
|
||||||
|
elif action == "users_search_prompt":
|
||||||
|
from . import user_management as admin_user_management_handlers
|
||||||
|
await admin_user_management_handlers.user_search_prompt_handler(
|
||||||
callback, state, i18n_data, settings, session)
|
callback, state, i18n_data, settings, session)
|
||||||
elif action == "view_banned":
|
elif action == "view_banned":
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from aiogram import Router, types, Bot
|
|||||||
from aiogram.filters import Command
|
from aiogram.filters import Command
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import update, or_
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
@@ -10,14 +11,19 @@ from bot.services.panel_api_service import PanelApiService
|
|||||||
from bot.services.notification_service import NotificationService
|
from bot.services.notification_service import NotificationService
|
||||||
|
|
||||||
from db.dal import user_dal, subscription_dal, panel_sync_dal
|
from db.dal import user_dal, subscription_dal, panel_sync_dal
|
||||||
|
from db.models import Subscription
|
||||||
|
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
|
||||||
router = Router(name="admin_sync_router")
|
router = Router(name="admin_sync_router")
|
||||||
|
|
||||||
|
|
||||||
async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
async def perform_sync(
|
||||||
settings: Settings, i18n_instance: JsonI18n) -> dict:
|
panel_service: PanelApiService,
|
||||||
|
session: AsyncSession,
|
||||||
|
settings: Settings,
|
||||||
|
i18n_instance: JsonI18n,
|
||||||
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Perform panel synchronization and return results
|
Perform panel synchronization and return results
|
||||||
Returns dict with status, details, and sync statistics
|
Returns dict with status, details, and sync statistics
|
||||||
@@ -27,7 +33,7 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
users_updated = 0
|
users_updated = 0
|
||||||
subscriptions_synced_count = 0
|
subscriptions_synced_count = 0
|
||||||
sync_errors = []
|
sync_errors = []
|
||||||
|
|
||||||
# Additional counters for detailed logging
|
# Additional counters for detailed logging
|
||||||
users_without_telegram_id = 0
|
users_without_telegram_id = 0
|
||||||
users_not_found_in_db = 0
|
users_not_found_in_db = 0
|
||||||
@@ -52,7 +58,12 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
session, "success", status_msg, 0, 0
|
session, "success", status_msg, 0, 0
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
return {"status": "success", "details": status_msg, "users_synced": 0, "subs_synced": 0}
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"details": status_msg,
|
||||||
|
"users_synced": 0,
|
||||||
|
"subs_synced": 0,
|
||||||
|
}
|
||||||
|
|
||||||
total_panel_users = len(panel_users_data)
|
total_panel_users = len(panel_users_data)
|
||||||
logging.info(f"Starting sync for {total_panel_users} panel users.")
|
logging.info(f"Starting sync for {total_panel_users} panel users.")
|
||||||
@@ -61,12 +72,16 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
try:
|
try:
|
||||||
panel_records_checked += 1
|
panel_records_checked += 1
|
||||||
panel_uuid = panel_user_dict.get("uuid")
|
panel_uuid = panel_user_dict.get("uuid")
|
||||||
panel_subscription_uuid = panel_user_dict.get("subscriptionUuid") or panel_user_dict.get("shortUuid")
|
panel_subscription_uuid = panel_user_dict.get("subscriptionUuid") or panel_user_dict.get(
|
||||||
|
"shortUuid"
|
||||||
|
)
|
||||||
telegram_id_from_panel = panel_user_dict.get("telegramId")
|
telegram_id_from_panel = panel_user_dict.get("telegramId")
|
||||||
|
|
||||||
if not panel_uuid:
|
if not panel_uuid:
|
||||||
sync_errors.append(f"Panel user missing UUID: {panel_user_dict}")
|
sync_errors.append(f"Panel user missing UUID: {panel_user_dict}")
|
||||||
logging.warning(f"Skipping panel user without UUID: {panel_user_dict}")
|
logging.warning(
|
||||||
|
f"Skipping panel user without UUID: {panel_user_dict}"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Track users without telegram ID
|
# Track users without telegram ID
|
||||||
@@ -75,22 +90,35 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
|
|
||||||
# Try to find existing user in local DB
|
# Try to find existing user in local DB
|
||||||
existing_user = None
|
existing_user = None
|
||||||
|
|
||||||
# First, try to find by telegram ID if available
|
# First, try to find by telegram ID if available
|
||||||
if telegram_id_from_panel:
|
if telegram_id_from_panel:
|
||||||
existing_user = await user_dal.get_user_by_id(session, telegram_id_from_panel)
|
existing_user = await user_dal.get_user_by_id(
|
||||||
|
session, telegram_id_from_panel
|
||||||
|
)
|
||||||
if existing_user:
|
if existing_user:
|
||||||
logging.debug(f"Found user by telegramId {telegram_id_from_panel}")
|
logging.debug(
|
||||||
|
f"Found user by telegramId {telegram_id_from_panel}"
|
||||||
|
)
|
||||||
|
|
||||||
# If not found by telegram ID, try to find by panel UUID
|
# If not found by telegram ID, try to find by panel UUID
|
||||||
if not existing_user:
|
if not existing_user:
|
||||||
existing_user = await user_dal.get_user_by_panel_uuid(session, panel_uuid)
|
existing_user = await user_dal.get_user_by_panel_uuid(
|
||||||
|
session, panel_uuid
|
||||||
|
)
|
||||||
if existing_user:
|
if existing_user:
|
||||||
logging.info(f"Found user by panel UUID {panel_uuid}, telegramId: {existing_user.user_id}")
|
logging.info(
|
||||||
|
f"Found user by panel UUID {panel_uuid}, telegramId: {existing_user.user_id}"
|
||||||
|
)
|
||||||
# Update telegram ID if it was missing in panel data but we have local user
|
# Update telegram ID if it was missing in panel data but we have local user
|
||||||
if telegram_id_from_panel and existing_user.user_id != telegram_id_from_panel:
|
if (
|
||||||
logging.warning(f"TelegramId mismatch: panel={telegram_id_from_panel}, local={existing_user.user_id}")
|
telegram_id_from_panel
|
||||||
|
and existing_user.user_id != telegram_id_from_panel
|
||||||
|
):
|
||||||
|
logging.warning(
|
||||||
|
f"TelegramId mismatch: panel={telegram_id_from_panel}, local={existing_user.user_id}"
|
||||||
|
)
|
||||||
|
|
||||||
if not existing_user:
|
if not existing_user:
|
||||||
users_not_found_in_db += 1
|
users_not_found_in_db += 1
|
||||||
if telegram_id_from_panel:
|
if telegram_id_from_panel:
|
||||||
@@ -100,26 +128,36 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
"user_id": telegram_id_from_panel,
|
"user_id": telegram_id_from_panel,
|
||||||
"username": None, # Username will be updated when user interacts with bot
|
"username": None, # Username will be updated when user interacts with bot
|
||||||
"first_name": None, # Panel doesn't provide this info
|
"first_name": None, # Panel doesn't provide this info
|
||||||
"last_name": None, # Panel doesn't provide this info
|
"last_name": None, # Panel doesn't provide this info
|
||||||
"language_code": "ru", # Default language
|
"language_code": "ru", # Default language
|
||||||
"panel_user_uuid": panel_uuid,
|
"panel_user_uuid": panel_uuid,
|
||||||
"is_banned": False,
|
"is_banned": False,
|
||||||
"referred_by_id": None
|
"referred_by_id": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
new_user, was_created = await user_dal.create_user(session, user_data)
|
new_user, was_created = await user_dal.create_user(
|
||||||
|
session, user_data
|
||||||
|
)
|
||||||
if was_created:
|
if was_created:
|
||||||
users_created += 1
|
users_created += 1
|
||||||
logging.info(f"Created new user {telegram_id_from_panel} from panel sync with UUID {panel_uuid}")
|
logging.info(
|
||||||
|
f"Created new user {telegram_id_from_panel} from panel sync with UUID {panel_uuid}"
|
||||||
|
)
|
||||||
|
|
||||||
existing_user = new_user
|
existing_user = new_user
|
||||||
|
|
||||||
except Exception as e_create:
|
except Exception as e_create:
|
||||||
sync_errors.append(f"Error creating user {telegram_id_from_panel}: {str(e_create)}")
|
sync_errors.append(
|
||||||
logging.error(f"Error creating user {telegram_id_from_panel}: {e_create}")
|
f"Error creating user {telegram_id_from_panel}: {str(e_create)}"
|
||||||
|
)
|
||||||
|
logging.error(
|
||||||
|
f"Error creating user {telegram_id_from_panel}: {e_create}"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
logging.debug(f"Panel user with UUID {panel_uuid} (no telegramId) not found in local DB - skipping")
|
logging.debug(
|
||||||
|
f"Panel user with UUID {panel_uuid} (no telegramId) not found in local DB - skipping"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# User found in local DB
|
# User found in local DB
|
||||||
@@ -134,20 +172,29 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
existing_user.panel_user_uuid = panel_uuid
|
existing_user.panel_user_uuid = panel_uuid
|
||||||
user_was_updated = True
|
user_was_updated = True
|
||||||
users_uuid_updated += 1
|
users_uuid_updated += 1
|
||||||
logging.info(f"Updated panel UUID for user {actual_user_id}: {panel_uuid}")
|
logging.info(
|
||||||
|
f"Updated panel UUID for user {actual_user_id}: {panel_uuid}"
|
||||||
|
)
|
||||||
|
|
||||||
# Ensure panel description contains Telegram fields
|
# Ensure panel description contains Telegram fields
|
||||||
try:
|
try:
|
||||||
if panel_uuid and existing_user:
|
if panel_uuid and existing_user:
|
||||||
description_text = "\n".join([
|
description_text = "\n".join(
|
||||||
existing_user.username or "",
|
[
|
||||||
existing_user.first_name or "",
|
existing_user.username or "",
|
||||||
existing_user.last_name or "",
|
existing_user.first_name or "",
|
||||||
])
|
existing_user.last_name or "",
|
||||||
|
]
|
||||||
|
)
|
||||||
# Update description only when it differs from the current one on panel
|
# Update description only when it differs from the current one on panel
|
||||||
current_panel_description = (panel_user_dict.get("description") or "").strip()
|
current_panel_description = (
|
||||||
|
panel_user_dict.get("description") or ""
|
||||||
|
).strip()
|
||||||
desired_description = description_text.strip()
|
desired_description = description_text.strip()
|
||||||
if desired_description and desired_description != current_panel_description:
|
if (
|
||||||
|
desired_description
|
||||||
|
and desired_description != current_panel_description
|
||||||
|
):
|
||||||
await panel_service.update_user_details_on_panel(
|
await panel_service.update_user_details_on_panel(
|
||||||
panel_uuid, {"description": description_text}
|
panel_uuid, {"description": description_text}
|
||||||
)
|
)
|
||||||
@@ -159,13 +206,13 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
# Sync subscription data
|
# Sync subscription data
|
||||||
panel_expire_at_iso = panel_user_dict.get("expireAt")
|
panel_expire_at_iso = panel_user_dict.get("expireAt")
|
||||||
panel_status = panel_user_dict.get("status", "UNKNOWN")
|
panel_status = panel_user_dict.get("status", "UNKNOWN")
|
||||||
|
|
||||||
if panel_expire_at_iso:
|
if panel_expire_at_iso:
|
||||||
try:
|
try:
|
||||||
panel_expire_at = datetime.fromisoformat(
|
panel_expire_at = datetime.fromisoformat(
|
||||||
panel_expire_at_iso.replace("Z", "+00:00")
|
panel_expire_at_iso.replace("Z", "+00:00")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Prefer syncing by concrete subscription UUID (shortUuid/subscriptionUuid)
|
# Prefer syncing by concrete subscription UUID (shortUuid/subscriptionUuid)
|
||||||
subscription_uuid_from_panel = (
|
subscription_uuid_from_panel = (
|
||||||
panel_user_dict.get("subscriptionUuid")
|
panel_user_dict.get("subscriptionUuid")
|
||||||
@@ -173,6 +220,27 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
)
|
)
|
||||||
|
|
||||||
if subscription_uuid_from_panel:
|
if subscription_uuid_from_panel:
|
||||||
|
# Если панель говорит, что подписка ACTIVE — сначала деактивируем все другие активные
|
||||||
|
if panel_status == "ACTIVE":
|
||||||
|
await session.execute(
|
||||||
|
update(Subscription)
|
||||||
|
.where(
|
||||||
|
Subscription.panel_user_uuid == panel_uuid,
|
||||||
|
Subscription.is_active.is_(True),
|
||||||
|
or_(
|
||||||
|
Subscription.panel_subscription_uuid
|
||||||
|
!= subscription_uuid_from_panel,
|
||||||
|
Subscription.panel_subscription_uuid.is_(
|
||||||
|
None
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.values(
|
||||||
|
is_active=False,
|
||||||
|
status_from_panel="INACTIVE",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Try to find subscription by its panel_subscription_uuid first (idempotent)
|
# Try to find subscription by its panel_subscription_uuid first (idempotent)
|
||||||
existing_sub_by_uuid = (
|
existing_sub_by_uuid = (
|
||||||
await subscription_dal.get_subscription_by_panel_subscription_uuid(
|
await subscription_dal.get_subscription_by_panel_subscription_uuid(
|
||||||
@@ -197,7 +265,8 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
subscriptions_updated += 1
|
subscriptions_updated += 1
|
||||||
user_was_updated = True
|
user_was_updated = True
|
||||||
logging.info(
|
logging.info(
|
||||||
f"Synced existing subscription {existing_sub_by_uuid.subscription_id} for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}"
|
f"Synced existing subscription {existing_sub_by_uuid.subscription_id} "
|
||||||
|
f"for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Create a new subscription only when we have a concrete subscription UUID
|
# Create a new subscription only when we have a concrete subscription UUID
|
||||||
@@ -212,6 +281,7 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
"is_active": panel_status == "ACTIVE",
|
"is_active": panel_status == "ACTIVE",
|
||||||
"status_from_panel": panel_status,
|
"status_from_panel": panel_status,
|
||||||
"traffic_limit_bytes": settings.user_traffic_limit_bytes,
|
"traffic_limit_bytes": settings.user_traffic_limit_bytes,
|
||||||
|
"auto_renew_enabled": False,
|
||||||
}
|
}
|
||||||
created_sub = await subscription_dal.upsert_subscription(
|
created_sub = await subscription_dal.upsert_subscription(
|
||||||
session, sub_payload
|
session, sub_payload
|
||||||
@@ -220,12 +290,15 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
subscriptions_created += 1
|
subscriptions_created += 1
|
||||||
user_was_updated = True
|
user_was_updated = True
|
||||||
logging.info(
|
logging.info(
|
||||||
f"Created subscription {created_sub.subscription_id} for user {actual_user_id} by panel_sub_uuid {subscription_uuid_from_panel}"
|
f"Created subscription {created_sub.subscription_id} "
|
||||||
|
f"for user {actual_user_id} by panel_sub_uuid {subscription_uuid_from_panel}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# No subscription UUID from panel: only update an already active subscription for this user/panel UUID
|
# No subscription UUID from panel: only update an already active subscription for this user/panel UUID
|
||||||
active_sub = await subscription_dal.get_active_subscription_by_user_id(
|
active_sub = (
|
||||||
session, actual_user_id, panel_uuid
|
await subscription_dal.get_active_subscription_by_user_id(
|
||||||
|
session, actual_user_id, panel_uuid
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if active_sub:
|
if active_sub:
|
||||||
await subscription_dal.update_subscription(
|
await subscription_dal.update_subscription(
|
||||||
@@ -241,23 +314,30 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
subscriptions_updated += 1
|
subscriptions_updated += 1
|
||||||
user_was_updated = True
|
user_was_updated = True
|
||||||
logging.info(
|
logging.info(
|
||||||
f"Updated active subscription {active_sub.subscription_id} for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}"
|
f"Updated active subscription {active_sub.subscription_id} "
|
||||||
|
f"for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Without a concrete subscription UUID we avoid creating new records to keep sync idempotent
|
# Without a concrete subscription UUID we avoid creating new records to keep sync idempotent
|
||||||
logging.debug(
|
logging.debug(
|
||||||
f"No subscriptionUuid for panel user {panel_uuid}; skipped creation for user {actual_user_id}"
|
f"No subscriptionUuid for panel user {panel_uuid}; skipped creation for user {actual_user_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
sync_errors.append(f"Error syncing subscription for user {actual_user_id}: {str(e)}")
|
sync_errors.append(
|
||||||
logging.error(f"Error syncing subscription for user {actual_user_id}: {e}")
|
f"Error syncing subscription for user {actual_user_id}: {str(e)}"
|
||||||
|
)
|
||||||
|
logging.error(
|
||||||
|
f"Error syncing subscription for user {actual_user_id}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
if user_was_updated:
|
if user_was_updated:
|
||||||
users_updated += 1
|
users_updated += 1
|
||||||
|
|
||||||
except Exception as e_user:
|
except Exception as e_user:
|
||||||
sync_errors.append(f"Error processing panel user {panel_user_dict.get('uuid', 'unknown')}: {str(e_user)}")
|
sync_errors.append(
|
||||||
|
f"Error processing panel user {panel_user_dict.get('uuid', 'unknown')}: {str(e_user)}"
|
||||||
|
)
|
||||||
logging.error(f"Error syncing user: {e_user}")
|
logging.error(f"Error syncing user: {e_user}")
|
||||||
|
|
||||||
# Update sync status
|
# Update sync status
|
||||||
@@ -266,14 +346,26 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
default_lang = settings.DEFAULT_LANGUAGE
|
default_lang = settings.DEFAULT_LANGUAGE
|
||||||
additional_stats = ""
|
additional_stats = ""
|
||||||
if users_without_telegram_id > 0:
|
if users_without_telegram_id > 0:
|
||||||
additional_stats += i18n_instance.gettext(default_lang, "admin_sync_no_telegram_id", count=users_without_telegram_id)
|
additional_stats += i18n_instance.gettext(
|
||||||
|
default_lang,
|
||||||
|
"admin_sync_no_telegram_id",
|
||||||
|
count=users_without_telegram_id,
|
||||||
|
)
|
||||||
if users_not_found_in_db > 0:
|
if users_not_found_in_db > 0:
|
||||||
additional_stats += i18n_instance.gettext(default_lang, "admin_sync_not_found_in_db", count=users_not_found_in_db)
|
additional_stats += i18n_instance.gettext(
|
||||||
|
default_lang,
|
||||||
|
"admin_sync_not_found_in_db",
|
||||||
|
count=users_not_found_in_db,
|
||||||
|
)
|
||||||
if sync_errors:
|
if sync_errors:
|
||||||
additional_stats += i18n_instance.gettext(default_lang, "admin_sync_errors", count=len(sync_errors))
|
additional_stats += i18n_instance.gettext(
|
||||||
|
default_lang, "admin_sync_errors", count=len(sync_errors)
|
||||||
|
)
|
||||||
|
|
||||||
# Build full details using localization
|
# Build full details using localization
|
||||||
details = i18n_instance.gettext(default_lang, "admin_sync_details",
|
details = i18n_instance.gettext(
|
||||||
|
default_lang,
|
||||||
|
"admin_sync_details",
|
||||||
panel_records_checked=panel_records_checked,
|
panel_records_checked=panel_records_checked,
|
||||||
users_found_in_db=users_found_in_db,
|
users_found_in_db=users_found_in_db,
|
||||||
users_created=users_created,
|
users_created=users_created,
|
||||||
@@ -281,11 +373,15 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
subscriptions_synced_count=subscriptions_synced_count,
|
subscriptions_synced_count=subscriptions_synced_count,
|
||||||
subscriptions_created=subscriptions_created,
|
subscriptions_created=subscriptions_created,
|
||||||
subscriptions_updated=subscriptions_updated,
|
subscriptions_updated=subscriptions_updated,
|
||||||
additional_stats=additional_stats
|
additional_stats=additional_stats,
|
||||||
)
|
)
|
||||||
|
|
||||||
await panel_sync_dal.update_panel_sync_status(
|
await panel_sync_dal.update_panel_sync_status(
|
||||||
session, status, details, panel_records_checked, subscriptions_synced_count
|
session,
|
||||||
|
status,
|
||||||
|
details,
|
||||||
|
panel_records_checked,
|
||||||
|
subscriptions_synced_count,
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
@@ -310,19 +406,27 @@ async def perform_sync(panel_service: PanelApiService, session: AsyncSession,
|
|||||||
"users_synced": users_found_in_db,
|
"users_synced": users_found_in_db,
|
||||||
"users_created": users_created,
|
"users_created": users_created,
|
||||||
"subs_synced": subscriptions_synced_count,
|
"subs_synced": subscriptions_synced_count,
|
||||||
"errors": sync_errors
|
"errors": sync_errors,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e_sync_global:
|
except Exception as e_sync_global:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
logging.error(f"Global error during sync: {e_sync_global}", exc_info=True)
|
logging.error(f"Global error during sync: {e_sync_global}", exc_info=True)
|
||||||
error_detail = f"Unexpected error during sync: {str(e_sync_global)}"
|
error_detail = f"Unexpected error during sync: {str(e_sync_global)}"
|
||||||
|
|
||||||
await panel_sync_dal.update_panel_sync_status(
|
await panel_sync_dal.update_panel_sync_status(
|
||||||
session, "failed", error_detail, panel_records_checked, subscriptions_synced_count
|
session,
|
||||||
|
"failed",
|
||||||
|
error_detail,
|
||||||
|
panel_records_checked,
|
||||||
|
subscriptions_synced_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"status": "failed", "details": error_detail, "errors": [str(e_sync_global)]}
|
return {
|
||||||
|
"status": "failed",
|
||||||
|
"details": error_detail,
|
||||||
|
"errors": [str(e_sync_global)],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("sync"))
|
@router.message(Command("sync"))
|
||||||
@@ -365,34 +469,40 @@ async def sync_command_handler(
|
|||||||
# Use the extracted perform_sync function
|
# Use the extracted perform_sync function
|
||||||
try:
|
try:
|
||||||
sync_result = await perform_sync(panel_service, session, settings, i18n)
|
sync_result = await perform_sync(panel_service, session, settings, i18n)
|
||||||
|
|
||||||
status = sync_result.get("status")
|
status = sync_result.get("status")
|
||||||
details = sync_result.get("details", "No details available")
|
details = sync_result.get("details", "No details available")
|
||||||
errors = sync_result.get("errors", [])
|
errors = sync_result.get("errors", [])
|
||||||
|
|
||||||
# Simple confirmation message to admin
|
# Simple confirmation message to admin
|
||||||
if status == "failed":
|
if status == "failed":
|
||||||
await bot.send_message(target_chat_id, _("sync_failed_simple"))
|
await bot.send_message(target_chat_id, _("sync_failed_simple"))
|
||||||
elif status == "completed_with_errors":
|
elif status == "completed_with_errors":
|
||||||
await bot.send_message(target_chat_id, _("sync_errors_simple", errors_count=len(errors)))
|
await bot.send_message(
|
||||||
|
target_chat_id,
|
||||||
|
_("sync_errors_simple", errors_count=len(errors)),
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
await bot.send_message(target_chat_id, _("sync_success_simple"))
|
await bot.send_message(target_chat_id, _("sync_success_simple"))
|
||||||
|
|
||||||
# Send notification to log channel with proper thread handling
|
# Send notification to log channel with proper thread handling
|
||||||
try:
|
try:
|
||||||
notification_service = NotificationService(bot, settings, i18n)
|
notification_service = NotificationService(bot, settings, i18n)
|
||||||
await notification_service.notify_panel_sync(
|
await notification_service.notify_panel_sync(
|
||||||
status, details,
|
status,
|
||||||
|
details,
|
||||||
sync_result.get("users_processed", 0),
|
sync_result.get("users_processed", 0),
|
||||||
sync_result.get("subs_synced", 0)
|
sync_result.get("subs_synced", 0),
|
||||||
)
|
)
|
||||||
except Exception as e_notification:
|
except Exception as e_notification:
|
||||||
logging.error(f"Failed to send sync notification: {e_notification}")
|
logging.error(f"Failed to send sync notification: {e_notification}")
|
||||||
|
|
||||||
except Exception as e_sync_global:
|
except Exception as e_sync_global:
|
||||||
logging.error(f"Global error during /sync command: {e_sync_global}", exc_info=True)
|
logging.error(
|
||||||
|
f"Global error during /sync command: {e_sync_global}", exc_info=True
|
||||||
|
)
|
||||||
await bot.send_message(target_chat_id, _("sync_critical_error"))
|
await bot.send_message(target_chat_id, _("sync_critical_error"))
|
||||||
|
|
||||||
# Send notification to log channel about failure
|
# Send notification to log channel about failure
|
||||||
try:
|
try:
|
||||||
notification_service = NotificationService(bot, settings, i18n)
|
notification_service = NotificationService(bot, settings, i18n)
|
||||||
@@ -400,7 +510,9 @@ async def sync_command_handler(
|
|||||||
"failed", str(e_sync_global), 0, 0
|
"failed", str(e_sync_global), 0, 0
|
||||||
)
|
)
|
||||||
except Exception as e_notification:
|
except Exception as e_notification:
|
||||||
logging.error(f"Failed to send sync failure notification: {e_notification}")
|
logging.error(
|
||||||
|
f"Failed to send sync failure notification: {e_notification}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.message(Command("syncstatus"))
|
@router.message(Command("syncstatus"))
|
||||||
@@ -419,7 +531,9 @@ async def sync_status_command_handler(
|
|||||||
if status_record_model:
|
if status_record_model:
|
||||||
last_time_val = status_record_model.last_sync_time
|
last_time_val = status_record_model.last_sync_time
|
||||||
last_time_str = (
|
last_time_str = (
|
||||||
last_time_val.strftime("%Y-%m-%d %H:%M:%S UTC") if last_time_val else "N/A"
|
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_val = status_record_model.details
|
||||||
@@ -436,4 +550,4 @@ async def sync_status_command_handler(
|
|||||||
else:
|
else:
|
||||||
response_text = _("admin_sync_status_never_run")
|
response_text = _("admin_sync_status_never_run")
|
||||||
|
|
||||||
await message.answer(response_text, parse_mode="HTML")
|
await message.answer(response_text, parse_mode="HTML")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from aiogram import Router, F, types, Bot
|
|||||||
from aiogram.exceptions import TelegramBadRequest
|
from aiogram.exceptions import TelegramBadRequest
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from aiogram.utils.markdown import hcode, hbold
|
from aiogram.utils.markdown import hcode, hbold
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any, Callable, Awaitable
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
@@ -29,14 +29,57 @@ router = Router(name="admin_user_management_router")
|
|||||||
USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
|
USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9_]{5,32}$")
|
||||||
|
|
||||||
|
|
||||||
async def user_management_menu_handler(callback: types.CallbackQuery,
|
async def users_list_handler(callback: types.CallbackQuery,
|
||||||
state: FSMContext, i18n_data: dict,
|
i18n_data: dict, settings: Settings,
|
||||||
settings: Settings, session: AsyncSession):
|
session: AsyncSession, page: int = 0):
|
||||||
"""Display user management menu"""
|
"""Display paginated list of all users"""
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
if not i18n or not callback.message:
|
if not i18n or not callback.message:
|
||||||
await callback.answer("Error preparing user management.", show_alert=True)
|
await callback.answer("Error preparing user list.", show_alert=True)
|
||||||
|
return
|
||||||
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get paginated users
|
||||||
|
from bot.keyboards.inline.admin_keyboards import get_users_list_keyboard
|
||||||
|
from db.dal import user_dal
|
||||||
|
|
||||||
|
users = await user_dal.get_all_users_paginated(session, page=page, page_size=15)
|
||||||
|
total_users = await user_dal.count_all_users(session)
|
||||||
|
total_pages = max(1, (total_users + 14) // 15)
|
||||||
|
|
||||||
|
# Format message
|
||||||
|
header_text = _(
|
||||||
|
"admin_users_list_header",
|
||||||
|
default="👥 <b>Список пользователей</b>\n\nСтраница {current}/{total} ({total_users} пользователей)",
|
||||||
|
current=page + 1,
|
||||||
|
total=total_pages,
|
||||||
|
total_users=total_users
|
||||||
|
)
|
||||||
|
|
||||||
|
keyboard = get_users_list_keyboard(users, page, total_users, i18n, current_lang, page_size=15)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
header_text,
|
||||||
|
reply_markup=keyboard,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error displaying user list: {e}")
|
||||||
|
await callback.answer("Ошибка отображения списка пользователей", show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def user_search_prompt_handler(callback: types.CallbackQuery,
|
||||||
|
state: FSMContext, i18n_data: dict,
|
||||||
|
settings: Settings, session: AsyncSession):
|
||||||
|
"""Display search prompt for user management"""
|
||||||
|
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 preparing search.", show_alert=True)
|
||||||
return
|
return
|
||||||
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
|
||||||
@@ -61,7 +104,8 @@ async def user_management_menu_handler(callback: types.CallbackQuery,
|
|||||||
await state.set_state(AdminStates.waiting_for_user_search)
|
await state.set_state(AdminStates.waiting_for_user_search)
|
||||||
|
|
||||||
|
|
||||||
def get_user_card_keyboard(user_id: int, i18n_instance, lang: str) -> InlineKeyboardBuilder:
|
def get_user_card_keyboard(user_id: int, i18n_instance, lang: str,
|
||||||
|
referrer_id: Optional[int] = None) -> InlineKeyboardBuilder:
|
||||||
"""Generate keyboard for user management actions"""
|
"""Generate keyboard for user management actions"""
|
||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
@@ -95,8 +139,27 @@ def get_user_card_keyboard(user_id: int, i18n_instance, lang: str) -> InlineKeyb
|
|||||||
text=_(key="admin_user_refresh_button", default="🔄 Обновить"),
|
text=_(key="admin_user_refresh_button", default="🔄 Обновить"),
|
||||||
callback_data=f"user_action:refresh:{user_id}"
|
callback_data=f"user_action:refresh:{user_id}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Row 4: Quick links
|
||||||
|
builder.button(
|
||||||
|
text=_(key="user_card_open_profile_button",
|
||||||
|
default="👤 Открыть профиль"),
|
||||||
|
url=f"tg://user?id={user_id}"
|
||||||
|
)
|
||||||
|
if referrer_id:
|
||||||
|
builder.button(
|
||||||
|
text=_(key="user_card_open_referrer_profile_button",
|
||||||
|
default="👤 Открыть профиль пригласившего"),
|
||||||
|
url=f"tg://user?id={referrer_id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Row 5: Destructive action
|
||||||
|
builder.button(
|
||||||
|
text=_(key="admin_user_delete_button", default="❌ Удалить пользователя"),
|
||||||
|
callback_data=f"user_action:delete_user:{user_id}"
|
||||||
|
)
|
||||||
|
|
||||||
# Row 4: Back button
|
# Row 6: Navigation
|
||||||
builder.button(
|
builder.button(
|
||||||
text=_(key="admin_user_search_new_button", default="🔍 Найти другого"),
|
text=_(key="admin_user_search_new_button", default="🔍 Найти другого"),
|
||||||
callback_data="admin_action:users_management"
|
callback_data="admin_action:users_management"
|
||||||
@@ -106,10 +169,61 @@ def get_user_card_keyboard(user_id: int, i18n_instance, lang: str) -> InlineKeyb
|
|||||||
callback_data="admin_action:main"
|
callback_data="admin_action:main"
|
||||||
)
|
)
|
||||||
|
|
||||||
builder.adjust(2, 2, 2, 2)
|
quick_links_width = 2 if referrer_id else 1
|
||||||
|
builder.adjust(2, 2, 2, quick_links_width, 1, 2)
|
||||||
return builder
|
return builder
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_profile_link_buttons(
|
||||||
|
markup: Optional[types.InlineKeyboardMarkup]) -> Optional[types.InlineKeyboardMarkup]:
|
||||||
|
"""Drop buttons that rely on tg://user links to avoid BUTTON_USER_INVALID errors."""
|
||||||
|
if not markup or not markup.inline_keyboard:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cleaned_rows = []
|
||||||
|
for row in markup.inline_keyboard:
|
||||||
|
filtered_row = [
|
||||||
|
button for button in row
|
||||||
|
if not (getattr(button, "url", None) and button.url.startswith("tg://user?id="))
|
||||||
|
]
|
||||||
|
if filtered_row:
|
||||||
|
cleaned_rows.append(filtered_row)
|
||||||
|
|
||||||
|
if not cleaned_rows:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return types.InlineKeyboardMarkup(inline_keyboard=cleaned_rows)
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_with_profile_link_fallback(
|
||||||
|
sender: Callable[..., Awaitable[Any]],
|
||||||
|
*,
|
||||||
|
text: str,
|
||||||
|
markup: Optional[types.InlineKeyboardMarkup],
|
||||||
|
user_id: int,
|
||||||
|
parse_mode: Optional[str] = "HTML") -> None:
|
||||||
|
"""Send text with markup and fallback if Telegram rejects tg://user buttons."""
|
||||||
|
send_kwargs: Dict[str, Any] = {"text": text, "reply_markup": markup}
|
||||||
|
if parse_mode is not None:
|
||||||
|
send_kwargs["parse_mode"] = parse_mode
|
||||||
|
|
||||||
|
try:
|
||||||
|
await sender(**send_kwargs)
|
||||||
|
except TelegramBadRequest as exc:
|
||||||
|
message = getattr(exc, "message", "") or str(exc)
|
||||||
|
if "BUTTON_USER_INVALID" not in message:
|
||||||
|
raise
|
||||||
|
|
||||||
|
logging.warning(
|
||||||
|
"Telegram rejected profile buttons for user %s: %s. Retrying without tg:// links.",
|
||||||
|
user_id,
|
||||||
|
message,
|
||||||
|
)
|
||||||
|
fallback_markup = _remove_profile_link_buttons(markup)
|
||||||
|
send_kwargs["reply_markup"] = fallback_markup
|
||||||
|
await sender(**send_kwargs)
|
||||||
|
|
||||||
|
|
||||||
async def format_user_card(user: User, session: AsyncSession,
|
async def format_user_card(user: User, session: AsyncSession,
|
||||||
subscription_service: SubscriptionService,
|
subscription_service: SubscriptionService,
|
||||||
i18n_instance, lang: str,
|
i18n_instance, lang: str,
|
||||||
@@ -266,11 +380,18 @@ async def process_user_search_handler(message: types.Message, state: FSMContext,
|
|||||||
try:
|
try:
|
||||||
referral_service = ReferralService(settings, subscription_service, message.bot, i18n)
|
referral_service = ReferralService(settings, subscription_service, message.bot, i18n)
|
||||||
user_card_text = await format_user_card(user_model, session, subscription_service, i18n, current_lang, referral_service)
|
user_card_text = await format_user_card(user_model, session, subscription_service, i18n, current_lang, referral_service)
|
||||||
keyboard = get_user_card_keyboard(user_model.user_id, i18n, current_lang)
|
keyboard = get_user_card_keyboard(
|
||||||
|
user_model.user_id,
|
||||||
|
i18n,
|
||||||
|
current_lang,
|
||||||
|
user_model.referred_by_id
|
||||||
|
)
|
||||||
|
|
||||||
await message.answer(
|
await _send_with_profile_link_fallback(
|
||||||
user_card_text,
|
message.answer,
|
||||||
reply_markup=keyboard.as_markup(),
|
text=user_card_text,
|
||||||
|
markup=keyboard.as_markup(),
|
||||||
|
user_id=user_model.user_id,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -324,6 +445,10 @@ async def user_action_handler(callback: types.CallbackQuery, state: FSMContext,
|
|||||||
await handle_view_user_logs(callback, user, session, settings, i18n, current_lang)
|
await handle_view_user_logs(callback, user, session, settings, i18n, current_lang)
|
||||||
elif action == "refresh":
|
elif action == "refresh":
|
||||||
await handle_refresh_user_card(callback, user, subscription_service, session, i18n, current_lang)
|
await handle_refresh_user_card(callback, user, subscription_service, session, i18n, current_lang)
|
||||||
|
elif action == "delete_user":
|
||||||
|
await handle_delete_user_prompt(
|
||||||
|
callback, state, user, settings, i18n, current_lang, session
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
await callback.answer(_("admin_unknown_action"), show_alert=True)
|
await callback.answer(_("admin_unknown_action"), show_alert=True)
|
||||||
|
|
||||||
@@ -527,18 +652,28 @@ async def handle_refresh_user_card(callback: types.CallbackQuery, user: User,
|
|||||||
_settings = _Settings()
|
_settings = _Settings()
|
||||||
referral_service = ReferralService(_settings, subscription_service, callback.message.bot, i18n_instance)
|
referral_service = ReferralService(_settings, subscription_service, callback.message.bot, i18n_instance)
|
||||||
user_card_text = await format_user_card(fresh_user, session, subscription_service, i18n_instance, lang, referral_service)
|
user_card_text = await format_user_card(fresh_user, session, subscription_service, i18n_instance, lang, referral_service)
|
||||||
keyboard = get_user_card_keyboard(fresh_user.user_id, i18n_instance, lang)
|
keyboard = get_user_card_keyboard(
|
||||||
|
fresh_user.user_id,
|
||||||
|
i18n_instance,
|
||||||
|
lang,
|
||||||
|
fresh_user.referred_by_id
|
||||||
|
)
|
||||||
|
markup = keyboard.as_markup()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await callback.message.edit_text(
|
await _send_with_profile_link_fallback(
|
||||||
user_card_text,
|
callback.message.edit_text,
|
||||||
reply_markup=keyboard.as_markup(),
|
text=user_card_text,
|
||||||
|
markup=markup,
|
||||||
|
user_id=fresh_user.user_id,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
await callback.message.answer(
|
await _send_with_profile_link_fallback(
|
||||||
user_card_text,
|
callback.message.answer,
|
||||||
reply_markup=keyboard.as_markup(),
|
text=user_card_text,
|
||||||
|
markup=markup,
|
||||||
|
user_id=fresh_user.user_id,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -549,8 +684,217 @@ async def handle_refresh_user_card(callback: types.CallbackQuery, user: User,
|
|||||||
await callback.answer("Error refreshing user card", show_alert=True)
|
await callback.answer("Error refreshing user card", show_alert=True)
|
||||||
|
|
||||||
|
|
||||||
|
# Destructive deletion flow
|
||||||
|
async def handle_delete_user_prompt(callback: types.CallbackQuery, state: FSMContext,
|
||||||
|
user: User, settings: Settings, i18n_instance,
|
||||||
|
lang: str, session: AsyncSession):
|
||||||
|
"""Trigger confirmation workflow for destructive deletion."""
|
||||||
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
|
|
||||||
|
admin = callback.from_user
|
||||||
|
admin_id = admin.id if admin else None
|
||||||
|
if not admin_id or admin_id not in settings.ADMIN_IDS:
|
||||||
|
logging.warning(
|
||||||
|
f"Unauthorized delete attempt by user {admin_id} targeting {user.user_id}."
|
||||||
|
)
|
||||||
|
await callback.answer(
|
||||||
|
_(
|
||||||
|
"admin_user_delete_not_allowed",
|
||||||
|
default="❌ У вас нет прав для удаления пользователей.",
|
||||||
|
),
|
||||||
|
show_alert=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
await state.update_data(
|
||||||
|
target_user_id=user.user_id,
|
||||||
|
delete_initiator_id=admin_id,
|
||||||
|
)
|
||||||
|
await state.set_state(AdminStates.waiting_for_user_delete_confirmation)
|
||||||
|
|
||||||
|
prompt_text = _(
|
||||||
|
"admin_user_delete_confirmation_prompt",
|
||||||
|
default=(
|
||||||
|
"⚠️ Вы хотите полностью удалить пользователя {user_id}.\n\n"
|
||||||
|
"Отправьте точный Telegram ID этого пользователя, чтобы подтвердить удаление.\n"
|
||||||
|
"Любой другой ответ отменит операцию."
|
||||||
|
),
|
||||||
|
user_id=hcode(str(user.user_id)),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await callback.message.answer(prompt_text, parse_mode="HTML")
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(
|
||||||
|
f"Failed to send delete confirmation prompt for user {user.user_id}: {e}"
|
||||||
|
)
|
||||||
|
await callback.message.reply(prompt_text, parse_mode="HTML")
|
||||||
|
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
|
||||||
|
async def _log_admin_user_deletion(
|
||||||
|
session: AsyncSession,
|
||||||
|
admin_id: int,
|
||||||
|
admin_user: Optional[types.User],
|
||||||
|
target_user_id: int,
|
||||||
|
) -> None:
|
||||||
|
"""Store audit log for successful deletion."""
|
||||||
|
try:
|
||||||
|
await message_log_dal.create_message_log_no_commit(
|
||||||
|
session,
|
||||||
|
{
|
||||||
|
"user_id": admin_id,
|
||||||
|
"telegram_username": admin_user.username if admin_user else None,
|
||||||
|
"telegram_first_name": admin_user.first_name if admin_user else None,
|
||||||
|
"event_type": "admin:user_deleted",
|
||||||
|
"content": f"Admin {admin_id} deleted user {target_user_id}",
|
||||||
|
"raw_update_preview": None,
|
||||||
|
"is_admin_event": True,
|
||||||
|
"target_user_id": target_user_id,
|
||||||
|
"timestamp": datetime.now(timezone.utc),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(
|
||||||
|
f"Failed to log deletion audit for admin {admin_id} -> user {target_user_id}: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# Message handlers for state-based inputs
|
# Message handlers for state-based inputs
|
||||||
|
|
||||||
|
@router.message(AdminStates.waiting_for_user_delete_confirmation, F.text)
|
||||||
|
async def process_delete_user_confirmation_handler(message: types.Message,
|
||||||
|
state: FSMContext,
|
||||||
|
settings: Settings,
|
||||||
|
i18n_data: dict,
|
||||||
|
panel_service: PanelApiService,
|
||||||
|
session: AsyncSession):
|
||||||
|
"""Confirm and execute destructive user deletion."""
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
if not i18n:
|
||||||
|
await message.reply("Language service error.")
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
|
||||||
|
admin = message.from_user
|
||||||
|
admin_id = admin.id if admin else None
|
||||||
|
if not admin_id or admin_id not in settings.ADMIN_IDS:
|
||||||
|
logging.warning(
|
||||||
|
f"Unauthorized delete confirmation attempt by user {admin_id}."
|
||||||
|
)
|
||||||
|
await message.answer(
|
||||||
|
_(
|
||||||
|
"admin_user_delete_not_allowed",
|
||||||
|
default="❌ У вас нет прав для удаления пользователей.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
data = await state.get_data()
|
||||||
|
target_user_id = data.get("target_user_id")
|
||||||
|
if not target_user_id:
|
||||||
|
await message.answer(
|
||||||
|
_(
|
||||||
|
"admin_user_delete_state_missing",
|
||||||
|
default="⚠️ Нет активной операции удаления. Начните заново.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
confirmation_input = message.text.strip() if message.text else ""
|
||||||
|
if confirmation_input.lower() in {"/cancel", "cancel", "отмена"}:
|
||||||
|
await message.answer(
|
||||||
|
_(
|
||||||
|
"admin_user_delete_cancelled",
|
||||||
|
default="Операция удаления отменена по запросу.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
if confirmation_input != str(target_user_id):
|
||||||
|
await message.answer(
|
||||||
|
_(
|
||||||
|
"admin_user_delete_mismatch",
|
||||||
|
default="⚠️ ID не совпадает. Удаление отменено.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
user_model = await user_dal.get_user_by_id(session, target_user_id)
|
||||||
|
if not user_model:
|
||||||
|
await message.answer(
|
||||||
|
_(
|
||||||
|
"admin_user_delete_already_removed",
|
||||||
|
default="ℹ️ Пользователь уже удален.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if user_model.panel_user_uuid:
|
||||||
|
panel_deleted = await panel_service.delete_user_from_panel(
|
||||||
|
user_model.panel_user_uuid
|
||||||
|
)
|
||||||
|
if not panel_deleted:
|
||||||
|
await message.answer(
|
||||||
|
_(
|
||||||
|
"admin_user_delete_panel_error",
|
||||||
|
default=(
|
||||||
|
"❌ Не удалось удалить пользователя на панели. "
|
||||||
|
"Операция прервана."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.rollback()
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
deleted = await user_dal.delete_user_and_relations(
|
||||||
|
session, target_user_id
|
||||||
|
)
|
||||||
|
if not deleted:
|
||||||
|
await message.answer(
|
||||||
|
_(
|
||||||
|
"admin_user_delete_already_removed",
|
||||||
|
default="ℹ️ Пользователь уже удален.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await state.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
await _log_admin_user_deletion(session, admin_id, admin, target_user_id)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
await message.answer(
|
||||||
|
_(
|
||||||
|
"admin_user_delete_success",
|
||||||
|
default="✅ Пользователь {user_id} удален из бота и панели.",
|
||||||
|
user_id=hcode(str(target_user_id)),
|
||||||
|
),
|
||||||
|
parse_mode="HTML",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error deleting user {target_user_id}: {e}", exc_info=True)
|
||||||
|
await session.rollback()
|
||||||
|
await message.answer(
|
||||||
|
_(
|
||||||
|
"admin_user_delete_error",
|
||||||
|
default="❌ Не удалось завершить удаление пользователя. Попробуйте позже.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
@router.message(AdminStates.waiting_for_subscription_days_to_add, F.text)
|
@router.message(AdminStates.waiting_for_subscription_days_to_add, F.text)
|
||||||
async def process_subscription_days_handler(message: types.Message, state: FSMContext,
|
async def process_subscription_days_handler(message: types.Message, state: FSMContext,
|
||||||
settings: Settings, i18n_data: dict,
|
settings: Settings, i18n_data: dict,
|
||||||
@@ -602,11 +946,18 @@ async def process_subscription_days_handler(message: types.Message, state: FSMCo
|
|||||||
if user:
|
if user:
|
||||||
referral_service = ReferralService(settings, subscription_service, message.bot, i18n)
|
referral_service = ReferralService(settings, subscription_service, message.bot, i18n)
|
||||||
user_card_text = await format_user_card(user, session, subscription_service, i18n, current_lang, referral_service)
|
user_card_text = await format_user_card(user, session, subscription_service, i18n, current_lang, referral_service)
|
||||||
keyboard = get_user_card_keyboard(user.user_id, i18n, current_lang)
|
keyboard = get_user_card_keyboard(
|
||||||
|
user.user_id,
|
||||||
|
i18n,
|
||||||
|
current_lang,
|
||||||
|
user.referred_by_id
|
||||||
|
)
|
||||||
|
|
||||||
await message.answer(
|
await _send_with_profile_link_fallback(
|
||||||
user_card_text,
|
message.answer,
|
||||||
reply_markup=keyboard.as_markup(),
|
text=user_card_text,
|
||||||
|
markup=keyboard.as_markup(),
|
||||||
|
user_id=user.user_id,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
@@ -711,11 +1062,18 @@ async def process_direct_message_handler(message: types.Message, state: FSMConte
|
|||||||
subscription_service = SubscriptionService(settings, panel_service)
|
subscription_service = SubscriptionService(settings, panel_service)
|
||||||
referral_service = ReferralService(settings, subscription_service, bot, i18n)
|
referral_service = ReferralService(settings, subscription_service, bot, i18n)
|
||||||
user_card_text = await format_user_card(target_user, session, subscription_service, i18n, current_lang, referral_service)
|
user_card_text = await format_user_card(target_user, session, subscription_service, i18n, current_lang, referral_service)
|
||||||
keyboard = get_user_card_keyboard(target_user.user_id, i18n, current_lang)
|
keyboard = get_user_card_keyboard(
|
||||||
|
target_user.user_id,
|
||||||
|
i18n,
|
||||||
|
current_lang,
|
||||||
|
target_user.referred_by_id
|
||||||
|
)
|
||||||
|
|
||||||
await message.answer(
|
await _send_with_profile_link_fallback(
|
||||||
user_card_text,
|
message.answer,
|
||||||
reply_markup=keyboard.as_markup(),
|
text=user_card_text,
|
||||||
|
markup=keyboard.as_markup(),
|
||||||
|
user_id=target_user.user_id,
|
||||||
parse_mode="HTML"
|
parse_mode="HTML"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -911,9 +1269,9 @@ async def process_ban_user_handler(message: types.Message, state: FSMContext,
|
|||||||
|
|
||||||
@router.message(AdminStates.waiting_for_user_id_to_unban, F.text)
|
@router.message(AdminStates.waiting_for_user_id_to_unban, F.text)
|
||||||
async def process_unban_user_handler(message: types.Message, state: FSMContext,
|
async def process_unban_user_handler(message: types.Message, state: FSMContext,
|
||||||
settings: Settings, i18n_data: dict,
|
settings: Settings, i18n_data: dict,
|
||||||
panel_service: PanelApiService,
|
panel_service: PanelApiService,
|
||||||
session: AsyncSession):
|
session: AsyncSession):
|
||||||
"""Process user unban input"""
|
"""Process user unban input"""
|
||||||
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
@@ -978,3 +1336,67 @@ async def process_unban_user_handler(message: types.Message, state: FSMContext,
|
|||||||
))
|
))
|
||||||
|
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@router.callback_query(F.data.startswith("admin_user_card_from_list:"))
|
||||||
|
async def user_card_from_list_handler(callback: types.CallbackQuery,
|
||||||
|
state: FSMContext, i18n_data: dict,
|
||||||
|
settings: Settings, bot: Bot,
|
||||||
|
subscription_service: SubscriptionService,
|
||||||
|
panel_service: PanelApiService,
|
||||||
|
session: AsyncSession):
|
||||||
|
"""Display user card when clicked from user list"""
|
||||||
|
try:
|
||||||
|
parts = callback.data.split(":")
|
||||||
|
user_id = int(parts[1])
|
||||||
|
page = int(parts[2])
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
await callback.answer("Invalid user data", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
||||||
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
||||||
|
if not i18n:
|
||||||
|
await callback.answer("Language service error", show_alert=True)
|
||||||
|
return
|
||||||
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
|
||||||
|
|
||||||
|
# Get user from database
|
||||||
|
user = await user_dal.get_user_by_id(session, user_id)
|
||||||
|
if not user:
|
||||||
|
await callback.answer("User not found", show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create keyboard with back to list button
|
||||||
|
keyboard = get_user_card_keyboard(
|
||||||
|
user_id,
|
||||||
|
i18n,
|
||||||
|
current_lang,
|
||||||
|
user.referred_by_id
|
||||||
|
)
|
||||||
|
keyboard.button(
|
||||||
|
text=_("admin_user_back_to_list_button", default="⬅️ К списку"),
|
||||||
|
callback_data=f"admin_action:users_list:{page}"
|
||||||
|
)
|
||||||
|
quick_links_width = 2 if user.referred_by_id else 1
|
||||||
|
keyboard.adjust(2, 2, 2, quick_links_width, 1, 2, 1)
|
||||||
|
|
||||||
|
# Format user card
|
||||||
|
try:
|
||||||
|
from bot.services.referral_service import ReferralService
|
||||||
|
referral_service = ReferralService(settings, subscription_service, bot, i18n)
|
||||||
|
user_card_text = await format_user_card(user, session, subscription_service, i18n, current_lang, referral_service)
|
||||||
|
markup = keyboard.as_markup()
|
||||||
|
|
||||||
|
await _send_with_profile_link_fallback(
|
||||||
|
callback.message.edit_text,
|
||||||
|
text=user_card_text,
|
||||||
|
markup=markup,
|
||||||
|
user_id=user.user_id,
|
||||||
|
parse_mode="HTML"
|
||||||
|
)
|
||||||
|
await callback.answer()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logging.error(f"Error displaying user card: {e}")
|
||||||
|
await callback.answer("Error displaying user card", show_alert=True)
|
||||||
|
|||||||
@@ -38,7 +38,13 @@ async def inline_query_handler(inline_query: InlineQuery,
|
|||||||
# For all users: referral functionality
|
# For all users: referral functionality
|
||||||
if not query or "реф" in query or "ref" in query or "друг" in query or "friend" in query:
|
if not query or "реф" in query or "ref" in query or "друг" in query or "friend" in query:
|
||||||
referral_result = await create_referral_result(
|
referral_result = await create_referral_result(
|
||||||
inline_query, bot, referral_service, i18n, current_lang, settings
|
inline_query,
|
||||||
|
bot,
|
||||||
|
referral_service,
|
||||||
|
i18n,
|
||||||
|
current_lang,
|
||||||
|
settings,
|
||||||
|
session,
|
||||||
)
|
)
|
||||||
if referral_result:
|
if referral_result:
|
||||||
results.append(referral_result)
|
results.append(referral_result)
|
||||||
@@ -67,9 +73,15 @@ async def inline_query_handler(inline_query: InlineQuery,
|
|||||||
await inline_query.answer(results=[], cache_time=10)
|
await inline_query.answer(results=[], cache_time=10)
|
||||||
|
|
||||||
|
|
||||||
async def create_referral_result(inline_query: InlineQuery, bot: Bot,
|
async def create_referral_result(
|
||||||
referral_service: ReferralService,
|
inline_query: InlineQuery,
|
||||||
i18n_instance, lang: str, settings: Settings) -> Optional[InlineQueryResultArticle]:
|
bot: Bot,
|
||||||
|
referral_service: ReferralService,
|
||||||
|
i18n_instance,
|
||||||
|
lang: str,
|
||||||
|
settings: Settings,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> Optional[InlineQueryResultArticle]:
|
||||||
"""Create referral link result for inline query"""
|
"""Create referral link result for inline query"""
|
||||||
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
|
|
||||||
@@ -80,7 +92,13 @@ async def create_referral_result(inline_query: InlineQuery, bot: Bot,
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
user_id = inline_query.from_user.id
|
user_id = inline_query.from_user.id
|
||||||
referral_link = referral_service.generate_referral_link(bot_username, user_id)
|
referral_link = await referral_service.generate_referral_link(
|
||||||
|
session, bot_username, user_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not referral_link:
|
||||||
|
logging.warning("Could not produce referral link for inline user %s", user_id)
|
||||||
|
return None
|
||||||
|
|
||||||
# Create message content (use same text as friend message)
|
# Create message content (use same text as friend message)
|
||||||
message_text = _(
|
message_text = _(
|
||||||
|
|||||||
@@ -170,6 +170,18 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
card_last4=display_last4,
|
card_last4=display_last4,
|
||||||
card_network=display_network,
|
card_network=display_network,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
await user_billing_dal.upsert_user_payment_method(
|
||||||
|
session,
|
||||||
|
user_id=user_id,
|
||||||
|
provider_payment_method_id=pm_id,
|
||||||
|
provider="yookassa",
|
||||||
|
card_last4=display_last4,
|
||||||
|
card_network=display_network,
|
||||||
|
set_default=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Failed to persist multi-card YooKassa method from webhook")
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.exception("Failed to persist YooKassa payment method from webhook")
|
logging.exception("Failed to persist YooKassa payment method from webhook")
|
||||||
updated_payment_record = await payment_dal.update_payment_status_by_db_id(
|
updated_payment_record = await payment_dal.update_payment_status_by_db_id(
|
||||||
|
|||||||
@@ -60,8 +60,18 @@ async def referral_command_handler(event: Union[types.Message,
|
|||||||
return
|
return
|
||||||
|
|
||||||
inviter_user_id = event.from_user.id
|
inviter_user_id = event.from_user.id
|
||||||
referral_link = referral_service.generate_referral_link(
|
referral_link = await referral_service.generate_referral_link(
|
||||||
bot_username, inviter_user_id)
|
session, bot_username, inviter_user_id)
|
||||||
|
|
||||||
|
if not referral_link:
|
||||||
|
logging.error(
|
||||||
|
"Failed to generate referral link for user %s (probably missing DB record).",
|
||||||
|
inviter_user_id,
|
||||||
|
)
|
||||||
|
await target_message_obj.answer(_("error_generating_referral_link"))
|
||||||
|
if isinstance(event, types.CallbackQuery):
|
||||||
|
await event.answer()
|
||||||
|
return
|
||||||
|
|
||||||
bonus_info_parts = []
|
bonus_info_parts = []
|
||||||
if settings.subscription_options:
|
if settings.subscription_options:
|
||||||
@@ -132,7 +142,16 @@ async def referral_action_handler(callback: types.CallbackQuery, settings: Setti
|
|||||||
return
|
return
|
||||||
|
|
||||||
inviter_user_id = callback.from_user.id
|
inviter_user_id = callback.from_user.id
|
||||||
referral_link = referral_service.generate_referral_link(bot_username, inviter_user_id)
|
referral_link = await referral_service.generate_referral_link(
|
||||||
|
session, bot_username, inviter_user_id)
|
||||||
|
|
||||||
|
if not referral_link:
|
||||||
|
logging.error(
|
||||||
|
"Failed to generate referral link for user %s via inline button.",
|
||||||
|
inviter_user_id,
|
||||||
|
)
|
||||||
|
await callback.answer(_("error_generating_referral_link"), show_alert=True)
|
||||||
|
return
|
||||||
|
|
||||||
friend_message = _("referral_friend_message", referral_link=referral_link)
|
friend_message = _("referral_friend_message", referral_link=referral_link)
|
||||||
|
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ async def ensure_required_channel_subscription(
|
|||||||
|
|
||||||
|
|
||||||
@router.message(CommandStart())
|
@router.message(CommandStart())
|
||||||
@router.message(CommandStart(magic=F.args.regexp(r"^ref_(\d+)$").as_("ref_match")))
|
@router.message(CommandStart(magic=F.args.regexp(r"^ref_((?:[uU][A-Za-z0-9]{9})|(?:[A-Za-z0-9]{9})|\d+)$").as_("ref_match")))
|
||||||
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
|
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
|
||||||
@router.message(CommandStart(magic=F.args.regexp(r"^(?!ref_|promo_)([A-Za-z0-9_\-]{2,64})$").as_("ad_param_match")))
|
@router.message(CommandStart(magic=F.args.regexp(r"^(?!ref_|promo_)([A-Za-z0-9_\-]{2,64})$").as_("ad_param_match")))
|
||||||
async def start_command_handler(message: types.Message,
|
async def start_command_handler(message: types.Message,
|
||||||
@@ -328,9 +328,23 @@ async def start_command_handler(message: types.Message,
|
|||||||
ad_start_param: Optional[str] = None
|
ad_start_param: Optional[str] = None
|
||||||
|
|
||||||
if ref_match:
|
if ref_match:
|
||||||
potential_referrer_id = int(ref_match.group(1))
|
raw_ref_value = ref_match.group(1)
|
||||||
if await user_dal.get_user_by_id(session, potential_referrer_id):
|
if raw_ref_value.isdigit():
|
||||||
referred_by_user_id = potential_referrer_id
|
if settings.LEGACY_REFS:
|
||||||
|
potential_referrer_id = int(raw_ref_value)
|
||||||
|
if potential_referrer_id != user_id and await user_dal.get_user_by_id(
|
||||||
|
session, potential_referrer_id):
|
||||||
|
referred_by_user_id = potential_referrer_id
|
||||||
|
else:
|
||||||
|
normalized_code = raw_ref_value.strip()
|
||||||
|
if normalized_code and normalized_code[0].lower() == "u":
|
||||||
|
normalized_code = normalized_code[1:]
|
||||||
|
ref_user = None
|
||||||
|
if normalized_code:
|
||||||
|
ref_user = await user_dal.get_user_by_referral_code(
|
||||||
|
session, normalized_code)
|
||||||
|
if ref_user and ref_user.user_id != user_id:
|
||||||
|
referred_by_user_id = ref_user.user_id
|
||||||
elif promo_match:
|
elif promo_match:
|
||||||
promo_code_to_apply = promo_match.group(1)
|
promo_code_to_apply = promo_match.group(1)
|
||||||
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
|
logging.info(f"User {user_id} started with promo code: {promo_code_to_apply}")
|
||||||
@@ -357,6 +371,17 @@ async def start_command_handler(message: types.Message,
|
|||||||
db_user, created = await user_dal.create_user(session, user_data_to_create)
|
db_user, created = await user_dal.create_user(session, user_data_to_create)
|
||||||
|
|
||||||
if created:
|
if created:
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
except Exception as commit_error:
|
||||||
|
await session.rollback()
|
||||||
|
logging.error(
|
||||||
|
f"Failed to commit new user {user_id}: {commit_error}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
await message.answer(_("error_occurred_processing_request"))
|
||||||
|
return
|
||||||
|
|
||||||
logging.info(
|
logging.info(
|
||||||
f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}."
|
f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}."
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from bot.keyboards.inline.user_keyboards import (
|
|||||||
from bot.services.subscription_service import SubscriptionService
|
from bot.services.subscription_service import SubscriptionService
|
||||||
from bot.services.panel_api_service import PanelApiService
|
from bot.services.panel_api_service import PanelApiService
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
from db.dal import subscription_dal
|
from db.dal import subscription_dal, user_billing_dal
|
||||||
from db.models import Subscription
|
from db.models import Subscription
|
||||||
|
|
||||||
router = Router(name="user_subscription_core_router")
|
router = Router(name="user_subscription_core_router")
|
||||||
@@ -455,6 +455,14 @@ async def toggle_autorenew_handler(
|
|||||||
if sub.provider == "tribute":
|
if sub.provider == "tribute":
|
||||||
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
|
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
|
||||||
return
|
return
|
||||||
|
if enable:
|
||||||
|
has_saved_card = await user_billing_dal.user_has_saved_payment_method(session, callback.from_user.id)
|
||||||
|
if not has_saved_card:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("autorenew_enable_requires_card"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
# Show confirmation popup and inline buttons
|
# Show confirmation popup and inline buttons
|
||||||
confirm_text = get_text("autorenew_confirm_enable") if enable else get_text("autorenew_confirm_disable")
|
confirm_text = get_text("autorenew_confirm_enable") if enable else get_text("autorenew_confirm_disable")
|
||||||
@@ -505,6 +513,18 @@ async def confirm_autorenew_handler(
|
|||||||
if sub.provider == "tribute":
|
if sub.provider == "tribute":
|
||||||
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
|
await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True)
|
||||||
return
|
return
|
||||||
|
if enable:
|
||||||
|
has_saved_card = await user_billing_dal.user_has_saved_payment_method(session, callback.from_user.id)
|
||||||
|
if not has_saved_card:
|
||||||
|
try:
|
||||||
|
await callback.answer(get_text("autorenew_enable_requires_card"), show_alert=True)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return
|
||||||
|
|
||||||
await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": enable})
|
await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": enable})
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@@ -408,6 +408,9 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
|
|||||||
user_id = callback.from_user.id
|
user_id = callback.from_user.id
|
||||||
currency_code_for_yk = "RUB"
|
currency_code_for_yk = "RUB"
|
||||||
autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False))
|
autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False))
|
||||||
|
autopay_require_binding = bool(
|
||||||
|
getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True)
|
||||||
|
)
|
||||||
saved_methods: List = []
|
saved_methods: List = []
|
||||||
if autopay_enabled:
|
if autopay_enabled:
|
||||||
try:
|
try:
|
||||||
@@ -463,7 +466,7 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
|
|||||||
months=months,
|
months=months,
|
||||||
price_rub=price_rub,
|
price_rub=price_rub,
|
||||||
currency_code_for_yk=currency_code_for_yk,
|
currency_code_for_yk=currency_code_for_yk,
|
||||||
save_payment_method=autopay_enabled,
|
save_payment_method=autopay_enabled and autopay_require_binding,
|
||||||
back_callback=f"subscribe_period:{months}",
|
back_callback=f"subscribe_period:{months}",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
@@ -520,6 +523,9 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti
|
|||||||
user_id = callback.from_user.id
|
user_id = callback.from_user.id
|
||||||
currency_code_for_yk = "RUB"
|
currency_code_for_yk = "RUB"
|
||||||
autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False))
|
autopay_enabled = bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False))
|
||||||
|
autopay_require_binding = bool(
|
||||||
|
getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True)
|
||||||
|
)
|
||||||
|
|
||||||
await _initiate_yk_payment(
|
await _initiate_yk_payment(
|
||||||
callback,
|
callback,
|
||||||
@@ -533,7 +539,7 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti
|
|||||||
months=months,
|
months=months,
|
||||||
price_rub=price_rub,
|
price_rub=price_rub,
|
||||||
currency_code_for_yk=currency_code_for_yk,
|
currency_code_for_yk=currency_code_for_yk,
|
||||||
save_payment_method=autopay_enabled,
|
save_payment_method=autopay_enabled and autopay_require_binding,
|
||||||
back_callback=f"subscribe_period:{months}",
|
back_callback=f"subscribe_period:{months}",
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -59,13 +59,15 @@ def get_user_management_keyboard(i18n_instance, lang: str) -> InlineKeyboardMark
|
|||||||
builder = InlineKeyboardBuilder()
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
builder.button(text=_(key="admin_users_management_button"),
|
builder.button(text=_(key="admin_users_management_button"),
|
||||||
callback_data="admin_action:users_management")
|
callback_data="admin_action:users_list:0")
|
||||||
|
builder.button(text=_(key="admin_users_search_button"),
|
||||||
|
callback_data="admin_action:users_search_prompt")
|
||||||
builder.button(text=_(key="admin_ban_management_section"),
|
builder.button(text=_(key="admin_ban_management_section"),
|
||||||
callback_data="admin_section:ban_management")
|
callback_data="admin_section:ban_management")
|
||||||
|
|
||||||
builder.button(text=_(key="back_to_admin_panel_button"),
|
builder.button(text=_(key="back_to_admin_panel_button"),
|
||||||
callback_data="admin_action:main")
|
callback_data="admin_action:main")
|
||||||
builder.adjust(2, 1)
|
builder.adjust(2, 1, 1)
|
||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
@@ -305,6 +307,68 @@ def get_banned_users_keyboard(banned_users: List[User], current_page: int,
|
|||||||
return builder.as_markup()
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
|
def get_users_list_keyboard(users: List[User], current_page: int,
|
||||||
|
total_users: int, i18n_instance, lang: str,
|
||||||
|
page_size: int = 15) -> InlineKeyboardMarkup:
|
||||||
|
"""Generate keyboard for paginated user list"""
|
||||||
|
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
|
||||||
|
builder = InlineKeyboardBuilder()
|
||||||
|
|
||||||
|
# Add user buttons
|
||||||
|
for user in users:
|
||||||
|
user_display_parts = []
|
||||||
|
if user.username:
|
||||||
|
user_display_parts.append(f"@{user.username}")
|
||||||
|
user_display_parts.append(f"ID: {user.user_id}")
|
||||||
|
if user.first_name:
|
||||||
|
user_display_parts.append(f"- {user.first_name}")
|
||||||
|
|
||||||
|
button_text = " ".join(user_display_parts)
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=button_text,
|
||||||
|
callback_data=f"admin_user_card_from_list:{user.user_id}:{current_page}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Pagination buttons
|
||||||
|
if total_users > page_size:
|
||||||
|
total_pages = math.ceil(total_users / page_size)
|
||||||
|
pagination_buttons = []
|
||||||
|
if current_page > 0:
|
||||||
|
pagination_buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=_("prev_page_button"),
|
||||||
|
callback_data=f"admin_action:users_list:{current_page - 1}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pagination_buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=f"{current_page + 1}/{total_pages}",
|
||||||
|
callback_data="stub_page_display"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if current_page < total_pages - 1:
|
||||||
|
pagination_buttons.append(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=_("next_page_button"),
|
||||||
|
callback_data=f"admin_action:users_list:{current_page + 1}"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if pagination_buttons:
|
||||||
|
builder.row(*pagination_buttons)
|
||||||
|
|
||||||
|
# Back button
|
||||||
|
builder.row(
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=_("back_to_user_management_button"),
|
||||||
|
callback_data="admin_section:user_management"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return builder.as_markup()
|
||||||
|
|
||||||
|
|
||||||
def get_user_card_keyboard(user_id: int,
|
def get_user_card_keyboard(user_id: int,
|
||||||
is_banned: bool,
|
is_banned: bool,
|
||||||
i18n_instance,
|
i18n_instance,
|
||||||
@@ -320,6 +384,13 @@ def get_user_card_keyboard(user_id: int,
|
|||||||
builder.button(
|
builder.button(
|
||||||
text=_(key="user_card_ban_button"),
|
text=_(key="user_card_ban_button"),
|
||||||
callback_data=f"admin_ban_confirm:{user_id}:{banned_list_page}")
|
callback_data=f"admin_ban_confirm:{user_id}:{banned_list_page}")
|
||||||
|
builder.button(
|
||||||
|
text=_(
|
||||||
|
key="user_card_open_profile_button",
|
||||||
|
default="👤 Open profile"
|
||||||
|
),
|
||||||
|
url=f"tg://user?id={user_id}"
|
||||||
|
)
|
||||||
builder.button(
|
builder.button(
|
||||||
text=_(key="user_card_back_to_banned_list_button"),
|
text=_(key="user_card_back_to_banned_list_button"),
|
||||||
callback_data=f"admin_action:view_banned:{banned_list_page}")
|
callback_data=f"admin_action:view_banned:{banned_list_page}")
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
|
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
from aiogram.utils.text_decorations import html_decoration as hd
|
from aiogram.utils.text_decorations import html_decoration as hd
|
||||||
from aiogram.exceptions import TelegramRetryAfter
|
from aiogram.exceptions import TelegramRetryAfter
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional, Union, Dict, Any
|
from typing import Optional, Union, Dict, Any, Callable
|
||||||
|
|
||||||
from config.settings import Settings
|
from config.settings import Settings
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
@@ -34,8 +35,45 @@ class NotificationService:
|
|||||||
if username:
|
if username:
|
||||||
base_display = f"{base_display} ({username_for_display(username)})"
|
base_display = f"{base_display} ({username_for_display(username)})"
|
||||||
return base_display
|
return base_display
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_profile_keyboard(
|
||||||
|
translate: Callable[..., str],
|
||||||
|
user_id: int,
|
||||||
|
referrer_id: Optional[int] = None,
|
||||||
|
) -> InlineKeyboardMarkup:
|
||||||
|
"""Create inline keyboard with links to user (and referrer) profiles."""
|
||||||
|
buttons = [
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=translate(
|
||||||
|
"log_open_profile_link",
|
||||||
|
default="👤 Открыть профиль",
|
||||||
|
),
|
||||||
|
url=f"tg://user?id={user_id}",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
if referrer_id:
|
||||||
|
buttons.append([
|
||||||
|
InlineKeyboardButton(
|
||||||
|
text=translate(
|
||||||
|
"log_open_referrer_profile_button",
|
||||||
|
default="👤 Открыть профиль пригласившего",
|
||||||
|
),
|
||||||
|
url=f"tg://user?id={referrer_id}",
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
return InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
|
||||||
async def _send_to_log_channel(self, message: str, thread_id: Optional[int] = None):
|
async def _send_to_log_channel(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
thread_id: Optional[int] = None,
|
||||||
|
reply_markup: Optional[InlineKeyboardMarkup] = None,
|
||||||
|
):
|
||||||
"""Send message to configured log channel/group using message queue"""
|
"""Send message to configured log channel/group using message queue"""
|
||||||
if not self.settings.LOG_CHAT_ID:
|
if not self.settings.LOG_CHAT_ID:
|
||||||
return
|
return
|
||||||
@@ -49,6 +87,7 @@ class NotificationService:
|
|||||||
text=message,
|
text=message,
|
||||||
parse_mode="HTML",
|
parse_mode="HTML",
|
||||||
disable_web_page_preview=True,
|
disable_web_page_preview=True,
|
||||||
|
reply_markup=reply_markup,
|
||||||
message_thread_id=thread_id or self.settings.LOG_THREAD_ID
|
message_thread_id=thread_id or self.settings.LOG_THREAD_ID
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -64,6 +103,8 @@ class NotificationService:
|
|||||||
"parse_mode": "HTML",
|
"parse_mode": "HTML",
|
||||||
"disable_web_page_preview": True
|
"disable_web_page_preview": True
|
||||||
}
|
}
|
||||||
|
if reply_markup:
|
||||||
|
kwargs["reply_markup"] = reply_markup
|
||||||
|
|
||||||
# Add thread ID for supergroups if specified
|
# Add thread ID for supergroups if specified
|
||||||
if final_thread_id:
|
if final_thread_id:
|
||||||
@@ -124,7 +165,12 @@ class NotificationService:
|
|||||||
|
|
||||||
referral_text = ""
|
referral_text = ""
|
||||||
if referred_by_id:
|
if referred_by_id:
|
||||||
referral_text = _("log_referral_suffix", default=" (реферал от {referrer_id})", referrer_id=referred_by_id)
|
referrer_link = hd.link(str(referred_by_id), f"tg://user?id={referred_by_id}")
|
||||||
|
referral_text = _(
|
||||||
|
"log_referral_suffix",
|
||||||
|
default=" (реферал от {referrer_link})",
|
||||||
|
referrer_link=referrer_link,
|
||||||
|
)
|
||||||
|
|
||||||
message = _(
|
message = _(
|
||||||
"log_new_user_registration",
|
"log_new_user_registration",
|
||||||
@@ -137,9 +183,10 @@ class NotificationService:
|
|||||||
referral_text=referral_text,
|
referral_text=referral_text,
|
||||||
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Send to log channel
|
# Send to log channel
|
||||||
await self._send_to_log_channel(message)
|
profile_keyboard = self._build_profile_keyboard(_, user_id, referred_by_id)
|
||||||
|
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||||
|
|
||||||
async def notify_payment_received(self, user_id: int, amount: float, currency: str,
|
async def notify_payment_received(self, user_id: int, amount: float, currency: str,
|
||||||
months: int, payment_provider: str,
|
months: int, payment_provider: str,
|
||||||
@@ -182,7 +229,8 @@ class NotificationService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Send to log channel
|
# Send to log channel
|
||||||
await self._send_to_log_channel(message)
|
profile_keyboard = self._build_profile_keyboard(_, user_id)
|
||||||
|
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||||
|
|
||||||
async def notify_promo_activation(self, user_id: int, promo_code: str, bonus_days: int,
|
async def notify_promo_activation(self, user_id: int, promo_code: str, bonus_days: int,
|
||||||
username: Optional[str] = None):
|
username: Optional[str] = None):
|
||||||
@@ -212,7 +260,8 @@ class NotificationService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Send to log channel
|
# Send to log channel
|
||||||
await self._send_to_log_channel(message)
|
profile_keyboard = self._build_profile_keyboard(_, user_id)
|
||||||
|
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||||
|
|
||||||
async def notify_trial_activation(self, user_id: int, end_date: datetime,
|
async def notify_trial_activation(self, user_id: int, end_date: datetime,
|
||||||
username: Optional[str] = None):
|
username: Optional[str] = None):
|
||||||
@@ -240,7 +289,8 @@ class NotificationService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Send to log channel
|
# Send to log channel
|
||||||
await self._send_to_log_channel(message)
|
profile_keyboard = self._build_profile_keyboard(_, user_id)
|
||||||
|
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||||
|
|
||||||
async def notify_panel_sync(self, status: str, details: str,
|
async def notify_panel_sync(self, status: str, details: str,
|
||||||
users_processed: int, subs_synced: int,
|
users_processed: int, subs_synced: int,
|
||||||
@@ -275,7 +325,7 @@ class NotificationService:
|
|||||||
details=details
|
details=details
|
||||||
)
|
)
|
||||||
|
|
||||||
# Send to log channel
|
# Send to log channel
|
||||||
await self._send_to_log_channel(message)
|
await self._send_to_log_channel(message)
|
||||||
|
|
||||||
async def notify_suspicious_promo_attempt(
|
async def notify_suspicious_promo_attempt(
|
||||||
@@ -308,7 +358,8 @@ class NotificationService:
|
|||||||
timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z"))
|
timestamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S %Z"))
|
||||||
|
|
||||||
# Send to log channel
|
# Send to log channel
|
||||||
await self._send_to_log_channel(message)
|
profile_keyboard = self._build_profile_keyboard(_, user_id)
|
||||||
|
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
|
||||||
|
|
||||||
async def send_custom_notification(self, message: str, to_admins: bool = False,
|
async def send_custom_notification(self, message: str, to_admins: bool = False,
|
||||||
to_log_channel: bool = True, thread_id: Optional[int] = None):
|
to_log_channel: bool = True, thread_id: Optional[int] = None):
|
||||||
|
|||||||
@@ -455,6 +455,37 @@ class PanelApiService:
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
async def delete_user_from_panel(self,
|
||||||
|
user_uuid: str,
|
||||||
|
log_response: bool = True) -> bool:
|
||||||
|
"""Delete a user from the panel. Treat not-found as already deleted."""
|
||||||
|
endpoint = f"/users/{user_uuid}"
|
||||||
|
response_data = await self._request(
|
||||||
|
"DELETE", endpoint, log_full_response=log_response
|
||||||
|
)
|
||||||
|
|
||||||
|
if not response_data:
|
||||||
|
logging.error(
|
||||||
|
f"Panel API delete_user_from_panel returned no data for user {user_uuid}."
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if response_data.get("error"):
|
||||||
|
details = response_data.get("details") or {}
|
||||||
|
error_code = details.get("errorCode") or response_data.get("errorCode")
|
||||||
|
if error_code in {"A062", "A040"}:
|
||||||
|
logging.info(
|
||||||
|
f"Panel user {user_uuid} already absent (errorCode {error_code}). Treating as deleted."
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
logging.error(
|
||||||
|
f"Failed to delete user {user_uuid} on panel. Response: {response_data}"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
logging.info(f"Panel user {user_uuid} deleted successfully.")
|
||||||
|
return True
|
||||||
|
|
||||||
async def get_subscription_link(
|
async def get_subscription_link(
|
||||||
self,
|
self,
|
||||||
short_uuid_or_sub_uuid: str,
|
short_uuid_or_sub_uuid: str,
|
||||||
|
|||||||
@@ -177,6 +177,8 @@ class ReferralService:
|
|||||||
"ACTIVE_BONUS",
|
"ACTIVE_BONUS",
|
||||||
"traffic_limit_bytes":
|
"traffic_limit_bytes":
|
||||||
self.settings.user_traffic_limit_bytes,
|
self.settings.user_traffic_limit_bytes,
|
||||||
|
"auto_renew_enabled":
|
||||||
|
False,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
await subscription_dal.deactivate_other_active_subscriptions(
|
await subscription_dal.deactivate_other_active_subscriptions(
|
||||||
@@ -255,9 +257,35 @@ class ReferralService:
|
|||||||
|
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def generate_referral_link(self, bot_username: str,
|
async def generate_referral_link(self, session: AsyncSession,
|
||||||
inviter_user_id: int) -> str:
|
bot_username: str,
|
||||||
return f"https://t.me/{bot_username}?start=ref_{inviter_user_id}"
|
inviter_user_id: int) -> Optional[str]:
|
||||||
|
try:
|
||||||
|
user = await user_dal.get_user_by_id(session, inviter_user_id)
|
||||||
|
if not user:
|
||||||
|
logging.warning(
|
||||||
|
"Unable to generate referral link: user %s not found.",
|
||||||
|
inviter_user_id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
referral_code = await user_dal.ensure_referral_code(session, user)
|
||||||
|
if not referral_code:
|
||||||
|
logging.warning(
|
||||||
|
"User %s has no referral code even after regeneration attempt.",
|
||||||
|
inviter_user_id,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return f"https://t.me/{bot_username}?start=ref_u{referral_code}"
|
||||||
|
except Exception as exc:
|
||||||
|
logging.error(
|
||||||
|
"Failed to generate referral link for user %s: %s",
|
||||||
|
inviter_user_id,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
async def get_referral_stats(self, session: AsyncSession, user_id: int) -> dict:
|
async def get_referral_stats(self, session: AsyncSession, user_id: int) -> dict:
|
||||||
"""Get referral statistics for a user"""
|
"""Get referral statistics for a user"""
|
||||||
|
|||||||
@@ -498,6 +498,15 @@ class SubscriptionService:
|
|||||||
session, panel_user_uuid, panel_sub_link_id
|
session, panel_user_uuid, panel_sub_link_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
auto_renew_should_enable = False
|
||||||
|
if (
|
||||||
|
provider == "yookassa"
|
||||||
|
and getattr(self.settings, "YOOKASSA_AUTOPAYMENTS_ENABLED", False)
|
||||||
|
):
|
||||||
|
auto_renew_should_enable = await user_billing_dal.user_has_saved_payment_method(
|
||||||
|
session, user_id
|
||||||
|
)
|
||||||
|
|
||||||
sub_payload = {
|
sub_payload = {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"panel_user_uuid": panel_user_uuid,
|
"panel_user_uuid": panel_user_uuid,
|
||||||
@@ -510,7 +519,7 @@ class SubscriptionService:
|
|||||||
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
|
"traffic_limit_bytes": self.settings.user_traffic_limit_bytes,
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
"skip_notifications": provider == "tribute" and self.settings.TRIBUTE_SKIP_NOTIFICATIONS,
|
"skip_notifications": provider == "tribute" and self.settings.TRIBUTE_SKIP_NOTIFICATIONS,
|
||||||
"auto_renew_enabled": True,
|
"auto_renew_enabled": auto_renew_should_enable,
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
new_or_updated_sub = await subscription_dal.upsert_subscription(
|
new_or_updated_sub = await subscription_dal.upsert_subscription(
|
||||||
@@ -616,6 +625,7 @@ class SubscriptionService:
|
|||||||
"is_active": True,
|
"is_active": True,
|
||||||
"status_from_panel": "ACTIVE_BONUS",
|
"status_from_panel": "ACTIVE_BONUS",
|
||||||
"traffic_limit_bytes": traffic_limit,
|
"traffic_limit_bytes": traffic_limit,
|
||||||
|
"auto_renew_enabled": False,
|
||||||
}
|
}
|
||||||
await subscription_dal.deactivate_other_active_subscriptions(
|
await subscription_dal.deactivate_other_active_subscriptions(
|
||||||
session, panel_uuid, panel_sub_uuid
|
session, panel_uuid, panel_sub_uuid
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ class AdminStates(StatesGroup):
|
|||||||
waiting_for_user_search = State()
|
waiting_for_user_search = State()
|
||||||
waiting_for_subscription_days_to_add = State()
|
waiting_for_subscription_days_to_add = State()
|
||||||
waiting_for_direct_message_to_user = State()
|
waiting_for_direct_message_to_user = State()
|
||||||
|
waiting_for_user_delete_confirmation = State()
|
||||||
|
|
||||||
# Ads campaigns
|
# Ads campaigns
|
||||||
waiting_for_ad_source = State()
|
waiting_for_ad_source = State()
|
||||||
|
|||||||
+9
-1
@@ -41,6 +41,10 @@ class Settings(BaseSettings):
|
|||||||
YOOKASSA_PAYMENT_SUBJECT: str = Field(default="service")
|
YOOKASSA_PAYMENT_SUBJECT: str = Field(default="service")
|
||||||
# Single toggle to enable recurring payments (saving cards, managing payment methods, auto-renew)
|
# Single toggle to enable recurring payments (saving cards, managing payment methods, auto-renew)
|
||||||
YOOKASSA_AUTOPAYMENTS_ENABLED: bool = Field(default=False)
|
YOOKASSA_AUTOPAYMENTS_ENABLED: bool = Field(default=False)
|
||||||
|
YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="When true, new YooKassa payments in autopay mode force card binding without a user checkbox."
|
||||||
|
)
|
||||||
|
|
||||||
WEBHOOK_BASE_URL: Optional[str] = None
|
WEBHOOK_BASE_URL: Optional[str] = None
|
||||||
|
|
||||||
@@ -114,7 +118,11 @@ class Settings(BaseSettings):
|
|||||||
# Referral program configuration
|
# Referral program configuration
|
||||||
REFERRAL_ONE_BONUS_PER_REFEREE: bool = Field(
|
REFERRAL_ONE_BONUS_PER_REFEREE: bool = Field(
|
||||||
default=True,
|
default=True,
|
||||||
description="When true, referral bonuses (for inviter and referee) are applied only once per invited user – on their first successful payment."
|
description="When true, referral bonuses (for inviter and referee) are applied only once per invited user - on their first successful payment."
|
||||||
|
)
|
||||||
|
LEGACY_REFS: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset."
|
||||||
)
|
)
|
||||||
|
|
||||||
PANEL_API_URL: Optional[str] = None
|
PANEL_API_URL: Optional[str] = None
|
||||||
|
|||||||
@@ -162,3 +162,19 @@ async def delete_user_payment_method_by_provider_id(
|
|||||||
await session.delete(method)
|
await session.delete(method)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def user_has_saved_payment_method(
|
||||||
|
session: AsyncSession,
|
||||||
|
user_id: int,
|
||||||
|
provider: str = "yookassa",
|
||||||
|
) -> bool:
|
||||||
|
"""Return True if the user has at least one saved payment method."""
|
||||||
|
try:
|
||||||
|
methods = await list_user_payment_methods(session, user_id, provider)
|
||||||
|
if methods:
|
||||||
|
return True
|
||||||
|
billing = await get_user_billing(session, user_id)
|
||||||
|
return bool(billing and billing.yookassa_payment_method_id)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|||||||
+135
-2
@@ -1,13 +1,71 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
from typing import Optional, List, Dict, Any, Tuple
|
from typing import Optional, List, Dict, Any, Tuple
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.future import select
|
from sqlalchemy.future import select
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
from sqlalchemy import update, delete, func, and_
|
from sqlalchemy import update, delete, func, and_, or_
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||||
|
|
||||||
from ..models import User, Subscription
|
from ..models import (
|
||||||
|
User,
|
||||||
|
Subscription,
|
||||||
|
Payment,
|
||||||
|
PromoCodeActivation,
|
||||||
|
MessageLog,
|
||||||
|
UserBilling,
|
||||||
|
UserPaymentMethod,
|
||||||
|
AdAttribution,
|
||||||
|
)
|
||||||
|
|
||||||
|
REFERRAL_CODE_ALPHABET = string.ascii_uppercase + string.digits
|
||||||
|
REFERRAL_CODE_LENGTH = 9
|
||||||
|
MAX_REFERRAL_CODE_ATTEMPTS = 25
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_referral_code_candidate() -> str:
|
||||||
|
return "".join(
|
||||||
|
secrets.choice(REFERRAL_CODE_ALPHABET) for _ in range(REFERRAL_CODE_LENGTH)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _referral_code_exists(session: AsyncSession, code: str) -> bool:
|
||||||
|
stmt = select(User.user_id).where(User.referral_code == code)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_unique_referral_code(session: AsyncSession) -> str:
|
||||||
|
"""
|
||||||
|
Generate a unique referral code consisting of uppercase alphanumeric characters.
|
||||||
|
Retries until a free code is found or raises RuntimeError after exceeding attempts.
|
||||||
|
"""
|
||||||
|
for _ in range(MAX_REFERRAL_CODE_ATTEMPTS):
|
||||||
|
candidate = _generate_referral_code_candidate()
|
||||||
|
if not await _referral_code_exists(session, candidate):
|
||||||
|
return candidate
|
||||||
|
raise RuntimeError("Failed to generate a unique referral code after several attempts.")
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_referral_code(session: AsyncSession, user: User) -> str:
|
||||||
|
"""
|
||||||
|
Ensure the provided user has a referral code, generating and persisting it if missing.
|
||||||
|
Returns the existing or newly generated code.
|
||||||
|
"""
|
||||||
|
if user.referral_code:
|
||||||
|
normalized = user.referral_code.strip().upper()
|
||||||
|
if normalized != user.referral_code:
|
||||||
|
user.referral_code = normalized
|
||||||
|
await session.flush()
|
||||||
|
await session.refresh(user)
|
||||||
|
return user.referral_code
|
||||||
|
|
||||||
|
user.referral_code = await generate_unique_referral_code(session)
|
||||||
|
await session.flush()
|
||||||
|
await session.refresh(user)
|
||||||
|
return user.referral_code
|
||||||
|
|
||||||
|
|
||||||
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]:
|
||||||
@@ -43,6 +101,11 @@ async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> Tuple
|
|||||||
if "registration_date" not in user_data:
|
if "registration_date" not in user_data:
|
||||||
user_data["registration_date"] = datetime.now(timezone.utc)
|
user_data["registration_date"] = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
if not user_data.get("referral_code"):
|
||||||
|
user_data["referral_code"] = await generate_unique_referral_code(session)
|
||||||
|
else:
|
||||||
|
user_data["referral_code"] = user_data["referral_code"].strip().upper()
|
||||||
|
|
||||||
# Use PostgreSQL upsert to avoid IntegrityError on concurrent inserts
|
# Use PostgreSQL upsert to avoid IntegrityError on concurrent inserts
|
||||||
stmt = (
|
stmt = (
|
||||||
pg_insert(User)
|
pg_insert(User)
|
||||||
@@ -71,6 +134,15 @@ async def create_user(session: AsyncSession, user_data: Dict[str, Any]) -> Tuple
|
|||||||
return user, created
|
return user, created
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_by_referral_code(session: AsyncSession, referral_code: str) -> Optional[User]:
|
||||||
|
normalized = referral_code.strip().upper()
|
||||||
|
if not normalized:
|
||||||
|
return None
|
||||||
|
stmt = select(User).where(User.referral_code == normalized)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
async def update_user(
|
async def update_user(
|
||||||
session: AsyncSession, user_id: int, update_data: Dict[str, Any]
|
session: AsyncSession, user_id: int, update_data: Dict[str, Any]
|
||||||
) -> Optional[User]:
|
) -> Optional[User]:
|
||||||
@@ -102,6 +174,29 @@ async def get_banned_users(session: AsyncSession) -> List[User]:
|
|||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_users_paginated(
|
||||||
|
session: AsyncSession, *, page: int = 0, page_size: int = 15
|
||||||
|
) -> List[User]:
|
||||||
|
"""Return a slice of users ordered by newest registration first."""
|
||||||
|
safe_page = max(page, 0)
|
||||||
|
safe_page_size = max(page_size, 1)
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(User)
|
||||||
|
.order_by(User.registration_date.desc())
|
||||||
|
.offset(safe_page * safe_page_size)
|
||||||
|
.limit(safe_page_size)
|
||||||
|
)
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def count_all_users(session: AsyncSession) -> int:
|
||||||
|
"""Count total number of users."""
|
||||||
|
result = await session.execute(select(func.count(User.user_id)))
|
||||||
|
return result.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
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)
|
stmt = select(User.user_id).where(User.is_banned == False)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
@@ -227,3 +322,41 @@ async def get_user_ids_without_active_subscription(session: AsyncSession) -> Lis
|
|||||||
)
|
)
|
||||||
result = await session.execute(stmt)
|
result = await session.execute(stmt)
|
||||||
return result.scalars().all()
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool:
|
||||||
|
"""Completely remove a user and all dependent records from the database.
|
||||||
|
|
||||||
|
This helper ensures we do not leave dangling foreign keys or orphaned data.
|
||||||
|
"""
|
||||||
|
user = await get_user_by_id(session, user_id)
|
||||||
|
if not user:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Ensure referral pointers do not block deletion
|
||||||
|
await session.execute(
|
||||||
|
update(User).where(User.referred_by_id == user_id).values(referred_by_id=None)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clean up dependent tables that do not cascade automatically
|
||||||
|
await session.execute(
|
||||||
|
delete(MessageLog).where(
|
||||||
|
or_(MessageLog.user_id == user_id, MessageLog.target_user_id == user_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.execute(delete(Payment).where(Payment.user_id == user_id))
|
||||||
|
await session.execute(
|
||||||
|
delete(Subscription).where(Subscription.user_id == user_id)
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
delete(PromoCodeActivation).where(PromoCodeActivation.user_id == user_id)
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
delete(UserPaymentMethod).where(UserPaymentMethod.user_id == user_id)
|
||||||
|
)
|
||||||
|
await session.execute(delete(UserBilling).where(UserBilling.user_id == user_id))
|
||||||
|
await session.execute(delete(AdAttribution).where(AdAttribution.user_id == user_id))
|
||||||
|
|
||||||
|
await session.delete(user)
|
||||||
|
await session.flush()
|
||||||
|
return True
|
||||||
|
|||||||
@@ -48,12 +48,86 @@ def _migration_0001_add_channel_subscription_fields(connection: Connection) -> N
|
|||||||
connection.execute(text(stmt))
|
connection.execute(text(stmt))
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_0002_add_referral_code(connection: Connection) -> None:
|
||||||
|
inspector = inspect(connection)
|
||||||
|
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
|
||||||
|
|
||||||
|
if "referral_code" not in columns:
|
||||||
|
connection.execute(
|
||||||
|
text("ALTER TABLE users ADD COLUMN referral_code VARCHAR(16)")
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
WITH generated_codes AS (
|
||||||
|
SELECT
|
||||||
|
user_id,
|
||||||
|
UPPER(
|
||||||
|
SUBSTRING(
|
||||||
|
md5(
|
||||||
|
user_id::text
|
||||||
|
|| clock_timestamp()::text
|
||||||
|
|| random()::text
|
||||||
|
)
|
||||||
|
FROM 1 FOR 9
|
||||||
|
)
|
||||||
|
) AS referral_code
|
||||||
|
FROM users
|
||||||
|
WHERE referral_code IS NULL OR referral_code = ''
|
||||||
|
)
|
||||||
|
UPDATE users AS u
|
||||||
|
SET referral_code = g.referral_code
|
||||||
|
FROM generated_codes AS g
|
||||||
|
WHERE u.user_id = g.user_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_users_referral_code
|
||||||
|
ON users (referral_code)
|
||||||
|
WHERE referral_code IS NOT NULL
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _migration_0003_normalize_referral_codes(connection: Connection) -> None:
|
||||||
|
inspector = inspect(connection)
|
||||||
|
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
|
||||||
|
if "referral_code" not in columns:
|
||||||
|
return
|
||||||
|
|
||||||
|
connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET referral_code = UPPER(referral_code)
|
||||||
|
WHERE referral_code IS NOT NULL
|
||||||
|
AND referral_code <> UPPER(referral_code)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
MIGRATIONS: List[Migration] = [
|
MIGRATIONS: List[Migration] = [
|
||||||
Migration(
|
Migration(
|
||||||
id="0001_add_channel_subscription_fields",
|
id="0001_add_channel_subscription_fields",
|
||||||
description="Add columns to track required channel subscription verification",
|
description="Add columns to track required channel subscription verification",
|
||||||
upgrade=_migration_0001_add_channel_subscription_fields,
|
upgrade=_migration_0001_add_channel_subscription_fields,
|
||||||
),
|
),
|
||||||
|
Migration(
|
||||||
|
id="0002_add_referral_code",
|
||||||
|
description="Store short referral codes for users and backfill existing rows",
|
||||||
|
upgrade=_migration_0002_add_referral_code,
|
||||||
|
),
|
||||||
|
Migration(
|
||||||
|
id="0003_normalize_referral_codes",
|
||||||
|
description="Normalize referral codes to uppercase for consistent lookups",
|
||||||
|
upgrade=_migration_0003_normalize_referral_codes,
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class User(Base):
|
|||||||
server_default=func.now())
|
server_default=func.now())
|
||||||
is_banned = Column(Boolean, default=False)
|
is_banned = Column(Boolean, default=False)
|
||||||
panel_user_uuid = Column(String, nullable=True, unique=True, index=True)
|
panel_user_uuid = Column(String, nullable=True, unique=True, index=True)
|
||||||
|
referral_code = Column(String(16), nullable=True, unique=True, index=True)
|
||||||
referred_by_id = Column(BigInteger,
|
referred_by_id = Column(BigInteger,
|
||||||
ForeignKey("users.user_id"),
|
ForeignKey("users.user_id"),
|
||||||
nullable=True)
|
nullable=True)
|
||||||
|
|||||||
+20
-2
@@ -101,7 +101,10 @@
|
|||||||
"admin_promo_marketing_section": "🎁 Promos",
|
"admin_promo_marketing_section": "🎁 Promos",
|
||||||
"admin_system_functions_section": "⚙️ System",
|
"admin_system_functions_section": "⚙️ System",
|
||||||
"admin_ban_management_section": "🚫 Bans",
|
"admin_ban_management_section": "🚫 Bans",
|
||||||
"admin_users_management_button": "👤 Management",
|
"admin_users_search_button": "🔍 Search",
|
||||||
|
"admin_users_management_button": "👥 User List",
|
||||||
|
"admin_users_list_header": "👥 <b>User List</b>\n\nPage {current}/{total} ({total_users} users)",
|
||||||
|
"admin_user_back_to_list_button": "⬅️ Back to List",
|
||||||
"back_to_user_management_button": "⬅️ To Users",
|
"back_to_user_management_button": "⬅️ To Users",
|
||||||
"back_to_admin_panel_button": "⬅️ To Admin",
|
"back_to_admin_panel_button": "⬅️ To Admin",
|
||||||
"admin_stats_header": "📊 Bot Statistics",
|
"admin_stats_header": "📊 Bot Statistics",
|
||||||
@@ -207,6 +210,8 @@
|
|||||||
"admin_user_card_title": "User Card",
|
"admin_user_card_title": "User Card",
|
||||||
"user_card_ban_button": "🚫 Ban",
|
"user_card_ban_button": "🚫 Ban",
|
||||||
"user_card_unban_button": "✅ Unban",
|
"user_card_unban_button": "✅ Unban",
|
||||||
|
"user_card_open_profile_button": "👤 Open profile",
|
||||||
|
"user_card_open_referrer_profile_button": "👤 Referrer profile",
|
||||||
"user_card_back_to_banned_list_button": "⬅️ Back to Ban List",
|
"user_card_back_to_banned_list_button": "⬅️ Back to Ban List",
|
||||||
"admin_logs_menu_title": "Logs Menu:",
|
"admin_logs_menu_title": "Logs Menu:",
|
||||||
"admin_view_all_logs_button": "📜 All Message Logs",
|
"admin_view_all_logs_button": "📜 All Message Logs",
|
||||||
@@ -244,6 +249,16 @@
|
|||||||
"admin_user_send_message_button": "✉️ Send Message",
|
"admin_user_send_message_button": "✉️ Send Message",
|
||||||
"admin_user_view_logs_button": "📜 User Actions",
|
"admin_user_view_logs_button": "📜 User Actions",
|
||||||
"admin_user_refresh_button": "🔄 Refresh",
|
"admin_user_refresh_button": "🔄 Refresh",
|
||||||
|
"admin_user_delete_button": "❌ Delete User",
|
||||||
|
"admin_user_delete_not_allowed": "❌ You are not permitted to delete users.",
|
||||||
|
"admin_user_delete_confirmation_prompt": "⚠️ You are about to delete user {user_id} completely.\n\nSend the exact Telegram ID of this user to confirm.\nAny other reply will cancel the operation.",
|
||||||
|
"admin_user_delete_state_missing": "⚠️ No active delete operation. Start again.",
|
||||||
|
"admin_user_delete_cancelled": "Deletion cancelled.",
|
||||||
|
"admin_user_delete_mismatch": "⚠️ ID mismatch. Deletion aborted.",
|
||||||
|
"admin_user_delete_already_removed": "ℹ️ The user is already removed.",
|
||||||
|
"admin_user_delete_panel_error": "❌ Failed to remove the user from the panel. Operation aborted.",
|
||||||
|
"admin_user_delete_success": "✅ User {user_id} was removed from the bot and the panel.",
|
||||||
|
"admin_user_delete_error": "❌ Unable to delete the user. Please try again later.",
|
||||||
"admin_user_search_new_button": "🔍 Find Another",
|
"admin_user_search_new_button": "🔍 Find Another",
|
||||||
"admin_user_view_all_logs_button": "📋 All Actions",
|
"admin_user_view_all_logs_button": "📋 All Actions",
|
||||||
"admin_user_back_to_card_button": "🔙 Back to Card",
|
"admin_user_back_to_card_button": "🔙 Back to Card",
|
||||||
@@ -278,7 +293,9 @@
|
|||||||
"inline_admin_financial_stats_title": "💰 Financial Statistics",
|
"inline_admin_financial_stats_title": "💰 Financial Statistics",
|
||||||
"inline_system_stats_message": "🖥 <b>Panel Statistics</b>\n\n🟢 Online: <b>{online}</b>\n📊 Active: <b>{active}</b>\n🔴 Disabled: <b>{disabled}</b>\n⏰ Expired: <b>{expired}</b>\n⚠️ Limited: <b>{limited}</b>\n👥 Total users: <b>{total}</b>\n💾 RAM Usage: <b>{memory:.1f}%</b>\n📊 Week traffic: <b>{week_traffic}</b>\n📊 Month traffic: <b>{month_traffic}</b>\n🔗 Active nodes: <b>{active_nodes}/{total_nodes}</b>",
|
"inline_system_stats_message": "🖥 <b>Panel Statistics</b>\n\n🟢 Online: <b>{online}</b>\n📊 Active: <b>{active}</b>\n🔴 Disabled: <b>{disabled}</b>\n⏰ Expired: <b>{expired}</b>\n⚠️ Limited: <b>{limited}</b>\n👥 Total users: <b>{total}</b>\n💾 RAM Usage: <b>{memory:.1f}%</b>\n📊 Week traffic: <b>{week_traffic}</b>\n📊 Month traffic: <b>{month_traffic}</b>\n🔗 Active nodes: <b>{active_nodes}/{total_nodes}</b>",
|
||||||
"inline_admin_system_stats_title": "🖥 System Statistics",
|
"inline_admin_system_stats_title": "🖥 System Statistics",
|
||||||
"log_referral_suffix": " (referral from {referrer_id})",
|
"log_referral_suffix": " (referral from {referrer_link})",
|
||||||
|
"log_open_profile_link": "👤 Open profile",
|
||||||
|
"log_open_referrer_profile_button": "👤 Referrer profile",
|
||||||
"log_new_user_registration": "👤 <b>New User</b>\n\n🆔 ID: <code>{user_id}</code>\n👤 Name: {user_display}{referral_text}\n📅 Time: {timestamp}",
|
"log_new_user_registration": "👤 <b>New User</b>\n\n🆔 ID: <code>{user_id}</code>\n👤 Name: {user_display}{referral_text}\n📅 Time: {timestamp}",
|
||||||
"log_payment_received": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n📅 Period: <b>{months} mo.</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
|
"log_payment_received": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n📅 Period: <b>{months} mo.</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
|
||||||
"log_promo_activation": "🎁 <b>Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n🎯 Bonus: <b>+{bonus_days}d</b>\n🕐 Time: {timestamp}",
|
"log_promo_activation": "🎁 <b>Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n🎯 Bonus: <b>+{bonus_days}d</b>\n🕐 Time: {timestamp}",
|
||||||
@@ -426,6 +443,7 @@
|
|||||||
"subscription_tribute_notice": "Paid via Tribute. Renew using your Tribute link.",
|
"subscription_tribute_notice": "Paid via Tribute. Renew using your Tribute link.",
|
||||||
"subscription_tribute_notice_with_link": "Paid via Tribute. Renew: {link}",
|
"subscription_tribute_notice_with_link": "Paid via Tribute. Renew: {link}",
|
||||||
"subscription_autorenew_not_supported_for_tribute": "Auto-renew is handled by Tribute. Manage renewal in the Tribute app/link.",
|
"subscription_autorenew_not_supported_for_tribute": "Auto-renew is handled by Tribute. Manage renewal in the Tribute app/link.",
|
||||||
|
"autorenew_enable_requires_card": "Link a payment card in Payment Methods before enabling auto-renew.",
|
||||||
"subscription_not_active": "You don't have an active subscription.",
|
"subscription_not_active": "You don't have an active subscription.",
|
||||||
"error_service_unavailable": "Service unavailable. Please try again later.",
|
"error_service_unavailable": "Service unavailable. Please try again later.",
|
||||||
"error_payment_gateway": "Payment service error. Please try again later.",
|
"error_payment_gateway": "Payment service error. Please try again later.",
|
||||||
|
|||||||
+20
-2
@@ -101,7 +101,10 @@
|
|||||||
"admin_promo_marketing_section": "🎁 Промокоды",
|
"admin_promo_marketing_section": "🎁 Промокоды",
|
||||||
"admin_system_functions_section": "⚙️ Система",
|
"admin_system_functions_section": "⚙️ Система",
|
||||||
"admin_ban_management_section": "🚫 Блокировки",
|
"admin_ban_management_section": "🚫 Блокировки",
|
||||||
"admin_users_management_button": "👤 Управление",
|
"admin_users_search_button": "🔍 Поиск",
|
||||||
|
"admin_users_management_button": "👥 Список пользователей",
|
||||||
|
"admin_users_list_header": "👥 <b>Список пользователей</b>\n\nСтраница {current}/{total} ({total_users} пользователей)",
|
||||||
|
"admin_user_back_to_list_button": "⬅️ К списку",
|
||||||
"back_to_user_management_button": "⬅️ К пользователям",
|
"back_to_user_management_button": "⬅️ К пользователям",
|
||||||
"back_to_admin_panel_button": "⬅️ В админку",
|
"back_to_admin_panel_button": "⬅️ В админку",
|
||||||
"admin_stats_header": "📊 Статистика Бота",
|
"admin_stats_header": "📊 Статистика Бота",
|
||||||
@@ -217,6 +220,8 @@
|
|||||||
"admin_user_card_title": "Карточка пользователя",
|
"admin_user_card_title": "Карточка пользователя",
|
||||||
"user_card_ban_button": "🚫 Заблокировать",
|
"user_card_ban_button": "🚫 Заблокировать",
|
||||||
"user_card_unban_button": "✅ Разблокировать",
|
"user_card_unban_button": "✅ Разблокировать",
|
||||||
|
"user_card_open_profile_button": "👤 Открыть профиль",
|
||||||
|
"user_card_open_referrer_profile_button": "👤 Профиль пригласившего",
|
||||||
"user_card_back_to_banned_list_button": "⬅️ К списку забаненных",
|
"user_card_back_to_banned_list_button": "⬅️ К списку забаненных",
|
||||||
"admin_logs_menu_title": "Меню логов:",
|
"admin_logs_menu_title": "Меню логов:",
|
||||||
"admin_view_all_logs_button": "📜 Все логи сообщений",
|
"admin_view_all_logs_button": "📜 Все логи сообщений",
|
||||||
@@ -244,6 +249,16 @@
|
|||||||
"admin_user_send_message_button": "✉️ Сообщение",
|
"admin_user_send_message_button": "✉️ Сообщение",
|
||||||
"admin_user_view_logs_button": "📜 Логи",
|
"admin_user_view_logs_button": "📜 Логи",
|
||||||
"admin_user_refresh_button": "🔄 Обновить",
|
"admin_user_refresh_button": "🔄 Обновить",
|
||||||
|
"admin_user_delete_button": "❌ Удалить пользователя",
|
||||||
|
"admin_user_delete_not_allowed": "❌ У вас нет прав для удаления пользователей.",
|
||||||
|
"admin_user_delete_confirmation_prompt": "⚠️ Вы хотите полностью удалить пользователя {user_id}.\n\nОтправьте точный Telegram ID этого пользователя, чтобы подтвердить удаление.\nЛюбой другой ответ отменит операцию.",
|
||||||
|
"admin_user_delete_state_missing": "⚠️ Нет активной операции удаления. Начните заново.",
|
||||||
|
"admin_user_delete_cancelled": "Удаление отменено.",
|
||||||
|
"admin_user_delete_mismatch": "⚠️ ID не совпадает. Удаление отменено.",
|
||||||
|
"admin_user_delete_already_removed": "ℹ️ Пользователь уже удалён.",
|
||||||
|
"admin_user_delete_panel_error": "❌ Не удалось удалить пользователя на панели. Операция прервана.",
|
||||||
|
"admin_user_delete_success": "✅ Пользователь {user_id} удалён из бота и панели.",
|
||||||
|
"admin_user_delete_error": "❌ Не удалось удалить пользователя. Попробуйте позже.",
|
||||||
"admin_user_search_new_button": "🔍 Новый поиск",
|
"admin_user_search_new_button": "🔍 Новый поиск",
|
||||||
"admin_user_view_all_logs_button": "📋 Все логи",
|
"admin_user_view_all_logs_button": "📋 Все логи",
|
||||||
"admin_user_back_to_card_button": "🔙 К карточке",
|
"admin_user_back_to_card_button": "🔙 К карточке",
|
||||||
@@ -278,7 +293,9 @@
|
|||||||
"inline_admin_financial_stats_title": "💰 Финансовая статистика",
|
"inline_admin_financial_stats_title": "💰 Финансовая статистика",
|
||||||
"inline_system_stats_message": "🖥 <b>Статистика панели</b>\n\n🟢 Онлайн: <b>{online}</b>\n📊 Активных: <b>{active}</b>\n🔴 Отключенных: <b>{disabled}</b>\n⏰ Истекшие: <b>{expired}</b>\n⚠️ Ограниченные: <b>{limited}</b>\n👥 Всего пользователей: <b>{total}</b>\n💾 Использование RAM: <b>{memory:.1f}%</b>\n📊 Трафик за неделю: <b>{week_traffic}</b>\n📊 Трафик за месяц: <b>{month_traffic}</b>\n🔗 Активных нод: <b>{active_nodes}/{total_nodes}</b>",
|
"inline_system_stats_message": "🖥 <b>Статистика панели</b>\n\n🟢 Онлайн: <b>{online}</b>\n📊 Активных: <b>{active}</b>\n🔴 Отключенных: <b>{disabled}</b>\n⏰ Истекшие: <b>{expired}</b>\n⚠️ Ограниченные: <b>{limited}</b>\n👥 Всего пользователей: <b>{total}</b>\n💾 Использование RAM: <b>{memory:.1f}%</b>\n📊 Трафик за неделю: <b>{week_traffic}</b>\n📊 Трафик за месяц: <b>{month_traffic}</b>\n🔗 Активных нод: <b>{active_nodes}/{total_nodes}</b>",
|
||||||
"inline_admin_system_stats_title": "🖥 Системная статистика",
|
"inline_admin_system_stats_title": "🖥 Системная статистика",
|
||||||
"log_referral_suffix": " (реферал от {referrer_id})",
|
"log_referral_suffix": " (реферал от {referrer_link})",
|
||||||
|
"log_open_profile_link": "👤 Открыть профиль",
|
||||||
|
"log_open_referrer_profile_button": "👤 Профиль пригласившего",
|
||||||
"log_new_user_registration": "👤 <b>Новый пользователь</b>\n\n🆔 ID: <code>{user_id}</code>\n👤 Имя: {user_display}{referral_text}\n📅 Время: {timestamp}",
|
"log_new_user_registration": "👤 <b>Новый пользователь</b>\n\n🆔 ID: <code>{user_id}</code>\n👤 Имя: {user_display}{referral_text}\n📅 Время: {timestamp}",
|
||||||
"log_payment_received": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n📅 Период: <b>{months} мес.</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
|
"log_payment_received": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n📅 Период: <b>{months} мес.</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
|
||||||
"log_promo_activation": "🎁 <b>Активирован промокод</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n🎯 Бонус: <b>+{bonus_days} дн.</b>\n🕐 Время: {timestamp}",
|
"log_promo_activation": "🎁 <b>Активирован промокод</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n🎯 Бонус: <b>+{bonus_days} дн.</b>\n🕐 Время: {timestamp}",
|
||||||
@@ -426,6 +443,7 @@
|
|||||||
"subscription_tribute_notice": "Оплачено через Tribute. Продление делайте по ссылке Tribute.",
|
"subscription_tribute_notice": "Оплачено через Tribute. Продление делайте по ссылке Tribute.",
|
||||||
"subscription_tribute_notice_with_link": "Оплачено через Tribute. Продлить: {link}",
|
"subscription_tribute_notice_with_link": "Оплачено через Tribute. Продлить: {link}",
|
||||||
"subscription_autorenew_not_supported_for_tribute": "Автопродление управляется Tribute. Управляйте продлением в приложении/ссылке Tribute.",
|
"subscription_autorenew_not_supported_for_tribute": "Автопродление управляется Tribute. Управляйте продлением в приложении/ссылке Tribute.",
|
||||||
|
"autorenew_enable_requires_card": "Прежде чем включать автоплатёж, привяжите карту в разделе «Способы оплаты».",
|
||||||
"subscription_not_active": "У вас нет активной подписки.",
|
"subscription_not_active": "У вас нет активной подписки.",
|
||||||
"error_service_unavailable": "Сервис недоступен. Попробуйте позже.",
|
"error_service_unavailable": "Сервис недоступен. Попробуйте позже.",
|
||||||
"error_payment_gateway": "Ошибка платежного сервиса. Попробуйте позже.",
|
"error_payment_gateway": "Ошибка платежного сервиса. Попробуйте позже.",
|
||||||
|
|||||||
Reference in New Issue
Block a user