437 lines
15 KiB
Python
437 lines
15 KiB
Python
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 func, select, update
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from database import (
|
||
get_key_details,
|
||
update_trial,
|
||
)
|
||
from database.models import Key, ManualBan, Payment, Referral, User
|
||
from filters.admin import IsAdminFilter
|
||
from handlers.utils import sanitize_key_name
|
||
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")
|
||
|
||
router = Router()
|
||
|
||
|
||
@router.callback_query(
|
||
AdminPanelCallback.filter(F.action == "search_user"),
|
||
IsAdminFilter(),
|
||
)
|
||
async def handle_search_user(callback_query: CallbackQuery, state: FSMContext):
|
||
text = (
|
||
"<b>🔍 Поиск пользователя</b>"
|
||
"\n\n📌 Введите ID, Username или перешлите сообщение пользователя."
|
||
"\n\n🆔 ID - числовой айди"
|
||
"\n📝 Username - юзернейм пользователя"
|
||
"\n\n<i>✉️ Для поиска, вы можете просто переслать сообщение от пользователя.</i>"
|
||
)
|
||
|
||
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"])
|
||
|
||
|
||
@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)
|
||
return
|
||
|
||
if not message.text:
|
||
await message.answer(text="🚫 Пожалуйста, отправьте текстовое сообщение.", reply_markup=kb)
|
||
return
|
||
|
||
if message.text.isdigit():
|
||
tg_id = int(message.text)
|
||
else:
|
||
username = message.text.strip().lstrip("@")
|
||
username = username.replace("https://t.me/", "")
|
||
|
||
stmt = select(User.tg_id).where(User.username == username)
|
||
result = await session.execute(stmt)
|
||
tg_id = result.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)
|
||
|
||
|
||
@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-форматирование — <b>жирный</b>, <i>курсив</i> и другие стили через редактор Telegram.\n\n"
|
||
"Вы можете отправить:\n"
|
||
"• Только <b>текст</b>\n"
|
||
"• Только <b>картинку</b>\n"
|
||
"• <b>Текст + картинку</b>"
|
||
),
|
||
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Максимум: <b>{max_len}</b> символов, сейчас: <b>{len(text_message)}</b>.",
|
||
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",
|
||
)
|
||
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):
|
||
users_result = await session.execute(select(User.tg_id).where(User.trial == 1))
|
||
users_with_trial_used = [row[0] for row in users_result.all()]
|
||
|
||
users_to_reset = []
|
||
for tg_id in users_with_trial_used:
|
||
has_keys = await session.execute(select(Key.tg_id).where(Key.tg_id == tg_id).limit(1))
|
||
if not has_keys.scalar():
|
||
users_to_reset.append(tg_id)
|
||
|
||
if users_to_reset:
|
||
stmt = update(User).where(User.tg_id.in_(users_to_reset)).values(trial=0)
|
||
await session.execute(stmt)
|
||
await session.commit()
|
||
|
||
builder = InlineKeyboardBuilder()
|
||
builder.row(build_admin_back_btn())
|
||
|
||
await callback_query.message.edit_text(
|
||
text=f"✅ Пробники восстановлены для {len(users_to_reset)} пользователей без подписок.",
|
||
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,
|
||
) -> None:
|
||
await state.clear()
|
||
|
||
stmt_user = select(User.username, User.balance, User.created_at, User.updated_at).where(User.tg_id == tg_id)
|
||
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 = 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")
|
||
|
||
stmt_ref_count = select(func.count()).select_from(Referral).where(Referral.referrer_tg_id == tg_id)
|
||
result_ref = await session.execute(stmt_ref_count)
|
||
referral_count = result_ref.scalar_one()
|
||
|
||
stmt_ref_by = select(Referral.referrer_tg_id).where(Referral.referred_tg_id == tg_id).limit(1)
|
||
result_ref_by = await session.execute(stmt_ref_by)
|
||
referrer_tg_id = result_ref_by.scalar_one_or_none()
|
||
|
||
referrer_text = None
|
||
if referrer_tg_id:
|
||
stmt_referrer = select(User.username).where(User.tg_id == referrer_tg_id)
|
||
result_referrer = await session.execute(stmt_referrer)
|
||
ref_username = result_referrer.scalar_one_or_none()
|
||
if ref_username:
|
||
referrer_text = f"🤝 Пригласил: @{ref_username} ({referrer_tg_id})"
|
||
else:
|
||
referrer_text = f"🤝 Пригласил: {referrer_tg_id}"
|
||
|
||
stmt = select(
|
||
func.count(Payment.id),
|
||
func.coalesce(func.sum(Payment.amount), 0),
|
||
).where(Payment.status == "success", Payment.tg_id == tg_id)
|
||
result = await session.execute(stmt)
|
||
topups_amount, topups_sum = result.one_or_none() or (0, 0)
|
||
|
||
stmt_keys = select(Key).where(Key.tg_id == tg_id)
|
||
result_keys = await session.execute(stmt_keys)
|
||
key_records = result_keys.scalars().all()
|
||
|
||
body = Text(
|
||
f"🆔 ID: {tg_id}\n",
|
||
f"📄 Логин: @{username}" 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",
|
||
)
|
||
|
||
if referrer_text:
|
||
body += Text(referrer_text, "\n")
|
||
|
||
text_builder = Text(Bold("📊 Информация о пользователе"), "\n\n", BlockQuote(body))
|
||
|
||
text = text_builder.as_html()
|
||
|
||
stmt_ban = select(1).where(ManualBan.tg_id == tg_id).limit(1)
|
||
result_ban = await session.execute(stmt_ban)
|
||
is_banned = result_ban.scalar_one_or_none() is not None
|
||
|
||
kb = await build_user_edit_kb(tg_id, key_records, is_banned=is_banned)
|
||
|
||
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,
|
||
)
|