diff --git a/app/bot.py b/app/bot.py
index aaa30c53..1be1fd65 100644
--- a/app/bot.py
+++ b/app/bot.py
@@ -19,15 +19,23 @@ from app.handlers import (
referral, support, common
)
from app.handlers.admin import (
- main as admin_main, users as admin_users, subscriptions as admin_subscriptions,
- promocodes as admin_promocodes, messages as admin_messages,
- monitoring as admin_monitoring, referrals as admin_referrals,
- rules as admin_rules, remnawave as admin_remnawave,
- statistics as admin_statistics, servers as admin_servers,
+ main as admin_main,
+ users as admin_users,
+ subscriptions as admin_subscriptions,
+ promocodes as admin_promocodes,
+ messages as admin_messages,
+ monitoring as admin_monitoring,
+ referrals as admin_referrals,
+ rules as admin_rules,
+ remnawave as admin_remnawave,
+ statistics as admin_statistics,
+ servers as admin_servers,
maintenance as admin_maintenance,
+ campaigns as admin_campaigns,
user_messages as admin_user_messages,
- updates as admin_updates, backup as admin_backup,
- welcome_text as admin_welcome_text
+ updates as admin_updates,
+ backup as admin_backup,
+ welcome_text as admin_welcome_text,
)
from app.handlers.stars_payments import register_stars_handlers
@@ -119,6 +127,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_rules.register_handlers(dp)
admin_remnawave.register_handlers(dp)
admin_statistics.register_handlers(dp)
+ admin_campaigns.register_handlers(dp)
admin_maintenance.register_handlers(dp)
admin_user_messages.register_handlers(dp)
admin_updates.register_handlers(dp)
diff --git a/app/database/crud/campaign.py b/app/database/crud/campaign.py
new file mode 100644
index 00000000..40313434
--- /dev/null
+++ b/app/database/crud/campaign.py
@@ -0,0 +1,258 @@
+import logging
+from datetime import datetime
+from typing import Dict, List, Optional
+
+from sqlalchemy import and_, func, select, update, delete
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+
+from app.database.models import (
+ AdvertisingCampaign,
+ AdvertisingCampaignRegistration,
+)
+
+logger = logging.getLogger(__name__)
+
+
+async def create_campaign(
+ db: AsyncSession,
+ *,
+ name: str,
+ start_parameter: str,
+ bonus_type: str,
+ created_by: Optional[int] = None,
+ balance_bonus_kopeks: int = 0,
+ subscription_duration_days: Optional[int] = None,
+ subscription_traffic_gb: Optional[int] = None,
+ subscription_device_limit: Optional[int] = None,
+ subscription_squads: Optional[List[str]] = None,
+) -> AdvertisingCampaign:
+ campaign = AdvertisingCampaign(
+ name=name,
+ start_parameter=start_parameter,
+ bonus_type=bonus_type,
+ balance_bonus_kopeks=balance_bonus_kopeks or 0,
+ subscription_duration_days=subscription_duration_days,
+ subscription_traffic_gb=subscription_traffic_gb,
+ subscription_device_limit=subscription_device_limit,
+ subscription_squads=subscription_squads or [],
+ created_by=created_by,
+ is_active=True,
+ )
+
+ db.add(campaign)
+ await db.commit()
+ await db.refresh(campaign)
+
+ logger.info(
+ "📣 Создана рекламная кампания %s (start=%s, bonus=%s)",
+ campaign.name,
+ campaign.start_parameter,
+ campaign.bonus_type,
+ )
+ return campaign
+
+
+async def get_campaign_by_id(
+ db: AsyncSession, campaign_id: int
+) -> Optional[AdvertisingCampaign]:
+ result = await db.execute(
+ select(AdvertisingCampaign)
+ .options(selectinload(AdvertisingCampaign.registrations))
+ .where(AdvertisingCampaign.id == campaign_id)
+ )
+ return result.scalar_one_or_none()
+
+
+async def get_campaign_by_start_parameter(
+ db: AsyncSession,
+ start_parameter: str,
+ *,
+ only_active: bool = False,
+) -> Optional[AdvertisingCampaign]:
+ stmt = select(AdvertisingCampaign).where(
+ AdvertisingCampaign.start_parameter == start_parameter
+ )
+ if only_active:
+ stmt = stmt.where(AdvertisingCampaign.is_active.is_(True))
+
+ result = await db.execute(stmt)
+ return result.scalar_one_or_none()
+
+
+async def get_campaigns_list(
+ db: AsyncSession,
+ *,
+ offset: int = 0,
+ limit: int = 20,
+ include_inactive: bool = True,
+) -> List[AdvertisingCampaign]:
+ stmt = (
+ select(AdvertisingCampaign)
+ .options(selectinload(AdvertisingCampaign.registrations))
+ .order_by(AdvertisingCampaign.created_at.desc())
+ .offset(offset)
+ .limit(limit)
+ )
+ if not include_inactive:
+ stmt = stmt.where(AdvertisingCampaign.is_active.is_(True))
+
+ result = await db.execute(stmt)
+ return result.scalars().all()
+
+
+async def get_campaigns_count(
+ db: AsyncSession, *, is_active: Optional[bool] = None
+) -> int:
+ stmt = select(func.count(AdvertisingCampaign.id))
+ if is_active is not None:
+ stmt = stmt.where(AdvertisingCampaign.is_active.is_(is_active))
+
+ result = await db.execute(stmt)
+ return result.scalar_one() or 0
+
+
+async def update_campaign(
+ db: AsyncSession,
+ campaign: AdvertisingCampaign,
+ **kwargs,
+) -> AdvertisingCampaign:
+ allowed_fields = {
+ "name",
+ "start_parameter",
+ "bonus_type",
+ "balance_bonus_kopeks",
+ "subscription_duration_days",
+ "subscription_traffic_gb",
+ "subscription_device_limit",
+ "subscription_squads",
+ "is_active",
+ }
+
+ update_data = {key: value for key, value in kwargs.items() if key in allowed_fields}
+
+ if not update_data:
+ return campaign
+
+ update_data["updated_at"] = datetime.utcnow()
+
+ await db.execute(
+ update(AdvertisingCampaign)
+ .where(AdvertisingCampaign.id == campaign.id)
+ .values(**update_data)
+ )
+ await db.commit()
+ await db.refresh(campaign)
+
+ logger.info("✏️ Обновлена рекламная кампания %s (%s)", campaign.name, update_data)
+ return campaign
+
+
+async def delete_campaign(db: AsyncSession, campaign: AdvertisingCampaign) -> bool:
+ await db.execute(
+ delete(AdvertisingCampaign).where(AdvertisingCampaign.id == campaign.id)
+ )
+ await db.commit()
+ logger.info("🗑️ Удалена рекламная кампания %s", campaign.name)
+ return True
+
+
+async def record_campaign_registration(
+ db: AsyncSession,
+ *,
+ campaign_id: int,
+ user_id: int,
+ bonus_type: str,
+ balance_bonus_kopeks: int = 0,
+ subscription_duration_days: Optional[int] = None,
+) -> AdvertisingCampaignRegistration:
+ existing = await db.execute(
+ select(AdvertisingCampaignRegistration).where(
+ and_(
+ AdvertisingCampaignRegistration.campaign_id == campaign_id,
+ AdvertisingCampaignRegistration.user_id == user_id,
+ )
+ )
+ )
+ registration = existing.scalar_one_or_none()
+ if registration:
+ return registration
+
+ registration = AdvertisingCampaignRegistration(
+ campaign_id=campaign_id,
+ user_id=user_id,
+ bonus_type=bonus_type,
+ balance_bonus_kopeks=balance_bonus_kopeks or 0,
+ subscription_duration_days=subscription_duration_days,
+ )
+ db.add(registration)
+ await db.commit()
+ await db.refresh(registration)
+
+ logger.info("📈 Регистрируем пользователя %s в кампании %s", user_id, campaign_id)
+ return registration
+
+
+async def get_campaign_statistics(
+ db: AsyncSession,
+ campaign_id: int,
+) -> Dict[str, Optional[int]]:
+ result = await db.execute(
+ select(
+ func.count(AdvertisingCampaignRegistration.id),
+ func.coalesce(
+ func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0
+ ),
+ func.max(AdvertisingCampaignRegistration.created_at),
+ ).where(AdvertisingCampaignRegistration.campaign_id == campaign_id)
+ )
+ count, total_balance, last_registration = result.one()
+
+ subscription_count_result = await db.execute(
+ select(func.count(AdvertisingCampaignRegistration.id)).where(
+ and_(
+ AdvertisingCampaignRegistration.campaign_id == campaign_id,
+ AdvertisingCampaignRegistration.bonus_type == "subscription",
+ )
+ )
+ )
+
+ return {
+ "registrations": count or 0,
+ "balance_issued": total_balance or 0,
+ "subscription_issued": subscription_count_result.scalar() or 0,
+ "last_registration": last_registration,
+ }
+
+
+async def get_campaigns_overview(db: AsyncSession) -> Dict[str, int]:
+ total = await get_campaigns_count(db)
+ active = await get_campaigns_count(db, is_active=True)
+ inactive = await get_campaigns_count(db, is_active=False)
+
+ registrations_result = await db.execute(
+ select(func.count(AdvertisingCampaignRegistration.id))
+ )
+
+ balance_result = await db.execute(
+ select(
+ func.coalesce(
+ func.sum(AdvertisingCampaignRegistration.balance_bonus_kopeks), 0
+ )
+ )
+ )
+
+ subscription_result = await db.execute(
+ select(func.count(AdvertisingCampaignRegistration.id)).where(
+ AdvertisingCampaignRegistration.bonus_type == "subscription"
+ )
+ )
+
+ return {
+ "total": total,
+ "active": active,
+ "inactive": inactive,
+ "registrations": registrations_result.scalar() or 0,
+ "balance_total": balance_result.scalar() or 0,
+ "subscription_total": subscription_result.scalar() or 0,
+ }
diff --git a/app/database/models.py b/app/database/models.py
index e12e1eb7..285eeb08 100644
--- a/app/database/models.py
+++ b/app/database/models.py
@@ -3,8 +3,17 @@ from typing import Optional, List
from enum import Enum
from sqlalchemy import (
- Column, Integer, String, DateTime, Boolean, Text,
- ForeignKey, Float, JSON, BigInteger
+ Column,
+ Integer,
+ String,
+ DateTime,
+ Boolean,
+ Text,
+ ForeignKey,
+ Float,
+ JSON,
+ BigInteger,
+ UniqueConstraint,
)
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship, Mapped, mapped_column
@@ -666,7 +675,7 @@ class UserMessage(Base):
class WelcomeText(Base):
__tablename__ = "welcome_texts"
-
+
id = Column(Integer, primary_key=True, index=True)
text_content = Column(Text, nullable=False)
is_active = Column(Boolean, default=True)
@@ -674,5 +683,61 @@ class WelcomeText(Base):
created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
-
+
creator = relationship("User", backref="created_welcome_texts")
+
+
+class AdvertisingCampaign(Base):
+ __tablename__ = "advertising_campaigns"
+
+ id = Column(Integer, primary_key=True, index=True)
+ name = Column(String(255), nullable=False)
+ start_parameter = Column(String(64), nullable=False, unique=True, index=True)
+ bonus_type = Column(String(20), nullable=False)
+
+ balance_bonus_kopeks = Column(Integer, default=0)
+
+ subscription_duration_days = Column(Integer, nullable=True)
+ subscription_traffic_gb = Column(Integer, nullable=True)
+ subscription_device_limit = Column(Integer, nullable=True)
+ subscription_squads = Column(JSON, default=list)
+
+ is_active = Column(Boolean, default=True)
+
+ created_by = Column(Integer, ForeignKey("users.id"), nullable=True)
+ created_at = Column(DateTime, default=func.now())
+ updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
+
+ registrations = relationship("AdvertisingCampaignRegistration", back_populates="campaign")
+
+ @property
+ def is_balance_bonus(self) -> bool:
+ return self.bonus_type == "balance"
+
+ @property
+ def is_subscription_bonus(self) -> bool:
+ return self.bonus_type == "subscription"
+
+
+class AdvertisingCampaignRegistration(Base):
+ __tablename__ = "advertising_campaign_registrations"
+ __table_args__ = (
+ UniqueConstraint("campaign_id", "user_id", name="uq_campaign_user"),
+ )
+
+ id = Column(Integer, primary_key=True, index=True)
+ campaign_id = Column(Integer, ForeignKey("advertising_campaigns.id", ondelete="CASCADE"), nullable=False)
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
+
+ bonus_type = Column(String(20), nullable=False)
+ balance_bonus_kopeks = Column(Integer, default=0)
+ subscription_duration_days = Column(Integer, nullable=True)
+
+ created_at = Column(DateTime, default=func.now())
+
+ campaign = relationship("AdvertisingCampaign", back_populates="registrations")
+ user = relationship("User")
+
+ @property
+ def balance_bonus_rubles(self) -> float:
+ return (self.balance_bonus_kopeks or 0) / 100
diff --git a/app/handlers/admin/campaigns.py b/app/handlers/admin/campaigns.py
new file mode 100644
index 00000000..68708b67
--- /dev/null
+++ b/app/handlers/admin/campaigns.py
@@ -0,0 +1,805 @@
+import logging
+import re
+from typing import List
+
+from aiogram import 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_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]
+) -> 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"campaign_toggle_server_{server.id}"
+ )
+ ]
+ )
+
+ keyboard.append(
+ [
+ types.InlineKeyboardButton(
+ text="✅ Сохранить", callback_data="campaign_servers_save"
+ ),
+ types.InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_campaigns"),
+ ]
+ )
+
+ return types.InlineKeyboardMarkup(inline_keyboard=keyboard)
+
+
+@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']}")
+ 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 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_callback=f"admin_campaign_delete_confirm_{campaign_id}",
+ cancel_callback=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(
+ 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,
+ )
diff --git a/app/handlers/start.py b/app/handlers/start.py
index b33ced5f..312010df 100644
--- a/app/handlers/start.py
+++ b/app/handlers/start.py
@@ -9,7 +9,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.states import RegistrationStates
from app.database.crud.user import (
- get_user_by_telegram_id, create_user, get_user_by_referral_code
+ get_user_by_telegram_id,
+ create_user,
+ get_user_by_referral_code,
+)
+from app.database.crud.campaign import (
+ get_campaign_by_start_parameter,
+ get_campaign_by_id,
)
from app.database.models import UserStatus
from app.keyboards.inline import (
@@ -17,15 +23,52 @@ from app.keyboards.inline import (
)
from app.localization.texts import get_texts
from app.services.referral_service import process_referral_registration
+from app.services.campaign_service import AdvertisingCampaignService
from app.utils.user_utils import generate_unique_referral_code
from app.database.crud.user_message import get_random_active_message
-from aiogram.enums import ChatMemberStatus
-from aiogram.exceptions import TelegramForbiddenError, TelegramBadRequest
logger = logging.getLogger(__name__)
+async def _apply_campaign_bonus_if_needed(
+ db: AsyncSession,
+ user,
+ state_data: dict,
+ texts,
+):
+ campaign_id = state_data.get("campaign_id") if state_data else None
+ if not campaign_id:
+ return None
+
+ campaign = await get_campaign_by_id(db, campaign_id)
+ if not campaign or not campaign.is_active:
+ return None
+
+ service = AdvertisingCampaignService()
+ result = await service.apply_campaign_bonus(db, user, campaign)
+ if not result.success:
+ return None
+
+ if result.bonus_type == "balance":
+ amount_text = texts.format_price(result.balance_kopeks)
+ return texts.CAMPAIGN_BONUS_BALANCE.format(
+ amount=amount_text,
+ name=campaign.name,
+ )
+
+ if result.bonus_type == "subscription":
+ traffic_text = texts.format_traffic(result.subscription_traffic_gb or 0)
+ return texts.CAMPAIGN_BONUS_SUBSCRIPTION.format(
+ name=campaign.name,
+ days=result.subscription_days,
+ traffic=traffic_text,
+ devices=result.subscription_device_limit,
+ )
+
+ return None
+
+
async def handle_potential_referral_code(
message: types.Message,
state: FSMContext,
@@ -86,13 +129,29 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
logger.info(f"🚀 START: Обработка /start от {message.from_user.id}")
referral_code = None
- if len(message.text.split()) > 1:
- potential_code = message.text.split()[1]
- referral_code = potential_code
- logger.info(f"🔎 Найден реферальный код: {referral_code}")
-
+ campaign = None
+ start_args = message.text.split()
+ if len(start_args) > 1:
+ start_parameter = start_args[1]
+ campaign = await get_campaign_by_start_parameter(
+ db,
+ start_parameter,
+ only_active=True,
+ )
+
+ if campaign:
+ logger.info(
+ "📣 Найдена рекламная кампания %s (start=%s)",
+ campaign.id,
+ campaign.start_parameter,
+ )
+ await state.update_data(campaign_id=campaign.id)
+ else:
+ referral_code = start_parameter
+ logger.info(f"🔎 Найден реферальный код: {referral_code}")
+
if referral_code:
- await state.set_data({'referral_code': referral_code})
+ await state.update_data(referral_code=referral_code)
user = db_user if db_user else await get_user_by_telegram_id(db, message.from_user.id)
@@ -130,9 +189,19 @@ async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession,
await db.commit()
texts = get_texts(user.language)
-
+
if referral_code and not user.referred_by_id:
- await message.answer("ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.")
+ await message.answer(
+ "ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена."
+ )
+
+ if campaign:
+ try:
+ await message.answer(texts.CAMPAIGN_EXISTING_USER)
+ except Exception as e:
+ logger.error(
+ f"Ошибка отправки уведомления о рекламной кампании: {e}"
+ )
has_active_subscription = user.subscription is not None
subscription_is_active = False
@@ -533,9 +602,35 @@ async def complete_registration_from_callback(
logger.info(f"✅ Реферальная регистрация обработана для {user.id}")
except Exception as e:
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
-
+
+ campaign_message = await _apply_campaign_bonus_if_needed(db, user, data, texts)
+
+ try:
+ await db.refresh(user)
+ except Exception as refresh_error:
+ logger.error(
+ "Ошибка обновления данных пользователя %s после бонуса кампании: %s",
+ user.telegram_id,
+ refresh_error,
+ )
+
+ try:
+ await db.refresh(user, ["subscription"])
+ except Exception as refresh_subscription_error:
+ logger.error(
+ "Ошибка обновления подписки пользователя %s после бонуса кампании: %s",
+ user.telegram_id,
+ refresh_subscription_error,
+ )
+
await state.clear()
+ if campaign_message:
+ try:
+ await callback.message.answer(campaign_message)
+ except Exception as e:
+ logger.error(f"Ошибка отправки сообщения о бонусе кампании: {e}")
+
from app.database.crud.welcome_text import get_welcome_text_for_user
offer_text = await get_welcome_text_for_user(db, callback.from_user)
@@ -551,10 +646,10 @@ async def complete_registration_from_callback(
else:
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
- has_active_subscription = user.subscription is not None
+ has_active_subscription = bool(getattr(user, "subscription", None))
subscription_is_active = False
-
- if user.subscription:
+
+ if getattr(user, "subscription", None):
subscription_is_active = user.subscription.is_active
menu_text = await get_main_menu_text(user, texts, db)
@@ -698,9 +793,35 @@ async def complete_registration(
logger.info(f"✅ Реферальная регистрация обработана для {user.id}")
except Exception as e:
logger.error(f"Ошибка при обработке реферальной регистрации: {e}")
-
+
+ campaign_message = await _apply_campaign_bonus_if_needed(db, user, data, texts)
+
+ try:
+ await db.refresh(user)
+ except Exception as refresh_error:
+ logger.error(
+ "Ошибка обновления данных пользователя %s после бонуса кампании: %s",
+ user.telegram_id,
+ refresh_error,
+ )
+
+ try:
+ await db.refresh(user, ["subscription"])
+ except Exception as refresh_subscription_error:
+ logger.error(
+ "Ошибка обновления подписки пользователя %s после бонуса кампании: %s",
+ user.telegram_id,
+ refresh_subscription_error,
+ )
+
await state.clear()
+ if campaign_message:
+ try:
+ await message.answer(campaign_message)
+ except Exception as e:
+ logger.error(f"Ошибка отправки сообщения о бонусе кампании: {e}")
+
from app.database.crud.welcome_text import get_welcome_text_for_user
offer_text = await get_welcome_text_for_user(db, message.from_user)
@@ -716,10 +837,10 @@ async def complete_registration(
else:
logger.info(f"ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя {user.telegram_id}")
- has_active_subscription = user.subscription is not None
+ has_active_subscription = bool(getattr(user, "subscription", None))
subscription_is_active = False
-
- if user.subscription:
+
+ if getattr(user, "subscription", None):
subscription_is_active = user.subscription.is_active
menu_text = await get_main_menu_text(user, texts, db)
diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py
index 067c1e83..d49daf82 100644
--- a/app/keyboards/admin.py
+++ b/app/keyboards/admin.py
@@ -36,12 +36,15 @@ def get_admin_users_submenu_keyboard(language: str = "ru") -> InlineKeyboardMark
def get_admin_promo_submenu_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
-
+
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text=texts.ADMIN_PROMOCODES, callback_data="admin_promocodes"),
InlineKeyboardButton(text=texts.ADMIN_STATISTICS, callback_data="admin_statistics")
],
+ [
+ InlineKeyboardButton(text=texts.ADMIN_CAMPAIGNS, callback_data="admin_campaigns")
+ ],
[
InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_panel")
]
@@ -147,6 +150,54 @@ def get_admin_promocodes_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
])
+def get_admin_campaigns_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
+
+ return InlineKeyboardMarkup(inline_keyboard=[
+ [
+ InlineKeyboardButton(text="📋 Список кампаний", callback_data="admin_campaigns_list"),
+ InlineKeyboardButton(text="➕ Создать", callback_data="admin_campaigns_create")
+ ],
+ [
+ InlineKeyboardButton(text="📊 Общая статистика", callback_data="admin_campaigns_stats")
+ ],
+ [
+ InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo")
+ ]
+ ])
+
+
+def get_campaign_management_keyboard(campaign_id: int, is_active: bool, language: str = "ru") -> InlineKeyboardMarkup:
+ status_text = "🔴 Выключить" if is_active else "🟢 Включить"
+
+ return InlineKeyboardMarkup(inline_keyboard=[
+ [
+ InlineKeyboardButton(text="📊 Статистика", callback_data=f"admin_campaign_stats_{campaign_id}"),
+ InlineKeyboardButton(text=status_text, callback_data=f"admin_campaign_toggle_{campaign_id}")
+ ],
+ [
+ InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"admin_campaign_delete_{campaign_id}")
+ ],
+ [
+ InlineKeyboardButton(text="⬅️ К списку", callback_data="admin_campaigns_list")
+ ]
+ ])
+
+
+def get_campaign_bonus_type_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
+ texts = get_texts(language)
+
+ return InlineKeyboardMarkup(inline_keyboard=[
+ [
+ InlineKeyboardButton(text="💰 Бонус на баланс", callback_data="campaign_bonus_balance"),
+ InlineKeyboardButton(text="📱 Подписка", callback_data="campaign_bonus_subscription")
+ ],
+ [
+ InlineKeyboardButton(text=texts.BACK, callback_data="admin_campaigns")
+ ]
+ ])
+
+
def get_promocode_management_keyboard(promo_id: int, language: str = "ru") -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
diff --git a/app/localization/texts.py b/app/localization/texts.py
index 4ad43c7c..2984f370 100644
--- a/app/localization/texts.py
+++ b/app/localization/texts.py
@@ -312,8 +312,15 @@ class RussianTexts(Texts):
💪 Быстро, надежно, недорого!
"""
-
+
CREATE_INVITE = "📝 Создать приглашение"
+ CAMPAIGN_EXISTING_USER = (
+ "ℹ️ Эта рекламная ссылка доступна только новым пользователям."
+ )
+ CAMPAIGN_BONUS_BALANCE = (
+ "🎉 Вы получили {amount} за регистрацию по кампании «{name}»!"
+ )
+ CAMPAIGN_BONUS_SUBSCRIPTION = "🎉 Вам выдана подписка на {days} д. (трафик: {traffic}, устройств: {devices}) по кампании «{name}»!"
TRIAL_ENDING_SOON = """
🎁 Тестовая подписка скоро закончится!
@@ -421,6 +428,7 @@ class RussianTexts(Texts):
ADMIN_USERS = "👥 Пользователи"
ADMIN_SUBSCRIPTIONS = "📱 Подписки"
ADMIN_PROMOCODES = "🎫 Промокоды"
+ ADMIN_CAMPAIGNS = "📣 Рекламные кампании"
ADMIN_MESSAGES = "📨 Рассылки"
ADMIN_MONITORING = "🔍 Мониторинг"
ADMIN_REFERRALS = "🤝 Партнерка"
@@ -535,6 +543,7 @@ To get started, select interface language:
CONTINUE = "➡️ Continue"
YES = "✅ Yes"
NO = "❌ No"
+ ADMIN_CAMPAIGNS = "📣 Campaigns"
MENU_BALANCE = "💰 Balance"
MENU_SUBSCRIPTION = "📱 Subscription"
@@ -545,6 +554,9 @@ To get started, select interface language:
GO_TO_BALANCE_TOP_UP = "💳 Go to balance top up"
RETURN_TO_SUBSCRIPTION_CHECKOUT = "↩️ Back to checkout"
NO_SAVED_SUBSCRIPTION_ORDER = "❌ Saved subscription order not found. Please configure it again."
+ CAMPAIGN_EXISTING_USER = "ℹ️ This campaign link is available for new users only."
+ CAMPAIGN_BONUS_BALANCE = "🎉 You received {amount} for joining via campaign “{name}”!"
+ CAMPAIGN_BONUS_SUBSCRIPTION = "🎉 You received a {days}-day subscription (traffic: {traffic}, devices: {devices}) from campaign “{name}”!"
LANGUAGES = {
diff --git a/app/services/campaign_service.py b/app/services/campaign_service.py
new file mode 100644
index 00000000..8fecc497
--- /dev/null
+++ b/app/services/campaign_service.py
@@ -0,0 +1,171 @@
+import logging
+from dataclasses import dataclass
+from typing import List, Optional
+
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.config import settings
+from app.database.crud.campaign import record_campaign_registration
+from app.database.crud.subscription import (
+ create_paid_subscription,
+ get_subscription_by_user_id,
+)
+from app.database.crud.user import add_user_balance
+from app.database.models import AdvertisingCampaign, User
+from app.services.subscription_service import SubscriptionService
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class CampaignBonusResult:
+ success: bool
+ bonus_type: Optional[str] = None
+ balance_kopeks: int = 0
+ subscription_days: Optional[int] = None
+ subscription_traffic_gb: Optional[int] = None
+ subscription_device_limit: Optional[int] = None
+ subscription_squads: Optional[List[str]] = None
+
+
+class AdvertisingCampaignService:
+ def __init__(self) -> None:
+ self.subscription_service = SubscriptionService()
+
+ async def apply_campaign_bonus(
+ self,
+ db: AsyncSession,
+ user: User,
+ campaign: AdvertisingCampaign,
+ ) -> CampaignBonusResult:
+ if not campaign.is_active:
+ logger.warning(
+ "⚠️ Попытка выдать бонус по неактивной кампании %s", campaign.id
+ )
+ return CampaignBonusResult(success=False)
+
+ if campaign.is_balance_bonus:
+ return await self._apply_balance_bonus(db, user, campaign)
+
+ if campaign.is_subscription_bonus:
+ return await self._apply_subscription_bonus(db, user, campaign)
+
+ logger.error("❌ Неизвестный тип бонуса кампании: %s", campaign.bonus_type)
+ return CampaignBonusResult(success=False)
+
+ async def _apply_balance_bonus(
+ self,
+ db: AsyncSession,
+ user: User,
+ campaign: AdvertisingCampaign,
+ ) -> CampaignBonusResult:
+ amount = campaign.balance_bonus_kopeks or 0
+ if amount <= 0:
+ logger.info("ℹ️ Кампания %s не имеет бонуса на баланс", campaign.id)
+ return CampaignBonusResult(success=False)
+
+ description = f"Бонус за регистрацию по кампании '{campaign.name}'"
+ success = await add_user_balance(
+ db,
+ user,
+ amount,
+ description=description,
+ )
+
+ if not success:
+ return CampaignBonusResult(success=False)
+
+ await record_campaign_registration(
+ db,
+ campaign_id=campaign.id,
+ user_id=user.id,
+ bonus_type="balance",
+ balance_bonus_kopeks=amount,
+ )
+
+ logger.info(
+ "💰 Пользователю %s начислен бонус %s₽ по кампании %s",
+ user.telegram_id,
+ amount / 100,
+ campaign.id,
+ )
+
+ return CampaignBonusResult(
+ success=True,
+ bonus_type="balance",
+ balance_kopeks=amount,
+ )
+
+ async def _apply_subscription_bonus(
+ self,
+ db: AsyncSession,
+ user: User,
+ campaign: AdvertisingCampaign,
+ ) -> CampaignBonusResult:
+ existing_subscription = await get_subscription_by_user_id(db, user.id)
+ if existing_subscription:
+ logger.warning(
+ "⚠️ У пользователя %s уже есть подписка, бонус кампании %s пропущен",
+ user.telegram_id,
+ campaign.id,
+ )
+ return CampaignBonusResult(success=False)
+
+ duration_days = campaign.subscription_duration_days or 0
+ if duration_days <= 0:
+ logger.info(
+ "ℹ️ Кампания %s не содержит корректной длительности подписки",
+ campaign.id,
+ )
+ return CampaignBonusResult(success=False)
+
+ traffic_limit = campaign.subscription_traffic_gb
+ device_limit = (
+ campaign.subscription_device_limit or settings.DEFAULT_DEVICE_LIMIT
+ )
+ squads = list(campaign.subscription_squads or [])
+
+ if not squads and getattr(settings, "TRIAL_SQUAD_UUID", None):
+ squads = [settings.TRIAL_SQUAD_UUID]
+
+ new_subscription = await create_paid_subscription(
+ db=db,
+ user_id=user.id,
+ duration_days=duration_days,
+ traffic_limit_gb=traffic_limit or 0,
+ device_limit=device_limit,
+ connected_squads=squads,
+ )
+
+ try:
+ await self.subscription_service.create_remnawave_user(db, new_subscription)
+ except Exception as error:
+ logger.error(
+ "❌ Ошибка синхронизации RemnaWave для кампании %s: %s",
+ campaign.id,
+ error,
+ )
+
+ await record_campaign_registration(
+ db,
+ campaign_id=campaign.id,
+ user_id=user.id,
+ bonus_type="subscription",
+ subscription_duration_days=duration_days,
+ )
+
+ logger.info(
+ "🎁 Пользователю %s выдана подписка по кампании %s на %s дней",
+ user.telegram_id,
+ campaign.id,
+ duration_days,
+ )
+
+ return CampaignBonusResult(
+ success=True,
+ bonus_type="subscription",
+ subscription_days=duration_days,
+ subscription_traffic_gb=traffic_limit or 0,
+ subscription_device_limit=device_limit,
+ subscription_squads=squads,
+ )
diff --git a/app/states.py b/app/states.py
index b0479c08..35f461e0 100644
--- a/app/states.py
+++ b/app/states.py
@@ -41,6 +41,15 @@ class AdminStates(StatesGroup):
setting_promocode_value = State()
setting_promocode_uses = State()
setting_promocode_expiry = State()
+
+ creating_campaign_name = State()
+ creating_campaign_start = State()
+ creating_campaign_bonus = State()
+ creating_campaign_balance = State()
+ creating_campaign_subscription_days = State()
+ creating_campaign_subscription_traffic = State()
+ creating_campaign_subscription_devices = State()
+ creating_campaign_subscription_servers = State()
waiting_for_broadcast_message = State()
waiting_for_broadcast_media = State()
diff --git a/migrations/alembic/versions/5d1f1f8b2e9a_add_advertising_campaigns.py b/migrations/alembic/versions/5d1f1f8b2e9a_add_advertising_campaigns.py
new file mode 100644
index 00000000..31a943b8
--- /dev/null
+++ b/migrations/alembic/versions/5d1f1f8b2e9a_add_advertising_campaigns.py
@@ -0,0 +1,70 @@
+"""add advertising campaigns tables"""
+
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = "5d1f1f8b2e9a"
+down_revision: Union[str, None] = "cbd1be472f3d"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "advertising_campaigns",
+ sa.Column("id", sa.Integer(), primary_key=True),
+ sa.Column("name", sa.String(length=255), nullable=False),
+ sa.Column("start_parameter", sa.String(length=64), nullable=False),
+ sa.Column("bonus_type", sa.String(length=20), nullable=False),
+ sa.Column("balance_bonus_kopeks", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column("subscription_duration_days", sa.Integer(), nullable=True),
+ sa.Column("subscription_traffic_gb", sa.Integer(), nullable=True),
+ sa.Column("subscription_device_limit", sa.Integer(), nullable=True),
+ sa.Column("subscription_squads", sa.JSON(), nullable=True),
+ sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
+ sa.Column("created_by", sa.Integer(), nullable=True),
+ sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+ sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+ sa.ForeignKeyConstraint(["created_by"], ["users.id"], ondelete="SET NULL"),
+ )
+ op.create_index(
+ "ix_advertising_campaigns_start_parameter",
+ "advertising_campaigns",
+ ["start_parameter"],
+ unique=True,
+ )
+ op.create_index(
+ "ix_advertising_campaigns_id",
+ "advertising_campaigns",
+ ["id"],
+ )
+
+ op.create_table(
+ "advertising_campaign_registrations",
+ sa.Column("id", sa.Integer(), primary_key=True),
+ sa.Column("campaign_id", sa.Integer(), nullable=False),
+ sa.Column("user_id", sa.Integer(), nullable=False),
+ sa.Column("bonus_type", sa.String(length=20), nullable=False),
+ sa.Column("balance_bonus_kopeks", sa.Integer(), nullable=False, server_default="0"),
+ sa.Column("subscription_duration_days", sa.Integer(), nullable=True),
+ sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+ sa.ForeignKeyConstraint(["campaign_id"], ["advertising_campaigns.id"], ondelete="CASCADE"),
+ sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
+ sa.UniqueConstraint("campaign_id", "user_id", name="uq_campaign_user"),
+ )
+ op.create_index(
+ "ix_advertising_campaign_registrations_id",
+ "advertising_campaign_registrations",
+ ["id"],
+ )
+
+
+def downgrade() -> None:
+ op.drop_index("ix_advertising_campaign_registrations_id", table_name="advertising_campaign_registrations")
+ op.drop_table("advertising_campaign_registrations")
+ op.drop_index("ix_advertising_campaigns_id", table_name="advertising_campaigns")
+ op.drop_index("ix_advertising_campaigns_start_parameter", table_name="advertising_campaigns")
+ op.drop_table("advertising_campaigns")