Initial release
Build and Push Docker Images / build (push) Successful in 17s

This commit is contained in:
Stardream
2026-06-02 10:58:24 +10:00
commit eace48732d
88 changed files with 11140 additions and 0 deletions
View File
+121
View File
@@ -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
+134
View File
@@ -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)