Compare commits

...
32 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
machka paslaandGitHub 183bef070d Merge pull request #124 from machka-pasla/dev
yookassa bug fix and other
2025-11-10 15:49:45 +03:00
machka pasla 643bf5fabb fix bug 2025-11-07 20:47:09 +03:00
machka pasla 7d446d5f64 upd promo bug 2025-11-06 19:03:34 +03:00
machka paslaandGitHub 73ce689d5e Merge pull request #120 from streletskiy/feature/user-profile-links
Feature/user profile links
2025-11-03 17:27:49 +03:00
Bogdan Strielecki 5791f2e58c Add user profile links
User profile buttons quickly opens user's telegram profile

Add user profile link button to log messages
Add user profile link button to user card in admin panel
Add referrer profile link button to log messages
Add referrer profile link button to user card in admin panel
2025-11-03 16:00:50 +03:00
machka pasla 69cc2cbe29 upd 2025-11-02 15:16:27 +03:00
machka pasla 7df2e127a4 fix 2025-10-31 22:41:48 +03:00
machka pasla d503cf7f7f YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING param added 2025-10-31 21:38:41 +03:00
machka paslaandGitHub c0d70030c3 Merge pull request #117 from machka-pasla/dev
Dev
2025-10-30 20:46:31 +03:00
machka paslaandGitHub 4e7c36dbf7 Merge pull request #115 from streletskiy/feature/paged-users-list
Paged list of all users
2025-10-30 17:57:00 +03:00
Bogdan Strielecki 3b0b88e9a0 Add admin button for delete user from bot and panel 2025-10-30 17:55:08 +03:00
Bogdan Strielecki 207e7751cd Add paged list of all users to admin panel 2025-10-28 15:13:19 +03:00
machka paslaandGitHub c65b608f0b Merge pull request #114 from machka-pasla/dev
more payment info in admin, docker compose for other servers, fixed channel sub check
2025-10-28 14:40:59 +03:00
machka pasla cff8368b7c docker hub push 2025-10-28 14:39:47 +03:00
machka paslaandGitHub 2f367bbd6b Merge pull request #112 from streletskiy/feature/display-user-income
Additional payment info in admin panel user card
2025-10-28 14:31:47 +03:00
machka pasla c3d6324ec6 fixed channel sub check 2025-10-27 15:58:59 +03:00
Bogdan Strielecki 4800a2da80 Additional payment info in admin panel user card
- total income
- total income from all user referrals
2025-10-27 13:38:37 +03:00
machka paslaandGitHub 70a0cd44a1 Merge pull request #111 from freize/main
An example Docker file for deploying a bot on a separate server
2025-10-25 20:02:43 +03:00
machka paslaandGitHub 545c9c7d07 Merge pull request #109 from Snowy-Fluffy/custom-payment-id
Добавление возможности выставлять ID способа оплаты для FreeKassa в env
2025-10-25 20:02:14 +03:00
FreizeandGitHub cd60f496fa Update docker-compose-removed-server.yml 2025-10-25 19:27:57 +03:00
FreizeandGitHub f883065bb9 Add Docker Compose configuration for removed server
This configuration file was created as an example showing the installation of the bot on a separate server.
2025-10-25 19:24:50 +03:00
Snowy-Fluffy b71bd71d8d allows you to set custom payment id for fk 2025-10-24 20:45:43 +03:00
Snowy-Fluffy 0524d24b13 set custom payment id for fk 2025-10-24 20:21:35 +03:00
30 changed files with 1391 additions and 149 deletions
+3
View File
@@ -44,12 +44,14 @@ 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
FREEKASSA_API_KEY=your_api_key # API key for REST requests FREEKASSA_API_KEY=your_api_key # API key for REST requests
FREEKASSA_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications) FREEKASSA_SECOND_SECRET=your_second_secret # Secret word #2 (used to verify notifications)
FREEKASSA_PAYMENT_IP= # Public IP address reported to FreeKassa FREEKASSA_PAYMENT_IP= # Public IP address reported to FreeKassa
FREEKASSA_PAYMENT_METHOD_ID=44 # Payment method ID, you can get it from https://merchant.freekassa.net/settings/currencies
# CryptoBot Payment Gateway Configuration # CryptoBot Payment Gateway Configuration
CRYPTOPAY_TOKEN= # API token for CryptoPay CRYPTOPAY_TOKEN= # API token for CryptoPay
@@ -91,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
+14 -5
View File
@@ -9,8 +9,9 @@ on:
- dev - dev
env: env:
REGISTRY: ghcr.io GHCR_REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
jobs: jobs:
build: build:
@@ -26,18 +27,26 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry - name: Log in to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker - name: Extract metadata (tags, labels) for Docker
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: |
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
${{ env.DOCKERHUB_IMAGE }}
tags: | tags: |
type=ref,event=branch type=ref,event=branch
type=ref,event=pr type=ref,event=pr
@@ -56,4 +65,4 @@ jobs:
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
- name: Image digest - name: Image digest
run: echo ${{ steps.meta.outputs.digest }} run: echo ${{ steps.meta.outputs.digest }}
+14 -2
View File
@@ -9,6 +9,10 @@ on:
paths-ignore: paths-ignore:
- 'README.md' - 'README.md'
env:
GHCR_IMAGE: ghcr.io/${{ github.repository }}
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
jobs: jobs:
build-and-push: build-and-push:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -27,6 +31,12 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up QEMU (для эмуляции arm64 на x86) - name: Set up QEMU (для эмуляции arm64 на x86)
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@@ -40,5 +50,7 @@ jobs:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: | tags: |
ghcr.io/${{ github.repository }}:latest ${{ env.GHCR_IMAGE }}:latest
ghcr.io/${{ github.repository }}:${{ github.ref_name }} ${{ env.GHCR_IMAGE }}:${{ github.ref_name }}
${{ env.DOCKERHUB_IMAGE }}:latest
${{ env.DOCKERHUB_IMAGE }}:${{ github.ref_name }}
+5
View File
@@ -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`). |
@@ -91,6 +93,7 @@
| `FREEKASSA_SECOND_SECRET` | Секретное слово №2 — используется для проверки уведомлений от FreeKassa. | | `FREEKASSA_SECOND_SECRET` | Секретное слово №2 — используется для проверки уведомлений от FreeKassa. |
| `FREEKASSA_PAYMENT_URL` | (Опционально, legacy SCI) Базовый URL платёжной формы FreeKassa. По умолчанию `https://pay.freekassa.ru/`. | | `FREEKASSA_PAYMENT_URL` | (Опционально, legacy SCI) Базовый URL платёжной формы FreeKassa. По умолчанию `https://pay.freekassa.ru/`. |
| `FREEKASSA_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. | | `FREEKASSA_PAYMENT_IP` | Внешний IP вашего сервера, который будет передаваться в запрос оплаты. |
| `FREEKASSA_PAYMENT_METHOD_ID` | ID метода оплаты через магазин FreeKassa. По умолчанию `44`. |
| `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). | | `STARS_ENABLED` | Включить/выключить Telegram Stars (`true`/`false`). |
| `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). | | `TRIBUTE_ENABLED`| Включить/выключить Tribute (`true`/`false`). |
</details> </details>
@@ -163,6 +166,8 @@
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки. Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки.
Для автоматической публикации образов настроены GitHub Actions (`.github/workflows`). По умолчанию образы пушатся в GitHub Container Registry и Docker Hub. Добавьте в Secrets репозитория значения `DOCKERHUB_USERNAME` и `DOCKERHUB_TOKEN` (персональный access token или пароль для Docker Hub), чтобы загрузка в Docker Hub работала корректно.
## 📁 Структура проекта ## 📁 Структура проекта
``` ```
+15 -1
View File
@@ -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":
+182 -68
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
@@ -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")
+467 -31
View File
@@ -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,
@@ -192,6 +306,20 @@ async def format_user_card(user: User, session: AsyncSession,
trial_status = _("admin_user_trial_used", default="Использовал") if had_subscriptions else _("admin_user_trial_not_used", default="Не использовал") trial_status = _("admin_user_trial_used", default="Использовал") if had_subscriptions else _("admin_user_trial_not_used", default="Не использовал")
card_parts.append(f"{_('admin_user_trial_label', default='🏡 <b>Триал:</b>')} {hcode(trial_status)}") card_parts.append(f"{_('admin_user_trial_label', default='🏡 <b>Триал:</b>')} {hcode(trial_status)}")
# Financial analytics (admin-only)
try:
from db.dal import payment_dal
# Total amount paid by this user
total_paid = await payment_dal.get_user_total_paid(session, user.user_id)
card_parts.append(f"{_('admin_user_total_paid_label', default='💰 <b>Всего оплачено:</b>')} {hcode(f'{total_paid:.2f} RUB')}")
# Total revenue from referrals
referral_revenue = await payment_dal.get_referral_revenue(session, user.user_id)
card_parts.append(f"{_('admin_user_referral_revenue_label', default='💸 <b>Доход по рефералам:</b>')} {hcode(f'{referral_revenue:.2f} RUB')}")
except Exception as e_fin:
logging.error(f"Failed to build financial analytics for admin card {user.user_id}: {e_fin}")
# Referral stats # Referral stats
if referral_service is not None: if referral_service is not None:
try: try:
@@ -252,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:
@@ -310,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)
@@ -513,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"
) )
@@ -535,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,
@@ -588,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:
@@ -697,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"
) )
@@ -897,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")
@@ -964,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)
+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 = _(
+12
View File
@@ -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(
+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)
+29 -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}")
@@ -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'}."
) )
+21 -1
View File
@@ -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()
+10 -4
View File
@@ -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:
@@ -881,10 +887,10 @@ async def pay_fk_callback_handler(
months=months, months=months,
amount=price_rub, amount=price_rub,
currency=freekassa_service.default_currency, currency=freekassa_service.default_currency,
method_code=44, payment_method_id=freekassa_service.payment_method_id,
ip_address=freekassa_service.server_ip, ip_address=freekassa_service.server_ip,
extra_params={ extra_params={
"us_method": 44, "us_method": freekassa_service.payment_method_id,
}, },
) )
+73 -2
View File
@@ -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}")
+8
View File
@@ -40,6 +40,14 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
if not event_user or event_user.id in self.settings.ADMIN_IDS: if not event_user or event_user.id in self.settings.ADMIN_IDS:
return await handler(event, data) return await handler(event, data)
callback_query = event.callback_query
if (
callback_query
and callback_query.data
and callback_query.data == "channel_subscription:verify"
):
return await handler(event, data)
# Allow /start to reach the handler so the check can be re-run. # Allow /start to reach the handler so the check can be re-run.
message_object: Optional[Message] = event.message message_object: Optional[Message] = event.message
if ( if (
+3 -2
View File
@@ -45,6 +45,7 @@ class FreeKassaService:
self.second_secret: Optional[str] = settings.FREEKASSA_SECOND_SECRET self.second_secret: Optional[str] = settings.FREEKASSA_SECOND_SECRET
self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper() self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
self.server_ip: Optional[str] = settings.FREEKASSA_PAYMENT_IP self.server_ip: Optional[str] = settings.FREEKASSA_PAYMENT_IP
self.payment_method_id: Optional[int] = settings.FREEKASSA_PAYMENT_METHOD_ID
self.api_base_url: str = "https://api.fk.life/v1" self.api_base_url: str = "https://api.fk.life/v1"
self._timeout = ClientTimeout(total=15) self._timeout = ClientTimeout(total=15)
@@ -72,9 +73,9 @@ class FreeKassaService:
months: int, months: int,
amount: float, amount: float,
currency: Optional[str], currency: Optional[str],
method_code: int,
email: Optional[str] = None, email: Optional[str] = None,
ip_address: Optional[str] = None, ip_address: Optional[str] = None,
payment_method_id: Optional[int] = None,
extra_params: Optional[Dict[str, Any]] = None, extra_params: Optional[Dict[str, Any]] = None,
) -> Tuple[bool, Dict[str, Any]]: ) -> Tuple[bool, Dict[str, Any]]:
if not self.configured: if not self.configured:
@@ -94,7 +95,7 @@ class FreeKassaService:
"shopId": int(self.shop_id), "shopId": int(self.shop_id),
"nonce": await self._generate_nonce(), "nonce": await self._generate_nonce(),
"paymentId": str(payment_db_id), "paymentId": str(payment_db_id),
"i": int(method_code), "i": int(payment_method_id),
"amount": amount_str, "amount": amount_str,
"currency": currency_code, "currency": currency_code,
"email": email, "email": email,
+61 -10
View File
@@ -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):
+31
View File
@@ -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,
+31 -3
View File
@@ -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"""
+11 -1
View File
@@ -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
+1
View File
@@ -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()
+10 -1
View File
@@ -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
@@ -57,6 +61,7 @@ class Settings(BaseSettings):
FREEKASSA_PAYMENT_URL: str = Field(default="https://pay.freekassa.ru/") FREEKASSA_PAYMENT_URL: str = Field(default="https://pay.freekassa.ru/")
FREEKASSA_API_KEY: Optional[str] = None FREEKASSA_API_KEY: Optional[str] = None
FREEKASSA_PAYMENT_IP: Optional[str] = None FREEKASSA_PAYMENT_IP: Optional[str] = None
FREEKASSA_PAYMENT_METHOD_ID: Optional[int] = None
YOOKASSA_ENABLED: bool = Field(default=True) YOOKASSA_ENABLED: bool = Field(default=True)
STARS_ENABLED: bool = Field(default=True) STARS_ENABLED: bool = Field(default=True)
@@ -113,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
+34
View File
@@ -262,3 +262,37 @@ async def get_last_tribute_payment(
Payment.created_at.desc()).limit(1)) Payment.created_at.desc()).limit(1))
result = await session.execute(stmt) result = await session.execute(stmt)
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def get_user_total_paid(session: AsyncSession, user_id: int) -> float:
"""Get total amount paid by a specific user (sum of all succeeded payments)."""
stmt = select(func.sum(Payment.amount)).where(
and_(
Payment.user_id == user_id,
Payment.status == 'succeeded'
)
)
result = await session.execute(stmt)
total = result.scalar()
return float(total or 0)
async def get_referral_revenue(session: AsyncSession, referrer_id: int) -> float:
"""Get total revenue generated from referred users' payments.
This calculates the sum of all succeeded payments made by users
where referred_by_id equals the referrer_id.
"""
from db.models import User
stmt = select(func.sum(Payment.amount)).join(
User, Payment.user_id == User.user_id
).where(
and_(
User.referred_by_id == referrer_id,
Payment.status == 'succeeded'
)
)
result = await session.execute(stmt)
total = result.scalar()
return float(total or 0)
+16
View File
@@ -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
View File
@@ -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
+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)
+41
View File
@@ -0,0 +1,41 @@
services:
remnawave-tg-shop:
image: ghcr.io/machka-pasla/remnawave-tg-shop:latest
container_name: remnawave-tg-shop
hostname: remnawave-tg-shop
env_file:
- .env
ports:
- 8080:8080
networks:
- remnawave-network
environment:
- TZ=UTC
volumes:
- ./locales:/app/locales
restart: unless-stopped
depends_on:
- remnawave-tg-shop-db
remnawave-tg-shop-db:
image: postgres:17
container_name: remnawave-tg-shop-db
hostname: remnawave-tg-shop-db
env_file:
- .env
environment:
- TZ=UTC
volumes:
- remnawave-tg-shop-db-data:/var/lib/postgresql/data
networks:
- remnawave-network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 20
networks:
remnawave-network: null
volumes:
remnawave-tg-shop-db-data:
name: remnawave-tg-shop-db-data
+22 -2
View File
@@ -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}",
@@ -337,6 +354,8 @@
"admin_user_na_value": "N/A", "admin_user_na_value": "N/A",
"admin_user_subscription_none": "No active subscription", "admin_user_subscription_none": "No active subscription",
"admin_user_actions_count_label": "📜 <b>Total actions:</b>", "admin_user_actions_count_label": "📜 <b>Total actions:</b>",
"admin_user_total_paid_label": "💰 <b>Total Paid:</b>",
"admin_user_referral_revenue_label": "💸 <b>Referral Revenue:</b>",
"admin_user_invited_friends_label": "👥 <b>Friends invited:</b>", "admin_user_invited_friends_label": "👥 <b>Friends invited:</b>",
"admin_user_ref_purchased_label": "💳 <b>Purchased subscription:</b>", "admin_user_ref_purchased_label": "💳 <b>Purchased subscription:</b>",
"admin_user_subscription_active_until": "⏰ <b>Active until:</b>", "admin_user_subscription_active_until": "⏰ <b>Active until:</b>",
@@ -424,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.",
+22 -2
View File
@@ -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}",
@@ -337,6 +354,8 @@
"admin_user_na_value": "N/A", "admin_user_na_value": "N/A",
"admin_user_subscription_none": "Нет активной подписки", "admin_user_subscription_none": "Нет активной подписки",
"admin_user_actions_count_label": "📜 <b>Всего действий:</b>", "admin_user_actions_count_label": "📜 <b>Всего действий:</b>",
"admin_user_total_paid_label": "💰 <b>Всего оплачено:</b>",
"admin_user_referral_revenue_label": "💸 <b>Доход по рефералам:</b>",
"admin_user_invited_friends_label": "👥 <b>Приглашено друзей:</b>", "admin_user_invited_friends_label": "👥 <b>Приглашено друзей:</b>",
"admin_user_ref_purchased_label": "💳 <b>Купили подписку:</b>", "admin_user_ref_purchased_label": "💳 <b>Купили подписку:</b>",
"admin_user_subscription_active_until": "⏰ <b>Действует до:</b>", "admin_user_subscription_active_until": "⏰ <b>Действует до:</b>",
@@ -424,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": "Ошибка платежного сервиса. Попробуйте позже.",