""" FenyaBot Mini App — FastAPI Backend. REST API для Telegram Mini App. Использует те же SQLite базы и бизнес-логику что и основной бот. """ import asyncio import logging import os import hashlib from urllib.parse import quote from urllib.parse import urljoin from urllib.parse import urlparse import sys from contextlib import asynccontextmanager from typing import Annotated import aiohttp from fastapi import FastAPI, Depends, Header, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import Response, StreamingResponse 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 from webapp.shorties import fetch_shorties_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 ──────────────────── _http_session: aiohttp.ClientSession | None = None def get_http_session() -> aiohttp.ClientSession: return _http_session @asynccontextmanager async def lifespan(app: FastAPI): global _http_session _http_session = aiohttp.ClientSession( timeout=aiohttp.ClientTimeout(total=120), connector=aiohttp.TCPConnector(limit=64, ttl_dns_cache=300), ) logger.info("Mini App API started") yield await _http_session.close() 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) active_bets = 0 try: from db import get_conn with get_conn() as conn: cur = conn.cursor() cur.execute( "SELECT COUNT(*) FROM bets WHERE user_id = %s AND status = 'pending'", (user_id,), ) row = cur.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 = { "dota2": "🎮 Dota 2", } 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. Разместить ставку (синхронная функция) success, result = await asyncio.to_thread(place_bet, user_id, match, req.league, team, req.amount) if not success: raise HTTPException(status_code=400, detail=result) 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 round(req.bet, 2) > round(current, 2): 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, tags: str = "", user: dict = Depends(get_current_user), ): """Получить ленту анимированных постов для FurTok.""" try: feed = await fetch_furtok_feed(safe=safe, page=page, extra_tags=tags) return {"feed": feed} except Exception as e: logger.exception("FurTok API failed") raise HTTPException(status_code=500, detail=str(e)) @app.get("/api/furtok/shorties") async def get_shorties_feed_api( page: int = 1, count: int = 8, user: dict = Depends(get_current_user), ): """Случайная shorties-лента с Pornhub.""" try: # page используем как "seed-сдвиг" для подгрузки следующих рандомных наборов. random_pages = 2 + min(max(page, 1), 6) feed = await fetch_shorties_feed(count=count, random_pages=random_pages) return {"feed": feed} except Exception as e: logger.exception("Shorties API failed") raise HTTPException(status_code=500, detail=str(e)) @app.get("/api/proxy-image") async def proxy_image(url: str): """Проксирует картинку с e621 чтобы обойти hotlink protection.""" allowed = ("static1.e621.net", "static2.e621.net", "static1.e926.net") from urllib.parse import urlparse parsed = urlparse(url) if parsed.hostname not in allowed: raise HTTPException(status_code=403, detail="Forbidden host") headers = { "User-Agent": f"FenyaBot/1.0 (by {config.E621_LOGIN or 'unknown'} on e621)", } try: async with aiohttp.ClientSession() as session: async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=15)) as resp: if resp.status != 200: raise HTTPException(status_code=resp.status, detail="Upstream error") content_type = resp.content_type or "image/png" body = await resp.read() return Response( content=body, media_type=content_type, headers={"Cache-Control": "public, max-age=86400"}, ) except aiohttp.ClientError: raise HTTPException(status_code=502, detail="Failed to fetch image") def _is_allowed_shorties_media_host(hostname: str) -> bool: host = (hostname or "").lower() if not host: return False if host in {"www.pornhub.com", "pornhub.com", "phncdn.com"}: return True if host.endswith(".phncdn.com"): return True return False @app.get("/api/proxy-shorties-media") async def proxy_shorties_media(url: str, request: Request): """Проксирует видео shorties, чтобы избежать ограничений hotlink/CORS.""" parsed = urlparse(url) if parsed.scheme not in {"http", "https"} or not _is_allowed_shorties_media_host(parsed.hostname or ""): raise HTTPException(status_code=403, detail="Forbidden host") headers = { "User-Agent": ( "Mozilla/5.0 (X11; Linux x86_64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/124.0.0.0 Safari/537.36" ), "Referer": "https://www.pornhub.com/shorties", "Origin": "https://www.pornhub.com", } range_header = request.headers.get("range") if range_header: headers["Range"] = range_header session = get_http_session() try: resp = await session.get(url, headers=headers, allow_redirects=True) except aiohttp.ClientError: raise HTTPException(status_code=502, detail="Failed to fetch media") if resp.status not in {200, 206}: await resp.release() raise HTTPException(status_code=resp.status, detail="Upstream error") content_type = resp.headers.get("Content-Type", "application/octet-stream") if ".m3u8" in parsed.path.lower() or "mpegurl" in content_type.lower(): try: body = await resp.text() finally: await resp.release() def _proxy_media_url(target_url: str) -> str: return f"/api/proxy-shorties-media?url={quote(target_url, safe='')}" rewritten_lines: list[str] = [] for raw_line in body.splitlines(): stripped = raw_line.strip() if not stripped: rewritten_lines.append(raw_line) continue if stripped.startswith("#EXT-X-KEY") and 'URI="' in raw_line: prefix, rest = raw_line.split('URI="', 1) original_uri, suffix = rest.split('"', 1) absolute_uri = urljoin(url, original_uri) rewritten_lines.append(f'{prefix}URI="{_proxy_media_url(absolute_uri)}"{suffix}') continue if stripped.startswith("#"): rewritten_lines.append(raw_line) continue absolute_uri = urljoin(url, stripped) rewritten_lines.append(_proxy_media_url(absolute_uri)) playlist_body = "\n".join(rewritten_lines) return Response( content=playlist_body, media_type=content_type, headers={ "Cache-Control": "public, max-age=120", "Access-Control-Allow-Origin": "*", }, ) passthrough_headers = { "Cache-Control": "public, max-age=600", "Accept-Ranges": resp.headers.get("Accept-Ranges", "bytes"), "Access-Control-Allow-Origin": "*", } if resp.headers.get("Content-Length"): passthrough_headers["Content-Length"] = resp.headers["Content-Length"] if resp.headers.get("Content-Range"): passthrough_headers["Content-Range"] = resp.headers["Content-Range"] async def _stream(): try: async for chunk in resp.content.iter_chunked(64 * 1024): yield chunk finally: await resp.release() return StreamingResponse( _stream(), media_type=content_type, status_code=resp.status, headers=passthrough_headers, ) # --- ИИ Чат --- @app.post("/api/talk") async def talk_api( req: TalkRequest, user: dict = Depends(get_current_user), ): """Отправить сообщение ИИ.""" from AI.talk_handler import ( BOT_MEMORY_NAME, EMPTY_RESPONSE_TEXT, SYSTEM_PROMPT, _generate_response, _normalize_reply, push_message, ) user_id = user["user_id"] user_name = user["first_name"] or "кент" # Используем user_id как chat_id для личных сообщений в webapp chat_id = user_id try: await asyncio.to_thread(push_message, chat_id, "user", user_name, req.text, user_id=user_id) response = await _generate_response( chat_id, system_prompt=SYSTEM_PROMPT, user_id=user_id, max_tokens=220, temperature=0.8, top_p=0.9, ) normalized_response = _normalize_reply(response) if not normalized_response: normalized_response = EMPTY_RESPONSE_TEXT await asyncio.to_thread( push_message, chat_id, "assistant", BOT_MEMORY_NAME, normalized_response, user_id=user_id, ) return {"response": normalized_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)