diff --git a/.env.example b/.env.example index 123d468a..0fc1e422 100644 --- a/.env.example +++ b/.env.example @@ -173,6 +173,8 @@ PRICE_PER_DEVICE=10000 DEVICES_SELECTION_ENABLED=true # Единое количество устройств для режима без выбора (0 — не назначать устройства) DEVICES_SELECTION_DISABLED_AMOUNT=0 +# ===== КОНКУРСНАЯ СИСТЕМА ===== +CONTESTS_ENABLED=false # ===== РЕФЕРАЛЬНАЯ СИСТЕМА ===== REFERRAL_PROGRAM_ENABLED=true @@ -181,6 +183,7 @@ REFERRAL_FIRST_TOPUP_BONUS_KOPEKS=10000 REFERRAL_INVITER_BONUS_KOPEKS=10000 REFERRAL_COMMISSION_PERCENT=25 + # Уведомления REFERRAL_NOTIFICATIONS_ENABLED=true REFERRAL_NOTIFICATION_RETRY_ATTEMPTS=3 diff --git a/app/bot.py b/app/bot.py index e95cf2ef..daa19f1f 100644 --- a/app/bot.py +++ b/app/bot.py @@ -47,6 +47,8 @@ from app.handlers.admin import ( maintenance as admin_maintenance, promo_groups as admin_promo_groups, campaigns as admin_campaigns, + contests as admin_contests, + daily_contests as admin_daily_contests, promo_offers as admin_promo_offers, user_messages as admin_user_messages, updates as admin_updates, @@ -63,6 +65,7 @@ from app.handlers.admin import ( payments as admin_payments, trials as admin_trials, ) +from app.handlers import contests as user_contests from app.handlers.stars_payments import register_stars_handlers from app.utils.message_patch import patch_message_methods @@ -162,6 +165,8 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_polls.register_handlers(dp) admin_promo_groups.register_handlers(dp) admin_campaigns.register_handlers(dp) + admin_contests.register_handlers(dp) + admin_daily_contests.register_handlers(dp) admin_promo_offers.register_handlers(dp) admin_maintenance.register_handlers(dp) admin_user_messages.register_handlers(dp) @@ -182,6 +187,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]: admin_blacklist.register_blacklist_handlers(dp) common.register_handlers(dp) register_stars_handlers(dp) + user_contests.register_handlers(dp) user_polls.register_handlers(dp) simple_subscription.register_simple_subscription_handlers(dp) logger.info("⭐ Зарегистрированы обработчики Telegram Stars платежей") diff --git a/app/config.py b/app/config.py index d33fd847..9a3ee4e9 100644 --- a/app/config.py +++ b/app/config.py @@ -156,6 +156,11 @@ class Settings(BaseSettings): REFERRAL_NOTIFICATIONS_ENABLED: bool = True REFERRAL_NOTIFICATION_RETRY_ATTEMPTS: int = 3 + # Конкурсы (глобальный флаг, будет расширяться под разные типы) + CONTESTS_ENABLED: bool = False + # Для обратной совместимости со старыми конфигами + REFERRAL_CONTESTS_ENABLED: bool = False + BLACKLIST_CHECK_ENABLED: bool = False BLACKLIST_GITHUB_URL: Optional[str] = None BLACKLIST_UPDATE_INTERVAL_HOURS: int = 24 @@ -1190,6 +1195,16 @@ class Settings(BaseSettings): return False return self.HIDE_SUBSCRIPTION_LINK + def is_contests_enabled(self) -> bool: + if getattr(self, "CONTESTS_ENABLED", False): + return True + # legacy fallback + return bool(getattr(self, "REFERRAL_CONTESTS_ENABLED", False)) + + def is_referral_contests_enabled(self) -> bool: + # kept for backward compatibility + return self.is_contests_enabled() + def get_happ_cryptolink_redirect_template(self) -> Optional[str]: template = (self.HAPP_CRYPTOLINK_REDIRECT_TEMPLATE or "").strip() return template or None diff --git a/app/database/crud/contest.py b/app/database/crud/contest.py new file mode 100644 index 00000000..67c197bb --- /dev/null +++ b/app/database/crud/contest.py @@ -0,0 +1,208 @@ +import logging +from datetime import datetime +from typing import List, Optional, Sequence, Tuple + +from sqlalchemy import and_, desc, func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import ContestTemplate, ContestRound, ContestAttempt, User + +logger = logging.getLogger(__name__) + + +# Templates +async def get_template_by_id(db: AsyncSession, template_id: int) -> Optional[ContestTemplate]: + result = await db.execute( + select(ContestTemplate).where(ContestTemplate.id == template_id) + ) + return result.scalar_one_or_none() + + +async def get_template_by_slug(db: AsyncSession, slug: str) -> Optional[ContestTemplate]: + result = await db.execute( + select(ContestTemplate).where(ContestTemplate.slug == slug) + ) + return result.scalar_one_or_none() + + +async def list_templates(db: AsyncSession, enabled_only: bool = True) -> List[ContestTemplate]: + query = select(ContestTemplate).order_by(ContestTemplate.id) + if enabled_only: + query = query.where(ContestTemplate.is_enabled.is_(True)) + result = await db.execute(query) + return list(result.scalars().all()) + + +async def upsert_template( + db: AsyncSession, + *, + slug: str, + name: str, + description: str = "", + prize_days: int = 1, + max_winners: int = 1, + attempts_per_user: int = 1, + times_per_day: int = 1, + schedule_times: Optional[str] = None, + cooldown_hours: int = 24, + payload: Optional[dict] = None, +) -> ContestTemplate: + template = await get_template_by_slug(db, slug) + if not template: + template = ContestTemplate(slug=slug) + db.add(template) + + template.name = name + template.description = description + template.prize_days = prize_days + template.max_winners = max_winners + template.attempts_per_user = attempts_per_user + template.times_per_day = times_per_day + template.schedule_times = schedule_times + template.cooldown_hours = cooldown_hours + template.payload = payload or {} + template.is_enabled = True + await db.commit() + await db.refresh(template) + return template + + +async def update_template_fields( + db: AsyncSession, + template: ContestTemplate, + **fields: object, +) -> ContestTemplate: + for key, value in fields.items(): + if hasattr(template, key): + setattr(template, key, value) + await db.commit() + await db.refresh(template) + return template + + +# Rounds +async def create_round( + db: AsyncSession, + *, + template: ContestTemplate, + starts_at: datetime, + ends_at: datetime, + payload: dict, +) -> ContestRound: + round_obj = ContestRound( + template_id=template.id, + starts_at=starts_at, + ends_at=ends_at, + status="active", + payload=payload, + max_winners=template.max_winners, + attempts_per_user=template.attempts_per_user, + ) + db.add(round_obj) + await db.commit() + await db.refresh(round_obj) + return round_obj + + +async def get_active_rounds(db: AsyncSession) -> List[ContestRound]: + now = datetime.utcnow() + result = await db.execute( + select(ContestRound) + .options(selectinload(ContestRound.template)) + .where( + and_( + ContestRound.status == "active", + ContestRound.starts_at <= now, + ContestRound.ends_at >= now, + ) + ) + .order_by(ContestRound.starts_at) + ) + return list(result.scalars().all()) + + +async def get_active_round_by_template(db: AsyncSession, template_id: int) -> Optional[ContestRound]: + now = datetime.utcnow() + result = await db.execute( + select(ContestRound) + .options(selectinload(ContestRound.template)) + .where( + and_( + ContestRound.template_id == template_id, + ContestRound.status == "active", + ContestRound.starts_at <= now, + ContestRound.ends_at >= now, + ) + ) + .order_by(desc(ContestRound.starts_at)) + ) + return result.scalar_one_or_none() + + +async def finish_round(db: AsyncSession, round_obj: ContestRound) -> ContestRound: + round_obj.status = "finished" + await db.commit() + await db.refresh(round_obj) + return round_obj + + +async def increment_winner_count(db: AsyncSession, round_obj: ContestRound) -> ContestRound: + round_obj.winners_count += 1 + await db.commit() + await db.refresh(round_obj) + return round_obj + + +# Attempts +async def get_attempt(db: AsyncSession, round_id: int, user_id: int) -> Optional[ContestAttempt]: + result = await db.execute( + select(ContestAttempt).where( + and_( + ContestAttempt.round_id == round_id, + ContestAttempt.user_id == user_id, + ) + ) + ) + return result.scalar_one_or_none() + + +async def create_attempt( + db: AsyncSession, + *, + round_id: int, + user_id: int, + answer: Optional[str], + is_winner: bool, +) -> ContestAttempt: + attempt = ContestAttempt( + round_id=round_id, + user_id=user_id, + answer=answer, + is_winner=is_winner, + ) + db.add(attempt) + await db.commit() + await db.refresh(attempt) + return attempt + + +async def count_attempts(db: AsyncSession, round_id: int) -> int: + result = await db.execute( + select(func.count(ContestAttempt.id)).where(ContestAttempt.round_id == round_id) + ) + return int(result.scalar_one()) + + +async def list_winners(db: AsyncSession, round_id: int) -> Sequence[Tuple[User, ContestAttempt]]: + result = await db.execute( + select(User, ContestAttempt) + .join(ContestAttempt, ContestAttempt.user_id == User.id) + .where( + and_( + ContestAttempt.round_id == round_id, + ContestAttempt.is_winner.is_(True), + ) + ) + ) + return result.all() diff --git a/app/database/crud/referral_contest.py b/app/database/crud/referral_contest.py new file mode 100644 index 00000000..9155ea03 --- /dev/null +++ b/app/database/crud/referral_contest.py @@ -0,0 +1,268 @@ +import logging +from datetime import datetime, date, time, timezone +from typing import List, Optional, Sequence, Tuple + +from sqlalchemy import and_, desc, func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.database.models import ( + ReferralContest, + ReferralContestEvent, + User, +) + +logger = logging.getLogger(__name__) + + +async def create_referral_contest( + db: AsyncSession, + *, + title: str, + description: Optional[str], + prize_text: Optional[str], + contest_type: str, + start_at: datetime, + end_at: datetime, + daily_summary_time: time, + timezone_name: str, + created_by: Optional[int] = None, +) -> ReferralContest: + contest = ReferralContest( + title=title, + description=description, + prize_text=prize_text, + contest_type=contest_type, + start_at=start_at, + end_at=end_at, + daily_summary_time=daily_summary_time, + timezone=timezone_name or "UTC", + created_by=created_by, + ) + db.add(contest) + await db.commit() + await db.refresh(contest) + return contest + + +async def list_referral_contests( + db: AsyncSession, + *, + limit: int = 10, + offset: int = 0, + contest_type: Optional[str] = None, +) -> List[ReferralContest]: + query = ( + select(ReferralContest) + .options(selectinload(ReferralContest.creator)) + .order_by(desc(ReferralContest.start_at)) + .offset(offset) + .limit(limit) + ) + if contest_type: + query = query.where(ReferralContest.contest_type == contest_type) + + result = await db.execute(query) + return list(result.scalars().all()) + + +async def get_referral_contests_count(db: AsyncSession, contest_type: Optional[str] = None) -> int: + query = select(func.count(ReferralContest.id)) + if contest_type: + query = query.where(ReferralContest.contest_type == contest_type) + result = await db.execute(query) + return int(result.scalar_one()) + + +async def get_referral_contest(db: AsyncSession, contest_id: int) -> Optional[ReferralContest]: + result = await db.execute( + select(ReferralContest) + .options( + selectinload(ReferralContest.creator), + selectinload(ReferralContest.events), + ) + .where(ReferralContest.id == contest_id) + ) + return result.scalar_one_or_none() + + +async def update_referral_contest( + db: AsyncSession, + contest: ReferralContest, + **fields: object, +) -> ReferralContest: + for key, value in fields.items(): + if hasattr(contest, key): + setattr(contest, key, value) + await db.commit() + await db.refresh(contest) + return contest + + +async def toggle_referral_contest( + db: AsyncSession, + contest: ReferralContest, + is_active: bool, +) -> ReferralContest: + contest.is_active = is_active + return await update_referral_contest(db, contest) + + +async def get_contests_for_events( + db: AsyncSession, + now_utc: datetime, + *, + contest_types: Optional[List[str]] = None, +) -> List[ReferralContest]: + query = select(ReferralContest).where( + and_( + ReferralContest.is_active.is_(True), + ReferralContest.start_at <= now_utc, + ReferralContest.end_at >= now_utc, + ) + ) + if contest_types: + query = query.where(ReferralContest.contest_type.in_(contest_types)) + + result = await db.execute(query) + return list(result.scalars().all()) + + +async def get_contests_for_summaries(db: AsyncSession) -> List[ReferralContest]: + result = await db.execute( + select(ReferralContest).where(ReferralContest.is_active.is_(True)) + ) + return list(result.scalars().all()) + + +async def add_contest_event( + db: AsyncSession, + *, + contest_id: int, + referrer_id: int, + referral_id: int, + amount_kopeks: int = 0, + event_type: str = "subscription_purchase", +) -> Optional[ReferralContestEvent]: + existing = await db.execute( + select(ReferralContestEvent).where( + and_( + ReferralContestEvent.contest_id == contest_id, + ReferralContestEvent.referral_id == referral_id, + ) + ) + ) + if existing.scalar_one_or_none(): + return None + + event = ReferralContestEvent( + contest_id=contest_id, + referrer_id=referrer_id, + referral_id=referral_id, + amount_kopeks=amount_kopeks, + event_type=event_type, + occurred_at=datetime.utcnow(), + ) + db.add(event) + await db.commit() + await db.refresh(event) + return event + + +async def get_contest_leaderboard( + db: AsyncSession, + contest_id: int, + *, + limit: Optional[int] = None, +) -> Sequence[Tuple[User, int, int]]: + query = ( + select( + User, + func.count(ReferralContestEvent.id).label("referral_count"), + func.coalesce(func.sum(ReferralContestEvent.amount_kopeks), 0).label("total_amount"), + ) + .join(User, User.id == ReferralContestEvent.referrer_id) + .where(ReferralContestEvent.contest_id == contest_id) + .group_by(User.id) + .order_by(desc("referral_count"), desc("total_amount"), User.id) + ) + if limit: + query = query.limit(limit) + result = await db.execute(query) + return result.all() + + +async def get_contest_participants( + db: AsyncSession, + contest_id: int, +) -> Sequence[Tuple[User, int]]: + result = await db.execute( + select(User, func.count(ReferralContestEvent.id).label("referral_count")) + .join(User, User.id == ReferralContestEvent.referrer_id) + .where(ReferralContestEvent.contest_id == contest_id) + .group_by(User.id) + ) + return result.all() + + +async def get_referrer_score( + db: AsyncSession, + contest_id: int, + referrer_id: int, + *, + start: Optional[datetime] = None, + end: Optional[datetime] = None, +) -> int: + query = select(func.count(ReferralContestEvent.id)).where( + and_( + ReferralContestEvent.contest_id == contest_id, + ReferralContestEvent.referrer_id == referrer_id, + ) + ) + if start: + query = query.where(ReferralContestEvent.occurred_at >= start) + if end: + query = query.where(ReferralContestEvent.occurred_at < end) + + result = await db.execute(query) + return int(result.scalar_one()) + + +async def get_contest_events_count( + db: AsyncSession, + contest_id: int, + *, + start: Optional[datetime] = None, + end: Optional[datetime] = None, +) -> int: + query = select(func.count(ReferralContestEvent.id)).where( + ReferralContestEvent.contest_id == contest_id + ) + if start: + query = query.where(ReferralContestEvent.occurred_at >= start) + if end: + query = query.where(ReferralContestEvent.occurred_at < end) + result = await db.execute(query) + return int(result.scalar_one()) + + +async def mark_daily_summary_sent( + db: AsyncSession, + contest: ReferralContest, + summary_date: date, +) -> ReferralContest: + contest.last_daily_summary_date = summary_date + await db.commit() + await db.refresh(contest) + return contest + + +async def mark_final_summary_sent( + db: AsyncSession, + contest: ReferralContest, +) -> ReferralContest: + contest.final_summary_sent = True + contest.is_active = False + await db.commit() + await db.refresh(contest) + return contest diff --git a/app/database/crud/transaction.py b/app/database/crud/transaction.py index bf1ecde0..1b419ab2 100644 --- a/app/database/crud/transaction.py +++ b/app/database/crud/transaction.py @@ -50,6 +50,21 @@ async def create_transaction( user_id, exc, ) + if type == TransactionType.SUBSCRIPTION_PAYMENT: + try: + from app.services.referral_contest_service import referral_contest_service + + await referral_contest_service.on_subscription_payment( + db, + user_id, + amount_kopeks, + ) + except Exception as exc: + logger.debug( + "Не удалось записать событие конкурса для пользователя %s: %s", + user_id, + exc, + ) return transaction diff --git a/app/database/models.py b/app/database/models.py index f8e9f719..41887159 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -1,4 +1,4 @@ -from datetime import datetime, timedelta +from datetime import datetime, timedelta, time, date from typing import Optional, List, Dict, Any from enum import Enum @@ -7,6 +7,8 @@ from sqlalchemy import ( Integer, String, DateTime, + Date, + Time, Boolean, Text, ForeignKey, @@ -16,6 +18,7 @@ from sqlalchemy import ( UniqueConstraint, Index, Table, + SmallInteger, ) from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship, Mapped, mapped_column @@ -957,6 +960,130 @@ class ReferralEarning(Base): return self.amount_kopeks / 100 +class ReferralContest(Base): + __tablename__ = "referral_contests" + + id = Column(Integer, primary_key=True, index=True) + title = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + prize_text = Column(Text, nullable=True) + contest_type = Column(String(50), nullable=False, default="referral_paid") + start_at = Column(DateTime, nullable=False) + end_at = Column(DateTime, nullable=False) + daily_summary_time = Column(Time, nullable=False, default=time(hour=12, minute=0)) + timezone = Column(String(64), nullable=False, default="UTC") + is_active = Column(Boolean, nullable=False, default=True) + last_daily_summary_date = Column(Date, nullable=True) + final_summary_sent = Column(Boolean, nullable=False, default=False) + created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + creator = relationship("User", backref="created_referral_contests") + events = relationship( + "ReferralContestEvent", + back_populates="contest", + cascade="all, delete-orphan", + ) + + def __repr__(self): + return f"" + + +class ReferralContestEvent(Base): + __tablename__ = "referral_contest_events" + __table_args__ = ( + UniqueConstraint( + "contest_id", + "referral_id", + name="uq_referral_contest_referral", + ), + Index("idx_referral_contest_referrer", "contest_id", "referrer_id"), + ) + + id = Column(Integer, primary_key=True, index=True) + contest_id = Column(Integer, ForeignKey("referral_contests.id", ondelete="CASCADE"), nullable=False) + referrer_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + referral_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + event_type = Column(String(50), nullable=False) + amount_kopeks = Column(Integer, nullable=False, default=0) + occurred_at = Column(DateTime, nullable=False, default=func.now()) + + contest = relationship("ReferralContest", back_populates="events") + referrer = relationship("User", foreign_keys=[referrer_id]) + referral = relationship("User", foreign_keys=[referral_id]) + + def __repr__(self): + return ( + f"" + ) + + +class ContestTemplate(Base): + __tablename__ = "contest_templates" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False) + slug = Column(String(50), nullable=False, unique=True, index=True) + description = Column(Text, nullable=True) + prize_days = Column(Integer, nullable=False, default=1) + max_winners = Column(Integer, nullable=False, default=1) + attempts_per_user = Column(Integer, nullable=False, default=1) + times_per_day = Column(Integer, nullable=False, default=1) + schedule_times = Column(String(255), nullable=True) # CSV of HH:MM in local TZ + cooldown_hours = Column(Integer, nullable=False, default=24) + payload = Column(JSON, nullable=True) + is_enabled = Column(Boolean, nullable=False, default=True) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + rounds = relationship("ContestRound", back_populates="template") + + +class ContestRound(Base): + __tablename__ = "contest_rounds" + __table_args__ = ( + Index("idx_contest_round_status", "status"), + Index("idx_contest_round_template", "template_id"), + ) + + id = Column(Integer, primary_key=True, index=True) + template_id = Column(Integer, ForeignKey("contest_templates.id", ondelete="CASCADE"), nullable=False) + starts_at = Column(DateTime, nullable=False) + ends_at = Column(DateTime, nullable=False) + status = Column(String(20), nullable=False, default="active") # active, finished + payload = Column(JSON, nullable=True) + winners_count = Column(Integer, nullable=False, default=0) + max_winners = Column(Integer, nullable=False, default=1) + attempts_per_user = Column(Integer, nullable=False, default=1) + message_id = Column(BigInteger, nullable=True) + chat_id = Column(BigInteger, nullable=True) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + template = relationship("ContestTemplate", back_populates="rounds") + attempts = relationship("ContestAttempt", back_populates="round", cascade="all, delete-orphan") + + +class ContestAttempt(Base): + __tablename__ = "contest_attempts" + __table_args__ = ( + UniqueConstraint("round_id", "user_id", name="uq_round_user_attempt"), + Index("idx_contest_attempt_round", "round_id"), + ) + + id = Column(Integer, primary_key=True, index=True) + round_id = Column(Integer, ForeignKey("contest_rounds.id", ondelete="CASCADE"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + answer = Column(Text, nullable=True) + is_winner = Column(Boolean, nullable=False, default=False) + created_at = Column(DateTime, default=func.now()) + + round = relationship("ContestRound", back_populates="attempts") + user = relationship("User") + + class Squad(Base): __tablename__ = "squads" diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 6bf30aff..058e3eab 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -1383,6 +1383,427 @@ async def create_discount_offers_table(): return False +async def create_referral_contests_table() -> bool: + table_exists = await check_table_exists("referral_contests") + if table_exists: + logger.info("Таблица referral_contests уже существует") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == "sqlite": + await conn.execute(text(""" + CREATE TABLE referral_contests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title VARCHAR(255) NOT NULL, + description TEXT NULL, + prize_text TEXT NULL, + contest_type VARCHAR(50) NOT NULL DEFAULT 'referral_paid', + start_at DATETIME NOT NULL, + end_at DATETIME NOT NULL, + daily_summary_time TIME NOT NULL DEFAULT '12:00:00', + timezone VARCHAR(64) NOT NULL DEFAULT 'UTC', + is_active BOOLEAN NOT NULL DEFAULT 1, + last_daily_summary_date DATE NULL, + final_summary_sent BOOLEAN NOT NULL DEFAULT 0, + created_by INTEGER NULL REFERENCES users(id) ON DELETE SET NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """)) + elif db_type == "postgresql": + await conn.execute(text(""" + CREATE TABLE referral_contests ( + id SERIAL PRIMARY KEY, + title VARCHAR(255) NOT NULL, + description TEXT NULL, + prize_text TEXT NULL, + contest_type VARCHAR(50) NOT NULL DEFAULT 'referral_paid', + start_at TIMESTAMP NOT NULL, + end_at TIMESTAMP NOT NULL, + daily_summary_time TIME NOT NULL DEFAULT '12:00:00', + timezone VARCHAR(64) NOT NULL DEFAULT 'UTC', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + last_daily_summary_date DATE NULL, + final_summary_sent BOOLEAN NOT NULL DEFAULT FALSE, + created_by INTEGER NULL REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """)) + elif db_type == "mysql": + await conn.execute(text(""" + CREATE TABLE referral_contests ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + title VARCHAR(255) NOT NULL, + description TEXT NULL, + prize_text TEXT NULL, + contest_type VARCHAR(50) NOT NULL DEFAULT 'referral_paid', + start_at DATETIME NOT NULL, + end_at DATETIME NOT NULL, + daily_summary_time TIME NOT NULL DEFAULT '12:00:00', + timezone VARCHAR(64) NOT NULL DEFAULT 'UTC', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + last_daily_summary_date DATE NULL, + final_summary_sent BOOLEAN NOT NULL DEFAULT FALSE, + created_by INTEGER NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_referral_contest_creator FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE SET NULL + ) + """)) + else: + raise ValueError(f"Unsupported database type: {db_type}") + + logger.info("✅ Таблица referral_contests создана") + return True + except Exception as error: + logger.error(f"Ошибка создания таблицы referral_contests: {error}") + return False + + +async def create_referral_contest_events_table() -> bool: + table_exists = await check_table_exists("referral_contest_events") + if table_exists: + logger.info("Таблица referral_contest_events уже существует") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == "sqlite": + await conn.execute(text(""" + CREATE TABLE referral_contest_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + contest_id INTEGER NOT NULL, + referrer_id INTEGER NOT NULL, + referral_id INTEGER NOT NULL, + event_type VARCHAR(50) NOT NULL, + amount_kopeks INTEGER NOT NULL DEFAULT 0, + occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(contest_id) REFERENCES referral_contests(id) ON DELETE CASCADE, + FOREIGN KEY(referrer_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(referral_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(contest_id, referral_id) + ) + """)) + await conn.execute(text(""" + CREATE INDEX IF NOT EXISTS idx_referral_contest_referrer + ON referral_contest_events (contest_id, referrer_id) + """)) + elif db_type == "postgresql": + await conn.execute(text(""" + CREATE TABLE referral_contest_events ( + id SERIAL PRIMARY KEY, + contest_id INTEGER NOT NULL REFERENCES referral_contests(id) ON DELETE CASCADE, + referrer_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + referral_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + event_type VARCHAR(50) NOT NULL, + amount_kopeks INTEGER NOT NULL DEFAULT 0, + occurred_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_referral_contest_referral UNIQUE (contest_id, referral_id) + ) + """)) + await conn.execute(text(""" + CREATE INDEX IF NOT EXISTS idx_referral_contest_referrer + ON referral_contest_events (contest_id, referrer_id) + """)) + elif db_type == "mysql": + await conn.execute(text(""" + CREATE TABLE referral_contest_events ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + contest_id INTEGER NOT NULL, + referrer_id INTEGER NOT NULL, + referral_id INTEGER NOT NULL, + event_type VARCHAR(50) NOT NULL, + amount_kopeks INTEGER NOT NULL DEFAULT 0, + occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_referral_contest FOREIGN KEY(contest_id) REFERENCES referral_contests(id) ON DELETE CASCADE, + CONSTRAINT fk_referral_contest_referrer FOREIGN KEY(referrer_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_referral_contest_referral FOREIGN KEY(referral_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT uq_referral_contest_referral UNIQUE (contest_id, referral_id) + ) + """)) + await conn.execute(text(""" + CREATE INDEX idx_referral_contest_referrer + ON referral_contest_events (contest_id, referrer_id) + """)) + else: + raise ValueError(f"Unsupported database type: {db_type}") + + logger.info("✅ Таблица referral_contest_events создана") + return True + except Exception as error: + logger.error(f"Ошибка создания таблицы referral_contest_events: {error}") + return False + + +async def create_contest_templates_table() -> bool: + table_exists = await check_table_exists("contest_templates") + if table_exists: + logger.info("Таблица contest_templates уже существует") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == "sqlite": + await conn.execute(text(""" + CREATE TABLE contest_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(100) NOT NULL, + slug VARCHAR(50) NOT NULL UNIQUE, + description TEXT NULL, + prize_days INTEGER NOT NULL DEFAULT 1, + max_winners INTEGER NOT NULL DEFAULT 1, + attempts_per_user INTEGER NOT NULL DEFAULT 1, + times_per_day INTEGER NOT NULL DEFAULT 1, + schedule_times VARCHAR(255) NULL, + cooldown_hours INTEGER NOT NULL DEFAULT 24, + payload TEXT NULL, + is_enabled BOOLEAN NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """)) + elif db_type == "postgresql": + await conn.execute(text(""" + CREATE TABLE contest_templates ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + slug VARCHAR(50) NOT NULL UNIQUE, + description TEXT NULL, + prize_days INTEGER NOT NULL DEFAULT 1, + max_winners INTEGER NOT NULL DEFAULT 1, + attempts_per_user INTEGER NOT NULL DEFAULT 1, + times_per_day INTEGER NOT NULL DEFAULT 1, + schedule_times VARCHAR(255) NULL, + cooldown_hours INTEGER NOT NULL DEFAULT 24, + payload JSON NULL, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """)) + elif db_type == "mysql": + await conn.execute(text(""" + CREATE TABLE contest_templates ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(100) NOT NULL, + slug VARCHAR(50) NOT NULL UNIQUE, + description TEXT NULL, + prize_days INTEGER NOT NULL DEFAULT 1, + max_winners INTEGER NOT NULL DEFAULT 1, + attempts_per_user INTEGER NOT NULL DEFAULT 1, + times_per_day INTEGER NOT NULL DEFAULT 1, + schedule_times VARCHAR(255) NULL, + cooldown_hours INTEGER NOT NULL DEFAULT 24, + payload JSON NULL, + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) + """)) + else: + raise ValueError(f"Unsupported database type: {db_type}") + + logger.info("✅ Таблица contest_templates создана") + return True + except Exception as error: + logger.error(f"Ошибка создания таблицы contest_templates: {error}") + return False + + +async def create_contest_rounds_table() -> bool: + table_exists = await check_table_exists("contest_rounds") + if table_exists: + logger.info("Таблица contest_rounds уже существует") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == "sqlite": + await conn.execute(text(""" + CREATE TABLE contest_rounds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + template_id INTEGER NOT NULL, + starts_at DATETIME NOT NULL, + ends_at DATETIME NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'active', + payload TEXT NULL, + winners_count INTEGER NOT NULL DEFAULT 0, + max_winners INTEGER NOT NULL DEFAULT 1, + attempts_per_user INTEGER NOT NULL DEFAULT 1, + message_id BIGINT NULL, + chat_id BIGINT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(template_id) REFERENCES contest_templates(id) ON DELETE CASCADE + ) + """)) + await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_contest_round_status ON contest_rounds(status)")) + await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_contest_round_template ON contest_rounds(template_id)")) + elif db_type == "postgresql": + await conn.execute(text(""" + CREATE TABLE contest_rounds ( + id SERIAL PRIMARY KEY, + template_id INTEGER NOT NULL REFERENCES contest_templates(id) ON DELETE CASCADE, + starts_at TIMESTAMP NOT NULL, + ends_at TIMESTAMP NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'active', + payload JSON NULL, + winners_count INTEGER NOT NULL DEFAULT 0, + max_winners INTEGER NOT NULL DEFAULT 1, + attempts_per_user INTEGER NOT NULL DEFAULT 1, + message_id BIGINT NULL, + chat_id BIGINT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """)) + await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_contest_round_status ON contest_rounds(status)")) + await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_contest_round_template ON contest_rounds(template_id)")) + elif db_type == "mysql": + await conn.execute(text(""" + CREATE TABLE contest_rounds ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + template_id INTEGER NOT NULL, + starts_at DATETIME NOT NULL, + ends_at DATETIME NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'active', + payload JSON NULL, + winners_count INTEGER NOT NULL DEFAULT 0, + max_winners INTEGER NOT NULL DEFAULT 1, + attempts_per_user INTEGER NOT NULL DEFAULT 1, + message_id BIGINT NULL, + chat_id BIGINT NULL, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_contest_round_template FOREIGN KEY(template_id) REFERENCES contest_templates(id) ON DELETE CASCADE + ) + """)) + await conn.execute(text("CREATE INDEX idx_contest_round_status ON contest_rounds(status)")) + await conn.execute(text("CREATE INDEX idx_contest_round_template ON contest_rounds(template_id)")) + else: + raise ValueError(f"Unsupported database type: {db_type}") + + logger.info("✅ Таблица contest_rounds создана") + return True + except Exception as error: + logger.error(f"Ошибка создания таблицы contest_rounds: {error}") + return False + + +async def create_contest_attempts_table() -> bool: + table_exists = await check_table_exists("contest_attempts") + if table_exists: + logger.info("Таблица contest_attempts уже существует") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == "sqlite": + await conn.execute(text(""" + CREATE TABLE contest_attempts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + round_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + answer TEXT NULL, + is_winner BOOLEAN NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(round_id) REFERENCES contest_rounds(id) ON DELETE CASCADE, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + UNIQUE(round_id, user_id) + ) + """)) + await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_contest_attempt_round ON contest_attempts(round_id)")) + elif db_type == "postgresql": + await conn.execute(text(""" + CREATE TABLE contest_attempts ( + id SERIAL PRIMARY KEY, + round_id INTEGER NOT NULL REFERENCES contest_rounds(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + answer TEXT NULL, + is_winner BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT uq_round_user_attempt UNIQUE(round_id, user_id) + ) + """)) + await conn.execute(text("CREATE INDEX IF NOT EXISTS idx_contest_attempt_round ON contest_attempts(round_id)")) + elif db_type == "mysql": + await conn.execute(text(""" + CREATE TABLE contest_attempts ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + round_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + answer TEXT NULL, + is_winner BOOLEAN NOT NULL DEFAULT FALSE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_contest_attempt_round FOREIGN KEY(round_id) REFERENCES contest_rounds(id) ON DELETE CASCADE, + CONSTRAINT fk_contest_attempt_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT uq_round_user_attempt UNIQUE(round_id, user_id) + ) + """)) + await conn.execute(text("CREATE INDEX idx_contest_attempt_round ON contest_attempts(round_id)")) + else: + raise ValueError(f"Unsupported database type: {db_type}") + + logger.info("✅ Таблица contest_attempts создана") + return True + except Exception as error: + logger.error(f"Ошибка создания таблицы contest_attempts: {error}") + return False + + +async def ensure_referral_contest_type_column() -> bool: + column_exists = await check_column_exists("referral_contests", "contest_type") + if column_exists: + logger.info("Колонка contest_type в referral_contests уже существует") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == "sqlite": + await conn.execute( + text( + "ALTER TABLE referral_contests " + "ADD COLUMN contest_type VARCHAR(50) NOT NULL DEFAULT 'referral_paid'" + ) + ) + elif db_type == "postgresql": + await conn.execute( + text( + "ALTER TABLE referral_contests " + "ADD COLUMN contest_type VARCHAR(50) NOT NULL DEFAULT 'referral_paid'" + ) + ) + elif db_type == "mysql": + await conn.execute( + text( + "ALTER TABLE referral_contests " + "ADD COLUMN contest_type VARCHAR(50) NOT NULL DEFAULT 'referral_paid'" + ) + ) + else: + raise ValueError(f"Unsupported database type: {db_type}") + + logger.info("✅ Колонка contest_type в referral_contests добавлена") + return True + except Exception as error: + logger.error(f"Ошибка добавления contest_type в referral_contests: {error}") + return False + + async def ensure_discount_offer_columns(): try: effect_exists = await check_column_exists('discount_offers', 'effect_type') @@ -3961,6 +4382,43 @@ async def run_universal_migration(): else: logger.warning("⚠️ Не удалось обновить колонки discount_offers") + logger.info("=== СОЗДАНИЕ ТАБЛИЦ ДЛЯ РЕФЕРАЛЬНЫХ КОНКУРСОВ ===") + contests_table_ready = await create_referral_contests_table() + if contests_table_ready: + logger.info("✅ Таблица referral_contests готова") + else: + logger.warning("⚠️ Проблемы с таблицей referral_contests") + + contest_events_ready = await create_referral_contest_events_table() + if contest_events_ready: + logger.info("✅ Таблица referral_contest_events готова") + else: + logger.warning("⚠️ Проблемы с таблицей referral_contest_events") + + contest_type_ready = await ensure_referral_contest_type_column() + if contest_type_ready: + logger.info("✅ Колонка contest_type для referral_contests готова") + else: + logger.warning("⚠️ Не удалось добавить contest_type в referral_contests") + + contest_templates_ready = await create_contest_templates_table() + if contest_templates_ready: + logger.info("✅ Таблица contest_templates готова") + else: + logger.warning("⚠️ Проблемы с таблицей contest_templates") + + contest_rounds_ready = await create_contest_rounds_table() + if contest_rounds_ready: + logger.info("✅ Таблица contest_rounds готова") + else: + logger.warning("⚠️ Проблемы с таблицей contest_rounds") + + contest_attempts_ready = await create_contest_attempts_table() + if contest_attempts_ready: + logger.info("✅ Таблица contest_attempts готова") + else: + logger.warning("⚠️ Проблемы с таблицей contest_attempts") + user_discount_columns_ready = await ensure_user_promo_offer_discount_columns() if user_discount_columns_ready: logger.info("✅ Колонки пользовательских промо-скидок готовы") @@ -4263,6 +4721,12 @@ async def check_migration_status(): "discount_offers_table": False, "discount_offers_effect_column": False, "discount_offers_extra_column": False, + "referral_contests_table": False, + "referral_contest_events_table": False, + "referral_contest_type_column": False, + "contest_templates_table": False, + "contest_rounds_table": False, + "contest_attempts_table": False, "promo_offer_templates_table": False, "promo_offer_templates_active_discount_column": False, "promo_offer_logs_table": False, @@ -4286,6 +4750,12 @@ async def check_migration_status(): status["discount_offers_table"] = await check_table_exists('discount_offers') status["discount_offers_effect_column"] = await check_column_exists('discount_offers', 'effect_type') status["discount_offers_extra_column"] = await check_column_exists('discount_offers', 'extra_data') + status["referral_contests_table"] = await check_table_exists('referral_contests') + status["referral_contest_events_table"] = await check_table_exists('referral_contest_events') + status["referral_contest_type_column"] = await check_column_exists('referral_contests', 'contest_type') + status["contest_templates_table"] = await check_table_exists('contest_templates') + status["contest_rounds_table"] = await check_table_exists('contest_rounds') + status["contest_attempts_table"] = await check_table_exists('contest_attempts') status["promo_offer_templates_table"] = await check_table_exists('promo_offer_templates') status["promo_offer_templates_active_discount_column"] = await check_column_exists('promo_offer_templates', 'active_discount_hours') status["promo_offer_logs_table"] = await check_table_exists('promo_offer_logs') @@ -4354,6 +4824,12 @@ async def check_migration_status(): "discount_offers_table": "Таблица discount_offers", "discount_offers_effect_column": "Колонка effect_type в discount_offers", "discount_offers_extra_column": "Колонка extra_data в discount_offers", + "referral_contests_table": "Таблица referral_contests", + "referral_contest_events_table": "Таблица referral_contest_events", + "referral_contest_type_column": "Колонка contest_type в referral_contests", + "contest_templates_table": "Таблица contest_templates", + "contest_rounds_table": "Таблица contest_rounds", + "contest_attempts_table": "Таблица contest_attempts", "promo_offer_templates_table": "Таблица promo_offer_templates", "promo_offer_templates_active_discount_column": "Колонка active_discount_hours в promo_offer_templates", "promo_offer_logs_table": "Таблица promo_offer_logs", diff --git a/app/handlers/admin/__init__.py b/app/handlers/admin/__init__.py index 2b5aca75..0e6e421c 100644 --- a/app/handlers/admin/__init__.py +++ b/app/handlers/admin/__init__.py @@ -5,6 +5,8 @@ from . import ( bot_configuration, bulk_ban, campaigns, + daily_contests, + contests, faq, main, maintenance, @@ -33,4 +35,4 @@ from . import ( user_messages, users, welcome_text, -) \ No newline at end of file +) diff --git a/app/handlers/admin/contests.py b/app/handlers/admin/contests.py new file mode 100644 index 00000000..8bcf2396 --- /dev/null +++ b/app/handlers/admin/contests.py @@ -0,0 +1,549 @@ +import logging +import math +from datetime import datetime, timezone +from zoneinfo import ZoneInfo + +from aiogram import Dispatcher, F, types +from aiogram.fsm.context import FSMContext +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.referral_contest import ( + create_referral_contest, + get_contest_events_count, + get_contest_leaderboard, + get_referral_contest, + get_referral_contests_count, + list_referral_contests, + toggle_referral_contest, +) +from app.keyboards.admin import ( + get_admin_contests_keyboard, + get_admin_contests_root_keyboard, + get_admin_pagination_keyboard, + get_contest_mode_keyboard, + get_referral_contest_manage_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__) + +PAGE_SIZE = 5 + + +def _ensure_timezone(tz_name: str) -> ZoneInfo: + try: + return ZoneInfo(tz_name) + except Exception: # noqa: BLE001 + logger.warning("Не удалось загрузить TZ %s, используем UTC", tz_name) + return ZoneInfo("UTC") + + +def _format_contest_summary(contest, texts, tz: ZoneInfo) -> str: + start_local = contest.start_at if contest.start_at.tzinfo else contest.start_at.replace(tzinfo=timezone.utc) + end_local = contest.end_at if contest.end_at.tzinfo else contest.end_at.replace(tzinfo=timezone.utc) + start_local = start_local.astimezone(tz) + end_local = end_local.astimezone(tz) + + status = texts.t("ADMIN_CONTEST_STATUS_ACTIVE", "🟢 Активен") if contest.is_active else texts.t( + "ADMIN_CONTEST_STATUS_INACTIVE", "⚪️ Выключен" + ) + + period = ( + f"{start_local.strftime('%d.%m %H:%M')} — " + f"{end_local.strftime('%d.%m %H:%M')} ({tz.key})" + ) + + summary_time = contest.daily_summary_time.strftime("%H:%M") if contest.daily_summary_time else "12:00" + parts = [ + f"{status}", + f"Период: {period}", + f"Дневная сводка: {summary_time}", + ] + if contest.prize_text: + parts.append(texts.t("ADMIN_CONTEST_PRIZE", "Приз: {prize}").format(prize=contest.prize_text)) + if contest.last_daily_summary_date: + parts.append( + texts.t("ADMIN_CONTEST_LAST_DAILY", "Последняя сводка: {date}").format( + date=contest.last_daily_summary_date.strftime("%d.%m") + ) + ) + return "\n".join(parts) + + +def _parse_local_datetime(value: str, tz: ZoneInfo) -> datetime | None: + try: + dt = datetime.strptime(value.strip(), "%d.%m.%Y %H:%M") + except ValueError: + return None + return dt.replace(tzinfo=tz) + + +def _parse_time(value: str): + try: + return datetime.strptime(value.strip(), "%H:%M").time() + except ValueError: + return None + + +@admin_required +@error_handler +async def show_contests_menu( + callback: types.CallbackQuery, + db_user, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + if not settings.is_contests_enabled(): + await callback.message.edit_text( + texts.t( + "ADMIN_CONTESTS_DISABLED", + "Конкурсы отключены через переменную окружения CONTESTS_ENABLED.", + ), + reply_markup=get_admin_contests_root_keyboard(db_user.language), + ) + await callback.answer() + return + + await callback.message.edit_text( + texts.t("ADMIN_CONTESTS_TITLE", "🏆 Конкурсы\n\nВыберите действие:"), + reply_markup=get_admin_contests_root_keyboard(db_user.language), + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_referral_contests_menu( + callback: types.CallbackQuery, + db_user, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + await callback.message.edit_text( + texts.t("ADMIN_CONTESTS_TITLE", "🏆 Конкурсы\n\nВыберите действие:"), + reply_markup=get_admin_contests_keyboard(db_user.language), + ) + await callback.answer() + + +@admin_required +@error_handler +async def list_contests( + callback: types.CallbackQuery, + db_user, + db: AsyncSession, +): + if not settings.is_contests_enabled(): + await callback.answer( + get_texts(db_user.language).t( + "ADMIN_CONTESTS_DISABLED", + "Конкурсы отключены через переменную окружения CONTESTS_ENABLED.", + ), + show_alert=True, + ) + return + + page = 1 + if callback.data.startswith("admin_contests_list_page_"): + try: + page = int(callback.data.split("_")[-1]) + except Exception: + page = 1 + + total = await get_referral_contests_count(db) + total_pages = max(1, math.ceil(total / PAGE_SIZE)) + page = max(1, min(page, total_pages)) + offset = (page - 1) * PAGE_SIZE + + contests = await list_referral_contests(db, limit=PAGE_SIZE, offset=offset) + texts = get_texts(db_user.language) + + lines = [texts.t("ADMIN_CONTESTS_LIST_HEADER", "🏆 Конкурсы\n")] + + if not contests: + lines.append(texts.t("ADMIN_CONTESTS_EMPTY", "Пока нет созданных конкурсов.")) + else: + for contest in contests: + lines.append(f"• {contest.title} (#{contest.id})") + contest_tz = _ensure_timezone(contest.timezone or settings.TIMEZONE) + lines.append(_format_contest_summary(contest, texts, contest_tz)) + lines.append("") + + keyboard_rows: list[list[types.InlineKeyboardButton]] = [] + for contest in contests: + title = contest.title if len(contest.title) <= 25 else contest.title[:22] + "..." + keyboard_rows.append( + [ + types.InlineKeyboardButton( + text=f"🔎 {title}", + callback_data=f"admin_contest_view_{contest.id}", + ) + ] + ) + + pagination = get_admin_pagination_keyboard( + page, + total_pages, + "admin_contests_list", + back_callback="admin_contests", + language=db_user.language, + ) + keyboard_rows.extend(pagination.inline_keyboard) + + await callback.message.edit_text( + "\n".join(lines), + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows), + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_contest_details( + callback: types.CallbackQuery, + db_user, + db: AsyncSession, +): + if not settings.is_contests_enabled(): + await callback.answer( + get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."), + show_alert=True, + ) + return + + contest_id = int(callback.data.split("_")[-1]) + contest = await get_referral_contest(db, contest_id) + texts = get_texts(db_user.language) + + if not contest: + await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True) + return + + tz = _ensure_timezone(contest.timezone or settings.TIMEZONE) + leaderboard = await get_contest_leaderboard(db, contest.id, limit=5) + total_events = await get_contest_events_count(db, contest.id) + + lines = [ + f"🏆 {contest.title}", + _format_contest_summary(contest, texts, tz), + texts.t("ADMIN_CONTEST_TOTAL_EVENTS", "Зачётов: {count}").format(count=total_events), + ] + + if contest.description: + lines.append("") + lines.append(contest.description) + + if leaderboard: + lines.append("") + lines.append(texts.t("ADMIN_CONTEST_LEADERBOARD_TITLE", "📊 Топ участников:")) + for idx, (user, score, _) in enumerate(leaderboard, start=1): + lines.append(f"{idx}. {user.full_name} — {score}") + + await callback.message.edit_text( + "\n".join(lines), + reply_markup=get_referral_contest_manage_keyboard( + contest.id, is_active=contest.is_active, language=db_user.language + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def toggle_contest( + callback: types.CallbackQuery, + db_user, + db: AsyncSession, +): + if not settings.is_contests_enabled(): + await callback.answer( + get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."), + show_alert=True, + ) + return + + contest_id = int(callback.data.split("_")[-1]) + contest = await get_referral_contest(db, contest_id) + + if not contest: + await callback.answer("Конкурс не найден", show_alert=True) + return + + await toggle_referral_contest(db, contest, not contest.is_active) + await show_contest_details(callback, db_user, db) + + +@admin_required +@error_handler +async def show_leaderboard( + callback: types.CallbackQuery, + db_user, + db: AsyncSession, +): + if not settings.is_contests_enabled(): + await callback.answer( + get_texts(db_user.language).t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."), + show_alert=True, + ) + return + + contest_id = int(callback.data.split("_")[-1]) + contest = await get_referral_contest(db, contest_id) + texts = get_texts(db_user.language) + + if not contest: + await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True) + return + + leaderboard = await get_contest_leaderboard(db, contest_id, limit=10) + if not leaderboard: + await callback.answer(texts.t("ADMIN_CONTEST_EMPTY_LEADERBOARD", "Пока нет участников."), show_alert=True) + return + + lines = [ + texts.t("ADMIN_CONTEST_LEADERBOARD_TITLE", "📊 Топ участников:"), + ] + for idx, (user, score, _) in enumerate(leaderboard, start=1): + lines.append(f"{idx}. {user.full_name} — {score}") + + await callback.message.edit_text( + "\n".join(lines), + reply_markup=get_referral_contest_manage_keyboard( + contest_id, is_active=contest.is_active, language=db_user.language + ), + ) + await callback.answer() + + +@admin_required +@error_handler +async def start_contest_creation( + callback: types.CallbackQuery, + db_user, + db: AsyncSession, + state: FSMContext, +): + texts = get_texts(db_user.language) + if not settings.is_contests_enabled(): + await callback.answer( + texts.t("ADMIN_CONTESTS_DISABLED", "Конкурсы отключены."), + show_alert=True, + ) + return + + await state.clear() + await state.set_state(AdminStates.creating_referral_contest_mode) + await callback.message.edit_text( + texts.t( + "ADMIN_CONTEST_MODE_PROMPT", + "Выберите условие зачёта: реферал должен купить подписку или достаточно регистрации.", + ), + reply_markup=get_contest_mode_keyboard(db_user.language), + ) + await callback.answer() + + +@admin_required +@error_handler +async def select_contest_mode( + callback: types.CallbackQuery, + db_user, + db: AsyncSession, + state: FSMContext, +): + texts = get_texts(db_user.language) + mode = "referral_paid" if callback.data == "admin_contest_mode_paid" else "referral_registered" + await state.update_data(contest_type=mode) + await state.set_state(AdminStates.creating_referral_contest_title) + await callback.message.edit_text( + texts.t("ADMIN_CONTEST_ENTER_TITLE", "Введите название конкурса:"), + reply_markup=None, + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_title(message: types.Message, state: FSMContext, db_user, db: AsyncSession): + title = message.text.strip() + texts = get_texts(db_user.language) + + await state.update_data(title=title) + await state.set_state(AdminStates.creating_referral_contest_description) + await message.answer( + texts.t("ADMIN_CONTEST_ENTER_DESCRIPTION", "Опишите конкурс (или отправьте '-' чтобы пропустить):") + ) + + +@admin_required +@error_handler +async def process_description(message: types.Message, state: FSMContext, db_user, db: AsyncSession): + description = message.text.strip() + if description in {"-", "skip", "пропустить"}: + description = None + + await state.update_data(description=description) + await state.set_state(AdminStates.creating_referral_contest_prize) + texts = get_texts(db_user.language) + await message.answer( + texts.t("ADMIN_CONTEST_ENTER_PRIZE", "Укажите призы/выгоды конкурса (или '-' чтобы пропустить):") + ) + + +@admin_required +@error_handler +async def process_prize(message: types.Message, state: FSMContext, db_user, db: AsyncSession): + prize = message.text.strip() + if prize in {"-", "skip", "пропустить"}: + prize = None + + await state.update_data(prize=prize) + await state.set_state(AdminStates.creating_referral_contest_start) + texts = get_texts(db_user.language) + await message.answer( + texts.t( + "ADMIN_CONTEST_ENTER_START", + "Введите дату и время старта (дд.мм.гггг чч:мм) по вашему часовому поясу:", + ) + ) + + +@admin_required +@error_handler +async def process_start_date(message: types.Message, state: FSMContext, db_user, db: AsyncSession): + tz = _ensure_timezone(settings.TIMEZONE) + start_dt = _parse_local_datetime(message.text, tz) + texts = get_texts(db_user.language) + + if not start_dt: + await message.answer( + texts.t("ADMIN_CONTEST_INVALID_DATE", "Не удалось распознать дату. Формат: 01.06.2024 12:00") + ) + return + + await state.update_data(start_at=start_dt.isoformat()) + await state.set_state(AdminStates.creating_referral_contest_end) + await message.answer( + texts.t( + "ADMIN_CONTEST_ENTER_END", + "Введите дату и время окончания (дд.мм.гггг чч:мм) по вашему часовому поясу:", + ) + ) + + +@admin_required +@error_handler +async def process_end_date(message: types.Message, state: FSMContext, db_user, db: AsyncSession): + tz = _ensure_timezone(settings.TIMEZONE) + end_dt = _parse_local_datetime(message.text, tz) + texts = get_texts(db_user.language) + + if not end_dt: + await message.answer( + texts.t("ADMIN_CONTEST_INVALID_DATE", "Не удалось распознать дату. Формат: 01.06.2024 12:00") + ) + return + + data = await state.get_data() + start_raw = data.get("start_at") + start_dt = datetime.fromisoformat(start_raw) if start_raw else None + if start_dt and end_dt <= start_dt: + await message.answer( + texts.t( + "ADMIN_CONTEST_END_BEFORE_START", + "Дата окончания должна быть позже даты начала.", + ) + ) + return + + await state.update_data(end_at=end_dt.isoformat()) + await state.set_state(AdminStates.creating_referral_contest_time) + await message.answer( + texts.t( + "ADMIN_CONTEST_ENTER_DAILY_TIME", + "Во сколько отправлять ежедневные итоги? Укажите время в формате ЧЧ:ММ (например, 12:00).", + ) + ) + + +@admin_required +@error_handler +async def finalize_contest_creation(message: types.Message, state: FSMContext, db_user, db: AsyncSession): + summary_time = _parse_time(message.text) + texts = get_texts(db_user.language) + + if not summary_time: + await message.answer( + texts.t("ADMIN_CONTEST_INVALID_TIME", "Не удалось распознать время. Формат: 12:00") + ) + return + + data = await state.get_data() + tz = _ensure_timezone(settings.TIMEZONE) + + start_at_raw = data.get("start_at") + end_at_raw = data.get("end_at") + if not start_at_raw or not end_at_raw: + await message.answer(texts.t("ADMIN_CONTEST_INVALID_DATE", "Не удалось распознать дату.")) + return + + start_at = ( + datetime.fromisoformat(start_at_raw) + .astimezone(timezone.utc) + .replace(tzinfo=None) + ) + end_at = ( + datetime.fromisoformat(end_at_raw) + .astimezone(timezone.utc) + .replace(tzinfo=None) + ) + + contest_type = data.get("contest_type") or "referral_paid" + + contest = await create_referral_contest( + db, + title=data.get("title"), + description=data.get("description"), + prize_text=data.get("prize"), + contest_type=contest_type, + start_at=start_at, + end_at=end_at, + daily_summary_time=summary_time, + timezone_name=tz.key, + created_by=db_user.id, + ) + + await state.clear() + + await message.answer( + texts.t("ADMIN_CONTEST_CREATED", "Конкурс создан!"), + reply_markup=get_referral_contest_manage_keyboard( + contest.id, + is_active=contest.is_active, + language=db_user.language, + ), + ) + + +def register_handlers(dp: Dispatcher): + dp.callback_query.register(show_contests_menu, F.data == "admin_contests") + dp.callback_query.register(show_referral_contests_menu, F.data == "admin_contests_referral") + dp.callback_query.register(list_contests, F.data == "admin_contests_list") + dp.callback_query.register(list_contests, F.data.startswith("admin_contests_list_page_")) + dp.callback_query.register(show_contest_details, F.data.startswith("admin_contest_view_")) + dp.callback_query.register(toggle_contest, F.data.startswith("admin_contest_toggle_")) + dp.callback_query.register(show_leaderboard, F.data.startswith("admin_contest_leaderboard_")) + dp.callback_query.register(start_contest_creation, F.data == "admin_contests_create") + dp.callback_query.register(select_contest_mode, F.data.in_(["admin_contest_mode_paid", "admin_contest_mode_registered"])) + + dp.message.register(process_title, AdminStates.creating_referral_contest_title) + dp.message.register(process_description, AdminStates.creating_referral_contest_description) + dp.message.register(process_prize, AdminStates.creating_referral_contest_prize) + dp.message.register(process_start_date, AdminStates.creating_referral_contest_start) + dp.message.register(process_end_date, AdminStates.creating_referral_contest_end) + dp.message.register(finalize_contest_creation, AdminStates.creating_referral_contest_time) diff --git a/app/handlers/admin/daily_contests.py b/app/handlers/admin/daily_contests.py new file mode 100644 index 00000000..58237de1 --- /dev/null +++ b/app/handlers/admin/daily_contests.py @@ -0,0 +1,306 @@ +import json +import logging +from datetime import datetime, timedelta +from typing import Dict + +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.contest import ( + get_template_by_id, + list_templates, + update_template_fields, + create_round, +) +from app.database.models import ContestTemplate +from app.keyboards.admin import ( + get_admin_contests_keyboard, + get_admin_contests_root_keyboard, + get_daily_contest_manage_keyboard, +) +from app.localization.texts import get_texts +from app.services.contest_rotation_service import contest_rotation_service +from app.states import AdminStates +from app.utils.decorators import admin_required, error_handler + +logger = logging.getLogger(__name__) + +EDITABLE_FIELDS: Dict[str, Dict] = { + "prize_days": {"type": int, "min": 1, "label": "приз (дни)"}, + "max_winners": {"type": int, "min": 1, "label": "макс. победителей"}, + "attempts_per_user": {"type": int, "min": 1, "label": "попыток на пользователя"}, + "times_per_day": {"type": int, "min": 1, "label": "раундов в день"}, + "schedule_times": {"type": str, "label": "расписание HH:MM через запятую"}, + "cooldown_hours": {"type": int, "min": 1, "label": "длительность раунда (часы)"}, +} + + +async def _get_template(db: AsyncSession, template_id: int) -> ContestTemplate | None: + return await get_template_by_id(db, template_id) + + +@admin_required +@error_handler +async def show_daily_contests( + callback: types.CallbackQuery, + db_user, + db: AsyncSession, +): + texts = get_texts(db_user.language) + templates = await list_templates(db, enabled_only=False) + + lines = [texts.t("ADMIN_DAILY_CONTESTS_TITLE", "📆 Ежедневные конкурсы")] + if not templates: + lines.append(texts.t("ADMIN_CONTESTS_EMPTY", "Пока нет созданных конкурсов.")) + else: + for tpl in templates: + status = "🟢" if tpl.is_enabled else "⚪️" + lines.append(f"{status} {tpl.name} (slug: {tpl.slug}) — приз {tpl.prize_days}д, макс {tpl.max_winners}") + + keyboard_rows = [] + for tpl in templates: + keyboard_rows.append( + [ + types.InlineKeyboardButton( + text=f"⚙️ {tpl.name}", + callback_data=f"admin_daily_contest_{tpl.id}", + ) + ] + ) + keyboard_rows.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="admin_contests")]) + + await callback.message.edit_text( + "\n".join(lines), + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard_rows), + ) + await callback.answer() + + +@admin_required +@error_handler +async def show_daily_contest( + callback: types.CallbackQuery, + db_user, + db: AsyncSession, +): + texts = get_texts(db_user.language) + try: + template_id = int(callback.data.split("_")[-1]) + except Exception: + await callback.answer("Некорректный id", show_alert=True) + return + + tpl = await _get_template(db, template_id) + if not tpl: + await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True) + return + + lines = [ + f"🏷 {tpl.name} (slug: {tpl.slug})", + f"{texts.t('ADMIN_CONTEST_STATUS_ACTIVE','🟢 Активен') if tpl.is_enabled else texts.t('ADMIN_CONTEST_STATUS_INACTIVE','⚪️ Выключен')}", + f"Приз: {tpl.prize_days} дн. | Макс победителей: {tpl.max_winners}", + f"Попыток/польз: {tpl.attempts_per_user}", + f"Раундов в день: {tpl.times_per_day}", + f"Расписание: {tpl.schedule_times or '-'}", + f"Длительность раунда: {tpl.cooldown_hours} ч.", + ] + await callback.message.edit_text( + "\n".join(lines), + reply_markup=get_daily_contest_manage_keyboard(tpl.id, tpl.is_enabled, db_user.language), + ) + await callback.answer() + + +@admin_required +@error_handler +async def toggle_daily_contest( + callback: types.CallbackQuery, + db_user, + db: AsyncSession, +): + texts = get_texts(db_user.language) + template_id = int(callback.data.split("_")[-1]) + tpl = await _get_template(db, template_id) + if not tpl: + await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True) + return + tpl.is_enabled = not tpl.is_enabled + await db.commit() + await callback.answer(texts.t("ADMIN_UPDATED", "Обновлено")) + await show_daily_contest(callback, db_user, db) + + +@admin_required +@error_handler +async def start_round_now( + callback: types.CallbackQuery, + db_user, + db: AsyncSession, +): + texts = get_texts(db_user.language) + template_id = int(callback.data.split("_")[-1]) + tpl = await _get_template(db, template_id) + if not tpl: + await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True) + return + + payload = contest_rotation_service._build_payload_for_template(tpl) # type: ignore[attr-defined] + now = datetime.utcnow() + ends = now + timedelta(hours=tpl.cooldown_hours) + round_obj = await create_round( + db, + template=tpl, + starts_at=now, + ends_at=ends, + payload=payload, + ) + await callback.answer(texts.t("ADMIN_ROUND_STARTED", "Раунд запущен"), show_alert=True) + await show_daily_contest(callback, db_user, db) + + +@admin_required +@error_handler +async def prompt_edit_field( + callback: types.CallbackQuery, + db_user, + db: AsyncSession, + state: FSMContext, +): + texts = get_texts(db_user.language) + parts = callback.data.split("_") + template_id = int(parts[3]) + field = parts[4] + + tpl = await _get_template(db, template_id) + if not tpl or field not in EDITABLE_FIELDS: + await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True) + return + + meta = EDITABLE_FIELDS[field] + await state.set_state(AdminStates.editing_daily_contest_field) + await state.update_data(template_id=template_id, field=field) + await callback.message.edit_text( + texts.t( + "ADMIN_CONTEST_FIELD_PROMPT", + "Введите новое значение для {label}:", + ).format(label=meta.get("label", field)), + reply_markup=None, + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_edit_field( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + texts = get_texts(db_user.language) + data = await state.get_data() + template_id = data.get("template_id") + field = data.get("field") + if not template_id or not field or field not in EDITABLE_FIELDS: + await message.answer(texts.ERROR) + await state.clear() + return + + tpl = await _get_template(db, template_id) + if not tpl: + await message.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден.")) + await state.clear() + return + + meta = EDITABLE_FIELDS[field] + raw = message.text or "" + try: + if meta["type"] is int: + value = int(raw) + if meta.get("min") is not None and value < meta["min"]: + raise ValueError("min") + else: + value = raw.strip() + except Exception: + await message.answer(texts.t("ADMIN_INVALID_NUMBER", "Некорректное число")) + await state.clear() + return + + await update_template_fields(db, tpl, **{field: value}) + await message.answer(texts.t("ADMIN_UPDATED", "Обновлено"), reply_markup=None) + await state.clear() + + +@admin_required +@error_handler +async def edit_payload( + callback: types.CallbackQuery, + db_user, + db: AsyncSession, + state: FSMContext, +): + texts = get_texts(db_user.language) + template_id = int(callback.data.split("_")[-1]) + tpl = await _get_template(db, template_id) + if not tpl: + await callback.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден."), show_alert=True) + return + + await state.set_state(AdminStates.editing_daily_contest_value) + await state.update_data(template_id=template_id, field="payload") + payload_json = json.dumps(tpl.payload or {}, ensure_ascii=False, indent=2) + await callback.message.edit_text( + texts.t("ADMIN_CONTEST_PAYLOAD_PROMPT", "Отправьте JSON payload для игры (словарь настроек):\n") + f"{payload_json}", + reply_markup=None, + ) + await callback.answer() + + +@admin_required +@error_handler +async def process_payload( + message: types.Message, + state: FSMContext, + db_user, + db: AsyncSession, +): + texts = get_texts(db_user.language) + data = await state.get_data() + template_id = data.get("template_id") + if not template_id: + await message.answer(texts.ERROR) + await state.clear() + return + + try: + payload = json.loads(message.text or "{}") + if not isinstance(payload, dict): + raise ValueError + except Exception: + await message.answer(texts.t("ADMIN_INVALID_JSON", "Некорректный JSON")) + await state.clear() + return + + tpl = await _get_template(db, template_id) + if not tpl: + await message.answer(texts.t("ADMIN_CONTEST_NOT_FOUND", "Конкурс не найден.")) + await state.clear() + return + + await update_template_fields(db, tpl, payload=payload) + await message.answer(texts.t("ADMIN_UPDATED", "Обновлено")) + await state.clear() + + +def register_handlers(dp: Dispatcher): + dp.callback_query.register(show_daily_contests, F.data == "admin_contests_daily") + dp.callback_query.register(show_daily_contest, F.data.startswith("admin_daily_contest_")) + dp.callback_query.register(toggle_daily_contest, F.data.startswith("admin_daily_toggle_")) + dp.callback_query.register(start_round_now, F.data.startswith("admin_daily_start_")) + dp.callback_query.register(prompt_edit_field, F.data.startswith("admin_daily_edit_")) + dp.callback_query.register(edit_payload, F.data.startswith("admin_daily_payload_")) + + dp.message.register(process_edit_field, AdminStates.editing_daily_contest_field) + dp.message.register(process_payload, AdminStates.editing_daily_contest_value) diff --git a/app/handlers/contests.py b/app/handlers/contests.py new file mode 100644 index 00000000..ae8cb143 --- /dev/null +++ b/app/handlers/contests.py @@ -0,0 +1,373 @@ +import logging +import random +from datetime import datetime +from typing import Optional + +from aiogram import Dispatcher, F, types +from aiogram.fsm.context import FSMContext +from aiogram.filters import Command +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.contest import ( + get_active_rounds, + get_template_by_slug, + get_active_round_by_template, + get_attempt, + create_attempt, + increment_winner_count, +) +from app.database.database import AsyncSessionLocal +from app.database.models import ContestRound, ContestTemplate, SubscriptionStatus +from app.localization.texts import get_texts +from app.services.contest_rotation_service import ( + GAME_QUEST, + GAME_LOCKS, + GAME_CIPHER, + GAME_SERVER, + GAME_BLITZ, + GAME_EMOJI, + GAME_ANAGRAM, +) +from app.database.crud.subscription import get_subscription_by_user_id +from app.database.crud.subscription import extend_subscription +from app.utils.decorators import auth_required, error_handler +from app.keyboards.inline import get_back_keyboard +from app.states import ContestStates + +logger = logging.getLogger(__name__) + + +def _user_allowed(subscription) -> bool: + if not subscription: + return False + return subscription.status in { + SubscriptionStatus.ACTIVE.value, + SubscriptionStatus.TRIAL.value, + } + + +async def _with_session() -> AsyncSession: + return AsyncSessionLocal() + + +async def _award_prize(db: AsyncSession, user_id: int, prize_days: int, language: str) -> str: + from app.database.crud.user import get_user_by_id + user = await get_user_by_id(db, user_id) + if not user: + return "" + subscription = await get_subscription_by_user_id(db, user_id) + if not subscription: + return "" + await extend_subscription(db, subscription, prize_days) + texts = get_texts(language) + return texts.t("CONTEST_PRIZE_GRANTED", "Бонус {days} дней зачислен!").format(days=prize_days) + + +async def _ensure_round_for_template(template: ContestTemplate) -> Optional[ContestRound]: + async with AsyncSessionLocal() as db: + round_obj = await get_active_round_by_template(db, template.id) + return round_obj + + +async def _reply_not_eligible(callback: types.CallbackQuery, language: str): + texts = get_texts(language) + await callback.answer(texts.t("CONTEST_NOT_ELIGIBLE", "Игры доступны только с активной или триальной подпиской."), show_alert=True) + + +# ---------- Handlers ---------- + + +@auth_required +@error_handler +async def show_contests_menu(callback: types.CallbackQuery, db_user, db: AsyncSession): + texts = get_texts(db_user.language) + subscription = await get_subscription_by_user_id(db, db_user.id) + if not _user_allowed(subscription): + await _reply_not_eligible(callback, db_user.language) + return + + active_rounds = await get_active_rounds(db) + buttons = [] + for rnd in active_rounds: + tpl_slug = rnd.template.slug if rnd.template else "" + title = rnd.template.name if rnd.template else tpl_slug + buttons.append( + [ + types.InlineKeyboardButton( + text=f"▶️ {title}", + callback_data=f"contest_play_{tpl_slug}_{rnd.id}", + ) + ] + ) + if not buttons: + buttons.append( + [types.InlineKeyboardButton(text=texts.t("CONTEST_EMPTY", "Сейчас игр нет"), callback_data="noop")] + ) + buttons.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="back_to_menu")]) + + await callback.message.edit_text( + texts.t("CONTEST_MENU_TITLE", "🎲 Игры/Конкурсы\nВыберите игру:"), + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=buttons), + ) + await callback.answer() + + +@auth_required +@error_handler +async def play_contest(callback: types.CallbackQuery, state: FSMContext, db_user, db: AsyncSession): + texts = get_texts(db_user.language) + subscription = await get_subscription_by_user_id(db, db_user.id) + if not _user_allowed(subscription): + await _reply_not_eligible(callback, db_user.language) + return + + try: + _, _, slug, round_id = callback.data.split("_", 3) + round_id = int(round_id) + except Exception: + await callback.answer("Некорректные данные", show_alert=True) + return + + # reload round with template + async with AsyncSessionLocal() as db2: + active_rounds = await get_active_rounds(db2) + round_obj = next((r for r in active_rounds if r.id == round_id), None) + if not round_obj: + await callback.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён или недоступен."), show_alert=True) + return + attempt = await get_attempt(db2, round_id, db_user.id) + if attempt: + await callback.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка в этом раунде."), show_alert=True) + return + + tpl = round_obj.template + if tpl.slug == GAME_QUEST: + await _render_quest(callback, db_user, round_obj, tpl) + elif tpl.slug == GAME_LOCKS: + await _render_locks(callback, db_user, round_obj, tpl) + elif tpl.slug == GAME_SERVER: + await _render_server_lottery(callback, db_user, round_obj, tpl) + elif tpl.slug == GAME_CIPHER: + await _render_cipher(callback, db_user, round_obj, tpl, state) + elif tpl.slug == GAME_EMOJI: + await _render_emoji(callback, db_user, round_obj, tpl, state) + elif tpl.slug == GAME_ANAGRAM: + await _render_anagram(callback, db_user, round_obj, tpl, state) + elif tpl.slug == GAME_BLITZ: + await _render_blitz(callback, db_user, round_obj, tpl) + else: + await callback.answer(texts.t("CONTEST_UNKNOWN", "Тип конкурса не поддерживается."), show_alert=True) + + +async def _render_quest(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate): + texts = get_texts(db_user.language) + rows = round_obj.payload.get("rows", 3) + cols = round_obj.payload.get("cols", 3) + keyboard = [] + for r in range(rows): + row_buttons = [] + for c in range(cols): + idx = r * cols + c + row_buttons.append( + types.InlineKeyboardButton( + text="🎛", + callback_data=f"contest_pick_{round_obj.id}_{idx}" + ) + ) + keyboard.append(row_buttons) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")]) + await callback.message.edit_text( + texts.t("CONTEST_QUEST_PROMPT", "Выбери один из узлов 3×3:"), + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + ) + await callback.answer() + + +async def _render_locks(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate): + texts = get_texts(db_user.language) + total = round_obj.payload.get("total", 20) + keyboard = [] + row = [] + for i in range(total): + row.append(types.InlineKeyboardButton(text="🔒", callback_data=f"contest_pick_{round_obj.id}_{i}")) + if len(row) == 5: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")]) + await callback.message.edit_text( + texts.t("CONTEST_LOCKS_PROMPT", "Найди взломанную кнопку среди замков:"), + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + ) + await callback.answer() + + +async def _render_server_lottery(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate): + texts = get_texts(db_user.language) + flags = round_obj.payload.get("flags") or [] + keyboard = [] + row = [] + for idx, flag in enumerate(flags): + row.append(types.InlineKeyboardButton(text=flag, callback_data=f"contest_pick_{round_obj.id}_{idx}")) + if len(row) == 5: + keyboard.append(row) + row = [] + if row: + keyboard.append(row) + keyboard.append([types.InlineKeyboardButton(text=texts.BACK, callback_data="contests_menu")]) + await callback.message.edit_text( + texts.t("CONTEST_SERVER_PROMPT", "Выбери сервер:"), + reply_markup=types.InlineKeyboardMarkup(inline_keyboard=keyboard), + ) + await callback.answer() + + +async def _render_cipher(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext): + texts = get_texts(db_user.language) + question = round_obj.payload.get("question", "") + await state.set_state(ContestStates.waiting_for_answer) + await state.update_data(contest_round_id=round_obj.id) + await callback.message.edit_text( + texts.t("CONTEST_CIPHER_PROMPT", "Расшифруй: {q}").format(q=question), + reply_markup=get_back_keyboard(db_user.language), + ) + await callback.answer() + + +async def _render_emoji(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext): + texts = get_texts(db_user.language) + question = round_obj.payload.get("question", "🤔") + await state.set_state(ContestStates.waiting_for_answer) + await state.update_data(contest_round_id=round_obj.id) + await callback.message.edit_text( + texts.t("CONTEST_EMOJI_PROMPT", "Угадай сервис по эмодзи: {q}").format(q=question), + reply_markup=get_back_keyboard(db_user.language), + ) + await callback.answer() + + +async def _render_anagram(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate, state: FSMContext): + texts = get_texts(db_user.language) + letters = round_obj.payload.get("letters", "") + await state.set_state(ContestStates.waiting_for_answer) + await state.update_data(contest_round_id=round_obj.id) + await callback.message.edit_text( + texts.t("CONTEST_ANAGRAM_PROMPT", "Составь слово: {letters}").format(letters=letters), + reply_markup=get_back_keyboard(db_user.language), + ) + await callback.answer() + + +async def _render_blitz(callback, db_user, round_obj: ContestRound, tpl: ContestTemplate): + texts = get_texts(db_user.language) + keyboard = types.InlineKeyboardMarkup( + inline_keyboard=[ + [types.InlineKeyboardButton(text=texts.t("CONTEST_BLITZ_BUTTON", "Я здесь!"), callback_data=f"contest_pick_{round_obj.id}_blitz")] + ] + ) + await callback.message.edit_text( + texts.t("CONTEST_BLITZ_PROMPT", "⚡️ Блиц! Нажми «Я здесь!»"), + reply_markup=keyboard, + ) + await callback.answer() + + +@auth_required +@error_handler +async def handle_pick(callback: types.CallbackQuery, db_user, db: AsyncSession): + texts = get_texts(db_user.language) + try: + _, _, round_id_str, pick = callback.data.split("_", 3) + round_id = int(round_id_str) + except Exception: + await callback.answer("Некорректные данные", show_alert=True) + return + + async with AsyncSessionLocal() as db2: + active_rounds = await get_active_rounds(db2) + round_obj = next((r for r in active_rounds if r.id == round_id), None) + if not round_obj: + await callback.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."), show_alert=True) + return + + tpl = round_obj.template + attempt = await get_attempt(db2, round_id, db_user.id) + if attempt: + await callback.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка."), show_alert=True) + return + + secret_idx = round_obj.payload.get("secret_idx") + is_winner = False + if tpl.slug in {GAME_QUEST, GAME_LOCKS, GAME_SERVER}: + try: + pick_int = int(pick) + is_winner = pick_int == secret_idx + except Exception: + is_winner = False + elif tpl.slug == GAME_BLITZ: + is_winner = True # первый клик получит + else: + is_winner = False + + await create_attempt(db2, round_id=round_obj.id, user_id=db_user.id, answer=str(pick), is_winner=is_winner) + + if is_winner: + await increment_winner_count(db2, round_obj) + prize_text = await _award_prize(db2, db_user.id, tpl.prize_days, db_user.language) + await callback.answer(texts.t("CONTEST_WIN", "🎉 Победа! ") + (prize_text or ""), show_alert=True) + else: + responses = { + GAME_QUEST: ["Пусто", "Ложный сервер", "Найди другой узел"], + GAME_LOCKS: ["Заблокировано", "Попробуй ещё", "Нет доступа"], + GAME_SERVER: ["Сервер перегружен", "Нет ответа", "Попробуй завтра"], + }.get(tpl.slug, ["Неудача"]) + await callback.answer(random.choice(responses), show_alert=False) + + +@auth_required +@error_handler +async def handle_text_answer(message: types.Message, state: FSMContext, db_user, db: AsyncSession): + texts = get_texts(db_user.language) + data = await state.get_data() + round_id = data.get("contest_round_id") + if not round_id: + return + + async with AsyncSessionLocal() as db2: + active_rounds = await get_active_rounds(db2) + round_obj = next((r for r in active_rounds if r.id == round_id), None) + if not round_obj: + await message.answer(texts.t("CONTEST_ROUND_FINISHED", "Раунд завершён."), reply_markup=get_back_keyboard(db_user.language)) + await state.clear() + return + + attempt = await get_attempt(db2, round_obj.id, db_user.id) + if attempt: + await message.answer(texts.t("CONTEST_ALREADY_PLAYED", "У вас уже была попытка."), reply_markup=get_back_keyboard(db_user.language)) + await state.clear() + return + + answer = (message.text or "").strip().upper() + tpl = round_obj.template + correct = (round_obj.payload.get("answer") or "").upper() + + is_winner = correct and answer == correct + await create_attempt(db2, round_id=round_obj.id, user_id=db_user.id, answer=answer, is_winner=is_winner) + + if is_winner: + await increment_winner_count(db2, round_obj) + prize_text = await _award_prize(db2, db_user.id, tpl.prize_days, db_user.language) + await message.answer(texts.t("CONTEST_WIN", "🎉 Победа! ") + (prize_text or ""), reply_markup=get_back_keyboard(db_user.language)) + else: + await message.answer(texts.t("CONTEST_LOSE", "Не верно, попробуй снова в следующем раунде."), reply_markup=get_back_keyboard(db_user.language)) + await state.clear() + + +def register_handlers(dp: Dispatcher): + dp.callback_query.register(show_contests_menu, F.data == "contests_menu") + dp.callback_query.register(play_contest, F.data.startswith("contest_play_")) + dp.callback_query.register(handle_pick, F.data.startswith("contest_pick_")) + dp.message.register(handle_text_answer, ContestStates.waiting_for_answer) + dp.message.register(lambda message: None, Command("contests")) # placeholder diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index c19136c9..544e013d 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -95,6 +95,12 @@ def get_admin_promo_submenu_keyboard(language: str = "ru") -> InlineKeyboardMark [ InlineKeyboardButton(text=texts.ADMIN_CAMPAIGNS, callback_data="admin_campaigns") ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_CONTESTS", "🏆 Конкурсы"), + callback_data="admin_contests", + ) + ], [ InlineKeyboardButton(text=texts.ADMIN_PROMO_GROUPS, callback_data="admin_promo_groups") ], @@ -471,6 +477,148 @@ def get_admin_campaigns_keyboard(language: str = "ru") -> InlineKeyboardMarkup: ]) +def get_admin_contests_root_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_CONTESTS_REFERRAL", "🤝 Реферальные конкурсы"), + callback_data="admin_contests_referral", + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_CONTESTS_DAILY", "📆 Ежедневные конкурсы"), + callback_data="admin_contests_daily", + ) + ], + [ + InlineKeyboardButton(text=texts.BACK, callback_data="admin_submenu_promo"), + ], + ] + ) + + +def get_admin_contests_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_CONTESTS_LIST", "📋 Текущие конкурсы"), + callback_data="admin_contests_list", + ), + InlineKeyboardButton( + text=_t(texts, "ADMIN_CONTESTS_CREATE", "➕ Новый конкурс"), + callback_data="admin_contests_create", + ), + ], + [ + InlineKeyboardButton( + text=texts.BACK, + callback_data="admin_contests", + ) + ], + ] + ) + + +def get_contest_mode_keyboard(language: str = "ru") -> InlineKeyboardMarkup: + texts = get_texts(language) + + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_CONTEST_MODE_PAID", "💳 Реферал с покупкой"), + callback_data="admin_contest_mode_paid", + ) + ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_CONTEST_MODE_REGISTERED", "🧑‍🤝‍🧑 Просто реферал"), + callback_data="admin_contest_mode_registered", + ) + ], + [ + InlineKeyboardButton(text=texts.BACK, callback_data="admin_contests_referral") + ], + ] + ) + + +def get_daily_contest_manage_keyboard( + template_id: int, + is_enabled: bool, + language: str = "ru", +) -> InlineKeyboardMarkup: + texts = get_texts(language) + toggle_text = _t(texts, "ADMIN_CONTEST_DISABLE", "⏸️ Остановить") if is_enabled else _t(texts, "ADMIN_CONTEST_ENABLE", "▶️ Запустить") + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton(text=toggle_text, callback_data=f"admin_daily_toggle_{template_id}"), + InlineKeyboardButton(text=_t(texts, "ADMIN_CONTEST_START_NOW", "🚀 Запустить раунд"), callback_data=f"admin_daily_start_{template_id}"), + ], + [ + InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_PRIZE", "🏅 Приз (дни)"), callback_data=f"admin_daily_edit_{template_id}_prize_days"), + InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_MAX_WINNERS", "👥 Победителей"), callback_data=f"admin_daily_edit_{template_id}_max_winners"), + ], + [ + InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_ATTEMPTS", "🔁 Попытки"), callback_data=f"admin_daily_edit_{template_id}_attempts_per_user"), + InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_TIMES", "⏰ Раундов/день"), callback_data=f"admin_daily_edit_{template_id}_times_per_day"), + ], + [ + InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_SCHEDULE", "🕒 Расписание"), callback_data=f"admin_daily_edit_{template_id}_schedule_times"), + InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_COOLDOWN", "⌛ Длительность"), callback_data=f"admin_daily_edit_{template_id}_cooldown_hours"), + ], + [ + InlineKeyboardButton(text=_t(texts, "ADMIN_EDIT_PAYLOAD", "🧩 Payload"), callback_data=f"admin_daily_payload_{template_id}"), + ], + [ + InlineKeyboardButton(text=texts.BACK, callback_data="admin_contests_daily"), + ], + ] + ) + +def get_referral_contest_manage_keyboard( + contest_id: int, + *, + is_active: bool, + language: str = "ru", +) -> InlineKeyboardMarkup: + texts = get_texts(language) + toggle_text = ( + _t(texts, "ADMIN_CONTEST_DISABLE", "⏸️ Остановить") + if is_active + else _t(texts, "ADMIN_CONTEST_ENABLE", "▶️ Запустить") + ) + + return InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_CONTEST_LEADERBOARD", "📊 Лидеры"), + callback_data=f"admin_contest_leaderboard_{contest_id}", + ), + InlineKeyboardButton( + text=toggle_text, + callback_data=f"admin_contest_toggle_{contest_id}", + ), + ], + [ + InlineKeyboardButton( + text=_t(texts, "ADMIN_BACK_TO_LIST", "⬅️ К списку"), + callback_data="admin_contests_list", + ) + ], + ] + ) + + def get_campaign_management_keyboard( campaign_id: int, is_active: bool, language: str = "ru" ) -> InlineKeyboardMarkup: diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index e5d95ba2..02cf3d0f 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -48,6 +48,71 @@ "ADMIN_CAMPAIGN_SERVERS": "🌍 Servers", "ADMIN_CAMPAIGN_STATS": "📊 Statistics", "ADMIN_CAMPAIGN_TRAFFIC": "🌐 Traffic", + "ADMIN_CONTESTS": "🏆 Contests", + "ADMIN_CONTESTS_CREATE": "➕ New contest", + "ADMIN_CONTESTS_DISABLED": "Contests are disabled via CONTESTS_ENABLED.", + "ADMIN_CONTESTS_LIST": "📋 Current contests", + "ADMIN_CONTESTS_REFERRAL": "🤝 Referral contests", + "ADMIN_CONTESTS_DAILY": "📆 Daily contests", + "ADMIN_CONTESTS_COMING_SOON": "Coming soon.", + "ADMIN_CONTESTS_LIST_HEADER": "🏆 Contests\n", + "ADMIN_CONTESTS_EMPTY": "No contests yet.", + "ADMIN_CONTESTS_TITLE": "🏆 Contests\n\nChoose an action:", + "ADMIN_CONTEST_MODE_PAID": "💳 Referral with purchase", + "ADMIN_CONTEST_MODE_REGISTERED": "🧑‍🤝‍🧑 Referral registration", + "ADMIN_CONTEST_MODE_PROMPT": "Choose qualifying rule: referral must buy a subscription or just register.", + "ADMIN_CONTEST_ENTER_TITLE": "Enter contest title:", + "ADMIN_CONTEST_ENTER_DESCRIPTION": "Describe the contest (or send '-' to skip):", + "ADMIN_CONTEST_ENTER_PRIZE": "Specify prizes/rewards (or '-' to skip):", + "ADMIN_CONTEST_ENTER_START": "Enter start date/time (dd.mm.yyyy hh:mm) in your timezone:", + "ADMIN_CONTEST_ENTER_END": "Enter end date/time (dd.mm.yyyy hh:mm) in your timezone:", + "ADMIN_CONTEST_ENTER_DAILY_TIME": "What time to send daily results? Use HH:MM (e.g., 12:00).", + "ADMIN_CONTEST_INVALID_DATE": "Cannot parse date. Format: 01.06.2024 12:00", + "ADMIN_CONTEST_INVALID_TIME": "Cannot parse time. Format: 12:00", + "ADMIN_CONTEST_END_BEFORE_START": "End date must be after start date.", + "ADMIN_CONTEST_CREATED": "Contest created!", + "ADMIN_CONTEST_PRIZE": "Prize: {prize}", + "ADMIN_CONTEST_LAST_DAILY": "Last digest: {date}", + "ADMIN_CONTEST_STATUS_ACTIVE": "🟢 Active", + "ADMIN_CONTEST_STATUS_INACTIVE": "⚪️ Disabled", + "ADMIN_CONTEST_TOTAL_EVENTS": "Qualified: {count}", + "ADMIN_CONTEST_LEADERBOARD_TITLE": "📊 Top participants:", + "ADMIN_CONTEST_LEADERBOARD": "📊 Leaders", + "ADMIN_CONTEST_ENABLE": "▶️ Start", + "ADMIN_CONTEST_DISABLE": "⏸️ Pause", + "ADMIN_CONTEST_NOT_FOUND": "Contest not found.", + "ADMIN_CONTEST_EMPTY_LEADERBOARD": "No participants yet.", + "ADMIN_DAILY_CONTESTS_TITLE": "📆 Daily contests", + "ADMIN_EDIT_PRIZE": "🏅 Prize (days)", + "ADMIN_EDIT_MAX_WINNERS": "👥 Winners", + "ADMIN_EDIT_ATTEMPTS": "🔁 Attempts", + "ADMIN_EDIT_TIMES": "⏰ Rounds/day", + "ADMIN_EDIT_SCHEDULE": "🕒 Schedule", + "ADMIN_EDIT_COOLDOWN": "⌛ Duration", + "ADMIN_EDIT_PAYLOAD": "🧩 Payload", + "ADMIN_CONTEST_FIELD_PROMPT": "Enter new value for {label}:", + "ADMIN_CONTEST_PAYLOAD_PROMPT": "Send JSON payload for this game:", + "ADMIN_ROUND_STARTED": "Round started", + "ADMIN_UPDATED": "Updated", + "ADMIN_INVALID_NUMBER": "Invalid number", + "ADMIN_INVALID_JSON": "Invalid JSON", + "CONTEST_MENU_TITLE": "🎲 Games/Contests\nChoose a game:", + "CONTEST_EMPTY": "No games right now", + "CONTEST_NOT_ELIGIBLE": "Games are available only with an active or trial subscription.", + "CONTEST_ROUND_FINISHED": "Round finished or unavailable.", + "CONTEST_ALREADY_PLAYED": "You already played this round.", + "CONTEST_UNKNOWN": "Contest type not supported.", + "CONTEST_QUEST_PROMPT": "Pick one of the 3×3 nodes:", + "CONTEST_LOCKS_PROMPT": "Find the hacked lock:", + "CONTEST_SERVER_PROMPT": "Choose a server:", + "CONTEST_CIPHER_PROMPT": "Decode: {q}", + "CONTEST_EMOJI_PROMPT": "Guess the service: {q}", + "CONTEST_ANAGRAM_PROMPT": "Make a word: {letters}", + "CONTEST_BLITZ_PROMPT": "⚡️ Blitz! Press “I'm here!”", + "CONTEST_BLITZ_BUTTON": "I'm here!", + "CONTEST_WIN": "🎉 You win! ", + "CONTEST_LOSE": "Wrong, try next round.", + "CONTEST_PRIZE_GRANTED": "Bonus {days} days credited!", "ADMIN_CANCEL": "❌ Cancel", "ADMIN_COMMUNICATIONS_MENU_MESSAGES": "📢 Menu messages", "ADMIN_COMMUNICATIONS_PROMO_OFFERS": "🎯 Promo offers", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 71f500d4..e1d2b5cb 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -48,6 +48,71 @@ "ADMIN_CAMPAIGN_SERVERS": "🌍 Серверы", "ADMIN_CAMPAIGN_STATS": "📊 Статистика", "ADMIN_CAMPAIGN_TRAFFIC": "🌐 Трафик", + "ADMIN_CONTESTS": "🏆 Конкурсы", + "ADMIN_CONTESTS_CREATE": "➕ Новый конкурс", + "ADMIN_CONTESTS_DISABLED": "Конкурсы отключены через переменную окружения CONTESTS_ENABLED.", + "ADMIN_CONTESTS_LIST": "📋 Текущие конкурсы", + "ADMIN_CONTESTS_REFERRAL": "🤝 Реферальные конкурсы", + "ADMIN_CONTESTS_DAILY": "📆 Ежедневные конкурсы", + "ADMIN_CONTESTS_COMING_SOON": "Функционал появится позже.", + "ADMIN_CONTESTS_LIST_HEADER": "🏆 Конкурсы\n", + "ADMIN_CONTESTS_EMPTY": "Пока нет созданных конкурсов.", + "ADMIN_CONTESTS_TITLE": "🏆 Конкурсы\n\nВыберите действие:", + "ADMIN_CONTEST_MODE_PAID": "💳 Реферал с покупкой", + "ADMIN_CONTEST_MODE_REGISTERED": "🧑‍🤝‍🧑 Просто реферал", + "ADMIN_CONTEST_MODE_PROMPT": "Выберите условие зачёта: реферал должен купить подписку или достаточно регистрации.", + "ADMIN_CONTEST_ENTER_TITLE": "Введите название конкурса:", + "ADMIN_CONTEST_ENTER_DESCRIPTION": "Опишите конкурс (или отправьте '-' чтобы пропустить):", + "ADMIN_CONTEST_ENTER_PRIZE": "Укажите призы/выгоды конкурса (или '-' чтобы пропустить):", + "ADMIN_CONTEST_ENTER_START": "Введите дату и время старта (дд.мм.гггг чч:мм) по вашему часовому поясу:", + "ADMIN_CONTEST_ENTER_END": "Введите дату и время окончания (дд.мм.гггг чч:мм) по вашему часовому поясу:", + "ADMIN_CONTEST_ENTER_DAILY_TIME": "Во сколько отправлять ежедневные итоги? Укажите время в формате ЧЧ:ММ (например, 12:00).", + "ADMIN_CONTEST_INVALID_DATE": "Не удалось распознать дату. Формат: 01.06.2024 12:00", + "ADMIN_CONTEST_INVALID_TIME": "Не удалось распознать время. Формат: 12:00", + "ADMIN_CONTEST_END_BEFORE_START": "Дата окончания должна быть позже даты начала.", + "ADMIN_CONTEST_CREATED": "Конкурс создан!", + "ADMIN_CONTEST_PRIZE": "Приз: {prize}", + "ADMIN_CONTEST_LAST_DAILY": "Последняя сводка: {date}", + "ADMIN_CONTEST_STATUS_ACTIVE": "🟢 Активен", + "ADMIN_CONTEST_STATUS_INACTIVE": "⚪️ Выключен", + "ADMIN_CONTEST_TOTAL_EVENTS": "Зачётов: {count}", + "ADMIN_CONTEST_LEADERBOARD_TITLE": "📊 Топ участников:", + "ADMIN_CONTEST_LEADERBOARD": "📊 Лидеры", + "ADMIN_CONTEST_ENABLE": "▶️ Запустить", + "ADMIN_CONTEST_DISABLE": "⏸️ Остановить", + "ADMIN_CONTEST_NOT_FOUND": "Конкурс не найден.", + "ADMIN_CONTEST_EMPTY_LEADERBOARD": "Пока нет участников.", + "ADMIN_DAILY_CONTESTS_TITLE": "📆 Ежедневные конкурсы", + "ADMIN_EDIT_PRIZE": "🏅 Приз (дни)", + "ADMIN_EDIT_MAX_WINNERS": "👥 Победителей", + "ADMIN_EDIT_ATTEMPTS": "🔁 Попытки", + "ADMIN_EDIT_TIMES": "⏰ Раундов/день", + "ADMIN_EDIT_SCHEDULE": "🕒 Расписание", + "ADMIN_EDIT_COOLDOWN": "⌛ Длительность", + "ADMIN_EDIT_PAYLOAD": "🧩 Payload", + "ADMIN_CONTEST_FIELD_PROMPT": "Введите новое значение для {label}:", + "ADMIN_CONTEST_PAYLOAD_PROMPT": "Отправьте JSON payload для игры (словарь настроек):", + "ADMIN_ROUND_STARTED": "Раунд запущен", + "ADMIN_UPDATED": "Обновлено", + "ADMIN_INVALID_NUMBER": "Некорректное число", + "ADMIN_INVALID_JSON": "Некорректный JSON", + "CONTEST_MENU_TITLE": "🎲 Игры/Конкурсы\nВыберите игру:", + "CONTEST_EMPTY": "Сейчас игр нет", + "CONTEST_NOT_ELIGIBLE": "Игры доступны только с активной или триальной подпиской.", + "CONTEST_ROUND_FINISHED": "Раунд завершён или недоступен.", + "CONTEST_ALREADY_PLAYED": "У вас уже была попытка в этом раунде.", + "CONTEST_UNKNOWN": "Тип конкурса не поддерживается.", + "CONTEST_QUEST_PROMPT": "Выбери один из узлов 3×3:", + "CONTEST_LOCKS_PROMPT": "Найди взломанную кнопку среди замков:", + "CONTEST_SERVER_PROMPT": "Выбери сервер:", + "CONTEST_CIPHER_PROMPT": "Расшифруй: {q}", + "CONTEST_EMOJI_PROMPT": "Угадай сервис по эмодзи: {q}", + "CONTEST_ANAGRAM_PROMPT": "Составь слово: {letters}", + "CONTEST_BLITZ_PROMPT": "⚡️ Блиц! Нажми «Я здесь!»", + "CONTEST_BLITZ_BUTTON": "Я здесь!", + "CONTEST_WIN": "🎉 Победа! ", + "CONTEST_LOSE": "Не верно, попробуй снова в следующем раунде.", + "CONTEST_PRIZE_GRANTED": "Бонус {days} дней зачислен!", "ADMIN_CANCEL": "❌ Отмена", "ADMIN_COMMUNICATIONS_MENU_MESSAGES": "📢 Сообщения в меню", "ADMIN_COMMUNICATIONS_PROMO_OFFERS": "🎯 Промо-предложения", diff --git a/app/services/contest_rotation_service.py b/app/services/contest_rotation_service.py new file mode 100644 index 00000000..86411d9e --- /dev/null +++ b/app/services/contest_rotation_service.py @@ -0,0 +1,247 @@ +import asyncio +import logging +import random +from datetime import datetime, timedelta, time, timezone +from typing import Dict, List, Optional + +from aiogram import Bot +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.contest import ( + create_round, + get_active_round_by_template, + list_templates, + upsert_template, +) +from app.database.database import AsyncSessionLocal +from app.database.models import ContestTemplate + +logger = logging.getLogger(__name__) + +# Slugs for games +GAME_QUEST = "quest_buttons" +GAME_LOCKS = "lock_hack" +GAME_CIPHER = "letter_cipher" +GAME_SERVER = "server_lottery" +GAME_BLITZ = "blitz_reaction" +GAME_EMOJI = "emoji_guess" +GAME_ANAGRAM = "anagram" + + +DEFAULT_TEMPLATES = [ + { + "slug": GAME_QUEST, + "name": "Квест-кнопки", + "description": "Найди секретную кнопку 3×3", + "prize_days": 1, + "max_winners": 3, + "attempts_per_user": 1, + "times_per_day": 2, + "schedule_times": "10:00,18:00", + "payload": {"rows": 3, "cols": 3}, + }, + { + "slug": GAME_LOCKS, + "name": "Кнопочный взлом", + "description": "Найди взломанную кнопку среди 20 замков", + "prize_days": 5, + "max_winners": 1, + "attempts_per_user": 1, + "times_per_day": 2, + "schedule_times": "09:00,19:00", + "payload": {"buttons": 20}, + }, + { + "slug": GAME_CIPHER, + "name": "Шифр букв", + "description": "Расшифруй слово по номерам", + "prize_days": 1, + "max_winners": 1, + "attempts_per_user": 1, + "times_per_day": 2, + "schedule_times": "12:00,20:00", + "payload": {"words": ["VPN", "SERVER", "PROXY", "XRAY"]}, + }, + { + "slug": GAME_SERVER, + "name": "Сервер-лотерея", + "description": "Угадай доступный сервер", + "prize_days": 7, + "max_winners": 1, + "attempts_per_user": 1, + "times_per_day": 1, + "schedule_times": "15:00", + "payload": {"flags": ["🇸🇪","🇸🇬","🇺🇸","🇷🇺","🇩🇪","🇯🇵","🇧🇷","🇦🇺","🇨🇦","🇫🇷"]}, + }, + { + "slug": GAME_BLITZ, + "name": "Блиц-реакция", + "description": "Нажми кнопку за 10 секунд", + "prize_days": 1, + "max_winners": 1, + "attempts_per_user": 1, + "times_per_day": 2, + "schedule_times": "11:00,21:00", + "payload": {"timeout_seconds": 10}, + }, + { + "slug": GAME_EMOJI, + "name": "Угадай сервис по эмодзи", + "description": "Определи сервис по эмодзи", + "prize_days": 1, + "max_winners": 1, + "attempts_per_user": 1, + "times_per_day": 1, + "schedule_times": "13:00", + "payload": {"pairs": [{"question": "🔐📡🌐", "answer": "VPN"}]}, + }, + { + "slug": GAME_ANAGRAM, + "name": "Анаграмма дня", + "description": "Собери слово из букв", + "prize_days": 1, + "max_winners": 1, + "attempts_per_user": 1, + "times_per_day": 1, + "schedule_times": "17:00", + "payload": {"words": ["SERVER", "XRAY", "VPN"]}, + }, +] + + +class ContestRotationService: + def __init__(self) -> None: + self.bot: Optional[Bot] = None + self._task: Optional[asyncio.Task] = None + self._interval_seconds = 60 + + def is_running(self) -> bool: + return self._task is not None and not self._task.done() + + def set_bot(self, bot: Bot) -> None: + self.bot = bot + + async def start(self) -> None: + await self.stop() + + if not settings.is_contests_enabled(): + logger.info("Сервис игр отключён настройками") + return + + await self._ensure_default_templates() + + self._task = asyncio.create_task(self._loop()) + logger.info("🎲 Сервис ротационных конкурсов запущен") + + async def stop(self) -> None: + if self._task and not self._task.done(): + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + async def _ensure_default_templates(self) -> None: + async with AsyncSessionLocal() as db: + for tpl in DEFAULT_TEMPLATES: + try: + await upsert_template(db, **tpl) + except Exception as exc: + logger.error("Не удалось создать шаблон %s: %s", tpl["slug"], exc) + + async def _loop(self) -> None: + try: + while True: + try: + await self._tick() + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 + logger.error("Ошибка в ротации конкурсов: %s", exc) + await asyncio.sleep(self._interval_seconds) + except asyncio.CancelledError: + logger.info("Сервис ротации конкурсов остановлен") + raise + + def _parse_times(self, times_str: Optional[str]) -> List[time]: + if not times_str: + return [] + times: List[time] = [] + for part in times_str.split(","): + part = part.strip() + if not part: + continue + try: + hh, mm = part.split(":") + times.append(time(int(hh), int(mm))) + except Exception: + continue + return times + + async def _tick(self) -> None: + async with AsyncSessionLocal() as db: + templates = await list_templates(db) + now_local = datetime.now().astimezone(timezone.utc) + for tpl in templates: + times = self._parse_times(tpl.schedule_times) or [] + for slot in times[: tpl.times_per_day]: + starts_at_local = now_local.replace( + hour=slot.hour, minute=slot.minute, second=0, microsecond=0 + ) + if starts_at_local > now_local: + starts_at_local -= timedelta(days=1) + ends_at_local = starts_at_local + timedelta(hours=tpl.cooldown_hours) + if not (starts_at_local <= now_local <= ends_at_local): + continue + + exists = await get_active_round_by_template(db, tpl.id) + if exists: + continue + payload = self._build_payload_for_template(tpl) + round_obj = await create_round( + db, + template=tpl, + starts_at=starts_at_local.replace(tzinfo=None), + ends_at=ends_at_local.replace(tzinfo=None), + payload=payload, + ) + logger.info("Создан раунд %s для шаблона %s", round_obj.id, tpl.slug) + + def _build_payload_for_template(self, tpl: ContestTemplate) -> Dict: + payload = tpl.payload or {} + if tpl.slug == GAME_QUEST: + rows = payload.get("rows", 3) + cols = payload.get("cols", 3) + total = rows * cols + secret_idx = random.randint(0, total - 1) + return {"rows": rows, "cols": cols, "secret_idx": secret_idx} + if tpl.slug == GAME_LOCKS: + total = payload.get("buttons", 20) + secret_idx = random.randint(0, max(0, total - 1)) + return {"total": total, "secret_idx": secret_idx} + if tpl.slug == GAME_CIPHER: + words = payload.get("words") or ["VPN"] + word = random.choice(words) + codes = [str(ord(ch.upper()) - 64) for ch in word if ch.isalpha()] + return {"question": "-".join(codes), "answer": word.upper()} + if tpl.slug == GAME_SERVER: + flags = payload.get("flags") or ["🇸🇪","🇸🇬","🇺🇸","🇷🇺","🇩🇪","🇯🇵","🇧🇷","🇦🇺","🇨🇦","🇫🇷"] + secret_idx = random.randint(0, len(flags) - 1) + return {"flags": flags, "secret_idx": secret_idx} + if tpl.slug == GAME_BLITZ: + return {"timeout_seconds": payload.get("timeout_seconds", 10)} + if tpl.slug == GAME_EMOJI: + pairs = payload.get("pairs") or [{"question": "🔐📡🌐", "answer": "VPN"}] + pair = random.choice(pairs) + return pair + if tpl.slug == GAME_ANAGRAM: + words = payload.get("words") or ["SERVER"] + word = random.choice(words).upper() + shuffled = "".join(random.sample(word, len(word))) + return {"letters": shuffled, "answer": word} + return payload + + +contest_rotation_service = ContestRotationService() diff --git a/app/services/referral_contest_service.py b/app/services/referral_contest_service.py new file mode 100644 index 00000000..77a94614 --- /dev/null +++ b/app/services/referral_contest_service.py @@ -0,0 +1,479 @@ +import asyncio +import logging +from datetime import datetime, date, time, timedelta, timezone +from typing import Optional, Sequence, Tuple +from zoneinfo import ZoneInfo + +from aiogram import Bot +from aiogram.exceptions import TelegramForbiddenError, TelegramNotFound +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.referral_contest import ( + add_contest_event, + get_contest_events_count, + get_contest_leaderboard, + get_contests_for_events, + get_contests_for_summaries, + get_referrer_score, + mark_daily_summary_sent, + mark_final_summary_sent, +) +from app.database.crud.user import get_user_by_id +from app.database.database import AsyncSessionLocal +from app.database.models import ReferralContest, User + +logger = logging.getLogger(__name__) + + +class ReferralContestService: + def __init__(self) -> None: + self.bot: Optional[Bot] = None + self._task: Optional[asyncio.Task] = None + self._poll_interval_seconds = 60 + + def set_bot(self, bot: Bot) -> None: + self.bot = bot + + def is_running(self) -> bool: + return self._task is not None and not self._task.done() + + async def start(self) -> None: + await self.stop() + + if not settings.is_contests_enabled(): + logger.info("Сервис конкурсов отключен настройками") + return + + if not self.bot: + logger.warning("Невозможно запустить сервис конкурсов без экземпляра бота") + return + + self._task = asyncio.create_task(self._run_loop()) + logger.info("🏆 Сервис конкурсов запущен") + + async def stop(self) -> None: + if self._task and not self._task.done(): + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + async def _run_loop(self) -> None: + try: + while True: + try: + await self._process_summaries() + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 + logger.error("Ошибка сервиса конкурсов: %s", exc) + + await asyncio.sleep(self._poll_interval_seconds) + except asyncio.CancelledError: + logger.info("Сервис конкурсов остановлен") + raise + + async def _process_summaries(self) -> None: + if not self.bot: + return + + async with AsyncSessionLocal() as db: + contests = await get_contests_for_summaries(db) + now_utc = datetime.utcnow() + + for contest in contests: + try: + await self._maybe_send_daily_summary(db, contest, now_utc) + await self._maybe_send_final_summary(db, contest, now_utc) + except asyncio.CancelledError: + raise + except Exception as exc: # noqa: BLE001 + logger.error( + "Ошибка обработки конкурса %s (%s): %s", + contest.id, + contest.title, + exc, + ) + + async def _maybe_send_daily_summary( + self, + db: AsyncSession, + contest: ReferralContest, + now_utc: datetime, + ) -> None: + tz = self._get_timezone(contest) + now_local = now_utc.replace(tzinfo=timezone.utc).astimezone(tz) + start_local = self._to_local(contest.start_at, tz) + end_local = self._to_local(contest.end_at, tz) + + summary_time = contest.daily_summary_time or time(hour=12, minute=0) + summary_dt = datetime.combine(now_local.date(), summary_time, tzinfo=tz) + + if now_local.date() < start_local.date() or now_local.date() > end_local.date(): + return + + if now_local < summary_dt: + return + + if contest.last_daily_summary_date == now_local.date(): + return + + await self._send_summary(db, contest, now_utc, now_local.date(), is_final=False) + + async def _maybe_send_final_summary( + self, + db: AsyncSession, + contest: ReferralContest, + now_utc: datetime, + ) -> None: + if contest.final_summary_sent: + return + + tz = self._get_timezone(contest) + end_local = self._to_local(contest.end_at, tz) + summary_time = contest.daily_summary_time or time(hour=12, minute=0) + summary_dt = datetime.combine(end_local.date(), summary_time, tzinfo=tz) + summary_dt_utc = summary_dt.astimezone(timezone.utc).replace(tzinfo=None) + + if now_utc < contest.end_at: + return + + if now_utc < summary_dt_utc: + return + + await self._send_summary(db, contest, now_utc, end_local.date(), is_final=True) + + async def _send_summary( + self, + db: AsyncSession, + contest: ReferralContest, + now_utc: datetime, + target_date: date, + *, + is_final: bool, + ) -> None: + tz = self._get_timezone(contest) + day_start_local = datetime.combine(target_date, time.min, tzinfo=tz) + day_end_local = day_start_local + timedelta(days=1) + day_start_utc = day_start_local.astimezone(timezone.utc).replace(tzinfo=None) + day_end_utc = day_end_local.astimezone(timezone.utc).replace(tzinfo=None) + + leaderboard = list(await get_contest_leaderboard(db, contest.id)) + total_events = await get_contest_events_count(db, contest.id) + today_events = await get_contest_events_count( + db, + contest.id, + start=day_start_utc, + end=day_end_utc, + ) + + await self._notify_admins( + contest=contest, + leaderboard=leaderboard, + total_events=total_events, + today_events=today_events, + is_final=is_final, + tz=tz, + ) + + await self._notify_public_channel( + contest=contest, + leaderboard=leaderboard, + total_events=total_events, + today_events=today_events, + is_final=is_final, + tz=tz, + ) + + if not leaderboard: + logger.info("Конкурс %s: пока нет участников", contest.id) + + if is_final: + await mark_final_summary_sent(db, contest) + else: + await mark_daily_summary_sent(db, contest, target_date) + + async def _notify_participants( + self, + db: AsyncSession, + *, + contest: ReferralContest, + leaderboard: Sequence[Tuple[User, int, int]], + total_events: int, + today_events: int, + day_start_utc: datetime, + day_end_utc: datetime, + is_final: bool, + ) -> None: + if not self.bot: + return + + # leaderboard already sorted by helper + score_map = {user.id: (idx + 1, score) for idx, (user, score, _) in enumerate(leaderboard)} + + for user, score, _ in leaderboard: + rank = score_map.get(user.id, (None, score))[0] + today_score = await get_referrer_score( + db=db, + contest_id=contest.id, + referrer_id=user.id, + start=day_start_utc, + end=day_end_utc, + ) if score else 0 + + text = self._build_participant_message( + contest=contest, + rank=rank or 0, + score=score, + today_score=today_score, + total_events=total_events, + today_events=today_events, + is_final=is_final, + ) + + try: + await self.bot.send_message(user.telegram_id, text, disable_web_page_preview=True) + except (TelegramForbiddenError, TelegramNotFound): + logger.info( + "Не удалось отправить сообщение участнику %s (вероятно, блокировка)", + user.telegram_id, + ) + except Exception as exc: # noqa: BLE001 + logger.error("Ошибка отправки участнику конкурса %s: %s", user.telegram_id, exc) + + async def _notify_admins( + self, + *, + contest: ReferralContest, + leaderboard: Sequence[Tuple[User, int, int]], + total_events: int, + today_events: int, + is_final: bool, + tz: ZoneInfo, + ) -> None: + if not self.bot: + return + + chat_id = settings.ADMIN_NOTIFICATIONS_CHAT_ID + if not chat_id: + return + + lines = [ + "🏆 Конкурс рефералов", + f"Название: {contest.title}", + f"Статус: {'финал' if is_final else 'дневная сводка'}", + f"Временная зона: {tz.key}", + f"Зачётов всего: {total_events}, сегодня: {today_events}", + "", + "Топ участников:", + ] + + if leaderboard: + for idx, (user, score, _) in enumerate(leaderboard[:5], start=1): + name = user.full_name + lines.append(f"{idx}. {name} — {score}") + else: + lines.append("Пока нет участников.") + + if contest.prize_text: + lines.append("") + lines.append(f"Приз: {contest.prize_text}") + + try: + await self.bot.send_message( + chat_id=chat_id, + text="\n".join(lines), + disable_web_page_preview=True, + message_thread_id=settings.ADMIN_NOTIFICATIONS_TOPIC_ID, + ) + except Exception as exc: # noqa: BLE001 + logger.error("Не удалось отправить админскую сводку конкурса: %s", exc) + + async def _notify_public_channel( + self, + *, + contest: ReferralContest, + leaderboard: Sequence[Tuple[User, int, int]], + total_events: int, + today_events: int, + is_final: bool, + tz: ZoneInfo, + ) -> None: + if not self.bot: + return + + channel_id_raw = settings.CHANNEL_SUB_ID + if not channel_id_raw: + return + + try: + channel_id = int(channel_id_raw) + except Exception: + channel_id = channel_id_raw + + lines = [ + f"🏆 {contest.title}", + "🏁 Итоги конкурса" if is_final else "📊 Промежуточные итоги", + f"Время зоны: {tz.key}", + f"Зачётов всего: {total_events} • Сегодня: {today_events}", + "", + "Топ участников:", + ] + + if leaderboard: + for idx, (user, score, _) in enumerate(leaderboard[:5], start=1): + lines.append(f"{idx}. {user.full_name} — {score}") + else: + lines.append("Пока нет участников.") + + if contest.prize_text: + lines.append("") + lines.append(f"Приз: {contest.prize_text}") + + try: + await self.bot.send_message( + chat_id=channel_id, + text="\n".join(lines), + disable_web_page_preview=True, + ) + except (TelegramForbiddenError, TelegramNotFound): + logger.info("Не удалось отправить сводку конкурса в канал %s", channel_id_raw) + except Exception as exc: # noqa: BLE001 + logger.error("Ошибка отправки сводки конкурса в канал %s: %s", channel_id_raw, exc) + + def _build_participant_message( + self, + *, + contest: ReferralContest, + rank: int, + score: int, + today_score: int, + total_events: int, + today_events: int, + is_final: bool, + ) -> str: + status_line = "🏁 Итоги конкурса" if is_final else "📊 Промежуточные итоги" + lines = [ + f"🏆 {contest.title}", + status_line, + "", + f"Ваше место: {rank}", + f"Зачётов за всё время: {score}", + f"За сегодня: {today_score}", + f"Общий пул зачётов: {total_events} (сегодня {today_events})", + ] + + if contest.prize_text: + lines.append("") + lines.append(f"Призовой фонд: {contest.prize_text}") + + if not is_final: + remaining = contest.end_at - datetime.now(timezone.utc) + if remaining.total_seconds() > 0: + hours_left = int(remaining.total_seconds() // 3600) + lines.append("") + lines.append(f"До окончания: ~{hours_left} ч.") + + return "\n".join(lines) + + def _get_timezone(self, contest: ReferralContest) -> ZoneInfo: + tz_name = contest.timezone or settings.TIMEZONE + try: + return ZoneInfo(tz_name) + except Exception: # noqa: BLE001 + logger.warning("Не удалось загрузить TZ %s, используем UTC", tz_name) + return ZoneInfo("UTC") + + def _to_local(self, dt_value: datetime, tz: ZoneInfo) -> datetime: + base = dt_value + if dt_value.tzinfo is None: + base = dt_value.replace(tzinfo=timezone.utc) + return base.astimezone(tz) + + async def on_subscription_payment( + self, + db: AsyncSession, + user_id: int, + amount_kopeks: int = 0, + ) -> None: + if not settings.is_contests_enabled(): + return + + user = await get_user_by_id(db, user_id) + if not user or not user.referred_by_id: + return + + now_utc = datetime.utcnow() + contests = await get_contests_for_events( + db, + now_utc, + contest_types=["referral_paid"], + ) + if not contests: + return + + for contest in contests: + try: + event = await add_contest_event( + db, + contest_id=contest.id, + referrer_id=user.referred_by_id, + referral_id=user.id, + amount_kopeks=amount_kopeks, + event_type="subscription_purchase", + ) + if event: + logger.info( + "Записан зачёт конкурса %s: реферер %s, реферал %s", + contest.id, + user.referred_by_id, + user.id, + ) + except Exception as exc: # noqa: BLE001 + logger.error("Не удалось записать зачёт конкурса %s: %s", contest.id, exc) + + async def on_referral_registration( + self, + db: AsyncSession, + user_id: int, + ) -> None: + if not settings.is_contests_enabled(): + return + + user = await get_user_by_id(db, user_id) + if not user or not user.referred_by_id: + return + + now_utc = datetime.utcnow() + contests = await get_contests_for_events( + db, + now_utc, + contest_types=["referral_registered"], + ) + if not contests: + return + + for contest in contests: + try: + event = await add_contest_event( + db, + contest_id=contest.id, + referrer_id=user.referred_by_id, + referral_id=user.id, + amount_kopeks=0, + event_type="referral_registration", + ) + if event: + logger.info( + "Записан зачёт конкурса регистрации %s: реферер %s, реферал %s", + contest.id, + user.referred_by_id, + user.id, + ) + except Exception as exc: # noqa: BLE001 + logger.error("Не удалось записать зачёт регистрации для конкурса %s: %s", contest.id, exc) + +referral_contest_service = ReferralContestService() diff --git a/app/services/referral_service.py b/app/services/referral_service.py index 5324a775..1d5761a5 100644 --- a/app/services/referral_service.py +++ b/app/services/referral_service.py @@ -50,6 +50,13 @@ async def process_referral_registration( reason="referral_registration_pending" ) + try: + from app.services.referral_contest_service import referral_contest_service + + await referral_contest_service.on_referral_registration(db, new_user_id) + except Exception as exc: + logger.debug("Не удалось записать конкурсную регистрацию: %s", exc) + if bot: commission_percent = get_effective_referral_commission_percent(referrer) referral_notification = ( diff --git a/app/states.py b/app/states.py index 97d24733..84950fa0 100644 --- a/app/states.py +++ b/app/states.py @@ -90,6 +90,16 @@ class AdminStates(StatesGroup): editing_promo_group_device_discount = State() editing_promo_group_period_discount = State() editing_promo_group_auto_assign = State() + + creating_referral_contest_title = State() + creating_referral_contest_description = State() + creating_referral_contest_prize = State() + creating_referral_contest_mode = State() + creating_referral_contest_start = State() + creating_referral_contest_end = State() + creating_referral_contest_time = State() + editing_daily_contest_field = State() + editing_daily_contest_value = State() editing_squad_price = State() editing_traffic_price = State() @@ -190,6 +200,9 @@ class SquadMigrationStates(StatesGroup): class RemnaWaveSyncStates(StatesGroup): waiting_for_schedule = State() +class ContestStates(StatesGroup): + waiting_for_answer = State() + class AdminSubmenuStates(StatesGroup): in_users_submenu = State() diff --git a/app/utils/decorators.py b/app/utils/decorators.py index 1f8db55a..9d044654 100644 --- a/app/utils/decorators.py +++ b/app/utils/decorators.py @@ -45,6 +45,23 @@ def admin_required(func: Callable) -> Callable: return wrapper +def auth_required(func: Callable) -> Callable: + """ + Простая проверка на наличие пользователя в апдейте. Middleware уже подтягивает db_user, + но здесь страхуемся от вызовов без from_user. + """ + @functools.wraps(func) + async def wrapper(event: types.Update, *args, **kwargs) -> Any: + user = None + if isinstance(event, (types.Message, types.CallbackQuery)): + user = event.from_user + if not user: + logger.warning("auth_required: нет from_user, пропускаем") + return + return await func(event, *args, **kwargs) + return wrapper + + def error_handler(func: Callable) -> Callable: @functools.wraps(func) diff --git a/docker-compose.yml b/docker-compose.yml index c62611f2..71c766bd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: volumes: - postgres_data:/var/lib/postgresql/data networks: - - bot_network + - remnawave-network healthcheck: test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-remnawave_user} -d ${POSTGRES_DB:-remnawave_bot}"] interval: 30s @@ -27,7 +27,7 @@ services: volumes: - redis_data:/data networks: - - bot_network + - remnawave-network healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 30s @@ -74,7 +74,7 @@ services: ports: - "${WEB_API_PORT:-8080}:8080" networks: - - bot_network + - remnawave-network healthcheck: test: ["CMD-SHELL", "python -c \"import requests, os; requests.get('http://localhost:8080/health', headers={'X-API-Key': os.environ.get('WEB_API_DEFAULT_TOKEN')}, timeout=5) or exit(1)\""] interval: 60s @@ -89,9 +89,7 @@ volumes: driver: local networks: - bot_network: - driver: bridge - ipam: - config: - - subnet: 172.20.0.0/16 - gateway: 172.20.0.1 + remnawave-network: + name: remnawave-network + driver: bridge + external: true diff --git a/main.py b/main.py index 161d6147..fa714e12 100644 --- a/main.py +++ b/main.py @@ -32,6 +32,8 @@ from app.localization.loader import ensure_locale_templates from app.services.system_settings_service import bot_configuration_service from app.services.external_admin_service import ensure_external_admin_token from app.services.broadcast_service import broadcast_service +from app.services.referral_contest_service import referral_contest_service +from app.services.contest_rotation_service import contest_rotation_service from app.utils.startup_timeline import StartupTimeline from app.utils.timezone import TimezoneAwareFormatter @@ -174,6 +176,7 @@ async def main(): admin_notification_service = AdminNotificationService(bot) version_service.bot = bot version_service.set_notification_service(admin_notification_service) + referral_contest_service.set_bot(bot) stage.log(f"Репозиторий версий: {version_service.repo}") stage.log(f"Текущая версия: {version_service.current_version}") stage.success("Мониторинг, уведомления и рассылки подключены") @@ -211,6 +214,37 @@ async def main(): stage.warning(f"Ошибка запуска сервиса отчетов: {e}") logger.error(f"❌ Ошибка запуска сервиса отчетов: {e}") + async with timeline.stage( + "Реферальные конкурсы", + "🏆", + success_message="Сервис конкурсов готов", + ) as stage: + try: + await referral_contest_service.start() + if referral_contest_service.is_running(): + stage.log("Автосводки по конкурсам запущены") + else: + stage.skip("Сервис конкурсов выключен настройками") + except Exception as e: + stage.warning(f"Ошибка запуска сервиса конкурсов: {e}") + logger.error(f"❌ Ошибка запуска сервиса конкурсов: {e}") + + async with timeline.stage( + "Ротация игр", + "🎲", + success_message="Мини-игры готовы", + ) as stage: + try: + contest_rotation_service.set_bot(bot) + await contest_rotation_service.start() + if contest_rotation_service.is_running(): + stage.log("Ротационные игры запущены") + else: + stage.skip("Ротация игр выключена настройками") + except Exception as e: + stage.warning(f"Ошибка запуска ротации игр: {e}") + logger.error(f"❌ Ошибка запуска ротации игр: {e}") + async with timeline.stage( "Автосинхронизация RemnaWave", "🔄", @@ -594,12 +628,24 @@ async def main(): except Exception as e: logger.error(f"Ошибка остановки сервиса отчетов: {e}") + logger.info("ℹ️ Остановка сервиса конкурсов...") + try: + await referral_contest_service.stop() + except Exception as e: + logger.error(f"Ошибка остановки сервиса конкурсов: {e}") + logger.info("ℹ️ Остановка сервиса автосинхронизации RemnaWave...") try: await remnawave_sync_service.stop() except Exception as e: logger.error(f"Ошибка остановки автосинхронизации RemnaWave: {e}") + logger.info("ℹ️ Остановка ротации игр...") + try: + await contest_rotation_service.stop() + except Exception as e: + logger.error(f"Ошибка остановки ротации игр: {e}") + logger.info("ℹ️ Остановка сервиса бекапов...") try: await backup_service.stop_auto_backup() diff --git a/vpn_logo.png b/vpn_logo.png index ff499bf7..d80d5bca 100644 Binary files a/vpn_logo.png and b/vpn_logo.png differ