This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import random
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.lottery import Lottery, LotteryStatus
|
||||
from app.models.participant import Participant
|
||||
from app.models.prize import Prize
|
||||
from app.models.user import User
|
||||
from app.models.winner import Winner
|
||||
|
||||
|
||||
async def execute_draw(db: AsyncSession, lottery_id: uuid.UUID, trigger: str = "manual") -> list[dict]:
|
||||
"""
|
||||
Draw winners by randomly pairing shuffled participants with shuffled prizes.
|
||||
Both lists are shuffled independently so prize order has no bearing on results.
|
||||
n_winners = min(participants, total prize slots).
|
||||
|
||||
Uses SELECT FOR UPDATE to prevent concurrent draws from producing duplicate
|
||||
winner sets. The status is transitioned to DRAWING inside the lock before
|
||||
any reads, so a second concurrent request will fail the status check.
|
||||
"""
|
||||
# Lock the lottery row - serialises all concurrent draw requests
|
||||
lottery_result = await db.execute(
|
||||
select(Lottery).where(Lottery.id == lottery_id).with_for_update()
|
||||
)
|
||||
lottery = lottery_result.scalar_one()
|
||||
|
||||
# Re-check status under lock; may have changed since the router pre-checked
|
||||
if lottery.status != LotteryStatus.ACTIVE:
|
||||
raise ValueError("Lottery is no longer active")
|
||||
|
||||
# Mark as DRAWING immediately so any concurrent request sees the changed
|
||||
# status and aborts before we even touch participants/prizes
|
||||
lottery.status = LotteryStatus.DRAWING
|
||||
await db.flush()
|
||||
|
||||
# Load participants with user info
|
||||
result = await db.execute(
|
||||
select(Participant, User)
|
||||
.join(User, Participant.user_id == User.id)
|
||||
.where(Participant.lottery_id == lottery_id)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
if not rows:
|
||||
# Roll back the DRAWING status so the lottery stays usable
|
||||
lottery.status = LotteryStatus.ACTIVE
|
||||
await db.commit()
|
||||
raise ValueError("No participants in this lottery")
|
||||
|
||||
# Load prizes
|
||||
prize_result = await db.execute(
|
||||
select(Prize).where(Prize.lottery_id == lottery_id)
|
||||
)
|
||||
prizes = prize_result.scalars().all()
|
||||
|
||||
if not prizes:
|
||||
lottery.status = LotteryStatus.ACTIVE
|
||||
await db.commit()
|
||||
raise ValueError("No prizes configured")
|
||||
|
||||
# Expand prizes by quantity: Prize(qty=3) → [Prize, Prize, Prize]
|
||||
expanded_prizes: list[Prize] = []
|
||||
for prize in prizes:
|
||||
expanded_prizes.extend([prize] * prize.quantity)
|
||||
|
||||
random.shuffle(expanded_prizes)
|
||||
now = datetime.utcnow()
|
||||
|
||||
winners_data = []
|
||||
if lottery.allow_multiple_wins:
|
||||
# Draw with replacement: all prize slots awarded, same participant can win multiple
|
||||
for prize in expanded_prizes:
|
||||
participant, user = random.choice(rows)
|
||||
db.add(Winner(
|
||||
lottery_id=lottery_id,
|
||||
user_id=user.id,
|
||||
prize_id=prize.id,
|
||||
prize_name=prize.name,
|
||||
drawn_at=now,
|
||||
))
|
||||
winners_data.append({
|
||||
"user_id": user.id,
|
||||
"username": user.username,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"prize_name": prize.name,
|
||||
"drawn_at": now,
|
||||
})
|
||||
else:
|
||||
# No repeat wins: shuffle participants and pair 1-to-1 with prizes
|
||||
random.shuffle(rows)
|
||||
n_winners = min(len(rows), len(expanded_prizes))
|
||||
for i in range(n_winners):
|
||||
participant, user = rows[i]
|
||||
prize = expanded_prizes[i]
|
||||
db.add(Winner(
|
||||
lottery_id=lottery_id,
|
||||
user_id=user.id,
|
||||
prize_id=prize.id,
|
||||
prize_name=prize.name,
|
||||
drawn_at=now,
|
||||
))
|
||||
winners_data.append({
|
||||
"user_id": user.id,
|
||||
"username": user.username,
|
||||
"first_name": user.first_name,
|
||||
"last_name": user.last_name,
|
||||
"prize_name": prize.name,
|
||||
"drawn_at": now,
|
||||
})
|
||||
|
||||
lottery.status = LotteryStatus.FINISHED
|
||||
lottery.drawn_at = now
|
||||
lottery.draw_trigger = trigger
|
||||
|
||||
await db.commit()
|
||||
return winners_data
|
||||
@@ -0,0 +1,53 @@
|
||||
import logging
|
||||
from aiogram.exceptions import TelegramAPIError, TelegramBadRequest, TelegramForbiddenError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Errors that mean the user is definitively NOT in the chat
|
||||
_NOT_MEMBER_PHRASES = (
|
||||
"user not found",
|
||||
"participant not found",
|
||||
"user_not_participant",
|
||||
"chat not found",
|
||||
"bot was kicked",
|
||||
"bot is not a member",
|
||||
)
|
||||
|
||||
# Errors that mean we simply can't verify (privacy / anonymous mode) - allow through
|
||||
_UNVERIFIABLE_PHRASES = (
|
||||
"user_id_invalid",
|
||||
"have no rights",
|
||||
"not enough rights",
|
||||
"administrators only",
|
||||
)
|
||||
|
||||
|
||||
async def check_membership(chat_id: int, user_id: int) -> bool:
|
||||
"""Check if a user is an active member of a Telegram chat.
|
||||
|
||||
Returns True when membership is confirmed OR when the check cannot be
|
||||
performed due to anonymous/privacy settings (bot has admin rights but
|
||||
Telegram hides the member - treat as verified to avoid false rejections).
|
||||
"""
|
||||
from app.bot.router import bot
|
||||
|
||||
try:
|
||||
member = await bot.get_chat_member(chat_id, user_id)
|
||||
return member.status in ("member", "administrator", "creator", "restricted")
|
||||
except TelegramBadRequest as e:
|
||||
msg = str(e).lower()
|
||||
if any(p in msg for p in _NOT_MEMBER_PHRASES):
|
||||
return False
|
||||
if any(p in msg for p in _UNVERIFIABLE_PHRASES):
|
||||
logger.debug("Membership unverifiable for user %s in chat %s (%s) - allowing", user_id, chat_id, e)
|
||||
return True
|
||||
# Unknown bad request - conservative deny
|
||||
logger.warning("getChatMember bad request for user %s in chat %s: %s", user_id, chat_id, e)
|
||||
return False
|
||||
except TelegramForbiddenError as e:
|
||||
# Bot removed from chat or no rights at all
|
||||
logger.warning("getChatMember forbidden for chat %s: %s", chat_id, e)
|
||||
return False
|
||||
except TelegramAPIError as e:
|
||||
logger.warning("getChatMember error for user %s in chat %s: %s", user_id, chat_id, e)
|
||||
return False
|
||||
@@ -0,0 +1,134 @@
|
||||
import asyncio
|
||||
import html
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models.lottery import Lottery, LotteryStatus
|
||||
from app.services.draw_service import execute_draw
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _announce_winners(lottery: Lottery, winners_data: list[dict]) -> None:
|
||||
if not winners_data:
|
||||
return
|
||||
targets = lottery.announcement_targets or (
|
||||
[{"chat_id": lottery.target_chat_id, "message_id": lottery.announcement_message_id}]
|
||||
if lottery.target_chat_id else []
|
||||
)
|
||||
if not targets:
|
||||
return
|
||||
from app.bot.router import bot
|
||||
from aiogram.exceptions import TelegramAPIError
|
||||
|
||||
lines = []
|
||||
for w in winners_data:
|
||||
full_name = html.escape(
|
||||
w["first_name"] + (f" {w['last_name']}" if w.get("last_name") else "")
|
||||
)
|
||||
safe_name = f'<a href="tg://user?id={w["user_id"]}">{full_name}</a>'
|
||||
lines.append(f"🏆 {safe_name} - {html.escape(w['prize_name'])}")
|
||||
|
||||
text = (
|
||||
f"🎉 <b>Draw Results: {html.escape(lottery.title)}</b>\n\n"
|
||||
+ "\n".join(lines)
|
||||
+ "\n\nCongratulations to all winners! 🎊"
|
||||
)
|
||||
from aiogram.types import ReplyParameters
|
||||
for t in targets:
|
||||
try:
|
||||
reply = (
|
||||
ReplyParameters(message_id=t["message_id"], allow_sending_without_reply=True)
|
||||
if t.get("message_id")
|
||||
else None
|
||||
)
|
||||
await bot.send_message(
|
||||
t["chat_id"],
|
||||
text,
|
||||
parse_mode="HTML",
|
||||
reply_parameters=reply,
|
||||
)
|
||||
except TelegramAPIError as e:
|
||||
logger.warning("Failed to announce auto-draw winners to %s: %s", t["chat_id"], e)
|
||||
|
||||
|
||||
async def notify_winners(
|
||||
lottery_title: str, winners_data: list[dict], contact_info: str | None = None,
|
||||
lottery_id: uuid.UUID | None = None,
|
||||
) -> None:
|
||||
"""Send a private congratulations message to each winner via the bot."""
|
||||
from app.bot.router import bot
|
||||
from aiogram.exceptions import TelegramAPIError
|
||||
from app.bot.keyboards.inline import lottery_detail_keyboard
|
||||
|
||||
from collections import defaultdict
|
||||
user_wins: dict[int, list[dict]] = defaultdict(list)
|
||||
for w in winners_data:
|
||||
user_wins[w["user_id"]].append(w)
|
||||
|
||||
keyboard = lottery_detail_keyboard(str(lottery_id), text="🏆 View results") if lottery_id else None
|
||||
|
||||
for user_id, wins in user_wins.items():
|
||||
if len(wins) == 1:
|
||||
text = (
|
||||
f"🎉 Congratulations! You won in <b>{html.escape(lottery_title)}</b>!\n"
|
||||
f"🎁 Prize: <b>{html.escape(wins[0]['prize_name'])}</b>"
|
||||
)
|
||||
else:
|
||||
prizes_lines = "\n".join(f"🎁 {html.escape(w['prize_name'])}" for w in wins)
|
||||
text = (
|
||||
f"🎉 Congratulations! You won {len(wins)} prizes in <b>{html.escape(lottery_title)}</b>!\n"
|
||||
+ prizes_lines
|
||||
)
|
||||
if contact_info:
|
||||
text += f"\n\n📬 Contact to claim your prize: {html.escape(contact_info)}"
|
||||
else:
|
||||
text += "\n\nPlease contact the lottery organizer to claim your prize."
|
||||
try:
|
||||
await bot.send_message(user_id, text, parse_mode="HTML", reply_markup=keyboard)
|
||||
except TelegramAPIError:
|
||||
pass
|
||||
|
||||
|
||||
async def run_auto_draw(lottery_id: uuid.UUID, trigger: str = "scheduled") -> None:
|
||||
async with AsyncSessionLocal() as db:
|
||||
try:
|
||||
winners_data = await execute_draw(db, lottery_id, trigger=trigger)
|
||||
except ValueError as e:
|
||||
logger.info("Auto-draw skipped for %s: %s", lottery_id, e)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error("Auto-draw error for %s: %s", lottery_id, e)
|
||||
return
|
||||
|
||||
result = await db.execute(select(Lottery).where(Lottery.id == lottery_id))
|
||||
lottery = result.scalar_one_or_none()
|
||||
if lottery:
|
||||
await _announce_winners(lottery, winners_data)
|
||||
await notify_winners(lottery.title, winners_data, lottery.contact_info, lottery_id=lottery.id)
|
||||
|
||||
|
||||
async def auto_draw_scheduler() -> None:
|
||||
"""Poll every 30 s for lotteries whose draw_at has passed."""
|
||||
while True:
|
||||
await asyncio.sleep(30)
|
||||
try:
|
||||
now = datetime.utcnow()
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(
|
||||
select(Lottery.id).where(
|
||||
Lottery.status == LotteryStatus.ACTIVE,
|
||||
Lottery.draw_at.isnot(None),
|
||||
Lottery.draw_at <= now,
|
||||
)
|
||||
)
|
||||
ids = result.scalars().all()
|
||||
|
||||
for lottery_id in ids:
|
||||
asyncio.create_task(run_auto_draw(lottery_id))
|
||||
except Exception as e:
|
||||
logger.error("Scheduler error: %s", e)
|
||||
Reference in New Issue
Block a user