import re
import pytz
from aiogram import F, Router, types
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.types import (
CallbackQuery,
InlineKeyboardButton,
InlineKeyboardMarkup,
Message,
)
from aiogram.utils.formatting import BlockQuote, Bold, Text
from aiogram.utils.keyboard import InlineKeyboardBuilder
from sqlalchemy import exists, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from database import (
get_key_details,
update_trial,
)
from database.access.resolution import resolve_user_optional
from database.models import Admin, Identity, Key, ManualBan, Payment, Referral, User
from filters.admin import IsAdminFilter
from handlers.utils import sanitize_key_name
from logger import logger
from utils.csv_export import export_referrals_csv
from ..panel.keyboard import (
AdminPanelCallback,
build_admin_back_btn,
build_admin_back_kb,
)
from .keyboard import (
AdminUserEditorCallback,
build_editor_kb,
build_user_edit_kb,
)
from .users_states import UserEditorState
MOSCOW_TZ = pytz.timezone("Europe/Moscow")
UUID_RE = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")
router = Router()
@router.callback_query(
AdminPanelCallback.filter(F.action == "search_user"),
IsAdminFilter(),
)
async def handle_search_user(callback_query: CallbackQuery, state: FSMContext):
text = (
"🔍 Поиск пользователя"
"\n\n📌 Введите ID, Username, Email, UUID веб-аккаунта или перешлите сообщение пользователя."
"\n\n🆔 ID - числовой айди"
"\n📝 Username - юзернейм пользователя"
"\n📧 Email - почта веб-кабинета"
"\n🧬 UUID - идентификатор веб-аккаунта (identity_id)"
"\n\n✉️ Для поиска, вы можете просто переслать сообщение от пользователя."
)
await state.set_state(UserEditorState.waiting_for_user_data)
await callback_query.message.edit_text(text=text, reply_markup=build_admin_back_kb())
@router.callback_query(
AdminPanelCallback.filter(F.action == "search_key"),
IsAdminFilter(),
)
async def handle_search_key(callback_query: CallbackQuery, state: FSMContext):
await state.set_state(UserEditorState.waiting_for_key_name)
await callback_query.message.edit_text(
text="🔑 Введите имя ключа для поиска:",
reply_markup=build_admin_back_kb(),
)
@router.message(UserEditorState.waiting_for_key_name, IsAdminFilter())
async def handle_key_name_input(message: Message, state: FSMContext, session: AsyncSession):
kb = build_admin_back_kb()
if not message.text:
await message.answer(text="🚫 Пожалуйста, отправьте текстовое сообщение.", reply_markup=kb)
return
key_name = sanitize_key_name(message.text)
key_details = await get_key_details(session, key_name)
if not key_details:
await message.answer(
text="🚫 Пользователь с указанным именем ключа не найден.",
reply_markup=kb,
)
return
await process_user_search(message, state, session, key_details["tg_id"], actor_tg_id=message.from_user.id)
@router.message(UserEditorState.waiting_for_user_data, IsAdminFilter())
async def handle_user_data_input(message: Message, state: FSMContext, session: AsyncSession):
kb = build_admin_back_kb()
if message.forward_from:
tg_id = message.forward_from.id
await process_user_search(message, state, session, tg_id, actor_tg_id=message.from_user.id)
return
if not message.text:
await message.answer(text="🚫 Пожалуйста, отправьте текстовое сообщение.", reply_markup=kb)
return
raw = message.text.strip()
if raw.isdigit():
tg_id = int(raw)
elif UUID_RE.match(raw):
identity_id = raw.lower()
ident = (
await session.execute(select(Identity).where(func.lower(Identity.id) == identity_id).limit(1))
).scalar_one_or_none()
if ident is None:
await message.answer(
text="🚫 Веб-аккаунт с указанным UUID не найден!",
reply_markup=kb,
)
return
if ident.tg_id is not None:
tg_id = ident.tg_id
else:
user_id = (
await session.execute(select(User.id).where(User.identity_id == ident.id).limit(1))
).scalar_one_or_none()
if user_id is None:
label = ident.email or ident.id
await message.answer(
text=f"🚫 Веб-аккаунт {label} не имеет биллинг-профиля.",
reply_markup=kb,
)
return
tg_id = user_id
elif "@" in raw and "." in raw.split("@", 1)[-1]:
email = raw.lower()
ident = (
await session.execute(select(Identity).where(func.lower(Identity.email) == email).limit(1))
).scalar_one_or_none()
if ident is None:
await message.answer(
text="🚫 Пользователь с указанным Email не найден!",
reply_markup=kb,
)
return
if ident.tg_id is not None:
tg_id = ident.tg_id
else:
user_id = (
await session.execute(select(User.id).where(User.identity_id == ident.id).limit(1))
).scalar_one_or_none()
if user_id is None:
await message.answer(
text=f"🚫 Веб-аккаунт {ident.email} не имеет биллинг-профиля.",
reply_markup=kb,
)
return
tg_id = user_id
else:
username = raw.lstrip("@").replace("https://t.me/", "")
stmt = (
select(User.tg_id)
.where(func.lower(User.username) == func.lower(username))
.order_by(User.updated_at.desc())
.limit(1)
)
tg_id = (await session.execute(stmt)).scalar_one_or_none()
if tg_id is None:
await message.answer(
text="🚫 Пользователь с указанным Username не найден!",
reply_markup=kb,
)
return
await process_user_search(message, state, session, tg_id, actor_tg_id=message.from_user.id)
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_send_message"),
IsAdminFilter(),
)
async def handle_send_message(
callback_query: types.CallbackQuery,
callback_data: AdminUserEditorCallback,
state: FSMContext,
):
tg_id = callback_data.tg_id
await callback_query.message.edit_text(
text=(
"✉️ Введите текст сообщения, которое вы хотите отправить пользователю:\n\n"
"Поддерживается только Telegram-форматирование — жирный, курсив и другие стили через редактор Telegram.\n\n"
"Вы можете отправить:\n"
"• Только текст\n"
"• Только картинку\n"
"• Текст + картинку"
),
reply_markup=build_editor_kb(tg_id),
)
await state.update_data(tg_id=tg_id)
await state.set_state(UserEditorState.waiting_for_message_text)
@router.message(UserEditorState.waiting_for_message_text, IsAdminFilter())
async def handle_message_text_input(message: Message, state: FSMContext):
data = await state.get_data()
tg_id = data.get("tg_id")
text_message = message.html_text or message.text or message.caption or ""
photo = message.photo[-1].file_id if message.photo else None
max_len = 1024 if photo else 4096
if len(text_message) > max_len:
await message.answer(
f"⚠️ Сообщение слишком длинное.\nМаксимум: {max_len} символов, сейчас: {len(text_message)}.",
reply_markup=build_editor_kb(tg_id),
)
await state.clear()
return
await state.update_data(text=text_message, photo=photo)
await state.set_state(UserEditorState.preview_message)
if photo:
await message.answer_photo(photo=photo, caption=text_message, parse_mode="HTML")
else:
await message.answer(text=text_message, parse_mode="HTML")
await message.answer(
"👀 Это предпросмотр сообщения. Отправить?",
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(text="📤 Отправить", callback_data="send_user_message"),
InlineKeyboardButton(text="❌ Отмена", callback_data="cancel_user_message"),
]
]
),
)
@router.callback_query(
F.data == "send_user_message",
IsAdminFilter(),
UserEditorState.preview_message,
)
async def handle_send_user_message(callback_query: CallbackQuery, state: FSMContext):
data = await state.get_data()
tg_id = data.get("tg_id")
text_message = data.get("text")
photo = data.get("photo")
try:
if photo:
await callback_query.bot.send_photo(
chat_id=tg_id,
photo=photo,
caption=text_message,
parse_mode="HTML",
)
else:
await callback_query.bot.send_message(
chat_id=tg_id,
text=text_message,
parse_mode="HTML",
)
try:
import re
from database import async_session_maker
from database.web_notifications import notify_web
clean = re.sub(r"<[^>]+>", "", text_message or "").strip()
lines = clean.split("\n", 1)
title = lines[0][:120]
body = lines[1].strip()[:300] if len(lines) > 1 else ""
async with async_session_maker() as session:
await notify_web(session, tg_id=tg_id, type="message", title=title, message=body)
await session.commit()
except Exception as e:
logger.warning("[UserManage] Ошибка web-уведомления для tg_id={}: {}", tg_id, e)
await callback_query.message.edit_text(
text="✅ Сообщение успешно отправлено.",
reply_markup=build_editor_kb(tg_id),
)
except Exception as e:
await callback_query.message.edit_text(
text=f"❌ Не удалось отправить сообщение: {e}",
reply_markup=build_editor_kb(tg_id),
)
await state.clear()
@router.callback_query(
F.data == "cancel_user_message",
IsAdminFilter(),
UserEditorState.preview_message,
)
async def handle_cancel_user_message(callback_query: CallbackQuery, state: FSMContext):
data = await state.get_data()
tg_id = data.get("tg_id")
await callback_query.message.edit_text(
text="🚫 Отправка сообщения отменена.",
reply_markup=build_editor_kb(tg_id),
)
await state.clear()
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_trial_restore"),
IsAdminFilter(),
)
async def handle_trial_restore(
callback_query: types.CallbackQuery,
callback_data: AdminUserEditorCallback,
session: AsyncSession,
):
tg_id = callback_data.tg_id
await update_trial(session, tg_id, 0)
await callback_query.message.edit_text(
text="✅ Триал успешно восстановлен!",
reply_markup=build_editor_kb(tg_id),
)
@router.callback_query(
AdminPanelCallback.filter(F.action == "restore_trials"),
IsAdminFilter(),
)
async def confirm_restore_trials(callback_query: types.CallbackQuery):
builder = InlineKeyboardBuilder()
builder.button(
text="✅ Подтвердить",
callback_data=AdminPanelCallback(action="confirm_restore_trials").pack(),
)
builder.row(build_admin_back_btn())
await callback_query.message.edit_text(
text=(
"⚠ Вы уверены, что хотите восстановить пробники для пользователей? \n\n"
"Только для тех, у кого нет подписок (активных или истекших)!"
),
reply_markup=builder.as_markup(),
)
@router.callback_query(
AdminPanelCallback.filter(F.action == "confirm_restore_trials"),
IsAdminFilter(),
)
async def restore_trials(callback_query: types.CallbackQuery, session: AsyncSession):
stmt = (
update(User)
.where(
User.trial == 1,
~exists(select(Key.user_id).where(Key.user_id == User.id)),
)
.values(trial=0)
)
result = await session.execute(stmt)
builder = InlineKeyboardBuilder()
builder.row(build_admin_back_btn())
await callback_query.message.edit_text(
text=f"✅ Пробники восстановлены для {result.rowcount} пользователей без подписок.",
reply_markup=builder.as_markup(),
)
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_export_referrals"),
IsAdminFilter(),
)
async def handle_users_export_referrals(
callback_query: types.CallbackQuery,
callback_data: AdminUserEditorCallback,
session: AsyncSession,
):
referrer_tg_id = callback_data.tg_id
csv_file = await export_referrals_csv(referrer_tg_id, session)
if csv_file is None:
await callback_query.message.answer("У пользователя нет рефералов.")
return
await callback_query.message.answer_document(
document=csv_file,
caption=f"Список рефералов для пользователя {referrer_tg_id}.",
)
async def process_user_search(
message: types.Message,
state: FSMContext,
session: AsyncSession,
tg_id: int,
edit: bool = False,
actor_tg_id: int | None = None,
) -> None:
await state.clear()
u = await resolve_user_optional(session, tg_id)
if u is None:
await message.answer(
text="🚫 Пользователь с указанным ID не найден!",
reply_markup=build_admin_back_kb(),
)
return
uid = u.id
real_tg_id = u.tg_id
identity_email = None
if u.identity_id:
identity_email = await session.scalar(select(Identity.email).where(Identity.id == u.identity_id))
stmt_user = select(User.username, User.balance, User.created_at, User.updated_at, User.trial).where(User.id == uid)
result_user = await session.execute(stmt_user)
user_data = result_user.first()
if not user_data:
await message.answer(
text="🚫 Пользователь с указанным ID не найден!",
reply_markup=build_admin_back_kb(),
)
return
username, balance, created_at, updated_at, trial = user_data
balance = int(balance or 0)
created_at_str = created_at.replace(tzinfo=pytz.UTC).astimezone(MOSCOW_TZ).strftime("%H:%M:%S %d.%m.%Y")
updated_at_str = updated_at.replace(tzinfo=pytz.UTC).astimezone(MOSCOW_TZ).strftime("%H:%M:%S %d.%m.%Y")
trial_status = "использован" if trial == 1 else "доступен"
stmt_ref_count = select(func.count()).select_from(Referral).where(Referral.referrer_user_id == uid)
result_ref = await session.execute(stmt_ref_count)
referral_count = result_ref.scalar_one()
stmt_ref_by = select(Referral.referrer_user_id).where(Referral.referred_user_id == uid).limit(1)
result_ref_by = await session.execute(stmt_ref_by)
referrer_uid = result_ref_by.scalar_one_or_none()
referrer_text = None
if referrer_uid:
stmt_referrer = select(User.username, User.tg_id).where(User.id == referrer_uid)
result_referrer = await session.execute(stmt_referrer)
ref_row = result_referrer.first()
ref_username = ref_row[0] if ref_row else None
ref_tg = ref_row[1] if ref_row else None
ref_label = int(ref_tg) if ref_tg is not None else int(referrer_uid)
if ref_username:
referrer_text = f"🤝 Пригласил: @{ref_username} ({ref_label})"
else:
referrer_text = f"🤝 Пригласил: {ref_label}"
stmt = select(
func.count(Payment.id),
func.coalesce(func.sum(Payment.amount), 0),
).where(
Payment.status == "success",
Payment.user_id == uid,
Payment.payment_system != "admin",
)
result = await session.execute(stmt)
topups_amount, topups_sum = result.one_or_none() or (0, 0)
stmt_keys = select(Key).where(Key.user_id == uid)
result_keys = await session.execute(stmt_keys)
key_records = result_keys.scalars().all()
stmt_ban = select(ManualBan).where(ManualBan.user_id == uid).limit(1)
result_ban = await session.execute(stmt_ban)
ban_record = result_ban.scalar_one_or_none()
ban_info = None
ban_reason = None
is_banned = ban_record is not None
if ban_record:
if ban_record.reason == "shadow":
ban_info = "🚫 Блокировка: 👻 Теневой бан"
elif ban_record.until:
until_str = ban_record.until.replace(tzinfo=pytz.UTC).astimezone(MOSCOW_TZ).strftime("%d.%m.%Y %H:%M")
ban_info = f"🚫 Блокировка: до {until_str}"
if ban_record.reason:
ban_reason = ban_record.reason
else:
ban_info = "🚫 Блокировка: навсегда"
if ban_record.reason:
ban_reason = ban_record.reason
body = Text(
f"🆔 TG ID: {real_tg_id if real_tg_id is not None else '—'}\n",
f"📧 Email: {identity_email if identity_email else '—'}\n",
f"📄 Логин: @{username}\n" if username else "📄 Логин: —\n",
f"📅 Дата регистрации: {created_at_str}\n",
f"🏃 Дата активности: {updated_at_str}\n",
f"💰 Баланс: {balance} Р.\n",
f"💳 Пополнения: {topups_sum} Р. ({topups_amount} шт.)\n",
f"👥 Количество рефералов: {referral_count}\n",
f"🎁 Триал: {trial_status}\n",
)
if referrer_text:
body += Text(referrer_text, "\n")
if ban_info:
body += Text(ban_info, "\n")
if ban_reason:
body += Text(f"📝 Причина: {ban_reason}\n")
text_builder = Text(Bold("📊 Информация о пользователе"), "\n\n", BlockQuote(body))
text = text_builder.as_html()
effective_actor_tg_id = actor_tg_id or (message.from_user.id if message.from_user else None)
admin_role = None
if effective_actor_tg_id is not None:
admin_role = await session.scalar(select(Admin.role).where(Admin.tg_id == effective_actor_tg_id))
has_email = identity_email is not None and str(identity_email).strip() != ""
has_tg = real_tg_id is not None
kb = await build_user_edit_kb(
tg_id,
key_records,
is_banned=is_banned,
admin_role=admin_role,
has_email=has_email,
has_tg=has_tg,
)
if edit:
try:
await message.edit_text(text=text, reply_markup=kb, disable_web_page_preview=True)
except TelegramBadRequest:
pass
else:
await message.answer(text=text, reply_markup=kb, disable_web_page_preview=True)
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_editor"),
IsAdminFilter(),
)
async def handle_users_editor(
callback: CallbackQuery,
callback_data: AdminUserEditorCallback,
session: AsyncSession,
state: FSMContext,
):
await process_user_search(
callback.message,
state=state,
session=session,
tg_id=callback_data.tg_id,
edit=callback_data.edit,
actor_tg_id=callback.from_user.id,
)
async def _resolve_identity_for_user(session: AsyncSession, legacy_ref: int) -> Identity | None:
u = await resolve_user_optional(session, legacy_ref)
if u is None or not u.identity_id:
return None
return await session.scalar(select(Identity).where(Identity.id == u.identity_id))
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_unlink_email"),
IsAdminFilter(),
)
async def handle_unlink_email(
callback: CallbackQuery,
callback_data: AdminUserEditorCallback,
session: AsyncSession,
state: FSMContext,
):
from database.identities import detach_email
identity = await _resolve_identity_for_user(session, callback_data.tg_id)
if identity is None:
await callback.answer("Нет привязанной identity", show_alert=True)
return
if identity.email is None:
await callback.answer("Email уже не привязан", show_alert=True)
return
if identity.tg_id is None:
await callback.answer("Нельзя отвязать email — это единственный способ входа", show_alert=True)
return
result = await detach_email(session, identity.id)
if result is None:
await callback.answer("Не удалось отвязать email", show_alert=True)
return
await callback.answer("Email отвязан", show_alert=False)
await process_user_search(
callback.message,
state=state,
session=session,
tg_id=callback_data.tg_id,
edit=True,
actor_tg_id=callback.from_user.id,
)
@router.callback_query(
AdminUserEditorCallback.filter(F.action == "users_unlink_tg"),
IsAdminFilter(),
)
async def handle_unlink_tg(
callback: CallbackQuery,
callback_data: AdminUserEditorCallback,
session: AsyncSession,
state: FSMContext,
):
from database.identities import detach_telegram
identity = await _resolve_identity_for_user(session, callback_data.tg_id)
if identity is None:
await callback.answer("Нет привязанной identity", show_alert=True)
return
if identity.tg_id is None:
await callback.answer("Telegram уже не привязан", show_alert=True)
return
if identity.email is None:
await callback.answer("Нельзя отвязать TG — нет email для входа", show_alert=True)
return
result = await detach_telegram(session, identity.id)
if result is None:
await callback.answer("Не удалось отвязать Telegram", show_alert=True)
return
await callback.answer("Telegram отвязан", show_alert=False)
await process_user_search(
callback.message,
state=state,
session=session,
tg_id=callback_data.tg_id,
edit=True,
actor_tg_id=callback.from_user.id,
)