Compare commits

...
17 Commits
Author SHA1 Message Date
3252a8 85c276bcf5 Setup ghcr 2026-04-03 20:36:34 +03:00
3252a8 55f8db11ce Add setup instructions 2026-02-15 18:58:52 +03:00
3252a8 79af61a48e Add configurable welcome bonus days for referred signups
- add REFERRAL_WELCOME_BONUS_DAYS setting (default 3) in settings.py
- document REFERRAL_WELCOME_BONUS_DAYS in .env.example
- apply welcome bonus on first /start only for newly created users with referred_by_id
- replace hardcoded 3 days with settings.REFERRAL_WELCOME_BONUS_DAYS
- skip bonus flow when value is 0 or less
- send user notification after successful bonus application
- add i18n key referral_welcome_bonus_applied to ru.json and en.json
2026-02-11 13:26:56 +03:00
3252a8 69d2e2a899 Fix promo failed message when using deeplink 2026-02-10 19:53:55 +03:00
3252a8 069ad967b5 Add user ref page deeplink 2026-02-10 19:36:29 +03:00
3252a8 18a6b3e18d Tune users rating 2026-02-09 10:29:40 +03:00
3252a8 3b9043332c Move user links to id's from buttons 2026-02-09 10:22:06 +03:00
3252a8 d94b57bd0b User card links in ratings 2026-02-09 10:09:16 +03:00
3252a8 5247092ca2 Add users rating feature 2026-02-09 10:03:55 +03:00
3252a8 049789c9c5 Tune invite buttons 2026-02-06 12:48:37 +03:00
3252a8 6b74ae4a8e Update locals 2026-01-25 10:20:54 +03:00
3252a8 c13c01ea43 Update traffic limit info in user profile 2026-01-25 10:16:00 +03:00
3252a8 48e7d3569f Fix used traffic display 2026-01-25 10:11:16 +03:00
3252a8 4f3b45cbd8 Merge branch 'main' into fork-new
# Conflicts:
#	docker-compose.yml
2026-01-25 09:57:57 +03:00
3252a8 90ab186a7c Add platega payment request logging 2026-01-07 22:42:00 +03:00
3252a8 dc725ccd68 Update locales 2026-01-05 23:51:33 +03:00
3252a8 d88aee03bb Add user agreement and privacy policy links 2026-01-05 23:42:51 +03:00
22 changed files with 730 additions and 80 deletions
+3
View File
@@ -17,6 +17,8 @@ DEFAULT_CURRENCY_SYMBOL="RUB" #
SUPPORT_LINK=https://t.me/your_support_link # Link to the support chat SUPPORT_LINK=https://t.me/your_support_link # Link to the support chat
SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service # Link to the server status page SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service # Link to the server status page
TERMS_OF_SERVICE_URL=https://example.com/tos # Link to the terms of service TERMS_OF_SERVICE_URL=https://example.com/tos # Link to the terms of service
PRIVACY_POLICY_URL=https://example.com/privacy # Link to the privacy policy
USER_AGREEMENT_URL=https://example.com/user-agreement # Link to the user agreement
SUBSCRIPTION_MINI_APP_URL= # URL of the subscription mini-app SUBSCRIPTION_MINI_APP_URL= # URL of the subscription mini-app
START_COMMAND_DESCRIPTION= # Description of the /start command START_COMMAND_DESCRIPTION= # Description of the /start command
DISABLE_WELCOME_MESSAGE= # Disable the welcome message DISABLE_WELCOME_MESSAGE= # Disable the welcome message
@@ -122,6 +124,7 @@ SUBSCRIPTION_NOTIFY_DAYS_BEFORE=3 #
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
REFERRAL_WELCOME_BONUS_DAYS=3 # Welcome bonus for newly registered user from referral link
LEGACY_REFS=true # Allow ref_<tg_id> links. Leave unset/true unless you want to disable old links 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
+6 -15
View File
@@ -1,4 +1,4 @@
name: Build and Push Dev Docker Image name: Build and Push Dev GHCR Image
on: on:
push: push:
@@ -9,9 +9,7 @@ on:
- dev - dev
env: env:
GHCR_REGISTRY: ghcr.io GHCR_IMAGE: ghcr.io/3252a8/remnawave-tg-shop
IMAGE_NAME: ${{ github.repository }}
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
jobs: jobs:
build: build:
@@ -24,29 +22,22 @@ jobs:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry - name: Log in to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ${{ env.GHCR_REGISTRY }} registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub - name: Set up Docker Buildx
uses: docker/login-action@v3 uses: docker/setup-buildx-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: | images: |
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }} ${{ env.GHCR_IMAGE }}
${{ env.DOCKERHUB_IMAGE }}
tags: | tags: |
type=ref,event=branch type=ref,event=branch
type=ref,event=pr type=ref,event=pr
+14 -14
View File
@@ -1,4 +1,4 @@
name: Build and Publish multi-arch Docker Image name: Build and Publish GHCR Docker Image
on: on:
push: push:
@@ -10,8 +10,7 @@ on:
- 'README.md' - 'README.md'
env: env:
GHCR_IMAGE: ghcr.io/${{ github.repository }} GHCR_IMAGE: ghcr.io/3252a8/remnawave-tg-shop
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
jobs: jobs:
build-and-push: build-and-push:
@@ -31,26 +30,27 @@ 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
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.GHCR_IMAGE }}
tags: |
type=raw,value=latest
type=semver,pattern={{version}}
- name: Build and push multi-arch Docker image - name: Build and push multi-arch Docker image
uses: docker/build-push-action@v5 uses: docker/build-push-action@v5
with: with:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: | tags: ${{ steps.meta.outputs.tags }}
${{ env.GHCR_IMAGE }}:latest labels: ${{ steps.meta.outputs.labels }}
${{ env.GHCR_IMAGE }}:${{ github.ref_name }}
${{ env.DOCKERHUB_IMAGE }}:latest
${{ env.DOCKERHUB_IMAGE }}:${{ github.ref_name }}
+9 -4
View File
@@ -45,7 +45,7 @@
1. **Клонируйте репозиторий:** 1. **Клонируйте репозиторий:**
```bash ```bash
git clone https://github.com/kavore/remnawave-tg-shop git clone https://github.com/3252a8/remnawave-tg-shop
cd remnawave-tg-shop cd remnawave-tg-shop
``` ```
@@ -182,7 +182,7 @@
### 1. Клонирование репозитория ### 1. Клонирование репозитория
```bash ```bash
git clone https://github.com/kavore/remnawave-tg-shop && cd remnawave-tg-shop git clone https://github.com/3252a8/remnawave-tg-shop && cd remnawave-tg-shop
``` ```
### 2. Настройка переменных окружения ### 2. Настройка переменных окружения
@@ -325,9 +325,14 @@ cd /opt/remnawave/nginx && docker compose down && docker compose up -d && docker
## 🐳 Docker ## 🐳 Docker
Файлы `Dockerfile` и `docker-compose.yml` уже настроены для сборки и запуска проекта. `docker-compose.yml` использует готовый образ с GitHub Container Registry, но вы можете раскомментировать `build: .` для локальной сборки. Файлы `Dockerfile` и `docker-compose.yml` уже настроены для локальной сборки и запуска проекта. Если нужен готовый образ из GHCR, используйте `docker-compose-remote-server.yml` или переключите `docker-compose.yml` на строку `image:` вместо `build: .`.
Для автоматической публикации образов настроены GitHub Actions (`.github/workflows`). По умолчанию образы пушатся в GitHub Container Registry и Docker Hub. Добавьте в Secrets репозитория значения `DOCKERHUB_USERNAME` и `DOCKERHUB_TOKEN` (персональный access token или пароль для Docker Hub), чтобы загрузка в Docker Hub работала корректно. Образ публикуется в GitHub Container Registry по пути `ghcr.io/3252a8/remnawave-tg-shop`. GitHub Actions выкладывают теги `latest` и `0.1.0`: `latest` обновляется из `main`, а `0.1.0` появляется при сборке тега `v0.1.0`.
Чтобы закрепить версию на сервере, можно запустить:
```bash
IMAGE_TAG=0.1.0 docker compose -f docker-compose-remote-server.yml up -d
```
## 📁 Структура проекта ## 📁 Структура проекта
+3
View File
@@ -141,6 +141,9 @@ async def admin_panel_actions_callback_handler(
from . import payments as admin_payments_handlers from . import payments as admin_payments_handlers
await admin_payments_handlers.view_payments_handler( await admin_payments_handlers.view_payments_handler(
callback, i18n_data, settings, session) callback, i18n_data, settings, session)
elif action == "user_ratings":
await admin_stats_handlers.show_user_ratings_handler(
callback, i18n_data, settings, session)
elif action == "ads": elif action == "ads":
from . import ads as admin_ads_handlers from . import ads as admin_ads_handlers
await admin_ads_handlers.show_ads_menu(callback, settings, i18n_data, session) await admin_ads_handlers.show_ads_menu(callback, settings, i18n_data, session)
+135 -1
View File
@@ -2,6 +2,7 @@ import logging
from aiogram import Router, F, types from aiogram import Router, F, types
from typing import Optional, Dict, List from typing import Optional, Dict, List
from datetime import datetime from datetime import datetime
import html
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config.settings import Settings from config.settings import Settings
@@ -10,12 +11,44 @@ from db.dal import user_dal, payment_dal, panel_sync_dal
from db.models import Payment, PanelSyncStatus from db.models import Payment, PanelSyncStatus
from bot.services.panel_api_service import PanelApiService from bot.services.panel_api_service import PanelApiService
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard from bot.keyboards.inline.admin_keyboards import (
get_back_to_admin_panel_keyboard,
get_back_to_user_management_keyboard,
)
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
router = Router(name="admin_statistics_router") router = Router(name="admin_statistics_router")
def _format_rating_user_label(user_row: Dict[str, object],
bot_username: Optional[str] = None) -> str:
user_id = int(user_row.get("user_id", 0) or 0)
username = user_row.get("username")
first_name = user_row.get("first_name")
user_id_text = str(user_id)
user_id_html = html.escape(user_id_text)
if bot_username:
safe_bot_username = html.escape(bot_username)
user_id_html = (
f'<a href="https://t.me/{safe_bot_username}?start=admin_user_{user_id_text}">'
f"{user_id_html}</a>"
)
parts: List[str] = []
if username:
parts.append(f"@{html.escape(str(username))}")
elif first_name:
parts.append(html.escape(str(first_name)))
if not parts:
parts.append(f"ID {user_id_html}")
else:
parts.append(f"(ID {user_id_html})")
return " ".join(parts)
async def show_statistics_handler(callback: types.CallbackQuery, async def show_statistics_handler(callback: types.CallbackQuery,
i18n_data: dict, settings: Settings, i18n_data: dict, settings: Settings,
session: AsyncSession): session: AsyncSession):
@@ -255,3 +288,104 @@ async def show_statistics_handler(callback: types.CallbackQuery,
reply_markup=get_back_to_admin_panel_keyboard( reply_markup=get_back_to_admin_panel_keyboard(
current_lang, i18n)) current_lang, i18n))
break break
async def show_user_ratings_handler(
callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error displaying ratings.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
await callback.answer()
top_limit = 10
bot_username: Optional[str] = None
try:
me = await callback.bot.get_me()
bot_username = me.username
except Exception as e_get_me:
logging.warning("Failed to resolve bot username for ratings links: %s",
e_get_me)
traffic_top = await user_dal.get_top_users_by_traffic_used(session, limit=top_limit)
lifetime_traffic_top = await user_dal.get_top_users_by_lifetime_traffic_used(
session, limit=top_limit
)
invited_top = await user_dal.get_top_users_by_referrals_count(session, limit=top_limit)
revenue_top = await user_dal.get_top_users_by_referral_revenue(session, limit=top_limit)
text_parts: List[str] = [
_("admin_user_ratings_header", top_limit=top_limit),
"",
f"<b>{_('admin_user_ratings_traffic_month_title')}</b>",
]
if traffic_top:
for idx, row in enumerate(traffic_top, start=1):
traffic_gb = float(row.get("traffic_used_bytes") or 0) / (1024**3)
text_parts.append(
_(
"admin_user_ratings_traffic_item",
rank=idx,
user=_format_rating_user_label(row, bot_username),
traffic_gb=f"{traffic_gb:.2f}",
)
)
else:
text_parts.append(_("admin_user_ratings_empty"))
text_parts.extend(["", f"<b>{_('admin_user_ratings_traffic_lifetime_title')}</b>"])
if lifetime_traffic_top:
for idx, row in enumerate(lifetime_traffic_top, start=1):
traffic_gb = float(row.get("lifetime_used_traffic_bytes") or 0) / (1024**3)
text_parts.append(
_(
"admin_user_ratings_traffic_item",
rank=idx,
user=_format_rating_user_label(row, bot_username),
traffic_gb=f"{traffic_gb:.2f}",
)
)
else:
text_parts.append(_("admin_user_ratings_empty"))
text_parts.extend(["", f"<b>{_('admin_user_ratings_invited_title')}</b>"])
if invited_top:
for idx, row in enumerate(invited_top, start=1):
text_parts.append(
_(
"admin_user_ratings_invited_item",
rank=idx,
user=_format_rating_user_label(row, bot_username),
invited_count=int(row.get("invited_count") or 0),
)
)
else:
text_parts.append(_("admin_user_ratings_empty"))
text_parts.extend(["", f"<b>{_('admin_user_ratings_revenue_title')}</b>"])
if revenue_top:
for idx, row in enumerate(revenue_top, start=1):
text_parts.append(
_(
"admin_user_ratings_revenue_item",
rank=idx,
user=_format_rating_user_label(row, bot_username),
revenue=f"{float(row.get('referral_revenue') or 0):.2f}",
)
)
else:
text_parts.append(_("admin_user_ratings_empty"))
await callback.message.edit_text(
"\n".join(text_parts),
reply_markup=get_back_to_user_management_keyboard(current_lang, i18n),
parse_mode="HTML",
)
+26
View File
@@ -18,6 +18,24 @@ from bot.middlewares.i18n import JsonI18n
router = Router(name="admin_sync_router") router = Router(name="admin_sync_router")
def _extract_lifetime_used_traffic_bytes(panel_user_data: dict) -> Optional[int]:
user_traffic = panel_user_data.get("userTraffic") or {}
raw_value = (
user_traffic.get("lifetimeUsedTrafficBytes")
if isinstance(user_traffic, dict)
else None
)
if raw_value is None:
raw_value = panel_user_data.get("lifetimeUsedTrafficBytes")
try:
if raw_value is None:
return None
return int(raw_value)
except (TypeError, ValueError):
return None
async def perform_sync( async def perform_sync(
panel_service: PanelApiService, panel_service: PanelApiService,
session: AsyncSession, session: AsyncSession,
@@ -176,6 +194,14 @@ async def perform_sync(
f"Updated panel UUID for user {actual_user_id}: {panel_uuid}" f"Updated panel UUID for user {actual_user_id}: {panel_uuid}"
) )
lifetime_used = _extract_lifetime_used_traffic_bytes(panel_user_dict)
if (
lifetime_used is not None
and existing_user.lifetime_used_traffic_bytes != lifetime_used
):
existing_user.lifetime_used_traffic_bytes = lifetime_used
user_was_updated = True
# 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:
+36 -4
View File
@@ -33,6 +33,26 @@ 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}$")
def _format_traffic_period(strategy: Optional[str], get_text: Callable[..., str]) -> Optional[str]:
if not strategy:
return None
strategy_upper = str(strategy).upper()
key_map = {
"MONTH": "traffic_period_month",
"WEEK": "traffic_period_week",
"DAY": "traffic_period_day",
"NO_RESET": "traffic_period_no_reset",
}
label_key = key_map.get(strategy_upper)
return get_text(label_key) if label_key else strategy_upper
def _format_used_with_period(get_text: Callable[..., str], used_display: str, period_label: Optional[str]) -> str:
if not period_label:
return used_display
return get_text("traffic_used_with_period", traffic_used=used_display, traffic_period=period_label)
async def users_list_handler(callback: types.CallbackQuery, async def users_list_handler(callback: types.CallbackQuery,
i18n_data: dict, settings: Settings, i18n_data: dict, settings: Settings,
session: AsyncSession, page: int = 0): session: AsyncSession, page: int = 0):
@@ -263,10 +283,22 @@ async def format_user_card(user: User, session: AsyncSession,
traffic_limit = subscription_details.get('traffic_limit_bytes') traffic_limit = subscription_details.get('traffic_limit_bytes')
traffic_used = subscription_details.get('traffic_used_bytes') traffic_used = subscription_details.get('traffic_used_bytes')
if traffic_limit and traffic_used is not None: traffic_strategy = subscription_details.get('traffic_limit_strategy')
traffic_limit_gb = traffic_limit / (1024**3) period_label = _format_traffic_period(traffic_strategy, _)
traffic_used_gb = traffic_used / (1024**3) if traffic_used is not None or traffic_limit is not None:
card_parts.append(f"{_('admin_user_traffic_label')} {hcode(f'{traffic_used_gb:.2f}GB / {traffic_limit_gb:.2f}GB')}") used_display = _("traffic_na")
if traffic_used is not None:
traffic_used_gb = traffic_used / (1024**3)
used_display = f"{traffic_used_gb:.2f}GB"
used_display = _format_used_with_period(_, used_display, period_label)
if traffic_limit:
traffic_limit_gb = traffic_limit / (1024**3)
limit_display = f"{traffic_limit_gb:.2f}GB"
else:
limit_display = _("traffic_unlimited")
card_parts.append(f"{_('admin_user_traffic_label')} {hcode(f'{used_display} / {limit_display}')}")
else: else:
card_parts.append(f"{_('admin_user_subscription_label')} {hcode(_('admin_user_subscription_none'))}") card_parts.append(f"{_('admin_user_subscription_label')} {hcode(_('admin_user_subscription_none'))}")
except Exception as e: except Exception as e:
+154 -3
View File
@@ -16,6 +16,7 @@ from bot.keyboards.inline.user_keyboards import (
get_main_menu_inline_keyboard, get_main_menu_inline_keyboard,
get_language_selection_keyboard, get_language_selection_keyboard,
get_channel_subscription_keyboard, get_channel_subscription_keyboard,
get_information_links_keyboard,
) )
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
@@ -304,16 +305,21 @@ async def ensure_required_channel_subscription(
@router.message(CommandStart()) @router.message(CommandStart())
@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"^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"^admin_user_(\d+)$").as_("admin_user_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^page_ref$").as_("page_ref_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^(?!ref_|promo_|admin_user_|page_ref$)([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,
state: FSMContext, state: FSMContext,
settings: Settings, settings: Settings,
i18n_data: dict, i18n_data: dict,
subscription_service: SubscriptionService, subscription_service: SubscriptionService,
referral_service: ReferralService,
session: AsyncSession, session: AsyncSession,
ref_match: Optional[re.Match] = None, ref_match: Optional[re.Match] = None,
promo_match: Optional[re.Match] = None, promo_match: Optional[re.Match] = None,
ad_param_match: Optional[re.Match] = None): page_ref_match: Optional[re.Match] = None,
ad_param_match: Optional[re.Match] = None,
admin_user_match: Optional[re.Match] = None):
await state.clear() await state.clear()
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")
@@ -323,8 +329,61 @@ async def start_command_handler(message: types.Message,
user = message.from_user user = message.from_user
user_id = user.id user_id = user.id
if admin_user_match and user_id in settings.ADMIN_IDS:
target_user_id = int(admin_user_match.group(1))
target_user = await user_dal.get_user_by_id(session, target_user_id)
if not target_user:
await message.answer(
_("admin_user_not_found", input=hd.quote(str(target_user_id)))
)
return
try:
from bot.handlers.admin.user_management import (
format_user_card,
get_user_card_keyboard,
_send_with_profile_link_fallback,
)
referral_service = ReferralService(
settings, subscription_service, message.bot, i18n
)
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,
target_user.referred_by_id,
)
await _send_with_profile_link_fallback(
message.answer,
text=user_card_text,
markup=keyboard.as_markup(),
user_id=target_user.user_id,
parse_mode="HTML",
)
return
except Exception as e_admin_card:
logging.error(
"Failed to open admin user card via deep-link for %s: %s",
target_user_id,
e_admin_card,
exc_info=True,
)
await message.answer(_("admin_user_card_error"))
return
referred_by_user_id: Optional[int] = None referred_by_user_id: Optional[int] = None
promo_code_to_apply: Optional[str] = None promo_code_to_apply: Optional[str] = None
should_open_referral_from_start = False
ad_start_param: Optional[str] = None ad_start_param: Optional[str] = None
if ref_match: if ref_match:
@@ -348,6 +407,9 @@ async def start_command_handler(message: types.Message,
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}")
elif page_ref_match:
should_open_referral_from_start = True
logging.info(f"User {user_id} started with page_ref deep-link.")
elif ad_param_match: elif ad_param_match:
ad_start_param = ad_param_match.group(1) ad_start_param = ad_param_match.group(1)
logging.info(f"User {user_id} started with ad start param: {ad_start_param}") logging.info(f"User {user_id} started with ad start param: {ad_start_param}")
@@ -357,6 +419,7 @@ async def start_command_handler(message: types.Message,
sanitized_last_name = sanitize_display_name(user.last_name) sanitized_last_name = sanitize_display_name(user.last_name)
db_user = await user_dal.get_user_by_id(session, user_id) db_user = await user_dal.get_user_by_id(session, user_id)
is_existing_user = db_user is not None
if not db_user: if not db_user:
user_data_to_create = { user_data_to_create = {
"user_id": user_id, "user_id": user_id,
@@ -386,6 +449,50 @@ async def start_command_handler(message: types.Message,
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'}."
) )
# Auto-grant referral welcome bonus to newly registered referred users.
referral_welcome_days = max(
0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0)
)
if referred_by_user_id and referral_welcome_days > 0:
try:
referral_bonus_end_date = await subscription_service.extend_active_subscription_days(
session,
user_id,
referral_welcome_days,
reason="referral_welcome_bonus",
)
if referral_bonus_end_date:
await session.commit()
logging.info(
"Referral welcome bonus applied: user %s got %s days, new end date %s.",
user_id,
referral_welcome_days,
referral_bonus_end_date.isoformat(),
)
await message.answer(
_(
"referral_welcome_bonus_applied",
days=referral_welcome_days,
end_date=referral_bonus_end_date.strftime("%d.%m.%Y %H:%M:%S"),
),
parse_mode="HTML",
)
else:
await session.rollback()
logging.warning(
"Referral welcome bonus was not applied for user %s (referred by %s).",
user_id,
referred_by_user_id,
)
except Exception as referral_bonus_error:
await session.rollback()
logging.error(
"Failed to apply referral welcome bonus for user %s: %s",
user_id,
referral_bonus_error,
exc_info=True,
)
# Send notification about new user registration # Send notification about new user registration
try: try:
from bot.services.notification_service import NotificationService from bot.services.notification_service import NotificationService
@@ -458,8 +565,12 @@ async def start_command_handler(message: types.Message,
db_user): db_user):
return return
open_referral_page_for_existing_user = (
should_open_referral_from_start and is_existing_user
)
# Send welcome message if not disabled # Send welcome message if not disabled
if not settings.DISABLE_WELCOME_MESSAGE: if not settings.DISABLE_WELCOME_MESSAGE and not open_referral_page_for_existing_user:
await message.answer(_(key="welcome", user_name=hd.quote(user.full_name))) await message.answer(_(key="welcome", user_name=hd.quote(user.full_name)))
# Auto-apply promo code if provided via start parameter # Auto-apply promo code if provided via start parameter
@@ -508,12 +619,20 @@ async def start_command_handler(message: types.Message,
else: else:
await session.rollback() await session.rollback()
logging.warning(f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}: {result}") logging.warning(f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}: {result}")
await message.answer(str(result), parse_mode="HTML")
# Continue to show main menu if promo failed # Continue to show main menu if promo failed
except Exception as e: except Exception as e:
logging.error(f"Error auto-applying promo code '{promo_code_to_apply}' for user {user_id}: {e}") logging.error(f"Error auto-applying promo code '{promo_code_to_apply}' for user {user_id}: {e}")
await session.rollback() await session.rollback()
if open_referral_page_for_existing_user:
from . import referral as user_referral_handlers
await user_referral_handlers.referral_command_handler(
message, settings, i18n_data, referral_service, message.bot, session
)
return
await send_main_menu(message, await send_main_menu(message,
settings, settings,
i18n_data, i18n_data,
@@ -696,6 +815,38 @@ async def main_action_callback_handler(
elif action == "language": elif action == "language":
await language_command_handler(callback, i18n_data, settings) await language_command_handler(callback, i18n_data, settings)
elif action == "info":
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
current_lang = i18n_data.get("current_language",
settings.DEFAULT_LANGUAGE)
if not i18n:
await callback.answer("Language service error.",
show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(
current_lang, key, **kwargs) if i18n else key
privacy_url = settings.PRIVACY_POLICY_URL
user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
if not privacy_url and not user_agreement_url:
await callback.answer(_("error_occurred_try_again"),
show_alert=True)
return
reply_markup = get_information_links_keyboard(
current_lang,
i18n,
privacy_url,
user_agreement_url,
)
try:
await callback.message.edit_text(_(key="info_links_message"),
reply_markup=reply_markup)
except Exception:
await callback.message.answer(_(key="info_links_message"),
reply_markup=reply_markup)
await callback.answer()
elif action == "back_to_main": elif action == "back_to_main":
await send_main_menu(callback, await send_main_menu(callback,
settings, settings,
+27 -2
View File
@@ -172,10 +172,30 @@ async def my_subscription_command_handler(
except Exception: except Exception:
pass pass
return str(val) return str(val)
def _format_traffic_period(strategy: Optional[str]) -> Optional[str]:
if not strategy:
return None
strategy_upper = str(strategy).upper()
key_map = {
"MONTH": "traffic_period_month",
"WEEK": "traffic_period_week",
"DAY": "traffic_period_day",
"NO_RESET": "traffic_period_no_reset",
}
label_key = key_map.get(strategy_upper)
return get_text(label_key) if label_key else strategy_upper
def _format_used_with_period(used_display: str, period_label: Optional[str]) -> str:
if not period_label:
return used_display
return get_text("traffic_used_with_period", traffic_used=used_display, traffic_period=period_label)
period_label = _format_traffic_period(active.get("traffic_limit_strategy"))
period_label = period_label or get_text("traffic_period_unknown")
if traffic_mode: if traffic_mode:
limit_display = _fmt_gb(active.get("traffic_limit_bytes")) limit_display = _fmt_gb(active.get("traffic_limit_bytes"))
used_display = _fmt_gb(active.get("traffic_used_bytes")) used_display = _format_used_with_period(_fmt_gb(active.get("traffic_used_bytes")), period_label)
remaining_display = get_text("traffic_na") remaining_display = get_text("traffic_na")
try: try:
limit_val = active.get("traffic_limit_bytes") or 0 limit_val = active.get("traffic_limit_bytes") or 0
@@ -191,6 +211,7 @@ async def my_subscription_command_handler(
traffic_limit=limit_display, traffic_limit=limit_display,
traffic_used=used_display, traffic_used=used_display,
traffic_left=remaining_display, traffic_left=remaining_display,
traffic_period=period_label,
config_link=config_link_value, config_link=config_link_value,
) )
else: else:
@@ -202,8 +223,12 @@ async def my_subscription_command_handler(
config_link=config_link_value, config_link=config_link_value,
traffic_limit=(f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" if active.get("traffic_limit_bytes") else get_text("traffic_unlimited")), traffic_limit=(f"{active['traffic_limit_bytes'] / 2**30:.2f} GB" if active.get("traffic_limit_bytes") else get_text("traffic_unlimited")),
traffic_used=( traffic_used=(
f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na") _format_used_with_period(
f"{active['traffic_used_bytes'] / 2**30:.2f} GB" if active.get("traffic_used_bytes") is not None else get_text("traffic_na"),
period_label,
)
), ),
traffic_period=period_label,
) )
base_markup = get_back_to_main_menu_markup(current_lang, i18n) base_markup = get_back_to_main_menu_markup(current_lang, i18n)
+21 -1
View File
@@ -64,10 +64,12 @@ def get_user_management_keyboard(i18n_instance, lang: str) -> InlineKeyboardMark
callback_data="admin_action:users_search_prompt") 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="admin_user_ratings_button"),
callback_data="admin_action:user_ratings")
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, 1) builder.adjust(2, 2, 1)
return builder.as_markup() return builder.as_markup()
@@ -460,3 +462,21 @@ def get_back_to_admin_panel_keyboard(lang: str,
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")
return builder.as_markup() return builder.as_markup()
def get_back_to_stats_monitoring_keyboard(lang: str,
i18n_instance) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.button(text=_(key="back_to_stats_monitoring_button"),
callback_data="admin_section:stats_monitoring")
return builder.as_markup()
def get_back_to_user_management_keyboard(lang: str,
i18n_instance) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
builder.button(text=_(key="back_to_user_management_button"),
callback_data="admin_section:user_management")
return builder.as_markup()
+27 -4
View File
@@ -34,7 +34,8 @@ def get_main_menu_inline_keyboard(
promo_button = InlineKeyboardButton( promo_button = InlineKeyboardButton(
text=_(key="menu_apply_promo_button"), text=_(key="menu_apply_promo_button"),
callback_data="main_action:apply_promo") callback_data="main_action:apply_promo")
builder.row(referral_button, promo_button) builder.row(referral_button)
builder.row(promo_button)
language_button = InlineKeyboardButton( language_button = InlineKeyboardButton(
text=_(key="menu_language_settings_inline"), text=_(key="menu_language_settings_inline"),
@@ -55,14 +56,36 @@ def get_main_menu_inline_keyboard(
InlineKeyboardButton(text=_(key="menu_support_button"), InlineKeyboardButton(text=_(key="menu_support_button"),
url=settings.SUPPORT_LINK)) url=settings.SUPPORT_LINK))
if settings.TERMS_OF_SERVICE_URL: user_agreement_url = settings.USER_AGREEMENT_URL or settings.TERMS_OF_SERVICE_URL
if settings.PRIVACY_POLICY_URL or user_agreement_url:
builder.row( builder.row(
InlineKeyboardButton(text=_(key="menu_terms_button"), InlineKeyboardButton(text=_(key="menu_info_button"),
url=settings.TERMS_OF_SERVICE_URL)) callback_data="main_action:info"))
return builder.as_markup() return builder.as_markup()
def get_information_links_keyboard(
lang: str,
i18n_instance,
privacy_policy_url: Optional[str],
user_agreement_url: Optional[str]) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
if privacy_policy_url:
builder.row(
InlineKeyboardButton(text=_(key="privacy_policy_button"),
url=privacy_policy_url))
if user_agreement_url:
builder.row(
InlineKeyboardButton(text=_(key="user_agreement_button"),
url=user_agreement_url))
builder.row(
InlineKeyboardButton(text=_(key="back_to_main_menu_button"),
callback_data="main_action:back_to_main"))
return builder.as_markup()
def get_language_selection_keyboard(i18n_instance, def get_language_selection_keyboard(i18n_instance,
current_lang: str) -> InlineKeyboardMarkup: current_lang: str) -> InlineKeyboardMarkup:
_ = lambda key, **kwargs: i18n_instance.gettext(current_lang, key, **kwargs _ = lambda key, **kwargs: i18n_instance.gettext(current_lang, key, **kwargs
+6
View File
@@ -96,6 +96,12 @@ class PlategaService:
# Remove optional keys with falsy values to avoid validation errors # Remove optional keys with falsy values to avoid validation errors
clean_body = {k: v for k, v in body.items() if v not in (None, "")} clean_body = {k: v for k, v in body.items() if v not in (None, "")}
safe_headers = {
"X-MerchantId": self._auth_headers.get("X-MerchantId"),
"X-Secret": "***" if self._auth_headers.get("X-Secret") else "",
"Content-Type": self._auth_headers.get("Content-Type"),
}
logging.info("Platega create_transaction request: url=%s headers=%s body=%s", url, safe_headers, clean_body)
try: try:
async with session.post(url, json=clean_body, headers=self._auth_headers) as response: async with session.post(url, json=clean_body, headers=self._auth_headers) as response:
+44 -8
View File
@@ -55,6 +55,33 @@ class SubscriptionService:
except Exception: except Exception:
return False return False
def _extract_panel_traffic_details(
self, panel_user_data: Dict[str, Any]
) -> Tuple[Optional[int], Optional[int], Optional[str]]:
traffic_stats = panel_user_data.get("userTraffic") or {}
used = traffic_stats.get("usedTrafficBytes")
if used is None:
used = panel_user_data.get("usedTrafficBytes")
limit = panel_user_data.get("trafficLimitBytes")
strategy = panel_user_data.get("trafficLimitStrategy")
if strategy is None:
strategy = traffic_stats.get("trafficLimitStrategy")
return used, limit, strategy
def _extract_lifetime_used_traffic(
self, panel_user_data: Dict[str, Any]
) -> Optional[int]:
traffic_stats = panel_user_data.get("userTraffic") or {}
lifetime = traffic_stats.get("lifetimeUsedTrafficBytes")
if lifetime is None:
lifetime = panel_user_data.get("lifetimeUsedTrafficBytes")
try:
if lifetime is None:
return None
return int(lifetime)
except (TypeError, ValueError):
return None
async def _notify_admin_panel_user_creation_failed(self, user_id: int): async def _notify_admin_panel_user_creation_failed(self, user_id: int):
if not self.bot or not self.i18n or not self.settings.ADMIN_IDS: if not self.bot or not self.i18n or not self.settings.ADMIN_IDS:
return return
@@ -444,9 +471,7 @@ class SubscriptionService:
return None return None
panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid) or {} panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid) or {}
traffic_info = panel_user_data.get("userTraffic") or {} current_used, current_limit, _ = self._extract_panel_traffic_details(panel_user_data)
current_limit = panel_user_data.get("trafficLimitBytes")
current_used = traffic_info.get("usedTrafficBytes")
active_sub = await subscription_dal.get_active_subscription_by_user_id( active_sub = await subscription_dal.get_active_subscription_by_user_id(
session, user_id, panel_user_uuid session, user_id, panel_user_uuid
@@ -842,13 +867,22 @@ class SubscriptionService:
await user_dal.update_user(session, user_id, {"panel_user_uuid": None}) await user_dal.update_user(session, user_id, {"panel_user_uuid": None})
return None return None
panel_lifetime_used = self._extract_lifetime_used_traffic(panel_user_data)
if (
panel_lifetime_used is not None
and db_user.lifetime_used_traffic_bytes != panel_lifetime_used
):
await user_dal.update_user(
session,
user_id,
{"lifetime_used_traffic_bytes": panel_lifetime_used},
)
if local_active_sub: if local_active_sub:
update_payload_local = {} update_payload_local = {}
panel_status = panel_user_data.get("status", "UNKNOWN").upper() panel_status = panel_user_data.get("status", "UNKNOWN").upper()
panel_expire_at_str = panel_user_data.get("expireAt") panel_expire_at_str = panel_user_data.get("expireAt")
traffic_stats = panel_user_data.get("userTraffic") or {} panel_traffic_used, panel_traffic_limit, _ = self._extract_panel_traffic_details(panel_user_data)
panel_traffic_used = traffic_stats.get("usedTrafficBytes")
panel_traffic_limit = panel_user_data.get("trafficLimitBytes")
panel_sub_uuid_from_panel = panel_user_data.get( panel_sub_uuid_from_panel = panel_user_data.get(
"subscriptionUuid" "subscriptionUuid"
) or panel_user_data.get("shortUuid") ) or panel_user_data.get("shortUuid")
@@ -901,6 +935,7 @@ class SubscriptionService:
if panel_user_data.get("expireAt") if panel_user_data.get("expireAt")
else None else None
) )
panel_traffic_used, panel_traffic_limit, panel_traffic_strategy = self._extract_panel_traffic_details(panel_user_data)
config_link_raw = panel_user_data.get("subscriptionUrl") config_link_raw = panel_user_data.get("subscriptionUrl")
display_link, connect_button_url = await prepare_config_links(self.settings, config_link_raw) display_link, connect_button_url = await prepare_config_links(self.settings, config_link_raw)
hwid_limit = panel_user_data.get("hwidDeviceLimit") hwid_limit = panel_user_data.get("hwidDeviceLimit")
@@ -913,8 +948,9 @@ class SubscriptionService:
"status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(), "status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(),
"config_link": display_link, "config_link": display_link,
"connect_button_url": connect_button_url, "connect_button_url": connect_button_url,
"traffic_limit_bytes": panel_user_data.get("trafficLimitBytes"), "traffic_limit_bytes": panel_traffic_limit,
"traffic_used_bytes": (panel_user_data.get("userTraffic") or {}).get("usedTrafficBytes"), "traffic_used_bytes": panel_traffic_used,
"traffic_limit_strategy": panel_traffic_strategy,
"user_bot_username": db_user.username, "user_bot_username": db_user.username,
"is_panel_data": True, "is_panel_data": True,
"max_devices": hwid_limit, "max_devices": hwid_limit,
+8
View File
@@ -23,6 +23,8 @@ class Settings(BaseSettings):
SUPPORT_LINK: Optional[str] = Field(default=None) SUPPORT_LINK: Optional[str] = Field(default=None)
SERVER_STATUS_URL: Optional[str] = Field(default=None) SERVER_STATUS_URL: Optional[str] = Field(default=None)
TERMS_OF_SERVICE_URL: Optional[str] = Field(default=None) TERMS_OF_SERVICE_URL: Optional[str] = Field(default=None)
PRIVACY_POLICY_URL: Optional[str] = Field(default=None)
USER_AGREEMENT_URL: Optional[str] = Field(default=None)
REQUIRED_CHANNEL_ID: Optional[int] = Field( REQUIRED_CHANNEL_ID: Optional[int] = Field(
default=None, default=None,
description="Telegram channel ID the user must join to access the bot") description="Telegram channel ID the user must join to access the bot")
@@ -168,6 +170,10 @@ class Settings(BaseSettings):
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."
) )
REFERRAL_WELCOME_BONUS_DAYS: int = Field(
default=3,
description="Welcome bonus days granted to a newly registered user who joined via referral link.",
)
LEGACY_REFS: bool = Field( LEGACY_REFS: bool = Field(
default=True, default=True,
description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset." description="Allow legacy referral links like ref_<telegram_id> to continue working. Defaults to True when unset."
@@ -544,6 +550,8 @@ class Settings(BaseSettings):
'PLATEGA_FAILED_URL', 'PLATEGA_FAILED_URL',
'SEVERPAY_RETURN_URL', 'SEVERPAY_RETURN_URL',
'CRYPT4_REDIRECT_URL', 'CRYPT4_REDIRECT_URL',
'PRIVACY_POLICY_URL',
'USER_AGREEMENT_URL',
mode='before', mode='before',
) )
@classmethod @classmethod
+121 -1
View File
@@ -5,7 +5,8 @@ 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_, or_ from sqlalchemy import update, delete, func, and_, or_, desc
from sqlalchemy.orm import aliased
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
@@ -360,3 +361,122 @@ async def delete_user_and_relations(session: AsyncSession, user_id: int) -> bool
await session.delete(user) await session.delete(user)
await session.flush() await session.flush()
return True return True
async def get_top_users_by_traffic_used(
session: AsyncSession,
*,
limit: int = 10,
) -> List[Dict[str, Any]]:
"""Return top users by total used traffic across all subscriptions."""
safe_limit = max(1, limit)
total_traffic_used = func.coalesce(func.sum(Subscription.traffic_used_bytes), 0)
stmt = (
select(
User.user_id,
User.username,
User.first_name,
total_traffic_used.label("traffic_used_bytes"),
)
.join(Subscription, Subscription.user_id == User.user_id, isouter=True)
.group_by(User.user_id, User.username, User.first_name)
.having(total_traffic_used > 0)
.order_by(desc("traffic_used_bytes"), User.user_id.asc())
.limit(safe_limit)
)
result = await session.execute(stmt)
return [dict(row._mapping) for row in result]
async def get_top_users_by_lifetime_traffic_used(
session: AsyncSession,
*,
limit: int = 10,
) -> List[Dict[str, Any]]:
"""Return top users by lifetime used traffic from panel data."""
safe_limit = max(1, limit)
lifetime_used = func.coalesce(User.lifetime_used_traffic_bytes, 0)
stmt = (
select(
User.user_id,
User.username,
User.first_name,
lifetime_used.label("lifetime_used_traffic_bytes"),
)
.where(lifetime_used > 0)
.order_by(desc("lifetime_used_traffic_bytes"), User.user_id.asc())
.limit(safe_limit)
)
result = await session.execute(stmt)
return [dict(row._mapping) for row in result]
async def get_top_users_by_referrals_count(
session: AsyncSession,
*,
limit: int = 10,
) -> List[Dict[str, Any]]:
"""Return top users by number of invited users."""
safe_limit = max(1, limit)
referred_user = aliased(User)
invited_count = func.count(referred_user.user_id)
stmt = (
select(
User.user_id,
User.username,
User.first_name,
invited_count.label("invited_count"),
)
.join(referred_user, referred_user.referred_by_id == User.user_id, isouter=True)
.group_by(User.user_id, User.username, User.first_name)
.having(invited_count > 0)
.order_by(desc("invited_count"), User.user_id.asc())
.limit(safe_limit)
)
result = await session.execute(stmt)
return [dict(row._mapping) for row in result]
async def get_top_users_by_referral_revenue(
session: AsyncSession,
*,
limit: int = 10,
) -> List[Dict[str, Any]]:
"""Return top users by total revenue brought by all invited users."""
safe_limit = max(1, limit)
referred_user = aliased(User)
referral_revenue = func.coalesce(func.sum(Payment.amount), 0.0)
stmt = (
select(
User.user_id,
User.username,
User.first_name,
referral_revenue.label("referral_revenue"),
)
.join(referred_user, referred_user.referred_by_id == User.user_id, isouter=True)
.join(
Payment,
and_(
Payment.user_id == referred_user.user_id,
Payment.status == "succeeded",
),
isouter=True,
)
.group_by(User.user_id, User.username, User.first_name)
.having(referral_revenue > 0)
.order_by(desc("referral_revenue"), User.user_id.asc())
.limit(safe_limit)
)
result = await session.execute(stmt)
return [dict(row._mapping) for row in result]
+18
View File
@@ -112,6 +112,19 @@ def _migration_0003_normalize_referral_codes(connection: Connection) -> None:
) )
) )
def _migration_0004_add_lifetime_used_traffic(connection: Connection) -> None:
inspector = inspect(connection)
columns: Set[str] = {col["name"] for col in inspector.get_columns("users")}
if "lifetime_used_traffic_bytes" in columns:
return
connection.execute(
text(
"ALTER TABLE users ADD COLUMN lifetime_used_traffic_bytes BIGINT"
)
)
MIGRATIONS: List[Migration] = [ MIGRATIONS: List[Migration] = [
Migration( Migration(
id="0001_add_channel_subscription_fields", id="0001_add_channel_subscription_fields",
@@ -128,6 +141,11 @@ MIGRATIONS: List[Migration] = [
description="Normalize referral codes to uppercase for consistent lookups", description="Normalize referral codes to uppercase for consistent lookups",
upgrade=_migration_0003_normalize_referral_codes, upgrade=_migration_0003_normalize_referral_codes,
), ),
Migration(
id="0004_add_lifetime_used_traffic",
description="Store lifetime traffic usage for users",
upgrade=_migration_0004_add_lifetime_used_traffic,
),
] ]
+1
View File
@@ -25,6 +25,7 @@ class User(Base):
referred_by_id = Column(BigInteger, referred_by_id = Column(BigInteger,
ForeignKey("users.user_id"), ForeignKey("users.user_id"),
nullable=True) nullable=True)
lifetime_used_traffic_bytes = Column(BigInteger, nullable=True)
channel_subscription_verified = Column(Boolean, nullable=True) channel_subscription_verified = Column(Boolean, nullable=True)
channel_subscription_checked_at = Column(DateTime(timezone=True), channel_subscription_checked_at = Column(DateTime(timezone=True),
nullable=True) nullable=True)
+1 -1
View File
@@ -1,6 +1,6 @@
services: services:
remnawave-tg-shop: remnawave-tg-shop:
image: kavore/remnawave-tg-shop:latest image: ghcr.io/3252a8/remnawave-tg-shop:${IMAGE_TAG:-latest}
container_name: remnawave-tg-shop container_name: remnawave-tg-shop
hostname: remnawave-tg-shop hostname: remnawave-tg-shop
env_file: env_file:
+4 -2
View File
@@ -1,7 +1,7 @@
services: services:
remnawave-tg-shop: remnawave-tg-shop:
image: kavore/remnawave-tg-shop:latest # image: ghcr.io/3252a8/remnawave-tg-shop:${IMAGE_TAG:-latest}
# build: . build: .
container_name: remnawave-tg-shop container_name: remnawave-tg-shop
hostname: remnawave-tg-shop hostname: remnawave-tg-shop
env_file: env_file:
@@ -14,6 +14,8 @@ services:
depends_on: depends_on:
remnawave-tg-shop-db: remnawave-tg-shop-db:
condition: service_healthy condition: service_healthy
ports:
- 127.0.0.1:8080:8080
remnawave-tg-shop-db: remnawave-tg-shop-db:
image: postgres:17 image: postgres:17
+33 -10
View File
@@ -5,18 +5,22 @@
"channel_subscription_verify_button": "Check subscription", "channel_subscription_verify_button": "Check subscription",
"channel_subscription_check_failed": "Couldn't verify the subscription. Please try again later or contact support.", "channel_subscription_check_failed": "Couldn't verify the subscription. Please try again later or contact support.",
"channel_subscription_verified_success": "✅ Subscription confirmed! You're good to go.", "channel_subscription_verified_success": "✅ Subscription confirmed! You're good to go.",
"main_menu_greeting": "Hi, {user_name}! 👋\nWhat would you like to do?", "main_menu_greeting": "Hi, {user_name}! 👋\nWhat would you like to do?\n\nTo set up the connection on your device, go to \"My subscription\" and click \"Connect\". If needed, add access days in the \"Buy\" section.",
"menu_activate_trial_button": "🆓 Free Trial", "menu_activate_trial_button": "🆓 Free Trial",
"menu_subscribe_inline": "🚀 Purchase", "menu_subscribe_inline": "🚀 Purchase",
"menu_my_subscription_inline": "🔐 My Subscription", "menu_my_subscription_inline": "🔐 My Subscription",
"no_subscription_options_available": "Subscription issuance is not configured by the bot administrator", "no_subscription_options_available": "Subscription issuance is not configured by the bot administrator",
"menu_referral_inline": "🎁 Referrals", "menu_referral_inline": "🎁 Invite friend",
"referral_no_bonuses_configured": "Sorry, the referral program is currently disabled", "referral_no_bonuses_configured": "Sorry, the referral program is currently disabled",
"menu_apply_promo_button": "🎟 Promo Code", "menu_apply_promo_button": "🎟 Promo Code",
"menu_language_settings_inline": "🌐 Language", "menu_language_settings_inline": "🌐 Language",
"menu_server_status_button": "📊 Status", "menu_server_status_button": "📊 Status",
"menu_support_button": "💬 Support", "menu_support_button": "💬 Support",
"menu_terms_button": "📄 Terms of Service", "menu_terms_button": "📄 Terms of Service",
"menu_info_button": "️ Information",
"info_links_message": "Choose a document:",
"privacy_policy_button": "🔒 Privacy Policy",
"user_agreement_button": "📄 User Agreement",
"back_to_main_menu_button": "⬅️ Back", "back_to_main_menu_button": "⬅️ Back",
"choose_language": "Choose language:", "choose_language": "Choose language:",
"language_set_alert": "Language changed!", "language_set_alert": "Language changed!",
@@ -72,6 +76,11 @@
"payment_failed": "❌ Payment failed or was cancelled. Please try again or contact support.", "payment_failed": "❌ Payment failed or was cancelled. Please try again or contact support.",
"config_link_not_available": "not available, contact support", "config_link_not_available": "not available, contact support",
"traffic_unlimited": "Unlimited", "traffic_unlimited": "Unlimited",
"traffic_period_day": "per day",
"traffic_period_week": "per week",
"traffic_period_month": "per month",
"traffic_period_no_reset": "no reset",
"traffic_used_with_period": "{traffic_used} ({traffic_period})",
"promo_code_prompt": "Please enter your promo code:", "promo_code_prompt": "Please enter your promo code:",
"promo_code_not_found": "Promo code <code>{code}</code> not found, expired, or already used the maximum number of times.", "promo_code_not_found": "Promo code <code>{code}</code> not found, expired, or already used the maximum number of times.",
"promo_code_already_used_by_user": "You have already used promo code <code>{code}</code>.", "promo_code_already_used_by_user": "You have already used promo code <code>{code}</code>.",
@@ -83,13 +92,14 @@
"trial_confirm_activate_button": "✅ Activate!", "trial_confirm_activate_button": "✅ Activate!",
"trial_activated_alert": "Trial activated!", "trial_activated_alert": "Trial activated!",
"trial_activated_details_message": "✅ Trial activated!\nYour {days}-day trial is active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇", "trial_activated_details_message": "✅ Trial activated!\nYour {days}-day trial is active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
"referral_welcome_bonus_applied": "🎁 You have received {days} bonus day(s) for registering via a referral link!\nYour subscription is active until {end_date}.",
"yes_button": "Yes", "yes_button": "Yes",
"no_button": "No", "no_button": "No",
"referral_program_info_new": "🎁 <b>Referral Program</b>\n\n📊 <b>Your stats:</b>\n👥 Friends invited: <b>{invited_count}</b>\n💳 Purchased subscription: <b>{purchased_count}</b>\n\n🔗 Your link:\n<code>{referral_link}</code>\n\n💰 <b>Invitation bonuses:</b>\n{bonus_details}\n\n📢 Share the link with friends and get bonuses!", "referral_program_info_new": "🎁 <b>Referral Program</b>\n\n📊 <b>Your stats:</b>\n👥 Friends invited: <b>{invited_count}</b>\n💳 Purchased subscription: <b>{purchased_count}</b>\n\n🔗 Your link:\n<code>{referral_link}</code>\n\n💰 <b>Invitation bonuses:</b>\n{bonus_details}\n\n📢 Share the link with friends and get bonuses!",
"referral_bonus_per_period": "\n\n🎁 For a friend's {months}-month subscription:\n ➢ You: <b>{inviter_bonus_days} days</b>\n ➢ Friend: <b>{referee_bonus_days} days</b>", "referral_bonus_per_period": "\n\n🎁 For a friend's {months}-month subscription:\n ➢ You: <b>{inviter_bonus_days} days</b>\n ➢ Friend: <b>{referee_bonus_days} days</b>",
"referral_not_available_for_traffic": "Referral bonuses are not available for traffic packages.", "referral_not_available_for_traffic": "Referral bonuses are not available for traffic packages.",
"referral_share_message_button": "📩 Message for friend", "referral_share_message_button": "📩 Message for friend",
"referral_friend_message": "🚀 Hey! Try this VPN - it's fast, reliable and affordable!\n\n🎁 Use my link to get bonus days with your subscription!\n\n{referral_link}", "referral_friend_message": "🚀 Hey! Try this service - it's fast, reliable and affordable!\n\n🎁 Use my link to get bonus days with your subscription!\n\n{referral_link}",
"friend_placeholder": "friend", "friend_placeholder": "friend",
"referral_bonus_inviter_notification_extended": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received {days} bonus days! Your subscription is now active until {new_end_date}.", "referral_bonus_inviter_notification_extended": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received {days} bonus days! Your subscription is now active until {new_end_date}.",
"referral_bonus_inviter_notification_new_sub": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received a {days}-day bonus subscription! It is active until {new_end_date}.", "referral_bonus_inviter_notification_new_sub": "🎉 Congrats! Your friend {referee_name} paid for a subscription. You received a {days}-day bonus subscription! It is active until {new_end_date}.",
@@ -123,6 +133,18 @@
"admin_stats_payment_item": "{status_emoji} {amount} {currency} from {user_info} ({p_status}) [{p_date}]", "admin_stats_payment_item": "{status_emoji} {amount} {currency} from {user_info} ({p_status}) [{p_date}]",
"admin_stats_no_payments_found": "No payments found yet.", "admin_stats_no_payments_found": "No payments found yet.",
"admin_view_payments_button": "💰 Payments", "admin_view_payments_button": "💰 Payments",
"admin_user_ratings_button": "🏆 User Ratings",
"back_to_stats_monitoring_button": "⬅️ To Statistics",
"admin_user_ratings_header": "🏆 <b>User Ratings (TOP {top_limit})</b>",
"admin_user_ratings_traffic_title": "📊 By used traffic",
"admin_user_ratings_traffic_month_title": "📊 By used traffic (month)",
"admin_user_ratings_traffic_lifetime_title": "📊 By used traffic (lifetime)",
"admin_user_ratings_invited_title": "👥 By invited users",
"admin_user_ratings_revenue_title": "💸 By referral revenue",
"admin_user_ratings_traffic_item": "{rank}. {user} — <b>{traffic_gb} GB</b>",
"admin_user_ratings_invited_item": "{rank}. {user} — <b>{invited_count}</b>",
"admin_user_ratings_revenue_item": "{rank}. {user} — <b>{revenue} RUB</b>",
"admin_user_ratings_empty": "No data yet.",
"admin_payments_header": "💰 <b>All Payments</b>", "admin_payments_header": "💰 <b>All Payments</b>",
"admin_no_payments_found": "No payments found.", "admin_no_payments_found": "No payments found.",
"admin_export_payments_csv": "📊 Export CSV", "admin_export_payments_csv": "📊 Export CSV",
@@ -240,11 +262,11 @@
"admin_broadcast_invalid_html": "❌ Invalid HTML in message. Please send valid HTML (Telegram-supported tags) or remove tags.", "admin_broadcast_invalid_html": "❌ Invalid HTML in message. Please send valid HTML (Telegram-supported tags) or remove tags.",
"error_displaying_logs_too_long": "Error: logs too long to display in one message. Try viewing logs for a specific user.", "error_displaying_logs_too_long": "Error: logs too long to display in one message. Try viewing logs for a specific user.",
"error_displaying_statistics": "Error displaying statistics.", "error_displaying_statistics": "Error displaying statistics.",
"subscription_72h_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expires in 3 days — {end_date}.\n\nPlease renew it using the button below.", "subscription_72h_notification": "👋 Hi, {user_name}!\n\n⏳ Your service subscription expires in 3 days — {end_date}.\n\nPlease renew it using the button below.",
"subscription_48h_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expires in 2 days — {end_date}.\n\nPlease renew it using the button below.", "subscription_48h_notification": "👋 Hi, {user_name}!\n\n⏳ Your service subscription expires in 2 days — {end_date}.\n\nPlease renew it using the button below.",
"subscription_24h_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expires in 1 day — {end_date}.\n\nPlease renew it using the button below.", "subscription_24h_notification": "👋 Hi, {user_name}!\n\n⏳ Your service subscription expires in 1 day — {end_date}.\n\nPlease renew it using the button below.",
"subscription_expired_notification": "👋 Hi, {user_name}!\n\n⛔ Your VPN subscription expired on {end_date}.\n\nPlease renew it using the button below.", "subscription_expired_notification": "👋 Hi, {user_name}!\n\n⛔ Your service subscription expired on {end_date}.\n\nPlease renew it using the button below.",
"subscription_expired_yesterday_notification": "👋 Hi, {user_name}!\n\n⏳ Your VPN subscription expired yesterday ({end_date}).\n\nPlease renew it using the button below.", "subscription_expired_yesterday_notification": "👋 Hi, {user_name}!\n\n⏳ Your service subscription expired yesterday ({end_date}).\n\nPlease renew it using the button below.",
"autorenew_48h_charge_tomorrow_notice": "🔔 Reminder\n\nTomorrow an automatic charge will occur to renew your subscription. If you don't want auto-renew, disable it using the button below.", "autorenew_48h_charge_tomorrow_notice": "🔔 Reminder\n\nTomorrow an automatic charge will occur to renew your subscription. If you don't want auto-renew, disable it using the button below.",
"autorenew_confirm_enable": "🔄 Enable auto-renew? An automatic charge will be attempted before your subscription ends.", "autorenew_confirm_enable": "🔄 Enable auto-renew? An automatic charge will be attempted before your subscription ends.",
"autorenew_confirm_disable": "🛑 Disable auto-renew? No further automatic charges will occur.", "autorenew_confirm_disable": "🛑 Disable auto-renew? No further automatic charges will occur.",
@@ -428,9 +450,10 @@
"admin_payments_pagination_info": "📊 Showing {shown} of {total} payments (page {current_page}/{total_pages})", "admin_payments_pagination_info": "📊 Showing {shown} of {total} payments (page {current_page}/{total_pages})",
"admin_payment_traffic_label": "🗂 Traffic: <b>{traffic_gb} GB</b>", "admin_payment_traffic_label": "🗂 Traffic: <b>{traffic_gb} GB</b>",
"admin_payment_months_label": "📅 Period: <b>{months} mo.</b>", "admin_payment_months_label": "📅 Period: <b>{months} mo.</b>",
"my_subscription_details": "🔐 <b>My Subscription</b>\n\n⏰ Status: <b>{status}</b>\n📅 Active until: <b>{end_date}</b>\n📆 Days left: <b>{days_left}</b>\n\n🔗 Configuration link:\n<code>{config_link}</code>\n\n📊 Traffic:\nLimit: <b>{traffic_limit}</b>\nUsed: <b>{traffic_used}</b>", "my_subscription_details": "🔐 <b>My Subscription</b>\n\n⏰ Status: <b>{status}</b>\n📅 Active until: <b>{end_date}</b>\n📆 Days left: <b>{days_left}</b>\n\n🔗 Configuration link:\n<code>{config_link}</code>\n\n📊 Traffic ({traffic_period}):\nLimit: <b>{traffic_limit}</b>\nUsed: <b>{traffic_used}</b>",
"my_traffic_details": "🔐 <b>My Traffic</b>\n\n⏰ Status: <b>{status}</b>\n📅 Valid until: <b>{end_date}</b>\n\n🔗 Configuration link:\n<code>{config_link}</code>\n\n📊 Traffic:\nLimit: <b>{traffic_limit}</b>\nUsed: <b>{traffic_used}</b>\nLeft: <b>{traffic_left}</b>", "my_traffic_details": "🔐 <b>My Traffic</b>\n\n⏰ Status: <b>{status}</b>\n📅 Valid until: <b>{end_date}</b>\n\n🔗 Configuration link:\n<code>{config_link}</code>\n\n📊 Traffic ({traffic_period}):\nLimit: <b>{traffic_limit}</b>\nUsed: <b>{traffic_used}</b>\nLeft: <b>{traffic_left}</b>",
"traffic_no_expiry": "no limit", "traffic_no_expiry": "no limit",
"traffic_period_unknown": "unknown",
"autorenew_enable_button": "🔄 Enable auto-renew", "autorenew_enable_button": "🔄 Enable auto-renew",
"autorenew_disable_button": "🛑 Disable auto-renew", "autorenew_disable_button": "🛑 Disable auto-renew",
"subscription_autorenew_updated": "Auto-renew settings updated.", "subscription_autorenew_updated": "Auto-renew settings updated.",
+33 -10
View File
@@ -5,18 +5,22 @@
"channel_subscription_verify_button": "Проверить подписку", "channel_subscription_verify_button": "Проверить подписку",
"channel_subscription_check_failed": "Не удалось проверить подписку. Попробуйте позже или обратитесь в поддержку.", "channel_subscription_check_failed": "Не удалось проверить подписку. Попробуйте позже или обратитесь в поддержку.",
"channel_subscription_verified_success": "✅ Подписка подтверждена! Можно продолжать.", "channel_subscription_verified_success": "✅ Подписка подтверждена! Можно продолжать.",
"main_menu_greeting": "Привет, {user_name}! 👋\nЧто бы вы хотели сделать?", "main_menu_greeting": "Привет, {user_name}! 👋\nЧто бы вы хотели сделать?\n\nДля настройки подключения на устройстве перейдите в раздел \"Моя подписка\" и нажмите кнопку \"Подключиться\". При необходимости добавьте дни доступа в разделе \"Купить\".",
"menu_activate_trial_button": "🆓 Пробный период", "menu_activate_trial_button": "🆓 Пробный период",
"menu_subscribe_inline": "🚀 Купить", "menu_subscribe_inline": "🚀 Купить",
"menu_my_subscription_inline": "🔐 Моя подписка", "menu_my_subscription_inline": "🔐 Моя подписка",
"no_subscription_options_available": "Выдача подписки не настроена администратором бота", "no_subscription_options_available": "Выдача подписки не настроена администратором бота",
"menu_referral_inline": "🎁 Рефералы", "menu_referral_inline": "🎁 Пригласить друга",
"referral_no_bonuses_configured": "Извините, реферальная программа в данный момент отключена", "referral_no_bonuses_configured": "Извините, реферальная программа в данный момент отключена",
"menu_apply_promo_button": "🎟 Промокод", "menu_apply_promo_button": "🎟 Промокод",
"menu_language_settings_inline": "🌐 Язык", "menu_language_settings_inline": "🌐 Язык",
"menu_server_status_button": "📊 Статус", "menu_server_status_button": "📊 Статус",
"menu_support_button": "💬 Поддержка", "menu_support_button": "💬 Поддержка",
"menu_terms_button": "📄 Условия сервиса", "menu_terms_button": "📄 Условия сервиса",
"menu_info_button": "ℹ️ Информация",
"info_links_message": "Выберите документ:",
"privacy_policy_button": "🔒 Политика конфиденциальности",
"user_agreement_button": "📄 Пользовательское соглашение",
"back_to_main_menu_button": "⬅️ Назад", "back_to_main_menu_button": "⬅️ Назад",
"choose_language": "Выберите язык / Select language:", "choose_language": "Выберите язык / Select language:",
"language_set_alert": "Язык изменен!", "language_set_alert": "Язык изменен!",
@@ -72,6 +76,11 @@
"payment_failed": "❌ Оплата не удалась или была отменена. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой.", "payment_failed": "❌ Оплата не удалась или была отменена. Пожалуйста, попробуйте еще раз или свяжитесь с поддержкой.",
"config_link_not_available": "недоступна, обратитесь в поддержку", "config_link_not_available": "недоступна, обратитесь в поддержку",
"traffic_unlimited": "Безлимитный", "traffic_unlimited": "Безлимитный",
"traffic_period_day": "за день",
"traffic_period_week": "за неделю",
"traffic_period_month": "за месяц",
"traffic_period_no_reset": "без сброса",
"traffic_used_with_period": "{traffic_used} ({traffic_period})",
"promo_code_prompt": "Пожалуйста, введите ваш промокод:", "promo_code_prompt": "Пожалуйста, введите ваш промокод:",
"promo_code_not_found": "Промокод <code>{code}</code> не найден, истек или уже использован максимальное количество раз.", "promo_code_not_found": "Промокод <code>{code}</code> не найден, истек или уже использован максимальное количество раз.",
"promo_code_already_used_by_user": "Вы уже активировали промокод <code>{code}</code>.", "promo_code_already_used_by_user": "Вы уже активировали промокод <code>{code}</code>.",
@@ -83,13 +92,14 @@
"trial_confirm_activate_button": "✅ Активировать!", "trial_confirm_activate_button": "✅ Активировать!",
"trial_activated_alert": "Пробный период активирован!", "trial_activated_alert": "Пробный период активирован!",
"trial_activated_details_message": "✅ Пробный доступ активирован!\nВаш триал на {days} дн. действует до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇", "trial_activated_details_message": "✅ Пробный доступ активирован!\nВаш триал на {days} дн. действует до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
"referral_welcome_bonus_applied": "🎁 Вам начислено {days} бонусных дн. за регистрацию по реферальной ссылке!\nПодписка активна до {end_date}.",
"yes_button": "Да", "yes_button": "Да",
"no_button": "Нет", "no_button": "Нет",
"referral_program_info_new": "🎁 <b>Реферальная программа</b>\n\n📊 <b>Твоя статистика:</b>\n👥 Приглашено друзей: <b>{invited_count}</b>\n💳 Купили подписку: <b>{purchased_count}</b>\n\n🔗 Твоя ссылка:\n<code>{referral_link}</code>\n\n💰 <b>Бонусы за приглашения:</b>\n{bonus_details}\n\n📢 Поделись ссылкой с друзьями и получай бонусы!", "referral_program_info_new": "🎁 <b>Реферальная программа</b>\n\n📊 <b>Твоя статистика:</b>\n👥 Приглашено друзей: <b>{invited_count}</b>\n💳 Купили подписку: <b>{purchased_count}</b>\n\n🔗 Твоя ссылка:\n<code>{referral_link}</code>\n\n💰 <b>Бонусы за приглашения:</b>\n{bonus_details}\n\n📢 Поделись ссылкой с друзьями и получай бонусы!",
"referral_bonus_per_period": "\n\n🎁 За {months}-мес. подписку друга:\n ➢ Вы: <b>{inviter_bonus_days} дн.</b>\n ➢ Друг: <b>{referee_bonus_days} дн.</b>", "referral_bonus_per_period": "\n\n🎁 За {months}-мес. подписку друга:\n ➢ Вы: <b>{inviter_bonus_days} дн.</b>\n ➢ Друг: <b>{referee_bonus_days} дн.</b>",
"referral_not_available_for_traffic": "Для пакетов трафика реферальные бонусы не начисляются.", "referral_not_available_for_traffic": "Для пакетов трафика реферальные бонусы не начисляются.",
"referral_share_message_button": "📩 Сообщение для друга", "referral_share_message_button": "📩 Сообщение для друга",
"referral_friend_message": "🚀 Привет! Попробуй этот VPN - быстрый, надёжный и доступный!\n\n🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}", "referral_friend_message": "🚀 Привет! Попробуй этот сервис - быстрый, надёжный и доступный!\n\n🎁 По моей ссылке тебе дадут бонусные дни к подписке!\n\n{referral_link}",
"friend_placeholder": "друг", "friend_placeholder": "друг",
"referral_bonus_inviter_notification_extended": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислено {days} бонусных дней! Ваша подписка теперь активна до {new_end_date}.", "referral_bonus_inviter_notification_extended": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислено {days} бонусных дней! Ваша подписка теперь активна до {new_end_date}.",
"referral_bonus_inviter_notification_new_sub": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислена бонусная подписка на {days} дней! Она активна до {new_end_date}.", "referral_bonus_inviter_notification_new_sub": "🎉 Поздравляем! Ваш друг {referee_name} оплатил подписку. Вам начислена бонусная подписка на {days} дней! Она активна до {new_end_date}.",
@@ -123,6 +133,18 @@
"admin_stats_payment_item": "{status_emoji} {amount} {currency} от {user_info} ({p_status}) [{p_date}]", "admin_stats_payment_item": "{status_emoji} {amount} {currency} от {user_info} ({p_status}) [{p_date}]",
"admin_stats_no_payments_found": "Платежей пока нет.", "admin_stats_no_payments_found": "Платежей пока нет.",
"admin_view_payments_button": "💰 Платежи", "admin_view_payments_button": "💰 Платежи",
"admin_user_ratings_button": "🏆 Рейтинг пользователей",
"back_to_stats_monitoring_button": "⬅️ К статистике",
"admin_user_ratings_header": "🏆 <b>Рейтинг пользователей (ТОП {top_limit})</b>",
"admin_user_ratings_traffic_title": "📊 По использованному трафику",
"admin_user_ratings_traffic_month_title": "📊 По использованному трафику за месяц",
"admin_user_ratings_traffic_lifetime_title": "📊 По использованному трафику за всё время",
"admin_user_ratings_invited_title": "👥 По количеству приглашенных",
"admin_user_ratings_revenue_title": "💸 По доходу с приглашенных",
"admin_user_ratings_traffic_item": "{rank}. {user} — <b>{traffic_gb} ГБ</b>",
"admin_user_ratings_invited_item": "{rank}. {user} — <b>{invited_count}</b>",
"admin_user_ratings_revenue_item": "{rank}. {user} — <b>{revenue} RUB</b>",
"admin_user_ratings_empty": "Пока нет данных.",
"admin_payments_header": "💰 <b>Все платежи</b>", "admin_payments_header": "💰 <b>Все платежи</b>",
"admin_no_payments_found": "Платежи не найдены.", "admin_no_payments_found": "Платежи не найдены.",
"admin_export_payments_csv": "📊 Экспорт CSV", "admin_export_payments_csv": "📊 Экспорт CSV",
@@ -169,11 +191,11 @@
"admin_promo_invalid_max_activations": "Максимальное количество активаций должно быть положительным числом.", "admin_promo_invalid_max_activations": "Максимальное количество активаций должно быть положительным числом.",
"admin_promo_invalid_validity_days": "Срок действия промокода (в днях) должен быть положительным числом.", "admin_promo_invalid_validity_days": "Срок действия промокода (в днях) должен быть положительным числом.",
"admin_promo_created_success": "✅ Промокод <code>{code}</code> успешно создан!\nБонус: {bonus_days} дней\nМакс. активаций: {max_activations}\nДействителен: {valid_until_str}", "admin_promo_created_success": "✅ Промокод <code>{code}</code> успешно создан!\nБонус: {bonus_days} дней\nМакс. активаций: {max_activations}\nДействителен: {valid_until_str}",
"subscription_72h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 3 дня — {end_date}.\n\nПродлите её по кнопке ниже.", "subscription_72h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на сервис истекает через 3 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
"subscription_48h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 2 дня — {end_date}.\n\nПродлите её по кнопке ниже.", "subscription_48h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на сервис истекает через 2 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
"subscription_24h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 1 день — {end_date}.\n\nПродлите её по кнопке ниже.", "subscription_24h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на сервис истекает через 1 день — {end_date}.\n\nПродлите её по кнопке ниже.",
"subscription_expired_notification": "👋 Привет, {user_name}!\n\n⛔ Срок вашей подписки на VPN истек ({end_date}).\n\nПродлите её по кнопке ниже.", "subscription_expired_notification": "👋 Привет, {user_name}!\n\n⛔ Срок вашей подписки на сервис истек ({end_date}).\n\nПродлите её по кнопке ниже.",
"subscription_expired_yesterday_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекла сутки назад ({end_date}).\n\nПродлите её по кнопке ниже.", "subscription_expired_yesterday_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на сервис истекла сутки назад ({end_date}).\n\nПродлите её по кнопке ниже.",
"autorenew_48h_charge_tomorrow_notice": "🔔 Напоминание\n\nЗавтра будет автоматическое списание за продление подписки. Если вы не хотите автопродление — отключите его кнопкой ниже.", "autorenew_48h_charge_tomorrow_notice": "🔔 Напоминание\n\nЗавтра будет автоматическое списание за продление подписки. Если вы не хотите автопродление — отключите его кнопкой ниже.",
"autorenew_confirm_enable": "🔄 Включить автопродление? Перед окончанием подписки будет выполняться автосписание.", "autorenew_confirm_enable": "🔄 Включить автопродление? Перед окончанием подписки будет выполняться автосписание.",
"autorenew_confirm_disable": "🛑 Отключить автопродление? Автосписаний больше не будет.", "autorenew_confirm_disable": "🛑 Отключить автопродление? Автосписаний больше не будет.",
@@ -428,9 +450,10 @@
"admin_payments_pagination_info": "📊 Показано {shown} из {total} платежей (стр. {current_page}/{total_pages})", "admin_payments_pagination_info": "📊 Показано {shown} из {total} платежей (стр. {current_page}/{total_pages})",
"admin_payment_traffic_label": "🗂 Трафик: <b>{traffic_gb} ГБ</b>", "admin_payment_traffic_label": "🗂 Трафик: <b>{traffic_gb} ГБ</b>",
"admin_payment_months_label": "📅 Период: <b>{months} мес.</b>", "admin_payment_months_label": "📅 Период: <b>{months} мес.</b>",
"my_subscription_details": "🔐 <b>Моя подписка</b>\n\n⏰ Статус: <b>{status}</b>\n📅 Действует до: <b>{end_date}</b>\n📆 Осталось дней: <b>{days_left}</b>\n\n🔗 Ссылка на конфигурацию:\n<code>{config_link}</code>\n\n📊 Трафик:\nЛимит: <b>{traffic_limit}</b>\nИспользовано: <b>{traffic_used}</b>", "my_subscription_details": "🔐 <b>Моя подписка</b>\n\n⏰ Статус: <b>{status}</b>\n📅 Действует до: <b>{end_date}</b>\n📆 Осталось дней: <b>{days_left}</b>\n\n🔗 Ссылка на конфигурацию:\n<code>{config_link}</code>\n\n📊 Трафик ({traffic_period}):\nЛимит: <b>{traffic_limit}</b>\nИспользовано: <b>{traffic_used}</b>",
"my_traffic_details": "🔐 <b>Мой трафик</b>\n\n⏰ Статус: <b>{status}</b>\n📅 Действует до: <b>{end_date}</b>\n\n🔗 Ссылка на конфигурацию:\n<code>{config_link}</code>\n\n📊 Трафик:\nЛимит: <b>{traffic_limit}</b>\nИспользовано: <b>{traffic_used}</b>\nОсталось: <b>{traffic_left}</b>", "my_traffic_details": "🔐 <b>Мой трафик</b>\n\n⏰ Статус: <b>{status}</b>\n📅 Действует до: <b>{end_date}</b>\n\n🔗 Ссылка на конфигурацию:\n<code>{config_link}</code>\n\n📊 Трафик ({traffic_period}):\nЛимит: <b>{traffic_limit}</b>\nИспользовано: <b>{traffic_used}</b>\nОсталось: <b>{traffic_left}</b>",
"traffic_no_expiry": "без ограничения", "traffic_no_expiry": "без ограничения",
"traffic_period_unknown": "неизвестно",
"autorenew_enable_button": "🔄 Включить автопродление", "autorenew_enable_button": "🔄 Включить автопродление",
"autorenew_disable_button": "🛑 Отключить автопродление", "autorenew_disable_button": "🛑 Отключить автопродление",
"subscription_autorenew_updated": "Настройки автопродления обновлены.", "subscription_autorenew_updated": "Настройки автопродления обновлены.",