Compare commits

..
9 Commits
Author SHA1 Message Date
machka paslaandGitHub 3e86fc76b6 Merge pull request #135 from machka-pasla/dev
upd
2025-11-13 23:04:43 +03:00
machka paslaandGitHub 829a18715a Merge pull request #134 from machka-pasla/revert-133-revert-132-fix/sync-admin-duplicate-subscriptions
Revert "Revert "Устранены дубли активных записей, ошибка уникальности и улучшена идемпотентность""
2025-11-13 23:04:13 +03:00
machka paslaandGitHub 844c8e12a7 Revert "Revert "Устранены дубли активных записей, ошибка уникальности и улучшена идемпотентность"" 2025-11-13 23:03:56 +03:00
machka paslaandGitHub ef75f905f4 Merge pull request #133 from machka-pasla/revert-132-fix/sync-admin-duplicate-subscriptions
Revert "Устранены дубли активных записей, ошибка уникальности и улучшена идемпотентность"
2025-11-13 23:01:58 +03:00
machka paslaandGitHub cd816cbe5c Revert "Устранены дубли активных записей, ошибка уникальности и улучшена идемпотентность" 2025-11-13 23:01:46 +03:00
machka paslaandGitHub 2b0fa3a314 Merge pull request #132 from orryxvpn/fix/sync-admin-duplicate-subscriptions
Устранены дубли активных записей, ошибка уникальности и улучшена идемпотентность
2025-11-13 23:01:30 +03:00
machka paslaandGitHub ead990e61c Merge pull request #129 from 3252a8/feature/ref-link
Ref link improvement
2025-11-13 23:00:43 +03:00
orryxvpnandGitHub eb6e343e4c Refactor perform_sync function for readability 2025-11-13 21:30:15 +05:00
3252a8 caf5b88eef Use random string in ref link instead of tg ID
- Enable old links with tg id by default in .env with LEGACY_REFS=true
2025-11-11 22:32:30 +03:00
10 changed files with 416 additions and 83 deletions
+1
View File
@@ -93,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
+180 -67
View File
@@ -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
@@ -221,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(
@@ -242,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
@@ -267,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,
@@ -282,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()
@@ -311,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"))
@@ -366,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)
@@ -401,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"))
@@ -420,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
+23 -5
View File
@@ -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 = _(
+22 -3
View File
@@ -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)
+18 -4
View File
@@ -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}")
+29 -3
View File
@@ -257,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"""
+5 -1
View File
@@ -118,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
+63
View File
@@ -1,4 +1,6 @@
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
@@ -18,6 +20,53 @@ from ..models import (
AdAttribution, 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]:
stmt = select(User).where(User.user_id == user_id) stmt = select(User).where(User.user_id == user_id)
@@ -52,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)
@@ -80,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]:
+74
View File
@@ -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,
),
] ]
+1
View File
@@ -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)