This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,41 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
|
||||
# URL is set dynamically in env.py from settings
|
||||
sqlalchemy.url =
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,52 @@
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models import Base # noqa: F401 - registers all models
|
||||
|
||||
config = context.config
|
||||
|
||||
# Use the app's database URL (swap driver for sync alembic operations)
|
||||
db_url = settings.database_url
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=db_url.replace("+asyncpg", ""),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
engine: AsyncEngine = create_async_engine(db_url)
|
||||
async with engine.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,25 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,134 @@
|
||||
"""initial_schema
|
||||
|
||||
Revision ID: 86cca6d7adcb
|
||||
Revises:
|
||||
Create Date: 2026-05-27 16:31:52.870967
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = '86cca6d7adcb'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'users',
|
||||
sa.Column('id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('username', sa.String(255), nullable=True),
|
||||
sa.Column('first_name', sa.String(255), nullable=False),
|
||||
sa.Column('last_name', sa.String(255), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'lotteries',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('creator_id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('title', sa.String(255), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('status', sa.String(20), nullable=False),
|
||||
sa.Column('target_chat_id', sa.BigInteger(), nullable=True),
|
||||
sa.Column('target_chat_title', sa.String(255), nullable=True),
|
||||
sa.Column('require_membership', sa.Boolean(), nullable=False),
|
||||
sa.Column('required_chat_ids', sa.JSON(), nullable=True),
|
||||
sa.Column('required_chat_titles', sa.JSON(), nullable=True),
|
||||
sa.Column('required_chat_types', sa.JSON(), nullable=True),
|
||||
sa.Column('required_chat_usernames', sa.JSON(), nullable=True),
|
||||
sa.Column('announcement_targets', sa.JSON(), nullable=True),
|
||||
sa.Column('max_participants', sa.Integer(), nullable=True),
|
||||
sa.Column('announcement_message_id', sa.BigInteger(), nullable=True),
|
||||
sa.Column('contact_info', sa.Text(), nullable=True),
|
||||
sa.Column('draw_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('auto_draw_count', sa.Integer(), nullable=True),
|
||||
sa.Column('allow_multiple_wins', sa.Boolean(), server_default=sa.text('false'), nullable=False),
|
||||
sa.Column('invite_link_chat_ids', sa.JSON(), server_default=sa.text("'[]'"), nullable=False),
|
||||
sa.Column('creator_timezone', sa.String(64), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.Column('drawn_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('draw_trigger', sa.String(20), nullable=True),
|
||||
sa.Column('required_chat_passphrases', sa.JSON(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['creator_id'], ['users.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'prizes',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('lottery_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('name', sa.String(255), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('quantity', sa.Integer(), nullable=False),
|
||||
sa.Column('sort_order', sa.Integer(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['lottery_id'], ['lotteries.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'participants',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('lottery_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('user_id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('joined_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['lottery_id'], ['lotteries.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('lottery_id', 'user_id'),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'winners',
|
||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('lottery_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('user_id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('prize_id', postgresql.UUID(as_uuid=True), nullable=True),
|
||||
sa.Column('prize_name', sa.String(255), nullable=False),
|
||||
sa.Column('drawn_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['lottery_id'], ['lotteries.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['prize_id'], ['prizes.id'], ondelete='SET NULL'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'managed_chats',
|
||||
sa.Column('id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('title', sa.String(255), nullable=False),
|
||||
sa.Column('username', sa.String(255), nullable=True),
|
||||
sa.Column('chat_type', sa.String(50), nullable=False),
|
||||
sa.Column('invite_link', sa.String(512), nullable=True),
|
||||
sa.Column('last_message_id', sa.BigInteger(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False),
|
||||
sa.Column('added_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
'passphrase_verifications',
|
||||
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
|
||||
sa.Column('lottery_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column('chat_id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('user_id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('verified_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['lottery_id'], ['lotteries.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('lottery_id', 'chat_id', 'user_id'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('passphrase_verifications')
|
||||
op.drop_table('managed_chats')
|
||||
op.drop_table('winners')
|
||||
op.drop_table('participants')
|
||||
op.drop_table('prizes')
|
||||
op.drop_table('lotteries')
|
||||
op.drop_table('users')
|
||||
@@ -0,0 +1,25 @@
|
||||
"""convert_status_to_varchar
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: 86cca6d7adcb
|
||||
Create Date: 2026-06-02 12:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = 'a1b2c3d4e5f6'
|
||||
down_revision: Union[str, None] = '86cca6d7adcb'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE lotteries ALTER COLUMN status TYPE VARCHAR(20) USING status::text")
|
||||
op.execute("DROP TYPE IF EXISTS lotterystatus")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("CREATE TYPE lotterystatus AS ENUM ('draft', 'active', 'drawing', 'finished', 'cancelled')")
|
||||
op.execute("ALTER TABLE lotteries ALTER COLUMN status TYPE lotterystatus USING status::lotterystatus")
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"}
|
||||
@@ -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()
|
||||
]
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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)
|
||||
]])
|
||||
@@ -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 = ""
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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"),)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -0,0 +1,11 @@
|
||||
fastapi>=0.111.0
|
||||
uvicorn[standard]>=0.30.0
|
||||
aiogram>=3.10.0
|
||||
sqlalchemy[asyncio]>=2.0.30
|
||||
asyncpg>=0.29.0
|
||||
alembic>=1.13.0
|
||||
pydantic>=2.7.0
|
||||
pydantic-settings>=2.3.0
|
||||
redis[hiredis]>=5.0.0
|
||||
httpx>=0.27.0
|
||||
python-multipart>=0.0.9
|
||||
Reference in New Issue
Block a user