forked from zovos/bot_tg
Merge pull request 'patch_Oreshnik' (#23) from Dan4ick/bot_tg:feature/FenyaBot into main
Reviewed-on: zovos/bot_tg#23
This commit is contained in:
commit
c307f48ebb
16 changed files with 2549 additions and 25 deletions
18
Dockerfile.api
Normal file
18
Dockerfile.api
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
FROM python:3.11-slim
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt ./
|
||||||
|
COPY webapp/requirements-api.txt ./requirements-api.txt
|
||||||
|
RUN python -m pip install --upgrade pip \
|
||||||
|
&& python -m pip install -r requirements.txt \
|
||||||
|
&& python -m pip install -r requirements-api.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN mkdir -p /db
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["python", "-m", "webapp.api"]
|
||||||
|
|
@ -11,5 +11,32 @@ services:
|
||||||
PENIS_DB_PATH: /db/penis_stats.sqlite3
|
PENIS_DB_PATH: /db/penis_stats.sqlite3
|
||||||
CHAT_HISTORY_DB_PATH: /db/chat_history.sqlite3
|
CHAT_HISTORY_DB_PATH: /db/chat_history.sqlite3
|
||||||
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
|
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
|
||||||
|
BET_DB_PATH: /db/bets.sqlite3
|
||||||
init: true
|
init: true
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
webapp-api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.api
|
||||||
|
volumes:
|
||||||
|
- ./db:/db
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
TZ: Europe/Moscow
|
||||||
|
PENIS_DB_PATH: /db/penis_stats.sqlite3
|
||||||
|
CHAT_HISTORY_DB_PATH: /db/chat_history.sqlite3
|
||||||
|
POLYCHAETSI_STATS_PATH: /db/polychaetsi_stats.json
|
||||||
|
BET_DB_PATH: /db/bets.sqlite3
|
||||||
|
API_PORT: "8080"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
webapp-frontend:
|
||||||
|
build:
|
||||||
|
context: ./webapp/frontend
|
||||||
|
ports:
|
||||||
|
- "3000:80"
|
||||||
|
depends_on:
|
||||||
|
- webapp-api
|
||||||
|
restart: unless-stopped
|
||||||
|
|
|
||||||
94
games/uwu.py
94
games/uwu.py
|
|
@ -31,8 +31,9 @@ async def handle_uwu_cmd(message: types.Message):
|
||||||
if len(args) > 1:
|
if len(args) > 1:
|
||||||
tags = args[1].strip()
|
tags = args[1].strip()
|
||||||
|
|
||||||
tags += " order:random"
|
tags += " order:random score:>300"
|
||||||
|
|
||||||
|
async def fetch_uwu_post(tags: str) -> dict:
|
||||||
url = "https://e621.net/posts.json"
|
url = "https://e621.net/posts.json"
|
||||||
params = {
|
params = {
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
|
|
@ -47,28 +48,102 @@ async def handle_uwu_cmd(message: types.Message):
|
||||||
"User-Agent": f"Sex Bomba TG Bot/1.0 (by {config.E621_LOGIN or 'unknown'} on e621)"
|
"User-Agent": f"Sex Bomba TG Bot/1.0 (by {config.E621_LOGIN or 'unknown'} on e621)"
|
||||||
}
|
}
|
||||||
|
|
||||||
try:
|
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
async with session.get(url, params=params, headers=headers, auth=auth) as resp:
|
async with session.get(url, params=params, headers=headers, auth=auth) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
logger.error(f"e621 API Error: {resp.status}")
|
logger.error(f"e621 API Error: {resp.status}")
|
||||||
await message.reply("Не удалось получить картинку от сервера :(")
|
raise Exception("Не удалось получить картинку от сервера :(")
|
||||||
return
|
|
||||||
data = await resp.json()
|
data = await resp.json()
|
||||||
|
|
||||||
posts = data.get("posts", [])
|
posts = data.get("posts", [])
|
||||||
if not posts:
|
if not posts:
|
||||||
await message.reply("Ничего не найдено по этим тегам :(")
|
raise Exception("Ничего не найдено по этим тегам :(")
|
||||||
return
|
|
||||||
|
|
||||||
post = posts[0]
|
post = posts[0]
|
||||||
file_url = post.get("file", {}).get("url")
|
file_url = post.get("file", {}).get("url")
|
||||||
ext = post.get("file", {}).get("ext", "png")
|
ext = post.get("file", {}).get("ext", "png")
|
||||||
if not file_url:
|
if not file_url:
|
||||||
await message.reply("У найденного поста нет прямого URL изображения :(")
|
raise Exception("У найденного поста нет прямого URL изображения :(")
|
||||||
return
|
|
||||||
|
|
||||||
caption = random.choice(CAPTIONS)
|
caption = random.choice(CAPTIONS)
|
||||||
|
return {
|
||||||
|
"url": file_url,
|
||||||
|
"ext": ext,
|
||||||
|
"caption": caption
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fetch_furtok_feed(safe: bool = True, page: int = 1) -> list[dict]:
|
||||||
|
rating = "rating:safe" if safe else "-rating:safe"
|
||||||
|
tags = f"{rating} animated order:random score:>200"
|
||||||
|
|
||||||
|
url = "https://e621.net/posts.json"
|
||||||
|
params = {
|
||||||
|
"tags": tags,
|
||||||
|
"limit": 10,
|
||||||
|
"page": page,
|
||||||
|
}
|
||||||
|
|
||||||
|
auth = None
|
||||||
|
if config.E621_LOGIN and config.E621_API_KEY:
|
||||||
|
auth = aiohttp.BasicAuth(config.E621_LOGIN, config.E621_API_KEY)
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"User-Agent": f"Sex Bomba TG Bot/1.0 (by {config.E621_LOGIN or 'unknown'} on e621)"
|
||||||
|
}
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(url, params=params, headers=headers, auth=auth) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
logger.error(f"e621 API Error: {resp.status}")
|
||||||
|
raise Exception("Не удалось загрузить ленту :(")
|
||||||
|
data = await resp.json()
|
||||||
|
|
||||||
|
posts = data.get("posts", [])
|
||||||
|
feed = []
|
||||||
|
for post in posts:
|
||||||
|
file_url = post.get("file", {}).get("url")
|
||||||
|
ext = post.get("file", {}).get("ext", "")
|
||||||
|
if not file_url or ext not in ("gif", "webm", "mp4"):
|
||||||
|
continue
|
||||||
|
sample_url = post.get("sample", {}).get("url") or file_url
|
||||||
|
feed.append({
|
||||||
|
"url": file_url,
|
||||||
|
"sample": sample_url,
|
||||||
|
"ext": ext,
|
||||||
|
"score": post.get("score", {}).get("total", 0),
|
||||||
|
"fav_count": post.get("fav_count", 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
return feed
|
||||||
|
|
||||||
|
async def handle_uwu_cmd(message: types.Message):
|
||||||
|
args = message.text.split(maxsplit=1)
|
||||||
|
|
||||||
|
if len(args) > 1 and args[1].strip() == "-h":
|
||||||
|
help_msg = (
|
||||||
|
"🐈 <b>Справка по тегам e621:</b>\n\n"
|
||||||
|
"Перечислите любые теги через пробел после команды.\n"
|
||||||
|
"• <b>rating</b>: <code>rating:safe</code> (безопасные), <code>rating:questionable</code>, <code>rating:explicit</code> (NSFW)\n"
|
||||||
|
"• <b>score</b>: <code>score:>500</code> (искать популярные посты с рейтингом больше 500)\n"
|
||||||
|
"• Исключить тег: поставьте минус, например <code>-fox</code>\n\n"
|
||||||
|
"<i>Пример:</i> <code>/uwu wolf -rating:explicit score:>100</code>\n"
|
||||||
|
"<i>По умолчанию:</i> <code>rating:safe score:>500 -animated</code>"
|
||||||
|
)
|
||||||
|
await message.reply(help_msg, parse_mode="HTML")
|
||||||
|
return
|
||||||
|
|
||||||
|
tags = "rating:safe score:>500 -animated"
|
||||||
|
if len(args) > 1:
|
||||||
|
tags = args[1].strip()
|
||||||
|
|
||||||
|
tags += " order:random score:>300"
|
||||||
|
|
||||||
|
try:
|
||||||
|
post = await fetch_uwu_post(tags)
|
||||||
|
file_url = post["url"]
|
||||||
|
ext = post["ext"]
|
||||||
|
caption = post["caption"]
|
||||||
|
|
||||||
if ext in ("gif", "webm", "mp4"):
|
if ext in ("gif", "webm", "mp4"):
|
||||||
# Для анимаций: отправляем caption отдельным сообщением,
|
# Для анимаций: отправляем caption отдельным сообщением,
|
||||||
# а саму гифку/видео через URL напрямую — так Telegram
|
# а саму гифку/видео через URL напрямую — так Telegram
|
||||||
|
|
@ -84,4 +159,7 @@ async def handle_uwu_cmd(message: types.Message):
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error in uwu command")
|
logger.exception("Error in uwu command")
|
||||||
|
if str(e) in ["Не удалось получить картинку от сервера :(", "Ничего не найдено по этим тегам :(", "У найденного поста нет прямого URL изображения :("]:
|
||||||
|
await message.reply(str(e))
|
||||||
|
else:
|
||||||
await message.reply("Произошла ошибка при обращении к API :(")
|
await message.reply("Произошла ошибка при обращении к API :(")
|
||||||
|
|
|
||||||
29
main.py
29
main.py
|
|
@ -14,7 +14,8 @@ from aiogram.filters import Command, CommandStart
|
||||||
from aiogram.enums import ParseMode
|
from aiogram.enums import ParseMode
|
||||||
from aiogram.types import (
|
from aiogram.types import (
|
||||||
BufferedInputFile, Message, BotCommand, FSInputFile,
|
BufferedInputFile, Message, BotCommand, FSInputFile,
|
||||||
BotCommandScopeDefault, BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats
|
BotCommandScopeDefault, BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats,
|
||||||
|
WebAppInfo, InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
)
|
)
|
||||||
from aiogram.client.default import DefaultBotProperties
|
from aiogram.client.default import DefaultBotProperties
|
||||||
|
|
||||||
|
|
@ -971,8 +972,33 @@ async def handle_help(message: Message):
|
||||||
await message.answer(config.get_hint_text(templates_list_text()), parse_mode=None)
|
await message.answer(config.get_hint_text(templates_list_text()), parse_mode=None)
|
||||||
|
|
||||||
async def handle_start(message: Message):
|
async def handle_start(message: Message):
|
||||||
|
webapp_url = os.getenv("WEBAPP_URL", "")
|
||||||
|
if webapp_url:
|
||||||
|
if message.chat.type == "private":
|
||||||
|
kb = InlineKeyboardMarkup(inline_keyboard=[
|
||||||
|
[InlineKeyboardButton(text="🎮 Открыть Mini App", web_app=WebAppInfo(url=webapp_url))]
|
||||||
|
])
|
||||||
|
await message.answer("Йо, кент! Жми кнопку 👇", reply_markup=kb)
|
||||||
|
else:
|
||||||
|
await message.answer("Йо, кент! Mini App доступно только в личных сообщениях. Пиши мне в личку!")
|
||||||
|
else:
|
||||||
await message.answer("Соси")
|
await message.answer("Соси")
|
||||||
|
|
||||||
|
async def handle_app_cmd(message: Message):
|
||||||
|
"""Открыть Mini App."""
|
||||||
|
webapp_url = os.getenv("WEBAPP_URL", "")
|
||||||
|
if not webapp_url:
|
||||||
|
await message.answer("Mini App не настроен.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if message.chat.type == "private":
|
||||||
|
kb = InlineKeyboardMarkup(inline_keyboard=[
|
||||||
|
[InlineKeyboardButton(text="🎮 Открыть Mini App", web_app=WebAppInfo(url=webapp_url))]
|
||||||
|
])
|
||||||
|
await message.answer("⬇️ Жми кнопку чтобы открыть приложение", reply_markup=kb)
|
||||||
|
else:
|
||||||
|
await message.answer("Братуха, встроенная кнопка Mini App пока работает только в личке (ограничение Telegram). Перейди в личные сообщения с ботом или используй прямую ссылку: https://t.me/FenyaBotTest_bot/app (если она настроена).")
|
||||||
|
|
||||||
async def handle_schedule_cmd(message: Message):
|
async def handle_schedule_cmd(message: Message):
|
||||||
now = datetime.now(ZoneInfo(config.DEFAULT_TZ))
|
now = datetime.now(ZoneInfo(config.DEFAULT_TZ))
|
||||||
raw_text = message.text or ""
|
raw_text = message.text or ""
|
||||||
|
|
@ -1397,6 +1423,7 @@ def main():
|
||||||
|
|
||||||
# Регистрация хендлеров
|
# Регистрация хендлеров
|
||||||
dp.message.register(handle_start, CommandStart())
|
dp.message.register(handle_start, CommandStart())
|
||||||
|
dp.message.register(handle_app_cmd, Command("app"))
|
||||||
dp.message.register(handle_help, Command("help"))
|
dp.message.register(handle_help, Command("help"))
|
||||||
dp.message.register(handle_schedule_cmd, Command("schedule"))
|
dp.message.register(handle_schedule_cmd, Command("schedule"))
|
||||||
dp.message.register(handle_break_cmd, Command("break"))
|
dp.message.register(handle_break_cmd, Command("break"))
|
||||||
|
|
|
||||||
1
webapp/__init__.py
Normal file
1
webapp/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# webapp package
|
||||||
443
webapp/api.py
Normal file
443
webapp/api.py
Normal file
|
|
@ -0,0 +1,443 @@
|
||||||
|
"""
|
||||||
|
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 _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
|
||||||
|
|
||||||
|
|
||||||
|
# --- 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)
|
||||||
82
webapp/auth.py
Normal file
82
webapp/auth.py
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
"""
|
||||||
|
Telegram Mini App — валидация initData.
|
||||||
|
|
||||||
|
Telegram передаёт подписанные данные при открытии WebApp.
|
||||||
|
Мы проверяем HMAC-SHA256 подпись через BOT_TOKEN,
|
||||||
|
чтобы убедиться что запрос пришёл от реального пользователя.
|
||||||
|
|
||||||
|
Документация: https://core.telegram.org/bots/webapps#validating-data
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
|
BOT_TOKEN = os.getenv("BOT_TOKEN", "")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_init_data(init_data: str, max_age_seconds: int = 86400) -> dict | None:
|
||||||
|
"""
|
||||||
|
Валидирует initData из Telegram WebApp.
|
||||||
|
|
||||||
|
Возвращает dict с данными пользователя при успехе, None при ошибке.
|
||||||
|
"""
|
||||||
|
if not init_data or not BOT_TOKEN:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = parse_qs(init_data, keep_blank_values=True)
|
||||||
|
|
||||||
|
# Извлекаем hash
|
||||||
|
received_hash = parsed.get("hash", [None])[0]
|
||||||
|
if not received_hash:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Собираем data-check-string (все поля кроме hash, отсортированные)
|
||||||
|
data_pairs = []
|
||||||
|
for key, values in parsed.items():
|
||||||
|
if key == "hash":
|
||||||
|
continue
|
||||||
|
data_pairs.append(f"{key}={values[0]}")
|
||||||
|
data_pairs.sort()
|
||||||
|
data_check_string = "\n".join(data_pairs)
|
||||||
|
|
||||||
|
# Вычисляем секретный ключ
|
||||||
|
secret_key = hmac.new(
|
||||||
|
b"WebAppData", BOT_TOKEN.encode("utf-8"), hashlib.sha256
|
||||||
|
).digest()
|
||||||
|
|
||||||
|
# Вычисляем HMAC
|
||||||
|
computed_hash = hmac.new(
|
||||||
|
secret_key, data_check_string.encode("utf-8"), hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
if not hmac.compare_digest(computed_hash, received_hash):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Проверяем auth_date (не старше max_age_seconds)
|
||||||
|
auth_date_str = parsed.get("auth_date", [None])[0]
|
||||||
|
if auth_date_str:
|
||||||
|
auth_date = int(auth_date_str)
|
||||||
|
if time.time() - auth_date > max_age_seconds:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Парсим user
|
||||||
|
user_str = parsed.get("user", [None])[0]
|
||||||
|
if not user_str:
|
||||||
|
return None
|
||||||
|
|
||||||
|
user_data = json.loads(user_str)
|
||||||
|
return {
|
||||||
|
"user_id": user_data.get("id"),
|
||||||
|
"first_name": user_data.get("first_name", ""),
|
||||||
|
"last_name": user_data.get("last_name", ""),
|
||||||
|
"username": user_data.get("username", ""),
|
||||||
|
"language_code": user_data.get("language_code", "ru"),
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
4
webapp/frontend/Dockerfile
Normal file
4
webapp/frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY . /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
1007
webapp/frontend/css/style.css
Normal file
1007
webapp/frontend/css/style.css
Normal file
File diff suppressed because it is too large
Load diff
46
webapp/frontend/index.html
Normal file
46
webapp/frontend/index.html
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||||
|
<meta name="theme-color" content="#0a0e1a">
|
||||||
|
<title>FenyaBot</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div id="page-container"></div>
|
||||||
|
<nav id="bottom-nav">
|
||||||
|
<button class="nav-btn active" data-page="home">
|
||||||
|
<span class="nav-icon">🏠</span>
|
||||||
|
<span class="nav-label">Главная</span>
|
||||||
|
</button>
|
||||||
|
<button class="nav-btn" data-page="betting">
|
||||||
|
<span class="nav-icon">⚽</span>
|
||||||
|
<span class="nav-label">Ставки</span>
|
||||||
|
</button>
|
||||||
|
<button class="nav-btn" data-page="casino">
|
||||||
|
<span class="nav-icon">🎰</span>
|
||||||
|
<span class="nav-label">Казино</span>
|
||||||
|
</button>
|
||||||
|
<button class="nav-btn" data-page="schedule">
|
||||||
|
<span class="nav-icon">📅</span>
|
||||||
|
<span class="nav-label">Пары</span>
|
||||||
|
</button>
|
||||||
|
<button class="nav-btn" data-page="furtok">
|
||||||
|
<span class="nav-icon">🐺</span>
|
||||||
|
<span class="nav-label">FurTok</span>
|
||||||
|
</button>
|
||||||
|
<button class="nav-btn" data-page="chat">
|
||||||
|
<span class="nav-icon">💬</span>
|
||||||
|
<span class="nav-label">Чат</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
46
webapp/frontend/js/api.js
Normal file
46
webapp/frontend/js/api.js
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
/**
|
||||||
|
* FenyaBot Mini App — API wrapper
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE = window.location.origin;
|
||||||
|
|
||||||
|
function getInitData() {
|
||||||
|
if (window.Telegram?.WebApp?.initData) {
|
||||||
|
return window.Telegram.WebApp.initData;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
const { method = 'GET', body = null } = options;
|
||||||
|
const headers = {
|
||||||
|
'X-Telegram-Init-Data': getInitData(),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = { method, headers };
|
||||||
|
if (body) config.body = JSON.stringify(body);
|
||||||
|
|
||||||
|
const resp = await fetch(`${API_BASE}${path}`, config);
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.detail || `HTTP ${resp.status}`);
|
||||||
|
}
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const API = {
|
||||||
|
getProfile: () => api('/api/profile'),
|
||||||
|
getLeagues: () => api('/api/leagues'),
|
||||||
|
getMatches: (league) => api(`/api/matches/${league}`),
|
||||||
|
placeBet: (data) => api('/api/bet', { method: 'POST', body: data }),
|
||||||
|
getMyBets: () => api('/api/mybets'),
|
||||||
|
getBetHistory: () => api('/api/bet_history'),
|
||||||
|
playCasino: (bet) => api('/api/casino', { method: 'POST', body: { bet } }),
|
||||||
|
playPenis: () => api('/api/penis', { method: 'POST' }),
|
||||||
|
getTop: () => api('/api/top'),
|
||||||
|
getSchedule: (day) => api(`/api/schedule${day != null ? `?day=${day}` : ''}`),
|
||||||
|
getUwu: (tags) => api(`/api/uwu?tags=${encodeURIComponent(tags || 'rating:safe score:>500 -animated')}`),
|
||||||
|
getFurtokFeed: (safe = true, page = 1) => api(`/api/furtok?safe=${safe}&page=${page}`),
|
||||||
|
talk: (text) => api('/api/talk', { method: 'POST', body: { text } }),
|
||||||
|
};
|
||||||
699
webapp/frontend/js/app.js
Normal file
699
webapp/frontend/js/app.js
Normal file
|
|
@ -0,0 +1,699 @@
|
||||||
|
/**
|
||||||
|
* FenyaBot Mini App — Main Application
|
||||||
|
* SPA with vanilla JS routing and page modules
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { API } from './api.js';
|
||||||
|
|
||||||
|
// ── Telegram WebApp SDK ──
|
||||||
|
const tg = window.Telegram?.WebApp;
|
||||||
|
if (tg) {
|
||||||
|
tg.ready();
|
||||||
|
tg.expand();
|
||||||
|
tg.setHeaderColor('#060a14');
|
||||||
|
tg.setBackgroundColor('#060a14');
|
||||||
|
}
|
||||||
|
|
||||||
|
function haptic(type = 'impact') {
|
||||||
|
try {
|
||||||
|
if (type === 'impact') tg?.HapticFeedback?.impactOccurred('light');
|
||||||
|
else if (type === 'success') tg?.HapticFeedback?.notificationOccurred('success');
|
||||||
|
else if (type === 'error') tg?.HapticFeedback?.notificationOccurred('error');
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── State ──
|
||||||
|
let currentPage = 'home';
|
||||||
|
let profileData = null;
|
||||||
|
let chatHistory = [];
|
||||||
|
let selectedBet = null; // { league, matchIndex, outcome, odds }
|
||||||
|
|
||||||
|
const container = document.getElementById('page-container');
|
||||||
|
const navBtns = document.querySelectorAll('.nav-btn');
|
||||||
|
|
||||||
|
// ── Router ──
|
||||||
|
function navigate(page, data = null) {
|
||||||
|
currentPage = page;
|
||||||
|
haptic();
|
||||||
|
// Убираем оверлеи при смене страницы
|
||||||
|
document.querySelector('.bet-slip')?.remove();
|
||||||
|
document.querySelector('.result-overlay')?.remove();
|
||||||
|
document.querySelector('.furtok-wrapper')?.remove();
|
||||||
|
navBtns.forEach(b => b.classList.toggle('active', b.dataset.page === page));
|
||||||
|
container.style.animation = 'none';
|
||||||
|
container.offsetHeight;
|
||||||
|
container.style.animation = 'fadeIn 0.25s ease';
|
||||||
|
|
||||||
|
switch (page) {
|
||||||
|
case 'home': renderHome(); break;
|
||||||
|
case 'betting': renderBetting(data); break;
|
||||||
|
case 'casino': renderCasino(); break;
|
||||||
|
case 'schedule': renderSchedule(); break;
|
||||||
|
case 'furtok': renderFurtok(); break;
|
||||||
|
case 'chat': renderChat(); break;
|
||||||
|
default: renderHome();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
navBtns.forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => navigate(btn.dataset.page));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Helpers ──
|
||||||
|
function $(html) {
|
||||||
|
const t = document.createElement('template');
|
||||||
|
t.innerHTML = html.trim();
|
||||||
|
return t.content.firstChild;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showResult(icon, title, desc, btnText = 'OK') {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const overlay = $(`
|
||||||
|
<div class="result-overlay">
|
||||||
|
<div class="result-popup">
|
||||||
|
<div class="result-icon">${icon}</div>
|
||||||
|
<div class="result-title">${title}</div>
|
||||||
|
<div class="result-desc">${desc}</div>
|
||||||
|
<button class="btn btn-primary">${btnText}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
overlay.querySelector('.btn').onclick = () => { overlay.remove(); resolve(); };
|
||||||
|
overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(); } };
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function skeleton(lines = 3) {
|
||||||
|
return `<div class="card">${'<div class="skeleton skeleton-line" style="width:' + (60 + Math.random()*35) + '%"></div>'.repeat(lines)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// PAGE: HOME
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
|
||||||
|
async function renderHome() {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="profile-card card">
|
||||||
|
<div class="skeleton skeleton-circle"></div>
|
||||||
|
<div class="skeleton skeleton-line" style="width:40%;margin:0 auto"></div>
|
||||||
|
<div class="skeleton skeleton-line" style="width:60%;margin:8px auto"></div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div class="stat-item"><div class="skeleton skeleton-line"></div></div>
|
||||||
|
<div class="stat-item"><div class="skeleton skeleton-line"></div></div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
profileData = await API.getProfile();
|
||||||
|
const p = profileData;
|
||||||
|
const length = p.length != null ? p.length.toFixed(1) : '—';
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="profile-card card">
|
||||||
|
<div class="profile-avatar">👤</div>
|
||||||
|
<div class="profile-name">${p.first_name || p.username || 'Кент'}</div>
|
||||||
|
<div class="profile-balance">${length} см</div>
|
||||||
|
<div class="profile-balance-label">текущий размер</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-value">${p.active_bets}</div>
|
||||||
|
<div class="stat-label">Активных ставок</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item" id="penis-btn" style="cursor:pointer">
|
||||||
|
<div class="stat-value">🎲</div>
|
||||||
|
<div class="stat-label">Крутить penis</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-header">Быстрые действия</div>
|
||||||
|
<div class="quick-actions">
|
||||||
|
<button class="quick-action" data-action="betting">
|
||||||
|
<div class="quick-action-icon">⚽</div>
|
||||||
|
<div class="quick-action-label">Ставки</div>
|
||||||
|
</button>
|
||||||
|
<button class="quick-action" data-action="casino">
|
||||||
|
<div class="quick-action-icon">🎰</div>
|
||||||
|
<div class="quick-action-label">Казино</div>
|
||||||
|
</button>
|
||||||
|
<button class="quick-action" data-action="top">
|
||||||
|
<div class="quick-action-icon">🏆</div>
|
||||||
|
<div class="quick-action-label">Топ</div>
|
||||||
|
</button>
|
||||||
|
<button class="quick-action" data-action="history">
|
||||||
|
<div class="quick-action-icon">📊</div>
|
||||||
|
<div class="quick-action-label">История</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Крутить penis
|
||||||
|
document.getElementById('penis-btn')?.addEventListener('click', async () => {
|
||||||
|
haptic();
|
||||||
|
try {
|
||||||
|
const res = await API.playPenis();
|
||||||
|
if (res.success) {
|
||||||
|
haptic('success');
|
||||||
|
await showResult('🎉', 'Успех!', res.message);
|
||||||
|
} else {
|
||||||
|
await showResult('⏳', 'Подожди', res.message);
|
||||||
|
}
|
||||||
|
renderHome();
|
||||||
|
} catch (e) {
|
||||||
|
haptic('error');
|
||||||
|
await showResult('❌', 'Ошибка', e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Quick actions
|
||||||
|
container.querySelectorAll('.quick-action').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const action = btn.dataset.action;
|
||||||
|
if (action === 'betting') navigate('betting');
|
||||||
|
else if (action === 'casino') navigate('casino');
|
||||||
|
else if (action === 'top') showTop();
|
||||||
|
else if (action === 'history') showBetHistory();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state-icon">⚠️</div>
|
||||||
|
<div class="empty-state-text">Не удалось загрузить профиль<br>${e.message}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showTop() {
|
||||||
|
try {
|
||||||
|
const data = await API.getTop();
|
||||||
|
await showResult('🏆', 'Лидерборд', data.text.replace(/\n/g, '<br>'));
|
||||||
|
} catch (e) {
|
||||||
|
await showResult('❌', 'Ошибка', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showBetHistory() {
|
||||||
|
try {
|
||||||
|
const data = await API.getBetHistory();
|
||||||
|
await showResult('📊', 'История ставок', data.text.replace(/\n/g, '<br>'));
|
||||||
|
} catch (e) {
|
||||||
|
await showResult('❌', 'Ошибка', e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// PAGE: BETTING
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
|
||||||
|
async function renderBetting(data) {
|
||||||
|
if (data?.league) {
|
||||||
|
return renderMatches(data.league, data.leagueName);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = `<div class="section-header">⚽ Ставки на матчи</div>${skeleton(2)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const leagues = await API.getLeagues();
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="section-header">⚽ Выбери лигу</div>
|
||||||
|
<div class="league-list" id="league-list"></div>
|
||||||
|
`;
|
||||||
|
const list = document.getElementById('league-list');
|
||||||
|
leagues.forEach(l => {
|
||||||
|
const item = $(`
|
||||||
|
<div class="league-item">
|
||||||
|
<span class="league-icon">⚽</span>
|
||||||
|
<span class="league-name">${l.name}</span>
|
||||||
|
<span class="league-arrow">›</span>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
haptic();
|
||||||
|
navigate('betting', { league: l.alias, leagueName: l.name });
|
||||||
|
});
|
||||||
|
list.appendChild(item);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = `<div class="empty-state"><div class="empty-state-icon">❌</div><div class="empty-state-text">${e.message}</div></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderMatches(league, leagueName) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:16px">
|
||||||
|
<button class="btn btn-secondary btn-sm" id="back-btn" style="width:auto;padding:8px 12px">← Назад</button>
|
||||||
|
<div class="section-header" style="margin:0">${leagueName}</div>
|
||||||
|
</div>
|
||||||
|
${skeleton(3)}
|
||||||
|
`;
|
||||||
|
document.getElementById('back-btn').onclick = () => navigate('betting');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const matches = await API.getMatches(league);
|
||||||
|
if (!matches.length) {
|
||||||
|
container.innerHTML += `<div class="empty-state"><div class="empty-state-icon">📭</div><div class="empty-state-text">Нет матчей в этой лиге</div></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove skeleton
|
||||||
|
container.querySelectorAll('.card').forEach(c => c.remove());
|
||||||
|
|
||||||
|
matches.forEach((m, idx) => {
|
||||||
|
const date = m.commence ? new Date(m.commence).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : '';
|
||||||
|
const card = $(`
|
||||||
|
<div class="card match-card" style="animation-delay:${idx * 0.05}s">
|
||||||
|
<div class="match-time">${date}</div>
|
||||||
|
<div class="match-teams">
|
||||||
|
<div class="match-team">${m.home}</div>
|
||||||
|
<div class="match-vs">VS</div>
|
||||||
|
<div class="match-team">${m.away}</div>
|
||||||
|
</div>
|
||||||
|
<div class="match-odds">
|
||||||
|
<button class="odds-btn" data-outcome="1" data-odds="${m.odds_home || 0}">
|
||||||
|
<span class="odds-label">П1</span>
|
||||||
|
<span class="odds-value">${m.odds_home?.toFixed(2) || '—'}</span>
|
||||||
|
</button>
|
||||||
|
<button class="odds-btn" data-outcome="X" data-odds="${m.odds_draw || 0}">
|
||||||
|
<span class="odds-label">X</span>
|
||||||
|
<span class="odds-value">${m.odds_draw?.toFixed(2) || '—'}</span>
|
||||||
|
</button>
|
||||||
|
<button class="odds-btn" data-outcome="2" data-odds="${m.odds_away || 0}">
|
||||||
|
<span class="odds-label">П2</span>
|
||||||
|
<span class="odds-value">${m.odds_away?.toFixed(2) || '—'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
card.querySelectorAll('.odds-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
haptic();
|
||||||
|
selectedBet = {
|
||||||
|
league,
|
||||||
|
matchIndex: m.index,
|
||||||
|
outcome: btn.dataset.outcome,
|
||||||
|
odds: parseFloat(btn.dataset.odds),
|
||||||
|
home: m.home,
|
||||||
|
away: m.away,
|
||||||
|
};
|
||||||
|
showBetSlip();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
container.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML += `<div class="empty-state"><div class="empty-state-icon">❌</div><div class="empty-state-text">${e.message}</div></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showBetSlip() {
|
||||||
|
if (!selectedBet) return;
|
||||||
|
document.querySelector('.bet-slip')?.remove();
|
||||||
|
|
||||||
|
const outcomeLabel = { '1': `П1 (${selectedBet.home})`, 'X': 'Ничья', '2': `П2 (${selectedBet.away})` };
|
||||||
|
const slip = $(`
|
||||||
|
<div class="bet-slip visible">
|
||||||
|
<div class="bet-slip-header">
|
||||||
|
<span class="card-title">${outcomeLabel[selectedBet.outcome]}</span>
|
||||||
|
<span class="badge badge-blue">x${selectedBet.odds?.toFixed(2) || '?'}</span>
|
||||||
|
</div>
|
||||||
|
<input type="number" class="bet-input" placeholder="Ставка (см)" step="0.1" min="0.1" max="3" value="0.5">
|
||||||
|
<button class="btn btn-primary" id="place-bet-btn">Поставить</button>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
slip.querySelector('#place-bet-btn').addEventListener('click', async () => {
|
||||||
|
const amount = parseFloat(slip.querySelector('.bet-input').value);
|
||||||
|
if (!amount || amount <= 0) return;
|
||||||
|
try {
|
||||||
|
const res = await API.placeBet({
|
||||||
|
league: selectedBet.league,
|
||||||
|
match_index: selectedBet.matchIndex,
|
||||||
|
outcome: selectedBet.outcome,
|
||||||
|
amount,
|
||||||
|
});
|
||||||
|
haptic('success');
|
||||||
|
slip.remove();
|
||||||
|
await showResult('✅', 'Ставка принята!', res.message);
|
||||||
|
} catch (e) {
|
||||||
|
haptic('error');
|
||||||
|
await showResult('❌', 'Ошибка', e.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(slip);
|
||||||
|
setTimeout(() => slip.classList.add('visible'), 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// PAGE: CASINO
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
|
||||||
|
async function renderCasino() {
|
||||||
|
const balance = profileData?.length != null ? profileData.length.toFixed(1) : '?';
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="section-header">🎰 Казино</div>
|
||||||
|
<div class="card" style="text-align:center;padding:24px">
|
||||||
|
<div style="font-size:13px;color:var(--text-secondary);margin-bottom:4px">Баланс</div>
|
||||||
|
<div style="font-size:24px;font-weight:800;color:var(--blue-400)" id="casino-balance">${balance} см</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" style="text-align:center;padding:24px">
|
||||||
|
<div class="slots-display">
|
||||||
|
<div class="slot-reel" id="reel1">🎰</div>
|
||||||
|
<div class="slot-reel" id="reel2">🎰</div>
|
||||||
|
<div class="slot-reel" id="reel3">🎰</div>
|
||||||
|
</div>
|
||||||
|
<div class="casino-bet-input">
|
||||||
|
<input type="number" id="casino-bet" value="0.5" step="0.1" min="0.1" max="1" placeholder="Ставка">
|
||||||
|
<span style="color:var(--text-secondary);font-size:13px">см</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-lg" id="spin-btn">🎰 Крутить!</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('spin-btn').addEventListener('click', async () => {
|
||||||
|
const bet = parseFloat(document.getElementById('casino-bet').value);
|
||||||
|
if (!bet || bet <= 0) return;
|
||||||
|
|
||||||
|
haptic();
|
||||||
|
const btn = document.getElementById('spin-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '⏳ Крутим...';
|
||||||
|
|
||||||
|
// Анимация вращения
|
||||||
|
const symbols = ['🍒', '🍋', '🔔', '💎', '7️⃣', '🍀'];
|
||||||
|
const reels = [document.getElementById('reel1'), document.getElementById('reel2'), document.getElementById('reel3')];
|
||||||
|
reels.forEach(r => r.classList.add('spinning'));
|
||||||
|
|
||||||
|
const spinInterval = setInterval(() => {
|
||||||
|
reels.forEach(r => { r.textContent = symbols[Math.floor(Math.random() * symbols.length)]; });
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await API.playCasino(bet);
|
||||||
|
|
||||||
|
clearInterval(spinInterval);
|
||||||
|
|
||||||
|
// Останавливаем слоты по одному
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
await new Promise(r => setTimeout(r, 300));
|
||||||
|
reels[i].classList.remove('spinning');
|
||||||
|
reels[i].textContent = res.reels[i];
|
||||||
|
if (res.won) reels[i].classList.add('won');
|
||||||
|
haptic();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновляем баланс
|
||||||
|
document.getElementById('casino-balance').textContent = `${res.new_length.toFixed(1)} см`;
|
||||||
|
if (profileData) profileData.length = res.new_length;
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
|
||||||
|
if (res.won) {
|
||||||
|
haptic('success');
|
||||||
|
const jackpot = res.matches === 3;
|
||||||
|
await showResult(
|
||||||
|
jackpot ? '🎉' : '✅',
|
||||||
|
jackpot ? 'ДЖЕКПОТ!!!' : 'Выигрыш!',
|
||||||
|
`${jackpot ? 'Три одинаковых!' : 'Две совпали!'}\nx${res.multiplier}\n+${res.delta.toFixed(1)} см\nБаланс: ${res.new_length.toFixed(1)} см`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
haptic('error');
|
||||||
|
await showResult('❌', 'Мимо!', `−${Math.abs(res.delta).toFixed(1)} см\nБаланс: ${res.new_length.toFixed(1)} см`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
clearInterval(spinInterval);
|
||||||
|
reels.forEach(r => r.classList.remove('spinning'));
|
||||||
|
haptic('error');
|
||||||
|
await showResult('❌', 'Ошибка', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '🎰 Крутить!';
|
||||||
|
reels.forEach(r => r.classList.remove('won'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// PAGE: SCHEDULE
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
|
||||||
|
async function renderSchedule() {
|
||||||
|
const days = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||||
|
const today = new Date().getDay();
|
||||||
|
const todayIdx = today === 0 ? 6 : today - 1;
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="section-header">📅 Расписание</div>
|
||||||
|
<div class="schedule-day-tabs" id="day-tabs">
|
||||||
|
${days.map((d, i) => `<button class="day-tab ${i === todayIdx ? 'active' : ''}" data-day="${i}">${d}</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
<div id="lessons-container">${skeleton(4)}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
async function loadDay(dayIdx) {
|
||||||
|
const lc = document.getElementById('lessons-container');
|
||||||
|
lc.innerHTML = skeleton(3);
|
||||||
|
try {
|
||||||
|
const lessons = await API.getSchedule(dayIdx);
|
||||||
|
if (!lessons.length) {
|
||||||
|
lc.innerHTML = `<div class="empty-state"><div class="empty-state-icon">🎉</div><div class="empty-state-text">Нет пар!</div></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lc.innerHTML = '<div class="card">' + lessons.map(l => `
|
||||||
|
<div class="lesson-item">
|
||||||
|
<div class="lesson-number">${l.pair || '—'}</div>
|
||||||
|
<div class="lesson-info">
|
||||||
|
<div class="lesson-title">${l.title}</div>
|
||||||
|
<div class="lesson-time">${l.time_start && l.time_end ? `${l.time_start} — ${l.time_end}` : ''}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('') + '</div>';
|
||||||
|
} catch (e) {
|
||||||
|
lc.innerHTML = `<div class="empty-state"><div class="empty-state-icon">❌</div><div class="empty-state-text">${e.message}</div></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('day-tabs').addEventListener('click', (e) => {
|
||||||
|
const tab = e.target.closest('.day-tab');
|
||||||
|
if (!tab) return;
|
||||||
|
haptic();
|
||||||
|
document.querySelectorAll('.day-tab').forEach(t => t.classList.remove('active'));
|
||||||
|
tab.classList.add('active');
|
||||||
|
loadDay(parseInt(tab.dataset.day));
|
||||||
|
});
|
||||||
|
|
||||||
|
loadDay(todayIdx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// PAGE: FURTOK
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
|
||||||
|
let furtokSafe = true;
|
||||||
|
let furtokPage = 1;
|
||||||
|
let furtokLoading = false;
|
||||||
|
let furtokWrapper = null;
|
||||||
|
|
||||||
|
function renderFurtok() {
|
||||||
|
// Очищаем основной контейнер и создаём полноэкранную обёртку
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
// Удаляем старый wrapper если есть
|
||||||
|
document.querySelector('.furtok-wrapper')?.remove();
|
||||||
|
|
||||||
|
furtokPage = 1;
|
||||||
|
furtokLoading = false;
|
||||||
|
|
||||||
|
furtokWrapper = document.createElement('div');
|
||||||
|
furtokWrapper.className = 'furtok-wrapper';
|
||||||
|
furtokWrapper.innerHTML = `
|
||||||
|
<div class="furtok-header">
|
||||||
|
<div class="furtok-title">🐺 FurTok</div>
|
||||||
|
<label class="furtok-toggle">
|
||||||
|
<span>Safe</span>
|
||||||
|
<input type="checkbox" id="furtok-safe" ${furtokSafe ? 'checked' : ''}>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="furtok-feed" id="furtok-feed"></div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(furtokWrapper);
|
||||||
|
|
||||||
|
const feed = document.getElementById('furtok-feed');
|
||||||
|
const safeToggle = document.getElementById('furtok-safe');
|
||||||
|
|
||||||
|
safeToggle.addEventListener('change', () => {
|
||||||
|
furtokSafe = safeToggle.checked;
|
||||||
|
furtokPage = 1;
|
||||||
|
feed.innerHTML = '';
|
||||||
|
loadFurtokPage(feed);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Infinite scroll
|
||||||
|
feed.addEventListener('scroll', () => {
|
||||||
|
if (furtokLoading) return;
|
||||||
|
if (feed.scrollTop + feed.clientHeight >= feed.scrollHeight - 200) {
|
||||||
|
furtokPage++;
|
||||||
|
loadFurtokPage(feed);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Первая загрузка
|
||||||
|
loadFurtokPage(feed);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFurtokPage(feedEl) {
|
||||||
|
if (furtokLoading) return;
|
||||||
|
furtokLoading = true;
|
||||||
|
|
||||||
|
// Лоадер
|
||||||
|
const loader = document.createElement('div');
|
||||||
|
loader.className = 'furtok-loader';
|
||||||
|
loader.textContent = '⏳ Загрузка...';
|
||||||
|
feedEl.appendChild(loader);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await API.getFurtokFeed(furtokSafe, furtokPage);
|
||||||
|
loader.remove();
|
||||||
|
|
||||||
|
if (!res.feed || res.feed.length === 0) {
|
||||||
|
if (furtokPage === 1) {
|
||||||
|
feedEl.innerHTML = `<div class="furtok-loader">Ничего не найдено</div>`;
|
||||||
|
}
|
||||||
|
furtokLoading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.feed.forEach(post => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'furtok-card';
|
||||||
|
|
||||||
|
let mediaHtml = '';
|
||||||
|
if (post.ext === 'gif') {
|
||||||
|
mediaHtml = `<img src="${post.url}" loading="lazy">`;
|
||||||
|
} else {
|
||||||
|
mediaHtml = `<video src="${post.url}" loop playsinline preload="metadata" muted></video>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
${mediaHtml}
|
||||||
|
<div class="furtok-overlay">
|
||||||
|
<div class="furtok-stats">
|
||||||
|
<span>⭐ ${post.score}</span>
|
||||||
|
<span>❤️ ${post.fav_count}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Тап по видео = unmute + play/pause
|
||||||
|
const video = card.querySelector('video');
|
||||||
|
if (video) {
|
||||||
|
card.addEventListener('click', () => {
|
||||||
|
video.muted = false;
|
||||||
|
if (video.paused) video.play();
|
||||||
|
else video.pause();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
feedEl.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
// IntersectionObserver для автоплейа
|
||||||
|
setupAutoplay(feedEl);
|
||||||
|
haptic('success');
|
||||||
|
} catch (e) {
|
||||||
|
loader.remove();
|
||||||
|
haptic('error');
|
||||||
|
if (furtokPage === 1) {
|
||||||
|
feedEl.innerHTML = `<div class="furtok-loader">❌ ${e.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
furtokLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupAutoplay(feedEl) {
|
||||||
|
const videos = feedEl.querySelectorAll('.furtok-card video');
|
||||||
|
const observer = new IntersectionObserver((entries) => {
|
||||||
|
entries.forEach(entry => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
entry.target.play().catch(() => {});
|
||||||
|
} else {
|
||||||
|
entry.target.pause();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, { root: feedEl, threshold: 0.6 });
|
||||||
|
|
||||||
|
videos.forEach(v => observer.observe(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
// PAGE: CHAT
|
||||||
|
// ══════════════════════════════════════════
|
||||||
|
|
||||||
|
function renderChat() {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="section-header">💬 ИИ на фене</div>
|
||||||
|
<div class="chat-messages" id="chat-messages">
|
||||||
|
${chatHistory.length === 0 ? `
|
||||||
|
<div class="chat-bubble bot">Йо, кент! Базарь, я тут. Пиши чё надо, братуха 🤙</div>
|
||||||
|
` : chatHistory.map(m => `<div class="chat-bubble ${m.role}">${m.text}</div>`).join('')}
|
||||||
|
</div>
|
||||||
|
<div class="chat-input-bar">
|
||||||
|
<input type="text" id="chat-input" placeholder="Напиши сообщение..." autocomplete="off">
|
||||||
|
<button class="chat-send-btn" id="chat-send">➤</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const input = document.getElementById('chat-input');
|
||||||
|
const sendBtn = document.getElementById('chat-send');
|
||||||
|
const messages = document.getElementById('chat-messages');
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
const text = input.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
|
||||||
|
input.value = '';
|
||||||
|
haptic();
|
||||||
|
|
||||||
|
// Add user bubble
|
||||||
|
chatHistory.push({ role: 'user', text });
|
||||||
|
const userBubble = $(`<div class="chat-bubble user">${text}</div>`);
|
||||||
|
messages.appendChild(userBubble);
|
||||||
|
messages.scrollTop = messages.scrollHeight;
|
||||||
|
|
||||||
|
// Loading
|
||||||
|
const loading = $(`<div class="chat-bubble bot" id="chat-loading">💭 печатает...</div>`);
|
||||||
|
messages.appendChild(loading);
|
||||||
|
messages.scrollTop = messages.scrollHeight;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await API.talk(text);
|
||||||
|
loading.remove();
|
||||||
|
chatHistory.push({ role: 'bot', text: res.response });
|
||||||
|
const botBubble = $(`<div class="chat-bubble bot">${res.response}</div>`);
|
||||||
|
messages.appendChild(botBubble);
|
||||||
|
messages.scrollTop = messages.scrollHeight;
|
||||||
|
} catch (e) {
|
||||||
|
loading.remove();
|
||||||
|
const errBubble = $(`<div class="chat-bubble bot">Ошибка: ${e.message}</div>`);
|
||||||
|
messages.appendChild(errBubble);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sendBtn.addEventListener('click', send);
|
||||||
|
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') send(); });
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ──
|
||||||
|
navigate('home');
|
||||||
26
webapp/frontend/nginx.conf
Normal file
26
webapp/frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# SPA fallback
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Proxy API requests to backend
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://webapp-api:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Telegram-Init-Data $http_x_telegram_init_data;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets
|
||||||
|
location ~* \.(css|js|png|jpg|svg|woff2?)$ {
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
}
|
||||||
18
webapp/ngrok_tunnel.py
Normal file
18
webapp/ngrok_tunnel.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
"""Скрипт для локального тестирования Mini App с ngrok"""
|
||||||
|
import time
|
||||||
|
from pyngrok import conf, ngrok
|
||||||
|
|
||||||
|
conf.get_default().auth_token = "39k4ojhN5UPUWQwb1ly84ORM2IE_5JnhMaKk5p8LFM3TCetyg"
|
||||||
|
tunnel = ngrok.connect(8080, "http")
|
||||||
|
print(f"\n{'='*50}")
|
||||||
|
print(f" NGROK URL: {tunnel.public_url}")
|
||||||
|
print(f"{'='*50}\n")
|
||||||
|
print("Туннель открыт. Не закрывай это окно!")
|
||||||
|
print("Ctrl+C чтобы остановить.\n")
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
time.sleep(1)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
ngrok.kill()
|
||||||
|
print("Туннель закрыт.")
|
||||||
2
webapp/requirements-api.txt
Normal file
2
webapp/requirements-api.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
fastapi>=0.111.0
|
||||||
|
uvicorn[standard]>=0.29.0
|
||||||
Loading…
Reference in a new issue