import logging
import re
from typing import List
from aiogram import Bot, Dispatcher, types, F
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.campaign import (
create_campaign,
delete_campaign,
get_campaign_by_id,
get_campaign_by_start_parameter,
get_campaign_statistics,
get_campaigns_count,
get_campaigns_list,
get_campaigns_overview,
update_campaign,
)
from app.database.crud.server_squad import get_all_server_squads, get_server_squad_by_id
from app.database.models import User
from app.keyboards.admin import (
get_admin_campaigns_keyboard,
get_admin_pagination_keyboard,
get_campaign_bonus_type_keyboard,
get_campaign_edit_keyboard,
get_campaign_management_keyboard,
get_confirmation_keyboard,
)
from app.localization.texts import get_texts
from app.states import AdminStates
from app.utils.decorators import admin_required, error_handler
logger = logging.getLogger(__name__)
_CAMPAIGN_PARAM_REGEX = re.compile(r"^[A-Za-z0-9_-]{3,32}$")
_CAMPAIGNS_PAGE_SIZE = 5
def _format_campaign_summary(campaign, texts) -> str:
status = "🟢 Активна" if campaign.is_active else "⚪️ Выключена"
if campaign.is_balance_bonus:
bonus_text = texts.format_price(campaign.balance_bonus_kopeks)
bonus_info = f"💰 Бонус на баланс: {bonus_text}"
else:
traffic_text = texts.format_traffic(campaign.subscription_traffic_gb or 0)
bonus_info = (
"📱 Подписка: {days} д.\n"
"🌐 Трафик: {traffic}\n"
"📱 Устройства: {devices}"
).format(
days=campaign.subscription_duration_days or 0,
traffic=traffic_text,
devices=campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT,
)
return (
f"{campaign.name}\n"
f"Стартовый параметр: {campaign.start_parameter}\n"
f"Статус: {status}\n"
f"{bonus_info}\n"
)
async def _get_bot_deep_link(
callback: types.CallbackQuery, start_parameter: str
) -> str:
bot = await callback.bot.get_me()
return f"https://t.me/{bot.username}?start={start_parameter}"
async def _get_bot_deep_link_from_message(
message: types.Message, start_parameter: str
) -> str:
bot = await message.bot.get_me()
return f"https://t.me/{bot.username}?start={start_parameter}"
def _build_campaign_servers_keyboard(
servers,
selected_uuids: List[str],
*,
toggle_prefix: str = "campaign_toggle_server_",
save_callback: str = "campaign_servers_save",
back_callback: str = "admin_campaigns",
) -> types.InlineKeyboardMarkup:
keyboard: List[List[types.InlineKeyboardButton]] = []
for server in servers[:20]:
is_selected = server.squad_uuid in selected_uuids
emoji = "✅" if is_selected else ("⚪" if server.is_available else "🔒")
text = f"{emoji} {server.display_name}"
keyboard.append(
[
types.InlineKeyboardButton(
text=text, callback_data=f"{toggle_prefix}{server.id}"
)
]
)
keyboard.append(
[
types.InlineKeyboardButton(
text="✅ Сохранить", callback_data=save_callback
),
types.InlineKeyboardButton(
text="⬅️ Назад", callback_data=back_callback
),
]
)
return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
async def _render_campaign_edit_menu(
bot: Bot,
chat_id: int,
message_id: int,
campaign,
language: str,
*,
use_caption: bool = False,
):
texts = get_texts(language)
text = (
"✏️ Редактирование кампании\n\n"
f"{_format_campaign_summary(campaign, texts)}\n"
"Выберите, что изменить:"
)
edit_kwargs = dict(
chat_id=chat_id,
message_id=message_id,
reply_markup=get_campaign_edit_keyboard(
campaign.id,
is_balance_bonus=campaign.is_balance_bonus,
language=language,
),
parse_mode="HTML",
)
if use_caption:
await bot.edit_message_caption(
caption=text,
**edit_kwargs,
)
else:
await bot.edit_message_text(
text=text,
**edit_kwargs,
)
@admin_required
@error_handler
async def show_campaigns_menu(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
overview = await get_campaigns_overview(db)
text = (
"📣 Рекламные кампании\n\n"
f"Всего кампаний: {overview['total']}\n"
f"Активных: {overview['active']} | Выключены: {overview['inactive']}\n"
f"Регистраций: {overview['registrations']}\n"
f"Выдано баланса: {texts.format_price(overview['balance_total'])}\n"
f"Выдано подписок: {overview['subscription_total']}"
)
await callback.message.edit_text(
text,
reply_markup=get_admin_campaigns_keyboard(db_user.language),
)
await callback.answer()
@admin_required
@error_handler
async def show_campaigns_overall_stats(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
overview = await get_campaigns_overview(db)
text = ["📊 Общая статистика кампаний\n"]
text.append(f"Всего кампаний: {overview['total']}")
text.append(
f"Активны: {overview['active']}, выключены: {overview['inactive']}"
)
text.append(f"Всего регистраций: {overview['registrations']}")
text.append(
f"Суммарно выдано баланса: {texts.format_price(overview['balance_total'])}"
)
text.append(f"Выдано подписок: {overview['subscription_total']}")
await callback.message.edit_text(
"\n".join(text),
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text="⬅️ Назад", callback_data="admin_campaigns"
)
]
]
),
)
await callback.answer()
@admin_required
@error_handler
async def show_campaigns_list(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
texts = get_texts(db_user.language)
page = 1
if callback.data.startswith("admin_campaigns_list_page_"):
try:
page = int(callback.data.split("_")[-1])
except ValueError:
page = 1
offset = (page - 1) * _CAMPAIGNS_PAGE_SIZE
campaigns = await get_campaigns_list(
db,
offset=offset,
limit=_CAMPAIGNS_PAGE_SIZE,
)
total = await get_campaigns_count(db)
total_pages = max(1, (total + _CAMPAIGNS_PAGE_SIZE - 1) // _CAMPAIGNS_PAGE_SIZE)
if not campaigns:
await callback.message.edit_text(
"❌ Рекламные кампании не найдены.",
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text="➕ Создать", callback_data="admin_campaigns_create"
)
],
[
types.InlineKeyboardButton(
text="⬅️ Назад", callback_data="admin_campaigns"
)
],
]
),
)
await callback.answer()
return
text_lines = ["📋 Список кампаний\n"]
for campaign in campaigns:
registrations = len(campaign.registrations or [])
total_balance = sum(
r.balance_bonus_kopeks or 0 for r in campaign.registrations or []
)
status = "🟢" if campaign.is_active else "⚪"
line = (
f"{status} {campaign.name} — {campaign.start_parameter}\n"
f" Регистраций: {registrations}, баланс: {texts.format_price(total_balance)}"
)
if campaign.is_subscription_bonus:
line += f", подписка: {campaign.subscription_duration_days or 0} д."
else:
line += ", бонус: баланс"
text_lines.append(line)
keyboard_rows = [
[
types.InlineKeyboardButton(
text=f"🔍 {campaign.name}",
callback_data=f"admin_campaign_manage_{campaign.id}",
)
]
for campaign in campaigns
]
pagination = get_admin_pagination_keyboard(
current_page=page,
total_pages=total_pages,
callback_prefix="admin_campaigns_list",
back_callback="admin_campaigns",
language=db_user.language,
)
keyboard_rows.extend(pagination.inline_keyboard)
await callback.message.edit_text(
"\n".join(text_lines),
reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows),
)
await callback.answer()
@admin_required
@error_handler
async def show_campaign_detail(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
campaign_id = int(callback.data.split("_")[-1])
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
await callback.answer("❌ Кампания не найдена", show_alert=True)
return
texts = get_texts(db_user.language)
stats = await get_campaign_statistics(db, campaign_id)
deep_link = await _get_bot_deep_link(callback, campaign.start_parameter)
text = ["📣 Управление кампанией\n"]
text.append(_format_campaign_summary(campaign, texts))
text.append(f"🔗 Ссылка: {deep_link}")
text.append("\n📊 Статистика")
text.append(f"• Регистраций: {stats['registrations']}")
text.append(
f"• Выдано баланса: {texts.format_price(stats['balance_issued'])}"
)
text.append(f"• Выдано подписок: {stats['subscription_issued']}")
text.append(
f"• Доход: {texts.format_price(stats['total_revenue_kopeks'])}"
)
text.append(
"• Получили триал: "
f"{stats['trial_users_count']}"
f" (активно: {stats['active_trials_count']})"
)
text.append(
"• Конверсий в оплату: "
f"{stats['conversion_count']}"
f" / пользователей с оплатой: {stats['paid_users_count']}"
)
text.append(
"• Конверсия в оплату: "
f"{stats['conversion_rate']:.1f}%"
)
text.append(
"• Конверсия триала: "
f"{stats['trial_conversion_rate']:.1f}%"
)
text.append(
"• Средний доход на пользователя: "
f"{texts.format_price(stats['avg_revenue_per_user_kopeks'])}"
)
text.append(
"• Средний первый платеж: "
f"{texts.format_price(stats['avg_first_payment_kopeks'])}"
)
if stats["last_registration"]:
text.append(
f"• Последняя: {stats['last_registration'].strftime('%d.%m.%Y %H:%M')}"
)
await callback.message.edit_text(
"\n".join(text),
reply_markup=get_campaign_management_keyboard(
campaign.id, campaign.is_active, db_user.language
),
)
await callback.answer()
@admin_required
@error_handler
async def show_campaign_edit_menu(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
campaign_id = int(callback.data.split("_")[-1])
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
await state.clear()
await callback.answer("❌ Кампания не найдена", show_alert=True)
return
await state.clear()
use_caption = bool(callback.message.caption) and not bool(callback.message.text)
await _render_campaign_edit_menu(
callback.bot,
callback.message.chat.id,
callback.message.message_id,
campaign,
db_user.language,
use_caption=use_caption,
)
await callback.answer()
@admin_required
@error_handler
async def start_edit_campaign_name(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
campaign_id = int(callback.data.split("_")[-1])
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
await callback.answer("❌ Кампания не найдена", show_alert=True)
return
await state.clear()
await state.set_state(AdminStates.editing_campaign_name)
is_caption = bool(callback.message.caption) and not bool(callback.message.text)
await state.update_data(
editing_campaign_id=campaign_id,
campaign_edit_message_id=callback.message.message_id,
campaign_edit_message_is_caption=is_caption,
)
await callback.message.edit_text(
(
"✏️ Изменение названия кампании\n\n"
f"Текущее название: {campaign.name}\n"
"Введите новое название (3-100 символов):"
),
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text="❌ Отмена",
callback_data=f"admin_campaign_edit_{campaign_id}",
)
]
]
),
)
await callback.answer()
@admin_required
@error_handler
async def process_edit_campaign_name(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
data = await state.get_data()
campaign_id = data.get("editing_campaign_id")
if not campaign_id:
await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.")
await state.clear()
return
new_name = message.text.strip()
if len(new_name) < 3 or len(new_name) > 100:
await message.answer(
"❌ Название должно содержать от 3 до 100 символов. Попробуйте снова."
)
return
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
await message.answer("❌ Кампания не найдена")
await state.clear()
return
await update_campaign(db, campaign, name=new_name)
await state.clear()
await message.answer("✅ Название обновлено.")
edit_message_id = data.get("campaign_edit_message_id")
edit_message_is_caption = data.get("campaign_edit_message_is_caption", False)
if edit_message_id:
await _render_campaign_edit_menu(
message.bot,
message.chat.id,
edit_message_id,
campaign,
db_user.language,
use_caption=edit_message_is_caption,
)
@admin_required
@error_handler
async def start_edit_campaign_start_parameter(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
campaign_id = int(callback.data.split("_")[-1])
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
await callback.answer("❌ Кампания не найдена", show_alert=True)
return
await state.clear()
await state.set_state(AdminStates.editing_campaign_start)
is_caption = bool(callback.message.caption) and not bool(callback.message.text)
await state.update_data(
editing_campaign_id=campaign_id,
campaign_edit_message_id=callback.message.message_id,
campaign_edit_message_is_caption=is_caption,
)
await callback.message.edit_text(
(
"🔗 Изменение стартового параметра\n\n"
f"Текущий параметр: {campaign.start_parameter}\n"
"Введите новый параметр (латинские буквы, цифры, - или _, 3-32 символа):"
),
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text="❌ Отмена",
callback_data=f"admin_campaign_edit_{campaign_id}",
)
]
]
),
)
await callback.answer()
@admin_required
@error_handler
async def process_edit_campaign_start_parameter(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
data = await state.get_data()
campaign_id = data.get("editing_campaign_id")
if not campaign_id:
await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.")
await state.clear()
return
new_param = message.text.strip()
if not _CAMPAIGN_PARAM_REGEX.match(new_param):
await message.answer(
"❌ Разрешены только латинские буквы, цифры, символы - и _. Длина 3-32 символа."
)
return
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
await message.answer("❌ Кампания не найдена")
await state.clear()
return
existing = await get_campaign_by_start_parameter(db, new_param)
if existing and existing.id != campaign_id:
await message.answer("❌ Такой параметр уже используется. Введите другой вариант.")
return
await update_campaign(db, campaign, start_parameter=new_param)
await state.clear()
await message.answer("✅ Стартовый параметр обновлен.")
edit_message_id = data.get("campaign_edit_message_id")
edit_message_is_caption = data.get("campaign_edit_message_is_caption", False)
if edit_message_id:
await _render_campaign_edit_menu(
message.bot,
message.chat.id,
edit_message_id,
campaign,
db_user.language,
use_caption=edit_message_is_caption,
)
@admin_required
@error_handler
async def start_edit_campaign_balance_bonus(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
campaign_id = int(callback.data.split("_")[-1])
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
await callback.answer("❌ Кампания не найдена", show_alert=True)
return
if not campaign.is_balance_bonus:
await callback.answer("❌ У кампании другой тип бонуса", show_alert=True)
return
await state.clear()
await state.set_state(AdminStates.editing_campaign_balance)
is_caption = bool(callback.message.caption) and not bool(callback.message.text)
await state.update_data(
editing_campaign_id=campaign_id,
campaign_edit_message_id=callback.message.message_id,
campaign_edit_message_is_caption=is_caption,
)
await callback.message.edit_text(
(
"💰 Изменение бонуса на баланс\n\n"
f"Текущий бонус: {get_texts(db_user.language).format_price(campaign.balance_bonus_kopeks)}\n"
"Введите новую сумму в рублях (например, 100 или 99.5):"
),
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text="❌ Отмена",
callback_data=f"admin_campaign_edit_{campaign_id}",
)
]
]
),
)
await callback.answer()
@admin_required
@error_handler
async def process_edit_campaign_balance_bonus(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
data = await state.get_data()
campaign_id = data.get("editing_campaign_id")
if not campaign_id:
await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.")
await state.clear()
return
try:
amount_rubles = float(message.text.replace(",", "."))
except ValueError:
await message.answer("❌ Введите корректную сумму (например, 100 или 99.5)")
return
if amount_rubles <= 0:
await message.answer("❌ Сумма должна быть больше нуля")
return
amount_kopeks = int(round(amount_rubles * 100))
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
await message.answer("❌ Кампания не найдена")
await state.clear()
return
if not campaign.is_balance_bonus:
await message.answer("❌ У кампании другой тип бонуса")
await state.clear()
return
await update_campaign(db, campaign, balance_bonus_kopeks=amount_kopeks)
await state.clear()
await message.answer("✅ Бонус обновлен.")
edit_message_id = data.get("campaign_edit_message_id")
edit_message_is_caption = data.get("campaign_edit_message_is_caption", False)
if edit_message_id:
await _render_campaign_edit_menu(
message.bot,
message.chat.id,
edit_message_id,
campaign,
db_user.language,
use_caption=edit_message_is_caption,
)
async def _ensure_subscription_campaign(message_or_callback, campaign) -> bool:
if campaign.is_balance_bonus:
if isinstance(message_or_callback, types.CallbackQuery):
await message_or_callback.answer(
"❌ Для этой кампании доступен только бонус на баланс",
show_alert=True,
)
else:
await message_or_callback.answer(
"❌ Для этой кампании нельзя изменить параметры подписки"
)
return False
return True
@admin_required
@error_handler
async def start_edit_campaign_subscription_days(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
campaign_id = int(callback.data.split("_")[-1])
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
await callback.answer("❌ Кампания не найдена", show_alert=True)
return
if not await _ensure_subscription_campaign(callback, campaign):
return
await state.clear()
await state.set_state(AdminStates.editing_campaign_subscription_days)
is_caption = bool(callback.message.caption) and not bool(callback.message.text)
await state.update_data(
editing_campaign_id=campaign_id,
campaign_edit_message_id=callback.message.message_id,
campaign_edit_message_is_caption=is_caption,
)
await callback.message.edit_text(
(
"📅 Изменение длительности подписки\n\n"
f"Текущее значение: {campaign.subscription_duration_days or 0} д.\n"
"Введите новое количество дней (1-730):"
),
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text="❌ Отмена",
callback_data=f"admin_campaign_edit_{campaign_id}",
)
]
]
),
)
await callback.answer()
@admin_required
@error_handler
async def process_edit_campaign_subscription_days(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
data = await state.get_data()
campaign_id = data.get("editing_campaign_id")
if not campaign_id:
await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.")
await state.clear()
return
try:
days = int(message.text.strip())
except ValueError:
await message.answer("❌ Введите число дней (1-730)")
return
if days <= 0 or days > 730:
await message.answer("❌ Длительность должна быть от 1 до 730 дней")
return
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
await message.answer("❌ Кампания не найдена")
await state.clear()
return
if not await _ensure_subscription_campaign(message, campaign):
await state.clear()
return
await update_campaign(db, campaign, subscription_duration_days=days)
await state.clear()
await message.answer("✅ Длительность подписки обновлена.")
edit_message_id = data.get("campaign_edit_message_id")
edit_message_is_caption = data.get("campaign_edit_message_is_caption", False)
if edit_message_id:
await _render_campaign_edit_menu(
message.bot,
message.chat.id,
edit_message_id,
campaign,
db_user.language,
use_caption=edit_message_is_caption,
)
@admin_required
@error_handler
async def start_edit_campaign_subscription_traffic(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
campaign_id = int(callback.data.split("_")[-1])
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
await callback.answer("❌ Кампания не найдена", show_alert=True)
return
if not await _ensure_subscription_campaign(callback, campaign):
return
await state.clear()
await state.set_state(AdminStates.editing_campaign_subscription_traffic)
is_caption = bool(callback.message.caption) and not bool(callback.message.text)
await state.update_data(
editing_campaign_id=campaign_id,
campaign_edit_message_id=callback.message.message_id,
campaign_edit_message_is_caption=is_caption,
)
current_traffic = campaign.subscription_traffic_gb or 0
traffic_text = "безлимит" if current_traffic == 0 else f"{current_traffic} ГБ"
await callback.message.edit_text(
(
"🌐 Изменение лимита трафика\n\n"
f"Текущее значение: {traffic_text}\n"
"Введите новый лимит в ГБ (0 = безлимит, максимум 10000):"
),
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text="❌ Отмена",
callback_data=f"admin_campaign_edit_{campaign_id}",
)
]
]
),
)
await callback.answer()
@admin_required
@error_handler
async def process_edit_campaign_subscription_traffic(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
data = await state.get_data()
campaign_id = data.get("editing_campaign_id")
if not campaign_id:
await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.")
await state.clear()
return
try:
traffic = int(message.text.strip())
except ValueError:
await message.answer("❌ Введите целое число (0 или больше)")
return
if traffic < 0 or traffic > 10000:
await message.answer("❌ Лимит трафика должен быть от 0 до 10000 ГБ")
return
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
await message.answer("❌ Кампания не найдена")
await state.clear()
return
if not await _ensure_subscription_campaign(message, campaign):
await state.clear()
return
await update_campaign(db, campaign, subscription_traffic_gb=traffic)
await state.clear()
await message.answer("✅ Лимит трафика обновлен.")
edit_message_id = data.get("campaign_edit_message_id")
edit_message_is_caption = data.get("campaign_edit_message_is_caption", False)
if edit_message_id:
await _render_campaign_edit_menu(
message.bot,
message.chat.id,
edit_message_id,
campaign,
db_user.language,
use_caption=edit_message_is_caption,
)
@admin_required
@error_handler
async def start_edit_campaign_subscription_devices(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
campaign_id = int(callback.data.split("_")[-1])
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
await callback.answer("❌ Кампания не найдена", show_alert=True)
return
if not await _ensure_subscription_campaign(callback, campaign):
return
await state.clear()
await state.set_state(AdminStates.editing_campaign_subscription_devices)
is_caption = bool(callback.message.caption) and not bool(callback.message.text)
await state.update_data(
editing_campaign_id=campaign_id,
campaign_edit_message_id=callback.message.message_id,
campaign_edit_message_is_caption=is_caption,
)
current_devices = campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT
await callback.message.edit_text(
(
"📱 Изменение лимита устройств\n\n"
f"Текущее значение: {current_devices}\n"
f"Введите новое количество (1-{settings.MAX_DEVICES_LIMIT}):"
),
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text="❌ Отмена",
callback_data=f"admin_campaign_edit_{campaign_id}",
)
]
]
),
)
await callback.answer()
@admin_required
@error_handler
async def process_edit_campaign_subscription_devices(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
data = await state.get_data()
campaign_id = data.get("editing_campaign_id")
if not campaign_id:
await message.answer("❌ Сессия редактирования устарела. Попробуйте снова.")
await state.clear()
return
try:
devices = int(message.text.strip())
except ValueError:
await message.answer("❌ Введите целое число устройств")
return
if devices < 1 or devices > settings.MAX_DEVICES_LIMIT:
await message.answer(
f"❌ Количество устройств должно быть от 1 до {settings.MAX_DEVICES_LIMIT}"
)
return
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
await message.answer("❌ Кампания не найдена")
await state.clear()
return
if not await _ensure_subscription_campaign(message, campaign):
await state.clear()
return
await update_campaign(db, campaign, subscription_device_limit=devices)
await state.clear()
await message.answer("✅ Лимит устройств обновлен.")
edit_message_id = data.get("campaign_edit_message_id")
edit_message_is_caption = data.get("campaign_edit_message_is_caption", False)
if edit_message_id:
await _render_campaign_edit_menu(
message.bot,
message.chat.id,
edit_message_id,
campaign,
db_user.language,
use_caption=edit_message_is_caption,
)
@admin_required
@error_handler
async def start_edit_campaign_subscription_servers(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
campaign_id = int(callback.data.split("_")[-1])
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
await callback.answer("❌ Кампания не найдена", show_alert=True)
return
if not await _ensure_subscription_campaign(callback, campaign):
return
servers, _ = await get_all_server_squads(db, available_only=False)
if not servers:
await callback.answer(
"❌ Не найдены доступные серверы. Добавьте серверы перед изменением.",
show_alert=True,
)
return
selected = list(campaign.subscription_squads or [])
await state.clear()
await state.set_state(AdminStates.editing_campaign_subscription_servers)
is_caption = bool(callback.message.caption) and not bool(callback.message.text)
await state.update_data(
editing_campaign_id=campaign_id,
campaign_edit_message_id=callback.message.message_id,
campaign_subscription_squads=selected,
campaign_edit_message_is_caption=is_caption,
)
keyboard = _build_campaign_servers_keyboard(
servers,
selected,
toggle_prefix=f"campaign_edit_toggle_{campaign_id}_",
save_callback=f"campaign_edit_servers_save_{campaign_id}",
back_callback=f"admin_campaign_edit_{campaign_id}",
)
await callback.message.edit_text(
(
"🌍 Редактирование доступных серверов\n\n"
"Нажмите на сервер, чтобы добавить или убрать его из кампании.\n"
"После выбора нажмите \"✅ Сохранить\"."
),
reply_markup=keyboard,
)
await callback.answer()
@admin_required
@error_handler
async def toggle_edit_campaign_server(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
parts = callback.data.split("_")
try:
server_id = int(parts[-1])
except (ValueError, IndexError):
await callback.answer("❌ Не удалось определить сервер", show_alert=True)
return
data = await state.get_data()
campaign_id = data.get("editing_campaign_id")
if not campaign_id:
await callback.answer("❌ Сессия редактирования устарела", show_alert=True)
await state.clear()
return
server = await get_server_squad_by_id(db, server_id)
if not server:
await callback.answer("❌ Сервер не найден", show_alert=True)
return
selected = list(data.get("campaign_subscription_squads", []))
if server.squad_uuid in selected:
selected.remove(server.squad_uuid)
else:
selected.append(server.squad_uuid)
await state.update_data(campaign_subscription_squads=selected)
servers, _ = await get_all_server_squads(db, available_only=False)
keyboard = _build_campaign_servers_keyboard(
servers,
selected,
toggle_prefix=f"campaign_edit_toggle_{campaign_id}_",
save_callback=f"campaign_edit_servers_save_{campaign_id}",
back_callback=f"admin_campaign_edit_{campaign_id}",
)
await callback.message.edit_reply_markup(reply_markup=keyboard)
await callback.answer()
@admin_required
@error_handler
async def save_edit_campaign_subscription_servers(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
data = await state.get_data()
campaign_id = data.get("editing_campaign_id")
if not campaign_id:
await callback.answer("❌ Сессия редактирования устарела", show_alert=True)
await state.clear()
return
selected = list(data.get("campaign_subscription_squads", []))
if not selected:
await callback.answer("❗ Выберите хотя бы один сервер", show_alert=True)
return
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
await state.clear()
await callback.answer("❌ Кампания не найдена", show_alert=True)
return
if not await _ensure_subscription_campaign(callback, campaign):
await state.clear()
return
await update_campaign(db, campaign, subscription_squads=selected)
await state.clear()
use_caption = bool(callback.message.caption) and not bool(callback.message.text)
await _render_campaign_edit_menu(
callback.bot,
callback.message.chat.id,
callback.message.message_id,
campaign,
db_user.language,
use_caption=use_caption,
)
await callback.answer("✅ Сохранено")
@admin_required
@error_handler
async def toggle_campaign_status(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
campaign_id = int(callback.data.split("_")[-1])
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
await callback.answer("❌ Кампания не найдена", show_alert=True)
return
new_status = not campaign.is_active
await update_campaign(db, campaign, is_active=new_status)
status_text = "включена" if new_status else "выключена"
logger.info("🔄 Кампания %s переключена: %s", campaign_id, status_text)
await show_campaign_detail(callback, db_user, db)
@admin_required
@error_handler
async def show_campaign_stats(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
campaign_id = int(callback.data.split("_")[-1])
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
await callback.answer("❌ Кампания не найдена", show_alert=True)
return
texts = get_texts(db_user.language)
stats = await get_campaign_statistics(db, campaign_id)
text = ["📊 Статистика кампании\n"]
text.append(_format_campaign_summary(campaign, texts))
text.append(f"Регистраций: {stats['registrations']}")
text.append(f"Выдано баланса: {texts.format_price(stats['balance_issued'])}")
text.append(f"Выдано подписок: {stats['subscription_issued']}")
if stats["last_registration"]:
text.append(
f"Последняя регистрация: {stats['last_registration'].strftime('%d.%m.%Y %H:%M')}"
)
await callback.message.edit_text(
"\n".join(text),
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text="⬅️ Назад",
callback_data=f"admin_campaign_manage_{campaign_id}",
)
]
]
),
)
await callback.answer()
@admin_required
@error_handler
async def confirm_delete_campaign(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
campaign_id = int(callback.data.split("_")[-1])
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
await callback.answer("❌ Кампания не найдена", show_alert=True)
return
text = (
"🗑️ Удаление кампании\n\n"
f"Название: {campaign.name}\n"
f"Параметр: {campaign.start_parameter}\n\n"
"Вы уверены, что хотите удалить кампанию?"
)
await callback.message.edit_text(
text,
reply_markup=get_confirmation_keyboard(
confirm_action=f"admin_campaign_delete_confirm_{campaign_id}",
cancel_action=f"admin_campaign_manage_{campaign_id}",
),
)
await callback.answer()
@admin_required
@error_handler
async def delete_campaign_confirmed(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
):
campaign_id = int(callback.data.split("_")[-1])
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign:
await callback.answer("❌ Кампания не найдена", show_alert=True)
return
await delete_campaign(db, campaign)
await callback.message.edit_text(
"✅ Кампания удалена.",
reply_markup=get_admin_campaigns_keyboard(db_user.language),
)
await callback.answer("Удалено")
@admin_required
@error_handler
async def start_campaign_creation(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
await state.clear()
await callback.message.edit_text(
"🆕 Создание рекламной кампании\n\nВведите название кампании:",
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text="⬅️ Назад", callback_data="admin_campaigns"
)
]
]
),
)
await state.set_state(AdminStates.creating_campaign_name)
await callback.answer()
@admin_required
@error_handler
async def process_campaign_name(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
name = message.text.strip()
if len(name) < 3 or len(name) > 100:
await message.answer(
"❌ Название должно содержать от 3 до 100 символов. Попробуйте снова."
)
return
await state.update_data(campaign_name=name)
await state.set_state(AdminStates.creating_campaign_start)
await message.answer(
"🔗 Теперь введите параметр старта (латинские буквы, цифры, - или _):",
)
@admin_required
@error_handler
async def process_campaign_start_parameter(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
start_param = message.text.strip()
if not _CAMPAIGN_PARAM_REGEX.match(start_param):
await message.answer(
"❌ Разрешены только латинские буквы, цифры, символы - и _. Длина 3-32 символа."
)
return
existing = await get_campaign_by_start_parameter(db, start_param)
if existing:
await message.answer(
"❌ Кампания с таким параметром уже существует. Введите другой параметр."
)
return
await state.update_data(campaign_start_parameter=start_param)
await state.set_state(AdminStates.creating_campaign_bonus)
await message.answer(
"🎯 Выберите тип бонуса для кампании:",
reply_markup=get_campaign_bonus_type_keyboard(db_user.language),
)
@admin_required
@error_handler
async def select_campaign_bonus_type(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
bonus_type = "balance" if callback.data.endswith("balance") else "subscription"
await state.update_data(campaign_bonus_type=bonus_type)
if bonus_type == "balance":
await state.set_state(AdminStates.creating_campaign_balance)
await callback.message.edit_text(
"💰 Введите сумму бонуса на баланс (в рублях):",
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text="⬅️ Назад", callback_data="admin_campaigns"
)
]
]
),
)
else:
await state.set_state(AdminStates.creating_campaign_subscription_days)
await callback.message.edit_text(
"📅 Введите длительность подписки в днях (1-730):",
reply_markup=types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text="⬅️ Назад", callback_data="admin_campaigns"
)
]
]
),
)
await callback.answer()
@admin_required
@error_handler
async def process_campaign_balance_value(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
try:
amount_rubles = float(message.text.replace(",", "."))
except ValueError:
await message.answer("❌ Введите корректную сумму (например, 100 или 99.5)")
return
if amount_rubles <= 0:
await message.answer("❌ Сумма должна быть больше нуля")
return
amount_kopeks = int(round(amount_rubles * 100))
data = await state.get_data()
campaign = await create_campaign(
db,
name=data["campaign_name"],
start_parameter=data["campaign_start_parameter"],
bonus_type="balance",
balance_bonus_kopeks=amount_kopeks,
created_by=db_user.id,
)
await state.clear()
deep_link = await _get_bot_deep_link_from_message(message, campaign.start_parameter)
texts = get_texts(db_user.language)
summary = _format_campaign_summary(campaign, texts)
text = (
"✅ Кампания создана!\n\n"
f"{summary}\n"
f"🔗 Ссылка: {deep_link}"
)
await message.answer(
text,
reply_markup=get_campaign_management_keyboard(
campaign.id, campaign.is_active, db_user.language
),
)
@admin_required
@error_handler
async def process_campaign_subscription_days(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
try:
days = int(message.text.strip())
except ValueError:
await message.answer("❌ Введите число дней (1-730)")
return
if days <= 0 or days > 730:
await message.answer("❌ Длительность должна быть от 1 до 730 дней")
return
await state.update_data(campaign_subscription_days=days)
await state.set_state(AdminStates.creating_campaign_subscription_traffic)
await message.answer("🌐 Введите лимит трафика в ГБ (0 = безлимит):")
@admin_required
@error_handler
async def process_campaign_subscription_traffic(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
try:
traffic = int(message.text.strip())
except ValueError:
await message.answer("❌ Введите целое число (0 или больше)")
return
if traffic < 0 or traffic > 10000:
await message.answer("❌ Лимит трафика должен быть от 0 до 10000 ГБ")
return
await state.update_data(campaign_subscription_traffic=traffic)
await state.set_state(AdminStates.creating_campaign_subscription_devices)
await message.answer(
f"📱 Введите количество устройств (1-{settings.MAX_DEVICES_LIMIT}):"
)
@admin_required
@error_handler
async def process_campaign_subscription_devices(
message: types.Message,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
try:
devices = int(message.text.strip())
except ValueError:
await message.answer("❌ Введите целое число устройств")
return
if devices < 1 or devices > settings.MAX_DEVICES_LIMIT:
await message.answer(
f"❌ Количество устройств должно быть от 1 до {settings.MAX_DEVICES_LIMIT}"
)
return
await state.update_data(campaign_subscription_devices=devices)
await state.update_data(campaign_subscription_squads=[])
await state.set_state(AdminStates.creating_campaign_subscription_servers)
servers, _ = await get_all_server_squads(db, available_only=False)
if not servers:
await message.answer(
"❌ Не найдены доступные серверы. Добавьте сервера перед созданием кампании.",
)
await state.clear()
return
keyboard = _build_campaign_servers_keyboard(servers, [])
await message.answer(
"🌍 Выберите серверы, которые будут доступны по подписке (максимум 20 отображаются).",
reply_markup=keyboard,
)
@admin_required
@error_handler
async def toggle_campaign_server(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
server_id = int(callback.data.split("_")[-1])
server = await get_server_squad_by_id(db, server_id)
if not server:
await callback.answer("❌ Сервер не найден", show_alert=True)
return
data = await state.get_data()
selected = list(data.get("campaign_subscription_squads", []))
if server.squad_uuid in selected:
selected.remove(server.squad_uuid)
else:
selected.append(server.squad_uuid)
await state.update_data(campaign_subscription_squads=selected)
servers, _ = await get_all_server_squads(db, available_only=False)
keyboard = _build_campaign_servers_keyboard(servers, selected)
await callback.message.edit_reply_markup(reply_markup=keyboard)
await callback.answer()
@admin_required
@error_handler
async def finalize_campaign_subscription(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext,
db: AsyncSession,
):
data = await state.get_data()
selected = data.get("campaign_subscription_squads", [])
if not selected:
await callback.answer("❗ Выберите хотя бы один сервер", show_alert=True)
return
campaign = await create_campaign(
db,
name=data["campaign_name"],
start_parameter=data["campaign_start_parameter"],
bonus_type="subscription",
subscription_duration_days=data.get("campaign_subscription_days"),
subscription_traffic_gb=data.get("campaign_subscription_traffic"),
subscription_device_limit=data.get("campaign_subscription_devices"),
subscription_squads=selected,
created_by=db_user.id,
)
await state.clear()
deep_link = await _get_bot_deep_link(callback, campaign.start_parameter)
texts = get_texts(db_user.language)
summary = _format_campaign_summary(campaign, texts)
text = (
"✅ Кампания создана!\n\n"
f"{summary}\n"
f"🔗 Ссылка: {deep_link}"
)
await callback.message.edit_text(
text,
reply_markup=get_campaign_management_keyboard(
campaign.id, campaign.is_active, db_user.language
),
)
await callback.answer()
def register_handlers(dp: Dispatcher):
dp.callback_query.register(show_campaigns_menu, F.data == "admin_campaigns")
dp.callback_query.register(
show_campaigns_overall_stats, F.data == "admin_campaigns_stats"
)
dp.callback_query.register(show_campaigns_list, F.data == "admin_campaigns_list")
dp.callback_query.register(
show_campaigns_list, F.data.startswith("admin_campaigns_list_page_")
)
dp.callback_query.register(
start_campaign_creation, F.data == "admin_campaigns_create"
)
dp.callback_query.register(
show_campaign_stats, F.data.startswith("admin_campaign_stats_")
)
dp.callback_query.register(
show_campaign_detail, F.data.startswith("admin_campaign_manage_")
)
dp.callback_query.register(
start_edit_campaign_name, F.data.startswith("admin_campaign_edit_name_")
)
dp.callback_query.register(
start_edit_campaign_start_parameter,
F.data.startswith("admin_campaign_edit_start_"),
)
dp.callback_query.register(
start_edit_campaign_balance_bonus,
F.data.startswith("admin_campaign_edit_balance_"),
)
dp.callback_query.register(
start_edit_campaign_subscription_days,
F.data.startswith("admin_campaign_edit_sub_days_"),
)
dp.callback_query.register(
start_edit_campaign_subscription_traffic,
F.data.startswith("admin_campaign_edit_sub_traffic_"),
)
dp.callback_query.register(
start_edit_campaign_subscription_devices,
F.data.startswith("admin_campaign_edit_sub_devices_"),
)
dp.callback_query.register(
start_edit_campaign_subscription_servers,
F.data.startswith("admin_campaign_edit_sub_servers_"),
)
dp.callback_query.register(
save_edit_campaign_subscription_servers,
F.data.startswith("campaign_edit_servers_save_"),
)
dp.callback_query.register(
toggle_edit_campaign_server, F.data.startswith("campaign_edit_toggle_")
)
dp.callback_query.register(
show_campaign_edit_menu, F.data.startswith("admin_campaign_edit_")
)
dp.callback_query.register(
delete_campaign_confirmed, F.data.startswith("admin_campaign_delete_confirm_")
)
dp.callback_query.register(
confirm_delete_campaign, F.data.startswith("admin_campaign_delete_")
)
dp.callback_query.register(
toggle_campaign_status, F.data.startswith("admin_campaign_toggle_")
)
dp.callback_query.register(
finalize_campaign_subscription, F.data == "campaign_servers_save"
)
dp.callback_query.register(
toggle_campaign_server, F.data.startswith("campaign_toggle_server_")
)
dp.callback_query.register(
select_campaign_bonus_type, F.data.startswith("campaign_bonus_")
)
dp.message.register(process_campaign_name, AdminStates.creating_campaign_name)
dp.message.register(
process_campaign_start_parameter, AdminStates.creating_campaign_start
)
dp.message.register(
process_campaign_balance_value, AdminStates.creating_campaign_balance
)
dp.message.register(
process_campaign_subscription_days,
AdminStates.creating_campaign_subscription_days,
)
dp.message.register(
process_campaign_subscription_traffic,
AdminStates.creating_campaign_subscription_traffic,
)
dp.message.register(
process_campaign_subscription_devices,
AdminStates.creating_campaign_subscription_devices,
)
dp.message.register(
process_edit_campaign_name, AdminStates.editing_campaign_name
)
dp.message.register(
process_edit_campaign_start_parameter,
AdminStates.editing_campaign_start,
)
dp.message.register(
process_edit_campaign_balance_bonus,
AdminStates.editing_campaign_balance,
)
dp.message.register(
process_edit_campaign_subscription_days,
AdminStates.editing_campaign_subscription_days,
)
dp.message.register(
process_edit_campaign_subscription_traffic,
AdminStates.editing_campaign_subscription_traffic,
)
dp.message.register(
process_edit_campaign_subscription_devices,
AdminStates.editing_campaign_subscription_devices,
)