Add uwu command description to README.md #25

Merged
q merged 2 commits from Furrex/bot_tg:main into main 2026-07-05 15:16:31 +00:00
24 changed files with 4129 additions and 246 deletions
Showing only changes of commit a8693b010f - Show all commits

5
.env
View file

@ -1,3 +1,6 @@
BOT_TOKEN=8488665890:AAH7FY5I2xzHhfOdxlzb11ewl2I-MMTH2-o
BOT_TOKEN=8156642467:AAEalekr-33lFS3tTTkXIEiaawfDzR6lBJc
TZ=Europe/Moscow
ODDS_API_KEY=c29bc26dee8aa9d95a5ce8d14ea63923
LLAMA_API_URL=https://mirror.porno4free.ru/zovos-ai/
LLAMA_FORCE_DISABLE_THINKING=0
LLAMA_LOG_THINKING=1

File diff suppressed because it is too large Load diff

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

@ -32,6 +32,8 @@ docker compose down
Переменные окружения: задайте `BOT_TOKEN` в `.env`.
Для ИИ-фич нужен `LLAMA_API_URL`.
По умолчанию проект настроен на `https://mirror.porno4free.ru/zovos-ai/`.
Если нужен лог reasoning/thinking модели, оставь `LLAMA_LOG_THINKING=1`; чтобы модель не резалась в режим без thinking, задай `LLAMA_FORCE_DISABLE_THINKING=0`.
Для ставок нужен `ODDS_API_KEY` (бесплатно на https://the-odds-api.com).
Контейнер хранит состояние в `./db`:
@ -39,6 +41,7 @@ docker compose down
- `chat_history.sqlite3`
- `polychaetsi_stats.json`
- `bets.sqlite3`
- `nude_history.json`
## Команды
@ -78,6 +81,18 @@ docker compose down
- В образ копируется весь проект, а runtime-данные исключены через `.dockerignore`.
- Если меняешь код или картинки, достаточно пересобрать: `docker compose up --build -d`.
## HTTPS / Let's Encrypt
- Фронтендовый `nginx` теперь слушает `80` и `443`, отдает ACME challenge и сам запускает `certbot` внутри контейнера.
- Для автоматической выдачи сертификата задай в `.env`:
- `APP_DOMAIN=bot.example.com`
- `APP_WWW_DOMAIN=www.bot.example.com` (опционально)
- `ACME_EMAIL=admin@example.com`
- Домен должен уже смотреть на сервер, а порты `80` и `443` должны быть доступны снаружи.
- Для безопасной первой проверки можно временно включить `CERTBOT_STAGING=1`, потом вернуть `0`.
- Сертификаты и webroot challenge хранятся в docker volumes `certbot_etc` и `certbot_www`, обновление запускается по кругу с интервалом `CERTBOT_RENEW_INTERVAL` (по умолчанию `12h`).
- Если нужен старый локальный маппинг портов, можно переопределить `WEBAPP_HTTP_PORT` и `WEBAPP_HTTPS_PORT`, но для реального Let's Encrypt внешний `80` должен оставаться доступным.
## Кратко о логике
- `/maket` подбирает размер текста, чтобы уместить его в область макета.

View file

@ -35,13 +35,25 @@ E621_API_KEY = os.getenv("E621_API_KEY", "")
ODDS_API_KEY = os.getenv("ODDS_API_KEY", "")
ODDS_API_BASE = "https://api.the-odds-api.com/v4"
ODDS_SPORTS = {
"cs": "esports_csgo",
"dota": "esports_dota2",
"football": "soccer_epl",
"epl": "soccer_epl",
"ucl": "soccer_uefa_champs_league",
"laliga": "soccer_spain_la_liga",
"bundesliga": "soccer_germany_bundesliga",
"seriea": "soccer_italy_serie_a",
"ligue1": "soccer_france_ligue_one",
"europa": "soccer_uefa_europa_league",
}
BET_MAX_AMOUNT = 3.0
BET_DB_PATH = "/db/bets.sqlite3"
ODDS_CACHE_TTL = 900
ODDS_CACHE_TTL = 86400 # 24 часа — экономим запросы (500/мес бесплатно)
# --- Nude ---
NUDE_HISTORY_PATH = Path(os.getenv("NUDE_HISTORY_PATH", "/db/nude_history.json"))
NUDE_POSTS_PER_PAGE = int(os.getenv("NUDE_POSTS_PER_PAGE", "100"))
NUDE_POSTS_POOL_TARGET = int(os.getenv("NUDE_POSTS_POOL_TARGET", "1200"))
NUDE_MAX_POSTS_TO_SCAN = int(os.getenv("NUDE_MAX_POSTS_TO_SCAN", "2000"))
NUDE_MIN_UNSEEN_POOL = int(os.getenv("NUDE_MIN_UNSEEN_POOL", "100"))
NUDE_HISTORY_MAX_SIZE = int(os.getenv("NUDE_HISTORY_MAX_SIZE", "5000"))
# --- Magnit API ---
MAGNIT_API_BASE_URL = os.getenv("MAGNIT_API_BASE_URL", "https://mirror.porno4free.ru/magnit")
@ -209,8 +221,8 @@ def get_hint_text(templates_str: str) -> str:
"• /talk [текст] — ИИ базарит на фене.\n"
"• /penis_casino [ставка] — казино на размер.\n"
"• /gadanie [тема] — гадание на фене с мемом.\n"
"• /matches [cs|dota|football] — матчи для ставок.\n"
"• /bet [спорт] [номер] [исход] [ставка] — поставить см.\n"
"• /matches [epl|ucl|laliga|bundesliga|seriea|ligue1|europa] — матчи для ставок.\n"
"• /bet [лига] [номер] [исход] [ставка] — поставить см.\n"
"• /mybets — мои активные ставки.\n"
"• /bet_history — история ставок за месяц + статистика.\n"
"• /sports_debug — диагностика API матчей.\n"

View file

@ -11,5 +11,52 @@ services:
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
LLAMA_API_URL: ${LLAMA_API_URL:-https://mirror.porno4free.ru/zovos-ai/}
LLAMA_FORCE_DISABLE_THINKING: ${LLAMA_FORCE_DISABLE_THINKING:-0}
LLAMA_LOG_THINKING: ${LLAMA_LOG_THINKING:-1}
init: true
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"
LLAMA_API_URL: ${LLAMA_API_URL:-https://mirror.porno4free.ru/zovos-ai/}
LLAMA_FORCE_DISABLE_THINKING: ${LLAMA_FORCE_DISABLE_THINKING:-0}
LLAMA_LOG_THINKING: ${LLAMA_LOG_THINKING:-1}
restart: unless-stopped
webapp-frontend:
build:
context: ./webapp/frontend
ports:
- "${WEBAPP_HTTP_PORT:-80}:80"
- "${WEBAPP_HTTPS_PORT:-443}:443"
environment:
APP_DOMAIN: ${APP_DOMAIN:-}
APP_WWW_DOMAIN: ${APP_WWW_DOMAIN:-}
ACME_EMAIL: ${ACME_EMAIL:-}
CERTBOT_STAGING: ${CERTBOT_STAGING:-0}
CERTBOT_RENEW_INTERVAL: ${CERTBOT_RENEW_INTERVAL:-12h}
volumes:
- certbot_etc:/etc/letsencrypt
- certbot_www:/var/www/certbot
depends_on:
- webapp-api
restart: unless-stopped
volumes:
certbot_etc:
certbot_www:

View file

@ -264,7 +264,15 @@ def format_matches(matches: list[dict], sport_alias: str, sport_label: str) -> s
def _get_sport_label(key: str) -> str:
labels = {"cs": "🎮 CS2", "dota": "🎮 Dota 2", "football": "⚽ Футбол"}
labels = {
"epl": "⚽ EPL",
"ucl": "⚽ Champions League",
"laliga": "⚽ La Liga",
"bundesliga": "⚽ Bundesliga",
"seriea": "⚽ Serie A",
"ligue1": "⚽ Ligue 1",
"europa": "⚽ Europa League",
}
return labels.get(key, key)
@ -436,15 +444,17 @@ def get_user_bet_history(user_id: int) -> str:
status = r["status"]
amount = float(r["amount"])
odds_val = float(r["odds"])
payout = float(r["payout"] or 0)
payout_raw = float(r["payout"] or 0)
total_bet += amount
if status == "won":
wins += 1
total_won += payout
# Для старых ставок payout может быть 0, пересчитываем
actual_payout = payout_raw if payout_raw > 0 else round(amount * odds_val, 1)
total_won += actual_payout
status_icon = ""
result_text = f"+{payout:.1f} см"
result_text = f"+{actual_payout:.1f} см"
elif status == "lost":
losses += 1
total_lost += amount

319
games/nude.py Normal file
View file

@ -0,0 +1,319 @@
import asyncio
import json
import logging
import os
import random
from pathlib import Path, PurePosixPath
from urllib.parse import urlparse
import aiohttp
from aiogram import types
import config
logger = logging.getLogger(__name__)
CAPTIONS = [
"Шо ты блять псюнчик решил свой подергать?",
"Решил подрочить сука?",
"гнойный писюн у тебя кнч, да",
]
LESAINT_POSTS_API = "https://lesaintdesseins.fr/wp-json/wp/v2/posts"
LESAINT_REFERER = "https://lesaintdesseins.fr/"
REQUEST_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
),
"Accept": "application/json,text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Referer": LESAINT_REFERER,
}
_NUDE_HISTORY_LOCK = asyncio.Lock()
def _resolve_int_setting(name: str, default: int) -> int:
value = getattr(config, name, None)
if value is None:
value = os.getenv(name, str(default))
try:
return int(value)
except (TypeError, ValueError):
logger.warning("Invalid %s=%r, using default %s", name, value, default)
return default
def _resolve_path_setting(name: str, default: str) -> Path:
value = getattr(config, name, None)
if value is None:
value = os.getenv(name, default)
if isinstance(value, Path):
return value
return Path(str(value))
NUDE_HISTORY_PATH = _resolve_path_setting("NUDE_HISTORY_PATH", "/db/nude_history.json")
NUDE_POSTS_PER_PAGE = _resolve_int_setting("NUDE_POSTS_PER_PAGE", 100)
NUDE_POSTS_POOL_TARGET = _resolve_int_setting("NUDE_POSTS_POOL_TARGET", 1200)
NUDE_MAX_POSTS_TO_SCAN = _resolve_int_setting("NUDE_MAX_POSTS_TO_SCAN", 2000)
NUDE_MIN_UNSEEN_POOL = _resolve_int_setting("NUDE_MIN_UNSEEN_POOL", 100)
NUDE_HISTORY_MAX_SIZE = _resolve_int_setting("NUDE_HISTORY_MAX_SIZE", 5000)
def _extract_photo_candidates(posts: list[dict]) -> list[dict]:
candidates = []
seen_urls = set()
for post in posts:
embedded = post.get("_embedded") or {}
media_items = embedded.get("wp:featuredmedia") or []
for media in media_items:
if media.get("media_type") != "image":
continue
media_details = media.get("media_details") or {}
sizes = media_details.get("sizes") or {}
image_url = (
(sizes.get("full") or {}).get("source_url")
or media.get("source_url")
)
if not image_url or image_url in seen_urls:
continue
seen_urls.add(image_url)
candidates.append(
{
"image_url": image_url,
"post_url": post.get("link", LESAINT_REFERER),
"title": (post.get("title") or {}).get("rendered", "").strip(),
}
)
break
return candidates
def _guess_filename(image_url: str) -> str:
parsed = urlparse(image_url)
filename = PurePosixPath(parsed.path).name or "nude.jpg"
if "." not in filename:
filename = f"{filename}.jpg"
return filename
def _load_nude_history() -> dict:
path = NUDE_HISTORY_PATH
if not path.exists():
return {"sent_images": []}
try:
with path.open("r", encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, json.JSONDecodeError):
logger.exception("Failed to load nude history")
return {"sent_images": []}
if not isinstance(data, dict):
return {"sent_images": []}
sent_images = data.get("sent_images")
if not isinstance(sent_images, list):
sent_images = []
cleaned = [item for item in sent_images if isinstance(item, str) and item.strip()]
return {"sent_images": cleaned[-NUDE_HISTORY_MAX_SIZE :]}
def _save_nude_history(data: dict) -> None:
path = NUDE_HISTORY_PATH
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_name(f"{path.name}.tmp")
with tmp_path.open("w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False, indent=2)
tmp_path.replace(path)
def _remember_sent_image(history: dict, image_url: str) -> None:
sent_images = history.get("sent_images")
if not isinstance(sent_images, list):
sent_images = []
sent_images = [item for item in sent_images if item != image_url]
sent_images.append(image_url)
history["sent_images"] = sent_images[-NUDE_HISTORY_MAX_SIZE :]
async def _fetch_posts_page(
session: aiohttp.ClientSession, page: int, per_page: int
) -> tuple[list[dict], int | None]:
params = {
"page": str(page),
"per_page": str(per_page),
"orderby": "date",
"order": "desc",
"_embed": "wp:featuredmedia",
}
async with session.get(LESAINT_POSTS_API, params=params, headers=REQUEST_HEADERS) as resp:
if resp.status == 400 and page > 1:
body = await resp.text()
if "rest_post_invalid_page_number" in body:
return [], None
raise RuntimeError(f"API returned 400: {body[:300]}")
if resp.status != 200:
body = await resp.text()
raise RuntimeError(f"API returned {resp.status}: {body[:300]}")
total_pages_header = resp.headers.get("X-WP-TotalPages")
total_pages = (
int(total_pages_header)
if total_pages_header and total_pages_header.isdigit()
else None
)
posts = await resp.json(content_type=None)
if not isinstance(posts, list):
raise RuntimeError("Unexpected API response format")
return posts, total_pages
async def _fetch_latest_photo_candidates(
session: aiohttp.ClientSession, sent_urls: set[str]
) -> list[dict]:
per_page = max(1, min(NUDE_POSTS_PER_PAGE, 100))
max_scan = max(NUDE_POSTS_POOL_TARGET, NUDE_MAX_POSTS_TO_SCAN)
max_pages = max(1, (max_scan + per_page - 1) // per_page)
candidates = []
seen_urls = set()
unseen_count = 0
total_pages = None
for page in range(1, max_pages + 1):
if total_pages is not None and page > total_pages:
break
posts, reported_total_pages = await _fetch_posts_page(session, page, per_page)
if reported_total_pages is not None:
total_pages = reported_total_pages
if not posts:
break
for candidate in _extract_photo_candidates(posts):
image_url = candidate["image_url"]
if image_url in seen_urls:
continue
seen_urls.add(image_url)
candidates.append(candidate)
if image_url not in sent_urls:
unseen_count += 1
if len(candidates) >= NUDE_POSTS_POOL_TARGET and unseen_count >= NUDE_MIN_UNSEEN_POOL:
break
if len(candidates) >= max_scan:
break
return candidates
def _pick_unsent_candidate(candidates: list[dict], sent_urls: set[str]) -> dict | None:
unseen = [candidate for candidate in candidates if candidate["image_url"] not in sent_urls]
if not unseen:
return None
return random.choice(unseen)
async def _download_photo(
session: aiohttp.ClientSession, image_url: str
) -> tuple[bytes | None, str]:
async with session.get(image_url, headers=REQUEST_HEADERS) as resp:
if resp.status != 200:
logger.warning("Image download failed for %s: %s", image_url, resp.status)
return None, _guess_filename(image_url)
content_type = resp.headers.get("Content-Type", "")
if "image" not in content_type.lower():
logger.warning(
"Unexpected content type for %s: %s", image_url, content_type
)
return None, _guess_filename(image_url)
return await resp.read(), _guess_filename(image_url)
async def handle_nude_cmd(message: types.Message):
args = message.text.split(maxsplit=1)
if len(args) > 1 and args[1].strip() == "-h":
help_msg = (
"🔥 <b>Справка по команде /nude:</b>\n\n"
"Берет случайную фотку из последних постов сайта le saint des seins.\n"
"• Источник: <code>https://lesaintdesseins.fr/</code>\n"
"• Пагинацией тянет 1000+ последних постов через WordPress REST API\n"
"• Хранит историю отправок и старается не повторять фотки\n"
"• Выбирает случайную картинку и отправляет ее прямо в Telegram\n\n"
"<i>Пример:</i> <code>/nude</code>"
)
await message.reply(help_msg, parse_mode="HTML")
return
timeout = aiohttp.ClientTimeout(total=60)
history_snapshot = _load_nude_history()
sent_urls_snapshot = set(history_snapshot["sent_images"])
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
candidates = await _fetch_latest_photo_candidates(session, sent_urls_snapshot)
if not candidates:
await message.reply("Не удалось найти свежие фотки :(")
return
async with _NUDE_HISTORY_LOCK:
history = _load_nude_history()
sent_urls = set(history["sent_images"])
selected = _pick_unsent_candidate(candidates, sent_urls)
if selected is None:
logger.info("Nude history exhausted current pool, resetting history")
history = {"sent_images": []}
selected = random.choice(candidates)
_remember_sent_image(history, selected["image_url"])
_save_nude_history(history)
caption = random.choice(CAPTIONS)
image_bytes, filename = await _download_photo(session, selected["image_url"])
if image_bytes:
photo = types.BufferedInputFile(image_bytes, filename=filename)
await message.answer_photo(photo=photo, caption=caption)
return
try:
photo = types.URLInputFile(selected["image_url"])
await message.answer_photo(photo=photo, caption=caption)
except Exception:
logger.exception("Failed to send photo by URL")
await message.answer(
f"{caption}\n{selected['image_url']}\n{selected['post_url']}"
)
except Exception:
logger.exception("Error in nude command")
await message.reply("Произошла ошибка при получении фото :(")

View file

@ -75,8 +75,9 @@ async def handle_uwu_cmd(message: types.Message):
if len(args) > 1:
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"
params = {
"tags": tags,
@ -91,38 +92,118 @@ 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)"
}
try:
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}")
await message.reply("Не удалось получить картинку от сервера :(")
return
data = await resp.json()
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", [])
if not posts:
await message.reply("Ничего не найдено по этим тегам :(")
return
posts = data.get("posts", [])
if not posts:
raise Exception("Ничего не найдено по этим тегам :(")
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")
ext = post.get("file", {}).get("ext", "png")
if not file_url:
await message.reply("У найденного поста нет прямого URL изображения :(")
return
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),
})
input_file = types.URLInputFile(file_url)
caption = random.choice(CAPTIONS)
if ext in ("png", "jpg", "jpeg", "webp"):
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"):
# Для анимаций: отправляем caption отдельным сообщением,
# а саму гифку/видео через URL напрямую — так Telegram
# корректно показывает её как анимацию, а не как файл
await message.answer(caption)
await message.answer_animation(animation=file_url)
elif ext in ("png", "jpg", "jpeg", "webp"):
input_file = types.URLInputFile(file_url)
await message.answer_photo(photo=input_file, caption=caption)
elif ext == "gif":
await message.answer_animation(animation=input_file, caption=caption)
elif ext in ("webm", "mp4"):
await message.answer_video(video=input_file, caption=caption)
else:
input_file = types.URLInputFile(file_url)
await message.answer_document(document=input_file, caption=caption)
except Exception as e:
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 :(")

110
main.py
View file

@ -14,15 +14,17 @@ from aiogram.filters import Command, CommandStart
from aiogram.enums import ParseMode
from aiogram.types import (
BufferedInputFile, Message, BotCommand, FSInputFile,
BotCommandScopeDefault, BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats
BotCommandScopeDefault, BotCommandScopeAllPrivateChats, BotCommandScopeAllGroupChats,
WebAppInfo, InlineKeyboardMarkup, InlineKeyboardButton
)
from aiogram.client.default import DefaultBotProperties
# Локальные импорты
from AI.talk_handler import handle_talk, generate_autoreply, push_message
from AI.talk_handler import handle_chat_message, handle_photo_message, handle_talk, push_message
from games.casino import play_casino
from games.fortune import generate_fortune
from games.uwu import handle_uwu_cmd
from games.nude import handle_nude_cmd
from games.betting import (
clear_user_sport_context,
debug_sports,
@ -46,6 +48,28 @@ _polychaetsi_lock = threading.Lock()
_polychaetsi_word_re = re.compile(r"[а-яё]+", re.IGNORECASE)
_no_lesson_re = re.compile(r"(нету\s+пары|нет\s+пары|пары\s+нету|пары\s+нет)", re.IGNORECASE)
def _is_supported_image_message(message: Message) -> bool:
return bool(
message.photo
or (
message.document
and (message.document.mime_type or "").startswith("image/")
)
)
async def handle_unmatched_message_debug(message: Message):
logger.warning(
"Unhandled message reached fallback. content_type=%s chat_id=%s has_text=%s has_photo=%s has_document=%s caption=%s",
getattr(message, "content_type", None),
message.chat.id if message.chat else None,
bool(message.text),
bool(message.photo),
bool(message.document),
bool(message.caption),
)
_weekday_names = {
0: "Понедельник",
1: "Вторник",
@ -948,7 +972,32 @@ async def handle_help(message: Message):
await message.answer(config.get_hint_text(templates_list_text()), parse_mode=None)
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):
now = datetime.now(ZoneInfo(config.DEFAULT_TZ))
@ -1133,11 +1182,9 @@ async def handle_gen_mem(message: Message, bot: Bot):
return
await message.reply_photo(BufferedInputFile(output.getvalue(), filename="meme.jpg"))
_last_autoreply_ts = 0
_autoreply_disabled_chats: set[int] = set()
async def handle_keywords(message: Message):
global _last_autoreply_ts
if not message.text or message.text.startswith('/'):
return
text_lower = message.text.lower()
@ -1173,25 +1220,7 @@ async def handle_keywords(message: Message):
if chat_id in _autoreply_disabled_chats:
return
import time as _time
now = _time.time()
triggered = any(trigger in text_lower for trigger in config.AUTOREPLY_TRIGGERS)
should_reply = triggered or (
random.random() < config.AUTOREPLY_CHANCE
and (now - _last_autoreply_ts) > config.AUTOREPLY_COOLDOWN
)
if should_reply:
try:
await message.bot.send_chat_action(chat_id=chat_id, action="typing")
response = await generate_autoreply(chat_id, message.text, user_name)
if response:
await asyncio.to_thread(push_message, chat_id, "assistant", "бот", response)
await message.reply(response, parse_mode=None)
_last_autoreply_ts = now
except Exception:
logger.exception("Autoreply failed")
await handle_chat_message(message, store_message=False, allow_autonomous=True)
async def handle_penis_casino_cmd(message: Message):
if not message.from_user:
@ -1234,13 +1263,33 @@ async def handle_matches_cmd(message: Message):
raw_text = message.text or ""
parts = raw_text.split(maxsplit=1)
sport_alias = parts[1].strip().lower() if len(parts) > 1 else None
if sport_alias and sport_alias not in config.ODDS_SPORTS:
# Если лига не указана — показать список
if not sport_alias:
labels = {
"epl": "⚽ EPL (Англия)",
"ucl": "⚽ Champions League",
"laliga": "⚽ La Liga (Испания)",
"bundesliga": "⚽ Bundesliga (Германия)",
"seriea": "⚽ Serie A (Италия)",
"ligue1": "⚽ Ligue 1 (Франция)",
"europa": "⚽ Europa League",
}
lines = ["🏆 Доступные лиги для ставок:\n"]
for alias, name in labels.items():
lines.append(f" {name} → /matches {alias}")
lines.append("\nПример: /matches epl")
await message.reply("\n".join(lines), parse_mode=None)
return
if sport_alias not in config.ODDS_SPORTS:
if message.from_user:
clear_user_sport_context(message.from_user.id)
await message.reply("Неизвестный спорт. Доступно: cs / dota / football", parse_mode=None)
available = " / ".join(config.ODDS_SPORTS.keys())
await message.reply(f"Неизвестная лига. Доступно: {available}", parse_mode=None)
return
if message.from_user:
if sport_alias and sport_alias in config.ODDS_SPORTS:
if sport_alias in config.ODDS_SPORTS:
remember_user_sport_context(message.from_user.id, sport_alias)
else:
clear_user_sport_context(message.from_user.id)
@ -1280,7 +1329,7 @@ async def handle_bet_cmd(message: Message):
sport_alias = get_user_sport_context(message.from_user.id)
if not sport_alias:
await message.reply(
"Сначала открой /matches cs, /matches dota или /matches football.\n"
"Сначала открой /matches с нужной лигой (epl, ucl, laliga...).\n"
"Либо пиши полный формат: /bet [спорт] [номер] [исход] [ставка]",
parse_mode=None,
)
@ -1374,6 +1423,7 @@ def main():
# Регистрация хендлеров
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_schedule_cmd, Command("schedule"))
dp.message.register(handle_break_cmd, Command("break"))
@ -1397,7 +1447,10 @@ def main():
dp.message.register(handle_sports_debug_cmd, Command("sports_debug"))
dp.message.register(handle_gen_mem, Command("gen_mem"))
dp.message.register(handle_uwu_cmd, Command("uwu"))
dp.message.register(handle_nude_cmd, Command("nude"))
dp.message.register(handle_photo_message, _is_supported_image_message)
dp.message.register(handle_keywords, F.text)
dp.message.register(handle_unmatched_message_debug)
async def on_startup(bot: Bot):
try:
@ -1426,6 +1479,7 @@ def main():
BotCommand(command="sports_debug", description="диагностика API матчей"),
BotCommand(command="svodka", description="СВО: итоги"),
BotCommand(command="uwu", description="Случайная картинка с e621"),
BotCommand(command="nude", description="Голые женщины из открытых источников"),
]
scopes = (
BotCommandScopeDefault(),

1
webapp/__init__.py Normal file
View file

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

465
webapp/api.py Normal file
View file

@ -0,0 +1,465 @@
"""
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 asyncio.to_thread(_get_user_bets_raw, user_id)
return {"text": result}
@app.get("/api/bet_history")
async def get_bet_history(user: dict = Depends(get_current_user)):
"""История ставок за месяц + статистика."""
user_id = user["user_id"]
result = await asyncio.to_thread(_get_user_bet_history_raw, user_id)
return {"text": result}
# --- Казино ---
@app.post("/api/casino")
async def play_casino_api(
req: CasinoRequest,
user: dict = Depends(get_current_user),
) -> CasinoResult:
"""Играть в казино (слоты)."""
import random
user_id = user["user_id"]
current = await asyncio.to_thread(get_user_length, user_id)
if current is None:
raise HTTPException(status_code=400, detail="Сначала крутни /penis")
if current <= 0:
raise HTTPException(status_code=400, detail="Баланс <= 0")
if req.bet > config.CASINO_MAX_BET:
raise HTTPException(status_code=400, detail=f"Макс ставка: {config.CASINO_MAX_BET} см")
if req.bet <= 0:
raise HTTPException(status_code=400, detail="Ставка > 0")
if req.bet > current:
raise HTTPException(status_code=400, detail=f"У тебя {current:.1f} см")
reels, match_count = await asyncio.to_thread(spin_slots)
if match_count >= 2:
min_mult, max_mult = MULTIPLIERS[match_count]
multiplier = round(random.uniform(min_mult, max_mult), 1)
winnings = round(req.bet * multiplier, 1)
new_length = await asyncio.to_thread(update_user_length, user_id, winnings)
return CasinoResult(
reels=reels, matches=match_count, multiplier=multiplier,
delta=winnings, new_length=new_length or current, won=True,
)
else:
new_length = await asyncio.to_thread(update_user_length, user_id, -req.bet)
return CasinoResult(
reels=reels, matches=0, multiplier=0,
delta=-req.bet, new_length=new_length or current, won=False,
)
# --- Penis Game ---
@app.post("/api/penis")
async def play_penis_api(user: dict = Depends(get_current_user)) -> PenisResult:
"""Крутить penis (раз в 24 часа)."""
# Импортируем из main.py
from main import play_penis, format_penis_user_name
user_id = user["user_id"]
display_name = user["username"] or user["first_name"] or f"user_{user_id}"
success, message = await asyncio.to_thread(play_penis, user_id, display_name)
length = await asyncio.to_thread(get_user_length, user_id)
return PenisResult(success=success, message=message, length=length)
# --- Топ ---
@app.get("/api/top")
async def get_top():
"""Лидерборд по размеру."""
from main import build_penis_top
result = await asyncio.to_thread(build_penis_top)
return {"text": result}
# --- Расписание ---
@app.get("/api/schedule")
async def get_schedule(day: int | None = None) -> list[SchedulePair]:
"""Расписание пар. day = 0-6 (пн-вс), None = сегодня."""
from datetime import datetime
from zoneinfo import ZoneInfo
if day is None:
day = datetime.now(ZoneInfo(config.DEFAULT_TZ)).weekday()
# Определяем неделю (верхняя/нижняя)
now = datetime.now(ZoneInfo(config.DEFAULT_TZ))
iso_week = now.isocalendar()[1]
if config.UPPER_WEEK_IS_ODD:
week_type = "upper" if iso_week % 2 == 1 else "lower"
else:
week_type = "upper" if iso_week % 2 == 0 else "lower"
lessons = config.LESSON_SCHEDULE.get(week_type, {}).get(day, [])
result = []
for lesson in lessons:
pair_num = lesson.get("pair")
time_start = time_end = None
if pair_num and pair_num in config.PAIR_TIMES:
start, end = config.PAIR_TIMES[pair_num]
time_start = f"{start.hour:02d}:{start.minute:02d}"
time_end = f"{end.hour:02d}:{end.minute:02d}"
result.append(SchedulePair(
pair=pair_num,
title=lesson.get("title", ""),
time_start=time_start,
time_end=time_end,
))
return result
# --- UwU Арты ---
@app.get("/api/uwu")
async def get_uwu_api(
tags: str = "rating:safe score:>500 -animated",
user: dict = Depends(get_current_user),
):
"""Отправить случайный uwu арт по тегам."""
try:
tags += " order:random score:>300"
post = await fetch_uwu_post(tags)
return {"response": post}
except Exception as e:
logger.exception("UwU API failed")
raise HTTPException(status_code=500, detail=str(e))
# --- FurTok Лента ---
@app.get("/api/furtok")
async def get_furtok_feed_api(
safe: bool = True,
page: int = 1,
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 (
BOT_MEMORY_NAME,
EMPTY_RESPONSE_TEXT,
SYSTEM_PROMPT,
_generate_response,
_normalize_reply,
push_message,
)
user_id = user["user_id"]
user_name = user["first_name"] or "кент"
# Используем user_id как chat_id для личных сообщений в webapp
chat_id = user_id
try:
await asyncio.to_thread(push_message, chat_id, "user", user_name, req.text, user_id=user_id)
response = await _generate_response(
chat_id,
system_prompt=SYSTEM_PROMPT,
user_id=user_id,
max_tokens=220,
temperature=0.8,
top_p=0.9,
)
normalized_response = _normalize_reply(response)
if not normalized_response:
normalized_response = EMPTY_RESPONSE_TEXT
await asyncio.to_thread(
push_message,
chat_id,
"assistant",
BOT_MEMORY_NAME,
normalized_response,
user_id=user_id,
)
return {"response": normalized_response}
except Exception:
logger.exception("Talk API failed")
raise HTTPException(status_code=500, detail="LLM error")
# ──────────────────── Static files (для локального теста) ────────────────────
_frontend_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "frontend")
if os.path.isdir(_frontend_dir):
app.mount("/", StaticFiles(directory=_frontend_dir, html=True), name="frontend")
# ──────────────────── Entry point ────────────────────
if __name__ == "__main__":
import uvicorn
port = int(os.getenv("API_PORT", "8080"))
uvicorn.run(app, host="0.0.0.0", port=port)

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,18 @@
FROM nginx:alpine
RUN apk add --no-cache certbot openssl
COPY index.html /usr/share/nginx/html/index.html
COPY css /usr/share/nginx/html/css
COPY js /usr/share/nginx/html/js
COPY nginx.conf /etc/nginx/default.conf.template
COPY docker-entrypoint.d/01-render-nginx-conf.sh /docker-entrypoint.d/01-render-nginx-conf.sh
COPY docker-entrypoint.d/05-init-self-signed.sh /docker-entrypoint.d/05-init-self-signed.sh
COPY docker-entrypoint.d/10-certbot-loop.sh /docker-entrypoint.d/10-certbot-loop.sh
RUN chmod +x /docker-entrypoint.d/01-render-nginx-conf.sh \
/docker-entrypoint.d/05-init-self-signed.sh \
/docker-entrypoint.d/10-certbot-loop.sh \
&& rm -f /etc/nginx/conf.d/default.conf
EXPOSE 80 443

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,20 @@
#!/bin/sh
set -eu
APP_DOMAIN="${APP_DOMAIN:-}"
APP_WWW_DOMAIN="${APP_WWW_DOMAIN:-}"
PRIMARY_DOMAIN="${APP_DOMAIN:-localhost}"
SERVER_NAMES="_"
if [ -n "$APP_DOMAIN" ]; then
SERVER_NAMES="$APP_DOMAIN"
if [ -n "$APP_WWW_DOMAIN" ]; then
SERVER_NAMES="$SERVER_NAMES $APP_WWW_DOMAIN"
fi
fi
sed \
-e "s|__SERVER_NAMES__|$SERVER_NAMES|g" \
-e "s|__PRIMARY_DOMAIN__|$PRIMARY_DOMAIN|g" \
/etc/nginx/default.conf.template > /etc/nginx/conf.d/default.conf

View file

@ -0,0 +1,16 @@
#!/bin/sh
set -eu
PRIMARY_DOMAIN="${APP_DOMAIN:-localhost}"
CERT_DIR="/etc/letsencrypt/live/$PRIMARY_DOMAIN"
FULLCHAIN="$CERT_DIR/fullchain.pem"
PRIVKEY="$CERT_DIR/privkey.pem"
if [ ! -s "$FULLCHAIN" ] || [ ! -s "$PRIVKEY" ]; then
echo "No TLS certificate for $PRIMARY_DOMAIN. Generating temporary self-signed cert."
mkdir -p "$CERT_DIR"
openssl req -x509 -nodes -newkey rsa:2048 -days 1 \
-keyout "$PRIVKEY" \
-out "$FULLCHAIN" \
-subj "/CN=$PRIMARY_DOMAIN" >/dev/null 2>&1
fi

View file

@ -0,0 +1,47 @@
#!/bin/sh
set -eu
APP_DOMAIN="${APP_DOMAIN:-}"
APP_WWW_DOMAIN="${APP_WWW_DOMAIN:-}"
ACME_EMAIL="${ACME_EMAIL:-}"
CERTBOT_STAGING="${CERTBOT_STAGING:-0}"
CERTBOT_RENEW_INTERVAL="${CERTBOT_RENEW_INTERVAL:-12h}"
if [ -z "$APP_DOMAIN" ] || [ -z "$ACME_EMAIL" ]; then
echo "Skipping certbot: APP_DOMAIN or ACME_EMAIL is empty."
exit 0
fi
(
sleep 20
if [ ! -f "/etc/letsencrypt/renewal/$APP_DOMAIN.conf" ]; then
rm -rf "/etc/letsencrypt/live/$APP_DOMAIN" "/etc/letsencrypt/archive/$APP_DOMAIN"
fi
while :; do
DOMAINS="-d $APP_DOMAIN"
if [ -n "$APP_WWW_DOMAIN" ]; then
DOMAINS="$DOMAINS -d $APP_WWW_DOMAIN"
fi
STAGING_FLAG=""
if [ "$CERTBOT_STAGING" = "1" ]; then
STAGING_FLAG="--staging"
fi
# shellcheck disable=SC2086
certbot certonly \
--webroot \
-w /var/www/certbot \
--agree-tos \
--non-interactive \
--email "$ACME_EMAIL" \
--keep-until-expiring \
$STAGING_FLAG \
$DOMAINS || true
nginx -s reload || true
sleep "$CERTBOT_RENEW_INTERVAL"
done
) &

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,53 @@
server {
listen 80;
listen [::]:80;
server_name __SERVER_NAMES__;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name __SERVER_NAMES__;
ssl_certificate /etc/letsencrypt/live/__PRIMARY_DOMAIN__/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/__PRIMARY_DOMAIN__/privkey.pem;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
root /usr/share/nginx/html;
index index.html;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location /api/ {
proxy_pass http://webapp-api:8080;
proxy_http_version 1.1;
proxy_set_header Connection "";
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-Forwarded-Proto $scheme;
proxy_set_header X-Telegram-Init-Data $http_x_telegram_init_data;
}
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(css|js|png|jpg|jpeg|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