Конкурсы

This commit is contained in:
gy9vin
2025-12-14 01:38:22 +03:00
parent 9df8ca52d6
commit 1409a0ab8d
23 changed files with 3444 additions and 11 deletions

View File

@@ -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

View File

@@ -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 платежей")

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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"<ReferralContest id={self.id} title='{self.title}'>"
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"<ReferralContestEvent contest={self.contest_id} "
f"referrer={self.referrer_id} referral={self.referral_id}>"
)
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"

View File

@@ -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",

View File

@@ -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,
)
)

View File

@@ -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"Период: <b>{period}</b>",
f"Дневная сводка: <b>{summary_time}</b>",
]
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", "🏆 <b>Конкурсы</b>\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", "🏆 <b>Конкурсы</b>\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", "🏆 <b>Конкурсы</b>\n")]
if not contests:
lines.append(texts.t("ADMIN_CONTESTS_EMPTY", "Пока нет созданных конкурсов."))
else:
for contest in contests:
lines.append(f"• <b>{contest.title}</b> (#{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"🏆 <b>{contest.title}</b>",
_format_contest_summary(contest, texts, tz),
texts.t("ADMIN_CONTEST_TOTAL_EVENTS", "Зачётов: <b>{count}</b>").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)

View File

@@ -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} <b>{tpl.name}</b> (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"🏷 <b>{tpl.name}</b> (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"<code>{payload_json}</code>",
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)

373
app/handlers/contests.py Normal file
View File

@@ -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", "🎲 <b>Игры/Конкурсы</b>\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

View File

@@ -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:

View File

@@ -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": "🏆 <b>Contests</b>\n",
"ADMIN_CONTESTS_EMPTY": "No contests yet.",
"ADMIN_CONTESTS_TITLE": "🏆 <b>Contests</b>\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: <b>{count}</b>",
"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": "🎲 <b>Games/Contests</b>\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",

View File

@@ -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": "🏆 <b>Конкурсы</b>\n",
"ADMIN_CONTESTS_EMPTY": "Пока нет созданных конкурсов.",
"ADMIN_CONTESTS_TITLE": "🏆 <b>Конкурсы</b>\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": "Зачётов: <b>{count}</b>",
"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": "🎲 <b>Игры/Конкурсы</b>\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": "🎯 Промо-предложения",

View File

@@ -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()

View File

@@ -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 = [
"🏆 <b>Конкурс рефералов</b>",
f"Название: <b>{contest.title}</b>",
f"Статус: {'финал' if is_final else 'дневная сводка'}",
f"Временная зона: <code>{tz.key}</code>",
f"Зачётов всего: <b>{total_events}</b>, сегодня: <b>{today_events}</b>",
"",
"Топ участников:",
]
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"Зачётов всего: <b>{total_events}</b> • Сегодня: <b>{today_events}</b>",
"",
"Топ участников:",
]
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"Ваше место: <b>{rank}</b>",
f"Зачётов за всё время: <b>{score}</b>",
f"За сегодня: <b>{today_score}</b>",
f"Общий пул зачётов: <b>{total_events}</b> (сегодня {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()

View File

@@ -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 = (

View File

@@ -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()

View File

@@ -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)

View File

@@ -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

46
main.py
View File

@@ -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()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 716 KiB

After

Width:  |  Height:  |  Size: 182 KiB