patch_Oreshnik #23

Merged
q merged 4 commits from Dan4ick/bot_tg:feature/FenyaBot into main 2026-04-12 08:50:49 +00:00
16 changed files with 2549 additions and 25 deletions

18
Dockerfile.api Normal file
View 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"]

View file

@ -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

View file

@ -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}") raise Exception("Не удалось получить картинку от сервера :(")
await message.reply("Не удалось получить картинку от сервера :(") data = await resp.json()
return
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")
ext = post.get("file", {}).get("ext", "png")
if not file_url:
raise Exception("У найденного поста нет прямого URL изображения :(")
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") file_url = post.get("file", {}).get("url")
ext = post.get("file", {}).get("ext", "png") ext = post.get("file", {}).get("ext", "")
if not file_url: if not file_url or ext not in ("gif", "webm", "mp4"):
await message.reply("У найденного поста нет прямого URL изображения :(") continue
return 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"]
caption = random.choice(CAPTIONS)
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")
await message.reply("Произошла ошибка при обращении к API :(") if str(e) in ["Не удалось получить картинку от сервера :(", "Ничего не найдено по этим тегам :(", "У найденного поста нет прямого URL изображения :("]:
await message.reply(str(e))
else:
await message.reply("Произошла ошибка при обращении к API :(")

31
main.py
View file

@ -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,7 +972,32 @@ 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):
await message.answer("Соси") 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("Соси")
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))
@ -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
View file

@ -0,0 +1 @@
# webapp package

443
webapp/api.py Normal file
View 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
View 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

View file

@ -0,0 +1,4 @@
FROM nginx:alpine
COPY . /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

File diff suppressed because it is too large Load diff

View 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
View 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
View 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');

View 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
View 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("Туннель закрыт.")

View file

@ -0,0 +1,2 @@
fastapi>=0.111.0
uvicorn[standard]>=0.29.0