631 lines
20 KiB
Python
631 lines
20 KiB
Python
"""
|
||
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 ────────────────────
|
||
|
||
@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,
|
||
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
|
||
|
||
timeout = aiohttp.ClientTimeout(total=120)
|
||
session = aiohttp.ClientSession(timeout=timeout)
|
||
try:
|
||
resp = await session.get(url, headers=headers, allow_redirects=True)
|
||
except aiohttp.ClientError:
|
||
await session.close()
|
||
raise HTTPException(status_code=502, detail="Failed to fetch media")
|
||
|
||
if resp.status not in {200, 206}:
|
||
await resp.release()
|
||
await session.close()
|
||
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()
|
||
await session.close()
|
||
|
||
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()
|
||
await session.close()
|
||
|
||
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)
|