1
0
Fork 0
forked from zovos/bot_tg
bot_tg/webapp/api.py
q add3553ce0 feat: переход с SQLite на PostgreSQL + скрипт миграции
- Все 4 SQLite базы объединены в одну PostgreSQL
- Новый db.py с ThreadedConnectionPool (psycopg2)
- docker-compose: сервис postgres с healthcheck, данные в ./db/postgres
- scripts/migrate_sqlite_to_pg.py — миграция существующих данных
- Убраны мёртвый код и дублирующий import в main.py и talk_handler.py
- .env удалён из git-индекса

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 23:25:22 +03:00

632 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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)
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 = {
"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)