""" 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_match_by_index, resolve_match_outcome, 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 from games.uwu import fetch_uwu_post, fetch_furtok_feed 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"] # 1. Найти матч по индексу match = await get_match_by_index(req.league, req.match_index) if not match: raise HTTPException(status_code=404, detail="Матч не найден") # 2. Преобразовать код исхода (1/X/2) в название команды team = resolve_match_outcome(match, req.outcome) if not team: raise HTTPException(status_code=400, detail="Неверный исход ставки") # 3. Разместить ставку (синхронная функция) result = await asyncio.to_thread(place_bet, user_id, match, req.league, team, 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 asyncio.to_thread(_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 asyncio.to_thread(_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 # --- UwU Арты --- @app.get("/api/uwu") async def get_uwu_api( tags: str = "rating:safe score:>500 -animated", user: dict = Depends(get_current_user), ): """Отправить случайный uwu арт по тегам.""" try: tags += " order:random score:>300" post = await fetch_uwu_post(tags) return {"response": post} except Exception as e: logger.exception("UwU API failed") raise HTTPException(status_code=500, detail=str(e)) # --- FurTok Лента --- @app.get("/api/furtok") async def get_furtok_feed_api( safe: bool = True, page: int = 1, user: dict = Depends(get_current_user), ): """Получить ленту анимированных постов для FurTok.""" try: feed = await fetch_furtok_feed(safe=safe, page=page) return {"feed": feed} except Exception as e: logger.exception("FurTok API failed") raise HTTPException(status_code=500, detail=str(e)) # --- ИИ Чат --- @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)