Compare commits

...
21 Commits
Author SHA1 Message Date
machka paslaandGitHub c0d70030c3 Merge pull request #117 from machka-pasla/dev
Dev
2025-10-30 20:46:31 +03:00
machka paslaandGitHub 4e7c36dbf7 Merge pull request #115 from streletskiy/feature/paged-users-list
Paged list of all users
2025-10-30 17:57:00 +03:00
Bogdan Strielecki 3b0b88e9a0 Add admin button for delete user from bot and panel 2025-10-30 17:55:08 +03:00
Bogdan Strielecki 207e7751cd Add paged list of all users to admin panel 2025-10-28 15:13:19 +03:00
machka paslaandGitHub c65b608f0b Merge pull request #114 from machka-pasla/dev
more payment info in admin, docker compose for other servers, fixed channel sub check
2025-10-28 14:40:59 +03:00
machka pasla cff8368b7c docker hub push 2025-10-28 14:39:47 +03:00
machka paslaandGitHub 2f367bbd6b Merge pull request #112 from streletskiy/feature/display-user-income
Additional payment info in admin panel user card
2025-10-28 14:31:47 +03:00
machka pasla c3d6324ec6 fixed channel sub check 2025-10-27 15:58:59 +03:00
Bogdan Strielecki 4800a2da80 Additional payment info in admin panel user card
- total income
- total income from all user referrals
2025-10-27 13:38:37 +03:00
machka paslaandGitHub 70a0cd44a1 Merge pull request #111 from freize/main
An example Docker file for deploying a bot on a separate server
2025-10-25 20:02:43 +03:00
machka paslaandGitHub 545c9c7d07 Merge pull request #109 from Snowy-Fluffy/custom-payment-id
Добавление возможности выставлять ID способа оплаты для FreeKassa в env
2025-10-25 20:02:14 +03:00
FreizeandGitHub cd60f496fa Update docker-compose-removed-server.yml 2025-10-25 19:27:57 +03:00
FreizeandGitHub f883065bb9 Add Docker Compose configuration for removed server
This configuration file was created as an example showing the installation of the bot on a separate server.
2025-10-25 19:24:50 +03:00
Snowy-Fluffy b71bd71d8d allows you to set custom payment id for fk 2025-10-24 20:45:43 +03:00
Snowy-Fluffy 0524d24b13 set custom payment id for fk 2025-10-24 20:21:35 +03:00
machka paslaandGitHub cdfd94c814 Merge pull request #108 from machka-pasla/dev
fixed locales and traffic after adding days
2025-10-24 10:34:25 +03:00
machka pasla 6513681125 fixed locales and traffic after adding days 2025-10-24 10:33:10 +03:00
machka paslaandGitHub f321fd04cf Merge pull request #107
fixed ad
2025-10-21 09:45:32 +03:00
machka pasla baede17adf fixed ad 2025-10-21 09:44:21 +03:00
machka paslaandGitHub 17bf8720a3 Merge pull request #105 from machka-pasla/dev
fixed my devices keyboard
2025-10-20 22:28:36 +03:00
machka pasla 280aced20e fixed my devices keyboard 2025-10-20 22:26:50 +03:00
21 changed files with 776 additions and 50 deletions
+1
View File
@@ -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
+13 -4
View File
@@ -9,8 +9,9 @@ on:
- dev - dev
env: env:
REGISTRY: ghcr.io GHCR_REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
jobs: jobs:
build: build:
@@ -26,18 +27,26 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry - name: Log in to GitHub Container Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker - name: Extract metadata (tags, labels) for Docker
id: meta id: meta
uses: docker/metadata-action@v5 uses: docker/metadata-action@v5
with: with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} images: |
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
${{ env.DOCKERHUB_IMAGE }}
tags: | tags: |
type=ref,event=branch type=ref,event=branch
type=ref,event=pr type=ref,event=pr
+14 -2
View File
@@ -9,6 +9,10 @@ on:
paths-ignore: paths-ignore:
- 'README.md' - 'README.md'
env:
GHCR_IMAGE: ghcr.io/${{ github.repository }}
DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ github.event.repository.name }}
jobs: jobs:
build-and-push: build-and-push:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -27,6 +31,12 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Set up QEMU (для эмуляции arm64 на x86) - name: Set up QEMU (для эмуляции arm64 на x86)
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@@ -40,5 +50,7 @@ jobs:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: | tags: |
ghcr.io/${{ github.repository }}:latest ${{ env.GHCR_IMAGE }}:latest
ghcr.io/${{ github.repository }}:${{ github.ref_name }} ${{ env.GHCR_IMAGE }}:${{ github.ref_name }}
${{ env.DOCKERHUB_IMAGE }}:latest
${{ env.DOCKERHUB_IMAGE }}:${{ github.ref_name }}
+3
View File
@@ -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 работала корректно.
## 📁 Структура проекта ## 📁 Структура проекта
``` ```
+15 -1
View File
@@ -98,8 +98,22 @@ async def admin_panel_actions_callback_handler(
await admin_user_mgmnt_handlers.unban_user_prompt_handler( await admin_user_mgmnt_handlers.unban_user_prompt_handler(
callback, state, i18n_data, settings, session) callback, state, i18n_data, settings, session)
elif action == "users_management": elif action == "users_management":
# This is deprecated, kept for compatibility
from . import user_management as admin_user_management_handlers from . import user_management as admin_user_management_handlers
await admin_user_management_handlers.user_management_menu_handler( await admin_user_management_handlers.user_search_prompt_handler(
callback, state, i18n_data, settings, session)
elif action == "users_list" and len(action_parts) > 2:
# Route to users list handler with page number
from . import user_management as admin_user_management_handlers
try:
page = int(action_parts[2])
await admin_user_management_handlers.users_list_handler(
callback, i18n_data, settings, session, page)
except (IndexError, ValueError):
await callback.answer("Invalid page number", show_alert=True)
elif action == "users_search_prompt":
from . import user_management as admin_user_management_handlers
await admin_user_management_handlers.user_search_prompt_handler(
callback, state, i18n_data, settings, session) callback, state, i18n_data, settings, session)
elif action == "view_banned": elif action == "view_banned":
+341 -10
View File
@@ -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)
@@ -96,7 +139,13 @@ def get_user_card_keyboard(user_id: int, i18n_instance, lang: str) -> InlineKeyb
callback_data=f"user_action:refresh:{user_id}" callback_data=f"user_action:refresh:{user_id}"
) )
# Row 4: Back button # Row 4: Destructive action
builder.button(
text=_(key="admin_user_delete_button", default="❌ Удалить пользователя"),
callback_data=f"user_action:delete_user:{user_id}"
)
# 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)
+30 -1
View File
@@ -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([
+2 -2
View File
@@ -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,
}, },
) )
+66 -2
View File
@@ -59,13 +59,15 @@ def get_user_management_keyboard(i18n_instance, lang: str) -> InlineKeyboardMark
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.button(text=_(key="admin_users_management_button"), builder.button(text=_(key="admin_users_management_button"),
callback_data="admin_action:users_management") callback_data="admin_action:users_list:0")
builder.button(text=_(key="admin_users_search_button"),
callback_data="admin_action:users_search_prompt")
builder.button(text=_(key="admin_ban_management_section"), builder.button(text=_(key="admin_ban_management_section"),
callback_data="admin_section:ban_management") callback_data="admin_section:ban_management")
builder.button(text=_(key="back_to_admin_panel_button"), builder.button(text=_(key="back_to_admin_panel_button"),
callback_data="admin_action:main") callback_data="admin_action:main")
builder.adjust(2, 1) builder.adjust(2, 1, 1)
return builder.as_markup() return builder.as_markup()
@@ -305,6 +307,68 @@ def get_banned_users_keyboard(banned_users: List[User], current_page: int,
return builder.as_markup() return builder.as_markup()
def get_users_list_keyboard(users: List[User], current_page: int,
total_users: int, i18n_instance, lang: str,
page_size: int = 15) -> InlineKeyboardMarkup:
"""Generate keyboard for paginated user list"""
_ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs)
builder = InlineKeyboardBuilder()
# Add user buttons
for user in users:
user_display_parts = []
if user.username:
user_display_parts.append(f"@{user.username}")
user_display_parts.append(f"ID: {user.user_id}")
if user.first_name:
user_display_parts.append(f"- {user.first_name}")
button_text = " ".join(user_display_parts)
builder.row(
InlineKeyboardButton(
text=button_text,
callback_data=f"admin_user_card_from_list:{user.user_id}:{current_page}"
)
)
# Pagination buttons
if total_users > page_size:
total_pages = math.ceil(total_users / page_size)
pagination_buttons = []
if current_page > 0:
pagination_buttons.append(
InlineKeyboardButton(
text=_("prev_page_button"),
callback_data=f"admin_action:users_list:{current_page - 1}"
)
)
pagination_buttons.append(
InlineKeyboardButton(
text=f"{current_page + 1}/{total_pages}",
callback_data="stub_page_display"
)
)
if current_page < total_pages - 1:
pagination_buttons.append(
InlineKeyboardButton(
text=_("next_page_button"),
callback_data=f"admin_action:users_list:{current_page + 1}"
)
)
if pagination_buttons:
builder.row(*pagination_buttons)
# Back button
builder.row(
InlineKeyboardButton(
text=_("back_to_user_management_button"),
callback_data="admin_section:user_management"
)
)
return builder.as_markup()
def get_user_card_keyboard(user_id: int, def get_user_card_keyboard(user_id: int,
is_banned: bool, is_banned: bool,
i18n_instance, i18n_instance,
+8
View File
@@ -40,6 +40,14 @@ class ChannelSubscriptionMiddleware(BaseMiddleware):
if not event_user or event_user.id in self.settings.ADMIN_IDS: if not event_user or event_user.id in self.settings.ADMIN_IDS:
return await handler(event, data) return await handler(event, data)
callback_query = event.callback_query
if (
callback_query
and callback_query.data
and callback_query.data == "channel_subscription:verify"
):
return await handler(event, data)
# Allow /start to reach the handler so the check can be re-run. # Allow /start to reach the handler so the check can be re-run.
message_object: Optional[Message] = event.message message_object: Optional[Message] = event.message
if ( if (
+3 -2
View File
@@ -45,6 +45,7 @@ class FreeKassaService:
self.second_secret: Optional[str] = settings.FREEKASSA_SECOND_SECRET self.second_secret: Optional[str] = settings.FREEKASSA_SECOND_SECRET
self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper() self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
self.server_ip: Optional[str] = settings.FREEKASSA_PAYMENT_IP self.server_ip: Optional[str] = settings.FREEKASSA_PAYMENT_IP
self.payment_method_id: Optional[int] = settings.FREEKASSA_PAYMENT_METHOD_ID
self.api_base_url: str = "https://api.fk.life/v1" self.api_base_url: str = "https://api.fk.life/v1"
self._timeout = ClientTimeout(total=15) self._timeout = ClientTimeout(total=15)
@@ -72,9 +73,9 @@ class FreeKassaService:
months: int, months: int,
amount: float, amount: float,
currency: Optional[str], currency: Optional[str],
method_code: int,
email: Optional[str] = None, email: Optional[str] = None,
ip_address: Optional[str] = None, ip_address: Optional[str] = None,
payment_method_id: Optional[int] = None,
extra_params: Optional[Dict[str, Any]] = None, extra_params: Optional[Dict[str, Any]] = None,
) -> Tuple[bool, Dict[str, Any]]: ) -> Tuple[bool, Dict[str, Any]]:
if not self.configured: if not self.configured:
@@ -94,7 +95,7 @@ class FreeKassaService:
"shopId": int(self.shop_id), "shopId": int(self.shop_id),
"nonce": await self._generate_nonce(), "nonce": await self._generate_nonce(),
"paymentId": str(payment_db_id), "paymentId": str(payment_db_id),
"i": int(method_code), "i": int(payment_method_id),
"amount": amount_str, "amount": amount_str,
"currency": currency_code, "currency": currency_code,
"email": email, "email": email,
+31
View File
@@ -455,6 +455,37 @@ class PanelApiService:
) )
return False return False
async def delete_user_from_panel(self,
user_uuid: str,
log_response: bool = True) -> bool:
"""Delete a user from the panel. Treat not-found as already deleted."""
endpoint = f"/users/{user_uuid}"
response_data = await self._request(
"DELETE", endpoint, log_full_response=log_response
)
if not response_data:
logging.error(
f"Panel API delete_user_from_panel returned no data for user {user_uuid}."
)
return False
if response_data.get("error"):
details = response_data.get("details") or {}
error_code = details.get("errorCode") or response_data.get("errorCode")
if error_code in {"A062", "A040"}:
logging.info(
f"Panel user {user_uuid} already absent (errorCode {error_code}). Treating as deleted."
)
return True
logging.error(
f"Failed to delete user {user_uuid} on panel. Response: {response_data}"
)
return False
logging.info(f"Panel user {user_uuid} deleted successfully.")
return True
async def get_subscription_link( async def get_subscription_link(
self, self,
short_uuid_or_sub_uuid: str, short_uuid_or_sub_uuid: str,
+23 -3
View File
@@ -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,
) )
+1
View File
@@ -28,6 +28,7 @@ class AdminStates(StatesGroup):
waiting_for_user_search = State() waiting_for_user_search = State()
waiting_for_subscription_days_to_add = State() waiting_for_subscription_days_to_add = State()
waiting_for_direct_message_to_user = State() waiting_for_direct_message_to_user = State()
waiting_for_user_delete_confirmation = State()
# Ads campaigns # Ads campaigns
waiting_for_ad_source = State() waiting_for_ad_source = State()
+1
View File
@@ -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
View File
@@ -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
+34
View File
@@ -262,3 +262,37 @@ async def get_last_tribute_payment(
Payment.created_at.desc()).limit(1)) Payment.created_at.desc()).limit(1))
result = await session.execute(stmt) result = await session.execute(stmt)
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def get_user_total_paid(session: AsyncSession, user_id: int) -> float:
"""Get total amount paid by a specific user (sum of all succeeded payments)."""
stmt = select(func.sum(Payment.amount)).where(
and_(
Payment.user_id == user_id,
Payment.status == 'succeeded'
)
)
result = await session.execute(stmt)
total = result.scalar()
return float(total or 0)
async def get_referral_revenue(session: AsyncSession, referrer_id: int) -> float:
"""Get total revenue generated from referred users' payments.
This calculates the sum of all succeeded payments made by users
where referred_by_id equals the referrer_id.
"""
from db.models import User
stmt = select(func.sum(Payment.amount)).join(
User, Payment.user_id == User.user_id
).where(
and_(
User.referred_by_id == referrer_id,
Payment.status == 'succeeded'
)
)
result = await session.execute(stmt)
total = result.scalar()
return float(total or 0)
+72 -2
View File
@@ -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
+41
View File
@@ -0,0 +1,41 @@
services:
remnawave-tg-shop:
image: ghcr.io/machka-pasla/remnawave-tg-shop:latest
container_name: remnawave-tg-shop
hostname: remnawave-tg-shop
env_file:
- .env
ports:
- 8080:8080
networks:
- remnawave-network
environment:
- TZ=UTC
volumes:
- ./locales:/app/locales
restart: unless-stopped
depends_on:
- remnawave-tg-shop-db
remnawave-tg-shop-db:
image: postgres:17
container_name: remnawave-tg-shop-db
hostname: remnawave-tg-shop-db
env_file:
- .env
environment:
- TZ=UTC
volumes:
- remnawave-tg-shop-db-data:/var/lib/postgresql/data
networks:
- remnawave-network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 5s
timeout: 5s
retries: 20
networks:
remnawave-network: null
volumes:
remnawave-tg-shop-db-data:
name: remnawave-tg-shop-db-data
+17 -1
View File
@@ -101,7 +101,10 @@
"admin_promo_marketing_section": "🎁 Promos", "admin_promo_marketing_section": "🎁 Promos",
"admin_system_functions_section": "⚙️ System", "admin_system_functions_section": "⚙️ System",
"admin_ban_management_section": "🚫 Bans", "admin_ban_management_section": "🚫 Bans",
"admin_users_management_button": "👤 Management", "admin_users_search_button": "🔍 Search",
"admin_users_management_button": "👥 User List",
"admin_users_list_header": "👥 <b>User List</b>\n\nPage {current}/{total} ({total_users} users)",
"admin_user_back_to_list_button": "⬅️ Back to List",
"back_to_user_management_button": "⬅️ To Users", "back_to_user_management_button": "⬅️ To Users",
"back_to_admin_panel_button": "⬅️ To Admin", "back_to_admin_panel_button": "⬅️ To Admin",
"admin_stats_header": "📊 Bot Statistics", "admin_stats_header": "📊 Bot Statistics",
@@ -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
View File
@@ -101,7 +101,10 @@
"admin_promo_marketing_section": "🎁 Промокоды", "admin_promo_marketing_section": "🎁 Промокоды",
"admin_system_functions_section": "⚙️ Система", "admin_system_functions_section": "⚙️ Система",
"admin_ban_management_section": "🚫 Блокировки", "admin_ban_management_section": "🚫 Блокировки",
"admin_users_management_button": "👤 Управление", "admin_users_search_button": "🔍 Поиск",
"admin_users_management_button": "👥 Список пользователей",
"admin_users_list_header": "👥 <b>Список пользователей</b>\n\nСтраница {current}/{total} ({total_users} пользователей)",
"admin_user_back_to_list_button": "⬅️ К списку",
"back_to_user_management_button": "⬅️ К пользователям", "back_to_user_management_button": "⬅️ К пользователям",
"back_to_admin_panel_button": "⬅️ В админку", "back_to_admin_panel_button": "⬅️ В админку",
"admin_stats_header": "📊 Статистика Бота", "admin_stats_header": "📊 Статистика Бота",
@@ -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>",