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
View File
+74
View File
@@ -0,0 +1,74 @@
import json
import logging
from urllib.parse import parse_qsl
from fastapi import Depends, Header, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.security import validate_init_data
from app.models.user import User
logger = logging.getLogger(__name__)
def _log_auth_failure(reason: str, init_data: str | None) -> None:
parsed = dict(parse_qsl(init_data or "", keep_blank_values=True))
logger.warning(
"telegram auth failed: reason=%s init_len=%s has_hash=%s has_user=%s auth_date=%s bot_token_set=%s",
reason,
len(init_data or ""),
bool(parsed.get("hash")),
bool(parsed.get("user")),
parsed.get("auth_date", ""),
bool(settings.bot_token),
)
async def get_current_user(
x_telegram_init_data: str | None = Header(None, alias="X-Telegram-Init-Data"),
db: AsyncSession = Depends(get_db),
) -> User:
if not x_telegram_init_data:
_log_auth_failure("missing_header", x_telegram_init_data)
raise HTTPException(status_code=401, detail="Open this page from the Telegram bot Mini App")
data = validate_init_data(x_telegram_init_data)
if data is None:
_log_auth_failure("invalid_init_data", x_telegram_init_data)
raise HTTPException(status_code=401, detail="Invalid Telegram auth data")
user_json = data.get("user", "{}")
try:
user_data = json.loads(user_json)
except (json.JSONDecodeError, TypeError):
_log_auth_failure("invalid_user_json", x_telegram_init_data)
raise HTTPException(status_code=401, detail="Invalid user data")
user_id = user_data.get("id")
if not user_id:
_log_auth_failure("missing_user_id", x_telegram_init_data)
raise HTTPException(status_code=401, detail="Missing user id")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
user = User(
id=user_id,
username=user_data.get("username"),
first_name=user_data.get("first_name", ""),
last_name=user_data.get("last_name"),
)
db.add(user)
await db.commit()
await db.refresh(user)
else:
user.username = user_data.get("username")
user.first_name = user_data.get("first_name") or user.first_name
user.last_name = user_data.get("last_name")
await db.commit()
return user
View File
+168
View File
@@ -0,0 +1,168 @@
import logging
import time
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.api.schemas import ChatInfo
from app.core.database import get_db
from app.models.managed_chat import ManagedChat
from app.models.user import User
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/chats", tags=["chats"])
# chat_id → (bytes, content_type, expires_at)
_chat_avatar_cache: dict[int, tuple[bytes, str, float]] = {}
@router.get("", response_model=list[ChatInfo])
async def list_accessible_chats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[ChatInfo]:
"""Groups: any member may use. Channels: admins/creators only."""
from app.bot.router import bot
from aiogram.exceptions import TelegramAPIError
result = await db.execute(
select(ManagedChat).where(ManagedChat.is_active == True)
)
chats = result.scalars().all()
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
_unverifiable = (
"user_id_invalid",
"have no rights",
"not enough rights",
"administrators only",
)
accessible: list[ChatInfo] = []
for chat in chats:
try:
member = await bot.get_chat_member(chat.id, current_user.id)
status = member.status
if chat.chat_type == "channel":
allowed = status in ("administrator", "creator")
else:
allowed = status in ("member", "administrator", "creator", "restricted")
except TelegramBadRequest as e:
msg = str(e).lower()
# Can't verify due to anonymous/privacy settings - include the chat
allowed = any(p in msg for p in _unverifiable)
if allowed:
logger.debug("Chat %s membership unverifiable for user %s (%s) - including", chat.id, current_user.id, e)
except TelegramForbiddenError:
continue
except TelegramAPIError:
continue
if allowed:
accessible.append(ChatInfo(
id=chat.id,
title=chat.title,
username=chat.username,
chat_type=chat.chat_type,
))
return accessible
@router.get("/{chat_id}/link")
async def get_chat_link(
chat_id: int,
db: AsyncSession = Depends(get_db),
_current_user: User = Depends(get_current_user),
) -> dict:
"""Return the best navigation URL for a chat (username > last_message_id > /1 fallback)."""
result = await db.execute(select(ManagedChat).where(ManagedChat.id == chat_id))
managed = result.scalar_one_or_none()
if not managed:
raise HTTPException(404, "Chat not found")
if managed.username:
return {"url": f"https://t.me/{managed.username}"}
channel_id = str(abs(chat_id))[3:] # strip -100 prefix
return {"url": f"https://t.me/c/{channel_id}/{managed.last_message_id or 1}"}
@router.get("/{chat_id}/invite")
async def get_chat_invite_link(
chat_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
"""Return a persistent invite link for a chat.
Reading an already-stored link: any authenticated user associated with a
lottery that requires this chat (owner or participant).
Generating a new link: only the chat's admin/creator.
"""
result = await db.execute(select(ManagedChat).where(ManagedChat.id == chat_id))
managed = result.scalar_one_or_none()
if not managed:
raise HTTPException(404, "Chat not found")
# Already-stored link: any authenticated user may read it.
# The admin intentionally generated it for participants to use.
if managed.invite_link:
return {"url": managed.invite_link}
# No link stored yet - only an admin/creator may trigger generation
from app.bot.router import bot
from aiogram.exceptions import TelegramAPIError, TelegramBadRequest
is_admin = False
try:
member = await bot.get_chat_member(chat_id, current_user.id)
is_admin = member.status in ("administrator", "creator")
except TelegramBadRequest as e:
msg = str(e).lower()
is_admin = any(p in msg for p in ("administrators only", "have no rights", "not enough rights"))
except TelegramAPIError:
pass
if not is_admin:
raise HTTPException(403, "Not authorized")
url: str | None = None
error: str | None = None
try:
link = await bot.create_chat_invite_link(chat_id)
url = link.invite_link
except Exception as e:
error = str(e)
logger.warning("create_chat_invite_link failed for chat %s: %s", chat_id, e)
if url:
managed.invite_link = url
await db.commit()
return {"url": url, "error": error}
@router.get("/{chat_id}/avatar")
async def chat_avatar(chat_id: int) -> Response:
from app.core.config import settings
ttl = settings.avatar_cache_ttl
cached = _chat_avatar_cache.get(chat_id)
if cached and time.monotonic() < cached[2]:
return Response(content=cached[0], media_type=cached[1],
headers={"Cache-Control": f"public, max-age={ttl}"})
from app.bot.router import bot
try:
chat = await bot.get_chat(chat_id)
if not chat.photo:
return Response(status_code=404)
data = await bot.download(chat.photo.small_file_id)
content = data.read()
content_type = "image/jpeg"
_chat_avatar_cache[chat_id] = (content, content_type, time.monotonic() + ttl)
return Response(content=content, media_type=content_type,
headers={"Cache-Control": f"public, max-age={ttl}"})
except Exception:
return Response(status_code=404)
+609
View File
@@ -0,0 +1,609 @@
import html
import uuid
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.api.schemas import LotteryCreate, LotteryResponse, LotteryUpdate, PrizeResponse, WinnerResponse
from app.core.database import get_db
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
from app.services.draw_service import execute_draw
router = APIRouter(prefix="/lotteries", tags=["lotteries"])
async def _revoke_invite_links(db: AsyncSession, chat_ids: set[int]) -> None:
from app.bot.router import bot
from app.models.managed_chat import ManagedChat
for chat_id in chat_ids:
result = await db.execute(select(ManagedChat).where(ManagedChat.id == chat_id))
managed = result.scalar_one_or_none()
if managed and managed.invite_link:
try:
await bot.revoke_chat_invite_link(chat_id, managed.invite_link)
except Exception:
pass
managed.invite_link = None
await db.commit()
async def _build_lottery_response(
db: AsyncSession, lottery: Lottery, current_user_id: int | None = None
) -> LotteryResponse:
count_result = await db.execute(
select(func.count(Participant.id)).where(Participant.lottery_id == lottery.id)
)
participant_count = count_result.scalar() or 0
prize_result = await db.execute(
select(Prize).where(Prize.lottery_id == lottery.id).order_by(Prize.sort_order)
)
prizes = prize_result.scalars().all()
total_prizes = sum(p.quantity for p in prizes)
is_winner: bool | None = None
if current_user_id is not None and lottery.status == LotteryStatus.FINISHED:
w = await db.execute(
select(Winner).where(Winner.lottery_id == lottery.id, Winner.user_id == current_user_id).limit(1)
)
is_winner = w.scalar_one_or_none() is not None
return LotteryResponse(
id=lottery.id,
creator_id=lottery.creator_id,
title=lottery.title,
description=lottery.description,
contact_info=lottery.contact_info,
status=lottery.status,
target_chat_id=lottery.target_chat_id,
target_chat_title=lottery.target_chat_title,
require_membership=lottery.require_membership,
required_chat_ids=lottery.required_chat_ids or [],
required_chat_titles=lottery.required_chat_titles or [],
required_chat_types=lottery.required_chat_types or [],
required_chat_usernames=lottery.required_chat_usernames or [],
announcement_targets=lottery.announcement_targets or [],
allow_multiple_wins=lottery.allow_multiple_wins,
invite_link_chat_ids=lottery.invite_link_chat_ids or [],
required_chat_passphrases=lottery.required_chat_passphrases or [],
max_participants=lottery.max_participants,
draw_at=lottery.draw_at,
auto_draw_count=lottery.auto_draw_count,
creator_timezone=lottery.creator_timezone,
participant_count=participant_count,
prize_count=len(prizes),
total_prizes=total_prizes,
created_at=lottery.created_at,
drawn_at=lottery.drawn_at,
draw_trigger=lottery.draw_trigger,
prizes=[PrizeResponse.model_validate(p) for p in prizes],
is_winner=is_winner,
)
@router.get("", response_model=list[LotteryResponse])
async def list_my_lotteries(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[LotteryResponse]:
result = await db.execute(
select(Lottery)
.where(Lottery.creator_id == current_user.id)
.order_by(Lottery.created_at.desc())
)
lotteries = result.scalars().all()
return [await _build_lottery_response(db, l, current_user.id) for l in lotteries]
@router.get("/joined", response_model=list[LotteryResponse])
async def list_joined_lotteries(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[LotteryResponse]:
result = await db.execute(
select(Lottery)
.join(Participant, Participant.lottery_id == Lottery.id)
.where(Participant.user_id == current_user.id)
.order_by(Lottery.created_at.desc())
)
lotteries = result.scalars().all()
return [await _build_lottery_response(db, l, current_user.id) for l in lotteries]
@router.get("/active", response_model=list[LotteryResponse])
async def list_active_lotteries(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[LotteryResponse]:
"""Active lotteries created by others that the user can join."""
result = await db.execute(
select(Lottery)
.where(Lottery.status == LotteryStatus.ACTIVE)
.where(Lottery.creator_id != current_user.id)
.order_by(Lottery.created_at.desc())
)
lotteries = result.scalars().all()
return [await _build_lottery_response(db, l) for l in lotteries]
@router.post("", response_model=LotteryResponse)
async def create_lottery(
data: LotteryCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> LotteryResponse:
lottery = Lottery(creator_id=current_user.id, **data.model_dump())
db.add(lottery)
await db.commit()
await db.refresh(lottery)
return await _build_lottery_response(db, lottery)
@router.get("/{lottery_id}", response_model=LotteryResponse)
async def get_lottery(
lottery_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> LotteryResponse:
result = await db.execute(select(Lottery).where(Lottery.id == lottery_id))
lottery = result.scalar_one_or_none()
if lottery is None:
raise HTTPException(404, "Lottery not found")
return await _build_lottery_response(db, lottery)
@router.put("/{lottery_id}", response_model=LotteryResponse)
async def update_lottery(
lottery_id: uuid.UUID,
data: LotteryUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> LotteryResponse:
result = await db.execute(select(Lottery).where(Lottery.id == lottery_id))
lottery = result.scalar_one_or_none()
if lottery is None:
raise HTTPException(404, "Lottery not found")
if lottery.creator_id != current_user.id:
raise HTTPException(403, "Not authorized")
if lottery.status in (LotteryStatus.FINISHED, LotteryStatus.CANCELLED):
raise HTTPException(400, "Cannot edit a finished or cancelled lottery")
old_invite_ids: set[int] = set(lottery.invite_link_chat_ids or [])
for field, value in data.model_dump(exclude_unset=True).items():
setattr(lottery, field, value)
await db.commit()
await db.refresh(lottery)
new_invite_ids: set[int] = set(lottery.invite_link_chat_ids or [])
removed_ids = old_invite_ids - new_invite_ids
if removed_ids:
await _revoke_invite_links(db, removed_ids)
# Auto-sync announcement messages whenever the lottery is edited
if lottery.announcement_targets or lottery.announcement_message_id:
try:
await sync_announcement(db, lottery, force_send=False)
except Exception:
pass
return await _build_lottery_response(db, lottery)
@router.post("/{lottery_id}/activate", response_model=LotteryResponse)
async def activate_lottery(
lottery_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> LotteryResponse:
result = await db.execute(select(Lottery).where(Lottery.id == lottery_id))
lottery = result.scalar_one_or_none()
if lottery is None:
raise HTTPException(404, "Lottery not found")
if lottery.creator_id != current_user.id:
raise HTTPException(403, "Not authorized")
if lottery.status != LotteryStatus.DRAFT:
raise HTTPException(400, "Lottery is already activated")
prize_result = await db.execute(
select(Prize).where(Prize.lottery_id == lottery_id).limit(1)
)
if prize_result.scalar_one_or_none() is None:
raise HTTPException(400, "Add at least one prize before activating")
lottery.status = LotteryStatus.ACTIVE
await db.commit()
await db.refresh(lottery)
return await _build_lottery_response(db, lottery)
@router.post("/{lottery_id}/draw", response_model=list[WinnerResponse])
async def draw_lottery(
lottery_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[WinnerResponse]:
result = await db.execute(select(Lottery).where(Lottery.id == lottery_id))
lottery = result.scalar_one_or_none()
if lottery is None:
raise HTTPException(404, "Lottery not found")
if lottery.creator_id != current_user.id:
raise HTTPException(403, "Not authorized")
if lottery.status != LotteryStatus.ACTIVE:
raise HTTPException(400, "Lottery is not active")
try:
winners_data = await execute_draw(db, lottery_id, trigger="manual")
except ValueError as e:
raise HTTPException(400, str(e))
# Announce winners to all announcement targets
targets = lottery.announcement_targets or (
[{"chat_id": lottery.target_chat_id, "chat_title": lottery.target_chat_title or "", "message_id": lottery.announcement_message_id}]
if lottery.target_chat_id else []
)
if targets and winners_data:
from app.bot.router import bot
from aiogram.types import ReplyParameters
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>'
safe_prize = html.escape(w["prize_name"])
lines.append(f"🏆 {safe_name} - {safe_prize}")
text = (
f"🎉 <b>Draw Results: {html.escape(lottery.title)}</b>\n\n"
+ "\n".join(lines)
+ "\n\nCongratulations to all winners! 🎊"
)
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 Exception:
pass
if winners_data:
from app.services.scheduler import notify_winners
import asyncio
asyncio.create_task(notify_winners(lottery.title, winners_data, lottery.contact_info, lottery_id=lottery.id))
return [WinnerResponse(**w) for w in winners_data]
@router.get("/{lottery_id}/winners", response_model=list[WinnerResponse])
async def get_winners(
lottery_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[WinnerResponse]:
result = await db.execute(
select(Winner, User)
.join(User, Winner.user_id == User.id)
.where(Winner.lottery_id == lottery_id)
.order_by(Winner.drawn_at)
)
return [
WinnerResponse(
user_id=user.id,
username=user.username,
first_name=user.first_name,
last_name=user.last_name,
prize_name=winner.prize_name,
drawn_at=winner.drawn_at,
)
for winner, user in result.all()
]
async def _build_announce_text(db: AsyncSession, lottery: Lottery, include_count: bool = True) -> str:
prize_result = await db.execute(
select(Prize).where(Prize.lottery_id == lottery.id).order_by(Prize.sort_order)
)
prizes = prize_result.scalars().all()
count_result = await db.execute(
select(func.count(Participant.id)).where(Participant.lottery_id == lottery.id)
)
participant_count = count_result.scalar() or 0
text = f"🎰 <b>{html.escape(lottery.title)}</b>"
if lottery.description:
text += f"\n\n{html.escape(lottery.description)}"
# Membership requirements
required_ids = lottery.required_chat_ids or []
required_titles = lottery.required_chat_titles or []
required_usernames = lottery.required_chat_usernames or []
required_passphrases = lottery.required_chat_passphrases or []
invite_link_ids = set(lottery.invite_link_chat_ids or [])
if lottery.require_membership and required_ids:
from app.models.managed_chat import ManagedChat
mc_result = await db.execute(select(ManagedChat).where(ManagedChat.id.in_(required_ids)))
managed_chats = {mc.id: mc for mc in mc_result.scalars().all()}
text += "\n\n📮 <b>Requirements:</b>"
for i, chat_id in enumerate(required_ids):
title = required_titles[i] if i < len(required_titles) else str(chat_id)
username = required_usernames[i] if i < len(required_usernames) else None
mc = managed_chats.get(chat_id)
if chat_id in invite_link_ids and mc and mc.invite_link:
link: str | None = mc.invite_link
elif username:
link = f"https://t.me/{username}"
elif mc:
channel_id = str(abs(chat_id))[3:]
link = f"https://t.me/c/{channel_id}/{mc.last_message_id or 1}"
else:
link = None
label = f'<a href="{link}">{html.escape(title)}</a>' if link else html.escape(title)
text += f"\n 🎫 Join - {label}"
if i < len(required_passphrases) and required_passphrases[i]:
text += f"\n └ 🔑 Send passphrase ﹝<code>{html.escape(required_passphrases[i])}</code>﹞"
elif lottery.require_membership and lottery.target_chat_title:
text += f"\n\n📮 <b>Requirements:</b>\n 🎫 Join - {html.escape(lottery.target_chat_title)}"
# Prizes
prize_lines = "\n".join(f" - {html.escape(p.name)} ×{p.quantity}" for p in prizes)
text += f"\n\n🎁 <b>Prizes:</b>\n{prize_lines}"
# Participant count
if include_count:
text += f"\n\n👥 Participants: {participant_count}"
# Draw conditions
if lottery.draw_at:
from datetime import timezone as _tz
draw_dt = lottery.draw_at.replace(tzinfo=_tz.utc)
tz_label = "UTC"
if lottery.creator_timezone:
try:
from zoneinfo import ZoneInfo
draw_dt = draw_dt.astimezone(ZoneInfo(lottery.creator_timezone))
offset = draw_dt.utcoffset()
if offset is not None:
total_min = int(offset.total_seconds() / 60)
sign = "+" if total_min >= 0 else "-"
h, m = divmod(abs(total_min), 60)
tz_label = f"UTC{sign}{h}" if m == 0 else f"UTC{sign}{h}:{m:02d}"
except Exception:
pass
text += f"\n\n📅 <b>Draw time:</b> {draw_dt.strftime('%Y/%m/%d %H:%M')} ({tz_label})"
if lottery.auto_draw_count:
text += f"\n🎯 Auto-draw at {lottery.auto_draw_count} participants"
return text
async def _sync_single_target(
bot,
text: str,
keyboard,
target: dict,
force_send: bool,
) -> int | None:
"""Sync announcement to one chat. Returns the resulting message_id (or None)."""
from aiogram.exceptions import TelegramBadRequest
chat_id = target["chat_id"]
msg_id = target.get("message_id")
if msg_id:
try:
await bot.edit_message_text(
chat_id=chat_id,
message_id=msg_id,
text=text,
reply_markup=keyboard,
parse_mode="HTML",
)
return msg_id
except TelegramBadRequest as e:
if "message is not modified" in str(e).lower():
return msg_id
# Message deleted or inaccessible
if not force_send:
return msg_id # keep existing id, silently skip
msg_id = None # will resend below
if force_send:
msg = await bot.send_message(
chat_id,
text,
reply_markup=keyboard,
parse_mode="HTML",
)
await _update_chat_last_message(chat_id, msg.message_id)
return msg.message_id
return None
async def _update_chat_last_message(chat_id: int, message_id: int) -> None:
from app.core.database import AsyncSessionLocal
from app.models.managed_chat import ManagedChat
from sqlalchemy import select
async with AsyncSessionLocal() as db:
result = await db.execute(select(ManagedChat).where(ManagedChat.id == chat_id))
managed = result.scalar_one_or_none()
if managed and message_id > (managed.last_message_id or 0):
managed.last_message_id = message_id
await db.commit()
async def sync_announcement(db: AsyncSession, lottery: Lottery, force_send: bool = False) -> None:
"""Edit or send announcement messages to all announcement_targets."""
from app.bot.router import bot
from app.bot.keyboards.inline import lottery_announce_keyboard
# Build target list - backward compat with old single-target lotteries
targets: list[dict] = lottery.announcement_targets or []
if not targets and lottery.target_chat_id:
targets = [{
"chat_id": lottery.target_chat_id,
"chat_title": lottery.target_chat_title or "",
"message_id": lottery.announcement_message_id,
}]
if not targets:
return
text = await _build_announce_text(db, lottery)
keyboard = lottery_announce_keyboard(str(lottery.id))
updated: list[dict] = []
changed = False
for t in targets:
new_msg_id = await _sync_single_target(bot, text, keyboard, t, force_send)
updated.append({**t, "message_id": new_msg_id})
if new_msg_id != t.get("message_id"):
changed = True
if changed:
lottery.announcement_targets = updated
await db.commit()
class _AnnounceChatItem(BaseModel):
id: int
title: str
class _AnnounceRequest(BaseModel):
chats: list[_AnnounceChatItem]
@router.post("/{lottery_id}/announce")
async def announce_lottery(
lottery_id: uuid.UUID,
data: _AnnounceRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
from aiogram.exceptions import TelegramAPIError
result = await db.execute(select(Lottery).where(Lottery.id == lottery_id))
lottery = result.scalar_one_or_none()
if lottery is None:
raise HTTPException(404, "Lottery not found")
if lottery.creator_id != current_user.id:
raise HTTPException(403, "Not authorized")
if not data.chats:
raise HTTPException(400, "Select at least one chat")
if lottery.status == LotteryStatus.CANCELLED:
raise HTTPException(400, "Cannot announce a cancelled lottery")
# Build new targets, preserving existing message_ids for same chats
existing = {t["chat_id"]: t for t in (lottery.announcement_targets or [])}
new_targets = [
{
"chat_id": c.id,
"chat_title": c.title,
"message_id": existing.get(c.id, {}).get("message_id"),
}
for c in data.chats
]
lottery.announcement_targets = new_targets
# Keep legacy target_chat_id pointing to first chat for draw-result compat
lottery.target_chat_id = data.chats[0].id
lottery.target_chat_title = data.chats[0].title
await db.commit()
await db.refresh(lottery)
try:
await sync_announcement(db, lottery, force_send=True)
except TelegramAPIError as e:
raise HTTPException(500, f"Failed to send message: {e}")
return {"status": "announced"}
@router.post("/{lottery_id}/prepare-share")
async def prepare_share(
lottery_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
result = await db.execute(select(Lottery).where(Lottery.id == lottery_id))
lottery = result.scalar_one_or_none()
if lottery is None:
raise HTTPException(404, "Lottery not found")
if lottery.status == LotteryStatus.CANCELLED:
raise HTTPException(400, "Cannot share a cancelled lottery")
from app.bot.router import bot
from app.bot.keyboards.inline import lottery_announce_keyboard
from aiogram.types import InlineQueryResultArticle, InputTextMessageContent
text = await _build_announce_text(db, lottery, include_count=False)
keyboard = lottery_announce_keyboard(str(lottery.id))
try:
prepared = await bot.save_prepared_inline_message(
user_id=current_user.id,
result=InlineQueryResultArticle(
id=str(lottery.id),
title=lottery.title,
input_message_content=InputTextMessageContent(
message_text=text,
parse_mode="HTML",
),
reply_markup=keyboard,
),
allow_user_chats=True,
allow_bot_chats=False,
allow_group_chats=True,
allow_channel_chats=True,
)
except Exception as e:
raise HTTPException(500, str(e))
return {"prepared_message_id": prepared.id}
@router.delete("/{lottery_id}")
async def cancel_lottery(
lottery_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
result = await db.execute(select(Lottery).where(Lottery.id == lottery_id))
lottery = result.scalar_one_or_none()
if lottery is None:
raise HTTPException(404, "Lottery not found")
if lottery.creator_id != current_user.id:
raise HTTPException(403, "Not authorized")
if lottery.status == LotteryStatus.FINISHED:
raise HTTPException(400, "Cannot cancel a finished lottery")
lottery.status = LotteryStatus.CANCELLED
await db.commit()
return {"status": "cancelled"}
+148
View File
@@ -0,0 +1,148 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.api.schemas import ParticipantResponse
from app.core.database import get_db
from app.models.lottery import Lottery, LotteryStatus
from app.models.participant import Participant
from app.models.user import User
from app.services.membership_service import check_membership
router = APIRouter(tags=["participants"])
@router.post("/lotteries/{lottery_id}/join")
async def join_lottery(
lottery_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
# Lock the lottery row so the capacity check + insert is atomic.
# Without this, concurrent joins can all pass the count check and exceed
# max_participants before any of them commits.
result = await db.execute(
select(Lottery).where(Lottery.id == lottery_id).with_for_update()
)
lottery = result.scalar_one_or_none()
if lottery is None:
raise HTTPException(404, "Lottery not found")
if lottery.status != LotteryStatus.ACTIVE:
raise HTTPException(400, "Lottery is not active")
# Check max participants inside the lock
if lottery.max_participants is not None:
count_result = await db.execute(
select(func.count(Participant.id)).where(Participant.lottery_id == lottery_id)
)
count = count_result.scalar() or 0
if count >= lottery.max_participants:
raise HTTPException(400, "Lottery is full")
# Check membership if required
if lottery.require_membership:
chat_ids = lottery.required_chat_ids or (
[lottery.target_chat_id] if lottery.target_chat_id else []
)
for i, cid in enumerate(chat_ids):
is_member = await check_membership(cid, current_user.id)
if not is_member:
titles = lottery.required_chat_titles or []
label = titles[i] if i < len(titles) else str(cid)
raise HTTPException(403, f"You must join {label} first")
# Check passphrase if set for this group
passphrases = lottery.required_chat_passphrases or []
if i < len(passphrases) and passphrases[i]:
from app.models.passphrase_verification import PassphraseVerification
pv = await db.execute(
select(PassphraseVerification).where(
PassphraseVerification.lottery_id == lottery_id,
PassphraseVerification.chat_id == cid,
PassphraseVerification.user_id == current_user.id,
)
)
if pv.scalar_one_or_none() is None:
titles = lottery.required_chat_titles or []
label = titles[i] if i < len(titles) else str(cid)
raise HTTPException(403, f"Please send the passphrase in {label} first")
# Check already joined
existing = await db.execute(
select(Participant).where(
Participant.lottery_id == lottery_id,
Participant.user_id == current_user.id,
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(409, "Already joined this lottery")
participant = Participant(lottery_id=lottery_id, user_id=current_user.id)
db.add(participant)
await db.commit()
# Refresh count for post-join checks
count_result = await db.execute(
select(func.count(Participant.id)).where(Participant.lottery_id == lottery_id)
)
new_count = count_result.scalar() or 0
# Always update the group announcement with the new participant count
if lottery.announcement_targets or (lottery.announcement_message_id and lottery.target_chat_id):
try:
from app.api.routers.lotteries import sync_announcement
await sync_announcement(db, lottery, force_send=False)
except Exception:
pass
# Trigger auto-draw if participant count reached the threshold
if lottery.auto_draw_count and new_count >= lottery.auto_draw_count:
import asyncio
from app.services.scheduler import run_auto_draw
asyncio.create_task(run_auto_draw(lottery_id, trigger="auto_count"))
return {"status": "joined"}
@router.get("/lotteries/{lottery_id}/passphrase-status")
async def get_passphrase_status(
lottery_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
from app.models.passphrase_verification import PassphraseVerification
result = await db.execute(
select(PassphraseVerification.chat_id).where(
PassphraseVerification.lottery_id == lottery_id,
PassphraseVerification.user_id == current_user.id,
)
)
return {"verified_chat_ids": [row[0] for row in result.all()]}
@router.get("/lotteries/{lottery_id}/participants", response_model=list[ParticipantResponse])
async def list_participants(
lottery_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> list[ParticipantResponse]:
result = await db.execute(
select(Participant, User)
.join(User, Participant.user_id == User.id)
.where(Participant.lottery_id == lottery_id)
.order_by(Participant.joined_at)
)
return [
ParticipantResponse(
user_id=user.id,
username=user.username,
first_name=user.first_name,
last_name=user.last_name,
joined_at=participant.joined_at,
)
for participant, user in result.all()
]
+102
View File
@@ -0,0 +1,102 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.api.schemas import PrizeCreate, PrizeResponse
from app.core.database import get_db
from app.models.lottery import Lottery, LotteryStatus
from app.models.prize import Prize
from app.models.user import User
router = APIRouter(tags=["prizes"])
@router.post("/lotteries/{lottery_id}/prizes", response_model=PrizeResponse)
async def add_prize(
lottery_id: uuid.UUID,
data: PrizeCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> Prize:
result = await db.execute(select(Lottery).where(Lottery.id == lottery_id))
lottery = result.scalar_one_or_none()
if lottery is None:
raise HTTPException(404, "Lottery not found")
if lottery.creator_id != current_user.id:
raise HTTPException(403, "Not authorized")
if lottery.status not in (LotteryStatus.DRAFT, LotteryStatus.ACTIVE):
raise HTTPException(400, "Cannot modify prizes after activation")
# Determine next sort order
last_result = await db.execute(
select(Prize)
.where(Prize.lottery_id == lottery_id)
.order_by(Prize.sort_order.desc())
.limit(1)
)
last = last_result.scalar_one_or_none()
sort_order = (last.sort_order + 1) if last else 0
prize = Prize(lottery_id=lottery_id, sort_order=sort_order, **data.model_dump())
db.add(prize)
await db.commit()
await db.refresh(prize)
return prize
@router.put("/prizes/{prize_id}", response_model=PrizeResponse)
async def update_prize(
prize_id: uuid.UUID,
data: PrizeCreate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> Prize:
result = await db.execute(select(Prize).where(Prize.id == prize_id))
prize = result.scalar_one_or_none()
if prize is None:
raise HTTPException(404, "Prize not found")
lottery_result = await db.execute(select(Lottery).where(Lottery.id == prize.lottery_id))
lottery = lottery_result.scalar_one()
if lottery.creator_id != current_user.id:
raise HTTPException(403, "Not authorized")
if lottery.status not in (LotteryStatus.DRAFT, LotteryStatus.ACTIVE):
raise HTTPException(400, "Cannot modify prizes after activation")
prize.name = data.name
prize.quantity = data.quantity
prize.description = data.description
await db.commit()
await db.refresh(prize)
return prize
@router.delete("/prizes/{prize_id}")
async def delete_prize(
prize_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
) -> dict:
result = await db.execute(select(Prize).where(Prize.id == prize_id))
prize = result.scalar_one_or_none()
if prize is None:
raise HTTPException(404, "Prize not found")
lottery_result = await db.execute(select(Lottery).where(Lottery.id == prize.lottery_id))
lottery = lottery_result.scalar_one()
if lottery.creator_id != current_user.id:
raise HTTPException(403, "Not authorized")
if lottery.status not in (LotteryStatus.DRAFT, LotteryStatus.ACTIVE):
raise HTTPException(400, "Cannot modify prizes after activation")
await db.delete(prize)
await db.commit()
return {"status": "deleted"}
+33
View File
@@ -0,0 +1,33 @@
import time
from fastapi import APIRouter
from fastapi.responses import Response
router = APIRouter(tags=["users"])
# user_id → (bytes, content_type, expires_at)
_avatar_cache: dict[int, tuple[bytes, str, float]] = {}
@router.get("/users/{user_id}/avatar")
async def get_user_avatar(user_id: int) -> Response:
from app.core.config import settings
ttl = settings.avatar_cache_ttl
cached = _avatar_cache.get(user_id)
if cached and time.monotonic() < cached[2]:
return Response(content=cached[0], media_type=cached[1],
headers={"Cache-Control": f"public, max-age={ttl}"})
from app.bot.router import bot
try:
photos = await bot.get_user_profile_photos(user_id, limit=1)
if not photos.photos:
return Response(status_code=404)
file_id = photos.photos[0][-1].file_id
data = await bot.download(file_id)
content = data.read()
content_type = "image/jpeg"
_avatar_cache[user_id] = (content, content_type, time.monotonic() + ttl)
return Response(content=content, media_type=content_type,
headers={"Cache-Control": f"public, max-age={ttl}"})
except Exception:
return Response(status_code=404)
+142
View File
@@ -0,0 +1,142 @@
import uuid
from datetime import datetime
from typing import Optional
from pydantic import BaseModel, Field, field_validator
class PrizeCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
quantity: int = Field(1, ge=1, le=1000)
class PrizeResponse(BaseModel):
id: uuid.UUID
name: str
description: Optional[str] = None
quantity: int
sort_order: int
model_config = {"from_attributes": True}
def _to_naive_utc(v: datetime | None) -> datetime | None:
if v is None:
return None
if v.tzinfo is not None:
from datetime import timezone
return v.astimezone(timezone.utc).replace(tzinfo=None)
return v
class LotteryCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=255)
description: Optional[str] = None
contact_info: Optional[str] = None
target_chat_id: Optional[int] = None
target_chat_title: Optional[str] = None
require_membership: bool = False
required_chat_ids: list[int] = Field(default_factory=list)
required_chat_titles: list[str] = Field(default_factory=list)
required_chat_types: list[str] = Field(default_factory=list)
required_chat_usernames: list[str] = Field(default_factory=list)
allow_multiple_wins: bool = False
invite_link_chat_ids: list[int] = Field(default_factory=list)
required_chat_passphrases: list[str] = Field(default_factory=list)
max_participants: Optional[int] = Field(None, ge=1)
draw_at: Optional[datetime] = None
auto_draw_count: Optional[int] = Field(None, ge=1)
creator_timezone: Optional[str] = Field(None, max_length=64)
@field_validator("draw_at", mode="after")
@classmethod
def normalize_draw_at(cls, v: datetime | None) -> datetime | None:
return _to_naive_utc(v)
class LotteryUpdate(BaseModel):
title: Optional[str] = Field(None, min_length=1, max_length=255)
description: Optional[str] = None
contact_info: Optional[str] = None
target_chat_id: Optional[int] = None
target_chat_title: Optional[str] = None
require_membership: Optional[bool] = None
required_chat_ids: Optional[list[int]] = None
required_chat_titles: Optional[list[str]] = None
required_chat_types: Optional[list[str]] = None
required_chat_usernames: Optional[list[str]] = None
allow_multiple_wins: Optional[bool] = None
invite_link_chat_ids: Optional[list[int]] = None
required_chat_passphrases: Optional[list[str]] = None
max_participants: Optional[int] = Field(None, ge=1)
draw_at: Optional[datetime] = None
auto_draw_count: Optional[int] = Field(None, ge=1)
creator_timezone: Optional[str] = Field(None, max_length=64)
@field_validator("draw_at", mode="after")
@classmethod
def normalize_draw_at(cls, v: datetime | None) -> datetime | None:
return _to_naive_utc(v)
class LotteryResponse(BaseModel):
id: uuid.UUID
creator_id: int
title: str
description: Optional[str] = None
contact_info: Optional[str] = None
status: str
target_chat_id: Optional[int] = None
target_chat_title: Optional[str] = None
require_membership: bool
required_chat_ids: list[int] = Field(default_factory=list)
required_chat_titles: list[str] = Field(default_factory=list)
required_chat_types: list[str] = Field(default_factory=list)
required_chat_usernames: list[str] = Field(default_factory=list)
announcement_targets: list[dict] = Field(default_factory=list)
allow_multiple_wins: bool = False
invite_link_chat_ids: list[int] = Field(default_factory=list)
required_chat_passphrases: list[str] = Field(default_factory=list)
max_participants: Optional[int] = None
draw_at: Optional[datetime] = None
auto_draw_count: Optional[int] = None
creator_timezone: Optional[str] = None
participant_count: int = 0
prize_count: int = 0
total_prizes: int = 0
created_at: datetime
drawn_at: Optional[datetime] = None
draw_trigger: Optional[str] = None
prizes: list[PrizeResponse] = Field(default_factory=list)
is_winner: Optional[bool] = None
model_config = {"from_attributes": True}
class ParticipantResponse(BaseModel):
user_id: int
username: Optional[str] = None
first_name: str
last_name: Optional[str] = None
joined_at: datetime
model_config = {"from_attributes": True}
class WinnerResponse(BaseModel):
user_id: int
username: Optional[str] = None
first_name: str
last_name: Optional[str] = None
prize_name: str
drawn_at: datetime
model_config = {"from_attributes": True}
class ChatInfo(BaseModel):
id: int
title: str
username: Optional[str] = None
chat_type: str
View File
+92
View File
@@ -0,0 +1,92 @@
from aiogram import F, Router
from aiogram.types import ChatMemberUpdated, Message
from app.core.database import AsyncSessionLocal
from app.models.managed_chat import ManagedChat
from app.models.lottery import Lottery, LotteryStatus
from app.models.passphrase_verification import PassphraseVerification
from sqlalchemy import select
router = Router()
@router.my_chat_member(F.chat.type.in_({"group", "supergroup", "channel"}))
async def bot_chat_member_updated(event: ChatMemberUpdated) -> None:
"""Record or update the bot's presence in a group/channel."""
async with AsyncSessionLocal() as db:
chat = event.chat
new_status = event.new_chat_member.status
result = await db.execute(
select(ManagedChat).where(ManagedChat.id == chat.id)
)
managed = result.scalar_one_or_none()
if new_status in ("member", "administrator"):
if managed is None:
managed = ManagedChat(
id=chat.id,
title=chat.title or "",
username=getattr(chat, 'username', None),
chat_type=chat.type,
)
db.add(managed)
else:
managed.is_active = True
managed.title = chat.title or managed.title
managed.username = getattr(chat, 'username', None)
await db.commit()
elif new_status in ("left", "kicked") and managed is not None:
managed.is_active = False
await db.commit()
@router.channel_post(F.chat.type == "channel")
@router.message(F.chat.type.in_({"group", "supergroup"}))
async def on_chat_message(message: Message) -> None:
"""Track latest message ID and detect passphrase submissions."""
async with AsyncSessionLocal() as db:
result = await db.execute(select(ManagedChat).where(ManagedChat.id == message.chat.id))
managed = result.scalar_one_or_none()
if managed is None:
managed = ManagedChat(
id=message.chat.id,
title=message.chat.title or "",
username=getattr(message.chat, "username", None),
chat_type=message.chat.type,
last_message_id=message.message_id,
)
db.add(managed)
elif message.message_id > (managed.last_message_id or 0):
managed.last_message_id = message.message_id
await db.commit()
if message.text and message.from_user:
text = message.text.strip()
uid = message.from_user.id
cid = message.chat.id
async with AsyncSessionLocal() as db2:
r = await db2.execute(
select(Lottery).where(
Lottery.status == LotteryStatus.ACTIVE,
Lottery.require_membership == True,
)
)
for lottery in r.scalars().all():
ids = lottery.required_chat_ids or []
phrases = lottery.required_chat_passphrases or []
for i, chat_id in enumerate(ids):
if chat_id == cid and i < len(phrases) and phrases[i] and text == phrases[i]:
existing = await db2.execute(
select(PassphraseVerification).where(
PassphraseVerification.lottery_id == lottery.id,
PassphraseVerification.chat_id == cid,
PassphraseVerification.user_id == uid,
)
)
if existing.scalar_one_or_none() is None:
db2.add(PassphraseVerification(
lottery_id=lottery.id, chat_id=cid, user_id=uid
))
await db2.commit()
break
+50
View File
@@ -0,0 +1,50 @@
from aiogram import Router
from aiogram.filters import Command, CommandObject, CommandStart
from aiogram.types import Message
from app.bot.keyboards.inline import create_lottery_keyboard, lottery_detail_keyboard, main_menu_keyboard
router = Router()
@router.message(CommandStart())
async def cmd_start(message: Message, command: CommandObject) -> None:
# Deep-link from group announcement: /start lottery_<uuid>
if command.args and command.args.startswith("lottery_"):
lottery_id = command.args[len("lottery_"):]
await message.answer(
"🎰 Tap the button below to join the lottery:",
reply_markup=lottery_detail_keyboard(lottery_id),
)
return
await message.answer(
"👋 Welcome to <b>TG Lottery Bot</b>!\n\n"
"Tap the button below to get started. Use /help for more information.",
reply_markup=main_menu_keyboard(),
parse_mode="HTML",
)
@router.message(Command("newlottery"))
async def cmd_new_lottery(message: Message) -> None:
await message.answer("Create a new lottery 👇", reply_markup=create_lottery_keyboard())
@router.message(Command("mylotteries"))
async def cmd_my_lotteries(message: Message) -> None:
await message.answer("View my lotteries 👇", reply_markup=main_menu_keyboard())
@router.message(Command("help"))
async def cmd_help(message: Message) -> None:
await message.answer(
"📖 <b>TG Lottery Bot</b>\n\n"
"<b>Commands</b>\n"
"/start - Open lottery manager\n"
"/newlottery - Create a new lottery\n"
"/mylotteries - View my lotteries\n"
"/help - Show this help message\n\n"
"📦 <b>Powered by</b> git.stdm.moe/Stardream/TGLotteryBot",
parse_mode="HTML",
)
+50
View File
@@ -0,0 +1,50 @@
from urllib.parse import urljoin
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
from app.core.config import settings
def _mini_app_url(path: str = "") -> str:
base = settings.mini_app_url.rstrip("/") + "/"
return urljoin(base, path.lstrip("/"))
def main_menu_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[[
InlineKeyboardButton(
text="Open lottery app",
web_app=WebAppInfo(url=_mini_app_url()),
)
]])
def create_lottery_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[[
InlineKeyboardButton(
text="Create lottery",
web_app=WebAppInfo(url=_mini_app_url("/create")),
)
]])
def lottery_detail_keyboard(lottery_id: str, text: str = "🎰 Join lottery") -> InlineKeyboardMarkup:
"""For private chat messages - web_app is allowed here."""
return InlineKeyboardMarkup(inline_keyboard=[[
InlineKeyboardButton(
text=text,
web_app=WebAppInfo(url=_mini_app_url(f"/lottery/{lottery_id}")),
)
]])
def lottery_announce_keyboard(lottery_id: str) -> InlineKeyboardMarkup:
"""For group/channel announcements - web_app is not allowed; use t.me deep link."""
from app.bot.router import bot_username
if bot_username:
url = f"https://t.me/{bot_username}?start=lottery_{lottery_id}"
else:
url = _mini_app_url(f"/lottery/{lottery_id}")
return InlineKeyboardMarkup(inline_keyboard=[[
InlineKeyboardButton(text="🎰 Join lottery", url=url)
]])
+17
View File
@@ -0,0 +1,17 @@
from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from app.core.config import settings
from app.bot.handlers import start, member
bot = Bot(
token=settings.bot_token,
default=DefaultBotProperties(parse_mode="HTML"),
)
dp = Dispatcher()
dp.include_router(start.router)
dp.include_router(member.router)
# Populated at startup by main.py after bot.get_me()
bot_username: str = ""
View File
+27
View File
@@ -0,0 +1,27 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
bot_token: str = ""
webhook_url: str = ""
webhook_path: str = "/webhook/telegram"
webhook_secret: str = ""
database_url: str = "postgresql+asyncpg://postgres:postgres@postgres:5432/tglotterybot"
mini_app_url: str = ""
avatar_cache_ttl: int = 3600
debug: bool = False
@lru_cache()
def get_settings() -> Settings:
return Settings()
settings = get_settings()
+10
View File
@@ -0,0 +1,10 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from app.core.config import settings
engine = create_async_engine(settings.database_url, echo=settings.debug)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async def get_db():
async with AsyncSessionLocal() as session:
yield session
+59
View File
@@ -0,0 +1,59 @@
import hmac
import hashlib
import time
from urllib.parse import parse_qsl
from app.core.config import settings
# Reject initData older than this many seconds (24 hours)
_MAX_AGE_SECONDS = 86_400
def validate_init_data(init_data: str) -> dict | None:
"""Validate Telegram WebApp initData using HMAC-SHA256.
Also rejects payloads whose auth_date is older than _MAX_AGE_SECONDS to
prevent indefinite replay of leaked initData strings.
"""
try:
# parse_qsl already URL-decodes each value. Decoding the whole query
# string first can turn escaped separators inside JSON values into real
# separators and make Telegram's HMAC check fail.
parsed = dict(parse_qsl(init_data, keep_blank_values=True))
hash_value = parsed.pop("hash", "")
if not hash_value:
return None
# Validate auth_date freshness before doing any crypto work
auth_date_str = parsed.get("auth_date")
if not auth_date_str:
return None
try:
age = time.time() - int(auth_date_str)
if age > _MAX_AGE_SECONDS:
return None
except ValueError:
return None
data_check_string = "\n".join(
f"{k}={v}" for k, v in sorted(parsed.items())
)
secret_key = hmac.new(
b"WebAppData",
settings.bot_token.encode(),
hashlib.sha256,
).digest()
expected = hmac.new(
secret_key,
data_check_string.encode(),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, hash_value):
return None
return parsed
except Exception:
return None
+85
View File
@@ -0,0 +1,85 @@
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from app.core.config import settings
from app.models import Base # noqa: F401 - ensure all models are registered
@asynccontextmanager
async def lifespan(app: FastAPI):
from app.bot import router as bot_router
from app.bot.router import bot, dp
me = await bot.get_me()
bot_router.bot_username = me.username or ""
from aiogram.types import BotCommand
await bot.set_my_commands([
BotCommand(command="start", description="Open lottery manager"),
BotCommand(command="newlottery", description="Create a new lottery"),
BotCommand(command="mylotteries", description="View my lotteries"),
BotCommand(command="help", description="Help and project info"),
])
from app.services.scheduler import auto_draw_scheduler
asyncio.create_task(auto_draw_scheduler())
if settings.webhook_url:
webhook_url = f"{settings.webhook_url}{settings.webhook_path}"
await bot.set_webhook(
webhook_url,
secret_token=settings.webhook_secret or None,
drop_pending_updates=True,
)
else:
# Development: long-polling mode
asyncio.create_task(dp.start_polling(bot, handle_signals=False))
yield
from app.bot.router import bot as _bot
await _bot.session.close()
app = FastAPI(title="TGLotteryBot API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# API routers
from app.api.routers import lotteries, prizes, participants, chats, users # noqa: E402
app.include_router(lotteries.router, prefix="/api")
app.include_router(prizes.router, prefix="/api")
app.include_router(participants.router, prefix="/api")
app.include_router(chats.router, prefix="/api")
app.include_router(users.router, prefix="/api")
@app.post(settings.webhook_path)
async def telegram_webhook(request: Request) -> Response:
"""Receive Telegram updates via webhook."""
from app.bot.router import bot, dp
from aiogram.types import Update
secret = request.headers.get("X-Telegram-Bot-Api-Secret-Token", "")
if settings.webhook_secret and secret != settings.webhook_secret:
return Response(status_code=403)
body = await request.json()
update = Update(**body)
await dp.feed_update(bot, update)
return Response(status_code=200)
@app.get("/health")
async def health() -> dict:
return {"status": "ok"}
+10
View File
@@ -0,0 +1,10 @@
from .base import Base
from .user import User
from .managed_chat import ManagedChat
from .lottery import Lottery, LotteryStatus
from .prize import Prize
from .participant import Participant
from .winner import Winner
from .passphrase_verification import PassphraseVerification
__all__ = ["Base", "User", "ManagedChat", "Lottery", "LotteryStatus", "Prize", "Participant", "Winner", "PassphraseVerification"]
+14
View File
@@ -0,0 +1,14 @@
from sqlalchemy import MetaData
from sqlalchemy.orm import DeclarativeBase
NAMING_CONVENTION = {
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
"pk": "pk_%(table_name)s",
}
class Base(DeclarativeBase):
metadata = MetaData(naming_convention=NAMING_CONVENTION)
+63
View File
@@ -0,0 +1,63 @@
import enum
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, JSON, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base
if TYPE_CHECKING:
from .prize import Prize
from .participant import Participant
from .winner import Winner
class LotteryStatus(str, enum.Enum):
DRAFT = "draft"
ACTIVE = "active"
DRAWING = "drawing"
FINISHED = "finished"
CANCELLED = "cancelled"
class Lottery(Base):
__tablename__ = "lotteries"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
creator_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id"))
title: Mapped[str] = mapped_column(String(255))
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[LotteryStatus] = mapped_column(String(20), default=LotteryStatus.DRAFT)
target_chat_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
target_chat_title: Mapped[str | None] = mapped_column(String(255), nullable=True)
require_membership: Mapped[bool] = mapped_column(Boolean, default=False)
required_chat_ids: Mapped[list | None] = mapped_column(JSON, nullable=True)
required_chat_titles: Mapped[list | None] = mapped_column(JSON, nullable=True)
required_chat_types: Mapped[list | None] = mapped_column(JSON, nullable=True)
required_chat_usernames: Mapped[list | None] = mapped_column(JSON, nullable=True)
announcement_targets: Mapped[list | None] = mapped_column(JSON, nullable=True)
max_participants: Mapped[int | None] = mapped_column(Integer, nullable=True)
announcement_message_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
contact_info: Mapped[str | None] = mapped_column(Text, nullable=True)
draw_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
auto_draw_count: Mapped[int | None] = mapped_column(Integer, nullable=True)
allow_multiple_wins: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
invite_link_chat_ids: Mapped[list] = mapped_column(JSON, default=list, server_default="'[]'")
creator_timezone: Mapped[str | None] = mapped_column(String(64), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
drawn_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
draw_trigger: Mapped[str | None] = mapped_column(String(20), nullable=True)
required_chat_passphrases: Mapped[list | None] = mapped_column(JSON, nullable=True)
prizes: Mapped[list["Prize"]] = relationship(
"Prize", back_populates="lottery", order_by="Prize.sort_order", cascade="all, delete-orphan"
)
participants: Mapped[list["Participant"]] = relationship(
"Participant", back_populates="lottery", cascade="all, delete-orphan"
)
winners: Mapped[list["Winner"]] = relationship(
"Winner", back_populates="lottery", cascade="all, delete-orphan"
)
+19
View File
@@ -0,0 +1,19 @@
from datetime import datetime
from sqlalchemy import BigInteger, Boolean, DateTime, String
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class ManagedChat(Base):
__tablename__ = "managed_chats"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True) # Telegram chat_id
title: Mapped[str] = mapped_column(String(255))
username: Mapped[str | None] = mapped_column(String(255), nullable=True)
chat_type: Mapped[str] = mapped_column(String(50)) # group, supergroup, channel
invite_link: Mapped[str | None] = mapped_column(String(512), nullable=True)
last_message_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
added_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+28
View File
@@ -0,0 +1,28 @@
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import BigInteger, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base
if TYPE_CHECKING:
from .lottery import Lottery
from .user import User
class Participant(Base):
__tablename__ = "participants"
__table_args__ = (UniqueConstraint("lottery_id", "user_id"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
lottery_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("lotteries.id", ondelete="CASCADE")
)
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id"))
joined_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
lottery: Mapped["Lottery"] = relationship("Lottery", back_populates="participants")
user: Mapped["User"] = relationship("User")
@@ -0,0 +1,20 @@
import uuid
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, UniqueConstraint
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class PassphraseVerification(Base):
__tablename__ = "passphrase_verifications"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
lottery_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("lotteries.id", ondelete="CASCADE"))
chat_id: Mapped[int] = mapped_column(BigInteger)
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id"))
verified_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
__table_args__ = (UniqueConstraint("lottery_id", "chat_id", "user_id"),)
+26
View File
@@ -0,0 +1,26 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base
if TYPE_CHECKING:
from .lottery import Lottery
class Prize(Base):
__tablename__ = "prizes"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
lottery_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("lotteries.id", ondelete="CASCADE")
)
name: Mapped[str] = mapped_column(String(255))
description: Mapped[str | None] = mapped_column(Text, nullable=True)
quantity: Mapped[int] = mapped_column(Integer, default=1)
sort_order: Mapped[int] = mapped_column(Integer, default=0)
lottery: Mapped["Lottery"] = relationship("Lottery", back_populates="prizes")
+16
View File
@@ -0,0 +1,16 @@
from datetime import datetime
from sqlalchemy import BigInteger, DateTime, String
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
username: Mapped[str | None] = mapped_column(String(255), nullable=True)
first_name: Mapped[str] = mapped_column(String(255))
last_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
+34
View File
@@ -0,0 +1,34 @@
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import BigInteger, DateTime, ForeignKey, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base
if TYPE_CHECKING:
from .lottery import Lottery
from .user import User
from .prize import Prize
class Winner(Base):
__tablename__ = "winners"
__table_args__ = ()
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
lottery_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("lotteries.id", ondelete="CASCADE")
)
user_id: Mapped[int] = mapped_column(BigInteger, ForeignKey("users.id"))
prize_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("prizes.id", ondelete="SET NULL"), nullable=True
)
prize_name: Mapped[str] = mapped_column(String(255)) # snapshot at draw time
drawn_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
lottery: Mapped["Lottery"] = relationship("Lottery", back_populates="winners")
user: Mapped["User"] = relationship("User")
prize: Mapped["Prize | None"] = relationship("Prize")
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)