forked from zovos/bot_tg
395 lines
12 KiB
Python
395 lines
12 KiB
Python
"""
|
||
FenyaBot Mini App — FastAPI Backend.
|
||
|
||
REST API для Telegram Mini App. Использует те же SQLite базы и
|
||
бизнес-логику что и основной бот.
|
||
"""
|
||
|
||
import asyncio
|
||
import logging
|
||
import os
|
||
import sys
|
||
from contextlib import asynccontextmanager
|
||
from typing import Annotated
|
||
|
||
from fastapi import FastAPI, Depends, Header, HTTPException
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.staticfiles import StaticFiles
|
||
from pydantic import BaseModel
|
||
|
||
# Добавляем корень проекта в sys.path чтобы импортировать модули бота
|
||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||
|
||
import config
|
||
from games.casino import play_casino, get_user_length, spin_slots, MULTIPLIERS, update_user_length
|
||
from games.betting import (
|
||
fetch_matches,
|
||
place_bet,
|
||
get_user_bets as _get_user_bets_raw,
|
||
get_user_bet_history as _get_user_bet_history_raw,
|
||
)
|
||
from webapp.auth import validate_init_data
|
||
|
||
logging.basicConfig(level=logging.INFO)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# ──────────────────── Auth dependency ────────────────────
|
||
|
||
async def get_current_user(x_telegram_init_data: Annotated[str, Header()] = "") -> dict:
|
||
"""Извлекает и валидирует пользователя из заголовка X-Telegram-Init-Data."""
|
||
user = validate_init_data(x_telegram_init_data)
|
||
if user is None:
|
||
raise HTTPException(status_code=401, detail="Invalid Telegram auth")
|
||
return user
|
||
|
||
|
||
# ──────────────────── Pydantic models ────────────────────
|
||
|
||
class BetRequest(BaseModel):
|
||
league: str
|
||
match_index: int
|
||
outcome: str # "1", "X", "2"
|
||
amount: float
|
||
|
||
class CasinoRequest(BaseModel):
|
||
bet: float
|
||
|
||
class TalkRequest(BaseModel):
|
||
text: str
|
||
|
||
class PenisResult(BaseModel):
|
||
success: bool
|
||
message: str
|
||
length: float | None = None
|
||
|
||
class ProfileResponse(BaseModel):
|
||
user_id: int
|
||
first_name: str
|
||
username: str
|
||
length: float | None
|
||
active_bets: int
|
||
|
||
class MatchResponse(BaseModel):
|
||
index: int
|
||
home: str
|
||
away: str
|
||
commence: str
|
||
odds_home: float | None
|
||
odds_draw: float | None
|
||
odds_away: float | None
|
||
|
||
class LeagueInfo(BaseModel):
|
||
alias: str
|
||
name: str
|
||
sport_key: str
|
||
|
||
class CasinoResult(BaseModel):
|
||
reels: list[str]
|
||
matches: int
|
||
multiplier: float
|
||
delta: float
|
||
new_length: float
|
||
won: bool
|
||
|
||
class SchedulePair(BaseModel):
|
||
pair: int | None
|
||
title: str
|
||
time_start: str | None = None
|
||
time_end: str | None = None
|
||
|
||
|
||
# ──────────────────── Lifespan ────────────────────
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(app: FastAPI):
|
||
logger.info("Mini App API started")
|
||
yield
|
||
logger.info("Mini App API stopped")
|
||
|
||
|
||
# ──────────────────── App ────────────────────
|
||
|
||
app = FastAPI(
|
||
title="FenyaBot Mini App API",
|
||
version="1.0.0",
|
||
lifespan=lifespan,
|
||
)
|
||
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=["*"],
|
||
allow_credentials=True,
|
||
allow_methods=["*"],
|
||
allow_headers=["*"],
|
||
)
|
||
|
||
|
||
# ──────────────────── Endpoints ────────────────────
|
||
|
||
# --- Профиль ---
|
||
|
||
@app.get("/api/profile")
|
||
async def get_profile(user: dict = Depends(get_current_user)) -> ProfileResponse:
|
||
"""Профиль пользователя: баланс и количество активных ставок."""
|
||
user_id = user["user_id"]
|
||
length = await asyncio.to_thread(get_user_length, user_id)
|
||
|
||
# Считаем активные ставки
|
||
import sqlite3
|
||
active_bets = 0
|
||
try:
|
||
with sqlite3.connect(config.BET_DB_PATH) as conn:
|
||
row = conn.execute(
|
||
"SELECT COUNT(*) FROM bets WHERE user_id = ? AND status = 'pending'",
|
||
(user_id,),
|
||
).fetchone()
|
||
active_bets = row[0] if row else 0
|
||
except Exception:
|
||
pass
|
||
|
||
return ProfileResponse(
|
||
user_id=user_id,
|
||
first_name=user["first_name"],
|
||
username=user["username"],
|
||
length=length,
|
||
active_bets=active_bets,
|
||
)
|
||
|
||
|
||
# --- Лиги ---
|
||
|
||
@app.get("/api/leagues")
|
||
async def get_leagues() -> list[LeagueInfo]:
|
||
"""Список доступных лиг для ставок."""
|
||
labels = {
|
||
"epl": "⚽ EPL (Англия)",
|
||
"ucl": "⚽ Champions League",
|
||
"laliga": "⚽ La Liga (Испания)",
|
||
"bundesliga": "⚽ Bundesliga (Германия)",
|
||
"seriea": "⚽ Serie A (Италия)",
|
||
"ligue1": "⚽ Ligue 1 (Франция)",
|
||
"europa": "⚽ Europa League",
|
||
}
|
||
return [
|
||
LeagueInfo(alias=alias, name=labels.get(alias, alias), sport_key=key)
|
||
for alias, key in config.ODDS_SPORTS.items()
|
||
]
|
||
|
||
|
||
# --- Матчи ---
|
||
|
||
@app.get("/api/matches/{league}")
|
||
async def get_matches(league: str) -> list[MatchResponse]:
|
||
"""Матчи для конкретной лиги."""
|
||
if league not in config.ODDS_SPORTS:
|
||
raise HTTPException(status_code=404, detail="League not found")
|
||
|
||
sport_key = config.ODDS_SPORTS[league]
|
||
matches = await fetch_matches(sport_key)
|
||
|
||
result = []
|
||
for i, m in enumerate(matches):
|
||
home = m.get("home", "?")
|
||
away = m.get("away", "?")
|
||
odds = m.get("odds", {})
|
||
|
||
odds_home = odds.get(home)
|
||
odds_away = odds.get(away)
|
||
|
||
_DRAW_NAMES = {"draw", "tie", "ничья"}
|
||
draw_name = next((name for name in odds if name.casefold() in _DRAW_NAMES), None)
|
||
odds_draw = odds.get(draw_name) if draw_name else None
|
||
|
||
result.append(MatchResponse(
|
||
index=i + 1,
|
||
home=home,
|
||
away=away,
|
||
commence=m.get("commence", ""),
|
||
odds_home=odds_home,
|
||
odds_draw=odds_draw,
|
||
odds_away=odds_away,
|
||
))
|
||
|
||
return result
|
||
|
||
|
||
# --- Ставки ---
|
||
|
||
@app.post("/api/bet")
|
||
async def create_bet(
|
||
req: BetRequest,
|
||
user: dict = Depends(get_current_user),
|
||
):
|
||
"""Разместить ставку на матч."""
|
||
user_id = user["user_id"]
|
||
display_name = user["username"] or user["first_name"]
|
||
result = await place_bet(user_id, display_name, req.league, req.match_index, req.outcome, req.amount)
|
||
return {"message": result}
|
||
|
||
|
||
@app.get("/api/mybets")
|
||
async def get_my_bets(user: dict = Depends(get_current_user)):
|
||
"""Активные ставки пользователя."""
|
||
user_id = user["user_id"]
|
||
result = await _get_user_bets_raw(user_id)
|
||
return {"text": result}
|
||
|
||
|
||
@app.get("/api/bet_history")
|
||
async def get_bet_history(user: dict = Depends(get_current_user)):
|
||
"""История ставок за месяц + статистика."""
|
||
user_id = user["user_id"]
|
||
result = await _get_user_bet_history_raw(user_id)
|
||
return {"text": result}
|
||
|
||
|
||
# --- Казино ---
|
||
|
||
@app.post("/api/casino")
|
||
async def play_casino_api(
|
||
req: CasinoRequest,
|
||
user: dict = Depends(get_current_user),
|
||
) -> CasinoResult:
|
||
"""Играть в казино (слоты)."""
|
||
import random
|
||
|
||
user_id = user["user_id"]
|
||
|
||
current = await asyncio.to_thread(get_user_length, user_id)
|
||
if current is None:
|
||
raise HTTPException(status_code=400, detail="Сначала крутни /penis")
|
||
if current <= 0:
|
||
raise HTTPException(status_code=400, detail="Баланс <= 0")
|
||
if req.bet > config.CASINO_MAX_BET:
|
||
raise HTTPException(status_code=400, detail=f"Макс ставка: {config.CASINO_MAX_BET} см")
|
||
if req.bet <= 0:
|
||
raise HTTPException(status_code=400, detail="Ставка > 0")
|
||
if req.bet > current:
|
||
raise HTTPException(status_code=400, detail=f"У тебя {current:.1f} см")
|
||
|
||
reels, match_count = await asyncio.to_thread(spin_slots)
|
||
|
||
if match_count >= 2:
|
||
min_mult, max_mult = MULTIPLIERS[match_count]
|
||
multiplier = round(random.uniform(min_mult, max_mult), 1)
|
||
winnings = round(req.bet * multiplier, 1)
|
||
new_length = await asyncio.to_thread(update_user_length, user_id, winnings)
|
||
return CasinoResult(
|
||
reels=reels, matches=match_count, multiplier=multiplier,
|
||
delta=winnings, new_length=new_length or current, won=True,
|
||
)
|
||
else:
|
||
new_length = await asyncio.to_thread(update_user_length, user_id, -req.bet)
|
||
return CasinoResult(
|
||
reels=reels, matches=0, multiplier=0,
|
||
delta=-req.bet, new_length=new_length or current, won=False,
|
||
)
|
||
|
||
|
||
# --- Penis Game ---
|
||
|
||
@app.post("/api/penis")
|
||
async def play_penis_api(user: dict = Depends(get_current_user)) -> PenisResult:
|
||
"""Крутить penis (раз в 24 часа)."""
|
||
# Импортируем из main.py
|
||
from main import play_penis, format_penis_user_name
|
||
|
||
user_id = user["user_id"]
|
||
display_name = user["username"] or user["first_name"] or f"user_{user_id}"
|
||
success, message = await asyncio.to_thread(play_penis, user_id, display_name)
|
||
length = await asyncio.to_thread(get_user_length, user_id)
|
||
return PenisResult(success=success, message=message, length=length)
|
||
|
||
|
||
# --- Топ ---
|
||
|
||
@app.get("/api/top")
|
||
async def get_top():
|
||
"""Лидерборд по размеру."""
|
||
from main import build_penis_top
|
||
result = await asyncio.to_thread(build_penis_top)
|
||
return {"text": result}
|
||
|
||
|
||
# --- Расписание ---
|
||
|
||
@app.get("/api/schedule")
|
||
async def get_schedule(day: int | None = None) -> list[SchedulePair]:
|
||
"""Расписание пар. day = 0-6 (пн-вс), None = сегодня."""
|
||
from datetime import datetime
|
||
from zoneinfo import ZoneInfo
|
||
|
||
if day is None:
|
||
day = datetime.now(ZoneInfo(config.DEFAULT_TZ)).weekday()
|
||
|
||
# Определяем неделю (верхняя/нижняя)
|
||
now = datetime.now(ZoneInfo(config.DEFAULT_TZ))
|
||
iso_week = now.isocalendar()[1]
|
||
if config.UPPER_WEEK_IS_ODD:
|
||
week_type = "upper" if iso_week % 2 == 1 else "lower"
|
||
else:
|
||
week_type = "upper" if iso_week % 2 == 0 else "lower"
|
||
|
||
lessons = config.LESSON_SCHEDULE.get(week_type, {}).get(day, [])
|
||
|
||
result = []
|
||
for lesson in lessons:
|
||
pair_num = lesson.get("pair")
|
||
time_start = time_end = None
|
||
if pair_num and pair_num in config.PAIR_TIMES:
|
||
start, end = config.PAIR_TIMES[pair_num]
|
||
time_start = f"{start.hour:02d}:{start.minute:02d}"
|
||
time_end = f"{end.hour:02d}:{end.minute:02d}"
|
||
|
||
result.append(SchedulePair(
|
||
pair=pair_num,
|
||
title=lesson.get("title", ""),
|
||
time_start=time_start,
|
||
time_end=time_end,
|
||
))
|
||
|
||
return result
|
||
|
||
|
||
# --- ИИ Чат ---
|
||
|
||
@app.post("/api/talk")
|
||
async def talk_api(
|
||
req: TalkRequest,
|
||
user: dict = Depends(get_current_user),
|
||
):
|
||
"""Отправить сообщение ИИ."""
|
||
from AI.talk_handler import _generate_response, push_message
|
||
|
||
user_id = user["user_id"]
|
||
user_name = user["first_name"] or "кент"
|
||
|
||
# Используем user_id как chat_id для личных сообщений в webapp
|
||
chat_id = user_id
|
||
|
||
try:
|
||
response = await _generate_response(chat_id, req.text, user_name)
|
||
if not response:
|
||
response = "Братуха, чёт базар не клеится."
|
||
await asyncio.to_thread(push_message, chat_id, "user", user_name, req.text)
|
||
await asyncio.to_thread(push_message, chat_id, "assistant", "бот", response)
|
||
return {"response": response}
|
||
except Exception:
|
||
logger.exception("Talk API failed")
|
||
raise HTTPException(status_code=500, detail="LLM error")
|
||
|
||
|
||
# ──────────────────── Static files (для локального теста) ────────────────────
|
||
|
||
_frontend_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend")
|
||
if os.path.isdir(_frontend_dir):
|
||
app.mount("/", StaticFiles(directory=_frontend_dir, html=True), name="frontend")
|
||
|
||
|
||
# ──────────────────── Entry point ────────────────────
|
||
|
||
if __name__ == "__main__":
|
||
import uvicorn
|
||
port = int(os.getenv("API_PORT", "8080"))
|
||
uvicorn.run(app, host="0.0.0.0", port=port)
|