Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0d70030c3 | ||
|
|
4e7c36dbf7 | ||
|
|
3b0b88e9a0 | ||
|
|
207e7751cd | ||
|
|
c65b608f0b | ||
|
|
cff8368b7c | ||
|
|
2f367bbd6b | ||
|
|
c3d6324ec6 | ||
|
|
4800a2da80 | ||
|
|
70a0cd44a1 | ||
|
|
545c9c7d07 | ||
|
|
cd60f496fa | ||
|
|
f883065bb9 | ||
|
|
b71bd71d8d | ||
|
|
0524d24b13 | ||
|
|
cdfd94c814 | ||
|
|
6513681125 | ||
|
|
f321fd04cf | ||
|
|
baede17adf | ||
|
|
17bf8720a3 | ||
|
|
280aced20e |
@@ -50,6 +50,7 @@ FREEKASSA_MERCHANT_ID=your_shop_id #
|
|||||||
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
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -91,6 +91,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 +164,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 работала корректно.
|
||||||
|
|
||||||
## 📁 Структура проекта
|
## 📁 Структура проекта
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
@@ -95,8 +138,14 @@ 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: 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 5: 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,7 +155,7 @@ 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)
|
builder.adjust(2, 2, 2, 1, 2)
|
||||||
return builder
|
return builder
|
||||||
|
|
||||||
|
|
||||||
@@ -192,6 +241,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:
|
||||||
@@ -310,6 +373,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)
|
||||||
|
|
||||||
@@ -535,8 +602,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,
|
||||||
@@ -897,9 +1173,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 +1240,58 @@ 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)
|
||||||
|
keyboard.button(
|
||||||
|
text=_("admin_user_back_to_list_button", default="⬅️ К списку"),
|
||||||
|
callback_data=f"admin_action:users_list:{page}"
|
||||||
|
)
|
||||||
|
keyboard.adjust(2, 2, 2, 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)
|
||||||
|
|
||||||
|
await callback.message.edit_text(
|
||||||
|
user_card_text,
|
||||||
|
reply_markup=keyboard.as_markup(),
|
||||||
|
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)
|
||||||
|
|||||||
@@ -183,9 +183,38 @@ async def my_subscription_command_handler(
|
|||||||
max_devices_display = str(max_devices_int)
|
max_devices_display = str(max_devices_int)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
max_devices_display = str(max_devices_value)
|
max_devices_display = str(max_devices_value)
|
||||||
|
current_devices_display = "?"
|
||||||
|
user_uuid = active.get("user_id")
|
||||||
|
devices_response = None
|
||||||
|
if user_uuid:
|
||||||
|
try:
|
||||||
|
devices_response = await panel_service.get_user_devices(user_uuid)
|
||||||
|
except Exception:
|
||||||
|
logging.exception("Failed to load devices for user %s", user_uuid)
|
||||||
|
if devices_response:
|
||||||
|
devices_count: Optional[int] = None
|
||||||
|
if isinstance(devices_response, dict):
|
||||||
|
devices_list = devices_response.get("devices")
|
||||||
|
if isinstance(devices_list, list):
|
||||||
|
devices_count = len(devices_list)
|
||||||
|
elif isinstance(devices_list, int):
|
||||||
|
devices_count = devices_list
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
devices_count = len(devices_list) # type: ignore[arg-type]
|
||||||
|
except Exception:
|
||||||
|
devices_count = None
|
||||||
|
if devices_count is None:
|
||||||
|
total_value = devices_response.get("total")
|
||||||
|
if isinstance(total_value, int):
|
||||||
|
devices_count = total_value
|
||||||
|
elif isinstance(devices_response, list):
|
||||||
|
devices_count = len(devices_response)
|
||||||
|
if devices_count is not None:
|
||||||
|
current_devices_display = str(devices_count)
|
||||||
devices_button_text = get_text(
|
devices_button_text = get_text(
|
||||||
"devices_button",
|
"devices_button",
|
||||||
current_devices="?",
|
current_devices=current_devices_display,
|
||||||
max_devices=max_devices_display,
|
max_devices=max_devices_display,
|
||||||
)
|
)
|
||||||
prepend_rows.append([
|
prepend_rows.append([
|
||||||
|
|||||||
@@ -881,10 +881,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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -568,6 +568,11 @@ class SubscriptionService:
|
|||||||
bonus_days: int,
|
bonus_days: int,
|
||||||
reason: str = "bonus",
|
reason: str = "bonus",
|
||||||
) -> Optional[datetime]:
|
) -> Optional[datetime]:
|
||||||
|
reason_lower = (reason or "").lower()
|
||||||
|
apply_main_traffic_limit = any(
|
||||||
|
keyword in reason_lower for keyword in ("admin", "promo code", "referral", "bonus")
|
||||||
|
)
|
||||||
|
|
||||||
user = await user_dal.get_user_by_id(session, user_id)
|
user = await user_dal.get_user_by_id(session, user_id)
|
||||||
if not user:
|
if not user:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
@@ -594,8 +599,12 @@ class SubscriptionService:
|
|||||||
start_date = datetime.now(timezone.utc)
|
start_date = datetime.now(timezone.utc)
|
||||||
new_end_date_obj = start_date + timedelta(days=bonus_days)
|
new_end_date_obj = start_date + timedelta(days=bonus_days)
|
||||||
|
|
||||||
# For promo code activations, use the configured user traffic limit
|
# Apply main traffic limit for admin/referral/promo bonuses, fallback to trial limit otherwise
|
||||||
traffic_limit = self.settings.user_traffic_limit_bytes if "promo code" in reason.lower() else self.settings.trial_traffic_limit_bytes
|
traffic_limit = (
|
||||||
|
self.settings.user_traffic_limit_bytes
|
||||||
|
if apply_main_traffic_limit
|
||||||
|
else self.settings.trial_traffic_limit_bytes
|
||||||
|
)
|
||||||
|
|
||||||
bonus_sub_payload = {
|
bonus_sub_payload = {
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
@@ -626,12 +635,23 @@ class SubscriptionService:
|
|||||||
session, active_sub.subscription_id, new_end_date_obj
|
session, active_sub.subscription_id, new_end_date_obj
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
apply_main_traffic_limit
|
||||||
|
and updated_sub_model
|
||||||
|
and updated_sub_model.traffic_limit_bytes != self.settings.user_traffic_limit_bytes
|
||||||
|
):
|
||||||
|
updated_sub_model = await subscription_dal.update_subscription(
|
||||||
|
session,
|
||||||
|
updated_sub_model.subscription_id,
|
||||||
|
{"traffic_limit_bytes": self.settings.user_traffic_limit_bytes},
|
||||||
|
)
|
||||||
|
|
||||||
if updated_sub_model:
|
if updated_sub_model:
|
||||||
# Prepare panel update payload
|
# Prepare panel update payload
|
||||||
panel_update_payload = self._build_panel_update_payload(
|
panel_update_payload = self._build_panel_update_payload(
|
||||||
expire_at=new_end_date_obj,
|
expire_at=new_end_date_obj,
|
||||||
traffic_limit_bytes=(
|
traffic_limit_bytes=(
|
||||||
self.settings.user_traffic_limit_bytes if "promo code" in reason.lower() else None
|
self.settings.user_traffic_limit_bytes if apply_main_traffic_limit else None
|
||||||
),
|
),
|
||||||
include_uuid=False,
|
include_uuid=False,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -57,6 +57,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)
|
||||||
|
|||||||
+42
-18
@@ -98,23 +98,37 @@ async def get_campaign_stats(session: AsyncSession, campaign_id: int) -> Dict[st
|
|||||||
trials = (await session.execute(trials_stmt)).scalar() or 0
|
trials = (await session.execute(trials_stmt)).scalar() or 0
|
||||||
|
|
||||||
# Payers (unique users with succeeded payments)
|
# Payers (unique users with succeeded payments)
|
||||||
payers_stmt = select(func.count(func.distinct(Payment.user_id))).select_from(Payment).where(
|
attrib_subq = (
|
||||||
and_(
|
select(
|
||||||
Payment.status == "succeeded",
|
AdAttribution.user_id.label("user_id"),
|
||||||
Payment.user_id.in_(
|
AdAttribution.first_start_at.label("first_start_at"),
|
||||||
select(AdAttribution.user_id).where(AdAttribution.ad_campaign_id == campaign_id)
|
)
|
||||||
),
|
.where(AdAttribution.ad_campaign_id == campaign_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
payers_stmt = (
|
||||||
|
select(func.count(func.distinct(Payment.user_id)))
|
||||||
|
.select_from(Payment)
|
||||||
|
.join(attrib_subq, Payment.user_id == attrib_subq.c.user_id)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Payment.status == "succeeded",
|
||||||
|
Payment.created_at >= attrib_subq.c.first_start_at,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
payers = (await session.execute(payers_stmt)).scalar() or 0
|
payers = (await session.execute(payers_stmt)).scalar() or 0
|
||||||
|
|
||||||
# Revenue sum
|
# Revenue sum
|
||||||
revenue_stmt = select(func.coalesce(func.sum(Payment.amount), 0.0)).select_from(Payment).where(
|
revenue_stmt = (
|
||||||
and_(
|
select(func.coalesce(func.sum(Payment.amount), 0.0))
|
||||||
Payment.status == "succeeded",
|
.select_from(Payment)
|
||||||
Payment.user_id.in_(
|
.join(attrib_subq, Payment.user_id == attrib_subq.c.user_id)
|
||||||
select(AdAttribution.user_id).where(AdAttribution.ad_campaign_id == campaign_id)
|
.where(
|
||||||
),
|
and_(
|
||||||
|
Payment.status == "succeeded",
|
||||||
|
Payment.created_at >= attrib_subq.c.first_start_at,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
revenue = float((await session.execute(revenue_stmt)).scalar() or 0.0)
|
revenue = float((await session.execute(revenue_stmt)).scalar() or 0.0)
|
||||||
@@ -151,10 +165,22 @@ async def get_totals(session: AsyncSession) -> Dict[str, float]:
|
|||||||
total_cost = float((await session.execute(total_cost_stmt)).scalar() or 0.0)
|
total_cost = float((await session.execute(total_cost_stmt)).scalar() or 0.0)
|
||||||
|
|
||||||
# Total revenue from all attributed users (unique users counted across all campaigns)
|
# Total revenue from all attributed users (unique users counted across all campaigns)
|
||||||
revenue_stmt = select(func.coalesce(func.sum(Payment.amount), 0.0)).select_from(Payment).where(
|
attrib_subq = (
|
||||||
and_(
|
select(
|
||||||
Payment.status == "succeeded",
|
AdAttribution.user_id.label("user_id"),
|
||||||
Payment.user_id.in_(select(AdAttribution.user_id)),
|
AdAttribution.first_start_at.label("first_start_at"),
|
||||||
|
)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
revenue_stmt = (
|
||||||
|
select(func.coalesce(func.sum(Payment.amount), 0.0))
|
||||||
|
.select_from(Payment)
|
||||||
|
.join(attrib_subq, Payment.user_id == attrib_subq.c.user_id)
|
||||||
|
.where(
|
||||||
|
and_(
|
||||||
|
Payment.status == "succeeded",
|
||||||
|
Payment.created_at >= attrib_subq.c.first_start_at,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
total_revenue = float((await session.execute(revenue_stmt)).scalar() or 0.0)
|
total_revenue = float((await session.execute(revenue_stmt)).scalar() or 0.0)
|
||||||
@@ -178,5 +204,3 @@ async def delete_campaign(session: AsyncSession, campaign_id: int) -> bool:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Failed to delete AdCampaign id={campaign_id}: {e}", exc_info=True)
|
logging.error(f"Failed to delete AdCampaign id={campaign_id}: {e}", exc_info=True)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
+72
-2
@@ -3,11 +3,20 @@ 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
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]:
|
||||||
@@ -102,6 +111,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 +259,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
|
||||||
|
|||||||
@@ -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
|
||||||
+17
-1
@@ -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",
|
||||||
@@ -244,6 +247,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",
|
||||||
@@ -257,6 +270,7 @@
|
|||||||
"admin_user_invalid_days": "❌ Invalid number of days. Enter number from 1 to 3650.",
|
"admin_user_invalid_days": "❌ Invalid number of days. Enter number from 1 to 3650.",
|
||||||
"admin_user_subscription_added_success": "✅ Successfully added {days} days to user {user_id}",
|
"admin_user_subscription_added_success": "✅ Successfully added {days} days to user {user_id}",
|
||||||
"admin_user_subscription_added_error": "❌ Error adding subscription days",
|
"admin_user_subscription_added_error": "❌ Error adding subscription days",
|
||||||
|
"admin_panel_user_creation_failed": "❌ Failed to create or link panel user for ID {user_id}. Manual intervention required.",
|
||||||
"admin_user_ban_toggle_success": "✅ User {status}",
|
"admin_user_ban_toggle_success": "✅ User {status}",
|
||||||
"admin_user_ban_toggle_error": "❌ Error changing ban status",
|
"admin_user_ban_toggle_error": "❌ Error changing ban status",
|
||||||
"admin_user_ban_success": "✅ User {input} has been banned",
|
"admin_user_ban_success": "✅ User {input} has been banned",
|
||||||
@@ -336,6 +350,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>",
|
||||||
|
|||||||
+17
-1
@@ -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": "📊 Статистика Бота",
|
||||||
@@ -244,6 +247,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": "🔙 К карточке",
|
||||||
@@ -257,6 +270,7 @@
|
|||||||
"admin_user_invalid_days": "❌ Неверное количество дней. Введите число от 1 до 3650.",
|
"admin_user_invalid_days": "❌ Неверное количество дней. Введите число от 1 до 3650.",
|
||||||
"admin_user_subscription_added_success": "✅ Успешно добавлено {days} дней подписки пользователю {user_id}",
|
"admin_user_subscription_added_success": "✅ Успешно добавлено {days} дней подписки пользователю {user_id}",
|
||||||
"admin_user_subscription_added_error": "❌ Ошибка добавления дней подписки",
|
"admin_user_subscription_added_error": "❌ Ошибка добавления дней подписки",
|
||||||
|
"admin_panel_user_creation_failed": "❌ Не удалось создать или привязать пользователя в панели для ID {user_id}. Требуется ручная проверка.",
|
||||||
"admin_user_ban_toggle_success": "✅ Пользователь {status}",
|
"admin_user_ban_toggle_success": "✅ Пользователь {status}",
|
||||||
"admin_user_ban_toggle_error": "❌ Ошибка изменения статуса блокировки",
|
"admin_user_ban_toggle_error": "❌ Ошибка изменения статуса блокировки",
|
||||||
"admin_user_ban_success": "✅ Пользователь {input} заблокирован",
|
"admin_user_ban_success": "✅ Пользователь {input} заблокирован",
|
||||||
@@ -336,6 +350,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>",
|
||||||
|
|||||||
Reference in New Issue
Block a user